ARTICLE DETAIL

资讯详情

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

I2C从机设计:时钟延展实现与总线死锁恢复指南

I2C从机设计:时钟延展实现与总线死锁恢复指南 如果你做过 I2C 从设备端的固件下面这个场景应该不陌生样机联调时主控上报“I2C Bus Busy”逻辑分析仪挂上去一看SDA 早就被拉成了一条直线又或者从机明明已经准备好了数据主机却卡在 ACK 之后一动不动等到超时才报通信故障。我在嵌入式这行摸爬滚打了十几年这类问题出现最多的地方恰好就是“从模式设计”和“总线鲁棒性”这两个话题交叠的区域。这篇是这个系列的第 06 讲重点聊透两个落地问题时钟延展Clock Stretching怎么在从机侧正确实现以及总线死锁之后如何从固件层面恢复。内容主要面向正在写 I2C 从机驱动的嵌入式工程师也适合调试 I2C 总线异常时想快速找方向的朋友。1. 先定位问题从机模式下让总线卡死的两种“现场”很多人遇到总线卡死第一反应是“把速率调低”“换一对上拉电阻”但如果不先分清卡死的物理形态多半会做很多无用功。I2C 从机模式下最典型的两种现场是 SCL 被从机长时间拉低以及 SDA 被从机死死钳住。两者表象相似根因和处理方式完全不同。1.1 现场一SCL 被从机拉低主机在“干等”时钟延展I2C 协议有个非常独特的设计SCL 高电平期间主机采样 SDA 上的数据位而 SCL 低电平期间从机可以主动把 SCL 拉得更低并且一直保持低电平直到它准备好继续通信。这个过程就是时钟延展。从机的典型使用场景是主机发起一个读操作从机收到地址字节后内部 Flash 还在准备数据这时候从机如果强硬地输出数据很可能把“未准备好”的数据送到总线上。于是从机在 ACK 信号之后把 SCL 拉低告诉主机“稍等一下”主机检测到 SCL 被拉低后会自动停留在当前状态直到 SCL 被释放。这是协议允许的也是大多数 I2C 主机控制器都支持的行为。问题在于协议本身并没有定义延展时间的上限。从机固件一旦在处理数据时卡死SCL 就可能被拉低几秒甚至更久主机的硬件状态机通常会一直等下去表现就是“通信卡住但不报错总线也还在占用中”。1.2 现场二SDA 被死死钳住主机发不出停止位第二种更常见的现场更刺激从机固件里某个变量被意外改写或者 I2C 中断处理时出了异常GPIO 被错误地配置成了推挽输出低电平SDA 线就被直接钳到地。这时候不管主机怎么发起通信SDA 都无法产生上升沿。I2C 的停止位要求 SDA 在 SCL 高电平期间产生一次从低到高的跳变。如果 SDA 始终是低电平主机的状态机就无法完成停止位总线会一直处于“忙”状态。更麻烦的是I2C 总线没有片选线任何设备拉低 SDA都会影响整条总线上所有挂载设备。我记得有个项目里主控端的 I2C 控制器在遭遇这种死锁后会一直返回“Bus Busy”既不会自动重试也不产生中断只能靠上层应用的路由看门狗去触发复位。而看门狗复位主控之后从机如果还在异常状态总线依然救不回来。1.3 为什么从机侧这两个问题尤其凶险主机侧发生超时大多数情况下主控自己会重发或复位。但总线卡死发生在从机侧时主机的 I2C 控制器往往能感知到“总线忙”却没有能力越过从机的异常状态去强制恢复。因为 I2C 是多点结构主机不能单独将某一个从机踢下线唯一能让从机释放总线的办法只有从 SCL、SDA 本身做文章。这也意味着从机固件必须自己拥有“故障自愈”能力。设计从机模式时不能抱着“只要遵循《IC 数据手册》里的时序图就能稳定工作”的幻想必须主动考虑异常分支、超时出口以及复位路径。下面两章分别展开时钟延展的正确落地和死锁的恢复手段。2. 时钟延展落地从协议原理到寄存器再到异常分支时钟延展不是“开了之后就有”也不是“不开就没问题”。它是一把双刃剑用得好可以从容应对从机内部的数据准备延迟用得糙可能导致主机侧超时甚至因为延展时序不合规被主机判定为通信错误。2.1 时钟延展的本质从机给主机踩了一脚“暂停键”理解时钟延展最直观的方式是把它想象成一个“暂停键”。主机只有在 SCL 高电平期间采样 SDA所以只要从机把 SCL 拉低主机就只能在低电平等待整个数据位传输过程被迫停止但不产生停止位也不会因此中断当前通信。关键在于延展只能发生在 SCL 低电平期间。如果从机在 SCL 高电平期间尝试拉低 SCL就属于违规行为某些严格的主机控制器会直接判定“仲裁丢失”或“START/STOP 错误”。所以从机实现延展时动作必须发生在主机释放 SCL 之后的低电平窗口内。硬件 I2C 外设在标准模式下会自动处理这个问题收到地址字节后如果内部数据寄存器还没有准备好硬件会在 ACK 之后的 SCL 低电平窗口内把 SCL 主动拉低。软件模拟 I2C 时则完全要自己做时序判断。从机侧还需要记住一个边界延展不是无限的。I2C 规范虽然没硬性规定最大延展时间但 SMBus 体系里有明确的 25ms 到 35ms 超时窗口通常取 35ms主机端很容易采纳这个值。当你把一个需要几百毫秒的 Flash 擦除操作放到时钟延展里慢慢等主机早就超时放弃了。所以延展一般只用于微秒到几毫秒级的准备延迟更重的操作应该用 DMA、乒乓缓冲或者内部预取来消除。2.2 硬件外设配置把 NoStretchMode 关掉但别以为这样就完了以 STM32 的硬件 I2C 外设为例配置从机模式时有一个关键选项叫 NoStretchMode禁止延展。默认情况下这个选项是失能的也就是允许时钟延展硬件会在需要时自动拉低 SCL。但不少工程师为了“防止被主机等死”会把 NoStretchMode 设为使能尝试从根上禁止延展。这里我强烈建议保持延展使能。STM32 这类硬件 I2C 外设对字节传输有自己的内部节奏读操作时如果从机数据寄存器没有及时装填硬件会直接输出旧数据或者返回 NACK。用另一个代价去规避“延展超时”往往换来更诡异的数据错乱。HAL 库的配置对应关系比较直观代码如下I2C_HandleTypeDef hi2c1; hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 400000; // 400kbps Fast Mode hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; // 频率参数 hi2c1.Init.OwnAddress1 0x32; // 从机地址7位 hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 0; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; // 关键禁止延展必须为 DISABLE HAL_I2C_Init(hi2c1);不要以为这里配置了“允许延展”就万事大吉。硬件从机在读取数据时你仍然需要及时往发送数据寄存器里写数据。如果代码里用阻塞的方式等待某个信号量而这个信号量所在的线程恰好被更高优先级中断抢占延展时间就会被无限拉长。正确做法是先用 DMA 把待发送数据准备好再让硬件产生“发送中断”中断里直接把下一个字节写入数据寄存器。实际项目中我遇到过这样一次经历一个从机在接收主机的写数据时因为内部状态机要更新参数表需要大约 400 微秒。最初代码把参数更新放进了 I2C 中断里一算中断里执行时间超过 400 微秒对其它紧急中断的影响非常大。后来改成“收到数据后只置一个标志等主循环统一处理”但主循环里更新参数时又从机还在被主机继续访问导致下一帧数据总是旧值。最后是通过双缓冲解决的内部准备区先更新完成后一次性把影子缓冲切换过来。这个过程中从机只用了几微秒的时钟延展就完成了缓冲切换。2.3 软件模拟 I2C 从机自己动手实现延展的正确姿势不少项目为了省去硬件 I2C 外设的配置复杂度会在 GPIO 上直接软件模拟 I2C 从机。软件模拟最大的优势是可控性极强但也意味着时钟延展必须完全手工实现。我从实际项目中提炼出的软件从机延展写法很简单却有几个非常离谱的坑。先给出核心示意// 假设 SCL/SDA 都是开漏配置复用为输入模式时释放总线 void i2c_slave_start_stretch(void) { SCL_GPIO_Mode_Output_OD(); // 输出开漏并拉低 SCL_WriteLow(); // 此时 SCL 被从机主动拉低进入延展 } void i2c_slave_stop_stretch(void) { // 千万不要写成推挽输出高正确做法是切回输入模式 SCL_GPIO_Mode_Input(); // 释放 SCL由上拉电阻把电平拉高 }很多初学者第一次写延展释放 SCL 时会直接调用一个“拉高”函数。对于开漏引脚“拉高”实际就是配置成输入靠外部上拉电阻完成高电平如果误把引脚切回推挽输出高虽然电气上也能输出高但会失去“线与”的能力一旦主从双方同时有设备驱动 SCL很容易产生硬件冲突严重时甚至损坏 GPIO。软件从机实现延展的时间点同样关键。你需要在 SCL 低电平期间完成拉低动作更稳妥的做法是等检测到 SCL 从高跳变到低之后立即在自己的 GPIO 上输出低保持住。如果检测不及时主机可能已经进入高电平采样阶段这时候你再去拉低 SCL就属于违规延展主机不会买账。另外一点很反直觉软件延展期间CPU 既不能睡死也不能响应过慢。我建议把延展逻辑放到一个高优先级中断或者紧循环里处理同时附带一个最大延展时间上限超过上限立刻放弃当前事务释放总线回到空闲状态。不然延展状态下收到一个外部复位SDA 和 SCL 可能就永远僵在那里了。2.4 主机不支持延展时的降级方案给从机一个“延展上限”虽然绝大多数 I2C 主机控制器支持时钟延展但我确实在项目里见过一个不支持延展的主机它写数据时SCL 释放不超过一个固定时间槽一旦超过就判定“从机未响应”立刻进入错误状态。如果从机侧无条件启用延展双方会频繁冲突。这种场景下的降级方案核心不是关闭延展而是“限制延展时间并快速退出”。我从一个量产设备里提炼出的推荐做法是这样三层先把所有需要延展的操作压缩成极短操作比如寄存器写、DMA 搬运而不是把实时操作系统里某个任务的调度塞进延展给延展设置一个较短硬上限比如 5ms 到 10ms超过就直接放弃本次事务释放 SCL 并复位从机状态机让主机自然重试如果主机不支持延展还频繁访问就从协议设计上避开主机在写入数据后主动等待一小段固定间隔比如 1ms再进行下一帧从根本上消除从机延展的触发场景。从机侧主动放弃事务后主机一般会上层重试虽然效率低一点但总比总线卡死强得多。这个取舍我在下一章的死锁恢复里还会再讲。3. SDA 被拉死之后从现场测量到恢复的完整排查链路总线死锁的排查比恢复手段本身更重要。很多人一上来就改代码、调参数结果发现改半个月还不如一次有逻辑的定位快。我把整个排查链路拆成四步每一步都有明确的结论方向和操作收益。3.1 示波器第一眼区分“假忙”和“真死锁”先用示波器或逻辑分析仪挂上 SCL 和 SDA看空闲态时两个引脚的静态电平。正常情况下SCL 和 SDA 都会被外部上拉电阻拉高。如果 SDA 被稳定拉在低电平且 SCL 是高的那基本可以判定 SDA 被某个设备钳位了。但有一种“假忙”很容易骗人从机刚被访问完主机还没来得及发停止位程序里手动把 I2C 控制器复位了此时总线上可能残留一些本来应该由主机发送的时钟脉冲但 SDA 并不会一直保持低。这种现象只要重新初始化 I2C 外设并做一次总线复位就能恢复根本不需要动硬件。为了快速区分我建议记录一个对比表格判断项目真死锁SDA 被钳假忙外设状态残留SDA 电平稳定低纹波很小偶尔有跳变但无完整时序SCL 电平通常为高与 SDA 都有随机跳变主机状态总线忙标志长时间置位复位外设后能自动恢复是否可被外部脉冲恢复可能被恢复绝大多数情况可以两个现象的处理路径完全不同。假忙多数是主机外设状态没有清干净做一次软复位、重新配置 GPIO 即可。真死锁则需要找到是谁在拉低 SDA这一步必须在示波器观察的同时完成。3.2 断开从机法迅速定位是哪个设备在拉 SDAI2C 是漏极开路结构任何一个设备异常拉低都会让 SDA 保持低。在只有一条总线上挂了多个从机的场景下先把疑似出问题的从机物理断开是最直接、最不浪费时间的定位方法。具体操作上我的习惯顺序是先断开最靠近总线上游的从设备看 SDA 是否立刻恢复高电平如果恢复就说明问题出在这颗芯片或者它对应的上拉网络如果没有恢复再断开下一个从设备全部断开后 SDA 依然被拉低那就检查上拉电阻是否焊接正常、电源是否异常。物理断开之后如果 SDA 恢复了就可以把注意力集中到这台从机的固件和硬件电路上。很多工程师跳过这一步直接看代码反而不容易发现问题。因为代码层面的 bug 往往只是在特定时序下触发而物理隔离能立刻确认“这是不是它”。怎么判断是哪种原因导致的钳位再到代码里验证就很有的放矢了。比如我遇到过一颗温度传感器在掉电瞬间会拉低 SDA因为它的 I/O 保护二极管反向漏电本质上是硬件问题不是固件能解决的。3.3 恢复手段逐级拆解9 个时钟脉冲、软件复位、硬件复位确认是从机固件导致 SDA 被钳位之后恢复手段要从“不打乱系统”开始逐级升级。第一级恢复额外时钟脉冲。I2C 协议允许在总线异常时给从机连续施加 9 个以上时钟脉冲让从机内部状态机把当前位计数清掉通常从机就会释放 SDA。这个手段由主机侧发起操作时把主机的 SCL 引脚临时配置成 GPIO 输出SDA 保持上拉输入然后连续翻转 9 到 16 次时钟每次翻转后检查 SDA 是否已经恢复高电平。我之前在 Linux 用户态调试时经常这么干用 ioctl 直接操作 I2C 适配器的 GPIO省去写内核驱动# 假设 I2C 控制器挂在 i2c-1适配器支持 gpio 模式 # 先查 SCL/SDA 的 GPIO 编号再通过标准 gpio 接口翻转 echo scl_gpio /sys/class/gpio/export echo out /sys/class/gpio/gpioscl_gpio/direction for i in $(seq 1 16); do echo 0 /sys/class/gpio/gpioscl_gpio/value echo 1 /sys/class/gpio/gpioscl_gpio/value # 检查 SDA 电平若变高则终止 done第二级恢复重新初始化从机外设。9 个脉冲恢复不了时多半是从机固件卡在了 I2C 中断里。先尝试用软件复位外设比如给从机的复位引脚一个低脉冲或者调用固件里的复位程序把从机拉回初始状态。这里特别强调软件复位后从机的 GPIO 必须重新初始化成开漏模式并且先释放总线再初始化外设。第三级恢复彻底掉电。掉电能恢复绝大多数 I2C 死锁尤其是从机内部晶体管可能闩锁的时候。前提是系统设计允许对单设备掉电并且掉电顺序能保证 SDA 不再被反向驱动。恢复顺序里最容易犯的错误是直接跳到“复位整个系统”。复位主控当然会把总线状态清掉但从机如果还保持着异常输出总线依然恢复不了。所以正确的检查顺序永远是“先观察从机、后复位主机”。3.4 为什么有时候“重启从机”还是救不回来明明已经从机上执行了软复位SDA 却还是低电平这种情况我见过太多次。背后的原因往往不是“复位没生效”而是“复位范围不够”。很多 ARM 芯片的软复位函数例如NVIC_SystemReset()只复位内核和外设寄存器GPIO 的端口复用配置和输出状态不一定被清除。如果从机 I2C 引脚被配置成推挽输出低复位后这个电平状态仍然被保持SDA 照样是低。更隐蔽的是另一种情况复位程序在初始化 GPIO 时先把它配置成了某个特殊功能模式再去切换成开漏模式中间某个瞬间不小心输出了一次低电平。看起来是“复位了”实际又被自己的初始化代码拉低了一次。这里我总结出三条从机复位时的硬性要求复位后第一件事把 SDA 和 SCL 的 GPIO 强制配置为输入模式高阻状态确保总线处于外部上拉控制的空闲电平再去做其它初始化初始化 I2C 外设前延时一小段时间至少 100us 到 1ms让总线上残存的电平稳定如果系统里有独立的电源域优先尝试对从机单独断电而不是只做外设软复位。做到这三条之后剩下还能把总线拉低的场景几乎只有硬件闩扣或外设损坏了那已经不是固件能解决的问题。4. 让从机在真实总线上“扛得住”鲁棒性设计要点前两章解决了“故障怎么恢复”的问题。但更高级的工程能力是把这些异常在源头想办法压住让从机不是总靠“事后救火”才能存活。鲁棒性设计看起来不酷但在量产设备里它的价值远高于写出一堆花哨的时序代码。4.1 电气基本功开漏、上拉、总线电容一个都不能含糊先从 GPIO 模式选择说起。I2C 引脚的 GPIO 必须配置成开漏模式原因很简单只有开漏才能实现“线与”特性。在多主机环境下如果某个节点用推挽输出高它在输出高电平时会把 SDA/SCL 直接拉到电源轨和其它节点输出的低电平打架轻则通信错乱重则引起大电流损坏芯片。另一个容易忽略的参数是上拉电阻。标准模式下I2C 上拉电阻常见取值是 4.7kΩ快速模式400kHz常取 2.2kΩ高速模式则要更小。上拉电阻太大配合总线电容会形成一个 RC 低通滤波SCL/SDA 的上升沿变缓严重时会被主机误判为时序违规。总线电容又和线长、节点数、PCB 布局直接相关。设备数量多、线缆长时建议实际用示波器测一下上升沿时间。我做过一个简单验证一条挂了 6 个 I2C 从设备的总线总线电容大约 200pF4.7kΩ 上拉时的上升沿已经接近 1us100kHz 模式下还算安全一旦把速率调到 400kHz主机的采样点直接落在上升沿上随机出错。后来把并联上拉改成 1.5kΩ瞬间恢复了正常。4.2 给所有“无限等待”加一个超时上限鲁棒性的核心思想是把协议里所有“可能无限等待”的环节加上超时。从机侧至少要有两个方向的超时等待内部处理准备完成的超时对应时钟延展的上限。前面提到 SMBus 推荐 35ms我一般会给到 50ms留一点余量超过就放弃事务等待主机下一个时钟或停止位的超时即从机已经释放 SCL 后主机迟迟没有继续产生时钟时同样需要超时。主机可能中途掉电、崩溃重启如果没有这个超时从机会一直悬在“数据准备完但主机已消失”的状态。具体超时参数的选取要先看总线速率等级。下面这个表是我在几个量产项目里用过的经验值总线速率最大时钟延展时间等待主机时钟超时总线恢复重试间隔100 kHz≤ 5 ms200 ms50 ms400 kHz≤ 2 ms100 ms20 ms1 MHz≤ 1 ms50 ms10 ms超时实现可以选择一个硬件定时器也可以直接在 I2C 中断里用系统 tick 计数。但要注意延展期间如果只是一层 while 循环空转很容易把整个系统卡住。我建议把“延展等待”和“超时计算”都放到一个专门的状态机里而不是写一个while(!ready) { ; }死等。4.3 错误状态机把 NACK、仲裁丢失和总线错误都归拢起来从机固件要处理的错误类型不止“死锁”一种。硬件 I2C 外设通常都会提供错误标志比如仲裁丢失ARLO、总线错误BERR、应答错误NACK等。这些错误如果只做“清标志继续跑”下一次可能还会异常。我习惯给从机写一个精简的状态机核心状态包括空闲IDLE、地址接收ADDR、数据传输DATA、停止或重复起始STOP/RESTART。每次中断事件都会带着一个“当前状态”的上下文可以很清楚地判断“这个事件在哪个状态发生才合理”。比如收到一个 STOP 事件时如果当前状态是“地址接收但数据还没准备好”说明主机没有完成本次事务此时不能直接把数据寄存器清掉而是应该回滚到空闲状态并保留缓冲区的内容。这种按状态区分错误处理的方式比单纯处理“收到 NACK 就重发”要稳得多。从机收到非法地址或非法寄存器地址时也要快速返回空闲态而不是进入死等。我见过一个从机固件收到主机写了不支持的子地址后直接卡在等待数据寄存器被读出的 while 循环里导致整个 I2C 中断一直被占着其它功能全部瘫痪。后来我把所有“等待”都改成“等待到超时即退出”问题就消失了。4.4 闭环验证故意搞挂总线看从机多久能爬出来鲁棒性设计做没做到位不能靠“感觉稳定”要靠主动测试。我在正式项目里会专门写一个故障注入测试脚本常规场景包括下面六项主机在从机处理事务的中途强制复位然后立即重新发起通信主机发送 9 个额外时钟脉冲后检查从机是否能释放 SDA从机在线时直接把总线对地短路几十毫秒然后断开短路看从机能否自动恢复让主机在从机延展期间主动超时并重新 START看从机是否还留在旧事务里热插拔从机带带电拔插头看总线会不会被拔插瞬间的毛刺打死外设掉电再上电检查 SDA/SCL 是否被残留电平钳住。故障注入后记录从机的恢复时间、重试次数以及是否出现过不可恢复的状态然后针对性修改固件。我实际测试过一个自称“很稳定”的样机故障注入测试下平均 20 次操作里会出现 1 次总线无法恢复。原因竟然是它在收到主机超时重发后没有及时清除内部的地址匹配标志把第二次地址当成了新的数据字节来处理。故障测试做完心里就有底了。很多从机固件在实验室单机环境下完全正常一上整机总线就出幺蛾子绝大多数是这类异常分支没有做闭环验证。最后再分享一个小技巧从机侧设计里把“总线自愈”能力当成一个必须存在的功能模块而不是“出 bug 之后才开始写的东西”。在做真机调试时死锁恢复路径先写最简单的 9 时钟脉冲版本再逐步增强到外设复位和掉电自检。这套思路帮我处理过不少难缠的总线问题比一上来就堆代码效率高得多。
返回列表