✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh--CSDN博客
✨ 文章所属专栏:c++篇--CSDN博客

文章目录
1.进程创建
在Linux中,创建一个新的进程,需要调用fork()函数,其中新进程为子进程,而原进程为父进程。
在之前的文章中已将讲解过关于fork函数的使用以及各种相关问题,在地址空间的文章中又重新讲解了关于fork函数遗留的一个问题,因此这里就不再具体讲解fork函数的内容,感兴趣的可以直接看我之前的两篇文章。
【Linux系统篇】:初步理解何为进程--从硬件"原子"到PCB"粒子"的进程管理革命
【Linux系统篇】:程序世界的"隐形地图":破解环境变量与地址空间的共生秘密
这里只需要重点讲解一下fork函数相关的写时拷贝。
写时拷贝原理
1.基本概念
-
共享初始状态:
父进程通过fork()函数创建子进程时,操作系统也要为子进程创建自己的内核数据结构(PCB,地址空间,页表),具体的填充内容就会以父进程的内核数据结构为模板,因为填充的内容是一样的,所以操作系统不会立即为子进程复制一份父进程的内存,而是让父子进程指向同一块物理内存(代码和数据)。
-
只读权限:
在父子进程的页表中,共享的物理内存对应的标识符会被从新标记为只读权限(即使原本是可写的),以触发后续的写保护机制。
-
延迟复制:
当父子进程任意一个进程要尝试修改共享的物理内存时,就会立即触发缺页中断机制,操作系统介入并为修改者从新分配一块物理内存,复制原数据到新内存中,同时更新修改者页表中的虚拟地址和物理地址的映射关系。
2.核心流程
-
1.fork()函数调用:
创建的子进程复制父进程的页表(以父进程的为模板填充),共享所有的物理内存(代码和数据)。
操作系统将共享的物理内存标记为COW(Copy-on-Write),并增加引用计数从1变成2(表示当前对应的物理内存有两个进程共享)。
-
2.某个进程尝试写入共享内存:
当父或者子进程写入共享的物理内存时,CPU检测到写入操作,但因页表中的标识符标记为只读,立即触发缺页中断。
-
3.操作系统处理终端:
检查要写入进程的页表中物理内存对应的引用计数:
如果引用计数>1:
- 重新分配新的物理内存,复制原数据到新的内存中。
- 更新当前写入进程页表中新的物理地址,同时修改对应的标识符为可写。
- 对应的引用计数减一。
如果引用计数=1:
直接将页表中物理内存对应的标识符标记为可写(无需重新复制)。
-
4.恢复执行:
要写入的进程重新执行写入操作,此时要写入的数据是写入到新的物理内存中。

2.进程终止
1.进程退出场景
对于一个进程来说,无非只有以下三种退出场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
先来看上面两个,都是代码运行完毕,不同的只是结果是否正确,但是如果结果不正确,又为什么不正确,我们怎么知道不正确的原因呢?
还记得我们在写main函数时,一般都会在结尾处写一句return 0
,这说明main函数是有返回值的,但是为什么要返回0,返回其他的不可以吗?
当然可以,main函数的返回值,本质表示:进程运行完毕是否是正确结果,如果不是,可以用不同的数字,表示不同的错误原因。
用return不同的返回值数字,表示不同的错误原因,这就是退出码,其中0表示结果正确,其他数字则表示不同的错误原因。
因此,至于代码运行完毕,结果是否正确,统一会采用进程的退出码来进行判定!!
为什么要存在退出码?
这是因为,对于一个进程来说,最关心当前进程的运行情况,肯定是该进程的父进程,退出码就是用来返回给父进程,让父进程得知子进程的运行情况,结果是否正确。
除了退出码外,还有另一个错误码,退出码是用来进程终止时返回给父进程的状态值,用于指示子进程运行结果;而错误码则是用于表示程序在运行过程中遇到的具体错误类型,通常由系统调用或库函数返回,帮助开发者定位问题。
对于错误码和退出码两者通常结合使用,程序运行时通过错误码定位问题;再根据错误类型决定退出码,从而让父进程得知错误信息。
测试:
errno
C语言提供的一个全局变量,用来保存最近一次执行的错误码 ;sererror()
参数为具体的错误码,返回对应的错误信息;指令echo $?
用来表示命令行当中最近一个程序运行时退出码。
先用函数sererror()
函数打印出错误码对应的错误信息:


使用指令ll
打印出myproc.txt
目录的详细信息时(当前目录并不存在),显示的错误信息就是篮框中的内容,再用指令echo $?
打印最近一个程序运行时的退出码,就是2;正好对应上面的编号为2的错误码和错误信息。

前两种退出情况讲完后,再来看剩下一种代码异常情况。
既然进程出现异常,说明本质是当前进程的代码还没有跑完,既然没有跑完,那退出码也就没有意义了,也就不需要关心退出码了。
虽然退出码不关心,但异常原因还是要关心的,至于为什么进程出现异常,本质是当前进程收到了对应的信号!
测试:
指令kill -l
查看所有的异常信号:

第一张图片中红色方框和绿色方框的指令表示的是向第二张图片中对应颜色的程序发出异常信号,第二张图片中的程序收到对应的异常信号就会终止运行。


2.进程退出方法
正常终止(可以通过指令echo $?
查看进程退出码):
-
1.从main函数return返回
-
2.调用exit()函数
-
3.调用_exit()函数
第一种方法,经常用到的就不多说了,主要是两外两种方法。
如果是在main函数中使用exit()函数,参数就是要返回的退出码,效果和使用return一样:



但是exit()函数和return还是有区别的:
在调用show函数时,用exit()函数返回,最后看到的现象,调用show函数后的下一语句并没有执行


在调用show函数时,用reutrn返回,最后看到的现象,调用show函数后的下一语句有执行


为什么会有不同的现象?这是因为exit在任意地方被调用,都表示当前进程直接退出,return只表示当前函数返回,在main函数中返回表示进程退出。
然后就是_exit()函数,用法和exit()函数相同,传入一个参数,这个参数就是要返回的错误码。


exit和_exit的区别:
对于打印带换行符的语句,exit和_exit的效果一样:




但是对于打印不带换行符的语句,exit和_exit的效果就不一样了,exit可以打印出不带换行符的语句,而_exit没有打印出不带换行符的语句:




为什么会有这样不同的现象?
首先,printf函数是把要打印的内容先放到缓存区,然后在合适的时候刷新缓存区打印出来,因为没有换行符,所以要打印的内容会先暂时存放到缓存区。
然后就是exit()函数是C标准库函数,属于用户区;而_exit()则是系统调用函数,属于内核区;
exit()函数会执行清理工作,刷新所有标准IO缓存区,关闭文件流等,完成一系列工作后,再调用系统接口_exit()函数,终止当前进程;而_exit()函数并不会执行清理工作,对于缓存区的内容保留(可能造成数据丢失),直接终止进程。因此本质上终止进程的其实是_exit()函数,因为exit()函数最后也要调用系统接口函数_exit(),只不过在调用之前会做一些清理工作,而_exit()则是直接终止掉进程。
明白了这两个函数的区别,其实也可以知道缓存区一定不会存在于内核区,如果存在内核区,这两个函数最后都不会刷新缓存区,也就都没有内容打印出来。

3.进程等待
进程等待的必要性
1.什么是进程等待?
父进程通过系统调用wait()/waitpid()
函数,来进行对子进程进行状态检测与回收的功能!
2.为什么需要进程等待?
-
1.避免僵尸进程:
子进程终止后,内核会保留其退出状态等待父进程读取,如果父进程一直不读取,当前子进程就会一直是僵尸进程,而僵尸进程无法被杀死,需要通过进程等待来杀掉它,进程解决内存泄漏问题。
-
2.获取子进程状态:
父进程要通过进程等待,获取子进程的退出情况,直知道子进程的退出码或终止信号。
-
3.同步控制:
父进程可能需要等待子进程完成特定任务后再继续执行。
进程等待的方法
父进程可以通过系统调用wait()/waitpid()函数进行僵尸进程的回收问题!
-
wati()函数:
c//头文件 #include<sys/types.h> #include<sys/wait.h> //返回类型和参数 pid wait(int *status);
功能 :父进程等待任意一个子进程终止,若子进程已终止,则立即返回。
返回值:如果等待成功,返回终止的子进程的pid;如果失败,返回-1。
参数:
status,输出型参数,后面会用waitpid函数讲解,可以先暂时传入
NULL
。
wait函数测试单个子进程:



-
waitpid()函数:
c//头文件 #include<sys/types.h> #include<sys/wait.h> pid_t waitpid(pid_t pid, int *status, int options);
功能:更灵活的等待方式,可指定等待的子进程等。
返回值:如果等待成功,返回终止的子进程的pid;如果失败,返回-1。
参数 :第一个参数pid,如果
pid=-1
,等待任意子进程(等价于wait());如果pid>0
,等待指定PID的子进程。第二个参数status在后面讲,这里还是和wait函数一样,可以先暂时传入NULL
;第三个参数options也是后面讲,这里可以先暂时传入0。
waitpid函数测试单个子进程:
(测试wait函数的代码保持不变,只需要将wait函数修改为waitpid函数即可)


获取子进程status
这里重点讲解wati和waitpid函数的参数*status
。
首先需要明白对于父进程等待回收子进程,父进程希望获得子进程退出的哪些信息呢?
根据上面讲的进程退出场景就可以知道:
1.子进程代码是否异常,如果异常,收到的终止信号是什么;
2.如果没有异常,结果是否正确,获取子进程的退出码。
而status表示的是一个整型数字,传参数时,将整形status的地址传过去,当函数调用结束后,整形status中保存的就是子进程的退出信息,因此status是一个输出型参数。
先通过一个测试来看看status保存的值是什么:
用exit()函数设置子进程的退出码为1



最后打印的status值为256,为什么会是这个值,不是1呢?
这是因为,status值是被当成两部分使用的,除了保存退出码,还要保存对应的终止信号。
status的组成:
一个整形是4字节,一个字节是8比特位,因此一个整型含有32比特位;其中对于status值只使用[0,15]前16个比特位,这16个比特位又分成两部分:[0,7]保存的是终止信号,[8,15]保存的是退出状态(也就是退出码)。
以上面的测试为例,因为子进程的退出码为1,所以对应的退出状态8个比特位上的值是:00000001
;而子进程是正常终止的,并没有异常收到终止信号,所以终止信号为0,对应的8个比特位上的值是:0000000
。前16位比特位上的值就组成了00000001000000000
,再加上剩余16位的全为0,最后转换就是对应的整型数字256。

可以使用位运算分别获取子进程退出的完整信息(终止信号和退出码):


这里再用子进程异常,收到终止信号测试一下,如果子进程出现异常:
发生段错误,异常信号为8,子进程没有运行完,所以退出码为0


如果想要分别获取子进程的退出信息,并不是必须使用位运算,可以使用系统提供的两个宏:
-
WIFEXITED(status):
若子进程是正常终止退出,没有收到终止信号,则为真;反之为假(通常用来查看进程是否是正常退出)。
-
WEITSTATUS(status):
保存退出码(通常用来查看进程的退出码)。



使用waitpid函数测试多个子进程:
最后输出所有子进程的退出码:
非阻塞轮询
waitpid的第三个参数options
:如果父进程结束,子进程还在运行时,父进程就要等待子进程结束,一直不接受,就要一直等待;如果第三个参数options=0
,父进程就会以阻塞方式等待子进程,此时父进程就是处于阻塞状态,被挂起;
而如果是宏WNOHANG
,父进程就会以非阻塞轮询方式等待子进程。
上面讲解的waitpid函数有两种返回值,返回-1或者>0,但是除了上面的两种,还有一种等于0的情况,表示子进程还未终止,仍在运行。
通过一个测试来看看两种不同的传参值在面对父进程等待子进程运行结束,会有怎样的现象:
等于0时:



等于宏时:
(代码不变,将0修改成宏)


根据上面两种不同的现象,可以发现,如果传入的参数值等于0,父进程以阻塞方式等待,在子进程没有终止前系统调用函数并不会返回,而是父进程一直阻塞等待,直到子进程终止返回,下面的判断语句也就不会执行等于0的情况;如果等于宏,父进程以非阻塞轮询方式等待,子进程没有终止,但系统调用函数却返回0,收到返回值为0,父进程就会执行下面的判断语句,然后循环继续获取返回值,执行判断语句,直到子进程终止退出,结束循环。
如果父进程处于阻塞状态,就只能一直等待子进程结束,然后系统调用函数返回,在这期间,父进程什么都做不了;而非阻塞轮询存在的意义,就是父进程在等待子进程时,不只是单纯的一直在等待导致什么都不做,而是在等待期间也可以做些其他的事,以下面为例:



通过执行程序看到的现象,父进程在等待子进程结束时,通过非阻塞轮询的方式也在执行自己的其他事情,并非只是单纯的在等待,直到最后子进程结束,父进程停止轮询,回收子进程。
非阻塞轮询中,父进程的可以在等待子进程的过程中,执行自己的其他事情,其中,在等待子进程和执行自己的其他事情这两件事中,等待子进程才是最主要的,一旦子进程结束,父进程就要立即停止做其他事情,回收子进程。
在多进程中一定是父进程先开始运行,也一定是父进程最后结束,所有子进程由父进程统一回收。
4.进程替换
什么是进程替换
进程替换是指在当前运行的进程中,替换其正在执行的程序为新程序的代码,同时保持替换前进程的部分属性不变。这一过程通过exec
系列函数实现。
先用单进程版的进程替换看一下现象,然后再讲解原理,最后再演示多进程版的进程替换。
1.单进程版进程替换
这里先调用execl()函数演示一下,后面会具体讲解:


根据运行后的现象,可以发现,函数execl()完成了指令ls -a -l
(指令本质上也是可执行程序)的工作。
2.进程替换原理
-
地址空间替换:
当调用exec系列函数时,操作系统会加载新程序的代码,数据段,堆栈等内容,替换当前进程的地址空间,完全覆盖原进程的内存映像。但进程的PID,以及与父进程之间的关系等属性不变。
-
执行流程:
调用成功后(程序替换成功),原进程的代码立即停止执行,也就是说exec函数后的代码不会被执行,转而从新程序的main函数开始执行。如果调用失败(程序替换失败),返回-1,可能继续执行之后的代码。因此只有失败返回值,没有成功返回值。
3.多进程版进程替换
通过fork函数创建子进程,子进程调用exec系列函数完成进程替换;结合fork()和exec系列函数实现子进程执行新程序:


根据上面的现象就可以发现,程序替换并不是创建新进程,只是进行进程的程序代码和数据的替换工作。因为程序替换后子进程的PID不变,和父进程依然是父子关系。
进程替换的接口函数
接下来就是讲解进程替换的几个接口函数:
-
execl()函数:
cint execl(const char *path, const char *arg0, ..., (char *)NULL); eg: execal("/usr/bin/ls", "ls","-a","-l",NULL);
执行一个程序的第一件事情是什么?当然是要先找到该程序,既然要进程替换,那肯定要找到要替换的目标进程的地址,也就是在哪个路径下存放。而第一个参数
path
就是要替换目标进程的完整路径(决定如何找到该程序)。找到这个程序之后,接下来就是要如何执行这个程序(比如是否要带选项),根据上面的例子,可以发现对于替换的程序是指令时,传参的格式和命令行中输入指令时非常相似,只不过把空格替换成了逗号,最后加了一个空指针。
这种传参方式就是列表形式传递参数(对应函数名中的
l
字符) -
execv()函数:
cint execv(const char *path, char *const argv[]); eg: char *argv[] ={"ls", "-a", "-l", NULL}; execv("/usr/bin/ls", argv);
第一个参数我们已经知道表示的是替换程序的完整路径,而第二个参数这里是一个指针数组,和上一个不同的是,这里需要通过数组形式来传递参数(对应函数名中的
v
字符)。 -
execlp()函数:
cint execlp(const char *file, const char *arg0, ..., char(*)NULL); eg: execlp("ls","ls","-a","-l",NULL);
这个函数和execl()函数比较相似,只有第一个参数不同,不再表示路径,而是目标程序所在的文件名。剩下的参数还是列表形式传递。为什么不需要指明替换程序的完整路径了?
这是因为函数名中的字符
p
表示的是环境变量PATH
,不需要再指明完整路径,可以通过PATH环境变量直接搜索可执行文件在哪,但是需要知道要执行的文件名是哪个。 -
execvp()函数:
cint execvp(const char *file, char *const argv[]); eg: char *argv[] ={"ls", "-a", "-l", NULL}; execv("ls", argv);
明白了前面三个再来理解这个就比较简单了,函数名中的字符
v
表示的是通过数组传递参数,字符p
表示通过环境变量PATH找到可执行程序的完整路径,只需指明具体的文件名即可。 -
execle()函数:
cint execle(const char *path, const char *arg0, ..., (char *)NULL, char const *envp[]);
前面的几个函数都是以替换指令程序为例,除了替换已存在的指令程序,当然还可以替换我们自己写的程序。但是如果替换我们自己写的程序,我们知道main函数的两个核心向量表是要传参的,那谁来传递这两个参数表呢?
因此在讲解该函数之前先讲解被替换程序的main函数中的两个核心向量表,命令行参数表和环境变量表是被谁传递的。
命令行参数表
char *argv[]
和环境变量表char *env[]
两个都是字符串指针数组。在exec系列的函数中,比如execv()和execvp()函数中的数组参数
char const *argv[]
,同样也是字符串指针数组,而命令行参数表就是由这个参数传递过来,除了这两个还有execl()和execlp()函数中的列表参数char cosnt *arg0,char const *arg1,.....,(char *)NULL
本质上最后也要变成字符串指针数组的形式存储,然后再传递给main函数的命令行参数表。命令行参数表是由exec系列函数中的参数传递,那环境变量又是怎么传递的呢?
其实环境变量表也是数据,存放在地址空间的环境变量区,创建子进程的时候,环境变量表就已经通过
extern char **environ
指针被子进程传递下去了,这也是为什么子进程能继承父进程环境变量的原因。所以在程序替换中,环境变量信息是不会被替换的,而execl(),execlp(),execlv(),execlvp()这四个函数都是默认继承environ,不需要借助参数传递。父进程是什么,子进程就是什么。
如果我们想给子进程传递环境变量,该怎么传递呢?
1.新增环境变量:
父进程调用
putenv()
函数,在父进程原有的环境变量基础上新增环境变量,然后子进程通过environ
继承。测试:
mycommand程序调用自己写好的test,C++程序,同时新增一个环境变量"ABC=123":
test程序,打印命令行参数表和环境变量表:
执行mycommand程序,创建一个子进程替换为程序test,最后运行结果可以看到子进程继承了父进程的环境变量并且新增了一个环境变量"ABC=123":
2.彻底替换(自定义环境变量):
这时候就需要借助execle()函数的
char const *envp[]
参数(使用方式等同于execv()函数的char const *argv[]
参数)。cpp//使用当前环境变量 execle("/路径", "./test", "选项1", "选项2", ..., NULL, environ); //使用自定义环境变量 char const *new_env[] = {"ABC=123","DEF=456", NULL}; execle("/路径", "./test", "选项1", "选项2", ..., NULL, new_env);
自定义环境变量测试,子进程不再继承父进程原来的环境变量,而是自定义环境变量:
-
execvpe()函数:
cint execvpe(const char *file, char const *argv[], char const *envp[]) eg: //使用当前环境变量 char const *argv[] = {"./test", "选项1", "选项2", ..., NULL}; execvpe("/文件名", argv, environ); //使用自定义环境变量 char const *argv[] = {"./test", "选项1", "选项2", ..., NULL}; char const *new_env[] = {"ABC=123","DEF=456",NULL}; execvpe("/文件名", argv, new_env);
该函数和execle()函数一样,也有一个参数
char const *envp[]
可以用来传递环境变量表。只不过不同的是对于命令行参数表采用数组形式传递,不再是列表形式传递,并且不需要指明完整路径。
除了上面六个函数外,还有一个特别的exeve()
函数:
cpp
int execve(const char *path, char *const argv[], char *const envp[]);
该函数和execvpe()
相似,唯一不同的就是没有PATH环境变量搜索,需要指明完整的路径。
除此之外,该函数也是进程替换接口函数中唯一一个系统调用函数,上面的六个都是C语言提供的库函数,本质上这六个函数最后都是调用底层系统函数execvpe()
。
函数之间的关系:

函数之间的区别:
虽然这些函数原型看起来很容易混,但只要掌握规律就会变得很好记:
- l(list):表示参数采用列表形式传递
- v(vector):表示参数采用数组形式传递
- p(path):表示会自动搜索环境变量PATH
- e(env):表示可以自定义环境变量
函数原型 | 参数传递方式 | 环境变量 | PATH搜索 | 示例场景 |
---|---|---|---|---|
int execl(const char *path, const char *arg0, ..., (char *) NULL); |
列表形式 | 继承当前环境 | 否 | 已知可执行文件完整路径 |
int execlp(const char *file, const char *arg0, ..., (char *) NULL); |
列表形式 | 继承当前环境 | 是 | 使用PATH 查找可执行文件 |
int execle(const char *path, const char *arg0, ..., (char *) NULL, char *const envp[]); |
列表形式 | 自定义环境 | 否 | 需要指定环境变量的场景 |
int execv(const char *path, char *const argv[]); |
数组形式 | 继承当前环境 | 否 | 参数动态生成 |
int execvp(const char *file, char *const argv[]); |
数组形式 | 继承当前环境 | 是 | 动态参数且需PATH 搜索 |
int execvpe(const char *file, char *const argv[], char *const envp[]); |
数组形式 | 自定义环境 | 是 | 动态参数且需自定义环境变量 |
int execve(const char *path, char *const argv[], char *const envp[]); |
数组形式 | 自定义环境 | 否 | 底层系统调用,直接控制环境变量 |
以上就是关于进程控制(进程创建,进程终止,进程等待,进程替换)的讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!