3. OpenSBI入门
调试嵌入式程序,说白了就是跟硬件“对话”。你写了个 RISC-V 程序,跑在 QEMU 里,怎么知道它执行得对不对?总不能靠猜吧。
这时候 GDB 就派上用场了。它就像个显微镜,能让你看到 CPU 内部的一举一动。我个人习惯,拿到一个新平台,第一件事就是先把 GDB 调通。这一步走顺了,后面所有问题都好办。
3.1 什么是 gdbstub?
QEMU 内部内置了一个轻量级的 GDB 服务器,叫 gdbstub。它不依赖操作系统,直接在模拟器底层实现了 GDB 远程调试协议。
你想想看,QEMU 模拟了一个 RISC-V CPU,GDB 通过 TCP 连上去,就能收发“读寄存器”、“写内存”、“设置断点”这些命令。整个过程不需要目标机跑任何调试代理,干净利落。
核心关系:
- GDB(客户端):运行在你的开发机上,负责解析符号、控制执行流。
- QEMU(服务器):运行 gdbstub,等待 GDB 连接,执行调试指令。
- 通信协议:GDB 远程串行协议(RSP),走 TCP 通道。
下面这张图能帮你快速理解整个调试链路:
3.2 启动 QEMU 并开启 gdbstub
要让 QEMU 等待 GDB 连接,只需要加一个参数:-s。它等价于 -gdb tcp::1234,默认监听 1234 端口。
我常用的启动命令长这样:
qemu-system-riscv64 \
-machine virt \
-cpu rv64 \
-m 128M \
-nographic \
-kernel my_program.elf \
-s \
-S
注意 -S 参数:它让 CPU 在启动后立即暂停,等待 GDB 连接。如果不加这个,程序可能跑完了你还没连上。
启动后你会看到 QEMU 停在黑屏状态,没有任何输出。别慌,这是正常的。它正在等你用 GDB 连上去发号施令。
3.3 连接 GDB 到 QEMU
打开另一个终端,启动 RISC-V 交叉编译版本的 GDB:
riscv64-unknown-elf-gdb my_program.elf
进入 GDB 后,执行远程连接命令:
(gdb) target remote localhost:1234
如果一切顺利,你会看到类似这样的输出:
Remote debugging using localhost:1234
0x0000000080000000 in ?? ()
嗯,这里要注意。地址 0x80000000 是 QEMU virt 平台的默认启动地址。你的程序如果链接到了正确的位置,GDB 应该能自动识别出当前停在 _start 入口处。
常见坑:如果连接后 GDB 报 Remote 'g' packet reply is too long,通常是因为 QEMU 模拟的 CPU 位数(32/64)与 GDB 架构不匹配。检查你的 GDB 版本和 QEMU 参数是否一致。
3.4 断点设置与单步执行
连接成功后,调试就正式开始了。我一般先设个断点,看看程序能不能正常跑到入口函数。
3.4.1 设置断点
(gdb) break main
Breakpoint 1 at 0x80000100: file main.c, line 12.
也可以按地址设断点:
(gdb) break *0x80000100
设好之后,让程序跑起来:
(gdb) continue
Continuing.
Breakpoint 1, main () at main.c:12
12 volatile int i = 0;
你看,程序停在了 main 函数的第一行。这时候你可以检查变量、查看寄存器,或者单步往下走。
3.4.2 单步执行
GDB 提供了几种单步方式,我整理了一个表格:
| 命令 | 作用 | 适用场景 |
|---|---|---|
step 或 s |
单步进入,遇到函数会跟进去 | 调试函数调用逻辑 |
next 或 n |
单步跳过,不进入子函数 | 快速跳过库函数调用 |
stepi 或 si |
单条指令执行 | 查看汇编级执行流 |
nexti 或 ni |
单条指令跳过 | 跳过函数调用指令 |
举个例子,我想看看 i = 0 这条 C 语句对应几条汇编指令:
(gdb) stepi
0x0000000080000104 in main () at main.c:12
12 volatile int i = 0;
(gdb) stepi
0x0000000080000108 in main () at main.c:12
(gdb) stepi
0x000000008000010c in main () at main.c:13
13 i = 1;
你看,一条 C 语句可能对应多条汇编指令。这在调试启动代码或中断处理时特别有用。
3.5 寄存器查看与修改
调试嵌入式程序,寄存器就是你的“仪表盘”。我每次遇到程序跑飞,第一件事就是看 PC 和栈指针。
3.5.1 查看所有寄存器
(gdb) info registers
pc 0x80000100 0x80000100 <main>
ra 0x800000f0 0x800000f0 <_start+16>
sp 0x8000ff00 0x8000ff00
gp 0x80001000 0x80001000
tp 0x00000000 0x0
t0 0x00000001 1
t1 0x00000000 0
t2 0x00000000 0
...
3.5.2 查看特定寄存器
(gdb) p $pc
$1 = (void (*)()) 0x80000100 <main>
(gdb) p $sp
$2 = (void *) 0x8000ff00
3.5.3 修改寄存器值
有时候你想跳过某段代码,或者模拟一个异常返回,可以直接改寄存器:
(gdb) set $pc = 0x80000120
(gdb) set $sp = 0x8000f000
我曾经在调试一个任务切换的 bug 时,直接改了 $ra(返回地址)来模拟函数返回。结果忘了更新栈上的返回地址,导致后续函数调用全部乱套。嗯,改寄存器要谨慎,改之前最好先记下原始值。
3.6 实战:调试一个简单的启动流程
我们来走一遍完整的调试流程。假设你有一个 RISC-V 程序,入口是 _start,然后跳转到 main。
- 启动 QEMU:加上
-s -S参数。 - 连接 GDB:
target remote localhost:1234。 - 在入口设断点:
break _start。 - 查看当前 PC:
p $pc,确认停在0x80000000。 - 单步执行几条指令:
stepi,观察 PC 如何变化。 - 查看栈指针:
p $sp,确认栈初始化是否正确。 - 继续执行到 main:
continue。
我的习惯:在调试启动代码时,我会在 _start、main 和关键中断入口各设一个断点。这样既能确认程序正常启动,又能快速定位是哪个环节出了问题。
3.7 断开连接与退出
调试结束后,断开 GDB 连接:
(gdb) disconnect
退出 GDB:
(gdb) quit
QEMU 那边,按 Ctrl+A 然后按 X 可以退出。
好了,GDB 远程调试的基本操作就这些。你想想看,有了这套工具,以后程序跑飞了、变量值不对了、中断没响应了,你都能一步步查清楚。这就是调试的底气所在。