不落辰

知不可乎骤得,托遗响于悲风

0%

操作系统xv6-系统调用

本文介绍xv6的trap流程

  • user空间和kernel空间的切换称为trap

    • xv6 系统调用 是如何在user和kernel空间进行切换的 ?
  • 关键步骤及指令如下

    • user ecall
      • 提高程序的权限 , 保存当前地址($sepc) , 记录trap原因(r_scause()) , 跳转到trampoline
    • trampoline uservec
      • 将cpu reg中user的上下文保存进trapframe,如user stack pointer , return addr 等; 并将 kernel thread的上下文聪trapframe加载进cpu reg. 如kernel stack pointer , func to jump , kernel pgtbl($satp). 然后跳转到usertrap()
    • usertrap
      • 根据陷入kernel的原因($scause),执行动作如syscall().
    • usertrapret
      • 即将离开kernel , 将kernel上下文保存进trapframe , 以便下次陷入内核时trampoline将其加载进来. 跳到userret
    • trampoline userret
      • 切换到 user pagetable , 将user上下文从trapframe加载进来. 跳转回user($sepc)
    • user ret : 跳到user程序的下一指令
  • 之前有人问我个问题hhh,当复习了。

    • 从用户到内核需要的寄存器哪些会发生改变啊?
    • 答:
    • 比说ecall吧. 他得改变代表权限的reg,从user mode改成supverisor mode ; 还得改变保存$PC到$sepc reg.;还得设置$scause reg代表trap的原因;进入trampoline的uservec之后,得把之前user态时候cpu上的reg保存进内存,然后将该process的kernel thread的上下文加载到cpu的reg上. 比如说kernel stack pointer加载到$sp ; 还得改变$satp,切换成kernel pagetable来切换地址空间,

xv6 系统调用

TRAP机制

  • 用户空间和内核空间的切换通常被称为trap,而trap涉及了许多小心的设计和重要的细节,这些细节对于实现安全隔离和性能来说非常重要。
    • 因为很多应用程序,要么因为系统调用,要么因为page fault,都会频繁的切换到内核中。所以,trap机制要尽可能的简单,这一点非常重要。
  • 一些重要寄存器
    • PC (Program Counter Register) :
      • 程序计数器
    • MODE :
      • 当前是supervisor mode还是user mode
      • 当我们在运行Shell的时候,自然是在user mode
      • vm.c中的函数全部是内核的一部分,运行在supervisor mode下
    • SATP (Supervisor Address Translation and Protection)
      • page table的物理内存地址
    • STVEC (Supervisor Trap Vector Base Address Register)
      • 指向了内核中处理trap指令的起始地址。
    • SEPC (Supervisor Exception Program Counter)
      • trap过程中保存PC值
      • 是为了之后从kernel return 到 usrspace时 可以知道要回到哪条指令。
    • SSRATCH (Supervisor Scratch Register)
      • sscratch points to where the process’s p->trapframe is mapped into user space, at TRAPFRAME
    • SCAUSE
      • 通过trap机制进入到supervisor mode的原因

系统调用

流程

user态

  • user态
    • 我们跟踪sh.c中的write系统调用

ecall

  • 指令如下
  • 并不是ecall之后就完全进入了内核的C代码,还有很远的距离。
  • ecall作用,简要来说就是 提高程序的权限,跳转到特定的地址.
  • 那么ecall 都做了什么? ecall只做了四件事。
    • 1. ecall将代码从user mode改到supervisor mode
      • 这也就是所谓的用户态运行非特权指令,内核态运行特权和非特权指令。
      • 话说用户态不能访问内核态得地址空间在xv6中究其原因是user process和kernel 用的根本不是一个页表。那么在linux中也是这个原因吧。
    • 2. ecall将当前PC的值(即user态ecall代码所在地址)保存在了$sepc reg。以便将来返回。
    • 3. 设置 $scause reg 去反映 trap 的原因 (ecall, 8)
    • 4. ecall会使得PC跳转到 $ svec reg指向的指令。
      • 在从trap是从user进入kernel的这一步骤,也即,PC指向tramopline的uservec段
    • 以上三个操作都是硬件负责实现的。
  • syscall语义 : 进入kernel
  • ecall 语义 :类似syscall,进入kernel
  • ecall : 即将进入trapmpoline。不是ecall之后就完全陷入了内核!
    • 注意此时还是user page table

trapframe

  • 先说一下trapframe(0x3fffffe000)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    struct trapframe {
    /* 0 */ uint64 kernel_satp; // kernel page table
    /* 8 */ uint64 kernel_sp; // top of process's kernel stack
    /* 16 */ uint64 kernel_trap; // kernel code usertrap()
    /* 24 */ uint64 epc; // saved user program counter to return to
    /* 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;
    // ...
    }
  • 用于保存进程寄存器现场的内存
  • 每个userprocess都有一段trapframe
  • trapframe保存的重要内容:
    • sepc reg 被保存在p->trapframe->epc中
      • p->trapframe->epc = r_sepc();
      • sepc : process 在user态时的程序计数器
        • process如何实现从kernel中返回待user?(如何进行向用户代码的跳转)
          • 在trampoline userret,最后一句sret指令 : 会将pc设置成$sepc reg。
          • 而在userret之前的usertrapret:w_sepc(p->trapframe->epc);。会将之前(比如user刚进入usertrap时的sepc reg),保存在p->trapframe->epc中。
    • 内核的 : 还有kernel pagetable , kernel stack pointer , usertrap()地址等
    • 还有其他user寄存器等

trampoline uservec

trampoline.S (汇编代码)

  • 对 ecall 瞬间的状态做快照,保存到trapframe处。
  • 填充 struct trapframe (proc.h)
  • 利用 $sscratch (S-mode scratch ) 保存所有寄存器
  • 切换到内核栈 (相当于切换到进程对应的 “内核线程”, L2)
  • 切换到内核地址空间
    • 修改 $satp (S-mode address translation and protection)
    • sfence.vma
  • 跳转到 tf->kernel_trap
    • 痛苦时间解除,进入 C 代码
  • 再简要说一下trampoline

    • 功能:对 ecall 瞬间的状态做快照,保存到trapframe处。
  • 问题1:为什么切换到页表之后不会崩溃?明明映射都不同了?

    • csrw satp, t1 # 切换页表
    • 原因见下
    • user process 和 kernel process 唯一相同的映射就是trampoline page
      • 即在user virtual address 和 kernel virtual process 的trampoline page 都映射到了同一块DRAM
    • 也正是因此,保证了我们在从user切换到kernel pagetable时不会异常终止
      • 因为切换的过程中就是在执行trampoline的uservec代码。且切换完成后,寻址的结果不会改变,我们可以在同一代码序列执行程序而不崩溃。

  • call之后我们进入了uservec

  • ecall并不会切换page table,这是ecall指令的一个非常重要的特点。所以这意味着,trap处理代码必须存在于每一个user page table中

  • 因此每个user的process的虚拟地址里的顶部都是trampoline

  • 刚进入trampoline的uservec

    • 使用的还是user的page table。即所看到的、所使用的地址仍然是user pagetable维护的虚拟地址
    • 我们找到trampoline的内容也是通过user的page table维护的虚拟地址0x3ffffff000找到的。
  • trampoline都做了什么:
    • 将当前cpu上运行的user process的上下文从cpu reg上存入user process的trapframe中。,即 保护用户寄存器。
      • save user’s context from cpu reg into dram(trapframe)
      • 如何实现保护用户寄存器?(利用 $sscratch (S-mode scratch ) 保存所有寄存器)
          1. XV6在每个user page table映射了trapframe page,这样每个进程都有自己的trapframe page(这个位置的虚拟地址总是0x3ffffffe000)
          1. 如何找到user process的trapframe ? : sscratch 指向的地址是trapframe的虚拟地址.
          • trapframe的地址是怎么出现在SSCRATCH寄存器中的?
            • 在内核前一次切换回用户空间时,内核会执行set sscratch指令,将这个寄存器的内容设置为0x3fffffe000,也就是trapframe page的虚拟地址。所以,当我们在运行用户代码,比如运行Shell时,SSCRATCH保存的就是指向trapframe的地址。之后,Shell执行了ecall指令,跳转到了trampoline page,这个page中的第一条指令会交换a0和SSCRATCH寄存器的内容。所以,SSCRATCH中的值,也就是指向trapframe的指针现在存储与a0寄存器中。
    • 将当前cpu上运行的user process之前保存在trapframe中的一些kernel context需要用到的上下文加载进cpu的reg
      • ld user process context from dram(trapframe) to cpu reg
      • 内核栈指针:cpu sp reg = kernel_sp
        • 切换到内核栈 (相当于切换到进程对应的 “内核线程”, L2)
        • 我认为作用是之后 对于改userprocess trap陷入内核之后 的 在内核的函数调用所开辟的栈帧,都是建立在这个kernel_sp的栈帧中的。
      • 当前cpu :p->trapframe->kernel_hartid
      • 内核处理trap的函数:t0 = p->trapframe->kernel_trap
      • 当前页表替换为全局内核页表:satp = p->trapframe->kernel_satp
        • 切换到内核地址空间
    • 跳转到usertrap:jr t0
      • 从trampoline跳转到内核的C code中。
      • 以kernel stack,kernel page table跳转到usertrap函数
  • uservec code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    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.
    #** save user's context from cpu reg into dram(trapframe) **
    将当前cpu上运行的user process的上下文从cpu寄存器上存入dram的trapframe中
    # swap a0 and sscratch so that a0 is TRAPFRAME
    csrrw a0, sscratch, a0
    这条指令交换了寄存器a0和sscratch的内容
    # swap(ssrcatch , a0)
    # a0 = trapframe
    # ssrcatch = user a0
    # SSCRATCH寄存器的作用就是保存另一个寄存器的值,并将自己的值加载给另一个寄存器
    # save the user registers in TRAPFRAME
    sd ra, 40(a0)
    sd sp, 48(a0)
    sd gp, 56(a0)
    sd tp, 64(a0)
    ...
    sd a1, 120(a0)
    sd a2, 128(a0)

    # save the user a0 in p->trapframe->a0
    csrr t0, sscratch # t0 = a0
    sd t0, 112(a0) # p->trapframe->a0 = a0

    # **ld user process context from dram(trapframe) to cpu reg**
    # restore kernel stack pointer from p->trapframe->kernel_sp
    ld sp, 8(a0)
    # make tp hold the current hartid, from p->trapframe->kernel_hartid
    ld tp, 32(a0)
    # load the address of usertrap(), p->trapframe->kernel_trap
    ld t0, 16(a0)
    # restore kernel page table from p->trapframe->kernel_satp
    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
    jr t0

usertrap

  • 有很多原因都可以让程序运行进入到usertrap函数中来,比如系统调用,运算时除以0,使用了一个未被映射的虚拟地址,或者是设备中断。usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式,我们在接下来执行usertrap的过程中会同时看到这两个行为

  • usertrap行为:做些前置处理,然后根据发生trap的原因(scause)进行相应动作如系统调用,然后准备返回user态.(usertrapret)

  • usertrap

  • syscall

    • 向trapframe中的a0赋值的原因是:所有的系统调用都有一个返回值,比如write会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0,所以为了模拟函数的返回,我们将返回值存储在trapframe的a0中**。之后,当我们返回到用户空间,trapframe中的a0槽位的数值会写到实际的a0寄存器,Shell会认为a0寄存器中的数值是write系统调用的返回值。
  • 获取trapframe中的参数

  • 内存trapframe里存储的值,有一部分是user process进入kernel需要用的上下文(就是前几个,内核栈指针啥的)。
    有一部分是从process从kernel恢复到user需要使用的寄存器(上下文)。

usertrapret

  • usertrapret.c
  • uint64 satp = MAKE_SATP(p->pagetable)
    • 我们根据user page table地址生成相应的SATP值,这样我们在返回到用户空间的时候才能完成page table的切换。实际上,我们会在汇编代码trampoline中完成page table的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射

trampoline userret (kernel)

流程如下

  • 1. 切换page table
    • 由kernel page table 变成user page table 。由于trampoline page是两页表映射的相同部分,因此代码可以正常运行。
  • 2. 将trapframe中保存的对user process有用的上下文加载进cpu的reg中; 将trapframe的地址存回sscratch中
  • 3. sret是我们在kernel中的最后一条指令
    • 程序会切换回user mode
    • $sepc会被拷贝到PC寄存器(程序计数器)
      • trapframe->epc 是在进入内核后/返回user前 内核设置好的,应当返回到user的哪个指令。
    • 重新打开中断
  • 之后我们回到了用户空间。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    .globl userret
    userret:
    # sret 时 ,先将trapframe交给a0寄存器,然后加载完user process的上下文之后,
    # 就将trapframe的地址再存入sscratch
    # userret(TRAPFRAME, pagetable)
    # switch from kernel to user.
    # usertrapret() calls here.
    # a0: TRAPFRAME, in user page table.
    # a1: user page table, for satp.
    # 将user process的上下文从dram(trapframe)中加载进cpu的reg
    # 1. 切换成用户页表
    # switch to the user page table.
    csrw satp, a1
    sfence.vma zero, zero
    # 2. 将trapframe中保存的对user process有用的上下文加载进cpu的reg中; 将trapframe的地址存回sscratch中
    # a0是trapframe地址 临时保存user的a0到sscratch中
    # put the saved user a0 in sscratch, so we
    # can swap it with our a0 (TRAPFRAME) in the last step.
    ld t0, 112(a0)
    csrw sscratch, t0
    # restore all but a0 from TRAPFRAME
    ld ra, 40(a0)
    ld sp, 48(a0)
    ld gp, 56(a0)
    ld tp, 64(a0)
    ld t0, 72(a0)
    ....
    ld s1, 104(a0)
    ld a1, 120(a0)
    # 将user的trapframe放回sscratch
    # restore user a0, and save TRAPFRAME in sscratch
    csrrw a0, sscratch, a0
    # 3.
    # return to user mode and user pc.
    # usertrapret() set up sstatus and sepc.
    sret
    程序会切换回user mode
    SEPC寄存器的数值会被拷贝到PC寄存器(程序计数器)
    重新打开中断

总结

  • 系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容.
  • 通过trap机制从用户态进入内核态的开销为什么大,大在哪里?
    • trampoline.S中,就有很多很多(离开和进入总共100多条)指令来存储当前寄存器
    • cpu $satp 扔掉user pagetable , 重新加载 kernel pagetable。清空当前TLB。
      1
      2
      3
      4
      # restore kernel page table from p->trapframe->kernel_satp
      ld t1, 0(a0)
      csrw satp, t1 # cpu $satp 扔掉user pagetable , 重新加载 kernel pagetable
      sfence.vma zero, zero # 清空当前TLB。