【Linux仓库】超越命令行用户:手写C语言Shell解释器,解密Bash背后的进程创建(附源码)

🌟 各位看官好,我是!****

🌍 Linux == Linux is not Unix !

🚀 通过对进程方面系统的学习,接下来可以动手实现一个迷你Xshell解释器!

👍 如果觉得这篇文章有帮助,欢迎您一键三连,分享给更多人哦!

目录

回顾进程

目标及实现思路

实现原理及代码实现

打印命令行提示符

获取用户输入指令

解析用户输入指令

初始化数据

执行指令

检测指令

更新命令行提示符

总结

附源码


回顾进程

前面我们系统梳理了 Linux 进程管理及相关底层机制的核心知识:从进程的本质(由 PCB 与代码数据构成,内核通过链表管理),到进程的创建(fork 函数的双返回值特性与写时拷贝技术优化内存使用);从进程的生命周期管理(终止的三种场景、return/exit/_exit 的差异,以及退出码的意义),到进程等待的必要性(wait/waitpid 函数避免僵尸进程,非阻塞等待的实现逻辑);同时也清晰了程序替换的原理(exec 函数族在不创建新进程的情况下替换代码数据,底层统一依赖 execve 系统调用),还掌握了命令行参数的传递(argc/argv 的应用)与环境变量的机制(继承特性、PATH 等关键变量的作用)------ 这些知识点看似分散,实则围绕 "进程的创建、控制与资源交互" 形成了完整的技术链条,而这恰好是实现命令解释器的核心基础。

要手写一个迷你 Xshell 解释器,本质上就是实现 "接收用户命令→解析命令→创建进程执行命令→等待命令执行完成" 的闭环,这正好能把前面的知识串联起来:用户输入的 "ls -l""cd ../" 等命令,需要用命令行参数解析的逻辑拆分成指令与选项(对应 argc/argv 的处理);执行外部命令时,需通过 fork 创建子进程(利用写时拷贝减少内存开销),再调用 exec 函数族(比如用 execvp 自动从 PATH 中查找命令路径)替换子进程代码;子进程执行期间,父进程(解释器本身)需通过 waitpid 等待其结束,避免僵尸进程;而环境变量(如 PATH、PWD)的继承特性,又能保证命令执行时的环境一致性 ------ 可以说,前面掌握的进程管理、程序替换、参数解析等技术,正是搭建迷你 Xshell 的 "积木",接下来就可以基于这些基础动手实现了。

目标及实现思路

  • 要能处理普通命令
  • 要能处理内建命令
  • 要能帮助我们理解内建命令/本地变量/环境变量这些概念
  • 要能帮助我们理解shell的允许原理

用下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读⼊字符串"ls"。shell建⽴⼀个新的进程,然后在那个进程中运 ⾏ls程序并等待那个进程结束。然后shell读取新的⼀⾏输⼊,建⽴⼀个新的进程,在这个进程中运⾏程序 并等待这个进程结束。 要写⼀个shell,要循环以下过程:

  1. 初始化化数据
  2. 打印命令行提示符
  3. 获取用户输入指令
  4. 解析用户指令
  5. 检测命令,内建命令,要让shell自己来执行!!!
  6. 执行命令,让子进程来执行!!!

实现原理及代码实现

打印命令行提示符

egoist@hcss-ecs-3ec8:~$

在xshell中,它的命令行提示符如上所示.基于前面的知识铺垫,我们可以通过环境变量获取命令提示符所需的信息。

cpp 复制代码
static std::string GetUserName()
{
    std::string username = getenv("USER");
    return username.empty()?"None":username;
}

static std::string GetLangName()
{
    std::string langname = getenv("LANG");
    return langname.empty()?"None":langname;
}

static std::string GetPwd()
{
    std::string pwd = getenv("PWD");
    return pwd.empty()?"None":pwd;
}

void PrintCommandPrompt()
{
    std::string user = GetUserName();
    std::string lang = GetLangName();
    std::string pwd = GetPwd();
    printf("[%s@%s:%s]# ",user.c_str(),lang.c_str(),pwd.c_str());
}

获取用户输入指令

GetCommanString 函数的核心作用是从标准输入(键盘)读取用户输入的命令字符串,存储到指定的缓冲区中,并处理输入末尾的换行符,为后续的命令解析做准备。

  • 当用户输入完指令后,该函数获取用户输入指令,存在 cmd_str_buff 数组中。
  • 每次输完指令后回车,即敲\n,导致 cmd_str_buff 读取到了\n,因此需要将 \n 修改为 \0 。
bash 复制代码
#define SIZE 1024
char commanstr[SIZE];

//2.获取用户输入指令
bool GetCommanString(char cmd_str_buff[], int len)
{
    if(cmd_str_buff==NULL||len<=0)
        return false;
    //输入指令
    char *res = fgets(cmd_str_buff,len,stdin);
    if(res==NULL)
        return false;
    //"ls -a -l\n" --> "ls -a -l\0"
    // cmd_str_buff[strlen(cmd_str_buff)] = 0; //err
    cmd_str_buff[strlen(cmd_str_buff) - 1] = 0;
    return strlen(cmd_str_buff)==0?false:true;
}

解析用户输入指令

在父进程创建子进程的过程中,子进程会以父进程为模板完成拷贝操作,这其中就包括对命令行参数的复制。基于这一特性,我们特意将命令行参数表设为全局变量 ------ 如此一来,当子进程完成创建时,便能自然地继承这份参数表,无需额外的传递操作,从而为后续子进程执行相关命令提供便捷的参数支持。

bash 复制代码
char *gargv[ARGS] = {NULL};
int gargc = 0;

当在 Xshell 中输入类似 "ls -a -l" 这样的指令并回车后,解释器会对该字符串进行解析处理:首先按空格分割出命令与各选项,将其拆分为 "ls"、"-a"、"-l" 三个独立的部分,然后依次存入命令行参数表中,并且按照规范在参数表的末尾添加 NULL 作为结束标志。

bash 复制代码
//3.解析用户指令
bool PraseCommanString(char cmd[])
{
    if(cmd==NULL)
        return false;
#define SEP " "
    //3."ls -a -l" --> "ls" "-a" "-l"
    gargv[gargc++]=strtok(cmd,SEP);
    //最后以NULL结尾
    while((bool)(gargv[gargc++]=strtok(NULL,SEP)));
    //回退一次gargc
    gargc--;
    
    return true;
}

初始化数据

然而,当进行下一次指令输入时,由于命令行参数表被设为全局变量,且未对其原有数据进行清空操作,这就引发了如下所示的问题:每次新输入指令本应生成独立的参数表,却因全局参数表留存旧数据,使得后续解析填充时,旧数据未被覆盖干净,从而出现参数表内容异常叠加、数据混乱的情况,像第二次执行 ls -a -l 时,参数数量和内容都出现了不符合预期的错误扩展,影响了命令解析与执行的正确性 。

因此,每次解析用户指令前都需要将命令行参数表进行清空。

bash 复制代码
//0.初始化化数据
void InitGlobal()
{
    gargc = 0;
    memset(gargv,0,sizeof(gargv));
}

执行指令

Bash 的核心执行逻辑是通过创建子进程来运行命令 ------ 这种设计既让 Bash 得以稳定承担用户与系统的交互中介角色,又能高效管控各命令的执行流程,也因此成为 Linux 系统中至关重要的核心组件。

具体到执行流程:Bash 先创建子进程,由子进程通过程序替换函数(如 exec 系列)执行解析后的命令;与此同时,Bash 会进入阻塞状态等待子进程,直至获取其执行结果。

此外,我们还可以借鉴echo $?获取进程退出码的机制,在这里实现类似功能:将子进程的退出码存入lastcode变量中,方便后续查看。

bash 复制代码
//5.执行命令,让子进程来执行!!!
void ForkAndExec()
{
    pid_t id = fork();
    if(id<0)
    {
        perror("fork"); //将错误码转为错误信息
        return;
    }
    else if(id == 0) //子进程
    {
        execvp(gargv[0],gargv);
        exit(0);
    }
    else
    {
        //父进程
        //等待子进程
        int status = 0;
        pid_t rid = waitpid(id,&status,0);
        if(rid > 0)
        {
            lastcode = WEXITSTATUS(status);
        }
    }
}  

检测指令

在操作中我们发现一个有趣的现象:当使用 cd .. 命令试图切换目录,之后执行 pwd 查看路径,会发现路径并没有如预期回退。这是因为我们当前采用的是 Bash 创建子进程执行命令 的方式,而 cd 这类命令比较特殊,得从进程工作原理说起:

核心原因:子进程无法改变父进程的工作目录

  • 子进程会独立拷贝父进程(Bash)的运行环境,包括当前工作目录(CWD)。
  • 子进程执行 cd .. 确实会在自己的环境里切换目录,但这一改动 仅作用于子进程自身 ,不会影响到父进程(Bash)的工作目录。
  • 后续执行 pwd 时,依旧创建子进程拷贝的工作目录,这就解释了为什么 cd ..pwd 路径没回退。

因此像cd、echo 这种命令是内建命令,是要由父进程来完成的。

进行路径切换,本质是父进程bash在进行路径切换,路径就会被子进程继承下去,因此pwd时能查到新路径。所以在执行指令之前,需要先进行对指令的检测,如果是内建命令则让bash自己执行.

bash 复制代码
static std::string GetHomePath()
{
    std::string homepath = getenv("HOME");
    return homepath.empty()?"/":homepath;
}

//4.检测命令,内建命令,要让shell自己来执行!!!
bool BuiltInCommanExec()
{
    std::string cmd = gargv[0];
    bool ret = false;
    if(cmd == "cd")
    {
        if(gargc == 2)
        {
            std::string target = gargv[1];
            if(target == "~")
            {
                ret = true;
                chdir(GetHomePath().c_str());
            }
            else
            {
                //解决 cd .. 问题
                ret = true;
                chdir(gargv[1]);
            }
        }
        else if(gargc == 1)
        {
            ret =true;
            chdir(GetHomePath().c_str());
        }
        else
        {
            //BUG
        }
    }
    else if(cmd == "echo")
    {
        if(gargc == 2)
        {
            std::string args = gargv[1];
            if(args[0]=='$')
            {
                if(args[1]=='?')
                {
                    //打印错误码
                    printf("lastcode:%d\n",lastcode);
                    lastcode = 0;
                    ret = true;
                }
                else
                {
                    const char *name = &args[1];
                    printf("%s\n",getenv(name));
                    lastcode = 0;
                    ret = true;
                }
            }
            else
            {
                printf("%s\n",args.c_str());
                ret = true;
            }
        }
    }
    return ret;
}

更新命令行提示符

上图当中:cd .. 进行回退路径时候,pwd确实验证了我们的路径进行了回退,但是我们也发现一个问题,路径更新的时候命令行提示符的路径并没有更新,为什么会这样呢?并且我们的命令行提示符路径太长了,能不能像xshell实现那样呢?

环境变量的变化,可能会依赖于进程,pwd需要shell自己更新环境变量的值。

bash 复制代码
static std::string GetPwd()
{
    char temp[1024];
    getcwd(temp,sizeof(temp));

    //顺便更新以下shell自己的环境变量pwd
    snprintf(pwd,sizeof(pwd),"PWD=%s",temp);
    putenv(pwd);

    std::string pwd_lable = temp;
    const std::string pathsep = "/";
    auto pos = pwd_lable.rfind(pathsep);
    if(pos == std::string::npos)
        return "None";

    pwd_lable = pwd_lable.substr(pos+pathsep.size());
    return pwd_lable.empty()?"/":pwd_lable;
}

总结

本文的核心目标是通过亲手实现一个简易的 Shell(myshell),来深入理解 Shell 的工作原理,特别是以下几个关键概念:

  1. 内建命令 (Built-in Commands) vs. 普通命令 (外部命令)

  2. 环境变量 (Environment Variables)本地变量 的作用与生命周期。

  3. 进程的独立性 和 进程创建 (fork) / 程序替换 (exec) 机制。

通过这个简单的 myshell 实现,我们清晰地看到了 Shell 的底层工作模型:
Shell 本身是一个死循环程序,它通过解析命令、识别内建命令、并巧妙地利用 forkexec 系统调用来管理所有外部命令的执行,从而扮演了用户与操作系统内核之间的翻译官和管理者的角色。

附源码

main.cc

bash 复制代码
#include"myshell.h"

#define SIZE 1024

int main()
{
    char commanstr[SIZE];
    while(true)
    {
        //初始化化数据
        InitGlobal();
        //1.打印命令行提示符
        PrintCommandPrompt();
        //2.获取用户输入指令
        //用户输入指令错误的话重来
        if(!GetCommanString(commanstr,SIZE))
            continue;
        //3.解析用户指令
        PraseCommanString(commanstr);
        //4.检测命令,内建命令,要让shell自己来执行!!!
        if(BuiltInCommanExec())
        {
            continue;
        }
        //4.执行命令,让子进程来执行!!!
        ForkAndExec();
    }
    return 0;
}

myshell.h

bash 复制代码
#ifndef __MYSHELL_H__
#define __MYSHELL_H__


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

#define ARGS 64


void Debug();

void PrintCommandPrompt();

bool GetCommanString(char cmd_str_buff[], int len);

bool PraseCommanString(char cmd[]);

void InitGlobal();

void ForkAndExec();

bool BuiltInCommanExec();



#endif

myshell.cc

bash 复制代码
#include"myshell.h"

int lastcode = 0;
char pwd[1024]; // 全局变量空间,保存当前shell进程的工作路径

//命令行参数表故意设为全局,为的是能给子进程继承下来
char *gargv[ARGS] = {NULL};
int gargc = 0;

void Debug()
{
    printf("hello myshell!\n");
}

static std::string GetUserName()
{
    std::string username = getenv("USER");
    return username.empty()?"None":username;
}

static std::string GetLangName()
{
    std::string langname = getenv("LANG");
    return langname.empty()?"None":langname;
}

//static std::string GetPwd()
//{
//    std::string pwd = getenv("PWD");
//    return pwd.empty()?"None":pwd;
//}

static std::string GetPwd()
{
    // 环境变量的变化,可能会依赖于进程,pwd需要shell自己更新环境变量的值

    char temp[1024];
    getcwd(temp,sizeof(temp));

    //顺便更新以下shell自己的环境变量pwd
    snprintf(pwd,sizeof(pwd),"PWD=%s",temp);
    putenv(pwd);

    std::string pwd_lable = temp;
    const std::string pathsep = "/";
    auto pos = pwd_lable.rfind(pathsep);
    if(pos == std::string::npos)
        return "None";

    pwd_lable = pwd_lable.substr(pos+pathsep.size());
    return pwd_lable.empty()?"/":pwd_lable;
}

static std::string GetHomePath()
{
    std::string homepath = getenv("HOME");
    return homepath.empty()?"/":homepath;
}

//1.打印命令行提示符
void PrintCommandPrompt()
{
    std::string user = GetUserName();
    std::string lang = GetLangName();
    std::string pwd = GetPwd();
    printf("[%s@%s:%s]# ",user.c_str(),lang.c_str(),pwd.c_str());
}


//2.获取用户输入指令
bool GetCommanString(char cmd_str_buff[], int len)
{
    if(cmd_str_buff==NULL||len<=0)
        return false;
    //输入指令
    char *res = fgets(cmd_str_buff,len,stdin);
    if(res==NULL)
        return false;
    //"ls -a -l\n" --> "ls -a -l\0"
    cmd_str_buff[strlen(cmd_str_buff) - 1] = 0;
    return strlen(cmd_str_buff)==0?false:true;
}

//3.解析用户指令
bool PraseCommanString(char cmd[])
{
    if(cmd==NULL)
        return false;
#define SEP " "
    //3."ls -a -l" --> "ls" "-a" "-l"
    gargv[gargc++]=strtok(cmd,SEP);
    //最后以NULL结尾
    while((bool)(gargv[gargc++]=strtok(NULL,SEP)));
    //回退一次gargc
    gargc--;

////打印验证
//#define DEBUG
#ifdef DEBUG
        printf("gargc: %d\n", gargc);
        printf("----------------------\n");
        for(int i = 0; i < gargc; i++)
        {
            printf("gargv[%d]: %s\n",i, gargv[i]);
        }
        printf("----------------------\n");
        for(int i = 0; gargv[i]; i++)
        {
            printf("gargv[%d]: %s\n",i, gargv[i]);
        }
#endif

        return true;
}


//初始化化数据
void InitGlobal()
{
    gargc = 0;
    memset(gargv,0,sizeof(gargv));
}


//5.执行命令,让子进程来执行!!!
void ForkAndExec()
{
    pid_t id = fork();
    if(id<0)
    {
        perror("fork"); //将错误码转为错误信息
        return;
    }
    else if(id == 0) //子进程
    {
        execvp(gargv[0],gargv);
        exit(0);
    }
    else
    {
        //父进程
        //等待子进程
        int status = 0;
        pid_t rid = waitpid(id,&status,0);
        if(rid > 0)
        {
            lastcode = WEXITSTATUS(status);
        }
    }
}   


//4.检测命令,内建命令,要让shell自己来执行!!!
bool BuiltInCommanExec()
{
    std::string cmd = gargv[0];
    bool ret = false;
    if(cmd == "cd")
    {
        if(gargc == 2)
        {
            std::string target = gargv[1];
            if(target == "~")
            {
                ret = true;
                chdir(GetHomePath().c_str());
            }
            else
            {
                //解决 cd .. 问题
                ret = true;
                chdir(gargv[1]);
            }
        }
        else if(gargc == 1)
        {
            ret =true;
            chdir(GetHomePath().c_str());
        }
        else
        {
            //BUG
        }
    }
    else if(cmd == "echo")
    {
        if(gargc == 2)
        {
            std::string args = gargv[1];
            if(args[0]=='$')
            {
                if(args[1]=='?')
                {
                    //打印错误码
                    printf("lastcode:%d\n",lastcode);
                    lastcode = 0;
                    ret = true;
                }
                else
                {
                    const char *name = &args[1];
                    printf("%s\n",getenv(name));
                    lastcode = 0;
                    ret = true;
                }
            }
            else
            {
                printf("%s\n",args.c_str());
                ret = true;
            }
        }
    }
    return ret;
}
相关推荐
Lenyiin2 小时前
《 Linux 修炼全景指南: 八 》别再碎片化学习!掌控 Linux 开发工具链:gcc、g++、GDB、Bash、Python 与工程化实践
linux·python·bash·gdb·gcc·g++·lenyiin
莲华君2 小时前
Bash Shell:从入门到精通
linux
m0_743125132 小时前
claude --version 报错Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).
开发语言·git·bash
风雨飘逸2 小时前
【shell&bash进阶系列】(二十一)向脚本传递参数(shift和getopts)
linux·运维·服务器·经验分享·bash
24级计算机应用技术3班闫卓2 小时前
Bash Shell 基础操作全面指南
开发语言·bash
zly35002 小时前
删除文件(rm 命令 删除目录)
linux·运维·服务器
被AI抢饭碗的人2 小时前
linux:线程池
linux·开发语言
Studying 开龙wu2 小时前
Linux 系统中配置国内源下载时使用pip install 和conda install哪个快?
linux·conda·pip
呱呱巨基2 小时前
Linux 进程控制
linux·c++·笔记·学习