ESP32 双核下 FreeRTOS 任务优先级反转导致 WiFi 断连的实战分析

背景与问题现象

在 ESP32 开发中,我们常使用 FreeRTOS 管理多任务。某项目使用 ESP32-WROOM-32,双核运行,WiFi 作为 STA 连接路由器。系统中有三个任务:

  • WiFi 管理任务(优先级 5,负责处理 WiFi 事件和 TCP/IP 协议栈)
  • 传感器采集任务(优先级 3,周期性读取 I2C 传感器)
  • 日志打印任务(优先级 2,通过 UART 输出日志)

运行一段时间后,WiFi 频繁断连,重连后再次断连,且伴随看门狗超时。通过串口日志发现,断连前传感器任务和日志任务执行时间异常增长,而 WiFi 任务似乎被阻塞。

优先级反转原理

FreeRTOS 基于优先级抢占式调度。正常情况下,高优先级任务(WiFi)应优先执行。但优先级反转发生在:

  • 高优先级任务等待一个被低优先级任务占用的资源(如互斥锁、信号量)
  • 低优先级任务被中优先级任务抢占,导致高优先级任务无限期等待

在 ESP32 双核上,问题更复杂:两个核独立调度,但共享资源(如 WiFi 驱动内部互斥锁、SPI 总线)需要跨核同步。

案例剖析

资源竞争点

传感器任务通过 I2C 读取数据,而 WiFi 任务在 TCP/IP 协议栈中可能使用 I2C 或 SPI 与外部芯片通信(本例中 WiFi 任务使用 SPI 与射频前端通信)。但关键资源是FreeRTOS 互斥锁

  • 传感器任务在读取 I2C 时,获取了一个全局互斥锁 i2c_mutex 保护总线
  • 日志任务在打印时,也获取了 uart_mutex 保护 UART

反转发生过程

  1. 传感器任务获取 i2c_mutex,但 I2C 设备响应慢,任务阻塞等待
  2. 日志任务(优先级 2)抢占传感器任务(优先级 3)?不,优先级 3 高于 2,所以不会。但若传感器任务在等待 I2C 时主动让出 CPU(vTaskDelay),日志任务得以运行
  3. 日志任务获取 uart_mutex,而 WiFi 任务此时需要获取 uart_mutex 来打印调试信息?实际上 WiFi 任务不打印,但 WiFi 任务需要获取 i2c_mutex 来配置射频芯片(假设)
  4. 此时 WiFi 任务(优先级 5)等待 i2c_mutex,而 i2c_mutex 被传感器任务(优先级 3)持有,传感器任务又在等待 I2C 硬件完成,同时日志任务(优先级 2)不断运行,但不会释放 i2c_mutex
  5. 由于日志任务优先级低于传感器,但高于?不,日志任务优先级 2 低于传感器 3,但传感器在等待 I2C 时被阻塞,日志任务得以运行,且日志任务可能长时间占用 CPU(如打印大量日志),导致传感器任务无法继续,进而无法释放 i2c_mutex
  6. WiFi 任务被饿死,无法处理网络事件,最终 WiFi 断连

双核影响

在双核上,传感器任务可能运行在核 0,日志任务运行在核 1,但互斥锁是全局的。若日志任务在核 1 上持续运行,而传感器任务在核 0 上等待 I2C 中断,但中断处理可能被其他任务延迟,导致传感器任务无法及时完成,锁持有时间过长。

检测方法

1. 使用 FreeRTOS 内核调试功能

  • 启用 configUSE_TRACE_FACILITYconfigUSE_STATS_FORMATTING_FUNCTIONS
  • 调用 vTaskList()vTaskGetRunTimeStats() 查看任务状态和 CPU 使用率
  • 观察 WiFi 任务是否长期处于 Blocked 状态

2. 添加钩子函数

  • 在互斥锁获取时记录时间戳,若等待时间超过阈值则打印警告
void vApplicationMutexTakeHook( SemaphoreHandle_t mutex, TickType_t block_time ) {
    // 记录当前任务和等待时间
}

3. 使用逻辑分析仪或 GPIO 翻转

  • 在关键任务切换时翻转 GPIO,观察波形

解决方案

方案一:优先级继承

FreeRTOS 互斥锁(xSemaphoreCreateMutex)默认支持优先级继承。但需确保使用互斥锁而非二值信号量。本例中,传感器任务应使用互斥锁,且优先级继承会临时将传感器任务优先级提升至 WiFi 任务级别,从而避免被日志任务抢占。

SemaphoreHandle_t i2c_mutex = xSemaphoreCreateMutex();
// 获取时使用 xSemaphoreTake(i2c_mutex, portMAX_DELAY);

方案二:调整任务优先级

  • 将日志任务优先级降低,或使用空闲钩子打印日志
  • 将 WiFi 任务优先级设为最高,并确保其不等待低优先级任务持有的锁

方案三:使用任务通知或队列代替互斥锁

  • 对于 I2C 访问,使用队列将请求发送给专用 I2C 任务,避免锁竞争

方案四:双核亲和性设置

  • 将 WiFi 任务固定到核 0,传感器和日志任务固定到核 1,减少跨核锁竞争
xTaskCreatePinnedToCore(wifi_task, "wifi", 4096, NULL, 5, &wifi_handle, 0);

完整代码示例

以下是一个简化的演示代码,展示如何正确使用互斥锁和优先级继承:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"

SemaphoreHandle_t i2c_mutex;

void sensor_task(void *arg) {
    while (1) {
        // 获取互斥锁,支持优先级继承
        if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
            // 模拟 I2C 读取
            vTaskDelay(pdMS_TO_TICKS(50));
            xSemaphoreGive(i2c_mutex);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void wifi_task(void *arg) {
    while (1) {
        // 需要访问 I2C 配置射频
        if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
            // 配置射频
            xSemaphoreGive(i2c_mutex);
        } else {
            ESP_LOGE("WIFI", "Failed to take mutex");
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void app_main() {
    i2c_mutex = xSemaphoreCreateMutex();
    xTaskCreatePinnedToCore(sensor_task, "sensor", 2048, NULL, 3, NULL, 1);
    xTaskCreatePinnedToCore(wifi_task, "wifi", 4096, NULL, 5, NULL, 0);
}

注意事项

  • 使用互斥锁时,确保所有访问共享资源的任务都使用同一个锁,且获取后及时释放
  • 避免在中断服务函数中获取互斥锁,应使用信号量或队列
  • 在 ESP32 双核上,注意 xTaskCreatePinnedToCore 的使用,合理分配核资源
  • 启用 FreeRTOS 的优先级继承特性(默认开启),但需注意继承可能导致高优先级任务被低优先级任务短暂阻塞,这是正常现象
  • 若使用二值信号量,则不会继承优先级,需改用互斥锁

总结

优先级反转是实时系统中的经典问题,在 ESP32 双核环境下更容易被忽视。通过合理使用互斥锁、调整任务优先级和核亲和性,可以有效避免 WiFi 断连等异常。建议在开发初期就设计好资源访问模型,并利用 FreeRTOS 调试工具持续监控任务状态。