0%

xv6-2020-lab2解析

先导知识

系统调用中,我们是如何传递参数的?

在x86-32中:通过中断实现(int 0x80)。把对应的系统调用的号码放入eax寄存器中,具体的参数放到指定的寄存器中。更为具体的代码:

1
2
3
4
5
6
pushl  ebx          # 把寄存器的内容丢到用户栈里面
pushl ecx
movl $2, %eax # 在32位系统中,2代表着fork
int $0x80 # 执行系统中断
popl ecx # 把寄存器的内容从栈中弹出
popl ebx

INT是用于x86处理器的汇编语言指令,该指令会生成软件中断。后面跟的0x80可以理解为指代是一个具体的中断处理程序。在Linux中,这个中断处理程序恰好就是内核。更加具体的中断号码对应的操作可以看这里

在x86-64中:通过syscall指令实现(其实也可以通过int 80,但是推荐别那么做)。同样把对应的系统调用放入eax寄存器中,具体的参数放到指定的寄存器中(和32位不同)。具体为什么从32位系统过度到64位系统的时候,会有这种变化,主要还是因为有了新的指令的支持,所以可以重新用新的机制。具体的变化的更为详细的信息可以阅读这份文档

Lab2:系统调用

这部分自己为xv6写入两个系统调用。

用户空间的代码在user/user.huser/usys.pl中。其中user.h声明了所有系统调用的原型,同时还声明了一些库函数的声明;而usys.pl这个文件则是一个perl脚本,用来生成usys.S,简单观看这个脚本文件可以发现,本质上就是把对应的系统调用(SYS_xxx)放入到寄存器a7中,然后调用ecall的一个过程。比如如果希望执行一个fork,那么实际上的汇编代码是:

1
2
3
4
5
6
7
8
9
10
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");

所以虽然是一个精简指令集RISC-V,但是其实和x86-32的做法非常像。你可以把ecall理解成int 0x80h

内核空间的代码则是在kernel/syscall.hkernel/syscall.c,其中头文件里面仅仅是一些系统调用号码,而c文件里面则是实现。c文件里面最最核心的实现是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void
syscall(void)
{
int num;
struct proc *p = myproc(); // 获取当前的进程

num = p->trapframe->a7; // 从当前进程的a7寄存器拿出系统调用号码
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num](); // 执行对应的以sys_开头的程序,然后把返回结果放到a0寄存器中
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}

可以看到系统调用的数字保存在当前进程的p->trapframe->a7中,然后返回值放在了p->trapframe->a0,其中的syscalls是一个函数指针的数组,所以可以执行。

这里可以看到,执行系统调用的时候,用户进程并没有向内核传递任何参数,仅仅传递了一个系统调用号码,而且是通过寄存器传递的。而系统调用结束之后的结果是存放在a0这个寄存器中。

除了用户空间和内核空间,这里还需要简单了解一下进程的数据结构——kernel/proc.hkernel/proc.c

trace(中等)

自己实现一个trace的系统调用,能够追踪感兴趣的系统调用。这里的这个系统调用需要接受一个参数,然而从上面我们可以发现系统调用并不能传递参数,那要怎么实现呢?

更加具体来说,因为read在系统中的调用号码是5,所以如果用了trace 32 grep hello README,则说明需要监控grep这个命令执行过程中所使用的read调用。

首先我自己的思路是这样的:

  1. trace是怎么追踪那些父子进程的呢?结合上面给出的需要去了解进程的数据结构这一提示,我决定在进程的数据结构中加入一个char mask[],专门用来记录进程感兴趣的系统调用,并且在fork的时候由父进程传递给子进程。
  2. trace是需要怎么获取对应的那个参数呢?因为系统调用又不是直接函数调用,我要怎么获取对应的mask参数呢?我这里是看到别的系统调用使用了argint这个函数,我也照着做了。

好了,解决上面两个问题,我觉得差不多可以试试看了。

首先是系统调用的声明,这个真的就是照葫芦画瓢,这里比较关键的是首次调用这个trace系统调用的时候,我们将这个参数转化成对应的mask数组,并且放入到当前的进程中对应的数据结构中,然后修改fork,这样就可以在进程之间使用fork的时候进行传递了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
uint64
sys_trace(void)
{
int n;
// 通过argint函数来获取trace的参数,也就是mask,这里为了区分所以叫n
if (argint(0, &n) < 0)
{
return -1;
}
struct proc *p = myproc();
// 现在设置进程的mask
char *mask = p->mask;
int i = 0;
while (n > 0 && i < 23)
{
if (n % 2 == 0)
{
mask[i++] = '0';
}
else
{
mask[i++] = '1';
}
n >>= 1;
}
return 0;
}

这里的重点应该是argint,我们来看看它是如何实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Fetch the nth 32-bit system call argument.
int
argint(int n, int *ip)
{
*ip = argraw(n);
return 0;
}

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来找到对应的寄存器,并且取出对应的数据。而且最多只能支持6个参数。

然后还有一个比较重要的地方就是在执行系统调用的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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(p->mask[num] =='1' && p->mask[0] !=0){
printf("%d: syscall %s -> %d\n",p->pid,system_call_name[num],p->trapframe->a0);
}
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}

首先获取了对应系统调用的数字,然后去对应的系统调用函数数组里面找到对应的函数,然后执行。这里判断下如果对应位置上的mask是1,那么就说明监听了对应的系统调用,而且还需要判断确实启动了监听这个动作,即p->mask[0] !=0

trace调用的过程

如果你仔细查看一下代码,会发现很奇怪,全部的代码中,只有trace.c(调用trace这个系统调用的代码)和user.h(仅仅声明了一下trace这个函数),那trace的实现到底在哪里?我们明明写的是sys_trace(),然而似乎找遍代码也没有发现哪里使用了这行代码啊?奥秘就在usys.pl这个perl文件中。

1
2
3
4
5
6
7
8
sub entry {
my $name = shift;
print ".global $name\n";
print "${name}:\n";
print " li a7, SYS_${name}\n";
print " ecall\n";
print " ret\n";
}

这里定义了一个函数,可以看到就是一个perl函数,最重要的应该就是第一行,它会把第一个参数赋值给$name,然后生成对应的汇编代码,比如我们的entry就会通过一定的手段生成对应的汇编代码:

1
2
3
4
5
.global trace
trace:
li a7, SYS_trace
ecall
ret

可以看到把系统调用号放到了a7这个寄存器里,并且调用了ecall指令。

sysinfo(中等)

新增一个系统调用sysinfo,用来收集当前系统的运行状态。这个系统调用接受一个指针,指向下面的数据结构:

1
2
3
4
struct sysinfo {
uint64 freemem; // amount of free memory (bytes)
uint64 nproc; // number of process
};

然后执行对应的操作,把freememnproc填充好。其中nproc的数量是,只要一个进程的状态不是UNUSED,就进行统计。

还是两个问题:

  • 怎么样才能获取当前的内存剩余的空间?同样怎么获取符合要求的进程呢?提示中给了去了解一下kernel/kalloc.ckernel/proc.c
  • 怎么样把传递的参数给返回出去,毕竟系统调用不是普通的函数。那可不可以直接把这个结构体的地址写入trapframe->a0里面呢?通过提示中去了解一下kernel/sysfile.c中的sys_fstat()kernel/file.c中的filestat()这两个函数,看看它们是怎么用的copyout(),然后依葫芦画瓢。

获取内存空间和进程状态

首先是遍历进程这部分,可以看到是这样子做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Print a process listing to console.  For debugging.
// Runs when user types ^P on console.
// No lock to avoid wedging a stuck machine further.
void
procdump(void)
{
static char *states[] = {
[UNUSED] "unused",
[SLEEPING] "sleep ",
[RUNNABLE] "runble",
[RUNNING] "run ",
[ZOMBIE] "zombie"
};
struct proc *p;
char *state;

printf("\n");
for(p = proc; p < &proc[NPROC]; p++){
if(p->state == UNUSED)
continue;
if(p->state >= 0 && p->state < NELEM(states) && states[p->state])
state = states[p->state];
else
state = "???";
printf("%d %s %s", p->pid, state, p->name);
printf("\n");
}
}

其中的proc是一个结构体数组,所以proc就是第一个进程,最后一个进程的地址就是&proc[NPROC]

所以依葫芦画瓢的结果是:

1
2
3
4
5
6
7
8
9
10
11
uint64
get_proc(void){
uint64 sum = 0;
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++){
if(p->state != UNUSED){
sum++;
}
}
return sum;
}

其次是获取剩余可用内存的部分,这里也是照葫芦画瓢:

1
2
3
4
5
6
7
8
9
10
uint64
get_freemem(void){
struct run *r = kmem.freelist;
int sum = 0;
while(r){
sum++;
r = r->next;
}
return sum * PGSIZE;
}

也是很简单的。

怎么把数据传出去

照理先分析一下提示中给出的函数。

1
2
3
4
5
6
7
8
9
10
uint64
sys_fstat(void)
{
struct file *f;
uint64 st; // user pointer to struct stat

if(argfd(0, 0, &f) < 0 || argaddr(1, &st) < 0)
return -1;
return filestat(f, st);
}

这里面的核心在于return中的函数,而这个函数又使用了copyout这个函数,所以直接在这里贴出copyout这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;

while(len > 0){
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (dstva - va0);
if(n > len)
n = len;
memmove((void *)(pa0 + (dstva - va0)), src, n);

len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}

从注释中就可以看到,说的是从内核拷贝到用户空间。这里需要结合下一章页表的内容来分析一下这个copyout函数。第一个参数是页表的地址,由于需要传递给用户进程,所以这里应该传入用户的页表的地址。第二个参数目标的地址,也就是系统调用会传入的那个结构体的地址。第三个参数就是要复制的对象,第四个是对象的长度。

首先获取目标地址所在页的基址va0和找到对应的物理地址pa0,计算出需要多少空间,然后搬动过去就可以了。

所以最终的成品也出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
uint64
sys_sysinfo(void)
{
struct sysinfo s;
s.freemem = get_freemem();
s.nproc = get_proc();
// 传出去即可
uint64 addr;
if (argaddr(0, &addr) < 0)
{
return -1;
}
if (copyout(myproc()->pagetable, addr, (char *)&s, sizeof(s)) == -1)
{
return -1;
}
return 0;
}

总结

这一章学习了如何使用调用,可以发现真正的系统调用并不能直接通过函数来传递参数,而是需要寄存器来传递参数,落实到具体的代码里,就是p->trapframe->a7这样的。其次是从内核传递参数到用户空间中,也不能直接通过返回值,而是通过特殊的函数,把内核空间的代码复制到用户空间里去。

还有我之前做trace的时候一直很胆怯,不敢修改proc(task_struct),后来发现如果不修改根本做不到在父子进程之间进行传递。