内核用户态协同调试设计与实现#

基本介绍#

调试操作系统与调试普通程序的关键区别在于:普通程序只有一份 ELF 符号表,调试器加载后即可完成断点、单步、调用栈查看等操作;而操作系统运行时涉及内核态和用户态,两者使用不同的符号来源:

状态符号来源典型代码
内核态内核 ELFkernel/src/...
用户态用户程序 ELFuser_apps/...

当 CPU 在内核态运行时,用户程序源码断点不能直接命中;当 CPU 在用户态运行时,内核源码断点也不能简单按普通程序方式处理。OSGDB 的作用就是在内核态和用户态切换时,自动切换符号表和断点组。

调试后端#

整体链路:

VS Code 前端 ←— DAP —→ 调试插件 ←— GDB/MI —→ GDB ←→ QEMU GDB stub / OpenOCD(JTAG + 真实硬件)
  • DAP(Debug Adapter Protocol):插件与 VS Code 调试界面交互的标准协议
  • GDB/MI:插件通过 GDB 的机器接口控制 GDB
  • 调试目标:QEMU 的 GDB stub(-s 参数开启,默认监听 1234 端口),或通过 OpenOCD 连接的 JTAG 真实硬件
  • 远程 attach 模式:支持不带 qemuPath/qemuArgs 的 attach 配置——调试器只负责连接已在运行的 GDB stub,QEMU 需要预先手动启动

内核/用户态协同调试#

1. 状态机#

内核/用户态切换是一个复杂的异步流程:特权级变化、符号表切换、断点组恢复等操作需要按严格顺序执行。为此引入了状态机架构(OSStateMachine),将整个切换流程建模为 4 种状态和 5 种事件的状态转移图;每次状态转移触发对应的动作序列(DebuggerActions,共 8 种动作)。状态机的具体定义见源码。

状态机重构显著提升了代码的可读性、可维护性和可扩展性——新增调试场景时只需扩展状态与动作,而不必修改分散在各处的流程代码。

2. 断点组管理#

断点组是 OSGDB 的核心概念:将用户设置的 GDB 断点按所在的地址空间分组,任意时刻只有当前地址空间对应的断点组生效。

当调试器检测到特权级切换时,自动完成:

  1. 保存当前地址空间的断点
  2. 卸载当前符号表,加载目标地址空间的符号表
  3. 恢复目标地址空间的断点
  4. 继续执行

整个过程对使用者透明:你只会看到程序停在了下一个断点上,无论它在内核还是在用户程序中。

断点组支持动态创建,因此可以同时调试多个用户态进程。

3. 边界断点#

边界断点用于标记内核态和用户态的切换位置(例如 enter_user、handle_syscall)。设计上经历了一次重要重构:

  • 第一版:边界断点是独立模块,实现逻辑与内核符号表耦合,普适性差
  • 重构后:边界断点逻辑上独立,但数据结构上作为断点组的属性;支持通过 customRequest 动态设置/取消;提供右键菜单操作(行号右键设为边界断点或取消)

针对不同调试目标的适配:

  • xv6:usys.S 中有多个 ecall 指令(多个用户态出口),因此边界断点数据结构支持一个断点组拥有多个边界断点
  • 真实硬件(星光 2):DCSR 寄存器不支持修改 stepie 字段,边界断点调整为设置在 ecall 前一条指令(而非 interrupt handler 入口)

在 osgdb 中,边界断点还经历了以下改造:

  • 函数名边界断点:支持在 border_breakpoints 中通过 function 字段按函数名设置边界断点——相比按行号设置,函数名方式不随代码行号变动而失效;同时支持按函数名删除,并管理函数名边界断点在断点组切换时的生命周期
  • 标记(marker)机制:支持在源码注释中放置标记,扫描识别后自动设置边界/钩子断点
  • 切换时序修复:当 PC 已切换到内核后再处理边界断点逻辑;user_to_kernel 边界的检测改用 PC 地址检查,防止误判

4. 钩子断点#

钩子断点是可在触发时执行自定义 JavaScript 函数的断点。核心用途:设在内核的进程调度相关代码处(如 exec),触发时自动获取即将被调度的用户进程名称,函数返回值用于设置 nextBreakpointGroup,使调试器知道接下来加载哪个用户程序的符号表和断点。

钩子断点在 osgdb 中的补充:

  • 动态读取 Rust String 变量:钩子函数需要在运行时读取被调试对象的变量(如 sys_execve 的 path 参数)。难点在于 Rust String 的内部结构随 Rust 版本变化——旧版 Rust(rCore 使用)中字符串数据指针的字段路径为 String.vec.buf.ptr.pointer.pointer,新版 Rust(StarryOS 使用)则多了 inner 一层(String.vec.buf.inner.ptr.pointer.pointer),硬编码字段路径只能支持一个 Rust 版本。解决方案是利用 GDB 顺序处理命令的保证:新增 captureConsoleOutput 方法,用命令 token 圈定 interpreter-exec console 的 console 输出范围,让 GDB 自己完成结构解析,再正则提取指针与长度、读取内存——自动兼容所有 Rust 版本,且仅需两条命令。备用的 fallback 字段路径方案(新旧版本分别尝试)保留在代码中。该方案在 StarryOS 调试环境中验证通过(成功捕获 /usr/bin/env)。
  • 钩子切换创建的断点组会自动注入 user_to_kernel 边界断点,保证后续特权级切换正常工作

VS Code 前端交互#

  • 断点同步:利用 VS Code 的 debug.addBreakpoints API,使调试器自动设置的断点也能在界面中正确显示
  • 侧边栏 TreeView:寄存器查看(RISC-V)、内存读写、断点表展示

OSGDB 本身没有 VS Code 可视化面板;Async Inspector 面板属于异步调试功能(async-debug),见异步调试设计与实现。

配置机制#

所有调试行为均通过 launch.json 配置,其中两个映射函数是断点组机制的关键:

  • filePathToBreakpointGroupNames:文件路径 → 断点组(源码文件属于哪个断点组)
  • breakpointGroupNameToDebugFilePaths:断点组 → 调试文件(断点组对应加载哪个符号文件)

配合 ${workspaceFolder} 等 VS Code 变量替换,同一份配置可在不同机器上使用。