ESP32 多核任务调度下 I2C 总线竞争导致的死锁排查与 FreeRTOS 临界区嵌套修复

一、问题背景

在 ESP32 双核(PRO_CPU 和 APP_CPU)上运行 FreeRTOS,多个任务(如传感器采集、OLED 显示、日志输出)需要共享同一路 I2C 总线。为保护总线,开发者通常使用互斥锁(Mutex)或临界区(Critical Section)。然而,若在临界区内调用带阻塞的 I2C 操作,或临界区嵌套不当,会导致系统死锁或任务卡死。

二、现象描述

  • 系统运行数分钟后,OLED 显示停止刷新,传感器数据不再更新。
  • 通过串口监视器发现,任务 sensor_taskdisplay_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() 嵌套保护。问题出在以下代码片段:

// 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,且支持优先级继承。

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(),并确保嵌套层级正确。

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 双核环境:

#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 操作必须使用互斥锁。
  • 嵌套临界区:若确需嵌套,务必保证 ENTEREXIT 严格配对,且不要在临界区内调用任何可能阻塞的 API。
  • 双核调度:ESP32 双核下,taskENTER_CRITICAL() 会获取全局 spinlock,若一个核在临界区内阻塞,另一个核会自旋等待,导致性能下降甚至死锁。
  • 使用 vTaskList() 调试:当任务卡死时,通过 vTaskList() 查看任务状态和等待的信号量,能快速定位问题。
  • 考虑 I2C 驱动本身的线程安全:ESP-IDF 的 I2C 驱动在 CONFIG_FREERTOS_UNICORE 下可能默认非线程安全,务必自行加锁。

八、总结

本次死锁源于对 FreeRTOS 临界区机制的误用,将阻塞的 I2C 操作置于临界区内,且错误嵌套。通过改用互斥锁并规范临界区使用,系统稳定运行。在 ESP32 多核环境下,共享资源保护必须谨慎设计,优先使用互斥锁,避免临界区嵌套和阻塞调用,才能确保实时性和可靠性。