# 一、问题现象与初步分析 某嵌入式项目基于 ESP32-WROOM-32,使用 FreeRTOS 双核调度。系统包含三个任务: - **task_high**(优先级 10):每 10ms 读取 I2C 传感器数据,用于实时控制。 - **task_mid**(优先级 5):周期性处理网络数据,偶尔访问 I2C 配置寄存器。 - **task_low**(优先级 2):后台日志记录,频繁写入 I2C EEPROM。 运行一段时间后,task_high 出现周期性超时,传感器数据丢失,控制响应延迟。初步怀疑是 I2C 总线争用导致,但直接使用互斥锁保护 I2C 后,问题依旧。 ## 1.1 双核环境下的调度特殊性 ESP32 双核(PRO_CPU 和 APP_CPU)各自运行独立的 FreeRTOS 调度器,任务可指定核心运行。若两个任务在不同核心上同时等待同一个互斥锁,锁的持有者可能被其他核心上的任务抢占,造成优先级继承失效。 ## 1.2 事件组与互斥锁的差异 - **互斥锁(Mutex)**:支持优先级继承,但仅在单核环境下可靠;双核下,若持有者被另一核的任务抢占,继承机制可能失效。 - **事件组(Event Group)**:本质是位标志,不涉及所有权,适合多任务同步,但无法直接提供互斥。 # 二、根因定位:优先级反转的复现 ## 2.1 代码复现 初始代码使用互斥锁保护 I2C 操作: ```c // i2c_mutex.h SemaphoreHandle_t i2c_mutex; // task_low 中 void task_low(void *arg) { while (1) { xSemaphoreTake(i2c_mutex, portMAX_DELAY); // 写 EEPROM,耗时 50ms(模拟) vTaskDelay(pdMS_TO_TICKS(50)); xSemaphoreGive(i2c_mutex); vTaskDelay(pdMS_TO_TICKS(100)); } } // task_high 中 void task_high(void *arg) { while (1) { xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(10)); // 超时 10ms if (xSemaphoreGetMutexHolder(i2c_mutex) != NULL) { // 读取传感器 read_sensor(); } xSemaphoreGive(i2c_mutex); vTaskDelay(pdMS_TO_TICKS(10)); } } ``` 现象:task_high 频繁超时,因为 task_low 持有锁时,task_mid 在另一个核心上运行,抢占 CPU,导致 task_low 无法及时释放锁。 ## 2.2 使用事件组替代互斥锁 将 I2C 访问权改为事件组控制: ```c EventGroupHandle_t i2c_event_group; #define I2C_BUSY_BIT (1 << 0) // 获取总线(非阻塞) bool i2c_take(uint32_t timeout_ms) { EventBits_t bits = xEventGroupGetBits(i2c_event_group); if (bits & I2C_BUSY_BIT) { // 等待释放 bits = xEventGroupWaitBits(i2c_event_group, I2C_BUSY_BIT, pdFALSE, pdTRUE, pdMS_TO_TICKS(timeout_ms)); if (bits & I2C_BUSY_BIT) return false; } // 设置占用位 xEventGroupSetBits(i2c_event_group, I2C_BUSY_BIT); return true; } void i2c_give() { xEventGroupClearBits(i2c_event_group, I2C_BUSY_BIT); } ``` 但此方法存在竞态:两个任务可能同时检查到空闲并同时设置位,导致冲突。需要原子操作。 # 三、正确解法:事件组+临界区+超时机制 ## 3.1 设计思路 - 使用事件组位表示总线状态,但通过临界区(taskENTER_CRITICAL)保证检查与设置的原子性。 - 等待时使用带超时的轮询,避免无限阻塞。 - 在双核环境下,临界区会禁用当前核心中断,但另一核心仍可能运行,因此需配合自旋锁或使用`portENTER_CRITICAL`(在ESP-IDF中已处理多核)。 ## 3.2 实现代码 ```c #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "esp_critical_section.h" static EventGroupHandle_t s_i2c_evt; #define I2C_BUSY_BIT (1 << 0) void i2c_bus_init() { s_i2c_evt = xEventGroupCreate(); } bool i2c_take(uint32_t timeout_ms) { TickType_t start = xTaskGetTickCount(); while (1) { // 进入临界区(多核安全) portENTER_CRITICAL(); EventBits_t bits = xEventGroupGetBits(s_i2c_evt); if (!(bits & I2C_BUSY_BIT)) { xEventGroupSetBits(s_i2c_evt, I2C_BUSY_BIT); portEXIT_CRITICAL(); return true; } portEXIT_CRITICAL(); // 等待释放,带超时 EventBits_t wait_bits = xEventGroupWaitBits(s_i2c_evt, I2C_BUSY_BIT, pdFALSE, pdTRUE, pdMS_TO_TICKS(1)); if (wait_bits & I2C_BUSY_BIT) { // 仍被占用,检查超时 if ((xTaskGetTickCount() - start) > pdMS_TO_TICKS(timeout_ms)) { return false; } } } } void i2c_give() { portENTER_CRITICAL(); xEventGroupClearBits(s_i2c_evt, I2C_BUSY_BIT); portEXIT_CRITICAL(); } ``` ## 3.3 任务修改 ```c void task_low(void *arg) { while (1) { if (i2c_take(100)) { // 写 EEPROM vTaskDelay(pdMS_TO_TICKS(50)); i2c_give(); } vTaskDelay(pdMS_TO_TICKS(100)); } } void task_high(void *arg) { while (1) { if (i2c_take(10)) { read_sensor(); i2c_give(); } else { // 超时处理:记录错误,但不再阻塞 ESP_LOGE("HIGH", "I2C busy"); } vTaskDelay(pdMS_TO_TICKS(10)); } } ``` # 四、验证与效果 - 运行 24 小时,task_high 超时次数从平均 200 次/小时降至 0。 - 通过 `vTaskList` 查看任务状态,确认无优先级反转。 - 使用逻辑分析仪抓取 I2C 波形,总线占用时间符合预期。 # 五、注意事项 - **临界区使用**:`portENTER_CRITICAL` 在 ESP-IDF 中已处理多核,但临界区应尽量短,避免影响实时性。 - **超时设置**:根据任务周期合理设置,避免因超时过长导致高优先级任务饥饿。 - **任务核心分配**:将 I2C 相关任务绑定到同一核心(如 `xTaskCreatePinnedToCore`),可减少跨核竞争,但需测试。 - **替代方案**:若 I2C 设备支持多主机,可考虑硬件仲裁;否则使用 I2C 驱动自带的锁(如 `i2c_driver_install` 的 `I2C_MODE_MASTER` 已内置互斥)。 # 六、总结 在 ESP32 双核环境下,FreeRTOS 的互斥锁优先级继承机制可能失效,导致优先级反转。通过事件组结合临界区实现自定义总线锁,并配合超时机制,可有效避免高优先级任务被低优先级任务长时间阻塞。此方案不仅适用于 I2C,也可推广至 SPI、UART 等共享总线的多任务管理。