XV6 - Syscall(1)

xv6是通过trap实现系统调用的,trap和中断主要有两个区别

  • trap是由当前进程触发的,中断是由设备产生的,可能和当前进程没有关系
  • 中断门和trap门只有一位不相同,具体是中断门清除 EFLAGS 寄存器的 IF 位。所以运行trap处理程序时,还是会响应其它中断。

虽然有区别,但是中断和trap的整个处理流程都是一样的。

中断 setup

uint vectors[] 存放 interrupt handler 地址的数组,共256项。 vectors.pl 脚本生成 vectors.S,定义了每一个 interrupt handler,并在最后定义了数组 uint vectors[]。每个 interrupt handler 主要做两件事情:

  • 没有error code的中断,push 0和中断号。有error code的中断,当执行 int 指令后,CPU 会 push error code,所以只需要 push 中断号
  • 跳转到 alltraps 执行

main.c:tvinit() 设置256个中断门描述符,存放在 IDT struct gatedesc idt[256];中。第64号中断用来做系统调用,用的是trap 门,不是中断门,而且门描述符的 DPL 设置成 3。虽然系统20多个系统调用,但是trap处理程序只有一个,所有的系统调用入口都是同一个trap处理程序,也就是第64号中断处理程序。 main.c:idtinit() 设置 IDTR,这样中断门/trap门就安装完毕了。

特权级保护

x86共有4个特权级,但是一般只会用到2个特权级,内核特权级为0,用户进程特权级为3.

中断描述符有3个相关的特权级

  1. 当前代码的特权级,就是当前 csCPL
  2. 中断处理过程所在的目标代码段的特权级,中断门中存放目标代码段描述符的选择子,通过选择子,可以从 GDT/LDT 中找到目标代码段的 DPL
  3. 中断门描述符的 DPL

规则如下:

  1. 当前的 CPL < 目标代码段描述符的特权级
    不允许将控制转移到中断处理程序中
  2. 目标代码段描述符的特权级 < 当前的 CPL
    就在 CPU 内部的寄存器保存 esp 和 ss,并进行栈切换
  3. 对于软中断 int n 和单步中断 int3,以及 into 引发的中断,当前的 CPL <= 门描述符的 DPL

系统调用

syscall num

64号中断处理所有的系统调用,所以每个系统调用需要一个数字来区分,syscall.h 定义来每一个 syscall num

代码调用 syscall

usys.S 中实现了系统调用,首先声明系统调用名是global的,所以用户程序链接了 usys.o 之后,系统调用名对用户程序就可见了。接着定义系统调用的过程,首先将 syscall num 存到寄存器 eax 中,然后执行 int 0x40,触发64号中断。

比如 mkdir.asm 中,用户程序调用 syscall:mkdir ,汇编代码显示这是一条 call 350 指令

    if(mkdir(argv[i]) < 0){
  32:	8b 44 24 1c          	mov    0x1c(%esp),%eax
  36:	c1 e0 02             	shl    $0x2,%eax
  39:	03 45 0c             	add    0xc(%ebp),%eax
  3c:	8b 00                	mov    (%eax),%eax
  3e:	89 04 24             	mov    %eax,(%esp)
  41:	e8 0a 03 00 00       	call   350 <mkdir

搜索地址为350的代码,可以看到是 mkdir 系统调用的具体代码

00000350 <mkdir>:
SYSCALL(mkdir)
 350:	b8 14 00 00 00       	mov    $0x14,%eax
 355:	cd 40                	int    $0x40
 357:	c3                   	ret    

上下文

不管是用户程序,还是内核调用了系统调用,CPU 都会离开原来的指令,跳转到系统调用的代码执行。当系统调用完成后,回到系统调用处,继续原来的指令。所以需要保存原来程序的上下文,当系统调用结束后,能恢复原来的运行环境,继续执行。

x86对于每一个 task, 都会有一个 TSS 来保存该 task 运行环境,当 task 发生切换时,TSS 负责保存/恢复上下文。不过 xv6 并没有采用这种方式。xv6 会为每个进程创建4KB大小的内核栈,在进程结构体 proc.h:struct proc 存放了内核栈的地址 char *kstack。具体上下文信息都存放在这个进程相关的内核栈中。

xv6 会为每个 CPU 创建一些 CPU 的状态信息,具体为 proc.h:struct cpu。在每个 CPU 结构体中有两个字段,一个是 GDT,一个是 TSS。其中 TSS 描述符会安装到 GDT 中。

struct cpu {
  ...
  struct taskstate ts;         // Used by x86 to find stack for interrupt
  struct segdesc gdt[NSEGS];   // x86 global descriptor table
  ...
 }

每个进程运行之前,内核会设置这些数据结构,具体代码在 vm.c:switchuvm(),主要是安装 TSS 描述符到 GDT中,然后设置 TSS 的内核栈和 iomb,其它3个特权级的栈信息和其它信息都没有用到。其中 TSS 的内核栈就是内核为进程创建的内核栈。

  cpu->gdt[SEG_TSS] = SEG16(STS_T32A, &cpu->ts, sizeof(cpu->ts)-1, 0);
  cpu->gdt[SEG_TSS].s = 0;
  cpu->ts.ss0 = SEG_KDATA << 3;
  cpu->ts.esp0 = (uint)p->kstack + KSTACKSIZE;
  // setting IOPL=0 in eflags *and* iomb beyond the tss segment limit
  // forbids I/O instructions (e.g., inb and outb) from user space
  cpu->ts.iomb = (ushort) 0xFFFF;

具体的上下文信息,xv6 用一个结构体 struct trapframe 表示。进程结构体 struct proc 有一个指针 struct trapframe *tf 指向上下文 struct trapframe,而这些上下文信息都存放在进程相关的内核栈中。

struct proc {
  ...
  struct trapframe *tf;        // Trap frame for current syscall

x86.h 定义了 struct trapframe

struct trapframe {
  // registers as pushed by pusha
  uint edi;
  uint esi;
  uint ebp;
  uint oesp;      // useless & ignored
  uint ebx;
  uint edx;
  uint ecx;
  uint eax;

  // rest of trap frame
  ushort gs;
  ushort padding1;
  ushort fs;
  ushort padding2;
  ushort es;
  ushort padding3;
  ushort ds;
  ushort padding4;
  uint trapno;

  // below here defined by x86 hardware
  uint err;
  uint eip;
  ushort cs;
  ushort padding5;
  uint eflags;

  // below here only when crossing rings, such as from user to kernel
  uint esp;
  ushort ss;
  ushort padding6;
};

int 0x40

栈切换

前面讲到特权级,可以看看 mkdir 时的特权级信息:

  • 当前用户进程的特权级是 3
  • interrup handler所在目标代码段是内核代码段,所以目标代码段的特权级是 0
  • trap门描述符的特权级是 3

根据前面讲的规则,目标代码段描述符的特权级 < 当前的 CPL 会发生栈切换。执行 int 0x40时,CPU会执行如下具体过程是

  1. 获取interrupt handler目标代码段描述符的特权级
  2. 从 TSS 中获取相同特权级的栈,并切换到新栈中

因为interrupt handler运行在内核态,特权级为0,所以 CPU 会把用户栈切换到用户进程对应的内核栈中。如果在内核代码中调用syscall,就不需要切换栈。

如果有栈切换,会将旧栈的 ss 和 esp push 到新栈中,如果没有栈切换,不需要 push 旧栈信息。

然后会保存以下信息

  • push eflags
  • push cs
  • push eip
  • 如果 exception 有 error code,push code

以上所有事情,都是调用 int 0x40 后, x86 CPU 自动完成的。假设发生栈切换,此时新栈的内容如下

stack_change

这些内容就是 struct trapframe 后半部的信息(栈是从高地址向低地址扩展的)。除了设置 trapframe,还会把代码段切换到 interrupt handler,即 cs:eip 切换到 handler上。

interrupt handler

接下来内核就会调整到interrupt handler去执行。上面讲到handler定义在 vectors.S 中,如果中断没有 error code,就会push 0,然后 push 中断号。如果有error code,上面讲到 CPU 会自动把 error code push到栈中。接下来跳转到 alltraps 运行。

trapasm.S:alltraps 主要是用来存储 struct trapframe 前半部分信息。虽然栈发生了切换(ss 和 esp 发生变化),但是其它寄存器还是旧进程的信息,所以 alltraps 会将这些信息都保存下来,比如 eax 存放的是 syscall num。

interrupt handler 是在内核态运行的,除了切换栈,保存上下文,还需要将各种段寄存器切换到内核段中。前面讲到 cs 和 ss 已经切换完毕,还需要设置 ds,es,fs,gs。之后栈的结果如下:

stack_change

设置完后,将 esp 压栈,然后调用 trap.c:trap(struct trapframe *tf),执行具体的 interrupt handler 逻辑。还记得 C 语言怎么利用栈来传参数的吗?在 call trap之前 push esp,相当于把 esp 当作参数,传入到 trap 函数中,即 tf=esp。这也就是为什么前面压栈的上下文,为什么会成为 trap 的参数。