剛接觸Linux時,對shell總有種神祕感;在對shell的工做原理有所瞭解以後,便嘗試着動手寫一個shell。下面是一個從最簡單的狀況開始,一步步完成一個模擬的shell(我命名之爲wshell)的過程。這個所謂的shell和主流的shell仍是有很多區別的,最大的區別是它自己不能執行shell腳本、也不能對一些複雜的命令行進行分析——緣由很簡單,我沒有寫相應的解釋器。若是想本身實現一個簡化的shell腳本解釋器,若是有編譯原理的知識準備,自己不是難事,可是工做量比較大,這裏就不完成了,有興趣的讀者能夠進行嘗試。html
本文是邊寫代碼邊記錄的,更接近於實現過程的思考過程,所以前面的章節可能和最新版的代碼有不小的差異,較大的改動會在後文提出,請注意。不過讀者不用擔憂,這些改動都是在原有基礎上的完善和提高,並不是推倒重來。能夠算做上一篇博文《現代操做系統》精讀與思考筆記 第一章 引論的副產品。git
所有的代碼開源,已託管至github:https://github.com/vvy/wshell,所以再也不往文中大段大段地粘源代碼了。第一次用github託管代碼,若是有哪裏沒設置好請告訴我。github
文中所指的和所模仿的shell均指bash。正則表達式
首先,shell的基本框架能夠用下面的代碼歸納,這部分代碼出自於《現代操做系統(英文第三版)》(Modern Operating Systems)原書P54圖1-19,這在上一篇博文《現代操做系統》精讀與思考筆記 第一章 引論中已經提到過一次了:shell
#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族函數能夠直接給程序傳參數。有了這個框架就好辦了,把那幾個函數給實現不就成了唄?編程
來思考下type_prompt()該如何實現。顧名思義,這個要提供一個終端上的提示符,好比vim
再如數組
這裏的實現須要注意的是,若是當前路徑在用戶路徑下,那麼用戶路徑就用~代替,不然會顯示完整路徑。分析這兩個例子,能夠看到輸出是這樣的形式:「用戶名@主機名:路徑$」(root權限的#提示符立刻提到),對應地:性能優化
這樣,就能夠着手編寫type_prompt()了。爲了以示和bash的區別,能夠在提示符里加點本身的東西,好比下圖第二行那樣:bash
注:查看默認shell版本的命令是echo $SHELL。
在type_prompt()寫好以後,能夠作一點簡單的測試,屏幕上會出現上一節最末的效果圖,乍一看還挺唬人的。不過此時仍是徒有其表,尚且不能執行任何程序,難道就讓它在這裏孤芳自賞?接下來須要實現read_command(),它從用戶輸入中讀取命令和參數,分別放入command[]和parameters[][]中,做爲exec族函數執行。
最初的版本只是經過fgets()把整行輸入讀入一個較大的緩衝區中,再對這行進行分析,提取出命令以及參數,分別放到相應的位置。其實Linux自己接受的參數表總長度大小是有限的,這個限制由ARG_MAX給出。所以,這裏的緩衝區也的大小用宏定義作一個硬性限制就好了。固然,fgets()有個壞處:若是輸入時想要使用退格鍵修改前面的輸入,是不能完成的,這和真實的shell相差有點大。不過這裏暫不考慮這個問題,留在後面補充。
輸入的分析,其實就是字符串的處理,把一個字符串拆成多個字符串(命令、參數)並分別複製到由malloc()分配的空間中。最第一版本的思路比較複雜,本文2.2提供了比較好的實現。
另一點須要注意:實際上command保存的是路徑+命令,而命令自己按照慣例應該存在parameters[0]中。這一點在最初時沒有注意,後面用ls命令測試時發現了這一點。
既然示例中的execve()的環境變量參數env恆爲0,沒有使用的必要了。何況execvp()可以直接執行ls這樣的命令而不用加上路徑,更接近於shell,因而選擇後者。
動手寫一個hello world的程序,而後用這個wshell運行。下面的輸出包含了一些分析輸入的調試信息:
再試試最初未把command中命令放入parameters[0]時不能運行的ls:
雖然和shell相比,沒有顏色區分,但已經能夠正常運行了。這兩個測試代表,wshell已經初具shell的基本功能。
這個版本對應於github上10.31及之前的提交。
當完成基本功能、喜滋滋地在其中測試各類經常使用命令時,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內部實現。
在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提交的版本。
有了前面的經驗,這些功能看上去無非也就是利用一些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); }
這個比較簡單,讓父進程不等待子進程退出而直接讀入用戶的下一步操做便可,不執行wait()。爲了進一步模擬shell,能夠把子進程ID顯示出來。
(2014.4.14更新)
注意,對於後臺運行的子進程,若是父進程提早退出了,天然會成爲init進程的孩子;而若是這些子進程在父進程退出前退出,又沒有對應的waitpid()進行回收,就會成爲殭屍進程。使用signal()處理SIGCHLD能夠解決這個問題,而且因爲Linux的信號是不排隊的,須要將全部的已結束的子進程進行回收。
可是,僅僅增長一個信號處理函數,對於前臺運行的進程,waitpid()阻塞過程是否會失效?爲了讓這兩種waitpid()不相互干擾,把後臺運行進程的pid放入一個專門的數組中,信號處理函數只對這一類進程進行處理。對於不是後臺運行的子進程,在信號處理函數什麼也不作就返回後,使用指定了其pid的waitpid()處理。
由於重定向只適用於用戶輸入中的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);
直接使用pipe()就能夠了,管道的寫法沒什麼特別,不過對於同時使用了輸出重定向和管道的command1,須要把它的管道關閉,這樣就會給command2發送一個EOF。
使用系統自帶的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提交的版本。