从开发板包装上写着“双核 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 // 核心 ID0 或 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 调用最实用的打开方式。
阅读完成 · 觉得有帮助?