橫越界線的那一扇門
第一篇裡,我們在這台機器中畫了一條硬性的線:你的程式碼跑在使用者模式,CPU 在這裡禁止那些危險的指令;而核心跑在享有特權的模式,被允許碰觸磁碟、網路卡,以及每個程式的記憶體。那條線不是建議——硬體會強制執行它。於是一個真實的問題立刻浮現:如果你的程式根本無法執行讀取磁碟區塊的那個指令,那 `read()` 究竟是怎麼從磁碟上拿到一個位元組的?答案就是這整個學程的主題。橫越那條線,恰恰只有一條官方途徑,它就叫做系統呼叫。
系統呼叫是你的程式向核心提出的一個請求:「請代我做這件需要特權的事。」開啟一個檔案、把位元組送上網路、建立一個新行程、要求更多記憶體——這每一件事都是只有核心才真能做的,因為每一件都碰到了硬體,或碰到了另一個程式的世界。你的程式碼不能直接做,於是它改為填好它「想要什麼」,把控制權交給核心,然後等待。核心檢查你是否被允許,在它的特權模式裡把活兒幹完,再把結果交還回來。可以把它想成:核心是唯一握有大樓鑰匙的人,而系統呼叫就是那台對講機——你用它請門房幫你取東西。
真正越過去的是什麼,回來的又是什麼
因為核心無法信任使用者模式裡的任何東西,系統呼叫是一場狹窄、正式的面談,而非隨意的閒聊。核心提供的每一種請求都有一個固定的編號——讀取在某個系統上可能是 0 號系統呼叫、寫入是 1 號、開啟是 2 號——以及一份它預期收到的固定引數清單。要發出一次呼叫,你的程式把那個編號與那些引數放進事先約定好的地方(在你會遇到的架構上,是 CPU 暫存器),然後觸發那次越界。核心讀出編號,查出對應的常式,把每一個引數都當成來自不可信的陌生人來驗證——因為它確實是——之後才動手做事。
核心做完後,控制權回到你那次呼叫之後的那一道指令,並帶回單一個值——通常留在某個暫存器裡。依一個近乎放諸四海皆準的慣例,非負的回傳代表成功(對 `read()` 來說是實際讀到的位元組數;對 `open()` 來說是一個新的檔案描述符,一個小小的整數把柄),而負的回傳則表示出錯。那個正負號就是整套協定:一個數字,要嘛是有用的結果,要嘛是一句抱怨。第四篇專門講那句抱怨如何透過errno變成可讀的理由;眼下,只要把這個形狀記在心裡——你遞進一個請求、拿回一個數字,而它的正負號告訴你它在說哪個故事。
your program (user mode) kernel (privileged)
-------------------------- ---------------------
number = 0 (read)
args = fd, &buf, count
trigger the crossing ---------------> look up syscall 0
check fd, buf, count are valid
copy bytes from disk into buf
<--------------------------- return value: bytes read (>=0)
or error (<0)
resume here, inspect the return value你不是直接呼叫核心——是 libc 在呼叫
接下來是讓人意外的部分。當你在 C 程式裡寫下 `read(fd, buf, 100)`,你並不是親手把一個編號放進暫存器、再觸發那次越界。你呼叫的是一個普通的 C 函式,名叫 `read`,它住在 C 標準函式庫裡——也就是 libc,那個幾乎連結進每一支 C 程式的函式庫。那個小函式是一層薄薄的包裝:它接過你友善的引數,照核心要求的確切方式把它們排好,替你觸發越界,然後把原始的帶號結果,譯回你所預期的整齊回傳值與錯誤回報。這正是libc 作為系統呼叫包裝所擔當的角色。
這正是函式庫呼叫與系統呼叫的區別,值得釘清楚,因為這些字眼在日常說話裡常糊成一團。函式庫呼叫是一個跑在使用者模式裡的單純函式呼叫:`strlen()`、`malloc()`、`printf()` 做它的格式化工作。系統呼叫則是一個跨進核心的請求。令人困惑的轉折在於:「同一個名字」——`read`、`write`、`open`——常常兩者皆是:一個使用者模式的 libc 函式,它整份工作就是去執行底層那個系統呼叫。所以當有人說「呼叫 read」,他們通常的意思是「呼叫名叫 read 的 libc 包裝,它會執行 read 這個系統呼叫」。包裝是看得見的把柄;系統呼叫則是它伸手穿過去碰到的那個東西。
POSIX:那份共享的系統呼叫菜單
如果每個作業系統都各自發明自己一套系統呼叫、用自己的名字,那麼一支在 Linux 上開檔案的 C 程式,搬到 macOS 或各種 BSD 上就得從頭重寫。大致救了我們的,是 POSIX——一份書面標準,它約定了一份共同的操作菜單,以及這些操作在 C 裡叫什麼名字:`open`、`read`、`write`、`close`、`fork`、`wait`,還有幾百個,連同約定好的引數與行為。POSIX 並不規定系統呼叫的「編號」或內部的陷阱機制——這些在不同系統之間天差地別,甚至在不同核心版本之間都會變——它標準化的是面向使用者的那些 C 函式,也就是 libc 包裝,好讓你的原始碼維持可攜。
不過要老實說它能帶你走多遠。POSIX 標準化的是「介面」——名字、引數、大致行為——卻不是每個系統都實作了它的每一個角落,而且在邊邊角角的行為仍可能不同。「符合 POSIX」讓你的檔案與行程程式碼有相當好的可攜性;它並不保證每支程式都能原封不動地在不同系統間搬移,而 Windows 在這方面眾所周知是自成一個世界。把 POSIX 當成一份強而有力的共享契約,它把常見情況涵蓋得很漂亮,但要附上一則誠實的註腳:這份契約有缺口、也有方言。先前的學程教你重視核心/使用者的分隔;POSIX 正是讓這道分隔面向使用者的那條邊,在整個 Unix 家族裡看起來相似的東西。
每支程式一出生就帶著的三條串流
現在來看這一切一個具體、日常的回報。當核心啟動你的程式時,它並非交給程式一張白紙——它會自動替程式開好三個檔案描述符,也就是標準串流。描述符 0 是標準輸入(位元組「進來」的地方,預設是你的鍵盤),描述符 1 是標準輸出(一般結果「出去」的地方,預設是你的終端機),描述符 2 是標準錯誤(抱怨去的地方,預設也是終端機,但刻意保持分開)。它們不過是擁有保留編號的普通檔案描述符——和 `open()` 會交還的把柄一模一樣——所以 `read()` 與 `write()` 完全不必設定就能在它們身上運作。
這也是友善的 `printf()` 所坐落其上的那一層:它在使用者模式裡把你的文字格式化,最終對描述符 1 呼叫 `write()` 把位元組推出去。把標準錯誤與標準輸出分開不是龜毛——正是它讓你能把一支程式真正的結果導向一個檔案,卻仍在螢幕上看見它的錯誤訊息;或把一支程式的輸出接到另一支程式的輸入,而不讓錯誤的雜訊混進來。第五篇會再回到這些串流,以及 libc 用 stdio 那一層包在它們外面的緩衝;在這裡,只要注意到它們就是檔案描述符,與其他一切一樣,都誕生自同一個系統呼叫的世界。