自己动手写Shell:基本功能

Shell的功能

Shell是管理进程和运行程序的程序。所有常用的Shell都有三个主要功能:

  • 运行程序:grep, date, ls等都是一些普通的程序,Shell将它们载入内存并运行它们
  • 管理输入和输出:使用<,>和 | 符号可以将输入、输出重定向。这样就可以告诉Shell将进程的输入和输出连接到一个文件或是其他的进程
  • 可编程:Shell同时也是带有变量和流程控制的编程语言

本文将介绍如何编写包含最基本功能,即能运行和终止程序的Shell。

Shell的主流程

Shell打印提示符、输入命令、运行命令,如此反复。因此,主流程由下面的循环组成:

1
2
3
4
5
for (;;) {
print_prompt(prompt_str);
handle_input(arg_buf, arg_list);
run_cmd(arg_list);
}

打印提示符

下面是一个提示符的示例:

1
[psh]krist@linux-szhw:/home/krist/Workspace$

加上前缀[psh]以和系统自带的Shell区别开来。后面依次是用户名(用getlogin_r函数获取)、主机名(用gethostname函数获取)、当前目录(用getcwd函数获取)和命令提示符。如果当前登录的用户是root,命令提示符为#,普通用户则打印美元符号$,因此需要用geteuid函数获取当前的Effective User ID。

处理用户输入

一般需要限制用户单次输入的最多字符数MAX_ARG_LEN以及参数的最大个数MAX_ARG_NUM。用户输入命令,按下回车键即表示当前的命令输入完毕。用getline函数读取一行用户输入,然后将一整条命令拆分。例如命令ls -al /home拆分之后变成ls-al/home三个字符串,第一个字符串是命令的名称,后面依次是命令参数。拆分字符使用strsep函数,分割符(delimeter)为空格(Space)。下面是拆分过程的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
stringp = input_buf;
arg_num = 0; // number of arguments
while (((token = strsep(&stringp, " ")) != NULL)
&& (arg_num < MAX_ARG_NUM))
{
// Token is terminated by overwriting the delimiter with a null
// byte('\0'), so continuous space will result in a token with
// only a null byte, skip it.
if (strcmp(token, "") != 0) {
arg_vec[arg_num] = token;
arg_num++;
}
}
arg_vec[arg_num] = NULL;

其中input_buf是一个字符数组,存放用户输入的单条命令字符串,arg_vec是字符串数组,存放拆分后的多个字符串。

运行命令

fork和exec

想必大家都熟悉shell运行命令的流程:用户输入一条命令后,父进程会fork一个子进程,在子进程中使用exec函数运行用户输入的命令,父进程等待子进程退出后,等待用户输入下一条命令,如此反复。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void exec_cmd(char **arg_vec)
{
int status;
pid_t pid;

pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
} else if (pid == 0) { // child process, run the command
// execvp() returns only if an error occurs
execvp(arg_vec[0], arg_vec);
perror("execvp");
exit(EXIT_FAILURE);
} else { // parent process, wait for children to exit
while (wait(&status) != pid)
;
}
}

exec函数毫无疑问选择最方便的execvp,主进程使用wait函数等待子进程退出。

内建命令

这个时候一个可以跑的Shell就完成了。但是却无法运行cd命令,提示错误如下:

1
execvp: No such file or directory

运行whereis cd命令,并没有显示可执行文件的路径。原来cd命令是内建命令(build in command),需要在Shell中自己实现,而且它的执行不需要建立子进程,需要额外判断和处理。

我写了一个函数判断是否是内建命令,如果不是内建命令则返回-1,否则按照内建命令的方式运行。目前只实现了cdexit两个内建命令。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int builtin_cmd(char **arg_vec)
{
if (strcmp(arg_vec[0], "cd") == 0) { // cd
if ((arg_vec[1] != NULL) && (arg_vec[2] == NULL)) {
if (chdir(arg_vec[1]) == -1) {
perror("cd");
}
} else {
printf("Usage: cd [directory]\n");
}
return 0;
} else if (strcmp(arg_vec[0], "exit") == 0) { // exit
exit(EXIT_SUCCESS);
}

return -1;
}

此时,Shell主循环中的run_cmd函数演变为:

1
2
3
4
5
6
7
8
9
void run_cmd(char **arg_vec)
{
if (arg_vec[0] == NULL) {
return;
}
if (builtin_cmd(arg_vec) == -1) { // run build-in commands
exec_cmd(arg_vec); // run normal commands
}
}

处理信号

我们期望Shell能够处理信号。在Shell的命令行中敲命令运行程序,在程序还在运行时按下Ctrl-CCtrl-\键(分别产生SIGINTSIGQUIT信号),运行的程序会退出,即中断运行子进程;在Shell的命令行中不运行命令,直接按下这两个键组合,不能退出Shell程序,即父进程不会中断运行,退出Shell程序只能通过Ctrl-D按键。实现上述功能需要在父进程中忽略信号SIGINTSIGQUIT,但是在子进程中恢复对信号SIGINTSIGQUIT的默认操作。代码很简单,在这里不列出。

这样,一个具备基本功能的Shell就完成了,完整的代码请参考这里。我会逐渐添加更多的功能。

参考

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