调试:probe-rs 与 defmt
没有 println 的世界如何调试:probe-rs 统一调试器生态、RTT 高速日志通道、defmt 把格式化推到主机端的高效日志、cargo embed / cargo flash 一键烧录,以及 panic 的精确定位。
没有 println 的世界如何调试:probe-rs 统一调试器生态、RTT 高速日志通道、defmt 把格式化推到主机端的高效日志、cargo embed / cargo flash 一键烧录,以及 panic 的精确定位。
cargo install probe-rs-tools 即用,是现代 Rust 嵌入式调试的事实标准。picoprobe)。它让主机能烧写 Flash、读写内存、设断点、单步执行。defmt-rtt 是 defmt 的传输后端,把编码后的日志字节写进 RTT 缓冲;主机侧的 defmt-decoder(集成在 probe-rs 里)读取字节、查 ELF 符号表还原文本。二者配合实现"设备端极简、主机端丰富"的日志。用 use defmt_rtt as _; 引入后端即可。cargo flash --chip STM32F411CEUx 编译并烧录固件;cargo embed 更进一步——烧录后自动打开 RTT 终端显示 defmt 日志,还能配 GDB。配合 .cargo/config.toml 把 runner 设成 probe-rs run,直接 cargo run 就能烧录+看日志,体验接近桌面开发。panic-halt(静默死循环)或 panic-reset,它让 panic 从"黑盒卡死"变成"可定位的报错"。gdb 连上设断点、单步、看变量;也支持 VS Code 的 probe-rs-debugger 扩展做图形化调试。日志(defmt)适合看"发生了什么流",断点适合"停下来看某时刻的状态",两者互补。理解 RTT + defmt 的数据流,就明白了为什么它比串口 println 又快又省:
串口 println 有三宗罪:① 格式化在设备端做,core::fmt 代码体积大、耗 CPU;② 完整字符串存进 Flash,占宝贵空间;③ UART 逐字节发,115200 波特下打一行要几毫秒,会拖慢时序敏感代码。defmt 全部避开:设备只发日志 ID 和原始数值(几字节),字符串和格式化都在主机。同样一句日志,defmt 的固件开销可能是 println 的几十分之一。
典型的 probe-rs + defmt 配置分三处:.cargo/config.toml(runner)、Cargo.toml(依赖与 feature)、代码里引入后端:
// .cargo/config.toml —— 让 cargo run 自动烧录并打开 RTT 看日志 // [target.thumbv7em-none-eabihf] // runner = "probe-rs run --chip STM32F411CEUx" // rustflags = ["-C", "linker=flip-link", # 栈溢出保护(第9章) // "-C", "link-arg=-Tlink.x", # cortex-m-rt 链接脚本 // "-C", "link-arg=-Tdefmt.x"] # defmt 需要的额外段 // [env] // DEFMT_LOG = "debug" # 编译期日志级别过滤 // —— main.rs:引入 defmt 后端与 panic 处理器 —— #![no_std] #![no_main] use defmt_rtt as _; // defmt 的 RTT 传输后端(只需引入,副作用注册) use panic_probe as _; // panic 时经 defmt 打印并断点,主机可见 use cortex_m_rt::entry; #[entry] fn main() -> ! { let temp = 23.5_f32; let id = 0xAB_u8; // 结构化日志:{} 占位,参数原样发出,主机端还原格式 defmt::info!("boot ok, sensor id={:#x}", id); defmt::debug!("temp = {} C", temp); defmt::warn!("threshold near limit"); let arr = [1_u16, 2, 3]; defmt::info!("samples = {:?}", arr); // 支持派生 Format 的复杂类型 assert!(temp < 100.0, "over temp!"); // 失败经 panic-probe 精确报位 loop {} }
// 派生 defmt::Format,即可用 {:?} 打印,编码高效(非文本) #[derive(defmt::Format)] struct Reading { channel: u8, value: u16, } let r = Reading { channel: 2, value: 1023 }; defmt::info!("got {:?}", r); // 主机显示:got Reading { channel: 2, value: 1023 }
defmt 的级别过滤靠环境变量 DEFMT_LOG 在编译期决定:设成 info 时,所有 debug!/trace! 调用会被完全编译掉,不留任何代码和字符串。这与桌面日志库运行期判断级别不同——生产固件里把级别调高,那些调试日志就彻底消失,不占 Flash、不耗 CPU。所以可以放心地在开发时写大量 debug!,发布时一键关掉。
| 手段 | 看什么 | 优点 | 局限 |
|---|---|---|---|
| defmt 日志 | 程序运行的"流" | 不停机、可看时序、开销极小 | 只能看你打点的地方 |
| GDB 断点 | 某时刻的完整状态 | 可看所有变量/寄存器/栈 | 停机破坏实时性、硬件断点有限 |
| panic-probe | 崩溃的现场 | 自动定位 panic 行与消息 | 仅崩溃时触发 |
| RTT 原始通道 | 自定义高速数据流 | 可传二进制/波形数据 | 需自己定协议解析 |
当发生非法内存访问、除零、栈溢出(未用 flip-link 时)等错误,Cortex-M 会进入 HardFault。默认 cortex-m-rt 提供一个死循环的 HardFault 处理器——现场信息全丢。更好的做法是配合 defmt 打印异常寄存器(SCB 里的 CFSR/HFSR 告诉你故障类型),或用 probe-rs 的 --catch-hardfault 停在故障点看调用栈。再叠加第 9 章的 flip-link,把静默栈溢出变成可见的 HardFault,调试体验会好非常多。
嵌入式没有标准输出,Rust 的现代调试链是 probe-rs + defmt + RTT。probe-rs(纯 Rust,替代 OpenOCD+GDB 老组合)经调试探针(ST-Link/J-Link/CMSIS-DAP)通过 SWD 访问芯片,提供烧录、RTT、GDB server。RTT 在目标 RAM 开环形缓冲,程序写内存即返回、探针后台读走,几乎零阻塞、远快于 UART。defmt 的核心是延迟格式化:设备只发日志 ID + 原始参数字节,格式字符串留在主机 ELF 里由 defmt-decoder 还原,固件体积与运行开销都极小;用 defmt-rtt 作后端、派生 #[derive(defmt::Format)] 打印自定义类型、DEFMT_LOG 在编译期过滤级别(关掉的日志彻底消失)。配好 .cargo/config.toml 的 runner = "probe-rs run" 后 cargo run 即烧录+看日志。panic-probe 让 panic 经 defmt 精确报位并断点。日志看"流"、GDB 断点看"某时刻状态"、二者互补;HardFault 配合 CFSR 寄存器解析与 flip-link 可从黑盒卡死变成可定位报错。