跳轉到

第 1 章:系統架構與排程器

本章學習目標

  • 看懂這份韌體從上電到馬達轉動的整體資料流
  • 理解為什麼飛控用「協作式分時排程」而不是作業系統(RTOS)
  • 讀懂排程器三個核心:SysTick 心跳、Loop_check() 計數、main_loop() 分派
  • 知道哪些事必須 2ms 跑一次、哪些可以慢慢來,以及這個取捨背後的道理
  • 學會用程式裡的計時與看門狗機制,判斷飛控有沒有「跑太慢 / 卡住」

搭配官方影片(下方可直接播放)。

📺 官方影片:飛控程式工程結構介紹(4.1)

📺 官方影片:飛控程式工作流程(4.2)— 對應本章排程器


1.1 先看大圖:一幀控制是怎麼跑完的

飛控的本質是一個無限重複的迴圈:讀感測器 → 算姿態 → 算控制量 → 輸出給馬達,每秒重複數百次。

flowchart TD
    HW["硬體 SysTick 計時器<br/>每 1ms 中斷一次"] --> CK["Loop_check()<br/>累加各計數器"]
    CK --> ML["main_loop()<br/>依計數分派任務"]
    subgraph fast ["每 2ms(最高優先:姿態環)"]
        direction LR
        S1["讀 MPU6050"] --> S2["姿態 PID"] --> S3["馬達 PWM 輸出"]
    end
    subgraph slow ["較慢的週期任務"]
        T4["4ms:遙控、模式切換"]
        T6["6ms:姿態解算"]
        T10["10ms:光流、定高"]
        T50["50ms:LED、電壓"]
    end
    ML --> fast
    ML --> slow

整個程式的「目錄」其實就藏在一個檔案裡:USER/scheduler.c。 讀懂它,你就知道這台飛機做哪些事、各多久做一次


1.2 為什麼不用作業系統(RTOS)?

STM32F103C8 只有 64KB Flash、20KB RAM,而飛控的工作有一個關鍵特性:

任務的週期是固定且已知的(姿態環 2ms、遙控 4ms、定高 10ms…), 而且彼此不應該互相搶佔——你不會希望「算姿態算到一半被打斷去刷 LED」。

這種情況下,與其用 RTOS 的執行緒切換(會吃記憶體、增加複雜度與不確定性), 不如用最簡單可靠的協作式分時排程(cooperative time-sliced scheduler)

  • 用一個硬體計時器產生穩定的「心跳」
  • 每個任務排在自己的時間片,一個跑完才換下一個,不搶佔
  • 程式行為完全可預測,也好除錯

這就是這份韌體的做法,也是絕大多數入門~中階飛控(如早期匿名、亞博等)的共同骨架。


1.3 心跳:SysTick 每 1ms 中斷一次

一切的時間基準來自 Cortex-M3 內建的 SysTick 計時器。在 main() 裡設定為每 1ms 中斷一次:

USER/main.c(已將 GBK 註解翻成正體中文)
int main(void)
{
    cycleCounterInit();                          // 記錄每 us 有多少 CPU cycle,供精準計時用
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
    SysTick_Config(SystemCoreClock / 1000);      // 系統滴答計時器:72MHz/1000 → 每 1ms 中斷一次

    ALL_Init();                                  // 系統初始化(感測器、通訊、PWM…)
    OLED_Init();
    IWDG_Init(4, 625);                           // 獨立看門狗,逾時約 1s

    while (1)
    {
        main_loop();                             // 主任務排程
        IWDG_Feed();                             // 餵狗,防止程式跑飛
    }
}

中斷處理在 USER/delay.c。注意一個設計細節:SysTick 是 1ms,但排程的最小時間片是 2ms, 所以中斷裡用一個 cnt 除以 2,每兩次才觸發一次排程檢查:

USER/delay.c
void SysTick_IRQ(void)               // 每 1ms 進來一次
{
    static u8 cnt;

    SysTick_count++;                 // 全域毫秒計數,給 GetSysTime_us() 用
    if (!sys_init_ok) return;

    cnt++;  cnt %= 2;
    if (cnt) Loop_check();           // 每 2ms 呼叫一次 Loop_check()
}

中斷裡只做最少的事

注意 SysTick_IRQ()沒有做任何感測器讀取或控制運算,只是「累加計數、通知排程器」。 真正的工作都放到 main_loop() 在主迴圈裡跑。這是嵌入式即時系統的通則: 中斷要短,把耗時的事丟回主迴圈,避免一個慢中斷拖垮整個系統的時序。


1.4 計數與「跑太慢」偵測:Loop_check()

Loop_check() 每 2ms 被呼叫一次,它做兩件事:把所有時間片的計數器 +1,並偵測主迴圈有沒有來不及跑

USER/scheduler.c
void Loop_check()
{
    loop.cnt_2ms++;
    loop.cnt_4ms++;
    loop.cnt_6ms++;
    loop.cnt_10ms++;
    loop.cnt_20ms++;
    loop.cnt_50ms++;
    loop.cnt_1000ms++;

    if (loop.check_flag >= 1)
    {
        loop.err_flag++;        // 上一輪 main_loop 還沒跑完 → 記一次超時
    }
    else
    {
        loop.check_flag += 1;   // 設旗標:通知 main_loop「有一個新的 2ms 到了」
    }
}

這裡的 check_flag 是一個很巧妙的握手機制

  • Loop_check()(中斷端)每 2ms 把 check_flag 設為 1,意思是「該做事了」。
  • main_loop()(主迴圈端)做完一輪後把它清回 0,意思是「做完了」。
  • 如果中斷又來了、卻發現 check_flag 還是 1(上一輪還沒清零),代表主迴圈這 2ms 沒跑完—— 於是 err_flag++ 記錄一次超時。

這就是飛控的『負載過重警報』

err_flag 持續增加,代表你塞進 2ms 的工作太多、CPU 來不及。 日後你若在 Duty_2ms() 裡加東西導致飛機姿態變鈍,就是從這裡發現問題的。


1.5 分派:main_loop() 決定誰該跑

main_loop() 是排程器的核心。它檢查各計數器,到了週期就執行對應任務並把計數歸零

USER/scheduler.c
void main_loop()
{
    if (loop.check_flag >= 1)              // 有新的 2ms 到了才進來
    {
        if (loop.cnt_2ms >= 1)  { loop.cnt_2ms = 0;   Duty_2ms();   }  // 每 2ms
        if (loop.cnt_4ms >= 2)  { loop.cnt_4ms = 0;   Duty_4ms();   }  // 每 4ms
        if (loop.cnt_6ms >= 3)  { loop.cnt_6ms = 0;   Duty_6ms();   }  // 每 6ms
        if (loop.cnt_10ms >= 5) { loop.cnt_10ms = 0;  Duty_10ms();  }  // 每 10ms
        if (loop.cnt_20ms >= 10){ loop.cnt_20ms = 0;  Duty_20ms();  }  // 每 20ms
        if (loop.cnt_50ms >= 25){ loop.cnt_50ms = 0;  Duty_50ms();  }  // 每 50ms
        if (loop.cnt_1000ms >= 500){ loop.cnt_1000ms = 0; Duty_1000ms(); } // 每 1s

        loop.check_flag = 0;               // 本輪做完,清旗標
    }
}

看懂這段的關鍵:計數器每 2ms +1,所以「次數」就是「毫秒 ÷ 2」

判斷式 觸發條件 實際週期
cnt_2ms >= 1 累計 1 次 1 × 2ms = 2ms
cnt_4ms >= 2 累計 2 次 2 × 2ms = 4ms
cnt_6ms >= 3 累計 3 次 3 × 2ms = 6ms
cnt_10ms >= 5 累計 5 次 5 × 2ms = 10ms
cnt_50ms >= 25 累計 25 次 25 × 2ms = 50ms
cnt_1000ms >= 500 累計 500 次 500 × 2ms = 1000ms

想一想

如果某一輪 Duty_2msDuty_10ms 剛好同時到期,它們會在同一輪 main_loop 裡依序執行(先 2ms 後 10ms)。 這代表那一輪的工作量是「最重的一輪」。設計排程時要確保最壞情況(所有任務同時到期)的總時間 < 2ms,否則就會觸發 1.4 節的超時。


1.6 每個時間片在做什麼(全機功能總覽)

下面這張表就是這台飛機的「作息表」。本課程之後每一章,基本上都是在深入講解其中一格:

週期 函式 做的事 對應章節
2ms Duty_2ms 讀 MPU6050 → 姿態 PID → 馬達輸出 第 4、5 章
4ms Duty_4ms nRF24L01 收遙控、ANO 遙測、解析遙控、飛行模式切換 第 6 章
6ms Duty_6ms 姿態解算(由感測器算出 Roll/Pitch/Yaw) 第 3 章
10ms Duty_10ms 遙控指令分析、光流融合與定點、氣壓定高 第 7、8 章
20ms Duty_20ms 向上位機輪詢傳資料 第 10 章
50ms Duty_50ms LED 刷新、狀態旗標、電池電壓檢查
1000ms Duty_1000ms 各通訊鏈路訊號強度統計、感測器在線偵測 第 6 章

以最關鍵的 2ms 姿態環為例,它是這樣的(這就是讓飛機「穩住」的核心三步):

USER/scheduler.c
void Duty_2ms()
{
    time[0] = GetSysTime_us();      // 記錄起始時間(量測本任務耗時)

    MpuGetData();                   // ① 讀慣性感測器原始資料
    FlightPidControl(0.002f);       // ② 姿態 PID 控制(dt = 0.002s)
    MotorControl();                 // ③ 把控制量換算成 4 路馬達 PWM 輸出

    time[0] = GetSysTime_us() - time[0];  // 本任務實際花了多少 us
}

為什麼姿態環要放在最快的 2ms?

飛機的姿態(傾斜)變化非常快,控制迴路更新越快、越能及時修正,飛起來越穩。 2ms = 500Hz 是這顆 72MHz 的 F103 在「夠快」和「跑得完」之間的平衡點。 相對地,電池電壓、LED 這種「慢變化」的事放到 50ms 甚至 1s,省下 CPU 給姿態環。


1.7 計時與看門狗:怎麼知道飛控健康?

這份韌體內建兩道「健康監測」,學會看它們,對除錯非常重要。

(1) 每個任務的耗時量測

每個 Duty_* 都用 GetSysTime_us() 把自己包起來,把耗時存進 time[] 陣列, 最後在 1 秒任務裡加總:

USER/scheduler.c
    time_sum = 0;
    for (i = 0; i < 6; i++)  time_sum += time[i];

GetSysTime_us() 用「毫秒計數 + SysTick 當前值」拼出微秒級時間戳(USER/delay.c):

USER/delay.c
uint32_t GetSysTime_us(void)
{
    register uint32_t ms, cycle_cnt;
    do {
        ms = SysTick_count;
        cycle_cnt = SysTick->VAL;
    } while (ms != SysTick_count);          // 防止讀取瞬間剛好跨毫秒,重讀確保一致
    return (ms * 1000) + (usTicks * 1000 - cycle_cnt) / usTicks;
}

time[]time_sum 透過上位機送出來看,就能知道哪個任務變慢了

(2) 獨立看門狗(IWDG)

main() 裡開了獨立看門狗,逾時約 1 秒。只要 main_loop() 正常跑完一圈就會 IWDG_Feed() 餵狗; 萬一程式卡死(例如某個感測器 I2C 通訊卡住、死迴圈),狗沒被餵到就會自動重啟 MCU, 讓飛機有機會恢復,而不是直接失控墜機。

餵狗不是萬靈丹

看門狗只能救「整個程式卡死」。它救不了「程式還在跑、但控制邏輯算錯」。 所以它是最後一道防線,真正的安全還是靠正確的程式 + 拆槳測試。


1.8 本章對應的原始碼

檔案 看什麼
USER/main.c main():時鐘、SysTick、初始化、主迴圈、看門狗
USER/scheduler.c Loop_check() / main_loop() / 各 Duty_* —— 本章重點
USER/delay.c SysTick_IRQ()GetSysTime_us()、精準延時
StartUp/stm32f10x_it.c SysTick_Handler() 中斷入口

1.9 動手做 / 思考題

  • 打開 USER/scheduler.c,對照 1.6 的表,把每個 Duty_* 裡呼叫的函式都圈出來,猜猜各屬於哪一章。
  • 算一題:如果你想新增一個「每 100ms 記錄一次飛行資料」的任務,cnt_100ms 的判斷式門檻應該設多少?(提示:100 ÷ 2)
  • 思考:為什麼「讀遙控(4ms)」可以比「姿態環(2ms)」慢?如果把遙控也放到 2ms,會有什麼好處與壞處?
  • 進階Loop_check() 裡的 err_flag 在什麼情況下會一直增加?你會怎麼把它顯示在 OLED 或上位機上,當作「CPU 過載」的警報?

下一章:感測器是怎麼被讀進來的——從 MpuGetData() 出發,看 I2C/SPI 如何跟 MPU6050、氣壓計、光流溝通。