xv6:trap机制
简述:trap机制
有三种事件会导致CPU搁置普通命令的执行,强制将控制权转交给处理该事件的特殊代码。
系统调用(system call):执行ecall指令,向内核请求并操作特定硬件资源。
异常(exception):由尝试做非法事件的指令触发,例如除零操作。
设备中断(device interrupt):当设备发出信号时提示内核需要注意,例如当磁盘完成读写请求时。
xv6使用trap(陷阱)作为这三种情况的术语。通常,代码在执行时发生 trap,之后都会被
恢复,而且不需要意识到发生了什么特殊的事情(trap 是透明的)。
透明性对于中断十分重要,被中断的代码通常不会意识到会发生trap。
trap执行的顺序为:
控制权转移到内核(在内核中忽略这一步)
内核保存寄存器和其他状态(存储到内存中)
内核执行特定的处理程序代码
内核恢复保存的状态,从trap中返回
代码从发起trap的地方恢复
xv6中trap处理的四个阶段
RISC-V CPU采取硬件行动(特殊指令,如ecall)
执行汇编指令(trampoline.S)进入内核的C语言代码(kernel/traps)处理
C函数(usertrap)将决定怎样处理trap,如系统调用、中断、xv6没有提供异常处理
内核执行系统调用或设备驱动服务
三种类型的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 类型(除时钟中断外)进行以下操作:
如果该 trap 是设备中断,且 sstatus SIE 位为 0,则不要执行以下任何操作。
通过清除 SIE 来禁用中断。
复制 pc 到 sepc
将当前模式(用户或监督者)保存在 sstatus 的 SPP 位。
在 scause 设置该次 trap 的原因。
将模式转换为监督者。
将 stvec 复制到 pc。
执行新的 pc
注意:CPU 不会切换到内核页表,不会切换到内核中的栈,也不会保存 pc 以外的任何
寄存器。
内核软件必须执行这些任务。CPU 在 trap 期间做很少的工作的一个原因是为了给软件提供灵活性,例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能。
用户陷阱(traps from user space)
来自用户的陷阱:主要有三种系统调用、异常、中断。接着我们以系统调用write为例讲解trap如何进入内核。
系统调用(代码调用流程)
从shell程序初始化启动,首先会调用write系统调用打印’$’、’ ‘两个字符。从shell程序角度来说,write就是C函数调用,但是实际上,write通过执行ecall指令来执行系统调用。ecall指令会切换到内核空间中。执行完ecall后会执行一个由汇编语言写的函数uservec( trampoline.S 文件的一部分)。
执行流程:如上图,黑色方向是进入内核,蓝色方向是退出内核
进入内核
调用write系统调用,执行ecall命令
进入trampoline.S的uservec函数:保存用户寄存器
进入内核中执行c语言实现的代码usertrap
调用syscall函数并调用sys_write函数打印字符
退出内核
在usertrap中调用usertrapret函数
最后调用汇编代码实现的userret代码:恢复用户寄存器的值
以上便是系统调用的大致流程,接着通过gdb的视角看看这部分过程。
系统调用(gdb)
ecall指令之前状态
通过系统调用进入内核
- 在user/sh.asm中查看write调用ecall的指令地址为e08(断点设置处)。
打开两个终端窗口T1、T2。在T1中输入
make qemu-gdb
,在T2中输入gdb-multiarch
(ubuntu20.4的实验环境)那么在T2中设置ecall的断点
b *0xe08
,使用continue
命令后,通过print
命令打印寄存器的值。
pc、sp的值都比较小靠近地址0x0,由页表章节也可以得知我们当前处于用户空间中
- 在gdb并没有提供给我们查看页表的方式,但是在qemu中提供给了查看页表的方法。转到终端T1按下
ctrl a + c
按键进入qemu的console,输入info mem
,那么qemu就会打印完整的页表(如果打印的是内核的页表,建议运行continue命令后等待一会再使用info mem
查看页表)。
看qemu的输出可以得知,这是一个非常小的一个页表只包含了7条映射关系。这是用户程序Shell的页表,而Shell是一个非常小的程序,这7条映射关系是有关Shell的指令和数据,以及一个无效的page用来作为guard page,以防止Shell尝试使用过多的stack page。
而且最后两个条PTE的虚拟地址十分的大,非常接近虚拟地址的顶端,通过查看页表章节可以得知这两个页分别为trapframe(用于保存/恢复用户寄存器)与trampoline(用户进出内核代码)页
注意:这里的页表没有包含任何内核部分的地址映射,这里既没有对于内核数据段的映射,也没有对内核指令段的映射。因此这个页表几乎是完全为用户代码执行而创建的。
- 使用info reg命令查看通用寄存的信息。简单看看一些寄存的值a0、a1、a2,这三个寄存器通过判断应该是保存了write系统调用的三个参数。而且在sh.asm代码中可以查看到a7这保存的是write系统调用的编号。
- 通过使用
examining
命令,查看a1寄存器存储了write系统调用的字符参数的地址,那么用字符(/c)的格式输出可以清晰的看见,存储的就是'$'
与' '
这两个字符。
同样的通过指令(/i)的格式化输出,可以看见在0xe06往后数的3条命令。
ecall指令之后的状态
在课程中教授使用stepi指令,就直接跳转到了0x3ffffff004地址,而我在测试时直接运行到了0xe0c地址(可能是环境或者gdb版本原因),所以就需要在trampoline地址设置断点。
虽然没有明确的指定,但是ecall所做的第一件事就是将cpu的用户模式转为管理者模式。
可以设置通过
b *(stvec)
或b 0x3ffffff000
设置断点,使用si命令可以发现我们直接跳入了这个地址,再查看sepc寄存器的值0xe08可以进一步的确认就是从ecall指令跳转到这个位置的。ecall的第二个作用是将ecall指令的地址写入sepc寄存器中
stvec中保存的就是0x3ffffff000这个地址,在用户空间从内核退出时由内核将trampoline.S的地址写入该寄存器。那么我们获知ecall的第三个作用就是将当前stvec寄存器的值写入pc。
总结:ecall完成三件事
代码从用户模式改到管理者模式(supervisor mode)
将
ecall
的pc值保存到了sepc
寄存中ecall
跳转到stvec
寄存器指向的指令,也就是将stvec
写入pc
- 在这个位置我们再去T1终端窗口,输入
info mem
命令,查看当前页表会发现页表映射并没有改变(satp
并没有变),所以也可以确定我们仍然在用户空间中。
ecall不会切换页表,我们需要在用户页表的某个地方来执行最初的内核代码(进入内核)。而trampoline页是内核映射到每一个用户页表中的,使得虽然我们在使用用户页表仍能够让内核在某个地方能够执行一些指令。
NOTE:在trampoline页映射的PTE没有PTE_U位,也就说明这个页不能被用户修改,这也就说明trap机制是安全的。同时我们在执行trampoline的代码(没有PTE_U位用户不能执行这些代码),也能够进一步的说明我们处于管理者模式中。
如果使用info reg
命令查看会发现现在寄存器的值仍然是之前的值,这些用户的数据需要保存在内存中的某处,像是栈一样保存数据。否则在trap返回时寄存器的数据无法恢复,用户数据也就被复写,导致无法正常的运行用户程序。
- 进入内核,执行内核中的C代码还需要做以下的一些事(
ecall
并不会做这些事):
保存用户寄存器的值到内存中
从用户页切换到内核页:修改satp寄存器
为C代码提供内核栈,这个栈是每个进程在创建之初就有的,现在需要让sp寄存器的指向内核栈的地址
跳转到内核处理trap的C代码usertrap函数
接着继续运行trampoline.S的代码完成以上的事件。
在其他机器中,或许可以直接将用户寄存器的内容保存于合适的物理内存中。但是RISC-V不允许这样做,因为RISC-V中管理者模式不能直接访问内存,需要通过页表映射去访问。
在xv6中,对于保存用户寄存器的实现有两部分。
- 每个用户页表都有自己的trapframe页,这个页包含的是32个用户寄存器的值。(可以在proc.h中查看trapframe结构体)
接着我们认识一下trapframe页中起始的5个特殊的槽位,这些都是内核事前存放的值,在用户进入内核时被读取。
1 | ----proch |
- 内核将trapframe页映射到了每个用户页表上,这样就形成了一对多的关系,唯一的trapframe虚拟地址,通过页表映射到了不同进程的trapframe物理地址。
在用户页表中由内核提前设计了一个映射关系,在虚拟地址trampoline页下面就是trapframe页,所以trapframe页的起始位置总是0x3ffffffe000。这样的话即使在管理者模式中内核同样可以通过这个映射访问到用户内存。
uservec函数
uservec函数的入口是trampoline页的起始地址,在修改a0寄存指向trapframe页之后。我们就可以开始保存寄存器的值了。
- 看下图中通过examining答应的接下来需要执行的6条指令。第一条命令
csrw
就是将a0的值保存到sscratch
寄存器中。
- 运行到0x3fffffff0c指令后,通过查看a0寄存的值可知,我们将trapframe的地址值写入了a0寄存器,对应的汇编代码是
li a7 TRAPFRAME
等效于第2到第4条指令。
如下图,我们打印ssrcatch与a0寄存器的值,可以发现a0的值已经保存到了sscratch中。而且a0的值是write函数的参数为文件描述符,在退出内核模式时将会还原a0的值。
a0保存了trapframe的虚拟地址0x3fffffe00,这是trapframe结构体的基址,通过偏移量便能保存特定寄存器的值了(具体可以查看trampoline.S代码,该过程比较无聊直接跳过)。
- 在寄存器存储结束的位置打上断点
b *0x3ffffff07e
,观测接下来的存储指令分别为
- 取a0+8的地址开始的一个双字加载到sp
- 取a0+32的地址开始的一个双字加载到tp
- 取a0+16的地址开始的一个双字加载到t0
- 取a0+0的地址开始的一个双字加载到t1
寄存器大小为64位(8字节=一个双字),那么每个寄存器的偏移量的粒度就是8字节
通过打印这些寄存器的值对应到trapframe的描述也可以进一步的了解这些值的意义。
- sp是内核栈的地址,如果你仔细观察查看页表章节的内核虚拟内存布局,可以得知sp地址0x3ffffffc000是内核栈的地址。在这个内核栈页上面的页是guard页地址应该为0x3ffffffd000。
- tp是硬件线程的编号,通过这个值我们可以确定xv运行在那个核上保存的值也就是hartid
- t0是内核C代码trap处理函数usertrap的入口地址,类型上是一个函数指针。
- t1是内核页表的地址
- 使用si继续执行代码,执行到
csrw satp t1
和sfence.vam
(刷新TLB),这也就是将内核页表写入了satp寄存器中。
在切换satp的值后,用户页表应该切换为了内核页表,在此时我们再去T1终端窗口打印页表信息
这时页表就变成了一个十分巨大的页表了,这便是内核页表。我们有了内核页表、内核栈指针,也就可以读取内核数据。
Q:为什么从用户页表切换到了内核页表之后(虚拟地址的映射关系改变),代码并没有奔溃?此时我们是在内存某个位置执行代码,
pc
的值也是虚拟地址,为什么代码同一个虚拟地址没有执行了一些无关的代码地址。
A:因为此时代码仍在trampoline中,内核与用户的虚拟地址都映射到了同一个物理地址(一对一关系)
- 执行
jr t0
指令,将会跳转到内核的C代码trap.c文件的usertrap函数中。接着就要一内核栈、内核页表跳转到usertrap函数了。
usertrap函数
之后便是运行C代码,相比于汇编也更加容易理解。下述便是trap.c中的usertrap函数。
1 | ----trap.c |
有很多原因都可以使程序运行到usertrap函数中来,例如系统调用、除0(异常)、设备中断。
usertrap某种程度上存储并恢复硬件的状态,但是需要检查触发trap的原因,确定相应的处理方式
usertrap函数执行流程
- 更改stvec寄存器写入内核处理异常与中断的地址。取决于trap来自于用户还是内核,不同的情况处理trap的方式也是不同。
1 | ----trap.c |
在内核中执行任何操作之前,usertrap中先将stvec指向了kernelvec变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。
- 调用myproc函数获取当前运行的进程。myproc函数实际上会查找一个根据当前CPU核的编号索引的数组,CPU核的编号是hartid。myproc函数找出当前运行进程的方法。
1 | ----trap.c |
- 保存sepc寄存器的值到trapframe中,它仍然保存在sepc寄存器中。
可能发生的情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致sepc寄存器的内容被复写。我们需要保存当前进程的sepc寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。
1 | ----trap.c |
- 通过检查scause的值判断引发trap的原因。打印scause的值为8,也可以得知引发trap的原因是系统调用。
- 通过if语句进入代码块后,首先判断进程是否被杀死,shell程序没有被杀掉,所以通过该语句。
接着将trapframe中的epc的值+4,在回到用户空间时pc寄存器就被设置epc的值(为ecall的下一条语句的地址),而不是重新执行ecall指令。
xv6会在处理系统调用的时候能够使用中断,这样中断可以更快的服务,有些系统调用需要更多的时间进行处理。中断总是会被risc-v的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。
1 | ----trap.c |
之后便是进入系统调用的流程,在syscall实验中也有所讲解,该部分也直接省略掉。值得注意的是系统调用的参数保存在了a0,a1,a2中,但是在uservec中我们将a0值换为了trapfame的地址,但是在之后我们便将sscratch(保存a0的值),那么在系统调用中也就可以通过用读取内存的方式获取系统调用的值了
1 | ----trampoline.S |
- 在系统调用完之后,再次判断进程是否被杀死。最后便调用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函数
- 关闭中断。我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新stvec寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码。
关闭中断因为当我们将stvec更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
1 | ----trap.c |
- 填入trapframe指定字段,这些内容对于执行trampoline代码非常有用。这里的代码就是:
- 存储内核页表的指针
- 存储当前用户进程的内核栈地址指针
- 存储usertrap函数的指针,这样trampoline代码才能跳转到这个函数
- 从tp寄存器中读取当前的cpu核编号,并存储在trapframe中,这样trampoline代码才能恢复这个数字,因为用户代码可能会修改这个数字。
在usertrapret函数中,设置trapframe中的数据,这样下一次从用户空间转换到内核空间时可以用到这些数据。
1 | ----trap.c |
- 设置sstatus寄存器。这个寄存器的SPP位控制了sret指令的行为,该位为0表示下次执行sret的时候,我们想要返回用户模式。这个寄存器的SPIE位控制了,在执行完sret之后,是否打开中断。因为我们在返回到用户空间之后,我们的确希望打开中断,所以这里将SPIE位设置为1。修改完这些bit位之后,我们会把新的值写回到sstatus寄存器。
1 | void |
- 将sepc设置成epc的值。在执行完sret指令后会将sepc的值写入pc,从而执行ecall的下一条指令。
1 | void |
- 生成用户页表地址,根据用户页表地址生成相应的satp值,这样我们在返回到用户空间的时候才能完成用户页表的切换。
在汇编代码trampoline中完成页表的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射。所以在之后我们便会调用汇编代码。
1 | void |
- 调用userret函数。计算得出trampoline.S中userret的函数地址,将trampoline_userrt地址转换为函数指针,并将之前生成的satp的值作为参数且存储在a0寄存器中。
1 | void |
userret
现在程序又回到了trampoline中,接着梳理userret的执行流程
- 切换页表。在执行
csrw satp, a1
之前,页表应该还是巨大的内核页表。这条指令会将用户页表存储在satp寄存器中,执行完这条指令后就切换后了用户页表。
幸运的是,用户页表也映射了trampoline页,所以程序还能继续执行而不是崩溃。
通过打印a0的值也可以得知,a0的值就是我们传入的satp的参数。
现在可能对a0的值似乎有点混乱了。先捋一下,我们正在使用的a0寄存器保存的是satp这个参数、trapframe保存的a0值(偏移量为112的地址)是系统调用write的返回值、系统调用的第一个参数值文件描述符保存在sscratch中。
- 恢复用户寄存器的值。将trapframe地址写入a0(也就将a0保存的satp地址的值覆盖了),通过以a0为基础将用户保存到trapframe中的用户寄存器的值恢复到寄存器中。
- 执行sret指令返回用户空间。我们在恢复完a0的值(系统调用的返回值)后,便指令sret指令。
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 | kernelvec: |
kernelvec
保存中断线程的寄存到栈中,这些寄存器也只属于这个线程。不同线程中,其中一个线程因为trap导致切换线程,在这种情况下需要能够在内核栈恢复trap(中断/异常)的线程的寄存器。
kernelvec
汇编函数保存完寄存器代码后,会跳转到kerneltrap
C函数代码中执行。kerneltrap
代码处理两种类型的trap(中断/异常)。之后将会调用devintr
函数去检查中断。如果不是设备中断,引起trap的就是异常,异常发生在内核是致命的错误,内核将会调用panic
并停止执行。
如果kerneltrap
是由于时钟被调用的,一个进程的内核线程正在运行(与不是调度线程),kerneltrap
调用yield
函数让其他的线程能够得到机会运行。在某个时刻,其中一个线程将会停止,让线程和它的kerneltrap
能够再次运行。
1 | void |
当kerneltrap
的执行结束,它需要返回中断的代码处。因为yield
函数可能会复写sepc
与sstatus
寄存器,kerneltrap
开始时需要保存这些寄存器。之后恢复这些控制寄存器,返回到kernelvec
。kernelvec
弹出内核栈内保存的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寄存器都会有相应的值。
- 第三个信息是触发缺页的指令的地址。这个地址存放在sepc寄存器中,并同时会保存在
trapframe->epc
我们或许想要知道的第三个信息是触发缺页的指令的地址。从上节课可以知道,作为trap处理代码的一部分,这个地址存放在sepc寄存器中,并同时会保存在trapframe->epc
缺页:硬件
从硬件和xv6的角度来说,当出现了缺页,现在有了3个对我们来说极其有价值的信息,分别是:
- 引起缺页的虚拟内存地址(stval)
- 引起缺页的原因类型(scause)
- 引起缺页时的程序计数器值(epc),这表明了缺页在用户空间发生的位置
之所以关心触发缺页时的程序计数器值,是因为在缺页处理程序中我们或许想要修复页表,并重新执行对应的指令。理想情况下,修复完page table之后,指令就可以无错误的运行了。所以,能够恢复因为缺页的指令运行是很重要的。
案例:Lazy Allocation
代码:堆空间分配
动态空间分配主要关注的是
sbrk
这个系统调用。
分析下述代码,在proc结构体中有一个字段是sz
,这个是整个进程虚拟空间的大小。如下图sz字段既是进程空间的大小,也是堆的起始地址。
在sys_brk
系统调用中,记录当前sz(堆的初始地址)为addr
,并使用growproc
新增物理页建立映射,将sz
字段增加n。最后返回addr
值(堆的虚拟地址)到用户空间,用户进程就能使用堆空间了。
NOTE:堆空间是向上增长的,而堆空间是向下增长的
1 | ----proc.h |
原理:Lazy Allocation
eager allocation
首先先了解,与之相对的eager allocation(xv6中默认的实现)。这种分配方式是用户进程调用malloc(sbrk系统调用)时,内核立即反应该请求,新建物理页并于虚拟地址建立映射,返回用户空间后用户便能够使用这部分的内存了。
为什么需要lazy allocation?
实际上,对于应用程序来说很难预测自己需要多少内存,所以通常来说,应用程序倾向于申请多于自己所需要的内存。这意味着,进程的内存消耗会增加许多,但是有部分内存永远也不会被应用程序所使用到。
lazy allcotion
lazy allocation的实现是,用户空间发起sbrk
系统调用时不是立即分配物理页建立页表映射,只是做出一点标识(增加sz字段)告诉操作系统地址,用户后续要使用增加的这部分内存。
之后在用户要使用刚刚sbrk
申请的这一段空间时,就会触发缺页进入内核空间,根据trap产生原因进行处理,然后就是创建页表与建立页表映射返回用户空间,那么用户就可以使用这部分内存了。
实现:Lazy allocation
- sbrk实现改为Lazy allocation
看下述代码,在sbrk中只需要做的是增加了sz的长度,注释growproc
语句。
1 | uint64 |
那么这时重新运行xv6,在shell中运行echo hi
命令这时候会得到以下的报错。对应到我们之前所讲的。下面几个寄存器的作用:
1 | init: starting sh |
- 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 | ----sh.asm |
- 缺页处理代码
根据上述错误,我们回到usertrap函数,查看原函数并没有处理store page fault
的代码,因此看下述代码中我们加入一个检查scause=15的处理代码。
具体的逻辑如下:首先我们需要从stval
寄存器中读取触发缺页的虚拟地址,然后申请一个物理页,清空物理页并建立内存映射。
1 | void |
- 释放内存uvmunmap函数错误
这时候我们再次运行echo hi
命令会出现下述错误。于是在kernel/vm.c文件查看对应的函数,uvmumap函数作用是释放用户内存。在panic处检查错误的虚拟地址,发现为虚拟地址值为0x6000。
1 | echo hi |
那么根据前两个page fault的地址可以知道,我们只创建了两个物理页地址分别为(0x5000和0x14000)并建立映射。但是uvmumap是按照顺序进行的释放物理页的。那么0x6000是实际上还没有触发缺页的地址(sbrk已经分配但是没有使用),因此页表内没有PTE记录,所以会panic。
这个错误理解起来就是,sbrk实际上分配很多内存,但是触发缺页的地址只有0x5000与0x14000因此只分配这两个物理页,页表里面也只添加了这些PTE。那么0x6000到
p->sz
这个区间(除去0x14000)在lazy allocation逻辑上已经分配,但是呢,并没有触发缺页因此没有实际分配对应的物理页。
根据下述GDB调试可以得知是调用exec覆盖之前页表内存
4.问题解决:
解决的方式十分的简单就是直接跳过这个检测就行了,也就是删除panic,添加一个continue
,因为在逻辑上我们已经增加了堆空间,但是在exec执行时我们需要跳过这个检测,到后续才能在使用内存时通过缺页创建。
1 | void |
总结:
以上便是一个基于缺页实现的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设计更加了解了。