Bỏ qua, đến nội dung chính
Bài viếtFirmwareMới

Real-Time Operating System (RTOS): FreeRTOS trên ESP32

Hướng dẫn dùng FreeRTOS trên ESP32 cho firmware multitasking: tạo task, truyền dữ liệu bằng queue, dùng notification, mutex và kiểm tra stack theo cách dễ bắt đầu.

Chia sẻ

LinkedInFacebookX
An ESP32 development board connected to a small sensor on a realistic electronics workbench

Khi dự án ESP32 chỉ đọc một cảm biến rồi nháy một cái LED, một vòng loop() duy nhất vẫn rất ổn. Rắc rối bắt đầu khi Wi-Fi, màn hình, nút nhấn, logging và việc lấy mẫu cảm biến đều cần được xử lý ở các nhịp khác nhau.

Đó là lúc FreeRTOS trở nên hữu ích. Nó giúp mình tách firmware thành những task nhỏ: task có thể chờ, được đánh thức và trao đổi dữ liệu mà không biến một vòng lặp khổng lồ thành nút giao thông. Với ESP-IDF, FreeRTOS đã nằm sẵn trong hệ thống, bạn không phải cài thêm một hệ điều hành riêng lên board.

Board ESP32 nối với nút nhấn và đèn LED trên breadboard trong một bài thử multitasking

Một setup nhỏ nhưng đủ để quan sát nhiều task chạy cùng nhau.

“Real-time” ở đây nghĩa là gì?

Real-time không có nghĩa là mọi thứ chạy tức thì. Nó có nghĩa là mình xác định được công việc nào cần được lên lịch ra sao và độ trễ nào còn chấp nhận được. Task đọc nút có thể cần phản hồi trong vài mili-giây, còn task đọc nhiệt độ chỉ cần chạy mỗi giây một lần.

FreeRTOS cung cấp các mảnh ghép để diễn tả điều đó:

  • Task: một đơn vị công việc độc lập, có stack và priority riêng.
  • Queue: hộp thư nhỏ để truyền dữ liệu giữa các task.
  • Task notification: tín hiệu nhẹ, gửi trực tiếp đến một task.
  • Mutex: khóa để bảo vệ tài nguyên dùng chung, chẳng hạn một màn hình.
  • Event group: nhóm bit biểu diễn trạng thái như Wi-Fi đã sẵn sàng hay sensor đã khởi tạo.
  • Software timer: callback được lên lịch từ timer service task của FreeRTOS.

Scheduler thường chạy task có priority cao nhất trong số các task đang ready. Khi task gọi vTaskDelay() hoặc chờ queue, nó chuyển sang trạng thái blocked và nhường CPU cho task ready khác.

Một kiến trúc đơn giản nhưng có thể mở rộng

Với một demo sensor nhỏ, mình thường tách như sau:

  1. sensor_task đọc input theo chu kỳ cố định.
  2. Task gửi một reading gọn vào queue.
  3. worker_task nhận reading và quyết định có đổi trạng thái LED hay không.
  4. logger_task in trạng thái mà không bắt sensor task phải chờ serial output.

Sơ đồ task sensor, queue, worker và logger phối hợp trong firmware FreeRTOS trên ESP32

Mỗi task nên có một vai trò rõ ràng; queue là ranh giới trao đổi dữ liệu.

Điểm quan trọng là ranh giới giữa các task. Thay vì để task nào cũng chạm vào mọi biến global, hãy truyền một message nhỏ. Cách này làm ownership rõ hơn và giảm các race condition khó chịu.

Tạo hai task với ESP-IDF

Tạo một project ESP-IDF, sau đó include các header FreeRTOS cần dùng:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

static void heartbeat_task(void *arg)
{
    for (;;) {
        // Đổi trạng thái LED hoặc chạy một công việc định kỳ nhỏ ở đây.
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void app_main(void)
{
    xTaskCreate(
        heartbeat_task,
        "heartbeat",
        2048,               // Stack trong ESP-IDF tính theo byte
        NULL,
        5,                  // priority
        NULL
    );
}

Thông thường task function sẽ chạy mãi. Nếu task thật sự cần kết thúc, gọi vTaskDelete(NULL) thay vì chỉ return. Với ESP32 dual-core, xTaskCreatePinnedToCore() cho phép chỉ rõ core; còn xTaskCreate() để task không bị pin vào một core trong IDF FreeRTOS.

Một chi tiết riêng của ESP32 rất dễ nhầm: ESP-IDF tính stack size theo byte, trong khi tài liệu Vanilla FreeRTOS thường mô tả stack depth theo word. Hãy luôn kiểm tra đúng port mà bạn đang dùng.

Truyền dữ liệu sensor qua queue

Đây là phần cốt lõi của ví dụ producer-consumer. Queue copy message, nên producer không phải giữ một con trỏ trỏ vào biến local tạm thời.

#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"

typedef struct {
    int value;
    TickType_t tick;
} sensor_reading_t;

static QueueHandle_t reading_queue;

static void sensor_task(void *arg)
{
    for (;;) {
        sensor_reading_t reading = {
            .value = read_sensor_value(),
            .tick = xTaskGetTickCount(),
        };

        // Chờ tối đa một chút nếu consumer tạm thời xử lý chậm.
        xQueueSend(reading_queue, &reading, pdMS_TO_TICKS(20));
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

static void worker_task(void *arg)
{
    sensor_reading_t reading;

    for (;;) {
        if (xQueueReceive(reading_queue, &reading, portMAX_DELAY)) {
            set_led(reading.value > 700);
        }
    }
}

void app_main(void)
{
    reading_queue = xQueueCreate(8, sizeof(sensor_reading_t));
    configASSERT(reading_queue != NULL);

    xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL);
    xTaskCreate(worker_task, "worker", 3072, NULL, 5, NULL);
}

Queue phù hợp khi task cần gửi dữ liệu thật: reading, enum hoặc một pointer tới buffer có ownership rõ ràng. Đừng nhét nguyên một frame ảnh lớn vào queue nhỏ; với workload đó hãy dùng buffer pool hoặc ring buffer.

Chọn queue, notification, semaphore hay mutex?

Mình thường bắt đầu bằng câu hỏi: “Mình đang muốn truyền điều gì?”

Nhu cầuNên dùngVí dụ
Gửi một giá trị hoặc structQueueGửi sensor reading sang task xử lý
Đánh thức đúng một taskTask notificationUART nhận xong, báo task xử lý
Bảo vệ một thiết bị dùng chungMutexHai task cùng dùng OLED
Báo event từ ISRBinary semaphore hoặc notificationGPIO interrupt đánh thức task
Chờ nhiều trạng thái hệ thốngEvent groupWi-Fi connected và sensor initialized

Mutex không chỉ là binary semaphore được gọi bằng tên khác: nó có priority inheritance và dành cho mutual exclusion. Hãy dùng mutex quanh critical section ngắn, đừng giữ mutex trong lúc gọi network hoặc delay dài.

static SemaphoreHandle_t display_mutex;

void display_status(const char *text)
{
    if (xSemaphoreTake(display_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
        oled_print(text);
        xSemaphoreGive(display_mutex);
    }
}

Nếu phần cập nhật màn hình có thể giao cho một task duy nhất, thiết kế sạch hơn thường là gửi display message cho task đó thay vì lock màn hình từ nhiều nơi.

Timing: delay không phải synchronization

Pattern này khá mong manh:

vTaskDelay(pdMS_TO_TICKS(1000));
assume_wifi_is_ready();

Delay chỉ nói rằng “đừng chạy task này ít nhất trong khoảng thời gian đó”. Nó không chứng minh Wi-Fi, peripheral hoặc task khác đã xong. Khi một task cần chờ event thật, hãy dùng queue, notification, semaphore hoặc event group.

Với công việc định kỳ, vTaskDelayUntil() giữ lịch đều hơn việc cộng dồn relative delay:

TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(100);

for (;;) {
    sample_sensor();
    vTaskDelayUntil(&last_wake, period);
}

Task vẫn có thể trễ deadline nếu phần việc quá lâu hoặc task priority cao hơn chiếm CPU. Vì vậy đo workload thật quan trọng hơn chọn một con số delay trông đẹp.

Stack, priority và những bug hay bị che đi

Hãy cấp stack đủ cho call path sâu nhất, nhưng đừng chữa mọi crash bằng cách cấp một stack thật lớn. Trên ESP32, stack task chiếm RAM mà những phần khác của ứng dụng cũng cần.

Trong lúc test, kiểm tra lượng stack còn trống ít nhất:

ESP_LOGI("worker", "stack spare: %u bytes",
         uxTaskGetStackHighWaterMark(NULL));

Hãy chạy đúng tình huống nặng nhất: JSON dài, Wi-Fi bận, refresh màn hình và cả error path. Mình cũng tránh đặt array lớn trong local scope của task nếu một buffer riêng giúp lifetime rõ ràng hơn.

Priority nên thể hiện độ gấp, không phải mức quan trọng của sản phẩm. Task priority cao nhưng không block có thể bỏ đói các task thấp hơn. Phần lớn application task có thể bắt đầu ở priority vừa phải, block trên đúng object cần chờ, rồi chỉ tăng priority sau khi đo thấy vấn đề.

Logic analyzer và dây Dupont trên bàn lab khi chuẩn bị kiểm tra timing của firmware

Chuẩn bị phép đo rõ ràng trước; khi timing không ổn định, tín hiệu thực tế thường nhanh hơn đoán.

Cách debug thực tế

Khi firmware có vẻ “random”, mình kiểm tra lần lượt:

  1. Đặt tên dễ đọc cho task và log core ID khi debug board dual-core.
  2. Kiểm tra return value của xQueueSend, xQueueReceive, mutex take và việc tạo task.
  3. Log queue depth và uxTaskGetStackHighWaterMark() trong workload nặng nhất.
  4. Dùng mutex cho shared state; đừng coi việc suspend scheduler là lock giữa các core.
  5. Đừng giữ mutex khi gọi network blocking và tránh log dày trong tight loop.
  6. Nếu timing vẫn sai, toggle một GPIO quanh phần việc rồi xem bằng logic analyzer.

Đừng dùng printf làm công cụ đo timing duy nhất. Bản thân serial logging cũng mất thời gian và có thể làm thay đổi hành vi mà bạn đang đo.

Quy tắc mình thấy hữu ích cho người mới

Bắt đầu với hai hoặc ba task, không phải mười task. Mỗi task một nhiệm vụ, quy định rõ nó nhận dữ liệu bằng cách nào và ai sở hữu mỗi buffer. Dùng queue cho message, notification cho một lần đánh thức trực tiếp, mutex cho tài nguyên dùng chung trong thời gian ngắn và event group cho trạng thái hệ thống.

FreeRTOS sẽ rất rối nếu task nào cũng có thể chạm vào mọi thứ. Nó dễ chịu hơn nhiều khi firmware được xem như một nhóm worker nhỏ nối với nhau bằng những con đường hẹp và nhìn thấy được. Đó là thói quen đáng mang theo khi project ESP32 lớn dần.

Tài liệu tham khảo

Chia sẻ

LinkedInFacebookX

Tiếp tục khám phá

Đọc tiếp

Bài viết liên quan

Xem thêm trong Bài viết

Nastrotek sử dụng cookie để phân tích truy cập và cá nhân hóa quảng cáo, giúp hiểu cách website được sử dụng. Bạn có thể chấp nhận hoặc từ chối các cookie không thiết yếu. Chính sách bảo mật