# ESP32 多核任务调度下 I2C 总线竞争导致的死锁排查与 FreeRTOS 临界区嵌套修复 ## 一、问题背景 在 ESP32 双核(PRO_CPU 和 APP_CPU)上运行 FreeRTOS,多个任务(如传感器采集、OLED 显示、日志输出)需要共享同一路 I2C 总线。为保护总线,开发者通常使用互斥锁(Mutex)或临界区(Critical Section)。然而,若在临界区内调用带阻塞的 I2C 操作,或临界区嵌套不当,会导致系统死锁或任务卡死。 ## 二、现象描述 - 系统运行数分钟后,OLED 显示停止刷新,传感器数据不再更新。 - 通过串口监视器发现,任务 `sensor_task` 和 `display_task` 均卡在 `i2c_master_write_to_device()` 调用上。 - 使用 `vTaskList()` 查看任务状态,发现两个任务均处于 `Blocked` 状态,且等待的信号量计数为 0。 ## 三、初步排查思路 1. **确认 I2C 驱动配置**:检查 `i2c_param_config()` 和 `i2c_driver_install()` 是否正确,总线速率、上拉电阻等是否合理。 2. **检查任务优先级**:确认两个任务的优先级是否合理,避免优先级反转。 3. **审查临界区使用**:重点检查所有访问 I2C 的代码路径,是否在临界区内调用了可能阻塞的函数。 ## 四、根因定位 通过代码审查,发现 `sensor_task` 中使用了 `taskENTER_CRITICAL()` 保护 I2C 操作,而 `display_task` 中则使用了 `taskENTER_CRITICAL()` 嵌套保护。问题出在以下代码片段: ```c // sensor_task 中 void read_sensor(void) { taskENTER_CRITICAL(&spinlock); i2c_master_write_to_device(I2C_NUM_0, SENSOR_ADDR, cmd, len, 100); i2c_master_read_from_device(I2C_NUM_0, SENSOR_ADDR, data, len, 100); taskEXIT_CRITICAL(&spinlock); } // display_task 中 void update_display(void) { taskENTER_CRITICAL(&spinlock); taskENTER_CRITICAL(&spinlock); // 错误:重复进入同一临界区 i2c_master_write_to_device(I2C_NUM_0, OLED_ADDR, buf, len, 100); taskEXIT_CRITICAL(&spinlock); taskEXIT_CRITICAL(&spinlock); } ``` **问题分析**: - `taskENTER_CRITICAL()` 在单核上会关闭中断,在双核上会获取一个全局互斥锁(spinlock)。重复进入同一临界区会导致嵌套计数错误,在双核环境下可能造成死锁。 - 更严重的是,`i2c_master_write_to_device()` 内部会调用 `vTaskDelay()` 或等待事件组,属于阻塞操作。在临界区内调用阻塞函数,会长时间占用 CPU 或锁,导致其他任务无法获得总线。 ## 五、修复方案 ### 1. 使用互斥锁替代临界区 对于 I2C 这类慢速外设,应使用 FreeRTOS 互斥锁(Mutex)而非临界区,因为互斥锁允许任务在等待时让出 CPU,且支持优先级继承。 ```c SemaphoreHandle_t i2c_mutex; void init_i2c_mutex(void) { i2c_mutex = xSemaphoreCreateMutex(); } void read_sensor(void) { if (xSemaphoreTake(i2c_mutex, portMAX_DELAY) == pdTRUE) { i2c_master_write_to_device(I2C_NUM_0, SENSOR_ADDR, cmd, len, 100); i2c_master_read_from_device(I2C_NUM_0, SENSOR_ADDR, data, len, 100); xSemaphoreGive(i2c_mutex); } } void update_display(void) { if (xSemaphoreTake(i2c_mutex, portMAX_DELAY) == pdTRUE) { i2c_master_write_to_device(I2C_NUM_0, OLED_ADDR, buf, len, 100); xSemaphoreGive(i2c_mutex); } } ``` ### 2. 若必须使用临界区,注意嵌套规则 FreeRTOS 允许临界区嵌套,但必须成对调用,且嵌套深度不能超过 `configMAX_SYSCALL_INTERRUPT_PRIORITY` 限制。在 ESP32 上,推荐使用 `portENTER_CRITICAL()` 和 `portEXIT_CRITICAL()`,并确保嵌套层级正确。 ```c void safe_i2c_operation(void) { portENTER_CRITICAL(&spinlock); // 短小、非阻塞的 I2C 操作(如寄存器读写) portEXIT_CRITICAL(&spinlock); } ``` ### 3. 避免在临界区内调用阻塞 API `i2c_master_write_to_device()` 带有超时参数,内部会等待 I2C 硬件事件,属于阻塞调用。应将其置于临界区之外,或使用非阻塞的 I2C 驱动接口(如 `i2c_master_start()` 等)。 ## 六、完整示例代码 以下是一个基于互斥锁的 I2C 共享示例,适用于 ESP32 双核环境: ```c #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "driver/i2c.h" SemaphoreHandle_t i2c_mutex; #define I2C_MASTER_SCL_IO 22 #define I2C_MASTER_SDA_IO 21 #define I2C_MASTER_NUM 0 #define I2C_MASTER_FREQ_HZ 100000 void i2c_master_init(void) { i2c_config_t conf = { .mode = I2C_MODE_MASTER, .sda_io_num = I2C_MASTER_SDA_IO, .scl_io_num = I2C_MASTER_SCL_IO, .sda_pullup_en = GPIO_PULLUP_ENABLE, .scl_pullup_en = GPIO_PULLUP_ENABLE, .master.clk_speed = I2C_MASTER_FREQ_HZ, }; i2c_param_config(I2C_MASTER_NUM, &conf); i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0); i2c_mutex = xSemaphoreCreateMutex(); } void sensor_task(void *arg) { uint8_t cmd = 0x10; uint8_t data[2]; while (1) { if (xSemaphoreTake(i2c_mutex, portMAX_DELAY)) { i2c_master_write_to_device(I2C_MASTER_NUM, 0x50, &cmd, 1, 100); i2c_master_read_from_device(I2C_MASTER_NUM, 0x50, data, 2, 100); xSemaphoreGive(i2c_mutex); } vTaskDelay(pdMS_TO_TICKS(1000)); } } void display_task(void *arg) { uint8_t buf[8] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; while (1) { if (xSemaphoreTake(i2c_mutex, portMAX_DELAY)) { i2c_master_write_to_device(I2C_MASTER_NUM, 0x3C, buf, sizeof(buf), 100); xSemaphoreGive(i2c_mutex); } vTaskDelay(pdMS_TO_TICKS(500)); } } void app_main(void) { i2c_master_init(); xTaskCreatePinnedToCore(sensor_task, "sensor", 2048, NULL, 2, NULL, 0); xTaskCreatePinnedToCore(display_task, "display", 2048, NULL, 2, NULL, 1); } ``` ## 七、注意事项 - **临界区与互斥锁的选择**:临界区用于保护极短代码段(如变量自增),互斥锁用于保护可能阻塞的操作。I2C 操作必须使用互斥锁。 - **嵌套临界区**:若确需嵌套,务必保证 `ENTER` 和 `EXIT` 严格配对,且不要在临界区内调用任何可能阻塞的 API。 - **双核调度**:ESP32 双核下,`taskENTER_CRITICAL()` 会获取全局 spinlock,若一个核在临界区内阻塞,另一个核会自旋等待,导致性能下降甚至死锁。 - **使用 `vTaskList()` 调试**:当任务卡死时,通过 `vTaskList()` 查看任务状态和等待的信号量,能快速定位问题。 - **考虑 I2C 驱动本身的线程安全**:ESP-IDF 的 I2C 驱动在 `CONFIG_FREERTOS_UNICORE` 下可能默认非线程安全,务必自行加锁。 ## 八、总结 本次死锁源于对 FreeRTOS 临界区机制的误用,将阻塞的 I2C 操作置于临界区内,且错误嵌套。通过改用互斥锁并规范临界区使用,系统稳定运行。在 ESP32 多核环境下,共享资源保护必须谨慎设计,优先使用互斥锁,避免临界区嵌套和阻塞调用,才能确保实时性和可靠性。