Merry Christmas🎄🎄🎄
操作系统系列日志从本章开始转入技术部分。由于OS涉及的基础知识相当广,在日志中不可能全部覆盖,故笔者将在每篇日志的开头列出无障碍地阅读本章所需要的基础知识点以供参考。绝大部分前置知识点可以在15-213的教材Computer Systems: A Programmer's Perspective中学习(@Bryant 考虑给五美分做推广费吧😳)。
当我们按下计算机的开机键时,所有硬件将经过一个从极度混沌的物理状态变有序的过程,而这一部分主要由各硬件自己的芯片和BIOS协同完成。之后,BIOS通过查找MBR(主引导记录)将OS逐步从硬盘加载到内存并把 %eip
设置为某个OS提供的入口地址。从此处开始,整个计算机的控制权被交给了目标OS。
尽管BIOS在计算机状态有序化上做了了相当一部分工作,OS仍然需要做若干初始化。其中比较重要的有:
由于这部分代码高度复杂但模板化,故非本文重点,在此略过。有兴趣的读者可以从任何一本OS教材的bootstrap部分或一个极简的教学用开源OS:xv6源码中学习其实现。
在完成上述初始化流程后,我们的OS可以通过 CALL kernelMain
将控制流转移到常规的主函数上。和常规的c程序类似,我们的主函数是OS一切功能的起点;不同的是,内核的主函数永远不能退出--常规c程序的 main
函数在退出后由libc调用 exit
系统调用显式结束自己,而内核的 kernelMain
函数在退出后往往因为没有合适的结束语句而导致 %eip
偏移到意想不到的内存中。故,一个简单的 kernelMain
实现如下:
// It MUST NOT return!
void kernelMain() {
Dprintf("Hello World!");
while (true)
;
}
需要注意的是,由于此时我们还没写显卡驱动模块,我们还不能在屏幕打印任何东西,故我们假设一个虚拟的函数 Dprintf
向假想的debugging console中打印内容。
尽管看起来和常规c程序非常类似,但我们要意识到该代码在运行环境上的本质不同:
OK,我们已经写出了一个最简单的“内核”(其实并不能叫内核)。该 kernelMain
能做的全部工作就是,让计算机不报任何错误地启动起来,并在while loop中做死循环,没有任何惊喜和变数。
有点无聊?那我们从这个死循环内核出发,开始引入一些控制流上的变数吧。
上文中我们编写的死循环“内核”并不是一个真正的内核——且不说其没有调度用户进程的功能,该“内核”甚至没有任何对外界事件的响应能力。当一个程序对任何响应(例如时钟、键盘、鼠标等信号)都无动于衷,仅仅按照编码顺序确定性地执行时,该程序连交互式应用都不算,遑论操作系统。
在x86架构下,传递响应事件的机制叫做中断(Interrupt)。中断分以下几种情况:
INT $??
(??为中断号)主动触发。不难注意到,前两种中断和第三种有所不同:前两种中断同步触发,即,被CPU正在执行的指令主动触发;后一种中断异步触发,即其触发和CPU当前状态没有直接相关性。另外,异常由于其重要性(如果不进行处理很可能无法执行下一语句)是不可被屏蔽的;而后两种中断则可以通过相关汇编指令延迟触发。
尽管中断触发原因多种多样、触发时机也各有不同,但他们拥有统一的处理流程。每一类型的中断都有其被硬件定义好的中断编号,而该中断能被正确处理的前提条件是OS已经为此中断编号安装了中断处理函数。
中断处理函数被定义在中断描述符表(Interrupt Descriptor Table, IDT)中,而中断编号本身则是对应中断描述符在IDT中的下标。通过设置中断描述符,填入合适的中断处理函数起始地址,我们就能让硬件在触发中断时自动将 %eip
跳到预想的位置,从而处理该中断。
鉴于中断的唐突性,中断处理流程必须保存中断来临前CPU合适的上下文,从而在处理完毕后可以恢复到常规控制流中。在x86中(Ring0下),CPU在处理中断时将遵循以下流程(假设我们已经设置了合适的处理函数):
eflags
寄存器压栈%cs
代码段寄存器压栈%eip
寄存器压栈%cs
和 %eip
,将控制流转移到我们设置的中断处理函数上。除此以外,其他寄存器相比中断来临前都不会发生变化。可以看到,由于 %esp
没有发生变动,中断触发时硬件帮忙进行了一次压栈,其过程类似 CALL
指令,但由于需要保存的上下文更加丰富,所以其压栈方式有所改变。根据上述流程压栈完成后,栈上情况如下图(来源:IA-32 Intel® Architecture Software Developer’s Manual第153页)。请注意,在x86下,栈的增长方向是从高地址向下的:
可以看到,由于在除了上述涉及的寄存器外其余寄存器均未改变但也没被保存,而自定义的中断处理函数几乎一定会修改它们,故我们往往需要在中断处理函数的一开头人为保存各类寄存器。一个 handlerWrapper
通常由汇编实现,在进行上下文保存操作后将控制权转给真正的 intHandler
(此函数可以为c实现):
handlerWrapper:
PUSHA
PUSH %ds, %es, %fs, %gs
CALL intHandler # Actual handler
POP %ds, %es, %fs, %gs
POPA
IRET
我们注意到,上述实现中最后一行的 IRET
命令不同于最终一般以 RET
结尾的正常函数。 IRET
全称Interrupt Return,顾名思义,用于从中断处理函数中返回。从上文我们能看出,中断处理的压栈情况和常规函数调用不同,那么作为逆操作的 IRET
自然也会和 RET
有所区别。
OK。我们现在可以给之前的死循环内核加上一点随着外界条件变化的控制流了。通过把 handlerWrapper
注册给时钟中断,我们可以在进入死循环后仍然每一个时钟周期输出一个tick。
void intHandler() {
static int numTick = 0;
Dprintf("TICK: %d", numTick++);
}
void kernelMain() {
// 将handlerWrapper注册为时钟中断的中断处理函数,代码略
// 而后解除中断屏蔽,CPU开始正常接收中断
enableInterrupt();
Dprintf("Hello World!");
while (true)
;
}
尽管我们的“内核”总算可以根据外界事件做出反应,其依然只能被称作一个“可以自启动的交互式程序”而已。OS的重要任务之一是载入运行第三方应用,而对于不信任的第三方应用,权限控制非常必要:毕竟OS不希望一个不被信任的程序有自由修改内核数据结构的能力。因而,除了简单将第三方应用读入内存并修改 %eip
交予其控制权,一些权限管理机制是必须的。
x86架构的权限控制核心数据被称为CPL。CPL作为CPU的属性,限制了CPU可以运行的指令集合。传统的x86 CPU只支持两种不同的CPL:CPL-0和CPL-3,一般被写为Ring0和Ring3。Ring0状态下的CPU拥有调用所有指令的能力,是为内核态;而Ring3状态的CPU几乎无法调用任何修改全局系统状态的命令,只能做常规计算、访问低权限的内存区域和修改常用寄存器——不守规矩的指令将触发保护性异常(General Protection Fault,在Ring3下的异常处理流程我们将稍后讲解),是为用户态。
既然我们可以利用CPL,那么在切换至第三方应用时简单地将其从0改为3似乎就满足我们的需求了。然而,不同于eflags里的诸多值,CPL并不能直接通过读写寄存器的方式进行修改。在x86中,CPU的CPL和当前 %cs
寄存器的值以一种间接的方式相耦合,而设置CPL则需要通过更改 %cs
的方式进行。在了解设置CPL的具体方法前,我们有必要了解 %cs
寄存器的作用——维护内存段。
相比大家几乎都知道的内存分页(Paging),内存段是一个冷门得多的知识。原因无他,内存段在现代操作系统中的应用往往让我们不需关心其存在。
内存段是比内存分页更底层的内存映射手段。每个内存段拥有自己的起始地址/段偏移(Segment Offset)和长度(Segment Size),而不同于内存分页通过逻辑内存地址决定所在页号,每次内存访问该使用哪个内存段一般是由指令本身决定的:例如,一般CPU从 %eip
获取指令的操作将使用 %cs
段(故常被写为 %cs:%eip
); PUSH
和 POP
等栈上操作一般使用 %ss
段(故常被写为 %ss:%esp
);而大部分常规的内存访问一般使用 %ds
段。
内存段的第一个作用是内存偏移。当一个内存访问操作确定了其归属的段后,该内存地址会首先加上段偏移,而后再进行可能的虚拟内存映射等过程。我们之所以在用户态程序开发中不需要关注不同指令所用的不同内存段以及其起始地址差异带来的问题,是因为操作系统常将这几个段全设置为(Offset = 0, Size = 4G)的覆盖整个内存空间的段,故不同操作间所用到的偏移前地址和偏移后地址毫无差别。
内存段的第二个作用,也是我们这里关注的作用,是权限控制。除了基础的地址范围外,每个段描述符还保存了可以使用该段的CPL、读写权限、可执行权限等权限控制内容(详情请见IA-32 Intel® Architecture Software Developer’s Manual第77页)。在我们的OS中,为了简化实现,我们定义了最小完备功能的四个段:
其中 %cs
使用对应CPL下的代码段,而其余段寄存器( %ss
, %ds
, %es
, %fs
, %gs
)则使用对应CPL的数据段。
前文提到CPL和 %cs
之间的耦合关系,就是通过 %cs
段中的CPL值建立的。然而,直接修改 %cs
依然对改变CPL没有帮助(而且我们往往也不会直接修改 %cs
),x86给出了一套相较而言更反直觉的CPL修改方式。在具体介绍该方式前,我们需要重新修订以下前文的中断处理流程。
前文提到的在当前栈上压入上下文从而引入中断处理函数的方式在引入权限控制后出现了一定的问题。假如CPU在Ring3下运行指令时,时钟中断触发了,而时钟中断处理函数是一个存储在内核内存空间、用到各种内核数据结构的函数(这也几乎一定是OS的实现情况),我们必须让中断处理流程有所区别:
%cs
:原本的 %cs
中CPL=3,我们需要修改到内核代码段以期望和我们想要的CPL相匹配。%ss
:根据第一条,硬件需要在内核空间中使用新的栈,那么对应的 %ss
也需要发生变化以获取访问内核栈的权限。这样一来,当CPU的CPL=3时,CPU在处理中断时将遵循以下修改后的流程(假设我们已经设置了合适的处理函数):
%ss
为内核数据段%esp
为某事先定义的内核栈地址(我们称之为 %esp0
)%ss
寄存器压栈%esp
寄存器压栈eflags
寄存器压栈%cs
代码段寄存器压栈%eip
寄存器压栈%cs
和 %eip
,将控制流转移到我们设置的中断处理函数上。作为总结,在Ring3状态下触发中断、并引发栈变化时的(内核)栈上状态如下:
看起来麻烦了非常多... 但好在这一切都是x86 CPU自动帮我们做好的。另一个好消息是,作为这一些列操作逆操作, IRET
竟然可以智能地根据栈上的 %cs
判断是从Ring0还是Ring3触发的中断,从而执行对应操作。作为OS实现者,我们需要做的就是知道这一切,但其并不会影响上文我们写下的 handlerWrapper
的实现。
OK!现在我们可以光明正大地开始讨论权限转换在x86下的实现了。可以注意到,上文的Ring3中断处理流程其实已经不知不觉地进行了双方转换——讽刺的是,这应该是x86下唯一修改CPL的方式了:在触发中断时和执行 IRET
指令时,硬件通过预先设置的/栈上存储的 %cs
段对应的CPL修改CPU的CPL。真是无比曲折婉转啊...
降低权限的使用场景有若干几个。
handlerWrapper
所示使用 IRET
即可。# void switchToRing3(int esp, int eflags, int eip);
# It never returns!!
switchToRing3:
MOV %esp, %ecx
MOV $SEG_USER_DS, %eax
MOV %ax, %ds
MOV %ax, %es
MOV %ax, %gs
MOV %ax, %fs
PUSH $SEG_USER_DS # ss
PUSH 4(%ecx) # esp
PUSH 8(%ecx) # eflags
PUSH $SEG_USER_CS # cs
PUSH 12(%ecx) # eip
IRET
提升权限使用场景归纳起来只有一个:触发中断。
INT $0x80
将控制权交给OS,不难看出系统调用处理函数本质上也是中断处理函数。经过本章,我们实现了一个可以被引导、接收中断且在Ring0和Ring3间切换自如的“内核”了!但在进入Ring3、开始真正运行第三方程序前,我们还需要设置页表(Page Directory)。尽管页表的结构在15-213中已经详细说明,但我们依然需要为内核和用户应用划分不同的区域从而起到权限控制作用。该部分的设计及实现详见下一章。