XV6-Lab-2 实验报告: Lab: system calls

摘要

本篇是 xv6 的第 2 个 lab,通过增加自定义的系统调用,了解系统调用的链路及原理。就实验内容本身来说,难度不高,也只包含两个 exercise。

原理及分析

我们知道,系统调用是操作系统内核提供的接口,提供封装好的底层功能。然而,系统调用的函数或者地址不是直接暴露给用户的。内核只是提供了一个统一的系统调用入口,称为 ECALL。ECALL 接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行 ECALL 指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的系统调用。这样的设计有很多优点,最直观的是,可以在 ECALL 内调用系统调用前,进行参数检查或者权限检查,避免非法的调用。

在 xv6 中,这些 “系统调用号” 声明于 kernel/syscall.h,例子如下:

1
2
3
4
// System call numbers
#define SYS_fork 1
#define SYS_exit 2
// ...

kernel/syscall.c 中,syscall 根据调用号执行对应的系统调用,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}
}

trapframe 保存了程序陷入内核前的寄存器信息,系统调用是一种主动的陷入内核的做法。在上述 syscall 函数中,读取了调用号,若合法则跳转并设置返回值。syscalls 是一个数组,将系统调用号和具体的函数进行对应。这些函数实现在不同的文件中:进程相关的调用(如 fork),实现在 kernel/sysproc.c 中,文件相关的调用(如 fstat, read, write),实现在 kernel/file.c 中。

在 C 语言中,函数参数里的 void,表明函数不接收任何参数。如果没有 void 的话,void syscall() 表明函数可以接收任意参数。

在用户态,系统调用在 user/user.h 中声明,用于链接。user/usys.pl 是所谓的系统调用桩(stub),通过 kernel/syscall.h,将系统调用转化为对应调用号的 ECALL 的调用。该文件会被编译为 user/usys.S,片段如下。能够看出是声明的系统调用代码,通过 RISC-V 的 ecall 指令陷入内核。ecall 会跳转到异常处理的地址,这个地址是通过硬件编程写入的。

1
2
3
4
5
6
7
8
9
10
11
12
# generated by usys.pl - do not edit
#include "kernel/syscall.h"
.global fork
fork:
li a7, SYS_fork # 设置系统调用号
ecall
ret
.global exit
exit:
li a7, SYS_exit
ecall
ret

如此一来,整个系统调用的流程就串起来了:

  1. user/user.h 中的声明
  2. user/usys.pl 的系统调用桩,设置系统调用号
  3. 中断处理逻辑
  4. kernel/syscall.csyscall 函数
  5. 对应的系统调用函数

相应地,如果需要新加系统调用,也得按这个流程来。os 处理中断的逻辑与系统调用不直接相关,可以后面再理解。

Exercises

trace

要求实现一个系统调用,能够根据一个 mask,监控某个进程对应系统调用执行结果,并打印。对于该进程创建的子进程,监控同样生效。

简单思考之后,不难想到这个调用需要更新一个进程级别的状态,在系统调用结束时,根据这个状态决定是否打印相关信息;在创建子进程时,需要传播这个状态。进程级的状态,最简单的是直接保存在进程结构体中,kernel/proc.c 中,proc 结构体维护了进程状态,包含锁、pid、页表等。可以在这个结构中新加一个字段,保存这个 mask。系统调用函数如下:

1
2
3
4
5
6
7
8
9
10
11
// trace system calls of given mask
uint64
sys_trace(void)
{
int mask;
if (argint(0, &mask) < 0)
return -1;
myproc()->tracemask = mask;
return 0;
}

需要注意的是,内核里系统调用的传参机制比较特殊,不是声明在函数里,而是通过 argint, argaddr 这种函数读参,猜测与中断的机制有关。

然后在 syscall 函数中的系统调用执行完毕后,根据 mask 判断是否打印信息即可。这里还需要维护一个系统调用号到系统调用名的数组 sysname,方便打印调用名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
}
if (p->tracemask & (1 << num))
{
printf("%d: syscall %s -> %d\n", p->pid, sysname[num], p->trapframe->a0);
}
}

不要忘记在 fork 调用中传播这个 mask。另外,用户态系统调用的声明和桩代码比较简单,这里就略去了。

sysinfo

要求实现一个系统调用,能够返回系统中的可用内存数量以及运行中的进程数量。难点有几处。

首先,如何计算可用内存大小?参考 kernel/kalloc.c,发现有一个名为 kmem 的变量保存了空闲物理页信息。

1
2
3
4
5
6
7
8
struct run {
struct run *next;
};

struct {
struct spinlock lock;
struct run *freelist;
} kmem;

通过计算这个 freelist 链表的长度,乘以单个页面的大小,即可得到可用内存的大小,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
int free_memory(void)
{
int amount = 0;
acquire(&kmem.lock);
struct run *r = kmem.freelist;
for (; r; r = r->next)
{
amount += PGSIZE;
}
release(&kmem.lock);
return amount;
}

其次,如何计算运行中的进程数量。参考 kernel/proc.c,发现其声明了一个大小为 64 的 proc 数组,猜测其应该是支持的最大进程数量。

1
struct proc proc[NPROC]; // 64

allocproc 函数印证了这一点,该函数申请一个新的进程,是通过遍历上述数组,找到 p->state == UNUSED 实现的。因此可以通过遍历数组,统计得到运行中的进程数量,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// num of processes

int num_proc()
{
struct proc *p;
int cnt = 0;
for (p = proc; p < &proc[NPROC]; p++)
{
acquire(&p->lock);
if (p->state != UNUSED)
{
cnt++;
}
release(&p->lock);
}
return cnt;
}

上面两个函数的声明需要加在 kernel/defs.h 中,方便调用。然后,可以在 kernel/sysproc.c 中新建系统调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
uint64
sys_info(void)
{
uint64 addr;
if (argaddr(0, &addr) < 0)
return -1;
struct sysinfo info;
info.freemem = free_memory();
info.nproc = num_proc();
if (copyout(myproc()->pagetable, addr, (char *)&info, sizeof(info)) < 0)
return -1;
return 0;
}

copyout 用于将内核的数据拷贝到用户态,参考 fstat 调用可以了解它的用法。

最后,完成用户态的相关代码即可。

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
== Test trace 32 grep == 
$ make qemu-gdb
trace 32 grep: OK (4.8s)
== Test trace all grep ==
$ make qemu-gdb
trace all grep: OK (0.8s)
== Test trace nothing ==
$ make qemu-gdb
trace nothing: OK (0.8s)
== Test trace children ==
$ make qemu-gdb
trace children: OK (9.9s)
== Test sysinfotest ==
$ make qemu-gdb
sysinfotest: OK (1.7s)
== Test time ==
time: OK
Score: 35/35