主要分析 XV6 系统调用流程及整体框架,总体来说分为 2 个流程
- 注册系统调用
- 使用系统调用
1. 注册系统调用
以read
系统调用为例,其注册的流程主要如下:1.1 增加调用声明
声明接口,给用户进程使用
增加系统调用号int read(int, void*, int);
#define SYS_read 5
1.2 在 user/usys.pl 增加 entry
执行make
指令编译 xv6 的时候会执行usys.pl
脚本,usys.pl
文件内容大致如下: ```c print “# generated by usys.pl - do not edit\n”;
print “#include \”kernel/syscall.h\”\n”;
sub entry { my $name = shift; print “.global $name\n”; print “${name}:\n”; print “ li a7, SYS_${name}\n”; print “ ecall\n”; print “ ret\n”; }
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”);
这一步操作的目的主要是为了在编译 xv6 时,为 `read` 接口生成如下的汇编代码定义:
以 read 为例
read: li a7, SYS_read # 将 SYS_read 系统调用号放到 a7 寄存器 ecall # 执行 ecall 指令进入中断 ret
<a name="TefCb"></a>
### 1.3 注册系统调用表
内核中有一个 `syscalls` 数组,用于关联 **系统调用号** 与 **系统调用 **,因此需要在此增加 `sys_read` 调用。
```c
extern uint64 sys_read(void);
static uint64 (*syscalls[])(void) = {
// some code ...
[SYS_read] sys_read,
};
1.4 实现系统调用
这一步骤就是进一步实现 sys_read
系统调用的逻辑,根据具体需求再具体实现。
可以看到,关于
read
有两个接口:
int read(int, void*, int);
uint64 sys_read(void);
这里为什么要拆分成 2 个接口呢? 个人观点,主要还是由于用户进程无法直接执行内核的接口(权限问题),需要通过中断触发进入内核态才能执行,因此需要封装一个 wrapper function,供用户进程使用,做一次中转。用户进程只需要知道
read
的函数签名,不关心其具体实现。read
主要提供给用户进程使用,进而触发中断执行sys_read
接口,sys_read
才是真正执行逻辑的地方。
2. 使用系统调用
2.1 执行用户态接口
根据 1.1 ,我们在 user.h
中增加了 read
接口,因此用户进程只需要 include 该头文件即可调用该接口。当用户进程调用 read
时,其本质是调用了 usys.pl
生成的汇编函数:
read:
li a7, SYS_read # 将 SYS_read 系统调用号放到 a7 寄存器
ecall # 执行 ecall 指令进入中断
ret
2.2 中断进入内核态
在 2.1 的汇编函数中,其主要干了 2 件事:
- 保存系统调用号到
a7
寄存器 - 执行
ecall
指令
ecall
指令会触发中断,进入内核,内核对在用户态发生的中断的处理大致如下:
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->trapframe->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->trapframe->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 {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
可以看到,17 ~ 31 行专门处理,由于 ecall
指令触发的中断,其中断号为 8。主要执行了 syscall
函数
2.3 处理系统调用中断
处理系统调用中断的接口很简单,流程大致如下:
- 获取
a7
寄存器中的 系统调用号 - 根据调用号,在
syscalls
数组中找到对应的系统调用函数 执行系统调用函数,返回值保存在
a0
寄存器void syscall(void) { int num; struct proc *p = myproc(); num = p->trapframe->a7; if(num > 0 && num < NELEM(syscalls) && syscalls[num]) { p->trapframe->a0 = syscalls[num](); } else { printf("%d %s: unknown sys call %d\n", p->pid, p->name, num); p->trapframe->a0 = -1; } }
这里可能会有疑惑,系统调用的参数是如何传递的,这里可以简单了解下 risc-v 的 calling-convention,其参数传递与 x86 类似,存放在寄存器中,risc-v 的前 5 个参数会存放在 a0 ~ a5 寄存器中。
3. 小结
小结一下 xv6 系统调用的注册与使用流程,以
read
为例:生成
- 增加 系统调用 声明,供用户进程使用,如
int read(int, void*, int);
- 增加 系统调用号,如
#define SYS_read 5
usys.pl
增加entry("read")
,生成read
接口的汇编代码,从而用户进程在执行read
后可以触发中断,进入内核态。- 定义真正执行逻辑的 系统调用
uint64 sys_read(void);
,这里的格式都是 sys_xxx,返回值都为 uint64,且都无参数,这是因为参数都存放在寄存器中。 注册系统调用,在
syscalls
数组增加 系统调用号SYS_read
与 系统调用sys_read
的对应关系执行
用户进程执行
read
,保存系统调用号到a7
寄存器,执行ecall
指令进入中断,陷入内核态- 内核处理中断,根据中断号确定是执行系统调用触发的中断(
ecall
指令) - 根据
a7
寄存器的中断号,在syscalls
数组中找到系统调用,并执行之