1.程序地址空间分配回顾
在前⾯C语⾔以及C部分介绍过⼆者的内存分配如下图所示:
全局变量区和未初始化全局变量区也被称为数据区,数据区中除了有全局变 量,还有静态变量和常量
使⽤下⾯的代码演示不同的内容所处的地址:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
// 初始化的全局变量
int global = 0;
// 未初始化的全局变量
int value;
int main() {
// 环境变量
char* envi = getenv("PWD");
// 堆区
int* ptr = (int*)malloc(sizeof(int)*10);
// 栈区
int a = 0;
int b = 0;
int c = 0;
printf("%p\n", &global);
printf("%p\n", &value);
printf("%p\n", ptr);
// 代码区
printf("%p\n", main); // 函数名即函数地址
printf("%p\n", envi);
printf("%p\n", &a);
printf("%p\n", &b);
printf("%p\n", &c);
return 0;
}
输出结果:
0x601048
0x60104c
0x7e1010
0x4005cd
0x7ffc99757f43
0x7ffc9975694c
0x7ffc99756948
0x7ffc99756944
实际上,所谓的程序地址空间就是进程地址空间,进程地址空间是如何产⽣的就是下 ⾯需要探讨的问题
2.进程地址空间
2.1.虚拟地址
前⾯提到,⽗进程和⼦进程会共享代码和数据,尤其是两个进程不进⾏数据修改时, 数据不会产⽣两份,那么这样理解就可以直观地认为当⼦进程修改了数据,对应的变 量内存地址就会发⽣改变,但是改变的是不是程序读取到的地址,看下⾯例⼦的演示 结果:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
// 初始化的全局变量
int global = 0;
// 未初始化的全局变量
// int value;
int main() {
printf("父进程, pid = %d, &global = %p\n", getpid(), &global);
pid_t p = fork();
if(p == 0) {
// 子进程
while(1) {
printf("子进程, pid = %d, &global = %p\n", getpid(), &global);
// 子进程修改
global++;
sleep(1);
}
} else {
while(1) {
}
}
return 0;
}
输出结果:
父进程, pid = 12969, &global = 0x601050
子进程, pid = 12970, &global = 0x601050
可以看到,尽管⼦进程修改了与⽗进程共享的代码中的变量,⼦进程读取到的变量的 地址与⽗进程读取到的变量的地址是完全相同的。
实际上,在C语⾔程序中使⽤ & 获取到的地址是⼀个虚拟地址(也称线性地址),对 应虚拟地址的就是物理地址。
在上⾯提到的「⼦进程改变代码中的数据,对应的内存地址会发⽣改变」,本质是因 为此处的内存地址指的是物理地址,⽽不是虚拟地址
2.2.地址空间
地址空间,可以理解为是操作系统为每⼀个进程开辟的⼀块运⾏空间,示意图如下:
为了保证所有的进程都有⼀个完整的地址空间,操作系统为每个进程提供⼀个独⽴的 虚拟地址空间。例如,如果操作系统⽀持3GB的虚拟地址空间,每个进程都会认为⾃ ⼰拥有独⽴的3GB空间,不会受到其他进程占⽤内存的影响。这⼀过程暂且可以理解 为操作系统为每⼀个进程“画的⼀张⼤饼”,⽽这个“饼”即为进程地址空间
2.3.区域划分
进程地址空间中存在着多个区域,每⼀个区域有着⾃⼰的作⽤。每⼀块区域的划分实 际上是根据区域的起始值和终⽌值进⾏决定,示意图如下:
为了⽅便管理,在Linux中,操作系统在底层的 task_struct 内部存在着⼀个结构 体指针,该结构体指针的类型是 mm_struct 结构体,该结构体中存在着⼀些变量⽤ 于存储指定区域的起始值和终⽌值。这些值本质也是地址值,但是这个地址并不是实 际的物理内存的地址,⽽是通过映射后的虚拟地址,源码如下:
struct mm_struct {
// ...
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long rss, total_vm, locked_vm;
// ...
};
因为变量的地址都是在这些指定的进程地址空间区域中,所以也可以解释为什么程序 获取到的是虚拟地址⽽⾮物理地址
解释:
3.分页与数据独立
在上⾯探讨了虚拟地址和物理地址,操作系统为了管理这两个地址之间的映射,从⽽ 保证虚拟地址能够正确访问到实际的物理地址对应的内容,在创建⼀个进程PCB时会 同时创建⼀个⻚表。
在执⾏前⾯的代码的过程中,⼦进程未创建之前,⽗进程拥有⾃⼰的⻚表和进程地址 空间,此时全局变量 global 会由操作系统在物理地址上开辟⼀块空间,并将映射后 的虚拟地址填充到⻚表中被进程获取,如下图所示:
当创建⼦进程之后,⼦进程会与⽗进程共享代码和数据,此时意味着⼦进程拷⻉⽗进 程中的 task_struct 和⻚表,对 task_struct 中的内容进⾏针对性地修改,在⼦进 程没有改变复制过来的数据之前,由于⻚表内容相同,所以⼦进程⻚表中的映射与⽗ 进程完全相同,示意图如下:
当⼦进程对 global 变量进⾏修改时,操作系统就会在物理内存中开辟⼀块新的空 间,将共享的 global 数据拷⻉到该空间,这个拷⻉的过程也被称为写时拷⻉。物理 内存虽然开辟了⼀块新的空间,实际上对于⼦进程的⻚表来说,还是通过同样的虚拟 地址映射物理地址:
上⾯的过程也就可以解释为什么最开始的代码中,⼦进程和⽗进程获取到的变量值不 同,但是地址却相同,也就更加验证了⼀个变量中不可能存在两个不同的值
4.页表基本介绍
在Linux中,⻚表不仅仅有虚拟地址和物理地址的映射,还有物理地址的属性 RWX 以 及是否有数据的标识 isExists ,所以更加细致的⻚表应该如下所示:
RWX 属性:代表虚拟地址对应的物理地址是否具有读(R)、写(W)和执⾏权限 (X)。
前⾯提到,每⼀个进程地址空间区域都由指定的起始值和终⽌值进⾏划分,⽽这些区 域有的是可以写,有的不可以写只能读,但是对于物理内存来说,绝⼤部分的空间都 是可以写的,所以对于限制指定的物理地址是否可以写⼊就是通过 RWX 属性进⾏控制
例如,前⾯学习到的栈区和堆区,在程序代码运⾏时,可以在栈区和堆区申请空间并 进⾏写⼊,但是对于字符串常量等具有常性的值就不可以进⾏随意修改和写⼊。⽽之 所以在语⾔层⾯⽆法检测到这种问题,原因就是⻚表并不是在编译期间就创建的,⽽ 是在程序运⾏开始由操作系统创建,但是为了语⾔层⾯更加容易看出这种错误,就可 以把不想被修改的内容或者本身不可以修改的内容修饰为 const ,从⽽让编译器可 以在编译器检查出错误
此刻也便可以解释为什么程序会有野指针的概念,在语⾔层⾯,野指针表示指针中的 地址对应的空间已经被释放并归还给操作系统进⾏管理,此时不可以通过这个指针访 问对应的地址。在此时的系统层⾯,之所以可以限制野指针写⼊就是通过 RWX 属性, 因为虚拟地址对应的物理地址具有的属性已经是R
isExists 属性:代表虚拟地址对应的物理地址是否存在有效数据。在操作系统加载 进程时,会创建对应的进程地址空间和⻚表,在⻚表中将虚拟地址和物理地址进⾏映 射。然⽽,这个过程中某些进程的代码可能特别多,导致正⽂代码区占⽤的空间变得 很⼤,⽽实际使⽤时,可能有很⼤⼀部分的代码⻓时间不会被执⾏,造成资源浪费。 为了解决这个问题,操作系统通常采⽤按需加载(惰性加载,Lazy Loading)策 略。操作系统会先加载⼀部分内容,在运⾏过程中,通过检查⻚表中的有效位 (Valid Bit)来判断虚拟地址访问的物理地址是否已经加载。如果没有加载,则 触发缺⻚中断(Page Fault),操作系统会从磁盘加载相应的数据到内存中,再继 续执⾏。这⼀过程也适⽤于交换分区(Swap Partition),决定何时将数据换⼊ 或换出内存
结合前⾯的两个属性,操作系统就实现先告诉进程⾃⼰已经开辟好了空间,这个空间 的地址由多个虚拟地址组成,但是实际上可能物理地址并没有全部与指定的虚拟地址 对应,当程序运⾏到指定的部分再进⾏开辟映射,这个操作就可以实现将内存空间利 ⽤率最⼤化
5.缺页中断与写时拷贝
前⾯提到,当⼦进程修改了⽗进程的共享变量就会发⽣写时拷⻉,实际上,在这之前 需要进⾏⼀系列的操作检测
当⽗进程开始运⾏,⼦进程还未被创建之前,⽗进程的代码区为只读,但是数据区是 默认为读写的,当⼦进程创建后,代码区依旧是只读,但是⼆者的数据区均变为只 读,⼦进程从创建的位置开始执⾏代码,因为⼦进程会拷⻉ task_struct 中有关程 序计数器部分的内容,此时⽗⼦进程都只是读取对应代码区和数据区的数据,实现数 据和代码共享
注意::!
此处之所以⼦进程不额外直接拷⻉⽗进程的数据,尽管将来可能要对数据进⾏ 修改,是因为可能⽗进程的数据很⼤,但是⼦进程需要修改的数据很⼩,如果在创建 ⼦进程时就将数据拷⻉,此时就会导致空间的浪费
当⼦进程准备修改数据时,因为数据区已经变为只读,⼦进程想要修改就会修改失 败,此时就会触发缺⻚中断错误。但是因为缺⻚中断错误发⽣的原因不只有⼀种,还 有可能是野指针写⼊等情况,所以此时系统需要检测是否是误操作。当系统检测到数 据区本身是读写的,但是现在被设置为只读时,就会认为需要发⽣写时拷⻉
判定完需要进⾏写时拷⻉时,系统就会在物理内存中申请空间,将原始数据拷⻉到新 空间,并对⽗进程和⼦进程的数据区修改为只读,⽗进程和⼦进程代码继续执⾏
这个过程中涉及到原始数据拷⻉⽽不是简单开辟空间使⽤等待⼦进程使⽤新数 据进⾏覆盖是因为可能⼦进程对数据的修改是基于原数据的,例如变量⾃增⾏为等
上述过程简略示意图如下:
⼦进程修改数据::
6.进程地址空间结构初始化时机
任何⼀个结构体在创建时⼀定要进⾏初始化,⽽进程地址空间也是由⼀个结构进⾏描 述,这个结构的初始化由操作系统在可执⾏程序加载到内存时完成,可执⾏程序加载 到内存变成进程时,操作系统可以获取到部分区域的起始值和终⽌值,例如正⽂代码 区、数据区(包括全局变量区、常量区、静态变量区)和命令⾏与环境变量区。所以 这就可以解释为什么静态变量、全局变量和常量⼀直持续到进程结束
但是这其中有两个不同的区域:栈区和堆区,栈区在函数创建时会开辟对应的栈帧, 堆区在申请时会在已有的堆区上额外开辟需要的空间,所以这两个区域在程序刚加载 到内存时是不存在的
7.总结
之所以需要存在⻚表和进程地址空间有以下三个原因:
1. 保护内存:如果让进程直接访问物理内存,会导致在物理层⾯的野指针情况, 并且这个情况在物理层⾯并不容易被发现和阻⽌
2. 存在⻚表可以达到进程管理和内存管理耦合度降低:因为⻚表主要作⽤的是虚 拟地址和物理地址之间的映射,操作系统在创建进程时初始化对应的虚拟地址 即可,但是虚拟地址是否有对应的物理地址可以不⽤关⼼,只要没有被使⽤。 ⽽对于内存管理,操作系统只需要考虑在需要的时候将物理地址加载到⻚表, 此时对应的虚拟地址有映射就可以正常执⾏,在不需要的时候,将物理地址设 置为只读或者给其他进程使⽤
3. 让进程以统⼀的视⻆看待物理内存(⽆序变有序):因为操作系统会为每⼀个 进程开辟⼀个独⽴的⻚表和进程地址空间,让每⼀个进程都认为⾃⼰拥有操作 系统分配的全部内存空间,并且在进程访问地址空间时,实际上这个地址空间 的地址是⼀个虚拟地址,这个虚拟地址可以由操作系统⾃主决定为连续地址, 此时就可以不⽤考虑物理地址是否需要连续,因为进程只能获取到虚拟地址, 只要虚拟地址有物理地址映射并且拥有指定的权限,就不会出现问题,从⽽实 现让「让⽆序的物理内存地址变为有序的地址」
物理地址在开辟时,⼀般也会尽量是连续开辟,保证CPU在缓冲中的数据命中率