创建多进程(demo)
一般而言(传参数的小习惯):输入: const & 输出:* 输入输出: &。简单说明一下下边CreateChildProcess函数,除去if(id == 0)的代码,其他的代码都是由父进程执行的,因此可以使用for循环来创建子进程。再注意一点,子进程结束自己任务之后一定要exit退出,不然它就会去执行父进程的代码了。
cpp
#include<iostream>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<stdlib.h>
#include<vector>
#include<stdio.h>
#include<string>
typedef void (*call_back)();//回调子进程所要做的事情的函数
void Hello()
{
int cnt = 5;
while(cnt--)
{
std::cout << "hello xxc" << std::endl;
sleep(1);
}
}
//创建num个子进程
void CreateChildProcess(int num, std::vector<pid_t>* v, call_back cb)
{
for(int i = 0;i < num;i++)
{
pid_t id = fork();
if(id == 0)
{
cb();
exit(0);
}
//到这就肯定是父进程,因为fork给父进程返回的是子进程的pid,肯定是大于0的
v->push_back(id);
}
}
//回收所有子进程
void WaitAllChild(const std::vector<pid_t>& v)
{
for(const auto& rid : v)
{
int status = 0;
pid_t rrid = waitpid(rid, &status, 0);
if(rrid == rid)
{
printf("等待成功, exit_code: %d\n", WEXITSTATUS(status));
sleep(1);
}
}
}
//枚举类型
//自定义错误码信息,枚举默认值从0开始
enum
{
OK,
USENUM_ERR
};
//启动多进程的方案
//./test7.exe 5
int main(int argc, char* argv[])
{
if(argc != 2)
{
std::cout << "Usage: " << argv[0] << " process num" << std::endl;
exit(USENUM_ERR);
}
//由于命令行参数是按一个个字符串的形式读进来的,所以要转化一下
int num = std::stoi(argv[1]);
//存所有子进程的pid
std::vector<pid_t> subs;
//创建多进程(具体创建几个进程由输入的命令行参数值来确定)
CreateChildProcess(num, &subs, Hello);
WaitAllChild(subs);
return 0;
}
进程程序替换
概念简介
程序替换,就是在同一个进程里,把磁盘上新可执行程序的代码和数据,加载到该进程的物理内存空间中,直接覆盖掉进程原有的代码和数据。进程原本按顺序执行代码,一旦执行到程序替换操作,后面未执行的原有代码就全部作废(因为被替换掉了),系统转而运行新程序的内容。整个过程进程本身不变,只是内部运行的程序被彻底替换了。
程序替换并没有创建新进程,因为进程的PID并未改变。程序替换改的只是物理内存,最多如果新进程替换进来的代码和数据比较多,页表对应的物理地址部分也可能要变大(存更多地址),虚拟地址空间也要相应的可能各个区域位置要调整一下大小,但是总的来说几乎没有什么变化,变的主要是物理内存。是否有新进程创建的标准是PID是否改变。(具体证明放后边)
程序替换的本质就是IO,就是拷贝,把磁盘里的代码和数据拷贝到物理内存里。之前讲冯诺依曼的时候说过程序要运行,必须先加载到内存,原因就是CPU跟内存直接交互效率比较高,但是怎么做到的呢?这本质就是程序替换,就是IO,由软硬件的管理者OS去做这个IO的过程,而这个需求又是用户发出来的,因此OS肯定会提供系统调用来完成这个加载的过程,这个系统调用就叫做程序替换,就是帮助我们完成加载的。
那如果是父子关系呢?就是如果是让子进程做程序替换,父进程依旧执行自己原来的代码,那程序替换的时候是怎么做的呢?fork创建子进程的时候会以父进程的PCB,虚拟地址空间,页表为模板拷贝一份给子进程,只做细微的修改,由页表里的虚拟地址映射物理地址,如果代码和数据不做修改,父子进程指向的是同一块物理地址,但现如今子进程需要程序替换,为了保证进程之间的独立性,会发生写时拷贝,这次不仅是拷贝数据了,代码也会发生拷贝,就是在物理内存中新开辟两块空间给子进程,将新进程的代码和数据程序替换进去,父进程原有代码不变,至此,父子进程彻底分离。
程序替换最广泛的应用就是fork之后,让子进程运行一段全新的程序,命令行解释器bash就是通过创建子进程做程序替换去完成我们输入的一系列指令的。
程序替换函数
程序替换主要有6个,都是以exec开头。注意exec*系列函数执行成功的话,是没有返回值的,因为一旦成功,程序就被替换了,返回值没什么意义。如果程序替换失败了就会有返回值-1并且会设置错误码,那什么时候会替换失败呢?程序路径写错或者exec*的参数写错。注意对于程序替换的部分函数来说,部分参数可以省略,系统会按默认规则补全,但不推荐这样干。
int execl(const char *path, const char *arg, ...);
execl:l是list(链表)的意思,第一个参数path表示你要执行的程序在哪里,路径/文件名。接下去的参数简单说明就是你在命令行怎么写,这里就怎么写(你想怎么执行这个程序就怎么写),最后以NULL结尾,...表示的是可变参数。其实执行任何一个程序都要有两个步骤,第一步:找到它并且加载它,第二步:你想怎么执行它。如果这里execl执行失败才会去执行exit,否则不会去执行程序替换之后的所有代码,我都替换掉了,走的是新进程的代码了,因此我们也从来不会去额外判断execl的返回值,而是直接通过exit来设置错误码,因为只要替换失败,程序就会继续往后执行。
cpp
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 #include<sys/wait.h>
5 #include<sys/types.h>
6
7 int main()
8 {
9 pid_t id = fork();
10 if(id == 0)
11 {
12 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
13 exit(0);
14 }
15 //父进程
16 wait(NULL);
17 printf("我是父进程\n");
18 printf("我是父进程\n");
19 printf("我是父进程\n");
20 printf("我是父进程\n");
21 printf("我是父进程\n");
22
23 return 0;
24 }

int execlp(const char *file, const char *arg, ...);
execlp:有了上边的基础,从此往后的exec*函数就好学了。execlp的p代表的是环境变量PATH的意思,第一个参数表示的是程序名,不用指定路径了,因为它会自动去环境变量PATH里所表明的路径下查找,其余参数跟execl没区别。问:下图中的两个ls重复吗?答:不重复,意思不一样,第一个ls的意思是PATH里要找哪个程序,名字告诉OS,第二个ls的意思是程序怎么执行。

int execv(const char *path, char *const argv\[\]);
execv:v表示vector的意思,execv跟execl唯一的区别就是它没有可变参数了,它用了一个argv数组去代替了这个可变参数,你就可以理解为它给之前execl传的那些可变参数整合成了一个数组叫argv,argv数组被const修饰,const修饰的是它的内容,也就是一个个的char*指针,说白了就是argv数组里的每一个指针的类型是char* const,指针的指向不能改但指向的内容可以改。
解释一下下边的argv数组,里边的字符串应为const char*类型,所以这个给它们强转了,根据C语言知识,传参的时候权限可以缩小,所以argv可以传给execv,再往深了想,ls是用C语言写的,它就是一个可执行程序,里边一定有main函数,所以这个argv里的参数本质上就是在传给ls里的main函数的命令行参数,由bash完成,我们平常在命令行解释器上输入指令和标签来执行指令的本质就是通过execv这种函数来帮我们对bash里fork的子进程做程序替换。

int execvp(const char *file, char *const argv\[\]);
execvp:跟execv几乎一样,除了其第一个参数是只需要传文件名就可以了,意思跟execlp一样。值得一提的是,execvp的第一个参数可以直接传argv0,但是OS里的ls是重命名过的,所以有颜色高亮,如果我们也想要高亮就要在argv里多加一个字符串,因为你不指定这个"--color=auto"的话,execvp是去PATH里找的,路径就变成/usr/bin/ls了,这样在运行的时候肯定是没有高亮的。

int execle(const char *path, const char *arg, ...,char *const envp\[\]);
int execve(const char *path, char *const argv\[\], char *const envp\[\]);
int execvpe(const char *file, char *const argv\[\], char *const envp\[\]);
execvpe:名字里带e的函数有三个,分别是execve,execle,execvpe,execvpe作为样例说明,其他两个要么就是传可变参数还是参数列表的区别,要么就是传路径还是只要传文件名的区别。
所有的程序替换函数,除了能替换系统指令,其实只要是一个进程,都能被替换。execvpe的e表示的是环境变量的意思,它的函数参数多了一个叫envp的数组,它支持我们传入全新的环境参数,如果我们自定义了一个环境变量表传给了execvpe,那么它会传全新的环境变量表覆盖父进程原来的环境变量表,如果我们传environ(之前博客里说过),那么它还是传父进程老的环境变量表,如果我们既想传老的环境变量表然后又想新添加几个新的环境变量一起传过去,就可以在传之前先用putenv加进去(这个后边会演示)。其实你第三个参数传一个空envp进去也依旧能跑,因为子进程创建出来会拷贝父进程的虚拟地址空间,虚拟地址空间里存有父进程的命令行参数和环境变量,所以只要父进程的环境变量里有你这个文件(程序替换进程文件)的路径就能跑,这也就印证了上文的那几个不带环境变量参数的函数明明也没传,但是子进程依旧能跑的原因,所以你要用execvpe函数一般就出现在你想用全新的环境变量或者自己想加点新环境变量进去。
execvpe前两个参数一个表示要替换进来的进程的文件名(后文简称文件),一个表示命令行参数表。argv和envp是传给文件的,文件里有main函数,main函数有命令行参数表和环境变量表,本质就是传给这两个表,所以才会在下边图片里两个样例里打印的是我们自己定义的环境变量和命令行参数。这个传递的工作是父进程在干,换做平时的命令行里执行指令,那这个工作就由bash干,它是所有指令的父进程,然后它也是调用exec*系列函数在完成这个程序替换的工作。
如下图几个是我使用execvpe的样例代码以及运行结果,test10.exe是我自己写的程序替换进程,其里边代码的意思就是把环境变量和命令行参数打印了一下,先说一下envp数组吧,我定义了一个envp数组,里边是我自定义的一个PATH环境变量,由于直接写test10.exe文件的路径会导致可能链接的时候源文件里边的函数需要其他路径底下的文件找不到了,所以我是直接把test10.exe所在的文件路径直接加进了父进程的PATH里,然后将整个PATH写进envp里的。下边几种不同版本的execvpe就是为了去印证上文说的给其第三个参数传不同的东西,得到的效果不一样,下图贴出来的是全新的环境变量表覆盖父进程的老环境变量表的那一种情况,其他的可以自行验证。
test9.c
cpp
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("我是子进程\n");
//child
char* argv[] = {
(char*)"test10.exe",
(char*)"-a",
(char*)"-b",
(char*)"-c",
(char*)"-d",
(char*)"-e",
NULL
};
char* envp[] = {
(char*)"PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/xxc/.local/bin:/home/xxc/bin:/home/xxc/code",
NULL
};
//沿用父进程的环境变量表的基础上新增环境变量
//extern char** environ;
//putenv的参数是char*,而""默认是const char*,所以强转一下
//putenv((char*)"HaHa=aaaaaaa");
//execvpe("test10.exe", argv, environ);
//沿用父进程的环境变量表
//extern char** environ;//指向子进程的环境变量(谁调用指向谁)
//execvpe("test10.exe", argv, environ);
//用自己全新的环境变量表
execvpe("test10.exe", argv, envp);
exit(3);//如果退出码为3就说明执行到这了,就说明程序替换失败了
}
//父进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
printf("exit_code:%d\n", WEXITSTATUS(status));
}
return 0;
}
test10.c
cpp
#include<stdio.h>
int main(int argc, char* argv[], char* env[])
{
int num = argc;
int i = 0;
for(;i < num;i++)
{
printf("argv[%d]:%s\n", i, argv[i]);
}
int j = 0;
for(; env[j];j++)
{
printf("env[%d]:%s\n", j, env[j]);
}
return 0;
}
运行结果:

总结:
上边的几个程序替换函数内容比较多,简单总结一下(下图)。所以其实底层只有一个系统调用,上层的6个库函数其实也是调用execve这个系统调用去完成程序替换的,库函数的参数传给execve,没有环境变量的库函数execve就默认用environ(父进程原配的环境变量),而execvpe(名字里带e的库函数)如果传了新的表,那execve就将这新的表覆盖原来的表,如果传的空表,那就还是继续用老表。

自定义Shell
我们使用过的遇到的所有程序基本上都是死循环,Shell本质也是死循环。
第一阶段
cpp
#define MAXSIZE 128
const char *GetUserName()
{
char *name = getenv("USER");
if(name == NULL)
return "None";
return name;
}
const char *GetHostName()
{
char *hostname = getenv("HOSTNAME");
if(hostname == NULL)
return "None";
return hostname;
}
const char *GetPwd()
{
char *pwd = getenv("PWD");
//char *pwd = getcwd(cwd, sizeof(cwd));
if(pwd == NULL)
return "None";
return pwd;
}
void PrintCommandLine()
{
printf("[%s@%s %s]", GetUserName(), GetHostName(), GetPwd());
fflush(stdout);//刷新缓冲区
}
int GetCommand(char commandline[], int size)
{
//由于scanf读到空格就停下了,所以用fgets
if(NULL == fgets(commandline, size, stdin))
return 0;
//将最后一个输入的回车(即为\n)变成'0', 因为会和printf里的\n冲突,就相当于多打印了一个空行
//如果用户只输入了一个换行,最终会被设置成0,strlen算出来就为0(空串)
commandline[strlen(commandline) - 1] = '\0';
return strlen(commandline);
}
int main()
{
while(1)
{
//1.打印命令行, [用户名@主机名字 路径]
PrintCommandLine();
sleep(1);
//2.获取用户输入的字符串
char commandline[MAXSIZE] = {0};
if(0 == GetCommand(commandline, sizeof(commandline)))
continue;
//printf("%s\n", commandline);
}
return 0;
}
第二阶段
1.解析字符串
在打印了命令行并且获取用户输入之后,下一步就是需要解析字符串了,我们在命令行输入的指令,shell会把它当成完整的一个字符串,比如说"ls -a -l",我们要将这串字符串解析成"ls" "-a" "-l",解析之后的每一个小字符串就存在命令行参数表里,就是之前博客里一直提到过的全局的命令行参数表argv。
怎么解析呢?用到了C语言里的一个函数strtok,先来说说这个函数吧,第一个参数就表示要解析的字符串,第二个参数表示一个符号字符串,意思就是delim是一个字符串,里边由分隔符组成,比如说" #@",凡是出现在delim里的字符都会被当作分隔符。假设输入的指令为"ls -l -a",然后delim为" ",则第一次调用strtok(commandline, delim)的结果为"ls",第二次调用的时候就要注意了,如果还是传commandline截取到的依旧是"ls",第一个参数此时要传NULL,表示从上一次截取的位置开始往后继续以delim里指定的分隔符结尾截取字符串,因为strtok里有静态全局指针会保留上一次拆分的位置.......注意这里说的截取不是说真的把"ls -l -a"这一整个字符串分割成了"ls" "-l" "-a",而是原地修改分割符的位置为"\0"然后返回当前被截取字符串的起始地址,打印字符串的时候会以"\0"作为结尾,因此看起来就像被截取了,所以实际上全部截取完后的字符串为"ls\0-l\0-a"。目标字符串便利截取完了就返回NULL。
最后注意argv里的最后一个有效字符串的下一个位置存NULL,直接依靠strtok的返回值就直接搞定了。(下边代码主要是截取的补充的部分,完整版会放在博客末尾)。

cpp
#define MAXARGS 32
//命令行参数表
int gargc;
char* gargv[MAXARGS];
const char* gsep = " ";
int ParseCommandline(char commandline[])
{
//由于gargv是全局变量,如果有多次分割就会导致gargv里的字符串不对了
//因此每次调用要先清空
gargc = 0;
memset(gargv, 0, sizeof(gargv));
gargv[0] = strtok(commandline, gsep);
//最后一个有效位置的下一个位置存NULL
while((gargv[++gargc] = strtok(NULL, gsep)));
//测试
printf("gargc:%d\n", gargc);
for(int i = 0;i < gargc;i++)
{
printf("gargv[%d]:%s\n", i, gargv[i]);
}
return gargc;
}
int main()
{
while(1)
{
//1.打印命令行, [用户名@主机名字 路径]
PrintCommandLine();
sleep(1);
//2.获取用户输入的字符串
char commandline[MAXSIZE] = {0};
if(0 == GetCommand(commandline, sizeof(commandline)))
continue;
//printf("%s\n", commandline);
//3.解析字符串
ParseCommandline(commandline);
}
return 0;
}
2.执行这个命令(不完善)
到这,在gargv里已经存着指令和选项了,接下来就是执行它,不能让Shell自己内部做程序替换执行命令,因为Shell执行完当前这个命令还要回过头去执行,打印命令行,读取指令,解析指令,它是一个死循环....如果就在内部程序替换了不就后续代码作废了嘛,while循环回不来了呀,所以应该交给子进程去做程序替换才对。
那程序替换的时候应该用哪一个函数比较好呢?不是总共有6个程序替换库函数,一个系统调用,系统调用肯定不用去考虑了,因为库函数底层就是调用的系统调用,我们现在已经有了命令行参数表argv了,指令的名字就存在argv0里,而且指令一般都能在环境变量里找到,不用我们去手动写路径,如果是要替换自己写的程序的话,我们在命令行输入命令的时候带上路径就行了./XXX,这样argv0里存着./XXX,环境变量里没有也没关系了,因为程序替换函数(名字里带p的)执行机制是这样的,如果环境变量里有,它会自己去找,找不到就出错,但是如果我们依旧手动指定路径,它不会去环境变量里找了,直接就按照我们手动给的路径去执行。综上:应该用execvp。
值得一提的是,我们自己写的Shell也可以执行系统的Shell,因为Shell本质也是个指令,也是个二进制文件,Linux里的Shell叫bash。
cpp
int ExecuteCommand()
{
pid_t id = fork();
if(id < 0)
{
//失败
return -1;
}
else if(id == 0)
{
//child
execvp(gargv[0], gargv);
exit(3);
}
else
{
//parent---就干一件事情就是等待回收子进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
//成功
}
}
return 0;
}
int main()
{
while(1)
{
//1.打印命令行, [用户名@主机名字 路径]
PrintCommandLine();
sleep(1);
//2.获取用户输入的字符串
char commandline[MAXSIZE] = {0};
if(0 == GetCommand(commandline, sizeof(commandline)))
continue;
//printf("%s\n", commandline);
//3.解析字符串
ParseCommandline(commandline);
//4.执行命令
ExecuteCommand();
}
return 0;
}
第三阶段
1.实现内建命令
将上两个阶段的代码整合一下就可以实现一个简陋版Shell,可以执行系统有的一部分指令,诸如像ls,clear,pwd......但还有很多问题。
以cd ..为例,cd ..回退到上一次路径无法实现,因为我们当前的所有指令都是交给子进程去执行的,子进程执行完就退出了,我们根本看不到它的路径切换,cd ..切换的应为当前进程(父进程bash)的路径,bash的所有子进程会继承父进程bash的工作路径,更改了bash的工作路径之后,就是更改了后续所有执行的指令(进程)的工作路径。
因此我们在解析完字符串之后不是直接就执行指令了,还要多一个步骤,判断这个指令到底是让父进程自己执行还是让子进程执行,让父进程自己执行的命令就叫做内建命令,内建命令就是bash自己调用函数,完成命令工作。
我们设计一个CheckBuiltinExecute函数来检查是否是内建命令,如果是就顺便执行它。如果判断的返回值为0就说明不是内建命令,如果为1就说明是内建命令并且执行成功。由于内建命令很多,也不可能每一个都写,就拿cd举例子,有一个系统调用叫chdir,作用就是改变当前的工作路径,谁调用这个系统调用就更改谁的工作路径,参数表示要更改的新的路径,更改成功0被返回,否则-1被返回。

还需要解决一个问题,就是我们的工作路径也需要变化,就是指的打印命令行时的工作路径,执行cd的时候这个也应该要变化才是,在第一阶段我们获取命令行的方式是直接使用环境变量去获取的,环境变量是继承自父进程的,父进程bash的环境变量是在启动时读取配置文件来的,也就是说,环境变量是OS启动之后才有的。我们至始至终都没去修改过环境变量,自然就不会变化。为什么OS不会自动更改环境变量,因为环境变量是用户数据,跟你OS没关系,是你用户自己的信息。所以我们cd ..的时候bash不仅要去调用chdir还要修改环境变量。

更改工作路径的方式:
法一:我们不用使用环境变量打印工作路径的方式,我们改成用系统调用getcwd的方式去打印工作路径,它的作用是获取当前的工作路径,第一个参数的意思是将获取到的工作路径存在buf里,第二个参数即为buf的大小,返回值就是buf自己失败的话就返回NULL。这种方式环境变量是没变的。

法二:直接改系统环境变量,具体看代码里。
cpp
//我们自己写的Shell的工作路径
char buf[MAXSIZE];
//法一
//const char *GetPwd()
//{
// char* pwd = getcwd(buf, sizeof(buf));
// //char *pwd = getenv("PWD");
// if(pwd == NULL)
// return "None";
// return pwd;
//}
const char *GetPwd()
{
char* pwd = getenv("PWD");
if(pwd == NULL)
return "None";
return pwd;
}
int CheckBuiltinExecute()
{
//相等就说明是内建命令
if(strcmp(gargv[0], "cd") == 0)
{
//以cd ..为例---新的目标路径gargv[1]
if(gargc == 2)
{
//1.更改进程内核中的路径
//路径就在gargv里存着
chdir(gargv[1]);
//2.更改环境变量
char pwd[1024];
//snprintf是将格式化字符串输入到指定字符串里
//将修改好的环境变量暂时存在buf数组里(Shell内部的数组)
snprintf(buf, sizeof(buf), "PWD=%s", getcwd(pwd, sizeof(pwd)));
//将buf加入到环境变量里
putenv(buf);
}
return 1;
}
return 0;
}
int main()
{
while(1)
{
//1.打印命令行, [用户名@主机名字 路径]
PrintCommandLine();
//2.获取用户输入的字符串
char commandline[MAXSIZE] = {0};
if(0 == GetCommand(commandline, sizeof(commandline)))
continue;
//printf("%s\n", commandline);
//3.解析字符串
ParseCommandline(commandline);
//5.子进程执行还是父进程执行
if(CheckBuiltinExecute())
{
//如果是内建命令就不要交给子进程
continue;
}
//4.让子进程执行命令
ExecuteCommand();
}
return 0;
}
至此,我们实现了cd功能,不仅把当前的工作路径改了,还把环境变量也给改了,但是为什么可以改环境变量呢?我自己的Shell里都没有写环境变量这个东西啊?原因就是我们自己写的Shell也是进程,它的父进程就是系统的Shell,就是bash,bash里有环境变量表,子进程自然是可以继承到的,但现如今自定义Shell要修改环境变量,就会发生写时拷贝。
2.命令行的当前工作路径的优化
对于命令行的工作路径,bash是只打印最后一个/之后的文件夹的,但是我们自己写的Shell是显示了一个完整的路径的,基于这点我们去做一下优化。

cpp
//加上static取消外部链接属性
static std::string PwdSpilt(const std::string& s)
{
if(s == "/")
return s;
std::size_t pos = s.rfind('/');
if(pos == std::string::npos)
{
return std::string();
}
return s.substr(pos + 1);
}
void PrintCommandLine()
{
printf("[%s@%s %s]", GetUserName(), GetHostName(), PwdSpilt(GetPwd()).c_str());
fflush(stdout);//刷新缓冲区
}
3.echo $XXX的问题
我们知道echo $?的作用是打印最近一次进程的退出码,由于echo是一个内建命令,我们也需要自己实现一下。怎么获取进程的退出码呢?首先得在Shell内部有个全局变量lastcode来存退出码,在我们的自定义Shell里有两种命令,一种是交给子进程执行的命令,该命令(进程)执行完的退出码就由父进程(bash)通过waitpid获得,还有一种是内建命令,内建命令由父进程自己完成,完成后直接设置lastcode==0即可。echo还可以查环境变量,我们可以直接使用getenv去查看父进程的环境变量(子进程继承父进程),这里就拿如何获取PATH举例子,其他的一样的。
注意:下边的代码只截取了实现echo的部分,退出码的定义和获取很简单,可以直接去下文完整版代码里去看。
cpp
int CheckBuiltinExecute()
{
//相等就说明是内建命令
if(strcmp(gargv[0], "cd") == 0)
{
//以cd ..为例---新的目标路径gargv[1]
if(gargc == 2)
{
//1.更改进程内核中的路径
//路径就在gargv里存着
chdir(gargv[1]);
//2.更改环境变量
char pwd[1024];
//snprintf是将格式化字符串输入到指定字符串里
//将修改好的环境变量暂时存在buf数组里(Shell内部的数组)
snprintf(buf, sizeof(buf), "PWD=%s", getcwd(pwd, sizeof(pwd)));
//将buf加入到环境变量里
putenv(buf);
lastcode = 0;
}
return 1;
}
else if(strcmp(gargv[0], "echo") == 0)
{
//"echo" "$?"
if(gargc == 2)
{
//gargv[1]得到的是一个char*类型的指针指向"$?"
//gargv[1][0]得到的是'$'
if(gargv[1][0] == '$')
{
//这里$之后是?,但也有可能是PATH之类的环境变量啊
//环境变量是字符串了,所以得那strcmp去比较
//如果写成gargv[1][1]就相当于解引用了两次,获得的是字符'?'
//gargv[1]获得的是"$?",再+1指针移动指向的是"?"
if(strcmp(gargv[1]+1, "?") == 0)
{
printf("lastcode: %d\n", lastcode);
}
else if(strcmp(gargv[1]+1, "PATH") == 0)
{
printf("%s", getenv("PATH"));
}
lastcode = 0;
}
}
return 1;
}
return 0;
}
4.加载环境变量表
我们知道环境变量表是bash在启动的时候读取配置文件加载进来的,其子进程的环境变量全部继承自父进程bash,我们自己写的话当然是不可能去搞配置文件的,这不是我们的学习范畴,我们就从父进程拷贝来构成我们自己Shell的环境变量表(下文代码里的LoadEnv函数)。
现在我们已经将父进程的环境变量表拷贝过来了,getenv和putenv到底在做什么?getenv其实就是去bash中查那张环境变量表,拿着环境变量的名字一一对比然后获取到它的值。putenv就是去env表里去new个位置出来然后将新的环境变量往里一填。所以getenv和putenv就是在访问环境变量表,所以我们哪怕不用函数也依旧可以获取或者导入环境变量。之前上文说过,怎么我明明没在自己的Shell里写环境变量表也能获取环境变量,原因就是我自己写的Shell也是系统bash的子进程,自然继承了bash的环境变量表environ,就是直接去这里边查的,如果发生子进程修改环境变量的情况,就会发生写时拷贝。
现如今我们就需要知道一个点,bash内部会有两张表,其中一张就是环境变量表,后续我们查环境变量导环境变量都是在Shell用内建命令的形式修改自己的表。
完整代码
cpp
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <iostream>
#include <string>
#define MAXSIZE 128
#define MAXARGS 32
//命令行参数表
int gargc;
char* gargv[MAXARGS];
const char* gsep = " ";
//存最近一个进程结束时的退出码
int lastcode = 0;
//环境变量表
char* genv[MAXARGS];
int genvc;
void LoadEnv()
{
extern char** environ;
//环境变量表以NULL结尾
for(;environ[genvc];genvc++)
{
genv[genvc] = (char*)malloc(sizeof(char)*4096);//不要纠结数字
strcpy(genv[genvc], environ[genvc]);
}
genv[genvc] = NULL;
}
//加上static取消外部链接属性
static std::string PwdSpilt(const std::string& s)
{
if(s == "/")
return s;
std::size_t pos = s.rfind('/');
if(pos == std::string::npos)
{
return std::string();
}
return s.substr(pos + 1);
}
//我们自己写的Shell的工作路径
char buf[MAXSIZE];
const char *GetUserName()
{
char *name = getenv("USER");
if(name == NULL)
return "None";
return name;
}
const char *GetHostName()
{
char *hostname = getenv("HOSTNAME");
if(hostname == NULL)
return "None";
return hostname;
}
//法一
//const char *GetPwd()
//{
// char* pwd = getcwd(buf, sizeof(buf));
// //char *pwd = getenv("PWD");
// if(pwd == NULL)
// return "None";
// return pwd;
//}
const char *GetPwd()
{
char* pwd = getenv("PWD");
if(pwd == NULL)
return "None";
return pwd;
}
void PrintCommandLine()
{
printf("[%s@%s %s]", GetUserName(), GetHostName(), PwdSpilt(GetPwd()).c_str());
fflush(stdout);//刷新缓冲区
}
int GetCommand(char commandline[], int size)
{
//由于scanf读到空格就停下了,所以用fgets
if(NULL == fgets(commandline, size, stdin))
return 0;
//将最后一个输入的回车(即为\n)变成'0', 因为会和printf里的\n冲突,就相当于多打印了一个空行
//如果用户只输入了一个换行,最终会被设置成0,strlen算出来就为0(空串)
commandline[strlen(commandline) - 1] = '\0';
return strlen(commandline);
}
int ParseCommandline(char commandline[])
{
//由于gargv是全局变量,如果有多次分割就会导致gargv里的字符串不对了
//因此每次调用要先清空
gargc = 0;
memset(gargv, 0, sizeof(gargv));
gargv[0] = strtok(commandline, gsep);
while((gargv[++gargc] = strtok(NULL, gsep)));
//测试
//printf("gargc:%d\n", gargc);
//for(int i = 0;i < gargc;i++)
//{
// printf("gargv[%d]:%s\n", i, gargv[i]);
//}
return gargc;
}
int ExecuteCommand()
{
pid_t id = fork();
if(id < 0)
{
//失败
return -1;
}
else if(id == 0)
{
//child---子进程的环境变量表即使父进程不传依旧能看到
execvp(gargv[0], gargv, genv);
exit(3);
}
else
{
//parent---就干一件事情就是等待回收子进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
//成功
lastcode = WEXITSTATUS(status);
}
}
return 0;
}
int CheckBuiltinExecute()
{
//相等就说明是内建命令
if(strcmp(gargv[0], "cd") == 0)
{
//以cd ..为例---新的目标路径gargv[1]
if(gargc == 2)
{
//1.更改进程内核中的路径
//路径就在gargv里存着
chdir(gargv[1]);
//2.更改环境变量
char pwd[1024];
//snprintf是将格式化字符串输入到指定字符串里
//将修改好的环境变量暂时存在buf数组里(Shell内部的数组)
snprintf(buf, sizeof(buf), "PWD=%s", getcwd(pwd, sizeof(pwd)));
//将buf加入到环境变量里
putenv(buf);
lastcode = 0;
}
return 1;
}
else if(strcmp(gargv[0], "echo") == 0)
{
//"echo" "$?"
if(gargc == 2)
{
//gargv[1]得到的是一个char*类型的指针指向"$?"
//gargv[1][0]得到的是'$'
if(gargv[1][0] == '$')
{
//这里$之后是?,但也有可能是PATH之类的环境变量啊
//环境变量是字符串了,所以得那strcmp去比较
//如果写成gargv[1][1]就相当于解引用了两次,获得的是字符'?'
//gargv[1]获得的是"$?",再+1指针移动指向的是"?"
if(strcmp(gargv[1]+1, "?") == 0)
{
printf("lastcode: %d\n", lastcode);
}
else if(strcmp(gargv[1]+1, "PATH") == 0)
{
printf("%s", getenv("PATH"));
}
lastcode = 0;
}
}
return 1;
}
return 0;
}
int main()
{
while(1)
{
//1.打印命令行, [用户名@主机名字 路径]
PrintCommandLine();
//2.获取用户输入的字符串
char commandline[MAXSIZE] = {0};
if(0 == GetCommand(commandline, sizeof(commandline)))
continue;
//printf("%s\n", commandline);
//3.解析字符串
ParseCommandline(commandline);
//5.子进程执行还是父进程执行
if(CheckBuiltinExecute())
{
//如果是内建命令就不要交给子进程
continue;
}
//4.让子进程执行命令
ExecuteCommand();
}
return 0;
}