Linux中断详解
阅读原文时间:2021年04月20日阅读:1

这里以linux-kernel 0.11版本为基础整理中断相关知识,目的在于对于中断有一个全面、清晰和简洁的认识

1、Linux的中断类型

     Linux的各种中断都是由系统负责统一处理的。在响应一个特定的中断的时候,内核会执行一个函数,该函数叫做中断处理函数或中断服务例程。CPU执行完一条指令后,下一条指令的逻辑地址会被放在相应的寄存器中(CS和EIP),在执行新指令之前,系统会检查是否有中断产生(有相应的寄存器来表示状态),如果有,就对中断进行处理。Linux处理中断,大致可分为如下的几步:

          (1)、保存正在执行的进程的上下文,便于中断处理返回后能恢复进程的执行

          (2)、对中断进行解析,确定产生中断的中断源,识别中断的类型。但系统接受中断时,能够从物理硬件中获得一个关于中断的数                         

                    ,用这个数去做查表操作,能获得关于这个中断类型、中断处理程序等相关信息

          (3)、内核调用由第二步获得的中断处理程序,对中断进行处理

          (4)、中断处理程序执行完对中断的处理后返回。恢复之前被中断的进程的上下文,或者根据需要调度优先级更高的进程去执行  

     中断信号可分为两类:硬件中断和软件中断,软件中断一般被称为异常。Intel x86公有256个中断,每个中断都有一个0~255之间的数来表示,Intel将前32个中断号(0~31)已经固定设定好或者保留未用。中断号32~255分配给操作系统和应用程序使用。在Linux中,中断号32~47对应于一个硬件芯片的16个中断请求信号,这16个中断包括时钟、键盘、软盘、数学协处理器、硬盘等硬件的中断。系统调用设为中断号128,即0x80。

2、中断描述表

获取中断号(或称为中断向量)的过程涉及到太多的硬件过程,这里不做介绍。系统获得中断向量之后,要获得相应的处理程序,需要去做一个查表的操作,这里所查询的这个表,就是中断描述表。

     中断描述表是存储系统中断向量对应中断处理程序的表。Intel x86中共有256个中断,因此中断描述表中也有256项,每一项对应一个中断。系统以中断向量作为索引从表中取出相应的中断服务例程。

[中断描述表在内存中断的位置]

     中断描述表可以在内存中的任意位置,它的具体地址保存在IDTR寄存器中。

[中断描述表的初始化]

  在Linux内核版本0.11中,中断描述表的初始化分为两个部分。第一部分是在内核引导启动部分,在head.s中实现。这里的效果是将中断描述符表(用IDT表示),设置成具有256项,并且都指向ignore_int的中断门。然后将IDT的地址加载到IDTR寄存器中。因此,这里的初始化只是把所有中断的服务例程都设置成了一个哑的中断服务程序,即不做任何中断的处理。第一部分的初始化代码(AT&T的汇编语言格式)很短,这里摘抄如下。setup_idt子程序在head.s的第24行被调用

setup_idt:

     lea ignore_idt, %edx  #获取ignore_ldt的有效地址,并且把结果保存在edx寄存器中

     movl $0x0080000, %eax  #eax寄存器中的高16位变成0x0008。用来做段选择符

     movw %dx,%ax         #ax寄存器中值现在变成ignore_idt的有效地址的低16位,ax是eax寄存器的低半部分 。那么现在eax寄存器的高          

                                     #16位的值是段选择符0x0008,低16位是ignore_idt的偏移地址。简单理解可以用来找到ignore_idt程序即可

     movw $0x8E00, %dx  #设置中断描述符表中断门的数据

     lea _idt, %edi         #_idt是中断描述符表的地址,将中断描述符表的地址存放在edi寄存器中

     mov $256,%ecx     #循环使用

rp_sidt:

     movl %eax , (%edi) #edi指向的是中断描述符表,而eax中保存的是可以用来找到中断服务例程ignore_idt的地址,所以这里的效果是把

                                   #用于找到ignore_idt的数据存入edi寄存器中地址指向的内存中

     movl %edx, 4(%edi) #中断描述符表的每一项都是64位(中断描述符表中每一项的数据结构后续会将)

     addl $8, %edi          #指向下一项

     dec %ecx                 #循环计数器减一

     jne rp_sidt                #jne的意思是如果不相等就跳到rp_sidt子程序去。jne使用的是ZF标志位。ZF=0就执行下一条指令,

                                     #ZF=1就跳转。而dec %ecx会影响这个标志位。只有当ecx寄存器中的值减为0的时候,才会使得ZF=0

                                     # 所以,这里实现的效果就是会使得这段程序执行256次。将中断描述表中的每一项都填入相同的内容

     lidt idt_descr               #使用lidt指令将中断描述表的地址加载到IDTR寄存器中

     ret

     经过第一部分的操作,中断描述表已经得到初始化。但其中的数据,是没有意义的,无法服务任何中断。为了使中断描述表中的数据真正有意义,还需要往中断描述表中对应项插入合适的数据。这第二部分的过程是在各个驱动初始化的时候完成的。这里以时钟中断为例,来解析数据插入过程。在这之前可以先简要介绍下其它中断的初始化,0~16号中断的初始化在kernel/traps.c的trap_init函数中实现(181行)。trap_init函数的调用则是在main.c的main函数中调用的。

对于时钟中断来说,它往IDT中真正写入中断处理例程的实现代码是sched.c文件的406行中。相关的部分代码如下所示

/* 下面的汇编语句是清楚NT标志,NT表示是用来表示是否有程序递归调用的标志Nested Task。但NT标志为1时,那么当前中断任务执行iret指令时就会引起任务切换。NT标志指出了在TSS中的back_link字段是否有效 */

__asm__(  "pushfl ; andl $0xffffbfff,(%esp) ; popfl"  );

ltr(0);   //将任务0的TSS加载到任务寄存器tr中

lldt(0);  //加载局部描述符表到局部描述符表寄存器

          //下面是设置8253定时器

outb_p(0x36,0x43);   /* binary, mode 3, LSB/MSB, ch 0 */

outb_p(LATCH & 0xff , 0x40);   /* LSB */

outb(LATCH >> 8 , 0x40);   /* MSB */

set_intr_gate(0x20,&timer_interrupt);  //设置时钟中断的中断处理程序

outb(inb_p(0x21)&~0x01,0x21);

set_system_gate(0x80,&system_call);

    这段代码是在sched.c的sched_init函数中,sched_init函数的调用是在main.c的main函数中调用的。到这里,就完整地实现了在中断描述表中设置时钟中断的工作

[中断描述表的数据格式]

从上面对时钟中断的设置中来看,使用的是set_intr_gate来进行设定的,这是一个宏。类似的,还有两个宏来实现对中断描述表的写操作,分别是set_trap_gate和set_system_gate。对set_intr_gate宏的定义如下

#define  set_intr_gate(n,addr) \

_set_gate(&idt[n],14,0,addr)

使用这个宏,需要传递两个参数,一个是中断号,一个是中断处理函数。在set_intr_gate宏中又调用了_set_gate宏。另外的两个宏,也是调用_set_gate宏来完成工作的。因此从_set_gate宏中可以了解中断描述表中每一项的数据结构。_set_gate宏的定义如下

gate_addr描述符的地址,type描述中断类型,dpl描述中断处理的特权级别,addr中断处理程序的地址。

#define  _set_gate(gate_addr,type,dpl,addr) \

__asm__ ( "movw %%dx,%%ax\n\t"  \    //将偏移地址低字与选择符组合成描述符低4直接(eax)

  "movw %0,%%dx\n\t"  \      //将类型标志字与偏移高字组合成描述符高4字节 (edx)

  "movl %%eax,%1\n\t"  \   //设置描述符的低4字节

  "movl %%edx,%2"  \     //设置描述符的高4字节

: \

:  "i"  (( short  ) (0x8000+(dpl<<13)+(type<<8))), \

  "o"  (*(( char  *) (gate_addr))), \

  "o"  (*(4+( char  *) (gate_addr))), \ //每一个描述符占64位,获取高32位地址,即高4字节地址

  "d"  (( char  *) (addr)), "a"  (0x00080000))

这种嵌入了汇编语句,GUN gcc手册中描述的嵌入汇编的基本格式为

asm("汇编语句"

     :输出寄存器

     :输入寄存器

     :会被修改的寄存器 );

%0,%1……等是使用参数的编号,从输出寄存器开始,从左往右,从上往下。那么%0表示的则是有0x8000+(dpl<<13)+(type<<8)的值。我们分析这个值,dpl左移13位,这刚好是下图中的DPL的部分(左边32开始算起的左移),type左移8位,这对应的是9,10,和11位的三位类型码。0x8000表示成二进制就是1000 0000 0000 0000,刚好是把P标志位设置成1。

仅仅从这段汇编上不容易理解具体的数据结构,下面用图来进行说明

内核中关于IDT相应的定义如下(head.h)

typedef struct  desc_struct {

  unsigned long  a,b;

} desc_table[256];

extern  desc_table idt,gdt;

3、Linux的中断处理操作

[中断服务例程的调用]

     Linux中剩下的中断相关的文件,主要是asm.s、traps.c、systemp_call.s和mm/page.s。asm.s和traps.c中主要涉及Intel保留中断0~16.17~31号中断则被保留未用。对于32~47的16个中断处理程序分别在各种硬件初始化过程中处理(如时钟、键盘、软盘等)。讲解的时钟中断向量是32。 system_call.s中主要实现系统调用中断的入口处理过程以及信号检测处理。并且还列出了处理过程与之相似的几个中断处理程序,其中就包括32号的时钟中断。

     时钟中断的处理代码如下(在system_call.s的176行 linux kernel v0.11)

.align 2

_timer_interrupt:

push %ds                              #

push %es                              #

push %fs                              #

pushl %edx                           #

pushl %ecx                           #

pushl %ebx                           #

pushl %eax                           #到这里为止,做一些上下文环境的保存工作

movl $0x10,%eax                  

mov %ax,%ds

mov %ax,%es                        #ds和es寄存器中的值都是10。这个10是用来做段选择符的,经过地址解析之后,

                                                             #它们都指向的是内核中的代码段

movl $0x17,%eax

mov %ax,%fs                         #es和es。这里只要知道fx寄存器指向的是出错程序的数据段即可。

incl _jiffies                             #没有自动化的中断结束,需要手动执行 结束此硬件中断的指令,表明正在被处理中

movb $0x20,%al                    #

outb %al,$0x20

movl CS(%esp),%eax             #

andl $3,%eax                         #这句和上一句的效果是取出当前CPU的运行级别

                                                            # 并压入堆栈中。这个值会作为do_timers的参数

pushl %eax

call _do_timer                       # do_timer会做很多事情,其中就包括如果进程的时间片到了,会做进程调度

addl $4,%esp                        # do_timer的定义在sched.c的305行

jmp ret_from_sys_call            #做一些返回的处理操作

ret_from_sys_call过程是中断调用返回后,对信号量进行识别处理。首先判断当前任务是不是初始任务task0,如果是则不必对其进行信号量方面的处理,直接返回。

     ret_from_sys_call:    #(在system_call.s的101行中)

movl _current,%eax                             #获取当前任务地址放在eax寄存器中,_current指针指向当前任务

cmpl _task,%eax                         #_task对应C程序的task[]数组,比较_current和task[0]地址

je 3f                                          #如果是task0,就向前调至标号3

cmpw $0x0f,CS(%esp)                        #这一条语句比较选择符是否为普通用户代码段选择符0x0000f,如果不是则               

                                                            # 直接调出中断处理程序

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       #对着 _timer_interrupt的入栈操作来看。恢复相应的数据

popl %ebx

popl %ecx

popl %edx

pop %fs

pop %es

pop %ds

iret

[时钟中断的C处理函数]

时钟中断的C处理函数源代码如下(来自linux kernel v0.11 kernel/sched.c 305行)

参数cpl是当前的特权级别。对于一个进程由于执行时间片用完时,则进行任务切换,并执行一个计时更新工作

void  do_timer( long  cpl)

{

  extern  int  beepcount;

  extern  void  sysbeepstop( void );

  if  (beepcount)

  if  (!--beepcount)

sysbeepstop();

  if  (cpl)   //cpl=0,表示运行在特权级别。cpl=3表示运行在用户级别。utime和stime分别对应用户程序运行时间和内核程序运行时间

current->utime++;

  else

current->stime++;

          //软驱操作定时相关部分

  if  (next_timer) {

next_timer->jiffies--;

  while  (next_timer && next_timer->jiffies <= 0) {

  void  (*fn)( void  );

fn = next_timer->fn;

next_timer->fn = NULL;

next_timer = next_timer->next;

(fn)();

}

}

  if  (current_DOR & 0xf0)

do_floppy_timer();

           //根据用户时间片是否用完,决定是否要执行进程调度工作。counter代表进程还剩下的时间片数目,具体的值与进程的priority相关。

  if  ((--current->counter)>0)  return  ;

current->counter=0;

  if  (!cpl)  return  ;

schedule();

}

系统中断调用处理流程如下所示

PS:在linux v0.11中我没有找到根据中断向量获取中断服务例程的代码。猜想可能是在8259A中断控制芯片中实现了这个过程和中断服务例程的调用。针对这个问题,我查询过2.4版本内核的相关资料,发现有一个common_interrupt的处理函数。