Unix考古記:一個「遺失」的shell

謹以此文記念偉大的計算機科學巨匠Ken ThompsonDennis Ritchie,並同時向其餘全部爲Unix發展作出貢獻的黑客致敬。html

歷史的塵埃

Unix做爲一個聞名中外的操做系統已有40餘年的歷史,圍繞着這個古老的操做系統的發展又衍生出了一系列外圍軟件生態羣,其中一個很是重要的組件就是shell。它是操做系統最外層的接口,負責直接面向用戶交互並提供內核服務,包括命令行接口(CLI)或圖形界面接口(GUI)兩種形式。以CLI爲例,它提供一套命令規範,是一種解釋性語言,將用戶輸入通過解釋器(interpreter)輸出使其轉化成真正的系統調用,實現人機交互的功能。前端

和操做系統同樣,shell也經歷了一個漫長的演變史。現在大部分資料講述最古老的shell都是從1977年的Bourne Shell提及的,它最初移植到Unix V7上,被追認整個shell家族成員的鼻祖,後來的種羣都是從其身上分支出來的。linux

Linux shells since 1977

對於1977年以前的歷史不少資料大多一筆帶過或略過不提。事實上,第一個移植到Unix上的shell卻不是Steve Bourne寫的,早在1975年5月,貝爾實驗室就對外發布了第一個普遍傳播的Unix版本——Unix V6(以前開發的版本只供內部研究之用),其根目錄下的/bin/sh是第一個Unix自帶的shell,由Ken Thompson寫的,所以也被稱爲Thompson Shell。甚至,更早能夠追溯到1971年的時候,Thompson Shell就做爲一個獨立於內核的應用程序而實現了,只不過從1975年正式問世到1977年被取代,短短兩年的壽命使得它不多爲大多數人所認識。程序員

關於Thompson Shell被取代的緣由在後文中會給出說明,這裏着重介紹一下該shell自己的一些技術細節。坦白講,關於Thompson Shell的資料有點稀缺,但至少還能從網上找到源代碼在線文檔。Thompson Shell自己是由一個不足900行代碼的解釋器和一些外部命令工具組件(utilities)構成,用K&R C寫成,下面給出各個組件的相關源碼和文檔連接。正則表達式

  • 解釋器sh:解析各類shell命令,包括內置命令和外部命令;源碼sh.c;安裝路徑/bin/sh;手冊sh(1)

下面是外部命令:算法

  • exit命令:退出一個文件;源碼exit.c;安裝路徑/bin/exit;手冊exit(1)
  • goto命令:在一個文件內跳轉shell控制流程;源碼goto.c;安裝路徑/bin/goto;手冊goto(1)
  • if命令:條件判斷表達式,是test命令的前身;源碼if.c;安裝路徑/bin/if), 手冊if(1)
  • glob命令:擴展命令參數通配符;源碼glob.c;安裝路徑/etc/glob;手冊glob(8)

命令結構和規範

儘管後來遭「埋汰」,Thompson Shell仍有着不容否定的歷史地位,其最大的價值在於它奠基了shell命令語言結構和規範的基礎,並且其解釋器具備跨平臺的可移植性,並影響到了後來包括Bourne Shell在內的各類腳本語言設計實現。下面咱們就以其中5個特性重溫一些你們已經耳熟能詳的命令規範,你也能夠經過sh(1)手冊查看原始資料。shell

  • 過濾器/管道線(filter/pipeline)。這絕對是要載入Unix史冊的發明,創立者是Douglas McIlroy,Thompson Shell引入並實現了這個偉大的概念——一個或多個命令組成一根過濾器的鏈條,由’|’或’^’符號分隔。除最後一個命令以外,每一個命令的標準輸出都被做爲下一個命令的標準輸入。這樣每一個命令都做爲一個獨立的進程來運行,並經過管道與鄰近的進程相鏈接。圓括弧內的命令序列總體上能夠替代單個命令做爲過濾器實現,好比用戶能夠輸入」(A;B)|C」。
  • 命令序列和後臺進程。分號’;’指示多個命令序列化執行。’&’符號指示該命令在後臺異步執行,使得前面的管道線沒必要等待其終止,僅僅報告一個進程id,這樣用戶之後能夠經過kill命令與它通訊。有益於進程管理。
  • I/O重定向。它利用了Unix設計上的一個重要特性——一切皆文件,用三個符號表示:」重定向輸出,若是文件不存在則建立它,若是文件存在則截斷它;’>>’追加模式重定向輸出,若是文件不存在則建立它,若是文件存在則追加輸出至末尾處。
  • 通配符擴展(globbing)。通配符的概念源自於正則表達式,使得解釋器智能地處理用戶不徹底輸入,好比記不清文件名、一次性輸入多個文件等。’?’匹配任意單一字符;’*’匹配任意字符串(包括空串);成對'[‘和’]’定義了字符集合一個類,可匹配方括號內任意成員,用’-‘兩端可指定一系列連續字符匹配範圍。
  • 參數傳遞。這裏主要引入了位置參數和選項參數的概念:’$n’指示shell調用的第n個參數替代;還定義了兩個選項參數’-t’和’-c’,前者用於交互,致使shell從標準輸入中讀入一行做爲用戶執行的系統命令,後者指示shell將附帶的下一個參數做爲命令執行(可正確處理換行符),是對’-t’的補充,特別是調用者已經讀取了命令其中某些字符的狀況下。若是不帶選項參數則直接讀取文件名

解釋器的原理與實現

接下來立刻要進入核心部分了,爲了搞懂shell解釋器原理,咱們要對其整個工做流程作個描述(這裏給出一份帶註解的sh.c源碼剖析)。讀過《編譯原理》的同窗知道,解釋器的實現跟編譯器差很少,只不過省略了生成目標代碼這一步,直接將用戶輸入(shell命令)轉化成輸出(系統調用)。軟件前端是一致的,包括預處理、詞法掃描、語法分析和語義分析,最後還要附加一個進程管理。固然相較於現代編譯器,Thompson Shell解釋器在算法和規模上都要簡單得多,不過原理上是相通的,況且年代上要比Lex & Yacc還要早。麻雀雖小,五臟俱全,對於初學者來講,從Thompson Shell去入手編譯原理或許不失爲一種好選擇。express

預處理(preprocessor)

同C預處理器須要事先將源代碼中包含的宏和頭文件展開同樣,Thompson Shell首先須要處理命令中的選項參數位置參數。選項參數有兩種’-t’和’-c’,決定了shell從標準輸入仍是參數緩存中讀取字符(見sh(1))。此外字符序列中還要處理反斜槓’\’,判斷是轉義字符仍是行接續符,前者對下一個字符設置引用標識,代表作普通字符處理,後者將緊鄰其後換行符過濾掉。編程

位置參數是美圓符號’$’打頭的,後帶一個數字,如’$n’,預處理器對shell命令參數從頭開始計數,返回數字n指定的參數位置。若是趕上double’$$’,則表示當前的進程標識,調用getpid()獲取。數組

注意到預處理器須要一次讀取多個字符,這樣就會多讀一個沒必要要的字符。對此解釋器提供了一種預讀(peek)方式,即每次從輸入流讀取一個字符時,放入一個預讀緩存裏(只有一個int大小的堆棧),也叫回退(push back)。此後先從預讀緩存中讀取,若是緩存被讀完,則從輸入流中讀取。

詞法掃描(lexical scanning)

通過預處理後的字符序列將被切割成爲一系列詞法記號(token),安置在token列表中,掃描器將對如下幾類字符作以下處理。

  • 空格和tab:簡單過濾。
  • 引號:須要成對出現,字符自己被過濾,一對引號之間全部字符都被設置引用標識,做爲一個token。
  • 元字符:如’&’,’|’等,字符自己做爲一個單獨token。
  • 其餘字符:一概填充token,直到碰上以上字符分隔爲止。

舉一個例子,當咱們輸入命令」(ls; cat tail) >junk」,那麼token列表映像將是這樣的:

語法分析(syntax parser)

語法分析就是將token列表中的元素做爲表達式(expression)並以節點爲單位構建語法樹,簡單命令是一個表達式,而複合命令以及命令序列是多個表達式的組合。Thompson Shell中以簡單數組做爲語法樹的容器,實際上這是結構體的一種變形,只不過每一個成員字段大小都同樣(都是sizeof int)而已。一個語法樹節點最多有6個字段(大小根據類型可變),分別是

  • DTYP(節點類型):每一個節點都有惟一的類型,又分爲四種——TCOM(簡單命令)、TPAR(複合命令)、TFIL(過濾器/管道線)、TLST(命令序列)。
  • DLEF(左子樹節點):至關於鏈表指針,根據DTYP定義有所不一樣。如過濾器類型左子樹節點爲前一個命令的輸出重定向文件,右子樹節點爲後一個命令的輸入重定向文件。
  • DRIG(右子樹節點):同上。
  • DFLG(節點屬性):這是個標誌位(flag),決定該節點包含命令的屬性以及以什麼樣的狀態執行。
  • DSPR(子命令):兩重含義,對於簡單命令,該字段爲空;對於複合命令,該字段指向子語法樹節點。
  • DCOM(命令字符):引用命令字符序列。

語法樹節點生成順序根據token列表中每一個元素的優先級(priority)而定,首先遍歷整個列表,找到優先級最高的token做爲根節點,再分別生成左右子樹,這是一種最簡單的自頂向下(top-down)解決方案。各個token優先級視DTYP字段而定

優先級

Token

DTYP

第一級

‘&’  ‘;’  ‘\n’

TLST

第二級

‘|’  ‘^’

TFIL

第三級

 ‘(‘  ‘)’

TPAR

第四級

其它字符

TCOM

語法樹的構建過程當中還使用了一種基於「有限狀態機(finite-state machine)」的動態規劃算法,其實現是將整個邏輯流程劃分爲四個狀態:syntax、syn一、syn二、syn3,對應於上面token優先級,程序在每一個狀態下都生成一個相應類型的節點,同時還生成四種策略,以決議下一步將轉移到何種狀態(根據優先級搜索對應的token)。這個四種策略分別是

  • 生成左子樹:左邊token列表遞進到下層狀態。
  • 生成右子樹:右邊token列表並回溯到上層狀態或遞歸調用。
  • 找不到對應token:保持原有token列表遞進到下層狀態。
  • 生成節點:直接返回節點。

當咱們遍歷完整個token列表後,程序老是能返回最初的調用點,即根節點上,從而生成一棵完整的語法樹。這種算法的好處是程序員沒必要關注具體實現的每一個細枝末節,只要關注相應的狀態並制定對應的轉移策略便可。還值得一提的是每一個轉移策略都是發生在賦值語句或返回語句上,並使用函數實參保存臨時變量,這樣就避免了調用次數過多致使堆棧溢出。

依舊舉兩個個例子,好比命令」A & ; B | C」對應的語法樹

命令」(A ; B) | C」對應的語法樹:

語義分析(Semantic Analyzer)

語法分析僅僅停留在token表達式合法性層面上,它並不知道該表達式是否有意義,好比哪些命令是要後臺運行,哪些命令的I/O被重定向到管道線上,通配符該如何擴展等等,這時候要靠語義分析了。這裏的「語義」體如今對特殊字符的動態處理以及語法樹節點的字段設置,根據上下文(context)而定。好比對於元字符’>’,咱們要判斷輸出重定向到哪一個文件,是截斷仍是追加。對於通配符’?’、’*’和'[…]’,咱們要決定對哪些字符進行擴展,這些在/etc/glob中專門處理。對於語法樹節點,除了自身固有屬性以外,還須要繼承上層節點的屬性,以及下推屬性到下層子樹節點,下面列了一張表格說明。

DTYP

DLEF/DRIG

DFLG

DSPR

TLST

能夠爲空,也能夠是其它節點,類型能夠是TLST/TFIL/TCOM 自身屬性爲0;若是帶’&’,則下推屬性FINT|FAND|FPRS到左右子樹(忽略信號、後臺異步,打印pid)

TFIL

必須同時存在、,類型只能是TCOM或TPAR 自身屬性繼承自上層TLST;下推FPIN到左子樹節點;下推FPOU到右子樹節點。

TPAR

繼承上層的TLST和TFIL;若是是追加模式重定向輸出,加上FCAT;若是是複合命令中最後一個子命令,加上FPAR, 將不會fork子進程。 子命令

TCOM

左子樹節點爲輸入重定向文件,右子樹爲節點輸出重定向文件。

執行命令(Executor)

當前面一系列步驟以後,若是錯誤計數爲0,則解釋器從語法樹的根節點開始,深度優先遍歷全部節點,並根據前面語法和語義分析獲得的類型和屬性,一一執行所包含的命令,以生成最後的系統調用。

對於命令序列(TLST)節點,從左至右順序執行子樹節點命令。

對於過濾器(TFIL)節點,建立管道文件句柄,做爲左右子樹的重定向文件。

對於簡單命令(TCOM)和複合命令(TPAR)節點,首先篩選出系統內置命令(built-in),對於剩下的外部命令則fork一個子進程執行它。若是是複合命令中最後一個子命令,那麼仍在原來的進程上執行而沒必要建立新進程。可執行文件路徑按前後順序搜索:①本地路徑;②/bin;③/usr/bin。

多進程環境下,特別要注意文件句柄管理。命令間共享標準輸入輸出設備以外,還會重定向到管道線,而父進程在fork以後子進程會獲取一份文件句柄拷貝,因此父進程必須在fork以後當即關閉閒置的管道線句柄(若是有的話)以避免形成資源泄漏,子進程也將在重定向以後關閉管道線句柄。

對於後臺命令須要打印pid,但不須要響應中斷信號,父進程也沒必要等待子進程終止。其他進程命令執行中可捕獲中斷信號,並轉入相應的處理函數。

解釋器用內置的errno全局變量保存進程終止狀態,並生成終止報告(termination report),系統調用wait()用於返回終止進程的pid並輸出報告消息索引。

孰優孰劣

儘管Thompson Shell是一款優秀的命令解釋器,還產生了多項歷史創舉,但遺憾的是依然得不到命運女神的垂青,這要歸咎於其自身的缺陷——功能單1、命令分散、控制流過於簡單,尚沒法用來編寫腳本(script)。隨着Unix日益壯大,它已經沒法應付趨於繁雜的編程項目了。那時還出現了一個叫John Mashey的人寫的PWB Shell(又叫作Mashey Shell),基於Thompson Shell作了些改進,擴展了命令集,增長了shell變量,還增長了if-then-else-endif,for,while等控制邏輯。不幸的是它比Thompson Shell更短命,由於1977年它趕上了一個強勁的對手。

沒錯,那就是Bourne Shell,它的主要優勢是真正實現告終構化腳本編程,比以前的shell實現得都要好,更要命的是它與前兩個shell都不兼容,因而一場標準化的論戰開始了。在David G. Kornksh做者)寫的「ksh – An Extensible High Level Language」一文中說起,Steve Bourne和John Mashey在三次連續的Unix用戶組集會上爭論他們各自的理由。在這些集會之間,各自增進他們的shell來擁有對方的功能。還設立了一個委員會來選擇標準shell,最終仍是選擇了Bourne shell做爲標準。

因而從Unix V7開始就有了前面所說的」Bourne Shell Family」。然而歷史上沒有完美的技術,隨着8、九十年代操做系統迅猛發展,針對Bourne Shell的詬病也愈來愈多了。在解釋器自己實現上,我看到網上一個對其評價是「universally considered to be one of the most horrible C code ever written」,至於緣由去看一下mac.h就知道了,包括基本運算符、關鍵字在內的大量宏定義使得整個代碼看上去簡直不是C寫的,也許Bourne是想把解釋器打形成本身獨特的風格吧,也難怪後來的bash以「born again」命名就是對其祖先的戲謔性調侃。另外內存管理上的一些毛病帶來平臺可移植性問題,至於其中的技術細節有點高級,超出本文範疇。

Thompson Again Shell?

雖然歷史沒有給Thompson Shell一個機會,但它並不是就此同Unix V6那樣一同淪爲開源博物館上的古老「化石」。做爲出自頂級黑客之手的做品,做爲伴隨Unix那樣偉大操做系統一同曾經流行計算機的產物,至今仍受國內外程序員的緬懷,或將其改寫,或爲其做注。好比國外一個站點v6shell.org上就實現了一個免費開源的可移植性shell,它兼容並擴充原來的Thompson Shell而且可用來作腳本編程。再好比中國程序員寒蟬退士在其我的博客上發佈了一個註解版,並對原版作了一些改寫,主要是將K&R C轉爲ANSI C,而且符合POSIX規範,使本來晦澀難懂的源碼變得清晰易讀起來。正是由於接觸到他的版本激起了我對老Unix的考古興趣,纔有了這篇「考古筆記」。我在想不知從此會不會像bash那樣,出一個tash來呢?

一些感想

原本全文應該就此結束了,但此時此刻不由想多說幾句。這篇筆記當初並不是有意而爲之,在hacking源碼的過程當中感想積累多了也就逐漸成章了。看代碼、做註解、查資料、寫此文,先後歷經四個多禮拜,是在繁雜的工做中「擠乳溝」擠出來的零散時間片拼湊起來的,雖然文字不長但也算耗費了一番心血,酸甜苦辣心中自明,體會到踏上社會以後潛下心作研究之艱難。現在面對這樣一份不到900行寫成的,沒有一行多餘的代碼,簡潔(clarity)、乾淨(clean)、快速(fast),這就是Pure C的魅力,我深爲這種厚重的編程功力所折服,正所謂「大道至簡」吧。雖然要徹底弄懂它須要不少時間,但我相信這種代價倒是值得的。

最後再八卦一下,2011年Dennis Ritchie去世了,有人生前問過他「學C須要多久才能成爲熟練開發者並寫出重要產品代碼?」,Ritchie回答「我不知道,我從沒去學過C。」(I don’t know. I never had to learn C.)其實這裏已經給出了答案——那就是沒有比去閱讀Unix源代碼更好的選擇了,某種意義上C語言就是爲Unix而生的。

Dennis Mac Ritchie

參考資料

The Unix Heritage Society:Unix社區遺產,上面有v6和v7以及其它一些衍生版本的操做系統源代碼。

The Traditional Bourne Shell Family:Bourne Shell家族簡史。

v6shell:osh,一個基於Thompson Shell的開源可移植性old shell。

寒蟬退士的博客:Thompson Shell的一個註解版。

Evolution of shells in Linux:簡述Linux Shell演變史。

附錄一箇中文註釋的 shell源碼

相關文章
相關標籤/搜索