同一道門,這次從裡面看
在緊接於此之前的開機指南裡,我們看著韌體把控制權交給核心;而早在基礎篇裡,我們把系統呼叫畫成銀行的櫃員窗口:使用者模式的程式無法把手伸進金庫,所以它填一張單,受信任的核心便代它去做那項特權工作。那幅圖是真的,但它刻意是個黑盒子。整篇指南就是要把這個盒子撬開。讀完之後,你應該能把一次像「read(fd, buf, n)」這樣的呼叫,從你的原始碼一路追蹤過去——跨越那道大分界進入核心模式,再返回——並且明白是什麼在守護著每一步。
這裡有個最有用的重新框定。從核心內部看,系統呼叫不是你呼叫的一個函式;它是一件發生在 CPU 身上的事件。處理器原本正愉快地在使用者模式跑著你的程式碼,接著某一條特殊指令把它猛地拽過界線、送進一個固定的核心進入點——這正是硬體中斷所用的同一套機械裝置。所以整套機制其實是在回答一個問題:一支照設計就不能執行特權指令的程式,要如何刻意而安全地讓 CPU 改去執行核心的程式碼,而非它自己的?
把一個誠實的區分保持清楚,就是你曾遇過的 API 與系統呼叫的差別。當你在程式語言裡寫「printf」或開啟一個檔案時,你呼叫的是一個函式庫函式——廣義上的一種 API。那段程式碼大部分是一般的使用者模式工作:格式化字串、緩衝位元組。只有在它跨入核心的那一精確瞬間,它才成為真正的系統呼叫;而單單一次函式庫呼叫,可能造成零次、一次或多次的跨越。API 是那張親切的臉;系統呼叫則是底下那次真正的過境。
包裝函式與 ABI:談妥那張單子
在你那親切的「read(fd, buf, n)」和裸的陷阱指令之間,夾著一小塊膠水:包裝函式(wrapper)。它是個小小的存根,通常隨 C 函式庫一起出貨,唯一的工作就是把你的請求依核心期待的精確樣式打包好,然後觸發陷阱。把它想成銀行那張標準化的提款單:你不能在大廳裡對著櫃員嚷嚷你的請求;你得把它寫在櫃員唯一會收的那張表格上、填進他會讀的那些格子、依他閱讀的順序填好。
那張表格的精確規則,就是系統呼叫的 ABI——應用程式二進位介面。ABI 是一份嚴格的、機器層級的合約,它把高階程式設計師從不去想的細節釘死:哪個 CPU 暫存器存放指明你要哪個呼叫的系統呼叫號碼、哪些暫存器以什麼順序存放各個參數、回傳值從哪裡回來、錯誤又如何被標示。以 64 位元 Linux 為例,慣例是把呼叫號碼放進某個暫存器、把前幾個參數放進另一組固定的暫存器;包裝函式的工作,就只是把那些暫存器正確地載入。只要把這份合約弄錯一個暫存器,核心讀到的就是亂碼。
陷阱:一條指令跨越分界
暫存器都載入好之後,包裝函式執行那條陷阱指令——在現代 x86-64 上這是專用的「syscall」指令;較舊的設計則使用軟體中斷。這單單一條指令,就是真正的過境,而它會以不可分割的方式、由硬體一氣呵成做好幾件特權工作,因此程式無法做到一半停下、落進某種未定義的中間狀態。它把模式位元從使用者模式翻成核心模式、切換到核心自己的堆疊,並跳到一個固定的、事先安排好的核心進入點。關鍵在於:程式無權選擇自己落在核心的哪裡——那個目的地早在開機時就由核心定好了。
為什麼進入點非得是固定、而且由核心選定不可?因為如果使用者程式能陷阱跳到它喜歡的任何位址,它就能直接越過核心的安全檢查、跳進某個特權常式的中段——整個保護機制就形同虛設了。硬體靠一張核心在開機時填好的表來執行這條規則。在 x86 上,對中斷與陷阱而言,那就是中斷描述符表:一個小陣列,每一種事件對應一個項目,每個項目都指向核心註冊的那個受信任處理常式。陷阱指令對那張表做索引,所以目的地永遠是核心事先核准過的。無論事件是鍵盤中斷、分頁錯誤,還是一次刻意的系統呼叫,用的都是同一族的機制。
這次跨越是有真實代價的,值得誠實地點出來。切換模式、把使用者暫存器存起來以便日後復原、切換堆疊、以及把 CPU 那條很深的管線清空——這些都要花時間,遠多於你自己程式內部的一次普通函式呼叫。這正是為什麼基礎篇曾警告:一支做上百萬次微小系統呼叫的程式,可能慢得出奇;也正是緩衝這類技巧存在的原因:每次跨越多做一點事,於是少跨越幾次。
進入核心之後:那張表,與那次小心的複製
此刻控制權已在核心之內、停在那一個固定進入點、處於核心模式、踩在核心的堆疊上。核心的第一步,是去看包裝函式留在某個暫存器裡的系統呼叫號碼,並用它當索引去查系統呼叫表——一個簡單的陣列,其中第 N 號項目存放著實作第 N 號呼叫的那個核心函式的位址。read 也許是 0 號、write 是 1 號、open 是 2 號,依此類推。想像一座有編號插孔的總機:來電者報出一個號碼,接線生就插進那一個插孔。正是這層間接,把門維持得很窄——使用者程式能請求的東西,是一份有限而固定的清單,清單之外的一切都搆不著。
接下來這部分,正是真實核心與玩具核心的分水嶺:它對使用者程式交來的東西,一樣都不信。這就是跨越使用者/核心界線的參數驗證,也是系統呼叫實作那顆跳動的心臟。假設這次呼叫是「read(fd, buf, n)」。核心必須檢查 fd 確實指名一個你有權讀取的已開啟檔案、n 在合理範圍、而且——最危險的一點——buf 指向的是你自己的記憶體,而非核心的。一支惡意程式巴不得傳進一個指向核心記憶體、或指向另一個行程記憶體的 buf,盼著核心好心地把祕密位元組複製過去。所以核心從不直接解參考一個使用者給的位址;它改用特殊、經過仔細檢查的複製常式,在搬動任何一個位元組之前,先驗證整段範圍都屬於這個發出呼叫的行程。
完整的來回旅程,從頭到尾
讓我們把每一塊拼起來,追蹤一次具體的呼叫:你的程式執行「read(fd, buf, n)」,想從一個已開啟的檔案讀 n 個位元組到緩衝區裡。順著這趟跨越分界、再返回的旅程走,留意我們一路堆起來的每一步——包裝函式、ABI、陷阱、系統呼叫表、驗證——都恰好依序出現一次。
- 你的程式碼呼叫 read(fd, buf, n)。這進入 C 函式庫的包裝函式,那仍是在你的行程裡執行的一般使用者模式程式碼。
- 包裝函式依循 ABI:把 read 的系統呼叫號碼放進約定好的暫存器,把 fd、buf、n 放進參數暫存器,然後執行陷阱(syscall)指令。
- 硬體不可分割地把模式位元翻成核心、切換到核心堆疊,並跳到描述符表所指名的那一個固定進入點。控制權此刻已在核心之內。
- 核心讀出系統呼叫號碼,用它去索引系統呼叫表,找到實作 read 的那個核心函式的位址。
- 那個函式驗證一切:fd 是不是一個你有權讀取的已開啟檔案、n 是否合理、以及從 buf 到 buf+n 的整段範圍是否都落在你自己的位址空間之內?只要任何一項檢查未過,它就回傳一個錯誤碼,而不去做那項工作。
- 若所有檢查都通過,核心便去做那項特權工作——它從檔案取出位元組,並用那個安全的複製常式把它們搬進你的緩衝區。
- 核心把結果(讀到的位元組數,或一個負的錯誤值)放進回傳暫存器、把模式位元翻回使用者,並返回到包裝函式;包裝函式再把這個值交還給你的程式碼,彷彿 read 自始至終都只是個普通函式。
退一步,欣賞它的形貌。系統呼叫是一支編排得密不透風的舞:跨越那道大分界唯一的途徑,是一扇由核心選定的窄門,而核心把每一位訪客都當成陌生人,直到對方被證明無害為止。包裝函式藏起醜陋之處、ABI 釘死那次精確的握手、陷阱以不可分割的方式完成跨越、系統呼叫表把請求的集合維持得有限而固定、驗證則守衛著那道界線。這同一副骨架——陷阱進入固定進入點、透過表來分派、驗證、做特權工作、返回——你會在下一篇指南裡再次認出它,只不過那時把 CPU 拽進核心的,是一個硬體中斷,而非你自己的程式。