IoTLabs

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

Series ESP32-S3 Dual-Core – Bài 3: FreeRTOS Từ loop() Đến Hệ Điều Hành Thời Gian Thực

Vấn Đề Của loop() Đơn Luồng

Hãy xem firmware quen thuộc:

void loop() {
    readSensor();      // 50ms
    sendMQTT();        // 200ms (nếu WiFi kém)
    updateDisplay();   // 30ms
    checkButton();     // 5ms
}

Mỗi vòng loop mất ~285ms. Nếu WiFi chậm, sendMQTT() block 2–3 giây → LCD đứng hình 2–3 giây → nút bấm không phản hồi 2–3 giây. Người dùng thấy sản phẩm “đơ”.

Giải pháp đúng không phải là viết code nhanh hơn — mà là không để các tác vụ block nhau. Đây chính là lý do FreeRTOS tồn tại.

FreeRTOS Là Gì?

FreeRTOS (Free Real-Time Operating System) là một RTOS mã nguồn mở, được Espressif tích hợp vào ESP-IDF và Arduino framework cho ESP32/ESP32-S3.

FreeRTOS cho phép bạn chạy nhiều task “đồng thời” bằng cách scheduler chuyển đổi nhanh giữa các task (hàng nghìn lần/giây). Người dùng thấy chúng chạy song song, dù CPU vẫn xử lý từng task một.

Trên ESP32-S3 với 2 core, FreeRTOS có thể chạy task thực sự song song — mỗi core chạy một task khác nhau cùng lúc.

Task: Đơn Vị Cơ Bản

Task trong FreeRTOS là một hàm C chạy vô tận trong vòng lặp của nó:

void myTask(void* parameter) {
    // Khởi tạo một lần
    Serial.println("Task bắt đầu!");

    while (true) {  // Vòng lặp vô tận — KHÔNG ĐƯỢC return
        // Làm việc
        Serial.printf("Task đang chạy trên Core %d\n", xPortGetCoreID());

        // Nghỉ 1 giây — nhường CPU cho task khác
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
    // Dòng này không bao giờ được thực thi
    // Nếu task cần kết thúc: gọi vTaskDelete(NULL) trước khi thoát
}

Tạo task:

TaskHandle_t myTaskHandle;

xTaskCreatePinnedToCore(
    myTask,          // Hàm task
    "MyTask",        // Tên (dùng để debug với vTaskList())
    4096,            // Stack size tính bằng bytes
    NULL,            // Tham số truyền vào hàm task
    1,               // Priority: 0 (thấp nhất) → 25 (cao nhất)
    &myTaskHandle,   // Handle để suspend/delete sau này
    1                // Core: 0 hoặc 1
);

Priority Hoạt Động Thế Nào

Priority cao hơn → chạy trước khi task priority thấp hơn sẵn sàng
Priority bằng nhau → scheduler chia thời gian đều (round-robin)
Priority 0 → task IDLE — chỉ chạy khi không có task nào khác

Quy tắc thực tế:

  • Sensor/ISR handler: priority 3–5
  • Xử lý logic: priority 2–3
  • Display/UI: priority 1–2
  • Background task (log, OTA check): priority 1

Queue: Giao Tiếp An Toàn Giữa Các Task

Queue là cấu trúc dữ liệu FIFO thread-safe — cách chuẩn để truyền data giữa các task mà không cần biến global (dễ gây race condition).

QueueHandle_t sensorQueue;

// Task thu thập sensor (Core 0, priority cao)
void sensorTask(void* param) {
    float voltage;
    while (true) {
        voltage = analogRead(34) * 3.3f / 4095.0f;

        // Gửi vào queue — nếu queue đầy, chờ tối đa 10ms rồi bỏ qua
        if (xQueueSend(sensorQueue, &voltage, pdMS_TO_TICKS(10)) != pdPASS) {
            Serial.println("Queue đầy — bỏ sample này");
        }

        vTaskDelay(pdMS_TO_TICKS(100));  // Đọc 10 lần/giây
    }
}

// Task hiển thị (Core 1, priority thấp hơn)
void displayTask(void* param) {
    float voltage;
    while (true) {
        // Chờ nhận dữ liệu — block tối đa 2 giây
        if (xQueueReceive(sensorQueue, &voltage, pdMS_TO_TICKS(2000))) {
            Serial.printf("Voltage: %.3f V\n", voltage);
            // updateLCD(voltage);  // Cập nhật màn hình
        }
        // Không cần delay — xQueueReceive đã block đủ
    }
}

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

    // Tạo queue chứa tối đa 20 phần tử kiểu float
    sensorQueue = xQueueCreate(20, sizeof(float));

    xTaskCreatePinnedToCore(sensorTask,  "Sensor",  2048, NULL, 3, NULL, 0);
    xTaskCreatePinnedToCore(displayTask, "Display", 4096, NULL, 1, NULL, 1);
}

void loop() { vTaskDelay(portMAX_DELAY); }

Queue này tách hoàn toàn việc thu thập data và hiển thị — sensorTask không cần biết display đang làm gì và ngược lại.

Semaphore: Đồng Bộ Sự Kiện

Binary Semaphore dùng để báo hiệu một sự kiện đã xảy ra — ví dụ: ISR báo cho task biết có dữ liệu mới.

SemaphoreHandle_t buttonSemaphore;

// ISR: chạy khi nút được nhấn
void IRAM_ATTR buttonISR() {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // Give semaphore từ ISR — thread-safe
    xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// Task xử lý nút bấm
void buttonTask(void* param) {
    while (true) {
        // Block vô tận đến khi có semaphore
        if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY) == pdPASS) {
            Serial.println("Nút được nhấn!");
            // Xử lý debounce, toggle LED, v.v.
        }
    }
}

void setup() {
    buttonSemaphore = xSemaphoreCreateBinary();
    pinMode(BUTTON_PIN, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
    xTaskCreatePinnedToCore(buttonTask, "Button", 2048, NULL, 2, NULL, 1);
}

Counting Semaphore dùng khi cần đếm số lần sự kiện xảy ra (ví dụ: slot trống trong pool buffer).

Mutex: Bảo Vệ Tài Nguyên Dùng Chung

Mutex (Mutual Exclusion) ngăn 2 task truy cập cùng một tài nguyên đồng thời:

SemaphoreHandle_t i2cMutex;

// Task 1 dùng I2C bus
void task1(void* param) {
    while (true) {
        // Lấy mutex trước khi dùng I2C (chờ tối đa 100ms)
        if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdPASS) {
            // Chỉ một task vào đây cùng lúc
            readFromI2CSensor();
            xSemaphoreGive(i2cMutex);  // Trả mutex sau khi xong
        }
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// Task 2 cũng dùng I2C bus
void task2(void* param) {
    while (true) {
        if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdPASS) {
            writeToI2CDisplay();
            xSemaphoreGive(i2cMutex);
        }
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

void setup() {
    i2cMutex = xSemaphoreCreateMutex();
    // Tạo cả 2 task...
}

Semaphore vs Mutex

Binary SemaphoreMutex
Mục đíchBáo hiệu sự kiệnBảo vệ tài nguyên
Give/TakeBất kỳ task nàoCùng task đã Take mới được Give
Priority inheritanceKhôngCó (tránh priority inversion)
Dùng trong ISRCó (FromISR variant)Không

vTaskDelay vs delay(): Khác Biệt Quyết Định

// SAI: delay() blocking — CPU ngồi chờ, task khác không chạy được
void badTask(void* param) {
    while (true) {
        doWork();
        delay(1000);  // CPU bị giữ 1 giây!
    }
}

// ĐÚNG: vTaskDelay() nhường CPU — task khác có thể chạy trong thời gian chờ
void goodTask(void* param) {
    while (true) {
        doWork();
        vTaskDelay(pdMS_TO_TICKS(1000));  // Task suspend, CPU tự do
    }
}

pdMSTOTICKS(ms) chuyển milliseconds sang ticks của FreeRTOS scheduler (thường 1 tick = 1ms trên ESP32-S3).

Debug: Xem Stack Usage và Task List

// In danh sách tất cả task đang chạy
void printTaskInfo() {
    char taskListBuffer[512];
    vTaskList(taskListBuffer);
    Serial.println("Task Name\t\tState\tPri\tStack\tNum\tCore");
    Serial.println(taskListBuffer);
}

// Kiểm tra task có gần stack overflow không
void checkStackUsage() {
    // Gọi trong task muốn kiểm tra
    UBaseType_t stackRemaining = uxTaskGetStackHighWaterMark(NULL);
    Serial.printf("Stack còn lại: %u bytes\n", stackRemaining * sizeof(StackType_t));
    // Nếu < 256: tăng stack size khi tạo task!
}

Stack overflow là bug phổ biến nhất với FreeRTOS. Triệu chứng: crash ngẫu nhiên, watchdog reset. Giải pháp: tăng stack size hoặc dùng ps_malloc cho buffer lớn.

Tổng Kết

PrimitiveDùng Khi Nào
TaskMỗi luồng công việc độc lập
QueueTruyền data giữa các task (producer-consumer)
Binary SemaphoreBáo hiệu sự kiện từ ISR hoặc task khác
Counting SemaphoreĐếm số lần sự kiện, quản lý pool
MutexBảo vệ tài nguyên dùng chung (I2C, SPI, file)
vTaskDelayNghỉ không blocking — LUÔN dùng thay cho delay()

Bài tiếp theo: Bài 4 — Thiết Kế Firmware Dual-Core Đúng Cách Trên ESP32-S3 — Cách phân chia task giữa 2 core, tránh race condition, và pattern firmware IoT production có thể mở rộng.