自己动手写Shell:I/O重定向和管道

介绍

自己动手写Shell:基本功能一文介绍了如何实现Shell的基本功能,本文介绍如何实现I/O重定向和管道。

I/O重定向使得程序可以自由地指定数据的流向,不一定从键盘读取数据或输出结果到屏幕上;管道使得一条命令的输出可以作为另一条命令的输入,多条命令可以配合完成一项任务。例如下面的命令:

1
2
3
4
5
6
7
8
9
$ ls -l > 1.txt
$ cat 1.txt
total 24
-rw-r--r-- 1 krist users 0 Apr 15 16:37 1.txt
-rw-r--r-- 1 krist users 86 Apr 15 09:56 Makefile
-rw-r--r-- 1 krist users 13177 Apr 15 09:56 psh.c
-rw-r--r-- 1 krist users 21 Apr 15 10:05 README.md
$ cat < 1.txt | wc -l
5

第一条命令中的>符号表示将ls -l命令的输出结果重定向到文件1.txt。最后一条命令中的<符号表示将文件1.txt的内容作为cat命令的输入(cat < 1.txt等价于cat 1.txt),|符号表示将前面命令的运行结果作为命令wc -l的输入,所以命令最终的运行结果为1.txt中文本的行数。

由于在下暂时才疏学浅,只好先实现简单的功能,传达基本精神即可。因此,本文的Shell实现对输入的命令进行限制:

  1. 一次只能有一种操作,即最多只能包含一个<>|符号,所以cat < 1.txt | wc -l是不合法的
  2. 三种符号的前后必须有空格,类似于ls>1.txtcat 1.txt| wc -l是不合法的

主流程

Shell会fork一个子程序运行用户输入命令。该子进程首先检查命令中是否包含<>|符号,以确定命令的类型,然后用做相应的处理。主流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pid_t pid = fork();
switch (pid) {
case -1:
perror("fork");
exit(EXIT_FAILURE);
case 0: // 子进程来执行命令
// 识别命令的类型
int symbol_pos; // 符号的位置
int cmd_type = check_cmd(arg_vec, &symbol_pos);
//
switch (cmd_type) {
case CMD_NORMAL:
exec_normal(arg_vec); // 普通命令
case CMD_INPUT_REDIRECT:
exec_input_redirect(arg_vec, symbol_pos); // 输入重定向
case CMD_OUTPUT_REDIRECT:
exec_output_redirect(arg_vec, symbol_pos); // 输出重定向
case CMD_PIPELINE:
exec_pipeline(arg_vec, symbol_pos); // 管道
case CMD_INVALID:
fprintf(stderr, "Invalid command!\n"); // 命令不合法
exit(EXIT_FAILURE);
default:
break;
}
default:
while (wait(&status) != pid); // 父进程等待子进程运行完毕
}

arg_vec变量是字符串数组,保存处理之后的用户命令。如果输入命令为ls -al | wc -l,那么该数组的内容为:

1
char *arg_vec[] = {"ls", "-al", "|", "wc", "-l", NULL};

实现原理

所有的系统调用(system call)都通过文件描述符(file descriptor)对各种类型的文件进行I/O操作。每个进程都维护自己的一组文件描述符。

一般来说,所有的程序都会使用三个标准的文件描述符0、1和2,分别对应着标准输入标准输出标准错误。当通过Shell运行命令时,这三个描述符在程序运行之前就会打开。准确来说,是程序继承了Shell的描述符,而Shell会一直保持这三个描述符是打开的。

程序会从标准输入读入数据,输出结果到标准输出,输出错误到标准错误。当我们使用交互式的Shell时,这三个描述符都连接到Shell所运行的终端上,所以程序会从键盘读取数据,然后运行,最后把结果和错误打印到屏幕上。所以,要进行I/O重定向和管道操作,就要重定向相应的文件描述符。

I/O重定向

输入重定向

程序从标准输入读取数据,如果将文件描述符0定位到一个文件上,那么此文件就成了标准输入的源。实现上述功能要用到dup2函数:

1
int dup2(int oldfd, int newfd);

dup2函数将oldfd文件描述符复制给newfd,如果newfd之前打开了,dup2会先将它关闭。将文件描述符0重定向到文件的步骤如下:

1
2
3
int fd = open(filename, O_RDONLY);  // 打开文件,描述符fd对应文件
dup2(fd, 0); // 将fd复制到0,此时0和fd都指向文件
close(fd); // 关闭fd,此时只有0指向文件

将标准输入重定向到文件后,再执行用户输入的命令,命令会从指定的文件中读取数据作为输入,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void exec_input_redirect(char **arg_vec, int pos)
{
// arg_vec保存用户输入的命令
// pos是符号'<'的位置
char *filename = arg_vec[pos+1]; // 符号'<'后面是文件名
arg_vec[pos] = NULL; // 符号'<'前面是命令

// 打开文件,将标准输入重定向到文件
int fdin = open(filename, O_RDONLY);
if (fdin == -1) {
perror("open");
exit(EXIT_FAILURE);
}
if (dup2(fdin, STDIN_FILENO) == -1) {
perror("dup2");
exit(EXIT_FAILURE);
}
if (close(fdin) == -1) {
perror("close");
exit(EXIT_FAILURE);
}

// 执行命令,文件内容成为命令的输入源
execvp(arg_vec[0], arg_vec);
perror("execvp");
exit(EXIT_FAILURE);
}

输出重定向

类似地,实现输出重定向需将文件描述符1定位到文件上。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void exec_output_redirect(char **arg_vec, int pos)
{
// arg_vec保存用户输入的命令
// pos是符号'>'的位置
char *filename = arg_vec[pos + 1]; // 符号'>'后面是文件名
arg_vec[pos] = NULL; // 符号'>'前面是命令

// 打开文件,将标准输出重定向到文件
int fdout = open(filename, O_WRONLY | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (fdout == -1) {
perror("open");
exit(EXIT_FAILURE);
}
if (dup2(fdout, STDOUT_FILENO) == -1) {
perror("dup2");
exit(EXIT_FAILURE);
}
if (close(fdout) == -1) {
perror("close");
exit(EXIT_FAILURE);
}

// 执行命令,结果会输出到文件中
execvp(arg_vec[0], arg_vec);
perror("execvp");
exit(EXIT_FAILURE);
}

管道

管道(pipe)是进程间通信的重要手段之一。调用pipe函数创建一个管道,并将其两端连接到两个文件描述符,其中pipefd[0]为读数据端的文件描述符,pipefd[1]为写数据端的文件描述符:

1
int pipe(int pipefd[2])

当进程创建一个管道之后,该进程就有了连向管道两端的连接(即为两个文件描述符)。当该进程fork一个子进程时,子进程也继承了这两个连向管道的连接,如下面左图所示。父进程和子进程都可以将数据写到管道的写数据端口,并从读数据端口将数据读出。两个进程都可以读写管道,但当一个进程读,另一个进程写时,管道的使用效率是最高的,因此,每个进程最好关闭管道的一端,如下面右图所示。

shell_pipeline_1

Shell要实现管道功能,需将前一条命令的输出作为后一条命令的输入。那么以上面右图为基础,还需将前一进程的标准输出重定向到管道的写数据端,将后一进程的标准输入重定向到管道的读数据端,如下图所示:

shell_pipeline_2

我们将运行整条命令的子进程称为进程A,本文Shell的实现中,进程A并不执行命令,而是再fork两个进程,称之为进程B1和B2,分别执行两条命令。两个进程都从进程A继承了管道两端的连接,可通过该管道通信。进程A不再需要管道连接,于是关闭两个文件描述符,然后等待进程B1和B2执行完毕。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
void exec_pipeline(char **arg_vec, int pos)
{
// arg_vec保存用户输入的命令
// pos是符号'|'的位置
char **arg_vec1 = &arg_vec[0]; // 第一条命令CMD 1
arg_vec[pos] = NULL; // 两条命令的分界
char **arg_vec2 = &arg_vec[pos+1]; // 第二条命令CMD 2

// 创建管道
int pfd[2];
if (pipe(pfd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}

// 创建进程B1,执行CMD 1
switch (fork()) {
case -1:
perror("fork");
exit(EXIT_FAILURE);
case 0:
// 关闭管道的读数据端
if (close(pfd[0]) == -1) {
perror("close");
exit(EXIT_FAILURE);
}

if (pfd[1] != STDOUT_FILENO) { // 防御性编程
// 将标准输出重定向到管道的写数据端
if (dup2(pfd[1], STDOUT_FILENO) == -1) {
perror("dup2");
exit(EXIT_FAILURE);
}
if (close(pfd[1]) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
}
// 执行CMD 1,运行结果将写入管道
execvp(arg_vec1[0], arg_vec1);
perror("execvp");
exit(EXIT_FAILURE);
default:
break;
}

// 创建进程B2,执行CMD 2
switch (fork()) {
case -1:
perror("fork");
exit(EXIT_FAILURE);
case 0:
// 关闭管道的写数据端
if (close(pfd[1]) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
if (pfd[0] != STDIN_FILENO) { // 防御性编程
// 将标准输入重定向到管道的读数据端
if (dup2(pfd[0], STDIN_FILENO) == -1) {
perror("dup2");
exit(EXIT_FAILURE);
}
if (close(pfd[0]) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
}
// 运行CMD 2,从管道中读数据作为输入
execvp(arg_vec2[0], arg_vec2);
perror("execvp");
exit(EXIT_FAILURE);
default:
break;
}

// 进程A不需要管道通信,关闭管道的两端
if (close(pfd[0]) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
if (close(pfd[1]) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
// 进程A等待两个子进程执行完毕
if (wait(NULL) == -1) {
perror("wait");
exit(EXIT_FAILURE);
}
if (wait(NULL) == -1) {
perror("wait");
exit(EXIT_FAILURE);
}

exit(EXIT_SUCCESS);
}

完整的代码请参考这里

参考

  1. The Linux Programming Interface
  2. Unix/Linux编程实践教程