向量表:在快閃記憶體最底端的一個位址陣列
你在兩篇之前就已經見過這張表最開頭的那個欄位了。當晶片上電時,重置向量告訴它你的程式從哪裡開始,而那裡的啟動程式碼在呼叫 main() 之前設好了堆疊與記憶體。那個重置項目並不是什麼特殊的矽晶片魔法——它只是一個普通陣列的第二個字(word),這個陣列就坐落在快閃記憶體的開頭,而整個陣列就是向量表。今天我們來讀它剩下的部分。向量表是硬體事件與你的函式之間唯一的橋樑:每個事件一個固定的欄位,每個欄位裝著「那個事件觸發時要執行的程式碼」的位址。
在一顆典型的 Arm Cortex-M 零件上把它具體化。這張表是一個 32 位元字的陣列。第 0 個字根本不是位址——它是核心在任何東西執行之前載入到堆疊指標暫存器裡的那個初始主堆疊指標值。第 1 個字是重置向量(你啟動程式碼的位址)。第 2、3、4……個字是各種錯誤(fault)以及每一個周邊中斷的處理常式位址:一次計時器溢位、一個 UART 位元組抵達、一支 GPIO 腳位變化。每個周邊都有一個固定的中斷編號,而那個編號就是它在表裡的索引。硬體從不搜尋;它計算 table_base + 4 * index、讀出那裡的位址、然後跳過去。整個查表就只有這樣。
flash @ 0x00000000 +-------------------------------------------------+ | word 0 0x20002000 initial stack pointer (SP) | <- not an address to run | word 1 0x080001c1 Reset_Handler (your crt0) | <- reset vector | word 2 0x08000201 NMI_Handler | | word 3 0x08000211 HardFault_Handler | | ... | | word 16 0x08000401 SysTick_Handler (timer) | <- core exceptions, then | word 17 0x08000431 WWDG_IRQHandler | <- peripheral IRQs begin | word 28 0x08000511 TIM2_IRQHandler | | word 38 0x08000605 USART1_IRQHandler | +-------------------------------------------------+ jump target = *(table_base + 4 * vector_number)
從一條線拉高到你的函式開始執行
位址坐落在某個表欄位裡的那個函式,就是一個中斷服務常式(interrupt service routine,ISR)。一個 ISR 看起來像一個普通的 C 函式——在 Cortex-M 上它字面上就寫成一個不收參數、回傳 void 的普通函式,例如 void TIM2_IRQHandler(void)——但抵達它的路徑不是任何一般呼叫所走的。回想組合語言那一級:一個正常的函式呼叫是由呼叫者設置的:呼叫者推入一個返回位址與參數、然後分支過去。中斷沒有呼叫者。當一個周邊把它的中斷線拉高時,CPU 正在某段不相干的程式碼中途執行某條指令,而矽晶片本身必須安排好一次呼叫所需要的一切,沒有任何編譯器產生的前置碼(prologue)來幫忙。
於是硬體來做這件事。在 Cortex-M 上,當一個中斷被接受時,核心會自動把一組固定的暫存器推入當前的堆疊——就是 C 函式被允許破壞的那些、你先前認識的呼叫者保存(caller-saved)那一組——這樣你的 ISR 就能自由使用它們,而被中斷的程式碼在返回時會發現它們原封不動。接著核心從表欄位載入程式計數器、開始執行你的 ISR。這個自動保存,正是為什麼在這個架構上一個 ISR 可以是一個沒有特殊屬性的普通 C 函式:C 呼叫慣例假設會被保存的那些暫存器,已經由矽晶片在你第一條指令執行之前替你保存好了。整個序列——接受、堆疊暫存器、擷取向量、跳轉——是中斷延遲的前緣:從事件發生到你的程式碼真正開始執行之間的時間。
NVIC:中斷的交通管制員
在「幾十個爭著要注意的周邊」與「一次只能跑一個 ISR 的那一顆 CPU」之間,坐著一小塊硬體:巢狀向量中斷控制器(nested vectored interrupt controller,NVIC)。它是核心裡擁有向量表、並決定誰得到 CPU 的那一部分。「向量(vectored)」意指它用硬體做我們剛才描述的那個查表。「巢狀(nested)」則意指這篇真正要談的那部分——一個較高優先權的中斷被允許去中斷一個已經在執行的 ISR。NVIC 是透過記憶體映射暫存器(下一篇的主題)來設定的,在那裡你可以為每個中斷啟用它、看它是否處於擱置(pending)狀態、並且——關鍵地——指派它一個優先權。
優先權就是整場遊戲。每個中斷帶著一個小小的優先權數字,而在 Arm 上慣例與直覺相反:較小的數字代表較高的急迫性。優先權 0 最急迫;較大的數字要等。當好幾個中斷同時擱置時,NVIC 先跑數字最小(最急迫)的那一個。而當一個 ISR 在執行時,NVIC 會把任何新抵達的中斷的優先權,和當前正在執行的那一個比較:如果新來者更急迫,它就搶佔(preempt)——正在執行的 ISR 被暫停、它的暫存器再一次被自動堆疊到第一組之上、那個更急迫的 ISR 跑到完成,然後才輪到被暫停的那個恢復。那個「往上堆疊」正是「巢狀」的意思,也是為什麼這些處理常式必須讓自己的堆疊胃口保持節制:深層巢狀會一層一層地堆疊框架(frame)。
設計優先權:延遲、期限,以及一個叫做反轉的陷阱
為什麼要指派優先權?因為在一個真實系統裡,有些事件真的等不了,而有些事件等等也無妨。一個安全互鎖、或一個馬達換相計時器,可能有一個硬性的期限——只有在比方說 5 微秒內發生,這個回應才算正確——而一個 UART 位元組可以容忍數十微秒的延遲。給那個受期限驅動的事件一個較高的優先權(較小的數字),好讓它能搶佔那個寬鬆的,你就替它的最壞情況買到了一個保證。這就是即時思維在裸機上的種子:問題從來不是「它平均很快嗎?」,而是「在這個特定事件被服務之前的最壞情況延遲是多少?」——也就是在其他中斷最糟糕的堆積之下,它的中斷延遲。
現在來看那個每個人都會被咬一次的陷阱。假設一個低優先權的 ISR 抓住了一個共享資源——短暫地關閉中斷、或持有系統其他部分會用到的一個鎖。在它持有期間,一個高優先權的中斷觸發了,而它也需要那個資源。那個高優先權處理常式,儘管是系統裡最急迫的東西,現在卻卡住、等著那個低優先權的去釋放。那就是優先權反轉(priority inversion):優先權實際上被顛倒了,急迫的任務被一個卑微的任務擋住。在它最糟的形式裡,一個既不需要這個也不需要那個資源的第三個、中等優先權的中斷搶佔了那個低的、把它餓死、於是它永遠不釋放,而高優先權任務便無界地等下去——這正是 1997 年讓 NASA 火星拓荒者號當機的那個失效。把優先權反轉當成一個你會主動檢查的設計危害,而不是一場罕見的意外。
與一個 ISR 分享資料,以及那個你不能略過的關鍵字
一個 ISR 幾乎總是必須和你的主迴圈溝通:一個 UART 處理常式填一個緩衝區、由主迴圈排空;一個按鍵處理常式設一個旗標、由主迴圈輪詢。但這個 ISR 在一個無法預測的瞬間、非同步地執行,恰恰像是第二條控制流,而且看不到任何排程器。這使得任何在 ISR 與主迴圈程式碼之間分享的變數都變成一個並行問題——一個等著發生的競爭條件——即使在一顆根本沒有作業系統的單核微控制器上也一樣。這裡住著兩個不同的危害,它們需要兩個不同的修法,而初學者老是把它們搞混。
第一個危害是編譯器。你的主迴圈寫著 while (!flag) { },輪詢一個 ISR 會去設的變數。最佳化器看到迴圈裡沒有任何東西改動 flag,便有權把它讀進一個暫存器一次、然後永遠在那份過時的副本上空轉——迴圈永遠看不到 ISR 的寫入。修法就是你以「型別限定詞」之名認識過的那個限定詞:把它宣告為 volatile sig_atomic_t flag,而 volatile 強迫編譯器在每一次存取時都從記憶體重新讀取這個變數,於是它看得見 ISR 的更新。這是裸機上最常見的那一個臭蟲,而它在 -O0 時消失、在 -O2 時又現身——一個課本級的提醒:最佳化器有權去假設你的程式碼沒告訴它的事。
第二個危害不是 volatile 能解決的,而相信它能,是危險的。如果共享的項目比單一一次原子存取還大——一顆 8 位元 MCU 上的 32 位元計數器、或一個多欄位的結構——主迴圈就可能讀到它更新到一半的樣子,因為 ISR 是在寫入中途觸發的。volatile 保證的是新鮮的讀取,不是不可分割的讀取;它不是一把鎖。裸機上的修法是親手讓這次存取成為原子的:在對共享物件的「讀—改—寫」前後,短暫地關閉相關的那個中斷,然後再重新啟用。把那個關閉的窗口保持在寥寥幾條指令——正是那個擋下優先權反轉的「最短可能臨界區段」。下一篇會把 volatile 與周邊暫存器完整拆解開來;現在,先握住這兩條規則:volatile 管可見性、短暫關中斷管原子性,而且絕不在需要其中一個的地方用了另一個。
把它組合起來,以及它通向何方
- 一個周邊拉高它的中斷線;NVIC 檢查這個中斷已被啟用,並把它的優先權和當前正在執行的東西比較——較小的優先權數字勝出,並可能搶佔。
- 核心自動堆疊呼叫者保存的暫存器、用中斷編號在向量表裡查出處理常式位址、把它載入程式計數器,於是你的 ISR 開始——沒有呼叫者、沒有編譯器前置碼。
- ISR 做最低限度的急迫工作、在一個短暫的關中斷之下碰任何共享狀態、然後透過那個特殊的例外返回哨兵返回,它會還原被自動堆疊的暫存器、並恢復被中斷的程式碼。
- 在整個設計裡,你指派優先權,好讓受期限約束的事件搶佔寬鬆的事件,並把每個上鎖窗口保持得很短,好讓優先權反轉無法悄悄潛入。
退一步看,向量表是一個美麗而簡單的想法,做著一件深刻的工作:它是那唯一的一張表,讓笨拙的硬體——一條拉高的線——能伸手上來、執行你一個有名字的 C 函式,而由 NVIC 仲裁誰先抵達。關於中斷,所有困難的部分都不在機制,那只是一次陣列查表,而在並行:一個 ISR 是第二條控制流,能在任何一瞬間插進來,而正是這一個事實,同時是「過時旗標」臭蟲、「讀到一半的結構」、以及優先權反轉的源頭。
這篇有兩條線索現在往前延伸。那個我們一直延後的承諾——一個記憶體映射周邊暫存器究竟如何表現、為什麼 volatile 是強制的卻又不夠——就是下一篇的全部。而那個更大的結構,在那裡你不再親手雜耍優先權與共享旗標,而是讓一個即時作業系統帶著正式的期限保證去排程任務,則為這一級收尾。中斷是這一級的地基:掌握向量表,以及「短而具優先權意識的處理常式」這個紀律,它之上的每一層便都站在堅實的地面上。