National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 03

fork、exec 與 wait

Shell 怎麼啟動一支程式:複製自己、換掉程式、等待結束
Interaction 35 minute(s)

Learning Objectives

  • 說明 fork 為什麼回傳兩次,以及子行程改變數為什麼不影響父行程
  • 算出一段含 fork 的程式會產生幾個行程、印出幾行
  • 說明 exec 換掉了什麼、留下了什麼
  • 用 wait 取得結束碼,解釋殭屍與孤兒是怎麼出現的
  • 用 fork、exec、wait 寫出一個迷你 Shell

Shell 也只是一支普通的程式

在終端機打 ls 按下 Enter,發生了什麼事?ls 是磁碟上的另一支程式,Shell(bash、zsh)也是一支普通程式,它沒有任何特權。它做的事只有三步:

fork()   複製自己,得到一個子行程
exec()   子行程把自己換成 ls
wait()   父行程(Shell)等 ls 結束,再印下一個提示字元

在 Linux 這類 Unix 系統上,新程式傳統上都是這樣跑起來的。建立行程和換掉程式是兩個分開的動作,這個設計看起來繞路,下一課會看到它的好處:在 fork 和 exec 之間,子行程可以先把輸出導到檔案或管線。

這一課會把三個呼叫各自拆開看,最後你會自己寫一個迷你 Shell。

fork 回傳兩次

fork() 複製目前的行程。呼叫一次,回來時變成兩個行程都從 fork 的下一步繼續,差別只有回傳值:

誰 fork 回傳
父行程 子行程的 pid(大於 0)
子行程 0
失敗時(只有父行程,沒有子行程) -1

所以程式都寫成這樣,用回傳值分流(pid_t 就是存 pid 的整數型別):

pid_t pid = fork();
if (pid == 0) {
    // 只有子行程會走這裡
} else {
    // 只有父行程會走這裡
}

子行程得到一份父行程位址空間的複本:全域變數、堆疊、堆積,連 stdio 緩衝區都一起。之後兩邊各改各的,互不影響。(真實的核心不會真的把記憶體全部複製一份,它用「寫入時複製」,第 14 課會看到。)

fork 之後誰先跑沒有保證,由排程器決定。程式如果需要特定的順序,要自己用 wait 這類方法安排。

互動練習:fork 之後有兩份變數

切到「變數與鎖」面板,可以看到兩個行程各有一份 x;「行程樹」面板看父子關係。排程先由你決定,也可以按「跑到結束」。這個練習會改幾次程式,每次改完都要重新編譯。

算 fork 會產生幾個行程

每個執行到 fork() 的行程都會多出一個。連續 n 個 fork,行程數每次翻倍:

fork();
fork();
printf("hi\n");
第一個 fork 之後:A、B
第二個 fork 之後:A 生出 C,B 生出 D

A
├── B
│   └── D
└── C

四個行程都會執行 printf,真正的 Linux 上印出四行:

hi
hi
hi
hi

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

算的方法是畫出樹:從第一行開始,遇到 fork 就分叉,兩條路都從下一步繼續走。別只用公式,有 if 的時候只有一邊會繼續:

if (fork() == 0) {
    fork();
}
printf("hi\n");

父行程不進 if,子行程再 fork 一次,總共三個行程、三行。

這也是為什麼不要跑 fork 炸彈:while (1) fork(); 的行程數會不斷翻倍,很快用完系統的行程數上限,連登入新的 Shell 都不行。真實系統用 ulimit -u 和 cgroup 限制行程數來防範。

緩衝區跟著 fork 複製

剛才的 hello 出現兩次,是因為它還在 stdio 緩衝區,而緩衝區就是行程記憶體的一部分,fork 把它一起複製了。真正的 Linux 上也一樣:

printf("hello ");
pid_t pid = fork();
if (pid == 0) printf("child\n");
else { wait(NULL); printf("parent\n"); }
hello child
hello parent

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

有換行也可能中招。輸出接到檔案或管線時是全緩衝,換行不會觸發 write。下面這支程式迴圈裡只有父行程在印,子行程什麼都沒印就 exit(0),輸出卻是這樣:

for (int i = 1; i <= 3; i++) {
    pid_t pid = fork();
    if (pid == 0) exit(0);
    wait(NULL);
    printf("第 %d 個子行程結束了\n", i);
}
第 1 個子行程結束了
第 1 個子行程結束了
第 2 個子行程結束了
第 1 個子行程結束了
第 2 個子行程結束了
第 3 個子行程結束了

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

每個子行程都帶著父行程緩衝區裡還沒送出的內容,exit 會在結束前清空緩衝區,內容就多印了一次。兩個常見的解法:fork 之前 fflush,或者子行程用 _exit 結束,_exit 直接請核心結束行程,不清 stdio 緩衝區。

exec 換掉程式,pid 和 fd 留著

exec 系列把目前行程的程式換成另一支。舊的程式碼、全域變數、堆疊、堆積全部消失,新程式從 main 開始。成功時不會回傳,因為呼叫它的程式已經不在了;回傳了就代表失敗。

execl("./worker", "worker", NULL);      // 路徑;接著是 argv 的每一項,最後一定要 NULL
perror("execl");                        // 走到這裡一定是失敗了

名字裡的字母說明傳參數的方式:

字母 意思 例子
l 參數一個一個列出來(list) execl、execlp
v 參數放在陣列(vector) execv、execvp
p 第一個參數不含 / 時,到 PATH 找程式 execlp("ls", "ls", NULL)

注意 "worker" 寫了兩次:第一個是要執行的檔案,第二個是新程式看到的 argv[0]。

有些東西不會被換掉:

換掉 留著
程式碼、資料、堆積、堆疊 pid、父行程
stdio 緩衝區(還沒送出的內容就消失了) 開著的檔案描述子(除非設了 close-on-exec)
自訂的訊號處理函數(恢復預設) 被設成忽略的訊號

fd 留著是 Shell 能做重新導向的關鍵:先把 fd 安排好,再 exec,新程式不知不覺就接到了。

真正的 Linux 上執行自己一次,pid 沒有變;第一次 exec 的路徑不存在,所以回傳失敗、程式繼續走:

exec 之前 pid = 2
exec 失敗才會走到這裡:No such file or directory
exec 之後 pid = 2,argv[0] = new-name

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

(/proc/self/exe 是指向自己這個執行檔的路徑,這裡拿來示範 exec 自己。這個環境沒有 echo 這些指令可以 exec,所以成功 exec 別的程式的畫面在下面的練習裡看。)

fd 留著也可以驗證:exec 之前打開一個檔案,拿到 fd 3,exec 之後新程式直接讀 fd 3。

exec 之前 fd = 3
exec 之後讀 fd 3:回傳 5,內容 note

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

互動練習:exec 之後還剩下什麼

main.c 打開 note.txt 再 exec 成 worker;worker.c 是另一支程式(上方分頁可以切換),它沒有開任何檔案,直接讀 fd 3。open 的第二個參數 O_RDONLY 代表唯讀。切到「記憶體」面板比較 exec 前後,「檔案與 fd」面板看 fd 表。

wait、結束碼、殭屍與孤兒

wait(&status) 讓父行程等任一個子行程結束,核心把結果寫到 status 指到的地方(&status 就是把變數的位址交給它),回傳那個子行程的 pid。status 不是結束碼本身,要用巨集拆開:

巨集 意思
WIFEXITED(status) 是不是自己正常結束
WEXITSTATUS(status) 結束碼;只有低 8 位元
WIFSIGNALED(status) 是不是被訊號終止
WTERMSIG(status) 被哪個訊號終止

結束碼只有 8 位元,所以 exit(256) 在父行程看來是 0,會被當成成功:

exit(7) 父行程看到 7
exit(256) 父行程看到 0
exit(300) 父行程看到 44

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

殭屍(zombie):子行程結束了,父行程可能還沒來問結果。核心不能馬上把這個行程的資料全部丟掉,要留著 pid 和結束碼等父行程來收,這段時間它就是殭屍。它的記憶體已經還給系統,只佔一個行程表的位置。真正的 Linux 上看得到:

子行程結束了、父行程還沒 wait:
  State:    Z (zombie)
wait 之後,結束碼 7:
  /proc/<pid>/status 已經不存在

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

少數幾個殭屍無傷大雅。長期執行的伺服器如果不斷 fork 又不 wait,殭屍會累積到把行程數上限用完,新的行程就建立不起來。

孤兒(orphan):父行程先結束,子行程還在跑。子行程會被 init(pid 1)或指定的 subreaper 行程收養,將來由新的父行程來 wait,所以孤兒不會永遠變成殭屍。

互動練習:製造殭屍與孤兒

父行程先睡 3 秒才 wait,所以子行程結束之後會有一段時間是殭屍。排程由你決定:讓子行程先走完。「行程樹」面板裡殭屍會標 <defunct>,這也是 ps 顯示殭屍的方式。

互動練習:寫一個迷你 Shell

真正的 Shell 會讀你打的字,再切成指令與參數;這裡把指令固定寫在 main,專心做核心的三步。在 run 函數裡把 TODO 寫出來,結構和這一課開頭的 if (pid == 0) 一樣。status 要先宣告成 int status;。

為什麼 cd 不是一支程式

迷你 Shell 的結構和 bash 相同,真正的 Shell 只是多了解析指令、重新導向、管線、背景執行。

不過有些指令不能用 fork 加 exec 做。cd 要改變的是 Shell 自己的工作目錄,如果交給子行程,改的是子行程那一份,子行程結束之後 Shell 還在原地。所以 cd、exit 這些是 Shell 自己內建的指令。這也是 fork 語意的直接結果:子行程改不到父行程。

確認一下

1.

這段程式會印出幾行?

fork();
fork();
fork();
printf("x\n");
2.

這段程式會印出幾行?

if (fork() == 0) {
    fork();
}
printf("x\n");
3.

這段程式印出什麼?

int x = 1;
if (fork() == 0) {
    x = 2;
    exit(0);
}
wait(NULL);
printf("%d\n", x);
4.

execlp("ls", "ls", NULL) 成功之後,下一行程式什麼時候執行?

5.

子行程已經結束,核心為什麼還要保留它的殭屍資料?

6.

為什麼 cd 必須是 Shell 的內建指令,不能做成外部程式?

第 1 次作答

重點整理

  • fork 回傳兩次:父行程拿到子行程的 pid,子行程拿到 0。之後兩邊各有一份記憶體,包含 stdio 緩衝區。
  • 算 fork 次數要畫樹,每個走到 fork 的行程都會分叉。
  • exec 換掉程式,保留 pid 和開著的 fd。成功就不回傳,後面的程式碼只有失敗才會跑。
  • wait 取得結束資訊,結束碼只有 8 位元。還沒被 wait 的已結束行程是殭屍;父行程先走的是孤兒,會被收養。
  • Shell 就是一個重複 fork、exec、wait 的迴圈。

下一課利用「fd 會留過 exec」,把兩支互不認識的程式接起來:管線、重新導向與訊號。

這一課完成了!
Next lesson:管線與訊號
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。