操作系统核心边界解析:线程、分页、系统调用与并发
简介:西安电子科技大学《操作系统》期末试题及答案解析,面向准备操作系统课程考试的高校学生、考研复试复习者及需要检验知识掌握程度的自学者,帮助梳理闭卷考试中的选择题、填空题等常见题型与核心考点。资源包共1个PDF文件,大小约118KB,试卷标注考试时间120分钟、四大题、满分100分,选择题与填空题各20分;题干为中英混合表述,内容覆盖进程与线程、CPU调度、分页与虚拟内存、局部性原理、i-node、磁盘访问时间、Linux权限与文件元数据、死锁、信号量及DMA等主题,并配有答案解析,便于对照题目定位概念盲区、理解出题方式与答题思路。现有207人学习,适合在考前进行限时模拟、按章节查漏补缺,也可作为课堂复习后的自测材料,快速建立操作系统课程的知识框架与题型意识。
1. 边界比定义更重要
操作系统这门课最反直觉的一点是:几乎所有考点都不在定义上,而在边界上。进程和线程的边界、用户态和内核态的边界、虚拟内存里虚拟地址和物理地址的边界、调度器在哪些时刻才真正插手——只要边界画错,选择题看着每项都像对的,大题写满了也未必拿分。这份某高校《操作系统》期末试题资料的价值不在答案字母,而在后面那几行解释:为什么线程既能实现在用户空间也能实现在内核、为什么 31 位地址拆出来正好是 1K 页、为什么 DMA 能让 CPU 完全袖手旁观。把这些线串成一张从硬件到系统调用的地图,「操作系统原理」就不再是需要背的东西。
2. 进程与线程:内核视角下到底共享了什么
2.1 用户级线程与内核级线程,差在一次陷入
题目里那句「Thread can be implemented in ___」问的其实是:线程表由谁维护。答案之所以是「用户空间或内核都可以」,是因为线程这个概念本身不绑定实现位置,绑定的是「谁来做调度决策」。
放到用户空间实现,运行时库自己维护一张线程表,切换线程就是保存几个 callee-saved 寄存器、换一下栈指针,连特权指令都不需要执行:
/* 用户级线程的上下文切换:全程在用户态完成 */ typedef struct uctx { void *sp; /* 保存的栈指针,指向该线程自己的栈 */ void (*entry)(void *); void *arg; } uctx_t; extern void ctx_switch(void **old_sp, void *new_sp); /* 汇编实现:压 callee-saved 寄存器后换栈 */ void thread_yield(uctx_t *from, uctx_t *to) { ctx_switch(&from->sp, to->sp); /* 没有 syscall,没有 CR3 切换,没有内核栈 */ }这段代码里最关键的信息是「没有」什么:没有 CR3 写入(页表基址不变),没有内核栈切换,没有 trapframe 保存。所以用户级线程切换便宜到几十纳秒,同一进程内的线程切换几乎免费。
代价同样明确:内核根本不知道这些线程存在,它眼里的这个进程是单线程的。于是任何一个线程执行阻塞式read,内核只会把整个进程挂起,同进程里其他就绪线程全被连坐。多核也救不了——内核只看到一个可调度实体,同一进程的用户级线程永远只能挤在一个核上。反过来在内核里实现线程,每个线程在调度器里有一条记录、一个内核栈和一个 TCB,创建要走系统调用、切换要陷入内核,但阻塞互不影响、能真正并行。混合模型则是把 n 个用户级线程复用到 m 个内核级线程上,各取一半好处。
2.2 一句话回答「线程不共享什么」
「进程内所有线程共享哪些资源」这道题,背答案很容易,但真正记住要看数据结构长什么样。常见的实现思路是把「属于地址空间的东西」放进进程结构,把「属于执行流的东西」放进线程结构:
struct thread { struct trapframe *tf; /* 私有:rax..r15、rip、rsp、rflags 的现场 */ void *kstack; /* 私有:内核栈,系统调用时用 */ void *ustack; /* 私有:用户栈,线程之间不能共用 */ int tid; struct proc *proc; /* 指回所属进程 */ struct thread *next; /* 挂调度队列用 */ }; struct proc { pagetable_t *pgdir; /* 共享:页表,切线程时完全不换 */ struct file *ofile[NOFILE]; /* 共享:打开文件表 */ struct inode *cwd; /* 共享:当前工作目录 */ struct sigaction sigact[NSIG];/* 共享:信号处理方式 */ int pid; };判断依据非常机械:pgdir在proc里,所以两个线程访问同一个虚拟地址会落到同一个物理页,堆上的全局变量天然互相可见;ofile在proc里,所以一个线程close掉的文件,另一个线程再读就是EBADF。而寄存器现场必须在thread里,因为 PC 决定下一条执行哪条指令、SP 决定往哪个栈压数据,这两样一旦共享,两个线程就会跑同一条指令流、压同一个栈,直接崩。
顺着这个结构看,就明白题目问的是哪个字段的归属:地址空间、打开文件、记账信息都在进程结构里,只有寄存器是线程私有的。
2.3 三级调度各管哪一段
短期调度(short-term schedule)就是 CPU 调度,从就绪队列里挑一个进程上处理器,频率最高,毫秒甚至微秒级一次,它的决策必须极快,所以只看优先级和时间片,不做任何重量级判断。这也是为什么题目把 short term schedule 对应到 CPU scheduling——它决定的是「此刻谁用 CPU」,不是「谁被创建」也不是「谁被换出」。
长期调度(long-term schedule,作业调度)决定哪个作业被选中、创建成进程进入内存,它控制的是多道程序度。批处理系统里这个度调得太高,每个进程分到的时间片就碎,交互体验崩;调得太低,CPU 又闲。中期调度(medium-term schedule,内存调度)管的是换出换入:内存紧张时挑一个进程挂起到磁盘,等内存宽裕再换回来,对应进程状态图里那个「挂起」态。
三级调度的分工本质是三种不同时间尺度的资源:CPU 是微秒级的,内存是毫秒到秒级的,作业吞吐是分钟级的。用同一套逻辑管三种尺度,必然有一头顾不好。
2.4 系统调用到底走了哪几步
Part 3 第一题问系统调用,标准答法是「内核提供给用户的一组接口」,但真正体现理解的是那条路径。用户态把系统调用号放进rax,参数依次放rdi、rsi、rdx、r10、r8、r9,然后执行syscall指令;CPU 依据 MSR 里预先登记的入口地址跳到内核,同时完成栈切换和rip/rflags保存;内核拿rax去查分派表。
/* 系统调用分派表:用户态那条 syscall 指令最终落到这里 */ static long (*sys_call_table[])(struct trapframe *) = { [SYS_read] = sys_read, [SYS_write] = sys_write, [SYS_fork] = sys_fork, [SYS_open] = sys_open, }; void syscall_entry(struct trapframe *tf) { if (tf->rax >= NR_SYSCALLS || !sys_call_table[tf->rax]) tf->rax = -ENOSYS; else tf->rax = sys_call_table[tf->rax](tf); /* 返回值回填 rax,由 sysret 带回用户态 */ }tf->rax在这里身兼两职:进来时是调用号,出去时是返回值。这也是「一次系统调用两次特权级切换」说法的由来——一次陷入、一次返回,中间那次查表分派才是真正的业务。需要额外注意的是,参数里的用户态指针在进入内核后必须校验并拷贝(copyin),否则用户传一个内核地址进来,内核就替它读了不该读的内存。
3. 分页与地址翻译:31 位地址是怎么被切开的
3.1 位宽是唯一的信息源
题目给了一张图:31 到 10 位是页号,9 到 0 位是页内偏移。它没告诉你页大小,也没告诉你页数,全靠位宽反推。偏移占 10 位,意味着页内能寻址 2^10 = 1024 字节;页号占 22 位,意味着最多 2^22 = 4M 个页。选 B。
def decode(addr_bits, offset_bits): page_size = 1 << offset_bits # 页大小 = 2^偏移位数 pages = 1 << (addr_bits - offset_bits) # 页数 = 2^页号位数 return page_size, pages print(decode(32, 10)) # (1024, 4194304) -> 1K 页、4M 页 print(decode(32, 11)) # (2048, 2097152) -> 2K 页、2M 页两个参数的含义必须说清:addr_bits是虚拟地址总位宽,offset_bits是低位偏移占的位数。分页系统里页大小永远是 2 的幂,正是为了让「除法和取余」退化成「移位和掩码」——硬件不做除法,右移 10 位得页号,与0x3FF得偏移。很多人在这里翻车,是因为看到「2M pages at most」就以为是页大小的暗示,其实 4M 页对应的是 32 位地址减 10 位偏移,加减法算错一位,四个选项全像对的。
3.2 TLB 打在地址翻译路径上的补丁
分页的代价藏得很深:页表本身在内存里,一次虚拟地址翻译要先读一次页表项,再读一次目标数据。也就是说,一条普通的mov在开了分页之后变成两次访存,性能直接腰斩。TLB(Translation Lookaside Buffer)就是针对这条路径的缓存,它放在 MMU 里,是一组小容量的相联存储器,表项结构大致是:
struct tlb_entry { unsigned long vpn : 40; /* 虚页号,命中比较时当 tag 用 */ unsigned long ppn : 24; /* 物理页框号,命中后直接拼出物理地址 */ unsigned long asid : 16; /* 地址空间标识,避免切换进程就全表刷掉 */ unsigned int valid : 1; unsigned int dirty : 1; /* 该页被写过,换出时要回写磁盘 */ unsigned int prot : 3; /* 读 / 写 / 执行权限 */ };命中时,MMU 用虚页号做一次并行比较,直接拿到物理页框号,省掉那次页表访存;未命中才走页表遍历,并把结果回填。asid字段是后加的补丁:早期 x86 没有 PCID,一写 CR3 切页表,整个 TLB 就得刷掉,切换进程后前几百条指令全是冷启动的缺页式延迟。加上地址空间标识之后,不同进程的翻译结果可以共存,切换只换一个标识。
为什么几十条表项就能覆盖工作集?因为页访问的局部性比指令级局部性更粗:一个程序在一个时间窗口里只会碰少数几个页,TLB 装得下。这也是虚拟内存和 TLB 能互相成全的原因。
3.3 局部性原理不是「顺序执行」
选择题里「程序按顺序执行代码」是个漂亮的干扰项。局部性讲的是两件事:时间局部性——刚被访问的指令或数据很可能马上再被访问(循环体、计数器变量);空间局部性——访问了地址 x,x 附近的地址很快也会被访问(数组遍历、顺序取指)。它描述的是一种「访问概率分布不均」,不是「执行流线性」。
虚拟内存整套机制建立在这个前提上:进程可以声明 8 页的地址空间,物理内存只给 4 个帧,靠换入换出把当前工作集留在内存里。如果程序真的均匀随机访问整个地址空间,命中率会低到每次访问都缺页,系统直接颠簸到不可用。Denning 提出的工作集模型就是把这件事量化:给一个时间窗口 τ,窗口内被访问的页集合就是工作集,操作系统只要保证工作集在内存里,缺页率就压得住。
3.4 页故障与地址范围:把除法做对就行
题目给的条件是页大小 2K、物理内存 4 个帧、进程需要 8 页,页表里第 0、1、3、5 页无效。虚拟页 i 覆盖的地址范围就是[i*2048, (i+1)*2048 - 1]:
| 虚页 | 有效位 | 物理帧 | 虚拟地址范围 |
|---|---|---|---|
| 0 | No | — | 0 – 2047 |
| 1 | No | — | 2048 – 4095 |
| 2 | Yes | 1 | 4096 – 6143 |
| 3 | No | — | 6144 – 8191 |
| 4 | Yes | 3 | 8192 – 10239 |
| 5 | No | — | 10240 – 12287 |
| 6 | Yes | 0 | 12288 – 14335 |
| 7 | Yes | 2 | 14336 – 16383 |
翻译一个具体地址只做两步:整除得页号,取余得偏移。8500 除以 2048 得 4 余 308,页 4 有效且映射到帧 3,物理地址就是 3×2048+308 = 6452;1400 落在页 0 的区间,页 0 无效,直接缺页;5000 是页 2 偏移 904,帧 1,物理地址 2952;2100 是页 1 偏移 52,缺页。
PAGE_SIZE = 2048 page_table = {0: None, 1: None, 2: 1, 3: None, 4: 3, 5: None, 6: 0, 7: 2} def translate(va): vpn, off = divmod(va, PAGE_SIZE) # 硬件等价于 va >> 11 与 va & 0x7FF frame = page_table[vpn] if frame is None: return f"page fault @ vpn={vpn}" return f"pa={frame * PAGE_SIZE + off}" print(translate(8500), translate(1400), translate(5000), translate(2100))真正的缺页处理比这个函数长得多:陷入内核、查页表确认确实无效、挑一个牺牲帧、脏页回写、从磁盘读入目标页、更新页表、把有效位置 1、重新执行那条触发缺页的指令。注意最后一步是「重新执行」而不是「继续执行下一条」——因为陷入时那条指令的副作用还没发生,必须原样重放,这也是缺页处理必须可重入的原因。
4. 并发与死锁:P/V、管程和安全序列的实现细节
4.1 信号量的原子性靠哪条指令
信号量 S 是个整数,但只能被 P 和 V 两个原语碰。P 的语义是「减一,如果减完小于零就阻塞自己」,V 是「加一,如果加完小于等于零就唤醒一个等待者」。注意那个「小于零」而不是「小于等于零」:S 为负时,绝对值恰好等于等待队列长度,这个设计让一个整数同时承载了「剩余资源数」和「等待者数量」两个信息。
void P(sem_t *s) { disable_interrupts(); /* 单核上用关中断拿到最便宜的原子性 */ s->value--; if (s->value < 0) { enqueue(s->waitq, current); /* 挂到等待队列,状态置为阻塞 */ enable_interrupts(); schedule(); /* 让出 CPU,被 V 唤醒后从这里返回 */ } else { enable_interrupts(); } } void V(sem_t *s) { disable_interrupts(); s->value++; if (s->value <= 0) { wakeup(dequeue(s->waitq)); /* 只唤醒,不立刻切换过去 */ } enable_interrupts(); }单核上关中断够了,多核上不够——disable_interrupts只屏蔽当前核心,别的核心照样能同时执行 P。所以现代实现靠硬件原子指令:x86 的lock xadd、lock cmpxchg,ARM 的ldrex/strex或 LSE 的ldadd,用一条不可被打断的读改写完成「减一并判断」。这也是为什么信号量这类原语必须和内核配合:原子性归硬件,阻塞和唤醒归内核,两者缺一不可。
4.2 管程把互斥藏进了语法
管程是一组过程、共享变量和初始化代码打包成的模块,外加一个隐含的互斥锁。进程可以随时调用管程里的过程,但同一个管程里任意时刻只能有一个进程在活动,这个互斥由编译器或运行时自动加上,不需要调用者写一行 P/V。对写并发代码的人来说,这是从「自己管锁」到「结构上保证互斥」的跨越。
条件变量是管程里的等待队列,只有wait和signal两个操作。wait的关键在于「挂起自己」和「释放管程互斥锁」必须是原子的——如果先放锁再挂起,中间那段窗口里别的进程可能已经signal过来,信号就丢了,自己再也醒不过来。所以pthread_cond_wait(&cond, &mutex)的第二个参数必须传管程锁,由运行时保证这两步不可分割。
signal的语义有两种流派:Hoare 语义是唤醒者立刻把管程让给被唤醒者,被唤醒者马上运行;Mesa 语义是唤醒者继续跑完,被唤醒者只进就绪队列。pthread 和 Java 都是 Mesa 语义,所以被唤醒后条件可能又被别人改掉了——这就是必须用while重新检查条件的根本原因,它跟「虚假唤醒」是同一件事的两面。
4.3 生产者消费者那六个空
这段代码用的是单槽缓冲区:buffer = 0表示空,非 0 表示满并存着数据。把六个空还原出来:
void *producer(void *arg) { 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); /* ③ 放锁 */ } return NULL; } void *consumer(void *arg) { for (int i = 1; i <= MAX; i++) { pthread_mutex_lock(&the_mutex); while (buffer == 0) /* ④ 槽空,消费者等 */ pthread_cond_wait(&condc, &the_mutex);/* ⑤ 等在生产者的对立条件上 */ int res = buffer; buffer = 0; pthread_cond_signal(&condp); /* 通知生产者可以填了 */ pthread_mutex_unlock(&the_mutex); /* ⑥ 放锁 */ printf("buffer=%d\n", res); } return NULL; }⑤ 填&condc而不是&condp是最容易错的一处:消费者必须等在自己「可能被满足」的那个条件变量上,生产者 signal 的也必须是这个变量,两边对不上就是永久阻塞。
② 和 ③ 的顺序值得多说一句。理论上先 signal 再 unlock 是安全的,pthread 允许持锁唤醒;但如果先 unlock 再 signal,中间会出现一个窗口:另一个生产者抢进来把 buffer 填满,然后才 signal,被唤醒的消费者醒来发现条件已经不成立。while能兜住这种情况,代价是一次空转。稳妥写法是持锁 signal、解锁收尾。
4.4 银行家算法:安全性检查就是一次模拟
Need = Max - Allocation,这一步只是减法,真正的逻辑在安全性检查:假设所有进程此刻都停住,手里资源都不还,只有 Available 可用;然后找一个 Need 不超过 Available 的进程,让它跑完,把它手里的 Allocation 全部归还,Work 变大,再找下一个。找不到可满足的进程而还有进程没完成,就判定不安全。
int safe <p> <a href="https://download.csdn.net/download/m0_51765271/86698951" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>