System call tracing (moderate)
实验目标
本实验要实现一个 trace 功能:跟踪某个用户程序执行过程中调用了哪些系统调用,并把每次调用的名称与返回值打印出来 ,格式形如 进程id: syscall 系统调用名 -> 返回值。
trace 接受一个 掩码(mask) 参数:mask 的第 n 位为 1,就表示要跟踪系统调用号为 n 的系统调用。仓库中已经写好了用户态的 trace 入口程序(user/trace.c),它会解析命令行参数、调用 trace(mask),再 exec 真正的目标命令:
c
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
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);
}
注意:user/trace.c 是题目已经提供的,真正的 trace 系统调用内核实现还没有,这正是我们要补的。看下面两个运行例子,就能明白要达成的效果:
bash
$ trace 32 grep hello README
3: syscall read -> 1023
3: syscall read -> 966
3: syscall read -> 70
3: syscall read -> 0
bash
$ trace 2147483647 grep hello README
4: syscall trace -> 0
4: syscall exec -> 3
4: syscall open -> 3
4: syscall read -> 1023
4: syscall read -> 966
4: syscall read -> 70
4: syscall read -> 0
4: syscall close -> 0
32的二进制是100000,只有第 5 位为 1,对应SYS_read(在syscall.h中#define SYS_read 5),所以只跟踪read。输出里3是grep的进程 id,-> 1023表示read这次读了 1023 字节。2147483647的低 31 位全为 1,表示跟踪所有 系统调用,因此grep执行涉及的trace / exec / open / read / close全部被打印。
前置知识
要动手前,先搞清楚两件"隐藏知识"。
1. 一个系统调用从用户态到内核态的完整链路
用户程序调用 trace() 后,并不会直接进内核,而是走这样一条路径:
用户程序 trace()
↓ usys.S 里的跳板(ecall 指令)
↓ trampoline.S uservec(保存现场到 trapframe)
↓ usertrap()(kernel/trap.c)
↓ syscall()(kernel/syscall.c,统一分发入口)
↓ sys_trace()(真正的内核处理函数)
↓ 返回 syscall() → usertrapret() → userret → 回到用户态
所以所有系统调用的"总入口"都是 syscall()。只要在那里做一次打印判断,就能覆盖全部系统调用------这正是 trace 功能的天然落点。
2. 掩码为什么用位运算
系统调用号 num 是 0~20+ 的整数。用一个 uint 的每一位表示一个系统调用是否启用,比用数组更省空间、判断更快:
- 启用系统调用
num→mask |= (1 << num) - 判断是否启用 →
(1 << num) & mask非零即启用
本实验中 trace(mask) 把 mask 存进进程,之后每次进 syscall() 都拿当前系统调用号 num 做一次 (1 << num) & p->trace_mask 即可决定打不打印。理解了这个,后面代码里那行位运算就毫无神秘感了。
实现思路
顺着"系统调用执行顺序",需要改动的地方其实是一条连贯的链:
- 用户态要能调到
trace→ 在user/user.h声明、user/usys.pl生成跳板、Makefile把_trace编进镜像。 - 内核要认得这个新系统调用 → 在
kernel/syscall.h分配一个系统调用号SYS_trace。 - 进程要记住"我要跟踪哪些调用" → 在
struct proc里加trace_mask字段,新建进程默认 0、fork时子进程继承父进程。 trace(mask)的内核实现 → 在kernel/sysproc.c里写sys_trace(),把用户传进来的 mask 存进myproc()->trace_mask。- 真正打印 → 在
kernel/syscall.c里:把sys_trace注册进分发数组,并在syscall()统一入口处按 mask 打印;再准备一个sysnames[]数组把"调用号"翻译成"名字"用于打印。
一句话:加接口 → 分配号 → 存 mask → 实现处理函数 → 在分发入口打印。这条链也是 xv6 里"新增任何一个系统调用"的标准套路。
代码实现
user/user.h ------ 用户态函数声明
让编译期知道 trace(int) 这个用户态接口存在,否则 user/trace.c 调用 trace() 会报隐式声明:
c
// user/user.h
...
// system calls
int fork(void);
...
int uptime(void);
int trace(int);
user/usys.pl ------ 生成用户态→内核态跳板
usys.pl 是一个 Perl 脚本,编译期会为每一行 entry("xxx") 生成一小段汇编(放进 usys.S),这段汇编用 ecall 陷入内核、并把系统调用号塞进 a7 寄存器。补上 trace 即可:
c
// user/usys.pl
...
entry("sbrk");
entry("sleep");
entry("uptime");
entry("trace"); # 为 trace 添加系统调用接口
注意这是 Perl 注释(
#),不是 C 注释,写在这里语法正确。
Makefile ------ 把程序编进内核镜像
UPROGS 列出所有要随内核一起编译的用户程序,缺了这一行 user/trace 不会被构建,trace 命令就不存在:
c
...
UPROGS=\
...
$U/_wc\
$U/_zombie\
$U/_trace\
kernel/syscall.h ------ 分配系统调用号
为每个系统调用分配一个唯一整数编号。我们把它定为 22(紧接已有的 SYS_close 21):
c
// kernel/syscall.h
// System call numbers
#define SYS_fork 1
#define SYS_exit 2
#define SYS_wait 3
...
#define SYS_mkdir 20
#define SYS_close 21
#define SYS_trace 22 // trace系统调用号
kernel/proc.h ------ 进程控制块加 trace_mask 字段
"某进程要跟踪哪些系统调用"是进程私有的状态 ,最自然的位置就是 struct proc。掩码用 uint(32 位,足够覆盖当前所有系统调用号):
c
// kernel/proc.h
// Per-process state
struct proc {
struct spinlock lock;
...
// 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)
uint trace_mask; // 系统调用跟踪掩码,记录需要跟踪的系统调用
};
kernel/proc.c ------ 初始化默认值与 fork 继承
新字段必须初始化,否则 allocproc 分配的 proc 里 trace_mask 是随机垃圾值,可能一启动就乱打印。所以在 allocproc 里显式清 0:
c
// kernel/proc.c
static struct proc*
allocproc(void)
{
struct proc *p;
...
// Set up new context to start executing at forkret,
// which returns to user space.
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
p->trace_mask = 0; // 新创建的进程的 trace_mask 默认为 0
return p;
}
trace 的语义应当是"跟踪本进程及其之后派生的子进程"。所以在 fork() 返回前,把父进程的 trace_mask 复制给子进程(注意要放在 release(&np->lock) 之后、返回之前,避免锁问题):
c
// kernel/proc.c
int
fork(void)
{
int i, pid;
...
// 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;
np->state = RUNNABLE;
release(&np->lock);
np->trace_mask = p->trace_mask; // fork 出的子进程继承父进程的 trace_mask
return pid;
}
kernel/sysproc.c ------ 实现 sys_trace 处理函数
trace(mask) 在内核里要做的事很简单:取出用户传来的参数,写进当前进程的 trace_mask。参数通过 argint(0, &mask) 从 a0 寄存器读取(第 0 个参数):
c
uint64 sys_trace(void)
{
int mask;
if (argint(0, &mask) < 0) {
return -1;
}
myproc()->trace_mask = mask;
return 0;
}
为什么放在
sysproc.c?因为"操作当前进程"这类系统调用(如sys_exit、sys_fork的近亲)惯例都放这里。其实放哪都行,只要syscall.c里用extern声明了它。
kernel/syscall.c ------ 声明并注册处理函数
先在 syscall.c 顶部用 extern 声明 sys_trace,再把它挂进 syscalls[] 分发数组------数组下标就是系统调用号,元素是对应处理函数指针:
c
// kernel/syscall.c
extern uint64 sys_chdir(void);
extern uint64 sys_close(void);
extern uint64 sys_dup(void);
...
extern uint64 sys_write(void);
extern uint64 sys_uptime(void);
extern uint64 sys_trace(void); // 全局声明trace系统调用处理函数
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
...
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
[SYS_trace] sys_trace, // 系统调用号与处理函数关联
};
有了这层映射,syscall() 就能用 syscalls[num]() 调用到正确的处理函数。
kernel/syscall.c ------ 在统一入口打印跟踪信息
syscall() 是所有系统调用的分发点。它先从 trapframe->a7 取出系统调用号 num,调用对应处理函数并把返回值写回 a0;紧接着用前面讲的位运算判断 num 是否在 trace_mask 中,是就打印。这里的打印格式与实验要求完全一致:
c
// kernel/syscall.c
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 ((1 << num) & p->trace_mask) {
printf("%d: syscall %s -> %d\n", p->pid, sysnames[num], p->trapframe->a0);
}
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
关键点:
printf用的是p->trapframe->a0,也就是处理函数刚写回的返回值 ,所以-> 1023打印的正是最新一次系统调用的返回结果。
kernel/syscall.c ------ 系统调用名映射表 sysnames
打印时需要"调用号 → 名字"的翻译。sysnames[] 用和 syscalls[] 同样的 designated initializer 写法,按下标把编号映射到字符串:
c
// kernel/syscall.c
static const char* sysnames[] = {
[SYS_fork] "fork",
[SYS_exit] "exit",
[SYS_wait] "wait",
[SYS_pipe] "pipe",
[SYS_read] "read",
[SYS_kill] "kill",
[SYS_exec] "exec",
[SYS_fstat] "fstat",
[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",
};
验证
编译并运行测评脚本,指定只测 trace 子项:
bash
$ ./grade-lab-syscall trace
bash
== Test trace 32 grep == trace 32 grep: OK (3.6s)
== Test trace all grep == trace all grep: OK (0.7s)
== Test trace nothing == trace nothing: OK (0.6s)
== Test trace children == trace children: OK (12.7s)
复盘
本实验解决了什么
在系统调用统一分发入口 syscall() 处,用进程私有的 trace_mask 位掩码过滤,按需打印"进程号 + 调用名 + 返回值"。设计上干净、零侵入------不需要改动任何一个已有系统调用的实现,只在总入口加了一个判断。
新增系统调用的标准模板(5 步)
这个实验本身就是"给 xv6 加一个系统调用"的最小范本,后面的实验会反复用到,记牢这 5 步:
| 步骤 | 文件 | 做什么 |
|---|---|---|
| 1 | user/user.h |
声明用户态接口 int trace(int); |
| 2 | user/usys.pl |
加 entry("trace") 生成 ecall 跳板 |
| 3 | Makefile |
UPROGS 加 $U/_trace 编进镜像 |
| 4 | kernel/syscall.h |
分配 #define SYS_trace 22 |
| 5 | kernel/syscall.c + sysproc.c |
extern 声明 + 挂进 syscalls[] + 实现 sys_trace |
如果系统调用需要进程级状态(如本实验的 trace_mask),再额外在 struct proc 加字段并在 allocproc/fork 里维护初值与继承。
基本要求
- 系统调用全景 :能口述"用户态
ecall→ trampoline →usertrap→syscall分发 →sys_xxx→ 返回"的完整链路。 - 位运算掩码 :用
1 << n与&做"开关集合"是极常见的技巧,能把"为什么用 mask 而不是数组"讲清楚会显得很扎实。 - 进程状态继承 :
fork时哪些状态该继承(如trace_mask、cwd、文件描述符)、哪些该重置(如pid、trace_mask其实不该重置)------本实验的fork处理就是个具体样例。
建议把本实验当成"添加系统调用的模板"存档,后面 labs 里还时不时要加新系统调用,忘了流程回来瞄一眼即可。