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进行调试,命令行如下:

break 0x753c

c

(0) Breakpoint 1, 0x753c in ?? ()

Next at t=16879006

(0) [0x0000753c] 0008:0000753c (unk. ctxt): call .+0x93d4             ; e8931e00

00

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字节内容,命令行如下:

x /2 0x54b8

[bochs]:

0x000054b8 :    0x00088dec      0x00008f00

x /2 0x55b8

[bochs]:

0x000055b8 :    0x000874f4      0x00008e00

x /2 0x58b8

[bochs]:

0x000058b8 :    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

 

int main()

{

  __asm__(“int3”);

  return 0;

}

       编译这个文件产生执行程序int3。

 

       通过查看System.map文件可知0x3号中断处理函数_int3的地址为0x8e2f。启动bochsdgb进行调试,命令行如下:

b 0x8e2f

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描述符提供。继续调试,命令行如下:

dump_cpu

……

esp:0xfa3fec  #这个值在在后面的分析将用到

……

tr:s=0x60, dl=0x32e80068, dh=0x89fa, valid=1

gdtr:base=0x5cb8, limit=0x7ff

……

x /2 0x5d18  #0x5cb8+0x60=0x5d18

[bochs]:

0x00005d18 :    0x32e80068      0x00008bfa

<bochs:5> x /26 0x00fa32e8

[bochs]:

0x00fa32e8 :    0x00000000      0x00fa4000      0x00000010

0x00000000

0x00fa32f8 :    0x00000000      0x00000000      0x00000000

0x00000000

0x00fa3308 :    0x000398af      0x00000246      0x00000000

0x00000005

0x00fa3318 :    0x000574c0      0x00000014      0x03fffdd8

0x03fffde4

0x00fa3328 :    0x00000001      0x00000000      0x00000017

0x0000000f

0x00fa3338 :    0x00000017      0x00000017      0x00000017

0x00000017

0x00fa3348 :    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个字的内容,命令行如下:

x /5 0xfa3fec

[bochs]:

0x00fa3fec :    0x0000001c      0x0000000f      0x00010202

0x03fffefc

0x00fa3ffc :    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指令返回时也类似从一个段间子程序调用的返回,堆栈中的这些内容将自动弹出到响应寄存器中,完成中断返回恢复现场的动作。调试来验证这一过程,命令行如下:

n   #7,8,9指令都是为了找到iret的位置

Next at t=172477604

(0) [0x00008e34] 0008:00008e34 (unk. ctxt): jmp .+0x8df1              ; ebbb

n

Next at t=172477605

(0) [0x00008df1] 0008:00008df1 (unk. ctxt): xchg dword ptr ss:[esp], eax ; 87042

4

u /30

……

00008e20: (                    ): iretd                     ; cf

b 0x8e20

c

(0) Breakpoint 2, 0x8e20 in ?? ()

Next at t=172498467

(0) [0x00008e20] 0008:00008e20 (unk. ctxt): iretd                     ; cf

n   #中断返回

Next at t=172498468

(0) [0x00fac01c] 000f:0000001c (unk. ctxt): xor eax, eax              ; 31c0

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号中断即属于“陷阱”,因此它们中断处理完毕后总是由内核态转换到用户态(通过分段机制,段寄存器加载不同的段描述符),并返回到应用程序的下一条指令。

后记

       中断处理的行为和长调用(段间子程序调用)的行为颇为相似,理解长调用的处理过程即可理解中断处理过程。计算机理论中很多概念都是相通的,因此,扎实的基本功完全可以触类旁通的指导我们开发应用程序。

你可能感兴趣的:(linux0.11下的中断机制分析)