6.S081——陷阱部分(一文读懂xv6系统调用)——xv6源码完全解析系列(5)

0.briefly speaking

这篇博客将要开始尝试阅读和研究与Xv6陷阱机制相关的代码,主要有以下文件,最重要的是结合Xv6 book将Xv6处理陷阱的相关逻辑和流程弄透。在Xv6的语境中所谓陷阱的触发有以下三种情况:

  • 系统调用
  • 严重错误(比如除0错误)
  • 设备中断

而从陷阱的来源分类,陷阱可以分为从用户态陷阱和从内核态陷阱

用户态陷阱包含上面的三种触发情况,而内核态陷阱只包含后两种情况。
作为用户态陷阱中最具有代表性的类型,对系统调用的全流程进行分析会帮助我们梳理清楚从用户态陷阱的每一个步骤。

陷阱部分的博客将从以上三种陷阱入手,分门别类地对Xv6内核中陷阱的处理进行详细分析,本篇博客将详细分析一下系统调用的整个流程。

本篇博客将会主要涵盖以下源代码文件的阅读:
1.kernel/trampoline.S
2.kernel/trap.c
3.kernel/riscv.h

在阅读中断和异常的更多细节之前,可以参考我的其他一篇博客:RISC-V架构中的异常与中断详解,这篇文章详细地介绍了Xv6系统所运行的平台SiFive_Unleashed中的陷阱处理机制,肯定会对大家有所助益。


1. system call

系统调用作为Xv6中很重要的一个话题,我们的前两个实验都与它有关。在第一个实验中,我们使用已有的系统调用功能完成了几个应用程序的编写。在第二个实验中,我们则深入内核编写了属于自己的一些系统调用。在这次的代码阅读中,我们将深入系统调用的作用流程和机理,同时弄懂我们在实验2中为什么要在usys.pl中添加一个系统调用的stub,它生成的usys.S到底是做什么的等等一些问题。

1.1 Phase 1——从用户态函数到ecall——陷入trampoline

这里就借用Robert教授在课上的例子吧,我们在user/sh.c中的getcmd函数(user/sh.c:134)的开始,将fprintf函数修改为write函数,替换前后的功能是一样的,不同的地方在于write函数是write系统调用在用户态下的封装,当用户使用write函数时,它会通过陷阱机制调用write系统调用从而完成函数功能。

int
getcmd(char *buf, int nbuf)
{
    
    
  // fprintf(2, "$ ");
  write(2, "$ ", 2);
  memset(buf, 0, nbuf);
  gets(buf, nbuf);
  if(buf[0] == 0) // EOF
    return -1;
  return 0;
}

将getcmd做了修改之后,当程序执行到write系统调用这里的时候会发生什么呢?这得深入到汇编的层面看看了,好在Xv6的Makefile为了debug更加方便,将编译出来的目标文件使用objdump进行反编译。上述的sh.c的反汇编文件就是sh.asm,我们在其中找到getcmd函数中对write函数的调用,如下所示:

write(2, "$ ", 2);
	  # 将2加载到a2,也就是字符串的长度
      10:	4609              li	a2,2			
      
      # 这两串代码应该是分配了一段空间来存储字符串
      # 并将地址放置到寄存器a1中
      # malloc函数作为库函数被链接到代码中,细节我们不在此追究
      12:	00001597          auipc	a1,0x1		
      16:	2d658593          addi	a1,a1,726 	# 12e8 <malloc+0xec>
      
      # 这里将第一个参数2放入a0
      1a:	4509              li	a0,2		
		
	  # <--可以看到上述的汇编其实就是按照calling conventions将参数放置到对应的寄存器里-->
	  
	  # auipc指令将1左移12位,与当前PC值相加:0x1000 + 0x1c = 0x101c,结果写入ra
	  # jalr指令将PC设置为0x101c - 0x232(562)= 0xdea(最低位清0),即write调用的位置
	  # 同时ra = PC + 4 = 24 
      1c:	00001097          auipc	ra,0x1
      20:	dce080e7          jalr	-562(ra) 	# dea <write>

上述汇编语言做的事情非常简单,就是按照RISC-V的calling conventions将参数传入约定好的寄存器。然后经过auipc和jalr两条指令的配合,跳转到write标签处继续执行,那么write标签处对应的代码是什么呢?我们跳转到地址dea这个地方看看:

0000000000000dea <write>:
# <以下这段代码其实就是usys.S中的内容>
# 声明write的全局可见性
.global write
write:
 # 将write函数的系统调用号加载到a7寄存器中,系统调用号定义在kernel/syscall.h中
 li a7, SYS_write
     dea:	48c1                	li	a7,16
     
 # 使用ecall指令主动触发一个陷阱
 ecall
     dec:	00000073          		ecall
 
 # 系统调用返回
 ret
     df0:	8082                	ret

上面这段汇编首先将系统调用号载入a7寄存器,系统调用号在后面将会被用来请求对应的内核服务,因此它起到了一个纽带的关系,将对应的内核系统调用例程一一地映射到用户态函数。然后这段汇编调用了ecall指令来请求内核的服务,接下来就是重头戏了:

当主动发起一个系统调用时,意味着我们要请求内核的服务(正如Xv6 Book中所述:One situation is a system call, when a user program executes the ecall instruction to ask the kernel to do something for it.)。ecall指令就像是一个钥匙,帮助我们打开内核服务的大门,那么ecall指令具体做什么呢?

ecall(environment call)指令负责提升RISCV的优先级模式,RISCV有三种模式:用户模式(User Mode)、监视者模式(Supervisor Mode)和机器模式(Machine Mode)。这三种模式的优先级依次升高,当我们主动调用一个系统调用时时,需要提升CPU的特权模式,以获得对某些寄存器的访问权某些指令的执行权

事实上,ecall主动触发了一个用户态异常,进而会导致一系列的陷阱动作,它们都是由硬件自动完成的

  • 将用户的特权模式从U-Mode提升至S-Mode,为陷阱的处理做准备。
  • 当前正在执行的指令地址(或当前指令的下一条)放入sepc中保存
  • 将stvec中保存的trampoline程序入口地址放入pc中,准备进入。
  • 将当前导致陷阱的原因记录在scause寄存器中
  • 当前模式保存在sstatus的SPP位,并清空sstatus中的SIE位来关闭中断,之前的SIE为保存在SPIE位。
  • 更新stval寄存器的值,使其指向出现异常的地址

上述步骤是处理一个陷阱的自动动作,无论导致陷阱的原因是异常还是中断,它们的硬件流程都是上面这些

这里其实涉及到一个业界一直以来争议许久的问题,那就是操作系统和硬件的边界:哪些功能由硬件负责完成,哪些由操作系统负责完成。这是设计者们必须要考虑的问题,在RISC-V的设计中,为了最大程度地保证指令集的可移植性,RISC-V选择将灵活性交给软件设计者,而硬件只完成很少的必要的工作

所以,现在pc的值被设置为stvec的值了,上面的步骤都是CPU内部的硬件电路完成的。所以程序即将从之前stvec保存的地址值开始执行,而stvec指向了谁呢?——uservec

(这里留下一个小问题,谁在一开始设置了stvec的值让它指向uservec呢?)

1.2 Phase 2——uservec——保存现场、转入内核

uservec是trampoline.S(kernel/trampoline.S:)中定义的一段汇编代码,之前我们在研究虚拟内存的时候研究过,无论是在用户地址空间还是在内核地址空间,在高地址总有一个虚拟地址指向这段物理内存,打开trampoline.S代码后发现,开头就是uservec的定义,我们仔细分段地阅读一下这段代码:

首先是一段注释,对trampoline.S这段代码的一些情况进行了说明:

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.
        # 译:trap.c设置了stvec指向这里,所以源于用户态的陷阱从这里开始
        # 当前CPU处于supervisor模式,但页表还是用户页表
        # sscratch指向的是进程的p->trapframe在内存中的映射位置,也就是TRAPFRAME

通过这段注释可以了解到,在trap.c源代码文件中有一段代码将stvec寄存器的值设置为指向这里(哈哈,这好像把之前问题的答案透露了一部分)。剩下的注释告诉我们,当代码运行到此的时候,CPU已经处于supervisor模式中,但是地址空间却还停留在用户地址空间中,这暗示了在接下来的汇编代码中可能涉及到页表的切换,我们暂且往下看。

在用户态陷入到内核态的过程中(或者处理中断时),一个很重要的概念是保存现场,这是几乎所有操作系统教材上都会提到的一个名词,Xv6也不例外。当我们主动触发一个系统调用时,它一定要保存在被打断时处理器的状态,而对于CPU来说,所谓的状态就是一些寄存器的值,RISC-V的通用寄存器堆如下所示:
在这里插入图片描述

保存寄存器的值需要一个空闲的通用寄存器来寻址,但是现在所有的寄存器又都在被占用,这就陷入了一个进退两难的境地。RISC-V解决这个问题的方法是,引入sscratch寄存器。正如上面的注释所说的,sscratch寄存器指向了一个进程trapframe的(虚拟)地址TRAPFRAME

RISC-V在这里会使用一个非常巧妙的办法,对换sscratch和a0寄存器的值,这样一方面保证了a0的值不丢失,另一方面还保证了对调之后a0寄存器正好指向了要保存寄存器的内存位置,代码如下所示:

		# swap a0 and sscratch
        # so that a0 is TRAPFRAME
        # 译:交换a0和sscratch的值
        # 所以a0现在的值指向trapframe
        # csrrw的用法:csrrw rd, csr, rs1
        # csrrw的作用:t = CSRs[csr], CSRs[csr] = x[rs1], x[rd] = t
        # 下面这条指令的作用即:t = sscratch, sscratch = a0, a0 = t
        # 即交换了二者的值,既暂存了a0在sscratch中,又使得a0指向了trapframe
        csrrw a0, sscratch, a0

(和上面一样,这里思考一个问题,谁在什么时候将sscratch寄存器的值写为TRAPFRAME的?)

trapframe是一个专门用来存放上下文状态的专用物理内存区域,每个进程在创建时(kernel/proc.c:124)都会分配一个物理页作为trapframe页,这个物理页的物理地址会保存在进程结构体的p->trapframe指针中。最后,在proc_pagetable函数中会将高地址TRAPFRAME映射到p->trapframe这个物理地址中。trapframe定义在kernel/proc.h:44,定义如下:

struct trapframe {
    
    
  /*   0 */ uint64 kernel_satp;   // kernel page table
  /*   8 */ uint64 kernel_sp;     // top of process's kernel stack
  /*  16 */ uint64 kernel_trap;   // usertrap()
  /*  24 */ uint64 epc;           // saved user program counter
  /*  32 */ uint64 kernel_hartid; // saved kernel tp
  /*  40 */ uint64 ra;
  /*  48 */ uint64 sp;
  /*  56 */ uint64 gp;
  /*  64 */ uint64 tp;
  /*  72 */ uint64 t0;
  /*  80 */ uint64 t1;
  /*  88 */ uint64 t2;
  /*  96 */ uint64 s0;
  /* 104 */ uint64 s1;
  /* 112 */ uint64 a0;
  /* 120 */ uint64 a1;
  /* 128 */ uint64 a2;
  /* 136 */ uint64 a3;
  /* 144 */ uint64 a4;
  /* 152 */ uint64 a5;
  /* 160 */ uint64 a6;
  /* 168 */ uint64 a7;
  /* 176 */ uint64 s2;
  /* 184 */ uint64 s3;
  /* 192 */ uint64 s4;
  /* 200 */ uint64 s5;
  /* 208 */ uint64 s6;
  /* 216 */ uint64 s7;
  /* 224 */ uint64 s8;
  /* 232 */ uint64 s9;
  /* 240 */ uint64 s10;
  /* 248 */ uint64 s11;
  /* 256 */ uint64 t3;
  /* 264 */ uint64 t4;
  /* 272 */ uint64 t5;
  /* 280 */ uint64 t6;
};

可以看到一个trapframe中不仅保存了RISC-V的所有通用寄存器,还保存了包括内核栈指针、内核页表等等一系列关键信息。现在trapframe的地址已经保存在a0寄存器里了,是时候将现有的寄存器数据存放到trapframe帧里了,在阅读下面的代码时注意一件事情,我们这里操纵的所有地址都是虚拟地址,这些代码能够正确地执行,是因为我们目前还处于用户地址空间,一旦切换到内核地址空间,a0这个地址就不会正确对应到trapframe了

代码如下所示:

        
        # save the user registers in TRAPFRAME
        # 将CPU的所有寄存器存放到trapframe中的对应位置
        # 注意前32个字节用来存放切换到内核时需要使用的信息
        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 s1, 112(a0),这是因为a0的值现在正存在sscratch中,必须单独处理>
        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
		# 之前我们将a0的值存放在了sscratch寄存器中,首先将其读到t0寄存器中
		# 并存放到trapframe中
        csrr t0, sscratch
        sd t0, 112(a0)
		
		# <至此完成了所有寄存器信息的保存>

至此,uservec完成了所有寄存器信息的保存,接下来准备转换进入内核态,看看它具体在做什么,首先是设置内核栈指针,回顾一下内核地址空间会发现,每个进程都有自己的内核栈,这个内核栈是在系统启动时就已经分配好并映射在内核地址空间中的(kernel/proc.c:32),现在我们只是设置好栈指针:

		
        # restore kernel stack pointer from p->trapframe->kernel_sp
        # 恢复对应进程的内核栈指针
        # 每个进程都有自己的内核栈,它是执行代码的必要条件,所以在这里设置好
        ld sp, 8(a0)

在这里插入图片描述
接下来,将其他切换入内核态需要的信息加载到对应寄存器,细节我都注释在了下方的代码中,如下所示:

        # make tp hold the current hartid, from p->trapframe->kernel_hartid
        # 将trapframe中存放的cpu的id号放入tp寄存器
        # hartid是RISC-V给每个CPU的一个ID号,内核态下必须保存在tp寄存器中
        ld tp, 32(a0)

        # load the address of usertrap(), p->trapframe->kernel_trap
        # 将p->trapframe->kernel_trap的地址放入t0寄存器,准备跳入
        ld t0, 16(a0)

        # restore kernel page table from p->trapframe->kernel_satp
        # 从p->trapframe的kernel_satp中恢复内核页表
        ld t1, 0(a0)
        csrw satp, t1
		
		# 清空快表TLB
        sfence.vma zero, zero	
		
		# <自此:地址空间已经从用户地址空间切换进入内核地址空间>
		# 所有地址翻译都将通过内核页表进行,但是原有地址翻译完全被打破
		# 但是因为trampoline.S的代码映射在内核和用户空间中保持一致
		# 所以代码得以连续不断地继续执行(PC的连续性得以保持)
		
        # a0 is no longer valid, since the kernel page
        # table does not specially map p->tf.
        # 译:a0此时不再有效,因为内核地址空间并没有用户的trapframe的映射

        # jump to usertrap(), which does not return
        # 跳转进usertrap的地址,本操作不会返回
        jr t0

OK,很优雅地,我们通过trampoline.S中的uservec函数成功实现了上下文的保存用户态到内核态的切换,现在我们看到的地址空间完全是内核地址空间,而且使用的程序栈也是内核栈。接下来由于我们在上面跳转进了usertrap函数,现在就要进入它了!

1.3 Phase 3——usertrap(kernel/trap.c:37)——分发到syscall

现在彻底进入了内核态执行程序,让我们看一看usertrap函数正在做什么。据Xv6 book所述,usertrap就像是一个中转站,它判断陷阱的原因并将陷阱分发到对应的处理句柄(handler)。所以千万不要被usertrap的名字迷惑,这里所谓的user指的是陷阱的来源是用户空间

下面仔细阅读一下usertrap的实现代码,因为本篇博客研究的主要内容是系统调用的实现,所以对于设备和定时器中断,抑或是内核陷阱,这里暂且不深入研究,等到对应的博客中再仔细研究,它们涉及进程的调度和更多背景知识。

// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
// 译:处理一个从用户空间来的中断、异常、或系统调用
// 从trampoline.S中被调用
void
usertrap(void)
{
    
    
  // 用于接收处理设备中断返回值的变量
  // 对应的细节我们到设备中断与驱动章节再仔细研究
  int which_dev = 0;
  
  // 读取sstatus寄存器,判断是否来自user mode
  // 如果不是,则陷入panic
  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");

这是整个usertrap函数的开头,除了which_dev是一个临时变量以外,这里还加了一个异常判断,那就是读取sstatus寄存器的值。我们看一下RISC-V中对sstatus寄存器的定义以及一段说明:

(The SPP bit indicates the privilege level at which a hart was executing before entering supervisor
mode. When a trap is taken, SPP is set to 0 if the trap originated from user mode, or 1 otherwise.
译:SPP位提示了CPU在进入supervisor模式之前的运行模式,当执行一个陷阱时,SPP为0表示陷阱源于用户模式,1表示其他模式)。

在这里插入图片描述
而我们查看一下SSTATUS_SPP的定义,如下所示,和RISC-V的约定是一致的,这段代码就是用来判定陷阱来源是否是来自用户模式的:

// Previous mode, 1=Supervisor, 0=User
// 上一个模式是什么,1表示supervisor模式,0表示用户模式
#define SSTATUS_SPP (1L << 8)  

我们接着往下读代码:


  // send interrupts and exceptions to kerneltrap(),
  // since we're now in the kernel.
  // 译:将当前模式下的中断和异常处理全部发送到kernelvec函数
  // 因为我们当前处于内核态下
  // 关于内核中断的处理,在后面的博客中会仔细分析
  // 将stvec寄存器设置为kernelvec,当发生内核陷阱时会跳转到kernelvec而非之前的uservec
  w_stvec((uint64)kernelvec);
  
  // 获取当前进程
  struct proc *p = myproc();
  
  // 将sepc再次保存一份到trapframe中去
  // xv6 book中说明这里之所以再次保存一份sepc
  // 是因为这里可能触发的是定时器中断,它会调用yield函数返回用户态
  // 从而导致原有的sepc被修改
  // 这涉及到CPU的调度和定时器中断,在后面的博客再进一步研究
  p->trapframe->epc = r_sepc();
  
  // 读取scause寄存器
  // 它是在我们调用ecall指令时由指令自动设置的
  // RISC-V标准定义,当scause的值为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.
    // 译:sepc当前指向的是ecall指令本身
    // 但是我们期望的是,执行完系统调用之后,接着执行它的下一条指令
    // 因此给trapframe中的epc+4
    p->trapframe->epc += 4;

    // an interrupt will change sstatus &c registers,
    // so don't enable until done with those registers.
    // 译:中断会改变sstatus和其他寄存器
    // 所以必须在使用完这些寄存器之后再使能中断
    // 之前说过ecall指令会关闭中断,这是通过清除sstatus中的SIE位实现的
    // intr_on的实现则是通过将SIE置位实现的
    intr_on();
    
    // 调用syscall函数,进行系统调用的下一步处理
    syscall();
  
  // 如果处理的是设备中断(interrupt),则调用devintr来处理
  } else if((which_dev = devintr()) != 0){
    
    
    // ok
  
  // 否则是异常(exception),杀死当前进程并报错
  } else {
    
    
    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的使用权
  // 这里涉及CPU的调度,我们后面再仔细研究
  if(which_dev == 2)
    yield();
  
  // 调用usertrapret完成陷阱处理返回
  usertrapret();
}

我们看到usertrap这个函数其实非常简单,它真的就是一个中转站,将来自用户态的陷阱分门别类地处理,因此我们甚至可以将它的代码骨架抽象如下

// usertrap的逻辑骨架
// 1. 处理系统调用
if(r_scause() == 8){
    
    
	syscall();
}

// 2.处理设备中断
else if(interrupt){
    
    
	devintr();
}

// 3.处理程序异常
else{
    
    
    // 直接杀掉
    kill process;
}

决定上述走向的就是scause中的异常码,这是RISC-V在规范中规定好的,8对应的就是源自用户态的系统调用,如下表所示:
在这里插入图片描述
好了,让我们总结一下,usertrap函数通过读取scause确定了陷阱的类型,当确定是系统调用时,将把处理权交给syscall执行下一步动作,现在故事到了syscall函数中。

1.4 Phase 4——syscall——调用内核功能完成调用

事情逐渐变得非常明朗了,我们在实验2中完成了系统调用的添加,那时候就已经和syscall函数打过很多交道,不过那时的我们对系统调用的全过程从未如此明朗,笑。syscall的代码实现如下:

void
syscall(void)
{
    
    
  int num;
  
  // 获取当前进程
  struct proc *p = myproc();
  
  // 早在Phase 1,我们就将对应的系统调用号通过汇编语言加载到了对应的寄存器中
  // 现在它终于重见天日了...
  num = p->trapframe->a7;

  // 判断系统调用号的合法性
  // 如果合法,则调用对应的内核功能函数,返回值放入trapframe中的a0
  // 这个值将会在userret汇编代码中恢复给用户态的a0
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    
    
    p->trapframe->a0 = syscalls[num]();
  
  // 否则打印错误原因,返回值置为-1
  } else {
    
    
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

我们仍以前面的系统调用write为例,在Phase 1用户态函数中,我们将SYS_write放入了a7寄存器。然后在syscall函数中读出a7寄存器的内容并使用它去索引系统调用表格,会发现它对应到一个sys_write的函数,这就是write系统调用对应的内核功能函数,定义在(kernel/sysfile.c:82)。

当执行完这个系统调用完成相应的功能后,返回值将被放入trapframe的a0寄存器,这样返回以后用户态函数就可以按照calling conventions的约定从trapframe中读取对应的返回值了。我们曾在实验2中编写了几个内核功能函数,比如sys_trace、sys_sysinfo等,它们的本质就是内核功能函数,而在usys.pl中加入的一个个entry,本质上就系统调用对应的用户态接口,它们只负责将对应的系统调用号载入到a7寄存器

1.5 Phase 5——usertrapret——设置stvec、sepc,内核信息放入trapframe

好了,该做的事情都做完了,现在可以准备返回了。当syscall(phase 4)函数执行完成之后,会回到Phase 3的usertrap函数中,并最终进入到usertrapret函数。从名字可以看到,这个函数负责从用户态陷阱返回,下面来仔细研究一下这个函数:

//
// 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.
  // 译:我们将要把陷阱的目的地从kerneltrap改为usertrap
  // 所以直到回到用户模式且usertrap被正确设置之前,所有中断都被关闭
  intr_off();

  // send syscalls, interrupts, and exceptions to trampoline.S
  // 设置stvec寄存器,将stvec设置为uservec
  // 这是我们之前问题的答案,stvec寄存器就是在这里被设置指向uservec的
  // 你可能会说,这是一个鸡生蛋和蛋生鸡的问题,只有进程触发陷阱才会被设置stvec
  // 事实上,在fork一个新的进程时,因为fork是一个系统调用,那么它在返回到用户态时一定会经过这里
  // 从而在一开始成功设置了stvec寄存器
  w_stvec(TRAMPOLINE + (uservec - trampoline));

  // set up trapframe values that uservec will need when
  // the process next re-enters the kernel.
  // 译:设置trapframe中的值,这些值在下次uservec再次进入内核时会使用到
  // 分别设置:内核页表、内核栈指针、usertrap地址、CPU的ID号
  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()

  // set up the registers that trampoline.S's sret will use
  // to get to user space.
  // set S Previous Privilege mode to User.
  // 译:设置好trampoline.S中的sret指令会用到的寄存器,以返回用户态
  // 将前一个模式设置为用户态
  // 这里在为返回用户态做准备
  unsigned long x = r_sstatus();
  
  // 将SPP位清空,确保陷阱返回到用户模式
  // 设置SPIE为1,在返回时SPIE会被设置给SIE,确保supervisor模式下的中断被打开
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  w_sstatus(x);

sstatus寄存器中保存着很多记录CPU信息的位,这些位里有一些是描述当前状态的,而有一些是记录上一个状态的。以这段代码中的SPP和SPIE来说,RISC-V规范中对SPP的描述是这样的:

  • SPP:The SPP bit indicates the privilege level at which a hart was executing before entering supervisor mode. When a trap is taken, SPP is set to 0 if the trap originated from user mode, or 1 otherwise. When an SRET instruction (see Section 3.3.2) is executed to return from the trap handler, the privilege level is set to user mode if the SPP bit is 0, or supervisor mode if the SPP bit is 1; SPP is then set to 0.(SPP位记录了一个CPU进入supervisor模式之前的特权等级,当执行一个陷阱时,如果是从用户态而来,SPP将会被设置为0,而在其它情况下会被设置为1。当SRET指令执行以准备从陷阱中返回时,如果SPP位为0,特权等级将降至用户模式,如果为1则降至supervisor模式;SPP在此之后会被置为0)。

  • SPIE:The SPIE bit indicates whether supervisor interrupts were enabled prior to trapping into supervisor mode. When a trap is taken into supervisor mode, SPIE is set to SIE, and SIE is set to 0. When an SRET instruction is executed, SIE is set to SPIE, then SPIE is set to 1.(SPIE位表示在陷入supervisor模式之前,supervisor模式下的中断是否开启。当一个陷阱陷入supervisor模式之后,SPIE会被设置成SIE,SIE则会被设置成0(也就是关中断),当SRET指令执行时,SIE会被设置成SPIE,SPIE会被设置成1)。

那么,接着读剩下的代码:

  // set S Exception Program Counter to the saved user pc.
  // 将trapframe中保存的pc值写入sepc
  // 注意对于系统调用来说,这里其实指向了陷阱指令的下一条指令
  // 因为在usertrap中我们给trapframe中的epc加4了
  w_sepc(p->trapframe->epc);

  // tell trampoline.S the user page table to switch to.
  // 准备切换回用户页表,准备好要写入的SATP值
  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.
  // 译:跳转到位于内存顶端的trampoline.S,这将会切换到用户页表
  // 恢复用户寄存器,并使用sret指令回到用户模式
  // 首先得到userret代码的虚拟地址,然后将虚拟地址直接作为函数指针调用之
  // 传入了两个参数TRAPFRAME和satp,按照calling convention,这两个参数将会被放置到a0和a1
  uint64 fn = TRAMPOLINE + (userret - trampoline);
  ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

好,最后简单总结一下usertrapret做的事情:

  • 关中断(直到执行SRET之前,始终不响应中断)
  • 设置stvec到uservec
  • 设置trapframe中与内核有关的信息,为下一次陷阱处理做准备
  • 正确地设置sepc,跳转回正确的地址继续执行
  • 调用userret函数,准备恢复现场、切换页表、设置sscratch

1.6 Phase 6——userret——恢复现场、切换到用户页表、设置sscratch

在usertrapret的最后,它通过函数指针的方式调用了userret函数,这段函数定义在trampoline.S的最后作为系统调用整个流程的收尾。下面我们来仔细阅读一下userret的代码:

userret:
        # userret(TRAPFRAME, pagetable)
        # switch from kernel to user.
        # usertrapret() calls here.
        # a0: TRAPFRAME, in user page table.
        # a1: user page table, for satp.
        # 译:调用形式userret(TRAMPFRAME, pagetable)
        # 负责从内核态到用户态切换
        # usertrapret函数调用这里
		# a0:TRAPFRAME的值,这对应用户页表中trapframe的位置
		# a1:用户页表对应的satp寄存器的值
		
        # switch to the user page table.
        # 将页表切换为用户页表
        # <至此,整个地址空间完全切换到了用户地址空间>
        # 由于trampoline.S在用户和内核地址空间中映射的地址一样
        # 所以代码的执行得以无缝衔接,这和uservec的逻辑是一样的
        csrw satp, a1
        sfence.vma zero, zero

        # put the saved user a0 in sscratch, so we
        # can swap it with our a0 (TRAPFRAME) in the last step.
        # 译:将被保存的用户的a0放置到sscratch中,所以在最后一步我们可以将它
        # 与我们的a0进行互换,这样就可以成功实现a0的恢复和sscratch指向TRAPFRAME
        # <这是另一个问题的答案,sscratch在此被成功设置>
        ld t0, 112(a0)
        csrw sscratch, t0

        # restore all but a0 from TRAPFRAME
        # 译:从TRAPFRAME中恢复除了a0以外的所有寄存器
        ld ra, 40(a0)
        ld sp, 48(a0)
        ld gp, 56(a0)
        ld tp, 64(a0)
        ld t0, 72(a0)
        ld t1, 80(a0)
        ld t2, 88(a0)
        ld s0, 96(a0)
        ld s1, 104(a0)
        ld a1, 120(a0)
        ld a2, 128(a0)
        ld a3, 136(a0)
        ld a4, 144(a0)
        ld a5, 152(a0)
        ld a6, 160(a0)
        ld a7, 168(a0)
        ld s2, 176(a0)
        ld s3, 184(a0)
        ld s4, 192(a0)
        ld s5, 200(a0)
        ld s6, 208(a0)
        ld s7, 216(a0)
        ld s8, 224(a0)
        ld s9, 232(a0)
        ld s10, 240(a0)
        ld s11, 248(a0)
        ld t3, 256(a0)
        ld t4, 264(a0)
        ld t5, 272(a0)
        ld t6, 280(a0)

		# restore user a0, and save TRAPFRAME in sscratch
		# 译:交换a0和TRAPFRAME的值,这样既恢复了a0,又将TRAPFRAME放在了sscratch中
        csrrw a0, sscratch, a0
        
        # return to user mode and user pc.
        # usertrapret() set up sstatus and sepc.
        # 译:返回用户模式和,恢复对应的PC值
        # usertrapret()已经提前设置好了sstatus和sepc的值
        # 故sret可以正常执行
        sret

其实这里的逻辑几乎和uservec是一致的,只是流程上恰好反过来。值得注意的是位于最后的sret指令,它几乎和ecall指令是反过来的。 我们前面将ecall指令比作进入内核的钥匙,这里的sret就可以看作是一把锁了,哈哈。具体来说,sret指令完成了以下一些事情(由硬件完成的):

  • 将sepc中保存的值放入PC,sepc的值在usertrapret中已经被设置好了,对于系统调用来说,它返回的是系统调用的下一条指令
  • 将权限模式设置为sstatus中的SPP位,这在usertrapret中也已经设置好了,0表示返回用户模式
  • sstatus中SIE位的值设置为SPIE,在usertrapret中SPIE被设置为1表示开启中断
  • 最后将sstatus中的SPP置为0,SPIE置为1

经过这一步,我们已经完成了整个系统调用的流程,正式地返回了用户态,并且当前PC指向了ecall指令的下一条指令。

1.7 Phase 7——重返用户态——ret尾声

经过userret的设置代码执行,现在终于回到了用户态,并且PC指向了ecall指令的下一条指令,让我们再回到sh.asm中看看它到底执行到了哪里

0000000000000dea <write>:
.global write
write:
 li a7, SYS_write
     dea:	48c1                li	a7,16
 ecall
     dec:	00000073          	ecall
     
 # ecall的下一条指令就是ret咯!
 # ret会被扩展为jalr x0, 0(x1)
 # x0就是Zero寄存器,值常为0,x1是ra寄存器,它的值是24,这是在getcmd函数中设定的,详见Phase1
 # 功能是:t = PC + 4 = df4, PC = ra & (~1) = 24 & (-1) = 24, Zero = t
 # 因为Zero是硬连线的0寄存器,所以最后的赋值无效
 # 控制流成功返回到getcmd,系统调用全部结束
 ret
     df0:	8082                ret

在执行了ret指令了,控制流再次返回了getcmd函数,这是write系统调用的开始,也是它的归属!

1.8 系统调用过程总结

在动笔写下这篇博客时,我只是想把Xv6的系统调用过程一点点弄清楚梳理下来,在这个过程中我查阅了许多RISC-V规范和相关书籍,并一直参阅Xv6 Book。结果没想到弄清一个小小的write系统调用,我竟然写了1.2万字,才将这过程中每一行代码研究清楚

即便如此,这个过程中还是有一些残留的问题,比如开关中断的时机(intr_on,intr_off),以及为什么要在usertrap中再保留一份epc的值,这些要完全搞明白需要后面中断、调度的相关知识。

不过无论如何,我们总算将Xv6的系统调用彻底弄清楚了,不是吗?

猜你喜欢

转载自blog.csdn.net/zzy980511/article/details/130255251