操作系统 | 虚拟存储器:从请求分页到抖动预防全解析

嗨!之前在计组那篇文章里,我们从硬件视角把虚拟存储器拆了个底朝天——局部性原理、页表地址转换、TLB加速、Cache协同……走的是"CPU内部怎么干活"的路线。但虚拟存储器可不是硬件一个人在战斗,操作系统才是幕后那个真正"调度全局"的大管家。这篇文章,我们切换到操作系统视角,把请求分页、页面置换、抖动与工作集、请求分段这些内容一次性讲透。和计组那篇对照着看,效果翻倍。

一、虚拟存储器概述:复习一下,它到底是什么?

在计组那篇文章里我们已经知道了:虚拟存储器的本质是把外存当内存来使用,让程序从外存按需调入内存运行。它的目的也很明确——程序过大无法一次全部装入主存,所以需要扩大逻辑内存容量。程序员写代码的时候再也不用操心"我的程序放不放得下"这个问题了,因为操作系统给你制造了一个"内存无限大"的幻觉。

这一切的理论基础是局部性原理,包括两个维度:时间局部性(刚刚访问过的数据,很可能马上再被访问)和空间局部性(刚刚访问过的地址附近的数据,很可能马上被访问)。

正因为程序在运行中具有这种"恋旧"的特性,我们才敢只把一部分页面放在内存里,剩下的扔在磁盘上——反正大部分时间程序只在几个页面上反复横跳。

虚拟存储器有三种分类:页式虚拟存储器段式虚拟存储器段页式虚拟存储器。这三种我们在计组那篇已经简单提过,接下来在操作系统视角下,我们要深入到每一种的请求机制和硬件支持层面。


二、请求分页存储管理方式:操作系统的核心武器

这是虚拟存储器中最主流、最重要的实现方式。

它的核心思想是:作业不必一次性全部装入内存,只装入一部分即可运行;当需要访问的页面不在内存中时,再由操作系统把缺少的页面从磁盘调入。

2.1 硬件支持:请求页表机制

在计组那篇文章里,我们认识的页表只有"虚页号→物理块号"的映射关系。

但在请求分页系统中,页表的功能要丰富得多——它不仅是一张地址映射表,还是一张"状态监控表"。

每个页表项(PTE)包含以下字段:

图片

页号:虚拟地址空间中的页面编号,作为页表的索引。

物理块号:该页面当前在主存中对应的物理块编号。如果页面不在内存中,这个字段就没有意义。

状态位(有效位/存在位):这是最关键的一位!它表示该页是否在主存中。状态位为1,说明页面在内存里,可以直接访问;状态位为0,说明页面在磁盘上,访问它就会触发缺页中断。你可以把它理解为"在/不在"的开关。

访问字段:记录该页在调入主存后被访问的次数或频率。这个信息是给页面置换算法用的——操作系统需要知道哪些页面"经常被翻牌子",哪些"已经被打入冷宫很久了",以便在需要腾出空间时做出聪明的淘汰决策。

修改位(脏位):表示该页在调入主存后是否被修改过。如果修改位为1,说明这个页面的内容和磁盘上的副本不一样了(变"脏"了)。当这个页面被换出时,必须把新内容写回磁盘,否则数据就丢了。如果修改位为0,说明它和磁盘上的副本一模一样,直接丢弃就行,省了一次磁盘写操作——这在性能上差别巨大,因为磁盘写入是很慢的。

外存地址:指出该页在外存(磁盘)上的存储位置。当需要把这个页面调入内存时,操作系统就根据这个地址去磁盘上找它。

2.2 缺页中断机构

这是请求分页系统中最精妙的机制之一。当CPU访问一个页面,而该页面的状态位为0(不在内存中)时,硬件会产生一个缺页中断

缺页中断的处理流程大致如下:

第一步,中断产生:CPU执行指令时发现所需页面不在内存,硬件触发缺页中断信号。注意,缺页中断和一般的中断不同——它属于内中断(陷入型异常),是在指令执行过程中产生的,而且可能在一条指令执行期间产生多次缺页中断(比如一条指令的操作数跨越了两个页面,如下图)。

图片

第二步,中断处理:CPU暂停当前指令的执行,转而进入操作系统的缺页中断处理程序。操作系统查看请求的页面在磁盘上的位置(从页表项的外存地址字段获取),然后在主存中寻找一个空闲物理块。

第三步,判断是否需要页面置换:如果主存中有空闲物理块,直接分配给它,然后把页面从磁盘读入。但如果主存已经满了——没有空闲物理块了——那就得按照某种页面置换算法选择一个"倒霉蛋"页面把它换出去,腾出空间给新来的页面。如果那个倒霉蛋被修改过(修改位为1),还得先把它写回磁盘,才能腾出物理块。

第四步,更新页表:新页面调入后,更新对应的页表项——填入物理块号,状态位设为1,修改位清零,访问字段清零。

第五步,重新执行:缺页处理完成后,CPU重新执行那条因为缺页而被中断的指令。这次页面已经在内存里了,一切顺利。

2.3 地址变换机构

在计组那篇文章里我们已经详细分析了地址变换的过程,这里从操作系统角度做一个精简回顾:

CPU发出虚拟地址,MMU从中提取虚页号。先查快表(TLB):如果命中,直接拿到物理块号,与页内偏移拼接得到物理地址,速度极快。如果快表未命中,再去主存中查慢表(页表)。在慢表里,除了拿到物理块号,还要检查状态位

如果状态位为1——页面在主存中,正常完成地址转换。同时把这次查到的页表项更新到快表中,供以后快速访问。

如果状态位为0——缺页! 触发缺页中断,进入上面描述的缺页中断处理流程。等操作系统把页面调入内存、更新页表之后,CPU重新执行被中断的指令,这次地址转换就能顺利完成了。

计组那篇文章重点讲的是"硬件怎么完成地址转换"(TLB、Cache、主存、磁盘的完整访存路径),而操作系统这里更关注的是"缺页之后怎么决策"——选哪个页面换出去、怎么管理外存地址、怎么维护页表状态。两门课看的其实是同一个系统的不同侧面,合起来才是全貌。

图片

2.4 请求分页的内存分配

知道了缺页中断怎么处理之后,还有一个更上层的问题:每个进程该分到多少个物理块? 分配多了,能装的进程就少;分配少了,缺页频繁,还可能引发抖动。这是操作系统必须做的资源分配决策。

内存分配原则分为三种策略:

固定分配局部置换:为每一个进程分配一组固定的物理块,在进程整个运行期间不再改变。如果发生了缺页,只能在该进程自己的物理块范围内进行页面置换——选自己的一个页面换出去,腾出空间给新页面。

这种方式简单、可预测,但灵活性差:一个进程可能暂时不需要那么多物理块(比如进入了I/O密集阶段),但它的块被"锁死"了,别的进程也用不了。

可变分配全局置换:为每个进程分配的物理块数量在运行期间可以变化。当发生缺页时,操作系统可以从系统的任何空闲物理块中选取一个来分配——甚至可以抢占其他进程的物理块。

这种方式灵活性高,但可能导致某个"贪吃"的进程不断侵占其他进程的资源,引发全局性的抖动。

可变分配局部置换:这是一种折中方案——每个进程分到的物理块数量可以动态调整(操作系统根据系统负载和进程需要增减分配),但发生缺页时,仍然只能在自己的物理块范围内进行置换。既保证了一定的灵活性,又避免了进程之间互相抢占。

还有一个重要概念是最小物理块数:这是能保证程序正常运行所需的最少物理块数量,与计算机的硬件结构有关。

如果分配的物理块少于这个最小值,某些指令根本无法执行——比如一条指令需要访问多个页面,如果同时能放在内存里的页面不够多,这条指令就会反复触发缺页,永远无法完成。

物理块分配算法有三种常见方式:

平均分配算法:把系统中可用的物理块总数除以进程数,每个进程分到相同数量的物理块。简单粗暴,但完全没考虑进程的实际需求——一个需要大量内存的数据处理进程和一个只做简单计算的小进程,拿到的块数一样多,显然不合理。

按比例分配算法:根据每个进程的大小(所需页面数)按比例分配物理块。进程越大,分到的物理块越多。比如进程A有100页,进程B有20页,系统有60个可用物理块,那A分到50个,B分到10个。这比平均分配合理得多。

考虑优先权的分配算法:在上述基础上,还考虑进程的优先权。优先级高的进程可以多分配一些物理块,减少其缺页次数,从而提高其响应速度。优先级低的进程少分配一些,缺页多一些,运行慢一些——这在实时系统或有明确优先级区分的场景中很有用。

2.5 页面调入策略

解决了"分多少块"的问题后,接下来是"页面怎么调入内存"。

何时调入页面,有两种策略:

预调页策略:在页面还没被访问之前,就根据预测将其提前调入内存

理论依据是局部性原理——如果程序刚访问了第5页,很可能紧接着就要访问第6页、第7页。操作系统可以把这些"预计不久会被访问"的页面提前加载进来,减少缺页次数。

但预调页的难点在于预测的准确性:预测错了,就白占了物理块,还做了无用的磁盘I/O。

请求调入策略:最简单直接的方式——只有在真正发生缺页中断时,才把需要的页面调入内存

不预测,不提前加载,需要什么才去拿什么。实现简单,但每次缺页都要付出一次磁盘I/O的代价,第一次访问时延迟较大。

大多数现代操作系统采用请求调入为主、预调页为辅的混合策略。

从何处调入页面,取决于系统对换区的空间情况:

系统拥有足够的对换区空间时:所有需要的页面都从对换区调入。对换区是磁盘上专门划出来做页面交换的区域,访问速度比文件区快(因为连续分配,不需要像文件区那样追踪碎片化的存储空间)。

系统缺少足够的对换区空间时:对于不会被修改的文件(比如只读的程序代码),直接从文件区换入换出——反正不会被修改,不需要在对换区备份。对于会被修改的数据页面,仍然从对换区调入调出。

未运行过的页面都从文件区调入——这是UNIX方式。UNIX系统的设计思想是:程序第一次运行时的页面直接从可执行文件(文件区)读入;之后如果被换出到对换区,后续再从对换区调入。这避免了一开始就把整个可执行文件拷贝到对换区的开销。

页面调入的过程:当发生缺页时,操作系统按照如下流程操作

首先检查所需的页面在磁盘上的位置(从页表的外存地址字段获取),然后在内存中寻找空闲物理块(或按置换算法淘汰一个页面腾出空间),接着启动磁盘I/O将页面读入物理块,最后更新页表项(填入物理块号、状态位置1、修改位清零),并唤醒等待该页面的进程。

缺页率的计算:缺页率是衡量虚拟存储器性能的关键指标。

定义上,缺页率 = 缺页中断次数 / 总页面访问次数。

在计组那篇文章里我们分析过,缺页时CPU要等待磁盘I/O,时间是正常内存访问的十万到百万倍,所以即使缺页率很低(比如1%),也会显著拖慢有效访问时间。

有效访问时间的计算公式可以表达为:

有效访问时间 = (1 - p) × 内存访问时间 + p × (缺页处理时间),其中p就是缺页率。这个公式直观地说明了为什么操作系统要千方百计地降低缺页率——每降低一点点p,性能提升都是巨大的。


三、页面置换算法:谁该被踢出去?

当主存已满、必须换出一个页面时,选谁就成了核心问题。

选得好,缺页率低、性能高;选得差,频繁换进换出,系统可能比蜗牛还慢。这就是页面置换算法要解决的问题。

3.1 最佳置换算法(OPT)

OPT算法的思路堪称"上帝视角":选择那个将来最长时间不会被访问的页面淘汰。换句话说,它所选择的被淘汰页面是"以后再也不用了"或者"最久以后才会用到"的。

这个算法理论上是最优的,能保证最低的缺页率。但它不可能实现——因为没有人能预知未来,操作系统不可能提前知道一个页面什么时候会被再次访问。它的存在意义是作为一把标尺:其他所有算法的实际缺页率,都拿来和OPT比较,看看离理论最优差多远。

图片

3.2 先进先出页面置换算法(FIFO)

FIFO非常简单粗暴:总是淘汰最先进入内存的页面。谁在内存里待得最久,谁就走人。

实现上只需要维护一个队列,新调入的页面排在队尾,要淘汰时就把队头的页面踢出去。逻辑清晰,代码好写。

但FIFO有个臭名昭著的问题——Belady异常(Belady's Anomaly):给进程分配的物理块数增加了,缺页率反而升高了!这完全反直觉——更多的内存怎么反而更差?举个例子:3个物理块时缺页9次,4个物理块时反而缺页10次。这是因为FIFO不考虑页面的使用频率,可能把正在频繁使用的"老"页面给换出去了。

FIFO在实际中很少单独使用,因为它的性能实在不够看。

图片

3.3 最近最久未使用算法(LRU)

LRU是工程实践中最常用的算法之一:选择最近最久没有被访问过的页面淘汰。它的理论基础还是局部性原理——如果一个页面很久没被访问了,那它未来被访问的可能性也较小。

LRU比FIFO聪明的地方在于:FIFO看的是"谁先来",LRU看的是"谁最近没人理"。一个很老的页面如果一直被频繁访问,LRU不会动它;而FIFO会毫不犹豫地把它踢掉。

图片

LRU需要硬件支持来记录每个页面的最近访问时间或访问顺序。常见的硬件实现方式有两种:

移位寄存器方式:每个页面对应一个移位寄存器,每次访问页面时,把寄存器最高位置1,然后定期右移一位。这样,最近访问过的页面其寄存器值较大,最久未访问的页面寄存器值最小。置换时选寄存器值最小的那个。

图片

栈方式:维护一个页面访问栈,每次访问一个页面就把它移到栈顶。栈底的页面就是最近最久未使用的,置换时直接选它。这种方式的好处是栈底的页面始终是最该被淘汰的,不需要遍历。

图片

LRU的性能接近OPT,但硬件开销不小。实际系统中常用的是它的近似实现——Clock算法。

3.4 最少使用算法(LFU)

LFU的思路和LRU不同:选择近期被访问次数最少的页面淘汰。它关注的不是"最后一次访问是什么时候",而是"总共被访问了多少次"。

LFU的逻辑是:一个被频繁访问的页面大概率是重要的,应该留在内存里;一个只被访问过一两次的页面,换出去损失不大。

实现上,每个页面对应一个计数器,每访问一次就加1。置换时选计数器值最小的页面。但LFU有个问题:一个页面可能在过去被频繁访问,但现在不用了,它的计数器值仍然很高,导致LFU迟迟不淘汰它——这叫"计数器老化"问题。解决办法是定期将计数器右移一位(除以2),让老的访问记录逐渐"衰减"。

3.5 简单的Clock算法(最近未使用算法 NRU)

Clock算法是LRU的一种近似实现,也叫"最近未使用算法"。它只需要每个页表项有一个访问位(0或1),不需要复杂的计数器或栈。

工作原理是这样的:把所有在内存中的页面组织成一个循环链表(想象一个钟表盘),配一个指针。当需要置换时,从指针当前位置开始扫描:

如果当前页面的访问位为0——恭喜,它就是被淘汰的对象,指针前进一位。

如果当前页面的访问位为1——说明它最近被访问过,"饶它一命",把访问位清零,指针继续往前找。

这样转一圈之后,所有被访问过的页面的访问位都被清零了,第二轮扫描时它们就可能被淘汰。本质上是用访问位来粗略地近似"最近是否使用过"。

图片

3.6 改进型Clock算法

简单Clock只看访问位,但有一个重要信息被忽略了——这个页面有没有被修改过? 

前面说过,没被修改过的页面换出去时不需要写回磁盘(直接丢弃就行),而被修改过的页面必须写回磁盘,开销大得多。

改进型Clock算法同时考虑访问位(A)和修改位(M),把所有页面分成四类:

(0, 0):最近未访问、未修改——最佳淘汰对象,换出去零开销。

(0, 1):最近未访问、但修改过——不太好的选择,因为需要写回磁盘。

(1, 0):最近访问过、未修改——可能很快又会被访问,不太想动它。

(1, 1):最近访问过、且修改过——最差的选择,既要写回磁盘,又可能马上再被用到。

算法按优先级进行多轮扫描:第一轮找(0,0),找不到就第二轮找(0,1)并写回磁盘,再不行就第三轮找(1,0)……依此类推。

改进型Clock减少了磁盘I/O次数(优先淘汰不需要写回的页面),但算法的开销增加了(需要多轮扫描)。

这是操作系统中经典的用CPU时间换I/O时间的权衡。

3.7 页面缓冲算法

页面缓冲算法在页面置换的基础上引入了两个额外的链表:空闲页面链表修改页面链表

空闲页面链表:系统预先维护一批空闲物理块。当需要调入新页面时,直接从空闲链表中取一个物理块,而不是先去淘汰一个旧页面再腾出空间。这样做的好处是新页面可以立即调入,不需要等淘汰操作完成,提高了响应速度。被淘汰的页面的物理块则被加入空闲链表末尾,供以后使用。

修改页面链表:当被换出的页面被修改过时(脏页),不立即写回磁盘,而是先挂到修改页面链表上。系统在空闲时或修改链表积累到一定数量时,再批量写回磁盘。批量写入比逐个写入效率高得多,减少了磁盘I/O的次数。

页面缓冲算法的核心思想是:把置换的开销"摊薄"——调入快(有空闲块预备着),写回快(脏页攒一批再写)

3.8 访问内存的有效时间

在引入虚拟存储器之后,CPU访问内存的有效时间变得复杂了,因为不再是简单的"查表→取数据"两步走,而是可能出现多种情况。

在计组那篇文章里,我们分析过TLB+Cache的完整访存路径。这里从操作系统的角度来总结三种典型情况

情况一:快表命中 + 数据在内存中——这是最快的情况。TLB命中后直接拿到物理块号,然后访问主存(或Cache)获取数据。

有效访问时间 ≈ 快表访问时间 + 一次内存访问时间。

情况二:快表未命中,慢表命中 + 数据在内存中——TLB没找到,得去主存里查页表(慢表),多了一次内存访问。

有效访问时间 ≈ 快表访问时间 + 慢表访问时间(一次内存访问)+ 一次数据内存访问。

情况三:慢表也未命中(缺页)——这是最糟糕的情况,需要触发缺页中断,操作系统介入进行磁盘I/O。磁盘访问的时间是内存访问的十万到百万倍!有效访问时间中,缺页处理的开销占据了绝对主导地位。

所以,虚拟存储器的性能关键就在于缺页率。缺页率越低,有效访问时间越接近前两种情况;缺页率一旦飙升,性能就会断崖式下跌——这就引出了下一个话题:抖动。


四、抖动与工作集:当虚拟存储器"抽风"的时候

4.1 多道程序度与处理机利用率

操作系统通过多道程序设计来提高资源利用率——让多个进程同时驻留在内存中,一个进程等I/O的时候CPU可以切换去执行另一个进程。多道程序度(同时驻留在内存中的进程数)越高,理论上CPU利用率越高。

图片

确实,一开始随着进程数目增加,处理机的利用率急剧增加——CPU几乎不闲着,总有活干。但当进程数增加到某个点之后,CPU利用率到达最高点,然后不是继续上升,而是缓慢下降

这个下降就是抖动(Thrashing) 的信号。

4.2 产生抖动的原因

抖动的根本原因是:系统中运行的进程太多,分配给每一个进程的物理块太少,导致每个进程都在频繁发生缺页中断。

想象一下:你有100个物理块,放5个进程,每个分到20个块,勉强够用。但如果你放了50个进程,每个只能分到2个块——连程序的基本工作集都放不下。于是每个进程刚调入一个页面,马上又要换出另一个页面来腾地方。CPU大部分时间都在处理缺页中断、做页面的换进换出,真正执行用户指令的时间少得可怜。

系统就陷入了"忙得团团转但什么也没干"的状态——这就是抖动的直观表现。就像你在图书馆同时复习5门课,但桌上只有1本书的位置,你得不停地去书架换书,最后一下午过去了,每门课都翻了个封面。

4.3 工作集

工作集(Working Set) 是解决抖动问题的理论工具。

它的定义是:在某段时间内,进程实际所需访问页面的集合。 简单说就是——这个进程"此刻"真正需要的那几页。

工作集理论和局部性原理紧密相关:一个进程在某段时间内,可能集中访问某几个页面(形成工作集),过了这段时间又转向另一组页面。

图片

一个重要的观察是:进程发生缺页的时间间隔与进程所获得的物理块数有关。当分配的物理块数很少时,缺页频繁发生;随着物理块数增加,缺页间隔迅速变长。但当物理块数超过某一阈值后,再增加物理块对缺页率的影响就不再明显了——因为工作集已经全部装下了,多给物理块也只是浪费。

所以,操作系统应该按照工作集的大小来分配物理块——给够了就行,给多了浪费,给少了抖动。

4.4 抖动的预防方法

操作系统有几种策略来预防或减轻抖动:

采用局部置换策略:每个进程只能在自己的物理块范围内进行置换,不能抢占其他进程的物理块。这样即使某个进程在频繁缺页,也不会"偷走"别人的物理块导致连锁反应。与之相对的是全局置换——所有进程共享一个物理块池,谁需要谁抢,容易引发雪崩式的抖动。

把工作集算法融入到处理机调度中:调度器在决定是否让一个新进程进入内存之前,先估算它的工作集大小,检查当前剩余物理块是否足够。如果不够,就不让新进程进来,或者先挂起一个当前进程。这样保证每个在运行的进程都有足够的物理块。

利用L=S准则调节缺页率:L=S准则是一种动态调节策略。其中L是缺页间隔的平均时间,S是处理一次缺页中断的平均时间。如果L > S,说明缺页不频繁,可以增加多道程序度;如果L < S,说明缺页太频繁了,处理缺页的时间比不缺页的时间还长,必须减少多道程序度。这个准则让系统自动寻找一个平衡点。

选择暂停的进程:当检测到抖动时,操作系统可以选择挂起某些优先级较低的进程,回收它们的物理块分配给其他进程。等抖动缓解后再恢复被挂起的进程。

抖动的后果不仅仅是CPU利用率下降——抖动后缺页中断处理时间延长,会增加就绪队列的等待时间,延长其他进程的缺页中断处理时间,形成恶性循环。所以预防抖动是操作系统存储管理中的重要课题。


五、请求分段存储管理方式:按逻辑来管理

分段存储管理在之前的存储器管理文章中已经介绍过。这里讲的是它的"升级版"——请求分段,也就是把分段和虚拟存储器结合起来。

请求分段的核心思想和请求分页一样:不需要把整个程序的所有段都装入内存,只需要当前需要的段在内存中即可运行;需要某个段时再从磁盘调入。

5.1 硬件支持:请求段表机制

请求分段的页表——哦不,段表——比基本分段系统的段表要丰富得多。每个段表项包含以下字段:

段号:段的编号,作为段表的索引。

段长:该段的长度(字节数),用于越界检查。

段在内存中的起始地址:如果段在内存中,这个字段记录它的物理起始地址。

存取方式:规定该段的访问权限——是只读、读写、还是可执行。这为段级保护提供了基础。

访问字段:和请求分页中的访问字段类似,记录该段被访问的次数或频率,供置换算法参考。

修改位:表示该段调入内存后是否被修改过。换出时,修改过的段需要写回磁盘,没修改过的直接丢弃。

存在位:和请求分页中的状态位完全一样——表示该段是否在内存中。存在位为1则在内存,为0则在磁盘上。

增补位:这是分段独有的一个字段!因为段的长度是可以动态增长的(比如数据段可能越来越大),增补位用来标记该段在调入内存后是否被扩充过。如果被扩充了,换出时需要把扩充后的完整段写回磁盘,而不是原来那个短版本。这个字段是分段系统特有的,分页系统不需要,因为页面大小是固定的。

外存地址:该段在磁盘上的存储位置,调入时根据此地址去磁盘上找。

5.2 缺段中断机制

和请求分页中的缺页中断类似,请求分段系统中有缺段中断:当CPU访问一个段,而该段的存在位为0(不在内存中)时,硬件产生缺段中断,通知操作系统把需要的段从磁盘调入内存。

图片

处理流程与缺页中断类似:保存CPU现场→查找该段在磁盘上的位置→在内存中分配空间(可能需要淘汰其他段来腾地方)→从磁盘读入该段→更新段表(存在位置1,填入起始地址)→恢复CPU现场,重新执行被中断的指令。

一个有趣的区别是:分页系统中换进换出的单位是固定大小的页面,而分段系统中换进换出的单位是大小不一的段。这意味着分段系统在分配内存空间时需要处理可变大小的分配问题——和连续分配存储管理中的那些算法(首次适应、最佳适应等)又产生了联系。

分段的地址变换过程也与分页类似

图片

5.3 分段的共享和保护

分段的一大优势是天然适合共享和保护——因为段是按逻辑意义划分的,一个函数库可以单独放在一个段里,供多个进程共享。

共享段表:系统维护一张共享段表,记录当前被多个进程共享的段的信息。每个共享段表项包含:

图片

共享进程计数:记录有多少个进程正在共享这个段。只有当共享计数降为0时(所有进程都不用了),这个段才会被真正回收。

存取控制字段:规定每个共享进程对该段的访问权限——有的进程可以读写,有的只能读,有的只能执行。不同进程对同一个共享段可以有不同的权限,这非常灵活。

段号:在不同的进程中,同一个共享段可以有不同的段号(因为每个进程有自己的段表)。共享段表记录了各个进程使用的段号与共享段的对应关系。

共享段的分配和回收:当第一个进程请求使用某个共享段时,系统把它从磁盘调入内存,并在共享段表中登记,共享计数设为1。后续其他进程请求同一个共享段时,不需要再调入,只需在共享段表中找到它,共享计数加1,并在该进程的段表中建立指向共享段的表项。回收时,共享计数减1,只有减到0时才真正从内存中移除。

5.4 分段保护

分段系统提供了多层次的保护机制:

越界检查:每次地址变换时,硬件都会检查段内偏移是否超过了该段的段长。如果超过,产生越界中断——你的程序想访问别人的地盘了,系统立刻拦截。这和在计组那篇文章中讲的"页号越界检查"道理相同,但分段系统中每个段的长度不同,所以每个段都需要单独做越界检查。

存取控制检查:在越界检查通过后,硬件还要检查当前操作是否符合该段的存取方式。比如对一个只读段执行写操作,就会被拦截。这防止了程序意外或恶意修改不该修改的数据。

环保护机构:这是一种更高级的保护机制,源自Multics系统的设计思想。系统把程序的执行环境分成若干个保护环(Ring),内层环(Ring 0)权限最高,外层环权限递减。操作系统的内核运行在Ring 0,用户程序运行在Ring 3。不同环之间的访问有严格的规则——内层可以访问外层的数据,外层不能直接访问内层的数据。段表中的"存取方式"字段可以配合环保护来实现精细的权限控制。

图片

六、分页 vs 分段 vs 段页式:三者的联动对比

到这里,我们把虚拟存储器的三种实现方式都讲完了。来做一个系统性的对比,这也是和计组那篇文章的一个联动总结。

6.1 请求分页 vs 基本分页

计组那篇文章讲的页式管理更偏"理想态"——假设页表已经在内存中,重点分析地址转换和TLB加速。而操作系统这里讲的请求分页是"现实态"——页面可能不在内存中,需要处理缺页中断、页面置换。请求分页在基本分页的基础上增加了状态位、修改位、访问字段、外存地址等字段,以及缺页中断机构和页面置换算法。

6.2 分页 vs 分段的本质区别

在之前的存储器管理文章中我们已经做过一次对比,这里从虚拟存储器角度再补充几点:

置换粒度不同:分页系统换进换出的是固定大小的页面(比如4KB),分段系统换进换出的是大小不一的段。固定大小的页面更容易管理(分配、回收都简单),但不利于共享和保护;可变大小的段逻辑清晰、便于共享,但内存管理更复杂(有外部碎片问题)。

共享粒度不同:分页系统的共享以页为单位——你想共享一个函数,但它可能横跨两个页面,共享了页面又可能泄露同页的其他数据。分段系统的共享以段为单位——一个函数一个段,精确共享,干净利落。

缺页 vs 缺段:分页系统产生缺页中断,分段系统产生缺段中断。处理流程类似,但由于段的大小不固定,缺段中断后在内存中为调入段分配空间的操作更复杂。

6.3 段页式虚拟存储器

段页式把分段和分页结合在一起:先将程序分成若干段(按逻辑),再把每个段分成若干页(按固定大小)

系统同时维护段表页表:段表记录每个段的页表起始地址,页表记录每页对应的物理块号。

地址变换过程需要更多的内存访问:先查段表,找到该段对应页表的起始地址;再查页表,得到物理块号;最后拼接块内偏移,得到物理地址。这意味着至少需要两次内存访问(段表+页表)才能真正取到数据,比纯分页或纯分段都多一次。

段页式需要更多的硬件支持(段表寄存器、页表基址寄存器、TLB等),速度上较慢。但它综合了两者的优点:既有分段的共享和保护能力,又有分页的消除外部碎片能力。在实际的操作系统中(如早期的Multics和某些Unix变体),段页式确实被采用过。

现代主流操作系统(Linux、Windows)主要采用的是纯请求分页方案,因为页表机制已经足够成熟,加上多级页表和反向页表的技术,分页系统在共享和保护方面也能做得很好。段的概念更多地体现在程序的逻辑结构中(代码段、数据段、栈段),而不是内存管理的物理层面。


七、写在最后:两门课的拼图

回顾一下这两篇文章的关系:

计组那篇文章,我们从硬件出发,讲了CPU发出的虚拟地址如何经过MMU查页表、经过TLB加速、结合Cache完成数据访问的完整路径。我们画出了"TLB命中+Cache命中"的最短路径和"TLB缺失+Cache缺失+缺页"的最长路径。重点在底层机制和性能分析

操作系统这篇文章,我们从软件出发,讲了操作系统如何管理页表(请求页表的各个字段)、如何处理缺页中断、如何分配物理块(固定分配、可变分配、平均/比例/优先权算法)、页面调入策略(预调页、请求调入、对换区vs文件区)、如何选择置换页面(八种置换算法)、如何预防抖动(工作集理论)、以及请求分段的共享和保护机制。重点在管理策略和决策算法

这两门课看的是同一个系统的不同层面。硬件负责"怎么找到数据",操作系统负责"数据该不该在内存里"。没有硬件的地址变换机构,虚拟存储器无法实现;没有操作系统的置换策略和管理机制,虚拟存储器没有意义。

下次你写代码时,想想你访问一个变量背后经历了多少层关卡:虚拟地址→快表→页表→缺页中断→磁盘I/O→物理地址→Cache→数据。而操作系统在背后默默做的所有决策——换谁出去、留谁进来、怎么防抖动——你完全感知不到。

这就是工程的浪漫:把复杂藏在底层,把简单留给用户。


觉得有用的话,别忘了点赞、在看、转发三连~ 和上一篇计组版的虚拟存储器搭配食用效果更佳!有问题欢迎在评论区讨论!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐