做嵌入式开发这些年,I2C 算是用得最多也最容易出问题的总线之一。尤其是两块 STM32 之间直接通过 I2C 对接时,表面上看只要初始化好外设、写好收发函数就能跑,可实际跑起来,各种通信异常层出不穷。前段时间我在一个项目里用两颗 STM32G0 做主从通信,就先后遇到了两个挺典型的 I2C 异常案例。一个表现为运行一段时间后主机读取从机数据偶发性无响应,逻辑分析仪抓下来是典型的时钟延展超时;另一个更夸张,系统上电后 SDA 直接掉到低电平,总线彻底锁死,复位主机都没用。这两个问题排查过程都花了不少心思,也让我把 STM32G0 的 I2C 特性从头到尾翻了个遍。这篇笔记就把两个案例的完整分析、根因还原和最终的解决方案整理出来,给正在用 STM32G0 I2C 做产品、或者对 I2C 通信协议稳定性有要求的工程师做个参考。
1. 项目背景与两个异常案例的现象
1.1 这套 I2C 通信方案的硬件连接
项目里需要两个 MCU 协同工作:一个负责主控逻辑、处理按键显示和对外接口,另一个负责采集传感器数据、管理电池电量。两块芯片之间数据量不大,但实时性要求比较高,而且引脚资源紧张,所以自然而然地选用了 I2C 这个只要两根线的通信方式。
主控用的是 STM32G0B1CEU6,从机用的是 STM32G031F8P6,两者都跑在 64MHz 主频。主机通过 I2C1 以 400kHz 的快速模式轮询从机,从机地址设为 0x32(7 位地址)。SCL 接到 PB6,SDA 接到 PB7,这都是 STM32G0 上 I2C1 的默认复用引脚,配 AF1 就可以。总线上拉了 4.7kΩ 上拉电阻到 3.3V,通信距离在 PCB 上只有几厘米,硬件上看起来非常常规,没什么特别之处。
通信的内容也很简单:主机每隔 100ms 从从机读一帧 8 字节的数据,包含温度、电池电压、工作状态;偶尔会往从机写一个字节的配置命令。从机这边工作在 I2C 从模式,用中断方式响应主机的读写请求,数据准备好后放在一个全局缓冲区里,由 I2C 中断直接读取。
这个方案正常情况下跑得很稳,逻辑分析仪抓波形也规规矩矩,但偏偏就是有一些“意料之外”的时刻,让整个系统变得不可用。
1.2 案例一:运行一段时间后偶发无响应
这个问题的出现非常折磨人。系统刚上电的几个小时内一切正常,主机读取从机数据的行为没有任何异常。但运行一段时间后,可能是半小时,也可能是两三个小时,主机某一次读取从机数据时突然超时,返回错误码。
我把超时的判断逻辑加在了 HAL_I2C_Mem_Read 的等待循环上,设置了 10ms 的超时上限。一旦超时,这次读取就失败了。按照最初的设计,读取失败后下一轮可以重新尝试,所以理论上不应该造成太大影响。然而实际情况是,一旦第一次超时发生,之后连续多次读取都会超时,系统进入了一种“半瘫痪”状态,主控拿不到最新的传感器数据,部分功能开始降级运行。
更让人头疼的是,只要按下复位键重启主机,通信立刻恢复正常,然后又可以稳定运行很长一段时间。这种“重启就好”的特征,让问题排查方向一度偏向软件逻辑或者内存溢出,而不是通信协议本身。
1.3 案例二:上电后总线直接锁死
如果说案例一是“软故障”,那案例二就是“硬死锁”。在某些特定条件下——尤其当从机先于主机完成初始化,或者两个芯片几乎同时上电时——系统上电后 I2C 总线直接完全卡死。用示波器测量,SCL 保持在 3.3V 高电平,SDA 却被死死拉在 0V,无论主机怎么发起通信,SDA 都无法回到空闲的高电平状态。
这个现象最诡异的地方在于:复位主机完全无效。因为 I2C 协议里,START 条件要求 SDA 在 SCL 为高的时候从高跳变到低。现在 SDA 已经是低电平了,主机就算把 I2C 外设翻个底朝天,也没办法在这个状态下制造一个合法的 START 条件。必须把从机一起断电复位,或者手动给从机的复位引脚打个脉冲,总线才能恢复。
这个问题在当时已经影响到了整机的量产测试,因为测试台上经常出现上电后通信失败的坏板,但重新上电又好了,极难复现和定位。
2. 案例一:从机中断响应不及时导致的时钟延展超时
2.1 先用逻辑分析仪抓出异常波形
排查案例一的时候,我先排除了软件逻辑层面的问题。检查了主机的状态机、超时重试逻辑,又查了从机的数据处理流程,都没发现明显漏洞。随后把目光转向总线本身,用逻辑分析仪长时间挂在 I2C 总线上,等待异常出现。
功夫不负有心人,在抓到一个完整异常过程后,我发现了关键线索。正常通信时,SCL 的频率应该是 400kHz,一个时钟周期约 2.5µs,高电平时间和低电平时间大约各 1.25µs。但在异常发生前的那一次通信中,SCL 的低电平时间被拉得极长,目测超过 200µs,差不多是正常情况的 160 倍。
这就是 I2C 协议里非常经典的现象——时钟延展(Clock Stretching)。从机发现自身来不及处理主机发来的数据时,会在某个时钟周期内主动拉低 SCL,告诉主机“你先停一下,我还没准备好”。主机检测到 SCL 为低,就会暂停时钟的产生,等待从机释放 SCL,直到从机准备好后再继续通信。
问题就出在这个“继续通信”上。如果从机一直不释放 SCL,主机就会一直等待。而我在主机端设置了超时时间,一旦超时就会放弃本次通信,发送 STOP 条件并返回错误。由于我使用的是 STM32G0 的 HAL 库,其等待循环在某些情况下会直接退出,这就导致了主机认为通信失败,而从机还处于等待状态,两侧状态不一致,后面的通信自然全部失败。
2.2 从机为什么迟迟不释放 SCL
查到这里,问题就变成了:从机为什么把 SCL 拉低这么久?
在 STM32G0 的 I2C 从机模式下,当地址匹配成功,硬件会自动拉低 SCL,直到软件读取状态寄存器的匹配标志并做出响应。这个机制的目的是确保从机软件在完全准备好之后才继续通信。所以从机软件处理 I2C 事件的快慢,直接影响着 SCL 低电平的持续时间。
我把从机的代码翻出来仔细检查,发现在从机里还开着另一个外设的中断——定时器 TIM2 的中断,频率是 1kHz,用来做软件定时。问题就出在这个 TIM2 中断里有一段耗时比较大的操作,包括一组 loop 循环做软件滤波。正常情况下这段操作耗时只有几十微秒,并不算长。
但问题是,从机的中断优先级配置不够合理。我把 TIM2 的中断优先级设置为 0(最高优先级),I2C1 的中断优先级设置为 1(次高优先级)。这意味着当 TIM2 中断正在执行时,I2C1 的中断请求是无法响应的。如果恰好在这段时间里主机发起了 I2C 通信,从机的 I2C 硬件已经检测到地址匹配并拉低了 SCL,但软件的中断处理函数却因为 TIM2 正在占用 CPU 而无法及时执行,SCL 就一直被拉低,直到 TIM2 的中断处理函数执行完毕。
因为 TIM2 的触发时刻是固定的 1ms 一次,而主机的 I2C 通信是随机的,所以只有当时钟延展恰好撞上 TIM2 中断正在执行时,这个小概率事件才会发生。这也解释了为什么问题不是必现,而是偶发。
2.3 修复方案:中断优先级调整与主机兜底
根因清楚了,修复就分成两步走。
第一步,调整从机的中断优先级。把 I2C1 的中断优先级提到比 TIM2 更高,让 I2C 事件能够抢占 TIM2 的处理。这样即使 TIM2 正在执行,I2C 中断也能立即介入,快速响应地址匹配事件,释放 SCL,让总线通信继续。
HAL_NVIC_SetPriority(I2C1_IRQn, 1, 0); // I2C 中断优先级设为 1 HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0); // TIM2 降为 2第二步,在从机的 I2C 中断处理函数里,严格执行“快速应答、异步处理”的原则。中断里只做读取寄存器、清除标志、存取数据这些轻量操作,任何耗时的处理全部挪到主循环或者低优先级任务里。我当时把软件滤波算法移出了 TIM2 中断,改成在主循环里定期执行,效果立竿见影,问题再也没出现。
同时,我也在主机侧加了超时重试机制作为兜底。即使从机因为某些极端原因响应慢了,主机也能在超时后主动终止本次通信,重新发起,而不是傻乎乎地一直等下去。这块逻辑可以参考下面的伪代码思路:
uint8_t I2C_ReadWithRetry(uint8_t addr, uint8_t reg, uint8_t *buf, uint8_t len) { for (uint8_t retry = 0; retry < 3; retry++) { if (HAL_I2C_Mem_Read(&hi2c1, addr, reg, 1, buf, len, 10) == HAL_OK) { return 1; // 成功 } HAL_I2C_DeInit(&hi2c1); HAL_I2C_Init(&hi2c1); // 重新初始化外设 } return 0; }这套组合拳打下来,案例一彻底解决。但这件事给我的启发更大:I2C 从机的中断响应时间,是整个 I2C 链路稳定性里最容易被忽略的一个环节。从机硬件通过时钟延展来等待软件处理,这个机制本身是好的,但如果软件处理不及时,它就会成为总线上的定时炸弹。
3. 案例二:上电毛刺把从机状态机带进死胡同
3.1 从现象锁定问题方向
案例二比案例一更棘手。SDA 被拉死,主机复位无效,说明问题不在主机侧,而在从机侧。我当时把主机和从机之间的 I2C 线断开,只用示波器测量从机 SDA 引脚的电平,确认它一直保持在 0V。又量了从机的 VDD 和复位引脚,供电正常,从机也能正常跑主循环,说明不是芯片损坏。
这时候我意识到,问题出在从机的 I2C 外设状态机上。I2C 外设进入了一个“错误状态”,一直在等待不可能到来的信号,同时把 SDA 拉低。
那么,这个错误状态是怎么进去的?
我一边分析一边做实验,发现一个规律:只要把从机的上电时间拉长,或者让主机先于从机完成初始化,问题就很少出现;反之,如果从机先初始化完成,问题就很容易复现。这说明问题根源和上电时序紧密相关。
3.2 根因分析:伪 START 与伪时钟
上电过程中,系统电源 3.3V 并不是瞬间建立的,而是有一个爬升过程。在这个爬升过程中,MCU 的 GPIO 引脚状态是不确定的,I2C 引脚的 SCL 和 SDA 上可能出现随机的高低电平抖动。
正常情况下,主机和从机的复位时序、启动代码执行速度应该是可以接受的。但在某些芯片个体的差异、电源上电波形差异的影响下,会出现从机已经完成 GPIO 初始化、I2C 外设已经使能,而主机的 I2C 引脚还处于未定义状态的窗口期。在这个窗口期内,主机引脚上任意一个下降沿,都可能被从机的 I2C 硬件误判为 START 条件。
一旦从机检测到了这个伪 START,它的状态机就认为总线上正在发生一次通信,于是开始等待时钟脉冲来接收地址字节。如果此时主机的 SCL 引脚上恰好再出现几个毛刺,从机会把这些毛刺当作时钟周期,接收一串随机的二进制位。如果这串位恰好与从机自身的地址匹配,从机就会回 ACK,把 SDA 拉低。如果后面主机的时钟不再继续,或者停止在某个中间状态,从机就会一直保持这个等待或应答状态,SDA 被死死拉低。
用一句话概括就是:从机被上电过程中的伪 START 和伪时钟骗进了错误状态,且没有出口。
I2C 协议本身是没有全局复位机制的。不像 CAN 总线有总线关闭状态,也不像 SPI 有片选信号可以做同步。I2C 从机一旦状态机错乱,唯一的恢复手段就是重新初始化从机外设,或者给它复位。
3.3 主机侧的总线恢复序列
既然是总线上没有合法的通信却出现了这种异常,那最直接的解决办法就是给总线“洗个澡”,把从机的状态机强制拉回正常。
这个操作在 I2C 调试领域有个俗称,叫“总线的 9 脉冲恢复法”。原理很简单:I2C 从机在接收地址或数据的过程中,如果收到了完整的 9 个时钟脉冲(8 位数据加 1 个 ACK 位),它的状态机一定会完成一个字节的接收并作出响应。只要让从机连续收到 9 个时钟脉冲,并且在这期间 SDA 保持高电平,那么从机的状态机就能从任何错误状态中走出来,释放 SDA。
我写了一个总线恢复函数,在系统启动早期、主机的 I2C 外设初始化之前调用:
/** * @brief I2C 总线恢复序列 * @note 通过 GPIO 模拟 9 个 SCL 脉冲,强制异常从机退出错误状态 */ void I2C_BusRecovery(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; // 1. 停用 I2C 外设,把 SCL/SDA 引脚切回 GPIO 推挽输出模式 __HAL_I2C_DISABLE(&hi2c1); GPIO_InitStruct.Pin = I2C1_SCL_PIN | I2C1_SDA_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(I2C1_SCL_PORT, &GPIO_InitStruct); // 2. SDA 先拉到高,模拟总线空闲状态 HAL_GPIO_WritePin(I2C1_SDA_PORT, I2C1_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN, GPIO_PIN_SET); // 3. 连续产生 9 个 SCL 脉冲 for (uint8_t i = 0; i < 9; i++) { HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN, GPIO_PIN_RESET); delay_us(5); HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN, GPIO_PIN_SET); delay_us(5); } // 4. 产生一个 STOP 条件:SDA 在 SCL 高电平时拉高 HAL_GPIO_WritePin(I2C1_SDA_PORT, I2C1_SDA_PIN, GPIO_PIN_RESET); delay_us(5); HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN, GPIO_PIN_SET); delay_us(5); HAL_GPIO_WritePin(I2C1_SDA_PORT, I2C1_SDA_PIN, GPIO_PIN_SET); delay_us(5); // 5. 恢复 I2C 复用的开漏模式 GPIO_InitStruct.Pin = I2C1_SCL_PIN | I2C1_SDA_PIN; GPIO_InitStruct.Mode = GPIO_MODE_AF_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.Alternate = GPIO_AF1_I2C1; HAL_GPIO_Init(I2C1_SCL_PORT, &GPIO_InitStruct); // 6. 重新使能 I2C 外设 __HAL_I2C_ENABLE(&hi2c1); }把这段函数放在从机初始化之后、主机开始正常通信之前执行,原本上电就死锁的现象直接消失了。后来我甚至在每次通信失败后的重试流程里也调用它,作为最后的兜底手段。
3.4 从机侧也做了加固
光在主机侧恢复还不够,治标不治本。我还给从机加了一层保护:如果从机在收到 START 之后,超过一定时间没有收到完整的数据或 STOP 条件,就主动复位 I2C 外设,释放总线。
STM32G0 的 I2C 外设自带一个超时寄存器 TIMEOUTR,可以针对 SCL 低电平时间设置超时。但更灵活的做法是用软件定时器,在主循环里检测 I2C 状态机的停留时间。
在从机的主循环里,我加了一个简单的状态检查:
// 从机主循环中周期性调用 void I2C_Slave_CheckStatus(void) { static uint32_t last_event_tick = 0; uint32_t now = HAL_GetTick(); uint32_t isr = I2C1->ISR; // 如果进入了地址匹配或正在传输状态,记录时间 if ((isr & (I2C_ISR_ADDR | I2C_ISR_RXNE | I2C_ISR_TXE)) == 0) { last_event_tick = now; return; } // 超过 50ms 没有进展,说明可能被卡住了 if ((now - last_event_tick) > 50) { __HAL_I2C_DISABLE(&hi2c1); __HAL_I2C_ENABLE(&hi2c1); // 重启 I2C 外设,释放总线 } }这段逻辑的思路是:正常情况下一次 I2C 通信在毫秒级以内就会完成,如果从机停留在某个中间状态超过 50ms,那大概率是异常情况,直接复位外设。这个加固方案上线后,即使偶尔再出现伪 START,从机也能在 50ms 内自行恢复,不会把总线锁死。
3.5 这个案例带来的反思
案例二让我特别深刻地意识到 I2C 协议在工业环境里的脆弱性。两条线、一个开源协议、看似简单,但正因为简单,协议本身没有为恶劣场景设计太多保护机制。上电时序、引脚毛刺、中途拔线,任何一个意外都可能让 I2C 从机进入无法自动恢复的状态。
所以现在做的所有涉及 I2C 从机的方案,我都会在从机固件里加入“超时复位”机制,并在主机初始化流程里加入“总线恢复序列”。这两个操作成本极低,但能解决掉九成以上的 I2C 死锁问题。
4. 两个案例背后的 STM32G0 I2C 调试方法论
4.1 STM32G0 的 I2C 外设特点和配置要点
STM32G0 系列使用的是 ST 新一代 I2C 外设 IP,和 F1 时代那个备受诟病的 I2C 外设完全是两回事。新外设功能更齐全,时序控制更精确,但配置方式也完全不同。
最典型的是时序寄存器 TIMINGR。在 STM32G0 上,I2C 的 SCL 频率完全由 TIMINGR 寄存器控制,需要配置 PRESC、SCLDEL、SDADEL、SCLH、SCLL 这五个字段。很多从 F1 系列转过来的工程师,会习惯性地沿用 F1 那套 I2C 时序配置思路,但在 G0 上根本行不通。
配置 TIMINGR 最稳妥的方式是用 STM32CubeMX,在 I2C1 的配置界面里输入目标 SCL 频率,选择 I2C 时钟源,它会自动算出一组 TIMINGR 值。我平时习惯手动核对一下 CubeMX 生成的值,确保它与我手动计算的结果一致。以下是一个配置示例(I2C 内核时钟源为 16MHz,目标 400kHz):
hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 400000; hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 = 0x32; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 = 0x00; hi2c1.Init.OwnAddress2Masks = I2C_OA2_NOMASK; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;注意 NoStretchMode 这个参数,默认是禁止时钟延展的,也就是从机可以正常使用时钟延展机制。如果你确认从机的软件响应足够快,也可以打开 NoStretchMode,但这会禁用从机的时钟延展能力,不建议在从机端设置为开启。
在实际调试中,我最常遇到的问题是 TIMINGR 配置值偏小,导致 SCL 实际频率超出目标值。这不仅会让从机跟不上,还可能因为上升沿时间不够产生信号完整性问题。
4.2 I2C 异常排查的实操步骤
经过这两个案例,我整理了一套自己的 I2C 排查流程。不管问题表现是什么,先按这个流程走一遍,基本能把问题定位到某个具体环节。
第一步,用万用表或示波器确认硬件基础状态。量 SCL 和 SDA 的空闲电平,应该是 VDD(高电平);如果某一个引脚是 0V,那就说明总线已经被拉死,先处理死锁问题。
第二步,用逻辑分析仪抓波形。逻辑分析仪的采样率至少要有 24MHz 以上,才能准确解析 400kHz 的 I2C 信号。抓取波形后,直接使用逻辑分析仪自带的 I2C 协议解码功能,可以快速判断 START、STOP、地址、ACK/NACK 是否正常。
第三步,看异常时刻的波形细节。如果遇到无响应,放大 SCL 波形,看是否有异常的低电平或者高电平时间。如果 SCL 低电平时间明显偏长,大概率是时钟延展问题,方向指向从机软件响应速度。
第四步,读 I2C 外设的错误标志位。STM32G0 的 I2C 外设提供了一组完整的状态寄存器,通过调试器直接查看 I2C_ISR 寄存器,