问题背景:为什么 Wi-Fi 会让控制环抖动 8ms?
在一个基于 ESP32-S3 的电机控制项目中,控制环要求 1kHz 稳定执行,但实测延迟抖动高达 8ms。用 esp_timer 打点发现,抖动总是出现在 Wi-Fi 收发数据前后。
根本原因:ESP-IDF 默认将 Wi-Fi 协议栈任务(wifi、bt 等)放在 Core 0,而用户任务默认也可能被调度到 Core 0。当 Wi-Fi 中断、协议栈处理、内存拷贝同时发生时,控制任务被抢占或延迟调度,导致抖动。
ESP32-S3 是双核 Xtensa LX7,Core 0 和 Core 1 各自有独立的中断控制器和调度器。只要把实时任务与 Wi-Fi 协议栈物理隔离到不同核心,并正确设置优先级,就能大幅降低抖动。
原理:双核隔离与 FreeRTOS 调度
-
核心亲和力(Core Affinity):FreeRTOS 的
xTaskCreatePinnedToCore()可以把任务固定到指定核心,避免跨核迁移带来的缓存失效和调度延迟。 - 中断分配:ESP32-S3 的外设中断可以绑定到某个核心。Wi-Fi 中断默认在 Core 0,实时控制用的定时器中断应绑定到 Core 1。
-
优先级设计:实时控制任务优先级应高于 Wi-Fi 任务,但不要高于系统关键任务(如
esp_timer)。 - 缓存与内存:两个核心共享 L2 缓存,但各自有 L1 缓存。跨核访问共享数据要用原子操作或队列,避免伪共享。
配置步骤
1. 确认核心与任务分布
在 menuconfig 中:
-
Component config → FreeRTOS → Run FreeRTOS only on first core不要勾选(保持双核)。 -
Component config → ESP System Settings → CPU frequency设为 240MHz。 -
Component config → Wi-Fi → Wi-Fi Task Core选择 Core 0。
2. 创建绑核任务
控制任务绑到 Core 1,Wi-Fi 相关任务保持 Core 0。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
#include "driver/gptimer.h"
static gptimer_handle_t ctrl_timer = NULL;
static volatile int64_t last_tick = 0;
static volatile int64_t max_jitter_us = 0;
// 实时控制任务,绑到 Core 1
void control_task(void *arg)
{
while (1) {
// 等待定时器通知,实际控制逻辑放这里
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
int64_t now = esp_timer_get_time();
int64_t jitter = now - last_tick - 1000; // 期望 1ms 周期
if (jitter < 0) jitter = -jitter;
if (jitter > max_jitter_us) max_jitter_us = jitter;
last_tick = now;
// 执行控制算法(示例:翻转 GPIO)
gpio_set_level(GPIO_NUM_4, !gpio_get_level(GPIO_NUM_4));
}
}
// 定时器回调,运行在 ISR 上下文,通知控制任务
static bool IRAM_ATTR on_ctrl_timer(gptimer_handle_t timer,
const gptimer_alarm_event_data_t *edata,
void *user_ctx)
{
BaseType_t high_task_wakeup = pdFALSE;
vTaskNotifyGiveFromISR((TaskHandle_t)user_ctx, &high_task_wakeup);
return high_task_wakeup == pdTRUE;
}
void app_main(void)
{
// 1. 创建控制任务,绑到 Core 1,优先级 10
TaskHandle_t ctrl_handle = NULL;
xTaskCreatePinnedToCore(control_task, "ctrl", 4096, NULL, 10,
&ctrl_handle, 1);
// 2. 配置 GPTimer,1kHz,中断绑到 Core 1
gptimer_config_t timer_cfg = {
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
.direction = GPTIMER_COUNT_UP,
.resolution_hz = 1000000, // 1MHz
};
ESP_ERROR_CHECK(gptimer_new_timer(&timer_cfg, &ctrl_timer));
gptimer_alarm_config_t alarm_cfg = {
.alarm_count = 1000, // 1ms
.reload_count = 0,
.flags.auto_reload_on_alarm = true,
};
ESP_ERROR_CHECK(gptimer_set_alarm_action(ctrl_timer, &alarm_cfg));
gptimer_event_callbacks_t cbs = {
.on_alarm = on_ctrl_timer,
};
ESP_ERROR_CHECK(gptimer_register_event_callbacks(ctrl_timer, &cbs, ctrl_handle));
// 关键:把定时器中断绑到 Core 1
ESP_ERROR_CHECK(gptimer_enable(ctrl_timer));
ESP_ERROR_CHECK(esp_intr_alloc(ETS_GPTIMER0_INTR_SOURCE,
ESP_INTR_FLAG_IRAM | ESP_INTR_FLAG_LEVEL3,
NULL, NULL, NULL));
ESP_ERROR_CHECK(gptimer_start(ctrl_timer));
// 3. 初始化 Wi-Fi(默认在 Core 0)
// ... 此处省略 Wi-Fi 初始化代码 ...
}
3. 验证抖动
在控制任务中每 1000 次打印一次 max_jitter_us,调优前后对比:
- 调优前:最大抖动约 8000us
- 调优后:最大抖动约 180~220us
注意事项
- 不要把所有任务都绑到 Core 1:Wi-Fi、蓝牙、TCP/IP 任务留在 Core 0,否则 Core 1 会被协议栈打断。
- 中断优先级:控制定时器中断优先级设为 3 级,高于 Wi-Fi 中断(通常 1 级),但不要用 7 级,避免屏蔽系统节拍。
-
ISR 中不要调用阻塞 API:
vTaskNotifyGiveFromISR是安全的,但printf、malloc不行。 -
共享数据保护:跨核共享变量用
portMUX_TYPE或原子操作,避免编译器重排。 - 栈大小:控制任务栈建议 4KB 以上,若使用浮点运算需额外预留。
-
测量方法:用
esp_timer_get_time()在任务内打点,不要用 GPIO 翻转加示波器测整体延迟,因为 GPIO 翻转本身有开销。 -
Wi-Fi 省电模式:若开启
WIFI_PS_MIN_MODEM,Wi-Fi 唤醒会周期性占用 Core 0,但对 Core 1 影响已隔离。
总结
双核隔离不是简单“绑核”就完事,需要同时处理任务亲和力、中断分配、优先级和共享数据。本次调优把控制环抖动从 8ms 降到 200us 左右,满足 1kHz 控制需求。核心原则:让实时任务独占一个核心,让协议栈和网络任务留在另一个核心,并用中断通知代替轮询。