基于 STM32 的 I2C 死锁恢复:定时器超时与 GPIO 位拆弹的混合方案
👁 1 阅读 · 2026-08-27 · 嵌入式
I2C 总线在嵌入式系统中应用广泛,但 SCL 或 SDA 被从设备拉低时,会导致总线死锁,常规复位无法恢复。本文深入分析死锁成因,提出一种混合恢复方案:利用定时器超时检测总线状态,结合 GPIO 位拆弹(Bit-Banging)强制释放总线。文章涵盖原理、配置步骤、完整代码示例及注意事项,帮助开发者构建健壮的 I2C 通信层。
# 基于 STM32 的 I2C 死锁恢复:定时器超时与 GPIO 位拆弹的混合方案
## 1. 问题背景与死锁成因
I2C 总线采用开漏结构,设备通过拉低 SCL/SDA 进行通信。当从设备在传输中异常(如掉电、复位、程序跑飞)时,可能将 SCL 或 SDA 拉低并保持,导致总线进入死锁状态。此时主设备无法正常发起通信,即使软件复位 I2C 外设,总线仍被占用,因为 SCL/SDA 的物理电平由外部设备控制。
常见死锁场景:
- 从设备在 ACK 阶段异常,SDA 被拉低。
- 主设备在传输中复位,从设备等待后续时钟,SCL 被拉低。
- 总线竞争或噪声干扰导致状态机错乱。
## 2. 恢复原理:定时器超时 + GPIO 位拆弹
### 2.1 定时器超时检测
在发起 I2C 通信前,启动一个定时器(如 TIM2),设定超时时间(例如 100ms)。若在超时时间内未完成传输,则认为总线可能死锁,进入恢复流程。
### 2.2 GPIO 位拆弹恢复
将 I2C 引脚(SCL、SDA)重新配置为 GPIO 开漏输出,通过软件模拟 I2C 时序,强制产生 9 个时钟脉冲,并在此期间释放 SDA,使从设备退出异常状态。具体步骤:
1. 将 SCL 和 SDA 配置为开漏输出,初始均为高电平。
2. 循环 9 次:
- 拉低 SCL,延时(>4.7μs,满足 I2C 低电平时间)。
- 释放 SCL(拉高),延时(>4.7μs)。
- 在 SCL 高电平期间,检查 SDA 是否被释放(读取 SDA 电平),若为高则提前退出。
3. 产生停止条件:SDA 拉低,SCL 拉高,再释放 SDA。
4. 将引脚重新配置为 I2C 复用功能。
此方法利用 GPIO 直接控制电平,绕过 I2C 外设状态机,强制从设备复位其内部状态。
## 3. STM32 配置步骤(以 HAL 库为例)
### 3.1 硬件连接
- 使用 I2C1:SCL=PB6,SDA=PB7(以 STM32F103 为例)。
- 外部上拉电阻 4.7kΩ 至 VDD。
### 3.2 初始化定时器
```c
// 定时器句柄
TIM_HandleTypeDef htim2;
void MX_TIM2_Init(void) {
htim2.Instance = TIM2;
htim2.Init.Prescaler = 7200 - 1; // 72MHz/7200 = 10kHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 1000 - 1; // 100ms 超时
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_Base_Init(&htim2);
}
```
### 3.3 I2C 初始化(正常模式)
```c
I2C_HandleTypeDef hi2c1;
void MX_I2C1_Init(void) {
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000;
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.OwnAddress1 = 0;
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;
HAL_I2C_Init(&hi2c1);
}
```
### 3.4 GPIO 位拆弹恢复函数
```c
// 定义引脚
#define I2C_SCL_PIN GPIO_PIN_6
#define I2C_SDA_PIN GPIO_PIN_7
#define I2C_GPIO_PORT GPIOB
// 恢复函数
void I2C_RecoverWithBitBang(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 1. 配置为开漏输出
GPIO_InitStruct.Pin = I2C_SCL_PIN | I2C_SDA_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
// 2. 初始电平:SCL=1, SDA=1
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
delay_us(10);
// 3. 产生 9 个时钟脉冲
for (int i = 0; i < 9; i++) {
// SCL 低
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_RESET);
delay_us(5);
// SCL 高
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
delay_us(5);
// 检查 SDA 是否释放(读引脚电平)
if (HAL_GPIO_ReadPin(I2C_GPIO_PORT, I2C_SDA_PIN) == GPIO_PIN_SET) {
break; // 从设备已释放,提前退出
}
}
// 4. 产生停止条件:SDA 低,SCL 高,释放 SDA
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_RESET);
delay_us(5);
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
delay_us(5);
HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
delay_us(10);
// 5. 重新配置为 I2C 复用功能
GPIO_InitStruct.Pin = I2C_SCL_PIN | I2C_SDA_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF4_I2C1; // 根据芯片调整
HAL_GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
}
```
### 3.5 带超时的 I2C 读写封装
```c
// 超时标志
volatile uint8_t i2c_timeout_flag = 0;
// 定时器回调(在中断中置位)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim->Instance == TIM2) {
i2c_timeout_flag = 1;
}
}
// 带超时的写函数
HAL_StatusTypeDef I2C_WriteTimeout(uint16_t devAddr, uint8_t *data, uint16_t len) {
i2c_timeout_flag = 0;
HAL_TIM_Base_Start_IT(&htim2);
HAL_StatusTypeDef status = HAL_I2C_Master_Transmit(&hi2c1, devAddr, data, len, 100);
HAL_TIM_Base_Stop_IT(&htim2);
if (i2c_timeout_flag) {
// 超时,执行恢复
I2C_RecoverWithBitBang();
return HAL_TIMEOUT;
}
return status;
}
```
## 4. 完整示例:检测并恢复死锁
```c
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C1_Init();
MX_TIM2_Init();
uint8_t data[] = {0x01, 0x02};
while (1) {
HAL_StatusTypeDef status = I2C_WriteTimeout(0x50 << 1, data, 2);
if (status == HAL_OK) {
// 正常处理
} else {
// 恢复后重试
HAL_Delay(10);
}
HAL_Delay(1000);
}
}
```
## 5. 注意事项
- **延时精度**:位拆弹中的延时需满足 I2C 时序(>4.7μs),建议使用定时器或 DWT 实现精确延时,避免使用 HAL_Delay(基于 SysTick,可能被中断影响)。
- **引脚复用**:恢复后重新配置为 AF_OD 时,务必确认复用功能编号(如 STM32F1 为 AF4,F4 为 AF4 或 AF1,需查数据手册)。
- **从设备状态**:位拆弹仅适用于从设备因时钟缺失而锁死的情况。若从设备硬件损坏,无法恢复。
- **超时时间**:根据 I2C 速率和传输长度合理设置,避免误判。例如 100kHz 下传输 256 字节约需 20ms,超时设为 100ms 较安全。
- **中断优先级**:定时器中断优先级应低于 I2C 事件中断,避免嵌套导致超时误触发。
- **测试验证**:在实际从设备上测试死锁场景(如热插拔、断电),确保恢复流程有效。
## 6. 总结
本文提出的混合方案结合了定时器超时检测和 GPIO 位拆弹,能有效解决 I2C 总线死锁问题。该方法不依赖特定从设备,通用性强,且代码实现简单。在实际项目中,建议将恢复逻辑封装为独立模块,便于移植和复用。通过合理配置超时和位拆弹时序,可显著提升系统的鲁棒性。