在VsCode上调试运行在RISC-V Qemu上的Rust内核
使用 VSCode + GDB 调试 RISC-V 架构的 QEMU 虚拟机上的 Rust 内核
一、调试器的原理
首先先讲下调试器的原理,ai总结的,就是帮助理解一下,没太深入。毕竟我之前还从来没想过调试器是怎么实现“调试”的。
基于操作系统支持
调试器能够发挥作用,首先要感谢操作系统的支持。当程序被调试器加载后,操作系统就会进入一种特殊的调试模式,让程序的执行受到调试器的控制。
比如在 Windows 系统中,调试器可以通过 API 函数 CreateProcess 来创建一个进程,并指定其为被调试进程。这时,操作系统会为该进程创建一个用于调试的结构体,记录其调试相关信息。
当程序运行过程中触发某些特定事件(如断点、异常等)时,操作系统会将控制权转移给调试器。调试器可以通过相应的事件处理函数来获取事件的详细信息,如事件的类型、发生的位置等,然后根据这些信息进行相应的调试操作。
利用符号信息(DWARF)
在编译过程中,编译器会根据程序的源代码生成符号信息,这些符号信息包括变量名、函数名、行号等与源代码相关的信息。DWARF(Debug With Arbitrary RecordFormats)是一种常用的调试信息格式,它可以将这些符号信息以一种结构化的方式存储在可执行文件或单独的调试信息文件中。
调试器会读取这些符合 DWARF 格式的符号信息,从而能够建立起源代码与机器码之间的映射关系。例如,调试器可以根据函数名找到对应的机器码指令地址,或者根据变量名找到变量在内存中的存储位置等。这样开发者就可以在调试过程中使用熟悉的源代码级别进行操作,而不是直接面对晦涩难懂的机器码。
断点设置
断点是调试器中最常用的功能之一,它允许程序在执行到特定的代码位置时暂停下来。调试器实现断点的方式通常有两种,一种是软件断点,另一种是硬件断点。
- 软件断点 :在要设置断点的指令位置插入一条特殊的中断指令(如在 x86 架构中,通常使用 int3 指令),当程序执行到该位置时,会触发一个中断,将控制权转移给调试器。此时调试器会记录下当前程序的执行状态,包括寄存器的值、堆栈的指针等信息,等待开发者的进一步操作。
- 硬件断点 :利用了处理器提供的调试寄存器,这些寄存器可以存储要断点的内存地址或 I/O 端口地址等信息,当程序访问到这些地址时,会触发硬件断点中断,由调试器进行处理。硬件断点的优点是不会像软件断点那样对程序的执行流程产生指令级别的干扰,但它受到处理器调试寄存器数量的限制,通常只能设置有限的几个硬件断点。
单步执行
单步执行是指让程序一次只执行一条指令或者一行源代码,以便开发者可以逐步跟踪程序的执行流程,查看每一步执行后的效果。调试器实现单步执行的方式通常有单步陷阱标志位和断点法。
- 单步陷阱标志位 :处理器提供的一种调试功能,调试器可以设置该标志位,当程序执行下一条指令时,处理器会触发一个单步中断,将控制权转移给调试器,此时调试器可以记录下程序的当前状态,并等待开发者的下一步操作。
- 断点法 :利用软件断点来实现单步执行,在每执行完一条指令后,就将断点重新设置到下一条指令的位置,如此循环往复,以实现单步执行的效果。
内存和寄存器访问
调试器通过操作系统提供的接口来访问和操作被调试程序的内存和寄存器。在程序暂停执行时,调试器可以读取程序的内存内容,查看变量的值、内存的分布情况等,也可以修改内存中的数据,以观察程序的行为变化。
同时,调试器还可以读取和修改处理器的寄存器值,寄存器是处理器内部用于存储数据和地址的关键部件,通过查看寄存器的值,可以了解程序当前的执行状态,如程序计数器(PC)的值表示下一条指令的地址,通过修改寄存器的值,可以改变程序的执行流程。
事件处理和状态监控
调试器还需要处理各种调试事件,如断点触发、异常发生、进程退出等事件,并根据不同的事件类型采取相应的处理措施。例如,当发生异常事件时,调试器会获取异常的类型和发生的位置等信息,然后将这些信息展示给开发者,帮助开发者分析异常的原因。
同时,调试器还会持续监控被调试程序的运行状态,包括进程的创建和销毁、线程的调度和执行等信息,以便开发者可以全面了解程序的运行情况。
二、实战:使用 vscode + gdb 调试 RISC - V 架构的 QEMU 虚拟机上的 Rust 内核
了解了调试器的原理之后,我们就可以将其应用到实际的开发中。比如,理论上我们可以实现使用 vscode + gdb 对运行在使用 RISC - V 架构的 QEMU 虚拟机上的 Rust 内核进行源代码的调试。
启动 QEMU 虚拟机
首先,我们需要启动 QEMU 虚拟机,运行我们的内核程序,不过启动时需要加上如下的参数:
bashQEMU_ARGS := -machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \
-gdb tcp::12346 -S
这样我们就启动了在 QEMU 虚拟机上的 gdb 调试服务器。
配置 VSCode
同时,在 VSCode 中编辑自定义调试配置文件 launch.json,类似于下面这种:
json{
"version": "0.2.0",
"configurations": [
{
"name": "RISC-V Kernel Debug",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/os/target/riscv64gc-unknown-none-elf/release/os",
"cwd": "${workspaceFolder}",
"MIMode": "gdb",
"miDebuggerPath": "riscv64-unknown-elf-gdb",
"miDebuggerServerAddress": "localhost:12346",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "set remotetimeout 20",
"ignoreFailures": true
}
],
// "preLaunchTask": "start gdbserver",
}
]
}
开始调试
然后先启动 QEMU 调试服务器,打好断点,然后再启动 VSCode 的调试功能,就可以开始调试了。
不过,由于隔了这么多层,源代码调试功能可能不太好用,所以有时候还是需要通过 VSCode 的调试控制台通过调试命令进行调试。在 VSCode 的调试控制台中使用调试命令需要加上 - exec 前缀,下面是一些常用的调试命令:
break *<16进制地址>:可以在指定位置打一个断点。continue:可以跳到下一个断点处暂停。si:可以执行一条汇编指令。p/x $<寄存器>:可以以 16 进制的方式查看指定寄存器的值。x/x <address>:可以以 16 进制的方式查看指定内存地址的值。这个命令有很灵活的用法,比如x/10i $pc可以以指令的形式查看当前将要执行的 10 条指令,x/x $sp + 8可以查看从 sp 寄存器指向的地址向后偏移 8 个字节地址处的值。
总之,调试器作为一种强大的工具,能够帮助我们有效地发现和解决程序中的问题。通过理解其原理并熟练掌握其使用方法,我们可以在开发过程中更加高效地定位和修复错误,让我们的程序更加稳定和完善。
💬 评论区
暂无评论,来发表第一条吧。