重新把内核基础学一遍,方便后续学习的展开。

x86 是一个非常经典的复杂指令集架构(CISC),它的特点是指令不定长,解析指令时会根据头个字节甚至是第二个字节决定指令解析的长度,作为本篇学习的研究例子。

x86 的 CPU 在早期都是以实模式运行的,在 80386 及以后,x86 CPU 新增了分页的虚拟内存机制,同时在 80286 CPU 中就新增了其它运行模式,比如保护模式,本篇将重点学习保护模式

保护模式

CPU分级

只需要知道,数值上越小,权限越大。就像你的Linux root的 uid 就是0。CPU设计的时候是分了四级 ring 环,如图所示:

最外层是 ring3,是我们平时程序运行的等级,只能执行少数的非特权指令。ring0 是操作系统或者是驱动运行的等级,分级的目的就是防止应用程序随意篡改内核数据。内核数据的不正确修改往往会导致操作系统无法继续运行,Linux上会直接 crash 并强制关机,Windows 会蓝屏。

在CPU设计的时候,将 ring2 和 ring1给了驱动程序去运行,然而实际情况是,大部分操作系统没有使用 ring1 和 ring2,只使用了 ring0 和 ring3。所以在编写驱动的时候,驱动程序通常是直接运行在和操作系统同一等级 ring0 的。

段寄存器

要理解保护模式,首先就得介绍一下段寄存器。

段寄存器主要有:CS,DS,SS,ES,FS,GS等等。最开始的实模式中,段寄存器的作用是做一个基址保存,因为当时 8086 的总线宽度为 20 位,寻址能力 1MB(2的20次方),但是寄存器却只有16位。于是 Intel 这么设计:16 位的段寄存器中的值 * 16 再加真实的 16 位寻址地址,得到了访问的真实地址,但是其中会有 12 位是重叠的。

  • 代码段寄存器CS与寄存器IP相配合获得当前线程代码执行到的内存位置;
  • 数据段寄存器DS与各通用寄存器配合访问内存中的数据;
  • 栈段寄存器SS与寄存器(E)SP、(E)BP配合访问线程的调用栈(call stack);
  • 扩展段寄存器ES用于特定字符串指令(如MOVS或CMPS)。

随着技术的发展,段寄存器已经不是最初那个仅用来保存基址的寄存器了,但是它对用户来看,仍然是 16 位的寄存器。它的含义也有了很大的变化,分段,不仅要规定内存起始地址,目标长度,还有对应的操作权限,仅凭16位的段寄存器看起来是远远不够的(由于需要兼容早期版本的机器,段寄存器一直被设计为16位)。于是,属于我们用户层的段寄存器,就仅保存段选择子,其余信息保存在段描述符中。段描述符可以被存储在任何内存的位置,但是通常来说它在内核空间

GDT和LDT表

上面提到了,段描述符是存储在内核空间的,内核空间负责维护了一张表,叫全局描述符表(GDT,Global Descriptor Table)。为了找到这张表,Intel 专门设计了一个寄存器 GDTR(Global Descriptor Table Register)来存储这张表的位置。

使用 LGDT 指令可以对 GDTR 寄存器做修改,当然,这也是特权指令,用户态无法直接调用。

除了 GDT,还允许程序员自行构建局部描述符表(LDT,Local Descriptor Table),它可以为每个进程构建一张段描述符表,另外,每一个LDT自身作为一个段存在,它们的段描述符被放在GDT中。LDT只是一个可选的数据结构,完全可以不用它,完全取决与操作系统的开发者意愿。实际上目前主流的 OS(Windows、Linux)中,很少出现 LDT 的身影,因为它们自身实现了很完整的分页管理机制,LDT 的实现可能的确对于内存管理有所方便,但还是和目前主流的 OS 设计观念不太相符(本人主观猜测)。

段选择子&段描述符

前面提到了,段寄存器对于用户来说仅保存段选择子,通过段选择子所指示的信息可以找到对应的段描述符。先来看看段选择子的一个结构图

最低的两位指示了CPU的请求特权等级(RPL,Request Privilege Level),猜测这里刚好对应 CPU 的四个环。

最低的第三位指示了该段的段描述符是查找 LDT 还是查找 GDT,如果为 0 表示查找 GDT。

其余指示了段描述符在 GDT 或者 LDT 的索引。


段描述符的结构如图所示(以32位为例):

它的成员有很多,一个一个来介绍:

  • P位:段描述符是否有效

  • Base:被分成了三个部分,Base 的低16位被放置在低 4 个字节的前两个字节。高 16 位分别被分到了高四个字节的首尾字节。

  • Limit:段限长,可以发现只有 16+4=20 位,范围在 1B~1MB,但是它还有个 G 位,可以保证32位程序的段限长到 4GB。

  • G位:是否以页为单位。如果 G=1,那么段限长以页(4KB)为单位,否则以字节(B)为单位。这样就保证了,段限长最大可以达到 $2^{20}\times4\text{KB}=4\text{GB}$。

  • S位:描述符是否为代码或数据段描述符。如果 S=1,则为代码或数据段描述符,否则为系统段描述符。

  • TYPE域:有四位的大小,比较复杂,根据 S 位具有不同的含义,在下面展开讲。

S=1 时,为代码或者数据段描述符,具体如下图所示:

最高位显然是区分是否为代码段的,若为 1 则是代码段,其余还有五个位需要介绍。

  1. A:访问位,判断该段是否被访问过
  2. E:向下扩展位,向下扩展表示段基址开始到段限长范围内的内存都可以访问,向上扩展则刚好相反,段基址到段限长之间的内存不能访问,其余可以访问。
  3. R:可读位,表示是否可读。
  4. W:可写位,表示是否可写(默认可读)。
  5. C:一致位,后面将介绍一致位。

如下图展示了向下扩展位和向上扩展位的区别,绿色块表示可访问,红色块表示不可访问。

S=0 时,为系统段描述符,TYPE域的具体如下所示:

下面接着讲段描述符相关的成员。。

  • D/B位:可以简单的理解为是 16 位和 32 位的一个区分。
  • DPL位:规定了访问这个段所需的权限。通常来说,当你的权限 <= 段权限时,访问才是允许的(这里数值越小,权限越高)。
  • AVL位:AVL指示是否可供系统软件使用,由操作系统来使用。

也来看看 D 位的区别:

红色表示向下拓展能寻址的范围。可以看出,如果D = 0,就算原来能寻址4GB,因为DB位的限制导致最大范围是64KB

段权限

其实前面介绍的已经比较完整了,段权限被存储在两个位置,一个是段选择子,一个是段描述符,但是这里要介绍三种权限等级。

  1. 当前特权级(CPL,Current Privilege Level),存储在代码段寄存器(段选择子)的低2位,表示了当前进程的特权等级。
  2. 请求特权级(RPL,Request Privilege Level),存储在其它段寄存器的低2位。表明了访问这个段所使用的权限。
  3. 段描述符特权级(DPL,Descriptor Privilege Level),存储在段描述符中,表明了访问这个段所需的特权等级。

这里可能会对 RPL 和 DPL 有所分不清,想着这俩不都是对同一个段的描述么,为什么还要进行区分。这里需要说明,两个的区别,DPL表示了,你访问这个段最少需要多少的权限。RPL 则指示了,我访问这个段通过什么权限去访问。

比如一个 ring0 的程序,它的特权等级显然是 0,但是它要去访问一个低权限的段可以不用这么高的权限,我可以只使用 ring3 的权限,所以我只需要修改一下我请求的这个段的 RPL 就可以更改我访问的权限而不必修改自身的权限(CPL)。

这里再举一个通俗易懂的例子:

正国级(0),正部级(1),正厅级(2),正处级(3)。它们所能管辖的范围也有所不同,对应的分别是全国(0),省级(1),市级(2),县级(3)。CPL就相当于你个人的职级,行政单位的权限就相当于段特权等级(DPL)。RPL在里面就有点意思了,它不随前两个变化,随你心意。你身为省长,想去一个县里面的单位视察肯定没有问题,但是问题来了,你要以什么身份去视察呢?这个身份,就是你去视察的地方所使用的特权等级(RPL)。显而易见的,你所使用的请求特权等级,必须低于或者等于你自身的身份。因为大官冒充小官,说好听点叫微服私访,而小官冒充大官就涉嫌招摇撞骗了。

同样的,判断你能不能访问一个段,需要做两方面的检查,第一,判断你级别够不够,第二,判断你有没有招摇撞骗。即,在访问一个段时,作两个检查

  1. RPL<=DPL(判断级别够不够)
  2. CPL<=RPL(判断是否招摇撞骗)

如果都通过了,说明你是可以访问这个段的,否则就会引发段错误。

一致性与非一致性

  • 对于一致代码段:也就是共享的段.
    1. 特权级高的程序不允许访问特权级低的数据:核心态不允许调用用户态的数据.
    2. 特权级低的程序可以访问到特权级高的数据.但是特权级不会改变:用户态还是用户态.
  • 对于普通代码段.也就是非一致代码段:
    1. 只允许同级间访问.
    2. 绝对禁止不同级访问:核心态不用用户态.用户态也不使用核心态.
  • 对于数据段来说高特权允许访问低特权的数据而不允许低特权访问高特权的数据。

总结:

非一致代码段只允许同级访问。

对于一致性的段,一般情况下认为内核代码是完善的,不容易出错的,用户层的代码是不完善的,极容易出错的。因此允许用户调用内核提供的代码,而不允许内核调用用户的代码。

对于一致性的数据段,一般情况下认为内核的数据很私密,不能够随便让应用程序读取。而操作系统对用户的数据应当有知情权,不论是为了调试还是管理,都应当有知情权。

可以总结出以下表

向高特权请求 向低特权请求 同级请求 适用性
一致代码段 Y N Y 共享库函数,暴漏的内核接口
非一致代码段 N N Y 避免低特权级的程序执行的代码
数据段 N Y Y *

参考文献

(注:引用不分先后)