一个操作系统至少需要满足以下三个要求:
硬件CPU的数量有限,且往往少于同时存在的进程数量。而操作系统需要支持进程的并发执行,所以操作系统应该能使多个进程分时共享计算机的资源。
一个进程的运行,应当具有一定的独立性,这个独立性指该进程在一定程度上不受其他进程的影响。这可以保证出了bug的程序不会严重影响其他程序的正常运行。
如上所说,进程的隔离并非是完全的,而是一定程度上的。进程间可能需要通信来协调进程的执行。
倘若应用程序与硬件资源直接交互,并把之前的系统调用视为一个库,应用程序与之连接。这样做,应用程序可能会实现高效、可预测的性能。一些用于嵌入式设备或实时系统的操作系统就是采用这样的方式。
但缺点就是,多个应用程序间必须配合的天衣无缝。比如每个应用程序必须定期放弃CPU,以使其他应用程序可以运行,这样做要求所有应用程序间都相互信任且没有bug。但这往往是难以实现的,所以操作系统实现应用程序与硬件资源的强隔离是非常有必要的。
操作系统实现强隔离的方式是将资源抽象为服务。比如Unix应用程序只通过文件系统的open、write等系统调用去与文件系统直接交互,而不是直接读写磁盘,这带来了路径名的便利。
总得来说,操作系统强隔离了应用程序与硬件资源,将资源抽象为服务,提高了开发与程序执行的效率。
RISC-V指令集架构的CPU有三种模式,分别是机器模式、监督者模式和用户模式。
注意:内核控制监督者模式的入口点,如果是应用程序决定内核的入口点,那么恶意程序就能够在跳过参数验证的情况下进入内核。
宏内核与微内核是内核的两种组织方式。
宏内核:又名单内核。它的用户服务与内核服务都保存在相同的地址空间,有内核进行统一管理。
微内核:用户服务与内核服务保存在不同的地址空间。
如下图所示:
在宏内核中,内核是一个大的整体,一个大的进程,函数间的调用链路少,之间通信简单高效。
在微内核中,微内核的功能被划分为独立的进程,进程间通过IPC通信,高度模块化,一个服务的故障不会影响另一个服务。但由于模块化,函数之间调用链路偏长,进程间不会之间通信,而是通过内核服务相互通信。
宏内核的执行效率高于微内核,因为微内核涉及到跨模块调用。但宏内核的扩展性比微内核差。
xv6采用宏内核组织,其所有系统调用都在监督者模式下运行。整个操作系统以全硬件权限运行。
这导致xv6的开发者需要仔细地琢磨各个接口,一旦有一个错误就是致命的。因为监督者模式下的错误往往会导致内核崩溃,内核崩溃导致计算机停止工作,所有应用程序崩溃,计算机必须重启。
xv6的内核源码在kernel/子目录下。按照模块化的设计思想,源码被分为了多个文件,下图列出了这些文件及其功能。
文件名 | 功能描述 |
bio.c | 文件系统的磁盘块缓存 |
console.c | 连接用户键盘与屏幕 |
exec.c | exec系统调用 |
file.c | 文件描述符的支持文件 |
fs.c | 文件系统 |
kalloc.c | 物理页面分配程序 |
log.c | 文件系统记录与崩溃恢复 |
main.c | 在启动过程中控制其他模块的初始化 |
pipe.c | 管道 |
plic.c | RISC-V中断控制器 |
printf.c | 到控制台的格式化输出 |
proc.c | 进程和调度 |
sleeplock.c | 自锁并让出CPU |
spinlock.c | 自锁但不让出CPU |
start.c | 机器模式启动代码 |
string.c | C 字符串和字节数组库 |
syscall.c | 分派系统调用处理函数 |
sysfile.c | 文件相关系统调用 |
sysproc.c | 进程相关系统调用 |
trap.c | 陷阱和中断的响应返回C程序 |
uart.c | 串行端口控制台设备驱动程序 |
virtio_disk.c | 磁盘驱动程序 |
vm.c | 管理页表和地址空间 |
entry.S | 首次开机引导程序 |
kernelvec.S | 内核陷阱和计时器中断的处理程序 |
swtch.S | 线程切换 |
trampoline.S | 用于切换用户空间与内核空间的汇编代码 |
xv6的隔离单位是一个进程。进程的实现,可以防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。同时还可以防止进程破坏内核。
xv6内核用来实现进程的机制包括:用户/监督模式标志、地址空间和线程的时间片轮转。
进程的隔离基于进程抽象,进程抽象为一个进程提供了一种错觉,即该进程以为自己有私有的机器(私有的内存系统与私有的CPU)。其他进程不能对该进程的内存系统进行读写。
xv6使用页表(由硬件实现)给每个进程提供属于它自己的内存空间(地址空间)。RISC-V页表将虚拟地址(RISC-V指令操作的地址)映射为物理地址(CPU芯片发送到内存的地址)。
xv6为每个进程维护一个页表,定义该进程的地址空间,如下图所示:
进程的用户空间的内存地址空间从虚拟地址0开始,指令存放在最前面,其次是全局变量,然后是栈,最后是一个堆区(用于malloc,以便进程根据需要扩展)。
但进程的地址空间是有限的,它被一些因素限制,分析如下:
// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
在地址空间的顶端,xv6保留了一页,用于trampoline和映射进程trapframe的页,以便切换到内核。
xv6为每个进程维护了许多状态,记录在proc结构体中。一个进程最重要的内核状态是它的页表、内核栈和运行状态。xv6用p->XXX表示proc结构的元素,例如:
proc结构体如下:
// Per-process state struct proc { struct spinlock lock; // p->lock must be held when using these: enum procstate state; // Process state void *chan; // If non-zero, sleeping on chan int killed; // If non-zero, have been killed int xstate; // Exit status to be returned to parent's wait int pid; // Process ID // wait_lock must be held when using this: struct proc *parent; // Parent process // these are private to the process, so p->lock need not be held. uint64 kstack; // Virtual address of kernel stack uint64 sz; // Size of process memory (bytes) pagetable_t pagetable; // User page table struct trapframe *trapframe; // data page for trampoline.S struct context context; // swtch() here to run process struct file *ofile[NOFILE]; // Open files struct inode *cwd; // Current directory char name[16]; // Process name (debugging) };
每个进程都有一个执行线程(简称线程),执行进程的指令。
一个线程可以被暂停,然后再恢复。为了在进程间透明地切换,内核会暂停当前运行的线程,并恢复另一个进程的线程。
线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的栈中。每个进程有两个栈:用户栈和内核栈(p->kstack)。当进程执行用户指令时,只有它的用户栈在被使用,而它的内核栈是空的。当进程在内核中时,它的用户栈仍然包含保存的数据,但不被主动使用。进程的线程在用户栈和内存栈中交替执行。内核栈是独立的,受到保护,不受用户代码的影响。所以,即使一个进程用户栈被破坏了,内核也可以执行。
一个进程通过执行RISC-V的ecall指令进行系统调用。该指令提高硬件权限级别,并将程序计数器改变为内核定义的入口点。入口点代码会切换到内核栈,并执行实现系统调用的内核指令。当系统调用完成后,内核切换到用户栈,并通过调用sret指令返回用户空间,降低硬件权限级别,恢复执行系统调用前的用户指令。
进程的线程可以在内核中阻塞等待I/O,当I/O完成后,再从离开的地方恢复。
当RISC-V计算机开机时,它会初始化自己,并运行一个存储在只读存储器中的boot loader程序。boot loader将xv6内核加载到内存中。
boot loader将xv6内核加载到物理地址0x80000000的内存中。
注意:内核不放在0x0,而放在0x80000000处,是因为地址范围0x0~0x80000000包含了I/O设备。
然后在机器模式下,CPU从_entry开始执行xv6。RISC-V的启动过程中禁用分页硬件,即此时的虚拟地址直接映射到物理地址。
_entry的代码:
# qemu -kernel loads the kernel at 0x80000000 # and causes each hart (i.e. CPU) to jump there. # kernel.ld causes the following code to # be placed at 0x80000000. .section .text .global _entry _entry: # set up a stack for C. # stack0 is declared in start.c, # with a 4096-byte stack per CPU. # sp = stack0 + (hartid * 4096) la sp, stack0 li a0, 1024*4 csrr a1, mhartid addi a1, a1, 1 mul a0, a0, a1 add sp, sp, a0 # jump to start() in start.c call start spin: j spin
_entry代码解析:
xv6在start.c的声明空间中声明了初始栈的空间,这些空间的首地址存放于stack0中。
注意:4096*NCPU。这是因为xv6运行在多核的RISC-V处理器上,所以初始栈的空间等于核数乘以4096。
在RISC-V调用约定标准中,栈是向下增长并且栈指针总是16字节对齐。
// entry.S needs one stack per CPU. __attribute__ ((aligned (16))) char stack0[4096 * NCPU];
下面分析_entry的代码:
将stack0的地址存入栈指针寄存器sp中。
la sp, stack0
在a0寄存器中存放一个核所需的初始栈空间4096字节。
li a0, 1024*4
mhartid是运行当前程序的CPU核的ID,其值范围是0~MaxCore-1。
在a1寄存器中存放核数。
csrr a1, mhartid addi a1, a1, 1
计算出总的初始栈空间。
mul a0, a0, a1
将sp置于栈顶。
add sp, sp, a0
此时sp等于stack0+NCPU*4096字节,指向了栈顶。
栈设置好后,该汇编程序调用start这个C程序函数。
call start
调用后,正常情况下,程序不会返回到_entry程序,即下面两条指令不会被执行。
spin: j spin
若从调用start返回,说明操作系统存在问题,系统转入死循环。
start的代码:
// entry.S jumps here in machine mode on stack0. void start() { // set M Previous Privilege mode to Supervisor, for mret. unsigned long x = r_mstatus(); x &= ~MSTATUS_MPP_MASK; x |= MSTATUS_MPP_S; w_mstatus(x); // set M Exception Program Counter to main, for mret. // requires gcc -mcmodel=medany w_mepc((uint64)main); // disable paging for now. w_satp(0); // delegate all interrupts and exceptions to supervisor mode. w_medeleg(0xffff); w_mideleg(0xffff); w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE); // configure Physical Memory Protection to give supervisor mode // access to all of physical memory. w_pmpaddr0(0x3fffffffffffffull); w_pmpcfg0(0xf); // ask for clock interrupts. timerinit(); // keep each CPU's hartid in its tp register, for cpuid(). int id = r_mhartid(); w_tp(id); // switch to supervisor mode and jump to main(). asm volatile("mret"); }
start代码分析:
读mstatus寄存器的值保存在x中
unsigned long x = r_mstatus();
将对应的MPP位设置为01,即标志着前模式为监督者模式。
第一条语句将x的前模式清0,其他位保持不变。
第二条语句将x的前模式的低位置1。
x &= ~MSTATUS_MPP_MASK; x |= MSTATUS_MPP_S;
将修改后的x写回mstatus寄存器,当执行mret指令时,从机器模式切换至监督者模式。
w_mstatus(x);
将main的地址看作机器模式下发生异常时指令的地址,并将其保存至mepc寄存器中,当执行mret时,程序从发生异常的指令处恢复执行。
w_mepc((uint64)main);
将0写入页表寄存器satp来禁用虚拟地址转换。
w_satp(0);
将所有中断核异常委托给监督者模式。
w_medeleg(0xffff); w_mideleg(0xffff); w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
配置物理内存保护,以便让监督者模式访问所有物理内存。
w_pmpaddr0(0x3fffffffffffffull); w_pmpcfg0(0xf);
对时钟芯片编程以产生计时器中断
timerinit();
将核ID保存至tp寄存器中。
int id = r_mhartid(); w_tp(id);
执行mret指令,系统由机器模式切换至监督者模式,并从main()开始处运行。
asm volatile("mret");
注意:_entry和start函数都是在机器模式下运行,start执行mret指令后,机器模式才切换至监督者模式,并执行main函数。
main函数代码:
// start() jumps here in supervisor mode on all CPUs. void main() { if(cpuid() == 0){ consoleinit(); printfinit(); printf("\n"); printf("xv6 kernel is booting\n"); printf("\n"); kinit(); // physical page allocator kvminit(); // create kernel page table kvminithart(); // turn on paging procinit(); // process table trapinit(); // trap vectors trapinithart(); // install kernel trap vector plicinit(); // set up interrupt controller plicinithart(); // ask PLIC for device interrupts binit(); // buffer cache iinit(); // inode table fileinit(); // file table virtio_disk_init(); // emulated hard disk userinit(); // first user process __sync_synchronize(); started = 1; } else { while(started == 0) ; __sync_synchronize(); printf("hart %d starting\n", cpuid()); kvminithart(); // turn on paging trapinithart(); // install kernel trap vector plicinithart(); // ask PLIC for device interrupts } scheduler(); }
main函数代码分析:
main函数当前执行核是否是核0,若是则执行if语句中的,其余核执行else语句中的。
核0执行代码:
初始化终端。
consoleinit();
初始化输出互斥锁。
printfinit();
显示提示信息:“xv6 kernel is booting\n”。
printf("\n"); printf("xv6 kernel is booting\n"); printf("\n");
初始化物理内存页。
kinit();
创建内核页表。
kvminit();
将h/w页表寄存器切换到内核的页表,并启用分页。
kvminithart();
初始化进程表。
procinit();
设置trap向量。
trapinit();
安装内核陷阱向量。
trapinithart();
设置中断控制器。
plicinit();
对监督者模式的硬件线程设置uart启用位,及优先级阈值为0。
plicinithart();
缓冲区初始化。
binit();
inode缓冲区初始化。
iinit();
文件表初始化。
fileinit();
初始化虚拟磁盘。
virtio_disk_init();
创建第一个用户进程。第一个进程执行一个小程序initcode.S(user/initcode.S:1),该程序通过调用exec系统调用重新进入内核。
userinit();
同步。
__sync_synchronize();
started=1。
started = 1;
其余核执行代码:
等待核0初始化完成。
while(started == 0) ;
同步。
__sync_synchronize();
输出核ID信息。
printf("hart %d starting\n", cpuid());
将h/w页表寄存器切换到内核的页表,并启用分页。
kvminithart();
安装内核陷阱向量。
trapinithart();
对监督者模式的硬件线程设置uart启用位,及优先级阈值为0。
plicinithart();
main函数调用userinit来创建第一个进程,第一个进程是一个使用RISC-V汇编编写的小程序initcode.S。其代码如下:
# Initial process that execs /init.
# This code runs in user space.
#include "syscall.h"
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit
# char init[] = "/init\0";
init:
.string "/init\0"
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
代码分析:
initcode.S通过调用exec系统调用重新进入内核,exec使用一个新程序init替换当前进程的内存和寄存器。一旦内核完成exec,它就会在init进程中返回到用户空间。init的代码如下:
int main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
for(;;){
// this call to wait() returns if the shell exits,
// or if a parentless process exits.
wpid = wait((int *) 0);
if(wpid == pid){
// the shell exited; restart it.
break;
} else if(wpid < 0){
printf("init: wait returned an error\n");
exit(1);
} else {
// it was a parentless process; do nothing.
}
}
}
}
init在需要时会创建一个新的控制台设备文件,然后以文件描述符0、1和2的形式打开它,然后在控制台上启动一个shell(使用exec执行shell程序),这样系统就启动了。
第二节通过重点讲解xv6进程的实现概况及xv6操作系统的启动,解释了xv6操作系统是如何启动这个重要问题。
[1] FrankZn/xv6-riscv-book-Chinese (github.com)
[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com)
[3] 一文了解宏内核和微内核_微内核和宏内核的区别-CSDN博客
[4] xv6实验课程:xv6启动过程分析-CSDN博客
[5] 请问Linux内核的trampoline是啥意思? - 知乎 (zhihu.com)
[6] 【xv6学习之番外篇】详解struct Env 与 struct Trapframe-CSDN博客