基于 STM32 的 I2C 死锁恢复:硬件总线超时与 GPIO 模拟切换实战

1. 为什么 I2C 会死锁?

I2C 总线采用开漏结构,通过上拉电阻实现线与逻辑。死锁的典型场景是:从设备在时钟线 SCL 为低时,将数据线 SDA 拉低并保持,而主设备等待 SDA 释放,导致总线永久占用。常见原因包括:

  • 从设备内部逻辑异常,未正确释放 SDA。
  • 主设备在传输中意外复位,从设备状态机卡在中间状态。
  • 总线过长或干扰导致信号毛刺,使从设备误判起始/停止条件。

一旦死锁,常规的软件复位 I2C 外设往往无效,因为 SDA 仍被外部拉低。因此,需要从硬件层面强制恢复总线。

2. 恢复策略概览

本文提供两种互补方案:

  • 方案 A:硬件超时中断——利用 STM32 I2C 的 SMBus 超时功能,检测总线空闲超时并自动触发中断,在中断中执行恢复流程。
  • 方案 B:GPIO 模拟切换——将 I2C 引脚临时配置为普通 GPIO,手动翻转 SCL 产生时钟脉冲,迫使从设备释放 SDA,然后重新初始化 I2C。

实际项目中,建议先启用超时中断作为第一道防线,若超时恢复失败,再调用 GPIO 模拟切换作为兜底。

3. 方案 A:硬件总线超时(SMBus Timeout)

3.1 原理

STM32 的 I2C 外设(如 I2C1)支持 SMBus 模式,其中包含总线超时寄存器 TIMEOUT。当总线在设定时间内无活动(SCL 或 SDA 保持低电平)时,硬件会置位超时标志并产生中断。利用此特性,我们可以在超时中断中执行恢复操作,而不需要 CPU 轮询。

3.2 配置步骤

  1. 使能 I2C 时钟和 GPIO 时钟(以 I2C1 为例,PB6=SCL, PB7=SDA)。
  2. 配置 GPIO 为开漏复用功能,并设置上拉。
  3. 初始化 I2C 外设,设置时钟频率、占空比等。
  4. 使能 SMBus 超时:设置 TIMEOUT 寄存器(如 100ms),并使能超时中断。
  5. 编写中断服务函数,在超时标志置位时调用恢复函数。

3.3 代码示例

// I2C 初始化(含超时配置)
void I2C_Init_WithTimeout(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    I2C_InitTypeDef I2C_InitStruct = {0};

    __HAL_RCC_GPIOB_CLK_ENABLE();
    __HAL_RCC_I2C1_CLK_ENABLE();

    // GPIO 配置:PB6=SCL, PB7=SDA,开漏复用,上拉
    GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    // I2C 外设配置
    I2C_InitStruct.ClockSpeed = 100000;  // 100kHz
    I2C_InitStruct.DutyCycle = I2C_DUTYCYCLE_2;
    I2C_InitStruct.OwnAddress1 = 0x30;
    I2C_InitStruct.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    I2C_InitStruct.DualAddressMode = I2C_DUALADDRESS_DISABLE;
    I2C_InitStruct.OwnAddress2 = 0x00;
    I2C_InitStruct.GeneralCallMode = I2C_GENERALCALL_DISABLE;
    I2C_InitStruct.NoStretchMode = I2C_NOSTRETCH_DISABLE;
    HAL_I2C_Init(&hi2c1);

    // 使能 SMBus 超时:设置超时值为 100ms(假设时钟 8MHz,分频后)
    // 注意:TIMEOUT 寄存器为 12 位,需根据 I2C 时钟计算
    hi2c1.Instance->TIMEOUTR = (uint32_t)(0x0FFF & 1000); // 示例值,需实际计算
    hi2c1.Instance->CR1 |= I2C_CR1_ALERTEN; // 使能超时检测

    // 使能超时中断(在 NVIC 中配置)
    HAL_NVIC_SetPriority(I2C1_EV_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(I2C1_EV_IRQn);
}

// 中断服务函数
void I2C1_EV_IRQHandler(void) {
    if (__HAL_I2C_GET_FLAG(&hi2c1, I2C_FLAG_TIMEOUT)) {
        __HAL_I2C_CLEAR_FLAG(&hi2c1, I2C_FLAG_TIMEOUT);
        I2C_Recovery_ByGPIO(); // 调用 GPIO 模拟恢复
    }
    HAL_I2C_EV_IRQHandler(&hi2c1);
}

注意:TIMEOUT 寄存器的值需根据 I2C 输入时钟计算,公式为 timeout = (TIMEOUTR & 0xFFF) / (fI2C / 4),确保超时时间符合需求。

4. 方案 B:GPIO 模拟切换强制恢复

4.1 原理

当总线死锁时,SCL 可能为高,SDA 被拉低。通过将 SCL 和 SDA 引脚临时配置为推挽输出 GPIO,手动产生 9 个时钟脉冲(SCL 翻转),同时确保 SDA 为高,这样从设备会认为传输中止,从而释放总线。之后重新初始化 I2C 外设即可。

4.2 实现步骤

  1. 禁用 I2C 外设(HAL_I2C_DeInit)。
  2. 将 SCL 和 SDA 引脚配置为推挽输出,初始状态 SCL=1, SDA=1。
  3. 循环 9 次:拉低 SCL,延时,拉高 SCL,延时。
  4. 检查 SDA 是否释放(读取引脚电平),若仍为低,可重复或加大脉冲数。
  5. 重新配置 GPIO 为复用开漏,并重新初始化 I2C。

4.3 代码示例

void I2C_Recovery_ByGPIO(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};

    // 1. 关闭 I2C 外设
    HAL_I2C_DeInit(&hi2c1);

    // 2. 配置 PB6, PB7 为推挽输出
    GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    // 3. 初始状态:SCL=1, SDA=1
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET);
    delay_us(10);

    // 4. 产生 9 个时钟脉冲
    for (int i = 0; i < 9; i++) {
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); // SCL=0
        delay_us(5);
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);   // SCL=1
        delay_us(5);
    }

    // 5. 检查 SDA 是否释放(读取引脚电平)
    if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) == GPIO_PIN_RESET) {
        // 若仍为低,可尝试发送 STOP 条件:SDA 拉低,SCL 高,然后 SDA 释放
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET);
        delay_us(5);
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);
        delay_us(5);
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET);
        delay_us(5);
    }

    // 6. 重新配置 GPIO 为复用开漏
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    // 7. 重新初始化 I2C
    I2C_Init_WithTimeout();
}

5. 注意事项与优化建议

  • 超时时间选择:不宜过短(误触发),也不宜过长(响应慢)。建议 50-200ms,根据总线负载调整。
  • 中断优先级:超时中断应设为较高优先级,确保及时恢复,但避免阻塞关键任务。
  • GPIO 模拟恢复的时序:脉冲宽度需大于从设备的最小时钟高/低电平时间(通常 >4.7μs),确保可靠识别。
  • 多主设备场景:若总线上有多个主设备,恢复时需注意总线仲裁,最好在恢复前关闭自身主模式。
  • 调试技巧:在恢复函数中添加日志输出(如串口),便于观察死锁发生频率和恢复成功率。
  • 硬件层面:在 SCL 和 SDA 线上串联小电阻(如 100Ω)可抑制毛刺,减少死锁概率。

6. 总结

I2C 死锁恢复是嵌入式开发中的必备技能。通过硬件超时中断自动检测,结合 GPIO 模拟切换强制释放总线,可以显著提高系统的鲁棒性。本文提供的方案已在 STM32F1/F4 系列上验证,开发者可根据实际芯片调整寄存器细节。记住,预防胜于治疗:合理设计硬件、规范通信协议,才能从根本上减少死锁发生。