xv6 源码阅读¶
xv6 有两个 cpu(hart)
1. 启动第一个进程¶
首先我们来看一下如何启动 xv6 以及开始第一个进程吧。(课程教材的 2.6 节)
机器启动后,xv6 从 kernel/entry.S 开始运行。启动时硬件分页机制未打开,物理地址等于虚拟地址。qemu 将 xv6 加载到地址 0x80000000,然后_entry 中设置栈, 栈的地址在 start.c 中确定,大小为 4096 乘以 CPU 个数,将 sp 的位置设置为栈顶(stack0+4096*id)。设置好栈后,跳转到 start() 函数。
start() 函数的工作首先是在 machine 模式下进行一些必要的配置,然后切换到 supervisor 模式。这些配置包括,将 main 函数的地址写到 mepc 寄存器中,将 0 写入 satp 寄存器中使得在 supervisor 模式下,关闭页表地址的转换。然后打开时钟中断。执行完这些之后,使用 mret 进入 s 模式,并跳转到 main() 函数。
在 main() 函数中进行一系列必要的初始化后,调用 user_init()(在proc.c)来创建第一个进程(initcode.S)。这个进程执行 exec 系统调用,打开 init 程序,init 程序(init.c)打开shell,就这样完成了第一个进程,启动了系统。
2. 创建新进程¶
在 proc.c 中。主要是 allocproc() 函数,首先给新进程创建一个新的 pid,使用 allocpid() 函数;再给进程分配 trapframe (PCB),这里面储存进程中断需要的变量;最后为用户进程分配页表,用于虚拟地址转化为物理地址。vm.c 中的 mappages 函数创建新的 PTE(页表项),而硬件 mmu 根据这些页表物理内存地址转化为虚拟内存地址(qemu)的工作。
系统分配空间用的都是 kalloc.c 中定义的 kalloc 函数,一次分配 4096 Byte 的内存,是一个页表的大小。kalloc 函数返回的是物理地址。
3. 系统调用的过程¶
xv6 内核地址映射:

用户地址映射:

举个例子,用户的 echo 指令调用了 write 函数。write 函数是一个系统调用,定义在 usys.h 中(用 perl 生成)。它调用 ecall 指令,进入内核态。stvec 寄存器储存的地址是 trampoline.s 中的 uservec 函数。储存好上下文后,跳转到 trap.c 中的 usertrap 函数。这里面我们运行一个 syscall 函数,根据传入的参数搜索系统调用,此时即 sys_write。调用完毕后,调用 usertrapret ,回到 trampoline.s 的 userret,恢复上下文返回。
trampoline 本意是蹦床。其代码在用户态和内核态之间切换,状态像蹦床一样跳来跳去,所以取这个名字。
问题 1:页表切换¶
内核态和用户态所用的页表是不同的,那么该怎么解决这个问题呢。如 pa 中所学的:我们把初始代码存储在所有用户程序都可见的地方。ecall 指令不会切换页表,所以我们在 trampoline.s 中,使用的还是用户页表。
我们在创建用户进程时,将用户进程的 TRAMPOLINE 地址映射到 trampoline.s 的地址。
_trampoline.S 对应的物理地址在内核程序链接时就已经确定了:
. = 0x80000000;
.text : {
(.text .text.)
. = ALIGN(0x1000);
_trampoline = .;
*(trampsec)
. = ALIGN(0x1000);
ASSERT(. - _trampoline == 0x1000, "error: trampoline larger than one page");
PROVIDE(etext = .);
}
对每个进程,虚拟地址都是 TRAMPOLINE(用户最高地址),物理地址就是 trampoline.S 这段代码对应的地址。
而内核程序,在初始化时,vm.c 中的 kvmmake 函数,将内核的最高地址映射到 trampoline.S 对应的地址:
除此以外,内核空间一般是直接映射:虚拟地址和物理地址相同:
用户态和内核态,trampoline.S 对应的虚拟地址一致,这样即使切换页表,trampoline.S 中的代码也能正常工作。那么上面,trampoline.S 在用户态的页表上保存好上下文到用户地址的 trapframe 中后,csrw satp, t1 就将页表切换到内核页表(内核页表的地址也储存在进程结构体中:p->trapframe->kernel_satp),跳转到内核代码。
(而 PA 实验中,由于内核页表地址是恒等映射,这些映射直接复制到用户页表中,因此不必切换)
4. 缺页异常¶
page fault 对应的代码
由硬件检测,若读取到无效地址,the scause register indicates the type of the page fault and the stval register contains the address that couldn’t be translated. 别忘了地址翻译由硬件执行,所以异常由硬件抛出(不管硬件还是软件异常,都先跳转到 epc 设置的中断向量)。
5. exec¶
用户程序执行 exec 系统调用后进入 kernel/exec 中的 exec 函数,可以看到,它会按照指定的路径读取 ELF 文件。给新进程分配新的页表和内存(proc_pagetable() 和 uvmalloc()),再通过 readi() 函数把文件读取到内存中。然后用 uvmalloc() 分配用户栈的内存,并存储 argv 的参数。这些准备好以后,就可以通过下面代码更新当前进程的内容了,并释放旧有的内容:
// Commit to the user image.
oldpagetable = p->pagetable;
p->pagetable = pagetable;
p->sz = sz;
p->trapframe->epc = elf.entry; // initial program counter = main
p->trapframe->sp = sp; // initial stack pointer
proc_freepagetable(oldpagetable, oldsz);
这样执行完毕后,就会跳转到新程序的起始位置 elf.entry。
6. 多线程切换¶
A -> CPU 调度器 -> B
首先,进程 A 让出 CPU,调用 yield(),最终调用 sched(),切换到 CPU 的scheduler 线程
// kernel/proc.c
void sched(void)
{
struct proc *p = myproc();
// ... 一些检查代码,比如确保持有锁 ...
// 【关键代码】
// 切换:从 "当前进程 A 的上下文" -> "当前 CPU 的调度器上下文"
swtch(&p->context, &mycpu()->context);
}
现在 CPU 运行的是 scheduler 线程(每个 CPU 核都有一个专属的调度器线程)。这是一个无限循环。
// kernel/proc.c
void scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){ // 无限循环
// 1. 开启中断,避免死锁
intr_on();
// 2. 遍历进程表,寻找一个 RUNNABLE 的进程 (比如进程 B)
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
// 找到了进程 B!准备切换
p->state = RUNNING;
c->proc = p;
// 【关键代码】
// 切换:从 "当前 CPU 的调度器上下文" -> "进程 B 的上下文"
swtch(&c->context, &p->context);
// -- 这里是分割线 --
// 当进程 B 将来由运行完 yield 时,CPU 会再次回到这里
c->proc = 0;
}
release(&p->lock);
}
}
}
这样就切成了 B 线程,它从内核的 sched() 回来。继续执行它的代码。
7. Trap¶
整理一下 trap 的过程,包括 ecall(系统调用)、硬件中断和软件异常。这里以硬件中断为例。
时钟到期,向 CPU 发送信号,CPU 强行停止执行,读取 stvec 寄存器,进入内核态(Supervisor Mode)。
stvec 指向的地址是 trampoline.S 中的 uservec 代码段。它保存寄存器到 trapframe 里面;然后从 trapframe 中读出内核页表的地址写入 satp 寄存器;跳转 trapframe 里面记录的 usertrap 函数地址。
// kernel/trap.c
void usertrap(void) {
// ... 检查中断原因 ...
// 如果是系统调用
if(r_scause() == 8) { ... }
// 如果是设备中断 (包括时钟)
else if((which_dev = devintr()) != 0) {
// 这里的 devintr() 会检查是不是时钟中断
}
// ...
// 【关键点】如果是时钟中断,并且进程还在运行,就强迫它让出 CPU
if(which_dev == 2) // 2 代表时钟中断
yield();
usertrapret(); // 如果没切换,或者切换回来后,从这里返回
}
// kernel/proc.c
void yield(void) {
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE; // 将状态从 RUNNING 改为 RUNNABLE
sched(); // 调用调度器,这里面会执行 swtch
release(&p->lock);
}
然后就进行了线程切换。