☰
STM32G0 SPI接收实战:HAL库阻塞/中断/DMA模式与避坑指南
2026/10/17 4:10:14 网站建设 项目流程

简介:基于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 从设备。分频系数选了 16,G0 的 APB 总线时钟默认可能是 16MHz 或更高,除以 16 之后 SPI 时钟就落在 1MHz 附近,属于相当保守的频率。低频的好处是抗干扰强,杜邦线拉长一点、面包板接触不良一点,也不容易把时序搞坏。真正要往高频调的时候,可以逐个往下压分频系数,8、4、2,压到哪一档看从设备手册里标的最大时钟频率。

有个新手容易忽略的点:SPI 的时钟极性 CPOL 和相位 CPHA 是主从双方必须完全一致的参数。CPOL 决定空闲时 SCK 是高还是低,CPHA 决定数据在哪个边沿被采样。如果两边差一个边沿,收上来的数据往往是错位的位流,波形上看着有数据,解析出来全不对,排查起来特别像玄学。我一般会在配置前打开从设备手册的时序图,数一遍 SCK 波形和数据变化点的相对位置,再决定 CPOL 和 CPHA 的值,而不是凭感觉照抄别人的配置。这个工程默认的 Mode 0 虽然能覆盖很多设备,但并不代表所有设备都默认 Mode 0。

2.3 MspInit:HAL 库把参数落到硬件的那一步

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 在主机模式内部会向发送数据寄存器写入 0x00,SCK 就跟着输出,时钟边沿同时把从设备 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 信号维持高电平。如果一定要用硬件 NSS,GPIO 需要配置为上拉输入,并且确认外部电路不会无故拉低。这个是我第一次调试 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。这个习惯帮我绕开了不少后续排查时间,也让我在接手别人的工程时能快速判断配置是否合理。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询