独立博客阅读地址:https://panqiincs.me/2017/02/26/write-a-shell-basic-functionality/
Shell的功能Shell是操作系统中管理进程和运行程序的程序。所有常用的shell都有三个主要功能:
- 运行程序:grep, date, ls等都是一些普通的程序,shell将它们载入内存并运行它们
- 管理输入和输出:使用<,>和 | 符号可以将输入、输出重定向。这样就可以告诉shell将进程的输入和输出连接到一个文件或是其他的进程
- 可编程:shell同时也是带有变量和流程控制的编程语言
本文将介绍如何编写包含最基本功能,即能运行和终止程序的shell。
Shell的主流程Shell打印提示符、输入命令、运行命令,如此反复。因此,主流程由下面的循环组成:
for (;;) {
print_prompt(prompt_str);
handle_input(arg_buf, arg_list);
run_cmd(arg_list);
}
打印提示符
下面是一个提示符的示例:
[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)。下面是拆分过程的代码:
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
是字符串数组,存放拆分后的多个字符串。
想必大家都熟悉shell运行命令的流程:用户输入一条命令后,父进程会fork一个子进程,在子进程中使用exec函数运行用户输入的命令,父进程等待子进程退出后,等待用户输入下一条命令,如此反复。代码如下:
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
命令,提示错误如下:
execvp: No such file or directory
运行whereis cd
命令,并没有显示可执行文件的路径。原来cd
命令是内建命令(build in command),需要在shell中自己实现,而且它的执行不需要建立子进程,需要额外判断和处理。
我写了一个函数判断是否是内建命令,如果不是内建命令则返回-1,否则按照内建命令的方式运行。目前只实现了cd
和exit
两个内建命令。代码如下:
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
函数演变为:
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-C
或Ctrl-\
键(分别产生SIGINT
和SIGQUIT
信号),运行的程序会退出,即中断运行子进程;在shell的命令行中不运行命令,直接按下这两个键组合,不能退出shell程序,即父进程不会中断运行,退出shell程序只能通过Ctrl-D
按键。实现上述功能需要在父进程中忽略信号SIGINT
和SIGQUIT
,但是在子进程中恢复对信号SIGINT
和SIGQUIT
的默认操作。代码很简单,在这里不列出。
这样,一个具备基本功能的shell就完成了,完整的代码请参考这里。请继续看下一篇:Shell实现:重定向和管道。
参考- The Linux Programming Interface
- Unix/Linux编程实践教程