简述:trap机制

有三种事件会导致CPU搁置普通命令的执行,强制将控制权转交给处理该事件的特殊代码。

  • 系统调用(system call):执行ecall指令,向内核请求并操作特定硬件资源。

  • 异常(exception):由尝试做非法事件的指令触发,例如除零操作。

  • 设备中断(device interrupt):当设备发出信号时提示内核需要注意,例如当磁盘完成读写请求时。

xv6使用trap(陷阱)作为这三种情况的术语。通常,代码在执行时发生 trap,之后都会被
恢复,而且不需要意识到发生了什么特殊的事情(trap 是透明的)。

透明性对于中断十分重要,被中断的代码通常不会意识到会发生trap。

trap执行的顺序为:

  1. 控制权转移到内核(在内核中忽略这一步)

  2. 内核保存寄存器和其他状态(存储到内存中)

  3. 内核执行特定的处理程序代码

  4. 内核恢复保存的状态,从trap中返回

  5. 代码从发起trap的地方恢复

xv6中trap处理的四个阶段

  1. RISC-V CPU采取硬件行动(特殊指令,如ecall)

  2. 执行汇编指令(trampoline.S)进入内核的C语言代码(kernel/traps)处理

  3. C函数(usertrap)将决定怎样处理trap,如系统调用、中断、xv6没有提供异常处理

  4. 内核执行系统调用或设备驱动服务

三种类型的trap有所共性表明了,内核可以用单一的代码入口处理所有的trap。为三种不同进入trap的情况:来自用户空间的trap、来自内核空间的trap、时钟中断,设置单一的汇编入口和trap的C语言处理程序还是很方便的。

RISC-V陷阱机制

寄存器

每个RISC-V CPU有一组控制寄存器,用于内核写入这些寄存器告诉CPU如何处理陷阱,并且内核可以读取这些寄存器查明以发生的陷阱。

RISC-V CPU除了32个通用寄存器外,还有一些特殊寄存器

32个通用寄存器

reg name saver description
x0 zero hardwired zero
x1 ra caller return address
x2 sp callee stack pointer
x3 gp global pointer
x4 tp thread pointer
x5-7 t0-2 caller temporary registers
x8 s0/fp callee saved register / frame pointer
x9 s1 callee saved register
x10-11 a0-1 caller
x12-17 a2-7 caller
x18-27 s2-11 callee saved registers
x28-31 t3-6 caller temporary registers
  • callee:在函数调用的时候不会保存
  • caller:在函数调用的时候会保存

特殊控制寄存器(与trap有关):

riscv.h中有所定义,这些是管理者模式与trap有关的寄存器,用户模式不能对其进行读写。

  • stvec:保存陷阱处理代码的地址,当内核转到用户空间,将trampoline页的虚拟地址写入该地址。

  • sepc: 当trap发生时,RISC-V保存pc的值到这个寄存器中(因为pc会被stvec的值给复写,那么就可以执行trap入口汇编代码)。sret(从trap中返回的指令)指令将会sepc赋值给pc,又返回发起trap指令的,下一条指令继续执行。

  • scause:RISC-V保存描述陷阱原因的数字(syscall实验中有所使用)

  • sscratch:内核在这里放置了一个值,这个值会方便 trap 恢复/储存用户上下文(在用户空间的系统调用中会演示其用途)

  • sstatus:SIE 位控制设备中断是否被启用,如果内核清除 SIE,RISC- V 将推迟设备中断,直到内核设置 SIE。如果内核清除 SIE,RISC-V 将推迟设备中断,直到内核设置 SIE。SPP 位表示 trap 是来自什么模式,并控制sret 返回到什么模式。(在syscall实验中也有使用)

其余的特殊寄存器:在前面章节有所讲解

  • pc(用户空间可以读写)
  • s0(fp)
  • satp(用户空间不能读写)

多核芯片上的每个 CPU 都有自己的一组这些寄存器,而且在任何时候都可能有多个
CPU 在处理一个 trap。

特殊寄存器处理trap

当需要执行 trap 时,RISC-V 硬件对所有的 trap 类型(除时钟中断外)进行以下操作:

  1. 如果该 trap 是设备中断,且 sstatus SIE 位为 0,则不要执行以下任何操作。

  2. 通过清除 SIE 来禁用中断。

  3. 复制 pc 到 sepc

  4. 将当前模式(用户或监督者)保存在 sstatus 的 SPP 位。

  5. 在 scause 设置该次 trap 的原因。

  6. 将模式转换为监督者。

  7. 将 stvec 复制到 pc。

  8. 执行新的 pc

注意:CPU 不会切换到内核页表,不会切换到内核中的栈,也不会保存 pc 以外的任何
寄存器。

内核软件必须执行这些任务。CPU 在 trap 期间做很少的工作的一个原因是为了给软件提供灵活性,例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能。

用户陷阱(traps from user space)

来自用户的陷阱:主要有三种系统调用、异常、中断。接着我们以系统调用write为例讲解trap如何进入内核。

系统调用(代码调用流程)

从shell程序初始化启动,首先会调用write系统调用打印’$’、’ ‘两个字符。从shell程序角度来说,write就是C函数调用,但是实际上,write通过执行ecall指令来执行系统调用。ecall指令会切换到内核空间中。执行完ecall后会执行一个由汇编语言写的函数uservec( trampoline.S 文件的一部分)。

trap process.png

执行流程:如上图,黑色方向是进入内核,蓝色方向是退出内核

进入内核

  1. 调用write系统调用,执行ecall命令

  2. 进入trampoline.S的uservec函数:保存用户寄存器

  3. 进入内核中执行c语言实现的代码usertrap

  4. 调用syscall函数并调用sys_write函数打印字符

退出内核

  1. 在usertrap中调用usertrapret函数

  2. 最后调用汇编代码实现的userret代码:恢复用户寄存器的值

以上便是系统调用的大致流程,接着通过gdb的视角看看这部分过程。

系统调用(gdb)

ecall指令之前状态

通过系统调用进入内核

  1. 在user/sh.asm中查看write调用ecall的指令地址为e08(断点设置处)。

write.png

  1. 打开两个终端窗口T1T2。在T1中输入make qemu-gdb,在T2中输入gdb-multiarch (ubuntu20.4的实验环境)

  2. 那么在T2中设置ecall的断点b *0xe08,使用continue命令后,通过print命令打印寄存器的值。

gdb1.png

pc、sp的值都比较小靠近地址0x0,由页表章节也可以得知我们当前处于用户空间中

  1. 在gdb并没有提供给我们查看页表的方式,但是在qemu中提供给了查看页表的方法。转到终端T1按下ctrl a + c按键进入qemu的console,输入info mem,那么qemu就会打印完整的页表(如果打印的是内核的页表,建议运行continue命令后等待一会再使用info mem 查看页表)。

info mem.png

看qemu的输出可以得知,这是一个非常小的一个页表只包含了7条映射关系。这是用户程序Shell的页表,而Shell是一个非常小的程序,这7条映射关系是有关Shell的指令和数据,以及一个无效的page用来作为guard page,以防止Shell尝试使用过多的stack page。

而且最后两个条PTE的虚拟地址十分的大,非常接近虚拟地址的顶端,通过查看页表章节可以得知这两个页分别为trapframe(用于保存/恢复用户寄存器)与trampoline(用户进出内核代码)页

注意:这里的页表没有包含任何内核部分的地址映射,这里既没有对于内核数据段的映射,也没有对内核指令段的映射。因此这个页表几乎是完全为用户代码执行而创建的。

  1. 使用info reg命令查看通用寄存的信息。简单看看一些寄存的值a0、a1、a2,这三个寄存器通过判断应该是保存了write系统调用的三个参数。而且在sh.asm代码中可以查看到a7这保存的是write系统调用的编号。

gdb2.png

  1. 通过使用examining命令,查看a1寄存器存储了write系统调用的字符参数的地址,那么用字符(/c)的格式输出可以清晰的看见,存储的就是'$'' '这两个字符。

同样的通过指令(/i)的格式化输出,可以看见在0xe06往后数的3条命令。

gdb3.png

ecall指令之后的状态

在课程中教授使用stepi指令,就直接跳转到了0x3ffffff004地址,而我在测试时直接运行到了0xe0c地址(可能是环境或者gdb版本原因),所以就需要在trampoline地址设置断点。

  1. 虽然没有明确的指定,但是ecall所做的第一件事就是将cpu的用户模式转为管理者模式。

  2. 可以设置通过b *(stvec)b 0x3ffffff000设置断点,使用si命令可以发现我们直接跳入了这个地址,再查看sepc寄存器的值0xe08可以进一步的确认就是从ecall指令跳转到这个位置的。ecall的第二个作用是将ecall指令的地址写入sepc寄存器中

stvec中保存的就是0x3ffffff000这个地址,在用户空间从内核退出时由内核将trampoline.S的地址写入该寄存器。那么我们获知ecall的第三个作用就是将当前stvec寄存器的值写入pc。

gdb4.png

总结:ecall完成三件事

  • 代码从用户模式改到管理者模式(supervisor mode)

  • ecall的pc值保存到了sepc寄存中

  • ecall跳转到stvec寄存器指向的指令,也就是将stvec写入pc

  1. 在这个位置我们再去T1终端窗口,输入info mem命令,查看当前页表会发现页表映射并没有改变(satp并没有变),所以也可以确定我们仍然在用户空间中。

info mem1.png

ecall不会切换页表,我们需要在用户页表的某个地方来执行最初的内核代码(进入内核)。而trampoline页是内核映射到每一个用户页表中的,使得虽然我们在使用用户页表仍能够让内核在某个地方能够执行一些指令。

NOTE:在trampoline页映射的PTE没有PTE_U位,也就说明这个页不能被用户修改,这也就说明trap机制是安全的。同时我们在执行trampoline的代码(没有PTE_U位用户不能执行这些代码),也能够进一步的说明我们处于管理者模式中。

如果使用info reg命令查看会发现现在寄存器的值仍然是之前的值,这些用户的数据需要保存在内存中的某处,像是栈一样保存数据。否则在trap返回时寄存器的数据无法恢复,用户数据也就被复写,导致无法正常的运行用户程序。

  1. 进入内核,执行内核中的C代码还需要做以下的一些事(ecall并不会做这些事):
  • 保存用户寄存器的值到内存中

  • 从用户页切换到内核页:修改satp寄存器

  • 为C代码提供内核栈,这个栈是每个进程在创建之初就有的,现在需要让sp寄存器的指向内核栈的地址

  • 跳转到内核处理trap的C代码usertrap函数

接着继续运行trampoline.S的代码完成以上的事件。

在其他机器中,或许可以直接将用户寄存器的内容保存于合适的物理内存中。但是RISC-V不允许这样做,因为RISC-V中管理者模式不能直接访问内存,需要通过页表映射去访问。

在xv6中,对于保存用户寄存器的实现有两部分。

  • 每个用户页表都有自己的trapframe页,这个页包含的是32个用户寄存器的值。(可以在proc.h中查看trapframe结构体)

接着我们认识一下trapframe页中起始的5个特殊的槽位,这些都是内核事前存放的值,在用户进入内核时被读取。

1
2
3
4
5
6
7
8
9
----proch
struct trapframe {
/* 0 */ uint64 kernel_satp; // 保存内核页表的地址
/* 8 */ uint64 kernel_sp; // 进程内核栈
/* 16 */ uint64 kernel_trap; // 内核C函数代码usertrap()入口地址
/* 24 */ uint64 epc; // 保存用户PC寄存器
/* 32 */ uint64 kernel_hartid; // 内核硬件线程id号
....
};
  • 内核将trapframe页映射到了每个用户页表上,这样就形成了一对多的关系,唯一的trapframe虚拟地址,通过页表映射到了不同进程的trapframe物理地址。

在用户页表中由内核提前设计了一个映射关系,在虚拟地址trampoline页下面就是trapframe页,所以trapframe页的起始位置总是0x3ffffffe000。这样的话即使在管理者模式中内核同样可以通过这个映射访问到用户内存。

uservec函数

uservec函数的入口是trampoline页的起始地址,在修改a0寄存指向trapframe页之后。我们就可以开始保存寄存器的值了。

  1. 看下图中通过examining答应的接下来需要执行的6条指令。第一条命令csrw就是将a0的值保存到sscratch寄存器中。

gdb4.png

  1. 运行到0x3fffffff0c指令后,通过查看a0寄存的值可知,我们将trapframe的地址值写入了a0寄存器,对应的汇编代码是li a7 TRAPFRAME等效于第2到第4条指令。

如下图,我们打印ssrcatcha0寄存器的值,可以发现a0的值已经保存到了sscratch中。而且a0的值是write函数的参数为文件描述符,在退出内核模式时将会还原a0的值。

a0保存了trapframe的虚拟地址0x3fffffe00,这是trapframe结构体的基址,通过偏移量便能保存特定寄存器的值了(具体可以查看trampoline.S代码,该过程比较无聊直接跳过)。

gdb5.png

  1. 在寄存器存储结束的位置打上断点b *0x3ffffff07e,观测接下来的存储指令分别为
  • 取a0+8的地址开始的一个双字加载到sp
  • 取a0+32的地址开始的一个双字加载到tp
  • 取a0+16的地址开始的一个双字加载到t0
  • 取a0+0的地址开始的一个双字加载到t1

寄存器大小为64位(8字节=一个双字),那么每个寄存器的偏移量的粒度就是8字节

gdb6.png

通过打印这些寄存器的值对应到trapframe的描述也可以进一步的了解这些值的意义。

  • sp是内核栈的地址,如果你仔细观察查看页表章节的内核虚拟内存布局,可以得知sp地址0x3ffffffc000是内核栈的地址。在这个内核栈页上面的页是guard页地址应该为0x3ffffffd000。
  • tp是硬件线程的编号,通过这个值我们可以确定xv运行在那个核上保存的值也就是hartid
  • t0是内核C代码trap处理函数usertrap的入口地址,类型上是一个函数指针。
  • t1是内核页表的地址
  1. 使用si继续执行代码,执行到csrw satp t1sfence.vam(刷新TLB),这也就是将内核页表写入了satp寄存器中。

在切换satp的值后,用户页表应该切换为了内核页表,在此时我们再去T1终端窗口打印页表信息

这时页表就变成了一个十分巨大的页表了,这便是内核页表。我们有了内核页表、内核栈指针,也就可以读取内核数据。

info mem2.png

Q:为什么从用户页表切换到了内核页表之后(虚拟地址的映射关系改变),代码并没有奔溃?此时我们是在内存某个位置执行代码,pc的值也是虚拟地址,为什么代码同一个虚拟地址没有执行了一些无关的代码地址。

A:因为此时代码仍在trampoline中,内核与用户的虚拟地址都映射到了同一个物理地址(一对一关系)

  1. 执行jr t0指令,将会跳转到内核的C代码trap.c文件的usertrap函数中。接着就要一内核栈、内核页表跳转到usertrap函数了。

usertrap函数

之后便是运行C代码,相比于汇编也更加容易理解。下述便是trap.c中的usertrap函数。

1
2
3
4
5
6
7
8
9
10
----trap.c
void
usertrap(void)
{
int which_dev = 0;

if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
....
}

有很多原因都可以使程序运行到usertrap函数中来,例如系统调用、除0(异常)、设备中断。

usertrap某种程度上存储并恢复硬件的状态,但是需要检查触发trap的原因,确定相应的处理方式

usertrap函数执行流程

  1. 更改stvec寄存器写入内核处理异常与中断的地址。取决于trap来自于用户还是内核,不同的情况处理trap的方式也是不同。
1
2
3
4
5
6
7
8
9
----trap.c
void
usertrap(void){
....
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
....
}

在内核中执行任何操作之前,usertrap中先将stvec指向了kernelvec变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。

  1. 调用myproc函数获取当前运行的进程。myproc函数实际上会查找一个根据当前CPU核的编号索引的数组,CPU核的编号是hartid。myproc函数找出当前运行进程的方法。
1
2
3
4
5
6
7
----trap.c
void
usertrap(void){
....
struct proc *p = myproc();
....
}
  1. 保存sepc寄存器的值到trapframe中,它仍然保存在sepc寄存器中。

可能发生的情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致sepc寄存器的内容被复写。我们需要保存当前进程的sepc寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。

1
2
3
4
5
6
7
8
----trap.c
void
usertrap(void){
....
// save user program counter.
p->trapframe->epc = r_sepc();
....
}
  1. 通过检查scause的值判断引发trap的原因。打印scause的值为8,也可以得知引发trap的原因是系统调用。

gdb7.png

  1. 通过if语句进入代码块后,首先判断进程是否被杀死,shell程序没有被杀掉,所以通过该语句。

接着将trapframe中的epc的值+4,在回到用户空间时pc寄存器就被设置epc的值(为ecall的下一条语句的地址),而不是重新执行ecall指令。

xv6会在处理系统调用的时候能够使用中断,这样中断可以更快的服务,有些系统调用需要更多的时间进行处理。中断总是会被risc-v的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 ----trap.c
void
usertrap(void){
....
if(r_scause() == 8){
// system call

if(killed(p))
exit(-1);

// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;

// an interrupt will change sepc, scause, and sstatus,
// so enable only now that we're done with those registers.
intr_on();

syscall();
}
....
}

之后便是进入系统调用的流程,在syscall实验中也有所讲解,该部分也直接省略掉。值得注意的是系统调用的参数保存在了a0,a1,a2中,但是在uservec中我们将a0值换为了trapfame的地址,但是在之后我们便将sscratch(保存a0的值),那么在系统调用中也就可以通过用读取内存的方式获取系统调用的值了

1
2
3
4
----trampoline.S
# save the user a0 in p->trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)
  1. 在系统调用完之后,再次判断进程是否被杀死。最后便调用usertrapret退出trap代码。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    ----trap.c
    void
    usertrap(void){
    ....
    if(killed(p))
    exit(-1);

    // give up the CPU if this is a timer interrupt.
    if(which_dev == 2)
    yield();

    usertrapret();
    }

usertrapret函数

查看trap.c中的usertrapret函数

  1. 关闭中断。我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新stvec寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码。

关闭中断因为当我们将stvec更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
----trap.c
void
usertrapret(void)
{
struct proc *p = myproc();

// we're about to switch the destination of traps from
// kerneltrap() to usertrap(), so turn off interrupts until
// we're back in user space, where usertrap() is correct.
intr_off();

// send syscalls, interrupts, and exceptions to uservec in trampoline.S
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);
....
}
  1. 填入trapframe指定字段,这些内容对于执行trampoline代码非常有用。这里的代码就是:
  • 存储内核页表的指针
  • 存储当前用户进程的内核栈地址指针
  • 存储usertrap函数的指针,这样trampoline代码才能跳转到这个函数
  • 从tp寄存器中读取当前的cpu核编号,并存储在trapframe中,这样trampoline代码才能恢复这个数字,因为用户代码可能会修改这个数字

在usertrapret函数中,设置trapframe中的数据,这样下一次从用户空间转换到内核空间时可以用到这些数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
----trap.c
void
usertrapret(void)
{
....
// set up trapframe values that uservec will need when
// the process next traps into the kernel.
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()
....
}
  1. 设置sstatus寄存器。这个寄存器的SPP位控制了sret指令的行为,该位为0表示下次执行sret的时候,我们想要返回用户模式。这个寄存器的SPIE位控制了,在执行完sret之后,是否打开中断。因为我们在返回到用户空间之后,我们的确希望打开中断,所以这里将SPIE位设置为1。修改完这些bit位之后,我们会把新的值写回到sstatus寄存器。
1
2
3
4
5
6
7
8
9
10
11
void
usertrapret(void)
{
....
// set S Previous Privilege mode to User.
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
....
}
  1. 将sepc设置成epc的值。在执行完sret指令后会将sepc的值写入pc,从而执行ecall的下一条指令。
1
2
3
4
5
6
7
8
void
usertrapret(void)
{
....
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
....
}
  1. 生成用户页表地址,根据用户页表地址生成相应的satp值,这样我们在返回到用户空间的时候才能完成用户页表的切换。

在汇编代码trampoline中完成页表的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射。所以在之后我们便会调用汇编代码。

1
2
3
4
5
6
7
8
void
usertrapret(void)
{
....
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
....
}
  1. 调用userret函数。计算得出trampoline.S中userret的函数地址,将trampoline_userrt地址转换为函数指针,并将之前生成的satp的值作为参数且存储在a0寄存器中。
1
2
3
4
5
6
7
8
9
void
usertrapret(void)
{
....

uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64))trampoline_userret)(satp);
....
}

userret

现在程序又回到了trampoline中,接着梳理userret的执行流程

  1. 切换页表。在执行csrw satp, a1之前,页表应该还是巨大的内核页表。这条指令会将用户页表存储在satp寄存器中,执行完这条指令后就切换后了用户页表。

幸运的是,用户页表也映射了trampoline页,所以程序还能继续执行而不是崩溃。

通过打印a0的值也可以得知,a0的值就是我们传入的satp的参数。

现在可能对a0的值似乎有点混乱了。先捋一下,我们正在使用的a0寄存器保存的是satp这个参数、trapframe保存的a0值(偏移量为112的地址)是系统调用write的返回值、系统调用的第一个参数值文件描述符保存在sscratch中。

gdb8.png

  1. 恢复用户寄存器的值。将trapframe地址写入a0(也就将a0保存的satp地址的值覆盖了),通过以a0为基础将用户保存到trapframe中的用户寄存器的值恢复到寄存器中。

gdb9.png

  1. 执行sret指令返回用户空间。我们在恢复完a0的值(系统调用的返回值)后,便指令sret指令。

gdb10.png

sret会执行三件事:

  • 管理者模式会切换回用户模式

  • sepc寄存器的数值会被拷贝到pc寄存器

  • 重新打开中断

执行完这条指令后我们就返回了用户空间。继续执行ecall的下一条指令。

总结

系统调用被刻意设计的像函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。

xv6实现trap的方式比较特殊,xv6并不关心性能。但是通常来说,操作系统的设计人员和cpu设计人员非常关心如何提升trap的效率和速度。必然还有跟我们这里不一样的方式来实现trap,当我们在实现的时候,可以从以下几个问题出发:

  • 硬件和软件需要协同工作,你可能需要重新设计xv6,重新设计rics-v使得这里的处理流程更加简单,更加快速。

  • 需要时刻记住的问题是,恶意软件是否能滥用这里的机制来打破隔离性。

内核陷阱(traps from kernel space)

xv6根据内核或是用户的代码,以不同的方式配置了CPU的trap寄存器

  • kernelvec:在内核栈中保存/恢复通用寄存器的值
  • kerneltrap:内核处理trap的C语言代码

当内核正在使用CPU,内核使stvec寄存器指向kernelvec汇编代码。由于xv6正处于内核,kernelvec可以依赖于satp寄存设置内核页表,并且栈指针指向一个有效的内核栈。kernelvec压入32个通用寄存器到内核栈,之后将会从中断内核代码除恢复这些寄存器,并且不会收到干扰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kernelvec:
# make room to save registers.
addi sp, sp, -256

# save the registers.
....

# call the C trap handler in trap.c
call kerneltrap

# restore registers.
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
# not tp (contains hartid), in case we moved CPUs
....

addi sp, sp, 256

# return to whatever we were doing in the kernel.
sret

kernelvec保存中断线程的寄存到栈中,这些寄存器也只属于这个线程。不同线程中,其中一个线程因为trap导致切换线程,在这种情况下需要能够在内核栈恢复trap(中断/异常)的线程的寄存器。

kernelvec汇编函数保存完寄存器代码后,会跳转到kerneltrapC函数代码中执行。kerneltrap代码处理两种类型的trap(中断/异常)。之后将会调用devintr函数去检查中断。如果不是设备中断,引起trap的就是异常,异常发生在内核是致命的错误,内核将会调用panic并停止执行。

如果kerneltrap是由于时钟被调用的,一个进程的内核线程正在运行(与不是调度线程),kerneltrap调用yield函数让其他的线程能够得到机会运行。在某个时刻,其中一个线程将会停止,让线程和它的kerneltrap能够再次运行。

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
void 
kerneltrap()
{
int which_dev = 0;
uint64 sepc = r_sepc();
uint64 sstatus = r_sstatus();
uint64 scause = r_scause();

if((sstatus & SSTATUS_SPP) == 0)
panic("kerneltrap: not from supervisor mode");
if(intr_get() != 0)
panic("kerneltrap: interrupts enabled");
//检查是否是中断引起的trap
if((which_dev = devintr()) == 0){
printf("scause %p\n", scause);
printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
panic("kerneltrap");
}

// give up the CPU if this is a timer interrupt.
if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
yield();

// the yield() may have caused some traps to occur,
// so restore trap registers for use by kernelvec.S's sepc instruction.
w_sepc(sepc);
w_sstatus(sstatus);
}

kerneltrap的执行结束,它需要返回中断的代码处。因为yield函数可能会复写sepcsstatus寄存器,kerneltrap开始时需要保存这些寄存器。之后恢复这些控制寄存器,返回到kernelveckernelvec弹出内核栈内保存的32个通用寄存器并执行sret,将sepc拷贝到pc从而重新执行被中断的内核代码。

当CPU从用户模式转到内核模式,xv6设置CPU的stvec寄存指向kernelvec(在usertrap代码,如果在系统调用期间发生中断,需要进入kernelvec函数中),在内核开始执行,stvec仍设置为uservec的时间窗口,重要的是在此期间不发生中断。幸运的是RISC-V中总是在进入trap时,会禁用中断。并且在设置stvec寄存器后才会再次启用中断。

缺页(page fault)

基础

缺页可以实现一系列管理虚拟内存功能,如下

  • lazy allocation:惰性分配
  • copy-on-write fork:COW
  • demand paging:分页请求

  • memory mapped files:内存映射文件

在当今的操作系统都实现了这些功能,但是在xv6中却没有实现这些功能,当出现缺页的问题时,内核会将该进程直接杀死。好在在后续的实验中需要我们自己实现COW与MMAP的功能。

缺页与除零操作一样属于异常trap类型(也有中断缺页,只不过在xv6中讲到的都是异常缺页,参考COW实验)

回顾:虚拟内存

虚拟内存的优点

  • Isolation,隔离性。虚拟内存使得操作系统可以为每个应用程序提供属于它们自己的地址空间。

  • level of indirection,提供了一层抽象。处理器和所有的指令都可以使用虚拟地址,而内核会定义从虚拟地址到物理地址的映射关系。需要特别注意的虚拟页:

    • trampoline page,它使得内核可以将一个物理内存page映射到多个用户地址空间中。

    • guard page,它同时在内核空间和用户空间用来保护Stack。

到目前为止内存地址映射相对来说比较静态。不管是用户页表还是内核页表,都是在最开始的时候设置好,之后就不会再做任何变动。

缺页:地址动态映射

缺页可以让地址映射关系变得动态起来。通过缺页,内核可以更新页表,这是一个非常强大的功能。因为现在可以动态的更新虚拟地址这一层抽象,结合页表和缺页,内核将会有巨大的灵活性

首先,我们需要思考的是,什么信息会向内核传达发生了缺页。或者说,当发生缺页时,内核需要什么样的信息才能够响应缺页。

  • 第一个信息是出错的虚拟地址,或者是触发缺页的源。当出现缺页的时候,xv6内核会打印出错的虚拟地址,并且这个地址会被保存在stval寄存器中。所以,当一个用户应用程序触发了缺页,缺页会使用与syscall相同的trap机制,将程序运行切换到内核,同时也会将出错的地址存放在stval寄存器中。

  • 第二个信息是出错的原因,对不同场景的缺页有不同的响应。不同的场景是指,比如因为load指令触发的缺页、因为store指令触发的缺页又或者是因为jump指令触发的缺页。在scause寄存器的介绍中(如下图),有多个与缺页相关的原因。比如,

    • 13表示是因为load引起的缺页;
    • 15表示是因为store引起的缺页;
    • 12表示是因为指令执行引起的缺页。

    所以第二个信息存在scause寄存器中,其中总共有3个类型的原因与缺页相关,分别是读、写和指令。ecall进入到supervisor mode对应的是8。基本上来说,缺页与系统调用使用相同的trap机制来从用户空间切换到内核空间。如果是因为缺页触发的trap机制并且进入到内核空间,stval寄存器和scause寄存器都会有相应的值。

scause

  • 第三个信息是触发缺页的指令的地址。这个地址存放在sepc寄存器中,并同时会保存在trapframe->epc我们或许想要知道的第三个信息是触发缺页的指令的地址。从上节课可以知道,作为trap处理代码的一部分,这个地址存放在sepc寄存器中,并同时会保存在trapframe->epc

缺页:硬件

从硬件和xv6的角度来说,当出现了缺页,现在有了3个对我们来说极其有价值的信息,分别是:

  • 引起缺页的虚拟内存地址(stval)
  • 引起缺页的原因类型(scause)
  • 引起缺页时的程序计数器值(epc),这表明了缺页在用户空间发生的位置

之所以关心触发缺页时的程序计数器值,是因为在缺页处理程序中我们或许想要修复页表,并重新执行对应的指令。理想情况下,修复完page table之后,指令就可以无错误的运行了。所以,能够恢复因为缺页的指令运行是很重要的。

案例:Lazy Allocation

代码:堆空间分配

动态空间分配主要关注的是sbrk这个系统调用。

分析下述代码,在proc结构体中有一个字段是sz,这个是整个进程虚拟空间的大小。如下图sz字段既是进程空间的大小,也是堆的起始地址。

vmproc

sys_brk系统调用中,记录当前sz(堆的初始地址)为addr,并使用growproc新增物理页建立映射,将sz字段增加n。最后返回addr值(堆的虚拟地址)到用户空间,用户进程就能使用堆空间了。

NOTE:堆空间是向上增长的,而堆空间是向下增长的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
----proc.h
struct proc{
uint64 sz; // Size of process memory (bytes)
....
}

----sysproc.c
uint64
sys_sbrk(void)
{
uint64 addr;
int n;

argint(0, &n);
addr = myproc()->sz;
if(growproc(n) < 0)
return -1;
return addr;
}

原理:Lazy Allocation

eager allocation

首先先了解,与之相对的eager allocation(xv6中默认的实现)。这种分配方式是用户进程调用malloc(sbrk系统调用)时,内核立即反应该请求,新建物理页并于虚拟地址建立映射,返回用户空间后用户便能够使用这部分的内存了。

为什么需要lazy allocation?

实际上,对于应用程序来说很难预测自己需要多少内存,所以通常来说,应用程序倾向于申请多于自己所需要的内存。这意味着,进程的内存消耗会增加许多,但是有部分内存永远也不会被应用程序所使用到。

lazy allcotion

lazy allocation的实现是,用户空间发起sbrk系统调用时不是立即分配物理页建立页表映射,只是做出一点标识(增加sz字段)告诉操作系统地址,用户后续要使用增加的这部分内存。

之后在用户要使用刚刚sbrk申请的这一段空间时,就会触发缺页进入内核空间,根据trap产生原因进行处理,然后就是创建页表与建立页表映射返回用户空间,那么用户就可以使用这部分内存了。

实现:Lazy allocation

  1. sbrk实现改为Lazy allocation

看下述代码,在sbrk中只需要做的是增加了sz的长度,注释growproc语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint64
sys_sbrk(void)
{
uint64 addr;
int n;

argint(0, &n);
addr = myproc()->sz;

+ myproc()->sz = myproc()->sz + n;
- // if(growproc(n) < 0)
- // return -1;
return addr;
}

那么这时重新运行xv6,在shell中运行echo hi命令这时候会得到以下的报错。对应到我们之前所讲的。下面几个寄存器的作用:

1
2
3
4
5
init: starting sh
$ echo hi
usertrap(): unexpected scause 0x000000000000000f pid=3
sepc=0x00000000000012c8 stval=0x0000000000005008
$
  • scause:保存触发trap的原因
  • sepc:触发trap时,在用户空间的pc值
  • stval:触发trap的的用户进程的虚拟地址

根据scause寄存器的值(=15)我们可以知道,是store指令错误导致缺页错误(查看scause值表)。之所以会导致这个错误,是因为在shell中执行程序,shell会先fork一个子进程,子进程会通过exec执行echo。在这个过程中,shell会申请一些内存,所以Shell会调用sys_sbrk,所以导致触发缺页错误。

在上述错误中我们可以知道,出问题的进程pid=3。根据xv6的设计可以知道内核进程pid=1,而shell程序的pid=2。那fork得到的子进程pid=3.

而且根据ecp的值,我们可以找到触发trap的指令,在sh.asm中可以发现,sw这是存储字的指令,那么就是说存储nu的值到内存时触发的缺页异常。

1
2
3
4
5
6
7
8
9
----sh.asm
void*
malloc(uint nbytes)
{
....
}
hp->s.size = nu;
12c8: 01652423 sw s6,8(a0)
....
  1. 缺页处理代码

根据上述错误,我们回到usertrap函数,查看原函数并没有处理store page fault的代码,因此看下述代码中我们加入一个检查scause=15的处理代码。

具体的逻辑如下:首先我们需要从stval寄存器中读取触发缺页的虚拟地址,然后申请一个物理页,清空物理页并建立内存映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void 
usertrap(void){
if(r_scause() == 8){
....
} else if((which_dev = devintr()) != 0){
// ok
}else if(r_scause() == 15){
uint64 va = r_stval(),pa;
printf("page falut: %p\n",va);

if((pa = (uint64)kalloc()) == 0){
p->killed = 1;
}else{
memset((void*)pa,0,PGSIZE);
va = PGROUNDDOWN(va);
if(mappages(p->pagetable,va,PGSIZE,pa,PTE_R|PTE_U|PTE_W) != 0){
kfree((void*)pa);
p->killed = 1;
}
}
}
}
  1. 释放内存uvmunmap函数错误

这时候我们再次运行echo hi命令会出现下述错误。于是在kernel/vm.c文件查看对应的函数,uvmumap函数作用是释放用户内存。在panic处检查错误的虚拟地址,发现为虚拟地址值为0x6000。

1
2
3
4
5
6
7
8
9
10
11
$ echo hi
page falut: 0x0000000000005008
page falut: 0x0000000000014f48
panic: uvmunmap: not mapped
#在uvmunmap中检查错误的虚拟地址
init: starting sh
$ echo hi
page falut: 0x0000000000005008
page falut: 0x0000000000014f48
va = 0x0000000000006000
panic: uvmunmap: not mapped

那么根据前两个page fault的地址可以知道,我们只创建了两个物理页地址分别为(0x5000和0x14000)并建立映射。但是uvmumap是按照顺序进行的释放物理页的。那么0x6000是实际上还没有触发缺页的地址(sbrk已经分配但是没有使用),因此页表内没有PTE记录,所以会panic。

这个错误理解起来就是,sbrk实际上分配很多内存,但是触发缺页的地址只有0x5000与0x14000因此只分配这两个物理页,页表里面也只添加了这些PTE。那么0x6000到p->sz这个区间(除去0x14000)在lazy allocation逻辑上已经分配,但是呢,并没有触发缺页因此没有实际分配对应的物理页。

根据下述GDB调试可以得知是调用exec覆盖之前页表内存

lazy allocation

4.问题解决:

解决的方式十分的简单就是直接跳过这个检测就行了,也就是删除panic,添加一个continue,因为在逻辑上我们已经增加了堆空间,但是在exec执行时我们需要跳过这个检测,到后续才能在使用内存时通过缺页创建。

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
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;

if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");

for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
if((*pte & PTE_V) == 0)
+ continue;
- //panic("uvmunmap: not mapped");
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
if(do_free){
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
}
*pte = 0;
}
}

总结:

以上便是一个基于缺页实现的lazy allocation策略。其核心思想主要是等一会儿,也就是到我们要使用这部分内存时再分配,避免造成内存的浪费。

缺点就是开销有点大,因为缺页导致每次都需要通过trap进出内核。

COW

Copy-on-Write fork的具体实现也就是缺页异常的一种。在实验中会有所涉及,在本文就不过多赘述了,具体的实现可以查看Xv6 Lab5:COW

其余

其余的实现有Zero Fill On Demand、mmap、Demand Paging,其思想与前两者一致,想要了解可以观看课程视频,或是中文翻译文档。

参考:8.3 Zero Fill On Demand - MIT6.S081 (gitbook.io)

总结

这一章花费了大量的文笔去描述了xv6中,如何通过trap实现进出内核空间。单纯的只是看xv6的指导书的话是很难理解的,建议还是观看课程视频。

本章主要是讲解了两种trap方式:

  • 系统调用:以系统调用的视角讲解了用户空间是如何进出内核的。
  • 异常:以缺页的方式,讲解了内核该如何优雅的处理异常代码,而不是简单的杀死进程。

本文主要以系统调用的trap类型,使用GDB的方式演示了用户空间是如果通过一行行指令进入内核空间的。通过学习完本节,不但可以了解GDB的调试方式、寄存器在其中的作用、汇编代码,更重要的是对xv6的trap设计更加了解了。