主要分析 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数组中找到系统调用,并执行之
