我很想知道一個 shell (像 bash,csh 等)内部是如何工作的。于是為了滿足自己的好奇心,我使用 python 實作了一個名為yosh (your own shell)的 shell。本文章所介紹的概念也可以應用于其他程式設計語言。
(提示:你可以在這裡查找本博文使用的源代碼,代碼以 mit 許可證釋出。在 mac os x 10.11.5 上,我使用 python
2.7.10 和 3.4.3 進行了測試。它應該可以運作在其他類 unix 環境,比如 linux 和 windows 上的 cygwin。)
讓我們開始吧。
步驟 0:項目結構
對于此項目,我使用了以下的項目結構。
yosh_project
|-- yosh
|-- __init__.py
|-- shell.py
yosh_project 為項目根目錄(你也可以把它簡單命名為 yosh)。
yosh 為包目錄,且 __init__.py 可以使它成為與包的目錄名字相同的包(如果你不用 python 編寫的話,可以忽略它。)
shell.py 是我們主要的腳本檔案。
步驟 1:shell 循環
當啟動一個 shell,它會顯示一個指令提示符并等待你的指令輸入。在接收了輸入的指令并執行它之後(稍後文章會進行詳細解釋),你的 shell 會重新回到這裡,并循環等待下一條指令。
在 shell.py 中,我們會以一個簡單的 main 函數開始,該函數調用了 shell_loop() 函數,如下:
def shell_loop():
# start the loop here
def main():
shell_loop()
if __name__ == "__main__":
main()
接着,在 shell_loop() 中,為了訓示循環是否繼續或停止,我們使用了一個狀态标志。在循環的開始,我們的 shell 将顯示一個指令提示符,并等待讀取指令輸入。
import sys
shell_status_run = 1
shell_status_stop = 0
status = shell_status_run
while status == shell_status_run:
### 顯示指令提示符
sys.stdout.write('> ')
sys.stdout.flush()
### 讀取指令輸入
cmd = sys.stdin.readline()
之後,我們切分指令tokenize輸入并進行執行execute(我們即将實作 tokenize 和 execute 函數)。
是以,我們的 shell_loop() 會是如下這樣:
### 切分指令輸入
cmd_tokens = tokenize(cmd)
### 執行該指令并擷取新的狀态
status = execute(cmd_tokens)
這就是我們整個 shell 循環。如果我們使用 python shell.py 啟動我們的 shell,它會顯示指令提示符。然而如果我們輸入指令并按回車,它會抛出錯誤,因為我們還沒定義 tokenize 函數。
為了退出 shell,可以嘗試輸入 ctrl-c。稍後我将解釋如何以優雅的形式退出 shell。
步驟 2:指令切分tokenize
當使用者在我們的 shell 中輸入指令并按下Enter鍵,該指令将會是一個包含指令名稱及其參數的長字元串。是以,我們必須切分該字元串(分割一個字元串為多個元組)。
咋一看似乎很簡單。我們或許可以使用 cmd.split(),以空格分割輸入。它對類似 ls -a my_folder 的指令起作用,因為它能夠将指令分割為一個清單 ['ls', '-a', 'my_folder'],這樣我們便能輕易處理它們了。
然而,也有一些類似 echo "hello world" 或 echo 'hello world'
以單引号或雙引号引用參數的情況。如果我們使用 cmd.spilt,我們将會得到一個存有 3 個标記的清單 ['echo', '"hello',
'world"'] 而不是 2 個标記的清單 ['echo', 'hello world']。
幸運的是,python 提供了一個名為 shlex 的庫,它能夠幫助我們如魔法般地分割指令。(提示:我們也可以使用正規表達式,但它不是本文的重點。)
import shlex
...
def tokenize(string):
return shlex.split(string)
然後我們将這些元組發送到執行程序。
步驟 3:執行
這是 shell 中核心而有趣的一部分。當 shell 執行 mkdir test_dir 時,到底發生了什麼?(提示: mkdir 是一個帶有test_dir 參數的執行程式,用于建立一個名為 test_dir 的目錄。)
execvp 是這一步的首先需要的函數。在我們解釋 execvp 所做的事之前,讓我們看看它的實際效果。
import os
def execute(cmd_tokens):
### 執行指令
os.execvp(cmd_tokens[0], cmd_tokens)
### 傳回狀态以告知在 shell_loop 中等待下一個指令
return shell_status_run
再次嘗試運作我們的 shell,并輸入 mkdir test_dir 指令,接着按下Enter鍵。
在我們敲下Enter鍵之後,問題是我們的 shell 會直接退出而不是等待下一個指令。然而,目錄正确地建立了。
是以,execvp 實際上做了什麼?
execvp 是系統調用 exec 的一個變體。第一個參數是程式名字。v 表示第二個參數是一個程式參數清單(參數數量可變)。p
表示将會使用環境變量 path 搜尋給定的程式名字。在我們上一次的嘗試中,它将會基于我們的 path 環境變量查找mkdir 程式。
(還有其他 exec 變體,比如 execv、execvpe、execl、execlp、execlpe;你可以 google 它們擷取更多的資訊。)
exec 會用即将運作的新程序替換調用程序的目前記憶體。在我們的例子中,我們的 shell 程序記憶體會被替換為 mkdir 程式。接着,mkdir 成為主程序并建立 test_dir 目錄。最後該程序退出。
這裡的重點在于我們的 shell 程序已經被 mkdir 程序所替換。這就是我們的 shell 消失且不會等待下一條指令的原因。
是以,我們需要其他的系統調用來解決問題:fork。
fork 會配置設定新的記憶體并拷貝目前程序到一個新的程序。我們稱這個新的程序為子程序,調用者程序為父程序。然後,子程序記憶體會被替換為被執行的程式。是以,我們的 shell,也就是父程序,可以免受記憶體替換的危險。
讓我們看看修改的代碼。
### 分叉一個子 shell 程序
### 如果目前程序是子程序,其 `pid` 被設定為 `0`
### 否則目前程序是父程序的話,`pid` 的值
### 是其子程序的程序 id。
pid = os.fork()
if pid == 0:
### 子程序
### 用被 exec 調用的程式替換該子程序
os.execvp(cmd_tokens[0], cmd_tokens)
elif pid > 0:
### 父程序
while true:
### 等待其子程序的響應狀态(以程序 id 來查找)
wpid, status = os.waitpid(pid, 0)
### 當其子程序正常退出時
### 或者其被信号中斷時,結束等待狀态
if os.wifexited(status) or os.wifsignaled(status):
break
當我們的父程序調用 os.fork() 時,你可以想象所有的源代碼被拷貝到了新的子程序。此時此刻,父程序和子程序看到的是相同的代碼,且并行運作着。
如果運作的代碼屬于子程序,pid 将為 0。否則,如果運作的代碼屬于父程序,pid 将會是子程序的程序 id。
當 os.execvp 在子程序中被調用時,你可以想象子程序的所有源代碼被替換為正被調用程式的代碼。然而父程序的代碼不會被改變。
當父程序完成等待子程序退出或終止時,它會傳回一個狀态,訓示繼續 shell 循環。
運作
現在,你可以嘗試運作我們的 shell 并輸入 mkdir test_dir2。它應該可以正确執行。我們的主 shell 程序仍然存在并等待下一條指令。嘗試執行 ls,你可以看到已建立的目錄。
但是,這裡仍有一些問題。
第一,嘗試執行 cd test_dir2,接着執行 ls。它應該會進入到一個空的 test_dir2 目錄。然而,你将會看到目錄并沒有變為 test_dir2。
第二,我們仍然沒有辦法優雅地退出我們的 shell。
作者:supasate choochaisri
來源:51cto