本文目標#

  • 學習 Shell 的基本操作
  • Shell 的執行流程
  • Shell 的實作

進入正題#

Shell 是一套能夠監聽使用者命令、解析命令再告知作業系統核心完成命令(System Call)的工具。因為這套工具是利用 OS kernel 提供的系統呼叫(System Call)完成作業的,所以可以把它想像成 OS kernel 的外殼。

Shell 的基本操作#

一般來說,Shell 的執行流程如下:

  1. Prompt($)
  2. 使用者輸入命令
  3. 依照路徑尋找命令的位置
  4. 產生 Child process 完成命令
  5. 輸出結果或是錯誤運行
  6. 再次出現 Prompt

萬用字元#

通常來說,系統都會支援以下萬用字元:

  • *:表示不限長度的任何字元

    cat *.html
  • ?:表示任一字元

    cat in?ex.html
  • []:表示中括號內的其中一個字元

    cat index[12].html

特殊字元#

  • \:取消後面字元的意義。假設我希望印出 * 字元的內容,就不能直接使用 *,因為該符號已經是萬用字元了。這時候可以這樣做:

    echo \* star \*
  • ':取消字串中的特殊意義。

  • ":取消字串中除了 $`'\ 的特殊意義。

  • `:執行字串中的命令。

Pipe#

pipe 可以讓使用者連結多個命令,參考以下命令:

cat file_1 | sort

在不使用 pipe | 時,cat 會將 file_1 的內容輸出到終端機上。使用 pipe 後,cat 的輸出會作為 sort 程式的輸入,等到排序完成後才做輸出。

Redirection#

我們可以利用轉向符號 >< 改變終端機輸出與輸入的對象。

  1. 輸出轉向

    cat file_1 > file_2

    file_1 的內容輸出到 file_2

  2. 輸出附加轉向

    cat file_1 >> file_2

    如果 file_2 原先就有內容且使用者需要保留該資料,可以使用 >>file_1 的內容接續到 file_2 的內容後面。

  3. 輸入轉向

    cat < file_1 > file_2

    輸入轉向會將原來由鍵盤輸入的資料改為使用者指定的周邊、檔案替代。上面的命令會將 file_1 作為輸入並輸出到 file_2 上面。

更多 CLI 工具#

礙於篇幅問題,這邊僅羅列幾個常見工具並大概說明其用途:

  • cat:cat(concatenate)工具能夠連結並顯示文字。

    cat index.html
  • grep:強大的檔案搜尋工具。

    cat index.html | grep head
  • less:分頁工具,在讀取內容巨大的檔案時不會一次載入全部的內容,可以加快載入的速度。

    cat index.html | less
  • lsof:查找行程(Process)開啟的檔案。

    lsof -c chrome
  • ps:查看目前執行中的行程。

    ps -A

專案觀摩:Picoshell#

該專案為成功大學的 Linux 核心設計小考題目,題目中提供了一個未完成的迷你 shell 程式,讓學員透過考試檢驗 C 語言程式設計的認知:

  • fork、wait、exec
  • stdin、stdout、stderr
  • pointer

執行流程#

首先看到 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;
}

我們可以得知 main() 會重複執行 while loop 的內容:

  • 執行 prompt()

    /*
     *  印出 $
     */
    static void prompt()
    {
        write(2, "$ ", 2);
    }
  • 設置 buffer 大小並將 stdin 的內容讀入。

  • 利用 for loop 將 c 指標指到 buffer 的最後一個字元。這邊要注意的是 fgets(c + 1, sizeof(buf) - 1, stdin),這個行為保留了 buffer 的第一個位置,等等看到 run() 時就會明白為什麼要這麼做。

執行 run()

在查看 run() 的原始碼之前,我們先看到其他定義好的 static 函式,這些函式會被 run() 呼叫,幫助 shell 判斷特殊的 token 以及印出錯誤訊息:

/* Display error message, optionally - exit */
static void fatal(int retval, int leave)
{
    if (retval >= 0)
        return;
    write(2, "?\n", 2);
    if (leave)
        exit(1);
}

/* Helper functions to detect token class */
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() 中的字串處理:

size_t length;
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 */
        break;
    if (!is_special(*c)) {
        /* Copy word of regular chars into previous u */
        length = 0;
        while(!is_special(*c)){
            length++;
            c--;
        }
        u--;
        c++;
        strncpy(*u, c, length);
        u[length] = '\0';
    }
    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;

if (!strcmp(*u, "cd")) { /* built-in command: cd */
    fatal(chdir(u[1]), 0);
    return; /* actually, should run() again */
}
  1. run() 中的 v 變數為指向 char pointer 的 array,也就代表他是用來存放多個字串的,可以讓我們在解析 command 後把內容存進去。

  2. u 變數則是指向 char pointer 的 pointer,在這邊被用來指向 v 存放的最後一個字串。

  3. 每次執行 for loop 都會將指標往前指,做到 parse command 的作用。

  4. 下面的判斷式可以讓我們知道 command 已經到頭了(也就是剛剛提到的 fgets(c + 1, sizeof(buf) - 1, stdin))或是遇到 pipe。

    if (is_delim(*c)) /* if NULL (start of string) or pipe: break */
        break;
  5. 接著這邊是小考的作答區,主要是做一些字串的處理,!is_special(*c) 會在字元為一般字母時成立。當條件成立以後,讓指標持續移動直到遇到特定字元,這時候我們就可以確定指標之後的 length 個字元就是我們要的命令:

    length = 0;
    while(!is_special(*c)){
        length++;
        c--;
    }
    u--;
    c++;
    strncpy(*u, c, length);
    u[length] = '\0';

處理 pipe 與執行:剩下的部分就是做 pipe 以及 redir 的後續處理,最後使用 execvp 執行我們一開始輸入的 command:

    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;
    }

    if (outfd) {
        dup2(pipefds[0], 0); /* dup read fd to stdin */
        close(pipefds[0]);   /* close read fd */
        close(outfd);        /* close output */
    }

    if (redir_stdin) {
        close(0); /* replace stdin with redir_stdin */
        fatal(open(redir_stdin, 0), 1);
    }

    if (t) {
        dup2(t, 1); /* replace stdout with t */
        close(t);
    }

    if (redir_stdout) {
        close(1);
        fatal(creat(redir_stdout, 438), 1); /* replace stdout with redir */
    }
    fatal(execvp(*u, u), 1);
}

總結#

Jserv 老師在題目設計上的用心不言而喻,shell 跟 pipe 等觀念都只有在恐龍書上看到,所以看到這個作業時就把它拿來玩看看了。雖然正解只需要補齊大約 10 行程式碼,但要補出這短短的程式碼需要掌握 C 語言技巧並且理解 shell 的行為、如何使用 fork()exec()

此外,可能會有讀者想要問為何不在 mini-riscv-os 中加入 Shell 的實作,主要是因為 mini-riscv-os 缺乏了這些要素:

  • File System(目前僅實作 Disk Driver,要實作檔案系統的話很多架構都需要重新考慮)
  • 系統呼叫(System Call)(目前有人提交 PR,但還不支援 exec 系統呼叫)

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10279079