Linux之进程(下)
- 前言
- 一.进程地址空间
- 1.1进程地址空间的概念
- 1.2进程地址空间和物理地址的关系
- 1.3进程地址空间的实现
- 1.4进程地址空间的意义
- 二.进程的创建
- 三.进程的退出
- 3.1退出码和错误码
- 3.2退出的几种方式
- 四.进程的等待
- 4.1进程等待的概念
- 4.2进程等待的必要性
- 4.3进程等待的方法
- 五.进程程序的替换
- 5.1程序替换的意义
- 5.2程序替换的原理
- 5.3进程替换的函数
- 总结
前言
一.进程地址空间
1.1进程地址空间的概念
在我们C语言的学习中我们了解到地址空间的构造即
但是我可以利用两组代码来让你对这个地址空间的真实性产生疑问
通过这两组代码我们可以有着一些结论
- 两个变量的值不同,所以父子进程输出的绝对不是同一个变量
- 但是地址又是相同的,所以这绝对不是物理地址
- 我们之前在C语言C++学习的地址全部都不是物理地址
所以这些地址到底是什么呢?
- 在linux中这些地址叫做虚拟地址,物理地址我们用户是看不到的。os统一管理这些虚拟地址也会将这些虚拟地址转化为物理地址
所以我们之前学习的地址空间的叫法是不正确的,正确的叫法应该是进程地址空间!
1.2进程地址空间和物理地址的关系
在Linux中利用进程地址空间和页表来将虚拟地址映射到物理地址中,如图。
在通过上面两个图我们大致理清了进程地址空间是通过页表来对物理地址造成映射的。
1.3进程地址空间的实现
那么进程地址空间又是如何实现的呢?
从这个问题我们可以通过观察进程地址空间的图来延申出两个问题出来:
-
什么是地址空间?
我们发现父子进程各自都拥有他们自己的地址空间,但是我们也知道实际上没有这么多的空间给他们,这些地址空间只能算是操作系统给每个进程画的大饼。
那么每个进程都有独立的进程空间的话操作系统要如何去管理这些地址空间呢?
在之前的学习我们掌握的那六字真言:先描述,再组织。
所以这些地址空间也都是每个进程pcb中的一个数据结构而已。 -
什么是区域划分?
在将地址空间描述成了每个进程pcb中的一个数据结构后我们也知道地址空间是有不同的区域的那么我们要如何去划分这些区域呢?
如果不对这些区域进行划分那么不同类型的变量存放的位置就会变得不可控那么这和物理地址又有什么区别呢?
对于划分区域我们可以用最最简单的方法就类似于我们小时候和同桌划的三八线,只要规定范围即可。所以我们只需要用变量来确定不同类型范围的开始和结束即可。
1.4进程地址空间的意义
- 将无序变为有序
让每个进程以统一的视角来看待内存,同时每个进程都可以用进程地址空间加页表的方式来将无序的数据变得整洁有序。 - 可以对进程访问内存的过程进行安全检查
页表内的内容不仅有虚拟内存和物理内存的对照还有对于这个物理内存的访问权限
所以当进程想要访问内存时我们可以通过页表里的访问权限来判断进程是只有读的权利还是有读和写权利或者都没有。
ps:当父进程创建子进程时父进程会先将读写权限改为只读再创建子进程,因为子进程的页表也是复制粘贴父进程的页表所以当子进程想要对内存内容进行修改时就会出现页表转换问题。这个时候操作系统就会进行介入来判断是真的出现问题还是要进行写时拷贝。
- 将进程管理和内存管理进行解耦
在我们之前的学习中我们知道了进程有着不同的状态例如挂起,休眠,僵尸等。那么现在学习了进程地址空间后我们是否有疑问:挂起状态是如何实现的呢?
在页表中不仅有着虚拟地址和物理地址的映射以及对于内存的访问权限还有着对于内存的是否分配和是否有内容。
当操作系统觉得你暂时不需要这片内存时就会将物理内存转移回磁盘中同时将内存资源释放,那么页表中的内容就会从11修改为了00。
上述的过程就是进程进行挂起时操作系统对于内存的过程,当然对于进程的pcb也会进入挂起的队列中。
当用户又需要访问这片空间后操作系统会让用户的访问暂时停留在页表的阶段,在这个等待的时间操作系统会重新将磁盘里的内容分配到内存中同时修改页表里的内容,这个等待的过程叫做缺页中断。
在拥有了进程地址空间和页表后,操作系统对于进程的管理只需要关注进程的各自队列和进程的状态,对于进程的内存也只停留在页表上,根本无需关注物理内存中的动作。
同时对于内存的管理时,操作系统也只需要管理物理内存的变化,不会涉及到进程的变化。
所以有了进程地址空间和页表就完成了对于进程管理和内存管理的解耦。
4. 实现进程的独立性
在之前对于进程的学习中我们强调过进程是独立的这一话题,同时我们也知道进程=内核数据结构+代码和数据。
那么进程地址空间和页表也存在于内核数据结构中所以每个进程都有着自己的进程地址空间和页表,这也算实现了进程的独立性。
同时对于不同的进程他们都可以通过自己的进程地址空间和页表来将自己的代码和数据映射到物理内存的不同位置中,虽然虚拟地址可能是相同的但是物理内存是不同的,这也是实现了进程的独立性。
二.进程的创建
想要创建一个进程通常都是使用我们之前学习过的函数fork(),而fork的用法在我们之前的学习中也已经介绍过了,所以这次我们就不再介绍了。从而将重点放在后面三个有关进程的控制介绍中。
三.进程的退出
3.1退出码和错误码
我们创建子进程的目的是为了让子进程为父进程完成一些工作,那么工作的完成状况只会有三种情况
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
那么子进程最后要如何能让父进程区分这三种情况呢?即在子进程退出时会留下退出码和错误码。
对于上面三种情况返回的退出码和错误码也不相同
- 退出码为0,无错误码
- 退出码为非0,无错误码
- 没有退出码,有错误码
但是退出码仅仅只是数字能表明出错的原因但是不方便人阅读,所以我们退出码是可以自己定义的。你可以自己定义不同退出码的所代表的具体内容。
那么退出码和错误码又有什么区别呢?
从三种退出状况中我们可以发现错误码只有在进程异常退出时才会出现,那么进程什么时候才会异常退出呢?例如数组越界,函数中出现空指针问题等等。
只有在进程内部代码中出现问题才会让进程异常退出,而退出码则是在没有出现异常状况时进程完整的跑完了之后的退出结果。
所以我们可以发现他们俩的区别是错误码是进程某个系统调用或者某个函数的调用情况也就是出现信号,而退出码是一个进程退出时的退出状态。
3.2退出的几种方式
-
exit函数退出
exit函数在我们之前C语言和C++的学习中都使用过但是当时并不了解其具体的使用方法
exit的参数就是我们说的退出码,所以我们可以人为控制进程结束时返回的退出码
-
_exit函数退出
_exit函数的使用方法和exit相同,但是他们俩有一些细节方面的地方有所不同,以代码为例
-
return退出
return退出就是调用exit函数即return n就是调用exit(n)
四.进程的等待
4.1进程等待的概念
进程的等待就是通过wait和waitpid的方式让父进程进行对子进程资源的回收利用的等待过程
4.2进程等待的必要性
进程等待有几点必要性
- 父进程如果对子进程不管不顾,会让子进程形成我们之前学习的僵尸进程从而产生资源泄露
- 僵尸进程无法被kill命令杀死
- 我们需要让父进程知道子进程对于任务的完成情况
- 在了解子进程对于任务的完成情况的同时回收子进程的资源
4.3进程等待的方法
- wait函数
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
在了解了进程等待的必要性后我们首先需要验证一件事:父进程能否通过进程等待回收已经成为僵尸进程的子进程?并且如果子进程在没有成为僵尸进程时父进程就进行等待了会如何?
- waitpid函数
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
我们在大概了解了waitpid返回值的参数的意义后我们一个一个的来深入了解其中的含义。
- pid
pid比较好理解,就是分为等待任意子进程和等待特定的子进程。 - status
在wait函数时我们解释了这是个输出型参数,即我们需要自己定义一个变量后函数会在运行过程中输出一个值到这个变量中,而这个变量的作用就是用来获取子进程的状态的即我们之前说的两个数:退出码和错误码。
status是一个整型变量,有32个bit但是我们存放状态只使用后面的16bit。并将这16个bit分为三个区域。
接下来我们使用代码来验证这种划分
那么问题也出现了如果我们想要通过status分别获得子进程的退出码和错误码呢?
所以我们通过status的概念也可以总结出来几点:
- 当一个进程出现异常即收到信号时,退出码就没有意义了
- 如何判断收没收到信号:利用WIFEXITED函数的返回值是否为零
在了解了status代表进程的退出状态后我们就要思考一个问题:父进程是如何利用wait和waitpid函数知道子进程的信息的?
- options
在前面wait函数处我们了解过如果父进程在子进程变为僵尸进程前就进行等待了那么父进程会进行阻塞等待直到子进程变为僵尸进程。
在阻塞等待的途中父进程无法进行其他的工作,而这个参数的作用就是可以让父进程进行非阻塞等待即将参数设为WNOHANG。
当设置了WNOHANG后waitpid的返回值也会发生改变,即在等待时如果子进程已经退出则会返回子进程的pid否则则返回0。
五.进程程序的替换
5.1程序替换的意义
在我们使用fork创建一个子进程后子进程往往执行的是和父进程相同的代码虽然有可能是不同的代码分支,但是在子进程中我们如果想要使用其他的一些的程序就需要使用一系列的exec函数来进行程序替换。
这种使用方法是否让我们可以联想到我们使用的shell呢?它是不是也是用进程替换来实现各种指令的呢?
5.2程序替换的原理
当我们使用exec函数后,该进程的代码和数据将会被新程序所替换并同时从新程序的启动序列开始执行。
在使用程序替换函数的时我们要了解一些细节问题:
- 进行程序替换并不是创建一个新进程所以进程的id不会发生变化
- 进行程序替换后原进程的后续代码将会被新进程的代码覆盖,所以在exec函数后的代码将不会执行。但是进程还会照常退出因为新进程也有退出的代码。
在原理上我们了解了程序替换是将新进程的代码和数据覆盖到原程序上,那么在之前的学习中我们了解了环境变量传递的方式是子进程通过进程地址空间继承父进程的环境变量。
现在进行了程序替换后进程的代码和数据都被覆盖了那么环境变量是否也发生了改变呢?
答案我们先公布:程序替换只替换代码和数据,环境变量不会被替换
5.3进程替换的函数
一共有六种以exec开头的函数,统称为exec函数。
我们一个一个的来进行介绍
- execl和execv
这两个函数是这个函数家族中最基础的两个函数。主要参数差距就在第二个参数上,execl的l代表list(列表),而execv的v代表vector(数组)
-
const char path*
是我们需要提供新程序的路径+文件名。 -
execl的const char *arg,……
是我们需要提供如何使用这个新程序,同时这是个不定参数:参数的数量没有限定,参数传完后要以NULL结束。这个参数的使用就将其当作我们平时命令行的使用方法例如命令行:ls -a -l 那么参数就传“ls”,“-a”,“-l”,NULL -
execv的char* const argv[]
不同于list的一个一个传递参数,execv可以将参数存储在一个数组中再将数组传递过来。
- execlp和execvp
- 当前面两个函数后加了一个p就说明我们不需要提供新程序的路径+文件名反而直接使用文件名就可以让系统在环境变量中自动寻找新程序。
- execle和execve
- 在上面的说明程序替换的原理里我们知道了子进程会继承父进程的环境变量,那么如果我们主观不需要父进程的环境变量呢?
- 在当加了e(env)后exec函数就需要我们组装环境变量
在介绍了这几种函数后我们再次观察man手册后发现只有exeve是在手册的第二节其他的都是在手册的第三节,这是因为只有exeve是真正的系统调用其他的的函数都是在其基础上增添了其他的功能。
exec家族的关系可以用下面一个图来表述
总结
这次我们先解决了在上篇文章中残留下来的问题即为什么一个变量有着不同的值从而引出进程地址空间和页表的概念。之后我们就进行对应进程控制的话题,从进程的创建到进程的退出,等待和替换。我们更加深入的了解了一个进程的前世今生以及更多的使用方法。