尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32 的 I2C 卡死在 BUSY:从机按住 SDA 的成因与恢复时序

STM32 的 I2C 卡死在 BUSY:从机按住 SDA 的成因与恢复时序 去年给实验室的环境监测节点加了一块 0.96 寸 OLED和温湿度传感器共用 I2C1。台面上调试一切正常放到窗边连续跑了几天后早上来发现串口没输出了程序停在 OLED 刷新的HAL_I2C_Master_Transmit里面。看门狗复位之后又能跑但过几个小时还会再来一次。换过传感器、换过杜邦线问题依旧最后用逻辑分析仪抓波形才发现每次卡死时 SCL 是高电平SDA 被从机一直拉低总线根本发不出起始条件。这篇文章把这件事拆开讲BUSY 标志到底在等什么从机为什么会按住 SDA 不放以及怎么用软件把总线救回来。BUSY 位到底在等什么STM32 的 HAL 库在阻塞式收发里会先检查I2C_FLAG_BUSY。这个标志不是外设正在忙而是总线电平状态的映射只要 SCL 或 SDA 有一根被拉低硬件就认为总线被占用BUSY 置位。问题在于很多示例代码是这么写的HAL_I2C_Master_Transmit(hi2c1, addr, buf, len, HAL_MAX_DELAY);HAL_MAX_DELAY意味着永不超时。一旦总线被从机按住这行代码就在while里等 BUSY 清零等不到就永远不返回。主循环停了喂狗停了最后只能靠看门狗复位。所以第一个结论很直接所有 I2C 阻塞调用都必须给超时HAL_MAX_DELAY在 I2C 上不是一个可以偷懒的选项。给了超时之后函数会返回HAL_BUSY或HAL_TIMEOUT。但返回错误只是把卡死变成了报错总线仍然是死的下一次调用照样失败。要真正恢复得先搞清楚从机为什么不肯松手。还有一种常见写法是用HAL_I2C_IsDeviceReady在初始化时扫描地址确认设备在线。这个函数同样依赖总线空闲总线被按住时它只会一直返回HAL_BUSY既不能证明从机坏了也不能把总线救活。把它当成上电自检可以当成修复手段不行。从机为什么会把 SDA 按住不放I2C 的两根线都是开漏结构器件只能把线拉低拉高靠上拉电阻。也就是说总线上任何一个器件拉低整根线就是低电平谁也抢不回来。一次完整的字节传输是 8 个数据位加 1 个应答位。从机在输出数据时会在 SCL 低电平期间把下一位放到 SDA 上如果这一位是 0它就把 SDA 拉低然后等主机给出第 9 个时钟来读应答。如果主机在这个节骨眼上复位了SCL 停在某个状态不再翻转从机就会一直保持 SDA 为低等那个永远不来的时钟。这时候主机重新启动想要发出起始条件SCL 为高时让 SDA 由高变低。可 SDA 已经被从机拉低主机根本无法制造这个下降沿硬件看到 SDA 低就置 BUSY于是卡住。除了 MCU 复位下面几种情况也会造成同样的局面从机上电比 MCU 晚MCU 已经开始扫描总线热插拔传感器插头瞬间的毛刺被从机当成了时钟从机自身死机或供电跌落输出级停在低电平多主机系统中 arbitration 丢失后状态机没有正确复位。所以换一根线就好了往往是错觉真正变化的是复位时机和上电顺序。另外要区分两种按住按住 SDA 和按住 SCL。前者是从机在等时钟补时钟有效后者是从机在做 clock stretching或者 SCL 被短路此时主机连一个高电平都发不出去9 个时钟也无从谈起。所以恢复函数执行前先看一眼两根线的电平能省掉很多无效尝试。先用 9 个时钟把总线救回来从机等的其实很简单把当前这个字节剩下的时钟补完它就会释放 SDA。一个字节最多 9 个时钟8 位数据加 1 位应答所以恢复的标准做法是把 I2C 外设关掉用 GPIO 手动在 SCL 上打最多 9 个脉冲SDA 保持释放最后补一个 STOP。手动打脉冲之前要先HAL_I2C_DeInit否则外设还占着引脚。恢复完成后调用HAL_I2C_InitHAL 会通过MspInit把引脚切回复用开漏模式。#define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 /* 返回 1 表示恢复成功0 表示 SDA 仍被拉低多半是硬件问题 */ static uint8_t i2c_bus_recover(I2C_HandleTypeDef *hi2c) { GPIO_InitTypeDef gpio {0}; uint8_t ok; HAL_I2C_DeInit(hi2c); /* 外设让出引脚 */ gpio.Mode GPIO_MODE_OUTPUT_OD; /* 开漏和总线电气一致 */ gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Pin I2C_SCL_PIN; HAL_GPIO_Init(I2C_SCL_PORT, gpio); /* SDA 先设为输入确保不会和从机对拉 */ gpio.Pin I2C_SDA_PIN; gpio.Mode GPIO_MODE_INPUT; HAL_GPIO_Init(I2C_SDA_PORT, gpio); ok (HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) GPIO_PIN_SET); if (!ok) { for (uint8_t i 0; i 9; i) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); if (HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) GPIO_PIN_SET) { ok 1; break; } } } if (ok) { /* SDA 已释放补一个 STOPSCL 高时 SDA 由低变高 */ gpio.Pin I2C_SDA_PIN; gpio.Mode GPIO_MODE_OUTPUT_OD; HAL_GPIO_Init(I2C_SDA_PORT, gpio); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); i2c_delay_us(5); } HAL_I2C_Init(hi2c); /* MspInit 会把引脚切回复用模式 */ return ok; }i2c_delay_us可以用 DWT 计数器或者定时器实现。核心不是精确的 5 微秒而是让 SCL 的低电平和高电平都足够宽慢一点没关系快到来不及被从机采样才会出问题。调用方应该把超时、恢复、重试一次包在一起并且限制重试次数HAL_StatusTypeDef i2c_write_retry(uint16_t addr, uint8_t *buf, uint16_t len) { HAL_StatusTypeDef st; st HAL_I2C_Master_Transmit(hi2c1, addr, buf, len, 10); if (st HAL_OK) { return st; } if (!i2c_bus_recover(hi2c1)) { return HAL_ERROR; /* 硬件层面没救回来交给上层处理 */ } return HAL_I2C_Master_Transmit(hi2c1, addr, buf, len, 10); }这里有一个容易忽略的细节恢复函数只负责把总线拉回空闲不负责判断为什么出错。如果i2c_bus_recover返回 09 个时钟打完 SDA 还是低那就不要再无限重试了先查上拉电阻、查短路、查从机供电再考虑给从机断电重启。我现在习惯在main里、第一次访问任何 I2C 设备之前先调用一次恢复函数。因为上一次复位很可能就发生在传输中途从机还停在半个字节里如果等第一次读写失败再恢复等于把故障推迟到业务代码里。代价只是启动时多花几十微秒。如果板子空间允许我还会留一路软件 I2C 作为兜底GPIO 模拟的时序完全可控遇到从机 clock stretching 也能自己决定等多久。它不是要替代硬件 I2C而是在调试阶段用来对比——同一套读写逻辑硬件外设失败、软件时序成功基本就能把问题锁定在总线状态而不是从机寄存器。恢复是补救预防才是正事把恢复函数加进去以后那个节点确实不再整机卡死了但偶尔还是会丢一帧显示数据。后来我按下面的顺序逐项检查才把根因压下去。现象更可能的原因怎么确认处理一上电就 BUSYSDA 始终为低从机供电晚于 MCU或上次传输被打断断电测 SDA 对地电阻上电看电平调整上电顺序从机加复位脚启动时先做一次总线恢复偶发HAL_BUSY重新初始化就好干扰、时钟被拉长、总线电容过大逻辑分析仪抓 SCL/SDA 波形加超时与恢复缩短走线检查上拉SCL 一直被拉低从机 clock stretching 卡住或短路断开从机看 SCL 是否释放查从机状态必要时断电重启从机多设备时偶发丢数据上拉偏弱或总线电容过大看上升沿是否变缓减小上拉阻值、降低速率、减少挂载设备上拉电阻也不是越小越好。常见模块板上已经带了 4.7k 或 10k多个模块并在一起等效阻值会变小低电平时的灌电流变大反而可能把低电平抬高。我一般按总线和速率这样选上拉阻值适用情况代价10k100kHz、短线、单从机上升沿慢400kHz 下波形容易变差4.7k100–400kHz 的常见折中模块自带时并联会偏小需要算总阻值2.2k400kHz、总线电容较小灌电流大对从机低电平驱动能力要求高上电顺序这一点值得单独说如果传感器和 MCU 共用 3.3V但传感器板上有大电容MCU 可能先启动并开始扫描此时传感器还没准备好SDA 被它拉低的概率就很高。我的做法是给传感器单独留一个 GPIO 控制的 MOSFET 或者直接用一个复位脚等电源稳定后再释放。除此之外还有几件成本很低但很值的事在 RTOS 里给 I2C 总线加互斥锁避免两个任务同时发起传输给从机留一个复位引脚软件恢复失败时直接硬件复位保留看门狗但不要把看门狗当成 I2C 的常规恢复手段。看门狗复位整机代价远大于恢复一条总线。小结I2C 卡死的本质不是STM32 的 I2C 有 bug而是开漏总线上的状态机没有走完从机在等时钟主机在等 SDA 释放双方互等。理解这一点之后处理思路就很清楚了——给所有调用加超时出错后用 9 个时钟加手动 STOP 把从机从当前字节里推出来再重新初始化外设。恢复代码最好在系统启动时也主动跑一次因为上一次复位很可能就是在传输中途发生的。这样即使现场再出现异常最坏也只是丢一次读数而不是整机停摆。
返回列表