Skip to the content.

经常说到context switch,上下文切换,可能有好几种理解,比如说用户空间切换到内核空间, 比如用户内核线程之间发生切换,再比如用户进程切换到另一个进程,可能都会被认为 是context swtich,下面记录一下xv6中这几种切换的过程吧。

下面说到的一些过程,主要是我个人的学习中的一些理解,不一定完全准确,有问题 的以后再修正。

0x00 几个概念之间的区别和联系

我想直观的理解,用户空间就是运行在U-mode的过程,内核空间就是运行在M-mode和S-mode中。 xv6的时钟中断处理会有一小段在M-mode中,还有就是初始化start.c运行在M-mode中,其余内核 空间均在S-mode。

那么用户空间如何切换到内核空间呢,主要是通过trap(中断+异常+系统调用ecall指令),内核 空间切换到用户空间使用sret指令(M-mode就是mret)。

xv6中用户进程中只有一个线程,也就是常规理解的用户空间是多进程运行。而在xv6内核中,是多 线程的结构,就是说内核中同时运行多个线程并共享内核内存空间等。

内核线程我觉得比较难以理解。xv6内核中的内核线程由regs、stack组成,说白了,CPU数量是有限的, 运行过程中肯定是要进行线程切换的,那么内核线程要想恢复运行,必须在切换的时候将内核线程的状态 保存起来。xv6中内核线程有(1)每个CPU包含的调度器scheduler线程,(2)每个进程在内核中的线程。

0x01 用户空间切换到内核空间

这里记录xv6中一个用户进程触发系统调用后返回结果的过程。

1. 一个简单的用户程序

编写一个简单的用户程序,有一个write系统调用,在“标准输出”输出一个字符a并回车,如下:

// user/hello.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int main(){
    write(1,"a\n",2); // write系统调用会切换到内核空间
    return 0;
}

其中write函数的声明在user/user.h中

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

write函数的定义在user/usys.S中:

.global write
write:
 li a7, SYS_write
 ecall
 ret

当用户程序执行到write的时候,a0, a1, a2 会保存传入参数,a7保存系统调用的id, 然后执行ecall。

修改Makefile,将hello.c文件加入到其中,就可以进行调试测试。

// 在 Makefile中添加hello编译
UPROGS += $U/_hello

如果我们执行 make 之后,可以看到 user/hello.asm 中的 hello.c 生成的 asm 文件:

 #...
  17     write(1,"a\n",2);
  18    8:   4609                    li  a2,2
  19    a:   00000597            auipc   a1,0x0
  20    e:   7e658593            addi    a1,a1,2022 # 7f0 <malloc+0xea>
  21   12:   4505                    li  a0,1
  22   14:   00000097            auipc   ra,0x0
  23   18:   2dc080e7            jalr    732(ra) # 2f0 <write>
 #...
 539 00000000000002f0 <write>:
 540 .global write
 541 write:
 542  li a7, SYS_write
 543  2f0:   48c1                    li  a7,16
 544  ecall
 545  2f2:   00000073            ecall
 546  ret
 547  2f6:   8082                    ret
 #...

2. ecall 切换到内核

write 函数调用 ecall 之后,就会陷入到内核中。在 xv6-riscv book rev3 文档中有明确的说明。

(1) 关闭中断,当然只是关闭该CPU的中断,其他CPU不会影响;

(2) 把pc寄存器值保存到epc;

(3) 保存当前mode到sstatus的SPP位,这里就是将U-mode保存;

(4) trap 的原因保存到scause中,syscall对应的cause id = 8;

(5) U-mode切换为S-mode;

(6) stvec中的值拷贝到pc寄存中;

然后继续执行。上述的过程都是硬件执行的,对于内核来说无法控制过程,只能应用上面的结果。

从这里看ecall执行之后,pc会指向stvec中的值。在$stvec上设置一个break,

继续执行,就切换到 trampoline 页,也就是 MAXVM 的最后一页。

trampoline 页就是将 trampoline.S 文件生成的执行文件放到里面,它内部有2个符号,uservec和userret, uservec就是从用户空间进入内核要执行的第一段代码。

uservec 保存用户空间的寄存器(从ra到t6)和其他一些内容到 p->trapframe,这个trapframe就是 用于保存用户进程的状态。然后切换到 usertrap():

得到usertrap的地址,赋值到t0,但是我们没办法直接获得usertrap符号的地址,那是因为 到现在为止,还没有切换到内核页表。也就是说现在虽然S-mode在运行,但是仍然在用户页表 中执行。现在可以说是操作系统非常奇怪的状态,也可以说是操作系统的用户和内核的切换 过程中,非常的脆弱,任何一点问题都会导致panic。

这下就终于进入到 trap.c:usertrap() 里了,有一个sepc寄存器的处理,从前面硬件操作中可以看到 epc已经设置为用户进程ecall时候的pc值了,这里需要将返回的pc值(p->trapframe->epc)准备好, 可以想象得到,我们是因为 syscall 进入到了内核,所以 p->trapframe->epc = $sepc+4。(为啥+4, 因为ecall指令正好占用4个字节。)

使用GDB调试也可看到现在 p->trapframe->epc 已经保存了用户进程 ecall 指令的位置,未来返回 的时候再加4,这样就到了ecall下一条指令。

设置好 p->trapframe->epc 之后,打开中断。(可以被打断执行了,这之前的所有操作,都是不可打断的。)

然后切换到 syscall() 函数。

syscall()函数是一个分配器,num 对应的系统调用的id,正如前面我们看到的 write 对应的id=16。 然后syscall切换到数组函数对应的sys_write函数执行真正的工作,并将返回值写到 p->trapframe->a0 中。

下一篇继续…

参考

  1. book-riscv-rev3.pdf