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。
三、初步排查思路
-
确认 I2C 驱动配置:检查
i2c_param_config()和i2c_driver_install()是否正确,总线速率、上拉电阻等是否合理。 - 检查任务优先级:确认两个任务的优先级是否合理,避免优先级反转。
- 审查临界区使用:重点检查所有访问 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 操作必须使用互斥锁。
-
嵌套临界区:若确需嵌套,务必保证
ENTER和EXIT严格配对,且不要在临界区内调用任何可能阻塞的 API。 -
双核调度:ESP32 双核下,
taskENTER_CRITICAL()会获取全局 spinlock,若一个核在临界区内阻塞,另一个核会自旋等待,导致性能下降甚至死锁。 -
使用
vTaskList()调试:当任务卡死时,通过vTaskList()查看任务状态和等待的信号量,能快速定位问题。 -
考虑 I2C 驱动本身的线程安全:ESP-IDF 的 I2C 驱动在
CONFIG_FREERTOS_UNICORE下可能默认非线程安全,务必自行加锁。
八、总结
本次死锁源于对 FreeRTOS 临界区机制的误用,将阻塞的 I2C 操作置于临界区内,且错误嵌套。通过改用互斥锁并规范临界区使用,系统稳定运行。在 ESP32 多核环境下,共享资源保护必须谨慎设计,优先使用互斥锁,避免临界区嵌套和阻塞调用,才能确保实时性和可靠性。