MIT6.S081/6.828 實驗2:Lab Shell
Mit6.828/6.S081 fall 2019的Lab2是Simple Shell,內容是實現一個簡易的shell程式,本文對該實驗的思路進行詳細介紹,並對xv6提供的shell實現進行深入解析。
準備
首先來看實驗要求:
- 實現的shell要支援 基礎命令執行、重定向 (< >) 處理、管道 ( | ) 處理
- 不能使用malloc()動態分配記憶體
- 使用"@"代替"$"作為命令列的提示符
- 及時關閉檔案描述符;對系統呼叫的異常進行處理
xv6中提供有sh.c
的實現,除了重定向和管道,還對括號、列表命令、後臺命令等做了支援,且整體設計較為複雜。所以我們無需過多參考已有程式碼,可以選擇簡單的思路來滿足需求,在完成後再去閱讀xv6的shell實現。
Shell本質上是一個使用者程式,在使用者和作業系統間建立連線。工作原理是在啟動後不斷接收並解析使用者輸入的命令,呼叫作業系統介面去執行命令,並把結果返回給使用者。Shell運行於使用者態而非核心態的好處是可以和核心完全解耦,實現可插拔的效果,因此你可以在bash、zsh、ksh等不同shell間輕鬆完成切換。
實驗思路
下面介紹實驗的整體思路,完整程式碼在 Github 中,並附有詳細註釋。
首先需要了解幾個核心的系統呼叫:
- fork() : 該呼叫會建立一個子程序,會複製一份記憶體到獨立的程序空間,程式碼中根據返回值來區分是子程序 (返回0) 還是父程序 (返回子程序的pid)。shell中會對輸入的命令fork出子程序去執行,除了
cd
- wait():該方法會阻塞父程序,等待子程序退出後再結束,注意如果fork()了多個子程序,則需要多次呼叫wait()才能等待所有子程序完成。且wait()是無法等待孫子程序的。
- exec(char * path, char **argv):該方法會執行一個指定的命令,會將新的可執行檔案載入到記憶體中執行,替換當前的程序空間。原程式中exec()後面的程式碼不會再被執行,這也是shell中需要fork程序去exec命令的原因,不然就無法繼續處理一條命令了。
主體邏輯
程式的主邏輯是在 main()
方法中迴圈接收標準輸入,fork()
出子程序進行處理,首先將接收到字串分割為字串陣列方便處理,然後進入命令解析和執行。
int main(void) {
char buf[MAXLEN]; // 用於接收命令的字串
char *argv[MAXARGS]; // 字串陣列(指標陣列)
int argc; // 引數個數
while (getcmd(buf, sizeof(buf)) >= 0) {
if (fork() == 0) {
argc = split(buf, argv); // 根據空格分割為字串陣列
runcmd(argv, argc); // 解析並執行命令
}
wait(0); // 等待子程序退出
}
exit(0);
}
getcmd()
實現較簡單,基於 gets() 函式來接收標準輸入,直接參考sh.c即可。直接來看處理輸入命令的 split()
函式,作用是將接收到的命令根據空格分割為引數陣列,方便後續解析和執行。思路是直接在源字串上進行分割,將每個引數的首地址收集到指標陣列中,並在在末尾設定空字元"\0"進行擷取,最終獲得引數字串陣列。
int split(char * cmd, char ** argv) {
int i = 0, j = 0, len = 0;
len = strlen(cmd);
while (i < len && cmd[i]) {
while (i < len && strchr(whitespace, cmd[i])) { // 跳過空格部分
i++;
}
if (i < len) {
argv[j++] = cmd + i; // 將每個引數的開始位置放入指標陣列中
}
while (i < len && !strchr(whitespace, cmd[i])) { // 跳過字元部分
i++;
}
cmd[i++] = 0; // 在每個引數後的第一個空格處用'\0'進行截斷
}
argv[j] = 0; // 表示引數陣列的結束
return j; // 返回引數個數
}
接著來到runcmd()
方法,包含了對特殊符號的解析和命令的執行,引數處理思路如下:
- 管道:從左往右順序解析,找到 | 符號,對左右兩邊的命令分別建立子程序處理,連線標準檔案描述符,並遞迴進入
runcmd()
方法 - 重定向:遇到 < > 符號,關閉相應標準fd,開啟檔案
- 普通引數:放入引數陣列中,等待執行
void runcmd(char **argv, int argc) {
int i, j = 0;
char tok;
char *cmd[MAXARGS];
for (i = 0; i < argc; i++) {
if (strcmp(argv[i], "|") == 0) {
runpipe(argv, argc, i); // 處理pipe
return;
}
}
for (i = 0; i < argc; i++) {
tok = argv[i][0]; // 該引數的第一個字元
if (strchr("<>", tok)) {
if (i == argc-1) {
panic("missing file for redirection"); // 後面沒有檔案則報錯
}
runredir(tok, argv[i+1]); // 處理重定向
i++;
} else {
cmd[j++] = argv[i]; // 收集引數
}
}
cmd[j] = 0;
exec(cmd[0], cmd); // 執行命令
}
注:相比sh.c的實現,該解析方法的不足之處是沒有支援符號與下一個引數連在一起的情況,如 echo 123 >1.txt
或 echo 123 |grep 12
,不過測試用例中的引數都是以空格分割,所以這裡也就簡單處理了。
重定向實現
在介紹 pipe (管道) 和 redir (重定向) 的實現前需要先說明下檔案描述符(fd) 的概念,對於每一個開啟的檔案會在核心中對應建立一個file物件,並且核心會為每個程序維護一個指標陣列,儲存該程序的file物件的地址,而fd正是這個指標陣列的索引。所以引用的路徑是: fd -> 核心指標陣列 -> file物件 -> 磁碟檔案。
fd是一個順序增長的整型,每個程序預設會開啟3個fd,分別是標準輸入(0),標準輸出(1) 和 標準錯誤(2)。對fd有幾個常用的系統呼叫:
- close(int fd):關閉一個fd,對應核心陣列中的指標也會被移除,當檔案物件的引用計數為0時,該檔案才會被關閉
- dup(int fd):複製一個fd,核心陣列中會增加一個指標指向相同的檔案,新建立的fd的值為當前可用的最小的整數
- pipe(int * fd):對兩個fd建立管道,對其中一個fd進行寫資料,能從另一個fd讀出資料
重定向 是將程序的標準輸入/輸出 轉移到開啟的檔案上。實現思路是利用fd的順序增長的特性,使用close()
關閉標準I/O的fd,然後open()
開啟目標檔案,此時檔案的fd就會自動替換我們關閉的標準I/O的fd,也就實現了重定向。
void runredir(char tok, char * file) {
switch (tok) {
case '<':
close(0);
open(file, O_RDONLY);
break;
case '>':
close(1);
open(file, O_WRONLY|O_CREATE);
break;
default:
break;
}
}
管道實現
管道 是將左邊程序的標準輸出作為右邊程序的標準輸入。實現思路如下:
- 呼叫
pipe()
連線兩個fd,然後呼叫兩次fork() 分別建立兩個子程序,2個兄弟程序均繼承了由管道連線起來的fd。(注: 這裡呼叫2次fork是參考了sh.c
的實現,實際發現如果每次只調用1次fork(),由父程序作為左側輸入程序,子程序進行遞迴fork(),同樣能通過測試。) - 在子程序中
close()
關閉標準輸出fd,dup()
複製管道其中一端的fd,然後執行命令 - 父程序需要呼叫兩次
wait()
來等待兩個子程序結束
從實現思路上也可以看出,由於管道的實現依賴於子程序對fd的繼承,所以只能用於有親緣關係的程序間通訊。
void runpipe(char **argv, int argc, int index) { // index為|符號在陣列中的位置
int p[2];
pipe(p); // 建立管道
if (fork1() == 0) {
close(1); // 關閉標準輸出
dup(p[1]); // 複製管道中fd
close(p[0]);
close(p[1]);
runcmd(argv, index); // 遞迴執行左側命令
}
if (fork1() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(argv+index+1, argc-index-1); // 遞迴執行右側命令
}
// 關閉不需要的fd
close(p[0]);
close(p[1]);
// 等待兩個子程序結束
wait(0);
wait(0);
}
至此,基本功能就實現了。測試步驟如下:
- 在
Makefile
檔案的UPROGS
部分追加上$U/_nsh\
- 執行
make qemu
編譯進入xv6命令列,隨後我們可以直接執行指令碼:testsh nsh
來執行測試case, 也可以執行nsh
進入我們的shell進行手動除錯 - 最後可以在
xv6-riscv-fall1
根目錄下執行make grade
進行評分。
xv6中的shell實現
xv6中的shell實現在user/sh.c
中,大致思路和我們的nsh相似,都是實現了對使用者命令的迴圈讀取、解析、執行,不過支援的命令型別更多且涉及更復雜。
1.主體邏輯
sh.c將命令解析和命令執行獨立開來,首先遞迴地構造出結構化的命令樹,然後又遞迴地去遍歷樹中的命令並執行。且對每一種支援的命令都定義了結構體,包括 可執行命令(execcmd),重定向(redircmd),管道(pipecmd),順序命令(listcmd),後臺命令(backcmd),這些命令都"繼承"於一個基礎cmd結構:
struct cmd {
int type; // 命令型別
};
且對於每種命令都實現了"建構函式",使用malloc()
動態分配了結構體記憶體,並且強轉為 cmd 結構的指標返回,等到具體使用的時候,再根據type欄位中的型別,強轉回具體的型別進行使用。(指標指向結構體的首地址,根據宣告來訪問欄位,所以這裡的強轉不影響使用)。
這裡使用了面向物件的思想,藉助指標和型別強轉實現了類似於"多型"的效果。這裡的parsecmd()
方法則像一個"工廠",根據輸入的不同構造不同型別的命令,以基類形式統一返回,runcmd()
中再根據具體型別執行不同邏輯。
if (fork() == 0) {
// parsecmd返回cmd,runcmd接收cmd
runcmd(parsecmd(buf));
}
此種設計將解析和執行獨立開來,使得程式碼邏輯更加清晰,函式功能更單一;並且提升了可擴充套件性,如果後續有新的命令型別增加,只需要定義新的結構體,並編寫相應的解析和處理方法就可以支援,對其他型別的命令影響較小。
2.命令解析
命令的解析和結構化在parsecmd()
中實現,支援管道,重定向,多命令順序執行,後臺執行,括號組合等符號的解析。方法中大量使用了以下兩個精巧的工具函式:
-
peek(char **ps, char *es, char *toks)
:判斷字串*ps的第一個字元是否在字串toks中出現,es指向字串末尾,同時該方法會移除掉字串*ps 的字首空白字元。如 peek(ps, es, "<>") 則用於判斷當前字串的首字元是不是 "<>" 中的一個。 -
int gettoken(char **ps, char *es, char *q, char *eq)
:同樣傳入字串的開始(ps)和結束(es),每次呼叫該方法將會移除掉第一段空格及前面的內容,且傳入的 q 和 eq 指向的內容就是被移除的引數。如果函式移除的內容命中了指定符號"| < >"等,就會返回該符號,否則返回常量'a'。 比如對字串"cat < 1.txt" 執行gettoken(),那麼源字元將變為"< 1.txt",q和eq指向字串"cat"的首尾,並返回字元'a'。
parsecmd() 以pipeline的鏈式呼叫進行命令解析,順序為 parsecmd() -> parseline() -> parsepipe() -> parseexec() -> parseblock() -> parseredirs(),分別對不同型別的命令進行處理,從左往右不斷使用peek()函式判斷當前的符號,使用gettoken()獲取空格分割的引數,構造樹狀命令結構。與傳統樹結構不同的是,該命令樹的每個節點都可能是不同的型別,比如管道命令的left和right欄位都是cmd型別,但可能具體結構並不相同。
值得一提的是,解析完成後,還呼叫了nulterminate方法進行遞迴的引數擷取。我們最終執行的命令是execcmd型別,argv指標陣列即指向所有引數的首地址,同時為其維護了一個eargv指標陣列,取值於gettoken()返回的eq引數,指向引數列表中每個引數的末尾地址,nulterminate()則將所有eargv指向的末尾字元置為'\0',這樣便巧妙地在源字串中完成了引數的分割。
3.命令執行
runcmd()
命令執行方法遞迴遍歷整顆命令樹,根據cmd結構的type引數進行判斷,做出相應處理。其中EXEC、PIPE、REDIR這三種命令和我們的nsh實現相似,其餘的幾種命令則比較簡單:
- LIST:由分號 ; 分割的順序命令,實現方法是fork一個子程序執行左命令,wait等待其完成後再執行右命令,從而實現順序執行的效果;
- BACK:由 & 結尾的後臺命令,實現方法是fork一個子程序執行命令,父程序則直接退出。