0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 未分類
  3. 正文

FreeRTOS 多工管理完整教學:任務、佇列與信號量

2026 年 5 月 27 日 1058點熱度 0人點贊 0條評論

FreeRTOS 多工管理完整教學封面

前言

如果你寫過 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 任務狀態機

FreeRTOS Task 四個狀態與轉移

圖 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)結構。

Queue 的傳遞流程與逾時處理

圖 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)解決優先級反轉問題。

Binary Semaphore 與 Mutex 的差別對照

圖 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 分配的參考值:

原文各段程式的 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 的差異,就能設計出穩定且高效的系統架構。實戰中的幾個原則:

  1. 優先級盡量少:3~5 個層級足以應付大多數場景
  2. ISR 盡量短:只在 ISR 中發送 Semaphore/Notification,耗時操作交給任務
  3. Queue 比全域變數安全:用 Queue 而不是直接讀寫共用變數
  4. Mutex 保護共用資源:使用 Mutex(非 Binary Semaphore)避免優先級反轉
  5. 監控 Stack:啟用 Stack Overflow 檢測,定期檢查 Heap 剩餘量

最後留一句自己的心得:RTOS 的難度不在 API 記不記得,而在「遇到問題時知道該看哪一個狀態」。任務變慢就先看優先級與阻塞時間,系統崩潰就先看 Stack,資料不對就先看 Queue 的深度與逾時 —— 這條排查順序比背 API 有用得多。

標籤: RS485 工業通訊
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang