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

从零开始写个操作系统II番外 - 第一个用户程序

本篇日志主要部分完成于从纽约回国的飞机上。因为在倒时差脑子可能不那么清楚,如果在文章中有任何不准确或错误的地方欢迎留言指出,谢谢!

在上一篇日志中,我们成功编写了一个能对中断有所反应、可以自由在Ring0和Ring3之间切换的“内核”。在文末也提到,由于若干细节尚未完成,我们还暂时不能直接用Ring3运行第三方应用程序。考虑到下一篇日志将讨论Kernel对于多进程、多线程的支持、上下文切换、核心系统调用等关键部分,再继续详述如何设立第一个进程显然不合适,故单独在此写一个泡面番,承接从零开始写个操作系统II - Hello World, Ring0 and Ring3,讨论设立并运行第一个进程所需要的步骤。

前置知识点

  • x86下程序的内存结构(代码、数据、栈)
  • x86虚拟内存(Virtual Memory)的工作方式(分页,二级映射,Page Directory, TLB)

用户进程页表模型

由于我们定义的内存段全部为覆盖整个内存空间的"全"段,目前我们的内核依然不能阻止运行在Ring3的用户程序修改内核使用的内存。显而易见,这是个非常严重的安全隐患:假如恶意用户程序得知内核所使用的栈地址,那么它完全可以使用类似Buffer Overflow的原理改写系统运行栈的Return Address,从而让OS以内核态运行用户指定的代码。

于是,我们通过内存分页来确保权限控制。内存分页的工作机制请参考前置知识点,一言以蔽之,在x86下,内存分页将内存地址空间[0, 4G)以4KB的"页"的形式映射到实际内存地址[0, 物理内存上限),而维护该映射的数据结构为二级的页表(Page Directory)。内存分页能带来的好处很多,包括在多进程下能让每个进程拥有独立且一致的内存布局、使用大于物理内存上限的虚拟地址、隔离不同进程、减小使用内存段进行隔离带来的外碎片等等,其中时常被忽略的一项是,页表让细粒度(4KB)内存权限控制成为可能,

页表的每一项除了存储该逻辑页被映射到的物理页地址外,还存储了该页的基本权限:

  • 能访问该页的最大CPL
  • 该页是否可写

而注册页表的命令本身只能在Ring0下运行,故OS可以通过设置页表,使用权限控制中的最大CPL"屏蔽"内核所使用的内存,从而使Ring3程序无法访问。

由于未来该内核可能会在不同进程的页表间切换,我们当然不希望切换完成后连内核的下一条指令都无法执行,故对所有进程的页表,内核都保留相同的一块内存区域作为内核内存,以容纳所有和内核有关的代码、栈和数据。由于内核内存在所有页表中完全一致,为简单起见,我们使用直接映射处理这部分内存,即,内核部分的虚拟内存地址就是物理内存地址。在具体实现中,我们可以选取一个合理的值作为内核内存长度(例如16M)。

对于用户态内存,直接映射便没有必要。为了给用户进程一个独享全部空间的错觉,我们只需将其用到的内存部分(代码段、数据段、栈等)按需映射到物理内存地址即可。总结来说,一个合理的页表和内存分布如下图所示:

其中绿色部分的最大可访问CPL为0,其余部分为3。理论上来说,这样的页表可以在不切换页表的前提下实现对用户态程序可访问内存的有效控制。有趣的是,2018年初突然爆出的CPU重大安全漏洞Meltdown和Spectre令全网震惊,因为该漏洞在采用上述形式页表的内核中允许Ring3的代码绕过CPL限制、读取绿色部分(Ring0)内存,从而严重危害OS安全性 -- 现有主流操作系统均采用此类页表,该漏洞的披露使几乎所有OS厂商均处于危机之中。目前,各OS均发布补丁,补丁内容本质非常简单:OS在切换进Ring3代码前修改现有页表,彻底删除绿色部分内存的映射(而非设置其CPL=0),从而让Ring3代码无从利用漏洞访问内核内存。该修补会带来更加频繁的页表切换、TLB清空等操作,从而严重拖累系统效率(5%~30%左右 [1])。

建立内核栈 & 第一次上下文切换

对于一个OS,当CPU在执行内核态代码时(因为中断等),所有栈上数据将被保存在内核内存中的内核栈上。由于每个线程拥有独立的执行上下文,即使在进入内核态时也如此,因此每个线程应该有不同的内核栈(在下一章中详述)。

而迄今为止,我们的OS仅有一个在引导时预定义的地址作为内核栈。为了方便今后让我们的内核支持至多任务,我们为第一个进程设立单独的内核栈并切换至它。另外,为了让CPU从Ring3切换回Ring0时能找到正确的栈,我们也需要将 esp0相应设置。

由于切换内核栈也是上下文切换的任务之一,完成对该内核栈的构建后,我们可以利用上下文切换转换至目标栈(上下文切换的详细实现在下一章中讨论)。我们在这里执行的代码和常规上下文切换的区别仅仅在于,常规上下文切换从一个进程的上下文切换到另一个,而这里的代码则是从当前不完整的系统状态(无进程、无被记录的内核栈等等)转换到构建的第一个完整进程的状态中。

// entry为切换至新内核栈后入口函数的地址。该函数不会返回。
void setInitialKernelStack(uint32_t entry) {
  void* kernelStack = kmalloc(PAGE_SIZE);
  firstProcess->kstack = kernelStack;
  firstProcess->context.esp = kernelStack + PAGE_SIZE - 4;
  firstProcess->context.eip = entry;
  // 构建内核栈:
  // 写入无效的ret address,迫使目标函数返回后会走入无效地址引发panic
  *(uint32_t*)firstProcess->context.esp = 0xdeadbeef;

  // esp0和esp的设置均在contextSwitchTo中完成
  // One-way trip!
  contextSwitchTo(firstProcess);
}

大功告成!

至此,我们基本完成了建立第一个进程所需的一切准备活动:设立用户进程页表、建立内核栈以及进行第一次上下文切换。将其攒在一起,便可以用上一章讨论的 switchToRing3运行用户程序了!

需要注意的是,我们此处省略了将用户程序加载进内存的细节。其过程大致分为解析ELF文件、在用户进程页表中开辟需要的空间和将几段数据复制进内存几个阶段。由于该过程并无难点但细节繁琐,我们在此略过。感兴趣的读者可以阅读ELF文件的定义了解更多。

把上述工作攒在一起,运行第一个用户程序伪代码如下:

void runMeOnKernelStack() {
  // 上一章中实现的函数
  switchToRing3(firstProcess->user.esp, USER_EFLAGS, firstProcess->user.eip);
}

// entry为切换至新内核栈后入口函数的地址。该函数不会返回。
void setInitialKernelStack(uint32_t entry) {
  void* kernelStack = kmalloc(PAGE_SIZE);
  firstProcess->kstack = kernelStack;
  firstProcess->context.esp = kernelStack + PAGE_SIZE - 4;
  firstProcess->context.eip = entry;
  // 构建内核栈:
  // 写入无效的ret address,迫使目标函数返回后会走入无效地址引发panic
  *(uint32_t*)firstProcess->context.esp = 0xdeadbeef;

  // esp0和esp的设置均在contextSwitchTo中完成
  // One-way trip!
  contextSwitchTo(firstProcess);
}

void kernelMain() {
  // 将handlerWrapper注册为时钟中断的中断处理函数,代码略

  // 而后解除中断屏蔽,CPU开始正常接收中断
  enableInterrupt();

  Dprintf("Hello World! Let's runn the 1st process!");

  // 1. Set up an empty page directory
  firstProcess->pageDirectory = initPageDirectory();
  // 2. loadELF load the app into memory, set firstProcess->user.eip and firstProcess->user.esp
  loadELF(firstProcess, "/path/to/first/app");
  // 3. First context switch to kernel stack, one way trip
  setInitialKernelStack((uint32_t)runMeOnKernelStack);

  while (true) 
    ;
}

Bravo!

参考文献

[1] https://www.forbes.com/sites/thomasbrewster/2018/01/03/intel-meltdown-spectre-vulnerabilities-leave-millions-open-to-cyber-attack/