剛接觸Linux時,對shell總有種神秘感;在對shell的工作原理有所了解之后,便嘗試着動手寫一個shell。下面是一個從最簡單的情況開始,一步步完成一個模擬的shell(我命名之為wshell)的過程。這個所謂的shell和主流的shell還是有不少區別的,最大的區別是它本身不能執行shell腳本、也不能對一些復雜的命令行進行分析——原因很簡單,我沒有寫相應的解釋器。如果想自己實現一個簡化的shell腳本解釋器,如果有編譯原理的知識准備,本身不是難事,但是工作量比較大,這里就不完成了,有興趣的讀者可以進行嘗試。
本文是邊寫代碼邊記錄的,更接近於實現過程的思考過程,因此前面的章節可能和最新版的代碼有不小的差別,較大的改動會在后文提出,請注意。不過讀者不用擔心,這些改動都是在原有基礎上的完善和提升,並非推倒重來。可以算作上一篇博文《現代操作系統》精讀與思考筆記 第一章 引論的副產品。
全部的代碼開源,已托管至github:https://github.com/vvy/wshell,因此不再往文中大段大段地粘源代碼了。第一次用github托管代碼,如果有哪里沒設置好請告訴我。
文中所指的和所模仿的shell均指bash。
一.基本功能
1.1 程序框架
首先,shell的基本框架可以用下面的代碼概括,這部分代碼出自於《現代操作系統(英文第三版)》(Modern Operating Systems)原書P54圖1-19,這在上一篇博文《現代操作系統》精讀與思考筆記 第一章 引論中已經提到過一次了:
#define TRUE 1 while(TRUE) { /* repeat forever */ type_prompt(); /* display prompt on the screen */ read_command(command,parameters); /* read input from terminal */ if(fork()!=0) { /* fork off child process */ /* Parent code */ waitpid(-1,&status,0); /* wait for child to exit */ } else { /* Child code */ execve(command,parameters,0); /* execute command */ } }
不怕寫不出來代碼,就怕沒思路。想想這么搞確實能夠模擬出shell最基本的行為:接受用戶輸入<=>執行相應程序,甚至借助execv族函數可以直接給程序傳參數。有了這個框架就好辦了,把那幾個函數給實現不就成了唄?
1.2 type_prompt()的實現
來思考下type_prompt()該如何實現。顧名思義,這個要提供一個終端上的提示符,比如

再如

這里的實現需要注意的是,如果當前路徑在用戶路徑下,那么用戶路徑就用~代替,否則會顯示完整路徑。分析這兩個例子,可以看到輸出是這樣的形式:“用戶名@主機名:路徑$”(root權限的#提示符馬上提到),對應地:
- 用戶名使用getpwuid(getuid())獲得,同時可以獲得該用戶home目錄的路徑;
- 主機名使用gethostname()獲得;
- 路徑使用getcwd()獲得,如果這個路徑包含了該用戶home路徑,那么使用~把home路徑縮略。
- 對於提示符,模仿bash的風格,對於普通用戶使用"$",root用戶使用"#",需要檢測執行這個wshell的用戶權限,利用geteuid()是否為0來判斷。
這樣,就可以着手編寫type_prompt()了。為了以示和bash的區別,可以在提示符里加點自己的東西,比如下圖第二行那樣:

注:查看默認shell版本的命令是echo $SHELL。
1.3 read_command()
在type_prompt()寫好之后,可以做一點簡單的測試,屏幕上會出現上一節最末的效果圖,乍一看還挺唬人的。不過此時還是徒有其表,尚且不能執行任何程序,難道就讓它在這里孤芳自賞?接下來需要實現read_command(),它從用戶輸入中讀取命令和參數,分別放入command[]和parameters[][]中,作為exec族函數執行。
最初的版本只是通過fgets()把整行輸入讀入一個較大的緩沖區中,再對這行進行分析,提取出命令以及參數,分別放到相應的位置。其實Linux本身接受的參數表總長度大小是有限的,這個限制由ARG_MAX給出。因此,這里的緩沖區也的大小用宏定義做一個硬性限制就行了。當然,fgets()有個壞處:如果輸入時想要使用退格鍵修改前面的輸入,是不能完成的,這和真實的shell相差有點大。不過這里暫不考慮這個問題,留在后面補充。
輸入的分析,其實就是字符串的處理,把一個字符串拆成多個字符串(命令、參數)並分別復制到由malloc()分配的空間中。最初版本的思路比較復雜,本文2.2提供了比較好的實現。
另外一點需要注意:實際上command保存的是路徑+命令,而命令本身按照慣例應該存在parameters[0]中。這一點在最初時沒有注意,后面用ls命令測試時發現了這一點。
1.4 選擇execve()還是execvp()
既然示例中的execve()的環境變量參數env恆為0,沒有使用的必要了。況且execvp()能夠直接執行ls這樣的命令而不用加上路徑,更接近於shell,於是選擇后者。
1.5 簡單測試
動手寫一個hello world的程序,然后用這個wshell運行。下面的輸出包含了一些分析輸入的調試信息:

再試試最初未把command中命令放入parameters[0]時不能運行的ls:

雖然和shell相比,沒有顏色區分,但已經可以正常運行了。這兩個測試表明,wshell已經初具shell的基本功能。
這個版本對應於github上10.31及以前的提交。
二、改善用戶體驗:內建命令、readline庫
2.1.內建命令(built-in command)
當完成基本功能、喜滋滋地在其中測試各種常用命令時,top、vim等都乖乖就范,唯獨cd沒有任何效果。本來以為cd只能改變子進程的工作目錄,而wshell是父進程,導致無效。然而輸入whereis cd來查看cd所在目錄,沒有顯示它的路徑,頓生疑惑:cd是怎么實現的?看到stackoverflow上一個問答,解釋了這個疑惑:像cd這樣的命令實際並非可執行程序,(如果想在自己編寫的shell里使用)需要自己來實現為內建命令。那么,對於這種命令,肯定是不能exec()了,需要進行分析和額外處理。而且可以看出,它的執行並不需要建立子進程。
這個分析和處理過程,實際上應該是解釋器的一部分功能,當然這里比較簡化,只是針對特定的命令進行處理罷了。這個過程由buildin_command()完成,並且不創建子進程。因此,主進程相應地添加
if(buildin_command(command,parameters)) continue;
接下來實現幾個內建命令。最簡單的是exit和quit,直接調用exit()結束wshell主進程即可。
順便編寫一個about命令,這是我自己添加的,shell本身是沒有這個命令的,它會顯示一些關於wshell的簡短信息。
接下來是cd的實現了。對於以下幾種使用方法,使用chdir()就可以直接完成對應的操作:
cd
cd PATHNAME
cd .
cd ..
但是對於cd ~以及cd ~/PATHNAME就不行了。對於這種情況,可以發現這個路徑的特點是以“~”開始,那么利用type_prompt()中的獲取工作目錄的方式,重新拼接出完整路徑再作為參數進行傳遞即可。為了提高效率,把type_prompt()中獲取的信息做成是全局的,這樣實現cd時可以直接調用。
chdir()的出錯處理也從簡了,直接把strerror的內容顯示在屏幕上。如果想建立一個比較完善的錯誤處理機制,可以參考《UNIX網絡編程(卷一)》(UNPv1)的附錄D.3。
甚至可以發現,shell本身似乎也是用對"~"路徑補全的方式來實現的,這可以通過cd一個不存在的目錄所表現的行為發現:

這一天發現最初的版本沒有對分配的內存進行回收,可能導致內存泄漏。打算重寫這一部分代碼,使其更接近於Linux內部實現。
2.2 readline庫的使用以及read_command()的重寫
在1.3節提到,read_command()的行為和真實的shell命令輸入不一樣,后者是基於readline庫實現的。讓wshell也是用這個庫,就可以做出同樣的行為了。正好之前發現了原先的read_command()處理command和parameter兩個參數時沒有釋放,會導致內存泄漏,這里重寫一下。為了便於理解,下圖是前后二者的區別:

使用后者,不必每次為command和parameter[][]分配空間,只需要一個足夠大(也就是ARG_MAX大小)的buffer即可,不必操心內存分配的問題了。同時,由於后者中參數的定位全部是由指針完成,在添加更多的功能(后文的重定向、pipe)也會更加方便。這種實現我不確定是否為bash的實現,但看上去更接近於“所有參數總長度限制為ARG_MAX”的設定。改寫之后,代碼也比之前精簡不少。
回到本節正題上來,看看readline庫是怎么使用的。
首先,這個庫是需要安裝的,我所使用的Ubuntu10.04上默認並沒有安裝這個庫。執行下面語句進行安裝:
sudo apt-get install libreadline5-dev
同時為了便於以后調試的方便,同時提供了兩個版本的read_command(),使用READLINE_ON來控制編譯時是否選擇使用了readline庫的版本,並在對應的makefile中加上-D READLINE_ON -I /usr/include -lreadline -ltermcap。
直接使用
buffer = readline(NULL);
這時,似乎已經很接近shell的用戶體驗了。但是使用退格鍵消除所有字符后,發現居然連着提示符也消失了。看來,type_prompt()也需要重寫了:把完整的提示符字符串作為參數傳遞給readline()。
這樣之后,就能模仿shell的輸入體驗,甚至可以進行命令補全和路徑補全。不過想實現歷史命令還是不行,可以參考使用readline庫實現應用程序下的仿終端輸入模式等。
這一節內容完成后,對應於github上11.1提交的版本。
三、進階功能:后台執行、輸入/輸出重定向、pipe
3.1 准備工作
有了前面的經驗,這些功能看上去無非也就是利用一些Linux的庫函數、系統調用等API完成的嘛。不過麻煩的地方在於,如何從用戶輸入中判斷使用哪一種或哪幾種功能?這似乎又繞不過句法分析這一步,因此繼續簡化設計,首先把一個合法用戶輸入規定為下面的形式:
command1 [[parameter1_1] ... [parameter1_n]] [<</< file1] [>>/> file2] [| command2 [parameter2_1] ... [parameter2_n]] [&]
並作出規定:
1.一個正確輸入只能為上面的形式,一共可以有20個單元,長度為MAXLINE大小,非法輸入的執行結果是未定義的;
2.當輸出重定向>>/>和管道符|同時出現時,command1的輸出只會重定向至file2,這樣之后才執行command2;
3.無論是否出現command2,"&"只對command1有效,且必須與前一個可選項中有一個空格(bash可以直接使用"ls&"這樣的命令,但在這里只能寫成"ls &")
這樣,就可以專注於處理合法輸入的情況了。當然,一個健壯的解釋器肯定是需要處理異常輸入的。
對於輸入的句法分析結果,使用一個結構體來進行保存,以便接下來的使用。這個結構體如下:
struct parse_info { int flag; //表明使用了哪些功能的標志位 char* in_file; //輸入重定向的文件名 char* out_file; //輸出重定向的文件名 char* command2; //命令2 char** parameter2; //命令2的參數表 };
編寫句法分析函數parsing()來填充這個結構體,以備后續使用。
以下各節的實現(API的選取)參考了《UNIX操作系統設計》7.8節 shell部分,主干如下,在理解了1.1節介紹的《現代操作系統》上的shell框架之后,下面無非在這個框架里面加了點東西而已。不過這個框架似乎不適合我原先寫的代碼,需要進行調整。
/*read command line until EOF*/ while(read(stdin,buffer,numchars)) { /*parse command line*/ if(/* command line contains & */) amper = 1; else amper = 0; /* for commands not part of the shell command language */ if(fork() == 0) { /* redirection of IO?*/ if(/* redirect output */) { fd = creat(newfile,fmask); close(stdout); dup(fd); close(fd); /* stdout is now redirected */ } if(/* piping */) { pipe(fildes); if(fork() == 0) { /* first component of command line */ close(stdout); dup(fildes[1]); close(fildes[1]); close(fildes[0]); /* stdout now goes to pipe */ /* child process does command */ execlp(command1,command1,0); } /* 2nd command component of command line*/ close(stdin); dup(fildes[0]); close(fildes[0]); close(fildes[1]); /* standard input now comes from pipe */ } execve(command2,command2,0); } /* parent continues over here ... /* waits for child to exit if required */ if(amper == 0) retid = wait(&status); }
3.2 后台運行
這個比較簡單,讓父進程不等待子進程退出而直接讀入用戶的下一步操作即可,不執行wait()。為了進一步模擬shell,可以把子進程ID顯示出來。
(2014.4.14更新)
注意,對於后台運行的子進程,如果父進程提前退出了,自然會成為init進程的孩子;而如果這些子進程在父進程退出前退出,又沒有對應的waitpid()進行回收,就會成為僵屍進程。使用signal()處理SIGCHLD可以解決這個問題,並且由於Linux的信號是不排隊的,需要將所有的已結束的子進程進行回收。
但是,僅僅增加一個信號處理函數,對於前台運行的進程,waitpid()阻塞過程是否會失效?為了讓這兩種waitpid()不相互干擾,把后台運行進程的pid放入一個專門的數組中,信號處理函數只對這一類進程進行處理。對於不是后台運行的子進程,在信號處理函數什么也不做就返回后,使用指定了其pid的waitpid()處理。
3.3 輸入/輸出重定向
因為重定向只適用於用戶輸入中的command1,一旦判斷出有command2的存在,就應該着手分離二者了,否則會導致二者的輸入/輸出都被重定向到同樣的文件上去。
不過要注意,雖然'>'和'>>'都是輸出重定向,前者會覆蓋原有文件內容,而后者是在文件尾部增加,需要進行簡單的分別對待。(12.8更新)
先利用flag的IS_PIPED標志位判斷是否需要為command2創建新進程,內容暫略,先利用dup2()把重定向功能寫好,下面只寫出了輸入重定向,輸出重定向是類似的:
if(info.flag & IS_PIPED) //command2 is not null { if(fork() == 0)//command2 { //pipe //execvp(info.command2,info.parameters2); } } int in_fd,out_fd; if(info.flag & IN_REDIRECT) { in_fd = open(info.in_file, O_CREAT |O_RDONLY, 0666); close(fileno(stdin)); dup2(in_fd, fileno(stdin)); close(in_fd); } if(info.flag & OUT_REDIRECT) { out_fd = open(info.out_file, O_CREAT|O_RDWR, 0666); close(fileno(stdout)); dup2(out_fd, fileno(stdout)); close(out_fd); } execvp(command,parameters);
3.4 管道
直接使用pipe()就可以了,管道的寫法沒什么特別,不過對於同時使用了輸出重定向和管道的command1,需要把它的管道關閉,這樣就會給command2發送一個EOF。
3.5 模仿,再模仿……
使用系統自帶的wc,並隨便編寫個1.txt,測試目前的版本吧。

看上去怎么就那么別扭呢?對比一下,下圖是真實的shell的行為:

這個問題的原因我嘗試了很久才想明白:第二個wc是第一個wc的子進程,而wshell最多只等待第一個wc,不等后一個進程結束就顯示下一行提示符了!
首先想到兩種解決辦法:
(1)wait()/waitpid()。但發現Linux本身的wait()/waitpid()函數不能處理子進程的子進程,同時command1在執行時就被替換成了wc,不能讓它執行wait()/waitpid();
(2)改變command2的父進程ppid使其為wshell的pid。但沒有查到可以完成的API。
因此,只好根據(1)的思路進行修改,為了能使用wait()/waitpid(),唯一的方法是讓command1和command2都是wshell的子進程了。這樣修改需要改變一部分已有邏輯關系,不過為了追求高仿,還是進行了。
修改完再試試:

嗯,還不錯,這樣修改后,甚至可以為command2也配置出"&"了。
這一節對應於github上11.3提交的版本。
四、總結
4.1 和真實的shell相比,有什么不足
- 暗藏了不少bug是肯定的,畢竟調試次數還是很少;
- 內建指令不全,只實現了最常用的cd,作為示例,姑且算是足夠了吧;
- 不能執行shell腳本、用戶命令分析模式單一,這都是沒有編寫完整解釋器的緣故。以前本科編譯原理實驗課的時候寫過還算完整的一個小語言的詞法分析和句法分析器,那時就寫的有點吐血。當然,正則表達式這樣高端的功能更是別想了。如果真寫起來shell的解釋器,代碼量絕對比上文中的shell多。這個shell只用parsing()來替代了這部分功能;
- 異常處理機制不夠健全,只有少數的異常處理,並且對不正確的用戶命令也無法處理,部分還是因為解釋器,另一部分是因為示例程序,我對它的健壯性就偷懶了不少;
- shell機制沒模仿全,只有管道、重定向、后台執行——如果加其他功能,同樣是需要擴充解釋器的;
- 命令行沒有歷史命令,這是readline庫的特性,我沒有加上;
- 一些可能的性能優化沒有進行,因為主要目的是展示原理,關於性能沒有再做深入思考。
4.2 收獲
- 練習了Linux的一些API(主要是進程相關API)的使用,深入了解或動手實現了一些機制
- 不再對shell感到神秘莫測:你也可以實現一個嘛!
- 第一次練習使用git/github
