- 原文地址:Tutorial - Write a Shell in C
- 原文做者:Stephen Brennan
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:nettee
- 校對者:kasheemlew,JackEggie
你很容易認爲本身「不是一個真正的程序員」。有一些程序全部人都用,它們的開發者很容易被捧上神壇。雖然開發大型軟件項目並不容易,但不少時候這種軟件的基本思想都很簡單。本身實現這樣的軟件是一種證實本身能夠是真正的程序員的有趣方式。因此,這篇文章介紹了我是如何用 C 語言寫一個本身的簡易 Unix shell 的。我但願其餘人也能感覺到這種有趣的方式。html
這篇文章中介紹的 shell(叫作 lsh
),能夠在 GitHub 上獲取它的源代碼。前端
學校裏的學生請注意! 許多課程都有要求你編寫一個 shell 的做業,並且有些教師都知道這樣的教程和代碼。若是你是此類課程上的學生,請不要在未經容許的狀況下複製(或複製加修改)這裏的代碼。我建議反對重度依賴本教程的行爲。android
讓咱們自頂向下地觀察一個 shell。一個 shell 在它的生命週期中主要作三件事。ios
這三個步驟過於寬泛,其實能夠適用於任何程序,但咱們能夠將其用於咱們的 shell 的基礎。咱們的 shell 會很簡單,不須要任何配置文件,也沒有任何關閉命令。那麼,咱們只須要調用循環函數,而後終止。不過對於架構而言,咱們須要記住,程序的生命週期並不只僅是循環。git
int main(int argc, char **argv) {
// 若是有配置文件,則加載。
// 運行命令循環
lsh_loop();
// 作一些關閉和清理工做。
return EXIT_SUCCESS;
}
複製代碼
這裏你能夠看到,我只是寫了一個函數:lsh_loop()
。這個函數會循環,並解釋執行一條條命令。咱們接下來會看到這個循環如何實現。程序員
咱們已經知道了 shell 程序如何啓動。如今考慮程序的基本邏輯:shell 在它的循環中會作什麼?處理命令的一個簡單的方式是採用這三步:github
下面,我將這些思路轉化爲 lsh_loop()
的代碼:shell
void lsh_loop(void) {
char *line;
char **args;
int status;
do {
printf("> ");
line = lsh_read_line();
args = lsh_split_line(line);
status = lsh_execute(args);
free(line);
free(args);
} while (status);
}
複製代碼
讓咱們看一遍這段代碼。一開始的幾行只是一些聲明。Do-while 循環在檢查狀態變量時會更方便,由於它會在檢查變量的值以前先執行一次。在循環內部,咱們打印了一個提示符,調用函數來分別讀取一行輸入、將一行分割爲參數,以及執行這些參數。最後,咱們釋放以前爲 line 和 args 申請的內存空間。注意到咱們使用 lsh_execute()
返回的狀態變量決定什麼時候退出循環。後端
從標準輸入讀取一行聽起來很簡單,但用 C 語言作起來可能有必定難度。壞消息是,你無法預先知道用戶會在 shell 中鍵入多長的文本。所以你不能簡單地分配一塊空間,但願能裝得下用戶的輸入,而應該先暫時分配必定長度的空間,當確實裝不下用戶的輸入時,再從新分配更多的空間。這是 C 語言中的一個常見策略,咱們也會用這個方法來實現 lsh_read_line()
。數組
#define LSH_RL_BUFSIZE 1024
char *lsh_read_line(void) {
int bufsize = LSH_RL_BUFSIZE;
int position = 0;
char *buffer = malloc(sizeof(char) * bufsize);
int c;
if (!buffer) {
fprintf(stderr, "lsh: allocation error\n");
exit(EXIT_FAILURE);
}
while (1) {
// 讀一個字符
c = getchar();
// 若是咱們到達了 EOF, 就將其替換爲 '\0' 並返回。
if (c == EOF || c == '\n') {
buffer[position] = '\0';
return buffer;
} else {
buffer[position] = c;
}
position++;
// 若是咱們超出了 buffer 的大小,則從新分配。
if (position >= bufsize) {
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer, bufsize);
if (!buffer) {
fprintf(stderr, "lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}
}
}
複製代碼
第一部分是不少的聲明。也許你沒有發現,我傾向於使用古老的 C 語言風格,將變量的聲明放在其餘代碼前面。這個函數的重點在(顯然是無限的)while (1)
循環中。在這個循環中,咱們讀取了一個字符(並將它保存爲 int
類型,而不是 char
類型,這很重要!EOF 是一個整型值而不是字符型值。若是你想將它的值做爲判斷條件,須要使用 int
類型。這是 C 語言初學者常犯的錯誤。)。若是這個字符是換行符或者 EOF,咱們將當前字符串用空字符結尾,並返回它。不然,咱們將這個字符添加到當前的字符串中。
下一步,咱們檢查下一個字符是否會超出當前的緩衝區大小。若是會超出,咱們就先從新分配緩衝區(並檢查內存分配是否成功)。就是這樣。
若是你對新版的 C 標準庫很熟悉,會注意到 stdio.h
中有一個 getline()
函數,和咱們剛纔實現的功能幾乎同樣。實話說,我在寫完上面這段代碼以後才知道這個函數的存在。這個函數一直是 C 標準庫的 GNU 擴展,直到 2008 年才加入規約中,大多數現代的 Unix 系統應該都已經有了這個函數。我會保持我已寫的代碼,我也鼓勵大家先用這種方式學習,而後再使用 getline
。不然,你會失去一次學習的機會!無論怎樣,有了 getline
以後,這個函數就不重要了:
char *lsh_read_line(void) {
char *line = NULL;
ssize_t bufsize = 0; // 利用 getline 幫助咱們分配緩衝區
getline(&line, &bufsize, stdin);
return line;
}
複製代碼
好,那咱們回到最初的那個循環。咱們目前實現了 lsh_read_line()
,獲得了一行輸入。如今,咱們須要將這一行解析爲參數的列表。我在這裏將會作一個巨大的簡化,假設咱們的命令行參數中不容許使用引號和反斜槓轉義,而是簡單地使用空白字符做爲參數間的分隔。這樣的話,命令 echo "this message"
就不是使用單個參數 this message
調用 echo,而是有兩個參數: "this
和 message"
。
有了這些簡化,咱們須要作的只是使用空白符做爲分隔符標記字符串。這意味着咱們可使用傳統的庫函數 strtok
來爲咱們幹些苦力活。
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line) {
int bufsize = LSH_TOK_BUFSIZE, position = 0;
char **tokens = malloc(bufsize * sizeof(char*));
char *token;
if (!tokens) {
fprintf(stderr, "lsh: allocation error\n");
exit(EXIT_FAILURE);
}
token = strtok(line, LSH_TOK_DELIM);
while (token != NULL) {
tokens[position] = token;
position++;
if (position >= bufsize) {
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens, bufsize * sizeof(char*));
if (!tokens) {
fprintf(stderr, "lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}
token = strtok(NULL, LSH_TOK_DELIM);
}
tokens[position] = NULL;
return tokens;
}
複製代碼
這段代碼看起來和 lsh_read_line()
極其類似。這是由於它們就是很類似!咱們使用了相同的策略 —— 使用一個緩衝區,而且將其動態地擴展。不過這裏咱們使用的是以空指針結尾的指針數組,而不是以空字符結尾的字符數組。
在函數的開始處,咱們開始調用 strtok
來分割 token。這個函數會返回指向第一個 token 的指針。strtok()
實際上作的是返回指向你傳入的字符串內部的指針,並在每一個 token 的結尾處放置字節 \0
。咱們將每一個返回的指針放在一個字符指針的數組(緩衝區)中。
最後,咱們在必要時從新分配指針數組。這樣的處理過程一直重複,直到 strtok
再也不返回 token 爲止。此時,咱們將 token 列表的尾部設爲空指針。
這樣,咱們的工做完成了,咱們獲得了 token 的數組。接下來咱們就能夠執行命令。那麼問題來了,咱們怎麼去執行命令呢?
如今,咱們真正來到了 shell 的核心位置。Shell 的主要功能就是啓動進程。因此寫一個 shell 意味着你要很清楚進程中發生了什麼,以及進程是如何啓動的。所以這裏我要暫時岔開話題,聊一聊 Unix 中的進程。
在 Unix 中,啓動進程只有兩種方式。第一種(其實不能算一種方式)是成爲 Init 進程。當 Unix 機器啓動時,它的內核會被加載。內核加載並初始化完成後,會啓動單獨一個進程,叫作 Init 進程。這個進程在機器開啓的時間中會一直運行,負責管理啓動其餘的你須要的進程,這樣機器才能正常使用。
既然大部分的程序都不是 Init,那麼實際上就只有一種方式啓動進程:使用 fork()
系統調用。當調用該函數時,操做系統會將當前進程複製一份,並讓二者同時運行。原有的進程叫作「父進程」,而新的進程叫作「子進程」。fork()
會在子進程中返回 0,在父進程中返回子進程的進程 ID 號(PID)。本質上,這意味着新進程啓動的惟一方法是複製一個已有的進程。
這看上去好像有點問題。特別是,當你想運行一個新的進程時,你確定不但願再運行一遍相同的程序 —— 你想運行的是另外一個程序。這就是 exec()
系統調用所作的事情。它會將當前運行的程序替換爲一個全新的程序。這意味着每當你調用 exec
,操做系統都會停下你的進程,加載新的程序,而後在原處啓動新的程序。一個進程歷來不會從 exec()
調用中返回(除非出現錯誤)。
有了這兩個系統調用,咱們就有了大多數程序在 Unix 上運行的基本要素。首先,一個已有的進程將本身分叉(fork)爲兩個不一樣的進程。而後,子進程使用 exec()
將本身正在執行的程序替換爲一個新的程序。父進程能夠繼續作其餘的事情,甚至也可使用系統調用 wait()
繼續關注子進程。
啊!咱們講了這麼多。可是有了這些做爲背景,下面啓動程序的代碼纔是說得通的:
int lsh_launch(char **args) {
pid_t pid, wpid;
int status;
pid = fork();
if (pid == 0) {
// 子進程
if (execvp(args[0], args) == -1) {
perror("lsh");
}
exit(EXIT_FAILURE);
} else if (pid < 0) {
// Fork 出錯
perror("lsh");
} else {
// 父進程
do {
wpid = waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}
複製代碼
這個函數使用了咱們以前建立的參數列表。而後,它 fork 當前的進程,並保存返回值。當 fork()
返回時,咱們實際上有了兩個併發運行的進程。子進程會進入第一個 if 分支(pid == 0
)。
在子進程中,咱們想要運行用戶提供的命令。因此,咱們使用 exec
系統調用的多個變體之一:execvp
。exec
的不一樣變體作的事情稍有不一樣。一些接受變長的字符串參數,一些接受字符串的列表,還有一些容許你設定進程運行的環境。execvp
這個變體接受一個程序名和一個字符串參數的數組(也叫作向量(vector),所以是‘v’)(數組的第一個元素應當是程序名)。‘p’ 表示咱們不須要提供程序的文件路徑,只須要提供文件名,讓操做系統搜索程序文件的路徑。
若是 exec 命令返回 -1(或者說,若是它返回了),咱們就知道有地方出錯了。那麼,咱們使用 perror
打印系統的錯誤消息以及咱們的程序名,讓用戶知道是哪裏出了錯。而後,咱們讓 shell 繼續運行。
第二個 if 條件(pid < 0
)檢查 fork()
是否出錯。若是出錯,咱們打印錯誤,而後繼續執行 —— 除了告知用戶,咱們不會進行更多的錯誤處理。咱們讓用戶決定是否須要退出。
第三個 if 條件代表 fork()
成功執行。父進程會運行到這裏。咱們知道子進程會執行命令的進程,因此父進程須要等待命令運行結束。咱們使用 waitpid()
來等待一個進程改變狀態。不幸的是,waitpid()
有不少選項(就像 exec()
同樣)。進程能夠以不少種方式改變其狀態,並非全部的狀態都表示進程結束。一個進程可能退出(正常退出,或者返回一個錯誤碼),也可能被一個信號終止。因此,咱們須要使用 waitpid()
提供的宏來等待進程退出或被終止。函數最終返回 1,提示上層函數須要繼續提示用戶輸入了。
你可能發現了,lsh_loop()
函數調用了 lsh_execute()
。但上面咱們寫的函數卻叫作 lsh_launch()
。這是有意爲之的。雖然 shell 執行的命令大部分是程序,但有一些不是。一些命令是 shell 內置的。
這裏的緣由其實很簡單。若是你想改變當前目錄,你須要使用函數 chdir()
。問題是,當前目錄是進程的一個屬性。那麼,若是你寫了一個叫 cd
的程序來改變當前目錄,它只會改變本身當前的目錄,而後終止。它的父進程的當前目錄不會改變。因此應當是 shell 進程本身執行 chdir()
,才能更新本身的當前目錄。而後,當它啓動子進程時,子進程也會繼承這個新的目錄。
相似的,若是有一個程序叫作 exit
,它也沒有辦法使調用它的 shell 退出。這個命令也必須內置在 shell 中。還有,多數 shell 經過運行配置腳本(如 ~/.bashrc
)來進行配置。這些腳本使用一些改變 shell 行爲的命令。這些命令若是由 shell 本身實現的話,一樣只會改變 shell 本身的行爲。
所以,咱們須要向 shell 自己添加一些命令是有道理的。我添加進個人 shell 的命令是 cd
、exit
和 help
。下面是他們的函數實現:
/* 內置 shell 命令的函數聲明: */
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
/* 內置命令列表,以及它們對應的函數。 */
char *builtin_str[] = {
"cd",
"help",
"exit"
};
int (*builtin_func[]) (char **) = {
&lsh_cd,
&lsh_help,
&lsh_exit
};
int lsh_num_builtins() {
return sizeof(builtin_str) / sizeof(char *);
}
/* 內置命令的函數實現。 */
int lsh_cd(char **args) {
if (args[1] == NULL) {
fprintf(stderr, "lsh: expected argument to \"cd\"\n");
} else {
if (chdir(args[1]) != 0) {
perror("lsh");
}
}
return 1;
}
int lsh_help(char **args) {
int i;
printf("Stephen Brennan's LSH\n");
printf("Type program names and arguments, and hit enter.\n");
printf("The following are built in:\n");
for (i = 0; i < lsh_num_builtins(); i++) {
printf(" %s\n", builtin_str[i]);
}
printf("Use the man command for information on other programs.\n");
return 1;
}
int lsh_exit(char **args) {
return 0;
}
複製代碼
這段代碼有三個部分。第一部分包括個人函數的前置聲明。前置聲明是當你聲明瞭(但還未定義)某個符號,就能夠在它的定義以前使用。我這麼作是由於 lsh_help()
使用了內置命令的數組,而這個數組中又包括 lsh_help()
。打破這個依賴循環的最好方式是使用前置聲明。
第二個部分是內置命令名字的數組,而後是它們對應的函數的數組。這樣作是爲了,在將來能夠簡單地經過修改這些數組來添加內置命令,而不是修改代碼中某處一個龐大的「switch」語句。若是你不理解 builtin_func
的聲明,這很正常!我也不理解。這是一個函數指針(一個接受字符串數組做爲參數,返回整型的函數)的數組。C 語言中任何有關函數指針的聲明都會很複雜。我本身仍然須要查一下函數指針是怎麼聲明的!
最後,我實現了每一個函數。lsh_cd()
函數首先檢查它的第二個參數是否存在,不存在的話打印錯誤消息。而後,它調用 chdir()
,檢查是否出錯,並返回。幫助函數會打印漂亮的消息,以及全部內置函數的名字。退出函數返回 0,這是讓命令循環退出的信號。
咱們的程序最後缺失的一部分就是實現 lsh_execute()
了。這個函數要麼啓動一個內置命令,要麼啓動一個進程。若是你一路讀到了這裏,你會知道咱們只剩下一個很是簡單的函數須要實現了:
int lsh_execute(char **args) {
int i;
if (args[0] == NULL) {
// 用戶輸入了一個空命令
return 1;
}
for (i = 0; i < lsh_num_builtins(); i++) {
if (strcmp(args[0], builtin_str[i]) == 0) {
return (*builtin_func[i])(args);
}
}
return lsh_launch(args);
}
複製代碼
這個函數所作的不過是檢查命令是否和各個內置命令相同,若是相同的話就運行內置命令。若是沒有匹配到一個內置命令,咱們會調用 lsh_launch()
來啓動進程。須要注意的是,有可能用戶輸入了一個空字符串或字符串只有空白符,此時 args
只包含空指針。因此,咱們須要在一開始檢查這種狀況。
以上就是這個 shell 的所有代碼了。若是你已經讀完,你應該徹底理解了 shell 是如何工做的。要試用它(在 Linux 機器上)的話,你須要將這些代碼片斷複製到一個文件中(main.c
),而後編譯它。確保代碼中只包括一個 lsh_read_line()
的實現。你須要在文件的頂部包含如下的頭文件。我添加了註釋,以便你知道每一個函數的來源。
#include <sys/wait.h>
waitpid()
及其相關的宏#include <unistd.h>
chdir()
fork()
exec()
pid_t
#include <stdlib.h>
malloc()
realloc()
free()
exit()
execvp()
EXIT_SUCCESS
, EXIT_FAILURE
#include <stdio.h>
fprintf()
printf()
stderr
getchar()
perror()
#include <string.h>
strcmp()
strtok()
當你準備好了代碼和頭文件,簡單地運行 gcc -o main main.c
進行編譯,而後 ./main
來運行便可。
或者,你能夠從 GitHub 上獲取代碼。這個連接直接跳轉到我寫這篇文章時的代碼當前版本 —— 將來我可能會更新代碼,增長一些新的功能。若是代碼更新了,我會盡可能在本文中更新代碼的細節和實現思路。
若是你讀了這篇文章,想知道我究竟是怎麼知道如何使用這些系統調用的。答案很簡單:經過手冊頁(man pages)。在 man 3p
中有對每一個系統調用的詳盡文檔。若是你知道你要找什麼,只是想知道如何使用它,那麼手冊頁是你最好的朋友。若是你不知道 C 標準庫和 Unix 爲你提供了什麼樣的接口,我推薦你閱讀 POSIX 規範,特別是第 13 章,「頭文件」。你能夠找到每一個頭文件,以及其中須要定義哪些內容。
顯然,這個 shell 的功能不夠豐富。一些明顯的遺漏有:
實現這幾個功能其實很是有趣,但已經遠不是我這樣一篇文章能夠容納的了的了。若是我開始實現其中任何一項,我必定會寫一篇關於它的後續文章。不過我鼓勵讀者們都嘗試本身實現這些功能。若是你成功了,請在下面的評論區給我留言,我很樂意看到你的代碼。
最後,感謝閱讀這篇教程(若是有人讀了的話)。我寫得很開心,也但願你能讀得開心。在評論區讓我知道你的想法!
更新: 在本文的較早版本中,我在 lsh_split_line()
中遇到了一些討厭的 bug,它們剛好相互抵消了。感謝 Reddit 的 /u/munmap(以及其餘評論者)找到了這些 bug! 在這裏看看我究竟作錯了什麼。
更新二: 感謝 GitHub 用戶 ghswa 貢獻了我忘記的一些 malloc()
的空指針檢查。他/她還指出 getline
的手冊頁規定了第一個參數所佔用的內存空間應當能夠被釋放,因此個人使用 getline()
的 lsh_read_line()
實現中,line
應當初始化爲 NULL
。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。