4. Rust硬件抽象层入门
好,咱们进入正题。SPI驱动开发,说白了就是让芯片跟外设“对上话”。你想想看,一个传感器、一个显示屏、一个Flash芯片,它们都挂在SPI总线上。怎么让它们听话?怎么把数据发出去、收回来?这就是我们今天要啃的硬骨头。
我个人习惯,写驱动之前先搞清楚两件事:一是硬件怎么接的,二是协议怎么跑的。硬件接错了,代码写得再漂亮也是白搭。协议理解偏了,数据收发就是鸡同鸭讲。
4.1 embedded-hal 的 SPI Trait 体系
Rust 的 embedded-hal 定义了一套 SPI 的抽象接口。它不关心你用的是 STM32 还是 ESP32,也不管你是硬件 SPI 还是软件模拟。它只告诉你:要实现 SPI 通信,你得有这几个“能力”。
这套 trait 分三层,我画了个图,你看一眼就明白了。
这张图你得记住。实际开发中,SpiDevice 用得最多。为什么?因为它帮你管了片选信号。你想想看,总线上挂着好几个设备,你跟谁说话,就得把谁的片选拉低。手动管片选,容易出bug。用 SpiDevice,它自动帮你搞定。
4.2 初始化SPI外设
初始化这一步,不同芯片差异很大。但套路是一样的:配时钟、设模式、定速率。
我拿一个典型的 STM32 例子来说。假设我们用的是 stm32f4xx-hal 这个库。
use embedded_hal::spi::MODE_0;
use stm32f4xx_hal::{
pac,
prelude::*,
spi::{Spi, NoMiso, NoSck, NoMosi},
};
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
// 配置引脚
let gpioa = dp.GPIOA.split();
let sck = gpioa.pa5.into_alternate();
let mosi = gpioa.pa7.into_alternate();
let miso = gpioa.pa6.into_alternate();
// 初始化 SPI1
let mut spi = Spi::new(
dp.SPI1,
(sck, mosi, miso),
MODE_0, // 模式0:CPOL=0, CPHA=0
1.MHz(), // 时钟速率 1MHz
&clocks,
);
这里有个关键点:SPI 模式。模式由时钟极性(CPOL)和时钟相位(CPHA)决定。一共四种组合:
| 模式 | CPOL | CPHA | 特点 |
|---|---|---|---|
| MODE_0 | 0 | 0 | 空闲时时钟低,第一个边沿采样 |
| MODE_1 | 0 | 1 | 空闲时时钟低,第二个边沿采样 |
| MODE_2 | 1 | 0 | 空闲时时钟高,第一个边沿采样 |
| MODE_3 | 1 | 1 | 空闲时时钟高,第二个边沿采样 |
嗯,这里要注意:外设的数据手册里一定会写它支持哪种模式。我见过有人不看手册,随便选了个 MODE_0,结果死活读不到数据。折腾了两天,最后发现传感器只支持 MODE_3。你说冤不冤?
4.3 发送与接收数据
初始化完了,就该干活了。发送和接收,在 embedded-hal 里主要有三种方式。
4.3.1 使用 SpiDevice 进行读写
这是最推荐的方式。它把片选管理封装好了,你只管传数据。
use embedded_hal::spi::SpiDevice;
// 假设 spi_dev 是 SpiDevice 的实例
let tx_buffer: [u8; 4] = [0x01, 0x02, 0x03, 0x04];
let mut rx_buffer = [0u8; 4];
// 同时发送和接收
spi_dev.transfer(&mut rx_buffer, &tx_buffer).unwrap();
// 只发送,不关心接收
spi_dev.write(&tx_buffer).unwrap();
// 只接收,发送全0
spi_dev.read(&mut rx_buffer).unwrap();
核心要点:
transfer是全双工操作,发送和接收同时进行write只管发,接收的数据直接丢弃read发送全0,把从设备的数据读回来
你可能会问:为什么 read 要发送全0?因为 SPI 是同步协议,主机不给时钟,从机就没法发数据。所以读数据的时候,主机必须持续提供时钟脉冲。发全0是最简单的做法。
4.3.2 手动管理片选
有些场景下,你需要自己控制片选。比如多个设备共用一个 SPI 总线,或者你需要连续发送多个帧而不释放片选。
// 手动拉低片选
cs_pin.set_low();
// 发送命令
spi.write(&[0xAB]).unwrap();
// 发送地址
spi.write(&[0x00, 0x01]).unwrap();
// 读取数据
let mut data = [0u8; 16];
spi.read(&mut data).unwrap();
// 拉高片选,释放总线
cs_pin.set_high();
4.4 实战:读取一个SPI Flash的ID
光说不练假把式。咱们来读一个 SPI Flash 的 ID。这是最经典的 SPI 通信例子,几乎所有 SPI Flash 都支持 0x9F 命令来读取 JEDEC ID。
fn read_flash_id(spi_dev: &mut impl SpiDevice) -> Result<[u8; 3], SpiError> {
// 发送读ID命令
let cmd = [0x9F];
spi_dev.write(&cmd)?;
// 读取3个字节的ID
let mut id = [0u8; 3];
spi_dev.read(&mut id)?;
Ok(id)
}
// 使用示例
let id = read_flash_id(&mut spi_dev).unwrap();
println!("Flash ID: {:02X} {:02X} {:02X}", id[0], id[1], id[2]);
这段代码看起来简单,但有个坑:有些 Flash 芯片在发送命令后,需要一小段等待时间才能读取数据。我遇到过一种 Flash,命令发完后必须等 100ns 才能读。100ns 虽然短,但在高速 SPI 下,可能就是几个时钟周期的事。
4.5 常见问题与调试技巧
做 SPI 驱动,总会遇到各种问题。我总结了几条经验:
- 数据全是 0xFF:大概率是片选没拉低,或者从机没上电
- 数据全是 0x00:检查 MOSI 和 MISO 是不是接反了
- 数据错位:检查 SPI 模式是否匹配,时钟极性/相位对不对
- 偶尔通信失败:看看时钟速率是不是太高了,信号完整性有问题
我个人调试 SPI 的流程是这样的:先用低速(比如 100kHz)把通信调通,确认数据能正确收发。然后逐步提高速率,每次翻倍,直到出现误码。最后把速率降到误码率以下的安全值。这个方法虽然保守,但很可靠。
好了,SPI 驱动开发的核心内容就这些。从 trait 体系到初始化,从收发数据到实战例子,每一步都有坑,但也都有解法。你写驱动的时候,多想想硬件是怎么工作的,多看看示波器上的波形,很多问题就迎刃而解了。