一、问题现象与初步分析

某嵌入式项目基于 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 操作:

// 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 访问权改为事件组控制:

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 实现代码

#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 任务修改

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_installI2C_MODE_MASTER 已内置互斥)。

六、总结

在 ESP32 双核环境下,FreeRTOS 的互斥锁优先级继承机制可能失效,导致优先级反转。通过事件组结合临界区实现自定义总线锁,并配合超时机制,可有效避免高优先级任务被低优先级任务长时间阻塞。此方案不仅适用于 I2C,也可推广至 SPI、UART 等共享总线的多任务管理。