4. SPI驱动代码编写:HAL库三种模式与缓冲管理

好,咱们进入实战环节。SPI的驱动代码怎么写?说白了,就是怎么把数据从MCU发出去、收回来。HAL库给了我们三种方式:轮询、中断、DMA。这三种方式各有各的脾气,选错了,轻则CPU空转,重则数据丢包。

我个人习惯,先把硬件调通,再用轮询验证,最后根据项目需求上中断或DMA。别一上来就搞DMA,万一硬件没通,你查错都找不到北。

4.1 轮询模式:最直接,也最“笨”

轮询模式,就是CPU死等。发一个字节,等它发完,再发下一个。接收也一样,等SPI硬件把数据收好了,CPU再去读。

代码其实很简单,就两个函数:

// 发送
HAL_SPI_Transmit(&hspi1, tx_buffer, size, timeout);

// 接收
HAL_SPI_Receive(&hspi1, rx_buffer, size, timeout);

// 同时收发(全双工)
HAL_SPI_TransmitReceive(&hspi1, tx_buffer, rx_buffer, size, timeout);

这里有个坑,我刚开始用的时候踩过。timeout参数,别设太大,也别设太小。设大了,死等半天;设小了,函数提前返回,数据没传完。一般设个100ms,够用了。

注意: 轮询模式下,CPU被完全占用。如果你在while(1)里调这个函数,其他任务就别想跑了。只适合在初始化阶段或者对实时性没要求的场合。

4.2 中断模式:让CPU喘口气

中断模式就好多了。你告诉SPI硬件:“数据发完了叫我一声。”然后CPU就可以去干别的事。等SPI发完了,触发中断,你在回调函数里处理。

HAL库的中断收发函数长这样:

// 中断方式发送
HAL_SPI_Transmit_IT(&hspi1, tx_buffer, size);

// 中断方式接收
HAL_SPI_Receive_IT(&hspi1, rx_buffer, size);

// 中断方式全双工
HAL_SPI_TransmitReceive_IT(&hspi1, tx_buffer, rx_buffer, size);

调用完这些函数,函数会立即返回。等传输完成,HAL库会调用回调函数:

void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
    // 发送完成,可以在这里置位标志位
    tx_done = 1;
}

void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
    // 接收完成
    rx_done = 1;
}

void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi)
{
    // 全双工完成
    txrx_done = 1;
}

嗯,这里要注意。回调函数是在中断上下文里执行的。别在里面做复杂运算,也别调用HAL_Delay()。我见过有人直接在回调里做浮点运算,结果系统直接崩了。回调里就干一件事:置个标志位,或者发个信号量。

4.3 DMA模式:真正的“解放CPU”

DMA模式,是终极方案。CPU告诉DMA控制器:“你帮我把这堆数据搬过去,搬完了告诉我。”然后CPU彻底撒手不管了。DMA在后台默默搬运,CPU该干嘛干嘛。

HAL库的DMA收发函数:

// DMA方式发送
HAL_SPI_Transmit_DMA(&hspi1, tx_buffer, size);

// DMA方式接收
HAL_SPI_Receive_DMA(&hspi1, rx_buffer, size);

// DMA方式全双工
HAL_SPI_TransmitReceive_DMA(&hspi1, tx_buffer, rx_buffer, size);

回调函数和中断模式一样,也是那三个:TxCpltCallback、RxCpltCallback、TxRxCpltCallback。

我的经验: DMA模式最适合大批量数据传输,比如一次传几百个字节。如果每次只传几个字节,DMA的初始化开销反而比传输本身还大,得不偿失。

4.4 三种模式对比:怎么选?

我整理了一个表格,方便你对照着选:

特性 轮询模式 中断模式 DMA模式
CPU占用率 100%(死等) 低(仅中断瞬间) 极低(几乎为零)
传输速度 受CPU频率限制 受中断响应时间限制 接近硬件极限
代码复杂度 最简单 中等 中等(需配置DMA)
适用场景 初始化、小数据量 中等数据量、实时性要求一般 大数据量、高实时性
典型数据量 < 10字节 10~100字节 > 100字节

4.5 收发缓冲管理:别让数据“打架”

不管用哪种模式,缓冲管理都是核心。我见过最典型的错误,就是发送缓冲还没发完,就被新数据覆盖了。

我的做法是:用环形缓冲区(Ring Buffer)。

环形缓冲区说白了,就是一个数组加两个指针:读指针和写指针。写指针追着读指针跑,永远不会覆盖未读的数据。

下面是一个简单的环形缓冲区实现:

#define RING_BUF_SIZE 256

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buffer_t;

// 初始化
void ring_buffer_init(ring_buffer_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
uint8_t ring_buffer_write(ring_buffer_t *rb, uint8_t data) {
    uint16_t next_head = (rb->head + 1) % RING_BUF_SIZE;
    if (next_head == rb->tail) {
        return 0;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 1;
}

// 读取一个字节
uint8_t ring_buffer_read(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return 0;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 1;
}
关键点: head和tail一定要用volatile修饰。因为中断和主循环都会修改它们,不加volatile,编译器优化后可能读到错误的值。

4.6 整体流程:一张图看懂

我把三种模式的选择流程画成了SVG图,方便你理解:

SPI驱动模式选择流程图 开始SPI传输 数据量 > 100字节? 是 DMA模式 否 实时性要求高? 是 中断模式 否 轮询模式 环形缓冲区管理 传输完成 小数据量+低实时性 → 轮询 大数据量 → DMA

4.7 避坑指南:我曾经踩过的坑

最后,分享几个我实战中遇到的坑,你遇到了能少走弯路:

  • NSS引脚管理: 用硬件NSS时,记得在HAL_SPI_Init()之后调用HAL_SPI_EnableCS()和HAL_SPI_DisableCS()。我一开始没调,片选信号一直拉低,多个从设备直接“打架”。
  • DMA缓冲区对齐: 有些MCU要求DMA缓冲区地址必须按4字节对齐。我遇到过用uint8_t数组,结果地址没对齐,DMA传输直接卡死。后来改成__attribute__((aligned(4)))就好了。
  • 中断优先级: SPI中断优先级别设太高,尤其是和系统滴答定时器抢优先级时。我试过把SPI中断设成最高优先级,结果HAL_Delay()直接失效,因为滴答定时器中断被抢了。
  • 环形缓冲区溢出: 写指针追上读指针时,数据会被覆盖。我的做法是在写入前检查剩余空间,如果空间不足,直接丢弃旧数据或者报错。

嗯,SPI驱动代码这块,说白了就是选对模式、管好缓冲。轮询、中断、DMA,没有绝对的好坏,只有合不合适。你根据项目需求,对照上面的表格和流程图,基本不会选错。