
很多人寫裸機程序都是從一個大循環(huán)開始的讀傳感器、處理按鍵、刷新屏幕、發(fā)送數據全塞在while(1)里。功能少的時候倒還好但只要功能一多代碼就開始互相拖后腿——按鍵按下要等半秒才響應通信數據偶爾就超時丟包。這時候你就會想能不能讓系統自己決定先跑哪個任務、跑多久、什么時候換下一個搶占式調度器解決的就是這件事。它負責在多個任務都處于“可運行”狀態(tài)時決定誰先上CPU、運行多長時間、被誰打斷、以及如何切回。這篇文章我會把一個最小可運行的搶占式調度器從頭到尾拆開講包括任務控制塊怎么設計、上下文切換到底在切什么、SysTick和PendSV如何配合、以及我在實測中踩過的各種坑。適合剛接觸RTOS、或者想深入理解操作系統調度機制的開發(fā)者看完你完全有能力自己手寫一個跑在MCU上的調度器。1. 先想清楚搶占式調度器的設計邏輯1.1 為什么必須要“搶占”而不是讓任務自己讓位調度器有兩種典型形態(tài)協作式和搶占式。協作式調度的核心邏輯是“每個任務主動放棄CPU系統才切到下一個任務”。聽起來簡單但工程上非常脆弱只要有一個任務里存在長循環(huán)、阻塞等待或者意外死循環(huán)整個系統就卡在那個任務里其他任務完全沒有翻身機會。我早年做的一個數據采集模塊就吃過這種虧一個串口等待應答的循環(huán)沒加超時結果整機其他功能全部掛起只能靠看門狗復位。搶占式調度則是把“何時切換”的控制權從任務手里收回來交給調度器本身。系統通過定時器中斷強行打斷當前正在運行的任務保存現場然后按優(yōu)先級策略決定下一個該跑誰。特別典型的一個場景就是高優(yōu)先級任務在等待某個外部事件低優(yōu)先級任務正在做批量計算這時候外部事件到了中斷里把高優(yōu)先級任務喚醒調度器立刻暫停低優(yōu)先級任務先讓高優(yōu)先級任務把關鍵動作做完再回來繼續(xù)算。這件事用生活中的場景類比就是醫(yī)院急診分診。協作式像普通門診排隊前面的人聊了二十分鐘后面的人就只能干等搶占式像急診分診救護車送來的危重病人一到立刻插到最前面處理。嵌入式系統里“危重病人”就是那些有實時性要求的任務比如電機控制、協議超時處理、安全保護邏輯。1.2 調度器要管的三大核心結構一個典型的搶占式內核無論手寫還是商用RTOS核心都離不開三個部分任務控制塊數組、就緒隊列、時基中斷源。任務控制塊叫TCB每創(chuàng)建一個任務就分配一個TCB它用來存放這個任務的所有“身份信息”任務當前的棧指針、優(yōu)先級、狀態(tài)、入口函數、私有棧底地址等等??梢园阉斫獬擅總€任務的檔案袋調度器做任何決策之前必須先翻檔案袋。就緒隊列負責表達“當前有哪些任務是能跑的”。最簡單的設計就是按優(yōu)先級分成多個隊列同優(yōu)先級的任務鏈在一起。調度時只需要找“最高優(yōu)先級里排在最前面的任務”即可。就緒隊列的更新時機很講究任務創(chuàng)建時、任務延時結束時、任務被信號量喚醒時都要把對應TCB掛到就緒隊列上。時基中斷是搶占的“節(jié)拍器”。沒有外部中斷周期性打斷搶占就沒有觸發(fā)源。常用的是定時器中斷比如配置一個1ms的周期性中斷每次中斷都觸發(fā)一次調度檢查當前任務時間片到沒到有沒有更高優(yōu)先級任務就緒任務狀態(tài)機也很重要一個任務只會有三種基本狀態(tài)運行態(tài)、就緒態(tài)、阻塞態(tài)。跑著的叫運行態(tài)被搶占的其實回退成就緒態(tài)等待延時、信號量、事件的時候叫阻塞態(tài)。調度器干的事說白了就是不斷在這三個狀態(tài)之間做搬運工。1.3 一個任務切換的完整足跡我畫不出流程圖直接用文字描述把整個切換足跡走一遍。某時刻低優(yōu)先級任務B正在運行。外部設備觸發(fā)中斷在中斷服務函數里把一個高優(yōu)先級任務A等待的事件置位。中斷服務函數退出前發(fā)現“A任務已經就緒而且A的優(yōu)先級比B高”于是設置一個切換請求標志。退出中斷后系統進入PendSV異常處理PendSV是專門用來做上下文切換的異常在PendSV里把B的當前寄存器現場全部壓入B自己的棧保存好B的棧指針到B的TCB里然后從A的TCB里取出A的棧指針把A之前保存的現場全部彈出到寄存器里最后退出異常CPU接著跑的就是A任務的代碼。這里有一個容易想歪的點所謂的搶占不是中斷服務函數里直接跳去執(zhí)行任務A的代碼而是“中斷退出之后順著異常返回機制自然切到另一個任務的上下文”。理解了這個后面看匯編代碼就不會懵。2. 四個關鍵設計點TCB、上下文切換、就緒隊列與臨界區(qū)2.1 任務控制塊TCB里的字段不是隨便定的我寫調度器的時候TCB結構體的字段順序都是刻意安排的。第一版我把棧指針放在第一個字段理由很實際上下文切換的匯編代碼需要頻繁訪問當前TCB的sp如果sp在結構體偏移為0的位置匯編里只用一個基地址加零偏移就可以讀寫代碼最緊湊也最不容易錯。后面加字段也只是往后追加前面的偏移不會變。一個直接可用的TCB設計大概長這樣typedef struct tcb { uint32_t *sp; // 當前任務的棧指針必須位于偏移0 uint8_t priority; // 優(yōu)先級數值越小優(yōu)先級越高 uint8_t state; // 任務狀態(tài) uint8_t delay_ticks; // 剩余延時tick數0表示就緒 void (*entry)(void *arg); // 任務入口函數 void *arg; // 任務參數 uint32_t *stack_bottom; // 棧底地址用于溢出檢測 uint32_t stack_size; // 棧大小 struct tcb *next; // 指向同優(yōu)先級隊列的下一個任務 } tcb_t;每個字段背后都有坑。delay_ticks用uint8_t意味著最大延時255個tick如果你把tick配置成1ms那一個任務最多只能延時255ms不夠用用uint16_t或者uint32_t才穩(wěn)妥。stack_bottom和stack_size是我后來加上的原因后面排查章節(jié)會說——任務棧溢出是跑調度器最常遇到的硬故障有這兩個字段可以在每次切換時做一次邊界檢查。任務狀態(tài)字段我習慣定義成枚舉運行、就緒、阻塞各一個值。運行態(tài)其實可以并入就緒態(tài)的語義中因為一個CPU上永遠只有一個任務在跑其他就緒任務都處于“隨時可以接手”的狀態(tài)。2.2 上下文切換到底在“切”什么上下文切換是整個調度器的靈魂。所謂上下文指的就是CPU在一瞬間執(zhí)行任務所需的全部狀態(tài)——通用寄存器、棧指針、程序計數器、程序狀態(tài)寄存器。切換的本質就是把當前任務的所有寄存器從一個地方搬到它自己的棧里再把它對手任務的所有寄存器從對方的棧里搬回來。在很多通用MCU上這個搬運動作是由硬件和軟件配合完成的。以Cortex-M為例一個任務正在被中斷打斷時CPU硬件會自動把一部分寄存器壓棧包括R0到R3、R12、返回地址LR、程序狀態(tài)寄存器xPSR和PC這會占用棧里的24字節(jié)。但R4到R11這八個寄存器硬件不壓棧必須由軟件在PendSV里自己保存和恢復。這正是手寫調度器最容易出錯的地方漏保存任何一個寄存器任務恢復運行后寄存器值被改掉輕則運行出錯重則直接HardFault。在Cortex-M上做上下文切換有一個極好的工具叫PendSV這是設計者專門留給操作系統做上下文切換的異常。它的優(yōu)先級可以在NVIC里配置成最低優(yōu)先級這樣任何中斷搶占它之后都會先完成自己的處理最后才輪到PendSV做切換。為什么一定要用PendSV而不是直接在SysTick中斷里切換因為如果SysTick正在跑的時候又被其他高優(yōu)先級中斷打斷打斷了就得服務于那個中斷之后還要返回SysTick處理完才能輪得到別的任務換任務的操作如果塞在SysTick里會把整個中斷時序搞亂。PendSV的“低優(yōu)先級”特性恰好把切換動作推遲到所有中斷處理完之后避免嵌套地獄。2.3 就緒隊列的檢索位圖比鏈表快一個數量級找“當前最高優(yōu)先級的就緒任務”這個操作在每次調度時都會執(zhí)行必須盡量快。我見過最簡單粗暴的寫法是從0到最大優(yōu)先級數遍歷一遍找到第一個非空隊列。如果優(yōu)先級總數就8個、16個那這種線性掃描完全夠用。但如果你想做一個稍微像樣的內核位圖法是更專業(yè)的做法。思路是這樣的為每個優(yōu)先級維護一個非空的標志位用一個32位整數就可以覆蓋32個優(yōu)先級。當某個優(yōu)先級對應的隊列有任務時對應位置1。檢索最高優(yōu)先級就緒任務時只需要把整個就緒位圖取出來然后數出最低位為1的位置或者直接查一張預計算的表。數前導零的指令在Cortex-M上是一條指令搞定的比遍歷快得多。我實際在項目里用一種拆分位圖的方式第一層是優(yōu)先級組位圖32位對應32個優(yōu)先級組每個組內部還有第二層位圖表示該組內有任務的具體任務編號。這樣兩層位圖配合可以應對大量任務而不拖慢檢索速度。優(yōu)先級數量不多時不需要做兩層一層就夠。就緒隊列的插入與刪除有個細節(jié)在任務切換時當前任務如果只是被搶占它的狀態(tài)應該回退到就緒態(tài)并放回就緒隊列如果是主動延時退出運行則放到阻塞態(tài)不進就緒隊列等延時結束再插入。這個區(qū)分判斷錯了調度器就會把已經阻塞的任務又調度上去跑白白消耗CPU。2.4 臨界區(qū)保護別讓調度器自己打斷自己調度器內部修改就緒隊列、TCB鏈表這些共享數據結構時必須保證操作的原子性。問題在于中斷隨時可能觸發(fā)一旦中斷里也去操作同一個就緒隊列就緒鏈表就可能被撕成兩截狀態(tài)錯亂任務就再也切不回來了。最常見的保護手段是關中斷。進入臨界區(qū)前調用關閉中斷的指令操作完再打開。在Cortex-M上最粗暴的寫法就是直接操作PRIMASK寄存器把全局中斷全關了。這種寫法保護效果最好但關中斷的時間一長系統的實時性就會被摧毀關鍵外部中斷響應會延遲幾個微秒都正常。更優(yōu)雅的做法是BASEPRI寄存器操作只屏蔽優(yōu)先級低于某個閾值的中斷高優(yōu)先級的中斷比如電機控制FOC采樣依然可以響應。操作系統的內核源碼里經常提到“鎖調度器”而不是“鎖全局中斷”指的也是類似思想——中斷還能進但它進不去會改動內核數據的API從而保持內核數據安全。另外有一個必須提醒的點中斷服務函數里不應該調用帶阻塞性質的調度API。中斷上下文里沒有任務的概念調用類似“任務延時”的函數會把當前任務掛起但實際上當前執(zhí)行流并非任務最終結果大概率是內核自己卡死。后面排查章節(jié)我會給一個具體場景。3. 從0到1實現一個最小搶占式調度器3.1 創(chuàng)建任務時的“偽現場”設計任務創(chuàng)建是整個調度器里最巧妙的一步。一個新任務從來沒有運行過它沒有“現場”可以保存但我們希望調度器第一次切換到它的時候像切換老任務一樣直接“恢復現場”就好。思路是創(chuàng)建任務時在它的私有棧里人為構造一個跟“剛被中斷打斷”一模一樣的假現場把棧指針指向這個假現場之后調度器用通用的恢復流程就能順理成章地把新任務跑起來。在Cortex-M上任務入口函數的地址放在假現場的PC位置程序狀態(tài)寄存器xPSR里要預置Thumb模式位這一個bit忘掉就是災難CPU恢復這個任務時默認為ARM模式很多MCU并不支持從異常返回直接進ARM模式于是硬故障立刻觸發(fā)。R0位置放任務參數LR位置放一個用于任務結束的兜底函數地址或者直接放一個死循環(huán)函數保證任務哪怕跑完了也不會失控往下亂跳。核心代碼類似這樣void task_stack_init(tcb_t *tcb, void (*entry)(void *arg), void *arg) { uint32_t *p (uint32_t *)((uint32_t)tcb-stack_bottom tcb-stack_size); *--p (uint32_t)0x01000000; // xPSR: 必須置Thumb位 *--p (uint32_t)entry; // PC: 入口地址 *--p (uint32_t)task_exit_handler; // LR: 返回兜底 *--p (uint32_t)0xFFFFFFFD; // 異常返回使用的EXC_RETURN *--p (uint32_t)arg; // R0: 參數 *--p 0x00000000; // R3 *--p 0x00000000; // R2 *--p 0x00000000; // R1 *--p 0x12121212; // R12 // 下面是PendSV里需要軟件保存的寄存器 *--p 0x11111111; // R11 *--p 0x10101010; // R10 *--p 0x09090909; // R9 *--p 0x08080808; // R8 *--p 0x07070707; // R7 *--p 0x06060606; // R6 *--p 0x05050505; // R5 *--p 0x04040404; // R4 tcb-sp p; }有幾個值不是隨便寫的。EXC_RETURN的值必須是0xFFFFFFFD它告訴CPU“返回線程模式并且使用PSP進程棧指針”這是用MSP和PSP分離的典型RTOS設計。每個任務跑自己的私有棧而中斷服務函數統一用主棧這樣任務?;ハ喔綦x一個任務棧溢出不會立刻污染另一個任務的現場。3.2 SysTick與PendSV的配置優(yōu)先級不能搞反時基我習慣選SysTick因為它本來就是設計給操作系統用的周期定時器。Cortex-M內核里SysTick定時器有獨立的寄存器配置周期可以這樣算假設系統時鐘72MHz要1ms中斷一次那么重裝載值就是72000000除以1000再減1即71999。如果系統時鐘更高比如168MHz那重載值是167999。這個計算是純數學但很多人會忽略SysTick的時鐘源可能不同于系統時鐘在部分芯片上SysTick用的時鐘可能是系統時鐘的分頻值必須查對應芯片手冊確定否則你配置出來的tick周期跟預期差好幾倍。NVIC里PendSV和SysTick的優(yōu)先級配置決定調度是否正常。我的配置習慣是SysTick優(yōu)先級中等偏下PendSV優(yōu)先級設為最低數值最大。這樣做的好處是SysTick中斷即使發(fā)生時CPU正在跑更緊急的中斷服務也不會立刻打斷而PendSV永遠最后一個執(zhí)行保證切換動作在所有中斷服務都處理完畢之后干凈開展。SysTick中斷服務函數要做的事情很簡單就三件全局tick計數加一遍歷所有阻塞態(tài)任務把它們的delay_ticks減一減到零就移入就緒隊列然后檢查當前運行任務是否還有時間片、是否有更高優(yōu)先級就緒任務有則置PendSV掛起位觸發(fā)切換。3.3 任務延時與主動切換的鏈路任務延時是最基礎的阻塞操作。它把當前任務從就緒隊列摘下來狀態(tài)改成阻塞態(tài)給delay_ticks賦一個目標值然后立即觸發(fā)一次調度。注意這里必須觸發(fā)調度因為當前任務自己阻塞自己之后CPU不能停留在任務里繼續(xù)往下跑必須馬上切到別的任務。對接下來的SysTick中斷來說每次tick都對阻塞任務統一做減法操作減到零就恢復就緒。這種實現方式簡單可靠但有個效能問題如果任務數量很多每個tick都遍歷全部任務列表并且延時是比較稀疏的事件那大部分遍歷都是無效的。更高效的辦法是維護一個按延時到期時間排序的定時器隊列每個tick只檢查隊首的最近到期任務。不過對學習用的最小調度器來說線性遍歷足夠清晰方便你觀察整個調度鏈路的運轉。任務主動讓出CPU也就是時間片輪轉的同優(yōu)先級切換實現上比搶占更簡單直接把當前任務放回就緒隊列尾部然后觸發(fā)調度即可。這樣的結果是同優(yōu)先級任務輪流跑每個任務跑一個時間片。這個功能在代碼里通常叫task_yield。3.4 一個可以直接抄作業(yè)的完整用例寫一個最小系統的初始化與調度啟動流程用偽代碼描述更直觀int main(void) { // 初始化硬件時鐘、串口、LED引腳等 board_init(); // 創(chuàng)建兩個任務 task_create(led_flash_task, NULL, 2, 256, tcb_led); task_create(uart_report_task, NULL, 1, 512, tcb_uart); // 設置SysTick 1ms中斷PendSV最低優(yōu)先級 systick_config(1000); nvic_set_priority(PendSV_IRQn, 0xFF); // 啟動調度器永遠不會返回 scheduler_start(); }scheduler_start做的事情是找到當前最高優(yōu)先級就緒任務把它的棧指針裝載到PSP然后把CONTROL寄存器的bit1置位從線程模式切換到使用PSP最后觸發(fā)一次異常返回指令讓CPU跑起來。這之后CPU就不會再按main里的順序往下走了而是完全由任務與中斷驅動。兩個任務的內容可以非常簡單void led_flash_task(void *arg) { while (1) { led_toggle(); task_sleep(500); // 阻塞500個tick即500ms } } void uart_report_task(void *arg) { uint32_t count 0; while (1) { printf(count: %lu\n, count); task_sleep(1000); } }串口任務優(yōu)先級比LED任務高所以調試時你能看到的現象是兩個任務都能按各自周期運行即使其中一個任務內部有長時間計算另一個任務也不會被拖垮因為SysTick每1ms就會檢查一次是否需要搶占。4. 實測中的常見問題與排查方法4.1 任務跑飛或進入HardFault這是新人寫調度器最常撞的墻沒有之一。我印象最深的一次所有任務建好以后調度器一啟動就進HardFault跟著調試器看了半天才意識到任務初始棧里xPSR的Thumb位沒置位。調這種問題沒有捷徑基本是靠調試器和細心。排查步驟我建議固定順序。先用調試器打開HardFault Handler查看異常的出入棧內容特別是壓棧保存的PC值這個PC大概率就是錯誤發(fā)生前的執(zhí)行位置。如果PC指向一個很離譜的地址優(yōu)先懷疑棧指針錯亂如果PC指向0xFFFFFFFF那基本是異常返回指令的EXC_RETURN設置錯了如果PC指向內存里的隨機區(qū)域就要去檢查當前任務棧底有沒有被踩穿過。棧溢出檢測也很實用。在每次上下文切換保存現場之后檢查當前任務棧指針sp有沒有越過stack_bottom邊界如果越界就給一個錯誤標志并停下來。我實際用下來這個方案能有很高的捕獲率特別是配合在每個任務棧首尾填充固定魔數比如0xDEADBEEF在切換時檢查魔數有沒有被改寫立刻就能知道誰在偷吃鄰居的棧空間。4.2 調度不切換或者切換時機錯亂表現是任務A一直在跑任務B永遠輪不到或者任務每次切換都比預期慢很多。出現這種情況先查NVIC的優(yōu)先級配置。一個典型錯誤是SysTick和PendSV優(yōu)先級配反了——PendSV優(yōu)先級比SysTick高那么SysTick中斷觸發(fā)后還沒完成它該做的標志量更新PendSV就搶著切換了最終切換到的可能是還沒完全就緒的任務整個系統邏輯亂套。還有可能是調度請求根本沒有被觸發(fā)。我見過一種情況任務延時函數里確實調用了調度觸發(fā)但忘了把當前任務從就緒隊列移除結果當前任務延時期間依然被調度器選中運行于是它自己切給自己看起來就像調度死了一樣。排查時可以在PendSV入口和SysTick入口各放一個全局變量計數器每次進入都自增。用調試器觀察這兩個值如果SysTick一直在加但PendSV一直零說明掛了切換請求但PendSV沒響應如果兩個都在加但任務就是不切換去查TCB的sp更新是否成功。4.3 優(yōu)先級反轉問題優(yōu)先級反轉是搶占式調度里一個經典的副作用。場景是三優(yōu)先級的任務低優(yōu)先級任務C拿到了一個互斥信號量正在執(zhí)行臨界區(qū)代碼高優(yōu)先級任務A等著這個信號量被阻塞中優(yōu)先級任務B不需要信號量持續(xù)就緒。結果A雖然優(yōu)先級最高卻只能等C跑完釋放信號量而C被B搶占得不到CPU整個系統看起來就像“中優(yōu)先級任務把高優(yōu)先級任務堵死了”。實際調試中優(yōu)先級反轉的直接表現是高優(yōu)先級任務的關鍵動作周期性超時。解決辦法的主流方案是優(yōu)先級繼承當低優(yōu)先級任務持有信號量且高優(yōu)先級任務在等待時把低優(yōu)先級任務的優(yōu)先級臨時提升到等待者的優(yōu)先級執(zhí)行完臨界區(qū)再恢復。更簡單粗暴的方案是優(yōu)先級天花板進入臨界區(qū)直接把任務優(yōu)先級提到一個預設的天花板值。這兩個方案我建議作為調度器的后續(xù)擴展編寫它們能讓你加深對“優(yōu)先級只是相對概念”的認知。4.4 中斷里調用阻塞API導致內核掛死場景是這樣的外部中斷服務函數里為了等待某個條件直接調用了任務延時函數。視覺上是中斷服務函數正常執(zhí)行但內部它試圖把“當前任務”從就緒隊列移除并觸發(fā)調度。中斷上下文里所謂的當前任務還是被打斷前那個任務把無辜的任務移到阻塞隊列之后調度器回頭發(fā)現那個任務在延時中就徹底不知道該運行誰了。我自己踩過這個坑以后給工程定了一條規(guī)矩中斷服務函數里的處理邏輯越短越好寧可只做“置標志、丟事件、喚醒任務”把業(yè)務邏輯全部搬到任務里做。喚醒操作本身只是把等待信號量的任務放入就緒隊列這個操作必須做到原子性同時它不會阻塞中斷是中斷與任務通信的唯一正確姿勢。這類問題還有一個很隱蔽的變種在關中斷的臨界區(qū)里調用調度API。臨界區(qū)已經關了全局中斷調度觸發(fā)要求PendSV立即掛起但PendSV的中斷優(yōu)先級此時也被屏蔽了切換請求只能干瞪眼等著。解決這類問題的思路是臨界區(qū)里只做對內核數據的快速修改離開臨界區(qū)之后再統一處理調度請求。最后分享一個調試小技巧手寫調度器看代碼不如看波形。我在全局搞一個調試用的GPIO在任務切換函數里讓這個引腳翻轉一次用示波器或邏輯分析儀掛著每個方波下降沿就是一次切換事件。這樣你不用打斷程序運行就能直觀看到調度器是不是在穩(wěn)定工作切換頻率有沒有異常比單步調試省太多時間。還有一個值得注意的細節(jié)是任務延時函數的參數如果按tick數算不同芯片、不同系統時鐘下同樣的參數實際延時時間不一樣所以設計API時最好按毫秒為單位傳入內部再換算成tick數這樣上層代碼移植的時候才不會出錯。