介绍
先看了一下所需要的系统调用方面的知识,然后花了两天时间做完了实验二,实验手册在 syscall,我的实验代码在 github。
实验二的目的是让我们实现两个系统调用函数,先来简单介绍一下前置知识。
操作系统的主要功能就是管理硬件,避免程序直接和硬件进行交互,而是通过内核来操作硬件,例如,使用CPU、读取内存和写入磁盘等操作。操作系统具有两大特性:隔离性(Isolation)和防御性(Defensive)。
-
隔离性主要目的是分离程序和硬件资源,避免程序直接使用硬件资源。如果不进行隔离,无法保证程序之间的切换,且一个程序可能会读写其他程序的内存,导致其他程序无法继续运行下去。隔离性能够很好地保证 cpu 的多路复用(进程切换、cpu 分时使用)和进程之间内存的隔离(虚拟内存,每个进程有自己的内存)。
-
防御性是指,并不是所有的指令进程都可以执行的。操作系统将指令划分了四个等级(R0-R3,其中R0的等级最高,可以执行任何指令),同时划分了两个状态,用户态和内核态。用户态执行的程序的等级较低,可以执行的指令有限;而内核态程序的等级较高,可以执行任何指令。这样可以避免恶意程序的攻击。
系统调用的功能就是用户态的程序需要执行等级更高的指令时,需要先切换到内核态,在内核态执行完后,再切换到用户态。过程如下图所示:
切换时,使用的是 cpu 特定的中断符号。所有的系统调用都是通过该中断进入到内核态中,并将所需要的执行的指令码传入到中断内核态中。内核态的程序取出指令码,执行相应的指令,然后返回到用户态。
个人觉得,如果理解了这篇文章内的图 MIT-6.S081-2020实验(xv6-riscv64)二:syscall ,就可以很好地做实验了。大概讲了一下实验中系统调用的流程。
实验内容
实验内容是实现两个系统调用函数,分别是 System call tracing 和 Sysinfo。
任务一(trace)
首先,需要知道的是,用户态的代码都在 user
文件夹内,内核态的代码都在 kernel
文件夹内。在上一个实验中,主要是在用户态内实现进程之间的创建和通信等功能,因此只需要在 user
文件夹内写代码。在这个任务中,user/trace.c
文件已经给出,重点是内核态部分。
第一步,Makefile
根据实验指导书,首先需要将 $U/_trace
添加到 Makefile 的 UPROGS 字段中。
第二步,添加声明
然后需要添加一些声明才能进行编译,进而启动 qemu。需要以下几步:
-
可以看到,在 user/trace.c 文件中,使用了 trace 函数,因此需要在 user/user.h 文件中加入函数声明:
int trace(int);
; -
同时,为了生成进入中断的汇编文件,需要在
user/usys.pl
添加进入内核态的入口函数的声明:entry("trace");
,以便使用ecall
中断指令进入内核态; -
同时在 kernel/syscall.h 中添加系统调用的指令码,这样就可以编译成功了。
说明:
- 在生成的 user/usys.S 文件中可以看到,汇编语言
li a7, SYS_trace
将指令码放到了 a7 寄存器中。在内核态kernel/syscall.c
的 syscall 函数中,使用p->trapframe->a7
取出寄存器中的指令码,然后调用对应的函数。
第三步,实现 sys_trace() 函数
最主要的部分是实现 sys_trace() 函数,在 kernel/sysproc.c 文件中。目的是实现内核态的 trace() 函数。我们的目的是跟踪程序调用了哪些系统调用函数,因此需要在每个 trace 进程中,添加一个 mask 字段,用来识别是否执行了 mask 标记的系统调用。在执行 trace 进程时,如果进程调用了 mask 所包括的系统调用,就打印到标准输出中。
首先在 kernel/proc.h 文件中,为 proc 结构体添加 mask 字段:int mask;
。
然后在 sys_trace() 函数中,为该字段进行赋值,赋值的 mask 为系统调用传过来的参数,放在了 a0
寄存器中。使用 argint()
函数可以从对应的寄存器中取出参数并转成 int 类型。
argint()
的函数声明在 kernel/defs.h 中,具体实现在 kernel/syscall.c 中。除了取出 int 型的数据函数外,还有取出地址参数的 argaddr 函数,就是将参数以地址的形式取出来,第二个任务需要使用。
sys_trace() 函数的实现:
uint64 sys_trace(void)
{
int mask;if(argint(0, &mask) < 0) return -1;myproc()->mask = mask;return 0;
}
第四步,跟踪子进程
需要跟踪所有 trace 进程下的子进程,在 kernel/proc.c 的 fork()
代码中,添加子进程的 mask:
np->mask = p->mask;
第五步,打印信息
所有的系统调用都需要通过 kernel/syscall.c 中的 syscall()
函数来执行,因此在这里添加判断:
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]();if ((1 << num) & p->mask) {
printf("%d: syscall %s -> %d\n", p->pid, syscalls_name[num], p->trapframe->a0);}} else {
printf("%d %s: unknown sys call %d\n",p->pid, p->name, num);p->trapframe->a0 = -1;}
}
由于系统调用的函数名称没办法直接获取,因此创建了一个数组,用于存储系统调用的名称,syscalls_name 代码如下:
char* syscalls_name[] = {
[SYS_fork] "fork",
[SYS_exit] "exit",
[SYS_wait] "wait",
[SYS_pipe] "pipe",
[SYS_read] "read",
[SYS_kill] "kill",
[SYS_exec] "exec",
[SYS_fstat] "fstat",
[SYS_chdir] "chdir",
[SYS_dup] "dup",
[SYS_getpid] "getpid",
[SYS_sbrk] "sbrk",
[SYS_sleep] "sleep",
[SYS_uptime] "uptime",
[SYS_open] "open",
[SYS_write] "write",
[SYS_mknod] "mknod",
[SYS_unlink] "unlink",
[SYS_link] "link",
[SYS_mkdir] "mkdir",
[SYS_close] "close",
[SYS_trace] "trace",
};
此外,还需添加系统调用入口 extern uint64 sys_trace(void);
和 [SYS_trace] sys_trace,
到 kernel/syscall.c 中。
其中的 p->trapframe->a0
存储的是函数调用的返回值。
任务二(sysinfo)
与任务一相似,也是实现一个系统调用。
第一步,添加 Makefile
根据实验指导书,首先需要将 $U/_sysinfotest
添加到 Makefile 的 UPROGS 字段中。
第二步,添加声明
同样需要添加一些声明才能进行编译,启动 qemu。需要以下几步:
-
在 user/user.h 文件中加入函数声明:
int sysinfo(struct sysinfo*);
,同时添加结构体声明struct sysinfo;
; -
在
user/usys.pl
添加进入内核态的入口函数的声明:entry("sysinfo");
; -
同时在 kernel/syscall.h 中添加系统调用的指令码。
第三步,获取内存信息
可以在 kernel/sysinfo.h 中查看结构体 struct sysinfo
, 其中只有两个字段,一个是保存空闲内存信息,一个是保存正在运行的进程数目。
两个字段的信息都需要自己写函数调用来获取,先来获取内存信息。内存信息的处理都写在 kernel/kalloc.c 文件中了,内存信息以链表的形式存储,每个节点存储一个物理内存页。
从 kfree 函数中可以发现,每次创建一个页时,将其内容初始化为1,然后将它的下一个节点指向当前节点的 freelist,更新 freelist 为这个新创建的页。也就是说,freelist 指向最后一个可以使用的内存页,它的 next 指向上一个可用的内存页。
因此,我们可以通过遍历所有的 freelist 来获取可用内存页数,然后乘上页大小即可。添加获取内存中剩余空闲内存的函数:
uint64 free_mem(void)
{
struct run *r = kmem.freelist;uint64 n = 0;while (r) {
n++;r = r->next;}return n * PGSIZE;
}
第四步,获取进程数目
所有的进程有关的操作都保存在 /kernel/proc.c 文件中,其中的 proc 数组保存了所有进程。进程有五种状态,我们只需要遍历 proc 数组,计算不为 UNUSED 状态的进程数目即可,函数为:
int n_proc(void)
{
struct proc *p;int n = 0;for (p = proc; p < &proc[NPROC]; p++) {
if (p->state != UNUSED)n++;}return n;
}
第五步,声明和调用
在 kernel/defs.h 中添加上面这两个函数的声明:
uint64 free_mem(void);
int n_proc(void);
然后在 kernel/sysproc.c 中的 sys_sysinfo 函数进行调用:
uint64 sys_sysinfo(void)
{
struct sysinfo info;uint64 addr;struct proc* p = myproc();if(argaddr(0, &addr) < 0) {
return -1;}info.freemem = free_mem();info.nproc = n_proc();if (copyout(p->pagetable, addr, (char*)&info, sizeof(info)) < 0) {
return -1;}return 0;
}
需要注意的是,这里使用 copyout 方法将内核空间中,相应的地址内容复制到用户空间中。这里就是将 info 的内容复制到进程的虚拟地址内,具体是哪个虚拟地址,由函数传入的参数决定(addr 读取第一个参数并转成地址的形式)。
总结
做完实验,确实对系统调用的理解更深刻了。很赞!
文章同步在知乎。
参考文献
- MIT 6.S081 2020 Lab2 system calls讲解
- 【MIT-6.S081-2020】Lab2 syscall
- MIT-6.S081-2020实验(xv6-riscv64)二:syscall