MMIO and Volatile Register Access
MMIO 与 volatile 寄存器访问
What you’ll learn: Type-safe hardware register access in embedded Rust — volatile MMIO patterns, register abstraction crates, and how Rust’s type system can encode register permissions that C’s
volatilekeyword cannot.
本章将学到什么: 在嵌入式 Rust 里怎样以类型安全的方式访问硬件寄存器,包括 volatile MMIO 的基本模式、寄存器抽象 crate 的用法,以及 Rust 类型系统怎样表达 C 里单靠volatile根本表达不清的寄存器权限。
In C firmware, hardware registers are usually accessed through volatile pointers aimed at fixed memory addresses. Rust has equivalent mechanisms, but it can wrap them in much stronger type guarantees.
在 C 固件里,硬件寄存器通常就是靠指向固定内存地址的 volatile 指针访问。Rust 也有对应手段,但它能把这件事包进更强的类型约束里,而不是全靠人肉小心。
C volatile vs Rust volatile
C 的 volatile 和 Rust 的 volatile
// C — typical MMIO register access
#define GPIO_BASE 0x40020000
#define GPIO_MODER (*(volatile uint32_t*)(GPIO_BASE + 0x00))
#define GPIO_ODR (*(volatile uint32_t*)(GPIO_BASE + 0x14))
void toggle_led(void) {
GPIO_ODR ^= (1 << 5); // Toggle pin 5
}
#![allow(unused)]
fn main() {
// Rust — raw volatile (low-level, rarely used directly)
use core::ptr;
const GPIO_BASE: usize = 0x4002_0000;
const GPIO_ODR: *mut u32 = (GPIO_BASE + 0x14) as *mut u32;
/// # Safety
/// Caller must ensure GPIO_BASE is a valid mapped peripheral address.
unsafe fn toggle_led() {
// SAFETY: GPIO_ODR is a valid memory-mapped register address.
let current = unsafe { ptr::read_volatile(GPIO_ODR) };
unsafe { ptr::write_volatile(GPIO_ODR, current ^ (1 << 5)) };
}
}
svd2rust — Type-Safe Register Access
svd2rust:类型安全的寄存器访问方式
In practice, raw volatile pointers are rarely written by hand. The normal Rust way is to let svd2rust generate a Peripheral Access Crate from the chip’s SVD file.
真到实际项目里,几乎没人愿意手写这种原始 volatile 指针。更正常的 Rust 路子,是让 svd2rust 根据芯片的 SVD 文件生成一个外设访问 crate。
#![allow(unused)]
fn main() {
// Generated PAC code (you don't write this — svd2rust does)
// The PAC makes invalid register access a compile error
// Usage with PAC:
use stm32f4::stm32f401; // PAC crate for your chip
fn configure_gpio(dp: stm32f401::Peripherals) {
// Enable GPIOA clock — type-safe, no magic numbers
dp.RCC.ahb1enr.modify(|_, w| w.gpioaen().enabled());
// Set pin 5 to output — can't accidentally write to a read-only field
dp.GPIOA.moder.modify(|_, w| w.moder5().output());
// Toggle pin 5 — type-checked field access
dp.GPIOA.odr.modify(|r, w| {
// SAFETY: toggling a single bit in a valid register field.
unsafe { w.bits(r.bits() ^ (1 << 5)) }
});
}
}
| C register access | Rust PAC equivalent |
|---|---|
#define REG (*(volatile uint32_t*)ADDR) | PAC crate generated by svd2rust由 svd2rust 生成的 PAC crate |
| `REG | = BITMASK;` |
value = REG; | let val = periph.reg.read().field().bits()读寄存器后再取字段 |
| Wrong register field → silent UB | Compile error — field does not exist 字段写错直接编译不过 |
| Wrong register width → silent UB | Type-checked width like u8 / u16 / u32位宽也由类型系统校验 |
Interrupt Handling and Critical Sections
中断处理与临界区
C 固件里通常会写 __disable_irq() / __enable_irq() 以及特定命名的 ISR。Rust 也有对应能力,但会把不少约束直接拉到类型系统层面。
这样一来,很多以前靠文档和命名约定维持的东西,会变成编译器帮忙盯着的规则。
C vs Rust Interrupt Patterns
C 与 Rust 的中断模式对比
// C — traditional interrupt handler
volatile uint32_t tick_count = 0;
void SysTick_Handler(void) { // Naming convention is critical — get it wrong → HardFault
tick_count++;
}
uint32_t get_ticks(void) {
__disable_irq();
uint32_t t = tick_count; // Read inside critical section
__enable_irq();
return t;
}
#![allow(unused)]
fn main() {
// Rust — using cortex-m and critical sections
use core::cell::Cell;
use cortex_m::interrupt::{self, Mutex};
// Shared state protected by a critical-section Mutex
static TICK_COUNT: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
#[cortex_m_rt::exception] // Attribute ensures correct vector table placement
fn SysTick() { // Compile error if name doesn't match a valid exception
interrupt::free(|cs| { // cs = critical section token (proof IRQs disabled)
let count = TICK_COUNT.borrow(cs).get();
TICK_COUNT.borrow(cs).set(count + 1);
});
}
fn get_ticks() -> u32 {
interrupt::free(|cs| TICK_COUNT.borrow(cs).get())
}
}
RTIC — Real-Time Interrupt-driven Concurrency
RTIC:实时中断驱动并发
For more complex firmware with multiple interrupt priorities, RTIC provides compile-time scheduling and resource locking with zero runtime overhead.
如果固件里有多级中断优先级、共享资源和更复杂的调度关系,RTIC 就很有价值。它把调度和资源访问规则尽量前移到编译期,而且基本没有额外运行时成本。
#![allow(unused)]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [USART1])]
mod app {
use stm32f4xx_hal::prelude::*;
#[shared]
struct Shared {
temperature: f32, // Shared between tasks — RTIC manages locking
}
#[local]
struct Local {
led: stm32f4xx_hal::gpio::Pin<'A', 5, stm32f4xx_hal::gpio::Output>,
}
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
let dp = cx.device;
let gpioa = dp.GPIOA.split();
let led = gpioa.pa5.into_push_pull_output();
(Shared { temperature: 25.0 }, Local { led })
}
// Hardware task: runs on SysTick interrupt
#[task(binds = SysTick, shared = [temperature], local = [led])]
fn tick(mut cx: tick::Context) {
cx.local.led.toggle();
cx.shared.temperature.lock(|temp| {
// RTIC guarantees exclusive access here — no manual locking needed
*temp += 0.1;
});
}
}
}
Why RTIC matters for C firmware developers:
为什么 RTIC 对 C 固件开发者很重要:
- The
#[shared]annotation replaces a lot of manual mutex bookkeeping.#[shared]这类标注,能替掉很多手写锁管理样板。 - Priority-based preemption is planned at compile time instead of by ad-hoc runtime discipline.
基于优先级的抢占关系在编译期就确定下来,不用在运行时靠人硬维持。 - Deadlock freedom is one of the big selling points: the framework can prove a lot of locking properties statically.
它的一大卖点就是很多锁相关性质能静态证明,死锁空间被压得很小。 - ISR naming mistakes become compile errors rather than mysterious HardFaults.
中断函数名写错这种事,也更容易在编译阶段暴露,而不是等到板子上硬炸。
Panic Handler Strategies
panic handler 策略
In C firmware, fatal failures often end in reset loops or blinking LEDs. Rust gives panic handling a structured hook so projects can choose a deliberate failure strategy.
C 固件里,出大问题时通常就是复位、死循环或者闪灯报警。Rust 则把这件事做成了明确的 panic handler 入口,让项目能选更清晰的故障策略。
#![allow(unused)]
fn main() {
// Strategy 1: Halt (for debugging — attach debugger, inspect state)
use panic_halt as _; // Infinite loop on panic
// Strategy 2: Reset the MCU
use panic_reset as _; // Triggers system reset
// Strategy 3: Log via probe (development)
use panic_probe as _; // Sends panic info over debug probe (with defmt)
// Strategy 4: Log over defmt then halt
use defmt_panic as _; // Rich panic messages over ITM/RTT
// Strategy 5: Custom handler (production firmware)
use core::panic::PanicInfo;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// 1. Disable interrupts to prevent further damage
cortex_m::interrupt::disable();
// 2. Write panic info to a reserved RAM region (survives reset)
// SAFETY: PANIC_LOG is a reserved memory region defined in linker script.
unsafe {
let log = 0x2000_0000 as *mut [u8; 256];
// Write truncated panic message
use core::fmt::Write;
let mut writer = FixedWriter::new(&mut *log);
let _ = write!(writer, "{}", info);
}
// 3. Trigger watchdog reset (or blink error LED)
loop {
cortex_m::asm::wfi(); // Wait for interrupt (low power while halted)
}
}
}
Linker Scripts and Memory Layout
linker script 与内存布局
Embedded Rust still uses the same basic memory layout concepts that C firmware does. The usual Rust-facing entry point is a memory.x file.
嵌入式 Rust 在内存布局这件事上,并没有脱离 C 固件世界。该写 FLASH、RAM 起始地址和大小,还是得写,只是入口通常换成了 memory.x。
/* memory.x — placed at crate root, consumed by cortex-m-rt */
MEMORY
{
/* Adjust for your MCU — these are STM32F401 values */
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 96K
}
/* Optional: reserve space for panic log (see panic handler above) */
_panic_log_start = ORIGIN(RAM);
_panic_log_size = 256;
# .cargo/config.toml — set the target and linker flags
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F401RE" # flash and run via debug probe
rustflags = [
"-C", "link-arg=-Tlink.x", # cortex-m-rt linker script
]
[build]
target = "thumbv7em-none-eabihf" # Cortex-M4F with hardware FPU
| C linker script | Rust equivalent |
|---|---|
MEMORY { FLASH ..., RAM ... } | memory.x at crate root根目录下的 memory.x |
__attribute__((section(".data"))) | #[link_section = ".data"] |
-T linker.ld in Makefile | -C link-arg=-Tlink.x in .cargo/config.toml |
__bss_start__, __bss_end__ | Usually handled by cortex-m-rt很多基础启动细节由 cortex-m-rt 处理 |
| Startup assembly file | #[entry] and runtime support from cortex-m-rt入口由运行时 crate 接管 |
Writing embedded-hal Drivers
编写 embedded-hal 驱动
The embedded-hal crate defines standard traits for SPI, I2C, GPIO, UART, and more. A driver written against those traits can often run on many different microcontrollers unchanged.embedded-hal 定义了一套 SPI、I2C、GPIO、UART 等外设的标准 trait。只要驱动写在这套 trait 之上,它通常就能跨很多 MCU 复用,这就是 Rust 嵌入式生态最值钱的地方之一。
C vs Rust: A Temperature Sensor Driver
C 与 Rust 对比:温度传感器驱动
// C — driver tightly coupled to STM32 HAL
#include "stm32f4xx_hal.h"
float read_temperature(I2C_HandleTypeDef* hi2c, uint8_t addr) {
uint8_t buf[2];
HAL_I2C_Mem_Read(hi2c, addr << 1, 0x00, I2C_MEMADD_SIZE_8BIT,
buf, 2, HAL_MAX_DELAY);
int16_t raw = ((int16_t)buf[0] << 4) | (buf[1] >> 4);
return raw * 0.0625;
}
// Problem: This driver ONLY works with STM32 HAL. Porting to Nordic = rewrite.
#![allow(unused)]
fn main() {
// Rust — driver works on ANY MCU that implements embedded-hal
use embedded_hal::i2c::I2c;
pub struct Tmp102<I2C> {
i2c: I2C,
address: u8,
}
impl<I2C: I2c> Tmp102<I2C> {
pub fn new(i2c: I2C, address: u8) -> Self {
Self { i2c, address }
}
pub fn read_temperature(&mut self) -> Result<f32, I2C::Error> {
let mut buf = [0u8; 2];
self.i2c.write_read(self.address, &[0x00], &mut buf)?;
let raw = ((buf[0] as i16) << 4) | ((buf[1] as i16) >> 4);
Ok(raw as f32 * 0.0625)
}
}
// Works on STM32, Nordic nRF, ESP32, RP2040 — any chip with an embedded-hal I2C impl
}
graph TD
subgraph "C Driver Architecture<br/>C 驱动结构"
CD["Temperature Driver<br/>温度驱动"]
CD --> STM["STM32 HAL"]
CD -.->|"Port = REWRITE<br/>移植基本重写"| NRF["Nordic HAL"]
CD -.->|"Port = REWRITE<br/>移植基本重写"| ESP["ESP-IDF"]
end
subgraph "Rust embedded-hal Architecture<br/>Rust embedded-hal 结构"
RD["Temperature Driver<br/>impl<I2C: I2c>"]
RD --> EHAL["embedded-hal::I2c trait"]
EHAL --> STM2["stm32f4xx-hal"]
EHAL --> NRF2["nrf52-hal"]
EHAL --> ESP2["esp-hal"]
EHAL --> RP2["rp2040-hal"]
NOTE["Write driver ONCE,<br/>runs on ALL chips<br/>驱动写一次,多平台复用"]
end
style CD fill:#ffa07a,color:#000
style RD fill:#91e5a3,color:#000
style EHAL fill:#91e5a3,color:#000
style NOTE fill:#91e5a3,color:#000
Global Allocator Setup
全局分配器配置
The alloc crate gives Vec、String and Box, but on bare-metal targets the program still has to define where heap memory comes from.alloc crate 能带来 Vec、String、Box 这些堆类型,但在裸机环境里,程序仍然要自己说明“堆内存到底从哪来”。
#![no_std]
extern crate alloc;
use alloc::vec::Vec;
use alloc::string::String;
use embedded_alloc::LlffHeap as Heap;
#[global_allocator]
static HEAP: Heap = Heap::empty();
#[cortex_m_rt::entry]
fn main() -> ! {
// Initialize the allocator with a memory region
// (typically a portion of RAM not used by stack or static data)
{
const HEAP_SIZE: usize = 4096;
static mut HEAP_MEM: [u8; HEAP_SIZE] = [0; HEAP_SIZE];
// SAFETY: HEAP_MEM is only accessed here during init, before any allocation.
unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
}
// Now you can use heap types!
let mut log_buffer: Vec<u8> = Vec::with_capacity(256);
let name: String = String::from("sensor_01");
// ...
loop {}
}
| C heap setup | Rust equivalent |
|---|---|
Custom malloc() or _sbrk() | #[global_allocator] plus Heap::init()注册全局分配器并手动初始化 |
configTOTAL_HEAP_SIZE in FreeRTOS | HEAP_SIZE constant |
pvPortMalloc() | Using Vec::new() and friends堆类型自动走全局分配器 |
| Heap exhaustion → chaos or custom behavior | alloc_error_handler or controlled panic path可以统一走受控失败策略 |
Mixed no_std + std Workspaces
混合 no_std 与 std 的 workspace
Real embedded projects often split code into several crates, some targeting the MCU directly and others targeting a host environment like Linux.
真实项目里,很常见的一种拆法是:一部分 crate 直接跑在 MCU 上,另一部分 crate 跑在 Linux 这种宿主环境里,两边共享协议和核心逻辑。
workspace_root/
├── Cargo.toml # [workspace] members = [...]
├── protocol/ # no_std — wire protocol, parsing
│ ├── Cargo.toml # no default-features, no std
│ └── src/lib.rs # #![no_std]
├── driver/ # no_std — hardware abstraction
│ ├── Cargo.toml
│ └── src/lib.rs # #![no_std], uses embedded-hal traits
├── firmware/ # no_std — MCU binary
│ ├── Cargo.toml # depends on protocol, driver
│ └── src/main.rs # #![no_std] #![no_main]
└── host_tool/ # std — Linux CLI tool
├── Cargo.toml # depends on protocol (same crate!)
└── src/main.rs # Uses std::fs, std::net, etc.
The key pattern is that shared crates like protocol stay no_std, so the same parsing or packet code can be compiled for both firmware and host tools without duplication.
这里最关键的设计点,是把像 protocol 这种共享逻辑做成 no_std,这样固件和宿主工具都能直接复用同一份代码,不用各写一套。
# protocol/Cargo.toml
[package]
name = "protocol"
[features]
default = []
std = [] # Optional: enable std-specific features when building for host
[dependencies]
serde = { version = "1", default-features = false, features = ["derive"] }
# Note: default-features = false drops serde's std dependency
#![allow(unused)]
fn main() {
// protocol/src/lib.rs
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(feature = "std")]
extern crate std;
extern crate alloc;
use alloc::vec::Vec;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct DiagPacket {
pub sensor_id: u16,
pub value: i32,
pub fault_code: u16,
}
// This function works in both no_std and std contexts
pub fn parse_packet(data: &[u8]) -> Result<DiagPacket, &'static str> {
if data.len() < 8 {
return Err("packet too short");
}
Ok(DiagPacket {
sensor_id: u16::from_le_bytes([data[0], data[1]]),
value: i32::from_le_bytes([data[2], data[3], data[4], data[5]]),
fault_code: u16::from_le_bytes([data[6], data[7]]),
})
}
}
Exercise: Hardware Abstraction Layer Driver
练习:硬件抽象层驱动
Write a no_std driver for a hypothetical LED controller that communicates over SPI and is generic over any embedded-hal SPI implementation.
写一个 no_std 驱动,目标设备是假想的 SPI LED 控制器,而且这个驱动要对任意实现了 embedded-hal SPI trait 的底层都通用。
Requirements:
要求如下:
- Define a
LedController<SPI>struct.
定义一个LedController<SPI>结构体。 - Implement
new()、set_brightness(led: u8, brightness: u8)andall_off().
实现new()、set_brightness(led: u8, brightness: u8)和all_off()。 - The SPI protocol is a 2-byte transaction:
[led_index, brightness_value].
SPI 协议规定每次发两个字节:[led_index, brightness_value]。 - Write tests using a mock SPI implementation.
再给它写一套基于 mock SPI 的测试。
#![allow(unused)]
fn main() {
// Starter code
#![no_std]
use embedded_hal::spi::SpiDevice;
pub struct LedController<SPI> {
spi: SPI,
num_leds: u8,
}
// TODO: Implement new(), set_brightness(), all_off()
// TODO: Create MockSpi for testing
}
Solution 参考答案
#![allow(unused)]
#![no_std]
fn main() {
use embedded_hal::spi::SpiDevice;
pub struct LedController<SPI> {
spi: SPI,
num_leds: u8,
}
impl<SPI: SpiDevice> LedController<SPI> {
pub fn new(spi: SPI, num_leds: u8) -> Self {
Self { spi, num_leds }
}
pub fn set_brightness(&mut self, led: u8, brightness: u8) -> Result<(), SPI::Error> {
if led >= self.num_leds {
return Ok(()); // Silently ignore out-of-range LEDs
}
self.spi.write(&[led, brightness])
}
pub fn all_off(&mut self) -> Result<(), SPI::Error> {
for led in 0..self.num_leds {
self.spi.write(&[led, 0])?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// Mock SPI that records all transactions
struct MockSpi {
transactions: Vec<Vec<u8>>,
}
// Minimal error type for mock
#[derive(Debug)]
struct MockError;
impl embedded_hal::spi::Error for MockError {
fn kind(&self) -> embedded_hal::spi::ErrorKind {
embedded_hal::spi::ErrorKind::Other
}
}
impl embedded_hal::spi::ErrorType for MockSpi {
type Error = MockError;
}
impl SpiDevice for MockSpi {
fn write(&mut self, buf: &[u8]) -> Result<(), Self::Error> {
self.transactions.push(buf.to_vec());
Ok(())
}
fn read(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
fn transfer(&mut self, _r: &mut [u8], _w: &[u8]) -> Result<(), Self::Error> { Ok(()) }
fn transfer_in_place(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
fn transaction(&mut self, _ops: &mut [embedded_hal::spi::Operation<'_, u8>]) -> Result<(), Self::Error> { Ok(()) }
}
#[test]
fn test_set_brightness() {
let mock = MockSpi { transactions: vec![] };
let mut ctrl = LedController::new(mock, 4);
ctrl.set_brightness(2, 128).unwrap();
assert_eq!(ctrl.spi.transactions, vec![vec![2, 128]]);
}
#[test]
fn test_all_off() {
let mock = MockSpi { transactions: vec![] };
let mut ctrl = LedController::new(mock, 3);
ctrl.all_off().unwrap();
assert_eq!(ctrl.spi.transactions, vec![
vec![0, 0], vec![1, 0], vec![2, 0],
]);
}
#[test]
fn test_out_of_range_led() {
let mock = MockSpi { transactions: vec![] };
let mut ctrl = LedController::new(mock, 2);
ctrl.set_brightness(5, 255).unwrap(); // Out of range — ignored
assert!(ctrl.spi.transactions.is_empty());
}
}
}
Debugging Embedded Rust — probe-rs, defmt, and VS Code
调试嵌入式 Rust:probe-rs、defmt 与 VS Code
C firmware developers often use OpenOCD + GDB or vendor IDEs. The Rust embedded ecosystem has increasingly converged around probe-rs as a more unified toolchain front end.
很多 C 固件开发者平时靠 OpenOCD + GDB,或者厂商自己的 IDE。Rust 嵌入式这边这几年越来越统一到 probe-rs 这条线上,整体体验会集中一些。
probe-rs — The All-in-One Debug Probe Tool
probe-rs:一站式调试探针工具
probe-rs effectively replaces the OpenOCD + GDB split setup for many workflows. It supports CMSIS-DAP, ST-Link, J-Link, and other common probes out of the box.
在很多工作流里,probe-rs 基本就是拿来替掉 OpenOCD + GDB 这套组合的。CMSIS-DAP、ST-Link、J-Link 这些常见探针它都能直接支持。
# Install probe-rs (includes cargo-flash and cargo-embed)
cargo install probe-rs-tools
# Flash and run your firmware
cargo flash --chip STM32F401RE --release
# Flash, run, and open RTT (Real-Time Transfer) console
cargo embed --chip STM32F401RE
probe-rs vs OpenOCD + GDB:probe-rs 和 OpenOCD + GDB 的对比:
| Aspect | OpenOCD + GDB | probe-rs |
|---|---|---|
| Install | Two separate tools plus scripts 通常要装两套工具再拼配置 | cargo install probe-rs-tools |
| Config | .cfg files per board/probe每块板子和探针都得配文件 | --chip or Embed.toml芯片名加项目配置即可 |
| Console output | Semihosting, often slow 半主机输出比较慢 | RTT, much faster RTT 更快 |
| Log framework | Usually printf or ad-hoc logs多半还是 printf 风格 | defmt integration和 defmt 配合更自然 |
| Flash algorithms | Often tied to external packs 常依赖外部包 | Built-in support for many chips |
| GDB support | Native | Available through probe-rs gdb |
Embed.toml — Project Configuration
Embed.toml:项目级配置
Instead of juggling multiple OpenOCD and GDB config files, probe-rs can centralize the setup in one Embed.toml file.
以前那种 .cfg、.gdbinit 到处飞的局面,在 probe-rs 这边通常可以收束到一个 Embed.toml 里。
# Embed.toml — placed in your project root
[default.general]
chip = "STM32F401RETx"
[default.rtt]
enabled = true # Enable Real-Time Transfer console
channels = [
{ up = 0, mode = "BlockIfFull", name = "Terminal" },
]
[default.flashing]
enabled = true # Flash before running
restore_unwritten_bytes = false
[default.reset]
halt_afterwards = false # Start running after flash + reset
[default.gdb]
enabled = false # Set true to expose GDB server on :1337
gdb_connection_string = "127.0.0.1:1337"
# With Embed.toml, just run:
cargo embed # Flash + RTT console — zero flags needed
cargo embed --release # Release build
defmt — Deferred Formatting for Embedded Logging
defmt:嵌入式日志里的延迟格式化
defmt stores format strings in the ELF and sends only compact identifiers plus argument bytes from the target. That makes logging dramatically faster and smaller than naïve printf-style approaches.defmt 的思路是把格式字符串留在 ELF 里,目标板端只发一个索引和参数字节。这比传统 printf 风格日志快得多,也省得多,特别适合资源紧张的嵌入式环境。
#![no_std]
#![no_main]
use defmt::{info, warn, error, debug, trace};
use defmt_rtt as _; // RTT transport — links the defmt output to probe-rs
#[cortex_m_rt::entry]
fn main() -> ! {
info!("Boot complete, firmware v{}", env!("CARGO_PKG_VERSION"));
let sensor_id: u16 = 0x4A;
let temperature: f32 = 23.5;
// Format strings stay in ELF, not flash — near-zero overhead
debug!("Sensor {:#06X}: {:.1}°C", sensor_id, temperature);
if temperature > 80.0 {
warn!("Overtemp on sensor {:#06X}: {:.1}°C", sensor_id, temperature);
}
loop {
cortex_m::asm::wfi(); // Wait for interrupt
}
}
// Custom types — derive defmt::Format instead of Debug
#[derive(defmt::Format)]
struct SensorReading {
id: u16,
value: i32,
status: SensorStatus,
}
#[derive(defmt::Format)]
enum SensorStatus {
Ok,
Warning,
Fault(u8),
}
// Usage:
// info!("Reading: {:?}", reading); // <-- uses defmt::Format, NOT std Debug
defmt vs printf vs log:defmt、printf 和常规 log 的对比:
| Feature | C printf with semihosting | Rust log crate | defmt |
|---|---|---|---|
| Speed | Very slow 常常慢得离谱 | Depends on backend | Very fast for embedded use 对嵌入式非常友好 |
| Flash usage | Stores full strings on target 格式字符串占空间 | Same basic problem | Keeps compact indices on target |
| Transport | Often semihosting 可能还会暂停 CPU | Backend-dependent | RTT |
| Structured output | No | Mostly text | Typed binary-encoded data |
no_std | Via special setups only | Front-end only, backends vary | Native support |
| Filtering | Manual or ad-hoc | RUST_LOG style | Feature-gated and tooling-aware |
VS Code Debug Configuration
VS Code 调试配置
With the probe-rs VS Code extension, you can use a full GUI debugger experience with breakpoints, variables, registers, and call stacks.
装上 probe-rs 的 VS Code 扩展之后,断点、变量、寄存器、调用栈这些图形化调试体验就都能用上了。
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "probe-rs-debug",
"request": "launch",
"name": "Flash & Debug (probe-rs)",
"chip": "STM32F401RETx",
"coreConfigs": [
{
"programBinary": "target/thumbv7em-none-eabihf/debug/${workspaceFolderBasename}",
"rttEnabled": true,
"rttChannelFormats": [
{
"channelNumber": 0,
"dataFormat": "Defmt",
"showTimestamps": true
}
]
}
],
"connectUnderReset": true,
"speed": 4000
}
]
}
Install the extension:
扩展安装命令如下:
#![allow(unused)]
fn main() {
ext install probe-rs.probe-rs-debugger
}
C Debugger Workflow vs Rust Embedded Debugging
C 调试流程与 Rust 嵌入式调试流程对比
graph LR
subgraph "C Workflow (Traditional)<br/>传统 C 流程"
C1["Write code<br/>写代码"] --> C2["make flash"]
C2 --> C3["openocd -f board.cfg"]
C3 --> C4["arm-none-eabi-gdb<br/>target remote :3333"]
C4 --> C5["printf via semihosting<br/>输出慢,还会停 CPU"]
end
subgraph "Rust Workflow (probe-rs)<br/>Rust 的 probe-rs 流程"
R1["Write code<br/>写代码"] --> R2["cargo embed"]
R2 --> R3["Flash + RTT console<br/>一条命令完成"]
R3 --> R4["defmt logs stream<br/>实时日志"]
R2 -.->|"Or<br/>或者"| R5["VS Code F5<br/>图形化调试"]
end
style C5 fill:#ffa07a,color:#000
style R3 fill:#91e5a3,color:#000
style R4 fill:#91e5a3,color:#000
style R5 fill:#91e5a3,color:#000
| C Debug Action | Rust Equivalent |
|---|---|
openocd -f board/st_nucleo_f4.cfg | probe-rs info |
arm-none-eabi-gdb -x .gdbinit | probe-rs gdb --chip STM32F401RE |
target remote :3333 | Connect GDB to localhost:1337 |
monitor reset halt | probe-rs reset --chip ... |
load firmware.elf | cargo flash --chip ... |
printf("debug: %d\n", val) | defmt::info!("debug: {}", val) |
| Keil or IAR GUI debugger | VS Code + probe-rs-debugger extension |
| Segger SystemView | defmt + probe-rs RTT viewer |
Cross-reference: For advanced unsafe patterns that show up in embedded drivers, such as pin projections or arena/slab allocators, see the companion Rust Patterns material mentioned elsewhere in the course.
交叉参考: 嵌入式驱动里更偏底层的 unsafe 模式,比如 pin projection、arena 或 slab 分配器,可以继续对照课程里配套的 Rust Patterns 材料去看。