文章

[mit6.s081] Lab2: systen calls 实验记录

[mit6.s081] Lab2: systen calls 实验记录

这是实验过程中参考的一些相关资源:

Lab 2:system calls

操作系统需要同时满足三个要求:

  • 多路复用:多个进程共享 CPU、内存和设备;
  • 隔离:用户程序不能任意访问内核或其他进程;
  • 交互:进程可以通过文件描述符、管道和系统调用协作。

xv6 用进程作为主要的隔离单位。一个进程把地址空间和执行线程绑定在一起:

flowchart TB
    P[进程]
    P --> A[地址空间]
    P --> T[执行线程]
    A --> A1[页表]
    A --> A2[用户内存]
    T --> T1[用户栈]
    T --> T2[内核栈]
    T --> T3[调度上下文]

本实验增加的 trace 是进程的一项配置:该进程以及它通过 fork() 派生的子进程,哪些系统调用需要打印。

系统调用本身也是一种抽象:用户程序不能直接调用内核函数,而是按照约定发起受控请求。

flowchart TB
    U[用户程序] --> I[系统调用接口]
    I --> E[ecall 跨越特权边界]
    E --> D[内核分派器]
    D --> K[具体内核实现]
    K --> R[返回值与状态]

启动背景

理解 trace 前,需要掌握 xv6 启动链路的几个节点:

flowchart TB
    A[entry.S 设置内核栈] --> B[start.c 切换到 Supervisor mode]
    B --> C[main.c 初始化内核]
    C --> D[userinit 创建第一个进程]
    D --> E[scheduler 调度]
    E --> F[initcode.S 执行 exec]
    F --> G[init.c 启动 shell]

kernel/proc.h:进程状态的容器

proc.h 定义了进程、CPU 和寄存器现场。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct proc {
  struct spinlock lock;
  enum procstate state;
  int pid;

  uint64 kstack;
  uint64 sz;
  pagetable_t pagetable;
  struct trapframe *trapframe;
  struct context context;

  struct file *ofile[NOFILE];
  struct inode *cwd;
  char name[16];
};

几个字段要区分:

  • pagetable:用户地址空间;
  • kstack:进入内核后使用的栈;
  • trapframe:用户态寄存器现场;
  • context:内核线程在调度切换时保存的现场;
  • state:UNUSED、RUNNABLE、RUNNING 等。

trace_mask 描述进程以后如何被跟踪,因此应该放进 struct proc,而不是放在某个函数的局部变量里。

kernel/defs.h:模块接口

defs.h 只声明内核模块提供的函数,不负责实现。例如 main.c 通过它调用 kinit()、kvminit()、userinit() 和 scheduler()。真正的实现位于 kalloc.c、vm.c、proc.c 等文件中。

这体现了接口思维:调用者只依赖函数的名字、参数和返回值,不需要知道实现细节。

kernel/entry.S 和 kernel/main.c

entry.S 是最早执行的汇编代码。它为每个 CPU 设置内核栈,然后调用 start()。start() 完成 Machine mode 下的早期配置,并通过 mret 进入 Supervisor mode 的 main()。

main() 负责初始化内存、页表、进程表、陷入处理、设备和文件系统,最后调用 userinit() 创建第一个用户进程,再进入 scheduler()。

user/initcode.S 和 user/init.c

initcode.S 是第一个临时用户程序。它把 SYS_exec 放入 a7,把 "/init" 和参数地址放入 a0/a1,执行 ecall。

成功后,当前进程的用户映像被替换成 /init,也就是 user/init.c 编译出来的程序。init.c 打开控制台,设置文件描述符 0、1、2,然后 fork() 并 exec("sh") 启动 shell。

因此:

1
2
initcode.S:把系统推进到 /init
init.c:启动控制台和 shell

实验目标:让内核跟踪系统调用

命令形式:

1
trace mask command [arguments ...]

示例:

1
trace 32 grep hello README

如果 SYS_read == 5,那么:

1
1 << 5 = 32

32 表示跟踪 read。内核在每个系统调用返回前检查 mask,如果对应位被设置,就打印:

1
pid: syscall name -> return_value

例如:

1
3: syscall read -> 1023

这里的设计可以分成两部分:

1
2
sys_trace():设置进程的 trace_mask
syscall():每次调用完成后,根据 trace_mask 决定是否打印

用户程序:user/trace.c

trace 命令本身不跟踪系统调用,它只做两件事:设置 mask,然后用 exec() 变成目标程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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];
  nargv[i-2] = 0;

  exec(nargv[0], nargv);
  exit(0);
}

对于:

1
trace 32 grep hello README

它会构造:

1
nargv = { "grep", "hello", "README", 0 }

然后执行:

1
2
trace(32);
exec("grep", nargv);

参数数组必须以 0 结尾,否则 exec() 不知道参数在哪里结束。

exec() 替换的是当前进程的用户代码、数据、栈和页表,不是整个 struct proc,所以 pid、父进程、打开的文件和 trace_mask 等进程属性仍然存在:

flowchart TB
    A[trace 进程] --> B[trace 设置 mask]
    B --> C[exec 替换用户映像]
    C --> D[同一进程变成 grep]
    D --> E[grep 继续使用 mask]

第一步:接通系统调用接口

新增系统调用时,用户和内核必须共同遵守一套协议:

flowchart TB
    A[用户函数声明] --> B[用户态调用桩]
    B --> C[系统调用号]
    C --> D[内核分派表]
    D --> E[内核入口函数]

用户接口

在 user/user.h 中加入:

1
int trace(int);

这只是用户 C 程序看到的接口声明。

系统调用号

在 kernel/syscall.h 中加入:

1
#define SYS_trace 22

这是用户态和内核态共同使用的编号。

调用桩

在 user/usys.pl 中加入:

1
entry("trace");

Makefile 会运行这个 Perl 脚本生成 user/usys.S,其中大致是:

trace:
    li a7, SYS_trace
    ecall
    ret

调用桩(stub)是用户态的一小段汇编桥梁,负责按 ABI 准备寄存器并执行 ecall。使用脚本生成,是因为每个系统调用桩结构几乎相同,脚本可以减少重复代码和编号错误。

把用户程序加入镜像

在 Makefile 的 UPROGS 中加入:

1
$U/_trace\

否则 trace 不会被写入 xv6 的文件系统镜像,启动后也就找不到这个命令。

ecall、寄存器和 trap

调用桩执行:

li a7, SYS_trace
ecall

两条指令的职责不同:

  • a7 传递“要调用哪个系统调用”;
  • ecall 触发 CPU 从 User mode 进入 Supervisor mode。

ecall 不会直接调用 sys_trace()。它只产生一个 trap,CPU 跳到内核设置的入口。trampoline.S 保存用户寄存器并切换到进程的内核栈,随后进入 usertrap()。

xv6 使用的寄存器约定是:

1
2
3
a0-a5:系统调用参数
a7:系统调用号
a0:系统调用返回值

进入内核后,寄存器被保存到:

1
2
3
4
p->trapframe->a0
p->trapframe->a1
...
p->trapframe->a7

usertrap() 看到 scause == 8,判断这是用户态执行 ecall,然后调用 syscall()。处理完成后,内核把返回值放回 trapframe->a0,通过 sret 回到用户态。

syscall():系统调用分派器

kernel/syscall.c 中的 syscalls[] 是函数指针数组:

1
2
3
4
5
6
7
static uint64 (*syscalls[])(void) = {
  [SYS_fork]  sys_fork,
  [SYS_exit]  sys_exit,
  [SYS_read]  sys_read,
  [SYS_exec]  sys_exec,
  [SYS_trace] sys_trace,
};

它把数字协议映射到内核函数:

1
2
SYS_trace = 22
syscalls[22] = sys_trace

分派逻辑:

1
2
3
4
5
6
num = p->trapframe->a7;

if(num > 0 && num < NELEM(syscalls) && syscalls[num])
  p->trapframe->a0 = syscalls[num]();
else
  p->trapframe->a0 = -1;

因此:

1
syscalls[SYS_trace]();

等价于:

1
sys_trace();

这就是系统调用“接口”和“实现”的连接点。

sys_trace():修改当前进程状态

在 kernel/sysproc.c 中:

1
2
3
4
5
6
7
8
9
10
11
uint64
sys_trace(void)
{
  int mask;

  if(argint(0, &mask) < 0)
    return -1;

  myproc()->trace_mask = mask;
  return 0;
}

调用链是:

flowchart TB
    A[trace 32] --> B[调用桩设置 a7]
    B --> C[ecall 进入内核]
    C --> D[usertrap]
    D --> E[syscall 查表]
    E --> F[sys_trace 读取参数]
    F --> G[保存 trace_mask]

argint(0, &mask) 读取第 0 个系统调用参数。sys_trace() 的职责很小:读取参数、找到当前进程、保存配置、返回结果。它不负责打印后续调用。

trace_mask 的生命周期

新进程:默认清零

进程表是固定数组,进程退出后槽位会变成 UNUSED,以后可能被新进程复用。因此 allocproc() 中要写:

1
p->trace_mask = 0;

这防止新进程继承旧进程槽位中残留的 mask。

fork():复制有语义的状态

fork() 不会原样复制整个 struct proc。PID、内核栈、页表、锁和调度上下文都要重新建立;用户内存、寄存器和部分进程属性则按语义复制。

为了让跟踪对派生出的子进程继续有效:

1
np->trace_mask = p->trace_mask;

父子进程可以执行不同的系统调用,但各自使用相同规则过滤自己的调用。

flowchart TB
    A[父进程已有 mask] --> B[fork]
    B --> C[子进程复制 mask]
    C --> D[父子独立执行]
    D --> E[分别按 mask 过滤]

exec():替换程序,不替换进程

exec() 把当前进程的用户程序替换成另一个程序,但 PID、父进程关系、文件描述符和 trace_mask 仍属于当前进程。这正是 trace 命令先设置 mask、再 exec() 目标程序的原因。

在统一分派处打印跟踪结果

系统调用名称表:

1
2
3
4
5
static char *syscall_names[] = {
  [SYS_read]  "read",
  [SYS_exec]  "exec",
  [SYS_trace] "trace",
};

在 syscall() 中,先调用真正的系统调用,再检查 mask:

1
2
3
4
5
6
7
8
p->trapframe->a0 = syscalls[num]();

if((p->trace_mask & (1U << num)) != 0){
  printf("%d: syscall %s -> %d\n",
         p->pid,
         syscall_names[num],
         p->trapframe->a0);
}

顺序必须是:

1
2
3
4
5
6
7
执行 sys_xxx()
    ↓
得到返回值
    ↓
检查 trace_mask
    ↓
打印名称和返回值

这样所有系统调用都经过统一逻辑,而不需要在每个 sys_xxx() 里重复写打印代码。

位运算中的括号

要写:

1
if((p->trace_mask & (1U << num)) != 0)

不要写:

1
if(p->trace_mask & (1U << num) != 0)

后者会被 C 解析成:

1
p->trace_mask & ((1U << num) != 0)

不是在检查第 num 位;在 -Werror 下还会直接导致编译失败。

一次命令的完整流程

以:

1
trace 32 grep hello README

为例:

flowchart TB
    A[用户运行 trace] --> B[trace 设置 mask]
    B --> C[exec 替换成 grep]
    C --> D[grep 发起 read]
    D --> E[内核执行 sys_read]
    E --> F[检查 mask]
    F --> G[打印 read 返回值]

更细的 trace(32) 调用链是:

1
2
3
4
5
6
7
8
9
trace.c: trace(32)
  → user/usys.S: a7 = SYS_trace; ecall
  → trampoline.S: 保存用户寄存器
  → usertrap(): 识别系统调用
  → syscall(): 读取 trapframe->a7
  → syscalls[SYS_trace]
  → sys_trace(): p->trace_mask = 32
  → trapframe->a0 = 0
  → usertrapret() → sret

之后 exec("grep", ...) 替换用户映像。grep 执行 read() 时,又重复同一条系统调用入口路径,只是这次 a7 == SYS_read,分派到 sys_read(),返回后根据 mask 打印。

sysinfo:读取系统整体状态

它和 trace 的区别在于:trace 修改当前进程状态,而 sysinfo 收集内核中的全局信息,再把一个结构体复制回用户空间。

需求如何映射到代码

题目要求 sysinfo(struct sysinfo *) 返回两个字段:

1
2
3
4
struct sysinfo {
  uint64 freemem;  // 空闲内存字节数
  uint64 nproc;    // 状态不是 UNUSED 的进程数
};

把题目拆开后,代码位置就很明确:

需求数据来源或实现位置
用户接口user/user.h
调用桩user/usys.pl
调用号和分派kernel/syscall.h、kernel/syscall.c
返回结构体kernel/sysproc.c
空闲内存kernel/kalloc.c
进程数量kernel/proc.c

完整关系是:

flowchart TB
    A[sysinfo 用户接口] --> B[读取用户地址]
    B --> C[统计空闲内存]
    B --> D[统计进程数量]
    C --> E[填充 sysinfo]
    D --> E
    E --> F[copyout 返回用户空间]

接通系统调用接口

和 trace 一样,先完成四个接口登记:

1
2
3
// user/user.h
struct sysinfo;
int sysinfo(struct sysinfo *);
1
2
# user/usys.pl
entry("sysinfo");
1
2
// kernel/syscall.h
#define SYS_sysinfo 23
1
2
3
4
// kernel/syscall.c
extern uint64 sys_sysinfo(void);

[SYS_sysinfo] sys_sysinfo,

SYS_sysinfo 是数字,sys_sysinfo 是内核函数,"sysinfo" 是打印名称,三者不能混用。

sys_sysinfo() 的职责

实现位于 kernel/sysproc.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint64
sys_sysinfo(void)
{
  uint64 addr;
  struct sysinfo info;
  struct proc *p = myproc();

  if(argaddr(0, &addr) < 0)
    return -1;

  info.freemem = freemem();
  info.nproc = nproc();

  if(copyout(p->pagetable, addr,
             (char *)&info, sizeof(info)) < 0)
    return -1;

  return 0;
}

它的职责只有四步:

1
2
3
4
读取用户指针
  → 在内核中收集数据
  → copyout() 写回用户空间
  → 返回 0 或 -1

argaddr() 只取得用户传入的地址;它不负责保证目标地址可写。真正的用户地址检查由 copyout() 完成,因此非法地址测试会在 copyout() 失败时返回 -1。

这和 sys_fstat() 的模式相同:系统调用入口解析参数,内核函数构造结果,最后通过 copyout() 返回用户空间。

统计空闲内存:freemem()

kernel/kalloc.c 中的物理页分配器用 kmem.freelist 保存空闲页。每个 struct run 节点代表一个大小为 PGSIZE 的空闲页。

1
2
3
4
5
6
7
8
9
10
11
12
13
uint64
freemem(void)
{
  uint64 bytes = 0;
  struct run *r;

  acquire(&kmem.lock);
  for(r = kmem.freelist; r; r = r->next)
    bytes += PGSIZE;
  release(&kmem.lock);

  return bytes;
}

这里必须注意:统计函数只能遍历链表,不能像 kalloc() 那样修改 kmem.freelist。因为统计过程和分配/释放可能并发发生,所以要持有 kmem.lock。

统计进程数量:nproc()

kernel/proc.c 中有固定大小的进程表:

1
struct proc proc[NPROC];

题目要求统计所有状态不是 UNUSED 的槽位,因此 USED、SLEEPING、RUNNABLE、RUNNING、ZOMBIE 都要计入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint64
nproc(void)
{
  uint64 count = 0;
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state != UNUSED)
      count++;
    release(&p->lock);
  }

  return count;
}

每个进程的 state 受对应的 p->lock 保护,因此读取前要加锁,读取后立即释放。这个函数返回的是某个瞬间的快照;其他 CPU 仍可能在统计过程中创建或退出进程。

测试程序如何验证实现

user/sysinfotest.c 主要验证三类行为:

  1. 非法地址:把无效指针传给 sysinfo(),必须返回 -1;
  2. 内存统计:用 sbrk(PGSIZE) 分配和释放一页,freemem 应分别减少和恢复 PGSIZE;
  3. 进程统计:fork() 后进程数增加 1,子进程退出并 wait() 后恢复。

运行:

1
2
3
make clean
make
make qemu

进入 xv6 后:

1
sysinfotest

成功输出:

1
2
sysinfotest: start
sysinfotest: OK

trace 和 sysinfo 的对比

特征tracesysinfo
作用修改当前进程的跟踪配置查询系统整体状态
状态位置struct proc.trace_maskkmem.freelist、proc[]
返回方式只返回整数状态码copyout() 返回结构体
fork() 关系子进程继承 mask统计结果包含子进程
主要锁进程锁kmem.lock 和进程锁

这两个实验分别展示了两种常见系统调用:

1
2
控制型系统调用:修改某个内核对象的状态
查询型系统调用:读取内核状态并复制给用户
本文由作者按照 CC BY 4.0 进行授权