# quiz 7A - shell contributed by < `limaoxd` > ## 專題說明 本專題是 [Unix Shell](https://en.wikipedia.org/wiki/Unix_shell) 的實作 , 並能夠使用一部份 **shell** 常見的指令 . **shell** 簡單的迴圈概念如以下步驟, * **Read :** 從 stdin 標準輸入流讀取命令. * **Parse :** 分析並拆解成程式名和參數. * **Execute :** 執行拆解後的命令. 先跳過輸入的字串拆解成程式名和參數,會留到**程式解析**. :::warning 改進漢語表達,力求通順和簡潔 :notes: jserv ::: * 如果需要將呼叫的程式拿來執行,就要分成不同的行程. 使用 `fork()` 系統呼叫,建立目前行程的副本,以`fork` 的傳回值進行判斷: 父行程 (即原本的行程) 得到的回傳值為 **PID(Process ID)** , 而子行程為 0 * 接下來就需要將子行程替換成命令要求的程式 , 這時候透過 `execvp()` 將行程做替換 , 與此同時父行程能夠繼續的執行其他事情,如果使用 `wait()` 的話,則繼續**監視**子行程. * 有些功能不能將功能交給子行程做 , 常見的如 `cd` , `exit` 等等的必須由父行程執行 , 比如說使用子行程做 `cd` 的話,並沒有改變 **shell** 本身, 所以需要自己寫個函式去執行這些功能 . ::: info 更加詳盡的說明 , 可以參考 [Tutorial: Write a Shell in C](https://brennan.io/2015/01/16/write-a-shell-in-c/) ::: --- ## 程式解析 #### ++prompt 命令列起始符號++ ``` cpp static void prompt() { write(2, "$ ", 2); } ``` * `prompt()` 為 **command_line** 的開頭 , 而將 `$` 命令列起始符號印出來 #### ++fatal 報錯處理++ ``` cpp /* Display error message, optionally - exit */ static void fatal(int retval, int leave) { if (retval >= 0) return; write(2, "?\n", 2); if (leave) exit(1); } ``` * 報錯處理 * `fatal` 目的為判斷行程執行是否出錯 , 其參數有 : `retval` , `leave` * `retval` 為判斷是否出錯 , `leave` 為是否終止行程的參數 * 行程出錯的話,顯示 `?` 來做為錯誤訊息 :::warning 或許能夠做到改造 `fatal` 函式 , 善用 **stderr** 的話 , 使其錯誤訊息更加詳盡 . ::: #### ++字符判斷函式++ ```cpp 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); } ``` * 這些函式是判斷字串是否為特殊字符而建立的 . 之後在 `run()` 做**字串解析**時會用到 ### 主程式分析 #### ++main 主函式++ ``` cpp 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; } ``` * 為了獲得輸入的命令 , 一開始 `main()` 透過 `fgets()` 將 **buffer** 抓下來, ``` graphviz digraph array1 { node [shape=plaintext, fontcolor=black, fontsize=18]; "Pointers:" -> "Array:" [color=white]; node [shape=record, fontcolor=black, fontsize=14, width=4.75, fixedsize=true]; pointers [label="<f0> c | <f1> c+1 | <f2> c+2 | <f3> c+3 | <f4> ... | <f5> c+511", color=white]; array [label="<f0> buf[0] | <f1> buf[1] | <f2> buf[2] | <f3> buf[3] | <f4> ... | <f5> buf[511]", fillcolor=white, style=filled]; { rank=same; "Pointers:"; pointers } { rank=same; "Array:"; array } } ``` * 不過 `fgets()` 將 `c+1` 也就是 `buf[1]` 的位置 ,設定為字串開頭有一個重要的原因 , 在字串拆解後會一併解釋 . 在判斷完是否成功的 `fgets()` 後 , 開始用 **for** 迴圈跑到字串的結尾 (該處為 `\0` ) ``` graphviz digraph array2 { node [shape=plaintext, fontcolor=black, fontsize=18]; "Pointers:" -> "Values:" [color=white]; node [shape=record, fontcolor=black, fontsize=14, width=4.75, fixedsize=true]; pointers [label="<f0> c-17| <f1> c-16| <f2> c-15| <f3> c-14| <f4> | <f5> c-1| <f6> c", color=white]; array [label="<f0> 0 | <f1> e | <f2> c | <f3> h | <f4> ... | <f5> d| <f6> 0", fillcolor=white, style=filled]; { rank=same; "Pointers:"; pointers } { rank=same; "Values:"; array } } ``` :::warning 該處可以使用 [Binary search algorithm](https://en.wikipedia.org/wiki/Binary_search_algorithm) 將**時間複雜度**從 ***O(n)*** 變成 ***O(log n)*** 不過長度 511 要省也省不到哪裡去. > 尤其是字串長度小於 8 的話 , 不用二分搜會比較快 將遍歷改良成 [Binary search algorithm](https://en.wikipedia.org/wiki/Binary_search_algorithm) 👇 ``` cpp int lwBound = 1 , upBound = 511 , p; while(lwBound < upBound){ /* if lwBound >= upBound then break */ p = (lwBound + upBound) / 2; if (!*(c + p)) /* if pivot value == NULL */ upBound = p; else lwBound = p+1; } c = c+p; ``` > 需設計實驗來證明該方法的成效 ::: ### 父子行程解析 > 我先將 `run()` 切成 2 個部分 , 並拆成許多小部分做講解. #### ++**run() - part 1**++ ``` cpp static void run(char *c, int t) { 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 */ ... ``` * 函式參數 * `c` 為紀錄 **buf** 位置的指標 (從 `main()` 那邊傳遞過來的) * `t` **filedescriptor** 的數值 , 若為 `0` , 代表沒有使用 **pipe** 將 **stdout** 標準輸出流做替換 * 宣告變數 * `redir_stdin` 為紀錄 **infile** 字串 * `redir_stdout` 則記錄 **outfile** * `v` 是紀錄要 **split** 後的 指標字串陣列 (可視為 **2** 維字元陣列), 其 **row** 數量為 **99** * `u` 則是類似 `main()` 裡的 `c` 一樣 , 但是它記錄的是 `v` 的 **row** 的位置 > 接下來 , 便開始將字串拆解 . > 由字串結尾 ( 一開始的 c ) 開始往字串前遍歷 , 並且分別遇到 `is_delim()` , `!is_special()` 和 `is_redir()` 後,分別作出不同的處理. ```cpp if (is_delim(*c)) /* if NULL (start of string) or pipe: break */ break; ``` * 遍歷時 , 遇到了 `\0` 或是 **pipe 字元** * 該情形是為了判斷是否到達了 **0** 的位置又或者是遇到 **pipe** * 判斷到達了頭的原因是因為在使用 `fgets()` 時 , 將 `buf[0]` 保留 \0, 這就是為甚麼要將 `*fgets(char *s, int size, FILE *stream)` 的 `s` , 位置設定在 `c+1` 的原因了 . * 最後直接 **break** 來停止讀取字串 :::danger 如果沒有做保留一個 \0 的話,讀到 -1 位置的值時 , 不一定是 \0. ::: ```cpp if (!is_special(*c)) { /* Copy word of regular chars into previous u */ char *delim = " |<>\n\t"; u--; while(!is_special(*c)) c--; *u = strtok(c+1,delim); } ``` * 遍歷到字串的結尾 * before ``` graphviz digraph structs { rankdir = LR; node [shape=record]; hashTable [label="<f0>0|<f1>...|<f2>95|<f3>96|<f4>97|<f5>u[0] 98"]; hashTable:f5 -> hashTable:f4 [label = "u--"]; } ``` * * after (repeat twice) ``` graphviz digraph structs { rankdir = LR; node [shape=record]; hashTable [label="<f0>0|<f1>...|<f2>95|<f3>u[0] 96|<f4>u[1] 97|<f5>u[2] 98"]; str2 [label="<f0> echo"] str1 [label="<f0> hello"]; str [label="<f0> world"]; hashTable:f3 -> str2:f0; hashTable:f4 -> str1:f0; hashTable:f5 -> str:f0; } ``` * * 開始讀取到字符後便對其進行遍歷 , 直到碰到特殊字符 * 這樣可以抵達到要==拆解字串的開頭前== * 之後進行字串分割 , 這裡我採用了 `strtok()` , 相較於 `strncpy()` 和 `strndup()` , 不需要在程式裡進行 **count** . :::warning 直接使用 `strncpy()` 會導致 [Segmentation fault](https://en.wikipedia.org/wiki/Segmentation_fault) , 必須要在結尾處補上 `\0` 而使用 `strndup()` 則是動態記憶體配置 , 最後需要用 free() 來進行釋放 , 避免 [Memory leak](https://en.wikipedia.org/wiki/Memory_leak) ::: ```cpp if (is_redir(*c)) { /* If < or > */ if (*c == '<') redir_stdin = *u; else redir_stdout = *u; if ((u - v) != 98) u++; } ``` * 遍歷到重新導向字符 * 判斷是重新導向的字符後 , 便對其記錄其為標準輸入亦或輸出 * `redir_stdin` 會記錄下 **infile** 的檔名 , `redir_stdout` 也是如此紀錄 **outfile** * 紀錄完 file 後 , 之後再讀取到字串的話 , 因為 u++ 而將 紀錄 file 的字串取代掉 , 不需要保留 file 在 u 裡 ```cpp if ((u - v) == 98) /* empty input */ return; ``` * **part1** 的最後 * 確認 `u` 的位子沒有從 **98** 改動的話 , 則為沒有輸入 , 結束主行程就好. #### ++**run()-part2**++ > 暨剛剛在專題簡介有介紹到主程式有需要自己執行的功能 , 不能交由子行程執行的程式. > 底下會解析該功能如何實現 ```cpp if (!strcmp(*u, "cd")) { /* built-in command: cd */ fatal(chdir(u[1]), 0); return; /* actually, should run() again */ } ``` * 在主行程完成 * 因為由 **shell** 執行 , 並不需要 `fork()` 直接使用 `chdir()` 後 , 即可直接結束行程. * 因為沒叫出子行程 , 如果報錯並不需要進行 **exit** 的動作 . ```cpp if (*c) { pipe(pipefds); outfd = pipefds[1]; /* write end of the pipe */ } 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; } ``` ```graphviz digraph pipe { rankdir=LR ranksep=1 a [label = "pipefds[0]"] b [label = "pipe" , shape=record] c [label = "pipefds[1]"] a -> b b -> c } ``` ```graphviz digraph pipe { rankdir=LR ranksep=1 a [label = "front"] c [label = "behind"] a -> c } ``` * 接下來就是檢查 `c` 位置是否不在 `buf[0]` 的位置 , 如果不在代表遇到了 `|` * 這個時候 , 使用 **pipe** 會建立管道從 `pipefds [0]` 送到 `piprfds [1]` , 後面只要用 `dup2()` 取代 主程式的 **stdout** 即可達到行程的溝通 . * 雖然說一開始 `u` 是從==c 後面開始遍歷== , 照理來說會使 `|` 後面的程式先執行 , 但是使用 **pipe** 後 , 如果 `|` 前的程式沒有先執行的話 , `|` 後的程式會無法執行 ```cpp if (outfd) { dup2(pipefds[0], 0); /* dup read fd to stdin */ close(pipefds[0]); /* close read fd */ close(outfd); /* close output */ } if (t) { dup2(t, 1); /* replace stdout with t */ close(t); } ``` >這邊就很好的解釋為甚麼 **pipe** 會先等待前面的程式執行完 * 底下的列表為說明預設的 **fd** , 在進行 `dup2()` 或 `close()` 對輸入或輸出流進行操作 | Handle | Name | Description | | -------- | -------- | -------- | | 0 | stdin | Standard input | | 1 | stdout | Standard output | | 2 | stderr | Standard error | * 判斷 `outfd` 的屬於 **pipe** 後面的程式 , 使用 `dup2()` 將 **stdin** 變成 **pipefds [0]** , 如果輸入為空的話 , 便會繼續等待 . * 這個時候前面的程式需要將 **stdout** 替換成 `pipefds [1]` 後 , 透過==管道輸出到後面==的程式.這樣就可以達成前面的程式先跑 , 後面的程式繼續 . ```cpp if (redir_stdin) { close(0); /* replace stdin with redir_stdin */ fatal(open(redir_stdin, 0), 1); } if (redir_stdout) { close(1); fatal(creat(redir_stdout, 438), 1); /* replace stdout with redir */ } ``` * 剛剛在 `!is_special()` 那邊做字串拆解時 , 有將 **infile** , **outfile** 做儲存 , 這時候將輸入或輸出流替換成 **file** 後即可完成重新導向 :::info 1. `int open(const char *pathname, int flags)` * 第二個參數為 flags 分別有 `O_CREAT` , `O_EXCL` , `O_NPCTTY` 等等 , 對於文件的讀取以不同方式打開亦能創建檔案 . 就算文件開啟後 , 依然能以 **fcntl** 進行修改 . 可以參考 : https://linux.die.net/man/2/open 2. `int creat(const char *path, mode_t mode); ` * 等同於部分 `open()` 功能的函式 > A call to creat() is equivalent to calling open() with flags equal to O_CREAT|O_WRONLY|O_TRUNC. >來源 : https://man7.org/linux/man-pages/man2/open.2.html * 不同於 `flags` , `mode` 代表了使用者權限 (**permission**) * 第一周提供的 [鳥哥的 linux 私房菜第五章](http://linux.vbird.org/linux_basic/0210filepermission.php) 中有提到使用 `ls -al` 可以列出檔案的權限如 **-rwxrwx---** , * 比對參數 **S_IRWXU(700)** **S_IRUSR(400)** **S_IWUSR(200)** 得知參數為八進位 * 由此得知將 `438` 轉成八進位 `666` , 為 **-rw-rw-rw-** **User , Group , World** 皆可讀可寫 ::: --- ## 延伸問題 ### 導向到 **stderr** 上述程式並未含有 `2>` 的延伸功能 , 故對 `run()` 和 `fatal()` 做小幅度的改變 * 改動部分 * 在 `!is_special()` 判斷中 , 對 `is_redirect()` 額外新增對 c[-1] 是否為 **2** 做了判斷 . * 使用 `dup2()` 將 **stderr** 導入到 `redir_stdout` ``` cpp int redir_stderr = 0; ``` * 新增一個 **int** 變數 , 來確認是否使用 **stderr** 導入到 `redir_stdout` ```cpp if (is_redir(*c)) { /* If < or > */ if (*c == '<') redir_stdin = *u; else{ redir_stdout = *u; if (c[-1] == '2') { redir_stderr = 2; c--; } } if ((u - v) != 98) u++; } ``` * 更改 `redir_stderr` 來確認是有導向到 **stderr** * 如改動部分所式 , 確認 `c[-1]` 是否有 **2** , 如果有將 `redir_stderr` 更改成 2 * 並且再做一次 `c--` 避免被紀錄在 `u` 裡 ```cpp static int fatal(int retval, int leave) { if (retval >= 0) return retval; write(2, "?\n", 2); if (leave) exit(1); } ``` ``` cpp if (redir_stdout) { close(1); outfd = fatal(creat(redir_stdout, 438),1); /* replace stdout with redir */ if(redir_stderr){ dup2(outfd, redir_stderr); close(outfd); } } ``` * 將 `fatal()` 改為 **int** 函式 , 在沒有出錯的情況回傳 `retavl` * 當確認要導向到 **stderr** , 使用 `dup2()` 實作出此功能 輸入以下 **command** 來確認是否執行成功 ```shell $ command1 2> x $ cat x ``` 輸出結果 ⬇ ```shell ? ``` 最後 `cat` 出來的內容為 `?` 符合 **fatal()** 裡的實作的報錯訊息 --- >還剩下 `>>` (append) 功能和 `&` (背景執行) , 和搭配 User - Mode Linux 用 `picosh.c` 取代 **init** 行程