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 驱动层整体架构

把上面这些内容整合起来,驱动层的架构就清晰了。我画了一张图,帮你理解各层的关系:

SPI驱动层架构图 SPI驱动层架构 应用层(ADC采集任务) 驱动封装层 SPI_WriteReg / SPI_ReadReg / SPI_StartDMARead HAL库层 HAL_SPI_Transmit / HAL_SPI_TransmitReceive_DMA 硬件层(SPI外设 + DMA控制器) 说明:驱动封装层屏蔽了HAL库的细节,上层应用只需调用简单的读写函数。DMA传输在后台自动完成,CPU可以处理其他任务。

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芯片“对话”了。