|
ESP32 多核编程:双核任务分配与共享内存同步

ESP32 多核编程:双核任务分配与共享内存同步

ESP32 系列芯片最大的亮点之一,就是它搭载了双核 Xtensa 32 位 LX6 处理器——Core 0 和 Core 1。这意味着你可以在两颗核心上同时跑不同的代码,真正实现并行处理。

不过,多核编程不是简单的「把代码拆开两边跑」就行。共享数据怎么同步?任务优先级怎么排?死锁怎么避免?这篇实战指南,从架构到代码到排坑,一次性讲清楚。


硬件清单

物品说明
ESP32 开发板DOIT DEVKIT V1、NodeMCU-32S 等双核型号(注意:ESP32-S2、ESP32-C3 是单核!)
LED × 25mm 任意颜色,用于任务可视化
330Ω 电阻 × 2LED 限流
面包板 + 跳线快速搭建测试电路

⚠️ 重要提醒:并不是所有 ESP32 型号都是双核!ESP32-S2、ESP32-C3、ESP32-C6、ESP32-H2 都是单核芯片。买板子前务必确认芯片型号。


理解 ESP32 的双核架构

ESP32 的两颗核心在 Espressif 官方文档中被称为:

核心编号别名典型用途
Core 0PRO_CPU(Protocol CPU)默认处理 Wi-Fi、蓝牙等协议栈
Core 1APP_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 0
  • 1 — 固定到 Core 1
  • tskNO_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.printfArduinoJson 等大栈消耗操作,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 双核编程的核心要点:

  1. xTaskCreatePinnedToCore() 指定核心,最后一个参数决定任务跑在哪颗 CPU 上
  2. 共享数据必须同步——互斥锁保护变量,消息队列传递数据,信号量同步任务
  3. 栈大小要给够,不够就加大,用 uxTaskGetStackHighWaterMark() 监控
  4. Core 0 有系统协议栈,别在上面跑高优先级重负载任务
  5. volatile 不能少,跨核共享变量必须声明

掌握这些,你的 ESP32 项目就能从单核「单线程」升级到双核并行处理,性能和响应速度都会有明显提升。动手试试吧!