【Linux 系统编程】手撕一个简易版的shell命令行解释器

文章目录

经过之前的学习,这篇文章我们来自己实现一个shell命令行解释器,理解一下shell的原理,同时帮助大家更好地理解一些我们之前学过的知识。

0. 准备工作

今天这份代码呢,我们依然采用多文件组织(或者叫分离编译:若干源文件+头文件共同实现)的方式:

分为三个文件:

myshell.h来存放一些函数的声明,myshell.cpp存放函数的具体实现, main.cpp存放shell主体逻辑的实现

然后我们来写一个makefile:

实现之前,我们先来分析一下大致的一个实现思路:

首先呢,shell肯定也是一个进程

启动之后,它就卡在这里等待我们输入 ;但是我们看到首先它会有一个命令行提示符 ,提示符的后面,我们可以输入要执行的命令。
那来分析一下这个提示符,它由哪些部分组成呢?

所以,我们要写一个shell,那启动之后:

  1. 输出命令行提示符
  2. 获取用户输入的命令

那暂且先到这里,我们来写会儿代码

1. 输出命令行提示符

下面来实现这个函数:


那要打印提示符的话首先我要获取到它包含的那些信息,用户名、主机名、当前工作路径这些,当然我们的样式不用跟它完全一样,可以稍微区分一下。
那这些信息其实在环境变量表都有记录,所以我们可以通过获取对应的环境变量来拿到这些信息:

不过当我这个机器的环境变量表中没有主机名(你的云服务器可能会有),那我们可以用系统调用来获取
int gethostname(char *name, size_t len);

来运行看看效果 :


没啥问题,但是我们看到打印完后面紧跟着就打印了系统bash(Linux的shell)的命令行。
为什么呢?

因为我们的myshell进程打印完就退出了,程序结束了。
那这样的话如果我输入的一个命令处理完(怎么处理我们还没写),那我怎么输入下一个命令啊?
所以,shell应该是一个死循环的程序!


再来看看效果:

可以了,这次就比较像样了。

下面进行第二步:获取用户输入的命令

2. 获取用户的命令行输入

思考一下:

命令行输入的,最多的就是一些命令,然后就是执行一些我们自己写的程序,但是输入命令的时候,是可以带很多选项的,依次用空格隔开
比如:ls -a -l -n
其实这就是一个长字符串嘛!虽然中间可能有空格,但我们要整体看作一个字符串。
我们可以可以考虑使用fgets函数,C语言的文章中我们讲过

我们来实现一下代码:

首先

定义一个字符数组保存用户的输入。
然后来实现GetCommandString函数

如何处理后面再说,我们获取后可以先打印出来看一下


运行看看

没有问题,但是我们发现:这里好像多打印了一个换行啊!
怎么回事呢?
我们把打印时候的换行去掉


现在看起来正常了,但是现在这个换行是哪来的呢?
原因在于:换行符会使 fgets 停止读取,但它被函数视为有效字符,并包含在复制到 str 的字符串中。(我们输入完是敲了一个回车的!)

那我们来处理一下:


把\n替换成\0
这次我们再来正常打印


就没问题了。

那继续往下,第三步

3. 解析命令行输入,构建命令行参数表

获取到用户的命令行输入之后呢,按理说我们就可以创建子进程(先不考虑内建命令)然后程序替换去执行了。
但是:
经过上一篇文章的学习

exec系列的接口,参数的传递要么是可变参数列表的形式,一个个短字符串传进去;要么是你自己把参数放到一个char*的指针数组里,然后传递。
但我们上面拿到的是一个完整的长字符串,所以,我们要先对获取到的命令行输入进行一个解析,方便我们后续传参!
回忆上一篇文章的一段文字:

而且,再回忆上篇文章最后我们讲的内容,这里传的这个参数是啥?


其实最终就是我们替换的程序的命令行参数。

那这里我们就可以再来理解一个问题:

通过main的参数可以打印命令行参数的原理

我们之前演示过,今天可以再深入理解一下了:

我们自己写一个程序,通过main的参数打印了命令行参数,发现打印出来的就是我们命令行输入的内容(以空格作为分隔符)


本质是bash先拿到,解析后,然后fork+exec传给了子进程,程序替换执行了我们自己的程序,所以我们的程序就拿到了这些命令行参数,因此就可以打印出来

那这些输入先被bash拿到,那bash的命令行参数也是这些吗?

可不是!
Bash 自身的命令行参数(即 argv[0]、argv[1] ...)是在 Bash 进程被启动时由它的父进程(如 login、sshd、另一个 Shell)传入的。
所以bash的命令行参数是底层启动bash的命令,类似./bash这样的字符串
所以一个进程的命令行参数(即自身程序 main 函数的 argv[] 数组参数的内容)本质就是启动该进程时,用户在 shell 中输入的命令及其参数(按空格分割后的字符串列表)

解析命令行输入

所以,继续我们的工作:

我们的myshell程序拿到命令行输入之后,想要程序替换执行命令行输入的命令/程序,而这些输入就是要执行的这个程序的命令行参数,要通过exec系列的接口参数传递。
所以一定要先解析成合法的格式,本质也是在构建新进程(要替换进程)的命令行参数表。

首先我们定义一个全局的数组(命令行参数表),保存解析之后的字符串

下面来实现ParseCommandString函数

首先,要按空格拆分字符串,放到gargv数组中
比如:"ls -a -l"->"ls" "-a" "-l"


这里我们使用strtok函数,之前的C语言文章我们都是讲过的,大家可以去复习一下:C进阶】------详解10个C语言中常见的字符串操作函数及其模拟实现
我们将分隔符设为空格,借助该函数就可以很好地分割字符串把所有的空格变成\0,并放到数组中

我们可以切换到vs2022上测试下这个函数

没问题!
然后我们可以把命令行参数表的内容打印出来看一下。
那这种调式性的代码,我们可以用一下条件编译,正好也来复习一下

看看结果

没问题,但是:

如果再继续输入命令,就出错了!
怎么回事呢?
原因在于我们这里的gargvargc是全局的,所以每次使用前应该重置一下,防止之前的结果干扰

这下

就没问题了。

那命令行输入解析完了,下面我们是不是就可以创建子进程,然后程序替换执行对应的命令/程序了,最后父进程把它回收掉。

4. 创建子进程,程序替换(处理外部命令)

接口的选择:

先来思考一下,进行程序替换的话,上篇文章我们学了好几个接口,这里我们选择哪个接口比较好呢?
首先,我们是在模拟实现一个shell,那正常我们在命令行输入命令的时候,不会带详细路径的

所以,我们可以选择带p的接口,回顾一下:
p(path)自动在 PATH 环境变量指定的目录中搜索可执行文件,无需写完整路径(给出程序名即可)
其次,我们已经解析好命令行输入放到一个char*数组中了,所以我们可以选择带v的
所以这里我们选择使用execvp,环境变量我们没什么传的,默认继承就行了。

下面我们来正式实现ForkAndExec函数:

c 复制代码
// 创建子进程,让子进程程序替换执行命令
void ForkAndExec()
{
    pid_t id = fork();
    if (id < 0) // fork失败
    {
        perror("fork");
        return;
    }
    else if (id == 0) // 子进程
    {
        execvp(gargv[0],gargv);
        exit(0);
    }
    else // 父进程
    {
        pid_t rid = waitpid(id, nullptr, 0);
    }
}

那现在我们的自定义shell其实就可以开始执行命令了,运行试一下:


非常不错,已经可以执行很多基本的命令了。
相信到这里,大家已经能比较好的理解bash的基本原理了。

那实现到这个程度是不是就差不多了呢,🆗,我们继续来看:


我执行cd切换工作目录怎么没有作用呢? 难道他和上面的命令有什么不同嘛!
其实我们是讲过这个问题的:
我们命令行启动的大部分指令/程序,都是bash创建子进程去执行的。
但是,Linux中,有一部分指令,执行的时候没有什么风险,更重要的是这些命令逻辑上就应该由bash自己执行,命令的语义要求修改 Shell 自身的状态,比如cd命令 ,这种命令就由bash自己去执行,把这种命令叫做内建命令。
cd就是一个内建命令!
我们执行cd改变工作目录,应该是改变执行cd命令的进程的工作目录啊(这里就是myshell进程本身)。

但是我们目前的实现,所有的命令都是创建子进程然后程序替换执行的,而pwd查到的是当前进程的路径,所以才出现上面执行cd但是路径不变的现象。

5. 内建命令的处理

所以,我们需要修改一下代码的逻辑,并且添加对内建命令的处理。

那内建命令怎么处理呢?

bash自己进行程序替换吗?
显然不能这样做!
如果bash自身进程程序替换的话,那bash不就没了!
所以:
内建命令(如 cd、echo等)由 Shell 进程直接执行,不创建子进程,也不调用 exec 系列函数进行程序替换。
Shell 内部会解析命令名,匹配到对应的内建函数并调用它来完成命令的执行 (例如执行 cd 时可以直接调用 chdir 系统调用,它可以改变当前进程的工作目录)。
如果判断出一个命令是内建命令呢?
内建命令比较有限,所以直接枚举对比即可。

内建命令cd

下面我们来实现这个函数,先来完成cd命令即可:

c 复制代码
bool BulidInCmd()
{
    bool ret = false;
    std::string cmd = gargv[0];
    if (cmd == "cd")
    {
        //cd /home
        if (argc == 2)
            chdir(gargv[1]);
        else if (argc == 1) // 只输了个cd------进入家目录
            chdir(getenv("HOME"));
        ret = true;
    }
    return ret;
}

看看效果:


没问题!
但是有一个问题:
我们会发现命令行提示符里面的路径一直没变!
正常bash:

是会变的!
🆗,这个问题先留着,待会处理

当然cd还有一些快捷键:

比如
cd ~:进入当前用户的家目录
cd -:返回最近访问的上一次所处的路径
我们写的shell现在当然不支持,那我们可以添加一下,比如支持一下cd ~

试一下

就可以了

命令行提示符中工作路径的动态更新

下面解决刚才那个问题:命令行提示符中的路径不会动态变化,怎么回事呢?

先来回忆一下,命令行的这个路径,我们是从哪获取的?

我们是从环境变量中获取的。

所以根本的原因在于:
我们myshell这个进程的环境变量中PWD环境变量不会动态变化(如果你去测试系统的bash,人家的是会变化的)

那如何解决呢?那就让它实时更新一下:


那这里就不再getenv获取环境变量了,因为当前进程的PWD环境变量不会实时更新(后面我们也会解决这个问题)
那现在,我们可以换一个方式,使用库函数getpwd
getcwd 是获取当前工作目录绝对路径的可靠方法,不依赖环境变量,直接向内核查询。

将当前进程的工作目录的绝对路径(不包含末尾的 /)存储在 buf 指向的缓冲区中,最多存储 size 字节(包括结尾的 '\0')。
再来试一下

就可以了!

PWD环境变量的动态更新

但是PWD这个环境变量依然不会变化:


因为它也需要bash手动更新。
我们之前讲过系统bash的环境变量是通过执行一些配置文件的脚本形成的。
我们今天自己写的bash当然没有这样做,我们现在也不会。我们的myshell的环境变量就是从它的父进程即真正的bash继承下来的。
那我们这里如何更新PWD呢?
我们也每次手动设置一下就行了。
我们可以定义一个全局数组,保存当前myshell进程的实时工作路径。

然后我们刚才不是已经修改了GetPwd使得可以每次获取准确的当前路径了嘛。

那我们在这里同时也更新一下PWD环境变量,这样PWD不就随命令行提示符实时更新了嘛。

我们可以使用putenv函数



传入一个KV格式的字符串(本质是char*),putenv会将传入的指针放入环境变量环境表中 ,不是复制字符串。
所以,调用后,环境变量表中的char*指针就会执行我们传入的这个字符串。

所以,为什么我们使用了一个全局数组呢?
其实不强制全局,但必须保证在进程生命周期内有效。因为如果传入局部变量,函数返回后环境变量将指向无效内存。
那首先我们要构造KV格式的字符串,如"PWD=/home/yhq",然后传参

试一下:

就可以了。

6. 简化命令行提示符的路径显式

目前我们的命令提示符,里面记录的是从根目录开始的完成路径,我想简化一下,只让他记录最后一层路径,像这样:

怎么做呢?

同样是这个函数

只需要最后返回的时候只返回最后一个/后面的内容即可

试一下:

没问题!

7. echo $?功能的实现

我们系统的bash,是可以获取到执行的命令的退出码的:


使用echo $?:打印上一条命令的退出状态码
那我们自己的shell目前肯定是不行的:

我们来支持一下这个功能,其实很简单

首先

定义一个全局变量记录执行的命令/程序的退出码
然后

这样就拿到进程退出码了。
但这还没完,我们还要让命令行输入echo $?时在命令行打印上一次执行命令的退出码,并且echo也是一个内建命令

试一下:

就可以了。
当然也可以加上打印环境变量的功能:

8. 总结

那到这里,我们的这个自定义shell就实现的差不多了

当然还有很多功能没有支持,因为我们一共也就写了差不多两百多行代码,系统的bash代码比我们的多得多了。
但是,这已经足够帮我们理解很多需要理解的东西了 ,这才是写这个shell真正的目的。
当然后面学习的过程中,我们也会再补充一些新的功能。
完整源码:myshell

在继续学习新知识前,我们来思考函数和进程之间的相似性:

exec/exit就像call/return
⼀个C程序有很多函数组成。⼀个函数可以调用另外⼀个函数,同时传递给它⼀些参数。被调用的函数执行特定的操作,然后返回⼀个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。
Linux鼓励将这种应用于程序之内的模式扩展到程序之间 。如下图

⼀个C程序可以fork创建子进程然后exec让其执行一个新程序,并可以传给它⼀些参数。子进程执行⼀定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait/waitpid(&ret)来获取exit的返回值。

相关推荐
小猫咪014 小时前
Linux 软链接和硬链接详解:ln 命令背后的 inode 原理
linux
小脑斧1235 小时前
从入门到精通:Linux 进程间通信 IPC 全解析|管道、共享内存、信号量、消息队列实战
linux·运维·服务器
難釋懷5 小时前
Nginx反向代理
运维·nginx
ABCDEEE75 小时前
3.RAG
java·linux·服务器
优化Henry5 小时前
LTE站点8通道RRU单通道驻波异常导致小区服务降级案例分析
运维·服务器·5g·信息与通信
剑神一笑5 小时前
Linux zip 与 unzip 命令详解:压缩算法原理与实战技巧
linux·前端·chrome
为思念酝酿的痛5 小时前
Linux线程
linux·服务器·后端
用户2367829801685 小时前
Linux cp 命令深度解析:文件复制的底层原理与高级技巧
linux
黄林晴5 小时前
Android CLI 1.0 稳定版发布!官方为 AI Agent 打造专属验证工具,改完自动校验
android