第 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/delay.c。注意一個設計細節:SysTick 是 1ms,但排程的最小時間片是 2ms,
所以中斷裡用一個 cnt 除以 2,每兩次才觸發一次排程檢查:
| USER/delay.c | |
|---|---|
中斷裡只做最少的事
注意 SysTick_IRQ() 裡沒有做任何感測器讀取或控制運算,只是「累加計數、通知排程器」。
真正的工作都放到 main_loop() 在主迴圈裡跑。這是嵌入式即時系統的通則:
中斷要短,把耗時的事丟回主迴圈,避免一個慢中斷拖垮整個系統的時序。
1.4 計數與「跑太慢」偵測:Loop_check()¶
Loop_check() 每 2ms 被呼叫一次,它做兩件事:把所有時間片的計數器 +1,並偵測主迴圈有沒有來不及跑。
| USER/scheduler.c | |
|---|---|
這裡的 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() 是排程器的核心。它檢查各計數器,到了週期就執行對應任務並把計數歸零:
看懂這段的關鍵:計數器每 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_2ms 和 Duty_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 | |
|---|---|
為什麼姿態環要放在最快的 2ms?
飛機的姿態(傾斜)變化非常快,控制迴路更新越快、越能及時修正,飛起來越穩。 2ms = 500Hz 是這顆 72MHz 的 F103 在「夠快」和「跑得完」之間的平衡點。 相對地,電池電壓、LED 這種「慢變化」的事放到 50ms 甚至 1s,省下 CPU 給姿態環。
1.7 計時與看門狗:怎麼知道飛控健康?¶
這份韌體內建兩道「健康監測」,學會看它們,對除錯非常重要。
(1) 每個任務的耗時量測¶
每個 Duty_* 都用 GetSysTime_us() 把自己包起來,把耗時存進 time[] 陣列,
最後在 1 秒任務裡加總:
GetSysTime_us() 用「毫秒計數 + SysTick 當前值」拼出微秒級時間戳(USER/delay.c):
| USER/delay.c | |
|---|---|
把 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、氣壓計、光流溝通。