MIT6828 学习笔记 006 (lab syscall)
如果是从 g.csail.mit.edu clone 下来的话别忘了
1 git checkout syscall
1. 看书
实验建议我把 4.3, 4.4 两节也看了
1.1. Sec 4.3. 代码:调用系统调用
Chapter 2 的后续: user/initcode.S:11 把执行 exec 的参数放进了寄存器 a0, a1 ,把系统调用号 SYS_exec 放进了寄存器 a7 ,这个号可以在系统调用表 (kernel/syscall.c:107) 里拿到对应的函数指针,然后通过 ecall 指令 trap 进内核并引起 uservec 和 usertrap ,然后进入内核的 syscall (kernel/syscall.c:131) , syscall 根据 struct trapframe 里存放的寄存器 a7 的值 (SYS_exec) 调用 sys_exec (kernel/sysfile.c:434) 。 sys_exit 返回后 syscall 会把 sys_exec 的返回值保存到进程的 struct trapframe 的 a0 里,使用户空间对 exec 的调用返回这个值。
系统调用一般会返回负值表示错误;
0或正值表示成功。如果系统调用号无效,syscall会输出错误信息 (kernel/trap.c:148) 并返回-1(kernel/syscall.c:143)
1.2. Sec 4.4. 代码:系统调用参数
系统调用的实现需要获取用户代码传递的参数,因为用户代码一般调用系统调用的的包装函数,这时参数最初被放在 RISC-V C 调用规定的位置:寄存器里。内核的 trap 代码把用户的寄存器存放到当前进程的 struct trapframe 里,然后内核可以从这里读取传递的参数,比如 argint (kernel/syscall.c:56), argaddr (kernel/syscall.c:65) 和 argfd (kernel/sysfile.c:21) 用来获取系统调用的第 n 个参数(分别以整数、指针、 fd 的形式),这几个函数内部使用 argraw (kernel/syscall.c:33) 获得指定寄存器里的值。
argfd只是argint的一层封装,确保传进来的 fd 确实是这个进程打开的- 现在的 Xv6 源码里还有
argstr(kernel/syscall.c:74)
一些系统调用的参数是指针,内核必须通过指针读写用户的内存,比如 exec 系统调用,要给内核传递一个 char*[] 指向用户空间的字符串参数,这就带来了两个挑战:首先用户程序可能有漏洞或有恶意代码,可能会给内核传递一个无效指针或为了访问内核内存的整活指针;其次 Xv6 内核 page table 映射和用户 page table 的不一样,所以内核不能使用常规指令来存取用户提供的地址。
内核实现了在用户提供的地址之间安全地传输数据的函数,比如 fetchstr (kernel/syscall.c:24) , exec 就是通过 fetchstr 来从用户空间获取文件名字符串参数的; fetchstr 内部通过 copyinstr (kernel/vm.c:402) 来实现具体的操作; copyinstr 从虚拟地址(进程的 struct trapframe 里的寄存器值)读取数据,通过 walkaddr (kernel/vm.c:108, 其内部调用 walk, kernel/vm.c:85) 获取物理地址,因为内核给每一个物理 RAM 地址都映射了一个对应的内核虚拟地址,所以 copyinstr 可以直接进行数据的复制。 walkaddr 可以检查用户提供的虚拟地址是否为用户进程空间的一部分,以确保程序不能给内核整活,类似的还有 copyout (kernel/vm.c:351) 可以把数据从内核写进用户提供的地址。
2. GDB 的使用
2.1. 如何调试内核
先在一个 shell 里运行内核:
1 | make qemu-gdb |
输出像这样:
1 | *** Now run 'gdb' in another window. |
最后的 tcp::26000 意思是在我的电脑上算出的端口是 26000 。再在另一个 shell 打开一个 gdb :
1 | riscv64-linux-gnu-gdb |
然后在 gdb shell 里输入:
1 | target remote localhost:26000 |
或者把实验源码的
.gdbinit文件目录加入到~/.config/gdb/gdbinit的set auto-load safe-path里
就可以开始调试了
2.2. Xv6 Lab 的一些小提示
kernel/kernel.asm是objdump -S出的汇编-源码对照文件kernel/kernel.sym是objdump -t出的符号表- 如果没有用 gdb 的时候 panic 了,可以通过
addr2line -e <executable> <addr>来获取具体 panic 在哪一行代码 - 如果要用 qemu console 的 info mem ,可以
make CPUS=1 qemu确保 qemu 硬件只有一个 CPU
2.3. 常用指令
- location 可以是函数名 (
func) 、代码位置 (main.c:114) 或内存地址 (*0x1145) - condition 可以是类似
foo == 114之类的表达式 - expression 可以是一个变量
- format 比如
13x为打印 13 个 hex 格式指令,13i为打印 13 个汇编指令
| 指令 | 缩写 | 功能 |
|---|---|---|
<ENTER> | 重复上一次操作 | |
continue | c/fg | 继续运行,直到断点或手动打断 |
finish | fin | 继续运行,直到跳出当前函数 |
advance <l> | adv | 继续运行到 l (location) |
step [n] | s | 逐行(源码)运行 n 次,会进入函数 |
stepi [n] | si | 逐行(汇编指令)运行 n 次,会进入函数 |
next [n] | n | 逐行(源码)运行 n 次,不进入函数 |
nexti [n] | ni | 逐行(汇编指令)运行 n 次,不进入函数 |
break <l> | b | 在 l (location) 设置一个断点 |
break <l> if <c> | b | 设置断点,只有 c (condition) 满足时打断 |
info breakpoints | i b | 列出所有断点信息 |
condition <n> <c> | cond | 设置断点 n 只有 c 满足时打断 |
delete [b] | d | 删除全部断点/断点 b |
disable [b] | dis | 禁用全部断点/断点 b ,不删除 |
enable [b] | en | 启用全部断点/断点 b |
watch <e> | wa | 在 e (expression) 取值发生变更时打断 |
watch -l <a> | wa | 在 a (address) 对应数据发生变更时打断 |
rwatch <e> | rw | 在 e 被读取时打断 |
rwatch -l <a> | rw | 在 a 被读取时打断 |
x[/f] [a] | 打印内存, f (format) | |
print/inspect | p | 执行一个 C 表达式并以适当的格式打印出来 |
print/inspect | p | 执行一个 C 表达式并以适当的格式打印出来 |
info registers | i r | 列出所有寄存器信息 |
info frame | i f | 列出当前 stack frame 信息 |
list [l] | l | 列出 l (location) 所在的代码上下文 |
backtrace/where | bt | 打印所有 stack frame 的 backtrace |
layout | la | 显示 curses UI , Toggle: C-x,C-a/a/A |
set variable | set var | 更改变量的值 |
symbol-file <o> | sy | 从 o 加载符号表 |
2.4. 问题的答案
问:通过 backtrace 的输出判断哪个方法调用了
syscall?
答: 0x0000000080001d18 in usertrap () at kernel/trap.c:67
问: step 过
struct proc* p = myproc();后在 gdb shell 里执行p /x *p(以十六进制形式打印当前进程的struct proc),p->trapframe->a7的值是什么?它代表什么?
答:我不确定题目允不允许 p p->trapframe->a7 ,因为 p /x *p 输出的 p->trapframe 是一个指针,值是地址,不能直接看到 a7 的值,但根据之前讲的 Xv6 启动过程来说这个值是 SYS_exit 应该是 7 ,通过内核里的系统调用表可以取到对应的 sys_exit 函数指针。
问:
p /x $sstatus可以打印寄存器sstatus值, CPU 之前的模式是什么?
答:看 RISC-V 文档 (Sec 4.1.1, p63) 的话 sstatus 的 SPP 位表示进入 supervisor mode 之前的 mode (0 为 user mode , 1 为 supervisor mode), p /x $sstatus 输出为 0x200000022, SPP 位(第 9 个 bit )为 0 ,应该是 user mode 吧,这个真的不确定。
问:把
kernel/syscall.c:135 的num = p->trapframe->a7;改成num = *(int*)0;后运行 Xv6 会 panic ,根据输出的信息写出 panic 发生在的汇编指令,变量num对应的寄存器是那个?
答:在我的机器上最新编译运行出的结果是:
1 | scause 0x000000000000000d |
就在 kernel/kernel.asm:4453 ,汇编指令为 lw a3,0(zero) , num 对应的寄存器应该是 a3
问:通过在上面的错误位置设置断点和 gdb 的 asm layout 确定上一问的答案是否正确,为什么内核崩溃了?上面
scause的值能证实这个原因吗?
答:在 gdb shell 执行 b *0x0000000080001ff8 可以设置断点 layout asm 切换 asm layout 看汇编指令, c 继续执行至断点发现上面的答案确实正确;原因应该是内核的虚拟地址并没有映射 0x0 ;看 RISC-V 文档 (table 4.2, p71) 的话上面 scause 的 interrupt 位为 0, exception code 为 13 (0x000000000000000d) ,对应描述是 “Load page fault” ,好像能证实?
问:上面的
scause之类的都是内核 panic 后打印出来的,有时要确定问题所在还要知道 panic 时运行的程序。所以内核 panic 时运行的程序叫什么, pid 是多少
答: p p->name 输出 "initcode\000\000\000\000\000\000\000" 所以程序是 user/initcode, p p->uid 输出 1 所以 pid 是 1
3. QEMU 的使用
<C-a>c 可以进入 qemu shell ,再按一遍可以回到虚拟机
3.1. QEMU Shell
info mem 可以查看当前分页表
4. System call tracing
设计一个 trace 系统调用,参数是一个 bit mask ,比如传入 1 << SYS_fork 表示跟踪 fork 系统调用,当对应的 sys_exit 即将返回时要进行一行输出,格式为 {pid}: syscall {syscall_name} -> {return_value} ,注意子进程的系统调用也要一起跟踪
我的答案
1 | diff --git a/user/user.h b/user/user.h |
5. Sysinfo
添加一个系统调用 sysinfo 收集正在运行的系统的信息,唯一一个参数是 struct sysinfo* (kernel/sysinfo.h) ,这个系统调用应该对里面的成员变量赋值:
freemem: 可用内存的数量,单位: bytesnproc: 状态为UNUSED的进程的数量
我的答案
1 | diff --git a/user/user.h b/user/user.h |
这里不能通过 user/sysinfotest.c 的 testproc 测试,大概率是测试代码写错了:
1 | // user/sysinfotest.c 简化版 |
这里第 13 行创建了个子进程后测试要求新的 sysinfo.nproc 比创建子进程之前多 1 ,显然是错误的,因为题目要求 sysinfo.nproc 是 UNUSED 状态的进程数量,所以新的 sysinfo.nproc 应该比创建子进程之前少 1 。
最后偷懒在
kernel/sysproc.cinclude 了sysinfo.h头文件,直接在里面实现了整个系统调用,可能不是很优雅,但我已经猪脑过载了,不知道如果想解耦的话应该怎么操作。但好像 include 了sysinfo.h的除了user/sysinfotest.c之外就没有了,要往struct sysinfo里写数据,再怎么解耦也逃不了 includesysinfo.h。
6. 系统调用的过程
因为我是 dinner 所以再总结一下防止猪脑过载。比如进程调用了 fork ,这个系统调用声明在 user/user.h ,但是没有具体的 C 代码实现,因为 C 代码不是万能的,它的实现在 user/usys.S (user/usys.pl 自动生成) 里,是汇编代码,把系统调用对应的系统调用号 SYS_fork (kernel/syscall.h) 放进寄存器 a7 里
为什么
user/usys.S可以用定义在 C 头文件kernel/syscall.h里的SYS_fork之类的符号(宏?):因为在Makefile里它被CC编译,CC会进行预处理把SYS_fork之类的符号按照kernel/syscall.h定义的值进行替换
然后通过 ecall 指令,经过目前还不知道(现在知道了)的途径进入了 kernel/trampoline.S ,它会跳进 kernel/trap.c:36 的 usertrap 里,然后 syscall (kernel/syscall.c:162) 被调用,根据 p->trampframe->a7 的值就能在 syscalls (kernel/syscall.c:109) 找到 fork 的真正实现 sys_fork (kernel/sysproc.c:25) 。
现在知道了,内核在初始化时 (
kernel/trap.c:29) 会给stvec寄存器写入kernel/trampoline.S中的uservec(kernel/trampoline.S:21) 的地址, RISC-V CPU 接收到ecall指令后会转到stvec指向的地址继续执行