为什么需要双核任务分配?
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 的双核性能才能真正为实时嵌入式应用服务。