本實驗希望我們做出一個 Unix Shell 。所以我們可以先來看一個簡單的 Shell 程式是如何實作的。
首先,Shell 的定義就是由使用者在 command line 上輸入字串,而 Shell 再根據字串執行對應的動作。因此接收到字串後便進入評估(eval
)。
而 eval
函式需要做以下的事:
前台與後台的差別:
詳細實作會在後面說明。
簡單範例如下。
講完了基本流程,接著需要思考兩件事。Shell 必須實現作業控制(job)與信號處理(Signal)。作業表明了 Shell 程式中運行的行程。每當新增一個新的命令時,需要增加,結束時需要刪除。而信號處理則是 Shell 必須要回應來自 Kernel 的信號,例如 Ctrl-C 或 Ctrl-Z。因此需要撰寫 Signal_handler 來處理。
上面簡單說明了 Shell 程式的基本程序。現在來說明本實驗需要實作的函式。
eval
: 解析和解釋 command line 的主程式builtin_cmd
: 識別並解釋內建指令:quit
、fg
、bg
和 jobs
do_bgfg
: 實作 bg
與 fg
的內建命令waitfg
: 等待前台作業完成sigchld_handler
: 獲取 SIGCHILD
信號sigint_handler
: 獲取 SIGINT
(Ctrl-C)sigtstp_handler
: 獲取 SIGTSTP
(Ctrl-Z)main
本實驗已經提供寫好的主程式如下,可以看到結構與上面例子大致相同。但因為 eval
函式會涉及到一些議題,因此我們從簡單的開始寫起。
builtin_cmd
在課本 8.4.6 的圖 8-24 中有給出簡單的範例。現在我們需要將其擴展到可以處裡 quit
、fg
、bg
和 jobs
命令。若是內建命令回傳 1,反之回傳 0。
quit
: 直接結束 Shell 程式fg
or bg
: 執行 do_bgfg
函式jobs
: 執行 listjobs
函式&
: 不理它(parseline
函式會負責處理它)eval
如同前面 background 提到 eval
函式的功能。而在課本的 Fig 8-24 有給出範例的程式碼。我們先複習一下要做的事:
需要修改的地方是我們實作的 Shell 必須包含作業控制。簡單來說就是需要在每次執行新的命令時使用 addjob
新增作業,而當命令執行結束後便要使用 deletejob
刪除作業。新增就直接在函式的後面做新增就好,而當一個子行程中止時,kernel 會向其父行程送發送 SIGCHLD
信號。叫父行程去回收殭屍行程。那也就是說只要在 sigchld_handler
裡面刪除作業就好。
但重點來了,我們知道說使用 fork
產生子行程我們沒辦法預測到底是父行程會先執行還是子行程會先執行。這樣就會導致一個嚴重的問題,如下圖所示,當子行程在父行程還沒 addjob 前就執行完的話,kernel 會向父行程(Shell)發送 SIGCHLD
信號,便會觸發sigchld_handler
,就直接刪除作業了,但是父行程根本就還沒新增完成阿。這就導致 race condition。
那解法就是利用阻塞(block)信號的方式。原理是這樣,在 addjob
函式之前,先阻塞所有的信號,在執行結束後在恢復。這樣就可以讓 addjob
函式執行期間不會受到任何信號的中斷了。
但是下圖是在 fork
之前先把 SIGCHLD
信號阻塞,在子行程在把它 unblock ,如果照我們上面講的,在 addjob
函式之前,先阻塞所有的信號就好啦。原因是可能父行程完全執行不到的情況下子行程就已經執行結束了。所以利用 fork
出來的子行程會繼承父行程的信號狀態,在子行程在 unblock,而父行程就會在阻塞所有信號之前,就已經阻塞 SIGCHLD
信號。
在 signal.h
定義了一些信號操作的 API,信號的阻塞都需要利用集合(set)的概念。
而要對信號阻塞就要使用 sigprocmask
函式,how
參數有以下幾個:
SIG_BLOCK
: 加入 set 中的信號進行阻塞 (blocked = blocked | set)SIG_UNBLOCK
: 把 set 中的信號解除阻塞 (blocked = blocked & ~set)SIG_SETMASK
: 阻塞 set 中所有信號 (blocked = set)而 oldset
會保存當前的信號集合狀態。
關於信號的實現,如以下幾點:
fork
之前對SIGCHLD
信號阻塞
SIGCHLD
信號阻塞,使其可以發送信號addjob
函式為 critical sectionsetpgid(0, 0)
使子行程獨立成一個 process group。另一方面,在最後判斷是否要在後台運行,若是要在前台運行,在原本的程式 (Fig 8.24) 中是直接使用 waitpid
API 直接等待子行程結束。本作業希望使用 waitfg
函式來解決一些問題,具體實作在下一節。
waitfg
這個函式是要等待子行程完成。這部份的討論在課本的 8.5.7 有說明。以下說明:
fgpid
函式,非常浪費資源。pause
函式,其作用是會讓行程暫停直到被信號中斷。但有個嚴重的 race condition 問題,就是當 SIGCHLD 信號是在 while 測試與 pause
函式之前發生,pause
函式可能會永遠醒不來。pause
改成 sleep
函式。很明顯這樣程式會跑得非常慢,就算可以將 time slice 設小一點也不能確定多小才剛好。sigsuspend
函式,其作用是可以使 pause
函式為 atomic,就等價於也就是說在解法二中擔心的 race condition 就被解決掉了。因為我們可以在前面先阻塞掉信號,接著在迴圈裡呼叫 sigsuspend
函式,它確保打開信號、暫停行程、阻塞信號是不可被中斷的,也就不可出現在 SIGCHLD 信號是在 while 測試與 pause
函式之前發生。
因此按照解法四的思路,來撰寫 waitfg
sigint_handler
在課本 8.5.5 有詳細介紹 Signal handler 需要注意的地方。以下簡單說明:
errno
當按下 ctrl-C
時,kernel 會發送 SIGINT
信號。接著由 sigint_handler
處理。注意到 kill
函式:
pid_t pid
:要傳送訊號的目標進程的進程 ID(PID)。
pid > 0
,則訊號 sig 將傳送給進程 ID 等於 pid 的進程。pid == 0
,則訊號 sig
將傳送給與呼叫進程屬於同一進程組的所有進程。pid < -1
,則訊號 sig
將傳送給進程組 ID 等於 pid 的所有進程。pid == -1
,則訊號 sig
將傳送給所有有權限發送訊號的進程(除了進程 ID 為 1 的進程)。int sig
:要傳送的訊號編號。常見的訊號包括 SIGINT
、SIGTERM
、SIGKILL
等。因此需要將 pid
設置為負的,使其可以對整個 process group 發送信號。
sigtstp_handler
而與前面雷同,當按下 ctrl-Z
時,kernel 會發送 SIGTSTP
信號。接著由 sigtstp_handler
將這個信號傳給這個 pid 所在的 process group 裡的所有行程。
sigchld_handler
在 Shell 執行的子行程中止時,或者是子行程收到 SIGSTOP 或 SIGTSTP 信號後,kernel 會向 Shell 發送 SIGCHLD 信號。而 sigchld_handler
就要負責回收 (reaped) 殭屍行程。
sigchld_handler - The kernel sends a SIGCHLD to the shell whenever a child job terminates (becomes a zombie), or stops because it received a SIGSTOP or SIGTSTP signal. The handler reaps all available zombie children, but doesn't wait for any other currently running children to terminate.
暫停(Stopped)與終止(terminated)的差異:
要使用 waitpid
函式來回收。在課本 8.4.3 中有詳細說明。我們可以透過 options
參數來調整 waitpid
函式的行為。
以上述的行為來講,可以使用下面兩個參數:
WNOHANG
:
WUNTRACED
:
而透過將參數 pid
設置為 -1,使得其等待集合就是由父行程所有的子行程組成的。status
參數則是當回傳值大於 0 時才有效。而此時就代表上述條件被觸發。就可以進行回收作業了。
WIFEXITED(status)
: 如果子行程是通過使用 exit
函式,或是正常使用 return 回傳,那我們就單純的把它從作業列表中刪除。WIFSIGNALED(status)
: 如果子行程是被一個未被捕獲的信號終止,則除了把它從作業列表中刪除之外,印出作業被哪個信號終止。WIFSTOPPED(status)
: 如果子行程是暫停的,那就不需要把它從作業列表中刪除。而是要標注其作業是暫停狀態,並印出作業被哪個信號暫停。前面說到信號處理函式只能使用非同步信號安全的函式,而 printf
並不是。所以必須要在前面阻塞掉所有信號。
我們來簡單梳理一下執行流程,首先先看 pause
函式的說明。它的作用是將 Shell 行程變成睡眠(sleep)狀態,直到信號處理執行結束。而因為在 sigchld_handler
中將前台作業刪除了,因此在 fgpid
函式會回傳 0,因而終止 waitfg
函式。
do_bgfg
這個函式是當使用者輸入 fg
或 bg
指令時進入。有兩種用法。第一種是在第二個參數寫 %1
,1 可以換成其他數字,意指將該數字對應的 job 設為前台或後台。第二種是直接寫數字 1
,表示將 pid 為 1 的行程設為前台或後台。接著我們需要考慮到一些例外情況:
NULL
: 印出錯誤訊息%
開頭,檢測出數字即為 jid
,在從 getjobjid
取得 job 的結構體。需檢查是否有此 jobgetjobpid
取得 job 的結構體。需檢查是否有此 job不管是前後台,都要向該 job 的 process group 發送 SIGCONT
信號。若是後台,則打印訊息後不用管它。若是前台則使用 waitfg
等待。
編譯通過後,就可以進行測試。一共有 16 個測試關卡,可以對照著標準答案。若是自己的 Shell 跑出來的結果與標準答案一樣,就通過了。以下是命令範例,需要從 01 測試到 16。