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个相关的特权级
- 当前代码的特权级,就是当前
cs
的CPL
- 中断处理过程所在的目标代码段的特权级,中断门中存放目标代码段描述符的选择子,通过选择子,可以从 GDT/LDT 中找到目标代码段的 DPL
- 中断门描述符的 DPL
规则如下:
- 当前的 CPL < 目标代码段描述符的特权级
不允许将控制转移到中断处理程序中 - 目标代码段描述符的特权级 < 当前的 CPL
就在 CPU 内部的寄存器保存 esp 和 ss,并进行栈切换 - 对于软中断 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会执行如下具体过程是
- 获取interrupt handler目标代码段描述符的特权级
- 从 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 自动完成的。假设发生栈切换,此时新栈的内容如下
这些内容就是 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
。之后栈的结果如下:
设置完后,将 esp
压栈,然后调用 trap.c:trap(struct trapframe *tf)
,执行具体的 interrupt handler 逻辑。还记得 C 语言怎么利用栈来传参数的吗?在 call trap
之前 push esp,相当于把 esp 当作参数,传入到 trap
函数中,即 tf=esp
。这也就是为什么前面压栈的上下文,为什么会成为 trap
的参数。