Chapter 11 · Rust 嵌入式开发

调试:probe-rs 与 defmt

没有 println 的世界如何调试:probe-rs 统一调试器生态、RTT 高速日志通道、defmt 把格式化推到主机端的高效日志、cargo embed / cargo flash 一键烧录,以及 panic 的精确定位。

课程进度92%

关键概念

probe-rs
纯 Rust 实现的嵌入式调试工具链,替代 OpenOCD + arm-none-eabi-gdb 的老组合。它直接对接 CMSIS-DAP、J-Link、ST-Link 等调试探针,提供烧录、RTT 读取、GDB server、内存/寄存器访问等能力。一套工具、跨平台、装 cargo install probe-rs-tools 即用,是现代 Rust 嵌入式调试的事实标准。
调试探针 (Debug Probe)
连接主机与目标 MCU 的硬件桥,通过 SWD(串行线调试)或 JTAG 协议访问芯片内核。常见有 ST-Link(STM32 板载)、J-Link、以及任意支持 CMSIS-DAP 的探针(如用 Raspberry Pi Pico 刷 picoprobe)。它让主机能烧写 Flash、读写内存、设断点、单步执行。
RTT (Real-Time Transfer)
SEGGER 发明的高速双向数据通道。原理:目标芯片在 RAM 里开一个环形缓冲区,程序把日志写进去,调试探针通过 SWD 后台读走——不占用串口、几乎不阻塞 CPU(写内存即返回),速度远超 UART。probe-rs 内置 RTT 支持,是 defmt 日志的默认传输载体。
defmt (deferred formatting)
专为嵌入式设计的日志框架。核心创意:格式化推迟到主机端——设备只发送"日志 ID + 原始参数字节",格式字符串留在主机的 ELF 里,由主机工具还原成完整文本。这样固件里不必存长字符串、不做设备端格式化,二进制体积和运行开销都极小,却能打出丰富的结构化日志。
defmt-rtt 与 defmt-decoder
defmt-rtt 是 defmt 的传输后端,把编码后的日志字节写进 RTT 缓冲;主机侧的 defmt-decoder(集成在 probe-rs 里)读取字节、查 ELF 符号表还原文本。二者配合实现"设备端极简、主机端丰富"的日志。用 use defmt_rtt as _; 引入后端即可。
cargo embed / cargo flash
probe-rs 提供的 cargo 子命令。cargo flash --chip STM32F411CEUx 编译并烧录固件;cargo embed 更进一步——烧录后自动打开 RTT 终端显示 defmt 日志,还能配 GDB。配合 .cargo/config.tomlrunner 设成 probe-rs run,直接 cargo run 就能烧录+看日志,体验接近桌面开发。
panic-probe
配合 defmt 的 panic 处理器。程序 panic 时,它通过 defmt 打印 panic 信息(含位置),然后触发一个断点让 probe-rs 捕获——你能立刻在主机看到"哪一行 panic 了、消息是什么"。相比 panic-halt(静默死循环)或 panic-reset,它让 panic 从"黑盒卡死"变成"可定位的报错"。
断点与单步 (GDB / probe-rs)
硬件断点由 Cortex-M 的 FPB 单元提供(数量有限,通常几个)。probe-rs 可作 GDB server 让 gdb 连上设断点、单步、看变量;也支持 VS Code 的 probe-rs-debugger 扩展做图形化调试。日志(defmt)适合看"发生了什么流",断点适合"停下来看某时刻的状态",两者互补。

调试数据是怎么流回主机的

理解 RTT + defmt 的数据流,就明白了为什么它比串口 println 又快又省:

defmt + RTT 日志链路 ═══════════════════════════════════════════════════ 目标 MCU 主机 PC ┌─────────────────────┐ ┌──────────────────────┐ │ defmt::info!(...) │ │ probe-rs run │ │ ↓ 只编码 ID+参数 │ │ ↓ 读 RTT 字节 │ │ defmt-rtt 写入 │ SWD 后台 │ defmt-decoder │ │ RTT RAM 环形缓冲 │ ══读取═══▶ │ ↓ 查 ELF 符号表 │ └─────────┬───────────┘ │ 还原完整文本 + 时间戳 │ │ 调试探针 (SWD) └──────────────────────┘ ▼ 写内存即返回,CPU 几乎零阻塞 对比 UART println:设备端格式化 + 逐字节发 → 慢且占 Flash defmt:设备只发几字节 ID + 原始参数 → 格式串留主机
为什么不用串口 println 就好?

串口 println 有三宗罪:① 格式化在设备端做,core::fmt 代码体积大、耗 CPU;② 完整字符串存进 Flash,占宝贵空间;③ UART 逐字节发,115200 波特下打一行要几毫秒,会拖慢时序敏感代码。defmt 全部避开:设备只发日志 ID 和原始数值(几字节),字符串和格式化都在主机。同样一句日志,defmt 的固件开销可能是 println 的几十分之一。

项目配置:一次配好,cargo run 直接调试

典型的 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 打印

// 派生 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!,发布时一键关掉。

日志 vs 断点:两种调试姿势

手段看什么优点局限
defmt 日志程序运行的"流"不停机、可看时序、开销极小只能看你打点的地方
GDB 断点某时刻的完整状态可看所有变量/寄存器/栈停机破坏实时性、硬件断点有限
panic-probe崩溃的现场自动定位 panic 行与消息仅崩溃时触发
RTT 原始通道自定义高速数据流可传二进制/波形数据需自己定协议解析
HardFault 与栈回溯

当发生非法内存访问、除零、栈溢出(未用 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 + RTTprobe-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.tomlrunner = "probe-rs run"cargo run 即烧录+看日志。panic-probe 让 panic 经 defmt 精确报位并断点。日志看"流"、GDB 断点看"某时刻状态"、二者互补;HardFault 配合 CFSR 寄存器解析与 flip-link 可从黑盒卡死变成可定位报错。