操作系统期末试题答案解析:分页地址、线程同步与磁盘调度
简介:面向高校操作系统课程期末复习的试题资料,聚焦西安电子科技大学《操作系统》期末试卷及配套答案解析,适合计算机相关专业学生考前自测与查漏补缺。资源包仅含1个PDF文件,大小约118KB,篇幅紧凑,核心内容为四大题、满分100分的闭卷试题及参考答案解析,便于打印或平板阅读。试题覆盖选择题与填空等题型,涉及计算机系统组成、线程实现方式、进程与CPU调度、页式存储管理、局部性原理、i-node目录项、磁盘访问时间、Linux权限位、DMA与中断驱动I/O、信号量、死锁必要条件及文件元数据等操作系统核心知识点。已有207人学习,可借助真题把握命题风格、题型分布与分值结构,并通过答案解析定位概念混淆和计算盲区,提升复习针对性。
1. 从第一道选择题说起:操作系统真正管的只有两样东西
一份《操作系统》期末试题里,最容易被一眼扫过的是第一道选择题——"计算机系统由什么组成"。多数人背下"硬件 + 系统程序 + 应用程序"就翻页,但它恰恰点破了整门课的前提:CPU 眼里只有地址、中断和几条特权指令,剩下的层次全是约定。页表是约定,i-node 是约定,信号量是约定,连 chmod 的 755 也是约定。这份试题及答案解析之所以值得逐题抠,不是因为它考了什么偏题,而是因为它把分页地址结构、线程实现、生产者消费者、银行家算法、磁盘调度这五条主线压在一张卷子里,逼你回答"为什么这样设计"。下面按"地址 → 并发 → 资源 → 设备"的链路,把每道题背后的机制拆开。
2. 分页地址结构:为什么 10 位偏移把页大小钉死在 1KB
2.1 偏移决定页大小,页号决定页表规模
卷子里那道地址结构题给了一张图:32 位地址,31~10 位是页号,9~0 位是偏移,问页大小和最大页数。很多人第一反应是"页号 22 位,那页大小就是 2 的 22 次方",这就是把两个字段的职责搞反了。偏移量负责在一个页面内部定位字节,它能寻址多大范围,页就有多大:10 位偏移意味着页内最多 1024 个字节,页大小就是 1KB。页号负责在一堆页面里挑一个,22 位页号意味着最多有 2²² = 4M 个页面。所以正确答案是"页大小 1K,最多 4M 页"。
这个约束是双向的。反过来推也一样:题干若说页大小 4K,那偏移就是 12 位;若说最多 16M 个页,那页号就是 24 位,地址总宽 36 位。做题时最稳的顺序是先看偏移定页大小,再看页号定页数,不要从地址总宽倒推,倒推时容易把"总位数"和"页号位数"混成一回事。
真正值得多问一句的是:4M 个页表项意味着什么?每个页表项按 4 字节算,单级页表就是 16MB,而且这是每个进程一份。进程一多,光页表就能把内存吃干净。这就是多级页表存在的理由——把 22 位页号再切成 11 + 11,让顶层页表只覆盖真正被用到的地址区间,没被映射的那一大片根本不需要分配二级页表。常见的实现思路是:顶层页表常驻,二级页表按需创建,缺哪块补哪块。
页表项本身也不是只有物理页框号。典型的表项里还塞着有效位、修改位(脏位)、访问位和保护位。有效位回答"这一页现在在不在内存",修改位告诉换出时要不要写回磁盘,保护位决定这次访问是读、写还是执行。这几个比特看着零碎,缺一个都会让内存管理出问题。
还有那道局部性原理的选择题,正确描述是"程序在任意时刻只访问地址空间中相对小的一部分"。这句话不是修辞,它是虚拟内存能成立的全部前提:如果程序真的均匀随机地访问几 GB 地址空间,页表再大都救不了你,每次访存都要缺页。时间局部性(刚用过的还会用)和空间局部性(用过的附近还会用)合起来,才让"用一小块物理内存扛住一个大地址空间"这件事在工程上可行,工作集理论就是从这个观察长出来的。
2.2 一次访存要走几层:TLB、页表、缺页
地址转换的完整路径,比卷面答案长得多。CPU 发出虚拟地址 → MMU 先查 TLB(转换检测缓冲区,一个很小的相联存储器,通常几十到几百项)→ 命中就直接拿到物理页框号,拼上偏移发出物理地址,全程不碰内存;未命中就去查页表,多级页表意味着一次地址转换可能要额外访问好几次内存;如果查到有效位是 0,就触发缺页异常,控制权交给内核。
缺页处理是整条链上最贵的一段:内核挑一个牺牲页,如果它是脏页就先写回磁盘,再从磁盘把目标页读进来,更新页表、置有效位,最后重新执行那条触发缺页的指令。注意是重新执行,不是跳过——指令必须在一个"所有页都在内存"的完整状态下跑完,否则算到一半地址又变了,状态就烂了。这也解释了为什么缺页率稍微高一点,性能就断崖式下跌:一次磁盘访问是毫秒级,一次内存访问是纳秒级,差了几个数量级,玄学般的卡顿往往就来自这里。
TLB 能起作用,靠的还是局部性。一个进程在任意时刻真正活跃的页面只有几十个,几百项的 TLB 足够覆盖。但 TLB 有个副作用:进程切换后,TLB 里全是上一个进程的映射,必须整体失效。常见的做法是给每个 TLB 表项加一个地址空间标识(ASID),切换时只换标识不刷表,省掉一轮冷启动。
2.3 把 8500 拆成页号和偏移:手算一次 page walk
综合题给的条件是:页大小 2K(2048 字节),物理内存 4 个页框,进程需要 8 个页,页表只标了哪几页有效、对应哪个物理页框。问三件事:每页的虚拟地址范围、哪些范围会缺页、几个具体地址转换后的物理地址。这类题最忌讳心算,写几行脚本把规则固化下来最保险。
PAGE_SIZE = 2048 # 2K 页大小,偏移占 11 位 page_table = {0: None, 1: None, 2: 1, 3: None, 4: 3, 5: None, 6: 0, 7: 2} # 虚拟页 -> 物理页框,None 表示有效位为 0 def translate(va): vpn = va // PAGE_SIZE # 页号 = 地址整除页大小 off = va % PAGE_SIZE # 页内偏移 = 地址对页大小取余 pfn = page_table.get(vpn) if pfn is None: # 有效位为 0,触发缺页 return vpn, off, None return vpn, off, pfn * PAGE_SIZE + off # 物理地址 = 页框号 * 页大小 + 偏移 for va in (8500, 1400, 5000, 2100): print(va, translate(va))这段代码的逻辑很直白:整除取页号、取余取偏移,查页表拿到物理页框号,再反向拼成物理地址。参数上唯一需要注意的是除数和被除数都必须是字节数,一旦把 2K 写成 2000,后面全错——这也是十进制与二进制混用最常见的翻车点。
跑出来的结果是:8500 落在虚拟页 4(有效,物理页框 3),偏移 308,物理地址 3×2048+308 = 6452;5000 落在虚拟页 2(有效,物理页框 1),偏移 904,物理地址 2952;1400 落在虚拟页 0,有效位为 0,缺页;2100 落在虚拟页 1,同样缺页。虚拟页 0~7 的范围就是 0~2047、2048~4095 一路推到 14336~16383,逐页加上有效位,缺页区间自然就浮出来了。
提示:转换前先查有效位,是这类题的硬规矩。有效位为 0 时"物理地址"这个概念根本不存在,硬算出来的数字没有任何意义。
3. 进程与线程:为什么"寄存器不共享"几乎年年考
3.1 共享贵的、独享便宜的
线程那道选择题问"进程内所有线程不共享哪一项",答案是寄存器。要理解它,得先看一个进程到底持有什么:进程控制块里存着状态、程序计数器、寄存器现场、调度优先级、内存管理信息、打开文件表、记账信息、I/O 状态。这些东西粗略分成两类——一类切换起来很贵,比如地址空间,换一次要动页表基址寄存器、要让 TLB 失效;另一类切换很便宜,比如几个通用寄存器和栈指针,保存恢复就是几十条指令。
线程的设计动机就一句话:把贵的共享,把便宜的私有。同一进程的所有线程共用同一个地址空间、同一张打开文件表、同一份统计信息,所以创建销毁、上下文切换的开销比进程小一到两个数量级;但每个线程必须有自己的程序计数器、寄存器组和栈,否则两个线程同时跑到不同函数里就互相踩栈了。寄存器是执行流的"当前位置",天然不能共享,这就是那道题的答案来源。
3.2 三级调度与线程的三种实现
短期调度那道题选"CPU 调度",因为它属于三级调度里最频繁的一级。长期调度又叫作业调度,决定哪些程序被选中创建进程,控制多道程序度;中期调度又叫内存调度,通常配合换出换入,决定哪个进程被调出内存;短期调度才是每次时间片用完、每次中断返回都要跑一遍的那个,从就绪队列里挑一个占 CPU。三级调度的时间尺度差了好几个数量级,混淆它们就很容易把"进程挂起"和"进程就绪"说成一回事。
线程的实现方式那道题选"内核空间或用户空间",因为三种形态都真实存在。用户空间实现里,内核根本不知道线程的存在,线程库在用户态自己做完创建和切换,好处是不用陷入内核、切换极快,代价是内核仍然按单线程进程来调度——一个线程发起阻塞系统调用,整个进程都被挂住,而且用不上多核。内核空间实现里,创建撤销线程走系统调用,内核线程表里有一项一记录,能真正多核并行,代价是每次切换都要陷入内核。混合实现把用户级线程多路复用到若干内核线程上,兼顾切换成本和并行能力。
这三种实现的分歧,本质上是同一个设计问题:调度权的边界划在哪里。划在用户态,灵活但内核不认账;划在内核态,能力完整但每次操作都要跨特权级。看懂这个取舍,比背三种名字有用。
3.3 生产者消费者的六个空:条件变量为什么必须裹在 while 里
综合题给了一段 pthread 版本的生产者消费者代码,挖了六个空。要填对,得先理解pthread_cond_wait的语义:它接收一把互斥锁和一个条件变量,内部原子地做两件事——释放锁、把当前线程挂到条件变量的等待队列上;被唤醒返回时,它会重新加上那把锁。这个"原子释放并睡眠"是整段代码的关键,没有它就会出现经典的丢失唤醒:线程检查完条件发现不满足,还没来得及睡下去,另一个线程就发了信号,等它真睡下去,信号已经飘走了,两边一起卡死。
#include <pthread.h> pthread_mutex_t the_mutex; pthread_cond_t condc, condp; int buffer = 0; /* 0 表示空,非 0 表示有数据 */ void *producer(void *ptr) { for (int i = 1; i <= MAX; i++) { pthread_mutex_lock(&the_mutex); /* 进入临界区 */ while (buffer != 0) /* ① 缓冲区非空则等 */ pthread_cond_wait(&condp, &the_mutex); buffer = i; /* 写数据,仍在锁内 */ pthread_cond_signal(&condc); /* ② 唤醒消费者 */ pthread_mutex_unlock(&the_mutex); /* ③ 释放锁 */ } pthread_exit(0); } void *consumer(void *ptr) { for (int i = 1; i <= MAX; i++) { pthread_mutex_lock(&the_mutex); while (buffer == 0) /* ④ 缓冲区空则等 */ pthread_cond_wait(&condc, &the_mutex); /* ⑤ 等 condc */ int res = buffer; buffer = 0; pthread_cond_signal(&condp); /* 唤醒生产者 */ pthread_mutex_unlock(&the_mutex); /* ⑥ 释放锁 */ printf("buffer=%d\n", res); /* 打印放在锁外 */ } pthread_exit(0); }这段代码里最容易被扣分的不是填空本身,而是while和if的选择。条件变量的唤醒语义是"通知",不是"交接":被唤醒的线程从条件队列回到就绪队列,还要重新竞争互斥锁,等它真正拿到锁时,条件可能已经被第三个线程改掉了;再加上虚假唤醒(没有 signal 也可能返回),所以醒来必须重判条件,while是唯一正确的写法。用if在单生产者单消费者时可能侥幸跑通,一放到多方竞争就翻车。
第二个细节是pthread_cond_wait的锁参数为什么必须是同一把the_mutex。传别的锁进去,等待与条件检查就不在同一临界区内,丢失唤醒立刻出现。第三个细节是 signal 和 unlock 的先后:先 signal 再 unlock(卷面答案的顺序)能保证被唤醒的线程肯定能看到完整的数据,但被唤醒者会立刻阻塞在锁上,多一次上下文切换;常见的优化是把 signal 挪到 unlock 之后,用一点时序风险换更少的切换。两种写法在 Mesa 语义下都正确,选哪个看你对吞吐和清晰的取舍。
生产者和消费者的这套机制,抽象出来就是管程。管程把共享数据和操作它们的过程打包成一个模块,编译器或运行时在过程入口自动加锁、出口自动解锁,任一时刻只允许一个进程在里面活动。互斥被语言结构吃掉了,程序员只需要关心条件变量上的等待与唤醒——这正是简答题里"管程能有效完成互斥"的实际含义。
4. 死锁与磁盘调度:银行家算法到底在验算什么
4.1 Need 矩阵不是算出来的,是"还差多少才敢跑"的承诺
银行家算法那道题给了 Allocation 和 Max,第一步永远是把 Need 矩阵减出来——每个进程还差多少资源才能跑完。这一步只是记账,真正决定结果的是后面的安全性检查:维护一个 Work 向量(初始等于 Available)和一张 Finish 表(初始全 false),反复扫描进程集合,找到一个 Finish 为 false 且 Need ≤ Work 的进程,假设它顺利跑完并归还全部资源,于是 Work 加上它的 Allocation,Finish 置 true;一轮轮扫下去,如果所有进程都能被标记,系统就是安全的。
题面给出的初始 Available 是 (1,5,2,0),扫描下来只有 P0(Need 全 0)和 P3 能被满足,把它们的资源收回后 Work 仍然凑不出任何一个剩余进程的需求,扫不动了,所以系统处于不安全状态。注意"不安全"不等于"马上死锁"——它只是说存在一条通往死锁的路径,系统目前还没走上去。这个区别是概念题的高频陷阱。
第二问更有意思:P1 请求 (0,4,2,0) 能不能立刻批准。标准流程是三步:先判 Request ≤ Need,再判 Request ≤ Available,两条都过了也不能直接给,得试探性分配——假装把资源给出去,改 Allocation、Available、Need,然后在新状态上重跑一遍安全性检查;能排出 P0、P3、P1、P2、P4 这样一条全通序列,才真正批准,排不出来就回滚,让 P1 继续等。
这套流程看着繁琐,逻辑其实很朴素:死锁是"未来某个时刻所有人互相卡住",所以判断标准不能是"现在够不够",而必须是"给出去之后,还存在不存在一条让所有人都跑完的顺序"。这也是银行家算法最被诟病的地方——它假设每个进程会提前声明最大需求,且实际运行中资源需求单调递增、归还及时,现实系统里这两个前提基本不成立,所以它更多活在教材和考试里。真正在生产环境里防死锁,靠的是破坏死锁四个必要条件之一:互斥、持有并等待、不可剥夺、循环等待。其中最容易动手的是循环等待,把所有资源统一编号、强制按序申请,环路从结构上就不可能出现。
4.2 三种磁盘调度的差距从哪来:876ms 到 348ms
磁盘那道题给了七个请求柱面 10、22、20、2、40、6、38,磁臂初始在 20,每移动一个柱面 6ms。三种算法算下来差距非常直观。
| 算法 | 访问序列 | 总移动柱面数 | 寻道时间 |
|---|---|---|---|
| FCFS | 20→10→22→20→2→40→6→38 | 146 | 876 ms |
| SSTF | 20→22→10→6→2→38→40 | 60 | 360 ms |
| SCAN(向上) | 20→22→38→40→10→6→2 | 58 | 348 ms |
先看 FCFS,老老实实按到达顺序走:20 到 10 是 10 个柱面,10 到 22 是 12,22 到 20 是 2,20 到 2 是 18,2 到 40 是 38,40 到 6 是 34,6 到 38 是 32,加起来 146 个柱面,乘以 6ms 就是 876ms。这里能看出 FCFS 的问题:磁臂像钟摆一样来回甩,两个方向上反复横跳,总位移被无谓的往返撑大了。
SSTF 每次挑离当前位置最近的请求:20 旁边就是 22,然后回手到 10,再到 6、2,然后一口气冲到 38、40。总位移 60 个柱面,360ms,比 FCFS 快了一倍多。但 SSTF 有个致命缺陷——饥饿。如果请求流里持续有靠近磁臂当前位置的新请求到达,远端的那批请求可能永远轮不到,血泪经验就是这么攒出来的。
SCAN 算法(电梯算法)先沿一个方向走到底,再反向:初始向上,从 20 到 22,一路扫到 38、40,然后掉头向下到 10、6、2。总位移 58 个柱面,348ms。它比 SSTF 略快是这道题的巧合,真正的价值在于有界——每个请求最多等磁臂扫完一个来回,不会被无限期饿死。SCAN 的变体还有 C-SCAN(单向扫描,回程不服务,等待更均匀)和 LOOK(走到最远请求就掉头,不空跑到磁盘端点),工程实现里 LOOK 最常见,因为它省掉了无意义的空程。
这道题还有个隐含知识点:为什么寻道时间能主导总耗时?一次磁盘读写的时间由三部分组成——寻道(移动磁臂到目标柱面)、旋转延迟(等目标扇区转到磁头下)、数据传输。机械臂移动是毫秒量级,7200 转的盘平均旋转延迟在 4ms 左右,而传一个扇区只要几十微秒。数量级一摆,优化重心自然落在减少寻道次数上,这就是所有磁盘调度算法都在折腾"访问顺序"的原因。换成固态盘,寻道时间这个概念基本消失,算法也就失去了意义。
顺带把 I/O 那道选择题一起收掉。程序控制 I/O 是 CPU 亲自轮询状态寄存器,全程占着处理器干等;中断驱动 I/O 让 CPU 在设备就绪前先去干别的,设备好了发中断,CPU 再进来搬数据,但每个字的搬运仍然要 CPU 参与;DMA 则是把一整块数据的搬运交给 DMA 控制器,CPU 只在开始时配置好源地址、目的地址和长度,传输结束后被中断通知一次。所以"不需要 CPU 参与数据块传输"的答案是 DMA——注意是"数据块传输过程"不参与,配置和收尾还是要管的。
5. 常见问题排查:这五类题最容易翻在同一个坑里
5.1 地址与页表:三个每年都有人错的点
现象一:地址结构题算出"页大小 4M、页数 1K"。原因很统一——把页号和偏移的职责对调了,看到 22 位就以为页大小是 2²²。解决方式是把两个字段的含义用一句话念出来:"偏移管页内,页号管页数",然后先算 2^10 = 1K 的页大小,再算 2^22 = 4M 的页数,顺序不能反。
现象二:虚拟地址范围写成 0~2000、2000~4000。原因是用十进制直觉去切二进制边界,页大小 2K 是 2048,不是 2000。解决方式是先把页大小写成 2 的幂(2K = 2¹¹),所有边界都用页大小整数倍去乘,最后一页的上界是"页号 × 页大小"到"(页号+1)× 页大小 − 1",别用开区间混着写。
现象三:对一个有效位为 0 的虚拟地址,硬算出了物理地址。原因是只做了除法取余,忘了查有效位。解决方式是转换流程固定成三步:先查页表项是否存在,存在才拼物理地址,不存在就明确写"缺页"。这个顺序在真实内核里也一样,缺页异常正是在这一步被触发的。
5.2 同步与调度:两个看起来对、跑起来错的写法
现象四:生产者消费者用if判断条件,单跑没问题,一加线程就死锁或数据错乱。原因是条件变量的唤醒不保证条件成立——被唤醒的线程要重新竞争锁,等它拿到锁时缓冲区状态可能又被改了,加上虚假唤醒的存在,只判一次必然出问题。解决办法就是把if换成while,让线程醒来后重新检查条件,不满足就继续等。
现象五:银行家算法里看到 Available ≥ Request 就直接批准。原因是把"当前资源够"当成了"分配后安全",这两件事完全不是一回事——资源够但分配后所有进程互相等待,死锁照样发生。解决办法是养成固定动作:判完 Need 和 Available 之后,先做试探性分配,再在新状态上完整跑一遍安全性检查,不安全就回滚,一步都不能省。
注意:安全性检查要跑在"假设已分配"的新状态上,直接在旧状态上验算,得到的结论没有意义。
6. 设计取舍:从 755 到 2 字节 i-node,操作系统为什么总在做减法
把整张卷子翻完,会发现一个反复出现的模式:几乎每道题的标准答案,都是某种"把无限压成有限"的妥协。
chmod 那道题最典型。owner 可读写执行、其他人只读,答案是 744 而不是 755——因为权限位只有三位八进制,r=4、w=2、x=1,三个数字分别对应属主、属组、其他,7 是 4+2+1,4 是纯读。这套编码省到了极致:一个文件的全部权限信息只占 9 个比特,却能表达 512 种组合,而且能用三个数字手打出来。代价是没有留任何扩展位,后来想加 ACL、想加 setuid 之外的语义,只能另开机制。
i-node 那道题是同一个思路的另一个样本。目录项只有文件名 14 字节加 i-node 编号 2 字节,2 字节就是 16 位,所以单个文件系统的文件数被硬性限制在 64K。设计者当然知道 64K 会不够用,但目录项做小,目录本身就能塞进更少的磁盘块,路径查找的 I/O 次数随之下降。这就是取舍:牺牲上限,换查找效率。后来的文件系统把编号位宽加到 32 位甚至 64 位,代价是每个目录项变胖、目录占用的块变多。
页大小也是同一笔账。1KB 的页让页内碎片小、页表项少,但页表项总数变多;4KB 的页反过来。选 4K 不是因为它"更正确",而是在碎片浪费和页表开销之间找了个当时最划算的点。至于单级页表还是多级页表,本质上是"用地址转换的多次访存,换页表本身的内存占用"——TLB 存在的意义,就是把多级页表带来的额外访存再补回去。
我自己的习惯是:每读完一套这样的题,不看答案,先把每道题的"约束从哪来"写一行在旁边——页大小来自偏移位数,文件数来自目录项字段宽度,安全序列来自 Work 向量的递推。写完再对答案,错的那几行就是真正没懂的地方。从那以后我每次碰到类似的设计题,都强制先找出"被压缩的那个维度"和"为此付出的代价",再去看实现。希望这套思路,能帮你在下一次翻开操作系统的时候,少背几页,多看穿几层。
本文还有配套的精品资源,点击获取