FreeRTOS trên ESP32: task, độ ưu tiên, hai nhân, vTaskDelay và vTaskDelayUntil cho người mới
Task trong FreeRTOS là gì, bốn trạng thái, độ ưu tiên, chọn nhân với xTaskCreatePinnedToCore, stack tính bằng byte, vTaskDelay và vTaskDelayUntil, chương trình ba task trên hai nhân.
Khi chương trình ESP32 lớn dần (đọc cảm biến, giữ Wi-Fi, nháy đèn, chờ nút bấm, gửi dữ liệu), nhét hết vào loop() với vài lệnh delay() sẽ nhanh chóng rối và lỡ nhịp. ESP32 có sẵn hệ điều hành thời gian thực FreeRTOS: chia chương trình thành nhiều task, mỗi task lo một việc, chạy song song trên hai nhân. Bài này giải thích task là gì, các trạng thái của task, độ ưu tiên, cách chọn nhân, khác nhau giữa vTaskDelay và vTaskDelayUntil, rồi viết chương trình ba task không cần thư viện ngoài.
1. Vì sao cần task

Trong delay(500), chương trình không làm được gì khác: nút bấm lúc đó bị bỏ lỡ, cảm biến phải chờ. Cách quen thuộc là dùng millis() để tự canh giờ cho từng việc, nhưng khi có năm, sáu việc với nhịp khác nhau thì mã rất khó đọc.
Với FreeRTOS, mỗi việc là một hàm riêng có vòng lặp của nó. Trong vòng lặp đó vẫn dùng "chờ" (vTaskDelay), nhưng chờ của task khác hẳn delay() kiểu cũ: task được chuyển sang trạng thái đang chờ và không tốn CPU, bộ lập lịch cho task khác chạy ngay. Trên ESP32, Arduino thực ra đã chạy FreeRTOS từ đầu: setup() và loop() nằm trong một task tên loopTask ở nhân 1, và delay() của Arduino ESP32 chính là vTaskDelay.
2. Các trạng thái của task

- Running (đang chạy): mỗi nhân có đúng một task ở trạng thái này.
- Ready (sẵn sàng): muốn chạy nhưng nhân đang bận với task ưu tiên cao hơn hoặc bằng.
- Blocked (đang chờ): chờ hết thời gian
vTaskDelay, chờ dữ liệu từ queue, chờ semaphore. Không tốn CPU. - Suspended (tạm dừng): bị
vTaskSuspend()dừng hẳn cho tới khi cóvTaskResume().
Bộ lập lịch chạy lại mỗi tick (1 ms trên ESP32 Arduino, configTICK_RATE_HZ = 1000) và mỗi khi một task chuyển trạng thái: nó luôn chọn task Ready có độ ưu tiên cao nhất. Hai task cùng độ ưu tiên thì thay phiên nhau mỗi tick. Khi không còn task nào cần chạy, task rỗi IDLE của nhân đó chạy, dọn dẹp bộ nhớ của task đã xóa và báo cho bộ giám sát (watchdog) rằng hệ thống vẫn ổn.
3. Tạo một task

xTaskCreatePinnedToCore trong chương trình (hình minh họa).Hàm của task nhận một con trỏ void* và không bao giờ return: thân hàm là vòng lặp for (;;). Muốn kết thúc task thì gọi vTaskDelete(NULL) ở cuối. Ba điểm cần chú ý:
- Stack: trên ESP32, kích thước stack tính bằng byte (FreeRTOS gốc tính bằng word). Task gọi
Serial.printfcần khoảng 3–4 KB; task chỉ bật tắt chân cần 1–2 KB. Thiếu stack thì chip khởi động lại với lỗi "stack overflow"; thừa nhiều thì phí RAM.uxTaskGetStackHighWaterMark()cho biết phần stack còn trống ít nhất từ lúc chạy, nên dùng để chỉnh lại kích thước. - Độ ưu tiên: từ 0 (thấp nhất, là của IDLE) trở lên. Task ưu tiên cao mà không bao giờ chờ sẽ chiếm trọn nhân, các task thấp hơn không được chạy.
- Nhân: 0, 1 hoặc
tskNO_AFFINITY. Wi-Fi và Bluetooth chạy chủ yếu ở nhân 0, Arduino chạyloop()ở nhân 1. Việc tính toán nặng đặt ở nhân 0 thì phải nhường thường xuyên để Wi-Fi không bị ảnh hưởng.
4. vTaskDelay và vTaskDelayUntil
vTaskDelay(n) chờ n tick tính từ lúc gọi. Nếu phần việc trước đó mất 3 ms thì chu kỳ thật là 503 ms, và sai số cộng dồn theo thời gian. vTaskDelayUntil(&lanTruoc, n) chờ tới đúng mốc lanTruoc + n rồi cập nhật lanTruoc: chu kỳ luôn đúng n tick dù phần việc dài ngắn khác nhau. Việc cần nhịp đều (lấy mẫu cảm biến, điều khiển) nên dùng vTaskDelayUntil. pdMS_TO_TICKS(500) đổi mili giây ra tick để chương trình vẫn đúng nếu tần số tick khác.
5. Chương trình ba task
Chương trình chỉ cần ESP32 (đèn xanh trên bo ở GPIO2), không cần linh kiện thêm:
- Task đèn (nhân 1, ưu tiên 1): đổi trạng thái đèn đúng mỗi 500 ms bằng
vTaskDelayUntil. - Task tính (nhân 0, ưu tiên 1): tìm số nguyên tố liên tục, cứ xét 2000 số thì nhường 1 tick.
- Task báo cáo (nhân 1, ưu tiên 2): mỗi 2 giây in số lần nháy, số đã xét, stack còn trống, số task, bộ nhớ heap còn trống.
// PYWORLD.VN - FreeRTOS trên ESP32: ba task chạy song song trên hai nhân, độ ưu tiên, vTaskDelay và vTaskDelayUntil, đo stack còn trống
// Task nháy đèn (nhân 1) đổi trạng thái GPIO2 đúng mỗi 500 ms; task tính số nguyên tố (nhân 0) chạy liên tục nhưng biết nhường;
// task báo cáo (nhân 1, ưu tiên cao hơn) in tình trạng mỗi 2 giây. Không cần thư viện ngoài
const int CHAN_DEN = 2;
TaskHandle_t taskDen, taskTinh, taskBaoCao;
volatile uint32_t soLanNhay = 0; // các task khác chỉ đọc; bài sau nói cách chia sẻ dữ liệu an toàn hơn
volatile uint32_t soDangXet = 1, soNguyenTo = 0;
// Task 1: nháy đèn. vTaskDelayUntil giữ chu kỳ 500 ms tính từ lần chạy trước, không bị trôi theo thời gian xử lý.
void nhayDen(void *thamSo) {
pinMode(CHAN_DEN, OUTPUT);
TickType_t lanTruoc = xTaskGetTickCount();
for (;;) {
digitalWrite(CHAN_DEN, !digitalRead(CHAN_DEN));
soLanNhay = soLanNhay + 1;
vTaskDelayUntil(&lanTruoc, pdMS_TO_TICKS(500));
}
}
bool laNguyenTo(uint32_t n) {
if (n < 2) return false;
for (uint32_t d = 2; d * d <= n; d++) {
if (n % d == 0) return false;
}
return true;
}
// Task 2: việc nặng chạy liên tục. Cứ xét xong 2000 số thì nhường 1 tick để task rỗi (IDLE) của nhân 0 được chạy;
// nếu không nhường, bộ giám sát task (watchdog) sẽ báo lỗi sau 5 giây.
void tinhNguyenTo(void *thamSo) {
for (;;) {
for (int i = 0; i < 2000; i++) {
soDangXet = soDangXet + 1;
if (laNguyenTo(soDangXet)) soNguyenTo = soNguyenTo + 1;
}
vTaskDelay(1);
}
}
// Task 3: báo cáo. Ưu tiên 2 nên khi tới giờ, nó được chạy ngay, chen trước task ưu tiên 1 trên cùng nhân.
void baoCao(void *thamSo) {
for (;;) {
vTaskDelay(pdMS_TO_TICKS(2000));
Serial.printf("t=%lus | den: %lu lan, nhan %d, stack trong %u byte | so nguyen to: xet toi %lu, tim duoc %lu, nhan %d\n",
(unsigned long)(millis() / 1000), (unsigned long)soLanNhay, (int)xTaskGetCoreID(taskDen),
(unsigned)uxTaskGetStackHighWaterMark(taskDen), (unsigned long)soDangXet, (unsigned long)soNguyenTo,
(int)xTaskGetCoreID(taskTinh));
Serial.printf(" | bao cao: nhan %d, stack trong %u byte | so task: %u | heap trong: %lu byte\n", (int)xPortGetCoreID(),
(unsigned)uxTaskGetStackHighWaterMark(NULL), (unsigned)uxTaskGetNumberOfTasks(),
(unsigned long)ESP.getFreeHeap());
}
}
void setup() {
Serial.begin(115200);
delay(500);
Serial.printf("setup() chay tren nhan %d, tick %d Hz\n", (int)xPortGetCoreID(), configTICK_RATE_HZ);
// hàm tên stack (byte) tham số ưu tiên handle nhân
xTaskCreatePinnedToCore(nhayDen, "den", 2048, NULL, 1, &taskDen, 1);
xTaskCreatePinnedToCore(tinhNguyenTo, "tinh", 2048, NULL, 1, &taskTinh, 0);
xTaskCreatePinnedToCore(baoCao, "bao cao", 4096, NULL, 2, &taskBaoCao, 1);
}
void loop() {
vTaskDelay(portMAX_DELAY); // loop() cũng là một task (loopTask); ở đây không còn việc gì nên cho ngủ hẳn
}
Hai biến đếm được khai báo volatile vì task khác đọc chúng. Ở đây chỉ một task ghi và mỗi biến là số 32 bit nên đọc không bị hỏng; khi nhiều task cùng ghi hoặc cần đọc nhiều biến cùng lúc cho khớp nhau, phải dùng queue hoặc mutex, là nội dung của bài tiếp theo trong loạt.
6. Kết quả mong đợi

Đèn trên bo nháy đều dù task tính chạy không nghỉ: hai việc nằm ở hai nhân, và kể cả khi cùng nhân thì task đèn cũng được chạy vì task tính có nhường. Thử bỏ dòng vTaskDelay(1) trong task tính: sau khoảng 5 giây Serial Monitor in lỗi của bộ giám sát task (task_wdt), vì task rỗi của nhân 0 không được chạy để báo "vẫn ổn". Bài về watchdog trong cùng loạt giải thích kỹ hiện tượng này.
7. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Khởi động lại, báo "stack overflow" hoặc "Guru Meditation" | Stack của task quá nhỏ (hay gặp khi thêm printf, String) | Tăng stack; xem uxTaskGetStackHighWaterMark rồi chỉnh |
| "task_wdt: Task watchdog got triggered" | Task chạy vòng lặp không nhường, task rỗi không được chạy | Thêm vTaskDelay(1) trong vòng lặp dài |
| Một task không bao giờ chạy | Task ưu tiên cao hơn trên cùng nhân không chờ | Cho task ưu tiên cao chờ; hoặc hạ độ ưu tiên |
| Chip khởi động lại ngay khi task kết thúc | Hàm task return | Dùng vòng lặp vô hạn, hoặc gọi vTaskDelete(NULL) |
| Wi-Fi chập chờn khi chạy việc nặng | Việc nặng ở nhân 0, ưu tiên cao | Chuyển sang nhân 1 hoặc nhường thường xuyên |
| Số in ra lộn xộn, chữ hai task chen nhau | Hai task cùng in Serial | Chỉ để một task in, các task khác gửi dữ liệu qua queue |
8. Xem thêm
- Ngắt GPIO trên ESP32: phản ứng tức thì với nút bấm, kết hợp tốt với task.
- Deep Sleep trên ESP32: khi mọi task đều rảnh lâu, cho cả chip ngủ để tiết kiệm pin.
- Các bài tiếp theo của loạt ESP32 nâng cao: queue, semaphore, mutex; timer phần cứng; watchdog.
Xem thêm