myShell:Linux Shell 的簡單實現

這是之前寫的博客,那時因爲內容涉及學校正在上的課程的作業,經同學提醒,刪除了,現在恢復,供有需要的同學參考。

實驗目的

綜合利用進程控制的相關知識,結合對shell功能的和進程間通信手段的認知,編寫簡易shell程序,加深操作系統的進程控制和shell接口的認識。

實驗內容

  • 可以使用Linux或其它Unix類操作系統;
  • 全面實踐進程控制、進程間通信的手段;
  • 編寫簡易shell程序。
    1. 嘗試自行設計一個C語言小程序,完成最基本的shell角色:給出命令行提示符、能夠逐次接受命令;對於命令分成三種,內部命令(例如help命令、exit命令等)、外部命令(常見的ls、cp等,以及其他磁盤上的可執行程序HelloWrold等)以及無效命令(不是上述三種命令)。
    2. 參考“綜合預備(3)”中的4.1.1小節內容將上述shell進行擴展,使得你編寫的shell程序具有支持管道的功能,也就是說你的shell中輸入“dir || more”能夠執行dir命令並將其輸出通過管道將其輸入傳送給more作爲標準輸入。
    3. 可以將步驟1和2直接合並完成。
    4. 設計標準的參考。1)提示符最低標準是固定字符串。提升標準是使用含當前路徑的信息爲提示符。2)接受命令的最低標準是一次接受一個命令就推出shell程序,提升標準是在shell內部循環讀取和執行命令。

實驗步驟與結果

shell的基本功能分析

Shell是用戶和系統內核之間的一個接口程序,shell可以較好地保護系統內核免受非法操作的破壞,也能讓用戶能方便地完成自己的任務。它是一個命令解釋command-language interpreter,我們提交的每條命令都會通過shell的解釋,然後再提交給內核執行。
圖1:啓動終端
當我們如圖1一樣,啓動一個終端時,會打印出“用戶@主機:~$”的提示符,這是由bash打印的,bash是shell的一種,我們可以手動指定自己要使用的shell。

shell包含內部命令和外部命令,比如打印目錄的pwd命令就是一個內部命令,而壓縮文件時用的tar命令則是一個外部命令。對於用戶來講,內部命令和外部命令使用起來並沒有差別,當然我們也並不關心這條命令究竟是外部命令還是內部命令。

對於Shell來說,當用戶需要執行一條命令時,它會先看看這條命令是否爲內部命令,如果不是,再去環境變量$PATH中去找一下這是不是一個可執行的程序,如果都不是,shell會返回錯誤,提示沒找到這條命令:
圖2:未找到命令

圖3:shell內部命令
圖3展示了幾個shell內部命令,echo用來指定的字符串;cd 用來切換目錄;help用來輸出幫助信息。

shell外部命令包括linux提供的一些使用的命令,比如ls,grep等,和我們自行安裝的一些軟件。

預計實現的功能

  • 命令提示符:如圖1一樣,輸出命令提示符(並設置對應的顏色);
  • 常用外部命令:ls,cp,cat;
  • 可執行程序運行支持:允許通過./helloworld 這樣的方式來執行可執行程序
  • 常用內部命令:help,echo,cd,exit
  • 無效命令提示:如圖2一樣提示未找到命令

設計及實現過程

shell我們一直都在用,其實很容易直觀地想到它的運行機制就是:
圖4:shell運行流程
按照圖4的運行流程,我們可以設計出我們自己的shell程序的大致框架:

while(TRUE){
print_prompt();
get_command();
if command valid
deal_command();
else
    print_error();
}

圖5:Shell 基本框架(Modern Operating Systems)

圖5中的shell框架來自《現代操作系統》一書,和我們剛開始自己想的框架沒有太大差別,說明我們的大體思路是沒有問題的。只是我們的框架太“抽象”了些,沒有多少指導意義,而這裏則比較清楚地展示了shell執行中需要fork命令子進程,以及調用exec族函數來執行命令等細節。

圖6:echo SHELL
圖6展示了我們正在使用的shell版本,本實驗中模仿的shell將以bash爲標準。

根據搜索的資料,爲了達到較好的命令交互效果,我們需要使用一個開源的庫,realine。
從官網下載源碼,解壓後進入readline所在目錄,使用./configure進行必要的配置,然後make,再make install,我們就完成了readline庫的配置工作。

命令提示符:

如圖1一樣,輸出命令提示符(並設置對應的顏色);

此部分關鍵的函數爲void print_prompt(),它用來輸出命令提示符。
圖7:命令提示符分析
如圖7所示,典型的命令提示符應該包含圖中的6個元素:
1:用戶名;
2:“@”符號;
3:主機名;
4:“:”符號
5:當前目錄
6:用戶權限符號(ubuntu系統下,在需要執行管理員權限命令時,使用sudo 來提供管理員權限,通常此提示符號均是$,並不如其他linux系統一樣,用“#”來表示管理員)
圖8:定義輸出顏色
如圖8,爲了達到打印不同顏色的字體的要求,我們定義一些基本的顏色。

此部分比較關鍵的幾個函數爲:
gethostname(),獲取主機名;
getcwd(),獲取當前目錄;
getpwuid(),根據用戶id獲取用戶信息;
getuid(),獲取用戶id;
要打印不同顏色字體時,使用類似下面的打印語句即可:
printf(L_GREEN“hello world\n”);
可以看到只需要在要打印的字符串前面加上我們之前定義顏色的宏即可。

爲了和真正的命令提示符看起來更像些,我們還需要要做一件工作:當用戶處在自己的家目錄下時,用“~”來替代“/home/*”,這個路徑。這裏需要用到字符串處理的一些手段。使用函數strtok()可以很方便地完成字符串的工作,它具有兩個參數,第一個爲要處理的字符串,第二個爲分割字符,第一次調用這個函數時,需要指定要分割的字符,之後調用時,將此參數設置爲“NULL”以繼續完成分割。
圖9:家目錄處理
我們嘗試運行一下,查看一下效果:
圖10:命令提示符部分效果
圖10中目錄爲紅色(爲了避免調試時和原本的shell提示符混淆)的命令提示符爲myShell打印出來,看起來似乎很正常。

上面的命令提示符使我們直接使用printf打印的,還不是真正的“命令提示符”,爲了使用到我們之前提到的readline庫,我們需要對prompt再進行一些處理。

使用字符串格式化函數sprintf來將命令提示符中的幾部分(目錄,主機名等)合到一個字符串中,於是我們有如下的代碼

sprintf(prompt,”\e[1;32m%s@%s\e[0m:\e[1;31m%s\e[0m%c”,pwp->pw_name,hostname,cwd,super);

其中\e[1;32m爲顏色的控制信息。readline.h頭文件中readline函數表示讀入一行字符串,它包含一個char *prompt參數,傳入的prompt將會作爲命令提示符來處理。

當用到這個函數時,編譯時,需要再鏈接一個庫文件, -lncurses,否則會出錯。有可能會出現無法找到libreadline.so.7這樣的錯誤,我們需要去ld.so.conf中添加一行,/usr/local/lib(libreadline.so.7所在的位置)。最後,爲了不用每次都敲長長的編譯命令,我們可以嘗試寫個makefile來幫助我們完成這項工作。
圖11:makefile
需要注意的是,兩個庫readline,ncurses的鏈接順序,不可以調換。

解決完這些問題,我們終於可以使用readline()了。
圖12:錯誤的prompt
從圖12的結果來看,確實輸出了不同顏色的字符,但是,我們的命令提示符完全被打亂了,而且光標跑到一個錯誤的位置(應該在命令提示符末尾)。

很自然的想法是去readline源碼中看看出了什麼問題。
readline
根據搜索資料的提示,我們找到readline.h如上圖的宏定義,上面的宏定義的意思是,prompt中的轉義用\001***\002這樣的方式來括起來。
修改我們的prompt,把顏色部分全部用這樣的方式:
\001\e[1;32m\002
來表示,修改完後,make一下,再次執行myShell。
圖13:不同目錄下的命令提示符效果
可以從圖13中看到,命令提示符顯示正常。把myShell放到不同目錄下測試一下,暫時沒有太大的問題了,可以開始下一步工作。

用戶命令分析

通過readline(),我們可以獲得用戶輸入的字符串,根據圖5,Shell的基本框架,我們接着要做的就是對用戶輸入的字符串進行分析。

首先回想我們自己使用shell時的場景:
圖14:用戶輸入命令的一些可能情況
從圖14可以看到,用戶通常輸入命令的一般格式爲:
命令 參數選項

在myShell中我們如果只是爲了簡單的達到執行命令的效果,可以使用system(“command”)函數,但是這個函數不太安全,很容易出錯,所以用戶命令分析還是有必要的,通過分析輸入的命令,拿到命令和參數,然後調用exec函數來執行它。

此部分的關鍵函數爲analysis_command()它用來分析用戶輸入的命令,並將其進行拆分。

由readline()獲得用戶輸入的字符串,使用之前提到過的strtok函數進行進行分割。
圖15:分割用戶命令字符串
這裏用的分割字符爲空格符,將分割後的字符串保存到argv中,這裏用argv[0]保存命令名稱,argv[1]開始爲命令的參數。

分割完成字符後,可以進行一些基本命令的處理,比如exit(退出shell),help(打印幫助信息)等。
圖16:基本內部命令exit,help的處理

圖17:cd命令的的處理
可以從圖17看到,展示瞭如何處理cd命令。需要注意的是,cd命令只能爲myShell的內部命令,也就是說,不能fork子進程去執行“cd”命令。因爲fork子進程執行完cd後,再返回父進程,當前目錄會變爲父進程的目錄,達不到切換路徑的效果。切換路徑需要使用函數chdir()。

測試一下此部分的運行情況:
圖18:命令分析
從圖18結果可以看出,命令分析看起來沒有太大的問題。cd命令也能正常地工作。不過,外部命令能否利用我們分析的命令正常工作還爲未可知,我們接着下一步的實驗。

命令處理

此部分的關鍵是“exec”函數的使用,當然,如果要實現管道重定向等擴展功能,還需要對進程同步和通信等知識有一定了解。

普通命令

事實上,Linux中並沒有一個名爲“exec”的函數,而是六個以exec開頭的函數族,它們是:

頭文件 #include<unistd.h>
函數原型 int execl(const char *path, const char *arg, …)
int execv(const char *path, char *const argv[])
int execle(const char *path, const char *arg, …, char *const envp[])
int execve(const char *path, char *const argv[], char *const envp[])
int execlp(const char *file, const char *arg, …)
int execvp(const char *file, char *const argv[])
返回值 成功:不返回
失敗:返回-1

表中前四個函數以完整的文件路徑進行文件查找,後兩個以p結尾的函數,可以直接給出文件名,由系統從$PATH中指定的路徑進行查找。這裏不同的函數後綴,代表着的含義是:

後綴 含義
l 接收以逗號分隔的參數列表,列表以NULL指針作爲結束標誌
v 接收到一個以NULL結尾的字符串數組的指針
p 是一個以NULL結尾的字符串數組指針,函數可以通過$PATH變量查找文件
e 函數傳遞指定參數envp,允許改變子進程的環境,無後綴e時,子進程使用當前程序的環境

值得注意的是:這六個函數中真正的系統調用只有execve(),其他的都是庫函數,它們最終都會調用到execve();exec函數常常會因爲找不到文件,或者沒有對應文件的運行權限等原因而執行失敗,所以,在使用是最好加上錯誤判斷語句。

這裏爲了簡單些,直接使用execvp(),然後我們需要傳入待執行命令,待執行命令參數作爲該函數的參數。需要注意的是,根據execvp函數的要求,這裏的待執行命令的參數必須以NULL結尾,而且這個參數的第0個字符串爲待執行命令,第一個字符串爲待執行命令的第一個參數….
圖19:執行命令
圖19展示了myShell的命令執行情況,可以看到,內部命令,外部命令和可執行文件均正常執行,不過還存在一些bug,這裏沒有展示出來,將在調試與結果部分對此進行說明和處理。

管道支持

接着是實現對管道的支持。
圖20:shell管道命令
圖20展示了shell中管道命令的使用,“|”前的表示第一條執行的命令,它的輸出結果將通過管道傳送給第二條命令,作爲第二條命令的參數。

我們可以簡單地使用無名管道(僅能在具有親緣關係的進程間使用)來在myShell中實現對這種管道命令的支持。這部分的設計僞代碼如下(備註:該僞代碼有誤,具體在調試與結果部分進行說明):

BEGIN:
pipe(fd);
pid = fork();
if(pid < 0)
 print(“ERROR”);
else if(pid == 0)
 close(fd[0]);//close read
 dup2(fd[1],stdout);//duplicate the file handle
 exec(command1,parameters);
else if(pid > 0)
 close(fd[1]);//close write
 dup2(fd[0],stdin);
 exec(command2.parameters);
 wait(NULL);
END

圖21:dup2函數
dup2函數用來複制文件描述符。在子進程(執行第一條命令)中,我們複製stdout描述符到管道寫端,表示子進程將輸出它的結果,類似地在父進程(執行第二條命令)(注:此處有誤,不應該在父進程,而應該在另外一個子進程中執行),讀端的描述符現在變爲stdin,它用來接收第一條命令的結果。如此我們達到了通過管道在第一條命令和第二條命令間通信的目的。

需要說明的是,具體實現中,命令的保存問題。管道命令不能當成一條命令來執行,所以,在命令分析階段通過判斷“|”的存在來將命令分割成兩部分,然後再分別在父子進程中調用execvp來執行。
圖22:myShell管道命令
圖22展示了myShell的運行效果,可以看到“ls | grep my”這條命令正常執行,但是執行完這條命令後,myShell就退出了(注意到命令提示符變爲真正的shell的藍色),這可不是我們想要的結果,應該是在進程處理部分出了bug,將在調試與結果部分對此進行說明和處理。

重定向支持

最後是重定向,重定向包含輸入和輸出重定向等,這裏僅簡單實現輸出重定向。

首先是命令的分析,和管道命令類似,掃描一下整個命令字符串,看看有沒有輸出重定向符號“>”,如果有,則把它當做一個重定向命令,重定向符號前面部分爲命令,後面部分爲重定向的目標(保存在另一個字符串redirect_target中),重定向的關鍵函數爲freopen(),它的原型爲:
FILE *freopen( const char *path, const char *mode, FILE *stream )

此部分設計的僞代碼爲:

BEGIN:
freopen(redirect_target,”w”,stdout);//redirect stdout to redirect_target
execvp(redirect_command,parameters);
fclose(stdout);
END

圖23:重定向命令運行效果
圖23展示了重定向命令的運行效果。我們執行ls命令並將結果重定向到data.txt文件中,然後使用cat命令查看data.txt的內容,可以看出,重定向命令工作基本正常。

調試與結果

上一部分說明了myShell如何進行命令分析,內部命令處理,外部命令處理以及如何實現管道和重定向支持,但沒有對程序中一些bug以及本實驗用到的第三方庫readline的使用問題等進行具體的說明,在此部分將對已經發現的bug進行處理,並總結本次實驗中的要點。

  • 首先要解決的便是圖22中所示的myShell異常退出的問題:
    分析一下之前的管道命令處理的僞代碼,我們發現,管道的第二條命令被我們放到父進程(myShell所在的進程)執行,當這條命令執行完正常退出時,我們的父進程也跟着“正常退出”了,而這不是我們想要的結果。所以,我們應該將第二條命令同樣放到一個子進程中去完成,這樣才能達到執行完這條命令後myShell依然運行的效果。

    根據分析,得到如下的僞代碼:

BEGIN:
pipe(fd);
pid1 = fork();
if(pid1 < 0)
 print(“ERROR”);
else if(pid1 == 0)
 close(fd[0]);//close read
 dup2(fd[1],stdout);//duplicate the file handle
 exec(command1,parameters);
else if(pid1 > 0)
 waitpid(pid1);
        pid2 = fork();
        if(pid2 < 0)
           printf(“ERROR”);
        else if(pid2 == 0)
          close(fd[1]);//close write
          dup2(fd[0],stdin);
          exec(command2.parameters);
else
close(fd);
            waitpid(pid2);
END

修改此部分實驗代碼,再次執行。
圖24:修改後的管道命名
從圖24可以看出,myShell正常執行了管道命令“ls -l | grep my”,輸出了正確的結果,並且沒有出現圖22中的異常退出問題。

  • 其他程序bug:
    myShell程序還有許多的bug,這是因爲沒有像真的shell一樣考慮各種可能的情況,做到面面俱到。比如當用戶輸入“ls -l|grep my”這樣的命令時,會出現問題,因爲myShell以單個空格爲命令分割符號,直接使用“|grep”會導致命令無法正確分析,從而出錯。再比如真實的shell中(Ubuntu16.04系統),普通用戶也可以切換到根目錄下,而myShell切換到用戶主目錄外的就會出現core dumped的錯誤。
  • readline庫使用時會出現些問題,這裏進行總結
    myShell因爲使用了第三方庫,readline,使得用戶輸入命令行的交互得到了極大的改善,可以光標移動修改命令,可以tab補全命令,支持歷史命令等。不過這個庫的安裝使用可能會遇到一些問題,這裏進行總結。

    本次實驗中使用的readline版本爲7.0。推薦從官網下載源碼進行編譯安裝。下載readline源碼後,解壓,然後進入readline所在目錄,先configure,再make,然後make install,和其他庫的源碼安裝步驟差不多。

    在我們的C程序中,包含
    #include <readline/readline.h>
    #include <readline/history.h>
    這兩個頭文件即可,第一個頭文件提供的readline()可以獲取用戶輸入,第二個頭文件是我們實現歷史命令時需要的。

    使用時:
    if(!(line = readline(prompt)))
    其中prompt爲我們的命令提示符字符串,如果在這個prompt中使用了轉義的話,最好用/001***/002這樣的方式把這部分括起來,否則可能出錯。

    編譯時使用gcc myShell.c -o myShell -lreadline -lncurses這樣的方式,ncurses爲readline依賴的一個底層輸入輸出的庫,所以要放在readline的後面。

    此時還可能會出現無法法找到libreadline.so.7這樣的錯誤,我們需要去ld.so.conf中添加一行,/usr/local/lib(libreadline.so.7所在的位置),然後使用ldconfig來使我們添加的信息生效。

  • 最後是myShell的運行展示
    比較有意思的是,myShell可以被當做一個真的shell(它實際上只是我們寫的一個C程序),我們可以在myShell中執行“./myShell”這個可執行程序,這個可執行程序會再產生一個myShell,我們可以在這個myShell中再執行“./myShell”…我們會得到類似圖25的進程關係。當我們多次執行./myShell的時候,有時候可能我們自己都記不清我們到底處在哪一層myShell中了,因爲看起來它們都像是“真的”。有點類似電影《盜夢空間》或者是《異次元駭客》中的多層空間。事實上,多線程多進程的使用有時候確實會使我們有種迷惑的感覺。

圖25:myShell help命令

以上就是myShell的全部實現過程。

reference

編寫自己的Shell解釋器
手把手教你編寫一個具有基本功能的shell(已開源)
Linux shell的實現——execvp

實驗源碼

實驗源碼已上傳至Github
myShell

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章