浅谈用户态和内核态以及系统调用

前言

  为了保证操作系统的强隔离性,设计者实现了“supervisor mode” 和 “虚拟内存”,来保证进程之间互相隔离。

  我们可以认为user/kernel mode是分隔用户空间和内核空间的边界,用户空间运行的程序运行在user mode,内核空间的程序运行在kernel mode。操作系统位于内核空间。

1633247506349

  一般来说,用户态的应用程序运行在用户态空间里面。但是,一些比较重要的函数,操作会发生在内核空间。因此,内核空间拥有更多的权限,能够访问更多的底层数据结构,以及指令。

  那么,我们来看看如何从 用户态陷入到内核态 (trap)

Trap机制

今天我想导论一下,程序是如何完成用户空间到内核空间的切换。每当发生下面的以下情况发生时:

  • 程序需要系统调用
  • 程序出现缺页,页表错误
  • 设备发出了中断

都会发生mode的切换。这种切换机制叫做trap

  下面,举一个Shell进程,执行write()系统调用的例子。看看程序是如何完成用户态到内核态的切换

1633247995037

  我们有一点需要知道的是,当用户态程序进入到系统态的时候,这个过程中硬件的状态非常重要,需要我们保存下来。因为,trap过程中需要设置cpu状态,这样才能让内核程序运行(这里我理解是保存CPU的现场或者说上下文信息)。

  因此,为了恢复用户态程序,需要把切换时的CPU现场保存下来。需要注意的寄存器有下面这些

  • 32位用户寄存器
  • stack pointer (指向程序的栈)
  • pc
  • mode位 (标志系统处于哪个状态,user mode or supervisor mode)
  • STAP — Supervisor Address Translation and Protection — (包含指向page table的地址)
  • STVEC — (trap指令的起始地址)
  • SEPC — (执行trap过程中,保存PC)
  • SSRACTCH — (保存trapfram的地址)
    • trapfram是用来保存CPU现场的一个重要数据结构

  可以肯定,trap机制刚执行的时候,cpu的状态是运行用户程序的状态

  因此,刚运行trap指令的时候,我们需要做一些操作(保存当前CPU的上下文)。这样才能运行内核中的C程序。

  • 保留32位寄存器,因为这是恢复的根本

  • PC,同上

  • supervi mode设置为内核态

  • STAP页表寄存器此时指向用户程序的页表,需要执行内核程序的话理所当然需要内核程序的页表。

    • trapframe中有指向内核的页表的地址
    • 把内核页表的地址,与当前STAP寄存器中,用户程序的页表地址交换
  • 也就是说,此时trapframe中有用户程序的页表,STAP中有内核程序的页表

  • Stack pointer 也要指向进程内核栈。因为C程序的运行需要一个栈空间

以上,就是trap机制大概执行了什么事情。接下来我们从代码的角度来进行分析。简单来说,就是在CPU里面的状态位,设置为能够运行内核程序的状态。并且保存用户程序的CPU上下文。

Trap代码执行流程

  Shell执行write( )系统调用。从Shell的角度来看,write( )是一个C函数调用。但是,从汇编的角度来看,write( )通过ecall num,来具体执行系统调用。

  ecall指令会切换到kernal mode中去。在这个过程中执行的是一个汇编语言写的函数uservec。这个函数是内核代码trampoline.s的一部分。

1633249004333

  之后,在这个汇编函数,代码跳转到了由C语言实现的函数usertrap中。该函数在trap.c中。

1633249051852

usertrap( )中执行了一个syscall指令,然后该指令通过number。在一个表里面,找到该number对应的系统调用。

1633249130252

sys_write会将显式数据输出到console上面,当它完成了之后,它会返回给syscall函数

1633249265805

上面就是我们如何从用户态陷入到内核态,执行系统调用的过程,但是现在需要考虑是如何从内核态返回给用户态

​ 在syscall函数中,会调用一个函数叫做usertrapret,它也位于trap.c中,这个函数完成了部分方便在C代码中实现的返回到用户空间的工作。

1633249347666

除此之外,最终还有一些工作只能在汇编语言中完成。这部分工作通过汇编语言实现,并且存在于trampoline.s文件中的userret函数中。

1633249369876

​ 最终,在汇编代码中调用机器指令,返回到了用户控件中。

1633249399340

ecall指令执行前的状态

  从那个sh执行write( )系统调用,我们看一看执行ecall前后的寄存器状态。

  这是write的代码,很简单

1633250221799

现在,我要让XV6开始运行。我期望的是XV6在Shell代码中正好在执行ecall之前就会停住。

1633250263072

从gdb可以看出,我们下一条要执行的指令就是ecall。我们来检验一下我们真的在我们以为自己在的位置,让我们来打印程序计数器(Program Counter),正好我们期望在的位置0xde6。

1633250283383

此时寄存器的状态

1633250314013

STAP页表寄存器状态以及内容

1633250345496

1633250357155

  最后两页明显不一样,因为这是trampoline page trapframe page的地址空间,当我们进入到supervisor mode就能访问者两页。

ecall执行后的状态

当我们执行完ecall之后,查看一下PC的地址。很明显这是一个比较大的值,与ecall执行前的Oxde6不一样。

1633250494236

再查看一下page table。发现page table没有变化

1633250542800

我们发现,此时PC中的值跟trampoline page的值十分相似。那么我们查看一下trampoline page中的指令。

1633250613680

这些指令是内核在supervisor mode中将要执行的最开始的几条指令,也是在trap机制中最开始要执行的几条指令。

因为gdb有一些奇怪的行为,我们实际上已经执行了位于trampoline page最开始的一条指令(也就是csrrw指令)。

我们查看32位寄存器的值,发现并没有改变。所以寄存器还是用户数据。再观察一下发现。

1633250690543

  当前执行的指令是把CPU的状态进行一个保存。也就是说trampoline page的指令是保存cpu寄存器的

  我们现在在这个地址0x3ffffff000,也就是上面page table输出的最后一个page,这是trampoline page。我们现在正在trampoline page中执行程序,这个page包含了内核的trap处理代码。ecall并不会切换page table,这是ecall指令的一个非常重要的特点。

1
2
3
1.此时 page register 仍旧是用户态的页表
2.只不过是把 用户态页表 最后的那一项(tampoline page)起始地址放到PC中
3.每一个用户态代码最后一定有这两页 trapfram and trampoline page

ecall做的事情

  • 第一,把user mode设置为supervisor mode

  • 第二,把当前的PC值放到SEPC寄存器中,以便系统调用结束后,能够恢复原来的程序位置

    • 至于保存CPU现场,是trampoline page里面的指令做的事情。所以,此时PC里面的指令是trampoline page
  • 第三,ecall会跳转到SPEVC寄存器指向的指令

打印当前PC,是trampolin page的地址,也是SPEVC的地址

1633251142484

打印SEPC寄存器,是用户态PC的值

1633251176774

所以执行完ecall,我们仅仅是改变了supervisor mode 和 pc的值

此时页表和栈的信息,仍旧是用户程序的。因此,我们接下来的工作:

  • 保存32位现场(trampoline page代码)
  • 切换page table
  • 切换进程栈空间

为什么ecall做的事情这么少

  实际上,我们也可以通过在硬件上进行处理,通过修改硬件让ecall帮我们执行完这些。但是,RISC-V秉持了这样一个观点:ecall只完成尽量少必须要完成的工作,其他的工作都交给软件完成。

  这里的原因是,RISC-V设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。

  让ecall指完成基本的功能,那些开销很大的操作,有可能是不用做的,那么就没必要放到ecall里面,让ecall指令更加的效率。

  • 举个例子,因为这里的ecall是如此的简单,或许某些操作系统可以在不切换page table的前提下,执行部分系统调用。切换page table的代价比较高,如果ecall打包完成了这部分工作,那就不能对一些系统调用进行改进,使其不用在不必要的场景切换page table。
  • 某些操作系统同时将user和kernel的虚拟地址映射到一个page table中,这样在user和kernel之间切换时根本就不用切换page table。对于这样的操作系统来说,如果ecall切换了page table那将会是一种浪费,并且也减慢了程序的运行。
  • 或许在一些系统调用过程中,一些寄存器不用保存,而哪些寄存器需要保存,哪些不需要,取决于于软件,编程语言,和编译器。通过不保存所有的32个寄存器或许可以节省大量的程序运行时间,所以你不会想要ecall迫使你保存所有的寄存器。
  • 最后,对于某些简单的系统调用或许根本就不需要任何stack,所以对于一些非常关注性能的操作系统,ecall不会自动为你完成stack切换是极好的。

所以,ecall尽量的简单可以提升软件设计的灵活性。

uservec函数

保存CPU现场

  刚才提到了trampoline page里面的代码是为了保存寄存器状态。但是现在有一个问题。

  在大多数的OS里面,是不能直接访问物理内存的,需要通过page table做一个映射。因此我们需要拿到kernal的page table。但是目前为止,我们并不知道它的page table地址。

​ 并且更新page table的寄存器,我们需要先把用户程序的page table进行保存,这样我们才能再修改。

​ 因此总结一下我们需要解决的两个问题:

1
2
- 我们需要知道kernal page table 的起始地址
- 我们需要保存user page table,这样才能使用它的寄存器

  这个做法其实十分简单,还记得user page table,最后的两页是什么内容吗?trampoline page trapfram page的起始地址。

  我们看看trapfram page中究竟存储的是什么东西?

1633252276286

  很显然,trapfram的字段都是对应着CPU里面的寄存器。但是多了五个数据,这是内核事先存放好的数据

  • 比如,第一个字段就存放着kernal page的地址
  • 第二个字段是栈
  • 第三个是usertrap代码的起始地址
  • 第四个是SEPC寄存器

所以很显然了,我们需要的kernal page就在trapframe中就可以找到。

  另一半的答案在于我们之前提过的SSCRATCH寄存器。(可以理解为box)

  在回到用户态之前,内核会把trapframe page的地址保存在这个寄存器。更重要的是csrrw指令,能够交换两个寄存器的值。

  我们看一下trampoline.s的代码

1633252617219

第一件事情,就是交换a0ssratch两个寄存器的内容,我们通过gdb查看一下。显然a0就是等于trapframe

1633252661829

打印一下ssractch,显然就是之前的a0

1633252692044

  所以我们现在将a0的值保存起来了,并且我们有了指向trapframe page的指针。现在我们正在朝着保存用户寄存器的道路上前进。通过计算位移,来保存寄存器的内容到trapframe

1
2
3
4
5
6
总结一下trampoline page是怎么保存的
1.不能直接访问内存,还是需要通过虚拟内存进行访问
2.需要保存cpu现场

(1)事先把trapframe的地址保存起来,进入supervisor后,执行置换指令,这样就得到一个相对位移的起点了
(2)然后就可以很顺利执行CPU现场保存

切换栈空间

  程序现在仍然在trampoline的最开始,也就是uservec函数的最开始,仅仅执行了CPU现场保存。我在寄存器拷贝的结束位置设置了一个断点,我们在gdb中让代码继续执行,现在我们停在了下面这条ld(load)指令。

1633253140983

也就是,加载trapfram中的第八个字节到stack_pointer寄存器中。我们查看一些trapframe中第八个字节是什么东西?

1633253213711

  显然很明显,该指令就是切换到内核栈上。这是这个进程的kernel stack。因为XV6在每个kernel stack下面放置一个guard page,所以kernel stack的地址都比较大。

1
2
1.栈寄存器 切换到 kernal程序的栈空间。(因为要开始执行内核程序)
2.kernal的stack point一开始就在trapframe中初始化好了

切换page寄存器

​ 有一条指令是向t1寄存器写入数据。这里写入的是kernel page table的地址,我们可以打印t1寄存器的内容。

1633253345893

  这条指令执行完成之后,当前程序会从user page table切换到kernel page table。现在我们在QEMU中打印page table,可以看出与之前的page table完全不一样。

1633253378219

​ 以上,就是从用户态陷入到内核态的所有流程。