embedded-hal 生态
trait 抽象层如何让一份驱动跑遍所有芯片:OutputPin/InputPin/DelayNs/SpiBus/I2c 核心 trait、HAL crate 的角色、如何写一个平台无关的泛型驱动。
trait 抽象层如何让一份驱动跑遍所有芯片:OutputPin/InputPin/DelayNs/SpiBus/I2c 核心 trait、HAL crate 的角色、如何写一个平台无关的泛型驱动。
stm32f4xx-hal、nrf52840-hal、rp2040-hal、esp-hal。它把裸寄存器操作封装成 into_push_pull_output()、Spi::new() 等符合直觉的方法,并让返回的类型实现对应 trait。OutputPin 提供 set_high()/set_low();StatefulOutputPin 增加 toggle() 与读回当前输出状态;InputPin 提供 is_high()/is_low()。方法返回 Result,因为某些平台的 GPIO 操作可能失败(如 I/O 扩展芯片经总线通信)。delay_ns()/delay_us()/delay_ms()。驱动需要"等待 20ms 让传感器上电"时,只需要一个 impl DelayNs,具体是用 SysTick、定时器还是空转由底层决定。这样传感器驱动不用关心时间从哪来。SpiBus 表示对总线的原始读写(read/write/transfer);SpiDevice 表示"总线 + 该设备的片选 CS",自动管理 CS 拉低拉高,是多从设备共享总线时的推荐接口。驱动 crate 通常要求传入 impl SpiDevice。read()/write()/write_read()(先写寄存器地址再读数据,I2C 传感器最常见的时序)以及批量 transaction()。地址通过参数传入,7 位地址用 u8。绝大多数 I2C 传感器驱动只依赖这一个 trait。async fn,如 async fn read(...)。配合 embassy(第 8 章)使用时,外设等待期间可让出 CPU 给其他任务,实现真正的并发 I/O。同步驱动和异步驱动往往共享大部分逻辑。这是 embedded-hal 最核心的价值。同一个 SSD1306 OLED 驱动,通过依赖 trait 而非具体芯片,可以直接搬到任意平台:
C 的传感器驱动通常直接调用某厂商 SDK 的 HAL_I2C_Master_Transmit(),换芯片就得改所有调用。虽然可以用函数指针手动抽象,但没有统一约定,每个库各搞一套。embedded-hal 用语言级 trait + 社区共识,让 crates.io 上数百个传感器/显示屏/无线模块驱动天然互通——这是 Rust 嵌入式生态能快速繁荣的结构性原因。
| Trait | 关键方法 | 用途 |
|---|---|---|
OutputPin | set_high / set_low | 驱动 LED、CS、复位脚 |
StatefulOutputPin | toggle / is_set_high | 翻转、读回输出状态 |
InputPin | is_high / is_low | 读按键、传感器 INT 脚 |
DelayNs | delay_ms / delay_us | 上电等待、时序延时 |
I2c | write / read / write_read | I2C 传感器、EEPROM |
SpiDevice | transfer / write / read | SPI 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 } }
#![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 做了破坏性重构: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。