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

文章详情

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

I2C从模式设计与总线鲁棒性:时钟延展落地与死锁恢复

I2C从模式设计与总线鲁棒性:时钟延展落地与死锁恢复 第06讲从模式设计与总线鲁棒性——时钟延展落地 死锁恢复做 I2C 从机开发的朋友十有八九都被同一类问题折磨过总线偶尔挂死、主设备读不到数据、从机明明在跑却响应不了地址。表面上看是时序问题往深了挖其实是从模式设计和总线鲁棒性这两个层面没做好。这一篇就把“从模式设计”这个概念拆开讲清楚重点落在两个实操点上时钟延展Clock Stretching怎么落地到代码里以及总线真的被锁死之后死锁恢复有哪些能直接抄的招。内容偏实战搞嵌入式、写驱动、调 I2C 的小伙伴都会用得上。1. 从模式设计先把“从机软件”当工程来做很多人写 I2C 从机上来就是堆中断、填寄存器能通就跑不通就调。等产品量大了、总线上的设备多了才发现稳定性经不起推敲。我以前也是这么干的直到连续两台样机在老化测试时总线锁死才开始认真琢磨从模式到底该怎么设计。1.1 从模式不是“从设备”翻译一下就行I2C 从机的本质是一个状态机。主设备发起通信从机被地址选中后要么接收数据要么发送数据。这个过程不是线性的任意时刻都可能被 stop 条件打断也可能被 nack 中止。如果你只在中断里处理“收到一个字节”这个事件那么寄存器状态、收发缓冲、总线时序之间的边界条件就会变得特别多一旦某个状态没处理好后续全是坑。更好的做法是把从机看成几个独立模块的组合地址匹配模块负责检测总线地址判断是否“选中”自己。收发引擎按状态机处理一个字节一个字节的传输包括 ACK/NACK 的生成。寄存器组模块对外暴露的寄存器地址空间读写都在这上面进行。事件分发模块把“收到数据”“需要发送数据”“总线错误”这些事件转成回调交给上层逻辑处理。这样拆分以后每一块都能单独测试。我以前调试过一块从机芯片问题出在寄存器地址跨页时读写指针没正确更新就是因为收发引擎和寄存器组耦合在一起查了半天才定位到。1.2 23种设计模式里嵌入式最值得用的就这几个提到“设计模式”很多人第一反应是 Java、C 那套面向对象的东西。但实际上嵌入式 C 语言里同样可以用设计模式的思路来组织从机代码。状态模式是最核心的I2C 从机天然就是一个状态机观察者模式对应事件回调机制命令模式适合处理寄存器命令字适配器模式则用来屏蔽不同硬件平台之间的差异。拿状态模式举例。I2C 从机的状态可以定义为IDLE空闲、ADDR地址匹配、RX接收、TX发送、STOP停止。每个状态下对 SCL 上升沿/下降沿事件的处理方式完全不同。typedef enum { I2C_SLAVE_IDLE, I2C_SLAVE_ADDR, I2C_SLAVE_RX, I2C_SLAVE_TX, I2C_SLAVE_STOP } i2c_slave_state_t;核心逻辑在一个 switch-case 里每个 case 是独立的状态处理函数。这样改一个状态的行为不会影响其他状态排查问题的时候也容易加日志。观察者模式在 I2C 从机里体现为中断回调。很多 MCU 的 I2C 外设都支持事件中断比如地址匹配中断、数据接收中断、数据发送中断、错误中断。你只需要注册几个回调函数把底层事件和业务逻辑解耦开上层不用关心底层寄存器细节。真正在嵌入式从机固件里常用的其实也就四五个模式。23种设计模式不是让你全部往上套而是挑能解决实际问题的用。我见过把简单从机代码写出十几个文件的接口抽象来抽象去最后编译出来比原来大了一倍性能还变差了这就属于过度设计。1.3 从模式状态机的关键状态定义与转移条件设计从机状态机最主要是定清楚“谁来改变状态”“状态转移的触发条件是什么”。I2C 总线的时序由主设备控制从机只能被动响应所以从机的状态转移完全由总线事件驱动。实际项目里几个容易出问题的状态转移点地址匹配后没有正确进入收发状态有些外设在检测到地址匹配后还要等第一个数据位才真正进入收发状态如果此时主设备立即发 stop从机可能卡在中间状态。接收完最后一个字节后的 NACK 处理主设备读从机数据时读完最后一个字节会发 NACK从机收到 NACK 后应该回到 IDLE 状态。如果这个转移漏了下一次通信就会错位。错误状态的重置总线错误比如仲裁丢失、超时发生后从机要能把状态机强制复位到 IDLE不能等下一次地址匹配才恢复。我建议写代码的时候给每个状态加一个入口函数和退出函数。入口函数负责初始化该状态下的硬件寄存器退出函数负责清理。这个习惯帮我避免过好几次“忘了清标志位”的低级错误。2. 总线鲁棒性从电气到协议的层层加固总线鲁棒性和从模式设计是两个层面的事但实际调板子的时候它们互相影响。**鲁棒性差的从机会把总线上的小问题放大成死锁鲁棒性好的从机即使主设备发错时序也能扛住不死。**这一节聊我在项目里验证过的几个加固手段。2.1 总线鲁棒性威胁主设备异常、从机跑飞、噪声干扰常见的 I2C 总线异常来源无非这么几类主设备端异常主控芯片 I2C 外设的 bug、驱动代码 bug、主设备看门狗复位导致通信中途终止。从机端异常从机固件跑飞、中断卡死、从机被复位但 SCL/SDA 处于输出状态。电气噪声干扰总线走线过长、上拉电阻选得不对、电源纹波大导致信号沿变差从机误判起始/停止条件。我之前遇到过一种很隐蔽的情况主设备在传输过程中因为紧急任务抢占了 CPUI2C 中断被延迟了好几个毫秒。SCL 停留在低电平超过从机的超时阈值从机判断总线异常主动释放 SCL。但主设备恢复过来之后不知道这个情况继续按原有时序操作两边就对不上了。这种问题靠硬件很难查因为逻辑分析仪抓到的时序看起来是完整的。2.2 软件防呆超时检测与状态不变量检查软件层面的鲁棒性设计核心思路是**“不要无条件信任总线状态”**。哪怕总线时序看起来正常代码层面也要有兜底机制。我最常用的两个手段字节级超时检测从机每接收到一个字节就启动一个硬件定时器超时时间设为 10ms 左右。如果超过 10ms 下一个字节没来判定总线异常强制复位收发状态机。这个 10ms 不是什么标准值而是基于 I2C 典型通信频率反推的。标准模式 100kHz 下一个字节约 90us快速模式 400kHz 下约 22.5us10ms 已经足够宽松不会误判正常通信。状态不变量检查在每个状态入口和出口校验 SCL/SDA 的实际电平是否符合预期。比如 RX 状态下理论上 SCL 应该是高电平等待主设备发送下一个时钟如果此时读到 SCL 为低说明从机可能正被时钟延展或者主设备出了异常需要特殊处理。void i2c_slave_state_check(i2c_slave_state_t current_state) { uint8_t scl_level gpio_read_pin(SCL_PIN); if (current_state I2C_SLAVE_RX scl_level 0) { // SCL被拉低可能正被从机时钟延展记录调试信息 i2c_debug_log(SCL low during RX state); } }这段代码看起来简单但在现场排查问题时价值极大。你在实验室复现不了的偶发死锁现场设备上通过日志就能看到总线卡在哪个状态、SCL/SDA 是什么电平。2.3 电气层面的鲁棒性上拉电阻与总线隔离软件怎么加固都替代不了基本的电气设计。I2C 是开漏结构SCL 和 SDA 必须接上拉电阻到 VDD。上拉电阻选多大直接影响总线抗干扰能力和信号边沿。上拉电阻的计算公式不复杂核心是满足上升时间的要求。以 400kHz 快速模式为例I2C 规范要求上升时间不超过 300ns总线等效电容按典型值 100pF 估算用公式Rpu T_rise / (C_bus * ln(2))计算大约得到300ns / (100pF * 0.693) ≈ 4.3kΩ。实际项目里我一般选 2.2kΩ 到 4.7kΩ 之间既能满足上升时间又不会让低电平灌电流太大。总线电平转换也要留意。如果主设备是 1.8V从机是 3.3V直接连 SCL/SDA 肯定不行。常见方案是使用双向电平转换芯片比如 PCA9306或者用分立 MOS 管搭建转换电路。这里有个细节转换电路的两侧上拉电阻都要接而且要保证一侧为高电平时另一侧能正确识别。很多人只接了一侧上拉结果高电平斜率变差距离稍远就误码。还有一个我比较少看到有人提的点总线上的电容组不要加太大滤波电容。有人为了防干扰在 SCL/SDA 上各加一个 100nF 电容接地结果上升时间直接翻车。I2C 是边沿触发协议加电容等于自废武功。真要滤波用串联小电阻 33Ω 左右搭配 RC 滤毛刺就行。3. 时钟延展落地让慢速从机“按住总线等一等”时钟延展是 I2C 协议里非常有特色的一个机制也是从模式设计里最容易被忽视的部分。很多人知道这个名词但不知道它到底怎么影响总线时序、代码里怎么实现。这一节完整拆开讲。3.1 时钟延展的物理机制SCL 被从机拉低正常情况下SCL 时钟由主设备产生。但 I2C 协议允许从机在需要时拉低 SCL强迫主设备等待。这个过程就叫时钟延展。它的物理机制很简单SCL 是开漏结构主设备释放 SCL 为高之后如果从机也把 SCL 驱动为低那么总线上的 SCL 就是低电平。主设备检测到 SCL 为低就暂停时钟产生一直等 SCL 变高再继续。这个机制的意义在于从机处理数据需要时间。比如从机接收完一个字节需要把数据搬进缓冲区更新寄存器状态如果这些操作还没做完下一个字节的时钟来了也处理不了。从机通过拉低 SCL给自己争取处理时间主机等多久取决于从机什么时候释放。类比一下就是你跟别人对话别人说一句你还没来得及消化你举手示意“等一等”对方就停下来等你示意继续再说。没有这个机制对话就只能按对方的节奏走你跟不上就只能丢信息。3.2 不同速率模式下的时序要求与延展窗口I2C 协议定义了多个速率模式常见的有标准模式 100kHz、快速模式 400kHz、快速模式 1MHz。时钟延展在每个模式下的规则是一致的但时间窗口不一样。速率模式最低 SCL 频率/周期字节传输典型时间从机可用延展时间推荐标准模式 100kHz周期 10us约 90us建议小于 1ms快速模式 400kHz周期 2.5us约 22.5us建议小于 250us快速模式 1MHz周期 1us约 9us建议小于 100us延展时间越长主设备占用总线的时间越久总线上其他设备可能被饿死。所以从机的延展窗口在满足处理时间的前提下越短越好。从机什么时候可以延展两个典型位置一是地址匹配阶段从机确认是自己的地址后可以拉低 SCL争取时间准备收发缓冲区二是数据传输阶段每接收或发送一个字节后从机在 ACK 位的位置拉低 SCL争取时间处理这一字节。有些从机在发送完数据后也要延展用于准备下一个字节这在 EEPROM 这类存储设备上很常见。3.3 落地实现硬件 I2C 外设与 GPIO 模拟两种方案时钟延展的落地实现分两种情况用 MCU 内置的硬件 I2C 外设或者用 GPIO 模拟。硬件 I2C 外设的情况下多数 MCU 支持自动时钟延展。比如 STM32 的 I2C 外设I2C_SLOW 模式或时钟延展使能位配置后从机在接收数据的过程中如果软件还没有读走数据寄存器硬件会自动拉低 SCL。这个行为是自动的你不需要手动控制 SCL但要注意配置正确的中断优先级。如果从机中断被更高优先级的中断阻塞SCL 会被一直拉低主设备就一直等。这种情况要特别小心不能让长时间中断阻塞 I2C 中断。我见过一个项目从机的中断优先级没配好一个外部中断处理函数跑了 50ms期间 I2C 中断完全被阻塞SCL 被拉低到从机看门狗超时总线锁死。GPIO 模拟从机时时钟延展就要自己实现了。基本思路是检测到 SCL 下降沿后从机主动把 SCL 引脚输出为低电平保持一定时间后再释放为高。void i2c_slave_clock_stretch(void) { // 拉低SCL让主设备等待 gpio_set_pin(SCL_PIN, 0); // 执行需要时间的数据处理 process_received_byte(); // 处理完成释放SCL让主设备继续产生时钟 gpio_set_pin_mode(SCL_PIN, GPIO_MODE_INPUT_PULLUP); }注意释放 SCL 时不能直接输出高电平因为 SCL 是开漏结构必须切回输入模式或者输出模式但输出高电平取决于硬件设计。如果 MCU 的 GPIO 不支持真正的开漏输出释放 SCL 时切回输入模式带上拉才是正确的。还有一种情况GPIO 模拟从机接收数据时要确认识别 SCL 的边沿。通常用外部中断捕获 SCL 下降沿在中断里读取 SDA然后进入字节处理流程。每一步处理完成后再释放 SCL。这种模式下整个 I2C 时序的每一步都由软件驱动任何一步卡住总线就卡住所以超时保护必须做得非常严密。3.4 时钟延展与 SMBus 超时一个容易忽略的兼容性问题时钟延展在 I2C 协议里没有严格的超时限制即使从机把 SCL 拉低 100ms纯 I2C 主设备也会一直等下去。但SMBus系统管理总线规范明确规定了 35ms 的超时阈值。如果你的从机同时需要兼容 SMBus 主设备时钟延展超过 35ms主设备就会判定通信失败。这意味着从机的延展时间必须做两次裁剪一次是考虑总线占用时间越短越好一次是考虑 SMBus 兼容性必须小于 35ms。落地时可以在从机软件里做一个延展时长检查每次拉低 SCL 后启动定时器超过 25ms 还没处理完强制执行完当前操作并释放 SCL。25ms 留了 10ms 余量给 SMBus 主设备的 35ms 超时留出安全裕量。我在一个双模设备同时支持 I2C 和 SMBus上吃过亏从机延展时间做到 40msI2C 主设备下一切正常换上 SMBus 主控后通信频繁超时。查了半天才发现是 SMBus 规范限制。这个细节如果不是对两种协议都比较熟真的很难想到。4. 死锁恢复总线被“锁死”后的最后一根稻草总线锁死这个现象做 I2C 开发的基本都碰到过。逻辑分析仪抓到的波形要么是 SCL 停在低电平不动要么是 SDA 被钳在低电平主设备一直等 ACK整个系统像卡死一样。这一节专门讲死锁是怎么产生的以及恢复手段有哪些。4.1 死锁的三类典型成因第一类是从机时钟延展未释放。从机在拉低 SCL 期间如果自身代码跑飞、死循环或者看门狗复位SCL 就被永久拉低。主设备等待延展结束一直等不到总线就锁死了。第二类是停止条件丢失。主设备发送 STOP 条件时SDA 要从低变高而此时 SCL 必须是高。如果因为时序问题SDA 拉高的动作发生在 SCL 为低的时候或者 SCL 被从机拉低STOP 条件就发不出去。总线状态停留在半传输状态下一次通信的起始条件无法正确对齐。第三类是总线电平冲突。主设备在发送起始条件时SDA 从高变低但此时总线上某个从机还占着 SDA 输出低电平就会导致 SDA 一直是低起始条件无法被其他设备识别。这三类死锁的共同点是总线上的某个信号线被拉低后没人释放。恢复思路也就围绕“强制释放”和“让所有设备重新对齐”展开。4.2 经典恢复法9 个时钟脉冲业界最常见的总线恢复方法是 9 个时钟脉冲法出自 SMBus 规范的恢复策略主设备切换 GPIO 模式接管 SCL 和 SDA手动发送 9 个时钟脉冲。原理是如果总线上有从机正处于半字节传输状态9 个脉冲可以让它接收完当前字节并释放总线。void i2c_bus_recover(void) { // 1. 将SCL和SDA配置为输出初始都为高 gpio_set_mode(SCL_PIN, GPIO_MODE_OUTPUT); gpio_set_mode(SDA_PIN, GPIO_MODE_OUTPUT); gpio_write_pin(SCL_PIN, 1); gpio_write_pin(SDA_PIN, 1); delay_us(1); // 2. 发送9个时钟脉冲 for (int i 0; i 9; i) { gpio_write_pin(SCL_PIN, 0); delay_us(5); gpio_write_pin(SCL_PIN, 1); delay_us(5); } // 3. 发送STOP条件 gpio_write_pin(SDA_PIN, 0); delay_us(1); gpio_write_pin(SCL_PIN, 0); delay_us(1); gpio_write_pin(SCL_PIN, 1); delay_us(1); gpio_write_pin(SDA_PIN, 1); delay_us(1); // 4. 恢复为正常I2C模式 gpio_set_mode(SCL_PIN, GPIO_MODE_I2C); gpio_set_mode(SDA_PIN, GPIO_MODE_I2C); }为什么是 9 个脉冲而不是 8 个或者 10 个I2C 协议中一个字节是 8 位数据加 1 位 ACK一共 9 个时钟周期。如果从机正处于半字节接收状态8 个时钟让它收完数据第 9 个时钟对应 ACK 位这个位置从机要释放 SDA 以便主设备发送 ACK。所以 9 个脉冲刚好能把从机从一个不完整的字节传输中“赶”出来回到可以识别下一次起始条件的空闲状态。实际操作中有一个前提如果 SCL 被从机拉低主设备根本发不出这 9 个脉冲。因为 SCL 被拉低时主设备往 SCL 上写高电平是写不上去的。这种情况下必须先把 SCL 引脚从“开漏输出”临时切换为“推挽输出”强制把 SCL 拉高然后才能产生脉冲。这也解释了为什么恢复函数第一步要把 GPIO 模式切换为输出模式并且直接写 1——就是为了强制释放 SCL。恢复完成后从机侧还需要做一件配合工作检测到超过一定时间没有通信自动复位自身 I2C 状态机到 IDLE。这样即使主设备发了 9 个脉冲从机也能正确识别新的起始条件两边重新对齐。4.3 软件看门狗与总线状态自恢复从机侧不能只靠主机死锁恢复不能只依赖主设备。从机如果具备独立的总线监控能力恢复效率会高很多。我在从机上做了一个 10ms 的总线空闲检查任何一个 I2C 事件起始、停止、数据都会刷新定时器。如果 10ms 内没有任何总线活动判定总线异常从机自动复位 I2C 外设和状态机。实现上很简单void i2c_slave_watchdog_update(void) { last_bus_activity get_tick_ms(); } void i2c_slave_watchdog_check(void) { if (get_tick_ms() - last_bus_activity 10) { // 总线空闲超过10ms主动复位I2C状态机 i2c_slave_force_reset(); } }这个机制在正常通信下不会误触发因为 I2C 通信是连续的字节间隔远小于 10ms。在死锁场景下总线确实没有活动了从机主动复位就能在下一次地址匹配时重新参与通信。还有一个设计细节从机复位后寄存器状态要恢复到默认值不能保留死锁前的中间状态。比如一个 16 位寄存器正在被主设备分两次写入第一次写入了高字节第二次还没写低字节就死锁了复位后寄存器应该是“未写入”状态而不是“高字节已写入”状态。否则恢复后主设备再对该寄存器读写数据就对不上了。4.4 防止死锁的设计经验延展窗口、中断优先级与 NACK 处理死锁最好的处理方式是让它根本不发生。基于我踩过的坑总结几个预防经验。第一时钟延展窗口要短且不能在中断里做耗时操作。从机需要拉低 SCL 争取时间但争取来的时间应该只做“数据搬运、状态更新”这种必要操作不能在中断里做协议栈处理、日志打印、Flash 擦写。这些都是耗时大户会无限拉长延展时间增加死锁概率。我在一个项目里就因为从机中断里做了 Flash 写入导致 SCL 延展超过 100ms主设备看门狗超时两边互相等系统直接假死。第二I2C 中断优先级要足够高。前面提到过I2C 中断被阻塞SCL 就会被长时间拉低。一般情况下I2C 中断优先级应该高于普通外设中断但可以低于系统节拍之类更紧急的中断。关键是“不能被长耗时中断阻塞”这点每个项目要根据实际中断负载评估。第三正确处理从机 NACK 后的状态。主设备发 NACK 表示“我收完了”从机收到 NACK 后应该立即释放 SDA回到 IDLE。有些从机芯片在 NACK 后 SDA 释放不及时再接下一个 start 条件时就会产生毛刺。代码上务必要在 NACK 状态退出时把 SDA 切回输入模式。5. 常见问题排查与避坑实录最后整理一份问题速查表都是我在实际项目中遇到的典型问题症状可能五花八门根因往往集中在几个点上。症状可能原因排查方法解决方案SCL 保持低电平总线锁死从机时钟延展未释放或 SCL 被从机开漏输出拉低用示波器量 SCL 电平检查从机代码是否卡在延展流程执行 9 脉冲恢复检查从机延展超时机制SDA 保持低电平主设备一直等 ACK从机未释放 SDA或主设备 STOP 条件缺失量 SDA 电平抓起始/停止条件波形执行 9 脉冲恢复检查从机 NACK 后 SDA 释放逻辑总线偶发死锁复位后恢复正常从机接收到非法字节状态机错乱加状态日志记录死锁前是哪个状态增加非法字节检测强制状态复位快速模式 400kHz 下偶尔误码上拉电阻选得过大上升沿太慢用示波器量上升时间换更小的上拉电阻比如 2.2kΩSMBus 主设备连接后频繁超时从机时钟延展超过 35ms触发 SMBus 超时检查延展期间 SCL 低电平持续时间缩短延展时间增加 SMBus 超时兼容逻辑从机复位后总线即锁死从机复位瞬间 SCL/SDA 处于输出状态与总线冲突从机复位代码不释放 GPIO或默认配置错误复位初始化时先将 SCL/SDA 配置为高阻输入再分享一个排查工具的心得逻辑分析仪是 I2C 调试的第一生产力。普通示波器可以看波形但分析协议帧很费劲。逻辑分析仪能直接解码 I2C 协议按帧显示地址、数据、ACK、停止条件。调试死锁问题时先把捕获深度调到足够大触发条件设为 STOP 条件就能抓全整个通信过程死锁发生在哪一帧一目了然。最后说个实战案例。之前做一个传感器从机主设备每隔 100ms 读取一次数据。产品在小批量测试时发现偶发死锁概率约千分之一。加日志后发现从机在一个特定寄存器地址被读取时正好赶上内置 ADC 转换结束中断。中断函数里做了均值计算耗时约 3ms期间 I2C 中断被阻塞。从机硬件外设因为数据寄存器没及时读取自动拉低 SCL 延展。3ms 虽然不长但那个应用场景下主设备连续发送了两次读请求第一次延展还没结束第二次就来了总线状态直接错乱。解决办法是把 ADC 均值计算放到主循环里做中断内只置标志位同时给 I2C 中断提升优先级。改完后跑了两周没有再复现。6. 写在最后的个人体会做从机设计这些年我最深的感觉是I2C 从机的问题80% 出在状态管理上而不是时序参数上。时序参数错了波形一眼就能看出来状态管理错了波形看起来一切正常数据却悄悄丢一两个字节或者偶尔死锁一次特别难查。时钟延展这个功能用好了是从机的“减速缓冲”用不好就是“死锁加速器”。关键不是知道怎么拉低 SCL而是想清楚什么时候拉低、拉低多久、超时了怎么退出。每一个延展窗口都要有超时保护每一个状态转移都要考虑异常路径这样才是真正把总线鲁棒性做进去了。最后再分享一个排查技巧如果死锁只在特定操作序列下出现比如先写寄存器 A 再读寄存器 B那大概率是状态机在连续两个传输之间没有正确复位。可以用逻辑分析仪把两次传输的波形同时抓下来对比重点看第二次的起始条件是否和第一次的停止条件对齐。这个位置很容易出现毛刺或者状态残留也是死锁的最高发区域。调试多了你会发现总线协议不怕慢就怕乱。状态机管住了总线自然就稳了。
返回列表