hello,各位小伙伴,本篇文章跟大家一起学习《Linux:地址空间与进程控制》,感谢大家对我上一篇的支持,如有什么问题,还请多多指教 !
如果本篇文章对你有帮助,还请各位点点赞!!!
话不多说,开始正题:
文章目录
- 续上文章[《Linux:进程概念、进程状态、进程切换、进程调度、命令行参数、环境变量,进程地址空间》](https://blog.csdn.net/2301_80153885/article/details/142438914?fromshare=blogdetail&sharetype=blogdetail&sharerId=142438914&sharerefer=PC&sharesource=2301_80153885&sharefrom=from_link)谈页表
- 虚拟地址结构(mm_struct)的初始化
- 为什么要有虚拟内存?
- 怎么维护虚拟内存和页表?
- 进程控制
- 进程创建
- 进程终止
- 进程等待
- 进程替换
- 认识全部接口
续上文章《Linux:进程概念、进程状态、进程切换、进程调度、命令行参数、环境变量,进程地址空间》谈页表
在页表中有好几个标志位,这里讲两个:其中一个是权限标记位rwx
有关,另一个是是否存在isexits
那么比如说我将代码区的代码通过页表映射到物理地址,我可以将这些代码设置为只读r
,又比如说将数据区中的gval
设置为rw
可读可写
当我通过虚拟地址对gval
进行修改,OS
会通过该虚拟地址查页表,发现存在不会立马直接转换为物理地址,OS
知道你这个动作是写入操作,所以会查看是否符合权限,发现有写入权限才会将物理地址给予上层访问,若没有写入权限,则访问失败
就好比代码中:
char* str = "asdfg";
*str = "bbb";
程序会崩掉,其实是进程被杀了,因为常量字符串在常量区,只能读不能写
既然如此,为什么编译不报错呢?那是因为这跟编译器没关系,这个问题只有在程序跑起来的时候系统发现问题,正因为编译器发现不了,所以引入了关键字const
来协助我们写程序
isexit
分批加载、挂起等的作用
在将磁盘中的数据加载到内存中时,是先加载数据结构,再加载磁盘中的数据的,假设我这个程序有1个G,如果我要将这个程序全部加载到内存里跑完要花10秒,其中有1秒钟的时间后半部分的代码并不执行,但是却还在内存中,这不就浪费空间了吗?又比如说:前半部分的代码已经跑完了,后面大概率也不会再跑一遍,那还占着内存,这不也是浪费空间吗?假设该进程被阻塞,就算已经加载进内存,OS
也不会调度,如果此时内存紧张,那么OS
就可能会把这段内存与磁盘进行交换。
isexits
说白了就是检查目标内容是否在内存中,在就直接返回上层访问,不在就只有两种情况,要么就是没有加载到内存中,要么就是被切换了,OS
会帮你从磁盘中把该数据换回物理内存来
为什么会没有加载呢?其实在跑一个进程的时候并不是将所有的数据都加载到内存当中,OS
只是简单建立起虚拟地址就不管了,当程序要进行访问的时候,发现isexits
标志位为false
,那么OS
就会帮你加载到内存当中,这就是分批加载
,节省空间的同时不影响程序运行
举个例子:就比如说,打游戏的时候,明明这个游戏几十GB,但是在运行的时候只有几GB,因为剩下的并没有用上,这就是分批加载
虚拟地址结构(mm_struct)的初始化
我们知道mm_struct是数据结构,所以要初始化,那么怎么初始化呢?数据是怎么来的?
我们通过readelf
可以查看到:
可执行程序编译的时候,各个区域的大小信息已经有了,所以初始化的数据就是从可执行程序来的!!!说白了就是在磁盘中就已经记录好各个区域的大小信息
可执行程序:
- 分段:已经帮我们分好段了,数据区、代码区、常量区……
- 包含属性:哪里到哪里是属于数据区……
也就是说:操作系统(进程管理)、编译原理、编译器、可执行程序也有关系,操作系统要从已经编译好的可执行程序中得到相关的信息(数据区的大小、可执行程序的分段、属性……)用来构建页表……等等
那么栈区、堆区这些是哪里来的?是操作系统自己动态开辟的
操作系统自己创建的空间:栈、堆
对于栈,只有程序调用函数的时候会使用寄存器来开辟栈空间
对于堆,我们自己malloc、new
的空间其实只是扩展了虚拟地址空间,并没有真正的在物理地址申请空间,只有在真正使用的时候系统才会申请空间,毕竟我们申请了空间通常并不会立即使用
为什么要有虚拟内存?
- 保护物理内存:
想想要是我们用指针随便指向一个地址直接访问,这不就是野指针了吗?有了虚拟内存和页表,操作系统就会检测到你这指针指向的虚拟地址不存在,就会发生拦截,相比于直接访问物理内存多了一层软件层的保护
所以什么是野指针?为什么野指针会发生崩溃?
其实就是指向的虚拟地址不对导致权限不对,又或者是指向的虚拟地址不存在,操作系统就会杀掉进程
-
进程管理和内存管理在系统层面上进行解耦合:
进程管理task_struct
只需要在虚拟内存申请空间,并不需要直接向物理内存申请,而且申请空间之后并不一定要立即向物理内存申请空间,只有当需要用到的时候才申请,这就有了延迟性;而物理内存并不需要知道操作系统要干什么,只需要分配内存空间就行了,所以使得进程管理和内存管理在系统层面上进行解耦合 -
让进程以统一的视角看到内存:
经过对虚拟内存和对页表的讲解,问问大家对于磁盘中可执行程序的代码和数据加载到内存当中时,是任意加载还是加载到特定地方?答案是:任意加载。因为我们已经有页表来对虚拟内存和物理内存之间的映射,不管物理内存是怎样的乱,在虚拟内存看来就是
都会使得“无序”变“有序”,只不过是区域大小不一样罢了
怎么维护虚拟内存和页表?
其实只需要维护好进程就可以了,虚拟内存地址空间的数据结构是mm_struct
依附于task_struct
,所有内容都是OS
亲自完成
全局变量和字符常量为什么会有全局性?是因为全局变量和字符常量存在于数据区,一直伴随着进程,所以进程在,全局变量和字符常量就在,不像堆区和栈区随时可以释放,也就是说全局变量一直可以被看到
进程控制
进程创建
我们知道fork
创建子进程,子进程会拷贝父进程的代码和数据(浅拷贝),并且在修改内容之前父子进程会指向同一份物理地址,也就是说代码和数据是共享的,但是一旦发生修改,系统就会对其进行写时拷贝
那为什么系统不直接拷贝一份而是搞一个写时拷贝?原因很简单,对于一个父进程10M大小,而他的子进程要发生改变的数据可能只有1M,如果全部重新拷贝一份,不就妥妥的浪费空间吗?
那么问题来了,系统怎么知道要发生写时拷贝?
其实在fork
之前,会更新权限位,将代码和数据权限全部改为只读,所以当要发生写入修改,系统就会检测到你正在对只读数据进行写入,触发系统错误!!
对于系统错误有很多种情况,那是什么情况?这个时候就会触发缺页中断!就相当于一种系统检测,来判定要发生写时拷贝,对于这个判定后续文章有关内存会讲解,比较复杂,在这不多说明
确定是写时拷贝后就会申请内存,发生拷贝,修改页表,恢复父子进程的读写权限,然后进行原先的写入操作
进程终止
在主程序中我们有main
函数,而main
函数的返回值是给谁返回呢?
答案:父进程或者是系统,这里说系统是为了强调父进程有可能是系统某个角色来承担父进程,比如说bash
进程
通常在shell
使用echo $?
来查看最近一个进程的退出码,如例子:
#include<stdio.h>
int main()
{
printf("hello\n");
return 10;
}
为什么第二次echo $?
结果是0?别忘了echo
也是一个进程,当你使用echo $?
之后,最近的进程的退出码当然就是echo $?
的退出码。
退出码的意义就是指明错误的原因,举个例子:
当我们安装一个app的时候,如果安装失败,那么系统就会回滚操作,将原先已经下载了的、安装了的全部清除,系统怎么知道安装失败了呢?这个时候就需要退出码这些相关信息来告诉系统。一般来讲都是用0
来表示成功,非0
来表示错误,也就是用不同的数字来表示不同的原因。所以,都会提前约定相关的数字来表示错误码。
系统提供的错误码,想必大家都见过这三个有关错误信息:
strerror
errno
perror
Linux
有134个错误码,可以用以下程序看看:
指令:
g++ -o del del.cc -std=c++11
这里我用的是C++11
!后续也是!!
#include <errno.h>
#include <iostream>
#include <string.h>
int main()
{
for(int i = 0; i < 200; i++)
{
std::cout << "code" << i << ": " << strerror(i) << std::endl;
}
return 0;
}
从0 ~ 133
都是系统已经约定好的错误码信息,后面的全是未知错误
当然了,并不是一定要使用系统给出的退出码,比如说在Linux中杀掉一个不存在的进程:
kill -9 888888
此时的退出码是1
,但是我们根据上述查到退出码是1
时,应该输出:
而在这里却是:No such process
那是因为退出码自己定也是可以的,如果跟系统强相关确实可以用系统的退出码,但是不相关用来干嘛呢。
进程终止的方式:
-
main
函数返回return
-
exit
在代码的任何地方,表示正常进程结束
里面的参数status
其实就是退出码,这是你设定的退出码或者使用系统提供的退出码,是整个进程直接退出,和return
不一样的就是return
表示的是函数的退出,函数结束后会继续执行后续代码。 -
_exit
和exit有点像:
参数也是一样,只不过头文件不一样,这是系统提供的函数
exit
和_exit
的不同:
要注意这个时候hello
后面是带着\n
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <unistd.h>
int main()
{
printf("hello\n");
sleep(2);
exit(20);
sleep(2);
return 0;
}
当你运行这个程序的时候,会发现先输出hello,停顿两秒,然后进程结束。
现在把hello
后面是带着\n
去掉:
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <unistd.h>
int main()
{
printf("hello");
sleep(2);
exit(20);
sleep(2);
return 0;
}
运行这个程序的时候,会发现先停顿两秒,再输出hello,然后进程结束。
也就是说exit
会把缓冲区的数据做刷新!!!
但是这时候把exit
换成_exit
,你就会发现hello不出现了,也就是缓冲区的数据没了!!
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <unistd.h>
int main()
{
printf("hello");
sleep(2);
_exit(20);
sleep(2);
return 0;
}
所以此时若需要把缓冲区的数据做刷新,就可以在hello后面加上\n
来刷新。
exit
和_exit
的区别:刷新缓冲区的问题!!
从系统角度上来理解:
exit
在标准库里面,_exit
在系统调用接口里,其实exit
就是调用了_exit
,只不过进行了封装
我们上述的缓冲区在哪里?其实是语言级缓冲区,也就是和glibc
在同一层,若此时用系统调用接口_exit
,那么就无法对缓冲区进行刷新,会直接跳转到操作系统,用语言层exit
为什么就会刷新呢?要知道exit
可是进行过封装的,所以也许会加入fflush
这类函数对缓冲区进行刷新。
进程等待
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <string.h>
int main()
{
int id = fork();
if(id < 0)
{
printf("error: %d, strerror: %s\n", errno, strerror(errno));
}
else if(id == 0)
{
int cnt = 5;
while(cnt--)
{
printf("子进程正在运行: pid = %d\n", getpid());
sleep(1);
}
exit(0);
}
else
{
while(1)
{
printf("父进程正在运行: pid = %d\n", getpid());
sleep(1);
}
}
return 0;
}
上述代码让子进程运行5秒之后直接exit(0)
退出,根据查看进程发现,子进程变成了僵尸状态等待父进程对其进行回收,等待的时候,子进程不退出父进程就会阻塞在wait
队列当中
wait
的返回值pid_t
是所等待的子进程的pid
,等待失败返回值就小于0,对于参数int *wstatus
后续讲waitpid
再讲
wait
作用就是等待任意一个子进程,我们用wait
对子进程进行等待:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int id = fork();
if(id < 0)
{
printf("error: %d, strerror: %s\n", errno, strerror(errno));
}
else if(id == 0)
{
int cnt = 5;
while(cnt--)
{
printf("子进程正在运行: pid = %d\n", getpid());
sleep(1);
}
exit(0);
}
else
{
pid_t rid = wait(nullptr);
if(rid > 0)
{
printf("等待子进程成功, rid: %d\n", rid);
}
while(1)
{
printf("父进程正在运行: pid = %d\n", getpid());
sleep(1);
}
}
return 0;
}
发现子进程的僵尸状态不见了,那是因为父进程对其进行了回收,返回值就是子进程的pid
对于我们回收了子进程,那么是不是想要知道子进程完成任务完成的怎么样?所以为了支撑,我们使用更多的调用是waitpid
:
pid_t waitpid(pid_t pid, int *wstatus, int options);
这里的参数pid
如果>0,就是等待指定一个子进程;要是=-1,就是等待任意一个子进程
要是想要waitpid
来替换掉上述的wait
,只需要更换成:
waitpid(-1, nullptr, 0);
也可以等待指定子进程:
waitpid(id, nullptr, 0);
等待失败也会有相应的错误码和错误信息,举个例子:
让子进程退出5秒后,父进程再进行回收waitpid
,回收的进程id是id + 1
故意出错
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int id = fork();
if(id < 0)
{
printf("error: %d, strerror: %s\n", errno, strerror(errno));
}
else if(id == 0)
{
int cnt = 5;
while(cnt--)
{
printf("子进程正在运行: pid = %d\n", getpid());
sleep(1);
}
exit(0);
}
else
{
//pid_t rid = wait(nullptr);
sleep(10);
pid_t rid = waitpid(id + 1, nullptr, 0);
if(rid > 0)
{
printf("等待子进程成功, rid: %d\n", rid);
}
else
{
perror("waitpid");
}
while(1)
{
printf("父进程正在运行: pid = %d\n", getpid());
sleep(1);
}
}
return 0;
}
如上说明了waitpid
能够回收子进程,那么怎么获取子进程的信息呢?
就是第二个参数int *wstatus
,如下述代码:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int id = fork();
if(id < 0)
{
printf("error: %d, strerror: %s\n", errno, strerror(errno));
}
else if(id == 0)
{
int cnt = 5;
while(cnt--)
{
printf("子进程正在运行: pid = %d\n", getpid());
sleep(1);
}
exit(1);
}
else
{
//pid_t rid = wait(nullptr);
int status = 0;
sleep(10);
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("等待子进程成功, rid: %d, status: %d\n", rid, status);
}
else
{
perror("waitpid");
}
while(1)
{
printf("父进程正在运行: pid = %d\n", getpid());
sleep(1);
}
}
return 0;
}
从代码上来讲,我将子进程的退出码设置为1
,也就是exit(1)
,那么从我们的角度,status
的值应该就是1
:
不对,打印出来的结果是256
,问题在于我们设置的exit
表示的是进程正常退出,但是进程不一定是正常退出的啊,所以status
并不仅仅包含了退出码,其实status
是一个32比特的位图,我们只关心低16位:
从上图可知退出码(退出状态)在第8到15比特位,所以要想拿到退出码,只需要status
右移8位,然后按位与0xFF
:
(status>>8)&0xFF
也许会好奇我们能不能使用全局变量来获取退出码?显然是不能的,进程是具有独立性的,即使是父子关系,也不行,所以我们只能够使用系统调用来获取退出码,让操作系统来将退出码给status
让我们获取
我们自己写的代码,自己约定退出码,这没有问题,问题在于如果进程异常退出!
举个例子:
int a = 1 / 0;
这肯定是异常退出:
此时的退出码是没有意义的,因为程序时异常退出的!!!
出现异常不用担心,操作系统会帮助我们把异常进程给杀掉,会通过发送信号杀掉进程
被信号所杀,低7位就是终止信号,status&0x7F
:
看到终止信号是8,8号信号是:
符合1/0
的报错信息,当然了,获取退出码和退出信息,系统给我们提供了宏:
if(rid > 0)
{
if(WIFEXITED(status))
printf("等待子进程成功, rid: %d, status code: %d\n", rid, WEXITSTATUS(status));
else
printf("等待子进程成功, 子进程异常退出: exit signal: %d\n",status&0x7F);
}
option:阻塞等待、非阻塞等待:
一般默认option
为0,阻塞等待,是很可靠的等待方法,编码上没难度
WNOHANG
:非阻塞等待,其实就是一个宏,一个整数
举个实例:你打电话给小明,让他上号打游戏,问他上号没有,小明说还没有就把电话挂了,过了一会你又打电话过去问,小明还是说没有,又挂电话了,每隔一段时间你就循环往复问,直到小明上号,这就是非阻塞等待,由你自己循环调用非阻塞接口轮询检测,在等待期间你可以先刷会抖音,和朋友聊聊天……,也就是父进程不会因为此进程就不做其他事情,可以让父进程做更多自己的事;但是如果你打电话给小明,让他上号,不挂电话一直等到小明上号,这就是阻塞等待。
进程替换
先来看看接口:
就后面的...
是可变参数列表,先讲讲excel
:
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main()
{
execl("/bin/ls", "ls", "-l", "-a", nullptr);
return 0;
}
他第一个参数不是要路径嘛,我就给他路径,可以看到后续所需写的其实就是ls -l -a
,然后再以nullptr
结尾,运行程序可以发现就是实现了系统的命令ls -l -a
,不过还是有点不一样,没有带光标
在我们所写的代码中并没有实现ls -l -a
的代码,也就是说是“偷取”操作系统的代码实现的,其实就是替换成ls -l -a
进程
再来调用top
命令也是可以的(一定要以nullptr
结尾):
execl("/usr/bin/top", "top", nullptr);
这种特性就是进程的程序替换!
execl
的原理:当我们一开始运行程序时,会形成一个进程,会有PCB、页表、物理内存、磁盘的交互
,当执行到execl
的时候,哪个进程调用execl
,哪个进程中的代码和数据就会被磁盘中execl
指定路径下的可执行程序的代码和数据覆盖
所以进程替换是创建新的进程吗?
不是,从上述我所讲,只不过将物理内存的代码和数据进行替换,修改页表映射,PCB
是没有变化的
那进程替换是什么呢?接着往下看
execl
第一个参数带路径的可执行程序,表示的是你要执行谁
后续的可变参数根据上述代码可以得知:你命令行怎么写的,你就怎么写,最重要的是要记得以nullptr
结尾 ——> 你想怎么执行
execl
不仅仅可以替换成系统的命令,还可以替换成我们自己的可执行程序
证明并没有创建新进程:
myexec.cc:
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main()
{
printf("我是myexec, pid: %d\n", getpid());
execl("./other", "other", nullptr);
return 0;
}
other.c:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("我是ohter, pid: %d\n", getpid());
return 0;
}
将other.c
转化为可执行程序后,执行myexec
,如果真的没有创建新的进程,那么打印出来的pid
将会是一样的:
符合猜想!
下一个问题:execl
的返回值?上述所说会将可执行程序覆盖掉原先的代码和数据,那么返回值还有吗?如下:
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main()
{
printf("我是myexec, pid: %d\n", getpid());
int val = execl("/bin/ls", "ls", "--color", "-l", "-a", nullptr);
printf("return val: %d\n", val);
return 0;
}
并没有!要是我故意写错呢?
int val = execl("/bin/lssss", "lsssss", "--color", "-l", "-a", nullptr);
出现了,execl
的返回值,是-1
- 显然
execl
成功是不会有返回值的,因为代码和数据完全被覆盖了 - 反之,只要
execl
有返回值,说明失败了
由于代码和数据完全被覆盖了,所以我们一般创建子进程来进行程序替换:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("我是myexec, pid: %d\n", getpid());
int id = fork();
if(id < 0)
{
perror("fork");
}
else if(id == 0)
{
execl("/bin/ls", "ls", "--color", "-l", "-a", nullptr);
exit(1);
}
int rid = waitpid(id, nullptr, 0);
if(rid > 0)
{
printf("等待子进程成功\n");
}
else
{
perror("waitpid");
}
return 0;
}
没什么难度就不多说了,在Linux当中运行程序其实就是不断的创建子进程来执行可执行程序,创建进程得首先有代码和数据结构,这不就是fork
出子进程吗?要是我们不断的从外面读取数据,并循环调用fork
创建子进程来执行可执行程序,不就相当于命令行解释器了吗?当然命令行解释器并没有那么简单,但是原理基本上就是这样。
一般fork
之后的子进程指向的都是父进程所指向的空间,所以一旦execl
就相当于写入数据和代码,就会发生写时拷贝,代码也一样!进程就彻底独立了!
认识全部接口
先讲
int execv(const char *pathname, char *const argv[]);
第一个参数和execl
一样你想执行谁,后面的参数是一个字符数组指针,表示的是你想怎么执行,其实就是把execl
的可变参数全部写入一个数组
你可以这么理解execl
的l
是链表list
,要以nullptr
结尾;execv
的v
是数组vector
,最后一个元素是nullptr
直接看代码:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("我是myexec, pid: %d\n", getpid());
int id = fork();
if(id < 0)
{
perror("fork");
}
else if(id == 0)
{
//execl("/bin/ls", "ls", "--color", "-l", "-a", nullptr);
char *const str[] = {"ls", "--color", "-l", "-a", nullptr};
execv("/bin/ls", str);
exit(1);
}
int rid = waitpid(id, nullptr, 0);
if(rid > 0)
{
printf("等待子进程成功\n");
}
else
{
perror("waitpid");
}
return 0;
}
当然,由于str
里面是字符串常量,会报警告,也可以将里面的常量强转为char*
,如:
(char*)ls
这个时候难免会想到:
int main(int argc, char *argv[])
{...}
所以联想一下,是谁传递的参数?是命令行fork
出execv
,将ls、-l、-a、nullptr
构建成一张表(数组),找到路径下(第一个参数/bin/ls
)的main
函数,通过execv
将那张表传递main
函数
在exec*
中带p
的就是:你想运行谁,不要求带路径:
也就是说直接这样写即可:
execlp("ls", "ls", "-l", "--color", "-a", nullptr);
有人就会好奇两个ls
不会重复吗?
当然不会,这两个ls
表示的意思都不一样呀,第一个表示的是你想执行谁;第二个表示的是你想要怎么执行。
所以来解释一下为什么不用带路径:
不卖关子:原因就是环境变量PATH,execl
这些不带p
的就是不会向环境变量中去寻找,而execlp
就会带着第一个参数去环境变变量中寻找,环境变量是从左到右寻找的,如果有相同的环境变量,会直接找第一个,因为已经找到了,所以p
就是PATH
execvp
就不用我多说了吧,跳过
下一个
这种带e
的,其实就是环境变量,多了的第三个参数就是让我传环境变量,那要是我不传,发生程序替换会拿到环境变量吗?
当然可以,环境变量可是有全局属性,只不过需要extern char** environ
的方式来获取罢了
那么要是我传环境变量呢?
其实很好理解,就会使用你传的环境变量
关于环境变量:
- 子进程会继承父进程的环境变量
- 如果要传递全新的环境变量(自己定义,自己传递)
- 新增环境变量?如下:
可以使用putenv
这个函数:
putenv("新的环境变量名");
谁调用putenv
,谁就会新增环境变量
补充:
所以这么多的接口其实就只有传参方式的差别,为了满足不同的情况而诞生。
你学会了吗?
好啦,本章对于《Linux:地址空间(续)与进程控制》的学习就先到这里,如果有什么问题,还请指教指教,希望本篇文章能够对你有所帮助,我们下一篇见!!!
如你喜欢,点点赞就是对我的支持,感谢感谢!!!