异常就是控制流中的突变,用来响应处理器状态中的某些变化。当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序,这张表即中断描述符表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进行调试,命令行如下:
<bochs:1> break 0x753c
<bochs:2> c
(0) Breakpoint 1, 0x753c in ?? ()
Next at t=16879006
(0) [0x0000753c] 0008:0000753c (unk. ctxt): call .+0x93d4 ; e8931e00
00
<bochs:3> dump_cpu
……
idtr:base=0x54b8, limit=0x7ff
……
IDT基址为0x54b8,0号中断描述符的地址为0x54b8+0*8=0x54b8,20号中断描述符的地址为0x54b8+0x20*8= 0x55b8,80号中断描述符的地址为0x54b8+0x80*8=0x58b8,分别查看内存这三个地址的8字节内容,命令行如下:
<bochs:4> x /2 0x54b8
[bochs]:
0x000054b8 <bogus+ 0>: 0x00088dec 0x00008f00
<bochs:5> x /2 0x55b8
[bochs]:
0x000055b8 <bogus+ 0>: 0x000874f4 0x00008e00
<bochs:6> x /2 0x58b8
[bochs]:
0x000058b8 <bogus+ 0>: 0x00087418 0x0000ef00
门描述符具有如下形式:
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 <stdio.h>
int main()
{
__asm__(“int3”);
return 0;
}
编译这个文件产生执行程序int3。
通过查看System.map文件可知0x3号中断处理函数_int3的地址为0x8e2f。启动bochsdgb进行调试,命令行如下:
<bochs:1> b 0x8e2f
<bochs:2> c #同时在启动的Linux下运行int3程序,将获得下面这些信息
(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描述符提供。继续调试,命令行如下:
<bochs:3> dump_cpu
……
esp:0xfa3fec #这个值在在后面的分析将用到
……
tr:s=0x60, dl=0x32e80068, dh=0x89fa, valid=1
gdtr:base=0x5cb8, limit=0x7ff
……
<bochs:4> x /2 0x5d18 #0x5cb8+0x60=0x5d18
[bochs]:
0x00005d18 <bogus+ 0>: 0x32e80068 0x00008bfa
<bochs:5> x /26 0x00fa32e8
[bochs]:
0x00fa32e8 <bogus+ 0>: 0x00000000 0x00fa4000 0x00000010
0x00000000
0x00fa32f8 <bogus+ 16>: 0x00000000 0x00000000 0x00000000
0x00000000
0x00fa3308 <bogus+ 32>: 0x000398af 0x00000246 0x00000000
0x00000005
0x00fa3318 <bogus+ 48>: 0x000574c0 0x00000014 0x03fffdd8
0x03fffde4
0x00fa3328 <bogus+ 64>: 0x00000001 0x00000000 0x00000017
0x0000000f
0x00fa3338 <bogus+ 80>: 0x00000017 0x00000017 0x00000017
0x00000017
0x00fa3348 <bogus+ 96>: 0x00000068 0x80000000
对这些调试信息按照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:6> x /5 0xfa3fec
[bochs]:
0x00fa3fec <bogus+ 0>: 0x0000001c 0x0000000f 0x00010202
0x03fffefc
0x00fa3ffc <bogus+ 16>: 0x00000017
这些信息就是cpu在进入int3中断处理之前自动保存的信息,参考赵炯博士的《Linux内核完全注释》可知:在用户程序(进程)将控制权交给中断处理程序之前,cpu会首先将至少12字节的信息压入中断处理程序的堆栈中。这种情况与一个长调用(段间子程序调用)比较相像。Cpu会将代码段选择符合返回地址的偏移值压入堆栈。另一个与段间调用比较相像的地方是80386将信息压入到了目的代码的堆栈上。当发生中断时,这个目的堆栈就是内核态堆栈。另外cpu还总是将标志寄存器EFLAGS的内容压入堆栈。如果优先级别发生变化,比如从用户级改变到内核系统级,cpu还会将原代码的堆栈段值和堆栈指针压入中断程序的堆栈中。
按照堆栈向下增长方向整理调试信息,如下表所示:
0x0000
原SS
0x00000017
原ESP
0x03fffefc
EFLAGS
0x00010202
0x0000
CS
0x0000000f
EIP
0x0000001c
表2:发生中断时堆栈的内容
执行iret指令返回时也类似从一个段间子程序调用的返回,堆栈中的这些内容将自动弹出到响应寄存器中,完成中断返回恢复现场的动作。调试来验证这一过程,命令行如下:
<bochs:7> n #7,8,9指令都是为了找到iret的位置
Next at t=172477604
(0) [0x00008e34] 0008:00008e34 (unk. ctxt): jmp .+0x8df1 ; ebbb
<bochs:8> n
Next at t=172477605
(0) [0x00008df1] 0008:00008df1 (unk. ctxt): xchg dword ptr ss:[esp], eax ; 87042
4
<bochs:9> u /30
……
00008e20: ( ): iretd ; cf
<bochs:10> b 0x8e20
<bochs:11> c
(0) Breakpoint 2, 0x8e20 in ?? ()
Next at t=172498467
(0) [0x00008e20] 0008:00008e20 (unk. ctxt): iretd ; cf
<bochs:12> n #中断返回
Next at t=172498468
(0) [0x00fac01c] 000f:0000001c (unk. ctxt): xor eax, eax ; 31c0
<bochs:13> dump_cpu
……
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到目标堆栈,中断返回则从堆栈中弹出这些值到相应寄存器。其中断处理函数将通过系统调用函数指针表来处理相应系统调用。这个过程就不做验证了,有兴趣的同志可以参考一般中断处理的调试过程。
eip的值
在cpu响应中断源时,压入的eip的值,中断返回将这个值弹出加载到eip,用这样的方式继续应用程序控制流。这个eip的值将根据不同的异常来确定:
类别
原因
异步/同步
返回行为
中断
来自I/O设备的信号
异步
总是返回到下一条指令
陷阱
有意的异常
同步
总是返回到下一条指令
故障
潜在可恢复的错误
同步
根据故障是否可修复决定要么重新执行当前指令,要么终止
终止
不可修复的错误
同步
不会返回
表3:异常的类别(摘自《深入理解计算机系统》)
之前分析到的0x3号中断和0x80号中断即属于“陷阱”,因此它们中断处理完毕后总是由内核态转换到用户态(通过分段机制,段寄存器加载不同的段描述符),并返回到应用程序的下一条指令。
后记
中断处理的行为和长调用(段间子程序调用)的行为颇为相似,理解长调用的处理过程即可理解中断处理过程。计算机理论中很多概念都是相通的,因此,扎实的基本功完全可以触类旁通的指导我们开发应用程序。