嵌入式面试真题第 06 题:I2C SDA 被拉低死锁后的软件恢复

在这里插入图片描述

问题

一款样机在产线上频繁死机,经抓取日志发现死在了一个 I2C 读取函数内的 while 等待应答 ACK 死循环里。进一步使用示波器或逻辑分析仪确认,I2C 总线的 SDA 线被外部从设备意外拉低,主机 I2C 控制器一直认为总线忙或者一直等待应答,导致后续访问全部失败。

除了给等待循环增加超时机制以外,还能通过什么软件 IO 操作序列去拯救并复位这条死锁的总线?恢复时需要如何处理 SCL、SDA、I2C 外设状态、从设备状态、线程并发和失败兜底?

回答

结论:应实现 I2C Bus Recovery。核心做法是:先停止硬件 I2C 控制器并解除它对 SCL/SDA 的复用驱动,然后把 SCL、SDA 临时切换成 GPIO 开漏模式;在不推挽强拉总线的前提下释放 SDA,并手动给 SCL 输出若干个时钟脉冲,让卡在中间 bit、ACK 或 byte 状态的从设备继续推进内部状态机;当 SDA 被释放后,在 SCL 为高电平时让 SDA 从低到高,生成一个标准 STOP 条件;最后再复位 I2C 外设、清除错误状态、恢复引脚复用并重新初始化控制器。

这类问题不能只回答“加超时”。超时只能防止应用线程永久卡死,不能把已经处于 BUSY 或 SDA Low 的物理总线恢复为空闲状态。真正完整的工程解法至少包括四层:第一层是事务超时和错误码返回,第二层是 GPIO bit-bang 式总线恢复,第三层是 I2C 控制器软件复位和驱动状态机重建,第四层是从设备级复位、电源隔离和故障上报。

NXP I2C-bus specification and user manual 在 Bus clear 小节中给出了关键原则:如果 SDA 数据线被卡在低电平,控制器应发送 9 个时钟脉冲,持有总线低电平的设备应在这些时钟内释放 SDA。Linux I2C 故障注入文档也把“强制 SDA 低电平后,主机应检测并触发 bus recovery”作为驱动应处理的故障场景,并指出可参考 Linux I2C core 的 bus_recovery_info 机制。Zephyr 也提供了 I2C recovery 相关 API,说明在 RTOS/驱动框架中把恢复逻辑做成标准接口是合理的工程实践。

总体架构

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
flowchart LR
A[应用线程 / 传感器任务 / 产测任务] --> B[I2C Client Driver]
B --> C[I2C Core / Adapter Lock]
C --> D{I2C Controller Driver}
D -->|正常事务| E[SCL/SDA AF 复用]
D -->|超时 / BUSY / SDA Low| F[Bus Recovery Manager]
F --> G[关闭控制器
禁用中断和 DMA]
G --> H[GPIO Open-Drain
控制 SCL / 采样 SDA]
H --> I[发送 9~16 个 SCL 脉冲]
I --> J[生成 STOP 条件]
J --> K[复位 I2C 外设
恢复复用和状态]
K --> C
E --> L[I2C 从设备]
H --> L

这个架构里,应用线程看到的是一个有超时、有错误码、有重试策略的 I2C 访问接口,而不是一个可能无限循环的裸 while (wait_ack)。驱动层在检测到超时、BUSY 位长期不清、SDA 长期为低、仲裁丢失、总线错误或 NACK 异常后,进入恢复流程。恢复流程不应散落在每个传感器驱动里,而应尽量放在 I2C adapter 或 bus 层,由同一套互斥、状态机和日志策略统一管理。

机制与 Linux/RTOS/规范的对应关系

本题机制 标准或开源机制 能否直接使用 主要参考价值
SDA stuck low 后发送 SCL 脉冲 NXP UM10204 Bus clear 规范级原则,所有平台都应参考 SDA 被卡低时发送 9 个时钟脉冲,推动从设备释放 SDA。
GPIO 方式恢复总线 Linux I2C core bus_recovery_info、GPIO fault injection Linux 平台可直接接入内核恢复框架;MCU 平台参考思想 将恢复动作抽象成 get_sclset_sclget_sdaprepare_recoveryunprepare_recovery 等硬件适配点。
事务超时和错误码 Linux I2C/SMBus fault codes Linux 驱动应按 errno 语义返回;MCU 驱动可定义等价错误码 让上层知道是 NACK、超时、仲裁、总线忙还是恢复失败,而不是统一返回失败。
RTOS 中的恢复 API Zephyr i2c_recover / i2c_rtio_recover Zephyr 平台可使用已有 API;自研 RTOS 可参考接口形式 将恢复动作放入总线设备能力,而不是放入单个传感器业务代码。
故障注入测试 Linux I2C GPIO fault injection Linux 可直接使用;MCU 可用测试夹具或产测治具模拟 用人为拉低 SDA/SCL 的方式验证恢复逻辑是否可靠。
从设备复位兜底 reset GPIO、load switch、电源域控制 依赖硬件设计 GPIO 脉冲失败时必须有比“继续死等”更强的恢复手段。

先区分:超时机制和总线恢复不是一回事

超时机制解决的是软件控制流问题,总线恢复解决的是物理总线状态问题。样机死在 while 等待 ACK 内,说明至少有一个驱动路径没有退出条件。给 while 增加超时之后,系统不再永久阻塞,这是必要动作;但如果超时后只是返回错误,而 SDA 仍然被从设备拉低,下一次访问仍会失败,甚至 I2C 控制器初始化后仍然读到 BUSY。此时必须有总线级恢复。

可以把问题分成三种层级:

层级 典型现象 仅加超时是否足够 应对动作
软件等待死循环 while 等 ACK、TXE、RXNE、STOPF 或 BUSY 无退出 否,但能防止系统卡死 所有等待点加 deadline;返回明确错误码。
总线物理状态异常 SDA 或 SCL 被拉低,控制器 BUSY 不清 不足够 GPIO bus recovery,必要时复位控制器。
从设备内部挂死 SDA/SCL 恢复脉冲无效,从设备不再响应 不足够 reset GPIO、断电重上电、隔离故障设备。

因此工程实现中,超时是进入恢复流程的触发条件之一,不是恢复流程本身。面试回答时应把两者明确区分:不允许继续死等;超时后进入 bus recovery;bus recovery 成功后恢复控制器;恢复失败后上报并隔离。

I2C 为什么会出现 SDA 被拉低死锁

I2C 是开漏或开集电极风格的双线总线。SCL 和 SDA 通常依靠上拉电阻回到高电平,总线上的任意设备都可以把线拉低。正常情况下,主机控制 SCL,从设备在 ACK 周期或读数据周期驱动 SDA。由于“高电平”主要由上拉电阻提供,主机不能简单理解为“自己输出高就一定高”,它只能释放线并读取线上真实电平。

SDA 被拉低死锁常见于以下几类场景。

第一类是主机在一次读事务中异常复位或事务中断。从设备已经进入发送数据或应答阶段,正在把 SDA 拉低表示数据 bit 0 或 ACK。主机重启后硬件 I2C 控制器看到 SDA 低,认为总线忙,不再产生新的 START 或 SCL。与此同时,从设备还在等待 SCL 的后续边沿来完成当前 bit 或 ACK 周期,于是双方都在等待对方。

第二类是驱动层在 ACK 或 RXNE 等等待点被高优先级任务长时间抢占,之后控制器状态机和从设备状态机不再同步。若驱动没有处理超时、仲裁丢失、总线错误和 STOP 生成失败,就可能把半截事务留在总线上。

第三类是电源时序不一致。主机已经复位或进入低功耗,而从设备仍然带电;或者从设备电源先掉,I/O 管脚通过保护二极管、内部状态机或异常锁存影响 SDA。产线反复上下电、热插拔夹具、夹具接触抖动都容易制造这种问题。

第四类是从设备固件或硬件 bug。部分 I2C 设备内部也有状态机、FIFO、时钟延展逻辑、复位条件和异常恢复路径。如果设备内部状态机卡死,单纯 SCL 脉冲可能无法释放 SDA,只能复位该从设备或切断其电源。

典型死锁时序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sequenceDiagram
participant M as 主机 I2C 控制器
participant S as 从设备
participant Bus as SCL/SDA 总线

M->>Bus: START + 地址 + 读命令
S-->>Bus: ACK,拉低 SDA
M->>Bus: 产生若干 SCL,准备读取数据
S-->>Bus: 输出数据 bit,其中某一位为 0,SDA 低
Note over M: 主机复位、异常跳出、驱动卡住或控制器停机
Note over S: 从设备仍停留在当前 bit/ACK 状态
M->>Bus: 重启后尝试初始化 I2C
Bus-->>M: SDA 仍为低,总线被判定 BUSY
Note over M,S: 主机不产生 SCL,从设备等 SCL,形成死锁

这个场景的关键在于:从设备不是“故意占用总线”,它可能只是停在了协议中间态。只要再给足够的 SCL 边沿,它就可能把剩余 bit、ACK 或内部移位寄存器走完,然后释放 SDA。Bus recovery 的本质就是用 GPIO 方式替代已经陷入 BUSY 的 I2C 控制器,手动把协议时序推进到可生成 STOP 的位置。

软件恢复的推荐完整流程

在这里插入图片描述

推荐把恢复流程实现成一个总线级函数,例如 i2c_bus_recover(),由 I2C adapter 在发现异常后调用。该函数的基本步骤如下。

1. 拿总线互斥锁,阻止并发访问

恢复动作必须在总线互斥锁保护下执行。I2C 是共享总线,同一条总线上可能挂多个 client driver。若一个线程正在恢复总线,另一个线程又发起普通 I2C 事务,就会把恢复脉冲、STOP 条件和控制器复位互相打断,结果可能更糟。

建议在 bus 层维护以下状态:

1
2
3
4
5
IDLE        总线空闲,可发起事务
XFER 正在执行普通事务
RECOVERING 正在执行总线恢复
FAILED 恢复失败,需要上报或复位外设
SUSPENDED 低功耗或电源域不可用

进入 RECOVERING 后,上层新事务应等待、返回 -EBUSY、返回 -EAGAIN 或进入有限重试,具体取决于业务实时性。无论采用哪种策略,都不能让新事务绕过总线状态机直接访问硬件寄存器。

2. 退出硬件 I2C 外设

在切换 GPIO 之前,应先停止硬件 I2C 控制器。典型动作包括:

  1. 禁止 I2C 中断,避免恢复过程中 ISR 误判 START、STOP、BERR、ARLO 或 NACK。
  2. 停止 DMA 通道,清理 TX/RX DMA descriptor 或半完成状态。
  3. 如果控制器支持软件复位,先置位 reset 或 disable 控制器。
  4. 清除驱动私有状态机中的 busy、msg pointer、remaining length、error flag。
  5. 记录进入恢复前的寄存器快照,便于产线定位。

不建议在 I2C 控制器仍然占用 AF 复用时直接操作 GPIO 输出寄存器。多数 MCU 上同一引脚的 GPIO 输出控制和外设复用输出不是同一个数据路径;即使写了 GPIO ODR/BSRR,也可能不会真正影响引脚。正确做法是先禁用或退出 I2C 外设,再把 SCL/SDA 的 pinmux 切到 GPIO。

3. SCL/SDA 切换为 GPIO 开漏并保持上拉

I2C 总线恢复必须遵守开漏语义:输出低电平表示主动拉低;输出高电平表示释放,由上拉电阻把线拉高。不要把 SDA 或 SCL 配置为推挽输出并强行拉高,因为总线上可能还有设备正在合法拉低。推挽强拉高与从设备拉低会形成总线争用,轻则电流异常、波形畸变,重则损坏 IO。

GPIO 配置建议如下:

引脚 模式 输出高的含义 输出低的含义 备注
SCL GPIO open-drain output 释放 SCL,等待上拉为高 主机手动拉低 SCL 恢复时由主机产生脉冲,但仍应读取真实 SCL。
SDA GPIO open-drain output/input 释放 SDA,等待上拉为高 必要时拉低 SDA 以产生 STOP 前半段 通常先释放 SDA,只在生成 STOP 时短暂拉低。

若硬件没有外部上拉而只依赖内部上拉,应谨慎评估上升时间。内部上拉往往阻值较大,恢复脉冲速度应放慢,延时应留足。恢复不追求高速,通常使用几微秒到几十微秒的半周期即可。产线问题定位时,建议把恢复脉冲做得足够慢,便于示波器确认。

4. 采样 SCL 和 SDA 的真实电平

切到 GPIO 后,应先释放 SCL/SDA,然后读取输入电平。注意读取的是引脚输入状态,不是输出寄存器影子值。

  1. 如果 SCL 为低,说明从设备可能正在做 clock stretching,或者某个设备/短路把 SCL 拉低。此时主机无法产生有效时钟脉冲。可以等待一个很短的 clock-stretch 超时;若仍低,应直接判定普通 SDA 恢复失败,进入从设备复位或电源复位。
  2. 如果 SCL 为高、SDA 为高,说明总线已经空闲或接近空闲。仍建议产生一个 STOP 或控制器复位后再恢复普通访问。
  3. 如果 SCL 为高、SDA 为低,说明正是本题典型场景,可以进入 SCL clock recovery。

5. 手动输出 9 到 16 个 SCL 脉冲

规范层面,SDA stuck low 的 bus clear 关键动作是产生 SCL 时钟。工程上常见实现是 9 个脉冲,也有人做 16 个、18 个或最多一个固定上限。9 的来源是 I2C 一次字节传输包含 8 个数据位加第 9 个 ACK/NACK 位;如果从设备停在一个字节内,最多 9 个时钟边沿就应能推进到 ACK/NACK 或下一个字节边界。

推荐实现上不要机械只打 9 个而完全不采样 SDA。更稳妥的做法是:每个高电平阶段都采样 SDA;如果 SDA 已经释放为高,立即跳出脉冲循环,转入 STOP 生成。若 9 个后仍低,可以按平台经验扩展到 16 个或 18 个,但必须设置上限,不能无限打脉冲。

伪时序如下:

1
2
3
4
5
6
7
8
9
10
11
12
release(SDA)
for i in 0 .. max_pulses - 1:
release(SCL)
wait t_high
if read(SCL) == 0:
return SCL_STUCK_LOW
if read(SDA) == 1:
break
drive_low(SCL)
wait t_low
release(SCL)
wait t_high

其中 release(SCL) 不是推高 SCL,而是开漏输出高或切输入,让上拉电阻拉高。read(SCL) 用于识别 SCL 被其他设备拉低的异常。如果 SCL 高不起来,后续脉冲没有协议意义。

6. 生成 STOP 条件

I2C STOP 条件定义为:SCL 为高电平期间,SDA 从低电平跳变到高电平。因此在 GPIO 模式下应按以下方式手动构造:

1
2
3
4
5
6
drive_low(SDA)
wait t_setup
release(SCL)
wait until SCL high or timeout
release(SDA)
wait bus free time

如果 SDA 已经是高电平,为什么还要先拉低再释放?原因是要形成一个明确的低到高跳变。许多从设备只有看到 STOP 时序才会重置内部 I2C 状态机。如果直接保持 SDA 高,不一定能被识别为 STOP。实际实现中,也可以先构造一个 START 再 STOP,但最小动作通常是保证 SCL 高时产生 SDA 低到高。

仍然要强调,拉低 SDA 前必须确认没有其他主机正在使用总线。单主机 MCU 系统一般可由总线锁保证;多主机系统必须额外谨慎,否则手动 STOP 可能破坏其他主机事务。

7. 复位 I2C 外设并恢复 pinmux

GPIO 恢复完成后,不应直接把引脚切回 AF 就认为万事大吉。硬件 I2C 控制器内部可能仍保留 BUSY、BERR、ARLO、OVR、NACK、STOPF、TXE/RXNE、DMA request 等状态。推荐做一次控制器级 reset 或完整 deinit/init。

典型流程:

  1. 将 I2C 外设保持 disable。
  2. 清除 pending interrupt 和错误 flag。
  3. 复位 I2C 外设时钟或使用外设 reset bit。
  4. 重新配置 timing、own address、filter、ack、DMA、中断。
  5. 把 SCL/SDA 恢复为 I2C AF open-drain。
  6. 读取总线电平,确认 SCL/SDA 均为高。
  7. 恢复 adapter 状态为 IDLE。

一些 MCU 的 I2C BUSY 位在外设看到 SDA 低时会一直置位,只有外设 reset 或 STOP 检测后才清除。不同厂商寄存器细节不一样,所以这里应写成平台适配层,而不是把某个芯片的寄存器序列硬编码到通用逻辑里。

8. 恢复失败后复位从设备或电源域

若输出 SCL 脉冲、生成 STOP、复位控制器后,SDA 或 SCL 仍然为低,说明问题可能不再是协议中间态,而是从设备挂死、硬件短路、夹具接触异常、ESD 损伤、电源域反灌或某个设备持续 clock stretching。此时软件应进入更强兜底:

兜底手段 适用前提 说明
reset GPIO 从设备 reset 引脚接到 MCU 拉复位保持满足 datasheet 要求,再释放并等待启动时间。
load switch 断电 从设备电源可控 断电时间要覆盖从设备 POR 要求,恢复后重新初始化寄存器。
I2C mux 隔离 总线经 PCA954x 等 mux 分段 逐段隔离,定位拉低总线的分支。
功能降级 非关键传感器或可选外设 屏蔽该设备,继续系统主功能。
故障上报 产线或现场可靠性要求 记录恢复失败次数、总线号、SCL/SDA 电平、设备地址和最近事务。

恢复失败时不能继续在 while 中死等 ACK,也不能在高优先级线程里无限重试。应返回明确错误,触发设备级故障管理。

为什么常说“9 个 SCL 脉冲”

I2C 以字节为单位传输,一个字节由 8 个数据位和第 9 个 ACK/NACK 位组成。发送方在第 9 个时钟释放 SDA,接收方在该位上拉低 SDA 表示 ACK,或者保持释放表示 NACK。因此若从设备卡在一个字节事务的任意位置,继续给 SCL 时钟一般能把它推进到字节边界,随后通过 NACK 或 STOP 使其退出当前事务。

这就是“9 个 clock recovery”的由来。但实际工程中要注意三个边界条件。

第一,9 个脉冲是对 SDA 被卡低且 SCL 可控时的恢复策略,不是所有 I2C 故障的万能解。SCL 被拉低时,主机无法形成有效脉冲,应先判断是否 clock stretching 超时或硬件短路。

第二,有些从设备内部状态机更复杂,可能需要超过 9 个边沿才释放,尤其是主机在多字节读中断、从设备内部 FIFO/移位状态异常、或者主机复位发生在非常规窗口时。因此很多平台会使用 max_pulses = 9 作为规范最小值,使用 max_pulses = 1618 作为工程上限。上限必须固定,不能无限循环。

第三,恢复脉冲只是让从设备“有机会”释放 SDA。真正让总线回到协议空闲态还需要 STOP 条件和控制器复位。只打 9 个 SCL 后立即重新启用 I2C 外设,可能留下半截事务状态。

引脚电气与 IO 模式细节

I2C 的开漏特性决定了恢复动作必须尊重总线电气。很多现场 bug 不是恢复算法错,而是 IO 模式、上拉、电平域、复用状态或读写方向处理错误。

开漏输出的正确含义

在 GPIO open-drain 模式下:

1
2
输出 0:IO 晶体管导通,主动把线拉低。
输出 1:IO 晶体管截止,释放该线,由上拉电阻拉高。

所以代码里最好不要写 gpio_set_high() 这种容易误导的名字,而应使用 i2c_gpio_release_scl()i2c_gpio_drive_scl_low()i2c_gpio_release_sda()i2c_gpio_drive_sda_low() 之类的语义函数。这样能避免后来维护者把开漏释放误理解为推挽输出高。

读取输入状态,而不是输出锁存值

恢复过程中要读取的是管脚输入状态,也就是总线真实电平。若读取的是输出寄存器,代码会误以为 SCL/SDA 已经高了,但实际线上可能仍被从设备拉低。尤其在 STM32、NXP、Renesas、TI、Nordic 等不同 MCU 平台,GPIO 输出寄存器和输入数据寄存器是两个概念,恢复代码必须调用能读到 pin input 的 API。

延时要按总线和上拉实际情况留足

恢复时没有必要追求 100 kHz、400 kHz 或 1 MHz。产线恢复动作可以很慢,例如 SCL 高低各 5 us、10 us、20 us,甚至更长。关键是让从设备可靠识别边沿,并给上拉电阻足够时间把总线拉高。若总线电容大、上拉弱或夹具较长,边沿过快会导致采样误判。

不要在多主机总线上盲目恢复

如果系统是单主机 MCU,对总线互斥锁和本机 I2C 控制器状态负责即可。如果系统存在多主机,例如外部测试仪、协处理器、PMIC、EC 或 BMC 也能成为 I2C master,那么恢复前必须确认总线所有权。GPIO 手动 STOP 和 SCL 脉冲可能破坏其他 master 的合法事务。多主机设计中更推荐在硬件层做隔离、仲裁或由统一 owner 执行恢复。

恢复状态机设计

I2C bus recovery 不应写成几个散乱的 goto。建议显式建模状态机,并且每个状态都有清晰的进入条件、退出条件、超时和日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
stateDiagram-v2
[*] --> Normal
Normal --> TimeoutDetected: ACK/RX/TX/BUSY timeout
TimeoutDetected --> RecoveryPrepare: lock bus + stop controller
RecoveryPrepare --> GpioMode: pinmux to GPIO OD
GpioMode --> CheckLines: release SCL/SDA + sample pins
CheckLines --> StopOnly: SCL=1 && SDA=1
CheckLines --> ClockRecovery: SCL=1 && SDA=0
CheckLines --> SlaveReset: SCL=0 timeout
ClockRecovery --> StopOnly: SDA released
ClockRecovery --> SlaveReset: pulse limit reached
StopOnly --> ControllerReset: generate STOP
ControllerReset --> VerifyIdle: reset I2C + restore AF
VerifyIdle --> Normal: SCL=1 && SDA=1
VerifyIdle --> SlaveReset: still busy / line low
SlaveReset --> Normal: reset success
SlaveReset --> Failed: reset failed
Failed --> [*]

推荐记录以下事件字段:

字段 说明
bus_id 第几路 I2C,例如 i2c0/i2c1。
owner 当前访问的 client driver 或设备地址。
last_addr 最近一次访问的 7-bit/10-bit 地址。
last_op read/write/write-read/register-read 等事务类型。
last_reg 若是寄存器访问,记录寄存器地址。
timeout_stage 卡在 BUSY、ADDR、ACK、TXE、RXNE、STOP、DMA complete 等哪个阶段。
scl_level/sda_level 恢复前后的真实线电平。
pulse_count 实际输出了多少个 SCL 脉冲。
recover_result 成功、SCL stuck、SDA stuck、controller reset failed、slave reset failed。
retry_count 本设备或本事务的重试次数。

产线问题最怕“恢复了但没有证据”。如果只打印 i2c error,后续很难判断是哪个设备拉低总线、发生在哪个事务、恢复是否成功。恢复日志应可被测试系统统计,至少能输出 bus、address、stage、line level 和 result。

C 语言伪代码设计

下面是一个可移植的 C 语言伪代码框架。它不是某个 MCU 的完整驱动,但展示了工程上应有的接口边界、Doxygen 注释、开漏语义和失败路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* @brief I2C bus line level snapshot.
*/
typedef struct i2c_bus_line_state {
bool scl_high; /**< True if the physical SCL pin is sampled as high. */
bool sda_high; /**< True if the physical SDA pin is sampled as high. */
} i2c_bus_line_state_t;

/**
* @brief Platform operations required by generic I2C bus recovery.
*/
typedef struct i2c_recovery_ops {
/**
* @brief Disable the hardware I2C controller before GPIO recovery.
*
* The implementation should mask I2C interrupts, stop DMA if used, and
* make sure the peripheral no longer drives SCL or SDA through alternate
* function output.
*/
int (*prepare)(void *ctx);

/**
* @brief Restore the hardware I2C controller after GPIO recovery.
*
* The implementation should restore pinmux, reset or reinitialize the
* controller, clear pending status bits, and make the adapter ready for a
* new transfer.
*/
int (*unprepare)(void *ctx);

/** @brief Configure SCL and SDA as GPIO open-drain pins. */
int (*gpio_od_init)(void *ctx);

/** @brief Release SCL. This must not drive SCL high in push-pull mode. */
void (*release_scl)(void *ctx);

/** @brief Actively pull SCL low using open-drain output. */
void (*drive_scl_low)(void *ctx);

/** @brief Release SDA. This must not drive SDA high in push-pull mode. */
void (*release_sda)(void *ctx);

/** @brief Actively pull SDA low using open-drain output. */
void (*drive_sda_low)(void *ctx);

/** @brief Read the physical SCL pin input level. */
bool (*read_scl)(void *ctx);

/** @brief Read the physical SDA pin input level. */
bool (*read_sda)(void *ctx);

/** @brief Busy-wait or sleep for the requested number of microseconds. */
void (*delay_us)(void *ctx, uint32_t us);

/**
* @brief Optional target reset fallback.
*
* This hook may toggle a reset GPIO, cycle a load switch, or reset an I2C
* mux branch. It may be NULL if the board has no such hardware support.
*/
int (*reset_target)(void *ctx);
} i2c_recovery_ops_t;

恢复主流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#define I2C_RECOVERY_MAX_PULSES        16U
#define I2C_RECOVERY_T_HIGH_US 10U
#define I2C_RECOVERY_T_LOW_US 10U
#define I2C_RECOVERY_BUS_FREE_US 10U
#define I2C_RECOVERY_SCL_WAIT_RETRY 100U

/**
* @brief Wait until SCL is released high or a small timeout expires.
*
* @param ops Platform recovery operations.
* @param ctx Platform private context.
* @return 0 if SCL is sampled high, otherwise a negative error code.
*/
static int i2c_recovery_wait_scl_high(const i2c_recovery_ops_t *ops, void *ctx)
{
uint32_t i;

for (i = 0U; i < I2C_RECOVERY_SCL_WAIT_RETRY; i++) {
if (ops->read_scl(ctx)) {
return 0;
}
ops->delay_us(ctx, 1U);
}

return -ETIMEDOUT;
}

/**
* @brief Generate a STOP condition with GPIO open-drain pins.
*
* STOP is defined as SDA rising while SCL is high. This function first pulls
* SDA low, releases SCL, waits for SCL high, and then releases SDA.
*
* @param ops Platform recovery operations.
* @param ctx Platform private context.
* @return 0 on success, otherwise a negative error code.
*/
static int i2c_recovery_generate_stop(const i2c_recovery_ops_t *ops, void *ctx)
{
ops->drive_sda_low(ctx);
ops->delay_us(ctx, I2C_RECOVERY_T_LOW_US);

ops->release_scl(ctx);
if (i2c_recovery_wait_scl_high(ops, ctx) != 0) {
return -EBUSY;
}

ops->delay_us(ctx, I2C_RECOVERY_T_HIGH_US);
ops->release_sda(ctx);
ops->delay_us(ctx, I2C_RECOVERY_BUS_FREE_US);

return ops->read_sda(ctx) ? 0 : -EBUSY;
}

/**
* @brief Recover an I2C bus whose SDA line may be stuck low.
*
* @param ops Platform recovery operations.
* @param ctx Platform private context.
* @return 0 if the bus is recovered and both lines are released, otherwise a
* negative error code.
*/
int i2c_bus_recover(const i2c_recovery_ops_t *ops, void *ctx)
{
uint32_t pulse;
int ret;

if ((ops == NULL) || (ops->prepare == NULL) || (ops->unprepare == NULL) ||
(ops->gpio_od_init == NULL) || (ops->release_scl == NULL) ||
(ops->drive_scl_low == NULL) || (ops->release_sda == NULL) ||
(ops->drive_sda_low == NULL) || (ops->read_scl == NULL) ||
(ops->read_sda == NULL) || (ops->delay_us == NULL)) {
return -EINVAL;
}

ret = ops->prepare(ctx);
if (ret != 0) {
return ret;
}

ret = ops->gpio_od_init(ctx);
if (ret != 0) {
(void)ops->unprepare(ctx);
return ret;
}

ops->release_scl(ctx);
ops->release_sda(ctx);
ops->delay_us(ctx, I2C_RECOVERY_T_HIGH_US);

if (i2c_recovery_wait_scl_high(ops, ctx) != 0) {
ret = -EBUSY;
goto fallback;
}

if (!ops->read_sda(ctx)) {
for (pulse = 0U; pulse < I2C_RECOVERY_MAX_PULSES; pulse++) {
ops->drive_scl_low(ctx);
ops->delay_us(ctx, I2C_RECOVERY_T_LOW_US);

ops->release_scl(ctx);
if (i2c_recovery_wait_scl_high(ops, ctx) != 0) {
ret = -EBUSY;
goto fallback;
}

ops->delay_us(ctx, I2C_RECOVERY_T_HIGH_US);
if (ops->read_sda(ctx)) {
break;
}
}
}

ret = i2c_recovery_generate_stop(ops, ctx);
if (ret != 0) {
goto fallback;
}

ret = ops->unprepare(ctx);
if (ret != 0) {
return ret;
}

return 0;

fallback:
if (ops->reset_target != NULL) {
(void)ops->reset_target(ctx);
}

(void)ops->unprepare(ctx);
return ret;
}

这段伪代码的重点不在 errno 名字,而在结构:所有平台相关动作都通过 ops 抽象;SCL/SDA 释放语义显式命名;每个等待都有上限;SCL 低和 SDA 低分别处理;STOP 生成和控制器恢复是恢复流程的一部分;从设备复位是可选兜底而不是默认动作。

在具体 MCU 上落地时要注意什么

STM32 类控制器

STM32 系列的 I2C 外设在不同代际上差异较大,但工程要点一致:异常后先停止外设、中断和 DMA;把 PB/PA 等 I2C 引脚从 AF_OD 切成 GPIO_OD;执行恢复脉冲和 STOP;再用 RCC 外设 reset 或 HAL/LL deinit/init 重新构建控制器状态。对于老款 I2C IP,BUSY 位异常不清是常见问题,必须结合 errata、外设 reset 和 pinmux 恢复流程验证。

NXP、Renesas、TI、Nordic 等平台

不同平台的 pinmux 和 GPIO 模式名称不同,但抽象动作相同:退出 I2C 控制器,释放 AF 输出,把 SCL/SDA 配成开漏 GPIO,采样真实输入,输出 SCL 脉冲,生成 STOP,恢复控制器。不要把某个平台“写 1 释放开漏”的语义硬套到另一个平台;应写平台适配函数并做示波器验证。

Linux 平台

Linux 下不建议每个设备驱动私自 bit-bang SCL/SDA。更推荐在 adapter driver 中实现 I2C core 期望的 bus recovery hooks。这样当 adapter 检测到总线异常时,可以由核心机制调用恢复流程,并且能配合 fault injection、adapter lock、错误码和统一日志。

RT-Thread、FreeRTOS、自研 RTOS

小型 RTOS 没有 Linux I2C core 那么完整的抽象时,也应至少把恢复逻辑放在 bus device 层,而不是每个 sensor driver 自己做。比如 rt_i2c_transfer() 或 adapter xfer 返回超时后,bus 层调用 i2c_bus_recover(),恢复成功后再决定是否重试一次当前事务。所有 client driver 应通过同一个 mutex 访问同一条 I2C 总线。

事务重试策略

恢复成功不等于当前事务一定可以无条件继续。原事务已经被中断,从设备可能已经看到部分地址、寄存器地址或数据字节。恢复后推荐重新发起一个完整事务,而不是从中间继续。

事务类型 恢复后是否可重试 注意点
只读寄存器 通常可以 重新发送寄存器地址,再读数据。
写配置寄存器 可以,但要考虑幂等性 对写 1 清除、中断状态、FIFO pop 等寄存器要谨慎。
EEPROM/Flash 写 谨慎 写周期可能已经开始,需轮询 busy 或读回校验。
PMIC 电源控制 谨慎或禁止自动重试 重复写可能改变电源状态,应由上层状态机确认。
FIFO 数据读 可能丢样 需要上报丢样或重新同步帧。
命令型设备 取决于协议 可能需要 soft reset 或重新握手。

建议每个 I2C client driver 标注事务幂等性。bus 层只能负责恢复总线,不能知道某个寄存器写操作重复执行是否安全。因此自动重试一般限制为一次,并且只对明确可重试的读事务或幂等写事务打开。

与 ACK 等待死循环的改造关系

题目中的直接故障点是 while 等 ACK。这个点必须改造为带 deadline 的等待函数。所有底层等待都应统一通过类似接口实现:

1
2
3
4
5
6
7
8
9
/**
* @brief Wait until a condition becomes true or the timeout expires.
*
* @param pred Condition predicate.
* @param arg Predicate private argument.
* @param timeout_us Timeout in microseconds.
* @return 0 if the condition is met, otherwise -ETIMEDOUT.
*/
static int i2c_wait_until(bool (*pred)(void *arg), void *arg, uint32_t timeout_us);

驱动中常见等待点包括:

等待点 可能卡住的原因 超时后的处理
BUSY 清除 SDA/SCL 被拉低、控制器状态未清 触发 bus recovery。
START 发送完成 控制器未获得总线、仲裁丢失 清错误状态,必要时恢复。
地址 ACK 从设备未响应、SDA 异常 返回 NACK 或触发恢复。
TXE/TXIS 控制器状态机卡住、DMA 异常 停止事务并恢复。
RXNE/RX not empty 从设备未继续发送、SCL 延展超时 STOP 或恢复。
STOP 完成 STOP 未被硬件生成或检测 GPIO STOP + 控制器复位。
DMA complete DMA 被打断或外设请求丢失 停 DMA,复位事务状态。

超时值也不要随便写成一个巨大循环计数。应按 I2C 速率、事务长度、允许 clock stretching 时间、RTOS 调度延迟和设备 datasheet 要求换算成时间。循环计数依赖 CPU 主频和优化等级,产线换固件编译选项后可能完全失真。

多设备共享总线时如何定位谁拉低了 SDA

I2C 总线是线与结构,只要一个设备拉低 SDA,整条总线都低。软件很难仅凭总线电平判断是哪一个从设备拉低。可以采用以下定位手段。

第一,记录最近访问设备。多数 SDA stuck 出现在某个事务中断后,最近访问地址是最高概率嫌疑。恢复日志应记录最近地址和事务类型。

第二,按设备错误计数统计。如果某个地址附近频繁出现超时和恢复,基本可以定位到该设备、该分支或该电源域。

第三,利用 reset GPIO。若每个关键从设备都有独立 reset,恢复失败后逐个复位并观察 SDA 是否释放,可以定位拉线设备。

第四,利用 I2C mux 或 load switch。复杂板卡可把多个外设分到不同 I2C mux 分支,故障时逐段断开,判断哪一段导致 SDA/SCL 低。

第五,产线治具配合示波器或逻辑分析仪。软件日志只能说明时序和状态,电气问题仍需看波形。尤其是上拉过强/过弱、线长过长、夹具接触抖动、电源反灌等问题,必须结合硬件测量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart TD
A[SDA/SCL stuck] --> B{恢复前最近访问地址?}
B -->|有| C[标记 last_addr 为嫌疑]
B -->|无| D[按总线分支排查]
C --> E{恢复脉冲是否成功?}
D --> E
E -->|成功| F[记录一次 recover success
保留设备错误计数]
E -->|失败| G{是否有 reset GPIO 或电源控制?}
G -->|有| H[复位嫌疑设备或分支]
G -->|无| I[上报硬件故障
禁止无限重试]
H --> J{SDA/SCL 是否释放?}
J -->|是| K[重新初始化该设备]
J -->|否| I

SCL 被拉低时怎么处理

本题强调 SDA 被拉低,但工程上也必须处理 SCL 被拉低。SCL 低可能是合法 clock stretching,也可能是从设备挂死、总线短路或电平域异常。主机想通过 SCL 脉冲恢复 SDA 的前提,是 SCL 能被主机释放后回到高电平。

处理建议如下:

  1. 先释放 SCL,等待一个有限时间。
  2. 如果 SCL 能回高,再执行 SDA stuck recovery。
  3. 如果 SCL 长期低,判断从设备是否可能在合法 clock stretching。若该设备 datasheet 允许长时间 stretching,应按设备最大值等待;否则视为异常。
  4. SCL 仍低时,不要继续 bit-bang,因为低电平阶段无法形成有效上升沿。
  5. 尝试复位从设备、电源域或 mux 分支。
  6. 若仍失败,记录硬件故障,禁止继续访问该总线。

很多驱动只检测 SDA,不检测 SCL,恢复代码看似执行了 9 个 clock,但实际上 SCL 一直被拉低,线上没有任何有效时钟。这种实现会误报恢复成功,随后事务继续失败。

低功耗和复位场景下的恢复策略

产线频繁死机往往与上下电、烧录、测试夹具和低功耗有关。I2C 恢复逻辑应纳入系统电源管理,而不是只在普通读写失败后触发。

系统启动时做一次 bus idle 检查

主机复位后,从设备未必复位。上电初始化 I2C 控制器之前,可以先把 SCL/SDA 配成 GPIO 输入或开漏释放,读取两线状态。如果 SDA 低而 SCL 高,说明存在历史遗留事务,可以先做 bus recovery,再启用 I2C 控制器。这样能解决“主机复位后 I2C BUSY 一直不清”的问题。

进入低功耗前结束事务

进入 sleep/stop/standby 前,应确保没有 I2C 事务在途。若要关闭 I2C 时钟,应先等待 STOP 完成或主动取消事务。不能在从设备正处于读数据或 ACK 状态时直接关 I2C 外设。

退出低功耗后重新检查线电平

退出低功耗后,pinmux、上拉、外设时钟和从设备电源域可能经历了不同的恢复顺序。恢复 I2C 外设前再次读取 SCL/SDA 电平,必要时执行 bus recovery。对于 PMIC、触摸、传感器等可唤醒设备,还要避免把合法中断或 wake 事件误处理成 I2C 线异常。

主从设备复位域不同要特别小心

如果主控 MCU reset 不会复位 I2C 从设备,就必须假设主机启动时总线上可能存在半截事务。若硬件条件允许,关键从设备 reset 应接到 MCU 可控 GPIO,或者放在跟 MCU 同步的 reset supervisor 下。否则软件恢复只能处理一部分情况。

DMA、中断和 RTOS 调度的影响

许多产线死机看似是 SDA 被拉低,根因却在驱动并发。比如 I2C 使用 DMA 接收,DMA 完成中断被屏蔽或优先级过低,I2C ISR 没有及时发送 NACK/STOP;或者 RTOS 中一个任务持有 I2C mutex 后被长时间阻塞,另一个高优先级任务抢占并尝试访问同一总线。

建议检查以下项:

项目 风险 建议
I2C mutex 多线程同时访问同一控制器 所有 client driver 通过同一 bus lock。
ISR 优先级 STOP/NACK 处理不及时 I2C 错误和事件中断优先级不能低到不可接受。
DMA abort 异常后 DMA 仍持有通道 超时路径必须停止 DMA 并清 descriptor。
回调状态 transfer complete 和 error callback 竞态 用原子状态机,禁止重复 complete。
超时计时 使用 busy loop 计数 改为硬件 timer/tick/deadline。
恢复上下文 在 ISR 中执行长恢复 ISR 只标记错误,恢复放到线程或 workqueue。

不建议在中断上下文里直接做完整 bus recovery。恢复动作包括微秒延时、pinmux 切换、外设复位和可能的从设备 reset,耗时不可控。更好的方式是 ISR 标记错误并唤醒高优先级恢复线程,由恢复线程持有 bus lock 执行恢复。

产线测试如何验证恢复有效

产线问题的修复不能只靠“跑一天没复现”。应设计可重复的故障注入测试,让恢复流程覆盖到 SDA stuck、SCL stuck、事务中断、主机复位和从设备复位等路径。

测试用例建议

用例 注入方式 期望结果
SDA 强制拉低 测试夹具或 MOSFET 将 SDA 拉低一段时间 驱动超时,不死循环;释放后 bus recovery 成功。
SDA 在 ACK 周期拉低 逻辑分析仪触发或从设备模拟器制造 ACK 后卡住 主机输出 SCL 脉冲并生成 STOP。
SCL 强制拉低 夹具拉低 SCL 驱动识别 SCL stuck,不误报恢复成功。
主机事务中复位 读事务中 reset MCU 启动阶段 bus idle check 能恢复总线。
从设备掉电/上电抖动 单独控制从设备电源 软件能复位或隔离设备,不影响系统主循环。
多线程并发访问 多任务同时访问不同 I2C client bus lock 有效,恢复期间无并发事务。
长时间老化 连续数万/数十万次读写加随机 reset 错误计数稳定,恢复成功率可统计。

验证波形标准

示波器或逻辑分析仪上至少应看到:

  1. 超时发生后,硬件 I2C 不再继续输出异常波形。
  2. SCL 被 GPIO 模式拉低/释放,形成固定数量或提前结束的恢复脉冲。
  3. 每个 SCL 高电平期间 SDA 被采样;SDA 释放后不再继续无限输出脉冲。
  4. STOP 条件清晰:SCL 高时 SDA 从低到高。
  5. 控制器恢复后,下一次 START、地址、ACK 和 STOP 波形正常。

若波形上看不到 STOP,或者 SCL/SDA 被推挽强拉,说明恢复代码需要重查 pinmux 和 GPIO 模式。

日志、指标和现场可观测性

一次好的修复应让问题从“死机”变成“可恢复、可计数、可定位”。建议增加如下指标。

1
2
3
4
5
6
7
8
i2c_xfer_total{bus, addr}
i2c_xfer_timeout{bus, addr, stage}
i2c_recover_attempt{bus}
i2c_recover_success{bus}
i2c_recover_fail_scl_stuck{bus}
i2c_recover_fail_sda_stuck{bus}
i2c_target_reset_count{bus, addr}
i2c_bus_disabled{bus}

日志示例:

1
2
3
4
5
I2C1 timeout: addr=0x68 op=reg_read reg=0x75 stage=WAIT_ACK scl=1 sda=0
I2C1 recovery: enter, last_addr=0x68, reason=SDA_LOW
I2C1 recovery: pulse=7, sda_released=1
I2C1 recovery: stop generated, controller reset done, scl=1 sda=1
I2C1 retry: addr=0x68 op=reg_read result=ok

如果恢复失败:

1
2
3
I2C1 recovery: failed, reason=SCL_STUCK_LOW, scl=0 sda=1
I2C1 target-reset: addr=0x68 reset-gpio toggled, delay=10ms
I2C1 recovery: after target reset scl=1 sda=1

日志不要在高频路径里刷屏。可以对同一 bus 的重复错误做限频,但计数器必须保留。产线系统可以读取计数器判断是否存在边缘硬件问题,即使系统已经自动恢复。

常见错误实现

错误 1:只加超时,不恢复总线

这会把“死循环”变成“每次访问都超时”。系统不再卡死,但设备功能仍不可用,且可能不断重试拖慢系统。

错误 2:推挽强拉高 SDA/SCL

I2C 是开漏总线。若从设备正在拉低,主机推挽强拉高会产生电气冲突。恢复代码必须使用 open-drain 或输入释放语义。

错误 3:只输出 9 个 SCL,不生成 STOP

从设备可能释放 SDA,但内部状态仍未被 STOP 重置。应在 SCL 高时生成 SDA 低到高的 STOP 条件。

错误 4:恢复后不复位 I2C 外设

硬件控制器可能仍保留 BUSY、BERR、ARLO、NACK 或 DMA 状态。恢复 GPIO 只是恢复物理线,控制器内部也要重新初始化。

错误 5:不读取真实 SCL/SDA

只看 GPIO 输出寄存器会误判。必须读 pin input,确认线上真实电平。

错误 6:无限恢复或无限重试

恢复本身也要有次数限制。建议每个事务最多触发一次 bus recovery,每个设备在一个时间窗口内最多重试固定次数,超过后上报故障并降级。

错误 7:在业务驱动里各写各的恢复逻辑

多个 client driver 对同一条总线各自切 GPIO、复位外设,容易并发冲突。恢复逻辑应集中在 adapter/bus 层。

面试回答可以如何组织

回答这类问题时,建议按以下顺序展开。

  1. 先给结论:这不是单纯 ACK 超时问题,应做 I2C Bus Recovery。
  2. 说明原理:从设备可能卡在数据 bit 或 ACK 阶段,SDA 被拉低;主机硬件 I2C 认为 BUSY,不再发 SCL;通过 GPIO 手动 SCL 脉冲推进从设备状态机。
  3. 给出 IO 序列:关 I2C 外设,SCL/SDA 切 GPIO 开漏,释放 SDA,检查 SCL,输出 9 到 16 个 SCL 脉冲,SDA 释放后生成 STOP,复位 I2C 控制器,恢复复用。
  4. 说明失败兜底:SCL 低或 SDA 不释放时复位从设备、电源域或 mux 分支;恢复失败要上报并隔离,不能无限重试。
  5. 说明工程化:加总线 mutex、事务超时、错误码、日志、恢复次数限制、产线故障注入和示波器验证。

一句话概括:ACK 超时只是触发点,真正的恢复是用 GPIO 开漏模拟 I2C bus clear,把从设备状态机推进到能释放 SDA 的位置,再用 STOP 和控制器复位把总线和主机外设都带回 idle。

可直接背诵的精简答案

结论:要做 I2C bus recovery。超时只能防止线程死锁,不能释放已经被从设备拉低的 SDA。恢复时先拿总线锁,关闭 I2C 控制器、中断和 DMA,把 SCL/SDA 临时切为 GPIO 开漏并保持上拉;释放 SDA,检查 SCL 是否能回高;如果 SDA 仍低,就手动给 SCL 输出 9 到 16 个脉冲,每个高电平阶段采样 SDA;SDA 释放后,在 SCL 高时让 SDA 从低到高生成 STOP;随后复位 I2C 外设、清错误状态、恢复 AF 复用并重新初始化。若 SCL 低或 SDA 始终不释放,说明从设备或硬件可能挂死,应通过 reset GPIO、load switch 或 I2C mux 隔离复位该设备,并上报故障。整个流程必须有 mutex、超时、恢复次数限制和日志,不能在 while 中无限等待 ACK。

参考链接

工程实现分层建议

Bus 层职责

Bus 层负责所有与总线共享资源相关的事情,包括互斥锁、控制器状态、恢复状态、错误计数、总线电平采样、pinmux 切换、控制器 reset 和统一日志。Bus 层不应理解某个传感器的寄存器语义,但它必须知道当前事务的 bus id、从设备地址、事务方向、长度和等待阶段。这样在异常时才能给出可定位的日志。

Bus 层推荐提供如下接口:

1
2
3
4
5
6
i2c_bus_transfer(bus, msgs, nmsgs, timeout)
i2c_bus_recover(bus, reason)
i2c_bus_reset_controller(bus)
i2c_bus_get_line_state(bus, &scl, &sda)
i2c_bus_set_target_power(bus, addr, on)
i2c_bus_get_stats(bus, &stats)

其中 i2c_bus_transfer() 是唯一被 client driver 调用的传输入口。恢复动作不应由 client driver 直接调用 GPIO,而应由 bus 层依据错误码和策略决定。这样可以避免两个驱动同时认为自己有权恢复总线。

Client 层职责

Client driver 负责设备协议本身,例如寄存器地址长度、读写事务组合、初始化序列、软复位命令、数据校验和业务重试。Client 层需要告诉 bus 层当前事务是否可重试,以及恢复后是否需要重新初始化设备。

例如,一个 IMU 传感器驱动可以把 WHO_AM_I 读取标记为可重试,把 FIFO burst read 标记为可重试但可能丢样,把清中断寄存器写操作标记为不可盲目重试。这样恢复成功后,bus 层不会误把非幂等操作重复执行。

Board 层职责

Board 层负责硬件连线信息,例如哪条 I2C bus 对应哪些 GPIO,哪些设备有 reset pin,哪些设备挂在同一个 load switch 后面,是否存在 I2C mux,是否存在电平转换器,以及上电时序要求。通用 I2C driver 不应硬编码某个设备 reset GPIO,而应通过 board config 或 device tree 获取。

Application 层职责

Application 层不应关心 SCL 脉冲细节,但应能感知设备不可用、数据丢失或功能降级。比如温度传感器恢复失败时,上层可以使用上一次有效值并上报告警;PMIC 通讯失败时,上层可能进入安全模式;触摸 IC 恢复后,上层需要重新校准或重新读取配置。

故障恢复策略矩阵

不同 I2C 错误不应使用同一种恢复策略。下面给出一个可落地的矩阵。

错误类型 线电平 可能原因 首选动作 兜底动作
NACK SCL=1,SDA=1 从设备未上电、地址错、设备忙 返回 NACK,有限重试 检查电源和初始化顺序
BUSY timeout SDA=0 或 SCL=0 半截事务、从设备拉线、控制器 BUSY 卡住 bus recovery controller reset / target reset
ACK wait timeout 常见 SDA=0 从设备卡 ACK、主机等待循环问题 STOP + recovery target reset
RX timeout 可能 SCL 被 stretching 从设备未继续发送、clock stretching 过长 检查 SCL,必要时 STOP target reset
STOP timeout 可能控制器状态异常 STOP 未生成或未检测 GPIO STOP + controller reset bus recovery
Arbitration lost 多主机或干扰 总线竞争 退避重试 多主机仲裁设计检查
Bus error START/STOP 时序异常 毛刺、波形、电源、驱动竞态 清错误并恢复 硬件波形检查

这个矩阵有两个目的:一是避免遇到所有错误都直接复位从设备,导致恢复成本过高;二是避免明明已经出现物理总线卡死,却只做普通重试。

从设备复位后的重新初始化

很多从设备复位后不会自动回到应用可用状态。例如传感器需要重新配置量程、采样率、中断路由和滤波参数;触摸 IC 需要重新下载配置;音频 codec 需要恢复 PLL 和路径;PMIC 可能需要谨慎处理不可重复写的电源寄存器。因此从设备 reset fallback 后,应通知 client driver 重新执行设备初始化或状态同步。

推荐在 client driver 中实现:

1
2
3
4
5
6
7
/**
* @brief Reinitialize an I2C client after bus-level recovery reset its target.
*
* @param dev Client device instance.
* @return 0 on success, otherwise a negative error code.
*/
int sensor_reinit_after_i2c_recovery(struct sensor_dev *dev);

Bus 层只负责拉 reset 或电源,client 层负责恢复寄存器配置。否则 bus 层不知道设备内部需要哪些配置,容易出现“通讯恢复了但功能不正常”的隐性问题。

I2C mux 和复杂拓扑

复杂产品里,一条主 I2C bus 下面可能挂 I2C mux,mux 后面有多个分支。若某个分支设备拉低 SDA,有些 mux 会把故障传播到上游,有些 mux 可以通过关闭通道隔离。恢复逻辑需要理解拓扑。

1
2
3
4
5
6
7
8
9
10
11
flowchart LR
M[MCU I2C0] --> U[I2C Mux]
U --> A[Branch 0
Sensor A]
U --> B[Branch 1
EEPROM]
U --> C[Branch 2
Touch IC]
U --> D[Branch 3
External Connector]
C --> E[SDA stuck low]

处理策略通常是:先恢复上游主总线;如果上游仍异常,尝试关闭 mux 所有通道;如果关闭后上游释放,再逐个打开通道定位故障分支;找到故障分支后,仅复位该分支设备或禁用该通道。这样可以避免一个可选外设拖死整条系统控制总线。

若 mux 本身也通过 I2C 控制,且总线已被拉低,可能无法通过 I2C 命令关闭通道。这时 mux 的 reset pin 就非常重要。硬件设计阶段应给关键 I2C mux、桥接器和外部连接器预留 reset 或 enable 控制。

硬件协同设计建议

软件 bus recovery 能处理协议中间态,但不能弥补所有硬件问题。为了让恢复机制真正可靠,硬件设计也要配合。

设计项 建议
上拉电阻 按总线电容、速率和电平计算,不要多个模块盲目并联导致阻值过低。
电平转换 选用适合开漏双向 I2C 的电平转换器,验证低功耗和掉电状态。
从设备 reset 关键外设尽量提供 MCU 可控 reset。
电源域 容易挂死的外设使用可控 load switch。
外部接口 外接 I2C 需限流、ESD、防热插拔异常,并考虑隔离 mux。
测试点 SCL/SDA 预留测试点,产线和研发可接逻辑分析仪。
串联电阻 适当串联阻尼可改善边沿和冲突电流,但要验证时序。
电源时序 主从 reset 域不同步时,启动阶段必须做 bus idle check。

特别要注意上拉电阻。上拉过弱会导致上升沿慢,I2C 控制器或从设备采样错误;上拉过强会导致拉低电流过大,弱驱动设备无法可靠拉低,还可能增加功耗和电气冲突风险。软件恢复失败率高时,应同步检查波形,而不是只改代码。

与 SMBus 的关系

SMBus 基于 I2C 物理层,但在超时、时钟、协议和设备行为上有额外约束。某些 SMBus 设备对 clock low timeout 有明确要求,因此 SCL 被长期拉低时,设备可能自行复位其通信状态。普通 I2C 设备不一定有这种机制。若产品混用了 I2C 和 SMBus 设备,恢复策略应按最严格的设备要求设置延时、timeout 和 reset 流程。

此外,SMBus 还可能涉及 PEC、Host Notify、Alert 等机制。Bus recovery 只负责把线恢复为空闲,不能替代 SMBus 协议层的错误处理。恢复后仍应让对应 client driver 重新读取状态寄存器,确认设备是否有未处理的告警或错误。

代码审查清单

修改 I2C 驱动时,可按下面清单检查。

检查项 是否必须
所有等待 ACK/BUSY/TX/RX/STOP 的循环都有 timeout 必须
timeout 使用时间基准,而不是裸循环计数 必须
bus recovery 在 bus mutex 下执行 必须
恢复前关闭 I2C 中断和 DMA 必须
SCL/SDA 切 GPIO open-drain,不使用 push-pull 强拉高 必须
读取真实 pin input 判断 SCL/SDA 必须
SCL stuck low 与 SDA stuck low 分开处理 必须
SCL 脉冲数量有上限,并且每个高电平阶段采样 SDA 必须
SDA 释放后生成 STOP 必须
恢复后复位或重新初始化 I2C 控制器 必须
恢复失败后有设备 reset 或错误上报 必须
自动重试次数有限,且考虑事务幂等性 必须
日志包含 bus、addr、stage、line level、result 建议必须
产线有 SDA/SCL fault injection 用例 建议必须

这份清单可以直接转成 PR review checklist 或产线问题修复验收表。

结论

I2C SDA 被拉低死锁的根因通常是主机和从设备的协议状态机停在了不同位置。主机硬件 I2C 控制器看到 SDA 低后认为总线忙,不再主动产生 SCL;从设备又在等待 SCL 边沿或 STOP 条件释放 SDA,于是形成互等。正确的软件恢复不是继续等待 ACK,而是由驱动层接管总线:关闭 I2C 控制器,切 GPIO 开漏,释放 SDA,手动输出 SCL 脉冲,SDA 释放后生成 STOP,复位控制器并恢复正常访问。若恢复失败,则说明问题可能超出协议中间态,必须复位从设备、电源域或隔离故障分支。

面试中把这个问题回答完整,重点不是记住“9 个 clock”这一个数字,而是说明为什么要这么做、什么时候不能这么做、恢复后还要复位什么、失败后如何兜底,以及如何通过日志、互斥锁、故障注入和硬件协同把它变成可量产的方案。