Giới thiệu
Như đã giới thiệu từ những bài trước, hệ điều hành thời gian thực (RTOS) như FreeRTOS cho phép bạn thiết kế các ứng dụng nhúng có khả năng thực hiện nhiều nhiệm vụ (task) đồng thời. Một trong những yếu tố then chốt quyết định đến hiệu suất và độ ổn định của hệ thống là cách quản lý ưu tiên (priority) và lập lịch (scheduling) các tác vụ. Nếu các bạn cứ thao thao bất tuyệt tạo các Task, đặt mức ưu tiên của các task bằng cách random hoặc đặt theo cảm tính thì rất có thể chương trình của bạn sẽ gặp các vấn đề như: chạy không đúng như luồng các bạn mong muốn ban đầu, có task chạy liên tục nhưng có những task bị lãng quên, .... Vì thế, trong bài viết này, chúng ta sẽ cùng tìm hiểu sâu về:
- Cách sử dụng và thiết lập mức ưu tiên tác vụ hợp lý
- Những lỗi phổ biến khi sử dụng ưu tiên sai cách và cách khắc phục
- Thực nghiệm lập lịch bằng code ví dụ có lỗi và giải pháp tương ứng
- Sơ đồ minh họa cách lập lịch giữa các task
1. Tổng quan về lập lịch trong FreeRTOS
FreeRTOS sử dụng bộ lập lịch ưu tiên với preemptive (có thể ngắt để nhường CPU) hoặc cooperative (tác vụ tự nhường CPU). Mỗi tác vụ có một mức ưu tiên (số nguyên không âm), càng cao thì càng được ưu tiên thực thi.
1.1. Các trạng thái của tác vụ
- Running: Đang được thực thi
- Ready: Sẵn sàng thực thi nếu CPU rảnh
- Blocked: Đang chờ một điều kiện (queue, semaphore...)
- Suspended: Bị tạm ngưng bởi
vTaskSuspend()
1.2. Cấu hình Scheduler trong FreeRTOS
FreeRTOS hỗ trợ:
- Preemptive scheduling (cấu hình
configUSE_PREEMPTION = 1
) - Time slicing (chia sẻ thời gian giữa các task cùng mức ưu tiên)
- Manual yield (dùng
taskYIELD()
để tự nhường)
2. Thiết kế mức ưu tiên tác vụ hiệu quả
Việc gán mức ưu tiên cho tác vụ không chỉ là "cao hay thấp", mà cần được đánh giá dựa trên:
- Tính chất thời gian thực: Task có yêu cầu thời gian phản hồi nhanh?
- Thời lượng xử lý: Có tiêu tốn CPU nhiều không?
- Tần suất hoạt động: Có chạy liên tục hay thỉnh thoảng mới chạy?
- Quan hệ phụ thuộc: Có phụ thuộc task khác không?
Dưới đây là một bảng gợi ý phương pháp đặt mức ưu tiên cho task mà mình hay sử dụng:
Loại tác vụ | Ưu tiên gợi ý |
---|---|
Xử lý ngắt ISR | Rất cao |
Giao tiếp cảm biến định kỳ | Trung bình |
Gửi UART định kỳ | Thấp |
Nhận UART từ ISR Thu thập, xử lý dữ liệu cảm biến khẩn cấp |
Cao |
Tác vụ nền (nền tảng hệ thống) | Rất thấp (có thể là idle) |
3. Tình huống thực tế & lỗi thường gặp
OK! Lý thuyết vậy là đủ rồi, dưới đây mình sẽ thực hành luôn cho các bạn thấy những tình huống có thể xảy ra và khắc phục nó.
Ở đây mình sử dụng esp32 và esp-idf frame-work nhé!
3.1. Tác vụ ưu tiên thấp chặn tác vụ ưu tiên cao
Tình huống khi lỗi: Ở đây mình sẽ tạo 2 task. Bài toán này có thể là bài toán một task thu thập dữ liệu cảm biến, một task xử lý dữ liệu. Mình cho 2 task có độ ưu tiên bằng nhau và đều là độ ưu tiên cao nha. Cùng xem điều gì sẽ xảy ra nhé:
void task1 (void * ctx)
{
#define Max_count 10
#define Min_count 0 for (;;) { for (int i=Min_count; i <= Max_count; i++) { if (i == Min_count) { printf ("\t====================>task1: %d\n", i); } if ( i == Max_count) { printf ("\t====================>task1: %d\n", i); } } vTaskDelay(pdMS_TO_TICKS(50)); }
} void task2 (void * ctx)
{
#define Max_count 1000
#define Min_count 0 for (;;) { for (int i=Min_count; i<Max_count; i++) { if (i == 0) { printf ("task2: %d\n", i); } if ( i == 999) { printf ("task2: %d\n", i); } } vTaskDelay(pdMS_TO_TICKS(50)); }
} void app_main()
{ xTaskCreate(task1, "task1", 2048, NULL, 3, NULL); xTaskCreate(task2, "task2", 2048*2, NULL, 3, NULL);
}
Kết quả khi chạy:
task2: 0
task2: 999 ====================>task1: 0 ====================>task1: 10
task2: 0
task2: 999 ====================>task1: 0 ====================>task1: 10
Như các bạn có thể thấy, Task 2 xử lý xong 1000 lần đếm rồi task 1 mới thực hiện nhiệm vụ của nó. Như vậy trong thực tế sẽ rất nguy hiểm nếu task 1 là task cần xử lý nhanh (giả sử như hệ thống tự lái của ô tô, khi thu dữ liệu từ sensor phát hiện có vật cản phía trước nhưng task xử lý dữ liệu sensor đó không được đặt mức ưu tiên đúng mức thì khả năng là "xin vĩnh biệt cụ" luôn
Giải pháp: Các bạn chỉ cần thay đổi mức ưu tiên của task 2 thấp hơn task 1 hoặc cho mức ưu tiên của task 1 cao hơn mức hiện tại và nhớ cần tuân theo quy tắc đã nói ở phía bên trên
3.2. Priority Inversion (Đảo ngược ưu tiên)
Tình huống lỗi:
- Task A (ưu tiên thấp) giữ tài nguyên mutex
- Task B (ưu tiên cao) cần mutex đó -> bị block
- Task C (ưu tiên trung bình) chiếm CPU -> Task A không được thực thi -> Task B bị "treo"
Kết quả: Hệ thống bị đóng băng logic do tác vụ ưu tiên cao không thể thực hiện, gây mất thời gian thực.
Giải pháp:
- Dùng Mutex với Priority Inheritance (tự động nâng ưu tiên task đang giữ mutex)
xSemaphore = xSemaphoreCreateMutex();
3.3. Starvation (đói tài nguyên)
Khi một task ưu tiên thấp không bao giờ có cơ hội chạy vì các task ưu tiên cao luôn chiếm CPU.
Giải pháp:
- Tránh dùng ưu tiên "cứng" mà không delay
- Sử dụng cơ chế time slicing hoặc cooperative yield để chia CPU
Kết luận
Lập lịch tác vụ hiệu quả và quản lý ưu tiên là kỹ năng thiết yếu khi làm việc với FreeRTOS. Việc hiểu rõ các nguy cơ như priority inversion, starvation và khả năng tối ưu tài nguyên CPU theo yêu cầu thời gian thực sẽ giúp hệ thống hoạt động ổn định và hiệu quả.
Hãy luôn kiểm tra hành vi thực tế thông qua đo đạc và theo dõi log, thay vì chỉ dựa trên dự đoán trong lý thuyết.
Bạn có thể mở rộng bài học này với việc sử dụng task notification, semaphore và event group để phối hợp giữa nhiều tác vụ trong hệ thống phức tạp hơn.