Rust in Everything
基于 probe-rs-tools 0.32.0

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-haltmod.rs:53只对 0 号核 reset_and_halt
地址默认 localhost:1337--gdb-connection-string 覆盖
分组GdbInstanceConfiguration::from_session按核类型把核分组,每组一个端口,端口号依次加 1
--gdbmod.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::MultiThreadbase.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 / SingleStepresume.rs:继续是对本组所有核 run(),单步只对一个核 step()vCont;c、vCont;s:p1.1
Breakpointsbreakpoints.rs:软件断点 None、硬件断点 Some、watchpoint None只认 Z1 / z1
target 描述 XMLdesc/data.rs 按核类型生成qXfer:features:read:target.xml
内存映射gdb_memory_map 从目标描述生成qXfer:memory-map:read
flash 操作flash.rs:vFlashErase 什么都不做,vFlashWrite 攒进一个 FlashLoader,vFlashDone 时一次 commitGDB 的 load
monitormonitor.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

逐条看:

  1. qSupported 的回复里有 hwbreak+,没有 swbreak+;有 qXfer:features:read+(target.xml)和 qXfer:memory-map:read+。这些全是 gdbstub 根据 Target 实现了哪些扩展自动拼出来的。
  2. 线程:qfThreadInfo 回 mp01.01,进程 1、线程 1,对应 0 号核。
  3. 单步回了 T02,也就是 SIGINT,而不是单步完成该有的 T05。原因在 FakeProbe:它的 DFSR 永远读 0(FakeProbe),HaltReason 是 Unknown,落进上面映射的最后一支。在真实内核上,Armv7m::step 读到 DFSR 的 HALTED 会借 pending_step 把原因改成 Step,之后 status() 读到已清零的 DFSR 时返回缓存的状态,gdb server 报 DoneStep。这一点是按源码推的,待实测。
  4. 断点: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
  1. 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 serverDAP 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 起有多线程回归,而且依赖机器人曾经把它升上去过一次。

💬 关联讨论

发起讨论
加载中...