跳转至

实验0:内核编译运行与 GDB 调试

本节目标

完成内核环境安装、编译启动、QEMU 交互与退出,并能使用 GDB 连接调试内核。

前情提醒

本文面向课程提供的 nexos 仓库。
你需要已经完成了 WSL/VLAB/VMware 任意一种环境搭建。

补充阅读

如果你对提交代码、分支管理、合并流程还不熟悉,完成本页后可继续阅读 实验0:希冀平台与课程仓库接入、附录 Git 提交、分支与合并Makefile 基础教程。若想了解如何更稳妥地借助 AI 工具辅助排错,也可参考 AI 工具与 Vibe Coding 入门

预计用时

如果基础环境已经就绪,本节预计 20~40 分钟。首次下载依赖、编译内核和配置 gdb 时,可能会因为网络或版本问题花费更久。

1 使用 Git 获取课程代码

请亲手执行一次 git clone

这是你在课程中第一次使用 Git 获取代码。后续所有实验都建立在这一步之上。不要使用网页提供的源码压缩包下载!!!

这一步的目标

Lab0 中,你至少需要先会用一次 git clone 把课程仓库下载到本地,并进入仓库完成后续编译。当前阶段先把环境、编译与调试流程跑通即可;更详细的分支与 merge 流程放在附录 Git 提交、分支与合并 中。

先在 Linux 环境中安装 Git(若已安装可跳过):

sudo apt update
sudo apt install -y git

克隆课程仓库并进入目录:

git clone https://git.ustc.edu.cn/KONC/nexos.git
cd nexos

先拿到代码,再学协作

这里先通过 HTTPS 完成一次 git clone,目的是尽快把课程代码拉到本地并继续后续环境配置。完成本页后,请继续阅读 实验0:希冀平台与课程仓库接入:你会把当前仓库的 origin 重命名为 upstream,再把自己在希冀 GitLab 中创建的空项目添加为新的 origin

检查目录结构(命令与输出示例):

$ ls
Makefile  README.md  kernel  tools  user

2 环境安装(Ubuntu/WSL/VLAB/VMware 通用)

2.1 安装基础依赖

sudo apt update
sudo apt install -y build-essential python3 qemu-system-misc gdb-multiarch

说明:

  • build-essential 包含 make/gcc 等常用构建工具。
  • qemu-system-misc 提供 qemu-system-riscv64
  • gdb-multiarch 用于跨架构调试(含 RISC-V 支持)。

2.2 安装 RISC-V 工具链(二选一)

本项目 Makefile 会自动检测两种前缀,安装其中一套即可。

sudo apt install -y gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf
sudo apt install -y gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu

2.3 检查环境是否安装成功

$ qemu-system-riscv64 --version | head -n 1
QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.13)
$ riscv64-unknown-elf-gcc --version | head -n 1
riscv64-unknown-elf-gcc (13.2.0-11ubuntu1+12) 13.2.0
$ python3 --version
Python 3.12.3

提示

版本号可能与示例不同,只要命令能正常输出即可。

3 编译并启动 Kernel

建议在仓库根目录按下面顺序执行(也可以直接make qemu):

make
make fsimg
make qemu
  • make:编译内核,生成 kernel.elf
  • make fsimg:打包用户程序到 fs.img
  • make qemu:启动 QEMU 运行内核(串口模式,无图形窗口)。

关键输出示例:

$ make
...
riscv64-unknown-elf-ld -z max-page-size=4096 --no-warn-rwx-segments -T kernel/ld/kernel.ld -o kernel.elf ...
$ make fsimg
...
packed inline -> TEST.TXT (inode=30, bytes=22)
wrote fs.img (blocks=65536, ninodes=1024, data_start=146, used_blocks=1923)
$ make qemu
qemu-system-riscv64 -machine virt -bios none -kernel kernel.elf -m 128M -smp 4 -nographic ...

 _   _            ___   ____
| \ | | ___ _  _ / _ \ / ___|
|  \| |/ _ \ \/ / | | \___ \
| |\  |  __/>  <| |_| |___) |
|_| \_|\___/_/\_\\___/|____/

$

可以在 $ 提示符输入命令测试:

$ hello
[user] hello from U-mode!
[user] sbrk ok
$ pid
[pid] 4
$ ls
.                    1       496
..                   1       496
init                 2     30576
...
TEST.TXT            30        22

4 QEMU 的运行与退出方法

4.1 运行时交互

  • make qemu 使用 -nographic,当前终端就是内核串口控制台。
  • 在 shell 里输入命令后回车执行。
  • Ctrl+C 会向串口发送控制字符,但在教学内核中不等价于 Linux 的进程信号中断。

4.2 退出 QEMU(重点)

不要用 exit 退出 QEMU

本项目中,输入 shell 的 exit 只会让 init 重新拉起 shell,不会退出 QEMU

正确退出方式:

  1. 按下 Ctrl+a
  2. 松开后按 x

Ctrl+a 然后 x

如果你忘了组合键,可以先按 Ctrl+a 再按 h 查看帮助。

5 使用 GDB 调试 Kernel

统一使用 gdb-multiarch

本文档后续调试步骤统一使用 gdb-multiarch。如果你已经按第 2.1 节安装过基础依赖,一般无需额外安装其他版本的 GDB。

调试时一定要准备两个终端

这一部分必须使用两个终端窗口

  • 第一个终端:运行 make qemu-gdb,负责启动并挂起 QEMU
  • 第二个终端:运行 gdb-multiarch kernel.elf,负责连接并控制调试流程。

如果你只开一个终端,QEMU 会占住当前窗口,你将无法在同一个终端里继续输入 GDB 命令。

可先确认命令可用:

$ gdb-multiarch --version | head -n 1
GNU gdb (Debian 15.2-1) 15.2

5.1 启动可调试的 QEMU

先开 第一个终端,在仓库根目录运行:

make qemu-gdb

关键输出示例:

$ make qemu-gdb
...
qemu-system-riscv64 ... -S -gdb tcp::26000

说明:

  • -S 表示 CPU 启动后先暂停,等待 GDB 连接。
  • -gdb tcp::26000 表示在本地 26000 端口开启 gdb server。

5.2 在第二个终端连接 gdb-multiarch

再开 第二个终端,并再次进入同一仓库目录:

cd nexos
$ gdb-multiarch kernel.elf
GNU gdb (Debian 15.2-1) 15.2
...
Reading symbols from kernel.elf...
(gdb) target remote localhost:26000
Remote debugging using localhost:26000
0x0000000000001000 in ?? ()
(gdb) b main
Breakpoint 1 at 0x80004374: file kernel/core/main.c, line 23.
(gdb) c
Thread 4 hit Breakpoint 1, main () at kernel/core/main.c:23

下面这张图给出了一个典型的双终端调试界面:左侧终端运行 make qemu-gdb,右侧终端运行 gdb-multiarch kernel.elf 并执行 target remote localhost:26000

双终端调试示意图

5.3 常用 GDB 命令

n                 # 单步(不进入函数)
s                 # 单步(进入函数)
bt                # 查看调用栈
info registers    # 查看寄存器
x/10i $pc         # 从当前 PC 开始反汇编 10 条指令
p variable        # 打印变量
c                 # 继续运行
q                 # 退出 gdb

5.4 调试实例——追踪系统调用 sys_exec 的大致流程

本部分讲解 GDB 的基本使用方法,读者可依照下列流程体验使用 GDB 调试的过程。

NexOS 的系统调用范式是:
1. 将调用号传入 a7 寄存器,执行 ecall 指令(user/syscall.c); 2. 进入异常处理例程(kernel/core/trap.c),执行 a7 对应的系统调用; 3. 返回 sepc 中保存的地址,系统调用处理完毕。

请根据上述范式来追踪 sys_exec 的执行过程。

首先打开 第一个终端,进入仓库,开启 QEMU

make qemu-gdb

然后另外打开 第二个终端,开启 GDB 并远程连接(连接前请确保你连接的端口并未被占用)。

$ gdb-multiarch kernel.elf
Reading symbols from kernel.elf...
(gdb) target remote localhost:26000
到这里,GDB 已经和 QEMU 完成了远程连接,接下来你可以在此终端进行调试。

看到 kernel/core/trap.c,可以发现处理系统调用的例程是下图中部分:

alt text

进入到 syscall() 函数(本仓库自动生成 compile_commands.json 文件,可用于 clangd 索引,提供跳转到定义、查找引用等功能,同学们可点击此处查看 clangd 安装教程)中深入观察内核行为,先在此处打断点:

b kernel/core/trap.c:94 # ":"前代表文件,后面的数字代表在此文件的哪一行打断点

然而这里存在一个问题,此断点不论当前发生的系统调用是否是 sys_exec 其都会”冻结“ CPU,如果这个过程中的系统调用数量较少,那可以一个个进行比对,但如果数量很多,你可能需要 GDB 来帮助筛选:

b kernel/core/trap.c:94 if p->trapframe->a7 == 7

字面上来说,这条 GDB 命令做这样一件事情:当 if 后的条件满足时,此断点生效,CPU 冻结。p 是当前进程,trapframe 是指向一个连续空间的指针,这个连续空间保存着当前进程的”上下文“,其中 a7 意即这个进程运行时寄存器 a7 的值,也就是上述的系统调用号被存入的地方,7 即 sys_exec 的调用号。

然后按 c 使 QEMU 持续运行直到触发断点,这个时候 QEMU 已经打印了 NexOS,但尚未进入到 shell,因为当前停驻的 sys_exec 就是用来执行 shell 的(user/init.c:13)。

接下来找到 syscall(),其中处理逻辑大致为找到对应系统调用号函数并执行,逻辑简单,不需要断点,于是进一步找到 sys_exec() 并在其内打断点。

b kernel/core/syscall.c:76

接下来你可以进行更细粒度的调试,即用 si/s/ni/n 来进行调试,四者的作用分别是:

  • si:以汇编指令的粒度进行调试,如果是函数则进入函数内部;
  • s:以 C 语言代码的粒度进行调试,进入函数内部;
  • ni:以汇编指令的粒度进行调试,不进入函数内部;
  • n:以 C 语言代码的粒度进行调试,不进入函数内部。

对于一些定义结构体、简单函数调用等的代码,你可以将其跳过以简化调试,而对于一些复杂的函数,则可能需要进入其中观察其行为,如果你有查看寄存器值等更高级的需求时则可能需要以汇编指令的粒度调试。

假设你已经来到了这个函数的最后一部分,发现其返回一个函数的执行结果:

return (uint64)exec(path, kargv);

于是再一次深入,这里面的函数行为,就是 sys_exec() 的真正业务逻辑。如果你想查看某个变量的值,如 exec() 的参数 name,则你可以输入:

$ p name
$3 = 0x3fffff8ef0 "/sh" # "/sh" 即 shell 可执行文件的路径,exec 的作用即执行传入其的路径对应的可执行文件

除此之外,还可以查看寄存器、某处内存的值/代码等,可自行掌握。

到了返回的时候了,在 return argc; 处打下断点,然后按 n,发现其返回到了中断/异常处理函数中,再进入到 usertrapret() 函数中,查看返回例程,即调用 kernel/arch/riscv/trampoline.S: userret 返回 sepc 中保存的地址(注意:你可以用 p $/sepc 查看 sepc 的值,此时其值为 0 是正确的,因为这正是 init 的地址)。

你可以在 kernel/arch/riscv/trampoline.S 中设置断点,但你运行之后可能会发现程序并不按照预想的停留,原因是 kernel.elf 中关于 trampoline.S 的符号是低地址的(类似于 0x8000XXXX),但是程序计算得到的地址是高地址的(类似于 0x3ffffff000),而 GDB 不能识别高地址的符号,因此不会停驻(你可以在 usertrapret() 末尾处使用 si 单步验证,程序将会运行到高地址处)。为使得 GDB ”认识“ 高地址,你可以在 GDB 中输入:

add-symbol-file build/kernel/arch/riscv/trampoline.o -s .text.trampoline 0x3ffffff000

以上就是追踪 sys_exec 执行的大致过程,由于篇幅所限不能逐步展示调试过程,同学们可以自行深入探索,也可另择一系统调用或其他功能使用 GDB 探究其流程。如有疑问,可以查询在线文档或询问助教。

下一步

如果你已经能在本地编译、运行并调试课程内核,下一步建议继续阅读 实验0:希冀平台与课程仓库接入。完成仓库配置后,再阅读 AI 工具与 Vibe Coding 入门 会更顺手。