先导知识
系统调用中,我们是如何传递参数的?
在x86-32中:通过中断实现(int 0x80)。把对应的系统调用的号码放入eax寄存器中,具体的参数放到指定的寄存器中。更为具体的代码:
1 | pushl ebx # 把寄存器的内容丢到用户栈里面 |
INT是用于x86处理器的汇编语言指令,该指令会生成软件中断。后面跟的0x80可以理解为指代是一个具体的中断处理程序。在Linux中,这个中断处理程序恰好就是内核。更加具体的中断号码对应的操作可以看这里。
在x86-64中:通过syscall指令实现(其实也可以通过int 80,但是推荐别那么做)。同样把对应的系统调用放入eax寄存器中,具体的参数放到指定的寄存器中(和32位不同)。具体为什么从32位系统过度到64位系统的时候,会有这种变化,主要还是因为有了新的指令的支持,所以可以重新用新的机制。具体的变化的更为详细的信息可以阅读这份文档。
Lab2:系统调用
这部分自己为xv6写入两个系统调用。
用户空间的代码在user/user.h和user/usys.pl中。其中user.h声明了所有系统调用的原型,同时还声明了一些库函数的声明;而usys.pl这个文件则是一个perl脚本,用来生成usys.S,简单观看这个脚本文件可以发现,本质上就是把对应的系统调用(SYS_xxx)放入到寄存器a7中,然后调用ecall的一个过程。比如如果希望执行一个fork,那么实际上的汇编代码是:
1 | sub entry { |
所以虽然是一个精简指令集RISC-V,但是其实和x86-32的做法非常像。你可以把ecall理解成int 0x80h。
内核空间的代码则是在kernel/syscall.h和kernel/syscall.c,其中头文件里面仅仅是一些系统调用号码,而c文件里面则是实现。c文件里面最最核心的实现是:
1 | void |
可以看到系统调用的数字保存在当前进程的p->trapframe->a7中,然后返回值放在了p->trapframe->a0,其中的syscalls是一个函数指针的数组,所以可以执行。
这里可以看到,执行系统调用的时候,用户进程并没有向内核传递任何参数,仅仅传递了一个系统调用号码,而且是通过寄存器传递的。而系统调用结束之后的结果是存放在a0这个寄存器中。
除了用户空间和内核空间,这里还需要简单了解一下进程的数据结构——kernel/proc.h和kernel/proc.c。
trace(中等)
自己实现一个trace的系统调用,能够追踪感兴趣的系统调用。这里的这个系统调用需要接受一个参数,然而从上面我们可以发现系统调用并不能传递参数,那要怎么实现呢?
更加具体来说,因为read在系统中的调用号码是5,所以如果用了trace 32 grep hello README,则说明需要监控grep这个命令执行过程中所使用的read调用。
首先我自己的思路是这样的:
- trace是怎么追踪那些父子进程的呢?结合上面给出的需要去了解进程的数据结构这一提示,我决定在进程的数据结构中加入一个
char mask[],专门用来记录进程感兴趣的系统调用,并且在fork的时候由父进程传递给子进程。 - trace是需要怎么获取对应的那个参数呢?因为系统调用又不是直接函数调用,我要怎么获取对应的mask参数呢?我这里是看到别的系统调用使用了
argint这个函数,我也照着做了。
好了,解决上面两个问题,我觉得差不多可以试试看了。
首先是系统调用的声明,这个真的就是照葫芦画瓢,这里比较关键的是首次调用这个trace系统调用的时候,我们将这个参数转化成对应的mask数组,并且放入到当前的进程中对应的数据结构中,然后修改fork,这样就可以在进程之间使用fork的时候进行传递了。
1 | uint64 |
这里的重点应该是argint,我们来看看它是如何实现的:
1 | // Fetch the nth 32-bit system call argument. |
所以很明显了,其实就是通过进程中的trapframe来找到对应的寄存器,并且取出对应的数据。而且最多只能支持6个参数。
然后还有一个比较重要的地方就是在执行系统调用的地方:
1 | void |
首先获取了对应系统调用的数字,然后去对应的系统调用函数数组里面找到对应的函数,然后执行。这里判断下如果对应位置上的mask是1,那么就说明监听了对应的系统调用,而且还需要判断确实启动了监听这个动作,即p->mask[0] !=0。
trace调用的过程
如果你仔细查看一下代码,会发现很奇怪,全部的代码中,只有trace.c(调用trace这个系统调用的代码)和user.h(仅仅声明了一下trace这个函数),那trace的实现到底在哪里?我们明明写的是sys_trace(),然而似乎找遍代码也没有发现哪里使用了这行代码啊?奥秘就在usys.pl这个perl文件中。
1 | sub entry { |
这里定义了一个函数,可以看到就是一个perl函数,最重要的应该就是第一行,它会把第一个参数赋值给$name,然后生成对应的汇编代码,比如我们的entry就会通过一定的手段生成对应的汇编代码:
1 | .global trace |
可以看到把系统调用号放到了a7这个寄存器里,并且调用了ecall指令。
sysinfo(中等)
新增一个系统调用sysinfo,用来收集当前系统的运行状态。这个系统调用接受一个指针,指向下面的数据结构:
1 | struct sysinfo { |
然后执行对应的操作,把freemem和nproc填充好。其中nproc的数量是,只要一个进程的状态不是UNUSED,就进行统计。
还是两个问题:
- 怎么样才能获取当前的内存剩余的空间?同样怎么获取符合要求的进程呢?提示中给了去了解一下
kernel/kalloc.c和kernel/proc.c - 怎么样把传递的参数给返回出去,毕竟系统调用不是普通的函数。那可不可以直接把这个结构体的地址写入trapframe->a0里面呢?通过提示中去了解一下
kernel/sysfile.c中的sys_fstat()和kernel/file.c中的filestat()这两个函数,看看它们是怎么用的copyout(),然后依葫芦画瓢。
获取内存空间和进程状态
首先是遍历进程这部分,可以看到是这样子做的:
1 | // Print a process listing to console. For debugging. |
其中的proc是一个结构体数组,所以proc就是第一个进程,最后一个进程的地址就是&proc[NPROC]。
所以依葫芦画瓢的结果是:
1 | uint64 |
其次是获取剩余可用内存的部分,这里也是照葫芦画瓢:
1 | uint64 |
也是很简单的。
怎么把数据传出去
照理先分析一下提示中给出的函数。
1 | uint64 |
这里面的核心在于return中的函数,而这个函数又使用了copyout这个函数,所以直接在这里贴出copyout这个函数:
1 | // Copy from kernel to user. |
从注释中就可以看到,说的是从内核拷贝到用户空间。这里需要结合下一章页表的内容来分析一下这个copyout函数。第一个参数是页表的地址,由于需要传递给用户进程,所以这里应该传入用户的页表的地址。第二个参数目标的地址,也就是系统调用会传入的那个结构体的地址。第三个参数就是要复制的对象,第四个是对象的长度。
首先获取目标地址所在页的基址va0和找到对应的物理地址pa0,计算出需要多少空间,然后搬动过去就可以了。
所以最终的成品也出来了:
1 | uint64 |
总结
这一章学习了如何使用调用,可以发现真正的系统调用并不能直接通过函数来传递参数,而是需要寄存器来传递参数,落实到具体的代码里,就是p->trapframe->a7这样的。其次是从内核传递参数到用户空间中,也不能直接通过返回值,而是通过特殊的函数,把内核空间的代码复制到用户空间里去。
还有我之前做trace的时候一直很胆怯,不敢修改proc(task_struct),后来发现如果不修改根本做不到在父子进程之间进行传递。