Learning Objectives
- 畫出 fd 表、開啟檔案與 inode 的關係,說明 fork 之後共用了什麼
- 用 pipe 在行程之間傳資料,判斷什麼時候讀得到 EOF、什麼時候會卡住
- 用 dup2 做重新導向,親手組出 ls | wc -l
- 說明常見訊號的來源與預設動作,判斷處理函數裡可以做什麼
兩支互不認識的程式怎麼接起來
ls | wc -l 可以算出目錄裡有幾個檔案。寫 ls 的人和寫 wc 的人不需要認識,兩支程式裡也都沒有管線相關的程式碼:ls 只是寫到 stdout,wc 只是從 stdin 讀。
能這樣接,靠的是 Unix 的一個設計:程式不管輸出到底去了哪裡,只管寫到 fd 1。fd 1 接到什麼,是啟動它的 Shell 事先安排的。而上一課看到,fd 會留過 exec。
這一課先看 fd 的結構,再用管線把兩個行程接起來,最後看行程之間另一種溝通方式:訊號。
fd 表與開啟檔案
檔案描述子(file descriptor,fd)是一個小整數,是程式手上的「號碼牌」。open、pipe 回傳它,read、write、close 用它。每個行程啟動時已經有三個:0 是 stdin、1 是 stdout、2 是 stderr。新的 fd 永遠拿目前最小的空號。
它背後有三層:
A: fd 0,1,2 --> terminal
A: fd 3 ---+
+--> open file --> inode
B: fd 3 ---+ offset = 13
A、B 是兩個行程,左邊是它們各自的 fd 表;open file 是核心的開啟檔案表裡的一個項目,記著讀寫位置(offset)和模式;inode 是檔案本身(第 17 課)。
重點是中間那一層:讀寫位置記在開啟檔案,不是記在 fd 表裡。fork 複製 fd 表,兩個行程的 fd 3 指向同一個開啟檔案,所以共用同一個位置。這是刻意的設計:Shell 執行 (指令一; 指令二) > out.txt 時,兩個指令可以依序寫下去,不會互相覆蓋。
真正的 Linux 上試試看,子行程先寫,父行程接著寫,然後從頭讀回來:
int fd = open("shared.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) { write(fd, "child\n", 6); return 0; }
wait(NULL);
write(fd, "parent\n", 7);
位置 13,檔案內容:
child
parent
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
如果是兩個行程各自 open 同一個檔案,就會是兩個開啟檔案、各有自己的位置,後寫的會蓋掉先寫的。
在 os-lab 的「檔案與 fd」面板裡,「共用」欄就是這個開啟檔案被幾個 fd 指著。
pipe 與 EOF
pipe(fd) 在核心裡建立一塊緩衝區,給你兩個 fd:fd[0] 讀、fd[1] 寫(int fd[2] 是兩個整數的陣列,傳給函數時核心會把結果填進去)。先 pipe 再 fork,父子兩邊都拿到這兩個 fd,就接上了。
子行程 write(fd[1]) ──► [ 核心裡的緩衝區 ] ──► read(fd[0]) 父行程
資料是位元組流,先寫的先讀,讀走就沒有了;也沒有訊息邊界,分三次寫的資料可能一次讀到。
讀取端要怎麼知道資料寫完了?這是管線最容易出錯的地方:
| 情況 | read 的結果 |
|---|---|
| 管線裡有資料 | 回傳讀到的位元組數 |
| 管線是空的,還有任何 fd 指向寫入端 | 阻塞,等人寫入 |
| 管線是空的,所有寫入端都關閉了 | 回傳 0,也就是 EOF |
注意是「所有」。fork 之後父子各有一份 fd[1],寫入端關掉自己那份,只要對方手上還有,讀取端就等不到 EOF。讀取的那個行程如果自己沒有關 fd[1],它會等自己。所以規則是:用不到的那一端,一定要關。
真正的 Linux 上,讀取端沒有關掉自己的寫入端,用 alarm(1) 當保險,一秒後用訊號結束它:
讀到 6 個位元組
讀取端一直等不到 EOF,1 秒後被訊號 14(SIGALRM)終止
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
真實的管線容量與 SIGPIPE
真正的 Linux 上,用非阻塞模式一直寫,直到寫不下去:
F_GETPIPE_SZ = 65536
寫了 65536 個位元組之後滿了:Resource temporarily unavailable
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
65536 位元組是 Linux 的預設(16 頁,每頁 4 KB),程式可以調整,不應該假設它是固定值。
反過來的情況是:讀取端都關閉了,還要寫入。沒有人會讀這些資料,再寫下去沒有意義,所以核心送 SIGPIPE 給寫入的行程,預設動作是終止它。如果程式忽略這個訊號,write 會回傳 -1,errno 是 EPIPE:
沒有處理:子行程被訊號 13 終止
忽略 SIGPIPE:write 回傳 -1,Broken pipe
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
這就是 yes | head -n 3 會結束的原因:yes 會永遠印下去,但 head 讀完三行就結束,yes 下一次寫入時就被 SIGPIPE 終止了。網路伺服器則常常忽略 SIGPIPE,改成檢查 write 的回傳值,否則一個客戶端斷線就會讓整個伺服器結束。
dup2:Shell 怎麼做重新導向
dup2(oldfd, newfd) 讓 newfd 改成指向 oldfd 指著的開啟檔案;newfd 原本開著的話會先被關掉。
Shell 執行 ls > out.txt 的步驟,就是在 fork 和 exec 中間做這件事:
pid_t pid = fork();
if (pid == 0) {
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // 現在 fd 1 指向 out.txt
close(fd); // 原本的號碼牌用不到了
execlp("ls", "ls", NULL);
}
ls 什麼都不知道,它照常寫 fd 1,內容就進了檔案。這就是 fork 和 exec 分開的好處:中間可以安排子行程的環境,被啟動的程式完全不用配合。
ls | wc -l 用同樣的方法,只是接到管線。步驟:
pipe(fd)建立管線。- fork 第一個子行程:
dup2(fd[1], 1),關掉fd[0]、fd[1],execls。 - fork 第二個子行程:
dup2(fd[0], 0),關掉fd[0]、fd[1],execwc。 - 父行程(Shell)關掉
fd[0]、fd[1],然後 wait 兩個子行程。
步驟 3 和 4 的 close 不能省。wc 要讀到 EOF 才會印出結果,少關任何一份寫入端,它就永遠等下去。
真正的 Linux 上跑同樣的結構(這個環境沒有 ls,所以兩邊用自己寫的程式碼代替 exec,一邊印三行,一邊數行數):
讀到 EOF,共 3 行
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
訊號:核心打斷行程的方式
管線傳的是資料。訊號(signal)只傳一件事:「某件事發生了」。收到訊號時,行程不管正在做什麼都會被打斷。
| 訊號 | 編號(x86) | 常見來源 | 預設動作 |
|---|---|---|---|
SIGINT |
2 | 在終端機按 Ctrl+C | 終止 |
SIGKILL |
9 | kill -9;記憶體不足時的 OOM killer |
終止,無法攔截 |
SIGSEGV |
11 | 存取不合法的記憶體(CPU 例外轉來的) | 終止並產生 core dump |
SIGPIPE |
13 | 寫入沒有讀取端的管線 | 終止 |
SIGTERM |
15 | kill 指令的預設訊號 |
終止 |
SIGCHLD |
17 | 子行程結束 | 忽略 |
行程可以對大部分訊號指定要怎麼處理:照預設、忽略(SIG_IGN),或交給自己寫的處理函數(signal handler)。
void on_int(int sig) { // 收到訊號時,核心讓這個函數插進來執行
write(1, "got SIGINT\n", 11);
}
signal(SIGINT, on_int); // 註冊:收到 SIGINT 時呼叫 on_int(傳的是函數名稱)
處理函數執行完,程式回到被打斷的地方繼續。
SIGKILL 和 SIGSTOP 是例外,不能攔截也不能忽略,所以系統永遠有辦法終止失控的程式。一般的關閉流程會先送 SIGTERM,給程式機會存檔收尾,過一段時間還不結束才送 SIGKILL。
新程式應該用
sigaction而不是signal,不同系統上signal的細節不一樣。教學核心只提供signal,觀念相同。
真正的 Linux 上,四種情況的結果:
攔截 SIGKILL:Invalid argument
got SIGTERM, cleaning up
SIGTERM,有處理函數:正常結束,結束碼 3
SIGTERM,沒有處理函數:被訊號 15 終止
SIGKILL,有 SIGTERM 處理函數:被訊號 9 終止
寫入 NULL 指標:被訊號 11 終止
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
處理函數裡不能做什麼
處理函數可能在任何兩個指令之間插進來。想像主程式正在 printf 裡面,更新緩衝區和計數器更新到一半,訊號來了,處理函數又呼叫 printf:它看到的是更新到一半的資料,結果無法預期。malloc 也有一樣的問題。
所以處理函數裡只能呼叫 async-signal-safe 的函數,man 7 signal-safety 有完整清單。
| 可以用 | 不可以用 |
|---|---|
write、_exit、read、kill |
printf 與整個 stdio、malloc、free、exit |
練習裡的 on_int 用 write 就是這個原因。最常見的安全寫法是只設一個旗標,由主程式去檢查:
volatile sig_atomic_t got = 0;
void on_usr1(int sig) {
got = 1;
}
int main(void) {
signal(SIGUSR1, on_usr1);
raise(SIGUSR1);
if (got) {
printf("處理函數只設旗標,主程式看到旗標才印這一行\n");
}
return 0;
}
處理函數只設旗標,主程式看到旗標才印這一行
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
volatile sig_atomic_t 告訴編譯器這個變數可能隨時被改、而且讀寫不會被打斷到一半。教學核心不支援 volatile,所以這段只放真實輸出。
行程之間溝通還有一種方式是共享記憶體:兩個行程把同一塊實體記憶體對應進自己的位址空間,不用經過核心複製,這要等第 14 課看過頁表之後才會清楚。
確認一下
子行程把資料寫進管線之後結束了,父行程的讀取迴圈卻一直不結束。最可能的原因是什麼?
執行 yes | head -n 1,yes 是怎麼結束的?
Shell 執行 ls > out.txt 時,dup2 是誰在什麼時候呼叫的?
這段程式執行完,檔案 a.txt 的內容是什麼?
int fd = open("a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) {
write(fd, "AAA", 3);
return 0;
}
wait(NULL);
write(fd, "BBB", 3);
為什麼處理函數裡不應該呼叫 printf?
要關閉一個正在寫檔的服務,為什麼通常先送 SIGTERM,不直接送 SIGKILL?
重點整理
- fd 是行程手上的號碼牌,指向核心的開啟檔案;讀寫位置在開啟檔案裡,fork 之後父子共用。
pipe給一對 fd。空管線的 read 會阻塞,直到所有寫入端都關閉才回傳 EOF,所以用不到的一端要關。滿了的管線讓 write 阻塞;讀取端全關時 write 收到 SIGPIPE。dup2加上 fd 留過 exec,讓 Shell 能在 fork 和 exec 之間安排重新導向,被啟動的程式不用配合。- 訊號打斷行程,可以攔截的訊號能自訂處理函數;SIGKILL、SIGSTOP 不能攔截。處理函數裡只用 async-signal-safe 的函數,最安全的是只設旗標。
下一課回到核心的另一個問題:這麼多就緒的行程,下一個該輪到誰,CPU 排程演算法。