Try   HackMD

2021q1 期末專題 (quiz7A - shell)

contributed by < robertlin0401 >

tags: linux2021

2021 年第 7 週測驗題:測驗 1
GitHub


專題簡介

TODO: 此處補上本程式的功能、設計考量,和實作議題,在實際逐行分析程式碼之前。

一如所有事物的解說,應從高階總覽到個別子系統模組的順序。
:notes: jserv

  • 此實作旨在模擬 Unix shell 的運作方式,並且透過解讀此實作的過程去理解 Unix shell 的運作原理是此專題的一大目標
  • 目前支援功能包含
    • 執行簡單的命令,如結構為 {command} {argument list} 的命令
    • 命令之間可使用 pipeline
    • 檔案重新導向
  • 未支援的功能包含但不限於
    • >> (append), 2> (導向到 stderr), & (背景執行) 等語法
    • 上述語法的支援將在後續嘗試進行擴充

程式運作原理

相關函式說明

  • 建立命令列起始符號
    ​​​​static void prompt()
    ​​​​{
    ​​​​    write(2, "$ ", 2);
    ​​​​}
    
    • 對應的命令列介面如下
      ​​​​​​​​> ./picosh
      ​​​​​​​​$ _
      
  • 錯誤處理
    ​​​​static void fatal(int retval, int leave)
    ​​​​{
    ​​​​    if (retval >= 0)
    ​​​​        return;
    ​​​​    write(2, "?\n", 2);
    ​​​​    if (leave)
    ​​​​        exit(1);
    ​​​​}
    
    • retval 為系統呼叫之回傳值,若為負值則表示有錯誤,須進行處理
      • 此處系統呼叫包含 chdir, fork, open, creat, execvp
    • 錯誤處理如下
      • 印出 ? 符號告知使用者錯誤發生
      • 判斷錯誤類型,在需要之情況終止該行程,至於何謂需要之情況將於後續段落中說明
  • 特殊字元符號之類型判斷
    ​​​​static inline int is_delim(int c)
    ​​​​{
    ​​​​    return c == 0 || c == '|';
    ​​​​}
    
    ​​​​static inline int is_redir(int c)
    ​​​​{
    ​​​​    return c == '>' || c == '<';
    ​​​​}
    
    ​​​​static inline int is_blank(int c)
    ​​​​{
    ​​​​    return c == ' ' || c == '\t' || c == '\n';
    ​​​​}
    
    ​​​​static int is_special(int c)
    ​​​​{
    ​​​​    return is_delim(c) || is_redir(c) || is_blank(c);
    ​​​​}
    

main 函式說明

int main()
{
    while (1) {
        prompt();
        char buf[512] = {0}; /* input buffer */
        char *c = buf;
        if (!fgets(c + 1, sizeof(buf) - 1, stdin))
            exit(0);
        for (; *++c;) /* skip to end of line */
            ;
        run(c, 0);
    }
    return 0;
}
  • 使用一迴圈,重複下列工作
    1. 呼叫 prompt 函式生成命令列起始符號
    2. 讀取一行命令
    3. 呼叫 run 函式解析並執行命令
  • 讀取之命令將存入長度為 512 的字元陣列 buf 內,值得一提的是
    • 最大讀取長度是 511 個字元,而非 512 個
    • 儲存的方式為留一格空格在最前方,從第二格(buf[1] 的位置)開始儲存
    • 若輸入命令為 echo hello world,則儲存方式如下圖所示
      
      
      
      
      
      
      %0
      
      
      
      c
      c
      
      
      
      content
      
      
      e
      
      c
      
      h
      
      o
      
      
      h
      
      e
      
      l
      
      l
      
      o
      
      
      w
      
      o
      
      r
      
      l
      
      d
      
      
      ...
      
      
      
      
      c:start->content:head
      
      
      
      
      
      
  • 呼叫 run 函式執行前,會將指向 buf 開頭之指標移到該命令之結尾字元的下一個位址
    • 因為解析命令時將會從命令字串的尾端開始解析,詳見後續段落說明
    • 同樣以 echo hello world 為例,其結果如下圖所示
      
      
      
      
      
      
      %0
      
      
      
      c
      c
      
      
      
      content
      
      
      e
      
      c
      
      h
      
      o
      
      
      h
      
      e
      
      l
      
      l
      
      o
      
      
      w
      
      o
      
      r
      
      l
      
      d
      
      
      ...
      
      
      
      
      c:end->content:tail
      
      
      
      
      
      

執行命令(run 函式)說明

以下分為數個部分說明

  • 參數
    ​​​​static void run(char *c, int t)
    
    • c - 指向一命令字串之結尾字元的下一個位址的指標,詳見上一段
    • t - 用於指定輸出位置之 file descriptor,若為 0 則表示使用 stdout
  • 命令字串解析
    ​​​​char *redir_stdin = NULL, *redir_stdout = NULL;
    ​​​​int pipefds[2] = {0, 0}, outfd = 0;
    ​​​​char *v[99] = {0};
    ​​​​char **u = &v[98]; /* end of words */
    ​​​​for (;;) {
    ​​​​    c--;
    ​​​​    if (is_delim(*c)) /* if NULL(start of string) or pipe */
    ​​​​        break;
    ​​​​    if (!is_special(*c)) {
    ​​​​        /* Copy word of regular chars into previous u */
    ​​​​        u--;
    ​​​​        int count = 0;
    ​​​​        while (!is_special(*c)) {
    ​​​​            count++;
    ​​​​            c--;
    ​​​​        }
    ​​​​        *u = strndup(c + 1, count);
    ​​​​    }
    ​​​​    if (is_redir(*c)) { /* if < or > */
    ​​​​        if (*c == '<')
    ​​​​            redir_stdin = *u;
    ​​​​        else
    ​​​​            redir_stdout = *u;
    ​​​​        if ((u - v) != 98)
    ​​​​            u++;
    ​​​​    }
    ​​​​}
    ​​​​if ((u - v) == 98) /* empty input */
    ​​​​    return;
    
    • v 為長度 99 的字元指標的陣列,u 則為指向陣列 v 的最後一個空間(v[98] 的位置)的指標
    • 使用迴圈從命令的尾端開始解析字元,若遇到下列 3 種情況則進行對應處理,各情況所代表之意義請見這裡
      1. is_delim 成立
        • 表示解析完畢或是遇到 pipe 符號(|),此時的處理為離開迴圈
        • 故命令字串儲存的方式為留一格空格在最前方,將之作為命令解析完畢的判斷依據
        • 且由此可知,最後的命令將優先解析,並且也是優先開始執行的,但透過 pipe 的機制,最終會是最前的命令率先執行完畢

          受限於 pipe 的機制,若前方的命令未執行完成並產生輸出,後方的命令無法完成執行

          節錄自 pipe(7) — Linux manual page

          If a process attempts to read from an empty pipe, then read(2) will block until data is available.

      2. is_special 不成立
        • 表示解析到命令內容的一般字元,此時的處理為將此字元所屬的整段文字的複本的指標,存入當前 u 所指向的位置的前一個位置
        • 承上,以 echo hello world 為例,則處理過程如下圖所示
          
          
          
          
          
          
          %0
          
          
          cluster_level1
          
          before                                                                                                               
          
          
          cluster_level2
          
          after                                                                                                                  
          
          
          
          u2
          u
          
          
          
          v2
          v
          
          0
          
          ...
          
          
          
          
          0
          
          
          
          u2:ptr->v2:end
          
          
          
          
          
          world
          
          w
          
          o
          
          r
          
          l
          
          d
          
          
          
          v2:echo->world:echo
          
          
          
          
          v2:hello->world:hello
          
          
          
          
          v2:world->world:world
          
          
          
          
          
          hello
          
          h
          
          e
          
          l
          
          l
          
          o
          
          
          
          world:echo->hello:echo
          
          
          
          
          world:hello->hello:hello
          
          
          
          
          
          echo
          
          e
          
          c
          
          h
          
          o
          
          
          
          hello:echo->echo:echo
          
          
          
          
          
          c2
          c
          
          
          
          content2
          
          
          e
          
          c
          
          h
          
          o
          
          
          h
          
          e
          
          l
          
          l
          
          o
          
          
          w
          
          o
          
          r
          
          l
          
          d
          
          
          ...
          
          
          
          
          c2:end->content2:head
          
          
          
          
          
          u1
          u
          
          
          
          v1
          v
          
          0
          
          ...
          
          0
          
          0
          
          0
          
          0
          
          
          
          u1:ptr->v1:start
          
          
          
          
          
          c1
          c
          
          
          
          content1
          
          
          e
          
          c
          
          h
          
          o
          
          
          h
          
          e
          
          l
          
          l
          
          o
          
          
          w
          
          o
          
          r
          
          l
          
          d
          
          
          ...
          
          
          
          
          c1:start->content1:tail
          
          
          
          
          
          
        • 值得注意的是,根據上述處理可知,陣列 v 的最後一個位置之值保留為 0,不會被更動,作為一命令之 argument list 的結尾標記,詳見這裡
      3. is_redir 成立
        • 表示解析到重新導向符號(<>),此時的處理為記錄欲重新導向的路徑
        • 因為是由右至左解析命令,故解析到重新導向符號時,當前的 u 所指向的字元指標所指向的字串即為欲重新導向的路徑
        • 將其指標存入 redir_stdinresir_stdout 後,便執行 u++,使後續解析的命令將其覆蓋掉,可視為將其指標從陣列 v 中移除
    • 解析完成後,判斷 v(即指向 v[0] 的指標)與 u(原本為指向 v[98] 的指標)之差,若正好為 98 則表示 u 在該次解析前後一致,故可將其視為一個空命令
  • 執行內建命令
    「內建命令」(bultin) 指不需要透過 execve 系統呼叫執行程式而獲得功能的操作。
    ​​​​if (!strcmp(*u, "cd")) {
    ​​​​    fatal(chdir(u[1]), 0);
    ​​​​    return;
    ​​​​}
    
    • 若目前 u 所指向的字元指標所指向的字串為 cd,則 u[1] 便應是欲切換的路徑
    • cd 為一 builtin command,故直接使用系統呼叫 chdir(u[1]) 來實現

      節錄自 GNU bash 手冊內容

      Builtin commands are contained within the shell itself. When the name of a builtin command is used as the first word of a simple command, the shell executes the command directly, without invoking another program.

    • 正因為無新增子行程來執行其他程式,故當此系統呼叫發生錯誤時,無需將當前行程終止,至於其他命令的作法之說明詳見後續段落
  • pipe 處理
    ​​​​if (*c) {
    ​​​​    pipe(pipefds);
    ​​​​    outfd = pipefds[1]; /* write end of the pipe */
    ​​​​}
    
    • 若離開解析命令的迴圈後,指標 c 所指向的空間仍有內容,則代表是因為遇到 pipe 符號而離開迴圈的
    • 故使用 pipe 系統呼叫來建立一個 pipe,並將其 read、write end 的 file descriptors 分別記錄在整數變數 pipefds[0]pipefds[1]
  • 執行命令:其他命令
    ​​​​pid_t pid = fork();
    ​​​​if (pid) { /* parent or error */
    ​​​​    fatal(pid, 1);
    ​​​​    if (outfd) {
    ​​​​        run(c, outfd);     /* parse the rest of the cmdline */
    ​​​​        close(outfd);      /* close output fd */
    ​​​​        close(pipefds[0]); /* close read end of the pipe */
    ​​​​    }
    ​​​​    wait(0);
    ​​​​    return;
    ​​​​}
    
    ​​​​if (outfd) {
    ​​​​    dup2(pipefds[0], 0); /* dup read fd to stdin */
    ​​​​    close(pipefds[0]);   /* close read fd */
    ​​​​    close(outfd);        /* close output */
    ​​​​}
    
    ​​​​if (redir_stdin) {
    ​​​​    /* replace stdin with redir_stdin */
    ​​​​    close(0);
    ​​​​    fatal(open(redir_stdin, 0), 1);
    ​​​​}
    
    ​​​​if (t) {
    ​​​​    dup2(t, 1); /* replace stdout with t */
    ​​​​    close(t);
    ​​​​}
    
    ​​​​if (redir_stdout) {
    ​​​​    /* replace stdout with redir */
    ​​​​    close(1);
    ​​​​    fatal(creat(redir_stdout, 438), 1);
    ​​​​}
    ​​​​fatal(execvp(*u, u), 1);
    
    • 使用 fork 系統呼叫建立子行程 (process),負責執行該輪解析出來的命令,此作法為模擬非 builtin command 的行為。父行程則在命令未解析完的狀況(即存在 pipe 的狀況),遞迴呼叫 run 來繼續解析
    • 父行程內容詳述
      • 若建立子行程失敗,則程式無法正常運作,故將此行程終止
      • 遞迴呼叫 run 來繼續解析命令,須將先前建立的 pipe 的 write end file descriptor 傳入,使前方命令的輸出寫入 pipe 中的 buffer,作為後方命令的輸入
    • 子行程內容詳述
      • 首先,在有需要的情況下,進行一系列輸入、輸出重新導向,且若在開檔的系統呼叫過程出現錯誤,皆須將目前行程終止。此部分應考慮以下情況
        1. 存在 pipe,則使用 dup2 系統呼叫,將原本代表 stdin 的 file descriptor,改寫成代表先前建立的 pipe 的 read end,使其能夠讀取前方命令的輸出
          ​​​​​​​​​​​​​​​​int dup2(int oldfd, int newfd);
          

          節錄自 dup(2) - Linux manual page

          The file descriptor newfd is adjusted so that it now refers to the same open file description as oldfd.

        2. 命令內容有輸入重新導向符號(<),則關閉 file decriptor 為 0 者(stdin 或先前建立的 pipe 的 read end),並使用 open 系統呼叫開啟指定檔案,使其 file descriptor 成為 0,因此被當作新的輸入位置
        3. 呼叫 run 時有傳入 file descriptor,則將 stdout 以 dup2 系統呼叫做更改
        4. 命令內容有輸出重新導向符號(>),則關閉 file decriptor 為 1 者(stdout、先前建立的 pipe 的 write end 或其他檔案位置),並使用 creat 系統呼叫開啟指定檔案。其中,438 為檔案 mode,轉換為二進位為 110110110,表示對所有使用者而言此檔案可讀可寫

      用八進位 (對! C 語言內建八進位就為了檔案權限的描述) 來解讀上述數值的作用。見: CHMOD Calculator
      :notes: jserv

      更新:

      • 438 為檔案 mode(以十進位表示),將其轉換為八進位為 666
      • 八進位的三個位數從左至右分別代表 User(檔案擁有者)、Group(擁有者所屬群組)與 Other(其他使用者)對此檔案的操作權限
      • 以單獨一個位數來看,其由 3 個 bits 組成,每個 bit 由右至左分別代表該類別的使用者是否擁有讀取、寫入與執行的權限
      • 雖然也能夠以二進位來解讀,但無法使用二進位來操作
      • 若重新導向部分未出錯,則接著使用 execvp 系統呼叫執行當前指令,而若系統呼叫出錯亦將此行程終止

        ​​​​​​​​​​​​int execvp(const char *file, char *const argv[]);
        

        節錄自 exec(3) — Linux manual page

        The char *const argv[] argument is an array of pointers to null-terminated strings that represent the argument list available to the new program.
        The first argument, by convention, should point to the filename associated with the file being executed. The array of pointers must be terminated by a null pointer.


提升開發便利性之修改

支援輸入重新導向自檔案

  • 每當修改程式碼,需要將所有命令輸入一輪進行測試,以確認程式是按照預期來運作
  • 而每次手動輸入所有指令相當沒有效率,故將其寫成檔案 test.cmd,並使用輸入重新導向的方式輸入
  • 為此將程式碼做少量更動,使用 isatty(0) 判斷是否有做輸入重新導向,若有,則無須輸出如命令列起始符號等資訊至螢幕
    ​​​​- prompt();
    ​​​​+ if (isatty(0))
    ​​​​+     prompt();
    

支援輸出位置選擇

  • 使用 argument list 來指定輸出位置的類別,格式如下
    ​​​​> ./picosh {output_class}
    
  • 該引數可寫在輸入、輸出重新導向之前或之後
    • 舉例而言,以下格式皆正確
      ​​​​​​​​> ./picosh < {input_dir} {output_class}
      ​​​​​​​​> ./picosh {output_class} < {input_dir}
      
  • 引數 output_class 說明
    • 0 表示輸出至 stdout,1 表示將輸出寫入 output.log 檔案
    • 預設值為 0
  • 實作如下
    ​​​​int t = open("./output.log", O_WRONLY | O_TRUNC | O_CREAT);
    ​​​​int out_class = 0;
    ​​​​if (argc > 1 && !(argc % 2)) {
    ​​​​    for (int i = 0; i < argc / 2; ++i) {
    ​​​​        if (!strncmp(argv[1 + 2 * i], "<", 1) ||
    ​​​​            !strncmp(argv[1 + 2 * i], ">", 1)) {
    ​​​​            continue;
    ​​​​        }
    ​​​​        out_class = atoi(argv[1 + 2 * i]);
    ​​​​        break;
    ​​​​    }
    ​​​​}
    ​​​​...
    ​​​​run(c, out_class ? t : 0);
    

引入 linenoise

  • 透過 linenoise 可提供 tab 鍵自動補其功能與 鍵瀏覽命令輸入歷史紀錄功能
  • 其目的性有二
    • 增進開發過程中的測試效率
    • 模擬如 Bash 的介面操作方式