为什么需要双核任务分配?

ESP32-S3 内置两个 Xtensa LX7 核心,主频高达 240 MHz。默认情况下,Arduino 或 ESP-IDF 的 app_main 运行在 Core 0,而 Wi-Fi/蓝牙协议栈也默认运行在 Core 0。当 Wi-Fi 进行扫描、连接或大量数据收发时,协议栈任务会频繁抢占 CPU,导致运行在同一核心上的实时控制任务(如电机 PID、传感器采样)出现毫秒级抖动,严重时甚至丢步。

解决思路很直接:把 Wi-Fi 协议栈固定在 Core 0,把实时控制任务固定在 Core 1,让两个核心各司其职,互不干扰。

FreeRTOS 双核调度与 xTaskCreatePinnedToCore 原理

ESP32-S3 的 FreeRTOS 支持对称多处理(SMP)。每个任务都有一个“核心亲和性”(core affinity)属性:

  • tskNO_AFFINITY:任务可在任意核心运行,由调度器动态分配。
  • 0 或 1:任务只能运行在指定核心。

xTaskCreatePinnedToCore 就是用来创建“绑定核心”的任务:

BaseType_t xTaskCreatePinnedToCore(
    TaskFunction_t pvTaskCode,   // 任务函数
    const char *pcName,          // 任务名
    uint32_t usStackDepth,       // 栈大小(字,非字节)
    void *pvParameters,          // 参数
    UBaseType_t uxPriority,      // 优先级
    TaskHandle_t *pxCreatedTask, // 任务句柄
    BaseType_t xCoreID           // 核心ID:0 或 1
);

关键点:

  • 栈大小单位是 字(word),ESP32-S3 上 1 字 = 4 字节。例如 4096 表示 16 KB。
  • 优先级数值越大优先级越高。Wi-Fi 协议栈任务优先级通常为 23(configMAX_PRIORITIES - 1),用户任务不要设得比它高,否则会饿死协议栈。
  • 中断默认分配到 Core 0,可通过 esp_intr_alloc 的 flags 调整,但一般不需要动。

配置步骤:把 Wi-Fi 和实时控制分到两个核

步骤 1:确认 Wi-Fi 协议栈所在核心

在 ESP-IDF 中,Wi-Fi 任务默认固定在 Core 0。可以用以下代码打印当前任务核心:

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

void print_core(void) {
    printf("当前任务运行在核心:%d\n", xPortGetCoreID());
}

步骤 2:创建实时控制任务并绑定到 Core 1

假设我们要做一个 1 kHz 的电机控制循环,必须严格周期执行。

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

#define CONTROL_PERIOD_US 1000  // 1 kHz

void realtime_control_task(void *arg) {
    TickType_t last_wake = xTaskGetTickCount();
    const TickType_t period_ticks = pdMS_TO_TICKS(1);

    while (1) {
        int64_t t0 = esp_timer_get_time();

        // 1. 读取传感器(如编码器、IMU)
        // 2. 执行 PID 计算
        // 3. 更新 PWM 输出
        // 此处用空循环模拟
        volatile int dummy = 0;
        for (int i = 0; i < 100; i++) dummy += i;

        int64_t t1 = esp_timer_get_time();
        int64_t elapsed = t1 - t0;
        if (elapsed > CONTROL_PERIOD_US) {
            printf("控制任务超时:%lld us\n", elapsed);
        }

        vTaskDelayUntil(&last_wake, period_ticks);
    }
}

void app_main(void) {
    // 绑定到 Core 1,优先级 5,栈 4096 字(16 KB)
    xTaskCreatePinnedToCore(
        realtime_control_task,
        "realtime_ctrl",
        4096,
        NULL,
        5,
        NULL,
        1  // Core 1
    );
}

步骤 3:确保 Wi-Fi 协议栈留在 Core 0

Wi-Fi 初始化后,协议栈任务自动运行在 Core 0。我们只需要不要把实时任务放到 Core 0 即可。如果使用 Arduino 框架,loop() 默认在 Core 1,setup() 在 Core 1,而 Wi-Fi 在 Core 0,天然分离;但 loop() 中若调用 delay() 或阻塞操作,仍会影响同核其他任务。

在 ESP-IDF 中,可以显式创建 Wi-Fi 事件处理任务并绑定到 Core 0:

void wifi_event_task(void *arg) {
    // 处理 Wi-Fi 事件、MQTT 等
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void app_main(void) {
    // Wi-Fi 协议栈任务已在 Core 0,这里再创建一个应用层网络任务也放 Core 0
    xTaskCreatePinnedToCore(
        wifi_event_task,
        "wifi_app",
        4096,
        NULL,
        4,
        NULL,
        0  // Core 0
    );

    // 实时控制任务放 Core 1
    xTaskCreatePinnedToCore(
        realtime_control_task,
        "realtime_ctrl",
        4096,
        NULL,
        5,
        NULL,
        1
    );
}

完整示例:双核解耦的 Wi-Fi + 电机控制

以下代码基于 ESP-IDF,展示一个完整的最小系统:Core 0 跑 Wi-Fi 连接与数据上报,Core 1 跑 1 kHz 电机控制。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "nvs_flash.h"

// ---------- Core 0:Wi-Fi 事件处理 ----------
static void wifi_event_handler(void *arg, esp_event_base_t base,
                               int32_t id, void *data) {
    if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
        esp_wifi_connect();
    } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
        printf("[Core %d] 获得 IP 地址\n", xPortGetCoreID());
    }
}

void wifi_task(void *arg) {
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES) {
        nvs_flash_erase();
        nvs_flash_init();
    }

    esp_netif_init();
    esp_event_loop_create_default();
    esp_netif_create_default_wifi_sta();

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    esp_wifi_init(&cfg);

    esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
                               &wifi_event_handler, NULL);
    esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
                               &wifi_event_handler, NULL);

    wifi_config_t wifi_config = {
        .sta = {
            .ssid = "YourSSID",
            .password = "YourPassword",
        },
    };
    esp_wifi_set_mode(WIFI_MODE_STA);
    esp_wifi_set_config(WIFI_IF_STA, &wifi_config);
    esp_wifi_start();

    while (1) {
        // 模拟周期性上报数据
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

// ---------- Core 1:实时控制 ----------
void control_task(void *arg) {
    TickType_t last_wake = xTaskGetTickCount();
    const TickType_t period = pdMS_TO_TICKS(1);

    while (1) {
        int64_t t0 = esp_timer_get_time();

        // 模拟控制计算
        volatile float setpoint = 100.0f;
        volatile float feedback = 99.5f;
        volatile float error = setpoint - feedback;
        volatile float output = error * 0.5f;

        int64_t t1 = esp_timer_get_time();
        if ((t1 - t0) > 1000) {
            printf("[Core %d] 控制超时 %lld us\n", xPortGetCoreID(), t1 - t0);
        }

        vTaskDelayUntil(&last_wake, period);
    }
}

void app_main(void) {
    // Wi-Fi 任务绑定 Core 0
    xTaskCreatePinnedToCore(wifi_task, "wifi_task", 8192, NULL, 4, NULL, 0);

    // 实时控制任务绑定 Core 1
    xTaskCreatePinnedToCore(control_task, "control_task", 4096, NULL, 5, NULL, 1);
}

注意事项与常见坑

  • 栈大小单位是字,不是字节。ESP32-S3 上 1 字 = 4 字节,写 2048 实际是 8 KB。栈溢出会触发 Stack canary watchpoint 复位。
  • 优先级不要超过 Wi-Fi 任务。Wi-Fi 协议栈任务优先级通常为 23,用户任务建议 1~10。实时控制任务可设为 5~10,但不要高于 23。
  • Core 1 也可能被中断打断。ESP32-S3 的中断默认分配到 Core 0,但某些外设中断可配置到 Core 1。若控制任务对抖动极度敏感,需检查 esp_intr_alloc 的分配。
  • 共享资源要加锁。两个核心访问同一全局变量或外设时,必须使用互斥锁(xSemaphoreCreateMutex)或原子操作,否则会出现数据竞争。
  • vTaskDelayUntil 比 vTaskDelay 更适合周期任务。前者能补偿任务执行时间,保持严格周期。
  • Arduino 框架下注意 loop() 所在核心。Arduino-ESP32 的 loop() 默认在 Core 1,setup() 也在 Core 1,Wi-Fi 在 Core 0,天然分离,但不要在 loop() 中写阻塞代码。
  • 调试时用 xPortGetCoreID() 确认任务核心。这是最直接的验证手段。

总结

通过 xTaskCreatePinnedToCore 将 Wi-Fi 协议栈固定在 Core 0、实时控制任务固定在 Core 1,可以显著降低网络活动对控制循环的干扰。核心要点:正确设置核心 ID、合理分配优先级、注意栈大小单位、保护共享资源。掌握这套方法后,ESP32-S3 的双核性能才能真正为实时嵌入式应用服务。