这是 Writing a Unix Shell - Part II 的一篇中文翻译,版权属于原作者 INDRADHANUSH GUPTA。如果有的地方有更好的翻译,还望不吝赐教。如果英文尚可,建议直接查看原文。

第一部分翻译)我们讨论了 fork 这个系统调用以及在使用它时的一些注意事项。在这篇文章中,我们将探索如何执行命令。

Exec

我们将碰到 exec 函数家族。也就是说,它有以下这些函数:

  • execl
  • execv
  • execle
  • execve
  • execlp
  • execvp

对我们来说,我们将使用 execvp,它的函数签名看起来类似这样:

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

函数名里的 vp 表明(1)它接收一个文件名并在系统的环境变量 $PATH 里搜索这个文件名;(2)一个参数数组。

想要了解其他几个函数可以查看 exec 的手册

我们看下面这段代码,它将执行 ls -l -h -a 这个命令:

execvp.c

#include <unistd.h>

int main() {
    char *argv[] = {"ls", "-l", "-h", "-a", NULL};
    execvp(argv[0], argv);

    return 0;
}

关于 execvp 有几个注意点:

  1. 第一个参数是要执行的命令名;
  2. 第二个参数包括这个命令名以及要传给这个命令的参数。这个参数必须以 NULL 结尾;
  3. 当执行命令时它会换出当前的进程镜像。后面会详细讨论。

编译执行上面的代码,可以看到类似如下的输出:

total 32
drwxr-xr-x  5 dhanush  staff   170B Jun 11 11:32 .
drwxr-xr-x  4 dhanush  staff   136B Jun 11 11:30 ..
-rwxr-xr-x  1 dhanush  staff   8.7K Jun 11 11:32 a.out
drwxr-xr-x  3 dhanush  staff   102B Jun 11 11:32 a.out.dSYM
-rw-r--r--  1 dhanush  staff   130B Jun 11 11:32 execvp.c

这和你手动在 shell 里执行 ls -l -h -a 得到的结果一样。

既然我们以及可以执行命令了,我们需要用在第一部分翻译)里学到的 fork 命令来构造一些有用的东西。实际上,我们将做以下几件事:

  1. 接收用户输入的命令;
  2. 调用 fork 创建一个子进程;
  3. 在子进程执行这个命令,同时父进程等待子进程这个命令执行完成;
  4. 回到第一步。

我们看下面这个函数,它接收一个叫 input 的字符串。使用库函数 strtok 并以 空格 拆分这个字符串,它将返回一个字符串数组。同样,我们也用 NULL 作为这个字符串的结尾。

#include <stdlib.h>
#include <string.h>

char **get_input(char *input) {
    char **command = malloc(8 * sizeof(char *));
    char *separator = " ";
    char *parsed;
    int index = 0;

    parsed = strtok(input, separator);
    while (parsed != NULL) {
        command[index] = parsed;
        index++;

        parsed = strtok(NULL, separator);
    }

    command[index] = NULL;
    return command;
}

如果这个函数接收的参数 input 是字符串 ls -l -h -a 的话,那么它将创建一个形为 ["ls", "-l", "-h", "-a", NULL] 的数组,并返回这个数组的指针。

main 函数里,调用 readline 读取用户输入,并将它传给 get_input 函数,当输入解析完成,调用 fork 并在生成的子进程里调用 execvp。在深入到具体代码之前,先看下图来理解 execvp 的语义:

fork 调用完成,得到的子进程就是父进程的一份拷贝。然而,当我们调用 execvp 后,它将用传递进来的参数产生的程序代替当前程序。这意味着尽管当前进程的文本、数据和堆栈信息被替换,但是它的进程号不变,而程序则被完全重写了。如果调用成功,execvp 永远不会返回,并且子进程里任何这个调用之后的代码都不会执行。下面是 main 函数代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <readline/readline.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    char **command;
    char *input;
    pid_t child_pid;
    int stat_loc;

    while (1) {
        input = readline("unixsh> ");
        command = get_input(input);

        child_pid = fork();
        if (child_pid == 0) {
            /* Never returns if the call is successful */
            execvp(command[0], command);
            printf("This won't be printed if execvp is successul\n");
        } else {
            waitpid(child_pid, &stat_loc, WUNTRACED);
        }

        free(input);
        free(command);
    }

    return 0;
}

可以从这里获取完整代码。使用 gcc -g -lreadline shell.c 编译并执行,你将得到一个最小的可工作的 Shell,你可以用它执行系统命令,比如 pwdls -lha

unixsh> pwd
/Users/dhanush/github.com/indradhanush.github.io/code/shell-part-2
unixsh> ls -lha
total 28K
drwxr-xr-x 6 root root  204 Jun 11 18:27 .
drwxr-xr-x 3 root root 4.0K Jun 11 16:50 ..
-rwxr-xr-x 1 root root  16K Jun 11 18:27 a.out
drwxr-xr-x 3 root root  102 Jun 11 15:32 a.out.dSYM
-rw-r--r-- 1 root root  130 Jun 11 15:38 execvp.c
-rw-r--r-- 1 root root  997 Jun 11 18:25 shell.c
unixsh> 

注意到仅当用户输入命令后 fork 才被调用,这意味着它从父进程接收用户输入。

错误处理

目前为止我们都假设我们的命令会工作的非常完美而没有进行可能的错误处理,因此我们将对 shell.c 做一些改变:

  • fork 如果系统耗尽内存或者达到了所允许使用的最大进程数,那么子进程将不会被创建并且 fork 调用将返回 -1. 添加以下代码到我们的代码中:

      ...
      while (1) {
          input = readline("unixsh> ");
          command = get_input(input);
    
          child_pid = fork();
          if (child_pid < 0) {
              perror("Fork failed");
              exit(1);
          }
      ...
    
  • execvp 如上所说,如果调用成功的话它将永远不会返回,然而,如果调用失败它将返回 -1。同样地,修改调用 execvp 的代码:

       ...
              if (execvp(command[0], command) < 0) {
                  perror(command[0]);
                  exit(1);
              }
      ...
    

注意到尽管调用 fork 后调用 exit 会结束整个程序,但是因为后面的代码都是在子进程调用,因此 execvp 之后调用 exit 仅会结束子进程。

  • malloc 如果系统已耗尽内存 malloc 调用将会失败。在这种情况下我们应该退出程序:

      char **get_input(char *input) {
      char **command = malloc(8 * sizeof(char *));
      if (command == NULL) {
          perror("malloc failed");
          exit(1);
      }
      ...
    
  • 动态内存分配 目前我们的命令缓存只分配了8块内存,但是如果我们输入了一个超过8个字的命令,我们的程序将不会像期待的那样工作。我们保留这样的设计以使得这个例子尽可能简单,易于理解。并且我们把它作为一个练习留给读者。

可以从这里获取添加了错误处理的代码。

内建命令

当你尝试执行 cd 命令时,你会得到这样的错误:

cd: No such file or directory

我们的 Shell 程序目前还不能识别 cd 命令。背后的原因是由于 cd 不是系统命令,比如 lspwd。假如 cd 是一个系统命令,那么调用流程会是什么样呢?

流程处理类似下面这样:

  1. 用户输入 cd /
  2. shell 调用 fork 命令生成子进程并在子进程里执行用户输入的命令;
  3. 执行成功后,子进程将会退出并将控制权交还给父进程;
  4. 父进程的当前工作目录并不会改变,因为用户输入的命令是在子进程里执行的。因此,尽管成功执行 cd 命令,我们并没有得到想要的结果。

因此,为了支持 cd 调用我们将自己实现这个命令。并且,我们得确保如果用户输入的是 cd 命令(或者一系列内建命令),那么就不执行 fork 调用而是执行我们自己实现的 cd(或者其他内建命令)命令并等待用户的下一次输入。对于 cd 命令来说,谢天谢地的是我们有 chdir 函数可以直接调用。它接收一个路径字符串作为参数并在调用成功时返回0,失败时返回-1.

int cd(char *path) {
    return chdir(path);
}

main 函数里添加对这个命令的检查:

while (1) {
        input = readline("unixsh> ");
        command = get_input(input);

        if (strcmp(command[0], "cd") == 0) {
            if (cd(command[1]) < 0) {
                perror(command[1]);
            }

            /* Skip the fork */
            continue;
        }
...

修改后的代码可以从这里获取。编译执行后你就可以执行 cd 命令了,下面是一个样例输出:

unixsh> pwd
/Users/dhanush/github.com/indradhanush.github.io/code/shell-part-2
unixsh> cd /
unixsh> pwd
/
unixsh> 

以上就是第二部分的内容。所有样例代码都可以从这里获取。下一篇文章我们将探索信号这个话题并且实现用户中断处理(Ctrl-C)。敬请期待!

致谢

感谢 Dominic Spadacene 与我合作完成这篇文章,感谢 Saul Pwanson 帮助我解决了奇怪的内存泄漏问题。

更新:Saul 提到:通常来说,用 <0 来检查错误比用 ==-1 更好,因为有些 API 会返回-1之外的负值,因此用 <0 可以解决这种情况

资源