Chapter 04 · Rust 嵌入式开发

embedded-hal 生态

trait 抽象层如何让一份驱动跑遍所有芯片:OutputPin/InputPin/DelayNs/SpiBus/I2c 核心 trait、HAL crate 的角色、如何写一个平台无关的泛型驱动。

课程进度33%

关键概念

trait(特征)
Rust 的接口机制,定义一组方法签名,类型可以"实现"某个 trait。函数可以对"任何实现了某 trait 的类型"泛型编程。embedded-hal 正是用 trait 定义了一套标准的外设接口,各家 HAL 去实现它,驱动只针对 trait 编程——这是整个 Rust 嵌入式生态互操作的基石。
embedded-hal(HAL 抽象层)
一个只定义 trait、不含任何具体实现的 crate。它规定了 GPIO、SPI、I2C、串口、延时、PWM、ADC 等外设的标准接口。1.0 版本是里程碑,接口稳定后生态迅速壮大。注意它本身不能"运行",必须由具体芯片的 HAL 提供实现。
HAL crate(芯片实现)
针对某个芯片家族、在 PAC 之上封装出好用 API 并实现 embedded-hal trait 的 crate,如 stm32f4xx-halnrf52840-halrp2040-halesp-hal。它把裸寄存器操作封装成 into_push_pull_output()Spi::new() 等符合直觉的方法,并让返回的类型实现对应 trait。
OutputPin / InputPin
数字 GPIO 的核心 trait。OutputPin 提供 set_high()/set_low()StatefulOutputPin 增加 toggle() 与读回当前输出状态;InputPin 提供 is_high()/is_low()。方法返回 Result,因为某些平台的 GPIO 操作可能失败(如 I/O 扩展芯片经总线通信)。
DelayNs(延时抽象)
embedded-hal 1.0 统一的延时 trait,提供 delay_ns()/delay_us()/delay_ms()。驱动需要"等待 20ms 让传感器上电"时,只需要一个 impl DelayNs,具体是用 SysTick、定时器还是空转由底层决定。这样传感器驱动不用关心时间从哪来。
SpiBus / SpiDevice
SPI 抽象。SpiBus 表示对总线的原始读写(read/write/transfer);SpiDevice 表示"总线 + 该设备的片选 CS",自动管理 CS 拉低拉高,是多从设备共享总线时的推荐接口。驱动 crate 通常要求传入 impl SpiDevice
I2c(I2C 抽象)
I2C 主机接口 trait,提供 read()/write()/write_read()(先写寄存器地址再读数据,I2C 传感器最常见的时序)以及批量 transaction()。地址通过参数传入,7 位地址用 u8。绝大多数 I2C 传感器驱动只依赖这一个 trait。
embedded-hal-async
embedded-hal 的异步孪生 crate,把同样的接口改成 async fn,如 async fn read(...)。配合 embassy(第 8 章)使用时,外设等待期间可让出 CPU 给其他任务,实现真正的并发 I/O。同步驱动和异步驱动往往共享大部分逻辑。

trait 抽象带来的"一次编写,处处运行"

这是 embedded-hal 最核心的价值。同一个 SSD1306 OLED 驱动,通过依赖 trait 而非具体芯片,可以直接搬到任意平台:

一份驱动,多个平台 ═══════════════════════════════════════════════════ ┌────────────────────────────┐ │ ssd1306 驱动 crate │ │ fn new(i2c: impl I2c) │ 只依赖 embedded-hal └──────────────┬─────────────┘ │ 需要一个 impl I2c ┌──────────────────────┼──────────────────────┐ │ │ │ ┌─────▼──────┐ ┌────────▼───────┐ ┌────────▼───────┐ │stm32f4xx-hal│ │ rp2040-hal │ │ esp-hal │ │ I2c 实现 │ │ I2c 实现 │ │ I2c 实现 │ └────────────┘ └────────────────┘ └────────────────┘ STM32F4 Raspberry Pico ESP32-C3 同一份 OLED 驱动代码,三块板子都能跑,驱动一行不改
这在 C 里几乎做不到

C 的传感器驱动通常直接调用某厂商 SDK 的 HAL_I2C_Master_Transmit(),换芯片就得改所有调用。虽然可以用函数指针手动抽象,但没有统一约定,每个库各搞一套。embedded-hal 用语言级 trait + 社区共识,让 crates.io 上数百个传感器/显示屏/无线模块驱动天然互通——这是 Rust 嵌入式生态能快速繁荣的结构性原因。

核心 trait 速查

Trait关键方法用途
OutputPinset_high / set_low驱动 LED、CS、复位脚
StatefulOutputPintoggle / is_set_high翻转、读回输出状态
InputPinis_high / is_low读按键、传感器 INT 脚
DelayNsdelay_ms / delay_us上电等待、时序延时
I2cwrite / read / write_readI2C 传感器、EEPROM
SpiDevicetransfer / write / readSPI Flash、显示屏、射频

编写一个平台无关的泛型驱动

假设我们要给一款虚构的 I2C 温度传感器(地址 0x48,寄存器 0x00 存温度)写驱动。关键在于:结构体对总线类型泛型,只约束它实现 I2c trait:

#![no_std]

use embedded_hal::i2c::I2c;

const ADDR: u8 = 0x48;
const REG_TEMP: u8 = 0x00;

// 驱动对 I2C 总线类型 I 泛型,唯一约束是 I: I2c
pub struct TempSensor<I> {
    i2c: I,
}

impl<I: I2c> TempSensor<I> {
    // 拿走 i2c 的所有权,驱动生命周期内独占它
    pub fn new(i2c: I) -> Self {
        Self { i2c }
    }

    // 读温度:先写寄存器地址,再读 2 字节 —— 用 write_read 一次完成
    pub fn read_celsius(&mut self) -> Result<f32, I::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(ADDR, &[REG_TEMP], &mut buf)?;
        // 大端 12 位有效,0.0625 °C/LSB(示例换算)
        let raw = ((buf[0] as i16) << 4) | ((buf[1] as i16) >> 4);
        Ok(raw as f32 * 0.0625)
    }

    // 释放总线所有权,交还给调用者复用
    pub fn release(self) -> I {
        self.i2c
    }
}

在 STM32 上使用这个驱动

#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    let gpiob = dp.GPIOB.split();
    // PB8=SCL, PB9=SDA,构造 HAL 的 I2C(它实现了 embedded-hal 的 I2c trait)
    let scl = gpiob.pb8;
    let sda = gpiob.pb9;
    let i2c = dp.I2C1.i2c((scl, sda), 100.kHz(), &clocks);

    // 把 HAL 的 i2c 交给平台无关驱动
    let mut sensor = TempSensor::new(i2c);

    loop {
        if let Ok(t) = sensor.read_celsius() {
            // 用 defmt 打印(见第 11 章)
            // defmt::info!("temp = {} C", t);
            let _ = t;
        }
        cortex_m::asm::delay(8_000_000);
    }
}
驱动为什么要"拿走"总线所有权

驱动 new(i2c: I) 按值接收总线,意味着它在生命周期内独占该 I2C。这防止了另一段代码同时用同一总线导致时序错乱。如果多个设备要共享一条 I2C,应使用 embedded-hal-bus 提供的 RefCellDevice/AtomicDevice 等共享包装,它们同样实现 I2c trait,可以安全地分发给多个驱动——所有权规则把总线争用问题显式化了。

注意 embedded-hal 1.0 与 0.2 不兼容

embedded-hal 在 1.0 做了破坏性重构:trait 名和方法有变化(如旧 DelayMs 合并为 DelayNs,SPI 拆分为 SpiBus/SpiDevice)。选用驱动时确认它依赖的是 1.x 还是 0.2.x,混用会遇到 trait 不匹配。新项目一律选 1.x 生态;老驱动可用 embedded-hal-compat 桥接。

本章小结

embedded-hal 是一个只定义 trait、不含实现的抽象层,规定了 GPIO(OutputPin/InputPin/StatefulOutputPin)、延时(DelayNs)、总线(I2c/SpiBus/SpiDevice)等标准接口。具体芯片的 HAL crate(stm32f4xx-hal、rp2040-hal、esp-hal 等)在 PAC 之上封装易用 API 并实现这些 trait。设备驱动只针对 trait 泛型编程(struct Sensor<I: I2c>),因此同一份驱动可在任意平台复用、一行不改——这是 C 难以做到的生态优势。驱动通常按值拿走总线所有权以独占它,多设备共享总线用 embedded-hal-bus 的共享包装。所有接口方法返回 Result(总线操作可能失败)。异步场景用镜像的 embedded-hal-async 配合 embassy。注意 embedded-hal 1.0 与 0.2 不兼容,新项目统一选 1.x。