中断处理
如前所述,我们知道异常的处理还是比较简单的,就是给相关的进程发送信号,而且不存在进程调度的问题,所以内核很快就处理完了异常。
但是,这种方法不适用于中断,因为当一个不相关的进程正在运行的时候,发送给特定进程的中断信号会被挂起,等到该进程执行的时候才会处理。所以,给中断发送一个信号没有太大意义。
另外,中断的处理与中断类型息息相关。所以,我们将中断分为3类:
- I/O中断
- 定时器中断
- CPU之间的中断
下面我们将以I/O中断为例展开叙述。
I/O中断处理
中断资源是有限的,所以对于I/O中断处理程序来说,应该尽量为尽可能多的设备提供服务。比如PCI总线架构,几个设备共享同一个IRQ请求线。这意味中断矢量表是共享的,不能一一覆盖所有设备。比如下面的表4-3中,中断号43就被分配给了USB端口和声卡。但是,对于一些旧的架构来说,共享IRQ请求线不是那么可靠,比如ISA总线。
增强中断处理程序的灵活性,有下面两种方式:
- IRQ共享
在每个中断处理程序中罗列所有共享该IRQ的设备的中断服务例程(ISR)。每次轮询一遍这些服务例程,判断是哪个设备发送的中断请求。所以,每次中断请求都要把所有的中断服务例程执行一遍。 - IRQ动态分配
直到最后时刻,IRQ中断请求线才会与设备驱动程序关联起来。比如,只有当用户访问软盘设备的时候才会给软盘设备分配中断请求线IRQ。使用这种方法,即使不共享IRQ中断请求线,几个硬件设备也能使用相同的中断号。
众所周知,中断有轻重缓急之分,而且中断处理程序的执行时间不能过长。因为中断处理程序运行时,IRQ中断请求线的信号会被暂时忽略,所以,长时间执行且非重要的操作应该被延后执行。更为重要的是,代表中断处理程序执行的进程必须总是处于TASK_RUNING状态,或系统冻结
中,因此,中断处理程序不能执行阻塞程序,比如I/O硬盘操作。
Linux将中断要执行的操作分为三类:
- 关键中断
比如响应PIC控制器发送的中断,重新编程设置PIC或者设备控制器,更新设备和处理器访问的数据结构等。这些中断能够被快速执行且是关键数据,因为它们都必须被尽可能快的执行。在中断处理程序中立即执行这些关键操作,此时可屏蔽中断被禁止。 - 非关键中断
更新只有处理器访问的数据结构的中断请求(比如,读取键盘按键按下后的键码)。这类中断在中断处理程序中也能很快完成处理。 - 非关键可延时中断
比如拷贝缓存中的内容到进程的地址空间中的操作就是非关键可延时中断操作(比如,发送键盘的一行缓存到终端处理进程中)。这类操作完全可以延时一段时间执行,并不会影响内核操作。对于这类操作一般使用软中断和tasklet
机制完成。
I/O中断处理的基本步骤是:
- 保存IRQ值和内核态堆栈中寄存器值->恢复进程的时候使用。
- 给PIC控制器发送应答,告知正在响应IRQ请求线,允许继续发送中断。
- 执行中断服务例程(ISR)。
- 从中断返回(跳转到ret_from_intr()函数地址)。
为了响应中断处理,需要几个数据结构和函数去描述IRQ请求线的状态和要执行的函数功能。图4-4展示了处理中断的过程原理图。其中的函数,后面描述。
中断向量表
在表4-2中,我们列出了IRQ的分配,中断号对应32-238。另外,Linux使用中断号128实现系统调用。
表4-2 Linux中断向量表
中断线号 | 使用范围 |
0–19 | 不可屏蔽中断和异常 |
20–31 | 为Intel保留 |
32–127 | 外部中断 |
128 | 系统调用专用 |
129–238 | 外部中断 |
239 | APIC定时器中断 |
240 | APIC温度中断 |
241–250 | 保留 |
251–253 | CPU之间的中断 |
254 | APIC错误中断 |
255 | APIC伪中断 |
对于IRQ可配置的设备,有三种方法选择IRQ中断请求线:
- 通过跳线帽(一般旧计算机时代使用)。
- 通过设备的程序进行设置。用户可以选择可用的IRQ请求线或者自行探查系统可用的IRQ中断请求线。
- 在系统启动阶段,按照硬件协议进行申请,然后通过协商,尽可能减少冲突。完成分配后,每个中断处理程序通过函数读取访问I/O设备的IRQ中断请求线。比如,遵循PCI总线标准的设备,可以使用一组类似pci_read_config_byte()的函数读取设备的配置空间。
表4-3展示了一个分配设备和IRQ的示例:
IRQ | INT | Hardware device |
0 | 32 | 定时器 |
1 | 33 | 键盘 |
2 | 34 | PIC级联 |
3 | 35 | 第二个串行端口 |
4 | 36 | 第一个串行端口 |
6 | 38 | 软盘 |
8 | 40 | 系统时钟 |
10 | 42 | 网口 |
11 | 43 | USB端口,声卡 |
12 | 44 | PS/2鼠标 |
13 | 45 | 协处理器 |
14 | 46 | EIDE硬盘控制器的第一个链 |
15 | 47 | EIDE硬盘控制器的第二个链 |
也就是说,内核必须在使能中断之前,知道哪个I/O设备对应哪个IRQ号。然后在设备驱动初始化的时候才能对应上正确的中断处理程序。
IRQ相关数据结构
那么,IRQ数据结构是什么样子呢?下图展示了IRQ数据结构以及它们之间的关系。该图中没有展示软中断和tasklet
相关的数据结构和关系。因为我们后面会单独写文章对其进行阐述。
中断矢量表中的每一项都包含一个irq_desc_t
类型的描述符,它的成员如表4-4所示。所有的项都存储到irq_desc
数组中。
表4-4 irq_desc_t结构成员
成员 | 描述 |
handler | 指向PIC对象,响应PIC发送的中断请求 |
handler_data | handler需要的数据 |
action | 指向具体的中断服务例程 |
status | 表明IRQ请求线的状态 |
depth | IRQ线禁止使能标志 |
irq_count | 中断计数(诊断使用) |
irqs_unhandled | 未处理中断计数 |
lock | 自旋锁,保护该数据结构的访问 |
非预期中断,就是那些可能没有中断服务例程(ISR)或者中断服务例程和中断请求线不匹配的中断。内核对于这类中断是不作处理的。但是内核如何检测这类中断呢?又是如何禁止这类中断呢?因为中断号是共享的,所以,内核不会一检测到非预期中断就禁止它,而是对于总的中断请求次数和未处理的中断次数进行计数。当总的中断次数达到100000次,而未处理的中断是99900次时,内核就会禁止该中断。
表4-5 展示了中断请求线的状态标志
标志 | 描述 |
IRQ_INPROGRESS | IRQ的服务程序正在被执行 |
IRQ_DISABLED | IRQ线被禁止 |
IRQ_PENDING | IRQ被挂起 |
IRQ_REPLAY | IRQ被禁止,但是上一次还没有响应PIC |
IRQ_AUTODETECT | 自动检测IRQ |
IRQ_WAITING | 内核在执行硬件设备探测时使用IRQ线 ;而且,相应的中断还没有被触发 |
IRQ_LEVEL | X86架构未使用 |
IRQ_MASKED | 未使用 |
IRQ_PER_CPU | X86架构未使用 |
depth
和标志IRQ_DISABLED
表明IRQ线被使能还是禁止。每次调用disable_irq()
和disable_irq_nosync()
函数,depth
都会增加;如果depth
大于0,则函数禁止IRQ线并且设置IRQ_DISABLED
标志。相反,如果调用enable_irq()
函数,depth
会递减,如果depth
等于0,则使能IRQ线并且清除IRQ_DISABLED
标志。
系统启动时,调用init_IRQ()
函数设置IRQ描述符中的status
成员为IRQ_DISABLED
。与讲解异常处理一样,也会调用setup_idt()
类似的函数初始化IDT表,通过下面的代码段完成:
for (i = 0; i < NR_IRQS; i++) if (i+32 != 128) set_intr_gate(i+32,interrupt[i]);
这段代码的功能就是遍历interrupt
数组,查找各个中断处理程序的地址。需要注意的是,中断号128没有分配,留给系统调用作为异常使用。
除了8259A芯片之外,Linux还支持其它的PIC控制器,比如SMP IO-APIC
、Intel PIIX4内部的8259中断控制器
、SGI的Visual Workstation Cobalt (IO-)APIC
。为了统一处理这些硬件,Linux内核使用了面向对象的编程思想,构建了一个PIC对象,包含PIC名称和7个PIC标准方法。这种设计的优点是驱动程序无需关注系统中到底是什么中断控制器,硬件的差异被屏蔽掉了。这个PIC对象的数据结构类型称为hw_interrupt_type
。
我们更好理解,举一个具体的实例,假设计算机是单核,带有2个8259A中断控制器,提供16个标准的IRQ。那么irq_desc_t
类型的描述符中的handler
指向hw_interrupt_type
类型的结构对象i8259A_irq_type
,其成员如下所示:
struct hw_interrupt_type i8259A_irq_type = { .typename = "XT-PIC", /* PIC名称 */ .startup = startup_8259A_irq, .shutdown = shutdown_8259A_irq, .enable = enable_8259A_irq, .disable = disable_8259A_irq, .ack = mask_and_ack_8259A, .end = end_8259A_irq, .set_affinity = NULL };
"XT-PIC"
,中断控制器名称。startup
和shutdown
分别表示启动和关闭IRQ线,但是对于8259A来说,这两个函数与enable
和disable
两个函数相同。 mask_and_ack_8259A()
应答中断控制器,end_8259A_irq()
函数在中断处理程序结束时调用。set_affinity
方法设为NULL
, 这个方法是为多核系统设计的,用来声明CPU的亲和力affinity
,也就是说为某个IRQ指定在哪个CPU上处理。
我们知道,多个设备可以共享一个IRQ。因此,内核必须为每个设备及其对应的中断维护一个数据结构,称为irqaction
描述符。它的成员如下表所示:
表4-6 irqaction
描述符的各个成员
成员 | 描述 |
handler | 中断服务例程(ISR) |
flags | 描述IRQ和设备之间的关系 |
mask | 未使用 |
name | I/O设备的名称 |
dev_id | 指向设备本身 |
next | 指向下一个irqaction |
irq | IRQ线 |
dir | 指向目录/proc/irq/n |
表4-7 irqaction
的标志位
成员 | 描述 |
SA_INTERRUPT | 执行中断处理程序时必须禁止中断 |
SA_SHIRQ | 允许共享IRQ |
SA_SAMPLE_RANDOM | 可以被当做随机数发生器 |
init_IRQ()
的代码实现随着硬件架构的发展,以及内核的不断优化升级,会不断变化,且变得越来越复杂。但是,万变不离其宗,核心的设计思想没变。
多核系统中的IRQ分配
我们知道SMP的全称是对称多处理系统,这意味,Linux内核不应该对一个CPU有任何偏向。于是,内核在CPU之间采用循环法(round-robin)分配IRQ。因此,所有的CPU响应中断的时间都差不多。
之前我们已经了解过,多APIC系统的分配IRQ机制非常复杂。
在系统引导阶段,负责引导的CPU执行setup_IO_APIC_irqs()
函数初始化I/O-APIC
芯片。也就是初始化其中断重定向表(24项),然后所有来自I/O设备的IRQ就可以被中继到各个CPU上,分配原则是最低优先级优先
原则。在此期间,所有的CPU执行setup_local_APIC()
函数,初始化自身的APIC控制器。当然也可以将中断控制器中的TPR(任务优先级寄存器)写入相同值,从而公平地对待每个CPU,按照循环的方式分配IRQ。一旦初始化完成,内核就不能再修改这个值了。至于实现循环,前面我们讲过了,请参考之前的文章。
简而言之,设备发出IRQ信号,多APIC系统选择一个CPU,并把中断信号发送给响应的私有APIC,继而,私有APIC中断CPU。
虽说初始化之后,内核本不应该在关心IRQ分配问题。但是不幸的是,有时候硬件在分配中断时会发生错误(比如,基于奔腾4的SMP主板就有这样的问题)。因此,Linux2.6内核使用一个特定的内核线程叫kirqd
进行纠正IRQ的自动分配(如果有必要的话)。
内核线程使用多APIC系统一个很棒的功能,叫做CPU的IRQ亲和力:通过修改I/O-APIC
的中断重定向表,将中断信号指定到新的CPU上。具体操作就是调用set_ioapic_affinity_irq()
函数,它需要两个参数:需要重定向的IRQ矢量表和一个32位的掩码(用来表示接收IRQ的CPU)。系统管理员也可以通过写新的CPU位掩码到/proc/irq/n/smp_affinity
文件中,修改响应中断的CPU。
kirqd
内核线程周期性地执行do_irq_balance()
函数,追踪最近一段时间内,每个CPU上接收到的中断次数。如果发现CPU的中断负载不均衡了,它就会选择将某个IRQ移到另一个负载低的CPU上,或者采用在所有的CPU上循环响应IRQ。
内核态堆栈
在学习标识进程的时候,我们已经知道每个进程的thread_info
描述符和内核态堆栈使用一个联合体结构组合在一起,占用内存一个或者两个页帧,这取决于编译内核时的配置。如果这个联合体的大小是8KB,内核态堆栈可以被任何一种内核控制路径使用:异常处理程序,中断处理程序和可延时函数。相反,如果这个联合体的大小是4KB,内核使用三种类型的内核态堆栈:
- 异常堆栈
处理异常时使用,包含系统调用。每个进程都有一个异常处理使用的堆栈。 - 硬IRQ堆栈
用于处理中断。每个CPU具有一个硬IRQ堆栈。 - 软IRQ堆栈
处理可延时函数时使用。比如,软中断或tasklet。每个CPU都有一个软IRQ堆栈。
软、硬IRQ堆栈分别使用hardirq_stack
和softirq_stack
两个数组存储。每个数组元素对应一个irq_ctx
类型的联合体,占用一个页帧。该页帧的底部存储thread_info
结构,其余的内存存储堆栈;因为堆栈的增长方向是递减的。因此软、硬IRQ堆栈与进程的堆栈非常相似,只是thread_info
不同,一个是描述CPU,而另一个是描述进程。
为中断服务程序保存寄存器
我们已经知道,当CPU收到中断,它就会执行IDT表中对应的中断处理程序。
执行中断处理程序,意味着上下文切换。这部分的内容需要汇编语言编写,然后才能调用C函数。前面我们已经知道,中断处理程序的地址首先存储在interrupt[]
数组中,然后才会被拷贝到IDT表中的某项对应的中断门。
中断数组的构建在arch/i386/kernel/entry.S
文件中,都是汇编指令。数组的个数是NR_IRQS
,如果内核支持I/O-APIC
芯片,则NR_IRQS
等于224,如果内核支持的是较旧的8259A中断控制器,则NR_IRQS
等于16。数组的每一项包含的汇编函数的地址处的内容如下所示:
pushl $n-256 jmp common_interrupt
存储在堆栈上的IRQ号是中断减去256。也就是说,内核使用负数表示IRQ号,因为内核保留正数表示系统调用。对于通用中断代码,如下所示:
common_interrupt: SAVE_ALL movl %esp,%eax call do_IRQ jmp ret_from_intr
宏SAVE_ALL
展开如下所示:
cld push %es push %ds pushl %eax pushl %ebp pushl %edi pushl %esi pushl %edx pushl %ecx pushl %ebx movl $__USER_DS,%edx movl %edx,%ds movl %edx,%es
SAVE_ALL
保存中断处理程序可能用到的所有的CPU寄存器到堆栈上,除了eflags、cs、eip、ss和esp这些寄存器之外,因为这些寄存器是由CPU控制单元自动保存的。该宏用户代码段的选择符到ds寄存器中。
保存完所有的寄存器之后,栈顶位置就被存储在eax寄存器中;然后中断处理程序调用do_IRQ()
函数。
do_IRQ()函数
函数do_IRQ()
执行和中断有关的所有的服务例程,声明如下:
__attribute__((regparm(3))) unsigned int do_IRQ(struct pt_regs *regs)
关键字regparm
指示函数去eax寄存器中获取参数regs
的值,如前所述,eax寄存器存储着中断使用的堆栈的栈顶位置。
函数do_IRQ()
主要执行以下内容:
- 执行
irq_enter()
宏,增加嵌套中断计数; - 如果堆栈的大小等于4KB,切换到硬IRQ堆栈;
- 调用
__do_IRQ()
函数,然后把regs指针和IRQ号(regs->orig_eax)传递给它; - 如果在第2步切换到硬IRQ堆栈中,则拷贝ebx寄存器中的原始堆栈指针到esp寄存器中,以便切换回之前使用的异常堆栈或软IRQ堆栈中;
- 执行
irq_exit()
宏,减少中断计数,检查是否有可延时处理的函数正在等待处理; - 终止:跳转到
ret_from_intr()
函数地址。
4.6.1.7 __do_IRQ()函数
__do_IRQ()
函数接收IRQ号和指向pt_regs
的指针作为参数,分别是通过eax和edx寄存器传递。然后,对中断作出应有的响应,代码片段如下所示:
spin_lock(&(irq_desc[irq].lock)); irq_desc[irq].handler->ack(irq); irq_desc[irq].status &= ~(IRQ_REPLAY | IRQ_WAITING); irq_desc[irq].status |= IRQ_PENDING; if (!(irq_desc[irq].status & (IRQ_DISABLED | IRQ_INPROGRESS)) && irq_desc[irq].action) { irq_desc[irq].status |= IRQ_INPROGRESS; do { irq_desc[irq].status &= ~IRQ_PENDING; spin_unlock(&(irq_desc[irq].lock)); handle_IRQ_event(irq, regs, irq_desc[irq].action); spin_lock(&(irq_desc[irq].lock)); } while (irq_desc[irq].status & IRQ_PENDING); irq_desc[irq].status &= ~IRQ_INPROGRESS; } irq_desc[irq].handler->end(irq); spin_unlock(&(irq_desc[irq].lock));
上面的代码主要执行内容如下所示:
- 加锁,保护IRQ描述符数据结构
通过上面的代码,我们可以看出,在访问相应的IRQ描述符时,内核会请求自旋锁。这是防止不同CPU之间可能造成的并发访问。因为,在多核系统中,可能会发生同类型的其它CPU关心的中断,它们使用同一个IRQ描述符,所以造成访问冲突。 - 响应PIC中断控制器
加锁之后,函数调用IRQ描述符的ack
方法,给中断控制器应答。如果使用的是旧的8259A中断控制器,使用mask_and_ack_8259A()响应PIC同时禁止IRQ线;屏蔽掉该IRQ线,保证CPU不再接收到这个类型的中断,直到中断处理程序完成处理。如果使用的是I/O-APIC
,情况更为复杂。依赖于中断的类型,既可以使用ack
方法响应PIC控制器也可以延时到中断处理程序结束再完成。 - 设置IRQ描述符的标志
设置IRQ_PENDING
标志,因为此时已经应答过PIC中断控制器,但是还没有对其进行服务。也会清除IRQ_WAITING
和IRQ_REPLAY
标志。 - 真正执行中断处理。
此时,可能有三种意外情况需要处理:
假设没有上面的三种情况,中断被正式处理。设置IRQ_INPROGRESS
标志,并启动循环处理。每次迭代过程,清除IRQ_PENDING
标志,释放中断自旋锁,然后执行调用handle_IRQ_event()
执行中断服务程序。
- 设置了
IRQ_DISABLED
即使IRQ线被禁止,CPU还是有可能执行__do_IRQ()
函数,所以需要特殊处理。 - 设置了
IRQ_INPROGRESS
多核系统中,此时可能另外一个CPU可能正在处理先前发生的相同中断。Linux对此的处理方式就是延后处理。这样的处理方式使内核架构更为简单,因为设备驱动程序的中断服务程序是不需要可重入的(它们的执行一般都是序列化的)。 irq_desc[irq].action
为空
当没有与中断相关联的中断服务例程时,就会发生这种情况。通常,只有在内核探测硬件设备时才会发生这种情况。
- 中断服务程序完成。
释放自旋锁。
总结
其实内核经过这么多年的发展,在实现方式上已经发生了很大变化。但是其基本思想没变。比如我们以Linux4.4.203内核对于中断的处理为例,与上面的处理过程进行比较,理解其主要变化。
- 调用do_IRQ函数。其入口位于entry_32.S文件中,是C语言实现的。
common_interrupt: ASM_CLAC addl $-0x80, (%esp) /* Adjust vector into the [-256, -1] range */ SAVE_ALL TRACE_IRQS_OFF movl %esp, %eax call do_IRQ jmp ret_from_intr ENDPROC(common_interrupt)
- do_IRQ函数原型为:
/* + do_IRQ处理所有常规设备的IRQ。 + 特殊的SMP系统中,CPU间的中断有自己特定的处理程序。 */ __visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs) { struct pt_regs *old_regs = set_irq_regs(regs); struct irq_desc * desc; /* high bit used in ret_from_ code */ unsigned vector = ~regs->orig_ax; entering_irq(); /* 进入中断,并对中断进行嵌套计数 */ /* entering_irq() tells RCU that we're not quiescent. Check it. */ RCU_LOCKDEP_WARN(!rcu_is_watching(), "IRQ failed to wake up RCU"); desc = __this_cpu_read(vector_irq[vector]); /* 取中段描述符 */ if (!handle_irq(desc, regs)) { /* handle_irq处理具体的中断服务程序 */ ack_APIC_irq(); if (desc != VECTOR_RETRIGGERED) { pr_emerg_ratelimited("%s: %d.%d No irq handler for vector\n", __func__, smp_processor_id(), vector); } else { __this_cpu_write(vector_irq[vector], VECTOR_UNUSED); } } exiting_irq(); /* 退出中断,并对中断进行嵌套计数递减 */ set_irq_regs(old_regs); return 1; }
handle_irq
函数最终调用的是下面的函数:
static inline void generic_handle_irq_desc(struct irq_desc *desc) { desc->handle_irq(desc); }
- 而我们之间已经说过
desc->handle_irq
的初始化在系统初始化时完成:
//linux-2.6.32/arch/x86/kernel/irqinit.c void __init init_IRQ(void) { x86_init.irqs.intr_init(); } //linux-2.6.32/arch/x86/kernel/x86_init.c struct x86_init_ops x86_init __initdata = { ...... .irqs = { .pre_vector_init = init_ISA_irqs, //被.intr_init调用 .intr_init = native_init_IRQ, .trap_init = x86_init_noop, }, ...... } //linux-2.6.32/arch/x86/kernel/irqinit.c void __init native_init_IRQ(void) { ...... /* Execute any quirks before the call gates are initialised: */ x86_init.irqs.pre_vector_init(); //init_ISA_irqs ...... } void __init init_ISA_irqs(void) { ...... for (i = 0; i < NR_IRQS_LEGACY; i++) { ...... set_irq_chip_and_handler_name(i, &i8259A_chip, handle_level_irq, "XT"); } } void set_irq_chip_and_handler_name(unsigned int irq, struct irq_chip *chip, irq_flow_handler_t handle, const char *name) { set_irq_chip(irq, chip); __set_irq_handler(irq, handle, 0, name); } void __set_irq_handler(unsigned int irq, irq_flow_handler_t handle, int is_chained,const char *name) { .... desc->handle_irq = handle;//handle 即为handle_level_irq .... }
- 可见
desc->handle_irq(irq, desc);
执行的是handle_level_irq(irq, desc)
。我们进入handle_level_irq(irq, desc)
看看都做了哪些操作:
void handle_level_irq(unsigned int irq, struct irq_desc *desc) { mask_ack_irq(desc, irq); //屏蔽中断 ...... action = desc->action; action_ret = handle_IRQ_event(irq, action); ...... desc->chip->unmask(irq); //打开中断 } irqreturn_t handle_IRQ_event(unsigned int irq, struct irqaction *action) { ...... do { ret = action->handler(irq, action->dev_id);//指向我们注册的中断处理函数 ...... } while (action); ..... }
通过上面5步分析,我们知道,内核代码以及硬件设备在发生变化,但是中断处理的核心思想没有变。