6.S081 Lab: system calls

这一节实验,我们将深入操作系统的内核源码,为 xv6 内核添加新的系统调用。这将会非常有趣。

[moderate] System call tracing

本节中,任务是使用户态程序 trace 正常运行,该程序能够追踪某个命令涉及的系统调用,并打印每次系统调用的相关信息。

在这个作业中,你将添加一个系统调用跟踪功能,这在后续实验调试时可能会对你有所帮助。你将创建一个新的 trace 系统调用来控制跟踪。它应该接受一个参数,即一个整数“掩码”(mask),其各个位指定要跟踪哪些系统调用。例如,要跟踪 fork 系统调用,程序会调用 trace(1 << SYS_fork),其中 SYS_fork 是来自 kernel/syscall.h 的系统调用号。你必须修改 xv6 内核,使得当每个系统调用即将返回时,如果该系统调用的编号在掩码中被设置,就打印一行信息。该行应包含进程 id、系统调用的名称和返回值;你不需要打印系统调用参数。trace 系统调用应该为调用它的进程以及它随后创建的任何子进程启用跟踪。

  • 在 Makefile 中把 $U/_trace 添加到 UPROGS。

  • 运行 make qemu,你会看到编译器无法编译 user/trace.c,因为该系统调用的用户空间桩(stub)还不存在:向 user/user.h 添加该系统调用的原型,向 user/usys.pl 添加一个桩,并向 kernel/syscall.h 添加一个系统调用号。Makefile 会调用 perl 脚本 user/usys.pl,它生成 user/usys.S,即实际的系统调用桩,这些桩使用 RISC-V 的 ecall 指令转换到内核。修复编译问题后,运行 trace 32 grep hello README;它会失败,因为你还没有在内核中实现该系统调用。

  • 在 kernel/sysproc.c 中添加一个 sys_trace() 函数来实现这个新系统调用,方法是在 proc 结构体(见 kernel/proc.h)中的一个新变量里记住它的参数。从用户空间获取系统调用参数的函数在 kernel/syscall.c 中,你可以在 kernel/sysproc.c 中看到它们的使用示例。

  • 修改 fork()(见 kernel/proc.c),把跟踪掩码从父进程复制到子进程。

  • 修改 kernel/syscall.c 中的 syscall() 函数以打印跟踪输出。你需要添加一个系统调用名称数组以便索引。

在开始,由于 trace 程序内部使用了 trace 系统调用,而 xv6 中尚未定义这个系统调用,QEMU 是无法编译通过的。

#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) {               //此处使用了 trace
    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/user.h 声明了用户空间可以使用的系统调用,使得用户空间可以以类似 C 函数调用一样就行系统调用。所以需要在该头文件中为 trace 系统调用添加一行声明。

int trace(int);

系统调用通常返回 int 类型,返回值小于 0 代表系统调用失败;直接返回 0 只代表调用成功,也可以返回大于 0 的数据,代表一些真实数据。如文件描述符。

声明完成后,还需要在 user/usys.pl 中为该系统调用添加一个“桩”。 user/usys.pl 是一个 perl 脚本,用于为系统调用自动生成一段汇编代码。当程序进行一次系统调用时,就需要执行这一小段汇编代码,其中最主要的就是 ecall ,用于陷入内核态。

需要写入一个条目:

entry("trace");

对于每个 entry ,下面这个脚本会生成对应的汇编代码。

sub entry {
    my $prefix = "sys_";
    my $name = shift;
    if ($name eq "sbrk") {
    print ".global $prefix$name\n";
    print "$prefix$name:\n";
    } else {
    print ".global $name\n";
    print "$name:\n";
    }
    print " li a7, SYS_${name}\n";
    print " ecall\n";
    print " ret\n";
}

比如 trace 生成的汇编桩代码如下:

trace:
 li a7, SYS_trace
 ecall
 ret

生成的汇编代码使用了 SYS_trace 这个系统调用号需要在 kernel/syscall.h 中定义,使用一个没有被其它系统调用占用的数字即可。

// System call numbers
#define SYS_fork   1
#define SYS_exit   2
#define SYS_wait   3
#define SYS_pipe   4
#define SYS_read   5
#define SYS_kill   6
#define SYS_exec   7
#define SYS_fstat  8
#define SYS_chdir  9
#define SYS_dup    10
#define SYS_getpid 11
#define SYS_sbrk   12
#define SYS_pause  13
#define SYS_uptime 14
#define SYS_open   15
#define SYS_write  16
#define SYS_mknod  17
#define SYS_unlink 18
#define SYS_link   19
#define SYS_mkdir  20
#define SYS_close  21
#define SYS_sync   22
#define SYS_trace  23           //添加的系统调用号

系统调用号通过 kernel/syscall.c 内部的一个系统调用分派表对应到具体的系统调用函数。

// An array mapping syscall numbers from syscall.h
// to the function that handles the system call.
static uint64 (*syscalls[])(void) = {
  // clang-format off
  [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_pause]   = sys_pause,
  [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_sync]    = sys_sync,
  [SYS_trace]   = sys_trace,            //添加的系统调用号到具体系统调用函数的映射
  // clang-format on
};

那么这个函数又是在哪声明的呢?在 kernel/syscall.c 文件内所有的系统调用都使用 extern 关键词定义,具体的函数可以位于其他文件中,因为在编译系统内核时可以通过链接找到相应的符号。在当前文件声明一个 外部 sys_trace 函数。

// Prototypes for the functions that handle system calls.
extern uint64 sys_fork(void);
extern uint64 sys_exit(void);
extern uint64 sys_wait(void);
extern uint64 sys_pipe(void);
extern uint64 sys_read(void);
extern uint64 sys_kill(void);
extern uint64 sys_exec(void);
extern uint64 sys_fstat(void);
extern uint64 sys_chdir(void);
extern uint64 sys_dup(void);
extern uint64 sys_getpid(void);
extern uint64 sys_sbrk(void);
extern uint64 sys_pause(void);
extern uint64 sys_uptime(void);
extern uint64 sys_open(void);
extern uint64 sys_write(void);
extern uint64 sys_mknod(void);
extern uint64 sys_unlink(void);
extern uint64 sys_link(void);
extern uint64 sys_mkdir(void);
extern uint64 sys_close(void);
extern uint64 sys_sync(void);
extern uint64 sys_trace(void);          //声明的外部函数

理论上,现在可以按照任务要求在 kernel/sysproc 实现具体的 sys_trace 系统调用了,但我有必要在这打断一下,梳理从系统调用指令 ecall 到 sys_trace 的完整路径。

系统调用路径,从 ecall 到 sys_trace

当用户程序调用 trcae() 时,在汇编的层面就是调用了前面的汇编桩代码,先将系统调用号载入寄存器 a7 ,然后执行 ecall 指令。

硬件行为

ecall 指令会设置 CPU 为特权级,陷入内核态,然后将当前 PC 保存到 sepc 寄存器,作为内核完成系统调用后应该返回到用户程序的地址。接下来 PC 会被设置为 stvec(stvec 在内核在运行时设置为指向 uservec 的代码),所以内核会开始执行 uservec 汇编。这一部分过程都是硬件完成的。

uservec 汇编

uservec 是一段定义在 kernel/tramponline.S 的汇编代码,这一段汇编用于加载当前进程的 trapframe 地址,保存所有寄存器状态,类似函数调用时的栈帧来保存现场。该段汇编还加载内核栈指针、加载内核页表。

 # initialize kernel stack pointer, from p->trapframe->kernel_sp
 ld sp, 8(a0)
​
 # make tp hold the current hartid, from p->trapframe->kernel_hartid
 ld tp, 32(a0)
​
 # load the address of usertrap(), from p->trapframe->kernel_trap
 ld t0, 16(a0)
​
 # fetch the kernel page table address, from p->trapframe->kernel_satp.
 ld t1, 0(a0)
​
 # wait for any previous memory operations to complete, so that
 # they use the user page table.
 sfence.vma zero, zero
​
 # install the kernel page table.
 csrw satp, t1
​
 # flush now-stale user entries from the TLB.
 sfence.vma zero, zero

之后则使用 jalr t0 指令跳转执行 usertrap() ,执行 C 语言代码。

usertrap 函数、syscall 函数

usertrap 函数会先对系统调用进行一系列检查,设置一些参数,最后进入 syscall 函数。

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]();
} else {
 printf("%d %s: unknown sys call %d\n",
         p->pid, p->name, num);
 p->trapframe->a0 = -1;
}
}

可以看到 num 是从 trapframe 结构体中国取出的 a7,而这个寄存器就是在系统调用时存放系统调用号的寄存器。syscalls[num]() 从前面讲到的系统调用分派表使用函数指针进行调用。如此一来,从 ecall 到具体的系统调用过程都被理清楚了。

接下来实现 sys_trace 系统调用,根据任务描述,需要先在进程结构体 proc 中新添一个参数作为跟踪掩码,所以需要先在 proc 结构体中添加 trace_mask 字段。

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 trace_mask;              //新定义的跟踪掩码
};

那么 sys_trace 的实现方式就很清晰了,调用该系统调用的进程的追踪掩码都应该被设置为传入的值。

注意的是系统调用的参数都需要从 trapframe 中获取,使用 argint 函数能够很方便地从该结构体中获取参数。下面的调用代表从 trapframe 获取第零个参数并赋值给 mask 。

uint64
sys_trace(void)
{
  int mask;
  argint(0, &mask);
  myproc()->trace_mask = mask;
  return 0;
}

加下来需要修改 kernel/proc.c 中 kfork 函数的逻辑(由于代码版本这里 fork 的实现名字为 kfork ),让子进程能够继承父进程的追踪掩码。

int
kfork(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;
​
  //添加的追踪掩码继承语句
  np->trace_mask = p->trace_mask;
  
  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);
​
  // Cause fork to return 0 in the child.
  np->trapframe->a0 = 0;
​
  // 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;
}

按照任务描述,接下来需要修改 syscall 的实现,使其每次执行系统调用前检查进程是否具有追踪掩码,并根据追踪掩码和进程正在进行的系统调用决定是否打印相关信息。

为了能够打印正确的字符串,需要创建一个系统调用号到系统调用名的映射表。

static char *syscall_names[] = {
[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_pause]   "pause",
[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_sync]    "sync",
[SYS_trace]   "trace",
};

只用在真正的系统调用进行后检查追踪掩码,并打印即可。

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 (p->trace_mask & (1 << num);)
      printk("%d: syscall %s -> %d\n", p->pid, syscall_names[num], p->trapframe->a0);
  } else {
    printk("%d %s: unknown sys call %d\n", p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

[moderate] Sysinfo

本节实验需要实现一个 sysinfo 系统调用,该系统调用能够返回当前剩余的内存和正在使用的进程。

在这个作业中,你将添加一个系统调用 sysinfo,用于收集正在运行系统的信息。该系统调用接受一个参数:一个指向 struct sysinfo 的指针(参见 kernel/sysinfo.h)。内核应该填写这个结构体的字段:freemem 字段应设置为空闲内存的字节数,nproc 字段应设置为状态不是 UNUSED 的进程数。我们提供了一个测试程序 sysinfotest;如果它打印出“sysinfotest: OK”,你就通过了这个作业。

  • 将 $U/_sysinfotest 添加到 Makefile 中的 UPROGS。

  • 运行 make qemu;user/sysinfotest.c 将无法编译。添加系统调用 sysinfo,按照上一个作业中的相同步骤进行。要在 user/user.h 中声明 sysinfo() 的原型,你需要预先声明 struct sysinfo 的存在:

    struct sysinfo;
    int sysinfo(struct sysinfo *);
  • 一旦你修复了编译问题,运行 sysinfotest;它会失败,因为你还没有在内核中实现该系统调用。

  • sysinfo 需要将一个 struct sysinfo 复制回用户空间;关于如何使用 copyout() 完成此操作,请参见 sys_fstat()(kernel/sysfile.c)和 filestat()(kernel/file.c)的示例。

  • 要收集空闲内存量,请向 kernel/kalloc.c 添加一个函数。

  • 要收集进程数,请向 kernel/proc.c 添加一个函数。

跳过前面一系列声明的过程,直接快进到在 kernel/sysproc.c 中实现系统调用。由于需要在 kernel/sysproc.c 中使用 sysinfo 结构体,需要包含 sysinfo.h 头文件。这个系统调用的主要逻辑是接收用户传入的第零个参数,也就是 sysinfo 结构体指针。

sysinfo 结构体需要在 user/user.h 中声明,该结构体对用户程序而言仅仅是可见的,在进行系统调用之前这上面没有有意义的数据。系统调用会声明一个临时的 sysinfo 结构体,并从全局获取可用内存空间、进程总个数这些数据并填充,然后将数据复制到用户空间指针指向的内存。

按照上面的思想,sys_sysinfo 系统调用就可以按照以下实现:

uint64
sys_sysinfo(void)
{
  uint64 uinfo;
  struct proc *p;
  struct sysinfo kinfo;
​
  argaddr(0, &uinfo);
​
  kinfo.freemem = freemem();
  kinfo.nproc = nproc();
​
  p = myproc();
  if(copyout(p->pagetable, p->sz, uinfo, (char *)&kinfo, sizeof(kinfo)) < 0) 
    return -1;
  return 0;
}

这个系统调用的实现函数有一个常见的踩坑点,当尝试将 kinfo 结构体复制到用户空间时,经常会有人直接使用用户态传入的指针 uinfo 作为目标地址。需要知道由于内核和用户态程序处于不同的内存空间中,用户态指针对于内核空间而言其实毫无意义,需要使用专门的 copyout 函数用于将内核数据复制给用户。

copyout 接收的五个参数分别是用户进程的页表、进程用户空间的大小、目标虚拟地址、源地址、拷贝的字节数。一些版本可能不需要进程用户空间大小的参数,这个参数主要用于安全检测,防止越界。

freemem 和 nproc 函数还需要分别在 kernel/kalloc.c 和 kernel/proc.c 中实现。对于 freemem 函数,需要从 kmem 结构体中计算。该结构体串联了一个空闲链表,用于管理所有的空闲物理页。

struct {
  struct spinlock lock;
  struct run *freelist;
} kmem;

只需要遍历这个链表,将所有空闲链表的个数乘以页大小 PGSIZE 即可算出物理内存剩余的总空间。

int
freemem(void)
{
  struct run *r;
  int n = 0;
​
  acquire(&kmem.lock);
  for(r = kmem.freelist; r; r = r->next)
    n += PGSIZE;
  release(&kmem.lock);
​
  return n;
}

接下来是 nproc 函数,该函数需要使用在 kernel/proc.c 中定义的 proc 全局进程表,该数组记的每一个元素都是一个 proc 结构体,记录了所有进程的状态,从中筛选出状态不为 UNUSED 的个数即可完成任务。

int
nproc(void)
{
  int count = 0;
​
  for(int i = 0; i < NPROC; i++) {
    acquire(&proc[i].lock);
    if(proc[i].state != UNUSED) count++;
    release(&proc[i].lock);
  }
​
  return count;
}

在编写这两个函数的时候,发现每一个函数都需要在遍历链表或数组时加锁。这是由于 xv6 是一个多核的操作系统,不加锁的话可能会出现多个 CPU 访问同一份数据的情况,虽然两个函数没有进行写操作,但是为了防止读到其他 CPU 改了一半的数据也要加锁。

xv6 内部很多结构体在记录数据的同时也会提供一份锁,前面的 kmem 和 proc 都有专门的锁字段。