☰
STM32 SPI双机通信:帧格式、中断与DMA避坑指南
2026/10/15 18:13:21 网站建设 项目流程

简介:STM32 SPI双机通信资料包面向嵌入式开发者与学生,目标是在两片STM32之间搭建可靠的高速SPI链路,解决主从/双主模式下的时钟控制、数据方向协商与角色切换等问题,适合作为学习SPI协议的入门工程模板。压缩包共51个文件,体积仅121KB,核心为C/H源文件与Keil工程(uvproj/uvopt),另含HEX固件、MAP/LST辅助分析文件、BAT批处理及TXT说明,可直接导入工程编译烧录并对照修改。已有1899人浏览学习,验证了其在实际教学和项目移植中的参考价值。压缩包内给出SPI初始化、收发函数、查询接收改造等完整代码框架,并涉及中断/DMA传输思路与错误处理要点,可帮助读者从硬件连接、协议设计到调试验证全流程理解双机通信;其中“主机改成查询接收”的示例对理解主设备主动读取从设备数据尤为实用,加上工程目录结构清晰,适合课程设计、竞赛准备及快速原型开发。

1. STM32 SPI 双机通信:为什么寄存器配置对了,数据还是对不上?

做嵌入式这几年,SPI 是绕不开的接口,但「STM32 SPI 双机通信」这种需求,一上来就劝退不少人。原因很反直觉:SPI 本来是所有串行总线里最直白的——一根时钟线,一根数据线出,一根数据线进,外加一根片选,逻辑上几乎没有理解成本。可真正把两块板子用杜邦线连起来,你会发现从机收不到完整数据、主机读回来的全是 0xFF、偶尔通信正常但一上电就错位。这些现象背后不是玄学,而是对 SPI 时序模型、主从角色边界和 STM32 HAL 库默认行为的理解出了问题。

这篇文章我会从工程实现的角度拆一份完整的双机通信案例,覆盖硬件连接、协议帧设计、主机从机两侧的驱动代码,以及我实际调试中踩过的四个真实坑位。适合刚把 SPI 收发跑通、但还没有做过双机联调的开发者,也适合想直接抄一份可靠通信框架的工程师。

2. 通信模型与协议设计:先把“谁先说、说多久、怎么停”定死

2.1 为什么一对一的 SPI 也要设计帧格式

很多人的第一版双机通信是这样写的:主机 SPI_Transmit 发一个字节,从机 SPI_Receive 收一个字节,两边都以为完事了。结果从机收到的第一个字节是垃圾,或者主机发 5 个字节、从机只收到 3 个。问题不在引脚,而在“同步”这件事上。

SPI 是同步串行接口,时钟由主机产生,从机被动跟随,但同步不代表两边天然对齐。从机不知道主机什么时候开始拉时钟,也不知道一帧数据什么时候结束,它只能靠片选信号和自身的状态机去猜。如果协议里没有定义“帧起始、帧长度、帧校验”,一旦第一个字节错位,后面全错,而且永远无法自恢复。

所以双机通信的第一个决定,不是选哪根引脚,而是定义帧格式。我在这份例程里用的是最精简的三段式帧:

  • 帧头:固定 0xAA,用于从机做字节对齐
  • 数据区:固定 2 字节,低字节在前
  • 校验:累加和,只算数据区,不含帧头

这个设计的出发点是:帧头让从机在任意时刻都能重新同步,固定长度让从机知道“我收到第 n 个字节了”,累加和负责过滤噪声和信号质量问题。高频应用下帧头和数据区是必要的,校验可以演进为 CRC8,但双机短距离调试先跑通累加和就够了。

2.2 主机和从机:谁产生时钟,谁决定时序边界

SPI 总线的铁律是:主机控制时钟和片选,从机不能主动发起传输,只能响应。这就带来一个隐患——如果主机只是简单地“发了就完”,从机在数据到达之前不知道去听总线,尤其在从机没有中断驱动的情况下,数据就只能睡在寄存器里等着被轮询读走。

我在例程里采用的主从分工方式是:

主机侧:负责发起所有通信,先拉低 CS,发送帧头,再发数据区和校验,最后拉高 CS。主机是唯一掌握“什么时候开始、什么时候结束”的角色,发送期间禁止被中断打断,否则片选时序会被撕裂。

从机侧:全程中断驱动,CS 跳变沿配合 SPI 中断接收。从机主要在片选拉高时刻检查一帧是否完整收齐,而不是在接收每个字节时做判断。这个“帧完成判定放在 CS 拉高处”的思路,能避开一个非常隐蔽的死锁:接收中断里处理业务会导致下一个字节进来时缓冲区状态错乱,而延后处理则可以保证一帧只响应一次。

典型连接方式是:主机和从机的 SPI 均为全双工,SCK 对 SCK,MOSI 接对方的 MOSI,MISO 接对方的 MISO,CS 直连 GPIO。注意这里不是交叉接线,因为 STM32 的 SPI 是全双工四线制,MOSI 和 MISO 各自独立,和 I2C 的 SDA 共线逻辑不同。

3. 主机侧驱动实现:DMA 发送 + 片选时序控制

3.1 主机初始化:下时钟沿、高电平空闲、16 位帧

主机这侧的 SPI 参数看起来常规,但有两个容易被忽略的细节:一个是 SPI 帧格式用 8 位还是 16 位,另一个是时钟极性和相位的组合。绝大多数 SPU 从机外设都是 8 位帧,所以主机侧若配置成 SPI_DATASIZE_16BIT,两边看到的数据宽度就不一致,寄存器里移位的锅最后全赖在“波形不对”上。

我在例程里主机的初始化如下:

void SPI_Master_Init(void) { SPI_HandleTypeDef hspi; hspi.Instance = SPI1; hspi.Init.Mode = SPI_MODE_MASTER; // 主机模式 hspi.Init.Direction = SPI_DIRECTION_2LINES; // 全双工 hspi.Init.DataSize = SPI_DATASIZE_8BIT; // 8 位帧,与外设对齐 hspi.Init.CLKPolarity = SPI_POLARITY_LOW; // 空闲时钟为低 hspi.Init.CLKPhase = SPI_PHASE_1EDGE; // 第一个时钟沿采样 hspi.Init.NSS = SPI_NSS_SOFT; // 软件管理片选 hspi.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; // 分频 hspi.Init.FirstBit = SPI_FIRSTBIT_MSB; // 高位先出 HAL_SPI_Init(&hspi); }

这段配置里,BaudRatePrescaler = 16 意味着 SPI 时钟是 APB2 时钟的 1/16,我用的是 72MHz 系统时钟,所以实际 SCLK 是 4.5MHz。双机短距离(10cm 杜邦线以内)、没有强干扰源的前提下,4.5MHz 完全够用,而且比满速 18MHz 稳得多。如果你直接把分频拉到 2,波形上升沿变缓,从机采样容易出错,尤其是杜邦线飞线时。

主机侧的关键决策是NSS 软件管理。STM32 的硬件 NSS 在某些型号上会自动拉低,但这会导致多从机场景下的竞争问题,双机一对一根本不需要硬件自动管理片选。自己用 GPIO 控制 CS 的优点是可以精确控制时序:比如先拉低 CS,延时几微秒再发起发送,给从机留出准备时间。

3.2 主机发送函数:帧头和校验位一起打包

发送一帧数据,我习惯直接把帧头、数据、校验组成一个数组一次性发完,而不是分三次调用 HAL_SPI_Transmit——后者的风险是每次调用之间的 GPIO 操作或中断可能插入,导致从机认为这是多帧。以下是例程里的发送函数:

void SPI_Master_SendFrame(uint16_t data) { uint8_t buf[4] = {0}; // 4 字节:帧头 + 数据区 2 字节 + 校验 1 字节 buf[0] = 0xAA; // 帧头 buf[1] = (data & 0xFF); // 数据低字节 buf[2] = (data >> 8) & 0xFF; // 数据高字节 buf[3] = buf[1] + buf[2]; // 累加和,只对数据区 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // 拉低片选 HAL_SPI_Transmit(&hspi1, buf, 4, 100); // 100ms 超时 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); // 拉高片选 }

先说逻辑:帧头固定 0xAA,从机只有收到 0xAA 才开始记录一帧,因此从机能从任何错位状态重新同步。累加和采用最简单的方式,数据低字节加高字节的溢出部分直接丢弃,实际效果是接收端算一次同样的和,与 buf[3] 不匹配就丢弃整帧。

这段代码有几个参数值得注意。HAL_SPI_Transmit 最后一个 100 是超时毫秒数,主机模式下一旦 CS 拉低,从机必须在这 100ms 内响应,否则主机直接放弃并返回超时。我看不少人直接把超时写成 HAL_MAX_DELAY,导致总线卡死时程序永远停在发送函数里,电工排查半天都找不着是硬件问题还是软件死循环。建议改成 50~100ms,然后调用方检查返回值。

还有一个很多人踩过的点:裸机下 HAL_SPI_Transmit 本身是阻塞调用,如果此时来了一个优先级更高的中断,延时会超过从机的容忍范围,表现为偶尔丢一帧。解决办法是发送期间禁止调度,或者干脆用 DMA 方式发。后者需要在初始化里配置 DMA 通道,并且要处理传输完成中断里释放信号量的问题,逻辑比阻塞略复杂,初期跑通不建议直接上 DMA。

4. 从机侧驱动实现:中断收帧 + 帧完成状态机

4.1 从机初始化:必须从机模式 + 中断使能

从机侧的初始化和主机有一个本质区别:从机的 SPI 时钟来自外部,所以不能配置时钟极性吗?能,但含义不同——从机仍然要配 CLKPolarity 和 CLKPhase,但这两个参数必须和主机完全一致,否则数据会在错误的时钟沿被采样。我见过不少双机通信失败,两边初始化分别抄的例程,主机用的是 SPI_POLARITY_LOW / SPI_PHASE_1EDGE,从机用的是 SPI_POLARITY_HIGH / SPI_PHASE_2EDGE,波形直接错半个周期,收的数据自然全是错的。

从机初始化如下:

void SPI_Slave_Init(void) { SPI_HandleTypeDef hspi; hspi.Instance = SPI2; hspi.Init.Mode = SPI_MODE_SLAVE; // 从机模式 hspi.Init.Direction = SPI_DIRECTION_2LINES; // 全双工 hspi.Init.DataSize = SPI_DATASIZE_8BIT; // 8 位帧 hspi.Init.CLKPolarity = SPI_POLARITY_LOW; hspi.Init.CLKPhase = SPI_PHASE_1EDGE; hspi.Init.NSS = SPI_NSS_SOFT; hspi.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi); HAL_SPI_Receive_IT(&hspi, &rx_buf[0], 1); // 开启单字节中断接收 }

最后这一行 HAL_SPI_Receive_IT 可能是从机侧最容易被遗漏的一步。SPI 外设只有调用了这个函数,接收中断才会被使能,从机缓冲区指针才会被挂到 SPI 驱动内部;不做这一步,从机收到数据后中断标志置了但也进不了回调,数据就静静地躺在 DR 寄存器里没人管。

我在这份例程里从机用的是单字节中断接收,每收到一个字节就进一次中断,回调里根据当前索引判断属于帧头、数据还是校验。单字节接收的缺点是每个字节都产生一次中断,4.5MHz 时钟下 CPU 会被频繁打断。但好处是逻辑极其清晰,便于理解整个接收过程。真正常用生产的是 DMA 循环接收,不过那是进阶优化方向,后面第 5 章会展开。

4.2 接收回调与状态机:靠索引还是靠帧头?

从机中断回调里,最忌讳的是在中断回调内部做业务处理。SPI 收字节的中断频率很高,回调里如果执行一个延时函数或者一个复杂逻辑,下一个字节到达时接收可能来不及挂起,直接导致 FSM 错乱。我的做法是:中断回调里只做“收字节、填数组、更新索引”,当索引到 4 时把帧完成标志位置 1,然后等主循环或任务去解析。

例程里的从机接收回调:

void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI2) { static uint8_t idx = 0; static uint8_t rx_frame[4] = {0}; uint8_t received = rx_buf[0]; // 本次中断收到的字节 if (idx == 0 && received != 0xAA) { // 还没对齐到帧头:丢弃,重新等 HAL_SPI_Receive_IT(&hspi2, &rx_buf[0], 1); return; } rx_frame[idx++] = received; if (idx == 4) { // 校验通过才算整帧有效 if (rx_frame[1] + rx_frame[2] == rx_frame[3]) { frame_valid = 1; frame_data = rx_frame[1] | (rx_frame[2] << 8); } idx = 0; // 恢复状态,等下一帧 } HAL_SPI_Receive_IT(&hspi2, &rx_buf[0], 1); } }

这段写的比较细,几个地方值得解释。第一个 if 判断 idx == 0 且 received != 0xAA 时就丢弃,是从机自恢复的关键——如果主机在某个时刻才刚上电,或者外界干扰让从机收到半个字节,这个条件会不断丢弃垃圾字节,直到出现一个 0xAA 帧头,状态机才开始计数。如果没有这个对齐逻辑,任何错位都会让从机维护着一个永远错误的“帧”。

第二个细节是每帧收完 4 个字节后,不能直接退出回调,而是马上调用 HAL_SPI_Receive_IT 让下一次中断重新挂起。漏掉这行,从机只能收一帧,后面的数据全部静默丢弃,且表面上看不出任何错误代码。我最初调试时被这个坑困了很久,板子始终只能收到第一帧数据,后来是在调试器里看 SPI 的 RXNE 标志位才发现问题。

还有一个细节值得说:rx_frame[1] + rx_frame[2] == rx_frame[3] 这个校验,接收端是直接相加,发送端也是直接相加,不做任何取反或变换。这种校验只能防止数据位错误,防不了“两个字节互相交换”这种位置上颠倒的错误——如果 buf[1] 和 buf[2] 互换,和不变,校验仍然通过。工程上如果需要更高可靠性,得把累加和换成带位置的异或校验。但在双机短距离、飞线调试阶段,累加和完全够了。

4.3 主循环解析:帧完成标志与数据分离

从机的中断做完“接收”工作,主循环只做“处理”工作,这就是中断与业务解耦的典型形态。主循环代码如下:

while (1) { if (frame_valid) { frame_valid = 0; // 业务处理:frame_data 就是主机下发的 16 位数据 Process_Command(frame_data); } }

有人会在主循环里加 osDelay 或 HAL_Delay,这类延时要留意会不会覆盖掉一帧的接收窗口。比如主机每隔 10ms 发一帧,从机主循环 Delay(20ms),则每两帧才能处理一次。严格说这不是丢帧,是处理延迟,但表现出来和丢帧一模一样。主机如果对响应有时序要求,从机主循环里就不能有超过主机发包周期的延时。

这一章从机状态机的核心思路总结下来就是:中断收字节,标志位置位后立即退中断,业务延后到主循环。收字节的时机由 SPI 时钟同步,业务处理的时机由 CPU 调度决定,两者互不阻塞。

5. 双机联调避坑:四个高频踩坑点,逐个排查

5.1 现象:从机收到的首字节总是 0xFF 或随机值

这是双机通信最常见的坑,几乎人人都会中一次。现象是从机上电后第一帧数据不对,或者每次都是第二帧开始才正常。

原因有两个方向。方向一是主机在从机还没有初始化完成时就开始了第一次传输,从机 SPI 外设处于复位状态,寄存器里没有挂接收回调,数据字节只到了移位寄存器里,没有进入 rx_buf。方向二是主机拉低 CS 后没有给从机留出准备时间,就开始打时钟——从机固件里虽然已经调用了 HAL_SPI_Receive_IT,但内部可能还在做别的初始化,比如 GPIO 配置时钟没有完全稳定。

解决方式:主机侧在每次拉低 CS 后加一个 10 微秒级别的延时,确保从机已经进入就绪状态。在从机侧初始化结束时,把 rx_buf[0] 预置为 0x00,并且要求主机在发正式数据之前先发一个哑帧(比如全 0x00),让从机丢弃这一帧。这个哑帧不算业务数据,只用于让双方的 SPI 状态机完成对齐。

5.2 现象:偶发丢帧,逻辑分析仪看波形却一切正常

这类问题的隐蔽性在于,波形层面看 SCLK、MOSI、CS 都没有毛刺,也没有缺失的时钟周期,但丢帧就是间歇性出现。

排查方向上,先从代码层面看主机 HALL_SPI_Transmit 返回值的处理。如果忽略返回值,主机发了一帧超时失败但继续发下一帧,从机还会被上一帧的尾巴干扰。其次是看从机中断回调里有没有做额外操作,比如打印调试信息或者翻转一个 GPIO,这些操作在 4.5MHz 时钟下会让中断响应时间变长,下一次 SPI 中断到来时上一次还没处理完,导致 RXNE 溢出错位。

解决方式:主机侧发送函数必须检查返回值,失败时连续重发;从机侧中断回调里只做寄存器赋值和索引更新,绝对不调用 HAL_Delay、printf 之类。如果确实需要调试输出,用 DMA 缓冲到内存里,主循环再打印。

5.3 现象:主机发送正常,但从机永远收不到中断

这种问题我会先怀疑从机有没有真的进入中断回调,而不是查波形。常见原因有两个。

第一个原因是从机初始化里 HAL_SPI_Receive_IT 没有在 SPI 初始化之后调用,或者 SPI 外设时钟没有单独使能——SPI 初始化失败后 HAL_SPI_Receive_IT 返回错误,代码却没有检查返回值。第二个原因是中断优先级配置问题,SPI 中断如果和别的外设共用同一个 NVIC 分组且优先级不够高,在某段时间内会被持续屏蔽。

解决方式:在初始化函数末尾加一个自测逻辑,开启中断后主动给从机 SPI 的 DR 寄存器写入一个字节,看是否触发接收中断。如果没触发,逐个查 SPI 外设时钟是否使能、NVIC 是否配置、回调函数是否注册到正确的外设句柄。

5.4 现象:CS 拉高后立即发下一帧,造成上一帧数据被覆盖

有些应用场景要求主机快速发包,比如每 5ms 一帧,而从机处理一帧需要 1ms,看起来没问题。但实际运行时,从机收到第 4 个字节的瞬间会置 frame_valid,可主循环可能刚好正在处理其他外设的回调,没有及时把 frame_valid 清零;此时下一帧数据已经来了,idx 状态机重置为 0,开始覆盖 rx_frame 数组,上一帧的数据还没来得及取走就被覆盖了。

解决方式有两种。一种是从机加双缓冲,rx_frame 定义两份,一个用于中断接收填充,一个给主循环读取,接收进程填满一个就触发切换。另一种是主机侧控制发包频率,最小间隔不得小于从机处理一帧的最坏耗时。我实际工程里更倾向第二种——把主机的总线和应用层解耦,在应用调度上把发包周期拉长,避免“数据覆盖”这种问题出现。双缓冲本质上只是延迟了覆盖时间,没有解决业务处理速度跟不上接收速度的矛盾。

6. 进阶优化:从阻塞收发到 DMA 流水线

双机通信跑通之后,很多人会问同一个问题:这套阻塞收发能不能直接上产品?短距离、低频、小数据量的场景可以,但遇到主机 CPU 还要跑屏幕刷新、传感器采集或者控制算法时,阻塞发送会占住总线时间,而且发送期间中断响应也被拖慢。我的做法是把发送和接收都搬上 DMA,让 SPI 在后台搬运数据,CPU 只在整帧完成时被唤醒。

从机侧的 DMA 配置比主机敏感。因为从机不知道主机什么时候来数据,DMA 必须在初始化时就挂接在 SPI 的 RX 通道上,并且设置为循环模式,让 DMA 自动把收到的字节搬进一个环形缓冲区,而不需要每收一个字节就进一次 CPU。这里要留意一个参数:DMA 的缓冲区大小必须大于最大可能接收字节数,我一般取 64 字节,主机端不会超过 4 字节,剩余空间用来吸收异常情况。环形缓冲区配合读索引和写索引,主循环只比较两个索引的差值,就知道有多少新数据待处理。

主机侧的 DMA 发送改造相对直接,发送一帧只需要配置 DMA 通道的源地址指向 buf 数组,传输长度 4,并启动即可。注意要处理 DMA 传输完成中断,在这个中断里把 CS 拉高,这样片选保持的低电平持续时间正好等于数据发送时间,不用再手工控制延时。如果不把 CS 拉高放在 DMA 完成回调里,而是在主循环调用 DMA 发送后马上拉高,从机就会看到 CS 提前抬走,一帧数据还没发完就被截断了。

从 DMA 环形缓冲区里重组帧的代码框架大致如下:

void SPI_Slave_DMA_Poll(void) { uint16_t wr = hdma_spi2_rx.Instance->NDTR; // DMA 剩余计数 uint16_t rd = last_read_idx; uint16_t avail = (rx_buf_size - wr - rd + rx_buf_size) % rx_buf_size; if (avail < 4) return; // 不够一帧 while (last_read_idx != (rx_buf_size - wr)) { uint8_t b = rx_buf[last_read_idx++]; if (last_read_idx >= rx_buf_size) last_read_idx = 0; Feed_Rx_FSM(b); // 复用在中断里写好的状态机 } }

这里 NDTR 是 DMA 的剩余传输计数,会随着接收而递减,通过它计算写索引的思路是 DMA 编程里的常见技术。坑点在于 NDTR 是个 16 位寄存器,直接读取时可能刚好被递减,数值不稳定,好在双机通信速率下这个误差只影响一个字节的判断,状态机本来就具备帧头对齐能力,所以问题不大。要注意的是循环模式下 DMA 不会产生完成中断,只能依赖主循环轮询读取 NDTR,这是典型的“中断收尾 + 轮询处理”混合模式。

如果想让 DMA 中断方式更彻底,可以关闭循环模式,每次传输完成中断里重新配置 DMA 的传输长度。这样 CPU 占用更低,但需要处理“重新配置期间新数据到来”的窗口,工程复杂度高于轮询方案。我的习惯是:4.5MHz 时钟下用轮询 NDTR 完全够用,不需要追求极端的中断频率。

至于可靠性验证,拿到这套代码后建议先做两个实验:第一个是主机以 100ms 周期连续发 2000 帧,统计从机校验失败的帧数,正常情况下应为 0;第二个是把主机发包周期压到 5ms,看从机是否还能全部收齐,这个实验能直接暴露双缓冲能力不足的问题。我做完这两个实验后,才敢把这套 DMA 搬运逻辑放进正式项目里。从那以后我每次调 SPI 双机,都会强制走一遍“先拉 CS 延时、再发哑帧对齐、最后跑 2000 帧压测”的流程,这套习惯帮我少排了很多查不出原因的故障。希望帮到你。

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

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

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

立即咨询