About Blog Contact

xv6中断系统学习笔记

xv6中断系统简介

xv6实现了一套支持x86体系结构的中断系统。其可以处理来自Intel Advanced Programmable Interrupt Controller(APIC)的硬件中断(interrupt),也可以处理来自执行过程中的异常(exception)和系统调用(system call)。由于在内核实现的过程中,以上几种的处理逻辑十分相似,再加上历史原因,因此在xv6代码和文档中用trap这个概念概括以上三类中断。

此文中用以下格式指代函数、变量、符号:

a.c:b

表示文件a.c中的b符号。本文学习的是rev-10版本的xv6。

中断管理的初始化

xv6系统的启动过程是由bootloader(bootasm.Sbootmain.c)完成的,负责把内核镜像载入内存中的有关区域,并从entry.S:entry处开始执行内核。entry.S初始化page directory的第一个entry,设置CPU的有关控制寄存器,把0x80000000开始的4 MiB的虚拟内存给映射到0x00000000开始的物理内存,使得CPU从实模式(real mode)进入保护模式(protected mode)。之后jmpmain.c:main,开始执行内核的主体部分。从main.c:main开始,系统就始终在保护模式了,使用虚拟内存。因此,main.c:main会在链接时进行设置(kernel.ld),使其内存基地址为KERNBASE(0x80000000)。对中断的初始化过程就是在内存管理系统已经设置完毕,内核内部的内存分配和内存释放机制也初始化完毕的情况下进行的。

x86的中断管理使用Interrupt Descriptor Table(IDT)实现的,IDT中有256个entry,每个entry有中断号,x86的中断类型(xv6只用到了trap和interrupt类型),中断处理程序的cs和eip(xv6段利用的不多,整个内核只分了代码和数据2段),以及其执行权限Descriptor Privilege Level。内核首先调用trap.c:tvinit,把中断描述符的信息存储到trap.h:idt中。idt是一个struct gatedesc类型的数组,一个mmu.h:gatedesc实例可以存储IDT一个entry的有关信息,除了以上所述,还有一个参数p(present):CPU只有在present为1的情况下才会执行才能成立。gatedesc有2个值得注意的地方,其一是gatedesc的默认值都是错误的,这是为了使得系统在IDT未初始化成功的情况下尽快出错。其二是gatedesc的参数的顺序和大小是按照x86对IDT格式的要求排列的,因此必须要防止编译时的对齐。我觉得应该是在哪里设置了一下,把对齐关了,不过我在Makefile里面还没找到设置的地方。

tvinit把存储在vector.S:vectors中的中断处理程序的地址存储到idt中,设置中断号和中断类型。除了系统调用T_SYSCALL采用trap类型,而且DPL为user之外,别的都是interrupt类型,要求只用ring 0态可以触发。T_SYSCALL使用trap是为了在调用时不清零IF,这样别的中断可以嵌套。设置完毕idt,内核调用x86.h:lidt,里面执行汇编lidt指令,设置IDT寄存器地址为idt。这里和设置gatedesc的代码一样,都使用额外的代码确保地址是小端顺序存储的(虽然不写我觉得也可以,毕竟针对x86平台编译默认结果就是小端)。这样中断系统的初始化就完成了。

一次中断的执行过程

发生中断时,CPU会先进行以下操作:

  1. 栈地址的存储。如果涉及用户态到内核态的转变,在内核态使用的代码只能在内核栈里面执行,这是因为用户态的栈可能是无效的(比如空间不够),也可能是恶意的(比如设置为一个自己没有权限的地址)。内核栈的地址在代码切换到用户态时会被系统保存到Task State Segment中(vm.c:switchuvm)。TSS本来是x86体系结构中用来存储进程信息的数据结构,不过大部分操作系统,如Windows和Linux都不使用,只是每个CPU设置一个,用来存储内核栈的信息。xv6也一样,用TSS存储内核栈的esp和ss。中断时,esp和ss会被CPU自动从TSS中读取并设置,并且当前的esp和ss会被硬件压入内核栈中。如果中断是内核态发生的,这一切都不会发生,毕竟栈不会发生改变。
  2. 保存现场:eflags,cs,eip,和有些中断所有的错误码会自动入栈。
  3. 设置cs和eip为IDT中的值,即指向vectors中的响应函数。设置CPU的特权级为响应函数所在位置内存的特权级:对xv6,所有响应函数都是在内核空间中,因此CPU会被设置为ring 0。

下面就是内核程序的处理,可以概括为vectors.S:vectors -> trapasm.S:alltraps -> trap.c:trap -> 特定处理函数 -> trap.c:trap -> trapasm.S:trapret

xv6对中断的处理是比较简单的:比如除0错误和缺页错误都是归类到无效中断,直接kill进程,没有进行更复杂的处理。对硬件中断,xv6也只对键盘,硬盘(IDE)和时钟中断进行了处理。

系统调用的实现

系统调用在实现中,利用了syscall.c:syscall作为wrapper。syscall根据用户进程在进行系统调用之前eax的数值,选择要调用的系统调用。要注意,syscall中对用户eax数值的有效性进行了详细的检查,以防用户通过设置无效的eax使得系统执行别的位置的代码:理论上,用户甚至可以通过伪造eax使其执行自己在用户空间的代码,非法获得对内核的权限。

在执行中,系统调用的处理程序可以通过proc.c:myproc获得系统调用的发起进程的进程描述符struct proc,进程栈、进程的寄存器都是以taskframe的格式存在发起进程的proc的tf中的,这是traps.c:traps完成的。系统调用的参数都是以Linux采用了的cdecl calling convention放到栈上面的,syscall.c提供了几个helper函数,方便实现者使用系统调用的参数。这几个helper也对参数的有效性进行了详细的检查。在系统调用时,系统是不转换页表的,因此用户空间的虚拟地址可以直接使用,这样指向用户空间的指针依然有效。

添加新的系统调用,要做的就是在call.h中设置新的系统调用号,在call.c:syscalls,这个函数指针的数组中加入新的一项。比如我们添加SYS_setrlimit,在call.h设置:

#define SYS_setrlimit 22

设置syscalls:

static int (*syscalls[])(void) = {
[SYS_fork] sys_fork,
...
[SYS_setrlimit] sys_setrlimit
};

在call.c里面添加对sys_setrlimit的声明(注意一定要extern,否则链接不上),在别的地方实现他即可。可以把实现放到sysproc.c中。

APIC硬件中断的设置

对硬件中断的处理流程之前已经说明了,这里简单补充一下其设置。现代的x86提供嵌入芯片的APIC系统进行中断响应,在接收到中断时执行int指令。有2类系统:全局的IOAPIC和每个时钟独立的local APIC。硬盘IDE接口用的就是IOAPIC,CPU时钟用的就是local APIC。启动时,内核执行lapic.c:lapicinitioapic.c:ioapicinit设置有关寄存器,把中断的IRQ号和IDT里面的中断号对应起来。

设置完毕前,整个过程中中断是关闭的。之后各种程序也可能需要屏蔽中断。这时候可以通过调用lock.c:pushcli来执行cli指令,临时屏蔽中断,在结束后调用lock.c:popcli恢复中断。这样可以防止对内核态的核心操作不被打断。