前言
Before you start coding, read Chapter 2 of the xv6 book, and Sections 4.3 and 4.4 of Chapter 4, and related source files:
- The user-space code for systems calls is in
user/user.h
anduser/usys.pl
.- The kernel-space code is
kernel/syscall.h
,kernel/syscall.c
.- The process-related code is
kernel/proc.h
andkernel/proc.c
.
本次实验的主要内容是学习系统调用的工作流程,实现两个简单的系统调用。
实验原文:Lab: System calls (mit.edu)
系统调用基本流程
用户空间
根据实验说明的提示,用户进程执行系统调用时相关的代码在user/user.h
中声明,在 user/usys.pl
中定义。
首先可以看到user/user.h
中有很多预设的系统调用。在Clion中,部分系统调用的左边会显示转到定义的双向箭头,但实际上转向的内容是内核空间对应的代码而非用户空间对应的代码。
实际的定义写在 user/usys.pl
中,在make时perl脚本会被编译为汇编文件usys.S
。
打开编译后的usys.S
可以看到以下内容:
# generated by usys.pl - do not edit
#include "kernel/syscall.h"
.global fork
fork:
li a7, SYS_fork
ecall
ret
......
首先执行#include "kernel/syscall.h"
导入系统调用的编号,然后接下来是各个系统调用的定义,每一个系统调用只有三行指令。
以fork举例,.global fork
表示fork是一个全局函数,li a7, SYS_fork
表示将SYS_fork的值加载到trapframe的a7中(li = load immediate),trapframe是在进程陷入内核态时保存寄存器状态的结构。接下来执行ecall
陷入内核态,在内核完成系统调用后执行ret
结束系统调用。
内核空间
根据实验说明的提示,内核空间关于系统调用的代码写在kernel/syscall.h
和kernel/syscall.c
中。
在用户空间执行完ecall
后,CPU陷入内核态,本质上是将当前运行的进程从执行系统调用的用户进程切换到内核进程,这个转换过程将在实验四trap中详细学习。
切换完成后,执行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]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
从trapframe的a7中获取系统调用编号,然后执行相应的系统调用,并将返回值写入trapframe的a0中,至此完成系统调用,CPU从内核态转回用户态,转换回去的具体过程同样在实验四中学习。
其中syscalls是一个函数指针数组,使用了一种特殊的初始化方式~~(我也是第一次见到)~~,用来自定义初始化数组的成员,数组的长度取决于成员下标的最大值。
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
......
};
syscalls首先与[]
结合,所以它是一个数组;接着与*
结合,所以这个数组的成员是指针;指针的类型是uint64 (void)
,即参数类型为void
,返回值类型为uint64
的函数。
如果无法理解这个数组的类型定义可以参考我的另一篇博文:浅析数组与指针
最后static
修饰syscalls表示它是一个静态变量,只在本文件内可以被访问,类似java中的private
。static
在汇编中的体现就是被修饰的变量在汇编中没有.global
修饰。
关于
static
可以参考这篇博文:C语言static修饰符作用 汇编层面验证
System call tracing (moderate)
任务
了解完系统调用基本流程可以进入正题了,这个实验的目标是实现系统调用trace,参数为一个整数,该整数的第n个二进制位为1时表示追踪编号为n的系统调用**(包括trace本身)**。例如,为了跟踪 fork和exit 系统调用,程序调用 trace(1 << SYS_fork + 1 << SYS_exit)
。
在执行被追踪的系统调用后,打印进程ID、系统调用的名称和返回值。
关键过程
修改进程结构体
为了能够记录进程想要追踪的系统调用,需要在PCB中添加一个成员来达成目的。
# kernel/proc.h
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
......
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
int trace;
uint64 kstack; // Virtual address of kernel stack
......
};
新增的成员trace是进程的私有成员,所以在对它进行操作的时候不需要先获取自旋锁。
关于操作一个成员时是否需要先获取自旋锁我的理解是,只有在进程处于RUNNING状态才会被访问的成员是不需要先获取自旋锁的,因为一个进程在任意时刻最多只可能运行在一个CPU上,那么这些成员不可能同时被两个CPU修改或者访问,自然就没有互斥问题。
实现sys_trace
sys_trace
的逻辑非常简单,只要获取参数并把参数赋值给PCB的trace就完成了。
uint64
sys_trace(void)
{
argint(0, &myproc()->trace);
return 0;
}
**完成任务是次要的,学习知识才是首要任务。**下面来分析一下内核如何获取用户进程执行系统调用时传递的参数。
argint
、argaddr
、argstr
实际上都是简单地调用了argraw
。
static uint64
argraw(int n)
{
struct proc *p = myproc();
switch (n) {
case 0:
return p->trapframe->a0;
case 1:
return p->trapframe->a1;
case 2:
return p->trapframe->a2;
case 3:
return p->trapframe->a3;
case 4:
return p->trapframe->a4;
case 5:
return p->trapframe->a5;
}
panic("argraw");
return -1;
}
查看argraw
的源码可以看到xv6的系统调用最多可以传递六个参数,使用trapframe的a0~a5进行传递。
值得一提的是,在risc-v指令集架构中,调用普通函数时将使用a0~a7共八个寄存器进行参数传递,也就是传递八个参数,当参数超过八个时多出来的参数使用进程栈进行传递,类似x86。当函数返回时,使用寄存器a0和a1来传递返回值。
修改syscall
为了输出系统调用的名称,首先我们要先定义一个数组储存各个系统调用的名字,仿照syscalls的定义方式定义一个数组syscalls_name如下。
static char* syscalls_name[] = {
[SYS_fork] = "fork",
[SYS_exit] = "exit",
[SYS_wait] = "wait",
[SYS_pipe] = "pipe",
......
}
相比于syscalls,这里在定义每个成员时多加了一个等于号,实际上这样才是标准的写法,syscalls的定义方法是GNU的一种扩展语法,允许省略等于号,可以不加等于号,但是Clion会给出警告。
接下来在内核执行完具体的系统调用后,检查PCB的trace中与该系统调用编号相对应的二进制位是否被置为1,如果是,输出相应信息。
p->trapframe->a0 = syscalls[num]();
if(p->trace&(1<<num)){
printf("%d: syscall %s -> %d\n", p->pid, syscalls_name[num], p->trapframe->a0);
}
至此系统调用trace实现完成。
Sysinfo(moderate)
任务
这个任务预设了一个有两个成员的结构体sysinfo,其中nproc表示未使用的进程数,也就是还可以创建的进程数,freemem表示操作系统空闲物理内存的字节数。任务的目标是实现系统调用sysinfo,接受一个sysinfo的指针,并填充它的字段。
关键过程
统计未使用的进程数
非常简单,只需要遍历一遍内核的进程数组,统计state为UNUSED的进程数量即可。
uint64
proc_count()
{
struct proc *p;
uint64 count = 0;
for(p = proc; p < &proc[NPROC]; p++){
if(p->state!=UNUSED)count++;
}
return count;
}
统计空闲物理内存的字节数
观察kalloc.c
中的代码可以发现,kmem是用来管理物理内存的结构,物理内存被分成了若干个大小为PGSIZE(其值为4096)bytes的单位,通常称之为物理页。kmem中的freelist是记录空闲物理页地址的链表,freelist的长度即为空闲物理页的个数,所以遍历链表计算长度,再将长度乘以PGSIZE即可。
uint64
kcount(void)
{
struct run *r;
uint64 count = 0;
acquire(&kmem.lock);
r = kmem.freelist;
while(r){
r=r->next;
count+=PGSIZE;
}
release(&kmem.lock);
return count;
}
需要注意的是内存使用情况在不断变化,统计时必须先获取kmem的互斥锁。
实现sys_sysinfo
用户进程传递了一个地址作为参数,由于虚拟内存的存在,内核和用户进程的地址空间是分离的,不能在内核态下直接向用户进程传递的地址写入数据,而是要先使用用户进程的页表来找到该虚拟地址对应的物理地址,然后才能将sysinfo的值写入。
仿照系统调用fstat,调用copyout来完成从内核空间到用户空间的数据传递。
uint64
sys_sysinfo(void)
{
struct proc *p = myproc();
uint64 addr;
argaddr(0,&addr);
struct sysinfo info;
info.nproc=proc_count();
info.freemem=kcount();
if(copyout(p->pagetable, addr, (char *)&info, sizeof(info)) < 0)
return -1;
return 0;
}
至此系统调用sysinfo实现完成。