# 一、问题现象:偶发 I2C 总线卡死 某产品基于 ESP32-WROOM-32,使用 FreeRTOS 双核运行多个任务: - 高优先级任务 A(优先级 10):每 100ms 读取传感器数据(通过 I2C) - 中优先级任务 B(优先级 5):处理网络协议栈,频繁阻塞 - 低优先级任务 C(优先级 3):周期性写 EEPROM(通过 I2C) 系统运行数小时后,偶发出现 I2C 总线挂死,所有 I2C 操作超时,且无法自动恢复。重启后正常,但故障复现率约 1%。 # 二、初步排查:日志与硬件检查 首先通过日志确认故障点: ```c // I2C 操作封装函数 esp_err_t i2c_read_reg(uint8_t dev_addr, uint8_t reg, uint8_t *data) { ESP_LOGI("I2C", "Read start, addr=0x%02x", dev_addr); // 实际 I2C 读操作 esp_err_t ret = i2c_master_read_from_device(dev_addr, reg, data, 1, 100); ESP_LOGI("I2C", "Read done, ret=%d", ret); return ret; } ``` 故障时日志显示:任务 A 的读操作发出后,日志停在 "Read start",不再有后续输出。说明任务 A 阻塞在 I2C 驱动内部。 硬件检查:I2C 引脚 SCL/SDA 电平正常,无短路,上拉电阻完好。排除硬件问题。 # 三、深入分析:FreeRTOS 优先级反转与 I2C 驱动互斥锁 ESP32 的 I2C 驱动(esp-idf v4.4)内部使用互斥锁(mutex)保护总线访问。当多个任务同时访问 I2C 时,驱动会获取互斥锁。 **优先级反转**:低优先级任务 C 持有互斥锁时,中优先级任务 B 抢占 CPU(因为 B 优先级高于 C),导致 C 无法继续执行释放锁。此时高优先级任务 A 等待锁,但锁被 C 持有,而 C 被 B 抢占,形成 A 等待 C,C 被 B 抢占的链式阻塞。 在单核环境下,FreeRTOS 默认支持优先级继承,可缓解此问题。但 ESP32 双核环境下,两个核心独立调度,优先级继承机制可能失效,因为互斥锁的持有者可能运行在另一个核心上,而优先级继承只影响本地核心的调度。 # 四、定位死锁:使用 FreeRTOS 内核调试 在故障发生时,通过串口控制台调用以下调试函数: ```c void dump_task_info(void) { TaskStatus_t *task_list; UBaseType_t task_count = uxTaskGetNumberOfTasks(); task_list = pvPortMalloc(task_count * sizeof(TaskStatus_t)); uxTaskGetSystemState(task_list, task_count, NULL); for (int i = 0; i < task_count; i++) { printf("Task: %s, state: %d, prio: %d, stack: %d\n", task_list[i].pcTaskName, task_list[i].eCurrentState, task_list[i].uxCurrentPriority, task_list[i].usStackHighWaterMark); } vPortFree(task_list); } ``` 输出显示: - 任务 A:Blocked(等待互斥锁) - 任务 B:Running(持续占用 CPU) - 任务 C:Blocked(等待某个事件,但锁未释放) 确认是优先级反转导致死锁。 # 五、解决方案:互斥锁超时与优先级继承 ## 方案一:使用带超时的互斥锁获取 修改 I2C 操作封装,使用 `xSemaphoreTakeRecursive` 并设置超时,避免无限阻塞: ```c static SemaphoreHandle_t i2c_mutex; void i2c_init_mutex(void) { i2c_mutex = xSemaphoreCreateMutex(); } esp_err_t i2c_read_reg_safe(uint8_t dev_addr, uint8_t reg, uint8_t *data) { if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(50)) != pdTRUE) { ESP_LOGE("I2C", "Mutex acquire timeout"); return ESP_ERR_TIMEOUT; } esp_err_t ret = i2c_master_read_from_device(dev_addr, reg, data, 1, 100); xSemaphoreGive(i2c_mutex); return ret; } ``` 超时后返回错误,任务可重试或放弃,避免死锁。但此方法治标不治本,仍可能频繁超时。 ## 方案二:启用优先级继承(推荐) 在创建互斥锁时使用 `xSemaphoreCreateMutex` 已默认支持优先级继承,但 ESP32 双核下需要额外配置。在 menuconfig 中启用 `CONFIG_FREERTOS_USE_TRACE_FACILITY` 和 `CONFIG_FREERTOS_ENABLE_INTERRUPT_BACKWARD_COMPATIBILITY`,并确保使用 `xSemaphoreCreateMutex` 而非 `xSemaphoreCreateBinary`。 更关键的是,将 I2C 操作限制在单核上执行,避免跨核锁竞争: ```c // 在 app_main 中创建 I2C 任务,固定到 core 0 xTaskCreatePinnedToCore(i2c_task, "i2c_task", 4096, NULL, 5, &i2c_task_handle, 0); ``` 这样所有 I2C 操作都在同一核心,优先级继承机制正常工作。 ## 方案三:重构任务优先级 调整任务优先级,使访问 I2C 的任务优先级相近,减少反转窗口。例如将任务 C 的优先级提升到 8,任务 A 保持 10,任务 B 保持 5,这样 C 不会被 B 抢占,锁能及时释放。 # 六、完整代码示例(规避方案) 结合方案一和方案二,实现安全 I2C 访问: ```c #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "driver/i2c.h" static SemaphoreHandle_t i2c_mutex; void i2c_init(void) { // 初始化 I2C 驱动(略) i2c_mutex = xSemaphoreCreateMutex(); } esp_err_t i2c_read_reg_safe(uint8_t dev_addr, uint8_t reg, uint8_t *data) { if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { ESP_LOGE("I2C", "Mutex timeout"); return ESP_ERR_TIMEOUT; } esp_err_t ret = i2c_master_read_from_device(dev_addr, reg, data, 1, 100); xSemaphoreGive(i2c_mutex); return ret; } // 在任务中使用 void sensor_task(void *arg) { uint8_t val; while (1) { if (i2c_read_reg_safe(0x48, 0x00, &val) == ESP_OK) { // 处理数据 } vTaskDelay(pdMS_TO_TICKS(100)); } } ``` # 七、注意事项 - **避免在中断中调用 I2C 操作**,会导致不可重入问题。 - **使用互斥锁时,确保所有 I2C 操作都经过同一封装**,不要直接调用驱动 API。 - **调试时开启 FreeRTOS 跟踪**,使用 `traceTASK_SWITCHED_IN` 等宏记录任务切换。 - **在双核环境下,优先考虑将共享资源访问固定到单核**,减少跨核锁竞争。 - **定期检查任务栈余量**,防止栈溢出导致异常。 # 八、总结 ESP32 双核 FreeRTOS 下,优先级反转是导致 I2C 死锁的常见原因。通过理解 FreeRTOS 调度机制、使用互斥锁超时、启用优先级继承并合理设计任务架构,可以有效规避。实际项目中,建议结合多种方案,并加入看门狗监控,确保系统健壮性。