Ucore异常处理详解
前言
本文由大量内容引用或者摘自以下来源,对原作者表示感谢
对于文章整体,我们将先对相关定义以及数据结构进行讲解,然后大致梳理异常处理的开启过程,最后讲解异常处理的过程.全程是以文件作为线索.
希望你对异常处理的相关概念比较熟悉,不然相关定义和结构体可能就看不太懂了.
一.相关定义与结构体
mmu.h定义了许多东西,除了许多宏和物理内存管理的一些变量函数,还包括中断描述符的格式,建立中断门的函数等等
1.mmu.h
/* *
* Set up a normal interrupt/trap gate descriptor
* - istrap: 1 for a trap (= exception) gate, 0 for an interrupt gate
* - sel: Code segment selector for interrupt/trap handler
* - off: Offset in code segment for interrupt/trap handler
* - dpl: Descriptor Privilege Level - the privilege level required
* for software to invoke this interrupt/trap gate explicitly
* using an int instruction.
*
*gate其实是多个结构体,分别对应着callgate,interrupt gate和trap gate(具体是struct gatedesc)
* 在mmu.h里面定义,我们看下面的函数,是采用define而不是一个正常的函数.这样可以方便的解决这个问题(gate不是一个具体的类型),但说到底,无论是哪一个gate的结构体,其实都是某某门的描述符的一个结构体,结构体中的每一部分就对应着这个descripter的每一个字段的值.
*/
#define SETGATE(gate, istrap, sel, off, dpl) { \
(gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \
(gate).gd_ss = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t)(off) >> 16; \
}
//中断,陷阱门描述符
struct gatedesc {
//冒号后面的是这个字段所占的位数
//从低位到高位,依次是:
unsigned gd_off_15_0 : 16; // low 16 bits of offset in segment,偏移地址
unsigned gd_ss : 16; // segment selector,段选择符
unsigned gd_args : 5; // # args, 0 for interrupt/trap gates
unsigned gd_rsv1 : 3; // reserved,为0
unsigned gd_type : 4; // 门的类型,不同的门此字段不同
unsigned gd_s : 1; // must be 0 (system)
unsigned gd_dpl : 2; // descriptor(meaning new) privilege level,特权级
unsigned gd_p : 1; // Present,p=1表示段存在,反之不存在
unsigned gd_off_31_16 : 16; // high bits of offset in segment,偏移地址
};
对照上面的中断描述符的结构体,我们来看下面的图,一一对照就可以对中断描述符有一个清晰的认识
中断描述符给出了一个段选择符和32位的偏移地址,段选择符指示异常处理程序或者中断服务程序所在段的段描述符在gdt或者ldt中的位置,偏移地址则给出对应处理程序的第一条指令所在的偏移量
2.vector.s
vector.S 文件通过 vectors.c 自动生成,其中定义了每个中断的入口程序和入口地址 (保存在 vectors 数组中)。其中,中断可以分成两类:一类是压入错误编码的 (error code),另一类不压入错误编码。对于第二类, vector.S 自动压入一个 0。此外,还会压入相应中断的中断号。在压入两个必要的参数之后,中断处理函数跳转到统一的入口 alltraps 处。
#这里是代码段
.text
.globl __alltraps
# 定义每个中断的入口和地址
# 对于中断处理,下面每一个都是
# 1 压入errcode 或 0
# 2 压入中断号
.globl vector0
vector0:
pushl $0
pushl $0
jmp __alltraps
.globl vector1
vector1:
pushl $0
pushl $1
jmp __alltraps
.globl vector2
vector2:
.......
# vector table
# 这里是数据段,每一项都是一个long,32位,也就是每一个vector的入口地址.(在上面定义的)
.data
.globl __vectors
__vectors:
.long vector0
.long vector1
.long vector2
.long vector3
.long vector4
.long vector5
.long vector6
............
3.中断描述符表
static struct gatedesc idt[256] = {{0}}//中断描述符表,是一个中断描述符类型的数组
4.trapframe中断/陷阱帧
在出现了中断(这里泛指)的时候,往往会涉及到进程切换,函数调用以及权限级的切换,而在这种切换中,我们使用了中断帧这样一个数据结构,将中断处理当作一个栈帧放在栈里,保存了中断处理前的各种信息,可以更方便的完成我们的各种调用,保存数据与切换.
中断帧的描述在trap.h里面
struct trapframe {
struct pushregs tf_regs; // pushal,寄存器组的值
uint16_t tf_gs;//gs寄存器
uint16_t tf_padding0;
uint16_t tf_fs;
uint16_t tf_padding1;
uint16_t tf_es;
uint16_t tf_padding2;
uint16_t tf_ds;
uint16_t tf_padding3;
uint32_t tf_trapno; //中断号
/* 下列字段由 x86 硬件定义 */
uint32_t tf_err; //错误号
uintptr_t tf_eip; // 产生中断后
uint16_t tf_cs; //出错的代码段
uint16_t tf_padding4;
uint32_t tf_eflags;
/* 下列字段近用于跨级别调用,如从 user 跨入 kernel */
uintptr_t tf_esp;
uint16_t tf_ss; //出错的栈
uint16_t tf_padding5;
} __attribute__((packed));//取消结构在编译过程中的优化对齐,按照实际结构对齐.
/* 执行pushal 指令时,这些寄存器值入栈 */
/* 参考 syscall,这些都是系统调用时传入的参数和返回值参数;eax 接收返回值 */
struct pushregs {
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* Useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
};
二.异常处理的开启
先来看init.c,这个函数是内核初始化的总控函数,这一部分(异常处理的开启)会按照这个文件中的函数为线索进行分析
1.init.c
//用中文注释出来的是需要注意的
int kern_init(void) {
extern char edata[], end[];
memset(edata, 0, end - edata); // clear bss
cons_init(); // 控制台初始化,初始化了外设,键盘等
print_history();
print_kerninfo();
//grade_backtrace();
pmm_init(); // init physical memory management
pic_init(); // 中断控制初始化
idt_init(); // 中断描述符表初始化
vmm_init(); // init virtual memory management
sched_init(); // init scheduler
proc_init(); // init process table
ide_init(); // init ide devices
swap_init(); // init swap
fs_init(); // init fs
clock_init(); // 时钟中断初始化
intr_enable(); // 中断请求初始化
cpu_idle(); // run idle process
2.console.c
外设基本初始化设置
Lab1实现了中断初始化和对键盘、串口、时钟外设进行中断处理。串口的初始化函数serial_init(位于/kern/driver/console.c)中涉及中断初始化工作的很简单:
......
// 使能串口1接收字符后产生中断
outb(COM1 + COM_IER, COM_IER_RDI);
......
// 通过中断控制器使能串口1中断
pic_enable(IRQ_COM1);
键盘的初始化函数kbd_init(位于kern/driver/console.c中)完成了对键盘的中断初始化工作,具体操作更加简单:
......
// 通过中断控制器使能键盘输入中断
pic_enable(IRQ_KBD);
3.pic_init(driver/picirq.c)
这个函数主要是初始化了8259A的中断处理控制器,涉及到具体芯片的硬件以及很多数字电路的内容,对于8259A感兴趣的可以参考下面一篇博客
8259A
/* pic_init - initialize the 8259A interrupt controllers */
void
pic_init(void) {
did_init = 1;
// mask all interrupts
outb(IO_PIC1 + 1, 0xFF);
outb(IO_PIC2 + 1, 0xFF);
// Set up master (8259A-1)
// ICW1: 0001g0hi
// g: 0 = edge triggering, 1 = level triggering
// h: 0 = cascaded PICs, 1 = master only
// i: 0 = no ICW4, 1 = ICW4 required
outb(IO_PIC1, 0x11);
// ICW2: Vector offset
outb(IO_PIC1 + 1, IRQ_OFFSET);
// ICW3: (master PIC) bit mask of IR lines connected to slaves
// (slave PIC) 3-bit # of slave's connection to master
outb(IO_PIC1 + 1, 1 << IRQ_SLAVE);
// ICW4: 000nbmap
// n: 1 = special fully nested mode
// b: 1 = buffered mode
// m: 0 = slave PIC, 1 = master PIC
// (ignored when b is 0, as the master/slave role
// can be hardwired).
// a: 1 = Automatic EOI mode
// p: 0 = MCS-80/85 mode, 1 = intel x86 mode
outb(IO_PIC1 + 1, 0x3);
// Set up slave (8259A-2)
outb(IO_PIC2, 0x11); // ICW1
outb(IO_PIC2 + 1, IRQ_OFFSET + 8); // ICW2
outb(IO_PIC2 + 1, IRQ_SLAVE); // ICW3
// NB Automatic EOI mode doesn't tend to work on the slave.
// Linux source code says it's "to be investigated".
outb(IO_PIC2 + 1, 0x3); // ICW4
// OCW3: 0ef01prs
// ef: 0x = NOP, 10 = clear specific mask, 11 = set specific mask
// p: 0 = no polling, 1 = polling mode
// rs: 0x = NOP, 10 = read IRR, 11 = read ISR
outb(IO_PIC1, 0x68); // clear specific mask
outb(IO_PIC1, 0x0a); // read IRR by default
outb(IO_PIC2, 0x68); // OCW3
outb(IO_PIC2, 0x0a); // OCW3
if (irq_mask != 0xFFFF) {
pic_setmask(irq_mask);
}
LOG_LINE("初始化完毕:中断控制器");
}
4.idt_init(trap.c)
操作系统如果要正确处理各种不同的中断事件,就需要安排应该由哪个中断服务例程负责处理特定的中断事件。系统将所有的中断事件统一进行了编号(0~255),这个编号称为中断向量。以ucore为例,操作系统内核启动以后,会通过 idt_init 函数初始化 idt 表 (参见trap.c),而其中 vectors 中存储了中断处理程序的入口地址。vectors 定义在 vector.S 文件中,通过一个工具程序 vector.c 生成。在上文已做叙述.
其中仅有 System call 中断的权限为用户权限 (DPL_USER),即仅能够使用 int 0x80 指令。此外还有对 tickslock 的初始化,该锁用于处理时钟中断。
static struct gatedesc idt[256] = {{0}}//中断描述符表,是一个中断描述符类型的数组
/* 初始化kern/trap/vectors.S的中断向量表 */
void
idt_init(void) {
/**
* 中断处理函数的入口地址定义在__vectors,位于kern/trap/vector.S
*/
LOG_LINE("初始化开始:中断向量表");
LOG("idt_init:\n\n");
extern uintptr_t __vectors[];
int i;
//遍历idt的每一项,并将每一项的详细信息填到idt的这一项里面
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
//SETGATE(gate, istrap, sel, off, dpl)
//初始化idt每一项,将vectors里面保存的中断向量处理入口地址写到idt(描述符)里面
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
//下面这一行的权限是DPL_USER,和上面不一样
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
/*
lidt:asm volatile ("lidt (%0)" :: "r" (pd) : "memory");
将idt_pd加载到 IDTR,idt_pd有两项,第一项是idt的大小,第二项是idt的入口地址
*/
lidt(&idt_pd);
....
LOG_LINE("初始化完毕:中断向量表");
}
首先理一下数据结构的关系,idt是一个数组,数组的每一项是一个中断描述符类型,这个类型里面保存了相应中断处理程序的位置(段描述符,偏移地址),而这个地址实在vectors里面保存的.对应的数据结构在上文也已经给出详解,如果还没有理清楚可以回去看看.
idt_init这个函数主要就是把idt的每一项写进去,其中中断处理函数的入口地址来自vectors.
5.clock.c
时钟是一种有着特殊作用的外设,其作用并不仅仅是计时。在后续章节中将讲到,正是由于有了规律的时钟中断,才使得无论当前CPU运行在哪里,操作系统都可以在预先确定的时间点上获得CPU控制权。这样当一个应用程序运行了一定时间后,操作系统会通过时钟中断获得CPU控制权,并可把CPU资源让给更需要CPU的其他应用程序。时钟的初始化函数clock_init(位于kern/driver/clock.c中)完成了对时钟控制器8253的初始化:
......
//设置时钟每秒中断100次
outb(IO_TIMER1, TIMER_DIV(100) % 256);
outb(IO_TIMER1, TIMER_DIV(100) / 256);
// 通过中断控制器使能时钟中断
pic_enable(IRQ_TIMER);
最后,在intr.c里面,开启外设中断请求
/* intr_enable - enable irq interrupt */
void
intr_enable(void) {
sti();
}
即在x86.h中的
//sti = Set Interupt, 允许中断
static inline void
sti(void) {
asm volatile ("sti");
}
调用sti的汇编指令,允许中断
三.异常处理的流程
trap函数(定义在trap.c中)是对中断进行处理的过程,所有的中断在经过中断入口函数__alltraps预处理后 (定义在 trapentry.S中) ,都会跳转到这里。在处理过程中,根据不同的中断类型,进行相应的处理。在相应的处理过程结束以后,trap将会返回,被中断的程序会继续运行。
# vectors.S 把所有 trap 发送到此
.text
.globl __alltraps
__alltraps:
# 中断陷入执行至此,由于 int 指令的结果,栈上已有
# | trapno |
# | errno |
# | eip |
# | cs |
# | eflags |
# | ss |
# | sp |
# 继续 push 寄存器值,以在栈空间构造struct trapframe中断帧.每个 pushl 都压入一个 uint32,对应 trapframe 中两个 uint16.
pushl %ds
pushl %es
pushl %fs
pushl %gs
# pushal = pushregs
pushal
# 设置内核的 ds,es段地址为GD_KDATA,准备内核环境
movl $GD_KDATA, %eax
movw %ax, %ds
movw %ax, %es
# 把刚刚构造的 trap frame 的地址作为参数传入trap(),并调用
pushl %esp
call trap
# 清理调用 trap 前压栈的 esp
popl %esp
trap.c
/**
* 处理并分发一个中断或异常,包括所有异常
* 如果返回,则trapentry.S 恢复 cpu 之前的状态(栈上保存,以 trapframe 的结构),然后执行 iret返回.
*/
void
trap(struct trapframe *tf) {
//LOG("陷阱预处理,维护中断嵌套\n");
// 基于陷阱的类型,分发 trapframe
//current是当前进程
if (current == NULL) {
//陷阱调度,参数为陷阱帧的地址
trap_dispatch(tf);
}
else {
// 在栈上维护一个 trapframe 链,用于处理中断嵌套
//1 暂存当前进程的上一个 tf
struct trapframe *otf = current->tf;//otf=old tf
//2 更新当前进程的 tf
current->tf = tf;
//判断是不是内核代码的trap
bool in_kernel = trap_in_kernel(tf);
// 处理当前tf,这是重点
trap_dispatch(tf);
// 恢复上一个 tf
current->tf = otf;
// 只有用户态进程可以抢占.内核进程不可抢占
if (!in_kernel) {
// 检查进程标志,是否被标记为希望"被"退出
if (current->flags & PF_EXITING) {
do_exit(-E_KILLED);
}
// 每次系统调用,当前进程都可能被标记为应被调度.此时进行调度刷新工作
if (current->need_resched) {
//进行进程调度
schedule();
}
}
}
//上文的中断调度函数
static void trap_dispatch(struct trapframe *tf) {
//LOG("trap_dispatch start:\n");
//LOG("开始分发中断.中断号:%u\n",tf->tf_trapno);
char c;
int ret=0;
//根据trap frame里面的trapno来进行不同的处理
switch (tf->tf_trapno) {
case T_PGFLT: //page fault
LOG("内核检测到缺页异常中断.\n");
if ((ret = pgfault_handler(tf)) != 0) {//在这个pgfault_handler里面处理了缺页异常,下文会重点介绍
print_trapframe(tf);
if (current == NULL) {
panic("handle pgfault failed. ret=%d\n", ret);
}
else {
if (trap_in_kernel(tf)) {
panic("handle pgfault failed in kernel mode. ret=%d\n", ret);
}
LOG("killed by kernel.\n");
panic("handle user mode pgfault failed. ret=%d\n", ret);
do_exit(-E_KILLED);
}
}
break;
case T_SYSCALL:
syscall();
break;
case IRQ_OFFSET + IRQ_TIMER:
.................//这里是一些其他的中断处理的case,省略
default:
print_trapframe(tf);
if (current != NULL) {
LOG("unhandled trap.\n");
do_exit(-E_KILLED);
}
// in kernel, it must be a mistake
panic("unexpected trap in kernel.\n");
}
//LOG("trap_dispatch end\n");
}
# 通过__trapret从中断返回.
# 中断返回:
#
.globl __trapret
__trapret:
# 1. 恢复各种寄存器值
popal
popl %gs
popl %fs
popl %es
popl %ds
# 去掉 trap number 和 error code
addl $0x8, %esp
# int n 命令:
# 1. 标志寄存器入栈
# 2. CS,IP 入栈,(IP)=(n*4), (CS)=(n*4+2)
# iret 命令:
# 1. 恢复 CS,IP
# 2. 恢复标志寄存器
# 3. 恢复 ESP,SS.(权限发生变化)
iret
整个中断处理流程大致如下:
最后
整个中断处理的过程还是比较好懂的,只要理解了几个关键的数据结构的关系(vectors,idt[],trapframe)再接下来看函数调用就会比较轻松.
但是如果想要搞懂每一个细节还是比较难的,就比如上文提到的8259A的中断处理控制器,键盘中断,时钟中断,不仅要求你熟悉软件,对于硬件和数字电路还需要一定量的知识,在这里我也不是很熟悉并且也不打算挖得那么深,就跳过了,有兴趣的可以深挖看看.