操作系统 MIT6.S081 Lab2 System Call
实验原理
① Xv6 运行在多核的 RISC-V 微处理器:
- RISC-V 处理器提供硬件级别的隔离,包括三种执行指令的模式:机器模式、内核模式和用户模式;内核模式下,CPU 可以运行如开关中断、读写寄存器等特权指令,并且是在内核空间上运行的,这些程序就是内核;
- RISC-V 为进程提供指令
ecall
用于将用户态转为内核态;执行ecall
以后,PC
将会知道内核指定的入口。该入口的代码切换到内核栈上运行内核指令;当系统调用结束时,内核通过sret
指令切换回用户栈和用户空间,继续之前的用户程序。
② Xv6 的内核组织:
- Xv6 采用单体内核的内核组织形式,内核接口对应操作系统的接口,由内核完成整个操作系统;
- 本次实验涉及到的文件(
kernel
目录下):defs.h
:模块间的接口;proc.c
:进程和调度相关的程序;syscall.c
:用于将系统调用的请求分派给不同的具体实现的函数;sysfile.c
:文件相关的系统调用的实现;sysproc.c
:进程相关的系统调用的实现;
③ Xv6 实现隔离机制的基本单元是进程:
-
使用一个
struct proc
保存进程的各类信息,系统调用遵循如下 calling convention:- 调用系统调用时,
proc
中寄存器a7
保存的序号确定调用的系统调用序号及对应函数,对应kernel/syscall.c
中保存的一个函数指针数组;将传递个系统调用的参数放在寄存器a0
、a1
… 中; - 系统调用结束时,返回值保存在寄存器
a0
中,返回非负整数表示成功,否则失败
- 调用系统调用时,
-
在一个进程的虚拟空间中,
trampoline
保存了进出内核的代码,trapframe
保存了从用户态到内核态转换时的现场:- 系统调用在
trapframe
中查找相关寄存器来得到用户进程传给内核的参数,内核可调用接口argint
,argaddr
和argfd
来得到不同类型的参数
- 系统调用在
Part A
**实验目标:**实现系统调用 trace
,用于跟踪系统调用,打印PID、系统调用名称、返回值等信息;通过 usertests
测试。
实验步骤:
① 在 Makefile
、user/user.h
和 user/usys.pl
中添加相关声明:
# Makefile
UPROGS=
...
$U/_trace\
// user/user.h
// systemcalls
int trace(int);
# user/usys.pl
entry("trace");
trace()
应当接受一个整数,作为被追踪的系统调用代码的掩码;
② 在 kernel/proc.h
保存进程信息的结构体中新增 mask
成员变量,用于保存该进程需要追踪的系统调用代码的掩码:
// kernel/proc.h
struct proc {
...
int mask;
}
③ 在 kernel/sysproc.c
中实现函数 uint64 sys_trace(void)
:
// kernel/sysproc.c
uint64
sys_trace(void) {
int mask;
argint(0, &mask);
myproc()->mask = mask;
return 0;
}
系统调用 trace
被调用时需要传递一个整型变量 mask
,使用接口 argint(n, addr)
以整数的形式获得第 0 个参数传到 addr 中,设置成当前进程的 mask
④ 在 kernel/syscall.h
和 kernel/syscall.c
中添加系统调用 trace
的相关代码;需要增加一个保存所有系统调用名称的字符串数组:
// kernel/syscall.h
#define SYS_trace 22
// kernel/syscall.c
extern uint64 sys_trace(void);
static uint64 (*syscalls[])(void) = {
...
[SYS_trace] sys_trace
}
char* syscall_name[] = {
"fork",
"exit",
...
}
void
syscall(void) {
...
if (((p->mask) & (1 << num)) > 0)
printf("%d: syscall %s -> %d\n", p->pid, syscall_name[num - 1], p->trapframe->a0);
...
}
通过掩码判断当前系统调用是否需要被追踪;根据 RISC-V 的 C 的 calling convention,系统调用的返回值存在寄存器 a0
中
⑤ 修改 fork()
使得掩码可以从父进程传递给子进程:
// kernel/proc.c
int
fork(void) {
...
acquire(&np->lock);
np->mask = p->mask;
acquire(&np->lock);
}
不清楚是否涉及到临界资源的问题,但是上锁总是安全的
测试:
① trace 32 grep hello README
用于跟踪 grep hello README
执行时系统调用 read
的执行情况(
32
=
2
5
32=2^5
32=25 ,SYS_read
= 5):
② trace 2147483647 grep hello README
用于跟踪 grep hello README
执行时所有系统调用的执行情况(2147483647 的二进制表示全是 1):
③ trace 2 usertests forkforkfork
用于跟踪 usertest forkforkfork
执行时系统调用 fork
的执行情况(
2
=
2
1
2=2^1
2=21 ,SYS_fork
= 1):
其中会出现 -1 的原因是,Xv6 最多允许 64 个进程同时存在,可以在 proc.c
中观察到进程数组值为 NPROC
(64)因此当进程数超过 64 时就会 fork 失败,返回 -1 :
Part B
**实验目标:**实现系统调用 sysinfo
,实现系统信息收集功能;通过 sysnifotest
测试。
实验步骤:
① 在 Makefile
、user/user.h
和 user/usys.pl
中添加相关声明:
# Makefile
UPROGS=
...
$U/_sysinfotest\
// user/user.h
// systemcalls
struct sysinfo;
int sysinfo(struct sysinfo*);
# user/usys.pl
entry("sysinfo");
系统调用 sysinfo
接受一个指向 strcut sysinfo
的指针,并将相关信息填充到这个结构体中
② 在 kernel/kalloc.c
和 kernel/proc.c
中添加辅助函数,用于获得空闲空间的大小以及正在使用的进程的数目,并将接口添加到 kernel/defs.h
,供 sys_sysinfo()
的实现使用:
// kernel/kalloc.c
uint64
free_memory_number(void) {
uint64 num = 0;
struct run* r;
acquire(&kmem.lock);
r = kmem.freelist;
while (r) {
num += PGSIZE;
r = r->next;
}
release(&kmem.lock);
return num;
}
计算空闲空间的大小,即遍历空闲列表,每块空间的大小为 PGSIZE
(4096B);由于空闲列表是临界资源,因此需要上锁
// kernel/proc.c
uint64
used_process_number(void) {
uint64 num = 0;
for (int i = 0; i < NPROC; ++i) {
if (proc[i].state != UNUSED) {
++num;
}
}
return num;
}
遍历进程数组中所有进程,统计不为 UNUSED
状态的所有进程数
// kernel/defs.h
// kalloc.c
uint64 free_memory_number(void);
// proc.c
uint64 used_process_number(void);
③ 在 kernel/sysinfo.h
中添加包含一个字符串类型成员的结构体,包含学号信息:
// kernel/sysinfo.h
struct {
char* stuID;
} stuinfo = {"xxxxxxxxxxx"};
④ 在 kernel/sysproc.c
中实现函数 uint64 sys_sysinfo(void)
:
// kernel/sysproc.c
uint64
sys_sysinfo(void) {
// stuinfo is defined in sysinfo.h
printf("my student number is %s\n", stuinfo.stuID);
uint64 ps;
argaddr(0, &ps);
struct sysinfo temp;
temp.freemem = free_memory_number();
temp.nproc = used_process_number();
if (copyout(myproc->pagetable, ps, (char*)(&temp), sizeof(temp)) < 0)
return -1;
return 0;
}
copyout(pagetable_t pagetable, uint64 dstva, char* src, uint64 len)
将来自 src
的内容拷贝到虚拟地址 dstva
中,成功返回 0,失败返回 -1;
⑤ 在 kernel/syscall.h
和 kernel/syscall.c
中添加系统调用 sysinfo
的相关代码:
// kernel/syscall.h
#define SYS_sysinfo 23
// kernel/syscall.c
extern uint64 sys_sysinfo(void);
static uint64 (*syscalls[])(void) = {
...
[SYS_sysinfo] sys_sysinfo
}
char* syscall_name[] = {
...
"sysinfo"
}
测试:
sysinfotest
用于测试系统调用 sysinfo
是否正确,包括测试空闲内存空间大小计算是否正确和 UNUSED 进程数是否正确;每测试一部分就会调用一次 sysinfo
,同时打印一遍学号:
问题回答
System calls Part A 部分,简述一下 trace 全流程
-
shell 由
user/sh.c
实现,输入的指令交给parsecmd()
解析,返回指令类型为EXEC
;再交给runcmd()
分派,发现是EXEC
类型,就调用exec()
并传递相关参数; -
exec()
根据解析出来的第一个字符串为 ”trace“ 而将user/trace.c
的可执行代码加载到内存中执行,并传递相关参数; -
user/trace.c
检查参数的有效性之后,分为两步:① 首先调用
trace()
函数:trace()
函数在user/user.h
中声明,由user/usys.pl
提供入口;user/usys.S
根据 calling convention 设置好相关参数,并且执行ecall
进入内核态:
-
在内核中,
kernel/syscall.c/syscall()
函数根据p->trapframe->a7
调用syscalls[SYS_trace]
,即kernel/sysproc.c
中的systrace()
函数kernel/sysproc.c/systrace()
函数通过p->trapframe->a0
获得掩码,并设置为当前进程的掩码;
执行完后,将返回值放入
p->trapframe->a0
,并执行指令sret
返回用户态
②
trace()
函数返回后,由返回值判断是否成功;若成功,再获取argv[2]
中存储的需要被追踪的系统调用名,调用exec()
调用该系统调用kernel/syscall.c/syscall()
中会判断当前掩码中该系统调用的编码位是否为 1;若为 1,则打印出 PID、进程名以及返回值
- 每次 fork 出子进程时,子进程将会继承父进程的掩码,因此可以追踪由原系统调用所引起的所有掩码中包含的系统调用
kernel/syscall.h
是干什么的,如何起作用的?
-
kernel/syscall.h
对每个系统调用都进行编号; -
当用户进程需要调用系统调用时,依据 calling convention,把即将调用的系统调用的编号存入寄存器
a7
,并将传递个系统调用的参数放在寄存器a0
、a1
… 中;接着调用ecall
指令切换到内核态 -
进入内核态时,现场被保存在
trapframe
中,因此内核从trapframe
中获取寄存器a7
和a0
、a1
… -
syscall.c
中保存了一个函数指针数组,里边存的是系统调用的具体实现,根据a7
的值确定实际调用哪个函数
命令 trace 32 grep hello README
中的 trace 字段是用户态下的还是实现的系统调用函数 trace?
用户态下的,在 user/usys.S
中实现;传递给 int trace(int)
的参数(即 mask / 掩码)在寄存器 a0
中,所作的事情是将系统调用 trace
的编码 SYS_trace
放入寄存器 a7
中,然后才调用 ecall
指令进入内核态,所以前面做的事情都是在用户态下完成的。