操作系统第三讲:Context Switch —— 用户态如何安全地进入内核态?

操作系统第三讲:Context Switch ------ 用户态如何安全地进入内核态?

这一讲的标题叫 Context Switch,但它一开始并不是在讲"线程之间怎么切换",而是在讲一个更底层的问题:

一个普通用户程序,凭什么能进入内核?CPU 又如何保证它只能从规定的入口进入,而不能直接跳进内核乱改东西?

如果说第二讲讲的是"操作系统启动之后,内核和用户程序要分开管理",那么第三讲就是在补上最关键的一环:用户态和内核态之间到底怎么切换

这件事看起来只是一次跳转,实际上背后包含了 CPU 特权级、异常、中断、系统调用、IDT、中断栈、现场保存、iret 返回等一整套机制。理解这一讲之后,我们再看后面的系统调用、线程切换、调度、缺页异常,就会顺很多。

目录

  • [操作系统第三讲:Context Switch ------ 用户态如何安全地进入内核态?](#操作系统第三讲:Context Switch —— 用户态如何安全地进入内核态?)
    • [1. 这节课到底在解决什么问题?](#1. 这节课到底在解决什么问题?)
    • [2. CPL:CPU 怎么知道自己在用户态还是内核态?](#2. CPL:CPU 怎么知道自己在用户态还是内核态?)
    • [3. 先把三个概念分清:Code、Process、Mode](#3. 先把三个概念分清:Code、Process、Mode)
      • [3.1 用户代码一定运行在用户进程里吗?](#3.1 用户代码一定运行在用户进程里吗?)
      • [3.2 用户代码一定运行在用户态吗?](#3.2 用户代码一定运行在用户态吗?)
      • [3.3 OS 代码一定运行在系统进程里吗?](#3.3 OS 代码一定运行在系统进程里吗?)
      • [3.4 OS 代码一定运行在内核态吗?](#3.4 OS 代码一定运行在内核态吗?)
    • [4. 用户态进入内核态的三种方式](#4. 用户态进入内核态的三种方式)
      • [4.1 Exception:异常](#4.1 Exception:异常)
      • [4.2 Interrupt:中断](#4.2 Interrupt:中断)
      • [4.3 System Call:系统调用](#4.3 System Call:系统调用)
      • [4.4 PPT 中几个例子的分类](#4.4 PPT 中几个例子的分类)
    • [5. IVT 和 IDT:内核入口不是随便跳的](#5. IVT 和 IDT:内核入口不是随便跳的)
      • [5.1 IVT:实模式下的中断向量表](#5.1 IVT:实模式下的中断向量表)
      • [5.2 IDT:保护模式下的中断描述符表](#5.2 IDT:保护模式下的中断描述符表)
      • [5.3 Gate 里面有什么?](#5.3 Gate 里面有什么?)
      • [5.4 IDT 和 GDT/LDT 如何配合?](#5.4 IDT 和 GDT/LDT 如何配合?)
      • [5.5 IVT vs. IDT](#5.5 IVT vs. IDT)
    • [6. 中断屏蔽:为什么用户程序不能随便关中断?](#6. 中断屏蔽:为什么用户程序不能随便关中断?)
    • [7. 中断栈:为什么不能直接用用户栈?](#7. 中断栈:为什么不能直接用用户栈?)
      • [7.1 用户栈可能不可靠](#7.1 用户栈可能不可靠)
      • [7.2 用户栈不可信](#7.2 用户栈不可信)
      • [7.3 内核里有多少个中断栈?](#7.3 内核里有多少个中断栈?)
      • [7.4 First fault、Double fault、Triple fault](#7.4 First fault、Double fault、Triple fault)
    • [8. 内核态如何回到用户态?](#8. 内核态如何回到用户态?)
      • [8.1 什么是 upcall?](#8.1 什么是 upcall?)
    • [9. x86 模式切换全过程](#9. x86 模式切换全过程)
      • [9.1 x86 背景:segment + offset](#9.1 x86 背景:segment + offset)
      • [9.2 哪些指令能修改 CS?](#9.2 哪些指令能修改 CS?)
      • [9.3 当 interrupt / exception / syscall 发生时,硬件做什么?](#9.3 当 interrupt / exception / syscall 发生时,硬件做什么?)
      • [9.4 为什么步骤 2-4 不能乱序?](#9.4 为什么步骤 2-4 不能乱序?)
      • [9.5 Error code 是什么?](#9.5 Error code 是什么?)
      • [9.6 硬件之后,OS 做什么?](#9.6 硬件之后,OS 做什么?)
      • [9.7 谁真正修改 CPL?](#9.7 谁真正修改 CPL?)
    • [10. 上下文切换为什么贵?](#10. 上下文切换为什么贵?)
      • [10.1 Linux Kernel 为什么不支持浮点操作?](#10.1 Linux Kernel 为什么不支持浮点操作?)
    • [11. Stack vs. Heap:为什么 OS 保存栈指针,不保存"堆指针"?](#11. Stack vs. Heap:为什么 OS 保存栈指针,不保存“堆指针”?)
      • [11.1 栈指针必须保存](#11.1 栈指针必须保存)
      • [11.2 堆没有一个简单的"当前指针"](#11.2 堆没有一个简单的“当前指针”)
    • [12. 本讲总结](#12. 本讲总结)
    • [13. PPT 逐页覆盖索引](#13. PPT 逐页覆盖索引)
    • [14. 小测题](#14. 小测题)
      • [题 1](#题 1)
      • [题 2](#题 2)
      • [题 3](#题 3)
      • [题 4](#题 4)
      • [题 5](#题 5)
      • [题 6](#题 6)
      • [题 7](#题 7)
      • [题 8](#题 8)
      • [题 9](#题 9)
      • [题 10](#题 10)
    • [15. 小测题答案](#15. 小测题答案)
      • [答 1](#答 1)
      • [答 2](#答 2)
      • [答 3](#答 3)
      • [答 4](#答 4)
      • [答 5](#答 5)
      • [答 6](#答 6)
      • [答 7](#答 7)
      • [答 8](#答 8)
      • [答 9](#答 9)
      • [答 10](#答 10)
    • [16. 常见 QA](#16. 常见 QA)
    • [17. 拓展:读 xv6 interrupt handler 时应该看什么?](#17. 拓展:读 xv6 interrupt handler 时应该看什么?)
    • 结尾

1. 这节课到底在解决什么问题?

我们前面已经知道,操作系统要保护自己,也要保护进程之间互不干扰。因此 CPU 至少要分出两种状态:

text 复制代码
用户态 user mode:普通应用程序运行的位置
内核态 kernel mode:操作系统内核运行的位置

用户态代码权限低,不能随便执行特权指令,也不能随便访问内核内存。内核态代码权限高,可以管理 CPU、内存、磁盘、I/O 设备等资源。

但是问题来了:普通程序又经常需要内核帮忙。例如:

text 复制代码
读写文件
创建进程
申请内存
等待网络数据
处理键盘鼠标输入

这些事情用户程序自己做不了,必须请求内核来做。所以系统必须提供一种机制:

text 复制代码
既允许用户程序进入内核,
又不能让用户程序随便进入内核任意位置。

第三讲讲的就是这件事:安全的用户态/内核态切换


2. CPL:CPU 怎么知道自己在用户态还是内核态?

在 x86 里,CPU 使用 CS 段寄存器的最低 2 位 记录当前特权级,这两个 bit 被称为 CPL,Current Privilege Level

传统 x86 有 4 个保护环:

text 复制代码
Ring 0:权限最高,通常给内核用
Ring 1
Ring 2
Ring 3:权限最低,通常给用户程序用

现代大多数操作系统基本只用两层:

text 复制代码
CPL = 0:kernel mode
CPL = 3:user mode

这时候很容易产生一个误解:既然 CPL 只是 CS 的低 2 位,那用户程序能不能自己执行几条位运算,把 CPL 改成 0?

例如 PPT 上故意列了几种看似可行的操作:

text 复制代码
CPL &= 0x0
CPL |= 0x3
CPL &= 0xfffffffc
...

答案当然是不行。

CPL 不是普通变量。 用户程序不能直接修改 CS 寄存器的特权位。否则一个恶意程序只要执行一行"把 CPL 改成 0",就能拥有内核权限,整个操作系统的隔离机制就崩了。

真正能改变 CPL 的,是 CPU 规定好的特殊路径,例如:

text 复制代码
用户态 -> 内核态:int / syscall / exception / interrupt
内核态 -> 用户态:iret / sysret

所以这一页的核心不是记住几个位运算,而是记住一句话:

特权级切换必须由硬件控制,用户程序不能自己改。


3. 先把三个概念分清:Code、Process、Mode

第三讲中有一个特别容易混的地方:

text 复制代码
Code    :代码是谁写的,属于什么软件
Process :代码运行在哪个进程上下文里
Mode    :CPU 当前处于用户态还是内核态

这三者不是一回事。

3.1 用户代码一定运行在用户进程里吗?

大多数时候是的。普通 App 的代码当然运行在用户进程里。

但是第三方驱动、内核模块这类东西就比较特殊:它可能不是 OS 作者写的,却可能被加载进内核环境执行。所以"用户写的代码"和"用户态代码"不能完全划等号。

3.2 用户代码一定运行在用户态吗?

多数时候是,但也有例外。PPT 里提到了 eBPF

eBPF 可以理解成用户提交的一段小程序,经过内核验证后,在内核侧运行。它是一个很好的例子:代码来源可能是用户,但执行位置可能在内核。

3.3 OS 代码一定运行在系统进程里吗?

不一定。

比如 interrupt handler,中断处理函数 是内核代码,但它不一定运行在某个独立的系统进程里。它可能是在当前被中断进程的内核栈上执行。

3.4 OS 代码一定运行在内核态吗?

也不一定。

Shell、图形界面组件、一些系统服务,都可以算操作系统生态的一部分,但它们通常是用户态进程。

所以我们要建立一个清晰认识:

CPU 当前有没有内核权限,看的是 mode;不是看代码名字,也不是看进程名字。

CPU 判断当前 mode 的方式,就是看 CS segment selector 的低 2 位,也就是 CPL。


4. 用户态进入内核态的三种方式

PPT 把用户态进入内核态分成三类:

text 复制代码
Exception:异常
Interrupt:中断
System Call:系统调用,也叫 trap

它们都可能让 CPU 从用户态切到内核态,但触发原因不一样。


4.1 Exception:异常

异常是 CPU 执行当前指令时遇到了问题。它和当前指令强相关,所以通常是同步发生的。

典型例子:

text 复制代码
非法内存访问
除零错误
执行非法指令
在用户态执行特权指令

比如程序访问了一个没有权限访问的地址,CPU 就会触发异常,进入内核的异常处理程序。内核再决定是终止进程、发送信号,还是进行某种恢复。


4.2 Interrupt:中断

中断是外部事件异步通知 CPU。

典型例子:

text 复制代码
定时器中断
键盘输入
鼠标点击
网卡收到数据包
磁盘 I/O 完成

它和当前正在执行哪条用户指令没有直接关系。比如 CPU 正在运行一个普通程序,突然网卡收到包了,网卡就通过中断告诉 CPU:"你得来处理一下。"

中断最重要的特点是:

text 复制代码
异步发生
由外部设备或其他处理器触发
通常用于让 OS 重新获得控制权

定时器中断尤其重要。没有定时器中断,一个死循环用户程序可能永远霸占 CPU,操作系统就没机会重新调度。


4.3 System Call:系统调用

系统调用是用户程序主动请求内核服务。

比如:

c 复制代码
write(fd, buf, len);
read(fd, buf, len);
fork();
exec();
open(path, flags);

用户程序不能直接操作磁盘、网卡或内核数据结构,所以必须通过 syscall 进入内核,让内核代表它完成操作。

系统调用可以理解成:

操作系统给用户程序开的"正规窗口"。

用户程序不能翻墙进内核,只能从窗口排队办事。


4.4 PPT 中几个例子的分类

PPT 给了几个例子,我们可以逐个判断。

text 复制代码
Inter-processor interrupt (IPI)    -> interrupt
Invalid opcode                     -> exception
Segmentation fault                 -> exception
Network card interrupt             -> interrupt
Divide-by-zero in Python/Java      -> 不一定是硬件异常

最后一个要特别注意。

如果是机器指令层面的除零,那是 CPU exception。可是 Python/Java 里的除零很多时候是语言运行时自己检查后抛出的语言异常,例如 Python 的 ZeroDivisionError、Java 的 ArithmeticException。这不一定真的触发一次 CPU 除零异常,也不一定产生一次用户态到内核态切换。


5. IVT 和 IDT:内核入口不是随便跳的

用户态进入内核态有一个重要安全原则:

不能允许用户程序指定任意内核地址执行。

否则用户程序可以直接跳到内核某个危险函数中间,绕过权限检查。

所以 CPU 和 OS 共同维护了一张"入口表"。发生中断、异常、系统调用时,CPU 不是乱跳,而是根据一个编号去表里查入口。


5.1 IVT:实模式下的中断向量表

在 real mode 里,这张表叫 Interrupt Vector Table,IVT,中断向量表

它保存各种异常、中断、trap 对应的处理函数入口。例如:

text 复制代码
0  -> divide-by-zero handler
3  -> breakpoint handler
14 -> page fault handler
...

在 x86 中,IVT 一共有 256 项,每项 4 字节。

它的作用可以理解成:

text 复制代码
interrupt number
       ↓
Interrupt Vector Table
       ↓
handler address
       ↓
执行对应的内核处理函数

这样一来,外部设备或用户程序只能给出一个 interrupt number,而不能直接给出"我要 CPU 跳到内核的哪个地址"。


5.2 IDT:保护模式下的中断描述符表

进入 protected mode 后,x86 使用的是 IDT,Interrupt Descriptor Table,中断描述符表

IDT 告诉 CPU:

text 复制代码
每个 interrupt / exception / syscall 对应的 ISR 在哪里

其中 ISR 是 Interrupt Service Routine,中断服务程序

IDT 的每个 entry 叫做 Gate,门。这个名字很形象:用户态进入内核态不是随便跳,而是必须通过门。

IDT 的特点:

text 复制代码
一共有 256 个 gates
32 位处理器上每个 gate 8 字节
64 位处理器上每个 gate 16 字节
IDT 的位置保存在 IDTR 寄存器中
通过 LIDT 指令加载 IDTR

5.3 Gate 里面有什么?

一个 IDT gate 里有几个关键字段:

text 复制代码
Offset   :中断服务程序 ISR 的地址偏移
Selector :指向 GDT 中的代码段描述符
Gate Type:门的类型,例如 interrupt gate、trap gate、call gate
DPL      :允许哪个特权级通过 INT 访问这个 gate
P        :Present bit,表示这个描述符是否有效

其中最值得注意的是 DPL

DPL 决定用户态代码能不能通过 INT 指令主动进入这个 gate。

例如,系统调用入口通常允许用户态访问,所以它的权限设置要允许 CPL=3 的代码进入。普通硬件中断入口则不能随便让用户伪造。


5.4 IDT 和 GDT/LDT 如何配合?

PPT 中画了一个很重要的路径:

text 复制代码
IDTR
 ↓
IDT
 ↓ 根据 interrupt number 找到 gate
Gate 中包含 selector + offset
 ↓
selector 去 GDT 或 LDT 找 segment descriptor
 ↓
segment descriptor 给出 base address
 ↓
base address + offset
 ↓
Interrupt Procedure

也就是说,CPU 找到 handler 不是只看一个地址,而是要经过 IDT、GDT/LDT、权限检查、地址组合等一套流程。

这背后的核心思想是:

内核入口必须少而固定,并且由硬件检查。


5.5 IVT vs. IDT

对比项 IVT IDT
使用模式 real mode protected mode
共同作用 限制用户进入内核的入口数量 限制用户进入内核的入口数量
entry 大小 4 bytes IA-32 为 8 bytes,x86-64 为 16 bytes
位置 通常在 0000:0000H 可以在内存任意位置,通过 LIDT 设置

IVT 和 IDT 的实现不同,但目的相似:

text 复制代码
不要让用户随便跳进内核,只允许从有限入口进入。

6. 中断屏蔽:为什么用户程序不能随便关中断?

PPT 接着讲了 Interrupt Masking,中断屏蔽

CPU 有些指令可以关闭中断、开启中断。例如:

text 复制代码
disable interrupts
enable interrupts

这些是特权指令,只能内核执行,用户程序不能执行。

为什么?

因为如果用户程序能关中断,它就可以这样做:

c 复制代码
关中断;
while (1) {
    // 永远循环
}

这样 OS 就再也收不到定时器中断,也就没办法抢回 CPU,整个系统都可能被一个用户程序拖死。

中断分两类:

text 复制代码
Maskable Interrupt:可屏蔽中断
Non-maskable Interrupt,NMI:不可屏蔽中断

可屏蔽中断包括软件中断、系统调用、部分硬件异常等。不可屏蔽中断通常用于非常严重的硬件事件。

PPT 里还强调:

text 复制代码
Interrupts are deferred, but not ignored.

也就是说,中断被屏蔽时通常不是彻底丢掉,而是被推迟处理。但硬件缓冲能力有限,所以内核也不能长时间关中断。


7. 中断栈:为什么不能直接用用户栈?

进入内核时,CPU 会切换到 interrupt stack,中断栈

中断栈在内核内存里,用来保存被中断进程的状态。没有中断发生时,它通常是空的。

这时候自然会问:为什么不直接用用户程序自己的栈?

答案是两个词:

text 复制代码
可靠性 reliability
安全性 security

7.1 用户栈可能不可靠

如果用户程序本来就是因为栈坏了、栈指针非法、访问非法地址才触发异常,那么内核再把现场保存到这个坏栈上,异常处理本身也会失败。

7.2 用户栈不可信

用户程序可以提前伪造用户栈内容。如果内核把关键返回信息、寄存器状态等放在用户可控的栈里,就可能被攻击者篡改。

所以 CPU 进入内核后必须切换到内核控制的中断栈。


7.3 内核里有多少个中断栈?

PPT 给出的答案是:

text 复制代码
1 × # of processes / threads

也就是通常每个进程或线程都有自己的内核栈 / 中断栈。

为什么要这样?

因为中断或系统调用处理过程中,内核可能不返回原来的进程,而是切换到另一个进程。

例如:

text 复制代码
进程 A 进入内核处理 I/O
↓
handler 发现 A 要等待磁盘
↓
A 不能继续运行
↓
OS 切换到进程 B

这时候 A 的内核现场必须保存在 A 自己的内核栈上,否则以后就没法恢复 A。


7.4 First fault、Double fault、Triple fault

PPT 还讲了三个异常层级:

text 复制代码
First fault :用户程序 trap 到内核 exception handler
Double fault:异常处理过程中又发生异常
Triple fault:系统重启

比如用户程序访问非法地址,进入 page fault handler。如果 page fault handler 自己又出错,就可能 double fault。如果 double fault 也处理不了,就可能 triple fault,机器直接重启。

PPT 中那句玩笑也很经典:

text 复制代码
Things never to do in an OS #1:
Swap out the page swapping code.

意思是:千万不要把负责处理换页的代码本身换出内存。

否则会出现循环:

text 复制代码
程序缺页
↓
需要 page fault handler
↓
page fault handler 不在内存
↓
需要把 handler 换入
↓
换入又需要 handler

这就是操作系统里的"自救工具不能丢"。


8. 内核态如何回到用户态?

前面讲的是用户态如何进入内核态。PPT 后面开始讲反方向:Kernel-to-User Mode Switch

内核返回用户态有几种情况:

text 复制代码
1. 启动一个新进程
2. 中断 / 异常 / 系统调用处理完后恢复原进程
3. 定时器中断后切换到另一个进程
4. User-level upcall

前三个比较好理解。第四个 upcall 比较特殊。


8.1 什么是 upcall?

普通 syscall 是:

text 复制代码
user code
   ↓
kernel code

这可以叫 downcall,因为用户向下请求内核服务。

而 upcall 是反过来:

text 复制代码
kernel
   ↓
user-level handler

它允许应用程序实现一些类似 OS 的功能,然后由 OS 在合适的时候通知它。

PPT 中给了几个例子:

text 复制代码
异步 I/O 通知:I/O 完成后通知用户程序
进程间通信:调试器暂停某个进程
用户级异常处理:程序退出前保存文件
用户级资源管理:Java garbage collection

所以用户态和内核态并不是只有"用户请求内核"这一种关系。有时候内核也会把事件通知给用户态,让用户态自己处理一部分逻辑。


9. x86 模式切换全过程

PPT 后半部分开始把前面的概念放到 x86 上具体看。


9.1 x86 背景:segment + offset

x86 历史上使用分段机制,所以指针经常由两部分构成:

text 复制代码
segment + offset

程序计数器和栈指针也类似:

text 复制代码
程序计数器:CS:EIP
栈指针    :SS:ESP

CPL 就存在 CS 的低 2 位里。

在 Intel 8086 中,地址计算方式是:

text 复制代码
physical address = CS * 16 + IP

CS 和 IP 都是 16 位,所以最多能访问 1MB 地址空间。

PPT 还提到 EFLAGS。EFLAGS 保存处理器状态并控制行为,例如当前是否屏蔽中断。


9.2 哪些指令能修改 CS?

只有少数指令能改变 CS,例如:

text 复制代码
ljmp:far jump
lcall:far call
lret:far return
INT :产生软件中断
IRET:从中断 / 异常处理程序返回

普通用户程序不能直接 mov 修改 CS。否则用户程序就能直接把 CPL 改成 0。

其中:

text 复制代码
INT  :进入中断 / 系统调用路径
IRET :从中断 / 异常 / 系统调用返回

PPT 上写了 INT X (syscall number),这个地方可以稍微精确一点理解:INT X 里的 X 是 interrupt vector number;在传统 Linux int 0x80 里,0x80 是系统调用入口,具体 syscall number 通常放在 eax 这类寄存器里。


9.3 当 interrupt / exception / syscall 发生时,硬件做什么?

当中断、异常或系统调用发生时,硬件会先完成最小的安全切换:

text 复制代码
1. Mask interrupts
2. Save special register values to temporary registers
3. Switch onto the kernel interrupt stack
4. Push the three key values onto the new stack
5. Optionally save an error code
6. Invoke the interrupt handler

三个关键值是:

text 复制代码
SS:ESP
EFLAGS
CS:EIP

这些值决定了以后能不能正确回到用户程序。

可以把这个过程想成:

text 复制代码
用户程序正在执行 foo()
↓
突然发生中断 / 异常 / syscall
↓
CPU 切换到内核栈
↓
CPU 保存返回所需的关键状态
↓
CPU 跳到内核 handler

这里注意,最开始的几步必须由硬件完成。因为 OS 代码还没开始执行,用户代码又不可信,所以不可能让软件自己完成这部分最关键的切换。


9.4 为什么步骤 2-4 不能乱序?

PPT 问了一个很好的问题:

text 复制代码
Steps 2-4 cannot be reversed. Why?

核心原因是:必须先保证旧现场不会丢,且保存位置必须可信。

如果不先保存旧的 SS:ESP,切换栈后就找不到原来的用户栈位置。

如果先往用户栈压现场,用户栈可能已经坏了,或者本来就是攻击者控制的。

所以合理顺序必须是:

text 复制代码
先拿到旧状态
再切换到可信内核栈
再把返回现场压入内核栈

9.5 Error code 是什么?

有些异常会附带 error code。

例如 page fault 需要告诉内核更多信息:

text 复制代码
哪个页面出了问题
是读、写还是执行导致的
是权限问题还是页面不存在
是否来自用户态

有些异常没有天然 error code,OS 可能会放一个 dummy value,让不同 trap 的栈帧格式保持一致,方便统一处理。


9.6 硬件之后,OS 做什么?

硬件只保存最关键的状态,但一个进程的完整上下文还包括普通寄存器,例如:

text 复制代码
EAX
EBX
ECX
EDX
...

所以进入 handler 后,OS 还要继续保存剩余现场。

PPT 给出的 OS 处理流程是:

text 复制代码
1. Save the rest of the interrupted process's state
   使用 pusha / pushad

2. Execute the handler

3. Resume the interrupted process
   使用 popa / popad + pop error code

4. iret

因此完整路径可以总结成:

text 复制代码
硬件保存最小现场
↓
进入内核 handler
↓
OS 保存通用寄存器
↓
OS 处理中断 / 异常 / 系统调用
↓
OS 恢复寄存器
↓
iret 返回用户态

9.7 谁真正修改 CPL?

这一页回答了一开始的问题。

真正修改 CPL 的不是普通位运算,而是这些特殊指令:

text 复制代码
int / SYSCALL
iret

例如 C 代码里写:

c 复制代码
syscall(SYS_write, STDOUT_FILENO, msg, sizeof(msg) - 1);

表面上看是一个普通函数调用,但底层会通过 syscall 机制进入内核。

传统 32 位 Linux 下,也可以通过 int 0x80 触发系统调用:

asm 复制代码
movl $4, %eax      # sys_write 的系统调用号
movl %0, %ebx      # fd
movl %1, %ecx      # msg 指针
movl %2, %edx      # msg 长度
int $0x80          # 进入内核

这里的关键不是记住寄存器细节,而是理解:

系统调用本质上是一种受控的用户态到内核态切换。


10. 上下文切换为什么贵?

PPT 后面讲到了 context switch 的成本。

很多人以为上下文切换就是"保存几个寄存器",其实不是。真正的成本包括软件成本和硬件微架构成本。

层次 成本类别 机制 典型延迟
Kernel 状态保存/恢复 保存 CPU registers、stack pointer、PCB 状态 0.5--1.0 us
Kernel 调度成本 遍历 scheduler、run queue lock、选择任务 0.2--0.5 us
MMU TLB/CR3 切换地址空间、刷新用户态 TLB 项 0.5--1.5 us
CPU Cache pollution 新进程需要的 L1/L2 cache line 被挤出 10--50 us
CPU Pipeline/Branch pipeline flush、branch predictor 受影响 5--20 us

所以最贵的地方往往不是保存寄存器,而是:

text 复制代码
TLB 失效
cache 被污染
pipeline 被清空
branch predictor 历史失效

这就是为什么频繁上下文切换会降低系统性能。


10.1 Linux Kernel 为什么不支持浮点操作?

PPT 提到一个 fun fact:

text 复制代码
Linux Kernel does not support floating-point operations.

这句话不是说 CPU 在内核态不能做浮点,而是说 Linux 内核代码通常避免使用浮点。

原因是浮点寄存器、SIMD 寄存器也是进程上下文的一部分。如果内核随便使用这些寄存器,就必须在进入内核时保存用户程序的 FPU/SIMD 状态,用完再恢复。

这样每次进入内核的成本就会变高。

所以内核通常使用整数运算或定点运算,避免引入额外上下文保存成本。


11. Stack vs. Heap:为什么 OS 保存栈指针,不保存"堆指针"?

PPT 问:

text 复制代码
Why OS does not track the "heap pointer" as for stack?

原因是 stack pointer 是 CPU 执行上下文的一部分,而 heap 没有唯一的当前指针

11.1 栈指针必须保存

函数调用、函数返回、中断返回都依赖栈。

栈里可能保存:

text 复制代码
函数参数
局部变量
返回地址
中断返回现场

所以 OS 做上下文切换时必须保存和恢复:

text 复制代码
SP / ESP / RSP

否则程序就不知道从哪里继续执行。

11.2 堆没有一个简单的"当前指针"

堆由用户态内存分配器管理。比如 malloc() 背后可能有:

text 复制代码
free list
arena
bins
mmap 区域
brk 扩展区域

它不是一个 CPU 必须保存的单一寄存器。

OS 更关心的是:

text 复制代码
虚拟内存区域 VMA
页表
program break
mmap mappings

而不是某个"堆指针"。

一句话总结:

栈指针决定程序从哪里继续执行;堆只是进程地址空间里的一片内存,由用户态分配器管理。


12. 本讲总结

第三讲最重要的一句话是:

用户程序不能直接进入内核,而必须通过 CPU 和 OS 共同规定好的入口。CPU 负责安全切换特权级和栈,OS 负责保存上下文、执行 handler,并最终恢复或切换进程。

完整流程可以写成:

text 复制代码
用户程序执行
↓
发生 syscall / exception / interrupt
↓
CPU 根据 interrupt number 查 IDT
↓
CPU 检查权限并切换到 kernel stack
↓
CPU 保存 SS:ESP、EFLAGS、CS:EIP 等关键现场
↓
进入内核 handler
↓
OS 保存通用寄存器
↓
OS 处理中断 / 异常 / 系统调用
↓
OS 决定返回原进程,还是切换到另一个进程
↓
OS 恢复现场
↓
iret 返回用户态

PPT 最后的总结也可以概括为三点:

text 复制代码
1. 中断处理对用户进程通常不可见
   它发生在指令之间,处理完后透明恢复。

2. 中断机制是安全设计的
   有有限入口 IDT,有内核中断栈,有中断屏蔽机制。

3. 用户态/内核态切换依赖硬件和 OS 协作
   硬件提供机制,OS 设置策略和 handler。

本讲作业是阅读 xv6 的 interrupt handler 代码,理解中断入口、trapframe、handler、返回路径到底发生了什么。


13. PPT 逐页覆盖索引

为了确认没有漏掉 PPT 内容,这里按页简单对照一下:

页码 内容 本文对应位置
1 Lecture 3 标题:Context Switch 开头
2 CPL,CS 低 2 位,用户态/内核态切换问题 第 2 节
3 复习 BIOS vs Bootloader 第 1 节背景补充
4 复习可执行文件内存布局,地址不是物理地址 第 1 节背景补充
5 Dual Mode,硬件辅助隔离与保护 第 1、2 节
6 Code、Process、Mode 三个概念 第 3 节
7 关于 user code / OS code / mode 的问题 第 3 节
8 上述问题答案,eBPF、中断处理、Shell/UI 第 3 节
9 今日目标:三种 mode switch,x86 example 第 4、9 节
10 今日目标重复 第 4、9 节
11 Exception、Interrupt、System Call 定义 第 4 节
12 IPI、invalid opcode、segfault 等分类 第 4.4 节
13 IVT 中断向量表基本概念 第 5.1 节
14 x86 IVT 256 项,每项 4 bytes 第 5.1 节
15 IDT、ISR、Gate、IDTR、LIDT 第 5.2 节
16 IDT gate 字段:offset、selector、type、DPL、P 第 5.3 节
17 offset 为什么拆成两部分 第 5.3 节补充
18 IDT + GDT/LDT 找 handler 的路径 第 5.4 节
19 interrupt number 来自设备通知 第 5.4 节
20 IVT vs IDT 对比 第 5.5 节
21 Interrupt Masking,可屏蔽/不可屏蔽中断 第 6 节
22 Interrupt Controller、mask、NMI 示意 第 6 节
23 Interrupt Stack,中断栈,不用用户栈 第 7 节
24 中断栈数量:1 × 进程/线程数 第 7.3 节
25 First fault、double fault、triple fault 第 7.4 节
26 不要 swap out page swapping code 第 7.4 节
27 每线程中断栈方便 handler 中切换进程 第 7.3 节
28 Kernel-to-user mode switch 类型 第 8 节
29 Upcalls 及例子 第 8.1 节
30 今日目标回顾 第 9 节
31 x86 背景,CS:EIP、SS:ESP、EFLAGS 第 9.1 节
32 CS 低 2 位为什么可以放 CPL 第 9.1 节补充
33 ljmp、lcall、INT、IRET 等修改 CS 的指令 第 9.2 节
34 x86 mode transfer,硬件步骤,before interrupt 第 9.3 节
35 handler 开始时中断栈布局 第 9.3 节
36 步骤 2-4 不能乱序,error code 第 9.4、9.5 节
37 OS 保存剩余寄存器,pushad/popad/iret 第 9.6 节
38 int/SYSCALL 和 iret 修改 CPL,syscall 例子 第 9.7 节
39 inline assembly int 0x80 例子 第 9.7 节
40 Linux Kernel 不支持浮点操作,为什么 第 10.1 节
41 Context switch 成本表 第 10 节
42 Stack vs Heap,为什么跟踪栈指针 第 11 节
43 总结:不可见、安全设计、硬件-软件协作 第 12 节
44 作业:读 xv6 interrupt handler 第 17 节

14. 小测题

题 1

x86 中 CPL 存在哪里?现代 OS 通常使用哪两个 CPL?

题 2

用户程序能不能通过普通位运算把 CPL 改成 0?为什么?

题 3

Exception、Interrupt、System Call 的区别是什么?请分别举一个例子。

题 4

Python 中 1 / 0 一定会触发 CPU 的 divide-by-zero exception 吗?

题 5

IDT 的作用是什么?为什么不能让用户程序直接指定内核 handler 地址?

题 6

IDT gate 中 DPL 字段有什么作用?

题 7

为什么进入内核时要切换到中断栈,而不是直接使用用户栈?

题 8

中断栈通常有多少个?为什么不只用一个全局中断栈?

题 9

发生 interrupt / exception / syscall 时,硬件大致做哪些事情?

题 10

为什么 context switch 的成本不只是保存和恢复寄存器?


15. 小测题答案

答 1

CPL 存在 x86 的 CS segment register 低 2 位中。现代 OS 通常只使用:

text 复制代码
CPL = 0:kernel mode
CPL = 3:user mode

答 2

不能。CPL 不是普通变量,用户程序不能直接修改 CS 的特权位。CPL 只能通过 CPU 规定的安全路径改变,例如 intsyscalliret 等。

答 3

Exception 是当前指令导致的同步异常,例如非法内存访问、非法指令、机器级除零。

Interrupt 是外部事件异步通知 CPU,例如定时器中断、网卡中断、键盘输入。

System Call 是用户程序主动请求内核服务,例如 read()write()fork()

答 4

不一定。Python/Java 的除零通常先由语言运行时检查并抛出语言级异常,不一定真的触发 CPU 的 divide-by-zero exception,也不一定进入内核。

答 5

IDT 保存中断、异常、系统调用对应的内核入口。用户程序不能直接指定 handler 地址,因为那样会绕过权限检查,可能跳进内核任意位置破坏系统安全。

答 6

DPL 决定哪些特权级的代码可以通过 INT 指令访问这个 gate。系统调用入口通常允许用户态进入,而普通硬件中断入口不能让用户随便伪造。

答 7

因为用户栈不可靠也不可信。用户栈可能已经损坏,也可能被用户恶意伪造。内核必须使用自己控制的中断栈保存关键现场。

答 8

通常是每个进程或线程一个中断栈。这样当一个进程在 handler 中阻塞或被切换出去时,它的内核现场可以保存在自己的内核栈上,之后能正确恢复。

答 9

硬件大致会:

text 复制代码
屏蔽中断
保存特殊寄存器状态
切换到内核中断栈
把 SS:ESP、EFLAGS、CS:EIP 等关键值压入新栈
必要时保存 error code
跳转到 interrupt handler

答 10

因为上下文切换还会影响 TLB、cache、pipeline、branch predictor 等硬件状态。保存寄存器只是直接成本,cache pollution 和 pipeline flush 等间接成本往往更大。


16. 常见 QA

Q1:系统调用和函数调用有什么区别?

函数调用只是在同一特权级内跳转,例如用户态函数调用用户态函数。它不会改变 CPL。

系统调用会从用户态进入内核态,需要 CPU 检查权限、切换栈、保存现场,最后由内核执行服务。因此 syscall 的成本比普通函数调用高得多。


Q2:异常和中断都进入内核,它们有什么本质区别?

异常通常由当前正在执行的指令导致,是同步的。

中断通常来自外部设备或其他处理器,是异步的。

简单说:

text 复制代码
异常:我自己这条指令出事了
中断:外面有事来打断我

Q3:为什么中断处理对用户程序"不可见"?

理想情况下,中断发生在两条用户指令之间。内核处理中断后恢复现场,用户程序继续执行,好像什么都没发生。

当然从性能上说,cache、TLB、时间都可能变化,但从程序语义上看,它应该透明恢复。


Q4:为什么说 IDT 是安全机制?

因为 IDT 限制了进入内核的入口。用户程序不能随便指定内核地址,只能通过有限数量的 gate 进入内核,并且这些 gate 会进行权限检查。


Q5:中断屏蔽是不是把中断丢掉?

通常不是。PPT 说 interrupts are deferred, but not ignored,也就是中断被推迟处理,而不是直接忽略。

不过硬件缓冲有限,所以内核不能长时间屏蔽中断。


Q6:为什么 Linux 内核不使用浮点?

因为浮点寄存器和 SIMD 寄存器也是用户进程上下文的一部分。如果内核使用它们,就要额外保存和恢复这些状态,增加每次进入内核的成本。因此内核通常避免浮点运算。


Q7:系统调用一定使用 int 0x80 吗?

不一定。int 0x80 是传统 32 位 Linux 的系统调用方式。现代 x86-64 Linux 更常用 syscall 指令。不同架构和不同 OS 的 syscall ABI 都可能不同。

但核心思想一样:

text 复制代码
通过受控入口从用户态进入内核态。

Q8:Context switch 和 mode switch 是一回事吗?

不完全是。

Mode switch 是用户态和内核态之间的切换,例如 syscall 进入内核、iret 返回用户态。

Context switch 更强调从一个执行上下文切换到另一个执行上下文,例如从进程 A 切换到进程 B。一次系统调用可能只有 mode switch,不一定切换到另一个进程;一次调度则通常会发生真正的 context switch。


17. 拓展:读 xv6 interrupt handler 时应该看什么?

PPT 作业要求阅读 xv6 的 interrupt handler。读的时候不要一上来陷入细节,可以抓住这条主线:

text 复制代码
中断入口在哪里?
↓
CPU / 汇编入口保存了哪些寄存器?
↓
trapframe 长什么样?
↓
trap handler 如何区分 syscall、timer interrupt、page fault 等?
↓
如果需要调度,在哪里调用 yield / scheduler?
↓
最后如何恢复现场并返回用户态?

可以重点关注这些概念:

text 复制代码
IDT 初始化
trap vector
trapframe
syscall handler
timer interrupt
yield / scheduler
trapret / iret

读 xv6 时,把代码和第三讲的流程对应起来:

text 复制代码
硬件保存最小现场
OS 保存通用寄存器
handler 根据 trap number 分发
可能调度其他进程
恢复现场
iret 返回

只要这条线能串起来,第三讲就真正学明白了。


结尾

第三讲表面是在讲 context switch,实际上是在讲操作系统最核心的安全边界:

text 复制代码
用户态不能直接控制硬件,也不能直接进入内核;
内核必须给用户程序提供服务,但必须以受控方式提供;
CPU 提供特权级、IDT、中断栈、iret 等机制;
OS 设置这些机制,并负责保存、处理、恢复上下文。

理解这一讲之后,后面的 syscall、thread context switch、scheduler、page fault、demand paging 都会变成同一个故事的不同分支:

CPU 被打断,进入内核,保存现场,处理事件,选择继续谁,然后恢复执行。

这就是操作系统能同时做到"保护自己"和"服务用户程序"的关键。

相关推荐
聚铭网络1 小时前
聚铭网络荣获《一种分层架构的安全运营平台的数据保护方法及系统》发明专利
网络·安全·架构
Geometry Fu1 小时前
《物联网安全》第6章 入侵检测技术
网络·物联网·安全·ips·入侵检测·ids
切糕师学AI1 小时前
深度解密现代零信任 Full-Mesh 安全网络:架构演进、NAT 穿透原理与企业私有网络实践
网络·安全·架构
德迅--文琪1 小时前
看不见的围墙:APP背后的网络安全暗门
安全·web安全
Fortinet_CHINA1 小时前
AI正在重塑网络安全格局,但技能差距仍是核心风险
人工智能·安全·web安全
light blue bird1 小时前
支轴事件任务线程执行工序路径的图表组件
前端·jvm·windows
终端行者1 小时前
企业级 Jenkins Pipeline 实战Docker构建前端+Ansible发布
前端·ci/cd·docker·jenkins
风之舞_yjf1 小时前
Vue基础(33)_web Storage(web存储)
前端·javascript·vue.js