FreeRTOS trên STM32: Task, Queue, Signal và cách tổ chức firmware cho cả team
Triển khai dự án FreeRTOS trên STM32.
1. Tại sao cần RTOS - và khi nào không cần
Trên POS system mà tôi đang làm, firmware phải xử lý đồng thời nhiều thứ: nhận lệnh từ máy tính thu ngân qua UART, giao tiếp với máy in qua UART khác, đọc đầu đọc thẻ, cập nhật màn hình hiển thị, feed watchdog, và thỉnh thoảng ghi config xuống Flash. Nếu tất cả chạy trong một vòng while(1) lớn, chắc chắn sẽ có lúc một việc block việc khác.
FreeRTOS giải quyết bằng cách cho mỗi thứ chạy trong task riêng. Scheduler quyết định task nào được chạy dựa trên priority và trạng thái của từng task.
Nhưng cũng nên nói thẳng: RTOS không phải câu trả lời cho mọi project. Nếu firmware chỉ có một flow tuyến tính - đọc sensor, xử lý, gửi kết quả - super loop với interrupt là đủ và đơn giản hơn nhiều. RTOS thêm complexity, thêm RAM overhead (stack cho mỗi task), và thêm các kiểu bug mới mà bài này sẽ nói đến.
2. Task - unit cơ bản của FreeRTOS
Mỗi task là một hàm C chạy trong vòng lặp vô tận, có stack riêng và trạng thái riêng:
void TaskPrinter(void *argument)
{
/* Init một lần */
Printer_Init();
for (;;) {
/* Chờ có lệnh in */
PrintJob_t job;
if (xQueueReceive(xPrinterQueue, &job, portMAX_DELAY) == pdTRUE) {
Printer_Execute(&job);
}
}
}
Tạo task bằng xTaskCreate() hoặc qua CubeMX (dùng CMSIS RTOS v2 wrapper):
/* Bare FreeRTOS API */
xTaskCreate(
TaskPrinter, /* Hàm task */
"Printer", /* Tên debug */
256, /* Stack size - tính bằng WORDS, không phải bytes */
NULL, /* Parameter */
3, /* Priority: cao hơn số = cao hơn ưu tiên */
&xPrinterTaskHandle
);
/* Hoặc CMSIS RTOS v2 (CubeMX generate) */
printerTaskHandle = osThreadNew(TaskPrinter, NULL, &printerTask_attributes);
Stack size - hay bị đặt sai
Stack tính bằng words (4 bytes trên Cortex-M), không phải bytes. 256 = 1024 bytes.
Đặt stack quá nhỏ → stack overflow → crash không rõ nguyên nhân, thường rất khó debug vì crash xảy ra xa điểm gây ra. FreeRTOS có watermark để đo mức sử dụng stack thực tế:
/* Xem stack còn lại bao nhiêu - gọi khi system đang chạy bình thường */
UBaseType_t remaining = uxTaskGetStackHighWaterMark(NULL); /* NULL = task hiện tại */
/* Nếu remaining nhỏ (< 20 words) → tăng stack */
Trong phát triển, bật configCHECK_FOR_STACK_OVERFLOW trong FreeRTOSConfig.h:
#define configCHECK_FOR_STACK_OVERFLOW 2 /* Method 2: kiểm tra kỹ hơn */
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
/* Task nào overflow stack sẽ vào đây */
/* Blink LED error hoặc log tên task trước khi hang */
Error_Handler();
}
3. Priority và scheduler - hiểu để không bị bất ngờ
FreeRTOS dùng preemptive priority scheduler: task có priority cao nhất và đang ở trạng thái Ready sẽ chiếm CPU. Task đang chạy bị preempt ngay khi có task priority cao hơn sẵn sàng.
Priority cao ─── Task_CardReader (priority 5)
│ đang block chờ card
Priority vừa ─── Task_Printer (priority 3) ← đang chạy
Priority thấp ── Task_UI (priority 1) ← bị preempt
Priority thấp ── Task_Logger (priority 1)
Nguyên tắc đặt priority cho POS system:
| Mục | Giá trị | Ghi chú |
|---|---|---|
| Priority cao (4-5) | Time-critical: đọc thẻ, nhận lệnh từ host | Task này nếu trễ là ảnh hưởng trực tiếp đến giao dịch. |
| Priority vừa (3) | Output: in ấn, gửi response | Quan trọng nhưng chịu được vài ms delay. |
| Priority thấp (1-2) | Background: UI update, logger, watchdog feed | Chạy khi không có task quan trọng nào cần CPU. |
| Priority 0 | Idle task của FreeRTOS | Không đặt task application ở đây. |
Priority inversion là bug hay gặp: task priority thấp đang giữ mutex, task priority cao cần mutex đó phải chờ, trong khi task priority trung bình chen vào chạy → task priority cao bị block bởi task priority thấp gián tiếp qua task priority trung bình. FreeRTOS hỗ trợ priority inheritance cho mutex để giảm thiểu vấn đề này - enable bằng configUSE_MUTEXES 1.
4. Giao tiếp giữa task - dùng cái gì?
Đây là câu hỏi hay gây nhầm lẫn nhất. FreeRTOS có nhiều primitive, mỗi cái phù hợp cho một usecase khác nhau.
Queue - lựa chọn mặc định cho data transfer
Queue truyền dữ liệu có cấu trúc từ producer sang consumer. Thread-safe, ISR-safe (với API FromISR), có thể chứa nhiều item.
/* Tạo queue chứa tối đa 10 PrintJob */
xPrinterQueue = xQueueCreate(10, sizeof(PrintJob_t));
/* Producer: gửi job vào queue */
PrintJob_t job = { .type = PRINT_RECEIPT, .data = &receipt };
xQueueSend(xPrinterQueue, &job, pdMS_TO_TICKS(100)); /* Timeout 100ms */
/* Consumer: nhận job từ queue (block đến khi có) */
PrintJob_t received_job;
xQueueReceive(xPrinterQueue, &received_job, portMAX_DELAY);
Queue copy dữ liệu - receiver nhận bản copy, không phải pointer vào bộ nhớ của sender. An toàn hơn nhưng cần chú ý size của item: queue item lớn (>32 bytes) thường nên truyền pointer thay vì copy.
Semaphore - signaling, không phải data
Semaphore dùng để báo hiệu (signal), không để truyền dữ liệu. Có hai loại:
Binary semaphore: một “token” - take nếu có token, give để trả token. Dùng để signal event.
/* ISR báo cho task biết có data mới */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xUartRxSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
/* Task chờ signal */
void TaskUartRx(void *arg) {
for (;;) {
xSemaphoreTake(xUartRxSemaphore, portMAX_DELAY);
/* Xử lý data */
ProcessUartData();
}
}
Counting semaphore: nhiều token - dùng cho resource pool hoặc đếm event.
Mutex - mutual exclusion cho shared resource
Mutex dùng để bảo vệ shared resource - đảm bảo chỉ một task access tại một thời điểm. Khác binary semaphore ở chỗ có ownership (chỉ task đã take mới được give) và có priority inheritance.
/* Bảo vệ SPI bus dùng chung giữa nhiều task */
if (xSemaphoreTake(xSpiMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
SPI_Transfer(&data);
xSemaphoreGive(xSpiMutex);
}
Event Group - chờ nhiều điều kiện
Event Group dùng khi một task cần chờ nhiều event cùng lúc hoặc theo tổ hợp điều kiện.
/* Định nghĩa các bit event */
#define EVT_CARD_READY (1 << 0)
#define EVT_PRINTER_READY (1 << 1)
#define EVT_HOST_APPROVED (1 << 2)
/* Task Transaction chờ cả 3 điều kiện */
EventBits_t bits = xEventGroupWaitBits(
xTransactionEvents,
EVT_CARD_READY | EVT_PRINTER_READY | EVT_HOST_APPROVED,
pdTRUE, /* Clear bits sau khi nhận */
pdTRUE, /* Phải có TẤT CẢ bits (AND, không phải OR) */
pdMS_TO_TICKS(5000)
);
if ((bits & (EVT_CARD_READY | EVT_PRINTER_READY | EVT_HOST_APPROVED))
== (EVT_CARD_READY | EVT_PRINTER_READY | EVT_HOST_APPROVED)) {
StartTransaction();
}
5. Bug signal/wait trong FreeRTOS v1 - và cách v2 giải quyết
Đây là bug tinh tế mà tôi gặp khá sớm khi dùng binary semaphore để signal giữa ISR và task.
Vấn đề với binary semaphore
Binary semaphore trong FreeRTOS v1 (thực chất là queue 1 item):
ISR fires → xSemaphoreGiveFromISR() → "token" = 1
ISR fires lại (trước task kịp take) → "token" vẫn = 1 (không tăng lên 2!)
Task wakes → xSemaphoreTake() → xử lý 1 lần
→ bỏ lỡ lần thứ 2!
Nếu ISR fires 3 lần liên tiếp trước khi task kịp xử lý, task chỉ xử lý 1 lần. Hai lần kia bị mất im lặng - không có error, không có log, chỉ là data bị bỏ qua.
Với POS system, đây có thể là bỏ qua một phần của giao dịch thẻ - rất nghiêm trọng.
/* Pattern nguy hiểm với binary semaphore */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
xSemaphoreGiveFromISR(xSem, &woken); /* Lần 2 give bị ignore nếu sem đã = 1 */
portYIELD_FROM_ISR(woken);
}
void TaskUart(void *arg) {
for (;;) {
xSemaphoreTake(xSem, portMAX_DELAY); /* Chỉ wake 1 lần dù ISR give 3 lần */
ProcessData(); /* Xử lý dữ liệu - nhưng thiếu 2 lần */
}
}
Giải pháp v1: counting semaphore
Dùng counting semaphore thay vì binary - mỗi Give tăng count, Take giảm count:
xUartSemaphore = xSemaphoreCreateCounting(10, 0); /* Max 10, init 0 */
/* ISR: mỗi lần give tăng count */
xSemaphoreGiveFromISR(xUartSemaphore, &woken);
/* Task: mỗi lần take giảm count, block khi count = 0 */
while (xSemaphoreTake(xUartSemaphore, portMAX_DELAY) == pdTRUE) {
ProcessData();
}
Giải pháp tốt hơn: Queue thay vì semaphore
Cách sạch nhất là dùng queue - mỗi event là một item trong queue, không bị mất:
/* ISR nhận byte và đẩy vào queue */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
uint8_t byte = huart->pRxBuffPtr[-1]; /* Byte vừa nhận */
xQueueSendFromISR(xRxQueue, &byte, &woken);
portYIELD_FROM_ISR(woken);
HAL_UART_Receive_IT(huart, &rxByte, 1); /* Re-arm */
}
/* Task xử lý từng byte */
void TaskUartRx(void *arg) {
uint8_t byte;
for (;;) {
xQueueReceive(xRxQueue, &byte, portMAX_DELAY);
Protocol_ProcessByte(byte);
}
}
Queue không bị mất item - nếu đầy thì xQueueSendFromISR trả errQUEUE_FULL có thể detect và đếm.
FreeRTOS v2 (CMSIS RTOS v2) - Task Notification
FreeRTOS v2 thêm Task Notification - lightweight notification với 32-bit value, không cần tạo object riêng, hiệu quả hơn semaphore:
/* ISR notify task */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
BaseType_t woken = pdFALSE;
/* Notify với action INCREMENT - mỗi notify tăng value 1 */
vTaskNotifyGiveFromISR(xUartTaskHandle, &woken);
portYIELD_FROM_ISR(woken);
}
/* Task wait notification */
void TaskUartRx(void *arg) {
for (;;) {
/* ulTaskNotifyTake clear-on-exit, giá trị trả về = số lần notify */
uint32_t count = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
/* count = số lần ISR đã fire từ lần take trước */
for (uint32_t i = 0; i < count; i++) {
ProcessNextByte();
}
}
}
ulTaskNotifyTake với pdTRUE (clear on exit) giải quyết đúng vấn đề của binary semaphore - value tích lũy số lần notify, task xử lý đúng số lần đó.
6. ISR - những gì không được làm
Đây là danh sách cần nhớ thuộc, không phải chỉ đọc qua:
/* TRONG ISR - KHÔNG được dùng */
xSemaphoreTake() → dùng xSemaphoreTakeFromISR() nếu cần
xQueueSend() → dùng xQueueSendFromISR()
xTaskNotifyGive() → dùng vTaskNotifyGiveFromISR()
vTaskDelay() → KHÔNG bao giờ - ISR không được block
printf() / vsnprintf() → nặng, không safe trong ISR
HAL_UART_Transmit() → blocking, không bao giờ trong ISR
malloc() / free() → không thread-safe trong ISR
/* TRONG ISR - luôn kèm portYIELD_FROM_ISR */
void MY_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendFromISR(xQueue, &data, &xHigherPriorityTaskWoken);
/* Nếu send vừa wake task priority cao hơn task hiện tại,
* yield ngay để task đó chạy khi exit ISR */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
Thiếu portYIELD_FROM_ISR không gây crash ngay - nhưng task priority cao hơn sẽ bị delay đến SysTick tick tiếp theo, tạo ra latency không cần thiết.
7. Race condition - và cách tránh
Race condition xảy ra khi hai context (task hoặc ISR) access cùng một data mà không có protection.
Ví dụ: buffer size bị tính sai
/* Shared counter - không có protection */
static uint32_t s_pending_count = 0;
/* Task A tăng */
void TaskA(void *arg) {
s_pending_count++; /* Read-Modify-Write: 3 instructions */
}
/* ISR cũng tăng */
void MY_IRQHandler(void) {
s_pending_count++; /* Có thể xảy ra giữa Read và Write của TaskA */
}
Trên Cortex-M, s_pending_count++ không phải atomic - là LDR, ADD, STR. Nếu ISR xảy ra giữa LDR và STR của task, một lần tăng bị mất.
Cách fix cho counter chia sẻ giữa task và ISR:
/* Option 1: taskENTER_CRITICAL / taskEXIT_CRITICAL
* Disable interrupt tạm thời - dùng cho đoạn code rất ngắn */
taskENTER_CRITICAL();
s_pending_count++;
taskEXIT_CRITICAL();
/* Option 2: Atomic built-in (GCC) */
__atomic_add_fetch(&s_pending_count, 1, __ATOMIC_SEQ_CST);
/* Option 3: Tách thành task-only và ISR-only variable
* ISR chỉ ghi, task đọc và reset - nếu design cho phép */
Cách fix cho shared buffer giữa các task:
/* Mutex cho mọi thao tác trên shared buffer */
if (xSemaphoreTake(xBufferMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
Buffer_Append(&sharedBuffer, data);
xSemaphoreGive(xBufferMutex);
} else {
/* Timeout - log error, không crash */
}
Volatile không đủ
Nhiều người dùng volatile để fix race condition - đây là hiểu nhầm phổ biến:
/* KHÔNG đủ để tránh race condition */
volatile uint32_t s_count = 0;
s_count++; /* Vẫn là 3 instructions, volatile chỉ ngăn compiler optimize */
volatile chỉ đảm bảo compiler đọc/ghi thật sự từ memory, không cache trong register. Nó không làm read-modify-write thành atomic.
8. Cách chia task cho cả team - mỗi người một task
Đây là phần tôi thấy có giá trị nhất khi làm việc nhóm trên POS firmware.
Vấn đề thực tế: khi 2-3 người cùng phát triển một firmware lớn, nếu code không được tách rõ ràng, người này sửa function người kia đang dùng, merge conflict liên tục, và bug từ chỗ không ngờ tới.
Giải pháp: mỗi task là một module độc lập.
firmware/
├─ tasks/
│ ├─ task_card_reader.c (A phụ trách)
│ ├─ task_printer.c (B phụ trách)
│ ├─ task_host_comm.c (C phụ trách)
│ ├─ task_ui.c (A phụ trách)
│ └─ task_watchdog.c (shared, ít thay đổi)
├─ ipc/
│ ├─ ipc_queues.c/.h (khai báo tất cả queue, semaphore, event group)
│ └─ ipc_types.h (các struct truyền qua queue)
├─ hal_wrapper/
│ ├─ uart_wrapper.c/.h (wrap HAL UART)
│ ├─ spi_wrapper.c/.h (wrap HAL SPI)
│ └─ gpio_wrapper.c/.h
└─ main.c (chỉ init và tạo task)
Nguyên tắc để task thật sự độc lập
1. Task chỉ nhận input qua queue, chỉ output qua queue - không gọi thẳng function của task khác:
/* ĐÚng: TaskHostComm gửi lệnh in vào queue của TaskPrinter */
PrintJob_t job = BuildPrintJob(transaction);
xQueueSend(xPrinterQueue, &job, pdMS_TO_TICKS(200));
/* SAI: TaskHostComm gọi thẳng hàm của Printer module */
Printer_Execute(&job); /* Ai đảm bảo Printer đang sẵn sàng? */
2. Mỗi task sở hữu peripheral của mình - không ai khác được access thẳng:
/* TaskPrinter sở hữu UART2 - chỉ nó được dùng */
/* TaskHostComm sở hữu UART1 - chỉ nó được dùng */
/* Nếu cần share (ví dụ SPI bus), dùng mutex */
3. ipc/ipc_queues.h là “hợp đồng” giữa các task - ai cần giao tiếp phải thỏa thuận qua đây:
/* ipc/ipc_queues.h */
extern QueueHandle_t xPrinterQueue; /* TaskPrinter nhận từ đây */
extern QueueHandle_t xHostRespQueue; /* TaskHostComm nhận từ đây */
extern EventGroupHandle_t xSystemEvents;
/* ipc/ipc_types.h */
typedef struct {
PrintJobType_t type;
uint8_t data[128];
uint16_t data_len;
} PrintJob_t;
typedef struct {
HostRespCode_t code;
uint8_t payload[64];
uint16_t payload_len;
} HostResponse_t;
Lợi ích thực tế: khi B đang sửa task_printer.c, A không cần biết chi tiết bên trong - chỉ cần biết PrintJob_t có những gì và queue timeout bao nhiêu. Merge conflict gần như không xảy ra vì mỗi người chỉ sửa file của mình.
9. HAL wrapper - đầu tư nhỏ, lợi ích lớn
Đây là thứ nhiều dự án bỏ qua vì “tốn thêm một lớp code” - nhưng khi cần port firmware sang chip khác, sẽ hối hận.
Không có wrapper: khi cần thay huart2 bằng huart3, hoặc port sang chip khác có HAL khác, phải tìm và sửa tất cả nơi gọi HAL trực tiếp trong cả project.
Có wrapper: chỉ cần sửa trong một file uart_wrapper.c.
/* hal_wrapper/uart_wrapper.h */
typedef struct {
UART_HandleTypeDef *huart;
} UartDevice_t;
HAL_StatusTypeDef Uart_Send(UartDevice_t *dev, const uint8_t *data, uint16_t len, uint32_t timeout);
HAL_StatusTypeDef Uart_SendIT(UartDevice_t *dev, const uint8_t *data, uint16_t len);
HAL_StatusTypeDef Uart_ReceiveIT(UartDevice_t *dev, uint8_t *buf, uint16_t len);
void Uart_RegisterRxCallback(UartDevice_t *dev, void (*cb)(UartDevice_t *));
/* hal_wrapper/uart_wrapper.c */
HAL_StatusTypeDef Uart_Send(UartDevice_t *dev, const uint8_t *data,
uint16_t len, uint32_t timeout)
{
return HAL_UART_Transmit(dev->huart, (uint8_t*)data, len, timeout);
}
Task dùng wrapper, không dùng HAL trực tiếp:
/* Trong task_printer.c */
static UartDevice_t s_printer_uart = { .huart = &huart2 };
void TaskPrinter(void *arg) {
for (;;) {
PrintJob_t job;
xQueueReceive(xPrinterQueue, &job, portMAX_DELAY);
Uart_Send(&s_printer_uart, job.data, job.data_len, 1000);
}
}
Khi cần thay huart2 → huart3: sửa một dòng init. Khi cần port sang chip dùng HAL khác: chỉ sửa uart_wrapper.c.
10. Checklist FreeRTOS cho POS firmware
Trước khi deploy FreeRTOS firmware
11. Tóm tắt
FreeRTOS không khó - nhưng có nhiều chi tiết mà sai một cái thì bug rất khó tìm:
- Task stack hay bị đặt quá nhỏ - dùng watermark để đo
- Queue là lựa chọn mặc định để truyền data giữa task và ISR
- Binary semaphore signal/wait có thể miss event - dùng counting semaphore hoặc task notification
- Task Notification của FreeRTOS v2 giải quyết vấn đề này sạch hơn
- ISR không được dùng non-FromISR API, không được block
- Race condition không fix được bằng
volatile- cần critical section hoặc atomic - Chia task theo ownership giúp nhiều người phát triển song song không đạp nhau
- HAL wrapper là một lớp mỏng đủ để port code dễ hơn rất nhiều sau này
Đọc tiếp
- UART non-blocking logger trên STM32 với DMA + Ring Buffer - ứng dụng thực tế của queue và ISR API trong logger
- Flash STM32G0: Linker Script, EEPROM Emulation và Dual Bank - tại sao RTOS tick bị ảnh hưởng khi erase Flash single-bank
- Vì sao ISR không được dùng Mutex trong FreeRTOS?
Tài liệu tham khảo
- FreeRTOS Documentation - Mastering the FreeRTOS Real Time Kernel, free PDF
- FreeRTOS API Reference - Queue, Semaphore, Task Notification API
- STM32CubeIDE FreeRTOS Guide - CMSIS RTOS v2 trên STM32
- Mastering the FreeRTOS Real Time Kernel - sách chính thức, miễn phí
Thấy nội dung này hữu ích?
Lưu lại hoặc chia sẻ cho người cũng đang học firmware, BIOS/UEFI và embedded systems.
Nội dung liên quan
Một số bài viết, ghi chú hoặc project có liên quan đến nội dung bạn vừa đọc.
UART non-blocking logger trên STM32 với DMA + Ring Buffer
Thiết kế debug UART non-blocking cho STM32: tránh printf, xử lý mất log với DMA, dùng ring buffer và DMA callback.
USB Device STM32 nâng cao: Vendor Request, Bulk, DMA và Debug với JTAG
USB Device STM32: xử lý vendor request và truyền qua DMA cho bulk và interrupt.
USB Descriptor: Phân tích từng field và HID Report Descriptor từ góc firmware
Phân tích USB Descriptor, HID Report Descriptor của thiết bị composite CDC+HID.
Đọc thêm về BIOS/UEFI
Khám phá các bài viết về BIOS/UEFI, embedded firmware, debugging và system-level thinking.