之前我们已经知道,一个 Process 本质上就是正在执行中的 Program。但一台单核 CPU 在某一瞬间实际上只能执行一个 Process,于是马上会出现一个问题:如果系统里同时有几十个 Process,为什么我们平时完全感觉不到“只有一个人在跑”?
答案就是 CPU Virtualization。
所谓 Virtualizing CPU,并不是真的给每一个 Process 创建一颗假的 CPU。Process 仍然是直接跑在真实 CPU 上的,只不过 OS 不断让 Process A 跑一会、停下来,再让 B 跑、再停下来,再让 C 跑。只要这种切换足够透明,每个 Process 就会感觉自己好像一直拥有一颗 CPU。
所以 CPU Virtualization 的核心并不是制造虚拟硬件,而是 Context Switching:让不同 Process 轮流直接使用真实 CPU,同时保证被暂停的 Process 以后还能从原来的地方继续执行。
从 User Mode 进入 Kernel
Process 平时在 User Mode 下直接使用 CPU 运行自己的代码,但有些事情 User Program 没有权限自己做,例如访问文件、申请某些受保护资源等等。这时候 Application 必须请求 OS 服务,也就是进行 System Call。
课件把这件事情放在 CPU Virtualization 前面,是因为 Context Switch 本身最终也一定要由 Kernel 完成,所以首先要搞清楚:CPU 到底是如何从 User Program 进入 Kernel 的。
不过 Application 一般不会自己手搓底层 System Call,而是调用更高层的 API,例如 Unix/Linux 程序通常通过 POSIX API 以及 C Library 来访问 OS 服务。API 的意义很直接:Programmer 只需要知道这个函数怎么调用、参数是什么、返回什么,而不用知道当前 OS 和 CPU 究竟使用哪条 Assembly Instruction、参数应该塞进哪个 Register。
所以我们平时写:
1 | int fd = open("test.txt", O_RDONLY); |
实际过程并不是直接跳进 Kernel,而更接近:
1 | Application |
System Call 从 C 代码到 Kernel
Lecture 专门把 open() 扒开给我们看了一次。Program 调用:
1 | open("test.txt", O_RDONLY); |
编译之后,Application 首先会进入 glibc 里 open() 对应的实现。这里一大堆 Assembly 不用背,我们真正关心的只是:
1 | Application |
随后 Library Wrapper 需要告诉 Kernel 两件事情:我要调用哪个 System Call?参数是什么?System Call 不是靠名字识别的,而是有自己的 System Call Number。Library 会把 System Call Number 和参数放进约定好的 CPU Registers,然后执行特殊的 Machine Instruction,例如 x86 里的 SYSCALL、INT 或者 SYSENTER。
所以可以把 Registers 理解成 CPU 手边的一些小格子:某个 Register 装 System Call Number,某些 Registers 装 Arguments。然后:
1 | syscall |
这条特殊 Instruction 就是一扇正规的 Kernel 入口。CPU 看到它以后,不会让 Application 随便跳进 Kernel 的任意位置,而是按照硬件和 OS 预先规定好的规则,进行一次受控的 User Mode → Kernel Mode 转移。这就是所谓的 trap into kernel,这里的 trap 强调的是“受控进入 Kernel”这件事情。
进入 Kernel 以后,Kernel 还要知道你究竟请求哪个服务,于是内部维护一个 System Call Table。它本质上是一个按照 System Call Number 索引的 Table,每一项指向对应 Kernel Function 的地址,逻辑上可以理解成:
1 | System Call Table |
于是完整流程就变成:
1 | Application |
执行完以后,Kernel 再使用对应的 return-from-trap 机制,例如 SYSRET、IRET 一类 Instruction,恢复之前保存的 Process 执行状态,回到 User Mode 继续执行。
这里需要特别区分:System Call Table 不是 Stack。Table 负责根据编号找到哪个 Handler,而 Stack 负责保存当前 Execution Chain 所需要的数据,这两个数据结构完全不是一回事。
Mode Switch 和 Context Switch
现在进入这章最核心的一组概念。假设 Process A 执行一句 read(...):
1 | A · User Mode |
整个过程中 Process 从头到尾还是 A,变的只是 CPU 当前的 Privilege Mode,所以这是 Mode Switch。课件甚至说,此时 Kernel 是在 “executing on behalf of the process”,也就是代表 A 去完成 A 自己没有权限完成的工作。
而如果发生的是:
1 | Process A |
这里真正换了当前 Running Process,这才叫 Context Switch。两者的区别可以这样记:
1 | Mode Switch |
Mode Switch 本身不意味着一定发生 Context Switch,例如一个 System Call 很快处理完,Kernel 可以直接返回 A。但是 Mode Switch 以后,Kernel 完全有可能决定 Context Switch,例如:
1 | A 调用 read() |
所以正确关系既不是“Mode Switch 一定 Context Switch”,也不是“System Call 不能触发 Context Switch”,而是:进入 Kernel 以后,Kernel 才有机会决定要不要切 Process。
另外,真正的 Process Switching 只能在 Kernel Mode 完成,因为 PCB、Scheduler、Kernel Stack 和各种 Process Management State 都属于 OS 控制范围,User Process 不能自己说“现在把 CPU 切给 B”。
Process 的 Context
所谓 Context,本质上就是足够让一个 Process 以后从当前位置继续执行的 CPU 状态。
Process A 正在 CPU 上运行时,CPU Registers 中装的是 A 当前的“活状态”,例如:
1 | Program Counter / Instruction Pointer |
CPU 只有一套真实 Registers,所以当 B 开始运行以后,这些 Registers 会被 B 使用。如果 A 原来的值完全不保存,那么以后再把 A 叫回来时,A 已经不知道自己之前执行到哪里了。于是切换 Process 的核心就是:
1 | A 当前 CPU Context |
这就是 Context Switch 最本质的数据流。
User Stack、Kernel Stack 和 PCB
这里非常容易把几个名词混成一坨。首先,Stack 不是 Running Log。Stack 不是“从程序启动到现在发生过什么”的完整历史记录,而是一块按照 Stack 方式使用的 Memory,用来保存当前 Execution Chain 还需要的数据,例如 Function Call Frame、Local Variables、Return Address、暂存下来的 Register Values 等等。
Process 在 User Mode 运行 Application Code 的时候,会使用 User Stack。当 Process 进入 Kernel 以后,Kernel 代表这个 Process 执行 Kernel Code,也需要 Function Calls、Local Variables、Return Address 等等,于是 Kernel 也需要一套 Kernel Stack。概念上:
1 | Process A |
Kernel Stack 和 PCB 不是同一个东西。Lecture 第 16 页明确把它们分开:A 的 User-space Register Context 先保存到 A 的 Kernel Stack;之后如果真正发生 Context Switch,A 当前 Kernel-side 的 Context 以及 Memory Information 再保存到 A 的 PCB。PCB 则更像 OS 对一个 Process 的总管理档案,包括 PID、Process State、Scheduling Information、Memory Management Information 以及需要保存的 Execution Context 等。
Kernel Stack 里保存的 Register 内容
这里不是在写“18:20 A 运行到了这里、18:21 A 做了什么”这种 Log,保存的是 Register 里面真正的数值。比如 A 在 User Mode:
1 | RAX = 42 |
发生 System Call 或者 Interrupt 以后,这些值会按照预先规定好的 Layout 保存到 A 的 Kernel Stack 某个区域,概念上:
1 | A Kernel Stack |
Kernel 以后恢复的时候也不是“解析 Log”,而是知道这个位置对应 RAX、这个位置对应 RSP、这个位置对应 RIP,然后把对应 Value 重新装回对应 CPU Register。所以:Kernel Stack 里面保存的是 Machine State 本身,而不是 Machine State 的文字记录。课件在 Interrupt Handling 部分也明确说,Interrupt 发生时 CPU 会把部分 Registers 保存到 Kernel Stack,OS 还可以继续保存剩余 Process State。
Kernel Stack 和 User Stack 的关系
这个也很容易误解。假设 A 的 User Stack 本来在 Memory 里:
1 | User Stack A |
此时 RSP = 0x7fff1340。发生 Context Switch 时,OS 通常并不会把整个 User Stack 复制进 Kernel Stack,User Stack 本身继续待在 A 的 Address Space 中。真正需要保存的只是 RSP = 0x7fff1340,也就是“A 的 User Stack 现在进行到哪里”:
1 | RSP ← 0x7fff1340 |
以后恢复 A 时 CPU 自然又会从原来的 User Stack 位置继续使用。所以 Kernel Stack 并不是 User Stack 的 Backup,它更像是保存足够让 CPU 以后回到 User Mode 的 Execution Context。
一次完整的 Context Switch
现在我们把整套数据流完整走一次。初始状态:
1 | CPU Registers |
A 的 Code、Data、Heap、User Stack 等等仍然在自己的 Address Space 里,并没有“装进 CPU”。
Step 1:A 从 User Mode 进入 Kernel
原因可能是 System Call 或者 Interrupt。首先必须保护 A 的 User-side Context,于是:
1 | CPU Registers |
也就是保存 A 的 user PC、user SP、general-purpose registers 以及其他必要 CPU state。现在 CPU 进入 A · Kernel Mode,但 Current Process 仍然是 A。所以到这里发生的只是 Mode Switch,还不是 Context Switch。
Step 2:Kernel 开始运行
此时 CPU Registers 会被 Kernel Code 继续使用,所以里面逐渐装的是 Kernel 当前执行所需的 Register Values。同时:
1 | Kernel Stack Pointer |
因为现在 Kernel 仍然是在代表 A 执行。
Step 3:Scheduler 决定 A → B
Kernel 执行过程中,Scheduler 可能决定 A 暂时不要继续跑了,换 B。如果 A 只是 Time Slice 用完:
1 | PCB A.state |
如果 A 正在等待 I/O:
1 | PCB A.state |
所以 A 停止以后不一定进入 Ready,具体状态取决于为什么停止。Lecture 第 15 页把 Update PCB State、Move Process to Queue、Select Ready Process 都明确列在 Context Switch 流程里。
Step 4:保存 A 的 Kernel-side Context
现在 CPU Registers 里面已经是 Kernel 当前执行状态,于是:
1 | CPU 当前 Kernel-side Register Values |
同时保存相关 Memory Information。课件这里写的是:
1 | Save A's registers (kernel space) |
这里的 Memory Info 不要理解成把 A 整个 Heap、整个 Code、整个 RAM 内容全部复制进 PCB,不是。真正保存的是之后重新激活 A 的 Address Space 所需要的管理信息,A 真正的大块 Memory 内容通常仍然待在原来的地方。
Step 5:从 PCB B 恢复 B 的 Kernel-side Context
现在开始反方向:
1 | PCB B |
同时:
1 | Kernel Stack Pointer |
这一刻很关键。Kernel 原本是在“A 的 Kernel Context”里执行,现在开始切换成“B 的 Kernel Context”。
Step 6:从 B Kernel Stack 恢复 B 的 User Context
B Kernel Stack 里面保留着 B 之前离开 User Mode 时保存下来的 Context:
1 | saved B user PC |
于是:
1 | B Kernel Stack |
恢复以后:
1 | RIP / PC |
最后执行 return-from-syscall / return-from-interrupt,从 Kernel Mode 回到 User Mode。现在:
1 | Current Process |
CPU 下一条 Instruction 就是 B 之前本来应该继续执行的那条。
整条链压缩下来就是:
1 | A User |
这就是 Context Switch 完整的数据流。
Context Switch 的开销
在进行 Context Switch 的时候,CPU 需要花时间:
1 | 保存 A |
这些时间并没有执行 A 或者 B 自己的 Application Logic,所以 Context Switch 过于频繁会降低整体效率。
除此之外还有 Indirect Cost。假设刚才一直运行 A,那么:
1 | CPU Cache |
突然换 B 以后,B 说“我要我的 Instruction 和 Data”,但是 Cache 里面可能大部分还是 A 的内容,于是 B 需要重新把自己的内容带进 Cache,产生更多 Cache Miss。课件还指出,新 Process 需要的数据或者 Instruction 甚至可能当前不在 Physical Memory 中。所以 Context Switch Cost 不仅仅是保存几个 Registers,还可能破坏当前的 Memory / Cache Locality。
OS 如何保证拿回 CPU
到这里还有最后一个致命问题。假设有个 Process 完全不进行 System Call:
1 | while (1) { |
如果 OS 只能等 Process 自愿进入 Kernel,那它岂不是永远拿不到 CPU?所以不能假设 Process 会合作。Lecture 把这种只等 Process 自己 System Call 或者 Illegal Operation 的办法叫 Passive Approach,它多数时候也许能工作,但遇到 Infinite Loop 就直接寄了。
真正可靠的是 Active Approach。系统依赖一个 Hardware Timer,周期性产生 Interrupt,例如每隔几毫秒一次:
1 | Timer Device |
于是即使 Process A 死活不主动交 CPU:
1 | A · User Mode |
OS 还是能强制重新获得 CPU 控制权。所以 Timer 的核心意义并不是提醒 Process 该休息了,而是保证 OS 永远能够重新拿回 CPU。
Interrupt
Interrupt 本质上是某个 Software / Hardware Event 需要 CPU Attention,于是强制 CPU 转去处理这个事件。典型的 Hardware Interrupt 包括:
1 | Timer 到时间 |
这些 Event 通常和当前正在执行哪个 Instruction 没有直接关系。比如 A 正在 x = y + 1;,Disk 照样可以突然说“我刚才那个 I/O 做完了”。所以这种 Hardware Interrupt 相对于 Current Running Process 来说是 Asynchronous。
Exception
还有另一种情况:
1 | A 执行某个 Instruction |
例如 Divide by Zero、Protected Memory Access。这种 Event 和 Current Process 执行的 Instruction 直接相关,所以叫 Synchronous Exception。Lecture 进一步把它分成:
1 | Exception |
Fault
Fault 表示 CPU 在 Instruction 完成之前检测到了问题,例如 Lecture 给的 Page Fault、Segmentation Fault。其中有些 Fault 是可以被修复的,处理完以后原 Program 还能继续运行。所以不要直接理解成 Fault = Program 必死,并不是。
Trap
这页把 Trap 定义为 CPU 在 Instruction 执行之后检测到 Exception,并且处理后 Program 可以继续,Lecture 举的例子是 INT Instruction。需要注意,这门课前面还会用 trap into kernel 来泛指“受控进入 Kernel”的动作,所以这里有两个不同语境:trap into kernel 强调的是进入 Kernel 这个动作,而 Fault / Trap / Abort 里面的 Trap 是 Exception Classification。不要把两层含义强行混成一个。
Abort
Abort 表示 CPU 遇到了不可恢复的严重错误,Lecture 例子是 Double Fault,也就是处理一个 Fault 的过程中又发生另一个 Fault。这种情况通常已经不能正常回到 Current Program 继续执行。
Interrupt Handler 和 Interrupt Vector
Hardware Interrupt 发生以后,CPU 还需要知道这个 Interrupt 到底应该执行哪个 Kernel Function,于是 OS 维护 Interrupt Vector。它可以理解成:
1 | Interrupt Vector |
它本质上是一个 Function Pointer Array,由 OS 在 Boot Time 建立。Hardware Interrupt Controller 会给 CPU 一个 Interrupt Vector Number,CPU 拿这个 Number 作为 Index,找到对应 Interrupt Handler。逻辑和 System Call Table 有点像:
1 | System Call Number |
Interrupt 则是:
1 | Interrupt Vector Number |
两个机制相似,但不是一个 Table。另外,Hardware Interrupt Handler 属于 Kernel,普通 User Application 不会自己注册一个底层 Timer Interrupt Handler 然后直接接管 Hardware Interrupt。
Interrupt 和 Context Switch 的关系
Interrupt 发生以后不一定 Context Switch。Interrupt 首先只是把 CPU 重新拉回 Kernel,例如:
1 | P1 User |
Interrupt Handler 处理完以后,Scheduler 再决定是继续 P1 还是切换到 P2,Lecture 第 23 页明确给了这两个分支。所以:
1 | Interrupt |
更准确的流程是:
1 | Interrupt |
Timer Interrupt 的价值主要是给 OS 一个重新考虑 CPU 使用权的机会,至于最后到底换不换人,是 Scheduler 的决定。
总结
这一章实际上一直在回答三个问题。第一,Application 需要做 Privileged Operation 怎么办?答案是 System Call。第二,一颗 CPU 怎么让多个 Process 感觉自己都有 CPU?答案是 Context Switch。第三,如果 Process 根本不合作,OS 怎么保证还能拿回 CPU?答案是 Interrupt。
整条故事连起来就是:
1 | Process A |
所以所谓 Virtualizing the CPU,本质并不玄学:Process 真的直接运行在真实 CPU 上;OS 通过 System Call 和 Interrupt 重新获得控制权,再通过 Context Switch 在不同 Process 之间切换真实 CPU 的执行现场,于是每个 Process 都获得了“我好像一直有一颗 CPU”的假象。
一句话 Takeaway:System Call 解决“User Program 怎么安全进入 Kernel”,Interrupt 解决“Kernel 怎么保证一定能重新拿回 CPU”,Context Switch 解决“Kernel 拿回 CPU 以后怎么从 A 无缝换到 B”;这三件事情一起构成了 CPU Virtualization 最核心的 Mechanism。