前言
最近在学习ESP32-S3的Rust开发时,涉及到了具体的设备驱动——通过I2C操作由SSD1306驱动的OLED屏。
按照以往的开发经验,这需要先去找到ESP32S3平台实现的驱动库,然后将驱动依赖添加到工程项目中,再进行开发即可。
但是当我从crates.io上寻找OLED屏的驱动库时,我发现很多驱动库并没有写明是适用于什么芯片平台,当我点进去使用数量最高的SSD1306驱动库并查看其源码时,发现里面竟然根本没有提到任何具体的硬件平台。
源码中既没有ESP32S3的寄存器地址,也不知道ESP32的I2C外设是怎么配置的,甚至不知道我要用的是哪一个芯片,是STM32还是ESP32。
一个完全不知道硬件细节的库,竟然能驱动硬件设备?不管你是STM32,ESP32,nRF还是RP2040,都能使用这个驱动库?
经进一步的了解学习才知道,原来这背后,就是Rust嵌入式生态的一个核心设计理念——平台无关驱动。
传统嵌入式开发的“移植困境”
有过传统的C语言嵌入式开发经历,才知道Rust的无关平台驱动是多么强大实用的设计。
在传统的嵌入式开发环境中,特别是C语言,为一个硬件平台写的驱动函数,要移植到另一个平台,并不容易。因为每个芯片厂商、每个MCU系列,都有自己独特的外设访问方式。他们的HAL库函数在函数签名、参数结构、错误处理上,几乎都不一样。也就是说,每当更换硬件平台,对于驱动几乎都要作出修改甚至是重写。在C语言中即便可以通过架构设计来实现可移植的抽象,但是在抽象成本,开发速度和代码的可维护性上都作出了一定的牺牲。
Rust的解法——embedded-hal
Rust为了解决这个问题,设计了一个名为 embedded-hal 的库
它的核心思想很简单:定义一套标准接口(Trait),驱动只依赖这套接口,芯片厂商为这套接口提供具体实现。
用一句话概括:embedded-hal是一套“接口协议”,但仅仅是接口定义,没有任何具体实现。而这个具体实现,则是由芯片厂商负责,开发者只需要在这个接口协议的基础上专注于驱动逻辑即可。
因此在embedded-hal生态中,设备驱动和芯片厂商的角色分工非常明确:
| 角色 | 职责 | 例子 |
|---|---|---|
| 芯片HAL库 | 为具体芯片实现embedded-hal的trait | esp-hal、stm32f4xx-hal |
| 设备驱动 | 只依赖embedded-hal的trait,不关心芯片 | ssd1306、bmp180、mpu6050 |
芯片HAL库:如esp-hal,负责把ESP32的硬件寄存器操作,封装成embedded_hal::i2c::I2c trait的实现。
设备驱动:如ssd1306,只认embedded_hal::i2c::I2c这个trait,完全不关心底层是ESP32还是STM32。
而我们在使用时只需要把芯片HAL库创建的I2C实例,注入到设备驱动里即可,这也就是所谓的"依赖注入"。
结合代码分析
在我所使用的驱动库的源码中,I2C 入口长这样:
pub fn new<I>(i2c: I) -> I2CInterface<I>
where
I: embedded_hal::i2c::I2c,
{
Self::new_custom_address(i2c, 0x3C)
}
它只要求:给我一个实现了 embedded_hal::i2c::I2c 的类型。esp-hal 的 I2c 实现了这个 trait,STM32、nRF、RP2040 的 HAL 也实现了,所以同一份驱动能用在不同芯片上。
当你把ESP32实现了embedded_hal::i2c::I2c 类型的实例注入驱动:
let i2c = esp_hal::i2c::master::I2c::new(...);
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306::new(interface,......);
编译器会把泛型里的 I 替换成具体类型:
Ssd1306<esp_hal::i2c::master::I2c<'_, Blocking>>
然后调用esp_hal的具体实现,再往底层便是ESP的寄存器操作,换作其他硬件平台也如出一辙。这也就是为什么叫“平台无关驱动”,因为驱动代码里没有任何平台相关的代码,不包含任何平台的特定实现,所有硬件交互都通过trait接口来完成,只要硬件平台实现了这个trait接口就可以使用。
与Linux对比——相同思想不同实现
通过标准接口驱动设备,隔离底层实现?熟悉Linux的读者,应该都会跟我一样觉得这个机制跟Linux的非常类似。
确实,两者在设计理念上是一致的,但是具体实现却不一样。简单来说,Linux是运行时的硬件抽象,Rust是编译时的运行抽象。
-
Linux 的做法:
read(fd, buffer, size),内核中的 VFS(虚拟文件系统)会把它转发给具体的 ext4、FAT32 驱动,或者转发给具体的 NVMe、SD 卡驱动。应用层代码不关心硬盘是 SATA 还是 USB。 -
Rust
embedded-hal的做法:i2c.write(address, data),这个 Trait 方法在编译时会被替换成具体的esp_hal::i2c::I2C或stm32_hal::i2c::I2C的实现。驱动代码不关心硬件平台用的是 ESP32 还是 STM32。
| Linux 抽象 | Rust 抽象 | |
|---|---|---|
| 发生时机 | 运行时 | 编译时 |
| 实现机制 | 函数指针、结构体虚表(Vtable)、设备树动态匹配。 | 泛型单态化(Monomorphization)、静态分发(Static Dispatch)。 |
| 性能开销 | 有运行时开销(函数指针跳转、指令缓存污染)。 | 零开销,编译后生成的代码。 |
| 灵活性 | 可以在不重启系统的情况下加载/卸载驱动模块。 | 驱动在编译时就已完全固定。换一颗芯片,需要重新编译整个固件。 |
架构层级分析
从架构上来看,embedded-hal相当于在设备驱动层和芯片HAL层中加了一个中间层,从而将底层硬件变化隔离。
┌─────────────────────────────────────────┐
│ 应用层 (业务逻辑)
├─────────────────────────────────────────┤
│ 设备驱动层 (ssd1306, bmp180, ...) │ ← 平台无关
├─────────────────────────────────────────┤
│ embedded-hal (trait接口定义) │ ← 统一标准
├─────────────────────────────────────────┤
│ 芯片HAL层 (esp-hal, stm32f4xx-hal, ..) │ ← 平台相关
├─────────────────────────────────────────┤
│ PAC层 (直接操作寄存器)
└─────────────────────────────────────────┘
-
PAC(Peripheral Access Crate):最底层,直接操作寄存器。
-
芯片HAL:在PAC之上封装,一般由芯片厂商实现
embedded-hal的trait。 -
embedded-hal:中间的“契约层”,定义标准接口 -
设备驱动:只依赖
embedded-hal,平台无关。 -
应用层:业务逻辑,将HAL层依赖注入设备驱动。
这种分层架构思想在传统的C语言嵌入式开发中也常用使用,但是实现起来并不容易,同时可能还会带来代码膨胀,性能损耗和更多的资源开销等等问题。相比之下,Rust的实现方式更加优雅、安全且成本开销更小。
平台无关驱动带来的巨大优势
-
对驱动开发者:写一次驱动,支持所有实现了
embedded-hal的芯片平台,移植维护变得非常简单。 -
对芯片厂商:实现一次
embedded-hal,就能使用整个Rust生态中数不胜数的设备驱动库。 -
对于普通开发者:不管使用什么硬件平台,在
crates.io上使用的驱动几乎都是即插即用的。
小结
Rust通过trait机制在硬件平台和设备驱动之间设计了embedded-hal这一中间接口,使其在编译期就完成了硬件抽象,实现设备驱动与具体芯片的解耦,驱动无需关心具体硬件平台即可使用,既保留了零成本的性能,又实现了前所未有的可移植性和代码复用率。