目录
1.再谈fork函数
fork是一个系统调用接口,他的实现代码在系统内核中,当我们的进程执行到调用fork是,cpu会去执行内核的fork代码,这时候内核会做以下一些工作
1.分配新的内存块的内核数据结构(pcb、地址空间、页表)给子进程
2.将父进程的部分数据拷贝到子进程比如页表和地址空间
3.添加子进程到系统进程列表中
4、fork返回,开始调度器调度
父子进程的数据以写诗拷贝的形式独立,代码共享。 我们能够知道,在fork函数返回之前,紫禁城就已经创建好了,所以当某一个进程收到返回值要进行写入时,由于接收返回值的变量是父子进程共享的数据,所以先返回的进程会发生写时拷贝,拷贝到一块新的空间进行写入。这就是为什么fork会有两个返回值的原因,同时为什么我们打印地址的时候,父子进程的该变量的地址是一样的,但是数据不一样,因为打印出来的地址是虚拟地址,虽然虚拟地址相同,但是父子进程的页表映射到的物理内存的地址却不同。
为什么给父进程返回子进程的pid,而给子进程返回0。 一个进程是可以创建多个子进程的,进程要管理子进程就必须要知道子进程的pid,所以要给父进程返回子进程的pid,而对于一个进程来说,他的父进程是唯一的,所以给子进程的返回值并没有意义。
fork的常见用法:
1.父子进程分别执行同一个程序的部分代码
2.父子进程执行不同的程序
对于第一个用法,我们前面已经写过简单的代码来让父子进程分别执行同一个程序的部分代码,而第二个用法在本文的后半部分也会讲到,也就是程序替换。
fork并不是都能成功创建子进程,他创建失败时会返回 -1 。创建子进程失败的原因可能是 系统中有太多的进程,或者实际用户的进程数超过了限制。要知道我们最多能够创建多少个子进程,我们可以写一个循环来测试一下。
2.进程终止
在聊进程终止之前,我们先谈谈main函数的返回值问题?为什么我们每次写main函数都要在最后写一行 return 0呢?这个0是返回给谁的呢?我们之前只是说返回 0 表示程序没有异常正常退出了,这个 0 返回给了调用 main 函数的函数,这是语言层面的说法,而在系统层面,我们将这个 0 称为进程退出时对应的退出码,退出码的意义就是可以用来标定进程执行的结果是否正确 。就好比上面的main函数,如果执行到了 return 0这一行代码,说明前面的代码都执行过了,没有执行出错,返回0就表示程序正常结束了。
那么我们如何拿到这个退出码呢?
shell中有一个变量 ? ,这个变量会永远记录最近一个进程在命令行执行完毕时对应的退出码,也就是main函数的返回值,我们在命令行中拿到这个退出码也就能知道程序程序是否正常执行到了return 0语句。
我们当然也可以在程序中检验某些函数肚饿返回结果是否正确来返回不同的退出码
也就是说我们即便都是正常退出,但是我们也可以通过不同的退出码来表示程序运行过程中的某些的结果是否正确。当我们不关注程序的运行结果是,main函数直接返回0就行了,而当我们对程序运行的结果很关注时,我们就需要使用不同的返回值来表示不用的错误类型
那么我们如何设定退出码来表示不同的错误信息呢?对于不同的退出码的错误信息,我们可以自己设定,比如在退出之前打印一条对应的退出信息,这样我们就能在程序执行完之后知道错误描述。当然我们也可是使用C语言库里的退出码,C语言有一个函数 strerror ,我们可以传一个退出码给这个函数,他会将该退出码对应的错误信息以字符串形式返回。
我们可以将进程退出分为三种情况:
1 正常退出,结果正确(return 0)
2 正常退出,结果错误(return 非0)
3 进程异常终止,这时候退出码没意义
我们如何控制进程退出呢?
如果是正常退出,我们可以使用 return返回 ,调用exit()退出,或者调用_exit()退出,_exit是一个系统调用。
如果是异常退出也就是终止程序,我们可以使用ctrl c 或者kill -9 来终止进程
exit 和_exit的区别,exit是库函数,而_exit是系统调用,库函数exit的底层就是调用_exit来实现的。同时,他们的功能上还有一个小差别,我们可以用下面这个程序来观察一下
exit()比_exit()多了一个刷新缓冲区的功能。_exit()竟然不刷新缓冲区,这只能说明 _exit()做不到,否则按照正常的做法肯定是要刷新缓冲区的,也就是说系统层面无法刷新缓冲区,这也间接说明了,缓冲区其实是在系统层面之上的,也就是用户层,缓冲区肯定是在用户空间而不是在内核空间。
3.进程等待
我们学习僵尸状态的时候就已经知道,如果父进程或者操作系统不回收一个进程的僵尸的话,这个僵尸就会一直存在,占用系统资源,即使用 kill -9 也杀不死这个僵尸,因为这个进程已经是退出了,剩下的是他的pcb。
而回收僵尸的方式就是进程等待 。等待顾名思义就是等待子进程退出变成僵尸状态,然后回收僵尸资源,在这同时会获取到子进程的退出信息。
进程等待有两个接口 : wait 和 waipid
这两个接口都需要两个头文件 <sys/types.h>和<sys/wait.h> ,而他们的返回值都是回收的子进程的pid,当然如果失败就是另一种情况。
status则是一个输出型参数,我们知道子进程退出之后是会有退出结果和退出信息的,而这些信息就存在子进程的僵尸里也就是pcb里,而等待的方式则能够将这些退出信息传给 status ,同时释放僵尸。
当然,如果我们不关注退出信息的话,我们可以传空指针在 status 参数的位置。
首先我们用一下简单的 wait ,wait这个接口就是一个简单的等待,一直在等待直到子进程变成僵尸被wait等待成功并且回收,在此期间父进程什么也干不了,这也叫阻塞等待。
我们可以写一个下面的程序来等待回收子进程僵尸
在这个程序中,我们让子进程每隔一秒打印一次,总共打印十次,然后就退出。而我们的父进程则是先休眠五秒之后开始等待,一直到子进程退出形成僵尸,然后回收子进程的僵尸和退出信息,通过status传回给父进程解析。
wait是阻塞等待,也就是说如果一直没有等待到进程,则会一直在这里等待,什么事也干不了。我们也可以用修改一下开始等待的时间,来看一下子进程是否真的是在僵尸状态被回收了。
我们可以看到,子进程先退出,父进程还在休眠,当父进程调用wait接口时,子进程的僵尸就被回收了。同时status是0。
waitpid
waitpid的参数比wait多了两个,pid和option,对于option等待方式,我们可以有三个选项
手册里是这样描述的,但是我们用的时候就很简单,可以传 0 ,表示阻塞等待,这时候 waitpid 就是和 wait 一样是阻塞等待,但是waitpid是等待我们指定的进程,回收之后再返回。我们可以用fork的返回值来传参。
阻塞等待,如果子进程一直不退出,父进程就会一直等待,做不了其他的事,看起来就像是卡在等待的函数。如果我们不想一直等待直到子进程退出,我们可以采用非阻塞等待,也就是我们的选项WNOHANG,当我们采用非阻塞等待时,不会一直等待,而是获取当前时刻的子进程的状态,如果子进程没有退出也会直接返回,然后继续执行后面的代码。但是我们是需要对子进程的资源进行回收的,所以我们要将非阻塞等待写成一个循环,以便我们能够回收掉子进程的pcb,这种循环式的非阻塞等待叫做轮询。 同时,我们也要注意waitpid的返回值,如果返回值小于0也就是-1,说明等待失败了,有可能是pid传错了,不是当前进程的子进程。 如果返回值是 0 ,说明子进程还没有退出,这时候父进程可以先去执行循环内后面的代码,如果返回值大于 0(子进程的pid),则说明等待成功,调用的时候子进程已经退出了,然后waitpid回收了子进程的僵尸,退出结果就通过 status 这个输出型参数带了回来。
#include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6 #include<errno.h>
7
8
9 //任务列表我们可以使用全局的函数指针数组来设置
10 void task1()
11 {
12 printf("任务1\n");
13 }
14
15 void task2()
16 {
17 printf("任务2\n");
18
19 }
20
21 void task3()
22 {
23 printf("任务3\n");
24 }
25
26 void task4()
27 {
28 printf("任务4\n");
29 }
30
31 //全局函数指针数组
32 void (*TaskList[10])();
33
34
35
36
37 int main()
38 {
39 //装配任务列表
40 TaskList[0]=task1;
41 TaskList[1]=task2;
42 TaskList[2]=task3;
43 TaskList[3]=task3;
44 TaskList[4]=NULL;//用来当作循环退出条件
45 pid_t id=fork();
46 if(id<0)
47 {
perror("fork failed");
49 exit(errno);
50 }
51
52
53 if(id==0)
54 {
55 //子进程执行 10 秒
56 int cnt=10;
57 while(cnt)
58 {
59 printf("%d\n",cnt--);
60 sleep(1);
61 }
62 exit(0);
63 }
64 //父进程
65 int status=0;
66 while(1)
67 {
68 int ret=waitpid(id,&status,WNOHANG);
69 if(ret<0)
70 {
71 perror("wairpid failed ");
72 exit(errno);
73 }
74 else if(ret==0) //子进程没有退出
75 {
76 printf("子进程还没退出\n");
77 //干点其他事
78 //可以去执行一个任务列表
79 for(int i=0;TaskList[i];++i)
80 {
81 TaskList[i]();
82 }
83
85 //休眠2秒之后再次等待
86 sleep(2);
87 }
88 else //等待成功
89 {
90 break;
91 }
92 }
93
94 printf("status: %d\n",status);
95
96 return 0;
97 }
98
在这段代码中,我们采用函数指针,在子进程还没退出的时候,采用回调的方式执行一些其他的任务。
这样我们就可以在子进程还没退出的时候,执行一些其他的任务,直到子进程退出
父进程再对子进程的退出结果进行解析。
非阻塞等待 和 阻塞等待 都可以完成回收子进程资源的功能,这两者也没有什么好坏之分,都有各自的优点,我们使用的的时候参考使用场景来用就行,不用一味追求非阻塞等待。
目前还剩下的一个问题就是 status 是什么?我们传过去的输出型参数,那么他带回来的数据是退出码还是信号?因为我们知道,程序退出是有三种情况的,两种是正常退出,这时候我们看的是退出码,而异常退出则说明是信号杀死的进程,这时候我们要看的是信号类型来判断进程异常的原因。我们可以拿一个简单的程序看一下status到底是退出码还是信号。
正常退出的时候,我们将status打印出来发现并不是我们的退出码。
我们再试一下异常退出的时候,status是不是对应的信号.
访问野指针或空指针的操作会收到的信号时11号信号
这时候我们运行程序发现,父进程回收子进程的退出信息就是11。
其实,这个输出型参数 status 并不是一个整体拿来用的,位图结构,也就是分比特位来用。
如果是正常退出的化,低十六位的次低八位为退出码,而最低八位都为0 .
如果进程是异常终止的话,操作系统会填充最低七位,表示退出信号。
了解了他的位图结构,我们怎么在程序中去解析他是正常退出还是异常退出呢?
其实很简单,首先看低七位是不是0,如果是0则表示正常退出,如果不是0,则表示异常终止,这时候我们将他的数值也就是信号的编号显示出来就好了 。而如果是正常退出,我们就打印他的次低八位就行了 。这些操作都可以用移位操作符以及按位与操作符来实现。
int status =0;
waitpid(id,&status,0);
//printf("status:%d\n",status);
//先看最低七位是否为0
if(status&0x7f) //最低七位不为 0
{
printf("异常终止,信号为:%d\n", status&0x7f);
}
else //最低七位为 0
{
//退出码为第8-15位
int exit_code = (status>>8)&0xff;
printf("正常退出,退出码为 %d\n",exit_code);
}
当然,如果我们觉得拿到status之后进行位运算很麻烦,也可以直接使用Linux给我们提供的宏。
WIFEXITED(status) ,如果这个宏的结果为真,则说明是正常退出,我们可以再使用 WEXITSTATUS(status)来提取退出码。
如果WIFEXITED(status)结果为假,则说明是被信号杀死,异常终止,这时候如果我们想要知道信号类型,还是可以使用上面的位运算来求
4.进程程序替换
子进程被创建出来无非就是两个目的,一个是与父进程执行同一个程序,各自执行一部分代码,而另一个目的就是让子进程执行全新的代码,让子进程想办法加载磁盘上指定的代码和数据来执行,而这个过程就是程序替换。
程序替换的接口:
在man手册3号手册我们能找到 6 个接口,首先我们可以看一下其中最简单的 excel 接口
他的标准格式是
int execl ( const char* path ,const char* arg , . . . ) ;
这里的参数前两个好理解, path 就是要替换的程序所在位置(带路径,可以是绝对路径也可以是相对路径,同时,由于我们的第一个参数以及指明了程序所在位置,所以我们的命令行参数的第一个可以不用带路径 ./mycmd -> mycmd),而 arg 我们也能理解,就是命令行参数,也就是我们的可执行程序加选项,在前面我们解析 main 函数的参数时见过类似的 ,但是这里的 arg是一个一级指针,按理来说只能接受命令行参数的一个字符串,我们可以看到arg参数后面还有一个 ... ,这就是可变参数列表,虽然我们没有学过可变参数列表,但是我们在C语言的有些接口中也见过,比如printf函数和scanf函数。 那么可变参数我们怎么传参呢? 很简单,我们的命令行参数怎么传,这里就怎么传,但是参数结束要加上 NULL ,表示可变参数的结尾,我们的命令行参数无非就是一个一个的字符串 。
我们先不用子进程来进行程序替换,而是使用单进程来进行替换看一下他的效果
我们的 mycmd 程序只是简单的打印了一行 "这是一个新的程序"
我们可以替换一下看一下程序运行的结果
我们也可以替换一下shell的ls程序
我们发现了一个现象,就是我们原来的程序,本来有两个 printf 的,最后却只打印了一行信息,execl之后的printf好像没有起作用,这是为什么呢?
进程程序替换的原理
要搞清楚为什么后面的printf没有被执行,我们就需要先知道程序替换的原理。所谓的进程程序替换是在做什么呢?进程程序替换,那么替换的就是一个正在运行中的进程的程序,本质上替换的是程序,程序是什么呢?程序就是我们在磁盘中的可执行二进制文件,那么程序替换就是将我们指定的磁盘上的程序(代码和数据)加载进来,加载到我们当前进程的地址空间和物理内存,也就是覆盖原来的代码和数据,替换成新的程序的代码和数据,必要时会修改页表比如当空间不够时。
简单来说,就是将要替换的程序的代码和数据覆盖当前进程的代码和数据,覆盖也就是会将原来的代码和数据先清除,这时候通过页表映射是会将物理内存中的代码和数据也清除,但是不会释放代码和数据的物理内存,而是直接将新的代码和数据写到原来的代码和数据的物理内存上,因为这时候页表映射关系我们还没有改。 但是对于原来程序已经开好的栈区和堆区的空间,则会直接释放被操作系统回收。 程序就是代码和数据,栈区和堆区的空间则是程序的代码跑起来之后才会开辟的,所以不会保留原来程序的栈和堆的空间。 再将新的程序加载进来之后,虽然创建新进程,还是原来的进程,但是里面的数据已经是一个新的程序的数据了 ,进程会从新的程序的main函数也就是程序入口开始执行。 那么原来的程序的程序替换之后的代码就已经被覆盖了,因为根本不会执行到(前提是程序替换成功)。
当然程序替换也可能会失败,最经典的失败原因就是我们传给了函数一个不存在的程序或者错误的位置,这就会导致程序替换失败,而替换失败函数的返回值为-1,所以我们在程序替换的时候,最好在后面判断一下程序也没有替换失败,这个判断只在失败的时候会有效。
同时,我们一般不会直接将我们启动的进程替换掉,而是创建一个子进程来进行替换。
我们这里为什么打印出来很奇怪呢? 这是因为我们在子进程还没退出的时候,父进程就已经退出了,而没有等待子进程结束回收资源,这时候子进程就被操作系统领养了,所以在父进程执行完已经退出之后替换后的子进程还打印了一行数据,父进程回收子进程之后再退出就行了。
//创建子进程进行程序替换
int main()
{
pid_t id=fork();
if(id==0)//子进程
{
printf("这是子进程\n");
execl("./mycmd","mycmd",NULL);//发生写时拷贝
}
printf("父进程代码还在哦!\n");
//回收子进程之后再退出
waitpid(id,NULL,0);
return 0;
}
子进程打印了一个地址是由于我在mycmd中加了一行打印变量地址的代码,不用在意。
子进程的程序替换的原理我们也很好理解,首先在子进程被创建的时候,父子进程除了 返回值 id发生写时拷贝之外,其他的代码和数据都是共享的。我们没有画栈和堆的空间的映射,因为栈和堆在程序替换的时候不是重点
而当子进程要进行程序替换的时候,由于子进程的代码和数据要被新的程序的代码和数据覆盖写如,这时候就会发生写时拷贝,在一块新的物理空间拷贝一份父进程的代码和数据,修改映射关系,然后再发生程序替换,这时候覆盖写入的就是子进程的私有的代码和数据空间了,所以子进程发生程序替换不会影响到父进程,保证了进程的独立性 。那么对于原来的父子进程所共享的栈区和堆区的空间,由于子进程被替换了,所以就变成独属于父进程的了。
程序替换调用的接口
在上面我们已经使用了 execl 接口了,这里的 l 的意思我们可以理解为list列表,也就是将参数一个一个传入,列表式的传参。
execlp
int execlp ( const char* file , const char* arg, ... )
这里的 p 的意思就是path的意思,也就是找到程序的方式,带 p 的程序替换接口我们只需要传程序的名字就行了,系统会自动在环境变量PATH的路径中去搜索。
我们使用带 p 的程序替换的接口就只能替换 PATH 的路径的程序,如果要替换我们自己的程序,可以修改PATH(不建议这么做,不如直接使用其他的接口更方便)。这里的两个 ls ,第一个是告诉系统(会使用系统调用,所以替换的工作是系统做的)我们要替换成哪个程序,第二个ls则是我们要怎么执行,也就是跟命令行的功能类似。
execle
int execle ( const char* path , const char* arg , ... ,char* const envp[ ])
e的意思就是环境变量,不带e的接口默认继承的就是父进程的环境变量,而带e的接口替换的程序的环境变量则是需要我们自己指定。但是当我们使用带e的接口时,会出现以下的问题
如果我们传自己的环境变量的话,如下
如果我们传自己定义的环境变量表的话,当我们替换 mycmd 程序的时候,会发现mycmd的环境变量只有我们传的自定义的和环境变量,而没有系统的环境变量。
但是如果传的传的是系统的环境变量表的话,我们又无法传自己定义的环境变量。这时候就需要用到我们在讲环境变量的时候提到的 putenv函数了,这个函数能够在程序中将我们自定义的环境变量导入到当前进程的环境变量表中,这样一来,我们再传进程的环境变量表就既有我们自己的环境变量,也有系统的环境变量了 。注意,我们在使用putenv的时候,发生了写时拷贝,原本我们的进程是与shell命令行解释器共享他的环境变量表的,但是当我们的进程需要修改环境变量表时,就会拷贝一张环境变量表,再进行写入,这样保证了进程的独立性,子进程不会影响到父进程的环境变量表。
这样我们就能将自定义的环境变量和系统的环境变量都传过去了。
execv
int execv (const char* path , char* const argv[ ])
v就是vector的意思,也就是命令行参数以数组的形式传参,使用起来也很简单。
execvp
int execvp ( const char* file , char*const argv[ ])
这个接口相比于 execv就是多了一个 p ,也就是不需要指定路径了,而是直接传程序名,系统去PATH找
execvpe
int execvpe(const char* file , char* const argv[ ] ,char* const envp[ ] )
这个接口也没什么好说的,无非就是比 execvp 多传一个环境变量表。
其实还有 execve 接口,这个也跟上面的差不多 。
这么多程序替换的接口,我们可能懒得记,但是不管C语言给我们提供的程序替换接口再多,他们的底层都是使用的系统接口 execve ,不管上层语言提供的接口再多,底层都是使用系统接口 execve 来完成程序替换的。
系统调用接口execve默认就是使用程序名来进行程序替换的,默认在PATH的路径中去搜索程序,而其它的接口在进行替换时其实就是在对其进行封装,比如修改环境变量、将列表式的参数转换为数组等工作,当然具体的封装不可能这么简单。
语言提供的这么多接口主要还是为了让我们在不同的场景下有更多的更方便的选择。
在了解了程序替换之后,其实我们已经可以实现一个最基础的xshell命令行解释器了,虽然有些功能我们目前还做不到(比如重定向),但是我们可以先写出一个大致的框架。后续我会出一个xshell的简单实现的文章。