目录
我们学习的进程控制主要包括进程创建、进程终止、进程等待以及进程程序替换
(2)问:请你描述一下,fork创建子进程,OS都做了些什么?
(3)再问:fork创建子进程,是不是系统中多了一个进程呢?
(4)问:“fork之后的代码共享”,这句话中,所谓的”代码共享”是指“fork()”下面的代码共享还是全部代码共享呢?
(1)int execv(const char *path, char *const argv[])
(2)int execlp(const char *file, const char *arg, ...)
(3)int execvp(const char *file, char *const argv[])
(4)int execle(const char *path, const char *arg, ...,char *const envp[]);
(5)int execve(const char *path, char *const argv[], char *const envp[]);
0.首先我们需要知道的是SHELL进程肯定是一个常驻的进程——不退出
我们学习的进程控制主要包括进程创建、进程终止、进程等待以及进程程序替换
一、进程创建
1.fork函数
(1)fork函数初识:
#include <unistd.h>
pid_t fork(void)
返回值:父进程返回子进程的pid,子进程返回-1;
(2)问:请你描述一下,fork创建子进程,OS都做了些什么?
答:
1.分配新的内核数据结构(PCB)和内存块给子进程
2.添加子进程到系统进程列表中
3.将父进程的PCB拷贝给子进程
4.fork返回时,开始调度器调度
(3)再问:fork创建子进程,是不是系统中多了一个进程呢?
答:是的
进程 = PCB + 进程代码和数据 在这其中,我们知道PCB是由OS维护的,而进程的代码和数据则是由C/C++程序加载后的结果。
创建子进程,给子进程分配对应的内核数据结构———拷贝的父进程的 ,必须让子进程独有它,因为进程具有独立性!那代码和数据呢?一般而言,我们没有加载代码和数据的过程,也就是说子进程没有自己的代码和数据!所以,子进程只能“使用”父进程的代码和数据。
在这其中:代码——代码是只读的,所以父子共享,没什么问题。
数据——数据是可读可写的,因为数据是可能被修改的,如果此时还要让父子进程共用的话,那来个什么全局变量之类的,就很有可能出现问题,因此,父子进程的数据必须分离!
(3)于是便有了写时拷贝策略: 如图:
在上图中,子进程的task_struct 是拷贝的父进程的,那也就导致mm_struct和父进程的相同,进程地址空间上数据的地址就指向同一个物理内存。但是如果数据要发生写入(修改),此时子进程正如我们上面所说,必须和父进程的数据分离,而OS又不能提前知道哪些数据会发生改变,并且如果提前拷贝了数据也不一定用的上,于是就有了写时拷贝策略——当数据要发生写入时,页表将其其映射到另一个物理地址上,来将父子进程分离。
这也就很好的解释“了父子进程中相同地址的变量的值不同”这个现象了——task_struct 是抄的父进程的,导致了进程地址空间相同,而由于变量发生了写入,导致其映射在物理内存上的另一个空间。
(4)问:“fork之后的代码共享”,这句话中,所谓的”代码共享”是指“fork()”下面的代码共享还是全部代码共享呢?
答:全部代码共享!,但子进程执行是从”fork();”下面开始的。
我们需要有这样的概念:
1.我们的代码汇编之后,会有很多行代码,而且每行代码加载到内存之后都有对应的地址。
2.因为进程随时可能被中断(可能并没有执行完),下次回来,还必须从之前的位置继续运行(不是最开始!),就要要求CPU必须能随时记录下来当前进程执行的位置,所以CPU中有对应的寄存器数据用来记录当前进程的执行位置!
CPU中有一个叫EIP的寄存器(又被称为PC指针,来指向当前执行代码的下一行代码的地址)
CPU干三件事:取指令、分析指令、执行指令 (代码编译->汇编->二进制语言 就是指令)CPU只能通过寄存器EIP来执行指令
寄存器在CPU上只有一套,而寄存器内的数据,是可以有多份的!——这就是所谓的上下文数据,每个进程都有自己的那一份上下文数据!而子进程创建的时候,也会认为自己的EIP起始值是"fork();"下面的代码(父进程的上下文数据存储在PCB中,被子进程给抄过去了),虽然父子进程各自调度,各自会修改EIP,但是这已经不重要了,这都已经是后话了(起始值一样,执行起点就相同)。
2.fork的常规用法
1.一个父进程希望复制自己,使父子进程同时执行不同的代码段。
例如父进程等待客户端请求,生成子进程来处理请求。
2.一个进程要执行一个不同的程序。
例如父进程从fork返回后,调用exec函数。(进程替换)
3.fork调用失败的原因
1.系统中有太多的进程了。
2.实际用户的进程个数超过了限制。
二、进程终止
问:进程终止的时候,OS做了什么?
答:释放进程申请的相关内核数据结构(PCB)和对应的数据和代码。(本质就是释放系统资源)
1.进程终止的场景
1.代码跑完,结果正确。
2.代码跑完,结果不正确。
3.代码没跑完,程序崩溃。
注意:这里的结果正确不正确,就是看退出码的值是否为0(0代表结果正确)。当程序崩溃时,退出码没有任何意义!一般而言,程序崩溃的话退出吗对应的return语句不会执行。
问:main函数的返回值的意义是什么?(main函数为什么要在后面加个return 0?)
答:
(1)main函数返回值并不总是0
(2)main函数的返回值叫做“进程的退出码”(若为0则代表结果正确 ,若为非0则代表结果错误以及相关错误原因)
(3)返回给上一级进程,用来评判该进程执行结果用的。父进程可以忽略。
注意:0代表正确,而不正确可以用任意非0,这可以使不同的非0值对应不同的错误原因。
Linux命令:echo $? 获得最近一次进程执行完毕的错误码。
函数:sterror(int 错误码) 显示错误码对应的错误(退出码)
我们自己可以使用这些退出码和含义,但是,如果想要自己定义,也可以自己去设计一套方案。
2.进程退出的常用方式
1.return 语句,注意是main函数的return语句
2.exit(退出码),这个函数可以直接结束进程,注意在任何地方调用都会终止进程。
3._exit(退出码),和exit(退出码)的用法相似。
只是:
_exit 函数会直接终止程序;
而exit会先执行析构函数,清空缓冲区(例如cout << "bznb" << endl)等操作再终止程序;
(推荐使用exit())
其实exit就是封装的_exit( ),exit()是库函数,而_exit()则是系统调用接口。
问:都说printf("xxx(没加\n)"),此时数据是在缓冲区的,想要写入到显示器文件中需要刷新缓冲区,那么这个缓冲区到底在哪里呢?谁来维护呢?
答:一定不是在操作系统内部的,C标准库来维护!
(这个后面会学到:FILE是C标准库提供的结构体,内部有缓冲区的指针->每个FILE中都有对应的缓冲区->C程序代码又算作进程的一部分->task_struct中的“代码与数据”中有关于缓冲区的内容”->每个进程都有对应的pcb->每个进程都有对应的缓冲区 )
三、进程等待
1.进程等待的必要性
(1)子进程退出,父进程如果一直读取子进程状态回收,那子进程就要处于“Z”状态——占用内存。
(2)注意,kill -9 也杀不掉僵尸进程,因为kill -9 是用来强制结束进程的,而僵尸进程已经退出了。换句话说,谁也没有办法杀死一个已经死去的进程。
(3)父进程创建子进程,是要让子进程办事的,那么子进程把任务完成的怎么样、结果正确还是错误,父进程肯定是需要关心的,而父进程关心子进程的方式就是进程等待!
总结:进程等待的原因:父进程通过进程等待的方式,回收子进程的资源,获取子进程的退出信息。
2.进程等待的方法
wait 方法
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);//错误码
wait会等待一个进程,直到该进程状态发生改变。
wait的返回值有两种情况:1.wait成功 会返回子进程的pid 2.wait失败会返回 -1
例如:
int main()
{
int id = fork();
if(id == -1)
{
perror("fork);
exit(1);
}
else if(id == 0)
{
//子进程正常运行,可以正常结束
//...
}
else
{
//父进程等待子进程结束
int ret = wait(NULL);
if(ret > 0)
{
//当ret > 0 时,就是等待成功了,返回的是子进程的pid
}
//等待完成之后,再执行父进程后面的代码。
}
return 0;
}
注意:
1.当进程等待时,进程处于阻塞态。
2.wait是系统调用接口
3.wait的参数是输出型参数,用来获取子进程的退出状态的,不关心的话可以设置成为NULL。
waitpid方法
//头文件和wait相同
pid_t waitpid(pid_t pid,int * status,int options);
pid 是被等待进程的pid
status 是错误码(输出型参数,用来获取被等待进程的退出状态)
options 是表示等待状态 ,默认为0,表示阻塞等待
waitpid同样也会等待一个进程直到它的状态发生改变
waitpid的返回值有三种:
1.当等待成功时,能返回收集到的子进程的pid
2.如果设置了选项WNOHANG,而调用中waitpid发现没有已经退出的子进程可以收集,就返回0(即等待成功,但是子进程未退出)
3.如果调用出错,就会返回-1.
对于waitpid的参数:
对于pid
也分为两种情况:
1. pid 为 -1 ,就说明waitpid等待任意一个子进程,此时waitpid的功能和wait一样。
2.pid大于 0 时,等待进程ID和pid相等的子进程。
对于status
它是一个输出型参数,可以设为NULL来表示不关心输出的结果。如果关心输出的结果,可以通过设立一个变量来显示退出状态,例如:
//定义一个变量status
int status;
waitpid( ,&status, )//会把子进程的退出状态给到status变量
另外,需要注意的是,status并不是按照整型来整体使用的,而是按照比特位的方式。将32个比特位进行划分,只看低16位的。
在上面这张图中,waitpid所等待进程正常终止情况下的status的低16位中的次低16位(也就是8到15位),表示的就是退出码。
而如果进程没有正常终止而是被杀掉了,那status的低16位中的最低0到7位,所代表的就是子进程收到的信号(如果我们想查看,可以 status& 0x7f)——如果位运算出来的数不是0,那就代表进程崩溃、异常退出了,而第8位就代表core dump标志。
(0x7f = 16乘7+15 = 127 = 01111111二进制 ,配合位运算中的与&运算——同时为1才为1,可以得到最低七位的结果)
注意:程序异常不光光只有可能是内部代码有问题,也有可能是外力直接杀掉-->这导致无法确定子进程代码是否跑完了。
但我们可以得出结论:进程异常退出或者崩溃,本质上就是OS杀掉了进程。
那OS是怎么杀进程的呢?发射信号!
我们kill -1 可以查看信号列表
如果我们想要看到子进程的退出码,可以:(status>>8)&0xff)
//...
int status = 0;
pid_t ret = waitpid(fork(),&status,0);
if(ret>0)
{
printf("等待结束,退出码为:%d\n",(status>>8)&0xff));
}
(0xff = 15乘16+15 = 255 = 11111111)
另外,我们还能通过将status传参函数方式来获得退出码:
bool WIFEXITED(status):若status为正常终止的子进程返回的状态,那结果就为真(查看进程是否正常退出)
int WEXITSTATUS(status): 若WIFEXITED,非0,则提取出这个进程的退出码(就是封装了WIFEXITED和(status>>8)&0xff)方法的函数)(查看进程的退出码)
对于options
默认为0,表示阻塞式等待
3.模拟实现
我们试着用代码去实现一个进程的阻塞等待的例子:
int main()
{
pid_t pid;
pid = fork();
if(pid < 0)
{
printf("%s fork error\n",__FUNCTION__);//__FUNCTION__是一个宏,可以获得当前函数名。
return 1;
}
else if( pid == 0 )
{ //child
printf("child is run, pid is : %d\n",getpid());
sleep(5);
exit(257);
}
else
{
int status = 0;
pid_t ret = waitpid(-1, &status, 0);//阻塞式等待,等待5S
printf("this is test for wait\n");
if( WIFEXITED(status) && ret == pid )
{
printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));
}
else
{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
运行结果:
[root@localhost linux]# ./a.out
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.
4.为什么要进行进程等待?
1.问:父进程通过wait/waitpid 可以间接拿到子进程的退出码,那为什么要用wait和waitpid函数呢?直接全局变量不行吗?
答:全局变量肯定是不行的,因为两个进程之间的数据是无法共通的(进程具有独立性),当子进程全局变量做修改时,会发生写时拷贝。况且异常信号用全局变量是拿不了的。
2.问:既然进程有独立性,进程退出码不也是进程的数据吗?父进程为啥能通过wait和waitpid拿到呢?
答:因为就算是僵尸进程,他也保留了自己的PCB,tast_struct里面保留了任何进程退出时的结果信息。本质上,wait和waitpid就是读取子进程的task_struct(中的int exit_code,int exit_signed成员),由于wait和/waitpid是系统调用接口,所以可以访问内核数据结构task_struct。
总结:为什么要进行进程等待:
1.只有子进程退出,父进程才会waitpid执行返回(在这期间父进程还没死),这使得进程退出具有了一定的顺序性,将来可以让父进程做更多的收尾工作。
2.防止内存泄露,因为僵尸状态的PCB会占用内存。
3.关心到子进程状态。
5.非阻塞式等待
pid_t waitpid(pid_t pid,&status,int options)
我们知道默认情况下options为0,代表阻塞式等待。如果我们想要非阻塞式等待,就要修改options的值。
options有两种情况: 1. 0 阻塞等待 2. WNOHANG 如果pid子进程没有退出,那waitpid立刻返回0。(WNOHANG就是wait no hang的意思)
(Linux自己用C语言写的——系统调用接口——OS自己提供的接口——就是C语言函数)
系统提供的一些大写的标记位WNOHANG,其实就是宏。
我们常说“夯住了”,其实就是没被CPU调度的进程要么在阻塞队列中,要么就是等待被调度。(CPU太忙了)。
阻塞等待:一般都是在内核中阻塞,等待被唤醒(把PCB放在等待队列中)(一般一个进程阻塞了,他的代码和数据就会被切换下去)
非阻塞等待:可以通过不断地调用来检测子进程的状态。
我们未来的编写代码的内容,网络代码,大部分都是IO类别的,会不断面临阻塞和非阻塞的接口。
小插曲:C++源文件可以写成.cpp/.cc/.cxx
非阻塞等待可让父进程执行别的任务(在等待过程中)
阻塞等待的例子:
int main()
{
pid_t pid;
pid = fork();
if(pid<0)
{
perror("fork");
return 1;
}
else if(pid == 0)
{
printf("我是子进程\n");
sleep(5);
exit(257);
}
else
{
int status = 0;
pid_t ret = waitpid(-1(或者吧pid写出来),&status,0);//阻塞等待五秒
prinf("我在等\n");
if(WIFEXITED(status)&&ret = pid)
{
prinf("等待5s之后子进程正常退出,退出码为%d\n",WEXITSTATUS(status));
}
else
{
printf("等待失败\n");
return 1;
}
}
return 0;
}
将这串代码改为非阻塞式等待只需要:
四、进程替换
1.进程替换的原理
我们前面已经说了,fork()之后。父子各自执行父进程代码的一部分。那如果子进程想执行一个全新的程序呢?
即:子进程想要有自己的代码呢?
——这就需要用到进程替换
进程替换是通过特定的接口,加载磁盘上的一个权限的程序(代码和数据),加载到调用进程的地址空间中,从而让其执行另一个程序。
进程替换:1.将新的磁盘程序加载到内存 2.和当前进程页表重新建立映射关系
问:进程替换之后,创建了新的进程吗?
答:没有,只是重新建立了映射关系,其他东西都没变的
问:如何理解所谓的“将程序放入到内存中”?
答:就是程序加载到内存,exec*函数本质上就是如何加载程序的函数。
我们使用进程替换的方式就是exec函数,当进程调用一种exec函数时,该进程的用户空间的代码和数据完全被新的程序替换,从程序的启动例程开始执行。调用exec并不创建新的进程,所以调用exec前后该进程的pid不会改变。
2.初识exec
int execl (const char* path,const char* arg,...)
path就是一个字符串,对应文件的路径,传这个参数用来找到路径
path后面的参数就是把命令行上的参数一个一个填进,最后一个参数必须是NULL表示参数传递完毕。
例如:
execl("usr/bin/ls","ls",NULL);
如果我们用的是ls -l 命令
那就得写成:execl("usr/bin/ls","ls","-l",NULL);
同理,如果我们用的是 ls -l -a 命令
那就得写成:execl("usr/bin/ls","ls","-l","-a",NULL);
现在我们有个例子:
int main()
{
printf("bznb\n");
execl("usr/bin/ls","ls",NULL);
printf("bzbnb\n");
return 0;
}
我们会发现,下面的printf函数没有打印处bzbnb,这是为什么呢?(因为bznb)
execl是程序替换,调用该函数成功之后,会将当前进程的所有的代码和数据都进行替换,包括已执行的和没执行的。(所以,一旦调用成功,后续的所有代码,全部都不会执行)
如果execl调用失败(例如路径错误),那后面的代码会接着执行
execl调用失败的话会返回-1,调用成功的话没有返回值(也不需要有,因为我进程都替换了,还要返回值干嘛呢)因此execl不需要进行返回值的判定,可以直接在后面跟个exit(1)就好了,这样如果调用execl失败就会执行exit(1),如果调用成功就不会执行,而是去执行别的程序。
问:进程替换之前,父子进程数据和代码的关系?
答:代码共享,数据写时拷贝。(指向一个物理地址,需要修改的数值映射到别的物理地址上面)
问:当子进程加载新程序的时候,不就是一种“写入”吗?那代码要不要写时拷贝将父子的代码分离呢?
答:必须分离,这页导致了父子进程在代码和数据上面彻底分开了。
3.进程函数
Linux下的进程替换函数主要包括这几种
#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[]);
(1)int execv(const char *path, char *const argv[])
![](https://img-blog.csdnimg.cn/e438109cdeee4e009cd477258dff993c.png)
所谓指针数组,其实就是指向各个字符串的指针。
(2)int execlp(const char *file, const char *arg, ...)
在exec族的函数中,所有函数名带p的函数的第一个参数都是file,意为:
我自己会在环境变量PATH中进程查找,你不用告诉我你要执行的程序的路径
因此我们在使用execlp时,可以这样写:
(3)int execvp(const char *file, char *const argv[])
我们先观察一下该函数的函数名,我们发现他又有v又有p。
其实他就是(1)和(2)的结合体——第一个参数可直接写指令名称,第二个参数传指针数组。
我们使用一下:
(4)int execle(const char *path, const char *arg, ...,char *const envp[]);
观察函数名我们发现,这次多了一个e,观察参数列表。我们发现,后面又多了一个指针数组。
其实,前两个参数我们知道,分别是路径和选项(命令行参数),第三个参数则是环境变量 。
例:
因为没有MYENV环境变量,所以我们打印它的路径就是空指针。
我们另起一个进程,在其中导入一个环境变量MYENV即可。
(5)int execve(const char *path, char *const argv[], char *const envp[]);
execve是系统提供的接口,上面的那些底层全部都会调用这唯一一个接口,他们内部所做的事就是让参数符合这个接口的参数列表,(也就是系统提供的封装,用来满足不同的调用场景)
(6)总结
函数名 | 参数格式 | 是否自带路径 | 是否使用当前环境变量 |
execl | 列表 (有l) | 不是 (没有p) | 是 (没有e) |
execlp | 列表 (有l) | 是 (有p) | 是(没有e) |
execle | 列表 (有l) | 不是(没有p) | 不是(有e) |
execv | 数组(有v) | 不是(没有p) | 是(没有e) |
execvp | 数组(有v) | 是(有p) | 不是(有e) |
execve | 数组(有v) | 不是(没有p) | 不是(有e) |
总结:
第一个参数由是否有p来决定是否要写绝对路径(有p就不用写)
第二个参数由v和l决定,v就传数组,l就一个一个传
第三个参数由是否有e决定,有e就要传环境变量,没有e就用当前的环境变量
五、写一个简易的SHELL(命令行解释器)
我们可以综合上面的知识,写一个简单的SHELL。
首先我们要有SHELL的运行原理:子进程来执行命令,父进程等待子进程执行完成并且解析命令。
0.首先我们需要知道的是SHELL进程肯定是一个常驻的进程——不退出
因此我们把整个程序在while(1)死循环中进行。
1.我们平时在使用shell时,会有提示行,比如这样
所以我们也可以拟写一份这样的提示行
2.我们需要获取键盘上输入的指令和选项
我们讲这些指令和选线视为字符串,那事情就变得简单了。
3.命令行解析
我们使用strtok函数来执行字符串切割的工作:
strtok (char* str,const char* delim)
str:要切割的字符串,第一次需要传原始字符串,后续如果还是对该字符串进行切割操作,只需要传NULL即可。
delim:分割符
5.父进程解析,子进程执行
4.TODO内置命令
如果没有这一部分的内置命令,我们的SHELL功能是不完整的,cd命令如果交由子进程来执行,那子进程切换了路径之后就退出了,没有任何的意义。因此,对于切换路径的命令的需要交给父进程来完成。
在切换路径操作上,我们使用的是chdir函数。
chdir函数是个系统能够调用接口,可以更改当前的工作目录
5.代码完善
我们稍作修饰,就完成了这样一个shell程序:
#include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <string.h>
5
6 char* g_argv[32];
7 char cmd_line[1024];//全局变量字符串,用来包含指令行
8 int main()
9 {
10 while(1)//shell程序是一个不会退出的程序,因此我们把他放在这样的一个死循环中
11 {
12 //1.打印出提示信息,我们要在提示信息后面输入我们的指令。
13 printf("[bz@localhost myshell]#");//注意这里我们没有加\n,是因为我们需要在提示信息后面输入指令
14 fflush(stdout);//由于没有\n,因此我们必须用fflush函数来刷新缓冲区
15 memset(cmd_line,'\0',sizeof(cmd_line));//先把我们要输入的指令字符串给初识化一下
16
17 //2.获取用户键盘输入的指令和选项(例如 ls -l -a)
18 if(fgets(cmd_line,sizeof(cmd_line),stdin) == NULL)
19 {
20 continue;//重新输入(基本不会出错),
21 }
22 //一般我们输入指令之后会回车一下,这个回车需要去掉。
23 cmd_line[strlen(cmd_line)-1]='\0';//strlen是不会算入'\o'的,因此strlen-1一定就是最后一个字符也就是'\n'的下标。
24
25 //3.命令行字符串解析(例如将 ls -l -a 解析成为 "ls" "-l" "-a" 这些命令和选项
26 //我们进行分割输入的字符串,以 空格 为分隔符即可
27 g_argv[0] = strtok(cmd_line," ");
28 int index = 1;
W> 29 while(g_argv[index++]=strtok(NULL," "));
30
31 //4.TODO内置命令
32 if(strcmp(g_argv[0],"cd")==0)//不想让子进程进行,而是想让父进程进行的命令
33 {
34 if(g_argv[1]!=NULL)
35 {
36 chdir(g_argv[1]);
37 }
38 continue;
39 }
40 //这种想让父进程(shell进程)自己执行的命令,我们就叫做内置命令,内建命令,内建命令其实本质就是shell中的一个函数调用
41
42 //5.fork()
43 pid_t id = fork();
44 if(id == -1)
45 {
46 perror("fork");
47 exit(1);
48 }
49 else if(id == 0)
50 {
51 //子进程
52 execvp(g_argv[0],g_argv);//ls -a -l
53 //这里无论什么命令进来,都会执行,但是对于子进程,如果让他执行cd命令这中内置命令,它执行完了之后就退出了,没有意义
E> 54 exit(1)
55 }
56 //父进程
57 int status = 0;
E> 58 pid_t ret = waitpid(id,&status,NULL);
59 if(ret>0)
60 printf("exit cod: %d\n",WEXITSTATUS(status));
61 }
62 return 0;
63 }