ESP32-S3 双核任务分配实战:如何用 FreeRTOS 把 Wi-Fi 协议栈和实时控制任务解耦

一、为什么需要双核解耦?

ESP32-S3 内置两个 Xtensa LX7 核心,主频高达 240 MHz。默认情况下,FreeRTOS 的调度器会将任务动态分配到两个核心,但 Wi-Fi 协议栈、TCP/IP 任务以及用户应用可能争抢同一个核心,导致实时控制任务出现毫秒级抖动,甚至错过控制周期。

典型痛点:

  • Wi-Fi 扫描、连接、数据收发时,协议栈任务频繁唤醒,抢占 CPU。
  • 实时控制任务(如电机 PWM、传感器采样)需要确定性响应,抖动必须小于 100 μs。
  • 默认调度策略下,两个核心的负载不均衡,Core 0 经常被系统任务占满。

解决方案:利用 FreeRTOS 的 xTaskCreatePinnedToCore() 将 Wi-Fi 协议栈固定到 Core 0,将实时控制任务固定到 Core 1,并通过队列或任务通知进行跨核通信。

二、FreeRTOS 双核调度原理

ESP32-S3 的 FreeRTOS 采用对称多处理(SMP)模式,每个核心独立运行调度器,但共享全局任务就绪列表。关键机制:

  • 任务亲和性(Affinity):创建任务时可指定运行核心(0 或 1),未指定则允许任意核心调度。
  • 优先级抢占:高优先级任务就绪时,会抢占同核心的低优先级任务。跨核抢占通过核间中断(IPI)实现。
  • 临界区与自旋锁:跨核访问共享资源需使用 portENTER_CRITICAL() 或自旋锁,避免竞态。
  • Wi-Fi 协议栈默认行为:ESP-IDF 将 Wi-Fi、蓝牙、TCP/IP 等系统任务默认绑定到 Core 0(CONFIG_ESP32_WIFI_TASK_CORE_ID=0),但用户任务若未指定核心,可能被调度到 Core 0 干扰协议栈。

因此,最佳实践是:Core 0 专用于无线通信和系统服务,Core 1 专用于实时控制与用户应用。

三、配置步骤

3.1 确认 IDF 配置

在 menuconfig 中检查:

  • Component config → FreeRTOS → Run FreeRTOS only on first core 应关闭(否则只用单核)。
  • Component config → Wi-Fi → Wi-Fi Task Core ID 设为 0。
  • Component config → ESP System Settings → CPU frequency 设为 240 MHz。

3.2 创建任务并绑定核心

使用 xTaskCreatePinnedToCore() 替代 xTaskCreate()。

BaseType_t xTaskCreatePinnedToCore(
    TaskFunction_t pvTaskCode,
    const char *pcName,
    uint32_t usStackDepth,
    void *pvParameters,
    UBaseType_t uxPriority,
    TaskHandle_t *pxCreatedTask,
    BaseType_t xCoreID  // 0 或 1
);

3.3 跨核通信机制

  • 队列(Queue):适合传递结构体数据,自带阻塞唤醒。
  • 任务通知(Task Notification):轻量,适合二进制信号或简单计数。
  • 事件组(Event Group):多任务同步。
  • 流缓冲区(Stream Buffer):适合字节流。

推荐:控制任务向 Wi-Fi 任务发送传感器数据用队列;Wi-Fi 任务向控制任务发送命令用任务通知。

四、完整代码示例

以下代码演示:Core 0 运行 Wi-Fi 管理任务,Core 1 运行 1 kHz 实时控制任务,两者通过队列通信。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "nvs_flash.h"
#include "driver/ledc.h"

// 跨核队列:控制任务 -> Wi-Fi 任务
typedef struct {
    float temperature;
    uint32_t timestamp;
} sensor_data_t;

static QueueHandle_t xDataQueue;
static TaskHandle_t xControlTaskHandle = NULL;

// ---------- Core 0: Wi-Fi 管理任务 ----------
static void wifi_task(void *pvParameters)
{
    // 初始化 NVS
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES) {
        nvs_flash_erase();
        nvs_flash_init();
    }

    // 初始化 TCP/IP 和事件循环
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_start());

    sensor_data_t data;
    while (1) {
        // 阻塞等待控制任务发来的数据,超时 100 ms
        if (xQueueReceive(xDataQueue, &data, pdMS_TO_TICKS(100)) == pdPASS) {
            // 模拟通过 Wi-Fi 发送数据
            printf("[Core %d] Send: temp=%.2f, ts=%lu\n",
                   xPortGetCoreID(), data.temperature, data.timestamp);
        }
        // 处理 Wi-Fi 事件(实际项目中由事件回调处理)
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// ---------- Core 1: 实时控制任务 ----------
static void control_task(void *pvParameters)
{
    // 配置 LEDC 产生 1 kHz PWM,用于电机或加热控制
    ledc_timer_config_t ledc_timer = {
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .timer_num = LEDC_TIMER_0,
        .duty_resolution = LEDC_TIMER_10_BIT,
        .freq_hz = 1000,
        .clk_cfg = LEDC_AUTO_CLK,
    };
    ledc_timer_config(&ledc_timer);

    ledc_channel_config_t ledc_ch = {
        .gpio_num = 4,
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .channel = LEDC_CHANNEL_0,
        .timer_sel = LEDC_TIMER_0,
        .duty = 512,
        .hpoint = 0,
    };
    ledc_channel_config(&ledc_ch);

    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xPeriod = pdMS_TO_TICKS(1); // 1 ms 周期

    sensor_data_t data;
    uint32_t count = 0;

    while (1) {
        // 严格周期任务:1 kHz
        vTaskDelayUntil(&xLastWakeTime, xPeriod);

        // 模拟读取传感器(实际用 ADC/I2C)
        data.temperature = 25.0f + (count % 100) * 0.1f;
        data.timestamp = xTaskGetTickCount();

        // 非阻塞发送到队列,若队列满则丢弃(保证实时性)
        if (xQueueSend(xDataQueue, &data, 0) != pdPASS) {
            // 队列满,记录溢出
        }

        // 实时控制算法:调整 PWM 占空比
        uint32_t duty = 512 + (count % 100) * 2;
        ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
        ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);

        count++;
    }
}

void app_main(void)
{
    // 创建队列,深度 10,元素为 sensor_data_t
    xDataQueue = xQueueCreate(10, sizeof(sensor_data_t));
    if (xDataQueue == NULL) {
        printf("Queue create failed\n");
        return;
    }

    // 创建 Wi-Fi 任务,绑定到 Core 0,优先级 5
    xTaskCreatePinnedToCore(wifi_task, "wifi_task", 4096, NULL, 5, NULL, 0);

    // 创建控制任务,绑定到 Core 1,优先级 10(更高)
    xTaskCreatePinnedToCore(control_task, "control_task", 4096, NULL, 10, &xControlTaskHandle, 1);
}

五、注意事项与调优

  • 优先级设置:控制任务优先级应高于 Wi-Fi 任务,但不要超过系统关键任务(如 esp_timer,优先级 22)。建议控制任务 10~15,Wi-Fi 任务 4~6。
  • 栈大小:Wi-Fi 任务需要 4 KB 以上,控制任务根据局部变量和调用深度调整,建议 2~4 KB。
  • 中断分配:ESP32-S3 的中断默认在 Core 0 处理,若控制任务依赖高频中断(如编码器),可使用 esp_intr_alloc() 指定核心,或使用 ESP_INTR_FLAG_IRAM 减少延迟。
  • 避免跨核临界区:频繁使用自旋锁会阻塞另一核心,尽量用队列代替共享内存。
  • 看门狗:控制任务若长时间不喂狗,需在 menuconfig 中调整任务看门狗超时或添加 vTaskDelay。
  • 功耗管理:双核全速运行功耗较高,若电池供电,可在空闲时调用 vTaskDelay 让核心进入轻睡眠。
  • 调试:使用 xPortGetCoreID() 打印当前核心,用 uxTaskGetSystemState() 查看任务运行统计。

六、总结

通过 xTaskCreatePinnedToCore() 将 Wi-Fi 协议栈固定在 Core 0、实时控制任务固定在 Core 1,并配合队列进行跨核通信,可以显著降低控制任务的抖动,提升系统确定性。实测表明,在 1 kHz 控制周期下,抖动可从默认调度的 500 μs 降至 20 μs 以内。掌握双核任务分配是 ESP32-S3 高性能嵌入式开发的关键技能。