译文可能会跟原文有些不大一样,有些成段的对代码的解释我会直接加到代码里做成注释,个人感觉这样读代码会更通畅易懂。
下面给的代码都会默认遵守在main()前面声明函数,在main()后定义。
Shell的基础生命周期
shell在其生命周期中主要做三件事:
- 初始化(Initialize):
一个典型的 shell 将读取并执行其配置文件,这会改变 shell 行为的各个方面。 - 解释(Interpret):
shell 从标准输入stdin(它可以是交互式输入或文件)读取命令并执行它们。 - 结束(Terminate):
执行完命令之后,shell执行关闭命令,释放所有内存并终止shell。
我们只是做一个简单的shell,所以不弄配置文件和关闭命令,只会调用一个主循环函数然后终止。
#include<stdio.h>
#include<stdlib.h>
int main(int argc, char **argv)
{
//命令主循环函数
lsh_loop();
return EXIT_SUCCESS;
}
主循环函数 lsh_loop(),将在这里循环解释命令。
一个shell的基本循环
shell基本的程序逻辑:在其循环过程中一个处理命令的简单方式:
- 读取(Read)
从黑窗口(标准输入)读取命令 - 分析(Parse)
将命令字符串分割成程序和参数。 - 执行(Execute)
运行分析后的命令
void lsh_loop()
{
char *line;//读取的原始命令字符串
char **args;//line分割后的一些参数
int status;//状态变量
do {
printf("> ");
line = lsh_read_line();//读取一行
args = lsh_split_line(line);//将读取的一行拆分成一些参数
status = lsh_execute(args);//执行这些参数
//释放之前创建且用过的内存,防止内存溢出
free(line);
free(args);
} while (status);
}
do-while循环更方便检查状态变量,因为在检查它的值之前会执行一遍。
获取命令字符串
从标准输入读取一行听起来很简单,但在C语言中却可能很麻烦。你事先不知道用户会在shell中输入多少文本,所以不能简单地分配一个块。
C语言中常见的策略则是:先创建一定大小的内存,如果输入超过了,就重新分配更多的空间。
我们将使用它来实现lsh_read_line()。
#define LSH_RL_BUFSIZE 1024
char *lsh_read_line()
{
int bufsize = LSH_RL_BUFSIZE; // 事先假定的接收命令字符串大小
char *buffer = malloc(sizeof(char) * bufsize); // 开辟内存空间
if (!buffer) // 如果分配失败
{
fprintf(stderr, "lsh: lsh_read_line中malloc失败: %m\n");
exit(EXIT_FAILURE);
}
int position = 0; // 记录数组有效的长度,初始化为0,表示当前位置
int c; // 接收读取的一个字符
while (1)
{
// 读取一个字符
c = getchar();
// 如果遇到EOF,用空字符替换它并返回
if (c == EOF || c == '\n')
{
buffer[position] = '\0';
return buffer;
}
else
{
buffer[position++] = c;
}
// 如果输入的命令字符串长度大于了最初假定的大小,就重新分配更大的空间
if (position >= bufsize)
{
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer, bufsize);
if (!buffer)
{
fprintf(stderr, "lsh: lsh_read_line中realloc error\n");
exit(EXIT_FAILURE);
}
}
}
}
EOF是一个特殊的标志,表示没有更多的输入可供读取。在终端中,你可以通过输入Ctrl+D来模拟EOF,这会告诉程序已经输入完毕。
为什么用int类型接收每个字符:EOF是一个整数,不是字符,如果你想检查它,你需要使用一个整数。
对于那些对较新版本的C库非常熟悉的人可能会注意到stdio.h中有一个名为getline()的函数,它可以完成我们刚刚实现的大部分工作。老实说,在我写完这段代码之后我才知道它的存在。这个函数是GNU对C库的一个扩展,直到2008年才被添加到规范中,因此大多数现代的Unix系统现在应该都有它。我会保留我的现有代码,并鼓励人们在使用getline之前先以这种方式学习。如果不这样做,你会失去一个学习的机会!无论如何,有了getline,函数变得更容易,但是用getchar并不会比用getline更琐碎,因为都需要在读取时检查EOF或错误:
char *lsh_read_line(void)
{
char *line = NULL;
ssize_t bufsize = 0; // have getline allocate a buffer for us
if (getline(&line, &bufsize, stdin) == -1){
if (feof(stdin)) {
exit(EXIT_SUCCESS); // We recieved an EOF
} else {
perror("readline");
exit(EXIT_FAILURE);
}
}
return line;
}
解析获取到的命令字符串
我们已经实现了lsh_read_line()函数,并且得到了输入的一行内容。现在,我们需要将这行内容解析成一个参数列表。
我将进行一个明显的简化,即不允许在命令行参数中使用引号或反斜杠进行转义,将会简单地使用空格来将参数分开。
例如:命令echo "this message"不会将echo调用为一个参数"this message",而是将其调用为两个参数:"this"和"message"。
简化后只需要使用空格作为分隔符来“标记化”这个字符串。这意味着我们可以使用经典的库函数strtok来帮助我们完成一些繁琐的工作。
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line)
{
int bufsize = LSH_TOK_BUFSIZE;
int position = 0;
char *token; // 用于接收每次分割出来的单个参数
char **tokens = malloc(bufsize * sizeof(char *)); // 存储分割好的参数
if (!tokens)
{
fprintf(stderr, "lsh: lsh_split_line中malloc error\n");
exit(EXIT_FAILURE);
}
token = strtok(line, LSH_TOK_DELIM);
while (token != NULL)
{
tokens[position] = token;
position++;
// 这里是对参数多于假定个数时进行的扩容处理
if (position >= bufsize)
{
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens, bufsize * sizeof(char *));
if (!tokens)
{
fprintf(stderr, "lsh: lsh_split_line中realloc error\n");
exit(EXIT_FAILURE);
}
}
// 接着分割取下一个参数
token = strtok(NULL, LSH_TOK_DELIM);
}
// 在最末尾将指针置空(加上空结束符)
tokens[position] = NULL;
// 将最终结果返回出去
return tokens;
}
在函数的开始,我们通过调用strtok开始标记化。它返回指向第一个标记的指针。实际上,strtok()的操作是返回在你给出的字符串内部的指针,并在每个标记的末尾放置\0字节。我们将每个指针存储在一个字符指针的数组(缓冲区)中。
一切准备就绪,我们得到了一个参数数组,可以进行下一步的执行操作。这就引出了一个问题,我们该如何执行这些标记呢?
Shell如何启动进程
现在真正涉及到了shell的核心功能:启动进程。这意味着你需要准确了解进程的运行方式以及它们是如何启动的。让我们稍微偏离一下话题,讨论一下类Unix操作系统中的进程:
在Unix上,启动进程只有两种方式:
- 第一种方式(几乎不算)是通过init进程,当Unix计算机启动时它的内核会被加载,一旦加载并初始化完成,内核只启动一个进程,称为init。这个进程在计算机运行期间一直存在,并负责加载其他的进程。
- 由于大多数程序都不是init进程,那么启动进程的实际方法只剩下一种:fork()系统调用。当调用这个函数时,操作系统会创建进程的副本并同时启动它们。原始进程称为“父进程”,新进程称为“子进程”。fork()会向子进程返回0,而父进程则会得到它的子进程的进程ID号(PID)。实际上,这意味着启动新进程的唯一方法是通过现有进程复制自身。
通常,当你想运行一个新进程时,你不只是想要同一个程序的另一个副本,而是想要运行一个不同的程序。这就是exec()系统调用的作用。它会用全新的程序替换当前运行的程序。这意味着当你调用exec时,操作系统会停止当前进程,加载新程序,并以新程序代替原来的程序运行,进程在执行exec()调用后不会返回(除非出现错误)。
通过这两个系统调用,我们了解到大多数Unix程序是如何运行的基本方法:首先,一个现有进程fork成两个独立的进程;然后,子进程使用exec()来用新程序替换自身。
父进程可以继续执行其他任务,甚至可以使用wait()系统调用来监视它的子进程。
呼~有了这些背景知识,下面启动程序的代码就会更加容易理解:
#include <unistd.h>
#include <sys/wait.h>
int lsh_launch(char **args)
{
pid_t pid; // 接收fork返回值
pid_t wpid;
int status;
pid = fork();
if (pid == 0) // fork成功:子进程执行的代码部分
{
if (execvp(args[0], args) == -1)
{
perror("lsh: lsh_launch中execvp error - ");
}
// execvp失败就会到这里,退出子进程
exit(EXIT_FAILURE);
}
else if (pid < 0) // fork失败(仅告知用户,不对错误进行处理)
{
perror("lsh: lsh_launch中fork error - ");
}
else // fork成功:当前进程(父进程)执行的代码部分
{
// 父进程需要等待子进程的命令执行完成
do
{
// 等待进程状态改变
wpid = waitpid(pid, &status, WUNTRACED);
}
// 循环直到进程要么退出,要么被杀死
while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
// 最终返回1,告诉调用函数应该再次提示用户输入命令
return 1;
}
这个函数接收之前解析好的参数数组,创建子进程并保存返回值。fork()过后,我们实际上有两个并发运行的进程,子进程将进入第一个if条件(其中pid == 0)。
子进程中想要运行用户输入的命令,就需要使用exec系统调用的多种变体之一:execvp。不同的exec变体稍有不同,有的接收可变数量的字符串参数,有的接受字符串列表,还有的让你指定进程运行的环境。这个特定的变体第一个参数:一个程序名,第二个参数:一个字符串参数数组(也称为vector,因此有个‘v’)。‘p’表示可以不提供要运行的程序的完整文件路径,只用提供程序名,让操作系统搜索该程序路径。
Shell内建命令
你可能注意到lsh_loop()
函数调用了 lsh_execute()
,但上面我们将函数命名为 lsh_launch(),
这是有意为之的!你会发现,大多数shell执行的都是程序,但并非全部。有些命令是直接内建在shell中的。
原因很简单,如果你想改变目录,你需要使用 chdir()
函数。问题是,当前目录是进程的一个属性。所以,如果你编写了一个名为 cd
的程序来改变目录,它只会改变它自己的当前目录,然后终止。它的父进程的当前目录将保持不变。但我们想让shell进程本身需要执行 chdir()
,以便更新它自己的当前目录,当它启动子进程时,它们也会继承这个目录。
同理输入exit
退出程序,会无法退出调用它的shell,这个命令也需要内建在shell中。此外,大多数shell通过运行配置脚本来配置,比如 ~/.bashrc
。这些脚本使用的命令会改变shell的运行方式,这些命令只有在内建在shell进程本身中时,才能改变shell的操作。
因此我们需要向shell中添加一些命令,我添加到我的shell中的命令有 cd
、exit
和 help
。以下是它们的函数实现:
// 内置命令列表
char *builtin_str[] = {
"cd",
"help",
"exit"};
//与列表相对应的函数指针列表
int (*builtin_func[])(char **) = {
&lsh_cd,
&lsh_help,
&lsh_exit};
//计算 内置命令列表 中的元素数量
int lsh_num_builtins()
{
return sizeof(builtin_str) / sizeof(char *);
}
//内建函数实现
int lsh_cd(char **args)
{
if (args[1] == NULL)
{
fprintf(stderr, "lsh: lsh_cd 中 参数缺失\n");
}
else
{
//更改当前工作目录到参数args[1]中的路径
//返回非0值表示失败
if (chdir(args[1]) != 0)
{
perror("lsh");
}
}
return 1;
}
int lsh_help(char **args)
{
printf("LSH: 一个简单的Unix Shell\n");
printf("输入程序名称和参数,然后按回车键\n");
printf("以下是已经内置的程序:\n");
for (int i = 0; i < lsh_num_builtins(); i++)
{
printf(" %s\n", builtin_str[i]);
}
printf("使用 man 命令获取其他程序的信息\n");
return 1;
}
int lsh_exit(char **args)
{
//0为终止shell循环的信号
return 0;
}
int (*builtin_func[])(char **):
builtin_func[]
表示builtin_func
是一个数组
*
表示数组中的元素是指针
int (*...)(char **)
表示指针指向的函数:
该函数返回
int
类型,并接受一个char **
类型的参数
将内置命令和进程整合在一起
拼凑起最后一块拼图是实现 lsh_execute() 函数,该函数将负责执行内置命令或者启动一个进程。如果你读到这里,你会知道我们已经为编写一个非常简单的函数做好了准备:
int lsh_execute(char **args)
{
if (args[0] == NULL)
{
// 输入了一个空的命令
return 1;
}
//先检查是否是内建命令
for (int i = 0; i < lsh_num_builtins(); i++)
{
if (strcmp(args[0], builtin_str[i]) == 0)
{
//返回 运行内建命令函数 后的返回值
return (*builtin_func[i])(args);
}
}
//返回 运行非内建命令函数(shell启动子进程) 后的返回值
return lsh_launch(args);
}
将上述代码片段全部整合到一起
这就是构成 shell 的所有代码。如果你一路阅读下来,你应该完全理解了 shell 是如何工作的。要尝试运行它(在 Linux 机器上),你需要将这些代码段复制到一个文件中(main.c),然后编译它。确保只包含一个 lsh_read_line() 的实现。你需要在文件顶部包含以下头文件。我已经添加了注释,以便你知道每个函数的来源。
#include <sys/wait.h>
waitpid()
及其相关宏#include <unistd.h>
chdir()
fork()
exec()
pid_t
#include <stdlib.h>
malloc()
realloc()
free()
exit()
execvp()
EXIT_SUCCESS
,EXIT_FAILURE
#include <stdio.h>
fprintf()
printf()
stderr
getchar()
perror()
#include <string.h>
strcmp()
strtok()
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#define LSH_RL_BUFSIZE 1024
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
// 内置命令列表
char *builtin_str[] = {
"cd",
"help",
"exit"};
// 程序主循环
void lsh_loop();
// 读取一行命令字符串
char *lsh_read_line();
// 对读取到的命令字符串解析成参数数组
char **lsh_split_line(char *line);
// shell启动子进程
int lsh_launch(char **args);
// 计算内置命令的数量
int lsh_num_builtins();
// shell内建命令函数
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
// 将内置命令和进程整合在一起
int lsh_execute(char **args);
// 与列表相对应的函数指针列表
int (*builtin_func[])(char **) = {
&lsh_cd,
&lsh_help,
&lsh_exit};
int main(int argc, char **argv)
{
// 命令主循环函数
lsh_loop();
return EXIT_SUCCESS;
}
void lsh_loop()
{
char *line; // 读取的原始命令字符串
char **args; // line分割后的一些参数
int status; // 状态变量
do
{
printf("> ");
line = lsh_read_line(); // 读取一行
args = lsh_split_line(line); // 将读取的一行拆分成一些参数
status = lsh_execute(args); // 执行这些参数
// 释放之前创建且用过的内存,防止内存溢出
free(line);
free(args);
} while (status);
}
char *lsh_read_line()
{
int bufsize = LSH_RL_BUFSIZE; // 事先假定的接收命令字符串大小
char *buffer = malloc(sizeof(char) * bufsize); // 开辟内存空间
if (!buffer) // 如果分配失败
{
fprintf(stderr, "lsh: lsh_read_line中malloc失败: %m\n");
exit(EXIT_FAILURE);
}
int position = 0; // 记录数组有效的长度,初始化为0,表示当前位置
int c; // 接收读取的一个字符
while (1)
{
// 读取一个字符
c = getchar();
// 如果遇到EOF,用空字符替换它并返回
if (c == EOF || c == '\n')
{
buffer[position] = '\0';
return buffer;
}
else
{
buffer[position++] = c;
}
// 如果输入的命令字符串长度大于了最初假定的大小,就重新分配更大的空间
if (position >= bufsize)
{
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer, bufsize);
if (!buffer)
{
fprintf(stderr, "lsh: lsh_read_line中realloc error\n");
exit(EXIT_FAILURE);
}
}
}
}
char **lsh_split_line(char *line)
{
int bufsize = LSH_TOK_BUFSIZE;
int position = 0;
char *token; // 用于接收每次分割出来的单个参数
char **tokens = malloc(bufsize * sizeof(char *)); // 存储分割好的参数
if (!tokens)
{
fprintf(stderr, "lsh: lsh_split_line中malloc error\n");
exit(EXIT_FAILURE);
}
token = strtok(line, LSH_TOK_DELIM);
while (token != NULL)
{
tokens[position] = token;
position++;
// 这里是对参数多于假定个数时进行的扩容处理
if (position >= bufsize)
{
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens, bufsize * sizeof(char *));
if (!tokens)
{
fprintf(stderr, "lsh: lsh_split_line中realloc error\n");
exit(EXIT_FAILURE);
}
}
// 接着分割取下一个参数
token = strtok(NULL, LSH_TOK_DELIM);
}
// 在最末尾将指针置空(加上空结束符)
tokens[position] = NULL;
// 将最终结果返回出去
return tokens;
}
int lsh_launch(char **args)
{
pid_t pid; // 接收fork返回值
pid_t wpid;
int status;
pid = fork();
if (pid == 0) // fork成功:子进程执行的代码部分
{
if (execvp(args[0], args) == -1)
{
perror("lsh: lsh_launch中execvp error - ");
}
// execvp失败就会到这里,退出子进程
exit(EXIT_FAILURE);
}
else if (pid < 0) // fork失败(仅告知用户,不对错误进行处理)
{
perror("lsh: lsh_launch中fork error - ");
}
else // fork成功:当前进程(父进程)执行的代码部分
{
// 父进程需要等待子进程的命令执行完成
do
{
// 等待进程状态改变
wpid = waitpid(pid, &status, WUNTRACED);
}
// 循环直到进程要么退出,要么被杀死
while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
// 最终返回1,告诉调用函数应该再次提示用户输入命令
return 1;
}
int lsh_num_builtins()
{
return sizeof(builtin_str) / sizeof(char *);
}
int lsh_cd(char **args)
{
if (args[1] == NULL)
{
fprintf(stderr, "lsh: lsh_cd 中 参数缺失\n");
}
else
{
// 更改当前工作目录到参数args[1]中的路径
// 返回非0值表示失败
if (chdir(args[1]) != 0)
{
perror("lsh");
}
}
return 1;
}
int lsh_help(char **args)
{
printf("LSH: 一个简单的Unix Shell\n");
printf("输入程序名称和参数,然后按回车键\n");
printf("以下是已经内置的程序:\n");
for (int i = 0; i < lsh_num_builtins(); i++)
{
printf(" %s\n", builtin_str[i]);
}
printf("使用 man 命令获取其他程序的信息\n");
return 1;
}
int lsh_exit(char **args)
{
// 0为终止shell循环的信号
return 0;
}
int lsh_execute(char **args)
{
if (args[0] == NULL)
{
// 输入了一个空的命令
return 1;
}
// 先检查是否是内建命令
for (int i = 0; i < lsh_num_builtins(); i++)
{
if (strcmp(args[0], builtin_str[i]) == 0)
{
// 返回 运行内建命令函数 后的返回值
return (*builtin_func[i])(args);
}
}
// 返回 运行非内建命令函数(shell启动子进程) 后的返回值
return lsh_launch(args);
}
简单的测试结果
你也可以从 GitHub上获取代码。那个链接直接指向本文撰写时的代码的当前修订版——我可能会选择在未来某一天更新它并添加新功能。如果我这样做,我会尽力更新这篇文章,包括详细的实现和想法。
总结
这个shell并不具备丰富的功能,它有明显的缺点:
- 仅使用空白字符来分隔参数,不支持引号或反斜杠转义
- 不支持管道(piping)或重定向(redirection)
- 标准内置命令较少
- 不支持文件名模式匹配(globbing)
具体更新信息可到原博主的博客查看