目录
1.用一个小实验引入虚拟地址空间
2.进程地址空间概念
3.用地址空间概念解决写实拷贝问题
4.感性理解虚拟地址空间
5.mm_struct和vm_area_struct
6.地址空间与知识的勾连
7.为什么要有虚拟地址空间
正文开始--》》》
1.用一个小实验引入虚拟地址空间
我们先来看一段代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
int id = fork();
if (id < 0)
{
perror("fork");
return 0;
}
else if (id == 0)
{ // child
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
else
{ // parent
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
运行结果:
parent[30116]: 0 : 0x556d70f44014
child[30117]: 0 : 0x556d70f44014
我们发现父子进程中全局变量g_val的地址是一样的,这看起来好像很好理解,因为子进程继承了父进程的变量,父进程没有对变量进行任何修改,所以地址一样,可是将代码稍微改动:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
int id = fork();
if (id < 0)
{
perror("fork");
return 0;
}
else if (id == 0)
{
g_val = 100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
else
{ // parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
运行结果:
child[30249]: 100 : 0x563637ad6014
parent[30246]: 0 : 0x563637ad6014
于是我们发现父子进程地址是一样的,但是变量的值居然不一样!所以我们得出结论:
- 变量内容不⼀样,所以父子进程输出的变量绝对不是同⼀个变量
- 但地址值是⼀样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做虚拟地址
- 我们在⽤C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
2.进程地址空间概念
首先明确一点,所以之前说的程序地址空间是不准确的,准确的应该说成进程地址空间。虚拟地址空间 是操作系统为所有进程提供的 "地址映射规则体系",进程地址空间是单个进程在这套规则下拥有的 "专属虚拟地址范围" ------ 前者是 "全局机制",后者是"局部实例",不能等同,但紧密依赖。
我们运行一个程序时系统会启动一个对应的进程,并把进程所对应的代码和数据加载到物理内存中,然后在物理内存中为进程创建一个与之关联的task_struct,这是我们之前学习的知识,但是今天要补充一点,除了加载代码和数据和创建PCB,系统还会每个进程创建一个独立的虚拟地址空间。 这个虚拟地址空间的地址范围是从全0到全F,虚拟地址空间中的每个地址都叫做虚拟地址,如果系统是32位,虚拟地址空间就是2^32次方,如果系统是64位,虚拟地址空间就是2^64次方,但是大部分教材和书籍都是以32为例讲解这一部分知识的,这里我们也以32位为例讲解。我们知道物理内存也有它对应的物理地址,虚拟地址需要和物理地址进行映射(映射关系是动态的、按需建立的,并非一一映射),因为我们用户层创建的进程只能拿到虚拟地址,所以进程在访问内存数据的时候,要先进行虚拟地址到物理地址的映射,找到数据对应的物理内存,然后才可以访问对应的内存数据,所以除了虚拟地址空间外系统还会为进程在内存中建立一种数据结构------页表,每个进程有各自独立的页表,它可以用来让虚拟地址和物理地址之间进行映射
3.用地址空间概念解决写实拷贝问题
我们先看起那面运行结果部分,父进程有g_val的虚拟地址0x563637ad6014,它可以拿着这个虚拟地址经过页表的映射访问到物理地址中的父进程g_val的数据。当父进程fork一个子进程后,子进程会继承父进程的task_struct、进程地址空间和页表(这里的继承指的是拷贝,父进程会把task_struct、进程地址空间和页表都拷贝一份给子进程),所以子进程的进程地址空间中也有一个g_val的虚拟地址,并且值和父进程的一样是0x563637ad6014,所以这是子进程的gval虚拟地址也能经过相同的页表映射逻辑映射并访问到父进程的gval的数据,这也就是为什么第一次实验子进程没修改g_val值时父子进程打印g_val的地址和内容都一样。但是当子进程尝试对父子共享数据gval做修改时,操作系统不会让子进程修改物理内存中原gval位置的数据,因为进程之间要遵循独立性,就算亲如父子。所以这时操作系统会自动在物理内存中重新为我们做下面三个操作:1、开辟一块和g_val一摸一样大小的空间。2、然后将原空间的数据拷贝一份到新空间。3、修改虚拟地址到物理地址之间的映射关系,让虚拟地址0x563637ad6014不再映射到原空间,而是映射到刚开辟的新空间。当父子进程之间其中一方要修改共享变量时(写操作),操作系统会自动在物理内存中开辟一块新空间并把被修改数据重新拷贝一份到新空间,让修改方修改新空间的数据,我们把这种技术叫做写时拷贝。该工作全部由操作系统自动完成,用户不知道。但是g_val的虚拟地址都一样,因为不需要,但是目前还不能详细解释原因,后面会慢慢解开这些谜团。
这里我们也就能解释为什么fork能有两个返回值了,因为创建子进程发生了写实拷贝,虽然父子拿到的虚拟地址一样,但是实际通过页表映射到物理内存的不同地址空间了,所以父子进程可以分别修改自己的返回值,互不影响。进程具有独立性
4.感性理解虚拟地址空间
我们目前只能对虚拟地址空间有一个感性的认识,我们把操作系统看作一个大富翁,它有10个亿资产,这10个亿资产就是4个GB的物理内存。他有很多个私生子,每个私生子互相并不知道彼此的存在,我们把私生子看作一个进程,操作系统给每个私生子说10个亿资产都是你的,我们知道这不现实,所以这就是大富翁给每个私生子画的饼,这些饼就是虚拟地址空间,大富翁给每个私生子画的饼就叫做进程地址空间。但私生子让大富翁把10个亿给他时,大富翁是不允许的,但是要个一两千块大富翁还是可以给的。所以进程不能一次性把4个GB物理内存全部占用了,但是可以申请占用几个kb,几个mb。这就是相当于操作系统再一次欺骗了进程,进程以为有全部。
5.mm_struct和vm_area_struct
当私生子很多时相对应的饼也很多,所以这些饼也就是进程地址空间也需要被管理起来。管理进程地址空间也要遵循先描述再组织,操作系统管理所有的进程地址空间也会为它们维护一份内核数据结构,在linux中这个内核数据结构名为mm_struct,它也是结构体,在进程的task_struct中会有一个指向mm_struct的指针。
这样我们把task_struct维护成链表,也就相当于变相把mm_struct维护成了链表,所以不用mm_struct单独维护一个数据结构。(但是实际上linux内核仍为mm_struct维护了独立的数据结构,也就是说mm_struct实际内嵌了list_head,以满足共享场景、管理效率和生命周期控制的需求。)
利用虚拟地址空间解释为什么父子进程共享代码和数据:
我们前面介绍过当父进程创建子进程时操作系统会为子进程创建task_struct,子进程的task_struct是以父进程的task_struct为模板初始化的,然后修正个别子进程特有的属性,如pid,ppid等。所以父进程自然就会将task_struct、mm_struct、页表(内部是键值对指针,分别指向虚拟地址和物理地址)都拷贝一份给子进程,所以父子进程的进程地址空间一样,页表一样,自然映射关系也一样,所以子进程也指向父进程物理内存中的代码和数据,自然父子进程共享代码和数据。
(代码一般不可写,共享可保证,数据共享的前提的父子进程都没有发生写入操作,否则会发生写时拷贝,数据就不是完全共享
页表与只读区与可读可写区:
先说明一点,进程地址空间中代码区是只读的,已初始化数据区和未初始化数据区是可读可写的,原因就是页表中有一列信息,它代表虚拟内存映射到物理内存的权限信息,已初始化数据区和未初始化数据区映射时这列信息对应位置会被写成rw,而代码区映射时对应位置会被写成r。
关于vm_area_struct
进程地址空间中的栈区、全局数据区、代码区的地址空间都是连续的,通常都是作为一个整体使用的,那么它们在一个进程中都只有一个空间起始地址,那么用mm_struct管理就完全足够了,可是堆空间不是这样的,堆区需动态响应多请求,必须拆分小块,无法整体使用,而每一个独立小块空间都有各自独立的起始地址和大小,所以要理解真正的进程地址空间,光一个mm_struct是不够的,因为它只能建立宏观概念,无法映射阐述像堆空间一样的小块内存。
所以linux内核会使⽤ vm_area_struct结构体来表⽰⼀个独⽴的虚拟内存区域(VMA),所有vm_area_struct结构体会由链表或红黑树组织起来,mm_struct里有一个指向进程中所有vm_area_struct结构体链表头的指针,每个vm_area_struct内部也有一个回指向它属于哪个mm_struct的指针。
每个vm_area_struct内部会有单独的start和end,那么代码区的start和end就会设置在代码区的start和end里,其他区域同理,vm_area_struct可以把空间区域划分的更详细
这里再补充一点,每个vm_area_struct内部还有访问权限成员,它可以规定vm_area_struct管理的某个区域是否有读写权限,操作系统可以结合该权限和用户对该区域变量的操作来决定是通过还是拦截。
6.地址空间与知识的勾连
字符串常量其实是和代码编译在一起的,因为代码是只读的,字符串常量自然也是只读属性。所以像下面一样修改字符串常量的第一个字符就会报错:
char* str = "hello world"; //str指向一个字符串常量
*str = 'C';
今天我们来深究一下其中的原因,当进程拿着字符串常量所在代码段的虚拟地址尝试修改字符串常量时,需要先经过页表将虚拟地址转为物理地址,转的过程中页表会对进程拿的地址进行权限检查,检查到该地址只有读操作,可我们用等号赋值相当于对地址内容进行写操作,权限不匹配,会导致从虚拟地址转为物理地址失败,然后操作系统就会把这个进程杀掉。总的来说就是因为字符常量区被页表映射的时候,有权限约束,不让写入操作进行转换。
我们再看一个示例:
//str是局部变量
const char* str = "hello world";
*str = 'C';
当给局部变量str加const修饰后,str存储在栈区默认是可写的,可以通过指针 "绕过编译检查" 间接修改其值。const的行为是约束编译器,让编译器编译代码时进行检查是否对变量进行了写操作,如果有,则会在编译时报错。而上面没加const进行写操作时是运行时报错,在运行时会查页表,页表发现后映射不过去。
补充:运行时报错全都是进程层面出问题。
7.为什么要有虚拟地址空间
这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?
- 安全问题和进程独立性:每个进程都可以访问任意的物理内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。如果一个进程可以随意访问物理内存,那它也可以访问其他进程的代码和数据,这样一个进程的操作会影响另一个进程,进程独立性就形同虚设了。
- 物理内存的无序性:我们的可执行程序中的代码和数据加载到物理内存时理论上可以加载到任意位置,所以如果没有虚拟内存的话,进程就需要把代码和数据分别加载再物理内存的什么位置记录起来。
- 如果直接使⽤物理内存,需要把进程看作整体在内存和磁盘之间转移,效率低下:当需要把进程的代码和数据加载进内存时,通常需要把进程整体全部加载进内存,如果出现物理内存不够⽤的时候,我们⼀般的办法是将不常⽤的进程拷⻉到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程⼀起拷⾛,这样,在内存和磁盘之间拷⻉时间太⻓,效率较低。
- 直接操作物理内存导致进程管理和内存管理强耦合。
那有了虚拟地址空间就可以解决上面的所有问题了吗?当然!这里涉及了一个计算机学科里的一个哲学思想:计算机中任何问题,都可以通过新增一层软件层来解决,如果解决不了,那就再增加一层
1、虚拟内存的存在变相保证物理内存的安全,维护进程独立性特性
因为有了虚拟内存,我们用户只能拿到虚拟地址,要访问物理地址必须将虚拟地址转换为物理地址,在转换时需要借助操作系统,这时操作系统就可以在这期间做安全审核,从而变相保证物理内存的安全。
2、虚拟内存的存在可以使进程看待内存由无序看成有序
如果有虚拟空间的话,所有代码和数据包括命令行参数、环境变量、堆区、栈区等等都是以虚拟内存空间的形式有序的呈现给进程的。
3、虚拟内存的存在可以实现进程内存的按需加载和局部交换 (前言补充:新建一个内存会先创建进程task_struct再加载代码和数据)
我们知道当进程新建出来时并不会被立即调度,而是待在过期队列里(这时task_struct已创建),所以在进程新建出来后并不会立即将代码和数据加载进内存,而是当用到对应代码和数据时才将对应的内容加载进内存,这就是所谓的惰性加载(惰性申请)。进程创建到调度之间的时间就可以将本该在创建时加载进内存的数据占据的空间给其他进程使用,提高了内存的利用率。写时拷贝本质也是一种惰性申请,因为创建子进程后为了满足进程独立性本应立即申请和父进程同样的空间存储子进程的数据,而写实拷贝技术可以让你用到对应数据也就是进行写操作时才开辟空间,也是用多少拿多少,可以最大化提升内存的利用率。
4、虚拟内存的存在可以使进程管理和内存管理解耦合
因为有地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配 和
进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。
关于虚拟地址空间,就了解到这里,后面再继续深入理解,拜~~~~