I2C多主机仲裁与时钟延展:开漏上拉下的总线博弈与工程实践
做嵌入式这些年,我没少在 I2C 总线上翻车。第一次用两片单片机挂在同一条总线上互传数据时,我以为只要有 7 位地址就能各干各的,结果被波形教育了:地址明明是对的,从机回 ACK 也正常,但两个主机同时发起传输时就是会互相踩,一个发地址、另一个也在发,SDA 上混出来的信号谁都不认。后来把 I2C 规范里"多主机仲裁"和"时钟延展"这两节翻来覆去读了几遍,才意识到整个协议最精妙的机关,恰恰藏在这两个最容易被初学者跳过的名词里,而不是大家最常盯着的 Start、Stop、ACK 时序图。
这篇就当第 04 讲,专门把这两个设计拆开讲透。如果你玩过 STM32、ESP32,或者调过 SSD1306 OLED、BH1750 这类常见 I2C 从机却一直觉得"哪里怪怪的",这篇文章能帮不少人补上最后一块拼图。
1. 为什么我先把仲裁和时钟延展放在地址格式之前讲
市面上的 I2C 教程几乎都在讲同一个顺序:先讲总线结构,然后画时序图,接着背 Start/Stop 条件、7 位地址、读写信标志、ACK/NACK,最后给一段 GPIO 模拟或硬件外设的初始化代码。这个顺序本身没错,但它有一个致命盲区——它把 I2C 当成一个"单主"总线来教了。
可 I2C 从骨子里就支持多个主设备挂在同一对 SDA、SCL 线上。只要地址不冲突,两个主控芯片可以在同一时刻发起通信。这才是它和 SPI 最大的区别,也是很多项目里敢用两条线挂十几个芯片的底气。
想理解多主机为什么能并存,必须先回到 I2C 最底层的电气实现:开漏输出(open-drain)加上拉电阻。SDA 和 SCL 两根线在物理上都是"线与"结构——任何设备拉低,整条线就是低;所有设备都释放,线才被上拉电阻拉回高。这直接引出了两个关键推论。
第一个推论:低电平是绝对优先级。谁把线拉低了,谁就是总线现在的"主人";另一台设备如果此刻想发高电平,它只能干等。多主机仲裁就是在这个推论上长出来的。
第二个推论:线的电平状态,任何时刻都对所有设备透明。每台设备都能读回总线当前状态,不需要额外的握手线,不需要总线仲裁器芯片。基于这点,I2C 才能做到"边发边听"——主机在发送每一位的同时,还能读到线上真实的电平,用来判断自己有没有"说错话"。
这两个推论往深了走,就是仲裁和时钟延展。所以我的建议是:学 I2C 第一步不是背时序,而是理解"开漏 + 上拉"这个电气模型。尤其你现在如果还在用 GPIO 模拟 I2C,更要先想明白一个问题:你配置的 SCL、SDA 引脚是真正的开漏模式,还是推挽输出?后者会直接毁掉仲裁与延展的物理基础。
2. 多主机仲裁:SDA 上的"边说边听"如何在不打架的前提下完成谈判
多主机仲裁的官方说法是:当两个或多个主机同时尝试在总线上传输时,会根据 SDA 线上的实际电平实时裁决,输的一方自动退出,赢的一方继续完成后续传输。这个过程完全由硬件和电气特性自动完成,不需要任何中心调度器。
2.1 一位一位比,谁发的和总线不一样,谁就闭嘴
仲裁的粒度是"位"级别的。每个主机在发送自己的数据位时,同时读回 SDA 电平。如果发送的是一位高电平(1),但实际上 SDA 已经被另一台主机拉成了低电平(0),那这个主机就知道"我抢输了",立刻停止发送 SDA,但注意,它不会立刻释放整个总线,而是继续同步 SCL,直到当前这个字节仲裁结束。
为什么低电平能赢?这就是开漏结构的天然优先级。两台主机都想发 1 时,SDA 保持高;两台都想发 0 时,SDA 保持低;一台想发 1、一台想发 0 时,SDA 是低,因为低电平把高电平"盖"掉了。发 1 的那台读回低电平,发现和自己预期的输出不一致,于是退出仲裁。整个过程就是一次纯粹的电气比较,连比较器都不用额外加。
用一个生活类比:两个人在同一个话筒前同时开口念同一份稿件,一开始声音一致,谁都没发现异常;突然第一个人念错了字,第二个人还在照着原文念,听众听到的是正确的原文,念错的人听到自己声音和大家听到的不一致,就自动闭嘴了。仲裁的原则就是"谁先不一致,谁退场,而且退场的判定只发生在不一致的那个瞬间"。
2.2 仲裁不只是比数据位,起始条件也要抢
很多人以为仲裁只发生在发送地址或数据阶段,其实起始条件(START)本身就是仲裁的一部分。多个主机同时想发起传输时,谁先把 SDA 从高拉到低,谁就抢占到起始条件。这个"先到先得"也是基于总线物理状态判定的,不需要额外机制。
一旦某主机成功发出了 START,它会继续发送自己的从机地址和读写信标志。此时剩余的主机如果也想发同样的地址,双方会继续在后续位上一路比下去,直到出现不同电平或者整个地址完全相同。地址完全相同的情况下,还会继续在数据阶段仲裁。总之,仲裁覆盖了从 START 一直到传输结束的每一个位。全程没有"协商"过程,只有"通过物理比较不断淘汰"的过程。
2.3 时钟同步:SCL 的低电平长度取所有人里最长的那段
多主机仲裁能成立,除了 SDA 的线与逻辑,还有一个前提:所有主机在仲裁时的 SCL 必须同步。不然你在这个 bit 的上升沿采样,我在那个 bit 的下降沿采样,SDA 上的比较就没意义了。
SCL 也是开漏结构,所有主机都会输出自己的时钟。当任何一个主机拉低 SCL 时,整条 SCL 就是低;要让 SCL 变高,必须所有主机都释放。结果就是:SCL 的低电平时间等于所有参与主机中"拉低最长"的那一位,而高电平时间等于所有主机中"先释放"的那一位。换句话说,慢的时钟决定了低电平的宽度,快的时钟决定了高电平的起始点。这个现象叫时钟同步(clock synchronization),它是仲裁和时钟延展得以实现的总线级基础。
我去翻过一些老式 MCU 的硬件 I2C 外设手册,它们会把时钟同步描述成"SCL 低电平期间计数器重置、高电平期间计数器不受影响"。翻译成人话就是:只要检测到 SCL 还是低,就继续等;一旦 SCL 变高,就开始算自己的高电平时间。这样多主机之间不用额外对表,就能在每一位上对齐。
2.4 仲裁失败的设备还要干什么
输掉仲裁的设备不是直接消失,而是要做两件事:第一,停止驱动 SDA;第二,继续跟随 SCL 直到当前传输的字节结束。之后,它可以判断是否需要在下一次总线空闲时重试自己未完成的传输。规范里明确写到,仲裁失败的主机不能认为"这次传输成功了",也不能发出 STOP 条件去干扰赢家的传输。
我早期写软件模拟 I2C 时就栽在这里:两个主机用 GPIO 模拟,一个地址发到一半发现自己输了,直接中断输出,结果 SDA 被拉飞,整条总线挂死。正确做法是输掉仲裁后,主机要立即把自己的 SDA 输出切成高阻输入态,老老实实跟着 SCL 走完当前字节,再去重试。
3. 时钟延展:从机说"等等我",主机为什么必须服从
时钟延展(clock stretching)与仲裁正好互补:仲裁解决的是"多个主机抢线"的问题,时钟延展解决的是"从机跟不上主机节奏"的问题。它是 I2C 为慢速从机预留的合法刹车机制。
3.1 从机怎么把主机踩停
时钟延展的触发点在 SCL 的低电平时段。正常情况下,主机把 SCL 拉低,完成数据位切换,然后释放 SCL,让上拉电阻把 SCL 拉高,这个低电平宽度由主机决定。但如果在 SCL 被拉低期间,从机主动把 SCL 继续维持为低(比如它内部正在处理事务),那么主机检测到 SCL 没有按预期变高,就会自动停在当前位置,一直等。直到从机处理完,释放 SCL,总线才能继续跑。
从机为什么能做到这一点?还是开漏结构。SCL 的线与特性意味着"只要还有一个设备拉低,SCL 就高不了"。主机此时读回 SCL 发现是低,就知道有从机在请求延展,不会傻等固定时间,也不会主动放弃。这就引入了新的思考,后面我看了一些项目的代码,所有声称"支持 I2C"的原子操作里,必须有对 SCL 电平的轮询或中断检测,而不是单纯的延时函数。
3.2 典型场景:EEPROM 写入、ADC 转换、OLED 内部操作
什么情况下从机会拉延展?最常见的是需要较长内部处理时间的从机。例如 EEPROM 页写入后需要几毫秒到几十毫秒的写入时间,AT24C 系列就可能在 ACK 后拉低 SCL,表示"正在写,别催"。再比如 AS5600 这类磁编码器,内部角度计算完成后才把结果锁存到寄存器,主机读它时如果正好碰到计算窗口,也会触发延展。SSD1306 OLED 在写命令密集操作、内部 RAM 刷新与外部写操作冲突时,也会出现类似行为。
我实际调过一颗 BH1750 光照传感器,它内部测光需要几十毫秒,主机发完测量命令后立刻去读数据,大概率会撞上延展。用逻辑分析仪抓 SCL,能看到一段明显被拉长的低电平,紧接着数据位才开始正常翻转。如果主机逻辑没有延展等待能力,这段波形就会直接被判成总线错误。
3.3 硬件 I2C 与软件模拟 I2C 在延展上的分水岭
如果你用 STM32、ESP32 的硬件 I2C 外设,时钟延展通常由外设自动处理:外设会检测 SCL 是否被从机拉低,拉低期间时钟自动停摆,你几乎感知不到。但一旦换成 GPIO 软件模拟 I2C,问题就来了。
绝大多数网上流传的软件 I2C 代码,SDA 和 SCL 用的是"推挽输出 + 固定延时"的写法。这种写法根本读不到 SCL 被从机拉低的信号,因为它强制把 SCL 当输出,一旦定时器到了,就强行拉高,即使从机还在延展,总线也会进入非法状态,轻则读到错数据,重则直接卡死从机,必须断电重启。
要正确实现软件模拟 I2C 的延展支持,SCL 必须配置为开漏输出,并且在释放 SCL 后额外加一步:轮询读 SCL 引脚电平,直到它变高才认为当前位完成。高电平等待的过程,就是给从机延展留的窗口。这一步看起来是代码问题,本质上是对开漏结构和时钟延展机制的理解问题。
4. 仲裁与时钟延展的组合拳:一个总线级的分布式公平协议
单独看仲裁和延展,各自都只是一个位级机制,但它们放在同一个总线上,配合时钟同步,就让 I2C 有了一个非常罕见的整体特性:总线上的每个设备,无论主机还是从机,都只通过两根线的电平变化来协作,没有任何集中式的总线调度器,却天然实现了公平占用和节奏适配。
4.1 一次传输的完整博弈过程
我们走一遍两个主机同时向不同从机发数据的场景。设备 A 和设备 B 在同一个 bit 边界同时拉低 SCL,双方进入同一条总线节奏。由于时钟同步,SCL 的低电平取两者中更长的拉低时间。此刻如果从机 C 需要延展,它也会加入拉低 SCL 的行列,于是 A、B 和 C 一起决定这个低电平的结束时刻;所有人释放后,SCL 才升高。在 SCL 高电平期间,A 和 B 在 SDA 上逐位仲裁。假设 A 在地址第 3 位发了 0,B 发了 1,SDA 呈现 0,B 发现不一致,退出 SDA 驱动,但仍然跟随 SCL。A 继续完成后面的地址和数据位,如果 C 中途又拉延展,A 就继续等。
整套过程里,只有 A 最终"赢"到总线,B 知道自己输了并准备重试,C 成功获得自己需要的处理时间。三台设备都在没有任何中央锁、没有任何软件互斥量的条件下,完成了协作。这放在操作系统的并发编程里是一个相当高级的话题,但 I2C 用两根电线和开漏结构就解决了。
4.2 对比 SPI:为什么 SPI 学不会这套
SPI 是绝对主从结构,四根线里 CS、SCK、MOSI 全由主机控制,从机不能主动拉低 SCK。它的高速和确定性很强,但代价是一对一或一组从机必须共享同一主机,多主场景必须切换主从角色,或者引入额外的总线仲裁芯片。I2C 则把"谁是主"这个问题从协议层下沉到电气层,让物理电平自己决策。这不是谁更高级的问题,而是设计目标不同:SPI 追求确定性和吞吐,I2C 追求最少走线和多设备共存的灵活性。
4.3 为什么它配得上"最精妙的设计"
I2C 规范发布于几十年前,当时低速外设大行其道,时钟延展解决的是现实痛点;多主机仲裁则被很多工程师视为"几乎不会用到的边角功能"。但恰恰是这两个边角功能,让 I2C 在后来几乎所有 SoC、MCU 和传感器上被保留至今,甚至演变成了一些可靠性系统里"双主冗余通信"的解决方案。一条总线上挂两个主控,一个故障另一个还能接管,这在设计时不需要额外的仲裁硬件,直接用现有机制就能做到。这种"用最小物理结构解决最大并发难题"的做法,才是真正让人拍案叫绝的地方。
5. 实测链路:逻辑分析仪、ESP32/STM32 与 OLED 的真实坑
再往后写前,需要说明:网络上有大量关于 I2C、SSD1306 OLED、硬件 I2C、逻辑分析仪、ESP32/STM32 的讨论,例如"0.9寸 OLED 对 I2C 兼容问题"、"esp32 休眠 i2c 复位"、"i2c hid 该设备找不到足够资源可以使用(代码 12)"等,如果把仲裁和时钟延展的视角套进去,会发现不少问题都能找到对应的总线层根源。
5.1 先用逻辑分析仪确认延展和仲裁是否发生
排查 I2C 问题,逻辑分析仪是最直接的工具,但很多人只盯着数据帧,从不关心时序细节。正确的做法是:把采集率拉高,观察 SCL 低电平宽度是否出现过长的异常。如果 SCL 某一段低电平明显比其他位长,多半就是从机在延展。如果 SDA 上出现不完整的字节、或在同一个地址重复出现两段 START,就可能发生了仲裁。看到这些信号,就不要再去怪"外设配置错了",而应该查总线物理层:上拉电阻是否太小、设备是否有多个主机在同时初始化、从机内部处理时间是否过长。
5.2 0.9 寸 OLED 的兼容性问题根源
经常看到有人问"0.9 寸 OLED 对 I2C 兼容问题"。这类显示屏通常使用 SSD1306 驱动,I2C 地址常见 0x3C 或 0x3D,由 SA0 引脚决定。很多人反映的"时序不稳定""偶尔不显示",很多时候不是地址或命令错误,而是 OLED 内部控制器在刷 RAM 时对总线的占用处理不同。如果你用硬件 I2C,一般没问题;用软件模拟 I2C 且没做延展检测,就会在 OLED 忙时误发下一个字节,导致显示异常。解决思路是降低通信时钟、或者在每次发命令前加一小段稳定时间,但更根本的还是把 SCL 配成开漏并检测延展。
5.3 ESP32 休眠后 I2C 复位,为什么会扯上仲裁
ESP32 的 I2C 外设在休眠唤醒后,有时会进入总线忙状态,网上讨论很多的做法是先重新初始化 GPIO,再重新调用驱动,或者做一次软件复位。但从机制上看,很多情况下外设卡住在忙状态,是因为上一次传输在 SCL 延展或 SDA 仲裁过程中被休眠事件打断,总线状态机停在了一个非空闲态。单纯调用初始化函数未必能清掉挂在半空中的时序。实际操作中比较稳妥的办法是:把 SCL、SDA 两个引脚重新配置成普通 GPIO 输出,先手动拉低再释放,模拟几个 CLK 脉冲,让挂在总线上的从机退出错误状态,再重新初始化 I2C 外设。这样比反复重启芯片有效。
5.4 STM32 HAL 的 BUSY 死等和 I2C 无响应
用 STM32 的 HAL 库调 I2C,最怕的就是HAL_I2C_Master_Transmit返回HAL_BUSY。很多人最终会搬出软件复位大法:__HAL_I2C_DISABLE(hi2c)、__HAL_I2C_ENABLE(hi2c)配合I2C_CR1的 SWRST 位去恢复。这个办法有效,但更好的是先搞明白为什么会 BUSY。HAL 库的状态寄存器里记录着上次传输是否完成,如果上次传输因为从机延展拖长了时间,主机的超时设置又太短,HAL 的内部状态就不会正确清零。此时调整超时时间、正确处理HAL_I2C_ErrorCallback,比每次出错就软复位更靠谱。顺手总结一张常见故障排查表:
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| 总线卡死,SCL 一直低 | 从机在延展等待,主机未等待 | 用逻辑分析仪看 SCL 低电平宽度 |
| 主机复位后总线仍显示忙 | 上次传输在未完成状态下被中断 | 手动清 SCL/SDA 引脚状态后再初始化 |
| SDA 数据错乱 | 软件模拟 I2C 未回读 SCL | 检查 GPIO 是否配置为开漏 |
| OLED 显示闪烁/不响应 | 内部刷新期间与外部写命令冲突 | 降低速率、加延时或启用延展检测 |
5.5 上拉电阻:仲裁与延展的电气生命线
最后提一个容易被忽略的变量:上拉电阻。仲裁和延展都依赖线与结构,但线与结构的上拉电阻如果太小,低电平释放后的上升沿会太缓,设备可能采样到不确定电平;如果太大,SCL 上升沿又会过长,高速模式下时序直接超标。经验法则是:100 kHz 模式常用 4.7k 到 10k,总线上拉电容大时取更低值;400 kHz 模式常用 2.2k 到 4.7k,具体可以按上升沿时间公式估算。实际调 I2C 时,遇到数据偶发错误,先拿示波器或逻辑分析仪看 SCL/SDA 边沿,比反复改代码快得多。
我个人的体会是:I2C 的仲裁和时钟延展不是"看完懂原理就够"的知识,它们是实际调板子的救命线索。以后再遇到设备不响应,别急着换单片机、换传感器,先确认 SCL 的低电平延展是否存在,再确认 SDA 上是否有仲裁冲突的痕迹。记住一句口诀:SCL 一直低,是有设备在踩刹车;SDA 乱飘,是有人在抢线。顺着这两条线往下查,绝大多数 I2C 的疑难杂症都能找到出口。