6.S081 笔记
记录一下学习 MIT 6.S081 与 xv6 book 时遇到的一些问题。
LEC 1: Introduction
- 为什么系统调用
exec
不直接和fork
做成一块。因为要考虑到管道这种操作的情况,管道需要把前一个程序的标准输出定向到后一个程序的标准输入(使用 close、dup 等操作),由于子进程的文件描述符表和父进程独立,这样先fork
再在子进程中exec
就可以直接不用管后续了。如果做成了forkexec
这种,如果遇到执行失败返回了原本的程序,需要再恢复文件描述符的对应,这样的操作比较繁琐,可扩展性差。
LEC 4: Page Table
在 RISC-V 中内存管理单元(MMU)并不会保存完整的页表,它会通过寄存器 SATP 找到当前运行的进程的页表,SATP 中存放的是页表在内存中的地址(实际物理地址),只有 SATP 设置了 RISC-V CPU 才会启用 MMU。当不同进程进行切换时,也会更新 SATP。
三级页表中的 PPN 都是实际的物理地址。
为什么硬件(RISC-V)实现了 MMU 的功能,我们还是要在内核代码中实现 walk 这样对虚拟内存翻译的功能?我的理解是,硬件自己实现的 MMU 可以帮助硬件在执行机器代码时可以通过 xv6 写入的页表进行映射,比如 C 语言代码编写的一些非系统调用的操作,比如 C 语言代码中的直接通过某地址访问内存,这个就是虚拟地址,而且就可以靠硬件自己的 MMU 了。而实现 walk 是因为内核需要实现页表的可编程,可以直接通过访问物理内存来实现一些操作(比如一些系统调用),比如直接从指定物理地址提取数据或向指定物理地址写入数据,要不然只能通过机器指令(汇编)获取物理内存中的内容。而且后续课程会提到的 Page Fault 等操作也需要靠这样可编程的页表来操作。另外值得提的一点是,内核在初始化时会将自己的页表构造为和物理地址一对一的映射(direct-mapping),所以内核程序执行时访问的内存地址就是实际的物理地址。
LEC 6: Isolation & system call entry/exit
- 这一章主要就是程序陷入 trap 的整个过程。以系统调用为例。系统调用都是通过
ecall
这个 RISC 指令进入,ecall
会触发一种 trap,并会设置好STVEC
(也就中断向量)、SEPC
等寄存器。在 xv6 里中断向量会指向trampoline.S
的uservec
函数,这里会进行一波 trap 之前的处理。然后接下来就是uservec
->usertrap
->usertrapret
->userret
,从进入中断向量,到中断处理,再到返回用户代码。中断处理的逻辑都在usertrap
,在后续的 lab 中基本都是在这里面进行操作。
LEC 8: Page faults
这一章的内容相对简单,主要是讲了现代操作系统利用 page fault 可以做一些什么样的操作。基本都是利用 page fault 做一些内存相关的 lazy 的操作。也就是对于内存的分配,不是一开始就分配好的,而是通过 page fault 的触发来现分配内存,这样可以节约创建进程的开销(但是会带来对内存页写的开销)。主要思想就是,分配了内存地址范围之后,并不分配实际内存,当访存失败触发 page fault 时再分配内存。COW fork,demand paging 等都依靠这种方式实现。
在 COW 的 Lab 中有一些小细节需要注意,这个地方害得我 cowtest 中的 file 一直遇到问题(见下面代码块)。还有一个小的注意点是,在
copyonwrite
中,内存页 copy 之后需要对原物理地址页调用一次kfree
来减去引用计数(或者单独写一个函数来控制引用计数,我这里是融合到了其他函数中),不然会出现内存未被正确清除干净的问题。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
33int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
// 这里不能直接使用 PA2PTE(pa0),
// 因为这样 pte 不带标志位,必须要重新 walk
pte_t* pte = walk(pagetable, va0, 0);
if (*pte & PTE_COW) {
if (copyonwrite(pagetable, va0) != 0) {
return -1;
}
// 这里也必须重新 walk,因为 copyonwrite 中可能重新分配了地址
pa0 = walkaddr(pagetable, va0);
}
n = PGSIZE - (dstva - va0);
if(n > len)
n = len;
memmove((void *)(pa0 + (dstva - va0)), src, n);
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}
LEC 11: Thread switching
- 做 lab 的时候有一个问题稍微困扰了一下我。在
thread_create
的时候使用t->ra = (uint64) func
将线程中要运行的函数的地址保存在了t->ra
中,这样在thread_switch
结束的时候我可以返回到正确的位置。我的疑惑在于要怎么保存线程运行到的地方呢,比如thread_a
执行到某一个行的时候进行了yield
让出了 CPU,那回到thread_a
后要怎么回到yield
的位置呢,因为pc
并没有保存,ra
中存的又是func
的初始位置,这岂不是每次都回thread_a
的时候都要从func
的一开始执行?这里其实是我想错了,因为ra
并不是一成不变的,当执行yield
等函数的时候,会在线程的栈中(也就是t->stack
中)记录好各种信息,退出函数的时候会从t->stack
中拿到返回地址等信息,返回地址会被打入ra
中。所以以thread_a
为例,在下一次thread_switch
的时候,存到thread_a
上下文信息中的ra
已经不是最开始的func
的地址了,而是执行thread_switch
时的ra
。进程切换、线程切换就这里比较绕,必须以汇编的思维来思考每一个函数、每一个指令的执行逻辑,必要时可以以 gdb 为辅助一步一步观察来理解整个过程。 - lab 的
Using threads
部分有一个小点,就是用一个全局大锁锁住所有操作没法通过ph_fast
测试,优化方法为给每一个 bucket 一个锁,分别锁自己的,这样才能通过ph_fast
测试。
LEC 16: File system performance and fast crash recovery
- ext3 的 logging system 与 xv6 的最大区别在于,xv6 的 transaction 是同步的,必须等头一个 transaction 的所有流程结束之后才能开始下一个;而 ext3 中,可以同时存在多个 transaction,但是下一个 transaction 也必须上一个 transaction 中的所有系统调用结束之后才能 open,在这之后两个 transaction 的不同状态可以共存(比如一个正在 commit,一个正在加入系统调用)。ext3 为 logging 提供了异步的解决方案,一个 transaction 中可以容纳更多的操作,提高并发度。
- 按照课中的说法,ext3 是每 5 秒开始一个新的 transaction。每一个系统调用会通过
start
获取一个handle
,然后在进行写操作时带上handle
作为参数,这样可以让内核记录下这个handle
(代表本次系统调用) 属于哪个 transaction。 - transaction 的 commit 会有专门的内核线程来执行,也就是说是和其他操作是并发执行的(类似于 GC)。