目录
方案一:fork创建子进程的时候,直接对数据进行拷贝处理,让父子进程各自私有一份
4.4.8. 补充:如何通过makefile一次形成两个可执行程序
1. 进程创建
我们这里谈论的进程创建其实就是fork创建子进程!!!函数原型如下:
# man 2 fork man 2号手册
#include <unistd.h>
pid_t fork(void);
至于fork返回值,在这里就不细说了,我们今天的主题不是它!
请你描述一下,fork创建子进程,OS都做了什么?
我们知道fork()的功能是:create a child process!!!
既然是创建一个子进程,那么本质上就是OS里面多了一个进程!!!
而我们知道,一个进程需要有与进程强相关的内核数据结构,包括PCB、地址空间、页表;还要有进程的代码和数据!!!
多了一个进程,就多了与进程强相关的内核数据结构,同时会将会将代码和数据Load到物理内存中,并通过页表构建好地址空间和物理内存的映射关系,并将对应的PCB放入运行队列里,等待CPU或者调度器进行调度该进程!
一般情况下:这些内核数据结构是由OS维护的; 代码和数据一般是从磁盘中来的,也就是加载到内存的C/C++的可执行程序;
1.1. 内核数据结构的处理
我们需要知道,进程是需要具有独立性的!!!
内核数据结构的处理:
因此,当创建子进程时,OS必须分配新的内存块和内核数据结构给子进程(定义的过程)!!!然后会将父进程的部分内核数据结构的内容拷贝给子进程(赋值的过程)!!!;
1.2. 代码的处理
可是代码和数据是如何处理的呢?
同理,由于进程具有独立性,因此,理论上,子进程也必须要有自己的代码和数据!!!
可是一般而言,fork创建子进程没有加载(将可执行程序Load到物理内存)的过程,也就是说,此时子进程没有自己的代码和数据!!!所以子进程只能使用 "父进程的代码和数据!"
可是现在就出现问题了,如果父子进程中的其中一个对数据甚至代码发生了修改,那么此时如何处理呢???
对于代码而言,一般情况下,都是不可以被写的!只能进行读!因此再一般情况下,父子进程共享代码,没有问题!!!
注意:fork之后,父子进程代码共享,指的是所有代码都是共享的!!!
然而对于数据而言!在很多情况下,都有可能发生数据的修改,因此,父子进程的数据在很多情况下都会各自私有一份!!!
1.3. 数据的处理:
因此对于数据而言,一般有两种处理方案:
方案一:fork创建子进程的时候,直接对数据进行拷贝处理,让父子进程各自私有一份
但是,我们在进程下也讨论过一些问题:
其一: 当fork创建这个子进程之后,一定会被立刻运行吗?
其二: 即便你准备要立刻运行这个子进程,那么你这个子进程一定会访问所有与子进程相关的数据吗?
其三: 即便你这个子进程要访问所有的数据,那么一定都是以写的方式访问所有数据吗?
也就是说,这种无脑的拷贝数据带来的问题就是:拷贝的数据空间,子进程可能根本就不会访问!甚至即便去访问了,也可能只是读取,而并非写入!!!
例如:
void Test1(void)
{
const char* str1 = "cowsay hello";
const char* str2 = "cowsay hello";
printf("str1: %p\n",str1);
printf("str2: %p\n",str2);
}
可以看到,str1和str2是指向的同一个字符串!!!也就是说,编译器编译程序的时候,尚且知道节省空间!!!本质上还是OS对于只读数据只会维护一份罢了!!!
因此,结论就是:fork创建子进程,根本不需要将不会被访问的或者只读的数据拷贝一份,此时父子进程共享这些数据即可!!!
那么什么数据需要被拷贝呢?
很简单,将来会被父进程或者子进程写入的数据需要拷贝一份,父子进程各自私有这些数据!!!
可是,一般而言,即便是OS,也很难预测到哪些数据会被写入,即便当OS预测到了某些数据会被写入,那么当提前拷贝了这些空间,你会立刻使用吗???答案是,不一定立刻使用;既然存在着不会立刻使用的可能,那么OS提前拷贝这些数据,不就是在浪费空间吗!!
因此OS选择了,写实拷贝技术,来进行将父子进程的数据进行分离!!!
方案二:写实拷贝(copy on write)
写实拷贝技术很好理解,通常情况下,父子进程的代码是共享的;父子进程当不对数据进行写入时,数据也是共享的;而当父子进程任意一方对数据或者代码进行修改,就会进行拷贝,让父子进程各自私有一份!!!在这里有一份图,帮助理解:
使用写实拷贝的原因:
其一,OS无法在代码执行前预知那些空间被写入!!
其二,当用的时候,在给你分配内存,是高效使用内存的一种表现;
写实拷贝本质就是一种延迟申请技术,来提高整机的使用率,那么也就提高了整机的效率!!!
因为有写实拷贝技术的存在,所以,父子进程得以彻底分离!完成了进程独立性的技术保证!
1.4. fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec系列函数!
1.5.fork失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
创建进程的成本是非常高的(体现在时间 + 空间上);例如:内核数据结构的创建与维护是需要消耗一定的时间和空间的!并且在Linux下,普通用户创建进程的数量是有限制的!!!
1.6. 扩展
我们的代码汇编之后,会有很多行代码,而且每行代码Load到内存之后,都有对应的地址!!
因为进程随时可能被中断(可能并没有执行完),下次被调度的时候,还必须从之前的位置继续运行(不是最开始的位置)!!!那么就要求CPU必须随时记录下,当前进程执行的位置!所以,CPU内有对应的寄存器数据,用来记录当前进程的执行位置,而这个寄存器称之为EIP,也称之为PC(point code)指针,即程序计数器,这个PC指针记录的永远都是当前正在执行代码的下一行代码的地址!
其实,硬件很无脑的!!!例如:物理内存就是用来存储和读取数据的,而不会对读写做什么限制;同样的CPU很无脑的,CPU根本不知道自己在干什么,它就做几件事,取指令、分析指令、执行指令!
分析指令和执行指令的前提就是:CPU必须认识这些指令!一般的CPU都有指令集!
寄存器再CPU内部,只有一套寄存器!但是寄存器内的数据,是可以有多份的!而寄存器内的数据称之为进程的上下文数据!!!
既然是数据,fork创建的时候,要不要给子进程呢?
答案是:当然要!
由于父进程执行fork的时候,EIP的值就是fork之后的代码!!!而fork创建子进程,这份数据(EIP的数据)也要以写实拷贝的方式给子进程!!!因此子进程的EIP的值也是fork之后的代码,故,fork之后,子进程会从fork之后的代码执行!而不是从所有代码的起始位置执行!!!
2. 进程终止
进程终止本质上就是OS内少了一个进程,那么当然要释放进程申请的相关内核数据结构和对应的数据和代码!!!本质上就是在释放系统资源(这个资源主要是内存)!!!
2.1. 进程退出场景:
进程退出的场景如下:
1、代码跑完,进程正常终止,结果正确 ;
2、代码跑完,进程正常终止,结果不正确;
3、代码没有跑完,进程异常终止;
注意:前两种情况为正常退出,只不过结果是否正确罢了;而第三种情况为异常终止,说明进程还没有执行完代码,就终止了!
2.2. 什么是退出码
在以前学习C/C++的过程中,为什么我们的main的返回值大多数情况下都是0呢?这个0代表着什么意思呢?
答案是:首先,这里的main的返回值并不是只能是0,其次这里的这个整数代表着这个进程的退出码!!!
补充:根据C和C++的标准规定:main 函数必须具有返回类型为 int。返回值的作用是向操作系统指示进程的执行状态,通常 0 表示进程正常终止且结果正确,非零值表示进程正常终止且结果错误!!!虽然根据标准,main 函数必须有返回值,但是在某些特定的情况下,编译器可能会允许省略返回值,如果省略,那么默认返回值就是0;但是,虽然有些编译器或者IDE可以省略返回值,但是为了提高代码的可读性和可移植性,建议在 main 函数中显式指定返回值类型并返回适当的值。
而在Linux下,在命令行上,用 echo $? 可以输出最近一个进程正常终止时的退出码!!!注意:异常终止的进程的退出码无意义!!!
例如:
代码跑完,进程正常终止,结果正确:
int main()
{
printf("haha\n");
return 0;
}
代码跑完,进程正常终止,结果不正确:
int main()
{
printf("haha\n");
return 10;
}
可以发现,在main中的return的这个值代表着进程的退出码!!!
2.3. 退出码如何表现的
我们也知道,退出码本质上是C语言的一种处理错误的机制!!!
当一个进程正常终止的时候,如果进程的结果正确,我们往往不关心为什么正确;但是如果结果错误,我们往往更想知道进程结果错误的原因是什么,这时候我们就需要用 非0 这种多组值,来表示不同的错误原因。
然而退出码有多组值,因此有时候用户并不能知道具体的某个退出码代表的是什么退出信息,因此我们可以根据一些函数得到退出码所表示的退出信息,例如:
#man 3 strerror
#include <string.h>
char *strerror(int errnum);
上面的这个函数的功能:将一个退出码转换成一个退出信息的字符串,并返回给上层用户;例如(在这里只展示一部分的退出码和退出信息):
可以看到, C语言为我们提过的退出码有134个,包括0;也可以看到,当退出码等于0时,代表着进程正常终止,并且结果正确!当退出码为非0时,代表着进程正常终止,并且结果错误!!!
当然,如果你不想使用C为我们提供的这一套退出码,你也可以在自己的代码呢中定义一套错误码供自己使用!!!
2.4. 退出码的意义
OK,你告诉我,main的return的值代表着这个进程的退出码!可是这个退出码有什么意义呢?
首先要强调一点:退出码只对正常终止的进程才具有意义;对非正常终止的进程毫无意义!
这个退出码的意义:返回给这个进程的父进程,父进程用这个退出码来判定子进程的执行结果,0代表着子进程正常终止且结果正确,非0值代表着子进程正常终止且结果不正确,当结果不正确时,不同的值可以表示不同的错误原因;
如何理解,退出码只对正常终止的进程才有意义?
生活中的场景:
李四是大学二年级的一名学生,准备考线代了!
情况一:李四考完了之后,没有挂科!然后李四给他老爹说:老爹,我考试过了!这时候老爹难道会问:你为什么过了?其实一般情况下,老爹是不会这样问的,既然你都过了,正常的达到了预期结果,那么我就不会关心你为什么会达到这种结果!类比到进程,当一个进程正常终止的时候且结果正确,那么退出码就是0,就代表着success!
情况二:李四考完了,挂科了!然后李四给他老爹说:老爹,我考试挂科了,线代才考了20分!那么正常情况下,老爹当然会问:你为什么才考这么低?也就是相当于老爹想知道我没有达到预期结果的原因;类比到进程,当子进程正常终止的时候并且结果错误的话,那么关心它的进程也就是它的父进程自然需要得到它的退出码,通过退出码表示子进程的错误原因!!!
情况三:李四考试过程中,作弊被逮住了!然后李四给他老爹说:老爹,我考试作弊被逮住了;此时老爹难道还会问你:你考了多少分?考的情况怎么样?答案是:不会;因为你是作弊被逮住了,就算你此时考了满分、考了0分,都没有任何意义!因为这样的结果是不被认可的,是没有意义的结果!类比到进程,当一个进程异常终止的时候,此时退出码对于这个进程而言是没有任何意义的!!!
因此,退出码只对正常终止的进程才具有意义!!!
2.5. 退出码的演示
2.5.1. 代码跑完,进程正常终止,结果正确:
当一个进程代码跑完,正常终止且结果正确,那么退出码就为0;
2.5.2. 代码跑完,进程正常终止,结果不正确:
可以发现,当退出码为1时,错误信息的确是 Operation not permitted,因此,以后我们自己写退出码时,也不要胡写,尽量符合标准!!!
2.5.3. 代码异常终止:
void Test1(void)
{
printf("haha\n");
printf("haha\n");
printf("haha\n");
int i = 10 / 0;
printf("hehe\n");
printf("hehe\n");
printf("hehe\n");
}
此时对于这个进程的父进程来说,既然这个子进程异常终止了,进程跑都没跑完(进程崩溃了),那么退出码也就没有意义了。
2.6. 如何正常的终止一个进程
进程正常终止:
1、main里面的return;
2、调用 C库函数 exit;
3、调用 系统调用接口 _exit;
2.6.1. main里面的return
int get_sum(int top)
{
int sum = 0;
for(int i = 0; i <= top; ++i)
{
sum += i;
}
return sum;
}
int main()
{
printf("hahaha\n");
printf("hahaha\n");
printf("hahaha\n");
int top = 10;
int sum = get_sum(top);
printf("sum = %d\n",sum);
return 123;
printf("hehehe\n");
printf("hehehe\n");
printf("hehehe\n");
return 0;
}
综上的结果,那么我们有:非main()的return只是单纯的代表着函数返回值罢了,不会终止进程!而main的return就代表了终止进程,而后面的数字就代表了进程的退出码!!!
2.6.2. exit
// man 3 exit
// exit - cause normal process termination
#include <stdlib.h>
void exit(int status);
可以看到,exit在man的二号手册。实际上它是一个C库函数!它的功能就是导致一个进程正常终止!!!exit函数的参数status就是进程的退出码!!例如:
int main()
{
printf("haha\n");
printf("haha\n");
exit(111);
printf("hehe\n");
printf("hehe\n");
return 0;
}
可以看到,exit也可以使当前进程终止,并且其参数就是进程的退出码!!! 那么它与return有什么区别呢?
void print(void)
{
printf("cowsay hello\n");
printf("cowsay hello\n");
exit(222);
printf("hello world\n");
printf("hello world\n");
}
int main()
{
printf("haha\n");
printf("haha\n");
print();
exit(111);
printf("hehe\n");
printf("hehe\n");
return 0;
}
可以看到,一个进程只要遇到了exit就会终止进程!也就是说,exit和return的区别:return只有在main才会终止进程,而exit在任意调用的地方都代表着终止进程!!!
2.6.3. _exit
man 2 _exit
//terminate the calling process
#include <unistd.h>
void _exit(int status);
void _Exit(int status);
// The function _Exit() is equivalent to _exit()
首先,_exit函数是一个系统调用接口!!!并且它的功能也是:任意地方调用_exit都代表着终止进程!!!实际上,exit的底层调用的就是_exit这个系统调用接口!!!而关于_exit的演示就不在这里重复演示了!!!我想说的是exit和_exit的一个区别,用这个差异说明一个问题;
2.6.4. _exit和exit的一个区别
看代码:
int main()
{
printf("you can see me?\n");
sleep(3);
exit(222);
return 0;
}
我们看到的现象:先看到打印消息,然后进程休眠三秒,进程退出,退出码为222;
如果更改为下面的代码:
int main()
{
printf("you can see me?");
sleep(3);
exit(222);
return 0;
}
看到的现象:先休息三秒,再打印消息!而导致这种"错觉"的原因是因为:printf函数会先将数据写入输出缓冲内!当调用该exit这个库函数接口时,会先去将输出缓冲区的内容刷新到显示器内,在终止进程!实际上,还是先打印消息,在休息三秒,只不过打印的数据暂时存放在输出缓冲区内罢了 !!!
而我们看看如果是_exit它会怎么做呢?
int main()
{
printf("you can see me?");
sleep(3);
_exit(222);
return 0;
}
现象是:休息了三秒钟,但却没有打印消息; 为什么???
注意:_exit是一个系统调用接口,exit是一个库函数调用!而我们看到的现象是:当调用库函数exit时,进程终止前会刷新缓冲区!而调用系统调用接口,进程终止了都没有将缓冲区的数据刷新到显示器内!那么说明,这个缓冲区一定不是OS维护的!!!如果这个缓冲区是OS维护的,那么两者都应该刷新!那么既然不是OS维护的,那么这个缓冲区究竟是谁维护的呢??? 要知道,这个exit是谁为我们提供的?是C标准!!!难道说,这个缓冲区是C语言为我们提供的? 答案是:是的!!!下面这个图,更能说明问题:
在这里就想说明一个问题:C语言是为我们提供了缓冲区的,例如printf就会先向C缓冲区写入数据!exit函数在终止进程之前,会做一些进程的收尾工作,例如刷新缓冲区(C语言的缓冲区),关闭流等。而_exit强制终止进程,不会进行进程的收尾工作,例如不会刷新缓冲区、不会关闭打开的文件描述符、不会执行终止处理程序等。
因此,如果在_exit调用之前有缓冲区未被刷新的输出(例如使用
printf
或fprintf
输出的内容),这些内容将不会被写入到相应的输出设备或文件中。这可能导致输出结果不完整或不一致。此外,未关闭的文件描述符可能会造成资源泄露。文件描述符是操作系统用于访问文件、套接字和其他 I/O 设备的标识符。正常情况下,进程退出时会自动关闭打开的文件描述符,释放相关资源。但是使用 _exit强制终止进程时,这些文件描述符可能会保持打开状态,从而导致资源泄露。
综上,使用_exit应该谨慎,确保在调用之前完成必要的收尾工作,例如手动刷新缓冲区(fflush)和关闭打开的文件描述符,以避免数据丢失和资源泄露等问题。因此,一般情况下,推荐使用C库函数exit来正常终止进程,因为它会执行标准的进程收尾工作。
3. 进程等待
进程等待是什么?
进程等待指的是一个进程等待另一个进程的退出;一般情况下,当fork创建子进程后,需要父进程等待子进程退出,得到子进程的退出信息以及回收子进程的资源!!!
进程等待为什么?
- 获取子进程退出的信息
- 可以保证时序问题,一般需要让子进程先退出,父进程后退出(回收子进程)。
- 进程退出的时候会先进入Z状态(kill -9 也无能为力),需要通过父进程wait()/waitpid(),释放该子进程占用的资源,避免了内存泄露。
3.1. wait等待子进程
// man 2 wait
// wait for process to change state
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
// return val:
# on success,returns the process ID of the terminated child;
# on error, -1 is returned;
首先,我们演示一下僵尸进程,思路如下:fork创建子进程,子进程运行三次,然后终止;父进程一直死循环运行;当子进程退出时,就会成为僵尸状态,需要父进程回收!
void Test2(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork failure");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process ,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(0);
}
else
{
// parent process
while(1)
{
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
sleep(1);
}
}
}
// 同样用一个shell命令行监控脚本
while :; do ps ajx | head -1 && ps ajx | grep 'my_test' | grep -v grep; sleep 1; echo "=========================================="; done
现象如下:
那么用wait如何处理僵尸进程呢?即wait如何等待子进程?处理逻辑如下:子进程执行三秒,进程退出,成为僵尸进程;父进程先休眠五秒,然后开始等待!!!
void Test3(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork failure");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process ,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(0);
}
else
{
// parent process
sleep(5);
printf("i am a parent process,PID: %d, PPID: %d\n",getpid(),getppid());
printf("wait begin!\n");
pid_t ret = wait(NULL);
printf("ret: %d\n",ret);
printf("wait success!\n");
}
}
可以看到,当父进程休眠完五秒的时候,此时子进程是一个僵尸状态,调用wait,开始回收子进程,并且可以看到,wait的返回值就是被回收的子进程的PID;实际上,当父进程调用wait时,是一种阻塞等待!也就是说,此时父进程的状态会成为阻塞状态,本质上是OS将这个进程的PCB添加到了一个等待队列中,等待子进程状态改变(实质上就是等待子进程死掉),当子进程终止,成为僵尸状态,那么OS会重新将父进程的PCB从等待队列链接到运行队列中,让CPU或者调度器调度这个父进程,使其执行wait系统调用接口,回收子进程的资源!!!
3.2. waitpid等待子进程
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
参数的介绍:
第一个参数 pid:pid代表着你要等待哪一个进程的ID号。
当pid == -1时,代表着等待任意一个子进程!
当pid > 0时,那么就等待ID号与pid相等的哪个进程!
第三个参数 options:options代表着等待进程的方式;
options == 0时,代表着阻塞等待!!!
options == WNOHANG时,代表着非阻塞等待!!!
那么用waitpid如何处理僵尸进程呢?即waitpid如何等待子进程?处理逻辑如下:子进程执行三秒,进程退出,成为僵尸进程;父进程等待子进程退出!!!
void Test1(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork error!");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(0);
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait child process:\n");
pid_t ret = waitpid(id,NULL,0);
printf("wait success,ret: %d\n",ret);
}
}
可以看到, 父进程调用waitpid时,当第三个参数也就是option为0时,此时就代表着阻塞等待;本质上是OS将父进程添加到等待队列里,即状态为阻塞状态,等待子进程退出!当子进程退出后,OS又将父进程链接到运行队列里,让CPU或者调度器调度父进程,回收子进程资源!!!
3.3. 如何获取子进程的exit code
我们知道,进程等待要做两件事情:1、回收子进程资源;2、得到子进程的退出码;
可是我们如何通过进程等待这两个函数获得子进程的退出结果呢?
答案是:int* status;这个status是一个输出型参数!!!本质上,当父进程等待子进程退出的时候,这个status的值是由OS为我们填充的!!!
void Test2(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork error!");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(123);
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait child process:\n");
int status = 0;
pid_t ret = waitpid(id,&status,0);
printf("wait success,ret: %d\n",ret);
printf("child process exit code: %d\n",status);
}
}
不对啊,我子进程的退出码是123啊! 原因是因为这个status并不是按照整数来整体使用的!!!而是按照比特位的方式!!! 而我们只使用status的低16位!!!如下图所示:
当进程正常终止的时候,上面这16个位的次低八位表示子进程的退出码!!!
代码如下:
void Test3(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork error!");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(123);
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait child process:\n");
int status = 0;
pid_t ret = waitpid(id,&status,0);
printf("wait success,ret: %d\n",ret);
// 次低八位代表子进程的退出码
printf("child process exit code: %d\n",(status >> 8) & 0xFF);
}
}
验证成功!次低八位代表着子进程的退出码!!!父进程可以根据子进程的退出码判断子进程正常终止时结果是否正确!!!
3.4. 如何获取子进程的退出信号
在说进程终止的时候,我们探讨过,进程异常退出或者崩溃,本质是操作系统杀掉了你这个进程!!!
操作系统如何杀掉你这个进程的???
本质是通过发送信号的方式!!!
换句话说,当一个进程异常终止的时候,本质是这个进程收到了某个信号导致其终止!!!
那么如何获取子进程的退出信号?
status的低7位就是子进程的退出信号!!!如下:
void Test4(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork error!");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(123);
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait child process:\n");
int status = 0;
pid_t ret = waitpid(id,&status,0);
printf("wait success,ret: %d\n",ret);
// 次低八位代表子进程的退出码
printf("child process exit code: %d\n",(status >> 8) & 0xFF);
// 低7位代表这个子进程的退出信号
printf("child process exit signal: %d\n",status & 0x7F);
}
}
一般情况下,当子进程正常终止的时候,那么退出信号为0;也就是说,当收到的信号为0时,代表进程是正常终止的!!!
那么进程异常终止是怎样的呢?演示如下:
此时子进程多了一个除0错误,我们看看会发生什么???
void Test5(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork error!");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 3;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PPID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
int i = 10 / 0;
exit(123);
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait child process:\n");
int status = 0;
pid_t ret = waitpid(id,&status,0);
printf("wait success,ret: %d\n",ret);
// 次低八位代表子进程的退出码
printf("child process exit code: %d\n",(status >> 8) & 0xFF);
// 低7位代表这个子进程的退出信号
printf("child process exit signal: %d\n",status & 0x7F);
}
}
可以看到,此时子进程就收到了一个信号,我们看看这个8号信号究竟是什么???
SIGFPE ---> Floating Point Exception,即浮点数异常错误!
当发生浮点运算错误时,操作系统会发送 SIGFPE 信号给相应的进程,终止这个进程!!
当进程异常终止时,status的低7位代表着进程的退出信号,并且可以发现此时进程的退出码为0,因为当进程异常终止时,进程的退出码毫无意义!!!
这也反面的验证了进程的退出码只有当进程正常终止时才有意义,当进程异常终止时,退出码毫无意义!!!
有时候,进程异常,不光光是内部代码逻辑有问题,也可能是外力直接杀掉,例如:
3.5. 用操作系统提供的宏帮助我们获取退出码
在3.3.中我们是以位运算的方案获取正常终止的进程的退出码而OS为了更简化这个过程,为我们提供了两个宏,分别是:WIFEXITED、WEXITSTATUS;
WIFEXITED(status):查看进程是否正常终止;如果进程正常终止,那么这个宏的结果就是真!(方便记忆: W --- wait IF --- if EXITED --- 退出 )
WEXITSTATUS(status):若WIFEXITED为真(即进程正常终止),那么提取子进程退出码!退出码就是WEXITSTATUS(status)!(方便记忆:W --- wait EXIT --- 退出 STATUS --- 状态)
具体如下:
void Test1(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork failed");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 10;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait\n");
int status = 0;
pid_t ret = waitpid(-1,&status,0);
if(WIFEXITED(status))
{
// 进程正常终止,获取进程退出码
printf("wait child process:(PID: %d),child process normal exit,exit code: %d\n",ret,WEXITSTATUS(status));
}
else
{
// WIFEXITED(status) == 0 ---> 进程异常终止
printf("child process get a signal\n");
// 如果还想获取子进程的退出信号,那么就用位运算的方案获取
printf("child process abnormal exit signal: %d\n",status & 0x7F);
}
}
}
具体细节就不再做过解释了,与上面的方式没有要大的差别!只不过多了用宏判断子进程如何终止以及用宏获取子进程正常终止的退出码!!!
3.6. 如何进行非阻塞等待
上面进程等待的处理方案都是一种阻塞等待!即父进程等待子进程的时候,父进程是处于阻塞状态的!本质是,在等待过程中,OS将父进程的PCB链接到了某个等待队列中罢了!当子进程退出时,OS又将这个父进程的PCB链接到运行队列里,进而让CPU或者调度器调度父进程,回收子进程的资源!
然而,我们也可以以非阻塞等待的方式进行等待子进程退出,回收子进程资源!!!本质上是,在等待过程中,父进程并不是处于阻塞状态,而是处于运行状态;同时我们可以借助waitpid的返回值达到采用轮询的方式实现非阻塞等待,即在等待期间不断地用waitpid的返回值检查子进程的状态,以便在子进程状态改变后立即得到通知。
需要注意的是,在进行非阻塞等待时,父进程可能会因为过于频繁地调用等待函数而产生性能问题。因此,父进程一般需要针对具体的应用场景合理选择合适的等待方式,以兼顾程序的性能和功能需求。
总之,在父进程进行非阻塞等待时,父进程会继续执行并处于运行状态,以便在子进程的状态改变时立即得到通知并做出响应。
那么waitpid如何实现非阻塞等待呢?
答案是:option这个参数
以前说过,option为0时,代表默认行为,即阻塞等待!
而当 option为 WNOHANG时,代表非阻塞等待;(便于理解: W --- wait NO --- 不 HANG --- 夯住了) ;夯住了在系统层面,不就是这个进程没有被CPU调度(要么这个进程的PCB在阻塞队列中,要么是等待被调度)!!!而WNOHANG就是代表这个进程不要被夯住!!!
grep -ER "WNOHANG" /usr/include/
可以看到,WNOHANG就是一个#define定义的一个符号常量!其值就是1;而这里之所以不直接用1,是为了代码的可读性(没有夯住,没有阻塞),即避免产生了魔鬼数字!!!
waitpid的返回值:
pid_t waitpid(pid_t pid, int *status, int options);
// 当options 被设置为 WNOHANG 那么:
# return val:
# val == 0 : 子进程未退出,返回值为0
# val > 0 : 等待成功,返回>0
# val < 0 : 等待失败,返回<0
说了这么多,还是要以代码举例的:
void Test2(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork failed");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 10;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
}
else
{
// parent process
while(1)
{
int status = 0;
pid_t ret = waitpid(-1,&status,WNOHANG);
if(ret < 0)
{
// wait failed
printf("wait failed\n");
exit(-1);
}
else if(ret > 0)
{
// waitpid success
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
if(WIFEXITED(status))
{
printf("child process normal exit\n");
printf("wait child(PID: %d) success,exit code: %d\n",ret,WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit\n");
printf("get a exit signal: %d\n",status & 0x7F);
}
break;
}
else
{
// ret == 0
// 子进程未退出,waitpid返回0,父进程继续等待子进程退出(轮询方案)
printf("child process no exit,parent process can do own things!\n");
}
sleep(1); // 让父进程每一秒检查一次,这就是基于轮询方案的非阻塞等待
}
}
}
实现逻辑很简单,通过waitpid的三种返回值设计出不同的逻辑;
当返回值小于0时,意味着父进程等待失败了,那么设置好退出码,终止父进程!
当返回值大于0时,意味着子进程终止,需要父进程回收子进程的资源,同时可以判断子进程是否正常终止,以便于获取子进程的退出码或者退出信号!
当返回值等于0时,那么说明子进程未退出,那么需要父进程重新进行调用waitpid等待子进程退出,也就是在这个逻辑下,父进程可以在等待子进程的同时可以处理自己的业务!!!
而上面的这种方案我们称之为:基于非阻塞调用的轮询检测方案!!!
为了更好体现非阻塞等待,我们可以让父进程等待的同时,处理一些自己的业务,如下:
void func1(void)
{
printf("service logic 1\n");
}
void func2(void)
{
printf("service logic 2\n");
}
typedef void(*p_func)();
std::vector<p_func> v_func;
void Load()
{
v_func.push_back(func1);
v_func.push_back(func2);
v_func.push_back([](){printf("service logic 3\n");});
}
void Test3(void)
{
pid_t id = fork();
if(id < 0)
{
perror("fork failed");
exit(-1);
}
else if(id == 0)
{
// child process
int cnt = 5;
while(cnt--)
{
printf("i am a child process,cnt: %d,PID: %d,PID: %d\n",cnt,getpid(),getppid());
sleep(1);
}
exit(111);
}
else
{
// parent process
while(1)
{
int status = 0;
pid_t ret = waitpid(id,&status,WNOHANG);
if(ret < 0)
{
printf("wait failed\n");
exit(-1);
}
else if(ret > 0)
{
// waitpid success
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
if(WIFEXITED(status))
{
printf("child process normal exit\n");
printf("wait child(PID: %d) success,exit code: %d\n",ret,WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit\n");
printf("get a exit signal: %d\n",status & 0x7F);
}
break;
}
else
{
// ret == 0
// 子进程未退出的同时,让父进程执行自己的业务逻辑
if(v_func.empty()) Load();
printf("child process no exit,parent process do own thisng:> \n");
for(auto iter : v_func)
{
iter();
}
}
sleep(1); // 让父进程每一秒检查一次,这就是基于轮询方案的非阻塞等待
}
}
}
现象如下:
上面主要演示的就是:父进程通过Load加载自己在等待过程中所需处理的各种业务逻辑;
总结来说,非阻塞等待是一种在等待事件完成时不会阻塞当前执行流程的机制,通过返回值或者信号来通知事件的发生,并允许进程继续执行其他任务。在waitpid函数中,可以通过设置选项参数WNOHANG来实现非阻塞等待的效果。
下面这段伪代码也可以帮助我们更好的理解waitpid函数:
pid_t waitpid(id, status, flag)
{
// 下面是内核中waitpid的实现,属于操作系统的
// 检测子进程退出状态,查看子进程的PCB中子进程的运行信息
if(子进程退出){
// 回收子进程的各种资源(子进程状态由Z->X)
// OS根据子进程PCB中的exit_code 和 exit_signal填充status,如下
status |= (child->exit_code << 8);
status |= (child->exit_signa);
return child_pid;
}
else if(子进程没退出)
{
if(flag == 0)
{
// 阻塞等待
挂起父进程;
// 本质就是将父进程的PCB链接到了等待队列中
// 因此进程阻塞的本质:是进程阻塞在系统函数的内部!!!
// 而当父进程被重新唤醒的时候,不是重新调用waitpid,而是从上次被挂起的地方
// 的后面继续运行
}
else if(flag == WNOHANG) return 0; // 不阻塞父进程
// 因此非阻塞等待的本质:
// 就是当waitpid检测到子进程没退出时,waitpid直接返回了
// 因此父进程可以执行waitpid后面的业务逻辑
// 同时可以进入下一次检测(轮询方案的检测机制)
return 0;
}
else
{
// 出错了等其他原因
return -1;
}
}
3.7. 补充
父进程通过 wait/waitpid 可以拿到子进程的退出结果和退出信号!!!为什么要用wait/waitpid函数呢?直接使用全局变量不行吗???
答案:不可以,因为进程具有独立性,当子进程修改全局变量时,这个全局数据就要发生写实拷贝,父进程无法拿到子进程设置的这个全局变量,况且,还有信号如何设置?
那么既然进程具有独立性,进程退出码不也是子进程的数据吗?父进程有凭什么能拿到呢??wait/waitpid究竟干了什么呢?
当子进程终止时,那么子进程就会成为僵尸状态,当成为僵尸状态时,子进程至少要保留该进程的PCB信息,task_struct里面保留了任何进程退出时的退出结果信息!!!父进程调用wait/waitpid中的status这个数据来源本质是子进程的task_struct 结构中的字段!!
那么也就是说:进程的PCB里面有两个字段,如下:
struct task_struct
{
int exit_code;
int exit_signal;
// ...
};
因此,wait/waitpid可以得到退出码和退出信号的本质就是:将子进程PCB中的这两个字段通过位操作设置到你传入的这个status里并返回给上层用户!!!
4. 进程的程序替换
4.0. 为什么要有进程的程序替换
目前,我们创建的子进程:子进程和父进程执行的是同一份代码,只不过两个进程执行的代码逻辑块可能不一样罢了!!! 但如果,我想让子进程执行全新的代码呢???即为什么要有进程的程序替换:一定和应用场景有关,我们有时候必须让子进程执行新的程序!!!
当然还有其他原因,例如:
程序更新:当一个程序需要更新版本时,旧的程序可以通过新的版本来替换,这样就不需要停止并重新启动进程,从而减少了系统的维护和操作负担。在新版本启动之前,进程仍能执行旧程序的任务。
资源节约:通过程序替换,可以让进程在不创建新进程的情况下更改自身的执行内容,从而节约了系统资源。
软件开发:有时开发人员需要在一个进程中动态替换执行程序,为调试和测试提供更方便的方式。
4.1. 进程的程序替换是什么?
我们将一个进程的内核数据结构不变,仅仅替换当前进程代码和数据的技术,叫做进程的程序替换;那么也就是说,进程的程序替换并不会重新创建一个子进程,而是更改当前进程的代码和数据和页表的映射关系罢了!
如何理解将磁盘上的可执行程序放入到内存中(从一个硬件到另一个硬件)?这个过程不就是加载吗?而我们知道编译有编译器,链接有链接器,而加载也有加载器!!!所谓的exec系列的接口的本质,就是如何加载可执行程序的函数!!即加载器的底层就是这些exec系列的函数!!!
补充: Linux的加载器(loader)在底层使用了exec系列的函数。exec系列函数是用于加载和执行新程序的函数族,在Linux中被广泛使用。exec系列的函数可以将一个新的可执行程序加载到当前进程的地址空间并执行,实现新程序的替换。当Linux加载器启动时,它会使用exec函数加载并执行指定的可执行文件,从而创建一个新的进程并运行程序。这个过程中,加载器会解析可执行文件的格式,将程序的代码、数据和其他资源加载到适当的内存区域,并设置正确的执行环境,最终调用exec函数执行程序。因此可以说,Linux的加载器底层使用了exec系列的函数来完成加载和执行可执行文件的任务。
4.2. 进程的程序替换的演示
void Test1(void)
{
printf("hahahahaha\n");
printf("----------\n");
printf("hahahahaha\n");
}
上面的代码没啥好说的,如果我想在打印分割后执行新的程序该如何实现呢?我们需要借助exec系列的函数帮助我们实现进程的程序替换;
# man 3 execl --- 3号手册
// path: 所要执行的可执行程序的路径
// arg: 预计要如何执行这个程序(命令行上如何写的,这个指针数组就填什么,并以NULL结尾)
int execl(const char *path, const char *arg, ...);
代码如下:
void Test1(void)
{
printf("hahahahaha\n");
printf("----------\n");
execl("/usr/bin/ls","ls","-l","-a","-i",NULL);
printf("hahahahaha\n");
}
可以看到,调用execl成功之后,会将当前进程所有的代码和数据进行替换,包括已经执行的和没有执行的,同时会去执行新的可执行程序!!! 那么也就是说,execl调用成功之后,原有代码会被替换掉(原有的后续代码全部都不会被执行),因此,execl根本不需要进行返回值来判断,只要调用execl函数,如果还执行了execl后续代码,那么代表着execl一定出错了!!!此时终止进程即可。
4.3. 利用fork创建子进程来执行进程替换
在我们以前的认知中,fork创建子进程,会以代码共享、数据写实拷贝的方式创建子进程;然而,在进程的程序替换的场景中,由于进程的程序替换会更改当前进程的代码和数据,因此在这种场景下,父子进程的代码和数据都会以写实拷贝的方式处理,各自私有一份(代码 + 数据)!!!
为什么创建子进程呢?
答案是:为了不影响父进程,我们想让父进程聚焦在读取数据、解析数据、指派子进程执行代码的功能!!!如果不创建子进程,那么进程的程序替换就会把父进程自身的代码和数据给替换了;如果创建了子进程,那么我们可以做到被替换的是子进程的代码和数据,父进程通过写实拷贝保证自己数据和代码的独立性!!!
处理代码如下:
void Test1(void)
{
pid_t id = fork();
if(0 == id)
{
// child process
printf("i am a child process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin execute execl:\n");
execl("/usr/bin/ls","ls","-a","-l","-i",NULL);
exit(-1); // 如果子进程走到这里,就说明execl调用失败了,进程终止
}
else
{
// parent process
printf("i am a parent process,PID: %d,PPID: %d\n",getpid(),getppid());
printf("begin wait:\n");
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,exit single: %d\n",status & 0x7F);
}
}
}
通过execl可以使得父子进程执行不同的可执行程序,父进程等待子进程退出,子进程执行全新的可执行程序!!!
进程的程序替换的意义:让进程执行新的可执行程序,只要进程的程序替换成功,就不会执行后续代码,意味着exec*()的函数,成功的时候。不需要返回值检测!只要exec*返回了,就一定是因为exec系列的函数调用失败了。
4.4. exec系列函数的运用和理解
// man 3 exec*() // 库函数
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
# man 2 execve // 系统接口
int execve(const char *filename, char *const argv[], char *const envp[]);
这些函数如果调用成功,则加载新的程序并开始执行 , 不再返回!!!
如果调用出错则返回 -1 ,所以exec系列函数只有出错的返回值而没有成功的返回值!!!
接下来,有一些关于这些函数名的理解:
l(list) : 表示参数采用列表的形式;
v(vector) : 参数用指针数组
p(path) :自动在环境变量 PATH中搜索目标程序
e(env) : 表示自己维护环境变量
4.4.1. execl函数
# man 3 execl
#include <unistd.h>
/*
* path: 代表你要执行哪一个可执行文件,此时要明确可执行文件的路径(相对/绝对)
* arg: 代表你要如何执行这个可执行文件,在命令行上如何执行,这里就如何将参数一个一个传递进去
* 注意: 最后要以NULL结尾作为参数传递的结束
* ... --- 可变参数列表
*/
int execl(const char *path, const char *arg, ...);
execl的示例代码:
void Test1(void)
{
pid_t id = fork();
if(id == 0)
{
execl("/usr/bin/ls","ls","-i","-l","-a",NULL);
exit(1);
}
else
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,get a exit signal\n");
}
}
}
4.4.2. execv函数
# man 3 execv
/*
* path: 代表着你要执行哪一个可执行文件? 包括这个文件的路径+文件名
* argv: 是一个指针数组,内容代表着你要如何执行这个可执行文件
* 即把命令行上怎么执行的一个一个参数写进这个指针数组里面
* 注意:最后这个指针数组要以NULL结尾,标志着参数传递的结束
*/
int execv(const char *path, char *const argv[]);
execv的示例代码:
void Test2(void)
{
pid_t id = fork();
if(id == 0)
{
//int execv(const char *path, char *const argv[]);
char* const argv[] = {
"ls",
"-i",
"-l",
"-a",
NULL
};
execv("/usr/bin/ls",argv);
exit(1);
}
else
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,get a exit signal\n");
}
}
}
4.4.3. execlp函数
# man 3 execlp
/*
* file:你要执行谁,通过文件名,OS自动在环境变量PATH中搜索可执行文件
* arg: 你要如何执行这个可执行程序(要执行的可执行程序在命令行上怎么执行
* 这里的参数就一个一个的传递进去);
* 注意: 最后以NULL结尾
* ...: 代表着可变参数列表
*/
int execlp(const char *file, const char *arg, ...);
execlp的示例代码::
void Test3(void)
{
pid_t id = fork();
if(id == 0)
{
//int execlp(const char *file, const char *arg, ...);
execlp("ls","ls","-a","-l","-i",NULL);
exit(1);
}
else
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,get a exit signal\n");
}
}
}
4.4.4. execvp函数
那这个函数就更简单了,因为函数名带p,那么就只需要传递文件名,操作系统自动在环境变量PATH中搜索该可执行文件;并且第二个参数是一个指针数组,我们只需要把命令行上怎么执行的一个一个参数写进一个指针数组里面,最后要以NULL结尾。
# man 3 execvp
/*
* file: 代表着可执行程序的文件名,OS自动在环境变量PATH中搜索
* argv: 是一个指针数组,内容为命令行参数
* 注意: 最后要以NULL结尾,标志着参数传递的结束
*/
int execvp(const char *file, char *const argv[]);
execvp的示例代码:
void Test4(void)
{
pid_t id = fork();
if(id == 0)
{
// int execvp(const char *file, char *const argv[]);
char* const argv[] = {
"ls",
"-a",
"-l",
"-i",
NULL
};
execvp("ls",argv);
exit(1);
}
else
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,get a exit signal\n");
}
}
}
4.4.5. execle函数
# man 3 execle
/*
* path: 你要执行谁(要执行的可执行程序的全路径,即所在路径/文件名)
* arg: 你要如何执行(命令行上如何执行的,你就一个一个将参数传递进来)
* 注意: 最后要以NULL结尾,标志着参数传递的结束
* ...: 可变参数列表
* envp: 可以自定义环境变量
*/
int execle(const char *path, const char *arg, ..., char * const envp[]);
execle的示例代码:
// proc.c 源文件
#include <stdio.h>
int main()
{
extern char** environ;
for(int i = 0; environ[i]; ++i)
{
printf("environ[%d]: %s\n",i,environ[i]);
}
return 0;
}
如果单独执行由proc.c源文件生成的可执行程序的话,运行这个可执行程序proc,那么打印的环境变量就是如下:
// test.c 源文件
void Test5(void)
{
pid_t id = fork();
if(id == 0)
{
//int execle(const char *path, const char *arg, ..., char *const envp[]);
char* const envp[] = {
"MYENV1 = hahahahahahaha\n",
"MYENV2 = hahahahahahaha\n",
"MYENV3 = hahahahahahaha\n",
"MYENV4 = hahahahahahaha\n",
NULL
};
execle("./proc","proc",NULL,envp);
exit(1);
}
else
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,get a exit signal\n");
}
}
}
我们可以通过进程的程序替换,将我们自己定义的env传给proc,那么proc可执行程序成为进程后会打印我们自己定义的环境变量,如下:
我们以前一直说:子进程会继承父进程的环境变量,那么现在我们就知道了,父进程可以通过execle这种函数将环境变量导给子进程!!!因此,看到的现象是:子进程继承了父进程的环境变量,故环境变量具有全局属性 !!!
4.4.6. execvpe函数
# man 3 execvpe
int execvpe(const char *file, char *const argv[], char *const envp[]);
这个函数跟execle几乎一样的,只不过第一个参数是通过环境变量PATH进行搜索,第二个参数是一个指针数组,内容为命令行参数,最后要以NULL结尾,第三个参数可以自己定义环境变量,在这里就不做演示了;
4.4.7. execve函数
# man 2 execve
/*
* filename: 你要执行谁(文件的路径 + 文件名)
* argv: 你要如何执行(argv是一个指针数组,内容为命令行参数)
* 注意: 最后要以NULL结尾,标志着参数传递的结束
* envp: 可以自己定义环境变量
*/
int execve(const char *filename, char *const argv[], char *const envp[]);
与前面不同的是,前面的函数都是库函数(严格意义讲,这些是C语言对execve这个系统调用的封装),而execve是一个系统调用,换句话说,前面的库函数都是对这个系统调用execve的封装!之所以提供了这些封装,其目的是为了满足了不同的调用场景!!!
void Test6(void)
{
pid_t id = fork();
if(id == 0)
{
//int execve(const char *filename, char *const argv[], char *const envp[]);
char* const envp[] = {
"MYENV1 = hahahahahahaha\n",
"MYENV2 = hahahahahahaha\n",
"MYENV3 = hahahahahahaha\n",
"MYENV4 = hahahahahahaha\n",
NULL
};
char* const argv[] = {
"ls",
"-a",
"-l",
"-i",
NULL
};
execve("/usr/bin/ls",argv,envp);
exit(1);
}
else
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status))
{
printf("child process normal exit,exit code: %d\n",WEXITSTATUS(status));
}
else
{
printf("child process abnormal exit,get a exit signal\n");
}
}
}
下面这个图,说明了exec系列函数的一些关系:
4.4.8. 补充:如何通过makefile一次形成两个可执行程序
# makefile文件:
my_test:test.c
gcc -o $@ $^ -std=gnu99
proc:proc.c
gcc -o $@ $^ -std=gnu99
.PHONY:clean
clean:
rm -f my_test proc
调换一下makefile中生成可执行程序文件的顺序,可以吗???
proc:proc.c
gcc -o $@ $^ -std=gnu99
my_test:test.c
gcc -o $@ $^ -std=gnu99
.PHONY:clean
clean:
rm -f my_test proc
通过上面的测试,我们知道makefile默认只会形成在依赖关系中形成第一个依赖文件。
那么如何才能通过makefile一次性生成两个可执行程序呢???
因此我们可以这样操作:
.PHONY:all
all:proc my_test
proc:proc.c
gcc -o $@ $^ -std=gnu99
my_test:test.c
gcc -o $@ $^ -std=gnu99
.PHONY:clean
clean:
rm -f my_test proc
因为all是一个伪目标,所以它总是被执行的。又因为all有依赖关系,所以make的时候,它需要生成all,那么必须先要生成proc和my_test,但是又因为没有依赖方法,所以最后all不会生成!!!结果如下:
此时我们就可以通过makefile一次性生成两个(多个)可执行文件!!!
5. 实现简陋版本的shell,重新认识shell运行原理
shell执行的命令通常有两种:第三方命令和内建命令;
1. 第三方提供的对应的在磁盘中有具体二进制文件的可执行文件称之为第三方命令,该命令需要由父进程创建子进程,让子进程执行第三方命令!!!2. 不创建子进程,让父进程(shell)自己执行的命令,我们称之为内建命令,也称之为内置命令!内置命令本质其实就是shell本身的一个函数调用!!!有些命令就是要影响shell本身的,例如cd,export!!!
enum MAX
{
CMD_MAX = 128,
ARGV_MAX = 64,
MYENV_MAX = 64
};
char myenv[MYENV_MAX];
void Test7(void)
{
char command[CMD_MAX] = {0};
while(1)
{
// step 1: 打印提示符
printf("[Xq#MY-LOCAL-LINUX]$ ");
// step 2: 获取命令行字符串
command[0] = 0; // 以O(1)的方式清空字符串
fgets(command,CMD_MAX,stdin);
// 注意我们上面的这个字符串,是有一个'\n'的
// 例如ls\n\0; 因此我们要将这个'\n' 置为 '\0'
command[strlen(command) - 1] = 0; // 将'\n' --> '\0'
// step 3: 解析字符串
// 用于存放命令行参数的指针数组
char* argv[ARGV_MAX] = {NULL};
// 定义分隔符
const char* delim = " "; // 一般情况下,分隔符都是空格
int i = 0;
argv[i++] = strtok(command,delim);
// 特殊处理: ls
if(strcmp(argv[0],"ls") == 0)
argv[i++] = (char*)"--color=auto"; // 配色方案
// 特殊处理: ll
if(strcmp(argv[0],"ll") == 0)
{
argv[0] = (char*)"ls";
argv[i++] = (char*)"-l";
argv[i++] = (char*)"--color=auto"; // 配色方案
}
while((argv[i++] = strtok(NULL,delim)));
// 打印当前解析的字符串
// 检测是否正确
for(int i = 0; argv[i]; ++i)
{
printf("argv[%d]: %s\n",i,argv[i]);
}
// step 5:处理内建命令
// 例如cd 以内建命令的方式进行运行,不创建子进程,让父进程shell自己运行
// 内建命令---不创建子进程,让父进程自己运行
if(0 == strcmp("cd",argv[0]) && argv[1] != NULL)
{
chdir(argv[1]); // chdir 是一个系统调用,更改进程当前的工作目录
continue;
}
if(0 == strcmp("export",argv[0]) && argv[1] != NULL)
{
// 父进程导环境变量
// 如果想既不覆盖之前的环境变量,又可以新增一个环境变量
// 那么需要借助putenv这个接口
// man 3 putenv 在<stdlib.h>
// int putenv(char* string);
// 需要借助这个全局字符数组myenv,argv中的内容是一个个的字符指针
// 这些字符指针指向的command这个数组的内容
// 而argv这个指针数组每次循环都会被清空,故原始内容就找不到了
// 因此在这里借助这个全局的字符数组,保存环境变量
strcpy(myenv,argv[1]);
putenv(myenv);
continue;
// 当发生程序替换时,环境变量和相关的数据,会被替换吗???
// 答案是: 不会,故全局变量具有全局属性!!!
// 那么问题又来了,shell的环境变量又是如何来的呢???
// 答案是: 环境变量,是写在配置文件中的!shell启动的时候,
// 通过读取配置文件,获得的起始环境变量!!!
}
// step 4: 处理第三方命令
// 子进程通过进程的程序替换执行新的可执行程序
// 父进程充当shell,回收子进程,如果子进程结果不正确,应该得到子进程的退出结果
pid_t id = fork();
if(id == 0)
{
// 处理第三方命令
execvp(argv[0],argv); //execvp 太合适不过了! ! !
exit(1); // 如果走到这,那么execvp一定出错了
}
else
{
// 父进程等待子进程退出即可
int status = 0;
waitpid(-1,&status,0);
if(WIFEXITED(status))
{
if(WEXITSTATUS(status) != 0)
{
printf("child process exit code: %d\n",WEXITSTATUS(status));
}
}
else
{
printf("child process get a exit signal\n");
}
}
}
}
通过对上面shell的简陋实现,我们可以知道shell的运行原理: 父进程解析用户输入的命令(第三方命令/内置命令),如果是第三方命令,那么父进程需要创建子进程,让子进程执行第三方命令,并等待子进程退出;如果是内置命令,那么需要父进程自己去执行对应命令,执行完,继续下一次解析!!!