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

開機、陷阱,與中斷機制

核心不會自己啟動,而一旦跑起來,它的一生都在被打斷。本篇追隨機器從通電後的第一條指令,走到一個穩定狀態:在那裡,每一次按鍵、每一個計時器滴答、每一次系統呼叫,都匯流進一個優雅的機制——陷阱。

從通電到核心的第一條指令

上一篇你依形狀把核心分了類——單體式、微核心、混合式。本篇問一個每種形狀都必須回答的更基本問題:這東西究竟是怎麼進到記憶體、開始執行的?當你按下電源鍵,CPU 不會神奇地找到你的核心。它是一塊矽,在重置時只知道一件事:一個固定、硬接線的位址,從那裡擷取它的第一條指令。在小型微控制器上那個位址是重置向量;在 PC 上韌體扮演同樣的角色。重點是,最開頭那段程式碼不是由軟體選的——它被燒進硬體裡,因為總得有個起點,是不需要任何其他軟體先存在的。

那第一段程式碼很小,它唯一真正的工作是載入某個更大的東西。這就是開機載入器:一個小程式,使命是在磁碟或快閃晶片上找到核心映像、把它複製進 RAM、把機器設置到剛好足以執行它的程度,然後跳到核心的進入點。這是一場信任的接力賽——韌體把棒子交給開機載入器,開機載入器再交給核心——每一棒選手都只做最少的事,好讓下一棒更有能力的選手能跑起來。開機載入器不是核心;它是替核心騰出空間、然後讓到一旁的那個東西。

當核心的進入點終於執行時,機器處在一個粗糙、半成品的狀態,而核心開機序列就是一邊住在房子裡、一邊把房子蓋完的刻意工程。它把 CPU 切換到完整的定址模式,建立給它一個核心位址空間的分頁表,偵測有多少 RAM、有哪些裝置在場,喚醒其他 CPU 核心,而且——對本篇至關重要——安裝那張告訴硬體「有東西打斷時該跳去哪」的表。只有在這一切之後,它才啟動第一個使用者行程。開機不是一個瞬間;它是一道謹慎的次序,每一步都假設前面幾步已經成功了。

在 CPU 眼裡看起來相同的三件事:陷阱、錯誤、中斷

一旦跑起來,核心幾乎從不只是「從頭跑到尾」。它多半閒著,直到有事發生,然後做出反應。每一次這種反應背後的硬體機制都是陷阱(trap):一個事件強制把 CPU 從它正在做的事轉向一個指定的處理常式,並在轉向時把權限從使用者層級切到核心層級。妙處在於一個機制服務三種不同的事件,而初學者老是把它們混在一起,所以讓我們把它們講清楚。

錯誤(fault,又稱例外)來自當下正在執行的指令出錯或需要協助:除以零、一次權限環違規、或一個有效但尚未在記憶體裡的分頁。它是同步的——由 CPU 剛剛嘗試的那條指令本身造成,而且如果你再執行那條指令一次,它會重現。狹義的陷阱(trap)是一次刻意的、由軟體請求的進入核心——那條指令的全部目的就是「我要一項核心服務」,這正是一次系統呼叫跨越界線的方式(第三篇會完整解剖它)。中斷(interrupt)則是個異類:它來自 CPU 外部——網路卡收到一個封包、計時器晶片觸發、某個鍵被按下——而且它是非同步的,與當下碰巧正在執行的那條指令毫無關係。同一套遞送機具,三個非常不同的來源。

派發表:CPU 怎麼知道該跳去哪

當一個陷阱觸發時,CPU 必須立刻找到對的處理常式——沒有時間用軟體去查任何東西,因為被打斷的程式碼正凍結在某條指令的中途。答案是一張核心在開機時建好、並讓硬體指向它的表:在 x86 上是中斷描述符表(IDT),在小型 ARM 核心上是餵給 NVIC中斷向量表。每個事件有一個編號——一個向量——而那張表不過是一個以該編號為索引的陣列,其中第 n 項存著事件 n 的處理常式位址。CPU 的整個決策就是:取向量、索引表、跳過去。這跟一個函式指標陣列是同一個想法,只是改由硬體替你做索引,在奈秒之內,就在事件到達的那一刻。

event arrives  -->  vector number n  -->  IDT[n] = &handler  -->  CPU jumps

  vector   meaning                       handler runs in
  ------   ---------------------------   ----------------
  0x00     divide-by-zero (fault)        kernel mode
  0x0E     page fault (fault)            kernel mode
  0x20     timer tick (interrupt)        kernel mode
  0x21     keyboard (interrupt)          kernel mode
  0x80     legacy system call (trap)     kernel mode

  on entry the CPU also: switches to the kernel stack,
  saves the interrupted rip/rsp/flags, raises privilege
一張表,多個向量。錯誤、中斷、與一次刻意的系統呼叫陷阱全都索引同一個陣列;硬體在進入時切換堆疊與權限,好讓處理常式從穩固的核心地基上開始。

注意硬體在進入的那一刻悄悄你做的事,因為那是承重的魔法。它不只是跳;它切換到一個受信任的核心堆疊(你絕不能在使用者控制的堆疊指標上執行核心程式碼),它儲存足夠的被打斷狀態——指令指標 rip、堆疊指標 rsp、與旗標——好讓核心稍後能把受害者精確地從原處續行,並且它提升權限層級,好讓處理常式能碰使用者程式碼碰不到的硬體。當處理常式結束時,單獨一條「從中斷返回」指令一步逆轉這一切:還原儲存的 rip/rsp/旗標、降回權限,被打斷的指令就彷彿什麼都沒發生地繼續。從被打斷的程式的角度看,時間只是跳了一下。

上半部、下半部:現在做一點點,其餘的稍後再做

這裡有個塑造所有真實中斷處理的張力。當一個硬體中斷處理常式執行時,它身處一個怪異又危險的地方,叫做中斷情境:它打斷了某個倒楣的執行緒,它自己不是一個執行緒,它沒有行程可以阻塞在上面,而且——最重要的——當它執行時,其他相同或更低優先權的中斷被擋住。你花在那裡的每一微秒,都是機器對新事件部分失聰的一微秒。所以鐵律是:在處理常式裡做盡可能少的事,然後快點出來。但一個網路封包或磁碟完成確實需要做真正的工作——複製緩衝區、喚醒那個在等待的執行緒。你要怎麼調和「做很少」與「做很多」?

解法是把工作一分為二——上半部與下半部設計。上半部是真正的硬體中斷處理常式:它立刻執行,在中斷情境裡、關著中斷,而且只做最緊急的最小限度——向裝置確認,讓它別再嚷嚷、把原始資料藏到安全的地方、並排程其餘的工作稍後發生。然後它返回,讓機器重新能聽見新事件。下半部就是那被排程的剩餘部分:粗重活——解析封包、把它往上複製、喚醒一個等待者——核心會在片刻之後,在一個更寬鬆的情境裡執行它,那裡中斷又開著,而且在許多版本中它甚至可以睡眠。

一個樸實的比喻:上半部是櫃台接待員,當快遞按鈴時,他五秒內簽收包裹、丟進收件匣——快速、絕不閒聊,好讓下一個快遞不會被晾在門口。下半部是你,稍後,真正打開包裹、處理裡面的東西。接待員絕不能在門鈴一直響的時候坐下來讀你的信。這單一的分割——現在確認、稍後處理——是每一個守規矩的裝置驅動程式的心跳,而你一動手寫驅動程式就會再遇到它。

為什麼這重要,以及它通向何處

退一步,看見這份統一。讓排程器把 CPU 從一個霸佔不放的行程手裡拿回來的計時器中斷、由位址空間機具透過把記憶體分頁進來而處理的分頁錯誤、以及一個程式用來請求一個檔案的刻意陷阱——這三者都從同一道門進入核心,那張派發表,用同一套切換堆疊並儲存的舞步。學會那一道門,大量的核心行為就不再神祕。核心,粗略地說,是一大堆接到一張表上的陷阱處理常式,加上它們喚醒的執行緒。

這也重新框定了你在這道階梯早期遇到的一句話。「核心在背景執行」是個溫柔的半真半假;更精確地說,核心是為了回應陷阱而執行。多數時候 CPU 在執行使用者程式碼或閒置,而核心只在那張表被索引時才奪取控制權。連核心搶佔——核心在自己執行核心程式碼時切換任務的能力——歸根結柢也是由計時器中斷到達、某個處理常式決定該換別人跑了所驅動的。陷阱不是眾多功能之一;它是整個作業系統跳動所依循的脈搏。

理解了那道門之後,下一篇會走過那一種你,身為程式設計師,每秒刻意觸發好幾千次的陷阱:系統呼叫。你會精確看見,在 C 裡呼叫 read() 與核心讀取你的檔案之間,究竟發生了什麼——那次模式切換、一個編號如何選出你想要哪項服務、以及核心如何安全地把資料跨過它剛剛如此謹慎守護的使用者/核心邊界複製過去。本篇裡的一切——那張表、權限的提升、堆疊的切換——都是舞台;第三篇才是那齣戲。