Bootstrap

【Linux】详解shell代码实现(上)

🪐🪐🪐欢迎来到程序员餐厅💫💫💫

          主厨:邪王真眼

主厨的主页:Chef‘s blog  

所属专栏:青果大战linux

总有光环在陨落,总有新星在闪烁

学校开始搞蓝桥的校选了,很好,300块钱不用掏了。昨天还看到一个OS的比赛,可以我这没实力,不然真想去试试。

我相信不少同学(包括我),最开始都理解不了所谓的shell或者说bash到底是什么,他被称之为命令行解释器,他创建子进程,他辅助用户和内核之间进行联系,它本身也是一个进程,这些定义都太抽象了,那么它到底是什么,他又是如何实现这些功能的,要搞清楚这个,其实只要手写一个shell就好了。

为了方便区分,下文对于系统自带的shell我们称之为shell,对于我们自己写的shell我们称之为myshell

思路大纲:

1.首先所谓shell其实就是运行起来的程序,不难发现shell他以运行以来就不会停止,所以可以想到他的代码最外层是一个死循环。

2.循环里面有什么,我们可以分为四个模块 ----【创建命令行提示符】 ------【获取用户命令】------

【分析用户命令】-----【执行用户命令】

接下来我会挨个介绍四个模块。

int main(){
   char command_buffer[basesize];
   while(true)
    { PrintCommradLine();//打印命令行提示符
   GetCommandLine(command_buffer,basesize))//获取命令
    ParseCommandLine(command_buffer,(int)strlen(command_buffer));//分析命令
    ExecuteCommandLine();//执行命令
    }
    return 0;
}

创建命令行提示符

 不难发现,在我们输入指令的那一行永远都会有一串字符,我们称其为命令行提示符,它由四部分构成:

[用户名+@+主机名+空格+当前路径]+#/$

细节:如果用户是root最后就是#,否则是$

要获取这些信息很简单,我们有很多接口,比如通过getenv从环境变量中获取,或者getcwd从进程信息里找,这里我们先用getenv,后面到cd指令我会在带领大家修改。

string GetUserName(){
 string name= getenv("USER");
 return name.empty()?"NULL":name;
}

string GetHostName(){
 string name= getenv("HOSTNAME");
 return name.empty()?"NULL":name;
}
char pwd[basesize];//当前工作路径
char pwdenv[basesize];//PWD的环境变量
string GetPwd(){
 string name= getenv("PWD");
 return name.empty()?"NULL":name;
}

通过他们三个函数我们就获得用户名,主机名以及当前路径。

接着我们可以通过snprintf函数把他们拼起来,当然这一步你想用c++的string操作也可以,我只提供一种思路,大家可以去充分发挥自己的想象力。

函数介绍:

  1. 参数s:表示要把内容输出到s数组中

  2. 参数n:s数组中能被该函数使用的最大空间大小,单位字节 

  3. 参数format:这是一个格式化字符串,类似printf函数中的格式字符串。它包含普通字符和转换说明符。普通字符会原样复制到s中,而转换说明符用于指定如何将后面的可变参数(用...表示)转换为字符串并插入到s中。

const int basesize=1024;

string MakeCommandLine(){
char CommandLine[basesize];
snprintf(CommandLine,basesize,"[%s@%s %s]#",GetUserName().c_str(),GetHostName().c_str(),GetPwd().c_str());
return CommandLine;
}

如此一来我们调用这个MakeCommandLine函数就可以获得命令行提示符的字符串了

最后我们打印该字符串即可,

void PrintCommradLine(){
    printf("%s",MakeCommandLine().c_str());
    fflush(stdout);
}

fflush刷新,因为我们的c语言缓冲区是行刷新的,我们这里又不能输出换行符,为了将字符串刷新到显示器就必须调用fflush刷新缓冲区了。


 获取用户的命令

获取命名本质就是获取显示器上的一些字符串,但是注意我们的命令常常有空格作为分隔符,例如ls -a -l等等,所以直接scanf显然是满足不了我们的,对于c++我们可以用getline,他会以回车作为结束符,并且丢掉回车字符,也可以用c语言的fgets,当fgets从显示器输入时他会以回车符为结束标志,并且接受到回车符

强调:getline会丢弃掉回车符(不是不接收,而是丢弃!!),fgets会接受回车符。

  • 参数 
  1. 参数str:这是一个字符数组,用于存储从文件流中读取到的字符串

  2. 参数num:它表示最多读取的字符数(包括结束符\0

  3. 参数stream:这是一个指向FILE类型的指针,表示要从中读取数据的文件流

  • 返回值

如果读取成功,函数返回指向str的指针,读取失败返回NULL

为了保证我们能成功接收到命令,我们把GetCommandLine的返回值设置为布尔值,当fgets调用失败时,就返回0,main函数搭配判断该返回值的语句,为0则继续调用GetCommandLine直到成功。

bool  GetCommandLine(char*command_buffer,int size)//获取命令
{
char *result=fgets(command_buffer,size,stdin);
if(!result)
    return 0;
command_buffer[strlen(command_buffer)-1]='\0';
if(strlen(command_buffer)==0)return 0;
return 1;
}
int main(){
   while(true)
    { 
   if(! GetCommandLine(command_buffer,basesize))//获取命令
   {
      continue; 
   }
    }
    return 0;
}

细节 

  1. 我们要记得把最后的回车符的给删除掉,因为我们的指令本体其实是不含回车的,可以直接把他换成‘\0’。

  2. 当发现传入的命令为空串(也就是直接敲回车)我们就返回0,让用户重新输入,因为对空串解析没有意义,还可能引发问题。


分析命令

之前我们了解到shell会把接受的命令转化为对应的命令行参数,即argc、argv,这就是现在的工作。

我们的想法是按照空格把命令分开,并放入到一个char* argv[]数组存储,按照目标分隔符分割字符串,这显然就是strtok的任务了。

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

​编辑 

  • 参数 
  1. 参数str:这是一个字符串,是要被分割的原始字符串。在第一次调用strtok时,需要将这个原始字符串的指针传递进来。如果要继续分割同一个字符串,继续调用函数时,str参数要设置为NULL。
  2. 参数delimiters:这是一个包含分隔符的字符串。例如,如果想要以逗号和句号作为分隔符来分割字符串,该参数可以是",。"。这个字符串中的每个字符都被视为一个分隔符。
  • 他的返回值

 当函数成功分割字符串时,它返回从左到右第一个分割出来的子串的指针。这个指针指向子串的开头位置。例如,对于“apple,banana,cherry”字符串,第一次调用函数(假设delim",")会返回一个指向apple这个子字符串的指针。无法继续分割时则返回NULL 

void ParseCommandLine(char command_buffer[],int len){
  memset(argv,0,sizeof argv);
  argc=0;
  (void)(len);
    const char*sep=" ";
    argv[argc++]=strtok(command_buffer,sep);
   char*Next=NULL;
    while((bool)(Next=strtok(NULL,sep)))//不加强转会有waring
    {
        argv[argc++]=Next;
    }
}

细节

  1. 这里我说个小知识点,vim里你定义一个变量但不用时会有waring,为了不出现警报你可以对该变量原地强转,就可以让编译器认为他被使用过了,如上面函数里的形参len。 

  2. 注意argv要以NULL结尾


  执行命令

我们一直听说shell会创建子进程去执行用户输入的命令,现在我们就看看她是怎么做的

fork创建子进程,接着我们对子进程进行进程替换,我们这里用的是execvpe函数,为什么是他呢

  1. 首先我们再命令分析时已经做好了argv,所以用argv传参更方便

  2. 其次输入p可以让我们只输入文件名就行了不用输入他的路径很方便

  3. 至于为什么要带传入环境变量功能,我们后面说

关于exec系列函数忘记的,进程替换不清楚的,请移步至这里进程替换及exec系列函数使用

父进程的工作自然就是waitpid回收进程,这里我们直接阻塞等待就好了

void ExecuteCommandLine(){
    pid_t id=fork();
    if(!id){
    execvpe(argv[0],argv,env);
        exit(1);
    }
    int status=0;
    pid_t rid=waitpid(id,&status,0);
}

假如进程替换失败了我们就exit(1),返回一个错误码

有了上面的这些函数我们的shell其实已经完成九成了,但绝不可小看剩下的一成,倒不如难点就在此处。


内建命令

此时已经自己通过上面的讲解构建好了myshell的同学可以试试,你们输入pwd,ls,mkdir这些指令都可以正常运行,但是你试试cd,就会发现没用。

原因不难想出,我们是先fork创建子进程接着再子进程跑指令,那么你的cd指令此时就是修改了子进程的路径,这对吗?这不对,因为我们实际要的是修改父进程也就是myshell的路径,于是我们就知道cd指令不用也不能让子进程去跑,而要myshell自己去跑,我们称这一类需要myshell自己跑的指令为内建命令

cd

对于cd其实就是改变进程的当前工作路径,于是我们可以用一个系统接口chdir,

他会把当前的进程工作路径修改为传入的path 

bool CheckAndExecBuiltCommand(){
    if(0==strcmp(argv[0],"cd")){
        if(argc==2){
            chdir(argv[1]);
        }
        return true;
    }

此时我们就要注意一个问题了,当我们cd路径后,myshell打印的命令行提示符中的路径部分是不会变得,这是因为我们的路径是通过环境变量获取的,但是环境变量再cd后不会自动更新,所以我们就要换个接口了,改用getcw的,该函数于获取当前工作目录(Current Working Directory,CWD)的路径。,这份信息来自/proc文件中,他存储了所有进程的相关信息。同时别的忘了通过putenv修改环境变量。

  

参数

  1. buf参数:这是一个字符数组,用于存储获取到的当前工作目录路径。

  2. size参数:它表示buf的大小,单位是字节。 

返回值:

  • 如果函数调用成功,getcwd返回buf的指针,也就是存储当前工作目录路径的字符数组的首地址。
  • 如果出现错误,getcwd返回NULL。常见的错误原因包括buf大小不够、没有权限访问当前工作目录等
string GetPwd(){
snprintf(pwdenv,sizeof pwdenv,"%s%s","PWD=",pwd);
putenv(pwdenv);
   if(getcwd(pwd,sizeof pwd)==nullptr)
       return "NULL";
   return pwd;
}

export

它的作用是导入新的环境变量 或修改已有的环境变量

首先我们创建自己的环境变量表,shell的环境变量表来自于再你打开xshell软件时他会自动读取一些配置文件来获得环境变量,这个比较复杂大家知道这个过程就行,我们就直接读取myshell从shell那里获得environ这个数组就好了

extern char** environ;
const int envnum=64;
char*env[envnum];//环境变量表
//本质是从真正的shell获取环境变量
void InitEnv(){
    int index=0;
    while(environ[index]) {
    env[index]=(char*)malloc(strlen(environ[index])+1);
    strcpy(env[index],environ[index]);
    index++;
    // env[index]=environ[index++];
    }
    env[index]=nullptr;
}

env要以NULL结尾

else  if(0==strcmp(argv[0],"export")){
        if(argc==2){
            AddEnv(argv[1]);
        }
        return true;
    }

所谓的添加就是把export后面的字符串加入到env数组中,但是注意export的另一个功能修改已经存在的环境变量这个功能我没有实现 

void AddEnv(char *item){
    int index=0;
    while(env[index]){
            index++;
    }
    env[index]=(char*)malloc(strlen(item)+1);
    strcpy(env[index],item);
    env[++index]=nullptr;
}

 为了让myshel创建的子进程的环境变量与myshell保持一致,我们就要用execvpe函数传递env了(waitpid前文)

env指令 

   else  if(0==strcmp(argv[0],"env")){
   for(int i=0;env[i];i++)
       printf("%s\n",env[i]);
   }

有了我们自己创建的环境变量表后,我们也就要重写env指令了,因为系统自带env指令不会去我们自己写的表中查看环境变量。


最后我们其实也要重写echo,别的不说,shell的echo是可以查看本地变量和环境变量的,而系统自带的echo接口只可以看环境变量

但是因为我比较懒所以就没写

但是我想锻炼一下大家,所以就没写

源码如下

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<string>
#include<cstring>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
const int basesize=1024;

const int argvnum=64;
char* argv[argvnum];
int argc=0;

extern char** environ;
const int envnum=64;
char*env[envnum];//环境变量表
//本质是从真正的shell获取环境变量
void InitEnv(){
    int index=0;
    while(environ[index]) {
    env[index]=(char*)malloc(strlen(environ[index])+1);
    strcpy(env[index],environ[index]);
    index++;
    // env[index]=environ[index++];
    }
    env[index]=nullptr;
}


string GetUserName(){
 string name= getenv("USER");
 return name.empty()?"NULL":name;
}

string GetHostName(){
 string name= getenv("HOSTNAME");
 return name.empty()?"NULL":name;
}
char pwd[basesize];//当前工作路径
char pwdenv[basesize];//PWD的环境变量
string GetPwd(){
// string name= getenv("PWD");
snprintf(pwdenv,sizeof pwdenv,"%s%s","PWD=",pwd);
putenv(pwdenv);
   if(getcwd(pwd,sizeof pwd)==nullptr)
       return "NULL";
   return pwd;
//ZZ return name.empty()?"NULL":name;
}

string MakeCommandLine(){
char CommandLine[basesize];
snprintf(CommandLine,basesize,"[%s@%s %s]#",GetUserName().c_str(),GetHostName().c_str(),GetPwd().c_str());
return CommandLine;
}

void PrintCommradLine(){
    printf("%s",MakeCommandLine().c_str());
    fflush(stdout);
}

bool  GetCommandLine(char*command_buffer,int size)//获取命令
{
char *result=fgets(command_buffer,size,stdin);
if(!result)
    return 0;
command_buffer[strlen(command_buffer)-1]='\0';
if(strlen(command_buffer)==0)return 0;
return 1;
}
void ParseCommandLine(char command_buffer[],int len){
  memset(argv,0,sizeof argv);
  argc=0;
  (void)(len);
    const char*sep=" ";
    argv[argc++]=strtok(command_buffer,sep);
   char*Next=NULL;
    while((bool)(Next=strtok(NULL,sep)))//不加强转会有waring
    {
        argv[argc++]=Next;
    }
}

void ExecuteCommandLine(){
    pid_t id=fork();
    if(!id){
    execvpe(argv[0],argv,env);
        exit(1);
    }
    int status=0;
    waitpid(id,&status,0);
}
void AddEnv(char *item){
    int index=0;
    while(env[index]){
            index++;
    }
    env[index]=(char*)malloc(strlen(item)+1);
    strcpy(env[index],item);
    env[++index]=nullptr;
}
bool CheckAndExecBuiltCommand(){
    if(0==strcmp(argv[0],"cd")){
        if(argc==2){
            chdir(argv[1]);
        }
        return true;
    }

   else  if(0==strcmp(argv[0],"export")){
        if(argc==2){
            AddEnv(argv[1]);
        }
        return true;
    }

   else  if(0==strcmp(argv[0],"env")){
   for(int i=0;env[i];i++)
       printf("%s\n",env[i]);
   }
    return false;
}
int main(){
   char command_buffer[basesize];
   InitEnv();
   while(true)
    { PrintCommradLine();//打印命令行提示符
   if(! GetCommandLine(command_buffer,basesize))//获取命令
   {
      continue; 
   }
    ParseCommandLine(command_buffer,(int)strlen(command_buffer));//分析命令
if( CheckAndExecBuiltCommand())//检测并执行内建命令
{
    continue;
}
    ExecuteCommandLine();//执行命令
    }
    return 0;
}

之后我会对于echo、文件、set、export修改功能以及别的细节来更新一次博客,大家敬请期待。 

-------------------------------创作不易,觉得有用就点赞或关注支持一下吧--------------------------------------

;