目录
一. 进程空间的布局
二. 进程地址空间
2.1 早期CPU访问物理内存的方式
2.2 什么是虚拟地址(进程地址空间)
2.3 操作系统对地址空间的管理方法
三. 地址空间存在的意义
四. 总结
一. 进程空间的布局
在语言层面学习C/C++时,根据变量/对象类型的不同,我们画出了如图1.1的空间布局图,从高地址到低地址,每个区域所代表的意义为:内核空间(用户无法使用)、命令行参数和环境变量区、栈区、堆区、静态区(包括未初始化全局数据区和已初始化全局数据区)和全局代码区(包括代码和只读常量)。
提问:图1.1所示的空间布局图,是真正的物理内存布局吗?
答案可能出乎意料,其实不是的。我们写了代码1.1来进行验证,在全局定义变量int g_val = 10,然后使用fork创建父子进程,在子进程和父进程中打印g_val的地址和值,在子进程中修改g_val的值为20,运行代码,我们发现,父子进程的g_val地址相同,但是值不同。为此,我们可以判定:父子进程中的g_val虽然在代码中看到相同的地址,但是,他们实际被保存在不同的物理内存地址。
结论:Linux下使用的是虚拟地址而不是物理地址,虚拟地址需要转换为适当的物理地址才能被正确使用。
代码1.1:验证OS是否直接使用物理内存
#include<iostream>
#include<unistd.h>
int g_val = 10;
int main()
{
pid_t ret = fork();
if(ret < 0) //子进程创建失败
{
perror("fork");
return 1;
}
else if(ret == 0) //子进程
{
g_val = 20;
while(1)
{
printf("child process, &g_val:%p, g_val:%d\n", &g_val, g_val);
sleep(1);
}
}
else //父进程代码
{
while(1)
{
printf("child process, &g_val:%p, g_val:%d\n", &g_val, g_val);
sleep(1);
}
}
}
造成这种现象的原因是,现代的OS均不是采用直接访问物理内存的方式,而是给每个进程都分配了地址空间,通过映射,将虚拟地址对应到物理地址,从而实现对物理地址的访问。
二. 进程地址空间
2.1 早期CPU访问物理内存的方式
在早期,CPU直接通过实际内存的物理地址,访问物理内存空间,OS会为每个进程都在内存中分配一块实际的内存空间,CPU拿到的指令和数据的地址,也直接就是物理内存的地址。
这样直接访问内存的方式,存在一个严重的安全隐患:
- 我们可以在一个进程中,通过定义指针,越界访问其他的进程,这样就很容易对其他进程进行修改,或者拿走其他进程的数据,具有很大的安全隐患。
为此,现代操作系统采用了 进程地址空间(虚拟地址) + 页表映射的方法,来间接访问物理内存。
2.2 什么是虚拟地址(进程地址空间)
如图2.2所示,OS会为每个进程分配一块虚拟地址空间,这块虚拟地址空间的分布遵循图1.1所示的分布规律,这就好像每个进程独占内存空间。但是,地址空间终究不是实际的物理内存地址,CPU在拿到虚拟地址,需要通过一定的映射关系将虚拟地址转换为实际的物理地址,然后CPU根据转换的结果去访问物理内存中的数据和代码。
虚拟内存到物理内存的转换是通过页表来实现的,页表中类似于哈希表结构,存有进程地址(虚拟地址)和物理内存的对应关系,当然页表中也存有内存的访问权限相关信息(可、可写、可执行)。
根据以上的分析结论,我们就不难理解,为什么代码1.1中,父子进程的g_val打印出不同的值,但却有相同的地址。因为我们通过&g_val看到的仅仅是OS分配给当前进程的地址空间中的虚拟地址,并不是实际的内存地址。父进程和子进程有各自的页表,通过页表将各自的&g_val进行映射转换之后,我们就能得到不同的实际地址。
结论:父子进程的g_val只是虚拟地址相同,实际存储在物理内存中的地址不同。
2.3 操作系统对地址空间的管理方法
(1)如何理解区域划分?
区域划分,类似于小时候一张桌子和同桌画的38线,它规定了每个人所享有的空间范围,一般采用[start, end]来表示区域划分。如图2.3所示,假设一张桌子厂100cm,A同学的范围为[0,50cm],B同学为[51cm, 100cm],这就是一种简单地区域划分方法。图2.2右侧展示了一种可以用于进行边界管理的结构体类型。
根据图1.1所示的地址空间结构,我们认为每一块区域都会被特定的[start, end]来限制,当区域的范围发生变化时,通过更正start/end的值,就可以实现区域范围的更新。
(2)地址空间的管理方法
地址空间,本质上也是一种操作系统内核的数据结构,它里面至少包括每个区域之间的划分(见代码2.1)。同时,为了保证每个进程之间的独立性,OS会为每一个进程都分配一份地址空间和一张页表,如果我们企图访问非法的地址,那么在页表映射的时候,就会将我们的非法访问请求拦截下来,这样就无法在一个进程中访问另一个进行,保证了进程的独立性和安全性。
代码2.1:地址空间的内核数据结构
struct mm_struct
{
int code_start;
int code_end;
int init_start;
int init_end;
int heap_start;
int heap_end;
int stack_start;
int stack_end;
... ...
//其他属性信息
}
我们知道,操作系统会对给每个进程创建一个进程控制块PCB,PCB中会记录对应进程的各种属性信息,其中就包括指向管理地址空间的结构体mm_struct的指针,图2.4展示了这种管理方式。
(3)虚拟地址的生成
提问:编译器生成的可执行程序中,是否包括地址相关的信息?
答案是包括,可执行程序中包含每条指令的指令的虚拟地址,它们由编译器赋予。实现地址空间(虚拟地址)通过页表与物理地址的映射,并不是单纯地操作系统的工作,而是需要操作系统和编译器协同工作来实现。
如图2.5所示,我们编写的函数中有3条指令,编译器给它们的虚拟地址分别为0x10,0x100和0x200,这些虚拟地址的信息会被包含在可执行程序内,我们通常说的程序运行前要现将代码加载到内存中去,可包括要将编译器赋予每条指令的虚拟地址。
如果我们运行可执行程序,就要为它生成页表,页表中的虚拟地址填的是编译器生成的虚拟地址,通过数据和代码实际在物理内存中存放的位置,填写页表中与虚拟地址对应的物理地址。
我们假设0x10、0x100和0x200对应的物理地址分别为0xA、0xAA和0xAAA,CPU首先拿到虚拟地址0x10,通过页表映射找到0x10对应的物理地址0xA,在0xA位置不但存有需要运行的代码,还包括下一条指令的地址(虚拟地址),CPU下一条指令的虚拟地址,通过页表映射再找到物理内存的地址,从而执行了下一条指令。
三. 地址空间存在的意义
1. 保护内存,杜绝非法访问
CPU拿到的内存是虚拟内存,要通过页表,找到虚拟内存实际对应的物理内存,从而访问到实际需要访问的数据。如果用户企图非法访问,那么就会在页表映射这一阶段被拦截,操作系统就会将这个进程杀掉,以包含物理内存中的数据不会被污染。
将地址空间和页表都置于OS的监管之下,安全性大大增强。
2. 实现进程管理和内存管理之间的解耦合
通过页表映射,我们就可以使用物理内存的任何区域来存储进程中的相关数据。这样,我们在管理进程的时候,只需要管理好PCB、地址空间、页表,而不需要再考虑对物理内存的管理。
这样,进程管理和内存管理就实现了解耦合。软件工程的一大原则就是高内聚、低耦合,这样做遵循了这一原则。
3. 提高物理内存资源的利用效率
当我们使用malloc、new申请内存动态内存空间时,如果直接在物理内存上申请,那么如果申请完空间后不立马使用,就会出现内存浪费的问题,这样资源利用效率就会变低。
但是,有了地址空间,malloc、new实际进行的操作就会是在虚拟内存上申请空间,如果申请了内存空间后不立马使用,那么OS会对动态分配的内存空间采用延时分配策略,暂时先不分配物理内存,这样就可以达到最大化利用物理内存资源的目的,OS有内置的内存分配算法,决定何时分配、分配大空间的物理内存。
同样的,CPU执行程序时,并不一定要先代码全部都加载到内存中才可以运行,OS会对代码进行分批加载。在最极端的情况下,内存中只加载了进程的PCB,而不带有任何的代码和数据。
这时,我们就可以再次深入理解进程挂起状态。挂起状态发生在内存资源接近饱和的时候,OS将某些进程的代码和数据暂时从内存换到磁盘中去,这时进程的状态就是挂起状态。然而,上文提到,一个进程的代码和数据并不是在同一时刻全部都在内存中存在,而是会分批的加载和释放内存资源以保证内存资源的最大化利用。这样我们可以认为,如果内存中只有一个进程的PCB而没有任何的代码和数据,这个进程就处于挂起状态。
通过页表,不但可以映射到物理内存的地址,也可以映射到磁盘地址。假设有一个进程即将被挂起,那么页表中就会填入该进程的代码/数据被换入的磁盘地址,当该进程再次被拿到CPU中运行时,就可以直接通过页表找到对应的磁盘空间,从而将磁盘中的相应数据拿回内存。
4. 在用户层面实现对空间的有序化使用
由于物理内存是可以被随机使用的,那么如果CPU直接读取物理内存的地址进行访问,那么就无法对地址空间实现有序化的管理。
然而,现代计算机系统通过引入地址空间,让每段进程看似独占全部内存空间,并且每段地址都存储有特定类型的数据值,这样在用户层面看来,内存的使用就是有序的。
四. 总结
- 在语言层面,根据对象/变量的类型不同,我们认为所有的对象/变量会根据类型的不同,存储在不同的内存地址区域,早期的计算机CPU也确实是直接对物理内存进行访问的。
- 现代计算机引入了地址空间的概念,我们在用户层面上看到的地址,都是进程地址空间中的虚拟地址,要通过页表进行映射,才能找到实际的物理内存。为了确保每个进程之间的独立性,每个进程独有一份地址空间和一张页表,OS会拦截非法的内存访问。
- 地址空间存在的意义在于:包含内存,拦截非法访问、实现进程管理与内存管理的解耦合、提高物理内存资源的利用效率、在用户层面实现对空间的有序化使用。