兩個你早已握有的事實
你抵達這最後一篇時,已經帶著它所需要的一切。從第 1 篇你知道檔案描述符不過是一個小小的非負整數——是核心為每個行程保管的一張表裡的索引,表中每個槽位指向一個開啟的檔案、一條管線、一個通訊端,任何東西都行。從第 2 篇你知道 read() 與 write() 並不在乎描述符背後坐著什麼種類的東西;它們只是把位元組搬進或搬出你交給它們的那個整數。而從你稍早見過的標準串流,你知道在你的 `main()` 都還沒跑起來之前,就已經有三個描述符開著了:0 是 stdin、1 是 stdout、2 是 stderr。
把這兩個事實並排擺著,一扇門就打開了。如果 write() 盲目地把位元組送往坐在描述符 1 背後的任何東西,而描述符 1 又不過是核心管理的一個表槽,那麼一支程式印出的位元組根本沒被綁在終端機上。它們去到槽位 1 剛好指向的地方。改掉槽位 1 指向什麼,程式裡每一個 `printf()`——程式自己不知道、也不必重新編譯——就落到一個新地方:一個檔案、一條管線、另一支程式。這一個觀察就是重導(redirection)的全部,也是為什麼一支 Unix 程式從來不必含有一行「我該寫到螢幕還是寫到檔案」的程式碼。
dup2:把表裡的一個槽位重接
核心給你一個精準的工具來做這重接,而它正是用 dup 做重導的核心。`dup2(oldfd, newfd)` 這個呼叫讓 newfd 成為 oldfd 的一份複本:它回傳之後,這兩個整數在幕後指向完全同一個開啟的檔案——同一個檔案、同一個檔案偏移、同樣的一切。如果 newfd 原本已經開著,dup2() 會先安靜地把它關掉。所以 `dup2(fd, 1)` 的意思是:「不管 fd 指向什麼,讓描述符 1 也指向那裡。」從那一刻起,每一次寫到 1——每一個 printf()——就流進 fd 的目的地,而不是終端機。
現在你能親手搭出當你敲下 `$ ./a.out > out.txt` 時 shell 所做的事。shell 先用 open() 開啟 out.txt,拿回某個描述符——比方說 4。接著它呼叫 `dup2(4, 1)` 把 stdout 指向那個檔案,再把現在多餘的 4 關掉。然後它才執行你的程式。你的程式照舊往描述符 1 印,完全不知道 1 已經不通往螢幕了。重導全都發生在開啟檔案與啟動程式之間那寥寥幾道指令裡——而關鍵在於啟動本身,那正是下一塊拼圖。
重導為什麼能一路活進你的程式裡?因為上一階的 先 fork 再 exec。當 shell 進行 fork 時,子行程繼承了父行程整張描述符表的一份複本——一個槽位對一個槽位。shell 在那個子行程裡、在 fork 之後、exec 之前 動它的 dup2() 手術,於是重接過的表已經就位。接著 exec() 用你的程式取代子行程的程式碼與記憶體——但 exec() 刻意不去碰那張描述符表。你嶄新的 `main()` 睜開眼時,看到的是一張剛剛為它重排過的表。那個次序,fork → dup2 → exec,是 Unix 裡每一次重導的骨架。
管線:一根有兩端的管子
重導到檔案很有用,但真正的大獎是把兩支正在執行的程式接起來,讓一支的輸出直接串流進另一支的輸入。讓這成為可能的核心物件就是匿名管線:一個核心內部的緩衝區,寬幾 KiB,附帶兩個描述符。一端唯讀,另一端唯寫。寫進寫入端的位元組在核心裡排起隊,再依序從讀取端出來。它是一根純由描述符構成的單向管子——硬碟上沒有檔案、沒有名字,目錄裡看不到任何東西。
你用 `pipe(fds)` 建立一個,傳入一個含兩個 int 的陣列。成功時核心把讀取端填進 `fds[0]`、寫入端填進 `fds[1]`——一對好記的組合,只要注意 0 是讀、像 stdin 的 0,1 是寫、像 stdout 的 1。如同本級每一個系統呼叫,你要檢查它的回傳值:pipe() 成功回傳 0、失敗回傳 -1,遇到 -1 時你去查看 errno,而不是抱著兩個垃圾描述符往前衝。一旦你握有這對端點,你就有了一條私有通道;下一步是把每一端分別交給不同的行程。
before fork: parent holds fds[0]=read fds[1]=write
fork() -> child inherits a COPY of both ends
parent (writer) child (reader)
close(fds[0]) read end close(fds[1]) write end
write(fds[1], ...) ----pipe----> read(fds[0], ...)
each side closes the end it does not use, so EOF can arrive把管線與 fork 接成一條真正的管線
美妙的地方來了:單單一條管線連不出什麼有用的東西,直到你拿 fork() 去跟它交叉。訣竅是你先呼叫 pipe()、再 fork()。因為子行程繼承描述符表的一份複本,現在父與子雙方都各自握有同一條管線兩端的複本。這兩個行程共用核心裡的同一根管子。接著每一邊留住自己需要的那一端、關掉另一端:寫的那方留住寫入端、關掉讀取端;讀的那方留住讀取端、關掉寫入端。現在管線真正地單向流動,從一個行程流向另一個。
- 呼叫 pipe(fds) 並檢查是否為 -1,這樣你就有了經過驗證的讀取端 fds[0] 與寫入端 fds[1]。
- 呼叫 fork()(同樣檢查 -1)。此時兩個行程都握有兩端的複本。
- 在寫入端行程裡:close(fds[0]) 丟掉讀取端,再 dup2(fds[1], 1) 讓它的 stdout 變成那條管線,然後 close(fds[1]),因為槽位 1 現在已經承載它了。
- 在讀取端行程裡:close(fds[1]) 丟掉寫入端,再 dup2(fds[0], 0) 讓它的 stdin 變成那條管線,然後 close(fds[0])。
- 對兩支程式呼叫 exec()。它們此刻照常從 stdin 讀、往 stdout 寫,渾然不覺那些串流早已彼此接通。
對兩支程式跑過這些步驟,你就親手重建了 shell 對 `$ ls | wc -l` 所做的事,分毫不差。shell 管線不過如此:一次 pipe()、每一段一次 fork()、一次 dup2() 把每支程式的 stdin 或 stdout 接上一個管線端、再對真正的命令做一次 exec()。你敲下的那個 `|` 是在請求 shell 替你跳這支小小的舞。ls 或 wc 裡頭並沒有什麼特別的「管線運算子」——它們在彼此一無所知的情況下被寫出來,各自只讀 stdin、寫 stdout,是核心安靜地把它們的串流接了起來。
關閉端點、檔案結尾,與斷掉的管線
把不用的端點關掉不是為了整潔——而是為了正確性,省略它是最常見的管線臭蟲。對一條管線做 read() 會回傳 0、也就是檔案結尾的訊號,只有在每一個寫入端都已關閉時才會這樣。如果讀取端行程忘了關掉它自己那份寫入端的複本,那麼就還有一個寫入端開著——它自己那個——於是 read() 會永遠卡住,等著那永遠不會來的位元組,因為唯一能送出它們的描述符,正是它自己誤握著沒放的那個。這個死結看起來莫名其妙,直到你想起每個行程都繼承了兩端,必須丟掉它不用的那一個。
鏡像的那個情況同樣重要,也為本級收尾。萬一是讀取端先離開了呢——` ls | head -1` 印了一行就結束——而寫入端還在寫?當一支程式寫入一條讀取端已完全關閉的管線時,核心會送給它 SIGPIPE 訊號,預設會直接把寫入端殺掉;這就是你或許在終端機裡瞥見過、寫著「Broken pipe」的[[sigpipe-broken-pipe|斷掉的管線]]。這不是故障——這是核心在說「沒人在聽了,別再產出了」。這正是為什麼 ` yes | head` 會迅速結束,而不是永遠空轉。一支健壯的程式要嘛處理 SIGPIPE、要嘛檢查 write() 的回傳值看有沒有 EPIPE 錯誤,而不是假設每一次寫入都會落地。
退一步,看看本級給了你什麼。你開始時相信檔案是你的程式握著的一個不透明之物;你結束時知道它是核心代你管理的一個編了號的槽位,無論它通往一個檔案、一台終端機,還是一條管線,都用同一套 read()/write() 抵達。重導不過是用 dup2() 把一個槽位重接;一條管線不過是兩個行程共用一根核心管子、接到它們的標準串流上。你多年來隨手敲下的 `>` 與 `|`,如今成了你能親手搭建的東西——用 open()、pipe()、fork()、dup2()、exec(),再加上對哪些描述符該保持開啟的一份細心留意。從這裡起,梯子轉向這些位元組與行程究竟住在記憶體的哪裡——但你在這裡磨利的那副一切皆檔案的眼鏡,會持續地回報你。