浅析 Linux 进程地址空间
说到 地址,C 语言的指针一定是痛苦的回忆,这篇文章也是需要指针功底的,做好准备 ^ ^
有趣的现象
二话不说,咱先看看如下代码:
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
printf("It is a process, pid: %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 0;
while (1)
{
printf("It is child process, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
++cnt;
if (cnt == 5)
{
int tmp = g_val;
g_val = 300;
printf("It is child process, change %d -> %d\n", tmp, g_val);
}
}
}
else
{
// father
while (1)
{
printf("It is father process, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
在 Linux 终端上跑跑看,会有如下现象:
在刚开始, g_val
都是 100,子进程修改后子进程的 g_val
已经变成了 300,但父进程依然没变
这倒也可以理解,毕竟我们之前说过 进程运行的时候,它们之间具有独立性(不是老死不相往来的关系,毕竟有些进程在管理数据上是有交集的:父进程可以拿到子进程、父进程可以杀掉子进程等等;最最主要的是,在正常运行的父子进程之间,其中一个退出是不会影响另一个进程的)
独立性如何体现呢?
之前也说过:进程 = 进程的内核数据结构(task_struct) + 代码和数据
那么首先 内核数据结构 肯定是具有独立性的,毕竟要管理不同进程,不会共用同一个 task_struct
对象,而代码是只读的,不同的进程当然可以读同一份代码呀(例:同时开启多个 QQ);但数据呢?数据是不能混为一谈的,一台机器的 QQ 程序登录不同的账号,必然开启不同的进程,数据也必然是不同的
但上面黄框子框出来的父子进程两个不同的 g_val
却是 相同的地址,怎么可能出现 相同地址的同一块空间存放两份不同的数据呢?
我们现在是可以 断定 :g_val
的地址绝对不是物理地址,这种地址在系统层面被称为 虚拟地址
地址空间和虚拟地址
已经知道当一个程序被加载到内存后即将成为进程时,OS 会为此程序创建一个进程 PCB
节点以管理此进程,但除了要创建 PCB
外还要创建进程对应的 地址空间 ,全名叫做 进程地址空间 ,而每个进程都有自己的 地址空间 ,其内线性的连续的的地址就是 虚拟地址
在 C 语言或 C++ 时学习的内存地址空间其实就是我们上面说的 地址空间 (如下图所示,32位,总 4 G),而这个 并不是真正的物理内存空间
既然不是物理的内存空间,那它在哪?是在 OS 内部,每当为程序开启一个进程,就会为此进程创建一个 地址空间 ,而进程 PCB
和地址空间 都是在物理内存中开辟的空间存放的
既然是虚拟的就不能存放实在的数据,也就是说最终数据还是都要放物理内存里的,既如此就需要将 虚拟地址 映射到真实的物理地址上,映射方式就是使用 页表 映射,每个进程也都有一个自己的页表,而页表接收到虚拟地址就能映射出真实的物理地址
OS 内进程可太多了,而每个进程又都有自己的 地址空间 ,那这些 地址空间 哪个被释放,哪个被修改等等,这些东西都是需要 OS 去管理,如何管理?先描述,再组织
所以 地址空间 本质上就是内核数据结构,会有它自己的结构体对象
页表 也是如此
开篇现象解释
解释
据上面所说,一个程序跑起来变成进程后,此进程会有一个自己独立的 地址空间 ,自己独立的 页表 ,由于程序里对应的地址是 地址空间 里的 虚拟地址 ,而 OS 也就是拿这个 虚拟地址 去查 页表 后得到真实的物理地址进行操作(读取,写入等等)
而开篇现象的代码里 g_val
的初始值为 100,那么 在真实的物理内存中 就存在一块存放整型 100 的空间 R,其地址为 RA
进程启动后,而我们查到 g_val
的 虚拟地址 为 0x601054
,那么 OS 将 0x601054
拿给 页表 一查就能映射出物理地址 RA
当创建子进程后,原进程就是父进程,子进程也一定会有自己的 PCB
、 地址空间 和 页表(这一堆东西都是存储在内存里的内核数据结构)
子进程也会继承父进程的很多属性,除了 pid
和 ppid
外,父进程很多属性甚至都能直接拿来初始化子进程属性,其中就包括地址空间,照搬父进程的内容和父进程保持一致,而页表也是如此,直接会给子进程拷贝一份;所以子进程会把父进程的很多内核数据结构全拷贝一份(除了个别几个属性除外)
那么子进程也就有了和父进程一样的 地址空间 和 页表 ,而 页表 是和指针打交道的,直接拷贝过来后子进程也一定有虚拟地址 0x601054
,也一定可以通过自己和父进程相同的 页表 映射到物理地址 RA
那这是 浅拷贝 ,也就是父子进程是共用地址为 RA 的物理内存空间,里面存放的是 g_val
的值
当子进程要执行更改 g_val
值时,那接下来的操作会是下面这样吗?
子进程要改 g_val
的值,就会拿出 g_val
的虚拟地址交给 OS,经过查询页表,发现是地址为 RA 的物理内存空间,紧接着对地址为 RA 的空间值更改为 300
这样对吗?肯定不对吧 ,如果这样更改,那父进程拿到的值也一定是 300,为啥还是 100 呢?况且运行的进程之间是具有 独立性 的,独享各种资源,多进程之间互不干扰;子进程要修改什么值也不应该影响到父进程
真实的情况是 :
子进程要修改 g_val
之前,OS 发现这个变量不是你子进程一个在用,父进程也在用,那么为了不影响父进程,OS 会提前为子进程在物理内存开辟一块空间,假设地址为 CA ,然后将地址为 RA 的空间里的值(100)拷贝到地址为 CA 的空间里,再将子进程的 页表 里 虚拟地址 为 0x601054
的映射改为地址为 CA 的新空间 ,到此,子进程要修改 g_val
之前的准备工作全部完成,这一切都是 OS 自主完成 ,这些工作被称之为 写时拷贝
接下来再按照传统步骤,经过 地址空间 ,经过 页表 将物理内存地址为 CA 的空间的值改为 300,至此修改 g_val
工作完成
OS 内核虽然开辟了空间,修改了 页表 ,虽然父子进程存放 g_val
的两个物理空间也是不一样的,但上层用户层对 g_val
使用的地址依然是 0x601054
这个 虚拟地址
以上就是我们看到开篇现象的解释
相关问题
- 如果父子进程不修改共用的数据呢?就像上面如果子进程一直不修改
g_val
的值呢?- 未来一个全局变量,默认是父子共享的,代码也是共享的(代码只读属性),如果不修改,父子进程会一直共享同一块
g_val
的物理内存空间
- 未来一个全局变量,默认是父子共享的,代码也是共享的(代码只读属性),如果不修改,父子进程会一直共享同一块
- 为什么要采用 写时拷贝 ,和父进程共享数据时,数据要发生改动还要 写时拷贝 ,怪麻烦,不能直接为子进程开辟单独的数据空间吗?
- 正是因为数据会存在耦合现象,所以咱才要 写时拷贝 ;如果直接将父进程的数据拷贝给子进程一份,页表重新全部修改不说,也肯定会造成 空间浪费 ,因为父进程的很多数据子进程是不会修改的,就比如命令行参数和环境变量,但这些数据的占地面积却不小,势必会发生父进程和子进程在不同的内存空间里读取相同的数据,这不是个管理内存的好方法,因为内存空间本来就不够大
- 所以 写时拷贝 本质上是一种 按需申请,通过调整拷贝的时间顺序,达到有效节省空间的目的
如何理解地址空间
通过上面的图片,可以看到 地址空间 内有很多区域,如何划分这些区域?如何对这些已划分好的区域做调整?很显然,这需要对地址空间进行描述,对地址空间内的区域进行描述
区域描述 肯定是需要地址的开头(start
)和结尾(end
),如此就能表示一段区域,比如栈的区域,堆的区域等等,既如此就需要一个结构体来表示,内有属性 start
和 end
;
而 地址空间 本质上也肯定是内核的一个 struct
结构体,内部很多属性都是 start
,end
表示的范围,从而规定出不同的区域,在 Linux 系统里,这个结构体叫做 mm_struct
但上面的图里,一个进程对应的 地址空间 似乎是 4 G,如果每一个进程都配备一个实际 4 G 空间,我想我这 16 G 的内存早就该换了吧?根本不够用的,所以这 地址空间 一定是 OS 给进程画的 "大饼" ,进程只知道这 4 G 连续的地址空间每一寸它都可以使用,至于操作系统能不能分出这么多空间给这么多进程并不是单一进程要考虑的
为什么要有地址空间
将无序变为有序,让进程以统一的视角看待物理内存以及自己运行的各个区域
试想如果没有 地址空间 ,那也就不需要 页表,而进程要访问内存资源,就会直接记录物理内存的地址
这样做并不是行不通,但进程的状态以及增减不是 OS 可以预料的,也就是说多个进程之间状态变化,如果在再出现增减,那么物理内存的数据存储肯定不是连续的,就算刚开始加载是连续的,但随着个别进程的空间释放,数据肯定是碎片化存储在物理内存上的
就算是一个进程的代码和数据也不一定就是连续存储的,再算上多个进程的数据交叉存储,如果再直接使用物理内存地址,这是一种极大的混乱,存在严重的存储安全问题,比如越界等行为,自己的进程可能会崩溃不说,还可能影响其他进程
有了地址空间 ,无论一个进程的代码和数据分散在物理内存的什么位置(乱序),进程只要关心自己地址空间的虚拟地址即可,因为虚拟地址是有序的,连续的,只有区域大或小的区别
进程管理模块和内存管理模块进行解耦
有了 地址空间 就可以迷惑进程的实际物理内存空间:
- 假如现在一个进程有 4MB 的数据,CPU 已经执行了 2MB 的数据后,如果 OS 发现物理内存的整体空间不够了,那 OS可以直接将闲置空间全部释放掉,那就有可能将这个进程被执行过的 2MB 代码唤出到外设,甚至直接释放;而地址空间里却可以不变,依然是 4MB 的数据,但实际上内存只有这个进程 2MB 的数据了
- 再比如现在进程申请了一块物理内存空间,但 OS 不一定立马就给你开辟,不建立页表映射,会一直到你需要使用时才会为你开辟需要的空间;如果立马给你开辟,那你不使用的这段时间就相当于拿着这块空间什么都不做,但如果给 OS ,它可能会使用这块空间做很多事情,内存的使用率就会非常高
所以对进程来说,只需要知道它的地址空间可以申请就够了,其他的交给 OS 安排
拦截非法请求
比如说现在你的写的程序变成进程跑在 OS 上,此进程越界访问了一块不属于自己的空间,那接下来 OS 就会拿到这块非法地址去查 页表 ,这时候由于 页表 内根本没有这一条 虚拟地址 的映射,OS 就会直接对你的访问进行拦截,对物理内存进行保护
粗浅理解页表和写时拷贝
页表
页表并不简单,查页表使得虚拟到物理的转换是由 CPU 内的 MMU 单元和 CR3 寄存器实现的,这种寄存器会保存当前进程的页表虚拟地址,而 MMU 会将虚拟地址结合页表快速的转化为物理地址
而页表也会有自己的 标记位 ,例如:指定的物理内存是否在内存中、指定单元是否具有 rwx
权限问题等等
进程挂起就可以利用这个辅以解释:当内存资源严重不足时会将部分进程的部分代码数据 唤出 外设磁盘的 swap
分区中,此时这个进程为挂起状态;
只要把页表的指定的物理内存是否在内存中的标记位标为 0,保留 虚拟地址 且具有 rwx
权限向进程表明地址存在且可以访问,只不过不在内存里,表明 该代码数据被 唤出 到外设中了
回忆 C 语言里 char* str = "Hello World!";
代码,字符串 "Hello World!"
究竟被存放在哪?或者是接下来添一句 *str = "H";
能编译成功吗?
字符串 "Hello World!"
是在字符常量区的,是不能被修改的,添一句 *str = "H";
会报错,那为什么不能被修改?
原因是每一个区都是经过页表映射,页表具有权限管理的 标记位,而你的单元只读那就只能只读,不能修改写入等等,写入操作会被直接拦截,保护了物理内存根本就没有被访问;一旦你可以进行写入了,就说明此操作就是合法的
写时拷贝
OS 层面如何支持 写时拷贝 呢?
以开篇现象为例:本来父进程的页表对于 g_val
的权限是 rw
的,父进程一旦创建了子进程,OS 就会修改父子进程页表中对该变量(g_val
)的权限,修改为 r
,一旦父子进程有一个尝试写入时,系统会直接识别到错误,那么 OS 判断出错的原因:
- 是不是数据不在物理内存:触发 缺页中断
- 是不是数据需要写时拷贝:触发 写时拷贝
- 不是以上情况,进行异常处理
而内存当中也会有 引用计数 来说明 g_val
被几个进程使用着,如果大于 1,就说明有别的进程也在用,触发 写时拷贝
粗浅认知虚拟地址
现在我们知道进程使用的地址都是 地址空间 里的 虚拟地址 ;在进程刚开始时,页表 需要将进程的 虚拟地址 和物理地址构建映射,那请问 页表 的 虚拟地址 从哪里来呢?它怎么知道进程要使用什么地址呢?
当程序被编译为二进程可执行文件后,文件内还存在各种各样的函数名变量名吗?是不存在的,都被替换为了地址!!!所以 程序本身就有地址,这个地址就是 虚拟地址(逻辑地址)
所以 页表 里的 虚拟地址 直接从可执行程序读过来即可