FEATURED · 精选文章

【Linux】让我们自己来实现一个Shell命令行解释器!

发布时间 / 2026/9/2 9:10:55
来源 / 创域科博编辑部
栏目 / 资讯中心
【Linux】让我们自己来实现一个Shell命令行解释器! 我们可以通过自己实现一个shell命令行来加深对shell的理解同时也能帮助我们理解内建命令、本地变量、环境变量这些概念目标能处理普通命令、内建命令。接下来我们一起来探索。一、原理Shell如 bash是用户与 Linux 内核交互的命令解释器。其核心流程为读取用户输入的命令后通过 fork 创建子进程自身进入等待状态子进程通过 exec 执行具体命令如ls、ps直接与内核交互完成操作命令执行完毕子进程退出Shell 唤醒并回到等待状态循环接收下一条命令。该设计让 Shell 始终保持交互能力避免被单个命令阻塞。所以要写一个shell需要循环以下过程:1.获取命令行2.解析命令行3.建立一个子进程(fork)4.替换子进程(execvp)5.父进程等待子进程退出(wait)根据这些思路就可以自己来实现一个shell了。二、功能实现实现一个简易 Shell 的第一步是完成环境初始化工作这一步的核心是为 myshell 搭建起基础的运行框架完成系统环境变量的获取与封装、全局核心参数的初始化、工作路径的初始化等关键操作为后续命令行获取、解析、执行等核心流程。2.1环境初始化2.1.1系统环境变量的获取与初始化Linux 系统中提供了全局的environ指针数组存储环境变量我们只需遍历该数组将环境变量内容拷贝到自定义的全局环境变量数组中并以NULL作为结束标志完成环境变量的初始化。提供底层支撑保证 myshell 能与系统环境交互、维护运行状态、传递命令参数。//环境变量表 #define MAXENV 100 char *g_env[MAXENV]; int g_envs0; //测试的环境变量表 char cwd[1024]; char cwdenv[1024];void InitEnv() { //把上面定义的环境变量表初始化置空 extern char **environ; memset(g_env,0,sizeof(g_env)); g_envs0; //本来要从配置文件中读取现在从父进程的环境变量表中读 for(int i0;environ[i];i) { //先申请比父环表大1的空间 g_env[i](char*)malloc(strlen(environ[i])1); //获取环境变量 strcpy(g_env[i],environ[i]); g_envs; } g_env[g_envs](char*)HELLO999;//测试新的环境变量 g_env[g_envs]NULL; //导入自己的shell环境变量表中 for(int i0;g_env[i];i) { putenv(g_env[i]); } //environg_env;//直接把系统的 environ 指针改成了指向你自己的 g_env 数组。这是错的 }2.1.2全局运行参数的定义与初始化命令解析、执行、退出码维护需要定义全局的核心运行参数并完成初始化主要包含命令行参数数组、命令行参数个数、上一条命令的退出码等。//命令行参数表 #define MAXARGC 128 char *g_argv[MAXARGC]; int g_argc0; //最后一次退出码 int lastcode0;2.2命令行提示符的实现2.2.1获取提示符的基础信息我们能看到提示符是由这四个部分组成的我们需要自己实现获取当前登录用户名的函数GetUserName()、获取主机名的函数GetHostName()、获取当前工作目录的函数GetPwd()、获取家目录的函数GetHome()方便实现cd ~能够找到固定不变的家目录//获取用户名 const char* GetUserName() { const char* namegetenv(USER); return nameNULL?None:name; } //获得主机名 const char* GetHostName() { const char* hostnamegetenv(HOSTNAME); return hostnameNULL?None:hostname; } //获得当前路径 const char* GetPwd() { // const char* pwdgetenv(PWD);GetPwd() //没有实时获取当前路径,这个函数是「只读一次环境变量 PWD」 const char* pwdgetcwd(cwd,sizeof(cwd)); if(pwd!NULL) { snprintf(cwdenv,sizeof(cwdenv),PWD%s,cwd); putenv(cwdenv); } return pwdNULL?None:pwd; } //获取家目录 const char* GetHome() { const char* homegetenv(HOME); return homeNULL? :home; }2.2.2格式化拼接提示符字符串我们通过上图能看出上述四类信息需要靠[用户主机 目录]符号的格式拼接起来所以接下来要实现一个制作命令行的函数以此来拼接成完整的提示符字符串//用宏定义它的格式 #define COMMAND_SIZE 1024 #define FORMAT [%s%s %s]# //做一个命令行 拼接成完整的提示符字符串 cmd_prompt是存放接受结果的数组 void MakeCommandLine(char cmd_prompt[],int size) { snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),DirName(GetPwd()).c_str()); //cmd_prompt 是指向 prompt 的指针形参别名,cmd_prompt是形参也就是它其实是prompt这个空白纸条的别名 }2.3命令行获取2.3.1获取用户输入的命令这里我们用fgets函数读取用户输入的整行命令输入回车或者为空都返回false。//是否成功获取用户输入的命令 bool GetCommandLine(char* out,int size) { //获取字符串 ls -a -l\n 比如输入ls -l回车数组里是 ls -l\n char* cfgets(out,size,stdin); if(cNULL)return false; //为了避免打印出一个空行,清理\n也就是把数组中最后一个字符的位置改为\0 out[strlen(out)-1]0; //如果只按了一下回车也是错 if(strlen(out)0)return false; return true; }2.3.2命令行参数分割在参数分割之前我们需要对一个特殊的命令进行检测就是重定向为什么要在命令行参数分割之前有这个操作呢命令行解析的核心是按空格分割参数若先解析命令再检查重定向会把重定向符号和文件名当成命令的一部分导致解析错误比如ls -l log.txt先解析命令的话分割出的参数是[ls, -l, , log.txt]Shell 会尝试执行 ls -l 并把 log .txt当成普通参数完全偏离了我们的意图了。所以我们要先检查重定向先把 log.txt 从命令行中剥离命令行只剩 ls -l再解析得到 [ls, -l]这样参数就完全正确了我们通过实现RedirCheck函数它会把重定向符号替换为 \0直接截断原始命令行字符串比如 ls -l log.txt 处理后cmd 数组中 的位置变成 \0字符串实际只有 ls -l然后重定向的逻辑打开文件、重定向文件描述符在执行阶段单独处理实现功能解耦//如果 后有空格end 跳过空格指向 l void Trimspace(char cmd[],int end)//end是引用传递修改会影响外部 { while(isspace(cmd[end])) { end; } } void RedirCheck(char cmd[]) { //至此内容就是全新的 redirNONE_REDIR; filename.clear(); int start0; int endstrlen(cmd)-1; //倒着找 while(endstart) { if(cmd[end]) { cmd[end]0;//把第替换为\0end后移 Trimspace(cmd,end); redirINPUT_REDIR; filenamecmdend; break; } else if(cmd[end]) { if(cmd[end-1]) { cmd[end-1]0; cmd[end]0; Trimspace(cmd,end); redirAPPEND_REDIR; filenamecmdend; break; } else{ cmd[end]0; Trimspace(cmd,end); redirOUTPUT_REDIR; filenamecmdend; break; } } else{ end--; } } }重定向检测之后就可以进行正常的参数分割了。我们通过实现CommandPrase函数对命令行进行解析比如 ls -a -l - ls -a -l然后存入全局数组 g_argv并统计参数个数 g_argc。//命令行参数分割 ls -a -l -ls -a -l bool CommandPrase(char *commandline) { #define SEP g_argc0; g_argv[g_argc]strtok(commandline,SEP); while((bool)(g_argv[g_argc]strtok(nullptr,SEP))); // while (true) { // // 1. 切割下一个参数 // char* next_arg strtok(nullptr, SEP); // // // 2. 把切割结果存入数组 // g_argv[g_argc] next_arg; // // // 3. 参数个数自增 // g_argc; // // // 4. 判断是否切割完毕终止循环 // if (next_arg nullptr) { // break; // } // } g_argc--;//循环的「第一次执行」是切割第二个参数-a不是第一个1-2 //return true; //再健壮一下代码,防止出现只按回车的命令 return g_argc0?true:false; }2.4命令行解析在命令解析完成后得到 g_argc/g_argv必须先区分内建命令和普通命令这是自己实现Shell 能正确执行cd / echo等特殊命令的关键。这里我们要先明白内建命令和普通命令的核心区别内建命令Shell 自身实现的命令无需创建子进程直接在 Shell 主进程执行执行方式是直接调用 Shell 内部函数普通命令系统中独立的可执行程序必须创建子进程执行执行方式是fork execvp进行程序替换所以我们在这两种命令的实现方法上要作区分这里我们先通过CheckAndExecBuiltin()函数进行判断cd / echo这两个内建命令后面执行阶段再进行区分bool CheckAndExecBuiltin() { std::string cmdg_argv[0]; if(cmdcd) { cd(); return true; } else if(cmdecho) { echo(); return true; } return false; }2.5命令执行2.5.1普通命令和重定向命令在执行阶段Execute() 函数是自己的Shell 处理普通命令非内建命令的核心函数核心完成 2 件事1.创建子进程执行外部命令如 ls/cat2.在子进程中完成输入 / 输出重定向的实现通过 dup2 重定向文件描述符让命令的输入 / 输出指向指定文件int Execute() { //4.执行命令我的父进程要一直执行命令行窗口的工作所以创建一个子进程来干活 pid_t idfork(); if(id0) { //子进程的 dup2 只修改自身的文件描述符父进程myshell的 IO 完全不受影响 //所以要子进程来实现重定向操作 int fd-1; if(redirINPUT_REDIR) { fdopen(filename.c_str(),O_RDONLY); if(fd0)exit(1); dup2(fd,0); close(fd); } else if(redirOUTPUT_REDIR) { fdopen(filename.c_str(),O_CREAT |O_WRONLY |O_TRUNC,0666); if(fd0)exit(2); dup2(fd,1); close(fd); } else if(redirAPPEND_REDIR) { fdopen(filename.c_str(),O_CREAT |O_WRONLY |O_APPEND,0666); if(fd0)exit(2); dup2(fd,1); close(fd); } else{} //进程替换exec 系列函数不会影响已完成的重定向结果 ?? 反而重定向必须在进程替换前执行才能让新程序继承重定向后的文件描述符 //child //进行程序替换 execvp(g_argv[0],g_argv); exit(1); } int status0; //parent pid_t ridwaitpid(id,status,0); if(rid0) { //等成功了,获取退出码 lastcodeWEXITSTATUS(status); } return 0; }这里有一个注意的点重定向必须在 execvp 之前执行因为execvp 会继承当前进程的文件描述符只有先完成 dup2新程序才会使用重定向后的输入 / 输出2.5.2内建命令cd、echo等除此之外我们还需要写两个不同的函数去单独实现内建命令这里我们以cd 、echo命令为例cd这个函数的目标是实现用户输入 cd 命令后的目录切换逻辑主要支持三个场景1.无参数 cd切换到当前用户的家目录2.带普通路径 cd /usr/local切换到指定目录3.待扩展场景cd -切回上一次目录、cd ~等价于无参数 cd。bool cd() { // 先记录当前目录为 cd - 做准备 std::string current_pwd GetCwd(); //只有cd这是进入家目录 if(g_argc1) { std::string homeGetHome(); if(home.empty())return true;//再理解一下 chdir(home.c_str()); } else{ std::string whereg_argv[1]; //cd - cd ~ if(where-) { if(last_pwd.empty())return false; chdir(last_pwd.c_str()); } else if (where~) { std::string homeGetHome(); if(home.empty())return true;//再理解一下 chdir(home.c_str()); } else{ chdir(where.c_str()); } } return true; }echo:echo() 也是 Shell 最常用的内建命令之一这个函数实现了 3 类核心输出场景1.普通文本输出如 echo hello → 打印 hello2.退出码输出如 echo $?→打印上一条命令的退出码)3.环境变量输出如echo $PATH-打印环境变量路径名bool echo() { //echo hello //ehco $? //echo $PATH if(g_argc2) { std::string optg_argv[1]; if(opt$?) { std::coutlastcodestd::endl; //把lastcode清零 lastcode0; return true; } else if(opt[0]$) { //查环境变量 std::string env_nameopt.substr(1); const char* env_valuegetenv(env_name.c_str()); if(env_value) { std::coutenv_valuestd::endl; } return true; } else{ std::coutoptstd::endl; } } return true; }好啦以上就是对自制myshell的一些基础功能的简单解析啦
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻