拂拉氏头像
【项目实现】 Linux自定义shell实现封面图

【项目实现】 Linux自定义shell实现


目录

前言

Part1. Shell 核心原理前置知识

Part2. 整体代码架构总览

Part3. 模块逐段代码解析

Part3.1. 提示符模块

Part3.2. 读取命令行 GetCommandline

Part3.3. 命令字符串分割 CommandlineParse

Part3.4. 重定向解析模块

Part3.5. Execute (),fork + execvp + dup2 重定向

Part3.6. 内建命令实现

Part3.6.1. cd

Part3.6.2. echo

Part3.6.3. export、alias

Part3.7. 主循环 main

Part4. 结语


前言

  前面学了这么多知识,我们可以写一个简易的自定义myshell来复习一波,接下来来跟随小编的视角来看看吧。


let's go!!!!!!!!


Part1. Shell 核心原理前置知识

Shell 本质就是运行在用户态的命令行解释程序,它的核心工作流程固定:

  1. 打印提示符,读取用户输入一行命令;
  2. 解析命令字符串,切割成 argv 参数数组;
  3. 识别特殊语法:重定向> >> <;
  4. 识别别名 alias,替换原始命令;
  5. 判断是否为内建命令:cd/echo/export/alias 这类,不能 fork 子进程执行,必须在当前 shell 进程内执行,因为它们要修改 shell 自身的进程环境(当前工作目录、环境变量);
  6. 外部命令:调用fork()创建子进程,子进程通过execvp加载程序,父进程waitpid等待子进程结束,回收子进程,保存退出码$?;
  7. 循环回到打印提示符,等待下一条输入。

重点区分:内建命令 vs 外部命令 外部命令(ls、cat)是磁盘上的可执行文件,必须 fork+exec; cd 为什么不能 fork 执行?如果 fork 后子进程 chdir,只会修改子进程的工作目录,父 shell 当前目录完全不变,所以 cd 必须在本进程执行。


Part2. 整体代码架构总览

全局变量说明

#define COMMAND_SIZE 1024
char* argv[MAX_ARGV_SIZE]; // 存放解析后的命令参数数组,execvp要求char*[]格式
int argc=0;               // 参数个数
int lastcode=0;           // 保存上一条命令退出码,对应shell里$?
std::unordered_map<std::string,std::string> alias_table; // 别名表
// 重定向标记
#define NO_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
int redir=0;
std::string file_name;    // 重定向目标文件名

Part3. 模块逐段代码解析

Part3.1. 提示符模块

const char* GetPwd()
{
    const char* pwd=getcwd(cwd_buf,sizeof(cwd_buf));
    if(pwd!=NULL)
    {
        snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd_buf);
        putenv(cwdenv);
    }
    return pwd==NULL?"None":pwd;
}
  • getcwd获取当前进程工作目录;
  • 同步更新环境变量PWD,其他程序读取 PWD 时拿到最新路径;
  • snprintf拼接提示符字符串;
  • fflush(stdout):关键,printf 默认行缓冲,如果输出不带换行,缓冲区不会自动刷出,提示符会卡住不显示,必须手动 fflush。

    Part3.​​​​​​​2. 读取命令行 GetCommandline

bool GetCommandline(char* out,int size)
{
    char* comline=fgets(out,size,stdin);
    if(comline==NULL) return false;
    out[strlen(out)-1]=0; // 把fgets读到的末尾'\n'替换成'\0'
    if(strlen(out)==0) return false;
    return true;
}
  • fgets读取一行输入,会保留回车换行\n;
  • 手动覆盖掉\n,方便后续字符串解析;
  • 返回 false 代表空输入,直接跳过本轮循环。

缺陷:fgets 遇到 Ctrl+D(EOF)返回 NULL,代码仅 return false,没有退出 shell,真实 bash 收到 EOF 直接退出。

Part3.3. 命令字符串分割 CommandlineParse

bool CommandlineParse(char* out)
{
#define SEP " "
    argc=0;
    argv[argc++]=strtok(out,SEP);
    while((bool)(argv[argc++]=strtok(NULL,SEP)));
    argc--;
    return argc>0?true:false;
}

strtok字符串分割,按空格切分,填充 argv 数组。

注意:strtok 会原地修改原字符串,把分隔符替换成\0,适合这里解析场景。 致命短板:不支持引号。原生 bash 支持echo "hello world"作为单个参数,strtok 直接按空格切,引号失效;同时连续多个空格可以分割,但 tab 制表符无法识别。

Part3.4. 重定向解析模块

void CheckRedir(char* out)
{
    redir=NO_REDIR;
    std::string s=out;
    if(s.find(">>")!=s.npos)
    {
        redir=OUTPUT_REDIR;
        size_t pos=s.find(">>");
        out[pos]=0; // 在>>位置截断原命令字符串,后面留给重定向文件
        file_name=s.substr(fpos);
    }
    else if(s.find('>')!=s.npos)
    {
        redir=APPEND_REDIR;
        // ...逻辑类似
    }
}

核心思路:

  1. 在原始命令行字符串找到>>/>/<;
  2. 在符号位置写入\0截断命令,前面部分交给命令解析;
  3. 提取符号后的文件名保存到file_name;
  4. 设置全局redir标记,fork 子进程后执行 dup2 修改文件描述符。

代码里这里有一个 BUG:>>识别后标记为 OUTPUT_REDIR,>识别标记 APPEND_REDIR,宏定义写反了,后续执行逻辑会错乱! 宏定义:OUTPUT_REDIR=2,APPEND_REDIR=3,代码逻辑写反: >>追加 应该绑定 APPEND_REDIR;>覆盖绑定 OUTPUT_REDIR,原代码写反。

Part3.5. Execute (),fork + execvp + dup2 重定向

int Execute()
{
    pid_t id=fork();
    if(id==0)
    {
        int fd=-1;
        if(redir==INPUT_REDIR)
        {
            fd=open(file_name.c_str(),O_RDONLY);
            dup2(fd,0); // fd复制到标准输入0
            close(fd);
        }
        // > >> 的输出重定向同理dup2(fd,1)标准输出
        execvp(argv[0],argv);
        exit(1);
    }
    int status=0;
    pid_t rid=waitpid(id,&status,0);
    if(rid>0)
    {
        lastcode=WEXITSTATUS(status);
    }
    return 0;
}

系统调用拆解:

  1. fork():复制父进程,得到子进程,子进程复制父进程文件描述符表;
  2. 子进程内部:open打开目标文件,dup2(oldfd, newfd)把文件描述符重定向到,dup2 会关闭 newfd 原有文件;
  3. execvp:在子进程加载新程序,替换子进程地址空间;execvp 成功不返回;失败才继续往下执行 exit (1);
  4. 父进程调用waitpid阻塞等待子进程结束,拿到子进程退出状态,WEXITSTATUS提取退出码存入lastcode(对应$?)。

注意:重定向逻辑只写在外部命令分支,原代码 echo 内建命令没有处理重定向,echo hello > a.txt不会生效。

Part3.6. 内建命令实现

Part3.6.1. cd

bool Cd()
{
    if(argc==1) // cd不带参数,切home
    {
        PREVPATH=getcwd(prevpath_buf,sizeof(prevpath_buf));
        chdir(home.c_str());
    }
    else 
    {
        std::string where=argv[1];
        if(where=="-") // cd - 切回上一次目录
        {
            chdir(PREVPATH);
        }
    }
}

核心:chdir修改当前进程工作目录,同时保存上一次路径 PREVPATH,实现cd -。


Part3.6.2. echo

支持 echo $?打印上一条退出码,echo $PATH读取环境变量。

限制:仅支持单个参数,echo a b c只会打印第一个参数。


Part3.6.3. export、alias

  • export:维护自定义环境变量数组 envs,调用 putenv 注册环境变量;
  • alias:unordered_map 存储别名映射;CheckAlias在解析完命令后,查找别名,如果命中,重新解析别名对应的字符串执行。

Part3.7. 主循环 main

while(true)
{
    PrintCommandPrompt();    
    char commandline[COMMANDLINE_SIZE];
    if(!GetCommandline(commandline,sizeof(commandline)))
            continue;
    CheckRedir(commandline);
    if(!CommandlineParse(commandline))
            continue;
    if(CheckAndExecBuiltin())
            continue;
    if(CheckAlias(commandline))
            continue;
    Execute();
}

标准事件循环:打印提示符→读命令→解析重定向→分割参数→优先执行内建命令→别名替换→fork 执行外部命令。


Part4. 结语

  这篇文章我们知道了自定义myshell的实现,接下来,小编还会带来更多Linux相关的知识,敬请期待~

 最后,祝大家可以:春风得意马蹄疾,一日看尽长安花!

 最后的最后,要是觉得本文还可以的话,可以点点赞,关注小编一波,谢谢大家!~

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2501_93971468/article/details/166016819

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--