xv6 的系统调用

系统中断与系统调用

Part 0: 预备阶段

  xv6 是通过 trap(和中断略有不同但处理流程相同)实现系统调用的,以下针对系统调用的流程做了具体分析。首先是vector.pl(Generate vectors.S, the trap/interrupt entry points)这个 perl 脚本,它在运行时生成 vector.S 用于存放中断处理程序的入口地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# vector.S
# generated by vectors.pl - do not edit
# handlers
.globl alltraps
.globl vector0
vector0:
  pushl $0
  pushl $0
  jmp alltraps
......
vector255:
  pushl $0
  pushl $255
  jmp alltraps

# vector table
.data
.globl vectors
vectors:
  .long vector0
  .long vector1
     ......
  .long vector255

  vectors 存放中断处理入口点,共 256 项;每一个中断处理做的事情是 push 0 和中断号并跳转至 trapasm.S: alltraps 执行后续操作。trap.c 中设置了 256 个中断门描述符,并将 T_SYSCALL 号中断的第二个参数设为1指定这是一个陷阱门,其 DPL 记为 DPL_USER(0x3):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// trap.c
// Interrupt descriptor table (shared by all CPUs).
struct gatedesc idt[256];
extern uint vectors[];  // in vectors.S: array of 256 entry pointers
struct spinlock tickslock;
uint ticks;

void
tvinit(void)
{
  int i;

  for(i = 0; i < 256; i++)
    SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
  SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);

  initlock(&tickslock, "time");
}

  trap.h 说明了用于处理系统调用的中断号为 64(0x40):

1
2
// trap.h
#define T_SYSCALL       64      // system call

  也就是说,所有的系统调用均由同一个第 64 号中断处理程序实现,因此需要区分不同的系统调用。syscall.h 定义了不同系统调用的调用号:

1
2
3
4
5
6
// syscall.h
// System call numbers
#define SYS_fork    1
#define SYS_exit    2
......
#define SYS_close  21

  在 usys.S 中实现了系统调用:

1
2
3
4
5
6
7
8
9
10
// usys.S
#include "syscall.h"
#include "traps.h"

#define SYSCALL(name) \
  .globl name; \
  name: \
    movl $SYS_ ## name, %eax; \
    int $T_SYSCALL; \  // 0x40
    ret

  由于系统调用名声明为 global,因此当用户程序链接 usys.o 后系统调用名对用户程序就可见了。系统调用的过程比较简单,先将系统调用号存入寄存器 %eax 中,再执行 int 0x40 触发64号系统中断。一旦触发了系统调用,cpu 就需要离开原来的指令,跳转至系统调用的代码执行;等系统调用完成后返回继续执行原来的指令,这就需要在触发系统调用时保存原先程序的上下文。xv6 会为每个进程创建 4KB 大小的内核栈保存上下文,结构体 proc.h: struct proc 存放了内核栈的地址 kstack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// proc.h
// Per-process state
struct proc {
  uint sz;                     // Size of process memory (bytes)
  pde_t* pgdir;                // Page table
  char *kstack;                // Bottom of kernel stack for this process
  enum procstate state;        // Process state
  int pid;                     // Process ID
  struct proc *parent;         // Parent process
  struct trapframe *tf;        // Trap frame for current syscall
  struct context *context;     // swtch() here to run process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

  其中指针 tf 指向 x86.h: struct trapframe 存放了具体的上下文信息,包括中断发生时 cpu 的寄存器状态等。该帧包含了 cpu 从当前进程的内核态恢复到用户态的所有信息。待上述事宜处理完成后,trapasm.S 会将 %esp 压栈作为参数,然后调用 trap.c: trap(struct trapframe *tf) 执行具体的中断处理程序:

1
2
3
4
5
6
7
8
9
10
11
# trapasm.S
  # vectors.S sends all traps here.
  .globl alltraps
alltraps:
  # Build trap frame.
  pushl %ds
.......
# Call trap(tf), where tf=%esp
  pushl %esp
  call trap
  addl $4, %esp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// trap.c
void
trap(struct trapframe *tf)
{
  if(tf->trapno == T_SYSCALL){ // 64
    if(myproc()->killed)
      exit();
    myproc()->tf = tf;
    syscall();
    if(myproc()->killed)
      exit();
    return;
  }
  ........

  syscall 的具体逻辑就很简单了,trap() 根据中断号 tf->trapno 判断是否是 syscall;当中断号为 64 时则调用 syscall.c: syscall(),取出 tf->eax 中的系统调用号再调用具体的内核函数。其中 syscalls 函数表储存了系统调用号对应的内核函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// syscall.c
static int (*syscalls[])(void) = {
[SYS_fork]    sys_fork,
......
[SYS_close]   sys_close,
};

void
syscall(void)
{
  int num;
  struct proc *curproc = myproc();
  // 系统调用号
  num = curproc->tf->eax;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // 保存系统调用函数的返回值
    curproc->tf->eax = syscalls[num]();
  } else {
    cprintf("%d %s: unknown sys call %d\n",
            curproc->pid, curproc->name, num);
    curproc->tf->eax = -1;
  }
}

  系统调用函数的第 n 个参数是通过函数 argintargptrargstrargfd 获得的,它们分别获取整数、指针、字符串起始地址和文件描述符,以 argint 为例:

1
2
3
4
5
6
// Fetch the nth 32-bit system call argument.
int
argint(int n, int *ip)
{
  return fetchint((myproc()->tf->esp) + 4 + 4*n, ip);
}

  用户栈顶位置为 %esp,参数就恰好在 %esp 之上(%esp + 4),因此第 n 个参数就在 %esp + 4 + 4*n。这里要注意,系统调用参数的大小只能是 4 个字节,因此它只能是int型或者指针。系统调用的实现 sysproc.csysfile.c 仅仅是封装,它们通过以上几个函数来解析参数(如果需要的话),然后调用真正的实现。系统调用结束后,trapasm.S 会将前面所有的寄存器(除了%eax)恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# trapasm.S
......
call trap
addl $4, %esp

  # Return falls through to trapret...
.globl trapret
trapret:
  popal
  popl %gs
  popl %fs
  popl %es
  popl %ds
  addl $0x8, %esp  # trapno and errcode
  iret

  最终,当整个系统调用完成后,cpu 恢复到原先进程的上下文,只有 %eax 改变成系统调用的返回值,cpu 继续执行 syscall 之后的代码,以上就是系统调用的大体流程。

Part 1: System call tracing

  我们的第一个任务是追踪系统调用,要求是当系统调用被启用时打印出它的名字和返回值。我们只需要在 syscall.c: syscall() 函数里添加该功能即可。为了打印方便,我们仿照系统调用函数表 static int (*syscalls[])(void) 定义一个系统调用函数名称表:

1
2
3
4
5
6
7
// syscall.c
static char syscalls_names[][6] = {
[SYS_fork]    "fork",
[SYS_exit]    "exit",
........
[SYS_close]   "close"
};

  然后在 xv6 保存系统调用函数的返回值语句前添加打印语句即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// modified syscall()
void
syscall(void)
{
  int num;
  struct proc *curproc = myproc();
  // 系统调用号
  num = curproc->tf->eax;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // syscalls_name -> syscalls_num
    cprintf("%s -> %d\n", syscalls_names[num], num);
    // 保存系统调用函数的返回值
    curproc->tf->eax = syscalls[num]();
  } else {
    cprintf("%d %s: unknown sys call %d\n",
            curproc->pid, curproc->name, num);
    curproc->tf->eax = -1;
  }
}

  补充:由于这个功能在每次系统调用的时候都会打印,十分影响其他实验的进行,因此笔者在 param.h 里定义了一个 flag 变量 PRINT_SYSCALL,默认为 0,如果需要打开该功能可以自行将该参数改为 1,这样就实现了功能的开关闭。修改后的代码如下:(后面的实验会关闭该功能)

1
2
3
// param.h
......
#define PRINT_SYSCALL 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// syscall.c
void
syscall(void)
{
  int num;
  struct proc *curproc = myproc();
  // 系统调用号
  num = curproc->tf->eax;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // syscalls_name -> syscalls_num
    if(PRINT_SYSCALL == 1)
      cprintf("%s -> %d\n", syscalls_names[num], num);
    // 保存系统调用函数的返回值
    curproc->tf->eax = syscalls[num]();
  } else {
    cprintf("%d %s: unknown sys call %d\n",
            curproc->pid, curproc->name, num);
    curproc->tf->eax = -1;
  }
}

  以下是运行 make qemu 启动xv6时系统调用的部分结果展示:

1
2
3
4
5
6
7
8
9
10
11
12
exec -> 7
open -> 15
mknod -> 17
open -> 15
......
fork -> 1
wait -> 3
sbrk -> 12
exit -> 2
write -> 16
$write -> 16
 read -> 5

  另一个任务是打印出系统调用的参数。我们已经知道封装在 sysproc.csysfile.c 里的系统调用函数是通过函数 argintargptrargstrargfd 解析参数的,因此我们只需要在原语句里添加打印信息即可,我们以 sysfile.c: open 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// sysfile.c
int
sys_open(void)
{
  char *path;
  int fd, omode;
  struct file *f;
  struct inode *ip;

  if(argstr(0, &path) < 0 || argint(1, &omode) < 0)
    return -1;
  // 打印系统调用open的第0个和第1个参数
  cprintf("open_arguments: %d, %d\n", argstr(0, &path), argint(1, &omode))
  ......
  return fd;

  所有的系统调用函数都声明在头文件 user.h 内,我们只需要对照着对 sysproc.csysfile.c 两个文件下有参数的系统调用函数做类似改动即可,此处不再赘述。

1
2
3
4
5
6
7
// user.h
// system calls
int fork(void);
int open(const char*, int);
........
int sleep(int);
int uptime(void);

  以下是 xv6 启动时的系统调用参数展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
exec_arguments: 5, 0
open_arguments: 7, 0
mknod_arguments: 7, 0, 0
open_arguments: 7, 0
dup_arguments: 0
.........
hwrite_arguments: 0, 0, 0

exec_arguments: 2, 0
open_arguments: 7, 0
close_arguments: 0
write_arguments: 0, 0, 0
$write_arguments: 0, 0, 0
 read_arguments: 0, 0, 0

  为方便观察,我们注释掉了其他的系统调用函数参数的打印,仅保留了 open,如下是执行 ls 的系统调用函数 open 的参数展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
$ ls
open_arguments: 1, 0
open_arguments: 3, 0
.              1 1 512
open_arguments: 4, 0
..             1 1 512
open_arguments: 8, 0
README         2 2 2170
open_arguments: 5, 0
cat            2 3 13376
open_arguments: 6, 0
echo           2 4 12448
open_arguments: 10, 0
forktest       2 5 8164
open_arguments: 6, 0
grep           2 6 15196
open_arguments: 6, 0
init           2 7 13032
open_arguments: 6, 0
kill           2 8 12496
open_arguments: 4, 0
ln             2 9 12392
open_arguments: 4, 0
ls             2 10 14616
open_arguments: 7, 0
mkdir          2 11 12520
open_arguments: 4, 0
rm             2 12 12496
open_arguments: 4, 0
sh             2 13 23136
open_arguments: 10, 0
stressfs       2 14 13172
open_arguments: 11, 0
usertests      2 15 56044
open_arguments: 4, 0
wc             2 16 14024
open_arguments: 8, 0
zombie         2 17 12224
open_arguments: 5, 0
big            2 18 13412
open_arguments: 9, 0
console        3 19 0

Part 2: Date system call

  第二部分需要我们为 xv6 添加一个新的系统调用用于打印当前的 UTC 时间。参考指导书执行 grep -n uptime *.[chS] 查看现有的系统调用 uptime 的源文件分布:

1
2
3
4
5
6
7
syscall.c:105:extern int sys_uptime(void);
syscall.c:121:[SYS_uptime]  sys_uptime,
syscall.c:145:[SYS_uptime]  "uptime",
syscall.h:15:#define SYS_uptime 14
sysproc.c:86:sys_uptime(void)
user.h:25:int uptime(void);
usys.S:31:SYSCALL(uptime)

  实际上根据我们最开始对系统调用整个流程的具体分析,添加一个系统调用仅仅涉及到以下几个文件:

  • syscall.c 定义了系统调用号和内核函数的对应关系并声明内核函数
  • syscall.h 定义了系统调用号
  • sysproc.csysfile.c 利用参数解析封装了系统调用函数
  • user.h 声明了系统调用函数对应的内核函数
  • usys.S 定义了系统调用的过程

  最后在 Makefile 文件中和bigger_files实验类似,添加需要的用户代码信息 UPROGS 即可。我们按照参考书首先在xv6里添加文件 date.c 实现 date 系统调用的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "types.h"
#include "user.h"
#include "date.h"

int
main(int argc, char *argv[])
{
  struct rtcdate r;
  if (date(&r)) {
    printf(2, "date failed\n");
    exit();
  }

  printf(1, "UTC-time: %d-%d-%dT%d:%d:%d\n", r.year, r.month, r.day, r.hour, r.minute, r.second);
  exit();
}

  其中,结构体 rtadate 预定义在头文件 date.h 里,存储了具体的日期信息。然后在 sysproc.c 中添加封装函数 sys_date,其中 cmostime 实现了系统时间的读取:

1
2
3
4
5
6
7
8
9
10
int
sys_date(struct rtcdate *r)
{
  if (argptr(0, (void*)&r, sizeof(*r)) < 0)
    return -1;
  // 打印系统调用date的参数
  cprintf("date_arguments: %d\n", argptr(0, (void*)&r, sizeof(*r)));
  cmostime(r);
  return 0;
}

  最后按照上述系统调用的流程对相应文件进行声明/定义等修改即可,最终执行 date 指令的效果如下:

1
2
3
$ date
date_arguments: 0
UTC-time: 2019-10-12T1:44:44