多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

I2C多主机仲裁与时钟延展:两根线背后的精妙协作

I2C多主机仲裁与时钟延展:两根线背后的精妙协作 做过几年嵌入式回头看 I2C 这个协议越用越觉得它“阴险”。它只有两根线却能实现多主机通信、从机反向控制时序、甚至自动错误恢复多主机仲裁和时钟延展这两个机制就是它在协议层最精妙的设计。这一讲是系列第 04 讲我们把这两个机制放到一起拆开讲仲裁解决的是“多个主机同时抢总线怎么办”时钟延展解决的是“从机跟不上主机节奏怎么喊停”。适合正在调 I2C 却被诡异通信失败折磨的人也适合逻辑分析仪买了但只会看波形、不会抓冲突现场的人如果你正准备做多主机系统这一讲尤其值得看完再动手。先说结论仲裁靠的是“线与”物理特性时钟延展靠的是“SCL 拉低不放”的握手约定。两个机制单独看都不难但放到一起就构成了 I2C 总线上最优雅的协作模型所有设备既是发送者也是接收者谁都不需要独占总线却能公平地共享同一对线。1. 多主机仲裁两根线如何解决“多人同时发言”的冲突1.1 开漏结构与线与逻辑仲裁的物理前提I2C 总线的电气底层是开漏输出每个设备的 SDA 和 SCL 引脚都只能主动拉低不能主动推高。总线上的高电平完全靠外部上拉电阻提供。这就带来了一个非常关键的物理特性只要有一个设备把线拉低整条线就是低电平只有当所有设备都释放线才会被上拉电阻拉回高电平。这个行为在数字电路里叫“线与”。因为有了线与逻辑两个主机同时发送时就天然存在竞争关系在同一个时钟周期里谁把 SDA 拉低谁就“赢”了。举个生活化的例子就像会议室里的抢答器按下去的人会亮灯但只有第一个按下的有效其他人再按也改变不了灯的状态。I2C 仲裁比抢答器更精妙的地方在于它不是在“开始抢”的瞬间决定胜负而是在每一个数据位上都实时比较整个过程总线上的数据不会出现“混音”。理解这点非常关键因为这意味着仲裁不是额外机制而是开漏结构自带的性质。只要你用的是标准 I2C 引脚配置仲裁就在物理层面自动存在剩下的只是协议层如何去利用它。1.2 位级仲裁实战推演地址冲突与数据冲突我直接给一个最容易理解的场景。假设两个主机同时要向总线上发起传输主机 A 发送的从机地址是 0x50即二进制 01010000主机 B 发送的地址是 0x52即二进制 01010010。两个都在 SCL 的驱动下逐位往 SDA 上写数据前 5 位10100A 和 B 完全一致总线电平与二者输出一致没人发现问题第 6 位A 输出 0B 输出 0仍然一致第 7 位A 输出 0B 输出 1。按照线与逻辑A 拉低 SDAB 释放 SDA总线电平是 0。B 在 SCL 高电平期间回读 SDA发现自己想发 1 但总线上是 0于是立刻判定“仲裁丢失”停止驱动 SDA转为纯监听状态A 继续发送剩余的位最终完成完整事务甚至不知道自己经历过仲裁。这个过程中SDA 上实际传输的内容就是 A 的完整地址和数据B 没有被破坏。B 之所以安全退出是因为它在输掉的那一位之后就不再往总线上写任何东西而这正好让 A 的后续位序列保持连续。很多初学者会担心“仲裁失败会不会把总线搞乱”答案是不会只要每个设备严格遵守“输掉就闭嘴”的规则。仲裁不仅发生在从机地址阶段数据位、重复起始位Sr、甚至 ACK 位都可能发生仲裁。比如两个主机读同一个地址的传感器两者发送的地址相同但后续要读取的寄存器地址不同这种情况下前面地址阶段两个主机都赢着直到数据阶段某一位不一致才分出胜负。这也意味着仲裁判定失败的主机可能已经在总线上发送了多个字节丢失仲裁后必须立即停止但已经发送的那些字节并不会污染总线因为它们和赢家的前缀完全相同。1.3 仲裁失败的软硬件表现与排查入口不同平台对仲裁丢失的处理方式差别很大但最终结果都能在寄存器或状态标志上体现出来。以 STM32 的硬件 I2C 为例外设会在检测到仲裁丢失时置位 ARLO 标志并产生中断。标准的处理流程是在中断里清除标志、停止当前传输把总线状态机复位到空闲状态然后决定是重试还是报错。这里有个容易被忽略的细节仲裁丢失中断触发时外设可能已经把当前字节的移位寄存器清空你需要重新启动整个传输而不是简单地从失败那一位继续。因为协议规定仲裁失败的设备必须退出不可能半路插回去继续发。如果是用 GPIO 模拟的软件 I2C仲裁检测就完全要靠代码实现。核心做法是在每个 SCL 高电平期间回读 SDA和自己写入的电平做比较。一旦不一致立即把 SDA 方向改为输入停止驱动然后继续产生时钟直到当前字节结束最后拉出一个 STOP 条件或让出总线。很多自研代码只检查从机 ACK不检查发送过程中的 SDA 回读这在单主机系统里没问题一旦挂上第二个主机总线就会时不时出现无解的错乱。排查仲裁冲突最直接的工具是逻辑分析仪。抓取时不要把触发条件设成普通 START要设置重复触发或者直接连续采集因为冲突现场的典型特征是两个 START 几乎重叠在一起随后其中一个设备突然停止输出总线上的后半段波形完全由赢家主导。如果你看到 SDA 上某一位出现了“既不是正常低电平、也不是正常高电平”的台阶那多半是设备驱动能力差异或上拉电阻不足不是标准仲裁能解释的现象需要优先查硬件。2. 时钟延展从机合法地让主机“等一下”2.1 时钟延展的工作原理与实现方式如果说仲裁是 I2C 的“谦让规则”时钟延展就是它的“暂停键”。I2C 是同步串行协议时钟通常由主机产生但从机如果处理不过来可以把 SCL 强制拉低而且不放。由于 SCL 也是开漏结构主机在产生下一个时钟脉冲前会去检查 SCL 电平发现线还是低就知道从机还没准备好于是自动等待。直到从机完成内部操作、释放 SCL上拉电阻把线拉高主机才继续产生后续的时钟。这个机制在硬件上实现起来非常廉价从机只需要把自己的 SCL 引脚配置成开漏输出想在某个时间点暂停就拉低准备好了就释放。需要强调的是时钟延展不是错误而是协议规格中明确规定的合法行为所有符合 I2C 规范的设备都应该支持。为什么说它是“最精妙的设计”因为主机和从机在时钟延展期间处于一种完全对等的关系主机虽然负责产生时钟但不能强制推进从机虽然是被寻址的设备却拥有暂停整条总线的权力。这个特征让 I2C 特别适合“外设简单、CPU 处理速度不确定”的嵌入式场景——从机再慢主机等一等就是了不用预先协商速率。2.2 三类最常触发时钟延展的真实设备第一类是 EEPROM。很多串行 EEPROM 在页编程之后需要一段内部写入时间规格书里通常写 tWR比如 5ms。有些芯片会在 tWR 期间用时钟延展把 SCL 拉低主机发完最后一个数据字节后就会一直等直到芯片写完才释放。我实际测过一款 24C256在 3.3V 下写一页 64 字节延展时间能到 3ms 左右如果主机没有等待逻辑这个写入操作就会直接超时失败。第二类是带内部 ADC 或传感器的芯片典型代表是 BH1750 光照传感器和部分气压计。这类芯片在启动转换后并不会立刻准备好数据如果寄存器配置成连续测量模式芯片在内部转换完成前会把 SCL 拉低直到结果可读。很多人第一次调 BH1750 失败不是代码写错而是主机没有处理时钟延展导致读回来的数据全是 0xFF 或 0x00。第三类是 MCU 作为从机的场景。当主机的读请求到达而从机 CPU 还没把响应数据填充到发送缓冲区时从机固件可以把 SCL 拉低拖住主机。这种用法在做“一个主板带多个 MCU 节点”的系统里非常常见从机不用急着响应准备好数据再释放总线主机的 I2C 事务会被自动拉长但数据不会错。2.3 主控侧正确的等待姿势与超时设计对硬件 I2C 控制器来说大多数主流芯片STM32、GD32、NXP、Microchip 等的硬件外设会自动处理时钟延展也就是说主机产生时钟前会等 SCL 变高不需要额外软件干预。但这不意味着可以高枕无忧因为如果从机因为代码 bug 或硬件故障把 SCL 永久拉低主机就会无限期挂死在那里。所以总线超时机制是必须的。超时阈值怎么选我一般遵循两个原则。第一必须大于目标设备规格书里标称的最大延展时间。比如 EEPROM 写周期最长 5ms超时设 10ms 就足够。第二要考虑同一总线上多个从机级联延展的情况比如挂了多个会延展的传感器它们不可能在同一时刻都恰好延展但保守起见我通常把超时设成所有从机最大延展时间之和的 2 倍。软件模拟 I2C 时等待 SCL 释放的代码要特别小心。基本逻辑是主机在把 SCL 拉高后循环读取 SCL 引脚电平直到变高或超时。这里有个常见错误有些平台的上拉电阻很大SCL 上升沿本来就慢从机没有延展但主机代码误以为从机在延展超时设置又太短结果读操作刚到第一个 ACK 位就判失败。解决方法是先把超时设置放宽再用逻辑分析仪确认 SCL 低电平的真实持续时间而不是靠猜。3. 实操排雷仲裁与时钟延展引发的几个典型故障3.1 多主机同时发送导致的写数据错乱先说一个我实际调过的故障现场。系统里有 A、B 两个 STM32 作为主机挂在同一条 I2C 总线上它们都会周期性地去写同一个 EEPROM。刚开始单板测试都没问题联调后偶发性出现 EEPROM 里某些字节变成 0x00而且每次出错的地址都不一样。用逻辑分析仪连续抓了几分钟波形终于抓到一次冲突A 和 B 几乎同时产生了 START 条件两者前几个地址位完全一致但随后在寄存器地址字节上发生仲裁A 赢了B 检测到仲裁丢失后退出。表面上看数据没问题但排查驱动代码发现B 在仲裁丢失中断里只做了标志位清空没有把 DMA 传输关闭结果 DMA 还在往发送数据寄存器里灌数据等下一个总线空闲事件出现时B 又自己发起了一次 START把一段没有意义的字节写进了 EEPROM。这个问题最后修了两处一是仲裁丢失中断里必须完整复位发送状态机关闭 DMA 请求二是给每个主机加了“仲裁失败后延迟随机 15ms 再重试”的退避逻辑避免两个主机在完全相同的周期内再次同时发起传输。多主机仲裁是硬件能力但工程上靠“随机退避”降低冲突概率是我强烈建议的做法。3.2 从机延展时间过长导致主机报错另一个案例是 GT911 触摸屏芯片的 I2C 通信失败。现象是主控上电后偶尔读不到触摸坐标用逻辑分析仪看主机发送地址和寄存器地址都正确但在读数据阶段从机的 SCL 被拉低后迟迟不放持续时间超过主控 I2C 外设的默认超时于是主机报“总线超时”并中止传输。查了一下 GT911 的数据手册发现它在内部固件初始化阶段确实会有一段较长的时钟延展尤其在上电后首次通信时最为明显。当时主控的 I2C 超时寄存器默认值是 16 个时钟周期在 100kHz 频率下只有 160us远远不够。后来我把超时改成 40ms并且在上电后对触摸芯片做一次“预读取”操作让它完成内部初始化之后再进入正常工作问题就消失了。这里也提醒一句如果你把 I2C 总线速率从 100kHz 提到 400kHz超时对应的绝对时间会缩短原来能通过的工程在新速率下可能突然出现超时错误调速率时一定要同步检查超时寄存器。3.3 休眠唤醒后 I2C 挂死与总线恢复步骤ESP32 这类低功耗芯片在休眠唤醒后经常出现 I2C 通信异常原因多半不是协议逻辑错了而是外设还没来得及正确复位或者总线上还残留着低电平状态。典型表现是 wakeup 之后第一次 read 永远返回失败但第二次就好了。我的复位流程是三步走先重新初始化 I2C 外设和 GPIO然后强制在 SCL 上产生 9 个脉冲最后发一个 STOP 条件。这组操作可以把卡在内部状态中的从机“唤醒”也能把被从机拉低的 SDA 释放。逻辑其实很简单I2C 从机靠 SCL 边沿移动状态连续 9 个时钟可以把任意状态的从机状态机推回空闲态附近STOP 条件则让所有从机确认总线传输结束。执行完这套恢复后再发正常通信成功率会明显提升。同样是休眠场景还要注意 SDA 和 SCL 引脚在休眠期间是否被配置成了下拉输入这会造成总线钳位。我建议把 I2C 引脚在休眠前切到高阻态并确保外部上拉电阻没有被断开否则恢复时序根本拉不高的线后面一切操作都是白费。3.4 Linux 驱动下 adapter timeout 的处理在 Linux 下调试 I2C 设备时仲裁和时钟延展的问题会直接体现在内核日志里最常见的是“i2c i2c-x: timeout waiting for bus”或“adapter timed out”。比如说有些 PHY 芯片不走 MDIO 接口而是通过 I2C 管理内核里的 PHY 驱动会通过 I2C 读寄存器如果 PHY 在复位或协商期间出现较长的时钟延展I2C adapter 的 timeout 设置又比较短驱动就会返回 -110。这个时候不需要改驱动逻辑先调整 adapter 的 timeout 参数用 i2c-tools 直接测试。具体做法是用 devicetree 或平台数据把 timeout 配到 50ms 以上再跑 i2cdetect 和 i2cget。我遇到过不少“Linux 下 I2C probe 失败”的案例最后查明根本不是设备地址问题而是超时设置太激进。碰到这种情况第一反应不要怀疑硬件先抓波形和查 timeout 配置能省下大量排错时间。4. 进阶设计把仲裁和时钟延展用成“生产力”4.1 多主机总线的工程决策该不该用多主多主机仲裁虽然听起来高级但我在实际项目里的态度是能不用尽量不用。I2C 多主机系统有几个固有难点一是总线冲突概率随着主机数量和数据量上升呈非线性增长二是仲裁失败后的重试策略需要额外代码和测试三是任何一台主机的软件 bug 都可能导致整条总线被拖死。如果只是想让两个 MCU 互相交换数据SPI 或 UART 明显更简单。但在某些场合多主机又是绕不开的。比如一个背板上有多个智能节点它们都需要读取同一个环境传感器如果让其中一个节点作主机、其余作从机从机数据通路会变得复杂不如让所有节点都直接挂在 I2C 总线上靠仲裁决定谁先访问传感器。这时候不要贪多建议总线上主机数量控制在 3 个以内访问频率错开并且所有主机共享一份“重试窗口”设计。重试窗口我习惯用 1ms 粒度随机生成保证冲突概率尽量分散。还要给每台主机设置不同优先级的起始条件比如让主机 A 在检测到总线空闲后延迟 0us主机 B 延迟 50us主机 C 延迟 100us从源头上就错开启动时刻。这样仲裁机制仍然保留作为后备但真正发生仲裁的概率会低很多。4.2 时钟同步与多主机时钟延展的协同关系多主机仲裁和时钟延展同时发生时总线的时钟会表现出一个特别有意思的现象多个主机各自产生 SCL但总线上只会出现一个统一的 SCL 波形。原因是 SCL 也是线与结构任何一个主机拉低 SCL总线就是低所有主机都释放 SCL总线才变高。这意味着总线的低电平时间被“拉最长”的主机决定高电平时间被“最先释放”的主机决定。如果主机 A 的低电平周期是 5us主机 B 的低电平周期是 4us那么总线实际低电平至少是 5us。当从机在某个时刻延展 SCL 时所有等待中的主机都会同步暂停暂停结束后剩余的主机继续在同一个时钟沿上仲裁数据。仲裁和延展不是互相干扰而是配合工作延展让慢从机也能参与多主机仲裁不会因为跟不上时钟而丢数据。理解这层关系后设计多主机系统时就要统一所有主机产生的时钟参数。如果两个主机一个用 100kHz一个用 400kHz仲裁过程虽然能完成但总线上会频繁出现非预期的高电平窗口从机和主机都可能把时序认错。我的经验是多主机系统里所有主机的 I2C 速率必须显式配置成完全相同包括上升时间和数字滤波参数否则不如分时复用。4.3 从机主动上报的常见替代设计有不少人问过我I2C 是从机被动应答的协议但我的应用需要从机主动更新主机寄存器该怎么办这个问题在仲裁和时钟延展的框架下有几个可行思路。第一种是把从机地址配置成“通知寄存器”主机周期性轮询这个寄存器。从机在数据准备好后利用时钟延展把主机的读操作拖住直到主机读到最新数据。这个过程里并没有从机主动发起通信但从机通过延展实现了“强制主机等待”的目的效果上接近主动上报。第二种是增加一条 GPIO 中断线。从机准备好数据后拉高一个 GPIO主机收到中断后主动发起读操作。这个方案不改变 I2C 语义但需要多一根线很多实际产品就是用这种玩法代替“总线仲裁式双向通信”。第三种是在 SMBus 基础上使用 ALERT 线这是 PMBus/SMBus 的标准做法但支持设备不如普通 I2C 多芯片选型上要提前确认。我想强调的结论是在标准 I2C 协议里真正的主从关系很难被“从机主动”打破想实现类似需求优先考虑轮询加中断线而不是去魔改时序。4.4 I2C 多路复用、扩展芯片与逻辑分析仪配合技巧总线上设备越来越多时除了仲裁和延展电容负载也会带来麻烦。I2C 快速模式要求上升时间不超过 300ns总线上挂 8 个设备后电容往往超过 300pF用常见的 4.7kΩ 上拉电阻计算上升时间 tR 约等于 0.85 x 4.7kΩ x 300pF算下来大约 1.2us远远超标。这时波形看起来就像从机一直在延展其实只是 RC 充电太慢。解决方式要么降低速率要么减小上拉电阻要么用 I2C 多路复用器把总线切段。TCA9548A 这类多路复用器本质上是一个从机主机先写它的 channel 寄存器选择某一段下游总线然后再跟对应段上的设备通信。仲裁在上游仍然有效但下游各段被电气隔离设备地址可以重复。调试这类系统时逻辑分析仪最好同时抓上游和下游两个通道否则分不清问题是出在选通寄存器写入错误还是下游器件延展时间过长。我也强烈建议在调试 I2C 时把采样率设成至少 4 倍于总线速率。100kHz 总线用 400kHz 采样勉强能看400kHz 总线最好用 2MHz 以上采样。仲裁冲突时两个 START 之间往往只有几百纳秒到几微秒的间隔采样率不够会直接漏掉关键事件。5. 调试 I2C 的个人心得与工具习惯5.1 逻辑分析仪抓 I2C 的触发与解码设置逻辑分析仪是我排 I2C 问题时的第一工具但很多人不会设置触发条件。常规做法是把触发设成 START 条件可如果总线经常有周期性通信START 会被正常通信触发抓不到问题现场。我建议在怀疑仲裁冲突时把触发设成 SDA 下降沿并且设置预触发深度 50% 以上这样能抓到完整的事件前后波形。解码设置里有个容易踩的坑地址格式要区分 7 位还是 8 位。很多逻辑分析仪默认显示 8 位地址把设备手册里的 7 位地址左移了一位导致对着手册核对时怎么都对不上。我习惯在解码器设置里勾选“显示 7 位地址”再配合读写的 R/W 位来看基本不会错。如果你用示波器而不是逻辑分析仪至少要保证两个通道同时抓 SDA 和 SCL并打开总线解码功能。示波器采样率通常足够但深存储更重要否则抓 400kHz 信号只能看到几百个字节很难定位偶发性仲裁冲突。5.2 软件模拟 I2C 时最容易忽略的 SDA 回读软件模拟 I2C 时多主机仲裁和时钟延展这两个机制都要自己实现但最常见的问题还不是安全而是漏了 SDA 回读。很多人写的模拟 I2C 发送字节函数只在移位输出 8 位后等待 ACK没有在每一位 SCL 高电平期间回读 SDA。单主机时这没有任何问题一旦第二个主机介入发送方根本不知道自己已经输掉了仲裁会继续把后面的数据写出去把总线搅得一塌糊涂。我的软件 I2C 发送函数模板里每一位都做同样三件事写 SDA 电平、SCL 拉高、等待 SCL 变高兼容时钟延展、回读 SDA 并对比不一致就退出并返回仲裁失败状态。虽然多花几条指令但换来的是不用担心总线冲突。如果你所在平台不支持开漏输出必须用推挽输出加外部二极管模拟那在仲裁失败后要格外小心把 SDA 释放建议直接把 GPIO 方向切到输入。5.3 写在最后仲裁和时钟延展其实是握手艺术回头再看这一讲多主机仲裁和时钟延展一个是“抢”一个是“等”看上去方向相反本质却都是同一件事让总线上每个设备在任意时刻都知道自己到底该不该驱动线路。仲裁规则保证了同一时刻最多只有一个发送者延展规则保证了即使从机很慢主机也愿意等。它们共同让 I2C 这种只有两根线的总线做到了很多四线甚至八线协议都做不到的灵活协作。我个人的体会是真正把 I2C 用好并不在于把数据手册背得多熟而在于调试时能不能第一时间想到“这里可能发生了仲裁丢位”或“这里可能是从机延展”。逻辑分析仪能帮你看到现象仲裁和时钟延展的知识能帮你解释现象。下回再遇到莫名其妙的通信失败先别急着换芯片抓一下 SDA 和 SCL看看波形里有没有该赢的没赢、该等的没等往往问题就自己浮出水面了。
返回列表