4. Rust硬件抽象层入门

好,咱们进入正题。SPI驱动开发,说白了就是让芯片跟外设“对上话”。你想想看,一个传感器、一个显示屏、一个Flash芯片,它们都挂在SPI总线上。怎么让它们听话?怎么把数据发出去、收回来?这就是我们今天要啃的硬骨头。

我个人习惯,写驱动之前先搞清楚两件事:一是硬件怎么接的,二是协议怎么跑的。硬件接错了,代码写得再漂亮也是白搭。协议理解偏了,数据收发就是鸡同鸭讲。

4.1 embedded-hal 的 SPI Trait 体系

Rust 的 embedded-hal 定义了一套 SPI 的抽象接口。它不关心你用的是 STM32 还是 ESP32,也不管你是硬件 SPI 还是软件模拟。它只告诉你:要实现 SPI 通信,你得有这几个“能力”。

这套 trait 分三层,我画了个图,你看一眼就明白了。

embedded-hal SPI Trait 体系 spi::FullDuplex<u8> 全双工:同时发送和接收 spi::SpiBus 总线级操作:transfer, write, read spi::SpiDevice 设备级操作:带片选管理 实际开发中,SpiDevice 用得最多,因为它自动帮你管片选信号 FullDuplex 偏底层,SpiBus 适合多设备共享总线场景

这张图你得记住。实际开发中,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,
);
我的习惯:初始化时把时钟速率设低一点,比如 1MHz。等通信调通了,再慢慢往上提。我曾经一上来就设 20MHz,结果示波器一看,波形都变形了。降低速率,波形立马干净了。

这里有个关键点: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();
注意:手动管片选,一定要保证时序正确。我曾经遇到过一个bug:片选拉低后,中间插了一个中断,导致片选被意外拉高。从那以后,我写手动片选代码时,一定会关中断,或者用临界区保护。

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 下,可能就是几个时钟周期的事。

避坑指南:如果你发现读回来的 ID 全是 0xFF 或者 0x00,先别急着怀疑代码。用示波器抓一下 MOSI 和 MISO 的波形。看看命令发出去了没有?从机有没有响应?我曾经花了一下午调一个 Flash 驱动,最后发现是片选引脚虚焊了。

4.5 常见问题与调试技巧

做 SPI 驱动,总会遇到各种问题。我总结了几条经验:

  • 数据全是 0xFF:大概率是片选没拉低,或者从机没上电
  • 数据全是 0x00:检查 MOSI 和 MISO 是不是接反了
  • 数据错位:检查 SPI 模式是否匹配,时钟极性/相位对不对
  • 偶尔通信失败:看看时钟速率是不是太高了,信号完整性有问题

我个人调试 SPI 的流程是这样的:先用低速(比如 100kHz)把通信调通,确认数据能正确收发。然后逐步提高速率,每次翻倍,直到出现误码。最后把速率降到误码率以下的安全值。这个方法虽然保守,但很可靠。

好了,SPI 驱动开发的核心内容就这些。从 trait 体系到初始化,从收发数据到实战例子,每一步都有坑,但也都有解法。你写驱动的时候,多想想硬件是怎么工作的,多看看示波器上的波形,很多问题就迎刃而解了。