note.wcoder.com
wcoder GitHub

Table of Contents

[TOC]

虚拟内存

Virtual address spaces 虚拟地址空间

一、定义

虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。目前,大多数操作系统都使用了虚拟内存,如:

  • Windows家族的“虚拟内存”
    WINDOWS的虚拟内存技术,即拿出一部分硬盘空间来充当内存使用,这部分空间即称为虚拟内存,虚拟内存在硬盘上的存在形式就是 PAGEFILE.SYS这个页面文件。

  • Linux的“交换空间”

内存管理单元MMU(Memory Management Unit)

内存管理单元MMU的主要功能是虚拟地址到物理地址的转换。除此之外,它还可以实现内存保护、缓存控制、总线仲裁以及存储体切换。也就是说程序运行的过程所需要的物理内存地址都是经过一个叫内存管理单元的东西完成的,需要注意的是内存管理单元是硬件管理,而不是软件实现内存管理的。内存管理单元使每一个程序都有自己独立的虚拟地址空间,提高了物理内存的使用率,它还提供了内存保护功能,可以将特定的内存块设置为读、写或者可执行属性,可以防止被程序恶意窜改内存。它的工作流程如下所示:

二、虚拟内存的工作方式

虚拟存储器是由硬件和操作系统自动实现存储信息调度和管理的。它的工作过程包括6个步骤:

  1. 中央处理器访问主存的逻辑地址分解成组号a和组内地址b,并对组号a进行地址变换,即将逻辑组号a作为索引,查地址变换表,以确定该组信息是否存放在主存内。
  2. 如该组号已在主存内,则转而执行④;如果该组号不在主存内,则检查主存中是否有空闲区,如果没有,便将某个暂时不用的组调出送往辅存,以便将这组信息调入主存。
  3. 从辅存读出所要的组,并送到主存空闲区,然后将那个空闲的物理组号a和逻辑组号a登录在地址变换表中。
  4. 从地址变换表读出与逻辑组号a对应的物理组号a。
  5. 从物理组号a和组内字节地址b得到物理地址。
  6. 根据物理地址从主存中存取必要的信息。

三、虚拟内存的调度方式

调度方式有分页式、段式、段页式3种。页式调度是将逻辑和物理地址空间都分成固定大小的页。主存按页顺序编号,而每个独立编址的程序空间有自己的页号顺序,通过调度辅存中程序的各页可以离散装入主存中不同的页面位置,并可据表一一对应检索。页式调度的优点是页内零头小,页表对程序员来说是透明的,地址变换快,调入操作简单;缺点是各页不是程序的独立模块,不便于实现程序和数据的保护。段式调度是按程序的逻辑结构划分地址空间,段的长度是随意的,并且允许伸长,它的优点是消除了内存零头,易于实现存储保护,便于程序动态装配;缺点是调入操作复杂。将这两种方法结合起来便构成段页式调度。在段页式调度中把物理空间分成页,程序按模块分段,每个段再分成与物理空间页同样小的页面。段页式调度综合了段式和页式的优点。其缺点是增加了硬件成本,软件也较复杂。大型通用计算机系统多数采用段页式调度。

1.页式调度

在页式虚拟存储系统中,虚拟空间被分成大小相等的页,称为逻辑页或虚页。主存空间也被分成同样大小的页,称为物理页或实页。相应地,虚拟地址分为两个字段:高位字段为虚页号,低位字段为页内地址。实存地址也分为两个字段:高位字段为实页号,低位字段为页内地址。同时,页的大小都取2的整数幂个字。

通过页表可以把虚拟地址转换成物理地址。每个程序设置一张页表,在页表中,对应每一个虚页号都有一个条目,条目内容至少包含该虚页所在的主存页面地址(实页号),用它作为实存地址的高位字段;实页号与虚拟地址的页内地址相拼接,就产生完整的实存地址,据此访问主存。

页表

虚拟内存中的一些虚拟页是要缓存在物理内存中才能被执行的,因此操作系统存在一种机制用来判断某个虚拟页是否被缓存在物理内存中,还需要知道这个虚拟页存放在磁盘上的哪个位置,从而在物理内存中选择空闲页或者更新缓存页,并将需要的虚拟页从磁盘复制到物理内存中。这些功能是由软硬件结合完成的,他存放在物理内存中一个叫页表的数据结构中。页表的结构如下图所示:

页表实际上是一个数组。该数组存放的是一个称为页表条目(PTE)的结构。虚拟地址空间的每一个页在页表中,都有一个对应的页表条目(PTE)。虚拟页地址翻译的时候就是查询的各个虚拟页在页表中的PTE,从而进行地址翻译的。地址翻译的过程如下所示:

假设每一个PTE都有一个有效位和一个n位字段的地址。其中有效位表示对应的虚拟页是否缓存在了物理内存中。0表示未缓存。1表示已缓存。n位地址字段表示如果未缓存(有效字段为0),n位地址字段不为空的话,这个n位地址字段就表示该虚拟页在磁盘上的起始的位置。如果这个n位字段为空,那么就说明该虚拟页未分配;如果已缓存(有效字段为1),n位地址字段则不为空,它表示该虚拟页在物理内存中的起始地址。

在上图中,四个虚拟页VP1 , VP2, VP4 , VP7 是被缓存在物理内存中。 两个虚拟页VP0, VP5还未被分配。但是剩下的虚拟页VP3 ,VP6已经被分配了,但是还没有缓存到物理内存中去执行。

2.段式调度

页面是主存物理空间中划分出来的等长的固定区域。分页方式的优点是页长固定,因而便于构造页表、易于管理,且不存在外碎片。但分页方式的缺点是页长与程序的逻辑大小不相关。例如,某个时刻一个子程序可能有一部分在主存中,另一部分则在辅存中。这不利于编程时的独立性,并给换入/换出处理、存储保护和存储共享等操作造成麻烦。

另一种划分可寻址的存储空间的方法称为分段。段是按照程序的自然分界划分的、长度可以动态改变的区域。通常,程序员把子程序、操作数和常数等不同类型的数据划分到不同的段中,并且每个程序可以有多个相同类型的段。 

在段式虚拟存储系统中,虚拟地址由段号和段内地址组成,虚拟地址到实存地址的变换通过段表来实现。每个程序设置一个段表,段表的每一个表项对应一个段,每个表项至少包括三个字段:有效位(指明该段是否已经调入主存)、段起址(该段在实存中的首地址)和段长(记录该段的实际长度)。 

3.段页式调度

段页式虚拟存储器是段式虚拟存储器和页式虚拟存储器的结合。

首先,实存被等分成页。在段页式虚拟存储器中,把程序按逻辑结构分段以后,再把每段按照实存的页的大小分页,程序按页进行调入和调出操作,但它又可按段实现共享和保护。因此,它可以兼有页式和段式系统的优点。它的缺点是在地址映像过程中需要多次查表,虚拟地址转换成物理地址是通过一个段表和一组页表来进行定位的。段表中的每个表目对应一个段,每个表目有一个指向该段的页表的起始地址(页号)及该段的控制保护信页表指明该段各页在主存中的位置以及是否已装入、已修改等标志。

四、虚拟内存的调度方式

虚拟存储器地址变换基本上有3种形虚拟存储器工作过程式:全联想变换、直接变换和组联想变换。任何逻辑空间页面能够变换到物理空间任何页面位置的方式称为全联想变换。每个逻辑空间页面只能变换到物理空间一个特定页面的方式称为直接变换。组联想变换是指各组之间是直接变换,而组内各页间则是全联想变换。替换规则用来确定替换主存中哪一部分,以便腾空部分主存,存放来自辅存要调入的那部分内容。常见的替换算法有4种。

  1. 随机算法:用软件或硬件随机数产生器确定替换的页面。  
  2. 先进先出:先调入主存的页面先替换。
  3. 近期最少使用算法(LRU,Least Recently Used):替换最长时间不用的页面。 
  4. 最优算法:替换最长时间以后才使用的页面。这是理想化的算法,只能作为衡量其他各种算法优劣的标准。

五、虚拟内存的作用

虚拟内存提供了三个重要的能力: 缓存,内存管理,内存保护

  1. 将主存视为一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据
  2. 为每个进程提供了一致的地址空间,简化内存管理
  3. 保护了每个进程的地址空间不被其他进程破坏

六、物理地址和虚拟地址的分布

物理地址空间布局

Linux系统在初始化时,会根据实际的物理内存的大小,为每个物理页面创建一个page对象,所有的page对象构成一个mem_map数组。进一步,针对不同的用途,Linux内核将所有的物理页面划分到3类内存管理区中,如图,分别为ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM。

  • ZONE_DMA 的范围是 0~16M,该区域的物理页面专门供 I/O 设备的 DMA 使用。之所以需要单独管理 DMA 的物理页面,是因为 DMA 使用物理地址访问内存,不经过 MMU,并且需要连续的缓冲区,所以为了能够提供物理上连续的缓冲区,必须从物理地址空间专门划分一段区域用于 DMA。

  • ZONE_NORMAL 的范围是 16M~896M,该区域的物理页面是内核能够直接使用的。

  • ZONE_HIGHMEM 的范围是 896M~结束,该区域即为高端内存,内核不能直接使用。

Linux内核空间虚拟地址分布


在 Kernel Image 下面有 16M 的内核空间用于 DMA 操作。位于内核空间高端的 128M 地址主要由3部分组成,分别为 vmalloc area、持久化内核映射区、临时内核映射区。

由于 ZONE_NORMAL 和内核线性空间存在直接映射关系,所以内核会将频繁使用的数据如 Kernel 代码、GDT、IDT、PGD、mem_map 数组等放在 ZONE_NORMAL 里。而将用户数据、页表(PT)等不常用数据放在 ZONE_HIGHMEM 里,只在要访问这些数据时才建立映射关系(kmap())。比如,当内核要访问 I/O 设备存储空间时,就使用 ioremap 将位于物理地址高端的 mmio 区内存映射到内核空间的 vmalloc area 中,在使用完之后便断开映射关系。

Linux用户空间虚拟地址分布


用户进程的代码区一般从虚拟地址空间的 0x08048000 开始,这是为了便于检查空指针。代码区之上便是数据区,未初始化数据区,堆区,栈区,以及参数、全局环境变量。

Linux物理地址和虚拟地址的关系


Linux 将 4G 的线性地址空间分为2部分,03G 为 user space,3G4G 为 kernel space。

由于开启了分页机制,内核想要访问物理地址空间的话,必须先建立映射关系,然后通过虚拟地址来访问。为了能够访问所有的物理地址空间,就要将全部物理地址空间映射到 1G 的内核线性空间中,这显然不可能。于是,内核将 0~896M 的物理地址空间一对一映射到自己的线性地址空间中,这样它便可以随时访问 ZONE_DMA 和 ZONE_NORMAL 里的物理页面;此时内核剩下的 128M 线性地址空间不足以完全映射所有的 ZONE_HIGHMEM,Linux 采取了动态映射的方法,即按需的将 ZONE_HIGHMEM 里的物理页面映射到 kernel space 的最后 128M 线性地址空间里,使用完之后释放映射关系,以供其它物理页面映射。虽然这样存在效率的问题,但是内核毕竟可以正常的访问所有的物理地址空间了。

Linux 是如何用虚拟地址来映射物理地址的,我们用一张图来总结一下:

七、总结

  1. 调度问题:决定哪些程序和数据应被调入主存。

  2. 地址映射问题:在访问主存时把虚地址变为主存物理地址(这一过程称为内地址变换);在访问辅存时把虚地址变成辅存的物理地址(这一过程称为外地址变换),以便换页。此外还要解决主存分配、存储保护与程序再定位等问题。

  3. 替换问题:决定哪些程序和数据应被调出主存。

  4. 更新问题:确保主存与辅存的一致性。

  5. CPU是如何访问内存

  • 以虚拟地址(VA)的形式,为应用程序提供远大于物理内存的虚拟地址空间(Virtual Address Space)

  • 每个进程都有独立的虚拟地址空间,不会相互影响,进而可提供非常好的内存保护(memory protection)

  • 提供内存映射(Memory Mapping)机制,以便把物理内存、I/O空间、Kernel Image、文件等对象映射到相应进程的地址空间中,方便进程的访问

  • 提供公平、高效的物理内存分配(Physical Memory Allocation)算法

  • 提供进程间内存共享的方法(以虚拟内存的形式),也称作Shared Virtual Memory

在操作系统的控制下,硬件和系统软件为用户解决了上述问题,从而使应用程序的编程大大简化。

进程的虚拟地址空间

多任务操作系统中的每个进程都在其自己的内存沙箱中运行。此沙箱是虚拟地址空间,在32位模式下始终是4GB的内存地址块。这些虚拟地址通过页表映射到物理内存,页表由操作系统内核维护并由处理器查阅。

每个进程都有自己的一组页表,但有一个问题。启用虚拟地址后,它们将应用于计算机中运行的所有软件,包括内核本身。因此,必须为内核保留一部分虚拟地址空间


在 32 位系统下,进程的虚拟地址空间有 4G,其中的 1G 分配给了内核空间,用户应用可以使用剩余的 3G。
在 64 位的 Linux 系统上,进程的虚拟地址空间可以达到 256TB,内核和应用分别占用 128TB。目前看来,这样的地址空间范围足够用了。

但这并不意味着内核使用了多少物理内存,这个是很重要的,初学者往往混淆虚拟地址和物理地址的概念 ,这里,每个进程只知道它拥有的可用映射它期望的物理内存地址空间的部分。内核空间在页表中标记为特权代码(ring 2或更低)专用,因此如果用户模式程序试图触摸它,则会触发页面错误。在Linux中,内核空间始终存在,并在所有进程中映射相同的物理内存。内核代码和数据始终是可寻址的,随时可以处理中断或系统调用。相反,只要发生进程切换,地址空间的用户模式部分的映射就会改变

蓝色区域表示映射到物理内存的虚拟地址,而白色区域未映射。在上面的示例中,Firefox已经使用了更多的虚拟地址空间。地址空间中的不同带对应于堆,堆栈等内存段。请记住,这些段只是一系列内存地址,与英特尔风格的段无关。下面这是Linux进程中的标准段布局:

蓝色区域表示映射到物理内存的虚拟地址,而白色区域未映射。在上面的示例中,由于当计算是快乐,安全和可爱的时,上面显示的段的起始虚拟地址几乎与机器中的每个进程完全相同。这使得远程利用安全漏洞变得容易。漏洞利用通常需要引用绝对内存位置:堆栈上的地址,库函数的地址等。远程攻击者必须盲目地选择此位置,依靠地址空间完全相同的事实。如果是这样的话,人们会被逮捕。因此地址空间随机化已经变得流行。Linux随机化堆栈, 内存映射段和堆通过向起始地址添加偏移量。不幸的是,32位地址空间非常紧张,几乎没有随机化的空间并妨碍其有效性。

进程地址空间中最顶部的段是堆栈,它在大多数编程语言中存储局部变量和函数参数。调用方法或函数会将新的堆栈帧压入堆栈。函数返回时会破坏堆栈帧。这种简单的设计,可能是因为数据遵循严格的LIFO顺序,这意味着不需要复杂的数据结构来跟踪堆栈内容 - 一个指向堆栈顶部的简单指针就可以了。因此,推动和弹出是非常快速和确定的。此外,堆栈区域的不断重用往往会在cpu缓存中保持活动堆栈内存,从而加快访问速度。进程中的每个线程都有自己的堆栈。

通过推送超出其能够容纳的数据,可以耗尽映射堆栈的区域。这会触发由expand_stack()在Linux中处理的页面错误,然后调用acct_stack_growth()来检查是否适合增长堆栈。如果堆栈大小低于RLIMIT_STACK(通常为8MB),那么通常堆栈会增长,程序会快速继续,不知道刚刚发生了什么。这是堆栈大小根据需求调整的常规机制。但是,如果已达到最大堆栈大小,则表示堆栈溢出并且程序收到分段错误。虽然映射的堆栈区域扩展以满足需求,但是当堆栈变小时它不会收缩, 它只会扩大。

动态堆栈增长是唯一一种访问未映射内存区域的情况,如上面的白色所示,可能是有效的。对未映射内存的任何其他访问都会触发导致分段错误的页面错误。参看我之前的文章Page Fault , 某些映射区域是只读的,因此对这些区域的写入尝试也会导致段错误。

在堆栈下面,我们有内存映射段。这里内核将文件内容直接映射到内存。任何应用程序都可以通过Windows中的Linux mmap()系统调用(实现)或CreateFileMapping()/ MapViewOfFile()来请求这种映射。内存映射是一种方便,高性能的文件I / O方式,因此用于加载动态库。还可以创建与任何文件不对应的匿名内存映射,而不是用于程序数据。在Linux中,如果通过malloc()请求大块内存,C库将创建这样的匿名映射,而不是使用堆内存。'大'意味着大于MMAP_THRESHOLD字节,默认为128 kB,可通过mallopt()调整。

说到堆,我们接下来就是地址空间。与堆栈不同,堆提供运行时内存分配(如堆栈),用于必须比执行分配的函数寿命更长的数据。大多数语言为程序提供堆管理。因此,满足内存请求是语言运行时和内核之间的联合事务。在C中,堆分配的接口是malloc(), 而在像C++这样的垃圾收集语言中,接口是new关键字。

如果堆中有足够的空间来满足内存请求,则语言运行时可以在没有内核参与的情况下处理它。否则,通过brk()系统调用(实现)扩大堆,以便为请求的块腾出空间。堆管理很复杂,需要复杂的算法,以便在我们程序的混乱分配模式面前争取速度和有效的内存使用。服务堆请求所需的时间可能会有很大差异。实时系统具有专用分配器来处理这个问题。堆也变得支离破碎,

最后,我们得到了最低的内存段:BSS,数据和程序文本。BSS和数据都存储C中静态(全局)变量的内容。区别在于BSS存储未初始化的静态变量的内容,其值不是由程序员在源代码中设置的。BSS内存区域是匿名的:它不映射任何文件。如果你说静态int cntActiveUsers,cntActiveUsers的内容就存在于BSS中。

另一方面,数据段保存源代码中初始化的静态变量的内容。此内存区域不是匿名的。它映射程序的二进制image的一部分,其中包含源代码中给出的初始静态值。因此,如果你说static int cntWorkerBees = 10,则cntWorkerBees的内容存在于数据段中并从10开始。即使数据段映射文件,它也是私有内存映射,这意味着内存更新不会反映出来在底层文件中。必须如此,否则对全局变量的赋值将改变您的磁盘二进制映像。不可思议!

下图中的数据示例比较棘手,因为它使用指针。在这种情况下,指针gonzo的内容 - 一个4字节的内存地址 - 存在于数据段中。但是,它指向的实际字符串不会。该字符串存在于文本段中,该段是只读的,除了像字符串文字这样的花絮之外,还存储了所有代码。文本段还将二进制文件映射到内存中,但写入此区域会使程序出现分段错误。这有助于防止指针错误,尽管不如首先避免C有效。这是一个显示这些段和示例变量的图表:


这就是虚拟地址空间布局

Linux的可执行文件格式elf与进程内存布局的区别:

← Previous Next →
Less
More