defmt 与 semihosting
主线位置:命令主线第五步「轮询」的两个分支。轮询循环每轮先看核停没停,停在 semihosting 上就处理请求、立刻放行;没停就读 RTT,读到的字节推给客户端,名叫
defmt的通道在客户端解码。前者靠停核传数据,后者靠读内存,代价完全不同。
flowchart TB
poll["RunLoop::poll_once"] --> st{"core.status"}
st -->|"Halted:Breakpoint(Semihosting)"| semi["handle_halt:解码好的 SemihostingCommand"]
semi -->|"Exit"| stop["结束 run"]
semi -->|"Open、Write 等"| fm["SemihostingFileManager:stdout、文件、tcp"]
fm --> resume["core.run,下次轮询间隔 0"]
st -->|"Running"| rtt["RttPoller:读每个 up 通道"]
rtt --> ev["RttEvent::Output:原始字节送客户端"]
ev --> dec{"通道名"}
dec -->|"defmt"| defmt["DefmtProcessor:Table 来自本机 ELF"]
dec -->|"其他"| text["按 UTF-8 文本输出"]
defmt:固件只发索引
defmt 的思路是把格式字符串留在主机上。defmt::info!("x = {}", x) 编译时,格式字符串被放进 ELF 的 .defmt 段,固件运行时只发送这个字符串在段里的索引和参数的二进制编码;解码需要同一份 ELF。defmt-rtt 把这些帧写进一个 RTT up 通道,通道名就叫 defmt(defmt-rtt 1.3.1 里 static NAME: [u8; 6] = *b"defmt\0"),控制块 _SEGGER_RTT 是一个带初值的 static。
probe-rs 这一侧,命令行在读 ELF 时(parse_metadata)顺便用 defmt_decoder::Table::parse 读出字符串表和源码位置表(DefmtState)。连上 RTT、知道有哪些通道之后,on_channels_discovered 按名字给每个通道选解码器:
// probe-rs-tools/src/bin/probe-rs/util/cli.rs
for channel in up_channels.iter() {
let decoder = if channel.name == "defmt" {
if let Some(defmt_data) = self.defmt_data.clone() {
RttDecoder::Defmt {
processor: DefmtProcessor::new(
defmt_data,
self.show_timestamps,
self.show_location,
self.log_format.as_deref(),
),
}
} else {
// Not much we can do. Don't silently eat the data.
RttDecoder::BinaryLE
}
} else {
RttDecoder::String { /* ... */ }
};
判断只看名字:通道叫 defmt 而 ELF 里没有 defmt 数据时,原样输出二进制;别的名字一律当文本。
DefmtProcessor::process 把新到的字节喂给 defmt-decoder(1.0)的流式解码器,循环取出完整的帧,按帧的索引查源码位置、交给格式化器(oneline、full 或自定义格式);数据不够一帧时等下一批。遇到损坏的帧,编码支持恢复(defmt 的 rzCOBS 编码有帧边界)就跳过,否则报「Unrecoverable error」。解码器借用着 Table,两者又要放进同一个结构体,源码用一个 unsafe 的 transmute 把解码器的生命周期延长成 'static,靠字段的声明顺序保证解码器先于表被丢弃。本机的依赖缓存里没有 defmt-decoder,这一段只读了 probe-rs 这一侧的源码。
两个由此而来的性质:
- 解码在客户端。服务端只转发
RttEvent::Output里的原始字节(一条 probe-rs run),远程模式下探针所在的机器不需要 ELF 的调试信息,解码用的是本机那份 ELF。ELF 和芯片里的固件对不上时,打出来的是错乱的日志。 - defmt 通道默认阻塞。
RttClient::configure在用户没指定模式时,把名叫defmt的通道改成BlockIfFull(写控制块里的flags):缓冲区满了固件就等主机读,日志不丢,但主机读得慢时固件会被拖慢。命令行在读到数据时立刻再读、读空才等 100 ms,就是为了减轻这一点。实际的吞吐和延迟待实测。
RTT 本身的读写见 RTT 控制块与通道。
semihosting:停核传参
semihosting 是另一条路:固件执行 BKPT 0xAB,核停下,调试器从 R0 读出操作号、从 R1 读出参数(通常是一个指向参数块的指针),替固件做完这件事,把结果写回 R0,再让核继续。不需要 RTT 控制块,代价是每次调用都停一次核。
识别在内核层。Cortex-M 内核讲过,status 发现停下的原因是断点时,会读 PC 处的两个字节:是 0xBEAB 就调 decode_semihosting_syscall,原因变成 Breakpoint(Semihosting(命令))。RISC-V 的规则是 PC−4 起的三条指令(RISC-V 调试模块),解码共用。
解码按 ARM semihosting 规范的操作号分派,认得 SYS_OPEN、SYS_CLOSE、SYS_WRITEC、SYS_WRITE0、SYS_WRITE、SYS_READ、SYS_SEEK、SYS_FLEN、SYS_REMOVE、SYS_RENAME、SYS_TIME、SYS_ERRNO、SYS_GET_CMDLINE、SYS_EXIT、SYS_EXIT_EXTENDED,其他的成为 Unknown。一个细节:对需要回应的请求,解码时先往 R0 写 −1,这样即使上层不处理这个请求,固件也会看到「失败」而不是一个随机值。
命令行在轮询循环里处理(handle_halt):
| 请求 | 处理 |
|---|---|
SYS_EXIT,原因 ADP_Stopped_ApplicationExit(0x20026) | ExitSuccess,结束 run,退出码 0 |
SYS_EXIT,其他原因 | ExitError,结束 run,带上原因 |
SYS_OPEN / WRITE / READ 等 | 交给 SemihostingFileManager::tt 以 w 打开映射到 stdout、以 a 打开映射到 stderr;其他路径可以用选项映射到本机文件、tcp: 或 unix: 套接字 |
SYS_WRITEC / SYS_WRITE0 | WriteConsole,直接输出到 stdout |
SYS_TIME | 写回当前时间 |
SYS_GET_CMDLINE、未知操作 | 打警告,继续运行 |
处理完返回 None,轮询循环调 core.run(),并把下次轮询的间隔设成 0,以便连续的 semihosting 调用(比如一个字符串分几次写)尽快处理完。run 一开头的那次单步会碰到 PC 上的 BKPT 0xAB:单步前后 PC 不变、这个地址上又没有硬件断点,于是把 PC 加 2 跳过它(Cortex-M 内核的 step 第 4 点),固件从 BKPT 的下一条继续。
实验:在模拟目标上触发 semihosting
在模拟 SWD 目标上手工制造「固件刚执行了 BKPT 0xAB」的状态:0x08000200 处放 0xBEAB,PC 指向它,设好 R0、R1,置 DFSR 的 BKPT 位并停核,然后调一次 core.status():
== SYS_WRITE0: r0=0x4 r1=0x20000100 -> WriteConsole -> "Hello from hprintln!\n" (71 DP/AP transfers incl. core())
== SYS_EXIT ADP_Stopped_ApplicationExit: r0=0x18 r1=0x20026 -> Halted(Breakpoint(Semihosting(ExitSuccess))) (37 DP/AP transfers incl. core())
== SYS_EXIT other reason: r0=0x18 r1=0x20023 -> Halted(Breakpoint(Semihosting(ExitError(ExitErrorDetails { reason: 131107, exit_status: None, subcode: None })))) (37 DP/AP transfers incl. core())
== unknown op: r0=0x30 r1=0x0 -> Halted(Breakpoint(Semihosting(Unknown(UnknownCommandDetails { operation: 48, parameter: 0 })))) (43 DP/AP transfers incl. core())
一次 SYS_EXIT 的识别是 37 次 DP / AP 访问:建 Core 8 次,读 DHCSR、DFSR,读 PC 和 PC 处的两个字节,读 R0、R1,各 3 次左右。SYS_WRITE0 多出来的是逐块读那个以 0 结尾的字符串。每次 semihosting 调用都要付这个代价,再加上一次停核和一次放行(放行前还有一次单步),所以 semihosting 不适合频繁打印。0x20023(131107)是 cortex-m-semihosting 的 debug::exit(EXIT_FAILURE) 用的原因码 ADP_Stopped_RunTimeErrorUnknown。
hprintln:QEMU 和 probe-rs 下各怎么走
embassy 卷的实验在 QEMU 上用 cortex-m-semihosting 的 hprintln! 输出、用 debug::exit 结束。按 cortex-m-semihosting 0.5.0 的源码,hprintln! 先 SYS_OPEN(":tt", W_TRUNC) 拿到 stdout 的句柄,再对它 SYS_WRITE;debug::exit 是 SYS_EXIT。
QEMU(-semihosting) | probe-rs | |
|---|---|---|
BKPT 0xAB 由谁处理 | 模拟器在执行这条指令时直接处理,不经过任何调试协议 | 真核停下,probe-rs 在下一次轮询(最多 100 ms 后)发现 |
SYS_OPEN(":tt", "w") | QEMU 返回 stdout | open_tt:w 映射到 stdout |
SYS_WRITE | 写到 QEMU 的标准输出 | 读出数据,以 SemihostingEvent::Output 推给客户端打印 |
SYS_EXIT | QEMU 进程退出,退出码由原因决定 | run 结束,退出码由原因决定 |
| 代价 | 几乎为零 | 每次调用一次停核、几十次调试端口访问,加上等下一轮轮询 |
所以同一个固件换到真板子上,hprintln! 照样能用,只是慢得多;从 QEMU 换到真板子讲把 embassy 卷的固件改成 defmt + RTT 时要改哪几处。
小结
- defmt 固件只发索引,
defmt-rtt用名叫defmt的 RTT 通道;probe-rs 的服务端只转发原始字节,客户端用本机 ELF 里的表解码,名字不叫defmt的通道当文本处理。 defmt通道默认被设成BlockIfFull,日志不丢,但主机读得慢会拖慢固件。- semihosting 是停核传参:
status识别BKPT 0xAB,从 R0、R1 解码请求;命令行在轮询循环里执行,处理完立即放行,run先单步时把 PC 加 2 跳过BKPT。 - 模拟目标上识别一次
SYS_EXIT是 37 次调试端口访问,SYS_WRITE0是 71 次;QEMU 里同样的调用几乎没有代价。