如何用 C 语言写一个简单的 Unix Shell

Posted 程序员日志

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何用 C 语言写一个简单的 Unix Shell相关的知识,希望对你有一定的参考价值。

↑↑

当你决定关注「日志君」,你已然超越了99%的程序员

日志君导读:


本文是一篇译文,原作者是Indradhanus Gupt。 作者用 C 语言实现了一个简易的unix shell,通过本文可加深对 shell 和 Unix 系统原理的理解。


免责声明:我不是编写 shell 这个课题的专家,我是一边自学一边分享我的发现。

shell 是什么?

关于这一点已经有很多书面资料,所以对于它的定义我不会探讨太多细节。只用一句话说明:

shell 是允许你与操作系统的核心作交互的一个界面(interface)。

shell 是怎样工作的?

shell解析用户输入的命令并执行它。为了能做到这一点,shell的工作流程看起来像这样:

  1. 启动shell

  2. 等待用户输入

  3. 解析用户输入

  4. 执行命令并返回结果

  5. 回到第 2 步。

但在这整个流程中有一个重要的部分:进程。shell是父进程。这是我们的程序的主线程,它等待用户输入。然而,由于以下原因,我们不能在主线程自身中执行命令:

  1. 一个错误的命令会导致整个shell停止工作。我们要避免此情况。

  2. 独立的命令应该有他们自己的进程块。这被称为隔离,属于容错(机制)。

Fork

为了能避免此情况,我们使用系统调用 fork。我曾以为我理解了 fork,直到我用它写了大约4行代码(才发现我没有理解)。

fork 创建当前进程的一份拷贝。这份拷贝被称为“子进程”,系统中的每个进程都有与它联系在一起的唯一的进程 id(pid)。让我们看以下代码片段:

fork.c

#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() {    pid_t child_pid = fork();    // The child process    if (child_pid == 0) {        printf("### Child ###nCurrent PID: %d and Child PID: %dn",               getpid(), child_pid);    } else {        printf("### Parent ###nCurrent PID: %d and Child PID: %dn",               getpid(), child_pid);    }    return 0; }

fork 系统调用返回两次,每个进程一次。这一开始听起来是反直觉的。但让我们看一下在底层发生了什么。

  1. 通过调用 fork,我们在程序中创建了一个新的分支。这与传统的 if-else 分支不同。fork 对当前进程创建一份拷贝并从中创建了一个新的进程。最终系统调用返回子进程的进程 id。

  2. 一旦 fork 调用成功,子进程和父进程(我们的代码的主线程)会同时运行。

为了让你更好理解程序流程,看这个图:


fork

fork() 创建了一个新的子进程,但与此同时,父进程的执行并没有停止。子进程执行的开始和结束独立于父进程,反之亦然。

更进一步讨论以前,先说明一点:getpid 系统调用返回当前的进程 id。

如果你编译并执行这段代码,会得到类似于下面的输出:

### Parent ### Current PID: 85247 and Child PID: 85248 ### Child ### Current PID: 85248 and Child PID: 0

在 ### Parent ### 下面的片段中,当前进程 ID 是 85247,子进程 ID 是 85248。注意,子进程的 pid 比父进程的大,表明子进程是在父进程之后创建的。(更新:正如某人在 Hacker News 上正确指出的,这并不是确定的,虽然往往是这样。原因在于,操作系统可能回收无用的老进程 id。)

在 ### Child ### 下面的片段中,当前进程 ID 是 85248,这与前面片段中子进程的 pid 相同。然而,这里的子进程 pid 为 0。

实际的数字会随着每一次执行而变化。

你可能在想,我们已经在第 9 行明确的给 child_pid 赋了一个值(译者注:应该是第7行),那么 child_pid 怎么会在同一个执行流程中呈现两个不同的值,这种想法值得原谅。但是,回想一下,调用 fork 创建了一个新进程,这个新进程与当前进程相同。因此,在父进程中,child_pid 是刚创建的子进程的实际值,而子进程本身没有自己的子进程,所以 child_pid 的值为 0。

因此,为了控制哪些代码在子进程中执行,哪些又在父进程中执行,需要我们在 12 到 16 行定义的 if-else 块(译者注:应该是 10 到 16 行)。当 child_pid 为 0 时,代码块将在子进程下执行,而 else 块却会在父进程下执行。这些块被执行的顺序是不确定的,取决于操作系统的调度程序。

引入确定性

让我向你介绍系统调用 sleep。引用 linux man 页面的话:

sleep – 暂停执行一段时间

时间间隔以秒为单位。

让我们给父进程,即我们代码中的 else 块,加一个 sleep(1) 调用:

sleep_parent.c

#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() {    pid_t child_pid = fork();    // The child process    if (child_pid == 0) {        printf("### Child ###nCurrent PID: %d and Child PID: %dn",               getpid(), child_pid);    } else {        sleep(1); // Sleep for one second        printf("### Parent ###nCurrent PID: %d and Child PID: %dn",               getpid(), child_pid);    }    return 0; }

当你执行这段代码时,输出将类似这样:

### Child ### Current PID: 89743 and Child PID: 0

1秒钟以后,你将看到

### Parent ### Current PID: 89742 and Child PID: 89743

每次执行这段代码时你会看到同样的表现。这是因为:我们在父进程中做了一个阻塞性的 sleep 调用,与此同时,操作系统调度程序发现有空闲的 CPU 时间可以给子进程执行。

类似的,如果你反过来,把 sleep(1) 调用加到子进程,也就是我们代码中的 if 块里面,你会发现父进程块立刻输出到控制台上。但你也会发现程序终止了。子进程块的输出被转存到标准输出。看起来是这样:

$ gcc -lreadline blog/sleep_child.c -o sleep_child && ./sleep_child ### Parent ### Current PID: 23011 and Child PID: 23012 $ ### Child ### Current PID: 23012 and Child PID: 0

这段源代码可在 sleep_child.c 获取。

这是因为父进程在 printf 语句之后无事可做,被终止了。然而,子进程在 sleep 调用处被阻塞了 1 秒钟,之后才执行 printf 语句。

正确实现的确定性

然而,使用 sleep 来控制进程的执行流程不是最好的方法,因为你做了一个 n 秒的 sleep 调用:

  1. 你怎么确保不管你等待的是什么,都会在 n 秒内完成执行呢?

  2. 不管你等待的是什么,要是它在远远早于 n 秒时就结束了呢?在此情况下你不必要地闲置了。

有一种更好的方法是,使用 wait 系统调用(或一种变体)来代替。我们将使用 waitpid 系统调用。它带有以下参数:

  1. 你想要程序等待的进程的进程 ID。

  2. 一个变量,用来保存进程如何终止的相关信息。

  3. 选项标志,用来定制 waitpid 的行为

wait.c

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() {    pid_t child_pid;    pid_t wait_result;    int stat_loc;    child_pid = fork();    // The child process    if (child_pid == 0) {        printf("### Child ###nCurrent PID: %d and Child PID: %dn",               getpid(), child_pid);        sleep(1); // Sleep for one second    } else {        wait_result = waitpid(child_pid, &stat_loc, WUNTRACED);        printf("### Parent ###nCurrent PID: %d and Child PID: %dn",               getpid(), child_pid);    }    return 0; }

当你执行这段代码,你会发现子进程块立刻被打印,然后等待很短的一段时间(这里我们在 printf 后面加了 sleep)。父进程等待子进程执行结束,之后就有空执行它自己的命令。

这里将介绍 exec 函数家族。即以下函数:

  • execl

  • execv

  • execle

  • execve

  • execlp

  • execvp

为了满足需要,我们将使用 execvp,它的签名看起来像这样:

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

函数名中的 vp 表明:它接受一个文件名,将在系统 $PATH 变量中搜索此文件名,它还接受将要执行的一组参数。

你可以阅读 exec 的 man 页面 以得到其它函数的更多信息。

让我们看一下以下代码,它执行命令 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

它和你在你的主 shell 中手动执行ls -l -h -a的结果完全相同。

既然我们能执行命令了,我们需要使用在第一部分中学到的fork 系统调用构建有用的东西。事实上我们要做到以下这些:

  1. 当用户输入时接受命令。

  2. 调用 fork 以创建一个子进程。

  3. 在子进程中执行命令,同时父进程等待命令完成。

  4. 回到第一步。

我们看看下面的函数,它接收一个字符串作为输入。我们使用库函数 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; }

如果该函数的输入是字符串 “ls -l -h -a”,那么函数将会创建这样形式的一个数组:[“ls”, “-l”, “-h”, “-a”, NULL],并且返回指向此队列的指针。

现在,我们在主函数中调用 readline 来读取用户的输入,并将它传给我们刚刚在上面定义的 get_input。一旦输入被解析,我们在子进程中调用 fork 和 execvp。在研究代码以前,看一下下面的图片,先理解 execvp 的含义:

当 fork 命令完成后,子进程是父进程的一份精确的拷贝。然而,当我们调用 execvp 时,它将当前程序替换为在参数中传递给它的程序。这意味着,虽然进程的当前文本、数据、堆栈段被替换了,进程 id 仍保持不变,但程序完全被覆盖了。如果调用成功了,那么 execvp 将不会返回,并且子进程中在这之后的任何代码都不会被执行。这里是主函数:

#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 successuln");        } else {            waitpid(child_pid, &stat_loc, WUNTRACED);        }        free(input);        free(command);    }    return 0; }

全部代码可在此处的单个文件中获取。如果你用 gcc -g -lreadline shell.c 编译它,并执行二进制文件,你会得到一个最小的可工作 shell,你可以用它来运行系统命令,比如 pwd 和 ls -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 – 如果操作系统内存耗尽或是进程数量已经到了允许的最大值,子进程就无法创建,会返回 -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 – It can fail if the OS runs out of memory. We should exit the program in such a scenario:

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不是ls或pwd这样的系统程序。让我们后退一步,暂时假设cd 也是一个系统程序。你认为执行流程会是什么样?在继续阅读之前,你可能想要思考一下。

流程是这样的:

  1. 用户输入 cd /。

  2. shell对当前进程作 fork,并在子进程中执行命令。

  3. 在成功调用后,子进程退出,控制权还给父进程。

  4. 父进程的当前工作目录没有改变,因为命令是在子进程中执行的。因此,cd 命令虽然成功了,但并没有产生我们想要的结果。

因此,要支持 cd,我们必须自己实现它。我们也需要确保,如果用户输入的命令是 cd(或属于预定义的内建命令),我们根本不要 fork 进程。相反地,我们将执行我们对 cd(或任何其它内建命令)的实现,并继续等待用户的下一次输入。,幸运的是我们可以利用 chdir 函数调用,它用起来很简单。它接受路径作为参数,如果成功则返回0,失败则返回 -1。我们定义函数:

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

并且在我们的主函数中为它加入一个检查:

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)的处理。敬请期待。





程序员日志

打造面向资深开发者的第一新媒体

深度有料有意思

【欢迎投稿】



以上是关于如何用 C 语言写一个简单的 Unix Shell的主要内容,如果未能解决你的问题,请参考以下文章

如何用GCC在linux下编译C语言程序

如何用GCC在linux下编译C语言程序?

linux下如何用c语言调用shell命令

如何用E语言写一个程序把指定DLL文件放入指定目录

linux下如何用c语言调用shell命令

如何用SHELL命令运行一个文件