在技术面前,多问为什么总是好的,知其然不如知其所以然。
为什么要有中断?
本文尽量以设计者的角度去介绍中断。
本文着重介绍Linux内核中中断处理的始末流程,因此对一些基本的概念应该有所了解。
我们知道,CPU有一个INTR引脚,用于接收中断请求信号。
而中断控制器用于提供中断向量,即第几号中断。
首先,中断可能来源于外部设备,而外部设备多种多样,也可能来源于CPU,无论如何我们需要区分到底是具体哪种中断,哪个设备产生的。因此,我们首先需要一个表,表中每项表示一种中断,内容可以是指向具体中断服务程序的函数指针,这是最简单的中断向量表,这样,当中断发生时,将中断向量作为中断向量表的下表,就可以直接找到中断服务程序。如图1所示。
但是操作系统考虑的更多,比如中断优先级、中断类型标识等。于是就产生了如下图所示中断向量表项。
但是这样还不行,一方面中断向量有硬件中断控制器产生,因此中断向量数目受限于硬件,无法弹性增长;另一方面,作为通用操作系统,可能存在许多不同的外部设备,也会操作中断向量不够用。因此,解决办法就是共用中断向量。系统为每个中断向量设置一个队列,根据中断源所使用的中断向量,将其挂入到相应的队列中去。其中队列的队列头是irq_desc_t结构体数组。
typedef struct {
unsigned int status; /* IRQ status */
hw_irq_controller *handler;
struct irqaction *action; /* IRQ action list */
unsigned int depth; /* nested irq disables */
spinlock_t lock;
} ____cacheline_aligned irq_desc_t;
extern irq_desc_t irq_desc [NR_IRQS];
其中,通过其结构体内部的结构体 irqaction 形成一个队列。irqaction结构体可以理解为挂在当前中断向量队列中的特定的设备中断服务程序,如此,同一中断向量队列,就能够区分是哪个中断服务程序属于哪个设备了。
struct irqaction {
void (*handler)(int, void *, struct pt_regs *); //设备具体中断服务程序
unsigned long flags;
unsigned long mask;
const char *name;
void *dev_id; //设备的id表示
struct irqaction *next; //队列中下一个此结构体
};
最终形成的中断服务结构图如下所示。
首先,中断向量表内容从0~0x20均为CPU内部产生的中断,包括除0、页面错等。从0x20开始均为用于外部设备的通用中断(包括中断请求队列),但是0x80系统调用除外。这些表项的内容都是在中断向量表初始化的时候进行设置。
对于0~19个内部中断设置由下面函数设置。
void __init trap_init(void)
{
#ifdef CONFIG_EISA
if (isa_readl(0x0FFFD9) == 'E'+('I'<<8)+('S'<<16)+('A'<<24))
EISA_bus = 1;
#endif
set_trap_gate(0,÷_error);
set_trap_gate(1,&debug);
set_intr_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,&spurious_interrupt_bug);
set_trap_gate(16,&coprocessor_error);
set_trap_gate(17,&alignment_check);
set_trap_gate(18,&machine_check);
set_trap_gate(19,&simd_coprocessor_error);
set_system_gate(SYSCALL_VECTOR,&system_call);
/*
* default LDT is a single-entry callgate to lcall7 for iBCS
* and a callgate to lcall27 for Solaris/x86 binaries
*/
set_call_gate(&default_ldt[0],lcall7);
set_call_gate(&default_ldt[4],lcall27);
/*
* Should be a barrier for any external CPU state.
*/
cpu_init();
#ifdef CONFIG_X86_VISWS_APIC
superio_init();
lithium_init();
cobalt_init();
#endif
}
中断类型为中断门的中断向量表设置具体函数如下。
void set_intr_gate(unsigned int n, void *addr)
{
//设置中断向量表项
//n表示第几项
//addr表示中断服务的入口程序地址
_set_gate(idt_table+n,14,0,addr);
}
事实上,上面函数参数 void *addr即为上面中断服务结构图中的一系列IRQ0x00_interrupt(),这类函数也是中断服务程序入口函数,即进入中断最先执行的一小段程序,这段程序非常重要,读者可以先猜测一下其功能。
之前提到,用于外设的通用中断,多个中断源可以共用一个中断向量,因此就有了上文中断服务结构图中的中断请求队列,为了清晰,这里再贴出其结构图。每一个中断请求队列可以想象成一个中断通道,里面容纳了一系列具体设备的中断服务程序。
在4.2中我们仅仅是设置了中断向量表项的内容,但是对于通用中断来说,那时仅仅设置了一系列IRQ0x00_interrupt()通用中断入口函数,并没有将各个设备的具体中断服务程序通过结构体irqaction挂到相应的中断请求队列中。
所以,真正的中断服务要到具体设备初始化程序将中断服务程序通过request_irq()向系统“登记”,挂入某个中断请求队列以后才会发生,下面的函数根据函数参数设置一个irqaction结构体,并根据下标irq挂入相应的irq_desc[irq]所表示的中断请求队列中。
int request_irq(unsigned int irq,
void (*handler)(int, void *, struct pt_regs *),
unsigned long irqflags,
const char * devname,
void *dev_id)
{
int retval;
struct irqaction * action;
#if 1
/*
* Sanity-check: shared interrupts should REALLY pass in
* a real dev-ID, otherwise we'll have trouble later trying
* to figure out which interrupt is which (messes up the
* interrupt freeing logic etc).
*/
if (irqflags & SA_SHIRQ) {
if (!dev_id)
printk("Bad boy: %s (at 0x%x) called us without a dev_id!\n", devname, (&irq)[-1]);
}
#endif
if (irq >= NR_IRQS)
return -EINVAL;
if (!handler)
return -EINVAL;
action = (struct irqaction *)
kmalloc(sizeof(struct irqaction), GFP_KERNEL);
if (!action)
return -ENOMEM;
//irqaction具体参数的设置
action->handler = handler;
action->flags = irqflags;
action->mask = 0;
action->name = devname;
action->next = NULL;
action->dev_id = dev_id;
retval = setup_irq(irq, action); //根据irq,将其挂入某个中断请求队列中
if (retval)
kfree(action);
return retval;
}
上述工作已经完成中断机制所需要的所有环境,对于0x20以上的通用中断,外部设备也已经将中断服务程序挂入到相应的中断请求队列中。
我们现在假设外部程序产生了一次中断请求,该请求通过中断控制器到达了CPU的中断请求引线INTR,并且中断开着,所以当CPU执行完当前指令后就来响应此才中断请求。
在介绍前,我们首先需要明白中断的执行意味着什么?
一般外设产生的中断属于突发状况,也就是需要离开正在执行的程序,转向执行中断服务。那么这就面临着几个问题:
带着这两个问题,我们接着往下看。
CPU从中断控制器取得中断向量后,就从中断向量表查找该中断向量所指的那一项,还记得通用中断请求队列图 里面的IRQ0x0x_interrupt()函数吗?它是通用中断向量表项中的一部分,即该通用中断通道的总服务程序入口函数。
另外,当中断在用户空间发生,当前运行级别为3,而中断服务程序属于内核,其运行级别在中断向量表项中用DPL标识为0,因此,需要切换堆栈:即从用户空间堆栈切换成当前进程的系统空间堆栈。正在运行的堆栈指针存放在寄存器TR所指向的TSS中(这不是重点),因此CPU从TSS中取出系统堆栈指针,完成用户堆栈到系统堆栈的切换。完成堆栈切换后,会将EFLAGS的内容及中断返回地址压入系统堆栈。注意这些压栈操作是由中断指令INT本身发出的,这是还未进入中断通道的总服务程序入口函数IRQ0x0x_interrupt()。
而至于切换堆栈,本人暂时没弄清楚由哪些代码完成,还请不吝赐教!
相反,如果中断发生在系统空间,那就无需切换堆栈了,两者差别如下图所示。
由于此过程非常关键,在此单独叙述。
以IRQ0x03_interrupt()为例,将函数具体内容列出。
__asm__ ( \
"\n" \
"IRQ0x03_interrupt: \n\t" \
"pushl $0x03 - 256 \n\t" \ //中断向量号-256,压栈
"jmp common_interrupt"); //跳转
----------------------------------------------------
#define BUILD_COMMON_IRQ() \
asmlinkage void call_do_IRQ(void); \
__asm__( \
"\n" __ALIGN_STR"\n" \
"common_interrupt:\n\t" \
SAVE_ALL \ //保存现场,中断前夕所有寄存器内容压栈
"pushl $ret_from_intr\n\t" \ //将一个函数地址压栈
SYMBOL_NAME_STR(call_do_IRQ)":\n\t" \
"jmp "SYMBOL_NAME_STR(do_IRQ)); //跳转到do_IRQ函数
首先执行IRQ0x03_interrupt()
然后,又跳转到common_interrupt
最后又跳转到do_IRQ函数。
其中,保存现场的SAVE_ALL操作:
#define SAVE_ALL \
cld; \
pushl %es; \
pushl %ds; \
pushl %eax; \
pushl %ebp; \
pushl %edi; \
pushl %esi; \
pushl %edx; \
pushl %ecx; \
pushl %ebx; \
movl $(__KERNEL_DS),%edx; \
movl %edx,%ds; \
movl %edx,%es;
因此,在跳转到do_IRQ时,系统堆栈应该如下图所示,结合上面代码,可以说非常清晰了。
堆栈中所保存的这些内容,用于将来恢复进入中断前的程序的执行。
接下来就是执行do_IRQ函数,即要执行具体的中断服务函数了,那么需要具备哪些条件呢?
那么中断调用号从哪获取呢?先看一下do_IRQ函数原型。
unsigned int do_IRQ(struct pt_regs regs);
struct pt_regs {
long ebx;
long ecx;
long edx;
long esi;
long edi;
long ebp;
long eax;
int xds;
int xes;
long orig_eax;
long eip;
int xcs;
long eflags;
long esp;
int xss;
};
请关注其参数 regs,然后再对照图6.2系统堆栈,你会发现什么?
如何没有想到,再提醒一下函数调用在栈中的构造过程是什么?
没错,当前所构造出系统堆栈的内容,其实是做了do_IRQ函数的参数,而在调用do_IRQ前,最后压入的ret_from_intr则是do_IRQ的返回地址。
这样中断服务程序do_IRQ所需的中断调用号有了,返回地址也设置妥了,接下来真的就要进入do_IRQ了。
结合前面所述,再看中断请求队列,可以想象一下do_IRQ函数具体做了什么?
限于篇幅,这里不再贴出代码,具体可以查看arch/i386/kernel/irq.c中的代码。
在函数的末尾,可能会执行软中断服务程序 do_softirq(),关于软中断的由来请参考其他资料。在函数执行完,就会按照之前精心设置的返回地址ret_from_intr进行返回了。
do_IRQ()函数通过返回地址ret_from_intr会到达entry.S中标号ret_from_intr处:
ENTRY(ret_from_intr)
GET_CURRENT(%ebx)
movl EFLAGS(%esp),%eax # mix EFLAGS and CS
movb CS(%esp),%al
testl $(VM_MASK | 3),%eax # return to VM86 mode or non-supervisor?
jne ret_with_reschedule
jmp restore_all
上面的操作主要是检查中断前夕,cpu运行于用户空间还是系统空间,若发生于用户空间,转移到ret_with_reschedule,然后最终还是会到达restore_all处。
ret_with_reschedule:
cmpl $0,need_resched(%ebx)
jne reschedule
cmpl $0,sigpending(%ebx)
jne signal_return
restore_all:
RESTORE_ALL
ALIGN
signal_return:
sti # we can get here from an interrupt handler
testl $(VM_MASK),EFLAGS(%esp)
movl %esp,%eax
jne v86_signal_return
xorl %edx,%edx
call SYMBOL_NAME(do_signal)
jmp restore_all
reschedule:
call SYMBOL_NAME(schedule) # test
jmp ret_from_sys_call
首先,在ret_with_reschedule中判断是否需要进行一次进程调度,需要这转移到reschedule处,接着会转移到ret_from_sys_call,但是从ret_from_sys_call最终还是会到达restore_all处。
而restore_all操作如下:
#define RESTORE_ALL \
popl %ebx; \
popl %ecx; \
popl %edx; \
popl %esi; \
popl %edi; \
popl %ebp; \
popl %eax; \
1: popl %ds; \
2: popl %es; \
addl $4,%esp; \
3: iret;
这与之前的SAVE_ALL遥相呼应
#define SAVE_ALL \
cld; \
pushl %es; \
pushl %ds; \
pushl %eax; \
pushl %ebp; \
pushl %edi; \
pushl %esi; \
pushl %edx; \
pushl %ecx; \
pushl %ebx; \
movl $(__KERNEL_DS),%edx; \
movl %edx,%ds; \
movl %edx,%es;
这样,当到达RESTORE_ALL的iret时,iret使CPU从中断返回,和进入中断时对应,如果是从系统态返回到用户态就会将堆栈切换到用户堆栈。
这就是Linux内核2.4.0版本的中断机制的内容,当然这里省略了软中断的内容,需要的读者可以参考其他资料。
技术是为了解决问题的,技术的门槛往往在于不了解技术本身,一旦清楚其过程,也就没有了门槛,但是这对于设计一项技术还远远不够,这就要求我们在了解技术的过程中,多问为什么,知其然不如知其所以然。
参考资料:
《Linux内核情景分析》毛德操,胡希明
欢迎大家扫描关注公众号:编程真相,向我提问,获取更多精彩的编程技术文章!