4、SPI驱动层开发:基于HAL库的SPI读写函数封装与DMA传输优化
驱动层开发,说白了就是给上层应用提供一套好用的“遥控器”。你不需要每次都去抠寄存器,而是调用几个封装好的函数就能完成SPI读写。这一章,我就带你从HAL库的底层API开始,一步步搭出一套健壮的SPI驱动层,顺便把DMA这个“加速器”也加上。
4.1 HAL库SPI接口的“三件套”
STM32的HAL库把SPI操作封装成了三个核心函数。我个人习惯把它们叫做“三件套”:
| 函数名 | 功能 | 阻塞/非阻塞 |
|---|---|---|
HAL_SPI_Transmit() |
发送数据 | 阻塞(轮询) |
HAL_SPI_Receive() |
接收数据 | 阻塞(轮询) |
HAL_SPI_TransmitReceive() |
全双工收发 | 阻塞(轮询) |
这三个函数默认是阻塞模式。CPU会一直等着,直到数据传完。对于ADC采集这种高速场景,阻塞模式会严重拖累系统响应。所以,我们得把它们“升级”成中断或DMA版本。
关键点:HAL库的SPI接口,底层都是操作DR(数据寄存器)。发送就是往DR写,接收就是从DR读。全双工时,读写同时进行。
4.2 封装自己的SPI读写函数
直接调用HAL库函数,代码会变得很散乱。我建议你封装一层,把SPI句柄、片选控制、超时处理都包进去。这样上层调用时,一行代码就能搞定一次ADC寄存器读写。
下面是我在项目中常用的封装模板:
/* spi_drv.h */
#ifndef __SPI_DRV_H
#define __SPI_DRV_H
#include "stm32f4xx_hal.h"
/* SPI句柄,由CubeMX生成 */
extern SPI_HandleTypeDef hspi1;
/* 片选控制宏 */
#define SPI_CS_LOW() HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET)
#define SPI_CS_HIGH() HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET)
/* 封装函数 */
HAL_StatusTypeDef SPI_WriteReg(uint8_t reg, uint8_t data);
HAL_StatusTypeDef SPI_ReadReg(uint8_t reg, uint8_t *data);
HAL_StatusTypeDef SPI_WriteMulti(uint8_t reg, uint8_t *pData, uint16_t len);
HAL_StatusTypeDef SPI_ReadMulti(uint8_t reg, uint8_t *pData, uint16_t len);
#endif
/* spi_drv.c */
#include "spi_drv.h"
HAL_StatusTypeDef SPI_WriteReg(uint8_t reg, uint8_t data)
{
HAL_StatusTypeDef status;
uint8_t txBuf[2];
txBuf[0] = reg; // 寄存器地址
txBuf[1] = data; // 要写入的数据
SPI_CS_LOW();
status = HAL_SPI_Transmit(&hspi1, txBuf, 2, 100);
SPI_CS_HIGH();
return status;
}
HAL_StatusTypeDef SPI_ReadReg(uint8_t reg, uint8_t *data)
{
HAL_StatusTypeDef status;
uint8_t txBuf[2] = {reg, 0xFF}; // 发送地址,同时发送0xFF以产生时钟
uint8_t rxBuf[2];
SPI_CS_LOW();
status = HAL_SPI_TransmitReceive(&hspi1, txBuf, rxBuf, 2, 100);
SPI_CS_HIGH();
if (status == HAL_OK) {
*data = rxBuf[1]; // 第二个字节才是读到的数据
}
return status;
}
经验之谈:读操作时,发送地址后必须跟着一个“假字节”(比如0xFF),才能把从机的数据“带”出来。这是SPI全双工的特性——你发一个字节,同时收一个字节。
4.3 DMA传输优化:让CPU“解放双手”
为什么用DMA?因为ADC采集时,数据量往往很大。比如一个16位ADC,采样率1Msps,每秒就是2MB的数据。如果CPU一个个字节去搬,那它啥也别干了。
DMA(直接存储器访问)可以自动把SPI接收到的数据搬到内存里,完全不需要CPU干预。CPU只需要在DMA传输完成时,去内存里取数据就行。
4.3.1 DMA配置要点
配置DMA时,有几个坑你得注意:
- 数据宽度对齐:SPI数据宽度(8位/16位)必须和DMA的数据宽度一致。否则数据会错位。
- 循环模式:对于连续采集,开启DMA循环模式。这样DMA会自动重新开始,不需要你手动触发。
- 中断优先级:DMA传输完成中断的优先级,要高于普通任务,但低于实时性要求更高的中断(比如定时器)。
我曾经在一个项目中,因为DMA和SPI的数据宽度没对齐,导致采集到的ADC数据全是乱的。排查了整整一天,最后发现是DMA配置成了半字(16位),而SPI配置成了字节(8位)。嗯,这种低级错误,犯过一次就再也不会忘了。
4.3.2 基于DMA的SPI收发封装
下面是一个基于DMA的SPI连续读取封装:
/* 使用DMA循环模式,连续读取ADC数据 */
#define ADC_DATA_BUF_SIZE 1024
uint16_t adcDataBuf[ADC_DATA_BUF_SIZE]; // DMA直接往这里写
void SPI_StartDMARead(void)
{
/* 启动DMA接收,SPI自动将数据搬入adcDataBuf */
HAL_SPI_Receive_DMA(&hspi1, (uint8_t *)adcDataBuf, ADC_DATA_BUF_SIZE * 2);
}
/* DMA传输完成回调 */
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
if (hspi->Instance == SPI1) {
/* 数据已就绪,可以处理了 */
ProcessADCBuffer(adcDataBuf, ADC_DATA_BUF_SIZE);
}
}
注意:DMA传输完成回调是在中断中执行的。不要在回调里做耗时操作,比如打印、延时。否则会阻塞其他中断。正确的做法是:设置一个标志位,在主循环中处理数据。
4.4 驱动层整体架构
把上面这些内容整合起来,驱动层的架构就清晰了。我画了一张图,帮你理解各层的关系:
4.5 性能对比:轮询 vs 中断 vs DMA
为了让你更直观地看到优化效果,我整理了一个对比表。这是我在实际项目中测出来的数据:
| 传输方式 | CPU占用率(1Msps) | 数据吞吐量 | 适用场景 |
|---|---|---|---|
| 轮询(阻塞) | ~95% | 受限于CPU速度 | 低速、简单调试 |
| 中断 | ~30% | 较高,但频繁中断有开销 | 中等速率、数据量不大 |
| DMA | <5% | 接近理论极限 | 高速、大数据量采集 |
结论:对于ADC采集,DMA几乎是必选项。CPU占用率从95%降到5%以下,这意味着你的MCU还有大把时间去处理其他任务,比如数据滤波、通信、显示等。
4.6 避坑指南:DMA传输中的常见问题
DMA虽好,但坑也不少。我把自己踩过的几个坑列出来,你遇到了可以直接对照排查:
- 数据错位:检查SPI和DMA的数据宽度是否一致。8位对8位,16位对16位。
- DMA未启动:调用
HAL_SPI_Receive_DMA()后,检查DMA使能位是否置位。有时候CubeMX配置漏了DMA请求映射。 - 缓冲区溢出:DMA循环模式下,如果主循环处理数据的速度跟不上DMA写入速度,缓冲区会被覆盖。解决方案:使用双缓冲(ping-pong buffer)。
- 中断冲突:DMA传输完成中断和SPI错误中断共用同一个中断线时,优先级设置不当会导致死锁。建议DMA中断优先级设为中等,SPI错误中断设为最高。
我的调试习惯:先用逻辑分析仪抓SPI的SCK和MOSI/MISO波形,确认硬件通信正常。然后再用DMA模式。这样能快速定位问题是出在硬件层还是驱动层。
好了,这一章的内容就到这里。驱动层封装好了,DMA也配上了,接下来就可以放心地跟ADC芯片“对话”了。