Boot and trap

好的,我们来一次最详尽的、端到端的旅程,从机器上电开始,到xv6内核初始化,再到第一个用户进程发起trap 以及内核如何处理内核自身的trap,最后返回用户态。

阶段一:启动与内核初始化

  1. 机器上电 -> _entry (kernel/entry.S)
    • 前提:RISC-V机器的引导加载程序(Bootloader,如QEMU的内置BIOS)已经将xv6内核加载到物理地址0x80000000处。
    • 跳转:Bootloader跳转到内核的入口点,即_entry
    • 设置初始栈:C语言代码需要栈才能运行。_entry的第一项任务就是设置一个临时的内核栈。它将sp(stack pointer)寄存器指向stack0内存区域的顶部。stack0kernel/start.c中被定义为一个静态数组,为早期启动代码提供栈空间。
    • 调用start:设置好栈后,_entry通过call start指令,跳转到kernel/start.c中的start函数。
  2. M-mode -> S-mode的切换 (kernel/start.c)
    • start函数是内核的第一个C函数,但它运行在最高权限级别——Machine Mode (M-mode)。它的核心任务是配置好硬件,然后从M-mode切换到S-mode(Supervisor Mode),因为整个xv6内核都设计为在S-mode下运行。
    • 配置trap:
      • 它将所有中断和异常委托(delegate)给S-mode处理。这是通过设置medeleg(Machine Exception Delegation)和mideleg(Machine Interrupt Delegation)寄存器完成的。这意味着当一个trap发生时,硬件会直接在S-mode下处理,而不是先在M-mode中捕获。
      • 设置时钟中断。
    • 设置返回点:它将main函数的地址(kernel/main.c中)写入mepc寄存器(Machine Exception Program Counter)。
    • 设置返回权限:它在mstatus寄存器中设置MPP字段为S-mode,告诉硬件mret指令执行后应该进入S-mode。
    • 执行mret:这是关键的切换指令。硬件会:
      1. 跳转到mepc指向的地址,即main函数。
      2. 将权限级别从M-mode降为S-mode。
  3. 内核服务初始化 (kernel/main.c)
    • 现在CPU处于S-mode,并开始执行main函数。
    • main函数会依次调用一系列*_init函数,初始化内核的各个子系统:
      • kinit():初始化物理内存分配器。
      • kvminit():创建内核页表。
      • kvminithart():启用分页机制(将内核页表地址写入satp寄存器),从此地址都是虚拟地址。
      • procinit():初始化进程表。
      • trapinit():空函数,占位。
      • trapinithart():设置stvec指向kernelvec。这是我们讨论过的关键点,确保内核态发生的任何trap都由kernelvec处理。
      • plicinit() / plicinithart():初始化中断控制器。
      • binit()iinit()fileinit():初始化文件系统相关模块。
      • userinit():创建第一个用户进程。
      • scheduler():启动调度器,永不返回。

阶段二:第一个用户进程的诞生与运行

  1. 创建init进程 (kernel/proc.c)
    • userinit函数负责创建第一个进程,它将执行一小段汇编程序initcode.S
    • 它调用allocproc分配一个进程结构体和内核栈。
    • 它为进程分配一页物理内存,并将initcode.S的二进制码复制进去。
    • 精心构造Trapframe:它设置进程的trapframe (p->trapframe),这相当于一个“假”的trap现场,为将来返回用户态做准备:
      • epc(返回地址)设置为0,因为initcode被加载在进程虚拟地址空间的0地址处。
      • sp(用户栈)设置为页的顶部。
    • 最后将进程状态设为RUNNABLE
  2. 调度与第一次进入用户态
    • main函数调用scheduler(),它在一个死循环里寻找RUNNABLE的进程。它找到了init进程。
    • scheduler通过swtch切换到init进程的内核栈,然后调用usertrapret
    • usertrapret (kernel/trap.c) 是进入用户态的闸门:
      1. w_stvec((uint64)uservec):将stvec切换到uservec,为即将到来的用户态trap做好准备。
      2. 准备sstatus寄存器的值,以便sret后能开启中断并进入U-mode。
      3. 调用userret (kernel/trampoline.S),它从trapframe中恢复所有通用寄存器。
      4. 执行sret:CPU切换到U-mode,开始从init进程的虚拟地址0处执行代码。

阶段三:用户态Trap与内核处理

  1. 用户进程发起Trap(例如 ecall
    • initcode.S执行ecall指令以调用exec系统调用。
    • 硬件响应:
      • CPU处于U-mode,stvec指向uservec
      • 硬件自动切换到S-mode,保存pc到sepc,保存原因到scause,然后跳转到stvec指向的uservec
  2. uservec -> usertrap
    • uservec (trampoline.S) 保存所有用户寄存器到trapframe,然后跳转到usertrap (trap.c)。
    • usertrap做的第一件事就是w_stvec((uint64)kernelvec),将stvec切回kernelvec,确保在内核处理期间的安全性。
    • 它判断scause是系统调用,于是调用syscall()函数,最终执行sys_exec

阶段四:内核态Trap的处理(假设内核有Bug)

  1. 内核代码触发Trap
    • 假设在执行sys_exec的过程中,内核代码访问了一个非法的空指针。
    • 硬件响应:
      • CPU当前处于S-mode,stvec指向kernelvec
      • 硬件自动保存pc(指向内核中出错的指令)到sepc,设置scause(例如,load page fault),然后跳转到stvec指向的kernelvec
  2. kernelvec -> kerneltrap
    • kernelvec (kernel/kernelvec.S) 是一个精简版的uservec。它也保存所有寄存器(此时是内核寄存器),然后跳转到kerneltrap (kernel/trap.c)。
    • kerneltrap函数是内核错误的终点站。它会打印出详细的错误信息(scause, sepc, stval),然后调用panic()
    • panic()会打印”panic: “前缀和错误信息,然后使系统停机(进入一个无限循环)。因为内核自身的错误是不可恢复的。

阶段五:正常返回用户态

  1. usertrap返回
    • 假设内核没有bug,syscall()正常返回到usertrap
    • usertrap调用usertrapret
    • usertrapret再次将stvec切换回uservec,并调用userret
    • userret恢复所有用户寄存器。
    • 执行sret:CPU切换回U-mode,从sepc指定的地址(ecall的下一条指令)继续执行用户代码。

这个完整的闭环展示了xv6如何利用stvec的动态切换,

为S-mode和U-mode下的trap分别设置不同的处理流程,从而清晰地隔离了用户错误和致命的内核错误。