同一個名字,兩件截然不同的工作
到現在,你已經一路跟著一個系統呼叫下探到底了:那條陷入指令、進入核心的模式切換、在另一側做完的工作,以及帶回來的回傳值。但這一路上,你一直依賴著一個悄無聲息的障眼法。你從來沒寫過一條陷入指令。你寫的是 `read(fd, buf, 100)`,它看起來活脫脫就像在呼叫一個你大可自己寫的函式。這個錯覺,正是 libc,也就是 C 標準函式庫的傑作,而把它拆開來看,是這篇收尾的第一件工作。
這裡有一個要握緊的關鍵區別,比先前更銳利。一個函式庫呼叫是一個普通的函式,住在你連結進程式裡的使用者模式程式碼中;呼叫它只是一次跳躍,而且它甚至可能從頭到尾都不去煩核心。一個系統呼叫,本身就是那道跨越核心的動作——陷入、權限切換、有人看守的進入點。讓人困惑的陷阱在於,對 I/O 函式來說,這兩者共用同一個名字。libc 的 read() 函式是一個函式庫呼叫。在它「內部」,把你的引數安排好之後,它才執行那個真正的 read 系統呼叫。所以一行原始碼其實是兩層:外面包著一層廉價的使用者模式包裝,肚子裡埋著一次貨真價實、昂貴的邊境跨越。把函式庫呼叫與系統呼叫分清楚,正是這一整階一直在替你培養的習慣。
給任何函式一個快速檢驗,就能定下它屬於哪一類:它要把自己的工作做完,是否曾經需要核心?strlen()、memcpy(),以及一個原本就還有餘裕記憶體的 malloc(),完全留在使用者模式裡——純粹的函式庫呼叫。open()、fork()、write() 則必須跨過去——每一個都包著一個真正的系統呼叫。printf() 是棘手的中間情況:它在使用者模式的記憶體裡把文字格式化好(函式庫的工作),然後有時候才在底下呼叫 write()(一個系統呼叫)。當你想要確定、而不是用猜的,就用 `strace ./a.out` 把程式跑起來,它每一個系統呼叫印一行,於是正好把哪些呼叫跨過了那條線給逐一讀了出來。
那層包裝實際上做了什麼
把這層包裝的工作具體看一遍是值得的,因為一旦你看過,libc 與核心之間那道邊界就不再是一句口號,而會變成一件小小的、機械式的東西。核心並不像一個 C 函式那樣收引數;它期待引數放在特定的暫存器裡,並用一個號碼來辨識你要的是哪一個呼叫。所以這層包裝,就是你所知的 C 呼叫慣例,與核心所說的那套原始系統呼叫編號之間的翻譯員。對每一個呼叫,libc 把你的引數搬進講定的暫存器、把系統呼叫號碼放進它的暫存器、執行陷入,然後——這是你最常忘記它存在的那一段——它讀取核心的原始結果,把它轉換成你的 C 程式碼所預期的、那套整潔的「回傳值加上 errno」慣例。
/* roughly what the libc read() wrapper does on x86-64 Linux */
read(fd, buf, count):
mov rax, 0 ; rax = syscall number for read
mov rdi, fd ; 1st arg
mov rsi, buf ; 2nd arg
mov rdx, count ; 3rd arg
syscall ; trap: cross into the kernel
; kernel returns in rax:
; >= 0 -> a real byte count, hand it straight back
; < 0 -> -errno; libc sets errno = -rax and returns -1兩個誠實的但書,能讓這件事不至於變成童話。第一,libc 並不是唯一的路:一支程式可以自己發出那條陷入指令、完全略過 libc,而這正是那些自帶執行期環境的語言(以及偶爾手寫的組合語言)有時會做的事。這層包裝是一種便利,不是一條法律。第二,上面那些暫存器與系統呼叫號碼,是 x86-64 Linux 專屬的;換一顆 CPU、換一個核心,號碼與暫存器都會不同,而這正是那種雜亂的、隨平台而異的細節——下一個觀念 POSIX,存在的目的就是把它糊起來、好讓你幾乎不必去在乎。
POSIX:讓它可攜的那紙合約
如果每個系統都講自己的系統呼叫號碼、自己的暫存器佈局,那麼為 Linux 寫的程式碼在 macOS 上就會毫無用處,你也得為每一台機器重新學一次檔案處理。解法是一套站在那些原始號碼之上一層的標準,它把大家同意的形狀釘死下來:函式的名字、它們的引數型別,以及各自承諾要做什麼。那套標準就是 POSIX API——可攜式作業系統介面——由 IEEE 於 1988 年首次發布,並一直維護至今。POSIX 並不規定那條陷入指令或那些系統呼叫號碼;它規定的是:存在一個收路徑與旗標的 open()、一個收檔案描述符、緩衝區與計數的 read(),諸如此類。每個系統再用它的核心底下偏好的任何方式,去實作那些形狀。
這就是為什麼本階這套檔案與行程的詞彙,是真正可以帶著走的知識,而不只是某一個作業系統的怪癖。一模一樣的那份 C 原始碼——open() 開檔、檢查 -1、在迴圈裡 read()、再 close()——可以在 Linux、macOS、各種 BSD,以及其他類 Unix 系統上編譯並執行,因為每一個都附了一份 libc,其包裝函式都遵守 POSIX 的形狀。這跟語言標準是同一種精神:把介面用白紙黑字講定,許多個獨立的實作就能彼此互通。不過要記住一條邊界:POSIX 是 Unix 家族的合約。Windows 並非原生 POSIX,所以同一份原始碼在那裡需要一層相容層;可攜性是一個真實的承諾,但它是「家族之內」的承諾,而不是「處處皆然」的保證。
三道一開始就開著的門
在你的 main() 跑出第一行之前,核心就已經交給你的行程三個開好的檔案描述符了。它們就是標準串流,而你早就把這三個都用過了,只是沒叫出它們的名字。描述符 0 是標準輸入,你的程式從這裡讀;描述符 1 是標準輸出,普通的結果往這裡走;描述符 2 是標準錯誤,抱怨與診斷訊息往這裡走。當你呼叫 printf() 時,它寫到描述符 1;當 perror() 或一則錯誤訊息送出去時,它寫到描述符 2。這些號碼不是什麼隨意的冷知識——它們就是你會傳給 read() 與 write() 的、字面意義上的那些檔案描述符。
標準串流那不動聲色的巧妙之處,在於你的程式並不知道、也不在乎另一端是什麼。你寫到描述符 1;那會落在終端機上、被存進一個檔案,還是被直接餵進另一支程式,是 shell 的決定,不是你的。這正是重導向與管線背後的機制:`./a.out > out.txt` 在你的程式啟動之前,悄悄地把描述符 1 指向一個檔案;而 `./producer | ./consumer` 把生產者的描述符 1 接到消費者的描述符 0。把正常輸出(1)與錯誤(2)分開,正是為什麼你可以把結果存進一個檔案、同時仍在螢幕上看到錯誤——它們走在兩個分開的描述符上,所以 shell 可以各自獨立地把它們導去不同地方。
幾乎每個人都會撞上的緩衝陷阱
在 printf() 與核心之間還有一層,而略過它會產生一個困惑了好幾代初學者的臭蟲。每印一個字元就呼叫一次 write() 會慢得殘忍,因為每一次呼叫都是一整趟核心跨越。所以 libc 那套較高階的 I/O——stdio 串流家族:printf()、fputs()、putchar()——並不是每次都去呼叫 write()。它把你的輸出收集在一個使用者模式的緩衝區裡,只有在緩衝區填滿、或某個觸發條件成立時,才呼叫一次 write()。這是疊在系統呼叫之上的、純粹的函式庫工作:許多次 printf() 呼叫,換來少得多的、真正跨進核心的次數。這是一個實實在在的效能勝利,而那個陷阱,也就住在這裡。
那個觸發條件,取決於輸出要去哪裡,而這就是陷阱所在。當標準輸出是一個終端機時,stdio 是行緩衝的:它在每一個換行符都沖刷一次,所以你會立刻看到你印的東西,一切感覺都很好。但當標準輸出被重導向到一個檔案或一條管線時,stdio 就切換成全緩衝——它會一直等到累積了幾 KiB 才動作。於是一支在螢幕上印得好好的程式,可能會看起來輸出得太晚、相對於標準錯誤亂了順序,或者——如果它在緩衝區沖刷之前就崩潰了——把最後幾行完全弄丟。你的邏輯一點問題也沒有;那些位元組正坐在一個從未被沖刷的使用者模式緩衝區裡。搞懂緩衝模式與沖刷,才能把這件事從一個莫名其妙的鬼,變成一個可預期、可控制的行為。
整個介面,收進一格
把你現在握住的這幾層疊起來,作業系統介面就不再是一面術語砌成的牆,而會變成一道乾淨的階梯。最底下是核心,只能透過陷入才碰得到。往上一階是原始的系統呼叫介面——號碼與暫存器,就是你在「陷入與模式切換」那篇裡看到的東西。再往上坐著 libc,它的包裝函式把每一次跨越打扮成一個普通的 C 呼叫,並把失敗翻譯成 errno。在那之上坐著 stdio,它把你的輸出緩衝起來,好讓一千次 printf() 呼叫變成寥寥幾次 write() 跨越。而橫跨在中間的,是 POSIX,那紙白紙黑字的合約,把名字與形狀釘死,好讓同一份原始碼跑遍整個 Unix 家族。
那道階梯,就是這一整階能留下來的那幅圖。你一開始畫下了使用者模式與核心之間的那條線;你看著單一一個呼叫陷入、跨過它;你學會了在一次跨越失敗時,讀懂核心交回來的東西;而現在,你能把每一個熟悉的 I/O 函式放上它正確的那一階,並對任何一行 C 程式說出:它真正的代價是什麼、又有什麼會出錯。從這裡開始,階梯還會繼續往上爬——檔案描述符與行程是接下來的——但那個形狀永遠不變:你的程式碼在一側、核心在另一側,中間夾著一小組標準化、可被檢查的門,而 libc 與 stdio,讓這些門用起來舒舒服服。