Loading

xv6系统调用流程——MIT6.S081操作系统

这篇文章通过gdb跟踪基于risc-v架构的xv6系统中write系统调用的处理流程。

系统调用是操作系统给应用程序提供的操作底层硬件资源的简单清晰的接口,隐藏底层资源的复杂性,比如UNIX会把网络、磁盘等一系列东西都抽象成文件,然后你可以简单的使用write对它们进行读写,你无需关心磁道、扇区等概念。

同时,由于系统调用会与底层资源通信,所以一定要在内核态执行,在xv6中称为supervisor mode,这里一定涉及到用户内核态的转换。

在转换过程中需要保存用户程序执行的现场,安全的陷入内核,执行实际系统调用,并恢复程序执行现场。

C中的包装方法

C库中的write调用,也就是我们程序中进行系统调用的函数:

int write(int, const void*, int);

它实际上是实际系统调用的一个包装,它(以及所有其它的系统调用)定义在user/user.h中,只有一个函数描述,实际的函数体是由三条汇编指令完成的。

通过查看编译后的程序的asm,我们可以看到write函数实际的指令。

00000000000002d2 <write>:
.global write
write:
 li a7, SYS_write
 2d2:	48c1                	li	a7,16
 ecall          # to kernel
 2d4:	00000073          	ecall
 ret
 2d8:	8082          
  1. SYS_write常量保存到a7寄存器中
  2. 执行ecall进入内核
  3. ret返回

SYS_write是实际的write系统调用的系统调用号,实际的值为16,定义在kernel/syscall.h中,所有的系统调用号都定义在这。

img

ecall

ecall是进行实际系统调用的入口。它是由risc-v提供的一个用于实现系统调用的指令,通常由低特权的代码发起,用来执行高特权代码,比如UserMode到SupervisorMode、SupervisorMode到MachineMode。

若你在UMode,ecall指令会做三件事:

  1. pc保存到sepc(S态异常程序计数器)
  2. 将权限提升至SMode
  3. 跳转到STVEC(S态陷阱向量基地址寄存器)

通过GDB查看write中这几条汇编指令,第一个就是熟悉的将16(SYS_write)存到a7中:

img

然后,我们执行stepi执行ecall,注意在执行前,pc指向的位置是0x2d2,这是一个用户地址空间中很小的地址,指向的就是用户空间的write包装函数中的li指令地址。

当我们stepi执行ecall后,按照risc-v中的约定,现在sepc中保存了原来的pc,也就是0x2d4(用户代码中ecall的地址),pc应该跳转到stvec寄存器指向的位置。

img

trampoline初识

实际上,stvec寄存器指向了0x3ffffff000这个位置,无论是用户虚拟地址空间还是内核虚拟地址空间,其最顶部,也就是0x3ffffff000的位置都被映射成了相同的东西,也就是下图中的这个trampoline

img

trampoline直译过来是蹦床的意思,你可以理解为它是一个U态到S态的蹦床。不过注意,执行ecall后我们已经处于S态,但我们还不能贸然的执行内核代码,因为我们还要保存原来执行系统调用的用户进程的数据。

trampoline中的实际代码在kernel/trampoline.S中可以看到,实际上,也就是上面那张截图中的汇编代码。

trampoline是一个神奇的东西,由于我们需要从用户态转换到内核态,执行内核代码,所以我们肯定要切换用户页表到内核页表,而无论用户页表还是内核页表中都有trampoline这个东西,并映射到了相同的位置,所以,在trampoline代码中切换页表是安全的,切换页表后程序不会崩溃,对于trampoline中的下一条指令,在切换后的页表中仍然存在相同的虚拟地址。

trampoline流程

到了trampoline中,实际上我们已经在SMode了,只不过当前的用户寄存器保存的还是用户进程的数据,页表、sp指针等都是用户进程的。

sscratch寄存器和进程的trapframe

在进入用户空间之前(无论是由于进程启动还是从中断中恢复),内核会先设置sscratch寄存器指向trapframe,它是每个进程都有的一个用于存储所有用户寄存器的结构体,并且,它也被映射到用户页表的TRAPFRAME部分,位于TRAMPOLINE的下面,所以也可以通过用户页表访问。

从源码上看,trapframe实际上是在陷入内核时用于保存进程的寄存器啥的乱七八糟的东西的一个结构体,它在kernel/proc.h中被定义,trampoline代码实际上会将用户寄存器保存到该结构体中。

img

保存用户寄存器

# 将a0和sscratch交换,所以现在a0就是trapframe的位置
# 而a0的原值被保存在了sscratch中
# 之所以要这么做,貌似是因为后面的sd指令的操作数必须是用户寄存器
# 所以我们先要提取出一个用户寄存器
csrrw a0, sscratch, a0

# 向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)

现在,所有用户寄存器都被正确保存到trapframe中了,除了a0,现在需要处理它。

# 将a0的原值(保存在sscratch)和t0交换
csrr t0, sscratch
# 将t0保存到trapframe中a0所在的位置(112(a0))
sd t0, 112(a0)

需要注意的是,一旦一个用户寄存器被保存到用户进程的trapframe中,内核代码就能随意操作它,不用担心用户数据丢失,就像上面使用t0来做中间寄存器一样。

切换到内核执行环境

用户寄存器已经保存好了,在执行内核代码之前,还需要加载内核的执行环境,比如将sp寄存器换成内核栈,将satp寄存器切换成内核页表。

# 从p->trapframe->kernel_sp处载入内核栈指针到sp寄存器
ld sp, 8(a0)

# 让tp持有当前hartid,貌似是当前进程执行的CPU核心id
# hartid从p->trapframe->kernel_hartid处载入
ld tp, 32(a0)

# 从p->trapframe->kernel_trap处载入`usertrap`函数的地址到t0寄存器
ld t0, 16(a0)

# 从p->trapframe->kernel_satp恢复内核页表
ld t1, 0(a0)
csrw satp, t1

# 执行内存屏障,确保内存访问的正确性和乱序等问题
# 清空TLB缓存以保证切换页表后地址转换不会出错
sfence.vma zero, zero

可以看到这里,从进程的trapframe中读出了内核栈指针、hartid、陷阱处理程序usertrap的地址以及内核页表,并设置到对应的寄存器上。

进程中的内核数据从哪里来?

无论是进程最初启动,还是从中断、陷阱中恢复,都会先执行对这些内核数据的设置。

一旦我们执行了csrw satp, t1这一行代码,用户页表就被切换到内核页表,此时,任何位于用户页表中的内容都无法访问了。

内存屏障的执行也很有必要,当我们还在用户页表下工作时,TLB中有很多基于用户页表的虚拟地址到物理地址的映射,一旦页表切换,这些映射就是错的了,所以,我们需要执行内存屏障将它们清空掉。

执行usertrap——陷阱处理程序

上面的ld t0, 16(a0)中将内核中的陷阱处理程序地址写到t0寄存器了,所以trampoline中的下一行代码就是jr t0,跳转到陷阱处理程序。注意下图执行si后ASM窗口和REG窗口中pc寄存器的变化,它从trampoline跳转到了陷阱处理程序——usertrap

img

如果你的GDB加载的文件不是kernel/kernel,你没法跟踪它的源码,可以使用file kernel/kernel加载内核文件然后再用layout src跟踪源码。

img

img

现在,我们进入到kernel/trap.cusertrap函数中,这就是陷阱处理程序。

陷阱处理程序要处理的东西比我们想的复杂,除了系统调用外,它还可能是因为程序运行中出现错误等原因必须陷入内核。此外,它还可能本身就是从内核空间进入的。这里,我们不考虑我们不需要考虑的代码,只考虑从用户空间通过系统调用进入的情况。

// 因为我们现在在内核中,使用的也是内核的页表
// 所以我们要重写stvec为内核中的陷阱向量位置
// 以处理后续(在内核中时)发生的陷入
w_stvec((uint64)kernelvec);

// 获取当前进程,实际上是通过读取tp寄存器中
// 保存的hartid获取到当前CPU,再通过当前CPU
// 获取其上的进程,也就是当前陷入的进程
struct proc *p = myproc();

// 在跳转到trampoline之前,用户pc被保存到了
// sepc寄存器中,现在,将它保存到进程的trapframe中
p->trapframe->epc = r_sepc();

解释一下上面最后一行代码,由于当前进程可能会由于时间片不足被切换到其它进程,所以这里我们不能保证sepc是否会被其它进程冲掉(比如它再进行一次系统调用),所以这里还需要将它保存到trapframe中。这行代码不写在trampoline中,而是以C语言的形式写出来,可能是因为从各种其它方面进入的代码也需要修改这个寄存器,所以从这里统一修改吧,也有可能是因为sd的操作数必须是用户寄存器。

继续往下

// 如果进入usertrap的原因是由于系统调用(由8标识)
if(r_scause() == 8){
    // 如果当前进程已经被杀掉了,不执行
    if(p->killed)
        exit(-1);

    // risc-v中每条指令是4字节,相当于跳过ecall指令,指向ecall的下一条
    p->trapframe->epc += 4;

    // 打开中断,risc-v的trap硬件总会关闭中断,在程序中打开
    // 让xv6可以在处理系统调用的过程中响应中断
    intr_on();
    // 调用syscall
    syscall();
} else if {...}

syscall

syscall执行实际的系统调用函数,通过之前在C中的包装函数里,我们将系统调用号SYS_write保存在了用户寄存器a7中,在trampoline代码中,我们将它保存在了进程的trapframe中,现在,我们在syscall里,通过进程的trapframe->a7读取到这个系统调用号,执行对应的内核中的系统调用程序。

void
syscall(void)
{
  int num;
  struct proc *p = myproc();

  num = p->trapframe->a7;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    p->trapframe->a0 = syscalls[num]();
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

每一个系统调用有一个返回值,这个返回值保存在trapframe->a0中,如果系统调用号未知,就保存-1

回到usertrap

int 
usertrap(void) {
    // ...省略一些代码...
        syscall();
    } else if {...}
    // 从系统调用中返回,再次检查进程是否被杀掉
    if(p->killed)
        exit(-1);

    // give up the CPU if this is a timer interrupt.
    if(which_dev == 2)
        yield();

    // 执行usertrapret,从陷阱中返回
    usertrapret();
}

下面是usertrap的完整代码:

void
usertrap(void)
{
  int which_dev = 0;

  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.
  w_stvec((uint64)kernelvec);

  struct proc *p = myproc();
  
  // save user program counter.
  p->trapframe->epc = r_sepc();
  
  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.
    p->trapframe->epc += 4;

    // an interrupt will change sstatus &c registers,
    // so don't enable until done with those registers.
    intr_on();

    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok
  } 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.
  if(which_dev == 2)
    yield();

  usertrapret();
}

从陷阱中返回——usertrapret

现在,系统调用已经执行完毕,是时候做必要的恢复并返回到用户空间。usertrapret函数就是完成这个工作的。

void
usertrapret(void)
{
  struct proc *p = myproc();

  // 关闭中断,直到回到user space
  intr_off();
  // 恢复stvec,让系统调用、中断以及异常都能正常走到trampoline
  w_stvec(TRAMPOLINE + (uservec - trampoline));

  // 设置trapframe中当进程再次进入内核时所需要的数据
  // 这也就是之前在trampoline中加载的,那些进程中的内核相关数据的来源
  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();         // 

  // 设置trampoline中的sret指令为返回user space要用到的寄存器sstatus
  // 该寄存器中的控制位控制了sret指令的行为
  unsigned long x = r_sstatus();
  x &= ~SSTATUS_SPP; // 该控制位返回到usermode
  x |= SSTATUS_SPIE; // 该控制位在进入用户模式时打开中断
  w_sstatus(x); // 写入该控制寄存器

  // 将sepc设置成epc,在系统调用的代码路径中,就是ecall的下一条指令
  w_sepc(p->trapframe->epc);

  // satp变量即用户进程页表
  uint64 satp = MAKE_SATP(p->pagetable);

  // 跳到内存顶部的trampoline.S,它会切换到用户页表,恢复用户寄存器
  // 通过sret切换回usermode
  uint64 fn = TRAMPOLINE + (userret - trampoline);
  ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

比较值得一提的,这个fn是一个函数指针,它指向了内存顶部的trampoline代码中的userret,调用这个函数指针,并将TRAPFRAMEsatp作为参数传递,它们会被存到a0a1上。现在,我们可以进入trampoline的userret

回到trampoline——userret

首先就将a1satp做了一个交换,并执行了一个内存屏障。相当于将页表切换回用户页表了。 ld t0, 112(a0)
csrw sscratch, t0

csrw satp, a1
sfence.vma zero, zero

同样,由于trampoline在用户页表和进程页表间被映射到了相同的虚拟地址上,这个切换不会发生问题。

现在,a0是函数指针那里传入的trapframe,从trapframe中找到原始进程中的a0(112(a0)),与sscratch进行交换。这一步是为了userret中最后一步的交换做准备,先不用管,只需要知道现在sscratch中保存了用户的a0寄存器值,而目前的a0保存的确实是trapframe的值,这是函数指针调用处传过来的。

ld t0, 112(a0)
csrw sscratch, t0

下面,将所有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)
# 保存用户a0,以及将trapframe保存在sscratch中
csrrw a0, sscratch, a0

# 返回用户模式,设置用户的pc寄存器
# 使用usertrapret中设置的sstatus和sepc
sret

总结

  1. 在用户模式,调用C函数库中的系统调用的封装,如write
  2. 该封装中会将具体的系统调用号加载到a7中,然后调用ecall指令
  3. ecall指令会做三件事
    1. 切换UMode到SMode
    2. 将用户pc保存到sepc
    3. 跳转到stvec指定的位置,也就是trampoline
  4. 在trampoline中
    1. 将用户寄存器保存到进程的trapframe中
    2. 从trapframe中读取内核栈、内核页表、中断处理程序usertrap的位置,当前CPU核心id
    3. 加载内核栈到sp、切换satp位内核页表,跳转到陷阱处理程序
  5. 陷阱处理程序将trapframe中的epc写成ecall的下一条指令,调用syscall执行系统调用
  6. syscall调用具体的在内核中的系统调用代码,然后将返回值写到a0
  7. usertrap执行usertrapret,为返回用户空间做准备
    1. 比如设置进程trapframe中的内核相关的信息,内核栈、内核页表、中断处理程序位置等
    2. 设置sret指令的控制寄存器,以在sret执行时顺利恢复到用户模式
    3. 设置sepc为trapframe的epc
    4. 使用函数指针,调用trampoline代码中的userret,并将TRAPFREAM作为a0、用户页表位置作为a1
  8. trampoline中的userret做的就很简单了,切换回用户页表,从trapframe恢复用户寄存器
  9. 执行sret返回到UMode

大体上是这样的流程

img

参考

感谢ChatGPT和newbing的大力支持

posted @ 2023-03-01 15:02  yudoge  阅读(1329)  评论(2编辑  收藏  举报