1. 三种接口的定位之争:选型错了,后面全是坑
搞硬件这行,SPI、UART、I²C 这三个词几乎是从入行第一天就刻进脑子里的。可真到项目上,很多人还是会卡在同一个问题上:手上这颗传感器、这颗存储芯片、这块屏,到底挂哪条总线?选 UART 吧,速率不够;选 I²C 吧,怕它卡总线;选 SPI 吧,又嫌它占引脚。最后往往是"上次那个项目用啥我就用啥",能跑通就收工,跑不通就开始怀疑人生。
我这几年做的板子从 STM32F103 这种经典款,到带屏、带 Flash、带一堆传感器的复合板都有,SPI、UART、I²C 三种接口的坑基本都踩过一遍。这篇文章想干的事很明确:把三种接口的选型逻辑讲透,把时序层面真正影响调试的那几个点挖出来,再配上一套可以直接照着复现的实操流程——CubeMX 配 SPI + DMA 读芯片数据、UART 调试链路打通并落盘日志、I²C 总线死锁恢复。不管你是刚上手画第一块板的学生,还是天天跟示波器打交道的工程师,看完至少能少熬两个通宵。
先说结论性的判断,方便你带着框架往下读:SPI 是"快、独占、线多",I²C 是"慢、共享、线少",UART 是"点对点、异步、最省事"。这三个定位没有优劣之分,只有场景匹配度。真正让人吃亏的从来不是"不知道用哪个",而是"用错了还硬调",把时间全耗在了本来就不该走的路上。
1.1 从物理层看:三根线、两根线、一根线的代价
SPI 的物理层是四根线打底:SCLK、MOSI、MISO、CS。全双工,主机给时钟,数据同时在两个方向上跑。每多挂一个从设备,就多一根 CS。挂八个从设备,光片选就八根线,这在小封装 MCU 上是真奢侈。但它的代价换来了收益——没有地址概念、没有仲裁、没有应答,时钟给多快数据就跑多快,协议开销几乎为零。
UART 是两根线:TX 和 RX。异步,没有时钟线,双方靠事先约定好的波特率对时。这个"约定"是它最大的优点也是最大的软肋:优点是线少、跨设备简单,一根 USB 转串口就能跟电脑说话;软肋是双方时钟必须足够接近,偏一点就采样错位,整帧数据崩掉。UART 天生只能点对点,想挂多个设备就得靠多路串口或者外扩芯片。
I²C 是两根线:SCL 和 SDA,而且都是开漏输出,靠上拉电阻把电平拉高。所有设备并联在同一对线上,靠 7 位地址区分彼此。这两根线理论上能挂一百多个设备,实际受总线电容限制,一般也就挂几个到十几个。它的"少线"是用"慢"和"复杂"换来的——开漏意味着上升沿是 RC 充电,速率上不去;共享总线意味着要处理地址冲突和仲裁。
注意:画原理图时最常见的低级错误,是把 I²C 的 SDA/SCL 当普通推挽 IO 用。开漏输出必须配上拉电阻,没有上拉,总线永远是低电平,芯片连 ACK 都发不出来。
1.2 速度、距离、拓扑与功耗的四维权衡
选型的时候我一般按四个维度过一遍,比单纯看速率靠谱得多。
速率上,SPI 在 STM32F103 上 SPI1 挂 APB2(72MHz),最高能到 18MHz;SPI2 挂 APB1(36MHz),最高 9MHz。I²C 标准模式 100kHz,快速模式 400kHz,F1 基本就到这。UART 常见 115200,高一点到 921600 甚至 2M 也能跑,但线路一长就掉链子。所以需要搬大量数据的场景,比如读 Flash、刷屏、接高速 ADC,SPI 几乎是唯一答案。
距离上,三个都是板内总线,超过几十厘米都要慎重。UART 相对皮实一点,配个 RS-485 收发器能拉上千米(那是另一套电气标准了)。I²C 对总线电容最敏感,官方建议总电容不超过 400pF,一条长排线走过去很容易就超了。SPI 高速时对走线阻抗和匹配更敏感,波形一塌糊涂的时候先看示波器。
拓扑上,SPI 是星型(每个从机一根 CS),I²C 是总线型(全并联),UART 是点对点。这个差异在 PCB 布局阶段就决定了你能不能省引脚。
功耗上,I²C 因为开漏加外部上拉,静态时总有电流从电阻流向被拉低的线,功耗比想象中大;SPI 的 CS 拉高后从机进休眠,整体更干净。低功耗产品里这点经常被忽略,实测能差出几百微安。
1.3 一张选型对照表,直接抄
| 维度 | SPI | I²C | UART |
|---|---|---|---|
| 信号线 | 4 根起(SCLK/MOSI/MISO/CS) | 2 根(SCL/SDA) | 2 根(TX/RX) |
| 拓扑 | 星型,每从机一根 CS | 总线型,并联共享 | 点对点 |
| 典型速率 | 1M~18M | 100k / 400k | 9.6k~921.6k |
| 双工 | 全双工 | 半双工 | 全双工 |
| 寻址 | 无,靠 CS 片选 | 7 位地址 | 无 |
| 应答机制 | 无 | 有 ACK/NACK | 无(可选校验) |
| 引脚开销 | 高 | 低 | 低 |
| 典型器件 | Flash、LCD、ADC、无线模块 | 传感器、EEPROM、IO 扩展 | GPS、蓝牙、模组、调试口 |
这张表我建议直接贴在工位上。选型时对着过一遍,多数纠结五分钟内就能定。
2. 把时序图看懂,调试就赢了一半
我见过太多人调不通接口就换芯片、换板子、怀疑买到假货,最后发现是 CPOL/CPHA 配错了,或者波特率算错了 0.5%。接口调试的 90% 问题,根子都在时序上。这一节把三种接口时序里最容易出错的地方逐个拆开。
2.1 SPI的四种模式,CPOL和CPHA到底怎么配
SPI 的四种模式来自两个参数:CPOL(时钟极性)和 CPHA(时钟相位)。CPOL 决定 SCK 空闲时是高还是低:CPOL=0 空闲低电平,CPOL=1 空闲高电平。CPHA 决定数据在哪个时钟边沿被采样:CPHA=0 在第一个边沿采样,CPHA=1 在第二个边沿采样。
组合起来就是四种模式:
- Mode 0:CPOL=0,CPHA=0。SCK 空闲低,上升沿采样,下降沿输出。这是最常见的一种,绝大多数 SPI Flash 和传感器默认用它。
- Mode 1:CPOL=0,CPHA=1。SCK 空闲低,下降沿采样,上升沿输出。
- Mode 2:CPOL=1,CPHA=0。SCK 空闲高,下降沿采样。
- Mode 3:CPOL=1,CPHA=1。SCK 空闲高,上升沿采样。用的人也很多,尤其是一些 ADC 和无线芯片。
怎么从手册里读出该用哪个?手册一般有两种写法。一种是直接写"支持 SPI Mode 0/3",那就照抄。另一种是描述边沿行为,比如"数据在 SCK 上升沿被采样,在下降沿被更新"。这时候要翻译:采样在上升沿,且 SCK 空闲状态决定了这是第几个边沿。如果芯片没说空闲电平,通常默认空闲低,那就是 CPOL=0、CPHA=0,即 Mode 0。
提示:CPOL 配错,波形看起来会"整体反相",数据全错;CPHA 配错,波形形状对但采样点偏了一个边沿,表现为数据稳定地错位一位。示波器抓 SCK 和数据线,量一下采样点落在数据眼图正中还是边缘,一眼就能定位是哪个参数的问题。
还有一个容易被忽略的点:First Bit 顺序。多数器件是 MSB First,但确实有 LSB First 的。这个配错的结果是数据整体比特反转,比如 0x01 读成 0x80,非常好认。
2.2 UART波特率误差怎么算,为什么差3%就乱码
UART 没有时钟线,接收端靠检测起始位的下降沿来对齐,然后在位周期中间采样。理想情况下,接收端在第 8、9 位(一帧第 0 位是起始位)附近采样,留出的容错余量大概是半个位周期。
STM32 的波特率计算公式是:
USARTDIV = fCK / (16 × BaudRate) BRR = round(USARTDIV) 实际波特率 = fCK / (16 × USARTDIV整数化后的值)以 STM32F103 为例,USART1 挂 APB2,fCK=72MHz。要 115200:
USARTDIV = 72000000 / (16 × 115200) = 39.0625 BRR 的整数部分 = 39,小数部分 = 0.0625 × 16 = 1 实际波特率 = 72000000 / (16 × (39 + 1/16)) = 115200误差为 0。这就是为什么 72MHz 主频配 115200 特别稳。反过来,如果你用的是 8MHz 晶振,跑 115200:
USARTDIV = 8000000 / (16 × 115200) = 4.3403 取整数部分 4,小数部分 0.3403×16 = 5.44 → 取 5 实际 = 8000000 / (16 × (4 + 5/16)) = 111111 误差 = (111111 - 115200)/115200 ≈ -3.5%这个误差已经超标了,接收端采样点会逐渐偏移到位的边缘,跑几帧就开始出错。解决办法是换合适的晶振频率,或者降波特率到 9600(误差会小很多)。
经验阈值:误差在 ±2% 以内安全,2%~3% 勉强,超过 3% 基本必乱码。而且这个误差是收发双方累积的,如果对端也不准,两边一叠加就更玄。所以调试串口出问题时,第一件事不是换线,是算一遍波特率误差。
2.3 I²C的开漏输出、上拉电阻与时钟拉伸
I²C 的三条铁律:开漏输出、上拉电阻、ACK 应答。
开漏输出意味着任何设备都只能把线拉低,不能主动拉高。高电平完全靠上拉电阻充上去,所以上升沿是个 RC 充电过程,波形是圆弧而不是陡峭的方波。这也是 I²C 速率上不去的根本原因。
时钟拉伸(Clock Stretching)是 I²C 独有的机制:从机如果处理不过来,可以把 SCL 拉低不放,强制主机等着。很多 MCU 的硬件 I²C 外设对这个机制支持不好,表现为通信卡死。STM32F1 的硬件 I²C 在遇到某些从机(尤其是反复拉伸时钟的传感器)时会出现总线挂死,这是老生常谈的问题。
还有一点,I²C 的 ACK 是接收方在第九个时钟周期把 SDA 拉低。很多新手用示波器抓波形,看到第九个脉冲后 SDA 没被拉低,就以为是数据错了,其实是"从机没应答",根因可能是地址写错、器件没上电、或者总线死锁。
3. STM32F103实操:CubeMX配置SPI+DMA读取芯片数据
理论讲完了,接下来是最实用的部分。我用 STM32F103 + CubeMX 演示一遍 SPI 配 DMA 读芯片数据的完整流程,这套方法适用于读 Flash、读传感器、读 ADC,换一下指令字节就能复用。
3.1 CubeMX里这8个参数决定成败
打开 CubeMX,选好 STM32F103C8 或者你手上的型号,配好时钟树(外部晶振 8MHz,PLL 倍频到 72MHz)。然后进 SPI1 配置:
| 参数 | 设置值 | 说明 |
|---|---|---|
| Mode | Full-Duplex Master | 全双工主机模式 |
| Hardware NSS Signal | Disable | 用软件控制 CS,后面细说 |
| Data Size | 8 Bits | 绝大多数器件是 8 位 |
| First Bit | MSB First | 除非手册特别说明 |
| Prescaler | 8 | 72MHz/8 = 9MHz,先用低速调通再提速 |
| Clock Polarity (CPOL) | Low | 对应 Mode 0 |
| Clock Phase (CPHA) | 1 Edge | 对应 Mode 0 |
| CRC Calculation | Disabled | 一般用不上,开了反而增加开销 |
Prescaler 这一项,调试阶段我强烈建议先降速。9MHz 甚至 4.5MHz 先跑通,确认数据对了,再一步步往上提到 18MHz。如果一上来就 18MHz 跑不通,你根本分不清是协议问题还是信号完整性问题。
接着配 DMA。在 DMA Settings 里点 Add,选 SPI1_RX,Mode 选 Normal(单次)或 Circular(循环,适合持续采集),Data Width 都选 Byte,Priority 选 High。然后在 NVIC Settings 里把对应的 DMA 通道中断打开。
最后配一个 GPIO 作为 CS,比如 PA4,设为 Output Push-Pull,初始电平 High(片选空闲时是高电平)。
3.2 DMA接收代码落地与数据错位的根因
CubeMX 生成的初始化代码不用动,我们只需要加几行自己的逻辑。先定义缓冲区和标志位:
#define SPI_RX_SIZE 64 uint8_t spi_rx_buf[SPI_RX_SIZE]; volatile uint8_t spi_rx_done = 0; #define CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)启动 DMA 接收:
void SPI_Start_DMA_Read(void) { spi_rx_done = 0; CS_LOW(); HAL_SPI_Receive_DMA(&hspi1, spi_rx_buf, SPI_RX_SIZE); }接收完成回调:
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { CS_HIGH(); /* 必须在数据收完之后再抬 CS */ spi_rx_done = 1; } }如果你要读某个寄存器,标准做法是发两个字节(命令 + 空字节),用全双工收发:
uint8_t Read_Reg(uint8_t reg) { uint8_t tx[2] = { reg | 0x80, 0x00 }; /* 读命令,最高位置1 */ uint8_t rx[2] = { 0 }; CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, 100); CS_HIGH(); return rx[1]; }这里面第一个大坑是CS 拉高的时机。用HAL_SPI_TransmitReceive时,函数返回说明两个字节都收发完了,此时抬 CS 没问题。但用 DMA 时,函数是立即返回的,如果你在启动 DMA 后马上抬 CS,从机会认为传输结束,后面收的数据全是垃圾。必须在RxCpltCallback里抬 CS,这是数据错位最常见的根因。
第二个坑是DMA 传输长度和实际数据长度不匹配。有些器件读命令发完之后,还得等一小段时间才有数据出来,这时候需要中间插入延时,或者用两个独立的HAL_SPI_Transmit和HAL_SPI_Receive分开处理。
第三个坑是SPI 的 RX 和 TX 是同一个时钟驱动。即使你只想读数据,也得给时钟,而给时钟就会同时发送数据。所以"只读"的场景必须给 TX 送空字节(0x00 或 0xFF,看器件要求),不能啥都不发。
3.3 硬件片选和软件片选怎么选
CubeMX 里 Hardware NSS Signal 有两个选项:Disable(软件控制)和 Hardware NSS Output(硬件自动控制)。
软件片选是我 90% 场景下的选择。原因很简单:可控。你可以精确控制 CS 拉低到第一个时钟之间的时间、最后一个时钟到 CS 拉高之间的时间,而这两个时间在时序要求严格的器件(比如某些 Flash 要求 tSLCH、tCHSH 有最小值)里非常关键。另外多从机场景下,软件片选能让你灵活切换。
硬件片选适合严格的单主机单从机、速度极高、CPU 不想介入的场景。它的优点是 NSS 由硬件自动拉低拉高,时序精确到时钟周期,缺点是灵活性差,而且 STM32F1 的硬件 NSS 在某些配置下会有奇怪的行为(比如需要配置 SSM/SSI 位,否则会误进从机模式)。
经验:如果发现 SPI 明明配了主机模式,时钟却不输出,先看 NSS 的配置。硬件 NSS 模式下,如果 NSS 引脚被拉低,SPI 会认为有别的设备在选中自己,于是退出主机模式,SCLK 停止输出。这是个非常隐蔽的坑,多数人第一次遇到会以为是 SPI 外设坏了。
4. UART链路打通:从驱动安装到调试日志落盘
UART 是调试阶段用得最多的接口。板子一出问题,第一反应就是接个串口看它有没有在跑。但这条链路看着简单,坑一点不少。
4.1 USB转串口芯片的驱动坑
板子上的 UART 是 TTL 电平(3.3V),电脑上是 USB,中间要有个桥接芯片。市面上常见的几款:
| 芯片 | 特点 | 常见问题 |
|---|---|---|
| CH340 | 便宜,国产,兼容性好 | 老系统上驱动签名问题,部分版本连不上 |
| CP2102 | 稳定,原厂驱动完善 | 假货多,便宜的模块可能是翻新片 |
| CP2104 | 支持更低功耗,带 GPIO | 驱动和 CP2102 不通用,别装错 |
| FT232R | 老牌,稳定性好 | 驱动要认准原厂,第三方驱动常出问题 |
装驱动的顺序很关键:先装驱动,再插模块。反过来的话,系统会先按默认的未知设备处理,之后即使装了驱动也可能要手动在设备管理器里更新。装完之后在设备管理器里看端口号,正常应该显示 "USB-SERIAL CH340 (COMx)" 或者 "Silicon Labs CP210x USB to UART Bridge (COMx)"。
如果设备管理器里出现黄色感叹号,八成是驱动没匹配上。这时候别急着换模块,先卸载设备(勾选"删除驱动程序软件"),拔掉模块,重装驱动,再插上。Windows 的驱动缓存很顽固,不彻底删干净会一直用错的那个。
4.2 串口助手参数与乱码排查顺序
串口调试助手的参数必须和固件完全一致:波特率、数据位(通常 8)、停止位(通常 1)、校验位(通常 None)、流控(通常 None)。这五项里有任何一项不一致,收到的都是乱码。
乱码排查我有个固定顺序,从高概率到低概率:
- 核对波特率。90% 的乱码是波特率不对。先在助手和固件两边都确认一遍数值。
- 算一遍波特率误差。按 2.2 节的公式算,超过 3% 就是配置问题,换晶振或降速。
- 看 TX/RX 是不是接反了。板子 TX 接模块 RX,板子 RX 接模块 TX,交叉接。接反的表现通常是完全收不到数据,而不是乱码。
- 确认共地。USB 转串口模块和板子必须共地,不共地的时候电平参考不一样,收到的数据是随机的。这是新手最容易忽略的一条。
- 看电平是否匹配。3.3V 的板子接 5V 的模块,或者反过来,可能收不到或者烧芯片。用万用表量一下空闲时 TX 的电平,正常应该和供电电压一致。
如果收到的数据是"能看懂一部分,中间夹杂乱码",那多半是波特率接近但不完全一致,或者有电源干扰。这种情况把波特率降一档通常能解决。
4.3 调试信息同时打印和存盘的做法
调试的时候经常遇到这种情况:屏幕上刷得飞快,问题一闪而过,想回看却没了。所以"同时打印到终端和写入日志文件"是个刚需。
最省事的办法是给调试串口加一层封装。在固件里定义:
#include <stdarg.h> #include <stdio.h> void log_printf(const char *fmt, ...) { char buf[256]; va_list ap; va_start(ap, fmt); int n = vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); if (n > 0) { HAL_UART_Transmit(&huart1, (uint8_t *)buf, n, 100); } }所有调试输出都走log_printf,这样做的好处是后面想加时间戳、加等级(INFO/WARN/ERR)、加模块名,都只改一个函数。
电脑这一侧,用串口助手自带的"保存日志"功能也行,但更灵活的是写个几行的 Python 脚本,同时打印和写文件:
import serial import datetime port = 'COM7' baud = 115200 logname = 'uart_%s.log' % datetime.datetime.now().strftime('%Y%m%d_%H%M%S') ser = serial.Serial(port, baud, timeout=1) f = open(logname, 'a', encoding='utf-8', errors='replace') print('logging to', logname) while True: data = ser.read(128) if not data: continue text = data.decode('utf-8', errors='replace') print(text, end='') f.write(text) f.flush()f.flush()这一句很关键,不加的话 Python 会攒一批才写盘,程序崩了日志就丢了。加上之后每收到一段就立刻落盘,断电都不怕。
提示:在 Keil 里调试的时候,如果你想在 Watch 窗口看到结构体变量的实时值,记得把编译优化等级设成 -O0 或者 -Og。开 -O2 之后局部变量可能被优化进寄存器,Watch 窗口显示 "cannot read memory",会让人误以为程序跑飞了。另外涉及中断里改、主循环里读的变量,一定要加
volatile,否则优化后主循环可能永远读到缓存里的旧值。
5. I²C总线疑难:上拉计算、地址冲突与总线死锁
I²C 是所有接口里"看起来最简单、出问题最缠人"的一个。两根线一接,代码一调,运气好跑通,运气不好就是无尽的卡死。
5.1 上拉电阻不是随手焊一个4.7k
很多人画 I²C 电路,上拉电阻直接从参考设计里抄个 4.7k。这个值在多数场景下没问题,但它是算出来的,不是拍脑袋定的。
上拉电阻有两个约束,一个是下限(不能太小),一个是上限(不能太大)。
下限由灌电流决定。开漏输出的器件把线拉低时,电流从上拉电阻灌进芯片。这个电流不能超过器件的 IOL 能力(一般 3mA)。公式:
Rmin = (VDD - VOL_max) / IOL_max3.3V 供电,VOL 取 0.4V,IOL 取 3mA:
Rmin = (3.3 - 0.4) / 0.003 ≈ 967Ω所以上拉电阻不能小于约 1kΩ。
上限由上升时间决定。I²C 标准规定标准模式(100kHz)上升时间不超过 1000ns,快速模式(400kHz)不超过 300ns。上升时间公式:
tr ≈ 0.847 × R × C其中 C 是总线总电容,包括引脚电容、走线电容、器件电容,官方建议不超过 400pF。快速模式下:
R ≤ 300ns / (0.847 × 400pF) ≈ 885Ω也就是说,如果总线电容真的到了 400pF 上限,快速模式的上拉电阻得小于 900Ω,4.7k 根本不够。实际项目中总线电容通常只有几十 pF,比如 100pF:
R ≤ 300ns / (0.847 × 100pF) ≈ 3542Ω这时候 4.7k 就超了,得用 2.2k 或 3.3k。所以结论是:跑 400kHz 时,4.7k 只有在总线电容很小(小于约 75pF)的情况下才安全。板子上一堆器件挂上去,很容易就超了。
我自己的做法是:100kHz 用 4.7k 没问题;400kHz 起步用 2.2k,走线长或者挂的设备多就用 1.5k,同时用示波器量上升沿,确保在 300ns 以内。
5.2 总线被拉死之后的恢复手法
I²C 最经典的故障是总线死锁:某个从机在传输过程中被打断(比如主机复位了),它还在等下一个时钟,于是死死把 SDA 拉低不放。这时候主机重新初始化 I²C,发现 SDA 一直是低,连起始条件都发不出去,整个总线就废了。
标准恢复手法是手动发 9 个时钟脉冲,让从机把剩下的位发完,然后补一个 STOP 条件。代码可以这么写:
void I2C_Bus_Recover(void) { GPIO_InitTypeDef g = {0}; /* 先把 I2C 外设停掉,引脚切成开漏输出 */ HAL_I2C_DeInit(&hi2c1); g.Pin = GPIO_PIN_6 | GPIO_PIN_7; /* SCL=PB6, SDA=PB7 */ g.Mode = GPIO_MODE_OUTPUT_OD; g.Pull = GPIO_PULLUP; g.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &g); /* 先确保 SDA 释放 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); /* 打 9 个时钟 */ for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } /* 手动产生 STOP: SCL 高时 SDA 由低变高 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); /* 重新初始化 I2C 外设 */ HAL_I2C_Init(&hi2c1); }这段代码我基本每个用硬件 I²C 的项目都会加上,放在HAL_I2C_ErrorCallback里或者上电初始化时先跑一遍,能省掉大量"莫名其妙就不通信了"的现场问题。
另外,找设备地址的时候别一个个猜。写个扫描循环,从 0x01 扫到 0x7F,哪个地址有 ACK 就是有设备:
void I2C_Scan(void) { for (uint8_t addr = 1; addr < 0x80; addr++) { if (HAL_I2C_IsDeviceReady(&hi2c1, addr << 1, 2, 10) == HAL_OK) { log_printf("I2C found: 0x%02X\r\n", addr); } } }扫描一遍,两秒钟就知道总线上挂了谁、地址是多少。地址冲突(两个器件地址一样)也会在这里暴露出来——表现为扫描到的设备数量比实际挂的少,这时候就得靠器件上的地址选择引脚(A0/A1/A2)来改地址,或者用 I²C 多路复用器分总线。
6. 问题速查表与几条用血换来的经验
6.1 高频问题速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| SPI 完全没有波形 | 主机模式没进、NSS 配置错 | 查 NSS 配置,切软件片选 |
| SPI 数据整体错位一位 | CPHA 配反 | 换 CPHA 重试 |
| SPI 数据整体反相 | CPOL 配反 | 换 CPOL 重试 |
| SPI 数据比特反转 | First Bit 顺序错 | 切 LSB First |
| SPI DMA 收数据全乱 | CS 抬早于传输完成 | 回调里再抬 CS |
| UART 全是乱码 | 波特率不匹配或误差超 3% | 核对参数、算误差 |
| UART 完全收不到 | TX/RX 接反或没共地 | 交叉接线、检查地线 |
| UART 偶发错帧 | 电源干扰或线太长 | 降速、缩短线、加滤波 |
| I²C 扫描不到设备 | 没上拉、供电没上 | 量 SDA/SCL 空闲电平 |
| I²C 通信一会儿就卡死 | 总线死锁、时钟拉伸 | 跑 9 时钟恢复程序 |
| I²C 高速下偶发 NACK | 上拉太大、上升沿太慢 | 减小上拉、示波器量上升时间 |
6.2 几个反常识的经验
第一条,SPI 不是越快越好。我刚开始做项目的时候,觉得 18MHz 能配就配 18MHz,结果接一个便宜的传感器,数据时对时错。后来降到 4.5MHz 一切正常,查手册发现那个器件的最高时钟只有 10MHz,而且对上升沿有要求。速度提上去之前,先把手册的时序参数表翻出来对照一遍,别想当然。信号完整性也是同理,长走线上 18MHz 的方波可能已经变成一条斜线了,用示波器看一眼就知道。
第二条,I²C 的硬件外设不一定比软件模拟靠谱。STM32F1 的硬件 I²C 在某些从机上有已知的卡死问题(错误标志位卡住、总线状态机跑飞),很多人最后改用 GPIO 模拟时序,反而稳定。这不是说硬件外设不好,而是说当你在硬件 I²C 上折腾超过半天还没进展时,及时切换到软件模拟是个理性的选择,别死磕。
第三条,调试信息永远要留后路。板子上只留一个 UART 口,固件里只留一个 printf,一旦 UART 挂了就完全瞎眼。我的习惯是至少留两条调试通道,一条串口打日志,一条用 GPIO 打时间戳(关键节点翻转电平,示波器一抓就知道时序),两条互补,成本几乎为零。
第四条,"换个芯片试试"应该是最后的手段。接口调不通,95% 是配置问题、时序问题、硬件连接问题,只有 5% 是真的器件坏了。我见过太多人换了三片芯片,最后发现是 CPOL 配错。调试的正确顺序永远是:先算参数、再看波形、再查连接、最后才怀疑器件。
最后分享一个我一直在用的小习惯:每调通一个接口,就把这次的配置参数(CPOL/CPHA、时钟分频、上拉阻值、波特率、地址)记在一个自己的清单里,注明器件型号和日期。下次遇到同类器件,直接查清单,五分钟搞定,比重新翻手册快得多。这份清单攒两三年之后,价值比任何一本书都高,因为它全是你在真实板子上验证过的参数。