ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

I2C多主机仲裁与时钟延展:原理、机制与调试实践

I2C多主机仲裁与时钟延展:原理、机制与调试实践 1. 为什么说多主机仲裁是 I2C 区别于 SPI/UART 的分水岭很多工程师第一次接触 I2C 时最先记住的是起始条件、停止条件、设备地址、ACK/NACK 这些帧格式层面的东西。等真正把两个主控接到同一组 SDA/SCL 上开始调试才会意识到 I2C 和 SPI/UART 有一个本质区别I2C 在物理层就把“多个设备同时说话”这个问题解决了而且解决得极其优雅。这一讲要拆解的多主机仲裁就是这个问题的核心。1.1 开放漏极与“线与逻辑”是仲裁的地基要理解多主机仲裁必须先理解 I2C 的物理层。I2C 的 SDA 和 SCL 都是开漏输出外部接上拉电阻。所谓开漏就是芯片内部的晶体管只能把引脚拉低到地不能主动把引脚推高到电源。要输出高电平芯片就把内部管子关断让外部上拉电阻把线拉高。这个看似“半残”的输出结构恰恰带来了两个关键优势第一多个设备可以安全地把引脚并联在同一根线上因为谁都不具备“强行灌电流把别人拉高”的能力第二总线自然形成了“线与逻辑”——只要有一个设备输出低电平整根线就是低电平只有在所有设备都释放总线时总线才会被上拉成高电平。这句话值得反复读几遍线上任何一个 0都有能力覆盖所有其他设备发出的 1。多主机仲裁就是建立在这个物理事实上的。两个主机同时向总线上发数据时它们发出的位会在 SDA 上做逻辑“与”而仲裁规则非常简单谁先写 0谁就获得总线。我见过不少初学者在这里犯晕总觉得仲裁应该靠某种“主机优先级寄存器”或“仲裁控制器”来实现。实际上 I2C 没有中央仲裁器它把仲裁机制直接做进了电气层面。你不需要额外的仲裁芯片不需要外加总线请求信号甚至不需要主机之间互相通信两根线加两个上拉电阻就完成了“谁先说话谁先赢”的判定。1.2 位级别的仲裁过程谁先写 0谁拿总线假设主机 A 和主机 B 在同一时刻检测到总线空闲同时发出起始条件。起始条件本身是 SDA 在 SCL 高电平期间由高到低跳变因为两个主机都拉低了 SDA所以这个阶段谁也赢不了谁两边都认为自己成功启动了总线。真正的较量从地址位的传输开始。I2C 的规定是SCL 为高电平期间 SDA 必须保持稳定数据采样都发生在 SCL 高电平期间。两位主机在每一个时钟高电平阶段发送自己的地址位同时读取 SDA 上的实际电平。如果主机 A 在这个位发送 1主机 B 发送 0那么在 SCL 高电平期间SDA 会被主机 B 拉低。主机 A 期望看到高电平实际读到的却是低电平于是主机 A 知道自己仲裁失败了。它必须立刻停止驱动 SDA转为从机接收模式等待当前这次传输结束。主机 B 则完全感知不到任何异常它读到的电平和自己发送的一模一样于是继续发送后续地址位直到完成整个事务。整个过程可以用一句口诀概括低电平优先先写 0 者胜。如果一个主机发送的地址是 0b1001000另一个发送 0b0001000那么在第一个地址位就会分出胜负后者胜出。如果两个主机发送的地址完全相同仲裁会自动延续到 R/W 位如果 R/W 位也相同仲裁可能进一步延续到数据字节。不过在实际系统里数据阶段的仲裁很少被刻意使用。因为两个主机如果在数据阶段发送不同的数据总线上的数据流已经完全错乱收到数据的从机大概率会把这一帧当作坏帧丢弃。更稳妥的做法是让系统里的每个主机在不同时间段发起不同设备的访问或者让从机地址本身就区分开业务。仲裁机制的真正价值是保证“两个主机同时抢总线”时系统不会崩溃而不是鼓励你让两个主机同时往一个从机写不同数据。1.3 仲裁失败之后控制器要做什么仲裁失败后的行为是很多新手写代码容易出错的地方。仲裁失败不等于错误它只是一个正常的退避信号。失败的主机必须做到两点第一立刻停止驱动 SDA。因为你正在发送 1 而线被拉低继续驱动 SDA 就是和获胜主机抢线轻则造成数据错误重则让总线处于不确定状态。第二不能立刻发送停止条件。很多初学者觉得“我输了那我发个 STOP 结束吧”这是错误的。失败的主机如果发 STOP就等于打断了获胜主机正在进行的事务会让从机和获胜主机都陷入混乱。正确的做法是保持静默让获胜主机完成自己的传输然后等到总线重新空闲再根据业务逻辑决定是否重试。如果你的 MCU 用的是硬件 I2C 外设仲裁逻辑通常已经由外设自动处理了。控制器检测到仲裁失败后会置一个仲裁丢失标志位并触发中断。你需要做的只是在错误处理函数里判断这个标志然后复位一下发送状态机准备下一次重试。以 STM32 的 HAL 库为例HAL_I2C_ERrorCallback里处理HAL_I2C_ERROR_ARLO时不要一上来就大张旗鼓地重新初始化整个外设先做一次 soft reset再根据重试计数决定是否放弃。真正的多主机系统里仲裁丢失是一个可以接受的常态不是硬件故障。2. 时钟延展从机用一根 SCL 做“流控”的巧妙手段如果说仲裁解决的是“多个主机竞争同一根总线”的横向问题那么时钟延展解决的则是“主机和从机速度不匹配”的纵向问题。在 SPI 里从机如果处理不过来要么靠硬件上多加一根 BUSY 引脚要么靠主机硬等固定延时。I2C 则把流控能力直接编进了时钟线本身。2.1 从机为什么需要延展I2C 从机并不总能在一个时钟周期内完成所有内部工作。最典型的场景是从机刚刚收到主机的读命令需要启动一次 ADC 转换或者需要把内部 FIFO 里的数据整理好才能发出来也可能是从机刚写完一页 EEPROM内部还需要时间擦除/写入。这时候从机怎么告诉主机“你先等一会儿”SPI 从机做不到UART 从机也做不到I2C 从机却可以它只要把 SCL 拉低主机就不得不停下来。因为 SCL 同样是开漏结构主机想给出高电平时只是释放了 SCL如果从机此时正拉着 SCL 不放总线上的 SCL 就一直是低电平。主机侧要么是硬件 I2C 外设检测到 SCL 不是预期的高电平而自动挂起要么是软件模拟 I2C 时你写的读 GPIO 代码会一直等到 SCL 变高。无论哪条路效果都是主机时钟被“暂停”。这种机制的精妙之处在于它不需要额外引脚不需要预先约定等待时长从机想延多久就延多久主机只需要无条件等待。所以从机的读时序里根本没有“主机给从机留处理时间”这个负担一切交给 SCL 上的物理拉低动作。2.2 时钟延展到底怎么发生在物理线上让我们把一次典型的读字节过程拆开看。主机驱动 SCL 拉低这是每个时钟周期的前半段然后主机释放 SCL想让 SCL 回到高电平这是每个时钟周期的后半段。正常情况下SCL 会在一两个上拉电阻的时间常数内迅速爬升到高电平。如果从机在某个时刻拉了 SCL主机释放 SCL 后就会发现哎SCL 怎么没高于是主机不会启动下一个时钟周期而是进入等待状态。从机内部忙完后把 SCL 释放SCL 被上拉电阻拉高主机的等待条件解除继续打出下一个时钟。这里要特别强调一个容易踩坑的点SCL 的开漏结构决定了主机不能“硬拉” SCL。有的工程师在软件模拟 I2C 时把 GPIO 配成推挽输出想直接输出高电平把 SCL 顶上去这在星型连接的单主机场景下或许能跑通但在真正有多主机或者从机会延展的总线上会出大问题。推挽输出强行拉高会直接和从机的拉低行为对抗轻则产生毛刺重则可能导致总线闩锁甚至器件损伤。正确写法永远是“先释放引脚再读引脚”而不是“输出高电平”。2.3 主机和软件应该如何处理延展中的等待对硬件 I2C 控制器来说时钟延展大部分是透明的。外设会在硬件状态机里等待 SCL 变高你只会在示波器上看到一段明显拉长的低电平而代码层面几乎感觉不到。对软件模拟 I2C 来说处理延展就必须非常显式。发送完一位数据后你释放 SCL 等待它变高然后引脚一直读不到高电平此时不能简单地继续跳下一个周期必须做一个带超时的循环。我在自己写的 bit-bang 驱动里通常这样处理int i2c_wait_scl_high(uint32_t timeout_us) { uint32_t t 0; i2c_scl_release(); // 释放 SCL让上拉电阻去拉高 while (i2c_scl_read() 0) { // 从机可能正在延展 if (t timeout_us) { return I2C_ERR_STRETCH_TIMEOUT; } delay_us(1); } return 0; }这里的 timeout 取值很有讲究。I2C 规范本身没有强制规定延展上限但 SMBus 规范里有一条超时约定要求从机拉低 SCL 的时间不能超过 25ms 到 35ms 这个量级。普通 I2C 设备常见的延展时长一般在几十微秒到几毫秒之间所以我把软件超时设成 10ms 起步遇到特殊传感器再根据数据手册调大。超时设计的重要性在于防止死锁。如果某个从机因为程序跑飞或者上电异常把 SCL 一直拉低不放主机没有超时就永远卡死在等待循环里整个系统就“脑死亡”了。加上超时后至少整个 driver 还能返回错误让上层任务有机会做恢复动作。3. 两个机制合体之后一次多主机读操作的完整推演仲裁负责“谁来说”时钟延展负责“说之前等一等”。这两个机制单独看都很漂亮真正让我觉得“I2C 设计很精”的是它们在同一根总线上配合时产生的默契。3.1 仲裁和延展在时间上如何交错我在一块板子上搭过这样一个系统主板 MCU 和协处理器共享一条 I2C 总线总线上挂了一个带内部测量的传感器。协处理器会周期性读取传感器数据主板有时候也会发起读取两边完全靠 I2C 仲裁决定谁先访问。某一时刻两个主机几乎同时判断总线空闲同时产生 START 条件。由于 START 是 SDA 下降沿两边拉低 SDA 的动作不会打架。接下来在地址阶段主机 A 的第一个地址位发了 1主机 B 发了 0眼睛能看到的总线电平是 0所以 A 仲裁失败退到后台B 赢得总线。这时候 B 继续向传感器发送读命令。传感器在 ACK 位之后需要一段时间准备数据于是它拉低了 SCL。B 检测到 SCL 没有变成高电平进入等待。此时已经仲裁失败的主机 A 会不会趁机重新启动一次传输不会。因为 START/STOP 条件都要求 SCL 处于高电平现在 SCL 被从机锁在低电平A 的起始条件根本没有合法窗口可以插入。时钟延展不仅暂停了获胜主机也顺带挡住了所有其他潜在主机想要插队的路。从机的数据准备好后释放 SCLB 继续产生时钟完成读取最后发 STOP。总线恢复空闲A 在下一个随机时刻再发起自己的访问。整个过程没有一个中央调度器没有总线冲突也没有一次额外的握手纯粹靠引脚电平就把秩序维持住了。3.2 从 EEPROM 读到带延展传感器的真实波形节奏实际调试时你可以用逻辑分析仪把上面这段推演录下来。节奏通常是这样的总线空闲期 SDA/SCL 都是高然后 SDA 先下降产生 START后面跟着一串地址位和 R/W 位ACK 位置 SDA 被从机拉低紧接着如果从机进入内部处理状态SCL 会停在低电平很久。这个“很久”可能是一根完整数据的 5 到 10 倍时钟周期在波形图上非常明显。同样是用逻辑分析仪看 EEPROM又是另一幅画面。EEPROM 在接受写数据后通常不是用时钟延展而是用 NACK 来告知主机“我正在写暂时不接受新命令”。所以你会看到 ACK 位保持高电平也就是从机不回 ACK。有些工程师会把这种 NACK 误读成时钟延展其实二者物理表现完全不同延展是 SCL 低电平被拉长NACK 是 SCL 正常翻转但 SDA 在第 9 个时钟保持高。我调一块带 I2C 接口的磁编码器时也遇到过和热词里 AS5600 类似的情况直接用硬件 I2C 读角度配置没问题但就是偶发读不到数据。最后用逻辑分析仪一抓发现根本不是地址问题而是总线上另一个传感器在每帧后段做时钟延展读编码器的时序被无端“叠加”了一段等待。处理办法不是改编码器时序而是把延展超时的判断标准化让驱动对延展有统一容忍度。3.3 边界情况从机延展期间新主机能不能插入上面已经提到了“不能”但值得再展开说一层。I2C 的仲裁不仅仅发生在数据位上它还包括时钟同步。由于 SCL 也是线与结构多个主机同时驱动 SCL 时总线上的 SCL 低电平长度会是所有主机里最长的那个高电平长度会是所有主机里最短的那个。这保证了参与仲裁的主机在每一位上看到的时钟边沿是基本对齐的不会因为你用 100kHz、我用 400kHz 就出现相位错乱。这个特性反过来也说明从机延展期间整条总线的 SCL 被锁在低电平对所有主机都是同一个状态。新主机即使认为总线“不忙”也没法在 SCL 低电平期间合法地产生 START。所以从机只要延展一次就等于向总线上所有潜在发起者广播了一个全局暂停信号。边界情况是如果从机延展时间超过了主机的等待超时硬件或软件就可能产生超时错误。这时主机回到错误处理流程但要小心不能立刻发 STOP。因为 STOP 同样需要 SCL 高电平而 SCL 还在被从机拉着。强行发 STOP 只会产生一个不合法的总线状态。正确的恢复顺序是先判断 SCL 是否已经被释放如果仍然为低再做总线清空操作而不是直接执行业务重试逻辑。4. 调试多主机和延展问题时的关键观察方法这一节聊点实操经验。真正到了调试台上仲裁和延展相关的问题很容易被误诊因为它们的表现时常和“从机不响应”“总线卡死”非常相似。4.1 别把仲裁丢失当成通信故障如果你在系统里开了多主机而代码看到“Arbitration Lost”就断定是硬件问题那多半会白折腾。只要两个主机有可能在同一个时间窗口发起传输仲裁丢失就会以一定概率出现。概率高不一定代表总线坏了可能只是两边发起传输的节拍太接近或者其中一个主机的重试太激进。正确的处理方式是把仲裁丢失看成“这次我让路下次再试”。我在项目里一般这么写错误处理if (error I2C_ARBITRATION_LOST) { i2c_soft_reset(); if (retry_count-- 0) { i2c_retry_request 1; // 让上层安排下一次重试 } }重试之间最好加一点随机退避比如几个毫秒到几十毫秒的时间窗。如果没有随机退避两个主机可能每次都在同一时刻重试形成“锁死式碰撞”那仲裁丢失率就会居高不下反而看起来像故障。如果你的系统严格上只有一个主机那仲裁丢失几乎不该出现。一旦出现就要优先查 SCL/SDA 硬件连接尤其是确认是否有两个主控的引脚都配成了推挽输出。推挽输出在开漏总线上是最常见的仲裁误报来源。4.2 用逻辑分析仪区分“NACK”和“时钟被拉低”逻辑分析仪是抓 I2C 最顺手的工具但采样率要够。跑 400kHz 快速模式时建议采样率至少 4MHz 到 8MHz不然 SCL 高电平的窄脉冲会被漏掉延展段也可能被错误解码成超长时钟周期。抓到波形后先看 ACK 位置。正常 ACK第 9 个 SCL 高电平期间 SDA 是低。NACK第 9 个 SCL 高电平期间 SDA 保持高。时钟延展第 9 个 SCL 下降沿之后SCL 并没有按预期上升而是继续维持低电平一段时间。很多逻辑分析仪软件在解码时会把延展当作正常的“长低电平时钟”不会报错。所以你不能只等解码结果要自己看波形。如果某个字节后 SCL 低电平的宽度明显是其它时钟的几倍那么从机确实延展了。如果 SCL 一直低不恢复那就要考虑从机挂死或者总线闩锁。示波器则适合看另一个信号SCL 释放后的上升沿。如果上升沿斜率非常缓像是慢慢爬上去的可能不是延展问题而是上拉电阻选太大、总线电容太重。延展是“从机主动按住不松手”释放后上升沿依然正常弱上拉是“没人按着但爬得太慢”。这两个现象在波形的上升沿部分很容易区分。4.3 总线卡死时的标准恢复流程如果你发现 SDA 被拉低后一直不恢复而 SCL 还能正常翻转大概率是有设备停在了数据位中间。I2C 规范建议了一种“总线清零”做法主机在 SDA 保持释放的状态下对 SCL 连续产生最多 9 个脉冲。这个动作会让卡在某个内部状态的从机完成当前字节的剩余时钟最终释放 SDA。我在软件模拟驱动里预留了这样一个函数void i2c_bus_clear(void) { i2c_sda_release(); for (int i 0; i 9; i) { i2c_scl_release(); delay_us(2); i2c_scl_drive_low(); delay_us(2); } i2c_scl_release(); if (i2c_sda_read() ! 1) { // 9 个脉冲后 SDA 仍然为低说明有设备物理故障 } }注意执行 bus clear 之前必须确认自己已经放弃当前事务而且最好让所有上层的重试逻辑暂停。另外9 个脉冲只是让卡住的那一字节走完不等同于发 STOP。走完 bus clear 后再发一次 START 来复位整个总线状态机会更稳。我用这套流程处理过不少“I2C 偶发挂死”的问题。每次清完总线后重新初始化通信就能恢复。但如果你发现 bus clear 后 SDA 依旧为低那就不是软件问题了优先查从机的电源、复位和外部上拉电阻阻值。有些触摸屏控制器比如 GT911 之所以“I2C 通信失败”根因往往是主机没有按它的上电时序给出 INT/RST 握手把它的 I2C 状态机弄乱了总线清零也救不回来只能断电重启。5. 软件模拟与硬件控制器两种实现方式的差异和坑聊完原理和调试方法最后落地到代码。多主机仲裁和时钟延展在 bit-bang 软件模拟和硬件 I2C 控制器里的处理方式差异很大这里分享一些我在这两种路径上踩过的坑。5.1 bit-bang 如何把仲裁写成代码软件模拟 I2C 最大的优势就是每一根引脚的释放/拉低动作都受你控制因此仲裁逻辑可以写得很直白。发送一个数据位的函数最核心的部分不是“把 SDA 设成目标电平”而是“发送 1 时必须释放 SDA”。int i2c_send_bit(int bit) { if (bit) { i2c_sda_input(); // 释放 SDA让上拉电阻拉高模拟发送 1 } else { i2c_sda_output(0); // 主动拉低模拟发送 0 } i2c_scl_release(); // 释放 SCL等待它变高 uint32_t t 0; while (i2c_scl_read() 0) { // 同时处理从机时钟延展 if (t STRETCH_TIMEOUT_US) return I2C_ERR_STRETCH_TIMEOUT; delay_us(1); } int line i2c_sda_read(); // SCL 高电平期间采样 SDA if (line ! bit) { // 我发 1 却读到 0说明仲裁失败 arbitration_lost 1; } i2c_scl_output(0); // 拉低 SCL完成这一位 return arbitration_lost ? 0 : 1; }这里最容易被忽略的就是i2c_sda_input()这个动作。很多初学者写 bit-bang 时会写i2c_sda_output(1)如果 GPIO 是推挽输出这个操作会直接把 SDA 顶到高电平和总线上其它设备拉低的动作形成硬对抗。正确的姿势是发送 1 就是把引脚配置成输入、由上拉电阻来决定电平发送 0 才是配置成输出并输出低。仲裁失败的判断也必须在 SCL 高电平期间做。因为 I2C 规定 SDA 在 SCL 低电平期间变化是允许的只有 SCL 高电平期间的 SDA 电平才是有效数据。如果你在 SCL 刚拉低的瞬间去采样 SDA很可能读到的是别人刚切换的电平从而误判仲裁失败。5.2 等待延展的超时设计硬件 I2C 外设里延展等待通常不需要你写循环但软件模拟就一定要把超时放在显眼位置。我见过不少人的 bit-bang 驱动里延展等待写成了死循环// 错误示范没有超时从机死了整机跟着死 while (SCL_READ() 0);在单主机、从机可靠的场景里这个写法可能跑很久都没事。但哪怕只加一台会偶尔延展的传感器一旦那传感器进入异常状态SCL 低电平就会把整个主循环卡死。所以超时不但要有; 位置也要对。我习惯在以下三个位置统一调用延展等待发送 START 之前、发送每一位之前、发送 STOP 之前。因为在多主机总线里SCL 可能被任何设备拉低不只是目标从机。超时之后的处理同样要小心。超时只代表“我放弃了等待”并不代表从机已经释放 SCL。如果你在 SCL 仍为低电平的状态下直接进入重试重试时发出的 START 会因为 SCL 低而不合法反而把总线状态搞得更乱。所以我一般会先调用上一节提到的i2c_bus_clear()确认 SCL 和 SDA 都已经恢复到高电平再重新发起 START。5.3 硬件 I2C 控制器为什么不总是省心硬件 I2C 控制器帮我省了很多事但也埋过不少雷。第一类问题是控制器对仲裁的处理不一致。有些低端 MCU 的 I2C 外设根本不支持多主机仲裁仲裁丢失标志位可能永远不置位总线冲突发生时它只会返回一个莫名其妙的 Bus Error调试起来非常迷惑。做新项目前最好先查芯片手册里 I2C 外设是否包含“Arbitration Lost”中断源和“Clock Synchronization”机制没有的话就老实走 bit-bang。第二类问题是延展超时默认值。部分控制器的 I2C 状态机虽然会等待 SCL但不一定暴露超时寄存器。你以为外设会无限等下去实际上它的 FIFO 或 DMA 可能在某个固定时间点报错。我调 STM32 的硬件 I2C 时习惯给传输函数包一层软件超时和重试逻辑不能完全依赖 HAL 库默认的超时参数。第三类问题出现在低功耗唤醒场景。以 ESP32 这类集成度较高的芯片为例从 deep sleep 唤醒后 I2C 控制器和 GPIO 复用状态经常不会自动恢复到休眠前的样子。我踩过的最典型现象是休眠前 I2C 正常唤醒后第一次读写直接超时逻辑分析仪显示 SCL/SDA 挂在半高电平位置怎么看都像是硬件坏掉了。后来查到原因就是外设没有重新初始化。解决方法是先i2c_driver_delete()彻底释放驱动资源再调用i2c_driver_install()重新安装并且重新配置 GPIO 的上下拉和开漏模式。这一步不能省光往寄存器里写配置往往没用。硬件控制器给你的是一个“已经实现了协议”的黑盒但你依然要理解仲裁和延展因为一旦黑盒行为不符合预期只有你心里有物理层的模型才能判断问题是来自从机、总线还是控制器配置。最后再分享一个我的个人体会I2C 的多主机仲裁和时钟延展表面上是两套独立机制实际上都是开漏结构出让电气控制权之后水到渠成的副产品。调试时不要只盯着寄存器先用逻辑分析仪看清楚 SCL 低电平的长度、SDA 在什么位置保持高、有没有仲裁阶段的不完整地址帧。只要养成先看波形再下结论的习惯大部分所谓“神秘的 I2C 问题”都会变得非常直白。
返回列表