每个程序员都应该了解的内存知识(三): 虚拟内存
虚拟内存
概念
它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),而实际上物理内存通常被分隔成多个内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
阅读链接
虚拟内存篇 (原文地址)
详解内存映射(做的图非常好, 一定要重点看一看)
MMU
(Memory Management Unit,内存管理单元)
- 地址翻译:将 CPU 生成的虚拟地址(或逻辑地址)翻译成物理内存中的物理地址。这种映射通常通过页表来完成,页表存储了虚拟地址到物理地址的映射信息。
- 内存保护:MMU 可以确保每个进程只能访问自己的内存空间,不会影响到其他进程的内存空间。这通过控制页表中的权限位来实现,比如读/写/执行权限。
- 内存共享:通过设置相同的映射,MMU 可以使得多个进程共享相同的物理内存区域。这对于进程间通信和共享代码库(例如,操作系统的库或者动态链接库)非常有用。
- 分页和分段:MMU 支持分页机制,可以将虚拟内存空间分为多个固定大小的页,并独立地将每个页映射到物理内存空间的任意位置。分段机制则允许将内存分为不同大小的段。
- 虚拟内存管理:MMU 的存在使得操作系统可以实现虚拟内存,即使物理内存已满,系统依然可以通过页面置换算法,将部分数据临时存储到硬盘的交换空间(swap space),从而扩展可用的内存空间。
- 缓存管理:MMU 还负责缓存控制,包括 TLB(Translation Lookaside Buffer)的管理。TLB 是一个专用的缓存,用于加速虚拟地址到物理地址转换过程中的页表查找。
地址转换
地址转换这部分的逻辑稍微有些复杂, 我们一点点的捋出来
整体来说, 内存的地址转换需要考虑一下几个方面
- 效率
- 动态
- 权限控制
Base + Bound (基址加界限)
这是一个最容易想到的方法, 就是直接使用一个 Base 作为物理内存的起点, Bound 表示其在物理内存的重点, 传入的虚拟地址表示偏移
当一个进程尝试访问内存时,CPU 会将进程产生的逻辑地址(相对地址)与基址寄存器的内容相加,转换成物理地址。如果计算出的物理地址超出了界限寄存器设定的范围,则会触发一个越界错误,通常会导致操作系统的干预。
这个方法虽然简单,但也有以下几个缺点:
- 固定分区大小:每个进程都需要在启动时确定其内存需求,这可能导致内存的浪费,因为进程可能不会使用到所有分配给它的内存。
- 内存碎片:随着进程的加载和卸载,内存中会出现无法使用的小空间,即产生碎片。
- 内存保护问题:如果操作系统中有 bug 或者某个进程能够篡改基址或界限寄存器的值,就可能破坏内存保护机制,访问或者污染其他进程的内存空间。
- 不支持多任务:这种方法不容易支持多任务操作,因为进程间的内存切换会变得复杂和开销较大。
基于 Segment
基于段(Segmentation)的内存管理是对基址加界限方法的一种改进。它的核心思想是将程序的逻辑地址空间划分为多个有意义的单元,称为“段”(Segment)。通常,每个段对应程序的一个逻辑结构,例如代码段、数据段、堆栈段等。这样的划分更加符合程序的逻辑结构,也有助于实现更细粒度的内存保护和共享。
在基于段的内存管理中,每个段都由一个段基址(Segment Base)和一个段界限(Segment Limit)来定义,类似于基址加界限方法中的基址寄存器和界限寄存器。但与基址加界限方法不同的是,基于段的方法允许每个段有不同的长度,更加灵活。
过程:
- 当程序想要访问内存时,它会生成一个由段号(Segment Number)和段内偏移(Offset)组成的逻辑地址。
- CPU 中的内存管理单元(MMU)会根据段号找到对应的段基址,将段内偏移与段基址相加得到物理地址。
- 同时,MMU 会检查偏移是否超过了段界限,以保证不会访问到当前段之外的内存区域。
关键特性:
- 保护和共享:通过对不同段设置不同的访问权限,可以实现内存保护。例如,代码段可以设置为只读,以防止程序代码被意外修改。另外,多个进程可以共享同一个段,如共享库等。
- 逻辑结构对齐:由于每个段通常对应程序中的一个逻辑部分,因此内存管理和程序结构紧密对应,这有助于程序的模块化和维护。
- 动态增长:某些段如堆栈段和堆段可以在运行时动态增长,因此段机制能够更好地支持动态内存分配。
- 减少内存碎片:虽然段式管理仍然可能产生外部碎片,但是由于可以根据需要为每个段分配合适大小的内存块,因此可以在一定程度上减少内存碎片。
- 地址隔离:不同的段可以独立定位,各个段之间地址空间隔离,提高了内存的使用效率。
缺陷:
- 内部碎片(Internal Fragmentation):由于每个段是连续分配的,当程序在一个段中申请的内存没有完全使用时,剩余的部分会产生内部碎片。例如,如果一个数据段分配了 1KB 的内存,而实际只用了 900B,那么剩下的 100B 就无法被其他段利用,从而造成浪费。
- 段的大小调整问题:如果一个段需要扩展,可能因为紧邻该段的内存空间已被占用,无法直接扩展,这时不得不移动该段到一个更大的空间去,这种操作会增加系统的开销,并可能导致额外的碎片产生。
基于 Page
分页(Paging)是现代操作系统中广泛采用的内存管理技术。与基于段的内存管理不同,分页管理将物理内存和虚拟内存都划分成了大小相同的块,称为“页”(Page)和“页框”(Page Frame)。
在分页系统中,虚拟内存空间由一系列连续的页组成,物理内存则由一系列连续的页框组成。虚拟地址到物理地址的映射是通过一个中间的映射结构——页表(Page Table)来完成的。每个运行的进程都有自己的页表。
关键概念:
- 页(Page) :是虚拟内存被划分的基本单位。
- 页框(Page Frame) :是物理内存被划分的基本单位,页和页框的大小是一致的。
- 页表(Page Table) :是操作系统维护的一个数据结构,用于记录虚拟页面到物理页框的映射关系。
- 虚拟地址(Virtual Address) :由进程生成,由页号(Page Number)和页内偏移(Offset)组成。
- 物理地址(Physical Address) :是实际存储数据的物理内存地址,由页框号(Frame Number)和页内偏移组成。
优点
- 消除外部碎片:由于所有的页和页框大小相同,不会像基址加界限或分段管理那样产生外部碎片。
- 灵活的内存分配:可以非连续地分配内存,一个进程的不同页可以分散在物理内存中的任何位置。
- 保护和隔离:每个页表条目可以有自己的保护位,确保进程间的内存是彼此隔离和保护的。
缺点
-
内部碎片:每个页面的内存利用可能不是 100%,最后一页尤其可能有未使用的空间。
-
地址转换开销:访问内存的次数变得更多了, 会导致耗时变长.
-
页表可能很大:页表大小与虚拟地址空间的大小成正比,对于具有大量内存的系统,页表占用的内存可能相当可观。
我们可以稍微估算下, 假如 Frame 大小为 16KB(即 214), 那么页表的大小会达到 250
Segment + 单层 Page
进程的内存先分成多个段,每个段再按照页来分配。
地址分三个部分:段号 + 页号 + 页内偏移
根据段号去段表里面找对应的页表地址,得到页表的地址后,根据页号找到对应的物理内存的 Page Frame 地址,最后再结合页内偏移计算得到实际地址,其中段表中的 Size 是指某一段对应的页表的长度,即物理页的数目。
以 32 位虚拟地址空间,4KB 大小的 Page 为例,前 10bit 用于段号,中间 10 位用于页号,后 12 位用于页内偏移,假设页表的每一行的大小是 4 个字节,则一个物理 Page Frame 正好可以容下每个段的页表。
多层页表
多级页表(Multi-level Page Table)是为了解决单级页表因虚拟地址空间过大导致页表本身也非常庞大的问题。在32位或64位的系统中,虚拟地址空间可能非常巨大,如果使用单级页表,那么页表必须为整个虚拟空间中的每个页都保留一个条目,这样的页表将非常巨大,占用大量的物理内存。
多级页表通过层次化的结构,将这个大页表分解为更小的表,以此减少所需的物理内存。常见的有两级页表(也称二级页表)、三级页表、甚至四级页表。其中,二级页表是最常见的结构。
二级页表的工作原理:
- 顶级页表(Page Directory) :虚拟地址的高位部分用来索引顶级页表,顶级页表的每个条目指向第二级页表。
- 第二级页表(Page Table) :顶级页表中的条目会指向一个第二级页表,虚拟地址的中间位用来索引这个第二级页表。
- 页框号(Page Frame Number) :通过第二级页表可以得到页框号,加上虚拟地址中的低位偏移量,就可以得到完整的物理地址。
优点:
- 减少内存占用:如果某个顶级页表条目对应的整个第二级页表都未被使用,则无需为其分配内存,从而节省了内存资源。
- 灵活性:可以根据虚拟地址的使用情况动态地添加或删除第二级页表,适应不同程序的内存需求。
缺点:
- 增加访问时间:由于需要多次内存访问来解析地址,因此可能会稍微增加虚拟地址到物理地址转换的时间。
- 复杂性:多级页表的管理比单级页表复杂,需要更多的算法和数据结构来维护。
Segment + 多层页表(X86-64的处理方式)
在64位模式下,段表主要提供兼容性支持和某些特定的安全功能,而内存管理的主要职责已经转移到了分页系统。
段表在x86-64中的角色:
在64位模式下,x86-64架构几乎不使用传统的段式内存管理。
各个段寄存器(如CS, DS, ES, SS等)仍然存在,但主要用于特权级检查和存储一些状态信息。段的基地址固定为0,限长固定为最大可能值,这样实际的线性地址就等同于虚拟地址。这意味着,尽管段仍然存在,但它们不再用于分割或隔离内存,而是提供了一种简单的安全和特权级别检查方式。
多级页表在x86-64中的应用:
在x86-64架构下,分页是现代操作系统进行内存管理的核心。x86-64架构的分页系统支持多达4个级别的页表,通常指的是:
- PML4(Page Map Level 4) :这是最顶级的页表,每个PML4条目指向一个PDP(Page Directory Pointer table)。
- PDP(Page Directory Pointer) :PDP的每个条目指向一个PD(Page Directory)。
- PD(Page Directory) :PD的每个条目指向一个PT(Page Table),或者在启用1GB大页的情况下直接映射到一个页框。
- PT(Page Table) :PT的每个条目指向一个4KB的页框,或者在启用2MB大页的情况下直接映射到一个页框。
转换过程:
当CPU需要访问虚拟内存时,它会通过以下步骤来将虚拟地址转换为物理地址:
- 使用虚拟地址中的PML4部分(通常是最高的几位)来索引PML4表,找到对应的PDP。
- 使用虚拟地址中的PDP部分来索引PDP表,找到对应的PD。
- 使用虚拟地址中的PD部分来索引PD表,找到对应的PT(对于1GB的大页,这一步中PD条目直接指向物理内存)。
- 使用虚拟地址中的PT部分来索引PT表,找到4KB页框(对于2MB的大页,这一步中PT条目直接指向物理内存)。
- 最后,虚拟地址中的偏移量被加到页框物理地址中,得到完整的物理地址。
总结
通过以上的方式, 我们最终解决了表过大, 内存分配不灵活等等问题, 但是同时又出现了新的问题
以Segment + 多层页表
的方式来举例, 我们想要定位到一个物理页时, 要经过几次的查表才能获得结果(Segment Table, PML4, PDP, PD), 换言之, 我们为了虚拟内存的一次访问, 实际的成本可能是3次/4次, 这无疑是存在巨大的问题的.
TLB (Translation lookaside buffer)
其实我们可以想到, 在一个进程中, 程序执行并不是跳跃性的, 而是按照一定的逻辑
换言之, 内存的需求是有一定的内聚性的, 即在一段时间内, 一块内存如果被访问, 那么很大概率它会被接着访问
在说的简单点: Cache, 我们可以将 虚拟内存地址 -> 物理内存的过策看做是一个求职函数 $Tranlaction()$, 传入虚拟地址, 返回物理地址, 这就使缓存
成为了可能.
TLB就像一个中间代理,它缓存了最近使用的一部分页表条目。当虚拟地址需要转换时,CPU首先会检查TLB是否包含该地址的转换信息。如果找到(这称为TLB命中),那么地址转换几乎是即时的。如果没有找到(TLB未命中),则需要从主内存中的页表中检索转换信息,并将其加载到TLB中以供后续使用。
TLB的作用:
- 减少延迟:TLB大大减少了虚拟地址转换为物理地址所需的时间,因为它避免了对主内存中页表的频繁访问。
- 提高效率:由于程序访问内存的局部性原理,即程序倾向于在短时间内多次访问同一块内存区域,TLB可以高效地服务于这些频繁访问的地址转换。
- 降低内存压力:通过减少对页表的查询次数,TLB可以减轻主内存的访问压力,从而提高系统的总体性能。
TLB的设计和优化:
- 条目数量:TLB可以存储的页表条目的数量。这个大小直接影响TLB的命中率,但是更大的TLB意味着更高的成本和可能的能耗。
- 关联度:TLB可以是全关联、组关联或直接映射。全关联TLB允许任何条目可以存储在TLB的任何位置上,这提供了最高的灵活性,但实现起来也最昂贵和复杂。
- 替换策略:当TLB满了而需要加载新的地址转换信息时,它需要决定哪个旧条目被替换。常见的替换策略包括最近最少使用(LRU)和随机替换。
TLB失效和维护:
当进程上下文切换发生或者页表更新时,TLB中的某些条目可能会变得陈旧或无效。在这种情况下,操作系统需要更新TLB,这可以通过刷新整个TLB或仅刷新受影响的条目来完成。为了减轻操作系统的负担,现代处理器还提供了硬件辅助的TLB管理功能。