跳转至

RISC-V 汇编语言程序的调试

程序调试能力也是编程能力的重要组成部分。本章介绍如何使用 GDB 和 QEMU 来调试 RISC-V 程序。 与一般的应用程序不同,我们需要在裸机环境下,没有操作系统支持的情况下进行程序调试,这就需要模拟器 QEMU 的支持。

在 QEMU 的命令行上,使用 -s 来启动一个内置的 gdb server 服务器。这样外部的 gdb 客户端可以连接这个端口(1234)来进行程序调试。也可以使用 -gdb tcp::9000 来更改这个服务程序的端口。-S 的选项是在 QEMU 启动的时候,先不启动客户虚拟机的 CPU,先等待一个 gdb 客户端连接进来,这样可以调试裸机初始化的过程。

样例代码

下面看一个程序如何编译以及在 QEMU 环境中如何运行。程序很简单,就是计算从 1 加到 10 的总和。

.section .text
.globl _start
_start:
    addi t0, x0, 0x1
    addi t1, x0, 0x0
    addi t3, x0, 0xa
loop:
    add t1, t1, t0
    add t0, t0, 0x1
    add t3, t3, -1
    bne t3, x0, loop
    jr ra

代码编译

用下面的命令进行编译,生成二进制目标代码:

riscv64-unknown-elf-c++ -c -nostdlib -nostdinc -static -g -Ttext 0x80000000 -o sum.elf sum.s

或者生成 32 位格式的二进制目标代码:

riscv64-unknown-elf-c++ -nostdlib -nostdinc -static -g -Ttext 0x80000000 sum.s -o sum.elf -march=rv32i -mabi=ilp32

各个编译参数的解释:

  • -nostdlib -nostdinc 不需要标准库(因为是裸机代码,无标准库的支持)
  • -c 只进行编译和汇编,不进行链接(因为是裸机代码,也不需要和任何其它库进行链接)
  • -static 静态编译,不做动态代码的特殊处理
  • -g 加入调试信息(符号表等),便于调试
  • -o 输出的文件名
  • -Ttext 0x80000000 把代码段放到指定地址(模拟器刚刚启动的时候,会从 0x1000 的位置执行,这个位置的代码会把 PC 值指向 0x80000000 位置。这样就需要把用户的代码放到 0x80000000 位置。)(见下面的执行截图)
  • -march=rv32i 指定使用的 RISC-V 指令集
  • -mabi=ilp32 指定使用的 abi 结构(指令集是 32 位,不指定这个的话,会使用 64 位的 abi,会不兼容。注意,这里默认使用的是 64 位的编译器。)

执行完上述 5 条指令后的寄存器的内容。

t0 就是目标地址,即用户地址,通过 jr t0 跳入。

模拟器执行与调试

下面通过编译为 32 位格式的目标代码来看一下程序的执行过程(由于程序不需要任何操作系统的支持,要用远程 gdb 工具进行单条指令执行,观察寄存器值的修改情况。)

上述代码保存为 sum.s,执行如下的命令:

riscv64-unknown-elf-c++ -nostdlib -nostdinc -static -g -Ttext 0x80000000 sum.s -o sum.elf -march=rv32i -mabi=ilp32

生成可执行文件:

生成的可执行文件能在 RISC-V 的 Linux 操作系统下执行,在 Windows 下无法执行。

可以使用 objectdump 来反汇编,看看可执行程序里面的内容:

使用 QEMU 模拟器开始装入可执行程序:

qemu-system-riscv32 -m 2G -nographic -machine virt -kernel sum.elf -s -S -bios none

注意一定要带入 -s -S 参数。-s 的意思是在 QEMU 中启动 gdb server,端口号为 1234,-S 的意思是,完成装载之后,不要启动模拟的处理器,等待调试器接入。

另外 -bios none 代表的意思就是不需要装载 qemu 默认的基本输入输出系统 bios,在模拟的平台上不需要这部分的信息。基本输入输出系统是机器自带的只读内存 rom 中的二进制代码,用以在机器启动的时候执行,可以用来装入操作系统。

启动 gdb 的调试器客户端:

riscv64-unknown-elf-gdb sum.elf

后面要跟上 sum.elf,主要目的是两个,一个是 gdb 本身是 64 位的,需要告诉它调试的对象是 32 位的,另外一个是 sum.elf 里面有调试的符号表,在进入调试的时候能够看到被调试的代码。

输入调试目标:

(gdb) target remote localhost:1234

之后可以开始调试,先把断点设置到 0x80000000 的位置,之后可以控制程序单步执行。

可以通过下面的一些指令来观察程序的执行行为。

gdb 的一些命令:

  • break 设置断点,比如 break *0x80000000
  • disas $pc, $pc+20 在一个范围内反汇编
  • info reg 列出所有的寄存器
  • x /24i $pc 在一个地址之后反汇编 24 条指令
  • x /24d $pc 在一个地址之后列出内存中 24 字节的内容
  • p $x1 打印某一个寄存器的内容

容易产生错误的地方:如果 QEMU 和 gdb 互相不能匹配的最大问题就是 32 位和 64 位不匹配。要保证 32 位的 QEMU 模拟器执行 32 位的程序,64 位模拟器去执行 64 位的程序。在调试的时候也要指定正确的字的长度(32 位或者 64 位)。

下面是联合起来的效果:

上面窗口启动 qemu 和用户程序,用命令行启动 qemu 等待连接,注意 -s-S。下面窗口启动 gdb 命令行进行连接。先设置目标位置 target remote localhost:1234。之后设置断点 break *0x80000000。随后通过 c 命令启动执行,这个时候就会激活上面窗口的 qemu。在执行的时候遇到第一个断点程序会停下来。随后可以通过 si 命令进行单步执行。因为没有设置其它的断点,如果使用 continue 命令的话会跑飞(即程序执行到无法控制的位置,执行未知的指令)。

另外,程序执行的时候会收到很多环境的影响,这里的一个影响来自于当前目录的 .gdbinit 文件。这个文件包含了在启动 gdb 时候的一些初始配置,没有注意到的话会造成困扰。可以找一个没有 .gdbinit 的目录下面执行上面的命令,避开使用和 kernel 一样的目录。

具体的做法是使用 打开命令行.cmd 打开的命令行窗口,然后可以用 cd 命令切换到别的目录。这个 cmd 脚本已经设置好一系列所需的环境变量。

启动和调试监控程序

下面过程说明如何使用 gdb 和 QEMU 来调试监控程序:

上述调试 RISC-V 程序的方法也可以应用在调试监控程序上。使用下面命令行可以通过 QEMU 启动监控程序并等待调试:

qemu-system-riscv32 -M virt -m 32M -kernel ./kernel.elf -nographic -monitor none -serial tcp::6666,server -s -S -bios none

其中几个参数意义是:

  • -M virt 该参数决定了模拟的 RISC-V 处理器的平台型号,通常不用更改
  • -m 32M 处理器可访问的内存容量是 32M 字节
  • -kernel ./kernel.elf 要运行的程序,必须为 elf 格式文件
  • -nographic 不启用图形功能支持
  • -monitor none 不启用控制窗口
  • -s -S 两个参数联合使用,表示启用调试功能,并等待调试器连接(默认的端口是 1234)
  • -bios none 不加载 BIOS(OpenSBI)

在调试监控程序的时候,QEMU 会打开两个端口,一个端口用于串口通信(端口号:6666),一个端口用作 gdb server 服务程序的端口(端口号:1234)。实际执行时,QEMU 会首先打开串口通信的端口,然后再打开 gdb server 服务程序的端口。所以,如果没有一个终端程序来连到串口的话,QEMU 是不会打开调试端口的。因此,为了能够对监控程序进行调试,需要把终端程序也启动起来。

第一步:使用上述命令来启动 QEMU 模拟器。

第二步:使用 term 终端程序来连接 6666 号端口。

QEMU 模拟器的状态也发生了变化。

可以看到,tcp 已经连接上了。但是,此时监控程序没有起来。因为 QEMU 还在等待 gdb 程序的连接。

第三步:下一步,使用 gdb 连接到 QEMU 的 gdb server。

注意,这里的命令是 riscv64-unknown-elf-gdb kernel.elf,后面跟着对应的二进制代码。这样可以通知 gdb 的客户端程序是在服务器端调试那个程序。而且,riscv64-unknown-elf-gdb 默认任务服务器端的程序是 64 位的,但是实际上监控程序是 32 位的。这里通过 kernel.elf 的参数明确告诉 gdb 客户端,需要调试的程序是 32 位的,这样双方才能够正确通信。

下图是 gdb 启动之后的情况。

这个时候就可以输入调试的目标了

(gdb) target remote localhost:1234

可以看到,gdb 使用 localhost:1234 开始调试。

这时,其它的两个窗口(QEMU 窗口和 term 窗口)没有发生变化,因为这个时候 QEMU 并没有启动模拟的处理器(或者说虚拟机内部的处理器)。在 gdb 窗口中键入 continue,启动 QEMU 中的模拟的处理器。

终端程序已经可以进行交互了:

关于 -M 的解释:

可以看到,这一参数是指定对应的模拟机器的型号配置,这在实验中不重要,因为我们并不需要其它外设,保持默认的 virt 即可。


最后更新: 2023年9月21日
作者:Jiajie Chen (6.47%), Youyou Lu (44.28%), gaoyichuan (37.81%), Kang Chen (11.44%)