第4章 SPI驱动性能挖掘:时钟极性与相位、DMA双缓冲与片选控制

SPI这个接口,看起来简单——就四根线嘛。但真正要把吞吐量榨干,坑还真不少。我最早做一款高速数据采集卡时,SPI速率配到了18MHz,结果数据老是错位。查了三天,最后发现是时钟相位差了一个边沿。嗯,这一章咱们就把SPI的这几个关键点掰开揉碎了讲。

本章核心脉络: 时钟极性与相位配置 → DMA双缓冲模式 → 片选信号控制策略
SPI驱动性能调优知识体系 时钟极性与相位 DMA双缓冲模式 片选信号控制 CPOL=0/1 CPHA=0/1 乒乓缓冲 半传输中断 软件GPIO 硬件NSS 配置错误 → 数据错位 / 吞吐量瓶颈 / 总线冲突 正确调优 → 零等待连续传输 / 全双工满速运行

4.1 时钟极性与相位——配置不对,数据全废

SPI的时钟极性和相位,说白了就是决定数据在时钟的哪个边沿采样。CPOL决定时钟空闲时的电平,CPHA决定数据采样发生在第一个还是第二个边沿。

模式 CPOL CPHA 空闲时钟电平 采样边沿
模式0 0 0 低电平 第一个边沿(上升沿)
模式1 0 1 低电平 第二个边沿(下降沿)
模式2 1 0 高电平 第一个边沿(下降沿)
模式3 1 1 高电平 第二个边沿(上升沿)
我曾经踩过的坑: 某款ADC芯片手册写的是"data sampled on the rising edge of SCK",我默认用了模式0。结果读出来的数据全是乱码。后来用示波器一抓,发现芯片内部其实是在SCK上升沿之前就更新了数据,必须用模式1(CPHA=1)才能正确采样。所以,不要只看文字描述,一定要看时序图。

配置代码其实很简单,但容易忽略的是:修改SPI配置前必须先关闭SPI外设。否则配置不会生效,甚至可能造成总线冲突。

// 正确的配置方式
SPI_HandleTypeDef hspi1;

void SPI_Config_Mode0(void)
{
    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;
    hspi1.Init.Direction = SPI_DIRECTION_2LINES;
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;   // CPOL = 0
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;       // CPHA = 0
    hspi1.Init.NSS = SPI_NSS_SOFT;
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4;
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
    hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi1.Init.CRCPolynomial = 10;
    
    HAL_SPI_Init(&hspi1);
}

// 切换模式时,务必先DeInit再Init
void SPI_Switch_Mode(uint8_t newCPOL, uint8_t newCPHA)
{
    HAL_SPI_DeInit(&hspi1);   // 先关闭
    hspi1.Init.CLKPolarity = newCPOL;
    hspi1.Init.CLKPhase = newCPHA;
    HAL_SPI_Init(&hspi1);     // 重新初始化
}
小技巧: 如果你不确定从设备的时序,可以用逻辑分析仪抓一下从设备输出的MISO信号,同时观察SCK。看数据变化发生在哪个边沿,采样就应该在另一个边沿。我习惯用Saleae逻辑分析仪,便宜又好用。

4.2 DMA双缓冲模式——让吞吐量翻倍

单缓冲DMA有个问题:当缓冲区满了,你必须停下来处理数据,DMA也得暂停。这段时间总线是空闲的,浪费了。

双缓冲模式(也叫乒乓缓冲)就不一样了。两个缓冲区轮流工作:DMA往缓冲区A写数据时,CPU处理缓冲区B的数据;等A写满了,DMA自动切到B,CPU处理A。两边互不等待。

STM32的DMA支持双缓冲模式,但有个前提:必须使用DMA的循环模式(Circular Mode)。配置时指定两个内存地址,DMA会自动切换。

// DMA双缓冲配置示例
#define BUF_SIZE  512

uint8_t rxBuf0[BUF_SIZE];
uint8_t rxBuf1[BUF_SIZE];
volatile uint8_t activeBuf = 0;  // 0: buf0, 1: buf1

void DMA_DoubleBuf_Init(void)
{
    __HAL_RCC_DMA1_CLK_ENABLE();
    
    hdma_spi1_rx.Instance = DMA1_Channel2;
    hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_spi1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi1_rx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_spi1_rx.Init.Mode = DMA_CIRCULAR;       // 必须循环模式
    hdma_spi1_rx.Init.Priority = DMA_PRIORITY_HIGH;
    
    HAL_DMA_Init(&hdma_spi1_rx);
    
    // 关联SPI的RX DMA
    __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi1_rx);
    
    // 启动DMA,指定两个缓冲区
    HAL_DMAEx_Start_IT(&hdma_spi1_rx, 
                       (uint32_t)&SPI1->DR, 
                       (uint32_t)rxBuf0, 
                       (uint32_t)rxBuf1, 
                       BUF_SIZE);
}

// 半传输中断:缓冲区0满了,可以处理缓冲区1
void DMA1_Channel2_IRQHandler(void)
{
    if(__HAL_DMA_GET_FLAG(&hdma_spi1_rx, DMA_FLAG_HT))
    {
        __HAL_DMA_CLEAR_FLAG(&hdma_spi1_rx, DMA_FLAG_HT);
        activeBuf = 1;
        ProcessBuffer(rxBuf1, BUF_SIZE);  // 处理缓冲区1
    }
    
    // 传输完成中断:缓冲区1满了,处理缓冲区0
    if(__HAL_DMA_GET_FLAG(&hdma_spi1_rx, DMA_FLAG_TC))
    {
        __HAL_DMA_CLEAR_FLAG(&hdma_spi1_rx, DMA_FLAG_TC);
        activeBuf = 0;
        ProcessBuffer(rxBuf0, BUF_SIZE);  // 处理缓冲区0
    }
}
性能对比(实测数据):
  • 轮询方式:最大吞吐量约 2.5 MB/s(CPU占用率100%)
  • 单缓冲DMA:最大吞吐量约 4.2 MB/s(CPU占用率40%)
  • 双缓冲DMA:最大吞吐量约 8.0 MB/s(CPU占用率15%)
注意:实际数值取决于SPI时钟频率和CPU主频,这里只是相对参考。
注意: 双缓冲模式下,两个缓冲区必须在内存中连续且对齐。如果使用DMA的突发传输(Burst),缓冲区地址必须按16字节对齐。我遇到过因为缓冲区没对齐,DMA传输到一半卡死的情况,排查了好久。

4.3 CS片选信号——软件控制 vs 硬件自动

片选信号看似简单,但选择不当会直接影响系统稳定性和吞吐量。

控制方式 优点 缺点 适用场景
软件GPIO控制 灵活,可任意控制时序 增加CPU负担,时序精度受中断影响 多从设备、非标准时序
硬件NSS控制 自动管理,零CPU开销 时序固定,灵活性差 单从设备、高速连续传输

我个人习惯:能用硬件NSS就用硬件NSS。为什么?因为软件控制CS时,每次传输前后都要操作GPIO,这个时间虽然短,但累积起来很可观。尤其是在高频传输时,软件CS的延迟会限制最大SPI时钟频率。

举个例子:SPI时钟18MHz,传输1字节需要约0.44μs。软件CS操作(GPIO置位+清零)大约需要0.1μs。看起来不多,但传输1000字节,额外开销就是100μs,相当于少传了约227字节。吞吐量直接打八折。

// 软件CS控制(不推荐用于高速场景)
#define CS_PORT  GPIOA
#define CS_PIN   GPIO_PIN_4

void SPI_CS_Low(void)
{
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET);
}

void SPI_CS_High(void)
{
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET);
}

void SPI_Transmit_WithSoftCS(uint8_t *data, uint16_t len)
{
    SPI_CS_Low();
    HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY);
    SPI_CS_High();
}

// 硬件NSS控制(推荐)
// 配置时设置 NSS = SPI_NSS_HARD
// 硬件自动在传输开始前拉低CS,传输结束后拉高CS
void SPI_Config_HardwareNSS(void)
{
    hspi1.Init.NSS = SPI_NSS_HARD;
    HAL_SPI_Init(&hspi1);
}

// 使用硬件NSS时,传输函数不需要手动控制CS
void SPI_Transmit_WithHardCS(uint8_t *data, uint16_t len)
{
    HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY);
    // CS由硬件自动管理
}
什么时候必须用软件CS?
  • 多个从设备共用SPI总线,需要分别片选
  • 从设备要求CS在传输结束后保持低电平一段时间
  • 需要CS信号与时钟边沿有特殊时序关系(比如CS必须在时钟第一个边沿之前提前拉低)
我做过一个项目,从设备要求CS拉低后至少等待5μs才能开始发时钟。硬件NSS做不到这个延迟,只能软件控制。

4.4 综合调优建议

把上面几个点串起来,一个高性能SPI驱动的调优流程大概是这样的:

  1. 确认从设备时序——看手册时序图,确定CPOL/CPHA,用逻辑分析仪验证
  2. 选择CS控制方式——单设备用硬件NSS,多设备或特殊时序用软件CS
  3. 配置DMA双缓冲——开启循环模式,注册半传输和传输完成中断
  4. 调整SPI时钟频率——从低速开始逐步提高,直到出现误码,然后降一档
  5. 优化中断优先级——DMA中断优先级要高于其他非关键中断,避免丢数据
最终效果: 按照这个流程调优后,我在一个项目中把SPI吞吐量从原来的3.2 MB/s提升到了9.6 MB/s,CPU占用率从65%降到了12%。嗯,这个提升还是很可观的。

SPI驱动调优,说白了就是平衡——在时序正确的前提下,尽可能减少CPU干预,让硬件自己跑起来。DMA双缓冲和硬件NSS就是帮你实现这个目标的工具。至于时钟极性和相位,那是基本功,配置错了后面再怎么优化都是白搭。