6.S081 附加Lab1 用户执行系统调用的过程(Trap)

6.S081 附加Lab011 用户执行系统调用的过程(Trap)

0. 一些背景说明

附加lab从011开始编号,下一个是012(为了避开lab10,lab11…)

注意,这里主要接 6.S081-5用户空间和内核空间的切换–Trap机制

代码流程:write() -> ECALL -> uservec()(Trampoline.s) -> usertrap() -> syscall() -> sys_write() -> syscall() -> usertrapret() -> usertrapret() ->ret

Trap的时候,我们需要做什么?(当前处于user mode,现在需要执行系统调用)

  • 保存32个用户寄存器
  • 保存当前PC
  • mode切换成supervisor
  • SATP从user page table切换成kernel_pagetable
  • Stack Frame和Stack Pointer都需要改变,因为需要一个stack来调用kernel的函数
  • 跳入kernel

本实验将主要关心的是,执行系统调用时计算机的状态 – 可以用寄存器状态来判断,主要关心的寄存器有:

  • PC 程序计数器(Program Counter Register)

  • mode标志位(是supervisor mode 还是 user mode)

  • SATP(Supervisor Address Translation and Protection)寄存器,它包含了指向page table的物理内存地址

  • STVEC(Supervisor Trap Vector Base Address Register)寄存器,它指向了内核中处理trap的指令的起始地址。

  • SEPC(Supervisor Exception Program Counter)寄存器,在trap的过程中保存程序计数器的值。

  • SSRATCH(Supervisor Scratch Register)寄存器

后面的实验内容和流程,主要是实验内容参考-中文notes,实验的流程和工具参考6.S081 Lab00 xv6启动过程

总结一下:系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。

1. 进入内核前的准备write() -> ECALL

打开sh.c,代码如下,注意write(2, "$ ", 2);,它是我们接下来要追踪的系统调用。-- shell 将 "$ " 通过 write系统调用输出到文件描述符2。 – 注意,我这里其实原本是fprintf(2, "$ ");,需要修改成write ,然后make clean,然后再调试

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;
}

int
main(void)
{
  static char buf[100];
  int fd;

  // Ensure that three file descriptors are open.
  while((fd = open("console", O_RDWR)) >= 0){
    if(fd >= 3){
      close(fd);
      break;
    }
  }

  // Read and run input commands.
	...
}

进入调试

  • make qemu-gdb,并使用cpu个数 = 1(方便调试)
wc@r740:~/OS_experiment/xv6-riscv-fall19$ make CPUS=1 qemu-gdb
*** Now run 'gdb' in another window.
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -S -gdb tcp::26017
  • 新建窗口,打开gdb并用gdb远程链接 qemu的gdb
wc@r740:~/OS_experiment/xv6-riscv-fall19$ riscv64-unknown-elf-gdb kernel/kernel
# 一大堆输出 ... 
(gdb) target remote localhost:26017
Remote debugging using localhost:26017
0x0000000000001000 in ?? ()
  • 实际上被调用的write函数的实现如下(usys.s中可以看到) – 还有很多系统调用都是这样实现的,比如pipe, close, read, kill …
#define SYS_write  16

.global write
write:
 li a7, SYS_write
 ecall
 ret

​ ecall会让我们跳转到内核,kernel执行完之后会返回,然后执行ret,最终返回到shell。

  • 找到shell 的write对应的ecall地址(再shell.asm中找到如下代码👇),可以看到ecall对应地址是0xd66
0000000000000d64 <write>:
.global write
write:
 li a7, SYS_write
     d64:	48c1                	li	a7,16
 ecall
     d66:	00000073          	ecall
 ret
     d6a:	8082                	ret
  • 在0xd66处设置断点,然后运行 (注意这里的display/i $pc在每次断点的时候,都显示下一条指令的反汇编)
Remote debugging using localhost:26017
0x0000000000001000 in ?? ()
(gdb) display/i $pc
1: x/i $pc
=> 0x1000:      auipc   t0,0x0
(gdb) b *0xd66
Breakpoint 1 at 0xd66
(gdb) c
Continuing.

Breakpoint 1, 0x0000000000000d66 in ?? ()
1: x/i $pc
=> 0xd66:       ecall
  • 可以看到,我们成功在shell的write对应的ecall处停下来了,然后我们删除当前断点,打印pc,打印全部32个用户寄存器(info reg)。这里的a0, a1, a2是shell传给write系统调用的参数(write(2, "$ ", 2)) ——所以a0是2(文件描述符),a1是字符串指针,a2是2(写入字符数)。
(gdb) delete 1
(gdb) p $pc
$1 = (void (*)()) 0xd66
(gdb) info reg
(gdb) info reg
ra             0x24     0x24
sp             0x3f80   0x3f80
gp             0x505050505050505        0x505050505050505
tp             0x505050505050505        0x505050505050505
t0             0x505050505050505        361700864190383365
t1             0x505050505050505        361700864190383365
t2             0x505050505050505        361700864190383365
fp             0x3fa0   0x3fa0
s1             0x13f8   5112
a0             0x3fffffe000     274877898752
a1             0x1280   4736
a2             0x2      2
a3             0x505050505050505        361700864190383365
a4             0x505050505050505        361700864190383365
a5             0x2      2
a6             0x505050505050505        361700864190383365
a7             0x10     16
s2             0x64     100
s3             0x20     32
s4             0x13fb   5115
s5             0x1380   4992
s6             0x505050505050505        361700864190383365
s7             0x505050505050505        361700864190383365
s8             0x505050505050505        361700864190383365
s9             0x505050505050505        361700864190383365
s10            0x505050505050505        361700864190383365
--Type <RET> for more, q to quit, c to continue without paging--c
s11            0x505050505050505        361700864190383365
t3             0x505050505050505        361700864190383365
t4             0x505050505050505        361700864190383365
t5             0x505050505050505        361700864190383365
t6             0x505050505050505        361700864190383365
pc             0x3ffffff004     0x3ffffff004
  • 打印a1的内容,确实是"$ "
(gdb) p/c *($a1)@2
$7 = {36 '$', 0 '\000'}
  • 此外,寄存器可以看出,sp和pc还都比较接近0,说明现在运行在用户态。(-- 说明是虚拟地址),因为物理地址,至少都是从0x80000(OS启动处)开始的了。 --xv6中 kernel的虚拟地址和物理地址是一样的。

  • 查看SATP寄存器(页表地址)

  • 查看页表:qemu界面,按ctrl + a然后按c可以进入qemu console, 输入info mem可以查看页表的,但是我这里看不到。

$ QEMU 4.1.0 monitor - type 'help' for more information
(qemu) info mem
unknown command: 'info mem'

寻找原因:在如下所示的路径文件下,存在的代码如下,说明我必须是I386机型,才能使用info mem,这里我进行了注释的修改,最终make会报错,因此后面关于page table的输出,我都只能暂时省去了。

// /home/wc/OS_experiment/qemu-4.1.0/riscv64-softmmu/hmp-commands-info.h

// changed by levi
#if defined(TARGET_I386)
{
.name       = "mem",
.args_type  = "",
.params     = "",
.help       = "show the active virtual memory mappings",
.cmd        = hmp_info_mem,
},
#endif
// {
// .name       = "mem",
// .args_type  = "",
// .params     = "",
// .help       = "show the active virtual memory mappings",
// .cmd        = hmp_info_mem,
// },

  • page table的正确输出如图👇——后面几页地址很大,是因为位于弹簧床程序中。(这是trampoline page。trampoline page包含了内核的trap处理代码)

在这里插入图片描述

2. 进入内核后的ECALL -> uservec()

  • 继续执行,ecall
Breakpoint 1 at 0xd66
(gdb) c
Continuing.

Breakpoint 1, 0x0000000000000d66 in ?? ()
1: x/i $pc
=> 0xd66:       ecall
(gdb) x/3i 0xd64
   0xd64:       li      a7,16
=> 0xd66:       ecall
   0xd6a:       ret
(gdb) stepi
0x0000003ffffff004 in ?? ()
1: x/i $pc
=> 0x3ffffff004:        sd      ra,40(a0)
  • 打印pc,发现已经是很大的地址了 – 说明进入了内核 根据现在的程序计数器,代码正在trampoline page的最开始,这是用户内存中一个非常大的地址,所以现在我们的指令正运行在内存的trampoline page中。
(gdb) p $pc
$1 = (void (*)()) 0x3ffffff004
  • 查看当前的指令(查看的是pc - 4的内容):这些指令是内核在supervisor mode中将要执行的最开始的几条指令,也是在trap机制中最开始要执行的几条指令。
(gdb) x/6i 0x3ffffff000
   0x3ffffff000:        csrrw   a0,sscratch,a0
=> 0x3ffffff004:        sd      ra,40(a0)
   0x3ffffff008:        sd      sp,48(a0)
   0x3ffffff00c:        sd      gp,56(a0)
   0x3ffffff010:        sd      tp,64(a0)
   0x3ffffff014:        sd      t0,72(a0)
  • 继续查看寄存器的值,发现并没有改变——因此在此之前,我们还不能往这些寄存器赋值,否则不能正确恢复
(gdb) info reg
ra             0x24     0x24
sp             0x3f80   0x3f80
gp             0x505050505050505        0x505050505050505
tp             0x505050505050505        0x505050505050505
t0             0x505050505050505        361700864190383365
t1             0x505050505050505        361700864190383365
t2             0x505050505050505        361700864190383365
fp             0x3fa0   0x3fa0
s1             0x13f8   5112
a0             0x3fffffe000     274877898752
a1             0x1280   4736
a2             0x2      2
a3             0x505050505050505        361700864190383365
a4             0x505050505050505        361700864190383365
a5             0x2      2
a6             0x505050505050505        361700864190383365
a7             0x10     16
s2             0x64     100
s3             0x20     32
s4             0x13fb   5115
s5             0x1380   4992
s6             0x505050505050505        361700864190383365
s7             0x505050505050505        361700864190383365
s8             0x505050505050505        361700864190383365
s9             0x505050505050505        361700864190383365
s10            0x505050505050505        361700864190383365
--Type <RET> for more, q to quit, c to continue without paging--c
s11            0x505050505050505        361700864190383365
t3             0x505050505050505        361700864190383365
t4             0x505050505050505        361700864190383365
t5             0x505050505050505        361700864190383365
t6             0x505050505050505        361700864190383365
pc             0x3ffffff004     0x3ffffff004
  • 进入qemu,查看page table,发现还没改变👇。ecall并不会切换page table,这是ecall指令的一个非常重要的特点。 —— trap处理代码必须存在于每一个user page table中,因为ecall并不会切换page table,我们需要在user page table中的某个地方来执行最初的内核代码。而这个trampoline page,是由内核小心的映射到每一个user page table中,以使得当我们仍然在使用user page table时,内核在一个地方能够执行trap机制的最开始的一些指令——这里的控制是通过STVEC寄存器实现的,在从内核空间进入到用户空间之前,内核会设置好STVEC寄存器指向内核希望trap代码运行的位置。
    在这里插入图片描述
(gdb) p/x $stvec
$2 = 0x3ffffff000
  • 即使trampoline page是在用户地址空间的user page table完成的映射,用户代码不能写它,因为这些page对应的PTE并没有设置PTE_u标志位。这也是为什么trap机制是安全的。

ecall指令都做了什么??

  1. user mode -> supervisor mode
  2. 将PC保存在了SEPC寄存器(调用ecall的pc)👇
(gdb) p/x $sepc
$4 = 0xd66
  1. ecall 会跳转到STVEC寄存器指向的指令(trampoline page设置的trap内核代码处,上面已经说过)

根据0. 一些背景说明中的要求,我们只完成了橘色部分,红色部分还需要别的函数/指令完成👇

Trap的时候,我们需要做什么?(当前处于user mode,现在需要执行系统调用)

  • 保存32个用户寄存器
  • 保存当前PC
  • mode切换成supervisor
  • SATP从user page table切换成kernel_pagetable
  • Stack Frame和Stack Pointer都需要改变,因为需要一个stack来调用kernel的函数
  • 跳入kernel

为什么ecall 不切换pagetable?——切换page table的代价比较高,不用在不必要的场景切换page table。

所以ecall的下一条指令的位置是STVEC指向的地址,也就是trampoline page的起始地址。(注,实际上ecall是CPU的指令,自然在gdb中看不到具体内容)

3. 保存用户寄存器内容uservec()

接上面,由于我们的page table中存放了trampoline page(-- 这样每个进程都有自己的trapframe page,并且此处的虚拟地址总是0x3ffffffe000

trampframe存放的内容如下(proc.h 中的trapframe的结构体),刚开始有5个kernel实现存放在trapframe中的数据,后面是32个用户寄存器的内容。

// per-process data for the trap handling code in trampoline.S.
// sits in a page by itself just under the trampoline page in the
// user page table. not specially mapped in the kernel page table.
// the sscratch register points here.
// uservec in trampoline.S saves user registers in the trapframe,
// then initializes registers from the trapframe's
// kernel_sp, kernel_hartid, kernel_satp, and jumps to kernel_trap.
// usertrapret() and userret in trampoline.S set up
// the trapframe's kernel_*, restore user registers from the
// trapframe, switch to the user page table, and enter user space.
// the trapframe includes callee-saved user registers like s0-s11 because the
// return-to-user path via usertrapret() doesn't return through
// the entire kernel call stack.
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;
};

查看trampoline.S的代码,第一行就是csrrw a0, sscratch, a0这个指令交换了a0和sscratch两个寄存器的内容。(为了清晰我这里给出了trampoline.S的完整代码——后续也会用到)

	#this is trampoline.S
        # code to switch between user and kernel space.
        #
        # this code is mapped at the same virtual address
        # (TRAMPOLINE) in user and kernel space so that
        # it continues to work when it switches page tables.
	#
	# kernel.ld causes this to be aligned
        # to a page boundary.
        #
	.section trampsec
.globl trampoline
trampoline:
.align 4
.globl uservec
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->tf is
        # mapped into user space, at TRAPFRAME.
        #
        
	# swap a0 and sscratch
        # so that a0 is TRAPFRAME
        csrrw a0, sscratch, a0

        # save the user registers in 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)

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

        # restore kernel stack pointer from p->tf->kernel_sp
        ld sp, 8(a0)

        # make tp hold the current hartid, from p->tf->kernel_hartid
        ld tp, 32(a0)

        # load the address of usertrap(), p->tf->kernel_trap
        ld t0, 16(a0)

        # restore kernel page table from p->tf->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

.globl 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.

        # switch to the user page table.
        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.
        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 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
        csrrw a0, sscratch, a0
        
        # return to user mode and user pc.
        # usertrapret() set up sstatus and sepc.
        sret

打印sscratch寄存器的内容,现在是2,其实这里就是a0之前的值,正如之前所说,write(2, "$ ", 2);函数调用的时候a0作为第一个传入参数的保存者,保存的是文件描述符2。也就是说:在进入到user space之前,内核会将trapframe page的地址保存在这个寄存器中,也就是0x3fffffe000这个地址。更重要的是,RISC-V有一个指令允许交换任意两个寄存器的值。而SSCRATCH寄存器的作用就是保存另一个寄存器的值,并将自己的值加载给另一个寄存器。,所以,现在的a0就是trapframe的首地址(0x3ffffffe000

(gdb) p/x $sscratch
$5 = 0x2
(gdb) p/x $a0
$6 = 0x3fffffe000

所以trampoline.S的后续指令(第二三四五六七八…)都有意义了,也就是a0其实是trapframe的首地址,由第二条指令sd ra, 40(a0)可知,ra被保存在了trapframe + 40的位置…。注意代码的最后(倒数第二条指令,又将a0和sscratch寄存器的内容互换了回去,然后执行sret就返回用户空间了)

  • 然后注意trampoline中的第一条load指令,因为a0是trapframe,所以a0 + 8是kernel_sp(kernel的Stack Pointer)所以这条指令的作用是初始化Stack Pointer指向这个进程的kernel stack的最顶端。
# restore kernel stack pointer from p->tf->kernel_sp
ld sp, 8(a0)
  • 指向完这👆条指令之后,我们打印一下当前的Stack Pointer寄存器,这是这个进程的kernel stack。因为XV6在每个kernel stack下面放置一个guard page,所以kernel stack的地址都比较大。下一条指令是tp – 用来保存hartid(CPU编号) —— 保存当前运行在多核CPU的哪一个核上(因为我设置了单核,所以编号一定是0)。
(gdb) p/x $sp
$9 = 0x3fffffc000
(gdb) p/x $tp
$10 = 0x0
  • 接下来是t0(usertrap的函数地址),t1(kernel_pagetable的地址)——实际上严格来说,t1的内容并不是kernel page table的地址,这是你需要向SATP寄存器写入的数据。它包含了kernel page table的地址,但是移位了(注,详见4.3),并且包含了各种标志位。
(gdb) p/x $t0
$11 = 0x8000276a
(gdb) p/x $t1
$12 = 0x505050505050505

下一条指令是交换SATP和t1寄存器。这条指令执行完成之后,当前程序会从user page table切换到kernel page table。现在我们在QEMU中打印page table,可以看出与之前的page table完全不一样👇。——成功切换了page table——切换到了kernel page table——有了kernel_pagetable我们就可以读取kernel的data
在这里插入图片描述

这里还有个问题,为什么代码没有崩溃?毕竟我们在内存中的某个位置执行代码,程序计数器保存的是虚拟地址,如果我们切换了page table,为什么同一个虚拟地址不会通过新的page table寻址走到一些无关的page中?看起来我们现在没有崩溃并且还在执行这些指令。有人来猜一下原因吗?

学生回答:因为我们还在trampoline代码中,而trampoline代码在用户空间和内核空间都映射到了同一个地址。

之所以叫trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。

这就是本科的时候,柏军老师说的弹簧床可以防止程序“跑飞”(这里的弹簧床和本实验的弹簧床有所不同)——柏军老师指的是操作系统启动的时候,用弹簧床程序引导bootloader去main()的首地址去执行(而不是直接用bootloader执行main),这里的“弹簧床”意思就是,当main()里面出现bug了,就再去弹簧床处执行(再次调用main()),这样程序就不会因为一次bug而panic。

最后一条指令是jr t0。执行了这条指令,我们就要从trampoline跳到内核的C代码中。这条指令的作用是跳转到t0指向的函数中。(前面已经说过,是usertrap函数),当然也可以打印一下:

(gdb) stepi
0x0000003ffffff08e in ?? ()
1: x/i $pc
=> 0x3ffffff08e:        jr      t0
(gdb) x/3i $t0
   0x8000276a <usertrap>:       addi    sp,sp,-32
   0x8000276c <usertrap+2>:     sd      ra,24(sp)
   0x8000276e <usertrap+4>:     sd      s0,16(sp)

接下来我们就要以kernel stack,kernel page table跳转到usertrap函数。

4. 处理trap——usertrap()

usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式。代码如下。

//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
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->tf->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->tf->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 if(r_scause() == 13 || r_scause() == 15)
  {
    // printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    // printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    uvmalloc(p->pagetable, PGROUNDDOWN(r_stval()), PGROUNDDOWN(r_stval()) + 4096);
  }
  else {
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    printf("page down:%d\n",PGROUNDDOWN(r_stval()));
    // printf("r :%d\n",r_scause());
    // int sz = 0;
    // while( !(r_stval()>=sz && r_stval()<sz+4096) )
    // {
    //   sz = sz + 4096;
    // }
    // printf("sz:%d\n",sz);
    p->killed = 1;
  }

  if(p->killed)
    exit(-1);

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

  usertrapret();
}

它做的第一件事情是更改STVEC寄存器。取决于trap是来自于用户空间还是内核空间,实际上XV6处理trap的方法是不一样的。目前为止,我们只讨论过当trap是由用户空间发起时会发生什么。如果trap从内核空间发起,将会是一个非常不同的处理流程,因为从内核发起的话,程序已经在使用kernel page table。所以当trap发生时,程序执行仍然在内核的话,很多处理都不必存在。

在内核中执行任何操作之前,usertrap中先将STVEC指向了kernelvec变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。

然后来一步步分析代码,注意看注释部分

// 找出当前正在运行的进程 -- 通过hartid (之前切换pagetable前已经保存到t0)
struct proc *p = myproc();
// 把当前进程的pc保存到当前进程的trapframe(防止进程切换找不到了)
// save user program counter.
p->tf->epc = r_sepc();
  • 把当前进程的pc保存到当前进程的trapframe(防止进程切换找不到了);接下来检查进程是否被killed(这里shell没有被killed,所以可以继续执行;记录ret的地址 = 当前pc + 4——这样我们会在ecall的下一条指令恢复,而不是重新执行ecall指令;打开中断(因为XV6会在处理系统调用的时候使能中断,这样中断可以更快的服务,有些系统调用需要许多时间处理。中断总是会被RISC-V的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。);然后调用syscall。
// 找出usertrap的原因,如果如果是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.
    p->tf->epc += 4;

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

    syscall();
  }
  • syscall函数——根据预定义的函数编号,找到函数地址sys_write
void
syscall(void)
{
  int num;
  struct proc *p = myproc();

  num = p->tf->a7;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    p->tf->a0 = syscalls[num]();
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->tf->a0 = -1;
  }
}
  • 运行sys_write(参数保存在a0,a1和a2),现在需要返回了
  • syscall之后再次返回usertrap函数,usertrap调用了一个函数usertrapret。

5. usertrapret()

处理返回用户空间之前,内核要做的工作。

  • 首先关闭中断。我们关闭中断因为当我们将STVEC更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
  • 在下一行我们设置了STVEC寄存器指向trampoline代码,在那里最终会执行sret指令返回到用户空间。位于trampoline代码最后的sret指令会重新打开中断。这样,即使我们刚刚关闭了中断,当我们在执行用户代码时中断是打开的。
  • 接下来的几行填入了trapframe的内容
    • kernel_pagetable
    • 当前用户进程的kernel stack
    • usertrap地址——trampoline可以跳转到这里
    • tp寄存器中的hartid
//
// return to user space
//
void
usertrapret(void)
{
  struct proc *p = myproc();

  // turn off interrupts, since we're switching
  // now from kerneltrap() to usertrap().
  intr_off();

  // send syscalls, interrupts, and exceptions to trampoline.S
  w_stvec(TRAMPOLINE + (uservec - trampoline));

  // set up trapframe values that uservec will need when
  // the process next re-enters the kernel.
  p->tf->kernel_satp = r_satp();         // kernel page table
  p->tf->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
  p->tf->kernel_trap = (uint64)usertrap;
  p->tf->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.
  unsigned long x = r_sstatus();
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  w_sstatus(x);

  // set S Exception Program Counter to the saved user pc.
  w_sepc(p->tf->epc);

  // tell trampoline.S the user page table to switch to.
  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.
  uint64 fn = TRAMPOLINE + (userret - trampoline);
  ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}
  • 要设置SSTATUS寄存器,这是一个控制寄存器。这个寄存器的SPP bit位控制了sret指令的行为,该bit为0表示下次执行sret的时候,我们想要返回user mode而不是supervisor mode。这个寄存器的SPIE bit位控制了,在执行完sret之后,是否打开中断。因为我们在返回到用户空间之后,我们的确希望打开中断,所以这里将SPIE bit位设置为1。修改完这些bit位之后,我们会把新的值写回到SSTATUS寄存器。我们在trampoline代码的最后执行了sret指令。这条指令会将程序计数器设置成SEPC寄存器的值,所以现在我们将SEPC寄存器的值设置成之前保存的用户程序计数器的值。在不久之前,我们在usertrap函数中将用户程序计数器保存在trapframe中的epc字段。
 unsigned long x = r_sstatus();
  • 根据current user process 的页表,设置SATP寄存器,准备切换到user 页表。
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
  • 计算好我们要jmp到的地址(trampoline中的userret函数 —— 这个函数包含了所有能将我们带回到用户空间的指令。)
uint64 fn = TRAMPOLINE + (userret - trampoline);
  • 最后,就是执行userret函数(这个函数在trampoline中)
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);

6. userrret()

这个函数包含了所有能将我们带回到用户空间的指令。

.globl 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.

        # switch to the user page table.
        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.
        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 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
        csrrw a0, sscratch, a0
        
        # return to user mode and user pc.
        # usertrapret() set up sstatus and sepc.
        sret

  • 首先是切换pagetable(从kernel pa tb到user pg tb)

  • 然后是将之前保存在trapframe中的registers还原。user page table也映射了trampoline page,所以程序还能继续执行而不是崩溃。 (在这里a0是trapframe的地址)

  • 解释 sfence.vma zero, zero是clear page table 的TLB

  • 重新打印所有寄存器(和调用前一致) (除了a0 —— a0 被write的返回值覆盖了)

  • 打印pc可以看出来,现在已经回到用户空间了 —— 以为pc很小,明显是虚拟地址

(gdb) p/x $pc
$44  = 0xd6a

最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。

Trampoline page之所以叫trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。从内核空间弹了一下,又走出来。——trampoline代码在用户空间和内核空间都映射到了同一个地址。这样不会让程序在user和kernel pagetable切换的时候崩溃。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
当一个进程在Linux系统中需要执行某个系统调用时,它会首先将参数传递给对应的系统调用函数,然后通过软中断(软件中断)或者陷阱指令(trap instruction)进入内核态,执行内核中对应的系统调用处理程序。 在进入内核态之前,进程需要将系统调用号传递给内核,告诉内核它希望执行哪个系统调用。这通常是通过CPU的寄存器来完成的。在Linux中,进程通过eax寄存器将系统调用号传递给内核。 当进程通过软中断或者陷阱指令进入内核态时,CPU会自动保存当前进程的状态(包括寄存器值、程序计数器、堆栈指针等)并将控制权转交给内核。内核会根据系统调用号找到对应的系统调用处理程序,并将进程的参数从用户空间拷贝到内核空间。 在系统调用处理程序中,内核会根据进程提供的参数执行相应的操作,并将结果返回给进程。如果系统调用执行成功,内核会将返回值存放在eax寄存器中,然后通过陷阱指令或者软中断返回到用户态,恢复进程的状态,并将控制权交给进程继续执行下面的指令。 如果系统调用执行失败,内核会在进程的地址空间中设置合适的错误码,并将其存放在eax寄存器中返回给进程。进程可以根据返回值来判断系统调用是否执行成功,并采取相应的措施。 总的来说,Linux系统调用执行过程可以分为以下几个步骤:传递参数、进入内核态、执行系统调用处理程序、返回结果或错误码。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值