JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

中斷的內部:向量與處理常式

第 2 篇導覽告訴你門鈴存在。這一篇帶你走進屋子裡:那條門鈴線插在哪裡、CPU 怎麼能瞬間知道該往哪個房間跑,以及那個聰明的兩段式把戲——它讓核心能在數微秒內應門,又不會錯過下一聲鈴響。

從門鈴到位址

在第 2 篇導覽裡你認識了與裝置溝通的三種方式,也看到了為什麼中斷驅動 I/O勝過輪詢:與其讓 CPU 站在門口問上千次「你好了沒?」,裝置會在真的完成時按一下門鈴。我們把那個門鈴叫做中斷,然後就先擱著。但如果你不知道鈴聲是從哪扇門來的、也不知道該怎麼處理,門鈴就毫無用處。一棟真正的房子有很多扇門——鍵盤、磁碟、網路卡、計時器——而且它們都可能響。這篇導覽會把門鈴的線路拆開,讓你看清楚 CPU 究竟是如何把一聲鈴響變成正確的動作。

這裡是核心的點子,其餘的一切都掛在它上面。每一個中斷都帶著一個小小的數字,叫做它的中斷號碼或向量——把它想成那扇門的門牌號。當鍵盤的控制器發出中斷時,它不只是大喊「有人來了!」;它實際上是說「這是 33 號中斷」。CPU 拿到那個數字,把它當成索引去查一張核心在開機時建好的對照表。那張表正是整套機制的心臟:給它一個向量號碼,它就告訴 CPU 處理那個裝置的程式碼的確切記憶體位址。不必搜尋、不必猜——一個數字、一次查表、一個位址。門鈴不只是響;它的響法本身就告訴你該往哪個房間跑。

向量表:處理常式的通訊錄

那張對照表有個正式的名字。在 x86 風格的硬體上它叫做中斷描述符表,通常簡稱 IDT;通用的說法是中斷向量表。把它想成貼在櫃台裡的大樓管理員通訊錄:第 14 列代表「分頁錯誤,執行這支常式」、第 33 列代表「鍵盤,執行那支常式」,依此類推。核心在開機過程中把這本通訊錄填好——遠在任何裝置被允許按鈴之前——再讓一個特殊的 CPU 暫存器指向它,好讓硬體知道通訊錄放在哪裡。從那一刻起,查表就純粹是硬體的事:CPU 自己做索引,以奈秒計,整個找位址的過程沒有任何軟體插手。

interrupt descriptor table  (one row per vector)

  vector | source              | handler address
  -------+---------------------+----------------
    0    | divide-by-zero      | 0x...A100
   14    | page fault          | 0x...B240
   32    | timer               | 0x...C080
   33    | keyboard controller | 0x...C300   <-- key pressed
   46    | disk controller     | 0x...C7F0

  CPU sees vector 33  ->  jump to 0x...C300  (the keyboard handler)
向量號碼只是一個索引。「33 號中斷到了」就變成「跳到第 33 列裡那個位址」——這是硬體替你完成的一次查表。

值得停下來想想,為什麼用表會勝過那個顯而易見的替代方案。你可以想像 CPU 在每次中斷時跑一長串 if-else——「是鍵盤嗎?不是。是磁碟嗎?不是……」——但那會讓慢速裝置變得人為地更慢,而且成本會隨著裝置數量增加。用表能讓每次查找的成本都一樣,不管你有多少裝置:這就是翻到索引正確那一頁,與從頭到尾讀完整本書只為了找一個事實之間的差別。這跟你在分頁表與硬體做轉譯時看到的是同一種直覺——當硬體能做直接查找時,就讓它去做。

鈴響時實際發生的事

讓我們一步步追蹤一次按鍵,就像你在紙上追蹤一次分頁錯誤那樣。假設你的程式正忙著在迴圈裡加數字,這時你按下了「k」鍵。鍵盤控制器鎖存掃描碼,並發出 33 號中斷。從 CPU 的角度看,中斷是一場被迫的繞道:它必須放下手邊的事、執行正確的處理常式,然後回到它原本離開的那個確切位置,彷彿什麼都沒發生過。那個「彷彿什麼都沒發生過」正是困難之處,也是為什麼底下這些步驟對保存狀態如此小心。

  1. 完成目前這條指令。CPU 不會在指令的半途停下;它會把正在進行的那次加法做完,讓機器停在一個乾淨、定義良好的狀態。直到那時,它才檢查是否有待處理的中斷。
  2. 只保存剛剛好夠用的狀態。硬體把程式計數器(待會要回到哪裡)和狀態旗標推上堆疊,接著把 CPU 切換進核心模式。這是一次極小、極快的保存——還不是把每個暫存器都存下來的完整上下文切換
  3. 查向量。CPU 拿著 33 這個數字,索引進中斷描述符表,讀出處理常式的位址,然後跳過去。鍵盤處理常式現在開始執行了。
  4. 服務裝置。處理常式從控制器的資料暫存器讀出掃描碼,存進核心的緩衝區,並告訴控制器「收到了」,好讓控制器把中斷線拉低、騰出手來回報下一個按鍵。
  5. 從中斷返回。一條特殊指令把先前存下的程式計數器和旗標從堆疊彈回來,把 CPU 切回原本的模式,執行就回到你那個迴圈裡的下一條指令上接著跑——你的程式甚至根本沒察覺。

兩段式把戲:上半部與下半部

那段追蹤裡藏著一個深層的矛盾。當一支處理常式執行時,通常會把進一步的中斷擋住,免得這支處理常式自己在工作半途被打斷。這意味著處理常式必須很快——它每花一微秒,就是門鈴有一微秒無法為別人響起。但裝置需要的工作可能很重:解碼一個網路封包、把一個磁碟區塊複製到正確的地方、喚醒那個一直在等資料的行程。你沒辦法又做重活又保持快速。經典的解法是把中斷服務常式一分為二,也就是著名的上半部與下半部

想像一間忙碌的餐廳。一道菜好了,廚房就敲一下鈴。應鈴的服務生在鈴邊只做最低限度的事——抓起盤子、記下這是哪一桌的——然後立刻讓開,好讓鈴騰出來給下一道菜。把盤子端過整個房間、添水、跟客人寒暄:這一切都在之後、有喘息空間時才做。上半部就是鈴邊的那位服務生:它現在立刻執行,關著中斷,只做緊急、不能等的工作——回應裝置、在控制器裡的資料被覆寫之前把它取出來、並把其餘的工作排到稍後。然後它很快就返回。

下半部是服務生之後做的一切——那些較重、較不緊急的處理。核心會在稍後一刻執行它,並重新打開中斷,所以當它埋頭處理一個網路封包時,門鈴是空著、可以再響的。不同的系統給下半部不同的名字(Linux 上的 softirq、tasklet、work queue;Windows 上的延遲程序呼叫),但各處的點子完全相同:把「現在就得慌」的部分立刻做掉,把「可以等」的部分延後。這種拆分,正是一台忙碌的伺服器能夠每秒處理數十萬次中斷而不被嗆住的最重要的單一原因——那扇門幾乎從不會被長時間擋住。

誠實的角落:優先權、共用,以及中斷的代價

真實的機器有很多裝置,卻只有少數幾條中斷線,所以有兩件事讓上面那幅整潔的圖變得複雜。第一,中斷有優先權層級:一張網路卡完成工作,不該被允許去耽誤那個讓你的時鐘走得準的系統計時器。硬體會讓高優先權的中斷搶占較低優先權的處理常式,好讓緊急事件插隊——這跟優先權排程是同一種直覺,只是套用在硬體事件上。第二,好幾個裝置可能共用同一條中斷線,所以當那條線被拉高時,核心光看線本身分辨不出是誰按的鈴。它的處理常式必須客氣地問那條線上的每個裝置「是你嗎?」,直到找出元兇——這是住在中斷系統裡頭的一小劑輪詢。

現在來算一筆誠實的帳,因為第 2 篇導覽把中斷賣給你,說它是輪詢浪費的解藥,這沒錯,但並非免費。每一次中斷都要付出代價:那場被迫的繞道、狀態的存與還、快取變冷,以及進出核心模式的跳轉。對一個不停觸發的裝置來說——一張滿載的 10Gb 網路卡幾乎每收到一個封包就發一次中斷——這些代價會堆積起來,CPU 可能花在應門上的時間比花在做事上還多,這是個真有其事的狀況,叫做中斷風暴。實務上的修法是以毒攻毒,加一點輪詢:在重負載下,高階的驅動程式會切換到輪詢模式,並讓中斷合併,好讓一聲鈴應付許多封包。第 2 篇導覽的教訓依然成立,但要加個註腳:事件偶爾發生時中斷勝出,而當事件接連不斷時輪詢可能再次勝出。

最後再連回第 2 篇導覽一次,好讓整個層級保持連貫。直接記憶體存取並沒有廢除中斷——它讓每一次中斷的價值大得多。沒有 DMA 時,CPU 會為傳輸的每一小塊都吃一次中斷;有了 DMA,控制器會自己把一整塊資料搬好,只在最後發一次中斷,說「整塊都進記憶體了,來處理吧」。所以 DMA 和中斷是夥伴,不是對手:DMA 做大宗的搬運,而單獨一次中斷就是那聲告訴核心「搬完了」的鈴。手上有了向量、處理常式,以及上半部/下半部的拆分,你已經準備好迎接下一篇導覽,它會再往上跳一層,談裝置驅動程式——那段知道每個特定裝置的位元組究竟代表什麼意思的程式碼。