原文:http://www.cnblogs.com/hongzg1982/articles/2115188.html 点击打开链接
linux0.11下的中断机制分析
异常就是控制流中的突变,用来响应处理器状态中的某些变化。当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序,这张表即中断描述符表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; e8931e0000
<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 ; 68f47a0000
首先关注一下内核堆栈中的内容,当前任务(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号中断即属于“陷阱”,因此它们中断处理完毕后总是由内核态转换到用户态(通过分段机制,段寄存器加载不同的段描述符),并返回到应用程序的下一条指令。
后记
中断处理的行为和长调用(段间子程序调用)的行为颇为相似,理解长调用的处理过程即可理解中断处理过程。计算机理论中很多概念都是相通的,因此,扎实的基本功完全可以触类旁通的指导我们开发应用程序。