
现代计算机能够同时运行多个应用程序而不会崩溃,我们常常认为这是理所当然的。然而,这种稳定性并非偶然;它是操作系统核心处一个基础设计原则的结果。核心挑战在于,如何授予应用程序所需的资源,同时防止任何一个有缺陷或恶意的程序破坏整个系统的稳定。一台计算机如何能在服务众多主人的同时不陷入混乱?答案在于一种被称为特权模式的严格权力分离。本文将揭开这个关键概念的神秘面纱,它是所有现代计算安全与稳定的基础。
在第一章“原理与机制”中,我们将探讨这种分离的核心,将其比作一个为公民(用户程序)和统治者(操作系统内核)划分了不同领域的王国。我们将剖析处理器硬件本身如何通过特权指令和内存保护来强制执行这一边界。随后的章节“应用与跨学科联系”将揭示该模型的深远影响,展示它如何促成从安全文件访问、高效网络到驱动云计算的虚拟化技术等一切。读完本文,你将理解,这个简单的双模式思想,正是使你的数字世界成为可能的无声守护者。
想象一个繁华且管理良好的城市。大多数居民都是市民,过着自己的日常生活。他们住在自己的房子里,在公共道路上开车,享受城市的公园。他们的生活富有成效且基本独立。现在,想象一群特殊的人:城市规划师、运营电网的工程师以及政府官员。他们拥有特殊的钥匙。他们可以改变交通信号灯的时间,接入中央供水总管,并重新规划整个区域。
这不是一个关于统治阶级及其臣民的故事,而是一个关于功能与安全的故事。你不会希望任何市民能够因意外或恶意企图而关闭电网或逆转高速公路上的交通流。这个系统之所以能运转,是因为一个“社会契约”:市民可以自由地生活,作为交换,他们信任城市官员来管理使一切成为可能的共享基础设施。
这正是现代计算机操作系统所使用的模型。在你的计算机上运行的绝大多数代码——你的网页浏览器、音乐播放器、视频游戏——都存在于一个称为用户模式的领域。这是市民的领域。操作系统的核心,即内核,则在一个独立且更强大的领域中运行:特权模式,也称为监督模式或内核模式。
内核是你计算机的市政府。它管理着基本资源:谁可以使用 CPU、使用多长时间,如何分配内存,如何将数据写入硬盘,以及如何通过网络发送数据包。这两种模式之间的分离并非关乎等级制度;它是实现稳定、安全和多任务计算环境的根本设计原则。没有它,一个有缺陷的程序就可能导致整个系统崩溃,或者一个恶意程序可以读取其他所有程序的私有数据。这种分离是构建所有现代计算的基石。
那么,内核究竟能做什么而用户程序不能做呢?这种区别并非随意的;它由处理器本身在芯片层面强制执行。处理器指令集中的某些指令被指定为特权指令,如果处理器处于用户模式,硬件会直接拒绝执行它们。
让我们戴上计算机架构师的帽子,思考一下哪些操作是如此强大以至于必须受到限制。如果我们从头开始设计一个处理器,我们会把哪些指令锁在监督者的工具箱里?
首先,任何能够改变游戏规则的指令都必须是特权的。想象一个指令,我们称之为 SET_STATUS,它可以将当前模式从 user 更改为 supervisor。如果用户程序可以执行这个指令,那就像一个市民自己印了一枚“我是市长”的徽章,并且这枚徽章立即被所有人承认。这是通往绝对权力的一条捷径。同一个指令可能还控制 CPU 是否响应中断。如果一个用户程序可以禁用中断,它就可以进入一个无限循环并永远独占 CPU,使所有其他程序乃至内核本身都无法运行。这将是一场灾难性的拒绝服务攻击。因此,像 SETPSW(Set Program Status Word)这样的指令是特权指令的典型例子。
其次,我们必须保护系统的应急响应计划。当发生不寻常的事情时——比如一个程序试图除以零,或者键盘上按下一个键——处理器会停止当前的工作,并跳转到内核中一个特定的处理程序例程。所有这些处理程序的地址都存储在内存中的一个特殊表中,通常称为中断向量表。如果一个用户程序可以使用像 SET[VEC](/sciencepedia/feynman/keyword/valence_electron_concentration_(vec)|lang=zh-CN|style=Feynman)TOR 这样的指令修改这个表,它就可以将“系统调用”处理程序重定向到它自己的恶意代码。下一次任何程序向操作系统发出合法请求时,它都会在不知不觉中以特权模式触发攻击者的代码,从而交出王国的钥匙。
最后,特权不仅涉及安全性,还包括系统稳定性和公平性。考虑一个像 TLBFLUSH 这样的指令,它会清除一个硬件缓存中最近的内存地址翻译。虽然这不明显是一个安全风险,但如果一个用户程序在一个紧密循环中执行此操作,将迫使处理器不断在内存中进行昂贵的查找,从而使整个系统对其他所有进程都陷入停顿。为确保公平,这也必须是一个特权操作。
原则很明确:任何能够影响整个系统状态,而不仅仅是当前程序状态的操作,都有可能成为特权操作。
如果用户程序不能执行特权指令,它们如何执行像打开文件或发送网络数据包这样明显需要内核干预的必要任务呢?用户程序不能简单地 JUMP 或 CALL 内核内存空间中的一个函数。这就像一个市民试图踢开市长办公室的门。
相反,硬件提供了一个正式、受控的正门:系统调用。系统调用由一个特殊的、非特权的指令(如现代 x86-64 处理器上的 SYSCALL 或传统的 INT 0x80)发起。执行这个指令就像在市政厅按门铃。它不会让你直接进去,但它会通知工作人员你需要帮助。这种由硬件发起的事件称为陷阱(trap)。
当陷阱发生时,处理器硬件会自动且原子地执行一系列关键步骤:
user 切换到 supervisor。这种转换的一个关键部分是栈切换(stack switch)。程序的栈是其临时的草稿纸。内核不能信任用户的栈;它可能对于内核的需求来说太小,甚至可能是为了导致崩溃而恶意构造的。因此,在进入内核时,硬件通常会切换到一个独立的、原始的内核栈(kernel stack),其位置存储在一个特权寄存器中。这确保了内核有一个安全的工作空间,无论用户程序处于何种状态。 这个过程非常健壮,即使内核本身被中断(例如,当它正在处理系统调用时被一个计时器滴答中断),处理器也可以优雅地处理这个嵌套事件,通常就在同一个内核栈上。
但如果用户程序不按门铃,而是试图通过直接执行特权指令来撬锁呢?硬件会当场抓住它。它会触发另一种陷阱——“非法指令”错误。内核对此错误的处理程序会收到违规通知,并且在大多数情况下,其响应迅速而简单:终止违规进程。该程序被移除,其资源被回收,就好像它从未存在过一样。这是系统规则的最终执行。 这种保护的粒度非常细。当一个用户程序试图非法写入一个特权寄存器时,硬件会在任何状态被改变之前检查模式和指令的意图。被禁止的写入操作会被抑制,然后陷阱被触发。非法的操作甚至从未发生。
世界的隔离比仅仅指令更深。内核需要自己的私有内存来存储其秘密,而每个用户进程也需要自己的私有地址空间,以防止其他进程的窥探。这些就是王国中的墙中之墙。
这是内存管理单元(MMU)的工作,它是一块硬件,充当每一次内存访问的警惕守门人。MMU 将程序使用的“虚拟地址”转换为 RAM 芯片的实际“物理地址”。这种转换的映射存储在一组称为页表的数据结构中,这些页表由内核控制。
至关重要的是,页表中的每个条目都带有权限标志。其中最基本的是用户/监督者(U/S)位。如果此位将一个内存页标记为“仅监督者”,那么用户模式程序任何试图从此页读取、写入或执行的操作都将被 MMU 阻止,MMU 将触发一个到内核的陷阱,称为页错误(page fault)。 这构成了第二道强大的防线。
但事情变得更加复杂。在监督模式下运行的内核,传统上可以访问所有内存,包括用户空间页面。它需要这种能力来为系统调用复制数据到用户程序或从用户程序复制数据。然而,这为一类危险的错误打开了大门。如果用户程序向系统调用传递一个坏指针——一个并非指向用户数据,而是欺骗性地指向内核内部某个敏感位置的指针,会怎么样?如果一个有缺陷的内核盲目地信任这个指针并向其写入,它可能会损坏自己的数据。
为了防范此类威胁,这些墙变得更加智能。现代 CPU 引入了诸如 SMEP(Supervisor Mode Execution Prevention,监督模式执行保护)和 SMAP(Supervisor Mode Access Prevention,监督模式访问保护)等功能。SMEP 阻止内核意外地从标记为用户的页面执行代码,从而挫败那些诱骗内核运行恶意用户提供的 shellcode 的攻击。 同样,SMAP 阻止内核意外地读取或写入标记为用户的页面上的数据。现在,当内核需要合法访问用户内存时,必须明确地、临时地禁用这些保护。这就像强迫城市规划师使用一把特殊的、有记录的钥匙才能进入市民的家,而不是让他们不小心闯进去。
有了特权指令、受控陷阱和硬件强制的内存保护,两个王国之间的界限似乎是绝对的。规则被刻在了芯片里。这就是架构状态(architectural state)的世界——机器的正式、有保证的状态。
但当我们深入了解底层时会发生什么?为了达到惊人的速度,现代处理器是无情的推测者。它们会猜测程序将如何分支,并可能在确认猜测正确之前,沿着预测的路径执行数百条指令。如果猜测错误,处理器会熟练地清理其混乱,撤销所有推测性工作。从架构上讲,就好像什么都没发生过一样。
但如果这种“幽灵”执行留下了一丝微弱、无形的痕迹呢?不是在寄存器或内存的架构状态中,而是在处理器的内部微架构状态(microarchitectural state)中,比如数据缓存。
这就是堡垒墙上的裂缝。一个聪明的用户模式攻击者可以通过在自己的代码中反复执行一个分支来“训练”处理器的分支预测硬件。然后,他们发起一个系统调用。当内核遇到一个类似的分支时,处理器使用被污染的预测,可能会推测性地执行一段从未打算执行的代码片段。这种瞬时执行是以监督者权限发生的。这个小工具可能会读取一个内核秘密值 S,然后用这个秘密来访问一个内存位置,比如 array[S]。这一切的结果都会被丢弃。但一个副作用仍然存在:array[S] 的内存已经被加载到共享的数据缓存中。
当控制权返回到用户模式的攻击者时,他们可以计时访问 array 的每个元素。其中一次访问会快如闪电——一次缓存命中。这就揭示了秘密值 S。这就是臭名昭著的 Spectre 攻击背后的原理。它们表明,通过观察推测执行留下的微架构幽灵,可以颠覆清晰、优美的特权边界。
确保这个新的、微妙的前沿安全性的斗争仍在继续。像 retpolines 这样的缓解措施涉及到巧妙的软件技巧,以在关键边界“隔离”推测执行,但这通常以牺牲性能为代价。 这种持续的演进告诉我们,两种模式这个简单而优雅的思想是一个活生生的概念,一个必须不断适应实现它的机器日益增加的复杂性的概念。两个王国的故事远未结束。
在理解了特权模式的基本“为什么”和“如何”之后,我们现在可以踏上一段旅程,看看这个简单而优雅的思想将我们引向何方。你会发现,这种受信任的监督者与不受信任的用户进程之间的分工,不仅仅是一个技术细节;它是整个现代计算大厦赖以建立的基石。它是你操作系统稳定性、网络性能以及云安全背后默默无闻的英雄。就像一条统一的物理定律,它的影响无处不在。
想象一下你计算机最关键的资源——内存映射、文件系统、网络硬件——如同王国的皇冠上的珠宝。你不会让任何市民随便走进宝库重新整理。你会在门口派驻一个值得信赖、无法被收买的守护者。在计算机中,运行在监督模式下的内核就是这个守护者。来自用户空间应用程序的每一个请求都是向这位守护者提交的请愿书。
考虑一个看似简单的操作:挂载文件系统,比如插入一个 U 盘。一个用户进程可能会说:“我想访问这个设备上的文件。”一个天真的方法可能是给予该进程直接访问 U 盘上原始块的权限。这将是一场灾难!一个有缺陷或恶意的程序可能会扰乱文件系统,覆盖主引导记录,或损坏属于其他分区的数据。
特权分离原则要求一种更好的方式。用户进程可以请求,但只有守护者——内核——才能执行。用户进程发起一个“系统调用”,这是一个正式、受控地转换到监督模式的过程。一旦进入监督模式,内核便接管一切。它验证请求:这个用户有权限吗?这个设备是它声称的那样吗?然后,它亲自执行所有危险的操作,比如读取文件系统的超级块并将其整合到系统对所有文件的全局视图中。用户进程永远不会触及原始硬件。
这个原则必须是绝对的。假设一个驱动程序需要提供一种切换设备电源状态的方法。安全检查——验证用户是否被授权(比如,是‘root’超级用户)——应该在哪里执行?如果你将检查放在用户空间库中,一个聪明的程序员可以简单地编写自己的应用程序,绕过该库直接进行系统调用。这个检查将毫无价值。安全检查必须始终由守护者在监督模式的堡垒内执行,用户进程无法篡改它们。内核安全地检查敲门进程的凭证,然后才执行特权操作。
门前的守护者是强大的。但如果守护者本身也可能犯错呢?一个庞大而复杂的守护者更有可能存在无法预见的缺陷。这就引出了计算机科学中一个深刻的设计哲学:最小权限原则。它指出,任何给定的组件应该只拥有完成其工作所必需的最低权限。如果内核是权限最高的组件,我们的目标应该是让它尽可能小而简单,以减少可能被利用的“攻击面”。
思考一下更新显卡固件的过程。这涉及一个复杂的过程:解析新的固件文件,验证其数字签名以确保其真实性,最后,向设备的硬件寄存器写入几个命令来启动刷新。验证代码可能非常庞大,并且通常由第三方提供,这使其成为一个可能存在错误的地方。
这整个五十万行的代码块应该在监督模式下运行吗?绝对不应该!这就像把整个王国的钥匙交给一个临时的、不甚了解的助手。混合方法要优美和安全得多。解析和验证这些复杂、有风险的工作在一个非特权的用户空间进程中完成。如果它崩溃或有错误,它只能伤害自己。一旦镜像被验证,这个用户进程就会发起一个系统调用,调用内核中一段微小、简单且经过良好审计的代码。这个最小化驱动程序的唯一工作就是执行那几个真正需要特权的操作:为设备分配一个安全的内存缓冲区以供读取(由 IOMMU 保护),并向硬件寄存器写入最终的“执行”命令。我们已经对问题进行了划分,将风险限制在尽可能低的权限环境中。
将这个想法推向其逻辑结论,便诞生了微内核(microkernel)架构。在这种设计中,监督模式的内核被无情地精简到其绝对核心。内核必须做什么?它必须管理内存,因为修改页表的指令是特权的。它必须管理调度,因为处理计时器中断和在进程间切换的指令是特权的。几乎所有其他东西——设备驱动程序、文件系统、网络栈——都被推到用户空间,成为相互通信的、独立的、非特权的进程。这是最小化可信核心的终极体现,一个优美但充满挑战的架构范式。
天下没有免费的午餐,特权模式提供的保护是有代价的:性能。每当应用程序需要内核服务时,处理器都必须执行一次系统调用。这不仅仅是一次函数调用;它是一次精心编排的上下文切换。CPU 必须保存用户进程的状态,切换到内核栈,进入监督模式,执行内核代码,然后反转整个过程以返回到用户。
这次“过境”是昂贵的。在现代 CPU 上,一次往返的系统调用可能会花费数千个处理器周期。现在,想象一个高性能网络应用程序,每秒需要发送数百万个小数据包。如果每个数据包都需要一次完整的系统调用,那么跨越用户/监督者边界的开销将占主导地位,处理器将把所有时间都花在切换模式上,而不是做有用的工作。应用程序的性能将急剧下降。
那么,我们如何解决这个问题呢?其见解非常简单:分摊(amortization)。如果过境昂贵,你就不会一次只带一件物品跑一百万趟。你会装满一辆大卡车,只跑一趟。这就是现代 I/O 接口背后的思想。应用程序不是为每个操作进行一次系统调用,而是在共享内存缓冲区中准备一大批请求(例如,“发送这 50 个数据包”),然后进行一次系统调用来“按门铃”。内核被唤醒,一次性处理所有 50 个请求,然后返回。这一次系统调用的固定成本现在被分摊到 50 个操作上,每个操作的平均成本急剧下降。这种在安全边界和性能之间的优雅权衡是系统设计中持续的博弈,推动着操作系统接口的创新。
我们有一个优美的用户和监督者两级系统。如果我们告诉你,还有一个隐藏的层次呢?如果你可以把一个完整的操作系统,连同它自己的用户和监督模式,放在一个盒子里运行,就好像它只是另一个应用程序一样,会怎么样?这就是虚拟化(virtualization)的魔力。
为了实现这一点,硬件设计者引入了一种新的、权限更高的模式,通常称为“Hypervisor 模式”(在 x86 上是“Ring -1”,在 ARM 上是异常级别 2)。一个名为 Hypervisor 或虚拟机监视器(VMM)的特殊程序在这种模式下运行。你在虚拟机(VM)中安装的操作系统——即“客户”操作系统——认为自己正在监督模式下运行。它相信自己拥有完全的控制权。但这是一种幻觉。
硬件参与了这个骗局。每当客户操作系统试图执行一个“敏感”指令——一个会修改真实机器状态的指令,比如改变内存映射或访问设备——硬件并不会执行它。相反,它会触发一个陷阱,但不是通向客户自己的错误处理程序,而是通向 Hypervisor。Hypervisor 检查客户的请求,决定如何处理(例如,假装操作成功,而实际上是在操纵一个虚拟设备),然后恢复客户的运行。客户操作系统对此一无所知。
这个额外的特权层提供了极其强大的隔离。它允许 Hypervisor 安全地托管一个工具,该工具可以检查已崩溃的客户操作系统的全部内存以进行调试,而这项任务在操作系统内部安全地实现要困难和危险得多。这种特权层级——用户、监督者、Hypervisor——是一个“层层递进”的案例,每一层都为上一层提供了信任的基础。
这些基本概念直接解释了现代云计算中最重要的架构争论之一:虚拟机与容器之争。
虚拟机(VM)利用了硬件虚拟化的全部功能。每个 VM 运行一个完整、独立的客户操作系统。Hypervisor 使用其超特权模式和诸如扩展页表(EPT)之类的硬件特性,在 VM 之间构建了一道坚固的、由硬件强制执行的墙。一个 VM 内核中的安全漏洞只能危及该 VM。要逃逸,攻击者必须找到 Hypervisor 本身的漏洞——这是一个小得多且更安全的目标。
另一方面,容器是一种操作系统级虚拟化。一台主机上的所有容器共享同一个宿主机内核。容器内的应用程序在用户模式下运行,它们都向这个共享的监督模式内核发起系统调用。容器之间的隔离是由该内核内的软件特性(如命名空间和 cgroup)提供的。
安全边界的差异是深远的。硬件 MMU 在容器进程之间提供了强大的内存隔离。然而,共享的内核是一个单点故障。共享内核的系统调用处理程序中的一个漏洞可能被一个容器利用来获得监督模式权限,此时它就可以接管整个宿主机及其上所有其他容器。虽然现代内核具有 SMAP 和 SMEP 等强化功能使此类攻击更加困难,但它们并不能改变这个基本的信任模型。容器的隔离边界是用户/监督者的软件接口,而虚拟机的隔离边界是 Hypervisor/客户的硬件接口。这种区别直接根植于特权模式的层级结构,正是它使得虚拟机成为多租户安全的选择,而容器成为轻量级应用打包的选择。
从保护单个系统调用到协调全球数据中心,特权分离这个简单的思想如同一条金线,贯穿于整个计算机科学,证明了逐层构建信任的力量。