gdb server:gdbstub 上的一层薄壳
主线位置:调试主线的另一个出口。GDB / lldb 讲的是 GDB 远程串行协议(RSP),
probe-rs gdb把它翻译成Core上的 halt、读寄存器、读内存、设硬件断点。完整的源码级调试(栈展开、变量)在 GDB 自己那一侧做,probe-rs-debug 完全不参与。
flowchart TB gdbc["GDB / lldb"] -->|"RSP 包,TCP"| sm["gdbstub 状态机"] sm --> rt["RuntimeTarget:gdbstub 的 Target trait"] rt --> base["MultiThreadBase:寄存器、内存"] rt --> bp["Breakpoints:只有 HwBreakpoint"] rt --> res["MultiThreadResume / SingleStep"] rt --> ext["target.xml、memory-map、monitor、flash"] base --> sess["FairMutex 包着的 Session"] bp --> sess res --> sess sess --> core["Core:DHCSR、DCRSR、FPB"]
cmd/gdb_server/ 一共 1 938 行(含快照测试),是 probe-rs 里最薄的前端。协议解析、包的收发、qSupported 的协商全交给 gdbstub 这个 crate,probe-rs 只实现 gdbstub 的一组 Target 扩展 trait。FakeProbe 一章已经用 gdb --dry-run 加 lldb 对照过每条 lldb 命令落到哪些调试寄存器(FakeProbe 的实验四);本页讲这层壳本身,再用 lldb 的包日志补上协议一侧。
入口:不走 RPC
probe-rs gdb 是少数不经过本地 RPC 的子命令(为什么其他命令要经过 RPC,见 CLI 的 RPC 架构)。Cmd::run 第一行就是 simple_attach:加载芯片描述、选目标、开探针、attach_session,三步都在本进程里直接调库。所以 --dry-run 在这里真的换上了 FakeProbe(一条 probe-rs run 讲过它为什么对 run 无效)。
之后的几件事:
| 步骤 | 源码 | 说明 |
|---|---|---|
--reset-halt | mod.rs:53 | 只对 0 号核 reset_and_halt |
| 地址 | 默认 localhost:1337 | --gdb-connection-string 覆盖 |
| 分组 | GdbInstanceConfiguration::from_session | 按核类型把核分组,每组一个端口,端口号依次加 1 |
--gdb | mod.rs:75-97 | 顺手起一个 GDB 子进程,参数 -ex "target remote <地址>",并屏蔽 Ctrl-C 让它传给 GDB |
| 主循环 | run | 轮询每个分组的 RuntimeTarget::process,取最短的等待时间 sleep |
分组的理由写在注释里:「GDB only supports one architecture per stub」。RP2040 两个 Cortex-M0+ 是同一类型,落在一个端口上,GDB 看到的是两个线程;一个 Cortex-A 加一个 Cortex-M 的芯片就会开两个端口。
Session 包在 parking_lot::FairMutex 里,每个 gdbstub 回调先 lock(),再 session.core(tid - 1):GDB 的线程号就是核号加 1。整个服务端是单线程同步的,没有 tokio,没有后台任务。
状态机:非阻塞地读一个字节
gdbstub 有两种用法:阻塞的事件循环,或者把状态机交给调用方自己驱动。probe-rs 用后者,因为它要在等 GDB 下一条命令的同时轮询目标有没有停下。RuntimeTarget::process 每次只推进一步:
stateDiagram-v2 Unconnected --> Idle: accept 后 halt_all_cores、load_target_desc Idle --> Idle: 没有字节,等 10 ms Idle --> Running: 收到 c 或 s Running --> Running: 核未停,等 10 ms Running --> Idle: 某核停下,halt 其余核,report_stop Running --> CtrlC: 收到 0x03 CtrlC --> Idle: halt_all_cores,报 SIGINT Idle --> Disconnected: D 或断线
- 未连接:
TcpListener设成非阻塞,accept返回WouldBlock就回 10 ms。连上之后先停住所有核(GDB 期望连上时目标是停着的),再读第一个核的寄存器组生成 target 描述,然后GdbStub::new(stream).run_state_machine(self)。 - Idle / Running:都是用
read_if_available先peek,有字节才读一个交给状态机。Running 状态下没有字节时,挨个核调status(),第一个停下的核决定停止原因,然后把其他核也停住,因为「GDB expects all or nothing stops」。
停止原因的映射只有三种:
// probe-rs-tools/src/bin/probe-rs/cmd/gdb_server/target/mod.rs
stop_reason = Some(match reason {
HaltReason::Breakpoint(BreakpointCause::Hardware)
| HaltReason::Breakpoint(BreakpointCause::Unknown) => {
// ...
MultiThreadStopReason::HwBreak(tid)
}
HaltReason::Step => MultiThreadStopReason::DoneStep,
_ => MultiThreadStopReason::SignalWithThread {
tid,
signal: Signal::SIGINT,
},
});
Cortex-M 的 DFSR 分不清硬件断点和 BKPT 指令(Cortex-M 内核),所以 Unknown 也当硬件断点报。HardFault 之类的 vector catch、semihosting 停下都落进最后一支,GDB 看到的是 SIGINT。
实现了哪些 gdbstub 扩展
impl Target for RuntimeTarget 列出了支持面:
| gdbstub 扩展 | probe-rs 的实现 | RSP 上的样子 |
|---|---|---|
BaseOps::MultiThread | base.rs:read_registers / write_registers 只覆盖第一个 feature 的寄存器;read_addrs 走 core.read,write_addrs 走 core.write_8;每个核一个线程 | g / G、p / P、m / M、qfThreadInfo |
| 单个寄存器读写 | 按 target 描述里的编号找寄存器 | p0、pf |
MultiThreadResume / SingleStep | resume.rs:继续是对本组所有核 run(),单步只对一个核 step() | vCont;c、vCont;s:p1.1 |
Breakpoints | breakpoints.rs:软件断点 None、硬件断点 Some、watchpoint None | 只认 Z1 / z1 |
| target 描述 XML | desc/data.rs 按核类型生成 | qXfer:features:read:target.xml |
| 内存映射 | gdb_memory_map 从目标描述生成 | qXfer:memory-map:read |
| flash 操作 | flash.rs:vFlashErase 什么都不做,vFlashWrite 攒进一个 FlashLoader,vFlashDone 时一次 commit | GDB 的 load |
monitor | monitor.rs:info、reset、reset halt 三条 | qRcmd,<hex> |
GDB 的 load 这条路复用了 06 烧录的整套 FlashLoader:GDB 按内存映射里的 blocksize 发擦除、按段发写入,probe-rs 不理会擦除命令,等到 vFlashDone 再自己决定擦哪些扇区。
断点与 watchpoint 的支持面
Breakpoints 三个方法,只有一个返回 Some:
// probe-rs-tools/src/bin/probe-rs/cmd/gdb_server/target/breakpoints.rs
impl Breakpoints for RuntimeTarget<'_> {
fn support_sw_breakpoint(&mut self) -> Option<SwBreakpointOps<'_, Self>> {
None
}
fn support_hw_breakpoint(&mut self) -> Option<HwBreakpointOps<'_, Self>> {
Some(self)
}
fn support_hw_watchpoint(&mut self) -> Option<HwWatchpointOps<'_, Self>> {
None
}
}
- 没有软件断点:代码多半在 flash 里,往 flash 写
BKPT要走一遍擦写,probe-rs 不做。GDB 的break默认先发Z0,得到空回复(不支持)后改发Z1。 - 硬件断点对组内所有核都设:
add_hw_breakpoint循环调core.set_hw_breakpoint(addr),用的就是 FPB 的比较器。 - 没有 watchpoint:Cortex-M 的 DWT 比较器在核心库里有(
HaltReason::Watchpoint也有),但 gdb server 没接,watch命令发的Z2/Z3/Z4一律不支持。
还有一个细节:add_hw_breakpoint 的错误走 into_target_result,除了「核未启用」映射成 EIO,其余错误都是 TargetError::Fatal。比较器用完时的 No available hardware breakpoints 因此不是一个回给 GDB 的错误码,而是让整个 gdbstub 状态机退出:服务端打印 During the execution of GDB an error was encountered:,Cmd::run 返回,进程结束。内存读写用的是 into_target_result_non_fatal,ARM / RISC-V / Xtensa 的架构错误回 EIO,GDB 可以继续。
实验:lldb 的包日志
用本机的 probe-rs gdb --dry-run --chip STM32F103C8 起服务端(背后是 FakeProbe),lldb 里先 log enable -f <文件> gdb-remote packets 再连接,执行几条 FakeProbe 一章没试过的命令。lldb 侧(节选):
(lldb) thread list
* thread #1: tid = 0x0001, 0x00000000, stop reason = signal SIGTRAP
(lldb) thread step-inst
* thread #1, stop reason = signal SIGINT
(lldb) process plugin packet monitor reset halt
Resetting and halting target
Target halted
packet: qRcmd,72657365742068616c74
response: OK
(lldb) process plugin packet send Z2,20000000,4
response:
error: UNIMPLEMENTED
包日志里能读出 gdbstub 替 probe-rs 回了什么(节选,$ 后是包体):
send packet: $qSupported:xmlRegisters=i386,arm,mips,arc;multiprocess+;fork-events+;vfork-events+;swbreak+;hwbreak+#cd
read packet: $PacketSize=1000;vContSupported+;multiprocess+;QStartNoAckMode+;hwbreak+;qXfer:features:read+;qXfer:memory-map:read+#72
send packet: $vCont?#49
read packet: $vCont;c;C;s;S#62
send packet: $qfThreadInfo#bb
read packet: $mp01.01#cd
send packet: $vCont;s:p1.1#f2
read packet: $T02thread:p01.01;#03
send packet: $Z0,8000100,4#6f
read packet: $#00
send packet: $Z1,8000100,4#70
逐条看:
qSupported的回复里有hwbreak+,没有swbreak+;有qXfer:features:read+(target.xml)和qXfer:memory-map:read+。这些全是 gdbstub 根据Target实现了哪些扩展自动拼出来的。- 线程:
qfThreadInfo回mp01.01,进程 1、线程 1,对应 0 号核。 - 单步回了
T02,也就是 SIGINT,而不是单步完成该有的T05。原因在FakeProbe:它的 DFSR 永远读 0(FakeProbe),HaltReason是Unknown,落进上面映射的最后一支。在真实内核上,Armv7m::step读到 DFSR 的HALTED会借pending_step把原因改成Step,之后status()读到已清零的 DFSR 时返回缓存的状态,gdb server 报DoneStep。这一点是按源码推的,待实测。 - 断点:lldb 先发
Z0(软件断点),空回复表示不支持;再发Z1。FakeProbe的 FP_CTRL 读出 0 个比较器,Z1触发 fatal error,服务端日志最后是:
Write 0xe0002000 = 0x00000003
Read 0xe0002000 = 0
During the execution of GDB an error was encountered:
Target threw a fatal error: No available hardware breakpoints
Z2(写 watchpoint)直接空回复,lldb 显示UNIMPLEMENTED。
target.xml 的内容(换行是本页加的):
<architecture>armv7</architecture>
<feature name='org.gnu.gdb.arm.m-profile'>
r0 … r12, sp(data_ptr), lr, pc(code_ptr), msp, psp, xpsr, extra, xpsr
</feature>
<feature name='org.gnu.gdb.arm.m-system'>msp, psp</feature>
build_cortex_m_registers 先加核心库给的 core_registers(),再加一次 psr(),再在 m-system 里加一次 msp / psp。对 Armv7m,core_registers() 本身已经含 msp、psp、xpsr 和 extra(CONTROL / FAULTMASK / BASEPRI / PRIMASK 拼成的一个字),所以 xpsr 在描述里出现两次,msp / psp 在两个 feature 里各一次。lldb 照单全收;GDB 遇到重名寄存器怎么处理,未核实。没有 FPU 的 STM32F103 没有 org.gnu.gdb.arm.vfp;有 FPU 时 s0–s31 两两拼成 d0–d15(TwoWordRegister),因为 GDB 的 vfp feature 只认双精度寄存器。
内存映射(qXfer:memory-map:read):
<memory type="ram" start="0x20000000" length="0x5000"/>
<memory type="flash" start="0x8000000" length="0x10000"><property name="blocksize">0x400</property></memory>
<memory type="flash" start="0x1ffff800" length="0x10"><property name="blocksize">0x10</property></memory>
目标描述里的 NVM 区不直接出现:只要芯片有 flash 算法,NVM 区就被跳过,改由每个算法的地址范围和扇区大小生成 flash 项(第二项是选项字节的算法)。GDB 据此决定 load 时哪些地址要走 vFlashWrite,哪些直接 M 写内存。0.32 修过这里的两个问题:扇区地址忘了加区域基址,导致 flash 不从 0 开始的芯片 load 失败(#4152);同一地址不同块大小的区域去重(#3978)。
gdbstub 为什么钉在 =0.7.8
probe-rs-tools/Cargo.toml 第 121–124 行:
# gdb server
# 0.7.9 causes this regression:
# https://github.com/probe-rs/probe-rs/issues/3805
gdbstub = "=0.7.8"
历史是这样的:#4021 把 "0.7.6"(锁文件里当时已经是 0.7.10)改成精确的 =0.7.8,CHANGELOG 写的是「Downgrade gdbstub to version 0.7.8 to avoid MultiThread regression #3805」,提交标题是「restore basic gdb usage」;一个多月后依赖机器人的「Non-breaking updates」(#4078)又把它改成 =0.7.10,同一天 #4080「Unbreak gdbstub」改回 =0.7.8。用 = 是为了挡住这类自动升级。回归的具体内容在上游 issue 里,本机不联网,未核实;从 CHANGELOG 的用词看,问题出在 probe-rs 用的 MultiThread 那套 API 上。
和 DAP server 的分工
| gdb server | DAP server | |
|---|---|---|
| 协议 | RSP,gdbstub 实现 | DAP(JSON),自己实现 |
| 栈展开、变量 | GDB 自己读 ELF 做 | probe-rs-debug 在服务端做 |
| 断点 | 只有地址断点(硬件) | 源码行断点、指令断点,都落到硬件断点 |
| watchpoint | 不支持 | 不支持(capabilities 里没有 data breakpoint) |
| RTT / semihosting | 不处理 | 处理 |
| 多核 | 同类型的核作为线程 | 只调一个核(core_configs 必须恰好一个) |
小结
probe-rs gdb不走 RPC:simple_attach直接建Session,所以--dry-run有效;按核类型分组,每组一个 TCP 端口,组内每个核是一个 GDB 线程。- 服务端是单线程的非阻塞轮询:
peek有字节才推进 gdbstub 状态机,Running 时每 10 ms 查一次各核状态;任一核停下就停住其余核。 - 支持面:多线程寄存器 / 内存、继续与单步、硬件断点、target.xml、内存映射、
load、三条monitor命令;没有软件断点和 watchpoint。lldb 抓包看到Z0、Z2都是空回复。 - 断点用完是 fatal error,整个 gdb 会话随之退出;内存访问错误则回
EIO。 - Cortex-M 的 target.xml 里
xpsr出现两次、msp/psp在两个 feature 里重复;GDB 上的影响未核实。 - gdbstub 精确钉在 0.7.8,因为 0.7.9 起有多线程回归,而且依赖机器人曾经把它升上去过一次。