对于mit-6.S081课程中的trap实验,初次接触,自己看着提示也没有头绪,坐在那里只看着无法敲出一个代码。看了别人的解答后发现自己对trap的进入到结束里面一些寄存器处理的细节并不清晰。下面就从trap实验中的testcase产生的trap从处理到结束来完整的学习下吧。

  • 首先进入alarmtest.asm,tarp的alarm实验是使用的系统调用进入的trap,在函数test0中找到系统调用sigalarm
  • 可以看到执行指令jalr前,调用的参数分别存入了a0,a1两个寄存器。jalr指令会做什么事情呢?

    • jalr( jump and link register )指令的基本格式如下:

      jalr rd,offest(rs1) rd表示目标寄存器,offset表示偏移量立即数,rs1表示源寄存器

    • 这个指令会做下面几件事:

      1. 计算目标地址:将rs1中的值加上立即数偏移量,得到目标地址
      2. 设置返回地址:将当前指令地址加4(下一条指令)写入rd寄存器。
      3. 跳转:将程序计数器(pc)设置为目标地址,并且最低位清零(确保对齐到字边界)。
  • 上面例子中目标地址是多少呢?就需要得到当前ra中的值了,上一条指令就对ra的值进行了处理,我们来计算下ec: auipc ra,0x0,同样又是一条汇编指令我们呢简单介绍下auipc rd,imm指令,rd = pc + (imm<<12)。按照这个指令执行下ra = ec + (0x0<<12), ra = 0xec, 再执行jalr指令,pc = 1510 + ra, pc = 0x6d2,同时ra = 0xec + 4 = 0xf4, 也就是跳转指令的下一条指令。可以看出就是汇编源码注释的内容。下图就是跳转后的地方:

  • 这里的所有指令相信大家都挺熟悉的,这是由文件user/usys.pl脚本语言生成的,系统调用号22,也是自己在usys.pl中设置的。我们需要重点关注下ecall指令,这是进入trap的开始。
    • ecall指令执行,会做什么事呢?
      1. 触发异常
        • ecall 指令会触发一个 环境调用异常
        • ecall指令会根据当前的特权级别(User mode or Supervisor mode), 处理器会进入相应的异常处理流程:
          • 如果处于 U-mode , 则会进入 S-mode
          • 如果处于 S-mode , 则会进入 M-mode
        • 异常的类型编号为:
          • 在 U-mode 下:ECALL_U_MODE(异常代码 8)
          • 在 S-mode 下:ECALL_S_MODE(异常代码 9)
      2. 保存上下文
        • 处理器会自动保存当前的上下文信息,包括:
          • 硬件会自动更新 mepc 或 sepc 来记录当前 PC 的值(即触发异常的指令地址)
          • 更新 mstatus 或 sstatus 来反映新的特权级别和其他状态变化(其中会将SIE保存到SPIE,同时将SIE置0,则s-mode中断会自动被禁用,防止在处理异常的过程中被新的中断打断。所以在进入usertrap函数中时,如果是系统调用,有个显示打开s-mode中断的语句–intr_on())
          • 设置 mcause 或 scause 来指示异常的原因
      3. 更新PC到异常处理程序
        • 当异常发生时,处理器根据 mtvec 或 stvec 中的值跳转到异常处理程序,即更新pc值为这两个寄存器(两个寄存器的值在内核启动时会被设置)中的值
  • 由于stvec寄存器中的的值为uservec函数的入口地址,接下来就跳转到了uservec函数。此时已经进入了S-mode,但页表还是user pagetable。没有崩溃的原因是uservec这段代码是写在trampoline中的,而trampoline在不同进程的页表都将这段相同的虚拟地址映射到同一段物理地址(可以看进程的创建中的页表映射实现),所以仍然可以执行。下面来看uservec中做了什么事:
    • 指令为csrrw a0, sscratch, a0,上面的注释中已经介绍了,就是交换了a0sscratch的值,而a0sscratch没交换前分别是什么呢?a0就是系统调用的第一个参数2,sscratch中的值在进程创建后设置为了trapframe的虚拟地址(进程创建时处于内核态,被调度器选择后,在返回到用户态之前设置sscratch的值为p->trapframe,不同进程都是同一个虚拟地址)。
    • 这样a0就是当前进程的trapframe的地址,trapframe是用来实现上下文切换的。保存切换前的用户寄存器信息如下:

    • 由于一开始将用户态中的a0交换到了sscratch,没有保存。所以需要再保存下以免丢失信息。
        csrr t0, sscratch # csrr 将 sscratch 值写入 t0
        sd t0,112(a0)  #将 t0 中的值 写入内存(p->trapframe->a0)
      
    • 保存了这些寄存器信息我们就可以为正式进入内核做准备了,准备将进程从用户栈切换到内核栈。这里简单介绍下内核栈和用户栈是在哪里创建的。

      1. 内核栈在kernel/proc.c中的proc_mapstacks中有这样一个分配操作:

        uint64 va = KSTACK((int) (p - proc));

        kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);

      2. userinit() 函数位于 kernel/proc.c 文件中,负责创建第一个用户进程,并为其设置用户栈。 exec() 系统调用的实现也在 kernel/exec.c 文件中,这里包含了加载程序及设置用户栈的具体逻辑。

    • 下面来看看栈的切换以及进入内核相关的准备,最后跳入usertrap函数。
    • ld指令是将内存中的数复制到寄存器中,trapframe中保存着kernel sp、kernel_hartid、kernel_trap、kernel_satp,它们一一从内存中加载到寄存器中,准备切换。完成后进入usertrap函数。