异常就是控制流中的突变,用来响应处理器状态中的某些变化。当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序,这张表即中断描述符表IDT。本文将针对Linux0.11代码进行分析和调试,来了解中断机制,主要分析以下三个问题:
1. 中断描述符表的建立。
2. 一般中断的处理过程,以0x3号中断为例。
3. 系统调用的处理过程,以fork系统调用为例。
有关调试环境的建立请参考:从linux0.11引导代码小窥内存分段机制。
中断描述符表(IDT)的创建代码在boot/head.s中,与全局描述符表的创建类似,内核执行lidt idt_descr指令完成创建工作,全局变量idt_descr的定义如下:
idt_descr: .word 256*8-1 # idt contains 256 entries .long _idt _idt: .fill 256,8,0 # idt is uninitialized |
lidt指令为6字节操作数,它将_idt的地址加载进idtr寄存器,IDT被设置为包含256个8字节表项的描述符表。
中断描述符表的初始化工作主要通过宏_set_get来完成,它定义于include/asm/system.h中,如下:
#define _set_gate(gate_addr,type,dpl,addr) / __asm__ ("movw %%dx,%%ax/n/t" / "movw %0,%%dx/n/t" / "movl %%eax,%1/n/t" / "movl %%edx,%2" / : / : "i" ((short) (0x8000+(dpl<<13)+(type<<8))), / "o" (*((char *) (gate_addr))), / "o" (*(4+(char *) (gate_addr))), / "d" ((char *) (addr)),"a" (0x00080000)) /*设置中断门函数,特权级0,类型386中断门*/ #define set_intr_gate(n,addr) / _set_gate(&idt[n],14,0,addr) /*设置陷阱门函数,特权级0,类型386陷阱门*/ #define set_trap_gate(n,addr) / _set_gate(&idt[n],15,0,addr) /*设置系统调用函数,特权级3,类型386陷阱门*/ #define set_system_gate(n,addr) / _set_gate(&idt[n],15,3,addr) |
内核将用这些宏初始化IDT表,代码如下:
/*摘自kernel/traps.c,trap_init函数*/ set_trap_gate(0,÷_error); set_trap_gate(1,&debug); set_trap_gate(2,&nmi); set_system_gate(3,&int3); /* int3-5 can be called from all */ set_system_gate(4,&overflow); set_system_gate(5,&bounds); set_trap_gate(6,&invalid_op); set_trap_gate(7,&device_not_available); set_trap_gate(8,&double_fault); set_trap_gate(9,&coprocessor_segment_overrun); set_trap_gate(10,&invalid_TSS); set_trap_gate(11,&segment_not_present); set_trap_gate(12,&stack_segment); set_trap_gate(13,&general_protection); set_trap_gate(14,&page_fault); set_trap_gate(15,&reserved); set_trap_gate(16,&coprocessor_error); for (i=17;i<48;i++) set_trap_gate(i,&reserved); set_trap_gate(45,&irq13); set_trap_gate(39,¶llel_interrupt); |
/*摘自kernel/chr_drv/serial.c,rs_init函数*/ set_intr_gate(0x24,rs1_interrupt); set_intr_gate(0x23,rs2_interrupt); |
/*摘自kernel/chr_drv/console.c,con_init函数*/ set_trap_gate(0x21,&keyboard_interrupt); |
/*摘自kernel/sched.c,sched_init函数*/ set_intr_gate(0x20,&timer_interrupt); set_system_gate(0x80,&system_call); |
/*摘自kernel/blk_drv/hd.c,hd_init函数*/ set_intr_gate(0x2E,&hd_interrupt); |
/*摘自kernel/blk_drv/floppy.c,floppy_init函数*/ set_trap_gate(0x26,&floppy_interrupt); |
每个中断向量号具体意义这里不做说明,有兴趣的同志可以参考清华大学出版社出版的《保护方式下的80386及其编程》和赵炯博士的《Linux内核完全注释》;中断调用的具体过程将在后面的例子中详细分析。现在我们关心的是初始化完毕的IDT,调试查看这张表的内容,选取0x0号、0x20号、0x80号中断作为例子。通过查看System.map文件可知:0x0号中断调用的divide_error函数地址为0x8dec,0x20号中断调用的timer_interrupt函数地址为0x74f4,0x80号中断调用的system_call函数地址为0x7418。当内核第一次调用fork函数创建进程0的子进程时,IDT表已经初始化完毕,因此我们在fork函数地址0x753c处设置断点,启动bochsdgb进行调试,命令行如下:
(0) Breakpoint 1, 0x753c in ?? ()
Next at t=16879006
(0) [0x0000753c] 0008:0000753c (unk. ctxt): call .+0x93d4 ; e8931e00
00
……
idtr:base=0x54b8, limit=0x7ff
……
IDT基址为0x54b8,0号中断描述符的地址为0x54b8+0*8=0x54b8,20号中断描述符的地址为0x54b8+0x20*8= 0x55b8,80号中断描述符的地址为0x54b8+0x80*8=0x58b8,分别查看内存这三个地址的8字节内容,命令行如下:
[bochs]:
0x000054b8
[bochs]:
0x000055b8
[bochs]:
0x000058b8
门描述符具有如下形式:
m+7 |
m+6 |
m+5 |
m+4 |
m+3 |
m+2 |
m+1 |
m+0 |
Offset(31...16) |
Attributes |
Selector |
Offset(15...0) |
Byte m+5 |
Byte m+4 |
||||||||||||||
BIT7 |
BIT6 |
BIT5 |
BIT4 |
BIT3 |
BIT2 |
BIT1 |
BIT0 |
BIT7 |
BIT6 |
BIT5 |
BIT4 |
BIT3 |
BIT2 |
BIT1 |
BIT0 |
P |
DPL |
DT0 |
TYPE |
000 |
Dword Count |
因此调试信息显示,0x0号中断描述符中断调用地址为0x0008:0x00008dec,是一个特权级为0的386陷阱门,0x20号中断描述符中断调用函数地址为0x0008:0x000074f4,是一个特权级为0的386中断门,0x80号中断描述符中断调用函数地址为0x0008:0x00007418,是一个特权级为3的386陷阱门。这和预先分析的情况一致。
在分析中断响应过程之前,先介绍一下任务的内核态堆栈。
当中断事件发生时,中断源向cpu发出申请,若cpu受理,则保存当前的寄存器状态、中断返回地址等许多信息,然后cpu转去执行相应的事件处理程序。中断处理完毕后,cpu将恢复之前保存的信息,并继续原来的工作。因为中断处理需要在内核态下进行,因此每个任务都有一个内核态堆栈,用来完成中断处理中保护现场和恢复现场的工作。这个内核态堆栈与每个任务的任务数据结构放在同一页面内,在创建新任务时,fork函数在任务tss内核级字段中设置,代码位于kernel/fork.c的copy_process函数中,如下:
/*p即需创建的新任务*/ p->tss.esp0 = PAGE_SIZE + (long) p; p->tss.ss0 = 0x10; |
tss.esp0和tss.ss0的值在任务内核态工作时不会被改变,因此任务每次进入内核态工作时,这个堆栈总是空的。
0x3号中断用于暂停程序的执行,通过查看Linux代码,可以知道对这个中断的处理仅仅是打印一些寄存器状态信息。选取这个中断作为例子的意义在于:它有一个完整的保护现场和恢复现场的过程(比如0x0号中断的处理将直接终止进程而不需要恢复现场);中断信号可以由用户态的程序产生。
0x3号中断处理程序int3在kernel/asm.s中定义,如下:
#源代码书写顺序并非如此,这样排列是为了阅读的方便 _int3: pushl $_do_int3 jmp no_error_code no_error_code: #以下入栈操作为保护现场的动作 xchgl %eax,(%esp) pushl %ebx pushl %ecx pushl %edx pushl %edi pushl %esi pushl %ebp push %ds push %es push %fs pushl $0 # "error code" lea 44(%esp),%edx pushl %edx movl $0x10,%edx mov %dx,%ds mov %dx,%es mov %dx,%fs call *%eax #调用实际中断处理函数 addl $8,%esp #以下出栈操作为恢复现场的动作 pop %fs pop %es pop %ds popl %ebp popl %esi popl %edi popl %edx popl %ecx popl %ebx popl %eax iret |
这里有个问题:如果发生特权级改变,用户态的堆栈指针在什么时候保存和恢复?答案是cpu响应中断时自动将这些数据入栈,执行iret指令时自动将这些数据出栈。下面的实验可以验证这一点。
接下来的试验比较繁琐,按照以下步骤进行:
1. 编写产生0x3号中断的程序。
2. 在int3函数地址处设置断点,查看此时内核态堆栈的内容,即验证保护现场的动作。
3. 执行直到中断返回,验证iret指令的作用,即验证恢复现场的动作。
编写产生0x3号中断的程序非常简单,启动bochs+linux-0.11-devel-040329(这个img由赵炯博士加入了gcc)。用vi创建编辑一个c文件int3.c,代码如下:
#include
int main() { __asm__(“int3”); return 0; } |
编译这个文件产生执行程序int3。
通过查看System.map文件可知0x3号中断处理函数_int3的地址为0x8e2f。启动bochsdgb进行调试,命令行如下:
(0) Breakpoint 1, 0x8e2f in ?? ()
Next at t=143245141
(0) [0x00008e2f] 0008:00008e2f (unk. ctxt): push 0x7af4 ; 68f47a00
00
首先关注一下内核堆栈中的内容,当前任务(0x60-0x20)/8=8号任务的tss结构中的ss0和esp0字段包含了内核态堆栈的段描述符和堆栈指针,tss结构的地址由GDT表的TSS描述符提供。继续调试,命令行如下:
……
esp:0xfa3fec #这个值在在后面的分析将用到
……
tr:s=0x60, dl=0x32e80068, dh=0x89fa, valid=1
gdtr:base=0x5cb8, limit=0x7ff
……
[bochs]:
0x00005d18
<bochs:5> x /26 0x00fa32e8
[bochs]:
0x00fa32e8
0x00000000
0x00fa32f8
0x00000000
0x00fa3308
0x00000005
0x00fa3318
0x03fffde4
0x00fa3328
0x0000000f
0x00fa3338
0x00000017
0x00fa3348
对这些调试信息按照tss字段的顺序排列得出下表:
BIT31—BIT16 |
BIT15—BIT1 |
BIT0 |
Offset |
Data |
0000000000000000 |
链接字段 |
0 |
0x00000000 |
|
ESP0 |
4 |
0x00fa4000 |
||
0000000000000000 |
SS0 |
8 |
0x00000010 |
|
ESP1 |
0CH |
0x00000000 |
||
0000000000000000 |
SS1 |
10H |
0x00000000 |
|
ESP2 |
14H |
0x00000000 |
||
0000000000000000 |
SS2 |
18H |
0x00000000 |
|
CR3 |
1CH |
0x00000000 |
||
EIP |
20H |
0x000398af |
||
EFLAGS |
24H |
0x00000246 |
||
EAX |
28H |
0x00000000 |
||
ECX |
2CH |
0x00000005 |
||
EDX |
30H |
0x000574c0 |
||
EBX |
34H |
0x00000014 |
||
ESP |
38H |
0x03fffdd8 |
||
EBP |
3CH |
0x03fffde4 |
||
ESI |
40H |
0x00000001 |
||
EDI |
44H |
0x00000000 |
||
0000000000000000 |
ES |
48H |
0x00000017 |
|
0000000000000000 |
CS |
4CH |
0x0000000f |
|
0000000000000000 |
SS |
50H |
0x00000017 |
|
0000000000000000 |
DS |
54H |
0x00000017 |
|
0000000000000000 |
FS |
58H |
0x00000017 |
|
0000000000000000 |
GS |
5CH |
0x00000017 |
|
0000000000000000 |
LDTR |
60H |
0x00000068 |
|
I/O许可位图偏移 |
000000000000000 |
T |
64H |
0x80000000 |
表1:任务8的tss结构
由表1可知:任务8内核态堆栈的起始堆栈指针为0x00fa4000。查看寄存器状态可知当前堆栈指针指向0x00fa3fec,与栈顶相差20/4 = 5个字,调试查看这5个字的内容,命令行如下:
[bochs]:
0x00fa3fec
0x03fffefc
0x00fa3ffc
这些信息就是cpu在进入int3中断处理之前自动保存的信息,参考赵炯博士的《Linux内核完全注释》可知:在用户程序(进程)将控制权交给中断处理程序之前,cpu会首先将至少12字节的信息压入中断处理程序的堆栈中。这种情况与一个长调用(段间子程序调用)比较相像。Cpu会将代码段选择符合返回地址的偏移值压入堆栈。另一个与段间调用比较相像的地方是80386将信息压入到了目的代码的堆栈上。当发生中断时,这个目的堆栈就是内核态堆栈。另外cpu还总是将标志寄存器EFLAGS的内容压入堆栈。如果优先级别发生变化,比如从用户级改变到内核系统级,cpu还会将原代码的堆栈段值和堆栈指针压入中断程序的堆栈中。
按照堆栈向下增长方向整理调试信息,如下表所示:
0x0000 |
原SS |
0x00000017 |
原ESP |
0x03fffefc |
|
EFLAGS |
0x00010202 |
|
0x0000 |
CS |
0x0000000f |
EIP |
0x0000001c |
表2:发生中断时堆栈的内容
执行iret指令返回时也类似从一个段间子程序调用的返回,堆栈中的这些内容将自动弹出到响应寄存器中,完成中断返回恢复现场的动作。调试来验证这一过程,命令行如下:
Next at t=172477604
(0) [0x00008e34] 0008:00008e34 (unk. ctxt): jmp .+0x8df1 ; ebbb
Next at t=172477605
(0) [0x00008df1] 0008:00008df1 (unk. ctxt): xchg dword ptr ss:[esp], eax ; 87042
4
……
00008e20: ( ): iretd ; cf
(0) Breakpoint 2, 0x8e20 in ?? ()
Next at t=172498467
(0) [0x00008e20] 0008:00008e20 (unk. ctxt): iretd ; cf
Next at t=172498468
(0) [0x00fac01c] 000f:0000001c (unk. ctxt): xor eax, eax ; 31c0
……
esp:0x3fffefc
eflags:0x10202
eip:0x1c
cs:s=0xf, dl=0x0, dh=0x10c0fa00, valid=1
ss:s=0x17, dl=0x3fff, dh=0x10c0f300, valid=1
……
无需解释,表2和上面寄存器状态信息即可说明问题。
以系统调用fork函数为例,它的定义如下:
/*摘自init/main.c*/ static inline _syscall0(int,fork) |
/*摘自include/unistd.h*/ #define __NR_fork 2 /*摘自include/unistd.h*/ #define _syscall0(type,name) / type name(void) / { / long __res; / __asm__ volatile ("int $0x80" / : "=a" (__res) / : "0" (__NR_##name)); / if (__res >= 0) / return (type) __res; / errno = -__res; / return -1; / } |
__NR_fork值2是系统调用中断处理的跳转表的索引,这张系统调用函数指针表定义如下:
/*摘自include/linux/sched.h*/ typedef int (*fn_ptr)(); |
/*摘自include/linux/sys.h*/ fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link, sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod, sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount, sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm, sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access, sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir, sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid,sys_setregid }; |
sys_call_table[2]的值是sys_fork函数指针,这个函数的功能不是我们研究的重点,有兴趣的同志可以参考其它资料。
将宏_syscall0和__NR_fork展开:
staic inline int fork(void) { long __res; __asm__ volatile ("int $0x80" : "=a" (__res) : "0" (2)); /* eax的值置为2*/ if (__res >= 0) return (int) __res; errno = -__res; return -1; } |
现在fork函数的功能就很清楚了:将eax的值置为2,产生0x80中断,0x80中断的中断处理函数是system_call(还记得吗?set_system_gate(0x80,&system_call))。system_call定义如下:
_system_call: cmpl $nr_system_calls-1,%eax #eax保存系统调用跳转函数表的索引值 ja bad_sys_call push %ds #保护现场 push %es push %fs pushl %edx pushl %ecx # push %ebx,%ecx,%edx as parameters pushl %ebx # to the system call movl $0x10,%edx # set up ds,es to kernel space mov %dx,%ds mov %dx,%es movl $0x17,%edx # fs points to local data space mov %dx,%fs call _sys_call_table(,%eax,4) #通过系统调用跳转函数表调用相关处理程序 pushl %eax movl _current,%eax cmpl $0,state(%eax) # state 当前进程未就绪则进行进程调度 jne reschedule cmpl $0,counter(%eax) # counter 时间片用完进行则进程调度 je reschedule ret_from_sys_call: movl _current,%eax # task[0] cannot have signals cmpl _task,%eax je 3f cmpw $0x0f,CS(%esp) # was old code segment supervisor ? jne 3f cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ? jne 3f movl signal(%eax),%ebx movl blocked(%eax),%ecx notl %ecx andl %ebx,%ecx bsfl %ecx,%ecx je 3f btrl %ecx,%ebx #有信号则调用信号处理程序 movl %ebx,signal(%eax) incl %ecx pushl %ecx call _do_signal popl %eax #恢复现场 3: popl %eax popl %ebx popl %ecx popl %edx pop %fs pop %es pop %ds iret #中断返回 |
cpu 处理0x80中断与一般中断处理过程是一样的:压入cs,eip,eflags到目标堆栈,中断返回则从堆栈中弹出这些值到相应寄存器。其中断处理函数将通过系统调用函数指针表来处理相应系统调用。这个过程就不做验证了,有兴趣的同志可以参考一般中断处理的调试过程。
在cpu响应中断源时,压入的eip的值,中断返回将这个值弹出加载到eip,用这样的方式继续应用程序控制流。这个eip的值将根据不同的异常来确定:
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回到下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回到下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
根据故障是否可修复决定要么重新执行当前指令,要么终止 |
终止 |
不可修复的错误 |
同步 |
不会返回 |
表3:异常的类别(摘自《深入理解计算机系统》)
之前分析到的0x3号中断和0x80号中断即属于“陷阱”,因此它们中断处理完毕后总是由内核态转换到用户态(通过分段机制,段寄存器加载不同的段描述符),并返回到应用程序的下一条指令。
中断处理的行为和长调用(段间子程序调用)的行为颇为相似,理解长调用的处理过程即可理解中断处理过程。计算机理论中很多概念都是相通的,因此,扎实的基本功完全可以触类旁通的指导我们开发应用程序。