shell 是允許你與作業系統的核心作互動的一個介面(interface)。下面是小編為大家帶來的關於如何用C語言寫一個簡單的 Unix Shell的知識,歡迎閱讀。
shell 是什麼?
關於這一點已經有很多書面資料,所以對於它的定義我不會探討太多細節。只用一句話說明:
shell 是允許你與作業系統的核心作互動的一個介面(interface)。
shell 是怎樣工作的?
shell解析使用者輸入的命令並執行它。為了能做到這一點,shell的工作流程看起來像這樣:
啟動shell
等待使用者輸入
解析使用者輸入
執行命令並返回結果
回到第 2 步。
但在這整個流程中有一個重要的部分:程序。shell是父程序。這是我們的程式的主執行緒,它等待使用者輸入。然而,由於以下原因,我們不能在主執行緒自身中執行命令:
一個錯誤的命令會導致整個shell停止工作。我們要避免此情況。
獨立的命令應該有他們自己的程序塊。這被稱為隔離,屬於容錯(機制)。
Fork
為了能避免此情況,我們使用系統呼叫 fork。我曾以為我理解了 fork,直到我用它寫了大約4行程式碼(才發現我沒有理解)。
fork 建立當前程序的一份拷貝。這份拷貝被稱為“子程序”,系統中的每個程序都有與它聯絡在一起的唯一的程序 id(pid)。讓我們看以下程式碼片段:
fork.c
#include
#include
#include
int
main() {
pid_t child_pid = fork();
// The child process
if (child_pid == 0) {
printf("### Child ###nCurrent PID: %d and Child PID: %dn",
getpid(), child_pid);
} else {
printf("### Parent ###nCurrent PID: %d and Child PID: %dn",
getpid(), child_pid);
}
return 0;
}
fork 系統呼叫返回兩次,每個程序一次。這一開始聽起來是反直覺的。但讓我們看一下在底層發生了什麼。
通過呼叫 fork,我們在程式中建立了一個新的分支。這與傳統的 if-else 分支不同。fork 對當前程序建立一份拷貝並從中建立了一個新的程序。最終系統呼叫返回子程序的程序 id。
一旦 fork 呼叫成功,子程序和父程序(我們的程式碼的主執行緒)會同時執行。
fork() 建立了一個新的子程序,但與此同時,父程序的執行並沒有停止。子程序執行的開始和結束獨立於父程序,反之亦然。
更進一步討論以前,先說明一點:getpid 系統呼叫返回當前的程序 id。
如果你編譯並執行這段程式碼,會得到類似於下面的輸出:
### Parent ###
Current PID: 85247 and Child PID: 85248
### Child ###
Current PID: 85248 and Child PID: 0
在 ### Parent ### 下面的片段中,當前程序 ID 是 85247,子程序 ID 是 85248。注意,子程序的 pid 比父程序的大,表明子程序是在父程序之後建立的。(更新:正如某人在 Hacker News 上正確指出的,這並不是確定的',雖然往往是這樣。原因在於,作業系統可能回收無用的老程序 id。)
在 ### Child ### 下面的片段中,當前程序 ID 是 85248,這與前面片段中子程序的 pid 相同。然而,這裡的子程序 pid 為 0。
實際的數字會隨著每一次執行而變化。
你可能在想,我們已經在第 9 行明確的給 child_pid 賦了一個值(譯者注:應該是第7行),那麼 child_pid 怎麼會在同一個執行流程中呈現兩個不同的值,這種想法值得原諒。但是,回想一下,呼叫 fork 建立了一個新程序,這個新程序與當前程序相同。因此,在父程序中,child_pid 是剛建立的子程序的實際值,而子程序本身沒有自己的子程序,所以 child_pid 的值為 0。
因此,為了控制哪些程式碼在子程序中執行,哪些又在父程序中執行,需要我們在 12 到 16 行定義的 if-else 塊(譯者注:應該是 10 到 16 行)。當 child_pid 為 0 時,程式碼塊將在子程序下執行,而 else 塊卻會在父程序下執行。這些塊被執行的順序是不確定的,取決於作業系統的排程程式。
引入確定性
讓我向你介紹系統呼叫 sleep。引用 linux man 頁面的話:
sleep – 暫停執行一段時間
時間間隔以秒為單位。
讓我們給父程序,即我們程式碼中的 else 塊,加一個 sleep(1) 呼叫:
sleep_parent.c
#include
#include
#include
int
main() {
pid_t child_pid = fork();
// The child process
if (child_pid == 0) {
printf("### Child ###nCurrent PID: %d and Child PID: %dn",
getpid(), child_pid);
} else {
sleep(1); // Sleep for one second
printf("### Parent ###nCurrent PID: %d and Child PID: %dn",
getpid(), child_pid);
}
return 0;
}
當你執行這段程式碼時,輸出將類似這樣:
### Child ###
Current PID: 89743 and Child PID: 0
1秒鐘以後,你將看到
### Parent ###
Current PID: 89742 and Child PID: 89743
每次執行這段程式碼時你會看到同樣的表現。這是因為:我們在父程序中做了一個阻塞性的 sleep 呼叫,與此同時,作業系統排程程式發現有空閒的 CPU 時間可以給子程序執行。
類似的,如果你反過來,把 sleep(1) 呼叫加到子程序,也就是我們程式碼中的 if 塊裡面,你會發現父程序塊立刻輸出到控制檯上。但你也會發現程式終止了。子程序塊的輸出被轉存到標準輸出。看起來是這樣:
$ gcc -lreadline blog/sleep_child.c -o sleep_child && ./sleep_child
### Parent ###
Current PID: 23011 and Child PID: 23012
$ ### Child ###
Current PID: 23012 and Child PID: 0
這段原始碼可在 sleep_child.c 獲取。
這是因為父程序在 printf 語句之後無事可做,被終止了。然而,子程序在 sleep 呼叫處被阻塞了 1 秒鐘,之後才執行 printf 語句。
正確實現的確定性
然而,使用 sleep 來控制程序的執行流程不是最好的方法,因為你做了一個 n 秒的 sleep 呼叫:
你怎麼確保不管你等待的是什麼,都會在 n 秒內完成執行呢?
不管你等待的是什麼,要是它在遠遠早於 n 秒時就結束了呢?在此情況下你不必要地閒置了。
有一種更好的方法是,使用 wait 系統呼叫(或一種變體)來代替。我們將使用 waitpid 系統呼叫。它帶有以下引數:
你想要程式等待的程序的程序 ID。
一個變數,用來儲存程序如何終止的相關資訊。
選項標誌,用來定製 waitpid 的行為
wait.c
#include
#include
#include
#include
int
main() {
pid_t child_pid;
pid_t wait_result;
int stat_loc;
child_pid = fork();
// The child process
if (child_pid == 0) {
printf("### Child ###nCurrent PID: %d and Child PID: %dn",
getpid(), child_pid);
sleep(1); // Sleep for one second
} else {
wait_result = waitpid(child_pid, &stat_loc, WUNTRACED);
printf("### Parent ###nCurrent PID: %d and Child PID: %dn",
getpid(), child_pid);
}
return 0;
}
當你執行這段程式碼,你會發現子程序塊立刻被列印,然後等待很短的一段時間(這裡我們在 printf 後面加了 sleep)。父程序等待子程序執行結束,之後就有空執行它自己的命令。
這裡將介紹 exec 函式家族。即以下函式:
execl
execv
execle
execve
execlp
execvp
為了滿足需要,我們將使用 execvp,它的簽名看起來像這樣:
int execvp(const char *file, char *const argv[]);
函式名中的 vp 表明:它接受一個檔名,將在系統 $PATH 變數中搜索此檔名,它還接受將要執行的一組引數。
你可以閱讀 exec 的 man 頁面 以得到其它函式的更多資訊。
讓我們看一下以下程式碼,它執行命令 ls -l -h -a:
execvp.c
#include
int main() {
char *argv[] = {"ls", "-l", "-h", "-a", NULL};
execvp(argv[0], argv);
return 0;
}
關於 execvp 函式,有幾點需要注意:
第一個引數是命令名。
第二個引數由命令名和傳遞給命令自身的引數組成。並且它必須以 NULL 結束。
它將當前程序的映像交換為被執行的命令的映像,後面再展開說明。
如果你編譯並執行上面的程式碼,你會看到類似於下面的輸出:
total 32
drwxr-xr-x 5 dhanush staff 170B Jun 11 11:32 .
drwxr-xr-x 4 dhanush staff 136B Jun 11 11:30 ..
-rwxr-xr-x 1 dhanush staff 8.7K Jun 11 11:32
drwxr-xr-x 3 dhanush staff 102B Jun 11 11:32
-rw-r--r-- 1 dhanush staff 130B Jun 11 11:32
它和你在你的主 shell 中手動執行ls -l -h -a的結果完全相同。
既然我們能執行命令了,我們需要使用在第一部分中學到的fork 系統呼叫構建有用的東西。事實上我們要做到以下這些:
當用戶輸入時接受命令。
呼叫 fork 以建立一個子程序。
在子程序中執行命令,同時父程序等待命令完成。
回到第一步。
我們看看下面的函式,它接收一個字串作為輸入。我們使用庫函式 strtok 以空格分割該字串,然後返回一個字串陣列,陣列也用 NULL來終結。
include
#include
char **get_input(char *input) {
char **command = malloc(8 * sizeof(char *));
char *separator = " ";
char *parsed;
int index = 0;
parsed = strtok(input, separator);
while (parsed != NULL) {
command[index] = parsed;
index++;
parsed = strtok(NULL, separator);
}
command[index] = NULL;
return command;
}
如果該函式的輸入是字串 “ls -l -h -a”,那麼函式將會建立這樣形式的一個數組:[“ls”, “-l”, “-h”, “-a”, NULL],並且返回指向此佇列的指標。
現在,我們在主函式中呼叫 readline 來讀取使用者的輸入,並將它傳給我們剛剛在上面定義的 get_input。一旦輸入被解析,我們在子程序中呼叫 fork 和 execvp。在研究程式碼以前,看一下下面的圖片,先理解 execvp 的含義:
當 fork 命令完成後,子程序是父程序的一份精確的拷貝。然而,當我們呼叫 execvp 時,它將當前程式替換為在引數中傳遞給它的程式。這意味著,雖然程序的當前文字、資料、堆疊段被替換了,程序 id 仍保持不變,但程式完全被覆蓋了。如果呼叫成功了,那麼 execvp 將不會返回,並且子程序中在這之後的任何程式碼都不會被執行。這裡是主函式:
#include
#include
#include
#include
#include
#include
int main() {
char **command;
char *input;
pid_t child_pid;
int stat_loc;
while (1) {
input = readline("unixsh> ");
command = get_input(input);
child_pid = fork();
if (child_pid == 0) {
/* Never returns if the call is successful */
execvp(command[0], command);
printf("This won't be printed if execvp is successuln");
} else {
waitpid(child_pid, &stat_loc, WUNTRACED);
}
free(input);
free(command);
}
return 0;
}
全部程式碼可在此處的單個檔案中獲取。如果你用 gcc -g -lreadline shell.c 編譯它,並執行二進位制檔案,你會得到一個最小的可工作 shell,你可以用它來執行系統命令,比如 pwd 和 ls -lha:
unixsh> pwd
/Users/dhanush/
unixsh> ls -lha
total 28K
drwxr-xr-x 6 root root 204 Jun 11 18:27 .
drwxr-xr-x 3 root root 4.0K Jun 11 16:50 ..
-rwxr-xr-x 1 root root 16K Jun 11 18:27
drwxr-xr-x 3 root root 102 Jun 11 15:32
-rw-r--r-- 1 root root 130 Jun 11 15:38 execvp.c
-rw-r--r-- 1 root root 997 Jun 11 18:25 shell.c
unixsh>
注意:fork 只有在使用者輸入命令後才被呼叫,這意味著接受使用者輸入的使用者提示符是父程序。
錯誤處理
到目前為止,我們一直假設我們的命令總會完美的執行,還沒有處理錯誤。所以我們要對 shell.c做一點改動:
fork – 如果作業系統記憶體耗盡或是程序數量已經到了允許的最大值,子程序就無法建立,會返回 -1。我們在程式碼里加上以下內容:
...
while (1) {
input = readline("unixsh> ");
command = get_input(input);
child_pid = fork();
if (child_pid < 0) {
perror("Fork failed");
exit(1);
}
...
execvp – 就像上面解釋過的,被成功呼叫後它不會返回。然而,如果執行失敗它會返回 -1。同樣地,我們修改 execvp 呼叫:
...
if (execvp(command[0], command) < 0) {
perror(command[0]);
exit(1);
}
...
注意:雖然fork之後的exit呼叫終止整個程式,但execvp之後的exit 呼叫只會終止子程序,因為這段程式碼只屬於子程序。
malloc – It can fail if the OS runs out of memory. We should exit the program in such a scenario:
malloc – 如果作業系統記憶體耗盡,它就會失敗。在這種情況下,我們應該退出程式:
char **get_input(char *input) {
char **command = malloc(8 * sizeof(char *));
if (command == NULL) {
perror("malloc failed");
exit(1);
}
...
動態記憶體分配 – 目前我們的命令緩衝區只分配了8個塊。如果我們輸入的命令超過8個單詞,命令就無法像預期的那樣工作。這麼做是為了讓例子便於理解,如何解決這個問題留給讀者作為一個練習。
上面帶有錯誤處理的程式碼可在這裡獲取。
內建命令
如果你試著執行 cd 命令,你會得到這樣的錯誤:
cd: No such file or directory
我們的 shell 現在還不能識別cd命令。這背後的原因是:cd不是ls或pwd這樣的系統程式。讓我們後退一步,暫時假設cd 也是一個系統程式。你認為執行流程會是什麼樣?在繼續閱讀之前,你可能想要思考一下。
流程是這樣的:
使用者輸入 cd /。
shell對當前程序作 fork,並在子程序中執行命令。
在成功呼叫後,子程序退出,控制權還給父程序。
父程序的當前工作目錄沒有改變,因為命令是在子程序中執行的。因此,cd 命令雖然成功了,但並沒有產生我們想要的結果。
因此,要支援 cd,我們必須自己實現它。我們也需要確保,如果使用者輸入的命令是 cd(或屬於預定義的內建命令),我們根本不要 fork 程序。相反地,我們將執行我們對 cd(或任何其它內建命令)的實現,並繼續等待使用者的下一次輸入。,幸運的是我們可以利用 chdir 函式呼叫,它用起來很簡單。它接受路徑作為引數,如果成功則返回0,失敗則返回 -1。我們定義函式:
int cd(char *path) {
return chdir(path);
}
並且在我們的主函式中為它加入一個檢查:
while (1) {
input = readline("unixsh> ");
command = get_input(input);
if (strcmp(command[0], "cd") == 0) {
if (cd(command[1]) < 0) {
perror(command[1]);
}
/* Skip the fork */
continue;
}
...
帶有以上更改的程式碼可從這裡獲取,如果你編譯並執行它,你將能執行 cd 命令。這裡是一個示例輸出:
unixsh> pwd
/Users/dhanush/
unixsh> cd /
unixsh> pwd
/
unixsh>
第二部分到此結束。這篇部落格帖文中的所有程式碼示例可在這裡獲取。在下一篇部落格帖文中,我們將探討訊號的主題以及實現對使用者中斷(Ctrl-C)的處理。敬請期待。