ESP32 芯片里塞的是两颗 Xtensa 32 位 LX6 处理器,也就是 core 0 和 core 1。但很多人用 Arduino IDE 写了很久 ESP32,都不知道自己的代码默认只跑在 core 1 上,另一颗核基本闲着。这篇把 xTaskCreatePinnedToCore 的用法理一遍,让两颗核真正同时干活。

先把结论放在前面:Arduino 代码默认跑在 core 1,想用上 core 0,就得用 FreeRTOS 建任务,把某段代码作为任务钉到指定的核上。另外提醒一句,单核上 FreeRTOS 本来就能多任务轮转,不建任务也能多线程-ish,双核的价值在于两段代码真并行。平时不折腾双核也没关系,用 Arduino IDE 上传代码后它自己就跑起来了,不用操心哪颗核在执行。
先确认你的板子是不是双核
不是所有 ESP32 系列芯片都有两颗核。基于 ESP32-S2、ESP32-C3、ESP32-C6、ESP32-H2 的板子是单核的,比如 ESP32-S2-Saola-1、Seeed Studio XIAO ESP32-C3、ESP32-C6-DevKitC-1 这类常见型号,动手前先查一下自己手上板子用的哪颗芯片。

想知道当前代码跑在哪个核,有一个现成的函数:xPortGetCoreID()。把下面这段烧进去,串口监视器设 115200 波特率,你会看到 setup() 和 loop() 都自报家门。
/*********
Rui Santos
Complete project details at https://randomnerdtutorials.com
*********/
void setup() {
Serial.begin(115200);
Serial.print("setup() running on core ");
Serial.println(xPortGetCoreID());
}
void loop() {
Serial.print("loop() running on core ");
Serial.println(xPortGetCoreID());
}
实测输出就是两个 core 1,连 loop() 也在 core 1 上转。这印证了默认行为:不特意指定,你写的所有 Arduino 代码都交给 core 1。
建任务:xTaskCreatePinnedToCore 的七个参数
Arduino IDE 给 ESP32 配好了 FreeRTOS,这是个实时操作系统,能让我们把几段代码拆成独立并行跑的任务。任务可以是闪一个 LED、发一次网络请求、读一组传感器数据,任何独立的事情都行。想把它一次吃透,可以先看 FreeRTOS 的任务入门篇,再回来看这篇的钉核操作,顺序反了容易懵。
建任务分三步。第一步,声明一个任务句柄:
TaskHandle_t Task1;第二步,在 setup() 里用 xTaskCreatePinnedToCore 创建任务并指定核。它的最后一个参数就是要钉的核,优先级也在参数里一起给:
xTaskCreatePinnedToCore(
Task1code, /* Function to implement the task */
"Task1", /* Name of the task */
10000, /* Stack size in words */
NULL, /* Task input parameter */
0, /* Priority of the task */
&Task1, /* Task handle. */
0); /* Core where the task should run */七个参数逐个拆开看,对着表读一遍基本就明白了:
| 参数 | 含义 | 示例值 |
|---|---|---|
| Task1code | 任务要执行的函数名 | Task1code |
| "Task1" | 任务名字符串 | "Task1" |
| 10000 | 栈大小,单位是 words | 10000 |
| NULL | 传给任务的输入参数 | NULL |
| 0 | 任务优先级,0 为最低 | 0 或 1 |
| &Task1 | 任务句柄的地址 | &Task1 |
| 0 | 钉到哪个核,最后一个参数 | 0 或 1 |
优先级从 0 开始起算,数值越大越先被处理器照顾。第三步,把任务函数写出来,里面放一个 for(;;) 无限循环,它就像第二个 loop()。底下这段是教程里的示意写法,原样贴出来看结构就够:
Void Task1code( void * parameter) {
for(;;) {
Code for task 1 - infinite loop
(...)
}
}如果运行到某个阶段想把这个任务撤掉,用 vTaskDelete(),参数就是任务句柄:
vTaskDelete(Task1);完整实例:两个核各闪一个 LED
来做一个能看见效果的最小实验。需要的材料:一块双核的 ESP32 开发板(比如 ESP32 DOIT DEVKIT V1)、两个 5mm LED、两个 330 欧电阻、一块面包板和几根杜邦线。

两个 LED 分别接 GPIO 2 和 GPIO 4,如上图。接下来建两个任务:Task1 钉到 core 0,每 1000 毫秒翻转一次 LED;Task2 钉到 core 1,每 700 毫秒翻转一次。完整代码如下:
/*********
Rui Santos
Complete project details at https://randomnerdtutorials.com
*********/
TaskHandle_t Task1;
TaskHandle_t Task2;
// LED pins
const int led1 = 2;
const int led2 = 4;
void setup() {
Serial.begin(115200);
pinMode(led1, OUTPUT);
pinMode(led2, OUTPUT);
//create a task that will be executed in the Task1code() function, with priority 1 and executed on core 0
xTaskCreatePinnedToCore(
Task1code, /* Task function. */
"Task1", /* name of task. */
10000, /* Stack size of task */
NULL, /* parameter of the task */
1, /* priority of the task */
&Task1, /* Task handle to keep track of created task */
0); /* pin task to core 0 */
delay(500);
//create a task that will be executed in the Task2code() function, with priority 1 and executed on core 1
xTaskCreatePinnedToCore(
Task2code, /* Task function. */
"Task2", /* name of task. */
10000, /* Stack size of task */
NULL, /* parameter of the task */
1, /* priority of the task */
&Task2, /* Task handle to keep track of created task */
1); /* pin task to core 1 */
delay(500);
}
//Task1code: blinks an LED every 1000 ms
void Task1code( void * pvParameters ){
Serial.print("Task1 running on core ");
Serial.println(xPortGetCoreID());
for(;;){
digitalWrite(led1, HIGH);
delay(1000);
digitalWrite(led1, LOW);
delay(1000);
}
}
//Task2code: blinks an LED every 700 ms
void Task2code( void * pvParameters ){
Serial.print("Task2 running on core ");
Serial.println(xPortGetCoreID());
for(;;){
digitalWrite(led2, HIGH);
delay(700);
digitalWrite(led2, LOW);
delay(700);
}
}
void loop() {
}有一个细节值得点出来:loop() 本身默认跑在 core 1,所以其实可以不建 Task2,把它的代码直接塞进 loop() 里,效果一样。这份教程为了演示双核才特意建了两个任务。但真到了任务多、逻辑散的项目里,把代码按任务组织反而更清爽,看个人取舍。
接线本身没有玄机:LED 长脚那端经 330 欧电阻接到 GPIO,短脚接 GND,两路一模一样。烧录前确认 Arduino IDE 里选的板子型号和 COM 口没选错,这是新手最常翻车的一步。
代码是怎么跑起来的
开头声明两个句柄 Task1 和 Task2,再把 GPIO 2 和 GPIO 4 分给两个 LED,初始化串口和 pinMode 之后,setup() 里两次调用 xTaskCreatePinnedToCore,唯一的关键差别就是最后一个参数:一次给 0,一次给 1。两个任务的优先级都给了 1,栈大小都是 10000 words。
任务函数里先打印一句 xPortGetCoreID() 的结果自报家门,方便调试确认任务真的落在了预期的核上,然后进 for(;;) 无限循环各干各的。
烧录时选对板子和 COM 口,打开 115200 波特率的串口监视器,应该看到这样的输出:

与此同时,面包板上一颗 LED 每秒闪一次,另一颗每 700 毫秒闪一次,节奏完全独立:

两颗 LED 节奏互不干扰,说明两个任务确实在两颗核上并行跑着,不是单核上排队轮转的假并行。
什么场合值得把任务钉到核上
闪 LED 这种量级的活,其实感受不出单核轮转和双核并行的差别。真正值得拆核的场景是两段代码都很重的时候:比如一颗核专门阻塞式地读传感器,另一颗核维持网络服务、对外发布数据,谁也别拖累谁。
顺着这个思路,可以把 FreeRTOS 的其他机制也补上:任务之间传数据用队列(queue),协调资源用信号量(semaphore),定时触发用软件定时器。这些都是围绕任务展开的配套工具,掌握了 xTaskCreatePinnedToCore 之后再看它们会顺很多。
动手建议从这篇的双 LED 实验起步,跑通之后把 Task1code 里的闪灯换成你自己的重活,比如传感器读数或者 WiFi 上报,感受一下两颗核各管一摊的清爽。代码不长,十分钟就能烧完看到结果。
最后把这篇的核心结论收拢一下:大多数 ESP32 型号是双核的;Arduino 代码默认跑 core 1;想用 core 0 就得建 FreeRTOS 任务;xTaskCreatePinnedToCore 负责把任务钉到指定的核,最后一个参数说了算。这套方法的价值在于让两个任务真正独立并行地跑起来,而不是在单核上排队。数字和参数我都对照原教程核对过,栈大小 10000 words、优先级 1、LED 的 GPIO 2 和 GPIO 4 这些值可以直接照抄。











试了,一个 1s 一个 700ms,两颗 LED 各闪各的,串口输出和文章截图一模一样
钉核这个说法比绑定接地气,最后一个参数给 0 还是给 1 就是全部秘密了
这个 xPortGetCoreID() 的自查思路很实用。我之前一直搞不懂为什么两个任务写在同一个 loop 里节奏会互相影响,看完才知道默认全挤在 core 1 上。照这篇把读传感器和刷屏分开钉到两个核之后,OLED 那边再也没掉过帧。栈大小 10000 words 那个参数我一开始不敢给,实际跑下来没问题,官方例程就是这个值。
七参数表格好评,比看文档顺眼多了
Task2 其实可以省掉直接用 loop 这点讲得清楚,省得新手以为必须建满两个任务