Xv6 剖析

个人对 xv6 这个简易操作系统内核比较感兴趣,就想写一个剖析,看看 xv6 都是怎么实现这些功能的,坚持一周更一个话题

预备知识

可以直接读下面的话题,这里是便于读此文章时快速了解

关于 xv6

xv6 是一个基于 RISC-V 64 位架构 CPU 的类 Unix 简易操作系统

寄存器

通用寄存器

Register ABI Name Description Saver
x0 zero Hard-wired zero -
x1 ra Return address Caller
x2 sp Stack pointer Callee
x3 gp Global pointer -
x4 tp Thread pointer -
x5-7 t0-2 Temporaries Caller
x8 s0/fp Saved register / frame pointer Callee
x9 s1 Saved register Callee
x10-x11 a0-1 Function arguments / return values Caller
x12-x17 a2-7 Function arguments Caller
x18-27 s2-11 Saved registers Callee
x28-31 t3-6 Temporaries Caller

在汇编中一般使用寄存器的 ABI 名字

还有 f 开头的寄存器,用于浮点数,没有列举出来

ra 保存当前函数的返回地址

s0/fp、sp 用于保存栈底和栈顶,注意 s0 和 fp 是同一个寄存器

tp 保存当前 CPU 核 id

a0 还用于保存函数返回值

  • Saver
    • 函数之间除了 a0 寄存器传递返回值外,应该不能互相影响,因此其他寄存器需要被保存下来,Saver 指定当前函数的寄存器是由调用函数还是被调用函数保存
    • Caller:调用函数
      • Caller 在函数开始就可以选择 Caller 类寄存器保存下来,一般只保存 ra,具体由编译器选择
    • Callee:被调用函数
      • Callee 只保存它会用到的 Callee 类寄存器,在返回到 Caller 前恢复
    • 为什么要分开保存呢?
      • Callee 类的寄存器不能确定下层函数会不会用到,而且随时会变化,每次调用函数前后都存取一遍,性能会降低很多,为了提高性能,由被调用者来保存更加合适

控制状态寄存器

CSR(Control Status Register)

  • pc:指向下一条将要指向指令的地址
    • Program Counter
  • satp:保存一级页表的物理地址
    • Supervisor Address Translation and Protection
  • stvec:保存发生 trap 时跳转的地址
    • Supervisor Trap Vector Base Address Register
    • 用户模式下会指向 kernel/trapmpoline.S 的 uservec
    • 管理者模式下会指向 kernel/kernelvec.S 的 kernelvec
  • sepc:保存发生 trap 时 pc 的值,便于返回到用户进程
    • Supervisor Exception Program Counter
    • 内核可控制 sepc 让 sret 返回到适当的位置
  • scause:记录发生 trap 的原因,内核根据这个做进一步处理
    • Supervisor Cause Register
    • 8 表示系统调用
    • 其他表示错误或者中断
  • sstatus:以 bitmap 形式保存一些控制信息
    • Supervisor Status Register
    • SPP:表示 trap 来自用户模式还是管理者模式,并用来告诉 sret 返回到哪个模式
    • SIE:表示在管理者模式下是否允许中断,0 表示禁止,RISC-V 会推迟硬件中断
  • sscatch:在内核态与用户态时起辅助作用
    • 一般用来保存 a0
    • 在 xv6 的 2020 版本用来保存 trapframe 地址

汇编指令

  • csrr、csrw
    • 用于读写 CSR
  • sfence.vma
    • 清空 TLB 缓存
  • ecall(Environment Call)
    1. 将模式从用户模式更改为管理者模式(sstatus 的 SPP 位)
    2. 将 pc 寄存器保存到 sepc 寄存器
    3. 将 pc 寄存器改为 stvec 寄存器值
    4. 关闭硬件中断(将 sstatus 的 SIE 位设为 0)
  • sret(Supervisor Return)
    1. 将模式从管理者模式更改为指定的模式(sstatus 的 SPP 位)
    2. 将 pc 寄存器改为 sepc 寄存器值
    3. 启用硬件中断(将 sstatus 的 SIE 位设为 1)

地址空间

  • trampline:在内核页表和用户页表中都有映射,作为用户进程切换到内核的跳板,放在虚拟地址空间的顶部(0x3ffffff000),大小为一页
  • trapframe:在用户页表中有映射,用于切换到内核时保存用户进程的上下文,放在 trampoline 下面(0x3fffffe000),大小为一页

其他

kernel/proc.h
1
2
3
4
5
6
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};
  • proc
    • 保存当前 CPU 核运行的进程
  • context
    • 保存内核用于调度功能的线程的上下文

怎么在内核和用户进程间切换

在 RISC-V 中,有三种 trap

  • 硬件中断
  • 系统调用
  • 异常

后两个也称为软件中断

在用户进程中发生 trap 时,需要陷入到内核中进行处理,处理完后内核会根据情况回到用户进程或者杀死用户进程,这其中就涉及到内核和用户进程间的切换

切换过程

发生 trap 时,硬件会执行以下操作

  1. 将 sstatus 中的 SIE 位清零,禁用硬件中断以防止干扰,如果这个 trap 是硬件中断,不会做以下操作
  2. 将模式从用户模式更改为管理者模式
  3. 将 pc 寄存器的值复制到 sepc 寄存器中
  4. 将当前模式(用户或者管理者)保存到 sstatus 寄存器的 SPP 位
  5. 设置 scause 寄存器的值反映 trap 原因
  6. 将 stvec 寄存器的值复制到 pc 寄存器中

此时 pc 指向 trampoline,开始执行,注意,此时页表寄存器并没有便,也就是说还使用着用户进程的页表

kernel/trampoline.S
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
.globl uservec
uservec:
# 缓存 a0
csrw sscratch, a0

# 把 trapframe 地址放到 a0 中
li a0, TRAPFRAME

# 把用户寄存器保存到 trapframe 中,kernel/proc.h 中对应着变量地址
sd ra, 40(a0)
sd sp, 48(a0)
# 此处省略...
sd t5, 272(a0)
sd t6, 280(a0)

# 再把原 a0 的值保存进去
csrr t0, sscratch
sd t0, 112(a0)

# 从 trapframe 中取出内核栈的指针、CPU 核的 id、处理 trap 的地址、内核页表
ld sp, 8(a0)
ld tp, 32(a0)
ld t0, 16(a0)
ld t1, 0(a0)

# 清空 TLB 缓存,这里英文注释是写等待之前的内存操作全部完全,不是很懂
sfence.vma zero, zero

# 切换到内核页表
csrw satp, t1

# 清空 TLB 缓存
sfence.vma zero, zero

# 跳转到处理 trap 的地方,也就是 kernel/trap.c 的 usertrap 函数地址
jr t0

总结:

  1. 将用户进程的寄存器保存到 trapframe 中
  2. 切换内核栈,内核线程 id,内核页表
  3. 跳转到 kernel/trap.c 的 usertrap 函数

uservec 主要就是做好切换到内核的准备

kernel/trap.c
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
void
usertrap(void)
{
// ...

// 设置 stvec 为 kernelvec,如果此时发生了 trap 会跳转到 kernelvec 进行处理
w_stvec((uint64)kernelvec);

// 使用 myproc 获取当前进程
struct proc *p = myproc();

// 保存用户进程的 pc 到 trapframe
p->trapframe->epc = r_sepc();

// 根据发生 trap 的原因处理 trap
if(r_scause() == 8){
// 系统调用
// ...
} else if((which_dev = devintr()) != 0){
// 硬件中断
// ...
} else {
// 其他异常
// ...
}

// 如果用户进程被杀死就退出
if(killed(p))
exit(-1);

// 如果是计时器中断,则进行进程调度
if(which_dev == 2)
yield();

usertrapret();
}

总结:

  1. 把 stvec 改成 kernelvec 以处理发生在内核的 trap
  2. 保存用户进程的 pc 到 trapframe
  3. 根据 scause、devintr 处理 trap
  4. 检查是否计时器中断,若是则进行进程调度
  5. 检查进程是否被杀死,若是则退出
  6. 跳转到 usertrapret 函数

usertrap 主要就是根据 trap 类型处理 trap

kernel/trap.c
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
void
usertrapret(void)
{
struct proc *p = myproc();

// 关闭硬件中断,因为后面要切换 stvec 为 uservec,但是现在又在内核态,如果发生硬件中断会导致混乱
intr_off();

// 把 uservec 的地址写入 stvec 寄存器
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);

// 保存内核页表、内核栈、usertrap 地址、CPU 核 id
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()

// set up the registers that trampoline.S's sret will use
// to get to user space.

// 设置 sstatus 寄存器
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // SPP 位清零,以便 sret 返回到用户模式
x |= SSTATUS_SPIE; // SPIE 位置 1,允许用户模式下硬件中断
w_sstatus(x);

// 把用户进程的 pc 写入 sepc
w_sepc(p->trapframe->epc);

// 取出用户页表
uint64 satp = MAKE_SATP(p->pagetable);

// 取出 userret 地址,准备调用,并传递用户页表进去
uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64))trampoline_userret)(satp);
}

总结:

  1. 保存内核页表、内核栈、CPU 核的 id
  2. 配置 sstatus 寄存器
  3. 调用 userret 并传递用户页表

usertrapret 主要是处理 trap 后为返回到用户进程做准备

kernel/trampoline.S
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
globl userret
userret:
# 转换到用户页表
sfence.vma zero, zero
csrw satp, a0
sfence.vma zero, zero

# 从 trapframe 中取出用户进程的寄存器值
li a0, TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
# 此处省略...
ld t6, 280(a0)
ld a0, 112(a0)

# 返回到用户模式和用户进程的 pc 所指位置
sret

userret 主要是恢复用户进程的上下文,回到发生 trap 的位置(或者发生 trap 位置后面一个指令,比如 ecall 后面的指令)

省流小结

真省流吗?

用户进程 -> uservec -> usertrap -> usertrapret -> userret -> 用户进程

  1. 发生 trap 时,程序会跳转到 stvec 寄存器指向的 kernel/trampoline.S 中的 uservec 保存用户进程的上下文,并设置内核栈、内核线程 id、内核页表
  2. 然后跳转到 kernel/trap.c 中的 usertrap 对 trap 进行处理
  3. 如果要恢复到用户进程,会跳转到 kernel/trap.c 的 usertrapret 为返回到用户进程做准备
  4. 最后跳转到 kernel/trampoline.S 中的 userret 恢复用户进程上下文,回到发生 trap 的位置(或者发生 trap 位置后面一个指令,比如 ecall 后面的指令)

怎么实现系统调用

在 user/user.h 我们可以看到系统调用函数的声明

user/user.h
1
2
3
4
5
6
7
8
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
int write(int, const void*, int);
int read(int, void*, int);

...

那么,就有一个问题,系统调用明明是要用 ecall 来使用,xv6 是怎么把系统调用做成一个函数,使得用户程序像调用函数那样调用系统调用?

怎么制作用户系统调用函数

我们可以在 user/usys.S 看到系统调用函数在汇编中的定义

user/usys.S
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "kernel/syscall.h"
.global fork
fork:
li a7, SYS_fork
ecall
ret
.global exit
exit:
li a7, SYS_exit
ecall
ret
.global wait
wait:
li a7, SYS_wait
ecall
ret

...

可以观察到,每一个系统调用函数都只是将系统调用号传递给 a7,然后使用 ecall 调用系统调用,最后返回到上一层函数

而参数传递是用户程序在调用系统调用函数之前,会根据函数声明将参数传递给对应的寄存器,然后跳转到系统调用函数的地址执行

这个 user/usys.S 文件其实是由一个 perl 脚本 user/usys.pl 生成的,便于添加系统调用

好的,上面解释了系统调用函数的实现,下面就看看 ecall 到底做了什么来请求内核完成系统调用

怎么请求内核完成系统调用

ecall 是由用户进程主动陷入 trap 请求内核完成系统调用的汇编指令

它会使 scause 寄存器值设为 8,在 RISC-V 中它代表着系统调用

在切换到内核态后,跳转到 kernel/trap.c 的 usertrap

我们仔细看看 kernel/trap.c 的 usertrap 怎么处理系统调用的

kernel/trap.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void
usertrap(void)
{
// ...

// 当检查到 trap 原因是系统调用时
if(r_scause() == 8){

// 先检查用户进程是否被其他进程杀死
if(killed(p))
exit(-1);

// 把 epc 加 4,ecall 指令占 4 字节,在恢复到用户进程时,跳转到 ecall 后面的指令
p->trapframe->epc += 4;

// 启用硬件中断
intr_on();

// 开始处理系统调用
syscall();
}

// ...
}

usertrap 检查到是系统调用时,将 epc 的值加 4,以便返回时执行 ecall 后面的指令,然后跳转到处理系统调用的函数 syscall

kernel/syscall.c
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
// 处理系统调用的函数的原型
extern uint64 sys_fork(void);
extern uint64 sys_exit(void);
// ...
extern uint64 sys_mkdir(void);
extern uint64 sys_close(void);

static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
// ...
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};

void
syscall(void)
{
int num;
struct proc *p = myproc();

// 取出系统调用号,检查对应的系统调用是否存在,不存在则打印错误
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// 存在则执行对应的系统调用处理函数,并把返回值存到 a0 中,后面会传递给用户进程
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}

syscall 会根据用户进程指定的系统调用号执行指定的系统调用处理函数

最后会经过 usertrapretuserret 返回到用户进程

省流小结

ecall -> uservec -> usertrap -> syscall -> usertrapret -> userret -> ecall + 4

关键在于 syscall 根据系统调用号调用对应的系统调用处理函数

怎么获取系统调用所需的用户参数

#todo

怎么实现可变参数 printf 并输出

这里介绍内核的 printf(kernel/printf.c)

可变参数

其实可变参数是由 C 语言库和编译器来实现的

C 语言库中给出了 va_startva_argva_end 接口,这里只简单介绍一下,想详细了解可参考末尾的链接,不过是 x86_64 架构,原理类似

  • va_list:一个变量类型,variable arguments list 可变参数列表
  • va_start(v, l):初始化可变参数列表,l 为函数的第一个参数名
  • va_arg(v, type):从可变参数列表中取出一个参数,须指定参数类型
  • va_end(v):结束时释放 va_list 内存

我们知道 RISC-V 前 8 个参数是放在 a0-7 寄存器中,它是怎么又从寄存器中取值又从栈中取值的

答案是编译器会将除 a0 以外的寄存器全部压到栈中,这样所有参数都是连续存储在栈中,便于取值,但是不要自己去取地址然后输出(笑),要用 C 语言库的接口

输出到硬件

每次输出时将一个字符传给硬件,硬件回显给控制台

内核中有 consputc 就是将字符传给硬件的函数,在 kernel/console.c 中

kernel/console.c
1
2
3
4
5
6
7
8
9
10
11
void
consputc(int c)
{
if(c == BACKSPACE){
// 如果是退格,那么就输出先退格,输出一个空格,再退格
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
// 否则直接输出
uartputc_sync(c);
}
}

传递给硬件寄存器具体在 kernel/uart.c 的 uartputc_sync 函数中

kernel/uart.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void
uartputc_sync(int c)
{
// 关闭硬件中断
push_off();

if(panicked){
for(;;)
;
}

// 等待之前的字符传输完成,硬件准备接收字符
while((ReadReg(LSR) & LSR_TX_IDLE) == 0)
;
// 把字符写入硬件用于接收字符的寄存器中
WriteReg(THR, c);

// 开启硬件中断
pop_off();
}

printf

接下来看看 printf 代码实现

kernel/printf.c
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
void
printf(char *fmt, ...)
{
va_list ap;
int i, c, locking;
char *s;

// 加锁
locking = pr.locking;
if(locking)
acquire(&pr.lock);

if (fmt == 0)
panic("null fmt");

// 初始化 ap
va_start(ap, fmt);

// 遍历 fmt
for(i = 0; (c = fmt[i] & 0xff) != 0; i++){
if(c != '%'){
consputc(c);
continue;
}
c = fmt[++i] & 0xff;
if(c == 0)
break;
switch(c){
// 如果前一个是 % 接下来根据后面的字母获取对应类型的变量
case 'd':
printint(va_arg(ap, int), 10, 1);
break;
case 'x':
printint(va_arg(ap, int), 16, 1);
break;
case 'p':
printptr(va_arg(ap, uint64));
break;
case 's':
if((s = va_arg(ap, char*)) == 0)
s = "(null)";
for(; *s; s++)
consputc(*s);
break;
case '%':
consputc('%');
break;
default:
// 如果是不支持的字母,则直接输出 % 和字母
consputc('%');
consputc(c);
break;
}
}

// 释放内存
va_end(ap);

// 解锁
if(locking)
release(&pr.lock);
}

其实逻辑挺简单的

参考

揭秘X86架构C可变参数函数实现原理:其实这篇文章所说的 x86 指的是 x86_64

怎么成功创建一个进程

#todo

怎么实现页表的创建与更新

#todo

怎么实现虚拟地址映射

#todo

怎么实现线程切换

作者

Humoooor

发布于

2023-05-23

更新于

2024-01-04

许可协议

评论