[MIT6.1810] 操作系统学习记录 陷阱 Trap

[MIT6.1810] 操作系统学习记录 陷阱 Trap

陷阱 (trap) 是一种从用户空间到内核空间的异常跳转, 主要有三个方面的来源

  1. 用户执行系统调用时 ecall
  2. 用户执行非法操作时
  3. 硬件设备产生了中断

在这里, 我们主要看一下 ecall 产生的陷阱。

从用户空间陷入

用户程序产生陷阱时, 在 RISC-V 上, 陷阱 经历了以下几个步骤

  1. uservec: 在 trampoline.S 中实现。 当陷阱发生时, stvec 寄存器会指向 uservecuservec 会切换到内核页表, 并调用 usertrap
  2. usertrap: 在 trap.c 中实现。用以处理陷阱 。
  3. usertrapret: 在 trap.c 中实现。 当处理完陷阱以后, 调用这个函数来将控制权交给用户空间。
  4. 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 指令交换了 a0sscratch,以获得指向 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)来指定函数调用时参数的存储位置,通常是寄存器 a0a7。当系统调用发生时,陷阱(由 ecall 指令引起)会将控制权交给内核。此时,内核会保存用户寄存器的状态(包括 a0a7 等)到当前进程的陷阱帧(trapframe)中,以便内核可以读取这些寄存器中的参数。

在 xv6 中, 在 xv6 中,argintargaddrargfd 是用于从陷阱帧中检索系统调用参数的函数。这些函数的作用如下:

  • 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值