# 引言 在 ESP32 双核 FreeRTOS 环境中,多任务并发访问共享外设(如 I2C)时,优先级反转(Priority Inversion)是常见隐患。通常我们关注其对实时性的影响,但若与硬件协议交互不当,可能演变为死锁。本文以一个实际案例,展示优先级反转如何导致 I2C 总线死锁,并给出可复现的代码和修复方案。 ## 案例背景 系统使用 ESP32-WROOM-32,双核运行 FreeRTOS。I2C0 总线连接多个传感器(如 BME280、MPU6050)。任务设计如下: - **任务A(高优先级,优先级10)**:周期性读取 BME280,频率 10Hz,使用 I2C0。 - **任务B(中优先级,优先级5)**:执行浮点运算和日志输出,不直接访问 I2C,但会占用 CPU 较长时间。 - **任务C(低优先级,优先级2)**:周期性读取 MPU6050,频率 1Hz,使用 I2C0。 所有任务均通过 `i2c_master_cmd_begin()` 访问 I2C 驱动,该函数内部使用 FreeRTOS 互斥量保护总线。 ## 死锁现象 系统运行数小时后,任务A 和任务C 均停止响应,I2C 总线 SCL 线被拉低(用逻辑分析仪观察),系统未触发看门狗,但功能失效。重启后恢复,但会再次发生。 ## 根因分析 ### 1. 优先级反转的经典场景 - 任务C(低优先级)获得 I2C 互斥量,开始传输数据。 - 任务B(中优先级)就绪,抢占 CPU(因为任务C 被互斥量阻塞?不,任务C 正在运行,但任务B 优先级更高,所以任务B 抢占任务C)。 - 任务C 被挂起,但持有 I2C 互斥量。 - 任务A(高优先级)就绪,尝试获取 I2C 互斥量,失败,进入阻塞。 - 此时,任务B 持续运行,任务C 无法获得 CPU,任务A 无限等待。 这导致高优先级任务被中优先级任务间接阻塞,即优先级反转。 ### 2. 从反转到死锁的演变 在 ESP32 双核环境下,问题更复杂。假设任务C 运行在核0,任务B 运行在核1。当任务B 抢占任务C 时,任务C 被挂起,但 I2C 硬件可能处于中间状态(例如,发送了地址字节后,等待 ACK)。 I2C 协议要求主机在时钟线 SCL 高电平期间采样数据,如果主机在传输过程中被挂起,SCL 可能保持低电平(因为主机拉低 SCL 进行时钟拉伸)。此时,若任务A 尝试访问 I2C,它会等待互斥量,而任务C 永远得不到 CPU 来继续传输,导致 SCL 一直为低,总线被锁死。 更关键的是,ESP32 的 I2C 驱动在互斥量保护下,不会超时退出。`i2c_master_cmd_begin()` 默认阻塞等待,没有超时参数,所以任务A 和任务C 永久阻塞。 ### 3. 双核带来的额外风险 双核下,任务调度是独立的。任务B 在核1 运行,不会主动让出 CPU,除非时间片耗尽或阻塞。而任务C 在核0 被挂起,核0 可能运行空闲任务,但无法唤醒任务C,因为任务B 不释放 CPU。这加剧了反转的持续时间。 ## 解决方案 ### 方案一:使用互斥量(Mutex)代替二进制信号量 FreeRTOS 互斥量支持优先级继承(Priority Inheritance)。当高优先级任务等待互斥量时,低优先级任务会临时提升到高优先级,从而快速完成临界区并释放互斥量。 修改代码: ```c // 创建互斥量 SemaphoreHandle_t i2c_mutex = xSemaphoreCreateMutex(); // 在 I2C 访问函数中 if (xSemaphoreTake(i2c_mutex, portMAX_DELAY) == pdTRUE) { // 执行 I2C 传输 i2c_master_cmd_begin(I2C_NUM_0, cmd, portMAX_DELAY); xSemaphoreGive(i2c_mutex); } ``` 这样,当任务A 等待时,任务C 的优先级被提升到10,任务B 无法抢占,任务C 完成传输后释放互斥量。 ### 方案二:为 I2C 操作添加超时 即使有优先级继承,也可能因硬件故障导致死锁。为 `i2c_master_cmd_begin()` 设置超时,如 100ms,超时后释放总线并复位 I2C 外设。 ```c // 设置超时 TickType_t timeout = pdMS_TO_TICKS(100); if (i2c_master_cmd_begin(I2C_NUM_0, cmd, timeout) != ESP_OK) { // 处理错误,复位 I2C i2c_reset_tx_fifo(I2C_NUM_0); i2c_reset_rx_fifo(I2C_NUM_0); i2c_driver_delete(I2C_NUM_0); i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0); } ``` ### 方案三:使用队列串行化 I2C 访问 将 I2C 访问请求放入队列,由一个专用任务(如 I2C 管理任务)统一处理。这样避免了多任务竞争,且管理任务可以设置优先级和超时。 ```c // 定义请求结构 struct i2c_request { uint8_t addr; uint8_t reg; uint8_t data; }; QueueHandle_t i2c_queue; // 发送请求 void i2c_send_request(struct i2c_request *req) { xQueueSend(i2c_queue, req, portMAX_DELAY); } // I2C 管理任务 void i2c_task(void *arg) { struct i2c_request req; while (1) { if (xQueueReceive(i2c_queue, &req, portMAX_DELAY) == pdTRUE) { // 执行 I2C 传输,带超时 // ... } } } ``` ### 方案四:核绑定(Core Affinity) 将 I2C 相关任务绑定到同一核心,并确保中优先级任务不绑定到该核心,或者降低中优先级任务的优先级。但这不是根本解决,只是缓解。 ```c // 创建任务时指定核心 xTaskCreatePinnedToCore(taskA, "A", 4096, NULL, 10, &handleA, 0); // 核0 xTaskCreatePinnedToCore(taskB, "B", 4096, NULL, 5, &handleB, 1); // 核1 xTaskCreatePinnedToCore(taskC, "C", 4096, NULL, 2, &handleC, 0); // 核0 ``` ## 完整代码示例(修复后) 以下代码演示了使用互斥量+超时的修复方案。 ```c #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "driver/i2c.h" SemaphoreHandle_t i2c_mutex; void i2c_init() { i2c_config_t conf = { .mode = I2C_MODE_MASTER, .sda_io_num = 21, .scl_io_num = 22, .sda_pullup_en = GPIO_PULLUP_ENABLE, .scl_pullup_en = GPIO_PULLUP_ENABLE, .master.clk_speed = 100000 }; i2c_param_config(I2C_NUM_0, &conf); i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0); i2c_mutex = xSemaphoreCreateMutex(); } bool i2c_read_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data) { if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { return false; } i2c_cmd_handle_t cmd = i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true); i2c_master_write_byte(cmd, reg_addr, true); i2c_master_start(cmd); i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_READ, true); i2c_master_read_byte(cmd, data, I2C_MASTER_LAST_NACK); i2c_master_stop(cmd); esp_err_t ret = i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(50)); i2c_cmd_link_delete(cmd); xSemaphoreGive(i2c_mutex); return (ret == ESP_OK); } void taskA(void *arg) { uint8_t val; while (1) { if (i2c_read_reg(0x76, 0xFA, &val)) { // 处理数据 } vTaskDelay(pdMS_TO_TICKS(100)); } } void taskB(void *arg) { while (1) { // 浮点运算,占用 CPU for (int i = 0; i < 100000; i++) { volatile float x = i * 3.14; } vTaskDelay(pdMS_TO_TICKS(10)); } } void taskC(void *arg) { uint8_t val; while (1) { if (i2c_read_reg(0x68, 0x3B, &val)) { // 处理数据 } vTaskDelay(pdMS_TO_TICKS(1000)); } } void app_main() { i2c_init(); xTaskCreatePinnedToCore(taskA, "A", 4096, NULL, 10, NULL, 0); xTaskCreatePinnedToCore(taskB, "B", 4096, NULL, 5, NULL, 1); xTaskCreatePinnedToCore(taskC, "C", 4096, NULL, 2, NULL, 0); } ``` ## 注意事项 - **优先级继承并非万能**:如果低优先级任务在临界区中阻塞(如等待其他资源),继承会失效。确保临界区代码简洁,不包含阻塞调用。 - **超时值设置**:I2C 传输通常很快(<10ms),超时设为 50-100ms 足够。超时后需正确复位 I2C 外设,否则可能残留错误状态。 - **双核调度**:使用 `xTaskCreatePinnedToCore` 时,注意任务间同步,避免核间竞争。建议将 I2C 相关任务放在同一核心,减少跨核互斥。 - **调试工具**:使用 `vTaskList()` 和 `vTaskGetRunTimeStats()` 监控任务状态,使用逻辑分析仪观察 I2C 波形,有助于定位死锁点。 ## 总结 本案例展示了优先级反转在 ESP32 双核环境下如何导致 I2C 死锁。通过互斥量优先级继承、超时机制和队列串行化,可以有效避免此类问题。嵌入式开发中,务必考虑优先级反转对共享资源的影响,并设计健壮的容错机制。希望本文能帮助你在实际项目中规避类似陷阱。