0.Briefly Speaking
这是6.S081的第二个实验,目的主要是自己实现一些系统调用,上一个实验更多的是使用已有的设施去实现一些功能各异的程序,这需要我们对系统调用的过程有更加深入的理解。当然整个过程要全部了解,可能还需要做完Lab4:Traps Lab,在那个实验中将会具体探究xv6是如何借助陷阱来实现系统调用的。本实验的实验指导书在这里。
因为系统调用的具体实现逻辑在内核内部,所以在实现一个系统调用时需要修改和增加一些东西,帮助内核能够正确索引到对应的系统调用,具体来说添加一个系统调用有以下文件需要修改:
1.首先,系统调用必须提供用户态下的调用接口,所以必须在user.h文件下添加用户态函数原型
2.在usys.pl中添加一个stub,我们在Makefile中检查一个这个文件,发现usys.pl会用来生成usys.S文件,这个文件专门用来生成汇编代码段来将对应的系统调用号读入a7寄存器:
$U/usys.S : $U/usys.pl
perl $U/usys.pl > $U/usys.S
3.接下来需要在kernel/syscall.h文件中加入一个新的系统调用号,内核需要借助这个调用号索引到对应的内核例程。还需要在kernel/syscall.c文件中的syscall中加入从系统调用号到函数的索引关系,如下所示:
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
[SYS_trace] sys_trace, // 注意新加入的两个系统调用和对应关系
[SYS_sysinfo] sys_sysinfo,
};
4.最后在sysproc文件中实现对应的系统调用(sys_trace,sys_sysinfo等),完成具体的功能。
以上的4个步骤是添加一个系统调用所必需的步骤,在完成本实验时要注意检查。在下面的记录中,就不再一一将上面的步骤描述出来,而是聚焦于问题本身的实现思路。
1.System call tracing (moderate)
第一个实验是实现一个可以追踪任意系统调用的系统调用(看起来有点拗口…),当设置了追踪掩码mask之后,对应编号的系统调用在触发时都会被追踪。并打印出如下的信息:
进程ID号:syscall xxx -> 返回值
实验书中指明,xv6已经实现了一个文件trace.c,这个文件和我们在上一个实验中实现的用户态程序是一样的,trace.c会调用trace系统调用来跟踪后面具体执行的命令行,所以这里也需要将_trace加入UPROGS字段,否则找不到这个应用程序。下面来具体实现一下。
其实指导书中暗示的都差不多了,我们就一步步照指示做事就好:
1.首先在表示每个进程的结构体proc(kernel/proc.h)中加入一个表示用于进程追踪的掩码,见下面的结构体尾部:
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// 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.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
int TraceMask; // <在这里加入一个新的属性,用于表示要追踪的进程掩码>
};
2.然后就是在sysproc.c的文件中真正地实现sys_trace的逻辑,这里涉及到用户态向内核态传递参数,这个过程中需要使用一些函数(argint, argaddr,argstr等)从trapframe中将用户态下传递的参数读取到内核态。所以在内核态下的系统调用函数,都是没有参数的,因为它们的参数都是从用户态下读进来的。上述的这些函数的关系如下,本质上都调用了argraw来解析参数:
argraw的逻辑非常简单,就是从对应的trapframe中返回a0-a7寄存器,因为根据RISC-V的calling convention,头几个寄存器是用来存放函数参数的。
// 以64位无符号整数返回寄存器a0-a7中的值
// 当n大于5的时候,函数错误,陷入panic
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;
}
至于用户态下的参数如何被放到trapframe里,发生系统调用后又如何从用户态过渡到内核态的,这个过程后面做到对应实验时再分析源码。sys_trace的实现非常简单,直接将用户态下传进来的追踪掩码存入当前进程的TraceMask(之前刚加入的变量属性)即可。
uint64
sys_trace(void)
{
int n;
if(argint(0, &n) < 0) // 将trapframe->a0读入
return -1;
myproc()->TraceMask = n; // set the TraceMask in proc struct
return 0;
}
3.因为trace系统调用还要能够追踪当前进程的子进程,所以在进程调用fork时,子进程应该将TraceMask一并复制过去,加入的代码我已经在下面用中文标出了。
int
fork(void)
{
int i, pid;
struct proc *np;
struct proc *p = myproc();
// Allocate process.
if((np = allocproc()) == 0){
return -1;
}
// Copy user memory from parent to child.
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
freeproc(np);
release(&np->lock);
return -1;
}
np->sz = p->sz;
// <在这加一行代码,将TraceMask复制到子进程中去>
np->TraceMask = p->TraceMask;
// copy saved user registers.
*(np->trapframe) = *(p->trapframe);
// Cause fork to return 0 in the child.
np->trapframe->a0 = 0;
// increment reference counts on open file descriptors.
for(i = 0; i < NOFILE; i++)
if(p->ofile[i])
np->ofile[i] = filedup(p->ofile[i]);
np->cwd = idup(p->cwd);
safestrcpy(np->name, p->name, sizeof(p->name));
pid = np->pid;
release(&np->lock);
acquire(&wait_lock);
np->parent = p;
release(&wait_lock);
acquire(&np->lock);
np->state = RUNNABLE;
release(&np->lock);
return pid;
}
4.最后实现具体的追踪功能,因为追踪时需要打印出具体的系统调用名称,所以在syscall.h中还需要加一个系统调用号到名称的映射表:
static char* SyscallName[] = {
[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",
[SYS_sysinfo] "sysinfo",
};
然后就是修改执行系统调用的具体函数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]();
// 如果当前系统调用需要被追踪,则打印出对应的调用信息
// 用&运算判断对应的位是否同为1
if(p->TraceMask & (1 << num)){
printf("%d: syscall %s -> %d\n", p->pid, SyscallName[num], p->trapframe->a0);
}
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
到此为止,以上4步就已经很好地完成了这个任务,验证一下实现是否正确:
这就是Lab2第一个小任务的具体实现思路。
2.Sysinfo (moderate)
这个任务是实现一个系统调用统计一些系统信息,包括:空闲内存的数量和不处于UNUSED状态的进程数量。和trace一样的,这里提供了一个用户态下的应用程序user/sysinfotest.c用来检测我们编写的系统调用的正确性。可以阅读一下测试程序的逻辑,学习一下,这里不再展开了。
还是要强调一下,不要忘记实现一个系统调用的必经4步骤,这些在最上面已经列出了,这里不再展开赘述。sysinfo暴露在用户态下的编程接口原型如下,需要在user.h中加入这个接口:
int sysinfo(struct sysinfo *);
struct sysinfo是一个结构体,它定义在kernel/sysinfo.h中,定义如下:
// sysinfo结构体中定义了空闲内存和不处于UNUSED态进程的数量
struct sysinfo {
uint64 freemem; // amount of free memory (bytes)
uint64 nproc; // number of process
};
下面直接进入到系统调用的实现,首先给出sysinfo系统调用整体的实现思路:
uint64
sys_sysinfo(void)
{
uint64 UserSysinfo;
struct sysinfo KernelSysinfo;
if(argaddr(0, &UserSysinfo) < 0)
return -1;
KernelSysinfo.freemem = kcountFreeMemory(); // 统计空闲内存的量
KernelSysinfo.nproc = countProc(); // 统计状态不是UNUSED的进程数量
/* 将sysinfo结构体拷贝回用户态下 */
struct proc *p = myproc();
if(copyout(p->pagetable, UserSysinfo, (char*)&KernelSysinfo, sizeof(struct sysinfo)) < 0)
return -1;
return 0;
}
首先通过argaddr系统调用,将用户传进来的指向结构体的指针读入变量UserSysinfo,记录这个地址是因为后面还要使用copyout函数将此变量从内核态拷贝回用户态。其中分别调用了两个函数分别用来计算空闲内存的数量和进程数量,下面来具体看看这两个函数的实现:
1.kcountFreeMemory函数
在实现这个函数之前,需要先阅读其他内存管理函数的实现,大致弄清xv6中物理内存的管理机制。
首先来看内存管理需要的两个数据结构,run和kmem。稍微有些数据结构基本功的人就可以看出这就是一个很简单的链表,事实上,xv6就是将空闲内存块串成了一个链表来管理,如下所示:
// 一个链表,这个链表每个结点都指向了一个空闲内存块的开头地址
struct run {
struct run *next;
};
// 一把自旋锁,防止并发时的访问冲突
// 空闲内存块组成的列表
struct {
struct spinlock lock;
struct run *freelist;
} kmem;
接下来主要阅读一下kmalloc和kfree函数的实现:
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
// 声明一个run类型的指针r
struct run *r;
// 在从空闲链表中获取空闲内存块时,首先加一把自旋锁
acquire(&kmem.lock);
r = kmem.freelist;
// 如果r不为空,则空闲链表还没有到达结尾
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
// 随意填一些数据将当前页面填满
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
// 返回新申请的页面
return (void*)r;
}
kmalloc的实现思路非常简单,就是从一个空闲链表中取一块空闲内存,稍作初始化之后返回。
接下来看看kfree函数,也非常简单,就是将空闲页面使用“头插法”插入回空闲列表:
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
// 此函数一次释放一整个页面(4096bytes)大小的内存
void
kfree(void *pa)
{
struct run *r;
// 如果pa不能被页面大小整除,或范围超出合法边界,则陷入panic
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
// 随便填一些数字将准备收回的页面填充
memset(pa, 1, PGSIZE);
// 接下来这段代码就是使用“头插法”来将空闲节点插回链表
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
看懂了基本的物理内存管理方式,下面就可以看看kcountFreeMemory的实现,基本思路就是遍历freelist链表直至结尾即可,直接贴出代码:
uint64
kcountFreeMemory()
{
struct run* r;
uint64 FreeMemory = 0;
acquire(&kmem.lock);
for(r = kmem.freelist ; r ; r = r->next)
FreeMemory += PGSIZE;
release(&kmem.lock);
return FreeMemory;
}
2.countProc函数
这个函数最重要的就是在kernel/proc.c文件中,学习如何遍历当前进程列表。事实上,在同一文件下的其他函数中,经常能看到类似于以下代码的实现:
for(p = proc; p < &proc[NPROC]; p++) {
...
}
这里的proc[NPROC]就是xv6的进程组,以上代码就完成了所有进程的遍历。据此,我们可以写出如下代码来统计进程组中正在被使用的进程,如下所示:
uint64
countProc()
{
struct proc* p;
uint64 NumberOfProcess = 0;
for(p = proc ; p < &proc[NPROC] ; p++){
acquire(&p->lock);
if(p->state != UNUSED)
NumberOfProcess++;
release(&p->lock);
}
return NumberOfProcess;
}
至此我们就已经完整实现了sysinfo的所有功能,在此测试一下:
表明上述实现是正确的。
3.小结
本实验的目的是在xv6中实现系统调用,这个过程中涉及到一些xv6中系统调用的基本机制,如系统调用号、如何从用户态向内核传参数、如何将结果从内核空间再传回用户空间(copyout)等。但要更加深入的理解系统调用的全过程,还需要在后面继续完成实验和研究内核源码。