卷 I · 边界CH 02深度 02/24

那道线:用户态与内核态

「内核态」和「用户态」这两个词你一定听过。但它们在硬件上究竟是什么?答案出人意料地朴素:CPU 里有两个比特。这一章讲这两个比特,以及围绕它建立起来的整套秩序。

ring 0 / ring 3特权指令#GP划线的标准

那道线,物理上是两个比特

x86 CPU 内部有一个字段叫 CPL(Current Privilege Level,当前特权级),宽度是两个比特,能表示 0 到 3 四个级别。Intel 当年管它们叫「环」(ring),画成同心圆:ring 0 在最里面,ring 3 在最外面。

  • ring 0:内核。能执行所有指令,能访问所有内存。
  • ring 1、ring 2:设计出来给设备驱动用的中间层。基本没人用——因为其他架构(ARM、RISC-V)没有对应概念,跨平台的操作系统懒得为 x86 特设一层。
  • ring 3:你。你写过的每一行应用代码,都在这里执行。

所以现实中只用了两个:0 和 3。中间那两级是历史留下的空房间。

这两个比特的作用非常直接:CPU 在执行每一条指令之前,会检查这条指令要求的特权级。如果当前 CPL 不够,指令不执行,而是触发一个异常。

▶ 动手 · 特权指令拦截器

下面这台机器让你亲手做这件事:挑一条指令,再挑一个 ring,看 CPU 怎么裁决。九条指令都点一遍,然后回来看下一节——我想让你自己先发现划线的标准是什么。

划线的标准:它能不能影响到别人

点完你大概已经看出来了。判断一条指令该不该是特权指令,标准朴素得让人意外:

◆ 划线的唯一标准

这条指令的影响,会不会越过执行它的那个进程的边界?

会——它就必须在线的下面。不会——随便你执行。

拿几个例子对照一下:

指令特权?为什么
add %rbx, %rax不需要只动自己的寄存器。它算错了,也只有自己倒霉
mov (%rsi), %rax不需要读内存。合不合法由页表管(第 11 章),不归特权级管——两套机制,各管一摊
cli(关中断)必须关掉中断就等于关掉时钟中断,调度器再也抢不回 CPU。一条指令就能霸占整台机器
mov %rax, %cr3必须CR3 是页表基址。能改它,就能把任意物理内存映射进自己的地址空间——所有进程的、内核的,全部
in $0x60, %al必须直接读硬件端口。0x60 是键盘控制器——能读它就能偷所有人的键盘输入
hlt(停机)必须让 CPU 睡到下次中断。谁都能执行的话,任何进程都能让机器停摆

注意第二行那个细节,它很容易被忽略但相当重要:「能不能访问某块内存」和「特权级」是两套独立的机制。访问内存不需要特权,但你能访问哪些地址由页表决定;而改页表需要特权。这个分层设计很漂亮——内核不必逐条检查你的每次访存(那样太慢了),它只要把页表锁死,剩下的交给 MMU 硬件自动执行。

撞线之后发生什么

假设你在用户态执行了 hlt。CPU 的反应是:

; 你的代码,在 ring 3
    mov  $42, %rax
    hlt                  ; ← CPU 检查:本指令要求 CPL=0,当前 CPL=3
                         ;   不执行,触发 #GP(general protection fault,向量 13)

接下来是一条固定的流水线:

  1. CPU 不执行这条指令,转而查中断描述符表(IDT)第 13 项。
  2. 切到 ring 0,换成内核栈,跳到内核注册好的 #GP 处理函数。
  3. 内核检查:这是个可以修复的情况吗?hlt 显然不是。
  4. 内核向你的进程投递 SIGSEGV
  5. 你没注册处理器,于是走默认动作——杀掉进程
$ ./bad
Segmentation fault (core dumped)

这就是那句你见过一万次的报错的完整来历。它不是「程序崩溃了」,它是「CPU 拒绝执行,内核决定处死」。

⚠ 一个常见的误解

很多人以为 Segmentation fault 一定是「访问了非法内存」。不是的——它是好几种不同事故的同一个出口:越权执行特权指令(本章)、访问没有映射的地址(第 11 章)、往只读页写入(第 11 章)、在不可执行的页上取指(第 11 章)……

它们在硬件上是不同的异常、走不同的路径,但内核判定「没救了」之后,投递的都是同一个信号。所以光看到这行字,你其实不知道发生了什么。dmesg 或 core dump 里才有真相。

那么,谁来切换这两个比特?

这里有个必须想清楚的问题。既然改变特权级这件事如此关键,那「从 ring 3 切到 ring 0」这个动作本身,肯定不能让用户态随便做。

但用户态又必须能进内核——不然没法读文件、没法发网络包。

这个矛盾的解法,是整个操作系统设计里最精巧的一招:

◆ 关键设计

用户态可以请求进入内核,但不能决定进去之后执行什么。

你能敲门(执行 syscall 指令),但门后的路是内核提前铺好的:入口地址存在一个特权寄存器(MSR)里,只有内核能写。你执行 syscall 的那一刻,CPU 自动把 CPL 设成 0,然后跳到那个固定的地址

你无法说「切到 ring 0,然后执行我这段代码」。你只能说「我要进去」,然后由内核决定接下来做什么、以及要不要理你。

用一个比喻:这不是给了你一把万能钥匙,而是给了你一个服务窗口。你可以走到窗口前递一张单子,但你走不进办公室。而且窗口后面坐着的人会把你单子上的每一栏都核对一遍(第 3 章会看到内核有多不信任你)。

切换特权级,具体发生了什么

「切到 ring 0」听起来像翻一个开关,但实际上 CPU 要做好几件事,因为内核不能用你的栈

; 执行 syscall 时,CPU 自动完成:
;   1. 把返回地址(下一条指令)存进 rcx
;   2. 把标志寄存器存进 r11
;   3. 从 MSR_LSTAR 读出内核入口地址,跳过去
;   4. CPL 设为 0
;   5. 换栈:切到这个 CPU 的内核栈

第 5 步值得多说一句。为什么内核不能用你的栈?因为你的栈指针 rsp 是你控制的。如果内核直接拿它用,你只要把 rsp 指向一个精心构造的地址,就能让内核把数据写到你想要的任何地方——包括内核自己的代码段。整个安全模型当场瓦解。

所以每个 CPU 核心都有一个专用的内核栈,进内核时立刻切过去。这是那些「安全设计」里非常典型的一类:不要相信任何来自线上面的东西,包括栈指针

✎ 掌故 · 那两个没人用的 ring

Intel 在 80286 上设计四级保护环时,设想的是一个分层的软件世界:ring 0 内核、ring 1 系统服务、ring 2 设备驱动、ring 3 应用。听上去很合理。

但没人这么用。原因很实际:Unix 早就只用两级了,而 Unix 要跑在各种各样的硬件上,那些硬件大多只有两个特权级。为 x86 特设四级意味着代码分叉,而收益并不明显——真正的隔离边界在地址空间(页表)上,不在特权级上。

有意思的是,这两个空房间在几十年后被派上了用场:虚拟化早期的「ring 压缩」技术,就是把客户机内核从 ring 0 挪到 ring 1,好让宿主的 hypervisor 独占 ring 0。后来硬件虚拟化(VT-x)引入了「ring -1」这个概念,这套把戏才退休。

ARM 上是一样的事,换了个名字

如果你是 Android 开发者,你的代码大多跑在 ARM 上。ARM 不叫 ring,叫 Exception Level(EL),但这是同一件事换了套词汇:

ARMx86 对应谁住在这里
EL0ring 3你的 App、ART 虚拟机、所有用户态代码
EL1ring 0Linux 内核
EL2(VT-x root)Hypervisor。Android 上跑着 pKVM,用来隔离受保护的虚拟机
EL3(SMM 近似)Secure Monitor。TrustZone 的守门人——指纹、密钥、DRM 都在这后面

陷入指令也换了名字:x86 是 syscall,ARM64 是 svc(supervisor call)。机制完全一致。

顺带解释了一个 Android 开发者熟悉的现象:为什么指纹和密钥你的 App 永远拿不到。因为它们不在 EL0,甚至不在 EL1——它们在 EL3 后面的另一个世界里。这是第 2 章这条线在硬件上又加了一层的结果。

↑ 回到应用层

「为什么我不能在 App 里直接读别的 App 的内存?」

这个问题的答案分两层,而且很多人只知道上面那层:

  • 上层(大家都知道的):Android 给每个 App 分了不同的 uid,权限系统不允许。
  • 下层(这一章讲的):就算权限检查被绕过了,你依然做不到。因为「读别人的内存」需要先改页表把它映射进来,而改页表(写 TTBR0CR3)是特权指令,EL0 执行它就是一个异常。

这两层的可靠性完全不同:上层是软件检查,有 bug 就能绕过;下层是 CPU 硬件检查,绕不过——除非你先找到一个内核漏洞把自己提权到 EL1。这也正是为什么所有 root 方案的第一步,都是找一个内核漏洞。

这个「软件策略 + 硬件兜底」的双层结构,你在《开门》讲插件沙箱时会再遇到一次——只不过那里的「硬件」换成了 JS 引擎的隔离机制,可靠性也就跟着降了一档。

这一章的一句话

那道线在硬件上就是 CPU 里的两个比特。划线的标准只有一条:这条指令的影响会不会越过自己的边界。而用户态能敲门进内核,却永远无法决定进去之后执行什么——这是整套安全模型的支点。

下一章,我们把「敲门」这个动作放到显微镜下:参数怎么递、内核怎么检查、以及为什么它一个字都不信你。