写一个自己的Unix Shell(2)将读入的字符变成命令

        在《写一个自己的UnixShell(1)搭建一个框架》这篇文章中,我们实现了一个基本的Shell程序框架,从终端中不断读取一行字符并打印的功能。接下来我们实现将读取的字符变为命令,在终端可以看到我们命令执行的结果的功能。就像下面图示中这样。

进入主题前,我们先看几个前置知识

什么是程序,什么是进程

        简单来说,程序就是存放在磁盘中的可以被我们执行的文件和一些其他的资源文件的集合,它们就静静地躺在磁盘中,等待着被运行。而进程就是进行中的程序,已经运行起来的程序。进程之间是相互独立的,比如运行起来的QQ和运行起来的微信,它们俩都是进程,但互不干扰,相互独立。

进程也可以创建进程

        正如人可以生小孩一样,进程也可以生孩子进程,我们把进程A创建的进程B叫进程A的子进程,进程A叫进程B的父进程。正如人生小孩会把父母的遗传物质给孩子,子进程也会有父进程的内存等资源信息,但父子进程已经是不同的进程了,它们可以做不同的事了。

如何创建子进程

        我们通过fork()系统调用(操作系统提供的API,在我们调用时就像调用一个函数)来创建子进程,下面来看看fork()函数的声明。

1
2
#include <unistd.h>
pid_t fork(void)

        fork()函数的声明在<unistd.h>头文件中。fork()函数返回子进程的进程ID(pid_t) , pid_t 是一个有符号整数。fork()函数返回两次,一次是父进程返回,一次是子进程返回,父进程返回时,返回的pid_t 大于0,子进程返回时,返回的pid_t 等于0。(因为fork()函数的返回值是子进程的进程ID,父进程刚生完孩子有子进程的进程ID,子进程没生孩子,所以子进程返回的pid_t 是0)当fork()函数返回的pid_t 小于0时,说明父进程没有把孩子生下来,难产了(发生了错误)。

父进程等待子进程退出

        在Shell程序中,我们需要父进程等待它创建的子进程退出后再执行,为什么呢?因为在Shell程序中,Shell程序本身是一个父进程,而我们输入的命令的执行是由子进程来完成的。拿ls命令来举例,我们输入ls命令后,终端会打印当前目录下的文件列表,打印当前目录下的文件列表这个功能是在Shell程序创建出的子进程中完成的,当子进程打印完当前目录下的文件列表后,父进程也就是Shell程序再输出类似 myshell# 这样的字符串等待下一个命令。如果父进程不等待子进程退出,继续往下执行,可能子进程还没有打印出当前目录下的文件列表,父进程就把myshell# 串输出了,这样显然是不对的。所以我们需要让父进程等待子进程退出后再继续执行。我们用wait()系统调用来实现这样的功能。下面来看看wait()函数的声明。

1
2
#include <sys/wait.h>
pid_t wait(int *status)

        wait()系统调用在<sys/wait.h>头文件中声明。wait()系统调用使父进程等待它的任意一个子进程退出后再继续执行,否则父进程会一直阻塞在那里。wait()系统调用返回结束的子进程的进程ID,如果返回-1,说明出错了。参数status用于存放子进程退出时的状态(成功,失败等),如果我们不关心子进程的退出状态,我们可以将status参数置为NULL。当然,wait()还有一个释放子进程资源的功能,否则子进程就成了“僵尸进程”(进程退出了,但资源没释放,半死不活的进程)。

改写《写一个自己的UnixShell(1)搭建一个框架》中的程序,使用创建子进程实现

        下面我们把《写一个自己的UnixShell(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
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
int main(int argc, char **argv) {

while(1) {
char *cmdString = NULL;
ssize_t nread = 0;
printf("myshell# ");
nread = getline(&cmdString, NULL, stdin);
if(nread < 0) {
fprintf(stderr, "main() getline()\n");
if(cmdString != NULL) {
free(cmdString);
}
exit(EXIT_FAILURE);
}
if(nread == 0) {
printf("\nBey~\n");
exit(EXIT_SUCCESS);
}
if(cmdString == NULL) {
fprintf(stderr, "main() cmdString = NULL\n");
exit(EXIT_FAILURE);
}
if(isSpaceStr(cmdString) == 1) {
free(cmdString);
continue;
}
//printf("%s", cmdString);
pid_t pid = -1;
//创建子进程
pid = fork();
if(pid < 0) {
fprintf(stderr, "main() fork()\n");
free(cmdString);
continue;
} else if(pid == 0) {
// 子进程打印字符串
printf("%s\n", cmdString);
// 释放子进程的内存,不会影响父进程
if(cmdString != NULL) {
free(cmdString);
}
// 子进程退出
exit(EXIT_SUCCESS);
}
// 父进程等待子进程退出
if(wait(NULL) < 0) {
fprintf(stderr, "main() wait()");
free(cmdString);
continue;
}
if(cmdString != NULL) {
free(cmdString);
}

}
return 0;
}

        我们只需将fork()wait()系统调用放入main()函数中即可实现在子进程打印一行字符的功能。

调用其它程序

        我们输入进Shell程序中的命令都是Shell程序来实现的吗?当然不是,实际上,正如Shell的中文翻译”壳”一样,Shell程序就是一个外壳,它不负责命令的具体实现,它是通过调用其它程序来实现这些命令的。例如在系统的/usr/bin//usr/sbin/ 等目录下,就存放着许多命令的具体实现程序,Shell程序正是通过调用它们来实现功能的。

        系统调用execve()可以将新程序加载到调用进程的内存空间,替换调用进程的原有部件,新的进程就可以从main()函数执行了。我们来看一看execve()系统调用的声明。

1
2
#include <unistd.h>
int execve(const char * __file, char * const * __argv, char * const * __envp)

        execve()系统调用在头文件<unistd.h>中声明,__file参数表示新的程序的文件路径;因为新的程序从main()函数开始执行,所以__argv参数相当于int main(int argc, char **argv)函数中的argv参数,程序会将execve()函数中的__argv参数的内容传入新程序的argv参数中,是一个以NULL结尾的字符串数组;__envp参数指定了新的程序的环境列表(什么是环境列表?就是几个类似于name=vlaue的字符串,比如:name=zhangsanagc=123,起个“环境列表”这个名字显得高大上些),它也是以NULL结尾的字符串数组。    execve()函数(因为调用系统调用和调用函数无异,我们都叫它们函数吧)调用成功不再返回,失败返回-1。下面我们写两个程序来使用一下execve()系统调用。

        程序 test_evecve:调用execve()系统调用来执行程序

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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char **argv) {
if(argc < 2) {
fprintf(stderr, "usage: test_execve <new progrom path>\n");
exit(EXIT_FAILURE);
}
// 新程序的路径
char *newProgramPath = argv[1];
// 设置 execve() 函数的 __argv 参数
char *__argv[10] = {NULL};
__argv[0] = newProgramPath;
__argv[1] = "hello";
__argv[2] = "I am zhang san";
__argv[3] = NULL;
// 设置 execve() 函数的 __envp 参数
char *__envp[10] = {NULL};
__envp[0] = "name=zhangsan";
__envp[1] = "abc=123";
__envp[2] = NULL;
// 调用 execve() 函数, 调用成功不返回,执行新程序,调用失败返回-1
execve(newProgramPath, __argv, __envp);
// 调用失败,打印错误
fprintf(stderr, "main() execve() failed\n");
exit(EXIT_FAILURE);
}

        程序p_args_envs:打印传入参数和环境列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
// 环境列表变量,里面保存着环境列表
extern char** environ;
int main(int argc, char **argv) {
// 打印argv
int i = 0;
printf("=========argv=============\n");
for(i = 0; i < argc; i++) {
printf("argv[%d]: %s\n", i, argv[i]);
}
// 打印环境列表
char **ep = environ;
int j = 0;
printf("=========envs=============\n");
while(*ep != NULL) {
printf("envs[%d]: %s\n", j, environ[j]);
j++;
ep++;
}
exit(EXIT_SUCCESS);
}

        执行 ./test_exevce程序,并将传入新程序p_args_envs的相对路径。执行结果如下图。

        上图就是我们在程序test_execve调用程序p_args_envs,由p_args_envs的打印结果。

        execve()函数是一个系统调用,也就是操作系统给我们提供的函数,在它之上,还有几个基于execve()系统调用的库函数,被称为exec()库函数(它们的功能都是调用新的程序)。它们的声明如下。

1
2
3
4
5
6
#include <unistd.h>
int execl(const char * __path, const char * __arg0, ...);
int execle(const char * __path, const char * __arg0, ...);
int execlp(const char * __file, const char * __arg0, ...);
int execv(const char * __path, char * const * __argv);
int execvp(const char * __file, char * const * __argv);

        我们可以看看上面五个函数声明的特点,它们都是以exec来开头,表明它们是exec()库函数,我们还看到有些函数有个l,有些函数有个e,有些函数有个p,有些函数有个v。下面来看看这些字母代表的含义。

  • l:list的缩写,表示以字符串列表的方式传入。字符串列表具体就是通过可变参数,而不是字符串数组。举个例子,用execl()函数来调用新程序的代码应该类似下面这样写。

    1
    execl("/new_program_path", "/new_program_path", "arg1", "arg2", (char *)NULL);

    当新程序从main()函数执行时,main()函数的argv中存放的有效内容就是execl()函数传入的__arg0和可变参数,上面的例子中就是:"/new_program_path", "arg1", "arg2"

  • e:envp的缩写,表示传入环境列表,也就是execve()中的第三个参数。举个例子,用execle()函数来调用新程序的的代码应该类似下面这样写。

    1
    2
    3
    4
    5
    6
    7
    char *envp[10] = {NULL};
    envp[0] = "name=zhangsan"
    envp[1] = "abc=123";
    envp[2] = NULL;
    execle("/new_program_path",
    "/new_program_path", "arg1", "arg2", (char *)NULL,
    envp);

    最后一个参数传入环境列表。exec库函数中没有带e的函数默认将调用进程的变量environ中的内容传给新的程序。

  • p:PATH的缩写,PATH是一个环境变量,就像C语言有全局变量一样,操作系统也有全局变量,不过为了显得高大上些,就把操作系统的全局变量称为环境变量。PATH就是一个环境变量,它的值是一个或多个操作系统的目录字符串,用冒号分割,就像下面这样

    1
    PATH=/bin:/usr/bin:/usr/sbin/:/usr/local/bin

    上面的环境变量PATH记录了操作系统的四个路径:“/bin”、“/usr/bin”、”/usr/sbin“和”/usr/local/bin“。

    exec()库函数中,带有字母p的表示使用环境变量PATH。我们可以直接传入一个文件名,它会去环境PATH的所有目录中去找我们传入的文件名,如果找到了我们传入的文件,并且是可以执行的文件的话,就会执行这个文件。相当于我们不用传入新的程序的完整路径了。当然,如果我们传入的文件名中带有/字符,它还是会按传入一个路径处理,而不去环境变量PATH中寻找。举个例子:函数execlp()的使用类似于下面这样。

    1
    2
    3
    execlp("ls", "ls", "-l", "-a", (char *)NULL);
    // 会到PATH环境变量中的所有目录中去寻找ls这个可执行文件,执行这个可执行文件,
    // 并传入-l和-a参数
  • v:vertor的缩写,中文是“向量”的意思。就是表示用以NULL结尾的字符串数组的方式来向新的程序传入参数列表。举个例子:execvp()函数的使用类似下面这样。

    1
    2
    3
    4
    5
    6
    char *argv[10] = {NULL};
    argv[0] = "ls";
    argv[1] = "-l";
    argv[2] = "-p";
    argv[3] = NULL;
    execvp("ls", argv);

ok,让我们总结一下exec()函数的差异。

函数 对程序文件的描述(-,p) 对参数的描述(l,v) 环境变量的来源(e,-)
execve() 路径名 数组 envp参数
execle() 路径名 列表(可变参数) envp参数
execlp() 文件名+PATH 列表(可变参数) 调用者的environ
execvp() 文件名+PATH 数组 调用者的environ
execv() 路径名 数组 调用者的environ
execle() 路径名 列表(可变参数) envp参数

进入正题,将输入的字符变为命令

        通过前文所述,我们知道了如何创建子进程,如何调用另一个程序,下面我们就把输入的那一行字符转换成调用其他程序的命令。

        我们用exec()函数中的execvp()函数来实现调用其他程序,这样我们就可以像在真正的Shell程序那样,输入一个文件名,Shell程序去环境变量PATH中所记录的目录中去寻找文件,从而执行了。前文可知,execvp()函数需要两个参数,文件名和参数数组。我们需要将我们输入的字符转成字符串数组的方式供execvp()使用。我们用parseCmdString()函数来实现将输入的字符串转为字符串数组的功能。函数定义如下所示。

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
#defile MAX_COMMAND_LEN(4096)
/*
des:
将命令字符串转为字符串数组
param:
cmdString[IN]: 命令字符串
return:
成功:以NULL结尾的字符串数组
失败:NULL
*/
char **parseCmdString(char *cmdString) {
int tokenCount = 0;
int ret = -1;
char tempCmdString1[MAX_COMMAND_LEN + 1] = {0};
char tempCmdString2[MAX_COMMAND_LEN + 1] = {0};
strcpy(tempCmdString1, cmdString);
strcpy(tempCmdString2, cmdString);
char *token = NULL;
char **argv = NULL;
// 检查字符串长度
if(strlen(cmdString) > MAX_COMMAND_LEN) {
fprintf(stderr, "parseCmdString() error: The command string is too long\n");
goto __finish;
}
// 分割字符串并计算小字符串数量
token = strtok(tempCmdString1, " \n\r\t");
while(token != NULL) {
tokenCount++;
token = strtok(NULL, " \n\r\t");
}
if(tokenCount == 0) {
fprintf(stderr, "parseCmdString() error: tokenCount = 0\n");
goto __finish;
}
// 分配内存
argv = malloc((tokenCount + 1) * sizeof(char **));
if(argv == NULL) {
fprintf(stderr, "parseCmdString() malloc() error: argv = NULL\n");
goto __finish;
}
for(int p = 0; p < tokenCount + 1; p++) {
argv[p] = NULL;
}
// 分割字符串并填充小字符串
token = strtok(tempCmdString2, " \n\r\t");
int i = 0;
while(token != NULL) {
argv[i] = malloc(strlen(token) + 1);
if(argv[i] == NULL) {
fprintf(stderr, "parseCmdString() malloc() error: argv[%d] = NULL\n", i);
goto __finish;
}
memset(argv[i], 0, strlen(token) + 1);
strcpy(argv[i], token);
token = strtok(NULL, " \n\r\t");
i++;
}
ret = 0;
__finish:
if(ret < 0 && argv != NULL) {
for(int i = 0; i < tokenCount + 1; i++) {
if(argv[i] != NULL) {
free(argv[i]);
}
}
free(argv);
}
return ret == 0 ? argv : NULL;
}

        在上面的函数中,我们对输入的字符串的长度做了限定,它的长度不能超过4096字节,然后将输入的字符串按空格,换行符,回车符和制表符分割成一个一个小字符串(比如字符串“ls -l -a”被分割为“ls”、“-l”和 “-a”),然后计算小字符串的数量,创建一个容量为字符串数量+1的字符串数组,多一个用来存放NULL(execvp__argv参数要求是以NULL结尾的字符串数组)。最后将每个小字符串复制到字符串数组中,返回这个字符串数组。这样我们就完成了从输入字符串到字符串数组的转变。

        接下来我们实现调用其他程序来执行我们的命令。我们用execCommand()函数来实现这个功能。下面是execCommand()函数的定义。

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
/*
des:
执行命令
param:
cmdString[IN]: 命令字符串
*/
void execCommand(char *cmdString) {
pid_t pid = -1;
pid = fork();
if(pid < 0) {
fprintf(stderr, "execCommand() fork() error\n");
} else if(pid == 0) {
// 子进程
// 获取argv数组
char *const *argv = NULL;
argv = parseCmdString(cmdString);
if(argv == NULL) {
fprintf(stderr, "execCommand() parseCmdString() error\n");
exit(EXIT_FAILURE);
}
// 执行execvp函数调用其他程序
if(execvp(argv[0], argv) < 0) {
perror("myshell");
}

} else {
// 父进程等待子进程退出
if(wait(NULL) < 0) {
fprintf(stderr, "execCommand() wait() error\n");
}
}
}

        经过前面的讲解,上面的代码应该很容易理解了,上面的代码中,父进程用fork()系统调用创建了子进程,在子进程中先将我们输入的字符串转成了字符串数组,然后调用evecvp()函数执行我们字符串中指定的新的程序,父进程等待新的程序退出后返回。

        最后,我们改一下《写一个自己的UnixShell(1)搭建一个框架》文章中的main()函数,让它调用execCommand()函数就可以了。

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
int main(int argc, char **argv) {

while(1) {
char *cmdString = NULL;
ssize_t nread = 0;
printf("myshell# ");
nread = getline(&cmdString, NULL, stdin);
if(nread < 0) {
fprintf(stderr, "main() getline()\n");
if(cmdString != NULL) {
free(cmdString);
}
exit(EXIT_FAILURE);
}
if(nread == 0) {
printf("\nBey~\n");
exit(EXIT_SUCCESS);
}
if(cmdString == NULL) {
fprintf(stderr, "main() cmdString = NULL\n");
exit(EXIT_FAILURE);
}
if(isSpaceStr(cmdString) == 1) {
free(cmdString);
continue;
}
// 在这里判断一下输入字符的数量是否超出限制
if(nread > MAX_COMMAND_LEN) {
fprintf(stderr, "myshell: The command is too long\n");
free(cmdString);
continue;
}
// 执行命令
execCommand(cmdString);
if(cmdString != NULL) {
free(cmdString);
}

}
return 0;
}

        现在,当我们输入ls等命令时,程序会调用execCommand()函数创建子进程,在子进程中将ls转成字符串数组,然后调用execvp()函数通过环境变量的PATH中的目录找到ls的可执行文件执行它。父进程等待ls的程序执行完毕后,打印出myshell# 字符串后等待下一次的输入。

        以上,便完成了我们制作自己的Unix Shell的第二步:将读入的字符变成命令。

附:

        欢迎大家关注我的微信公众号^_^。

dark

sans