
简介基于STM32G0系列芯片的SPI从机接收代码示例使用HAL库开发面向需要快速掌握STM32 SPI通信与从机接收逻辑的嵌入式开发者和学生。工程基于STM32CubeMX配置包含完整的HAL驱动层、启动文件和链接脚本可直接导入Keil或IAR编译调试。包体共1138个文件以662个C源文件、260个头文件和103个汇编文件为主并含有24个ICF链接脚本、73个文本说明及工程配置文件如uvprojx、ioc压缩包大小9.21MB目录结构按功能模块划分便于检索。除SPI从机接收例程外还封装了STM32G0系列HAL库常用外设驱动如TIM、I2C、加密模块等可一并参考。目前已有238人学习使用适合希望结合完整代码实例理解SPI协议与HAL库运作机制的开发者。1. SPI 接收工程到底在做什么STM32G0 上的 HAL 实践SPI 接收有个反直觉的前提主设备想收数据得先送时钟出去。很多做 STM32G0 外接传感器的人都栽在这以为 HAL_SPI_Receive 是纯等待数据进来结果挂死在超时里。这份 stmg0-spi-receive 工程解决的就是这组问题基于 STM32G0 和 STM32 HAL 库把 SPI 主机的阻塞接收、中断接收和 DMA 接收三种读取方式完整实现并跑通覆盖外挂 ADC、Flash 等常见从设备的读数据场景。工程里带有可直接编译的引脚初始化代码、SPI 参数配置和三类接收示例新手照着烧录就能看到数据从 MISO 上回来熟手则能直接拿它当 SPI 驱动层的起步骨架来改。2. 工程结构拆解从 CubeMX 生成到 HAL 外设初始化链路2.1 解压后的目录与各文件职责拿到 stmg0-spi-receive.rar解压后是一个标准的 CubeMX 输出工程。目录分布如下stmg0-spi-receive/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── spi.h │ │ ├── gpio.h │ │ └── stm32g0xx_it.h │ └── Src/ │ ├── main.c │ ├── spi.c │ ├── gpio.c │ └── stm32g0xx_it.c ├── Drivers/ │ ├── CMSIS/ │ │ ├── core_cm0plus.h │ │ └── system_stm32g0xx.c │ └── STM32G0xx_HAL_Driver/ │ ├── Inc/ │ └── Src/ ├── MDK-ARM/ │ ├── stmg0-spi-receive.uvprojx │ └── stmg0-spi-receive.uvopt └── stmg0-spi-receive.ioc对这份工程来说真正要动的地方集中在 Core/Src/spi.c、Core/Src/main.c 和 stm32g0xx_it.c。ioc 文件是 CubeMX 的图形化配置源文件双击它可以重新打开生成器调整引脚和参数Drivers 里是另一大部分 HAL 库源码平时不要手动改编译时它会自动参与构建。理解这个目录结构有个实际好处后面如果换 G0 系列里的具体型号只需要在 CubeMX 里重新选芯片再生成一遍用户代码几乎不用动。2.2 SPI 参数配置的语义分频、极性和相位在 ioc 文件里SPI1 被配置成主机模式方向为双线全双工8 位数据长度MSB 先行时钟极性低、相位为第一边沿这就是常说的 Mode 0。很多外部传感器默认就工作在 Mode 0所以工程能直接对上一批常见的 SPI 从设备。分频系数选了 16G0 的 APB 总线时钟默认可能是 16MHz 或更高除以 16 之后 SPI 时钟就落在 1MHz 附近属于相当保守的频率。低频的好处是抗干扰强杜邦线拉长一点、面包板接触不良一点也不容易把时序搞坏。真正要往高频调的时候可以逐个往下压分频系数8、4、2压到哪一档看从设备手册里标的最大时钟频率。有个新手容易忽略的点SPI 的时钟极性 CPOL 和相位 CPHA 是主从双方必须完全一致的参数。CPOL 决定空闲时 SCK 是高还是低CPHA 决定数据在哪个边沿被采样。如果两边差一个边沿收上来的数据往往是错位的位流波形上看着有数据解析出来全不对排查起来特别像玄学。我一般会在配置前打开从设备手册的时序图数一遍 SCK 波形和数据变化点的相对位置再决定 CPOL 和 CPHA 的值而不是凭感觉照抄别人的配置。这个工程默认的 Mode 0 虽然能覆盖很多设备但并不代表所有设备都默认 Mode 0。2.3 MspInitHAL 库把参数落到硬件的那一步CubeMX 生成的代码里SPI 初始化分两段第一段是 HAL_SPI_Init它把 hspi1 结构体里的参数写进外设寄存器第二段是 HAL_SPI_MspInit负责打开外设时钟、配置对应 GPIO 复用功能以及按需开启 DMA 和中断。HAL 库把这两段拆开是有意的MspInit 里的动作和具体芯片引脚紧密相关换一颗芯片大概率要改Init 里的逻辑在外设层面是通用的。这样分层以后写应用层的人通常只关心 hspi1 结构体引脚级改动被隔离在 MspInit 里。在 G0 上SPI1 的 SCK、MISO、MOSI 通常分布在 PA5、PA6、PA7复用功能选 AF0。MspInit 里对这几个引脚全部配置为 GPIO_MODE_AF_PP也就是复用推挽输出并指定 Alternate GPIO_AF0_SPI1。如果通信速率比较高GPIO Speed 那一栏应该改成 HIGH实际工程里多数低速传感器用 LOW 也够用。NSS 脚在软件控制方式下只是一个普通输出引脚配置成 GPIO_MODE_OUTPUT_PP初始时刻必须拉高避免上电瞬间误选中从设备。这一段代码如果被手动改坏常见现象是 SCK 上根本没波形排查时可以先用调试器确认 MspInit 有没有在初始化时被调用。3. 核心代码实战一条 SPI 读事务的完整落地3.1 阻塞式读单个寄存器Transmit 接 Receive 的写法这份工程最核心的演示代码是从外部设备读单个寄存器的值。典型实现如下uint8_t spi_read_register(uint8_t reg_addr) { uint8_t tx_buf[2] {0}; uint8_t rx_buf[2] {0}; tx_buf[0] 0x06; // 读寄存器命令 tx_buf[1] reg_addr; // 寄存器地址 HAL_GPIO_WritePin(DEV_CS_GPIO_Port, DEV_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, tx_buf, 2, HAL_MAX_DELAY); tx_buf[0] 0x00; // 产生 SCK 时钟用的空字节 tx_buf[1] 0x00; HAL_SPI_Receive(hspi1, rx_buf, 2, HAL_MAX_DELAY); HAL_GPIO_WritePin(DEV_CS_GPIO_Port, DEV_CS_Pin, GPIO_PIN_SET); return rx_buf[1]; }工作过程是这样先拉低片选选中从设备把命令字节和寄存器地址通过 MOSI 发出去。从设备收到之后开始准备数据在 MISO 线上回应。此时主机的接收不能凭空发生HAL_SPI_Receive 在主机模式内部会向发送数据寄存器写入 0x00SCK 就跟着输出时钟边沿同时把从设备 MISO 上的数据采回来。这里有个关键点为什么不用 HAL_SPI_TransmitReceive 一步到位拆成 Transmit 再 Receive 是为了在逻辑上先完成命令阶段、再进入数据阶段代码读起来更直白。但代价是把一次事务拆成了两次 HAL 调用中间如果被其他中断插进来理论上会拉长片选低电平的时间。对大多数传感器来说这没什么影响但对时序极敏感的设备建议合并成一次 TransmitReceive。另外要注意 HAL_SPI_Receive 在主机模式里发送的是 0x00 空字节少部分设备要求数据阶段主机必须发特定的 dummy 值遇到这种情况就得换接口。参数说明tx_buf发送缓冲区第一个字节是读命令第二个字节是目标寄存器地址rx_buf接收缓冲区HAL_SPI_Receive 收满 2 字节后寄存器值在 rx_buf[1]HAL_MAX_DELAY阻塞超时设为无穷大实际调试时可以改成 100ms 这种有限值方便快速发现异常3.2 读多字节数据HAL_SPI_TransmitReceive 的正确用法当需要连续读一段数据比如读 8 字节的传感器原始数据时代码通常长这样uint8_t spi_read_burst(uint8_t reg_addr, uint8_t *rx_data, uint16_t len) { uint8_t tx_buf[16] {0}; tx_buf[0] 0x10; // 突发读命令 tx_buf[1] reg_addr; HAL_GPIO_WritePin(DEV_CS_GPIO_Port, DEV_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, tx_buf, 2, HAL_MAX_DELAY); for (uint16_t i 2; i len; i) { tx_buf[i] 0x00; // 后续字节全部填 0用来驱动 SCK } HAL_SPI_TransmitReceive(hspi1, tx_buf, rx_data, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(DEV_CS_GPIO_Port, DEV_CS_Pin, GPIO_PIN_SET); return len; }HAL_SPI_TransmitReceive 是真正全双工的接口在一次调用里同时完成发送和接收。主设备发出的每个字节都对应一个 SCK 时钟周期从设备在每个时钟周期返回一个字节。这里把 tx_buf 里后续字节全部填成 0x00相当于主设备不断输出空数据来换取从设备返回数据。和 HAL_SPI_Receive 相比区别在于 TransmitReceive 完全由调用方控制 MOSI 线上每个时钟周期的电平。HAL_SPI_Receive 虽然也会自动发 0x00但有些设备的 SPI 时序要求主机在数据阶段发送特定值比如 CRC 命令或者寄存器地址自增命令这种场景下 Receive 就无能为力必须用 TransmitReceive。判断该用哪个的依据还是要回到从设备手册的时序图看数据阶段 MOSI 上到底有什么要求。使用上有个细节tx_buf 是定长 16 字节的数组所以 len 最大只能到 16。实际项目里我会直接把它改成宏根据 len 动态定义或者干脆用 len 接收方同等的静态缓冲区。固定大小的数组在裸机工程里是常态但一定要在代码注释里写明长度上限。参数说明reg_addr突发读操作的起始寄存器地址rx_data接收缓冲区存放从设备返回的连读数据len本次连读的字节数同时决定了 SCK 输出多少个时钟周期HAL_MAX_DELAY阻塞超时上限稳定运行前建议换成有限超时3.3 中断接收非阻塞方式收满一帧阻塞收发适合命令短、频率低的场景。传感器持续输出、主循环还要同时处理按键和显示时就得切到中断模式。工程里对应的做法是volatile uint8_t spi_rx_done 0; uint8_t spi_rx_buffer[8]; void start_async_read(void) { uint8_t cmd 0x10; // 突发读命令 spi_rx_done 0; HAL_GPIO_WritePin(DEV_CS_GPIO_Port, DEV_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_SPI_Receive_IT(hspi1, spi_rx_buffer, 8); } void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { spi_rx_done 1; } }调用 HAL_SPI_Receive_IT 之后函数立即返回主循环可以继续干别的事。SPI 外设每收到一个字节就触发一次接收中断HAL 库在中断服务函数里判断传输计数收满 8 字节后调用 HAL_SPI_RxCpltCallback 这个回调。整个传输过程不占用 CPU 等待时间。回调里现在只做一件事置位 spi_rx_done。原因很简单中断上下文里做耗时操作会拖累系统实时响应如果 SPI 速率较高处理期间还可能丢失后续字节。正确做法是中断置位、主循环消费。主循环发现 spi_rx_done 为 1 后再去拉高片选、解析数据。这套模式是裸机开发里最基本也最稳的异步写法很多 RTOS 驱动里也能看到同样的影子。3.4 DMA 接收大块数据传输的正确姿势当读的数据量上升到几十上百字节中断模式频繁进中断的代价就会体现出来这时可以切到 DMA。CubeMX 里开启 SPI1 的 RX DMA 请求后HAL_SPI_MspInit 里会多出一段类似这样的代码static DMA_HandleTypeDef hdma_spi1_rx; 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(); GPIO_InitStruct.Pin GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.Alternate GPIO_AF0_SPI1; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); __HAL_RCC_DMA_CLK_ENABLE(); hdma_spi1_rx.Instance DMA1_Channel1; hdma_spi1_rx.Init.Request DMA_REQUEST_SPI1_RX; 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_NORMAL; hdma_spi1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_spi1_rx); __HAL_LINKDMA(hspi, hdmarx, hdma_spi1_rx); } }DMA 通道号要看具体 G0 型号的参考手册映射表我写的是通用配置思路。开启 DMA 后接收这样调用uint8_t dma_rx_buffer[64]; HAL_SPI_Receive_DMA(hspi1, dma_rx_buffer, 64);DMA 搬运不再经过 CPU每收满一个字节DMA 控制器直接把数据从 SPI 数据寄存器搬到内存数组里全部收完后再触发一次传输完成中断。CPU 在整个过程中基本是空闲的这对高速连续采样特别关键。需要注意 DMA 缓冲区必须保持有效并且不能定义为局部变量否则函数退出后内存被回收DMA 还在往里面写数据就会踩到不可预期的地址。4. 避坑记录G0 上 SPI 接收最常见的四个故障4.1 HAL_SPI_Receive 反复返回 HAL_ERROR状态寄存器出现 MODF现象调用 HAL_SPI_Receive 后函数直接返回 HAL_ERROR程序进入 Error_Handler。在调试器里读 SPI 状态寄存器MODF 位是置位状态。原因主机模式下NSS 引脚被配置成了硬件模式而外部电路恰好让 NSS 引脚变成低电平外设认为发生了多主机冲突于是报模式错误并停止传输。CubeMX 里如果没主动把 NSS 切到软件模式NSS 引脚默认行为就会受硬件电平影响很容易踩中。解决把 SPI 初始化里的 NSS 改为软件控制即 hspi1.Init.NSS SPI_NSS_SOFT同时保持内部 SSI 置位让内部 NSS 信号维持高电平。如果一定要用硬件 NSSGPIO 需要配置为上拉输入并且确认外部电路不会无故拉低。这个是我第一次调试 SPI 时翻车最惨的一次花了半天才发现是 NSS 信号没盯住。从那以后我每次新建 SPI 工程都会先去外设配置页确认 NSS 是软件还是硬件模式。4.2 数据错位首字节丢失或整体右移现象从设备手册里写明某个寄存器应该返回 0xA5实际收到的却是 0x4B或者所有数据都像往低位移了一位。原因CPOL 和 CPHA 配置与从设备不一致。例如从设备在 SCK 上升沿驱动数据、下降沿采样而主机配置成第二边沿采样两边就差了半个周期采样时刻落在数据跳变沿上采到的自然是不稳定甚至错误的值。解决仔细阅读从设备手册里的时序图确认三件事空闲时 SCK 是高还是低、数据在哪个边沿变化、接收方在哪个边沿采样。通常把主从配置成 Mode 0 就能对上大多数设备但真正的依据永远是手册时序图。我在接入新传感器之前会先写一个回环小测试验证波形而不是直接把数据手册丢一边。这个习惯帮我避开了大量肉眼难查的通信故障。4.3 阻塞超时HAL_SPI_Receive 卡到 HAL_TIMEOUT现象函数一直阻塞直到超时时间耗尽返回 HAL_TIMEOUT程序不死但反应迟钝像卡住了一样。原因主机模式下SCK 时钟是由发送动作驱动的。如果 SPI 被误配置成单线模式比如 BIDIMODE 寄存器位被置上MOSI 方向被锁死时钟无法正常输出接收自然等不来数据。另一种常见情况是片选没有拉低从设备根本没参与通信MISO 一直保持高阻态或浮空状态主设备读到的字节毫无意义也可能因为超时机制而被中断。解决先用调试器或逻辑分析仪看 SCK 上有没有波形。没有波形就查 BIDIMODE 和方向配置有波形但 MISO 没反应用万用表量 CS 引脚电平确认整个通信期间 CS 一直保持低。把 HAL_MAX_DELAY 临时改成有限超时也是一个好办法能让系统在异常时快速抛错而不是死等定位问题会快很多。4.4 中断回调里做耗时操作丢中间字节现象HAL_SPI_Receive_IT 明明配置了收 8 字节最终拿到的数据里中间某几个字节是 0x00回调也没有报错。原因在 HAL_SPI_RxCpltCallback 里直接调用了别的 HAL 发送接口或者在回调里做了长时间延时中断优先级又不够高导致一次接收还没完成时接收中断被更高优先级打断RXNE 标志被覆盖数据就丢了。解决回调只负责标记事件把解析、存储、发送统统挪到主循环或任务里做。如果数据量大且实时要求高直接改用 DMA 循环模式从硬件层面绕过 CPU 参与搬运。这是我在做了几次数据丢失排查之后养成的习惯回调函数体里只允许出现标志位赋值和简单的变量更新其他事情一律放到主循环。5. 二次开发改造把接收逻辑重构成可复用驱动5.1 加一个环形缓冲区把裸接收变成数据流现有工程只能收完一帧再取走一帧。如果要连续不断接收不定长数据最自然的改造是给接收模块加环形缓冲区。我一般这样设计#define RING_BUF_SIZE 256 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; bool ring_buffer_push(ring_buffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % RING_BUF_SIZE; if (next rb-tail) { return false; // 缓冲区满了 } rb-buffer[rb-head] data; rb-head next; return true; } bool ring_buffer_pop(ring_buffer_t *rb, uint8_t *data) { if (rb-head rb-tail) { return false; // 缓冲区为空 } *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RING_BUF_SIZE; return true; }头尾指针各自维护读写位置互不阻塞。接收中断里只做 ring_buffer_push主循环做 ring_buffer_pop数据不会因为处理不及时而丢掉。需要注意 head 和 tail 必须声明成 volatile因为它们在中断和主循环两个上下文里都被访问。5.2 帧解析状态机从数据流里提取完整指令环形缓冲拿到的是字节流下一步用状态机把完整的一帧提取出来typedef enum { FRAME_IDLE, FRAME_HEADER, FRAME_LEN, FRAME_DATA, FRAME_END } frame_state_t; frame_state_t frame_parse(uint8_t byte) { static frame_state_t state FRAME_IDLE; static uint8_t frame_len 0; static uint8_t frame_cnt 0; switch (state) { case FRAME_IDLE: if (byte 0xAA) state FRAME_HEADER; break; case FRAME_HEADER: frame_len byte; frame_cnt 0; state FRAME_LEN; break; case FRAME_LEN: state FRAME_DATA; break; case FRAME_DATA: frame_cnt; if (frame_cnt frame_len) state FRAME_END; break; case FRAME_END: if (byte 0x55) state FRAME_IDLE; else state FRAME_IDLE; // 出错则重新找帧头 break; default: state FRAME_IDLE; break; } return state; }状态机按帧头、长度、数据、帧尾的顺序消费缓冲区里的字节解析出完整帧时再交给上层业务处理。这个模式的好处是上层代码永远只面对完整帧不用关心底层某个字节是哪个时刻收到的。相比每收完一帧就进一次回调状态机让 SPI 驱动变得更加通用换一颗新的从设备只需要改协议状态机不用动底层初始化。5.3 改造后的验证路径改完代码第一步是把 MOSI 和 MISO 短接做成回环连续发送 0x55、0xAA 等特征序列看收回来是否完全一致。第二步再用真实从设备读已知寄存器确认时序和电平。第三步测边界条件拔掉 MISO 线确认超时机制能正常触发而不是把整个系统拖死。这三步走完SPI 部分基本不会再有玄学问题。我一直保留的习惯是每次换新的从设备都会先花五分钟读一遍手册的时序图把 CPOL、CPHA、最大时钟频率这三项抄到代码注释里再开始配置 CubeMX。这个习惯帮我绕开了不少后续排查时间也让我在接手别人的工程时能快速判断配置是否合理。希望帮到你。本文还有配套的精品资源点击获取