[MIT 6.1810]Xv6 Chapter 2

Operating system organization

一个操作系统必须满足三个需求:

  1. 复用
  2. 隔离
  3. 交互

xv6是用LP64C写的(long和pointer是8B,int是4B)。

2.1 Abstracting physical resources

通过抽象硬件资源,可以使操作系统方便的实现复用、隔离、交互。

2.2 User mode,supervisor mode,and system calls

隔离需要应用程序和操作系统之间有一条硬边界。

为了实现这种隔离,操作系统必须使进程不能访问操作系统的指令和数据,也不能访问其他进程的指令和数据。

CPU为这种隔离提供了硬件支持。RISC-V有三种指令执行模式:机器模式(machine mode)、内核模式(supervisor mode),用户模式(user mode)。

机器模式(machine mode)

该模式的指令有完整的特权。CPU初始启动的时候在该模式,然后在boot的过程中设置电脑,之后就会切换到内核模式。

内核模式(supervisor mode)

该模式下可以执行特权指令,例如开关中断、访问页表寄存器等。如果一个用户模式下的进程尝试执行特权指令,CPU不会执行,而是切换到内核模式,然后终止该进程。

应用想要执行内核中的函数必须切换到内核中执行,且不能直接执行内核函数,而是通过一种特殊的指令进入内核(RISV-V提供ecall指令),之后内核会去检查应用传进来的参数是否合法(例如内存是否越界,是否有权限执行这种操作等)。之所以用通过统一的指令来进入内核,是防止有的程序跳过参数检查等步骤。

2.3 Kernel organization

设计的关键是操作系统的什么部分应该在内核模式下运行。

宏内核

一种方法是将整个操作系统放在内核中,称为宏内核。

宏内核的优点是不用决定哪部分放在内核;操作系统不同部分合作容易。缺点是系统十分复杂,错误通常会导致整个内核崩溃。

微内核

另一种方法是最小化内核的代码,将大部分系统功能实现在用户模式下,称为微内核。

在微内核模式下,操作系统的有些部分可以运行在用户模式的进程中,称为服务器(server)。操作系统提供一种进程间通信的方法,程序间通过互相发送消息来进行通信。

2.4 Code:xv6 organization

xv6 的内核源代码位于 kernel/ 子目录中,源代码被分为多个文件,遵循一定的模块化概念。图 2.2 列出了这些文件。模块之间的接口在 defs.h(即 kernel/defs.h)中定义。

2.5 Process overview

xv6中隔离的最小单元是进程。

内核实现进程的机制包括用户/特权模式标志、地址空间以及线程的时间片分配。

进程给程序提供了一种自己拥有整个地址空间和CPU的假象。

xv6 使用页表(由硬件实现)为每个进程提供独立的地址空间。RISC-V 页表将虚拟地址(RISC-V 指令操作的地址)转换为物理地址(CPU 发送到主内存的地址)。

xv6 为每个进程维护一个独立的页表,定义该进程的地址空间。如图 2.3 所示,地址空间从虚拟地址零开始,包括进程的用户内存。首先是指令,然后是全局变量,再是栈,最后是“堆”区域(用于 malloc),该区域可以根据需要扩展。限制进程地址空间最大大小的因素有很多:RISC-V 的指针宽度为 64 位,硬件在查找页表中的虚拟地址时仅使用低 39 位,而 xv6 只使用这 39 位中的 38 位。因此,最大地址为 (2^{38} - 1 = 0x3fffffffff),也就是 MAXVA(定义在 kernel/riscv.h:378)。在地址空间的顶部,xv6 放置了一个 4096 字节的 trampoline 页和一个 trapframe 页。xv6 使用这两个页面进行内核的切换;trampoline 页包含切换进出内核的代码,而 trapframe 用于保存进程的用户寄存器,具体内容在第四章中解释。

内核为每个进程维护各自的状态,存放在proc结构体里(kernel/proc.h:85)。

每个进程都有一个控制线程(简称线程),它保存执行该进程所需的状态。在任何给定时刻,线程可能在 CPU 上执行,或者处于挂起状态(未执行但能够在未来恢复执行)。要在进程之间切换 CPU,内核会挂起当前在该 CPU 上运行的线程并保存其状态,然后恢复另一个进程之前挂起的线程的状态。线程的大部分状态(局部变量、函数调用的返回地址)存储在线程的栈上。

每个进程有两个栈:用户栈和内核栈(p->kstack)。当进程执行用户指令时,仅使用其用户栈,而内核栈为空。当进程进入内核(进行系统调用或中断)时,内核代码在进程的内核栈上执行;在进程处于内核状态时,其用户栈仍然保存着数据,但不再被主动使用。进程的线程在活跃使用用户栈和内核栈之间交替切换。内核栈是独立的(并且受到保护,防止用户代码破坏),以确保即使进程破坏了其用户栈,内核也能正常执行。

进程可以通过执行 RISC-V 的 ecall 指令来发起系统调用。此指令提升硬件特权级,并将程序计数器更改为内核定义的入口点。入口点的代码切换到进程的内核栈,并执行实现系统调用的内核指令。当系统调用完成时,内核切换回用户栈,并通过调用 sret 指令返回用户空间,这将降低硬件特权级,并在系统调用指令后继续执行用户指令。进程的线程可以在内核中“阻塞”,以等待 I/O 操作完成,并在 I/O 完成后从上次中断的地方恢复。

p->state 指示进程的状态,可能是已分配、准备运行、当前在 CPU 上运行、等待 I/O 或正在退出。

p->pagetable 保存进程的页表,格式符合 RISC-V 硬件的要求。xv6 使得分页硬件在执行进程的用户空间时使用 p->pagetable。进程的页表还记录了分配给该进程存储内存的物理页的地址。

总之,进程结合了两个设计理念:地址空间使进程拥有自己的内存的错觉,线程使进程拥有自己的 CPU 的错觉。在 xv6 中,进程由一个地址空间和一个线程组成。在实际的操作系统中,进程可能拥有多个线程,以利用多个 CPU。

2.6 Code:starting xv6,the first process and system call

当RISC-V计算机上电后,首先会初始化硬件,然后执行一个存放在ROM中的boot loader。bool loader负责把xv6内核装载进内存中。然后,在机器模式下,CPU从_entry(kernel/entry.S:7)开始执行xv6。开始时页表装置被关闭,虚拟地址直接被映射到对应的物理地址。

loader将内核装在到物理地址0x80000000,之所以装载到这里,是因为0x0-0x80000000包含了IO设备。

从代码中可以看到, _entry.S 为每个CPU都准备了一个栈。通过 csrr 指令取得当前hart的id(通常从0开始),并以该id为偏移,将不同CPU的栈错开。xv6在 start.c (kernel/start.c:11)中声明了一个初始栈数组, stack0_entry 中的指令将sp改为 stack0+4096 * hartid (栈顶)。之后 _entry 调用 start (kernel/start.c:15)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
        # 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

start 执行一些只能在机器模式下执行的配置,然后转入内核模式。为了进入内核模式,RISC-V提供了指令 mret ,这条指令通常用来从内核模式到机器模式的调用返回。 start 并不上述的情况,我们可以通过手动设置使之好像是这种情况。首先,设置mstatus寄存器为S态,然后设置mepc寄存器为main的地址,接下来在进行一些相应的配置。在转入内核模式之前, start 还使时钟芯片能够产生时钟中断。之后执行 mret 指令,调转到 main 继续执行(kernel/main.c:11)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

void main();
void timerinit();

// entry.S needs one stack per CPU.
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

// 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");
}

// ask each hart to generate timer interrupts.
void
timerinit()
{
// enable supervisor-mode timer interrupts.
w_mie(r_mie() | MIE_STIE);

// enable the sstc extension (i.e. stimecmp).
w_menvcfg(r_menvcfg() | (1L << 63));

// allow supervisor to use stimecmp and time.
w_mcounteren(r_mcounteren() | 2);

// ask for the very first timer interrupt.
w_stimecmp(r_time() + 1000000);
}

main 首先初始化几个设别和子系统,然后通过调用 userinit (kernel/proc.c)来创建第一个用户进程。第一个进程执行一个用RISC-V汇编写的小程序,这个小程序执行了xv6中的第一个系统调用。这个小程序在initcode.S中,它实际执行了 exec(init, argv) 。 它将 exec 系统调用号(SYS_EXEC)load进寄存器a7,然后执行 ecall 指令重新进入内核。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

volatile static int started = 0;

// 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();
}

内核在 syscall 中使用a7寄存器中的值去调用指定的系统调用,即 exec 。系统调用表(system call table)(kernel/syscall.c:107)将SYS_EXEC映射到系统调用 sys_exec 。之后 exec 会把内存和寄存器替换为 init (user/init.c:15)的内存镜像。

当内核完成 exec 调用后,会返回用户空间执行 init 函数。该函数首先创建一个新的控制台设备文件,并通过文件描述符 0、1 和 2 打开它。随后,init 在该控制台上启动 shell,至此,系统已经启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// init: The initial user-level program

#include "kernel/types.h"
#include "kernel/stat.h"
#include "kernel/spinlock.h"
#include "kernel/sleeplock.h"
#include "kernel/fs.h"
#include "kernel/file.h"
#include "user/user.h"
#include "kernel/fcntl.h"

char *argv[] = { "sh", 0 };

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.
}
}
}
}


[MIT 6.1810]Xv6 Chapter 2
https://erlsrnby04.github.io/2024/09/24/MIT-6-1810-Xv6-Chapter-2/
作者
ErlsrnBy04
发布于
2024年9月24日
许可协议