MIT6.S081/6.828 實驗2:Lab Shell

Mit6.828/6.S081 fall 2019的Lab2是Simple Shell,內容是實現一個簡易的shell程序,本文對該實驗的思路進行詳細介紹,並對xv6提供的shell實現進行深刻解析。html

準備

首先來看實驗要求git

  1. 實現的shell要支持 基礎命令執行、重定向 (< >) 處理、管道 ( | ) 處理
  2. 不能使用malloc()動態分配內存
  3. 使用"@"代替"$"做爲命令行的提示符
  4. 及時關閉文件描述符;對系統調用的異常進行處理

xv6中提供有sh.c的實現,除了重定向和管道,還對括號、列表命令、後臺命令等作了支持,且總體設計較爲複雜。因此咱們無需過多參考已有代碼,能夠選擇簡單的思路來知足需求,在完成後再去閱讀xv6的shell實現。github

Shell本質上是一個用戶程序,在用戶和操做系統間創建鏈接。工做原理是在啓動後不斷接收並解析用戶輸入的命令,調用操做系統接口去執行命令,並把結果返回給用戶。Shell運行於用戶態而非內核態的好處是能夠和內核徹底解耦,實現可插拔的效果,所以你能夠在bash、zsh、ksh等不一樣shell間輕鬆完成切換。shell

實驗思路

下面介紹實驗的總體思路,完整代碼在 Github 中,並附有詳細註釋。數組

首先須要瞭解幾個核心的系統調用:bash

  • fork() : 該調用會建立一個子進程,會複製一分內存到獨立的進程空間,代碼中根據返回值來區分是子進程 (返回0) 仍是父進程 (返回子進程的pid)。shell中會對輸入的命令fork出子進程去執行,除了cd命令,由於須要修改主進程的當前路徑。
  • wait():該方法會阻塞父進程,等待子進程退出後再結束,注意若是fork()了多個子進程,則須要屢次調用wait()才能等待全部子進程完成。且wait()是沒法等待孫子進程的。
  • exec(char * path, char **argv):該方法會執行一個指定的命令,會將新的可執行文件加載到內存中執行,替換當前的進程空間。原程序中exec()後面的代碼不會再被執行,這也是shell中須要fork進程去exec命令的緣由,否則就沒法繼續處理一條命令了。

主體邏輯

程序的主邏輯是在 main()方法中循環接收標準輸入,fork() 出子進程進行處理,首先將接收到字符串分割爲字符串數組方便處理,而後進入命令解析和執行。函數

int main(void) {
  char buf[MAXLEN];             // 用於接收命令的字符串
  char *argv[MAXARGS];          // 字符串數組(指針數組)
  int argc;                     // 參數個數

  while (getcmd(buf, sizeof(buf)) >= 0) {
    if (fork() == 0) {
      argc = split(buf, argv);  // 根據空格分割爲字符串數組
      runcmd(argv, argc);       // 解析並執行命令
    }
    wait(0);                    // 等待子進程退出
  }
  exit(0);
}

getcmd() 實現較簡單,基於 gets() 函數來接收標準輸入,直接參考sh.c便可。直接來看處理輸入命令的 split() 函數,做用是將接收到的命令根據空格分割爲參數數組,方便後續解析和執行。思路是直接在源字符串上進行分割,將每一個參數的首地址收集到指針數組中,並在在末尾設置空字符"\0"進行截取,最終得到參數字符串數組。工具

int split(char * cmd, char ** argv) {
  int i = 0, j = 0, len = 0;

  len = strlen(cmd);
  while (i < len && cmd[i]) {
    while (i < len && strchr(whitespace, cmd[i])) {   // 跳過空格部分
      i++;
    }
    if (i < len) {  
      argv[j++] = cmd + i;   // 將每一個參數的開始位置放入指針數組中
    }
    while (i < len && !strchr(whitespace, cmd[i])) {  // 跳過字符部分
      i++;
    }
    cmd[i++] = 0;            // 在每一個參數後的第一個空格處用'\0'進行截斷
  }
  argv[j] = 0;               // 表示參數數組的結束
  return j;                  // 返回參數個數
}

接着來到runcmd()方法,包含了對特殊符號的解析和命令的執行,參數處理思路以下:測試

  • 管道:從左往右順序解析,找到 | 符號,對左右兩邊的命令分別建立子進程處理,鏈接標準文件描述符,並遞歸進入runcmd()方法
  • 重定向:遇到 < > 符號,關閉相應標準fd,打開文件
  • 普通參數:放入參數數組中,等待執行
void runcmd(char **argv, int argc) {
  int i, j = 0;
  char tok;
  char *cmd[MAXARGS];

  for (i = 0; i < argc; i++) {
    if (strcmp(argv[i], "|") == 0) {
      runpipe(argv, argc, i);       // 處理pipe
      return;
    }
  }
  for (i = 0; i < argc; i++) {
    tok = argv[i][0];               // 該參數的第一個字符
    if (strchr("<>", tok)) {
      if (i == argc-1) { 
        panic("missing file for redirection");    // 後面沒有文件則報錯
      }
      runredir(tok, argv[i+1]);     // 處理重定向
      i++;
    } else {
      cmd[j++] = argv[i];           // 收集參數
    }
  }
  cmd[j] = 0;
  exec(cmd[0], cmd);                // 執行命令
}

注:相比sh.c的實現,該解析方法的不足之處是沒有支持符號與下一個參數連在一塊兒的狀況,如 echo 123 >1.txtecho 123 |grep 12,不過測試用例中的參數都是以空格分割,因此這裏也就簡單處理了。spa

重定向實現

在介紹 pipe (管道) 和 redir (重定向) 的實現前須要先說明下文件描述符(fd) 的概念,對於每個打開的文件會在內核中對應建立一個file對象,而且內核會爲每一個進程維護一個指針數組,存儲該進程的file對象的地址,而fd正是這個指針數組的索引。因此引用的路徑是: fd -> 內核指針數組 -> file對象 -> 磁盤文件。

fd是一個順序增加的整型,每一個進程默認會打開3個fd,分別是標準輸入(0),標準輸出(1) 和 標準錯誤(2)。對fd有幾個經常使用的系統調用:

  • close(int fd):關閉一個fd,對應內核數組中的指針也會被移除,當文件對象的引用計數爲0時,該文件纔會被關閉
  • dup(int fd):複製一個fd,內核數組中會增長一個指針指向相同的文件,新建立的fd的值爲當前可用的最小的整數
  • pipe(int * fd):對兩個fd創建管道,對其中一個fd進行寫數據,能從另外一個fd讀出數據

重定向 是將進程的標準輸入/輸出 轉移到打開的文件上。實現思路是利用fd的順序增加的特性,使用close()關閉標準I/O的fd,而後open()打開目標文件,此時文件的fd就會自動替換咱們關閉的標準I/O的fd,也就實現了重定向。

void runredir(char tok, char * file) {
  switch (tok) {
  case '<':
    close(0);   
    open(file, O_RDONLY);
    break;
  case '>':
    close(1);
    open(file, O_WRONLY|O_CREATE);
    break;  
  default:
    break;
  }
}

管道實現

管道 是將左邊進程的標準輸出做爲右邊進程的標準輸入。實現思路以下:

  • 調用pipe()鏈接兩個fd,而後調用兩次fork() 分別建立兩個子進程,2個兄弟進程均繼承了由管道鏈接起來的fd。(注: 這裏調用2次fork是參考了sh.c的實現,實際發現若是每次只調用1次fork(),由父進程做爲左側輸入進程,子進程進行遞歸fork(),一樣能經過測試。)
  • 在子進程中close()關閉標準輸出fd,dup()複製管道其中一端的fd,而後執行命令
  • 父進程須要調用兩次wait()來等待兩個子進程結束

從實現思路上也能夠看出,因爲管道的實現依賴於子進程對fd的繼承,因此只能用於有親緣關係的進程間通訊。

void runpipe(char **argv, int argc, int index) {    // index爲|符號在數組中的位置
  int p[2];

  pipe(p);                                 // 創建管道
  if (fork1() == 0) {
    close(1);                              // 關閉標準輸出
    dup(p[1]);                             // 複製管道中fd
    close(p[0]);      
    close(p[1]);
    runcmd(argv, index);                   // 遞歸執行左側命令
  } 
  if (fork1() == 0) {
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    runcmd(argv+index+1, argc-index-1);    // 遞歸執行右側命令
  }
  // 關閉不須要的fd
  close(p[0]);
  close(p[1]);
  // 等待兩個子進程結束
  wait(0);
  wait(0);
}

至此,基本功能就實現了。測試步驟以下:

  • Makefile文件的 UPROGS 部分追加上 $U/_nsh\
  • 執行make qemu 編譯進入xv6命令行,隨後咱們能夠直接運行腳本: testsh nsh來執行測試case, 也能夠運行nsh進入咱們的shell進行手動調試
  • 最後能夠在xv6-riscv-fall1根目錄下執行 make grade 進行評分。

xv6中的shell實現

xv6中的shell實如今user/sh.c中,大體思路和咱們的nsh類似,都是實現了對用戶命令的循環讀取、解析、執行,不過支持的命令類型更多且涉及更復雜。

1.主體邏輯

sh.c將命令解析和命令執行獨立開來,首先遞歸地構造出結構化的命令樹,而後又遞歸地去遍歷樹中的命令並執行。且對每一種支持的命令都定義告終構體,包括 可執行命令(execcmd),重定向(redircmd),管道(pipecmd),順序命令(listcmd),後臺命令(backcmd),這些命令都"繼承"於一個基礎cmd結構:

struct cmd {
  int type;       // 命令類型
};

且對於每種命令都實現了"構造函數",使用malloc()動態分配告終構體內存,而且強轉爲 cmd 結構的指針返回,等到具體使用的時候,再根據type字段中的類型,強轉回具體的類型進行使用。(指針指向結構體的首地址,根據聲明來訪問字段,因此這裏的強轉不影響使用)。

這裏使用了面向對象的思想,藉助指針和類型強轉實現了相似於"多態"的效果。這裏的parsecmd()方法則像一個"工廠",根據輸入的不一樣構造不一樣類型的命令,以基類形式統一返回,runcmd()中再根據具體類型執行不一樣邏輯。

if (fork() == 0) {
  // parsecmd返回cmd,runcmd接收cmd
  runcmd(parsecmd(buf));  
}

此種設計將解析和運行獨立開來,使得代碼邏輯更加清晰,函數功能更單一;而且提高了可擴展性,若是後續有新的命令類型增長,只須要定義新的結構體,並編寫相應的解析和處理方法就能夠支持,對其餘類型的命令影響較小。

2.命令解析

命令的解析和結構化在parsecmd()中實現,支持管道,重定向,多命令順序執行,後臺執行,括號組合等符號的解析。方法中大量使用瞭如下兩個精巧的工具函數:

  • peek(char **ps, char *es, char *toks):判斷字符串*ps的第一個字符是否在字符串toks中出現,es指向字符串末尾,同時該方法會移除掉字符串*ps 的前綴空白字符。如 peek(ps, es, "<>") 則用於判斷當前字符串的首字符是否是 "<>" 中的一個。

  • int gettoken(char **ps, char *es, char *q, char *eq):一樣傳入字符串的開始(ps)和結束(es),每次調用該方法將會移除掉第一段空格及前面的內容,且傳入的 q 和 eq 指向的內容就是被移除的參數。若是函數移除的內容命中了指定符號"| < >"等,就會返回該符號,不然返回常量'a'。 好比對字符串"cat < 1.txt" 執行gettoken(),那麼源字符將變爲"< 1.txt",q和eq指向字符串"cat"的首尾,並返回字符'a'。

parsecmd() 以pipeline的鏈式調用進行命令解析,順序爲 parsecmd() -> parseline() -> parsepipe() -> parseexec() -> parseblock() -> parseredirs(),分別對不一樣類型的命令進行處理,從左往右不斷使用peek()函數判斷當前的符號,使用gettoken()獲取空格分割的參數,構造樹狀命令結構。與傳統樹結構不一樣的是,該命令樹的每一個節點均可能是不一樣的類型,好比管道命令的left和right字段都是cmd類型,但可能具體結構並不相同。

值得一提的是,解析完成後,還調用了nulterminate方法進行遞歸的參數截取。咱們最終執行的命令是execcmd類型,argv指針數組即指向全部參數的首地址,同時爲其維護了一個eargv指針數組,取值於gettoken()返回的eq參數,指向參數列表中每一個參數的末尾地址,nulterminate()則將全部eargv指向的末尾字符置爲'\0',這樣便巧妙地在源字符串中完成了參數的分割。

3.命令執行

runcmd()命令執行方法遞歸遍歷整顆命令樹,根據cmd結構的type參數進行判斷,作出相應處理。其中EXEC、PIPE、REDIR這三種命令和咱們的nsh實現類似,其他的幾種命令則比較簡單:

  • LIST:由分號 ; 分割的順序命令,實現方法是fork一個子進程執行左命令,wait等待其完成後再執行右命令,從而實現順序執行的效果;
  • BACK:由 & 結尾的後臺命令,實現方法是fork一個子進程執行命令,父進程則直接退出。

實驗代碼: https://github.com/zhayujie/xv6-riscv-fall19

本文連接: https://zhayujie.com/mit6828-lab-shell.html

相關文章
相關標籤/搜索