STM32 SPI实战:从模式配置到DMA传输的深度解析与避坑指南

STM32 SPI实战:从模式配置到DMA传输的深度解析与避坑指南

1. 从“能用”到“好用”:STM32 SPI实战的深度思考

最近在调试一个基于STM32F4的传感器模块,传感器用的是SPI接口。按理说,SPI是单片机里最基础的外设之一,配置几行代码,数据就该“唰唰”地来了。但实际情况是,我对着逻辑分析仪抓到的波形发呆了半小时——时钟有,片选有,但MOSI线上就是没数据。这让我重新审视了STM32的SPI外设:它远不止是CubeMX里勾选几个选项那么简单。从模式选择、时钟极性与相位的理解,到DMA传输的坑,再到多从机场景下的片选管理,每一个环节都可能让新手栽跟头。网上很多教程只告诉你“怎么配通”,但很少深入讲“为什么这么配”以及“配通了之后还可能遇到什么”。今天,我就结合自己踩过的坑和项目经验,把STM32 SPI从配置到稳定高效使用的完整链路拆解清楚,目标是让你不仅能让SPI跑起来,更能理解其内在机理,从容应对各种复杂场景。

2. SPI协议核心:四种模式与时钟极性的本质

在动手配置STM32的SPI之前,我们必须先抛开库函数和寄存器,把SPI协议本身,尤其是最让人头疼的时钟模式(CPOL和CPHA)吃透。很多朋友配置SPI失败,第一步就卡在这里。

2.1 CPOL与CPHA:并非随意组合的四选一

SPI的四种模式,本质上是时钟极性(CPOL)和时钟相位(CPHA)两个布尔值的组合。但记住“00, 01, 10, 11”对应的模式0/1/2/3是没用的,关键要理解其物理意义。

  • CPOL (Clock Polarity):时钟极性。它定义的是SPI时钟线(SCK)在空闲状态(即片选CS无效,无数据传输时)的电平。

    • CPOL=0:SCK空闲时为低电平。
    • CPOL=1:SCK空闲时为高电平。
    • 一个常见的误解:认为CPOL决定了数据在上升沿还是下降沿采样。不对,它只决定空闲状态。采样边沿由CPHA决定。
  • CPHA (Clock Phase):时钟相位。它定义的是数据在时钟的哪个边沿被采样(捕获),以及在哪个边沿被改变(移位输出)。

    • CPHA=0:数据在时钟的第一个边沿被采样,在时钟的第二个边沿被改变。这里的“第一个边沿”是相对于SCK从空闲状态跳变开始的第一个边沿。
    • CPHA=1:数据在时钟的第二个边沿被采样,在时钟的第一个边沿被改变。

理解这个定义最直观的方法是看时序图,但光看图容易忘。我总结了一个更“感性”的记忆和理解方法:关注数据建立(Setup)和保持(Hold)的时间窗口相对于时钟边沿的关系。对于从设备(如传感器、Flash芯片)来说,它需要在主设备STM32提供的SCK的某个边沿稳定地采样MOSI上的数据。因此,从设备的数据手册是唯一权威。它一定会明确规定自己工作在SPI的哪种模式(Mode 0/1/2/3)。

2.2 如何为你的从设备选择正确模式?

假设你拿到一颗W25Q64 Flash芯片的数据手册,找到了SPI时序部分。它通常会写明:“支持Mode 0和Mode 3”。或者更详细地给出时序图。你的操作步骤应该是:

  1. 确定CPOL:看时序图中,SCK线在CS片选无效(高电平)期间的状态。如果是一直保持低电平,那么CPOL=0;如果保持高电平,CPOL=1。
  2. 确定CPHA:找到数据采样点。看MISO线(主设备输入)上的数据,是在SCK的上升沿还是下降沿变得稳定并被采样?同时看MOSI线(主设备输出)上的数据,是在哪个边沿发生变化的。根据CPHA的定义反推。
  3. 对照模式表:将得出的CPOL和CPHA组合,对应到模式0-3。

一个必须避开的坑:主从设备的模式必须严格一致。STM32作为主设备,它的CPOL和CPHA配置必须完全遵从从设备的要求。如果从设备是Mode 0,STM32也必须配成Mode 0。配错模式是导致“有时钟,无数据”或“数据错乱”的最常见原因。

3. CubeMX配置详解:超越默认选项

理解了协议,我们进入实战。STM32CubeMX和HAL库极大简化了配置,但也隐藏了一些细节。以下配置以STM32F407为例,使用SPI1。

3.1 基础参数配置:细节决定成败

在CubeMX的SPI配置界面,我们需要关注以下几个关键点:

  • Mode:选择Full-Duplex Master(全双工主机)。这是最常用的模式,MOSI和MISO独立,可以同时收发。
  • Hardware NSS Signal:硬件NSS(片选)信号。对于单个从设备,我强烈建议将此选项设为Disable,而使用任意一个GPIO口来软件控制片选。原因如下:
    • 灵活性:硬件NSS通常与特定引脚绑定(如SPI1的NSS是PA4或PA15),你可能需要这个引脚做其他用途。
    • 多从机支持:硬件NSS通常难以优雅地支持多个从设备。软件控制可以轻松管理多个片选GPIO。
    • 时序控制:软件片选可以让你在数据传输前后精确控制CS引脚的高低电平时间,满足某些从设备的特殊时序要求(如两次传输间需要一定延时)。
  • Baud Rate Prescaler:波特率预分频器。这是决定SPI时钟速度的核心。SCK频率 =APBx总线时钟 / Prescaler。初始调试时,建议选择一个较低的速度,如PCLK2 / 256,确保通信稳定。调通后再逐步提高速度测试稳定性。
  • Data Size:数据大小,通常为8 Bits。有些16位ADC可能会用16位模式。
  • First Bit:首比特顺序。绝大多数设备都是MSB First(最高位先传),除非从设备手册特别说明。
  • CPOL & CPHA:根据上一节的分析,严格按照从设备要求选择。例如从设备是Mode 0,则CPOL=Low, CPHA=1 Edge。

3.2 进阶配置:为性能和稳定打下基础

点击Parameter Settings标签页下的User Constants或直接修改生成的代码,有几个地方值得优化:

  • CRC Calculation:除非协议强制要求(如某些安全通信),否则保持Disable。开启会增加软件开销。
  • NSSP Mode:NSS脉冲模式,通常保持Disable
  • FIFO Threshold:FIFO阈值。如果你的芯片支持SPI FIFO(如F4/F7/H7系列),合理设置阈值可以优化DMA或中断性能。例如,在TX模式下,设置为FIFO threshold equal to 1/4 of FIFO size可以在FIFO有1/4空时就触发TXE(发送缓冲区空)中断,实现更平滑的数据流。

配置完成后生成代码,CubeMX会帮我们初始化GPIO和SPI外设。但真正的挑战才刚刚开始。

4. HAL库SPI驱动:阻塞、中断与DMA的抉择

HAL库提供了三种SPI传输方式:阻塞式、中断式和DMA式。选择哪种,取决于你的应用场景和对系统实时性的要求。

4.1 阻塞式传输:简单场景的利器

这是最简单的方式,调用HAL_SPI_Transmit(&hspi1, pData, Size, Timeout)后,函数会一直等待,直到传输完成或超时。

优点:代码直观,易于调试。缺点:CPU被完全占用,无法执行其他任务。在传输大量数据时,会导致系统“卡死”。适用场景:上电初始化时配置从设备寄存器、传输少量数据(如读取传感器ID)。

一个关键技巧:超时时间设置。超时时间不宜过短,否则容易在系统繁忙时误判为通信失败。建议根据波特率计算一个合理值。例如,传输100字节,波特率1MHz,理论耗时约0.8ms(8bit/字节 * 100 / 1e6 ≈ 0.8ms)。考虑到系统调度等开销,可以将超时设置为理论值的5-10倍,比如10ms。

// 示例:阻塞式读取W25Q64的制造商和设备ID uint8_t cmd = 0x90; // 读ID命令 uint8_t dummy_addr[3] = {0, 0, 0}; // 24位地址,此处为0 uint8_t id_buffer[2] = {0}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); // 拉低片选 HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); // 发送命令,超时100ms HAL_SPI_Transmit(&hspi1, dummy_addr, 3, 100); // 发送地址 HAL_SPI_Receive(&hspi1, id_buffer, 2, 100); // 接收ID HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); // 拉高片选

4.2 中断式传输:平衡CPU占用与复杂度

中断方式下,CPU启动传输后即可返回,执行其他任务。传输完成或半程(如果使能)时,SPI外设会产生中断,在中断回调函数中处理后续逻辑。

优点:释放了CPU,提高了系统并发能力。缺点:编程模型变复杂,需要处理中断,并注意重入等问题。频繁的中断仍会消耗大量CPU资源。适用场景:中等数据量、非周期性、实时性要求一般的传输。

关键点:回调函数的使用。HAL库为我们准备了传输完成回调函数HAL_SPI_TxRxCpltCallback。我们需要在main.c或专门的驱动文件里重写(Weak)这个函数。

// 在合适的地方声明一个全局标志位或信号量 volatile uint8_t spi_transfer_done = 0; // 重写传输完成回调函数 void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi->Instance == SPI1) { spi_transfer_done = 1; // 设置完成标志 } } // 在主循环或任务中 uint8_t tx_buffer[100], rx_buffer[100]; spi_transfer_done = 0; HAL_SPI_TransmitReceive_IT(&hspi1, tx_buffer, rx_buffer, 100); // 等待传输完成(这里用简单轮询示意,实际项目建议用RTOS信号量) while(spi_transfer_done == 0) { // 可以执行其他低优先级任务 } // 传输完成,处理rx_buffer数据

注意:中断回调函数是在中断上下文执行的,务必保持简短,不要在里面做耗时操作(如打印日志)。通常只设置标志位、释放信号量或复制数据到安全缓冲区。

4.3 DMA传输:大数据量与高性能的必选项

当需要传输大量数据(如图像、音频、大块Flash读写)或要求极高的传输效率时,DMA(直接存储器访问)是唯一的选择。DMA控制器可以在不占用CPU核心的情况下,在内存和外设(如SPI数据寄存器)之间搬运数据。

优点:几乎零CPU开销,传输效率高,特别适合连续、大批量数据传输。缺点:配置相对复杂,需要理解DMA流/通道、传输模式(普通/循环)、数据对齐、中断协调等问题。适用场景:LCD屏刷新、SD卡读写、高速ADC数据采集、与FPGA进行高速数据交换。

DMA配置的核心步骤与避坑指南:

  1. CubeMX中配置DMA:在SPI配置的DMA Settings标签页,添加SPIx_TXSPIx_RX的DMA请求。选择正确的流(Stream)和通道(Channel),优先级设为MediumHigh。模式(Mode)通常选Normal(单次传输),如果是连续传输(如驱动LCD),可以考虑Circular(循环模式)。
  2. 内存地址与宽度:确保Memory的数据宽度(Data Width)与SPI的数据宽度(8/16位)匹配。例如SPI是8位,Memory也应是Byte
  3. 使用HAL_DMA_SPI函数:调用HAL_SPI_TransmitReceive_DMA启动传输。
  4. 处理DMA传输完成中断:和中断方式类似,需要重写HAL_SPI_TxRxCpltCallback。但此时,SPI传输和DMA传输都已完成。
  5. 最大的坑:数据缓存一致性。如果CPU和DMA共同访问同一块内存(即你提供的tx/rx缓冲区),且CPU有缓存(如Cortex-M7),你必须处理缓存一致性问题。在DMA传输前,如果CPU修改了发送缓冲区,需要调用SCB_CleanDCache_by_Addr清理缓存,确保DMA看到的是最新数据。在DMA传输完成后,如果CPU要读取接收缓冲区,需要调用SCB_InvalidateDCache_by_Addr无效化缓存,确保CPU从内存读取新数据。对于无缓存的M3/M4内核,则无需此操作。
// DMA传输示例(假设无缓存或已处理缓存) ALIGN_32BYTES(uint8_t spi_tx_buffer[1024]); // 建议对齐缓冲区,提升DMA效率 ALIGN_32BYTES(uint8_t spi_rx_buffer[1024]); // 填充发送数据... // 清理缓存(M7内核需要) // SCB_CleanDCache_by_Addr((uint32_t*)spi_tx_buffer, sizeof(spi_tx_buffer)); spi_transfer_done = 0; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); if(HAL_SPI_TransmitReceive_DMA(&hspi1, spi_tx_buffer, spi_rx_buffer, 1024) != HAL_OK) { // 错误处理 } // 等待传输完成回调函数设置标志位... // 传输完成后,无效化缓存(M7内核需要) // SCB_InvalidateDCache_by_Addr((uint32_t*)spi_rx_buffer, sizeof(spi_rx_buffer)); // 处理接收数据... HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET);

5. 实战排坑:那些手册里不会写的“暗坑”

即使你正确配置了模式和参数,在实际项目中依然会遇到各种奇怪的问题。下面分享几个我亲身踩过并填平的坑。

5.1 片选(CS)时序管理:不仅仅是拉低和拉高

软件片选给了我们灵活性,也带来了责任。片选时序不当是导致通信不可靠的隐形杀手。

  • 建立与保持时间:很多从设备要求,在SCK产生第一个边沿之前,片选CS必须已经有效(拉低)一段时间(Tcss,建立时间)。在最后一个SCK边沿之后,CS必须继续保持有效一段时间(Tcsh,保持时间)才能拉高。如果忽略这些,轻则第一次读写失败,重则数据完全错位。

    • 解决方案:在拉低CS后,增加一个微秒级的延时(HAL_Delay(1)DWT_Delay_us(5)),再开始SPI传输。传输结束后,同样延时再拉高CS。具体延时值需查阅从设备数据手册。
  • 两次传输间的间隔:连续进行两次SPI传输时,必须确保CS有一个从高到低再到高的完整周期。不能在两次HAL_SPI_Transmit调用之间保持CS为低。因为很多设备以CS的下降沿作为一次传输命令的起始标志。

    • 错误示范
      HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, 0); // CS拉低 HAL_SPI_Transmit(&hspi1, cmd1, 1, 10); // 发送命令1 // 缺少CS拉高再拉低的过程! HAL_SPI_Transmit(&hspi1, cmd2, 1, 10); // 发送命令2 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, 1); // CS拉高
    • 正确做法:每次独立的命令或数据帧传输,都必须用CS的下降沿和上升沿包裹起来。

5.2 HAL_SPI_TransmitReceive 的“虚读”与“虚写”

HAL_SPI_TransmitReceive是一个全双工函数,它同时启动发送和接收。但这里有一个关键点:SPI是同步接口,主设备必须提供时钟才能读取数据。而时钟是通过发送数据产生的。

这意味着,如果你想从从设备读取N个字节,主设备(STM32)必须同时发送出N个字节(可以是任意值,通常是0xFF或0x00)来驱动SCK时钟。反过来,如果你只想发送数据而不关心接收,你仍然会收到等长的垃圾数据,需要提供一个缓冲区来接收它们(或者忽略接收中断/DMA完成标志)。

  • 只想读取数据

    uint8_t tx_dummy[10]; uint8_t rx_real[10]; memset(tx_dummy, 0xFF, 10); // 发送0xFF是读取Flash等设备的常见做法 HAL_SPI_TransmitReceive(&hspi1, tx_dummy, rx_real, 10, 100); // 此时 rx_real 中才是从设备返回的真实数据
  • 只想发送数据

    uint8_t tx_real[10]; uint8_t rx_dummy[10]; // 必须提供一个接收缓冲区 HAL_SPI_TransmitReceive(&hspi1, tx_real, rx_dummy, 10, 100); // 忽略 rx_dummy 的内容

5.3 SPI总线冲突与多从机隔离

当一个SPI总线上挂载多个从设备时,必须严格管理片选,确保同一时刻只有一个从设备的CS处于有效状态。如果两个从设备的CS同时被意外拉低,它们会同时向MISO线输出数据,造成总线冲突(电平竞争),可能导致数据错误甚至损坏硬件。

硬件设计建议:如果从设备不多(<=4个),可以为每个从设备分配一个独立的GPIO作为片选。这是最清晰可靠的方式。软件设计建议:将每个从设备的SPI操作封装成独立的函数,在函数入口处拉低对应CS,在函数退出前(或所有操作完成后)拉高CS。确保这些函数不会被高优先级中断打断,或者使用互斥锁(在RTOS中)保护对SPI总线的访问。

5.4 时钟速度与布线:当SPI跑在高速时

当SPI时钟频率提高到几十MHz时,PCB布线和信号完整性问题就会凸显出来。

  • SCK、MOSI、MISO走线:应尽可能等长、平行走线,并远离高频噪声源(如开关电源、晶振)。
  • 片选线走线:虽然对时序要求不如数据线严格,但也应尽量短。
  • 上拉电阻:SPI协议本身是推挽输出,一般不需要外部上拉。但在总线较长、从设备可能热插拔或处于高阻态时,在SCK、MOSI、MISO上添加弱上拉电阻(如10kΩ)可以帮助稳定空闲状态,防止因浮空输入而引入噪声和额外功耗。
  • 实际速度测试:使用逻辑分析仪或示波器测量实际的SCK频率和信号质量。过冲、振铃或边沿过于缓慢都会导致通信失败。如果发现问题,可以尝试:
    1. 降低SPI波特率。
    2. 在STM32端配置GPIO的输出速度(GPIO Speed)。对于高速SPI,应设置为HighVery High
    3. 在信号线上串联一个小电阻(如22-100欧姆)进行阻抗匹配,抑制振铃。

6. 进阶话题:SPI与RTOS、低功耗的协同

在复杂的嵌入式系统中,SPI通信往往不是孤立的,它需要与实时操作系统(RTOS)和低功耗设计协同工作。

6.1 在RTOS任务中安全使用SPI

当有多个任务需要访问同一个SPI外设(例如,一个任务读写Flash,另一个任务读取传感器)时,必须对SPI总线进行互斥保护,防止资源竞争。

  • 使用互斥锁(Mutex):这是最推荐的方式。在SPI驱动初始化时创建一个互斥锁。任何任务在调用SPI传输函数前,必须先获取这个锁;传输完成后,释放锁。
// 假设使用FreeRTOS SemaphoreHandle_t spi1_mutex; void SPI1_Init(void) { // ... SPI硬件初始化代码 spi1_mutex = xSemaphoreCreateMutex(); // 创建互斥锁 } int32_t SPI1_TransmitReceive_Safe(uint8_t *tx, uint8_t *rx, uint16_t size, uint32_t timeout) { if(xSemaphoreTake(spi1_mutex, pdMS_TO_TICKS(timeout)) == pdTRUE) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_StatusTypeDef status = HAL_SPI_TransmitReceive(&hspi1, tx, rx, size, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); xSemaphoreGive(spi1_mutex); return (status == HAL_OK) ? 0 : -1; } else { return -2; // 获取锁超时 } }
  • 中断/DMA回调中的同步:如果使用中断或DMA,传输完成的通知发生在中断上下文。此时不能直接使用会阻塞的RTOS API(如xQueueSend),而应使用其FromISR版本(如xQueueSendFromISR),并考虑进行任务通知(Task Notification)或使用二进制信号量。

6.2 低功耗应用下的SPI管理

在电池供电的设备中,SPI外设是耗电大户。不当的管理会使设备无法进入深度睡眠。

  • 传输间隙关闭SPI时钟:在两次SPI传输之间,如果间隔较长,可以在软件中禁用SPI外设的时钟(通过__HAL_RCC_SPI1_CLK_DISABLE())。下次使用前再开启。这需要重新初始化SPI吗?通常HAL库的初始化函数会配置GPIO和SPI寄存器,如果只关闭时钟而不复位外设,重新使能时钟后寄存器配置可能还在。但最稳妥的做法是在进入低功耗前DeInit,唤醒后重新Init。注意:这会增加额外的延迟。
  • 从设备的选择:选择支持低功耗模式的从设备。在不需要通信时,通过发送特定命令让从设备进入睡眠模式(Sleep/Shutdown),同时STM32也可以拉高其片选线。
  • IO口状态:将SPI相关的GPIO(SCK, MOSI, CS)在睡眠前配置为模拟输入(Analog)模式,并关闭内部上下拉,这样可以减少漏电流。唤醒后再重新配置为复用推挽输出等模式。这是一个容易忽略但有效的省电细节。

7. 调试技巧:当SPI不工作时如何快速定位

“我的SPI没反应!”——这是调试中最常听到的话。一套系统的排查方法能帮你快速找到问题。

  1. 硬件检查

    • 供电:用万用表测量从设备和STM32的供电电压是否正常、稳定。
    • 连线:检查SCK、MOSI、MISO、CS四根线是否连接正确、牢固,有无短路或虚焊。特别注意:MOSI和MISI是否接反了?这是新手最高频的错误。
    • 引脚复用:确认你使用的SPI引脚没有被其他功能(如JTAG、USB)占用。例如,STM32F1的PA15、PB3、PB4默认是JTAG引脚,用作SPI时需要先禁用JTAG。
  2. 软件配置检查

    • 模式CPOL/CPHA:再次核对,这是第一嫌疑犯。
    • 时钟使能:确认在main.cSystemClock_Config之后,已经调用了MX_SPIx_Init()
    • GPIO模式:SPI的SCK、MOSI应配置为Alternate Function Push-Pull,MISO为InputAlternate Function,CS为Output Push-Pull
  3. 动态信号观测(最有效)

    • 工具:逻辑分析仪是调试SPI的利器。没有的话,示波器也可以,但解码起来麻烦些。
    • 观测点:同时抓取SCK、MOSI、MISO、CS四路信号。
    • 分析
      • CS线:是否在预期的时间被拉低和拉高?拉低的时间是否覆盖了整个数据帧?
      • SCK线:是否有时钟输出?频率是否符合配置?空闲电平是否符合CPOL设置?
      • MOSI线(STM32输出):在CS有效期间,随着SCK跳动,MOSI上是否有数据变化?数据值是否和你发送的缓冲区内容一致?第一个比特是MSB还是LSB?
      • MISO线(从设备输出):在CS有效期间,从设备是否有数据输出?如果MOSI有数据而MISO没有,问题可能出在从设备(未上电、模式不对、损坏)或MISO线路。
    • 一个典型故障波形:SCK正常,MOSI一直为低或高,没有数据变化。这很可能是因为STM32的SPI数据寄存器(DR)没有被写入数据。检查你的发送缓冲区地址是否正确,是否在传输前填充了数据,或者DMA配置是否正确指向了发送缓冲区。
  4. 分步测试法

    • 先写一个最简单的测试程序:只发送一个固定的字节(如0xAA),用逻辑分析仪看MOSI上是否有对应的比特流(0xAA = 0b10101010)。
    • 如果发送正常,再测试接收。发送一个读取从设备状态寄存器/ID的命令,看MISO上是否有数据返回。
    • 从单字节阻塞传输开始测试,逐步增加复杂度到多字节、中断、DMA。

调试SPI就像破案,硬件是现场,软件是线索,逻辑分析仪是显微镜。遵循“先硬件后软件,先配置后时序,先简单后复杂”的原则,大部分问题都能被定位和解决。