一、进程创建 —— fork
1.fork
fork:在调用时,创建子进程,父进程返回子进程pid,子进程返回0,出错返回-1
头文件:#include<unistd.h>
2.fork函数被调用时,CPU做了什么?
a.分配新的内存块和内核数据结构给子进程
b.将父进程的部分数据和内容拷贝给子进程
c.添加子进程到系统进程列表中
d.fork返回,开始调度器调度
3.写时拷贝
父进程和子进程在不需要对数据进行操作时,它们的页表指向同一块区域,共享同一份代码和数据信息,只有当某一个进程中需要对这些信息进行修改操作时,系统才会去对这部分进行拷贝,额外分配空间去给该进程进行管理和操作,这就是写时拷贝
4.fork的常规用法
(1)希望子进程执行父进程的部分代码
(2)执行不同的程序
5.调用失败的原因
(1)系统中进程过多
(2)实际用户的进程次数超过了限制
二、进程终止
1.情况分类
(1)代码运行完毕,结果正确
(2)代码运行完毕,结果不正确
(3)代码异常终止(崩溃了)
崩溃本质:进程因为某些原因,导致进程收到了来着操作系统的终止信号
2.退出方式
(1)main函数的返回return
(2)调用c库函数exit
(3)调用系统接口_exit
区别:exit在终止进程前,会干一系列的善后工作,例如刷新缓存区等等,最后再调用接口_exit,而_exit则是直接终止进程,因此通常推荐使用exit
3.退出码
退出码反映的是进程执行结束的一个结果,例如return 0中的0表示的就是程序正常执行结束的意思
exit函数中传的参数也是程序终止返回的退出码,退出码有相对应的一张表格,0表示程序正常退出,结果正确,其他数字则表示进程有问题,不同数字代表的内容不同
4.如何理解进程终止?
OS内少了一个进程,OS就要释放进程内对应的数据结构 + 代码和数据(如果有独立的)
三、进程等待
1.为什么要进程等待
(1)因为存在僵尸进程,所以为了避免内存泄露
(2)获取子进程执行结果
2.什么是?
等待就是通过系统调用,获取子进程的退出码或者退出信号的方式,顺便释放内存
3.如何做到?—— waitpid
pid_t waitpid(pid_t pid,int* status,int options);
作用
这个函数的作用就是等待子进程死亡,然后回收子进程,返回大于0的值表示成功,返回-1则表示失败
如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退 出信息。 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。 如果不存在该子进程,则立即出错返回。
参数说明
第一个参数,pid:可以指定子进程的pid,当参数给-1时,则认为等待任一个子进程
第二个参数int* status:输出型参数,会返回子进程的状态值,该状态值包含着子进程结束后的退出码和信号,采用位图的方式返回,有效位置是后面16位
当进程正常终止时,8到15位表示的是退出状态(退出码),当进程被信号干掉(异常终止),则在0到7位记录着终止信号的信息
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
第三个参数options:在子进程执行时,父进程使用waitpid等待子进程结束,有两种等待方式,一种是阻塞等待,即父进程的内容停在waitpid那一行不再继续执行,直到回收子进程后,再继续执行,此时父进程被放到阻塞队列中,当子进程结束时,再找到父进程改变其状态,让其继续运行,而默认情况下,就是这种方式等待,还有一种等待方式是非阻塞轮询,即一边执行父进程的内容,一边对子进程进行时不时的状态检测,当子进程结束后进行回收,此时我们给第三个参数传参WNOHANG,就是非阻塞轮询等待
4.代码测试
阻塞等待
非阻塞轮询
四、进程替换
1.替换原理
前面说到,创建子进程的其中一个作用就是让子进程去执行其他的程序,而子进程和父进程共享同一份代码,如何能够做到让子进程单独的去执行其他的程序呢?
这里的进程替换就是,利用系统接口,让子进程去调用其他程序,此时会发生写时拷贝去确保进程之间的独立性,代码区会被替换,因此从这里可以看出来,写时拷贝替换的不只有数据,还有代码也可能被替换
在进程的角度,是子进程发生写时拷贝,并且替换了一部分代码,而在程序的角度看,就是程序被加载到了这个子进程里,因此这个过程也就是程序加载
当子进程使用进程替换后,会写时拷贝出独立的空间,此时,以接口为开始的后续所有代码都会被替换成被加载的程序,而不会继续执行,除非替换失败,因此在对程序替换函数的返回值是只有失败才返回,一旦检测到有返回值,则必定失败
2.加载器exec...
#include <unistd.h>
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[ ]);
int execve(const char* filename,char* const argv[ ],char* const envp[ ]);
以上的接口都是用来实现程序替换的函数,最后一个标红的是真正的系统接口,其余六个都是根据不同情况对该接口的封装,实际的功能都是一样的,并且通过函数名的不同,使用的方式略微有些差异:
前缀都是exec,一般最多有三个参数
第一个参数位置一般是填写程序或者指令的名称或路径
第二个参数一般就是表示在使用这个程序或者指令时,要怎么使用,例如命令行参数等等
例:ls -a -l ,需要将如何使用这个信息通过字符串数组或者列表的方式传参,注意要以NULL结尾
第三个参数就是指定传环境配置,若是不传参则会使用系统默认的环境配置
按照命名顺序来解释:
l和v :表示第二个参数传参的方式
l 可以看作为 list 列表,以列表的方式,直接在传参时,将一个个使用方式传过去
例如:想调用“ls -a -l”时,execl("/bin/ls","ls","-a","-l",NULL);
v 可以认为是vector 数组,以数组的方式传参,在c语言中,可以定义一个字符串数组(字符指针数组)去传参
例:char* arr[ ] = { "ls" , "-a" , "-l" , NULL}; execv("/bin/ls",arr);
p : 表示path,表示函数会自动搜索环境变量中配置好的路径
此时,传参的第一个参数中可以不带路径而是直接带指令名称即可,当然自己写的程序默认路径下找不到,也无法执行
因此有没有带p在使用者的角度区分,就是传路径还是传名称
例如:想调用“ls -a -l”时
execl("/bin/ls","ls","-a","-l",NULL);
execlp("ls" , "ls","-a" ,"-l" , NULL);//注意前后两个"ls"的意义不同,尽可能不要省略
(前者表示第一个参数中的指令名称,后面一串中,表示的是该指令的使用方法)
e :表示允许自己传入自己配置的环境变量,不传则给一个NULL,系统会使用从父进程那继承的默认环境变量
因此,我们可以从这里理解,子进程是如何继承到父进程的环境变量,在系统可以用这个接口去调用子进程,将环境变量继承下去
从命名上去记忆这些函数以及使用方法,在了解上面的命名规则后,可以采用一些技巧去记忆
首先exec是同用的前缀,exe表示可执行程序的后缀,c表示该函数用c语言实现的
然后就是考虑在调用一个指令时的方法,采用列表传参还是数组传参,这个决定了下一个字母带l还是v
然后就是要如何找到这个指令(程序),希望是默认路径去找就带p,指定路径则不需要带
最后是环境变量,需要自己传环境配置则最后加e,若是不需要则不加e
总结
综上,我们整理了关于一个进程的创建,核心要掌握fork的理解和使用
还有进程的终止,终止的情况有正常和非正常,以及正常终止后的退出码信息如何获取等等
然后就是关于进程等待的理解,当子进程结束后,需要被父进程回收,父进程等待子进程结束的过程就是进程等待,分为阻塞等待和非阻塞轮询,以及相关的接口waitpid的理解和使用
最后讲到进程替换,也叫程序加载,理解程序是如何被加载到进程中的,以及对应的接口exec...的使用
根据对进程和这些接口的理解,我们可以尝试去写一个简易版的命令行解释器,下一篇将整理如何自己实现一个简易版的Linux命令行解释器