书接上回,我们已经对虚拟地址空间、页表以及“懒加载”机制和COW机制有了一定的认识,本篇文章我们将再次深化理解,并对相关内容进行拓展
目录
一、虚拟地址空间到底是什么?
1.上篇虚拟地址空间概念总结
2.大富翁的例子
3.结合例子深化理解进程与虚拟地址空间的关系
Ⅰ.隔离与独立性
Ⅱ.“承诺”≠“真实拥有”
Ⅲ.操作系统的 “管理” 角色
Ⅳ.“大饼” 的欺骗性
4.mm_struct结构体
二、如何理解虚拟地址中的区域划分
三、深入了解页表
1.标志位
Present(存在位)
Read/Write(读写位)
Execute(执行位)
2.为什么全局变量的生命周期是全局的?
3.常量字符串为什么是只读的?
一、虚拟地址空间到底是什么?
1.上篇虚拟地址空间概念总结
回顾上篇文章,我们已经知道了我们所涉及到是栈内存空间、堆内存空间、数据段等等,包括C语言的指针指向的地址,通通都是虚拟地址空间,我们是无法访问到真实的物理地址空间的,这是OS的一个强力的权限限制,这么做是为了保护我们的内存。
2.大富翁的例子
这里讲一个小故事帮助大家理解虚拟地址空间:
有一个海外大富翁,拥有10个亿资产,同时有三个私生子,每一个私生子都以为自己是独生子,对其他人的存在并不知情。
有一天大富翁对私生子1说,你好好工作,专注于事业,等我百年之后,我的十个亿都是你的,私生子1很开心;大富翁又找到私生子2说,你大学好好钻研金融专业,将来替我管理公司,等我死后,十个亿都是你的,私生子2狂喜;后来大富翁又找到私生子3说,你高中努力学习,等我去世后我的十个亿资产都给你,你想做什么就做什么,私生子3开始埋头苦干。大富翁跟这几个私生子说的承诺本质上是画一个大大的饼。
但是他们真的能一次要到十个亿吗?当然不可能,运气不好的,能要到千把块钱,运气好的,能要到一万十万,因为老子还在世呢!每一个人要的时候,只会一点一点地要,但是他们都沉浸在自己能有十个亿的幻想当中。换到大富翁的角度,他给那么多人画饼,万一忘记怎么办?那需不需要把这些“饼”给管理起来?当然需要!
在这个故事当中,这些“饼”就是虚拟地址空间!!!而大富翁就是操作系统OS,那十个亿就是真实的物理内存大小,这一个个私生子都是一个个进程,当它们需要内存的时候,操作系统就会给它们分配虚拟内存空间
有意思的是,这里面的进程都以为自己独占了全部内存资源,其实也只是得到了一个承诺(虚拟地址空间),进程拿到的虚拟地址,和真实的物理地址不是一回事,就像私生子拿到的是 “未来能拿到钱的承诺”,而不是直接拿到现金。操作系统会通过页表,把进程使用的虚拟地址,映射到真实的物理内存地址上。当物理内存不够用时,还会把暂时不用的内存数据放到磁盘上(交换分区 / 分页),就像大富翁暂时拿不出钱,先写个欠条,等你真要用了再想办法凑钱。
3.结合例子深化理解进程与虚拟地址空间的关系
Ⅰ.隔离与独立性
每个私生子(进程)都不知道其他人的存在,以为自己是独生子,能独享 10 个亿。对应到虚拟内存,每个进程都有自己独立的虚拟地址空间,看不到也碰不到其他进程的地址,天然实现了进程间的内存隔离,防止互相干扰和破坏。
Ⅱ.“承诺”≠“真实拥有”
私生子(进程)拿到的只是 “未来能拿到 10 个亿” 的承诺(虚拟地址空间),而不是立刻拥有所有钱。只有当进程真正需要用内存时,操作系统才会把物理内存分配给它,这就是按需分配的核心思想,避免了资源浪费。
Ⅲ.操作系统的 “管理” 角色
大富翁(OS)需要管理好给每个私生子画的饼,不能让他们的需求冲突。对应到技术上,就是操作系统通过页表、缺页中断等机制,把虚拟地址转换成真实的物理地址,同时管理物理内存的分配、回收和换入换出。
Ⅳ.“大饼” 的欺骗性
每个进程的虚拟地址空间大小,通常都远大于物理内存(比如 32 位系统每个进程有 4GB 虚拟地址空间,而物理内存可能只有 2GB),就像大富翁的 “10 个亿” 被分给了好几个私生子,但真实资产只有一份。这正是虚拟内存解决 “物理内存不足” 问题的关键 —— 用虚拟地址空间的 “假象”,让多个进程高效复用有限的物理内存。
4.mm_struct结构体
说了这么多,那虚拟地址空间到底以什么样的形式存在?它本质就是进程数据结构当中的一个结构体而已!!!那这个结构体应该包含什么呢?如下
这里面包含了虚拟空间的内存总数、栈空间的起始位置与结束位置、堆空间的起始位置和结束位置、代码段的起始位置和结束位置等等。
先通过mm_struct里的字段,描述清楚进程虚拟地址空间里每一段(代码、数据、堆、栈)的位置、大小、属性;再通过这些信息,组织起页表映射、内存分配、回收、缺页中断处理等所有和虚拟内存相关的操作。本质上还是先描述再组织的道理
二、如何理解虚拟地址中的区域划分
如下是上篇讲解的空间分布图的一部分,可以看到,栈和堆是相向增长的
我们以栈和堆的空间来讲解虚拟地址中的区域划分
从上面提到的mm_struct结构体当中,有明确区分栈和堆的起始位置和结束位置的代码,本质上就是把虚拟地址中的区域划分了,区域内,相当于获得了一批有效地址!!!区域内的任何位置,对应的栈或者堆都可以直接使用。
当然也会存在栈或者堆的虚拟地址空间使用超过原定范围的情况,此时OS就会自动调整区域,比如给栈多点空间或者给堆多点空间来动态调整并解决这个问题,当然,栈和堆的动态调整扩展机制是不一样的,这里先不详细介绍他们的具体机制
三、深入了解页表
上篇我们知道了页表支持了程序的虚拟地址与真实物理地址的哈希映射关系,但是除了虚拟地址与物理地址映射之外,还有一个我们要讲的地方,用来表示数据的各个状态的,就是标志位!
1.标志位
常见的标志位有:Present(存在位)、Read/Write(读写位)、Execute(执行位)等等,我们一一来简单介绍一下:
Present(存在位)
Present为1:表示该虚拟页已映射到物理内存,页表项中的物理地址有效;Present 为0:表示该虚拟页不在物理内存中(可能被换出到交换分区,或还未按需加载),此时访问会触发缺页异常(Page Fault),操作系统会处理:若页在交换分区,就把它换回到物理内存;若页是首次访问(如代码段、未初始化数据段),就从磁盘文件加载进内存。Present为0时,页表项的物理地址字段通常会存 “该页在交换分区的位置”,而不是物理内存地址。
Read/Write(读写位)
用于控制页的读写权限:0 = 只读,1 = 可读写,是为了保护代码段,还会用于保护只读数据段、共享库的只读部分,防止进程自身或其他进程非法修改
Execute(执行位)
表示控制页是否可执行,主要实现数据段不可执行(防溢出攻击)、代码段只读。
一般与读写位混合使用:
代码段:Read/Write=0(只读) +Execute = 1(可执行)
数据段:Read/Write=1(可读写) +Execute = 0(不可执行)
2.为什么全局变量的生命周期是全局的?
简单来说全局变量的生命周期 = 进程的生命周期,物理地址的存在是其能够被访问的必要条件,但生命周期的起点是程序启动,终点是程序结束,由进程的本身存在性决定。这也就解释了为什么进程重启后,全局变量会被重新初始化了,因为新的进程会重新加载数据段,分配新的物理地址。
3.常量字符串为什么是只读的?
我们都知道添加const修饰的变量都无法被修改,要么修改后程序崩溃,要么是编译器直接报错,这里就要分两种情况讨论了:
Ⅰ.首先是全局的const修饰的变量,编译器会把这个变量放到.rodata(只读数据段)中。操作系统在为.rodata段建立页表映射时,会把页表项的Write位设为0(只读),当你试图修改它时(比如通过指针强写 *(int*)&a = 20),CPU 会检测到写操作访问了只读页,触发写保护异常(Segmentation Fault),直接终止进程。本质上是页表的写权限为 0,硬件直接阻止了修改,修改全局const变量,会触发保护异常,程序直接被抹杀,这也就是为什么编译器这样执行代码会崩溃的原因。不是编译器让程序崩溃,是页表权限 + CPU 硬件保护 + 操作系统三者联手把程序 “抹杀” 了!
Ⅱ.第二种是局部的const修饰的变量,它仅仅编译器层面的 “不可修改”,这个变量分配在栈上,而栈所在的页表项Write位是1(可读写)的。它的 “不可修改” 只是C 语言编译器的语法检查:编译器会阻止你直接写b = 30,但如果你用指针绕开编译器检查(比如 *(int*)&b = 30),修改是会成功的,不会触发错误。因为它的页表写权限是开着的,没有硬件保护,只是编译器不让你改而已。
本篇到此结束,下篇我们将继续讲解相关的内容