Levy's ink.
Doodles, whimsy & life.
About
Blog
Mess
Catalog

从零开始写个操作系统II - Hello World, Ring0 & Ring3

Merry Christmas🎄🎄🎄

操作系统系列日志从本章开始转入技术部分。由于OS涉及的基础知识相当广,在日志中不可能全部覆盖,故笔者将在每篇日志的开头列出无障碍地阅读本章所需要的基础知识点以供参考。绝大部分前置知识点可以在15-213的教材Computer Systems: A Programmer's Perspective中学习(@Bryant 考虑给五美分做推广费吧😳)。

前置知识点

  • x86汇编入门知识
  • x86下程序的内存结构(代码、数据、栈)
  • x86虚拟内存(Virtual Memory)的工作方式(分页,二级映射,Page Directory, TLB)
  • 现代计算机主要硬件以及其作用

Hello World!

Bootstrap (略)

当我们按下计算机的开机键时,所有硬件将经过一个从极度混沌的物理状态变有序的过程,而这一部分主要由各硬件自己的芯片和BIOS协同完成。之后,BIOS通过查找MBR(主引导记录)将OS逐步从硬盘加载到内存并把 %eip设置为某个OS提供的入口地址。从此处开始,整个计算机的控制权被交给了目标OS。

尽管BIOS在计算机状态有序化上做了了相当一部分工作,OS仍然需要做若干初始化。其中比较重要的有:

  • 设置最初的数据段、栈段和代码段(关于段的描述详见后文)
  • 从实模式(Real Mode)转换至保护模式(Protected Mode),从而获得x86程序所应该有的整个4GB内存寻址空间

由于这部分代码高度复杂但模板化,故非本文重点,在此略过。有兴趣的读者可以从任何一本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程序非常类似,但我们要意识到该代码在运行环境上的本质不同:

  • 独占机器全部资源:没有任何其他程序和该代码共享资源,CPU不会切换到其他程序上,没有race -- 情况类似于一个单线程的、Linux中唯一运行的进程(尽管在现实中这是不可能的)。
  • 内存均为直接映射:由于此时虚拟内存尚未启动,运行环境中所用到的地址均为物理内存真实地址。在这种情况下,尽管x86提供了4GB内存寻址空间,该代码依然不能引用超出物理内存大小限制的内存区域。
  • 在内核态运行:该代码运行在Ring0(下文介绍)。

OK,我们已经写出了一个最简单的“内核”(其实并不能叫内核)。该 kernelMain能做的全部工作就是,让计算机不报任何错误地启动起来,并在while loop中做死循环,没有任何惊喜和变数。

有点无聊?那我们从这个死循环内核出发,开始引入一些控制流上的变数吧。

中断:控制流变化的核心

上文中我们编写的死循环“内核”并不是一个真正的内核——且不说其没有调度用户进程的功能,该“内核”甚至没有任何对外界事件的响应能力。当一个程序对任何响应(例如时钟、键盘、鼠标等信号)都无动于衷,仅仅按照编码顺序确定性地执行时,该程序连交互式应用都不算,遑论操作系统。

在x86架构下,传递响应事件的机制叫做中断(Interrupt)。中断分以下几种情况:

  • 异常(Exception):当CPU正在执行的语句出现错误时(例如除零、访问不存在的内存等)触发。
  • 软中断(Trap / Software Interrupt):通过执行汇编指令 INT $??(??为中断号)主动触发。
  • 硬中断(Hardware Interrupt):通过其他硬件事件(例如时钟事件、键盘被按下等等)触发。

不难注意到,前两种中断和第三种有所不同:前两种中断同步触发,即,被CPU正在执行的指令主动触发;后一种中断异步触发,即其触发和CPU当前状态没有直接相关性。另外,异常由于其重要性(如果不进行处理很可能无法执行下一语句)是不可被屏蔽的;而后两种中断则可以通过相关汇编指令延迟触发。

设置中断处理函数

尽管中断触发原因多种多样、触发时机也各有不同,但他们拥有统一的处理流程。每一类型的中断都有其被硬件定义好的中断编号,而该中断能被正确处理的前提条件是OS已经为此中断编号安装了中断处理函数。

中断处理函数被定义在中断描述符表(Interrupt Descriptor Table, IDT)中,而中断编号本身则是对应中断描述符在IDT中的下标。通过设置中断描述符,填入合适的中断处理函数起始地址,我们就能让硬件在触发中断时自动将 %eip跳到预想的位置,从而处理该中断。

中断处理流程

鉴于中断的唐突性,中断处理流程必须保存中断来临前CPU合适的上下文,从而在处理完毕后可以恢复到常规控制流中。在x86中(Ring0下),CPU在处理中断时将遵循以下流程(假设我们已经设置了合适的处理函数):

  1. 将中断触发前的 eflags寄存器压栈
  2. 将中断触发前的 %cs代码段寄存器压栈
  3. 将中断触发前的 %eip寄存器压栈
  4. 设置新的 %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) 
    ;
}

当前权限等级CPL (Current Privilege Level),Ring0 & Ring3

尽管我们的“内核”总算可以根据外界事件做出反应,其依然只能被称作一个“可以自启动的交互式程序”而已。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

既然我们可以利用CPL,那么在切换至第三方应用时简单地将其从0改为3似乎就满足我们的需求了。然而,不同于eflags里的诸多值,CPL并不能直接通过读写寄存器的方式进行修改。在x86中,CPU的CPL和当前 %cs寄存器的值以一种间接的方式相耦合,而设置CPL则需要通过更改 %cs的方式进行。在了解设置CPL的具体方法前,我们有必要了解 %cs寄存器的作用——维护内存段。

内存段(Segment)

相比大家几乎都知道的内存分页(Paging),内存段是一个冷门得多的知识。原因无他,内存段在现代操作系统中的应用往往让我们不需关心其存在。

内存段是比内存分页更底层的内存映射手段。每个内存段拥有自己的起始地址/段偏移(Segment Offset)和长度(Segment Size),而不同于内存分页通过逻辑内存地址决定所在页号,每次内存访问该使用哪个内存段一般是由指令本身决定的:例如,一般CPU从 %eip获取指令的操作将使用 %cs段(故常被写为 %cs:%eip); PUSHPOP等栈上操作一般使用 %ss段(故常被写为 %ss:%esp);而大部分常规的内存访问一般使用 %ds段。

内存段的第一个作用是内存偏移。当一个内存访问操作确定了其归属的段后,该内存地址会首先加上段偏移,而后再进行可能的虚拟内存映射等过程。我们之所以在用户态程序开发中不需要关注不同指令所用的不同内存段以及其起始地址差异带来的问题,是因为操作系统常将这几个段全设置为(Offset = 0, Size = 4G)的覆盖整个内存空间的段,故不同操作间所用到的偏移前地址和偏移后地址毫无差别。

内存段的第二个作用,也是我们这里关注的作用,是权限控制。除了基础的地址范围外,每个段描述符还保存了可以使用该段的CPL、读写权限、可执行权限等权限控制内容(详情请见IA-32 Intel® Architecture Software Developer’s Manual第77页)。在我们的OS中,为了简化实现,我们定义了最小完备功能的四个段:

  • 内核代码段:只读、可执行,CPL=0
  • 内核数据段:读写、不可执行,CPL=0
  • 用户代码段:只读、可执行,CPL=3
  • 用户数据段:读写、不可执行,CPL=3

其中 %cs使用对应CPL下的代码段,而其余段寄存器( %ss, %ds, %es, %fs, %gs)则使用对应CPL的数据段。

前文提到CPL和 %cs之间的耦合关系,就是通过 %cs段中的CPL值建立的。然而,直接修改 %cs依然对改变CPL没有帮助(而且我们往往也不会直接修改 %cs),x86给出了一套相较而言更反直觉的CPL修改方式。在具体介绍该方式前,我们需要重新修订以下前文的中断处理流程。

Ring3下的中断处理流程

前文提到的在当前栈上压入上下文从而引入中断处理函数的方式在引入权限控制后出现了一定的问题。假如CPU在Ring3下运行指令时,时钟中断触发了,而时钟中断处理函数是一个存储在内核内存空间、用到各种内核数据结构的函数(这也几乎一定是OS的实现情况),我们必须让中断处理流程有所区别:

  • 我们不能依然使用当前的栈:当前的栈为用户态程序使用,这意味着Ring3对其拥有完整的读写权限。将内核态的运行状态存储在上面违反了权限控制的原则。
  • 我们需要修改 %cs:原本的 %cs中CPL=3,我们需要修改到内核代码段以期望和我们想要的CPL相匹配。
  • 我们需要修改 %ss:根据第一条,硬件需要在内核空间中使用新的栈,那么对应的 %ss也需要发生变化以获取访问内核栈的权限。

这样一来,当CPU的CPL=3时,CPU在处理中断时将遵循以下修改后的流程(假设我们已经设置了合适的处理函数):

  1. 修改新的 %ss为内核数据段
  2. 修改新的 %esp为某事先定义的内核栈地址(我们称之为 %esp0)
  3. 将中断触发前的 %ss寄存器压栈
  4. 将中断触发前的 %esp寄存器压栈
  5. 将中断触发前的 eflags寄存器压栈
  6. 将中断触发前的 %cs代码段寄存器压栈
  7. 将中断触发前的 %eip寄存器压栈
  8. 设置新的 %cs%eip,将控制流转移到我们设置的中断处理函数上。

作为总结,在Ring3状态下触发中断、并引发栈变化时的(内核)栈上状态如下:

看起来麻烦了非常多... 但好在这一切都是x86 CPU自动帮我们做好的。另一个好消息是,作为这一些列操作逆操作, IRET竟然可以智能地根据栈上的 %cs判断是从Ring0还是Ring3触发的中断,从而执行对应操作。作为OS实现者,我们需要做的就是知道这一切,但其并不会影响上文我们写下的 handlerWrapper的实现。

权限转换 (Mode Switch)

OK!现在我们可以光明正大地开始讨论权限转换在x86下的实现了。可以注意到,上文的Ring3中断处理流程其实已经不知不觉地进行了双方转换——讽刺的是,这应该是x86下唯一修改CPL的方式了:在触发中断时和执行 IRET指令时,硬件通过预先设置的/栈上存储的 %cs段对应的CPL修改CPU的CPL。真是无比曲折婉转啊...

Ring0 -> Ring3

降低权限的使用场景有若干几个。

  • 从中断处理函数返回到原本的Ring3:由于在中断处理函数被调用前硬件已经将所需的一切压在栈上,故直接如 handlerWrapper所示使用 IRET即可。
  • OS主动进入Ring3:此情况往往发生在OS试图将控制权转移给第三方应用时。由于不是在中断处理函数,当前栈上没有硬件构造好的返回寄存器值,所以我们需要手动构造该结构:
# 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

Ring3 -> Ring0

提升权限使用场景归纳起来只有一个:触发中断。

  • 第三方应用出错/调用权限不允许的指令,触发异常:经过上述流程切换至Ring0并进入对应中断处理函数。
  • 第三方应用进行系统调用:应用程序主动调用 INT $0x80将控制权交给OS,不难看出系统调用处理函数本质上也是中断处理函数。
  • 第三方应用运行时硬件中断触发:切换至Ring0进入中断处理函数,处理结束后返回Ring3。

总结

经过本章,我们实现了一个可以被引导、接收中断且在Ring0和Ring3间切换自如的“内核”了!但在进入Ring3、开始真正运行第三方程序前,我们还需要设置页表(Page Directory)。尽管页表的结构在15-213中已经详细说明,但我们依然需要为内核和用户应用划分不同的区域从而起到权限控制作用。该部分的设计及实现详见下一章。