先級繼承:從優(yōu)先級反轉(zhuǎn)實測到OSMutex源碼)
1. 第 8 篇為什么繞不開互斥信號量一次真實的優(yōu)先級反轉(zhuǎn)這個系列寫到第七篇uC/OS-II 里那些看著眼熟、真問起來又說不清的骨架已經(jīng)拆得差不多了目錄怎么分層、上電之后一路走到 OSStart 的啟動鏈、OS_TCB 和 OSRdyTbl 怎么用兩個字節(jié)裝下 64 個優(yōu)先級、OSSched 憑什么用常數(shù)時間找到最高優(yōu)先級任務、PendSV 里那幾行匯編怎么把棧指針換來換去、OSTimeTick 和 OSTimeDly 怎么配合以及第七篇那個只當模板用的 OS_EVENT 事件控制塊。前面攢下來的最大懸念是OS_EVENT 自己什么都不干真正干活的是掛在它下面的具體對象——信號量、互斥量、郵箱、消息隊列。第八篇就從這堆對象里挑一個最容易被低估、也最容易在項目里出事的家伙互斥信號量 OSMutex以及它背后那套優(yōu)先級繼承Priority Inheritance ProtocolPIP。選它不是因為代碼多——整個 uC/OS-II 內(nèi)核骨架算下來六千多行不同版本和裁剪配置會差幾十行os_mutex.c 連注釋一起也就三百行上下——而是因為它解決的是 RTOS 領域最經(jīng)典的實驗室能跑、上壓力測試就炸的問題優(yōu)先級反轉(zhuǎn)。這篇適合三類人看一是剛啃完信號量、正準備把信號量當萬能鎖到處用的新手二是移植過 gd32f103 或者 STM32F103 上的 RTOS、但從來沒認真讀過 os_mutex.c 的嵌入式工程師三是正在準備面試、被uC/OS-II 的互斥量為什么創(chuàng)建時要傳一個優(yōu)先級這類問題卡住的人??赐曛竽銘撃茏龅皆谧约旱陌遄由蠌同F(xiàn)一次優(yōu)先級反轉(zhuǎn)再用互斥量把它消掉并且能用示波器或者串口打點把兩者的差別量出來。1.1 前七篇留下的坑信號量到底解決不了什么二值信號量OSFlag 之前的 OSSem初值為 1是實現(xiàn)互斥最直覺的做法誰要進臨界區(qū)先 OSSemPend 拿一下出來 OSSemPost 放一下語義上完全夠用。問題出在夠用這兩個字上——OSSSem 只管有沒有不管誰拿著。假設三個任務L低優(yōu)先級編號數(shù)值大比如 15、M中優(yōu)先級10、H高優(yōu)先級5。L 先拿到二值信號量進了臨界區(qū)準備訪問一塊共享的 SPI Flash 或者一段共享緩沖區(qū)這時候 M 因為某個外部事件被激活了它不碰這塊共享資源純粹是算一段浮點或者刷一段 UART 緩沖區(qū)。uC/OS-II 的調(diào)度器是搶占式的M 的優(yōu)先級比 L 高所以 M 一就緒就把 L 擠下去了。緊接著 H 也醒了H 要訪問同一塊共享資源去 Pend 那個已經(jīng)被 L 拿著的信號量只能掛起等待。到這一步H 的實際等待時間是L 剩余臨界區(qū)時間加上M 的全部執(zhí)行時間。M 跟共享資源一點關系都沒有卻把最高優(yōu)先級任務堵在后面——這就是無界優(yōu)先級反轉(zhuǎn)。反轉(zhuǎn)本身不可怕可怕的是它不可預測M 什么時候來、跑多久取決于外部輸入最壞情況下 H 的響應時間可以做到完全沒法算。硬實時系統(tǒng)里沒法算等于不合格。1.2 把反轉(zhuǎn)攤開成時間線看光講概念容易飄把它按時間軸鋪開更直觀。下面這張表是一次典型的實測過程時間單位為毫秒與后面第五節(jié)的實驗參數(shù)一致時刻發(fā)生了什么此時 H 的狀態(tài)t0L 拿到鎖進入臨界區(qū)計劃忙等 200ms還沒被激活t50H 被激活嘗試 Pend鎖被占用H 掛起阻塞開始計時t60M 被激活優(yōu)先級 10 高于 L 的 15搶占 L繼續(xù)阻塞t60~560M 跑滿 500ms純計算不碰共享資源阻塞中被無關任務拖延t560M 結束L 恢復運行跑完剩下 150ms 臨界區(qū)繼續(xù)阻塞t710L 放鎖H 被喚醒阻塞結束實測約 660ms用互斥量替換二值信號量之后同樣的三個任務H 的阻塞時間會掉到 200ms 出頭。差別就在 t50 那一刻H 一掛到互斥量上內(nèi)核會立刻把 L 的優(yōu)先級從 15 臨時抬到 5L 一旦被抬到 5M 就搶不過它了臨界區(qū)會一路跑到底然后放鎖H 隨即執(zhí)行。優(yōu)先級繼承的本質(zhì)是讓持有鎖的人暫時借走等鎖的人的優(yōu)先級把不該插進來的中優(yōu)先級任務擋在外面。1.3 這篇要回答的三個問題接下來的內(nèi)容圍繞三個問題展開這也是我讀 os_mutex.c 時反復問自己的一個 16 位的 OSEventCnt 字段怎么同時存下優(yōu)先級上限、可用標志和持有者優(yōu)先級這三件事優(yōu)先級繼承這套動作在 OSMutexPend 里到底是哪幾行寫出來的恢復又是在哪里做的以及最實際的——在 GD32F103 這種 Cortex-M3 的板子上怎么搭一個可以反復復現(xiàn)、能量出數(shù)據(jù)的對照實驗這三個問題答完互斥量這條線基本就通了。順帶還會聊幾個面試高頻問題和幾個只有在真項目里踩過才知道的坑。2. 拆開 OSMutex一個 16 位字段怎么存下三件事第七篇已經(jīng)看過 OS_EVENT 的結構這里再貼一遍因為互斥量的所有秘密都藏在這個結構里沒有任何額外的私有數(shù)據(jù)結構typedef struct os_event { INT8U OSEventType; /* 事件類型信號量/互斥量/郵箱/隊列 */ void *OSEventPtr; /* 類型不同指向的東西也不同 */ INT16U OSEventCnt; /* 計數(shù)器 / 位組合 / 優(yōu)先級組合 */ OS_PRIO OSEventGrp; /* 等待任務組8 位 */ OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任務表8 字節(jié) */ #if OS_EVENT_NAME_EN 0u INT8U *OSEventName; /* 調(diào)試用名字 */ #endif } OS_EVENT;信號量里 OSEventCnt 就是個普通計數(shù)郵箱里 OSEventPtr 直接指向消息消息隊列里 OSEventPtr 指向隊列控制塊。到了互斥量這里劇情變了OSEventCnt 被當成一個位域來用高字節(jié)和低字節(jié)承載完全不同的語義。2.1 OSEventCnt 的位布局以我手上這份 v2.9x 的源碼為例位定義是這樣的不同小版本可能有一點點差異建議對著自己那份 os_mutex.c 核一遍位段含義生命周期bit15 ~ bit8優(yōu)先級上限就是 OSMutexCreate 傳進來的 prio創(chuàng)建時寫入之后不再變bit7 ~ bit00xFF 表示鎖空閑鎖被占用時存的是持有者當前優(yōu)先級每次 Pend/Post 都會變對應的宏也有幾個看名字就懂#define OS_MUTEX_KEEP_LOWER_8 ((INT16U)0x00FFu) /* 取低字節(jié) */ #define OS_MUTEX_KEEP_UPPER_8 ((INT16U)0xFF00u) /* 取高字節(jié) */ #define OS_MUTEX_AVAILABLE ((INT16U)0x00FFu) /* 空閑標志 */ #define OS_MUTEX_EN 0u /* 開啟優(yōu)先級繼承 */ #define OS_MUTEX_DIS 1u /* 關閉優(yōu)先級繼承 */判斷鎖是否空閑的方式非常位運算if ((pevent-OSEventCnt OS_MUTEX_AVAILABLE) OS_MUTEX_AVAILABLE)。低字節(jié)等于 0xFF 就是空閑否則說明低字節(jié)里塞著某個人當前的有效優(yōu)先級永遠小于 0xFF因為 uC/OS-II 最多 64 個優(yōu)先級0~63。這個用一個不可能出現(xiàn)的值做哨兵的小技巧在 uC/OS-II 里到處都是讀源碼時記住它能省下不少困惑。順帶說一句為什么低字節(jié)存的是持有者當前優(yōu)先級而不是是否被占用這樣一個布爾值因為恢復優(yōu)先級的時候需要它。如果只存一個布爾Post 的時候就不知道該把持有者抬回哪個數(shù)值了。2.2 OSMutexCreate 里那個占位動作OSMutexCreate(prio, perr)只接收一個參數(shù) prio很容易被當成隨便填一個而忽略。實際上創(chuàng)建函數(shù)開頭有一段檢查if (prio OS_LOWEST_PRIO) { /* OS_LOWEST_PRIO 通常配成 63 */ *perr OS_ERR_PRIO_INVALID; return ((OS_EVENT *)0); } OS_ENTER_CRITICAL(); if (OSTCBPrioTbl[prio] ! (OS_TCB *)0) { /* 這個優(yōu)先級槽已被占用 */ OS_EXIT_CRITICAL(); *perr OS_ERR_PRIO_EXIST; return ((OS_EVENT *)0); } OSTCBPrioTbl[prio] OS_TCB_RESERVED; /* 用一個特殊指針把槽占住 */ OS_EXIT_CRITICAL();OS_TCB_RESERVED其實就是(OS_TCB *)1這么一個假指針。它的作用是告訴任務創(chuàng)建函數(shù) OSTaskCreate這個優(yōu)先級已經(jīng)被占了你不許用。換句話說uC/OS-II 的互斥量是從任務的優(yōu)先級池里硬生生摳出來一個槽位當上限用的。這個設計經(jīng)常被吐槽但邏輯其實很自洽優(yōu)先級繼承要回答最多能把持有者抬到多高而這個上限必須是一個真實存在、可比較的優(yōu)先級數(shù)值。uC/OS-II 沒有額外開一塊空間存它就直接借用優(yōu)先級池里的一格。注意正因為如此你在 os_cfg.h 里給 OS_LOWEST_PRIO 留的余量必須夠。如果項目里同時用了 3 個互斥量那就要額外準備 3 個優(yōu)先級槽而且這些槽不能再分給任何任務。見過太多次任務數(shù)剛好卡滿 63 個再加一個互斥量就創(chuàng)建失敗的情況。2.3 為什么上限這個參數(shù)不能隨便填一個常見誤區(qū)是既然低字節(jié)存的是持有者的真實優(yōu)先級那高字節(jié)這個 prio 好像只是占位隨便填個沒用到的值就行。這種用法在單層繼承的場景下確實能跑因為 uC/OS-II 恢復優(yōu)先級時抬升用的是等待者的優(yōu)先級回落用的是低字節(jié)里記錄的值加上池里的對照prio 本身參與得不多。但一旦出現(xiàn)多級繼承H 等 LL 又在等另一個互斥量prio 就會參與判斷填得離譜會導致持有者被抬到一個比自己還高的位置或者干脆算錯。穩(wěn)妥的做法是這個 prio 就填所有可能使用這個互斥量的任務中最高的那個優(yōu)先級。比如 H5、M10、L15都會用這把鎖prio 填 5。這個規(guī)則在 uC/OS-III 里被明確寫進了文檔uC/OS-II 的注釋里說得比較含糊所以很多人栽在這里。我在實際項目里養(yǎng)成的習慣是給每把互斥量單獨建一個注釋塊寫清楚使用者TaskH(5)/TaskM(10)/TaskL(15)prio 5占用優(yōu)先級槽 5。半年后回頭看這一行注釋能救命。3. 優(yōu)先級繼承是怎么寫出來的OSMutexPend 逐段過OSMutexPend 的簽名是void OSMutexPend(OS_EVENT *pevent, INT16U timeout, INT8U *perr)比 OSSemPend 多了什么其實沒有多只是內(nèi)部多了一大段。整個函數(shù)可以切成三段來看下面給的是去掉參數(shù)檢查、臨界區(qū)進出和超時處理的精簡骨架主干邏輯都在void OSMutexPend (OS_EVENT *pevent, INT16U timeout, INT8U *perr) { OS_ENTER_CRITICAL(); if ((pevent-OSEventCnt OS_MUTEX_AVAILABLE) OS_MUTEX_AVAILABLE) { /* ---- 第一段快路徑鎖是空的 ---- */ pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; /* 清掉低字節(jié)的空閑標志 */ pevent-OSEventCnt | OSTCBCur-OSTCBPrio; /* 低字節(jié)記下持有者優(yōu)先級 */ pevent-OSEventPtr (void *)OSTCBCur; /* 指向持有者 TCB */ OS_EXIT_CRITICAL(); *perr OS_ERR_NONE; return; } /* ---- 第二段慢路徑鎖已經(jīng)被別人拿著 ---- */ pto (OS_PRIO)(pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8); /* 持有者當前優(yōu)先級 */ ppto OSTCBCur-OSTCBPrio; /* 我自己 */ if (ppto pto) { /* 數(shù)值越小優(yōu)先級越高說明我比持有者高 */ /* 把持有者抬到我的優(yōu)先級這就是繼承動作 */ pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; pevent-OSEventCnt | ppto; /* 同步修改 TCB 里的優(yōu)先級和就緒表里的位置此處省略細節(jié) */ } /* ---- 第三段把自己掛進等待表按優(yōu)先級排序 ---- */ OS_EventTaskWait(pevent); OS_EXIT_CRITICAL(); OS_Sched(); /* ... 被喚醒后處理超時和返回碼 ... */ }3.1 第一段快路徑為什么只動三個字段快路徑干的事只有三件清低字節(jié)、寫持有者優(yōu)先級、把 OSEventPtr 指向當前任務。這三件事的順序不能顛倒尤其是先把低字節(jié)清成 0 再寫優(yōu)先級這一步。如果先寫優(yōu)先級再操作低字節(jié)里就會同時殘留 0xFF 的高位和優(yōu)先級數(shù)值 OS_MUTEX_AVAILABLE的判斷可能出問題。這里還有個小細節(jié)值得留意快路徑里 OSEventCnt 的高字節(jié)完全沒動優(yōu)先級上限還是原樣保留。這印證了前面的說法——上限是靜態(tài)配置持有者優(yōu)先級是動態(tài)狀態(tài)兩者物理上擠在一個 16 位變量里但生命周期完全不同。這種把配置和狀態(tài)打包進一個變量的寫法在資源緊張的年代很常見放到今天的代碼規(guī)范里大概率會被 review 打回來但它確實省了內(nèi)存。3.2 第二段繼承那幾行是整個互斥量的靈魂第二段的判斷條件是ppto pto。uC/OS-II 里優(yōu)先級數(shù)值越小越高所以這個不等式成立就意味著我等待者比持有者優(yōu)先級高應該繼承。這個判斷同時起到兩個作用一是決定要不要抬二是順便擋掉了低優(yōu)先級任務來 Pend 時白白提權的浪費。提權這個動作看起來只有三行實際牽涉的東西不少OSEventCnt 的低字節(jié)要改、持有者 TCB 的 OSTCBPrio 字段要改、持有者在 OSRdyTbl 里占的那一位要從舊位置搬到新位置、OSTCBPrioTbl 里也要跟著換。uC/OS-II 把后面這些打包在內(nèi)部的優(yōu)先級變更邏輯里代碼量不大但很容易讀漏。我建議你在看這一段的時候拿張紙把L 從 15 抬到 5這個變化畫出來標一下 OSRdyTbl 里兩個 bit 的翻轉(zhuǎn)理解會牢固得多。另外還有一個容易忽略的點PIP 是可以通過宏關掉的。os_cfg.h 里OS_MUTEX_EN和OS_MUTEX_DIS決定行為關掉之后互斥量的用法就退化成帶所有權檢查的信號量不再繼承。什么時候會關一般是邏輯簡單、任務數(shù)極少、且能證明不會發(fā)生反轉(zhuǎn)的場景。我個人的建議是別關繼承了不虧關了省不下幾個字節(jié)。3.3 第三段掛等待表之后必須先讓出 CPU第三段是熟悉的套路OS_EventTaskWait把自己從就緒表挪到 pevent 的等待表里然后OS_EXIT_CRITICAL()開中斷最后OS_Sched()觸發(fā)一次調(diào)度。這里有個新手經(jīng)常踩的坑OS_EventTaskWait之后必須在退出臨界區(qū)之后才調(diào)用 OS_Sched如果順序反了會在關中斷狀態(tài)下做一次任務切換棧切換和中斷嵌套會打架表現(xiàn)是偶發(fā)死機很難查。還有個更隱蔽的問題這段代碼執(zhí)行時當前任務的 TCB 已經(jīng)被掛到等待表里了但它仍然占著 CPU直到 OS_Sched 真正切走。中間這段時間里如果有個中斷服務程序調(diào)用了OSMutexPost把鎖放了會不會出問題不會因為此時還在關中斷狀態(tài)ISR 根本進不來。這就是臨界區(qū)存在的意義。提示OSMutexPend 的 timeout 參數(shù)傳 0 表示無限等待這一點和 OSSemPend 一致。如果傳了非 0 值掛起期間時鐘節(jié)拍會把它移到超時鏈表上返回碼是 OS_ERR_TIMEOUT?;コ饬康却瑫r是很危險的狀態(tài)——任務沒拿到鎖卻繼續(xù)往下跑會直接破壞共享數(shù)據(jù)的完整性。如果你用了非 0 超時一定要在所有返回碼分支上都做正確判斷寧可讓任務進錯誤處理也不要假裝成功。4. OSMutexPost 的收尾解鎖、喚醒、恢復優(yōu)先級OSMutexPost 的簽名只有INT8U OSMutexPost(OS_EVENT *pevent)一個參數(shù)因為誰在放鎖這件事內(nèi)核是知道的——它就是當前任務。整個函數(shù)的邏輯主線是確認調(diào)用者是持有者把鎖交給等待隊列里優(yōu)先級最高的那個人如果有恢復被抬高的優(yōu)先級最后觸發(fā)一次調(diào)度。4.1 所有權檢查為什么比信號量多這一道if (OSTCBCur ! (OS_TCB *)pevent-OSEventPtr) { OS_EXIT_CRITICAL(); return (OS_ERR_NOT_MUTEX_OWNER); }這兩行是互斥量和二值信號量在語義上最本質(zhì)的區(qū)別。信號量是票誰拿都能放A 拿了 B 也能放內(nèi)核不管互斥量是所有權只有持有者能釋放。這個約束看起來是多此一舉實際上擋掉了一整類設計錯誤任務 A 因為在臨界區(qū)里出錯提前返回忘了放鎖任務 B 好心幫它放了一把結果共享數(shù)據(jù)在 A 還在寫的時候就被 B 打開了破壞得比死鎖還慘。返回 OS_ERR_NOT_MUTEX_OWNER 的時候很多人會習慣性地忽略返回值覺得反正我知道我在放了。這個假設在調(diào)試期不成立——一旦設計里出現(xiàn)了交叉釋放你忽略的這個返回值就是唯一的線索。我自己的習慣是把它包一層宏失敗時點亮一顆 LED 并記錄任務號和錯誤碼這樣在實驗室里就能發(fā)現(xiàn)不用等到客戶現(xiàn)場。4.2 喚醒等待者直接交接而不是先置空閑放鎖的核心動作是這樣一段如果等待表非空就把鎖直接交給排隊里最高優(yōu)先級的任務而不是先把 OSEventCnt 置成 0xFF 空閑再讓那個任務自己重新 Pend 一次。這個直接交接的設計避免了兩個麻煩一是中間窗口被別的任務插進來搶走鎖二是避免了多一次正常的 Pend 調(diào)用。交接完成之后還要處理優(yōu)先級問題。如果之前為了繼承把某個任務的優(yōu)先級抬高了那么放鎖的時候要把它放回去。這里的邏輯有兩種寫法一種是恢復成創(chuàng)建互斥量時指定的那個上限另一種是恢復成持有者原本的優(yōu)先級。uC/OS-II 走的是相對簡單的路徑而 uC/OS-III 干脆在互斥量里多開了一個字段記錄持有者原優(yōu)先級從根上解決了嵌套場景下恢復不準確的問題。你讀自己那份源碼的時候最快的驗證方式是打開 OSMutexPost看它調(diào)用的優(yōu)先級變更函數(shù)第二個參數(shù)是從哪兒取出來的——是從 OSEventCnt 的高字節(jié)取還是從別的地方取一眼就能看出來。4.3 嵌套與多級繼承的邊界優(yōu)先級繼承不是萬能的它有兩個天然的邊界實際項目里必須知道。第一個邊界是嵌套持鎖。任務 L 持有互斥量 A同時任務 M 也在等 A 并且 M 的優(yōu)先級更高L 被抬到了 M 的優(yōu)先級如果此時 L 又去 Pend 互斥量 B而 B 被另一個低優(yōu)先級任務持有那這個鏈會繼續(xù)傳遞下去嗎uC/OS-II 的處理是有限的鏈長了容易恢復不準確。工程上的對策很簡單不要在持有鎖的時候去申請另一把鎖也就是避免鎖嵌套。真需要嵌套就把幾把鎖合并成一把或者重新設計數(shù)據(jù)訪問路徑。第二個邊界是不參與繼承的等待者。如果等鎖的任務是通過二值信號量或者消息隊列間接依賴共享資源的靠互斥量是感知不到的。也就是說優(yōu)先級繼承只對直接等這把鎖的任務生效間接依賴、跨層依賴統(tǒng)統(tǒng)看不見。真正的解決辦法是設計階段的優(yōu)先級分配時盡量避免長臨界區(qū)把共享資源的訪問時間壓到最短。注意這里說的長臨界區(qū)不是指幾毫秒而是指包含了可能引起阻塞的調(diào)用。臨界區(qū)里絕對不能調(diào)用 OSTimeDly、OSxxxPend、OS_QPost 里帶 Pend 語義的函數(shù)。只要你調(diào)了任何一個會讓出 CPU 的東西臨界區(qū)就不再是臨界區(qū)等于自己拆了保護。5. 上手實操在 GD32F103 上跑一個可復現(xiàn)的優(yōu)先級反轉(zhuǎn)實驗理論講完落到板上。這一節(jié)給出一個完整可復現(xiàn)的實驗設計硬件用 GD32F103Cortex-M372MHz和 STM32F103 引腳兼容移植層可以直接借用軟件是標準 uC/OS-II 加 Cube 風格的外設初始化。整個實驗的目標只有一個量出同一組任務在二值信號量和互斥量兩種保護方式下最高優(yōu)先級任務的阻塞時間差多少。5.1 硬件與工程準備硬件只要三樣一塊 GD32F103 最小系統(tǒng)板、一個 USB 轉(zhuǎn)串口模塊接 PA9/PA10、一根杜邦線把 PA0 引到示波器或者邏輯分析儀上。PA0 用來打點標記臨界區(qū)開始/結束如果你沒有示波器用串口打點也能看只是時間精度差一些。os_cfg.h 里必須確認打開的開關#define OS_MUTEX_EN 1u /* 互斥量模塊必須為 1 */ #define OS_SEM_EN 1u /* 對照實驗要用信號量 */ #define OS_TIME_DLY_HMSM_EN 1u /* 用 OSTimeDlyHMSM 做粗粒度延時 */ #define OS_TASK_STAT_EN 0u /* 實驗階段關掉統(tǒng)計任務減少干擾 */ #define OS_LOWEST_PRIO 63 /* 留夠優(yōu)先級槽 */ #define OS_TICKS_PER_SEC 1000u /* 1ms 一個節(jié)拍方便 OSTimeGet 直接當毫秒讀 */注意 OS_TICKS_PER_SEC 設成 1000 意味著 SysTick 每 1ms 中斷一次。這個頻率在 72MHz 的 M3 上完全扛得住中斷里只做一次 OSTimeTick而且讓后面的時間統(tǒng)計變得極其直觀OSTimeGet 返回的 tick 數(shù)就是毫秒數(shù)。這點小便利在調(diào)時序問題的時候很值。5.2 三個任務的參數(shù)設計任務設計直接照搬第一節(jié)的時間線參數(shù)如下表任務優(yōu)先級行為是否會訪問共享資源TaskH5延時 50ms 后 Pend記錄阻塞時長是TaskM10被激活后忙等 500ms否TaskL15先拿鎖忙等 200ms再放鎖是關鍵點在于TaskM 必須在 TaskL 拿鎖之后、TaskH 之前被激活實驗才有效。實現(xiàn)方式有兩種一是讓 TaskM 自己也延時時間點卡在 60ms 左右二是讓 TaskM 等一個信號量由 TaskL 在進入臨界區(qū)之后 Post。第二種更精確但會引入額外的同步動作可能干擾判斷。我一般用第一種靠粗略的時序?qū)R就夠用了。堆棧大小按 TASK_STK_SIZE 給 256 就夠了Cortex-M3 上 OS_STK 是 32 位也就是每任務 1KB。三個任務加空閑任務一共占用 4KB 左右的 SRAM20KB 的 GD32F103 完全夠。如果開了浮點或者調(diào)用了 sprintf建議提到 512。5.3 核心代碼兩種保護方式的對照先看 TaskL 和 TaskM這兩個任務在兩種模式下代碼完全一樣#define TASK_STK_SIZE 256 #define TASK_H_PRIO 5u #define TASK_M_PRIO 10u #define TASK_L_PRIO 15u static OS_EVENT *pLock; /* 指向信號量或互斥量 */ static volatile INT32U g_h_block_ms; /* TaskH 的阻塞時長 */ static void TaskL (void *p_arg) { INT8U err; (void)p_arg; for (;;) { OSSemPend(pLock, 0u, err); /* 互斥量模式改成 OSMutexPend(pLock, 0, err) */ GPIO_BOP(GPIOA) GPIO_PIN_0; /* 打點進臨界區(qū) */ BusyWaitMs(200u); /* 模擬耗時 200ms 的共享資源操作必須忙等 */ GPIO_BC(GPIOA) GPIO_PIN_0; /* 打點出臨界區(qū) */ OSSemPost(pLock); /* 互斥量模式改成 OSMutexPost(pLock) */ OSTimeDlyHMSM(0u, 0u, 1u, 0u); } } static void TaskM (void *p_arg) { (void)p_arg; OSTimeDlyHMSM(0u, 0u, 0u, 60u); /* 卡在 L 拿鎖之后醒來 */ for (;;) { BusyWaitMs(500u); /* 純計算不碰任何共享資源 */ OSTimeDlyHMSM(0u, 0u, 1u, 0u); } }TaskH 負責測量它的循環(huán)里只做一件事static void TaskH (void *p_arg) { INT32U t0, t1; INT8U err; (void)p_arg; OSTimeDlyHMSM(0u, 0u, 0u, 50u); /* 等 L 先拿到鎖 */ for (;;) { t0 OSTimeGet(); /* 記錄 Pend 之前的時間 */ OSSemPend(pLock, 0u, err); /* 互斥量模式改成 OSMutexPend */ t1 OSTimeGet(); /* 拿到鎖的瞬間 */ g_h_block_ms t1 - t0; /* 阻塞時長單位 ms */ printf(H block %lu ms\r\n, (unsigned long)g_h_block_ms); OSSemPost(pLock); /* 互斥量模式改成 OSMutexPost */ OSTimeDlyHMSM(0u, 0u, 2u, 0u); } }創(chuàng)建部分兩種模式只在初始化那一行不同/* 模式 A二值信號量 */ pLock OSSemCreate(1u); /* 模式 B互斥量prio 取使用者里最高的優(yōu)先級 5 */ pLock OSMutexCreate(TASK_H_PRIO, err);5.4 實測數(shù)據(jù)與觀測要點把兩種模式各跑一遍串口輸出的數(shù)字差別非常明顯保護方式TaskH 實測阻塞時長說明OSSemCreate(1)約 660 msTaskM 的 500ms 被算進了等待里OSMutexCreate(5)約 220 msTaskL 被抬到優(yōu)先級 5TaskM 完全插不進來220ms 這個數(shù)字不是嚴格等于 200ms多出來的 20ms 是幾件事湊出來的TaskL 已經(jīng)跑掉了一部分臨界區(qū)TaskH 是 50ms 才 Pend 的此時 L 大概已經(jīng)忙等了 50ms加上 GPIO 操作、串口打印、調(diào)度開銷。數(shù)量級對得上就說明實驗成功了不用糾結那幾毫秒。用示波器看 PA0 更直觀模式 A 下 PA0 的高電平會被撐得特別長因為 L 被 M 搶占高電平中間還夾著L 被切走的空檔實際上會看到一個高電平被拉長到 660ms 左右模式 B 下 PA0 就是一段干凈的 200ms 高電平形狀非常規(guī)整。這個波形對比比任何文字描述都有說服力面試的時候如果你能把這兩張圖拿出來講基本就不用擔心被追問了。提示BusyWaitMs 必須是自己寫的不讓出 CPU 的空循環(huán)不能調(diào)用 OSTimeDly。這一點是整個實驗成立的前提——如果 TaskL 在臨界區(qū)里用 OSTimeDly 讓出了 CPU那它跟 TaskM 就是普通的優(yōu)先級搶占關系測出來的數(shù)據(jù)毫無意義。我見過不止一個人在這里翻車然后得出優(yōu)先級繼承沒用的結論。6. 常見問題與排查實錄這一節(jié)是我在幾個實際項目里攢下來的都是文檔里不會寫、但碰到一次就記一輩子的東西。6.1 問題速查表現(xiàn)象最可能的原因排查動作OSMutexCreate 返回空指針err OS_ERR_PRIO_EXIST傳進去的 prio 已經(jīng)被某個任務或其他互斥量占了打印 OSTCBPrioTbl 里非空的槽位看誰占了Post 返回 OS_ERR_NOT_MUTEX_OWNER有人替別人放鎖或者任務在 Pend 失敗后仍執(zhí)行了 Post檢查所有 Pend 的返回碼分支優(yōu)先級繼承好像沒生效OS_MUTEX_EN 被關掉了或者 TaskM 的優(yōu)先級其實比 TaskH 低打開 os_cfg.h 確認再看任務優(yōu)先級表系統(tǒng)在臨界區(qū)里死鎖持有鎖的任務 Pend 了另一把被別人持有的鎖用 OSMutexQuery 打印每把鎖的持有者和等待隊列移植后 SysTick 一開就跑飛OS_CPU_SysTickInit 里沒按 72MHz 算重裝載值核對 reload SystemCoreClock / OS_TICKS_PER_SEC - 16.2 三個不太容易想到的坑第一個坑跟中斷服務程序有關。ISR 里能不能 Pend 互斥量不能。OSMutexPend 會掛起當前任務并觸發(fā)調(diào)度而 ISR 上下文里沒有當前任務這個概念OSTCBCur 指向的是被打斷的那個任務但它不是你。ISR 里只能 Post不能 Pend。這個限制對信號量同樣成立但互斥量上更容易犯錯因為直覺上我在中斷里訪問共享資源所以我要加鎖這個想法太自然了。第二個坑是優(yōu)先級上限填錯導致的隱性降級。假設你有三把互斥量prio 全填了 5創(chuàng)建的時候后兩把就會返回 OS_ERR_PRIO_EXIST如果代碼里沒檢查返回值直接往下用pLock 就是空指針OSMutexPend 會返回 OS_ERR_PEVENT_NULL而很多人的 Pend 是不檢查返回碼的——結果就是鎖根本沒生效但程序照跑一直跑到負載上來了才炸。所以我在模板代碼里強制要求所有 Pend/Post 都要判返回碼這點堅持幾年下來救過不少項目。第三個坑是優(yōu)先級繼承和任務優(yōu)先級分配互相打架。如果你的 TaskL 優(yōu)先級本來就比 TaskM 高那反轉(zhuǎn)根本不會發(fā)生實驗也復現(xiàn)不出來。反過來說實際項目里如果發(fā)現(xiàn)某把鎖的繼承動作很頻繁說明任務優(yōu)先級分配本身有問題——可能 L 干了太多本該由更高優(yōu)先級任務干的事。優(yōu)先級繼承是補丁不是設計目標能不動用就不動用。6.3 什么時候不該用互斥量互斥量解決的是任務之間互斥訪問共享資源它有兩個明確的使用邊界。一是任務和 ISR 之間的互斥。ISR 里不能 Pend所以任務和中斷之間共享數(shù)據(jù)時常規(guī)做法是關中斷加內(nèi)存屏障或者用二值信號量做單向通知而不是互斥量。二是純計數(shù)場景。資源池比如三個串口緩沖區(qū)需要的是計數(shù)信號量用 OSSemCreate(3) 配 OSSemPend/Post 更貼切互斥量天然只有一個。硬要用互斥量模擬會白白浪費一個優(yōu)先級槽還丟掉了計數(shù)的表達能力。順帶說一句和 Linux 的對比這個問題面試里出現(xiàn)頻率不低。Linux 內(nèi)核里也有 PI優(yōu)先級繼承相關的鎖比如用戶態(tài) pthread_mutex 配 PTHREAD_PRIO_INHERIT 屬性內(nèi)核里的 rt_mutex 也是按繼承的思路設計的。區(qū)別在于 Linux 面對的是動態(tài)優(yōu)先級、調(diào)度類、CPU 親和性一大堆變量而裸機 RTOS 里的臨界區(qū)通常只有幾十到幾百微秒靜止優(yōu)先級、單核、搶占式調(diào)度所以 uC/OS-II 那個低字節(jié)存優(yōu)先級、高字節(jié)存上限的樸素做法在它的場景里剛好夠用。理解了這個差異也就理解了為什么嵌入式 RTOS 的代碼可以寫得這么摳門。系列寫到這里os_mutex.c 這條線基本通了。下一篇準備啃消息郵箱和消息隊列那邊有個和互斥量完全相反的坑郵箱傳遞的是指針不是數(shù)據(jù)副本一不小心就會把已經(jīng)復用的棧緩沖區(qū)傳出去。