🔑🔑博客主页:阿客不是客
🍓🍓系列专栏:深入代码世界,了解掌握 Linux
欢迎来到泊舟小课堂
😘博客制作不易欢迎各位👍点赞+⭐收藏+➕关注
一、进程替换
之前我们通过写时拷贝,让子进程和父进程在数据上互相解耦,保证独立性。如果想让子进程和父进程彻底分开,让子进程彻彻底底地执行一个全新的程序,我们就需要 进程的程序替换。
为什么要进行程序替换?因为我们想让我们的子进程执行一个全新的程序。
那为什么要让子进程执行新的程序呢?我们一般在服务器设计的时候(Linux 编程)的时候,往往需要子进程干两件种类的事情:
- 让子进程执行父进程的代码片段(服务器代码…)
- 想让子进程执行磁盘中一个全新的程序(shell、想让客户端执行对应的程序、通过我们的进程执行其他人写的进程代码、C/C++ 程序调用别人写的 C/C++/Python/Shell/Php/Java...)
1.1 进程替换原理
📃 程序替换的原理:
- 将磁盘中的内存,加载入内存结构。
- 重新建立页表映射,设执行程序替换,就重新建立谁的映射(下图为子进程建立)。
- 效果:让父进程和子进程彻底分离,并让子进程执行一个全新的程序
这个过程有没有创建新的进程呢?没有!根本就没有创建新的进程!
因为子进程的内核数据结构根本没变,只是重新建立了虚拟的物理地址之间的映射关系罢了。内核数据结构没有发生任何变化! 包括子进程的 都不变,说明压根没有创建新进程。
1.2 认识进程替换(execl 接口)
我们要调用接口,让操作系统去完成这个工作 —— 系统调用。如何进行程序替换?我们先见见猪跑 —— 从 execl 这个接口讲,看看它怎么跑的。
int execl(const char* path, const char& arg, ...);
如果我们想执行一个全新的程序,我们需要做几件事情:
(要执行一个全新的程序,以我们目前的认识,程序的本质就是磁盘上的文件)
- 第一件事情:先找到这个程序在哪里。
- 第二件事情:程序可能携带选项进行执行(也可以不携带)。
明确告诉 OS,我想怎么执行这个程序?要不要带选项。
简单来说就是:① 程序在哪? ② 怎么执行?
所以,execl 这个接口就必须得把这两个功能都体现出来!
- 它的第一个参数是 path,属于路径。
- 参数 const char* arg, ... 中的 ... 表示可变参数,命令行怎么写(ls, -l, -a) 这个参数就怎么填。ls, -l, -a 最后必须以 NULL 结尾,表示 "如何执行程序的" 参数传递完毕。
💬 代码演示:exec():
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我是一个进程,我的PID是:%d\n", getpid());
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
printf("我执行完毕了,我的PID是:%d\n", getpid());
return 0;
}
🚩 运行结果如下:
刚才是带选项的,现在我们再来演示一下不带选项的:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我是一个进程,我的PID是:%d\n", getpid());
execl("/usr/bin/top", "top" , NULL);
printf("我执行完毕了,我的PID是:%d\n", getpid());
return 0;
}
🚩 运行结果如下:
这样我们的程序就直接能执行 top 命令了,除此之外,我们曾经学的大部分命令其实都可以通过 execl 执行起来。这就叫做 程序替换。
不知道大家有没有发现问题?代码和输出结果有什么不对劲的地方?
printf("我执行完毕了,我的PID是:%d\n", getpid());
最后一句代码 —— "我执行完毕了,我的PID是:%d" 似乎没有打印出来啊?
为什么我们最后的代码并没有被打印出来?
因为 一旦替换成功,是会将当前进程的代码和数据全部替换的!
所以自然后面的 printf 代码早就被替换了,这意味着该代码不复存在了,荡然无存!因为在程序替换的时候,就已经把对应进程的代码和数据替换掉了!而第一个 printf 执行了的原因自然是因为程序还没有执行替换。
这里的程序替换函数用不用判断返回值?为什么?
int ret = execl(...);
一旦替换成功,还会执行返回语句吗?返回值有意义吗? 没有意义的!
程序替换不用判断返回值!因为只要成功了,就不会有返回值,也不需要返回值。 而失败的时候,必然会继续向后执行。通过返回值最多能得到是什么原因导致替换失败的。只要执行了后面的代码,看都不用看,一定是替换失败了;只要有返回值,就一定是替换失败了。
我们来模拟一下失败的情况,我们来执行一个不存在的指令
💬 代码演示:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我是一个进程,我的PID是:%d\n", getpid());
execl("/usr/bin/Atop", "ls" , NULL);
printf("我执行完毕了,我的PID是:%d, ret: %d\n", getpid(), ret);
return 0;
}
🚩 运行结果如下:
💡 说明:execl 替换失败,就会继续向后执行。但是,一旦 execl 成功后就会跟着新程序的逻辑走,就不会再 return 了,再也不回来了,所以返回值加不加无所谓了。
诶……子进程执行程序替换,会不会影响父进程呢?不会!因为进程具有独立性。
为什么?如何做到的?子进程是如何做到代码和数据做分离的呢?让子进程与父进程做相似的代码片段,子进程改了,父进程也不受影响。
我们在前几章,讲过数据层面发生写时拷贝的概念。我们说过:fork 之后父子是共享的,如果要替换新的程序我能理解把新的程序的代码加载到内存里,我的子进程新的代码程序出来之后发生数据的写时拷贝,生成新的数据段。
不是说代码是共享的吗?我们该如何去理解呢?当程序替换的时候,我们可以理解成 —— 代码和数据都发生了写时拷贝,完成了父子分离。
1.3 更多 exec 接口
1. execlp 接口:无需带路径就能直接执行(可变参数列表)
int execlp(const char* file, const char* arg, ...);
execlp,它的作用和 execl 是一样的,它的作用也是执行一个新的程序。
仍然是需要两步:① 找到这个程序 ② 告诉我怎么执行
所以这一块的参数传递,和 execl 是一样的:
- 第一个参数为字符指针,指向要执行的可执行文件的名称
- 后续的参数为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
唯一的区别是比 execl 多了一个 p!我们执行指令的时候,默认的搜索路径在环境变量 中,所以这个 p 的意思是环境变量。
这意味着:执行 execlp 时,会直接在环境变量中找,不用去输路径了,只要程序名即可。而 execlp 可以不带路径,只说出你要执行哪一个程序即可,例如:子进程要执行一个ls命令,并带上 -al 参数:
execlp("ls", "ls", "-a", "-l", "NULL");
值得一提的是:这里出现的两个 ls 含义是不一样的,是不可以省略的。第一个参数是 "供系统去找你是谁的",后面的代表的是 "你想怎么去执行它" 。
💬 代码演示:
#include<stdio.h>
#include<unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0) {
// child
printf("我是子进程,我的PID是:%d\n", getpid());
execlp("ls", "ls", "-a", "-l", NULL);
exit(10); // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
}
printf("我是父进程,我的PID是: %d\n", getpid());
int status = 0;
int ret = waitpid(id, &status, 0);
// ret 接收的返回值不是exit的,而是子进程的pid
if (ret == id)
{
sleep(2);
printf("父进程等待成功!\n");
}
return 0;
}
🚩 运行结果如下:
2. execv 接口:以指针数组接收参数的
int execv(const char* path, char* const argv[]);
- 第一个参数为要执行程序的路径
- 第二个参数为一个指向字符指针数组的指针,数组中的每个元素都是一个指向字符串的指针,这些字符串就是命令行参数。最后要以NULL结尾。
大家在命令行上 $ ls -a -l ,在 execl 里我们是这么传的: "ls", "-a", "-l", NULL 。所以 execv 和execl 只有传参方式的区别,一个是可变参数列表 (l),一个是指针数组 (v)。值得注意的是,在构建 argv[] 的时,结尾仍然是要加上 NULL!
char* myargv[] = { "ls", "-l", "-a" NULL };
execv("/usr/bin/ls", myargv);
💬 代码演示:
#include<stdio.h>
#include<unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0) {
// child
printf("我是子进程,我的PID是:%d\n", getpid());
char* const argv_[] = {
(char*)"-ls",
(char*)"-l",
(char*)"-a",
NULL
};
execv("usr/bin/ls", argv_);
exit(10); // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
}
printf("我是父进程,我的PID是: %d\n", getpid());
int status = 0;
int ret = waitpid(id, &status, 0);
// ret 接收的返回值不是exit的,而是子进程的pid
if (ret == id)
{
sleep(2);
printf("父进程等待成功!\n");
}
return 0;
}
🚩 运行结果如下:
3. execvp 接口:无需带路径(指针数组)
int execvp(const char* file, char* const argv[]);
- 第一个参数为要执行程序的名称
- 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
看到这里,想必大家光看到这个接口的名字,就能猜到它是什么意思了。与 execlp 类似,execvp 也是带 p 的,执行 execvp 时,会直接在环境变量中中搜索可执行文件,只要程序名即可。
char* myargv[] = { "ls", "-l", "-a" NULL };
execvp("myargv[0]", myargv);
💬 代码演示:
#include<stdio.h>
#include<unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0) {
// child
printf("我是子进程,我的PID是:%d\n", getpid());
char* const argv_[] = {
(char*)"-ls",
(char*)"-l",
(char*)"-a",
NULL
};
execvp("argv_[0]", argv_);
exit(10); // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
}
printf("我是父进程,我的PID是: %d\n", getpid());
int status = 0;
int ret = waitpid(id, &status, 0);
// ret 接收的返回值不是exit的,而是子进程的pid
if (ret == id)
{
sleep(2);
printf("父进程等待成功!\n");
}
return 0;
}
🚩 运行结果如下:
4. 超级缝合怪 execvpe 接口
缝合怪罢了,v - 数组,p - 文件名,e - 可自定义环境变量:
int execvpe(const char* file, char* const argv[], char* const envp[]);
5. execle 接口:添加环境变量给目标进程
int execle(const char* path, const char* arg, ..., char* const envp[]);
我们可以使用 execle 接口传递环境变量,相当于自己把环境变量导进去。
- 第一个参数是要执行程序的路径
- 第二个参数是一个字符指针数组,为传递给可执行文件的命令行参数列表。arg[0] 通常是程序的名称,虽然实际上你可以传递任何字符串作为arg[0],最后要以NULL结尾。
- 最后一个参数是你自己设置的环境变量
创建 mycmd 文件,我们加上几句环境变量:
#include <iostream>
#include <stdlib.h>
int main()
{
std::cout << "PATH:" << getenv("PATH") << std::endl;
std::cout << "--------------------------------------\n";
std::cout << "MYPATH:" << getenv("MYPATH") << std::endl;
std::cout << "--------------------------------------\n";
std::cout << "hello c++" << std::endl;
return 0;
}
其中, 是自带的环境变量,
是我们自己的环境变量:
💬 代码演示:
#include<stdio.h>
#include<unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0) {
// child
printf("我是子进程,我的PID是:%d\n", getpid());
char* const __env[] =
{
(char*)"MYPATH=123456789",
NULL
};
execle("./mycmd", "mycmd", NULL, __env); // 手动传递环境变量
exit(10); // 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程
}
printf("我是父进程,我的PID是: %d\n", getpid());
int status = 0;
int ret = waitpid(id, &status, 0);
// ret 接收的返回值不是exit的,而是子进程的pid
if (ret == id)
{
sleep(2);
printf("父进程等待成功!\n");
}
return 0;
}
🚩 运行结果如下:
为什么没有打印内容啊?
我们自己定义的 环境变量也没出来,我们先把 mycmd 的
注释掉,然后再运行:
#include <iostream>
#include <stdlib.h>
int main()
{
// std::cout << "PATH:" << getenv("PATH") << std::endl;
// std::cout << "--------------------------------------\n";
std::cout << "MYPATH:" << getenv("MYPATH") << std::endl;
std::cout << "--------------------------------------\n";
std::cout << "hello c++" << std::endl;
return 0;
}
🚩 运行结果如下:
所以,为什么会出现这种情况?我自己不定义自己的环境变量时, 可以获取得到,一旦我传入了一个自己定义的环境变量时,
就打不出来了?!
execle 接口,这个 e 表示的是 添加环境变量给目标进程,如果是自己的变量,那么是覆盖式的。
6. 为什么会有这么多 exec 接口?
为什么搞这么多接口,这些接口好像没有太大的差别啊。
唯一的差别就是传参的方式不一样,有的带路径,有的不带路径,有的是列表传参,有的是数组传参,有的可带环境变量,有的不带环境变量。
因为要适配各种各样的应用场景,使用的场景不一样,有些人就喜欢列表传参,有些人喜欢数组传参。所以就配备了这么多接口,这就好比我们 C++ 函数重载的思想。
那为什么 execve 是单独的呢?
int execve(const char* file, char* const argv[], char* const envp[]);
它处于 man 2 号手册,execve 才属于是真正意义上的系统调用接口。而刚才介绍的那些,实际上就是为了适配各种环境而封装的接口:
总结一下它们的命名规律,通过这个来记忆对应接口的功能会好很多:
- l (list) :表示参数采用列表形式
- v (vector) :表示参数采用数组形式
- p (path):有 p 自动收缩环境变量 PATH
- e (env) :表示自己维护环境变量
二、实现简易 shell 脚本
shell就是一个命令解释器,它互动式地解释和执行用户输入的命令;当有命令要执行时,shell创建子进程让子进程去执行命令,而shell只需要等待子进程执行完退出即可。
具体步骤:
- 获取终端输入的命令
- 解析命令
- 创建子进程
- 对子进程进行程序替换
- 等待子进程执行完后退出
2.1 显示提示符和获取用户输入
shell 本质就是个死循环,我们不关心获取这些属性的接口我们先从简单的入手,先来实现前两步,显示提示符 和 获取用户输入:
我们需要按照终端的格式打印出命令提示符,那么就需要获取用户、主机和当前路径等信息,这就需要用到我们之前学习的获取环境变量的函数 getenv() :
💬 代码演示:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
// 用户名
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");
return pwd == NULL ? "None" : pwd;
}
int main()
{
// 显示提示符
printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
return 0;
}
🚩 运行结果如下:
接下来是获取用户输入的内容:
我们用户输入一行本质上是一行完整的字符串,我们用 scanf 是以空格为分割符,而我们的指令是带空格的,我们利用 fgets 函数从键盘上获取,标准输入 stdin,获取到 C 风格的字符串,注意默认会添加 \0 。
但因为后续还会涉及到很多字符串的操作,我们将文件改成 C++ 的格式,同时也需要修改文件后缀为 .cc ,修改 makefile 中的文件名和 编译方式(gcc -> g++)。但因为一些接口适配性的问题,为了方便入门学习,我们这里不使用 string 类型和 getline 函数,依旧按照 C 语言风格来执行。
💬 代码演示:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#define COMMAND_SIZE 1024
// 用户名
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
// 主机名
const char *GetHostName()
{
const char *hostname = getenv("HOST");
return hostname == NULL ? GetUserName() : hostname;
}
// 当前路径
const char *GetPwd()
{
const char *pwd = getenv("PWD");
return pwd == NULL ? "None" : pwd;
}
int main()
{
// 显示提示符
printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
// 获取用户输入
char commandline[COMMAND_SIZE];
char *res = fgets(commandline, COMMAND_SIZE, stdin);
printf("%s\n", commandline);
return 0;
}
🚩 运行结果如下:
为什么这最后有一行空行?因为我们输入最后按了回车,commandline 里有一个 \n,我们把它替换成 \0 即可:
commandline[strlen(commandline) - 1] = '\0'; // 消除 '\n'
🚩 运行结果如下:
至此,我们已经完成了提示用户输入,并且也获取到用户的输入了。
但是目前的代码有些许不美观,我们换成其他的方式:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s:%s]#"
// 用户名
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
// 主机名
const char *GetHostName()
{
const char *hostname = getenv("HOST");
return hostname == NULL ? GetUserName() : hostname;
}
// 当前路径
const char *GetPwd()
{
const char *pwd = getenv("PWD");
return pwd == NULL ? "None" : pwd;
}
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
// 输入命令行提示符
void PrintfCommandPrompt()
{
char prompt[COMMAND_SIZE];
// 将格式化内容(提示符行)输入进字符串prompt
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
//接收指令
bool GetCommandLine(char *out, int size)
{
// ls -a -l => "ls -a -l\n"
char *res = fgets(out, size, stdin);
if(res == NULL)
return false;
out[strlen(out) - 1] = '\0'; // 消除 '\0'
return strlen(out) == 0 ? false : true;
}
int main()
{
// 1.输出命令行提示符
// printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
PrintfCommandPrompt();
// 2.获取用户输入的命令
// char commandline[COMMAND_SIZE];
// // ls -a -l => "ls -a -l\n"
// char *res = fgets(commandline, COMMAND_SIZE, stdin);
// commandline[strlen(commandline) - 1] = '\0'; // 消除 '\0'
// printf("%s\n", commandline);
char commandline[COMMAND_SIZE];
if(GetCommandLine(commandline, sizeof(commandline)))
{
printf("%s\n", commandline);
}
return 0;
}
其中值得注意的是,我们将命令提示符的内容使用 define 方便我们进行修改。
snprintf 是将格式化内容输入进字符串中,并给出字符串的起始地址和长度
🚩 运行结果如下:
2.2 将接收到的字符串拆开
我们系统读取指令肯定不是读取一个完整的字符串,而是拆分成一个个命令行参数,下面我们需要 将接收到的字符串拆开,比如:把 "ls -a -l" 拆成 "ls" "-a" "-l"
我们可以使用 strtok 函数,将一个字符串按照特定的分隔符打散,将子串依次返回:
char* strtok(char* str, const char* delim);
💬 代码演示:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s:%s]#"
// 下面是shell定义的全局数据
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;
// 用户名
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
// 主机名
const char *GetHostName()
{
const char *hostname = getenv("HOST");
return hostname == NULL ? GetUserName() : hostname;
}
// 当前路径
const char *GetPwd()
{
const char *pwd = getenv("PWD");
return pwd == NULL ? "None" : pwd;
}
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
// 输入命令行提示符
void PrintfCommandPrompt()
{
char prompt[COMMAND_SIZE];
// 将格式化内容(提示符行)输入进字符串prompt
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
//接收指令
bool GetCommandLine(char *out, int size)
{
// ls -a -l => "ls -a -l\n"
char *res = fgets(out, size, stdin);
if(res == NULL)
return false;
out[strlen(out) - 1] = '\0'; // 消除 '\0'
return strlen(out) == 0 ? false : true;
}
// 命令行拆分
bool CommandParse(char *commandline)
{
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, " ");
while(g_argv[g_argc++] = strtok(nullptr, " "));
return true;
}
void PrintArgv()
{
for(int i = 0; g_argv[i]; i++)
{
printf("argv[%d]: %s\n", i, g_argv[i]);
}
}
int main()
{
while(true)
{
// 1.输出命令行提示符
// printf("[%s@%s:%s]# ", GetUserName(), GetHostName(), GetPwd());
PrintfCommandPrompt();
// 2.获取用户输入的命令
// char commandline[COMMAND_SIZE];
// // ls -a -l => "ls -a -l\n"
// char *res = fgets(commandline, COMMAND_SIZE, stdin);
// commandline[strlen(commandline) - 1] = '\0'; // 消除 '\0'
// printf("%s\n", commandline);
char commandline[COMMAND_SIZE];
if(GetCommandLine(commandline, sizeof(commandline)))
{
printf("%s\n", commandline);
}
// 3.命令行分析 "ls -a -l" -> "ls" "-a" "-l"
CommandParse(commandline);
PrintArgv();
}
return 0;
}
🚩 运行结果如下:
2.3 通过创建进程和进程替换执行程序
我们命令存放在 g_argv 之中了,那要怎么执行呢?
我们命令可以看成一个个程序,所以为了不影响主进程,我们创建一个新的进程来执行命令,同时主进程进行进程等待,等待子进程的结束。
我们在子进程中要执行命令,自然需要使用进程替换,来把数组中的内容替换成进程,那么就需要用到带 v 的 exec 接口,因为程序名不带路径,所以需要从环境变量里面去找,要用到 execvp。
💬 代码演示:
// 4.执行命令
pid_t id = fork();
if(id == 0)
{
// child
execvp(g_argv[0], g_argv);
exit(1);
}
// father
pid_t rid = waitpid(id, nullptr, 0);
(void)rid;
🚩 运行结果如下:
2.4 让命令结果高亮
还有很多地方不完美,比如:在系统中使用 ls 命令,可执行程序是高亮的:
那么如何让我们的命令带颜色呢?
💬 代码演示:
// 命令行拆分
bool CommandParse(char *commandline)
{
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, " ");
if (strcmp(g_argv[0] , "ls") == 0)
g_argv[g_argc++] = (char*)"--color=auto";
while(g_argv[g_argc++] = strtok(nullptr, " "));
g_argc--;
return true;
}
🚩 运行结果如下:
2.5 内建命令:实现路径切换
目前还有一个问题,我们 cd.. 回退到上级目录时,我们的路径是不发生变化的:
真相:虽然系统中存在 cd 命令,但我们写的 shell 脚本中用的根本就不是这个 cd 命令。
当你在执行 cd 命令时,调用 execvp 执行的实际上是系统特定路径下的 cd,它只影响了子进程,如果我们直接 exec* 执行 cd,那么最多只是让子进程进行路径切换。但是请不要忘了:子进程是一运行就完毕的进程!运行完了你切换它的路径,毫无意义。所以,我们在 shell 中,更希望谁的路径发生变化呢?父进程!(shell 本身)
这部分由 shell 自己执行的命令,我们称之为 内建指令。
下面我们就来解决路径切换的问题,这一步应该需要在第四步之前,顶替执行命令成为第四步:
// 家目录路径
const char *GetHome()
{
const char *home = getenv("HOME");
return home == NULL ? "None" : home;
}
// 检测并处理内建命令
bool CheckAndExecBuiltin()
{
if(strcmp(g_argv[0], "cd") == 0)
{
// g_argv 中只有一个参数 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];
chdir(where.c_str());
}
return true;
}
return false;
}
int main()
{
while(true)
{
// ...
// 4.检测并处理内建命令
if(CheckAndExecBuiltin())
continue;
// ...
}
return 0;
}
🚩 运行结果如下:
似乎是成功执行了,但问题是命令行提示符这么为什么没变?
当一个进程的工作路径发生变化的时候,此时系统还存在一个环境变量 pwd。我们在系统中更改工作路径后,shell 会立刻更新环境变量 pwd,二者的值会保持一致,所以系统优化为使用命令 pwd 是查看的是当前工作路径,而不是去环境变量中查找 pwd。
而我们自己创建的 shell 并没有更新环境变量的功能,但是系统执行 pwd 命令还是按照原先查看当前工作路径,所以就能正常显示;而命令行提示符是从环境变量中获取的 pwd,所以依然保持不变。
那我们该怎么处理呢?我们使用一个系统调用:getcwd(),用来获取当前工作路径,并修改环境变量。
💬 代码演示:
char cwd[COMMAND_SIZE];
// 当前路径
const char *GetPwd()
{
// const char *pwd = getenv("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;
}
🚩 运行结果如下:
2.6 echo 命令
echo 同样也是内建命令,我们想打印环境变量,打印退出码等等,都需要 echo。
所以我们在执行程序时获取退出码 status,并将其保存在全局变量 lastcode 中,再在内建命令函数中实现 echo 函数。
// 最近一个程序的退出码
int lastcode = 0;
bool Echo()
{
if(g_argc == 2)
{
// echo "hello"
// echo $?
// echo $PATH
std::string opt = g_argv[1];
if(opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0;
}
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;
}
else if(opt[0] == '"')
{
std::string new_str(opt.begin() + 1, opt.end() - 1); // 去掉首尾双引号
std::cout << new_str << std::endl;
}
}
return true;
}
// 检测并处理内建命令
bool CheckAndExecBuiltin()
{
if(strcmp(g_argv[0], "cd") == 0)
{
Cd();
return true;
}
else if(strcmp(g_argv[0], "echo") == 0)
{
Echo();
return true;
}
return false;
}
// 执行程序
int Execute()
{
pid_t id = fork();
if(id == 0)
{
// child
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
// father
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
}
(void)rid;
return 0;
}
int main()
{
// ...
return 0;
}
🚩 运行结果如下:
2.7 再次理解环境变量
一个真正的 shell 还会存在一张环境变量表,启动的时候正常情况下是从系统配置文件中读取,但我们想模拟实现的时候,暂时做不到从配置文件中读取,所以我们的环境信息,从 父shell 中读取
获取环境变量,直接遍历环境变量列表就行:
// 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;
// 创建环境变量表
void InitEnv()
{
extern char** environ;
memset(g_env, 0, sizeof(environ));
g_envs = 0;
//1. 获取环境变量
for (int i = 0; environ[i]; i++)
{
// 为每个环境变量分配内存并复制
g_env[i] = new char[strlen(environ[i]) + 1]; // +1 用于 '\0' 结尾
strcpy(g_env[i], environ[i]);
g_envs++;
}
g_env[g_envs] = NULL;
}
获取完环境变量表之后,将其导入我的 shell:
//2. 导成环境变量
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
为了证明这确实是我自己导入的环境变量表,我们在最后加入一个特殊的环境变量:
// ...
g_env[g_envs++] = "HAHA=for_my_test";
g_env[g_envs] = NULL;
// ...
🚩 运行结果如下:
📚 环境变量的数据在进程的上下文中:
- 环境变量会被子进程继承下去,所以他会有全局属性。
- 当我们进行程序替换时, 当前进程的环境变量非但不会替换,而且是继承父进程的!
- 如果你不传环境变量表,默认子进程全部都会自动继承。
- 如果你 exel 函数簇带 e,就相当于你选择了自己传,就会覆盖式地把原本的环境变量弄没,然后你自己交给子进程。如果不带 e,那么环境变量就会自己被子进程继承。
- 如果既不想覆盖系统,也不想新增,所以我们采用 putenv 的方式向父 Shell 获取导入新增一个它自己的环境变量,这样的话原始的环境变量还在,我们能在子进程的 shell 上下文上给它新增环境变量。
所以,如何理解环境变量具有全局属性?因为所有的环境变量会被当前进程之下的所有子进程默认继承下去。
如何在子进程 Shell 内部自己导入新增自己的环境变量?putenv,要注意的是,需要一个独立的空间,放置环境变量的数据被改写。
2.8 完整代码
我们为了美观,对应代码进行了一定程度的排版上的修改,功能无变化。
💬 代码演示:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s:%s]#"
// 下面是shell定义的全局数据
// 1.命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;
// 2.环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;
// 测试使用
char cwd[COMMAND_SIZE];
char cwdenv[COMMAND_SIZE];
// 最近一个程序的退出码
int lastcode = 0;
// 用户名
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
// 主机名
const char *GetHostName()
{
const char *hostname = getenv("HOST");
return hostname == NULL ? GetUserName() : hostname;
}
// 当前路径
const char *GetPwd()
{
// const char *pwd = getenv("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 ? "None" : home;
}
// 创建环境变量表
void InitEnv()
{
extern char** environ;
memset(g_env, 0, sizeof(environ));
g_envs = 0;
//1. 获取环境变量
for (int i = 0; environ[i]; i++)
{
// 为每个环境变量分配内存并复制
g_env[i] = new char[strlen(environ[i]) + 1]; // +1 用于 '\0' 结尾
strcpy(g_env[i], environ[i]);
g_envs++;
}
g_env[g_envs++] = "HAHA=for_my_test";
g_env[g_envs] = NULL;
//2. 导成环境变量
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
}
bool Cd()
{
// g_argv 中只有一个参数: 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];
chdir(where.c_str());
}
return true;
}
void Echo()
{
if(g_argc == 2)
{
// echo "hello"
// echo $?
// echo $PATH
std::string opt = g_argv[1];
if(opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0;
}
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;
}
else if(opt[0] == '"')
{
std::string new_str(opt.begin() + 1, opt.end() - 1); // 去掉首尾双引号
std::cout << new_str << std::endl;
}
}
}
// 测试:将格式化内容(提示符行)输入进字符串prompt
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
// 1.输入命令行提示符
void PrintfCommandPrompt()
{
char prompt[COMMAND_SIZE];
// 将格式化内容(提示符行)输入进字符串prompt
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
// 2.接收指令
bool GetCommandLine(char *out, int size)
{
// ls -a -l => "ls -a -l\n"
char *res = fgets(out, size, stdin);
if(res == NULL)
return false;
out[strlen(out) - 1] = '\0'; // 消除 '\0'
return strlen(out) == 0 ? false : true;
}
// 3.命令行拆分(导入命令行参数表)
bool CommandParse(char *commandline)
{
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, " ");
if (strcmp(g_argv[0] , "ls") == 0)
g_argv[g_argc++] = (char*)"--color=auto";
while(g_argv[g_argc++] = strtok(nullptr, " "));
g_argc--;
return g_argc > 0 ? true : false;
}
// 测试:打印命令行参数 argv
void PrintArgv()
{
for(int i = 0; g_argv[i]; i++)
{
printf("argv[%d]: %s\n", i, g_argv[i]);
}
}
// 4.检测并处理内建命令
bool CheckAndExecBuiltin()
{
if(strcmp(g_argv[0], "cd") == 0)
{
Cd();
return true;
}
else if(strcmp(g_argv[0], "echo") == 0)
{
Echo();
return true;
}
return false;
}
// 5.执行程序
int Execute()
{
pid_t id = fork();
if(id == 0)
{
// child
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
// father
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
}
(void)rid;
return 0;
}
int main()
{
// shell启动的时候,从系统中获取环境变量
// 我们自己的 shell 环境变量信息应该从父 shell 中获得
InitEnv();
while(true)
{
// 1.输出命令行提示符
PrintfCommandPrompt();
// 2.获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
//printf("%s\n", commandline);
// 3.命令行分析 "ls -a -l" -> "ls" "-a" "-l"
if(!CommandParse(commandline))
continue;
// PrintArgv();
// 4.检测并处理内建命令
if(CheckAndExecBuiltin())
continue;
// 5.执行命令
Execute();
}
return 0;
}