本文为MIT 6.S081课程第四章教材内容翻译加整理。
本课程前置知识主要涉及:
有三种事件会导致CPU搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上:
ecall
指令要求内核为其做些什么时;本书使用陷阱(trap)作为这些情况的通用术语。
通常,陷阱发生时正在执行的任何代码都需要稍后恢复,并且不需要意识到发生了任何特殊的事情。
xv6内核处理所有陷阱。这对于系统调用来说是顺理成章的。由于隔离性要求用户进程不直接使用设备,而且只有内核具有设备处理所需的状态,因而对中断也是有意义的。因为xv6通过杀死违规程序来响应用户空间中的所有异常,它也对异常有意义。
Xv6陷阱处理分为四个阶段:
虽然三种陷阱类型之间的共性表明内核可以用一个代码路径处理所有陷阱,但对于三种不同的情况:来自用户空间的陷阱、来自内核空间的陷阱和定时器中断,分别使用单独的程序集向量和C陷阱处理程序更加方便。
每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。RISC-V文档包含了完整的内容。riscv.h(kernel/riscv.h:1)包含在xv6中使用到的内容的定义。以下是最重要的一些寄存器概述:
stvec
:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。sepc
:当发生陷阱时,RISC-V会在这里保存程序计数器pc
(因为pc
会被stvec
覆盖)。sret
(从陷阱返回)指令会将sepc
复制到pc
。内核可以写入sepc
来控制sret
的去向。scause
: RISC-V在这里放置一个描述陷阱原因的数字。sscratch
:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。sstatus
:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIE。SPP位指示陷阱是来自用户模式还是管理模式,并控制sret
返回的模式。上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。
多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
pc
复制到sepc
。scause
以反映产生陷阱的原因。stvec
复制到pc
。pc
上开始执行。请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc
之外的任何寄存器。内核软件必须执行这些任务。CPU在陷阱期间执行尽可能少量工作的一个原因是为软件提供灵活性;
你可能想知道CPU硬件的陷阱处理顺序是否可以进一步简化:
satp
寄存器来指向允许访问所有物理内存的页表。stvec
,是很重要的。第2章以initcode.S调用exec
系统调用(user/initcode.S:11)结束。让我们看看用户调用是如何在内核中实现exec
系统调用的。
首选,我们来回滚一下initcode被调用的流程:
前面章节讲过的代码,后续章节都不会再进行讲解,只会讲解未讲过的。
hart0会在系统各个模块初始化任务完成后,唤醒其他hart , 然后scheduler调度0号init程序执行,而后续其他hart也会调度scheduler,然后不断轮询,等待可被调度的任务出现。
如何获取当前CPU的信息:
swtch函数最终会跳回ra寄存器指向的地址继续执行,那么0号init进程的proc->context上下文中的ra寄存器的值是在何时设置的呢?毕竟如果不进行设置,这里无法正确跳转到initcode代码段执行。
这里有一个概念大家需要注意区分一下:
allocproc函数在初始化进程结构体时,会设置context中的ra寄存器指向forkret函数,该函数负责完成内核态返回到用户态的工作。
scheduler函数中调用swtch完成内核态上下文的保存和恢复,将当前hart上正在运行的进程的内核态上下文保存到context中,然后将即将被调度执行的进程的内核态上下文进行恢复:
此时ra寄存器会被恢复为指向forkret函数的地址,然后swtch函数最后执行ret函数,跳转到forkret函数入口地址处执行,完成内核态到用户态的切换,这个切换的具体过程是下一部分我们要重点分析的过程。
forkret函数负责完成内核态到用户态的切换,通过trapframe恢复用户态上下文,此时sepc被赋值为userinit 0号进程初始化函数中被设置的trapframe->epc的值,然后执行sret完成S态到U态的切换,pc寄存器被赋值为spec,我们跳转到了initcode代码段入口地址处执行:
用户代码将exec
需要的参数放在寄存器a0
和a1
中,并将系统调用号放在a7
中。
系统调用号与syscalls
数组中的条目相匹配,syscalls
数组是一个函数指针表(kernel/syscall.c:108)。
ecall
指令陷入(trap)到内核中,执行uservec
、usertrap
和syscall
,这个过程我们将会在下面详细分析。
syscall
(kernel/syscall.c:133)从陷阱帧(trapframe)中保存的a7
中检索系统调用号(p->trapframe->a7
),并用它索引到syscalls
中,对于第一次系统调用,a7
中的内容是SYS_exec
(kernel/syscall. h:8),导致了对系统调用接口函数sys_exec
的调用。
当系统调用接口函数返回时,syscall
将其返回值记录在p->trapframe->a0
中。这将导致原始用户空间对exec()
的调用返回该值,因为RISC-V上的C调用约定将返回值放在a0
中。系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用号无效,syscall
打印错误并返回-1。
我们紧接上一part的流程继续往下分析,当我们在swtch函数中通过ret指令跳转到forkret函数时,forkret内核会干什么呢?
注意:
usertrapret函数中会执行S态返回用户态的操作:
trampoline,uservec,userret是定义在trampoline.S中的三个全局符号,其中trampoline 符号是一个占位符标记,并不包含任何指令地址,它的存在只是为了让代码可以被正确地映射到用户空间和内核空间相同的虚拟地址:
同时,通过.section trampsec 语句用于创建一个名为 trampsec 的新节,而trampoline.S汇编文件后面做的事情,则是定义该节中包含哪些符号,在kernel.ld链接器脚本中会搜集名为trampsec的节,并将其放置在代码段后面:
kvminit内核页表初始化函数中,最后会将trampoline映射到内核虚拟地址空间最高处,大小为一个物理页。
同时,通过链接器脚本可知,.text节中包含了内核原本的所有代码节和trampsec节,所以可知,最终得到的代码段不仅包含了原有的内核代码,还包含了trampsec节相关指令。
又因为kvminit函数中已经对代码段进行了等价映射,又在最后单独将trampsec节的内容映射到内核虚拟地址空间最高处,实际上是将一块物理内存映射到了内核虚拟地址空间两处地方,但是我们只会通过访问内核虚拟地址最高地址处,来访问trampsec节的内容:
kvmmap第二个参数中只需要传入trampoline全局符号的物理地址即可,因为trampoline全局符号的地址就是trampsec节在物理内存上的起始地址,映射大小为一页,刚好包含了整个trampsec节的内容。
所以usertrapret函数中涉及到的trampoline,uservec,userret三个符号的运算实际目的如下图所示:
返回用户空间的是通过调用usertrapret
(kernel/trap.c:90)完成的。
stvec
更改为指向uservec
,准备uservec
所依赖的陷阱帧字段,并将sepc
设置为之前保存的用户程序计数器。usertrapret
在用户和内核页表中都映射的蹦床页面上调用userret
;userret
中的汇编代码会切换页表。usertrapret函数在关闭S态全局中断,更改stvec指向uservec,设置好trapframe相关待恢复上下文,sstatus寄存器相关Previous值,sepc寄存器和satp寄存器待恢复的值后,调用userret函数,并传入trapframe地址和satp寄存器的应该恢复值。
下面我们来看看userret这段汇编过程调用都干了啥:
usertrapret
对userret
的调用将指针传递到a0
中的进程用户页表和a1
中的TRAPFRAME
(kernel/trampoline.S:88)。
userret
将satp
切换到进程的用户页表。TRAPFRAME
,但没有从内核映射其他内容。satp
后继续执行。userret
复制陷阱帧保存的用户a0
到sscratch
,为以后与TRAPFRAME
的交换做准备。userret
可以使用的唯一数据是寄存器内容和陷阱帧的内容。userret
从陷阱帧中恢复保存的用户寄存器,做a0
与sscratch
的最后一次交换来恢复用户a0
并为下一个陷阱保存TRAPFRAME
,并使用sret
返回用户空间。userret函数进行trap返回,跳转到设置好的sepc地址处执行,这里就是initcode代码的入口地址处,并且页表完成了切换,所以使用的是当前进程用户态页表进行虚地址翻译。
initcode代码执行的实际是ecall系统调用,调用的syscall_exec函数:
ecall指令干了什么呢 ?
如果用户程序发出系统调用(ecall
指令),或者做了一些非法的事情,或者设备中断,那么在用户空间中执行时就可能会产生陷阱。
uservec
(kernel/trampoline.S:16)usertrap
(kernel/trap.c:37);usertrapret
(kernel/trap.c:90)userret
(kernel/trampoline.S:16)ecall指令本质是产生异常,从而打断当前程序正常执行,进入异常指令流,也就是stvec寄存器指向的异常处理程序入口地址,stvec寄存器初始是在trapinithart中被设置:
kernelvec用于处理发生在S态下的所有中断和异常,但是如果是处于用户态下的程序发生了中断或者异常,则会跳转到uservec地址处执行,因为我们在usertrapret函数中将stvec更改为指向uservec,这样从S态返回到U态后,如果发生了异常或者中断,就都会跳转到uservec地址处执行了。
uservec负责完成用户态切换到内核态时,用户态下寄存器上下文环境的保存:
trap发生时,硬件会自动保存当前特权级到SPP,SIE到SPIE,pc到spec , 同时关闭全局中断。
来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为satp
指向不映射内核的用户页表,栈指针可能包含无效甚至恶意的值。
由于RISC-V硬件在陷阱期间不会切换页表,所以用户页表必须包括uservec
(stvec指向的陷阱向量指令)的映射。uservec
必须切换satp
以指向内核页表;为了在切换后继续执行指令,uservec
必须在内核页表中与用户页表中映射相同的地址。
xv6使用包含uservec
的蹦床页面(trampoline page)来满足这些约束。xv6将蹦床页面映射到内核页表和每个用户页表中相同的虚拟地址。这个虚拟地址是TRAMPOLINE
。蹦床内容在trampoline.S中设置,并且(当执行用户代码时)stvec
设置为uservec
(kernel/trampoline.S:16)。
当uservec
启动时,所有32个寄存器都包含被中断代码所拥有的值。但是uservec
需要能够修改一些寄存器,以便设置satp
并生成保存寄存器的地址。RISC-V以sscratch
寄存器的形式提供了帮助。uservec
开始时的csrrw
指令交换了a0
和sscratch
的内容。现在用户代码的a0
被保存了;uservec
有一个寄存器(a0
)可以使用;a0
包含内核以前放在sscratch
中的值。
uservec
的下一个任务是保存用户寄存器。在进入用户空间之前,内核先前将sscratch
设置为指向一个每个进程的陷阱帧,该帧(除此之外)具有保存所有用户寄存器的空间(kernel/proc.h:44)。因为satp
仍然指向用户页表,所以uservec
需要将陷阱帧映射到用户地址空间中。每当创建一个进程时,xv6就为该进程的陷阱帧分配一个页面,并安排它始终映射在用户虚拟地址TRAPFRAME
,该地址就在TRAMPOLINE
下面。尽管使用物理地址,该进程的p->trapframe
仍指向陷阱帧,这样内核就可以通过内核页表使用它。
因此在交换a0
和sscratch
之后,a0
持有指向当前进程陷阱帧的指针。uservec
现在保存那里的所有用户寄存器,包括从sscratch
读取的用户的a0
。
陷阱帧包含指向当前进程内核栈的指针、当前CPU的hartid
、usertrap
的地址和内核页表的地址。uservec
取得这些值,将satp
切换到内核页表,并调用usertrap
。
uservec函数再完成用户态寄存器环境上下文保存后,跳转到usertrap继续执行:
usertrap
的任务是确定陷阱的原因,处理并返回(kernel/trap.c:37)。
stvec
,这样内核中的陷阱将由kernelvec
处理。sepc
(保存的用户程序计数器),再次保存是因为usertrap
中可能有一个进程切换,可能导致sepc
被覆盖。syscall
会处理它;devintr
会处理;pc
上加4,因为在系统调用的情况下,RISC-V会留下指向ecall
指令的程序指针(返回后需要执行ecall
之后的下一条指令)。usertrap
检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是计时器中断)。内核中的系统调用接口需要找到用户代码传递的参数。因为用户代码调用了系统调用封装函数,所以参数最初被放置在RISC-V C调用所约定的地方:寄存器。
内核陷阱代码将用户寄存器保存到当前进程的陷阱框架中,内核代码可以在那里找到它们。函数artint
、artaddr
和artfd
从陷阱框架中检索第n个系统调用参数并以整数、指针或文件描述符的形式保存。他们都调用argraw
来检索相应的保存的用户寄存器(kernel/syscall.c:35)。
有些系统调用传递指针作为参数,内核必须使用这些指针来读取或写入用户内存。
exec
系统调用传递给内核一个指向用户空间中字符串参数的指针数组。内核实现了安全地将数据传输到用户提供的地址和从用户提供的地址传输数据的功能。
fetchstr
是一个例子(kernel/syscall.c:25)。exec
,使用fetchstr
从用户空间检索字符串文件名参数。fetchstr
调用copyinstr
来完成这项困难的工作。copyinstr
(kernel/vm.c:406)从用户页表中的虚拟地址srcva
复制max
字节到dst
。
walkaddr
(它又调用walk
)在软件中遍历页表,以确定srcva
的物理地址pa0
。copyinstr
可以直接将字符串字节从pa0
复制到dst
。walkaddr
(kernel/vm.c:95)检查用户提供的虚拟地址是否为进程用户地址空间的一部分,因此程序不能欺骗内核读取其他内存。copyout
,将数据从内核复制到用户提供的地址。如果做过页表节实验小伙伴可能知道,由于我们为每个进程在内核中增加了自己的内核页表,并将用户态下的内存通样映射到了每个进程自己的内核页表中,所以传入的用户态指针可以直接在内核态下进行解引用,下面是上一节实验改造后的copystr实现:
下面补充视频课程中提到的一些点,虽然上面都提到了,但是可能有些地方教材描述的不是特别清楚,我附加的说明也不够到位,所以还是给出视频中大佬通俗易懂的讲解:
该标志位由硬件负责维护,代码不可见,我们只能通过XPP来间接控制当前特权级切换。
本节内容已经足够多了,后续的内容将会在下一小节进行补充说明。