CPU的运行级别和保护机制

CPU的运行级别和保护机制

你可能已经知道在Intel x86计算机中,应用程序的能力是有限的,并且只有操作系统代码才能执行某些任务,但是你知道这是如何真正工作的吗?本文将介绍x86特权级别(运行级别),即操作系统和CPU一起合作来限制用户模式程序所能做的事情。有4个特权级别,编号为0(特权最大)到3(特权最小),以及3个受保护的主要资源:内存、I/O端口和执行某些机器指令的能力。在任何给定的时间,x86 CPU都以特定的特权级别运行,这决定了代码可以做什么和不能做什么。这些特权级别通常被描述为保护环,最里面的环对应于最高的特权。大多数现代x86内核只使用两个特权级别,0和3:

x86 Protection Rings

在数十条机器指令中,大约有15条被CPU限制为Ring 0指令。许多其他操作数都有限制。这些指令可能会破坏保护机制,或者在用户模式允许的情况下引发混乱,因此它们保留给内核。如果试图在Ring 0之外运行它们,就会导致通用保护异常,比如程序使用无效的内存地址。同样,对内存和I/O端口的访问也受到权限级别的限制。但是在研究保护机制之前,我们先来看看CPU是如何跟踪当前的特权级别的,这涉及到段选择器,他们是:

Segment Selectors - Data and Code

数据段选择器的全部内容由代码直接加载到各种段寄存器中,如ss(堆栈段寄存器)和ds(数据段寄存器)。这包括所请求的特权级别(RPL)字段的内容,我们稍后将解释它的含义。然而,代码段寄存器(cs)是不可思议的。首先,它的内容不能由诸如mov这样的加载指令直接设置,而只能由改变程序执行流(如call)的指令来设置。其次,对我们来说很重要的一点是,与可以由代码设置的RPL字段不同,cs具有由CPU本身维护的当前特权级别(CPL)字段。代码段寄存器中的这个2位CPL字段始终等于CPU的当前特权级别。英特尔文档在这一点上有些摇摆不定,有时在线文档会混淆这个问题,但这是一条硬性规定。在任何时候,无论CPU中发生了什么,查看cs中的CPL都会告诉你正在运行的特权级别代码。

请记住,CPU特权级别与操作系统用户无关。无论你是Root用户、管理员、来宾还是普通用户,都没有关系。所有用户代码都在环3中运行,所有内核代码都在环0中运行,而不管代码是代表哪个OS用户运行的。有时某些内核任务可以被推到用户模式,例如Windows Vista中的用户模式设备驱动程序,但这些只是为内核执行任务的特殊进程,通常可以在没有重大后果的情况下终止。

由于对内存和I/O端口的访问受到限制,用户模式在不调用内核的情况下几乎不能对外部世界做任何事情。它不能打开文件、发送网络数据包、打印到屏幕或分配内存。用户进程运行在由Ring 0的众神设置的严格限制的沙箱中。这就是为什么从设计上来说,进程不可能泄漏其存在之外的内存或在其退出后保留打开的文件。所有控制这些东西的数据结构——内存、打开的文件等等——都不能被用户代码直接访问;一旦进程完成,沙箱就会被内核销毁。这就是为什么我们的服务器可以有600天的正常运行时间——只要硬件和内核不出问题,就可以永远运行。这也是为什么Windows 95 / 98崩溃得如此之多:不是因为“M$ sucks”,而是因为重要的数据结构因为兼容性的原因被用户模式所访问。尽管代价高昂,但在当时,这可能是一种不错的权衡。

CPU在两个关键时刻保护内存:加载段选择器和使用线性地址访问内存页。因此,在涉及分段和分页的情况下,保护映射内存地址转换。当加载数据段选择器时,进行如下检查:

x86 Segment Protection

由于更高的数字意味着更少的特权,上面的MAX()选择CPL和RPL中特权最少的,并将其与描述符特权级别(DPL)进行比较。如果DPL更高或相等,则允许访问。RPL背后的思想是允许内核代码使用降低的特权加载段。例如,你可以使用RPL(3)来确保给定的操作使用用户模式可访问的段。堆栈段寄存器ss是例外,CPL、RPL和DPL这三个寄存器必须完全匹配。

实际上,段保护并不重要,因为现代内核使用扁平的地址空间,其中用户模式的段可以到达整个线性地址空间。在分页单元中,当线性地址转换为物理地址时,可以进行有用的内存保护。每个内存页是一个字节块,由页表项描述,其中包含两个与保护相关的字段:一个监控器标志和一个读/写标志。supervisor标志是内核使用的主要x86内存保护机制。打开时,无法从环3访问该页。虽然读/写标志对于强制特权来说不那么重要,但它仍然很有用。当加载进程时,存储二进制(代码)的页面被标记为只读,因此,如果程序试图写入这些页面,就会捕获一些指针错误。此标志还用于在Unix中生成子进程时实现写时复制。在Fork时,父级页面被标记为只读,并与Fork后的子级共享。如果任何一个进程试图写入页面,处理器就会触发一个错误,内核就会知道复制此页面,并将其标记为可以读/写。

最后,我们需要一种让CPU在特权级别之间切换的方法。如果环3代码可以将控制转移到内核中的任意位置,那么很容易通过跳到错误的位置来破坏操作系统。有必要进行受控的转移。这是通过门描述符和sysenter指令实现的。门描述符是类型系统的段描述符,有四种子类型:调用门描述符、中断门描述符、陷阱门描述符和任务门描述符。调用门提供了一个内核入口点,可以与普通调用和jmp指令一起使用,但是它们的使用并不多,因此我将忽略它们。任务门也不是那么热门(在Linux中,它们只在由内核或硬件问题引起的双故障中使用)。

这样就剩下两个更有趣的:中断和陷阱门,它们用于处理硬件中断(如键盘、计时器、磁盘)和异常(如页面错误,除0)。我将两者都称为“中断”。这些门描述符存储在中断描述符表(IDT)中。每个中断被分配一个介于0和255之间的数字,称为vector,处理器在确定处理中断时使用哪个门描述符时,将其作为IDT的索引。中断门和陷阱门几乎是相同的。它们的格式如下所示,以及在发生中断时强制执行的特权检查。我为Linux内核填充了一些值,以使事情具体化。

Interrupt Descriptor with Privilege Check

门中的DPL和段选择器都控制访问,而段选择器加上偏移量一起为中断处理程序代码确定了一个入口点。在这些门描述符中,内核通常使用段选择器来选择内核代码段。中断永远不能将控制权从特权更大的环转移到特权更小的环。特权必须保持不变(当内核本身被中断时)或提升(当用户模式代码被中断时)。无论哪种情况,得到的CPL都等于目标代码段的DPL;如果CPL发生变化,还会发生堆栈切换。如果一个中断是由代码通过int n这样的指令触发的,那么还要进行一次检查:gate DPL必须具有与CPL相同或更低的特权。这可以防止用户代码触发随机中断。如果这些检查失败,就会发生一般保护异常。所有Linux中断处理程序最终都在ring 0中运行

在初始化期间,Linux内核首先在setup_idt()中设置一个忽略所有中断的IDT。然后使用include/asm-x86/desc.h中的函数来充实arch/x86/kernel/traps_32.c中常见的IDT条目。在Linux中,名称中带有“system”的gate描述符可以从用户模式访问,其set函数使用的DPL为3。“系统门”是一种可以进入用户模式的英特尔陷阱门。但是,这里没有设置硬件中断门,而是在适当的驱动程序中设置。

用户模式可以访问三个门:向量3和4分别用于调试和检查数值溢出。然后为SYSCALL_VECTOR设置一个系统门,对于x86体系结构是0x80。这是一种机制,用于进程将控制权转移到内核,进行系统调用,在我申请“int 0x80”虚荣车牌的时候:)。从奔腾Pro开始,sysenter指令作为一种更快的系统调用方式被引入。它依赖于专门用于存储内核系统调用处理程序的代码段、入口点和其他的CPU寄存器。当sysenter执行时,CPU不进行特权检查,立即进入CPL 0,并将新值加载到寄存器中,用于代码和堆栈(cs、eip、ss和esp)。只有Ring 0可以加载sysenter设置寄存器,这是在enable_sep_cpu()中完成的。

最后,当返回到环3时,内核发出iret或sysexit指令,分别从中断和系统调用返回,从而离开环0和恢复执行CPL为3的用户代码。我们的x86环和保护之旅到此结束。感谢你的阅读!

--未完待续

由于本人水平有限,翻译必然有很多不妥的地方,欢迎指正。
同时,欢迎关注下方微信公众号,一起交流学习:)

编辑于 2019-02-02

文章被以下专栏收录