news 2026/10/1 20:24:04

I2C总线精讲:从开漏结构看多主机仲裁与时钟延展

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
I2C总线精讲:从开漏结构看多主机仲裁与时钟延展

做嵌入式这两年,我读过最多的协议文档就是I2C,但真正让我对这套总线“肃然起敬”的,不是那两根线的时序图,而是多主机仲裁和时钟延展这两个机制。它们藏在最朴素的电气设计里,却在多设备总线上解决了我认为最难的问题:大家同时说话时,怎么保证数据不坏、不丢,还能让慢设备优雅地喊停。这篇作为系列第04讲,我把这两个机制彻底拆开讲清楚,顺便把总线挂死、OLED不兼容、EEPROM写周期这些高频坑一起串起来。如果你正在用I2C外接传感器、存储芯片,或者写过软件模拟I2C,这篇值得花十分钟看完。

1. 先搞懂为什么 I2C 不用推挽输出:开漏结构与“线与”

1.1 推挽输出在I2C上会出什么事

很多从GPIO转过来写I2C驱动的朋友,第一个疑问是:为什么SDA和SCL不能像SPI那样直接推挽输出?推挽输出的优点是驱动能力强、电平切换快,但它有一个致命问题:如果两个主机同时输出相反电平,低阻抗路径会直接把电源和地短路。即便不短路,总线电平也会被强驱动成某个确定值,那个“确定值”会让竞争方无法感知自己输了。I2C为了支持多主机,必须让总线电平是“可共享”的,于是它选择了开漏输出加上拉电阻的结构。在这个结构里,每个设备只有两种状态:“拉低”和“释放”。你不主动拉低,总线就靠外部上拉电阻回到高电平。

1.2 一根线上实现“谁拉低谁说了算”

开漏带来的第一个效果叫“线与”。意思是,只要总线上任何一个设备把线拉低,整根线就是低电平;只有当所有设备都释放时,线才会被上拉电阻拉高。你可以想象一群人共用一个开关:只要有人按下,灯就灭,所有人松开灯才亮。这种电气特性看起来简单,却是多主机仲裁和时钟延展的温床。

  • 仲裁靠“线与”:主机A发1(释放SDA),主机B发0(拉低SDA),总线电平为0。A会说“我想发1,但线上是0,我输了”。
  • 时钟延展靠“线与”:主机释放SCL后,从机只要还能把SCL拉低,主机就检测不到高电平,于是乖乖等待。

所以那两个看似高级的协议机制,底层逻辑竟然是同一个物理行为:拉低总线,表达诉求。这也是我常跟新人说的,I2C精妙不在状态机,而在电气层。

1.3 这个设计省掉了什么

如果不用“线与”,要实现多主机冲突检测,一般得加一根专门的“总线忙”信号线,或者用复杂的状态机仲裁。I2C把仲裁信息编码在数据位本身,不需要额外的握手线,也不需要主机间互相发送“我要抢总线”的消息。省掉的东西就是成本和复杂度。当你想在一个板子上挂多个MCU,两根线就够用,正是因为这个机制。

2. 多主机仲裁:边说边听的逐位裁决

2.1 两个主机同时抢总线,为什么数据不会坏

想象一个场景:板子上有两片MCU(主机A和主机B),总线上还有一片EEPROM。A想读地址0x50的从机,B想写地址0x70的从机。两个主机同时检测到总线空闲,同时拉低SDA发起START。这时竞争就开始了。I2C的仲裁方式是逐位比较:每个主机在发送一个位的同时,会监控SDA线上的实际电平。如果线上电平和自己想发送的位一致,就继续;如果不一致,说明有别人在发0,而自己发的是1,于是自己退出仲裁。

这里的关键是:在开漏结构里,发0的能力永远强于发1。所以仲裁的结果永远是“发0的那个赢”。A想发的地址是0x50,二进制是0101 0000;B想发的地址是0x70,二进制是0111 0000。两个地址最高位都是0,相安无事;但第三位A是0、B是1(因为0x50=0101,0x70=0111),在这一位上,A拉低SDA,B释放SDA却被A拉低,B发现线是0而自己想发1,立刻退出。整个过程A毫无察觉,它甚至不知道刚才有人跟它抢过总线。而B退出后也不会捣乱,它转变为接收者角色,继续接收后续时钟和数据,直到这轮传输结束。

2.2 仲裁发生在哪些位置

仲裁不是一个单独的“判定阶段”,它贯穿在每一位的传输过程中。具体有这几个位置:

  • START条件:谁先拉低SDA谁赢,后到者看到总线已经被拉低,就不会再发起START。这个仲裁是时间先后的自然结果。
  • 地址位和方向位:这是最常见的仲裁区域。从机地址不同时,往往在地址字节的前几位就分出胜负。
  • 数据位:如果两个主机地址相同、方向相同,仲裁可能一直延伸到数据字节。比如两个主机同时向同一个从机的同一个寄存器写不同数据,数据位里照样能分出胜负。
  • 重复起始条件Sr:如果主机在传输中要改变方向,需要先发Sr。协议规定,只有仲裁获胜的主机才有资格产生Sr,输家看到SDA上的电平变化后,不会再继续。

2.3 仲裁失败后主机会怎样

很多初写I2C驱动的人会以为,失败的主机要“重发”一次。实际上I2C里没有像以太网那样的冲突后重传机制。仲裁失败的主机只是不再驱动SDA,但它的移位寄存器还继续接收总线上的数据,目的就是让自己保持“在线”,等待这轮传输结束,顺便看看总线何时释放。这个行为和CAN协议里“错误帧后重发”的思路完全不同。I2C的仲裁过程对从机来说是透明的,从机从头到尾只看到一次干净完整的传输,它根本不知道有多个主机竞争过。这也是为什么仲裁不会丢数据、不会产生垃圾帧。

2.4 时钟延展会让仲裁“暂停”

仲裁过程中有个细节经常被忽略:主机在SCL高电平期间采样SDA,但如果这时某个从机(或者时钟延展中的慢速设备)把SCL拉低了,那整个仲裁过程就会暂停。也就是说,在等待SCL恢复高电平之前,所有主机都停在原地,SDA上的仲裁位也会被保持住。这带来一个实际问题:调试多主机系统时,如果逻辑分析仪抓到SCL上很宽的“低电平”,不要只想到时钟延展,它也可能是在仲裁状态下被延展卡住的暂停点。区分方法很简单,看SDA在那个宽低电平期间是否稳定;稳定说明是正常的从机延展,不稳定则大概率是竞争后的残余状态。

3. 时钟延展:从机手里握着的那颗“暂停键”

3.1 从机为什么要暂停主机

I2C是同步串行协议,时钟由主机产生,从机没有独立的时钟输出。但问题是:从机不是每时每刻都能跟上主机的节奏。经典的例子是EEPROM(比如AT24C32)的写周期。你写完一个字节后,芯片内部需要3到10毫秒把数据烧进存储单元,在这期间你发任何命令它都不响应。这时从机有两种处理方式:要么不理会主机(表现为数据丢失或NACK),要么通过时钟延展告诉主机“等我写完你再继续”。第二种方式显然更优雅。

类似的场景还有:传感器内部正在做ADC转换、数据寄存器还没更新;软件模拟的从机正在处理别的中断,来不及在第九个时钟前准备好ACK;外设的DMA缓冲区还没填满,硬件自动拉低SCL。可以说,时钟延展就是I2C的“流量控制”。

3.2 从机怎么拉低SCL,主机怎么发现

注意一个容易混淆的点:SCL和SDA一样是开漏结构,所以不仅主机能拉低SCL,从机也能拉低它。主机在产生每一个时钟脉冲的低电平后,会释放SCL,让上拉电阻把它拉高。就在SCL刚要变高的时候,从机可以主动把SCL拉低。主机下一次想继续发时钟之前,会去检测SCL是不是高电平;发现还是低,就知道从机在要时间,于是等待。直到从机释放SCL,主机才继续发下一个脉冲。

我用一个实际场景来类比:你在跟人打电话提问,对方说“等我翻一下资料”,然后不说话。你不能自己继续往下念,只能听着,等他说“好了”再继续。I2C的听筒就是SCL线,从机不说话时就把SCL按死,你一检测到按死,就闭嘴等着。

3.3 不同主机对时钟延展的“天生”支持度天差地别

这部分是实操里最容易踩坑的地方:

  • 纯软件模拟(bit-bang)的实现:天然支持时钟延展。因为软件I2C的每一个SCL高电平,都需要循环读回SCL引脚确认确实为高之后才会继续。如果从机延展,软件会一直卡在等待循环里。这样的实现不会漏掉从机的任何请求,代价是你要写好超时保护,否则从机死掉,软件主循环也一起死。
  • STM32的硬件I2C外设:绝大多数情况能处理时钟延展,但HAL库里的等待函数是有超时的。总线延展时间超过超时阈值,HAL层会返回错误,不会真的无限等下去。
  • 树莓派的BCM2835 I2C控制器:支持时钟延展,但有些第三方库在配置时没开相关选项,实际跑起来会丢数据。
  • CH341这类USB转I2C适配器:这是个重灾区。部分型号的硬件状态机对从机时钟延展的支持很差,遇到EEPROM写周期时直接丢字节。我第一次用CH341调AT24C02时,以为芯片坏了,换了一片又一片,最后用逻辑分析仪才发现是适配器不支持延展。后来我干脆在这种场景下改用USB转串口+软件模拟I2C的方案,反而稳了。
  • 逻辑分析仪的解码器:注意区分“从机延展SCL”和“总线死锁”。有些逻辑分析仪的I2C解码器会把长达几毫秒的SCL低电平解释成错误,其实是解码器没配置容忍延展,不是协议错。

3.4 时钟延展与ACK的关系

第九个时钟脉冲是ACK位。正常情况下,从机在第九个脉冲时拉低SDA表示“我收到了”。如果从机没准备好,它可以在第九个脉冲时既拉低SDA表示ACK,也可以同时把SCL拉低表示延展。更常见的是:从机在ACK位之前就拉低SCL,一直保持到它准备好为止。这个过程里SDA的状态要稳定,否则主机可能把SDA上的不确定电平误判成响应。所以协议规定:在SCL延展期间,SDA线上的数据不能变化。

4. 实操:软件模拟 I2C 里实现仲裁与时钟延展

4.1 最小bit-bang框架应该怎么写

我更喜欢在MCU上用GPIO模拟I2C,不是因为硬件外设不好,而是软件模拟能精确控制每一位,出了问题也更好排查。下面是一个精简的框架,关键点都在注释里:

#define I2C_SDA_H() gpio_set_level(SDA_GPIO, 1) #define I2C_SDA_L() gpio_set_level(SDA_GPIO, 0) #define I2C_SCL_H() gpio_set_level(SCL_GPIO, 1) #define I2C_SCL_L() gpio_set_level(SCL_GPIO, 0) #define I2C_SDA_READ() gpio_get_level(SDA_GPIO) #define I2C_SCL_READ() gpio_get_level(SCL_GPIO) // 释放SDA:设为输入模式,相当于开路 #define I2C_SDA_RELEASE() gpio_set_direction(SDA_GPIO, GPIO_MODE_INPUT) // 拉低SDA:设为输出模式且输出低 #define I2C_SDA_DRIVE_LOW() do { \ gpio_set_direction(SDA_GPIO, GPIO_MODE_OUTPUT); \ gpio_set_level(SDA_GPIO, 0); \ } while (0) static int i2c_arbitration_lost = 0; // 发送一个bit,同时做仲裁检测 static void i2c_send_bit(int bit) { if (bit) { I2C_SDA_RELEASE(); // 发送1 = 释放SDA } else { I2C_SDA_DRIVE_LOW(); // 发送0 = 拉低SDA } i2c_delay_us(1); // 等待从机/低速设备释放SCL(时钟延展) I2C_SCL_H(); int timeout = 10000; while ((I2C_SCL_READ() == 0) && (--timeout > 0)) { i2c_delay_us(1); } if (timeout == 0) { i2c_bus_stuck = 1; // SCL被拉死,需要复位 return; } // 仲裁检测:读SDA,看线上电平和自己想发的是否一致 if (bit == 1 && I2C_SDA_READ() == 0) { i2c_arbitration_lost = 1; // 我发1线上是0,我输了 } I2C_SCL_L(); i2c_delay_us(1); }

这个函数就是整个软件模拟I2C的核心。你会看到,只要把SCL拉高后加入“读回SCL直到为高”的等待,天然就支持了时钟延展。只要在发送1时读回SDA做比对,天然就支持了仲裁检测。这两个机制加起来不到20行代码,但解决了多主机I2C里最难的问题。

4.2 如何启动一次带仲裁检测的传输

发送START之后,接着发地址和方向位。每发一位都要检查仲裁标志:

int i2c_start_and_send_addr(uint8_t addr_with_dir) { // 空闲检测 I2C_SDA_RELEASE(); I2C_SCL_H(); if (I2C_SDA_READ() == 0) { return I2C_BUS_BUSY; } I2C_SDA_L(); // START: SDA先拉低 i2c_delay_us(1); I2C_SCL_L(); // 然后再拉低SCL i2c_delay_us(2); i2c_arbitration_lost = 0; for (int i = 7; i >= 0; i--) { i2c_send_bit((addr_with_dir >> i) & 1); if (i2c_arbitration_lost) { // 仲裁失败后不要再发数据,转为接收模式 I2C_SDA_RELEASE(); // 继续接收直到STOP return I2C_ARBITRATION_LOST; } } // 第9个时钟:读取ACK I2C_SDA_RELEASE(); I2C_SCL_H(); int timeout = 10000; while ((I2C_SCL_READ() == 0) && (--timeout > 0)); int ack = (I2C_SDA_READ() == 0); I2C_SCL_L(); return ack ? I2C_ACK : I2C_NACK; }

仲裁失败后最忌讳的是继续拉低SDA。输家必须立刻释放SDA,然后跟着总线的时钟继续“听”,直到辨认出STOP条件。如果输家还强行拉低SDA,就会把总线搞死,而且破坏赢家正在进行的传输。这一点我在早期写代码时踩过坑,现在所有发送函数都会在仲裁失败分支里加一行I2C_SDA_RELEASE()。

4.3 逻辑分析仪上怎么认出仲裁和时钟延展

抓I2C波形时,时钟延展最好认:SCL上会出现一个宽度异常的低电平,可能是正常的百纳秒级别的几倍甚至几十倍。如果软件里设置了超时,你会看到“等待超时”后SCL被主机强制操作,但总线上还留着从机的低电平残留。仲裁的波形就比较隐蔽:当两个主机抢总线时,SDA上会出现一个短暂的、不属于任何正常数据帧的电平跳变,随后又恢复正常。用逻辑分析仪自带的I2C解码器去解,可能会报“地址错误”或“数据错误”,那是因为解码器看到了两个主机位交错的痕迹。我建议抓多主机竞争时,不用急着看解码结果,先看原始的SDA/SCL波形,找“不符合正常时序的位”,那通常就是仲裁现场。

4.4 这几个热词背后的常见问题串讲

顺着搜索记录里出现的高频问题,我补充几个实战结论,都是我在项目里验证过的:

  • 0.9寸OLED(SSD1306)的I2C兼容问题:大多数时候不是协议不兼容,而是地址搞错。0x3C是常见的7位地址,写地址要左移一位变成0x78。还有一种情况是模块上已经自带上拉电阻,你在主板上再并联一颗,总上拉阻值过小,低电平拉不下去,表现为通信偶发失败。遇到这类问题,先量一下SDA和SCL对地的静态电平。
  • I2C读写EEPROM的Verilog代码:FPGA里写EEPROM控制器,核心就是一个状态机:IDLE、START、地址、数据、ACK、STOP。如果你要把仲裁和时钟延展做进去,别忘了在SCL生成模块里加一个“SCL输入检测”端口,在SCL高电平被从机拉低时冻结状态机。
  • ESP32休眠后I2C复位:深度睡眠唤醒后,外设寄存器不保证还是之前的状态。我遇到过唤醒后SDA卡在低电平,就是因为I2C外设状态机没有重新初始化。解决办法是唤醒后调用一次i2c_driver_delete再i2c_driver_install,让外设彻底复位。
  • BH1750这类传感器:上电后需要稳定时间,测量模式下有转换周期,很多开发者误用“连续读”导致超时。这时如果你把读寄存器操作放在转换周期内,I2C可能一直NACK,不是总线坏了,是传感器还没准备好。

5. 常见问题与排查技巧实录

5.1 “总线挂死”的三层解读

I2C调试里最经典的现象就是总线挂死,SDA和SCL卡在低电平。我见过很多人一上来就拉高上拉电阻,或者干脆换芯片。先别急,挂死也有三种情况:

  • SCL被拉低:这可能是从机在做时钟延展。用示波器看SCL是不是一条直线,如果是,大概率是主机超时到了,但从机还在延展。此时应该查从机的状态,而不是复位总线。
  • SDA被从机拉低:常见原因是从机收到了错误的地址或命令后进入了异常状态,锁住了SDA。这种情况要等从机超时或给它一个STOP条件,有时必须断电。
  • SDA被主机拉低:通常是主机软件状态机跑了一半,比如发送START后死循环,没有继续产生SCL。复位主机的I2C外设即可。

我排查挂死问题的顺序是:万用表测SDA/SCL静态电平,然后用逻辑分析仪抓最后一笔传输,分析是停在哪个阶段。九成的挂死都能靠这一步定位。

5.2 上拉电阻阻值不是随便选的

I2C规范里对上升沿有要求,RC时间常数决定波形能不能在SCL高电平采样窗口内稳定。阻值过大,总线电容充电慢,上升沿变缓,高速模式下直接出错;阻值过小,灌电流太大,芯片的IO可能承受不住。我在项目里的经验值:

  • 100kHz标准模式,总线上挂3到5个设备,用4.7k到10k比较稳。
  • 400kHz快速模式,建议2.2k到4.7k。
  • 1MHz快速模式+,建议1k到2.2k。

如果板子上多个模块自带不同上拉,计算并联值时要按最终并联阻值算。之前调试一个板子,三个模块都有4.7k上拉,并联后等效约1.6k,在100kHz下偏强,导致低电平驱动太吃力,偶发NACK。把模块自带上拉拆掉,只在主机主板上保留一颗4.7k,问题消失。

5.3 多主机I2C设计的几个忠告

真正把I2C跑成多主机的产品不常见,但一旦你做了,就要注意几件事:

  • 每个主机软件里必须实现仲裁失败后释放SDA的逻辑。很多人只写了发送,没写“失败后继续接收”,这个坑会让总线在仲裁后持续被错误驱动。
  • 上拉电阻全板只放一组,别每个主机都加。多主机环境下,上拉过多会让总线强拉能力不足。
  • 时钟延展超时时间要设置成“最慢设备最坏情况 + 余量”。我习惯设为10ms以上,因为有些EEPROM写周期极限值就是10ms。
  • 多主机抢总线的概率和数据量是成正比的,如果能预分配通信时间片,就不要硬靠仲裁。仲裁是兜底机制,不是常态设计。

5.4 问题速查表

现象可能原因建议处理
SCL卡低且是直线从机时钟延展检查从机状态,延长主机超时
SDA一直为低从机锁死或主机半状态发STOP,必要时复位设备
偶发NACK上拉过强/过弱,时序不达标重新计算上拉,用示波器看上升沿
两个主机交互时数据错乱仲裁失败后没释放SDA加上“失败转接收”代码
逻辑分析仪解码报错时钟延展未配置调整解码器延展容忍参数
休眠唤醒后通信失败外设状态机残留重新初始化I2C外设

6. 写到最后想说的

我在实际项目里反复验证过一句话:I2C的所有高级特性,都能从“开漏+上拉”这两个词推出来。多主机仲裁是“线与”在数据位上的自然结果,时钟延展是“线与”在时钟线上的自然结果。理解了这个底层逻辑,再去读芯片手册上的时序图,很多奇怪的现象都能解释通。最后分享一个小习惯:我写软件模拟I2C时,把所有“发送1”都写成“释放SDA并读回确认”,把“SCL拉高”都写成“拉高后等它确实为高”。这个习惯让我在后来遇到任何奇怪的总线问题时,都不用怀疑协议本身的机制,直接就能定位是电气问题还是状态机问题。如果你刚接触I2C,不如也从这个角度再看一眼你手里的驱动代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 20:23:27

OpenCode Windows 安装指南:用 TaoToken 统一 Key 打通 Node.js 与 npm 配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 20:22:58

四旋翼PID调参难?用粒子群优化实现轨迹跟踪自动整定

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 20:22:31

Linux进程管理本质:ps与kill背后的内核机制与信号语义

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 20:20:37

什么是本体语义网络AI智询?

本体语义网络AI智询,指构建在"本体语义网络"之上的智能问数能力:管理者用一句自然语言直接问业务问题,系统沿着预先建好的语义网络定位业务对象、按统一口径取数,给出"每个数字来龙去脉可查"的确定性答案。目…

作者头像 李华
网站建设 2026/10/1 20:20:07

深入理解U-Boot的SPL与TPL:源码、启动流程与调试实践

搞嵌入式Linux的,尤其是玩过裸机或者做过ARM平台Bring-up的朋友,几乎都会在U-Boot这个“最后一道bootloader”上花不少时间。但很多人一开始接触U-Boot,就被SPL和TPL这两个名字绕晕了:明明是两级加载器,为什么有的平台…

作者头像 李华