#前言
本文中所有相关的行号均使用markdown渲染后的代码块内的行号
本文是对trap源码的解析,建议配合xv6手册第四章一起阅读
1.系统调用
2.系统异常
3.硬件中断
以下是摘自xv6原文的一段话:
Kernel code (assembler or C) that processes a trap is often called a handler; the first handler instructions are usually written in assembler (rather than C) and are sometimes called a vector.
这里面提到了第一段trap handler也被称为vector,在源码文件里面我们也能够发现这个别称,所以在开篇先提一嘴,防止后面解析源码时出现不清楚的情况。
CSR:Control and Status Register
CSR只能通过csr指令来进行原子操作,这类指令我们在第三章也曾提到过
Riscv提供了一组CSR用于给CPU提供处理trap时需要读取的信息:
1.stvec:本寄存器中储存了trap handler的地址,当发生了trap的时候,Riscv会跳转到这个寄存器中的地址,以执行trap handler代码
2.sepc:当trap发生时,Riscv把PC(Program Counter)的值保存在这里。sret指令会把sepc的值复制到PC中,然后cpu就会执行sepc所存储的地址,以达到返回原地址的目的
3.scause:Riscv用这个寄存器来描述当前trap发生的原因
4.sscratch:(这个寄存器的用处会在实现线程时起到作用,目前仅了解即可)
为了能够执行内核态的中断处理流程,仅有一个入口地址是不够的。中断处理流程很可能需要使用栈,而程序当前的用户栈是不安全的。因此,我们还需要一个预设的安全的栈空间,存放在这里。
在内核态中,sp 可以认为是一个安全的栈空间,sscratch 便不需要保存任何值。此时将其设为 0,可以在遇到中断时通过 sscratch 中的值判断中断前程序是否处于内核态。
5.sstatus:该寄存器有两个标记位,分别为SIE和SPP标记位
若内核清除SIE位,一直到Riscv设置SIE位之前,Riscv都会拒绝接受设备中断
SPP位则用于指明trap的来源到底是用户模式还是内核模式,且指明sret返回需要返回的模式
1.若trap为设备中断,sstatus寄存器中的SIE位会被清除,且下述所有步骤都不会被执行
2.若trap不为设备中断,则sstatus寄存器中的SIE位还是会被清除,内核停止接收设备中断信号
3.将PC的值复制到sepc寄存器
4.将当前的状态模式(内核模式或用户模式)保存到sstatus的SPP寄存器
5.将trap的来源信息保存在scause(设备中断、异常或系统调用)
6.将状态模式切换为内核模式
7.将stvec(保存了trap handler代码地址的寄存器)的值复制到PC中
8.处理器开始执行trap handler的代码
用户空间trap的大致路径 uservec->usertrap->usertrapret->userret
我们接下来看看这四个模块的源码
uservec [kernel/trampoline.S]
.section trampsec
.globl trampoline
trampoline:
.align 4
.globl uservec
uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# save user a0 in sscratch so
# a0 can be used to get at TRAPFRAME.
csrw sscratch, a0
# each process has a separate p->trapframe memory area,
# but it's mapped to the same virtual address
# (TRAPFRAME) in every process's user page table.
li a0, TRAPFRAME
# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
sd tp, 64(a0)
sd t0, 72(a0)
sd t1, 80(a0)
sd t2, 88(a0)
sd s0, 96(a0)
sd s1, 104(a0)
sd a1, 120(a0)
sd a2, 128(a0)
sd a3, 136(a0)
sd a4, 144(a0)
sd a5, 152(a0)
sd a6, 160(a0)
sd a7, 168(a0)
sd s2, 176(a0)
sd s3, 184(a0)
sd s4, 192(a0)
sd s5, 200(a0)
sd s6, 208(a0)
sd s7, 216(a0)
sd s8, 224(a0)
sd s9, 232(a0)
sd s10, 240(a0)
sd s11, 248(a0)
sd t3, 256(a0)
sd t4, 264(a0)
sd t5, 272(a0)
sd t6, 280(a0)
# save the user a0 in p->trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)
# initialize kernel stack pointer, from p->trapframe->kernel_sp
ld sp, 8(a0)
# make tp hold the current hartid, from p->trapframe->kernel_hartid
ld tp, 32(a0)
# load the address of usertrap(), from p->trapframe->kernel_trap
ld t0, 16(a0)
# fetch the kernel page table address, from p->trapframe->kernel_satp.
ld t1, 0(a0)
# wait for any previous memory operations to complete, so that
# they use the user page table.
sfence.vma zero, zero
# install the kernel page table.
csrw satp, t1
# flush now-stale user entries from the TLB.
sfence.vma zero, zero
# jump to usertrap(), which does not return
jr t0
当trap开始执行后,程序将读取stvec寄存器,一般存储的就是trampoline的地址,然后程序跳转到trampoline的地址开始执行代码,然后再执行uservec后的代码,从uservec的源码的分析中我们可以得到一些信息
这里我们要注意一点,在跳转过程中并没有发生页表的切换,此时CPU使用的地址仍然是用户空间的虚拟地址,所以我们的trampoline程序必须在用户空间有他的相应的映射,在第三章的源码解析中,我们详细地分析过了trampoline的映射机制,内核空间里对trampoline的物理地址采用双重映射,且每个用户空间都会将地址空间的顶层映射到trampoline的物理地址上,这样就能保证我们即使不转换页表也能访问trampoline的物理地址。
1.第16行指令 trap第一步的实际执行指令一定是 csrw sscratch,a0
由于在保存当前程序的上下文的过程中,没有空闲的寄存器可以使用,因此需要先释放一个空闲的寄存器以供后续使用,这一步操作主要用于保存a0的原值,且将a0释放出来以供后续的偏移式寻址用
2.第21行指令 每个进程的trapframe内存区域都是不同的,但是他们的虚拟地址是相同的,而且cpu在执行指令的时候,所用的地址都是虚拟地址,那样就有效地保证了进程之间的隔绝性,同时也降低了程序重定向的难度
所以我们接下来可以利用 li a0,TRAPFRAME 将TRAPFRAME加载入a0,那么a0中就存储了TRAPFRAME的虚拟地址,后续使用这个地址的时候会由MMU解析为一个进程相关的物理地址,从而能够有效隔绝开不同进程的trapframe
且由于我们的页表的切换的指令还在后文,所以当前访问的TRAPFRAME仍然是属于用户空间的TRAPFRAME
3.第24到53行 将当前进程所使用的所有的寄存器值全部存入trapframe,注意这段内存写入的地方仍然是用户进程虚拟地址空间所映射的物理页
4.将先前保存的a0的值,也就是sscratch的值存入trapframe中
5.初始化内核栈指针,注意这里使用的地址是 8(a0),和上方所存入trapframe的值并没有构成相互干涉的情况。我们可以大胆假设,这部分值就是一直预留在trapframe中用于初始化trap的必要参数的值
6.提取hartid与usertrap()的地址
7.利用 sfence.vma zero, zero 指令刷新TLB
利用 csrw satp, t1 指令 将用户页表切换到内核页表,注意到这里就发生了页表转换,后续对用户空间的访问都需要重新提取出用户空间的页表来做偏移解析。
8.Riscv中最常用的jr指令,直接跳转到目的地址,即执行usertrap()
总的来说 uservec所做的工作就是为后续的trap处理准备了运行的环境,也印证了我们开篇的说法,vector是trap handler的第一段代码
我们接下来分析usertrap()的源码
usertrap [kernel/trap.c]
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
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();
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
setkilled(p);
}
if(killed(p))
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
本段代码的第10行读取sstatus的值,并与 SSTATUS_SPP 作与运算操作,类似的与运算操作其实相当常见,在判断pte的有效位时也经常用到类似的宏和与运算
第15行 w_stevc((uint64)kernelvec); 用于将当前的trap handler的启动代码——也就是我们所说的vector,更换为内核的vector,这是很容易理解的,因为我们当前处于内核模式,在此模式下发生了trap,当然就要使用内核专属的vector
第20行代码将sepc寄存器的值存入进程结构proc中,将sepc寄存器释放,用以处理后续的内核trap(sepc中存储了当前用户进程执行到的指令地址)
第22行 r_scause() 用于判断trap的类型,(系统调用,设备中断,系统异常)
第30行代码很有意思,可以着重拿出来讲
为什么一开始不去对epc+4呢?
在CSAPP第八章提到了一种名为缺页错误的硬件中断,具体说就是当前进程需要访问的一个虚拟页,并没有载入内存中,而是还在磁盘上,所以需要停止当前进程,将对应的虚拟页从磁盘导入内存,待完成后再向cpu发出一个中断信号,然后cpu会重新执行这条访问虚拟页的指令
在上面提到的硬件中断的情况中,我们可以看到,程序的控制流并没有执行到trap处的下一条指令,而是重新执行了当前指令,如果转化成我们的程序来看的话,那么epc就不需要做+4操作
而系统调用不一样,在完成系统调用后,对于进程来说,他当前的函数调用的操作也完成了,一切就像正常的函数调用执行一般,程序继续向下一条指令去推进,所以需要执行+4操作
第37行中,在设备中断的分支里面,什么事情都没发生?或许这可能是6.s081的lab留给我们的一个惊喜,也许我们要在上面加点什么功能也说不定
如果既不是系统调用,又不是设备中断,那么就是纯纯的异常了,那直接当作系统出bug了就好,打印40和41行分支的内容
在第37行中,whichdev用于接收devintr()的返回值,这个值既用于判断设备中断是否有发生,又用于在第49行判断时钟中断
对时钟中断有兴趣的话可以阅读OSTEP的虚拟化一章,其中有对进程调度的原理的深度解剖,其中有关于时钟中断器的策略,可以去了解一下
所以综上所述,usertrap()的作用其实就是判断trap的类型,以及做出相应的决策,函数将syscall()封装在这里,若为trap为系统调用类型,然后就可以按照lec2中学到的syscall的流程去调用对应syscall
笔者在解析到这里仍然感到十分疑惑,设备中断的分支代码为什么是空的呢,也许还需要往下学习或者看看lab4才能搞明白这个问题了
第52行中调用了usertrapret(),我们接着重点分析这个系统调用过程的第三个函数
由函数名称和注释不难看出,usertrapret()最主要的功能是做好返回用户空间的准备,我们直接来看源码
//
// return to user space
//
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);
// 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()
// set up the registers that trampoline.S's sret will use
// to get to user space.
// 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);
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
// jump to userret in trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64))trampoline_userret)(satp);
}
第12行代码关闭了设备中断,因为除非你代码写错了导致了系统异常,否则唯一可能发生的trap就是设备中断了,由于我们这时候要准备返回用户空间,在下方我们将要把用户的trap handler地址写进去stvec()里面,但是我们仍然在内核模式下处理代码,若这时候发生了设备中断,调用了trap handler,则会导致用户进程能够以内核模式运行代码,这是极其不安全的行为,所以一定要在这个时候把硬件中断给关闭
如上面说的一样,第15行和16行我们将trap handler切换回用户空间的
第20到23行的代码值得我们拿出来讨论一下
在我们的uservec中,我们读取了四个trapframe中的值,而在当时读取的时候,我们并没有作页表切换,而是直接从用户的地址空间里面把四个参数取了出来,现在处理完了,要把这几个参数的状态保存起来,以供下次trap处理的流程使用,那能不能直接压入trapframe的内存里面呢?
答案显然是不能的,因为我们并没有将页表从内核页表换回用户页表,所以我们现在对trapframe的读写并不是用户空间的trapframe,而是内核的全局trapframe,所以我们只能先把他们保存到进程状态结构proc,待后面页表切换后再压入trapframe
第29到32行将sstatus寄存器的SPP位切换回用户模式,但是需要注意的是此时cpu仍然在内核模式下运行,sstatus的作用是给jret指令指明待返回的模式状态,在jret模式没有执行之前,cpu都还处于内核模式!
43行中取出了trampoline.S中的userret标签的地址,这里再次用上了我们的老朋友
extern char trampoline[], userret[];
第43行利用userret-trampoline来寻找userret指令相对于trampoline的偏移,而TRAMPOLINE则是trampoline.S文件加载在虚拟地址空间中的虚拟地址,我们前面提到了很多次,在正常寻址的时候我们都是用的虚拟地址,而得益于多重映射机制,我们在内核空间中访问trampoline和用户空间中访问trampoline都是用的同一个虚拟地址,所以在返回的时候,只需要用TRAMPOLINE就可以寻址到trampoline.S的起点。
第44行就是纯纯的黑魔法了,C语言独有的离谱操作
将uint64整形变量trampoline_userret强制转换为函数指针,然后将satp传参调用对应函数,实际上可能没有这样的函数原型,但是编译器会为这行调用生成汇编代码,会将satp作为参数压入传参寄存器,然后跳转到函数地址trampoline_userret中执行trampoline.S中的userret代码
在开头我们概述过usertrapret()的作用,这里不再做赘述
.globl userret
userret:
# userret(pagetable)
# called by usertrapret() in trap.c to
# switch from kernel to user.
# a0: user page table, for satp.
# switch to the user page table.
sfence.vma zero, zero
csrw satp, a0
sfence.vma zero, zero
li a0, TRAPFRAME
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
ld t1, 80(a0)
ld t2, 88(a0)
ld s0, 96(a0)
ld s1, 104(a0)
ld a1, 120(a0)
ld a2, 128(a0)
ld a3, 136(a0)
ld a4, 144(a0)
ld a5, 152(a0)
ld a6, 160(a0)
ld a7, 168(a0)
ld s2, 176(a0)
ld s3, 184(a0)
ld s4, 192(a0)
ld s5, 200(a0)
ld s6, 208(a0)
ld s7, 216(a0)
ld s8, 224(a0)
ld s9, 232(a0)
ld s10, 240(a0)
ld s11, 248(a0)
ld t3, 256(a0)
ld t4, 264(a0)
ld t5, 272(a0)
ld t6, 280(a0)
# restore user a0
ld a0, 112(a0)
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
第8行到第11行是一段页表替换的代码,在上面的分析中我们已经提到过,satp形参被放到寄存器a0中,然后用csrw stap,a0指令将a0写入stap,完成页表替换。
执行sret指令,返回sepc的地址