STM32 SPI从机模式实战:HAL库配置、中断与DMA驱动详解
1. 项目概述从“主”到“从”的思维转换搞了这么多年STM32SPI通信大家肯定不陌生但绝大多数教程和项目经验都集中在“主机模式”上。主机嘛发时钟、发数据、控制一切逻辑清晰主动权在手。然而当你需要让STM32作为一个“听话”的设备去响应另一个主控芯片比如另一个MCU、FPGA甚至某些专用主控的指令时SPI从机模式的配置就成了一个不大不小的坎。这个基于STM32的SPI从机实验核心就是完成这个思维转换从发号施令的主变成时刻准备响应的从。这不仅仅是改个配置参数那么简单。从机模式下STM32的SPI外设失去了对时钟线SCK的掌控权它的时钟、数据收发节奏完全由外部主机决定。这意味着你的程序架构、数据处理时机、甚至中断服务程序的设计逻辑都需要进行根本性的调整。很多朋友第一次做从机时会遇到数据收不到、数据错位、或者响应不及时导致主机通信失败等问题其根源往往在于对从机模式的“被动性”理解不够深入。为什么用HAL库在裸机寄存器操作、标准外设库和HAL库之间我选择HAL库来做这个实验分享原因很实际。HAL库的抽象层虽然有时显得“臃肿”但在快速构建、代码可读性和跨STM32系列移植方面优势明显。对于从机这种需要精细时序配合的场景HAL库提供的清晰回调函数机制如HAL_SPI_RxCpltCallback能让我们更专注于应用逻辑而不是埋头于繁琐的寄存器标志位查询。当然理解其背后的机制同样重要我会在细节部分穿插说明。这个实验适合谁如果你已经玩转过STM32的SPI主机想拓展技能树到双向通信或主从系统或者你的项目需要STM32作为协处理器、传感器数据采集模块或专用功能单元受控于一个更强大的主处理器那么掌握SPI从机模式是必经之路。接下来我会从设计思路、HAL库配置的每一个参数、数据收发实战到避坑指南完整拆解这个过程。2. SPI从机模式的核心逻辑与HAL库配置解析2.1 理解SPI从机的“被动”本质在主机模式下我们初始化SPI然后调用HAL_SPI_Transmit()或HAL_SPI_Receive()函数内部会操作寄存器启动传输控制SCK时钟产生直到传输完成或超时函数返回。整个过程是“主动发起同步等待”。而在从机模式下这个逻辑完全颠倒时钟由外部提供SCK引脚被配置为输入。从机无法启动一次传输只能等待主机发出的SCK时钟信号。传输由主机发起从机时刻“监听”着NSS片选引脚。当主机拉低NSS假设低电平有效时表示一次通信帧的开始。从机必须在这个信号有效期间准备好与主机进行数据交换。数据同步于外部时钟在主机提供的每个SCK边沿从机根据配置的时钟极性(CPOL)和相位(CPHA)进行数据的采样接收或输出发送。时序必须严格匹配主机。这种被动性带来了两个关键编程模型中断驱动和DMA驱动。轮询模式在从机中几乎不可用因为你无法预知主机何时会发起通信死等HAL_SPI_Transmit()只会让程序卡死。最常用的方式是使能SPI的RXNE接收缓冲区非空和TXE发送缓冲区空中断或者配置DMA在数据到达时自动搬运。2.2 HAL库配置参数逐项解读使用STM32CubeMX或直接编写代码初始化时以下几个参数需要格外关注SPI_HandleTypeDef hspi1; void SPI1_Init(void) { hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_SLAVE; // 核心设置为从机模式 hspi1.Init.Direction SPI_DIRECTION_2LINES; // 通常使用全双工 hspi1.Init.DataSize SPI_DATASIZE_8BIT; // 数据大小必须与主机一致 hspi1.Init.CLKPolarity SPI_POLARITY_LOW; // CPOL: 时钟极性 hspi1.Init.CLKPhase SPI_PHASE_1EDGE; // CPHA: 时钟相位 hspi1.Init.NSS SPI_NSS_SOFT; // 重点片选管理方式 hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_2; // 从机模式下此参数通常无效 hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; // 位顺序必须与主机一致 hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial 10; if (HAL_SPI_Init(hspi1) ! HAL_OK) { Error_Handler(); } }关键参数深度解析SPI_NSS_SOFTvsSPI_NSS_HARD_INPUT软件NSS (SPI_NSS_SOFT)这是我最推荐在从机模式下使用的方式。此时硬件NSS引脚通常是PA4或PA15可以被释放为普通GPIO。你需要在代码中手动控制一个GPIO引脚来模拟片选信号。这样做的好处是灵活性极高你可以通过外部中断来检测主机片选信号的下降沿从而精确知道传输开始的时间点便于进行缓冲区准备或状态重置。在初始化后你需要调用HAL_SPI_Init但片选的实际控制权在你自己手里。硬件NSS (SPI_NSS_HARD_INPUT)将STM32的NSS引脚配置为硬件输入模式。当主机拉低这个引脚时STM32的SPI外设硬件会自动将自己设置为从机并准备通信。这种方式看似“自动”但有个巨大陷阱STM32的硬件NSS管理有时会有特定的时序要求并且在多从机系统中管理起来可能不如软件模拟直观。更重要的是调试时你无法主动“断开”片选灵活性较差。对于初学者我强烈建议先从软件NSS入手把所有控制权掌握在自己代码中。BaudRatePrescaler波特率分频器在从机模式下这个参数不起任何作用因为时钟源是外部的。但为什么还要配置呢主要是HAL库数据结构的要求你可以随便填一个合法值如SPI_BAUDRATEPRESCALER_2它不会被使用。这一点很多文档没强调但心里一定要清楚。CPOL和CPHA这是SPI通信的基石主从机必须绝对一致。通常有四种模式(0,0), (0,1), (1,0), (1,1)。你需要查阅主机芯片的数据手册或代码来确定其模式。一个记忆技巧CPOL决定SCK空闲时的电平0低电平1高电平CPHA决定数据在哪个时钟边沿采样0第一个边沿1第二个边沿。在示波器上对照SCK和MOSI/MISO的波形这是排查通信问题的第一步。2.3 中断与DMA的选型考量纯中断模式使能SPI的SPI_IT_RXNE和SPI_IT_TXE中断。当接收缓冲区有数据时产生RXNE中断你需要在中断服务程序(ISR)中调用HAL_SPI_IRQHandler它最终会调用你重写的HAL_SPI_RxCpltCallback回调函数。同时TXE中断表示发送缓冲区已空可以填入下一个要发送的数据。这种方式响应及时编程模型清晰适合单次传输数据量不大比如几个到几十个字节的场景。缺点是频繁中断可能带来CPU开销。DMA模式这是处理流式数据或大数据块的利器。你可以为SPI的接收和发送分别配置一条DMA通道。当主机发起连续传输时DMA会自动将接收到的数据搬运到你指定的内存数组并在发送端自动从内存数组取出数据送到SPI数据寄存器。整个过程几乎不占用CPU。配置的关键在于设置好DMA的数据宽度与SPI数据位宽一致、内存地址自增、以及传输完成中断。在从机模式下使用DMA尤其要注意传输长度的确定性。因为从机无法控制传输何时结束你通常需要预设一个DMA传输长度例如256字节或者结合软件NSS信号片选拉高表示帧结束来触发DMA传输完成中断。注意在实际项目中我经常采用“软件NSS DMA循环模式”来处理不定长但以帧为单位的数据。将DMA配置为循环模式(Circular Mode)并设置一个足够大的缓冲区。当检测到片选下降沿外部中断触发时记录缓冲区起始索引当片选上升沿时记录结束索引。然后在主循环或回调函数中处理这一帧数据。这样既能享受DMA的自动搬运又能灵活处理帧边界。3. 实战中断驱动SPI从机完整代码实现我们以一个具体场景为例STM32作为从机等待主机发送一个命令字节CMD根据命令字节回复不同的数据。采用软件NSSPA4配置为外部中断输入和SPI中断方式。3.1 硬件连接与CubeMX设置假设使用SPI1主机-STM32从机SCK (PA5) - SCKMOSI (PA7) - MOSI (主机输出从机输入)MISO (PA6) - MISO (主机输入从机输出)NSS (主机输出) - PA4 (配置为GPIO_EXTI4下降沿和上升沿触发中断)在CubeMX中配置SPI1为Slave全双工8位数据。设置正确的CPOL/CPHA假设为Mode 0: CPOL0, CPHA0。NSS选择Software。使能SPI1全局中断NVIC Settings。配置PA4为GPIO_Input并开启上升沿和下降沿触发的外部中断。3.2 核心代码实现// spi_slave.h #define SPI_RX_BUFFER_SIZE 64 #define SPI_TX_BUFFER_SIZE 64 extern SPI_HandleTypeDef hspi1; extern volatile uint8_t spi_rx_buf[SPI_RX_BUFFER_SIZE]; extern volatile uint8_t spi_tx_buf[SPI_TX_BUFFER_SIZE]; extern volatile uint8_t spi_rx_index; extern volatile uint8_t spi_tx_index; extern volatile uint8_t spi_frame_ready; extern volatile uint8_t spi_cs_active; // 片选活动标志 void SPI_Slave_Init(void); void SPI_Slave_Start_Receive(void);// spi_slave.c #include spi_slave.h SPI_HandleTypeDef hspi1; volatile uint8_t spi_rx_buf[SPI_RX_BUFFER_SIZE] {0}; volatile uint8_t spi_tx_buf[SPI_TX_BUFFER_SIZE] {0}; volatile uint8_t spi_rx_index 0; volatile uint8_t spi_tx_index 0; volatile uint8_t spi_frame_ready 0; volatile uint8_t spi_cs_active 0; // 0: 空闲 1: 正在传输 // PA4外部中断服务程序 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_4) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4) GPIO_PIN_RESET) { // 片选下降沿传输开始 spi_cs_active 1; spi_rx_index 0; spi_tx_index 0; spi_frame_ready 0; // 预先放入第一个要发送的字节例如设备ID spi_tx_buf[0] 0xA5; // 启动SPI接收以中断方式 // 注意从机模式下调用HAL_SPI_Receive_IT会准备接收但实际传输由主机时钟驱动 // 更常见的做法是使能SPI中断并在TXE中断中填充发送缓冲区 __HAL_SPI_ENABLE_IT(hspi1, SPI_IT_RXNE | SPI_IT_TXE); } else { // 片选上升沿传输结束 spi_cs_active 0; __HAL_SPI_DISABLE_IT(hspi1, SPI_IT_RXNE | SPI_IT_TXE); spi_frame_ready 1; // 标志一帧数据接收完成可供主循环处理 } } } // SPI中断服务程序中会调用此回调 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { // 当通过HAL_SPI_Receive_IT启动的接收完成时调用 // 在从机中断驱动模型中我们通常不用这个而是用下面的TXE/RXNE中断处理逻辑 } // 更底层的处理在stm32f1xx_it.c的SPI1_IRQHandler中调用HAL_SPI_IRQHandler后 // HAL库会根据中断标志位调用以下函数 void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { // 全双工传输完成回调 } // 但为了更精细控制我们有时需要直接处理中断标志。这里展示在IRQHandler中的简化逻辑 // 在 SPI1_IRQHandler 函数中通常由CubeMX生成在 stm32f1xx_it.c void SPI1_IRQHandler(void) { HAL_SPI_IRQHandler(hspi1); // 必须调用这个以处理HAL库状态机 // 或者你可以选择直接操作寄存器更高效但可移植性差 // if (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_RXNE)) { // // 读取数据寄存器清除RXNE标志 // spi_rx_buf[spi_rx_index] hspi1.Instance-DR; // // 如果接收缓冲区满了可以处理或丢弃 // if (spi_rx_index SPI_RX_BUFFER_SIZE) spi_rx_index 0; // } // if (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_TXE)) { // // 发送缓冲区空填入下一个要发送的字节 // if (spi_tx_index SPI_TX_BUFFER_SIZE) { // hspi1.Instance-DR spi_tx_buf[spi_tx_index]; // } else { // // 没有更多数据要发可以发送哑元数据如0xFF // hspi1.Instance-DR 0xFF; // } // } } // 主循环中处理接收到的数据 void Application_Process(void) { if (spi_frame_ready) { spi_frame_ready 0; uint8_t cmd spi_rx_buf[0]; // 假设第一个字节是命令 switch(cmd) { case 0x01: // 准备下一次的回复数据到 spi_tx_buf spi_tx_buf[0] 0x01; spi_tx_buf[1] 0x02; spi_tx_buf[2] 0x03; break; case 0x02: // 读取ADC值并准备发送 // ... 准备数据到 spi_tx_buf ... break; default: // 无效命令回复错误码 spi_tx_buf[0] 0xFF; break; } // 注意这里准备的数据会在**下一次**主机发起传输时被发送出去。 // 从机的发送总是滞后主机一个字节全双工特性。 } }代码逻辑解读片选同步利用PA4的外部中断精确捕获每一次通信帧的开始下降沿和结束上升沿。这是协调数据收发的“节拍器”。中断使能/失能在帧开始时使能SPI的RXNE和TXE中断在帧结束时失能。防止帧间干扰。发送缓冲区管理在TXE中断中程序从spi_tx_buf中取出下一个字节放入SPI数据寄存器(DR)。这里有个关键点在全双工SPI中主机发送一个字节的同时从机也必须发送一个字节。所以从机需要提前准备好要发送的数据或者在TXE中断中实时生成。示例中是在上一帧处理完后就为下一帧预置了回复数据。接收数据处理在RXNE中断中读取DR寄存器得到主机发来的字节存入spi_rx_buf。一帧结束后片选上升沿设置spi_frame_ready标志主循环再处理避免在中断中做耗时操作。4. 进阶DMA驱动SPI从机与不定长数据处理对于需要连续传输大量数据的场景如固件升级、音频流传输中断方式每个字节都进中断CPU开销太大。DMA是更好的选择。4.1 DMA循环模式配置我们配置SPI1_RX和SPI1_TX分别使用DMA1的通道2和通道3具体通道需查数据手册并设置为循环模式。// CubeMX中配置DMA // SPI1_RX - DMA1 Channel2, Direction: Peripheral To Memory, Mode: Circular, Increment Address: Memory // SPI1_TX - DMA1 Channel3, Direction: Memory To Peripheral, Mode: Circular, Increment Address: Memory初始化后启动DMA接收和发送// 启动循环DMA接收 HAL_SPI_Receive_DMA(hspi1, (uint8_t*)spi_rx_dma_buffer, SPI_DMA_BUFFER_SIZE); // 启动循环DMA发送可以先填充缓冲区为默认值如0xFF memset(spi_tx_dma_buffer, 0xFF, SPI_DMA_BUFFER_SIZE); HAL_SPI_Transmit_DMA(hspi1, (uint8_t*)spi_tx_dma_buffer, SPI_DMA_BUFFER_SIZE);一旦启动DMA就会在后台自动工作。当主机发起通信时数据会自动流入spi_rx_dma_buffer同时spi_tx_dma_buffer的数据会自动发送出去。4.2 结合软件NSS处理数据帧循环DMA的问题在于它像一个环形的传送带数据不断被覆盖。我们需要知道哪些数据是有效的。这时软件NSS的外部中断再次成为关键。volatile uint32_t dma_rx_start_index 0; volatile uint32_t dma_rx_frame_length 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_4) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4) GPIO_PIN_RESET) { // 帧开始记录当前DMA接收计数器值作为起始点 dma_rx_start_index SPI_DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_spi1_rx.Instance); } else { // 帧结束计算接收到的字节数 uint32_t current_index SPI_DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_spi1_rx.Instance); dma_rx_frame_length (current_index dma_rx_start_index) ? (current_index - dma_rx_start_index) : (SPI_DMA_BUFFER_SIZE - dma_rx_start_index current_index); // 触发一个信号量或标志通知任务处理从 dma_rx_start_index 开始长度为 dma_rx_frame_length 的数据 osSemaphoreRelease(spi_frame_semHandle); // 如果使用了RTOS } } }通过计算DMA传输计数器(CNDTR)的变化我们可以精确计算出在片选有效期间接收到了多少字节的数据从而在环形缓冲区中定位出一帧完整的数据。对于发送我们也可以在片选开始时将需要发送的数据拷贝到DMA发送缓冲区的对应位置。实操心得DMA循环模式软件NSS中断是处理高速、流式SPI从机数据的黄金组合。但要注意DMA缓冲区的对齐和大小。缓冲区大小最好是2的幂次方如256、512这样索引回绕计算非常高效可以用 (BUFFER_SIZE-1)。同时确保处理数据帧的速度快于主机发送帧的速度否则会发生数据覆盖。5. 调试技巧与常见问题排查实录调试SPI从机逻辑分析仪或示波器几乎是必备的。没有它就像在黑暗中摸索。5.1 问题一完全收不到任何数据检查清单电气连接用万用表检查SCK、MOSI、MISO、NSS四根线是否连通有无短路到地或电源。基础配置确认主从机CPOL/CPHA模式完全一致。用示波器看SCK和MOSI对照数据手册的时序图。片选信号如果使用硬件NSS确保主机拉低NSS的时间足够长且STM32的NSS引脚确实配置为硬件输入。强烈建议先用软件NSSGPIO中断调试在中断里翻转一个测试引脚用示波器看是否能抓到片选边沿。SPI使能确保在初始化后调用了__HAL_SPI_ENABLE(hspi1)或HAL库的初始化函数已包含此操作。中断/DMA使能检查NVIC中断是否开启DMA通道是否配置并启动。5.2 问题二收到的数据错位或全是0xFF/0x00数据错位如预期0xAA收到0x55检查SPI_FIRSTBIT设置。主机是MSB在先还是LSB在先必须一致。0xAA (0b10101010) 如果位序反了就会变成0x55 (0b01010101)。全是0xFF通常意味着MISO线有问题。0xFF是SPI总线在空闲时的上拉值。检查STM32的MISO引脚配置是否正确应为复用推挽输出AF_PP。检查主机端是否将MISO引脚正确配置为输入。在从机TXE中断或DMA发送缓冲区中你是否正确填充了要发送的数据如果没填默认发送的就是0xFF。全是0x00检查MOSI线连接主机是否确实在发送数据。检查从机的接收中断或DMA是否正常工作。在RXNE中断或DMA完成中断中设置断点看是否能进入。逻辑分析仪查看MOSI线上是否有数据变化。5.3 问题三通信不稳定偶尔丢数据时钟速度虽然从机不控制时钟但STM32的SPI从机有最大支持时钟频率见芯片数据手册。如果主机SCK太快从机可能无法正确采样。尝试降低主机波特率。中断优先级如果SPI中断被更高优先级的中断长时间阻塞可能导致数据来不及处理而丢失。确保SPI中断或DMA中断有足够高的优先级尤其是接收中断。缓冲区溢出在中断服务程序中如果接收数据的速度快于处理速度缓冲区会被覆盖。增加缓冲区大小或者使用双缓冲区Ping-Pong Buffer机制一个缓冲区用于接收另一个用于处理接收满后交换。电源噪声长距离或恶劣环境下的SPI通信需考虑信号完整性。可以尝试降低速率或在线上串联小电阻如22-100欧姆抑制振铃。5.4 一个典型的调试流程静态验证不接主机将STM32的MOSI引脚通过上拉电阻接到3.3V或GND将MISO引脚接到逻辑分析仪。在代码中固定发送一个字节如0xAA。用另一块板子模拟主机手动控制NSS和SCK看逻辑分析仪上MISO是否有正确波形输出。这验证了从机的发送通路。动态监听接上主机用逻辑分析仪同时抓取SCK、MOSI、MISO、NSS四路信号。发起一次通信对照时序图看数据是否对齐片选是否正常。软件调试在片选中断、SPI接收中断、DMA传输完成中断入口设置断点观察程序流是否按预期执行。在内存窗口中观察接收缓冲区是否被正确写入。最后分享一个我踩过的坑有一次调试从机数据总是慢一个字节。后来发现我在片选下降沿中断里才去准备要发送的第一个字节数据。而SPI是全双工的主机在发出第一个SCK时钟边沿时从机就必须在MISO上输出第一个数据位。因此从机需要提前准备好至少第一个字节的发送数据。我的解决方法是在每次回复完一帧数据后立即将下一帧可能用到的第一个字节比如状态字预加载到发送缓冲区或准备好确保片选一有效数据就位。这个细节在标准文档里很少强调但实际中至关重要。