资讯动态

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

发布时间:2026/10/26 21:51:26 来源:尧图企业网站定制
简介这份面向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 三种接收方式的取舍阻塞、中断、DMAHAL 给 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 MHzSPI1 挂在 APB 上PCLK 也是 64 MHz那么理论上 SCK 上限在 32 MHz 附近但考虑 PCB 走线、杜邦线、上拉电阻的上升沿实际能稳跑 816 MHz 就已经不错了。我一般会先把主机侧分频到 1 MHz 跑通协议再逐档往上加加一档抓一次波形直到误码出现再退回上一档——这比一次性上高频然后怀疑人生要快得多。引脚方面SPI1 常用的那组是 PA5SCK、PA6MISO、PA7MOSI复用号一般是 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; /* CPOL0空闲低电平 */ hspi1.Init.CLKPhase SPI_PHASE_1EDGE; /* CPHA0第一个边沿采样 - 模式 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是唯一必须和主机逐位对齐的两个参数组合起来就是常说的模式 03。模式 0CPOL0、CPHA0最常见含义是 SCK 空闲为低、第一个上升沿采样。这里错一位收到数据的现象不是全错而是整体偏移半个到一整个 bit——看起来「像数据又不像数据」最难查。所以拿到一块新板子第一件事是拿示波器或逻辑分析仪确认主机侧的空闲电平和采样沿不是直接抄别人的配置。Direction这一项很多人以为从机只收就该填SPI_DIRECTION_1LINE其实单线模式是给半双工双向数据线用的从机只收却填单线HAL 会按「只发」的方向去配置接收路径根本没打开。保守做法是双线接收时 HAL 内部会自动往 DR 里塞空字节完成时钟对齐不影响功能。FifoThreshold影响中断触发的灵敏度。填01DATA表示 FIFO 里只要有 1 个数据就置标志中断来得最勤、延迟最小填更高的阈值能减少中断次数但一帧的尾巴可能卡在 FIFO 里等不到触发。从机高频小帧的场景选前者更稳。3.2 HAL_SPI_MspInitGPIO 复用、上拉与中断优先级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 短接主机发什么就应该收到什么验证引脚和模式第二步发递增码 0x000xFF看从机侧原始字节是否完全一致验证 DMA 计数和帧边界第三步才上真实协议跑压力测试主机侧改成 1 ms 一帧连续发半小时中间穿插人为拔插杜邦线看能不能自动恢复。第三步最费时间但也最能暴露问题——很多帧边界 bug 只在连续跑几万帧之后才冒出来。从那以后我每次新起一个 SPI 从机工程都会先把「回环 递增码」这两步强制走一遍哪怕引脚是照抄上一块板子的。因为 SPI 这东西的故障现象太像了错位、丢字节、溢出看起来都是「数据不对」但根因分布在配置、时序、缓冲三个完全不同的层。跳过验证直接写业务逻辑后面每改一次协议就得回头重查一次底层反而更慢。希望这些能帮你少熬两个晚上。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑