MIT 6.S081 syscall 实验篇(lab2):System call tracing (moderate)

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。输出里 3grep 的进程 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 的每一位表示一个系统调用是否启用,比用数组更省空间、判断更快:

  • 启用系统调用 nummask |= (1 << num)
  • 判断是否启用 → (1 << num) & mask 非零即启用

本实验中 trace(mask) 把 mask 存进进程,之后每次进 syscall() 都拿当前系统调用号 num 做一次 (1 << num) & p->trace_mask 即可决定打不打印。理解了这个,后面代码里那行位运算就毫无神秘感了。


实现思路

顺着"系统调用执行顺序",需要改动的地方其实是一条连贯的链:

  1. 用户态要能调到 trace → 在 user/user.h 声明、user/usys.pl 生成跳板、Makefile_trace 编进镜像。
  2. 内核要认得这个新系统调用 → 在 kernel/syscall.h 分配一个系统调用号 SYS_trace
  3. 进程要记住"我要跟踪哪些调用" → 在 struct proc 里加 trace_mask 字段,新建进程默认 0、fork 时子进程继承父进程。
  4. trace(mask) 的内核实现 → 在 kernel/sysproc.c 里写 sys_trace(),把用户传进来的 mask 存进 myproc()->trace_mask
  5. 真正打印 → 在 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 分配的 proctrace_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_exitsys_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 → usertrapsyscall 分发 → sys_xxx → 返回"的完整链路。
  • 位运算掩码 :用 1 << n& 做"开关集合"是极常见的技巧,能把"为什么用 mask 而不是数组"讲清楚会显得很扎实。
  • 进程状态继承fork 时哪些状态该继承(如 trace_maskcwd、文件描述符)、哪些该重置(如 pidtrace_mask 其实不该重置)------本实验的 fork 处理就是个具体样例。

建议把本实验当成"添加系统调用的模板"存档,后面 labs 里还时不时要加新系统调用,忘了流程回来瞄一眼即可。

相关推荐
凉、介2 小时前
ARMv8-A 指令学习
linux·笔记·学习·嵌入式·arm·指令
_张一凡2 小时前
Ubuntu 完美安装 FT_SCServo_Debug_Qt 飞特SCS_STS舵机调试工具
linux·ubuntu·舵机调试
星恒随风3 小时前
Linux 基础指令(三):grep、压缩打包、系统信息与 Shell 运行原理
linux·笔记·学习
Tyfrank3 小时前
Linux内核收包路径及中断
linux·运维·单片机
男孩李3 小时前
浅谈Linux的last命令
java·linux·服务器
yunqi3 小时前
ELK日志平台架构详解:从Filebeat采集到Elasticsearch检索的企业级日志方案实践
linux
手揽回忆怎么睡4 小时前
Ubuntu 22.04 64位安装MinIO
linux·前端·ubuntu
阳光九叶草LXGZXJ4 小时前
达梦数据库-报错-11-cmd 13 validate error
linux·运维·数据库·sql·学习
~光~~4 小时前
【嵌入式linux学习_OV8858 bring up】L1_从Sensor到RK3588发生了什么
linux·运维·学习