之前我们已经知道,一个 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
2
3
4
Application
→ C Library / API wrapper
→ 准备 System Call
→ 进入 Kernel

System Call 从 C 代码到 Kernel

Lecture 专门把 open() 扒开给我们看了一次。Program 调用:

1
open("test.txt", O_RDONLY);

编译之后,Application 首先会进入 glibc 里 open() 对应的实现。这里一大堆 Assembly 不用背,我们真正关心的只是:

1
2
3
4
5
Application
↓
call open
↓
glibc

随后 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
2
3
4
5
6
7
System Call Table

0 → handler A
1 → handler B
2 → handler C
...
257 → sys_openat()

于是完整流程就变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Application
↓
API / libc wrapper
↓
准备 System Call Number + Arguments
↓
特殊 Machine Instruction
↓
Kernel
↓
System Call Table
↓
找到对应 Handler
↓
执行 OS Service

执行完以后,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
2
3
4
5
6
7
8
9
A · User Mode
↓
System Call
↓
A · Kernel Mode
↓
Return
↓
A · User Mode

整个过程中 Process 从头到尾还是 A,变的只是 CPU 当前的 Privilege Mode,所以这是 Mode Switch。课件甚至说,此时 Kernel 是在 “executing on behalf of the process”,也就是代表 A 去完成 A 自己没有权限完成的工作。

而如果发生的是:

1
2
3
4
5
6
7
Process A
↓
保存 A
↓
恢复 B
↓
Process B

这里真正换了当前 Running Process,这才叫 Context Switch。两者的区别可以这样记:

1
2
3
4
5
Mode Switch
→ CPU 现在以什么权限执行?

Context Switch
→ CPU 现在到底在执行哪个 Process?

Mode Switch 本身不意味着一定发生 Context Switch,例如一个 System Call 很快处理完,Kernel 可以直接返回 A。但是 Mode Switch 以后,Kernel 完全有可能决定 Context Switch,例如:

1
2
3
4
5
6
7
8
9
10
11
A 调用 read()
↓
A User → A Kernel
↓
Kernel 发现 I/O 还没完成
↓
A → Blocked
↓
Scheduler 选择 B
↓
Context Switch A → B

所以正确关系既不是“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
2
3
4
5
6
7
8
Program Counter / Instruction Pointer
→ 下一条要执行的 Instruction 在哪里

Stack Pointer
→ 当前 Stack 在哪里

General-purpose Registers
→ 当前运算的中间值

CPU 只有一套真实 Registers,所以当 B 开始运行以后,这些 Registers 会被 B 使用。如果 A 原来的值完全不保存,那么以后再把 A 叫回来时,A 已经不知道自己之前执行到哪里了。于是切换 Process 的核心就是:

1
2
3
4
5
6
7
A 当前 CPU Context
↓
保存到 Memory

B 之前保存的 Context
↓
重新装回 CPU

这就是 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
2
3
4
5
6
7
8
9
Process A
├─ User Stack A
├─ Kernel Stack A
└─ PCB A

Process B
├─ User Stack B
├─ Kernel Stack B
└─ PCB B

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
2
3
4
RAX = 42
RBX = 100
RSP = 0x7fff1340
RIP = 0x401250

发生 System Call 或者 Interrupt 以后,这些值会按照预先规定好的 Layout 保存到 A 的 Kernel Stack 某个区域,概念上:

1
2
3
4
5
6
7
8
A Kernel Stack

saved RAX = 42
saved RBX = 100
saved RSP = 0x7fff1340
saved RIP = 0x401250
saved FLAGS = ...
...

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
2
3
4
5
User Stack A
0x7fff0000
...
0x7fff1340 ← 当前 Stack Top
...

此时 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
2
3
4
5
6
7
8
9
10
11
12
13
14
CPU Registers
→ A User Context

RSP
→ A User Stack

Current Process
→ A

PCB A.state
→ RUNNING

PCB B.state
→ READY

A 的 Code、Data、Heap、User Stack 等等仍然在自己的 Address Space 里,并没有“装进 CPU”。


Step 1:A 从 User Mode 进入 Kernel

原因可能是 System Call 或者 Interrupt。首先必须保护 A 的 User-side Context,于是:

1
2
3
4
5
CPU Registers
[A user values]
↓
A Kernel Stack
[saved user context]

也就是保存 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
2
Kernel Stack Pointer
→ A Kernel Stack

因为现在 Kernel 仍然是在代表 A 执行。


Step 3:Scheduler 决定 A → B

Kernel 执行过程中,Scheduler 可能决定 A 暂时不要继续跑了,换 B。如果 A 只是 Time Slice 用完:

1
2
3
4
PCB A.state
RUNNING → READY

A → Ready Queue

如果 A 正在等待 I/O:

1
2
3
4
PCB A.state
RUNNING → BLOCKED

A → Blocked Queue

所以 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
2
3
CPU 当前 Kernel-side Register Values
↓
PCB A

同时保存相关 Memory Information。课件这里写的是:

1
2
3
Save A's registers (kernel space)
and memory info
→ A's PCB

这里的 Memory Info 不要理解成把 A 整个 Heap、整个 Code、整个 RAM 内容全部复制进 PCB,不是。真正保存的是之后重新激活 A 的 Address Space 所需要的管理信息,A 真正的大块 Memory 内容通常仍然待在原来的地方。


Step 5:从 PCB B 恢复 B 的 Kernel-side Context

现在开始反方向:

1
2
3
4
5
6
PCB B
↓
B saved kernel context
B memory info
↓
CPU / relevant hardware state

同时:

1
2
3
4
Kernel Stack Pointer
A Kernel Stack
↓
B Kernel Stack

这一刻很关键。Kernel 原本是在“A 的 Kernel Context”里执行,现在开始切换成“B 的 Kernel Context”。


Step 6:从 B Kernel Stack 恢复 B 的 User Context

B Kernel Stack 里面保留着 B 之前离开 User Mode 时保存下来的 Context:

1
2
3
4
saved B user PC
saved B user SP
saved B general registers
...

于是:

1
2
3
B Kernel Stack
↓
CPU Registers

恢复以后:

1
2
3
4
5
6
7
8
RIP / PC
→ B 上次停下来的 Instruction

RSP
→ B User Stack

General Registers
→ B 上次的值

最后执行 return-from-syscall / return-from-interrupt,从 Kernel Mode 回到 User Mode。现在:

1
2
3
4
5
Current Process
→ B

PCB B.state
→ RUNNING

CPU 下一条 Instruction 就是 B 之前本来应该继续执行的那条。


整条链压缩下来就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
A User
↓
保存 A user context → A Kernel Stack
↓
A Kernel
↓
Scheduler 决定 A → B
↓
更新 A PCB state / queue
↓
保存 A kernel-side context → A PCB
↓
恢复 B kernel-side context ← B PCB
↓
Switch to B Kernel Stack
↓
恢复 B user context ← B Kernel Stack
↓
Return from Kernel
↓
B User

这就是 Context Switch 完整的数据流。


Context Switch 的开销

在进行 Context Switch 的时候,CPU 需要花时间:

1
2
3
4
保存 A
更新 Process State
Scheduler 选择 B
恢复 B

这些时间并没有执行 A 或者 B 自己的 Application Logic,所以 Context Switch 过于频繁会降低整体效率。

除此之外还有 Indirect Cost。假设刚才一直运行 A,那么:

1
2
CPU Cache
≈ 大量 A 最近使用的数据

突然换 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
2
while (1) {
}

如果 OS 只能等 Process 自愿进入 Kernel,那它岂不是永远拿不到 CPU?所以不能假设 Process 会合作。Lecture 把这种只等 Process 自己 System Call 或者 Illegal Operation 的办法叫 Passive Approach,它多数时候也许能工作,但遇到 Infinite Loop 就直接寄了。

真正可靠的是 Active Approach。系统依赖一个 Hardware Timer,周期性产生 Interrupt,例如每隔几毫秒一次:

1
2
3
4
5
Timer Device
↓
Timer Interrupt
↓
CPU 进入 Kernel

于是即使 Process A 死活不主动交 CPU:

1
2
3
4
5
A · User Mode
↓
Timer Interrupt
↓
Kernel

OS 还是能强制重新获得 CPU 控制权。所以 Timer 的核心意义并不是提醒 Process 该休息了,而是保证 OS 永远能够重新拿回 CPU。


Interrupt

Interrupt 本质上是某个 Software / Hardware Event 需要 CPU Attention,于是强制 CPU 转去处理这个事件。典型的 Hardware Interrupt 包括:

1
2
3
4
Timer 到时间
Keyboard Input
Mouse Input
Device I/O 完成

这些 Event 通常和当前正在执行哪个 Instruction 没有直接关系。比如 A 正在 x = y + 1;,Disk 照样可以突然说“我刚才那个 I/O 做完了”。所以这种 Hardware Interrupt 相对于 Current Running Process 来说是 Asynchronous。


Exception

还有另一种情况:

1
2
3
4
5
A 执行某个 Instruction
↓
CPU 发现有问题
↓
进入 Kernel 处理

例如 Divide by Zero、Protected Memory Access。这种 Event 和 Current Process 执行的 Instruction 直接相关,所以叫 Synchronous Exception。Lecture 进一步把它分成:

1
2
3
4
Exception
├─ Fault
├─ Trap
└─ Abort

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
2
3
4
5
6
Interrupt Vector

0 → Handler A
1 → Handler B
2 → Handler C
...

它本质上是一个 Function Pointer Array,由 OS 在 Boot Time 建立。Hardware Interrupt Controller 会给 CPU 一个 Interrupt Vector Number,CPU 拿这个 Number 作为 Index,找到对应 Interrupt Handler。逻辑和 System Call Table 有点像:

1
2
3
4
5
System Call Number
↓
System Call Table
↓
System Call Handler

Interrupt 则是:

1
2
3
4
5
Interrupt Vector Number
↓
Interrupt Vector
↓
Interrupt Handler

两个机制相似,但不是一个 Table。另外,Hardware Interrupt Handler 属于 Kernel,普通 User Application 不会自己注册一个底层 Timer Interrupt Handler 然后直接接管 Hardware Interrupt。


Interrupt 和 Context Switch 的关系

Interrupt 发生以后不一定 Context Switch。Interrupt 首先只是把 CPU 重新拉回 Kernel,例如:

1
2
3
4
5
6
7
P1 User
↓
Timer Interrupt
↓
Kernel
↓
Interrupt Handler

Interrupt Handler 处理完以后,Scheduler 再决定是继续 P1 还是切换到 P2,Lecture 第 23 页明确给了这两个分支。所以:

1
2
3
Interrupt
≠
Context Switch

更准确的流程是:

1
2
3
4
5
6
7
8
9
Interrupt
↓
Kernel 获得控制权
↓
处理 Event
↓
Scheduler 决定
├─ Resume Current Process
└─ Context Switch

Timer Interrupt 的价值主要是给 OS 一个重新考虑 CPU 使用权的机会,至于最后到底换不换人,是 Scheduler 的决定。


总结

这一章实际上一直在回答三个问题。第一,Application 需要做 Privileged Operation 怎么办?答案是 System Call。第二,一颗 CPU 怎么让多个 Process 感觉自己都有 CPU?答案是 Context Switch。第三,如果 Process 根本不合作,OS 怎么保证还能拿回 CPU?答案是 Interrupt。

整条故事连起来就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Process A
↓
Direct Execution on Real CPU
↓
可能发生 System Call / Interrupt
↓
Kernel 重新获得 CPU
↓
Handle Event / Service
↓
Scheduler Decision
├─ Resume A
└─ Context Switch
↓
Process B

所以所谓 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。