【Linux系统#4】从零开始手搓 Shell (超详解)_hush shell 源码分析
✨ 白头并非雪可替,相识已是上上签 🌏
📃个人主页:island1314
🔥个人专栏:Linux—登神长阶
⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏 💞 💞 💞
前言 🚀
今天我们来构建一下 shell,下面时我们这篇代码中主要要实现的四大内容
#include #include #include #include #include #include #include using namespace std;int main(){ while(true) // 不断重复该工作 { //PrintCommandLine(); // 1. 命令行提示符 //GetCommandLine() // 2. 获取用户命令 // ParseCommandLine(); // 3. 分析命令 //ExecuteCommand(); // 4. 执行命令 } return 0;}
用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串\"s\"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。
- 然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。
- 所以要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
在继续学习新知识前,我们来思考函数和进程之间的相似性
💢 exec/exit 就像 call/return
- 一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。
- 这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。
一个C程序可以 fork/exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过 exit(n) 来返回值。调用它的进程可以通过 wait(&ret)来 获取exit的返回值。
1. 命令行提示符 🔖
1.1 获取当前用户、主机、工作路径
#include #include #include #include #include #include #include #include using namespace std;string GetUserName() // 获取用户名{ string name = getenv(\"USER\"); //return name; // 为防止获取失败 return name.empty() ? \"None\" : name;}string GetHostName() // 获取主机名{ string hostname = getenv(\"HOSTNAME\"); return hostname.empty() ? \"None\" : hostname;}string GetPwd() // 获取当前工作路径{ string pwd = getenv(\"PWD\"); return pwd.empty() ? \"None\" : pwd;}
1.2 生成命令行提示符
string MakeCommandLine(){ // [lighthouse@VM-8-10-centos myshell] $ char command_line[basesize]; // 定义数组,使用接口 // snprintf 安全地把我们的参数按照指定格式写入到缓冲区字符串里 snprintf(command_line, basesize, \"[%s@%s %s]# \", \\ GetUserName().c_str(), GetHostName().c_str(), GetPwd().c_str()); return command_line;}
上面用到的 snprintf 通过man 了解可知
- int printf(const char *restrict format, ...); 输出到stdout标准化输出
- int fprintf(FILE *restrict stream, const char *restrict format, ...); 输出到stream文件流
- int sprintf(char *restrict str, const char *restrict format, ...); va_list格式参数输出到str字符指针
- int snprintf(char *restrict str, size_t size, const char *restrict format, ...); 输出到str字符指针(指定长度)
🍉 sprintf 和 snprintf 区别
sprintf() 函数可能会发生缓冲区溢出的问题,存在安全隐患,为了解决这个问题,引入了 snprintf() 函数。
🍋🟩 在该函数中,使用参数 size 显式的指定缓冲区的大小,如果写入到缓冲区的字节数大于参数 size 指定的大小,超出的部分将会被丢弃!如果缓冲区空间足够大,snprintf() 函数就会返回写入到缓冲区的字符数,与 sprintf() 函数相同,也会在字符串末尾自动添加终止字符 \'\\0\'
1.2 打印命令行提示符
void PrintCommandLine() // 打印命令行提示符{ printf(\"%s\", MakeCommandLine().c_str()); // 没有 \\n fflush(stdout); //让 printf 打印的字符串立马刷新出来}
🔥 使用 printf 或 cout 打印内容时,输出永远不会直接写入“屏幕”。 而是被发送到 stdout。
- stdout 就像一个缓冲区,默认情况下,发送到 stdout 的输出然后再发送到屏幕(我们可以根据需要将其重定向到其他文件/流)
- 同样,stdin 默认映射到键盘,但可以重定向到任何其他文件/流。
- 现在,默认情况下,stdout 是 行缓冲 的。 这意味着,发送到 stdout 的输出不会被立即发送到屏幕以供显示(或重定向文件/流),直到它在其中获得换行符。 因此,如果要覆盖默认缓冲行为,则可以使用 fflush 清除缓冲区(立即将所有内容发送到屏幕/文件/流)
1.4 代码演示
#include #include #include #include #include #include #include using namespace std;const int basesize = 1024;string GetUserName() // 获取用户名{ string name = getenv(\"USER\"); return name.empty() ? \"None\" : name;}string GetHostName() // 获取主机名{ string hostname = getenv(\"HOSTNAME\"); return hostname.empty() ? \"None\" : hostname;}string GetPwd() // 获取当前工作路径{ string pwd = getenv(\"PWD\"); return pwd.empty() ? \"None\" : pwd;}string MakeCommandLine() // 生成命令行提示符{ char command_line[basesize]; // 定义数组,使用接口 snprintf(command_line, basesize, \"[%s@%s %s]# \", \\ GetUserName().c_str(), GetHostName().c_str(), GetPwd().c_str()); return command_line;}void PrintCommandLine() // 打印命令行提示符{ printf(\"%s\", MakeCommandLine().c_str()); // 没有 \\n fflush(stdout); //让 printf 打印的字符串立马刷新出来}int main(){ while(true) // 不断重复该工作 { PrintCommandLine(); // 1. 命令行提示符 printf(\"\\n\"); sleep(1); } return 0;}
输出结果:
此时我们就可以每隔一秒地生成命令行提示符了
2. 获取命令 📚
当我们将上面 1.4 代码main函数内的换行删去,sleep休眠改为 100s 之后,如下输出:
我们可以把 ls -a -l 看作一个字符串来获取命令,来构建对应函数来接受
先对main 函数内进行修改
int main(){ char command_buffer[basesize]; while(true) // 不断重复该工作 { PrintCommandLine(); // 1. 命令行提示符 // command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串 if(!GetCommandLine(command_buffer, basesize)) // 2. 获取用户命令 { continue; } printf(\"%s\\n\", command_buffer); //测试 } return 0;}
2.1 函数实现
通过当前功能实现,从键盘中获取的命令字符串放到当前缓冲区里 command_buffer
下面这里可以使用 getline,而且还可以避免一些问题,但是为了更好了解后面的知识,我们就使用 fgets,函数实现:
// 把键盘获取的字符串放到当前缓冲区里bool GetCommandLine(char command_buffer[], int size){ // 认为:我们要将用户输入命令行,当做一个完整字符串 // \"ls -a -l -n\" char *result = fgets(command_buffer, size, stdin); if(!result) { return false; } command_buffer[strlen(command_buffer)-1] = 0; // 去掉最后的 \'\\n\' return true;}
-
char *fgets(char *restrict str, int size, FILE *restrict stream
💦 通俗来讲的话,fgets()函数的作用就是用来读取一行数据的。但要详细且专业的说的话,fgets()函数的作用可以这么解释:从第三个参数指定的流中读取最多第二个参数大小的字符到第一个参数指定的容器地址中。在这个过程中,在还没读取够第二个参数指定大小的字符前,读取到换行符\'\\n\'或者需要读取的流中已经没有数据了。则提前结束,并把已经读取到的字符存储进第一个参数指定的容器地址中
注意:fgets()函数的最大读取大小是其“第二个参数减1”,这是由于字符串是以’\\0’为结束符的,fgets()为了保证输入内容的字符串格式,当输入的数据大小超过了第二个参数指定的大小的时候,fgets()会仅仅读取前面的“第二个参数减1”个字符,而预留1个字符的空间来存储字符串结束符’\\0’。
2.2 代码演示
此时代码演示:
在上面我们可以发现,明明我们没有多输入换行符,为啥会多换行呢,这个是因为我们最后输入的回车键,由于给用户提供的需要一个纯净版字符串,因此我们需要做点处理
💫 在fgets()函数的眼里,换行符’\\n’也是它要读取的一个普通字符而已。在读取键盘输入的时候会把最后输入的回车符也存进数组里面,即会把’\\n’也存进数组里面,而又由于字符串本身会是以’\\0’结尾的。所以在输入字符个数没有超过第二个参数指定大小之前,你输入n个字符按下回车输入,fgets()存储进第一个参数指定内存地址的是n+2个字节。最后面会多出一个’\\n’和一个’\\0’,而且’\\n’是在’\\0’的前面一个(\\n\\0)。
注意:strlen 的头文件为 ,如果不带会报这样的错误
此时最后的输出就变为:
3. 分析命令 🔍
比如 ls -a -l ,后面带了各种选项,我们就需要解析一下,将其解析成字符数组的样子
int main(){ char command_buffer[basesize]; while(true) // 不断重复该工作 { PrintCommandLine(); // 1. 命令行提示符 // command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串 if(!GetCommandLine(command_buffer, basesize)) // 2. 获取用户命令 { continue; } //printf(\"%s\\n\", command_buffer); //测试 // ls -a -b -c 解析每个指令 > \"ls\" \"-a\" \"-b\" \"-c\" 拆成一个一个字符串 ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令 //ExecuteCommand(); // 4. 执行命令 } return 0;}
对我们的解析命令需要
定义几个全局数据来进行维护
const int basesize = 1024;const int argvnum = 64;// 全局的命令行参数表char *gargv[argvnum];int gargc = 0;
3.1 函数实现
void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令{ (void)len; // 避免不使用的时候告警 // 虽然定义的是全局默认为0,但是由于这些工作都是重复去做的,为保证安全性,需要局部初始为0 memset(gargv, 0, sizeof(gargv)); gargc = 0; // \"ls -a -l -n\" const char *sep = \" \"; //分隔符 // 切的字符串,分隔符 gargv[gargc++] = strtok(command_buffer, sep); // gargv 保存的是 ls // 传 nullptr,表示切历史上一次字符串,如果传command_buffer,就会重新开始切了 // = 是刻意写的 // bool 强转避免告警 while((bool)(gargv[gargc++] = strtok(nullptr, sep))); // 形成上面图片的结构}
strtok 函数分析:char *strtok(char *str, const char *delim)
功能:函数返回字符串 str 中紧接“标记”的部分的指针, 字符串 delim 是作为标记的分隔符。如果分隔标记没有找到,函数返回 NULL。为了将字符串转换成标记,第一次调用 str 指向作为标记的分隔符。之后所以的调用 str 都应为 NULL.
3.2 代码测试
我们在该分析函数下实现一个debug函数,来进行测试:
函数实现:
void debug(){ printf(\"argc: %d\\n\",gargc); // 打印当前的命令行字符串数目 for(int i = 0; gargv[i]; i++) { printf(\"argv[%d]: %s\\n\", i, gargv[i]); //打印当前所有命令行 }}
结果输出:
但是我们会发现最后输出的 argc 为4,明显可知我们最开始实现的函数还是有点不对的,最后等于 NULL 多加了一次,因此我们还需要做一次 argc --。
此时修改后的输出就对头了:
但是还是有个问题:
当我们直接回车的话,argc 也会输出一个1
原因:当我们直接进行回车时,fgets 获得了字符串(回车符),然后经过这个
command_buffer[strlen(command_buffer)-1] = 0; // 去掉最后的 \'\\n\'
导致其变为一个空串,空串的话也会正常解析,然后由于 gargv[garc++],正常 ++ 了,所以输出了 1,因此我们需要对上面第2点的代码做些修改,加上这个判定语句即可。
备注:
大家不会觉得刚刚那个 while 内的函数过于冗杂嘛,而且还需要 -- 一次,我们有个更好的方法来解决,如下:
void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令{ (void)len; // 避免不使用的时候告警 // 虽然定义的是全局默认为0,但是由于这些工作都是重复去做的,为保证安全性,需要局部初始为0 memset(gargv, 0, sizeof(gargv)); gargc = 0; // 拆分读取的字符串 // \"ls -a -l -n\" const char *sep = \" \"; //分隔符// // 方式一:// //strtok(command_buffer, sep); // 切的字符串,分隔符// gargv[gargc++] = strtok(command_buffer, sep); // gargv 保存的是 ls // // 传 nullptr,表示切历史上一次字符串,如果传command_buffer,就会重新开始切了// // = 是刻意写的// // bool 强转避免告警// while((bool)(gargv[gargc++] = strtok(nullptr, sep))); // 形成上面图片的结构// gargc--; // 避免等于 NUll 时多加 一次// 方式二; for(char* ch = strtok(command_buffer, sep); (bool) ch; ch = strtok(nullptr, sep)) { gargv[gargc++] = ch; }}
❓为啥用while 需要 -- ,用 for 不需要呢?
我们来看个例子就知道了
int main(){int i = 1, j = 1;while (i++ < 5) printf(\"%d \", i);printf(\"\\n========\\n\");for (j; j < 5; j++) printf(\"%d \", j);printf(\"\\n========\\n\");printf(\"%d %d\", i, j);return 0;}// 输出2 3 4 5========1 2 3 4========6 5
🌈 从上面我们就可以发现 whiel 循环会比 for 循环多 1,这是因为在后置 ++中, while 它会先判断条件,无论条件是否满足 都会 ++ 一次。
4. 执行命令 🖊
int main(){ char command_buffer[basesize]; while(true) // 不断重复该工作 { // 下面三步可以不断生成命令行提示符 PrintCommandLine(); // 1. 命令行提示符 // command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串 if(!GetCommandLine(command_buffer, basesize)) // 2. 获取用户命令 { continue; } //printf(\"%s\\n\", command_buffer); //测试 // ls -a -b -c 解析每个指令 > \"ls\" \"-a\" \"-b\" \"-c\" 拆成一个一个字符串 ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令 //debug(); ExecuteCommand(); // 4. 执行命令 } return 0;}
4.1 函数实现
对解析好的命令,开始执行,但是shell 不能自己去执行这个命令,因为如果我们输入错误的命令话,假如用户输入错误的命令或者有 Bug,导致 shell 本身就直接崩溃了,就不能再进行其他进程的命令行解析了,因此我们需要用子进程来执行,这样不会 影响到 shell 本身的执行
bool ExecuteCommand() // 4. 执行命令{ // 让子进程进行执行 pid_t id = fork(); if(id 0) { // Do Nothing return true; } return false;}
在命令行的输入的命令是如何被解析,谁来解析,又是如何传递给 子进程的
🍊 实际上是 shell 帮我们做解析,形成这个表,然后再进行程序替换,然后直接用 execp 的形式执行程序, 并把这个表传给对应的进程。
结果演示:
4.2 分析
再输入一些其他指令,我们可以发现:
我们输入路径回退之后,再用pwd查发现没有改变,即 cd 执行命令无效,这是什么原因呢?
🔥 每一个进程都有一个叫做 当前路径的概念!这就是为啥把新建文件默认把文件建在原路径上,因为会记录当前路径。因此我们需要用到了 chdir。由于是让子进程执行的 cd ..,因此我们改的是子进程的路径, 而 shell 路径未变,因此后面创建的子进程来执行命令时,仍然在 shell 原路径下,
因此我们可以得到一些结论:
- 在 shell 中有些命令,必须由子进程来执行,
- 有些命令,不能由子进程来执行,由shell 自己执行 --- 内建命令
- 内建命令之前我们也在这篇博客【Linux】进程详解:命令行参数、环境变量及地址空间中有提及。
【内部命令 vs. 外部命令】
(1)内部命令实际上是 shell 程序的一部分,其中包含的是一些比较简单的linux系统命令,这些命令由shell程序识别并在shell程序内部完成运行,通常在 linux 系统加载运行时 shell 就被加载并驻留在系统内存中。内部命令是写在bashy源码里面的,其执行速度比外部命令快,因为解析内部命令shell不需要创建子进程。比如:exit,history,cd,echo 等。
(2)外部命令是 linux 系统中的实用程序部分,因为实用程序的功能通常都比较强大,所以其包含的程序量也会很大,在系统加载时并不随系统一起被加载到内存中,而是在需要时才将其调用内存。通常外部命令的实体并不包含在shell中,但是其命令执行过程是由shell程序控制的。shell程序管理外部命令执行的路径查找、加载存放,并控制命令的执行。外部命令是在bash之外额外安装的,通常放在/bin,/usr/bin,/sbin,/usr/sbin......等等。可通过“echo $PATH”命令查看外部命令的存储路径,比如:ls、vi 等。
因此我们需要构建一个判定内建命令的函数
4.3 执行内建命令
shell 自己去执行命令
bool CheckAndExecBuiltCommand(){ //检测其是否为内建命令 -- 穷举法 if(strcmp(gargv[0], \"cd\") == 0) { // 内建命令 if(gargc == 2) { chdir(gargv[1]); // 可以用 chdir,自己调用自己的函数直接改变 shell 工作路径 } //else if(string) return true; } return false;}int main(){ char command_buffer[basesize]; while(true) // 不断重复该工作 { PrintCommandLine(); // 1. 命令行提示符 // command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串 if(!GetCommandLine(command_buffer, basesize)) // 2. 获取用户命令 { continue; } //printf(\"%s\\n\", command_buffer); //测试 // ls -a -b -c 解析每个指令 > \"ls\" \"-a\" \"-b\" \"-c\" 拆成一个一个字符串 ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令 //debug(); if(CheckAndExecBuiltCommand()) { continue; } ExecuteCommand(); // 4. 执行命令 } return 0;}
chdir 分析
- int chdir(const char * path);
- chdir()用户将当前的工作目录改变成以参数路径所指的目录。
- 返回值执行成功则返回0,失败返回-1,errno为错误代码。
4.4 GetPwd 完善
但是还有问题,通过 env 查的时候,发现其环境变量没有改变:
原因:路径发生变化了,环境变量没有进行修改,可知路径是需要维护的。
因此我们对应的当前路径不建议直接从 环境变量 去获取,应该从系统中获取,而且还需要进行更新,因此我们需要对 GetPwd() 进行修改。
const int argvnum = 64;// 全局的命令行参数表char *gargv[argvnum];int gargc = 0;string GetPwd() // 获取当前工作路径{ // 从 shell 系统中获取当前路径 if(getcwd(pwd, sizeof(pwd)) == nullptr ) return \"None\"; // 当前环境变量中工作路径的更新 snprintf(pwdenv, sizeof(pwdenv), \"PWD=%s\", pwd); putenv(pwdenv); // PWD = XXX return pwd; //string pwd = getenv(\"PWD\"); //return pwd.empty() ? \"None\" : pwd;}
- char *getcwd(char *buf,size_t size)
- getcwd()会将当前工作目录的绝对路径复制到参数buffer所指的内存空间中,参数size为buf的空间大小。
因此我们可以知道 pwd 的底层实现是调用 getcwd 这种系统级接口.
执行结果:
结论:环境变量是由 shell 自己去维护,环境变量表需要由 shell 自己去更新,这就是我们以前可以直接更改环境变量的原因,因为 shell 支持用户自己去更改
4.5 维护 Shell 环境变量表
🍉 虽然说 shell 会自己维护环境变量表,但是我们却从来没有见过这个环境变量表,所有的环境变量信息基本都是自己获取的,因此我们的 putenv 到底去哪呢?
🌈 shell 不是从 0 开始读取配置文件,而是从我们的系统直接启动的,所以我们刚刚对应的shell 期待的是我们系统对应的环境变量,也就是说 ./myshell 它自己环境变量就没有维护,它环境变量其实从父进程(系统进程)继承过来的,shell 里面就没有维护自己的环境变量表。
const int envnum = 64;// 我的系统的环境变量表char *genv[envnum]; //自己维护当前表
💌 如果要自己维护这个环境变量表的话,export、echo这种命令就不需要创建子进程来让子进程执行, 因为如果shell维护了当前的环境变量表的话,我们的echo命令肯定就需要从 0 实现的,让子进程来执行是不会影响当前的 shell 的,因此我们也可以发现其也是一个 内建命令。
// 作为一个 shell 获取环境变量应该从系统配置文件 来// 今天就直接从 父 shell 获取环境变量// 本质:把系统的环境变量拷贝到 shell 当中void InitEnv(){ extern char **environ; //获取环境变量 int index = 0; while(environ[index]) { genv[index] = (char*)malloc(strlen(environ[index]) + 1); //malloc 出自己维护的空间 // 拷贝复制 strncpy(genv[index], environ[index], strlen(environ[index]) + 1); index++; } genv[index] = nullptr;}
strncpy 函数用于将一个字符串复制到另一个字符串,但与strcpy 不同,它允许我们指定要复制的字符数量。这使得strncpy 在处理字符串复制时更加安全,特别是当目标缓冲区的大小已知时。
export 也是一个内建命令,原因:可以改当前的环境变量表,因此我们对内建命令的函数也要做修改
echo 是内建命令,因为其可以打印出本地变量,环境变量可以被子进程继承(环境变量的全局性),都是本地变量不能被子进程继承。
// 添加环境变量void AddEnv(const char *item){ int index = 0; while(genv[index]) { index++; } genv[index] = (char*) malloc(strlen(item)+1); // 重新申请空间 strncpy(genv[index], item, strlen(item)+1); // 拷贝复制 genv[++index] = nullptr; // 最后为空}// shell 自己执行命令, 本质:shell 调用自己的函数bool CheckAndExecBuiltCommand(){ //检测其是否为内建命令 -- 穷举法 if(strcmp(gargv[0], \"cd\") == 0) { // 内建命令 if(gargc == 2) { chdir(gargv[1]); // 可以用 chdir,自己调用自己的函数直接改变 shell 工作路径 } return true; } else if(strcmp(gargv[0], \"export\") == 0) { // export 也是内建命令 if(gargc == 2) { AddEnv(gargv[1]); //添加到环境变量表里面 } return true; } else if(strcmp(gargv[0], \"env\") == 0) { for(int i = 0; genv[i]; i++) { printf(\"%s\\n\", genv[i]); } return true; } return false;}
就可以在shell 中导入自己的本地变量
注意:当我们使用自己本地的环境变量的时候,我们上面 GetPwd函数就又会出现一点点问题,当我们 cd../时,仍然在原来路径。
因为我们此时 cd ../ 修改的是当前的系统的环境变量,而不是shell自己的环境变量表。
修改:
string GetPwd() // 获取当前工作路径{ // 从 shell 系统中获取当前路径 if(nullptr == getcwd(pwd, sizeof(pwd))) return \"None\"; // 当前环境变量中工作路径的更新 snprintf(pwdenv, sizeof(pwdenv), \"PWD=%s\", pwd); putenv(pwdenv); // PWD =xxx // 修改当前维护的环境变量表 for(int i = 0; genv[i]; i++) { string s = genv[i]; //获取当前字符串 if(s[0] == \'P\' && s[1] == \'W\' && s[2] == \'D\') { genv[i] = pwdenv; break; } } return pwd;}
4.6 Shell 环境变量表理解
我们再建一个文件来打印系统的环境变量,该文件继承的环境变量信息是从我们这继承的。
#include int main(int argc, char *argv[], char *env[]){ for(int i = 0; i < argc; i++) { printf(\"argv[%d]:%s\\n\", i, argv[i]); } for(int i = 0; env[i]; i++) { printf(\"env[%d]:%s\\n\", i, env[i]); } return 0;}
然后再在我们自己实现的 shell 命令下运行 该文件发现可以打印出正常的系统内的环境变量,
// 正常执行[lighthouse@VM-8-10-centos myshell]$ ./testenvargv[0]:./testenvenv[0]:XDG_SESSION_ID=369582env[1]:HOSTNAME=VM-8-10-centos....env[23]:_=./testenv// 输入以下指令:[lighthouse@VM-8-10-centos /home/lighthouse/112/lesson17/myshell]# export HAHA=aa// 输入指令 env 查询 shell 内的环境变量[lighthouse@VM-8-10-centos /home/lighthouse/112/lesson17/myshell]# envXDG_SESSION_ID=369582HOSTNAME=VM-8-10-centosTERM=xterm-256color..._=./myshellHAHA=aa// 通过 testenv 内代码打印系统的环境变量[lighthouse@VM-8-10-centos /home/lighthouse/112/lesson17/myshell]# ./testenv argv[0]:./testenvenv[0]:XDG_SESSION_ID=369582env[1]:HOSTNAME=VM-8-10-centos...env[23]:_=./myshell
为啥后面这个程序没有获得这个环境变量呢,由于我们自己实现的 shell 是系统的子进程,而这个文件是默认继承 shell 内的环境变量的,而我们在这自己实现的 shell 下再启动个testenv进程,此时是shell的孙子,但是无论是哪个,用的都是系统最开始的环境变量表。
因此我们该怎么保证我们未来子进程都使用我们自己维护的环境变量表呢?
修改如下:
bool ExecuteCommand() // 4. 执行命令{ // 让子进程进行执行 pid_t id = fork(); if(id 0) { // Do Nothing return true; } return false;}
演示:
[lighthouse@VM-8-10-centos /home/lighthouse/112/lesson17/myshell]# export HaHa=aa [lighthouse@VM-8-10-centos /home/lighthouse/112/lesson17/myshell]# envXDG_SESSION_ID=369582HOSTNAME=VM-8-10-centosTERM=xterm-256color..._=./myshellHaHa=aa[lighthouse@VM-8-10-centos /home/lighthouse/112/lesson17/myshell]# ./testenvargv[0]:./testenvenv[0]:XDG_SESSION_ID=369582env[1]:HOSTNAME=VM-8-10-centos...env[23]:_=./myshellenv[24]:HaHa=aa
💖 因此我们想说的是:命令行参数表是从命令行中获取,由shell 自己维护的,环境变量表是从系统文件读取也是由 shell 自己维护的,然后通过 execvpe 这样的系统调用接口把环境变量传给所有的子进程(环境变量具有全局性的根本原因)
echo 命令为啥也是内建命令(深理解)
🍒上面的 a=100可以看作字符串,系统除了命令行参数表、环境变量表以外还会维护一个本地变量表,但是本地变量表无法通过enecvpe 传递下去, a =100 这个字符串根本无法被子进程看到,此时的变量叫作本地变量
4.7 查看子进程的退出信息
echo $? 拿到上个进程的退出码,这个我们又该怎么去实现呢?
- 父进程执行命令的时候,定义一个全局最近进程退出时的退出码,每一个子进程执行结束,获取子进程退出信息并且更新退出码,echo以内建命令的形式打印出来,因此 ? 就相当于 shell 中的全局变量
具体操作 -- 实现内建命令 echo 的操作 + 全局变量作退出码
// 全局变量 -- 保存命令退出时的退出结果int lastcode = 0;bool ExecuteCommand() // 4. 执行命令{ pid_t id = fork(); if(id 0) { if(WIFEXITED(status)) { lastcode = WEXITSTATUS(status); } else { lastcode = 100; //非正常退出 } return true; } return false;}.....bool CheckAndExecBuiltCommand(){ //检测其是否为内建命令 -- 穷举法 if(strcmp(gargv[0], \"cd\") == 0) { // 内建命令 if(gargc == 2) { chdir(gargv[1]); // 可以用 chdir,自己调用自己的函数直接改变 shell 工作路径 lastcode = 0; } else { lastcode = 1; } return true; } else if(strcmp(gargv[0], \"export\") == 0) { // export 也是内建命令 if(gargc == 2) { AddEnv(gargv[1]); //添加到环境变量表里面 lastcode = 0; } else { lastcode = 2; } return true; } else if(strcmp(gargv[0], \"env\") == 0) { for(int i = 0; genv[i]; i++) { printf(\"%s\\n\", genv[i]); } lastcode = 0; return true; } else if(strcmp(gargv[0], \"echo\") == 0) { if(gargc == 2) { // echo $? // echo $PATH // echo hello if(gargv[1][0] == \'$\') { if(gargv[1][1] == \'?\') { printf(\"%d\\n\",lastcode); lastcode = 0; // 查一次清0 } } else { printf(\"%s\\n\", gargv[1]); } } else { lastcode = 3; } return true; } return false;}
由上面我们可以知道,shell 之所以可以拿到我们子进程的退出信息,是因为 shell 内部里维护了一个记录最近退出信息的退出码。
那么现在有个问题,为啥要进行进程等待呢?
- 回收僵尸进程
- 获得子进程的退出信息,因为父进程获得子进程的退出信息,可以自行抉择,比如:根据退出信息再跑一次子进程。
4.8 打印最近路径名
最后一个问题,我们的显示路径过长了,想让系统只显示最近路径的路径名,如下:
打印提示符的时候,提取最近一个目录名字即可,代码如下:
string LastDir(){ string curr = GetPwd(); if(curr == \"/\" || curr == \"None\") return curr; // /home/lighthouse/最近路径名 size_t pos = curr.rfind(\"/\"); // 从最后面开始找 if(pos == std::string::npos) return curr; // 未找到的情况 return curr.substr(pos + 1);}
5. 补充 📕
后面我换了 Ubuntu 系统测试之后,发现代码不知道为啥又多了新的问题还有其他的警告,如下:
我们先来分析一下这个警告是为啥?
1. 编译警告:snprintf
可能截断输出
原因 :
在 GetPwd()
函数中,snprintf(pwdenv, sizeof(pwdenv), \"PWD=%s\", pwd);
的格式化字符串 \"PWD=%s\"
可能超出 pwdenv
缓冲区的容量(1024字节)
pwdenv
缓冲区大小为 1024 字节。\"PWD=\"
占用 4 字节,剩余可用空间为1024 - 4 - 1 = 1019
字节(需保留 1 字节给字符串终止符\\0
)。- 当前代码
sizeof(pwdenv) - 4
计算为 1020,导致总长度可能达到4 + 1020 + 1 = 1025
,超出缓冲区容量
解决方案 :
调整格式化方式,确保不超过缓冲区大小。例如,限制 pwd
的最大复制长度:
snprintf(pwdenv, sizeof(pwdenv), \"PWD=%.*s\", static_cast(sizeof(pwdenv) - 5), pwd); // 保留5字节给\"PWD=\"
2. 运行时错误:std::logic_error
异常
原因 :
GetUserName()
和 GetHostName()
函数中,getenv
可能返回 nullptr
(环境变量不存在),直接用其构造 std::string
会触发未定义行为(C++禁止用 nullptr
初始化 std::string
)
#include #include int main(){std:: string s = nullptr;std::cout << \"nihao\" << std::endl;return 0;}
- 原因:string不能和nullptr 比较,但可以和\"\"空串比较,可使用s.empty()函数, s.length() == 0
因此以后实例化最好还是用 string s = \"\"; 或者 string s;
修复代码 :在返回前检查 getenv
的结果,避免空指针:
string GetUserName() { const char* user = getenv(\"USER\"); return user ? user : \"None\"; // 明确处理 nullptr [[9]]}string GetHostName() { const char* hostname = getenv(\"HOSTNAME\"); return hostname ? hostname : \"None\"; // 明确处理 nullptr [[9]]}
6. 补充 🎐
1. 错误修复
1.1 回车段错误
🔥 最核心的问题是:没有对 gargv[0]
是否为 NULL 进行判断
但如果你输入了一个空命令(比如只按了回车),或者输入了一个无法解析成有效命令的字符串(如 help
没有被任何逻辑处理),那么:
ParseCommandLine()
可能导致gargv[0] == NULL
- 然后调用
strcmp(NULL, \"xxx\")
就会触发 段错误(Segmentation Fault)
修改如下:
ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令if(gargc == 0){ continue; // 防止空命令导致 gargv[0] 为 NULL}if(CheckAndExecBuiltCommand())continue;
1.2 完善 ParseCommandLine()
,确保安全切分
当前写法:
gargv[gargc++] = strtok(command_buffer, sep); // gargv 保存的是 ls while((bool)(gargv[gargc++] = strtok(nullptr, sep))); // 形成上面图片的结构gargc--; // 避免等于 NUll 时多加 一次
如果用户输入全是空格,或无效内容,可能导致 strtok
返回 NULL
,从而 gargv[0] = NULL
。
建议修改为:
char *token = strtok(command_buffer, sep);if(token == nullptr){ return; // 如果没有 token,直接返回,避免后续出错}gargv[gargc++] = token;while((token = strtok(nullptr, sep)) != nullptr){ gargv[gargc++] = token;}
1.3 避免[island@None code]# export VAR^[[D^[[D^[[D^H^H^H^[[B^H^C
其实并不是你的 程序 bug ,而是你在终端中输入命令时使用了 键盘按键的转义序列字符(ANSI Escape Codes) ,这些 ^[
、D
、H
、B
等是 终端模拟器在你按下方向键、退格键等时发送给 shell 的原始控制字符 。
🧠 为什么会看到这些乱码?
✅ 背景知识
你现在实现的 shell 是一个 极简版的交互式 shell ,但它缺少了一个非常关键的功能模块:行编辑功能(Line Editing) 。
- 现代 shell(如 bash、zsh)之所以支持方向键、退格、Tab 补全、历史记录等功能,是因为它们都使用了 GNU Readline 库 来处理终端输入。
- 而你目前的 shell 使用的是标准的
fgets()
获取输入,它会直接把用户按下的每一个字符(包括退格、方向键等)当作普通文本传给你,不会做任何解释或清理。
实现
// 把键盘获取的字符串放到当前缓冲区里bool GetCommandLine(char command_buffer[], int size){ (void)size; char *line = readline(MakeCommandLine().c_str()); // 这里传入了提示符 if (!line) return false; if(strlen(line) > 0) add_history(line); // 添加到历史记录 strncpy(command_buffer, line, size - 1); command_buffer[size - 1] = \'\\0\'; // 防止溢出 free(line); return true;}
注意:可能会出现了 重复提示符 的问题,这是由于在使用 readline()
函数时传入了命令行提示符字符串,并且该函数本身已经负责打印提示符。
只要
2. 功能补充
2.1 Exit 实现
在 CheckAndExecBuiltCommand()
中添加:
else if(strcmp(gargv[0], \"exit\") == 0){ printf(\"Bye!\\n\"); exit(0);}
2.2 help 实现
else if(strcmp(gargv[0], \"help\") == 0){ printf(\"可用命令:\\n\"); printf(\" cd 切换目录\\n\"); printf(\" export 设置环境变量\\n\"); printf(\" env 查看所有环境变量\\n\"); printf(\" echo $var 打印变量或字符串\\n\"); printf(\" exit 退出 shell\\n\"); lastcode = 0; return true;}
2.3 echo 的 echo $ 查询变量
[island@None code]# export Mykey=123[island@None code]# echo MykeyMykey[island@None code]# echo $mykey
这样没有任何反应,因为 在 CheckAndExecBuiltCommand()
中的 echo
实现部分只处理了 $?
,没有实现对其他变量的解析。
if(gargv[1][0] == \'$\'){ if(gargv[1][1] == \'?\'){ printf(\"%d\\n\",lastcode); lastcode = 0; }}
解决办法:需要遍历 genv[]
查找变量名是否存在于环境中
else if (strcmp(gargv[0], \"echo\") == 0) { if (gargc == 2) { if (gargv[1][0] == \'$\') { if (gargv[1][1] == \'?\') { printf(\"%d\\n\", lastcode); lastcode = 0; } else { std::string var_name = &gargv[1][1]; // 去掉 $ const char* value = getenv(var_name.c_str()); if (value) { printf(\"%s\\n\", value); } else { printf(\"\\n\"); } lastcode = 0; } } else { printf(\"%s\\n\", gargv[1]); lastcode = 0; } } else { lastcode = 3; } return true;}
然后对于 AddEnv 函数还需要进行改进,代码如下:
void AddEnv(const char *item){ int index = 0; while(genv[index]) index++; genv[index] = (char*)malloc(strlen(item) + 1); strcpy(genv[index], item); // 创建副本进行解析 char* copy = new char[strlen(item) + 1]; strcpy(copy, item); char* key = strtok(copy, \"=\"); if(key != nullptr){ char* val = strtok(nullptr, \"\"); if(val != nullptr){ setenv(key, val, 1); // 更新系统环境变量 } } delete[] copy; genv[++index] = nullptr;}
2.4 jobs 功能
jobs 全局结构体定义如下:
enum JobState { RUNNING, STOPPED, DONE };struct Job { int jid; pid_t pid; string cmd; JobState state; // 替换 bool running};vector jobs;pid_t fg_pid = 0; int next_jid = 1;
子进程状态回调函数实现如下
void sigchld_handler(int sig) { (void)sig; int old_errno = errno; pid_t pid; int status; while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) { for (auto it = jobs.begin(); it != jobs.end(); ++it) { if (it->pid == pid) { if (WIFEXITED(status) || WIFSIGNALED(status)) { it->state = DONE; // jobs.erase(it); // 删除已完成的作业 } else if (WIFSTOPPED(status)) { it->state = STOPPED; } break; } } } errno = old_errno;}
执行指令函数需要修改,如下:
bool ExecuteCommand() // 4. 执行命令{ // 判断是否是后台运行:最后一个参数是否是 \'&\' bool background = false; if (gargv[gargc - 1] && strcmp(gargv[gargc - 1], \"&\") == 0) { gargv[--gargc] = nullptr; // 删除 & background = true; } pid_t id = fork(); if(id 0){ if(WIFEXITED(status)) { lastcode = WEXITSTATUS(status); } else { lastcode = 100; } return true; } } return false;}
Jobs 功能代码实现如下:
bool JobsCommand() { printf(\"JOBID\\tPID\\tCOMMAND\\t\\tSTATE\\n\"); for (const auto& job : jobs) { const char* state_str = \"Unknown\"; switch(job.state){ case RUNNING: state_str = \"Running\"; break; case STOPPED: state_str = \"Stopped\"; break; case DONE: state_str = \"Done\"; break; } printf(\"[%d]\\t%d\\t%s\\t\\t%s\\n\", job.jid, (int)job.pid, job.cmd.c_str(), state_str); } return true;}
3. 流程图
主体流程图
内建命令行判断 流程图如下:
jobs 的信号更新流程图如下:
7. 小结 📖
Makefile 如下:
code:code.ccg++ -o $@ $^ -std=c++17 -ljsoncpp -lreadline -lcurses.PHONY:cleanclean:rm -rf code
完整代码如下:
#include #include #include #include #include #include #include #include #include #include #include using namespace std; const int basesize = 1024;const int argvnum = 64;const int envnum = 64; // 全局的命令行参数表char *gargv[argvnum];int gargc = 0; // 全局变量 -- 保存命令退出时的退出结果int lastcode = 0; // 我的系统环境变量表char *genv[envnum]; //自己维护当前表 // 全局的当前 shell 工作路径char pwd[basesize];char pwdenv[basesize];enum JobState { RUNNING, STOPPED, DONE };struct Job { int jid; pid_t pid; string cmd; JobState state;};vector jobs;int next_jid = 1;pid_t fg_pid = 0;void Menu(){ printf(\"可用命令:\\n\"); printf(\" cd 切换目录\\n\"); printf(\" export 设置环境变量\\n\"); printf(\" env 查看所有环境变量\\n\"); printf(\" echo $var 打印变量或字符串\\n\"); printf(\" jobs查看后台作业\\n\"); printf(\" exit退出 shell\\n\");}string GetUserName() // 获取用户名{ const char* user = getenv(\"USER\"); return user ? std::string(user) : std::string(\"None\");} string GetHostName() // 获取主机名{ const char* hostname = getenv(\"HOSTNAME\"); return hostname ? std::string(hostname) : std::string(\"None\");} string GetPwd() // 获取当前工作路径{ // 从 shell 系统中获取当前路径 if(nullptr == getcwd(pwd, sizeof(pwd))){ pwd[0] = \'\\0\'; // 显式清空缓冲区 return \"None\"; } // 当前环境变量中工作路径的更新 snprintf(pwdenv, sizeof(pwdenv), \"PWD=%.*s\", static_cast(sizeof(pwdenv) - 5), pwd); // 保留4字节给\"PWD=\" putenv(pwdenv); // PWD =xxx // 修改当前维护的环境变量表 for(int i = 0; genv[i]; i++) { string s = genv[i]; //获取当前字符串 if(s[0] == \'P\' && s[1] == \'W\' && s[2] == \'D\') { genv[i] = pwdenv; break; } } return pwd; //string pwd = getenv(\"PWD\"); //return pwd.empty() ? \"None\" : pwd;} string LastDir(){ string curr = GetPwd(); if(curr == \"/\" || curr == \"None\") return curr; // /home/lighthouse/最近路径名 size_t pos = curr.rfind(\"/\"); // 从最后面开始找 if(pos == std::string::npos) return curr; // 未找到的情况 return curr.substr(pos + 1);} string MakeCommandLine() //生成命令行提示符{ // [lighthouse@VM-8-10-centos myshell] $ char command_line[basesize]; // 定义数组,使用接口 // snprintf 安全地把我们的参数按照指定格式写入到缓冲区字符串里 snprintf(command_line, basesize, \"[%s@%s %s]# \", \\ GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str()); return command_line;} void PrintCommandLine() // 1. 命令行提示符{ printf(\"%s\", MakeCommandLine().c_str()); // 没有 \\n fflush(stdout); // 让 printf 打印的字符串立马刷新出来} // 把键盘获取的字符串放到当前缓冲区里bool GetCommandLine(char command_buffer[], int size){ (void)size; char *line = readline(MakeCommandLine().c_str()); // 这里传入了提示符 if (!line) return false; if(strlen(line) > 0) add_history(line); // 添加到历史记录 strncpy(command_buffer, line, size - 1); command_buffer[size - 1] = \'\\0\'; // 防止溢出 free(line); return true;} void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令{ (void)len; // 避免不使用的时候告警 // 虽然定义的是全局默认为0,但是由于这些工作都是重复去做的,为保证安全性,需要局部初始为0 memset(gargv, 0, sizeof(gargv)); gargc = 0; // \"ls -a -l -n\" const char *sep = \" \"; //分隔符 //strtok(command_buffer, sep); // 切的字符串,分隔符 // 用个临时值来保存内容, 避免无效内容导致 strtok 返回 null char *token = strtok(command_buffer, sep); if(token == nullptr) return; gargv[gargc++] = token; // gargv 保存的是 ls while((token = strtok(nullptr, sep)) != nullptr){ gargv[gargc++] = token; }} void debug(){ printf(\"argc: %d\\n\",gargc); // 打印当前的命令行字符串数目 for(int i = 0; gargv[i]; i++) { printf(\"argv[%d]: %s\\n\", i, gargv[i]); //打印当前所有命令行 }} void sigchld_handler(int sig) { (void)sig; int old_errno = errno; pid_t pid; int status; while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) { for (auto it = jobs.begin(); it != jobs.end(); ++it) { if (it->pid == pid) { if (WIFEXITED(status) || WIFSIGNALED(status)) { it->state = DONE; // jobs.erase(it); // 删除已完成的作业 } else if (WIFSTOPPED(status)) { it->state = STOPPED; } break; } } } errno = old_errno;}// 在 shell 中// 有些命令,必须由子进程来执行// 有些命令,不能由子进程来执行,由shell 自己执行 --- 内建命令bool ExecuteCommand() // 4. 执行命令{ // 判断是否是后台运行:最后一个参数是否是 \'&\' bool background = false; if (gargv[gargc - 1] && strcmp(gargv[gargc - 1], \"&\") == 0) { gargv[--gargc] = nullptr; // 删除 & background = true; } pid_t id = fork(); if(id 0){ if(WIFEXITED(status)) { lastcode = WEXITSTATUS(status); } else { lastcode = 100; } return true; } } return false;} void AddEnv(const char *item){ int index = 0; while(genv[index]) index++; genv[index] = (char*)malloc(strlen(item) + 1); strcpy(genv[index], item); // 创建副本进行解析 char* copy = new char[strlen(item) + 1]; strcpy(copy, item); char* key = strtok(copy, \"=\"); if(key != nullptr){ char* val = strtok(nullptr, \"\"); if(val != nullptr){ setenv(key, val, 1); // 更新系统环境变量 } } delete[] copy; genv[++index] = nullptr;}bool JobsCommand() { printf(\"JOBID\\tPID\\tCOMMAND\\t\\tSTATE\\n\"); for (const auto& job : jobs) { const char* state_str = \"Unknown\"; switch(job.state){ case RUNNING: state_str = \"Running\"; break; case STOPPED: state_str = \"Stopped\"; break; case DONE: state_str = \"Done\"; break; } printf(\"[%d]\\t%d\\t%s\\t\\t%s\\n\", job.jid, (int)job.pid, job.cmd.c_str(), state_str); } return true;}// shell 自己执行命令, 本质:shell 调用自己的函数bool CheckAndExecBuiltCommand(){ //检测其是否为内建命令 -- 穷举法 if(strcmp(gargv[0], \"cd\") == 0) { // 内建命令 if(gargc == 2) { chdir(gargv[1]); // 可以用 chdir,自己调用自己的函数直接改变 shell 工作路径 lastcode = 0; } else{ lastcode = 1; } return true; } else if(strcmp(gargv[0], \"export\") == 0){ // export 也是内建命令 if(gargc == 2){ AddEnv(gargv[1]); //添加到环境变量表里面 lastcode = 0; } else{ lastcode = 2; } return true; } else if(strcmp(gargv[0], \"env\") == 0){ for(int i = 0; genv[i]; i++){ printf(\"%s\\n\", genv[i]); } lastcode = 0; return true; } else if(strcmp(gargv[0], \"echo\") == 0){ if(gargc == 2){ if(gargv[1][0] == \'$\'){ if(gargv[1][1] == \'?\'){ printf(\"%d\\n\",lastcode); lastcode = 0; } else{ std::string var_name = &gargv[1][1]; // 去掉 $ const char *value = getenv(var_name.c_str()); if(value) { printf(\"%s\\n\", value); } else { printf(\"\\n\"); } lastcode = 0; } } else{ printf(\"%s\\n\", gargv[1]); lastcode = 0; } } else{ lastcode = 3; } return true; } else if(strcmp(gargv[0], \"jobs\") == 0){ // pid_t pid = fork(); // if(pid == 0) execlp(\"ps\", \"\", nullptr); // else if(pid > 0) wait(nullptr); JobsCommand(); } else if(strcmp(gargv[0], \"help\") == 0){ Menu(); lastcode = 0; } else if(strcmp(gargv[0], \"exit\") == 0){ printf(\"Bye!\\n\"); exit(0); } return false;} // 作为一个 shell 获取环境变量应该从系统配置文件 来// 今天就直接从 父 shell 获取环境变量// 本质:把系统的环境变量拷贝到 shell 当中void InitEnv(){ extern char **environ; //获取环境变量 int index = 0; while(environ[index]) { genv[index] = (char*)malloc(strlen(environ[index]) + 1); //malloc 出自己维护的空间 // 拷贝复制 strncpy(genv[index], environ[index], strlen(environ[index]) + 1); index++; } genv[index] = nullptr;}void Free(){ for(int i = 0; genv[i]; ++i){ free(genv[i]); }} int main(){ signal(SIGCHLD, sigchld_handler); InitEnv(); // 初始化环境变量表 char command_buffer[basesize]; while(true) // 不断重复该工作 { // 下面三步可以不断生成命令行提示符 // 1. 命令行提示符 // PrintCommandLine(); // 这里就无需打印命令行提示符了 //printf(\"\\n\"); //sleep(1); // command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串 // 2. 获取用户命令 if(!GetCommandLine(command_buffer, basesize)) continue; //printf(\"%s\\n\", command_buffer); //测试 // ls -a -b -c 解析每个指令 > \"ls\" \"-a\" \"-b\" \"-c\" 拆成一个一个字符串 ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令 //debug(); if(gargc == 0) continue; // 防止空命令导致 gargv[0] 为 null if(CheckAndExecBuiltCommand()) continue; // 4. 执行命令 ExecuteCommand(); } Free(); return 0;}
【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就结束啦,如果我的这篇博客可以给你提供有益的参考和启示,可以三连支持一下 !💖💞!后面我们会讲到重定向,会对我们实现的 shell 进行完善,敬请期待啦