【Linux】模拟实现一个shell

接受每一个人的批评,可是保留你自己的判断。 ------莎士比亚


一段时间的没有更新是由于最近开学期间比较的忙,同时也是由于刚开学的几门课才学习的时候有点迷糊,需要在学校课堂上花的时间更多了,所以才没有更新的,求放过。

简单shell的实现

1、shell介绍

对于什么是shell问题来说,这是个好问题😊 ,但是其实如果你看过我之前的文章的话,应该能准确的理解什么是shell,如果想要看之前怎么介绍的话,就会到之前文章里看一看。这里的话就简单讲一下吧,shell简单点来说,就是一个你的老板的一个秘书,这里的老板也能够看作是内核,你想要让你的老板有什么行为的话,你的报告换句话说就是你得将你的命令行代码给到你的老板的秘书,也就是shell,会通过shell来帮助你去找到老板,但是并不是直接就能够找到,并且让他去执行,给到老板前,秘书也会自己考虑一下这个命令行的方式有没有什么不妥的地方,如果有的话也就不会直接麻烦操作系统,这样的话,既保证了内核的安全性,也保证了运行时候的效率,这里的效率提升就是因为能够秘书在接收到几次一样的请求之后能够不再去进行判断,直接否定。

2、shell实现概括

对于shell实现来说,每一次的命令行输入,都会对应着有着一段的运行结果。那对于这种方式来说,可以看作是一个在一个父进程的情况下,一个子进程在不断的执行不同的命令,或者换句话说是在不断的替换进程(其中的环境变量是从父进程传下来的)

所以我们可以用进程替换的思想去实现一个shell进程(这里的这种进程要一直进行,这样才能够实现执行多次的命令行。

由于我们每次输入的命令行指令都是会被bash读到,然后寻找指定的命令行中提到的程序,然后执行相关的选项。就像这篇文章讲的那样,我们的程序中能够读取到我们输入的东西,所以为什么我们不能够利用这点来实现每次的命令行输入,将对应到进程替换成我们需要的进程,运行结束之后再退出来。

按照这样的方法的话,我就能够奠定了我们实现shell主要实现方向。

3、shell实现困难

1、对于shell来说,不仅仅是读取到我们输入到的命令行是什么,我们还需要在执行之前,每次都会有一段的前置的信息,这一段的前置消息就是,分别对应着用户名,主机名以及当前目录,所以第一个目标就是要解决基本信息的获取以及显示

2、除此之外,我们还需要将读取到的命令行参数存放在数组之中,所以我们需要根据每一次的用户的命令字符串,切分为不同的字符串数组,其中的要求就是依据空格为分界符号。
3、拆分后,分别的放在一个字符串数组之中。然后进行进程替换,这里的进程替换,选择的函数是
execvp
这个在之前的文章中讲述过具体的使用方法,不知道的可以回顾一下,这个进程替换的系统调用函数能够解决我们的问题。

4、当然如果我们知道内建命令,那么我们还需要额外的去实现内建命令构建的操作。

4、shell实现具体方式

4、1、main函数

首先构建一个main函数。

包含一下最主要的函数,最主要的需要实现的功能。

为了方便后续的使用,我们把512定义为一个SIZE,简单的认为这是一个大小的限制(就类似数组大小的限制)。

复制代码
#define SIZE 512
int main()
{
    int quit = 0;
    while(!quit)
    {
        // 1. 我们需要自己输出一个命令行
        MakeCommandLineAndPrint();

        // 2. 获取用户命令字符串
        char usercommand[SIZE];
        int n = GetUserCommand(usercommand, sizeof(usercommand));
        if(n <= 0) return 1;

        // 3. 命令行字符串分割. 
        SplitCommand(usercommand, sizeof(usercommand));

        // 4. 检测命令是否是内建命令
        n = CheckBuildin();
        if(n) continue;
        // 5. 执行命令
        ExecuteCommand();
    }
    return 0;
}

4、2、MakeCommandLineAndPrint函数

让每一个命令行都打印出自己的相关的信息。这个函数也不需要传参,因为所有需要得到的都已经存在于环境变量中了 。所以为了能够打印相关的信息,就要去读取。所以我们就需要去编写相关函数去编写读取的方法。

首先第一步是构建一个框架。

复制代码
void MakeCommandLineAndPrint()
{
	char line[SIZE];
	const char *uswename=GetUserName();
	const char *hostname=GetHostName();
	const char *cwd=GetCwd();	
	SkipPath(cwd);
	snprintf(line,sizeof(line),"[%s@%s %s]> ",username,hostname,strlen(cwd)==1 ? "/":cwd+1);
	printf("%s",line);
	fflush(stdout);
}

其次就是去实现每一个函数的具体意义。

首先我们来看SkipPath

为什么这里会有一个SkipPath呢?难道说每次得到的还不是我们正常使用的cwd吗?那当然不是能够直接使用的啊 。所以对于这个函数来说就是为了处理一开始得到的不是我们最终想要的结果。如果不知道原本是什么的话,其实简单说一下也就是从家目录到当前目录的所有的路径都在环境变量的cwd中。所以我们才需要进行额外的处理。为了能够不用多余的函数来增加我们shell的时间复杂度,并且为了能够不传指针就能够实现对于变量的改写 ,我们需要使用到宏。因为宏是一个能够在编译的时候就能在原本的位置中展开,这也就不会造成重新开栈,重新消耗空间,考虑形参和实参的关系。

复制代码
#define SkipPath(p) do {p+=(strlen(p)-1); while(*p!='/')p--;} while(0)

这里单独的写出来do{}while,来包含主要的程序,主要的作用是为了防止出现优先级错误的情况。

其中的几个得到环境变量相关信息的函数本质上都是一样的。大概看看应该能够看懂。

复制代码
const char *GetUserName()
{
    const char *name = getenv("USER");
    if(name == NULL) return "None";
    return name;
}
const char *GetHostName()
{
    const char *hostname = getenv("HOSTNAME");
    if(hostname == NULL) return "None";
    return hostname;
}
const char *GetCwd()
{
    const char *cwd = getenv("PWD");
    if(cwd == NULL) return "None";
    return cwd;
}

这样的话,就能够实现我们编写的shell的第一步了。

4、3、GetUserCommand函数

这个函数的话,是要读取用户输入的字符串,当然用户在输入的时候是有空格的,所以对于该函数,需要注意的是,这里不能够是直接使用scanf函数,而是要找到一个能够按照行来拿到字符串的函数。这样的话才能够保证不会因为存在空格反而不能读到正确的结果

所以这个函数是什么呢?有没有比较好的一个接口呢?我的建议是选择一个char *fgets(char *s,int size,FILE *stream),如果能够 正确返回,那么返回s的起始位置的地址。如果返回错误,就返回NULL 。建议使用这个文件流相关的知识,那是因为之后的文章中马上就要讲解有关于文件流的知识。其中的size指的是s的大小。并且输入的话,存放在的位置是在s中。==其中有一个不注意就会忘记的一点是,我们每次输入的时候按回车才能实现fgets真正的读完,所以说如果我们不干涉的话,在最后会有一个多余的回车。==所以我们需要进行改写,将函数内部传入命令行之后进行sizeof结尾置零操作

对于这个函数传参的设计的话,应该是需要传入两个。
第一个参数 是我们在main函数创建的一个专门存放命令行内容的usercommand数组,这是因为这个数组在读完数据之后还需要进行之后的操作,就比如说分割操作。
第二个参数 就是我们用来得到这个字符串所占据的内存大小,因为在fgets函数使用的时候需要用到。

这样的话注意点,以及一些传参的设计都已经搞定了,下面就是真正的代码的实现。

复制代码
#define ZERO '\0'
int GetUserCommand(char command[], size_t n)
{
    char *s = fgets(command, n, stdin);
    if(s == NULL) return -1;
    command[strlen(command)-1] = ZERO;
    return strlen(command); 
}

4、4、SplitCommand函数

对于分割命令行参数的函数来说,我们需要像之前那样定义一个宏函数来帮助我们实现不用传参的操作吗?其实宏函数确实能够实现,但是对于学习阶段来说我们其实可以想一下 ,之前在介绍C语言中的字符串函数的时候,有一个函数其实能够刚好符合我们的要求。strtok函数 ,能够根据特定的字符来找到字符串中每一个字符的位置,如果只执行一次的话,找到的就是第一个要求的字符,如果接着执行的话,就会在第一个基础上往后找。根据函数的这个属性的话,我们就能够利用这个函数从前往后的一次寻找空格来自动帮我们分开字符串。当然找到了符合条件的情况下,就会返回从左到右的第一个子串,后续的会返回第一个结尾之后的第二个位置的子串。如果找不到符合条件的话,就返回NULL。
为什么就是需要我们去实现一个字符串分割为多个呢?那是因为无论未来我们是用什么样子的系统调用的程序替换都需要我们命令行输入的一个一个打散的,而不是整个一起的方式去读取。

其中的NUM是用来默认设置一个命令行参数的个数的,通常情况下来说一个指令后面加上的选项不会超过NUM默认的32个的,如果超过的话,可以自行修改NUM让其能够存放在gArgv[]之中

复制代码
#define NUM 32
#define SEP " "
char *gArgv[NUM];
void SplitCommand(char command[], size_t n)
{
    (void)n;
    // "ls -a -l -n" -> "ls" "-a" "-l" "-n"
    gArgv[0] = strtok(command, SEP);
    int index = 1;
    while((gArgv[index++] = strtok(NULL, SEP))); // done, 故意写成=,表示先赋值,在判断. 分割之后,strtok会返回NULL,刚好让gArgv最后一个元素是NULL, 并且while判断结束
}

这里定义的SEP我们需要找到的目标的位置是空格 ,但是这里非常容易错,那是因为strtok函数中的第二个参数是字符串而不是字符

4、5、CheckBuildin函数

内建命令的特点就是不需要考虑当前环境或者是默认的配置的条件,在什么地方shell都能够运行出来相对于的结果。

对于现在的我来说我只认识两个内建命令 。分别是cd命令,echo $?命令。这两个我在之前讲环境变量的时候讲述过了其特点。所以要想这两个命令的与众不同,肯定是在函数结构上的与众不同。就比如之前的一些命令的话会存在于bin目录之下,但是内建命令可能就直接存在程序之中,这样的话,不会受到环境的因素也能够实现相对应的指令。

所以根据内建命令的特点,我写了一个检查内建命令的函数,如果满足条件的话就会直接运行,不会先替换进程 然后执行,这样就能够避免环境改变造成无法执行相关功能的问题。
函数的返回值设置为int类型,这样做的话能够判断是否用户输入的为内建命令,如果是内建命令的话,就会执行完,也就不会再去执行下一个的ExecuteCommand函数。避免了重复执行的错误。

复制代码
char cwd[SIZE*2];
int lastcode = 0;
const char *GetHome()
{
    const char *home = getenv("HOME");
    if(home == NULL) return "/";
    return home;
}
void Cd()
{
    const char *path = gArgv[1];
    if(path == NULL) path = GetHome();//如果是空的话,会在直接返回家目录
    // path 一定存在
    chdir(path);

    // 刷新环境变量
    char temp[SIZE*2];
    getcwd(temp, sizeof(temp));
    snprintf(cwd, sizeof(cwd), "PWD=%s", temp);
    putenv(cwd); // OK
}

int CheckBuildin()
{
    int yes = 0;
    const char *enter_cmd = gArgv[0];
    if(strcmp(enter_cmd, "cd") == 0)
    {
        yes = 1;
        Cd();
    }
    else if(strcmp(enter_cmd, "echo") == 0 && strcmp(gArgv[1], "$?") == 0)
    {
        yes = 1;
        printf("%d\n", lastcode);
        lastcode = 0;
    }
    return yes;
}

这里容易错的地方就是环境变量也是需要更新的,不能说我们进行了好几次的cd或者其他命令之后环境变量因为没有进行更新从而错误。

这样的话能够实现简单的内建命令。那我们该怎么去执行内建命令之外的命令呢?当然是使用进程替换

4、6、ExecuteCommand函数

进程替换,那就是说在该函数中需要使用到fork()函数,并且还需要判断使用哪一个系统调用函数来确定传参条件。考虑之后还是使用execvp函数。下面是实现的代码。

复制代码
void ExecuteCommand()
{
    pid_t id = fork();
    if(id < 0) Die();
    else if(id == 0)
    {
        // child
        execvp(gArgv[0], gArgv);
        exit(errno);
    }
    else
    {
        // fahter
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if(rid > 0)
        {
            lastcode = WEXITSTATUS(status);
            if(lastcode != 0) printf("%s:%s:%d\n", gArgv[0], strerror(lastcode), lastcode);
        }
    }
}

5、总结

这样的话就简单的把一个shell的指令完全的自我实现了,其中当然也会有很多的不足的地方,但是基本上的内容都已经实现。希望读者能够在本篇文章的基础之上,学到更多,理解过多的关于shell编程的快乐,也希望能够通过该篇文章,给自己的学习路上添砖加瓦,做到更好。

相关推荐
安全系统学习33 分钟前
网络安全之RCE分析与利用详情
服务器·网络·安全·web安全·系统安全
longze_734 分钟前
Ubuntu连接不上网络问题(Network is unreachable)
linux·服务器·ubuntu
Dirschs1 小时前
【Ubuntu22.04安装ROS Noetic】
linux·ubuntu·ros
qianshanxue111 小时前
ubuntu 操作记录
linux
风吹落叶花飘荡3 小时前
2025 Next.js项目提前编译并在服务器
服务器·开发语言·javascript
AmosTian4 小时前
【系统与工具】Linux——Linux简介、安装、简单使用
linux·运维·服务器
YC运维5 小时前
RIP实验以及核心原理
运维·网络·智能路由器
阿蒙Amon5 小时前
C#随机数生成全面详解:从基础到高级应用
服务器·网络·c#
leo__5206 小时前
自动化运维:使用Ansible简化日常任务
运维·自动化·ansible