[譯] 教程 — 用 C 寫一個 Shell

nettee發表於2019-02-25

你很容易認為自己“不是一個真正的程式設計師”。有一些程式所有人都用,它們的開發者很容易被捧上神壇。雖然開發大型軟體專案並不容易,但很多時候這種軟體的基本思想都很簡單。自己實現這樣的軟體是一種證明自己可以是真正的程式設計師的有趣方式。所以,這篇文章介紹了我是如何用 C 語言寫一個自己的簡易 Unix shell 的。我希望其他人也能感受到這種有趣的方式。

這篇文章中介紹的 shell(叫做 lsh),可以在 GitHub 上獲取它的原始碼。

學校裡的學生請注意! 許多課程都有要求你編寫一個 shell 的作業,而且有些教師都知道這樣的教程和程式碼。如果你是此類課程上的學生,請不要在未經允許的情況下複製(或複製加修改)這裡的程式碼。我建議反對重度依賴本教程的行為。

Shell 的基本生命週期

讓我們自頂向下地觀察一個 shell。一個 shell 在它的生命週期中主要做三件事。

  • 初始化:在這一步中,shell 一般會載入並執行它的配置檔案。這些配置會改變 shell 的行為。
  • 解釋執行:接著,shell 會從標準輸入(可能是互動式輸入,也可能是一個檔案)讀取命令,並執行這些命令。
  • 終止:當命令全部執行完畢,shell 會執行關閉命令,釋放所有記憶體,然後終止。

這三個步驟過於寬泛,其實可以適用於任何程式,但我們可以將其用於我們的 shell 的基礎。我們的 shell 會很簡單,不需要任何配置檔案,也沒有任何關閉命令。那麼,我們只需要呼叫迴圈函式,然後終止。不過對於架構而言,我們需要記住,程式的生命週期並不僅僅是迴圈。

int main(int argc, char **argv)
{
  // 如果有配置檔案,則載入。

  // 執行命令迴圈
  lsh_loop();

  // 做一些關閉和清理工作。

  return EXIT_SUCCESS;
}
複製程式碼

這裡你可以看到,我只是寫了一個函式:lsh_loop()。這個函式會迴圈,並解釋執行一條條命令。我們接下來會看到這個迴圈如何實現。

Shell 的基本迴圈

我們已經知道了 shell 程式如何啟動。現在考慮程式的基本邏輯:shell 在它的迴圈中會做什麼?處理命令的一個簡單的方式是採用這三步:

  • 讀取:從標準輸入讀取一個命令。
  • 分析:將命令字串分割為程式名和引數。
  • 執行:執行分析後的命令。

下面,我將這些思路轉化為 lsh_loop() 的程式碼:

void lsh_loop(void)
{
  char *line;
  char **args;
  int status;

  do {
    printf("> ");
    line = lsh_read_line();
    args = lsh_split_line(line);
    status = lsh_execute(args);

    free(line);
    free(args);
  } while (status);
}
複製程式碼

讓我們看一遍這段程式碼。一開始的幾行只是一些宣告。Do-while 迴圈在檢查狀態變數時會更方便,因為它會在檢查變數的值之前先執行一次。在迴圈內部,我們列印了一個提示符,呼叫函式來分別讀取一行輸入、將一行分割為引數,以及執行這些引數。最後,我們釋放之前為 line 和 args 申請的記憶體空間。注意到我們使用 lsh_execute() 返回的狀態變數決定何時退出迴圈。

讀取一行輸入

從標準輸入讀取一行聽起來很簡單,但用 C 語言做起來可能有一定難度。壞訊息是,你沒法預先知道使用者會在 shell 中鍵入多長的文字。因此你不能簡單地分配一塊空間,希望能裝得下使用者的輸入,而應該先暫時分配一定長度的空間,當確實裝不下使用者的輸入時,再重新分配更多的空間。這是 C 語言中的一個常見策略,我們也會用這個方法來實現 lsh_read_line()

#define LSH_RL_BUFSIZE 1024
char *lsh_read_line(void)
{
  int bufsize = LSH_RL_BUFSIZE;
  int position = 0;
  char *buffer = malloc(sizeof(char) * bufsize);
  int c;

  if (!buffer) {
    fprintf(stderr, "lsh: allocation error\n");
    exit(EXIT_FAILURE);
  }

  while (1) {
    // 讀一個字元
    c = getchar();

    // 如果我們到達了 EOF, 就將其替換為 '\0' 並返回。
    if (c == EOF || c == '\n') {
      buffer[position] = '\0';
      return buffer;
    } else {
      buffer[position] = c;
    }
    position++;

    // 如果我們超出了 buffer 的大小,則重新分配。
    if (position >= bufsize) {
      bufsize += LSH_RL_BUFSIZE;
      buffer = realloc(buffer, bufsize);
      if (!buffer) {
        fprintf(stderr, "lsh: allocation error\n");
        exit(EXIT_FAILURE);
      }
    }
  }
}
複製程式碼

第一部分是很多的宣告。也許你沒有發現,我傾向於使用古老的 C 語言風格,將變數的宣告放在其他程式碼前面。這個函式的重點在(顯然是無限的)while (1) 迴圈中。在這個迴圈中,我們讀取了一個字元(並將它儲存為 int 型別,而不是 char 型別,這很重要!EOF 是一個整型值而不是字元型值。如果你想將它的值作為判斷條件,需要使用 int 型別。這是 C 語言初學者常犯的錯誤。)。如果這個字元是換行符或者 EOF,我們將當前字串用空字元結尾,並返回它。否則,我們將這個字元新增到當前的字串中。

下一步,我們檢查下一個字元是否會超出當前的緩衝區大小。如果會超出,我們就先重新分配緩衝區(並檢查記憶體分配是否成功)。就是這樣。

如果你對新版的 C 標準庫很熟悉,會注意到 stdio.h 中有一個 getline() 函式,和我們剛才實現的功能幾乎一樣。實話說,我在寫完上面這段程式碼之後才知道這個函式的存在。這個函式一直是 C 標準庫的 GNU 擴充套件,直到 2008 年才加入規約中,大多數現代的 Unix 系統應該都已經有了這個函式。我會保持我已寫的程式碼,我也鼓勵你們先用這種方式學習,然後再使用 getline。否則,你會失去一次學習的機會!不管怎樣,有了 getline 之後,這個函式就不重要了:

char *lsh_read_line(void)
{
  char *line = NULL;
  ssize_t bufsize = 0; // 利用 getline 幫助我們分配緩衝區
  getline(&line, &bufsize, stdin);
  return line;
}
複製程式碼

分析一行輸入

好,那我們回到最初的那個迴圈。我們目前實現了 lsh_read_line(),得到了一行輸入。現在,我們需要將這一行解析為引數的列表。我在這裡將會做一個巨大的簡化,假設我們的命令列引數中不允許使用引號和反斜槓轉義,而是簡單地使用空白字元作為引數間的分隔。這樣的話,命令 echo "this message" 就不是使用單個引數 this message 呼叫 echo,而是有兩個引數: "thismessage"

有了這些簡化,我們需要做的只是使用空白符作為分隔符標記字串。這意味著我們可以使用傳統的庫函式 strtok 來為我們幹些苦力活。

#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line)
{
  int bufsize = LSH_TOK_BUFSIZE, position = 0;
  char **tokens = malloc(bufsize * sizeof(char*));
  char *token;

  if (!tokens) {
    fprintf(stderr, "lsh: allocation error\n");
    exit(EXIT_FAILURE);
  }

  token = strtok(line, LSH_TOK_DELIM);
  while (token != NULL) {
    tokens[position] = token;
    position++;

    if (position >= bufsize) {
      bufsize += LSH_TOK_BUFSIZE;
      tokens = realloc(tokens, bufsize * sizeof(char*));
      if (!tokens) {
        fprintf(stderr, "lsh: allocation error\n");
        exit(EXIT_FAILURE);
      }
    }

    token = strtok(NULL, LSH_TOK_DELIM);
  }
  tokens[position] = NULL;
  return tokens;
}
複製程式碼

這段程式碼看起來和 lsh_read_line() 極其相似。這是因為它們就是很相似!我們使用了相同的策略 —— 使用一個緩衝區,並且將其動態地擴充套件。不過這裡我們使用的是以空指標結尾的指標陣列,而不是以空字元結尾的字元陣列。

在函式的開始處,我們開始呼叫 strtok 來分割 token。這個函式會返回指向第一個 token 的指標。strtok() 實際上做的是返回指向你傳入的字串內部的指標,並在每個 token 的結尾處放置位元組 \0。我們將每個返回的指標放在一個字元指標的陣列(緩衝區)中。

最後,我們在必要時重新分配指標陣列。這樣的處理過程一直重複,直到 strtok 不再返回 token 為止。此時,我們將 token 列表的尾部設為空指標。

這樣,我們的工作完成了,我們得到了 token 的陣列。接下來我們就可以執行命令。那麼問題來了,我們怎麼去執行命令呢?

Shell 如何啟動程式

現在,我們真正來到了 shell 的核心位置。Shell 的主要功能就是啟動程式。所以寫一個 shell 意味著你要很清楚程式中發生了什麼,以及程式是如何啟動的。因此這裡我要暫時岔開話題,聊一聊 Unix 中的程式。

在 Unix 中,啟動程式只有兩種方式。第一種(其實不能算一種方式)是成為 Init 程式。當 Unix 機器啟動時,它的核心會被載入。核心載入並初始化完成後,會啟動單獨一個程式,叫做 Init 程式。這個程式在機器開啟的時間中會一直執行,負責管理啟動其他的你需要的程式,這樣機器才能正常使用。

既然大部分的程式都不是 Init,那麼實際上就只有一種方式啟動程式:使用 fork() 系統呼叫。當呼叫該函式時,作業系統會將當前程式複製一份,並讓兩者同時執行。原有的程式叫做“父程式”,而新的程式叫做“子程式”。fork() 會在子程式中返回 0,在父程式中返回子程式的程式 ID 號(PID)。本質上,這意味著新程式啟動的唯一方法是複製一個已有的程式。

這看上去好像有點問題。特別是,當你想執行一個新的程式時,你肯定不希望再執行一遍相同的程式 —— 你想執行的是另一個程式。這就是 exec() 系統呼叫所做的事情。它會將當前執行的程式替換為一個全新的程式。這意味著每當你呼叫 exec,作業系統都會停下你的程式,載入新的程式,然後在原處啟動新的程式。一個程式從來不會從 exec() 呼叫中返回(除非出現錯誤)。

有了這兩個系統呼叫,我們就有了大多數程式在 Unix 上執行的基本要素。首先,一個已有的程式將自己分叉(fork)為兩個不同的程式。然後,子程式使用 exec() 將自己正在執行的程式替換為一個新的程式。父程式可以繼續做其他的事情,甚至也可以使用系統呼叫 wait() 繼續關注子程式。

啊!我們講了這麼多。但是有了這些作為背景,下面啟動程式的程式碼才是說得通的:

int lsh_launch(char **args)
{
  pid_t pid, wpid;
  int status;

  pid = fork();
  if (pid == 0) {
    // 子程式
    if (execvp(args[0], args) == -1) {
      perror("lsh");
    }
    exit(EXIT_FAILURE);
  } else if (pid < 0) {
    // Fork 出錯
    perror("lsh");
  } else {
    // 父程式
    do {
      wpid = waitpid(pid, &status, WUNTRACED);
    } while (!WIFEXITED(status) && !WIFSIGNALED(status));
  }

  return 1;
}
複製程式碼

這個函式使用了我們之前建立的引數列表。然後,它 fork 當前的程式,並儲存返回值。當 fork() 返回時,我們實際上有了兩個併發執行的程式。子程式會進入第一個 if 分支(pid == 0)。

在子程式中,我們想要執行使用者提供的命令。所以,我們使用 exec 系統呼叫的多個變體之一:execvpexec 的不同變體做的事情稍有不同。一些接受變長的字串引數,一些接受字串的列表,還有一些允許你設定程式執行的環境。execvp 這個變體接受一個程式名和一個字串引數的陣列(也叫做向量(vector),因此是‘v’)(陣列的第一個元素應當是程式名)。‘p’ 表示我們不需要提供程式的檔案路徑,只需要提供檔名,讓作業系統搜尋程式檔案的路徑。

如果 exec 命令返回 -1(或者說,如果它返回了),我們就知道有地方出錯了。那麼,我們使用 perror 列印系統的錯誤訊息以及我們的程式名,讓使用者知道是哪裡出了錯。然後,我們讓 shell 繼續執行。

第二個 if 條件(pid < 0)檢查 fork() 是否出錯。如果出錯,我們列印錯誤,然後繼續執行 —— 除了告知使用者,我們不會進行更多的錯誤處理。我們讓使用者決定是否需要退出。

第三個 if 條件表明 fork() 成功執行。父程式會執行到這裡。我們知道子程式會執行命令的程式,所以父程式需要等待命令執行結束。我們使用 waitpid() 來等待一個程式改變狀態。不幸的是,waitpid() 有很多選項(就像 exec() 一樣)。程式可以以很多種方式改變其狀態,並不是所有的狀態都表示程式結束。一個程式可能退出(正常退出,或者返回一個錯誤碼),也可能被一個訊號終止。所以,我們需要使用 waitpid() 提供的巨集來等待程式退出或被終止。函式最終返回 1,提示上層函式需要繼續提示使用者輸入了。

Shell 內建函式

你可能發現了,lsh_loop() 函式呼叫了 lsh_execute()。但上面我們寫的函式卻叫做 lsh_launch()。這是有意為之的。雖然 shell 執行的命令大部分是程式,但有一些不是。一些命令是 shell 內建的。

這裡的原因其實很簡單。如果你想改變當前目錄,你需要使用函式 chdir()。問題是,當前目錄是程式的一個屬性。那麼,如果你寫了一個叫 cd 的程式來改變當前目錄,它只會改變自己當前的目錄,然後終止。它的父程式的當前目錄不會改變。所以應當是 shell 程式自己執行 chdir(),才能更新自己的當前目錄。然後,當它啟動子程式時,子程式也會繼承這個新的目錄。

類似的,如果有一個程式叫做 exit,它也沒有辦法使呼叫它的 shell 退出。這個命令也必須內建在 shell 中。還有,多數 shell 通過執行配置指令碼(如 ~/.bashrc)來進行配置。這些指令碼使用一些改變 shell 行為的命令。這些命令如果由 shell 自己實現的話,同樣只會改變 shell 自己的行為。

因此,我們需要向 shell 本身新增一些命令是有道理的。我新增進我的 shell 的命令是 cdexithelp。下面是他們的函式實現:

/*
  內建 shell 命令的函式宣告:
 */
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);

/*
  內建命令列表,以及它們對應的函式。
 */
char *builtin_str[] = {
  "cd",
  "help",
  "exit"
};

int (*builtin_func[]) (char **) = {
  &lsh_cd,
  &lsh_help,
  &lsh_exit
};

int lsh_num_builtins() {
  return sizeof(builtin_str) / sizeof(char *);
}

/*
  內建命令的函式實現。
*/
int lsh_cd(char **args)
{
  if (args[1] == NULL) {
    fprintf(stderr, "lsh: expected argument to \"cd\"\n");
  } else {
    if (chdir(args[1]) != 0) {
      perror("lsh");
    }
  }
  return 1;
}

int lsh_help(char **args)
{
  int i;
  printf("Stephen Brennan's LSH\n");
  printf("Type program names and arguments, and hit enter.\n");
  printf("The following are built in:\n");

  for (i = 0; i < lsh_num_builtins(); i++) {
    printf("  %s\n", builtin_str[i]);
  }

  printf("Use the man command for information on other programs.\n");
  return 1;
}

int lsh_exit(char **args)
{
  return 0;
}
複製程式碼

這段程式碼有三個部分。第一部分包括我的函式的前置宣告。前置宣告是當你宣告瞭(但還未定義)某個符號,就可以在它的定義之前使用。我這麼做是因為 lsh_help() 使用了內建命令的陣列,而這個陣列中又包括 lsh_help()。打破這個依賴迴圈的最好方式是使用前置宣告。

第二個部分是內建命令名字的陣列,然後是它們對應的函式的陣列。這樣做是為了,在未來可以簡單地通過修改這些陣列來新增內建命令,而不是修改程式碼中某處一個龐大的“switch”語句。如果你不理解 builtin_func 的宣告,這很正常!我也不理解。這是一個函式指標(一個接受字串陣列作為引數,返回整型的函式)的陣列。C 語言中任何有關函式指標的宣告都會很複雜。我自己仍然需要查一下函式指標是怎麼宣告的!

最後,我實現了每個函式。lsh_cd() 函式首先檢查它的第二個引數是否存在,不存在的話列印錯誤訊息。然後,它呼叫 chdir(),檢查是否出錯,並返回。幫助函式會列印漂亮的訊息,以及所有內建函式的名字。退出函式返回 0,這是讓命令迴圈退出的訊號。

組合內建命令與程式

我們的程式最後缺失的一部分就是實現 lsh_execute() 了。這個函式要麼啟動一個內建命令,要麼啟動一個程式。如果你一路讀到了這裡,你會知道我們只剩下一個非常簡單的函式需要實現了:

int lsh_execute(char **args)
{
  int i;

  if (args[0] == NULL) {
    // 使用者輸入了一個空命令
    return 1;
  }

  for (i = 0; i < lsh_num_builtins(); i++) {
    if (strcmp(args[0], builtin_str[i]) == 0) {
      return (*builtin_func[i])(args);
    }
  }

  return lsh_launch(args);
}
複製程式碼

這個函式所做的不過是檢查命令是否和各個內建命令相同,如果相同的話就執行內建命令。如果沒有匹配到一個內建命令,我們會呼叫 lsh_launch() 來啟動程式。需要注意的是,有可能使用者輸入了一個空字串或字串只有空白符,此時 args 只包含空指標。所以,我們需要在一開始檢查這種情況。

全部組合在一起

以上就是這個 shell 的全部程式碼了。如果你已經讀完,你應該完全理解了 shell 是如何工作的。要試用它(在 Linux 機器上)的話,你需要將這些程式碼片段複製到一個檔案中(main.c),然後編譯它。確保程式碼中只包括一個 lsh_read_line() 的實現。你需要在檔案的頂部包含以下的標頭檔案。我新增了註釋,以便你知道每個函式的來源。

  • #include <sys/wait.h>
    • waitpid() 及其相關的巨集
  • #include <unistd.h>
    • chdir()
    • fork()
    • exec()
    • pid_t
  • #include <stdlib.h>
    • malloc()
    • realloc()
    • free()
    • exit()
    • execvp()
    • EXIT_SUCCESS, EXIT_FAILURE
  • #include <stdio.h>
    • fprintf()
    • printf()
    • stderr
    • getchar()
    • perror()
  • #include <string.h>
    • strcmp()
    • strtok()

當你準備好了程式碼和標頭檔案,簡單地執行 gcc -o main main.c 進行編譯,然後 ./main 來執行即可。

或者,你可以從 GitHub 上獲取程式碼。這個連結直接跳轉到我寫這篇文章時的程式碼當前版本 —— 未來我可能會更新程式碼,增加一些新的功能。如果程式碼更新了,我會盡量在本文中更新程式碼的細節和實現思路。

結語

如果你讀了這篇文章,想知道我到底是怎麼知道如何使用這些系統呼叫的。答案很簡單:通過手冊頁(man pages)。在 man 3p 中有對每個系統呼叫的詳盡文件。如果你知道你要找什麼,只是想知道如何使用它,那麼手冊頁是你最好的朋友。如果你不知道 C 標準庫和 Unix 為你提供了什麼樣的介面,我推薦你閱讀 POSIX 規範,特別是第 13 章,“標頭檔案”。你可以找到每個標頭檔案,以及其中需要定義哪些內容。

顯然,這個 shell 的功能不夠豐富。一些明顯的遺漏有:

  • 只用了空白符分割引數,沒有考慮到引號和反斜槓轉義。
  • 沒有管道和重定向。
  • 內建命令太少。
  • 沒有萬用字元。

實現這幾個功能其實非常有趣,但已經遠不是我這樣一篇文章可以容納的了的了。如果我開始實現其中任何一項,我一定會寫一篇關於它的後續文章。不過我鼓勵讀者們都嘗試自己實現這些功能。如果你成功了,請在下面的評論區給我留言,我很樂意看到你的程式碼。

最後,感謝閱讀這篇教程(如果有人讀了的話)。我寫得很開心,也希望你能讀得開心。在評論區讓我知道你的想法!

更新: 在本文的較早版本中,我在 lsh_split_line() 中遇到了一些討厭的 bug,它們恰好相互抵消了。感謝 Reddit 的 /u/munmap(以及其他評論者)找到了這些 bug! 在這裡看看我究竟做錯了什麼。

更新二: 感謝 GitHub 使用者 ghswa 貢獻了我忘記的一些 malloc() 的空指標檢查。他/她還指出 getline手冊頁規定了第一個引數所佔用的記憶體空間應當可以被釋放,所以我的使用 getline()lsh_read_line() 實現中,line 應當初始化為 NULL

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章