book_2
2.1 User mode and supervisor mode
为了实现进程隔离,RISC-V CPU在硬件上提供3种执行命令的模式:machine mode, supervisor mode, user mode。
- machine mode的权限最高,CPU以machine mode启动,machine mode的主要目的是为了配置电脑,之后立即切换到supervisor mode。
- supervisor mode运行CPU执行privileged instructions,比如中断管理、对存储页表地址的寄存器进行读写操作、执行system call。运行在supervisor mode也称为在kernel space中运行。
- 应用程序只能执行user mode指令,比如改变变量、执行util function。运行在user mode也称为在user space中运行。要想让CPU从user mode切换到supervisor mode,RISC-V提供了一个特殊的
ecall指令,要想从supervisor mode切换到user mode,调用sret指令
2.2 Kernel organization
monolithic kernel:整个操作系统在kernel中,所有system call都在supervisor mode下运行。xv6是一个monolithic kernel
micro kernel:将需要运行在supervisor mode下的操作系统代码压到最小,保证kernel内系统的安全性,将大部分的操作系统代码执行在user mode下。
如2.1所示,文件系统是一个user-level的进程,为其他进程提供服务,因此也叫做server
xv6 kernel source file如下所示
2.3 Process
隔离的单元叫做进程,一个进程不能够破坏或者监听另外一个进程的内存、CPU、文件描述符,也不能破坏kernel本身。
为了实现进程隔离,xv6提供了一种机制让程序认为自己拥有一个独立的机器。一个进程为一个程序提供了一个私有的内存系统,或address space,其他的进程不能够读/写这个内存。xv6使用page table(页表)来给每个进程分配自己的address space,页表再将这些address space,也就是进程自己认为的虚拟地址(virtual address)映射到RISC-V实际操作的物理地址(physical address)
虚拟地址从0开始,往上依次是指令、全局变量、栈、堆。RISC-V上的指针是64位的,xv6使用低38位,因此最大的地址是=0x3fffffffff=MAXVA
进程最重要的内核状态:1. 页表 p->pagetable 2. 内核堆栈p->kstack 3. 运行状态p->state,显示进程是否已经被分配、准备运行/正在运行/等待IO或退出
每个进程中都有线程(thread),是执行进程命令的最小单元,可以被暂停和继续
每个进程有两个堆栈:用户堆栈(user stack)和内核堆栈(kernel stack)。当进程在user space中进行时只使用用户堆栈,当进程进入了内核(比如进行了system call)使用内核堆栈
2.4 Starting the first process
RISC-V启动时,先运行一个存储于ROM中的bootloader程序kernel.ld来加载xv6 kernel到内存中,然后在machine模式下从_entry开始运行xv6。bootloader将xv6 kernel加载到0x80000000的物理地址中,因为前面的地址中有I/O设备
在_entry中设置了一个初始stack,stack0来让xv6执行kernel/start.c。在start函数先在machine模式下做一些配置,然后通过RISC-V提供的mret指令切换到supervisor mode,使program counter切换到kernel/main.c
main先对一些设备和子系统进行初始化,然后调用kernel/proc.c中定义的userinit来创建第一个用户进程。这个进程执行了一个initcode.S的汇编程序,这个汇编程序调用了exec这个system call来执行/init,重新进入kernel。exec将当前进程的内存和寄存器替换为一个新的程序(/init),当kernel执行完毕exec指定的程序后,回到/init进程。/init(user/init.c)创建了一个新的console device以文件描述符0,1,2打开,然后在console device中开启了一个shell进程,至此整个系统启动了
lecture_5
C程序到汇编程序的转换
已经挺熟了,不过多了解
RISC-V vs X86
没啥内容水了
gdb和汇编代码的执行
左侧的数字表示该指令在内存中的位置
gdb调试小技巧
- gdb中输入tui enable可以打开源代码展示窗口。
- 在gdb中输入layout asm,可以在tui窗口看到所有的汇编指令。再输入layout reg可以看到所有的寄存器信息。
- foucus asm可以聚焦窗口
RISC-V寄存器
寄存器之所以重要是因为汇编代码并不是在内存上执行,而是在寄存器上执行,也就是说,当我们在做add,sub时,我们是对寄存器进行操作。
寄存器是用来进行任何运算和数据读取的最快的方式
我们之前看过了汇编语言和RISC-V的介绍。接下来我们看一下之后lab相关的内容。这部分的内容其实就是本节课的准备材料中的内容。
- Caller Saved寄存器在函数调用的时候不会保存
- Callee Saved寄存器在函数调用的时候会保存
stack
栈从高地址向低地址增长,每个大的box叫一个stack frame(栈帧),栈帧由函数调用来分配,每个栈帧大小不一定一样,但是栈帧的最高处一定是return address
注意返回地址位于栈帧帧指针的固定偏移(-8)位置,并且保存的帧指针位于帧指针的固定偏移(-16) 位置, sp是stack pointer,用于指向栈顶(低地址),保存在寄存器中
fp是frame pointer,用于指向当前帧底部(高地址),保存在寄存器中,同时每个函数栈帧中保存了调用当前函数的函数(父函数)的fp(保存在to prev frame那一栏中)
这些栈帧都是由编译器编译生成的汇编文件生成的
lecture_6
该一节课主要细致的讲解了系统调用引发的缺陷和中断的具体过程
trap机制
用户空间和内核空间的切换,会有以下原因产生
- 程序执行系统调用
- 程序出现了类似page fault、运算时除以0的错误
- 一个设备触发了中断使得当前程序运行需要响应内核设备驱动
RISC-V CPU有一系列的控制寄存器可以通知kernel发生了trap,也可以由kernel写入来告诉CPU怎样处理trap
-
satp: 它包含了指向page table的物理内存地址 -
stvec:它指向了内核中处理trap的指令的起始地址,由kernel写入 -
sepc:保存trap发生时的现场program counter,因为接下来pc要被取代为stvec。sret是从trap回到现场的指令,将sepc写回到pc -
scause:一个trap产生的原因代码,由CPU写入 -
sscratch:放在trap handler的最开始处,用作交换 -
sstatus:控制设备中断是否被开启,如果sstatus中的SIE位被清除,则RISC-V将推迟设备中断。SPP位指示这个trap是在user space中产生的还是在kernel space产生的,并将控制sret回到什么模式以上寄存器只在supervisor模式下发生的trap被使用
对执行kernel里的C程序之前所需的准备工作
- 保存用户的32个寄存器
- 保存用户的
pc - 将
SATP的内容从userpage改为kernelpage - 将CPU的
mode位从usermode改为kernelmode以获得特殊权限执行各种指令 - 将堆栈寄存器指向内核的一个地址,为内核中C函数调用做准备
supervisor mode相较于user mode privilege
- 读写控制寄存器,比如说,读写
SATP寄存器,也就是page table的指针;STVEC,也就是处理trap的内核指令地址;SEPC,保存当发生trap时的程序计数器;SSCRATCH等等。在supervisor mode你可以读写这些寄存器,而用户代码不能做这样的操作。 - 使用
PTE_U为0的PTE
trap代码的执行流程
从Shell的角度来说,这就是个Shell代码中的C函数调用,但是实际上,write通过执行ECALL指令来执行系统调用。ECALL指令会切换到具有supervisor mode的内核中。在这个过程中,内核中执行的第一个指令是一个由汇编语言写的函数,叫做uservec。这个函数是内核代码trampoline.s文件的一部分。所以执行的第一个代码就是这个uservec汇编函数。
之后,在这个汇编函数中,代码执行跳转到了由C语言实现的函数usertrap中,这个函数在trap.c中。
现在代码运行在C中,所以代码更加容易理解。在usertrap这个C函数中,我们执行了一个叫做syscall的函数。
这个函数会在一个表单中,根据传入的代表系统调用的数字进行查找,并在内核中执行具体实现了系统调用功能的函数。对于我们来说,这个函数就是sys_write。
sys_write会将要显示数据输出到console上,当它完成了之后,它会返回给syscall函数。
因为我们现在相当于在ECALL之后中断了用户代码的执行,为了用户空间的代码恢复执行,需要做一系列的事情。在syscall函数中,会调用一个函数叫做usertrapret,它也位于trap.c中,这个函数完成了部分方便在C代码中实现的返回到用户空间的工作。
除此之外,最终还有一些工作只能在汇编语言中完成。这部分工作通过汇编语言实现,并且存在于trampoline.s文件中的userret函数中。
最终,在这个汇编函数中会调用机器指令返回到用户空间,并且恢复ECALL之后的用户程序的执行。
ecall指令之前的状态
// wire系统调用的实现过程
write:
li a7, SYS_write
ecall
ret
从QEMU界面,输入ctrl a + c可以进入到QEMU的console,之后输入info mem,QEMU会打印完整的page table
最后两条PTE的虚拟地址非常大,非常接近虚拟地址的顶端,如果你读过了XV6的书,你就知道这两个page分别是trapframe page和trampoline page。你可以看到,它们都没有设置u标志,所以用户代码不能访问这两条PTE。一旦我们进入到了supervisor mode,我们就可以访问这两条PTE了。
ecall 指令之后的状态
ecall后pagetable与registers的值均未发生变化,程序跳转到0x3ffffff000,也就是上面page table输出的最后一个page,这是trampoline page。我们现在正在trampoline page中执行程序,这个page包含了内核的trap处理代码。
这里的控制转移是通过STVEC寄存器完成的,从内核进入用户空间之前,内核会设置好STVEC指向内核希望运行trap代码的地方
ecall做的三件事
- 从
usermode切换到supervisor mode - 将用户程序的
PC保存到SEPC寄存器 ecall会跳到STEVC指定的地方
uservec函数
uservec 第一步对寄存器的保存
- 在一些其他机器可以直接将寄存器保存到内存中,但
risc不能直接操控物理内存,需要页表 - 将
SATP寄存器改为kernel pagetable利用它完成存储
实际做法
- XV6在每个
user page table映射了trapframe page,这样每个进程都有自己的trapframe page。这个page包含了很多有趣的数据,但是现在最重要的数据是用来保存用户寄存器的32个空槽位。所以,在trap处理代码中,现在的好消息是,我们在user page table有一个之前由kernel设置好的映射关系,这个映射关系指向了一个可以用来存放这个进程的用户寄存器的内存位置。这个位置的虚拟地址总是0x3ffffffe000。 SSCRATCH寄存器。这个由RISC-V提供的SSCRATCH寄存器,就是为接下来的目的而创建的。在进入到user space之前,内核会将trapframe page的地址保存在这个寄存器中,也就是0x3fffffe000这个地址。更重要的是,RISC-V有一个指令允许交换任意两个寄存器的值。而SSCRATCH寄存器的作用就是保存另一个寄存器的值,并将自己的值加载给另一个寄存器。
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;
};
接下来会执行一系列加载trapframe中的寄存器到指定寄存器内容,并在这个过程中切换为kernel pagetable,并进入到执行内核中的C代码
切换页表未崩溃的原因:
trampoline代码在用户空间和内核空间都映射到了同一个地址。
usertrap函数
usertrap函数是位于trap.c文件的一个函数。
既然我们已经运行在C代码中,接下来,我在gdb中输入tui enable打开对于C代码的展示。
我们现在在一个更加正常的世界中,我们正在运行C代码,应该会更容易理解。我们仍然会读写一些有趣的控制寄存器,但是环境比起汇编语言来说会少了很多晦涩。
有很多原因都可以让程序运行进入到usertrap函数中来,比如系统调用,运算时除以0,使用了一个未被映射的虚拟地址,或者是设备中断。usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式,我们在接下来执行usertrap的过程中会同时看到这两个行为。
接下来,让我们一步步执行usertrap函数。
它做的第一件事情是更改STVEC寄存器。取决于trap是来自于用户空间还是内核空间,实际上XV6处理trap的方法是不一样的。目前为止,我们只讨论过当trap是由用户空间发起时会发生什么。如果trap从内核空间发起,将会是一个非常不同的处理流程,因为从内核发起的话,程序已经在使用kernel page table。所以当trap发生时,程序执行仍然在内核的话,很多处理都不必存在。
在内核中执行任何操作之前,usertrap中先将STVEC指向了kernelvec变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。
出于各种原因,我们需要知道当前运行的是什么进程,我们通过调用myproc函数来做到这一点。myproc函数实际上会查找一个根据当前CPU核的编号索引的数组,CPU核的编号是hartid,如果你还记得,我们之前在uservec函数中将它存在了tp寄存器。这是myproc函数找出当前运行进程的方法。
接下来我们要保存用户程序计数器,它仍然保存在SEPC寄存器中,但是可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致SEPC寄存器的内容被覆盖。所以,我们需要保存当前进程的SEPC寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。这里我们使用trapframe来保存这个程序计数器。
接下来我们需要找出我们现在会在usertrap函数的原因。根据触发trap的原因,RISC-V的SCAUSE寄存器会有不同的数字。数字8表明,我们现在在trap代码中是因为系统调用。可以打印SCAUSE寄存器,它的确包含了数字8,我们的确是因为系统调用才走到这里的。
所以,我们可以进到这个if语句中。接下来第一件事情是检查是不是有其他的进程杀掉了当前进程,但是我们的Shell没有被杀掉,所以检查通过。
在RISC-V中,存储在SEPC寄存器中的程序计数器,是用户程序中触发trap的指令的地址。但是当我们恢复用户程序时,我们希望在下一条指令恢复,也就是ecall之后的一条指令。所以对于系统调用,我们对于保存的用户程序计数器加4,这样我们会在ecall的下一条指令恢复,而不是重新执行ecall指令。
XV6会在处理系统调用的时候使能中断,这样中断可以更快的服务,有些系统调用需要许多时间处理。中断总是会被RISC-V的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。
下一行代码中,我们会调用syscall函数。这个函数定义在syscall.c。
它的作用是从syscall表单中,根据系统调用的编号查找相应的系统调用函数。如果你还记得之前的内容,Shell调用的write函数将a7设置成了系统调用编号,对于write来说就是16。所以syscall函数的工作就是获取由trampoline代码保存在trapframe中a7的数字,然后用这个数字索引实现了每个系统调用的表单。
我们可以打印num,的确是16。这与Shell调用的write函数写入的数字是一致的。
之后查看通过num索引得到的函数,正是sys_write函数。sys_write函数是内核对于write系统调用的具体实现。这里再往后的代码执行就非常复杂了,我就不具体介绍了。在这节课中,对于系统调用的实现,我只对进入和跳出内核感兴趣。这里我让代码直接执行sys_write函数。
这里有件有趣的事情,系统调用需要找到它们的参数。你们还记得write函数的参数吗?分别是文件描述符2,写入数据缓存的指针,写入数据的长度2。syscall函数直接通过trapframe来获取这些参数,就像这里刚刚可以查看trapframe中的a7寄存器一样,我们可以查看a0寄存器,这是第一个参数,a1是第二个参数,a2是第三个参数。
现在syscall执行了真正的系统调用,之后sys_write返回了。
这里向trapframe中的a0赋值的原因是:所有的系统调用都有一个返回值,比如write会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0,所以为了模拟函数的返回,我们将返回值存储在trapframe的a0中。之后,当我们返回到用户空间,trapframe中的a0槽位的数值会写到实际的a0寄存器,Shell会认为a0寄存器中的数值是write系统调用的返回值。执行完这一行代码之后,我们打印这里trapframe中a0的值,可以看到输出2。
这意味这sys_write的返回值是2,符合传入的参数,这里只写入了2个字节。
从syscall函数返回之后,我们回到了trap.c中的usertrap函数。
我们再次检查当前用户进程是否被杀掉了,因为我们不想恢复一个被杀掉的进程。当然,在我们的场景中,Shell没有被杀掉。
最后,usertrap调用了一个函数usertrapret。
usertrapter函数
usertrap函数的最后调用了usertrapret函数,来设置好我之前说过的,在返回到用户空间之前内核要做的工作。我们可以查看这个函数的内容。
它首先关闭了中断。我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新STVEC寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码(6.6)。我们关闭中断因为当我们将STVEC更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
在下一行我们设置了STVEC寄存器指向trampoline代码,在那里最终会执行sret指令返回到用户空间。位于trampoline代码最后的sret指令会重新打开中断。这样,即使我们刚刚关闭了中断,当我们在执行用户代码时中断是打开的。
接下来的几行填入了trapframe的内容,这些内容对于执行trampoline代码非常有用。这里的代码就是:
- 存储了kernel page table的指针
- 存储了当前用户进程的kernel stack
- 存储了usertrap函数的指针,这样trampoline代码才能跳转到这个函数(注,详见6.5中 ld t0 (16)a0 指令)
- 从tp寄存器中读取当前的CPU核编号,并存储在trapframe中,这样trampoline代码才能恢复这个数字,因为用户代码可能会修改这个数字
现在我们在usertrapret函数中,我们正在设置trapframe中的数据,这样下一次从用户空间转换到内核空间时可以用到这些数据。
学生提问:为什么trampoline代码中不保存SEPC寄存器?
Robert教授:可以存储。trampoline代码没有像其他寄存器一样保存这个寄存器,但是我们非常欢迎大家修改XV6来保存它。如果你还记得的话(详见6.6),这个寄存器实际上是在C代码usertrap中保存的,而不是在汇编代码trampoline中保存的。我想不出理由这里哪种方式更好。用户寄存器(User Registers)必须在汇编代码中保存,因为任何需要经过编译器的语言,例如C语言,都不能修改任何用户寄存器。所以对于用户寄存器,必须要在进入C代码之前在汇编代码中保存好。但是对于SEPC寄存器(注,控制寄存器),我们可以早点保存或者晚点保存。
接下来我们要设置SSTATUS寄存器,这是一个控制寄存器。这个寄存器的SPP bit位控制了sret指令的行为,该bit为0表示下次执行sret的时候,我们想要返回user mode而不是supervisor mode。这个寄存器的SPIE bit位控制了,在执行完sret之后,是否打开中断。因为我们在返回到用户空间之后,我们的确希望打开中断,所以这里将SPIE bit位设置为1。修改完这些bit位之后,我们会把新的值写回到SSTATUS寄存器。
我们在trampoline代码的最后执行了sret指令。这条指令会将程序计数器设置成SEPC寄存器的值,所以现在我们将SEPC寄存器的值设置成之前保存的用户程序计数器的值。在不久之前,我们在usertrap函数中将用户程序计数器保存在trapframe中的epc字段。
接下来,我们根据user page table地址生成相应的SATP值,这样我们在返回到用户空间的时候才能完成page table的切换。实际上,我们会在汇编代码trampoline中完成page table的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射。但是我们现在还没有在trampoline代码中,我们现在还在一个普通的C函数中,所以这里我们将page table指针准备好,并将这个指针作为第二个参数传递给汇编代码,这个参数会出现在a1寄存器。
倒数第二行的作用是计算出我们将要跳转到汇编代码的地址。我们期望跳转的地址是tampoline中的userret函数,这个函数包含了所有能将我们带回到用户空间的指令。所以这里我们计算出了userret函数的地址。
倒数第一行,将fn指针作为一个函数指针,执行相应的函数(也就是userret函数)并传入两个参数,两个参数存储在a0,a1寄存器中。
userret函数
现在程序执行又到了trampoline代码。
第一步是切换page table。在执行csrw satp, a1之前,page table应该还是巨大的kernel page table。这条指令会将user page table(在usertrapret中作为第二个参数传递给了这里的userret函数,所以存在a1寄存器中)存储在SATP寄存器中。执行完这条指令之后,page table就变成了小得多的user page table。但是幸运的是,user page table也映射了trampoline page,所以程序还能继续执行而不是崩溃。(注,sfence.vma是清空页表缓存,详见4.4)。
在uservec函数中,第一件事情就是交换SSRATCH和a0寄存器。而这里,我们将SSCRATCH寄存器恢复成保存好的用户的a0寄存器。在这里a0是trapframe的地址,因为C代码usertrapret函数中将trapframe地址作为第一个参数传递过来了。112是a0寄存器在trapframe中的位置。(注,这里有点绕,本质就是通过当前的a0寄存器找出存在trapframe中的a0寄存器)我们先将这个地址里的数值保存在t0寄存器中,之后再将t0寄存器的数值保存在SSCRATCH寄存器中。
为止目前,所有的寄存器内容还是属于内核。
接下来的这些指令将a0寄存器指向的trapframe中,之前保存的寄存器的值加载到对应的各个寄存器中。之后,我们离能真正运行用户代码就很近了。
学生提问:现在trapframe中的a0寄存器是我们执行系统调用的返回值吗?
Robert教授:是的,系统调用的返回值覆盖了我们保存在trapframe中的a0寄存器的值(详见6.6)。我们希望用户程序Shell在a0寄存器中看到系统调用的返回值。所以,trapframe中的a0寄存器现在是系统调用的返回值2。相应的SSCRATCH寄存器中的数值也应该是2,可以通过打印寄存器的值来验证。
现在我们打印所有的寄存器,
我不确定你们是否还记得,但是这些寄存器的值就是我们在最最开始看到的用户寄存器的值。例如SP寄存器保存的是user stack地址,这是一个在较小的内存地址;a1寄存器是我们传递给write的buffer指针,a2是我们传递给write函数的写入字节数。
a0寄存器现在还是个例外,它现在仍然是指向trapframe的指针,而不是保存了的用户数据。
接下来,在我们即将返回到用户空间之前,我们交换SSCRATCH寄存器和a0寄存器的值。前面我们看过了SSCRATCH现在的值是系统调用的返回值2,a0寄存器是trapframe的地址。交换完成之后,a0持有的是系统调用的返回值,SSCRATCH持有的是trapframe的地址。之后trapframe的地址会一直保存在SSCRATCH中,直到用户程序执行了另一次trap。现在我们还在kernel中。
sret是我们在kernel中的最后一条指令,当我执行完这条指令:
- 程序会切换回user mode
- SEPC寄存器的数值会被拷贝到PC寄存器(程序计数器)
- 重新打开中断
现在我们回到了用户空间。打印PC寄存器,
这是一个较小的指令地址,非常像是在用户内存中。如果我们查看sh.asm,可以看到这个地址是write函数的ret指令地址。
所以,现在我们回到了用户空间,执行完ret指令之后我们就可以从write系统调用返回到Shell中了。或者更严格的说,是从触发了系统调用的write库函数中返回到Shell中。
学生提问:你可以再重复一下在sret过程中,中断会发生什么吗?
Robert教授:sret打开了中断。所以在supervisor mode中的最后一个指令,我们会重新打开中断。用户程序可能会运行很长时间,最好是能在这段时间响应例如磁盘中断。
最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。
另一方面,XV6实现trap的方式比较特殊,XV6并不关心性能。但是通常来说,操作系统的设计人员和CPU设计人员非常关心如何提升trap的效率和速度。必然还有跟我们这里不一样的方式来实现trap,当你在实现的时候,可以从以下几个问题出发:
- 硬件和软件需要协同工作,你可能需要重新设计XV6,重新设计RISC-V来使得这里的处理流程更加简单,更加快速。
- 另一个需要时刻记住的问题是,恶意软件是否能滥用这里的机制来打破隔离性。
lecture08 page faults
这节课主要讲了一些page fault的出现原因,已经如何正确的处理,和下一个实验的关联性比较大
8.1 basics
当发生
page fault时,内核需要什么样的信息才能响应page fault呢
- 出错时的虚拟地址,当出现
page fault的时候,XV6内核会打印出错的虚拟地址,并且这个地址会被保存在STVAL寄存器中 - 需要产生
page fault的原因,以对不同的page fault进行不同的处理,SCAUSE(注,Supervisor cause寄存器,保存了trap机制中进入到supervisor mode的原因)寄存器的介绍中,有多个与page fault相关的原因。比如,13表示是因为load引起的page fault;15表示是因为store引起的page fault;12表示是因为指令执行引起的page fault。总体分为三类
- load page faults:当load instruction无法翻译虚拟地址时发生
- store page faults:当store instruction无法翻译虚拟地址时发生
- instruction page faults:当一个instruction的地址无法翻译时发生
- 触发
page fault的指令的地址。从上节课可以知道,作为trap处理代码的一部分,这个地址存放在SEPC(Supervisor Exception Program Counter)寄存器中,并同时会保存在trapframe->epc
8.2 Lazy page allocation
可以实现lazy allocation。旧的sbrk()申请分配内存,但是申请的这些内存进程很可能不会全部用到,因此改进方案为:当进程调用sbrk()时,将修改p->sz,但是并不实际分配内存,并且将PTE_V置0。当在试图访问这些新的地址时发生page fault再进行物理内存的分配
8.3 Zero Fill On Demand
当程序有很多都是0时候,我们只需要分配一个物理page,进行多次映射,当需要执行写操作时在重新分配page
8.4 Copy On Write Fork (写时拷贝)
可以实现copy-on-write fork。在fork时,一般都是将父进程的所有user memory复制到子进程中,但是fork之后一般会直接进行exec,这就会导致复制过来的user memory又被放弃掉。因此改进的思路是:子进程和父进程共享一个物理内存,但是mapping时将PTE_W置零,只有当子进程或者父进程的其中一个进程需要向这个地址写入时产生page fault,此时才会进行copy
8.5 Demanding mapping
整体思想和lazy-page-allocation一样
当内存满了,我们需要撤回一些
page来运行程序,撤回哪些页面呢?
根据Least Recently Used(LRU) 机制, 撤回 non-dirty-page , 可以将内存page中的内容写到文件中,之后将相应的PTE标记为non-valid,这就完成了所有的工作。之后你可以在另一个page table重复使用这个page
为什么要撤回
non-dirty-page?
如果撤回的是dirty-page(后续被修改的概率更大),如果要对其进行修改,就要进行两次的修改,一次在内存中,一次在磁盘中,因此出于性能的考量,选择undirty-page