很多人学 I2C 的第一印象是“简单”:两根线、一主多从、发地址、收数据,好像没什么可讲的。可真到了项目里,把两个单片机挂到同一条总线上,或者接了一个稍微慢一点的传感器,总线就开始跟你玩“卡死”和“丢包”。直到你把多主机仲裁和时钟延展这两个机制彻底想明白,才发现这两个设计才是 I2C 里最精妙的地方。
这一篇我把这两个机制从头到尾拆一遍,包括它们依赖的物理层逻辑、逐位仲裁的完整过程、从机怎么“叫停”总线,以及实战中怎么排查相关故障。内容不搞玄学,都是你拿逻辑分析仪能看到、能复现的东西,适合正在被 I2C 异常困扰的嵌入式开发者,也适合想系统学协议原理的初学者。
1. 没有仲裁会怎样:先推演一遍总线冲突
1.1 两个主机同时发起传输的现场
假设一条 I2C 总线上挂了两个主机,一个单片机 A 要向 EEPROM 写数据,另一个单片机 B 要向温度传感器读数据。两个主机各自检测总线状态,几乎同时发现 SDA 和 SCL 都处于高电平,也就是“总线空闲”,于是同时发出了 START 条件。
此时会发生什么?如果协议里没有仲裁机制,情况会非常难看。两个主机都会在自己的内部状态机里认为“总线归我了”,然后各发各的地址。问题在于,SDA 是一条物理上的单根线,同一时刻只能有一个确定的电平。A 想发 0,B 想发 1,SDA 到底该是多少?如果两个主机的 SDA 输出级都是推挽结构,那么一个推高、一个拉低,直接形成电源到地的通路,轻则电平紊乱,重则芯片过热甚至烧毁。就算没烧,从机侧也完全无法判断地址是谁发出来的,后续数据必然错乱,总线彻底瘫痪。
这不是臆想。我在实际做双 MCU 冗余通信的板子上就遇到过类似的“总线打架”现象,第一个反应是怀疑供电和复位,后来抓波形才发现两个控制器几乎同时启动了传输,却没有一套机制去裁定输赢。
1.2 软件互斥为什么不能根治
有人会说,多主机竞争这件事,我可以在上层软件里解决:规定好谁是主、谁是辅,或者用 GPIO 握手信号做一个互斥锁,谁拿到锁谁才能发起传输。这当然可行,很多项目也确实这么干。但你要注意,这种做法有几个明显的代价。
第一,软件互斥需要额外引脚或者报文交互,总线上真正传数据的带宽被挤占。第二,如果第三个设备也要主动上报数据,整个“分配令牌”的逻辑会越来越复杂,系统耦合度直线上升。第三,也是最关键的,软件互斥无法处理“两个主机在同一时刻、同一个物理总线上同时拉低 SDA 发起 START”这种竞态——你再怎么握手,这两个动作已经发生在了同一个电平叠加的瞬间。
所以 I2C 的设计者选择把裁定机制做进物理层和协议层,而不是丢给上层软件。所有设备只要遵循协议,仲裁就自动发生,不需要额外的锁、轮询、令牌。这一点,是理解 I2C 多主机能力的前提。
1.3 仲裁设计的目标:把竞争嵌入通信本身
I2C 仲裁的设计目标可以概括成一句话:让竞争发生在通信过程之中,而不是通信之前。两个主机竞争总线时,不需要先停下来问“谁先用”,而是直接开干。在发送每一个比特的同时,每个主机都在监听 SDA 上的实际电平,一旦发现自己期望发出的电平和线上实际电平不一致,就立刻认输退出。
这意味着胜者的数据从头到尾没有被破坏,败者损失的只是一个比特的“识别时间”,而不是一个完整的传输周期。整个胜负判定没有额外的仲裁帧,没有额外的总线占用时间,纯粹顺着正常的数据帧同步完成。这种“边通信用边仲裁”的设计,在总线协议里非常少见,也是我后来觉得它精妙的最主要原因。
2. 开漏输出与线与逻辑:仲裁能成立的物理根基
2.1 为什么 I2C 要用开漏而不用推挽
要理解仲裁,必须先回到 I2C 的物理层:SCL 和 SDA 都是开漏输出,外部接上拉电阻。所谓开漏,就是芯片内部只是一个 MOSFET 或者三极管,负责把引脚拉低;要让引脚变成高电平,只能靠外部的上拉电阻把电压“提”上去。
这一点和 SPI、UART 常用的推挽输出完全相反。推挽输出有很强的驱动能力,既能主动拉高,也能主动拉低,电平跳变快。那为什么 I2C 不用推挽?因为推挽无法实现“线与”。想象两根推挽输出线接在同一个 SDA 节点上,一个输出高、一个输出低,两个输出级相当于直接短接,电流会烧穿引脚,而且线上电平取决于谁的驱动能力更强,完全不可控。开漏输出就没有这个问题:任意一个设备想拉低,直接拉低;没有任何设备拉低时,上拉电阻把线恢复成高电平。多个开漏输出并联,就是天然的逻辑与,所以又被称为“线与逻辑”。
这里的核心逻辑很简单:这决定了总线上的电平不是由某一个设备单独决定的,而是由“所有正在驱动低电平的设备”共同决定的。谁在拉低,谁就拥有“一票否决权”。
2.2 线与逻辑的“最慢者决定”规则
用生活化的方式理解线与逻辑,你可以把总线想象成一根绳子,绳子上绑着很多只手。每只手只能做两个动作:松开绳子,或者用力往下拽。绳子另一端有个弹簧,只要没人拽它,它就会自动回到高位。只要任何一个人拽着,绳子就一直是低的。要让大家同时松手,这根绳子才会重新升起来。
所以总线行为的规则是:低电平由任何一个“拉低者”决定,高电平必须所有人都释放才能出现。这条规则同时奠定了仲裁和时钟延展的物理基础。仲裁的时候,发 0 的设备在拉低,发 1 的设备在释放,线上的结果就是低,发 1 的设备发现实际电平和自己的预期不符,输掉仲裁。时钟延展的时候,从机把 SCL 拉低,主机的时钟发生器即使想继续跑,也会发现 SCL 根本高不起来,于是只能原地等待。
2.3 上拉电阻不会凭空存在:取值与总线电容的关系
很多人写 I2C 驱动时完全忽略上拉电阻,觉得这是硬件工程师的事。等你真的遇到总线波形爬坡太慢、时序不满足,或者干脆通信时好时坏,才会回头查上拉电阻取值。
上拉电阻的选取要考虑两个约束:一是总线电容 RC 充电时间不能太长,否则上升沿太慢,在高速模式下会不满足时序要求;二是电阻不能太小,否则设备拉低时灌电流过大,超出从机引脚允许的 IOL。经验公式是上升时间tr约等于 0.847 乘以电阻和总线电容的乘积。总线上的设备越多、走线越长,分布电容越大,需要适当调小上拉电阻。
| 总线模式 | 典型速率 | 经验上拉电阻 |
|---|---|---|
| 标准模式 | 100 kHz | 4.7k~10k |
| 快速模式 | 400 kHz | 2.2k~4.7k |
| 快速+模式 | 1 MHz | 1k~2.2k(视负载电容) |
我常用的做法是:板子只有一两个从机、走线短,用 4.7k;传感器挂了三四个、或者总线上有排线引出,就上 2.2k。千万别为了追求高速一口气把电阻降到 330 欧,低电平时的灌电流会非常难看,从机可能直接被拉死。顺带说一句,调试时如果总线波形上升沿肉眼可见的倾斜,优先怀疑上拉电阻,而不是时序寄存器。
3. 逐位仲裁的完整过程:从 START 到数据字节
3.1 仲裁发生的时钟节奏:每个 SCL 高电平都是一次“比拼”
I2C 的数据传输节奏是:SCL 低电平期间,发送方可以改变 SDA 的电平;SCL 高电平期间,接收方必须采样 SDA,此时 SDA 必须保持稳定。仲裁的判定点,就落在 SCL 高电平的采样窗口里。
当一个主机在某个 bit 上向外发送电平的同时,它内部其实也在读 SDA 的实际电平。如果它发送的是 1,而读到线上是 0,说明有别的设备在拉低这根线,那么它就可以断定自己输掉了这一位仲裁。反过来,如果发送的是 0,读到 0,那它不会输,因为 0 在线上有优先权——这正好是“线与逻辑”的自然结果。
主机仲裁是从 START 之后的第一位就开始的,也就是说,从第一个地址位开始,每一个 bit 都是一场潜在的比拼。你不需要额外发送“我要参与仲裁”的信号,你正常发送的每一个字节、每一个 bit 都天然带着仲裁属性。
3.2 一次地址相同但数据不同的仲裁实例
用一个具体例子走一遍完整过程。假设主机 A 要写 EEPROM 设备地址 0x50,二进制是 0101 0000,写方向位是 0。主机 B 要写地址 0x51,二进制是 0101 0001,写方向位也是 0。
两个主机同时发出 START,然后开始发送地址字节。前 7 位 0101 000 完全一致,SDA 线上由两个主机同时驱动,由于电平一致,谁也不觉得自己输了。第 8 位 R/W 也都是 0,仍然一致。接下来从机在第 9 个时钟周期给出 ACK,把 SDA 拉低。这里要注意,第 9 位 SDA 是从机驱动的,两个主机的输出级都处于释放状态,它们同时读到了 ACK,并且都认为“从机是在应答我”。
到了数据阶段,分歧出现了。主机 A 发送第一个字节 0xAA,也就是 1010 1010;主机 B 发送第一个字节 0x55,也就是 0101 0101。第一位 A 发 1,B 发 0。刚才说过,线上谁拉低谁优先,所以 SDA 实际是 0。主机 A 期望发 1 却读到 0,立刻知道自己输了仲裁,在剩余位停止驱动 SDA,彻底退场。主机 B 发 0 读到 0,一切都正常,它继续以 0x55 为第一个数据字节完成后续传输。
这个例子里,落败方 A 只输在数据阶段的第一位,但它从那个时刻开始,就不再是总线的驱动者了。整个仲裁过程,没有被破坏的起始条件,没有额外的错误帧,也没有总线的空闲等待。
3.3 R/W 位、ACK 位与 NACK 在仲裁中的角色
有些细节值得展开说一下。地址和 R/W 位同样参与仲裁。如果两个主机发送的地址完全相同,但 R/W 不同,一个想写、一个想读,那么第 8 位就会分出胜负:发 0(写方向)的主机赢,发 1(读方向)的主机输。因为发 0 等于拉低总线,发 1 读到 0 只能认输。这个现象在调试双主机系统时很常见,一个主机想读数据,另一个恰好同时写同一个地址,读方总是莫名其妙仲裁失败。
ACK 位的情况要严谨一点:严格来说,ACK 位本身不算主机之间的仲裁。因为第 9 位期间 SDA 是由从机驱动的,主机们都在释放状态,不存在两个主机的输出级互相竞争。但 ACK 位又是仲裁的一个天然分界点:如果两个主机从 START 到地址 ACK 全部一致,仲裁会自然延伸到数据字节的第一位;如果数据第一个字节也一致,就继续延伸到下一位。换句话说,只要你无法让从机在确定该听谁的之前让两个主机分道扬镳,仲裁就一直持续到某个数据位或 R/W 位出现差别。
还有一种容易被忽略的情况:如果两个主机发送完全相同的地址、相同的数据,从机 ACK 也相同,那么仲裁会一路平局直到 STOP 条件。这时候两个主机都会认为自己完成了一次成功传输。从协议角度讲,这并不算错误,因为总线上的事务确实完整发生了,而且内容完全一致,无论哪个主机“以为”是自己完成的,最终结果都一样。这个特征有点反直觉,但想通了会觉得很巧妙。
3.4 完全相同的传输会发生什么
上面提到了完全相同的传输,我再稍微展开一下。两个主机发送相同的地址和相同的数据,总线上的波形没有任何异常,从机眼中只有一个完整事务。两个主机内部状态机都会进入“本次传输成功”的状态。如果它们各自还有后续任务,接下来两个主机需要继续竞争下一次启动权。实际情况是,这种“完全重复”大概率很小,因为数据内容有变化,一旦变化,仲裁就在变化处结束。
这部分对系统设计有个启发:仲裁不是“先到先得”的排队式调度,而是“谁发 0 多、谁的节奏刚好压住对方”的实时比拼。仲裁失败并不代表总线出错了,它只是一种正常的竞争结果,应当被当作可重试事件来处理,而不是系统故障。
4. 时钟延展:慢从机如何冻结整条总线
4.1 从机为什么能拉低 SCL
时钟延展(Clock Stretching)这个机制,很多人学 I2C 时会被一笔带过,但它恰恰是慢速设备能挂在高速总线上而不出错的关键。
SCL 也是开漏线。主机负责产生时钟,只是它“负责启动高电平”,并不代表只有它能拉低。从机同样有权利把 SCL 拉低,而且一旦拉低,整条总线的时钟就升不上去。主机在产生一个时钟周期时,并不会闭着眼睛一直翻转,它会在准备把 SCL 拉高之后检查 SCL 是否真的变高了。如果从机还在拉着,主机就会停在当前状态等待,直到从机释放 SCL 才继续下一个 bit。
你可以把时钟延展理解成设备之间的“电话会议里,有人说等一下,我先记个笔记”。其他人不是挂断电话,而是安静等这个人说完。整个过程是同步握手,不是中断,也不是异常,它是 I2C 协议里合法的、预期内的事件。
4.2 主机遇到延展时该做什么
主机侧的正确反应是:检测到 SCL 为低时,原地等待,超时机制作为兜底。绝大多数 MCU 的硬件 I2C 外设都会自动处理时钟延展,你基本不用额外写代码。但如果你用的是软件模拟 I2C,就必须在代码里显式处理,否则就是各种诡异问题。
我见过很多软件 I2C 驱动是把 SCL 拉高后用延时函数等半周期,然后直接读 SDA。这种写法在从机不做延展时没问题,一旦从机在某个时刻需要延展,SCL 会在拉高后被从机按住,你的延时还没结束,总线电平根本不是你预期的,读到的 SDA 自然全是错的。正确写法是每个 SCL 高电平阶段都要等待“SCL 确实变高”之后再做延时。
// 软件模拟I2C:SCL高电平阶段必须等待从机释放 void scl_high_and_wait(void) { SCL = 1; uint32_t timeout = get_timeout(); while (SCL_READ() == 0) { // 从机正在时钟延展 if (timeout_expired()) { // 总线异常,做错误处理 handle_bus_error(); break; } } }这段代码是软 I2C 的命门之一。没有这一步,你的软 I2C 只能跟那些“从不延展”的简单从机正常工作,一旦换成会延展的传感器或大容量存储器,通信就时好时坏。
4.3 最容易触发时钟延展的三类从机场景
根据我这几年实际碰过的设备,时钟延展主要出现在三类场景,列出来供你排查时对号入座。
第一类是 EEPROM 和非易失存储。典型代表是各种 AT24C 系列,页写一个数据块之后,芯片内部要把数据烧进存储单元,这段时间芯片无法响应新的 I2C 操作。早期的经典做法是“ACK Polling”:主机会反复发送设备地址,EEPROM 在没有写完时回 NACK,写完才回 ACK。但也有很多存储器件会直接把 SCL 拉低直到内部写周期完成。如果你遇到写 EEPROM 后总线卡住,优先考虑是不是内部编程期间发生了延展,再决定是轮询还是等待。
第二类是 ADC 和传感器。不少 ADC 在转换期间会把 SCL 拉低,转换完成才释放,比如某些高精度 Δ-Σ ADC。对于这种从机,主机读数据时不能设置太短的总线超时,否则转换稍微久一点,主机会误判“总线挂了”而触发复位,反而把正常通信打断。传感器领域也有典型例子,像 BMP280、MPU6050 这类设备在内部校准或 FIFO 处理期间,都可能出现延展行为。还有像 AS5600 这种简单磁编码器一般不做延展,它的时序非常固定,与延展类从机形成鲜明对比。
第三类是显示驱动和触摸类芯片。SSD1306 这类 OLED 驱动在显存访问比较忙时,偶尔也会延展;GT911 触摸屏则要特别注意上电后的初始化时间,主机过早访问时,芯片还未就绪,可能出现 SCL 被拉住或者直接无 ACK 的现象。很多人把“GT911 通信失败”归结为地址不对,实际查下来很多都是启动时序和延展超时设置的问题。
4.4 超时设置与软 I2C 的正确写法
时钟延展的常见坑是超时设置。你需要先看从机手册里规定的“最坏延展时间”,然后把这个时间乘以足够的安全系数,写进主机的超时寄存器。比如某个传感器最坏延展 30ms,你的 I2C 任务轮询超时就不能只设 5ms,否则它会周期性误报总线错误。反过来,如果某个从机手册明确说“不支持时钟延展”,而实际运行起来把 SCL 拉低超时,那就要优先查它的电源稳定性或复位状态。
最后提醒一句,SMBus 和 I2C 在处理延展上态度不同。SMBus 有强制超时下限,设备不能无限制延展,这是为了服务器平台的可管理性设计的。普通 I2C 没有这种强制超时,所以你在普通 I2C 上移植 SMBus 设备时,要注意 SMBus 规范里那些超时限制并不适用于所有 I2C 从机,反而容易造成误判。
5. 多主机仲裁与时钟延展的相遇
5.1 时钟同步:多个主机如何共享同一拍子
前面讲的仲裁,默认了一个前提:两个主机是在同一个时钟节拍上发送每一位的。但两个主机各自有自己的时钟发生器,它们是怎么保证同步的?
答案还是开漏线和线与逻辑。两个主机同时拉低 SCL,低电平开始于最早拉低的那一方;随后双方轮流释放 SCL,但只要有一个还没释放,SCL 线就是低电平,所以高电平必须等到最后一个释放 SCL 的主机。这样得到的实际结果是:每个时钟周期的低电平宽度,由“最早拉低者”和“最晚释放者”共同决定;整体时钟频率被压到了最慢的那个主机一侧。这个过程叫做时钟同步。
也就是说,即使两个主机主频相差很大,只要都遵守开漏协议,它们在总线上实际跑出的时钟节拍是一致的,仲裁也因此在同一个时钟沿上稳定进行。我之前见过有人担心两个 MCU 的 I2C 时钟频率不一致会仲裁失败,其实不会:它们会互相拖慢到同一个拍子,然后在这个拍子上逐位竞争。这个机制把“硬件差异”消解在了物理层。
5.2 从机延展时正在仲裁会怎样
如果仲裁正在进行的过程中,从机突然拉低了 SCL,会发生什么?答案很简单:两个主机都会被冻结。因为 SCL 在低电平期间,主机状态机不会推进,所有仲裁判定都必须等到 SCL 重新变高之后才能继续。从机的延展相当于按下了总线的暂停键,暂停期间,SDA 保持当时的电平,仲裁结果悬而未决。从机释放 SCL 后,两个主机才在下一个采样窗口继续比拼。
这对系统设计有实际意义:从机的延展时间不应该影响仲裁的公平性,因为两个主机在同一物理总线上共享同一根 SCL,等待的时间完全一样。换句话说,时钟延展对竞争双方是平等的,不会出现某一方因为本地时钟快而抢跑。这是仲裁设计和时钟延展机制相互配合的一个隐藏优点。
5.3 从机视角:输掉仲裁的主机不会“偷听”后续数据
有些搞多主机的朋友会有个担忧:如果两个主机同时向同一个从机发命令,输掉仲裁的主机是不是还会继续接收从机后续返回的数据?或者从机是不是会同时响应两个主机的请求?
不会。仲裁失败的判定点比你想的更早。一旦某个主机在某个 bit 发现自己输掉,它会立刻停止驱动 SDA,并且在当前字节结束后退出总线事务。从机视角下,总线上的事务从头到尾只有一个“获胜者”在驱动,虽然从机无法感知仲裁的存在,但它只跟最终获胜的主机完成了这次交互。输掉仲裁的主机不会再看后续数据,因为它内部状态机已经切换到了“仲裁丢失,等待重试或空闲”的状态。
这个特性保证了仲裁即使发生在从机面前,从机也不需要任何额外的处理逻辑——它自始至终只看到一个完整、无冲突的事务。这就是为什么从机硬件可以做得特别简单,复杂逻辑全部留给了主机的状态机。
5.4 多主机调度状态机的设计思路
真正要多主机场景稳定工作,我的建议是把“仲裁丢失”当成一条正常分支,而不是错误分支。状态机的设计大致是这样的:发起传输时,发送一个 bit 之后如果检测到仲裁丢失,立即停止驱动,但仍要跟随时钟直到当前字节结束,然后在总线空闲时重新发起 START,重试次数要有限制,防止两个故障主机互相死磕。
还有一个要注意的点:如果主机 A 丢失仲裁,它在当前字节后退出,而主机 B 可能才刚刚开始一个很长的多字节传输。此时 A 不能因为急着重试就强行插入 START,必须等总线出现 STOP 后再次检测空闲。硬件 I2C 控制器一般会自动遵守这个规则,但软件模拟 I2C 就完全看你自己的实现是否严谨。我早期做软 I2C 多主机时,就是在这上面吃了大亏:仲裁丢失后我在字节中间就重发 START,结果把一个合法传输拦腰截断,从机直接懵了。
6. 实测波形与排障经验:我被 I2C 坑过的几个瞬间
6.1 用逻辑分析仪一眼识别时钟延展和仲裁
调试 I2C 问题,逻辑分析仪是必须的。采样率不要低于总线频率的 10 倍,400kHz 的总线建议至少 10M 采样率,有条件直接上 20M。采样率太低,你会把正常的窄脉冲看丢,也可能把时钟延展误判为普通低电平。
看波形有两个重点。第一,在 SCL 波形上找一个异常宽的“低电平”窗口,宽度远大于其他位,而且它旁边没有对应的 SDA 变化,那基本就是时钟延展。第二,在 SDA 波形上,如果在某个 SCL 高电平期间出现了“与发送端预期相反”的 SDA 电平,而且这个拖延贯穿了后续整个字节,那多半就是仲裁现场。仲裁丢失时 SDA 上会留下一个比正常数据沿更明显的被“按下去”的电平,因为你既看到了拉低动作,也看到了竞争方的释放动作。
把逻辑分析仪解码设成 I2C 模式,它会自动标出 START、Address、ACK、Data、STOP。多主机竞争时,解码器可能显示“Arbitration Lost”,这时候不要慌,配合波形看是哪一位出的问题,判断是地址竞争还是数据竞争。
6.2 案例一:SCL 被无缘无故拉低一整串位
有块板子接了 OLED 和两个传感器,偶尔开机后整条总线上 SCL 就一直低,单片机 I2C 外设完全不工作。我用万用表量 SCL 对地,确实被拉死了,但把所有从机一个个摘下来,发现摘到 OLED 时总线恢复。奇怪的是,这个 OLED 模块单独上电时又是正常的。
后来排查发现,这块 OLED 的驱动芯片电源不稳,板载 LDO 输出电压在启动瞬间跌落,芯片内部逻辑处于“上电异常”状态,把 I2C 引脚钳位住了。等电源稳定后芯片也没恢复,因为它没有独立的复位引脚。处理方式是给 OLED 单独加一个软启动延时,主机上电后先等 200ms 再访问 I2C;同时给 LDO 的输出电容加大一点。这个案例的教训是:SCL 被拉低,不能只查总线逻辑,还要怀疑从机自身的电源状态。时钟延展是合法的,但从机因为供电异常而“非法延展”才是故障根因。
6.3 案例二:仲裁丢失后控制器卡死
另一块板子是双 MCU 共享 I2C,一个做心跳,一个回状态,看起来很简单,但每次断电重启后有概率整个通信不再恢复。抓波形发现,两个 MCU 的上电延时几乎一样,经常会同时发起 START,事件本身并不可怕——可怕的是其中一个 MCU 的硬件 I2C 外设在发生仲裁丢失后,状态机卡在了发地址的半路,既没有触发错误中断,也没有释放总线,之后总线就一直被它占用。
处理办法是外设错误复位流程:检测到仲裁丢失后,手动关断 I2C 外设,重新初始化控制器,再等待总线空闲后重启传输。很多 HAL 库的默认错误处理在仲裁丢失场景下并不够用,需要自己接管错误回调。大致流程是这样:
// 伪代码:仲裁丢失后的恢复流程 if (i2c_status == ARBITRATION_LOST) { // 1. 停止当前传输并清标志 // 2. 释放SDA/SCL release_bus(); // 3. 重新初始化i2c控制器 reinit_i2c_controller(); // 4. 延迟一段时间后重试 delay_ms(1); }这之后我没有再遇到过那种“必须断电才能恢复”的情况。仲裁丢失要当成一次可重试的普通事件,而不是系统错误。你可以把两个主机故意配置成同一时间向同一个地址写不同数据,来测试仲裁恢复流程是否可靠。
6.4 案例三:休眠唤醒后 I2C 复位与软复位技巧
还有一个高频问题,是 MCU 休眠唤醒后 I2C 状态不对。ESP32 这类芯片在深度睡眠唤醒后,I2C 外设需要重新初始化,否则 SDA 可能保持低电平,总线一直忙。我踩过这个坑之后,习惯把休眠唤醒后的 I2C 复位做成标准动作:先释放引脚,再重新初始化驱动,必要时发几个停止条件让总线回到空闲状态。
至于从机把总线彻底拉死的极端情况,可以尝试“软复位”:按从机手册规定的时序,对 SCL 发送 9 个以上时钟脉冲,同时让 SDA 保持高电平,强迫从机释放总线。这是很多 I2C 从机通用的解除死锁办法,但不是所有芯片都支持,使用前务必查手册。我在实际项目里只遇到过一次需要这么做的从机,其他时候重新上电最干脆。
最后说一句个人体会。我最早做双 MCU 冗余心跳的板子时,被 I2C 的多主机特性毒打了好几周。那时不理解为什么两个控制器会“打起来”,也不懂仲裁丢失为什么会导致总线挂死。后来把多主机仲裁和时钟延展这两个机制从头到尾研究透,再用逻辑分析仪复现了一遍地址冲突,才真正明白:这两套机制是 I2C 最精妙的设计,它们把竞争、同步、拥塞控制全部放进了物理层和协议层,让上层软件几乎无需感知。建议你拿到任何一块新板子,第一件事不是急着写代码,而是用逻辑分析仪抓一帧完整的总线波形,看看有没有你之前没注意到的延展和竞争痕迹。很多看似玄学的 I2C 故障,其实早就在波形里写好了答案。