
前言
如果你寫過 STM32 或 ESP32,那其實你早就用過 FreeRTOS 了 —— STM32 的 HAL 庫內建 CMSIS-RTOS 封裝層,ESP32 的 ESP-IDF 更是直接基於 FreeRTOS 當成原生執行環境。我們平常只是呼叫一行建立任務的 API,背後那顆排程器就開始替我們安排「現在輪到誰跑」。
先講重點:不論你用哪個平台,要理解的東西都是同一套 —— 任務排程、佇列通訊、信號量同步和中斷管理。這四樣弄懂了,之後不管是加 Wi-Fi、加感測器、加 OLED,都只是把它們拼起來而已。這篇會從任務狀態機一路講到可以跑的實戰程式碼,中間順便把幾個很容易踩的坑點出來。
一、為什麼需要 RTOS?
先回答一個很多人心裡的疑問:我的程式本來就跑得好好的,為什麼要多一層作業系統?
傳統前後台系統(Super Loop)在單一任務時很好用,但當系統需要同時處理 Wi-Fi 連線、感測器讀取、按鍵掃描和螢幕更新時,輪詢式的 while(1) 很快就會讓某個任務被 Block 住。RTOS 提供了:
- 多工並行:多個任務看似同時執行
- 優先級排程:重要任務優先處理
- 同步機制:Queue、Semaphore、Mutex 讓任務安全溝通
- 模組化:每個功能獨立成任務,降低耦合
以 ESP32 為例,開機後已經有 11 個系統任務在執行(Wi-Fi、TCP/IP、Timer、IPC 等),你的應用任務只是其中之一。換句話說,真正要學的不是「怎麼寫一個 while(1)」,而是怎麼跟這 11 個鄰居排隊、讓路、交接工作。
二、任務(Task)管理
2.1 任務狀態機

圖 1:FreeRTOS Task 狀態機 — 四個核心狀態:Ready、Running、Blocked、Suspended。
- Ready(就緒):任務可以執行,等待調度器選擇
- Running(執行中):正在使用 CPU
- Blocked(阻塞):等待某個事件(Timeout、Queue、Semaphore)
- Suspended(暫停):被 vTaskSuspend() 暫停,需 vTaskResume() 恢復
看圖就知道一件事:四個狀態裡只有 Running 真的在用 CPU,其他三個都在等。所以設計任務時真正的功夫不在「讓它跑」,而在「讓它知道什麼時候該讓開」—— 該等的時候就進 Blocked,不要用一個空的迴圈去佔 CPU。
2.2 創建任務
ESP-IDF(原生 FreeRTOS API):
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static void sensor_task(void *pvParameters)
{
while (1) {
read_sensor();
vTaskDelay(pdMS_TO_TICKS(100)); // 100ms 一次
}
}
void app_main(void)
{
xTaskCreate(
sensor_task, // 任務函數
"sensor_task", // 名稱 (除錯用)
4096, // Stack 大小 (bytes)
NULL, // 參數
5, // 優先級 (0~configMAX_PRIORITIES-1)
NULL // Task handle (可選)
);
}
這六個參數值得一個一個看過:任務函式、除錯用的名稱、Stack 大小(單位是 bytes)、傳進去的參數、優先級,最後是可以拿來操作這個任務的 handle。上面給 sensor_task 的是 4096 bytes 與優先級 5,訊息每 100 ms 收一次。
STM32 + CMSIS-RTOS(FreeRTOS 封裝):
#include "cmsis_os.h"
void sensor_task(void const *argument)
{
while (1) {
read_sensor();
osDelay(100); // 100ms
}
}
osThreadDef(sensor_task, osPriorityNormal, 1, 1024, NULL);
int main(void)
{
HAL_Init();
// ...
osThreadCreate(osThread(sensor_task), NULL);
osKernelStart(); // 啟動調度器
while (1);
}
同一個概念換一組 API 而已,差別在 CMSIS-RTOS 把執行緒定義(osThreadDef)跟建立(osThreadCreate)拆成兩步,而且最後一定要呼叫 osKernelStart(),否則排程器根本不會啟動 —— 這是 STM32 新手最常見的「程式燒進去卻什麼都沒發生」。
2.3 任務優先級與搶佔
先把「誰決定現在跑誰」這件事拆開來看:

圖 2:FreeRTOS 搶佔式排程 — 高優先級 Task A 搶佔低優先級 Task B;ISR 觸發後喚醒 Task A。
FreeRTOS 使用搶佔式優先級排程(Preemptive Priority Scheduling):
- 高優先級任務就緒時,立即搶佔低優先級任務
- 同等優先級採用時間片輪轉(Round-Robin Time Slicing)
- 優先級數:0(最低)~ configMAX_PRIORITIES-1(最高)
- ESP32 預設 configMAX_PRIORITIES = 25
實務建議:優先級不是越多越好,3~5 個層級通常就夠了:
// 常用優先級分層
#define PRIO_ISR_HANDLER 10 // 硬即時任務
#define PRIO_CONTROL 8 // 控制迴路 (PID)
#define PRIO_COMM 5 // 通訊任務
#define PRIO_SENSOR 3 // 感測器讀取
#define PRIO_DISPLAY 1 // 顯示/除錯

圖 3:五層優先級示範 — 編號越大越容易搶到 CPU。
這張表的意思是:每多一層,就多一個「誰會打斷誰」的組合。值設得越高,任務越不容易被別的任務拖慢,但相對地,低優先級的工作就更容易被餓著。所以我自己的習慣是:只有真的趕時間(硬即時、控制迴路)才往上加,其餘一律壓在下面幾層。
三、Queue(佇列)— 任務間通信
Queue 是 FreeRTOS 中最基礎的 IPC(行程間通訊)機制。Task A 可以把資料放入 Queue,Task B 可以取出。Queue 是先進先出(FIFO)結構。

圖 4:Queue 資料流 — Sender 寫入 Queue,Receiver 阻塞等待直到資料到達。
這張圖裡有兩個容易寫錯的地方:佇列滿了怎麼辦、等不到資料怎麼辦。這兩個問題的答案都寫在下面的程式裡。
3.1 基本 Queue 操作
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
QueueHandle_t sensor_queue;
// 資料結構
typedef struct {
float temperature;
float humidity;
uint32_t timestamp;
} SensorData;
void sender_task(void *pvParam)
{
SensorData data;
while (1) {
data.temperature = read_temp();
data.humidity = read_humidity();
data.timestamp = millis();
// 發送資料(若 queue 滿,等 portMAX_DELAY)
xQueueSend(sensor_queue, &data, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void receiver_task(void *pvParam)
{
SensorData data;
while (1) {
// 接收資料(若 queue 空,阻塞等待)
if (xQueueReceive(sensor_queue, &data, portMAX_DELAY) == pdPASS)
{
printf("Temp: %.1f, Hum: %.1f\n", data.temperature, data.humidity);
}
}
}
void app_main(void)
{
// 創建 queue:每個元素 sizeof(SensorData),最多 10 個
sensor_queue = xQueueCreate(10, sizeof(SensorData));
xTaskCreate(sender_task, "sender", 2048, NULL, 5, NULL);
xTaskCreate(receiver_task, "receiver", 2048, NULL, 5, NULL);
}
這段的重點有三個:資料結構(SensorData)、佇列深度(10 個元素)、以及發送與接收都用 portMAX_DELAY —— 意思是「沒空間就一直等、沒資料就一直等」。它能跑,但要小心:當 Queue 滿了,發送方是真的會被卡住的。
3.2 Queue 超時處理
// 阻塞最多 100ms,若無資料則繼續執行
if (xQueueReceive(queue, &data, pdMS_TO_TICKS(100)) == pdPASS)
{
process_data(&data);
}
else
{
// Timeout,做其他事
check_watchdog();
}
把 portMAX_DELAY 換成具體時間(這裡是 100 ms),行為就從「死等」變成「等不到就先去做別的事」。實務上我幾乎都建議這樣寫,理由不是效能,而是讓任務有機會回來檢查自己的健康狀態,例如餵一下看門狗。
四、Semaphore(信號量)與 Mutex
如果說 Queue 負責傳資料,Semaphore 負責的就是傳「事件」與管「資源」。這兩件事在 RTOS 裡長得很像,但用途完全不同。
4.1 Binary Semaphore(二值信號量)
Binary Semaphore 最常見的用法是中斷同步——ISR 通知任務執行耗時操作:
SemaphoreHandle_t sem;
void IRAM_ATTR gpio_isr_handler(void *arg)
{
BaseType_t wake = pdFALSE;
xSemaphoreGiveFromISR(sem, &wake);
if (wake) portYIELD_FROM_ISR();
}
void sensor_task(void *pvParam)
{
while (1) {
if (xSemaphoreTake(sem, portMAX_DELAY) == pdPASS)
{
// ISR 觸發後執行
uint32_t val = read_adc();
process_adc(val);
}
}
}
void app_main(void)
{
sem = xSemaphoreCreateBinary();
xTaskCreate(sensor_task, "sensor", 2048, NULL, 10, NULL);
gpio_set_intr_type(GPIO_NUM_34, GPIO_INTR_POSEDGE);
gpio_install_isr_service(0);
gpio_isr_handler_add(GPIO_NUM_34, gpio_isr_handler, NULL);
}
流程是這樣:中斷發生時 ISR 只做一件小事 —— 給出信號量,然後叫排程器看要不要切換任務;真正耗時的讀取與處理,全部留給 sensor_task 去做。這是寫 RTOS 最重要的一條紀律:ISR 要短。
4.2 Counting Semaphore(計數信號量)
Counting Semaphore 用於管理有限數量的資源(如記憶體池、連線槽):
SemaphoreHandle_t pool_sem;
#define POOL_SIZE 5
void worker_task(void *pvParam)
{
int id = (int)pvParam;
while (1) {
// 取得資源
if (xSemaphoreTake(pool_sem, pdMS_TO_TICKS(5000)) == pdPASS)
{
printf("Worker %d acquired resource\n", id);
vTaskDelay(pdMS_TO_TICKS(2000 + rand() % 3000));
xSemaphoreGive(pool_sem); // 釋放資源
}
else
{
printf("Worker %d timeout!\n", id);
}
}
}
void app_main(void)
{
pool_sem = xSemaphoreCreateCounting(POOL_SIZE, POOL_SIZE);
for (int i = 0; i < 8; i++)
xTaskCreate(worker_task, "worker", 2048, (void *)i, 5, NULL);
}
這裡的對比很有意思:資源有 5 個(POOL_SIZE),但建立的工作任務有 8 個。也就是說一定有 3 個在等,程式設計上就必須接受「等不到」是正常狀態,所以才會寫上 5000 ms 的逾時與那句 timeout 訊息。
4.3 Mutex(互斥鎖)與 Priority Inheritance
Mutex 用於保護共用資源(全域變數、硬體周邊),與 Binary Semaphore 最大的不同是支援優先級繼承(Priority Inheritance)解決優先級反轉問題。

圖 5:Binary Semaphore 與 Mutex 的差別 — 兩者的 API 幾乎一樣,差別在優先級繼承。
優先級反轉的意思是:低優先級任務先拿到鎖,高優先級任務只好等它;偏偏中間還有一個中優先級任務一直插隊,結果高優先級任務被拖得最久。Mutex 的優先級繼承會讓持有鎖的低優先級任務「暫時升級」,趕快把事情做完還鎖 —— 這也是為什麼保護資源請用 Mutex,不要用 Binary Semaphore。
SemaphoreHandle_t mutex;
int shared_data = 0;
void writer_task(void *pvParam)
{
while (1) {
xSemaphoreTake(mutex, portMAX_DELAY);
shared_data = read_sensor();
xSemaphoreGive(mutex);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void reader_task(void *pvParam)
{
while (1) {
xSemaphoreTake(mutex, portMAX_DELAY);
int val = shared_data;
xSemaphoreGive(mutex);
printf("Value: %d\n", val);
vTaskDelay(pdMS_TO_TICKS(50));
}
}
void app_main(void)
{
mutex = xSemaphoreCreateMutex(); // 使用 Mutex (含 Priority Inheritance)
xTaskCreate(writer_task, "writer", 2048, NULL, 3, NULL);
xTaskCreate(reader_task, "reader", 2048, NULL, 5, NULL);
}
五、Task Notification(任務通知)
FreeRTOS 的 Task Notification 比 Semaphore 更快(不需要額外的 queue/semaphore 物件):
TaskHandle_t worker_handle;
void worker(void *pvParam)
{
uint32_t val;
while (1) {
// 阻塞等待通知
xTaskNotifyWait(0, ULONG_MAX, &val, portMAX_DELAY);
printf("Received: %lu\n", val);
}
}
void app_main(void)
{
xTaskCreate(worker, "worker", 2048, NULL, 5, &worker_handle);
// 從 ISR 或另一個任務發送通知
xTaskNotify(worker_handle, 0x42, eSetValueWithOverwrite);
}
Task Notification 性能對比:
| 機制 | RAM 開銷 | 速度 | 適用場景 |
|---|---|---|---|
| Queue | Queue 結構 + Buffer | 慢 | 傳送大量資料 |
| Semaphore | 約 60 bytes | 中 | 事件通知、資源計數 |
| Mutex | 約 80 bytes | 中 | 共享資源保護 |
| Task Notification | 0(內建於 TCB) | 快 (快 45%) | 簡單事件通知 |
這張表的結論很直接:Task Notification 的開銷是 0,因為資料就存在任務自己的 TCB 裡,不用另外配置物件;速度還快上約 45%。代價是它只能「一對一」通知 —— 一次只有一個任務能收到,這點跟 Semaphore 不同。
六、實戰專案:多感測器資料採集系統
整合上述機制,實作一個完整的 IoT 資料採集系統:
// ┌─────────────┐ Queue ┌──────────────┐ UDP ┌──────┐
// │ Sensor Task │────data────→│ Network Task │───────→│ Server │
// │ (Prio 3) │ │ (Prio 5) │ │Port │
// └──────┬───────┘ └──────┬───────────┘ └──────┘
// │ ISR notification │
// ┌────▼───┐ ┌──────▼───────┐
// │ Button │ │ OLED Display │
// │ ISR │ │ (Prio 1) │
// └────────┘ └──────────────┘
SemaphoreHandle_t button_sem;
QueueHandle_t data_queue;
TaskHandle_t sensor_handle;
TaskHandle_t network_handle;
TaskHandle_t display_handle;
// Button ISR
void IRAM_ATTR button_isr(void *arg)
{
BaseType_t w = pdFALSE;
xSemaphoreGiveFromISR(button_sem, &w);
if (w) portYIELD_FROM_ISR();
}
// Sensor Task
void sensor_task(void *pv)
{
while (1) {
xSemaphoreTake(button_sem, portMAX_DELAY);
SensorData d;
d.temp = read_ds18b20();
d.hum = read_dht22();
d.ts = esp_timer_get_time();
xQueueSend(data_queue, &d, pdMS_TO_TICKS(10));
}
}
// Network Task (Wi-Fi + UDP)
void network_task(void *pv)
{
SensorData d;
while (1) {
if (xQueueReceive(data_queue, &d, pdMS_TO_TICKS(1000)) == pdPASS)
{
char buf[128];
snprintf(buf, sizeof(buf),
"{\"t\":%.1f,\"h\":%.1f,\"ts\":%lu}",
d.temp, d.hum, d.ts);
udp_send(buf);
}
}
}
// Display Task
void display_task(void *pv)
{
SensorData d;
while (1) {
if (xQueuePeek(data_queue, &d, 0) == pdPASS)
{
oled_printf(0, "T: %.1f C", d.temp);
oled_printf(1, "H: %.1f %%", d.hum);
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void app_main(void)
{
button_sem = xSemaphoreCreateBinary();
data_queue = xQueueCreate(5, sizeof(SensorData));
gpio_isr_handler_add(BTN_PIN, button_isr, NULL);
xTaskCreate(sensor_task, "sensor", 2048, NULL, 3, &sensor_handle);
xTaskCreate(network_task, "network", 4096, NULL, 5, &network_handle);
xTaskCreate(display_task, "display", 2048, NULL, 1, &display_handle);
}
這個專案把前面所有東西都用上了:按鈕中斷只用信號量通知、量測結果走 Queue 交給網路任務、OLED 用 xQueuePeek 偷看最新資料(不會把資料取走,所以不影響上傳)。三個任務的優先級分別是 3、5、1,剛好對應「慢工作、關鍵工作、顯示工作」的排序。
七、常見問題與除錯
前面講的都是「怎麼寫」,這一段講「為什麼會壞」。下面四種是我在 RTOS 專案裡最常遇到的狀況。
7.1 Stack Overflow
任務的 Stack 大小不足是 RTOS 最常見的崩潰原因。解決方案:
// 在 FreeRTOSConfig.h 啟用 Stack Overflow 檢測
#define configCHECK_FOR_STACK_OVERFLOW 2
// 註冊掛鉤函數
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
printf("STACK OVERFLOW: %s\n", pcTaskName);
while(1);
}
回頭看原文的每一段程式,其實就是一個 Stack 分配的參考值:

圖 6:Stack 需求對照 — 4096 只用在感測器與 Wi-Fi 網路任務上。
我的習慣是:先照這個表給保守值,再開檢測看實際用了多少,確認有餘裕之後才往下砍。反過來做(先給小、再慢慢加)通常會先換來幾個晚上的 debug。
7.2 Deadlock(死鎖)
兩個任務互相等待對方釋放的資源。避免方法:
- 所有任務依相同順序取得 Mutex(A → B → C)
- 使用 xSemaphoreTake() 加上 Timeout,不要用 portMAX_DELAY
- 考慮使用 Queue 替代複雜的 Mutex 嵌套
7.3 Priority Inversion(優先級反轉)
使用 Mutex(而不是 Binary Semaphore)來保護資源,因為 Mutex 支援優先級繼承。
7.4 記憶體耗盡
// 檢查剩餘 heap
printf("Free heap: %d\n", esp_get_free_heap_size());
printf("Min free: %d\n", esp_get_minimum_free_heap_size());
// 使用 xTaskCreateStatic() 預分配 Stack

圖 7:四種毛病與處理方式 — 由症狀反推該檢查什麼。
八、總結
FreeRTOS 的多工管理是嵌入式系統開發的核心技能。不論你的目標平台是 STM32 還是 ESP32,理解任務狀態機、Queue、Semaphore、Mutex 和 Task Notification 的差異,就能設計出穩定且高效的系統架構。實戰中的幾個原則:
- 優先級盡量少:3~5 個層級足以應付大多數場景
- ISR 盡量短:只在 ISR 中發送 Semaphore/Notification,耗時操作交給任務
- Queue 比全域變數安全:用 Queue 而不是直接讀寫共用變數
- Mutex 保護共用資源:使用 Mutex(非 Binary Semaphore)避免優先級反轉
- 監控 Stack:啟用 Stack Overflow 檢測,定期檢查 Heap 剩餘量
最後留一句自己的心得:RTOS 的難度不在 API 記不記得,而在「遇到問題時知道該看哪一個狀態」。任務變慢就先看優先級與阻塞時間,系統崩潰就先看 Stack,資料不對就先看 Queue 的深度與逾時 —— 這條排查順序比背 API 有用得多。
文章評論