ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32G0 SPI从机接收:HAL库DMA+EXTI帧边界实战

STM32G0 SPI从机接收:HAL库DMA+EXTI帧边界实战

简介:这份面向STM32G0系列SPI从机接收场景的HAL库代码包,适合已具备一定单片机基础、希望借助STM32 HAL库理解SPI主从通信与从机接收流程的开发者。压缩包共1138个文件,约9.21MB,以C源文件和H头文件为核心,配合启动汇编、链接脚本、Keil工程文件及CubeMX配置文件,另有说明文档、库文件等,并包含较完整的G0系列HAL驱动与数学库,可作为工程模板参考。目录组织完整,便于在常用开发环境中打开、对照与移植。已有238人学习下载,说明该示例对入门SPI从机接收具有参考价值。读者可获得可直接研读的从机接收代码框架、外设初始化与底层驱动组织方式,并借助工程配置和启动文件理解STM32G0的编译链接过程;还可结合示例梳理SPI时序、数据收发与调试排错思路,适合课程实验、项目预研和HAL库学习时对照使用。

1. 一块 SPI 从机接收例程,卡住新人的往往不是寄存器

主控每隔 10 ms 通过 SPI 发来一帧状态数据,从机这边要在下一次数据到来之前收完、校验、入队,晚一步,后一帧就会把前一帧覆盖掉。这种「板间对拷 + 高频小帧」的场景里,SPI 从机接收比发送难写得多:SCK 由别人给,你既不知道什么时候来,也不知道一次来多少。stmg0-spi-receive.rar 就是一个把这件事做完整的最小工程——基于 HAL 库,用中断、DMA 和片选边沿三件套把「收得到、收得全、收得齐」跑通。适合刚上手 HAL 想找一份不夹带额外代码的从机接收参考、准备做板间通信或传感器数据汇总的开发者。

2. 工程定位与技术栈:这份 SPI 接收例程解决了什么

拿到压缩包解压,看到的是典型的单片机工程骨架:Core、Drivers、项目文件三块,没有额外的中间件,也没有操作系统。这个选择很明确——把范围缩小到「SPI 外设怎么配、数据怎么进来、帧怎么切分」这三件事上,其他一概不碰。

从机接收和主机发送的关注点完全不同。主机掌握节奏,可以发完就睡;从机完全被动,SCK 什么时候来、连续来多少拍、中间会不会断,全看对面脸色。所以这个工程的代码量虽然不大,但每个函数都对应一个具体问题:初始化解决「配置对不对」,MSP 解决「引脚和中断挂没挂上」,回调解决「数据从哪搬到哪」,EXTI 解决「一帧到哪里算完」。

2.1 三种接收方式的取舍:阻塞、中断、DMA

HAL 给 SPI 接收提供了三个入口,参数签名几乎一样,行为差别很大:

接口谁在等适用场景从机模式下的坑
HAL_SPI_ReceiveCPU 全程阻塞主机立刻就会发、帧长固定且很短主机不发就死等到超时,主循环被拖住
HAL_SPI_Receive_IT中断逐字节搬帧长短、频率低、想省 DMA 通道每字节一次中断,频率一高 CPU 就忙不过
HAL_SPI_Receive_DMADMA 搬运,CPU 只收尾连续帧、帧长较长、要高吞吐传输计数和帧边界不是一回事,容易错位

这个工程主推第三条路。原因很实际:从机接收的瓶颈从来不是 CPU 算力,而是「主机发得快、你搬得慢」导致溢出(OVR)。用 DMA 把搬运这件事从 CPU 手里拿走以后,主循环该跑什么跑什么,只在半满、全满、片选结束这几个节点上做一次处理就行。

提示:三种方式不要混着调同一个hspi1句柄。HAL 内部用State字段做状态机,上一次 DMA 接收还没结束时再喊一次 IT 接收,函数会直接返回HAL_BUSY,很多人就是在这里查到半夜。

2.2 目录结构与启动链路:从 main.c 一路看到 MSP

工程目录基本是这个形状,不同配置工具生成出来只是文件名略有差异:

stmg0-spi-receive/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── spi.h # hspi1 句柄与 MX_SPI1_Init 声明 │ │ └── stm32g0xx_hal_conf.h │ └── Src/ │ ├── main.c # 时钟、外设初始化、主循环 │ ├── spi.c # SPI 参数 + MSP 引脚/中断 │ ├── stm32g0xx_hal_msp.c# 全局 MSP(时钟、调试口等) │ ├── stm32g0xx_it.c # SPI1_IRQHandler / EXTI4_15_IRQHandler │ └── stm32g0xx_hal_timebase_tim.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32G0xx_HAL_Driver/ └── MDK-ARM/ 或 STM32CubeIDE/ # 工程文件

真正要读的只有五个文件。启动链路是:main()里先HAL_Init()把 SysTick 和时基搭好,再SystemClock_Config()把主频拉到目标值,然后MX_GPIO_Init()、MX_DMA_Init()、MX_SPI1_Init()。MX_SPI1_Init()只负责往hspi1.Init里填参数并调HAL_SPI_Init(),而HAL_SPI_Init()内部会回调HAL_SPI_MspInit(),把时钟、GPIO 复用、NVIC 这些「板级」的东西配好。

这个分层是 HAL 的固定套路,理解了就能少走弯路:参数在spi.c里改,引脚和中断在HAL_SPI_MspInit()里改。新手最常见的错误是把 GPIO 配置写进MX_SPI1_Init()里,结果HAL_SPI_Init()执行时外设时钟还没开,直接进Error_Handler()。

注意:MX_DMA_Init()必须在MX_SPI1_Init()之前调用。DMA 控制器自己的时钟要先使能,否则后面HAL_SPI_Receive_DMA()挂通道时读到的是全零寄存器,现象就是「DMA 一次都不触发」。

2.3 时钟树与引脚规划:先把 PCLK 和 SCK 算清楚

从机模式下BaudRatePrescaler这个参数完全无效,因为分频器只作用于 SCK 输出,而从机的 SCK 是输入。但另一个时钟约束反而更关键:从机采样 SCK 需要内部 PCLK 参与,SCK 的高电平、低电平持续时间至少要覆盖若干个 PCLK 周期,所以从机能接受的最高 SCK 通常不超过 PCLK 的一半,实际工程里还要再留余量。

假设系统主频跑到 64 MHz,SPI1 挂在 APB 上,PCLK 也是 64 MHz,那么理论上 SCK 上限在 32 MHz 附近,但考虑 PCB 走线、杜邦线、上拉电阻的上升沿,实际能稳跑 8~16 MHz 就已经不错了。我一般会先把主机侧分频到 1 MHz 跑通协议,再逐档往上加,加一档抓一次波形,直到误码出现再退回上一档——这比一次性上高频然后怀疑人生要快得多。

引脚方面,SPI1 常用的那组是 PA5(SCK)、PA6(MISO)、PA7(MOSI),复用号一般是 AF0,具体以对应封装的数据手册复用表为准。即便从机只收不发,MISO 也要一起配成复用推挽,因为 HAL 在双线模式下初始化时会检查这几个引脚,配不全就有可能在HAL_SPI_Init()里报错。片选则单独拎出来当普通 GPIO 用,这一点在 3.3 节展开讲。

3. 核心初始化拆解:MX_SPI1_Init 里每个参数都在管什么

初始化代码就那么二十来行,但每个字段填错都对应一种具体的故障现象。下面这份是从工程里摘出来的实际配置,注释标的是「为什么这么填」,不是「这个字段叫什么」。

3.1 MX_SPI1_Init 的参数逐项对照

SPI_HandleTypeDef hspi1; void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_SLAVE; /* 从机:SCK/NSS 都是输入 */ hspi1.Init.Direction = SPI_DIRECTION_2LINES; /* 只收也保守地用双线,HAL 内部要发空字节 */ hspi1.Init.DataSize = SPI_DATASIZE_8BIT; /* 帧内数据宽度,必须和主机一致 */ hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; /* CPOL=0,空闲低电平 */ hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; /* CPHA=0,第一个边沿采样 -> 模式 0 */ hspi1.Init.NSS = SPI_NSS_SOFT; /* 片选不占复用脚,走 GPIO + EXTI */ hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2;/* 从机无效,占位即可 */ hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; /* 高位先出,和主机的 MSB/LSB 必须对齐 */ hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 7; hspi1.Init.CRCLength = SPI_CRC_LENGTH_DATASIZE; hspi1.Init.NSSPMode = SPI_NSS_PULSE_DISABLE; /* 主机侧才有效,这里写死无所谓 */ hspi1.Init.FifoThreshold = SPI_FIFO_THRESHOLD_01DATA; /* 1 个数据就触发,延迟最小 */ if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }

逐个说关键项。Mode填SPI_MODE_SLAVE之后,HAL 不会去碰 BR 分频位,但会把SSOE、MSTR这类只对主机有意义的位清掉,所以别指望从机自己去产生时钟。

CLKPolarity和CLKPhase是唯一必须和主机逐位对齐的两个参数,组合起来就是常说的模式 0~3。模式 0(CPOL=0、CPHA=0)最常见,含义是 SCK 空闲为低、第一个(上升)沿采样。这里错一位,收到数据的现象不是全错,而是整体偏移半个到一整个 bit——看起来「像数据又不像数据」,最难查。所以拿到一块新板子,第一件事是拿示波器或逻辑分析仪确认主机侧的空闲电平和采样沿,不是直接抄别人的配置。

Direction这一项很多人以为从机只收就该填SPI_DIRECTION_1LINE,其实单线模式是给半双工双向数据线用的,从机只收却填单线,HAL 会按「只发」的方向去配置,接收路径根本没打开。保守做法是双线,接收时 HAL 内部会自动往 DR 里塞空字节完成时钟对齐,不影响功能。

FifoThreshold影响中断触发的灵敏度。填01DATA表示 FIFO 里只要有 1 个数据就置标志,中断来得最勤、延迟最小;填更高的阈值能减少中断次数,但一帧的尾巴可能卡在 FIFO 里等不到触发。从机高频小帧的场景,选前者更稳。

3.2 HAL_SPI_MspInit:GPIO 复用、上拉与中断优先级

HAL_SPI_Init()内部会调用这个函数,所以它是配置引脚的唯一正确位置:

void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi) { GPIO_InitTypeDef gpio = {0}; if (hspi->Instance == SPI1) { __HAL_RCC_SPI1_CLK_ENABLE(); /* 外设时钟必须先开 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); /* SCK / MISO / MOSI 三个脚走复用 */ gpio.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; /* 速率档位不够,高频下波形会塌 */ gpio.Alternate = GPIO_AF0_SPI1; HAL_GPIO_Init(GPIOA, &gpio); /* 片选单独当普通 IO,上升沿+下降沿都要,上拉防止主机未上电时悬空误触发 */ gpio.Pin = GPIO_PIN_4; gpio.Mode = GPIO_MODE_IT_RISING_FALLING; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &gpio); /* SPI 中断优先级给高,片选中断给低:保证数据先落地,再处理帧尾 */ HAL_NVIC_SetPriority(SPI1_IRQn, 0, 0); HAL_NVIC_EnableIRQ(SPI1_IRQn); HAL_NVIC_SetPriority(EXTI4_15_IRQn, 1, 0); HAL_NVIC_EnableIRQ(EXTI4_15_IRQn); } }

几个参数值得单独拿出来讲。Speed填FREQ_HIGH不是「保险起见调高」,它是 GPIO 输出驱动器的压摆率档位,档位低的时候上升沿变缓,SPI 在几 MHz 以上就会出现采样点落在斜坡上的情况,逻辑分析仪上看波形是圆的,示波器上看是三角的。

Pull对 SCK 和 MOSI 填NOPULL没问题,但如果主机和从机不是同一时刻上电,SCK 悬空会随机抖动,从机就可能收到一串「噪声时钟」,直接触发 OVR。稳妥一点的做法是 SCK 加弱下拉,跟模式 0 的空闲电平保持一致。

中断优先级这里有个反直觉的点:片选上升沿代表一帧结束,直觉上应该给它最高优先级赶紧收尾,实际上应该让 SPI 中断优先级更高。因为片选拉高的时候,SPI 的移位寄存器或 FIFO 里可能还有最后一个字节没搬进内存,如果 EXTI 抢在前面把 DMA 通道关了,这个字节就永久丢了。血泪经验:先让数据通道清空,再处理边界事件。

3.3 NSS 的两副面孔:硬件片选和 EXTI 帧边界

SPI 的 NSS 引脚有两种用法,对应两套完全不同的代码逻辑,选错了会在很后面才暴露问题。

第一种是硬件 NSS 输入,把 NSS 配成复用功能,交给 SPI 外设自己管。好处是主机不拉低片选时,从机自动忽略所有 SCK,硬件层面就杜绝了误接收;坏处是这个引脚被外设占住了,你没法再拿它做 EXTI,也就拿不到「帧开始」和「帧结束」这两个时间点。

第二种是软件 NSS,也就是工程里选的这种。SPI 内部的 SSI 位被 HAL 置为有效,从机处于「永远被选中」的状态,SCK 来多少收多少。片选引脚完全解放出来当普通 GPIO 用,接 EXTI,下降沿表示一帧开始、上升沿表示一帧结束。代价是主机如果不规矩地乱发时钟,从机也照收不误,抗干扰能力靠外部上拉和软件校验补。

选第二种的理由很直接:从机接收必须知道帧边界。HAL 的接收接口是按字节数工作的,你跟它说收 8 个字节,它就收满 8 个字节才回调;可主机这一帧是 6 个字节还是 10 个字节,HAL 不知道。只有片选边沿能告诉你「这一帧到此为止」。所以工程里用软件 NSS + EXTI 的组合,本质上是拿硬件片选的自动保护换来了软件侧的帧同步能力。

提示:如果坚持用硬件 NSS,又想拿帧边界,可以再单独引一根「数据就绪」信号线到另一个 GPIO,主机发完拉一下。多一根线,但抗干扰和帧同步都拿到了,量产板子上这么干的不少。

4. 接收链路实战:中断、DMA 循环缓冲与帧边界判定

配置对了只是能收到数据,要收得稳、收得齐,功夫全在回调里。这一章按「先简单后复杂」的顺序把三条链路走一遍,最后落到工程实际用的那套组合。

4.1 中断接收:HAL_SPI_Receive_IT 的一次性挂载

最小可跑的版本是先上中断,验证引脚和时钟模式有没有对:

#define FRAME_LEN 8 static uint8_t rx_it_buf[FRAME_LEN]; void SPI_IT_Start(void) { /* 挂载一次只收一帧,收满 FRAME_LEN 个字节后进回调,然后必须重新挂 */ if (HAL_SPI_Receive_IT(&hspi1, rx_it_buf, FRAME_LEN) != HAL_OK) { Error_Handler(); } } /* 收满 FRAME_LEN 个字节触发 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { frame_ready(rx_it_buf, FRAME_LEN); /* 先交给上层,别在里面做耗时操作 */ HAL_SPI_Receive_IT(&hspi1, rx_it_buf, FRAME_LEN); /* 关键:重新挂下一次 */ } }

HAL_SPI_Receive_IT的语义是「收满 Size 个字节为止」,收满之后 HAL 会把状态置回HAL_SPI_STATE_READY并把中断关掉。所以回调里那句重挂不是可选项,漏了就是「只有第一帧有数据,后面全是空的」——这个现象太典型了,评论区一问基本就是这个原因。

这套写法适合帧长短、频率低的场景,比如主机 100 ms 发一次 8 字节。一旦帧率上来,比如每 1 ms 一帧、每帧 32 字节,中断频率就是 32 kHz,光是进出中断的开销就够 CPU 喝一壶,更别说主循环里还有别的事情。

4.2 循环 DMA + 半传回调做双缓冲

工程里实际用的是 DMA。关键在于把 DMA 配成循环模式,让缓冲区自动回卷,省掉回调里反复重装的那一步:

#define HALF_LEN 32 static uint8_t rx_dma_buf[HALF_LEN * 2]; /* 前后两个半区,轮流交给解析 */ void SPI_DMA_Start(void) { /* 长度给整个缓冲区,循环模式下 DMA 走满一圈自动回到起点 */ if (HAL_SPI_Receive_DMA(&hspi1, rx_dma_buf, sizeof(rx_dma_buf)) != HAL_OK) { Error_Handler(); } } /* 前半区填满:可以安全读 rx_dma_buf[0..HALF_LEN-1] */ void HAL_SPI_RxHalfCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { frame_ready(&rx_dma_buf[0], HALF_LEN); } } /* 后半区填满:可以安全读 rx_dma_buf[HALF_LEN..2*HALF_LEN-1] */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { frame_ready(&rx_dma_buf[HALF_LEN], HALF_LEN); } }

双缓冲的价值在于 DMA 往 A 区写的时候 CPU 读 B 区,互不打架。半传和全传两个回调各管一半,任何时刻只有一个半区正在被 DMA 改写,另一个半区可以放心解析。

但这套逻辑有个隐含前提:帧长固定且等于半区长度。主机发的是 8 字节一帧,而半区是 32 字节,那每次半传回调拿到的其实是 4 帧粘在一起的数据,得上层自己再切。如果帧长可变,这套就不够用了,得往下看 4.3。

HAL_SPI_Receive_DMA的Size参数在循环模式下表示「一圈的长度」,不是「总接收量」。有人把它当成一次性接收量,传了 8,结果就是缓冲区只有 8 字节,DMA 疯狂回卷覆盖,上层永远只看到最后两个字节。

4.3 用 CS 上升沿 EXTI 收尾:帧边界的判定与结算

变长帧的正解是「DMA 只管搬,帧边界由片选说了算」,靠 DMA 的剩余计数(NDTR)反推这一帧到底收了多少个字节:

static uint16_t last_pos = 0; /* 上一次结算时,DMA 累计写入位置(对缓冲区长度取模) */ void HAL_GPIO_EXTI_Callback(uint16_t pin) { if (pin != GPIO_PIN_4) return; if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4) == GPIO_PIN_SET) /* 上升沿 = 帧尾 */ { /* NDTR 是 DMA 还要搬多少个才回卷,用总长减掉就是累计写入量 */ uint16_t remain = __HAL_DMA_GET_COUNTER(hspi1.hdmarx); uint16_t written = sizeof(rx_dma_buf) - remain; /* 两次结算之间的差值就是这一帧的长度,% 处理回卷 */ uint16_t len = (uint16_t)((written + sizeof(rx_dma_buf) - last_pos) % sizeof(rx_dma_buf)); if (len > 0 && len <= FRAME_MAX) { frame_ready(&rx_dma_buf[last_pos], len); } last_pos = written; } }

__HAL_DMA_GET_COUNTER读的是 DMA 通道的 NDTR 寄存器,它在循环模式下递减到 0 之后自动重装回缓冲区长度,所以总长 - remain就等于「从启动到现在一共搬了多少字节」。这个累计值对缓冲区长度取模,就是当前写指针的位置;和上次记录的位置一减,就是这一帧的长度。

两个参数要盯紧:FRAME_MAX是协议层允许的单帧上限,len超过它就说明要么片选抖动误触发,要么主机发了超长帧,两种情况都该丢弃而不是硬收。缓冲区长度建议取 2 的幂(比如 64、128),这样取模可以换成位与,省掉除法指令,在中断里更划算。

注意:__HAL_DMA_GET_COUNTER读到的是一个瞬态值。如果片选上升沿来得太早,DMA 可能还有一个字节的请求在排队没搬完,这时读到的written会少 1。稳妥做法是在 EXTI 里先读一次、隔几微秒再读一次,两次一致才结算;或者干脆判断__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY)清零后再读。

还有一点,16 位的written在累计到 65536 之后会回绕,缓冲区长度如果不是 65536 的因数,回绕那一刻算出来的len就是错的。工程里缓冲区小、跑的时间短,问题不明显;产品代码里我会另开一个 32 位软件计数器专门记累计字节数,written只用来算位置。

5. 联调避坑:从机接收最容易翻车的五个点

下面这五条基本覆盖了从机接收 90% 的现场故障,每条按「现象 → 原因 → 解决」写,对号入座就行。

5.1 起不来的两类:时钟、复用、片选

第一条:程序卡在HAL_SPI_Receive里直到超时。

现象是程序跑起来就停在那行不动,几秒后返回HAL_TIMEOUT,主循环再也没往下走。 原因是阻塞式接收在从机模式下等的是「收满 Size 个字节」,而主机此刻根本没发数据,SCK 一拍都没有,自然永远收不满。 解决办法有两个方向:一是改成HAL_SPI_Receive_IT或 DMA 版本,让函数立即返回;二是如果确实要用阻塞版,把调用点挪到片选下降沿之后,并且把Timeout设成一个合理的帧时长,别填HAL_MAX_DELAY。

第二条:一上电就进HAL_SPI_ErrorCallback,错误码是HAL_SPI_ERROR_MODF。

现象是初始化完成后立刻报模式错误,数据一个字节都收不到。 原因通常是用了硬件 NSS 输入,但主机侧片选一直是高电平(或者根本没接),SPI 外设在被选中的状态下检测到 NSS 无效,硬件置位 MODF。也有一种情况是主机把 NSS 当普通 IO 用,电平乱跳。 解决办法:按 3.3 节的思路改成SPI_NSS_SOFT,把片选解放成 GPIO;如果必须用硬件 NSS,那就在HAL_SPI_ErrorCallback里做恢复——清SPE位再重新置位,同时调用HAL_SPI_Receive_DMA重新挂载,别指望它自己缓过来。

5.2 数据不对的两类:错位与丢字节

第三条:收到的数据整体偏移一位,看起来像又不像。

现象是发 0x01 0x02 0x03 收回来是 0x80 0x81 0x01 这种,或者第一个字节永远是 0x00 或 0xFF。 原因是 CPOL/CPHA 和主机不一致。模式 0 和模式 3 的空闲电平不同,错配以后采样点落在数据跳变的边沿上,采到的就是「上一位的尾巴」。 解决办法:先确认主机侧用的是哪个模式(多数 MCU 默认模式 0),把CLKPolarity/CLKPhase对齐;同时检查FirstBit是不是都 MSB。实测最省事的定位方法是让主机发 0x00 到 0xFF 的递增序列,从机侧把原始字节全部打印出来,错位一眼就能看出来是移了几位。

第四条:一帧 8 字节只收到 7 字节,最后那个永远丢。

现象是帧头和中间数据都对,就是帧尾少一个。 原因是片选上升沿进 EXTI 后,代码里立刻调了HAL_SPI_Abort()把 DMA 通道关了,而此时最后一个字节还躺在 SPI 的接收 FIFO 里没搬进内存,通道一关就永久丢了。 解决办法:不要在 EXTI 里立刻停 DMA。先读SPI_FLAG_BSY等它清零,或者干脆不停 DMA——循环模式下它自己会继续转,你只要按 4.3 的方式把last_pos结算掉就行,停不停根本不影响数据完整性。真要停,也得在确认 FIFO 空之后再停。

5.3 溢出与调试手段:OVR 不是玄学

第五条:跑几分钟必进一次HAL_SPI_ErrorCallback,错误码HAL_SPI_ERROR_OVR。

现象是偶发、无规律、压测才复现,重启就好,越跑越频繁。 原因是接收速度跟不上发送速度,新数据来了旧数据还没被读走,硬件置位溢出标志。常见诱因有三个:SPI 或 DMA 中断优先级太低,被串口、定时器中断压住了;回调里塞了printf这种耗时操作;主机侧突发连发多帧,中间没有留间隔。 解决办法:把SPI1_IRQn和 DMA 通道中断的优先级提到高于其他通信外设;回调里只做「记录指针 + 置标志」,解析逻辑全部丢到主循环;实在避不开突发,就在协议层加流控或者扩大缓冲区吸收峰值。

调试这套东西,逻辑分析仪比任何printf都管用。我的固定套路是:先抓 SCK、MOSI、CS 三根线的波形,确认模式、帧长、帧间隔;再看从机侧hspi1.State和hdmarx->Instance->NDTR的实时值,判断 DMA 到底动没动。这两个值一摆出来,问题基本就定位到「配置」「时序」「缓冲」三选一了,比盲猜快十倍。

6. 二次开发:把它改成自定义协议的从机收发一体

这份例程只做到「字节收进来」,实际项目里下一步一定是加协议。我的做法是保持 DMA 层不动,在frame_ready()和业务逻辑之间插一层无状态的字节流解析器,这样协议改动完全不影响底层:

/* 帧格式:0xAA 0x55 | LEN | PAYLOAD[LEN] | CRC8 */ static void frame_ready(const uint8_t *p, uint16_t len) { static uint8_t state = 0, expect = 0, idx = 0, payload[64]; for (uint16_t i = 0; i < len; i++) { switch (state) { case 0: state = (p[i] == 0xAA) ? 1 : 0; break; /* 找帧头第一字节 */ case 1: state = (p[i] == 0x55) ? 2 : 0; break; /* 帧头第二字节,错则重找 */ case 2: if (p[i] > sizeof(payload)) { state = 0; break; } expect = p[i]; idx = 0; state = 3; break; /* 长度合法性检查 */ case 3: payload[idx++] = p[i]; if (idx >= expect) state = 4; /* 收满载荷转校验 */ break; case 4: if (crc8(payload, expect) == p[i]) on_frame(payload, expect); state = 0; break; /* 校验失败也回帧头状态 */ } } }

这个状态机的好处是跨帧、跨缓冲区都能工作。DMA 半传回调给过来的数据可能正好切在一帧中间,状态机自己会记住进度,下一次喂进来接着解析,不用在底层做任何对齐。expect那个长度检查不能省,否则主机发一个0xAA 0x55 0xFF就能把payload写穿。

改成收发一体也很简单:HAL_SPI_TransmitReceive_DMA在从机模式下同样可用,但要注意发送的前提是主机在发时钟,从机自己发不出去。所以从机的「发送」本质上是预装——主机下一次来读的时候,你把要回的数据提前放进发送缓冲区。常见做法是维护一个发送队列,在片选下降沿(帧开始)那一刻,把队首的数据装进tx_buf,然后挂上HAL_SPI_TransmitReceive_DMA。

验证顺序我一般固定三步走:第一步回环,把 MOSI 和 MISO 短接,主机发什么就应该收到什么,验证引脚和模式;第二步发递增码 0x00~0xFF,看从机侧原始字节是否完全一致,验证 DMA 计数和帧边界;第三步才上真实协议跑压力测试,主机侧改成 1 ms 一帧连续发半小时,中间穿插人为拔插杜邦线,看能不能自动恢复。第三步最费时间,但也最能暴露问题——很多帧边界 bug 只在连续跑几万帧之后才冒出来。

从那以后我每次新起一个 SPI 从机工程,都会先把「回环 + 递增码」这两步强制走一遍,哪怕引脚是照抄上一块板子的。因为 SPI 这东西的故障现象太像了:错位、丢字节、溢出,看起来都是「数据不对」,但根因分布在配置、时序、缓冲三个完全不同的层。跳过验证直接写业务逻辑,后面每改一次协议就得回头重查一次底层,反而更慢。希望这些能帮你少熬两个晚上。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进