什么是内存(二):虚拟内存

发布时间:2026/7/3 2:20:54
什么是内存(二):虚拟内存 通过上一篇文章的扯淡我们应该已经明白了存储器的层次结构技术细节很复杂但是思想却不难理解因为就是很简单的缓存思想。那么本文我们开始讨论关于内存的另一个话题.虚拟内存。其实思想也是很容易理解的。我不知道有多少人听过虚拟内存这个概念但是虚拟内存是计算机系统最重要的概念之一并且它成功的主要原因就是它一直在沉默的自动的工作换句话说我们这些做应用的程序员根本不需要干涉它的工作过程但是一个没追求的码农不是好的搬砖民工所以作为一个有理想有抱负的程序员我们还是要去理解虚拟内存甚至可以这样说如果不理解虚拟内存你根本不可能理解程序的深层次运行原理。也不可能去理解汇编器链接器加载器共享对象文件和进程等概念。上篇文章中提出了几个让大家思考的问题:不管什么程序最后的直接/间接的编译结果都是0和1(我们直接理解为汇编)。(这点不知道的欢迎阅读我的另一篇文章关于跨平台的一些认识)比如这句汇编代码mov eax,0x123456;它的意思是将内存0x123456处的内容送往eax这个寄存器。各个应用的数据共同存在内存中的。假设有一个音乐播放器应用的汇编代码中引用了0x123456这个内存地址。但是同时运行的应用有很多那其他应用也完全有可能引用0x123456这个地址。那为什么竟然没起冲突和错误呢进程是计算机领域最重要的概念之一什么是进程进程是关于某次数据集合的一次运行活动 是运行在它自己地址空间的一段自包容程序 解释的通俗的点 一个程序在运行时我们会得到一个假象该进程好像是独占地使用CPU和内存CPU是没有间断地一条接一条的执行该程序的指令所有的内存空间都是供该进程的代码和数据分配使用的。(这点不严谨其实内存还有一部分要分给内核kernel)。说起来这个程序就好像得到了全世界一样。CPU是我的内存也全部我的妹子们还是我的。当然这是假象而已。但是这些假象又是怎么做到的呢程序中都会引用库API比如每个C程序都要引用stdio.h库的printf()在程序运行时库代码也要被加入到内存这么多程序都引用了这个库难道我内存中需要加很多份吗这自然不可能那么库代码又是怎么被所有进程共享的呢这些让我们细思恐极的疑问都将通过这篇文章来给大家解答。物理和虚拟寻址在访问者看来主存就是一个有M个字节大小的单元组成的数组每字节都有一个唯一的物理地址(Physical Address, PA)。 它的访问地址和数组一样第一个地址为0后面地址依次为1,2,3-----M-2, M-1;这叫做线性地址空间。这种自然的访问内存的方式我们称之为物理寻址(physical addressing)。注意在访问内存时对于任意一个地址(不管是第0个还是第M-1个)访问该地址的时间总是相同的。在各种数据结构中我们都说hash表是最快的比红黑树之类的都要快那hash表为什么最快那是因为hash表内部本质上是使用了数组。所以还是数组最快那数组为什么最快这是因为我们知道数组的起始地址以及某个元素的序号就可以得到该元素在内存中的地址而对于内存访问任意一个地址访问时间总是相同的。而类似链表树等结构却只能靠遍历了。(不过好的hash算法还是很难设计的这是另外一个话题了)。图10一个使用物理寻址的系统上图是一个物理寻址的示例这是一条加载指令它读取从物理地址4开始的4个字节CPU通过内存总线将指令和地址传递给主存主存读取从物理地址4处开始的4个字节返回给CPU。因为这篇文章主要讨论 虚拟内存是关于L4级主存和磁盘之间的交互问题为行文方便文章中有时候直接说内存代指主存。所以这些不要误以为是指L1L2之类的缓存。如果看不懂这段话啥意思务必看看我的上一篇文章什么是内存一存储器层次结构,然后再来看这篇文章。早期计算机使用物理寻址方式但是到了现在的多任务计算机时代普遍使用的是虚拟寻址(virtual addressing)。如下图所示图11一个使用虚拟寻址的系统CPU 通过一个虚拟地址virtual address,VA来访问主存这个虚拟地址在被送到主存之前会先转换成一个物理地址。将虚拟地址转换成物理地址的任务叫做地址翻译address translation。地址翻译需要 CPU 硬件和操作系统之间的配合。 CPU 芯片上叫做内存管理单元Menory Management Unit, MMU的专用硬件利用存放在主存中的查询表来动态翻译虚拟地址该表的内容由操作系统管理。有少数现代计算机系统依旧在使用物理寻址方式比如DSP嵌入式系统超级计算机系统。这些系统的主要任务是执行单一任务不像通用性计算机那样需要执行多任务。可以想象到物理寻址方式更快。这个道理和关于跨平台的一些认识文章中理论上java比C慢的道理是一样的。前面解释完虚拟地址那么关于文章开头时提的那些疑问可能有些人心里面都有数了。因为那些地址都是虚拟地址并非真实的物理内存当中的地址。基本思想已经懂了那么剩下的我们就更具体的讨论细节。进程地址空间图12:进程地址空间上图是一个64位的进程地址空间编译器在编译程序时将结果编译成32/64位的地址空间。虚拟寻址方式简化了编译器链接器的工作。同样也因为虚拟内存每个进程才能有很大的一致的私有的的地址空间。这方便了内存管理保护了每个进程的地址空间不被其他进程破坏。同时也方便了共享库。虚拟内存也是一种缓存思想虚拟内存将主存看成是一个磁盘的高速缓存主存中只保存活动区域并根据需要在磁盘和主存之间来回传送数据。从概念上来说虚拟内存被组织成为一个由存放在磁盘上的 N 个连续的字节大小的单元组成的数组也就是字节数组。每个字节都有一个唯一的虚拟地址作为数组的索引。虚拟内存的地址和磁盘的地址之间建立影射关系。磁盘上活动的数组内容被缓存在主存中。在存储器层次结构中磁盘(较低层L5参见我们上篇文章图4)的数据被分割成块(block)这些块作为和主存(较高层,L4)之间的传输单元。主存作为虚拟内存(或者说磁盘)的缓存。虚拟内存VM系统将虚拟内存分割成称为大小固定的虚拟页Virtual Page,VP每个虚拟页的大小为固定字节。同样的物理内存被分割为物理页Physical Page,PP,大小也为固定字节物理页也称作页帧page frame。在任意时刻虚拟页面都分为三个不相交的部分未分配的(Unallocated)VM 系统还未分配或者创建的页未分配的页没有任何数据和它们关联因此不占用任何内存/磁盘空间。缓存的(Cached)当前已缓存在物理内存中的已分配页。未缓存的(UnCached)该页已经映射到磁盘上了但是还没缓存在物理内存中。其中未分配的VP不占用任何的实际物理空间这点要理解。32位程序地址空间就有4G至于64G的程序它的地址空间是一个非常大的天文数字(貌似是16777216T)而目前我们的电脑高配的也就2T磁盘16G内存。如果64位程序每个VP都映射着实际的PP。无论如何也对应不上的。并且也完全没必要一一映射,图12:进程地址空间中可以看到地址空间内有大量的空白。毕竟程序不可能实际使用那么大的地址空间。图13VM使用主存来作为缓存上图展示了在一个有 8 个页面的虚拟内存中虚拟页 0 和 3 还没有被分配所以在磁盘上不存在。虚拟页 146 被缓存在物理内存中。虚拟页 257 已经被映射分配了但是还没有缓存在主存中。当然那个图上标注的不对,VP 部分n-p和N-1应该分别标注为3和7,不过我们找不到更合适的图了(这种图自己画压力太大了)。所以大家知道我们假设共有8个VP就好了。页表(page table)系统必须得有办法判定某个虚拟页是否缓存在主存的某个地方。这具体可分为两种情况。已经在主存中就需要判断出该虚拟页存在于哪个物理页中。不在主存中那么系统必须判断虚拟页存放在磁盘的哪个位置并且在物理主存中选择一个牺牲页并将该虚拟页从磁盘复制到 主存替换这个牺牲页。这些功能由软硬件联合提供包括操作系统CPU中的内存管理单元Memory Management Unit,MMU和一个存放在物理内存中叫页表page table的数据结构页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换成物理地址时都会读取页表。图14页表上图展示了一个页表的基本结构页表就是一个页表条目Page Table Entry,PTE的数组。虚拟地址的每个页在页表中都有一个对应的PTE。在这里我们假设每个 PTE 是由一个有效位Valid bit和一个 n 位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在 主存 中。有效位为 1则主存缓存了该虚拟页。地址字段就表示主存中相应的物理页的起始位置。有效位为 0则地址字段的null表示这个虚拟页还未被分配否则该地址就指向该虚拟页在磁盘上的起始位置。页命中与缺页我们在上篇文章什么是内存一存储器层次结构中说过缓存命中与不命中的问题都是缓存思想在这里肯定也会存在同样的问题。并且磁盘与主存之间的缓存不命中代价肯定大的多。因为L0-L4之间每级缓存的速度大约相差10倍左右但是L4主存与L5磁盘之间它们的速度相差约十万倍。所以主存与磁盘之间交换的页容量是最大的尽可能的增加命中率。相应的替换策略操作系统也使用了更加复杂精密的算法。在上篇文章什么是内存一存储器层次结构每次替换的区域我们用了块(block),而这里我们却在说页(page) 其实同一个意思。只是因为历史原因叫法不同罢了。当CPU想要读取包含在某个虚拟页的内容时如果该页已经缓存在主存中也就是页命中。perfect,很完美。但是如果该页没有缓存在主存中则我们称之为缺页(page fault)图15对VP3中的字的应用会引起不命中如上图所示CPU 引用了 VP3 中的内容 VP3 并未缓存在主存中。系统从内存中读取 PTE3,得知 VP3 未被缓存这会触发了一个缺页异常。缺页异常会调用kernel的缺页异常处理程序该程序会选择一个牺牲页。如下图所示牺牲页选择了存放在 PP3 中的 VP4。图16VP4被牺牲了此时如果 VP4 的内容被修改了kernel会将它复制回磁盘。接下来kernel从磁盘赋值 VP3 到内存中的 PP3并更新 PTE3。随后返回用户进程。当异常处理程序返回时它会重启执行导致缺页的指令当重新执行这条指令时因为 VP3 已经在主存中了此时就是页命中了。图17VP3被缓存到PP3根据习惯性的叫法我们在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。这种交换活动只有当不命中发生时才会发生(也就说系统并不会将磁盘内容预存到内存中)。这种策略被称之为按需页面调度(demand paging)。我们刚才说缺页错误是一种异常但是实际上在计算机系统中被0除读写文件还有上篇文章中我们所说的中断(interrupt)甚至包括我们代码中写的try catch都是一种异常。 比如被0除是intel 的CPU规定的的第0号故障(fault)类型的异常。而读写文件分别是linux规定的第0号和第1号陷阱(trap)类型的异常。多任务的上下文切换进程的创建回收等等与系统中这种异常流的处理密切相关。当然这是另外一个话题了。我们在这里不做累述。虚拟内存作为内存管理和内存保护的工具理所当然的每个进程都有一个独立的页表和一个独立的虚拟地址空间回到文章开头的问题比如每个C程序都要调用的stdio这个库不可能为每个进程都添加一份库内存中只有一份stdio库的内容供每个使用该库的进程共享。