从Unix的早期开始,shell就已经成为用户与操作系统的接口的一部分。 第一个Unix shell( Thompson shell )具有非常有限的功能,主要是I / O重定向和命令管道。 后来的shell在那个早期的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系统(我个人使用Ubuntu和Fedora ,但可以随意使用自己喜欢的Linux发行版)。
- GCC (GNU编译器集合)来编译代码。
- 编写代码的文本编辑器(我个人使用GEdit ,但您也可以使用Vim , Emacs或任何其他编辑器)。
- 如何用C编程
我不会在这里详细介绍安装所需软件的细节。 如果不确定如何使系统运行上述任何软件包,请参考Linux发行版的文档,并确保在进行下一步操作之前已完成所有设置。
现在让我们开始做生意。 我们将从对构成Linux shell的鸟瞰图开始。
Linux Shell的组件
Shell是一个复杂的软件,包含许多不同的部分。
任何Linux Shell的核心部分是命令行解释器( CLI) 。 这部分有两个目的:读取和解析用户命令,然后执行解析的命令。 您可以将CLI本身分为两部分: 解析器 (或前端)和执行器 (或后端)。
解析器扫描输入并将其分解为令牌。 令牌由一个或多个字符(字母,数字,符号)组成,表示单个输入单位。 例如,令牌可以是变量名,关键字,数字或算术运算符。
解析器获取这些标记,将它们分组在一起,并创建一个特殊的结构,我们称为抽象语法树或AST 。 您可以将AST视为您提供给Shell的命令行的高级表示。 解析器获取AST并将其传递给执行器 ,该执行器读取AST并执行解析后的命令。
Shell的另一部分是用户界面,通常在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
的文件(使用touch main.c
),然后使用您喜欢的文本编辑器将其打开。 在main.c
文件中输入以下代码:
# include <stdio.h>
# include <stdlib.h>
# include <errno.h>
# include <string.h>
# 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 ] == '\0' || strcmp (cmd, "\n" ) == 0 )
{
free (cmd);
continue ;
}
if ( strcmp (cmd, "exit\n" ) == 0 )
{
free (cmd);
break ;
}
printf ( "%s\n" , cmd);
free (cmd);
} while ( 1 );
exit (EXIT_SUCCESS);
}
我们的main()
函数非常简单,因为它只需要实现REPL循环。 我们首先打印外壳程序的提示符,然后读取命令(现在,让我们将命令定义为以\n
结尾的输入行)。 如果读取命令时出错,则退出外壳。 如果命令为空(即用户没有输入任何内容就按ENTER
),则跳过此输入并继续循环。
如果命令为exit
,则退出外壳。 否则,我们将回显命令,释放用于存储命令的内存,然后继续循环。 很简单,不是吗?
我们的main()
函数调用了两个自定义函数print_prompt1()
和read_cmd()
。 第一个函数输出提示字符串,第二个函数读取输入的下一行。 让我们仔细看看这两个函数。
打印提示字符串
我们说过,shell在读取每个命令之前会打印一个提示字符串。 实际上,有五种不同类型的提示字符串: PS0 , PS1 , PS2 , PS3和PS4 。 第零个字符串PS0仅由bash使用 ,因此我们在这里不考虑它。 当外壳要将某些消息传达给用户时,其他四个字符串会在特定时间打印。
在本节中,我们将讨论PS1和PS2 。 其余的将在以后讨论更高级的Shell主题时出现。
现在创建源文件prompt.c
并输入以下代码:
# include <stdio.h>
# 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\n" , strerror(errno));
return NULL ;
}
strcpy (ptr+ptrlen, buf);
if (buf[buflen -1 ] == '\n' )
{
if (buflen == 1 || buf[buflen -2 ] != '\\' )
{
return ptr;
}
ptr[ptrlen+buflen -2 ] = '\0' ;
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 ] == '\n' )
{
if (buflen == 1 || buf[buflen -2 ] != '\\' )
{
return ptr;
}
ptr[ptrlen+buflen -2 ] = '\0' ;
buflen -= 2 ;
print_prompt2();
}
在这里,我们检查缓冲区中输入的内容是否以\n
结尾,如果是,则以反斜杠字符\\
来转义 \n
。 如果最后一个\n
没有转义,则输入行已完成,我们将其返回给main()
函数。 否则,我们将删除两个字符( \\
和\n
),打印出PS2 ,然后继续读取输入。
编译外壳
使用以上代码,我们的利基外壳几乎可以编译了。 在继续编译外壳程序之前,我们将只添加带有函数原型的头文件。 此步骤是可选的,但它可以大大提高我们的代码可读性,并防止一些编译器警告。
创建源文件shell.h
,然后输入以下代码:
# ifndef SHELL_H
# define SHELL_H
void print_prompt1 ( void ) ;
void print_prompt2 ( void ) ;
char * read_cmd ( void ) ;
# endif
现在让我们编译外壳。 打开您喜欢的终端仿真器(我使用GNOME Terminal和Konsole测试了我的命令行项目,但是您也可以使用XTerm ,其他终端仿真器或Linux的虚拟控制台之一 )。 导航到您的源目录,并确保其中包含3个文件:
现在,使用以下命令编译shell:
gcc -o shell main.c prompt.c
如果一切顺利, gcc
将不会输出任何内容,并且在当前目录中应该有一个名为shell
的可执行文件:
现在,通过运行./shell
调用Shell,并尝试输入一些命令:
在第一种情况下,外壳程序会打印PS1 ,默认为$
和一个空格。 我们输入命令echo Hello World
,外壳程序将其回显给我们(我们将在第二部分中扩展外壳程序,以使其能够解析并执行此(以及其他)简单命令)。
在第二种情况下,shell再次回显我们的命令(稍长)。 在第三种情况下,我们将long命令分为4行。 请注意,每次我们在输入反斜杠后按ENTER
,shell都会打印PS2并继续读取输入。 输入最后一行后,shell合并所有行,删除所有转义的换行符,然后将命令回显给我们。
要从外壳exit
,请键入exit
,然后ENTER
:
就是这样! 我们刚刚完成了第一个Linux shell的编写。 好极了!
下一步是什么
尽管我们的shell目前可以使用,但是它没有任何用处。 在下一部分中,我们将修复外壳,使其能够解析和执行简单
命令。
敬请关注!
翻译自: https://hackernoon.com/lets-build-a-linux-shell-part-i-bz3n3vg1