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.S
,bootmain.c
)完成的,负责把内核镜像载入内存中的有关区域,并从entry.S:entry处开始执行内核。entry.S
初始化page directory的第一个entry,设置CPU的有关控制寄存器,把0x80000000
开始的4 MiB的虚拟内存给映射到0x00000000开始的物理内存,使得CPU从实模式(real mode)进入保护模式(protected mode)。之后jmp
到main.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会先进行以下操作:
- 栈地址的存储。如果涉及用户态到内核态的转变,在内核态使用的代码只能在内核栈里面执行,这是因为用户态的栈可能是无效的(比如空间不够),也可能是恶意的(比如设置为一个自己没有权限的地址)。内核栈的地址在代码切换到用户态时会被系统保存到Task State Segment中(
vm.c:switchuvm
)。TSS本来是x86体系结构中用来存储进程信息的数据结构,不过大部分操作系统,如Windows和Linux都不使用,只是每个CPU设置一个,用来存储内核栈的信息。xv6也一样,用TSS存储内核栈的esp和ss。中断时,esp和ss会被CPU自动从TSS中读取并设置,并且当前的esp和ss会被硬件压入内核栈中。如果中断是内核态发生的,这一切都不会发生,毕竟栈不会发生改变。 - 保存现场:eflags,cs,eip,和有些中断所有的错误码会自动入栈。
- 设置cs和eip为IDT中的值,即指向
vectors
中的响应函数。设置CPU的特权级为响应函数所在位置内存的特权级:对xv6,所有响应函数都是在内核空间中,因此CPU会被设置为ring 0。
下面就是内核程序的处理,可以概括为vectors.S:vectors
-> trapasm.S:alltraps
-> trap.c:trap
-> 特定处理函数 -> trap.c:trap
-> trapasm.S:trapret
:
vectors
:这是一个数组,里面每一项都是对该中断进行处理的地址。这里的中断处理程序是批量生成的,其主要作用就是向栈内写入中断号(x86不会告知中断处理程序其中断号,因此必须每个中断都设置一个不同的处理程序才能实现这个目的),并且如果该中断没有错误码的话把错误码的位置补上0,使得处理程序更加一致。然后所有程序都会调转到trapasm.S:alltraps
。alltraps
:进一步保存现场,把所有的段寄存器和通用寄存器存入栈中,并且设置段寄存器的当前值。这时候,系统栈里面就和x86.h:struct trapframe
的组成情况一致了。把esp入栈,再calltrap.c:trap
,就实现了调用void trap(trapframe*)
的效果。trap
:trap会根据中断号的情况进行操作。如果是interrupt,会进行相应的硬件操作,实现基本都在别的文件中。如果是无效的中断,则根据其发起者的特权级不同进行处理:内核态会panic,而用户态会kill用户进程。如果是系统调用,则会检查来源进程是否被killed,如果没有的话则向该用户进程的进程描述符的tf项传入该函数的参数taskframe,调用syscall.c:syscall
具体执行该系统调用。执行完毕后,要判断进程是否被杀死了,如果是的话意味着系统出现了异常,或者SYS_exit被调用了,这时候会调转到一个不断执行SYS_exit的循环,确保系统能退出。trapret
:返回之前要先把alltraps
保存的现场恢复,包括寄存器和存在栈里面的中断号和错误码,然后执行iret
指令,恢复CPU进行的操作,返回之前的特权级。要注意的是中断过程中可能会修改内核栈里面存储的现场,使得返回后开始执行别的进程。时钟中断就是这样的。
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:lapicinit
和ioapic.c:ioapicinit
设置有关寄存器,把中断的IRQ号和IDT里面的中断号对应起来。
设置完毕前,整个过程中中断是关闭的。之后各种程序也可能需要屏蔽中断。这时候可以通过调用lock.c:pushcli
来执行cli
指令,临时屏蔽中断,在结束后调用lock.c:popcli
恢复中断。这样可以防止对内核态的核心操作不被打断。