SPI与I2C深度解析:片间通信协议的原理、应用与实战避坑

发布时间:2026/10/26 17:43:36
SPI与I2C深度解析:片间通信协议的原理、应用与实战避坑

1. 为什么需要片间通信:从片内总线到片间总线的演进

1.1 片内通信的局限

在讲 SPI 和 I2C 之前,我们先花一分钟理清一个关键概念:什么叫“从片内到片间”。前面几章我们一直在芯片内部打转,寄存器映射、总线矩阵、DMA 搬运这些,本质上都是在 SoC 内部用某种总线(比如 AHB、APB)把 CPU、存储器和外设连起来。片内总线有个特点——它是在同一块硅片上用金属互连线搭建的,地址空间统一、时序由内部逻辑保证,最长也就几毫米到几厘米,根本不需要考虑信号反射、电平转换、噪声容限这类现实问题。

但单片机的设计从来不是只靠一颗芯片就完事的。你总会遇到这样的场景:MCU 的 ADC 分辨率不够,需要外挂一颗 16 位的 AD7606;片内 Flash 容量太小,得加一块 SPI NOR Flash 来存固件和字库;传感器在电路板的另一端,不想拉一堆模拟线,那就让它在本地做好模数转换然后通过数字总线把结果送回来。从这一刻起,通信就不再是“芯片内部的事”,而是变成了“两块硅片之间的事”,这就是片间通信的由来。

片间通信和片内通信的本质差异在于,信息必须变成几个引脚上能观测到的电信号,通过 PCB 走线从一颗芯片送到另一颗芯片。你没法再用 APB 那种“总线读写”的抽象来做这件事,必须让一方做主机(Master),一方做从机(Slave),在物理层上定义好时钟、数据、片选这些线的角色。现实世界里最常见的两种方案就是 SPI 和 I2C,这两个协议在硬件上极其廉价——SPI 至少要四根线,I2C 更省,只要两根线,但从工程师的角度来看,省线的代价是协议复杂度上去了,或者说把复杂性从硬件转移到了软件。这也是这篇文章想讲清楚的第一个点:选 SPI 还是 I2C,表面上是选总线拓扑和速度,实际上是在性能、引脚资源、代码复杂度和调试便利性之间做权衡。

1.2 两种主流选择的底层逻辑

SPI 和 I2C 几乎同时诞生于 1980 年代左右,前者由飞思卡尔(现在的恩智浦)推广,后者由飞利浦发明,但这么多年下来它们都没有被淘汰,反而形成了泾渭分明的应用格局。核心原因很简单:SPI 是全双工、高速、无寻址的总线,适合吞吐量大的场景,比如 Flash 烧录、ADC 采样、显示屏刷屏;I2C 是半双工、低速、带寻址的总线,适合连接大量小终端设备,比如 EEPROM、RTC、温度传感器、IO 扩展芯片。

从电气角度来说,这两者也代表了两种完全不同的设计思路。SPI 使用推挽输出,主机在任何时刻都主动驱动 MOSI、SCK 等引脚的电平,速度快、上升沿陡峭,但点到点拓扑,片选线也得跟着设备数量增加。I2C 则使用了开漏加外部上拉电阻的方案,任何设备都可以把 SDA 线拉低,谁也没法主动拉高,这就实现了多设备共享两条线的总线结构,代价是速度受限于上拉电阻的充放电时间,通常只能跑到 400kHz 或 1MHz,哪怕现在有了 3.4MHz 的高速模式,跟 SPI 动辄几十 MHz 的速率还是有数量级的差距。

我在实际项目中见过太多新手在这两个协议上栽跟头,归根结底都是没有理解“推挽还是开漏”、“片选还是寻址”这两个底层分岔点。这篇文章接下来会用偏实战的角度拆解 SPI 和 I2C 的每一个关键细节,包括那些网上很难找到完整解释的坑:硬件片选和软件片选的取舍、I2C 总线卡死的恢复方案、Verilog 怎么写 I2C 时序、SPI DMA 如何减少 CPU 占用率,诸如此类。

2. 深入 SPI:四线出奇迹还是四线惹麻烦

2.1 SPI 的物理层与全双工机制

SPI 最基本的形态是四线:SCK(时钟)、MOSI(主机输出从机输入)、MISO(主机输入从机输出)、CS/SS(片选)。很多初学者会把注意力集中在 SCK、MOSI、MISO 这三根线上,觉得片选线不就是一根“拉低就选中”的控制线嘛。其实片选恰恰是 SPI 系统里最容易被低估的一根线——它决定了总线上到底哪个从机在回应你,如果你的 CS 时序不对,后面所有数据都是白搭。

全双工机制是 SPI 最大的卖点。因为 MOSI 和 MISO 是独立的物理信号线,主机可以在发送一个字节的同时收到从机返回的一个字节。硬件上这非常简单,移位寄存器在时钟上升沿把 MOSI 推出去、在下降沿把 MISO 采进来(或者反过来),一个时钟周期内收发同时完成。所以从原理上说,SPI 每一次传输的“净效率”非常高,不像 UART 和 I2C 那样需要额外的起始位、停止位或者应答位。

不过全双工也带来一个常见误解:很多人以为 SPI 通信必须像对讲机一样“一问一答”,实际上不是。主机只需要在 SCLK 上提供时钟,MOSI 和 MISO 自然同步地进行字节交换。比如你向 SPI Flash 发送读命令“0x03 0x00 0x00 0x00”,从同一个时钟沿上 MISO 线上会同时移出 Flash 原始状态寄存器里的垃圾数据,你只要忽略它就行。理解了这一点,你就明白为什么 SPI 设备可以做到“边写边读”——这在 DMA 持续采样外设时会非常有用。

2.2 CPOL 和 CPHA:为什么总有四张排列组合

SPI 协议没有规定时钟空闲时是高还是低,也没有规定数据在哪个边沿有效,这就引出了 CPOL(时钟极性)和 CPHA(时钟相位)这两个参数。它们合起来有四种组合,对应模式 0 到模式 3。我在调试 SPI 的时候几乎每次都先确认目标设备的时序图,而不是凭经验猜一个模式,因为一旦 CPOL/CPHA 不匹配,读回来的数据要么是位移了一位,要么是边沿采到了毛刺,表现非常隐蔽。

举一个我常跟同事解释的例子:假设一个 SPI 设备手册要求 Mode 0,即 CPOL=0、CPHA=0,意思是时钟空闲为低,数据在第一个边沿(也就是上升沿)被采样。如果你的 MCU 配置成了 Mode 1(CPOL=0、CPHA=1),那么从机依然会在上升沿放置数据,但主机会在第二个边沿(下降沿)才去采样,结果读到的每一位都往左偏移了半个时钟周期或者整整错了一拍。更麻烦的是,有些从机对时序很敏感,可以直接容错,有些则彻底罢工。

所以我的建议是:当你看不懂设备手册的时序图时,先用逻辑分析仪捕捉从机在已知数据下的输出,对比四种模式下读到的结果,最快的那个基本上就是正确的模式。但在做产品固件时,千万不要把 CPOL/CPHA 做成用户可配置项,否则售后会让你怀疑人生。把模式固定下来写死在驱动里,留一个编译期宏供维护人员改,就够了。

2.3 硬件片选与软件片选的取舍

片选信号可以用 MCU 硬件外设自动控制,也可以用普通 GPIO 由软件代码手动拉高拉低,这两者在工程上各有适用场景。硬件片选的典型代表是 STM32 系列 SPI 外设的 NSS 引脚,配置成硬件控制后,主机在开始传输时自动拉低 CS,传输结束后自动拉高,配合 DMA 可以做到完全无需 CPU 干预的连续搬运。

听起来硬件片选很省事,但在实际项目中我踩过一个大坑:STM32 的硬件 NSS 在某些型号上“自动拉高”的时机并不是紧跟最后一个 SCK 边沿,而是有一两个 APB 时钟周期的延迟。如果从机要求 CS 必须在时钟停止后的严格时间内拉高(比如某些 SPI Flash 的读操作要求 CS 高电平时间不小于 100ns),硬件 NSS 的延迟可能导致帧尾出错。另外,硬件 NSS 在总线有多个从机时管理起来很别扭,因为硬件只认一组 NSS,要么用附加 GPIO 模拟,要么不断重配。更别说 GD32F303 这类兼容品的硬件 NSS 行为可能和 STM32 还有细微差异。

软件片选则是放弃硬件自动管理,把 CS 接到任意一个 GPIO,代码在 SPI 传输开始前手动拉低,传输结束后手动拉高。这样做的好处是灵活,多从机菊花链无压力,还能精确控制 CS 拉起时机;坏处是每次传输都要多两条 GPIO 操作指令,并且如果你忘了在 DMA 传输完成后拉高 CS,总线就会一直处于被选中状态,从机也不报错,数据就会莫名其妙地失真。我个人的准则很简单:单个从机、连续大块数据、追求吞吐——用硬件 NSS;多个从机、时序要求刁钻、需要灵活控制的场景——用软件片选。这两种方案没有绝对的对错,关键是你得清楚自己选择的代价是什么。

2.4 SPI 的典型应用案例:从 AD7606 到 TF 卡

2.4.1 高速 ADC:AD7606 的 SPI 采样

AD7606 是安富莱等众多开发板上常见的一款 16 位同步采样 ADC,它支持 8 通道同时采样,最大吞吐率能达到 200kSPS。刚接触这颗芯片时,我最惊讶的是它的接口可以配置成并行或串行模式,推荐串行模式时用 SPI 其实是个很自然的选型——因为 SPI 的全双工特性正好匹配“主机发送通道配置信息、从机返回采样结果”的需求。

串行模式下,AD7606 的 BUSY 引脚会告诉主机“采样转换完成”,FRSTDATA 引脚则标记第一个通道的数据。实际工程中,我会把 BUSY 接到 MCU 的外部中断或者用一个 DMA 请求触发,然后在 SPI 上连续读 8 次(每次 16bit),再在 CS 控制下拼出 8 个通道的数据。这里的重点在于:SPI 的时钟频率不能想多高就多高,要参考 AD7606 数据手册里的最大 SCLK 参数,太高了采样数据位会不稳定。另外,读数据时最好用 SPI DMA 模式,让 8 通道的读取一次性搬进 RAM,避免 CPU 频繁进中断,尤其当采样率拉到满额时,每一个微秒都宝贵。这也是为什么“SPI DMA”在热搜词里常年稳定出现的根本原因——真实项目中 SPI 速度上去之后,轮询读写CPU占用率根本吃不住。

2.4.2 SPI NOR Flash 与 TF 卡电路设计

“不拆 SPI Flash 芯片可以刷 BIOS 吗”这句话在热搜里出现,本质上是因为很多路由器、主板上的 SPI NOR Flash 已经变成了“软件可升级”的存储介质。如果你有 SPI 编程器甚至直接用开发板的 SPI 控制器去挂一块 SPI Flash,就不需要把它从 PCB 上拆下来,直接用 SOP8 测试夹或者花焊盘接触就可以刷写固件。这也是 SPI 在嵌入式领域最经典的应用场景——读芯片 ID、查状态寄存器、擦除扇区、按页编程,每一步都是标准的 SPI 命令帧。

TF 卡的 SPI 电路则更考验硬件设计。TF 卡原生协议是 SD/MMC 那种 4-bit 并行接口,但为了兼容低引脚数的 MCU,几乎所有 TF 卡都支持切换到 SPI 模式。SPI 模式下需要把卡的 CS 拉低后发送 CMD0 并附加 0x95 校验字节,接着发送 CMD8 确认电压范围,再反复发送 CMD55/ACMD41 循环初始化,直到卡返回空闲状态。电路设计上,TF 卡的数据线不是纯开漏,却要求串电阻和上拉,而且布线不能过长,否则时钟频率高了就会出现 SD 卡初始化偶尔成功偶尔失败的情况。我在 layout 时通常会在 TF 卡座靠近 MCU 一侧放置 33 欧姆串阻,并且给 VCC 加 10uF 和 100nF 双电容,实测能大幅减少高低温下的读写不稳定问题。

2.5 SPI DMA 与 SPI 引擎:如何榨干带宽

SPI DMA 的核心价值在于“搬运数据不占 CPU”。举个例子,假设 SPI 时钟跑 36MHz,一个字节 8 位,每传一个字节需要约 222ns,如果你在中断里一个一个字节处理,CPU 几乎全被占用。但把发送和接收都配置成 DMA 模式后,外设数据寄存器空了就自行去内存取下一个数据,满了就自行写入内存,CPU 只负责在整体传输完成时进一次完成中断。

配置 SPI DMA 时有一个容易踩的坑:有些 MCU 的 SPI 发送 DMA 需要先使能 DMA,再使能 SPI,否则第一个字节会在 SPI 还没使能时就被 DMA 搬走,导致帧头丢失。另外,接收缓存区要预先清零,但注意清零点要放在使能 DMA 之前,否则清零操作本身也会被 DMA 当成一次接收。SPI 接收时若使用 DMA 循环模式,必须小心处理“半满中断”与“全满中断”的边界,数据量不是 2 的幂次时,多半还需要额外补读一个字节来凑整数。

所谓“SPI 引擎”,在嵌入式圈子里通常指的是将 SPI 外设配置成可编程状态机式收发,或者利用硬件 FIFO 队列来背靠背发送多个事务。比如,使用 DMA 时,可以准备一个“命令描述符链表”,将读 Flash 的 0x03 命令、地址和 dummy 字节拼在一起,安排 DMA 依次从内存搬运到 SPI 发送寄存器,最后一个描述符再启动接收 DMA。这样整个读快过程 CPU 零参与,逻辑分析仪上能看到的是连续的时钟波形,从机返回数据一气呵成。这种技巧在做高速采集或者图形刷屏时价值极大。

3. 深入 I2C:两线走天下但没那么简单

3.1 推挽模式和开漏模式:为什么 I2C 必须开漏

SPI 使用了推挽输出,而 I2C 恰恰相反,强制要求所有设备输出级采用开漏结构。开漏的意思是,器件内部的 MOS 管只能把引脚拉低到地,不能主动拉高;引脚的高电平完全依靠外部上拉电阻接 VCC 来提供。这样做的好处首先是“线与”逻辑:任何设备都可以拉低总线,而不会出现两个设备一个拉高另一个拉低导致的电源短路。

这一点和 SPI 的推挽输出形成强烈对比。推挽输出在任何时刻都有一个 MOSFET 主动驱动引脚到 VCC 或 GND,输出阻抗很低,上升下降沿都很快,适合高速点对点传输。但在多主机共享总线的场景里,推挽是灾难——两个主机同时驱动一根线、一个输出高一个输出低,会产生很大的短路电流,轻则数据错误,重则烧毁引脚。I2C 用开漏加外接上拉的方式,让所有设备只能“释放”或“拉低”,谁也没法强制拉高,这样就天然规避了总线冲突的电平竞争问题。

所以当你看到 I2C 总线上的 SDA/SCL 波形有圆角而不是陡峭的方波,不要怀疑是信号质量问题,这恰恰是上拉电阻和总线寄生电容共同决定的特点。上拉电阻的取值有两个极端:太小会灌入过大电流,器件可能拉不低;太大会使上升沿变慢,限制通信速率。常规 400kHz 模式下,我用 2.2kΩ 到 4.7kΩ 居多,具体数值可以按公式 Rmin=(VCC-0.4)/3mA 和 Rmax=Trise/(0.8473*Cbus) 估算,工程上不需要算得很精,但方向得对。ADC、EEPROM 这类低速设备,上拉电阻大一点反而稳;高速设备如某些新式传感器,就得尽量小,才能跑满 1MHz。

3.2 地址机制与仲裁:两根线如何挂几十个设备

I2C 的寻址机制是它和 SPI 最本质的区别:SPI 用物理片选线选设备,I2C 则是在数据帧里带上 7 位(或 10 位)从机地址,所有设备都挂在同两条线上,通过地址匹配来决定谁响应。每个 I2C 从机都有一个出厂固化的地址,其中一部分位可以通过引脚电平配置。比如常见的 24C02 EEPROM,地址的低三位由 A0/A1/A2 引脚决定,所以一条 I2C 总线上最多可挂 8 颗同型号的 24C02。

地址机制带来了一个在实际使用中很令人头疼的问题:如果总线上有两颗相同地址的设备,它们会同时响应主机的访问,造成数据相互干扰,而且你很难通过软件发现这一点。排查手段只有逐个检查地址引脚电平,或者用 I2C 扫描工具发一轮地址查询,看哪些地址有 ACK。我做产品时会在初始化阶段自动扫描总线上的设备,把响应地址打印到日志里,一旦发现意料之外的地址冲突立刻报错,而不是等到跑业务时数据乱套。

多主机和仲裁机制则是 I2C 更高级的功能。两个主机可以在同一时刻发起传输,I2C 协议允许它们在 SDA 线上做逐位比较,谁发送的地址位更“0”谁胜出,输的那一方自动放弃总线,等下一轮重试。这个机制看着很优雅,但在嵌入式系统里真正用上多主机 I2C 的机会并不多。我遇到过最多的是 MCU 和 DSP 双核共享一片 EEPROM 的场景,这时候仲裁和时钟同步才显得重要。如果你只是一个 MCU 挂若干传感器,完全可以忽略多主机这条分支。

3.3 典型应用:EEPROM 读写、OLED 显示、I2C 编码器

3.3.1 EEPROM 读写与页写入边界

I2C 最经典的应用就是 24C 系列 EEPROM。这玩意儿有一个非常容易踩的坑叫做“页写入边界”。24C02 一页是 8 个字节(24C64 一页是 32 字节),你在连续写入跨页时,如果超出了当前页的末尾,地址会回卷到页首,后面的数据覆盖掉前面刚写的内容。我第一次用的时候没注意这一点,连续写 20 个字节,结果只保留了最后一页的数据,前面全被覆写,排查了很久才发现是页边界问题。

正确的写法是再封装一层“跨页写”逻辑:先把当前页剩余空间填满,然后每次按页大小对齐写入。读操作虽然可以任意连续读,但如果从某个非页对齐地址批量读,也会在页面边界处发生回卷,所以要么逐字节读,要么对读地址做同样的分页处理。此外,24C 系列在写命令后需要等待内部编程完成(一般是 5ms 左右),期间不会响应任何 I2C 请求。聪明的驱动会在写完一页后发送一个“当前地址读”命令来轮询 ACK,直到器件给出应答再继续下一页,这比盲等延时更可靠。

3.3.2 OLED 屏与 I2C 编码器的实战心得

CH32V307 这类 RISC-V 单片机驱动 I2C OLED 的例程在网上非常多,核心思路都差不多:写命令字节 0x00、写数据字节 0x40,屏幕的 I2C 地址固定在 0x3C(或 0x3D,取决于 DC 引脚)。OLED 的刷新率和 I2C 速率直接相关,400kHz 下大约每秒能刷十几帧低分辨率内容,如果要做动画,就得考虑提升频率或者改用 SPI 接口版本屏幕。我用 I2C OLED 很少追求高帧率,更看重的是接线少,两根线就能搞定显示模块,特别适合做调试信息面板。

I2C 编码器(比如 AS5600 或者 EC11 配套的旋转编码器模块)则是另一个有趣的场景。这类编码器原本是模拟增量输出,但带 I2C 接口的型号可以直接读出绝对角度。这种设备内部的寄存器映射往往很简单,读一个 16 位寄存器就能得到角度值,但麻烦在实时性——I2C 协议本身是半双工的,每一次查询都要写寄存器地址、重新发起起始条件、再读数据,延迟比 SPI 高。如果要做高速角度反馈控制,我更倾向用 SPI 版本的角度传感器,或者干脆用正交解码器硬件直接读增量编码器的 A/B 信号。

3.4 I2C 扩展与电平转换:两根线不够用怎么办

I2C 扩展有两种典型的“不够用”:一种是设备太多、地址冲突,另一种是设备电平域不一致。前者的常用方案是 TCA9548A 这类 I2C 多路选择器,它本身就是一个 I2C 从设备,分 8 路转发到不同子总线,每一路可以再接 8 个相同地址的从机。我在一条总线上挂过 24 颗同型号传感器,硬生生用一颗 TCA9548A 解决了地址冲突。后者则需要 I2C 电平转换,比如 3.3V 的 MCU 要接一个 5V 供电但不支持 3.3V 逻辑的老式传感器,直接连肯定出问题,因为 5V 传感器的高电平输出可能超过 MCU 引脚耐压。

很多人第一反应是“I2C 是不是必须用电平转换芯片才能跨电平通信”,严格说不一定——如果 5V 设备的逻辑高电平阈值低于 3.3V,且 MCU 的引脚能承受 5V 输入,并且两边都认可开漏结构和上拉到合适电压,那么中间只需用 MOS 管搭一个经典的双向电平转换电路就能工作。这种电路利用 I2C 开漏的特性和 MOS 管的二极管导通方向,天然实现了双向功能,成本低到不足一元钱。但要注意,如果总线上有几个不同电压域的从机,上拉电阻要统一接到最低电压侧,总线空闲电平才有保障。对于初学者,直接用 PCA9306 这种专用电平转换芯片是最不会出错的方案,尤其当总线速率超过 400kHz 时,自己搭的 MOS 管电路容易因为寄生电容过大把上升沿拉成斜坡。

4. 实践:从硬件到代码的完整实现

4.1 软件 SPI 和硬件 SPI 怎么选

软件模拟 SPI,就是用 GPIO 翻转时钟和数据线,通过延时实现时序。硬件 SPI,就是 MCU 内置的外设自动产生时钟、收发移位。单从速度来看,硬件 SPI 甩软件 SPI 几条街,而且 CPU 占用率低。但为什么软件 SPI 至今没有被淘汰?原因在于它有三个硬件 SPI 难以替代的优势:第一个是引脚选择极度自由,任意 GPIO 都可以作为 SCK/MOSI/MISO;第二个是时序参数可以任意调节,比如某些从机要求 SCK 占空比不是 50%,硬件外设很难满足;第三个是它可以方便地实现“同时驱动多个不同协议参数的从机”,只要每个从机都有独立的 CS 和对应的切换逻辑。

实际项目中我的选择原则是:只要 MCU 有硬件 SPI 外设引脚够用,就优先用硬件 SPI;做传感器读取这种一次只有几十字节的短事务,软件 SPI 其实也够用;但当数据量一大,比如刷屏、读写 Flash、采样 AD7606,还是老老实实上硬件 SPI 加 DMA。一个常见的折中方案是“硬件 SPI + GPIO 片选”,既兼顾速度又保留灵活性。我也处理过一些芯片因为硬件 SPI 引脚刚好被其他功能占用,转而用软件 SPI 驱动一块 SPI Flash 的案例,实测初始化慢一点,但功能没问题。关键是你得知道自己项目的性能预算在哪里,不要为了“省事”强上软件 SPI 结果变成性能瓶颈,也不要为了“高大上”非用硬件 SPI 结果把宝贵的特定引脚浪费在只有 100Hz 速率的传感器上。

4.2 用 Verilog 实现 I2C EEPROM 读写

热搜里有一条“I2C 读写 EEPROM 代码 Verilog”,这说明提问者大概率是在 FPGA 上挂 24C 系列 EEPROM,而不是单片机。FPGA 做 I2C 和单片机最大的不同在于,你要在主时钟域里手工生成 I2C 时序状态机,而不是调用现成的库函数。一个典型的设计包括:一个计数器把主时钟分频产生 SCL,一个状态机控制 SDA 电平、切换方向和采样时刻,一个移位寄存器缓存待发数据,以及一个 ACK 检测逻辑。

从头开始写没经验的人容易一上来就把状态机写得复杂,动不动就几十个状态。比较好的做法是先把 I2C 时序拆成几个基础动作:起始、停止、发送一字节(含 ACK 接收)、接收一字节(含 ACK 发送)。然后根据目标 EEPROM 的时序要求,调用这些基础动作组成写字节和随机读等高层操作。例如 24C02 的随机读时序是:起始 -> 发送器件地址(写方向)-> 发送内存地址 -> 重启起始 -> 发送器件地址(读方向)-> 读回一个字节 -> 主机发 NACK -> 停止。用一个 always 块做状态跳转,用另一个 always 块做 SDA 数据控制,代码结构会清晰很多。

Verilog 实现时最常踩的坑是亚稳态。I2C 的 SDA 来自外部芯片,在 FPGA 的时钟域里采样时,如果它刚好在时钟沿附近变化,触发器的输出可能处于不确定状态。稳妥的做法是把外来的 SDA 信号打两拍再做逻辑判断(两级同步器),同时用额外的采样窗口避开 SCL 上升沿之前的数据变化时刻。另一个坑是 FPGA 的引脚通常不是真正的开漏结构,不少板卡的 I2C 上拉电阻设计不完善,此时驱动外部器件时输出高电平能力不够,最好在顶层模块里把 SDA/SCL 输出用“三态门”实现——想拉低时输出 0,想释放时输出高阻 z,配合外部上拉电阻完成开漏效果。

4.3 接口测试与调试实战:逻辑分析仪是标配

不管做 SPI 还是 I2C,强烈建议在调试阶段配一台逻辑分析仪,便宜的二三十块钱的 8 通道设备就够用了。不要问“我看波形到底能看出什么”这种问题,我的经验是:几十个引脚时序问题,没有逻辑分析仪靠人眼逐行看代码,那是在浪费生命。逻辑分析仪接上之后,重点看三样东西:时钟是否连续稳定、片选(或起始条件)的位置是否和预期一致、数据位是否对齐了正确边沿。

SPI 调试时,我一般先让主机发一串 0xA5、0x5A 交替的测试序列,观察 MISO 上是否收到对应的回环数据。更快捷的方法是直接把 MOSI 和 MISO 短接,开启 SPI 自发自收模式,如果收到的等于发出的,说明 MCU 的 SPI 外设配置没问题,问题一定出在从机侧。I2C 调试时,最有效的自检是写一个“总线扫描”函数,逐个地址发送起始条件和设备地址,看哪个地址能收到 ACK。如果扫描到设备但读写数据不对,再用逻辑分析仪抓取波形,检查地址位、寄存器地址字节和数据的字节顺序是否符合数据手册。

我见过很多人在 SPI 和 I2C 调试卡住时第一反应是“换一个 IO 口试试”,其实这通常解决不了问题。先固定下来“总线本身通不通”这个前提,用最短的测试例程验证,再逐步增加功能模块,才是正规的排查路径。

5. 真实世界的踩坑记录

5.1 SPI 常见问题排查与经验总结

5.1.1 随机丢字节与时钟极性错乱

SPI 最常见的问题就是“偶尔丢一个字节”或者“读回来的数据每隔一段时间错一位”。如果是高速连续传输时丢字节,多半出在接收缓冲溢出——CPU 还没来得及读走数据寄存器里的旧数据,下一个字节就已经到达覆盖了。解法很简单,要么打开 SPI 的 FIFO 或 DMA,要么降低时钟频率。还有一种隐蔽情况是外部中断优先级过高,导致 SPI 中断延迟超过一个字节时间,接收寄存器被硬件自动覆盖。排查方法是在中断里加一个“溢出错误标志”判断,看看丢字节时是否触发了 OVR 标志,如果是,就说明问题出在响应延迟而非布线质量。

时钟极性错乱的症状更诡异:明明读到的数据“看起来差不多”,但低位和高位颠倒,或者整体左移/右移了一位。这类问题的根本原因是 CPOL/CPHA 和从机的需要不匹配。我在实际项目中遇到过一颗 SPI 从机芯片的默认模式是 Mode 3,而 MCU 默认配置是 Mode 0,导致通信完全失败。排查这种问题不要靠猜,去翻手册把时序图画出来,标出数据的建立时间和采样边沿,然后对照寄存器配置。调试时用逻辑分析仪抓波形最直观,一个周期的数据就够判断配置是否对得上。

5.1.2 多从机总线冲突与 CS 毛刺

在一个 SPI 总线上挂多颗从机时,如果 CS 的释放和拉低控制不到位,容易出现两颗从机同时在 MISO 上输出数据,导致总线冲突。这类问题在波形上的表现是 MISO 的中间电平出现“弱的中间态”——既不是清晰的高也不是清晰的低。排查时首先要确保任何时刻只有一颗从机的 CS 处于低电平,其次要看 MISO 线在 CS 切换之间是否处于高阻状态,最后还要防止上电时序导致所有从机在初始状态下混乱地驱动 MISO。解决方式通常是在硬件上给每颗从机加一个小的 MISO 串联电阻,或者在软件上于切换片选之后插入一个极小延时,让 MISO 总线状态稳定后再开始时钟。

CS 毛刺则是另一种让人头疼的问题。如果 CS 线布线过长且附近有高频信号干扰,就会在空闲状态出现瞬时低脉冲,从机误以为被选中,从而输出状态机被扰乱。我去查这类问题时的顺序是:先看原理图,CS 上有没有接上拉电阻;再看 PCB,CS 是否和 SCLK 靠得太近;最后用示波器在 CS 引脚上捕捉毛刺。软件上还可以给 CS 输出配置成推挽并且加上一点软件滤波——但这只是缓解,根治还是得从 layout 和阻抗匹配下手。

5.2 I2C 常见问题排查与经验总结

5.2.1 SDA 卡死问题:为什么总线会永久为低

I2C 最经典的问题莫过于“SDA 一直为低,总线挂了”。导致这个现象的机制很简单:某个从机在接收主机的数据过程中,因为复位、掉电或者时钟延音,自己的中断服务程序被打断,导致它内部的 I2C 状态机停在“等待下一个时钟”的位置,而此时它的 SDA 输出仍被拉低,于是整条总线被锁死。多发场景是给设备供电不稳、或者主机主动复位时没有先发 STOP 条件。

解决技巧主要有三个:第一个是硬件上别让 SDA/SCL 的上拉电阻太小,否则设备拉低时电流太大,影响内部电平判断;第二个是软件上引入“总线恢复”流程,当检测到 SDA 在非通信时段仍为低时,主机连续切换 9 到 16 个 SCL 时钟脉冲,强制让卡死的从机释放 SDA;第三个是在引脚允许的情况下,把 SDA 引脚配置成开漏输入并加上“读写端口”的不同方向控制,如果单纯靠外部器件的开漏能力,排查起来会比较悬。到了后期设计阶段,我甚至会在每个从机的电源入口加一个小的复位延迟电路,让主机上电初始化时先等从机稳定再发起总线访问,这样能躲开一大半“首次通信即卡死”的问题。

5.2.2 地址不响应与 ACK 异常

写 I2C 驱动时,如果发送地址后收不到 ACK,很多人第一反应是怀疑硬件没接好。实际上多数情况是地址写错了。I2C 的 7 位地址和 8 位地址字节之间差一个 R/W 位,说白了就是同一个地址,读方向和写方向的地址字节不一样。比如一颗器件地址是 0x50(7 位),写方向发送 0xA0,读方向发送 0xA1,0xA0 和 0xA1 之间就差了最后一位。初学者经常把 0x50 直接填进寄存器发送,结果从机根本不应答。

ACK 异常还有一类是“有时有 ACK 有时没有 ACK”,这往往是时钟拉伸(clock stretching)的问题。某些从机(特别是需要时间处理内部事务的传感器)会在接收字节后拉低 SCL,要求主机等待,直到它准备好再释放 SCL。如果你的 I2C 主机外设不支持时钟拉伸,那么通信就会时好时坏。解决办法要么换一个支持时钟拉伸的 MCU 外设,要么降低 I2C 速率,给从机更多裕量,要么在软件模拟 I2C 中显式等待 SCK 释放。总之遇到这种问题,先看看从机数据手册里有没有“clock stretching”字眼。

5.3 接口测试工单:如何把问题量化成可复现用例

我在团队里有一个习惯:任何 SPI 或 I2C 的问题,都必须先写一个最小复现用例,再谈解决。最小用例说白了就是:固定主频、固定通信参数、固定数据内容,用逻辑分析仪抓一段波形存档,然后在不同条件下改一个变量,比如时钟频率、上拉电阻阻值、DMA 开关、从机数量,一组一组地比对。把排查过程记录下来比“这次好了就完事”有用得多,因为问题很可能在温度或电压波动下重新出现。

对于 SPI,最小用例通常是回环测试加外部从机读写 ID;对于 I2C,最小用例是一个总线扫描加 EEPROM 单字节写读。如果最小用例通过但上层业务失败,那就是代码逻辑问题,跟接口无关;如果最小用例都不通过,那再开始调硬件时序。用这种分层排除法,我解决过许多被其他同事拖了几天的“灵异问题”,最后发现无非是上拉电阻选型错了或者时钟极性差一个模式。

6. 工程选型建议与个人实操体会

写了这么多,说到底还是想给出一条清晰的选型路径。选 SPI 还是 I2C,不要被“线少”或者“速度快”这种标签一票定音。我自己的决策序列是:先看设备数量,只有两三个从机且都近,SPI 是最省心的,不需要考虑寻址冲突;从机多于三四个,优先 I2C,靠地址而不是片选来区分设备;看重吞吐量和实时性,比如 ADC 连续采样、显示屏刷帧、Flash 高速存取,直接放弃 I2C 上 SPI;看重引脚和连线简洁,比如传感器板上布两根线就完成所有通信,那就是 I2C 的地盘。

还有一个反复在项目里被验证的心得是:无论选了哪种协议,一定要留后备方案。比如 PCB 上预留一个 SPI 接口的 OLED 焊盘,同时兼容 I2C 版本屏幕;驱动代码里把 SPI 和 I2C 两种抽象层都写出来,用宏切换。硬件上 20 分钟的预留,可能省下后续换型时的一整周适配时间。毕竟从沙子到车辙这条路,真正的工程量不在于芯片内部能算多少亿次浮点,而在于把成千上万个片间的小数据流,稳稳地输送到该去的地方。

以我个人的体会来说,SPI 和 I2C 看起来是两个古董级别的协议,却在今天的每块开发板上几乎成为标配。你越早把它们的时序本质、电气特性和软件细节吃透,后面做任何复杂的多传感器系统、数据采集设备或者带屏交互产品,底气就越足。调试这些接口,最忌讳的就是浮于表面——波形不好看就加电阻,通信不通就换引脚。静下心来,先弄明白协议的数学模型和物理约束,问题往往自然就水落石出了。