操作系统开发入门,第三部分






4.98/5 (22投票s)
描述符表和中断。
引言
- 环境设置
- C++ 支持代码和控制台
- 描述符表和中断
- 实时时钟、可编程中断定时器和键盘控制器
本文是系列文章的一部分。它可以独立阅读,但如果先阅读前两部分,会更有意义。本文我们将介绍描述符表和中断;通过本文的结论,您将能够处理内部和外部中断,并将拥有在不同环中运行代码所需的一些基础设施。我们将从全局描述符表开始。
GDT 是 i386 保护系统的重要组成部分。它允许我们定义内存某些部分的行为和权限。这使得内核能够在进程尝试违反这些约束时处理异常,并通过某种方式杀死进程(通过将其地址提供给“回收线程”来终止进程,或直接终止进程)。所有内存访问都通过给定的 GDT;因此,它是抵御恶意计算机程序的第一道防线。
GDT 是条目集合,每个条目长 64 位,用于告知处理器内存段的信息。分段是一种组织内存的方法;它在很大程度上已被分页取代(大多数 C 编译器更好地支持分页,只支持固定内存模型)。然而,它比分页有一个显著的优势——它可以设置特权级别。此外,某些指令需要分段才能工作,因此我们将设置两个覆盖整个内存的大型代码和数据段。
我以前曾提到过这一点,但尽可能地理解这一点会有所帮助。x86 和 x86-64 处理器架构使用环的概念来实现安全性。x86 处理器架构支持环 0、1、2 和 3,而 x86-64 只支持环 0 和 3。环号越低,可访问的指令越多。内核的核心将始终在环 0(主管模式)中运行,因为它经常需要运行需要访问特权指令的代码。根据操作系统的设计,内核的其他部分可能会出于安全目的与用户创建的程序一起在环 3(用户模式)中运行。
如您所见,通常只使用可能环的一半。然而,一些操作系统在环 1 和/或 2 中运行设备驱动程序。这可以防止它们访问内核代码,因此需要提供其他方法使它们能够工作。其中一些方法是系统调用、SYSENTER
和 SYSCALL
。
让我们回到段的概念。如果您以前从事过汇编代码工作,您会知道有六个段寄存器。我们感兴趣的两个是 CS
和 DS
。它们分别代表代码段和数据段,并为 CPU 提供执行或存储代码或数据所需的访问权限。现在,这意味着我们必须在 GDT 中为任何引用这两个寄存器的指令设置一个代码段和一个数据段,以便它们正常工作。
如果那么简单,那么我们就能直接编写一些实用的代码了。但请记住,一个段也包含访问权限。由于我们最终希望在环 3 中运行代码,因此我们也需要为 GDT 提供这些条目。所以,总而言之,我们需要五个条目;空条目、内核模式代码、内核模式数据、用户模式代码和用户模式数据。
现在,这一切听起来可能相当简单。我们只是给处理器一个包含五个条目的列表,让它了解代码和数据的权限。但是,我们必须提供更多。这五个条目中的每一个都必须有粒度和访问字段。这些字段具有非常特定的位布局,如下所示
位 |
字段大小(位) |
描述 |
0 : 3 |
4 |
段长度的位 16:19。 |
4 |
1 |
始终为 0。 |
5 |
1 |
始终为 0。 |
6 |
1 |
操作数大小。如果设置,则使用 32 位操作数;否则,使用 16 位操作数。 |
7 |
1 |
粒度。如果设置,粒度为 4 KiB;否则,粒度为 1 字节。 |
位 |
字段大小(位) |
描述 |
0 : 3 |
4 |
条目类型 |
4 |
1 |
描述符类型;始终为 1 |
5 : 6 |
2 |
CPU 环 |
7 |
1 |
如果设置,段存在 |
如您所见,这两个字段都是一个字节长。现在,我们可以为它们设置结构。但是,我们每个处理器只使用一次,所以这样做没有太大意义。另一方面,GDT 条目足够重要,值得拥有自己的结构。我们稍后会介绍这些。首先,我们需要知道粒度字节的必要值。基本上,除了四和五之外,所有位都已设置。这意味着我们使用 32 位操作数,并使用 4 KiB 页而不是单个字节。这种设置在 32 位奔腾模型(使用分段和分页)上是正常的。最终的二进制值是 11001111,或十六进制的 0xCF。
访问字节略有不同。因为我们要设置四个正确的段,所以我们需要根据段是用于代码还是数据以及特权级别略微更改这些值。为了使事情更简单,我将它们放在一个表中,显示所需的值。
|
代码 |
Data |
用户模式 |
11111010 (0xFA) |
11110010 (0xF2) |
内核模式 |
10011010 (0x9A) |
10010010 (0x92) |
这相对简单。用户模式段将左数第二位和第三位设置为表示用户模式,或 CPU 环为 3(基本问题——为什么只需要两位来表示 CPU 环?)。数据将第四位设置为更改条目类型。
现在我们已经有了这些值,我们只需要告诉处理器它们。为此,我们使用 GDT 条目,其格式如下所示
位 |
字段大小(位) |
描述 |
0 : 15 |
16 |
限制的低 16 位 |
16 : 31 |
16 |
基址的低 16 位 |
32 : 39 |
8 |
基址的中间 8 位 |
40 : 47 |
8 |
访问标志 |
48 : 55 |
8 |
粒度字节 |
56 : 63 |
8 |
基址的最后 8 位 |
如我们所见,这些位都对应于无符号短整型和无符号字符的大小。多么方便!我们可以将其表示为如下结构
struct GDTEntry
{
ushort LimitLow; //The lower 16 bits of the limit.
ushort BaseLow; //The lower 16 bits of the base.
uchar BaseMiddle; //The middle 8 bits of the base.
uchar Access; //Access flags.
uchar Granularity;
uchar BaseHigh; //The last 8 bits of the base.
} __attribute__((packed));
现在,像大多数编译器一样,G++ 会尝试重新排列 GDT 条目的布局。我们不希望发生这种情况,因此我们应用 `packed` 属性来防止这种情况。您还会注意到我们没有给出限制的高 16 位;这是因为每个段的限制高 16 位都相同,所以我们可以在向 CPU 提供 GDT 指针的同时提供它。作为一点兴趣,Base 字段为了与 Intel 2086 兼容而散布在各处,Intel 2086 有一个 24 位的 Base 字段。
我们已经创建了 GDT。现在,我们只需将其传递给处理器。这是一个简单的过程,只需要一个简单的结构,其位布局如下
位 |
位计数 |
描述 |
0 : 15 |
16 |
每个 GDT 条目的总大小 |
16 : 47 |
32 |
第一个 GDT 条目的地址 |
我希望你能从此创建结构布局。不要忘记 packed
属性。正如你稍后会注意到的,每个描述符指针都有这种格式。
您不会在这里找到太多实际代码。这不是学前班——您必须自己编写。您可以在下面找到一些伪代码,但我将逐字提供一个汇编例程。尽管如此,仍然需要理解它;它有一些细微但关键的行为。要设置我们的 GDT,我们需要做类似这样的事情
GDTEntry gdt[5] ={
//NULL segment, sometimes used to hold the GDT address
{.LimitLow = 0, .BaseLow = 0, .BaseMiddle = 0, .Access = 0,
.Granularity = 0, .BaseHigh = 0},
//Kernel-mode code
{.LimitLow = 0xFFFF, .BaseLow = 0, .BaseMiddle = 0,
.Access = 0x9A, .Granularity = 0xCF, .BaseHigh = 0},
//Kernel-mode data
{.LimitLow = 0xFFFF, .BaseLow = 0, .BaseMiddle = 0,
.Access = 0x92, .Granularity = 0xCF, .BaseHigh = 0},
//User-mode code
{.LimitLow = 0xFFFF, .BaseLow = 0, .BaseMiddle = 0,
.Access = 0xFA, .Granularity = 0xCF, .BaseHigh = 0},
//User-mode data
{.LimitLow = 0xFFFF, .BaseLow = 0, .BaseMiddle = 0,
.Access = 0xF2, .Granularity = 0xCF, .BaseHigh = 0}
}
DescriptorPointer pointer;
void GDT::SetupGDT()
{
pointer.Limit = sizeof(GDTEntry) * 5 - 1;
pointer.Address = (uint)&gdt;
Processor_SetGDT((uint)&pointer);
}
我们将 GDT 指针的 Limit
属性设置为 GDT 的总大小。这里不应该有任何惊喜——这很基本。不过,Processor_SetGDT
方法可能有点棘手(不要忘记声明中的 extern "C"
指令,否则您将面临连接器引起的痛苦)。要理解这一点,您需要精确理解 CS
和 DS
是什么。
简单地说,它们是偏移量。CPU 将 CS
添加到 GDT 地址(即 GDT,而不是 GDTPointer),结果是 GDT 条目的开头。从那里,它可以检查代码权限,并确保正在执行的是代码,而不是数据。该过程与 DS
几乎相同。唯一的问题是 GrUB 的 GDT 可能不同,因此 CS
和 DS
可能会指向其他地方。仔细想想,这意味着如果我们不更改这些寄存器,我们可能认为是环 3 代码的实际上正在环 0 中运行。所以我们需要更改这些。C++ 不允许我们本机执行此操作,因此我们必须降级到汇编语言。此代码片段非常重要,因此下面逐字复制
[GLOBAL Processor_SetGDT]
Processor_SetGDT:
mov eax, [esp+4]
lgdt [eax]
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x8:.codeFlush
.codeFlush:
ret
现在仔细观察。首先,我们将 EAX
设置为第一个参数,并执行 LGDT
指令,该指令实际上将表加载到处理器中。然后,我们将值 0x10 移入 EAX
的低 16 位,并将 DS
、ES
、FS
、GS
和 SS
设置为这些低 16 位。这完成了加载数据和堆栈段的简单操作。然后,我们执行一个跳转。但这不仅仅是一个普通的跳转;我们明确声明 CS
为 0x8。
理解我们为什么将 CS
和 DS
分别设置为 8 和 16,以及当我们使用内存地址时处理器如何工作,可能对我们很有用。事件的一般顺序如下
- 我们引用一个内存地址(无论是获取下一条指令还是解引用一个指针)。
- 处理器查看 GDT 指针,特别是最后 32 位。
CS
被添加到 GDT 指针的最后 32 位,并且该加法的结果被解引用。- 此时,CPU 拥有相关的 GDT 条目,并可以获得特权级别等。
- 如果违反了保护级别,则会引发异常。
通过查看这一系列事件,我们可以推断 CS
指向内核级代码,而 DS
指向内核级数据。这并不太困难,但需要一点考虑。
IDT
GDT 并不是普通台式计算机使用的唯一描述符表。除了一两个其他描述符表之外,它还使用 IDT。我意识到上一节的理论性很强,所以我将尝试稍微分解一下。简而言之,IDT 是一个连续的数组,由中断号索引。当发生内部或外部事件时,处理器可以使用它向内核发出信号。
进入处理器的事件可以是内部的,也可以是外部的。外部事件来自独立的硬件,例如键盘、鼠标、计时器或 PCI 设备。它们通过可编程中断控制器路由。内部事件源自处理器内部。它们不通过 PIC 路由,通常被称为异常。
认识到 IDT 是一个保护模式结构也非常重要。在实模式下,其作用由 IVT(中断向量表)执行。此 IVT 位于内存地址 0x0,通常扩展到 0x3FF。重要的是,我们绝不能在任何时候覆盖它,因为它在我们开始使用虚拟 80x86 模式时非常有用。这使得空指针更加应该受到谴责,因为如果我们搞砸了将要执行的代码,那么它就可以被安全地归类为“糟糕的事情”。
现在一些理论已经讲完了,我们可以看看前 31 个中断号以及它们的含义。请记住,这些中断是内部的。要接收外部中断,我们需要重新编程两个 PIC,以摆脱实模式的麻烦,进入保护模式。
最简单的方法是直接给您提供一个大表格。您可以在代码中将其用作数组
异常 |
名称 |
注释 |
0 |
除以零 |
不可恢复。其原因不言自明。 |
1 |
保留 |
有时被称为调试,Intel 手册称其为保留。 |
2 |
不可屏蔽中断 |
这是一个看门狗。如果中断没有响应,则会引发此异常。用于防止恶意代码禁用中断。 |
3 |
断点 |
程序通常会在指令之前放置 |
4 |
溢出 |
相当罕见。当我们执行 |
5 |
超出边界 |
安全功能。当我们想要将索引与数组的上限和下限进行比较时调用。 |
6 |
无效操作码 |
主要问题。我们正在执行无效代码。由于编译器最初不会生成此代码,因此我们很可能正在执行堆栈或内存中的随机位置。 |
7 |
设备不可用 |
在没有 FPU 的情况下调用 FPU 指令时发生。 |
8 |
双重故障 |
这发生在我们搞砸了 IDT 时。 |
9 |
协处理器段溢出 |
大部分保留。Intel 386 之后的处理器不使用。 |
10 |
无效 TSS |
我们暂时不会遇到这个问题。在执行环 3 代码时,我们弄乱了 TSS。 |
11 |
段不存在 |
GDT 中某个段的访问字节的第 7 位设置为 0。 |
12 |
堆栈段故障 |
正如其名;堆栈被弄乱了。 |
13 |
通用保护故障 |
常用于虚拟 80x86 模式。但是,当在环 3 中使用特权指令时,也会发生这种情况。 |
14 |
页面故障 |
分页出了问题,或者我们访问了一个尚不存在的页面。有些人不映射第一个页面,以便通过此异常轻松捕获空指针。 |
15 |
保留 |
|
16 |
浮点异常 |
我们正在等待另一个浮点异常,或者我们没有打开 FPU。 |
17 |
对齐检查 |
仅在环 3 中发生。 |
18 |
机器检查 |
通常禁用,但在处理器检测到内部错误时调用。 |
19 |
SIMD 浮点异常 |
此功能也默认禁用,但启用后,当发生浮点异常时会发生。 |
20 |
保留 |
|
21 |
保留 |
|
22 |
保留 |
|
23 |
保留 |
|
24 |
保留 |
|
25 |
保留 |
|
26 |
保留 |
|
27 |
保留 |
|
28 |
保留 |
|
29 |
保留 |
|
30 |
保留 |
|
31 |
保留 |
|
如我所说,我们可以在代码中将其表示为一个数组,并按异常号对其进行索引。我们将设置每个故障处理程序,因为如果我们不处理异常,那么我们最终将导致三重故障。
三重故障是计算机的硬重置。它发生在找不到双重故障处理程序时。例如,程序可能除以零。处理器将看到此情况,并遍历 IDT 查找相关的故障处理程序。如果故障处理程序不存在,那么它将尝试做同样的事情,但会搜索双重故障处理程序。如果同样的事情再次发生,那么计算机将被重置。
好了,理论就到这里。现在,我们可以开始一些伪代码。不幸的是,我们必须提供一系列函数声明,而不是简单地将第一个 ISR 的地址扔给处理器。您可以编写一个脚本来自动创建方法定义,或者您可以简单地手动编写它们。
IDT 比 GDT 要直接得多。它包含多达 256 个函数指针,所有这些指针都应该被处理(即使是完全清零的条目)。这些函数需要设置大量无法在 C++ 中完成的东西,所以不幸的是,我们必须降到汇编,然后再回到 C。
每个 IDT 条目都具有相同且简单的格式。它们是函数指针的低 16 位和高 16 位、中断发生时要传输到 CS
的值、一些标志和一个保留值。
位 |
字段大小(位) |
描述 |
0 : 15 |
16 |
函数指针的低 16 位。 |
16 : 31 |
16 |
代码段偏移量。有关完整解释,请参阅 GDT 部分。 |
32 : 39 |
8 |
保留,设置为零。 |
40 : 47 |
8 |
标志字节。 |
48 : 63 |
16 |
函数指针的高 16 位。 |
标志字节也有自己的格式
位 |
字段大小(位) |
描述 |
0 : 3 |
4 |
门的类型。我们需要一个 32 位中断门,所以这是 0xE。 |
4 |
1 |
如果此为零,则段偏移量指向代码或数据段。 |
5 : 6 |
2 |
应从中调用此环。目前为零,但最终将为三。 |
7 |
1 |
如果此为一,则 IDT 条目存在。 |
现在,我们只需要设置其中的几个位——准确地说是第二、第三、第四和第八位。这给了我们一个总值为 0x8E 的值。当我们移到环 3 时,这将会改变。
因此,我们的 IDT 条目应该看起来像这样
IDTEntry idtEntries[256] =
{
{.LowerFunction = (uint)exception0 & 0xFFFF, .CS = 0x8, .Reserved = 0,
.Flags = 0x8E, .UpperFunction = ((uint)exception0 >> 16) & 0xFFFF},
...
{.LowerFunction = (uint)exception31 & 0xFFFF, .CS = 0x8, .Reserved = 0,
.Flags = 0x8E, .UpperFunction = ((uint)exception31 >> 16) & 0xFFFF}
}
显然,您必须自己完成那些令人不快的打字工作。虽然重复,但值得。当我们填写汇编部分时,我们将能够使用 NASM 的宏功能来简化工作。
说到汇编方面,你需要这部分。需要认识到的一件重要事情是,某些异常会将额外的数据推送到堆栈;一个错误代码。这在页面错误等情况下特别有用;我们想要这个。
只是为了让事情复杂化,只有少数异常会这样做。所以我们需要两个宏——一个定义推送错误代码的异常,一个不推送错误代码的异常。我将逐字逐句地给你这些,因为它们非常重要。
%macro Exception_NoErrorCode 1
[GLOBAL exception%1]
exception%1
cli
push byte 0
push byte %1
jmp commonExceptionHandler
%endmacro
%macro Exception_ErrorCode 1
[GLOBAL exception%1]
exception%1
cli
push byte %1
jmp commonExceptionHandler
%endmacro
不算太难。它们都禁用中断(以避免中断嵌套和其他讨厌的事情),推送中断号,并跳转到一个通用的汇编级异常处理程序。唯一的区别是 Exception_NoErrorCode
推送一个虚拟错误代码,而 Exception_ErrorCode
不推送。你还会注意到我们是跳转而不是调用。原因非常简单——当我们试图组合堆栈时,调用会弄乱堆栈。
现在我们已经构建了针对每个异常的代码,我们可以更通用一些。commonExceptionHandler
只是推送一些寄存器,将内核数据段描述符加载到 DS
中,调用我们使用的 C 代码,恢复 CPU 最后保存的状态,然后再次启用中断。为此,我们使用以下代码
commonExceptionHandler:
pusha ; Pushes EDI, ESI, EBP, ESP, EBX, EDX, ECX and EAX
mov ax, ds ; Set AX to the current data segment descriptor
push eax ; Save the data segment descriptor on the stack
mov ax, 0x10 ; Give the CPU the kernel’s clean data segment descriptor
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
call exceptionHandler
pop eax ; Get the orginal data segment descriptor back
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
popa ; Pops EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX
add esp, 8 ; Get rid of the pushed error code and interrupt vector
sti ; Re-enable interrupts
iret ; Tidy up the stack, ready for the next interrupt
这比前两个代码片段稍难,但在理论上仍然相对简单。我们推送必要的寄存器,以便在堆栈跟踪工作时,我们的异常处理代码可以读取它们。然后,我们保存当前数据段描述符并加载我们自己的。这是为了防止恶意程序弄乱其描述符并将其提供给内核。我们所要做的就是调用通用异常处理程序,恢复数据段描述符,弹出必要的寄存器,并从堆栈中移除错误代码和中断向量。我们通过将 8 添加到 ESP
来实现这一点。请记住,每个元素长四个字节,并且堆栈向下增长。
一旦这写好,我们就会回到 C 代码。您的方法定义将如下所示
extern "C" void exceptionHandler(StackState stack)
{
//Handle our exception here
}
要创建多个异常处理程序,您可以使用函数指针数组并按中断向量对其进行索引。我们将在几页之后对这个函数进行更多的工作,所以请将其放在手边。
重新映射 PIC
要指出我们为什么要这样做,请点击此链接。如果您仔细观察,您会发现 IRQ 0 映射到异常 8。现在,如果您向上看,您会发现异常 8 是一个双重故障。因此,每次 IRQ 0 触发时,我们都会遇到双重故障。为了防止这种情况,我们需要重新编程 PIC,即可编程中断控制器。
实际上有两个 PIC,每个处理八个中断。我们需要与它们都进行通信和重新编程。我们通过访问端口来完成此操作。为了保持一致性,我们将它们置于已知状态,这样我们就不会陷入某种僵局,这可能会在以后导致非常细微的错误。
实际的过程最好用代码解释。请原谅硬编码的值,但在这种情况下,使用常量只会增加不必要的间接级别。
outportByte(0x20, 0x11);
outportByte(0xA0, 0x11);
outportByte(0x21, 0x20);
outportByte(0xA1, 0x28);
outportByte(0x21, 0x04);
outportByte(0xA1, 0x02);
outportByte(0x21, 0x01);
outportByte(0xA1, 0x01);
outportByte(0x21, 0x0);
outportByte(0xA1, 0x0);
是的,它很丑。是的,它很有必要。您会注意到我们正在将值发送到端口 0x20、0x21、0xA0 和 0xA1。这些分别是主 PIC 和辅助 PIC 的端口对。
我们发送的第一件事是初始化控制字一,简称 ICW1。这告诉我们正在通信的 PIC 我们正在设置东西,所以接下来是对设置的调整。接下来,我们发送一个偏移量 (ICW2)。请记住,IRQ 和异常通常在相同的地址空间中操作。最后一个保证未使用的 IDT 条目是 32,所以我们只需告诉主 PIC 将 IRQ 0 发送到 IDT 中的条目 32(或 0x20),并告诉从 PIC 将 IRQ 8 发送到 IDT 中的条目 40(0x28)。
此时,您可能想知道我们为什么不为中断指定终点。这很简单;向上看几段,我们看到每个 PIC 处理八个中断。这意味着 IRQ 0 被路由到条目 32,IRQ 7 被路由到条目 39,依此类推。这再简单不过了。
我们发送的第三个 ICW 对 CPU 来说几乎微不足道。我们只是告诉 PICs 如何相互通信。在 ICW1 中,我们告诉 PICs 在级联模式下工作;现在我们告诉它们如何工作。由于这种安排,我们发送给主 PIC 和次 PIC 的值是不同的。我将同时涵盖两者。
我们向主 PIC 发送值 4。这通过中断线 2 将主 PIC 连接到辅助 PIC。这是因为 x86 架构规定它们应该以这种方式连接,并且十进制的 4 设置了第两位(不要忘记在二进制中计算位位置时我们使用零基)。
为了使这种通信双向,我们需要使用相同的线路将辅助 PIC 连接到主 PIC。我们已经知道我们使用的是第二条中断线,所以我们将此值发送给辅助 PIC。
现在我们已经告诉 PICs 如何通信,我们再给它最后一条信息。要理解这一点,重要的是要认识到这组电路被用于数百个不同的电子项目中。它在普通计算机出现之前就已存在。因此,它具有比我们需要的更多的功能。为了禁用所有这些额外的垃圾,我们发送最后一个 ICW——ICW4。这会设置标志。我们唯一需要担心的是位零,它会切换到 x86 模式。这是我们设置的唯一位。
当我们设置了该位后,我们将该值发送给两个 PIC。这显示在倒数第二行;值为一。
我们使用的一个巧妙功能是中断屏蔽。在 CPU 接收中断并运行我们的代码所需的时间内,我们可能正在处理更重要的中断。为了帮助解决这个问题,我们可以在 PIC 级别屏蔽中断。为此,我们只需将位掩码写入常用端口。每个设置的位都表示应该被屏蔽或不传递给 CPU 的中断号。
我们还需要做最后一件事。在每个中断结束时,我们需要告诉所有可能见过中断的 PIC,我们已经完成。这是一个相当优雅的解决方案,可以解决很快就会出现的问题:如果我们在处理中断时又来了一个中断怎么办?要告诉 PICs,我们需要向端口 0x20 写入值 0x20,如果通过了辅助 PIC,则写入 0xA0。
这稍微改变了我们的异常处理程序。最后,我们只需将字节 0x20 发送到端口 0x20。这是一个相当简单的改变,但它允许我们接收多个中断。
现在中断和异常的理论已经到位,我将提供一个 NASM 宏和一个快速函数。这与没有错误代码的 ISR 宏非常相似,并且将控制权传递给 C 的函数也类似。唯一的区别是它跳转的函数的名称。
%macro IRQ 2
[GLOBAL IRQ%1]
IRQ%1:
cli
push byte 0
push byte %2
jmp commonIRQHandler
%endmacro
这里没有什么过于复杂的。我们声明了一个函数,它禁用中断,将零和中断向量压入堆栈,然后跳转到 commonIRQHandler
。
commonIRQHandler:
pusha ; Pushes EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX
mov ax, ds ; Set AX to the current data segment descriptor
push eax ; Save the data segment descriptor on the stack
mov ax, 0x10 ; Give the CPU the kernel’s clean data segment descriptor
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
call irqHandler
pop ebx ; Get the original data segment descriptor back
mov ds, bx
mov es, bx
mov fs, bx
mov gs, bx
popa ; Pops EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX
add esp, 8 ; Get rid of the pushed error code and interrupt vector
sti ; Re-enable interrupts
iret ; Tidy up the stack, ready for the next interrupt
现在汇编级代码已经可以工作了(别担心,这是您在动态链接和任务切换之前最后一次看到它),我们可以开始 C 处理程序了。不要忘记,我们需要像处理每个需要从汇编中显式调用的方法一样,覆盖名称修饰。
extern "C" void irqHandler(StackState stack)
{
if(stack.InterruptNumber > 39)
outportByte(0xA0, 0x20);
outportByte(0x20, 0x20);
}
不要忘记,如果中断号大于 39,我们也需要确认辅助 PIC。
要将 IDT 提供给 CPU,我们只需使用与 GDT 相同的方法。唯一的区别是,我们只需执行 LIDT
指令,而无需降到汇编级代码。请记住将我们提供给 CPU 的结构的指针和限制字段更改为 IDT 的开头。
既然中断基础设施已经完成,我们只需要传递中断。您可以用很多方法来做:您可以有一个函数指针数组,您可以在基本异常处理程序中处理它们,或者您可以有一个函数指针数组的链表。我更喜欢第三种,因为一个中断可以被几个不同的设备共享,并且从一开始就构建功能更容易。
要初始化 GDT 和 IDT,您所要做的就是调用 GDT::SetupGDT
和 IDT::SetupIDT
。按此顺序调用它们,并尽快完成——越快进入稳定的操作环境,就越好。
结束
我们完成了。这是一场漫长的苦战,但我相信描述符表、异常和中断是相辅相成的。现在,您的操作系统可以写入控制台、处理异常和处理中断。这些是您制作基本键盘驱动程序和使用计时器所需的全部。
如果您下载附件,您会找到相关的源代码。关键词是*相关*。我没有包含控制台驱动程序、汇编引导程序等,因为它们已在上一章中介绍过,我想让材料简短精悍,这样您就不必在大量代码文件中搜索您正在寻找的内容。
本系列的下一部分将是一个插曲;从更硬核的东西中休息一下。在其中,我将向您展示如何构建三个基本驱动程序:键盘、计时器和实时时钟。之后,我们将深入探讨物理内存管理中更实质性的内容。