我们可以通过自己实现一个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_envs=0; //测试的环境变量表 char cwd[1024]; char cwdenv[1024];void InitEnv() { //把上面定义的环境变量表初始化置空 extern char **environ; memset(g_env,0,sizeof(g_env)); g_envs=0; //本来要从配置文件中读取,现在从父进程的环境变量表中读 for(int i=0;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*)"HELLO=999";//测试新的环境变量 g_env[g_envs]=NULL; //导入自己的shell环境变量表中 for(int i=0;g_env[i];i++) { putenv(g_env[i]); } //environ=g_env;//直接把系统的 environ 指针改成了指向你自己的 g_env 数组。这是错的 }2.1.2全局运行参数的定义与初始化
命令解析、执行、退出码维护需要定义全局的核心运行参数并完成初始化,主要包含命令行参数数组、命令行参数个数、上一条命令的退出码等。
//命令行参数表 #define MAXARGC 128 char *g_argv[MAXARGC]; int g_argc=0; //最后一次退出码 int lastcode=0;2.2命令行提示符的实现
2.2.1获取提示符的基础信息
我们能看到提示符是由这四个部分组成的,我们需要自己实现获取当前登录用户名的函数GetUserName()、获取主机名的函数GetHostName()、获取当前工作目录的函数GetPwd()、获取家目录的函数GetHome()(方便实现cd ~,能够找到固定不变的家目录)
//获取用户名 const char* GetUserName() { const char* name=getenv("USER"); return name==NULL?"None":name; } //获得主机名 const char* GetHostName() { const char* hostname=getenv("HOSTNAME"); return hostname==NULL?"None":hostname; } //获得当前路径 const char* GetPwd() { // const char* pwd=getenv("PWD");GetPwd() //没有实时获取当前路径,这个函数是「只读一次环境变量 PWD」 const char* pwd=getcwd(cwd,sizeof(cwd)); if(pwd!=NULL) { snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd); putenv(cwdenv); } return pwd==NULL?"None":pwd; } //获取家目录 const char* GetHome() { const char* home=getenv("HOME"); return home==NULL?" ":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* c=fgets(out,size,stdin); if(c==NULL)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[]) { //至此内容就是全新的 redir=NONE_REDIR; filename.clear(); int start=0; int end=strlen(cmd)-1; //倒着找 while(end>=start) { if(cmd[end]=='<') { cmd[end++]=0;//把第>替换为\0,end后移 Trimspace(cmd,end); redir=INPUT_REDIR; filename=cmd+end; break; } else if(cmd[end]=='>') { if(cmd[end-1]=='>') { cmd[end-1]=0; cmd[end++]=0; Trimspace(cmd,end); redir=APPEND_REDIR; filename=cmd+end; break; } else{ cmd[end++]=0; Trimspace(cmd,end); redir=OUTPUT_REDIR; filename=cmd+end; 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_argc=0; 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_argc>0?true:false; }2.4命令行解析
在命令解析完成后(得到 g_argc/g_argv),必须先区分内建命令和普通命令,这是自己实现Shell 能正确执行cd / echo等特殊命令的关键。
这里我们要先明白内建命令和普通命令的核心区别:
内建命令:Shell 自身实现的命令,无需创建子进程,直接在 Shell 主进程执行,执行方式是直接调用 Shell 内部函数!
普通命令:系统中独立的可执行程序,必须创建子进程执行,执行方式是fork + execvp进行程序替换!
所以我们在这两种命令的实现方法上要作区分!
这里我们先通过CheckAndExecBuiltin()函数进行判断cd / echo这两个内建命令,后面执行阶段再进行区分!
bool CheckAndExecBuiltin() { std::string cmd=g_argv[0]; if(cmd=="cd") { cd(); return true; } else if(cmd=="echo") { echo(); return true; } return false; }2.5命令执行
2.5.1普通命令和重定向命令
在执行阶段Execute() 函数是自己的Shell 处理普通命令(非内建命令)的核心函数,核心完成 2 件事:
1.创建子进程执行外部命令(如 ls/cat)
2.在子进程中完成输入 / 输出重定向的实现(通过 dup2 重定向文件描述符),让命令的输入 / 输出指向指定文件
int Execute() { //4.执行命令,我的父进程要一直执行命令行窗口的工作,所以创建一个子进程来干活 pid_t id=fork(); if(id==0) { //子进程的 dup2 只修改自身的文件描述符,父进程(myshell)的 IO 完全不受影响 //所以要子进程来实现重定向操作 int fd=-1; if(redir==INPUT_REDIR) { fd=open(filename.c_str(),O_RDONLY); if(fd<0)exit(1); dup2(fd,0); close(fd); } else if(redir==OUTPUT_REDIR) { fd=open(filename.c_str(),O_CREAT |O_WRONLY |O_TRUNC,0666); if(fd<0)exit(2); dup2(fd,1); close(fd); } else if(redir==APPEND_REDIR) { fd=open(filename.c_str(),O_CREAT |O_WRONLY |O_APPEND,0666); if(fd<0)exit(2); dup2(fd,1); close(fd); } else{} //进程替换(exec 系列函数)不会影响已完成的重定向结果 ?? 反而,重定向必须在进程替换前执行,才能让新程序继承重定向后的文件描述符 //child //进行程序替换 execvp(g_argv[0],g_argv); exit(1); } int status=0; //parent pid_t rid=waitpid(id,&status,0); if(rid>0) { //等成功了,获取退出码 lastcode=WEXITSTATUS(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_argc==1) { std::string home=GetHome(); if(home.empty())return true;//再理解一下 chdir(home.c_str()); } else{ std::string where=g_argv[1]; //cd - cd ~ if(where=="-") { if(last_pwd.empty())return false; chdir(last_pwd.c_str()); } else if (where=="~") { std::string home=GetHome(); if(home.empty())return true;//再理解一下 chdir(home.c_str()); } else{ chdir(where.c_str()); } } return true; }echo:
echo() 也是 Shell 最常用的内建命令之一,这个函数实现了 3 类核心输出场景
1.普通文本输出:如 echo "hello" → 打印 hello;
2.退出码输出:如 echo $?→打印上一条命令的退出码(𝑙𝑎𝑠𝑡𝑐𝑜𝑑𝑒)
3.环境变量输出:如echo $PATH->打印环境变量路径名
bool echo() { //echo "hello" //ehco $? //echo $PATH if(g_argc==2) { std::string opt=g_argv[1]; if(opt=="$?") { std::cout<<lastcode<<std::endl; //把lastcode清零 lastcode=0; return true; } else if(opt[0]=='$') { //查环境变量 std::string env_name=opt.substr(1); const char* env_value=getenv(env_name.c_str()); if(env_value) { std::cout<<env_value<<std::endl; } return true; } else{ std::cout<<opt<<std::endl; } } return true; }好啦,以上就是对自制myshell的一些基础功能的简单解析啦!