最近拿到一块基于 STM32C092RC 的评测板,顺手就把 SPI 回环测试安排上了。所谓回环,就是拿一根杜邦线把 MOSI 和 MISO 短接,让 SPI 主机自己发数据、自己收数据,然后比对收发结果是否一致。这个操作在很多人眼里属于“入门级动作”,但实际做下来你会发现,它远不止点亮一颗 LED 那么简单。SPI 外设的时钟开启、引脚复用、模式配置、极性和相位选择,任何一个环节出错,数据都对不上,而回环测试恰好能用最直观的方式把这些隐藏问题全部暴露出来。
这篇文章就从我自己的实测过程出发,讲讲这次 SPI Loopback 测试的完整思路、工程配置、代码实现、实测结果和踩坑记录。不管你是刚接触 STM32C0 系列的新手,还是想快速验证某个硬件平台 SPI 外设是否正常的开发者,这份记录都能让你少走弯路。
1. 为什么先做回环测试:思路拆解与验证价值
1.1 回环测试到底验证了什么
很多人对回环测试的理解还停留在“把发送脚和接收脚接一起,看看能不能收到数据”,其实它的验证范围比你想的要广得多。
SPI 通信要能正常工作,至少需要满足四个条件:外设时钟已经使能、GPIO 复用功能选对、SPI 模块的参数配置正确、主从设备之间的时序能够对齐。回环测试虽然没有真实的从设备参与,但主机发送数据时,字节要经过移位寄存器、经过引脚驱动电路,从 MOSI 引脚输出,然后通过外部导线回到 MISO 引脚,再经过输入路径进入接收移位寄存器,最后存进接收缓冲区。这条链路里,只要有一个环节配置错误,接收到的数据就会出现异常。
举个例子,如果 GPIO 复用功能没选对,MOSI 引脚可能根本不输出 SPI 信号,那 MISO 自然什么都收不到。如果时钟极性和相位配置得不合理,接收端采样时刻不对,收到的数据就会错位,出现二进制数据看起来“乱七八糟”的情况。这些问题的排查难度远高于“没收到数据”这种直观故障。
所以说,回环测试是验证“SPI 外设从寄存器到引脚再到寄存器的完整通路”是否正常的最好办法。它不需要外接任何 SPI 从设备,只需要一根杜邦线,就能把外设配置问题暴露得明明白白。
1.2 两种回环实现方式的选择
SPI 回环有两种常见做法:一种是硬件回环,用导线把 MOSI 和 MISO 短接;另一种是软件回环,把 SPI 配置成接收和发送内部短接的模式。
对于一些特定系列的 MCU,SPI 外设内置了回环模式(Loopback Mode),只需要设置寄存器里对应的位,就能在芯片内部把发送数据直接反馈到接收路径,连杜邦线都不用接。这样做的好处是不受外部干扰,测试结果更纯粹,适合跑自动化测试。
不过 STM32C0 系列的 SPI 外设相对入门,并没有像我之前用过的某些高端芯片那样提供独立回环模式位。所以在这次实测中,我选择的是硬件回环:用杜邦线把 SPI1 的 MOSI 引脚和 MISO 引脚短接。这种方式最接近真实通信场景,因为数据真的经过了引脚电平变化,能顺带把 GPIO 驱动能力、引脚复用配置、输入路径上拉下拉影响都验证到。
我个人也建议在刚开始调 SPI 的时候优先用硬件回环,因为软件回环跳过了引脚和 PCB 走线路径,一旦之后接入真实设备出现问题,你反而分不清是外设配置问题还是硬件连接问题。先把硬件路径验证通了,后面排查范围会小很多。
1.3 测试目标的拆解
这次测试我给自己定了几个明确的验证目标,而不是简单地跑一遍发收就完事。
- 验证 SPI1 外设时钟是否正常使能,寄存器读写是否生效。
- 验证 GPIO 复用映射是否正确,MOSI、MISO、SCK 引脚是否被正确配置成外设功能。
- 验证 SPI 主模式下的发送和接收路径是否同时工作。
- 验证不同数据长度、不同时钟分频系数下,收发数据是否保持一致。
- 验证 NSS 引脚在软件管理模式下不会干扰通信。
把这些目标列清楚之后,测试方案就不是“跑一下看结果”了,而是有意识地构造不同测试条件,逐步缩小潜在问题范围。后面我会详细讲测试数据是怎么设计的。
2. 硬件准备与环境搭建
2.1 板卡与接线清单
这次用的板卡是一块基于 STM32C092RC 的评估板,芯片属于 STM32C0 系列,Cortex-M0+ 内核,主频最高可以跑到 64MHz,定位是成本敏感型应用。它的 SPI 外设分布在标准引脚上,我用的是 SPI1,引脚映射如下:
| 信号 | 引脚 | 功能说明 |
|---|---|---|
| SCK | PA5 | SPI1 时钟输出 |
| MISO | PA6 | SPI1 主输入从输出 |
| MOSI | PA7 | SPI1 主输出从输入 |
| NSS | PA4 | 软件管理,未使用硬件控制 |
接线就一句话:用一根杜邦线把 PA7 和 PA6 短接,也就是把 MOSI 和 MISO 连起来。除此之外,不需要连接任何从设备。为了方便调试,我还把串口1(USART1)通过 USB 转串口模块连到了电脑,用来打印测试结果,串口配置为 115200-8-N-1。
注意:杜邦线要选短一点的,线越短,寄生电容和线间串扰越小。回环测试频率不高时可能无所谓,但如果你把 SPI 时钟分频调得很高,长线回环很容易出现误码,到时候你排查半天发现是线的问题,就有点冤了。
2.2 软件环境与工具版本
开发环境方面,我这次用的是 STM32CubeMX 生成初始化代码,然后基于 HAL 库做二次开发。IDE 选择的是常见的 GCC 工具链配合命令行编译,当然你用其他集成 IDE 也一样,核心逻辑没区别。必装组件有两个:
- STM32CubeMX:用来生成工程模板,自动配置时钟树、引脚复用和外设参数。
- STM32C0 系列支持包:如果 CubeMX 里找不到芯片型号,记得先在软件中心安装对应的器件支持包。
STM32C092RC 的芯片型号,全称末尾是 RC 后缀,表示 Flash 容量 256KB,不过我这次只是跑回环测试,容量根本不是瓶颈。厂商配套的开发工具链和 HAL 库版本尽量保持最新,因为 C0 系列属于较新的产品线,早期固件库版本对某些外设的支持存在一些小问题,更新到新版本之后省心很多。
2.3 最小工程初始化步骤
不少初学者在 CubeMX 里会把一堆外设全都勾上,然后生成的代码又长又乱,出现问题根本不知道从哪里查。我这次反其道而行之,建立一个最小工程,只保留三个外设:SPI1、USART1、以及一个 GPIO 输出口用来点灯。
CubeMX 里的关键配置如下:
- RCC:选择外部晶振(HSE),如果板子上没有贴晶振,就直接用 HSI,也就是芯片内部时钟源。
- 时钟树:把系统时钟配置到 64MHz。需要注意的是,SPI 外设时钟来自 APB 总线,APB 分频系数不同,SPI 的时钟频率上限也会变化。
- SPI1:配置为 Full-Duplex Master,也就是全双工主机模式。
- USART1:异步模式,波特率 115200,用于打印测试信息。
- GPIO:如果需要点灯,把一个 LED 引脚配置为输出模式。
这样生成的工程非常干净,后续增加逻辑的时候不会牵扯到无关外设,排查起来也轻松。
3. SPI 外设配置与核心代码实现
3.1 从寄存器角度理解 SPI 发送接收流程
开始写代码之前,我先把 SPI 外设的发送接收流程捋一遍。SPI 主机发送一个字节,本质上是把数据写进发送数据寄存器,移位寄存器会把数据一位一位地从 MOSI 引脚送出去,同时从 MISO 引脚采回一位数据。这个过程是同步的,所以主机发送一个字节的同时,必然也会接收一个字节。
理解了这一点,就明白为什么回环测试只需要调用一次发送接收函数,而不是先调发送、再调接收。很多人第一次写 SPI 代码时容易犯一个错:先调一次发送函数发数据,然后再调一次接收函数去收数据,结果发现收到的永远不是自己想要的内容。原因就是发送和接收在硬件层面本来就绑定在同一个时钟时序里,你应该把这两个动作当成一次数据传输的两个面来看待。
HAL 库提供的 HAL_SPI_TransmitReceive() 函数就是干这件事的:它同时把一个缓冲区里的数据发出去,同时把接收到的数据存到另一个缓冲区里。回环测试的代码核心就是它。
3.2 CubeMX 参数配置的最佳实践
SPI1 在 CubeMX 中的参数设置,我给出实际使用的配置项:
| 参数项 | 配置值 | 说明 |
|---|---|---|
| Mode | Full-Duplex Master | 全双工主机模式 |
| Data Size | 8 Bits | 8 位数据帧 |
| First Bit | MSB First | 高位先行 |
| Prescaler | 16 | 时钟分频系数 |
| Clock Polarity (CPOL) | Low | 空闲状态为低电平 |
| Clock Phase (CPHA) | 1 Edge | 第一个边沿采样 |
| NSS | Software | 软件管理片选 |
| NSS Signal Type | Soft | 不使用硬件 NSS 引脚 |
这里重点说说 Prescaler 的选择逻辑。APB1 时钟在 HSI 64MHz 配置下,如果 APB 分频系数为 1,那么 SPI 外设时钟就是 64MHz,16 分频之后得到 4MHz 的 SCK 频率。对回环测试来说,4MHz 是一个非常保守但足够用的速度,即使杜邦线接触不太稳定,也不容易因为信号质量导致误码。
如果你用的是更高的分频,比如 4 分频甚至 2 分频,SCK 频率一下提到 16MHz 或 32MHz,回环测试也能跑通,但对杜邦线的质量、引脚接触状态、板卡的走线寄生参数都会更敏感。第一次做回环验证,建议从低频开始,先把通路跑通,再逐步往上提。
CPOL 和 CPHA 的选择,在回环测试里其实主机自己发自己收,只要收发采样时刻和输出时刻错开一个合适的窗口,大概率能跑通。但为了以后接真实 SPI 设备时有参考基准,我建议从最常用的模式 0,也就是 CPOL=Low、CPHA=1 Edge 开始,这个模式兼容性最好。
3.3 发送接收代码:轮询方式实测最稳
回环测试不需要追求极致性能,所以我直接用阻塞方式调用 HAL 库函数。核心代码如下:
#define TEST_BUFFER_SIZE 16 uint8_t txBuffer[TEST_BUFFER_SIZE]; uint8_t rxBuffer[TEST_BUFFER_SIZE]; static void fillTxBuffer(uint8_t *buf, uint8_t len) { for (uint8_t i = 0; i < len; i++) { buf[i] = i + 0xA0; // 用非连续且有规律的填充值 } } static uint8_t compareBuffer(uint8_t *buf1, uint8_t *buf2, uint8_t len) { for (uint8_t i = 0; i < len; i++) { if (buf1[i] != buf2[i]) { return i; // 返回第一个不一致的位置 } } return 0xFF; // 全部一致 }在主循环里,我做了多次不同条件的测试。每一次测试都重新填充发送缓冲区,调用 HAL_SPI_TransmitReceive(),然后比较接收缓冲区和发送缓冲区的内容,把比对结果通过串口打印出来。
void spi_loopback_test(void) { uint8_t ret; uint8_t mismatchIndex; fillTxBuffer(txBuffer, TEST_BUFFER_SIZE); memset(rxBuffer, 0, TEST_BUFFER_SIZE); ret = HAL_SPI_TransmitReceive(&hspi1, txBuffer, rxBuffer, TEST_BUFFER_SIZE, 1000); if (ret != HAL_OK) { printf("SPI TransmitReceive Error: %d\n", ret); return; } mismatchIndex = compareBuffer(txBuffer, rxBuffer, TEST_BUFFER_SIZE); if (mismatchIndex == 0xFF) { printf("Loopback Test PASS, %d bytes matched\n", TEST_BUFFER_SIZE); } else { printf("Loopback Test FAIL, first mismatch at index %d\n", mismatchIndex); } }这里有几个细节值得你注意。第一,发送缓冲区的数据不要全填一样的值,比如全填 0x55 或者 0xAA,虽然这种特殊值也有意义,但一旦接线反了或者采样边沿错了,你很难从错误数据中判断出问题类型。用递增序列 i + 0xA0,既有规律性,又能精确到字节位置地定位错误。
第二,调用 HAL_SPI_TransmitReceive() 之前,接收缓冲区一定要先清零。不要想当然地认为库函数会自动清空接收区,实际上它只是把收到的数据往缓冲区里写,之前残留的脏数据如果不处理,一旦通信超时返回错误,你查看接收缓冲区时会读到一堆不明来历的数据,干扰判断。
第三,超时时间设成 1000 毫秒。轮询方式下,16 个字节在 4MHz 时钟下瞬间就传完了,正常情况根本用不了 1 毫秒。如果 SPI 外设配置有问题,函数会一直卡在等待标志位,超时机制能帮你跳出死等状态。
3.4 主频与 SPI 时钟分频的关系推导
这里再展开讲讲时钟树配置对 SPI 实际波特率的影响,因为很多人直接在 CubeMX 里改了分频系数,却不知道 SCK 的实际频率最终由 APB 总线时钟决定。
在 STM32C092RC 上,系统时钟配置为 64MHz。HAL_RCC_GetPCLK1Freq() 返回的是 APB1 总线时钟频率。如果 APB1 分频器设置的是 1 分频,那 PCLK1 就是 64MHz,SPI 内核时钟也是 64MHz,16 分频后 SCK 就是 4MHz。计算公式很简单:
SCK = PCLK1 / Prescaler SCK = 64MHz / 16 = 4MHz如果我把 APB1 分频设为 2,PCLK1 变成 32MHz,那同样的 16 分频,SCK 就只剩 2MHz 了。实测过程中我特意对比了这两种配置,发现 SCK 频率确实跟着变化,回环数据也都能正确收发。这说明一个道理:只看 CubeMX 界面里 Prescaler 的数字是不靠谱的,最终频率必须结合 APB 分频一起算。
这个知识点在后续接真实外设时尤其重要。比如你接一个最高支持 10MHz 的 SPI 屏幕,从 CubeMX 里看到 16 分频以为只有 4MHz,没细算 APB 分频就放心用了,结果实际 SCK 跑到 8MHz 甚至更高,屏幕显示乱码,那时候排查起来就很痛苦。
4. 实测运行:从接线到结果分析
4.1 实测操作记录
硬件连线确认无误后,我给板卡上电,打开串口终端,复位开发板,观察输出。第一次跑回环测试,串口打印结果如下:
SPI Loopback Test Start System Clock: 64MHz APB1 Clock: 64MHz SPI1 SCK Frequency: 4MHz Loopback Test PASS, 16 bytes matched看到 PASS 的结果,心里那块石头算是落了地。不过我没有就此打住,而是接续做了几组变条件测试,把分频系数从 16 调整到 8,再把 CPOL 和 CPHA 组合成 SPI Mode 0、Mode 1、Mode 2、Mode 3 分别跑了一遍。
为了不反复改代码重新编译,我直接把 SPI 参数做成运行时可调整的结构体,调用 HAL_SPI_Init() 重新初始化外设。每次变更参数后,重新执行一次发送接收测试,并打印当前模式信息。这样就能在一个循环里把所有模式全部验证完。
实测下来的结果很有代表性:
| 测试条件 | 发送字节数 | 匹配结果 | 备注 |
|---|---|---|---|
| Mode 0, 16分频 | 16 | 全部匹配 | 通过 |
| Mode 1, 16分频 | 16 | 全部匹配 | 通过 |
| Mode 2, 16分频 | 16 | 全部匹配 | 通过 |
| Mode 3, 16分频 | 16 | 全部匹配 | 通过 |
| Mode 0, 8分频 | 16 | 全部匹配 | 通过 |
| Mode 0, 4分频 | 16 | 全部匹配 | 通过 |
| Mode 0, 2分频 | 16 | 部分位置读回数据异常 | 需排查 |
前三种模式全部通过,说明这个芯片的 SPI 外设本身没什么问题,引脚复用的通路也是通的。真正有趣的是后面的高压测试:当分频系数降到 2,SCK 频率提高到 32MHz 时,回环数据开始出现偶发错误。这个结果完全符合预期,因为杜邦线回路在这个频率下信号完整性已经不够了,但我恰恰建议你亲手试一次这个操作,因为“高频误码”的直观感受,会让你在以后 PCB 布局时对 SPI 走线长度和阻抗匹配更加敏感。
4.2 测试日志的解读方法
如果你做回环测试时打印的日志是 FAIL,不要急着看代码,先看日志输出格式。我把我的打印格式设计成了“第一个不一致位置 + 收发具体值”的格式,这样能直接定位到出错字节。
改进后的比对输出大概是这样的:
Loopback Test FAIL, first mismatch at index 3 Tx[3] = 0xA3 Rx[3] = 0xA5从 Tx 和 Rx 的差异就能猜出问题的大致方向:如果 Rx 的数据比 Tx 的数据“移位”了几个字节,说明接收采样时序有问题;如果 Rx 收到的全是 0x00,说明 MISO 引脚根本没有接收到有效电平;如果 Rx 的数据像是二进制反转,说明 MSB/LSB 配置不一致;如果所有字节都对,只有个别字节错,那大概率是信号完整性问题。
这种分层解读日志的方法,比直接看 PASS/FAIL 两个大字有意义得多。
5. 常见问题与排查经验
5.1 典型问题速查表
回环测试虽然简单,但我在不同板子、不同工程上都见过各种奇怪的失败现象。这里把最常见的问题整理成一张速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 接收缓冲区全为 0x00 | MOSI 和 MISO 没接好,或 GPIO 复用配置错误 | 检查杜邦线连接,检查 CubeMX 引脚映射,用万用表量通断 |
| 接收数据等于发送数据但整体左移或右移一位 | CPHA 配置错误,采样边沿不对 | 切换 SPI Mode 0 到 Mode 3 分别测试 |
| 接收数据出现固定 bit 翻转 | MSB/LSB First 配置错误 | 检查 First Bit 配置 |
| 接收缓冲区出现上一次残留数据 | 接收缓冲区没清零,超时返回 | 在调用传输函数前置零接收区 |
| 传输函数一直返回 HAL_BUSY | SPI 总线忙标志未清除 | 检查 NSS 配置,软件管理模式下是否有其他外设占用引脚 |
| 高频下偶发错误 | 杜邦线过长、接触不良 | 降低分频系数,更换短导线 |
| 程序跑飞或进入 HardFault | SPI 引脚配置冲突 | 检查 GPIO 是否被其他外设复用 |
5.2 我最想强调的两个排查细节
第一个细节,用万用表蜂鸣挡量一下杜邦线两端是否真正导通。听起来很基础,但确实有人把线插到了相邻的排针孔位,看起来接上了,实际信号根本没通。回环测试跑不通,排查半天发现是线插歪了,这种感觉真的不好受。
第二个细节,检查 CubeMX 生成的 GPIO 初始化代码。SPI 的 MOSI 和 SCK 引脚需要配置为复用推挽输出,MISO 引脚配置为复用输入。有些版本的 CubeMX 在引脚冲突时会悄悄把某个引脚改回 GPIO 模式,你如果不看初始化代码,很难发现实际生效的配置和你计划的不一样。
// 检查生成的代码是否类似这样 GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_6; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // MISO 同样配置为复用功能 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);注意 MISO 引脚配置为复用推挽输出是正常现象,因为 MISO 引脚在芯片内部也是由 SPI 外设驱动的,只是方向控制由外设自己管理。
6. 从回环测试到真实外设:后续扩展方向
6.1 用回环测试验证 DMA 传输路径
SPI 回环测试跑通之后,很多人的第一反应是“任务完成”,然后直接把 SPI 接到真实传感器或者屏幕上,开始调业务逻辑。但我的习惯是再做一步进阶测试,那就是把轮询方式改成 DMA 方式,再做一次回环。
DMA 回环测试能验证的,不只是 SPI 外设本身,还包括 DMA 控制器、中断优先级、内存缓冲区的地址对齐等更深层次的问题。回环测试的数据路径从 CPU 寄存器变成了内存缓冲区,如果 DMA 配置有误,比如数据长度参数不对、外设地址写错,测试立刻就会暴露问题。相比直接接真实外设,回环测试的排查面更小,定位速度更快,我强烈建议你在接真实外设之前,先花十分钟把 DMA 回环跑一遍。
6.2 扩展:利用回环测试自动校准 SPI 时序
还有一个更有意思的玩法,就是写一个自动扫描函数,让程序自动遍历所有 CPOL/CPHA 组合、多个分频系数,每轮都做回环测试和比对,最后打印出一份“当前环境下的最优 SPI 参数表”。
这种方式在我调试某些对时序敏感的 SPI 传感器时非常有用。不同传感器对 SPI 模式的支持不同,你以为手册上写的是 Mode 0,实际跑起来可能 Mode 1 更稳定。用回环测试先摸清主控端在不同参数组合下的稳定性,再结合传感器的需求去匹配,调试效率会提高很多。
void spi_parameter_sweep_test(void) { static const uint8_t prescalers[] = { SPI_BAUDRATEPRESCALER_16, SPI_BAUDRATEPRESCALER_8, SPI_BAUDRATEPRESCALER_4, SPI_BAUDRATEPRESCALER_2 }; for (uint8_t mode = 0; mode < 4; mode++) { for (uint8_t i = 0; i < sizeof(prescalers); i++) { // 更新 hspi1.Init 中的 CLKPolarity、CLKPhase、BaudRatePrescaler // 调用 HAL_SPI_Init(); // 执行 spi_loopback_test(); } } }回环测试的最大价值,在于它是一个“可重复、可量化、可自动化”的验证工具。你不需要依赖任何外部设备,就能在最短时间内确认 MCU 的 SPI 外设是否被正确配置,这为后续接真实外设建立了一个可靠的信任基线。我在实际测试中的体会是,与其拿到一块新板子先急匆匆写业务代码,不如花半小时把 SPI、I2C、UART 这些基础外设全部用回环方式测一遍,把硬件和软件配置的底子打牢,后面开发会顺畅得多。最后再分享一个小技巧:做回环测试时,把测试代码封装成一个独立函数,并保留自动扫描模式,以后每换一块新板子、每移植一次工程,都能拿来即用,这比每次从零搭测试工程要高效得多。