STM32 SPI从机模式+DMA双机通信实战:配置、协议与避坑指南

发布时间:2026/7/31 15:55:26
STM32 SPI从机模式+DMA双机通信实战:配置、协议与避坑指南
1. 从“主”到“从”一个被忽视的SPI应用场景在嵌入式开发圈子里一提到STM32的SPI大家脑子里蹦出来的第一个应用场景大概率是驱动外设SPI Flash、TFT屏幕、无线模块、各类传感器……在这些场景里STM32稳坐“主机”宝座掌控着时钟SCK的生杀大权指挥着数据的流动。主机模式下的配置、DMA传输、中断处理相关的教程和代码示例铺天盖地几乎成了每个STM32开发者的必修课。但不知道你有没有遇到过这样的需求两块STM32之间需要高速、全双工地交换数据I2C太慢UART又占用了宝贵的串口资源或者需要同步时钟。这时候SPI的双机通信就成了一个非常优雅的解决方案。然而一旦涉及到让其中一块STM32扮演“从机”角色很多人的经验就瞬间清零了。网上的资料也呈现出一种“冰火两重天”的态势主机模式的资料汗牛充栋而从机模式尤其是稳定、高效、带DMA的从机模式相关的深入讨论和避坑指南却少得可怜。我自己就在一个数据采集项目中踩过这个坑。项目里一块STM32F4作为前端传感器数据采集单元从机需要将实时采集的AD数据高速发送给另一块负责复杂算法处理和网络上传的STM32H7主机。最初尝试用查询方式做从机发送结果不是丢数据就是程序被“卡死”在等待标志位上主机时钟一停从机就“傻等”。后来改用中断虽然有所改善但在大数据量连续传输时中断开销又成了瓶颈CPU利用率居高不下。最终经过一番折腾才把SPI从机DMA这套组合拳打通。今天我就把自己从“主机思维”切换到“从机实战”过程中积累的经验、踩过的坑以及如何结合DMA实现高效、稳定的双机SPI通信系统地梳理出来。如果你正在或即将面临类似的需求希望这篇内容能帮你少走弯路。2. SPI从机模式的本质被动响应与同步之舞要玩转SPI从机首先得从根上理解它和主机模式的核心差异。这不是简单的配置参数改动而是工作哲学的根本不同。2.1 时钟主权与数据流控制在SPI协议中SCK时钟信号由主机独家产生这是主机权力的象征。对于从机而言SCK是一个纯粹的输入信号。这意味着从机无法发起通信从机不能主动产生一个时钟周期来发送或请求数据。它的一切行为都必须等待主机发出的SCK边沿来触发。从机必须严格同步从机的内部移位寄存器必须在主机SCK的驱动下与主机保持严格的位同步。主机发一个脉冲双方就各自移出一位移入一位。这种“被动性”是理解所有从机模式问题的基石。你的从机代码逻辑必须建立在“响应主机时钟”这一前提下而不是试图去“控制”或“预测”通信流程。2.2 STM32 SPI从机硬件架构要点以STM32F4/H7系列常见的SPI外设为例在从机模式下以下几个硬件特性需要特别关注NSS引脚片选的角色变化在主机模式下NSS通常可以配置为软件管理或作为通用输出引脚去控制外部从器件的片选。在从机模式下NSS必须被硬件管理Hardware Slave Select Management。这意味着NSS引脚必须配置为输入模式并由外部的主机来控制其电平。当NSS为低电平时本设备被选为从机SPI接口激活当NSS为高时SPI接口被禁用通常也会复位内部状态。这是一个极易配置错误的点如果配置成软件管理从机可能根本无法正常工作。数据寄存器DR与移位寄存器的交互当主机发起传输SCK开始跳动时从机的移位寄存器就开始工作。如果从机需要发送数据它必须在主机SCK驱动第一个边沿到来之前将待发送的数据写入发送缓冲区TXE标志置1后写入DR寄存器。写入DR的数据会被立即或很快加载到移位寄存器中等待SCK移出。对于接收数据从机在SCK驱动下将MOSI线上的数据移入移位寄存器当收到一个完整的数据帧比如8位或16位后接收缓冲区非空RXNE标志置1数据可以从DR寄存器中读取。关键点从机的“发送”和“接收”在硬件层面是同时进行的全双工。即使你只想接收也必须写入一个“哑元数据”Dummy Data到DR寄存器以产生SCK时钟实际上SCK由主机产生但主机需要从机在MISO线上给出电平这个电平就来自哑元数据。反之亦然。时钟极性CPOL与相位CPHA的从机视角CPOL/CPHA决定了数据在SCK的哪个边沿被采样和更新。从机的CPOL和CPHA配置必须与主机严格一致否则读到的将是乱码。这一点和主机模式要求一样但在双机通信中因为两端都是可编程的MCU更容易因疏忽而导致配置不匹配。理解了这些本质我们就能明白编写SPI从机程序的核心思想是提前准备好要发送的数据并时刻准备好在主机时钟的驱动下完成发送和接收同时通过中断或DMA及时处理接收到的数据和准备下一帧要发送的数据。3. 从机模式配置详解从CubeMX到代码理论说再多不如一行配置。我们以STM32CubeMX工具配合HAL库为例一步步拆解一个SPI从机的配置过程。这里假设我们使用SPI1目标是与另一块STM32主机进行全双工16位数据通信。3.1 CubeMX图形化配置模式选择在Pinout Configuration标签页找到SPI1。将Mode设置为Full-Duplex Slave。注意这里没有“Transmit Only Slave”或“Receive Only Slave”全双工是硬件基础。硬件NSS管理将Hardware NSS Signal设置为Hardware NSS Input。这个选项至关重要它决定了NSS引脚的模式。通常你会看到SS引脚比如PA4被自动配置为输入模式。基本参数Frame Format:Motorola。Data Size: 根据需求选择例如16 Bits。注意主机和从机必须相同。First Bit: 通常选择MSB First。Clock Polarity和Clock Phase: 这里必须和你的主机配置一模一样假设主机配置为Low和1 Edge那么这里就选Low和1 Edge。一个记忆方法是看主机空闲时SCK的电平CPOL和在第几个边沿采样数据CPHA。CRC计算与NSSP脉冲模式在从机模式下除非协议特殊要求通常关闭CRC计算CRC Calculation-Disable。NSS Pulse Mode也通常禁用它用于在多从机系统中每次传输后产生一个NSS脉冲在标准双机通信中一般不需要。DMA配置关键步骤点击DMA Settings标签页。点击Add添加DMA请求。对于SPI从机我们需要两个DMA流/通道一个用于发送SPI1_TX一个用于接收SPI1_RX。发送DMATX配置Mode:Normal或Circular。如果数据是连续、循环发送的如实时音频、传感器流用Circular如果是定长数据块用Normal。Increment Address: 使能。因为我们要传输的是一个数组。Data Width: 根据Data Size选择如果数据是16位这里选Half Word。接收DMARX配置Mode: 同样根据需求选择Normal或Circular。强烈建议接收使用Circular模式这样可以构建一个环形缓冲区持续接收主机发来的数据避免溢出。Increment Address: 使能。Data Width:Half Word。DMA中断为了在传输完成或半传输时得到通知建议使能接收DMA流的传输完成中断TC和半传输中断HT。在NVIC Settings中使能DMA对应的全局中断。3.2 生成代码与关键函数解析生成代码后HAL库已经为我们初始化好了SPI和DMA。我们需要关注以下几个核心函数和操作启动DMA接收这是从机准备接收数据的起点。必须在主机开始传输之前调用。// 假设我们定义了一个接收缓冲区 uint16_t rx_buffer[BUFFER_SIZE]; // 在main初始化部分或某个准备函数中启动循环DMA接收 HAL_SPI_Receive_DMA(hspi1, (uint8_t*)rx_buffer, BUFFER_SIZE);调用这个函数后DMA控制器就会“盯住”SPI的DR寄存器。一旦主机开始传输SPI收到数据并存入DRDMA就会自动将其搬运到rx_buffer中同时递增地址。当填满整个缓冲区或半满时如果使能了HT中断会触发相应的DMA中断。启动DMA发送从机发送数据也需要DMA来驱动。// 假设我们定义了一个发送缓冲区并已经填充了数据 uint16_t tx_buffer[BUFFER_SIZE]; // 填充tx_buffer... // 启动DMA发送 HAL_SPI_Transmit_DMA(hspi1, (uint8_t*)tx_buffer, BUFFER_SIZE);这里有一个极其重要的细节对于从机调用HAL_SPI_Transmit_DMA并不会立即开始发送。它只是配置DMA将tx_buffer中的数据预先搬运到SPI的发送缓冲区。真正的发送动作要等到主机拉低NSS并开始产生SCK时钟时才会发生。从机的DMA会在每个数据被发送后TXE标志自动加载下一个数据到DR寄存器。中断处理我们需要在DMA的中断服务函数中处理接收到的数据。// 在stm32f4xx_it.c或类似文件中 void DMA2_Stream0_IRQHandler(void) // 假设SPI1_RX使用DMA2_Stream0 { if(__HAL_DMA_GET_HT_FLAG(hdma_spi1_rx)) { // 半传输完成可以处理rx_buffer的前半部分数据 process_rx_data(rx_buffer, 0, BUFFER_SIZE/2); __HAL_DMA_CLEAR_FLAG(hdma_spi1_rx, DMA_FLAG_HTIF0_4); } if(__HAL_DMA_GET_TC_FLAG(hdma_spi1_rx)) { // 全传输完成可以处理rx_buffer的后半部分数据 process_rx_data(rx_buffer, BUFFER_SIZE/2, BUFFER_SIZE); __HAL_DMA_CLEAR_FLAG(hdma_spi1_rx, DMA_FLAG_TCIF0_4); } }这种“双缓冲”机制是处理连续流数据的关键可以在处理一半数据时另一半继续接收实现无缝衔接。3.3 一个常见的初始化流程示例void SPI_Slave_Init(void) { // CubeMX已生成SPI和DMA的初始化代码HAL_SPI_MspInit等 // 1. 先启动循环DMA接收让从机随时准备“听” HAL_SPI_Receive_DMA(hspi1, (uint8_t*)spi_rx_buffer, SPI_BUFFER_SIZE); // 2. 准备要发送的数据并启动DMA发送等待主机时钟 prepare_transmit_data(spi_tx_buffer, SPI_BUFFER_SIZE); HAL_SPI_Transmit_DMA(hspi1, (uint8_t*)spi_tx_buffer, SPI_BUFFER_SIZE); // 此时从机已就绪。 // SPI_RX的DMA在循环接收数据会源源不断存入spi_rx_buffer。 // SPI_TX的DMA已就绪数据已装载一旦主机选通NSS拉低并产生SCK数据就会自动发出。 }4. 双机通信的同步与协议设计硬件和驱动配置好了两块板子也连上了线MISO-MOSI交叉SCK和NSS直连GND共地是不是就能愉快地通信了还不行。没有协议的通信就像没有交通规则的十字路口迟早要撞车。SPI硬件只负责搬运比特数据的意义需要软件来定义。4.1 主从同步启动问题这是第一个拦路虎。主机怎么知道从机的DMA接收/发送已经准备好了一个稳健的做法是引入一个简单的握手信号。方案一利用GPIO握手。从机初始化完成后将一个GPIO引脚例如READY_PIN置高。主机程序在开始SPI传输前先循环检测这个引脚是否为高。一旦检测到说明从机已就绪主机再拉低NSS开始传输。传输结束后主机可以将该引脚拉低或由从机在数据处理完后拉低以指示“忙”状态。方案二利用首字节/帧协议。主机发送一个特定的命令帧例如0xAA55作为通信开始的标志。从机在DMA接收缓冲区中持续检测这个特定模式。一旦检测到就从下一个数据开始作为有效数据来处理。这种方式不需要额外的连线但软件逻辑稍复杂。4.2 数据帧结构设计对于双机通信设计一个包含帧头、长度、数据、校验的帧结构是非常必要的。typedef struct { uint16_t header; // 帧头如 0x5AA5 uint16_t length; // 有效数据载荷长度 uint8_t cmd; // 命令字 uint8_t data[256]; // 数据载荷 uint16_t checksum; // CRC16校验和 } SPI_Frame_t;帧头用于在连续的比特流中识别一帧的开始。从机的接收处理程序需要在DMA环形缓冲区中搜索这个帧头。长度指明后面data字段的真实长度避免解析错误。命令字定义这帧数据的用途例如“读取传感器”、“设置参数”、“心跳包”等。校验和用于验证数据传输的正确性。SPI硬件本身没有错误检测机制软件校验是保证数据可靠性的最后一道防线。4.3 从机端的“流”解析由于从机的DMA接收是循环的我们得到的是一个源源不断的字节流。需要在中断半传输/全传输服务函数中实现一个状态机来解析这个流提取出完整的帧。typedef enum { FRAME_STATE_IDLE, FRAME_STATE_HEADER1, FRAME_STATE_HEADER2, FRAME_STATE_LENGTH, FRAME_STATE_CMD, FRAME_STATE_DATA, FRAME_STATE_CHECKSUM } FrameParseState; void process_rx_buffer(uint16_t* buf, uint32_t start, uint32_t end) { static FrameParseState state FRAME_STATE_IDLE; static SPI_Frame_t current_frame; static uint16_t data_index 0; static uint16_t calc_checksum 0; for(uint32_t i start; i end; i) { uint8_t byte (uint8_t)(buf[i] 0xFF); // 假设我们按字节解析 switch(state) { case FRAME_STATE_IDLE: if(byte 0x5A) state FRAME_STATE_HEADER1; break; case FRAME_STATE_HEADER1: if(byte 0xA5) { state FRAME_STATE_LENGTH; calc_checksum 0; // 可以开始计算校验或重置帧结构 } else { state FRAME_STATE_IDLE; // 头错误复位状态机 } break; // ... 其他状态处理长度、命令、数据 case FRAME_STATE_CHECKSUM: // 校验接收到的校验和与计算的calc_checksum是否一致 if(checksum_ok) { // 将完整的current_frame交给应用层处理 handle_received_frame(current_frame); } state FRAME_STATE_IDLE; // 处理完毕复位状态机 break; } } }这个状态机在DMA中断中运行每次处理半个或整个缓冲区。它不阻塞主循环实现了通信与处理的解耦。5. 避坑指南从机模式下的那些“雷”纸上得来终觉浅绝知此事要踩坑。下面是我在实际项目中遇到的几个典型问题及其解决方案。5.1 NSS引脚配置错误导致通信全无现象逻辑分析仪显示主机在疯狂产生SCK但MISO线上完全没有数据或者数据全是0xFF/0x00。排查首先检查硬件连线。然后重点检查从机的NSS引脚配置。在CubeMX中确认Hardware NSS Signal设置为Hardware NSS Input而不是SoftWare。在代码中检查生成的GPIO初始化代码确认NSS引脚如PA4被初始化为GPIO_MODE_AF_PP复用推挽输出不对对于从机模式的NSS输入它应该被初始化为浮空输入(GPIO_MODE_INPUT)或上拉输入具体看硬件连接。但CubeMX配置为Hardware NSS Input后通常会自动将其设置为正确的复用功能输入模式。最可靠的验证方法是用示波器或逻辑分析仪同时测量主机的NSS输出和从机的NSS输入看电平是否同步变化。如果从机NSS引脚一直为高SPI外设根本不会工作。解决确保CubeMX配置正确并检查原理图确认NSS线连接无误。有时为了简化在单一从机系统中主机可以不用NSS而将从机的NSS引脚直接接地永久选中。但这仅限于一对一通信且从机SPI需支持该模式有些SPI从机NSS必须有时序。5.2 DMA传输模式与缓冲区溢出现象通信一开始正常一段时间后数据错乱或者DMA中断不再触发。排查发送端如果从机发送DMA配置为Normal模式当传输完指定长度后DMA传输完成中断TC触发DMA停止。如果主机还在继续要数据但从机已经没有新的DMA传输来提供数据SPI的TXE标志会一直置起但DR寄存器里没有新数据可能导致发送0xFFFF或旧数据。主机读到的数据就会出错。接收端如果接收DMA配置为Normal模式收满指定长度后停止。此时如果主机继续发送数据SPI会收到数据并试图存入DR但DMA已停止搬运会导致溢出错误OVR标志置位后续数据全部丢失。解决对于连续流数据接收DMA务必使用Circular模式。这样DMA会在缓冲区首尾循环永不停止只要处理速度跟上就不会丢数据。对于发送根据业务决定。如果是应答式通信主机发命令从机回数据用Normal模式每次主机发起新传输前从机重新启动一次DMA发送。如果是从机持续推送数据如传感器流也需要用Circular模式并确保应用层能及时填充发送缓冲区的下一半利用DMA发送的半传输完成中断HT。5.3 时钟相位与极性不匹配现象逻辑分析仪显示SCK、MOSI、MISO上都有信号但主机和从机读到的数据完全对不上或者只有某些特定模式的数据能对上。排查这是最经典的SPI问题。用逻辑分析仪抓取SCK和MOSI/MISO的波形。对照数据手册仔细确认CPOLSCK在空闲时是高电平(1)还是低电平(0)主机和从机是否一致CPHA数据是在SCK的第一个边沿上升沿或下降沿采样还是在第二个边沿采样主机和从机是否一致一个快速验证方法让主机发送一个简单的字节例如0xAA (1010 1010)。用逻辑分析仪看MOSI线上的数据位是在SCK的哪个边沿稳定的数据应在采样边沿之前保持稳定。对比从机SPI配置的采样边沿。解决修改主机或从机一方的CPOL/CPHA配置确保两者完全一致。记住从机必须服从主机的时钟时序。5.4 数据位宽与DMA数据宽度不匹配现象通信能进行但数据的高8位和低8位是反的或者隔一个数据正确。排查检查SPI的Data Size设置和DMA的Data Width设置。如果SPI配置为16位通信(Data Size 16 Bits)那么每次传输的单位是16位2字节。DMA的Data Width也必须设置为Half Word16位。如果你定义的数据缓冲区是uint8_t数组但DMA以16位为单位去搬运就会导致内存访问错位。解决保持数据宽度一致。如果SPI是16位则数据缓冲区定义为uint16_t数组。HAL库函数调用时数据长度参数以16位字为单位。例如HAL_SPI_Receive_DMA(hspi1, (uint8_t*)rx_buf, 100)这里的100意味着接收100个16位数据即200字节。如果你的缓冲区rx_buf是uint16_t[100]那就对了。DMA配置中外设和内存的数据宽度都设为Half Word。5.5 从机发送数据未提前准备现象从机似乎只接收不发送或者发送的第一字节总是错的。根因如前所述从机的发送数据必须在主机SCK第一个边沿到来之前就位。如果你在主机开始传输后才去准备数据并启动DMA发送那么第一个时钟边沿到来时从机的发送缓冲区可能是空的TXE已置位但DR是旧的或未定义值导致发送出去的是“哑元”或错误数据。解决先启动从机的发送DMA再让主机开始传输。在从机初始化序列的最后调用HAL_SPI_Transmit_DMA。此时DMA会将第一个数据加载到SPI的发送缓冲区。当主机拉低NSS并产生SCK时第一个数据就能被正确移出。对于后续数据DMA会在TXE事件时自动加载。6. 进阶优化提升稳定性与吞吐量当基础通信打通后我们可以考虑一些优化措施让系统更稳健、效率更高。6.1 使用硬件CRC如果支持一些高端的STM32系列如H7的SPI支持硬件CRC计算。在双机通信中启用硬件CRC可以极大地减轻CPU负担并提高校验的可靠性。配置SPI的CRC Calculation为Enable并设置合适的CRC Polynomial。在发送端硬件会自动在数据帧后附加CRC值在接收端硬件会自动计算并比较CRC并可通过标志位或中断通知结果。这比软件计算CRC16要快得多也更可靠。6.2 利用FIFO与阈值STM32的SPI通常带有一定深度的TX/RX FIFO例如4个字。结合DMA可以通过设置FIFO阈值来优化DMA请求的触发时机。例如设置RX FIFO阈值为1/4满即收到4个数据中的1个就产生DMA请求可以减少DMA传输的延迟对于高速通信尤其有益。这需要在SPI初始化时通过寄存器进行精细配置HAL库可能没有直接接口需要直接操作寄存器。6.3 双缓冲与乒乓操作对于超高吞吐量的应用简单的DMA环形缓冲区可能仍然会在中断处理数据时面临被新数据覆盖的风险如果处理速度慢于接收速度。此时可以采用“乒乓缓冲”策略准备两个独立的缓冲区A和B。DMA首先循环填充缓冲区A。当A半满或全满时触发中断在中断服务程序中立即将DMA的目标地址切换到缓冲区B然后主程序可以安全地处理缓冲区A的数据。当B快满时再切回A。这需要动态控制DMA的目标内存地址可以通过__HAL_DMA_SET_MEM_ADDR等宏来实现实现零拷贝的数据交换。6.4 总线矩阵与内存访问优化在STM32H7等高性能MCU上SPI和DMA可能通过不同的总线矩阵如AXI, AHB访问内存DTCM, SRAM, SDRAM。如果DMA访问的内存位于低速存储区或者存在总线竞争可能会成为性能瓶颈。确保用于DMA传输的缓冲区位于高速、且与DMA控制器和SPI外设访问路径一致的内存中如H7的DTCM或AXI-SRAM。可以使用__attribute__((section(.ram_section)))来指定变量的存储位置。打通STM32的SPI从机模式特别是结合DMA就像是为你的嵌入式系统打开了一扇高速数据交换的后门。它摆脱了查询方式的低效和中断方式的频繁上下文切换在主机时钟的驱动下实现了近乎“无感”的并行数据搬运。关键在于理解从机的被动同步本质精心配置NSS和DMA模式设计好软件协议来管理数据流并避开那些硬件层面的陷阱。回顾整个实现过程最深刻的体会是“准备”二字。从机的代码核心思想就是“时刻准备着”——提前配置好DMA提前装载好发送数据提前开辟好接收缓冲区并设计好状态机来解析源源不断的比特流。当主机这位“指挥家”挥下时钟的指挥棒时从机这个“乐团”就能精准、流畅地奏出数据的乐章。