編寫自己的Shell解釋器摘要:本期的目的是向大家介紹shell的概念和基本原理,并且在此基礎上動手做一個簡單shell解釋器。同時,還將就用到的一些 linux環(huán)境編程的知識做一定講解。 本文適合的讀者對象 對linux環(huán)境上的c語言開發(fā)有一定經(jīng)驗; 對linux環(huán)境編程(比如進程、管道)有一點了解。 概述本章的目的是帶大家了解shell的基本原理,并且自己動手做一個shell解釋器。為此, 首先,我們解釋什么是shell解釋器。 其次,我們要大致了解shell解釋器具有哪些功能; 最后,我們具體講解如何實現(xiàn)一個簡單的 shell 解釋器,并對需要用到一些 linux環(huán)境編程的知識做一定講解,并提醒你如果想深入掌握,應該去看哪些資料。
Shell解釋器是什么?Shell解釋器是一個程序。對,是一個程序,而且,它就在我們的身邊。在linux系統(tǒng)中,當我們輸入用戶名和密碼登陸之后,我們就開始執(zhí)行一個shell解釋器程序,通常是 /bin/bash,當然也可以是別的,比如/bin/sh。(詳細概念請看第一期中的shell有關部分) 提示:在 /etc/passwd 文件中,每個用戶對應的最后一項,就指定了該用戶登陸之后,要執(zhí)行的shell解釋器程序。 在 linux 字符界面下,輸入 man bash 調出 bash 的幫助頁面 幫助的最開始就對bash下了一個定義: bash 是一個兼容于 sh 的命令語言解釋器,它從標準輸入或者文件中讀取命令并執(zhí)行。它的意圖是實現(xiàn) IEEE POSIX標準中對 shell和工具所規(guī)范的內(nèi)容。 Shell解釋器的作用在登陸 linux 系統(tǒng)之后,屏幕上就會出現(xiàn)一行提示符,在我的機器上,是這樣的: [root@stevens root]#
這行提示符就是由bash解釋器打印出來的,這說明,現(xiàn)在已經(jīng)處于 bash 的控制之下了,也同時提示用戶,可以輸入命令。用戶輸入命令,并回車確認后,bash分析用戶的命令,如果用戶的命令格式正確,那么bash就按照用戶的意思去做一些事情。 比如,用戶輸入: [root@stevens root]# echo “hello, world” 那么,bash就負責在屏幕上打印一行“hello world”。 如果,用戶輸入: [root@stevens root]# cd /tmp 那么,bash就把用戶的當前目錄改變?yōu)?/span> /tmp。 所以,shell解釋器的作用就是對用戶輸入的命令進行“解釋”,有了它,用戶才可以在 linux 系統(tǒng)中任意揮灑。沒有它的幫助,你縱然十八般本領在身,也施展不出。 bash每次在“解釋”完用戶命令之后,又打印出一行提示符,然后繼續(xù)等待用戶的下一個命令。這種循環(huán)式的設計,使得用戶可以始終處于 bash 的控制之下。除非你輸入 exit、logout明確表示要退出 bash。 Shell語法梗概我們不停的命令 bash 做這做那,一般情況下它都很聽話,按你的吩咐去做。可有時候,它會對你說:“嗨,老兄,你的命令我理解不了,無法執(zhí)行”。例如,你輸入這樣的命令: [root@stevesn root]# aaaaaa bash會告訴你: bash: aaaaaa: command not found 是的,你必須說的讓它能聽懂,否則它就給你這么一句抱怨,當然也還會有其它的牢騷。 那么,什么樣格式的命令,它才能正確理解執(zhí)行了?這就要引出shell 的語言規(guī)范了。 Shell作為一個命令語言解釋器,有一套自己的語言規(guī)范,凡是符合這個規(guī)范的命令,它就可以正確執(zhí)行,否則就會報錯。這個語言規(guī)范是在 IEEE POSIX的第二部分:“shell和tools規(guī)范”中定義的。關于這份規(guī)范,可以在這里看到。 官方的東西,總是冗長而且晦澀,因為它要做到面面俱到且不能有破綻。如果讀者有興趣,可以仔細研究這份規(guī)范。而我們的目的只是理解shell的實現(xiàn)思想,然后去實現(xiàn)一個簡單的 shell 解釋器,所以沒必要陷入枯燥的概念之中。 現(xiàn)在請繼續(xù)在 linux 字符界面下輸入 man bash,調出 bash 的幫助頁面,然后找到 “shell語法”那一部分,我們就是以這里的描述作為實現(xiàn)的依據(jù)。 在 bash幫助的“shell 語法”一節(jié),是這樣來定義shell 語法的: l 簡單命令 簡單命令是(可選的)一系列變量賦值, 緊接著是空白字符分隔的詞和重定向符號, 最后以一個控制操作符結束. 第一個詞指明了要執(zhí)行的命令, 它被作為第 0 個參數(shù). 其余詞被作為這個命令的參數(shù). 這個定義可以這樣來理解: 1、 可以有變量賦值,例如 a=10 b=20 export a b 2、 “詞”是以空白字符分隔開的,空白字符包括制表符(tab)和空格,例如: ls /tmp 就是兩個詞,一個 ls,一個 /tmp 3、可以出現(xiàn)重定向符號,重定向符號是“>”和“<”,例如: echo “hello world” > /tmp/log 4、 簡單命令結束于控制操作符,控制操作符包括: || & && ; ;; ( ) | <newline> 例如,用戶輸入: ls /tmp 用戶最后敲的回車鍵就是控制操作符 newline,表示要結束這個簡單命令。 如果用戶輸入: echo “ 那么這是兩個簡單命令,第一個結束于“;”,第二個結束于newline。 5、 簡單命令的第一個詞是要執(zhí)行的命令,其余的詞都是這個命令的參數(shù),例如: echo “hello world” echo 第一個echo 是命令,第二個詞“hello world”是參數(shù)1,第三個詞 echo 是參數(shù)2,而不再作為一個命令了。 簡單命令是 shell 語法中最小的命令,通過簡單命令的組合,又可以得到管道命令和列表命令。 l 管道(命令) 管道是一個或多個簡單命令的序列,兩個簡單命令之間通過管道符號(“|”)來分隔 例如 echo “hello world” | wc –l 就是一個管道,它由兩個簡單命令組成,兩個簡單命令之間用管道符號分隔開。 我們可以看到,管道符號“|”也是屬于上面提到的控制操作符。 根據(jù)這個定義,一個簡單命令也同時是一個管道。 管道的作用是把它前面的那個簡單命令的輸出作為后面那個簡單命令的輸入,就上面這個例子來說: echo “hello world” 本來是要在標準輸出(屏幕)上打印 “hello world” 的,但是管道現(xiàn)在不讓結果輸出到屏幕上,而是“流”到 wc –l 這個簡單命令,wc –l 就把“流”過來的數(shù)據(jù)作為它的標準輸入進行計算,從而統(tǒng)計出結果是 1 行。 關于管道更詳細的內(nèi)容,我們在后面具體實現(xiàn)管道的時候再說明。 l 列表(命令): 列表是一個或多個管道組成的序列,兩個管道之間用操作符 ;, &, &&, 或 || 分隔。我們看到,這幾個操作符都屬于控制操作符。 例如 echo “hello world” | wc –l ; echo “nice to meet you” 就是一個列表,它由兩個管道組成,管道之間用分號(;)隔開 分號這種控制操作符僅僅表示一種執(zhí)行上的先后順序。 l 復合命令 這個定義比較復雜,實現(xiàn)起來也有相當難度,在咱們這個示例程序中,就不實現(xiàn)了。 以上是 shell 語法規(guī)范的定義,我們的 shell 程序就是要以此規(guī)范為依據(jù),實現(xiàn)對簡單命令、管道和列表的解釋。對于列表中的控制操作符,我們只支持分號(;),其它的留給讀者自己來實現(xiàn)。 接下來,我們具體介紹如何實現(xiàn)一個簡單的 shell解釋器。 實現(xiàn)shell實例程序主框架主程序很簡單,它在做一些必要的初始化工作之后,進入這樣一個循環(huán): u 打印提示符并等待用戶輸入 u 獲取用戶輸入 u 分析用戶輸入 u 解釋執(zhí)行; 如果用戶輸入 logout或者 exit 之后,才退出這個循環(huán)。 用類似偽代碼的形式表示如下: while(1) { print_prompt(); get_input(); parse_input(); if(“l(fā)ogout” || “exit”) break; do_cmd(); } 讀取用戶輸入如何獲取用戶輸入?一種方法是通過 getchar() 從標準輸入每次讀一個字符,如果讀到的字符是 ‘/n’,說明用戶鍵入了回車鍵,那么就把此前讀到的字符串作為用戶輸入的命令。 代碼如下: int len = 0; int ch; char buf[300]; ch = getchar(); while(len < BUFSIZ && ch != '/n') { buf[len++] = ch; ch = getchar(); } if(len == BUFSIZ) { printf("command is too long/n"); break; } buf[len] = '/n'; len++; buf[len] = 0; 但是,我們注意到,在 bash 中,可以用“<-”和“->”鍵在命令行中左右移動,可以用上下鍵調用以前使用的命令,可以用退格鍵來刪除一個字符,還可以用 tab 鍵來進行命令行補全。我們的shell如果也要支持這些功能,那么就必須對這些鍵進行處理。這樣僅僅對用戶輸入的讀取就非常麻煩了。 實際上,任何需要一個獲取用戶輸入的程序,都會涉及到同樣的問題,如何象bash 那樣處理鍵盤?GNU readline 庫就是專門解決這個問題的,它把對鍵盤的操作完全封裝起來,對外只提供一個簡單的調用接口。有了它,對鍵盤的處理就不再讓人頭疼了。 關于 readline 庫的詳細信息,可以通過 man readline 來看它的幫助頁面。在我們的 shell 程序中,我是這樣來使用 readline的。 char* line; char prompt[200]; while(1) { set_prompt(prompt); if(!(line = readline(prompt))) break; 。。。。。。 } 首先通過 set_prompt() 來設置要輸出的提示符,然后以提示符作為參數(shù)調用 readline(),這個函數(shù)等待用戶輸入,并動態(tài)創(chuàng)建一塊內(nèi)存來保存用戶輸入的數(shù)據(jù),可以通過返回的指針 line 得到這塊內(nèi)存。在每次處理完用戶輸入的命令之后,我們必須自己負責來釋放這塊內(nèi)存。 有了 readline 之后,我們就可以象 bash 那樣使用鍵盤了。 在通過 readline 獲取用戶輸入之后,下一步就是對用戶輸入的命令進行分析。 命令行分析
對命令行的分析,實際上是一個詞法分析過程。學過編譯原理的朋友,都聽說過 lex 和yacc 的大名,它們分別是詞法分析和語法分析工具。Lex 和 yacc 都有GNU的版本(open source 的思想實在是太偉大了,什么好東東都有免費的用),分別是 flex 和 bison。 所謂“工欲善其事,必先利其器”,既然有這么好的工具,那我們就不必辛辛苦苦自己進行詞法分析了。對,我們要用 lex 來完成枯燥的命令行詞法分析工作。 “去買本《lex與yacc》(中國電力出版社)來看吧。第一次學當然稍微有點難度,不過一旦掌握了,以后再碰到類似問題,就可以多一個利器,可以節(jié)省勞動力了。 在我們的這個 shell 程序中,用 flex 來完成詞法分析工作。相對語法分析來說,詞法分析要簡單的多。由于我們只是做一個簡單的 shell,因此并沒有用到語法分析,而實際上在 bash 的實現(xiàn)代碼中,就用到了語法分析和 yacc。 關于 lex 的細節(jié),在這里我就不能多說了。Lex程序,通常分為三個部分,其中進行語法分析工作的就是它的第二部分: “規(guī)則”。規(guī)則定義了在詞法分析過程中,遇到什么樣的情況,應該如何處理。 詞法分析的思路,就是根據(jù)前面定義的“shell語法規(guī)范”來把用戶輸入的命令行拆解成 首先,我們要把用戶輸入的命令,以空白字符(tab鍵或者空格)分隔成一個個的參數(shù),并把這些參數(shù)保存到一個參數(shù)數(shù)組中。但是,這其中有幾種特殊情況。 一、如果遇到的字符是“;”、“>”、“<”或“|”,由于這些符號是管道或者列表中所用到的分隔符,因此必須把它們當作一個單獨的參數(shù)。 二、以雙引號(”)括起來的字符串要作為一個單獨的參數(shù),即使其中出現(xiàn)了空白字符、“;”、“>”、“<”、“|”。其實,在POSIX標準中,對引號的處理相當復雜,不僅包括雙引號(”),還有單引號(’)、反引號(`),在什么情況下,應該用什么樣的引號以及對引號中的字符串應該如何解釋,都有一大堆的條款。我們這里只是處理一種極簡單的情況。 其次,如果我們遇到換行符(’/n’),那么就結束本次命令行分析。根據(jù)前面定義的 shell 語法規(guī)范,最上層的是列表命令,因此下一步是把所有的參數(shù)作為一個列表命令來處理。 根據(jù)這個思路,我們來看對應的 lex 規(guī)則。
我們對這些規(guī)則逐條解釋: 1-4這4條規(guī)則,目的是為了在命令行中支持引號,它們用到了 lex 規(guī)則的狀態(tài)特性。 1、"/"" {BEGIN QUOTE;} 2、<QUOTE>[^/n"]+ {add_arg(yytext);} 3、<QUOTE>"/"" {BEGIN 0;} 4、<QUOTE>/n {BEGIN 0; do_list_cmd(); reset_args();} 1、 如果掃描到引號( “),那么進入 QUOTE 狀態(tài)。在這個狀態(tài)下,即使掃描到空白字符或“;”、“>”、“<”、“|”,也要當作普通的字符。 2、 如果處于 QUOTE狀態(tài),掃描到除引號和回車以外的字符,那么調用 add_arg()函數(shù),把整個字符串加入到參數(shù)數(shù)組中。 3、 如果處于QUOTE狀態(tài),掃描到引號,那么表示匹配了前面的引號,于是恢復到默認狀態(tài)。 4、 如果處于QUOTE狀態(tài),掃描到回車,那么結束了本次掃描,恢復到默認狀態(tài),并執(zhí)行 do_list_cmd(),來執(zhí)行對列表命令的處理。 以下幾條規(guī)則,是在處于默認狀態(tài)的情況下的處理。 5、";" {add_simple_arg(yytext);} 6、">" {add_simple_arg(yytext);} 7、"<" {add_simple_arg(yytext);} 8、"|" {add_simple_arg(yytext);} 9、[^ /t/n|<>;"]+ {add_arg(yytext);} 10、/n {do_list_cmd(); reset_args();} 5、 如果遇到分號(;),因為這是一個列表命令結束的操作符,所以作為一個單獨的參數(shù),執(zhí)行 add_simple_arg(),將它加入?yún)?shù)數(shù)組。 6、 如果遇到 >,因為這是一個簡單命令結束的操作符,所以作為一個單獨的參數(shù),執(zhí)行 add_simple_arg(),將它加入?yún)?shù)數(shù)組。 7、 如果遇到 <,因為這是一個簡單命令結束的操作符,所以作為一個單獨的參數(shù),執(zhí)行 add_simple_arg(),將它加入?yún)?shù)數(shù)組。 8、 如果遇到管道符號(|),因為這是一個管道命令結束的操作符,所以作為一個單獨的參數(shù),執(zhí)行 add_simple_arg(),將它加入?yún)?shù)數(shù)組。 9、 對于不是制表符(tab)、換行符(’/n’)、| 、<、>和分號(;)以外的字符序列,作為一個普通的參數(shù),加入?yún)?shù)數(shù)組。 10、 如果遇到換行符,那么結束本次掃描,執(zhí)行 do_list_cmd(),來執(zhí)行對列表命令的處理。 11、 對于任意其它字符,忽略 通過 lex 的“規(guī)則”把用戶輸入的命令行分解成一個個的參數(shù)之后,都要執(zhí)行 do_list_cmd() 來執(zhí)行對列表命令的處理。 命令處理首先是對處于“shell語法規(guī)范”中最上層的列表命令的處理。 l 列表命令的處理過程: 依次檢查參數(shù)數(shù)組中的每一個參數(shù),如果是分號(;),那么就認為分號前面的所有參數(shù)組成了一個管道命令,調用 do_pipe_cmd() 來執(zhí)行對管道命令的處理。如果掃描到最后,不再有分號出現(xiàn),那么把剩下的所有參數(shù)作為一個管道命令處理。 代碼很簡單:
接下來是對管道命令的處理。 管道命令的處理管道是進程間通信(IPC)的一種形式,關于管道的詳細解釋在《unix高級環(huán)境編程》第14章:進程間通信以及《unix網(wǎng)絡編程:第2卷:進程間通信》第4章:管道和FIFO中可以看到。 我們還是來看一個管道的例子: [root@stevens root]# echo “hello world”|wc –c |wc –l 在這個例子中,有三個簡單命令和兩個管道。 第一個命令是 echo “hello world”,它在屏幕上輸出 hello world。由于它后面是一個管道,因此,它并不在屏幕上輸出結果,而是把它的輸出重定向到管道的寫入端。 第二個命令是 wc –c,它本來需要指定輸入源,由于它前面是一個管道,因此它就從這個管道的讀出端讀數(shù)據(jù)。也就是說讀到的是 hello world,wc –c 是統(tǒng)計讀到的字符數(shù),結果應該是12。由于它后面又出現(xiàn)一個管道,因此這個結果不能輸出到屏幕上,而是重定向到第二個管道的寫入端。 第三個命令是 wc –l。它同樣從第二個管道的讀出端讀數(shù)據(jù),讀到的是12,然后它統(tǒng)計讀到了幾行數(shù)據(jù),結果是1行,于是在屏幕上輸出的最終結果是1。 在這個例子中,第一個命令只有一個“后”管道,第三個命令只有一個“前”管道,而第二個命令既有“前”管道,又有“后”管道。 在我們處理管道命令的do_pipe_cmd()函數(shù)中,它的處理過程是: 首先定義兩個管道 prefd 和 postfd,它們分別用來保存“前”管道和“后”管道。此外,還有一個變量 prepipe 來指示“前”管道是否有效。 然后依次檢查參數(shù)數(shù)組中每一個參數(shù),如果是管道符號(|),那么就認為管道符號前面所有的參數(shù)組成了一個簡單命令,并創(chuàng)建一個“后”管道。如果沒有“前”管道(管道中第一個簡單命令是沒有“前”管道的),那么只傳遞“后”管道來調用do_simple_cmd(),否則,同時傳遞“前”管道和“后”管道來調用 do_simple_cmd()。 執(zhí)行完以后,用“前”管道來保存當前的“后”管道,并設置“前”管道有效標識prepipe,繼續(xù)往后掃描。如果掃描到最后,不再有管道符號出現(xiàn),那么只傳遞“前”管道來調用do_simple_cmd()。 代碼如下:
最后,我們分析簡單命令的處理過程。 簡單命令處理過程我們已經(jīng)看到,對列表命令和管道命令的處理,實際只是一個分解過程,最終命令的執(zhí)行還是要由簡單命令來完成。 在簡單命令的處理過程中,必須考慮以下情況: 1、區(qū)分內(nèi)部命令和外部命令 根據(jù)簡單命令的定義,它的第一個參數(shù)是要執(zhí)行的命令,后面的參數(shù)作為該命令的參數(shù)。要執(zhí)行的命令有兩種情況: 一種是外部命令,也就是對應著磁盤上的某個程序,例如 wc、ls等等。對這種外部命令,我們首先要到指定的路徑下找到它,然后再執(zhí)行它。 二是內(nèi)部命令,內(nèi)部命令并不對應磁盤上的程序,例如cd、echo等等,它需要shell自己來決定該如何執(zhí)行。例如對 cd 命令,shell就應該根據(jù)它后面的參數(shù)改變當前路徑。 對于外部命令,需要創(chuàng)建一個子進程來執(zhí)行它,而對于內(nèi)部命令,則沒有這個必要。 外部命令的執(zhí)行,是通過 exec 函數(shù)來完成的。有六種不同形式的 exec 函數(shù),它們可以統(tǒng)稱為 exec 函數(shù)。我們使用的是 execv()。關于 exec的細節(jié),請看《unix環(huán)境高級編程》第8章:進程控制。 對于內(nèi)部命令,我們目前支持五種,分別是: exit:退出shell解釋器 cd:改變目錄 echo:回顯 export:導入或顯示環(huán)境變量 history:顯示命令歷史信息 這幾個內(nèi)部命令分別由 do_exit()、do_cd()、do_echo()、do_export()、do_history()來實現(xiàn)。 2、處理重定向 在簡單命令的定義中,包括了對重定向的支持。重定向有多種情況,最簡單的是輸入重定向和輸出重定向,分別對應著“<”和“>”。 輸入重定向,就是把“<”后面指定的文件作為標準輸入,例如: wc < xxx 表示把 xxx 這個文件的內(nèi)容作為 wc 命令的輸入。 輸出重定向,就是把“>”后面指定的文件作為標準輸出,例如: echo “hello world” > xxx 表示把 echo “hello world” 的結果輸入到 xxx 文件中,而不是屏幕上。 為了支持重定向,我們首先對簡單命令的參數(shù)進行掃描,如果遇到“<”或者“>”那么就認為遇到了重定向,并把“<”或者“>”符號后面的參數(shù)作為重定向的文件名稱。 對于輸入重定向,首先是以只讀方式打開“<”后面的文件,并獲得文件描述符,然后將該文件描述符復制給標準輸入。 對于輸出重定向,首先是以寫方式打開“>”后面的文件,并獲得文件描述符,然后將該文件描述符復制給標準輸出。 具體實現(xiàn)在 predo_for_redirect() 函數(shù)中: 3、管道的實現(xiàn) 管道的實現(xiàn)實際上也是一種重定向的處理。對于“前”管道,類似于輸入重定向,不同的是,它是把一個指定的描述符(“前”管道的輸出端)復制給標準輸入。對于“后”管道,類似于輸出重定向,不同的是,它把一個指定的描述符(“后”管道的輸入端)復制給標準輸出。 在對管道的處理上,還必須要注意管道和輸入或輸出重定向同時出現(xiàn)的情況,如果是一個“前”管道和一個輸入重定向同時出現(xiàn),那么優(yōu)先處理輸入重定向,不再從“前”管道中讀取數(shù)據(jù)了。同樣,如果一個“后”管道和一個輸出重定向同時出現(xiàn),那么優(yōu)先處理輸出重定向,不再把數(shù)據(jù)輸出到“后”管道中。 至此,我們已經(jīng)描述了實現(xiàn)一個簡單的 shell 解釋器的全部過程,相應的代碼和 makefile 在我們的網(wǎng)站上可以下載。希望大家能夠結合代碼和這篇文章,親自動手做一次,以加深對shell 解釋器的理解。 實驗代碼可以下載 msh |
|