操作系统的的几个组织架构

  • Isolation。隔离性是设计操作系统组织结构的驱动力。
  • Kernel 和 User mode。这两种模式用来隔离操作系统内核和用户应用程序。
  • System calls。系统调用是你的应用程序能够转换到内核执行的基本方法,这样你的用户态应用程序才能使用内核服务。

操作系统的隔离性

为什么操作系统要有隔离性

  1. 我们在用户空间有多个应用程序,例如 Shell,echo,find。但是,如果你通过 Shell 运行你们的代码时,假设你们的代码出现了问题,Shell 不应该会影响到其他的应用程序。举个反例,如果 Shell 出现问题时,杀掉了其他的进程,这将会非常糟糕。所以需要在不同的应用程序之间有强隔离性。
  2. 同样操作系统和用户程序间也需要强隔离性,当应用程序出现问题时,希望操作系统不会因此而崩溃。比如说向操作系统传递了一些奇怪的参数,希望操作系统仍然能够很好的处理它们(能较好的处理异常情况)。也需要在应用程序和操作系统之间有强隔离性。

协同调度

假设没有操作系统,来调度应用程序的 cpu 等资源使用,则应用程序如 shell 就会主动定期释放 cpu 等资源。将 cpu 将给其他程序使用,这称为协同调度。
但是如果某一应用程序的代码出问题,如死循环,则该应用程序将永远无法释放硬件资源,故需要操作系统参与。

exec 系统调用

我们可以认为 exec 抽象了内存。当我们在执行 exec 系统调用的时候,我们会传入一个文件名,而这个文件名对应了一个应用程序的内存镜像。内存镜像里面包括了程序对应的指令,全局的数据。应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限,例如应用程序不能直接访问物理内存的 1000-2000 这段地址。不能直接访问的原因是,操作系统会提供内存隔离并控制内存,操作系统会在应用程序和硬件资源之间提供一个中间层。exec 是这样一种系统调用,它表明了应用程序不能直接访问物理内存。

操作系统的防御性

为什么要有防御性

  1. 操作系统需要确保所有的组件都能工作,所以它需要做好准备抵御来自应用程序的攻击。如果说应用程序无意或者恶意的向系统调用传入一些错误的参数就会导致操作系统崩溃,那就太糟糕了。在这种场景下,操作系统因为崩溃了会拒绝为其他所有的应用程序提供服务。所以操作系统需要以这样一种方式来完成:操作系统需要能够应对恶意的应用程序。
  2. 应用程序不能够打破对它的隔离。应用程序非常有可能是恶意的,它或许是由攻击者写出来的,攻击者或许想要打破对应用程序的隔离,进而控制内核。一旦有了对于内核的控制能力,你可以做任何事情,因为内核控制了所有的硬件资源。

开发内核时,防御性是必须掌握的一个思想

怎么实现防御性

  1. 一般通过硬件来实现强隔离
  2. user/kernel 和虚拟内存

user/kernel 模式

为了支持 user/kernel 模式,cpu 也会有两种运行状态,一种为 user mode 一种为 kernel mode
当运行在 kernel mode 中时,cpu 可以执行某些特殊权限指令,user mode 则只能运行普通权限指令

普通权限指令 :ADD,MOVE ,SUB,JRC 等
特殊权限指令 :直接操作硬件和设置保护的指令

通常,当运行在 user mode 的程序尝试执行特殊指令时,会跳入 kernel mode,从而操作系统会杀掉该进程。

依靠什么来判断 user/ kernel 模式

在处理器里面有一个 flag。在处理器的一个 bit,当它为 1 的时候是 user mode,当它为 0 时是 kernel mode。当处理器在解析指令时,如果指令是特殊权限指令,并且该 bit 被设置为 1,处理器会拒绝执行这条指令,就像在运算时不能除以 0 一样。

如何在 user/kernel 模式切换

在 RISC-V 中,有一个专门的指令用来实现这个功能,叫做 ECALL。ECALL 接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行 ECALL 指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的 System Call。
image (79).pngl

例如,当我们调用 fork()时,fork 函数体会去调用 ecall(),ecall 传入一个 int,表示系统调用号,然后 ecall()会去调用 syscall,此时就已经在内核中了,syscall 会去调用实际的系统调用函数

如果有恶意程序或者死循环在占用 CPU,内核如何抢回控制权

内核会通过硬件设置一个定时器,定时器到期之后会将控制权限从用户空间转移到内核空间,之后内核就有了控制能力并可以重新调度 CPU 到另一个进程中。

宏内核 vs 微内核

宏内核

  1. 代表操作系统:Linux
  2. 特点:
    1. 整个操作系统都在 kernel mode 运行
    2. 内核共用一个地址空间,服务间彼此通信较容易
    3. 可以提供较好的性能。
    4. 内核代码量较大,体积较大,一个漏洞可能影响全局

微内核

  1. 代表操作系统:minix
  2. 特点:
    1. 内核不同组件运行在不同的地址空间,也就是不同组件分属不同进程,用过消息交互
    2. 部分核心组件运行在 kernel mode,部分组件运行在 user mode
    3. Shell 能与文件系统交互,比如 Shell 调用了 exec,必须有种方式可以接入到文件系统中。通常来说,这里工作的方式是,Shell 会通过内核中的 IPC 系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。文件系统会完成它的工作之后会向 IPC 系统发送回一条消息说,这是你的 exec 系统调用的结果,之后 IPC 系统再将这条消息发送给 Shell。这里是典型的通过消息来实现传统的系统调用。
    4. 性能通常相对于宏内核较低

宏/微内核总结

在实际中,两种内核设计都会出现,出于历史原因大部分的桌面操作系统是宏内核,如果你运行需要大量内核计算的应用程序,例如在数据中心服务器上的操作系统,通常也是使用的宏内核,主要的原因是 Linux 提供了很好的性能。但是很多嵌入式系统,例如 Minix,Cell,这些都是微内核设计。这两种设计都很流行,如果你从头开始写一个操作系统,你可能会从一个微内核设计开始。但是一旦你有了类似于 Linux 这样的宏内核设计,将它重写到一个微内核设计将会是巨大的工作。并且这样重构的动机也不足,因为人们总是想把时间花在实现新功能上,而不是重构他们的内核。

编译内核

首先,我们来看一下代码结构,你们或许已经看过了。代码主要有三个部分组成:

image (115).png

  • 第一个是 kernel。我们可以 ls kernel 的内容,里面包含了基本上所有的内核文件。因为 XV6 是一个宏内核结构,这里所有的文件会被编译成一个叫做 kernel 的二进制文件,然后这个二进制文件会被运行在 kernle mode 中。

image (84).png

  • 第二个部分是 user。这基本上是运行在 user mode 的程序。这也是为什么一个目录称为 kernel,另一个目录称为 user 的原因。
  • 第三部分叫做 mkfs。它会创建一个空的文件镜像,我们会将这个镜像存在磁盘上,这样我们就可以直接使用一个空的文件系统。

接下来,我想简单的介绍一下内核是如何编译的。你们可能已经编译过内核,但是还没有真正的理解编译过程,这个过程还是比较重要的。

首先,Makefile(XV6 目录下的文件)会读取一个 C 文件,例如 proc.c;之后调用 gcc 编译器,生成一个文件叫做 proc.s,这是 RISC-V 汇编语言文件;之后再走到汇编解释器,生成 proc.o,这是汇编语言的二进制格式。

image (91) (1).png

Makefile 会为所有内核文件做相同的操作,比如说 pipe.c,会按照同样的套路,先经过 gcc 编译成 pipe.s,再通过汇编解释器生成 pipe.o。

之后,系统加载器(Loader)会收集所有的.o 文件,将它们链接在一起,并生成内核文件。

这里生成的内核文件就是我们将会在 QEMU 中运行的文件。同时,为了你们的方便,Makefile 还会创建 kernel.asm,这里包含了内核的完整汇编语言,你们可以通过查看它来定位究竟是哪个指令导致了 Bug。比如,我接下来查看 kernel.asm 文件,我们可以看到用汇编指令描述的内核:

这里你们可能已经注意到了,第一个指令位于地址 0x80000000,对应的是一个 RISC-V 指令:auipc 指令。有人知道第二列,例如 0x0000a117、0x83010113、0x6505,是什么意思吗?有人想来回答这个问题吗?

是的,完全正确。所以这里 0x0000a117 就是 auipc,这里是二进制编码后的指令。因为每个指令都有一个二进制编码,kernel 的 asm 文件会显示这些二进制编码。当你在运行 gdb 时,如果你想知道具体在运行什么,你可以看具体的二进制编码是什么,有的时候这还挺方便的。

接下来,让我们不带 gdb 运行 XV6(make 会读取 Makefile 文件中的指令)。这里会编译文件,然后调用 QEMU(qemu-system-riscv64 指令)。这里本质上是通过 C 语言来模拟仿真 RISC-V 处理器。