☰
STM32 SPI双机通信从配参到联调避坑全指南
2026/10/15 18:12:58 网站建设 项目流程

简介:这是一份STM32 SPI双机通信的完整工程资源,面向嵌入式开发者和电子设计爱好者,解决两块STM32之间高速同步串行通信的配置与实现问题。包内提供主机、从机两套工程代码及配置文件,覆盖SPI接口初始化、时钟极性相位设置、主从模式切换、查询接收触发等关键环节,为理解中断与DMA传输方式提供参考,可直接用于单片机通信实验或产品原型验证。资源共51个文件,以C源码、头文件、Keil工程文件(uvproj/uvopt)、编译生成的hex/lst/map及备份文档为主,整体体积仅121KB,结构紧凑,适合快速查阅和移植。已有1899人学习下载,适合需要参考实际SPI双机通信代码框架、理解双主机轮询机制或排查通信异常的开发者使用。

1. 两块板子做 stm32 spi双机通信,为什么配通了却不干活

stm32 spi双机通信,网络教程通常一句带过:主机调 HAL_SPI_Transmit,从机调 HAL_SPI_Receive,搞定。但真把两块板子摆在桌面上、杜邦线一接,省略掉的前提全跑出来了——时钟极性一不一致、从机要不要开中断、片选信号谁拉低、分频系数怎么取。任何一个对不上,示波器里 SCK 波形完全正常,接收端却永远读不到预期数据。

本文写给手里有板子、想让两块 STM32 互传数据的工程师。我会按「协议前提 → 主机代码 → 从机代码 → 联调避坑 → 进阶手段」的顺序,把双机通信从配参到调通的全过程拆开讲,所有代码都以最小可验证的主从模型为基准,你改一改引脚定义就能直接烧进去。

2. 先定协议再焊线:SPI 主从分工、四线制与时钟极性怎么选

2.1 主机与从机的权力边界:时钟在谁手里,节奏就在谁手里

SPI 和 UART、I2C 最大的不同在权力模型。UART 是双方各自用自己的时钟采样,波特率差一点还能忍;I2C 靠时钟线同步,但设备可以拉低时钟线叫慢点。SPI 则简单粗暴——主机产生 SCK,所有数据位都在 SCK 的边沿上被采样,从机一个字都别想反抗。

这个权力边界决定了双机通信的基本设计思路:从机只能被动响应,主机要主动发起、主动结束、主动控制节奏。两块板子通信,谁当主机明确是第一步。通常选择资源更多、运算更强、或者负责调度逻辑的那块当主机,另一块当外设式从机。某开发者的习惯是控制板当主机,采集板当从机,这样采集板代码可以做得非常简洁,主循环里只处理解析和上报。

时钟在主机手里还有个连带后果:从机的接收缓冲区是死等的。主机不发时钟,从机的收数据函数就卡在那里。所以从机代码里凡是阻塞式接收,一定要配合超时处理或者中断方式,否则一旦主机端出问题,从机的整个任务调度都被拖死。

2.2 四线制与三线制:双机通信用全双工还是半双工

SPI 物理上最多四根线:SCK(时钟)、MOSI(主机输出/从机输入)、MISO(主机输入/从机输出)、NSS(片选)。双机通信按理该用全双工,主机发的同时收从机回的数据,但实际上很多场景只用一半。

只做主到从的单向传数据,可以砍掉 MISO 接线。从机方对应的接收引脚悬空也不影响工作,因为从机只输入不输出。这种三线制的优点是接线简单、排查容易,缺点是主机完全不知道从机处理结果——从机收完成功与否全靠自定义的应答帧。真正的双机互发还是建议把四根线都接上,全双工模式下主机一次 HAL_SPI_TransmitReceive 调用既能发指令又能拿回从机的状态或数据,效率高不只一倍。

NSS(片选)经常被忽略成一根普通 GPIO,但它在新手阶段最容易埋雷。硬件 NSS 模式由外设自动拉低拉高,看似省事,但引脚复用限制多;软件 NSS 模式则需要自己写 GPIO,麻烦一点却可控性极强。双机通信我强烈建议用软件 NSS,原因后面避坑章节会细说。

2.3 CPOL/CPHA 两个参数:主从不一致时连 0xFF 都是错的

CPOL 决定 SCK 空闲时的电平,CPHA 决定数据在哪个边沿采样。这是双机通信里最便宜、也最容易全盘皆输的两个参数。Mode 0(CPOL=0,CPHA=0)是绝大多数板载器件的默认配置,双机通信这里也直接用 Mode 0 即可——两个 STM32 同种配置通信时甚至不存在兼容性问题,但如果你把从机换成 OLED、Flash 芯片等其他器件,坐标就必须按对方 datasheet 重新对表。

需要警惕的是 CubeMX 下拉表里的措辞:时钟极性选 Low 还是 High,时钟相位选 First Edge 还是 Second Edge。Mode 0 对应 Low + First Edge,Mode 3 对应 High + Second Edge。我见过有人主机配的 Mode 0、从机配的 Mode 2,结果逻辑分析仪上数据横竖都对不上号,找了好几天才发现是 CPOL 差了一个身位。两个 STM32 之间通信,这类问题不会报错,只会表现为数据错位,你根本不知道是自己代码问题还是参数问题。

模式CPOLCPHA采样边沿适用场景
Mode 000第一个边沿双机通信默认选择
Mode 101第二个边沿部分传感器外设
Mode 210第二个边沿少数同步外设
Mode 311第二个边沿部分 Flash 器件

3. 主机端从 CubeMX 配置到发送函数:最小可用的发送代码

3.1 CubeMX 里主机侧参数:从工作模式到预分频器怎么填

打开 CubeMX 的 SPI1 配置面板,第一步把 Mode 选为 Full-Duplex Master。这里要是选成 Slave,你的时钟引脚方向就反了,连示波器探头都不用接就能发现 SCK 上永远没有波形。SPI1 挂在 APB2 总线上,最大时钟通常到 72MHz 甚至更高;SPI2/SPI3 挂在 APB1 上,上限低一些。双机通信初期不建议追求吞吐,先把分频系数压到 16 或 32,也就是实际 SCK 在 4.5MHz 以下,等链路稳定了再慢慢提。

数据宽度默认 8 Bits,除非你的业务帧确实需要 16 Bit 单位,否则保持 8 Bits。帧格式 Motorola 默认即可。真正要动脑子的是 NSS 设置。CubeMX 里把 NSS 设为 Software(软件管理),然后在 GPIO 配置里单独开一个输出引脚做片选——随便找一个普通 GPIO,配置为 Output Push Pull,初始电平 HIGH。因为 SPI 的片选低有效,初始高电平等于默认不选中从机。

时钟极性和相位按照上一章说的,全部选 Low 和 First Edge,对应 Mode 0。从机侧也要完全一致,这是双机通信的硬前提。最后配置完成记得把生成的初始化代码确认在HAL_SPI_MspInit里有 GPIO 的复用功能映射,缺了这个后面的发送必然石沉大海。

3.2 主机发送最小代码:HAL_SPI_Transmit 加片选控制

主机发送的最小代码其实只有几行,但要把片选打开和关闭包在发送函数外面,保证整个帧传输期间从机保持选中状态。看下面的例子:

#include "spi.h" #include "gpio.h" extern SPI_HandleTypeDef hspi1; // 两块板子通信,CS 由 GPIO 控制,这里定义为 PE4 #define CS_GPIO_Port GPIOE #define CS_Pin GPIO_PIN_4 uint8_t tx_data[8] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x88}; void Host_SendFrame(uint8_t *buf, uint16_t len) { // 1. 拉低片选,通知从机主机要开始发数据了 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // 2. 同步发送 len 字节,超时 10ms HAL_StatusTypeDef ret = HAL_SPI_Transmit(&hspi1, buf, len, 10); // 3. 拉高片选,结束本次传输 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); // 4. 超时或总线错误时清标志位,避免卡死后续调用 if (ret != HAL_OK) { __HAL_SPI_CLEAR_FLAG(&hspi1, SPI_FLAG_MODF | SPI_FLAG_OVR); } }

逻辑说明:片选拉低要在发送动作之前,形成一个完整的帧窗口;发送结束后立刻拉高,给从机一个「帧结束」的电平参考。如果先发送后拉低片选,从机可能从 GPIO 变化才刚进入接收状态,前几个字节就丢了。

参数说明:HAL_SPI_Transmit最后一个参数是超时时间,单位毫秒,只在阻塞模式下生效。双机通信的主机发送通常是阻塞式的,因为主机是主动方,发得快慢自己心里有数。但注意超时不能设得太大,否则 SPI 总线异常挂死时主循环会被拖住。10ms 是个折中值,后续可以按帧长调整。

3.3 把发送函数封装成业务接口:超时、返回值和总线忙判断

裸写 HAL_SPI_Transmit 能跑通,但不能上线。真实业务里主机要发的不只一种命令,每种命令的数据长度、内容格式都不一样。我一般会在外层再包一层业务接口,把原始发送函数和具体业务解耦。一个常用写法是加总线忙状态判断,发送前检查 SPI 是否正被其他任务占用:

volatile uint8_t spi_bus_busy = 0; int Host_SendCommand(uint8_t cmd, uint8_t *payload, uint16_t len) { if (spi_bus_busy) { return -1; // 总线忙,直接返回,不阻塞等待 } spi_bus_busy = 1; // 组装一帧:命令字 + 长度 + 数据 + 校验 uint8_t frame[64]; frame[0] = cmd; frame[1] = (uint8_t)len; for (int i = 0; i < len; i++) { frame[2 + i] = payload[i]; } frame[2 + len] = Calc_Checksum(frame, len + 2); // 自定义校验函数 Host_SendFrame(frame, len + 3); spi_bus_busy = 0; return 0; }

逻辑说明:这里的spi_bus_busy是一个软件锁,如果后续你把这套代码接入 FreeRTOS 或者裸机时间片调度,这个标志位可以换成互斥量或者关中断保护。对一个双机通信的简单项目来说,一个全局标志位够用且直白。

返回值的意义要约定清楚:返回 0 表示发送调用已成功提交,返回 -1 表示总线忙,返回 -2 表示参数不合法。这里的「成功」不代表从机已经正确收到,只代表主机把数据交给了 SPI 外设。真正的端到端确认必须靠从机回发的应答帧,在下一章的收发模型中会体现。

4. 从机端用中断接收:双缓冲与自定帧协议是怎么搭的

4.1 从机为什么不建议用 while 轮询:主频不一致时数据会丢

从机是被动方,它的接收方式直接决定整个链路的可靠性。最直觉的做法是主循环里不断检查接收标志,但这里有两个问题。第一,主机发送的节奏不受从机调度控制,如果从机主循环里刚好在处理其他外设的中断,SPI 的接收数据寄存器已经满了,而你又没及时读走,后续字节就会把前面的数据覆盖掉或者直接溢出丢帧。第二,轮询代码常常写成while (HAL_SPI_Receive(...) != HAL_OK)的死等形式,主机只发了 4 个字节而这道卡在 8 字节,从机就会一直等到天荒地老。

中断接收是双机通信的主流方案。每个 SPI 字节到达都会触发一次中断,CPU 在中断服务函数里把数据取走,主循环只负责软件层面的解析。这种方式对 CPU 主频和 SPI 时钟比例不敏感,即使 SPI 时钟远高于主循环任务频率,中断也能把每个字节接住。代价是中断频率等于字节到达频率,高速传输时中断会比较频繁,所以后面要用乒乓缓冲把处理时间挪出中断。

4.2 从机中断接收最小代码:乒乓缓冲与回调写法

从机侧的接收设计用两个缓冲区轮换:一个缓冲区用于当前正在接收的数据,另一个缓冲区放着上一帧收完、等待主循环解析的数据。两个缓冲区交替使用,避免中断服务函数里做复杂拷贝和解析。

#include "spi.h" extern SPI_HandleTypeDef hspi1; #define RX_BUF_SIZE 64 volatile uint8_t buf_ping[RX_BUF_SIZE]; volatile uint8_t buf_pong[RX_BUF_SIZE]; volatile uint8_t *active_buf = buf_ping; // 当前正在接收的缓冲区 volatile uint8_t frame_ready = 0; // 解析完成的标志 volatile uint8_t parse_buf[RX_BUF_SIZE]; // 主循环解析用的拷贝区 volatile uint16_t parse_len = 0; void Start_Rx_IT(void) { // 把 active_buf 作为接收目标,开中断接收 RX_BUF_SIZE 字节 HAL_SPI_Receive_IT(&hspi1, (uint8_t *)active_buf, RX_BUF_SIZE); } // 接收完成回调:由 HAL 库在中断服务函数内部调用 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { // 当前缓冲区收满一帧,切换 used 指针 volatile uint8_t *done_buf = active_buf; // 把 active 切到另一个 buffer,准备下一轮接收 if (active_buf == buf_ping) { active_buf = buf_pong; } else { active_buf = buf_ping; } // 把已收完的数据拷给主循环,避免主循环和中断抢同一块内存 memcpy((uint8_t *)parse_buf, (uint8_t *)done_buf, RX_BUF_SIZE); parse_len = RX_BUF_SIZE; frame_ready = 1; // 立刻开启对下一个缓冲区的接收 HAL_SPI_Receive_IT(&hspi1, (uint8_t *)active_buf, RX_BUF_SIZE); } }

逻辑说明:中断回调里不解析业务帧,只做三件事——切换缓冲区、拷贝数据、重新启动接收。memcpy虽然有点耗时,但 64 字节的拷贝在 ARM Cortex-M 上只要几十个指令周期,远比在中断里做数据解析安全。如果你的从机主频很低或者 SPI 时钟非常高,可以把memcpy换成指针交换,但代价是主循环不能碰done_buf,只能碰parse_buf。

参数说明:RX_BUF_SIZE必须和主机单次发送的最大帧长匹配。主机如果发 100 字节而这里只设 64,回调永远不会触发,因为每次HAL_SPI_Receive_IT等待的是 64 字节后续,收发长度不一致会直接卡死。双机通信的帧长必须两头对齐,这是第一原则。

4.3 自己约定帧格式:帧头帧尾、长度和校验的约定

中断接收拿到的是一整块原始字节流,从机必须知道哪些字节有意义。双机通信最常见的帧格式是:帧头(2 字节)+ 长度(1 字节)+ 数据(N 字节)+ 校验(1 字节)。帧头用固定值如0xAA 0x55,从机收到帧头才开始记录,之前的填充字节全部丢弃。

uint8_t Parse_Frame(uint8_t *buf, uint16_t len) { if (buf == NULL || len < 4) { return 0; // 帧长度小于最小长度,肯定不完整 } // 1. 找帧头 int i = 0; while (i < len - 1) { if (buf[i] == 0xAA && buf[i + 1] == 0x55) { break; } i++; } if (i >= len - 1) { return 0; // 没找到帧头 } // 2. 取数据长度,必须小于等于剩余可用空间 uint8_t payload_len = buf[i + 2]; if (payload_len > len - i - 4) { return 0; // 长度字段超过实际剩余字节 } // 3. 校验:从帧头到数据尾的全部字节累加和 uint8_t checksum = 0; for (int j = i; j < i + 3 + payload_len; j++) { checksum += buf[j]; } if (checksum != buf[i + 3 + payload_len]) { return 0; // 累加和校验失败 } // 4. 校验通过,取出数据段交给业务逻辑 memcpy((uint8_t *)&rx_payload[0], &buf[i + 3], payload_len); rx_payload_num = payload_len; return 1; }

逻辑说明:这个解析函数从收到的字节流里提取完整帧。找不到帧头时返回 0,相当于做了一次搜索。长度字段和校验字段直接从帧内读出,校验采用最简单的累加和——双机通信的帧校验不需要加密级算法,能防住线材干扰和错位就够了。CRC16 当然更可靠,但如果你只在裸机上跑,累加和的计算成本几乎可以忽略。

参数说明:帧头建议两个字节且各不相同,0xAA 0x55是常用组合。payload_len对应用户数据的字节数,不包含帧头帧尾。校验只覆盖整帧内容但不包括校验字节本身,两边约定一致即可。这个帧格式在主从两端是同一份头文件,双机通信的协议一致性就靠这个头文件保证。

5. SPI双机联调避坑:五个高频翻车现象与排查顺序

5.1 现象一:从机收到的第一个字节总是丢

主机会先拉低片选再发数据,但第一个字节在逻辑分析仪上能看到,从机缓冲区里就是没有。原因出在从机的接收启动时序上——片选下降沿和 SCK 第一个边沿之间的时间差太短,从机的中断还没来得及把接收使能开启,主机已经送出了第一位数据。

解决方法是把从机的HAL_SPI_Receive_IT提前到初始化时启动,不要等片选到了再开。从机先进入等待接收状态,主机再拉低片选,这样第一个字节就能正常落入缓冲区。另一种办法是在主机代码里加入一个小延时,但这不是正道——从机保持常驻接收是双机通信的基本模型。

5.2 现象二:从机收不到任何数据,但 CS 明明拉低了

CS 拉低看起来正常,SCK 波形也有,但中断一次都没触发。最常见原因是 CubeMX 里 SPI 的 NSS 配置选成了 Hardware 模式。硬件 NSS 模式下,从机的 NSS 引脚由 SPI 外设接管,不能再用 GPIO 控制,主机那边随意拉一个 GPIO 去操作自然无效。而且硬件模式下如果检测到状态位异常,还能触发 MODF 错误把 SPI 锁死。

解决方法是主机从机的 NSS 都改成 Software 模式,CS 引脚完全用 GPIO 控制,外设不再插手片选逻辑。这种配置一是灵活,引脚选择自由,二是不会因为硬件 NSS 的电平判断机制引入额外错误标志。

5.3 现象三:接收数据里混进旧帧和 0xFF 垃圾字节

数据里出现大量0xFF,或者解析出来的内容明显是上一帧的半截数据。原因是从机的缓冲区没有清零,或者主机和从机的帧长不一致,导致从机收不满预定的帧长就开始了下一轮接收。0xFF在 SPI 里是特殊的——当从机不驱动 MISO 时,线上会被上拉电阻拉成高电平,采样出来的就是0xFF,这种数据看起来像是从机发的,其实是引脚悬空。

解决方法是接收前显式清零缓冲区,并在协议层加上帧头校验。清零不能用 memset 放在高频路径,而是在单次帧处理完成后清一次。帧头校验能从源头过滤掉无效字节,只要帧头没对上,这帧数据就当噪声丢了。

5.4 现象四:低速正常,一提高分频系数就错位

把预分频从 64 改到 8 之后,数据开始随机出错。原因有两个层面。第一种是 SPI 时钟快到从机的中断处理不过来,特别是中断回调里做了memcpy这类耗时操作时,新字节追着旧字节到来,缓冲区被覆盖。第二种是物理层面的问题——杜邦线太长、接触不良,信号完整度不行,高速时钟下采样点不稳定。

解决方法是先看逻辑分析仪,确认 SCK 频率是否真达到了目标值。如果频率达标还错位,把中断回调里的memcpy去掉,换成双指针交换。如果仍然错码,说明是线材问题,换短线或者用示波器看边沿速率。双机通信的速度瓶颈通常不在外设而在布线,这一点很现实。

5.5 现象五:两块板子接上后某颗芯片发热严重

接线一上电,从机主控芯片温度上升,手摸能明显感觉到烫。原因几乎都是电平不匹配——主机板子是 3.3V 逻辑,从机板子的引脚碰上了 5V 的或者直接电源接反。SPI 的 MOSI、SCK 输出端是推挽结构,如果从机引脚耐压不够,过压电流直接灌进 I/O 保护二极管,发热是轻的,永久性损伤是重的。

解决方法是在连接前用万用表测两块板子的电源和逻辑电平,确认同电压等级。3.3V 接 3.3V,5V 接 5V,实在要跨电平就在线上串电阻或者用电平转换芯片。这个坑属于低级但高发,因为调协议时注意力都在代码上,物理链路反而没人管。

6. 双机进阶:DMA 传大包与全双工对发,验证链路稳定性的三个习惯

6.1 DMA 模式解放 CPU:传 1KB 数据的配置要点

中断接收在低速小数据量下没问题,但如果你要传 1KB 以上的数据包,每个字节一个中断,CPU 占用率会非常难看。这时候用 DMA 就能把数据搬运工作交给外设直接内存访问。主机侧把HAL_SPI_Transmit换成HAL_SPI_Transmit_DMA,从机侧换成HAL_SPI_Receive_DMA。DMA 模式下回调不再逐字节触发,而是整段数据搬运完才触发一次完成中断,CPU 占用率几乎为零。

6.2 全双工对发模型:主机发命令,从机同时回状态

双机通信真正有价值的是全双工。主机调用HAL_SPI_TransmitReceive一次调用里既发出当前指令,又回收从机上一轮填好的应答数据。这要求从机在接收完主机的命令后,把要回的数据填进发送缓冲区,然后启动一次HAL_SPI_Transmit_DMA,数据刚好在主机下一次发指令的时候被采样走。一来一回数据的同步完全由 SPI 时钟统一驱动,不需要额外握手。

6.3 三个验证习惯:先看波形,再对帧头,最后压速率

第一个习惯是每次改完代码,先用逻辑分析仪确认 SCK、MOSI 两线的时序是否和预期一致。第二个习惯是调试阶段把帧格式的原始字节打印出来,直接肉眼核对帧头位置和长度字段。第三个习惯是完成小数据量调通后,用 128 字节以上的帧做压力测试,慢慢调快分频直到出错,再退一档。这三个顺序能帮你快速定位问题是协议层还是物理层。

我最初的教训是省掉第一步就上线,最后用 OLED 屏看输出,一屏乱码根本不知道是软件还是硬件问题。后来老老实实接逻辑分析仪,十分钟就找到是 CS 拉低时序错了。双机通信说到底是时序和状态机的游戏,越早看波形,坑越浅。希望帮到你。

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

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

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

立即咨询