一、整条主线:从写代码到 CPU 执行指令
完整流程:编辑 → 编译 → 链接 → 装入内存 → 运行
int x=10; x=x+1;).o),每个目标模块内部地址从 0 开始(局部逻辑地址).exe可执行文件)。核心问题:exe 指令里写的是
地址79(逻辑地址,假设模块从 0 开始),如果操作系统把这个程序放到内存100 号位置开始,那变量 x 真实物理地址应该是100+79=179。
如果不做地址转换,CPU 去访问物理地址 79,那是别的进程的内存空间,直接出错!
→ 三种装入方式,本质就是什么时候做这个地址加法。
三种装入方式对比
编译的时候提前知道程序将来会放在内存哪一块物理地址,编译直接生成物理地址。
特点:
① 编译阶段直接写死绝对物理地址;
② 装入程序直接把代码丢进内存,不需要修改地址;
局限:只能单道程序(同一时刻内存只有一个程序)。现代多任务 OS 完全不用。
例子:提前确定程序从 100 开始,编译时直接把指令里的 79 改成 179。
装入内存的时候一次性完成地址转换(静态:运行前完成)
缺陷:内存不够就装不进去;运行时不能挪位置。
装入的时候不做地址转换! 指令里仍然保留逻辑地址;等到 CPU 真正执行这条指令那一刻,才做地址加法。
物理地址 = 重定位寄存器的值 + 逻辑地址⚠️考点区分:
静态重定位:装入时一次性改指令内部的地址,指令里面存的是物理地址;
动态重定位:指令永远存逻辑地址,硬件实时计算,指令本身不会被修改。
问:静态重定位和动态重定位的区别?
从写程序到程序运行
编译:由编译程序将用户源代码编译成若干个目标模块(编译就是把高级语言翻译为机器语言)
链接:由链接程序将编译后形成的一组目标模块,以及所需库函数链接在一起,形成一个完整的装入模块
装入(装载):由装入程序将装入模块装入内存运行1
对于链接做解释
源代码.c → 编译 → 多个独立目标模块.o(每个模块内部地址都是从 0
开始的局部逻辑地址,模块之间的外部调用符号是未解析的)
然后进入链接阶段,链接的本质:解析模块之间的外部符号引用,合并地址空间,把零散目标模块拼成一个统一地址空间的程序;链接分 3
类,区别只在于链接发生在哪个时间点。
链接完成后得到装入模块,再执行装入,放入内存,把逻辑地址转为物理地址。
一句话区分:编译是高级语言→机器码;链接是把多个
.o拼起来;装入是把程序放到内存。
链接的三种方式
时机:程序运行之前(编译之后、装入之前)一次性完成链接
408 考点:静态链接生成完整的装入模块,链接发生在装入内存之前。
装入时动态链接(边装入、边链接)
时机:把目标模块装入内存的过程中,同步完成链接
操作:不提前把所有模块拼成一个大文件。把各个独立.o逐个加载进内存,装入一个模块,就立刻解析这个模块的外部符号,完成链接。
特点:
考点:链接动作发生在装入阶段,运行前所有模块都已经载入内存并完成链接。
运行时动态链接(最灵活,就是动态库 dll/so)
时机:程序已经跑起来,执行过程中,需要用到某个模块的时候,才去链接 + 载入
.dll、Linux 的.so共享库就是这个。| 链接方式 | 链接发生时间 | 模块是否提前合并 | 核心特点 |
|---|---|---|---|
| 静态链接 | 运行前,装入内存之前 | 全部合并成单个装入模块 | exe 包含全部代码;库更新必须重链接 |
| 装入时动态链接 | 装入内存的过程中 | 不提前合并,装入时逐个链接 | 全部模块最终都会载入内存 |
| 运行时动态链接 | 程序运行过程中,按需加载 | 不合并,需要才载入链接 | 按需装入,支持模块共享,节省内存 |
链接和装入的过程 一定不要弄混
如果采用的装入方式是动态重定位 采用的链接方式是运行时动态链接 完整的流程是
编译阶段
主模块、模块 A、模块 B 各自编译,各自生成.o目标文件,每个模块内部都有从 0 开始的局部逻辑地址。此时各个模块互相不知道对方的地址。
程序启动
只把主模块加载进内存,主模块的局部逻辑地址,在链接时映射到进程虚拟地址空间一段区域。
此时A、B 模块还在磁盘,完全不进内存。
运行到调用 A 模块函数的语句
重点:动态重定位是取指令执行的时候硬件现场转换,不是装入模块的时候一次性转换。
二者独立,可以自由搭配:
运行时动态链接(软件按需加载模块) + 动态重定位(硬件运行时地址转换),就是现代操作系统加载动态库的标准组合。
内存管理
包括一下几个方面
内存空间的分配与回收
内存空间的扩充
地址转换
存储保护
内存空间的分配与回收
内存是有限资源,多个进程并发运行,OS 要解决两件事:1.运行的时候 给进程分配空间 记录哪些是空闲 哪些已经分配 2. 回收 把进程占用的内存回收
物理内存容量有限,但是可以利用外存(磁盘),让程序的总地址空间大于物理内存大小。也就是虚拟内存的核心思想。程序的一部分放在磁盘,只有需要的时候才调入内存,看起来内存 “变大了”。
地址转换 (后面说)
存储保护
下限寄存器:进程物理内存起始地址
上限寄存器:进程物理内存结束地址
CPU 访问内存时,检查生成的物理地址是否在【下限,上限】之间。超出则触发越界异常。
例子图:进程 1 物理地址 100~279;访问不在这个区间就报错。
进程的内存映像
‼️‼️薄弱地方 容易忘!!
虚拟地址空间
CPU 给进程用的虚拟地址是 n 位,那么虚拟地址编号总数就是 ,也就是虚拟地址空间大小。
例:32 位虚拟地址 →
= 4GB 虚拟地址空间
64 位虚拟地址,一般只用 48 位做地址,那虚拟空间就是 。
32 位系统进程虚拟地址空间共 4GB,操作系统将其划分为低 3GB 用户区与高 1GB 内核区。
用户区属于进程私有;内核区在每个进程虚拟地址空间中都存在,所有进程的内核虚拟地址映射到同一内核物理内存。
这样设计,进程切换时内核部分页表无需更换,降低上下文切换开销;用户态程序无法访问内核区,实现权限隔离。
同一进程用户态与内核态切换,不更换页表;进程之间切换,才更换页表。内核区在每个进程的页表预先映射,所以内核态可以直接用当前进程的虚拟地址访问内核代码。
举个例子
进程 A 正在运行,调用 read:
分区从上到下(地址从高→低)
存放:操作系统内核代码、内核数据、PCB 进程控制块等。
✅权限:用户态进程不能直接访问,只有内核态才能读写。
所有进程共享同一份内核,但是在每个进程的虚拟地址空间里都映射到这片高位区域
用户栈 Stack(用户区最高位置,向下增长)
对应 C 例子:int d=4; main 函数里面的局部变量,就放在栈。
共享库存储映射区
存放动态链接库(共享库),比如printf的代码。
对应前面学的运行时动态链接:调用库函数时,才把共享库映射到这个区域。多个进程可以共享同一份库代码,节省物理内存。
堆 Heap(向上增长)
malloc()分配、free()手动回收。C 例子:
int *p = (int *)malloc(...),指针变量p本身在栈,p 指向的数组空间,在堆。
C 例子:int a=1;(全局变量)、static int c=3;(静态变量),放在这里。
✅特点:进程启动时就分配好,进程整个生命周期都存在。
const int b=2;覆盖与交换
(这个知识点了解即可)
关于一些容易混淆概念的辨析
1)连续、非连续分配
解决的问题:进程放到物理内存时,内存地址是一整块连续,还是分散多个块。
这两个是分配的规则。覆盖、交换可以搭配连续分配一起用(早期系统都是连续分配 + 覆盖 / 交换)。
2)覆盖技术
属于内存扩充,目标:一个程序太大,放不下内存,同一时刻只加载不会并发执行的模块。
底层一般依托连续分配实现,但是它本身不是分配方式,是扩充手段。
3)交换(对换)技术
也属于内存扩充,目标:多道程序环境,内存进程太多,把整个进程暂时挪到磁盘,腾出内存。
同样,交换本身不是分配方式,只是内存紧张时,在内外存搬运整个进程的机制
和虚拟内存的关系(考试高频坑)
覆盖、交换 ≠ 虚拟内存。
简单一句话区分:
虚拟内存是OS 自动按需加载一小部分(页);
交换是OS 搬整个进程;
覆盖是程序员提前规划好,同一程序内部模块互相覆盖。
一句话:覆盖是同一个程序内部,代码段互相覆盖,程序员手动安排。
交换技术(对换技术)
内存空间不够时,把整个进程从内存换出到外存磁盘;等内存有空、进程具备运行条件,再把整个进程换回内存。是进程级别的内外存调度。
中级调度(内存调度)本质就是交换:决定把哪个挂起在外存的进程重新调入内存。
就绪挂起:进程在磁盘,已经就绪,等待调入内存;
阻塞挂起:进程在磁盘,还在等待事件。
磁盘分为两块区域:
文件区:存放普通文件,追求空间利用率,用离散分配,读写慢;
对换区:专门存放被换出的进程,追求读写速度,用连续分配,I/O 速度更快。
一句话:交换是进程整体在内存和外存之间换入换出,操作系统自动完成,对程序员透明。
内存分配
单一连续分配
在单一连续分配方式中,内存被分为系统区和用户区。
系统区通常位于内存的低地址部分,用于存放操作系统相关数据;用户区用于存放用户进程相关数据。
✅ 优点
❌ 缺点
OS 维护分区说明表,记录每个分区:分区号、大小、起始地址、分配状态(已分配 / 未分配)。当进程要装入内存,查表,找一个能装下它、且未分配的分区分配。
✅ 优点
❌ 缺点
举例子:分区大小 6MB,进程只需要 4MB,剩下 2MB 就是内部碎片,无法给别的进程
- 动态分区分配
进程装入内存的时候,才根据进程实际需要的大小,从空闲内存里切一块刚好合适的连续空间分配给它。进程退出,回收这块分区。分区大小、分区数量都是动态变化的。
要解决 3 个核心问题:
✅ 优点
❌ 缺点
- 回收内存的 4 种情况
当进程结束,回收它占用的分区,检查相邻上下两块是不是空闲,分 4 类:
一句话记忆:看左右邻居,能合并就合并,减少碎片。
动态分区分配算法
核心目标:当多个空闲分区都满足进程大小需求时,按规则选哪一块分配。
首次适应 First Fit(FF)
✅ 思想:从低地址往高地址从头查找,找到第一个能满足大小的空闲分区就分配。✅
空闲分区排序:按起始地址从小到大(地址递增)。✅ 分配:从链头 / 表开头遍历,找到第一个够大的块。✅ 优点:
综合性能最好;
优先利用低地址空闲块,高地址的大空闲块尽量保留,给后面大进程;
回收分区时,大多情况不需要重新排序,开销小。✅ 缺点:
一句话:从头找,找到第一个够用的就分。
✅ 空闲分区排序:按分区容量从小到大(容量递增)。✅ 分配:顺序查找,找到第一个能满足大小的分区(也就是最小够用块)。✅ 优点:大空闲块不容易被拆分,更容易满足后续大进程。✅ 缺点【高频考点】:
一句话:挑刚好够的最小块,“物尽其用”,但碎渣多。
✅ 思想:每次选当前最大的空闲分区分配。
思路:把大块切掉,剩下的剩余空间不至于太小,减少微小碎片。✅ 空闲分区排序:按分区容量从大到小(容量递减)。✅ 分配:从头就是最大块,只要够就分配。✅ 优点:减少微小碎片的产生。✅ 缺点【高频考点】:
一句话:每次拿最大的块去切,防止小碎片,但大块容易被用光。
思路:把大块切掉,剩下的剩余空间不至于太小,减少微小碎片。✅ 空闲分区排序:按分区容量从大到小(容量递减)。✅ 分配:从头就是最大块,只要够就分配。✅ 优点:减少微小碎片的产生。✅ 缺点【高频考点】:
一句话:每次拿最大的块去切,防止小碎片,但大块容易被用光。
邻近适应 Next Fit(NF,循环首次适应)
思想:由首次适应改进而来。不再每次都从头开始,从上次查找结束的位置继续往后找,链表可做成循环链表。✅
空闲分区排序:按起始地址从小到大(和 FF 一样,地址递增)。✅
优点:不用每次都从低地址从头遍历,减少低地址碎片的重复扫描开销;高低地址分区被使用的概率更均衡。✅ 缺点:
会把高地址的大空闲分区也拆分消耗掉;
不像 FF 那样保留高地址大分区,后续大进程更容易分配失败。
一句话:上次查到哪,这次就从哪接着往后找,循环查找。
| 算法 | 算法思想 | 分区排列顺序 | 优点 | 缺点 |
|---|---|---|---|---|
| 首次适应 | 从头到尾找适合的分区 | 空闲分区以地址递增次序排列 | 综合看性能最好。算法开销小,回收分区后一般不需要对空闲分区队列重新排序 | |
| 最佳适应 | 优先使用更小的分区,以保留更多大分区 | 空闲分区以容量递增次序排列 | 会有更多的大分区被保留下来,更能满足大进程需求 | 会产生很多太小的、难以利用的碎片;算法开销大,回收分区后可能需要对空闲分区队列重新排序 |
| 最坏适应 | 优先使用更大的分区,以防止产生太小的不可用的碎片 | 空闲分区以容量递减次序排列 | 可以减少难以利用的小碎片 | 大分区容易被用完,不利于大进程;算法开销大(原因同上) |
| 邻近适应(循环首次适应) | 由首次适应演变而来,每次从上次查找结束位置开始查找 | 空闲分区以地址递增次序排列(可排列成循环链表) | 不用每次都从低地址的小分区开始检索。算法开销小(原因同首次适应算法) | 会使高地址的大分区也被用完 |
本质:这 4
种都属于动态连续分配,分配的是物理内存,程序加载到连续的物理内存块;需要动态重定位。
注意:静态重定位是【装入的时候一次性把所有逻辑地址改成物理地址】,它要求:程序装入内存之后,这个分区不能移动**。
而动态分区分配(首次 / 最佳 / 最坏 / 邻近适应),内存里的分区是可以移动(紧凑)的,所以静态重定位就失效了。**
选择题结论:综合性能首次适应 > 邻近适应 > 最佳 / 最坏适应。
基本分页存储管理的基本概念
将内存空间分为一个个大小相等的分区
页框=页帧=内存块=物理块=物理页面 是一个概念 他们都有一个编号叫做 页框号=页帧号=内存块号=物理块号=物理页号
将进程的逻辑地址空间也分为与页框大小相等的一个个部分
每个部分称为一个“页”或“页面” 。每个页面也有一个编号,即“页号”,页号也是从0开始
各个页面不必连续存放,可以放到不相邻的各个页框中。
用页表来指示
考点:页越大,内部碎片平均浪费越大;页太小,页表会变得很长。
✅ 重要考点:页表项存的是页框号,不是物理起始地址!
页框 J 的物理起始地址 = 页框号 J × 页面大小。
分页下,进程页面离散放在内存,不能只用一个基址寄存器,需要查表。
逻辑地址 A,拆成两部分:页号 P + 页内偏移 W
页内偏移的位数,由页面大小决定,页面内部地址连续,页内偏移在逻辑地址、物理地址里完全不变!
基本地址变换机构
基本地址变换机构,也叫页表地址变换机构,是分页存储管理中负责把逻辑地址转换成物理地址的硬件机制。
程序运行时,并不要求整个作业全部连续存放在内存中。一个作业的多个页,可以离散地存放在不同的物理块里。因此,用户程序给出的逻辑地址,不能直接当作内存地址使用,必须经过地址变换。
地址变换的基本过程
第 1 步:根据逻辑地址计算页号和页内偏移量
第 2 步:判断页号是否越界
第 3 步:访问页表,得到物理块号
第 4 步:形成物理地址
基本地址变换机构的组成
第一次访问内存:查页表
第二次访问内存:访问目标内存单元
具有快表的地址变换机构
快表,又称联想寄存器(TLB, translation lookaside buffer ),是一种访问速度比内存快很多的
高速缓存(TLB不是内存!),用来存放最近访问的页表项的副本,可以加速地址变换的速度。与此对应,内存中的页表常称为慢表。
一、核心背景
基本分页的原生地址变换:每次访问逻辑地址,必须 2 次访问内存。
第 1 次:访问内存里的页表,拿到内存块号;
第 2 次:用物理地址,访问真正要读写的数据。
两次访存速度很慢。
而程序有局部性原理,很多页表项会被反复查,于是引入TLB 快表,TLB
是高速硬件缓存,只存页表项的副本,查询速度远快于内存。
一句话:TLB = 页表的 Cache;普通 Cache 是内存数据的 Cache,这是考点区分。
带 TLB 的地址变换完整流程(硬件自动完成)
✅ TLB 命中:TLB 里找到对应页号,直接取出内存块号。块号拼接页内偏移得到物理地址。只需要 1 次内存访问(访问目标数据)。
❌ TLB 未命中:去内存查慢表(内存中的页表),拿到内存块号;同时把这个页表项拷贝进 TLB(TLB 满了就用替换算法淘汰旧项);块号拼接偏移得到物理地址,访问目标数据。一共 2 次内存访问(查页表 1 次 + 访问数据 1 次)。
TLB 是硬件,访问 TLB 不算 “访存”,访存特指访问主存。
一个串联逻辑的例子
先明确:
TLB:页表项的高速缓存,解决「逻辑地址→物理地址」的地址翻译;
Cache:内存数据块的高速缓存,解决「物理地址拿到之后,快速拿数据」。
执行顺序:CPU 给逻辑地址 →【TLB 做地址转换得到物理地址】→【拿着物理地址去查 Cache 拿数据】
重要区分:
✅ TLB 管地址翻译;Cache 管数据读取。二者是两套独立缓存,可以自由组合 4 种情况。
总耗时:1μs(TLB)
通俗理解:地址翻译在 TLB 搞定;目标数据正好在 Cache 里,全程不碰内存。
通俗理解:地址翻译很快,但是数据不在 Cache,要去内存读数据。
通俗理解:TLB 没有页表项,要去内存翻页表做地址转换;但是转换完之后,目标数据刚好在 Cache 里。
两级页表
首先要明白为什么要引出两级页表 因为单级页表会有问题
若分为两级页表后,页表依然很长,则可以采用更多级页表,一般来说各级页表的大小不能超过一个页面
当逻辑地址空间很大(比如 64 位),单级页表会巨大,很难找到一大块连续内存存放整张页表。
最高层:页目录表(一级页表)
它的起始地址存放在进程 PCB + 页表基址寄存器里。CPU 做地址转换第一步,就要读这个表。
👉 结论:两级页表,无 TLB,访问 1 个逻辑地址,一共需要 3 次访存!
对比记忆:单级页表无 TLB:2 次访存。级数越多,访存次数越多,这是多级页表的代价。
TLB 仍然是存放页号→物理页框号,直接缓存最终页表映射,不管你是几级页表。
TLB 的好处:抵消多级页表带来的多次访存开销。这也是多级页表能在实际系统使用的关键。
疑问1:关于页表寄存器的疑问
页表寄存器存放:当前进程整个页表在内存中的起始物理地址(页表基址)
页表寄存器存放:页目录表(最高层一级页表)的起始物理地址
TLB 和这个寄存器互不冲突。TLB 命中的时候,直接拿到物理页框号,完全不需要读取页表寄存器、不需要访问各级页表。
页表长度存放在进程 PCB(进程控制块)中。
进程切换时,OS 把 PCB 里的「页表长度」送入 MMU 的页表长度寄存器(这个寄存器≠页表基址寄存器 CR3!两个独立寄存器)。
疑问2:多级页表下,越界检查怎么变?
????
基本分段存储管理方式
进程的地址空间:按照程序自身的逻辑关系划分为若干个段,每个段都有一个段名(在低级语言
中,程序员使用段名来编程),每段从0开始编址内存分配规则:以段为单位进行分配,每个段在内存中占据连续空间,但各段之间可以不相邻
1、段表项里面存什么
每个段表项 = 段长(界限) + 基址(本段在内存的起始物理地址)
每个段表项大小一致的话 段号就是隐含的
分段地址变换完整流程
段表项地址 = M + 段号 × 段表项长度(6B)✅ 分页是:页号 ≥页表长度才越界;分段是:段内地址 ≥段长越界!这个是选择题高频坑!
一句话:分页是 “物理切块”,分段是 “逻辑分块”。
分段系统的逻辑地址结构由段号(段名)和段内地址(段内偏移量)所组成。如
段号的位数决定了每个进程最多可以分几个 段内地址位数决定了每个段的最大长度是多
分段地址转换
① 逻辑地址进入 MMU 得到段号和段内地址
② 查段表 段表寄存器提供段表起始地址: 段表项地址 = 段表起始地址 + 段号 × 段表项长度
③ 找到那一项段表项 可以看到基址 和段长 检查段内地址是否越界 段内地址 ≥ 段长 → 越界中断
④ 计算物理地址 物理地址 = 基址 + 段内地址
分段、分页管理的对比
页是信息的物理单位。分页的主要目的是为了实现离散分配,提高内存利用率。分页仅仅是系统管
理上的需要,完全是系统行为,对用户是不可见的。
段是信息的逻辑单位。分段的主要目的是更好地满足用户需求。一个段通常包含着一组属于一个逻
辑模块的信息。分段对用户是可见的,用户编程时需要显式地给出段名。
页的大小固定且由系统决定。段的长度却不固定,决定于用户编写的程序。
分页的用户进程地址空间是一维的,程序员只需给出一个记忆符即可表示一个地址。分段的用户进程地址空间是二维的,程序员在标识一个地址时,既要给出段名,也要给出段内地址。
分段比分页更容易实现信息的共享和保护
不能被修改的代码称为纯代码或可重入代码(不属于临界资源) 试想一下 假设代码段有一部分功能是所有进程都可以访问的
但是因为分页这个操作是系统来完成的 他可能会把共享区和非共享区的数据混在一起放在一个页框里 所以是不方便共享的
但是段的话 这部分逻辑就单独抽成一个段
可以把共享数据单独作为一个段:多个进程的段表都指向这个段,就能实现共享。
段页式管理
为什么要引入段页式
分页管理✅优点:内存利用率高,只有页内碎片,没有外部碎片❌缺点:分页对用户透明,不方便按照逻辑模块做共享、保护
分段管理:✅优点:段是逻辑模块,方便实现段的共享、访问保护❌缺点:会产生外部碎片;段很长时,很难找到一块连续内存放整个段
段页式 = 分段 +
分页,结合两者优点:对进程:先按逻辑模块分段(满足程序员逻辑、共享保护);对每个段:再切分成固定大小的页(消除外部碎片)。
地址变换流程
逻辑地址:段号 S,页号 P,页内偏移 W
👉段页式每个段都有独立页表,不是整个进程一张页表
👉两次越界检查:①段号越界;②页号越界,两处都要判断
👉段页式依然存在页内碎片,没有外部碎片
👉段对用户可见,页对用户不可见。程序员只需要给出段号,系统自动拆分页号和页内偏移
虚拟内存的基本概念
传统内存管理的痛点
❌一次性
❌驻留性
虚拟内存的定义与三大特征
传统非连续分配:基本分页、基本分段、基本段页。❗基本分页 / 分段:依然要求整个作业一次性装入内存,不支持请求调入,不属于虚拟内存。
内存管理总分支:
请求分页存储管理
请求分页三大核心组成:页表机制、缺页中断机构、地址变换机构
基本分页页表:只有【页号 | 内存块号】请求分页页表,在原有基础上新增 4 个字段:
一句话理解新增字段:状态位:判断要不要缺页中断;外存地址:缺页去哪里读;访问位:选谁换出去;修改位:换出去要不要写回磁盘。
假设一个场景:进程一共 10 个逻辑页面;物理内存最多只能给这个进程分配 2 个页框
👉请求页表,必须有 10 个页表项,一页对应一条表项。
每个逻辑页号(0~9),都要有独立的描述信息:
对比基本分页:基本分页 10 页,必须一次性全部装入内存,10 条页表项全部有效。
请求分页:10 条页表项永久存在内存(多级页表的话,页表本身也可以换出外存,但页表项条目总数不变),只是大部分页面的状态位是 0。
延伸:那这 10 条页表本身存在哪里
缺页中断处理流程
注意:缺页中断处理全程是操作系统内核完成,用户态感知不到。缺页时进程会阻塞,调页完成唤醒。
地址变换机构
在基本分页地址变换流程基础上增加两步:缺页判断 + 页面置换。完整地址变换步骤(有快表):
补充细节:快表保存的一定是当前在内存中的页面;页面被置换淘汰出内存时,快表中对应的表项也要清除,否则会访问错误。
具体场景:
场景:TLB(快表)缺失 → 访问内存里的页表 → 发现有效位 = 0,缺页异常中断
CPU 查 TLB,TLB 不命中 → MMU 去内存读页表(慢表)
读取页表项,发现有效位为 0:缺页,触发缺页中断(属于内中断 / 异常)
CPU 保存当前指令现场,转入操作系统内核的缺页中断处理程序
OS 做几件事:
中断处理完成:恢复 CPU 现场,返回,重新执行那条触发缺页的指令
重新执行这条访存指令:
再次查 TLB → TLB 命中,直接拿到页框号,完成地址转换,访问物理内存
是重新执行触发异常的那条指令,不是下一条!
普通 IO
中断是做完之后执行下一条指令;缺页属于故障(fault),指令执行一半卡住,页面进来之后,要重新跑这一条访存指令,这是选择题超级爱挖坑的地方。
TLB 什么时候更新?
缺页处理完、修改完内存页表之后,OS(或者硬件)把新映射装入 TLB。
少数硬件架构:也可以不主动写 TLB,等到重新执行指令 TLB 缺失时,再从内存页表自动填充 TLB。
👉 408 考研默认:缺页调入页面、更新页表后,TLB 会被更新,再次执行指令 TLB 命中,按这个结论做题。
页面置换算法
请求分页:程序运行时,需要访问的页面不在内存,OS
才把页面从外存调入内存;内存装满时,要选择一个旧页面换出外存,这个选择页面的算法就是页面置换算法。
页面置换算法一共 5 种:OPT、FIFO、LRU、CLOCK(时钟 / NRU)、改进型 CLOCK。
页面换入换出会触发磁盘 I/O,开销很大。置换算法的核心目标:尽可能降低缺页率。
做题技巧:遇到要淘汰页面时,看内存里现存的几个页号,往后看访问序列,最后才出现的那个页,就是 OPT 要淘汰的。
规则:淘汰最早进入内存的页面,页面按调入顺序排成队列,换页直接踢队头。✅优点:实现最简单,只需要记录页面调入顺序❌缺点:
Belady 异常考点:只有 FIFO 会出现,LRU、OPT、时钟算法都不会!
规则:淘汰最近一段时间最久没有被访问过的页面。✅优点:性能很好,十分接近 OPT;符合程序局部性原理❌缺点:实现困难,硬件开销大,需要专门硬件(寄存器 / 栈)记录每个页面上次访问到现在的时间。
做题技巧:需要淘汰页面时,反向看访问序列,内存中现存页号,逆向最晚出现的就是要淘汰的。
背景:平衡 FIFO 简单、LRU 性能好的折中方案,开销不大,性能尚可。
目的就是要先置换没有访问的页面 就是需要首先将访问位为0的页面换出去
内存页面组织成循环队列,每个页面增加一位访问位:访问过置 1,没访问置 0。
置换时,指针循环扫描:
缺点:只考虑页面有没有被访问,不考虑页面是否被修改。如果被淘汰页修改过,换出时要写回磁盘,I/O 开销大。
简单的时钟置换算法仅考虑到一个页面最近是否被访问过。事实上,如果被淘汰的页面没有被修改过,就不需要执行I/O操作写回外存。只有被淘汰的页面被修改过时,才需要写回外存
现在考虑两个因素 访问位和修改位
访问位 访问过1 未访问0
修改位 修改过1 未修改0
最终目标淘汰 即未访问 又未修改 也就是 0 0
第一轮遇到 0 0 就淘汰 不修改任何
第二轮淘汰 0 1 也就是淘汰未访问 但是已经修改 当前如果访问位是1 就改成0 为下一轮做准备
第三轮淘汰 0 0 也就是 已经访问 但是未修改 不改任何标志
第四轮淘汰 0 1 也就是又修改 又访问
因此改进型CLOCK置换算法选择一个淘汰页面最多会进行四轮扫描
性能排序(理论):OPT > LRU > 改进CLOCK > CLOCK > FIFO
页面分配策略
一些关键的概念
驻留集:OS 分配给这个进程的物理块集合(当前在内存里属于该进程的页面)。
页面分配置换策略
固定分配:操作系统为每个进程分配一组固定数目的物理块,在进程运行期间不再改变。即,驻留集大小不变
可变分配:先为每个进程分配一定数目的物理块,在进程运行期间,可根据情况做适当的增加或减少。即,驻留集大小可变
局部置换 发生缺页时只能选进程自己的物理块进行置换。
全局置换 可以将操作系统保留的空闲物理块分配给缺页进程,也可以将别的进程持有的物理块置换
到外存,再分配给缺页进程。
两个维度组合:分配(固定 / 可变) + 置换(局部 / 全局),一共 3 种合法策略:
固定分配 + 全局置换:不可能。固定分配意味着进程物理块数量不能变,全局置换会抢走别的进程块,矛盾。
表格
| 策略 | 核心规则 | 考点理解 |
|---|---|---|
| ✅固定分配 + 局部置换 | 进程运行前分配固定数量物理块,全程不变;缺页时只能淘汰自己驻留集里的页面 | 难点:一开始很难预估该分配多少块。分配少了抖动,分配多了浪费。 |
| ✅可变分配 + 全局置换 | 初始分配若干块;缺页优先拿系统空闲块;空闲块用光,可以淘汰任意进程(包括别的进程)的页面。👉缺页进程的驻留集会变大;被抢页面的进程驻留集变小,缺页率上升。 | 全局:可以跨进程抢内存,系统全局视角。 |
| ✅可变分配 + 局部置换 | 初始分配若干块;缺页只能淘汰自己的页面。OS 监测缺页频率:缺页频繁 → 给它增加物理块;缺页很少 → 回收减少物理块。 | 局部:只能动自己的页面;驻留集大小动态根据进程自身缺页情况调整。 |
可变分配全局置换和可变分配局部置换区别
可变分配全局置换:刚开始会为每个进程分配一定数量的物理块。操作系统会保持一个空闲物理块队
列。当某进程发生缺页时,从空闲物理块中取出一块分配给该进程;若已无空闲物理块,则可选择一
个未锁定的页面换出外存,再将该物理块分配给缺页的进程。采用这种策略时,只要某进程发生缺页,
都将获得新的物理块,仅当空闲物理块用完时,系统才选择一个未锁定的页面调出。被选择调出的页
可能是系统中任何一个进程中的页,因此这个被选中的进程拥有的物理块会减少,缺页率会增加。
可变分配局部置换:刚开始会为每个进程分配一定数量的物理块。当某进程发生缺页时,只允许从该
进程自己的物理块中选出一个进行换出外存。如果进程在运行中频繁地缺页,系统会为该进程多分配
几个物理块,直至该进程缺页率趋势适当程度;反之,如果进程在运行中缺页率特别低,则可适当减少分配给该进程的物理块。
重点区分两件完全不一样的事:
可变分配 +
局部置换:缺页时只允许从「当前进程自己已经分到的物理块」里淘汰页面;但如果缺页率太高,操作系统可以额外给这个进程增加物理块数量,新增的块是从系统空闲块池拿,不是抢别的进程已经占有的块。
可变分配全局置换:只要缺页就给分配新物理块
可变分配局部置换:要根据发生缺页的频率来动态地增加或减少进程的物理块
何时调入页面
调入页面的时机(什么时候把磁盘页面拉进内存)
考点区分:预调页是提前预判;请求调页是 “缺了才调”。
从何处调入页面(磁盘分两块:对换区、文件区)
:进程运行前,把进程数据从文件区复制到对换区;页面调入调出都在内存 ↔ 对换区之间,速度快
后面两种好像都是一开始就放在文件去 然后一开始从文件区调 然后有修改就放回对换区 然后再从对换区读
抖动(颠簸)现象
抖动定义:刚换出去的页面马上又要调入,刚调入的页面立刻又要换出,系统疯狂页面置换,CPU 几乎都耗在磁盘 IO 上,进程几乎无法有效工作
根本原因:分配给进程的物理块太少,小于进程当前工作集,进程频繁访问的页面数量 > 可用物理块数。
工作集
驻留集:OS 分配给进程的物理块集合(客观分配了多少内存)
工作集:一段时间窗口内,进程实际访问过的页面集合(进程真实需要多少页面)
公式关系:驻留集大小 ≥ 工作集大小,否则必然抖动。
窗口尺寸:统计工作集的时间窗口;窗口越大,统计出来的工作集页面越多。
工作集大小 ≤ 窗口尺寸(会有重复访问页面)。
工作集思路:OS 持续监测进程工作集,给进程分配的物理块数量至少不小于它的工作集,避免抖动。
拓展:可以基于工作集做置换算法,优先淘汰不在当前工作集中的页面。
说明:工作集大小可能小于窗口尺寸,实际应用中,操作系统可以统计进程的工作集大小,根据工作集大小
给进程分配若干内存块。如:窗口尺寸为5,经过一段时间的监测发现某进程的工作集最大为3,那么
说明该进程有很好的局部性,可以给这个进程分配3个以上的内存块即可满足进程的运行需要。一般来说,驻留集大小不能小于工作集大小,否则进程运行过程中将频繁缺页
内存映射文件
理解从
传统文件方式方式和内存映射的区别
传统的文件访问方式
传统读操作:第一步磁盘读入内核缓冲区,第二步内核缓冲区拷贝一份到用户内存空间。
用户程序不能直接访问磁盘文件,必须借助系统调用、读写指针操作这块独立的缓冲区。
**读写指针(文件偏移量)是内核在文件打开时,在「文件表项」里维护的一个变量,存在内核内存,不在磁盘!lseek 只是修改这个内存里的偏移数值,**磁盘 IO 完全不发生 **。
其实读写指针就是一个变量 初始为零 然后需要读文件的时候 给出offset 计算出便宜 直接去磁盘的对应位置读取到内核缓冲区
和 mmap 对比
mmap 没有这个 “读写指针”!mmap 是页表映射,访问哪个虚拟地址,就对应文件哪个偏移,靠缺页中断调入页面,没有 lseek 这个操作
内存映射文件 mmap
流程:open打开文件 → mmap:把磁盘上这个文件,映射到进程虚拟地址空间一段连续的虚拟地址。
✅ 缺页中断自动完成:访问虚拟地址,如果页面不在内存,OS 自动从磁盘文件读入内存;页面被修改变成脏页,OS 在合适时机(进程关闭文件、munmap)自动把脏页写回磁盘文件区。
记得ppt上的流程
open 系统调用——打开文件
mmap 系统调用——将文件映射到进
程的虚拟地址空间
• 以访问内存的方式访问文件数据
• 文件数据的读入、写出由操作系统
自动完成
• 进程关闭文件时,操作系统自动将文件被修改的数据写回磁盘
多个进程可以映射同一个文件,实现共享
在物理内存中,一个文件对应同一份数据,当一个进程
修改文件数据时,另一个进程可以立马“看到”