cargo runner 与 defmt 三件套
主线位置:命令主线的两头。起点是
cargo run调起probe-rs run,终点是轮询循环读到 RTT 字节、在客户端解码,或者看到核停在 HardFault 上、打印调用栈后退出。中间每一步在一条 probe-rs run里走过一遍,本页只看固件一侧放了什么、probe-rs 怎么用它。
flowchart TB
cfg[".cargo/config.toml:runner、-Tdefmt.x、DEFMT_LOG"] --> build["cargo build:ELF"]
build --> sec[".defmt:格式串表,不加载"]
build --> cb[".data:_SEGGER_RTT,名为 defmt 的通道"]
build --> et[".embedded_test:有就进测试模式"]
build --> run["probe-rs run --chip ... ELF"]
run --> meta["parse_metadata:找 _SEGGER_RTT、读 defmt 表"]
run --> mode{"detect_run_mode"}
mode -->|普通| mon["monitor:轮询 RTT,HardFault 时打印调用栈"]
mode -->|测试| tst["每个用例:reset_and_halt、run、等 SYS_EXIT"]
本页的实验固件:cortex-m-rt 0.7.7、defmt 1.2.0、defmt-rtt 1.3.1、panic-probe 1.0.0(开 print-defmt),目标 thumbv7em-none-eabihf,内存图按 STM32L475VG,release 构建带调试信息。依赖全部来自本机缓存,离线编译。它做的事很少:打几行日志,递归三层,到底以后原地自旋。
runner:cargo 只是追加一个路径
.cargo/config.toml:
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32L475VGTx"
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"]
[env]
DEFMT_LOG = "info"
cargo 编译完把 ELF 的路径接在 runner 后面执行。本机没有探针,cargo run 的结果:
$ cargo run --release
Finished `release` profile [optimized + debuginfo] target(s) in 0.02s
Running `probe-rs run --chip STM32L475VGTx <target>/thumbv7em-none-eabihf/release/defmt-demo`
Error: No connected probes were found.
停在选探针这一步,原因见常见坑:No connected probes were found.。runner 里的芯片名也可以不写,改由固件依赖 probe-rs-meta、用 chip! 宏写进 ELF(目标描述)。另外两行是 defmt 要的:-Tdefmt.x 是 defmt 提供的链接脚本,负责放 .defmt 段;DEFMT_LOG 在编译期决定哪些级别的日志被编进固件,低于它的 defmt::debug! 之类直接不生成代码。
defmt:格式串留在 ELF 里
defmt::info!("level {=u32}", depth) 在编译期把格式串变成一个符号名,符号放在 .defmt 段里;固件运行时只发这个符号的地址(索引)和参数的二进制编码。看实验固件的 ELF:
Idx Name Size VMA LMA Type
1 .vector_table 00000400 08000000 08000000 DATA
2 .text 00001738 08000400 08000400 TEXT
3 .rodata 00000244 08001b38 08001b38 DATA
4 .data 00000038 20000000 08001d7c DATA
6 .bss 00000010 20000038 20000038 BSS
7 .uninit 00000400 20000048 20000048 BSS
8 .defmt 0000000a 00000000 00000000
00000003 g O .defmt 00000001 {"package":"defmt-demo","tag":"defmt_info","data":"hello from {=str}",...}
00000004 g O .defmt 00000001 {"package":"defmt-demo","tag":"defmt_info","data":"level {=u32}",...}
00000005 g O .defmt 00000001 {"package":"defmt-demo","tag":"defmt_info","data":"reached the bottom, spinning",...}
00000006 g O .defmt 00000001 {"package":"defmt-demo","tag":"defmt_error","data":"panicked at 'unreachable: {}'",...}
00000007 g O .defmt 00000001 {"package":"panic-probe","tag":"defmt_error","data":"{}",...}
00000008 g O .defmt 00000001 _defmt_encoding_ = rzcobs
.defmt 的地址从 0 开始、没有加载地址,烧录时不会写进芯片(从镜像到烧录计划只取 PT_LOAD 段)。每个符号占 1 字节,地址就是索引:level {=u32} 的索引是 4。整个段只有 10 字节,而符号名里的 JSON 留在符号表里,这就是「格式串不进芯片」的实际形态。
probe-rs 这一侧,命令行在读 ELF 时用 defmt_decoder::Table::parse 把这些符号读成表,解码在客户端做(defmt 与 semihosting)。所以用来解码的 ELF 必须和芯片里跑的是同一个;strip 过的 ELF 连符号表都没有,.defmt 段也跟着没了,既找不到控制块也解不了码。
defmt-rtt:控制块和一个通道
defmt-rtt 1.3.1 的全部状态是三个 static:
| 符号 | 段 | 地址(实验固件) | 内容 |
|---|---|---|---|
_SEGGER_RTT | .data | 0x20000008,48 字节 | 24 字节的头(魔数、1 个 up 通道、0 个 down 通道)加一个通道描述;带初值,所以复位后启动代码一拷完 .data 控制块就在 |
NAME | .data | 0x20000000 | b"defmt\0",通道名 |
BUFFER | .uninit | 0x20000048,1024 字节 | 环形缓冲区;大小由编译期环境变量 DEFMT_RTT_BUFFER_SIZE 决定,默认 1024 |
通道的 flags 初值是 MODE_NON_BLOCKING_TRIM(1):没有主机读的时候,缓冲区满了就截断,固件不会卡住。probe-rs 连上以后,命令行把名叫 defmt 的通道改成 BlockIfFull(defmt 与 semihosting),从此缓冲区满了固件就等主机读。这个改动写在芯片的 RAM 里,probe-rs 退出时并不改回去;按源码推断,此后固件再把缓冲区写满就会一直等下去,直到复位。真板子上的表现待实测。
控制块在 .data 里、地址在符号表里,所以 parse_metadata 直接拿到 ScanRegion::Exact(0x20000008),不用扫描 RAM(RTT 控制块与通道)。defmt-rtt 的源码注释还说明了为什么它自己声明控制块:rtt-target 允许往任意通道写任意数据,会把 defmt 帧写坏,所以两者不能同时用。
panic-probe:让 probe-rs 看见 panic
panic-probe 1.0.0 的 panic 处理函数只有几步:关中断;第一次 panic 时用 defmt::error!("{}", Display2Format(info)) 打出 panic 信息(print-defmt feature);然后调 hard_fault():
// panic-probe 1.0.0,src/lib.rs
pub fn hard_fault() -> ! {
#[cfg(not(any(armv6m, armv8m_base)))]
{
const SHCSR: *mut u32 = 0xE000ED24usize as _;
const USGFAULTENA: usize = 18;
unsafe {
let mut shcsr = core::ptr::read_volatile(SHCSR);
shcsr &= !(1 << USGFAULTENA);
core::ptr::write_volatile(SHCSR, shcsr);
}
}
cortex_m::asm::udf();
}
先关掉 UsageFault,再执行一条未定义指令,于是 UsageFault 升级成 HardFault。probe-rs run 默认打开 HardFault 的 vector catch(--no-catch-hardfault 才关,NormalRunOptions),核一进 HardFault 就停下。轮询循环看到停下的原因不是 semihosting,handle_halt 返回 UnexpectedExit,客户端打印「Firmware exited unexpectedly」、展开并打印调用栈,以错误退出(cli.rs:827)。
栈展开一章的 nRF52833 夹具展示的正是这条路:第 2 帧是「HardFault <Cause: Escalated UsageFault <Cause: Undefined instruction>>」,第 3、4 帧是 udf。也就是说,「Escalated UsageFault」就是 panic-probe 留下的指纹。
| 结束方式 | 固件做了什么 | probe-rs 的反应 |
|---|---|---|
| 正常结束 | semihosting SYS_EXIT,原因 0x20026 | 「Firmware exited successfully」,退出码 0 |
debug::exit(EXIT_FAILURE) | SYS_EXIT,原因 0x20023 | 「Firmware exited with: Unknown runtime error」,打印调用栈,非 0 |
panic(panic-probe) | defmt 打印、udf、HardFault | 「Firmware exited unexpectedly」,打印调用栈,非 0 |
| 一直跑 | 无 | 等 Ctrl-C:「Exited by user request」 |
这几行文字和「什么时候打印调用栈」都在 cli::monitor 的结尾;成功退出和 Ctrl-C 只在加了 --always-print-stacktrace 时打印。没有板子,实际终端输出待实测。
embedded-test:同一个 runner 跑测试
cargo test 用同一个 runner。Cmd::run 第一行是 detect_run_mode:ELF 里有 .embedded_test 段和 EMBEDDED_TEST_VERSION 符号,就进测试模式;没有却给了 --list、--exact 这类测试参数,直接报错。所以普通固件和测试固件可以共用一行 runner,参数还兼容 libtest,IDE 的测试按钮可以直接调。
用例怎么来,看协议版本(ElfReader::decode):
| 版本 | embedded-test | 用例从哪来 |
|---|---|---|
| 0 | 0.7 之前 | 先把固件跑起来,固件用自定义的 semihosting 操作 0x100 把 JSON 形式的用例表交给主机 |
| 1 | 0.7 起 | 直接从 ELF 读:.embedded_test 段里每个 12 字节的全局数据符号是一个用例,内容是函数地址加模块路径,符号名本身是一段 JSON(名字、should_panic、超时),和 defmt 把格式串藏进符号名是同一个手法(try_decode_testcase_sym) |
| 其他 | 更新的 | 「not yet supported by probe-rs. Update probe-rs?」 |
每个用例单独跑一遍(run_test_impl,test_threads 固定为 1):
sequenceDiagram participant P as probe-rs participant F as 固件 P->>F: reset_and_halt,run F->>P: SYS_GET_CMDLINE P->>F: 写回 run_addr 加函数地址 F->>F: 跑这一个用例 F->>P: SYS_EXIT:成功或失败 P->>P: 和 should_panic 比,记结果
RunEventHandler::handle_halt 只认 semihosting:SYS_EXIT 成功算 Pass、失败算 Panic,超时默认 60 秒。核因为别的原因停下(比如 HardFault)不是「这个用例失败」,而是整个测试中止,报错原文是「The CPU halted unexpectedly: … Test should signal failure via a panic handler that calls semihosting::process::abort() instead」。换句话说,测试固件不能用 panic-probe,panic 要走 semihosting 退出。本机的依赖缓存里没有 embedded-test,固件一侧没有编译,上面只是 probe-rs 这一侧的源码。
小结
- runner 只是让 cargo 把 ELF 路径接在
probe-rs run --chip …后面;defmt 还要-Tdefmt.x和编译期的DEFMT_LOG。 - defmt 的格式串是
.defmt段里的符号名,段不加载,地址就是索引;解码用主机上的 ELF,必须和芯片里的一致。 defmt-rtt的控制块带初值放在.data,地址来自符号表,probe-rs 不用扫描;通道初始不阻塞,probe-rs 连上后改成BlockIfFull,退出后不改回。panic-probe关掉 UsageFault 再执行udf,制造一次「Escalated UsageFault」的 HardFault,靠probe-rs run默认的 vector catch 停下、打印调用栈。embedded-test的固件由.embedded_test段识别;每个用例复位一次,经SYS_GET_CMDLINE领到要跑的函数地址,以SYS_EXIT交回结果;HardFault 会中止全部测试。