那道线:用户态与内核态
「内核态」和「用户态」这两个词你一定听过。但它们在硬件上究竟是什么?答案出人意料地朴素:CPU 里有两个比特。这一章讲这两个比特,以及围绕它建立起来的整套秩序。
那道线,物理上是两个比特
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)
接下来是一条固定的流水线:
- CPU 不执行这条指令,转而查中断描述符表(IDT)第 13 项。
- 切到 ring 0,换成内核栈,跳到内核注册好的
#GP处理函数。 - 内核检查:这是个可以修复的情况吗?
hlt显然不是。 - 内核向你的进程投递
SIGSEGV。 - 你没注册处理器,于是走默认动作——杀掉进程。
$ ./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 核心都有一个专用的内核栈,进内核时立刻切过去。这是那些「安全设计」里非常典型的一类:不要相信任何来自线上面的东西,包括栈指针。
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),但这是同一件事换了套词汇:
| ARM | x86 对应 | 谁住在这里 |
|---|---|---|
| EL0 | ring 3 | 你的 App、ART 虚拟机、所有用户态代码 |
| EL1 | ring 0 | Linux 内核 |
| 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,权限系统不允许。
- 下层(这一章讲的):就算权限检查被绕过了,你依然做不到。因为「读别人的内存」需要先改页表把它映射进来,而改页表(写
TTBR0/CR3)是特权指令,EL0 执行它就是一个异常。
这两层的可靠性完全不同:上层是软件检查,有 bug 就能绕过;下层是 CPU 硬件检查,绕不过——除非你先找到一个内核漏洞把自己提权到 EL1。这也正是为什么所有 root 方案的第一步,都是找一个内核漏洞。
这个「软件策略 + 硬件兜底」的双层结构,你在《开门》讲插件沙箱时会再遇到一次——只不过那里的「硬件」换成了 JS 引擎的隔离机制,可靠性也就跟着降了一档。
这一章的一句话
那道线在硬件上就是 CPU 里的两个比特。划线的标准只有一条:这条指令的影响会不会越过自己的边界。而用户态能敲门进内核,却永远无法决定进去之后执行什么——这是整套安全模型的支点。
下一章,我们把「敲门」这个动作放到显微镜下:参数怎么递、内核怎么检查、以及为什么它一个字都不信你。