Rust in Everything
基于 probe-rs-tools 0.32.0

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_WRITE0WriteConsole,直接输出到 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 返回 stdoutopen_tt:w 映射到 stdout
SYS_WRITE写到 QEMU 的标准输出读出数据,以 SemihostingEvent::Output 推给客户端打印
SYS_EXITQEMU 进程退出,退出码由原因决定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 里同样的调用几乎没有代价。

💬 关联讨论

发起讨论
加载中...