MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上

MIT 6.S081 教材第四章内容 -- 上

  • 引言
  • 陷阱指令和系统调用
  • RISC-V陷入机制
  • 代码:调用系统调用
  • 从用户空间陷入
  • 系统调用参数
  • 补充


引言

MIT 6.S081 2020 操作系统

本文为MIT 6.S081课程第四章教材内容翻译加整理。

本课程前置知识主要涉及:

  • C语言(建议阅读C程序语言设计—第二版)
  • RISC-V汇编
  • 推荐阅读: 程序员的自我修养-装载,链接与库

陷阱指令和系统调用

有三种事件会导致CPU搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上:

  • 一种情况是系统调用,当用户程序执行ecall指令要求内核为其做些什么时;
  • 另一种情况是异常:(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址;
  • 第三种情况是设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注。

本书使用陷阱(trap)作为这些情况的通用术语。

通常,陷阱发生时正在执行的任何代码都需要稍后恢复,并且不需要意识到发生了任何特殊的事情。

  • 也就是说,我们经常希望陷阱是透明的;这对于中断尤其重要,中断代码通常难以预料。
  • 通常的顺序是陷阱强制将控制权转移到内核;
  • 内核保存寄存器和其他状态,以便可以恢复执行;
  • 内核执行适当的处理程序代码(例如,系统调用接口或设备驱动程序);
  • 内核恢复保存的状态并从陷阱中返回;
  • 原始代码从它停止的地方恢复。

xv6内核处理所有陷阱。这对于系统调用来说是顺理成章的。由于隔离性要求用户进程不直接使用设备,而且只有内核具有设备处理所需的状态,因而对中断也是有意义的。因为xv6通过杀死违规程序来响应用户空间中的所有异常,它也对异常有意义。

Xv6陷阱处理分为四个阶段:

  • RISC-V CPU采取的硬件操作
  • 为内核C代码执行而准备的汇编程序集“向量”
  • 决定如何处理陷阱的C陷阱处理程序
  • 以及系统调用或设备驱动程序服务例程。

虽然三种陷阱类型之间的共性表明内核可以用一个代码路径处理所有陷阱,但对于三种不同的情况:来自用户空间的陷阱、来自内核空间的陷阱和定时器中断,分别使用单独的程序集向量和C陷阱处理程序更加方便。


RISC-V陷入机制

每个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将推迟设备中断,直到内核重新设置SIESPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式。

上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。

多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。

当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:

  1. 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
  2. 清除SIE以禁用中断。
  3. pc复制到sepc
  4. 将当前模式(用户或管理)保存在状态的SPP位中。
  5. 设置scause以反映产生陷阱的原因。
  6. 将模式设置为管理模式。
  7. stvec复制到pc
  8. 在新的pc上开始执行。

请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc之外的任何寄存器。内核软件必须执行这些任务。CPU在陷阱期间执行尽可能少量工作的一个原因是为软件提供灵活性;

  • 例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能。

你可能想知道CPU硬件的陷阱处理顺序是否可以进一步简化:

  • 例如,假设CPU不切换程序计数器。那么陷阱可以在仍然运行用户指令的情况下切换到管理模式。
  • 但因此这些用户指令可以打破用户/内核的隔离机制,例如通过修改satp寄存器来指向允许访问所有物理内存的页表。
  • 因此,CPU使用专门的寄存器切换到内核指定的指令地址,即stvec,是很重要的。

代码:调用系统调用

第2章以initcode.S调用exec系统调用(user/initcode.S:11)结束。让我们看看用户调用是如何在内核中实现exec系统调用的。

首选,我们来回滚一下initcode被调用的流程:

  1. main函数中完成系统初始化工作
    MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第1张图片
  2. userinit实现对init进程的初始化工作
    MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第2张图片

前面章节讲过的代码,后续章节都不会再进行讲解,只会讲解未讲过的。

//kernel/vm.c

// Load the user initcode into address 0 of pagetable,
// for the very first process.
// sz must be less than a page.
// 将initcode程序的代码加载到0号进程用户态页表的0地址处
void
uvminit(pagetable_t pagetable, uchar *src, uint sz)
{
  char *mem;

  if(sz >= PGSIZE)
    panic("inituvm: more than a page");
  //分配物理页,并初始化该物理页
  mem = kalloc();
  memset(mem, 0, PGSIZE);
  // 虚拟地址0~PGSIZE 与 上面分配的物理页地址建立映射关系
  mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
  // 将initcode代码数据从内核空间拷贝到用户空间 ---> 因为此时这个物理页是映射到了0号用户态进程的,因此可以这样理解
  memmove(mem, src, sz);
}
  1. scheduler完成0号进程的调度执行
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// 每个CPU都会在初始化工作完成后,调用scheduler开始任务调度执行
// Scheduler never returns.  It loops, doing:
//  - choose a process to run.
//  - swtch to start running that process.
//  - eventually that process transfers control
//    via swtch back to the scheduler.
void scheduler(void)
{
  struct proc *p;
  // 获取当前hart
  struct cpu *c = mycpu();
  // 清空当前hart正在运行的进程信息
  c->proc = 0;
  for (;;)
  {
    // Avoid deadlock by ensuring that devices can interrupt.
    // 设置sstatus寄存器的SIE为1,即打开S态下的全局中断
    intr_on();
    // 寻找下一个需要被调度的进程
    int found = 0;
    for (p = proc; p < &proc[NPROC]; p++)
    {
      acquire(&p->lock);
      // 简单遍历proc数组,找到第一个处于RUNNABLE状态的进程
      // RUNNABLE在xv6中表示可调度运行状态
      if (p->state == RUNNABLE)
      {
        // Switch to chosen process.  It is the process's job
        // to release its lock and then reacquire it
        // before jumping back to us.
        // 设置当前进程的状态为RUNNABLE ——> 运行态
        p->state = RUNNING;
        // 将该进程与当前hart绑定
        c->proc = p;

         // 页表实验中我们新增的代码: 更新SATP指向当前进程的内核页表
        // Store the kernal page table into the SATP
        proc_inithart(p->kernelpt);
        
        // 具体切换执行的函数---cpu中的context用于保存当前寄存器上下文环境
        // proc中的context用于恢复其保存的寄存器上下文环境
        swtch(&c->context, &p->context);

        // Come back to the global kernel page table
        // 当CPU空闲时,恢复stap指向全局的内核页表---也是我们新增的代码
        kvminithart();

        // Process is done running for now.
        // It should have changed its p->state before coming back.
        // 解决绑定关系
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
#if !defined(LAB_FS)
    if (found == 0)
    {
      intr_on();
      asm volatile("wfi");
    }
#else
    ;
#endif
  }
}

hart0会在系统各个模块初始化任务完成后,唤醒其他hart , 然后scheduler调度0号init程序执行,而后续其他hart也会调度scheduler,然后不断轮询,等待可被调度的任务出现。

如何获取当前CPU的信息:

struct cpu cpus[NCPU];

// Saved registers for kernel context switches.
// 下面是一些需要在函数调用时,进行保存的寄存器
struct context {
  uint64 ra;
  uint64 sp;

  // callee-saved
  uint64 s0;
  uint64 s1;
  uint64 s2;
  uint64 s3;
  uint64 s4;
  uint64 s5;
  uint64 s6;
  uint64 s7;
  uint64 s8;
  uint64 s9;
  uint64 s10;
  uint64 s11;
};

// Per-CPU state.
struct cpu {
  // 当前hart上正在运行的进程
  struct proc *proc;          // The process running on this cpu, or null.
  // context用于存储需要在swtch函数进行任务切换过程中保存的寄存器
  struct context context;     // swtch() here to enter scheduler().
  int noff;                   // Depth of push_off() nesting.
  int intena;                 // Were interrupts enabled before push_off()?
};

// Must be called with interrupts disabled,
// to prevent race with process being moved
// to a different CPU.
// 获取tp寄存器中保存的当前硬件线程的hart id
int cpuid()
{
  int id = r_tp();
  return id;
}

// Return this CPU's cpu struct.
// Interrupts must be disabled.
struct cpu *
mycpu(void)
{
  int id = cpuid();
  // xv6中使用cpu[]数组保存所有cpu核--hart id作为数组索引
  struct cpu *c = &cpus[id];
  return c;
}
  1. swtch切换到0号进程执行
    MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第3张图片

swtch函数最终会跳回ra寄存器指向的地址继续执行,那么0号init进程的proc->context上下文中的ra寄存器的值是在何时设置的呢?毕竟如果不进行设置,这里无法正确跳转到initcode代码段执行。

  • 每个新创建出来的进程,其ra寄存器是在allocproc函数中进行的设置,上面我们直接跳过了该函数,下面我们深入分析一下
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc *
allocproc(void)
{
  struct proc *p;
  //遍历找到一个空位---如果找到了,说明当前创建的进程数量没有超过最大进程数的限制
  for (p = proc; p < &proc[NPROC]; p++)
  {
    acquire(&p->lock);
    if (p->state == UNUSED)
    {
      //跳到found标记处继续执行 
      goto found;
    }
    else
    {
      release(&p->lock);
    }
  }
  return 0;

found:
  // 分配pid
  p->pid = allocpid();

  // Allocate a trapframe page.
  // 为trapframe帧分配页面--->该帧是用于上下文切换时来保持当前寄存器环境上下文快照的
  if ((p->trapframe = (struct trapframe *)kalloc()) == 0)
  {
    release(&p->lock);
    return 0;
  }

  // An empty user page table.
  // 为进程的用户态页表分配一个新的空闲物理页--同时做好TRAMPOLINE和TRAMPOLINE的映射
  // TRAMPOLINE和TRAMPOLINE这两部分代码是上下文切换通用代码,所以会被映射到每个用户虚拟地址空间最高地址处
  p->pagetable = proc_pagetable(p);
  if (p->pagetable == 0)
  {
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  //----------上一节页表实验中补充的代码-----------------
  // 为当前进程分配一个空的内核态根页表
  p->kernelpt = proc_kpt_init(p);
  if (p->pagetable == 0)
  {
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // Allocate a page for the process's kernel stack.
  // Map it high in memory, followed by an invalid
  // guard page.
  char *pa = kalloc();
  if (pa == 0)
    panic("kalloc");  
  uint64 va = KSTACK((int)(p - proc));
  // 当前进程的内核栈映射到当前进程的自己内核页表中
  uvmmap(p->kernelpt, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  p->kstack = va;
  //-----------补充结束----------------------

  // Set up new context to start executing at forkret,
  // which returns to user space.
  // 清空当前进程的context
  memset(&p->context, 0, sizeof(p->context));
  //设置context的ra指向forkret函数地址
  p->context.ra = (uint64)forkret;
  //设置sp指向内核栈栈顶地址---内核栈只占据一个页面大小
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

这里有一个概念大家需要注意区分一下:

MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第4张图片

allocproc函数在初始化进程结构体时,会设置context中的ra寄存器指向forkret函数,该函数负责完成内核态返回到用户态的工作。

scheduler函数中调用swtch完成内核态上下文的保存和恢复,将当前hart上正在运行的进程的内核态上下文保存到context中,然后将即将被调度执行的进程的内核态上下文进行恢复:

swtch(&c->context, &p->context);

此时ra寄存器会被恢复为指向forkret函数的地址,然后swtch函数最后执行ret函数,跳转到forkret函数入口地址处执行,完成内核态到用户态的切换,这个切换的具体过程是下一部分我们要重点分析的过程。

forkret函数负责完成内核态到用户态的切换,通过trapframe恢复用户态上下文,此时sepc被赋值为userinit 0号进程初始化函数中被设置的trapframe->epc的值,然后执行sret完成S态到U态的切换,pc寄存器被赋值为spec,我们跳转到了initcode代码段入口地址处执行:

MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第5张图片

用户代码将exec需要的参数放在寄存器a0a1中,并将系统调用号放在a7中。

系统调用号与syscalls数组中的条目相匹配,syscalls数组是一个函数指针表(kernel/syscall.c:108)。
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第6张图片

ecall指令陷入(trap)到内核中,执行uservecusertrapsyscall,这个过程我们将会在下面详细分析。

syscallkernel/syscall.c:133)从陷阱帧(trapframe)中保存的a7中检索系统调用号(p->trapframe->a7),并用它索引到syscalls中,对于第一次系统调用,a7中的内容是SYS_execkernel/syscall. h:8),导致了对系统调用接口函数sys_exec的调用。

当系统调用接口函数返回时,syscall将其返回值记录在p->trapframe->a0中。这将导致原始用户空间对exec()的调用返回该值,因为RISC-V上的C调用约定将返回值放在a0中。系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用号无效,syscall打印错误并返回-1。


从用户空间陷入

我们紧接上一part的流程继续往下分析,当我们在swtch函数中通过ret指令跳转到forkret函数时,forkret内核会干什么呢?

//proc.c

// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void forkret(void)
{
  //静态的局部变量first
  //第一次执行该函数时进行初始化,在随后的forkret()调用中,'first'的值将跨越函数调用保持不变,不会重新初始化
  static int first = 1;

  // Still holding p->lock from scheduler.
  release(&myproc()->lock);

  if (first)
  {
    // File system initialization must be run in the context of a
    // regular process (e.g., because it calls sleep), and thus cannot
    // be run from main().
    first = 0;
    fsinit(ROOTDEV);
  }
  //执行从S态返回用户态的操作
  usertrapret();
}

注意:

  • 在xv6中,文件系统初始化必须在一个正常的进程上下文中进行,因为它需要执行一些可能会导致进程阻塞或休眠的操作。这些操作包括从磁盘上读取文件系统数据以及建立与硬件设备的连接。

  • 如果文件系统初始化在main()之前进行,那么它将在内核启动时进行,此时还没有正常的进程上下文可用。如果在这种情况下尝试执行与进程相关的操作,例如调用sleep()函数等待I/O完成,那么就会导致死锁,因为当前没有任何进程可以运行。

  • 为了避免这个问题,在xv6中,文件系统初始化被推迟到第一个进程被创建并开始执行之后。这样,文件系统初始化就在正常的进程上下文中进行,并且可以安全地进行可能会导致进程阻塞或休眠的操作。

usertrapret函数中会执行S态返回用户态的操作:

//proc.c

//这三个外部全局遍历定义在trampoline.s中
extern char trampoline[], uservec[], userret[];

//
// 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.
  //设置sstatus的值为0,以此来关闭S态下全局中断
  intr_off();

  // send syscalls, interrupts, and exceptions to trampoline.S
  // stvec更改为指向uservec
  w_stvec(TRAMPOLINE + (uservec - trampoline));

  // set up trapframe values that uservec will need when
  // the process next re-enters the kernel.
  //保存内核根页表的位置
  p->trapframe->kernel_satp = r_satp();         // kernel page table
  //保存当前进程的内核栈栈顶指针
  p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
  // 用户态发生trap时会调用usertrap进行处理
  p->trapframe->kernel_trap = (uint64)usertrap;
  //保存hart id
  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();
  //设置status的SPP位为0,这样sret指令执行后,会恢复到user特权下
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  //设置status的SPIE位为1,这样sret指令执行后, 会重新打开全局中断 
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  //重新设置sstatus寄存器的值
  w_sstatus(x);

  // set S Exception Program Counter to the saved user pc.
  //设置sepc指向trapframe中保存的epc,也就是我们先前设置好的程序的入口地址
  w_sepc(p->trapframe->epc);

  // tell trampoline.S the user page table to switch to.
  // 设置stap在sret执行后,指向进程的用户态根页表
  uint64 satp = MAKE_SATP(p->pagetable);

  // jump to trampoline.S at the top of memory, which 
  // switches to the user page table, restores user registers,
  // and switches to user mode with sret.
  // fn为userret函数的入口地址
  uint64 fn = TRAMPOLINE + (userret - trampoline);
  // TRAPFRAME帧用于在用户态和内核态切换时进行上下文的保存和恢复
  // 该帧在proc_pagetable初始化进程的用户态页表时分配物理页并在用户态页表建立映射
  // 其位置就在TRAMPOLINE帧下面
  // userret函数传入当前用户态上下文trapframe地址,和用户态的根页表
  ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

trampoline,uservec,userret是定义在trampoline.S中的三个全局符号,其中trampoline 符号是一个占位符标记,并不包含任何指令地址,它的存在只是为了让代码可以被正确地映射到用户空间和内核空间相同的虚拟地址:
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第7张图片

  • uservec用于用户态进入内核态时,进行用户态寄存器上下文环境的保存。
  • userret用于内核态返回用户态时,进行用户态寄存器上下文环境的恢复

同时,通过.section trampsec 语句用于创建一个名为 trampsec 的新节,而trampoline.S汇编文件后面做的事情,则是定义该节中包含哪些符号,在kernel.ld链接器脚本中会搜集名为trampsec的节,并将其放置在代码段后面:

MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第8张图片
kvminit内核页表初始化函数中,最后会将trampoline映射到内核虚拟地址空间最高处,大小为一个物理页。

同时,通过链接器脚本可知,.text节中包含了内核原本的所有代码节和trampsec节,所以可知,最终得到的代码段不仅包含了原有的内核代码,还包含了trampsec节相关指令。

又因为kvminit函数中已经对代码段进行了等价映射,又在最后单独将trampsec节的内容映射到内核虚拟地址空间最高处,实际上是将一块物理内存映射到了内核虚拟地址空间两处地方,但是我们只会通过访问内核虚拟地址最高地址处,来访问trampsec节的内容:
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第9张图片

kvmmap第二个参数中只需要传入trampoline全局符号的物理地址即可,因为trampoline全局符号的地址就是trampsec节在物理内存上的起始地址,映射大小为一页,刚好包含了整个trampsec节的内容。

所以usertrapret函数中涉及到的trampoline,uservec,userret三个符号的运算实际目的如下图所示:

//memlayout.h

// map the trampoline page to the highest address,
// in both user and kernel space.
// TRAMPOLINE被映射到的虚拟地址空间的地址--占据虚拟地址空间最高地址处的一个页面
#define TRAMPOLINE (MAXVA - PGSIZE)

MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第10张图片
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第11张图片


返回用户空间的是通过调用usertrapret (kernel/trap.c:90)完成的。

  • 该函数设置RISC-V控制寄存器,为将来来自用户空间的陷阱做准备。
  • 这涉及到将stvec更改为指向uservec,准备uservec所依赖的陷阱帧字段,并将sepc设置为之前保存的用户程序计数器。
  • 最后,usertrapret在用户和内核页表中都映射的蹦床页面上调用userret
  • 原因是userret中的汇编代码会切换页表。

usertrapret函数在关闭S态全局中断,更改stvec指向uservec,设置好trapframe相关待恢复上下文,sstatus寄存器相关Previous值,sepc寄存器和satp寄存器待恢复的值后,调用userret函数,并传入trapframe地址和satp寄存器的应该恢复值。

下面我们来看看userret这段汇编过程调用都干了啥:
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第12张图片
usertrapretuserret的调用将指针传递到a0中的进程用户页表和a1中的TRAPFRAME (kernel/trampoline.S:88)。

  • userretsatp切换到进程的用户页表。
  • 回想一下,用户页表同时映射蹦床页面和TRAPFRAME,但没有从内核映射其他内容。
  • 同样,蹦床页面映射在用户和内核页表中的同一个虚拟地址上的事实允许用户在更改satp后继续执行。
  • userret复制陷阱帧保存的用户a0sscratch,为以后与TRAPFRAME的交换做准备。
  • 从此刻开始,userret可以使用的唯一数据是寄存器内容和陷阱帧的内容。
  • 下一个userret从陷阱帧中恢复保存的用户寄存器,做a0sscratch的最后一次交换来恢复用户a0并为下一个陷阱保存TRAPFRAME,并使用sret返回用户空间。

userret函数进行trap返回,跳转到设置好的sepc地址处执行,这里就是initcode代码的入口地址处,并且页表完成了切换,所以使用的是当前进程用户态页表进行虚地址翻译。


initcode代码执行的实际是ecall系统调用,调用的syscall_exec函数:

MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第13张图片

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中被设置:
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第14张图片

// set up to take exceptions and traps while in the kernel.
void
trapinithart(void)
{
  //stvec指向kernelvec
  w_stvec((uint64)kernelvec);
}

kernelvec用于处理发生在S态下的所有中断和异常,但是如果是处于用户态下的程序发生了中断或者异常,则会跳转到uservec地址处执行,因为我们在usertrapret函数中将stvec更改为指向uservec,这样从S态返回到U态后,如果发生了异常或者中断,就都会跳转到uservec地址处执行了。
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第15张图片
uservec负责完成用户态切换到内核态时,用户态下寄存器上下文环境的保存:

trap发生时,硬件会自动保存当前特权级到SPP,SIE到SPIE,pc到spec , 同时关闭全局中断。

.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.
        #
        # sscratch points to where the process's p->trapframe is
        # mapped into user space, at TRAPFRAME.
        #
        
	# swap a0 and sscratch
        # so that a0 is TRAPFRAME
        # 交换a0和sscratch寄存器的值,此时a0指向TRAPFRAME的地址,sscratch保存a0的值
        csrrw a0, sscratch, a0

        # save the user registers in TRAPFRAME
        # 保存通用寄存器到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
	# 将sscratch保存的a0寄存器的值读取到t0中,然后把原本a0的值也保存进trapframe中
        csrr t0, sscratch
        sd t0, 112(a0)

        # restore kernel stack pointer from p->trapframe->kernel_sp
        # 有关当前进程在内核态中寄存器的值是在usertrapret中被设置的
        # 将kernel_satp的值设置到sp寄存器中,也就是sp指向当前进程内核栈的地址
        ld sp, 8(a0)

        # make tp hold the current hartid, from p->trapframe->kernel_hartid
        # 恢复tp寄存器的值,保存当前hart id 
        ld tp, 32(a0)

        # load the address of usertrap(), p->trapframe->kernel_trap
        # 使用t0寄存器暂存usertrap函数地址
        ld t0, 16(a0)

        # restore kernel page table from p->trapframe->kernel_satp
        # satp指向内核页表,并刷新TLB
        ld t1, 0(a0)
        csrw satp, t1
        sfence.vma zero, zero

        # a0 is no longer valid, since the kernel page
        # table does not specially map p->tf.

        # jump to usertrap(), which does not return
        // 跳转到Usertrap地址处继续执行
        jr t0

来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为satp指向不映射内核的用户页表,栈指针可能包含无效甚至恶意的值。

由于RISC-V硬件在陷阱期间不会切换页表,所以用户页表必须包括uservecstvec指向的陷阱向量指令)的映射。uservec必须切换satp以指向内核页表;为了在切换后继续执行指令,uservec必须在内核页表中与用户页表中映射相同的地址。

xv6使用包含uservec的蹦床页面(trampoline page)来满足这些约束。xv6将蹦床页面映射到内核页表和每个用户页表中相同的虚拟地址。这个虚拟地址是TRAMPOLINE。蹦床内容在trampoline.S中设置,并且(当执行用户代码时)stvec设置为uservec (kernel/trampoline.S:16)。

MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第16张图片

uservec启动时,所有32个寄存器都包含被中断代码所拥有的值。但是uservec需要能够修改一些寄存器,以便设置satp并生成保存寄存器的地址。RISC-V以sscratch寄存器的形式提供了帮助。uservec开始时的csrrw指令交换了a0sscratch的内容。现在用户代码的a0被保存了;uservec有一个寄存器(a0)可以使用;a0包含内核以前放在sscratch中的值。

uservec的下一个任务是保存用户寄存器。在进入用户空间之前,内核先前将sscratch设置为指向一个每个进程的陷阱帧,该帧(除此之外)具有保存所有用户寄存器的空间(kernel/proc.h:44)。因为satp仍然指向用户页表,所以uservec需要将陷阱帧映射到用户地址空间中。每当创建一个进程时,xv6就为该进程的陷阱帧分配一个页面,并安排它始终映射在用户虚拟地址TRAPFRAME,该地址就在TRAMPOLINE下面。尽管使用物理地址,该进程的p->trapframe仍指向陷阱帧,这样内核就可以通过内核页表使用它。

因此在交换a0sscratch之后,a0持有指向当前进程陷阱帧的指针。uservec现在保存那里的所有用户寄存器,包括从sscratch读取的用户的a0

陷阱帧包含指向当前进程内核栈的指针、当前CPU的hartidusertrap的地址和内核页表的地址。uservec取得这些值,将satp切换到内核页表,并调用usertrap


uservec函数再完成用户态寄存器环境上下文保存后,跳转到usertrap继续执行:

// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
// 处理用户态发生的trap
void
usertrap(void)
{
  int which_dev = 0;
  // 确保是用户态发生的trap  
  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.
  // 由于我们现在处于内核态,所以后续trap发生都交给kernelvec处理
  w_stvec((uint64)kernelvec);
  
  struct proc *p = myproc();
  
  // save user program counter.
  // 将spec保存到trapframe中
  p->trapframe->epc = r_sepc();
  // 错误码为8,表示产生的是系统调用异常
  if(r_scause() == 8){
    // system call
    // 如果当前进程被杀死了,然后销毁当前进程
    if(p->killed)
      exit(-1);

    // sepc points to the ecall instruction,
    // but we want to return to the next instruction.
    // 产生异常时,spec保存的是发生异常的那条指令地址,这里为了防止产生无限循环,将trapframe中保存的epc值+4
    p->trapframe->epc += 4;

    // an interrupt will change sstatus &c registers,
    // so don't enable until done with those registers.
    // 发生trap时,硬件会自动关闭全局中断,我们这里确保保存好了上面这些寄存器内容后,重新打开中断
    intr_on();
    // 系统调用号保存在a7寄存器中,调用syscall函数,根据系统调用号进行系统调用的派发
    syscall();
  }
  // 处理设备中断--判断是什么设备中断
  else if((which_dev = devintr()) != 0){
    // ok
  } else {
    //无法识别的cause错误码
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    p->killed = 1;
  }
  
  if(p->killed)
    exit(-1);

  // give up the CPU if this is a timer interrupt.
  // 如果是时钟中断,则主动让出当前cpu
  if(which_dev == 2)
    yield();
  // 如果不是时钟中断,说明是系统调用执行完毕后,执行到这里,则进行trap返回流程
  //该函数已经分析过了
  usertrapret();
}

usertrap的任务是确定陷阱的原因,处理并返回(kernel/trap.c:37)。

  • 如上所述,它首先改变stvec,这样内核中的陷阱将由kernelvec处理。
  • 它保存了sepc(保存的用户程序计数器),再次保存是因为usertrap中可能有一个进程切换,可能导致sepc被覆盖。
  • 如果陷阱来自系统调用,syscall会处理它;
  • 如果是设备中断,devintr会处理;
  • 否则它是一个异常,内核会杀死错误进程。
  • 系统调用路径在保存的用户程序计数器pc上加4,因为在系统调用的情况下,RISC-V会留下指向ecall指令的程序指针(返回后需要执行ecall之后的下一条指令)。
  • 在退出的过程中,usertrap检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是计时器中断)。
  • 最后调用usertrapret进行trap返回,这个函数已经分析过了,这里不再多讲

系统调用参数

内核中的系统调用接口需要找到用户代码传递的参数。因为用户代码调用了系统调用封装函数,所以参数最初被放置在RISC-V C调用所约定的地方:寄存器。

内核陷阱代码将用户寄存器保存到当前进程的陷阱框架中,内核代码可以在那里找到它们。函数artintartaddrartfd从陷阱框架中检索第n个系统调用参数并以整数、指针或文件描述符的形式保存。他们都调用argraw来检索相应的保存的用户寄存器(kernel/syscall.c:35)。
MIT 6.S081 (BOOK-RISCV-REV1)教材第四章内容 -- 上_第17张图片

有些系统调用传递指针作为参数,内核必须使用这些指针来读取或写入用户内存。

  • 例如:exec系统调用传递给内核一个指向用户空间中字符串参数的指针数组。
  • 这些指针带来了两个挑战。
    • 首先,用户程序可能有缺陷或恶意,可能会传递给内核一个无效的指针,或者一个旨在欺骗内核访问内核内存而不是用户内存的指针。
    • 其次,xv6内核页表映射与用户页表映射不同,因此内核不能使用普通指令从用户提供的地址加载或存储。

内核实现了安全地将数据传输到用户提供的地址和从用户提供的地址传输数据的功能。

  • fetchstr是一个例子(kernel/syscall.c:25)。
  • 文件系统调用,如exec,使用fetchstr从用户空间检索字符串文件名参数。
  • fetchstr调用copyinstr来完成这项困难的工作。
// 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();
  // 负责用户态虚地址转换,并将数据从用户态缓冲区拷贝到内核态中来
  int err = copyinstr(p->pagetable, buf, addr, max);
  if(err < 0)
    return err;
  return strlen(buf);
}

copyinstrkernel/vm.c:406)从用户页表中的虚拟地址srcva复制max字节到dst

  • 它使用walkaddr(它又调用walk)在软件中遍历页表,以确定srcva的物理地址pa0
  • 由于内核将所有物理RAM地址映射到同一个内核虚拟地址,
  • copyinstr可以直接将字符串字节从pa0复制到dst
  • walkaddrkernel/vm.c:95)检查用户提供的虚拟地址是否为进程用户地址空间的一部分,因此程序不能欺骗内核读取其他内存。
  • 一个类似的函数copyout,将数据从内核复制到用户提供的地址。
// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
// 从用户空间传过来的虚拟地址srcva处拷贝字符串,直到遇到'\0'结束符号,或者拷贝字符超过max限制
// copy带有'\0'结束符号的字符串
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  uint64 n, va0, pa0;
  int got_null = 0;

  while(got_null == 0 && max > 0){
    //用户态下的虚拟地址进行向下对齐
    va0 = PGROUNDDOWN(srcva);
    //遍历用户态页表,将传入的用户态虚拟地址翻译为物理地址
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > max)
      n = max;
    // 整体实现思路和 copyin 一致
    //不同之处在于,由于事先不清楚copy数据的长度,只能一个个字节的copy,边copy边判断是否到达字符串末尾
    char *p = (char *) (pa0 + (srcva - va0));
    while(n > 0){
      if(*p == '\0'){
        *dst = '\0';
        got_null = 1;
        break;
      } else {
        // 由于内核态下采用的是等价映射,所以才可以直接这样玩
        //毕竟dst代表内核态的虚拟地址,而p代表物理地址
        *dst = *p;
      }
      --n;
      --max;
      p++;
      dst++;
    }
    srcva = va0 + PGSIZE;
  }
  if(got_null){
    return 0;
  } else {
    return -1;
  }
}

如果做过页表节实验小伙伴可能知道,由于我们为每个进程在内核中增加了自己的内核页表,并将用户态下的内存通样映射到了每个进程自己的内核页表中,所以传入的用户态指针可以直接在内核态下进行解引用,下面是上一节实验改造后的copystr实现:

// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  return copyinstr_new(pagetable, dst, srcva, max);
}

// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  struct proc *p = myproc();
  char *s = (char *) srcva;
  
  stats.ncopyinstr++;   // XXX lock
  //无需经历虚地址到物理地址翻译这一步,可以直接解引用,因为每个进程的内核页表包含了用户态内核映射
  //所以无需考虑转换
  for(int i = 0; i < max && srcva + i < p->sz; i++){
    dst[i] = s[i];
    if(s[i] == '\0')
      return 0;
  }
  return -1;
}

补充

下面补充视频课程中提到的一些点,虽然上面都提到了,但是可能有些地方教材描述的不是特别清楚,我附加的说明也不够到位,所以还是给出视频中大佬通俗易懂的讲解:

  1. trap处理过程中,我们需要关注哪些寄存器?
  • 32个用户寄存器,RISC-V总共有32个比如a0,a1这样的寄存器,用户应用程序可以使用全部的寄存器,并且使用寄存器的指令性能是最好的。
  • 寄存器是stack pointer(也叫做堆栈寄存器 stack register)
  • 程序计数器(Program Counter Register)
  • 表明当前mode的标志位,这个标志位表明了当前是supervisor mode还是user mode。当我们在运行Shell的时候,自然是在user mode。该标志位由硬件负责维护,代码不可见,我们只能通过XPP来间接控制当前特权级切换。
  • 还有一堆控制CPU工作方式的寄存器(CSR)
    • 比如SATP(Supervisor Address Translation and Protection)寄存器,它包含了指向page table的物理内存地址
    • 比如STVEC(Supervisor Trap Vector Base Address Register)寄存器,它指向了内核中处理trap的指令的起始地址
  • SEPC(Supervisor Exception Program Counter)寄存器,在trap的过程中保存程序计数器的值
  • SSRATCH(Supervisor Scratch Register)寄存器,这也是个非常重要的寄存器
  • 这些寄存器表明了执行系统调用时计算机的状态。
  1. trap发生时,我们需要做些什么?
  • 首先,我们需要保存32个用户寄存器。因为很显然我们需要恢复用户应用程序的执行,尤其是当用户程序随机的被设备中断所打断时。我们希望内核能够响应中断,之后在用户程序完全无感知的情况下再恢复用户代码的执行。所以这意味着32个用户寄存器不能被内核弄乱。但是这些寄存器又要被内核代码所使用,所以在trap之前,你必须先在某处保存这32个用户寄存器。

  • 程序计数器也需要在某个地方保存,它几乎跟一个用户寄存器的地位是一样的,我们需要能够在用户程序运行中断的位置继续执行用户程序。

  • 我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令。

  • SATP寄存器现在正指向user page table,而user page table只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将SATP指向kernel page table。

  • 我们需要将堆栈寄存器指向位于内核的一个地址,因为我们需要一个堆栈来调用内核的C函数。

  • 一旦我们设置好了,并且所有的硬件状态都适合在内核中使用, 我们需要跳入内核的C代码。

  • 一旦我们运行在内核的C代码中,那就跟平常的C代码是一样的。

  • 操作系统的一些high-level的目标能帮我们过滤一些实现选项。其中一个目标是安全和隔离,我们不想让用户代码介入到这里的user/kernel切换,否则有可能会破坏安全性。所以这意味着,trap中涉及到的硬件和内核机制不能依赖任何来自用户空间东西。比如说我们不能依赖32个用户寄存器,它们可能保存的是恶意的数据,所以,XV6的trap机制不会查看这些寄存器,而只是将它们保存起来。
  • 在操作系统的trap机制中,我们仍然想保留隔离性并防御来自用户代码的可能的恶意攻击。同样也很重要的是,另一方面,我们想要让trap机制对用户代码是透明的,也就是说我们想要执行trap,然后在内核中执行代码,同时用户代码并不用察觉到任何有意思的事情。这样也更容易写用户代码。
  1. 在supervisor mode完成,但是不能在user mode完成的工作,或许并没有你想象的那么有特权。所以,我们接下来看看supervisor mode可以控制什么?
  • 其中的一件事情是,你现在可以读写控制寄存器(CSR)了。
    • 比如说,当你在supervisor mode时,你可以:读写SATP寄存器,也就是page table的指针;
    • STVEC,也就是处理trap的内核指令地址;
    • SEPC,保存当发生trap时的程序计数器;
    • SSCRATCH等等。
    • 在supervisor mode你可以读写这些寄存器,而用户代码不能做这样的操作。
  • 另一件事情supervisor mode可以做的是,它可以使用PTE_U标志位为0的PTE。
    • 当PTE_U标志位为1的时候,表明用户代码可以使用这个页表;
    • 如果这个标志位为0,则只有supervisor mode可以使用这个页表。
  • 这两点就是supervisor mode可以做的事情,除此之外就不能再干别的事情了。
  • 需要特别指出的是,supervisor mode中的代码并不能读写任意物理地址。在supervisor mode中,就像普通的用户代码一样,也需要通过page table来访问内存。
  • 如果一个虚拟地址并不在当前由SATP指向的page table中,又或者SATP指向的page table中PTE_U=1,那么supervisor mode不能使用那个地址。
  • 所以,即使我们在supervisor mode,我们还是受限于当前page table设置的虚拟地址。
  1. vm.c里的函数为什么可以直接访问物理内存?
  • 这些函数能这么做的原因是,内核小心的在page table中设置好了各个PTE。
  • 这样当内核收到了一个读写虚拟内存地址的请求,会通过kernel page table将这个虚拟内存地址翻译成与之等价物理内存地址,再完成读写。
  • 所以,一旦使用了kernel page table,就可以非常方便的在内核中使用所有这些直接的映射关系。
  • 但是直到trap机制切换到内核之前,这些映射关系都不可用。
  • 直到trap机制将程序运行切换到内核空间之前,我们使用的仍然是没有这些方便映射关系的user page table。
  1. read和write系统调用,相比内存的读写,他们的代价都高的多,因为它们需要切换模式,并来回捣腾。有没有可能当你执行打开一个文件的系统调用时, 直接得到一个page table映射,而不是返回一个文件描述符?这样只需要向对应于设备的特定的地址写数据,程序就能通过page table访问特定的设备。你可以设置好限制,就像文件描述符只允许修改特定文件一样,这样就不用像系统调用一样在用户空间和内核空间来回捣腾了。
  • 这是个很好的想法。实际上很多操作系统都提供这种叫做内存映射文件(Memory-mapped file access)的机制,在这个机制里面通过page table,可以将用户空间的虚拟地址空间,对应到文件内容,这样你就可以通过内存地址直接读写文件。
  • 实际上,你们将在mmap 实验中完成这个机制。
  • 对于许多程序来说,这个机制的确会比直接调用read/write系统调用要快的多。

本节内容已经足够多了,后续的内容将会在下一小节进行补充说明。

你可能感兴趣的:(#,MIT,6.S081,网络,linux,服务器)