接手 ESP32 项目后,我先做的一件事就是把运行日志里的核心编号打印出来。为什么?因为这颗芯片标称双核,但如果你不主动调度,很多任务会挤在同一颗核心上排队,另一颗核心空转,性能和功耗全浪费了。这篇文章想聊的就是 ESP32 双核 CPU 的调用:怎么把任务绑定到指定核心、怎么在核间安全地传数据、以及我调试双核任务时踩过的一些实打实的坑。适合已经会点灯、会串口打印,但想把 ESP32 性能进一步榨干的开发者。
1. 双核架构的基本盘:先搞清楚你的代码默认在哪颗核心上跑
1.1 双核对应用来说意味着什么:从“单行道”到“双车道”
ESP32 内部有两个 Xtensa 处理器核心,公共的叫法一般是 Core 0 和 Core 1。它们可以同时执行不同指令,共享同一片内存和大部分外设。这跟 PC 上的多核 CPU 思路类似,但在嵌入式场景里,很多初学者根本没意识到自己在用一个双核系统,代码经常全部挤在默认核心上,另一个核心只是在跑空闲任务。
两颗核心同时工作的好处很直接:高频数据采集和复杂的显示刷新可以并行,不用担心互相拖后腿;网络协议栈和用户逻辑也能分开,不至于在 Wi-Fi 收包高峰期让主逻辑卡顿。但代价也很明显:两个核访问同一个变量、同一个外设、同一个队列时,需要通信和同步机制,否则就会时不时出现“灵异崩溃”。
在双核环境下,一个任务会处于“就绪-运行-阻塞-挂起”状态,调度器会决定它跑到哪个核心上。系统默认使用对称多处理调度,也就是任务不主动绑核时,调度器会优先选择任务核心亲和性设置允许的任意核心。这就带来一个日常现象:同一个任务这次在 Core 0 上执行,下次可能出现在 Core 1 上。对大部分逻辑来说这没问题,但对实时控制类、硬件外设初始化类任务,核心漂移可能存在隐患。
1.2 默认任务分配:别让另一颗核心继续摸鱼
不同开发环境下,默认的核心安排不太一样。常见的入门开发框架(也就是很多教程里的 setup/loop 结构)里,应用程序主循环默认跑在 Core 1,而 Wi-Fi、蓝牙协议栈任务默认放在 Core 0。你在主循环里写的传感器读取、逻辑控制,本质上是“单核程序”,Core 0 被协议栈占用,剩下的空闲时间就空转了。
如果使用芯片厂商的官方物联网开发框架(IDF),app_main函数默认运行在 Core 0,新建任务不指定核心时则由调度器自由安排。所以同样一段代码,在不同框架下打印出的当前核心 ID 可能完全不同。这就是为什么我建议每个项目开机先打印一次xPortGetCoreID(),把“现在这个代码到底在哪个核上跑”这件事变成可观测的。
你真正要做的事,是让两颗核心按你的意图分工。常见的设计是:Core 0 跑 Wi-Fi/BLE 协议栈、低频辅助任务;Core 1 跑用户主业务。但这只是一个参考,不是唯一答案。我见过有人把音频采集放 Core 0、UI 渲染放 Core 1,也见过把高优先级控制任务单独放一个核来保证响应确定性的。理解默认行为,才能谈得上“调用双核”。
2. 核心分配:如何把任务放到指定 CPU 上执行
2.1 用xTaskCreatePinnedToCore创建核心亲和性任务
FreeRTOS 原生 APIxTaskCreate创建任务时不指定核心,调度器自己分配。想在 ESP32 上精确绑定,就得用扩展接口xTaskCreatePinnedToCore。原型大概是:
BaseType_t xTaskCreatePinnedToCore( TaskFunction_t pxTaskCode, const char *pcName, configSTACK_DEPTH_TYPE usStackDepth, void *pvParameters, UBaseType_t uxPriority, TaskHandle_t *pxCreatedTask, const BaseType_t xCoreID );和普通创建函数相比,区别主要在最后一个参数xCoreID:填 0 表示绑定 Core 0,填 1 表示绑定 Core 1,还可以填tskNO_AFFINITY让调度器自由调度。日常使用中,建议显式指定核心,因为“自由调度”在双核下的行为不容易预测,排查问题时要多绕一步。
参数pvParameters是可以传给任务函数的指针,我经常用它把队列句柄传进去,这样任务函数不用依赖全局变量。pxCreatedTask可以填 NULL,如果在控制逻辑里需要挂起或删除任务,就需要保留句柄。栈深度给多少的问题我后面会单独讲,这里先提醒一句:不要凭感觉给一个很大的数字,尤其别在任务里定义大数组时还算不好栈空间。
绑定核心不是越高越好。Core 0 通常承担中断处理和协议栈回调,如果你把一个重量级任务也塞到 Core 0,中断响应就可能被拖长。Core 1 在默认框架下是主用户逻辑的“主战场”,底层的协议栈回调一般不跑在这边。做绑核之前,先画出任务和资源依赖图,再决定谁跟谁待在一起。
2.2 栈大小、优先级和核心绑定的实际取舍
栈太小,任务一深挖就溢出,系统表现可能是随机重启或打印Stack canary watchpoint triggered。我之前遇到过:任务里用了一个 512 字节的局部缓冲区,栈深度只给了 1024 字节,跑十几分钟后崩溃。排查根因时,用uxTaskGetStackHighWaterMark查剩余栈空间,才发现峰值栈只剩不到 100 字节。从那以后,新任务我至少预留 30% 余量。
优先级的选择和单核没什么大区别:高优先级任务优先执行,同优先级任务按时间片轮转。但双核叠加绑核后会出现一个有意思的现象:如果你把两个同优先级任务分别绑到两个核,它们可以真正并行跑;如果绑到同一个核,就是轮流跑。优先级还可以用来保证实时性,比如把 1kHz 控制任务优先级提到最高,它就能抢占其他任务。
一个常见误区是把所有任务优先级都调高。在一个核心上,高优先级任务如果不阻塞,低优先级任务永远得不到执行,看门狗甚至可能触发复位。合理的做法是:绑到不同核心的任务,优先级可以根据各自实时性独立设计;绑到同一核心的任务,一定要保证每一级都有机会运行或让渡 CPU。
| 核心选择 | 推荐场景 | 不推荐场景 |
|---|---|---|
| Core 0 | Wi-Fi/BLE 协议栈、中断回调、低功耗管理 | 重计算任务、长时间占用 CPU 的任务阻塞协议栈 |
| Core 1 | 用户主逻辑、UI 刷新、传感器采集 | 高频硬件中断直接处理 |
| tskNO_AFFINITY | 低优先级辅助任务、日志输出 | 对实时性和确定性要求高的任务 |
表格只是经验参考,不是硬性规定。实际项目里还是要用数据说话:先跑起来,观察每颗核心的 CPU 占用率,再调整绑核方案。
3. 核间通信与资源共享:双核协作的关键
3.1 队列是把数据从 A 核搬到 B 核的保险箱
双核项目最常见的数据流转模式是:Core 0 采集数据,Core 1 处理数据。如果直接定义一个全局变量,Core 0 往里写,Core 1 往外读,两个核可能同时访问同一段内存,轻则读到中间值,重则触发断言或硬件异常。队列就是用来解决的。
FreeRTOS 队列本质上是一个带锁的环形缓冲区,发送方把数据拷进队列,接收方从队列取走数据,内部用互斥机制保护,全程对开发者透明。示例代码里,采集任务用xQueueSend把采集值发给显示任务,显示任务用xQueueReceive阻塞等待数据。即便两个任务跑在不同核心上,也只需要关心数据本身,不用自己写锁。
#include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" #include "esp_log.h" static const char *TAG = "dual_core_demo"; void data_collect_task(void *arg) { QueueHandle_t queue = (QueueHandle_t)arg; uint32_t count = 0; while (1) { int reading = (int)(count & 0xff); xQueueSend(queue, &reading, pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(100)); count++; } } void display_task(void *arg) { QueueHandle_t queue = (QueueHandle_t)arg; int reading = 0; while (1) { if (xQueueReceive(queue, &reading, pdMS_TO_TICKS(200)) == pdTRUE) { ESP_LOGI(TAG, "core=%d got reading=%d", xPortGetCoreID(), reading); } } } void app_main(void) { QueueHandle_t queue = xQueueCreate(10, sizeof(int)); if (queue == NULL) { ESP_LOGE(TAG, "create queue failed"); return; } xTaskCreatePinnedToCore(data_collect_task, "collector", 2048, queue, 5, NULL, 0); xTaskCreatePinnedToCore(display_task, "display", 2048, queue, 5, NULL, 1); }有人会觉得,队列每次传数据都涉及拷贝,效率低。如果数据量大,可以用队列传递指针,但指针指向的内存必须保证生命周期和访问安全。更极端的情况是用队列只传状态值,大数据放在 DMA 缓冲区中,这样兼顾速度和安全性。我的原则是:宁可多一次拷贝,也要保证核间数据一致;优化永远是在跑通之后考虑的事。
3.2 互斥锁与临界区:别让两个核同时改一个变量
队列适合一收一发的流水线,但有些场景是多个任务共享一个状态,比如全局配置结构体、校准参数。这种时候要用互斥锁。FreeRTOS 的xSemaphoreCreateMutex创建互斥锁,xSemaphoreTake获取,xSemaphoreGive释放。重点在于:拿锁和放锁必须在同一个任务里,不可在中断里使用普通互斥锁。
对于极短的共享访问,可以用临界区 APIportENTER_CRITICAL和portEXIT_CRITICAL,它会把当前核心的中断屏蔽掉,保证一段代码原子执行。要注意的是,在双核芯片上,还有另一个版本的临界区需要指定自旋锁,用来防止另一颗核心同时进入临界区。用不好会让另一个核空等,或者直接触发看门狗。
我踩过的一个典型坑是:写了一个“看起来没问题”的全局标志,Core 0 置位,Core 1 查询,没有加锁也没有用原子操作。大多数时候正常,但偶尔 Core 1 读到的是更新一半的值,直接导致后续逻辑跳错。后来改成标准库的原子操作或者临界区保护,问题消失。凡是跨核共享的变量,一定要认真对待原子性,不要赌运气。
3.3 双核协作实例:一边采集数据,一边处理显示
上面代码已经是一个最小协作样例:Core 0 的采集任务每 100ms 生成一个读数放进队列,Core 1 的显示任务持续阻塞在队列上,一有数据就打印。两个任务之间唯一交集就是队列,逻辑边界非常干净。
如果你想把这个模式扩展成真实项目,可以考虑这样拆分:Core 0 负责定时从传感器 DMA 读取数据,并做简单的滤波;Core 1 负责把数据换算成显示帧,通过 SPI 写屏幕。中间队列的长度要按最坏情况计算,避免数据突发时丢包。比如 100Hz 采集、每包 4 字节,显示端处理偶尔慢到 200ms,那队列至少能缓存 20 包以上,加上一定余量,用 32 长度队列比较稳妥。
这类架构的好处不只是性能,而是调试体验。你可以在队列两端分别加计数,观察两个核心的处理速率,一旦某端速度跟不上,通过计数器立刻就能定位是哪一侧的瓶颈。
4. 实操:一个双核任务调度的完整 Demo 开发记录
4.1 需求分析和任务分配表
假设有个小型设备:一组按键输入,一组 LED 状态显示,还需要把状态变化通过日志输出。按单核思路写就是轮询按键、刷 LED、打印日志,全部串行。双核方案可以把按键扫描和 LED 刷新分开,减少相互干扰。
任务分配我做得比较保守:
| 任务名 | 绑定核心 | 优先级 | 栈深度 | 职责 |
|---|---|---|---|---|
| button_task | Core 0 | 3 | 2048 | 扫描按键、消抖、产生事件 |
| led_task | Core 1 | 2 | 2048 | 消费事件、更新 LED 状态 |
| log_task | Core 0 | 1 | 1024 | 定时输出 CPU 状态 |
按钮事件用队列发送,LED 任务接收后执行。两个核心各有一个主要任务,状态数据通过队列传递,没有共享全局变量。log_task 只在调试阶段使用,优先级最低,不会影响主任务时序。
4.2 编写代码与逐步绑定
这个 Demo 的核心代码和前面队列示例相当接近,但加工了事件结构:
typedef struct { uint8_t key_id; uint8_t pressed; } button_event_t; void button_task(void *arg) { QueueHandle_t queue = (QueueHandle_t)arg; button_event_t evt = {0}; while (1) { evt.key_id = 1; evt.pressed = 1; xQueueSend(queue, &evt, pdMS_TO_TICKS(20)); vTaskDelay(pdMS_TO_TICKS(10)); } } void led_task(void *arg) { QueueHandle_t queue = (QueueHandle_t)arg; button_event_t evt; while (1) { if (xQueueReceive(queue, &evt, pdMS_TO_TICKS(100)) == pdTRUE) { printf("LED handle key=%d pressed=%d on core %d\n", evt.key_id, evt.pressed, xPortGetCoreID()); } } }实际项目里,按键和 LED 之间通常有协议逻辑,比如长按切换模式、单击开关灯。这种逻辑如果放在 LED 任务里,CPU 占用很低,也容易扩展成状态机。绑定核心时,我把按键任务放 Core 0,LED 任务放 Core 1,因为这两者互不依赖,并行度最高。
绑定之后要做一件很容易被忽略的事:检查创建任务的返回值。当内存不足或参数非法时,xTaskCreatePinnedToCore会返回pdFAIL,如果忽略,任务一直不运行,排查起来特别迷惑。我的习惯是每个任务创建后都断言返回值。
4.3 怎么验证任务确实跑在了指定核心上
代码写完不是结束,还要证明它真的跑对了核心。两个常用方法:任务内打印xPortGetCoreID();系统层面用vTaskGetRunTimeStats统计每个任务在每个核心上的运行时间。
xPortGetCoreID()是 ESP32 平台特有的接口,调用时会返回当前正在执行代码的物理核心编号。我在任务主循环里放一次打印,开机几秒内就能确认绑核是否生效。要注意打印本身会占用串口时间,生产环境建议用宏开关关掉。
运行时统计相对复杂,需要在 ESP-IDF 中开启CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS,然后周期性调用vTaskGetRunTimeStats获取表格。它能显示每个任务的 CPU 利用率,结合核心 ID,就能回答“Core 0 上到底谁在消耗 CPU”这类问题。我之前靠它定位过一个隐藏问题:某个任务绑在 Core 1 后,占有率突然从 5% 跳到 40%,原因是它和另一个高优先级任务共同争抢一个外设,互斥等待消耗了大量时间。
4.4 实测结果与性能观察
运行 Demo 后,串口输出大致长这样:
LED handle key=1 pressed=1 on core 1 LED handle key=1 pressed=1 on core 1单看这个输出只能证明 LED 任务在 Core 1,按键任务是否在 Core 0 还得看代码里是否打印。我把按键任务也加了一行核心 ID 日志,结果确认两个任务确实分布在两个核心上。
从性能角度看,这个小 Demo 的瓶颈在printf和串口驱动上,双核并没有直接带来两倍吞吐。但把按键扫描和 LED 控制分开后,按键扫描的最差响应时间不再受 LED 刷新的长阻塞拖累,系统实时性明显更稳。这其实是双核更重要的价值:不是把单线程加速,而是隔离干扰。
5. 双核开发中常见的坑与排查经验
5.1 崩溃、重启、WDT 复位:先怀疑这三件事
双核环境下,问题比单核难查。我整理了一个排查速查表,按出现频率排序:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 随机崩溃、栈溢出提示 | 任务栈过小或局部数组过大 | 用uxTaskGetStackHighWaterMark查看剩余栈 |
| 卡死后看门狗复位 | 临界区嵌套或互斥锁未释放 | 检查所有portENTER_CRITICAL/xSemaphoreTake分支 |
| 任务没有按时执行 | 优先级倒挂或同核高优先级任务占满 CPU | 打印运行状态,调整优先级 |
| 数据偶尔错乱 | 跨核共享变量未同步 | 改用队列/互斥锁/原子操作 |
| 两个核同时访问外设异常 | 外设驱动未支持并发调用 | 同一外设只允许一个核访问,加互斥 |
最让我头疼的是临界区导致的死锁:Core 0 持锁后,在临界区里调用了一个会阻塞的 API,此时 Core 1 也在等同一把锁,两边谁也不让,看门狗就响了。经验是:临界区代码必须足够短,绝不调用阻塞函数,最好只做变量读写。
5.2 双核不是性能翻倍那么理想化
很多刚接触双核的朋友会默认“双核 = 两倍速度”,这是最大的误解。实际系统里,两个核共享同一片内存控制器和总线带宽。如果大量任务频繁访问内存,反而会因为总线争抢造成性能下降。
我实测过一个 FFAT 数据写盘任务:把它从单核改成绑到另一个核心后,总耗时几乎没有下降。查了统计才发现,任务本身大部分时间在等待 Flash 擦写,另一个核心也在跑网络协议栈,两者干扰明显。双核优化要先看任务到底是被 CPU 算力限制,还是被等待时间限制。只有 CPU 密集且可并行拆分时,双核收益才最明显。
正确思路是:先测单核版本的各任务 CPU 占用率,把 CPU 密集、无共享依赖、可并行的任务拎出来,再考虑绑到不同核心。不是所有功能都值得拆成双核任务,拆得不好反而增加通信和同步开销。
5.3 我建议的迁移路径:单核先调通,再拆双核
如果你手头已经有一个单核 ESP32 项目,不要直接翻改成双核。我自己的流程是先做“任务图”:把每个功能画成任务,标出它们何时需要交流,哪些数据是共享的。然后,先用队列把全局变量替换掉,保证单核下逻辑也跑得通。这一步很关键,因为队列方式虽然是单核执行,但跨核安全性和单核一样适用。
单核稳定之后,再给耗时任务指定核心:协议栈/中断相关留在 Core 0,主逻辑放 Core 1,辅助任务看情况绑核。每绑一个核,跑一段时间观察统计,确认没有新增异常,再绑下一个。这样一步步迁移,问题出现时能立刻锁定是哪次改动引入的。
最后再分享一个小技巧:把“核心 ID + 任务名”固定打印到开机日志里。双核调试时,很多奇怪问题其实是“我以为它在这个核上,而它实际在另一个核上”。只要日志里先把这个事实固定住,你会少走很多弯路。