G
N
I
D
A
O
L

浅析 IA-32 架构的分段机制和保护机制

浅析 IA-32 架构的分段机制和保护机制

目录

0 写在前面

0.1 如何阅读本文

操作系统的内核对于计算机及操作系统爱好者而言无疑是一个激动人心的话题,相信一些爱钻研的同学也有读过相关书籍,比如王爽的《汇编语言》,《x86汇编语言:从实模式到保护模式》以及《操作系统真象还原》。虽然这些书都很接地气,通俗易懂,但是都存在一个问题,那就是逻辑性和连贯性不强(《x86汇编语言:从实模式到保护模式》还是不错的,至少有源码可以对着看),再加上保护模式的机制本身就相当复杂,容易让人看的迷糊。

因此,本文写给那些已经学习过 x86 分段机制却还是迷迷糊糊的人,即,我默认你已经大致接触过这些知识了。本文所涉及的知识可以从《x86汇编语言:从实模式到保护模式》的第 11 章-第 15 章找到。首先,我们先介绍一些基本的概念。如果你对这些概念和相关问题都很清楚,那么可以跳到下一节。然后,我们再用一大节内容来理清一些关于描述符和选择子的概念,这可能有点枯燥,但相信我,如果你认真看完后一定会对这些概念更加清晰。本文最后,还基于《x86汇编语言:从实模式到保护模式》的有关代码作了一个梳理。

需要注意的是,本文所介绍的相关技术已经基本不再使用:分段模型早已转变为平坦模型,而任务切换也早已由软件模拟实现。因此,对于学过计算机操作系统课程的 CS 专业同学而言,本文可能有相当一部分的内容你们没有接触过。

0.2 一些需清晰的基本概念

【保护模式的主要功能?】

为了保护一些特定功能的段不被错误或恶意修改,Intel 引入了保护模式,在保护模式下,处理器可以禁止程序的非法访问。比如,向代码段写入数据,访问段以外的内存等非法操作,都有可能使程序崩溃,那么在保护模式下,这些涉及段的访问就会先进行检查,检查无误后才会允许访问,否则就会被阻止。除此之外,不同的段也有等级之分,以区分用户程序段和系统内核段。

【何为逻辑地址、有效地址、线性地址、虚拟地址、物理地址?】

传统上,段偏移地址称为逻辑地址,偏移地址叫做有效地址(Effective Address,EA)。而在保护模式下,段地址和偏移地址称为线性地址(虚拟地址)。虚拟地址经换算以后才是物理地址,物理地址即是真实的内存地址。

image

【这些寄存器是什么?】

  • CS:代码段寄存器
  • DS:数据段寄存器
  • ES:附加段寄存器(附加的意思是该寄存器的作用不像CS、DS和SS那样功能固定,可以随便用)
  • FS:附加段寄存器
  • GS:附加段寄存器
  • SS:堆栈段寄存器

1 描述符、描述符表和段选择子

描述符用来记录段的起始地址、段的界限等各种访问属性;描述符表就是描述符的一个连续存放的集合;段选择子用来在描述符表中获取相应的描述符。关于描述符的分类,是参考了《IA-32架构软件开发手册》的分类方法。

1.1 非系统描述符(S=0)

和一个段相关的信息需要 8 个字节(双字)来描述,被称为段描述符。当 S = 0 时,即为存储器的段描述符(也即是非系统描述符,可理解为一般的段描述符)。

1.1.1 全局描述符(GDT描述符)

每个任务(即一段程序)都有数据段和栈段,这些是属于任务的内存空间。为了方便管理,可以将各个任务都使用的空间划分为全局空间(一般是操作系统的程序或资源),而将另外一部分任务独自使用的空间划分为局部空间(每个程序自己的数据)。因此,可以使用全局描述符来描述(定义)一个全局空间,使用局部描述符来描述一个局部空间。

image

全局描述符的格式:

  • 段基地址 31 - 24 位
  • 粒度(Granularity,G):用于解释段界限的含义。当G=0,段界限以字节为单位;当G=1,段界限以 4KB 为单位。
  • 默认操作数大小(Default Operation Size/Upper Bound,D/B):或是默认的栈指针大小,或上部边界的标志。16 位保护模式下置 0,目前已很少用,应当置 1。
  • 64位代码段标志(64-bit Code Segment,L):保留此为给 64 位处理器使用,置 0 即可。
  • 软件可使用位(Available,A):一个没什么用的位。
  • 段界限 19 - 16 位
  • 段存在位(Segment Present,P):P=0,描述符所指示的段在内存中并不存在;P=1反之。
  • 描述符特权级(Descriptor Privilege Level,DPL):有四种特权,分别是0、 1、 2、 3, 0是最高等级特权,3是最低等级特权。
  • 描述符类型(Descriptor Type,S):S=0,表示是一个系统段;S=1,表示是一个代码段和数据段。这里是存储器的段描述符,所以S=0
  • 描述符子类型(TYPE):该字段一共有 4 位,对于数据段来说,是 X、E、W、A 位;对于代码段来说,是 X、C、R、A 位。可用来指定访问属性。详见以下表格:

image

  • 段基地址 23 - 16 位
  • 段基地址 15 - 0 位
  • 段界限 15 - 0 位

1.1.2 计算段的大小

根据段界限,可推出实际可访问的段的大小,即实际段界限:

实际段界限 =(描述符中的段界限 + 1)* 粒度 - 1

比如,段界限值为 0xFFFFF,段粒度为 4KB,则实际可访问大小为:

(0xFFFFF + 1)* 0x1000 - 1 = 0xFFFF FFFF = 4GB

1.2 系统描述符(S=1)

当 S = 1 时,即为系统描述符。在系统描述符中,由TYPE决定了不同类型的系统描述符,可分为系统段描述符和门描述符,如下表所示:

image

其中,TYPE 字段的 11 位表示的是 16 位模式还是 32 位模式。这里我们只提及几个常用的描述符。

1.2.1 局部描述符(LDT描述符)

局部描述符属于系统段描述符。注意系统描述符和系统段描述符表不是同一个东西,系统描述符包含了系统段描述符。任务独自使用的空间划分为局部空间。因此,可以使用局部描述符来描述一个局部空间。

image

局部描述符的格式:

  • 段基地址 31 - 24 位
  • 粒度(Granularity,G):用于解释段界限的含义。当G=0,段界限以字节为单位;当G=1,段界限以 4KB 为单位。
  • 默认操作数大小(Default Operation Size/Upper Bound,D/B):对于 LDT 描述符来说无意义,置 0。
  • 64位代码段标志(64-bit Code Segment,L):保留此为给 64 位处理器使用,置 0 即可。
  • 软件可使用位(Available,A):一个没什么用的位。
  • 段界限 19 - 16 位
  • 段存在位(Segment Present,P):P=0,描述符所指示的段在内存中并不存在;P=1反之。
  • 描述符特权级(Descriptor Privilege Level,DPL):有四种特权,分别是0、 1、 2、 3, 0是最高等级特权,3是最低等级特权。
  • 描述符类型(Descriptor Type,S):必须S=0,表示是一个系统段。
  • 描述符子类型(TYPE):在S=0的前提下,TYPE为 “0010”,表示为一个 LDT 描述符。
  • 段基地址 23 - 16 位
  • 段基地址 15 - 0 位
  • 段界限 15 - 0 位

1.2.2 任务状态段描述符(TSS描述符)

任务状态段描述符也属于系统段描述符。

image

格式:

  • 段基地址 31 - 24 位
  • 粒度(Granularity,G):用于解释段界限的含义。当G=0,段界限以字节为单位;当G=1,段界限以 4KB 为单位。
  • 默认操作数大小(Default Operation Size/Upper Bound,D/B):对于 TSS 描述符来说无意义,置 0。
  • 64位代码段标志(64-bit Code Segment,L):保留此为给 64 位处理器使用,置 0 即可。
  • 软件可使用位(Available,A):一个没什么用的位。
  • 段界限 19 - 16 位
  • 段存在位(Segment Present,P):P=0,描述符所指示的段在内存中并不存在;P=1反之。
  • 描述符特权级(Descriptor Privilege Level,DPL):有四种特权,分别是0、 1、 2、 3, 0是最高等级特权,3是最低等级特权。
  • 描述符类型(Descriptor Type,S):必须S=0,表示是一个系统段。
  • 描述符子类型(TYPE):在S=0的前提下,TYPE为 “10B1”,表示为一个 TSS 描述符。注意,其中的 B 位很重要,它表示 BUSY 的意思,置位即表示该任务的状态为忙(即正在运行中)。具体会在 4.3 节说到。
  • 段基地址 23 - 16 位
  • 段基地址 15 - 0 位
  • 段界限 15 - 0 位

1.2.3 调用门描述符

以下提及的几种描述符是门描述符。门是另一种形式的描述符,称为门描述符。与段描述符区别是:段描述符用来描述内存段,而门描述符用来描述一段可执行的代码。

调用门用于在不同特权级的程序之间进行控制转移,本质上只是一个不同于存储器的段描述符。

image

格式:

  • 段内偏移量 31 - 16 位
  • 段存在位(Segment Present,P):P=0,描述符所指示的段在内存中并不存在;P=1反之。
  • 描述符特权级(Descriptor Privilege Level,DPL):有四种特权,分别是0、 1、 2、 3, 0是最高等级特权,3是最低等级特权。
  • 描述符类型(Descriptor Type,S):S 必须为 0,表示是一个系统段。
  • 描述符子类型(TYPE):该字段一共有 4 位,必须是 “1100”,表示调用门。
  • 参数个数:通过调用门去调用高特权的例程时,需要传入参数,最多可以传入31个参数。
  • 例程所在代码段的选择子
  • 段内偏移量 15 - 0 位

1.2.4 任务门描述符

image

格式:

  • P 位:指示任务门是否有效。当 P = 0 时,不允许通过此门进行任务切换;当 P = 1 时,允许任务切换。
  • DPL:任务门描述符的特权级,但是对因中断而引起的任务切换不起作用(也就是说,中断引起的任务切换,不需要进行特权检查),只对非中断方式的任务切换起作用。
  • 描述符类型(Descriptor Type,S):S 必须为 0,表示是一个系统段。
  • 描述符子类型(TYPE):该字段一共有 4 位,必须是 “0101”,表示任务门。
  • TSS 选择子:也即任务状态段选择子(如大家所见,门描述符都有选择子的字段,是比较特别的)
  • 其他位不使用。

还有其他的门描述符:中断门和陷阱门,限于篇幅就不介绍了。其实,在现代操作系统中,用的最多的是中断门(如果我有时间,会另起一文介绍);而陷阱门,是给调试程序用的。

1.3 描述符表寄存器

说完了描述符,接下来是记录它们所在的存放位置。为了存放这些段描述符,需要开辟一个空间来存储,这叫描述符表。硬件上,使用描述符寄存器来存储描述符表的基地址和它们的边界(即大小)。注意,对于 GDT,第一个描述符(索引号为 0)应为空,称为哑描述符

image

1.3.1 全局描述符表寄存器(GDTR)

image

全局描述符表寄存器 GDTR 是一个 48 位的寄存器,分别是 32 位的全局描述符表基地址和 16 位的全局描述符表边界,其值等于表的总字节数减去 1。

1.3.2 局部描述符表寄存器(LDTR)

如上图,局部描述符表寄存器 LDTR 是一个 48 位的寄存器,分别是 32 位的局部描述符表基地址和 16 位的局部描述符表边界,其值等于表的总字节数减去 1。

1.3.3 任务状态段寄存器(TR)

如上图,任务状态段寄存器 TR 也是一个 48 位的寄存器,分别是 32 位的任务状态段表的基地址和 16 位的任务状态段表的边界,其值等于表的总字节数减去 1。

图中还有一个 IDTR,IDT 是中断描述符表,是保护模式下的中断向量表,IDTR 是中断描述符表寄存器,本文由于没有涉及,故不介绍。

1.4 段选择子

保护模式下有 6 个段选择器 CS、DS、ES、FS、GS、SS和任务状态段 TSS。在实模式下访问一个段时,只需要传送基地址给段寄存器即可;而在保护模式下,需要给段选择器传送段选择子用以访问相应的段。下图为格式:

image

  • 描述符索引号:索引号指的是描述符表中的索引号。比如数据段描述符在表中顺序为第 3 个,那么索引号为 2。
  • 描述符表指示器(Table Indicator,TI):当TI=0时,表示描述符在全局描述符表(GDT)中;当TI=1时,表示描述符在局部描述符表(LDT)中。
  • 请求特权级(Request Privilege Level,RPL):表示的是请求者的特权级别,哪个代码段提供了选择子,哪个就是请求者。

1.5 控制寄存器CR0和段选择器

控制寄存器 CR0 是一个 32 位的寄存器,位 0 是保护模式允许位(Protection Enable,PE),置 1 表示处理器进入保护模式。

无论是否在实模式还是保护模式,段寄存器(段选择器)除了 16 位空间外,还有一个看不见的部分,称为描述符高速缓存器,用来存放段的基地址、段界限和段属性,我们是不能访问这些内容的,它是给处理器内部使用的。如图所示为段寄存器的结构:

image

1.6 总结

分段模型如图所示:

image

数据段、代码段和系统段描述符的格式如下:

image

好了,简单过一遍相关的概念后,接下来我们谈到大家比较关心的问题:段访问合法性的检查。

2 段访问的合法性

我们使用段选择器和偏移地址联合进行内存访问,自然是要对这两部分进行检查了。

2.1 检查段选择子的合法性(≈Type Checking)

我们将一个新的段选择子赋值给段选择器时,硬件都要对段选择子进行检查。一旦硬件检查到段选择子出现问题,处理器则中止处理,引发异常中断。出现问题的情况一般有:

  • 段选择子的 TI 位不正确。比如要访问的段描述符不在 GDT 中,但 TI = 1,此时引发中断。
  • 索引号超出了 GDT 或 LDT 的范围。
  • 通过以上检查没问题后,读取相应的段描述符,接着对比段选择器和段描述符是否类型匹配(Type Checking),不匹配则引发中断。比如段选择器是 CS,负责代码段,而读取的段描述符描述的是数据段,肯定是不行的。如下表所示为类型匹配规则:

image

  • 最后判断描述符中的 P 位。如果 P = 0,说明要访问的段根本不在物理内存中,还是会引发中断。

2.2 检查偏移地址的合法性(Limit Checking)

在使用段选择器和偏移地址联合进行内存访问时,即使段选择子没有问题了,但偏移地址可能还是会有问题,接下来我们来看看究竟怎样会出现问题。

2.2.1 检查代码段偏移地址(EIP)的合法性

代码段的指针寄存器 EIP 是向高地址增加的,我们都知道偏移地址即是由 EIP 提供的,很显然,当 EIP 超出了实际段界限(段大小)时,就会引发中断。但还有一个很容易忽略的点:即使 EIP 没有超出段界限,但所指的指令的长度超出了段界限,依然也会引发中断。所以能访问的范围为:

0 ≤ (EIP + 指令长度 - 1) ≤ 实际段界限(段大小)

2.2.2 检查数据段偏移地址的合法性

数据段也是和代码段类似的,偏移地址可以向高地址增加,也可以向低地址递减。不过,无论如何,当偏移地址超出了实际段界限(段大小)时,就会引发异常中断。能访问的范围为:

0 ≤ (偏移地址 + 数据长度 - 1) ≤ 实际段界限(段大小)

在手册中给出了高地址增加时,不同数据长度的实际段界限值:

For all types of segments except expand-down data segments, the effective limit is the last address that is allowed
to be accessed in the segment, which is one less than the size, in bytes, of the segment. The processor causes a
general-protection exception (or, if the segment is SS, a stack-fault exception) any time an attempt is made to
access the following addresses in a segment:

• A byte at an offset greater than the effective limit

• A word at an offset greater than the (effective-limit – 1)

• A doubleword at an offset greater than the (effective-limit – 3)

• A quadword at an offset greater than the (effective-limit – 7)

• A double quadword at an offset greater than the (effective limit – 15)

When the effective limit is FFFFFFFFH (4 GBytes), these accesses may or may not cause the indicated exceptions.
Behavior is implementation-specific and may vary from one execution to another.

2.2.3 检查栈段栈指针(ESP)的合法性

栈段和前两个段的区别在于,栈段栈指针 ESP 是向下扩展的,即向低地址递减,正因为如此,我们在定义栈段的段描述符时,它的实际段界限并不代表实际的段大小。比如,我们定义了一个段基地址为 0x0000 0000、段界限为 0x07A00 的段描述符,根据 1.1.2 节所讲的公式,实际允许访问和使用的栈段范围不是我们想要的范围,实际的栈空间是很大的,如图所示:

image

所以,这里的实际段界限并不是实际的段大小,而是视作为基地址一样的东西,即整个栈的最低端地址(或者也可以认为基地址是0xFFFFFFFF,而段界限是一个补码的负数,那么实际的段界限(段大小)可视为基地址减去段界限加一)。最高端地址是没有限制的,为 0xFFFF FFFF。每压入一个操作数,ESP 指针就会减去操作数的长度,所以可以访问的范围为:

实际段界限 + 1 ≤ (ESP - 操作数的长度) ≤ 0xFFFFFFFF

For expand-down data segments, the segment limit has the same function but is interpreted differently. Here, the
effective limit specifies the last address that is not allowed to be accessed within the segment; the range of valid
offsets is from (effective-limit + 1) to FFFFFFFFH if the B flag is set and from (effective-limit + 1) to FFFFH if the B
flag is clear. An expand-down segment has maximum size when the segment limit is 0.

注意,压栈操作是先减 ESP,然后再访问栈。

3 特权级

如大家所见,其实在我们回顾描述符的相关概念时,就已经涉及到了特权级。事实上,特权级是分段机制一个绕不过的坎。

3.1 特权保护环

Intel 处理器分为了 4 个特权级,分别是 0、 1、 2、 3,数字越大特权级别越低,数字越小特权级别越高。处理器总是处于其中的一个等级。如下图所示,因为这些等级构成了一个同心圆环,所以我们又可以称之为 0 环、1 环、2 环、 3 环(Ring 0-3)

拥有 0 级特权的一般是操作系统的主体部分,也称为内核(Kernel);拥有特权级 1 和 2 的一般是设备驱动程序;特权级 3 的是应用程序或用户程序,它们不能直接访问硬件资源和系统资源,需要调用驱动程序或操作系统例程,这时特权级可能也会随之转移,至于如何转移是后面的内容。

image

现在的操作系统一般都将硬件驱动程序和系统内核都归为特权级 0,用户程序归在特权级 3,而特权级 0 和 1 不被使用。

3.2 DPL、CPL、RPL及I/O特权级

image

  • DPL(Descriptor Privilege Level)即描述符特权级,DPL 在创建描述符时就已经定义好了。段描述符的 DPL 标明了段的特权级别。

  • RPL(Request Privilege Level)即请求特权级,位于段选择子内。每当需要访问某个段时,段选择子赋值给段寄存器(段选择器),选择子的 RPL 标明了请求者(自己)的特权级别。(对于 RPL 有不懂的,下面内容会分析清楚,不用急)

  • CPL(Current Privilege Level)即当前特权级,仅位于代码段寄存器 CS 中,可视作为 CS 的 RPL 位,即CS.RPLCPL 标明了当前处理器的运行等级。

【深刻理解CPL特权级的概念】

CPL 是处理器的 CPL。任意时刻处理器所处的特权级即为 CPL。注意我说的是“处理器的特权级”,而非“正在执行代码的特权级”或“正在执行指令的特权级”,因为 CPL 更强调处理器的特权身份地位。如果通过某种方式(至于什么方式下面将会提及)使 CPL 级升高或降低,那么指的是处理器的特权等级升高或降低。比如,将 CPL=0 降级为 CPL=3,意味着处理器原本还能直接读取系统资源或运行内核程序,降级后就无法读取资源或运行内核了,硬件阻止了这一行为。

  • IOPL(I/O Privilege Level)即为输入/输出特权级,位于处理器的标志寄存器 EFLAGS 内,共占两位。因为涉及的是访问硬件资源,因此只有当 CPL = 0 时,才可以修改此位。有一些涉及 IO 的指令,需要 CPL 等级高于或等于 IOPL 时才能执行。有关 IO 特权的内容留待下一节再讲。

3.3 特权的转移

特权级检查仅发生在jmp、call、int或向段寄存器mov段选择子的时候。而特权的转移一般发生在执行jmp、int、call等指令。此时,处理器会进行一次特权级检查(有关特权级检查的更进一步探讨,4.2.2 节有提及)。

【深刻理解特权级检查!】

注意这句话:“特权级检查仅发生在jmp、call、int或向段寄存器mov段选择子的时候”,需要注意这个“仅”字,只在上述情况下才会发生检查,检查并通过后才可使用段寄存器和偏移地址进行段的访问,之后如果不再向段寄存器mov段选择子,那么无论是什么特权等级下,通过该段寄存器去访问段的行为都是合法的。看似很合理,但也正是因为这样还是会出现越权访问的问题(其中之一是返回后出现的越权访问,详见 4.2.2 节;其中之二是接下来我们谈到引入 RPL 的必要性这个问题)。

当程序需要转移到其他段时,处理器会对当前特权级 CPL 和目标特权级(即描述符特权级)DPL 进行检查,要求 CPL = DPL 才能进行转移,即只能平级转移。比如当前特权级 CPL = 2,那么就不能转移到 DPL = 0,1,3 的段上去。这样看来,是不是就没有办法改变处理器的等级了呢?为了解决这个问题,处理器提供了两个办法。

3.3.1 方法一:将高特权代码段定义为依从代码段———从低等级转移至高等级

依从代码段(Confirming)也叫做一致性代码段。在段描述符中,该段需要为非系统段(S=0),且代码段的 TYPE 字段的 C 位为 1,则该代码段为依从代码段,可以被特权级比它低的程序调用;如果 C = 0,则该代码段只能被同等级的程序调用。如下图为当前代码段分别转移到依从代码段和非依从代码段时的图:

image

调用依从代码段的条件是当前代码段 CPL 必须低于或等于目标依从代码段 DPL(否则,如果 CPL 等级还高于 DPL,你还需要依从吗?),但并不检查段选择子的 RPL。在数值上,CPL ≥ DPL。这时,处理器在运行依从代码段,但 CPL 依然不会变,还是原来的特权等级。这样的好处是不必升高 CPL 就能访问到系统资源,不用担心恶意程序会改动系统资源。

注意,数据段一定是非依从(非一致性)的。

3.3.2 方法二:使用门——从低等级转移至高等级

还有另外一种方式是使用门。我们以调用门为例来简要说明转移的过程。

之前也已经提及过,调用门(Call Gate)用于在不同特权等级的程序之间进行转移,其本质仍然是一个描述符,可安装在 GDT 或 LDT 中。一般的使用方法是,当前特权等级的程序需要访问系统资源或调用系统例程时,当前程序使用一个段选择子作为调用门例程的参数传入(比如当前程序希望将系统数据传入到自己的数据段中),然后调用门运行例程,帮助当前程序写入数据。这个时候,CPL 已经变为最高级别的 0,而 RPL 依然是段选择子所指示的 RPL。一个非常形象的理解是,门相当于是一个跳板,可以将低特权等级“蹦”到高特权等级上,如图所示:

image

以下图为例来说明。一个特权等级为 3 的应用程序希望从硬盘读入一个扇区,并将数据复制到自己的代码段中。因此,数据段 RPL 为 3,该数据段选择子作为参数传入调用门例程,这时 CPL 变为最高等级的 0,系统会把硬盘的数据传输到应用程序的数据段中。此时可以发现 CPL 和 RPL 是不相同的,因为请求者不是系统程序,而是应用程序。多说一句,这里用户程序访问系统资源的过程,是 CPU 陷入内核的过程,也叫做管态。注意,我们这里只是简单的描述了一下这个过程,许多细节有待后文分析。

image

3.3.3 由门调用过程提出思考:RPL有什么用?

从以上案例中,我们可以发现,RPL 能帮助处理器来辨别请求者的特权等级,但我们也看不出 RPL 有什么存在的必要性。那我们假设没有 RPL,只有 DPL 和 CPL,回顾刚刚上面的过程,转移前 CPL = 3,要访问的 DPL = 0,当拿着一个没有 RPL 选择子去传入调用门例程的时候,系统内核依然能将硬盘数据传入到用户程序的数据段中。操作上没有什么问题。

不过现在一件非常遗憾的事情发生了:如果一个不怀好意的程序提供的数据段选择子不是自己的,而是系统的数据段呢?注意,我们假设的是没有 RPL,那么检查的只有 CPL 了,而 CPL(= 3)等级小于调用门的 DPL(= 0),检查是通过的。那么现在调用例程了,写入的数据段居然是系统内核的数据段!这就意味着,一个等级为 3 的应用,直接改写了系统内核的数据段!这是万万不可接受的事情。

image

所以我们需要多加一项检查,那就是 RPL,它位于选择子中,用以表明请求者的真实特权等级。调用例程之前,同时检查当前特权等级 CPL 和选择子的 RPL,如果 CPL 等级低于 DPL(符合要求),但你提供的选择子 RPL 等级高于或等于 DPL,那是不符合要求的,会引发异常中断。在数值上,需要满足以下条件才能使用调用门例程:

CPL ≥ DPL, RPL ≥ DPL

好了,下图就是一个总结性的调用门过程解析(细心的同学已经发现了,这里面还有个栈切换的操作,留待第四大节再谈):

image

好了,最后再来思考一个问题:你凭什么保证用户程序的选择子 RPL 是正确的?万一他偷偷改了呢?这个问题其实很有意思,因为其实 Intel 引入这个安全机制完全是靠用户的自觉性的,你不想遵守规则,处理器也拿你没办法。不过,不要以为这样就能得逞了,需要提醒,一般情况下用户程序是在操作系统上运行的,操作系统接管用户程序的时候,都要进行一次重定位,即对数据段、栈段进行一次重定位,这个时候它会将你的代码段、连同你的数据段的等级都改为最低级。一般来说,你在用户程序里无论是访问数据段,还是调用例程,用的都是数据段符号或函数符号吧,一般不会直接使用选择子(地址)来访问。那么这个时候,你调用例程时写下的这个符号,已经被系统调换为一个 RPL = 3 的选择子(有关用户程序是如何加载和重定位的问题留待最后大致讲解)。总之,一般情况下,你是没法自己改 RPL 的。当然,如果是做游戏外挂的,有一种办法就是通过某种手段来修改 RPL 以此来篡改游戏的各个属性。

3.3.4 那么从高特权等级转移至低特权等级呢?

每次启动后,处理器首先运行的一定是特权级 0 的内核程序,接下来一般会通过任务门切换任务,发生高特权转移到低特权。待任务切换那节再谈。

另外,在调用门返回的时候,也会出现从高等级越到低等级的情况,第四大节再谈。

4 任务

现在已经将描述符和特权级的事情都讲完了。不过,处理器不可能只运行一个任务,有时还会根据情况进行任务切换。这时一个问题出现了:数据段、代码段每个任务都有了,但处理器只提供了一个 GDTR、一个 LDTR、一个 EFLAGS,每个任务都要用,用了就会把上一个任务的相关属性给覆盖掉,那怎么办?

4.1 任务状态段(TSS)和任务控制块(TCB)

4.1.1 任务状态段(TSS)

之前在第一大节已经提及了 TSS 描述符,现在我们先正式引入 TSS,再来结合 TSS 描述符来讲。

为了保存任务的状态,并在下一次执行时恢复它们,每个任务都需要一个额外的内存空间来保存相关信息,这叫任务状态段(Task State Segment,TSS)。如图所示为 TSS 的结构:

image

可以注意到,TSS 是一个链表结构,其最低位存储了前一个任务的指针。TSS 存储了任务的不同特权等级的栈寄存器,还有各寄存器和段寄存器,以及 LDT 段选择子和 I/O 映射基地址。当要切换新的任务时,处理器会先把旧任务的所有寄存器状态都存储到 TSS 中。

【深刻理解 TSS 链表和 TSS 链接域!】

在解释 TSS 链接域之前,我必须先强调一个事情:大家都接触过数据结构吧,应该知道链表的存储空间不一定是连续的。所以,TSS 链表的存储空间也不一定是连续的,但这个不是我所讲的重点。重点在下面。

每个任务都有自己的 TSS,但不是所有 TSS 都连成了一个链表,即,每个任务的 TSS 中的链接域不一定都有内容。这意味着,其实可以有多条 TSS 链表。

TSS 链接域是干什么的呢?之后我们会提及任务切换,有一种任务嵌套式的任务切换,需要在任务切换回来后回到原来的任务(题外话:还有一些任务切换是不需要回来的),这时新任务的 TSS 链接域就需要填写原来任务 TSS 的基地址,这样返回时就能直接通过这个地址回到原来的任务。TSS 链接域的作用就是任务切换时记录旧任务的 TSS 基地址,注意,是用在任务切换的时候!!!因此,不是一创建新任务 TSS 就把它的基地址立刻填入到其他 TSS 链接域中。

不过,在《x86汇编语言:从实模式到保护模式》中的程序,为 TSS 申请了一片连续的空间,但每个 TSS 在任务新创建时并不是连接在一起的。是在任务切换嵌套的情况下才会连在一起,形成链表。下面提及的 TCB 也是一样的道理。

TSS 的最后一个字节的内容是 I/O 映射基地址,它指示的是 I/O 许可位映射区(也称为 I/O 许可位串)的基地址。 I/O 许可位串是一个比特序列,最多允许 8KB 长度,每个比特位代表了相应的端口是否能被访问,为 1 时,禁止访问;为 0 时,允许访问。许可位串其实也应该属于 TSS 的一部分。

这个许可位串是干什么用的呢?慢慢细讲。当 CPL 等级高于或等于 IOPL 时,处理器被允许访问所有硬件端口;当不符合这个条件时,也并不意味着所有端口都不允许访问,个别在许可位串中标明 1 的硬件端口是可以访问的,其他硬件端口依然不能访问。因此,许可位串做的工作就是将每个任务可以访问的硬件端口都记录下来。

4.1.2 任务状态段描述符(TSS描述符)和任务寄存器(TR)

任务状态描述符的格式如下:

image

GDT 和 LDT 内存储了描述符,但 TSS 中存储的不是描述符,刚刚说过存储的是任务的相关信息。那么,TSS 描述符放在哪才能生效呢?放在任务寄存器中。

处理器使用任务寄存器 TR(Task Register)来指向这个段的基地址,跟 GDTR 和 LDTR 的结构类似。

不过,又一个问题来了:处理器只有一个 TR,面对这么多的任务,任务切换时 TR 的值会被不断覆盖,那 TSS 描述符应该存在哪呢?我们需要使用一个法宝,本节内容最核心的部分要闪亮登场了——TCB。

4.1.3 任务控制块(TCB)

内核为每一个任务创建一个内存区域,用以存放任务的信息和状态,称为任务控制块(Task Control Block,TCB)。如果之前有接触过操作系统的相关知识,就应该知道,TCB 是操作系统不可或缺的一部分,无论是 Windows、Linux 还是嵌入式操作系统(如 FreeRTOS、uCos)都有 TCB 的身影。与描述符和 TSS 不同,TCB 完全与处理器硬件无关,它是我们自己想出来的方便记录任务的东西

image

如图为 TCB 的结构。当然因为这是我们自己想出来的,所以其他操作系统的 TCB 可能不长这样。我们的 TCB 存储内容是很重要的:LDT 选择子、基址、界限,TSS 选择子、基址、界限,不同特权等级栈段的选择子、基址等。TCB 也是一个链表结构。

为了加深大家对 TCB 和 TSS 作用的理解,这里简要说明一下任务的创建、切换、删除过程,具体过程留待最后讲解:通常,内核就绪完毕后,就开始加载任务,这时开始构造 TCB 和 TSS(当然此时任务还未运行,TSS 的内容即是内核的内容)。一旦所有要加载的任务都加载完毕,各任务的 TCB 都各就各位,那么一般不再更改 TCB 里面的内容,这时可以运行任务了。要想切换到某个任务,就需要访问该任务的 TSS(切换或恢复新任务的寄存器状态)和 LDT(切换到新任务的数据段),这就必须首先到 TCB 的相应位置寻找 TSS 选择子和 LDT 选择子,然后还要...(具体的切换过程其实挺复杂的,留待任务切换时讲解)。如果有新的任务加入,那么只会在 TCB 链表后面继续加入新的 TCB,在 TSS 链表后面加入新的 TSS;若任务完成,内核可以选择删除该任务的 TCB 和 TSS,也可以保留,以便下次直接使用。需要注意,新的任务也可以是之前相同的程序。

【注意区分任务和程序的概念!】

任务≠程序。一个程序可以被多个任务使用,多个任务也可以对应一个程序。比如,把 A 程序视为任务 1,把 B 程序视为任务 2。现在新加入一个任务 3,任务 3 可以是 C 程序,也可以是之前的 A 程序或 B 程序。两个任务都使用同一个程序,很像我们在电脑打开两个相同的文件或应用窗口。

回顾完一些涉及任务的基本概念后,我们借助 TSS 来讨论一个完整的门转移控制过程。

4.2 特权转移的完整过程——以调用门为例

本节内容是 3.3.2 节的延伸,还记得我们当时说过很多细节有待解释吗?现在他来了。另外,是否注意到 TCB 中为何存储着不同特权等级的栈段?因为在 IA-32 架构中,不允许进行栈段的特权转移。因此,需要我们自己为任务创建不同特权等级的栈,听起来是不是麻烦事越来越多?

4.2.1 特权转移前的栈切换过程

通过指令jmp far, call far使用调用门实施控制转移,当前特权等级从低等级切换到高等级时,由于栈段不允许特权转移,只能平级切换,所以处理器硬件会自动切换到另外一个目标特权等级的栈。通过理解栈切换的过程,可以帮助我们更好理解特权转移,也可以更好地理解 TSS 的作用。

  • 调用系统例程,也即是跳转至调用门描述符指示的地址。处理器硬件根据描述符中的 DPL,到当前任务 TSS 中读取相应特权等级的栈段选择子(SS)和栈指针(SP)。注意当前任务中,TR 寄存器已经保存了 TSS 的基地址,因此可以找到当前任务的 TSS。可以对照 TSS 的结构进行查看。
  • 硬件检查栈段选择子和栈指针是否合法(有没有超界限?),若不合法,则引发中断异常。若没有问题,切换到新栈,将旧的 ESP 和 SS 压入到新栈中,新的栈段选择子和栈指针存入 ESP 寄存器和 SS 寄存器中。
  • 是否还记得调用门描述符中的参数个数字段?根据参数的个数,硬件将旧栈的参数全部复制到新栈中(是的,的确是硬件自动完成的,你不用操心),供例程使用。
  • 将 CS 和 EIP 也压入新栈中。因为调用门一定是远转移,所以 CS 也需要保存。
  • 最后,从调用门描述符中获取段选择子和偏移地址,存入 CS 寄存器和 EIP 寄存器,开始执行调用门的例程!

4.2.2 完成转移后的返回过程

如果使用的是jmp far进行调用,那么这是一次“有去无回”的调用,不需要再切换回来。但是,如果使用了call farretf回来,按理来讲,在运行完系统例程后,处理器需要弹出新栈中的旧代码段选择子和旧指令指针,然后存储在 CS 寄存器和 EIP 寄存器,接着再弹出新栈中的旧栈段选择子和旧栈指针,存入到 SS 和 ESP 寄存器里,切换为旧的栈就行了,不需要进行特权检查。

不过,编写程序的人总是很狡猾的。谁说call farretf一定要配对的?我只写retf,欺骗处理器,让它以为我是刚从另外一个程序回来的。试想一下,如果编写程序的人知道程序在某个时刻栈段中会出现高特权等级的段选择子和指针,它在低特权等级时使用了retf,那么处理器没做任何特权检查直接弹出了高等级的段选择子和指针,低特权等级的 CPL 就这样轻轻松松变成了高特权等级的 CPL。那这样显然是不合适的。

因此,处理器硬件在调用门返回时也要做一次特权级检查。过程如下:

  • 检查栈中代码段选择子(即旧 CS),与现在的 CPL 进行比较,根据旧 CS 的 RPL 字段判断是否需要改变现在的 CPL。
  • 获取栈中的旧 CS 和旧 ESP,并检查旧 CS 选择子的 RPL 和 选择子对应的描述符的 DPL 是否符合规则。如果当前特权级 CPL 高于 DPL 和 RPL(即你是用“正当”的手段去返回),则弹出旧 EIP 和旧 CS 存到相应寄存器中。否则(即你是用“不正当”手段返回),引发异常。
  • 在第一步中,如果需要改变 CPL,则现在需要切换到旧栈,从新栈中取出旧栈的 ESP 和 SS,丢弃新栈的 ESP 和 SS;接着,再检查其他数据段寄存器(DS、ES、FS、GS),通过段选择子找到段描述符,如果出现段描述符的 DPL 等级高于改变后的 CPL,则该段寄存器将被清零。如果不需要改变 CPL,什么都不用做。

为什么要清零呢?因为在调用门之后,必然有数据段寄存器指向了内核的数据段。如果从调用门回来时,处理器只是处理代码段和栈段的问题,而没有处理数据段的问题,那么,只要切换回来的用户程序不往段寄存器加载新的选择子,那么用户程序可以一直访问内核的数据段(需要注意:主动的特权级检查只发生在往段寄存器加载选择子的时候,即向段寄存器mov东西,属于编写程序的人主动申请往里面送选择子,这种我称之为“主动的(或显式的)特权级检查”。像这里我们讨论retf返回时进行的特权级检查,或是由call、jmp调用所引起的特权级检查,我们没有送选择子,更符合“被动的(隐式的)特权级检查”的说法。不过,无论是什么方式,都属于特权级检查,这里只是个人的一个分法,大家不必分得这么细)。所以,需要把段寄存器中的选择子清零。这样,所有位数都是 0 的选择子,相当于指向了 GDT 的索引号为 0 的 GDT 描述符,相信大家都有印象,GDT 的第一个描述符是哑描述符,是不可用的,用这样的选择子去访问哑描述符,会引发中断异常。

4.3 任务切换

对多任务切换的支持是现代处理器的标志之一。第一种切换方式是中断(中断是没有指令的,中断结束后用iret返回)(也是抢占式多任务的基础),第二种切换方式是正常的远转移(jmp),第三种方式是正常的远调用(call)(int指令虽然也是中断,但也符合这种方式),第四种是直接使用 iret 进行切换(中断返回也可视作一次“任务切换”,中断产生时“从旧任务切换新任务”,中断返回时“从新任务切换到旧任务”。另外,谁说iret就一定用在中断?狡猾的人类又出现了!)。第一种和第三种是任务嵌套的方式切换,而第二种和第四种不是任务嵌套式的切换。在讨论不同方式的任务切换之前,先来引入两个新的东西。

4.3.1 TSS描述符的B位和EFLAGS寄存器的NT位

image

处理器有一个 EFLAGS 寄存器,EFLAGS 的 NT 位代表嵌套任务标志(Nested Task Flag)。这是干什么的呢?因为中断而引起的任务切换,和正常的任务切换是不同的。中断式的任务切换,是嵌套在其他任务里的,中断完了以后还要回到原先被中断的任务;对于远调用方式,也是嵌套在其他任务中,调用完了子程序还要回到老地方的;但是对于转移式的任务切换,不一定再回到原先的任务去执行,这个 NT 位记录的就是这个玩意。当中断需要切换任务时,将新任务的 EFLAGS 的 NT 位置位,TSS 描述符的 B 位置位;而老任务的 EFLAGS 和其他一堆寄存器,不管里面是什么内容,都不用动,统统存到 TSS 中。当使用 iret 中断返回时,处理器检查老任务(即前一个任务的 TSS)的 NT 位是否为 1,如果是,则说明该中断任务是嵌套在其他任务里的,必须回到原先的地方,那就从 TSS 中恢复现场咯。

那么现在来看一个具体的例子,这个例子反映了由 call 发起的任务切换。记得 TSS 是一个链表结构吧?每个任务的 TSS 都有一个任务链接域(即指向前一个任务的 TSS),这个链接域填写的是前一个任务的 TSS 选择子。如图所示,任务 1 是我们执行的第一个任务,此时因为没有别的任务内嵌,因此 B=1,NT=0。执行指令 call,任务 1 切换到任务 2 后,任务 1 的所有寄存器都存到 TSS 中,任务 2 的 B 置位,NT 置位,表示任务 2 嵌套在任务 1 中,同时,任务 2 的 TSS 链接域需要填写任务 1 的 TSS 选择子(这样就构成了一个链表了)。当任务 2 还要切换到任务 3 时,也是一样的操作,不再赘述。

image

任务门的有关介绍,已在第一大节说过,还记得 TSS 描述符的 B 位么?B 位用来指示当前任务是否繁忙。这又是用来干什么的呢?使用 call 或中断方式 进行任务切换时,如果任务切换到原先的被嵌套的任务(注意不是切换到新的任务),那么 TSS 链表会乱起来的。还是以上图为例,如果任务 3 成功切换到任务 1,那任务 1 的 TSS 链接域既指向任务 2 又指向任务 3,大家都学过数据结构吧,一个域不可能同时指向两个空间,这种结构显然是不可能的。所以,检测任务 1 的 B 位就是为了避免这种情况,任务 1 的 B=1,就不能切换到任务 1 上去了。此即“任务的不可重入性”

4.3.2 中断式和远调用(call)式的任务切换过程

注意:1. 此时的中断向量表,在保护模式下,变成了中断描述符表;2. 任务门描述符可以安装在中断描述符表、全局描述符表和局部描述符表;3. GDT 什么描述符都可以装,描述符装在 GDT 中比较好访问。

  • 当中断发生时,处理器得到中断号,用中断号作为索引去访问中断描述符表,发现所对应的是任务门描述符,再从任务门描述符中获得 TSS 选择子。由于 TSS 描述符是存储在 GDT 中的,因此用 TSS 选择子去访问 GDT,获得 TSS 描述符,这个 TSS 描述符所对应的就是新的任务。(很绕的过程。。。这个很绕的过程会在第五大节详细说明,大家先不用管)
  • 对于中断引起的任务切换,不需要进行特权级检查(忽略 DPL);对于 call 引起的任务切换,需要进行特权级检查:当前旧任务的 CPL 和新任务选择子的 RPL 等级需要高于或等于 TSS 描述符的 DPL。
  • 检查 TSS 描述符中的 P 是否为 1,即检查描述符是否有效;检查 TSS 描述符中的 B 位是否为 0,即检查新任务是否为忙状态,是否可用。
  • 各种检查通过后,不修改旧任务的 B 位(即保持置位状态),NT 位保持,将旧任务的所有寄存器和 EFLAGS 等统统存储在 TSS 中。
  • 将 EFLAGS 的 NT 位置位,表示新任务是嵌套在其他任务中。将新任务 TSS 描述符的 B 位置位,新任务 TSS 链接域中需填写旧任务的 TSS 选择子(参照 TR 的结构,在 TR 中即可获取旧任务的 TSS 选择子)
  • 将 TSS 描述符加载到 TR 中,通过 TR 读取 TSS 的寄存器数据并载入到各寄存器中。
  • 开始执行新任务。

4.3.3 远跳转(jmp)式的任务切换过程

  • 发现远跳转的操作数是一个 TSS 选择子时,通过 GDT 得知任务门描述符的位置(这个过程太绕了,省略N字。。。),从中获得 TSS 选择子。由于 TSS 描述符是存储在 GDT 中的,因此用 TSS 选择子去访问 GDT,获得 TSS 描述符,这个 TSS 描述符所对应的就是新的任务。
  • 由于是远跳转引起的任务切换,需要进行特权级检查,当前旧任务的 CPL 和新任务选择子的 RPL 等级需要高于或等于 TSS 描述符的 DPL。
  • 检查 TSS 描述符中的 P 是否为 1,即检查描述符是否有效;检查 TSS 描述符中的 B 位是否为 0,即检查新任务是否为忙状态,是否可用。
  • 各种检查通过后,清除旧任务的 B 位,NT 位保持,将旧任务的所有寄存器和 EFLAGS 等统统存储在 TSS 中。
  • 不需要修改 EFLAGS 的 NT 位(即保持原来 NT 位的状态)将新任务 TSS 描述符的 B 位置位
  • 将 TSS 描述符加载到 TR 中,通过 TR 读取 TSS 的寄存器数据并载入到各寄存器中。
  • 开始执行新任务。

4.3.4 中断返回(iret)式的任务切换过程

以防有些读者感到困惑,需要特别声明,这里所说的“旧任务”是中断服务程序,而“新任务”是被中断打断的程序。下同。

  • 执行 iret 指令时,在当前任务的 TSS 任务链接域中获取新任务的 TSS 选择子。由于 TSS 描述符是存储在 GDT 中的,因此用 TSS 选择子去访问 GDT,获得 TSS 描述符,这个 TSS 描述符所对应的就是新的任务。(也是很绕的过程。。。)
  • 由于是远跳转引起的任务切换,不需要进行特权级检查,忽略 DPL。
  • 检查 TSS 描述符中的 P 是否为 1,即检查描述符是否有效;检查 TSS 描述符中的 B 位是否为 0,即检查新任务是否为忙状态,是否可用。
  • 各种检查通过后,清除旧任务的 B 位和 NT 位(表示从中断返回,旧任务完成,嵌套结束),将旧任务的所有寄存器和 EFLAGS 等统统存储在 TSS 中。
  • 不需要修改 EFLAGS 的 NT 位(即保持原来 NT 位的状态)将新任务 TSS 描述符的 B 位置位
  • 将 TSS 描述符加载到 TR 中,通过 TR 读取 TSS 的寄存器数据并载入到各寄存器中。
  • 开始执行新任务。

4.3.5 任务切换总结

各种切换方式的总结:

标志或TSS链接域 | 中断和call | jmp | iret
---|---|---|---|---
新任务段描述符的B位 | 置位,原先必须为0 | 置位,原先必须为0 | 不变,原先必须为1
旧任务段描述符的B位 | 不变,原先必须为1 | 清0 | 清0
新任务EFLAGS的NT位 | 置位 | 设置为新任务TSS中的对应值 | 设置为新任务TSS中的对应值
旧任务EFLAGS的NT位 | 不变 | 不变 | 清0
新任务TSS的任务链接域 | 填写旧任务的TSS描述符 | 不变 | 不变
旧任务TSS的任务链接域 | 不变 | 不变 | 不变

大家都不用去记忆哈,只要理解了这个过程就可以了,毕竟我们是来学习操作系统的,需要知道有这么一回事就行了。原因其一,现代操作系统基本上都用软件来实现这个切换过程了,像上面谈及的硬件任务切换是比较浪费时间的(Intel:老子tm白设计了!);原因其二,以后更深入的操作系统学习中,肯定还会遇见任务切换的,花时间去理解这些远古不用的东西对以后的学习是有帮助的。

5 实例:从BootLoader到任务的加载、重定位和切换

最后一部分是实战环节,给大家看看,一个微型的系统内核是如何载入内存,以及如何管理、运行、切换任务的。当然,这里并不会给出实际的汇编代码,大家可以在《x86汇编语言:从实模式到保护模式》的电子附件中找到源码,其中代码 13-1 和代码 15-1 结合起来,完整地展示了整个过程(这个过程可太复杂了,真的很不容易!)。

5.1 硬盘主引导扇区程序(BootLoader)

先看代码 13-1。

BIOS 在检查硬件没有问题后,从 MBR 中读取启动引导程序,也就是这段代码。它是主机开机后运行的第一个代码,其功能是加载系统内核程序。由于该程序存储在此时处理器工作在实模式下,我们需要切换到保护模式。在此之前,需要为引导程序本身创建数据段、栈段和代码段的描述符,并把它们存到 GDT 中。一切准备就绪后,开启 A20 地址线,通过 CR0 进入保护模式的世界。

接下来及以后的工作都是位于保护模式下了。各段寄存器加载好段选择子,各就各位,然后从硬盘扇区读取内核程序(通常是从第一个扇区开始存储),计算实际代码大小,一个一个扇区的写入引导程序自己的数据段中。

读取完毕后,需要为内核程序建立自己的段,包括建立系统例程段、内核数据段、内核代码段的描述符,写入 GDT 中。完成这一切后,主引导程序也就功成身退了,通过远跳转将运行权交给内核程序。

5.2 内核程序(Kernel)

这部分参见代码 15-1。

内核程序需要做的事情可太多了,用户程序的加载、以及其 LDT、TSS、TCB 的建立全都是它负责的。除此之外,它自己还有一些可供调用的例程(该书作者称系统例程符号表为“C-SALT”,是他自己想出来的新词,我们照用好了),需要生成调用门描述符。内核程序就是一个任务管理器,让我们一个个来看它是怎么完成这一切的。

在内核程序的数据段,存储着 GDT 的基地址和段界限、TCB 的基地址、它自己的 TSS 基地址和选择子、C-SALT 例程的选择子和偏移地址。

5.2.1 安装调用门和TSS

由 C-SALT 例程的选择子和偏移地址构造出调用门描述符,然后写入 GDT 中。由于例程选择子还未构造,需要构造好后重新写回数据段中的相应位置。

创建一段内存,划分为自己的 TSS 空间,注意,系统内核自己是不需要特权级堆栈的,也不需要 LDT 和 I/O 位图。由 TSS 的基地址和段界限创建 TSS 描述符,写入 GDT 中。构造 TSS 选择子,写入数据段中的相应位置。

系统内核自己不需要 TCB,它创建 TCB 链表是为了方便管理其他用户程序。

现在,有关系统内核的东西都准备就绪了。

5.2.2 加载并重定位用户程序

这部分需要做的东西很多,咱们慢慢看。

首先先为用户程序创建一个 TCB,作为 TCB 链表中的第一个任务控制块。

之后,开始加载用户程序。与之前加载内核程序如出一撤,从硬盘扇区读取用户程序,计算实际代码大小,一个一个扇区的写入内核自己的数据段中。

重定位用户程序中使用到的系统例程符号,将这些符号全部替换为系统调用门选择子(RPL = 3)。例如以下用户程序头部中的PrintString, TerminateProgram, ReadDiskData需要替换:

;-------------------------------------------------------------------------------
         ;符号地址检索表
         salt_items       dd (header_end-salt)/256 ;#0x24
         
         salt:                                     ;#0x28
         PrintString      db  '@PrintString'
                     times 256-($-PrintString) db 0
                     
         TerminateProgram db  '@TerminateProgram'
                     times 256-($-TerminateProgram) db 0
                     
         ReadDiskData     db  '@ReadDiskData'
                     times 256-($-ReadDiskData) db 0
                 
header_end:
1. 围绕 TCB 完成一系列准备工作

申请 LDT 所需的空间,将 LDT 的基地址和选择子写入 TCB 中,并将 LDT 描述符写入 GDT 中。

然后建立用户程序的各种段描述符(特权级为 3)和 LDT 选择子:程序头部段(通常是记录代码的长度等信息,与代码段分开便于读取代码段的相关信息)、程序代码段、程序数据段、程序堆栈段,将它们的选择子写入 TCB 中,描述符写入 LDT 中,同时将用户程序头部的各种段符号替换为 LDT 选择子。例如,下面用户程序头部中的符号需要替换:

;===============================================================================
SECTION header vstart=0

         program_length   dd program_end          ;程序总长度#0x00
         
         head_len         dd header_end           ;程序头部的长度#0x04

         stack_seg        dd 0                    ;用于接收堆栈段选择子#0x08
         stack_len        dd 1                    ;程序建议的堆栈大小#0x0c
                                                  ;以4KB为单位
                                                  
         prgentry         dd start                ;程序入口#0x10 
         code_seg         dd section.code.start   ;代码段位置#0x14
         code_len         dd code_end             ;代码段长度#0x18

         data_seg         dd section.data.start   ;数据段位置#0x1c
         data_len         dd data_end             ;数据段长度#0x20

接下来,开始创建特权级堆栈:0 特权级堆栈、1 特权级堆栈、2 特权级堆栈的基地址、选择子和初始 ESP 统统填写在 TCB 中。

2. 创建并完成 TSS

为 TSS 创建空间,填写各特权级堆栈的初始 ESP 和段选择子,以及填写其他一大堆段选择子和寄存器,不再赘述,可参看 TSS 结构图。

将 TSS 描述符写入 GDT 中,将 TSS 选择子写入 TCB 中。

最后,使 GDTR、LDTR 和 TR 生效。此时,TR 存储的是内核 TSS 的基地址和界限。

到此,我们已经成功加载完毕第一个用户程序了,也可视作是第一个任务。加载完毕后既可以选择立即执行,也可以继续加载这个程序或别的用户程序,用来作为第二、三、四...个任务。你可以认为这些任务处于就绪态,任务程序和数据段已经载入内存,什么时候执行这些任务完全由内核来决定。

加载好的 GDT、LDT、TCB 和 TSS 如图所示(我自己画的,请无视红线和红字,因为有误。。。):

image

书中对此描述如下图:

image

3. 准备就绪,开始运行用户程序

执行用户程序,使用call far跳转到用户程序的起始位置。跳转的操作数其实是该用户程序的 TCB 中的 TSS 基地址和选择子。当然,由于某个汇编语法原因,32 位的偏移地址被丢弃,处理器最终得到的是 TSS 选择子。

5.3 用户程序(User)

费了一波周折,终于可以运行用户程序了。不过,如何找到这个程序,也要费一波周折。

;===============================================================================
      [bits 32]
;===============================================================================
SECTION code vstart=0
start:
         ;任务启动时,DS指向头部段,也不需要设置堆栈 
         mov eax,ds
         mov fs,eax
     
         mov eax,[data_seg]
         mov ds,eax
     
         mov ebx,message_1
         call far [fs:PrintString]
         
         mov ax,cs
         and al,0000_0011B
         or al,0x0030
         mov [message_2],al
         
         mov ebx,message_2
         call far [fs:PrintString]
     
         call far [fs:TerminateProgram]      ;退出,并将控制权返回到核心 
    
code_end:

;-------------------------------------------------------------------------------
SECTION trail
;-------------------------------------------------------------------------------
program_end:

5.3.1 运行用户程序的前夜

我们刚刚已从 TCB 中取出 TSS 选择子了,处理器使用这个选择子去访问 GDT,发现对应的是用户程序(即新任务)的 TSS 描述符,处理器就知道准备任务切换了(从内核程序切换到用户程序)。

注意,现在 TR 指向的是 内核的 TSS。处理器先把寄存器(包括内核用不到的 LDTR)都存到由 TR 指向的 TSS 中,作为备份存起来。然后再用新任务的 TSS 描述符读取 TSS,将 TSS 的数据都复制(恢复)到寄存器中(当然这是第一次运行用户程序,用户程序的 TSS 内容什么都没有),其中:由 TSS 中的 LDT 选择子来访问 GDT,获得该用户程序的 LDT 描述符,将其装载到 LDTR 中;TR 指向新的 TSS(GDTR 当然是不变的)。

注意,在加载用户程序时,已在用户程序的 TSS 中设置了 CS(指向了用户代码段)、EIP(指向了用户程序的入口点)。处理器以此来进行任务切换。

5.3.2 用户程序访问自己的数据段

在 5.2.2 节已提及,用户程序中往段寄存器mov的段选择子,实际得到的是一个由操作系统分配好的 LDT 选择子。由该选择子,再根据 LDTR 所指示的 LDT,找到 LDT 中存储的用户程序数据段描述符,最后再根据描述符找到数据段的位置。

(你已经看到这了,是不是觉得很多操作都很绕。。。但保护模式下的处理器确实是这样工作的:选择子->GDT/LDT->描述符->访问段

5.3.3 用户程序调用系统例程

在 5.2.2 节已提及,用户程序中调用的系统例程符号已全部替换为调用门选择子。当用户程序调用例程时,由 GDTR 得知 GDT 位置,再由选择子得知调用门描述符位置,最后由描述符获得例程所在位置。当然,特权检查、堆栈切换的过程也会发生,这里就不再赘述了。

5.3.4 任务的切换

我们以call为例来说明。call 后面的操作数,已在 5.2.2 节的最后提及,得到的其实是 TSS 选择子。因此,内核切换到用户程序的过程与任务切换的过程本质上是一样的,都是“切换”。

由 TSS 选择子,在 GDT 中获得 TSS 描述符,这就是新任务的描述符,指向了新任务的 TSS。在切换任务之前,不需要修改当前任务的 TSS 描述符的 B 位,修改 EFLAGS 的 NT 位置位,然后将当前的寄存器内容全部存到当前任务的 TSS 中。

将新任务 TSS 描述符加载到 TR 中,将新任务 TSS 的内容恢复到寄存器中,跳转到新任务开始执行。

至此,所有操作讲解完毕。

6 写在后面

断断续续花了两个星期的时间,我在边学边写中完成了这篇文章。11.27-12.01 是我第一次接触这部分的内容,范围是《x86汇编语言:从实模式到保护模式》第 11 章-第 14 章。12 月的大部分时间用来期末考和完成课设验收。之后的 12.22-01.05 断断续续写完了这篇文章,同时也学习了《x86汇编语言:从实模式到保护模式》第 15 章和第 16 章的部分内容。最大的感触是,每过一段时间再来看这部分内容,都会有更深入的理解,原本繁杂的体系逐渐变得简单起来。

这是本人在学习操作系统迈出的第一步,保护模式如此复杂,不是三天两头就能学会的,若不是凭着一腔热爱,很难坚持下来。虽然本文许多细节可能以后我们不会关心了,但是,因为好奇和那份执着,我最终还是选择了坚持下来。

第二篇文章的内容将与分页机制有关,会发布的,如果我不鸽的话。

参考内容

  • 《IA-32架构软件开发手册》卷三第3章、第5章
  • 《x86汇编语言:从实模式到保护模式》
  • 《操作系统真象还原》
  • 《x86/x64体系探索及编程》
  • 《Orange's 一个操作系统的实现》
posted @ 2022-01-17 20:15  漫舞八月(Mount256)  阅读(265)  评论(1编辑  收藏  举报