Queue, semaphore, mutex trong FreeRTOS: chia sẻ dữ liệu an toàn giữa các task trên ESP32

Vì sao biến dùng chung bị sai trên ESP32 hai nhân, queue chuyển dữ liệu, semaphore để ngắt báo cho task, mutex bảo vệ Serial, thí nghiệm hai nhân cùng tăng một biến.

Chia chương trình thành nhiều task giúp mỗi việc giữ đúng nhịp, nhưng ngay khi các task cần trao đổi dữ liệu thì xuất hiện lỗi khó tìm: số đếm bị thiếu, dòng in ra bị chen nhau, giá trị đọc được nửa cũ nửa mới. FreeRTOS có ba công cụ để giải quyết: queue (hàng đợi) để chuyển dữ liệu, semaphore để báo hiệu, mutex để bảo vệ tài nguyên dùng chung. Bài này giải thích từng công cụ, dùng khi nào, và có một thí nghiệm cho thấy hai nhân của ESP32 làm mất dữ liệu thật khi không có khóa.

1. Vì sao biến dùng chung bị sai

Giản đồ theo thời gian: nhân 0 và nhân 1 cùng đọc dem bằng 41, cùng cộng thành 42, lần lượt ghi 42; giá trị trong RAM chỉ tăng từ 41 lên 42 dù có hai lần cộng
Cả hai nhân đọc 41 trước khi bên kia kịp ghi, nên một lần cộng bị mất (hình minh họa).

Câu lệnh dem++ trông như một bước, nhưng CPU làm ba bước: đọc giá trị từ RAM vào thanh ghi, cộng 1, ghi trở lại. Trên ESP32 có hai nhân chạy thật sự cùng lúc, nên nhân kia có thể đọc đúng lúc nhân này đã đọc mà chưa ghi. Kết quả: hai lần cộng, biến chỉ tăng 1. Ngay cả trên một nhân, bộ lập lịch cũng có thể chuyển task giữa ba bước đó.

Từ khóa volatile chỉ bắt trình dịch đọc biến từ RAM mỗi lần, không làm phép tăng thành một bước. Muốn an toàn phải có cơ chế khóa.

2. Queue: chuyển dữ liệu giữa các task

Task đọc mỗi 100 ms gửi mẫu vào queue gồm các ô, task xử lý lấy mẫu ra ở đầu hàng; ghi chú queue chép nguyên giá trị, queue đầy thì bỏ mẫu, queue rỗng thì task xử lý ngủ
Task đọc đưa mẫu vào cuối hàng, task xử lý lấy ra ở đầu hàng (hình minh họa).

Queue là hàng đợi có số ô cố định, vào trước ra trước. xQueueCreate(20, sizeof(MauDo)) tạo 20 ô, mỗi ô vừa một MauDo. Điểm quan trọng: queue chép nguyên dữ liệu vào ô của nó, nên bên gửi dùng lại biến của mình ngay được và bên nhận luôn có một bản riêng, không ai đọc dở dang của ai.

  • xQueueSend(q, &m, thoiGianCho): thêm vào cuối hàng. Hàng đầy thì chờ tối đa thoiGianCho tick; chờ 0 thì trả lỗi ngay. Task đọc trong bài chọn không chờ, để nhịp lấy mẫu không bị kéo dài, và đếm số lần phải bỏ mẫu.
  • xQueueReceive(q, &m, portMAX_DELAY): lấy ở đầu hàng. Hàng rỗng thì task ngủ (không tốn CPU) cho tới khi có phần tử mới.
  • uxQueueSpacesAvailable(q) cho biết còn bao nhiêu ô trống: còn ít nghĩa là bên xử lý không theo kịp.

Lỗi hay gặp là gửi con trỏ tới biến cục bộ qua queue: khi bên nhận đọc, biến đó có thể đã bị ghi đè hoặc không còn tồn tại. Gửi cả struct nhỏ (vài chục byte) là cách an toàn nhất.

3. Semaphore và mutex

Phía trên: ngắt nút BOOT gọi xSemaphoreGiveFromISR dựng cờ, task nút ưu tiên 3 chờ cờ bằng xSemaphoreTake; phía dưới: giản đồ task A giữ mutex in dòng rồi trả, task B phải chờ, sau khi A trả khóa thì B được khóa và in
Hai công cụ trông giống nhau nhưng dùng cho hai việc khác nhau (hình minh họa).

Semaphore nhị phân là một lá cờ: bên này dựng cờ (Give), bên kia chờ cờ và hạ xuống (Take). Cách dùng điển hình là ngắt báo cho task: hàm ngắt phải thật ngắn, chỉ dựng cờ bằng xSemaphoreGiveFromISR() rồi thoát; việc dài (chống dội, in Serial, gửi mạng) để task làm. Trong ngắt chỉ được gọi các hàm FreeRTOS có đuôi FromISR. portYIELD_FROM_ISR() cho task vừa được báo chạy ngay sau khi ngắt kết thúc nếu nó có ưu tiên cao hơn task đang chạy.

Semaphore đếm giữ một con số thay vì một cờ. Trong bài, setup() dùng nó để chờ đủ hai task của thí nghiệm báo xong.

Mutex là khóa cho tài nguyên dùng chung (biến, cổng Serial, bus I2C): task nào Take được thì làm việc, xong phải Give; task khác Take trong lúc đó sẽ phải chờ. Khác với semaphore nhị phân, mutex có người giữ: chỉ task đã lấy mới được trả, và FreeRTOS tạm nâng ưu tiên của task đang giữ khóa để task ưu tiên cao đang chờ không bị kẹt lâu (kế thừa ưu tiên). Không dùng mutex trong hàm ngắt.

4. Chương trình

Chỉ cần ESP32: nút BOOT (GPIO0) có sẵn trên bo. Nối thêm biến trở (hai đầu 3V3, GND, chân giữa GPIO34) nếu muốn số đo thay đổi theo tay xoay.

  • Thí nghiệm lúc khởi động: hai task ở hai nhân cùng tăng một biến 100 000 lần, một lần không khóa, một lần có mutex.
  • Task đọc (ưu tiên 2): mỗi 100 ms gửi thời điểm và điện áp GPIO34 vào queue.
  • Task xử lý (ưu tiên 1): nhận từ queue, mỗi 10 mẫu in trung bình, nhỏ nhất, lớn nhất, số ô trống của queue.
  • Task nút (ưu tiên 3): chờ cờ từ ngắt nút BOOT, chống dội rồi in số lần bấm.
  • Hai task cùng in Serial qua hàm inDong() có mutex, nên các dòng không bao giờ bị chen giữa.
// PYWORLD.VN - Queue, semaphore và mutex của FreeRTOS trên ESP32: thí nghiệm hai nhân cùng tăng một biến, queue chuyển số đo, ngắt nút báo cho task
// Chỉ cần ESP32: nút BOOT (GPIO0) có sẵn trên bo. Muốn số đo thay đổi thì nối biến trở: hai đầu 3V3, GND, chân giữa GPIO34
// Không cần thư viện ngoài

#include <stdarg.h>

const int CHAN_NUT = 0;                  // nút BOOT trên bo, bấm thì xuống mức 0
const int CHAN_DO = 34;

struct MauDo {                           // một phần tử của queue: queue chép nguyên giá trị, không giữ con trỏ
  uint32_t thoiGianMs;
  int mv;
};

QueueHandle_t hangDoi;                   // task đọc → task xử lý
SemaphoreHandle_t coNutBam;              // semaphore nhị phân: ngắt nút → task nút
SemaphoreHandle_t khoaSerial;            // mutex: mỗi lúc chỉ một task in ra Serial
volatile uint32_t soLanDay = 0;          // số lần queue đầy, mẫu bị bỏ

// In một dòng có giữ mutex, để dòng của hai task không chen vào nhau.
void inDong(const char *dinhDang, ...) {
  char dong[160];
  va_list ds;
  va_start(ds, dinhDang);
  vsnprintf(dong, sizeof(dong), dinhDang, ds);
  va_end(ds);
  xSemaphoreTake(khoaSerial, portMAX_DELAY);
  Serial.println(dong);
  xSemaphoreGive(khoaSerial);
}

// ---- Thí nghiệm: hai task ở hai nhân cùng tăng một biến ----
const uint32_t SO_LAN = 100000;
volatile uint32_t demKhongKhoa = 0, demCoKhoa = 0;
SemaphoreHandle_t khoaDem, daXong;

void tangDem(void *thamSo) {
  for (uint32_t i = 0; i < SO_LAN; i++) demKhongKhoa = demKhongKhoa + 1;   // đọc, cộng, ghi: nhân kia có thể chen vào giữa
  for (uint32_t i = 0; i < SO_LAN; i++) {
    xSemaphoreTake(khoaDem, portMAX_DELAY);              // chỉ một task được ở trong đoạn này
    demCoKhoa = demCoKhoa + 1;
    xSemaphoreGive(khoaDem);
  }
  xSemaphoreGive(daXong);                                // báo đã xong (semaphore đếm)
  vTaskDelete(NULL);
}

void thiNghiemHaiNhan() {
  khoaDem = xSemaphoreCreateMutex();
  daXong = xSemaphoreCreateCounting(2, 0);
  uint32_t batDau = millis();
  xTaskCreatePinnedToCore(tangDem, "tang 0", 2048, NULL, 1, NULL, 0);
  xTaskCreatePinnedToCore(tangDem, "tang 1", 2048, NULL, 1, NULL, 1);
  xSemaphoreTake(daXong, portMAX_DELAY);                 // chờ đủ hai task
  xSemaphoreTake(daXong, portMAX_DELAY);
  Serial.printf("Khong khoa: %lu / %lu (mat %lu lan cong)\n", (unsigned long)demKhongKhoa, (unsigned long)(2 * SO_LAN),
                (unsigned long)(2 * SO_LAN - demKhongKhoa));
  Serial.printf("Co mutex:   %lu / %lu, mat %lu ms\n", (unsigned long)demCoKhoa, (unsigned long)(2 * SO_LAN),
                (unsigned long)(millis() - batDau));
}

// ---- Queue: task đọc gửi mẫu, task xử lý nhận ----
void taskDoc(void *thamSo) {
  TickType_t lanTruoc = xTaskGetTickCount();
  for (;;) {
    MauDo m = {millis(), (int)analogReadMilliVolts(CHAN_DO)};
    if (xQueueSend(hangDoi, &m, 0) != pdTRUE) soLanDay = soLanDay + 1;   // không chờ: queue đầy thì bỏ mẫu và đếm lại
    vTaskDelayUntil(&lanTruoc, pdMS_TO_TICKS(100));
  }
}

void taskXuLy(void *thamSo) {
  MauDo m;
  int dem = 0, nho = 0, lon = 0;
  long tong = 0;
  for (;;) {
    xQueueReceive(hangDoi, &m, portMAX_DELAY);           // ngủ cho tới khi có mẫu, không tốn CPU
    if (dem == 0) nho = lon = m.mv;
    nho = min(nho, m.mv);
    lon = max(lon, m.mv);
    tong += m.mv;
    if (++dem == 10) {                                   // 10 mẫu = 1 giây
      inDong("t=%5.1fs | trung binh %4ld mV, nho nhat %4d, lon nhat %4d | queue con %u cho, day %lu lan", m.thoiGianMs / 1000.0f,
             tong / 10, nho, lon, (unsigned)uxQueueSpacesAvailable(hangDoi), (unsigned long)soLanDay);
      dem = 0;
      tong = 0;
    }
  }
}

// ---- Semaphore nhị phân: ngắt chỉ "báo", việc xử lý để task làm ----
void IRAM_ATTR ngatNut() {
  BaseType_t canDoiTask = pdFALSE;
  xSemaphoreGiveFromISR(coNutBam, &canDoiTask);          // trong ngắt chỉ dùng hàm có đuôi FromISR
  portYIELD_FROM_ISR(canDoiTask);                        // task nút có ưu tiên cao hơn thì chạy ngay khi ngắt xong
}

void taskNut(void *thamSo) {
  int soLanBam = 0;
  for (;;) {
    xSemaphoreTake(coNutBam, portMAX_DELAY);
    vTaskDelay(pdMS_TO_TICKS(50));                       // chờ hết dội phím
    while (xSemaphoreTake(coNutBam, 0) == pdTRUE) {}     // bỏ các lần "báo" do dội
    if (digitalRead(CHAN_NUT) == LOW) inDong(">> Nut BOOT duoc bam, lan %d", ++soLanBam);
  }
}

void setup() {
  Serial.begin(115200);
  delay(500);
  thiNghiemHaiNhan();

  hangDoi = xQueueCreate(20, sizeof(MauDo));             // 20 phần tử, mỗi phần tử 8 byte
  coNutBam = xSemaphoreCreateBinary();
  khoaSerial = xSemaphoreCreateMutex();
  pinMode(CHAN_NUT, INPUT_PULLUP);
  attachInterrupt(CHAN_NUT, ngatNut, FALLING);
  xTaskCreatePinnedToCore(taskDoc, "doc", 2048, NULL, 2, NULL, 1);
  xTaskCreatePinnedToCore(taskXuLy, "xu ly", 4096, NULL, 1, NULL, 1);
  xTaskCreatePinnedToCore(taskNut, "nut", 4096, NULL, 3, NULL, 1);
}

void loop() {
  vTaskDelay(portMAX_DELAY);
}

5. Kết quả mong đợi

Serial Monitor: dòng không khóa 136518 trên 200000, mất 63482 lần cộng; dòng có mutex 200000 trên 200000 mất 1932 ms; các dòng thống kê mỗi giây trung bình khoảng 1650 mV rồi tăng lên khoảng 2850 mV, queue còn 20 chỗ, đầy 0 lần; một dòng nút BOOT được bấm lần 1
Thí nghiệm hai nhân lúc khởi động, rồi mỗi giây một dòng thống kê; bấm nút BOOT một lần (số liệu ví dụ).

Kết quả không khóa mỗi lần chạy một khác và luôn thiếu: đó chính là các lần cộng bị mất. Có mutex thì luôn đủ 200 000, đổi lại chậm hơn nhiều vì mỗi lần cộng phải lấy và trả khóa, nhiều lần phải chờ nhân kia. Bài học: khóa đúng chỗ cần khóa và giữ khóa thật ngắn; dữ liệu chảy một chiều từ task này sang task kia thì dùng queue, không cần biến dùng chung.

Với biến đếm đơn giản dùng trong cả ngắt và task, ESP32 còn có khóa quay portMUX_TYPE với portENTER_CRITICAL / portEXIT_CRITICAL: nhanh hơn mutex nhưng tắt ngắt trong lúc giữ khóa, nên chỉ dùng cho vài lệnh.

6. Chọn công cụ nào

Cần làmDùngGhi chú
Chuyển số đo, lệnh từ task này sang task kiaQueueGửi struct theo giá trị
Ngắt báo cho task "có việc"Semaphore nhị phân (hoặc task notification)Trong ngắt dùng hàm FromISR
Chờ đủ N việc xong, hoặc đếm tài nguyên còn trốngSemaphore đếm
Nhiều task dùng chung Serial, I2C, biến lớnMutexLấy rồi phải trả; giữ ngắn
Vài lệnh dùng chung giữa ngắt và taskportMUX + critical sectionKhông gọi hàm chậm bên trong

7. Xử lý lỗi thường gặp

Hiện tượngNguyên nhân thường gặpCách xử lý
Chương trình đứng hẳnHai task chờ khóa của nhau (khóa chéo); quên GiveLuôn lấy các khóa theo cùng một thứ tự; Give ở mọi nhánh
Khởi động lại khi bấm nút, báo lỗi trong ngắtGọi hàm không có FromISR, hoặc Serial trong ngắtNgắt chỉ dựng cờ; việc khác để task làm
"queue con 0 cho", số lần đầy tăngTask xử lý chậm hơn task đọcTăng ưu tiên task xử lý; tăng số ô; giảm việc mỗi mẫu
Một lần bấm nút đếm nhiều lầnDội phím tạo nhiều ngắtChờ 50 ms rồi xóa các cờ thừa như trong bài
Số đọc từ queue lạ, thay đổi lung tungGửi con trỏ tới biến cục bộGửi giá trị (struct) thay vì con trỏ

8. Xem thêm

  • Ngắt GPIO trên ESP32: các quy tắc viết hàm ngắt.
  • I2C là gì: bus dùng chung cần mutex khi nhiều task cùng đọc cảm biến.
  • Bài FreeRTOS trên ESP32 (task, độ ưu tiên, hai nhân) ở đầu loạt ESP32 nâng cao.

Tin tức liên quan

Modbus RTU (phần 2): ESP32 làm thiết bị tớ, tự xử lý khung tin và mã lỗi
Modbus RTU (phần 2): ESP32 làm thiết bị tớ, tự xử lý khung tin và mã lỗi

Hướng dẫn biến ESP32 thành thiết bị tớ Modbus RTU qua RS-485: lập bảng thanh ghi, tách khung bằng khoảng lặng, kiểm tra CRC, xử lý mã hàm 0x01, 0x03, 0x05, 0x06, 0x10 và trả lời mã lỗi, không dùng thư viện.

Điện một chiều DC, xoay chiều AC và an toàn khi làm việc gần điện 220 V
Điện một chiều DC, xoay chiều AC và an toàn khi làm việc gần điện 220 V

Điện một chiều và xoay chiều khác nhau thế nào, vì sao điện lưới 220 V có đỉnh 311 V, bộ nguồn đổi 220 V thành 5 V ra sao, vì sao điện giật nguy hiểm, CB, chống giật và quy tắc khi dùng relay 220 V.

Modbus TCP/IP (phần 1): phần đầu MBAP và ESP32 làm máy chủ qua Wi-Fi
Modbus TCP/IP (phần 1): phần đầu MBAP và ESP32 làm máy chủ qua Wi-Fi

Giải thích Modbus TCP/IP: phần đầu MBAP, cổng 502, điểm khác với Modbus RTU. Kèm chương trình ESP32 làm máy chủ Modbus TCP qua Wi-Fi với 10 thanh ghi giữ và 8 cuộn, tự phân tích khung, không dùng thư viện.

Cảm biến độ ẩm: điện dung, điện trở, ẩm kế khô ướt – cấu tạo, nguyên lý, chế tạo và điểm sương
Cảm biến độ ẩm: điện dung, điện trở, ẩm kế khô ướt – cấu tạo, nguyên lý, chế tạo và điểm sương

Cảm biến độ ẩm hoạt động thế nào: độ ẩm tương đối, tuyệt đối, điểm sương; cảm biến điện dung polyme, điện trở, ẩm kế tóc, khô ướt, gương lạnh; chế tạo và đọc SHT31 bằng ESP32.

Điện áp, dòng điện, điện trở và định luật Ohm: hiểu bằng ví dụ và đo bằng ESP32
Điện áp, dòng điện, điện trở và định luật Ohm: hiểu bằng ví dụ và đo bằng ESP32

Điện áp, dòng điện, điện trở là gì, đơn vị và tiền tố, định luật Ohm U = I × R với ví dụ, đường U–I của điện trở và LED, thí nghiệm kiểm chứng định luật Ohm bằng DAC và ADC của ESP32.

Vali đựng flycam: chọn vali chống sốc cho flycam cỡ nhỏ, tay điều khiển và pin
Vali đựng flycam: chọn vali chống sốc cho flycam cỡ nhỏ, tay điều khiển và pin

Chọn vali đựng flycam: vì sao cần vali cứng chống nước, gợi ý cỡ vali theo nhóm flycam gập, xếp xốp cho máy, tay điều khiển, pin, và những điều cần nhớ về pin khi cất hay đi máy bay.

Cấu tạo máy CNC mini và máy khắc laser: khung, ray trượt, truyền động, đầu công cụ, bộ điều khiển
Cấu tạo máy CNC mini và máy khắc laser: khung, ray trượt, truyền động, đầu công cụ, bộ điều khiển

Cấu tạo máy CNC kiểu cổng và máy khắc laser khung hở: khung nhôm định hình, ray dẫn hướng, vít me và dây đai, động cơ bước, spindle, module laser, phần điện và ba kiểu kết cấu.

Loa trợ lý AI trên ESP32-S3: cấu tạo phần cứng, cách hoạt động, độ trễ, quyền riêng tư, thử âm thanh
Loa trợ lý AI trên ESP32-S3: cấu tạo phần cứng, cách hoạt động, độ trễ, quyền riêng tư, thử âm thanh

Loa trợ lý AI tự làm bằng ESP32-S3 hoạt động thế nào: micro I2S, mạch khuếch đại, phần chạy trên chip và trên máy chủ, vì sao có độ trễ, dữ liệu đi đâu, chương trình thử ghi âm, phát lại.


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