CSAPP-虚拟内存
地址空间
计算机通过CPU和操作系统的紧密结合,构建了被称为虚拟寻址的技术。
虚拟寻址:CPU不再直接访问内存,而是通过一个地址翻译(MMU)单元将虚拟地址映射到物理地址上。
内存被抽象为一个巨大的数组,其中地址空间是一个非负整数地址的有序集合,就像是数组的下标。地址空间的大小是由表示最大地址所需的位数来决定的,比如64位操作系统的地址空间为 \([0,...,2^{64}-1]\) 。
虚拟内存
虚拟内存被当作存放在磁盘上的连续数组,每字节均存在唯一对应的虚拟地址作为索引。我们常说的内存,被当作是这个数组的缓存。
虚拟内存和物理内存都被划分为大小固定的块,这些块分别称为“虚拟页”和“页帧”。虚拟页和页帧通常具有相同的大小(例如4KB),但虚拟页不一定总是被映射到物理内存中,部分虚拟页可能会被换出到磁盘上的交换区。
由上可知,一个虚拟内存中的虚拟页有三种状态:
- 未分配的:该虚拟页还没有映射到物理内存或交换区,尚未被操作系统分配。
- 未缓存的:该虚拟页已分配,但尚未被加载到物理内存中,可能存放在磁盘的交换区。
- 已缓存的:该虚拟页已经被加载到物理内存中,处于内存中的有效状态。
页表
页表是一种存放在内存中的数据结构,负责将虚拟页映射至物理页。当程序访问虚拟地址时,操作系统会通过页表条目查找相应的物理页地址,完成虚拟地址到物理地址的转换。
页表条目
页表是一个数组,存储着页表条目
元素。页表条目可以被抽象为一个有效位和物理页号或磁盘地址构成的条目。有效位决定了地址是该页表是否已被分配,如果无效,意味着该虚拟页未映射到物理内存或磁盘。
缺页异常:当试图访问一个并未缓存在DRAM(内存)中的页表时,操作系统会触发缺页异常,导致进程从用户态切换到内核态。操作系统会根据页表的映射信息,查找该虚拟页对应的物理页。如果该页在磁盘上,操作系统将从磁盘加载该页到内存。若物理内存已满,操作系统将选择一个页进行换出,这个页被称为“牺牲页”。如果该牺牲页被修改过,操作系统会将其内容写回磁盘。由于磁盘的性能很慢,频繁的缺页会导致程序性能下降。
值得一提的是,操作系统为每个进程维护一份独立的页表,而多个虚拟页面有时可以映射到同一个物理内存页上。这种映射关系为进程间的写时复制提供了可能。
尽管我们之前一直将地址空间描述为连续的,但由于虚拟内存和物理内存之间存在映射关系,实际上物理内存的存储可能是不连续的。也就是说,虚拟页面可能会映射到物理内存中的不同位置,这些物理页帧可以是分散存储的。
Linux 虚拟内存系统
虚拟内存可以被分为两部分:内核虚拟内存、进程虚拟内存。
内核虚拟内存:内核虚拟内存是由操作系统内核进行管理,并且该部分内存的分配是固定的。它的主要内容包括:
- 每个进程都独立的部分:如页表、内核栈、任务结构(
task_struct
)。 - 每个进程共享的部分:如内核代码、内核数据等。
内核虚拟内存的使用范围通常是内核空间,且所有进程共享同一个内核地址空间。
进程虚拟内存:每个进程都有自己的独立虚拟地址空间,其中包括以下几个主要区域:
- 代码段:存储程序的指令。
- 数据段:存储静态数据和全局变量。
- 堆:用于动态分配内存(如
malloc
)。 - 栈:用于存储局部变量和函数调用信息。
段(区域)
段(又称区域)是用来划分不同类型的内存区域。每个段包含多个虚拟内存页。常见的段包括代码段、数据段、堆、栈等。所有已分配的虚拟页均属于某个段,不存在不属于某个段的已分配虚拟页。
缺页异常
当进程触发了缺页异常时,Linux 内核会执行以下步骤:
- 虚拟地址是否合法:准备搜索的地址是否属于某个区域,如果不是将引发段错误(Segmentation Fault),导致进程被终止。
- 访问操作是否合法:如果虚拟地址合法,内核将检查访问操作是否符合权限。比如,检查进程是否试图在只读区域进行写操作,或用户进程是否试图访问内核空间。如果访问不合法,进程将被终止。
- 加载缺失的内存页:如果虚拟地址有效且操作合法,内核将通过页表查找对应的物理内存。如果该页尚未加载到物理内存中,内核会将该页从磁盘加载到物理内存,并更新页表。然后,进程可以重新执行原来的操作,继续访问加载的内存页。
当我读到这里时,我有个疑惑:CSAPP 中总是提及页面异常有一步“选择牺牲页,然后交换出去。”那么,发生以下有两种情况会如何?
- 有大量的空闲内存可用时,操作系统为何不使用空闲内存进行分配。
- 如果我关闭了swap分区,那么我还会触发页面异常吗?
我试图搜索了一下,以下是我的收获:
Assuming there is free memory available, the initial page fault will grab a page of free memory and zero it out. This is no slower than grabbing a page of free memory and zeroing it out at allocation time.——Microsoft Dev Blogs
假设有空闲内存可用,初始的缺页异常会分配一页空闲内存并将其清零。这一操作的速度与在分配时获取一页空闲内存并将其清零一样快。——Microsoft Dev Blogs
也就是说,如果系统有足够的空闲内存,操作系统会直接将该虚拟内存页映射到一块空闲的物理内存中。此时,不需要通过磁盘或 swap 来加载页面,因为空闲内存已经可以满足需求。
那么,如果我关闭了 swap 分区,操作系统将面对两种可能:
- 有空闲内存,那么就正常使用空闲内存即可。
- 没有空闲内存:杀掉进程来腾出空间;或者直接拒绝内存请求。
同时,我也意识到这是操作系统用来增加一个进程可用内存的机制:当进程需要更多内存时(比如栈的增长和手动 malloc() 请求),操作系统会先为其分配一个新的内存页。然后等到进程实际上访问该内存页时通过触发缺页异常来实际增加内存。
内存映射
虚拟内存的内存页可以被分为两种类型:普通文件的映射,匿名文件的映射。
普通文件的映射:当程序需要访问硬盘上的文件时,操作系统通过内存映射将文件的一部分(通常是以页为单位)映射到进程的虚拟内存空间。只有在程序访问这些内存页时,操作系统才会实际地将文件的内容加载到物理内存中。
匿名文件的映射:匿名映射是由操作系统内核创建的内存区域,不与任何文件对应。它通常用于程序运行时的内存分配需求,例如堆空间的分配,以及进程的栈空间。
共享对象
大部分程序中存在大量共用的库文件,为了避免每个进程单独加载库文件所带来的内存开销。内存映射提供了多个进程共享对象的机制。
一个对象被映射到内存区域时,要么成为共享对象,要么成为私有对象。
共享对象:当一个进程对共享对象进行了合法的写操作,改变了对象的内容时其他进程也将同步这份修改,因为共享对象是同一份内容的映射。
私有对象:私有对象被进程合法修改时,其他进程是不可见的。同时,这份修改也不会被同步至磁盘的原文件中。创建子进程时的写时复制操作针对的就是私有对象。
写时复制
一开始,父进程和子进程将同一份物理副本映射至自身虚拟内存的不同区域。操作系统将这些私有对象的页表条目设置为只读,以防止进程在不触发写时复制的情况下修改内存。同时,整个区域被标记为私有的写时复制。
当一个进程修改了这部分内容,将触发保护异常。然后,操作系统将会发现进程在更改私有的写时复制区域,那么操作系统将会创建这个页面的新副本,更新页表条目将其指向这份副本并恢复写权限。接着返回程序继续运行。
创建内存映射
Linux 中可以使用 mmap 函数来创建新的虚拟内存区域,并将对象映射到这些区域中。
1 |
|