OS有一些工具程序,抽象了硬件资源。OS与应用程序间的接口被称为 System call interface
一个进程、应用程序出问题,我们不希望他影响到其他进程或应用程序,所以需要在不同的应用程序之间有强隔离性
同时我们也不希望应用程序出问题时导致OS崩溃,所以也需要在应用程序和操作系统之间有强隔离性
如果没有OS,硬件资源直接裸漏在应用程序视野下,需要应用程序主动释放CPU资源,即Cooperative Scheduling
而如果出现死循环,那么应用程序永远不会释放CPU,其他应用程序无法运行,无法实现 multiplexing
内存中各个应用程序间的代码数据没有边界,可能互相覆盖,无法实现 内存隔离
应用程序很难设计,需要直接和硬件交互,没有OS提供的 接口与各种服务,如 进程的概念
服务之一:进程。抽象了CPU,运行计算任务
接口之一:exec。抽象了内存,其参数对应一个应用程序的内存镜像
服务之一:files。抽象了磁盘,进行读写命名等基本操作。
操作系统需要确保所有的组件都能工作,所以它需要做好准备抵御来自应用程序的攻击——无论有意还是无意,防止自身崩溃
同时还需要防止应用程序打破隔离性,进而控制内核,控制所有硬件资源
通常来说,需要通过硬件来实现这的强隔离性。
内核模式下可以执行特定权限指令 privileged instructions,而用户模式下只能运行普通权限指令 unprivileged instructions
特殊权限指令主要是一些直接操纵硬件的指令和设置保护的指令,例如设置page table寄存器、关闭时钟中断,及其他设置处理器状态的指令
处理器中有一个 flag bit 标识当前运行模式,必须是 privileged instructions 才能设置这个 bit
实际上我们有三级权限(user/kernel/machine)
应用程序通过系统调用接口请求OS服务,这些接口封装了指令 ECALL x,x为相应的系统调用号。执行该指令即可切换模式,并提供服务
ECALL会跳转到内核中一个特定,由内核控制的位置,这个位置叫做 系统调用接入点 (syscall entry?),从此处进入到内核中
内核侧有一个函数 syscall,它检查ECALL的参数并调用相应的函数
以上过程即:
$$
fork \rightarrow ecall \space x \rightarrow syscall \rightarrow fork
$$
用户并不能直接调用内核侧的、实际的fork,必须通过 ecall 才可以
其他系统调用也是类似的:
原则上来说,在内核侧实现系统调用接口的任意位置都可以进行任何的检查
内核会通过硬件设置一个定时器,定时器到期之后会将控制权限从用户空间转移到内核空间,之后内核就有了控制能力并可以重新调度CPU到另一个进程中。
C提供了很多对于硬件的控制能力
映射 VM 到 PM。每个进程都有自己独立的 PT,也即有独立的虚拟地址空间,只能访问 VM 被映射的物理内存
操作系统会设置 PT,使得每一个进程都有不重合的物理内存,这样一个进程就不能访问其他进程的物理内存,甚至都不能随意编造一个内存地址进行访问
由处理器中的 MMU 进行查询 PT 从而转换 VM 为 PM。MMU有缓存 TLB。
Monolithic Kernel vs. Micro Kernel
通过ECALL可以转移控制权给OS,由内核实现功能并检查参数。所以内核有时候也被称为可被信任的计算空间(Trusted Computing Base),在一些安全的术语中也被称为TCB
TCB 要求内核正确、无bug,且必须把应用程序当成恶意的进行防范
那么什么程序运行在 kernel mode?
内核只有非常少的几个模块,如 IPC的实现或者是Message passing、非常少的虚拟内存的支持、分时复用CPU的一些支持
这里所有的代码会被编译为一个二进制文件 kernel,运行在kernel mode 中
基本上是运行在user mode的程序
它会创建一个空的文件镜像,我们会将这个镜像存在磁盘上,这样我们就可以直接使用一个空的文件系统。
(看不懂)
kernel.asm 中包含了内核的完整汇编语言
第一个指令位于地址0x80000000,对应的是一个RISC-V指令:auipc指令
第二列,例如0x0000a117,是二进制编码后的指令
这里本质上是通过C语言来模拟仿真RISC-V处理器
QEMU表现的就像一个真正的计算机一样。当你想到QEMU时,你不应该认为它是一个C程序,你应该把它想成是下图,一个真正的主板。
在内部,在QEMU的主循环中,只在做一件事情:
读取4字节或者8字节的RISC-V指令。
解析RISC-V指令,并找出对应的操作码(op code)。我们之前在看kernel.asm的时候,看过一些操作码的二进制版本。通过解析,或许可以知道这是一个ADD指令,或者是一个SUB指令。
之后,在软件中执行相应的指令。
为了完成这里的工作,QEMU的主循环需要维护寄存器的状态。所以QEMU会有以C语言声明的类似于X0,X1寄存器等等。
QEMU 将OS加载到0x8000000处,从程序 entry.s 开始启动,此时无内存分页、隔离性,运行在 M-mode
main 程序初始化一系列数据,并调用 userinit(),由它初始化并启动第一个用户进程
第一个用户进程在代码中用二进制指令静态定义,对应 initcode.S,它通过 ecall 调用 syscall 并最终调用 sys_exec
sys_exec 拷贝参数后,利用 syscalls 函数指针数组,执行参数中指定的程序 init
init 配置用户空间后,在其子进程中执行 shell
本质上来说QEMU内部有一个gdb server,当我们启动之后,QEMU会等待gdb客户端连接。
1 | make CPUS=1 qemu-gdb |
客户端:
1 | riscv64-unknown-elf-gdb |
入口点不在 0x8000000,而在0x800000a
从这里也可以看到,内核使用的起始地址就是QEMU指定的0x80000000这个地址
可以看出,csrr是一个4字节的指令,而addi是一个2字节的指令。
XV6从entry.s开始启动,这个时候没有内存分页,没有隔离性,并且运行在M-mode(machine mode)。XV6会尽可能快的跳转到kernel mode或者说是supervisor mode。
PLIC:Platform Level Interrupt Controller
部分函数的初始化顺序很重要,一些函数必须在另一些函数之后运行
进入userinit():
它像是一种胶水代码,不实现具体的功能,它利用了XV6的特性,并启动了第一个用户进程
这段程序被直接在链接或内核中静态定义:
对应这段汇编程序:
加载 init、argv 的地址,以及 exec 对应的系统调用,最后调用 ECALL。
如果我在syscall中设置一个断点,userinit会创建初始进程,返回到用户空间,执行刚刚介绍的3条指令,再回到内核空间
num = p->trapframe->a7 会读取使用的系统调用对应的整数:
即 exec 对应的系统调用号是 7,查看 syscall.h:
所以 p->trapframe->a0 = syscalls[num]() 这一行是实际执行系统调用,syscalls 是一个函数指针数组:
可以预期的是syscall[7]对应了exec的入口函数。我们跳到这个函数中去,可以看到,我们现在在sys_exec函数中。
先从用户空间将参数拷贝到内核空间
打印 path:
传入了 init 程序。所以,综合来看,initcode完成了通过exec调用init程序。
让我们来看看init程序:
init会为用户空间设置好一些东西,比如配置好console,调用fork,并在fork出的子进程中执行shell。
此处的几个系统调用还会使我们运行到syscall断点。继续运行,shell 就就启动了