CPU的运行环、特权级与保护

原文标题:CPU Rings, Privilege, and Protection

原文地址:http://duartes.org/gustavo/blog/

 

[注:本人水平有限,只好挑一些国外高手的精彩文章翻译一下。一来自己复习,二来与大家分享。]

 

可能你凭借直觉就知道应用程序的功能受到了Intel x86计算机的某种限制,有些特定的任务只有操作系统的代码才可以完成,但是你知道这到底是怎么一回事吗?在这篇文章里,我们会接触到x86特权级privilege level),看看操作系统和CPU是怎么一起合谋来限制用户模式的应用程序的。特权级总共有4个,编号从0(最高特权)到3(最低特权)。有3种主要的资源受到保护:内存,I/O端口以及执行特殊机器指令的能力。在任一时刻,x86 CPU都是在一个特定的特权级下运行的,从而决定了代码可以做什么,不可以做什么。这些特权级经常被描述为保护环(protection ring),最内的环对应于最高特权。即使是最新的x86内核也只用到其中的2个特权级:03

 x86的保护环

x86的保护环

 

在诸多机器指令中,只有大约15条指令被CPU限制只能在ring 0执行(其余那么多指令的操作数都受到一定的限制)。这些指令如果被用户模式的程序所使用,就会颠覆保护机制或引起混乱,所以它们被保留给内核使用。如果企图在ring 0以外运行这些指令,就会导致一个一般保护错(general-protection exception),就像一个程序使用了非法的内存地址一样。类似的,对内存和I/O端口的访问也受特权级的限制。但是,在我们分析保护机制之前,先让我们看看CPU是怎么记录当前特权级的吧,这与前篇文章中提到的段选择符segment selector)有关。如下所示:

 数据段和代码段的段选择符

数据段和代码段的段选择符

 

数据段选择符的整个内容可由程序直接加载到各个段寄存器当中,比如ss(堆栈段寄存器)和ds(数据段寄存器)。这些内容里包含了请求特权级(Requested Privilege Level,简称RPL)字段,其含义过会儿再说。然而,代码段寄存器(cs)就比较特别了。首先,它的内容不能由装载指令(如MOV)直接设置,而只能被那些会改变程序执行顺序的指令(如CALL)间接的设置。而且,不像那个可以被代码设置的RPL字段,cs拥有一个由CPU自己维护的当前特权级字段(Current Privilege Level,简称CPL),这点对我们来说非常重要。这个代码段寄存器中的2位宽的CPL字段的值总是等于CPU的当前特权级。Intel的文档并未明确指出此事实,而且有时在线文档也对此含糊其辞,但这的确是个硬性规定。在任何时候,不管CPU内部正在发生什么,只要看一眼cs中的CPL,你就可以知道此刻的特权级了。

 

记住,CPU特权级并不会对操作系统的用户造成什么影响,不管你是根用户,管理员,访客还是一般用户。所有的用户代码都在ring 3上执行,所有的内核代码都在ring 0上执行,跟是以哪个OS用户的身份执行无关。有时一些内核任务可以被放到用户模式中执行,比如Windows Vista上的用户模式驱动程序,但是它们只是替内核执行任务的特殊进程而已,而且往往可以被直接删除而不会引起严重后果。

 

由于限制了对内存和I/O端口的访问,用户模式代码在不调用系统内核的情况下,几乎不能与外部世界交互。它不能打开文件,发送网络数据包,向屏幕打印信息或分配内存。用户模式进程的执行被严格限制在一个由ring 0之神所设定的沙盘之中。这就是为什么从设计上就决定了:一个进程所泄漏的内存会在进程结束后被统统回收,之前打开的文件也会被自动关闭。所有的控制着内存或打开的文件等的数据结构全都不能被用户代码直接使用;一旦进程结束了,这个沙盘就会被内核拆毁。这就是为什么我们的服务器只要硬件和内核不出毛病,就可以连续正常运行600天,甚至一直运行下去。这也解释了为什么Windows 95/98那么容易死机:这并非因为微软差劲,而是因为系统中的一些重要数据结构,出于兼容的目的被设计成可以由用户直接访问了。这在当时可能是一个很好的折中,当然代价也很大。

 

CPU会在两个关键点上保护内存:当一个段选择符被加载时,以及,当通过线形地址访问一个内存页时。因此,保护也反映在内存地址转换的过程之中,既包括分段又包括分页。当一个数据段选择符被加载时,就会发生下述的检测过程:

 x86的分段保护

x86的分段保护

 

因为越高的数值代表越低的特权,上图中的MAX()用于挑出CPLRPL中特权最低的一个,并与描述符特权级(descriptor privilege level,简称DPL)比较。如果DPL的值大于等于它,那么这个访问就获得许可了。RPL背后的设计思想是:允许内核代码加载特权较低的段。比如,你可以使用RPL=3的段描述符来确保给定的操作所使用的段可以在用户模式中访问。但堆栈段寄存器是个例外,它要求CPLRPLDPL3个值必须完全一致,才可以被加载。

 

事实上,段保护功能几乎没什么用,因为现代的内核使用扁平的地址空间。在那里,用户模式的段可以访问整个线形地址空间。真正有用的内存保护发生在分页单元中,即从线形地址转化为物理地址的时候。一个内存页就是由一个页表项(page table entry)所描述的字节块。页表项包含两个与保护有关的字段:一个超级用户标志(supervisor flag),一个读写标志(read/write flag)。超级用户标志是内核所使用的重要的x86内存保护机制。当它开启时,内存页就不能被ring 3访问了。尽管读写标志对于实施特权控制并不像前者那么重要,但它依然十分有用。当一个进程被加载后,那些存储了二进制镜像(即代码)的内存页就被标记为只读了,从而可以捕获一些指针错误,比如程序企图通过此指针来写这些内存页。这个标志还被用于在调用fork创建Unix子进程时,实现写时拷贝功能(copy on write)。

 

最后,我们需要一种方式来让CPU切换它的特权级。如果ring 3的程序可以随意的将控制转移到(即跳转到)内核的任意位置,那么一个错误的跳转就会轻易的把操作系统毁掉了。但控制的转移是必须的。这项工作是通过门描述符gate descriptor)和sysenter指令来完成的。一个门描述符就是一个系统类型的段描述符,分为了4个子类型:调用门描述符(call-gate descriptor),中断门描述符(interrupt-gate descriptor),陷阱门描述符(trap-gate descriptor)和任务门描述符(task-gate descriptor)。调用门提供了一个可以用于通常的CALLJMP指令的内核入口点,但是由于调用门用得不多,我就忽略不提了。任务门也不怎么热门(在Linux上,它们只在处理内核或硬件问题引起的双重故障时才被用到)。

 

剩下两个有趣的:中断门和陷阱门,它们用来处理硬件中断(如键盘,计时器,磁盘)和异常(如缺页异常,0除数异常)。我将不再区分中断和异常,在文中统一用“中断”一词表示。这些门描述符被存储在中断描述符表(Interrupt Descriptor Table,简称IDT)当中。每一个中断都被赋予一个从0255的编号,叫做中断向量。处理器把中断向量作为IDT表项的索引,用来指出当中断发生时使用哪一个门描述符来处理中断。中断门和陷阱门几乎是一样的。下图给出了它们的格式。以及当中断发生时实施特权检查的过程。我在其中填入了一些Linux内核的典型数值,以便让事情更加清晰具体。

 伴随特权检查的中断描述符

伴随特权检查的中断描述符

 

门中的DPL和段选择符一起控制着访问,同时,段选择符结合偏移量(Offset)指出了中断处理代码的入口点。内核一般在门描述符中填入内核代码段的段选择符。一个中断永远不会将控制从高特权环转向低特权环。特权级必须要么保持不变(当内核自己被中断的时候),或被提升(当用户模式的代码被中断的时候)。无论哪一种情况,作为结果的CPL必须等于目的代码段的DPL。如果CPL发生了改变,一个堆栈切换操作就会发生。如果中断是被程序中的指令所触发的(比如INT n),还会增加一个额外的检查:门的DPL必须具有与CPL相同或更低的特权。这就防止了用户代码随意触发中断。如果这些检查失败,正如你所猜测的,会产生一个一般保护错(general-protection exception)。所有的Linux中断处理器都以ring 0特权退出。

 

在初始化阶段,Linux内核首先在setup_idt()中建立IDT,并忽略全部中断。之后它使用include/asm-x86/desc.h的函数来填充普通的IDT表项(参见arch/x86/kernel/traps_32.c)。在Linux代码中,名字中包含“system”字样的门描述符是可以从用户模式中访问的,而且其设置函数使用DPL 3。“system gate”是Intel的陷阱门,也可以从用户模式访问。除此之外,术语名词都与本文对得上号。然而,硬件中断门并不是在这里设置的,而是由适当的驱动程序来完成。

 

有三个门可以被用户模式访问:中断向量34分别用于调试和检查数值运算溢出。剩下的是一个系统门,被设置为SYSCALL_VECTOR。对于x86体系结构,它等于0x80。它曾被作为一种机制,用于将进程的控制转移到内核,进行一个系统调用system call),然后再跳转回来。在那个时代,我需要去申请“INT 0x80这个没用的牌照 J。从奔腾Pro开始,引入了sysenter指令,从此可以用这种更快捷的方式来启动系统调用了。它依赖于CPU上的特殊目的寄存器,这些寄存器存储着代码段、入口点及内核系统调用处理器所需的其他零散信息。在sysenter执行后,CPU不再进行特权检查,而是直接进入CPL 0,并将新值加载到与代码和堆栈有关的寄存器当中(cseipssesp)。只有ring 0的代码enable_sep_cpu()可以加载sysenter 设置寄存器。

 

最后,当需要跳转回ring 3时,内核发出一个iretsysexit指令,分别用于从中断和系统调用中返回,从而离开ring 0并恢复CPL=3的用户代码的执行。噢!Vim提示我已经接近1,900字了,所以I/O端口的保护只能下次再谈了。这样我们就结束了x86的运行环与保护之旅。感谢您的耐心阅读。

阅读更多
个人分类: Internals
上一篇内存地址转换与分段
下一篇信号系统与卷积
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭