STM32G0 SPI从机接收实战:HAL库中断模式配置与避坑指南
简介这份资源是面向STM32开发者、嵌入式初学者及需要实现SPI从机通信的工程师的HAL库代码示例包聚焦于使用STM32硬件抽象层编程SPI从机接收功能帮助读者理解SPI在实际应用中的运作方式并掌握STM32的编程方法。压缩包共包含1138个文件以662个C源文件与260个头文件为核心辅以103个汇编文件、73个文本说明、24个ICF链接脚本及少量工程与库文件整体约9.21MB覆盖HAL驱动、数学库与工程配置等模块目录结构完整便于直接导入或参考移植。目前已有238人学习下载适合作为SPI从机接收实验的起点。读者可从中获取可运行的从机接收代码框架、HAL外设初始化与中断处理思路以及工程组织与链接配置的参考快速搭建自己的SPI通信实验环境并对照调试。1. 从一份只有文件名的压缩包说起stm32 spi hal 接收端到底该怎么写拿到stmg0-spi-receive.rar的时候我第一反应是这名字起得够直白STM32G0 系列、SPI 从机接收、HAL 库实现三个关键词全在文件名里了。但打开之前心里也打鼓——SPI 从机接收这个场景在 STM32 的 HAL 库里属于那种“看着简单、写起来全是坑”的典型。主机发数据从机收听起来一句话的事但真到项目里NSS 怎么管、时钟极性相位怎么配、DMA 和中断怎么选、接收长度不固定怎么办每一个都能让你调半天。这份资源适合两类人一是手上有个 STM32G0 的板子需要跟另一块 MCU 或者传感器做 SPI 通信自己当从机收数据二是已经用 HAL 库写过 SPI 主机发送但切到从机接收模式时发现行为跟预期不一样想找个能跑的参考实现。它不是什么大框架就是一个聚焦在“SPI 从机接收”这个点上的工程代码包但恰恰因为聚焦里面关于 HAL 库 SPI 从机配置的细节反而更值得拆开看。2. 工程结构与技术选型为什么是 HAL 库加中断接收2.1 目录结构推测与文件组织虽然压缩包还没解开但从命名习惯和 STM32 工程的通用组织方式来看这类资源包内部大概率是标准的 STM32CubeMX 生成结构加上用户自己写的应用层代码。我一般拿到这种包会先看这几个地方stmg0-spi-receive/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32g0xx_hal_conf.h │ │ └── stm32g0xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32g0xx_it.c │ └── system_stm32g0xx.c ├── Drivers/ │ ├── STM32G0xx_HAL_Driver/ │ └── CMSIS/ ├── .ioc └── README.md如果有的话Core/Src/main.c是主逻辑所在SPI 的初始化、接收回调、数据处理都在这里。stm32g0xx_it.c里放的是中断服务函数SPI 中断触发后会跳到这里再转给 HAL 库的中断处理入口。.ioc文件是 CubeMX 的工程配置用 CubeMX 打开就能看到引脚分配和时钟树这对二次开发很重要——你想改引脚或者换 SPI 实例从.ioc改比手动翻代码快得多。提示如果你拿到的包里没有.ioc文件那说明作者是手动建的工程或者把配置文件删了。这种情况下改引脚就得直接动main.c里的HAL_SPI_MspInit函数别去 CubeMX 里找。2.2 技术选型HAL 库 中断模式接收STM32 的 SPI 从机接收底层实现方式无非三种轮询、中断、DMA。轮询最省事但最占 CPU主机不发数据你就得死等DMA 最高效但配置复杂尤其是接收长度不固定的时候得配合空闲中断或者超时机制中断模式是折中方案每收一个字节进一次中断CPU 占用比轮询低配置又比 DMA 简单。这份资源从标题看用的是 HAL 库那大概率走的是中断接收的路子。HAL 库对 SPI 从机接收提供了几个关键函数// 启动中断接收指定接收缓冲区和长度 HAL_StatusTypeDef HAL_SPI_Receive_IT(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size); // 接收完成回调用户重写这个函数来处理数据 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi); // 接收一半回调用于双缓冲场景 void HAL_SPI_RxHalfCpltCallback(SPI_HandleTypeDef *hspi);HAL_SPI_Receive_IT调用之后HAL 库会把接收缓冲区挂到 SPI 句柄上然后使能 RXNE 中断。每收到一个字节中断触发HAL 库的中断处理函数把数据搬进缓冲区同时递减剩余计数。计数到零时调用HAL_SPI_RxCpltCallback你在这个回调里处理完整的一帧数据。为什么选中断而不是 DMA我猜这个项目的场景是接收长度固定且不长比如每次收 8 字节或者 16 字节的传感器数据帧。这种场景下中断的开销完全可以接受而且代码逻辑清晰不需要处理 DMA 传输完成和空闲中断的配合问题。如果你要收几百字节的固件升级包那还是老老实实上 DMA。2.3 SPI 从机模式的关键配置项SPI 从机能不能正常工作八成取决于初始化配置对不对。HAL 库的SPI_InitTypeDef结构体里有几个参数在从机模式下特别容易翻车hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_SLAVE; // 从机模式 hspi1.Init.Direction SPI_DIRECTION_2LINES; // 全双工 hspi1.Init.DataSize SPI_DATASIZE_8BIT; // 8位数据 hspi1.Init.CLKPolarity SPI_POLARITY_LOW; // 时钟空闲低电平 hspi1.Init.CLKPhase SPI_PHASE_1EDGE; // 第一个边沿采样 hspi1.Init.NSS SPI_NSS_HARD_INPUT; // 硬件NSS输入 hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_2; // 从机模式下这个参数无效 hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; // MSB先行 hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE;CLKPolarity和CLKPhase必须跟主机端完全一致这四个组合CPOL/CPHA只要有一个对不上收到的数据就是乱的。我一般会在代码里把这两个参数单独拎出来加注释提醒自己跟主机端核对。NSS在从机模式下通常设成SPI_NSS_HARD_INPUT让硬件自动检测片选信号但有些板子的 NSS 引脚没接出来那就得改成SPI_NSS_SOFT用软件管理。BaudRatePrescaler在从机模式下其实不起作用因为时钟是主机给的但从机得能跟上主机的时钟频率。STM32G0 的 SPI 从机最高能跑多少得查数据手册的 SPI 时序参数别超过上限。3. 核心代码拆解从初始化到接收回调的完整链路3.1 SPI 初始化与 GPIO 配置先看main.c里 SPI 初始化的部分。CubeMX 生成的代码会把 GPIO 配置放在HAL_SPI_MspInit里这个函数在HAL_SPI_Init内部被调用void HAL_SPI_MspInit(SPI_HandleTypeDef* hspi) { GPIO_InitTypeDef GPIO_InitStruct {0}; if(hspi-Instance SPI1) { __HAL_RCC_SPI1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // SCK 引脚配置复用推挽高速 GPIO_InitStruct.Pin GPIO_PIN_5; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate GPIO_AF0_SPI1; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // MISO 引脚从机输出复用推挽 GPIO_InitStruct.Pin GPIO_PIN_6; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // MOSI 引脚从机输入复用推挽 GPIO_InitStruct.Pin GPIO_PIN_7; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // NSS 引脚硬件片选输入 GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Pull GPIO_PULLUP; // 片选通常需要上拉 HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // 使能 SPI1 中断 HAL_NVIC_SetPriority(SPI1_IRQn, 1, 0); HAL_NVIC_EnableIRQ(SPI1_IRQn); } }这里有几个点值得说。NSS 引脚我加了GPIO_PULLUP因为 SPI 的片选信号是低有效主机不拉低的时候从机不应该被选中上拉能防止浮空误触发。SCK 和 MOSI 在从机模式下是输入但 GPIO 模式仍然配成AF_PP因为复用功能下方向由外设控制不需要手动改成输入模式。中断优先级设成 1不算高。如果你的系统里有更实时的事情要处理比如电机控制或者高速 ADC 采样SPI 接收中断的优先级可以再降。但也不能太低否则主机发完一帧了你还没处理完下一帧就丢了。3.2 启动接收与回调处理初始化完成后在main函数里启动第一次接收#define RX_BUFFER_SIZE 16 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint8_t rx_complete_flag 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); // 启动 SPI 中断接收等待主机发数据 HAL_SPI_Receive_IT(hspi1, rx_buffer, RX_BUFFER_SIZE); while (1) { if (rx_complete_flag) { rx_complete_flag 0; // 在这里处理接收到的数据 process_rx_data(rx_buffer, RX_BUFFER_SIZE); // 处理完后重新启动接收准备下一帧 HAL_SPI_Receive_IT(hspi1, rx_buffer, RX_BUFFER_SIZE); } } } // 接收完成回调 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { rx_complete_flag 1; } }这个流程是 HAL 库中断接收的标准写法启动接收 → 中断触发 → 回调置标志 → 主循环处理 → 重新启动接收。rx_complete_flag用volatile修饰因为它在中断里被写、在主循环里被读编译器不能优化掉。注意HAL_SPI_Receive_IT必须在每次接收完成后重新调用否则只能收到第一帧。这是 HAL 库的设计不是 bug。很多人第一次用的时候发现“怎么只收了一次就没反应了”就是忘了重新启动。3.3 中断服务函数与 HAL 库的衔接stm32g0xx_it.c里的中断服务函数是硬件中断和 HAL 库之间的桥梁void SPI1_IRQHandler(void) { HAL_SPI_IRQHandler(hspi1); }HAL_SPI_IRQHandler是 HAL 库提供的统一中断处理入口它会判断当前中断类型RXNE、TXE、错误等执行相应的操作。接收场景下RXNE 中断触发后HAL 库从数据寄存器读出数据放进缓冲区递减计数计数到零时调用HAL_SPI_RxCpltCallback。如果你在调试时发现中断进了但回调没执行大概率是计数没到零。比如你启动接收时指定长度 16但主机只发了 8 个字节就拉高了片选那 HAL 库会一直等剩下的 8 个字节回调永远不触发。这种情况要么主机保证每次发满要么从机端用超时机制自己判断一帧结束。3.4 错误处理与状态查询SPI 从机接收常见的错误有溢出OVR和模式错误MODF。溢出是指上一个数据还没被读走新的数据又来了模式错误通常发生在 NSS 信号异常的时候。HAL 库提供了错误回调void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { uint32_t error HAL_SPI_GetError(hspi); if (error HAL_SPI_ERROR_OVR) { // 溢出处理清除标志重新启动接收 __HAL_SPI_CLEAR_OVRFLAG(hspi); HAL_SPI_Receive_IT(hspi, rx_buffer, RX_BUFFER_SIZE); } if (error HAL_SPI_ERROR_MODF) { // 模式错误通常需要重新初始化 SPI HAL_SPI_DeInit(hspi); MX_SPI1_Init(); HAL_SPI_Receive_IT(hspi, rx_buffer, RX_BUFFER_SIZE); } } }溢出错误在从机模式下不算罕见尤其是主机发得快、从机处理慢的时候。清除 OVR 标志后重新启动接收就能恢复。模式错误麻烦一些往往意味着 NSS 信号有毛刺或者主机从机配置不匹配重新初始化 SPI 是最稳妥的恢复方式。4. 避坑与常见问题SPI 从机接收调不通的五个原因4.1 现象一直接收不到数据回调从不触发原因最常见的是 CPOL/CPHA 跟主机不匹配。SPI 没有握手协议从机不知道主机什么时候发、用什么时序只能靠配置去猜。猜错了就收不到。解决拿示波器或者逻辑分析仪看 SCK 和 MOSI 的波形。SCK 空闲时是高还是低决定了 CPOL数据在第一个边沿还是第二个边沿采样决定了 CPHA。把这两个参数跟主机端对齐问题基本就解决了。没有示波器的话把四种组合都试一遍虽然笨但有效。4.2 现象能收到数据但全是 0xFF 或者乱码原因MISO 和 MOSI 接反了或者从机的 MISO 引脚没配成复用功能。从机模式下 MISO 是从机输出如果配成了普通 GPIO 输入主机读到的永远是浮空电平。解决检查原理图上主机的 MOSI 是不是接到了从机的 MOSI。SPI 是全双工主机的 MOSI 接从机的 MOSI主机的 MISO 接从机的 MISO不是交叉接。再检查HAL_SPI_MspInit里 MISO 引脚的Alternate值对不对STM32G0 的 SPI1 在 PA6 上对应的是GPIO_AF0_SPI1。4.3 现象只收到第一帧后面就没反应了原因忘了在回调或者主循环里重新调用HAL_SPI_Receive_IT。HAL 库的接收是一次性的收完指定长度就停了。解决在HAL_SPI_RxCpltCallback里直接重新启动接收或者在主循环处理完数据后重新启动。我一般倾向于在主循环里重新启动这样处理逻辑和接收启动分开代码更清晰。但要注意处理时间不能太长否则会丢帧。4.4 现象接收长度不固定时回调不触发原因HAL_SPI_Receive_IT需要指定固定长度主机发多少你就得提前知道。如果主机每次发的长度不一样HAL 库没法判断一帧什么时候结束。解决这种情况得换方案。要么用 DMA 加空闲中断靠总线空闲来判断帧结束要么在协议层加帧头帧尾从机收到帧尾后主动停止接收。还有一种办法是用HAL_SPI_Receive_IT每次只收一个字节在回调里自己拼帧但这样中断太频繁只适合低速场景。4.5 现象调试时正常跑一段时间后死机原因中断优先级配置不当导致中断嵌套问题或者rx_complete_flag没加volatile导致编译器优化后主循环读不到变化。解决检查HAL_NVIC_SetPriority的设置SPI 中断优先级不要高于系统关键中断。rx_complete_flag必须加volatile这是血泪教训。另外如果主循环里处理数据的时间超过了主机发送一帧的时间也会导致溢出得优化处理逻辑或者降低主机发送频率。5. 二次开发与改造从固定长度到变长协议解析5.1 改造思路用状态机处理变长帧原始工程大概率是固定长度接收比如每次收 16 字节。但实际项目里主机发过来的数据帧长度往往是变化的这时候就得在应用层加一个状态机来解析。我的做法是SPI 中断每次只收一个字节在回调里把字节喂给状态机状态机负责找帧头、收长度、收数据、校验帧尾。typedef enum { STATE_IDLE, STATE_HEADER, STATE_LENGTH, STATE_PAYLOAD, STATE_CHECKSUM } frame_state_t; static frame_state_t state STATE_IDLE; static uint8_t payload_len 0; static uint8_t payload_idx 0; static uint8_t frame_buf[64]; void spi_rx_byte_handler(uint8_t byte) { switch (state) { case STATE_IDLE: if (byte 0xAA) { // 帧头 state STATE_HEADER; } break; case STATE_HEADER: if (byte 0x55) { // 帧头第二字节 state STATE_LENGTH; } else { state STATE_IDLE; } break; case STATE_LENGTH: payload_len byte; payload_idx 0; state STATE_PAYLOAD; break; case STATE_PAYLOAD: frame_buf[payload_idx] byte; if (payload_idx payload_len) { state STATE_CHECKSUM; } break; case STATE_CHECKSUM: // 校验并处理完整帧 if (verify_checksum(frame_buf, payload_len, byte)) { process_frame(frame_buf, payload_len); } state STATE_IDLE; break; } }这个状态机的核心思想是不依赖固定长度而是靠协议里的帧结构来界定一帧的边界。帧头用来同步长度字段告诉状态机后面还有多少数据校验字段用来确认帧的完整性。改造完之后主机发多长的帧都能正确解析。5.2 验证方法用逻辑分析仪抓波形对照改完代码怎么验证最直接的办法是拿逻辑分析仪抓 SCK、MOSI、NSS 三根线同时在你的代码里把收到的每个字节通过串口打印出来。两边对照看字节序对不对、帧边界判断准不准。我一般会构造几组测试数据正常帧、帧头错误的帧、长度字段超范围的帧、校验错误的帧看状态机能不能正确地从错误中恢复。如果没有逻辑分析仪也可以用另一块 STM32 当主机写个简单的发送程序定时发不同长度的帧从机端打印接收结果。这种方法成本低但看不到时序细节适合验证协议逻辑不适合排查电气层的问题。5.3 一个具体技巧用 DMA 加空闲中断处理高速变长帧如果你的主机发送频率很高比如 1MHz 以上的 SCK那中断方式可能扛不住。这时候得上 DMA。STM32G0 的 SPI 支持 DMA 接收配合串口的空闲中断思路SPI 本身没有空闲中断但可以用定时器或者 DMA 传输完成加超时来判断帧结束。// DMA 接收配置循环模式缓冲区设大一点 HAL_SPI_Receive_DMA(hspi1, dma_buffer, DMA_BUFFER_SIZE); // 在定时器中断里判断帧结束 void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(htim3, TIM_FLAG_UPDATE); // 如果 DMA 接收计数在两次中断之间没有变化认为一帧结束 uint16_t current_cnt __HAL_DMA_GET_COUNTER(hspi1.hdmarx); if (current_cnt last_dma_cnt) { uint16_t received DMA_BUFFER_SIZE - current_cnt; if (received 0) { process_frame(dma_buffer, received); // 重新启动 DMA 接收 HAL_SPI_Receive_DMA(hspi1, dma_buffer, DMA_BUFFER_SIZE); } } last_dma_cnt current_cnt; } }这个方案的思路是用 DMA 把数据搬进缓冲区CPU 不参与每个字节的搬运然后用一个定时器定期检查 DMA 的剩余计数如果计数不变说明主机已经停止发送一帧结束。定时器的周期要略大于主机发送一帧的时间太短了会误判太长了会丢帧。从那以后我每次调 SPI 从机接收都强制自己先用逻辑分析仪确认 CPOL/CPHA 和 NSS 时序再去看代码逻辑。因为 SPI 的玄学问题十有八九出在物理层和配置层代码本身反而很少出错。希望这份拆解能帮你少走点弯路把stmg0-spi-receive.rar里的代码跑起来然后按自己的协议需求改下去。本文还有配套的精品资源点击获取