STM8 I2C BUSY位卡死排查与恢复:从寄存器状态到总线释放方案
1. Busy bit卡死的第一现场:现象记录与初步判断
如果你在STM8S105K6上调试I2C外设,八成会遇到一个让人抓狂的问题:I2C通信跑着跑着突然卡死,读I2C_SR2寄存器,BUSY位死活是1,主设备既发不了起始条件,也收不到应答,整个总线像被焊死了一样。
我这次遇到的情况是驱动板上的STM8S105K6作为主控,外挂一颗24C02 EEPROM和一个温湿度传感器。板子上电后第一次读写EEPROM一切正常,但只要通信中发生一次异常——比如从设备忙、上电时序不好、或者线缆被碰了一下——I2C就再也恢复不了。代码里反复调用I2C_GenerateSTART(),EV5事件就是不出现,打断点看I2C_SR2寄存器的值,BUSY位稳如泰山,一直是1。
先说判断思路。BUSY位卡住,严格来说不一定是总线物理上有数据传输,而是I2C模块认为总线忙。STM8的I2C判断总线忙闲的方式很直接:在I2C_CR1的STOP位清零、BUSY标志为0的情况下,检测到SDA或SCL上的起始条件就置1;检测到停止条件就清零。问题在于,一旦通信过程中出现异常,主从两侧没有完成一次完整的“起始—数据—停止”序列,那个起始条件之后就一直没等到合法停止条件,BUSY位就永远置1了。
我最初怀疑是从设备把SDA拉低了。拿示波器量了一下,SDA和SCL其实都是高电平,总线物理上完全是空闲状态。这就有意思了——物理空闲,寄存器却说忙。这说明问题不是硬件短路,而是I2C模块内部状态机卡在了“等待停止条件”的状态。
另一个典型特征是:此时如果强制给SWRST位写1复位I2C外设,BUSY位会清零,但如果你在代码里只是清标志,不做处理,那么下一次通信只要遇到同样的异常,又会卡死。这个问题的根源往往不在硬件,而在主设备发起传输的时机和对外部异常的处理方式上。后续也会说到,为什么STM8的I2C模块比STM32更容易出现这个问题,以及它在设计上少了哪些保护机制。先把现象和寄存器状态定位清楚,排查就有了方向。
2. STM8S105K6硬件模块细节:为什么BUSY位容易卡住
2.1 STM8 I2C外设的硬件设计差异
STM8S105K6的I2C模块其实是早期设计,没有ES1和ES2那种完整的错误处理机制,也没有硬件的超时保护。跟STM32的I2C比,它在很多地方更像一颗“裸奔”的状态机。最典型的就是BUSY位,它的置位和清理由硬件自动管理,但硬件只认起始条件和停止条件——只要总线上的时序不满足合法停止条件,BUSY就永远不会自己清零。
从寄存器手册来看,I2C_SR2的BUSY位定义是“总线忙”,清0条件写的是“检测到停止条件”。这意味着,如果通信过程中出现以下情况之一,停止条件就不会出现:
- 主设备已经开始传输,但在产生停止条件前总线异常断开。
- 从设备拉低时钟进行时钟拉伸,主设备没有等待足够长时间就超时放弃,导致停止条件发送不完整。
- 主设备发起新的起始条件时,前一个传输的停止条件尚未完全送出,也就是“背靠背”传输时序处理不当。
- SCL或SDA线上有毛刺,被I2C模块误识别为起始条件,而后又没有合法的停止条件。
STM32的I2C在检测到总线错误时会触发BERR错误中断,而且有AF、ARLO等明确的错误标志位,配合超时机制可以相对从容地做恢复。STM8这个模块就简单很多,错误标志少,没有超时计数器,一切靠用户代码自己兜底。所以很多人从STM32转过来,都会在这上面栽跟头。
2.2 寄存器层面的状态组合
排查时要重点区分I2C_SR1和I2C_SR2的状态组合,这会直接影响恢复策略。
I2C_SR2里除了BUSY,还有MSL(主从模式)、ADSL(地址发送)、BTF(字节发送完成)、TRA(收发状态)等位。正常主模式发送时,BUSY=1、MSL=1、TRA=1。卡死时的一个典型状态是:BUSY=1、MSL=0或者MSL=1但TRA=0,说明状态机已经不在正常的数据传输流程里。
我踩坑的一个细节是:很多人看到BUSY=1就急着复位I2C外设,但复位前没有记录I2C_SR1的值。有时候SR1里的AF(应答失败)或OVR(溢出)位已经置位,而错误标志没被清掉的话,单纯复位BUSY后,下一次传输还会被这些残留标志干扰。所以我在排查时习惯先把SR1整个读出来,打印或记录下来,再看SR2。这样才能判断是“物理空闲但状态机忙”还是“确实有错误标志残留”。
2.3 硬件上拉与总线上设备的依赖关系
还有一个硬件层面的原因值得单独说:STM8S105K6的I2C引脚是开漏输出,外部必须有上拉电阻。上拉电阻的取值直接影响信号边沿的上升时间。如果上拉电阻太大(比如用了10kΩ甚至更大),或者总线电容太大(连线过长、过孔太多、接了多个器件),那么SCL/SDA的上升沿会变缓。I2C模块内部对边沿的检测存在施密特触发器,边沿过缓时,可能在一个时钟周期内无法被稳定识别,极端情况下会误判起始或停止条件。
我自己测试时用的板子是手工焊接的,飞线比较长,上拉电阻用了10kΩ,结果就是I2C在低速时偶尔能跑,但一旦把速率提到100kHz以上就出现莫名其妙的BUSY卡死。后来把上拉改成4.7kΩ,飞线尽量缩短,问题频率显著下降。这个属于硬件层面的诱发因素,不一定每次都是它,但值得在排查早期就排除。
2.4 为什么“仲裁丢失”和“应答失败”也要一并关注
BUSY位卡死很多时候不是孤立发生的。在单主机模式下,仲裁丢失的情况比较少,但如果是多主机共享总线,或者其他设备在总线上产生了噪声,主设备可能在发送起始条件时丢失仲裁。STM8的I2C在仲裁丢失时会置位ARLO标志,此时BUSY位可能已经置1,但主设备已经失去总线控制权。
这种状态下,如果代码只是简单判断BUSY位是否为0来决定是否发起传输,那么一旦ARLO置位加上BUSY卡住,你就永远发不出起始条件。关键是,仲裁丢失之后要做的不是清掉BUSY,而是先复位I2C的配置寄存器,重新初始化为主模式,再执行总线恢复。
3. 完整定位链路:从代码追踪到时序问题的排查过程
3.1 第一步:缩小问题范围——是代码问题还是外部因素
排查这类问题,我习惯分三步走:先看代码时序,再量物理波形,最后做单步实验。当时我先在代码里加了详细的标志打印,每次I2C操作前后都记录状态寄存器的值。跑了一会儿发现,卡死前最后一次成功的操作是读温湿度传感器的第一个字节,紧接着的下一次写操作就再也没有发生过起始条件。
从这个现象判断,问题大概率出在“读操作结束到写操作开始”的过渡期间。查看代码发现,我用的I2C库函数在读取最后一个字节后,发送的是NACK加STOP条件。但问题在于,读取函数返回后,主循环里紧接着调用了下一个写操作,而读结束时的STOP条件是否已经在总线上完整发送完毕,代码里并没有检查。
3.2 第二步:逻辑分析仪看时序——STOP条件没有完整发送
用逻辑分析仪抓取卡死时刻的波形,真相很清楚:SCL上出现了一个残缺的脉冲,SDA的电平在STOP条件应有的位置只拉高了一半,然后就维持高电平不动了。也就是说,软件发了STOP命令,但硬件还没来得及完成STOP条件序列,新的起始条件命令就已经到来。
这里要解释一下为什么STM8的I2C会出现这种“命令覆盖”的情况。看数据手册,I2C_CR2寄存器的START位和STOP位,它们的置位是以软件写寄存器的方式触发的,但硬件状态机执行需要时间。你在前一个操作还没结束时置位了START,硬件可能无法正确排队处理,导致当前状态机混乱。尤其在一些HAL库的实现里,发送STOP后并没有等待BUSY位清零就返回了,于是主代码立刻调用下一个传输,START位被设置,时序就乱了。
3.3 第三步:单步调试定位到“背靠背传输”问题
为了验证这一点,我在发送STOP条件后加了一个延时,等BUSY位清零再继续。神奇的是,问题真的消失了。但这只是表面修复,因为延时时间靠不住,不同温度、不同从设备响应速度下,延时不够依然会出问题。
进一步看代码,发现背靠背传输不只发生在读后写,写后读(比如先写寄存器地址再读数据)也容易触发。这是因为STM8的I2C外设在处理“重复起始条件”(repeated start)时,如果上一笔传输的停止条件还没完成就被新的START命令打断,行为是不确定的。有些新出的MCU在硬件上支持自动排队,但STM8不行。
3.4 第四步:异常注入实验——人为制造卡死
为了确认根因,我特意在代码里做了一个异常注入实验。在正常发送停止条件之前,插入一个跳转,跳过STOP发送,直接准备下一个START。实验结果是:BUSY位果然卡住了,现象和现场问题一致。这就彻底证明,根因就是停止条件未完成状态下启动新传输,导致I2C模块内部状态机无法退出繁忙态。
实验还顺带发现一个规律:在100kHz标准模式下,从发出STOP到BUSY位清零,大约需要10到20微秒。这看起来很短,但对主频16MHz的STM8来说,已经是不少指令周期了。如果在STOP发送后紧接着执行一个耗时很短的下一次I2C操作,比如直接写一个字节,这期间极有可能撞上硬件还未清零BUSY的窗口。
3.5 排查过程总结
这一段排查链路,如果总结成一句话就是:遇到I2C Busy bit卡死,首先不要怀疑是MCU坏了,先检查上一个传输是否完整结束,再检查当前时间点是否处于停止条件的发送窗口内。其次是检查错误标志位,最后才是考虑是硬件上拉和信号完整性的问题。按照这个顺序排查,基本能在半小时内定位问题。
4. 解决方案:释放busy bit的三种有效手段
既然根因已经很明确,下面直接给出实测有效的三种恢复方案。根据你的应用场景选择就好,我按推荐程度排序。
4.1 方案A:发送STOP后等待BUSY清零,从流程上根治
代码层面最简单的修复,是在每次发送STOP条件后等待BUSY位清零,然后再执行下一个传输。
// I2C发送停止条件,并等待总线释放 void I2C_SendStop_WaitBusyFree(void) { I2C_CR2 |= I2C_CR2_STOP; // 发送停止条件 // 等待总线释放,超时保护 uint16_t timeout = 0xFFFF; while ((I2C_SR2 & I2C_SR2_BUSY) && timeout--) ; if (timeout == 0) { // 超时仍忙,走恢复流程,见方案B I2C_BusRecovery(); } }这个方案在实践中最为可靠。关键在于timeout不能省,因为某些异常情况下BUSY可能永远不清零,如果没有超时保护,程序就真的死在等待里了。超时时间的取值,我一般设为100ms级别,对应16MHz主频下足够大循环次数,避免干扰正常业务。
另外,注意一点:在某些STM8库函数里,I2C_GenerateSTOP()函数内部只是简单置位STOP位,并没有等待BUSY清零。所以在调用库函数后,你仍然需要自己加等待逻辑,不能依赖库函数替你完成。
4.2 方案B:I2C外设软复位加总线恢复序列
如果BUSY位已经卡死,或者等待超时了,就需要更彻底的恢复手段。首选是使用I2C_CR1寄存器的SWRST位复位整个I2C外设。操作顺序如下:
- 置位
SWRST,让I2C模块回到复位状态。 - 清除
SWRST,重新初始化I2C配置(时钟、模式、地址等)。 - 通过GPIO软件模拟的方式,对总线执行最多9个时钟脉冲的恢复序列,确保任何处于异常状态的从设备都能复位。
void I2C_BusRecovery(void) { // 1. 软复位I2C外设 I2C_CR1 |= I2C_CR1_SWRST; I2C_CR1 &= ~I2C_CR1_SWRST; // 2. 重新初始化I2C配置 I2C_DeInit(); I2C_Init(100000, 0xA0, I2C_MODE_I2C, I2C_DUTYCYCLE_2, I2C_ACK_CURR, I2C_ADDMODE_7BIT, 16); // 3. GPIO模拟时钟恢复序列 // 先把SCL/SDA配成普通开漏输出 GPIO_Init(GPIOC, GPIO_PIN_4 | GPIO_PIN_5, GPIO_MODE_OUT_OD_HISLOW); for (int i = 0; i < 9; i++) { GPIO_WriteLow(GPIOC, GPIO_PIN_4); // SCL拉低 delay_us(5); GPIO_WriteHigh(GPIOC, GPIO_PIN_4); // SCL拉高 delay_us(5); } // 4. 发送一个停止条件,让从设备彻底释放总线 GPIO_WriteLow(GPIOC, GPIO_PIN_5); // SDA拉低 GPIO_WriteLow(GPIOC, GPIO_PIN_4); // SCL拉低 delay_us(5); GPIO_WriteHigh(GPIOC, GPIO_PIN_4); // SCL先高 delay_us(5); GPIO_WriteHigh(GPIOC, GPIO_PIN_5); // SDA再高,形成停止条件 delay_us(5); // 5. 重新配置为I2C复用功能 GPIO_Init(GPIOC, GPIO_PIN_4 | GPIO_PIN_5, GPIO_MODE_OUT_OD_HISLOW); // 根据实际引脚复用配置 }这里的代码是基于标准外设库风格写的,具体引脚和模式要结合你的板子调整。核心思路是:先用软件时钟脉冲把所有可能处于异常状态的从设备时钟状态机复位,再发一个停止条件,让它们释放SDA。这个序列是I2C总线规范里推荐的总线恢复方法,对大多数从设备都有效。
我实测过,在执行完9个时钟脉冲加停止条件后,再用I2C外设重新发起通信,成功率是100%。注意如果总线上有不支持时钟拉伸的从设备,9个脉冲期间该设备可能主动释放总线,但没关系,我们只是想让它回到空闲状态。
4.3 方案C:彻底改用GPIO模拟I2C
如果项目对成本不敏感,或者你已经被硬件I2C折腾得没脾气了,还有个终极大法——直接用GPIO模拟I2C时序。STM8S105K6上有充足的GPIO资源,随便找两个引脚就能模拟出I2C时序,完全绕开硬件模块的状态机问题。
GPIO模拟的好处是时序完全由自己控制,想怎么等就怎么等,不会出现硬件状态机卡死的问题。坏处是CPU占用率稍高,但对于绝大多数低速传感器、EEPROM的应用,100kHz速率下CPU占用率完全可接受。模拟I2C的代码网上有很多成熟实现,这里不贴完整代码,只提醒几个关键点:
- 引脚要配置为开漏输出,并启用内部上拉或者外部上拉。
- SCL和SDA的电平变化之间要保证足够的建立时间和保持时间,标准模式下至少4.7微秒的建立时间。
- 起始条件:SCL高电平时SDA从高变低。
- 停止条件:SCL高电平时SDA从低变高。
- 读取从设备应答时,要把SDA切为输入模式,读取完成后切回输出模式。
我用GPIO模拟I2C驱动SSD1306 OLED显示模块,连续跑了48小时,没有出现一次卡死。所以在稳定性优先的场景,这个方案可以作为备选。
4.4 三种方案的选型建议
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| A(等BUSY清零) | 代码还未完全稳定,处于开发调试期 | 改动小,最直接 | 如果异常原因多样,不一定全面覆盖 |
| B(软复位+总线恢复) | 产品已量产,需要现场自动恢复 | 能处理各种异常,稳定性高 | 需要额外的GPIO操作代码 |
| C(GPIO模拟) | 极端追求稳定,对速率要求不高 | 完全避开硬件I2C | 占用CPU,代码量大 |
大多数情况下,我会先用A方案做快速修复,保证开发进度不受影响,然后在发布生产版本前加上B方案的总线恢复逻辑。C方案除非遇到硬件I2C无法解决的棘手问题才启用。
5. 驱动代码的健壮性改造:预防busy bit卡死的实战建议
5.1 每个I2C操作都要有超时机制
经验不足的开发者在写I2C驱动时,经常用while(!(I2C_SR1 & I2C_SR1_SB))这种死等的方式。在正常通信时没问题,但在总线上有任何异常时,这个while就变成了死循环,程序卡死,而BUSY位恰好在一边高挂。
正确做法是给每个等待条件都设置超时:
uint8_t I2C_WaitEvent(uint16_t event, uint16_t timeout_ms) { uint16_t timeout = timeout_ms * 200; // 粗略换算,具体数值需要实际标定 while (timeout--) { if (I2C_GetLastEvent() & event) return 0; // 成功 if ((I2C_SR2 & I2C_SR2_BUSY) == 0 && (I2C_SR1 & I2C_SR1_SB) == 0) break; // 总线已释放但事件仍未发生,说明有问题 } return 1; // 超时 }超时时间的选择,建议覆盖最大可能的从设备时钟拉伸时间。比如有些温湿度传感器在转换期间会把SCL拉低几十毫秒,超时时间至少设到100毫秒才稳妥。如果设得太短,正常通信也会被误判为超时。
5.2 错误标志的及时清理
STM8的I2C在发生错误时,往往会置位I2C_SR1中的错误标志位,比如AF、BERR、ARLO。如果不及时清除,这些标志会干扰后续传输。清除方式通常是读I2C_SR1,然后写I2C_CR2或者读I2C_DR。
我自己的习惯是:每次I2C传输开始前,主动检查并清除所有错误标志,确保外设处于干净的状态。
void I2C_ClearErrorFlags(void) { uint16_t sr1 = I2C_SR1; // 如果有错误标志,读SR1后写CR2来清除 if (sr1 & (I2C_SR1_AF | I2C_SR1_BERR | I2C_SR1_ARLO | I2C_SR1_OVR)) { I2C_CR2 |= I2C_CR2_SWRST; // 软复位,或者按库函数的具体操作 I2C_CR2 &= ~I2C_CR2_SWRST; } }5.3 中断模式下的一致性保护
如果I2C操作放在中断里执行,尤其是主循环里也在操作I2C,就可能出现重入问题。两个上下文同时操作I2C_CR2,一个置START,一个置STOP,硬件收到相互矛盾的指令,BUSY位卡死的概率会成倍上升。
解决方案很简单:加互斥锁,或者保证同一时刻只有一个上下文操作I2C。我用一个全局变量i2c_busy做互斥标志,进中断前检查,如果在忙就丢弃本次操作,比单纯的关闭中断更灵活。
volatile uint8_t i2c_busy = 0; uint8_t I2C_StartTransfer(uint8_t devAddr, uint8_t rw) { if (i2c_busy) return 1; // 总线忙,直接返回失败 i2c_busy = 1; // 发起传输... }5.4 配合硬件设计减少卡死概率
软件做了万全准备,硬件上也需要配合。上面提到的上拉电阻,建议标准模式下用4.7kΩ,快速模式(400kHz)下用2.2kΩ。走线方面,SDA和SCL要尽量靠近,远离大电流走线和晶振等干扰源。如果板子上有多个I2C从设备,建议每个设备都单独加上拉电阻,而不是共用一个上拉。多个上拉并联会降低等效电阻,这就需要计算一下,保证不超出I2C规范的最大IOL容限。
另外给I2C供电加一个RC滤波器或者磁珠,也能有效抑制电源上的毛刺耦合到总线上。别小看这些硬件细节,我遇到过一块板子怎么改代码都偶尔卡死,最后发现是I2C跟旁边的DC-DC电感靠得太近,信号被干扰了。重新布线后才彻底好。
5.5 从实际项目中总结的驱动模板
这里给出一份我个人在项目里使用的简易I2C驱动写操作模板,它结合了超时、错误标志清零和停止条件等待。读操作类似,只是把发送和接收顺序调整一下。
// 向从设备写一个字节 uint8_t I2C_WriteByte(uint8_t devAddr, uint8_t regAddr, uint8_t data) { // 1. 等待总线空闲 if (I2C_WaitBusFree(100) != 0) { I2C_BusRecovery(); return 1; } // 2. 清除残留错误标志 I2C_ClearErrorFlags(); // 3. 发送起始条件 I2C_GenerateSTART(ENABLE); if (I2C_WaitEvent(I2C_EVENT_MASTER_START_SENT, 100) != 0) { I2C_BusRecovery(); return 2; } // 4. 发送从设备地址(写) I2C_Send7bitAddress(devAddr, I2C_DIRECTION_TX); if (I2C_WaitEvent(I2C_EVENT_MASTER_ADDRESS_ACKED, 100) != 0) { I2C_BusRecovery(); return 3; } // 5. 发送寄存器地址 I2C_SendData(regAddr); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED, 100) != 0) { I2C_BusRecovery(); return 4; } // 6. 发送数据字节 I2C_SendData(data); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED, 100) != 0) { I2C_BusRecovery(); return 5; } // 7. 发送停止条件,并等总线释放 I2C_GenerateSTOP(ENABLE); if (I2C_WaitBusFree(100) != 0) { I2C_BusRecovery(); return 6; } return 0; }这份模板在项目里经受住了长时间运行测试,卡死的概率从最初每个小时一次降到了几乎为零。核心思想就一句话:每次传输开始前确保总线真正空闲,每次传输结束后确保总线上真的发出了停止条件。很多看似玄学的I2C问题,根源都是这几个基础点没做好。
6. 最后的几点体会
STM8S105K6的I2C总线问题,归根结底不是芯片有缺陷,而是它的I2C模块缺少现代MCU上的那些自动保护和恢复机制。这就像一把没有保险栓的老式步枪,好用,但需要使用者自己养成严格的操枪习惯。
我个人的习惯是,在开发初期就把I2C驱动做成带完整错误处理和恢复机制的标准模块,而不是先用一个能跑的版本,等出了问题再打补丁。因为I2C的异常往往是稀发性的,可能跑几个小时才出现一次,靠现场调试去抓问题效率太低。把驱动做健壮之后,这类问题基本不会在你眼前出现。
如果你正在被STM8或者其他老架构MCU的I2C忙标志卡死困扰,不妨按这篇文章的顺序走一遍:先确认物理总线是否真的空闲,再看上一个传输是否完整结束,最后补上超时和总线恢复逻辑。大部分情况下,这三板斧下去问题就解决了。
