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 Semaphore | Mutex | |
|---|---|---|
| Mục đích | Báo hiệu sự kiện | Bảo vệ tài nguyên |
| Give/Take | Bất kỳ task nào | Cùng task đã Take mới được Give |
| Priority inheritance | Không | Có (tránh priority inversion) |
| Dùng trong ISR | Có (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
| Primitive | Dùng Khi Nào |
|---|---|
| Task | Mỗi luồng công việc độc lập |
| Queue | Truyền data giữa các task (producer-consumer) |
| Binary Semaphore | Bá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 |
| Mutex | Bảo vệ tài nguyên dùng chung (I2C, SPI, file) |
| vTaskDelay | Nghỉ 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.
📚 Series: Sức Mạnh ESP32-S3 Dual-Core
⬅️ Bài trước: S3 Dual-Core – Bài 2: Kiến Trúc Dual-Core Xtensa LX7 Trên ESP32-S3
➡️ Bài tiếp theo: S3 Dual-Core – Bài 4: Thiết Kế Firmware Dual-Core Đúng Cách


