# 引言 ESP32 采用双核 Xtensa LX6 处理器,支持对称多处理(SMP)FreeRTOS。Arduino 核心库将 `loop()` 函数包装为 `loopTask`,运行在 Core 1 上,优先级为 1(`tskIDLE_PRIORITY + 1`)。当开发者创建自定义 FreeRTOS 任务时,默认优先级为 1,且未指定核心,系统会将其调度到任意空闲核心。这种默认配置极易引发优先级抢占问题,导致任务执行顺序混乱、数据竞争甚至系统卡死。 # 一、核心概念与默认行为 ## 1.1 双核调度机制 ESP32 的 FreeRTOS 是 SMP 版本,每个核心独立运行调度器,但共享就绪队列。任务可以绑定到特定核心(`xCoreID` 参数),也可以不绑定(`tskNO_AFFINITY`)。调度器根据优先级和核心空闲状态选择任务运行。 ## 1.2 Arduino loopTask 的默认配置 在 `main.cpp` 中,`loopTask` 创建如下: ```c xTaskCreatePinnedToCore(loopTask, "loopTask", 8192, NULL, 1, &loopTaskHandle, 1); ``` - 优先级:1(最低,仅次于空闲任务) - 核心:Core 1 - 栈大小:8KB ## 1.3 自定义任务的默认行为 使用 `xTaskCreate` 创建任务时,若不指定核心,则 `xCoreID = tskNO_AFFINITY`,优先级默认为 1。此时任务可能被调度到 Core 0 或 Core 1,且与 `loopTask` 同优先级,调度器采用时间片轮转。 # 二、优先级抢占实测 ## 2.1 测试场景设计 我们设计三个任务: - `loopTask`:Arduino 自带,在 `loop()` 中打印当前核心和 tick 计数 - `taskHigh`:优先级 2,绑定 Core 0,打印执行次数 - `taskLow`:优先级 1,不绑定核心,打印执行次数 代码示例如下: ```c #include void taskHigh(void *param) { while (1) { Serial.printf("High on core %d, tick %d\n", xPortGetCoreID(), xTaskGetTickCount()); vTaskDelay(pdMS_TO_TICKS(100)); } } void taskLow(void *param) { while (1) { Serial.printf("Low on core %d, tick %d\n", xPortGetCoreID(), xTaskGetTickCount()); vTaskDelay(pdMS_TO_TICKS(100)); } } void setup() { Serial.begin(115200); xTaskCreatePinnedToCore(taskHigh, "high", 2048, NULL, 2, NULL, 0); xTaskCreate(taskLow, "low", 2048, NULL, 1, NULL); } void loop() { Serial.printf("Loop on core %d, tick %d\n", xPortGetCoreID(), xTaskGetTickCount()); vTaskDelay(pdMS_TO_TICKS(100)); } ``` ## 2.2 实测结果与分析 运行程序,串口输出(节选): ``` Loop on core 1, tick 12345 High on core 0, tick 12345 Low on core 1, tick 12346 Loop on core 1, tick 12346 High on core 0, tick 12346 ... ``` 观察发现: - `taskHigh` 始终运行在 Core 0,且优先于其他任务,因为其优先级最高。 - `loopTask` 和 `taskLow` 都在 Core 1 上运行,且由于优先级相同(均为 1),它们按时间片轮转,每个时间片约 10ms(`configTICK_RATE_HZ` 默认 100Hz)。 - 若 `taskLow` 不绑定核心,它可能被调度到 Core 0,但 Core 0 被 `taskHigh` 占用,导致 `taskLow` 饿死。实测中,`taskLow` 几乎不执行,因为 Core 0 的 `taskHigh` 优先级更高,且 `taskLow` 未被绑定,调度器优先将高优先级任务放在空闲核心,低优先级任务只能等待。 ## 2.3 优先级反转与饿死 当 `taskLow` 与 `loopTask` 同优先级时,若 `taskLow` 被调度到 Core 1,则两者轮转,但 `loopTask` 可能因 `delay` 而让出 CPU,导致 `taskLow` 获得更多执行时间。若 `taskLow` 优先级高于 `loopTask`(例如设为 2),则 `loopTask` 会被抢占,导致 `loop()` 执行频率降低,甚至卡死。 # 三、避坑指南与最佳实践 ## 3.1 明确指定任务核心 创建任务时,使用 `xTaskCreatePinnedToCore` 并指定核心,避免调度器随机分配导致的不确定性。例如,将耗时任务绑定到 Core 0,将 `loopTask` 保留在 Core 1。 ```c xTaskCreatePinnedToCore(taskHigh, "high", 2048, NULL, 2, NULL, 0); // Core 0 ``` ## 3.2 合理设置优先级 - 避免与 `loopTask` 同优先级,除非需要时间片轮转。 - 高优先级任务应使用 `vTaskDelay` 或 `vTaskDelayUntil` 主动让出 CPU,防止饿死低优先级任务。 - 使用互斥量(`SemaphoreHandle_t`)保护共享资源,防止数据竞争。 ## 3.3 使用任务通知替代轮询 对于事件驱动型任务,使用任务通知(`xTaskNotifyGive`/`ulTaskNotifyTake`)替代轮询,减少 CPU 占用。 ## 3.4 监控任务状态 使用 `vTaskList` 或 `uxTaskGetStackHighWaterMark` 检查任务栈使用和状态,避免栈溢出。 # 四、完整示例代码 以下是一个安全的双核任务示例,包含互斥锁和优先级管理: ```c #include SemaphoreHandle_t mutex; void taskA(void *param) { while (1) { if (xSemaphoreTake(mutex, portMAX_DELAY)) { Serial.printf("Task A on core %d\n", xPortGetCoreID()); xSemaphoreGive(mutex); } vTaskDelay(pdMS_TO_TICKS(200)); } } void taskB(void *param) { while (1) { if (xSemaphoreTake(mutex, portMAX_DELAY)) { Serial.printf("Task B on core %d\n", xPortGetCoreID()); xSemaphoreGive(mutex); } vTaskDelay(pdMS_TO_TICKS(300)); } } void setup() { Serial.begin(115200); mutex = xSemaphoreCreateMutex(); xTaskCreatePinnedToCore(taskA, "A", 2048, NULL, 2, NULL, 0); xTaskCreatePinnedToCore(taskB, "B", 2048, NULL, 1, NULL, 1); } void loop() { // 主循环仅处理轻量级任务 vTaskDelay(pdMS_TO_TICKS(1000)); } ``` # 五、注意事项 - **栈大小**:任务栈默认 2048 字节,若使用 `printf` 或复杂逻辑,可能溢出,建议增大到 4096 或以上。 - **优先级数值**:FreeRTOS 中数值越大优先级越高,0 为最低(空闲任务)。 - **核心绑定**:Core 0 通常用于 Wi-Fi/蓝牙协议栈,若任务与无线通信无关,建议绑定 Core 1 或使用 `tskNO_AFFINITY`,但需注意竞争。 - **调试**:使用 `vTaskGetRunTimeStats` 查看各任务 CPU 占用率,辅助优化。 # 结语 ESP32 双核环境下的 FreeRTOS 调度既灵活又复杂,理解 `loopTask` 的默认配置和优先级抢占规则,是避免并发问题的关键。通过明确核心绑定、合理设置优先级和使用同步机制,可以充分发挥双核性能,同时保证系统稳定。希望本文的实测与避坑指南能帮助你在项目中少走弯路。