MIT 6.1810 Lab:system calls
lab网址:https://pdos.csail.mit.edu/6.828/2022/labs/syscall.html
xv6Book:https://pdos.csail.mit.edu/6.828/2022/xv6/book-riscv-rev3.pdf
Using gdb
总体感觉,对xv6的调试还是比较容易的,至少没有出现令人疑惑的bug。开启gdbserver的操作也被封装好了,在xv6目录中执行$ make qemu-gdb
就行。
首先需要安装 gdb-multiarch ,如果是 ubuntu 的话,$ apt install gdb-multiarch
,其他发行版也大同小异。在另一个窗口,$ gdb-multiarch
,在gdb界面输入(gdb) target remote localhost:<PORT>
连接 gdbserver 和(gdb) file kernel/kernel
载入源码后,就可以开始调试了。
这里使用的是原生 gdb 进行的调试,习惯于使用peda、gef、pwndbg等插件进行调试的小伙伴可能会有些不适应,这里我就不折腾了,一方面也是学习一下gdb的原生命令,另一方面一些插件可能会因为不同指令级而产生一些不兼容的情况。
部分使用命令
(gdb) layout src
可以在调试界面分屏显示源码。
可以看到蓝框表示当前选择的窗口,此时使用方向键可以阅读源码,但就没法用方向键输入调试命令了,如果想切换窗口,可以使用Ctrl+x ,o
Ctrl+x ,o
窗口切换,Ctrl+x ,2
/Ctrl+x ,3
上下/左右分屏,Ctrl+x ,0
/Ctrl+x ,1
关闭 / 独占Ctrl+x ,a
返回原模式
print
简写为p
,作为最常用的输出命令,可以以优美的格式打印各种数据结构。
例如p /x *p
,/x 表示以16进制输出,*p 表示指针 p 对应的数据结构。
Q1:Looking at the backtrace output, which function called syscall?
从 backtrace 可以看出,syscall 显然是由 usertrap 调用的。
Q2:What is the value of p->trapframe->a7 and what does that value represent?
(gdb) p /x *p
$1 = {lock = {locked = 0x0, name = 0x80008178, cpu = 0x0}, state = 0x4,
chan = 0x0, killed = 0x0, xstate = 0x0, pid = 0x1, parent = 0x0,
kstack = 0x3fffffd000, sz = 0x1000, pagetable = 0x87f73000,
trapframe = 0x87f74000, context = {ra = 0x80001466, sp = 0x3fffffde70,
s0 = 0x3fffffdea0, s1 = 0x80008d10, s2 = 0x800088e0, s3 = 0x1,
s4 = 0x3fffffded0, s5 = 0x8000eb98, s6 = 0x3, s7 = 0x800199b0, s8 = 0x1,
s9 = 0x80019ad8, s10 = 0x4, s11 = 0x0}, ofile = {0x0 <repeats 16 times>},
cwd = 0x80016e20, name = {0x69, 0x6e, 0x69, 0x74, 0x63, 0x6f, 0x64, 0x65,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}}
(gdb) p /x *(struct trapframe*)0x87f74000
$14 = {kernel_satp = 0x8000000000087fff, kernel_sp = 0x3fffffe000,
kernel_trap = 0x80001c88, epc = 0x18, kernel_hartid = 0x0,
ra = 0x505050505050505, sp = 0x1000, gp = 0x505050505050505,
tp = 0x505050505050505, t0 = 0x505050505050505, t1 = 0x505050505050505,
t2 = 0x505050505050505, s0 = 0x505050505050505, s1 = 0x505050505050505,
a0 = 0x24, a1 = 0x2b, a2 = 0x505050505050505, a3 = 0x505050505050505,
a4 = 0x505050505050505, a5 = 0x505050505050505, a6 = 0x505050505050505,
a7 = 0x7, s2 = 0x505050505050505, s3 = 0x505050505050505,
s4 = 0x505050505050505, s5 = 0x505050505050505, s6 = 0x505050505050505,
s7 = 0x505050505050505, s8 = 0x505050505050505, s9 = 0x505050505050505,
s10 = 0x505050505050505, s11 = 0x505050505050505, t3 = 0x505050505050505,
t4 = 0x505050505050505, t5 = 0x505050505050505, t6 = 0x505050505050505}
这里*p
中的 p 就是源码struct proc *p = myproc()
中的 p ,因此这里打印出了相应的结构体。
显然p->trapframe->a7
的值为0x7
。
根据上下文源代码,可以判断出,当前是一个用户程序执行系统调用,然后陷入内核,此处为 syscall 内核函数,内核通过myproc()
获取该进程的描述符,而p->trapframe->a7
为该用户程序陷入内核时设置的系统调用号。
Q3:What was the previous mode that the CPU was in?
系统调用运行在内核模式下,我们可以通过(gdb) p/t $sstatus
来查看特权寄存器的值。这里/t表示二进制。
参考官方文档对 sstatus 寄存器的介绍 (RISC-V privileged instructions )
sstatus寄存器是 riscv64 中描述特权状态的寄存器。sstatus是mstatus的一个子集,两者的修改直接相关,sstatus寄存器记录了处理器的当前的操作状态。
SPP位表示在进入特权模式前,hart执行的权限级别。当一个陷阱被捕获时,如果陷阱来自于用户模式,SPP被设置为0,否则为1。当执行SRET指令从陷阱处理程序返回时,SPP被设置为0。
由$sstatus
为100010可知,高位都为0,则SPP也为0,所以可以得知调用前的模式为用户模式。
Q4:Write down the assembly instruction the kernel is panicing at. Which register corresponds to the varialable num?
这部分实验,需要我们先对位于kernel/syscall.c中的syscall源代码做一个修改。制造一个内核崩溃,并学习分析原因。
$ git diff
diff --git a/kernel/syscall.c b/kernel/syscall.c
index ed65409..0f36bec 100644
--- a/kernel/syscall.c
+++ b/kernel/syscall.c
@@ -134,7 +134,8 @@ syscall(void)
int num;
struct proc *p = myproc();
- num = p->trapframe->a7;
+ //num = p->trapframe->a7;
+ num = * (int *) 0;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
其中的 sepc 为内核发生 panic 的代码地址。在kernel/kernel.asm 中可以查看内核完整编译的汇编代码,在其中搜索sepc的地址,就可以找到具体的异常位置。
//num = p->trapframe->a7;
num = * (int *) 0;
80001ff6: 00002683 lw a3,0(zero) # 0 <_entry-0x80000000>
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
80001ffa: fff6871b addiw a4,a3,-1
80001ffe: 47d1 li a5,20
80002000: 00e7ef63 bltu a5,a4,8000201e <syscall+0x3c>
80002004: 00369713 slli a4,a3,0x3
80002008: 00006797 auipc a5,0x6
8000200c: 3c878793 addi a5,a5,968 # 800083d0 <syscalls>
80002010: 97ba add a5,a5,a4
80002012: 639c ld a5,0(a5)
80002014: c789 beqz a5,8000201e <syscall+0x3c>
lw a3,0(zero)
试图将地址为0处的字赋值给a3,显然这是一个非法地址,因此就发生了错误。所以表示num的寄存器为a3。
接下来这部分需要我们用gdb调试该panic。
$make qemu-gdb
# in another window
(gdb) target remote localhost:26000
(gdb) b *0x0000000080001ff6
(gdb) layout asm
(gdb) c
停到断点处后,单步执行,当输入ni后,刚好发生了crash,证明crash发生的位置与我们先前找到汇编指令一致。
Q5:Why does the kernel crash? is address 0 mapped in the kernel address space? Is that confirmed by the value in scause above?
lw a3,0(zero)
试图将地址为0处的字赋值给a3,显然这是一个非法地址,因此就发生了错误。我们可以看到xv6book,figure3.3给出的具体的地址映射。
我们可以看到,左侧是内核的虚拟地址空间,内核基址从蓝色框开始,映射到物理内存高地址部分,物理内存的内核基址取决于物理内存的大小,但与用户进程不同的是,内核的大部分映射是线性且整齐,即偏移是固定值。在内核的虚拟地址空间中,我们可以看到地址0没有被使用,因此地址0为非法地址。
阅读risc-vbook得知:scause寄存器是一个SXLEN位读写寄存器,其格式如图4.11所示。当陷阱被带入特权模式时,scause被写入一个代码,表示引起陷阱的事件。 否则,scause永远不会被实现写入,尽管它可能被软件明确地写入。
(gdb) p $scause
$19 = 13
查阅表格可知,13为加载页错误,因此scause可以验证我们的猜想。
Q6:What is the name of the binary that was running when the kernel paniced? What is its process id (pid)?
我们可以看到当前程序位于中断上下文中
在中断上下文中,进程p已经不存在了,我们需要在内核发生panic前,找到程序名。
可以看出程序名为initcode,pid为1。最后不要忘记将kernel/syscall.c的代码改回。
System call tracing
这部分需要我们添加一个系统调用跟踪功能,它应该接受一个参数,即一个整数 "掩码",其位数指定要跟踪哪些系统调用。例如,为了跟踪fork系统调用,程序会调用trace(1 << SYS_fork),其中SYS_fork是来自kernel/syscall.h的系统调用编号。你必须修改xv6内核,以便在每个系统调用即将返回时打印出一行,如果系统调用的编号被设置在掩码中的话。这一行应该包含进程ID、系统调用的名称和返回值;你不需要打印系统调用的参数。追踪系统调用应该对调用它的进程和它随后分叉的任何子进程进行追踪,但不应该影响其他进程。
添加系统调用
添加系统调用,需要修改的文件较多,这里是做一个简单的总结
用户空间
- user/trace.c 用户程序,调用系统调用trace
- user/user.h 用户程序头文件,系统调用至少需要一个声明,相关符号才能使用
- user/usys.pl 用于自动生成汇编文件(user/usys.S),实现在用户空间,调用系统调用陷入内核的具体代码
- Makefile 添加需要编译的用户程序
内核空间
- kernel/syscall.h 使用宏定义,将每个SYS_xxx替换为一个系统调用号
- kernel/syscall.c 陷入内核时,内核的接收代码,跳转到对应的系统调用代码
- kernel/sysproc.c 与进程相关的系统调用代码
- kernel/proc.h 进程相关头文件
kernel/syscall.c 解析
这一部分为使用系统调用陷入内核时,内核的接收代码。如下是一个较为疑惑的结构。首先 syscalls 是一个数组,数组的元素是指针,指针指向参数为空、返回值为 uint64 的函数。
// An array mapping syscall numbers from syscall.h
// to the function that handles the system call.
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,
};
C99标准允许我们定义数组的适合,指定顺序,例如int a[6] = { [4] = 29, [2] = 15 };
,而且中间的等号可以省略。因此当使用syscalls[SYS_xxx]
时,就会执行具体的函数。syscall是具体的接收函数,我们在上文调试gdb时,已经有所感受。通过p->trapframe->a7
获取系统调用号,并使用syscalls[num]()
执行系统调用。
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
trace系统调用编写
在 user/trace.c 中会先调用trace(MASK)
系统调用,让被追踪的系统调用在结束时打印一行。完成这一操作后,再执行追踪程序。
int
main(int argc, char *argv[])
{
int i;
char *nargv[MAXARG];
if(argc < 3 || (argv[1][0] < '0' || argv[1][0] > '9')){
fprintf(2, "Usage: %s mask command\n", argv[0]);
exit(1);
}
if (trace(atoi(argv[1])) < 0) {
fprintf(2, "%s: trace failed\n", argv[0]);
exit(1);
}
for(i = 2; i < argc && i < MAXARG; i++){
nargv[i-2] = argv[i];
}
exec(nargv[0], nargv);
exit(0);
}
因此,再结合提示,笔者有了以下思路
- syscall函数,接收用户程序参数,执行系统调用,完成后返回。在此添加语句,使返回前打印提示信息。
- 在proc添加需要跟踪的系统调用掩码,由syscall返回前比较当前系统调用是否是被跟踪的。
- 由sys_trace根据掩码,在该进程的proc结构中,添加需要跟踪的系统调用。
kernel/proc.h
// Per-process state
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 tmask; // Trace msak (Tracing)
};
kernel/syscall.c
// An array mapping syscall numbers
// to syscall name
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] "stat",
[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",
};
void
syscall(void)
{
int num;
int tmask;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
tmask = p->tmask;
if(tmask&(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;
}
}
kernel/sysproc.c
uint64
sys_trace(void)
{
int tmask;
argint(0, &tmask);
myproc()->tmask = tmask;
return 0;
}
kernel/proc.c
// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
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;
// copy saved user registers.
*(np->trapframe) = *(p->trapframe);
// Cause fork to return 0 in the child.
np->trapframe->a0 = 0;
// copy trace mask
np->tmask = p->mask;
// 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;
}
trace测试
Sysinfo
在这个作业中,你将添加一个系统调用,sysinfo,用来收集运行系统的信息。这个系统调用需要一个参数:一个指向sysinfo结构的指针(见kernel/sysinfo.h)。内核应该填写这个结构的字段:freemem字段应该被设置为自由内存的字节数,nproc字段应该被设置为状态不是UNUSED的进程数。我们提供了一个测试程序sysinfotest;如果它打印出 "sysinfotest: OK"。
kernel/sysproc.c
uint64
sys_sysinfo(void)
{
struct proc *p = myproc();
uint64 upinfo; //user pointer
struct sysinfo kinfo; //kernel struct
kinfo.freemem=kremain();
kinfo.nproc=getnproc();
argaddr(0, &upinfo);
if(copyout(p->pagetable, upinfo, (char*)&kinfo, sizeof(kinfo)) < 0){
return -1;
}
return 0;
}
用户进程的地址空间与内核地址空间不同,因此同样指针的值,却指向不同的位置,所以需要使用copyout()
。
kernel/kalloc.c
在 kernel/kalloc.c 添加函数,并在defs.h中声明
uint64
kremain(void)
{
struct run *r;
uint64 remain = 0;
acquire(&kmem.lock);
r = kmem.freelist;
while(r){
remain += PGSIZE;
r = r->next;
}
release(&kmem.lock);
return remain;
}
kernel/proc.c
在 kernel/proc.c 添加函数,并在defs.h中声明
uint64
getnproc(void)
{
uint64 nproc = 0;
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
if(p->state != UNUSED)
nproc++;
}
return nproc;
}
测试
问题思考
在这个实验过程中,可以了解到用户经常通过汇编代码,设置系统调用号,陷入内核,然后由syscall内核函数根据系统调用号,执行具体的系统调用代码。但陷入内核到执行syscall函数的过程中,已经有一部分工作完成了,比如说用户状态下寄存器的保存,trapframe结构初始化,这些工作是由谁完成,又是如何触发的呢。