如何构建一个LinuxShell(一)
1098
从Unix的早期开始,shell就已经成为用户与操作系统的接口的一部分。第一个Unix shell具有非常有限的功能,主要是I / O重定向和命令管道。后来的shell在那个早期的shell上进行了扩展,并增加了越来越多的功能,这给了我们强大的功能,包括单词扩展,历史替换,循环和条件表达式等。那么如何构建一个Linux Shell?
为什么使用本教程?
在过去的20年中,我一直使用GNU / Linux作为主要操作系统。我使用了许多GNU / Linux shell,包括但不限于bash,ksh和zsh。但是,我一直被这个问题困扰:是什么使shell打勾?例如,例如:
Shell如何解析我的命令,将它们转换为可执行指令,然后执行这些命令?
Shell如何执行不同的单词扩展过程,例如参数扩展,命令替换和算术扩展?
Shell如何实现I / O重定向?.. 等等。
由于大多数GNU / Linux外壳都是开源的,因此,如果您想学习外壳的内部工作原理,可以在线搜索源代码并开始深入研究。但是,这个建议实际上说起来容易做起来难。例如,您应该从哪里开始阅读代码?哪些源文件包含实现I / O重定向的代码?在哪里可以找到解析用户命令的代码?我想你明白了。
这就是为什么我决定编写本教程的原因,以帮助Linux用户和程序员更好地理解其shell。我们将一起从头开始实现一个功能齐全的Linux Shell 。在此过程中,我们将看到Linux shell如何通过实际编写执行上述任务的C代码来管理解析和执行命令,循环和条件表达式。我们将讨论字扩展和I / O重定向,并看到执行功能的代码。
在本教程结束时,我们将拥有一个基本的Linux shell,目前尚不能做很多事情,但是在接下来的部分中我们将对其进行扩展和改进。在本系列的最后,我们将提供一个功能齐全的Linux shell,该shell可以解析和执行一组相当复杂的命令,循环和表达式。
您将需要什么
为了遵循本教程,您将需要以下内容:
一个运行良好的GNU / Linux系统。
GCC来编译代码。
编写代码的文本编辑器。
如何用C编程
我不会在这里详细介绍安装所需软件的细节。如果不确定如何使系统运行上述任何软件包,请参考Linux发行版的文档,并确保在进行下一步操作之前已完成所有设置。
现在让我们开始做生意。我们将从对构成Linux shell的鸟瞰图开始。
Linux Shell的组件
Shell是一个复杂的软件,包含许多不同的部分。
任何Linux壳的核心部分是命令行解释,或CLI。这部分有两个目的:读取和解析用户命令,然后执行解析的命令。您可以将CLI本身分为两部分:解析器和执行器。
该解析器将扫描输入,将其分解到令牌。甲令牌由一个或多个字符,和表示输入的单个单元。例如,令牌可以是变量名,关键字,数字或算术运算符。
该分析器采用这些令牌,组在一起,并创建我们所说的一种特殊结构抽象语法树,或AST。您可以将AST视为您提供给Shell的命令行的高级表示。解析器获取AST并将其传递给执行器,该执行器读取AST并执行解析后的命令。
Shell的另一部分是用户界面,通常在Shell处于交互模式时操作。在这里,shell循环运行,我们称为Read-Eval-Print-Loop或REPL。
就像循环的名称所示,shell读取输入,解析并执行输入,然后循环读取下一个命令,依此类推,直到输入以下命令为止: exit , shutdown, 要么 reboot。
大多数外壳程序实现一种称为符号表的结构,该外壳程序用于存储有关变量及其值和属性的信息。我们将在本教程的第二部分中实现符号表。
Linux Shell还具有历史记录功能,该功能使用户可以访问最新输入的命令,然后无需过多输入即可编辑和重新执行命令。Shell也可以包含内置实用程序,它们是作为Shell程序本身的一部分实现的一组特殊命令。
内置实用程序包括常用命令,例如cd,fg和bg。在学习本教程时,我们将实现许多内置实用程序。
现在我们知道了典型Linux shell的基本组件,让我们开始构建自己的shell。
我们的第一个壳
我们第一个版本的shell不会做任何花哨的事情。它只会打印一个提示字符串,读取一行输入,然后将输入回显到屏幕上。在本教程的后续部分中,我们将添加解析和执行命令,循环,条件表达式等的功能。
让我们从为该项目创建目录开始。我通常使用~/projects/ 用于我的新项目,但是请随意使用您喜欢的任何方式。
我们要做的第一件事是编写我们的基本REPL循环。创建一个名为main.c,然后使用您喜欢的文本编辑器将其打开。在您的计算机中输入以下代码main.c 文件:
#include
#include
#include
#include
#include "shell.h"
int main(int argc, char **argv)
{
char *cmd;
do
{
print_prompt1();
cmd = read_cmd();
if(!cmd)
{
exit(EXIT_SUCCESS);
}
if(cmd[0] == '' || strcmp(cmd, " ") == 0)
{
free(cmd);
continue;
}
if(strcmp(cmd, "exit ") == 0)
{
free(cmd);
break;
}
printf("%s ", cmd);
free(cmd);
} while(1);
exit(EXIT_SUCCESS);
}
我们的 main()函数非常简单,因为它只需要实现REPL循环。我们首先打印外壳程序的提示符,然后读取命令。如果读取命令时出错,则退出外壳。如果命令为空,我们跳过此输入并继续循环。
如果命令是 exit,我们退出外壳。否则,我们将回显命令,释放用于存储命令的内存,然后继续循环。很简单,不是吗?
我们的 main() 函数调用两个自定义函数, print_prompt1() 和 read_cmd()。第一个函数输出提示字符串,第二个函数读取输入的下一行。让我们仔细看看这两个函数。
打印提示字符串
我们说过,shell在读取每个命令之前会打印一个提示字符串。实际上,有五种不同类型的提示字符串:PS0,PS1,PS2,PS3和PS4。第零个字符串PS0仅由bash使用,因此我们在这里不考虑它。当外壳要将某些消息传达给用户时,其他四个字符串会在特定时间打印。
在本节中,我们将讨论PS1和PS2。其余的将在以后讨论更高级的Shell主题时出现。
现在创建源文件 prompt.c 并输入以下代码:
#include
#include "shell.h"
void print_prompt1(void)
{
fprintf(stderr, "$ ");
}
void print_prompt2(void)
{
fprintf(stderr, "> ");
}
第一个函数显示第一个提示字符串,即PS1,在外壳程序等待您输入命令时通常会看到它。第二个函数将打印第二个提示字符串,即PS2,当您输入多行命令时,该字符串将由外壳程序打印。
接下来,让我们阅读一些用户输入。
读取用户输入
开启档案 main.c 并在结尾处的末尾输入以下代码 main() 功能:
char *read_cmd(void)
{
char buf[1024];
char *ptr = NULL;
char ptrlen = 0;
while(fgets(buf, 1024, stdin))
{
int buflen = strlen(buf);
if(!ptr)
{
ptr = malloc(buflen+1);
}
else
{
char *ptr2 = realloc(ptr, ptrlen+buflen+1);
if(ptr2)
{
ptr = ptr2;
}
else
{
free(ptr);
ptr = NULL;
}
}
if(!ptr)
{
fprintf(stderr, "error: failed to alloc buffer: %s ", strerror(errno));
return NULL;
}
strcpy(ptr+ptrlen, buf);
if(buf[buflen-1] == ' ')
{
if(buflen == 1 || buf[buflen-2] != '\')
{
return ptr;
}
ptr[ptrlen+buflen-2] = '';
buflen -= 2;
print_prompt2();
}
ptrlen += buflen;
}
return ptr;
}
在这里,我们以1024字节的块大小从stdin中读取输入,并将输入存储在缓冲区中。第一次读取输入(当前命令的第一个块)时,我们使用以下命令创建缓冲区malloc()。对于后续块,我们使用realloc()。我们不应该在这里遇到任何内存问题,但是如果发生错误,我们将输出一条错误消息并返回NULL。如果一切顺利,我们会将刚从用户读取的输入块复制到缓冲区中,并相应地调整指针。
最后的代码块很有趣。为了理解为什么我们需要此代码块,让我们考虑以下示例。假设您要输入非常长的输入行:
echo "This is a very long line of input, one that needs to span two, three, or perhaps even more lines of input, so that we can feed it to the shell"
这是一个愚蠢的例子,但它完美地展示了我们在说什么。要输入这么长的命令,我们可以将整个内容写在一行中(就像我们在此处所做的那样),这是一个繁琐而丑陋的过程。或者,我们可以将线切成较小的部分,然后将这些部分一次放入外壳中:
echo "This is a very long line of input,
one that needs to span two, three,
or perhaps even more lines of input,
so that we can feed it to the shell"
键入第一行后,为了让外壳程序知道我们还没有完成输入,我们在每行末尾添加一个反斜杠字符 \,然后是换行符(我还对行进行了缩进以使它们更具可读性)。我们称这个转义换行符。当外壳程序看到转义的换行符时,它知道需要丢弃这两个字符并继续读取输入。
现在让我们回到我们的 read_cmd()功能。我们正在讨论最后的代码块,内容为:
if(buf[buflen-1] == ' ')
{
if(buflen == 1 || buf[buflen-2] != '\')
{
return ptr;
}
ptr[ptrlen+buflen-2] = '';
buflen -= 2;
print_prompt2();
}
在这里,我们检查缓冲区中输入的内容是否以 如果是的话 是逃脱通过一个反斜杠字符\。如果最后 未转义,输入行已完成,我们将其返回到 main()功能。否则,我们删除两个字符(\ 和 ),打印出PS2,然后继续读取输入。
编译外壳
使用以上代码,我们的利基外壳几乎可以编译了。在继续编译外壳程序之前,我们将只添加带有函数原型的头文件。此步骤是可选的,但它可以大大提高我们的代码可读性,并防止一些编译器警告。
创建源文件 shell.h,然后输入以下代码:
#ifndef SHELL_H
#define SHELL_H
void print_prompt1(void);
void print_prompt2(void);
char *read_cmd(void);
#endif
现在让我们编译外壳。打开您喜欢的终端仿真器。导航到您的源目录,并确保其中包含3个文件:
现在,使用以下命令编译shell:
gcc -o shell main.c prompt.c
如果一切顺利 gcc 应该不输出任何东西,并且应该有一个名为 shell 在当前目录中:
现在通过运行来调用shell ./shell,然后尝试输入一些命令:
在第一种情况下,外壳程序会打印PS1,默认为$和一个空间。我们输入命令echo Hello World,外壳程序将其回显给我们将在第二部分中扩展外壳程序,以使其能够解析和执行此(以及其他简单命令。
在第二种情况下,shell再次回显我们的命令。在第三种情况下,我们将long命令分为4行。请注意,每次输入反斜杠后ENTER,外壳程序将打印PS2并继续读取输入。输入最后一行后,shell合并所有行,删除所有转义的换行符,然后将命令回显给我们。
要退出外壳,请键入 exit, 其次是 ENTER:
就是这样!我们刚刚完成了第一个Linux shell的编写。好极了!
下一步是什么
尽管我们的shell目前可以使用,但是它没有任何用处。在下一部分中,我们将修复外壳,使其能够解析和执行简单命令。更多关于Linux的信息,请继续关注。