IoTLabs

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

Series ESP32-S3 Dual-Core – Bài 4: Thiết Kế Firmware Dual-Core Đúng Cách

Tại Sao Firmware Dual-Core Dễ Sai

Thêm một core không tự nhiên làm firmware tốt hơn. Thực tế, firmware Dual-Core kém thiết kế còn tệ hơn firmware đơn core vì:

  • Race condition: 2 task cùng ghi một biến → data bị hỏng ngẫu nhiên
  • Deadlock: Task A chờ Task B, Task B chờ Task A → cả 2 đứng
  • Priority inversion: Task quan trọng bị chặn bởi task ít quan trọng giữ Mutex
  • Stack overflow: Task mới tạo mà không ước tính đủ stack
  • Watchdog reset: Task blocking quá lâu → watchdog không được feed

Bài này đưa ra blueprint thiết kế firmware để tránh những lỗi này ngay từ đầu.

Anti-Pattern #1: Biến Global Không Có Bảo Vệ

// SAI — race condition!
float g_temperature = 0.0f;  // Biến global

// Task A (Core 0): cập nhật nhiệt độ
void sensorTask(void* p) {
    while (true) {
        g_temperature = readSensor();  // Ghi không bảo vệ
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// Task B (Core 1): đọc và gửi MQTT
void mqttTask(void* p) {
    while (true) {
        float temp = g_temperature;  // Đọc không bảo vệ
        publishMQTT(temp);           // Có thể đọc giá trị đang bị ghi dở!
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

Trên ESP32-S3 với 2 core, 2 task này thực sự chạy đồng thời. g_temperature = readSensor() không phải phép gán nguyên tử với float 32-bit trên kiến trúc 32-bit — CPU cần nhiều lệnh để ghi đủ 4 bytes. Nếu Task B đọc giữa chừng, bạn nhận được giá trị rác.

Sửa đúng: Dùng Queue

QueueHandle_t temperatureQueue;

void sensorTask(void* p) {
    while (true) {
        float temp = readSensor();
        xQueueOverwrite(temperatureQueue, &temp);  // Queue size=1, luôn ghi đè
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void mqttTask(void* p) {
    float temp;
    while (true) {
        xQueuePeek(temperatureQueue, &temp, portMAX_DELAY);  // Đọc không xóa
        publishMQTT(temp);
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

void setup() {
    temperatureQueue = xQueueCreate(1, sizeof(float));  // Queue 1 phần tử
    // ...
}

Anti-Pattern #2: delay() Trong Task

// SAI — blocking toàn task
void networkTask(void* p) {
    while (true) {
        if (WiFi.status() != WL_CONNECTED) {
            WiFi.reconnect();
            delay(5000);  // Block 5 giây — watchdog nguy hiểm!
        }
        // ...
    }
}

delay() block CPU hoàn toàn. FreeRTOS scheduler vẫn chạy và chuyển sang task khác, nhưng task này không thể phản hồi gì trong 5 giây.

Sửa đúng: vTaskDelay + State Machine

typedef enum {
    WIFI_CONNECTING,
    WIFI_CONNECTED,
    WIFI_RECONNECTING
} WifiState;

void networkTask(void* p) {
    WifiState state = WIFI_CONNECTING;
    uint32_t reconnectTimer = 0;

    while (true) {
        switch (state) {
            case WIFI_CONNECTING:
                WiFi.begin(SSID, PASS);
                state = WIFI_RECONNECTING;
                reconnectTimer = xTaskGetTickCount();
                break;

            case WIFI_RECONNECTING:
                if (WiFi.status() == WL_CONNECTED) {
                    state = WIFI_CONNECTED;
                    Serial.println("WiFi connected");
                } else if (xTaskGetTickCount() - reconnectTimer > pdMS_TO_TICKS(10000)) {
                    // Timeout 10s, thử lại
                    WiFi.reconnect();
                    reconnectTimer = xTaskGetTickCount();
                }
                break;

            case WIFI_CONNECTED:
                if (WiFi.status() != WL_CONNECTED) {
                    state = WIFI_RECONNECTING;
                }
                // Làm việc thực tế
                break;
        }

        vTaskDelay(pdMS_TO_TICKS(500));  // Check mỗi 500ms, không blocking
    }
}

Anti-Pattern #3: Task Stack Quá Nhỏ

// SAI — stack 512 bytes cho task làm nhiều việc
xTaskCreatePinnedToCore(bigTask, "Big", 512, NULL, 1, NULL, 1);
// → Stack overflow → crash ngẫu nhiên sau vài giờ

Ước Tính Stack Size:

Loại taskStack gợi ý
Đọc sensor, gửi Queue1024–2048 bytes
WiFi/MQTT (Arduino)4096–8192 bytes
LCD/LVGL rendering8192–16384 bytes
HTTP request/response8192–12288 bytes
JSON parsing (ArduinoJson)4096–8192 bytes

Quy tắc thực tế: bắt đầu với stack lớn hơn ước tính, dùng uxTaskGetStackHighWaterMark() để đo thực tế, rồi giảm dần nếu cần.

// Đo stack usage sau khi task đã chạy thật
UBaseType_t remaining = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("Stack còn lại tối thiểu: %u bytes\n",
    remaining * sizeof(StackType_t));
// Nếu còn > 500 bytes: có thể giảm stack khi tạo task
// Nếu còn < 200 bytes: tăng stack ngay!

Blueprint Firmware Dual-Core IoT

Đây là kiến trúc kinh nghiệm cho firmware IoT production trên ESP32-S3:

Core 0 (PRO_CPU) — "Network Core"
├── WiFi stack (Espressif, tự động)
├── networkTask: WiFi reconnect, MQTT pub/sub
├── otaTask: OTA update check (priority thấp)
└── healthTask: Watchdog feed, memory monitor

Core 1 (APP_CPU) — "Application Core"
├── sensorTask: Đọc sensor, gửi queue
├── displayTask: LCD/LVGL render
├── logicTask: Business logic, state machine
└── logTask: Serial log, SD card log (priority thấp)

Shared (thread-safe):
├── sensorQueue: sensorTask → logicTask
├── displayQueue: logicTask → displayTask
├── mqttQueue: logicTask → networkTask
├── i2cMutex: protect I2C bus
└── spiffsMutex: protect filesystem

Ví Dụ: Firmware IoT 4 Task

#include <Arduino.h>
#include <WiFi.h>

// Queues và Mutex
QueueHandle_t sensorQueue;
QueueHandle_t mqttQueue;
SemaphoreHandle_t i2cMutex;

struct SensorData {
    float temperature;
    float humidity;
    uint32_t timestamp;
};

struct MqttMessage {
    char topic[64];
    char payload[128];
};

// ── Core 0: Đọc sensor mỗi 2 giây ──────────────────────────────────────────
void sensorTask(void* p) {
    SensorData data;
    while (true) {
        // Lấy I2C mutex trước khi đọc sensor I2C
        if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdPASS) {
            data.temperature = 25.5f + (esp_random() % 10) / 10.0f;
            data.humidity    = 60.0f + (esp_random() % 20) / 10.0f;
            data.timestamp   = millis();
            xSemaphoreGive(i2cMutex);
        }
        xQueueSend(sensorQueue, &data, pdMS_TO_TICKS(10));
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

// ── Core 0: Gửi MQTT khi có data ────────────────────────────────────────────
void networkTask(void* p) {
    MqttMessage msg;
    while (true) {
        if (xQueueReceive(mqttQueue, &msg, pdMS_TO_TICKS(5000))) {
            if (WiFi.isConnected()) {
                Serial.printf("[MQTT] %s: %s\n", msg.topic, msg.payload);
                // mqttClient.publish(msg.topic, msg.payload);
            }
        }
    }
}

// ── Core 1: Logic xử lý và tạo MQTT message ─────────────────────────────────
void logicTask(void* p) {
    SensorData data;
    MqttMessage msg;
    while (true) {
        if (xQueueReceive(sensorQueue, &data, pdMS_TO_TICKS(3000))) {
            // Tạo MQTT payload
            snprintf(msg.topic,   sizeof(msg.topic),   "iotlabs/sensor/env");
            snprintf(msg.payload, sizeof(msg.payload),
                "{\"temp\":%.1f,\"hum\":%.1f,\"ts\":%lu}",
                data.temperature, data.humidity, data.timestamp);
            xQueueSend(mqttQueue, &msg, pdMS_TO_TICKS(100));

            // Log
            Serial.printf("[Logic] Temp:%.1f Hum:%.1f\n",
                data.temperature, data.humidity);
        }
    }
}

// ── Core 1: Hiển thị LCD (placeholder) ──────────────────────────────────────
void displayTask(void* p) {
    while (true) {
        // Peek queue để đọc không xóa — sensor task vẫn giữ nguyên data
        SensorData data;
        if (xQueuePeek(sensorQueue, &data, 0) == pdPASS) {
            // lcd.setCursor(0, 0);
            // lcd.printf("T:%.1f H:%.1f", data.temperature, data.humidity);
        }
        vTaskDelay(pdMS_TO_TICKS(500));  // Refresh LCD 2 lần/giây
    }
}

void setup() {
    Serial.begin(115200);

    // Khởi tạo primitives
    sensorQueue = xQueueCreate(10, sizeof(SensorData));
    mqttQueue   = xQueueCreate(20, sizeof(MqttMessage));
    i2cMutex    = xSemaphoreCreateMutex();

    // WiFi
    WiFi.begin("SSID", "PASSWORD");

    // Core 0: network + sensor (cùng core với WiFi stack)
    xTaskCreatePinnedToCore(sensorTask,  "Sensor",  2048, NULL, 3, NULL, 0);
    xTaskCreatePinnedToCore(networkTask, "Network", 8192, NULL, 2, NULL, 0);

    // Core 1: logic + display
    xTaskCreatePinnedToCore(logicTask,   "Logic",   4096, NULL, 2, NULL, 1);
    xTaskCreatePinnedToCore(displayTask, "Display", 8192, NULL, 1, NULL, 1);
}

void loop() {
    vTaskDelay(portMAX_DELAY);  // loop() không làm gì — task lo hết
}

Checklist Thiết Kế Firmware Dual-Core

Trước khi viết code, trả lời các câu hỏi:

  • [ ] Task nào cần chạy ở Core 0 (cùng WiFi stack)?
  • [ ] Task nào thuần application, đặt ở Core 1?
  • [ ] Tài nguyên nào dùng chung giữa task? → Cần Mutex
  • [ ] Data nào cần truyền giữa task? → Dùng Queue
  • [ ] Stack size mỗi task ước tính bao nhiêu? → Đo với HighWaterMark
  • [ ] Watchdog task nào chịu trách nhiệm feed?
  • [ ] Task nào có thể bị blocking lâu? → Không được dùng delay()

Tổng Kết

Vấn ĐềGiải Pháp
Biến global dùng chungQueue hoặc Mutex bảo vệ
delay() blockingvTaskDelay() + state machine
Stack overflowĐo HighWaterMark, stack đủ lớn
Race conditionKhông share data thô, dùng Queue
DeadlockKhông Take 2 Mutex cùng lúc, timeout khi Take
Task không biết core nàopinToCore() explicit

Bài tiếp theo: Bài 5 — Memory Architecture Trên ESP32-S3: SRAM, Flash, PSRAM, Heap, Stack và Cache — Hiểu bản đồ bộ nhớ để allocate đúng, tránh fragmentation và dùng PSRAM hiệu quả.