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 通道。

下面这张图能帮你快速理解整个调试链路:

GDB 远程调试架构图 开发机 GDB 客户端 解析 ELF / 控制执行 TCP :1234 QEMU 主机 gdbstub 服务器 接收调试命令 内部总线 RISC-V 模拟 CPU 调试流程 GDB → TCP 发送 RSP 包 → QEMU gdbstub 解析 → 操作模拟 CPU 状态 → 返回结果

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。

  1. 启动 QEMU:加上 -s -S 参数。
  2. 连接 GDB:target remote localhost:1234。
  3. 在入口设断点:break _start。
  4. 查看当前 PC:p $pc,确认停在 0x80000000。
  5. 单步执行几条指令:stepi,观察 PC 如何变化。
  6. 查看栈指针:p $sp,确认栈初始化是否正确。
  7. 继续执行到 main:continue。

我的习惯:在调试启动代码时,我会在 _start、main 和关键中断入口各设一个断点。这样既能确认程序正常启动,又能快速定位是哪个环节出了问题。

3.7 断开连接与退出

调试结束后,断开 GDB 连接:

(gdb) disconnect

退出 GDB:

(gdb) quit

QEMU 那边,按 Ctrl+A 然后按 X 可以退出。

好了,GDB 远程调试的基本操作就这些。你想想看,有了这套工具,以后程序跑飞了、变量值不对了、中断没响应了,你都能一步步查清楚。这就是调试的底气所在。