浅析 IA-32 架构的分页机制和中断机制
Posted Mount256
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅析 IA-32 架构的分页机制和中断机制相关的知识,希望对你有一定的参考价值。
浅析 IA-32 架构的分页机制和中断机制
文章目录
本文所涉及的知识可以从《x86汇编语言:从实模式到保护模式》的第 16 章-第 17 章找到。
1 分页机制
在分页模式下,4GB 内存被分为大小相同(4KB)的页,操作系统内核为每个任务创建一个虚拟内存空间(只要不超过 4GB 大小)。
1.1 页目录(PDT)、页表(PT)和页
下面先简单回顾一些基本概念:
- 把 4GB 物理内存分为 1048576 个页(Page),每个页大小为 4KB。
- 随机抽取这些页,将这些页的物理地址组织起来,存到一个页表(Page Table,PT) 中。页表的每一项叫做页表项(PTE),占 4 字节。因此一个页表大小也为 4KB,可以存放 1024 个页表项。
- 再用一个页目录表(Page Directory Table,PDT) 存放这些页表的物理地址,称为页目录项(PDE),占 4 字节,因此页目录表大小为 4KB,可以存放 1024 个页目录项。
- 控制寄存器 CR3 存放当前任务页目录(表)的物理地址,故 CR3 又称为页目录(表)基址寄存器(Page Directory Base Register,PDBR)。
- 页面位映射串,用于指示每个页的位置及分配情况,取决于你所拥有的实际页数,最多可以有 1048576 比特,即 128KB。比如,位 0 对应物理地址为 0x00000000 的页,位 1 对应物理地址为 0x00001000 的页,位 2 对应物理地址为 0x00002000 的页。当某位为 0 时,表示是可以分配的页,并未被其他任务占用;否则就表示这个页已经被占用了,不能再分配给其他任务。
全局图如下:
由上图,需要必须注意的地方(十分重要,可加深对分页机制的理解):
- 每个任务都有这样的层次化结构,即都有自己的页目录和页表,并不是一堆任务只能共用一个页目录表,不是这样的,这就跟我们在讲到任务切换时的情况是一致的。这就意味着,在每个任务的 TSS 中,存放了 CR3 的内容,即任务自己的页目录物理地址。当处理器进行任务切换时,CR3 会被更新为新任务的页目录物理地址。
- 对第一点所谈及的问题进行补充。在分页机制下,由于每个任务都有自己的页目录和页表,这也意味着,每个任务都有 4GB 的虚拟地址空间。看起来内存不够用,但实际上是“虚拟”的,电脑内存不够用,可以暂时挪到硬盘里嘛。
- 已经提及,页、页表、页目录表的大小都是 4KB。所以,无论是页表还是页目录表,均可以看做是普通的页,它们的功能仅与普通的页不同,其他别无二处。因此,在一些情况下,页目录表可视为页表或页,而页表可视为页。你可能觉得这是一句废话,别急,待会我们就能看到,利用这个机制是如何进行页表和页目录的访问的。
- 在分页模式下,分段机制依然起作用,是不能关闭的。虚拟空间内存并不实际存放数据,虚拟空间内存只用来指示内存的映射关系。从我们用户(或软件)的角度看过去,虚拟空间就好像真的用来存放数据,但硬件自己心知肚明这些都是假的,实际情况是硬件已经把这些数据搬到其他地方去了,不一定存储在虚拟空间指示的位置,这也意味着这些数据不一定连续存放!
1.2 页目录项(PDE)和页表项(PTE)
下图即为页目录项和页表项的格式:
- 高 20 位用来存储页目录表或页目录的物理基地址,由于它们的物理基地址的低 12 位一定是零,所以只需存储高 20 位。
- P(Present): 存在位。置位表示页表或页在内存中,否则不在内存中,必须创建或从硬盘调入内存中。
- R/W(Read/Write): 读/写位。置位表示可读可写,否则只读。
- U/S(User/Supervisor): 用户/管理位。置位表示所有特权级别的程序都能访问,否则只允许特权级别为 0-2 的程序访问,3 不能访问。
- PWT(Page-level Write-Through): 页级通写位。和高速缓存有关,“通写”是处理器高速缓存的方式,这里我们直接忽略置零。
- A(Accessed): 访问位。该位由硬件来负责,不用我们管。主要是方便操作系统监视页的使用频率,如果页的使用频率较低而内存空间紧张的时候,将其换到硬盘中,P 位置零,然后将释放的空间马上分配给需要的程序。
- D(Dirty): 脏位。该位由硬件来负责,不用我们管。指示表项指向的页是否写过数据。
- PAT(Page Attribute Table): 页属性表支持位。涉及更复杂的分页机制,这里我们置零。
- G(Global): 全局位。指示该表项所指向的页是否为全局性质的,如果是,那么它在高速缓存中将一直保存。
- AVL(Ignore): 硬件忽略,软件可使用。
1.3 地址变换
在纯粹的分段模型下,段地址 + 偏移量 = 线性地址(虚拟地址),这线性地址即是物理地址(真实地址)。
在分页模型下,段地址 + 偏移量 = 线性地址(虚拟地址),但线性地址并不是物理地址,还需要通过页部件进行地址转换才能得到物理地址(真实地址)。处理器的页部件将 32 位线性地址分为了 3 段,高 10 位是页目录内索引,中间 10 位是页表内索引,低 12 位为页内偏移。
1.3.1 访问页
首先需要明确,段部件产生线性地址,而当前页目录的物理基地址存放在 CR3 中。那么硬件访问页的步骤是(如下图所示,图上方为线性地址):
- 取出线性地址的高 10 位,即页目录表内索引,将其乘以 4,作为偏移量获取页目录项,即
页目录项的物理地址 = CR3 + 页目录表内索引 * 4
。 页目录项存储的是页表的物理地址,好了,我们现在就得到了页表的物理地址。 - 取出线性地址的中间 10 位,即页表内索引,将其乘以 4,作为偏移量获取页表项,即
页表项的物理地址 = 页表的物理地址 + 页表内索引 * 4
。 页表项存储的是页的物理地址,好了,我们现在就得到了页的物理地址。 - 取出线性地址的低 12 位,即为页内偏移,然后
物理地址 = 页的物理地址 + 页内偏移量
,得到了我们最终想要访问的物理地址。
1.3.2 访问页目录
为方便访问页目录,在页目录表中的最后一项,填写的是页目录表自己的物理地址。因为页目录大小为 4KB,所以最后一项的索引为 0xFFC,即 4092。这意味着,页目录表的最后一项对应的整个页表被浪费,这个页表对应的所有页空间也被浪费,因此一共浪费了 1024 * 4KB = 4MB 空间。
要想访问页目录,也必须经过访问页的过程,才能访问到页目录,因此,段部件发出的线性地址高 20 位必须为 0xFFFFF,即线性地址必须为 0xFFFFF!!!(接下来我们使用"!“和”?"来表示任意值)。为什么是这个数值呢?来看下面的过程就知道了(假定 CR3 = 0x???000,为页目录表的物理基地址):
- 首先,取出线性地址的高 10 位(0x3FF),即页目录表内索引,然后得到:
页目录项的物理地址 = CR3 + 页目录表内索引 * 4 = 0x???000 + 0x3FF * 4 = 0x???000 + 0xFFC = 0x???FFC
,从中得到页表的物理地址。实际获取的即是页目录中的最后一个页目录项,该项记录的是页目录表物理地址(即 0x???000),这实际上是把页目录表当成了页表来看待。 - 接着,取出线性地址的中间 10 位(0x3FF),即页表内索引,然后得到:
页表项(页目录项)的物理地址 = 页表(页目录表)的物理地址 + 页目录表内索引 * 4 = 0x???000 + 0x3FF * 4 = 0x???000 + 0xFFC = 0x???FFC
,从中得到页的物理地址。跟之前的结果是一样的,实际获取的是页目录中的最后一个页目录项,该项记录的是页目录表物理地址(即 0x???000),这实际上是把页目录表当成了页来看待。 - 最后,取出线性地址的低 12 位(0x!!!),即页内偏移量,然后得到:
物理地址 = 页(页目录表)的物理地址 + 页内偏移量 = 0x???000 + 0x!!! = 0x!!!
。实际得到的是页目录项的位置。这样,我们就通过这样的方式,在分页模式下,访问到了页目录表中的页目录项。
总结一下:第一个过程,访问页目录表,获得页目录表的基地址;第二个过程,还是访问页目录表,又获得了页目录表的基地址;第三个过程,访问页目录表中的页目录项。
1.3.3 访问页表
从上面访问页目录的过程可见,页目录可以被视为页表和页。硬件是死的,它只会按部就班的进行地址转换,人类欺骗它,它不知道此时找到的页表和页就是页目录。那么,对于访问页表,也是同样的道理,只不过,要比访问页目录要麻烦一点。
线性地址的高 10 位,记录了页表物理地址在页目录表中的登记位置(即页目录表内索引);中间 10 位,记录了页物理地址在页表中的登记位置(即页表内索引);低 12 位,记录了页内偏移量。因此,访问页表,就需要把页表视为普通的页;这样,页表中的页表项就被视为页内的数据了;而页目录也会被视为页表。因此,线性地址的高 10 位必须为 0x3FF;中间 10 位实际上是页目录项索引;低 12 位实际上是页表项索引。 下面为访问过程:
- 首先,取出线性地址的高 10 位(0x3FF),即页目录表内索引,然后得到:
页目录项的物理地址 = CR3 + 页目录表内索引 * 4 = 0x???000 + 0x3FF * 4 = 0x???000 + 0xFFC = 0x???FFC
,从中得到页表的物理地址。实际获取的即是页目录中的最后一个页目录项,该项记录的是页目录表物理地址(即 0x???000),这实际上是把页目录表当成了页表来看待。 - 接着,取出线性地址的中间 10 位,然后得到:
页表项(页目录项)的物理地址 = 页表(页目录表)的物理地址 + 页表内索引 * 4 = 0x???000 + 页表内索引(页目录内索引) * 4 = 0x???000 + 0x!!! = 0x!!!
,从中得到页的物理地址,实际获取的是页表的物理地址。页目录在这时发挥了应有的作用。 - 最后,取出线性地址的低 12 位,即页内偏移量,然后得到:
物理地址(页表项的物理地址) = 页(页表)的物理地址 + 页内偏移量
。这样,就访问到了页表内的页表项了。
总结一下:第一个过程,访问页目录表,获得了页目录表的基地址;第二个过程,还是访问页目录表,不过此时获得的是页表的基地址;第三个过程,访问页表中的页表项。
1.3.4 为什么这么麻烦呢?
你可能觉得,我直接通过物理地址去访问页表和页目录不就好了?干嘛这么麻烦。请思考一个问题,我们为什么需要访问页表和页目录?因为在内核运行的时候会有新任务创建,所以需要修改表中的项。但是,初始化进入内核之后就已经位于分页模式下了,所有操作都必须在分页模式下完成,如果再用物理地址直接访问页表和页目录那是不行的,所以只能使用线性地址去访问。
1.4 转换后援缓冲器(TLB)
转换后援缓冲器(Translation Lookaside Buffer,TLB),又可称为“转换旁路缓冲器”、“转换后备缓冲区”、或者是更为人熟知的“快表”,它把页表项预先存放到处理器中,可以加快地址转换速度。
TLB 的结构分为两个部分:第一部分是标记,内容是线性地址的高 20 位,第二部分是页表数据,包括访问权、属性和页物理地址的高 20 位。如下图所示:
工作原理:当段部件发起一个线性地址时,处理器用线性地址的高 20 位查找 TLB 中的每一项,,与高 20 位比较,如果找到匹配项,则直接使用这一项后面的数据部分作为物理地址进行访问;如果找不到,则需要按上面提及的地址变换流程得到物理地址,将它写到 TLB 中,以备后用。
所以这会出现一个问题:如果我们修改了页表项,TLB 是不会跟着修改的。这样当处理器获得一个线性地址时,先去 TLB 匹配,最后得到的是一个错误的物理地址。因此,我们需要在修改页表项之后,刷新 TLB。当然,改动 CR3 的内容一样也可以使 TLB 得到更新。
1.5 别了,多段模型——平坦模型的到来
分页机制的到来,彻底终结了对于分段机制的合理性的争论。因为,多段模型对内存的访问虽然加强了对段的保护,但访问起来实在是太麻烦了,方便了处理器的内存访问,但受苦的是程序员。别了,多段模型!平坦模型的到来,让受苦的程序员彻底解放了生产力。在之后的实例中,我们将看到,无论是内核,还是用户任务,都使用平坦模型,多段模型的时代已经彻底终结了。
所谓平坦模型,即不对内存进行分段,但并不意味着真的不分段。在 Intel 处理器中,分段是固有机制,不可能避开这种机制,但是我们可以将全部 4GB 内存作为一个大段来处理,这样,所有段的大小都是 4GB,段的基地址都是 0x00000000,段界限为 0xFFFFF,粒度为 4KB。段保护机制依然进行,但不会再报错了。
2 中断机制
中断和异常的作用是指示系统中的某个地方发生了一些事情,需要引起处理器(包括正在执行的程序和任务)的注意。当中断和异常发生时,典型的结果是迫使处理器将控制从当前正在执行的程序或任务转移到另一个例程或者任务中去。该例程叫做中断处理程序或异常处理程序。如果转移到了任务,那么就是任务切换。
对于某些异常,处理器会在转入异常处理程序之前,会在当前栈中压入一个称为错误代码的数值,帮助程序进一步诊断异常产生的位置和原因。
2.1 中断机制的基本组成
2.1.1 中断描述符表(IDT)
在实模式下,中断向量表(IVT)只能位于内存最低端的 1KB 内存。而在保护模式下,中断向量表变成了中断描述符表(Interrupt Descriptor Table,IDT),它不要求必须在内存的最低端,在这个表里,保存着的是和中断处理过程有关的描述符,包括中断门、任务门和陷阱门。因为处理器最多支持 256 种中断,因此中断描述符表 IDT 最大为 2KB。和 GDT 不同,IDT 的第一个描述符是有效的。
2.1.2 中断描述符表寄存器(IDTR)
和 GDT 一样,IDT 也需要有一个寄存器来保存它的基地址。48 位的中断描述符表寄存器(Interrupt Descriptor Table Register,IDTR)保存着 IDT 的线性基地址和界限。因为整个处理器只需要一个 IDT,所以,跟 LDTR 和 TR 不同,没有选择器部分。
2.1.3 中断门
调用门、任务门、中断门的格式都非常相似,包括 16 位的代码段选择子、32 位的段内偏移量,唯一能区别他们的是 TYPE 字段,而且只差了一个比特。
中断来临时,EFLAGS 的 NT 位和 TF(Trap Flag,陷阱标志位) 位会被置 0。如果中断对应的门描述符是中断门,EFLAGS 的 IF 位置 0,以避免中断嵌套,否则会引起 #GP (一般保护性)异常。如果中断对应的门描述符为任务门或陷阱门,EFLAGS 的 IF 位不会置 0,即允许中断嵌套。
2.2 中断和异常处理程序的控制转移
2.2.1 特权级保护
如果中断由外部设备和异常引起:
- 先从 IDT 中获取对应的中断门/任务门/陷阱门描述符。
- 不必检查门描述符 DPL,直接由门描述符获得选择子,在 GDT 中取得目标代码描述符。
- 检查目标代码描述符 DPL 特权级别是否低于当前特权级别 CPL,若是,可以转移,否则抛出异常。
如果中断由软中断 int n、int3 和 into 引起:
- 先从 IDT 中获取对应的中断门/任务门/陷阱门描述符。
- 检查门描述符 DPL 特权级别是否低于当前任务特权级别 CPL,若是,检查通过。
- 直接由门描述符获得选择子,在 GDT 中取得目标代码描述符。
- 检查目标代码描述符 DPL 特权级别是否低于当前特权级别 CPL,若是,可以进行转移控制,否则抛出异常。
因此,无论是什么情况,特权级只能由低特权转移到高特权级。
2.2.2 栈的切换
如果中断处理程序的特权级别与当前特权级相等(如图 a):
- 不用切换栈。
- 处理器把 EFLAGS、CS 和 EIP 的当前状态压入当前栈中。
- 对于有错误代码的异常,处理器还要把错误代码压入当前栈,紧挨着 EIP。
如果中断处理程序的特权级别低于当前特权级(如图 b):
- 需要切换栈,从当前任务的 TSS 中取得栈段选择子和栈指针,处理器把旧栈选择子和栈指针压入到新栈中。
- 处理器把 EFLAGS、CS 和 EIP 的当前状态压入当前栈中。
- 对于有错误代码的异常,处理器还要把错误代码压入当前栈,紧挨着 EIP。
2.3 使用中断机制实现任务切换
当中断和异常发生时,如果根据中断向量从 IDT 中找到的描述符是任务门描述符,则不是中断处理,而是任务切换。使用中断,可以实现一个抢占式的多任务系统。
在中断机制中使用任务门可以获得以下好处:
- 被中断的任务的所有状态会被保存在 TSS 中。
- 由于是切换到一个新的任务,所以有独立的地址空间。
不利的因素也很明显:
- 受制于硬件,需要保存大量状态,速度很慢。
- 若有些中断和异常定义了错误代码,则处理器还会把错误代码压入栈中,处理起来有点麻烦。
注意:
- 在进入中断之后,必须关中断,以防止中断嵌套而引起 #GP 异常。
- 通过中断进行的任务切换,不需要检查任务门描述符 DPL,也不需要检查目标代码段的 DPL。
3 实例:从 BootLoader 到 任务调度
接下来,参考《x86 汇编语言:从实模式到保护模式》的代码 17-1 和代码 17-2,一起简单回顾整个系统内核的启动和工作流程。
3.1 硬盘主引导扇区程序(BootLoader)
主引导程序的加载位置是 0x00007C00,由于待会使用的是平坦模型,因此 EIP 的初始内容必须为 0x7C00。
代码 17-1 完成的事情有:
- 在地址 0x00008000 处,为内核程序创建代码段、数据段、堆栈段描述符(数据段和堆栈段共用),基地址均为 0,界限为 0xFFFFF,DPL = 0。加载 GDTR。
注意到,我们没有为主引导程序创建单独的段描述符。事实上,这确实没必要,本程序连自己的栈都没有使用过。
- 打开 A20 地址线,开启保护模式。
- 使所有段寄存器指向 4GB 数据段(平坦模型),除了 ESP(堆栈指针)被赋值为 0x7000,说明栈是从地址 0x00007000 开始向下推进的。
- 加载系统内核程序到 0x00040000 处。
- 创建并填写系统内核的页目录表 PDT。
物理地址 0x00020000 作为页目录表的基地址,在表的最后一项填写了页目录表的物理地址;在表偏移量为 0x800(正好位于表的中间位置) 写入了页表的物理基地址 0x00021000(表项为 0x00021003)。
值得一提的是,在《x86 汇编语言:从实模式到保护模式》的代码 16-1 中,页目录表的第一项也填写上了这个页表地址。为什么这里就没有必要呢?因为代码 16-1 是进入内核才开启分页,而我们这里是在主引导中开启分页。代码 16-1 的情况就比较麻烦了。
我们计划:每个任务的虚拟内存的低 2GB 空间(0x00000000-0x7FFFFFFF)是局部地址空间,是任务自己的私有内存空间;每个任务的虚拟内存的高 2GB (0x80000000-0xFFFFFFFF)空间是全局地址空间,全局空间是所有任务共有的,内核是所有任务共用的,就应该映射到此处。因此,页目录表的前半部分指向任务自己的页表(此时前半部分为空),而后半部分则指向内核的页表。 注意,页目录表和页表不属于任何一个空间,只有页(即虚拟空间)才能被归为局部或全局空间。
- 创建并填写系统内核的页表 PT。
物理地址 0x00021000 作为页表的基地址,在表中仅填写了前 256 个表项,即登记前 256 页的物理地址,设置为 US=0,RW=1,P=1。这是因为,内核占据的栈段、代码段、页目录、页表、GDT和主引导程序的代码都位于整个物理内存的最低端 1MB 内,正好是 256 页的大小。
创建好 PDT 和 PT 后的映射关系如下图:
- 让 CR3 指向页目录(将 0x20000000 赋值给 CR3),开启页功能。
- 将 GDT 映射到虚拟内存的高端,即 GDT 的线性地址 = GDT 的物理地址 + 0x80000000。别忘记将栈指针 ESP 也映射到高端。
- 开启分页机制。
- 跳转到内核程序(跳到 0x80040004)。
因为内核代码段开始的虚拟地址为 0x80040000,入口处位于代码段起始偏移量为 0x00000004 处,因此跳转到 0x80040004。
上图即为主引导程序运行完毕后的内存图。注意图中的任务 TCB 和 TSS 是进入内核程序中才会出现的(也注意到,内核的 TCB 位于内核的数据段中,而任务 TCB 是独立出来的),它们都必须位于全局空间,这里先提前画了出来。大家还会发现内存中还有空的位置,这些空的页可以用来分配给用户程序,只要在页目录表中将它们映射到局部空间即可,页在物理内存的位置其实不用关心。
3.2 内核程序(Kernel)
接下来,请参考代码 17-2。
3.2.1 创建 IDT
- 在物理地址 0x0001F000 处(线性地址 0x8001F000)创建 IDT。
- 在 IDT 中安装前 20 个异常中断处理过程的中断门描述符(都指向通用异常处理程序)。
通用异常处理程序在屏幕上显示错误信息,然后停机(hlt)。
- 在 IDT 中安装剩余的异常中断处理过程的中断门描述符(都指向通用中断处理程序)。
通用中断处理程序向 8259A 芯片发送中断结束命令 EOI(End Of Interrupt),然后执行 itetd 指令返回。
- 编写实时时钟 RTC 中断处理过程,创建该中断门描述符表,并填在 IDT 的 0x70 索引处。
因为使用定时中断来进行任务切换,所以需要详细分析 RTC 的中断处理过程,位于 3.2.2 节。
- 初始化 8259A 芯片。
- 用 sti 指令设置 EFLAGS 的 IF 位,开放硬件中断。
- 安装系统的调用门,即为 C-SALT 表安装对应的调用门选择子,在 GDT 中安装调用门描述符。
3.2.2 定时中断实施任务切换
定时中断处理程序完成的是任务调度和任务切换。那么我们定义了一个 TCB 链表,用于记录当前有哪些任务已经被加载完毕。下图即为一个 TCB 节点的结构,偏移量为 0x00 的地方为下一个 TCB 的线性地址,偏移量为 0x04 的地方记录的是任务的状态,这是一个字,若是 0x0000,表示这是一个空闲的任务,或是一个挂起的任务;若是 0xFFFF,表示这是一个正在运行的任务。TCB 链表中只允许一个正在运行的任务,其他任务不能同时为正在运行状态。
TCB 链表中节点的顺序已经隐含了任务优先级的概念,即排在最前面的更有可能被优先执行,排在后面的更迟被执行。为什么这么说呢?看看下面的操作步骤就知道原因了。
定时中断处理程序完成的工作如下:
- 遍历 TCB 链表,找到正在运行的任务(即状态值为 0xFFFF),如果找不到,跳到最后一步。
- 如果找到了,则将该节点移到链表的末尾处,使其成为最后一个节点。把它移到链尾,是因为可以确保被调度的优先级为最低。
- 再次遍历 TCB 链表,寻找链表中第一个空闲任务(即状态值为 0x0000),如果找不到,跳到最后一步。
- 如果找到了,将正在运行的任务 TCB 状态值设为 0x0000,把该空闲任务 TCB 状态值设为 0xFFFF。
- 使用 jmp 指令从当前任务切换到该空闲任务。
- 执行 iretd 指令,中断返回。
注意,每当该程序运行时,就表明处理器已经在全局空间下工作了。这时使用的是 0 特权级别的栈,不会与用户程序发生冲突。
我们实现了一个很简单的任务调度算法,虽然有不合理之处,但对于初学者而言,还是很振奋人心的。
3.2.3 创建内核任务的 TCB 和 TSS
接下来为内核也创建一个任务,你可以将其视作空转任务,即当没有任务的时候就会运行内核的空转任务。
- 内核 TCB 存储在内核的数据段中,不需要另外为其分配空间。
- 内核 TCB 任务状态设为忙碌(0xFFFF)。填写内核 TCB 的空间分配的线性基地址 0x80100000,内核虚拟空间的分配从这里开始(可参考 3.1 节的最后一张图,找到内核任务存放的虚拟空间位置在哪)。
- 将此 TCB 加入 TCB 链表中(尽管此时是空链表)。
- 首先需要关闭中断,遍历整个 TCB 链表,找到最后一个 TCB,修改其链接域,使其指向下一个 TCB 的线性基地址。位于链尾的新 TCB 的链接域需要为 0。
- 最后,开中断。
然后需要创建内核任务的 TSS:
- 需要为 TSS 分配一个空间,在分页模式下是为其分配一个 4KB 页。
- 在 TSS 中设置必要的项目。
- 创建 TSS 描述符,安装到 GDT 中,TSS 线性基地址写入 TR 中。
3.2.4 分配一个页
现在为 TSS 分配一个 4KB 页。当然,这个分配页的程序也可以被其他任务调用。它是一个系统例程。
- 从 TCB 的空间分配线性基地址中得到需要分配页的线性地址(0x80100000),首先需要检查该线性地址所对应的页表是否存在:访问页目录表,且由线性地址取得页目录表内索引,获取对应的页目录项。
在分页模式下,如何访问页目录表的方法之前已经介绍过,这里不再赘述。
由于在主引导程序中,已经创建好页目录表,所以内核不需要再创建一个,但是页表和页需要内核自己创建。对于任务的页目录表,则只能由内核帮忙创建,任务的页表和页可以由任务自己或内核创建。
- 检测页目录项的 P 位是否为 1,如果不是,说明页表不存在,则需要分配一个 4KB 页作为页表。
- 使用页面位映射串看哪个页没被分配,将空闲页找出来,将它的物理基地址写入到页目录表中。该 4KB 页即作为页表。
- 访问这个新创建的页表,由线性地址取得页表内索引。
在分页模式下,如何访问页目录表的方法之前已经介绍过,这里不再赘述。
- 分配一个 4KB 页,这个页才是我们真正需要的页。通过搜索页面位映射串,找到空闲的页,并将页的物理基地址及属性写入对应页表项中。
至此,分配页的过程完成。
3.2.5 创建用户任务
- 创建用户 TCB,先在内核空间中分配一个页。由于该任务还没有页表,所以也会一并创建。注意,此时 CR3 的值未改变,创建任务时 CR3 依然指向全局空间。所以,我们是“借用”内核的页目录表去创建任务的。
按一般的思路,我们可以先让内核创建用户任务的页目录表,然后临时改变 CR3 的内容,使其指向这个新的页目录表,然后在这个新表上填写内容,最后任务创建完毕再切换到内核的页目录表。但是,新的页目录表是空的,什么都没写,这样一变,肯定引发异常。所以,我们只能保持在内核的页目录表上工作,涉及任务局部空间的修改,只在内核页目录表的前半部分进行。修改完后,将内核的页目录表复制到任务的页目录表中。所以,内核的局部空间,就是用来作为任务创建时的过渡。这意味着在每次任务创建时,内核的局部空间都会发生改动。
- 清空页目录表的前半部分(前 512 项页目录项,对应低 2GB 的局部地址空间)。显式刷新 TLB。
- 初始化用户任务的 TCB。需要填写的项包括 LDT 界限值、以及从哪个线性地址开始在用户任务的局部空间内分配内存(线性地址一般为 0x00000000)。任务的状态值为 0x0000。最后将该 TCB 添加到 TCB 链表中。
- 加载用户程序。需要为用户程序的局部空间分配页,用来存放代码段、数据段、栈段、LDT。在全局空间分配页,用来存放任务 TSS。
注意,在局部空间中分配页,可参照 3.2.4节,也是从 TCB 的空间分配线性基地址中得到需要分配页的线性地址(对于该任务 TCB,得到的线性地址为 0x00000000,位于局部空间)。之后的步骤相同。
- 重定位 U-SALT。
- 在 GDT 中登记 LDT 描述符。
- 填写 TSS。
- 刚刚已经提及了,我们是“借用”内核的页目录表去创建任务的,任务自己还没有页目录表呢。所以,需要创建任务的页目录表。一个最简单的办法是:继续“借用”内核的页目录表,创建一个 4KB 页,作为该任务的页目录表。为了能访问到该页,我们将它的物理地址写入当前(即内核)页目录表的倒数第 2 个目录项。得到两个表的线性基地址后,可以将当前页目录表复制到新的(即任务)页目录表了。这意味着,每次任务创建时,当前页目录表的倒数第 2 个目录项总会被修改,修改后的页目录表会被复制到新的页目录表中。这个新页目录表的全局空间是内核空间,局部空间是任务自己的空间。 再次强调,无论是内核的、还是任务的页目录表和页表,都并不属于任何空间!
- 将新页目录表的物理地址也填写到 TSS 中。
- 将新任务 TCB 加入 TCB 链表中。
至此,用户任务的虚拟地址空间分布如下(我这里画的页表项指向虚拟内存其实不准确,请参考下面的图):
为什么全局空间才用一个页表?因为内核的全部数据加起来连 1MB 都不到,你说需要那么多页表吗?而用户任务的代码很长很长,可能超出了 1MB,所以可能会需要用到多个页表。
加载完毕后的映射关系如下:
3.3 任务切换
现在,一切准备就绪,等待中断到来。在此之前,TCB 链上只有一个状态为忙的内核任务。一旦 0x70 号中断来临,便会发生:
- 执行中断处理程序。
- 使正在忙碌的内核任务 TCB 节点移到 TCB 的链尾。
- 在 TCB 链表中寻找第一个空闲任务。将内核任务 TCB 状态值改为空闲,将该空闲任务状态值改为忙碌。
- 使用 jmp 进行任务切换,得到的是 TSS 选择子。
- 硬件将内核任务的状态保存到当前 TSS 中,接着,找到用户任务的 TSS,加载状态,跳转到任务入口处,开始执行。
- 待下一次中断又发生时,重复以上步骤。
好了,全过程已经讲解完毕。不知道大家有没有搞懂。
4 写在最后
其实早在 1.15 的时候,我已经读完了《x86汇编语言:从实模式到保护模式》(下简称《保护模式》)这本书,但后面又花了好几天去细细研究了书中的代码,终于在今天写成这篇文章。本文的大部分内容都是自己思考而来的,当然也借鉴了其他书籍。这几天也在学实时操作系统 uCOS,所以耽误了一些时间成文。不过,在学习 uCOS 时再来回头看这部分内容,竟有种殊途同归的感觉。同时,我觉得《保护模式》在刚刚提及任务调度的话题就完书,有点戛然而止,让人意犹未尽。
现在,让我回想一下在这些年的经历吧。
遥想高一时,我加入了学校的科普协会,无意之中遇见了创客团队的老师,加入了他们的团队,从此我拾回了编程的这条道路,也让我第一次认识到单片机,这对于我来说,还是个非常新奇而又陌生的东西。我把之前荒废的C语言重新温故了一遍,甚至还把初中时期没有搞懂的指针部分都拿下了,这对当时的我而言,无疑是最大的鼓舞,也使我更迫切的想知道计算机更多的知识。在学习指针时,我一步步进行程序调试,开始明白内存和代码的关系,开始发现变量和地址背后的真相,在这个过程中,我也开始逐渐对计算机有了一种更深入的认识。我渴望了解计算机背后的秘密,我渴望了解 CPU 的原理,我渴望了解操作系统是如何运行的,我很想知道单片机为什么能操控这么多东西。只可惜,因高中学业繁重,我并没有许多时间去研究这些奥秘。但是,这颗求知的好奇心却不知不觉地埋藏在了我心里。星转斗移,随着时间的流逝,这个梦想也渐渐被我遗忘了。
高考结束,填报志愿,最后去的专业与 EE 相关,虽然有我感兴趣的东西,但绝大部分我都不怎么感兴趣,也就没怎么学懂,再加上自己动手能力实在不堪,焊个电路都十分费劲。看着一些同学在学业和竞赛方面取得的成就,我心有不甘,却又无可奈何。我性格很内向,不善言辞,自然也错过了很多机会。直到大二下学期的那一门单片机课,上的全是汇编,老师也没有仔细讲语法,因此我也听不懂。我终于忍无可忍,买来一本王爽的《汇编语言》,却没曾想,此书为我打开了一扇大门,我不仅很快学会了汇编,也从此带我走进了 CPU 的世界。当我读完这本书,发现还有《保护模式》时,我也当机立断地买来了。只可惜,那年暑假,我在读 CS:APP,因为,《保护模式》这本书在当时看来,还是有点难懂。我深信,很多时候,学习一门知识,你学不学得会,完全是六分天意,四分努力。去年 11 月底,我无意翻开了这本书,谁曾想,读着读着,我竟然慢慢看懂了,从实模式,接下来,是分段机制、再接下来,是描述符、再接下来是保护机制、TCB、TSS。。。短短几天,我窥探了现代 CPU 和操作系统的雏形,这变化让我欣喜不已,同时也告诉我,没有什么事是不可能,一个非科班的人也能搞明白操作系统的内核。我回想起这两个月来在这方面的深耕,虽经历坎坷,历经多次课设和期末的侵袭,但依然坚持了下来。那个梦想,只是被遗忘了,它没有消失。
现在,我尝试拾起了多年的梦想。我深知,我不是科班的同学,在许多方面,例如操作系统、数据结构方面,没有他们强;而且,也正因为不是专业相关,我也很难清楚学习这些内容对我以后工作、乃至人生有多大帮助(可能没啥用吧。笑),我不知道这条路还能走多远(我也不知道,我要不要考研计算机)。但是,每当我回想高中时,我第一次彻底搞懂指针的那个夜晚,那个梦想就不会消失。抚摸着内心,我一次又一次地问自己:你为什么奔跑?因为,心中有梦,就要奔跑着去追寻。
(关于《保护模式》这本书的两篇学习记录,已全部更新完毕)
参考内容
- 《IA-32架构软件开发手册》卷三
- 《x86汇编语言:从实模式到保护模式》
- 《操作系统真象还原》
- 《x86/x64体系探索及编程》
- 《Orange’s 一个操作系统的实现》
以上是关于浅析 IA-32 架构的分页机制和中断机制的主要内容,如果未能解决你的问题,请参考以下文章