本篇位置 虚拟化 CPU 的机制篇。第 5–7 篇讲策略(选谁),这一篇讲怎么做到
运行环境 容器里的 Linux(见总览页),Python 3 + gcc

一、一个两难

你要让用户的程序跑起来。有两条路。

路一:解释执行。 内核把用户程序的指令一条一条读进来,检查一遍,再模拟执行。安全得很——每一条指令你都看过。代价是慢几十倍。 没人接受。

路二:直接执行。 把 CPU 交给用户程序,让它自己跑真指令。快,和裸机一样快。 但你交出去之后:

问题一:它想干什么就干什么。 它可以读别的进程的内存、直接操作硬盘、把整台机器关掉。 问题二:它可以永远不还回来。 写一个 while (1);,CPU 就再也不是你的了。

操作系统选了路二,然后想办法把这两个问题堵上。堵上之后的路二,叫受限直接执行(limited direct execution)。

这一篇就是这两个补丁。

打个比方

这就像让一个新来的实习生直接用公司的电脑

你当然可以站他背后,每一步都替他敲——安全,但你什么活都别干了(这是路一)。实际的做法是让他自己用,但是:

  • 给他一个受限账号。 装软件、改系统设置、看别人的文件,一律做不了。他要做这些,只能填一张工单交给 IT。→ 这是特权级和系统调用。
  • 你每隔一会儿过来看一眼。 不是靠他自觉回来汇报——万一他睡着了呢。→ 这是时钟中断。

⭐ 两个补丁一个也不能少。这一篇后面就按这两条走。

二、补丁一:受限账号(特权级)

CPU 有两种运行模式:

  • 用户态(user mode):能执行的指令有限。碰到不该碰的,CPU 直接抛异常。
  • 内核态(kernel mode):什么都能干。

这不是操作系统的软件检查,是 CPU 自己的电路在管。我们让它证明给你看。

下面这段 C,做的事是读页表基址寄存器——每次内存访问都要用到它,但它只能在内核态读:

#include <stdio.h>
#include <unistd.h>
int main(void) {
    unsigned long v = 0;
    printf("我是一个普通的用户态程序,pid=%d\n", (int)getpid());
    printf("现在我要读页表基址寄存器 —— 这条指令只有内核态能执行\n");
    fflush(stdout);
#if defined(__aarch64__)
    __asm__ volatile("mrs %0, ttbr0_el1" : "=r"(v));
#elif defined(__x86_64__)
    __asm__ volatile("cli");          /* 关中断,同样是特权指令 */
#endif
    printf("我读到了 %lx —— 如果你看到这一行,隔离就失效了\n", v);
    return 0;
}
gcc -o priv priv.c && ./priv; echo "退出状态: $?"
我是一个普通的用户态程序,pid=19
现在我要读页表基址寄存器 —— 这条指令只有内核态能执行
Illegal instruction
退出状态: 132

最后一行 printf 没有被执行,进程被 SIGILL(非法指令)杀掉了——132 = 128 + 4,4 就是 SIGILL。(“信号"这个机制本身,以及 128 + N 这个约定,在第 3 篇第六节。)

⭐ 请注意这件事是谁拒绝的:不是 Linux 检查了这段代码,不是编译器拒绝生成——gcc 老老实实编译了,指令也真的被送进了 CPU。是 CPU 在执行的那一纳秒发现"当前是用户态,这条指令不许”,然后抛了异常。

这就是"受限"两个字的物理基础。它不是软件约定,是硬件。

顺带看看软件层的限制也在(这些是内核检查的,不是 CPU):

# privileged.py
import os, ctypes

libc = ctypes.CDLL("libc.so.6", use_errno=True)

rc = libc.mount(b"none", b"/mnt", b"tmpfs", 0, None)
print(f"1. mount() 挂一个 tmpfs 到 /mnt   -> {'成功' if rc == 0 else '失败:' + os.strerror(ctypes.get_errno())}")

try:
    os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(99))
    print("2. 把自己设成实时最高优先级       -> 成功了?!")
except OSError as e:
    print(f"2. 把自己设成实时最高优先级       -> 失败:{e.strerror}")

rc = libc.reboot(0x1234567)
print(f"3. reboot() 重启整台机器          -> 失败:{os.strerror(ctypes.get_errno())}")
1. mount() 挂一个 tmpfs 到 /mnt   -> 失败:Operation not permitted
2. 把自己设成实时最高优先级       -> 失败:Operation not permitted
3. reboot() 重启整台机器          -> 失败:Operation not permitted

三、那正经事怎么办:系统调用

限制住了,可是用户程序总得读文件、总得开网络。这些事非要内核态不可。

答案是留一道门系统调用(system call)。

它的关键不在"有一个函数可以调用",而在这道门只能从固定的地方进

  1. 用户程序把系统调用号和参数放进约定好的寄存器。
  2. 执行一条特殊指令——syscall(x86-64)或 svc(arm64)。这条指令叫陷入(trap)。
  3. 硬件干三件事,原子地:切到内核态、把 PC 跳到内核开机时就设好的那个地址、保存用户程序的寄存器。
  4. 内核照着系统调用号查表,执行,把结果放回寄存器。
  5. 执行 return-from-trap,恢复寄存器,切回用户态,从陷入的下一条指令继续。

第 3 步里"内核开机时就设好的那个地址"是整个安全模型的支点。 用户程序不能指定跳到哪——它只能说"我要陷入",跳到哪是开机时内核写进陷阱表(trap table)里的,那时候还在内核态,还改得动;开机之后就锁死了。

否则的话,你就可以说"帮我切到内核态,然后跳到我自己的这段代码"——那所有的限制就一起作废了。

strace 可以看见每一次过门:

strace -e trace=openat,write python3 -c "print(open('/etc/hostname').read().strip())"
openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 3
write(1, "34ce9693384c\n", 13)          34ce9693384c

那句 open(...).read() 在 Python 里是一行,落到硬件上是两次进出内核态。回到实习生那个比方:他每填一张工单,就是一次过门——门本身是安全的,但过门要排队登记,不是免费的。这笔钱第六节算。

四、补丁二:定时查岗(时钟中断)

第二个问题还没解决:程序跑着跑着不还了怎么办?

不能指望它自觉。 早期有过这种系统(协作式调度),程序自愿定期调用 yield 让出 CPU。一个死循环、或者一个崩了的程序,就能让整台机器卡死——唯一的办法是重启。

现在的做法是硬件定时器:内核开机时告诉硬件"每隔 N 毫秒给我发一次中断"。时间到了,不管当前程序在干什么,硬件强行把 PC 跳进内核。

这不是我编的,让内核自己报账。/proc/stat 里有两个累计计数器:intr 是硬件中断总数,ctxt 是上下文切换总数:

# takeover.py
import time

def counters():
    intr = ctxt = 0
    for line in open("/proc/stat"):
        if line.startswith("intr "):
            intr = int(line.split()[1])
        elif line.startswith("ctxt "):
            ctxt = int(line.split()[1])
    return intr, ctxt

i0, c0 = counters()
print(f"现在:硬件中断累计 {i0} 次,上下文切换累计 {c0} 次")
print("接下来我这个程序什么都不做,只是睡 2 秒……")
time.sleep(2)
i1, c1 = counters()
print(f"两秒后:硬件中断累计 {i1} 次,上下文切换累计 {c1} 次")
print()
print(f"这 2 秒里:硬件打断了 CPU {i1-i0} 次(每秒约 {(i1-i0)//2})")
print(f"           内核换了 {c1-c0} 次进程(每秒约 {(c1-c0)//2})")
现在:硬件中断累计 673062 次,上下文切换累计 1275012 次
接下来我这个程序什么都不做,只是睡 2 秒……
两秒后:硬件中断累计 673538 次,上下文切换累计 1275750 次

这 2 秒里:硬件打断了 CPU 476 次(每秒约 238)
           内核换了 738 次进程(每秒约 369)

你的程序在睡觉,什么都没做。这两秒里内核换了 738 次进程。

这就是"定时查岗"的实际频率。它跟你的程序想不想被打断,一点关系都没有。

⭐ 所以第 1 篇那 20 个进程能同时跑,靠的不是它们互相谦让,是每隔几毫秒硬件就把 CPU 从当前这个手里夺回来一次。这个能力叫抢占(preemption),它是后面三篇所有调度策略的前提——没有抢占,任何调度策略都是纸上谈兵

五、拿回来之后:上下文切换

时钟中断把 PC 跳进了内核。内核决定"换人"。换人具体是什么动作?

回到第 2 篇那三样东西——地址空间、寄存器、打开的文件。切换就是把 A 的收起来、把 B 的铺开:

  1. 把 A 的通用寄存器、PC、SP 存进 A 的内核栈(其实中断发生时硬件已经存了一部分)。
  2. 切换页表基址寄存器——就是第二节那条把程序打死的指令,内核态执行它是家常便饭
  3. 从 B 的内核栈里恢复 B 的寄存器。
  4. return-from-trap 回到用户态,PC 落在 B 上次被打断的地方。

B 完全不知道自己被停过。 它的下一条指令,就是它上次那条指令的下一条。

⚠️ 这里有个容易混的地方:“陷入内核"和"切换进程"是两件事。 每次系统调用都陷入内核,但绝大多数陷入之后原路返回,还是原来那个进程在跑。只有内核决定换人时,才发生上下文切换。上面那个演示里 2 秒 476 次中断、738 次切换——这两个数字不是一回事。

六、代价与取舍

受限直接执行换来了什么: 全速执行 + 安全 + 抢占,三样都有。这是过去五十年里没有被推翻过的设计。

它花了什么:

  • 每次过门要几百纳秒。 保存恢复寄存器、切页表、以及最贵的一项——缓存和 TLB 被弄脏了。切换回来之后,你的数据不在缓存里了,接下来几千条指令都会慢。这笔隐性开销经常比显性开销更大。
  • 查岗本身要花钱。 每秒两百多次中断,每次都要进内核走一趟。所以现代内核有 NO_HZ 模式——一个核上只有一个可运行进程时,干脆把定时器关掉,省下这笔钱。“加一层中间人"的开销,又一次被专门优化掉了。

它放弃了什么: 用户态和内核态之间那道墙,在 2018 年被证明不是密不透风的。Meltdown 和 Spectre 利用 CPU 的推测执行,不需要越过这道墙就能读到墙那边的内容——因为墙挡住了架构层的访问,却没挡住它在缓存上留下的影子。这是第 1 篇说的"谎言总会在某个地方漏出来"最贵的一次,第 30 篇细讲。

七、小结

  • 直接执行快,但带来两个问题:程序想干什么就干什么、程序可以不还回来。受限直接执行用两个补丁堵上。
  • 补丁一:特权级。 用户态执行特权指令,CPU 直接抛异常——是硬件拒绝的,不是软件检查的(我们让它抛了 SIGILL)。
  • 正经事走系统调用。 关键不在"有个函数能调”,而在陷入之后跳到哪,是开机时内核设好的,用户改不了
  • 补丁二:时钟中断。 不指望程序自觉。你什么都不做的 2 秒里,硬件打断了 CPU 476 次,内核换了 738 次进程。抢占是所有调度策略的前提。
  • 上下文切换就是把第 2 篇那三样东西收起来再铺开。显性开销是几百纳秒,隐性开销(缓存和 TLB 变冷)往往更大
  • ⚠️ 陷入内核 ≠ 切换进程。绝大多数陷入都原路返回。

思考题

  1. 第四节里 2 秒有 476 次中断、738 次上下文切换。为什么切换次数比中断次数还多?(提示:还有什么会触发切换?回想第 2 篇那条"发起 I/O"的边。)
  2. 假设硬件定时器的周期从 4 毫秒改成 0.1 毫秒,会发生什么?往两个方向各说一个后果。
  3. 第三节说陷阱表的地址在开机时设好、之后改不动。如果攻击者能改它,他能做到什么?为什么这比"读到内核内存"严重得多?
  4. 实习生那个比方里,“受限账号"和"定时查岗"都有对应物。那么**上下文切换的隐性开销(缓存变冷)**对应比方里的什么?

延伸