一、进程创建
1. fork函数
(1)基本认识
在 Linux 中fork
函数是一个非常重要的函数,它从已存在进程中(以它为模板)创建一个新进程。新进程为子进程,而原进程为父进程。
fork
函数的返回值:
① 成功:给子进程返回 0,给父进程返回子进程的 pid 。
② 失败:给父进程返回 -1。
进程调用 fork ,当控制转移到内核中的 fork 代码后,内核做:
① 分配新的内存块和内核数据结构给子进程。
② 将父进程部分数据结构内容拷贝至子进程。
③ 添加子进程到系统进程列表当中。
④ fork 返回,开始调度器调度。
父子进程的所有代码都是共享的,即便是父进程已经执行过的。
由于子进程是以父进程为模板来完成初始化的,所以子进程 PCB 内的程序计数器也跟父进程的相同,因此在fork
后,父子进程都会从fork
之后的代码往下执行。
示例:
运行结果:
所以,fork
之前父进程独立执行,fork
之后父子两个执行流分别执行。
注意:fork 之后,谁先执行完全由调度器决定。
(2)如何实现写时拷贝?
fork
之后,页表的代码和数据都被设置为只读(代码被设置为只读很好理解,因为父子进程都不会对代码做修改)。
若父子进程其中一个对数据进行写入,操作系统会立马中断该进程的写入动作,又因为该区域是父子进程共享的,所以操作系统会进行写时拷贝:重新开辟一块空间,把数据拷过来,修改进程页表对应的映射关系,然后再让进程进行写入。(因为父子进程在那块空间上分开了,所以把父子进程页表对应那项的只读属性去掉)
写时拷贝保证了父子进程的数据是独立的,维护了父子进程的独立性。
(3)常规用法
① 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
② 一个进程要执行一个不同的程序。例如子进程从fork
返回后,调用 exec 函数。
(4)调用失败的原因
① 系统中有太多的进程(创建进程是有成本的,包括时间上的和空间上的。若系统资源不足,有可能创建进程失败)。
② 实际用户的进程数超过了限制(为了防止用户恶意创建进程,系统设置了每个用户能创建进程的数目是有上限的)。
二、进程终止
1.进程退出场景
共有三种情况:
① 代码运行完毕,结果正确。
② 代码运行完毕,结果不正确。
③ 代码异常终止。
(1)对于前两种情况,如何判断代码运行完毕结果是否正确?
main 函数的返回值是进程的退出码,返回给父进程或系统,用来判断进程执行代码完毕结果是否正确。
① 返回 0 是 success,表示代码跑完结果正确。
② 返回非 0 是 failed,表示代码跑完结果不正确。
可以用echo $?
命令查看最近一次进程退出时的退出码。
为什么用非 0 表示结果不正确呢?
非 0 是有多种值的,代码跑完结果不正确,我们更想知道的是为什么不正确。
结果不正确的原因有很多种,我们就用一个具体的数字来代表结果不正确的一种原因,因此具体的一个非 0 值可以称之为错误的退出码。
我们通过strerror
函数可以查看每个错误码对应的错误描述。
通过 man 手册查看 strerror 函数:
通过 strerror 函数查看每个错误码对应的错误描述(只截出了前20个):
比如我们在当前目录下要求列出文件 myfile.txt(实际上不存在)的信息:
通过对照上面的表,我们可以知道对应的错误原因。
因此,我们在写代码时,也可以用上述的退出码来标识对应的错误原因。
(2)对于第三种情况
代码异常终止:代码跑了一部分就被终止了,即程序崩溃。本质是这个进程因为异常问题,导致自己收到了某种终止信号。
比如我们除零:
退出码对应的错误描述:
可以看到退出码对应的错误描述是没有的,其实程序崩溃的退出码是没有意义的,程序崩溃时 return 语句不会被执行,所以对应的退出码是多少我们也不关心。
2.进程常见退出方法
(1)正常退出
① main 函数的 return
main 函数的 return ,代表进程退出,返回值就是进程的退出码。
② 调用 exit 函数
exit
函数在任意地方调用,都代表终止进程,函数参数是退出码。
执行 return n 等同于执行 exit(n) 。
通过 man 手册查看 exit 函数:
示例:
进程执行 exit 函数后退出,不再执行后续的打印代码,进程的退出码就是 exit 函数的参数。
exit
函数和 main 函数的 return ,本身就会要求系统进行缓冲区(用户级缓冲区)的刷新。
它们运行的结果都是:printf 函数打印的消息没有被立即显示出来,是因为数据是被暂时保存在输出缓冲区中的,过了 4s 后,执行 exit 函数或者 main 函数的 return ,会刷新缓冲区,这样我们才看到打印的消息。
exit
函数和 main 函数的 return 的作用,除了能够让进程退出之外,还帮我们刷新缓冲区。
③ 调用 _exit 系统接口
_exit
系统接口会强制终止进程,不进行进程的后续收尾工作,比如刷新缓冲区(用户级缓冲区)。
由于 _exit 系统接口没有刷新缓冲区,所以最后 printf 函数打印的消息没有显示出来,进程的退出码就是 _exit 系统接口的参数。
关于exit
函数与_exit
系统接口的关系:
exit 函数会先执行用户定义的清理函数,然后冲刷缓冲、关闭流等,最后再调用 _exit 系统接口。
我们最常用的是前两种进程退出方法。
(2)异常退出
进程收到了某种终止信号,导致进程异常退出。
3.进程退出,操作系统做了什么?
进程退出,就是在系统层面上少了一个进程,操作系统就要释放 PCB、mm_struct、页表和各种映射关系,代码和数据申请的空间也要释放掉。
三、进程等待
1.基本认识
我们使用fork
函数创建出子进程是为了帮助父进程完成某种任务,父进程需要知道子进程完成任务完成得怎么样,于是我们让父进程fork
之后,需要通过wait
或者waitpid
函数等待子进程退出。
为什么要让父进程等待呢?
① 通过获取子进程退出的信息,能够得知子进程的执行结果。
② 可以保证时序问题,子进程先退出,父进程后退出。
③ 子进程退出时会先进入僵尸状态,如果父进程不管不顾会造成内存泄漏问题,因此需要父进程通过等待来释放子进程占用的资源。
另外,进程一旦进入僵尸状态,说明它已经死掉了,即使使用
kill -9 pid
命令也无能为力,因为谁也没有办法杀死一个已经死去的进程。
2.等待方法
(1)wait 函数
wait
函数:等待子进程改变状态,意思是等待子进程从别的状态变为Z状态,然后才能读取进程的状态信息进而返回。
wait
函数的作用:父进程阻塞等待子进程退出变为僵尸进程,然后对子进程进行回收。
wait
函数的参数:
① status 是输出型参数,用于获取子进程的退出信息,若不关心则可设置为 NULL 。
wait
函数的返回值:
① 成功,返回已终止子进程的 pid 。
② 失败(不存在该子进程),返回 -1 。
通过 man 手册查看 wait 函数:
示例:
在前5s,子进程每秒打印一次消息,父进程在休眠。
然后子进程退出变为僵尸进程,父进程再休眠5s。
父进程休眠结束,等待子进程成功并将其回收,最后再休眠10s然后退出。
wait
函数的参数 status 的具体用法跟waitpid
函数的第二个参数 status 是一样的,具体可以看下面的waitpid
函数。
(2)waitpid 函数
waitpid
函数的作用:父进程等待子进程退出变为僵尸进程,然后对子进程进行回收。
waitpid
函数的参数:
① pid 表示进程 id,若设置为子进程的 pid ,则等待该子进程;若设置为 -1 ,则等待任意一个子进程,与wait
函数等效。
② status 是输出型参数,用于获取子进程的退出信息,若不关心则可设置为 NULL 。
③ options 表示等待方式,若设置为 0 ,则代表阻塞等待;若设置为 WNOHANG,则代表非阻塞等待。
waitpid
函数的返回值:
① 成功,返回已终止子进程的 pid 。
② 失败(不存在该子进程),返回 -1 。
③ 如果第三个参数 options 被设置为 WNOHANG ,当子进程尚未退出时,返回 0 。
通过 man 手册查看 waitpid 函数:
示例:
在这个示例中,waitpid(id, NULL, 0) 与 waitpid(-1, NULL, 0) 显示的过程都和 wait 的一样。
waitpid
函数的第二个参数 status 是输出型参数,父进程拿到什么样的 status 结果跟子进程如何退出强相关!
子进程退出无外乎上面所说进程退出的三种情况:
因此最终一定是让父进程通过 status 来得到子进程执行的结果。
整形 status 不能简单地理解为整数,可以当作位图来看待。
status 共有 32 个比特位:我们现在只研究低 16 个比特位,高 16 个比特位不研究。
① 如果收到信号,说明子进程是异常终止的,不关心退出码。
② 如果没有收到信号,说明子进程的代码是正常跑完的,需要关心退出码。
也就是说 status 的低 7 位如果是 0 ,代表没有收到终止信号,就证明进程是正常运行完的,运行完了结果正确还是不正确,通过次低 8 位来判断。
示例①:代码运行完毕,结果不正确。
示例②:代码运行完毕,结果正确。
示例③:代码异常终止(子进程收到了 8 号信号SIGFPE)。
示例④:代码异常终止(子进程收到了 2 号信号SIGINT)。
bash 是命令行启动的所有进程的父进程,因此 bash 一定是通过 wait 方式得到子进程的退出结果,所以我们通过echo $?
命令能够查到子进程的退出码!
对 status 使用位操作有点麻烦,因此,我们可以使用宏。
① WIFEXITED(status)
:若子进程正常终止,则为真。(判断子进程是否正常退出)
② WEXITSTATUS(status)
:在WIFEXITED(status)
为真时使用,用于提取子进程的退出码。(查看子进程的退出码)
子进程正常退出:
子进程异常终止:
所以,我们在写代码时就可以不用写那些麻烦的位操作了,直接用这些宏。
waitpid
函数的第三个参数 options,表示等待方式:
① 0:阻塞等待。
② WNOHANG:非阻塞等待。
① 阻塞等待:子进程不退出父进程就不返回(阻塞等待),子进程退出了父进程才返回。本质就是操作系统把父进程的 PCB 放入到等待队列,并将进程的状态改为 S 状态,如果子进程执行结束退出,操作系统就会把父进程的 PCB 从等待队列拿到运行队列当中,从而被 CPU 调度,获取子进程的退出结果。
② 非阻塞等待:父进程不会阻塞地等待子进程,若子进程尚未退出,父进程就会立马返回。每一次非阻塞等待就是一次检测,我们通常需要多次检测,这称为基于非阻塞等待的轮询方案。本质就是调用一次等待接口,调用完立马返回,CPU 正常调用父进程。CPU 不断重复地调度父进程,就是不断重复地执行等待接口的过程,父进程就不会被阻塞。
示例:基于非阻塞等待的轮询方案
3.如何理解 waitpid ?
应用层调用waitpid
系统接口,传入变量 status 的地址,当子进程退出时,子进程会处于僵尸状态(PCB 保存进程退出时的退出数据),操作系统让父进程去获取子进程的退出结果放到 status 里面,这样用户通过 status 就能看到退出结果了。
四、进程程序替换
1.基本认识
之前我们创建子进程都是为了让子进程执行父进程代码的一部分,但如果我们创建子进程是为了让子进程执行另一个程序呢?
一个进程执行的是当前程序的代码和数据,如果让一个进程去执行一个新程序的代码和数据,就可以把新程序的代码和数据都加载到对应的当前程序的代码段和数据段(加载时地址可能会发生变化,可能会修改页表对应的映射关系),即进程的的代码和数据都被替换了。这个进程就不再执行原来程序的代码和数据,而执行的是新程序的代码和数据。
进程整体上不变,仅仅替换其代码和数据,叫做进程的程序替换。
该过程并没有创建任何新的进程。
程序替换函数:有几种以 exec 开头的函数,统称为 exec 函数。
先来个例子直观地感受一下:
我们可以看到,进程执行 execl 函数时,当前程序的代码和数据就被我们指定程序 ls 的代码和数据给替换了(原程序后续的代码不会被执行),然后进程执行程序 ls 的代码和数据。
2.如何理解进程程序替换?
进程程序替换的本质就是把指定程序的代码和数据加载进特定进程的上下文中!
我们之前说过,C/C++ 程序要运行,必须先加载到内存中。如何加载呢?实际上就是加载器把在磁盘上程序的代码和数据拷贝到内存中。
加载器的底层原理可以理解为采用 exec 系列的程序替换函数。
由于子进程的程序发生替换,为维护进程的独立性,代码和数据都要写实拷贝,写时拷贝后父子进程的代码和数据各自一份,父进程继续执行原来的代码,而子进程就执行新的程序。
(原本父子进程的代码是共享的,但由于进程的程序替换会更改代码区,所以代码也要发生写时拷贝)
示例:
只要进程的程序替换成功了,就不会执行后续代码,意味着 exec 系列的函数在成功的时候,不需要返回值检测。换句话说,只要 exec 系列的函数返回了,就一定是调用失败了。
示例:
我们把当前程序替换成一个不存在的程序,所以程序替换失败,该进程执行后续代码。
所以,我们在调用这些接口的时候不做返回值判断,只要返回了一定是程序替换失败了。
3.程序替换函数:exec 系列函数
通过 man 手册查看 exec 系列的函数:
命名理解:
- l (list):表示参数采用列表方式传递。
- v (vector):表示参数采用数组方式传递。
- p (path):表示自动在环境变量 PATH 中搜索程序。
- e (env):表示自己维护环境变量。
l 与 v 传递方式的区别:
① l 表示参数采用列表方式传递。
② v 表示参数采用数组方式传递。
其实就是把参数列表中一个一个的参数统一放到数组里,两者没有本质的区别。
(1)execl 函数
通过 man 手册查看 execl 函数:
execl
函数的参数:
① 第一个参数 path 代表要执行的目标程序的路径。
② 第二个参数 arg 和后续的省略号代表可变参数列表(可被视为 arg0, arg1, …, argn),且必须以 NULL 结尾表示参数传递结束。
示例:
(2)execv 函数
通过 man 手册查看 execv 函数:
execv
函数与execl
函数在本质上是一样的,只是在传参形式上有差别。
execv
函数的参数:
① 第一个参数 path 代表要执行的目标程序的路径。
② 第二个参数 argv ,是一个指针数组,里面是要传给目标程序的命令行参数(必须以 NULL 结尾表示参数传递结束)。
示例:
(3)execlp 函数
通过 man 手册查看 execlp 函数:
execlp
函数的参数:
① 第一个参数 file 是程序文件名(p 表示该函数会自动在环境变量 PATH 中搜索这个程序文件在什么地方)。
② 第二个参数 arg 和后续的省略号代表可变参数列表(可被视为 arg0, arg1, …, argn),且必须以 NULL 结尾表示参数传递结束。
示例:
(4)execvp 函数
通过 man 手册查看 execvp 函数:
execvp
函数的参数:
① 第一个参数 file 是程序文件名(p 表示该函数会自动在环境变量 PATH 中搜索这个程序文件在什么地方)。
② 第二个参数 argv ,是一个指针数组,里面是要传给目标程序的命令行参数(必须以 NULL 结尾表示参数传递结束)。
示例:
(5)execle 函数
通过 man 手册查看 execle 函数:
execle
函数的参数:
① 第一个参数 path 代表要执行的目标程序的路径。
② 第二个参数 arg 和后续的省略号代表可变参数列表(可被视为 arg0, arg1, …, argn),且必须以 NULL 结尾表示参数传递结束。
③ 第三个参数 envp ,是一个指针数组,里面是要传给目标程序的自己维护的环境变量(必须以 NULL 结尾表示环境变量传递结束)。
示例:
(6)execve 系统调用
通过 man 手册查看 execve 系统调用:
execve
系统调用的参数:
① 第一个参数 filename 代表要执行的目标程序的路径。
② 第二个参数 argv ,是一个指针数组,里面是要传给目标程序的命令行参数(必须以 NULL 结尾表示参数传递结束)。
③ 第三个参数 envp ,是一个指针数组,里面是要传给目标程序的自己维护的环境变量(必须以 NULL 结尾表示环境变量传递结束)。
示例:
(7)execvpe 函数
通过 man 手册查看 execvpe 函数:
execvpe
函数的参数:
① 第一个参数 file 是程序文件名(p 表示该函数会自动在环境变量 PATH 中搜索这个程序文件在什么地方)。
② 第二个参数 argv ,是一个指针数组,里面是要传给目标程序的命令行参数(必须以 NULL 结尾表示参数传递结束)。
③ 第三个参数 envp ,是一个指针数组,里面是要传给目标程序的自己维护的环境变量(必须以 NULL 结尾表示环境变量传递结束)。
这里不再展示示例了,用法都大同小异。
+++++
关于 execve 系统调用和其它 exec 函数:
所有的接口,看起来是没有太大差别的,只有参数不同而已。
但是为什么会提供这么多类似的接口呢?主要是为了满足不同的应用场景!
其实操作系统只提供了 execve 系统调用,其它的 exec 函数都是库函数,都是对 execve 系统调用的封装,最终底层调用的都是 execve 系统调用。
+++++
4.具体用途
有了程序替换函数,我们可以在父进程中创建出子进程,让子进程去执行其它程序,比如用程序替换函数来执行其它语言的解释器,进而执行其它语言的代码。
示例:
在命令行上执行:
程序替换:
五、实现一个简易的 shell:bash
现在我们利用上面的相关知识,做一个简易的 shell:bash,使其能执行一些基本的 shell 命令。
总代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#define NUM 128
#define CMD_NUM 64
int main()
{
char command[NUM];
for(;;){
char *argv[CMD_NUM] = { NULL };
command[0] = 0; // 用这种方式,可以做到以O(1)的时间复杂度清空字符串
//1.打印提示符
printf("[who@myhostname mydir]# ");
fflush(stdout); // 手动刷新缓冲区,使其立马刷新到显示器上
//2.获取命令字符串
fgets(command, NUM, stdin); // command: "ls -a -l -i\n\0"
command[strlen(command) - 1] = 0;
//command: "ls -a -l -i\0"
//3.解析命令字符串, char *argv[];
//使用strtok函数分割command字符串,提取每个参数
const char *sep = " "; // 分隔符
argv[0] = strtok(command, sep);
int i = 1;
while(argv[i] = strtok(NULL, sep)){
++i;
}
//4.检测命令是否是需要shell本身执行的(检测是否为内建命令)
if(strcmp(argv[0], "cd") == 0){
if(argv[1] != NULL) chdir(argv[1]); // 保证第二个参数不为空
continue; // 执行完内建命令后,不需要执行后续的代码了
}
//5.执行第三方命令
if(fork() == 0){
//child
execvp(argv[0], argv);
exit(1);
}
int status = 0;
waitpid(-1, &status, 0);
printf("exit code: %d\n", (status >> 8) & 0xFF); // 打印退出码
}
}
1.打印提示符
在这里我们把提示符弄得简单一点,就固定打印。
不以 ‘\n’ 结尾的字符串是不会立即刷新到显示器上的,为了能够立即刷新到显示器上,我们需要用fflush
函数手动刷新缓冲区。
2.获取命令字符串
bash会先给你输出提示符,然后获取你的输入,你的每一次输入都是一个长字符串。
传入一行命令字符串,实际上是向标准输入写数据,我们用fgets
函数把获取的一行字符串放到字符数组里面。
我们输入完毕后会按回车键表示输入结束,这个换行符’\n’也会被读进去,因此我们需要去掉这个’\n’。
3.解析命令字符串
因为程序替换函数的传参需要一个一个的字符串,所以我们需要对原字符串进行分割,这要用到strtok
函数。
4.执行命令
(1)如果是执行第三方命令的话:
我们通常让fork
出来的子进程执行第三方命令,让子进程去执行程序替换函数。
我们选择 exec 系列函数中的execvp
函数,原因是:
① v:命令行参数使用数组方式。
② p:我们输入的命令一般都可以在环境变量 PATH 中可以搜索到。
综上,我们使用 execvp 函数。
假设没有“检测是否为内建命令”的那部分代码:
我们发现cd
命令对我们自己实现的 bash 进程不起作用。
如何解释这种现象呢?
这是因为我们把cd
命令交给了子进程去执行,结果改变的是子进程的路径(而实际上我们想要的是父进程 bash 来执行,因为我们想要改变的是父进程 bash 的路径)。
pwd
命令打印的是当前进程所处的路径,但由于父进程的路径并没有改变,所以我们看到了上述现象。
那么如何正确地处理呢?
这就涉及到内建命令了,下面就对内建命令详细说明。
(2)如果是执行内建命令的话:
shell 内建命令:不创建子进程,让父进程 shell 自己执行,相当于调用了自己的一个函数。
比如说cd
命令等。
需要把是否为内建命令的条件判断放在第三方命令前,因为内建命令是父进程 shell 自己执行的,而第三方命令是交给子进程执行的。
如何让父进程 bash 执行内建命令呢?
会有专门的系统接口提供给父进程 bash 去调用。
比如说,chdir
系统接口可以改变进程所处的路径。
可以通过 man 手册查看 chdir 系统接口:
内建命令有很多,在这里我们只以
cd
命令为例。
在最后,我们还可以获得子进程的退出码,在这里直接通过打印的方式显示出来。
在这里就不对退出状态做进一步的条件判断了。