在Linux环境制作一个简易的Shell


声明:本文代码由C语言实现,环境是CentOS 7.9

Shell是什么?

简单来说,Shell 是一个命令行解释器。它是用户与计算机操作系统内核(Kernel)之间的桥梁和接口。

  • 内核 (Kernel):操作系统的核心。它直接与计算机硬件(CPU、内存、硬盘等)进行交互,管理着所有底层的、关键的操作。用户一般不直接与内核打交道,因为这会非常复杂且危险。
  • Shell:包裹在内核外面的“外壳”。它接收用户输入的命令,将其解释并转换为内核能够理解的语言(系统调用),然后将内核执行的结果输出给用户。

一个生动的比喻:
内核是发动机、变速箱和底盘,而 Shell 是方向盘、油门踏板和仪表盘。作为司机(用户),你通过操作方向盘和踏板(在 Shell 中输入命令)来控制汽车(计算机)的运行。
请添加图片描述

在Linux中,我们常见的shell类型有bash和sh。

实现一个简易的Shell

在过去我们学习了进程概念和进程控制,内容繁多,真叫人苦不堪言呐!
实现一个简易的shell,可以很好的将这些知识揉在一起,综合运用,特别是进程控制。

1. 获取命令行

打印提示语

在输入命令行之前,都有一条提示语打印在前面,如下图(xshell 8):
在这里插入图片描述
提示语的内容是什么呢?
依次为:
[ + 用户名 + @ + 主机名 + 当前所在目录 + ] + $
这里的用户名、主机名、当前目录可以在环境变量中找到,并用getenv()函数提取出来。
在这里插入图片描述
现在我们仿照着用printf打印出来:

#define LEFT "["
#define RIGHT "]"
#define STYLE "#"

printf(LEFT"%s@%s %s"RIGHT""STYLE, getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));

将一些符号定义为宏,方便日后我们可以随意更换风格。
这里的当前所在目录我直接采用路径了,如果想要获取当前所在目录,截取路径最后一个/后的子串即可。

输入命令行

首先scanf是肯定麻烦的,因为命令行有许多空格。
最好的选择应该是只需要换行中断输入的函数,fgets()。如果是用C++的话可以用getline()
另外,有一些细节需要注意:

  • 命令行不可能为空(最少都应该有一个\n,否则一定是出现了异常) ,assert断言解决
  • 命名行被用过后,不能被再次使用,需要刷新后才能使用,强转类型阻止重复使用
  • 结束输入命令行时,需要回车结束,这样在尾部会留下\n,用\0覆盖即可
#define LINE_SIZE 1024

char commandline[LINE_SIZE];

char* s = fgets(commandline, LINE_SIZE, stdin);
assert(s);//命令行不可能为空                                             
(void)s;//表示该命名行已被用过,强转类型阻止重复使用
commandline[strlen(commandline)-1] = '\0';//结束输入命令行时,需要回车结> 束,这样在尾部会留下\n,需要除去

最后我们用函数将这些逻辑封装起来,在主函数调用即可:

#include<stdio.h>                                                                                                                         
#include<stdlib.h>
#include<string.h>
#include<assert.h>
 
#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
#define LINE_SIZE 1024

char commandline[LINE_SIZE];//存储命令行

void interact(char* cline, int size)
{
     printf(LEFT"%s@%s %s"RIGHT""STYLE, getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));
     char* s = fgets(cline, size, stdin);
     assert(s);//命令行不可能为空
     (void)s;//表示该命名行已被用过,强转类型阻止重复使用
     commandline[strlen(commandline)-1] = '\0';//结束输入命令行时,需要回车结束,这样在尾部会留下\n,需要除去
}
 
int main()
{
     //1. 获取命令行
     interact(commandline, sizeof(commandline));
	//测试命令行
     for(int i = 0; i < sizeof(commandline); ++i ) printf("%c", commandline[i]);
    printf("\n");                                                                 
    return 0;                                                  
}           

运行结果:
在这里插入图片描述


2. 解析命令行

我们需要将输入的命令行的内容提取出来,关键点在于命令行每一部分内容由空格隔开。我们目的在于将每部分用数组存储起来,届时我们需要哪部分时直接通过数组访问即可。

因此,我们的问题转化为:提取字符串以空格为分隔符的各部分子串

这个问题在C++中非常简单,但在C语言中就略微复杂了。

此处strtok函数较为合适,这个函数用法特殊,此处简单介绍一下:


strtok函数

函数原型

#include<string.h>
char *strtok(char *str, const char *delimiters);

功能:strtok 函数用于将字符串 str 分割成一系列由分隔符 delimiters 分隔的子字符串。

核心思想:你第一次调用它时,传入要分割的字符串。之后每次调用,它都会返回下一个子字符串的指针,直到没有更多子字符串可找,此时返回 NULL。

参数说明

  1. str:
    • 第一次调用时:传入要被分割的字符串的指针(例如 char *)。
    • 后续调用时:传入 NULL。这是因为 strtok 函数内部有一个静态指针,它会记住上次分割结束的位置。传入 NULL 就是告诉函数:“继续从上一次的位置开始分割下一个子字符串”。
  2. delimiters:
    • 一个字符串,里面包含了所有被视为分隔符的字符。
    • 例如," ,.-" 表示空格、逗号、句点和连字符都是分隔符。注意:分隔符是字符集合,而不是一个完整的子字符串。

返回值

  • 成功找到令牌时:返回一个指向该子字符串的指针。
  • 无法再找到更多令牌时:返回 NULL。

掌握了strtok了后,问题就很简单了。
核心逻辑:首次调用strtok获取第一个子串,再循环调用strtok依次获取后续子串。当子串为NULL时,循环结束并将NULL也赋值给目标子串数组的结尾。另外我们可以将循环使用的数组索引处理一下,变成我们数组的大小用作返回,表示子串个数,

代码如下:
cline为命令行;_argv为目标数组。

int splitstring(char cline[], char* _argv[])
{
    int i = 0;
    _argv[i++] = strtok(cline, DELIM);//先获取第一个子串
    while(_argv[i++] = strtok(NULL, DELIM));//后传NULL作为参数,循环获取后续子串。
    return i - 1;//返回子串个数
}

我们知道,我们在使用shell时,shell进程是不中断的,在输入完命令行执行后又会提示输入命令。
怎么做到的呢?
很简单,死循环就好了!
当前main函数代码:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>

#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
#define DELIM " /t"//分隔符
#define LINE_SIZE 1024
#define ARGV_SIZE 32

char commandline[LINE_SIZE];//存储命令行
char* argv[ARGV_SIZE];//存储解析好的子串集
int quit = 0;

int main()
{
    while(!quit)
    {
         //1. 获取命令行
         interact(commandline, sizeof(commandline));
         
         //2. 解析命令行(字符串分隔问题)
         int argc = splitstring(commandline, argv);
         if(argc == 0) continue;//若解析命令行为空,重新输入命令行
         //测试
         for(int i = 0; i < argc; ++i) printf("%s ", argv[i]);
         printf("\n");
    }
    return 0;
}

有如下结果,就说明成功了!
在这里插入图片描述


3. 指令的判断

我们知道,指令分为两类:

  • 普通命令:创建子进程并替换相应指令的程序完成
  • 内建命令:Shell进程内部调用函数来完成

这两类命令处理方式完全不同,因此我们需要判断并分类讨论。
如何判断?

  1. 判断该命令是否是我们创建的内建命令,如果是,就在执行内建命令的函数中返回真。反之,返回假。
  2. 如果为真,就不运行普通命令函数,反之,就运行。
//判断命令
//1 内建命令
int n = buildcommand(argv, argc);

//2 普通命令
if(!n) commoncommand(argv);
3.1 内建命令

所谓内建命令,顾名思义就是我们自己创建命令。
内建命令有许多,需要我们一一实现,本文只实现cdexportecho

我们只需检查命令行第一个子串(argv[0])即可,因为它才是命令的核心,后面都是它的选项罢了。

如何检查?利用strcmp的返回值判断即可。

对于cd命令,作用是修改工作目录

在Linux中,存在一个修改工作目录的系统调用函数——chdir()
函数原型:

#include <unistd.h>
int chdir(const char *path);

绝对路径和相对路径均可做参数。
另外,我们还需更新环境变量,核心逻辑如下:
用一个字符数组存储当前工作目录的绝对路径,然后用该数组覆盖原环境变量PWD即可。

  1. 如何获取当前工作目录的绝对路径?
    用系统调用接口getcwd:
    #include <unistd.h>
    char *getcwd(char *buf, size_t size);
    
  2. 如何用数组覆盖原环境变量PWD
    用函数sprintf:
    #include <stdio.h>
    int sprintf(char *str, const char *format, ...);
    

代码如下:

if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
{ 
    //切换目标工作目录
    chdir(_argv[1]);
    //更新环境变量
    getcwd(pwd, sizeof(pwd));//获取当前工作目录绝对路径
    sprintf(getenv("PWD"), "%s", pwd);
    return 1;
}

对于export命令,作用是导入环境变量、

这里非常容易出错,这里不能直接将_argv[1]直接作为putenv的参数
原因:

  1. 环境变量表存储的并非环境变量本身,它是一个指针数组,存储的都是各个环境变量的地址。
  2. 如果我们将_argv[1]的地址添加进环境变量表,而_argv[1]内容并不是固定的。
  3. 当我们输入下一条命令时,_argv[1]就会改变,就会从环境变量表中消失。

解决方法:
用一个全局数组存储目标环境变量,然后用该数组作为putenv的参数即可

//自定义环境变量
char myenv[LINE_SIZE];

else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
{
    strcpy(myenv, _argv[1]);
    putenv(myenv);
    return 1;
}

注意:这里有很大的优化空间,比如

  • putenv不安全,可以换成setenv。
  • 使用putenv,可以动态申请空间,这样就可以添加更大的环境变量。但可能会有内存泄漏。

对于echo命令,作用是回显

  1. 回显上一个进程的退出码(echo $?
  2. 回显环境变量(echo $
  3. 回显输入的内容(echo

代码如下:

int lastcode = 0;//存储上一个进程的退出码

else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
    if(strcmp(_argv[1], "$?") == 0)//查看上一次退出码
    {
        printf("%d\n", lastcode);
        lastcode = 0;
    }
    else if(*_argv[1] == '$')
    {
        char *val = getenv(_argv[1]+1);
        if(val) printf("%s\n", val);
    }
    else
    {
        printf("%s\n", _argv[1]);
    }
    return 1;
}

另外,我们还可以使我们ls出来的文件带颜色:

// 特殊处理ls,使字体有颜色
if(strcmp(_argv[0], "ls") == 0)
{
    _argv[_argc++] = "--color";
    _argv[_argc] = NULL;
}

效果图:
在这里插入图片描述
完整代码:

int buildcommand(char* _argv[], int _argc)
{
    //内建命令cd
    if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
    { 
        //切换目标工作目录
        chdir(_argv[1]);
        //更新环境变量
        getcwd(pwd, sizeof(pwd));//获取当前工作目录绝对路径
        sprintf(getenv("PWD"), "%s", pwd);
        return 1;
    }
    //export
    else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        strcpy(myenv, _argv[1]);
        putenv(myenv);
        return 1;
    }
    // echo
    else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        if(strcmp(_argv[1], "$?") == 0)//查看上一次退出码
        {
            printf("%d\n", lastcode);
            lastcode = 0;
        }
        else if(*_argv[1] == '$')
        {
            char *val = getenv(_argv[1]+1);
            if(val) printf("%s\n", val);
        }
        else
        {
            printf("%s\n", _argv[1]);
        }
        return 1;
    }

    // 特殊处理ls,使字体有颜色
    if(strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    return 0;
}

3.2 普通命令

核心逻辑:

  1. 用fork创建子进程
  2. 用if将父子进程分流
  3. 子进程执行命令(进程替换)
  4. 父进程回收子进程并接受子进程的运行结果(进程等待)

代码如下:

void commoncommand(char* _argv[])
{                             
    int id = fork();  
    if(id < 0)//fork错误
    {
        perror("fork");
        return;
    }
    else if(id == 0)//child
    {
        //进程替换,让子进程执行命令
        execvp(_argv[0], _argv);
        exit(EXIT_CODE);
    }
    else//father
    {
        //阻塞等待子进程
        int status = 0;
        int ret = waitpid(id, &status, 0);
        if(ret == id)
        {
            lastcode = WEXITSTATUS(status);//获取最近一个进程的退出码
        }
    } 
}


4. 完整代码

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
#define DELIM " \t"//分隔符
#define LINE_SIZE 1024
#define ARGV_SIZE 32
#define EXIT_CODE 44

char commandline[LINE_SIZE];//存储命令行
char* argv[ARGV_SIZE];//存储解析好的子串集
char pwd[LINE_SIZE];//存储当前工作目录的绝对路径
int quit = 0;
int lastcode = 0;//存储上一个进程的退出码

extern char **environ;

//自定义环境变量
char myenv[LINE_SIZE];

void interact(char* cline, int size)
{
    printf(LEFT"%s@%s %s"RIGHT""STYLE, getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));
    char* s = fgets(cline, size, stdin);
    assert(s);//命令行不可能为空
    (void)s;//表示该命名行已被用过,强转类型阻止重复使用
    commandline[strlen(commandline)-1] = '\0';//结束输入命令行时,需要回车结束,这样在尾部会留下\n,需要除去
}

int splitstring(char cline[], char* _argv[])
{
    int i = 0;
    _argv[i++] = strtok(cline, DELIM);//先获取第一个子串
    while(_argv[i++] = strtok(NULL, DELIM));//后传NULL作为参数,循环获取后续子串。
    return i - 1;//返回子串个数
}

int buildcommand(char* _argv[], int _argc)
{
    //内建命令cd
    if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
    { 
        //切换目标工作目录
        chdir(_argv[1]);
        //更新环境变量
        getcwd(pwd, sizeof(pwd));//获取当前工作目录绝对路径
        sprintf(getenv("PWD"), "%s", pwd);
        return 1;
    }
    //export
    else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        strcpy(myenv, _argv[1]);
        putenv(myenv);
        return 1;
    }
   // echo
    else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        if(strcmp(_argv[1], "$?") == 0)//查看上一次退出码
        {
            printf("%d\n", lastcode);
            lastcode = 0;
        }
        else if(*_argv[1] == '$')
        {
            char *val = getenv(_argv[1]+1);
            if(val) printf("%s\n", val);
        }
        else
        {
            printf("%s\n", _argv[1]);
        }
        return 1;
    }

    // 特殊处理ls,使字体有颜色
    if(strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    return 0;
}

void commoncommand(char* _argv[])
{                             
    int id = fork();  
    if(id < 0)//fork错误
    {
        perror("fork");
        return;
    }
    else if(id == 0)//child
    {
        //进程替换,让子进程执行命令
        execvp(_argv[0], _argv);
        exit(EXIT_CODE);
    }
    else//father
    {
        //阻塞等待子进程
        int status = 0;
        int ret = waitpid(id, &status, 0);
        if(ret == id)
        {
            lastcode = WEXITSTATUS(status);//获取最近一个进程的退出码
        }
    }
    
}

int main()
{
    while(!quit)
    {
         //1. 获取命令行
         interact(commandline, sizeof(commandline));
         //测试
         // for(int i = 0; i < sizeof(commandline); ++i ) printf("%c", commandline[i]);
         // printf("\n");
         
         //2. 解析命令行(字符串分隔问题)
         int argc = splitstring(commandline, argv);
         if(argc == 0) continue;//若解析命令行为空,重新输入命令行
         //测试
         //for(int i = 0; i < argc; ++i) printf("%s ", argv[i]);
         //printf("\n");

         //3. 判断命令
         //3.1 内建命令
         int n = buildcommand(argv, argc);

         //3.2 普通命令
         if(!n) commoncommand(argv);
    }

    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值