从开发板包装上写着“双核 ESP32”,再到数据手册上看到两颗 Xtensa LX6,相信不少人和我一样,第一反应都是“这板子性能肯定猛”。真上手写代码之后才发现,默认情况下你用 Arduino 那一套写出来的东西,大部分任务都挤在一个核心上,另一个核心基本在围观。第6篇学习记录,我就把双核调用的底层逻辑、关键 API、完整可跑的例程,以及踩过的几个明显坑一次整理清楚。
1. 双核调用本质上是把任务分开,不是把性能翻倍
1.1 核心0和核心1是两颗对等的计算单元
ESP32 经典款用的是两颗 Xtensa LX6,主频最高能到 240MHz,两核共享同一份内存和外设地址空间。这个概念很关键,意味着你不需要像写多进程程序一样去刻意隔离数据,两边默认就能看到同一个全局变量、同一个外设寄存器。也正因为共享内存,两颗核心不是主从关系,没有“核心0是领导,核心1是下属”的说法,它们都能独立取指、独立执行任务,由 FreeRTOS 的调度器统一安排谁在什么时候运行。
很多人第一次听到“双核”时会觉得“是不是所有代码都能自动分到两个核”,其实不是。代码还是那段代码,只跑一次的话它只会固定占用一个核心的去处;真正能用上双核的前提是,你的程序里有两个以上的独立任务,并且把这些任务合理地拆到两个核心上。用生活里的例子来理解,双核更像厨房里两个灶台,不是让一道菜变快一倍,而是可以同时炖汤和炒菜。
1.2 默认情况下你的代码到底跑在哪个核心
在 Arduino-ESP32 框架里,一般会把主循环任务 loopTask 固定到核心 1,所以如果你只是写一个简单的 LED 闪烁,实际在跑的就是核心 1。而核心 0 上通常挂着协议栈任务,比如 WiFi、蓝牙、LWIP 相关的后台任务,还有两个空闲任务分别跑在各自核心上。也就是说,默认状态下你的用户代码常常集中在核心 1,核心 0 被系统占用一小部分,其余时间在跑 idle 任务。
这就带来一个非常常见的现象:程序写得越满,核心 1 占用量越高,核心 0 却很闲。你打开串口监视器看任务执行时间会发现,占用率两极分化。这时候如果再往上加任务,要么是原有任务延时变大,要么是新的任务根本排不上。解决办法就在标题里:主动把一部分用户任务调用到核心 0,让两个核心都背一些活。
2. 控制任务去哪个核心的 API 与参数
2.1 xTaskCreatePinnedToCore 是主力接口
如果只想指定任务跑在哪个核心,最直接的工具就是 xTaskCreatePinnedToCore。它是 FreeRTOS 在 ESP-IDF 里扩展出来的一个函数,比标准版 xTaskCreate 多一个核心编号参数。
原型大概是这样的:
BaseType_t xTaskCreatePinnedToCore( TaskFunction_t pxTaskCode, // 任务函数 const char *pcName, // 任务名,主要用于调试 uint32_t ulStackDepth, // 栈深度,单位是 word,不是字节 void *pvParameters, // 传给任务的参数 UBaseType_t uxPriority, // 任务优先级 TaskHandle_t *pxCreatedTask, // 任务句柄,可传 NULL const BaseType_t xCoreID // 核心 ID,0 或 1 );几个容易被忽略的细节:
- ulStackDepth 单位是 word,在 ESP32 上一般一个 word 是 4 字节。给它 2048,实际占用约 8KB。
- uxPriority 在双核情况下同样有效,不同优先级之间依然是高优先级先抢到运行权。
- xCoreID 传 0 就是核心 0,传 1 就是核心 1。有的工程里会看到 tskNO_AFFINITY,表示不指定核心,由调度器自己安排。
一开始做实验的时候,很多人喜欢把所有任务都设置成同一个优先级,这样在单核上会出现时间片轮转;到了双核上,如果两个任务恰好被分到不同核心,它们就是真正意义上的并行执行,效果会比时间片轮转更稳定。
2.2 普通版 xTaskCreate 和默认分配策略
标准版 xTaskCreate 也存在于 ESP-IDF 中,它不接收核心参数。在不同的 SDK 版本里,默认行为可能不太一样,有的倾向于放在调用它的核心,有的会交给调度器去选。所以如果你的项目对核心归属有明确要求,就不要赌默认行为,直接用 xTaskCreatePinnedToCore 把核心编号写死。
我见过不少初学者把任务放到了错误的“默认核心”上,然后发现任务怎么总是不执行。其实就是他假设的核心和实际不一致。一个项目里如果有 WiFi、有传感器、有 UI,建议第一步就规划好每个任务的核心归属,而不是等着系统帮你平衡。
2.3 优先级和核心是两套控制维度
双核调度还有一个容易想歪的地方:认为每个核心上各自排一套优先级。实际上 FreeRTOS 在整个系统层面维护一个就绪任务列表,当两个核心都空闲时,调度器会把最高优先级的任务先安排出去。不是说核心 0 上面排核心 0 的优先级、核心 1 上面排核心 1 的优先级,两边会为了抢占 CPU 而起冲突。好在多数情况下任务函数里都会有阻塞,比如 vTaskDelay、xQueueReceive,一旦任务阻塞,核心就可以去跑其他任务。
我在规划任务时通常会先把任务的周期和实时性写出来,再决定优先级分配。比如传感器读取要求误差在几十毫秒内,就给高优先级;日志上报可以晚一点,就给低优先级。低级任务也不会饿死,只要高优先级任务有阻塞,低优先级任务就有机会运行。
3. 快速验证双核调用的完整实验
3.1 先跑一个“核心 ID 自报家门”测试
与其只看文档,不如先做一个能证明任务确实在指定核心上运行的实验。下面这段代码创建两个任务,分别固定到核心 0 和核心 1,每过一秒打印一次当前任务所在的核心 ID:
#include <Arduino.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> void printCoreTask(void *param) { while (1) { Serial.printf("任务 running on core %d\n", xPortGetCoreID()); vTaskDelay(pdMS_TO_TICKS(1000)); } } void setup() { Serial.begin(115200); xTaskCreatePinnedToCore(printCoreTask, "core0task", 2048, NULL, 1, NULL, 0); xTaskCreatePinnedToCore(printCoreTask, "core1task", 2048, NULL, 1, NULL, 1); } void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }这里有个小细节我一开始没留意:loop 本身还在核心 1 上运行,所以加上 loop 自己,核心 1 上实际有两个任务在交替。实验时最好在 loop 里也加上 vTaskDelay,防止空转影响观察结果。
运行之后串口输出会交替出现 core 0 和 core 1。如果只有 core 1 的输出,很可能是核心编号参数传错了,或者任务创建失败后卡死在启动流程里。
3.2 用队列让两个核心协作
只是让两个任务分别打印,还不算真正的协作。更常见的场景是一个核心负责采集数据,一个核心负责处理上报。这两个任务之间需要安全地传递数据,最稳妥的轻量级方案就是 FreeRTOS 队列。
下面的例子模拟一个传感器采集任务固定在核心 0,每 100 毫秒把计数发送到队列;处理任务固定在核心 1,从队列里取数据并打印。通过这个结构,你应该能看到两个核心之间数据流动的过程:
#include <Arduino.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include <freertos/queue.h> QueueHandle_t dataQueue; void sensorTask(void *param) { uint32_t count = 0; while (1) { count++; xQueueSend(dataQueue, &count, pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(100)); } } void processTask(void *param) { uint32_t value; while (1) { if (xQueueReceive(dataQueue, &value, pdMS_TO_TICKS(1000)) == pdPASS) { Serial.printf("core %d got value %u\n", xPortGetCoreID(), value); } } } void setup() { Serial.begin(115200); dataQueue = xQueueCreate(10, sizeof(uint32_t)); xTaskCreatePinnedToCore(sensorTask, "sensor", 2048, NULL, 1, NULL, 0); xTaskCreatePinnedToCore(processTask, "process", 2048, NULL, 1, NULL, 1); } void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }xQueueSend 里的超时我留了 10 毫秒,这样如果队列满了,发送任务不会无限卡死;xQueueReceive 里的 1000 毫秒则是等待数据超时的时间。建议你不论在生产还是学习实验里,都养成给队列读写加超时的习惯。真把超时设成 portMAX_DELAY 的场景也有,但至少先确认队列另一端的任务不会因为异常而永久不回复。
3.3 怎么确认任务真的分布在两个核心
串口打印能证明任务所在的核心,但要更进一步验证负载是不是均衡,可以在两个任务里分别增加计数频率。比如核心 0 上的任务每 1 毫秒计数一次,核心 1 上的任务每 5 毫秒打印一次统计值。如果两个核心确实在并行跑,核心 0 的计数增长速率会比核心 1 稳定得多,不会因为另一个核心忙碌而掉速。
想要更直观,我这里还有一个办法:在 Arduino 环境打开串口绘图器,把两个计数差值直接打印上去,观察曲线是否平滑。曲线毛刺大,往往说明核心之间由于共享资源或优先级抢占产生了互相干扰。这个方法不严谨,但排查初期够用。
4. 双核场景下最容易翻车的几个细节
4.1 共享变量的保护不是加 volatile 就行
双核共享内存,听起来很方便,但同时也意味着两个核心可能同时读写同一个变量。很多教程会告诉你“加个 volatile 防止编译器优化”,可到了双核场景,volatile 只是让编译器每次都从内存取数,并不保证读写操作的原子性。
举个我实际遇到的例子:核心 0 上的采集任务每 1 毫秒更新一次某个传感器状态变量,核心 1 上的显示任务每次刷新屏幕时去读它。最初我图省事直接定义一个 uint8_t 变量,两边随便读写,结果屏幕上的状态每隔几百次刷新就会跳动一次。后来用临界区包住读写才稳定下来。
临界区写法可以参考这种模式:
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; // 写侧 taskENTER_CRITICAL(&mux); sensorStatus = newValue; taskEXIT_CRITICAL(&mux); // 读侧 taskENTER_CRITICAL(&mux); uint8_t tmp = sensorStatus; taskEXIT_CRITICAL(&mux);对于更复杂的结构体,临界区还是要及时退出,不要让临界区范围无限扩大。双核下临界区只保证同一时刻只有一个核心进入,并不代表你可以长时间把系统锁住。
4.2 任务之间的时序同步不要靠“等一拍”
双核并行之后最常见的一个错误,是用固定延时去等待另一个任务完成某个动作。比如写完一个标志位之后再 sleep 100ms,以为对方肯定处理完了。这种代码在单核上可能碰巧能跑,双核上由于两个核心真实并行,任务执行速度会受编译优化、外设占用和调度器影响,固定延时是一个特别脆弱的假设。
更靠谱的做法是用队列、信号量或事件组来做通知。在 FreeRTOS 里,xSemaphoreGive / xSemaphoreTake、xEventGroupSetBits / xEventGroupWaitBits 都能实现真正意义上的任务同步。拿出一点时间把这些同步原语搞清楚,比事后加延时稳得多。
4.3 中断服务函数里不要干重活
双核往往会让人更激进地在中断里做时间敏感的操作,但中断服务函数应当保持短小精悍,这和不分双核单核都没有区别。需要重点注意的是,在中断上下文里尽量不要直接调用可能阻塞的 API,也别做耗时的计算。需要处理的数据可以先放进一个环形缓冲区,然后通知一个高优先级任务去慢慢处理。
跨核心的中断处理还要小心一件事:中断可能在任意一个核心上触发,你要确保中断处理里访问的变量不会和另一个核心上的任务产生竞争。如果逻辑复杂,简单粗暴的处理是把中断里的事件通过队列上报,哪怕队列短一点,也比你直接在中断里动共享结构体安全。
4.4 栈大小是拍脑袋还是算出来的
给任务分配栈的时候,很多人都凭感觉写个 2048 或 4096。数值太小会导致栈溢出,程序随机崩溃;数值太大则浪费宝贵的内存。ESP32 虽然内存比很多单片机大,但任务多起来之后 RAM 一样会告急。
想确认栈大小够不够,可以在任务里周期性调用 uxTaskGetStackHighWaterMark 拿到剩余栈空间的峰值,然后把任务栈整体往下调,留出 20%-30% 的余量即可。调试阶段可以先给一个偏大的值,跑一段时间观察 HighWaterMark,再收窄。
5. 什么代码适合固定核,什么代码别乱固定
5.1 适合固定到核心 0 的任务
从默认布局看,核心 0 上系统任务多,留给你的计算窗口不算宽裕,但它有一个好处:和核心 1 上的串口打印、UI 刷新天然隔开。适合放到核心 0 的任务包括:需要稳定采样周期的传感器读取、音频采样、按键扫描这类 IO 密集任务。核心 1 上即使正在死磕一条很重的 UI 刷新逻辑,核心 0 上的采样也不会被它拖到。
另外一点经验之谈:通信协议栈相关任务尽量不要刻意去和用户任务凑到一个核心,尤其当你用的是带 WiFi 的模块时,无线协议栈本身就有不少内部任务。把 WiFi 发数据、收数据的逻辑拆出来放到核心 1,能够明显降低核心 0 的拥塞概率。
5.2 不建议过度固定的任务
反过来,如果任务本身是轻量的、周期不敏感的,比如偶尔更新一个状态文本、低频率的日志输出,就别费心固定核心了。这些任务放在哪个核心都能活,强行固定反而可能把某个核心的负载挤爆。
还有一个建议:在项目初期,先不急着做核心分配,把所有任务都创建起来,用系统提供的任务统计功能看看哪个核心先到瓶颈。只有当你真的观察到某个核心占用率长期偏高、另一个核心却空闲时,才把重任务搬过去。为了双核而双核,只会让代码可读性变差,还多出一堆同步问题。
6. 用了双核之后我保留的几个习惯
第一个习惯是给每个任务起名字。调试的时候串口打印任务名和核心 ID,比看地址靠谱一百倍。第二个习惯是定期打印任务栈剩余空间,而不是等崩了再查。第三个习惯是明确每个任务和另一个任务之间的通信通道,能用队列就不用全局变量,能用信号量就不用轮询。
最后说一句这几年做下来最深的体会:双核的真正价值是“隔离”,不是“加速”。当你有一段任务不希望被另一段意外拖住,把两个任务请到不同核心上,比单纯调优先级要有效得多。先把任务拆清楚,再把拆出来的任务分别固定好核心,才是双核 CPU 调用最实用的打开方式。