IoTLabs

Nghiên cứu, Sáng tạo và Thử nghiệm

Series ESP32-S3 Dual-Core – Bài 2: Kiến Trúc Dual-Core Xtensa LX7 Trên ESP32-S3

Tại Sao Cần Hiểu Kiến Trúc CPU?

Nhiều người biết ESP32-S3 có 2 core nhưng firmware của họ vẫn chạy đơn luồng trên Core 1, Core 0 bỏ không. Hoặc tệ hơn: họ chạy task trên Core 0 nhưng xung đột với WiFi stack và sinh ra các lỗi ngẫu nhiên khó debug.

Hiểu đúng kiến trúc CPU giúp bạn:

  • Phân chia task đúng core, tránh xung đột WiFi
  • Tận dụng cache để code chạy nhanh hơn không cần tăng tần số
  • Debug watchdog timeout và stack overflow đúng nguyên nhân
  • Thiết kế firmware mở rộng được khi thêm tính năng mới

Xtensa LX7: Thế Hệ Mới Hơn LX6

ESP32 gốc dùng Xtensa LX6, ESP32-S3 dùng Xtensa LX7. Sự khác biệt không chỉ ở số phiên bản:

Đặc điểmXtensa LX6 (ESP32)Xtensa LX7 (ESP32-S3)
Pipeline stages57
Instruction cache32 KB (chia 2 core)32 KB mỗi core (riêng)
Data cache8 KB8 KB mỗi core (riêng)
SIMD instructionsKhôngCó (PIE — 128-bit)
Branch predictionCơ bảnCải tiến
Hiệu năng thực tếbaseline~40% nhanh hơn/MHz

Quan trọng: mỗi core LX7 có cache riêng. Trên ESP32 gốc, 2 core chia sẻ cache — khi Core 0 cần cache, Core 1 phải chờ. Trên ESP32-S3, 2 core hoạt động độc lập hơn, ít tranh chấp bus hơn.

Core 0 và Core 1: Vai Trò Mặc Định

Trong ESP-IDF và Arduino framework, 2 core có phân công mặc định:

Core 0 — PRO_CPU (Protocol CPU)

  • Chạy WiFi stack (Espressif WiFi firmware)
  • Chạy Bluetooth controller
  • Xử lý timer ISR nền
  • setup() không chạy ở đây (trái với tên gọi)

Core 1 — APP_CPU (Application CPU)

  • Chạy setup()loop() của bạn
  • Mọi task bạn tạo mà không chỉ định core sẽ vào đây
  • Scheduler FreeRTOS chạy trên cả 2 core, nhưng APP_CPU là nơi application code sống
void setup() {
    Serial.begin(115200);
    // Kiểm tra bạn đang ở core nào
    Serial.printf("setup() chạy trên Core %d\n", xPortGetCoreID());
    // Output: "setup() chạy trên Core 1"
}

Nhầm Lẫn Phổ Biến

Nhiều người đọc documentation cũ thấy “Core 0 là PRO_CPU” và nghĩ Core 0 dành riêng cho protocol, không động vào được. Sai. Bạn hoàn toàn có thể tạo task và ghim vào Core 0 — chỉ cần tránh xung đột với WiFi stack.

pinToCore(): Gán Task Vào Core Cụ Thể

FreeRTOS trên ESP32-S3 cho phép gán task vào core cụ thể bằng xTaskCreatePinnedToCore():

// Task chạy trên Core 0 (cùng core với WiFi)
xTaskCreatePinnedToCore(
    networkTask,        // Hàm task
    "NetworkTask",      // Tên (để debug)
    4096,               // Stack size (bytes)
    NULL,               // Tham số truyền vào
    2,                  // Priority (cao hơn = ưu tiên hơn)
    &networkTaskHandle, // Handle để quản lý sau
    0                   // Core: 0 hoặc 1
);

// Task chạy trên Core 1 (cùng core với loop())
xTaskCreatePinnedToCore(
    displayTask,
    "DisplayTask",
    8192,               // LCD cần stack lớn hơn
    NULL,
    1,
    &displayTaskHandle,
    1                   // Core: 1
);

Nếu dùng xTaskCreate() thông thường (không có PinnedToCore), FreeRTOS tự chọn core — thường là Core 1, nhưng không đảm bảo.

Cache: Tại Sao Quan Trọng Với Firmware

Mỗi core có:

  • Instruction cache 32 KB: lưu code đã fetch từ Flash
  • Data cache 8 KB: lưu data đã đọc từ Flash (read-only, như const strings)

Flash của ESP32-S3 kết nối qua SPI bus (100 MHz–120 MHz). Đọc trực tiếp từ Flash tốn ~100 ns mỗi lần cache miss, trong khi SRAM chỉ tốn ~5 ns.

Điều này có ý nghĩa gì?

// Hàm này chạy nhanh vì nằm gọn trong cache 32 KB
void fastSensorPoll() {
    // Vài chục dòng code đơn giản
    float temp = readTemperature();
    float hum  = readHumidity();
    xQueueSend(sensorQueue, &temp, 0);
}

// Hàm này có thể bị cache miss nếu code lớn
void hugeDisplayRenderer() {
    // Hàng trăm dòng code, nhiều branch phức tạp
    // → Instruction cache miss → chậm hơn dự kiến
}

Thực hành tốt: Chia code thành các hàm nhỏ, mỗi hàm làm 1 việc. Không chỉ tốt cho đọc code — còn giúp cache hiệu quả hơn.

IRAM: Code Nhanh Không Cần Cache

Một số code cần chạy cực nhanh và không thể chấp nhận cache miss — đặc biệt là ISR (Interrupt Service Routine):

// Đặt ISR vào IRAM để chạy trực tiếp từ SRAM, không qua cache
void IRAM_ATTR gpioISR() {
    // ISR phải cực ngắn — chỉ set flag, không làm gì phức tạp
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void setup() {
    buttonSemaphore = xSemaphoreCreateBinary();
    attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), gpioISR, FALLING);
}

IRAM_ATTR yêu cầu linker đặt hàm vào IRAM (Internal RAM) thay vì Flash. Khi CPU thực thi hàm này, không có flash access, không có cache miss — chạy ở tốc độ tối đa.

Đừng lạm dụng IRAM_ATTR — IRAM chỉ có khoảng 400 KB, chủ yếu dùng cho WiFi stack và FreeRTOS. Chỉ dùng cho ISR và các hàm cực kỳ time-critical.

Ví Dụ Thực Tế: 2 Core Làm 2 Việc Song Song

#include <Arduino.h>

QueueHandle_t dataQueue;

// Task trên Core 0: thu thập sensor mỗi 100ms
void sensorTask(void* parameter) {
    float sensorData;
    while (true) {
        sensorData = analogRead(A0) * 3.3f / 4095.0f;

        // Gửi dữ liệu qua Queue (thread-safe)
        xQueueSend(dataQueue, &sensorData, pdMS_TO_TICKS(10));

        vTaskDelay(pdMS_TO_TICKS(100));  // Nhường CPU, không blocking
    }
}

// Task trên Core 1: hiển thị lên Serial mỗi 500ms
void displayTask(void* parameter) {
    float receivedData;
    while (true) {
        // Chờ nhận dữ liệu từ Queue (block tối đa 1 giây)
        if (xQueueReceive(dataQueue, &receivedData, pdMS_TO_TICKS(1000))) {
            Serial.printf("Voltage: %.2f V (Core %d)\n",
                receivedData, xPortGetCoreID());
        }
    }
}

void setup() {
    Serial.begin(115200);
    dataQueue = xQueueCreate(10, sizeof(float));  // Queue chứa tối đa 10 float

    // Ghim task vào core cụ thể
    xTaskCreatePinnedToCore(sensorTask,  "Sensor",  2048, NULL, 2, NULL, 0);
    xTaskCreatePinnedToCore(displayTask, "Display", 4096, NULL, 1, NULL, 1);
}

void loop() {
    // loop() trống — mọi logic đã vào task
    vTaskDelay(portMAX_DELAY);
}

Chạy code này, bạn sẽ thấy 2 task thực sự chạy song song — sensorTask trên Core 0, displayTask trên Core 1.

Khi Nào Không Cần Dual-Core

Dual-Core không phải lúc nào cũng tốt hơn:

Tình huốngCó nên dùng Dual-Core?
Sensor đơn giản + MQTTKhông — single task đủ
LCD + MQTT cùng lúcCó — tách ra 2 core
OTA + sensor pollingCó — OTA blocking cần core riêng
Xử lý audio real-timeCó — latency yêu cầu core riêng
Blink LEDKhông — overkill

Quy tắc đơn giản: nếu bạn có 2 việc không liên quancần chạy đồng thời không ngắt quãng nhau → tách ra 2 core. Nếu không, một core với FreeRTOS task switching là đủ.

Tổng Kết

Khái niệmĐiểm chính
Core 0 (PRO_CPU)WiFi stack chạy ở đây — có thể thêm task nhưng cẩn thận
Core 1 (APP_CPU)setup()loop() chạy ở đây — nơi application của bạn
Instruction cache 32 KB/coreMỗi core có cache riêng — ít tranh chấp hơn ESP32 gốc
pinToCore()Gán task vào core cụ thể bằng xTaskCreatePinnedToCore()
IRAM_ATTRĐặt ISR và code time-critical vào SRAM — không qua cache
QueueGiao tiếp giữa task an toàn, không biến global

Bài tiếp theo: Bài 3 — FreeRTOS Trên ESP32-S3: Từ loop() Đến Hệ Điều Hành Thời Gian Thực — Task, Queue, Semaphore, Mutex và cách tổ chức firmware nhiều luồng không bị race condition.