[MIT6.1810] 操作系统学习记录 陷阱 Trap
陷阱 (trap) 是一种从用户空间到内核空间的异常跳转, 主要有三个方面的来源
- 用户执行系统调用时
ecall
- 用户执行非法操作时
- 硬件设备产生了中断
在这里, 我们主要看一下 ecall
产生的陷阱。
从用户空间陷入
用户程序产生陷阱时, 在 RISC-V 上, 陷阱 经历了以下几个步骤
uservec
: 在trampoline.S
中实现。 当陷阱发生时,stvec
寄存器会指向uservec
,uservec
会切换到内核页表, 并调用usertrap
。usertrap
: 在trap.c
中实现。用以处理陷阱 。usertrapret
: 在trap.c
中实现。 当处理完陷阱以后, 调用这个函数来将控制权交给用户空间。userret
: 在trampoline.S
中实现, 负责切换回用户页表, 让用户程序继续运行。
陷阱的初始处理 - uservec
陷阱发生时, 执行 uservec
, 此时所有寄存器仍然包含用户程序的值。为了修改寄存器的值, uservec
需要寄存器 sscratch
来存储中间数据。
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
在这里, 用户程序的 a0
被保存到了 sscratch
中, 现在 uservec
可以任意使用 a0
。
csrw
命令表示将寄存器a0
的值写到sscratch
,csw
表示控制和状态寄存器 Control and Status Register,w
表示写入 write。RISC-V 体系结构将 CSR 操作限定在CSR
指令上。
# 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
在进入内核之前, xv6 使用 sscratch
指向一个陷阱帧, 现在,a0
寄存器取得了陷阱帧的指针。 之后 uservec
负责将所有用户寄存器保存到陷阱帧中,包括先前保存到 sscratch
的用户寄存器的a0
。
# 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)
# 以下略
保存完寄存器后, uservec
切换 satp
到内核页面, 并调用 usertrap
处理陷阱。
# 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
usertrap
的任务
首先 usertrap
会将 stevc
改变为指向 kernelvec
。这样, 如果在内核中又发生了陷阱, 就会跳转到 kernelvec
进行处理, 而不是再次进入。
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
之后, 确定陷阱的原因, 并采取不同的处理方式, 在这里, 我们研究系统调用的处理,RISC-V 中是用户通过 ecall
来主动请求内核服务。 如果 usertrap
检测到是系统调用,就会调用 syscall()
函数来处理, 而 syscall()
根据系统调用的编号指向对应的内核功能。
具体而言,当检测到是系统调用引起的时, 会保存程序计数器 sepc
, 这是一个控制状态寄存器, 保存了用户程序在产生陷阱时的指令地址。 这个值用于在处理完陷阱后回复用户程序的执行。但是如果在处理完系统调用后不对 sepc
进行修改, 那么返回用户空间是会再次执行 ecall
, 从而陷入无限循环。 因此 usertrap
会将 sepc
的指加 4, 以跳过 ecall
指令。 这样返回到用户空间后, 程序会继续执行 ecall
后的下一条指令, 避免重复执行系统调用。
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();
返回用户空间
在处理完陷阱后,usertrapret
会将 stvec
寄存器设置为指向 uservec
,这样下一次从用户空间产生的陷阱就会跳转到 uservec
来处理。uservec
是一段汇编代码,用于处理用户空间的陷阱。
// 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);
在 usertrapret
中,内核会设置一些 trapframe
字段,包括保存用户空间程序的状态(如寄存器内容、栈指针、程序计数器等)。这些信息用于在用户空间恢复执行之前,确保寄存器状态和内存映射一致。
// 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()
在完成了所有准备工作后,usertrapret
会调用 userret
,这是一段位于蹦床页面(trampoline page)上的汇编代码,用于完成最后的页表切换和状态恢复,并最终返回到用户空间。
// 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);
这里
(void (*)
是一个类型转换, 将trampoline_userret
转换为一个函数指针。这个函数指针的类型是void (*)(uint64)
,表示这是一个返回类型为void
、接受一个uint64
参数的函数。
userret
的最终步骤
userret
的最后几步是关键,它们确保返回到用户空间后,下一次发生陷阱时,内核能正确保存和处理用户的状态。
在进入内核时,uservec
使用了 csrrw
指令交换了 a0
和 sscratch
,以获得指向 trapframe
的指针。现在,在返回用户空间之前,userret
需要做一次交换,将原来的用户 a0
恢复,同时将 trapframe
的指针存入 sscratch
。这样,下一次用户空间发生陷阱时,uservec
就可以利用 sscratch
快速找到 trapframe
,保存用户寄存器。
# 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
之后调用 sret
特权指令,负责从特权模式(内核态)返回到较低的特权模式(用户态)。在 userret
中,sret
使用保存的 sepc
值(存储在 trapframe
中)作为返回地址,将控制权交还给用户程序。这一步完成了内核到用户空间的切换,用户程序将从之前中断的位置继续执行。
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
系统调用时参数的传递与检索
在 RISC-V 架构中,当用户程序发起系统调用时,参数会被放置在特定的寄存器中。这是因为 RISC-V 使用一套约定(称为 RISC-V ABI,Application Binary Interface)来指定函数调用时参数的存储位置,通常是寄存器 a0
到 a7
。当系统调用发生时,陷阱(由 ecall
指令引起)会将控制权交给内核。此时,内核会保存用户寄存器的状态(包括 a0
到 a7
等)到当前进程的陷阱帧(trapframe)中,以便内核可以读取这些寄存器中的参数。
在 xv6 中, 在 xv6
中,argint
、argaddr
和 argfd
是用于从陷阱帧中检索系统调用参数的函数。这些函数的作用如下:
argint
:用于获取第n
个系统调用参数,并将其作为一个整数返回。argaddr
:用于获取第n
个系统调用参数,并将其作为一个指针返回。argfd
:用于获取第n
个系统调用参数,并将其作为文件描述符返回。
这些函数都调用 argraw
,这个函数从陷阱帧中提取出指定的寄存器内容,以获取用户传递的参数。
问题1:指针的安全性
- 有些系统调用,比如
exec
,会传递指针作为参数。指针通常指向用户空间中的内存。这里存在一个风险:用户程序可能是缺陷的或恶意的,可能会传递一个无效的指针,或者试图欺骗内核访问内核空间的内存(从而破坏系统安全)。
问题2:内核和用户空间的内存映射不同
- 在
xv6
中,用户页表和内核页表的映射是不同的。用户空间使用一套特定的虚拟地址映射到实际的物理内存,而内核空间有自己的映射方式。因此,内核不能直接使用用户提供的地址来读写数据,而必须通过一种安全的方式来转换和检查这些地址。
xv6
内核提供了函数来安全地访问用户空间内存,这样可以避免用户传递无效或恶意指针。以下是两个重要的函数:
fetchstr
:用于从用户空间检索字符串。例如,exec
系统调用需要从用户空间获取一个指向字符串的指针(字符串表示文件名)。fetchstr
用于确保安全地读取用户提供的字符串。
// Fetch the nul-terminated string at addr from the current process.
// Returns length of string, not including nul, or -1 for error.
int
fetchstr(uint64 addr, char *buf, int max)
{
struct proc *p = myproc();
if(copyinstr(p->pagetable, buf, addr, max) < 0)
return -1;
return strlen(buf);
}
copyinstr
:这是一个更底层的函数,由 fetchstr
调用,负责将用户页表中指定的虚拟地址内容复制到内核空间中。
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
uint64 n, va0, pa0;
int got_null = 0;
-
pagetable_t pagetable
: 表示用户进程的页表。页表是内核在虚拟内存中管理物理内存映射的结构,通过它可以找到虚拟地址对应的物理地址。 -
char *dst
: 目标缓冲区的指针,用于存储从用户空间拷贝来的字符串。位于内核空间。 -
uint64 srcva
: 源虚拟地址(虚拟地址的起始位置),位于用户空间。函数将从这个地址开始读取数据。 -
uint64 max
: 最大可以拷贝的字节数,防止缓冲区溢出。 -
uint64 n, va0, pa0
用于计算页面相关信息:n
:要拷贝的字节数。va0
:当前虚拟地址所在的页面基地址。pa0
:虚拟地址va0
对应的物理地址。
-
int got_null
: 标记是否遇到字符串结束符'\0'
,用于判断是否已经拷贝完整个字符串。
while(got_null == 0 && max > 0){
va0 = PGROUNDDOWN(srcva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
首先, 找到页表的物理地址, va0 = PGROUNDDOWN(srcva)
: 将 srcva
(用户空间虚拟地址)向下取整到页面大小的边界。这样可以确定 srcva
所在的页的起始地址。PGROUNDDOWN
是一个宏,通常返回给定地址所在页的基址。之后使用 walkaddr
函数通过页表找到虚拟地址 va0
对应的物理地址。walkaddr
遍历页表来查找 va0
的映射,如果找不到有效的物理地址则返回 0
。
n = PGSIZE - (srcva - va0);
if(n > max)
n = max;
接下来计算当前页面中从 srcva
开始可以拷贝的字节数。PGSIZE
是页大小 , srcva - va0
表示 srcva
在当前页内的偏移量,因此 PGSIZE - (srcva - va0)
是从 srcva
开始到当前页末的字节数。如果这个字节数超过了 max
,则取 max
,确保不会超出预定的拷贝长度。
char *p = (char *) (pa0 + (srcva - va0));
while(n > 0){
if(*p == '\0'){
*dst = '\0';
got_null = 1;
break;
} else {
*dst = *p;
}
--n;
--max;
p++;
dst++;
}
之后逐字节拷贝数据。walkaddr
(*kernel/vm.c*:95)检查用户提供的虚拟地址是否为进程用户地址空间的一部分,因此程序不能欺骗内核读取其他内存。一个类似的函数是copyout
,将数据从内核复制到用户提供的地址。
从内核空间陷入
TODO