ESP32 ESP32 多核编程:双核任务分配与共享内存同步
ESP32 系列芯片最大的亮点之一,就是它搭载了双核 Xtensa 32 位 LX6 处理器——Core 0 和 Core 1。这意味着你可以在两颗核心上同时跑不同的代码,真正实现并行处理。
不过,多核编程不是简单的「把代码拆开两边跑」就行。共享数据怎么同步?任务优先级怎么排?死锁怎么避免?这篇实战指南,从架构到代码到排坑,一次性讲清楚。
硬件清单
| 物品 | 说明 |
|---|---|
| ESP32 开发板 | DOIT DEVKIT V1、NodeMCU-32S 等双核型号(注意:ESP32-S2、ESP32-C3 是单核!) |
| LED × 2 | 5mm 任意颜色,用于任务可视化 |
| 330Ω 电阻 × 2 | LED 限流 |
| 面包板 + 跳线 | 快速搭建测试电路 |
⚠️ 重要提醒:并不是所有 ESP32 型号都是双核!ESP32-S2、ESP32-C3、ESP32-C6、ESP32-H2 都是单核芯片。买板子前务必确认芯片型号。
理解 ESP32 的双核架构
ESP32 的两颗核心在 Espressif 官方文档中被称为:
| 核心编号 | 别名 | 典型用途 |
|---|---|---|
| Core 0 | PRO_CPU(Protocol CPU) | 默认处理 Wi-Fi、蓝牙等协议栈 |
| Core 1 | APP_CPU(Application CPU) | 用户代码默认运行的核心 |
当你用 Arduino IDE 上传代码时,setup() 和 loop() 默认都跑在 Core 1 上。你可以用下面这段代码验证:
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());
delay(1000);
}
打开串口监视器(波特率 115200),你会看到输出全是 core: 1。
把任务固定到指定核心
FreeRTOS 提供了 xTaskCreatePinnedToCore() 函数,可以创建一个任务并指定它跑在哪个核心上:
xTaskCreatePinnedToCore(
TaskFunction, // 任务函数指针
"TaskName", // 任务名称(调试用)
10000, // 栈大小(字节)
NULL, // 传入任务的参数
1, // 优先级(0=最低)
&taskHandle, // 任务句柄
0 // 核心编号:0=Core 0, 1=Core 1
);
最后一个参数 xCoreID 有三种合法取值:
0— 固定到 Core 01— 固定到 Core 1tskNO_AFFINITY— 不固定,调度器自由分配
实战:双核 LED 闪烁
先接线:GPIO 2 接 LED1,GPIO 4 接 LED2(各串联 330Ω 电阻到 GND)。
TaskHandle_t Task1;
TaskHandle_t Task2;
const int led1 = 2;
const int led2 = 4;
void setup() {
Serial.begin(115200);
pinMode(led1, OUTPUT);
pinMode(led2, OUTPUT);
// 创建任务1 → Core 0,1秒闪烁
xTaskCreatePinnedToCore(
Task1code, "Task1", 10000, NULL, 1, &Task1, 0
);
delay(500);
// 创建任务2 → Core 1,700毫秒闪烁
xTaskCreatePinnedToCore(
Task2code, "Task2", 10000, NULL, 1, &Task2, 1
);
delay(500);
}
void Task1code(void * pvParameters) {
Serial.print("Task1 running on core: ");
Serial.println(xPortGetCoreID());
for(;;) {
digitalWrite(led1, HIGH);
delay(1000);
digitalWrite(led1, LOW);
delay(1000);
}
}
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 上运行
// 如果需要可以留空或做别的事情
}
上传后,两个 LED 会以不同频率闪烁——它们在不同的核心上独立运行,互不阻塞。
共享内存:双核编程的真正挑战
ESP32 是 SMP(对称多处理)架构,两个核心共享同一块 RAM。这意味着 Core 0 和 Core 1 可以直接读写同一个全局变量。但问题也来了——如果两个核心同时修改同一个变量,数据就会乱套,这叫竞态条件(Race Condition)。
// ❌ 危险写法:两个核心同时修改,可能丢数据
volatile int sensorValue = 0;
// Core 0 任务
void readSensor(void *p) {
for(;;) {
sensorValue = analogRead(34); // 可能正被 Core 1 读取
delay(10);
}
}
// Core 1 任务
void sendData(void *p) {
for(;;) {
// 读取时 sensorValue 可能正在被修改
Serial.println(sensorValue);
delay(100);
}
}
要安全地共享数据,必须使用同步机制。
同步机制一:互斥锁(Mutex)
互斥锁就像一把锁——拿到锁的核心才能读写共享数据,其他核心只能排队等。
SemaphoreHandle_t xMutex = NULL;
volatile int sensorValue = 0;
void setup() {
Serial.begin(115200);
// 创建互斥锁
xMutex = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(readSensor, "ReadSensor", 10000, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(sendData, "SendData", 10000, NULL, 1, NULL, 1);
}
// Core 0:读取传感器
void readSensor(void *p) {
for(;;) {
int val = analogRead(34);
// 获取锁(最多等 100ms)
if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
sensorValue = val;
xSemaphoreGive(xMutex); // 释放锁
}
delay(10);
}
}
// Core 1:发送数据
void sendData(void *p) {
for(;;) {
int val = 0;
if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
val = sensorValue;
xSemaphoreGive(xMutex);
}
Serial.printf("Sensor: %d\n", val);
delay(100);
}
}
void loop() {}
关键点:
xSemaphoreTake()加锁,xSemaphoreGive()解锁- 等待超时用
pdMS_TO_TICKS(毫秒数)设置 - 拿到锁后必须
Give,否则其他任务永久阻塞
同步机制二:消息队列(Queue)
如果你需要在两个核心之间传递数据而不是简单共享变量,FreeRTOS 消息队列是最安全的选择。队列天然线程安全,不需要额外的锁。
QueueHandle_t sensorQueue = NULL;
void setup() {
Serial.begin(115200);
// 创建队列:最多 10 个元素,每个是 int 类型
sensorQueue = xQueueCreate(10, sizeof(int));
xTaskCreatePinnedToCore(readSensor, "ReadSensor", 10000, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(processData, "ProcessData", 10000, NULL, 1, NULL, 1);
}
// Core 0:读传感器 → 发送到队列
void readSensor(void *p) {
for(;;) {
int val = analogRead(34);
// 发送到队列(最多等 100ms)
if (xQueueSend(sensorQueue, &val, pdMS_TO_TICKS(100)) != pdTRUE) {
Serial.println("Queue full!");
}
delay(50);
}
}
// Core 1:从队列读取 → 处理数据
void processData(void *p) {
int val;
for(;;) {
// 从队列接收(最多等 1秒)
if (xQueueReceive(sensorQueue, &val, pdMS_TO_TICKS(1000)) == pdTRUE) {
// 处理数据
float voltage = val * (3.3 / 4095.0);
Serial.printf("Voltage: %.3f V\n", voltage);
}
}
}
void loop() {}
队列的优势很明显:不需要手动加锁解锁,数据传递有缓冲,还能设置超时避免死锁。
同步机制三:信号量(Semaphore)
信号量适合任务同步——比如让 Core 1 等 Core 0 完成某个操作后再执行。
SemaphoreHandle_t xDataReady = NULL;
volatile int processedData = 0;
void setup() {
Serial.begin(115200);
// 创建二值信号量
xDataReady = xSemaphoreCreateBinary();
xTaskCreatePinnedToCore(core0Producer, "Producer", 10000, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(core1Consumer, "Consumer", 10000, NULL, 1, NULL, 1);
}
// Core 0:生产数据 → 释放信号量
void core0Producer(void *p) {
for(;;) {
int val = analogRead(34);
processedData = val;
// 通知 Core 1 数据就绪
xSemaphoreGive(xDataReady);
delay(200);
}
}
// Core 1:等待信号量 → 消费数据
void core1Consumer(void *p) {
for(;;) {
// 阻塞等待,直到 Core 0 释放信号量
if (xSemaphoreTake(xDataReady, portMAX_DELAY) == pdTRUE) {
Serial.printf("Got data: %d\n", processedData);
}
}
}
void loop() {}
二值信号量(Binary Semaphore)只有「有」和「无」两种状态,适合简单的通知场景。如果需要计数(比如生产者连续生产了 N 个数据),可以用计数信号量 xSemaphoreCreateCounting(max, initial)。
实战项目:双核温湿度监控系统
下面是一个完整的项目案例:Core 0 负责读取 SHT30 传感器数据,Core 1 负责将数据格式化为 JSON 并通过串口输出。两者通过队列通信。
#include <Wire.h>
#include <ArduinoJson.h>
// SHT30 I2C 地址
#define SHT30_ADDR 0x44
QueueHandle_t dataQueue;
const int QUEUE_SIZE = 5;
struct SensorData {
float temperature;
float humidity;
uint32_t timestamp;
};
void setup() {
Serial.begin(115200);
Wire.begin();
// 创建队列
dataQueue = xQueueCreate(QUEUE_SIZE, sizeof(SensorData));
// 初始化 SHT30
Wire.beginTransmission(SHT30_ADDR);
Wire.write(0x2C); // 周期测量模式
Wire.write(0x06); // 中重复性,0.5Hz
Wire.endTransmission();
// Core 0:读传感器
xTaskCreatePinnedToCore(
readSHT30, "ReadSHT30", 10000, NULL, 2, NULL, 0
);
// Core 1:输出 JSON
xTaskCreatePinnedToCore(
outputJSON, "OutputJSON", 10000, NULL, 1, NULL, 1
);
}
void readSHT30(void *p) {
uint8_t buf[6];
SensorData data;
for(;;) {
Wire.requestFrom(SHT30_ADDR, 6);
if (Wire.available() == 6) {
for (int i = 0; i < 6; i++) {
buf[i] = Wire.read();
}
// 计算温度
int rawTemp = (buf[0] << 8) | buf[1];
data.temperature = -45.0 + 175.0 * rawTemp / 65535.0;
// 计算湿度
int rawHum = (buf[3] << 8) | buf[4];
data.humidity = 100.0 * rawHum / 65535.0;
data.timestamp = millis();
// 发送到队列
if (xQueueSend(dataQueue, &data, pdMS_TO_TICKS(500)) != pdTRUE) {
Serial.println("[Core0] Queue full, data dropped");
}
}
delay(2000); // 0.5Hz 采样
}
}
void outputJSON(void *p) {
SensorData data;
for(;;) {
if (xQueueReceive(dataQueue, &data, pdMS_TO_TICKS(5000)) == pdTRUE) {
// 构建 JSON
StaticJsonDocument<200> doc;
doc["temp"] = data.temperature;
doc["humidity"] = data.humidity;
doc["ts"] = data.timestamp;
char json[128];
serializeJson(doc, json);
Serial.printf("[Core1] %s\n", json);
}
}
}
void loop() {}
这个项目的架构清晰:
- Core 0 专注传感器读取,不受串口输出影响
- Core 1 专注数据处理和格式化
- 队列 作为安全的跨核通信通道,最多缓冲 5 条数据
常见问题排查
1. 任务创建失败 / 系统重启
症状:调用 xTaskCreatePinnedToCore() 后 ESP32 重启或触发 Guru Meditation Error。
原因:栈空间不足。FreeRTOS 任务栈默认较小,如果你的任务里有 Serial.printf、ArduinoJson 等大栈消耗操作,10000 字节可能不够。
解决:增加栈大小到 20000 甚至更高,同时用 uxTaskGetStackHighWaterMark() 检查实际使用量:
// 在任务中定期检查栈余量
UBaseType_t highWater = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("Stack remaining: %u bytes\n", highWater);
2. 互斥锁死锁
症状:系统卡死,串口无输出。
原因:任务 A 拿到了锁,等待任务 B 的信号;任务 B 也想拿同一个锁——两个互相等,谁都动不了。
解决:
- 使用超时:
xSemaphoreTake(mutex, pdMS_TO_TICKS(100))而不是永久等待 - 加锁顺序保持一致(所有任务按相同顺序获取多个锁)
- 避免在持有锁时调用可能阻塞的函数(如
delay()、串口输出)
3. Core 0 的 Wi-Fi/蓝牙干扰
症状:把任务固定到 Core 0 后,Wi-Fi 连接不稳定。
原因:Core 0 默认运行 Wi-Fi/蓝牙协议栈。如果你的任务占用太多 CPU 时间,协议栈就会被挤占。
解决:
- 把用户任务放到 Core 1
- Core 0 的任务设置较低优先级(优先级 0-1)
- 用
vTaskDelay()主动让出 CPU 时间片
4. 共享变量没加 volatile
症状:一个核心修改了变量,另一个核心读到的还是旧值。
原因:编译器优化可能把变量缓存到寄存器。
解决:跨核共享的全局变量必须加 volatile 关键字:
volatile int sharedCounter = 0; // ✅ 正确
int sharedCounter = 0; // ❌ 可能被优化
核心选择建议
| 场景 | 推荐核心 | 理由 |
|---|---|---|
| 传感器读取 | Core 0 | 靠近外设,不影响用户逻辑 |
| 数据处理 / 算法 | Core 1 | 更多可用 CPU 资源 |
| Wi-Fi/蓝牙通信 | Core 0(让系统处理) | 协议栈默认在 Core 0 |
| UI 刷新 / 显示 | Core 1 | 不干扰网络协议栈 |
| 后台日志记录 | Core 1 低优先级 | 不影响主逻辑 |
黄金法则:Core 0 留给系统和外设,Core 1 跑你的业务逻辑。如果 Core 0 有闲置算力,再把轻量任务分配过去。
总结
ESP32 双核编程的核心要点:
- 用
xTaskCreatePinnedToCore()指定核心,最后一个参数决定任务跑在哪颗 CPU 上 - 共享数据必须同步——互斥锁保护变量,消息队列传递数据,信号量同步任务
- 栈大小要给够,不够就加大,用
uxTaskGetStackHighWaterMark()监控 - Core 0 有系统协议栈,别在上面跑高优先级重负载任务
- volatile 不能少,跨核共享变量必须声明
掌握这些,你的 ESP32 项目就能从单核「单线程」升级到双核并行处理,性能和响应速度都会有明显提升。动手试试吧!