目录
1 物理内存
1.1 物理内存概述
1.2 直接使用物理内存的问题
1.2.1 多进程地址布局困难
1.2.2 进程地址空间小
1.2.3 程序链接不统一
2 虚拟内存
2.1 引入虚拟内存的目的
2.2 局部性原理与虚拟内存
2.3 虚拟内存到物理内存的映射
2.3.1 概述
2.3.2 页面分配与映射
2.4 页表结构
2.4.1 概述
2.4.2 页表
2.4.3 页目录表
2.4.4 虚拟地址翻译过程
3 页面换入换出
4 内存的段式管理与页式管理
4.1 段式管理
4.1.1 特征
4.1.2 优点
4.1.3 缺点
4.2 页式管理
4.2.1 特征
4.2.2 优点
4.3 使用现状
5 内存布局
5.1 抽象内存布局
5.1.1 内存布局组成
5.1.2 磁盘程序段与内存程序段
5.2 IA-32 + Linux的进程内存布局
5.3 Intel 64 + Linux的进程内存布局
5.3.1 使用部分地址线
5.3.2 canonical address
1. 物理内存由多个连续的存储单元组成,每个单元称为一个字节
2. 每个字节有一个唯一的物理地址(Physical Address,PA),地址编码从0开始
3. 在早期的体系结构中(e.g. X86实模式),程序直接使用物理地址。也就是说,程序中每个数据存储在内存中的位置,都由程序员负责
由于系统中存在多个进程,每个进程分配多少内存、如何保证指令中访问内存地址的正确性并且与其他进程不冲突,都需要程序员完成。相当于将linker和loader的工作全部交给程序员手动完成
由于是多个进程共享物理内存,所以需要对每个进程可使用的物理内存区域进行分配,因此每个进程的地址空间都很小(最大也不会超过当前可使用的物理内存)
1. 对于程序而言,链接地址和运行地址需要一致,才能确保程序中的地址相关操作正确执行
2. 由于进程被分配在不同的物理地址运行,所以在链接时需要指定相应的链接地址。或者说,程序需要加载到链接地址处运行
说明:上述情况是针对没有分段机制的处理器而言的,对于有分段机制的处理器(e.g. X86),程序可以使用统一的链接方式(e.g. 程序中的每个段都从0地址开始链接),此时程序中使用相对于段起始位置的偏移地址而不是使用绝对地址
在加载程序时,将程序中不同的段加载到当前空闲的物理内存中,之后用段寄存器记录段的物理起始地址,即可以实现程序的重定位
相关内存可参考X86汇编语言从实模式到保护模式01:处理器、内存和指令_麦小兜的博客-CSDN博客 chapter 2,相关加载器的实现可参考X86汇编语言从实模式到保护模式07:硬盘和显卡的访问控制_麦小兜的博客-CSDN博客
虚拟内存的出现,就是为了解决直接使用物理内存的问题
1. 使得每个进程有不依赖物理内存大小的虚拟地址空间,这个空间可以比可用的物理内存大得多
2. 使得每个进程的虚拟地址空间是私有的、独立的,与其他进程的虚拟地址空间相互隔离,这就解决了多进程之间地址冲突的问题
3. 由于虚拟地址空间是进程独占的,可以任意使用,因此可以将变量和函数分配地址的任务交给链接器自动安排
也就是说,每个进程的虚拟地址空间布局是相同的,因此链接器可以按统一的方式链接不同程序
处理器和操作系统是基于局部性原理(Principle of locality)为程序员虚拟化了一层内存,也就是虚拟内存。局部性原理有如下2方面,
1. 时间局部性:被访问过一次的内存地址很可能在不远的将来会被再次访问
2. 空间局部性:如果一个内存地址被访问过,那么与他临近的地址在不远的将来也很可能会被访问
从局部性原理就可以得出结论:无论一个进程占用的内存资源有多大,在任一时刻,他需要的物理内存都是很少的
在这个推论的基础上,处理器只要为每个进程保留很少的物理内存就可以保证进程的正常执行
1. 任何虚拟内存中的的数据,最终还是要保存在真实的物理内存中。也就是说,虚拟内存需要映射到物理内存
2. 虚拟内存的大小远大于物理内存的大小
3. 基于局部性原理,处理器和操作系统只将当前使用的虚拟内存映射到物理内存
1. 首先,虚拟内存与物理内存的映射以页为单位,常见的页大小为4KB
2. 虽然虚拟内存提供了很大的地址空间,但是在进程启动后,这些空间并不是全部被使用,而是处于未分配状态
3. 当程序中通过malloc等内存分配接口获取内存时,相应的虚拟内存页面将从未分配转变为已分配但未映射状态
4. 当对这段分配到的虚拟内存进行读写时,操作系统才会在缺页异常中为他们分配物理内存,当映射关系建立后,该页面转变为正常页面
可见虚拟内存实现一个假象,让程序员觉得整个虚拟内存空间可以随时访问,但真实的数据可能不在物理内存中,而是在需要用到时才被加载到内存中,并建立虚拟内存到物理内存的映射
说明1:虚拟地址中的"虚拟",是指不存在但能看见
① 本质上,虚拟地址是一套虚拟内存分配与映射机制,真正操作的还是物理内存。所以说虚拟内存本身是不存在的
② 但是对程序员而言,能直接操作的就是虚拟地址,因此是能看见的
说明2:引入虚拟内存后,分配内存分为2级
① 首先是在进程的虚拟地址空间中分配虚拟内存
② 之后是分配物理内存,并建立虚拟内存与物理内存的映射关系
分配物理内存被推迟到对虚拟内存的访问触发缺页异常时才进行,实现了按需加载
说明3:当虚拟内存已分配但未映射时,他所对应的数据在哪里?
① 可能在磁盘上,比如文件
② 也可能是申请但未访问的内存,比如malloc分配一个数组
说明4:在虚拟内存中连续的页面,在物理内存中不必是连续的。只要维护好从虚拟内存到物理内存的映射关系,就能正确使用内存
说明5:虚拟地址空间的大小
虚拟地址空间的大小一般由处理器字宽决定,
① 对于32位处理器,寄存器是32位的,可以存储32位指针,因此能表示的地址范围为0 ~ 4GB
② 对于64位处理器,寄存器是64位的,可以存储64位指针。但是一般并不实际使用全部位数,比如只使用低48位,此时的虚拟地址空间为256TB
说明6:有不遵循局部性原理的程序吗?
① 这种局部性不好的程序是存在的,他会导致处理器频繁进入缺页异常,为其分配物理内存,从而影响性能
② 对于这种程序,在物理内存足够的情况下,应该让其使用的数据尽可能驻留在内存中
③ 可以在该程序启动时,就分配虚拟内存,并且对分配的虚拟内存空间进行一次访问,强制将未映射的页面转变为正常页面,从而降低缺页异常发生的概率
上述过程通常称为内存的commit
1. 上述虚拟内存和物理内存的映射关系由操作系统管理,而管理这种映射关系的数据结构就是页表
2. 映射的过程由处理器的内存管理单元(Memory Management Unit,MMU)自动完成,但是他依赖操作系统设置的页表。因此,虚拟内存是软硬件一体化设计的典型代表
1. 页表本质上是页表项(Page Table Entry,PTE)的数组,虚拟地址空间中的每个虚拟页在页表中都有一个PTE与之对应
2. 以X86-32体系结构为例,页大小为4KB,每个页表项4B,因此将1024个页表项组成一张页表。这样一张页表的大小正好是4KB,占据一个内存页
3. 而一张页表的1024个页表项能够映射1024 * 4KB = 4MB内存
如上文所述,一张页表可以映射4MB内存。为了编码更多地址,就需要更多页表。因此引入了页目录表
1. 页目录表中的每一项叫做页目录项(Page Directory Entry,PDE),每个PDE都对应一张页表
2. 每个页目录项也是4B,因此将1024个页目录项组成一张页目录表,可以映射1024 * 4MB = 4GB内存,可以覆盖32位处理器的虚拟地址空间
说明:使用多级页表结构,而不是使用单级页表的原因,可参考X86汇编语言从实模式到保护模式19:分页和动态页面分配_麦小兜的博客-CSDN博客_汇编page chapter 2.4.3
以X86-32体系结构为例,使用两级页表,并将虚拟地址划分为3段,分别作为页目录表索引、页表索引和页内偏移量
虚拟地址的翻译过程如下,
1. 确定页目录基址
每个CPU都有一个页目录基址寄存器,记录最高级页表的物理基地址。在X86-32体系结构中,就是CR3寄存器
2. 定位页目录项(PDE)
页目录物理基址 + 虚拟地址高10位 * 4 = PDE物理地址
页目录项(PDE)中记录了页表的物理地址
3. 定位页表项(PTE)
页表物理基址 + 虚拟地址中间10位 * 4 = PTE物理地址
页表项(PTE)中记录了物理页的物理地址
4. 确定物理地址
以虚拟地址低12位作为物理页中的索引
说明1:页表项(PTE)中处理记录物理页的物理地址外,还记录了一些属性(e.g. 页的读写权限、标记页是否存在),操作系统可以基于这些属性实现内存保护
说明2:每个进程都有自己的页目录表,当进程切换时,会将目标进程的页目录表物理地址加载到CR3寄存器。所以在任意时刻,只有一个进程页表是活跃的
说明3:在64位处理器上,由于虚拟地址空间更大,需要更多级的页表
以X86-64体系结构为例,PDE和PTE都是8B,所以一页之中只能存放512项,需要9位进行编码。所以虚拟地址会被分割为9 + 9 + 9 + 9 + 12共4段,而页表也是4级
由此可见,页表的级数与虚拟地址的分段是匹配的
1. 由于程序运行符合局部性原理,对于那些没有被经常使用的内存,可以将他们换出到内存之外,比如磁盘的swap分区
2. 当物理内存足够时,操作系统会让尽可能多的页驻留在物理内存中,因为将内存中的数据写入磁盘是非常耗时的操作
说明1:极端情况下,给一个进程4KB物理内存就可以。操作系统通过对一页不断的换入换出,使得进程可以运行
说明2:虚拟内存会耗尽吗?
① 虚拟内存也是会耗尽的,也就是out of memory错误。以Linux 32位操作系统为例,用户空间只有3GB,如果程序中一次性要申请4GB内存,则虚拟内存不足
② 虽然有了虚拟内存,但是物理内存还是会耗尽的。当物理内存不足时,可以将不活跃的物理页换出到磁盘的swap分区。当swap分区耗尽时,物理内存就耗尽了
所以任何时候,及时释放申请的内存是一个好习惯
说明:关于X86体系结构实模式与保护模式的相关内容,可参考X86汇编语言:从实模式到保护模式学习笔记
X86汇编语言:从实模式到保护模式学习笔记
1. 按功能将内存划分为不同的段,例如代码段、数据段、只读数据段、栈段等
2. 为不同的段设置不同的特权级和读写权限
1. 按功能对内存进行划分,符合人的直观思维
2. 可以提供更好的安全性(这点依赖保护模式下段机制的检查机制)
1. 段长度往往是不固定的,难以以段为单位进行内存的分配和回收
1. 不按功能对内存进行划分,而是按固定大小将内存划分为大小相同的页
2. 无论存放数据还是代码,都需要先分配一个页,再将内容加载到页中
1. 页大小固定,易于内存的分配与回收
2. 段式管理提供的安全性,在现代CPU中可以被页表项中的属性替代
1. 现代操作系统都采用段式管理实现基本的权限管理(比如区分内核态和用户态),而对于内存的分配、回收和调度则依靠页式管理实现
2. 段式管理负责将逻辑地址转换为线性地址,页式管理负责将线性地址转换为物理地址。通过使用段页式混合管理模式,兼具了段式管理和页式管理的优点
说明1:现代操作系统中,一般将段描述符中的段基址设置为0,段长度设置为最大,也就是使用平坦模型
说明2:段式管理与页式管理的内存碎片
① 段式管理可以根据实际需求分配段的大小,因此段内部没有内存碎片。但是由于每个段的长度不固定,所以多个段未必能敲好使用所有的内存空间,所以段与段之间会产生内存碎片
② 页式管理中页的大小固定,即使所需内存不足一页,也会分配一页,因此页内部有内存碎片。但是页与页之间紧密排列,没有内存碎片
说明3:操作系统弱化分段机制后如何补偿
① 首先需要说明的是,X86体系结构的设计者希望大家继续使用段机制,所以基于段寄存器构造了保护模式,同时在段和页两级提供权限管理机制
但是Linux操作系统使用平坦模型绕过了段机制,且大多数体系结构都没有段基址,所以段机制逐渐被弱化。甚至在X86-64体系结构中,默认就是使用平坦模型,废弃了段描述符中的段基址和长度
② Linux内核引入vm_area_struct结构,通过软件方式进行权限管理,部分代替了段基址的权限管理工作
抽象内存布局描述的是运行一个程序所需的最小功能集,对于一个典型的进程,内存布局包括如下部分
1. 代码段
存储程序的机器指令,这段区域的内存一般可读可执行,但不可写
2. 数据段
① 存储程序中已经初始化且不为0的全局变量和静态变量
② 这些变量的初值会存储在程序编译后的二进制文件中,然后被加载到内存中
3. BSS(Block Started by Symbol)段
① 存储程序中未初始化或初始化为0的全局变量和静态变量
② 由于他们的初值为0,因此不需要在程序编译后的二进制文件中存储那么多0,只需要记录他们的起止地址即可
③ 操作系统在加载程序时,会根据记录的BSS段起止地址初始化相应的内存区域
④ BSS除了Block Started by Symbol,从功能上也可以理解为Better Save Space
4. 堆 & 栈
堆空间和栈空间不是从磁盘上加载,而是在程序运行过程中申请的内存空间
说明1:Linux 0.11内核支持的a.out文件格式与内存布局,就是如上图所示
说明2:现代应用程序除了上面的内存段,还会包含如下内存区域
① 存放加载的共享库的内存空间
如果一个进程使用共享库(动态库),该共享库的代码段、数据段和BSS段也需要被加载到进程的地址空间中
② 共享内存段
可以通过系统调用映射一块匿名内存作为共享内存,用来进行进程间通信
③ 内存映射文件
可以将磁盘上的文件映射到内存中,用来进行文件编辑或以类似共享内存的方式进行进程间通信
1. 上图中左边是程序在磁盘中的文件布局,右边是程序加载到内存中的内存布局
2. 对于磁盘的的程序,每个单元称为一个Section,可以通过readelf -S命令查看可执行程序中所有的Section信息
3. 对于内存镜像,每个单元称为一个Segment,可以通过readelf -l命令查看可执行程序加载到内存之后的Segment布局
说明1:Section与Segment的对应关系
① 因为Segment是将具有相同权限的Section集合在一起,为他们分配同一块内存空间,所以往往是多个Section对应一个Segment
② 对于磁盘文件中一些保存辅助信息的Section(e.g. symtab段、strtab段),不需要在内存中进行映射
说明2:Section与Segment对应关系实验
编写一个简单的hello world程序,编译后查看Section与Segment的对应关系。首先来看一下readelf命令中-S和-l选项的功能
-S:Displays the information contained in the file's section headers
-l:Displays the information contained in the file's segment headers
从执行结果看见,
.text段被映射到可读(R)可执行(E)的内存段
.data和.bss段被映射到可读(R)可写(W)的内存段
首先需要说明的是,在IA-32 + Linux的进程内存布局中,低3GB为用户空间,高1GB为内核空间,此处我们关注用户空间的组成部分
1. 保留区
① 从0地址开始的是一段不可访问的保留区,用于防止程序跑飞。这是因为在大多数系统中,一般认为一个比较小数值的地址是一个不合法地址(e.g. NULL指针)
② 此处保留区大小为0x08048000,约128MB
2. 代码段
① 从0x08048000开始是代码段
② 以上地址需要GCC在编译时不开启PIE选项
③ 代码段是从可执行文件镜像中加载到内存中
3. 数据段
① 数据段紧接在代码段之后
② 数据段也是从可执行文件镜像中加载到内存中
4. BSS段
① BSS段紧接在数据段之后
② BSS段是根据BSS段所需大小,在加载时生成的一段以0填充的内存空间
③ 由于BSS段和数据段属性相同,所以如之前的实验所示,在内存中与数据段映射在相同的Segment
5. 堆(Heap)
① 堆空间地址向上增长
② 堆的指针叫做Program break
③ 每次进程向内核申请新的堆地址时,分配的地址值增加
④ 堆的最大值会受到操作系统限制,如果耗尽就会发生out of memory错误,分配不出新的内存
6. 栈(Stack)
① 栈空间地址向下增长
② 栈的指针叫做Stack Pointer
③ 每次进程申请新的栈地址时,分配的地址值减小
7. 文件映射与匿名映射区
这里最常见的就是程序所依赖的共享库(e.g. libc.so),共享库的代码段、数据段和BSS段会被加载到这里
说明1:可以通过cat /proc/[pid]/maps命令查看指定进程的虚拟内存空间布局
示例程序如下,
将该程序在后台运行,之后查看对应进程的虚拟内存空间布局
说明2:进程地址随机化
① 上述内存布局是在Linux操作系统关闭进程地址随机化时的情况,如果打开进程地址随机化,其中的堆空间、栈空间和共享库映射的地址,在每次程序运行时都会不一样
② 在实现方法上,就是内核在加载程序时,会对这些区域的起始地址增加一个随机的偏移量
③ 可以通过sysctl命令设置进程地址随机化
sudo sysctl -w kernel.ramdomize_va_space=val
# val = 0,表示关闭内存地址随机化
# val = 1,表示mmap的基地址、栈地址和VDSO的地址随机化
# val = 2,表示在1的基础上,增加堆地址随机化
补充:地址随机化是由Linux内核与GCC的PIE编译选项共同决定的,操作系统的加载器负责生成随机地址,编译器负责产生的代码地址无关(因此使能进程地址随机化,需要编译时携带PIE选项)
Intel 64 + Linux进程内存布局中的组成部分与Intel 32中是相同的,此处重点说明虚拟地址空间的划分
1. 64位处理器的理论寻址范围为2^64 = 16EB,但是目前的操作系统和应用程序往往用不到这么庞大的地址空间,因此只使用部分地址线
2. Intel 64位处理器目前支持48位虚拟地址,即寻址空间为2^48 = 256TB
3. 由于使用48位虚拟地址,所以地址的最高有效位为bit [47](从bit[0]开始)
1. Intel 64位体系结构定义了canonical address的概念,即在64位模式下,如果地址位63到地址最高有效位被设置为全0或全1,那么该地址被认为是canonical form
2. 在Intel 64位体系结构中使用48位虚拟地址,因此根据canonical address的划分,地址空间天然被划分为两个区间,分别是0x0 ~ 0x00007FFFFFFFFFFF和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF这2个128TB空间(即根据地址最高有效位bit [47],用该值设置到bit [63])
3. 地址空间中的其他区域均不满足canonical form,也就是上图中的非canonical空间,对这部分内存不会建立页表进行映射
4. 在实际使用中,将低128TB用作用户空间,高128TB用作内核空间
说明1:在64位操作系统中查看进程虚拟地址内存布局
使用与32位操作系统中相同的hello world程序
从中可以看出,在代码段(0x00400000 ~ 0x00401000)和数据段(0x00600000 ~ 0x00601000)之间存在一段空隙
对于64位应用程序,这是一段不可读写的保护区域,作用是防止程序在读写数据段时越界访问到代码段。这个保护段可以让这种越界访问行为直接崩溃,防止程序继续运行下去