文章

操作系统面试题 —— 从进程管理到虚拟内存的深度问答

覆盖进程与线程、CPU 调度、死锁、内存管理、虚拟内存、页面置换、文件系统、IO、中断、系统调用十大核心主题,30 道高频面试题附原理图解

操作系统面试题 —— 从进程管理到虚拟内存的深度问答

操作系统是”计算机的大管家”——管理 CPU、内存、磁盘、网络,让所有程序和谐共处。面试中,操作系统题目出现频率极高,因为它直接反映你对”计算机到底怎么跑起来的”这个问题的理解深度。

这篇文章的组织思路:每道题先给出”一句话记忆点”,再展开原理,最后附上面试回答要点。不求面面俱到,但求把面试高频考点讲透。


第一部分:进程与线程

Q1:进程和线程的区别?

记忆点:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。进程有独立地址空间,线程共享所属进程的地址空间。

维度进程线程
定义程序的一次执行实例进程中的一条执行路径
地址空间独立共享所属进程的地址空间
资源拥有独立的文件描述符、信号处理等共享进程资源,有自己的栈和寄存器
创建开销大(需要分配地址空间、页表等)小(只需分配栈和少量管理结构)
切换开销大(需要切换页表、刷新 TLB)小(同进程线程切换不需要切换页表)
通信方式需要 IPC(管道、共享内存等)直接读写共享变量(需同步)
安全性一个进程崩溃不影响其他进程一个线程崩溃可能导致整个进程崩溃

面试加分: 线程共享的是代码段、数据段、堆、文件描述符表;线程私有的是栈、寄存器、线程 ID、errno、信号掩码。

Q2:进程有哪些状态?状态之间怎么转换?

记忆点:五状态模型——新建、就绪、运行、阻塞、终止。核心转换:就绪 → 运行(调度)、运行 → 就绪(时间片用完)、运行 → 阻塞(等 IO)、阻塞 → 就绪(IO 完成)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
          创建
           |
           v
  +------[新建]
  |        |
  |     admit(许可)
  |        |
  |        v
  |  +->[就绪] <---+
  |  |     |       |
  |  |  dispatch   | IO 完成 / 事件到来
  |  |  (调度)     |
  |  |     |       |
  |  |     v       |
  |  +--[运行]-->[阻塞]
  |  时间片  |
  |  用完    | exit
  |          v
  +------>[终止]

关键转换触发条件:

  • 就绪 → 运行: CPU 调度器选中该进程(dispatch)
  • 运行 → 就绪: 时间片用完(抢占式调度)、更高优先级进程到来
  • 运行 → 阻塞: 发起 IO 请求、等待锁、等待信号量、sleep
  • 阻塞 → 就绪: IO 完成、锁释放、信号到达
  • 运行 → 终止: 进程执行完毕、被 kill

注意:阻塞状态 不能直接 转为运行状态,必须先回到就绪队列排队。

Q3:进程间通信(IPC)有哪些方式?

记忆点:管道、命名管道、消息队列、共享内存、信号量、信号、Socket。共享内存最快但需要同步,Socket 最通用可跨网络。

方式特点适用场景
管道(Pipe)半双工,父子进程间简单的父子进程通信
命名管道(FIFO)半双工,不限亲缘关系无关进程间通信
消息队列有格式的消息,可按类型读取需要消息分类的场景
共享内存最快,直接映射同一块物理内存大量数据传输
信号量计数器,用于同步控制并发访问
信号(Signal)异步通知机制通知进程某事件发生
Socket全双工,可跨机器网络通信、跨机器 IPC

面试高频追问: 共享内存为什么最快?因为它不需要内核态和用户态之间的数据拷贝,两个进程直接读写同一块物理内存。但正因如此,需要配合信号量等同步机制防止竞争。

更详细的 IPC 原理和代码实现,参考 进程间通信实战教程

Q4:fork() 的工作原理?父子进程的关系?

记忆点:fork 创建子进程,子进程是父进程的”几乎完整拷贝”。fork 返回两次——父进程返回子进程 PID,子进程返回 0。现代系统用 COW(写时复制)优化。

1
2
3
4
5
6
7
8
9
10
pid_t pid = fork();
if (pid < 0) {
    // 创建失败
} else if (pid == 0) {
    // 子进程:pid == 0
    printf("I am child, my PID = %d\n", getpid());
} else {
    // 父进程:pid == 子进程的 PID
    printf("I am parent, child PID = %d\n", pid);
}

写时复制(Copy-On-Write, COW):

fork 后父子进程共享同一份物理页面,页表标记为只读。当任何一方尝试写入时,触发缺页异常,内核才真正拷贝那一页。

  • 好处: 如果子进程 fork 后立刻 exec(执行新程序),旧地址空间直接丢弃,根本不需要拷贝
  • 体现: fork + exec 是 Unix 创建新程序的经典模式

Q5:孤儿进程和僵尸进程是什么?怎么处理?

记忆点:孤儿进程——父进程先退出,子进程被 init 收养,无害。僵尸进程——子进程退出但父进程没有 wait 回收,占用 PID 资源,有害。

类型产生原因危害解决方法
孤儿进程父进程先于子进程退出无害,被 init(PID=1) 收养不需要特殊处理
僵尸进程子进程退出,父进程未 wait占用 PID 和进程表项父进程调用 wait/waitpid

防止僵尸进程的方法:

  1. 父进程调用 wait/waitpid: 显式回收子进程
  2. 信号处理: 注册 SIGCHLD 信号处理函数,在处理函数中调用 waitpid
  3. 两次 fork: 父进程 fork 子进程,子进程再 fork 孙进程后立即退出。孙进程变成孤儿进程被 init 收养,不会产生僵尸
1
2
3
4
// 方法 2:信号处理
signal(SIGCHLD, [](int) {
    while (waitpid(-1, nullptr, WNOHANG) > 0);
});

Q6:线程的实现方式?用户态线程 vs 内核态线程?

记忆点:用户态线程由线程库管理,内核不感知,切换快但一个阻塞全阻塞。内核态线程由内核管理,一个阻塞不影响其他,但切换要陷入内核。Linux 用 1:1 模型(每个线程对应一个内核线程)。

模型描述优点缺点
多对一(N:1)多个用户线程映射到一个内核线程切换快、跨平台一个阻塞全阻塞
一对一(1:1)每个用户线程对应一个内核线程真正并行、阻塞不影响创建开销大
多对多(M:N)M 个用户线程映射到 N 个内核线程灵活、兼顾性能实现复杂

Linux 的实现:

  • Linux 内核不区分进程和线程,统一用 task_struct 表示
  • pthread_create 底层调用 clone 系统调用,通过标志位控制哪些资源共享
  • clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, ...) → 创建线程(共享地址空间)
  • clone(0, ...) → 类似 fork(不共享地址空间)

Q7:协程(Coroutine)是什么?和线程有什么区别?

记忆点:协程是用户态的轻量级”线程”,由程序自己调度(协作式),切换不需要陷入内核。一个线程可以跑成千上万个协程。

维度线程协程
调度方内核(抢占式)用户程序(协作式)
切换开销微秒级(陷入内核)纳秒级(保存/恢复寄存器)
并发/并行多核并行单线程并发(不是并行)
栈大小固定(通常 1-8MB)可动态调整(通常几 KB)
数量上限受内核限制(通常几千)可达百万级

C++20 协程:

1
2
3
4
5
6
// C++20 coroutine 示例(概念性代码)
task<int> compute() {
    int a = co_await async_read();   // 挂起,让出执行权
    int b = co_await async_read();   // 挂起,让出执行权
    co_return a + b;                 // 返回结果
}

协程的核心价值:用同步的写法实现异步的性能


第二部分:CPU 调度

Q8:常见的 CPU 调度算法有哪些?

记忆点:FCFS 简单但护航效应、SJF 最优但不可知、RR 公平但上下文切换多、优先级可能饥饿、多级反馈队列是现实中的综合方案。

算法全称原理优点缺点
FCFS先来先服务按到达顺序执行简单公平护航效应(短进程等长进程)
SJF最短作业优先选最短的执行平均等待时间最优无法预知运行时间,可能饥饿
SRTF最短剩余时间SJF 的抢占版比 SJF 更优同 SJF
RR时间片轮转每个进程执行一个时间片公平、响应快时间片选取关键
优先级优先级调度按优先级执行重要任务优先低优先级饥饿
MLFQ多级反馈队列多个优先级队列+动态调整综合最优实现复杂

面试重点——时间片大小的权衡:

  • 太大 → 退化为 FCFS,响应慢
  • 太小 → 上下文切换频繁,CPU 花在切换上
  • 经验值:10-100ms,且时间片要远大于上下文切换时间(通常几微秒)

多级反馈队列(MLFQ)核心规则:

  1. 新进程进入最高优先级队列
  2. 如果用完时间片(CPU 密集型),降到下一级队列(时间片更长)
  3. 如果在时间片内主动让出 CPU(IO 密集型),留在当前队列
  4. 定期将所有进程提升到最高优先级(防止饥饿)

Q9:什么是上下文切换?开销有多大?

记忆点:上下文切换就是保存当前进程/线程的 CPU 状态(寄存器、PC、栈指针等),恢复下一个进程/线程的状态。直接开销 3-5 微秒,间接开销(缓存失效)更大。

上下文切换保存/恢复的内容:

  • 通用寄存器(rax, rbx, rcx, …)
  • 程序计数器(PC/RIP)
  • 栈指针(RSP)
  • 状态寄存器(RFLAGS)
  • 进程切换还需要:页表基址寄存器(CR3)、刷新 TLB

间接开销(往往比直接开销更大):

  • CPU 缓存失效: 新进程的数据不在 L1/L2 cache 中,需要重新从内存加载
  • TLB 失效(进程切换): 页表基址变了,TLB 条目全部失效
  • 分支预测器失效: 新进程的分支模式不同

减少上下文切换的方法:

  • 使用线程代替进程(同进程线程切换不需要切换页表)
  • 使用协程(用户态切换,无需陷入内核)
  • 使用异步 IO(减少阻塞导致的切换)
  • CPU 亲和性(affinity)绑定,减少跨核迁移

Q10:什么是抢占式调度和非抢占式调度?

记忆点:抢占式——操作系统可以强制”打断”正在运行的进程(时间片用完、高优先级到来)。非抢占式——进程一旦开始运行就一直运行到结束或主动让出 CPU。现代操作系统基本都是抢占式。

  • 非抢占式(协作式): 进程必须主动放弃 CPU(通过 yield 或 IO)。如果一个进程死循环,整个系统就卡死。Windows 3.1 就是这样。
  • 抢占式: 操作系统通过时钟中断强制收回 CPU 控制权。Linux、Windows NT 以后都是抢占式。

内核态的抢占:

Linux 2.6 以后支持内核抢占(CONFIG_PREEMPT),即进程在内核态执行系统调用时也可以被抢占(在安全点),这提高了系统的实时响应性。


第三部分:死锁

Q11:什么是死锁?产生的四个必要条件?

记忆点:死锁就是一组进程互相等待对方持有的资源,谁也无法继续。四个必要条件——互斥、持有并等待、不可抢占、循环等待,缺一不可。

经典场景:

1
2
线程 A: lock(mutex1) → 尝试 lock(mutex2) → 等待...
线程 B: lock(mutex2) → 尝试 lock(mutex1) → 等待...

四个必要条件(全部满足才会死锁):

  1. 互斥(Mutual Exclusion): 资源同一时刻只能被一个进程使用
  2. 持有并等待(Hold and Wait): 进程持有至少一个资源,同时等待其他资源
  3. 不可抢占(No Preemption): 已分配的资源不能被强制收回
  4. 循环等待(Circular Wait): 存在一条进程链,每个进程都在等待下一个进程持有的资源

Q12:怎么预防、避免和检测死锁?

记忆点:预防——破坏四条件之一(最实用是破坏循环等待:按顺序加锁)。避免——银行家算法(运行时判断安全状态)。检测——资源分配图找环。

1. 预防死锁(破坏四条件之一):

破坏哪个条件方法实用性
互斥使用无锁数据结构困难
持有并等待一次性申请所有资源资源利用率低
不可抢占拿不到就释放已有的trylock 方式
循环等待对资源编号,按顺序申请最实用

2. 避免死锁(银行家算法):

每次分配资源前,检查分配后系统是否仍处于”安全状态”(存在一个安全序列使所有进程都能完成)。如果不安全,拒绝分配。

  • 需要预先知道每个进程的最大资源需求
  • 运行时开销大,实际系统中很少使用

3. 检测和恢复:

  • 定期运行死锁检测算法(资源分配图中找环)
  • 发现死锁后:终止进程 / 抢占资源 / 回滚到检查点

面试中最常问的实际方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 方案 1:按顺序加锁(破坏循环等待)
// 规则:永远先锁 id 小的,再锁 id 大的
void transfer(Account& from, Account& to, int amount) {
    Account& first  = (from.id < to.id) ? from : to;
    Account& second = (from.id < to.id) ? to : from;
    std::lock_guard<std::mutex> g1(first.mtx);
    std::lock_guard<std::mutex> g2(second.mtx);
    // 转账...
}

// 方案 2:std::lock 同时加多把锁(C++ 标准库)
void transfer(Account& from, Account& to, int amount) {
    std::lock(from.mtx, to.mtx);  // 原子地同时加两把锁
    std::lock_guard<std::mutex> g1(from.mtx, std::adopt_lock);
    std::lock_guard<std::mutex> g2(to.mtx, std::adopt_lock);
    // 转账...
}

// 方案 3:C++17 scoped_lock(最简洁)
void transfer(Account& from, Account& to, int amount) {
    std::scoped_lock lock(from.mtx, to.mtx);
    // 转账...
}

更详细的锁机制和并发编程面试题,参考 锁、并发编程与内存模型面试题


第四部分:内存管理

Q13:虚拟内存是什么?为什么需要虚拟内存?

记忆点:虚拟内存让每个进程以为自己拥有整个地址空间(32位 4GB / 64位 128TB),实际通过页表映射到物理内存。解决了三个问题:内存不够用、进程隔离、地址空间碎片。

虚拟内存的三大好处:

  1. 扩大可用内存: 物理内存只有 8GB,但可以运行需要 20GB 内存的程序——把暂时不用的页面换到磁盘(swap)
  2. 进程隔离: 每个进程有独立的虚拟地址空间,A 进程无法访问 B 进程的内存
  3. 简化编程: 每个进程都从 0 开始编址,不需要关心物理内存布局

虚拟地址 → 物理地址的转换过程:

1
2
3
4
5
6
7
虚拟地址: [页号(VPN)] + [页内偏移(Offset)]
              |
              v
         查页表(Page Table)
              |
              v
物理地址: [帧号(PFN)] + [页内偏移(Offset)]

Q14:分页和分段的区别?

记忆点:分页——固定大小(通常 4KB),对程序员透明,有内部碎片。分段——按逻辑划分(代码段、数据段、栈段),对程序员可见,有外部碎片。现代系统以分页为主,段主要用于保护。

维度分页分段
划分单位固定大小(4KB)可变大小(按逻辑)
对用户透明可见(代码段、数据段)
碎片类型内部碎片(最后一页可能没用满)外部碎片(段之间的空隙)
地址结构页号 + 页内偏移段号 + 段内偏移
共享以页为单位以段为单位(更自然)

Linux 实际使用的是”段页式”: 用分段实现保护(代码段只读、数据段可读写),用分页实现虚拟内存管理。但实际上 Linux 把所有段的基址都设为 0,段限长设为最大值,相当于”绕过”了分段,核心靠分页。

Q15:多级页表是什么?为什么需要?

记忆点:如果用一级页表,32 位系统需要 4MB 连续内存存页表(2^20 个页表项 × 4 字节),64 位系统更不可能。多级页表把页表也分页,用到哪级才分配,节省空间。

32 位系统的两级页表:

1
2
3
4
5
6
7
8
9
虚拟地址 (32 bit):
[一级页号 10bit] [二级页号 10bit] [页内偏移 12bit]
       |               |               |
       v               v               v
   一级页表 ──→ 二级页表 ──→ 物理页帧 + 偏移
  (页目录)    (页表)

一级页表: 1024 个条目, 4KB
每个二级页表: 1024 个条目, 4KB

多级页表的核心优势:

  • 一级页表如果某个条目标记为”无效”,对应的二级页表 根本不需要分配
  • 进程通常只用到地址空间的很小一部分(代码+数据+栈+堆),大量二级页表不需要创建
  • 以少量的额外查询开销,换来巨大的空间节省

64 位系统(x86-64)用四级页表: PGD → PUD → PMD → PTE → 物理页,Linux 5.x 支持五级页表。

Q16:TLB 是什么?为什么重要?

记忆点:TLB(Translation Lookaside Buffer)是页表的缓存,存在 CPU 里。查页表要访问内存(多级页表要多次),TLB 命中就不用查页表了。TLB 命中率通常 > 99%。

地址翻译的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
CPU 发出虚拟地址
       |
       v
   查 TLB ──命中──→ 直接得到物理地址(1 个时钟周期)
       |
     未命中
       |
       v
   查多级页表 ──→ 得到物理地址(多次内存访问,慢)
       |
       v
   更新 TLB(下次就命中了)

关键点:

  • TLB 通常只有 64-1024 个条目(全相联或组相联)
  • 程序的局部性原理保证了极高的命中率
  • 进程切换时 TLB 需要刷新(不同进程的页表不同),这是进程切换开销大的重要原因
  • 现代 CPU 用 ASID(Address Space Identifier) 给 TLB 条目标记进程 ID,避免切换时全部刷新

Q17:进程的虚拟地址空间布局是怎样的?

记忆点:从低到高——代码段(只读)、数据段(已初始化全局变量)、BSS段(未初始化全局变量)、堆(向上增长)、共享库映射区、栈(向下增长)、内核空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
高地址
+------------------+
|    内核空间        |  用户态不可访问
+------------------+  0xFFFF FFFF (32位) 或更高
|      栈 ↓        |  局部变量、函数调用帧
|                  |  向低地址增长
+------------------+
|                  |
|   共享库映射区     |  mmap, 动态链接库
|                  |
+------------------+
|      堆 ↑        |  malloc/new 分配
|                  |  向高地址增长
+------------------+
|    BSS 段         |  未初始化的全局/静态变量 (自动清零)
+------------------+
|    数据段          |  已初始化的全局/静态变量
+------------------+
|    代码段          |  程序指令 (只读、可执行)
+------------------+  0x0000 0000 (32位)
低地址

面试追问——堆和栈的区别?

维度
管理方式编译器自动分配/释放程序员手动(malloc/free)
增长方向向低地址增长向高地址增长
分配速度极快(移动栈指针)较慢(查找空闲块)
大小限制较小(通常 1-8MB)较大(受虚拟内存限制)
碎片有(外部碎片)
对齐自动对齐需要分配器保证

第五部分:页面置换

Q18:常见的页面置换算法有哪些?

记忆点:OPT 最优但不可实现,LRU 最常问但纯实现开销大,Clock(二次机会)是 LRU 的实用近似,Linux 实际用的是改进版 Clock。

算法原理优缺点
OPT置换未来最久不用的页最优,但不可实现(需要预知未来)
FIFO置换最早进入的页简单,但有 Belady 异常
LRU置换最近最久未使用的页效果好,但需要硬件支持或开销大
ClockLRU 的近似,用访问位实用,是 Linux 的基础
LFU置换使用频率最低的页区分热度,但对模式变化不敏感

LRU 的实现方式(面试重点):

1
2
3
4
5
6
7
8
方式 1:时间戳法
  每次访问更新时间戳,置换时间戳最小的。开销 O(n)。

方式 2:栈法
  每次访问把该页移到栈顶,栈底就是 LRU 页。O(1) 但需要链表操作。

方式 3:哈希表 + 双向链表
  O(1) get + O(1) put,面试手撕 LRU 就是这个方案。

LRU Cache 的 C++ 实现详解,参考 线程安全 LRU Cache TDD 实战

Q19:什么是 Belady 异常?

记忆点:FIFO 算法的反直觉现象——增加物理页帧数反而导致缺页率上升。LRU 和 OPT 不会出现这个问题(它们是”栈算法”)。

经典例子:页面引用序列 1,2,3,4,1,2,5,1,2,3,4,5

  • 3 个页帧:9 次缺页
  • 4 个页帧:10 次缺页(更多帧反而更差!)

这是 FIFO 算法的固有缺陷,也是面试官喜欢问的陷阱题。

Q20:什么是页面抖动(Thrashing)?怎么解决?

记忆点:页面抖动——进程频繁缺页,CPU 大量时间花在处理缺页异常和换页上,实际执行指令的时间很少。根本原因是进程的工作集大于物理内存。

触发条件:

  1. 系统中运行的进程太多
  2. 每个进程分到的物理内存不够其”工作集”
  3. 不断缺页 → 换入换出 → CPU 利用率反而下降
  4. 操作系统以为 CPU 空闲 → 调度更多进程 → 更严重的抖动

解决方案:

  1. 工作集模型: 监控每个进程的工作集大小,保证分配给它的帧数 ≥ 工作集大小
  2. 缺页率策略: 监控缺页率。太高→分配更多帧;太低→减少帧
  3. 减少多道程序度: 挂起部分进程,释放内存给活跃进程
  4. 加物理内存 — 最直接的解决方案

第六部分:文件系统

Q21:文件系统的作用?常见的文件系统有哪些?

记忆点:文件系统把磁盘上的裸块组织成文件和目录,提供命名、权限、一致性保护。Linux 用 ext4/XFS/Btrfs,Windows 用 NTFS。

文件系统使用场景特点
ext4Linux 默认成熟稳定,支持到 1EB
XFS大文件/高吞吐并行 IO 性能强
BtrfsLinux 新一代COW、快照、校验
NTFSWindows权限、加密、压缩
FAT32U盘/跨平台简单通用,4GB 文件限制
ZFS服务器/NAS企业级可靠性

Q22:inode 是什么?软链接和硬链接的区别?

记忆点:inode 存储文件的元数据(大小、权限、数据块位置),不存文件名。文件名在目录项中,指向 inode 号。硬链接是同一个 inode 的多个名字,软链接是一个独立文件存放目标路径。

1
2
3
4
5
6
7
8
9
10
11
12
目录项            inode                    数据块
+----------+    +-----------+    +------------------+
| "a.txt"  |--->| inode 42  |--->| 实际文件内容...    |
+----------+    | size:1024 |    +------------------+
| "b.txt"  |--/ | mode:644  |
+----------+    | nlink:2   |    (硬链接:a.txt 和 b.txt 指向同一个 inode)
                +-----------+

+----------+    +-----------+    +------------------+
| "c.txt"  |--->| inode 99  |--->| "/path/to/a.txt" |
+----------+    | 类型:链接  |    +------------------+
                +-----------+    (软链接:c.txt 是独立 inode,内容是路径)
维度硬链接软链接(符号链接)
本质同一个 inode 的多个目录项独立文件,内容是目标路径
跨文件系统不可以可以
链接目录不可以(防止循环)可以
删除原文件不影响(inode 引用计数 > 0)断链(悬挂链接)
inode与原文件相同不同

Q23:什么是磁盘调度算法?

记忆点:磁盘寻道是机械运动,很慢(毫秒级)。调度算法目标是减少磁头移动距离。SCAN(电梯算法)是实际中最常用的。SSD 随机访问性能接近顺序,调度算法意义不大。

算法原理特点
FCFS按请求顺序公平但磁头移动多
SSTF最短寻道优先性能好但可能饥饿
SCAN磁头单向扫描到底再反向电梯算法,均衡
C-SCAN单向扫描,回到起点不服务比 SCAN 更公平
LOOK/C-LOOKSCAN/C-SCAN 的改进,只到最远请求实际使用

第七部分:中断与系统调用

Q24:什么是中断?中断的分类?

记忆点:中断是 CPU 暂停当前执行、跳转到中断处理程序的机制。分为硬件中断(外部设备触发)和软件中断(指令触发,如 int 0x80)。系统调用就是通过软件中断/专用指令实现的。

中断分类:

1
2
3
4
5
6
7
8
9
10
中断
├── 外部中断(硬件中断)
│   ├── 可屏蔽中断(INTR):键盘、网卡、磁盘
│   └── 不可屏蔽中断(NMI):内存校验错误、电源故障
└── 内部中断
    ├── 异常(Exception)
    │   ├── 故障(Fault):可恢复,如缺页异常
    │   ├── 陷阱(Trap):系统调用(int 0x80 / syscall)
    │   └── 终止(Abort):不可恢复,如除零错误
    └── 软件中断:int 指令主动触发

中断处理流程:

  1. CPU 收到中断信号,完成当前指令
  2. 保存现场(压栈:PSW、PC、通用寄存器)
  3. 根据中断号查中断向量表(IDT)
  4. 跳转到对应的中断处理程序(ISR)
  5. 执行中断处理
  6. 恢复现场(出栈),继续执行被中断的程序

Q25:系统调用的过程是怎样的?用户态和内核态怎么切换?

记忆点:系统调用是用户程序请求内核服务的唯一合法入口。用户态 → 内核态通过 syscall 指令(x86-64)或 int 0x80(x86-32)触发,CPU 自动切换到内核栈并提升特权级。

write(fd, buf, len) 为例的完整过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
用户态代码                    内核态
─────────                    ─────
write(fd, buf, len)
    |
    v
libc 包装函数
    |  设置系统调用号 (rax = 1)
    |  设置参数 (rdi=fd, rsi=buf, rdx=len)
    |
    v
syscall 指令 ─────────────→ CPU 切换到内核态
                              |
                              v
                         保存用户态寄存器
                              |
                              v
                         查系统调用表 sys_call_table[1]
                              |
                              v
                         执行 sys_write()
                              |
                              v
                         恢复用户态寄存器
                              |
sysret 指令 ←─────────── CPU 切换回用户态
    |
    v
返回给用户程序

用户态 vs 内核态:

维度用户态(Ring 3)内核态(Ring 0)
权限受限,不能直接访问硬件完全权限
地址空间只能访问用户空间可以访问全部地址空间
指令不能执行特权指令可以执行所有指令
切换场景系统调用、中断、异常sysret/iret 返回用户态

Q26:中断上半部和下半部是什么?

记忆点:中断处理分两阶段。上半部(Top Half):关中断快速处理,只做最紧急的事(读硬件状态、应答中断)。下半部(Bottom Half):开中断延迟处理,做耗时操作(数据处理、协议解析)。

为什么要分两部分?

中断处理期间 CPU 不能响应新中断(或同级中断被屏蔽)。如果中断处理耗时太长,会导致:

  • 其他中断丢失
  • 系统响应延迟

Linux 下半部的实现方式:

方式特点使用场景
软中断(softirq)静态定义,高优先级网络收包(NET_RX)
tasklet基于软中断,动态创建驱动程序
工作队列(workqueue)在内核线程中执行,可以睡眠耗时操作

网卡收包为例:

1
2
3
4
5
6
7
8
9
10
11
上半部(硬中断):
  1. 读取网卡状态寄存器
  2. 把数据从网卡 DMA 缓冲区标记为待处理
  3. 触发 NET_RX 软中断
  4. 应答中断,返回

下半部(软中断):
  1. 从 DMA 缓冲区取数据
  2. 解析以太网帧头、IP 头、TCP 头
  3. 将数据放入 socket 接收缓冲区
  4. 唤醒等待的用户进程

第八部分:IO 模型

Q27:Linux 的五种 IO 模型是什么?

记忆点:阻塞 IO → 非阻塞 IO → IO 多路复用 → 信号驱动 IO → 异步 IO。前四种本质都是”同步 IO”(数据拷贝阶段进程要参与),只有 AIO 是真正的异步。

IO 操作分两个阶段理解:

  1. 等待数据准备好(数据从网卡/磁盘到内核缓冲区)
  2. 将数据从内核缓冲区拷贝到用户缓冲区
模型阶段1阶段2特点
阻塞 IO阻塞等待阻塞拷贝最简单,线程挂起
非阻塞 IO轮询(返回 EAGAIN)阻塞拷贝CPU 空转浪费
IO 多路复用select/poll/epoll 等待阻塞拷贝一个线程监控多个 fd
信号驱动 IO注册信号,数据就绪通知阻塞拷贝很少使用
异步 IO(AIO)不阻塞不阻塞(内核完成拷贝后通知)真正异步

面试追问:为什么 IO 多路复用也算”同步”?

因为第二阶段(从内核缓冲区拷贝数据到用户缓冲区)时,进程仍然需要调用 recvfrom 阻塞等待拷贝完成。只有 AIO 是内核完成全部工作后通知用户进程。

select/poll/epoll 的详细对比和使用,参考 网络编程与进程间通信面试题


第九部分:经典问题

Q28:用户态和内核态的切换什么时候发生?

记忆点:三种情况——系统调用(主动)、异常(被动,如缺页)、中断(被动,如时钟中断)。每次切换都有保存/恢复现场的开销。

触发方式主动/被动例子
系统调用主动read, write, fork, exec, mmap
异常被动缺页异常、除零、段错误
外部中断被动时钟中断、键盘中断、网卡中断

切换开销包括:

  1. 保存用户态寄存器到内核栈
  2. 切换到内核栈
  3. 执行内核代码
  4. 恢复用户态寄存器
  5. 切换回用户栈

减少切换的优化:

  • vDSO(Virtual Dynamic Shared Object):把部分只读的内核数据(如 gettimeofday)映射到用户空间,不需要真正陷入内核
  • io_uring:提交和完成 IO 请求通过共享内存环,减少系统调用次数

Q29:什么是缺页异常(Page Fault)?处理流程是怎样的?

记忆点:访问的虚拟页面不在物理内存中,触发缺页异常。MMU 产生异常 → 内核处理(合法则从磁盘调入,非法则段错误杀进程)。这是虚拟内存和按需分页的核心机制。

缺页异常的处理流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CPU 访问虚拟地址
       |
       v
  查 TLB → 未命中 → 查页表
                       |
                       v
                 页表项有效?
                /            \
              是              否 → 缺页异常!
              |                      |
              v                      v
         正常访问             地址合法?
                            /         \
                          是           否 → 段错误(SIGSEGV)
                          |                  杀掉进程
                          v
                    有空闲帧?
                   /         \
                 是           否 → 页面置换
                 |               选择一个页面换出
                 v               (如果 dirty 则写回磁盘)
            从磁盘读入该页          |
                 |               v
                 v            腾出空闲帧
            更新页表              |
                 |               v
                 v            从磁盘读入该页
            重新执行指令           |
                               v
                          更新页表 → 重新执行指令

缺页类型:

类型原因处理
硬缺页(Major)页面在磁盘上(swap 或文件)需要磁盘 IO,慢(毫秒级)
软缺页(Minor)页面在内存中但页表未映射只需更新页表,快(微秒级)

Q30:什么是内存映射(mmap)?有什么用?

记忆点:mmap 把文件或设备直接映射到进程的虚拟地址空间,读写内存就等于读写文件,避免了 read/write 的内核缓冲区拷贝。适用于大文件读写、进程间共享内存、动态库加载。

mmap vs 传统 read/write:

1
2
3
4
5
6
7
8
传统方式:
  read(fd, buf, len)
  磁盘 → 内核缓冲区(页缓存) → 用户缓冲区   (两次拷贝)

mmap 方式:
  ptr = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0)
  磁盘 → 页缓存 (直接映射到用户地址空间)      (一次拷贝)
  直接通过 ptr 访问数据

mmap 的常见用途:

  1. 读写大文件: 不需要一次加载整个文件,按需缺页加载
  2. 进程间共享内存: MAP_SHARED 让多个进程映射同一个文件/匿名区域
  3. 动态链接库加载: .so/.dll 通过 mmap 映射到进程地址空间
  4. 零拷贝: 配合 sendfilesplice 减少数据拷贝
1
2
3
4
5
6
7
8
9
10
11
// mmap 读文件示例
int fd = open("big_file.dat", O_RDONLY);
struct stat sb;
fstat(fd, &sb);

char* ptr = (char*)mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
// 直接通过 ptr 访问文件内容,像访问数组一样
printf("First byte: %c\n", ptr[0]);

munmap(ptr, sb.st_size);
close(fd);

第十部分:综合与高频追问

Q31:进程调度和线程调度有什么区别?Linux 调度的是什么?

记忆点:Linux 内核的调度单位是”任务”(task_struct),不区分进程和线程。内核调度器看到的都是 task_struct,线程就是共享地址空间的 task。

Linux 的 CFS(完全公平调度器):

  • 核心思想:每个任务应该获得”公平”的 CPU 时间
  • 使用红黑树组织可运行任务,按 vruntime(虚拟运行时间)排序
  • vruntime 小的优先运行 → 保证公平
  • 优先级高的任务 vruntime 增长更慢 → 自然获得更多 CPU 时间
1
2
3
4
5
6
CFS 红黑树:
         vruntime=10
        /            \
  vruntime=5     vruntime=15
     |                |
   最先调度         最后调度

Q32:什么是 Copy-On-Write?在哪些场景中使用?

记忆点:COW 就是”先共享,写的时候才复制”。fork 时父子进程共享页面,哪个进程先写就触发拷贝那一页。节省了 fork + exec 模式下不必要的复制。

COW 的应用场景:

  1. fork 系统调用: 子进程与父进程共享所有页面,只在写时复制
  2. std::string(旧版 GCC): 多个 string 共享同一块内存,修改时才复制(C++11 后已弃用,因为不线程安全)
  3. 文件系统快照: Btrfs/ZFS 用 COW 实现快照,创建快照只记录指针不复制数据
  4. 虚拟机内存去重: KSM(Kernel Same-page Merging)合并相同内容的页面

Q33:大页(Huge Pages)是什么?有什么好处?

记忆点:默认页面 4KB,大页 2MB 或 1GB。好处是减少 TLB 条目数(一个 TLB 条目覆盖更大范围),减少页表层级。适用于大内存应用(数据库、JVM)。

页面大小TLB 覆盖范围(假设 1024 条目)页表级数
4KB4MB4 级
2MB2GB3 级(少一级)
1GB1TB2 级(少两级)

Linux 配置大页:

1
2
3
4
5
# 预留 128 个 2MB 的大页 (共 256MB)
echo 128 > /proc/sys/vm/nr_hugepages

# 或使用透明大页 (THP),内核自动合并
echo always > /sys/kernel/mm/transparent_hugepage/enabled

Q34:什么是内存泄漏?什么是内存溢出?怎么排查?

记忆点:内存泄漏——分配了没释放,内存越用越少。内存溢出(OOM)——需要内存但已经没有了。泄漏是因,溢出可能是果。

概念含义例子
内存泄漏已分配的内存丢失了指针,无法释放new 了没 delete
内存溢出需要分配内存但不够了请求 1GB 但系统只剩 100MB
野指针指向已释放内存的指针delete 后继续使用
悬挂指针指向无效内存的指针函数返回局部变量地址

排查工具:

  • Valgrind(Memcheck): 检测内存泄漏、越界访问、未初始化内存使用
  • AddressSanitizer(ASan): 编译时插桩,检测越界、use-after-free,速度比 Valgrind 快很多
  • LeakSanitizer(LSan): 专门检测内存泄漏
  • perf + /proc/meminfo: 监控整体内存使用趋势
1
2
3
4
5
6
# Valgrind 检测内存泄漏
valgrind --leak-check=full ./my_program

# ASan (编译时加参数)
g++ -fsanitize=address -g my_code.cpp -o my_program
./my_program

Q35:Linux 的 OOM Killer 是什么?

记忆点:当系统内存耗尽时,内核的 OOM Killer 会选择一个”最该杀”的进程杀掉,释放内存。选择标准是 oom_score——占内存多、运行时间短、非 root 的进程更容易被杀。

oom_score 的计算因素:

  • 进程及其子进程使用的内存越多,得分越高
  • root 进程得分减半
  • 可以通过 oom_score_adj 手动调整(-1000 到 1000)
  • 设为 -1000 表示永不 OOM Kill(通常用于数据库等关键进程)
1
2
3
4
5
# 查看某个进程的 OOM 得分
cat /proc/<PID>/oom_score

# 保护重要进程不被 OOM Kill
echo -1000 > /proc/<PID>/oom_score_adj

面试追问:overcommit 机制

Linux 默认允许 overcommit(分配的虚拟内存可以超过物理内存+swap)。因为大多数程序申请了内存但不会全部使用。这就是为什么 malloc 几乎不会失败,但实际使用时可能触发 OOM。


面试答题策略

操作系统题目的答题框架

1
2
3
4
1. 一句话定义(是什么)
2. 核心原理(为什么/怎么做)
3. 对比分析(如果有多个方案,对比优劣)
4. 联系实际(Linux 实际怎么做的、对性能的影响)

高频考点优先级

优先级主题核心考点
最高进程/线程区别、状态转换、IPC
最高虚拟内存页表、TLB、缺页处理
CPU 调度FCFS/SJF/RR/MLFQ、上下文切换
死锁四条件、预防/避免/检测
页面置换LRU/Clock/FIFO、Belady 异常
文件系统inode、软硬链接
IO 模型五种模型、同步 vs 异步
系统调用用户态/内核态切换过程

与其他面试主题的关联

操作系统知识相关面试文章
进程间通信IPC 进程间通信实战教程
锁/并发/内存模型锁、并发编程与内存模型面试题
IO 多路复用网络编程与进程间通信面试题
网络协议计算机网络面试题
线程安全 LRU Cache线程安全 LRU Cache TDD 实战
shared_ptr 多线程shared_ptr + Lambda + 多线程

操作系统是一门”知其然更要知其所以然”的学科。面试时不要死记硬背结论,而是要能从第一性原理出发推导出答案。比如”为什么需要多级页表”——先想一级页表要多大内存,自然就理解了。理解了原理,换个问法也不怕。

本文由作者按照 CC BY 4.0 进行授权