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

Giản đồ thời gian một giây: phía trên loop có delay 500 ms làm CPU đứng chờ; phía dưới nhân 0 chạy task tính liên tục chia nhiều đoạn, nhân 1 có task đèn chạy rất ngắn ở 0 và 500 ms và task báo cáo gần cuối, phần xám là thời gian rỗi
Cùng một giây: loop() đứng chờ trong delay; với FreeRTOS hai nhân chia nhau ba việc (thời gian ước lượng).

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

Sơ đồ bốn trạng thái Running, Ready, Blocked, Suspended với các mũi tên: bộ lập lịch chọn task từ Ready sang Running, bị task ưu tiên cao hơn chen thì về Ready, vTaskDelay hoặc chờ queue thì sang Blocked, hết thời gian chờ thì về Ready, vTaskSuspend và vTaskResume
Mỗi nhân chỉ có một task Running; task đang chờ không tốn thời gian CPU (hình minh họa).
  • 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

Lời gọi xTaskCreatePinnedToCore với bảy tham số được đánh số: hàm của task, tên, stack 2048 byte, tham số, độ ưu tiên, handle, nhân
Bảy tham số của 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.printf cầ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ạy loop() ở 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

Serial Monitor: dòng setup chạy trên nhân 1, tick 1000 Hz; mỗi 2 giây hai dòng báo cáo gồm số lần đèn đổi, nhân, stack còn trống, số đang xét và số nguyên tố tìm được trên nhân 0, số task 10, heap còn trống
Mỗi 2 giây task báo cáo in tình trạng các task (số đang xét, stack, heap là ví dụ).

Đè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ượngNguyên nhân thường gặpCá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ạyThêm vTaskDelay(1) trong vòng lặp dài
Một task không bao giờ chạyTask ư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úcHàm task returnDù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ặngViệc nặng ở nhân 0, ưu tiên caoChuyển sang nhân 1 hoặc nhường thường xuyên
Số in ra lộn xộn, chữ hai task chen nhauHai task cùng in SerialChỉ để 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.

Tin tức liên quan

Gá lắp gia tốc kế theo ISO 5348: tần số cộng hưởng, dải tần dùng được và các sai số khi đo rung
Gá lắp gia tốc kế theo ISO 5348: tần số cộng hưởng, dải tần dùng được và các sai số khi đo rung

Cách gá gia tốc kế theo ISO 5348: vít cấy, keo, sáp, nam châm, đầu dò cầm tay; tần số cộng hưởng lắp đặt, dải tần dùng được, ảnh hưởng khối lượng cảm biến và các điểm kỹ thuật khi gắn.

IC ổn áp tuyến tính 7805, AMS1117: cách nối, tụ đi kèm, tính nhiệt và tản nhiệt
IC ổn áp tuyến tính 7805, AMS1117: cách nối, tụ đi kèm, tính nhiệt và tản nhiệt

IC ổn áp tuyến tính hoạt động thế nào, các loại 7805, 78L05, LM317, AMS1117 và thứ tự chân, tụ đi kèm, sụt áp tối thiểu, cách tính công suất nhiệt, tản nhiệt và khi nào nên dùng mạch nguồn xung.

Bài đầu tiên với ESP32 cho học sinh: đèn nhấp nháy và phát tên bằng mã Morse
Bài đầu tiên với ESP32 cho học sinh: đèn nhấp nháy và phát tên bằng mã Morse

Dự án STEM đầu tiên với ESP32 không cần linh kiện: nháy đèn LED có sẵn trên mạch, rồi phát tên của bé bằng mã Morse; cài Arduino IDE, giải thích code và trò chơi đoán tên.

Jack Type-C đực: sơ đồ chân, điện trở CC và cách hàn dây
Jack Type-C đực: sơ đồ chân, điện trở CC và cách hàn dây

Jack Type-C đực (đầu cắm) chỉ có một chân CC ở A5 và một cặp D+ D−. Bài trình bày sơ đồ chân đầu đực, điện trở 56 kΩ hoặc 5,1 kΩ cần gắn ở chân CC cho từng kiểu dây và các bước hàn dây vào pad.

Điện trở: đọc mã vạch màu, mã SMD, sai số, công suất và cách chọn
Điện trở: đọc mã vạch màu, mã SMD, sai số, công suất và cách chọn

Điện trở dùng để làm gì, các loại điện trở, cách đọc mã vạch màu 4 vòng và 5 vòng, đọc mã điện trở dán SMD, dãy giá trị chuẩn E12, sai số, công suất và các bước chọn điện trở cho mạch.

Mức logic 3,3 V và 5 V: khi nào cần chuyển mức và cách nối an toàn với ESP32
Mức logic 3,3 V và 5 V: khi nào cần chuyển mức và cách nối an toàn với ESP32

Giải thích mức logic 3,3 V và 5 V: ngưỡng mức thấp, mức cao của ESP32, Arduino và các họ IC; khi nào nối thẳng được, khi nào cần mạch chia áp hoặc mạch chuyển mức MOSFET. Kèm chương trình đo kiểm tra.

Cấu tạo flycam và nguyên lý hoạt động: khung, động cơ, ESC, cánh quạt, pin, mạch điều khiển bay
Cấu tạo flycam và nguyên lý hoạt động: khung, động cơ, ESC, cánh quạt, pin, mạch điều khiển bay

Cấu tạo flycam 4 cánh: khung, động cơ không chổi than, ESC, cánh quạt, pin LiPo, mạch điều khiển bay; đường năng lượng, tín hiệu và cách tạo ga, roll, pitch, yaw chỉ bằng tốc độ động cơ.

Lập trình nhúng và IoT khác nhau thế nào: phạm vi, ví dụ, cách kết nối, kỹ năng và nên học gì trước
Lập trình nhúng và IoT khác nhau thế nào: phạm vi, ví dụ, cách kết nối, kỹ năng và nên học gì trước

Phân biệt lập trình nhúng và IoT qua bốn lớp thiết bị, kết nối, máy chủ, ứng dụng; ví dụ nhiệt kế bật quạt; chọn Wi-Fi, BLE, LoRa, 4G; kỹ năng từng vai trò, bảo mật và học gì trước.


Bình luận
Đã thêm vào giỏ hàng