拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32 SPI帧结束检测:基于DMA与中断的可靠实现方案

1. 为什么“帧结束”是个真问题做嵌入式这些年跟 SPI 打交道的次数多到数不清。如果说 I2C 和 UART 自带清晰的“消息边界”那 SPI 在这方面就像一个“只负责搬砖、不负责包装”的苦力——它定义好了时钟、数据、片选怎么配合却从没告诉过你“这一包数据到哪儿算完”。很多刚从单片机入门转向复杂外设的开发者第一次在 SPI master 端开发时都会遇到同一个困惑数据明明能发能收但怎么可靠地知道一帧数据结束了这个问题的本质在于SPI 协议本身没有内置的“帧结束”标识。它不像 UART 那样有起始位和停止位也不像 I2C 那样有 STOP 条件。SPI 的通信完全由 master 主导只要 master 还在翻转 SCK从机就得跟上节奏。那么“帧结束”到底怎么定义是时钟停转了是片选拉高了还是数据长度凑够了不同场景下的答案完全不同这也是这个问题的陷阱所在。这篇文章我会围绕“SPI master 如何可靠检测 end of frame”这个核心话题把我在 STM32 平台上的实际经验拆开讲透。内容包括为什么 SPI 天生缺这个能力、三种主流检测方案的优缺点对比、基于 HAL 库和 DMA 的完整代码实现、以及我在项目中踩过的几个典型坑。无论你是在调一块 SPI 屏幕、一个 ADC 芯片、一个 Flash 存储器还是在写 FPGA 和 MCU 之间的通信桥这篇文章的思路都能直接拿来用。2. 先弄清楚SPI 的“帧”到底由谁说了算2.1 协议层的“无帧”设计与现实需求的冲突SPI 总线只有四根线SCK、MOSI、MISO、CS片选。从协议规范的角度看CS 拉低表示从机被选中CS 拉高表示从机被释放。很多人直觉上认为“CS 拉高”就代表一帧结束这个理解在硬件层面没错但在软件层面却没那么简单。关键在于SPI 外设的硬件在检测到 CS 拉高时不会主动向 CPU 产生一个“帧结束”中断。它只会在字节传输完成时产生中断TXE/RXNE 或 Transfer Complete而 CS 信号由 GPIO 控制时硬件根本不知道 CS 什么时候被拉高。换句话说硬件层面“CS 拉高”这个事件对 SPI 外设来说是透明的需要你自己想办法捕捉。这就造成了协议层需求和现实硬件能力之间的冲突你想检测的东西硬件没有直接给你这个信号。2.2 “帧结束”在不同场景下的三种含义根据实际应用场景“帧结束”至少有三层含义理解清楚这一点是解决问题的第一步。第一种是“字节层面的帧结束”。比如你只发一个字节、两个字节中断里判断发送完成就行。这是最原始的场景缺点是 CPU 全程参与高速传输时根本忙不过来。第二种是“DMA 传输的帧结束”。当你用 DMA 搬运一整块数据时DMA 的传输完成中断就代表这一帧发完了。这种方案效率最高但要注意 DMA 完成和 CS 释放之间的时序关系后面会详细讲。第三种是“从机视角的帧结束”。有些从机设备对帧结束的定义很特殊——比如说它认为 CS 拉高才算一帧结束又或者它要求 CS 保持低电平超过一定时间才算结束。这时候你就得综合考虑 SCK 停转和 CS 状态两个维度。搞明白你要的是哪一种“结束”才能选对检测方法。3. 三种可靠的 end of frame 检测方案对比3.1 方案一固定长度 DMA 传输完成中断这应该是我用得最多的方案也是绝大多数场景下的最优解。思路非常简单通信协议里规定好每一帧的长度或者用固定长度的命令字master 用 DMA 发送/接收完整帧后DMA 的传输完成中断就是天然的“帧结束”信号。配合硬件片选或者软件片选在 DMA 完成中断里拉高 CS整个流程干净利落。具体来说STM32 的 DMA 有传输完成中断Transfer CompleteHAL 库也提供了对应的回调函数。你在初始化时配置好 SPI 的 DMA 通道发起一次传输后硬件自动把数据搬完然后进中断。这个方案的好处是CPU 几乎零负担数据搬运全交给 DMA“帧结束”信号非常明确就是 DMA 完成中断时序可控方便精确计算一帧的总耗时代价是你必须在协议层面保证“帧”的长度是已知的、固定的。如果一帧长一帧短就得每帧重新配置 DMA 长度或者改用下面的方案二。3.2 方案二CS 边沿检测GPIO 外部中断如果你的通信协议允许“不定长帧”或者你根本不想维护帧长度寄存器那就得靠 CS 边沿来界定帧的边界了。做法是把 CS 接到一个支持外部中断的 GPIO 上配置为上升沿触发。当 master 拉高 CS 时进入外部中断服务函数在这里处理“帧结束”的逻辑比如解析接收缓冲区、清状态标志、准备下一帧。这个方案看起来很美但实际工程里有几个需要注意的坑第一CS 信号在布线较长或电平转换时会存在毛刺直接接 EXTI 很容易误触发。我一般会在硬件上加一个小电容滤波比如 1nF 到 10nF或者代码里做消抖——进中断后检测 CS 电平是否确实为高再延时几个微秒确认一次。第二外部中断服务函数里不能做耗时操作。我通常的做法是在中断里只置一个标志位然后回到主循环或 RTOS 任务里处理真正的数据解析。第三如果 SPI 速率很高、帧间距很短中断延迟可能成为瓶颈。假设一帧间隔只有 10 微秒而你的系统刚好在处理别的紧急中断CS 上升沿的中断可能会被延迟几微秒影响时序精度。3.3 方案三空闲线检测SPI 超时机制还有一种比较冷门但很有意思的思路利用“SCK 空闲超过一定时间”来判断帧结束。这个方法在 STM32 的部分系列比如某些带 SPI timeout 检测的高端型号或者外挂的 SPI 控制器 IP 里可以实现。原理是当 SPI 主设备停止翻转 SCK 超过设定阈值时硬件会触发一个超时事件这个事件就可以当作帧结束信号。这个方案的优点是帧长度完全自由不需要预先知道长度。缺点也很明显——不是所有 MCU 都支持这个特性而且“帧结束”的判定延迟取决于你设置的超时时间实时性不如前两种方案。在 STM32 平台上要自己实现“SCK 空闲检测”也不是不行——用另一个定时器捕获 SCK 的下降沿超过 N 个时钟周期没有新边沿就触发更新事件。但总感觉有点绕如果前两种方案能满足需求我不会优先选这个。3.4 三类方案选型速查表方案帧长度要求CPU 负载实时性实现复杂度适用场景固定长度 DMA 完成中断必须固定极低高低绝大多数传感器/Flash/屏幕驱动CS 边沿检测EXTI不限制中中中不定长帧、自定义协议SPI 超时/空闲检测不限制低中低高特定 MCU、对实时性要求不苛刻的场景从我个人的项目经验看能固定长度就固定长度。通信协议设计阶段就把帧长定死后面所有问题都能简化一半。如果实在不能固定CS 边沿检测是最通用、最可控的兜底方案。4. 实战STM32 HAL 库下的 DMA 帧完成检测实现4.1 硬件连接与 CubeMX 配置要点以我手头常用的 STM32F103 和 STM32F407 为例说说实际配置步骤。硬件连接上我习惯把 SPI 的 SCK、MOSI、MISO 接到 SPI 外设对应的引脚上CS 则单独挑一个普通的 GPIO 输出口来控制。这里插一句用“软件片选”还是“硬件片选”NSS 硬件控制在帧结束检测这个问题上差别巨大。如果你用了硬件 NSSSPI 外设在发送完成时可能会自动控制 NSS 拉高这个行为在不同系列、不同配置下表现不一样反而容易把帧结束的时序搞乱。我自己做项目时90% 的情况都选择软件片选——CS 上想什么时候拉高、什么时候拉低完全在代码掌控之中不会出现硬件自动行为的意外。CubeMX 里的配置建议SPI 模式Master8 位或 16 位数据时钟极性/相位根据从机手册设置常用模式 0 和模式 3波特率先按从机支持的最高速率来留 20% 余量打开 SPI 的 DMA 发送和 DMA 接收通道有些从机只需要发送或接收按需打开DMA 模式Normal普通模式不要用 Circular因为我们要在传输完成后拉高 CS4.2 HAL 库下 DMA 传输的完整代码框架CubeMX 生成的初始化代码会把它拆成MX_SPI1_Init()和MX_DMA_Init()并在main()里先后调用。初始化完成后核心代码如下// SPI 发送一帧数据使用 DMA帧长固定 void spi_frame_send(uint8_t *data, uint16_t len) { // 拉低片选从机开始监听 HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_RESET); // 开启 DMA 发送最后一个参数是 DMA 传输完成回调里用的索引或标志 HAL_SPI_Transmit_DMA(hspi1, data, len); } // DMA 传输完成回调 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { // 拉高片选从机知道这一帧结束了 HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_SET); // 置标志位告诉主循环这一帧发完了 spi_frame_done 1; } }这里最关键的细节是CS 拉高的动作放在 DMA 传输完成回调里。为什么因为 HAL_SPI_Transmit_DMA 是异步的它只是把任务交给 DMA 就返回了如果你在前面同步地拉高 CS那数据还没发完 CS 就没了从机根本收不到完整数据。另外要注意这个回调是在中断上下文执行的不要在回调里做大型数据处理。我在回调里只是拉高 CS、置一个标志位然后回到主循环里统一处理。如果是在 RTOS 环境就改用信号量或消息队列通知任务。4.3 接收方向的帧结束检测接收方向的逻辑和发送方向是对称的但有一个额外的坑很多从机设备是“先发命令再回数据”的半双工模式比如 SPI Flash、ADC 芯片。这时候帧结束的检测要分两步走先发命令帧等命令发完再启动接收 DMA等数据收完。// 示例向 SPI Flash 读取一页数据 void spi_flash_read(uint32_t addr, uint8_t *rx_buf, uint16_t len) { uint8_t cmd[4] {0x03, (addr 16) 0xFF, (addr 8) 0xFF, addr 0xFF}; // 拉低 CS HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_RESET); // 先通过 DMA 发送命令 HAL_SPI_Transmit_DMA(hspi1, cmd, 4); // 发送完成后在 TxCpltCallback 里启动接收 } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { // 命令发送完毕立刻启动 DMA 接收 HAL_SPI_Receive_DMA(hspi1, rx_buf, len); } } void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { // 数据接收完毕拉高 CS HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_SET); spi_frame_done 1; } }注意一个细节在 HAL_SPI_Transmit_DMA 完成回调里我将 SPI 的 DMA 接收通道配置成自动从机状态吗不SPI 是全双工外设接收和发送同步进行。当你启动发送 DMA 时接收 DMA 如果不开启收到的数据会被丢弃。不过没关系因为命令阶段我们不需要接收数据。但在真正的全双工场景下发送和接收是同时进行的需要同时开启两个 DMA 通道。4.4 时序测量用逻辑分析仪验证帧结束信号写完了代码一定要用逻辑分析仪实测别只看代码觉得对就行。我习惯把 CS、SCK、MOSI 三根线引出来接到逻辑分析仪上抓一帧完整的波形。重点观察CS 拉低到第一个 SCK 上升沿的时间间隔、最后一个 SCK 下降沿到 CS 拉高的时间间隔。前者是 CS 建立时间Setup Time后者是 CS 保持时间Hold Time这两个参数每个从机芯片的数据手册里都有明确要求。拿我调过的一块 SPI NOR Flash 为例手册要求 CS 建立时间最小 20ns保持时间最小 50ns。代码里的 GPIO 翻转速度很快通常在几十纳秒级别但如果你在 DMA 完成中断里先做了别的事再拉高 CS保持时间可能就不够了。实际上很多从机对保持时间也有要求——比如某些 SD 卡要求 CS 拉高前 SCK 必须已经停转否则 CRC 校验会失败。这种问题非常隐蔽波形抓出来就能一眼发现。5. 代码之外的坑时序、DMA 与中断的“三角关系”5.1 未等 SPI 完全停止就拉高 CS这是我在新手上路时踩过最冤的坑至今记忆犹新。用 HAL 库的HAL_SPI_Transmit_DMA()发起 DMA 发送后DMA 传输完成中断触发时SPI 外设是否已经把所有数据从移位寄存器里送出去了答案是不一定DMA 把最后一个字节给 SPI 的发送数据寄存器后SPI 移位寄存器可能还在往外挪最后几位。如果这时候你立刻拉高 CS从机端的最后一个字节就可能没收到或者收到的数据是错的。这个问题的本质是 DMA 完成和 SPI 发送完成之间存在一个“尾巴”。解决方案有两个方案一在拉高 CS 前延时等待 SPI 总线空闲。用while (__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY))等待忙标志清除。这个方法最可靠。void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { // 等待 SPI 完全空闲 while (__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY)) {} HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_SET); spi_frame_done 1; } }方案二在发送最后一字节时用阻塞方式等待 BSY 标志清除后再启动 DMA。这样做时序控制更精确但代码会变得复杂一点。5.2 非 8 位对齐帧长度的处理SPI 支持 8 位或 16 位数据帧。如果你用的是 16 位帧模式DMA 传输的长度单位是“半字”而不是“字节”。写代码时很容易搞混。举例你想发 10 个字节5 个半字HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)data, 10)这样写对吗不对。第三个参数Size的单位是“数据项”在 16 位数据帧下应该传 5而不是 10而且传进去的指针会被当作uint16_t *处理。如果你必须发奇数个字节又用了 16 位帧模式那就需要做特殊处理要么最后一字节单独用 8 位模式发送要么拼两个字节凑成半字。我一般直接在协议设计时避开这种尴尬全部帧长固定为偶数。5.3 中断优先级帧结束检测被更高优先级中断打断在带 RTOS 的项目里如果 DMA 完成中断的优先级设得比较低可能被其他中断比如定时器中断、UART 中断延迟响应。一次两次没问题但如果系统负载高帧结束信号可能被延迟几十微秒。我的习惯是DMA 中断优先级设一个比较高的等级不高于系统节拍中断即可并且在回调里只做“置标志/发信号量”这类原子操作把耗时的解析工作放到任务里。这样即使中断响应有波动也几乎不会影响帧结束的实际时序。如果你对帧间隔有硬实时要求比如从机需要在 CS 拉高后的 10 微秒内收到下一个帧那建议用 DMA 中断 高优先级同时在软件里统计“帧结束到下一帧开始”的时间确保在最坏延迟下也能满足要求。6. 常见问题与排查技巧实录做 SPI master 帧结束检测代码量其实不大但小问题层出不穷。下面这组问题排查笔记是我在不同项目里积累的基本覆盖了绝大多数“看起来像灵异事件”的故障。6.1 为什么最后一字节总是丢现象DMA 发送 8 字节逻辑分析仪抓到只有 7 个完整的字节周期最后一个字节只发了一半。排查思路这就是 5.1 节说的“SPI 尾巴”问题。DMA 把最后一个字节搬进 SPI 数据寄存器后SPI 的移位寄存器还在输出。如果你在 DMA 完成回调里立刻执行了别的 SPI 操作比如修改了 SPI 配置、关闭了 SPI 时钟那最后一个字节就废了。解决办法在 DMA 完成回调里先等SPI_FLAG_BSY清除再操作 CS 或 SPI 外设。还有一个容易被忽略的地方如果使用了HAL_SPI_Transmit_DMA()完成后又立刻调用HAL_SPI_Transmit()发下一帧HAL 库会返回HAL_BUSY因为它认为外设还在忙。这不是 bug是 HAL 的正常保护机制。你需要先调用HAL_SPI_Abort()或者等之前的传输完全结束。6.2 CS 上升沿有毛刺从机误判帧结束现象逻辑分析仪上看 CS 波形在中间某个位置出现了一个短暂的高电平脉冲从机就以为这一帧结束了。排查思路这个脉冲通常不是硬件毛刺而是软件操作引起的——比如你在某个中断里顺手写了一下 CS 引脚所在的 GPIO 口恰好把整个端口的输出值重新写了一次把 CS 对应的位写成了高电平。如果用库函数HAL_GPIO_WritePin()应该没有这个问题因为它是读-改-写操作。但如果你用直接寄存器赋值的方式操作 GPIO比如GPIOA-ODR something就很容易手滑。解决办法设置 CS 引脚的寄存器操作只改变目标位不要整端口覆盖。另外硬件上在 CS 引脚串一个 1kΩ 电阻加一个 10nF 电容到地可以从物理层面滤掉绝大多数毛刺。6.3 外设要求的帧间间隔太短软件来不及准备下一帧现象从机手册要求 CS 拉高后至少等 100ns 才能拉低开始下一帧但你的软件由于中断延迟实际等了 5 微秒。这看起来应该没问题——除非从机对最大帧间间隔也有要求比如超过 10 微秒就进入休眠。这种场景在低功耗传感器上特别常见。比如某些陀螺仪、气压计为了省电片选释放后很快进入低功耗状态再拉低 CS 需要重新唤醒唤醒时间可能长达几毫秒。解决办法如果频繁通信就不要在每帧之间把 CS 拉高保持 CS 一直拉低用 SCK 的空闲来控制帧间隔。如果必须拉高 CS那就得确保软件能在限定时间内发起下一帧。必要时把关帧结束检测改成阻塞式传输虽然占 CPU但时序最可控。6.4 使用硬件片选NSS时帧结束信号不稳定现象用 STM32 的硬件 NSS SPI 自动片选功能帧结束检测逻辑总是时好时坏。原因STM32 的硬件 NSS 在不同系列、不同工作模式下行为有差异。有些系列在 DMA 完成时 NSS 不会自动拉高需要额外配置有些系列在 SPI 发送缓冲区为空时就会提前拉高 NSS导致帧尾数据不完整。而且 NSS 的控制权归外设出问题时你很难在代码层面直接干预。我的建议除非你特别了解所用 MCU 的 NSS 行为否则一律用软件片选。软件片选只是多占用一个 GPIO但你能拿到完整、稳定的 CS 时序控制权这在调试帧结束问题时价值巨大。6.5 全双工场景下接收和发送的“帧结束”不同步现象用 SPI 全双工和 FPGA 通信一帧里既发数据又收数据但发送完成回调和接收完成回调的触发时间不一致导致你判断“帧结束”时接收数据还没完全到位。原因SPI 全双工模式下发送和接收同时进行但 DMA 的完成中断在发送端和接收端的触发时机可能略有差异。发送 DMA 完成时最后一个字节可能还在移位寄存器里接收 DMA 完成时最后一个字节必须已经完整移入移位寄存器了。所以接收完成往往比发送完成慢一点点。解决办法在全双工场景下以接收 DMA 完成作为一帧真正的结束信号因为接收完成意味着主从双方已经交换完了所有数据。发送完成回调里不要做帧结束处理最多清理一下发送状态。7. 调试方法总结与几个提升效率的小工具技巧如果有人问我做 SPI master 帧检测最重要的是什么我的回答是有一个好用的逻辑分析仪比任何代码技巧都重要。我的调试流程通常是这样的先写一个最小可复现的测试代码只做一件事——固定长度 DMA 发送帧结束后拉高 CS。然后用逻辑分析仪抓波形对照手册确认 CS 时序、SCK 频率、帧长度。这一步合格了再叠加业务逻辑。如果业务逻辑出问题先回头重复这个基础测试确认不是底层帧检测的问题。另外分享两个能显著提升效率的小技巧。一是抓波形时同时抓 IRQ 输出引脚把 DMA 完成回调里的一个 GPIO 翻转动作引出来这样能精确测量“DMA 完成到 CS 拉高”这段代码延迟优化实时性时特别有用。二是在调试阶段给每一帧数据加上递增序号从机端解析时可以快速发现是否有帧丢失或数据错位。对于逻辑分析仪市面上的 8 通道 24MHz 采样的基础型号就够用了价格不高。如果要抓 SPI 速率超过 10MHz 的波形建议选采样率至少 100MHz 以上的型号否则波形上升沿会失真导致误判时序。8. 关于“可靠检测”我最后想说的几点回头再看“SPI master: reliably detecting end of frame”这个题目所谓“可靠”其实包含三层意思信号可靠、时序可靠、逻辑可靠。信号可靠是指 CS 波形干净没有毛刺和抖动这靠硬件设计和 GPIO 操作的规范性来保证。时序可靠是指 CS 拉高和 SPI 数据结束的先后顺序完全符合从机手册要求绝不允许“数据还没发完就拉高 CS”的情况。逻辑可靠是指你的代码在不同编译优化等级、不同中断负载、不同 MCU 型号下都能稳定输出正确的帧结束信号而不是靠运气跑通。这三层里最容易出问题的其实是第三层。我在不同项目里反复遇到过类似情况本地调试一切正常换一批芯片或换个编译器优化等级帧检测就间歇性出问题。后来总结下来根本原因都是代码里存在未定义行为或隐含时序依赖比如没有等待 BSY 标志就操作 CS、用了一个可能被优化器重排的 volatile 变量、或者在中断回调外修改了 DMA 配置状态。所以在写 SPI 帧检测代码时我给自己定了一条规矩所有涉及硬件状态判断的地方一律使用硬件提供的状态标志所有跨中断和主循环共享的变量一律加上 volatile所有时序关键操作一律加注释说明为什么必须在这里做。看起来笨办法但确实是保证“可靠”最有效的路径。如果你读完这篇文章后只记住一个建议那我希望你记住这个帧结束检测方案不要等到写完代码再想要在协议设计阶段就想清楚。通信协议定了固定帧长后面就不用纠结怎么识别帧边界协议里必须支持不定长帧那就提前给 CS 留一个支持外部中断的引脚并把帧结束中断服务函数的设计考虑进去。好的方案不是写出来的是设计出来的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门