本文参考:《x86汇编:从实模式到保护模式》《操作系统:真相还原》,SegmentationAndPrivilege80386 Programmer’s Reference Manual

特权级概述

我们知道,实模式是单任务系统,对于各个段的隔离,仅依靠分段机制来维护,可以说毫无安全可言。而在保护模式下,通过将内存分为大小不一的段,并用描述符指定各个段的类型与权限,就可以在程序运行时由处理器硬件实施访问保护。但这仍然无法有效保护操作系统,比如,如果恶意程序通过某种方式知道了 GDT 的位置,它就能向段寄存器加载操作系统的数据段描述符,或者在 GDT 中增加一个指向操作系统数据区的描述符,以此来修改操作系统的私有数据。再者,多任务系统对任务之间的隔离与保护,以及任务与操作系统之间的隔离与保护都提出了复杂的要求,基本的段保护机制已经无法胜任。因此,操作系统引入了特权级的概念。

特权级分为 0、1、2、3 四级,数字越小,权力越大 。0 级特权级是操作系统拥有的权力;系统程序(如虚拟机, 驱动程序)分别位于 1、2 特权级;用户程序则位于第 3 特权级。
特权级环(ring)

需要注意的是,我们将数值小的级称为高特权级,数值大的称为低特权级,别搞混啦!

LDT

LDT,即局部描述符表,大家应该并不陌生了,前文经常提起它的大名。在之前的代码中,我们一直将所有的段描述符放在 GDT 中,而不管它是属于内核还是用户程序(当然,因为我们还没有用户进程,敬请期待)。为了有效地实现任务之间的隔离,处理器建议每个任务都应该有自己专属的描述符表,即 LDT,并且把专属于自己的段放在 LDT 中。

想想看,这是不是很好地呼应了开启分页中的内容:一个完整的程序分为用户代码(私有部分)和操作系统代码(全局部分)

LDT 与 GDT 不同的地方有(笔者目前所知道的):

  1. 每个任务都有自己的 LDT,而一个 CPU 只有一张 GDT ,由所有任务共享。

  2. GDT 的第 0 号描述符不可用,LDT 的第 0 号描述符则是可用的。

    为什么有这个区别呢?因为在某些情况下(后文会提),CPU 为了控制权限会将选择子初始化为 0,如果后续用户忘记给选择子重新初始化,就会引发异常,这是一种积极的保护措施。而如果指定为 LDT,则选择子的 TI 位为 1,这必然是经过用户显式初始化的结果,完全排除了忘记初始化的可能,因此 LDT 的第 0 个描述符可用。

  3. GDT 由 lgdt 指令加载进 GDTR 寄存器;LDT 由 lldt 指令加载到 LDTR 寄存器。需要注意的是,与 GDTR 不同,LDTR 是一个 16 位的寄存器,且其中装载的是指向 GDT 的索引,是不是蒙圈啦?别急,接下来我们细说。

LDTR 中本质上装载的是选择子,这个选择子将其引导到 GDT 中的某一个描述符(LDT描述符),而这个描述符中装载着 LDT 的信息。换句话说,LDT 的基址和界限等信息都存在 GDT 的描述符当中。让我们看看 LDTR 的结构:

显然,TI 为必须为 0,即指向 GDT,不然就会指向 LDT 本身。
至于为什么不直接将 LDTR 设计为 48 位,然后直接从其中获取 LDT 的基址和界限,而采用这种迂回的方式?这是考虑到特权级检查的问题:用选择子在 GDT 中索引 LDT 描述符,这样就可以套用引用段描述符时的特权级检查(后面我们将会介绍)。

可见,LDTR 和段寄存器有着神之相似。我们在全局描述符表中提到过,32 位 CPU 的段寄存器分为 16 位可见部分(选择子)和不可见部分(高速缓存器,位数根据CPU型号而变),LDTR 也是如此。通过索引在 GDT 中定位到 LDT 描述符所在位置,然后将描述符中的信息加载进不可见部分的高速缓存中 。读者可能忘了,GDT 中可不仅有数据段/代码段,还有系统段,如下:
第三项便是 LDT

注意,GDT 中不包含中断门和陷阱门。

在多任务系统中,正在执行的任务被称为“当前任务(Current Task)”。由于 LDTR 只有一个,所以它仅指向当前任务 ,每当发生任务切换,LDTR 中的内容被更新,以指向新的 LDT 。那么问题来了,切换任务时,LDTR 是如何被更新的呢?也就是说 LDTR 中的内容从哪里获取?而且,任务切换时,必须保护旧任务的寄存器现场,这些又保存到哪里呢?这就不得不提到 TSS 了。

TSS

为了保存任务的状态以便在下次切换时恢复,每个任务都需要使用一个额外的区域来保存相关信息,这个区域叫做任务状态段 (Task State Segment, TSS ) 。TTS 具有固定格式,且最小尺寸为 104 字节,根据需要还可以接上 I/O 位图。TSS 结构如下:

看见 96 字节处的 LDT 段选择子了吗?LDTR 就是从这里获取内容并加载的

TSS 是 32 位处理器在硬件上原生支持多任务的一种实现方式,处理器固件能够识别 TSS 中的每个元素,并在任务切换时自动读取其中的信息。既然 TSS 也是内存中的一块区域,且每个任务都有一个 TSS,那 CPU 又如何获取这些 TSS 的位置呢?和 LDT 类似,处理器使用 TR(Task Register) 寄存器来指向当前任务的 TSS ,既然是指向当前任务的 TSS,所以 TR 寄存器也只有一个。当发生任务切换时,处理器将当前任务的寄存器现场保存到 TR 指向的 TSS 中;然后再使 TR 指向新任务的 TSS,并根据 TSS 恢复现场。TR 结构如下:

可见,TR 寄存器也同段寄存器类似,分为可见的 16 位段选择子与不可见的高速缓冲器。其中 BASE 指向 TSS 所在的起始地址,LIMIT 则为其界限。为什么还有界限呢?因为 TSS 长度是不固定的,会根据 I/O 位图而变化。既然 TR 为选择子,那就肯定有 TSS 描述符,如下:

  • TSS 描述符可能只驻留在 GDT 中 ,所以 TR 选择子的 TI 位只能为 1,否则会导致异常。
  • LIMIT 字段的值必须等于或大于 103,尝试切换到界限小于 103 的任务会导致异常
  • YTPE 字段中的 B 位主要用来判断任务是否重入,即是否为自己调用自己。如果被调用的任务的 B 位为 1,则表明当前任务是在调用自己,这将破坏任务调用链,继而引发严重错误。另外,也并不是只有当前任务的 B 位才为 1,当使用 call 指令进入新任务(成为当前任务)时,不仅新任务的 B 位被置 1,旧任务的 B 位仍保持为 1,这是因为 call 指令是“有去有回”的指令,这说明新任务只是旧任务的分支,待新任务完成后还会回到旧任务,所以本质上它们属于同一个任务。同时,任务的嵌套调用还会影响 eflags 的 NT 位,详见 中断超详解 文末。

另外,注意到一个细节没?TSS 中起始位置存放着前一个任务的指针,而这个指针只有 16 字节!奇了怪了,16 位地址怎么定位?细心的读者可能已经观察到了,上一张系统段类型图中,还包含了 TTS 类型,这说明什么?这说明此处 16 位的 TTS 指针也是一个选择子可见,不仅是 LDT,连 TSS 的信息也是作为段描述符存放在 GDT 当中的(即 TSS 也需要在 GDT 中注册)

插一句,由于效率问题,Linux 并没有为每个任务都创建一个 TSS,而是所有任务共享一个 TSS,我们的 OS 也会模仿 Linux 的做法, 详见实现用户进程

加载初始任务时,使用 ltr 指令将 TSS 选择子加载进 TR 寄存器:

1
ltr 16位寄存器/16位内存单元

后续任务切换时,由 CPU 自动加载 TR 寄存器。

关于最上方的 I/O 映射基地址,其内容较多,我们将在另一篇文章中详细阐述。另外一个疑惑是,为什么一个 TSS 中有三个栈呢?这涉及到特权级转移的相关内容,请见下文。

特权级转移

代码段发生段间跳转时,其特权级检查是很严格的。一般而言,跳转只允许发生在两个同级的代码段之间。显然,这不能满足我们对操作系统的要求(这意味着不能进行系统调用,用户将什么也做不了)。因此,为了让低特权级能够调用高特权级的例程,处理器提供了两个办法

  1. 将高特权级的代码段设为依从
  2. 使用门( GATE )

下面我们来详细说明这两点。

依从代码段
还记得段描述符中的 type 字段吗?不用说,肯定忘了,再把图搬过来:

其中,代码段的 C 位表示代码段的依从属性。依从表示可以从特权级比它低的代码段中进入该段 。注意,将控制转移到依从的代码段时,要求当前特权级(CPL)必须低于或等于目标(依从)代码段的DPL ,即:

1
CPL≥目标代码段的DPL

除了返回指令(retf, iret/iretd),任何时候都不允许将控制从高特权级转移到低特权级上,因为操作系统无法相信用户程序的可靠性

同时,转移到目标(依从)代码段后,也并不是以它自己的 DPL 运行,而是在调用程序的特权级上运行。换句话说,当控制转移到依从的代码段上执行时,不会改变当前特权级(CPL)! 举个例子,从特权级为 3 的用户程序切换到特权级为 0 的依从代码段时,当前特权级(CPL)依然是 3,而非 0 。

注意,仅代码段有依从属性,数据段只允许比自己更高或同级的代码段访问!

门(GATE)
另一种从低特权级转移到高特权级的方式就是通过门调用操作系统有调用门、中断门、陷阱门、任务门四种门,它们各有自己的应用环境,但相同点是它们都用来从低特权级的代码段转移到高特权级的代码段 。下面简单说一下各个门的用途与特权级:
调用门 通过 calljmp 指令进入调用门,操作数为门选择子。call 以调用函数的方式向高特权级代码转移;jmp 可以转移到高特权级的代码段,但不改变 CPL 。返回时,使用 retf 返回;调用门可用来实现系统调用;位于 GDT/LDT 中
中断门int 指令主动发起中断的形式向高特权级代码段转移。Linux 采用中断门实现系统调用。中断将在IDT与中断中详细展开。另外,进入中断后,eflags 中的 IF 位自动置零(cli),关闭可屏蔽中断以避免中断嵌套 。使用 iret/iretd 返回;仅位于 IDT

陷阱门int3 指令主动发起中断的形式向高特权级代码段转移,这一般是编译器在调试时使用。陷阱门和中断门很类似,唯一区别是进入中断后,eflags 中的 IF 位不会自动置零仅位于 IDT
任务门 可以借助中断发起,如果对应的中断向量号是任务门,则发起任务切换;也可以用 call 或 jmp 指令,后接任务门的选择子或 TSS 的选择子。位于 GDT/LDT/IDT 中

门(GATE)

上文我们粗略了解了门结构,下面我们继续深入剖析。门结构存在目的就是为了让 CPU 提升特权级,以便完成低特权级下不能完成的工作 。四种门结构的图示如下:
调用门描述符
陷阱门描述符
中断门描述符
任务门描述符
除了任务门以外,其他三种门都是直接指向一段例程(函数)。和普通段描述符的区别在于,普通段描述符中包含的是段基址和段界限,是在界定内存区域;而这三种描述符中包含的是段选择子和段内偏移地址,它们直接指向内存中的一段程序 。因此,在调用任务门和调用门时,CPU 会忽略调用指令的偏移量 !比如,call 0x9:0x1000 ,如果第 9 号段描述符是这两种门描述符,则 CPU 会忽略偏移量 0x1000

大家一定疑惑为什么门描述符中还要放目标代码段的选择子,而不直接存放基址?这样迂回好麻烦啊。这一点笔者也不太清楚,但有理由推测,这应该与 LDTR 相同,都是为了引用段描述符的特权级保护(后文将提到)。

任务门描述符可以放在 GDT、LDT、IDT 中,调用门可以位于 GDT、LDT 中,中断门和陷阱门则只能位于 IDT 中 。正因为调用门和任务门描述符位于 GDT、LDT 中,所以可以通过 calljmp 指令调用,原因是它们和普通段描述符类似,都需要通过选择子。而陷阱门和中断门位于 IDT 中,则只能通过中断信号或 int 指令来触发调用。

门(GATE)这个词很形象,因为“门槛”是调用者特权级的下限,也就是说 调用者的特权级(CPL)必须高于门描述符的 DPL(门槛) ,即:

1
CPL≤门的DPL

“门顶”则是调用者特权级的上限,调用者特权级不能高于门描述符中目标程序所在的代码段的 DPL ,即:

1
CPL≥目标代码段的DPL

门的作用相当于蹦床,只起引导作用

有几点需要说明:

  • 以上规则适用于调用门,中断门有所不同。
  • 别忘了,门描述符中装载的是选择子而非段基址,选择子还要去索引目标段描述符。
  • 为什么要求当前特权级必须高于目标代码段特权级?这点我们在前面已经强调过,这是因为,除了返回指令(iret),任何时候都不允许将控制从高特权级转移到低特权级上,因为操作系统无法相信用户程序的可靠性。

为什么要通过门这种结构来提升特权级?

因为门不仅有“门顶”,还有“门槛”,门顶规定只能主动从低特权级转移到高特权级,而门槛规定了能向高特权级进行转移的最低特权级。前者在上面第三点解释了,那后者的原因是什么呢?这是为了防止某些低特权级软件通过门访问一些只为内核服务的程序,比如页故障处理。这就是门的本质

前面这两个检查,相信大家完全能够理解。然而,实际的特权级检查并非如此简单,这还会牵扯到 RPL,后文将详细剖析。先让我们先看看实际的特权级检查规则全览

特权级保护规则

特权级检查只会发生在往段寄存器赋值的一瞬间 ,规则如下:
1)将控制直接转移到非依从的代码段:

1
2
CPL=目标代码段的DPL 
RPL=目标代码段的DPL

典型例子为 jmp 0x0012:0x2000 ,当两个代码段的特权级相同,则检查通过,顺利转移。

2)将控制转移转移到依从代码段:

1
2
CPL≥目标代码段的DPL  //注意,数值上,CPL大于目标段DPL;实际上,当前特权级低于目标段DPL
RPL≥目标代码段的DPL

控制转移后,当前特权级不变。

3)通过门转移控制权:

1
2
目标代码段的DPL≤CPL≤门描述符的DPL
RPL≤门描述符的DPL

注意,调用门和中断门的特权级检查不同,调用门通过 call 和 jmp 进入,选择子中有 RPL 字段,因此还需要检测 RPL ;而中断门通过中断号调用,所以无法检测 RPL 中断门特权级检查还有几点不同,将在IDT与中断详述。

4)访问数据段时:

1
2
CPL≤目标数据段的DPL
RPL≤目标数据段的DPL

与代码段转移不同,CPU 只允许高特权级代码段访问低特权级数据段。

5)任何时候,栈段的特权级必须和当前特权级相同:

1
2
CPL=目标栈段描述符的DPL
RPL=目标栈段描述符的DPL

注意,代码段发生特权级转移时,会自动根据 TSS 将栈更换为同特权级的栈(后文会详解),以上检查只发生在主动给 ss 赋值的时候。

大家肯定对 RPL 还不太明白,下面我们来理清 RPL,CPL,DPL 的关系。

剖析 RPL, CPL ,DPL

DPL
DPL(Descriptor Privilege Level,描述符特权级),位于 GDT/LDT/IDT 的描述符中。对于门描述符,DPL 意味着访问该门的最低特权级;对于段描述符,DPL 意味着访问该段的最高特权级(参考前面的蹦床示意图)

CPL
CPL(Current Privilege Level,当前特权级),指正在运行的代码所对应的段描述符中的 DPL 。也就是说,当前运行的代码的特权级就是 CPL 。注意,不要将 CPL 定义为 CS 段寄存器中的 RPL 位(CS.RPL),尽管绝大多数时候 CPL=CS.RPL ,这对后面理解非常重要。

RPL
RPL(Request Privilege Leve,请求特权级),指段寄存器中选择子的低 2 位。注意,什么能发出“请求”?显然,只有具备能动性的代码段(不一定是当前代码段)才能发出请求,所以 RPL 是指发出访问请求的代码段的特权级 ,这对理解也很重要。

不管是实施控制转移,还是访问数据段,这都能看作是一个请求,请求者请求访问指定的段。因此,RPL 就是指请求者的特权级请求者往往是当前代码段自己 ,即 CPL=RPL,如下:

1
2
3
;假设当前代码段的特权级为0
mov eax,0x0009
mov ds,eax

由于 CPL 为 0,所以 ds 选择子中的 RPL 也被设置为 0,代表请求者的特权级为 0 。可是,这个选择子是我们自己设置的呀,我难道不能自己修改 RPL 来伪造请求者的身份吗?想到这点很不错!需要说明的是,上面只是演示,实际上选择子是由操作系统提供的(GDT/LDT/IDT都是由操作系统构造,故选择子理所应当也由操作系统提供),操作系统会保障 RPL 的真实身份 。也就是说,RPL 一定是为该段寄存器赋值时的代码的特权级 ,这话有点绕,还是以上的例子:为 ds 赋值时,该代码段的特权级为 0,因此操作系统会保障 ds 中的 RPL 一定为 0 。怎么保障呢?使用 arpl 指令,其格式为

1
2
3
arpl 通用寄存器/16位内存, 16位通用寄存器
;即
arpl 用户提交的段选择子,用户代码段CS的值

具体过程还要涉及栈切换,加上我们的操作系统不会用到该指令,所以此处不做过多讨论,详细请参考《操作系统真相还原》pdf 版第 245 页。有了该指令,即使用户伪造 RPL 也无济于事。

说了半天,我们只了解了 RPL 的内涵,还不知道 RPL 的存在到底有什么必要性。下面对 RPL 的必要性进行阐述。

RPL的必要性
假设段选择子不存在 RPL 字段,发生如下场景:某个恶意程序通过一些奇淫巧计获取了内核的数据段选择子,它计划从硬盘读取一个扇区,并将所读数据写入内核的数据段。显然,它自己位于 3 特权级(ring 3),不能直接访问内核数据段。由于涉及硬盘读取,必须通过系统调用访问硬盘,此处使用调用门来进行系统调用。控制转移到调用门后,CPL 从 3 变为 0,此时将内核段选择子传递给内核例程,例程将选择子赋值进 ds,发生特权级检查:CPL=内核数据段描述符的DPL,检测通过。于是,恶意程序成功向内核数据段写入内容!

就这样,恶意程序就这样成功利用调用门的掩护完成了对内核数据段的修改。以上方式出现问题的原因很容易得出:受访者不知道访问者的真实身份 。在上例中,访问者的真实身份是恶意程序(ring 3),然而恶意程序通过调用门这个代理(ring 0)去访问内核数据,内核就以为调用门是真正的请求者(毕竟是调用门直接接触内核的),于是通过了特权级检查。因此,要破解这个问题,我们就必须让内核知道某个请求后有没有背后的请求者,背后真正请求者的特权级是多少 。而 RPL 就是为了解决此问题而生,RPL 代表着 真正 的请求者的特权级。让我们看看当加入了 RPL 后,重复以上情形将发生什么:再一次,恶意程序通过某些方式获取了内核数据段选择子(其中 RPL=0),当控制权转入调用门时,由于是远转移,处理器会将 cs, eip 等寄存器压栈以保护现场;因此调用门能够从栈中获取恶意程序 cs 中选择子的 RPL(也就是转移前的 CPL),进而使用 arpl 指令将传入的内核数据段选择子的 RPL 更正为恶意程序自身的 CPL,以保证 RPL 的真实身份 ,此时内核数据段选择子的 RPL 变成了 3 ;而后内核例程向 ds 中赋值,发生特权级检查,发现 CPL<=目标数据段的DPL 成立,但 RPL<=目标数据段的DPL 不成立(数据段选择子的RPL=3,而对应描述符的DPL=0),因此拒绝访问,保卫成功!

从上面能够看出,光有 RPL 还不够,必须还要有 arpl 指令保证 RPL 的真实性 。同时也能发现,CPL 并不总是等于 RPL,这需要看当前运行的程序在访问数据段或代码段时用的是谁提供的选择子

值得一提的是,由于效率原因,现代操作系统基本不使用调用门和任务门 ,陷阱门也只在调试时使用。我们的 OS 只用到了中断门。因此,对调用门,任务门,陷阱门不做过多讨论,中断门将在IDT与中断详细阐述。


特权级下的栈保护

之前我们说过,一个任务被分为用户(私有)和内核(全局)两个部分 ,内核位于第 0 特权级,用户位于第 3 特权级。因此,当特权级发生改变时(系统调用时,从用户态陷入内核态),栈也要发生改变,换句话说,不同的特权级应该使用与其同等级的栈

不同的特权级应该使用不同的栈,理由如下:

  1. 恶意的低特权级程序可以通过栈获取高特权级的信息,这非常危险。
  2. 如果所有特权级都使用一个栈,那么这种交叉引用将会变得非常混乱。
  3. 用一个栈容纳所有特权级下的数据,栈很可能溢出。

处理器位于 0 特权级时要用 0 特权级的栈,位于 3 特权级时要使用 3 特权级的栈。问题是,一共有 4 个特权级,那么每个任务都应该有 4 个栈,为什么 TSS 中只有 3 个栈呢?这是由于 TSS 中记录的是转移后的高特权级对应的目标栈,因为 ring3 是最低级的,没有更低的特权级会向它转移,所以 TSS 不需要记录 ring3 的栈 。另外,也不是每一个任务都有 4 个栈,这取决于它最低的特权级别。比如 ring3 程序,还能提升 3 级,于是额外拥有 0,1,2 三个特权级的栈;而 ring0 程序则没有额外的栈。下面我们以调用门为例来看看发生特权级转移时,栈是如何变化的。

1) 假设当前位于 ring3,有两个参数,欲通过调用门执行 ring0 的内核例程。call 调用门前,先将两个参数压栈,此时的栈理所应当是 ring3 的栈。

2) call 调用门,进行特权级检查,若检查通过,则顺利跳到目标代码段。
3) 由于此时 CPL=0,所以处理器自动在 TSS 中找到合适的栈段选择子 SS0 和 SP0,将其作为新栈。为了在返回时切换回旧栈,需要在新栈中保存旧栈的栈段选择子 SS_old 和栈指针 SP_old:

4) 由于之前压入参数是在旧栈进行的,所以现在需要把参数转移到新栈,处理器怎么知道转移多少个字节呢?这由调用门描述符中的参数个数位给出。通过调用门描述符可知,需要转移两个参数,即 8 字节:

注意,参数复制工作是由 CPU 自动完成,栈切换和参数复制对程序员来说是完全透明的。

5) 为了将来恢复到用户进程,还需要压入转移前的段选择子 CS 和 EIP:

注意,不论转移前后 CS 中是否是同一个段选择子,CS 都会被重新加载,因此都必须记录 CS 。

转移完成。另外需要注意,如果为平级转移,比如内核程序调用“调用门”,即从 ring0 到 ring0 ,则不会更新当前栈,直接跨过第 3,4 步,来到第 5 步压入 CS 和 EIP 。下面我们再来看看 retf 从调用门返回的过程:
1) 将 EIP_old 和 CS_old 分别弹出到 eip 和 cs 寄存器中,这个过程仍要进行特权级检查! 若通过检查才能顺利赋值。

你一定觉得返回时的特权级检查很鸡肋,明明我是通过调用门来到高特权级的,怎么返回时还要检查?原因为如下三点:

  1. retf 也涉及到给 CS 赋值,所以也会有特权级检查。
  2. retf(从调用门返回)、iret/iretd(从中断门返回) 这两类指令是从高特权级到低特权级的唯一办法 ,而你完全可以使用 retf 来达到远转移的目的。谁说 retf 只能用来返回?给我目标段选择子和偏移量,将其存入栈中,再 retf ,完全可以达到 call 指令的效果。因此,为了防止使用 retf 来进行远转移(而非返回),必须再进行一次特权级检查。
  3. 再者,也可以通过修改栈内的 CS,EIP 来达到返回时进入其他段的目的。其实第 2,3 点也不算正经理由,因为系统调用是系统开发者编写的,开发者总不会这么来玩吧。

2) 如果有参数,则 ESP_new 跳过参数,指向 ESP_old 。
3) 如果在第 1 步检查中发现特权级发生了改变,则说明切换了新栈,所以从栈中分别弹出 ESP_old 和 SS_old 到 esp 和 ss 中。
4) 如果涉及到特权级改变,则还会检查 DS,ES,FS 和 GS 的内容,如果其中某个寄存器里的选择子所指向的数据段描述符的 DPL 比返回后的 CPL 高(数值上小于),则会将该寄存器中填充 0 。

关于第 4 点,做简单解释:当控制转移到内核(ring 0)后,内核程序必然会使用自己的数据段,所以会向 DS/ES/FS/GS 赋予自己的数据段选择子,赋值时发生特权级检查,检查通过则赋值成功,此后对该数据段进行读写时将不再检查。问题就在于此,当从内核返回到用户程序后,DS/ES/FS/GS 很可能还指向内核的数据段,如果此时用户程序对其进行读写,将没有任何限制!因此,返回后 DS/ES/FS/GS 则可能被自动初始化为 0,如果用户程序直接使用值为 0 的段选择子,则会被索引到 GDT 的第 0 个描述符,而第 0 个描述符是不可用的从而引发 CPU 第 0x0d 号异常(#GP General Protection) 。这就是前文提到的为什么 GDT 第 0 号描述符不可用,而 LDT 第 0 号描述符可用的原因。

前文强调过,特权级检查只发生在向段寄存器赋值的一瞬间,此后任何操作不再受限

另外需要注意,发生特权级转移时,调用门和中断门的栈处理是不同的,大概有以下两点:

  1. 中断门还会压入 EFLAGS 寄存器,而调用门不会。
  2. 中断门不能通过栈压入参数,而调用门可以(因为调用门描述符中有4位用来记录参数个数)。

以上是调用门压栈,关于中断门压栈,参见IDT与中断

本文结束。