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,够用了。
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。
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;
}
4.6 整体流程:一张图看懂
我把三种模式的选择流程画成了SVG图,方便你理解:
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,没有绝对的好坏,只有合不合适。你根据项目需求,对照上面的表格和流程图,基本不会选错。