4. SPI 总线驱动开发:从协议到 HAL 层实战
SPI 总线,说实话,是嵌入式开发里最常用的串行接口之一。速度快、全双工、硬件简单,但坑也不少。我这些年调过的 SPI 驱动,少说也有几十个,从低速传感器到高速显示屏,每个场景都有它的脾气。
这一章,咱们不扯虚的。直接从协议回顾开始,然后深入到 HAL 层的接口设计,最后把 DMA 和中断的配合讲透。你跟着我的思路走一遍,以后写 SPI 驱动心里就有底了。
4.1 SPI 协议核心回顾
SPI 全称 Serial Peripheral Interface,摩托罗拉发明的。四根线:SCLK、MOSI、MISO、CS。主从结构,一个主机可以挂多个从机。
为什么它快?因为它是同步的。时钟由主机产生,数据在时钟边沿采样。没有起始位、停止位,纯粹的数据流。
| 信号 | 方向 | 作用 |
|---|---|---|
| SCLK | 主机→从机 | 时钟信号,决定传输速率 |
| MOSI | 主机→从机 | 主机输出,从机输入 |
| MISO | 从机→主机 | 从机输出,主机输入 |
| CS | 主机→从机 | 片选,低电平有效 |
这里有个关键点:时钟极性(CPOL)和时钟相位(CPHA)。CPOL 决定空闲时时钟电平,CPHA 决定数据在哪个边沿采样。组合起来就是 4 种模式。
模式 0(CPOL=0, CPHA=0):空闲低电平,上升沿采样。这是最常用的模式,我 80% 的项目都用它。
我曾经在一个项目里,因为从机手册写的是模式 1,结果实际是模式 0,调了整整两天。后来用逻辑分析仪一看,才发现是文档错了。所以,别全信手册,用示波器或逻辑分析仪确认波形,这是铁律。
4.2 HAL 层 SPI 接口设计
HAL 层的 SPI 接口,说白了就是给上层应用一个干净、统一的调用方式。不管底层是哪个 MCU,接口长一样。
我个人习惯把 SPI 接口分成三层:
- 底层硬件操作层:直接操作寄存器或调用 MCU HAL 库
- 中间抽象层:封装成 SPI 设备对象,管理片选、时钟、模式
- 应用接口层:提供读写函数,屏蔽传输细节
来看一个我常用的 SPI 设备结构体:
typedef struct {
SPI_TypeDef *instance; // SPI 外设基地址
GPIO_TypeDef *cs_port; // 片选 GPIO 端口
uint16_t cs_pin; // 片选 GPIO 引脚
uint32_t baudrate; // 波特率
uint8_t mode; // SPI 模式 0-3
uint8_t data_width; // 数据宽度 8/16
void *tx_buf; // 发送缓冲区
void *rx_buf; // 接收缓冲区
uint16_t len; // 传输长度
uint8_t dma_enable; // DMA 使能标志
} spi_device_t;
这个结构体把 SPI 设备的所有属性都包进去了。初始化时填好,后面所有操作都基于这个对象。你想想看,如果每个函数都要传一堆参数,代码得多难看。
核心接口我一般只暴露三个:
spi_init(spi_device_t *dev)— 初始化 SPI 外设和 GPIOspi_transfer(spi_device_t *dev, uint8_t *tx, uint8_t *rx, uint16_t len)— 同步传输spi_transfer_async(spi_device_t *dev, uint8_t *tx, uint8_t *rx, uint16_t len, callback_t cb)— 异步传输
小技巧:同步传输里,如果不需要接收,可以把 rx 设为 NULL,内部自动丢弃接收数据。反过来,如果只接收不发送,tx 设为 NULL,内部发 0xFF。这样接口更灵活。
4.3 DMA 传输与中断配合
SPI 的 DMA 传输,是提升系统吞吐量的关键。但很多人一上来就开 DMA,结果数据错乱、中断冲突,调得头皮发麻。
为什么会这样?因为 DMA 和中断的配合,时序上必须严丝合缝。
我画了一张流程图,把 SPI DMA 传输的完整流程理清楚了:
这里有个关键步骤,很多人会忽略:DMA 传输完成中断里,不能立刻开始下一次传输。必须等 SPI 外设的忙标志(BSY)清除。为什么?因为 DMA 把最后一个字节搬到 SPI 数据寄存器后,SPI 还在串行移位输出,这时候如果改 CS 或重新配置,数据就丢了。
我曾经踩过的坑:在一个高速 ADC 采集项目里,SPI 时钟 20MHz,DMA 传输完直接拉高 CS,结果最后一个采样值总是错的。后来发现是 CS 拉高太快,SPI 还没移完最后一位。解决方案:在 DMA 中断里加一个 while 循环等待 BSY 清除,再操作 CS。
来看一段实际的 DMA 中断处理代码:
void spi_dma_tx_rx_callback(spi_device_t *dev)
{
// 等待 SPI 忙状态清除
while (dev->instance->SR & SPI_SR_BSY);
// 拉高片选
HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET);
// 关闭 DMA 和 SPI
__HAL_DMA_DISABLE(dev->hdma_tx);
__HAL_DMA_DISABLE(dev->hdma_rx);
__HAL_SPI_DISABLE(dev->instance);
// 调用应用层回调
if (dev->callback) {
dev->callback(dev);
}
}
这段代码看着简单,但顺序很重要。先等 BSY,再拉 CS,最后关外设。顺序反了,数据就可能出问题。
4.4 中断与 DMA 的取舍
什么时候用中断,什么时候用 DMA?我个人的经验法则:
| 传输长度 | 推荐方式 | 理由 |
|---|---|---|
| 1-4 字节 | 轮询 | DMA 配置开销大于传输本身 |
| 4-16 字节 | 中断 | CPU 处理中断的开销可以接受 |
| 16 字节以上 | DMA | 释放 CPU,提升系统吞吐量 |
你想想看,如果每次只传 2 个字节,却要配置 DMA 通道、使能中断、等完成回调,这 CPU 时间都花在配置上了,还不如轮询快。
实际项目经验:在驱动 OLED 显示屏时,每次刷新要传 1024 字节,我果断用 DMA。但在配置寄存器时,每次只传 1-2 字节,用轮询就够了。同一个 SPI 总线,不同场景用不同方式,这才是灵活的设计。
4.5 调试技巧与避坑指南
SPI 调试,说难不难,说简单也不简单。我总结几个实用技巧:
- 先确认 GPIO 配置:复用功能、推挽输出、上拉下拉,错一个都不行。我习惯先写一个 GPIO 翻转测试,确认引脚能正常输出。
- 用逻辑分析仪看波形:这是最直接的手段。看 SCLK 频率对不对,CS 时序对不对,数据位对不对。
- 先跑轮询模式:新板子第一次调 SPI,我从来不用 DMA。先轮询跑通,再切中断,最后上 DMA。一步到位容易出问题,排查起来也麻烦。
- 注意 SPI 的 FIFO:有些 MCU 的 SPI 有 FIFO,深度 4 或 8 字节。FIFO 没处理好,数据会错位。我建议先关 FIFO 调通,再开 FIFO 优化性能。
核心原则:SPI 驱动开发,先求稳,再求快。轮询能跑通,再考虑中断和 DMA。每一步都验证,不要跳步。
嗯,这一章的内容就到这。SPI 总线驱动开发,协议是基础,HAL 层接口设计是骨架,DMA 与中断配合是灵魂。你把这三块吃透了,市面上 90% 的 SPI 设备都能轻松驱动。