漫谈Unix Shell

用过Linux系统的开发者应该都知道shell,它是类Unix操作系统的标配。操作系统的使用者通过shell调用系统或者自己实现的命令,完成任务。同时shell提供了很多好用的机制,输入输出重定向、管道等,使得用户可以通过不同命令的组合完成复杂的任务。本文主要介绍shell的基本原理和实现机制。

什么是shell

shell是用户和操作系统进行交互的命令行接口,它读取用户的输入并解释为对应的命令进行执行,并将命令执行的结果进行输出。shell命令最简单的形式如下:

1
cmd arg1 arg2 arg3

shell是一个应用程序,一个简单的shell的结构如下:

1
2
3
4
5
6
7
8
9
10
11
while (1) {
write (1, "$ ", 2); // 命令提示符
readcmd (cmd, args); // 分析用户输入
if ((pid = fork ()) == 0) { // fork() 如果是子进程
exec (cmd, args, 0); // exec() 执行命令
} else if (pid > 0) { // 如果是父进程
wait (0); // 等待子进程结束
} else {
perror ("fork");
}
}

fork和exec是Unixt提供的用来创建新进程的方式。fork会拷贝当前的进程空间(包括寄存器、内存、打开的文件等),子进程会有一个不同的进程ID(PID)并且会有一个父进程ID(PPID)指向原来的进程。父进程和子进程一个重要的不同点在于fork的返回,fork系统调用会返回两次,父进程的fork调用会返回子进程ID,子进程会返回0,通过fork的返回判断当前进程是父进程还是子进程。exec系统调用是Unix提供的用来替换当前进程空间的一种方式,它会加载指定命令到当前的进程空间并运行。

shell程序就是使用Unix提供的fork和exec来完成命令的调用。当用户输入命令以后,shell调用fork,然后等待子进程结束,子进程加载用户输入的命令到内存,并执行。

fork和exec的简单的流程示例如下图:

输入输出重定向

在类Unix系统中,所有的进程启动时都会有0、1、2三个文件描述符,其中0是标准输入,1是标准输出,2是标准错误输出。默认情况下,shell总是从标准输入读取命令,将结果输出到标准输出和标准错误,同时shell也提供了输入输出重定向的功能。

1
sh < script > out // 从script中读取命令,将结果输出到out中

Unix的进程PCB结构中有一个打开文件描述符的table,每次进程打开一个文件,操作系统从file_descriptor_table选择一个index最小的表项进行创建。另外,前面我们已经提到了当进程调用fork系统调用时,这个PCB结构都会被拷贝,这其中也包括了file_descriptor_table,因此父进程和子进程共享打开的文件。

所以,输入重定向的功能:

1
2
3
4
sh < scirpt;
close(0);
open("script", 0666);

关闭fd 0也就是标准输入以后,打开script文件,此时fd 0的指向便从标准输入变成了script文件。剩下的操作仍然从fd 0读取,实现了输入的重定向。

输出的重定向与输入的重定向类似:

1
2
3
4
ls 2> out
close(2);
open("out", 0666);

一种特殊的情况,比如我们想把标准输出和错误输出一起指向同一个文件。

1
2
ls > out 2> out // 错误的做法
ls > out 2>&1 // 正确的做法

第一种做法之所以是错误的是因为这样做会两次打开out文件,两个文件描述符有各自独立的offset,每次写操作会导致相互覆盖(这里涉及到内核文件系统的实现,可以参考链接。正确的做法是下面的这种情况,首先关闭fd 1,打开out文件,然后关闭fd 2,dup(1),这样两个文件指向的是同一个全局文件描述符的结构,共享同一个offset。

1
2
close(2);
dup(1);

管道

Unix系统另外一个强大之处在于每个程序只要做好自己的工作,可以将多个应用程序组合在一起完成更加复杂的工作。例如要统计一个每行都是qq号的文件有多少个不同的号码。

1
sort < qq.in | unique | wc

其中|便是管道,管道的作用是将前一个程序的输出作为输入提供给下一个应用。管道也是unix操作系统中能够组合多个程序的基础。

在Unix操作系统中,pipe系统调用为实现上述的操作提供了前提。pipe(int pipefd[2])系统调用会创建一个单向的通道用于进程间通信。pipe中传入一个int pipefd[2]的数组,调用成功后pipefd中填入管道读写端的文件描述符,其中pipefd1作为管道的写端,pipefd[0]作为管道的读端。

1
2
3
4
5
6
7
int fds[2];
char buf[512];
int n;
pipe(fds);
write(fds[1], "hello", 5);
n = read(fds[0], buf, sizeof(buf));

结合管道和前面提到的输入输出重定向,在shell中创建管道后,fork得到的子进程会继承父进程创建的管道,管道左边命令进程的输出重定向到管道的写端,管道右边命令进程的输入重定向的管道的读端,就实现了管道的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cmd 1 | cmd 2 | ...
int fds[2];
if (pipe(fds) < 0) panic ("error");
if ((pid = fork ()) == 0) { child (left end of pipe)
close (1);
dup (fds[1]); // fds[1] is the write end, ret will be 1
close (fds[0]); // close read end
close (fds[1]); // close fds[1]
exec (command1, args1, 0);
} else if (pid > 0) { // parent (right end of pipe)
close (0);
dup (fds[0]); // fds[0] is the read end, ret will be 0
close (fds[0]);
close (fds[1]); // close write end
exec (command2, args2, 0);
} else {
printf ("Unable to fork\n");
}

由此可见,Unix的设计非常巧妙,这应该也是Unix后来取得成功的重要原因。