用 QEMU/GDB 调试 xv6 / Debugging xv6 With QEMU And GDB
1. 这篇文章解决什么问题
读 xv6 源码会产生很多心智模型。
例如系统调用先到 usertrap。
例如 fork 会复制 trapframe。
例如 exec 会替换用户页表。
例如调度器只选择 RUNNABLE 进程。
这些模型如果只停留在脑中,很容易错。
GDB 的价值不是炫技。
GDB 的价值是验证。
验证表示用可观察事实检查自己的理解。
本篇讲如何用 QEMU 和 GDB 调试 xv6。
我们会看断点。
会看调用栈。
会看 struct proc。
会看 trapframe。
会看页表。
会跟踪系统调用路径。
本篇基于本地可读的 E:\Github\xv6-riscv。
如果你的源码是标准 MIT xv6-riscv,路径和函数名应基本一致。
1.1 本文基础词小注释
调试 debugging:调试不是只在程序崩了以后才做。对学习 xv6 来说,调试是“让源码里的路径真的跑给你看”,用事实校准理解。
断点 breakpoint:让程序运行到某一行或某个函数时暂停。比如在 usertrap 下断点,就可以观察一次系统调用或中断进入内核时的状态。
单步 step:让程序一次执行一小步。它适合观察控制流如何从一个函数走到另一个函数,但在汇编或中断路径里要小心,步子太细会迷路。
run
|
v
hit breakpoint
|
v
inspect registers / memory / structs
|
v
continue or step寄存器 register:CPU 内部很小但很快的存储位置。函数参数、返回地址、栈指针、临时值都会放在寄存器里。看 trap 和 context switch 时,寄存器尤其重要。
调用栈 backtrace:当前函数是被谁调用来的,再往上是谁调用的,这条链就是调用栈。GDB 的 bt 可以帮助你从“当前卡在哪里”回溯到“为什么会走到这里”。
GDB stub:QEMU 提供的调试接口。它让外部 GDB 能连接进来控制虚拟机里的 xv6:暂停、继续、读寄存器、读内存。
客体机 guest:QEMU 里运行的 xv6。宿主机 host 是你的 Windows/Linux/macOS 环境。调试时要分清命令在哪个世界执行。
2. 最简单的心智模型
QEMU 负责运行虚拟机器。
GDB 负责连接 QEMU 的调试端口。
QEMU 可以停在第一条指令。
GDB 可以读取寄存器和内存。
GDB 可以设置断点。
GDB 可以单步执行。
GDB 可以查看 C 结构体。
图:
+-------------------+ TCP gdb stub +-------------------+
| QEMU xv6 machine | <------------------------> | riscv64 gdb |
| CPU/memory/devices| | break/step/print |
+-------------------+ +-------------------+调试时要分清两个世界。
一个是宿主机世界。
一个是 xv6 客体机世界。
GDB 命令在宿主机执行。
被调试的代码在 QEMU 中运行。
3. 本篇涉及的 xv6 源码文件
Makefile 提供 qemu-gdb 目标。
.gdbinit.tmpl-riscv 生成 GDB 初始化文件。
kernel/kernel 是带符号的内核 ELF。
kernel/trap.c 包含 usertrap、kerneltrap、prepare_return。
kernel/trampoline.S 包含 uservec、userret。
kernel/syscall.c 包含系统调用分派。
kernel/sysproc.c 包含进程类系统调用入口。
kernel/sysfile.c 包含文件类系统调用入口。
kernel/proc.h 定义 struct proc 和 struct trapframe。
kernel/proc.c 定义 scheduler、sched、kfork、kwait、kexit。
kernel/vm.c 定义页表创建、映射、复制、walk、copyin/copyout。
kernel/memlayout.h 定义 TRAMPOLINE、TRAPFRAME 等地址。
kernel/riscv.h 定义 CSR 读写函数和页表位。
user/init.c、user/sh.c、user/echo.c 可作为用户态触发点。
4. 准备调试环境
xv6 Makefile 通常提供:
make qemu
make qemu-gdbmake qemu 直接启动系统。
make qemu-gdb 启动系统并等待 GDB 连接。
Makefile 中的 QEMU 选项包含 -S。
-S 表示 CPU 启动后先暂停。
Makefile 中的 QEMU 选项还包含 gdb stub。
stub 是调试代理接口。
它让外部 GDB 控制虚拟 CPU。
典型流程:
terminal 1:
make qemu-gdb
terminal 2:
riscv64-unknown-elf-gdb kernel/kernel不同环境的 GDB 名称可能不同。
有的叫 riscv64-linux-gnu-gdb。
有的叫 gdb-multiarch。
关键是它要能理解 RISC-V ELF。
5. 为什么需要带符号的内核
符号是地址到名字的映射。
例如地址 0x80001abc 对应 usertrap。
没有符号,GDB 只能显示地址。
有符号,GDB 可以显示函数名和源码行。
xv6 Makefile 的 CFLAGS 通常包含:
-ggdb
-gdwarf-2
-fno-omit-frame-pointer-ggdb 生成 GDB 调试信息。
-gdwarf-2 指定调试信息格式。
-fno-omit-frame-pointer 保留帧指针。
帧指针有助于查看调用栈。
如果 GDB 看不到源码,先确认加载的是 kernel/kernel。
不要加载 fs.img。
不要加载宿主机上的用户程序 ELF 当内核符号。
6. 第一次连接后的基本命令
连接后可以先用这些命令:
(gdb) target remote localhost:26000
(gdb) b main
(gdb) c
(gdb) bt
(gdb) info registers端口号以 Makefile 输出为准。
b 是 break。
breakpoint 是断点。
断点表示执行到某个位置时暂停。
c 是 continue。
continue 表示继续运行。
bt 是 backtrace。
backtrace 表示调用栈。
info registers 查看寄存器。
RISC-V 常见寄存器包括 ra、sp、a0、a7、sepc。
ra 是返回地址。
sp 是栈指针。
a0 常放第一个参数和返回值。
a7 常放系统调用号。
sepc 是 trap 返回时的程序计数器。
7. 调试时先建立观察问题
不要一上来乱单步。
先问一个可验证的问题。
例如:write 系统调用号是不是放在 a7?
例如:usertrap 是否会把 epc 加 4?
例如:exec 是否会改变 p->pagetable?
例如:fork 后父子 trapframe 是否相似?
例如:wait 回收时子进程状态是否为 ZOMBIE?
好的调试是实验。
实验需要假设。
实验需要观察点。
实验需要预期结果。
实验需要对反例保持敏感。
8. 断在系统调用路径
要观察系统调用,可以设置这些断点:
(gdb) b usertrap
(gdb) b syscall
(gdb) b sys_write
(gdb) b sys_fork
(gdb) b sys_exec
(gdb) b sys_wait然后在 xv6 shell 中运行:
$ echo hello你可能会先停在许多系统调用上。
因为 shell 自己也会调用 write 打印提示符。
它会调用 read 读取输入。
它会调用 fork 创建子进程。
它会调用 wait 等待子进程。
调试时噪声是正常的。
可以使用条件断点降低噪声。
例如只在某个系统调用号时停下。
9. 查看当前进程
在内核中,可以调用 myproc()。
GDB 里可以打印:
(gdb) p myproc()
(gdb) p *myproc()struct proc 是进程控制块。
进程控制块是内核保存进程状态的数据结构。
它包含 pid。
它包含 state。
它包含 pagetable。
它包含 trapframe。
它包含 kernel stack。
它包含 open files。
它包含 cwd。
它包含 name。
更聚焦的命令:
(gdb) p myproc()->pid
(gdb) p myproc()->name
(gdb) p myproc()->state
(gdb) p myproc()->sz
(gdb) p/x myproc()->pagetablep/x 用十六进制显示。
地址、PTE、CSR 通常适合十六进制。
10. 查看 trapframe
trapframe 保存用户态寄存器。
系统调用参数也在其中。
可用命令:
(gdb) p myproc()->trapframe
(gdb) p *myproc()->trapframe
(gdb) p/x myproc()->trapframe->epc
(gdb) p/x myproc()->trapframe->a0
(gdb) p/x myproc()->trapframe->a1
(gdb) p/x myproc()->trapframe->a2
(gdb) p/x myproc()->trapframe->a7对 write(1, buf, n) 来说:
a0 应该接近 1。
a1 应该是用户虚拟地址。
a2 应该是长度。
a7 应该是 SYS_write。
进入 syscall() 后:
p->trapframe->a0 = syscalls[num]()所以系统调用返回值会覆盖 a0。
这解释了为什么 a0 既是参数又是返回值。
它们发生在不同阶段。
11. 验证 epc += 4
RISC-V 的 ecall 指令长度是 4 字节。
当用户程序执行 ecall 进入内核时,sepc 指向 ecall 本身。
如果返回时还从 ecall 执行,就会重复陷入内核。
所以 usertrap 中有:
p->trapframe->epc += 4调试方法:
(gdb) b usertrap
(gdb) c
(gdb) p/x r_sepc()
(gdb) n
(gdb) p/x myproc()->trapframe->epc预期:保存的 epc 比原始 ecall 地址多 4。
如果你忘了这点,系统调用会像卡住一样不断重复。
这是一个非常好的心智模型验证点。
12. 查看调用栈
在 sys_write 断下时,执行:
(gdb) bt你可能看到类似路径:
sys_write
syscall
usertrap调用栈不会直接显示用户态 write stub。
因为已经跨过 trap 边界。
用户态寄存器保存在 trapframe。
内核态调用栈在内核栈上。
这说明调试跨特权级路径时,要同时看两种状态。
一种是当前内核调用栈。
一种是 trapframe 中保存的用户上下文。
13. 查看页表的基本思路
页表把虚拟地址映射到物理地址。
PTE 是页表项。
PTE 包含物理页号和权限位。
xv6 RISC-V 使用 Sv39 页表。
Sv39 是 RISC-V 64 位的一种分页模式。
它使用三级页表。
每级索引 9 位。
页内偏移 12 位。
结构:
virtual address
|
+-- VPN[2] -> level 2 page table
+-- VPN[1] -> level 1 page table
+-- VPN[0] -> level 0 page table
+-- offset -> byte inside pageGDB 可以打印 myproc()->pagetable。
但光看地址还不够。
要理解映射,最好调用或临时使用页表打印函数。
MIT xv6 实验中常让你实现 vmprint。
如果源码中没有现成 vmprint,可以自己加在学习分支中。
14. 查看 satp
satp 是 RISC-V 保存当前页表根的 CSR。
CSR 是控制和状态寄存器。
内核运行时通常使用内核页表。
用户态运行时使用用户页表。
trap 入口会切换页表。
返回用户态前也会切换页表。
在 GDB 中查看寄存器:
(gdb) info registers satp也可以在源码中关注:
MAKE_SATP(p->pagetable)
w_satp(...)trampoline.S 负责关键切换。
为什么 trampoline 要特殊映射?
因为切换页表的那段代码必须在切换前后都可访问。
所以 xv6 把 trampoline 映射到用户页表和内核页表的固定高地址。
15. 跟踪 fork
断点:
(gdb) b sys_fork
(gdb) b kfork
(gdb) b allocproc运行:
$ echo hello观察点:
父进程 pid 是多少。
新进程 pid 是多少。
np->trapframe 是否从父进程复制。
子进程的 trapframe->a0 是否被改成 0。
父进程的 fork 返回值是否是子 pid。
这是 fork 的经典语义:
parent: fork returns child pid
child: fork returns 0GDB 验证命令:
(gdb) p p->pid
(gdb) p np->pid
(gdb) p np->trapframe->a0如果在局部变量作用域外,变量名可能不可见。
这时在源码行附近断点更方便。
16. 跟踪 exec
断点:
(gdb) b sys_exec
(gdb) b kexec运行:
$ echo hello观察点:
sys_exec 从用户地址取 path。
kexec 打开 ELF 文件。
kexec 创建新页表。
kexec 加载程序段。
kexec 准备用户栈。
kexec 设置 trapframe->epc。
kexec 设置 trapframe->sp。
kexec 释放旧页表。
重点不是背每一行。
重点是确认 exec 替换了用户空间。
进程 pid 通常不变。
进程的程序内容变了。
这就是“同一个进程,新的程序”。
17. 跟踪 wait 和 exit
断点:
(gdb) b sys_wait
(gdb) b kwait
(gdb) b sys_exit
(gdb) b kexit观察点:
子进程退出时状态变成 ZOMBIE。
父进程 wait 扫描子进程。
父进程发现 ZOMBIE。
父进程读取退出状态。
父进程调用 freeproc。
可打印:
(gdb) p myproc()->pid
(gdb) p myproc()->state
(gdb) p myproc()->xstateZOMBIE 不表示进程还在运行。
它表示进程已经退出,但记录尚未被父进程回收。
18. 跟踪调度器
调度器断点容易很吵。
可以从单 CPU 开始。
启动时设置:
make CPUS=1 qemu-gdb单 CPU 能减少并发交错。
断点:
(gdb) b scheduler
(gdb) b sched
(gdb) b swtch观察点:
scheduler 找到 RUNNABLE 进程。
它把进程改成 RUNNING。
它设置 c->proc = p。
它调用 swtch。
进程让出 CPU 时又调用 sched。
流程:
scheduler context
|
v
swtch to process context
|
v
process runs
|
v
sched
|
v
swtch back to scheduler19. 条件断点
条件断点在 xv6 调试中很有用。
普通断点会停太多次。
例如只想看某个 pid:
(gdb) b syscall if myproc()->pid == 3例如只想看某个系统调用号:
(gdb) b syscall if myproc()->trapframe->a7 == SYS_exec有时 GDB 不认识宏。
可以直接使用数字。
系统调用号在 kernel/syscall.h。
条件断点也可能让执行变慢。
调试内核时慢是正常的。
先求观察清楚。
再求效率。
20. watchpoint
watchpoint 是观察点。
它在某个内存位置被读写时暂停。
例如观察当前进程状态:
(gdb) watch myproc()->state但这个表达式可能随当前 CPU 和进程变化。
更稳定的做法是先保存地址。
例如:
(gdb) set $p = myproc()
(gdb) watch $p->statewatchpoint 对页表、状态机调试很有帮助。
但硬件 watchpoint 数量有限。
在 QEMU 中也可能较慢。
21. 查看内存
GDB 的 x 命令查看内存。
常见形式:
(gdb) x/10gx address
(gdb) x/20i address
(gdb) x/s addressg 表示 giant word,通常 8 字节。
i 表示反汇编指令。
s 表示字符串。
如果地址是用户虚拟地址,要注意当前页表。
GDB 看到的是 QEMU 当前 CPU 的地址空间。
在内核态直接 x/s user_pointer 不一定可靠。
这也是为什么内核用 copyinstr。
内核不能把用户地址当普通内核地址。
22. 调试用户程序的限制
xv6 用户程序也可以带符号。
但调试用户程序比调试内核麻烦。
因为 exec 会切换地址空间。
用户程序常被链接到低地址。
不同用户程序可能复用相同虚拟地址。
如果要调用户程序,可以加载对应用户 ELF 的符号。
但入门阶段建议先调内核路径。
例如通过 sys_exec 打印 path。
通过 trapframe 看用户 PC。
通过内核函数观察用户程序行为。
这样更稳定。
23. 用 GDB 验证 shell 模型
假设模型:
sh reads command
|
v
fork child
|
+-- child exec target
|
+-- parent wait验证方法:
断在 sys_fork。
断在 sys_exec。
断在 sys_wait。
运行 echo hello。
观察 pid 和 name。
预期:
shell pid 调用 fork。
子进程调用 exec。
父 shell 调用 wait。
如果结果不同,不要急着改模型。
先确认是否被 shell 提示符的 write 或输入的 read 干扰。
调试第一原则:先区分目标事件和背景噪声。
24. 用 GDB 验证 trapframe 模型
假设模型:
系统调用参数来自用户寄存器。
trap 入口把寄存器保存到 trapframe。
内核从 trapframe 取参数。
返回值写回 trapframe 的 a0。
验证方法:
断在 syscall。
打印:
(gdb) p myproc()->trapframe->a7
(gdb) p myproc()->trapframe->a0
(gdb) p myproc()->trapframe->a1单步越过分派。
再打印:
(gdb) p myproc()->trapframe->a0预期:a0 从第一个参数变成返回值。
这能纠正一个常见误解。
寄存器含义不是永久固定。
含义由调用约定和执行阶段决定。
25. 用 GDB 验证页表模型
假设模型:
用户页表包含用户代码、数据、栈、trapframe、trampoline。
内核页表包含内核代码、数据、设备映射、内核栈。
trap 过程中需要在两张页表间切换。
验证方法:
断在 prepare_return。
打印:
(gdb) p/x myproc()->pagetable
(gdb) p/x r_satp()观察 MAKE_SATP(p->pagetable)。
断在 usertrap。
观察进入内核后的 satp。
如果实现了 vmprint,打印用户页表。
关注高地址的 TRAMPOLINE 和 TRAPFRAME。
这能把抽象地址空间落到具体映射上。
26. 常见问题:GDB 连不上
先看 QEMU 是否已经启动 qemu-gdb。
再看 Makefile 打印的端口。
不要假设永远是 26000。
检查是否已有旧 QEMU 占用端口。
检查是否使用了正确 GDB。
检查 .gdbinit 是否生成。
如果 GDB 拒绝自动加载 .gdbinit,可能需要配置安全路径。
可以手动执行 target remote。
先让连接成功。
再追求自动化。
27. 常见问题:断点不命中
可能断错函数名。
可能代码被内联。
可能还没运行到该路径。
可能符号文件不是当前构建产物。
可能你在多 CPU 下被其他 CPU 先打断。
可能断点设置在用户程序但只加载了内核符号。
解决步骤:
确认 kernel/kernel 是最新的。
确认 make clean 后重建是否改变。
先断 main 或 usertrap 这种必经点。
再逐步缩小到目标函数。
28. 常见误解
误解一:GDB 调试就是单步每一行。
不是。
高质量调试是带着问题找证据。
误解二:调用栈能显示全部历史。
不能。
调用栈只显示当前栈上的调用链。
trap 边界和上下文切换会改变栈。
误解三:myproc() 总是有值。
不是。
调度器或早期启动阶段可能没有当前进程。
误解四:多 CPU 下停住的就是自己关心的进程。
不一定。
要看 pid、name、hartid。
误解五:用户指针能直接 x/s。
不一定。
要考虑当前地址空间。
误解六:断点越多越好。
不是。
断点太多会让事件顺序变得难读。
29. 小实验:验证 write
目标:看 echo hello 的 write 路径。
步骤:
启动 make CPUS=1 qemu-gdb。
GDB 连接。
设置 b syscall。
运行到 shell。
输入 echo hello。
每次停下打印 a7。
当 a7 是 SYS_write 时,打印 a0、a1、a2。
观察返回后 a0 变成写入字节数。
结论:系统调用号和参数确实来自 trapframe。
30. 小实验:验证 fork
目标:确认父子返回值不同。
断在 kfork。
在子进程 trapframe 设置返回值的位置附近单步。
打印父进程 pid。
打印新进程 pid。
打印 np->trapframe->a0。
预期:子进程看到 fork 返回 0。
父进程看到 fork 返回子 pid。
思考:为什么返回值不一样但代码看起来相同?
答案:父子有各自的 trapframe。
31. 小实验:验证 exec
目标:观察 exec 改变地址空间。
断在 kexec。
输入 echo hello。
记录 myproc()->pid。
记录旧 myproc()->pagetable。
在成功路径后记录新 myproc()->pagetable。
记录 trapframe->epc。
预期:pid 不变,pagetable 和 epc 改变。
解释:exec 不是创建新进程,而是替换当前进程的程序。
32. 小实验:验证 wait/exit
目标:看 ZOMBIE 被回收。
断在 kexit。
断在 kwait。
运行一个短命令。
在 kexit 打印当前 pid 和 state。
在 kwait 找到 ZOMBIE 子进程时打印 child pid。
观察 freeproc 后状态变为 UNUSED。
结论:exit 先留下记录,wait 再回收记录。
这比一句“wait 等待子进程”更精确。
33. 小实验:验证页表打印
目标:把用户地址空间画出来。
如果已实现 vmprint,在 exec 成功后调用它。
运行一个用户程序。
观察低地址代码段。
观察用户栈。
观察 TRAPFRAME。
观察 TRAMPOLINE。
如果没有 vmprint,先只用 GDB 打印 pagetable 和 sz。
不要急着解析所有 PTE。
先理解每块区域的作用。
34. 调试记录模板
每次调试建议记录:
问题是什么。
预期路径是什么。
设置了哪些断点。
输入了什么命令。
停在哪个函数。
当前 pid 是多少。
当前进程名是什么。
关键寄存器是什么。
关键结构体字段是什么。
观察结果是否符合预期。
如果不符合,下一步要排除什么。
这样的记录能防止调试变成随机游走。
35. 本篇总结
GDB 是显微镜。
但显微镜不自动给答案。
你要先提出可验证的模型。
再用断点、寄存器、结构体、页表去检查它。
本篇最重要的调试路径是:
user stub
|
v
ecall
|
v
uservec
|
v
usertrap
|
v
syscall
|
v
sys_* function
|
v
kernel implementation理解这条路径后,xv6 不再只是源码文本。
它会变成一台可以暂停、观察、验证的机器。