手把手教你編寫一個具備基本功能的shell(已開源)

  剛接觸Linux時,對shell總有種神祕感;在對shell的工做原理有所瞭解以後,便嘗試着動手寫一個shell。下面是一個從最簡單的狀況開始,一步步完成一個模擬的shell(我命名之爲wshell)的過程。這個所謂的shell和主流的shell仍是有很多區別的,最大的區別是它自己不能執行shell腳本、也不能對一些複雜的命令行進行分析——緣由很簡單,我沒有寫相應的解釋器。若是想本身實現一個簡化的shell腳本解釋器,若是有編譯原理的知識準備,自己不是難事,可是工做量比較大,這裏就不完成了,有興趣的讀者能夠進行嘗試。html

  本文是邊寫代碼邊記錄的,更接近於實現過程的思考過程,所以前面的章節可能和最新版的代碼有不小的差異,較大的改動會在後文提出,請注意。不過讀者不用擔憂,這些改動都是在原有基礎上的完善和提高,並不是推倒重來。能夠算做上一篇博文《現代操做系統》精讀與思考筆記 第一章 引論的副產品。git

  所有的代碼開源,已託管至github:https://github.com/vvy/wshell,所以再也不往文中大段大段地粘源代碼了。第一次用github託管代碼,若是有哪裏沒設置好請告訴我。github

  文中所指的和所模仿的shell均指bash。正則表達式

 

一.基本功能

1.1 程序框架

  首先,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族函數能夠直接給程序傳參數。有了這個框架就好辦了,把那幾個函數給實現不就成了唄?編程

 

1.2 type_prompt()的實現

  來思考下type_prompt()該如何實現。顧名思義,這個要提供一個終端上的提示符,好比vim

  

再如數組

  

   這裏的實現須要注意的是,若是當前路徑在用戶路徑下,那麼用戶路徑就用~代替,不然會顯示完整路徑。分析這兩個例子,能夠看到輸出是這樣的形式:「用戶名@主機名:路徑$」(root權限的#提示符立刻提到),對應地:性能優化

  • 用戶名使用getpwuid(getuid())得到,同時能夠得到該用戶home目錄的路徑;
  • 主機名使用gethostname()得到;
  • 路徑使用getcwd()得到,若是這個路徑包含了該用戶home路徑,那麼使用~把home路徑縮略。
  • 對於提示符,模仿bash的風格,對於普通用戶使用"$",root用戶使用"#",須要檢測執行這個wshell的用戶權限,利用geteuid()是否爲0來判斷。

  這樣,就能夠着手編寫type_prompt()了。爲了以示和bash的區別,能夠在提示符里加點本身的東西,好比下圖第二行那樣: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及之前的提交。 

 

2、改善用戶體驗:內建命令、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提交的版本。

 

3、進階功能:後臺執行、輸入/輸出重定向、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、總結

4.1 和真實的shell相比,有什麼不足

  • 暗藏了很多bug是確定的,畢竟調試次數仍是不多;
  • 內建指令不全,只實現了最經常使用的cd,做爲示例,姑且算是足夠了吧;
  • 不能執行shell腳本、用戶命令分析模式單一,這都是沒有編寫完整解釋器的緣故。之前本科編譯原理實驗課的時候寫過還算完整的一個小語言的詞法分析和句法分析器,那時就寫的有點吐血。固然,正則表達式這樣高端的功能更是別想了。若是真寫起來shell的解釋器,代碼量絕對比上文中的shell多。這個shell只用parsing()來替代了這部分功能;
  • 異常處理機制不夠健全,只有少數的異常處理,而且對不正確的用戶命令也沒法處理,部分仍是由於解釋器,另外一部分是由於示例程序,我對它的健壯性就偷懶了很多;
  • shell機制沒模仿全,只有管道、重定向、後臺執行——若是加其餘功能,一樣是須要擴充解釋器的;
  • 命令行沒有歷史命令,這是readline庫的特性,我沒有加上;
  • 一些可能的性能優化沒有進行,由於主要目的是展現原理,關於性能沒有再作深刻思考。

 

4.2 收穫

  • 練習了Linux的一些API(主要是進程相關API)的使用,深刻了解或動手實現了一些機制
  • 再也不對shell感到神祕莫測:你也能夠實現一個嘛!
  • 第一次練習使用git/github
相關文章
相關標籤/搜索