Firmware chạy trên MCU thu thập dữ liệu qua UART, hiển thị lên LCD, và cần cập nhật OTA định kỳ — nếu tầng nền (ISR, task, watchdog) không vững, mọi tính năng phía trên đều rủi ro treo máy ngẫu nhiên. Bài này dành cho kỹ sư firmware dùng FreeRTOS đang thiết kế hoặc debug luồng ISR → queue → task, cần hiểu watchdog theo task, deadlock giữa mutex, và cách đọc đúng số đo heap.
#Bối cảnh: vì sao ISR + UART + FreeRTOS hay vỡ trận
Interrupt Service Routine (ISR) có ba giới hạn cứng trong FreeRTOS: không được gọi API blocking (ví dụ chờ mutex vô hạn), không được cấp phát heap động (malloc/pvPortMalloc thông thường), và phải thoát càng nhanh càng tốt để không chặn các ngắt khác. Vi phạm bất kỳ điều nào cũng dẫn đến hành vi không xác định hoặc treo hệ thống.
UART tốc độ cao — log debug, giao tiếp Modbus, hoặc luồng cảm biến liên tục — tạo áp lực ngắt dồn dập. Nếu task xử lý dữ liệu chậm hơn tốc độ byte đến, buffer nhận sẽ tràn và dữ liệu mất mà không có cảnh báo rõ ràng, trừ khi có cơ chế đo đếm tràn.
Bài này là bài mở đầu series: trước khi nói tới OTA hay kết nối mạng ở các bài sau, firmware phải chứng minh được nó "sống" ổn định — không deadlock, không rò heap, không có task nào âm thầm bị treo.
#Đưa dữ liệu từ ISR ra task: queue vs ring buffer
Cách chuẩn để đưa dữ liệu từ ISR ra task là dùng nhóm API có hậu tố ...FromISR mà FreeRTOS cung cấp riêng cho ngữ cảnh ngắt (theo tài liệu FreeRTOS chính thức, phần API Reference — không suy diễn tên hàm cụ thể ở đây vì có thể khác nhau giữa các phiên bản). Nhóm hàm này không blocking và an toàn gọi trong ISR.
Kích thước queue phải được tính dựa trên tốc độ baud của UART và thời gian trễ tối đa mà task xử lý có thể gặp phải (do bị các task ưu tiên cao hơn chiếm CPU) — tính riêng cho từng thiết bị theo baud rate và độ trễ task thực tế đo được, không có công thức chung áp dụng mọi trường hợp. Nguyên tắc chung: queue phải đủ lớn để chứa dữ liệu tích tụ trong khoảng thời gian task bị trễ dài nhất có thể xảy ra, cộng thêm biên an toàn.
Một nguyên tắc quan trọng: ISR không nên copy dữ liệu lớn. ISR chỉ nên đẩy một con trỏ, một index, hoặc một byte/struct nhỏ vào queue; việc parse, xử lý nội dung để lại cho task.
// Minh hoạ, chưa biên dịch — ISR UART đẩy byte vào queue
void UART_RxISR(void) {
uint8_t rxByte = UART_ReadDataRegister(); // đọc thanh ghi dữ liệu, tuỳ MCU
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// Gửi vào queue, không blocking, an toàn trong ISR
xQueueSendFromISR(uartRxQueue, &rxByte, &xHigherPriorityTaskWoken);
// Yêu cầu chuyển context ngay nếu task nhận có priority cao hơn
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// Task xử lý, chạy ngoài ngữ cảnh ngắt
void UART_RxTask(void *pvParameters) {
uint8_t rxByte;
for (;;) {
if (xQueueReceive(uartRxQueue, &rxByte, portMAX_DELAY) == pdPASS) {
// Parse khung tin, xử lý nội dung ở đây — không trong ISR
ProcessByte(rxByte);
}
}
}#Watchdog theo task: tại sao watchdog toàn cục không đủ
Watchdog phần cứng chỉ biết CPU còn "sống" — nó được reset (kick) từ một điểm nào đó trong code, thường là task nền hoặc idle hook. Vấn đề: nếu watchdog được kick từ một task độc lập, nó không phản ánh việc các task khác có đang treo hay không. CPU vẫn chạy, watchdog vẫn được kick đúng hạn, nhưng task xử lý UART hoặc LCD có thể đã bị deadlock từ lâu.
Giải pháp là watchdog theo task (task-level heartbeat): mỗi task quan trọng tự "báo danh" theo chu kỳ — ví dụ ghi timestamp hoặc tăng một counter riêng — và có một task giám sát (watchdog task) đọc các heartbeat này, so sánh với chu kỳ kỳ vọng, và chỉ kick watchdog phần cứng khi tất cả các task đều báo danh đúng hạn.
| Task | Chu kỳ heartbeat kỳ vọng | Hành động khi timeout |
|---|---|---|
| UART Rx Task | 500 ms (ví dụ minh hoạ) | Log lỗi, thử reset queue, nếu lặp lại → reset hệ thống |
| LCD Update Task | 1 s (ví dụ minh hoạ) | Log lỗi, bỏ qua khung hình hiện tại, cảnh báo |
| Network/OTA Task | 5 s (ví dụ minh hoạ) | Huỷ tác vụ đang chờ, quay lại trạng thái an toàn |
| Watchdog Task (giám sát) | Ngắn hơn tất cả các task trên | Không kick watchdog phần cứng nếu có task timeout |
Một điểm dễ bị bỏ qua: task ưu tiên thấp bị đói CPU (starvation) — do các task ưu tiên cao chiếm dụng liên tục — cũng có thể khiến nó không kịp báo danh, dù bản thân task không "treo" theo nghĩa logic bị kẹt. Watchdog theo task giúp phát hiện cả trường hợp này, còn watchdog toàn cục thì không.
#Deadlock giữa UART và LCD: kịch bản điển hình
Kịch bản kinh điển: Task UART giữ mutex A (bảo vệ buffer UART) và đang chờ mutex B (bảo vệ LCD) để cập nhật hiển thị trạng thái kết nối. Cùng lúc, Task LCD giữ mutex B và đang chờ mutex A để đọc dữ liệu mới nhất từ UART hiển thị lên màn hình. Cả hai chờ nhau vô hạn — deadlock.
Dấu hiệu nhận biết trên thiết bị thật:
- Hai task cùng ở trạng thái Blocked khi xem qua công cụ debug/trace của FreeRTOS.
- CPU idle cao bất thường — vì không task nào chạy được, CPU rơi vào idle task liên tục.
- Watchdog theo task kích hoạt dù CPU "rảnh" — đây là tín hiệu mâu thuẫn rất đặc trưng: CPU không bận nhưng có task không báo danh.
Giải pháp:
- Quy tắc thứ tự lấy mutex cố định toàn hệ thống — ví dụ luôn lấy mutex UART trước mutex LCD, không có ngoại lệ ở bất kỳ task nào. Đây là cách triệt để nhất.
- Dùng timeout khi lấy mutex thay vì chờ vô hạn (
portMAX_DELAY). Nếu không lấy được mutex trong thời gian quy định, task rollback, log lỗi, và thử lại — tránh treo vĩnh viễn.
Sơ đồ minh hoạ: Task UART → giữ Mutex A → chờ Mutex B; Task LCD → giữ Mutex B → chờ Mutex A — hai mũi tên chờ chéo tạo thành vòng khép kín, đây chính là điều kiện deadlock.
#Đo heap còn lại: công cụ và cách đọc số đúng
FreeRTOS cung cấp hàm đo mức heap còn lại thấp nhất từng đạt được trong suốt quá trình chạy (watermark tối thiểu) — cần trích dẫn đúng mục trong tài liệu FreeRTOS chính thức, phần liên quan đến quản lý heap (heap_4/heap_5), không suy diễn tên hàm cụ thể ở đây.
Hai điều cần lưu ý khi đọc số:
- Phân mảnh heap (fragmentation): tổng số byte "còn lại" không đảm bảo có thể cấp phát một block lớn liên tục. Heap có thể còn 10 KB tổng nhưng không cấp phát được block 4 KB nếu bị chia nhỏ thành nhiều mảnh rời rạc.
- Đo một lần không có ý nghĩa. Cần đo tại nhiều thời điểm: lúc boot, sau khi kết nối mạng, sau khi chạy OTA — để có đường cơ sở (baseline) thực tế, thay vì kết luận từ một con số đơn lẻ.
#Chúng tôi đã gặp gì trên thiết bị thật
Ngưỡng "sức khỏe" hệ thống trong logic kiểm tra trước khi áp OTA được đặt ở 20 KB — cao hơn mức heap thực tế đang chạy ổn định. Ngưỡng này được chọn theo cảm giác "an toàn", không dựa trên số đo thực tế của thiết bị.
Hậu quả: mọi ảnh OTA mới, dù hợp lệ và đã qua kiểm thử, đều bị logic kiểm tra heap đánh giá "không đủ heap" và bị rollback — dù thiết bị vẫn đang chạy ổn định ở mức heap thấp hơn ngưỡng đó từ trước. Đây là rollback giả: hệ thống tự chặn bản cập nhật tốt vì ngưỡng cảnh báo sai, không phải vì thiết bị thật sự thiếu tài nguyên.
Bài học: ngưỡng cảnh báo — heap, watchdog timeout, hay bất kỳ chỉ số "sức khỏe" nào — phải được đặt dựa trên số đo thực đa điểm trên thiết bị thật, không phải giá trị "cảm thấy an toàn" khi thiết kế trên giấy. Cách khắc phục thực tế — hạ ngưỡng hay tối ưu heap — cần được ghi lại kèm nguồn số đo (log hệ thống hay báo cáo nội bộ) khi áp dụng cho từng thiết bị cụ thể.
#Checklist khi thiết kế
- ISR chỉ đẩy dữ liệu tối thiểu (byte/con trỏ/index) vào queue, không gọi API blocking, không cấp phát heap động
- Kích thước queue UART được tính theo baud rate và độ trễ tối đa của task, có log đếm số lần queue đầy/mất dữ liệu
- Mỗi task quan trọng (UART, LCD, Network/OTA) có heartbeat riêng, giám sát bởi một watchdog task tổng hợp
- Watchdog phần cứng chỉ được kick khi tất cả heartbeat task đều đúng hạn, không kick từ một task độc lập
- Thứ tự lấy mutex được quy định thành văn bản và áp dụng nhất quán ở mọi task, kèm timeout khi lấy mutex thay vì chờ vô hạn
- Heap được đo watermark tại nhiều thời điểm (boot, sau kết nối mạng, sau OTA), có log theo thời gian
- Ngưỡng cảnh báo (heap, timeout) được đặt dựa trên số đo thực đa điểm trên thiết bị thật, có ghi rõ nguồn số đo
- Có quy trình review khi thay đổi ngưỡng cảnh báo, không sửa "theo cảm giác" mà không đo lại
#Đọc tiếp
Bài kế tiếp trong series: "OTA an toàn: phân vùng, rollback thật, và chữ ký firmware" — bàn về cách thiết kế cơ chế cập nhật không rơi vào tình trạng rollback giả như trường hợp heap ở trên, cùng các nguyên tắc phân vùng bộ nhớ và xác thực ảnh firmware trước khi áp dụng.