xv6的系统调用
系统调用就是内核提供出系统调用接口,然后供开发者进行使用,你无需关心里面的实现方法,只需要按照规定去进行调用使用即可。但是,为了深入了解一下,在这里概述一下系统调用的过程
代码分析
内核与用户程序的桥梁
-
在学习了一段时间后,我们可以看到
xv6
里面有着许多系统调用,在lab1里面我们就做过fork
、exec
、sleep
之类的系统调用。可以看到的是,在user
目录下,只有许多这些系统调用函数的定义,具体细节并没有实现,而为了实现系统调用,我们就不得不去转到kernel
里面去执行,那么这个过程是怎么发生的呢? -
这里就用到了我们的桥梁,也就是
user/usys.pl
,这是一个自动脚本,我们可以来看一下里面的内容#!/usr/bin/perl -w # Generate usys.S, the stubs for syscalls. print "# generated by usys.pl - do not edit\n"; print "#include \"kernel/syscall.h\"\n"; # 这是我们的系统调用存根 # 会生成一个汇编文件 # 其中 li 指令是加载立即数,也就是把 SYS_name 的系统调用号加载到a7寄存器里面 # 然后就去调用ecall命令,这个命令会提高运行的权限,为后续进入内核做准备 # 然后执行完退出就行 sub entry { my $name = shift; print ".global $name\n"; print "${name}:\n"; print " li a7, SYS_${name}\n"; print " ecall\n"; print " ret\n"; } # 在这个文件里面添加存根(stub),生成我们的系统调用汇编文件 entry("fork"); entry("exit"); entry("wait"); entry("pipe"); entry("read"); entry("write"); entry("close"); entry("kill"); entry("exec"); entry("open"); entry("mknod"); entry("unlink"); entry("fstat"); entry("link"); entry("mkdir"); entry("chdir"); entry("dup"); entry("getpid"); entry("sbrk"); entry("sleep"); entry("uptime"); entry("trace");
其中,我们重点来看一下
entry
的部分,这个脚本会接受一个参数,也就是系统调用的名称,然后去生成汇编文件,也就是说,当我们调用系统调用sleep
(举个例子),便会生成这个汇编文件.global sleep sleep: li a7, SYS_sleep ecall ret
这几行汇编的意思就是把
sleep
系统调用号(在syscall.h
里面定义)存储到a7
寄存器里面,然后执行ecall
指令,提高运行权限,由user model
转到监管模式下,在此,我们便成功进入了内核。同时,在执行的时候,我们可以去使用make qemu
去编译一下,查看一下生成的usys.S
文件。PS(有时候会看到汇编文件是大写的S而不是小写的s,这是因为S里面还包含一些预处理指令)。 -
ecall
指令干了什么 了解到了一些关于ecall
指令的细节。刚开始一直在想,为什么执行个ecall
就可以去跳转到内核里面了。今天来细细说一下ecall
指令干的事。-
首先提升权限,这个是很明显的
-
其次是把next pc值存储到sepc(Smode exception pc S态异常处理pc),因为处理完还要跳转回来嘛
-
然后跳转到STVEC(Smode Trap Vector)寄存器那,这个存储的就是我们的异常处理程序的开端,也是一个很大的数
-
stvec
寄存器里面存储的就是我们蹦床(trampoline)
的地址,蹦床我觉得就是一个过度机制,因为你进入了内核,但是你不能在内核直接执行c
代码,因为现在的这些寄存器里面存储的还是用户态的数据,不能直接去执行c函数,你还得把这些数据存储下来,存储到别的地方去,而蹦床就提供了一个缓冲机制 -
之后就开始我们参数的传递了
-
函数的参数传递
-
在执行完
trampoline
后,我们就开始执行trapframe
里面的东西了,这个结构体在kernel/proc.h
里面保存,目的就是为了去保存用户寄存器里面的数据,可以看到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; };
这就是这些寄存器,比较奇怪的是,前面还有着偏移量,我现在来解释一下这个偏移量是什么意思,在进入用户空间之前(无论是由于进程启动还是从中断中恢复),内核会先设置
sscratch
寄存器指向trapframe,它是每个进程都有的一个用于存储所有用户寄存器的结构体,并且,它也被映射到用户页表的TRAPFRAME部分,位于TRAMPOLINE的下面,所以也可以通过用户页表访问 -
在系统调用的时候,我们还要传入参数嘛,比如
sleep
里面的休眠时间就是一个参数,在传递参数的时候,通常是由寄存器来保存我们的参数的,其中还有着一个复杂的函数调用约定,就是要求了哪个寄存器去保存我们的第一个参数、第二个参数、函数返回参数等等,在调用函数接口的时候,就已经把参数给保存好了。 -
这就又引出了一个问题,我们在函数的参数都在用户态的时候保存下来了,那么进入内核态后我们怎么传递参数呢。这里就封装出了另一个函数,这个函数就在
syscall.c
里面,你可以把它理解为就是从寄存器里面取出参数的函数 -
现在我们来了解一下什么是trampoline,这个意思直译过来就是蹦床的意思,在我们执行系统调用后,每个系统调用的接口其实就是汇编函数的体现,就像是上面那个
sleep
汇编。执行ecall
指令后,提高运行权限。。。
系统调用图例
-
这张图片真是完美的解释了系统调用的全流程,在执行完
trap
后,就会去执行syscall
函数,此时,先在syscall
函数里面去找到我们要执行的系统调用编号(存储在a7
寄存器里面),然后去执行,执行完后,把返回值存储在a0
寄存器里面,这样,我们就可以实现系统调用的跟踪过程,然后返回。 -
现在我们来分析一下怎么实现
syscall
里面的trace
,这个功能就是去跟踪调用,首先这个系统调用有一个参数,就是一个系统调用的掩码,(1<<SYS_name)
这样的形式。因为syscall
这个函数是所有系统调用的抽象接口,这个函数里面封装了一个系统调用函数的结构体,通过传进来的系统调用号(a7
寄存器)我们就可以直接去在数组里面调用这个函数,然后调用完后把返回值保存在我们的a0
寄存器里面,这个寄存器里面存储的就是我们的返回值,调用完这个函数之后,就返回陷阱处理函数,然后再逐渐的进行返回。