十二条取舍
主线位置:三条主线的汇总。命令主线上的取舍是「插件」「编译期目标描述」「本地也走 RPC」「轮询」「客户端解码」;访问主线上的是「探针分层」「去掉 typestate」「短命的 Core」;调试主线上的是「只认 MemoryInterface」「展开策略」「gdb 只做硬件断点」。每一条只说收益和代价,机制在链接指向的页面里。
flowchart TB cmd["命令主线"] --> t1["1 注册表与插件"] cmd --> t2["2 编译期目标描述"] cmd --> t5["5 本地也走 RPC"] cmd --> t9["9 轮询"] cmd --> t10["10 客户端解码"] acc["访问主线"] --> t3["3 探针分层"] acc --> t4["4 去掉 typestate"] acc --> t6["6 短命的 Core"] dbg["调试主线"] --> t7["7 只认 MemoryInterface"] dbg --> t8["8 展开策略"] dbg --> t11["11 gdb 只做硬件断点"]
1. 四张全局表加插件
做法:探针驱动、厂商、镜像格式、目标四样东西各放在一张 static LazyLock<RwLock<Vec<…>>> 里,0.32 起由 plugin::register_plugin 统一往里加;ESP 支持和 Linux 探针都改成了插件(源码地图)。
换来:核心库里不再有 ESP 专用代码,espflash 这类依赖不会进到只调 STM32 的用户那里;厂商表插到最前面,用户的实现能覆盖内置的(Xtensa 与 ESP 插件)。
代价:初始化顺序成了调用方的责任。命令行在 main 第一行注册,库用户忘了就是一个找不到芯片的报错,而且报错里不会提示「你没注册插件」(常见坑:找不到 esp32c3)。全局可变状态还意味着同一进程里的两个使用者共享同一份表。
2. 目标描述编译期转 bincode
做法:build.rs 把 242 个 YAML 编成 bincode,include_bytes! 进库,首次访问时解码(目标描述)。
换来:运行时不解析 YAML;YAML 写错在编译期就让构建失败。
代价:首次解码在 debug 构建下是 57.6 ms,之后每次 Registry::from_builtin_families 都克隆整张表(4.4 ms),源码 TODO 提了「叠加层」的改法。加目标有两条路,容易混:PROBE_RS_TARGETS_DIR 是编译期变量,只在编译 probe-rs 时起作用;运行时要用 --chip-description-path 或 add_target_family_from_yaml(常见坑:PROBE_RS_TARGETS_DIR 设了不生效)。
3. 探针 trait 分层加 polyfill
做法:探针在哪一层接入由它的固件决定:CMSIS-DAP、Glasgow 实现 RawDapAccess(DAP 事务),J-Link、FTDI、CH347 只实现 RawSwdIo / RawJtagIo(位级),由 polyfill 的 blanket impl 补出 DAP 事务;ST-Link 这类自己做内存访问的,干脆实现自己的 ARM 调试接口(DebugProbe 与驱动注册表)。
换来:SELECT 缓存、上电、调试序列这些 DP / AP 逻辑只写一遍,所有走 ArmCommunicationInterface 的探针共享;本卷能在库外接上软件模拟目标,也是因为这些 trait 是公开的(写一个软件探针)。
代价:上层一次给一个寄存器访问,polyfill 没有机会把写 TAR 和读 DRW 合成一批,位级探针上一次 read_word_32 是 20 个 SWD 包,约是 DAP 层访问数的两倍(位级探针)。ST-Link 绕开了 DP 一级的调试序列钩子。
(一个容易误读的统计:polyfill 文件里的 16 处 todo!() / unimplemented!() 全在 #[cfg(test)] 的测试替身里,不在生产代码路径上。todo! 真正集中的地方是 FakeProbe(25 处,FakeProbe)和 CoreDump 的写方法。)
4. 0.30 去掉 typestate
做法:ArmCommunicationInterface<S> 的「未初始化 / 已初始化」两种类型状态,改成运行期的 current_dp: Option<DpAddress>(#3382,DP、AP 与 SELECT)。
换来:接口能装进 Box<dyn ArmDebugInterface> 长期存放;复位后要原地重连的芯片,reinitialize 就是清空再 select_dp 一次。
代价:「对未初始化的接口读 AP」从编译错误变成了运行期的懒初始化逻辑,正确性靠代码路径保证。
5. 本地也走 RPC
做法:run、download、info 等命令即使探针插在本机,也在同一进程里起一个 postcard-rpc 服务端,Session 留在服务端,客户端只拿 key(一条 probe-rs run、CLI 的 RPC 架构)。
换来:开了 remote feature 时,同一套命令代码改连远端的 probe-rs serve,一行不改;defmt 解码留在客户端,远端不需要固件的调试信息。
代价:
--dry-run对走 RPC 的命令失效:probe/select在看到dry_run之前就因为没有探针失败了(常见坑:No connected probes were found.)。- 不是所有入口都走:
gdb、cargo-embed用simple_attach直接建Session,dap-server自己建、自己有一套远程模式。对gdb来说,本地那个 RPC 服务端从头到尾空转。 - 错误穿过 RPC 后变成字符串:cargo-flash 为
NoProbesFound准备的安装提示因此不会出现(同一个坑页里有实测)。
6. Core 是短命视图
做法:状态全放在 Session 里,session.core(i) 每次新建一个借用 Session 的 Core;文档反复叮嘱不要长期持有它(Session、Core 与 MemoryInterface)。
换来:没有悬空的核句柄,多个使用者(gdb、RTT)轮流 lock 一个 Session 就行。
代价:ARM 上每次 core(i) 都重建 MEM-AP 状态,模拟目标上是 8 次 DP / AP 访问;命令行一次空的 RTT 轮询是 11 次访问,其中 8 次花在这里(RTT 控制块与通道)。另一个后果是库外的人造不出 Core(Core::new 是 pub(crate)),而 Rtt::attach_at 只收 &mut Core:接 QEMU 的实验就卡在这里。
7. 调试层只认 MemoryInterface
做法:probe-rs-debug 的入口只要一个 &mut impl MemoryInterface 和一组寄存器值(08 调试信息)。
换来:同一套展开、变量代码既能接活芯片,也能接 coredump;仓库用 coredump 夹具加 insta 快照测展开逻辑。本卷更进一步,把 QEMU 的 GDB 服务端包成一个 MemoryInterface,展开了 QEMU 里正在跑的固件(13 动手)。
代价:CoreDump 的写方法全是 todo!(),调试层对着 coredump 改变量会 panic 而不是报错;RTT、单步这些需要 Core 的功能不在这层抽象里,接不上非 probe-rs 的后端。
8. 只读 .debug_frame,每次停下全量展开
做法:DebugInfo 只读 .debug_frame 的 CFI,不读 .eh_frame(DebugInfo 与 DWARF);DAP server 每次由运行转为停下(包括每次单步)都调一次 unwind(…, 500)(DAP server)。
换来:嵌入式 Rust 的 ELF 都有 .debug_frame,一种格式就够;全量展开让 stackTrace 只是切片,实现简单。
代价:没有 CFI 的代码(naked 汇编、去掉调试信息的库)只能靠 LR 推一层(栈展开);每按一次 F10 都要读一遍整个调用栈,源码 TODO 原文是「we should be able to unwind incrementally as the user requests more frames on the UI」。真实探针上的耗时待实测。
9. 轮询而不是中断
做法:命令行每 100 ms 查一次 halt、读一遍 RTT,有数据时立刻再读;DAP server 核运行时 50 ms 一轮(一条 probe-rs run)。注释给的理由是「If the polling frequency is too high, the USB connection to the probe can become unstable.」
换来:不需要目标和探针有任何通知机制,任何探针都能用。
代价:semihosting 调用和 HardFault 最多要等一个轮询周期才被发现;为了不丢日志,命令行把 defmt 通道改成 BlockIfFull,主机读得慢会拖慢固件,而且 probe-rs 退出后这个模式留在芯片里(cargo runner 与 defmt 三件套)。
10. defmt 在客户端解码
做法:服务端只转发 RTT 原始字节,客户端用本机 ELF 里的 .defmt 表解码(defmt 与 semihosting)。
换来:远端不需要 ELF 的调试信息;固件里只有索引和参数,日志又快又小。
代价:ELF 和芯片里的固件对不上时日志是乱的,没有任何校验;strip 过的 ELF 连控制块地址都找不到,命令行就安静地不读 RTT(常见坑:RTT 什么都不打印)。
11. gdb server 只做硬件断点
做法:gdbstub 上的一层薄壳,Breakpoints 只实现硬件断点,没有软件断点和 watchpoint;完整的源码级体验放在 DAP 一侧(gdb server)。
换来:不用往 flash 里写 BKPT、不用为断点擦写扇区;整个 cmd/gdb_server/ 连快照测试在内 1 938 行。
代价:断点数等于 FPB 比较器数,用完时是 TargetError::Fatal,整个 gdb 会话退出,而不是一个回给 GDB 的错误码(常见坑:No available hardware breakpoints)。gdbstub 还得精确钉在 0.7.8。
12. TODO 集中在哪
按 tag 数 TODO 注释,最多的三个文件各 13 条:
| 文件 | 典型的 TODO |
|---|---|
probe/cmsisdap/mod.rs | 「Perform error handling -> clear fault, etc.」「Properly track what exactly failed」:块传输出错后的恢复还没做全(CMSIS-DAP 驱动) |
dap_server/.../adapter.rs | 多核(「until we do multicore」)、watch 表达式单步后不刷新、SVD 寄存器不能安全写 |
probe-rs-debug/src/unit_info.rs | 按地址找函数 DIE 是线性扫描(「binary-search for the function DIE」)、Rust 对 DWARF 的扩展没处理 |
还有一条值得单独引:烧录前装载 flash 算法时只做 reset_and_halt,flasher.rs:199 写着「TODO: we probably want a full system reset here to make sure peripherals don't interfere.」:外设在烧录过程中是否干扰算法,取决于芯片的复位做了多少(flash 算法)。
小结
- 可扩展性靠全局表和插件,代价是初始化顺序交给调用方;目标描述编译期嵌入,运行时追加走另一条路。
- 访问主线上,trait 分层让所有探针共享 DP / AP 逻辑,代价是位级探针上包数翻倍、每次
core()重建 MEM-AP。 - 命令行本地也走 RPC,换来本地远程同一套代码,付出
--dry-run失效和入口不统一。 - 调试层只认
MemoryInterface,所以 coredump 和 QEMU 都能接;需要Core的 RTT、单步接不上。 - 轮询、客户端解码、只做硬件断点,都是用「简单、通用」换「延迟、校验、断点数」。