xv6 系统调用与第一个进程

该文章作为本人学习 MIT 6.S081 课程与 xv6 book 的学习笔记,如有错误或疑问,欢迎联系我

什么是系统调用

对于用户程序而言,是无法与计算机硬件等进行交互的,必须通过系统调用通知内核与硬件交互,例如读取文件,只有通过read系统调用通知内核才能读取对应的文件。通过系统调用,可以把内核和用户完全分隔开。

在xv6中有许多系统调用,甚至xv6 lab中让你来添加系统调用。xv6实现的系统调用如下所示。

接下来就是如何进行系统调用,如何从用户程序切换到内核程序,我们从xv6第一个进程的启动开始讲起。

在操作系统中,有两个非常重要的系统调用:fork和exec,fork用于创建一个新的进程,exec用于执行可执行程序,一般来说,操作系统都是创建第一个进程然后在第一个进程中fork出新进程,然后在新进程中exec程序来工作的。

内核和用户进程的内存空间

其中比较重要的是kernel和user的内存中的最高处是trampoline(跳板)页面,其中保存着用于用户态跳转到内核态的代码。

其次就是在user内存中在trampoline下面的trapframe(陷阱帧)页面。

对于内核态trampoline代码的分配请参考我上一篇 笔记 。对于用户态trampoline代码,是在proc_pagetable函数(每次初始化并给进程分配页表的时候调用)中实现,其中还分配了trapframe页

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

extern char trampoline[]; // trampoline.S

// Create a user page table for a given process, with no user memory,
// but with trampoline and trapframe pages.
pagetable_t proc_pagetable(struct proc *p) {
pagetable_t pagetable;

// An empty page table.
pagetable = uvmcreate();
if (pagetable == 0) return 0;

// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.

// 分配trampoline页面,并且把trampoline代码映射进去
if (mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline,
PTE_R | PTE_X) < 0) {
uvmfree(pagetable, 0);
return 0;
}

// map the trapframe page just below the trampoline page, for
// trampoline.S.
if (mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p->trapframe),
PTE_R | PTE_W) < 0) {
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmfree(pagetable, 0);
return 0;
}

return pagetable;
}

在trapframe页面中保存了一个如下的数据结构:

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
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap()
/* 24 */ uint64 epc; // saved user program counter
/* 32 */ uint64 kernel_hartid; // saved kernel tp
/* 40 */ uint64 ra;
/* 48 */ uint64 sp;
/* 56 */ uint64 gp;
/* 64 */ uint64 tp;
/* 72 */ uint64 t0;
/* 80 */ uint64 t1;
/* 88 */ uint64 t2;
/* 96 */ uint64 s0;
/* 104 */ uint64 s1;
/* 112 */ uint64 a0;
/* 120 */ uint64 a1;
/* 128 */ uint64 a2;
/* 136 */ uint64 a3;
/* 144 */ uint64 a4;
/* 152 */ uint64 a5;
/* 160 */ uint64 a6;
/* 168 */ uint64 a7;
/* 176 */ uint64 s2;
/* 184 */ uint64 s3;
/* 192 */ uint64 s4;
/* 200 */ uint64 s5;
/* 208 */ uint64 s6;
/* 216 */ uint64 s7;
/* 224 */ uint64 s8;
/* 232 */ uint64 s9;
/* 240 */ uint64 s10;
/* 248 */ uint64 s11;
/* 256 */ uint64 t3;
/* 264 */ uint64 t4;
/* 272 */ uint64 t5;
/* 280 */ uint64 t6;
};

其中保存都是一些在跳转到内核的时候需要保存的寄存器现场,例如a0、a1等等。其中比较重要的是前几个字段,kernel_satp保存内核页表的地址,kernel_sp保存进程的内核栈地址,kernel_trap保存usertrap的地址,epc保存在跳转内核之前用户的pc值。

第一个进程

首先我们看向main.c中的main函数,在初始化一些内容之后进入userinit函数开启第一个进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void main() {
if (cpuid() == 0) {
...
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
...
userinit(); // first user process
....
}

scheduler();
}

看到userinit函数,首先调用allocaproc分配页表、pid以及trapframe和trampoline页面,在allocproc函数中还有一个比较特殊的地方,它将当前进程上下文中的ra字段定义为forkret函数的地址。

进程上下文中的ra字段在后续scheduler函数切换上下文的时候会加载到ra寄存器,而ra寄存器的作用是在函数return的时候跳转到ra寄存器中的地址,上述过程将ra字段定义为forkret函数的地址,那么在下次切换上下文就会将ra字段加载到ra寄存器,return之后就会到forkret函数,具体看我下一篇笔记。

接下来调用uvmfirst函数,在用户页表最低处分配一个页,并且将initcode放进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// Set up first user process.
void userinit(void) {
struct proc *p;

p = allocproc();
initproc = p;

// allocate one user page and copy initcode's instructions
// and data into it.
uvmfirst(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;

// prepare for the very first "return" from kernel to user.
p->trapframe->epc = 0; // user program counter
p->trapframe->sp = PGSIZE; // user stack pointer

safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");

p->state = RUNNABLE;

release(&p->lock);
}

对于initcode,它是如下一段十六进制数,其实是initcode.S的十六进制表示,在汇编代码initcode.S中可以看到调用了ecall指令,这就是risc-v中的系统调用指令,a0寄存器保存了第一个参数/init\0(第一个进程的名字),a7寄存器保存了SYS_exec(对应exec函数调用),所以这一段就是执行了exec(init, argv)系统调用。

1
2
3
4
5
6
7
8
9
// a user program that calls exec("/init")
// assembled from ../user/initcode.S
// od -t xC ../user/initcode
uchar initcode[] = {0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x45, 0x02, 0x97,
0x05, 0x00, 0x00, 0x93, 0x85, 0x35, 0x02, 0x93, 0x08,
0x70, 0x00, 0x73, 0x00, 0x00, 0x00, 0x93, 0x08, 0x20,
0x00, 0x73, 0x00, 0x00, 0x00, 0xef, 0xf0, 0x9f, 0xff,
0x2f, 0x69, 0x6e, 0x69, 0x74, 0x00, 0x00, 0x24, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
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
// 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

然后我们回到main.c中的main函数,调用完userinit之后会调用scheduler函数,他会调度我们的init进程执行。

到目前为止,我们第一个进程的页表已经建立,trampoline和trapframe页面我们页分配好了,并且写入了trampoline代码和trapframe数据,还将initcode的汇编代码映射到了用户页表,只等scheduler调度init进程执行initcode代码。

系统调用

当进行系统调用时,我们先了解下面几个寄存器:

  • stvec: 保存trap handler的代码地址(系统调用是trap的一种),当我们陷入到内核的时候会跳转到这个地方
  • sepc: 当我们陷入内核的时候,我们把用户当前的pc复制到这里,在sret指令回到用户态的时候将sepc的值复制回pc
  • scause: 保存陷入内核的原因,如果是系统调用,这个寄存器的值是8.
  • sscratch: 内核在此处放置一个值,在陷阱处理程序的最开始时派上用场。
  • sstatus: sstatus 中的 SIE 位控制是否启用设备中断。如果内核清除 SIE,RISC-V 将推迟设备中断,直到内核设置 SIE。 SPP 位指示陷阱是来自用户模式还是管理员模式,并控制 sret 返回的模式。

当我们真正执行到上面的ecall指令时,我们的硬件会执行下面的操作

  1. 清除sstatus中的SIE位关闭中断
  2. 将当前用户程序的pc复制到sepc
  3. 将当前模式(用户或supervisor)保存在sstatus寄存器的SPP位中
  4. 设置scause值为8
  5. 设置当前模式为supervisor模式(因为后续要跳转到内核执行内核代码)
  6. 将stvec寄存器复制到pc
  7. 开始执行新pc的地址

那我们第一次进行系统调用的时候,stvec中的值是什么呢?

To be continue….