这边跳过了21章和22章,其中21章主要是粗略的讲了下内存交换到硬盘。22章主要讲的是内存中的页本身的汰换算法。
23章通过分析探讨2套操作系统的内存管理实现,将之前的内容串联起来了
VAX/VMS
VAX-11微型计算机架构是在1970年代后期由数字设备公司(DEC)推出的。在微型计算机时代,DEC是计算机行业的重要参与者;不幸的是,一系列糟糕的决定和PC的出现缓慢地(但必然地)导致了它们的消亡[C03]。
内存管理
VAX-11的内存管理通过页来进行管理,使用的方式是之前提到过得段+页的方式,即段用来表示页表存在的位置。以此来节省空间。
VAX-11还做了2个点来优化内存的使用:
页表空间优化
首先,VAX-11系统通过将用户地址空间分成两个部分(P0和P1),只为实际使用的部分分配页表空间。堆和堆栈之间未使用的地址空间不需要页表。这通过以下机制实现:
- 基址和界限寄存器:基址寄存器(Base Register)存储该段的页表的起始地址,界限寄存器(Bounds Register)存储页表的大小(即页表条目的数量)。通过这种方式,只需为实际使用的地址空间分配页表条目。
页表存放在内核虚拟内存中
进一步,操作系统将用户页表(P0和P1的页表,因而每个进程有两个页表)放在内核虚拟内存中。具体来说:
- 内核分配页表空间:当需要分配或扩展页表时,内核从其虚拟内存空间(S段)中分配空间。如果内存压力过大,内核可以将这些页表的页面交换到磁盘,从而腾出物理内存供其他用途
物理地址空间
VAX-11的另一个内存管理在于它对物理内存的分配。
主要也是有2个点:
- 开头的page0 invalid标记:使用这种方式,可以让操作系统方便的处理空指针——只需要把空对象指到这页,就可以很方便的做检查了。
- 底部的system区域:这部分用于在物理内存中保存内核的代码、数据等。使用这种方式保存内核的好处在于,在对用户进程进行处理时,只需要在用户进程的页表中增加记录映射到内核的物理页,就可以让内核以library库的形式被用户进程来调用使用。当然,这也会带来了一个缺点:内核的页将很难被交换到虚拟内存(硬盘)中去,因为他们会一直被使用,如果想要让它们可以被交换的话,那么又需要更加复杂的设计和性能开销来支持。
这里也带到了一个概念,就我们常说的用户态和内核态,虽然用户进程的页表里保存了内核的一些物理页的映射,但是内核的页会通过页表的标记来让CPU知道这一页必须得具备什么特权等级才可以访问。所以也就是为什么产生系统中断或者使用一些内核函数的时候,必须要切换到内核态才可以的原因。
页面汰换算法
这章有个很有意思的词:memory hogs。hogs本义是猪、贪婪的人。XX hogs就表示占用内存大的程序。
这章主要讨论VAX-11的页面汰换算法选择,LRU存在一个比较致命的问题:它无法保证进程间的内存使用公平。
想想也知道,某个进程如果使用的少,或者说某段时间内某个进程大量使用内存,那么LRU会导致除了这个进程外其他所有进程的所有页全部被汰换到虚拟内存中去。想想这样的结果:在你使用另一个进程时,等于直接从硬盘中重新读取这个进程的整个内存,这会有多慢?
VAX-11的优化方法是,使用分段FIFO替换策略。这个想法很简单:每个进程都有它可以在内存中保留的最大页数,称为其常驻集大小(RSS)。每一页都保存在FIFO列表中;当一个进程超过它的RSS时,“先入”页面将被驱逐。FIFO显然不需要硬件的任何支持,因此很容易实现。
当然,正如我们前面看到的那样,纯FIFO的表现并不特别好。为了提高FIFO的性能,VMS引入了两个第二次机会列表,其中页面在从内存中被驱逐之前被放置,具体来说是一个全局clean页列表和一个dirty页面列表(这里其实主要表示的还是物理页)。当一个进程P的页数超过它的RSS时,一个页面将从它的每个进程FIFO中移除;如果是clean的(未修改),则放在clean页列表的末尾;如果是dirty的(修改过的),它被放在脏页列表的末尾。
如果另一个进程Q需要一个空闲页,它会从全局clean列表中取出第一个空闲页。但是,如果原始进程P在回收之前在该页上出现错误(P又访问到了这一页,产生了缺页中断),则P会从空闲(或脏)列表中回收该页,从而避免代价高昂的磁盘访问。这些全局第二次机会列表越大,分段FIFO算法执行得越接近LRU [RL81]。
VMS中使用的另一个优化也有助于克服VMS中的小页面大小。具体来说,对于如此小的页面,交换期间的磁盘I/O效率可能非常低,因为磁盘在处理大的传输时会做得更好。为了提高I/O交换的效率,VMS增加了许多优化,但最重要的是cluster(这里不知道该怎么翻译,总而言之就是批量写)。通过cluster,VMS将全局脏列表中的大量页面分组在一起,并写入它们(其实就是脏页批量更新)。
页面初始化0
在用户进程申请内存的时候,操作系统给用户进程分配一个物理页并映射到这个进程的虚拟页表中,这个时候无论这个物理页之前是什么状态,对这个用户进程来说,这个页也应该是初始化之后的,每个地址的值应该都是0。
操作系统使用懒加载来处理这个问题,来降低实际的性能开销,在分配一个页时,操作系统并不会立马把内存都初始化为0,而是给页增加一个标记,标记这个页为一个特殊状态。当用户进程实际发生对这个页的访问或者操作时,操作系统根据这个标记发生中断,此时再进行页的初始化。
这种懒加载唯一的好处就是,大部分时候进程申请后,并不会马上访问内存页,这样就将内存页初始化的开销分散到了使用时。比如我们直接申请一个int[100000]的内存,此时使用到很多页,但是我们遍历和使用这个数组,大概率在时间上是稀疏的。这很好的分散了内存页初始化的开销。
页面的copy on write
这也是一种懒加载,而且是很有名的一种处理方式。当一个进程需要使用一个已有的页时,操作系统总是先将该页添加到这个进程的页表中,此时可以进行正常的读等操作(因为这对页没有影响)。只有当进程需要对这个页进行修改的时候,才产生一个中断并从新复制一个物理页,并将页表指向复制出的物理页,再做修改。
这大大提升了内存的性能,任何类型的共享库都可以通过COW先映射到许多进程的地址空间中,在写时才复制,从而节省宝贵的内存空间。在UNIX系统中,由于fork()和exec()的语义,COW更为重要。我们产生子进程或者线程时,不需要马上复制所有进程空间,而是只需要增加页表的映射,当实际进行修改时,再进行这个操作。
本质上,这实现了对通过页表来对物理页一定程度上的“复用”,只有在真的产生修改时,才去复制一个物理页来支持这种操作。
Linux
Linux虚拟地址空间
用户空间和内核空间
- 用户空间:包括用户程序代码、堆栈、堆和其他部分。在上下文切换时,当前运行进程的用户空间会改变。
- 内核空间:包括内核代码、堆栈、堆和其他部分。在所有进程中保持不变。运行在用户模式下的程序无法访问内核的虚拟页面,只有通过陷入内核并切换到特权模式才能访问这些内存。
32位Linux中的地址空间划分
- 地址空间分为两部分:
- 用户空间:从0x00000000到0xBFFFFFFF(即地址空间的前3/4)。
- 内核空间:从0xC0000000到0xFFFFFFFF(即地址空间的最后1/4)。
内核虚拟地址的两种类型
- 内核逻辑地址:
- 这是内核的标准虚拟地址空间。通过
kmalloc
分配内存。 - 内核数据结构,如页表和每个进程的内核堆栈,通常驻留在这里。
- 内核逻辑内存无法被交换到磁盘。
- 内核逻辑地址和物理内存的前部分有直接映射关系。例如:
- 0xC0000000的内核逻辑地址映射到物理地址0x00000000。
- 0xC0000FFF的内核逻辑地址映射到物理地址0x00000FFF。
- 这种直接映射有两个重要影响:
- 可以简单地在内核逻辑地址和物理地址之间转换。
- 在内核逻辑地址空间中连续的内存块在物理内存中也是连续的,这对需要连续物理内存的操作(如DMA)很重要。
- 内核虚拟地址:
- 通过
vmalloc
分配内存。 - 返回的指针指向虚拟上连续的内存区域,但物理上不一定连续,因此不适用于DMA。
- 内核虚拟地址空间更易于分配,适用于需要大缓冲区的场景,因为找到一大块连续的物理内存可能很困难。
页表结构
Linux页表现在在64位的操作系统下,页表分为四级页表了,unused留着以后需要五六级时使用
大页支持
机器内存越来越大的情况下,从CPU开始,慢慢的对更大的页提供了支持,更大的页的好处是更低的多级页表层级,以及更快的存取性能,最重要的是,页数量的减少,可以大大提升TLB的速度,这对页表寻址的提升是质变。
当然,大页的开销依然存在,也就是我们老生常谈的大页内部碎片的问题。
页的cache
为了减少访问持久存储的成本(这是本书第三部分的重点),大多数系统使用积极的缓存子系统,将常用数据保存在内存中。Linux在这方面与传统操作系统没有不同。
Linux的页缓存是统一的,维护来自三种主要来源的内存页:
- 内存映射文件
- 设备的文件数据和元数据(通常通过文件系统的读写调用访问)
- 每个进程的堆和堆栈页(有时称为匿名内存,因为没有关联的命名文件,而是使用交换空间)
这些数据保存在页缓存哈希表中,以便在需要时快速查找。
页缓存的状态
页缓存跟踪条目是否为干净的(读取但未更新)或脏的(即,已修改)。后台线程(称为pdflush
)定期将脏数据写入后备存储(对于文件数据写入特定文件,对于匿名区域写入交换空间),确保修改的数据最终写回持久存储。这个后台活动要么在一定时间间隔后进行,要么在太多页被标记为脏页时进行(这两个参数都是可配置的)。
内存不足时的页面替换
当系统内存不足时,Linux需要决定哪些页需要从内存中移出以释放空间。为此,Linux使用了一种修改过的2Q替换算法。
2Q替换算法
标准的LRU(最近最少使用)替换算法虽然有效,但可能会被某些常见的访问模式破坏。例如,如果一个进程反复访问一个大文件(尤其是几乎与内存大小相同或更大的文件),LRU会将内存中的其他文件全部踢出。而且,保留该文件的一部分在内存中并没有用,因为它们在被踢出内存之前从未被再次引用。
Linux的2Q替换算法通过维护两个列表并在它们之间划分内存来解决这个问题。当页面第一次被访问时,它被放在一个队列中(在原始论文中称为A1,在Linux中称为不活跃列表);当页面被重新引用时,它被提升到另一个队列(在原始论文中称为Aq,在Linux中称为活跃列表)。需要替换时,替换候选项从不活跃列表中取出。Linux还定期将页面从活跃列表的底部移到不活跃列表,保持活跃列表大约占整个页缓存大小的三分之二。
近似LRU
虽然理想情况下Linux会按完美的LRU顺序管理这些列表,但如前几章所述,这样做成本很高。因此,与许多操作系统一样,Linux使用一种近似LRU的方法(类似于时钟替换)。
这种2Q方法总体上表现得非常像LRU,但在处理循环大文件访问的情况下表现尤为出色。通过将循环访问的页限制在不活跃列表中,因为这些页在被踢出内存前不会被再次引用,它们不会将活跃列表中的其他有用页清除出去。
内存攻击
内存攻击主要有以下几种,这里就不展开了:
-
缓冲区溢出,通过拷贝内存溢出,将构造的代码或者数据溢出到其他内存页,以修改内存数据或者构造指定的代码来执行
-
返回重定向,攻击者覆盖堆栈,使函数执行的返回跳转到自己指定的位置,以此来执行自己的代码。典型的解决方式就是随机返回地址,这样就可以让这种攻击难以猜测到需要跳转到的地址,错误的跳转会导致进程崩溃
-
Meltdown和Spectre攻击,这两个难度太高,贴一下gpt的解释:
Meltdown
基本原理
Meltdown攻击利用了现代处理器在处理用户和内核之间的权限隔离上的漏洞。它通过推测执行和时序分析,允许攻击者读取本应无法访问的内核内存。
工作机制
- 推测执行:现代处理器为了提高性能,会在分支预测后执行某些可能不需要的指令。这些指令在正式验证前就被执行,如果预测错误,结果会被丢弃。
- 权限检查延迟:处理器在执行某些指令时,会延迟权限检查。Meltdown利用这个延迟窗口,通过推测执行访问内核空间的数据。
- 时序分析:攻击者通过观察缓存的状态变化来推测内核内存的内容。具体来说,Meltdown会通过引入缓存命中和未命中的时序差异来读取内核数据。
影响
Meltdown影响了许多使用推测执行的现代处理器,包括Intel处理器。通过Meltdown,攻击者可以绕过系统的内存保护机制,读取任意物理内存中的数据。
Spectre
基本原理
Spectre攻击利用了推测执行中的分支预测漏洞,使得攻击者可以诱导处理器执行错误的推测指令,从而间接读取其他进程的内存数据。
工作机制
- 分支预测注入:攻击者通过精心构造的输入数据来训练处理器的分支预测器,使其在实际分支中进行错误预测。
- 推测执行:处理器在错误预测的情况下,执行了某些不应被执行的指令。这些指令会访问某些敏感数据。
- 侧信道攻击:通过时序分析或缓存状态变化,攻击者可以推测出被错误执行的推测指令访问到的敏感数据。
影响
Spectre影响了几乎所有现代处理器,包括Intel、AMD和ARM处理器。不同于Meltdown,Spectre不仅影响用户态和内核态之间的隔离,还影响不同进程之间的隔离。
防御措施
- 软件补丁:操作系统和应用程序开发者可以通过补丁来缓解这些攻击。例如,内核页面表隔离(KPTI)可以有效防御Meltdown。
- 硬件修复:处理器制造商(如Intel和AMD)正在设计和发布新硬件,来从底层解决这些漏洞。
- 编译器改进:编译器可以插入一些防御机制,来阻止某些推测执行路径被利用。