介紹#
對應章節:第八章
實驗內容:寫一個簡單的支持任務控制的 Unix shell
實驗目的:熟悉進程控制與信號處理
實驗講義:http://csapp.cs.cmu.edu/3e/shlab.pdf
Unix Shells 是什麼#
維基百科中對 Unix shell 的解釋:
A Unix shell is a command-line interpreter or shell that provides a command line user interface for Unix-like operating systems.
按照實驗講義中的描述,Unix shell 具有以下特性:
- shell 是一個互動式的命令行解釋器,代表用戶運行程序。
- shell 會反復打印提示符,等待用戶輸入命令行,然後執行相應的操作。
- 命令行是由空白字符分隔的 ASCII 文本單詞序列。第一個單詞是內置命令名或可執行文件的路徑名,其餘單詞是命令行參數。
- 如果第一個單詞是內置命令,shell 會立即執行該命令。否則,shell 會創建一個子進程,並在子進程中加載和運行程序。這些子進程統稱為一個 job。
- 如果命令行以
&
結尾,則 job 在後台運行,shell 不會等待它結束就打印下一個提示符。否則,job 在前台運行,shell 會等待它結束。 - Unix shell 支持作業控制,允許用戶在前後台之間移動 job,以及改變 job 中進程的狀態 (運行、停止或終止)。
- 按 Ctrl-C 會給前台 job 發送 SIGINT 信號,默認操作是終止進程。按 Ctrl-Z 會給前台 job 發送 SIGTSTP 信號,默認操作是將進程置於停止狀態。
- Unix shell 提供了一些內置命令來支持作業控制,如
jobs
、bg
、fg
、kill
等。
我們的 tsh 是什麼#
本次實驗要實現一個叫 tsh 的簡單的 shell,它具有以下特性:
- 提示符應該是字符串 "tsh>"。
- 用戶輸入的命令行由一個名稱和零個或多個參數組成,所有元素用一個或多個空格分隔。如果名稱是內置命令,tsh 應該立即處理它並等待下一個命令。否則,tsh 應該假定名稱是可執行文件的路徑,並在初始子進程的上下文中加載和運行它。在這種情況下,"job" 指代這個初始子進程。
- tsh 不需要支持管道 (|) 或 I/O 重定向 (< 和 >)。
- 按 Ctrl-C (Ctrl-Z) 應該向當前前台 job 及其所有子進程發送 SIGINT (SIGTSTP) 信號。如果沒有前台 job,信號應該無效。
- 如果命令行以
&
結尾,tsh 應該在後台運行 job。否則,它應該在前台運行 job。 - 每個 job 可以用進程 ID (PID) 或 job ID (JID) 標識,JID 是 tsh 分配的正整數,在命令行上用前綴
%
表示,如 "%5"。 - tsh 應該支持以下內置命令:
quit
: 終止 shell。jobs
: 列出所有後台 job。bg <job>
: 通過發送 SIGCONT 信號重啟<job>
,並在後台運行它。<job>
可以是 PID 或 JID。fg <job>
: 通過發送 SIGCONT 信號重啟<job>
,並在前台運行它。<job>
可以是 PID 或 JID。
- tsh 應該回收所有殭屍子進程。如果任何 job 因為收到它沒有捕獲的信號而終止,tsh 應該識別這個事件並打印一條消息,包含 job 的 PID 和造成這個問題的信號描述。
具體來說我們需要在實驗代碼框架的基礎上實現以下函數:
eval
:解析命令行的主要程序。builtin_cmd
:識別並運行內置命令 (quit
,fg
,bg
andjobs
)。do_fgbg
:實現bg
和fg
這兩個內置命令。waitfg
:等待一個前台任務完成。sigchld_handler
:處理 SIGCHILD 信號。sigint_handler
:處理 SIGINT (ctrl-c) 信號。sigtstp_handler
:處理 SIGTSTP (ctrl-z) 信號。
接下來我們以合理的順序完成這些函數並解析。
完成 tsh#
在完成 tsh 時應當閱讀官方提供的 trace 文件,逐個實現其功能並在最後完善錯誤處理等語句,在這裡整體講解最後的結果。
任務管理#
先別急,在開始完成這些函數之前我們首先要了解代碼框架提供的任務列表管理工具
在 shell 中任務的狀態有以下幾種:
- FG (foreground):前台運行
- BG (background):後台運行
- ST (stopped):停止
在所有任務中,只有一個能處於 FG 狀態,任務的幾種狀態的轉換條件如下圖所示:
tsh 任務的數據結構如下:
struct job_t { /* The job struct */
pid_t pid; /* job PID */
int jid; /* job ID [1, 2, ...] */
int state; /* UNDEF, BG, FG, or ST */
char cmdline[MAXLINE]; /* command line */
};
任務列表以全局變量形式存儲:
struct job_t jobs[MAXJOBS]; /* The job list */
tsh 提供了以下的函數來管理任務
void clearjob(struct job_t *job);
void initjobs(struct job_t *jobs);
int maxjid(struct job_t *jobs);
int addjob(struct job_t *jobs, pid_t pid, int state, char *cmdline);
int deletejob(struct job_t *jobs, pid_t pid);
pid_t fgpid(struct job_t *jobs);
struct job_t *getjobpid(struct job_t *jobs, pid_t pid);
struct job_t *getjobjid(struct job_t *jobs, int jid);
int pid2jid(pid_t pid);
void listjobs(struct job_t *jobs);
結合它們的名字、參數和返回值,很好理解其作用,在這裡不做解釋
waitfg#
我們要實現的第一個函數,功能是阻塞當前進程,直到 pid 不再是前台進程
void waitfg(pid_t pid) {
while (pid == fgpid(jobs)) {
sleep(0);
}
return;
}
builtin_command#
當用戶輸入的是內建指令時立即執行, 否則返回 0
int builtin_cmd(char **argv) {
char *cmd = argv[0];
if (strcmp(cmd, "quit") == 0) {
exit(0); /* execute it immediately */
} else if (strcmp(cmd, "fg") == 0 || strcmp(cmd, "bg") == 0) {
do_bgfg(argv);
return 1; /* is a builtin command */
} else if (strcmp(cmd, "jobs") == 0) {
listjobs(jobs);
return 1;
}
return 0; /* not a builtin command */
}
只需要識別輸入的arg[0]
(即程序名),如果是三種內建指令之一,則執行相應的函數,否則返回 0
do_bgfg#
執行內建指令 fg 和 bg
void do_bgfg(char **argv) {
struct job_t *jobp = NULL;
if (argv[1] == NULL) {
printf("%s command requires PID or %%jobid argument\n", argv[0]);
return;
}
if (isdigit(argv[1][0])) { /* fg/bg pid */
pid_t pid = atoi(argv[1]);
if ((jobp = getjobpid(jobs, pid)) == 0) {
printf("(%d): No such process\n", pid);
return;
}
} else if (argv[1][0] == '%') { /* fg/bg %jid */
int jid = atoi(&argv[1][1]);
if ((jobp = getjobjid(jobs, jid)) == 0) {
printf("%s: No such job\n", argv[1]);
return;
}
} else {
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
if (strcmp(argv[0], "bg") == 0) {
jobp->state = BG;
kill(-jobp->pid, SIGCONT);
printf("[%d] (%d) %s", jobp->jid, jobp->pid, jobp->cmdline);
} else if (strcmp(argv[0], "fg") == 0) {
jobp->state = FG;
kill(-jobp->pid, SIGCONT);
waitfg(jobp->pid);
}
return;
}
首先說明一下,參數argv
為一個二位指針數組,每個元素都是一個指向字符串的指針,第一個元素argv[0]
為該命令的名稱如”fg“,後續的元素argv[1]
、argv[2]
等則是該命令的參數,最後一個元素為 NULL 代表結束。
- 如果用戶輸入 fg %1,則 argv 數組會是 {"fg", "%1", NULL}。
- 如果用戶輸入 bg 2345,則 argv 數組會是 {"bg", "2345", NULL}。
在這裡有三個 if 語句:
第一個 if 語句:判斷輸入參數是否為空。
第二個 if 語句:判斷輸入參數為 pid 格式還是 % jid 格式,並獲取當前的任務指針。
第三個 if 語句:判斷當前執行的是 fg 還是 bg 命令,並相應地設置任務的 state 字段,發送 SIGCONT 信號給進程所屬的進程組,如果是 fg 命令還需要等待其結束。
eval#
執行用戶輸入的命令行
void eval(char *cmdline) {
char *argv[MAXARGS];
int bg = parseline(cmdline, argv);
pid_t pid;
sigset_t mask, prev_mask;
if (builtin_cmd(argv) == 0) { /* not a builtin command */
// 屏蔽 SIGCHLD 信號
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fork();
if (pid == 0) {
// 在子進程中解除對 SIGCHLD 信號的屏蔽
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
setpgid(0, 0);
if (execve(argv[0], argv, environ) < 0) {
printf("%s: Command not found\n", argv[0]);
exit(1);
}
} else {
if (!bg) {
addjob(jobs, pid, FG, cmdline);
} else {
addjob(jobs, pid, BG, cmdline);
struct job_t *job = getjobpid(jobs, pid);
printf("[%d] (%d) %s", job->jid, pid, cmdline);
}
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (!bg) {
waitfg(pid);
}
}
}
return;
}
- 使用
parseline
解析命令行得到 argv 數組和 bg 標誌。 - 使用
builtin_cmd
判斷若是內建指令直接執行。 - 不是內建指令使用
fork
創建子進程複製一份 tsh。 - 在子進程中,使用
setpgid(0, 0)
將子進程的進程組編號設為自己的進程號,即創建一個以它為領導的進程組,否則子進程會默認加入父進程的用戶組,想一想這樣向子進程發送信號的時候會發生什麼? - 在父進程中向任務列表中添加任務,如果是前台進程等待其結束,如果是後台進程打印相關信息。
- 在創建子進程前屏蔽 SIG_CHILD 信號,在父進程執行完 addjob 後解除屏蔽,想想要不然會什麼樣?同時因為子進程繼承父進程的阻塞向量,所以也要在子進程中解除屏蔽。
信號處理#
我們的 tsh 不應該在收到 SIGINT 或 SIGTSTP 時終止,而是轉發給子進程,在收到 SIG_CHILD 時應當在任務系統中刪除子進程。所以我們需要使用 signal 函數來修改和信號相關聯的默認行為,替換為我們的處理程序。在編寫處理程序時為了保證安全我們需要遵循一些原則:
- G0. 處理程序盡可能簡單。
- G2. 保存和恢復 errno。
- G3. 阻塞所有的信號,在訪問共享全局數據結構時。
sigchld_handler#
當進程接收到 SIGCHILD 信號時的處理程序
void sigchld_handler(int sig) {
int olderrno = errno;
pid_t pid;
int status;
sigset_t mask, prev_mask;
sigfillset(&mask);
while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
if (WIFEXITED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
deletejob(jobs, pid);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
} else if (WIFSIGNALED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
struct job_t *job = getjobpid(jobs, pid);
printf("Job [%d] (%d) terminated by signal %d\n", job->jid, pid, WTERMSIG(status));
deletejob(jobs, pid);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
} else if (WIFSTOPPED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
struct job_t *job = getjobpid(jobs, pid);
job->state = ST;
printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(status));
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
}
}
errno = olderrno;
return;
}
這段程序有以下幾個重點:
- 在開始保存 errno,在結束時恢復。
- 在操作全局變量 jobs 時使用
sigprocmask
阻塞所有的信號操作完後恢復阻塞向量。 - 使用 while 循環來獲取結束的子進程,因為 SIGCHLD 信號可能會被阻塞,所以我們一次要處理盡可能多的已終止子進程。
- 我們使用
WIFEXITED()
、WIFSIGNALED()
和WIFSTOPPED()
來檢查子進程的狀態,並執行相應的操作。
sigint_handler#
當進程接收到 SIGINT 信號時的處理程序
void sigint_handler(int sig) {
pid_t pid;
int olderrno = errno;
sigset_t mask, prev_mask;
sigfillset(&mask);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fgpid(jobs);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (pid > 0) {
kill(-pid, sig);
}
errno = olderrno;
return;
}
- 注意保存恢復 errno。
- 在操作全局變量時屏蔽所有信號。
sigtstp_handler#
當進程接收到 SIGTSTP 信號時的處理程序
void sigtstp_handler(int sig) {
pid_t pid;
int olderrno = errno;
sigset_t mask, prev_mask;
sigfillset(&mask);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fgpid(jobs);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (pid > 0) {
kill(-pid, sig);
}
errno = olderrno;
return;
}
與上一條類似。
總結#
通過這個實驗,實現了一個帶任務控制的簡單 Shell,對異常控制流和信號有了更好的理解。