为什么Rust嵌入式的驱动实现没有涉及任何具体芯片——Rust的平台无关驱动

1 阅读7分钟

前言

最近在学习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的traitesp-halstm32f4xx-hal
设备驱动只依赖embedded-hal的trait,不关心芯片ssd1306bmp180mpu6050

芯片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这一中间接口,使其在编译期就完成了硬件抽象,实现设备驱动与具体芯片的解耦,驱动无需关心具体硬件平台即可使用,既保留了零成本的性能,又实现了前所未有的可移植性和代码复用率。