Linux内核21-Linux内核的中断处理过程

2022-08-15 15:53:41 浏览数 (1)

中断处理

如前所述,我们知道异常的处理还是比较简单的,就是给相关的进程发送信号,而且不存在进程调度的问题,所以内核很快就处理完了异常。

但是,这种方法不适用于中断,因为当一个不相关的进程正在运行的时候,发送给特定进程的中断信号会被挂起,等到该进程执行的时候才会处理。所以,给中断发送一个信号没有太大意义。

另外,中断的处理与中断类型息息相关。所以,我们将中断分为3类:

  1. I/O中断
  2. 定时器中断
  3. 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中断处理的基本步骤是:

  1. 保存IRQ值和内核态堆栈中寄存器值->恢复进程的时候使用。
  2. 给PIC控制器发送应答,告知正在响应IRQ请求线,允许继续发送中断。
  3. 执行中断服务例程(ISR)。
  4. 从中断返回(跳转到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表,通过下面的代码段完成:

代码语言:javascript复制
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-APICIntel 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,其成员如下所示:

代码语言:javascript复制
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",中断控制器名称。startupshutdown分别表示启动和关闭IRQ线,但是对于8259A来说,这两个函数与enabledisable两个函数相同。 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_stacksoftirq_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。数组的每一项包含的汇编函数的地址处的内容如下所示:

代码语言:javascript复制
pushl $n-256
jmp common_interrupt

存储在堆栈上的IRQ号是中断减去256。也就是说,内核使用负数表示IRQ号,因为内核保留正数表示系统调用。对于通用中断代码,如下所示:

代码语言:javascript复制
common_interrupt:
    SAVE_ALL
    movl %esp,�x
    call do_IRQ
    jmp ret_from_intr

SAVE_ALL展开如下所示:

代码语言:javascript复制
cld
push %es
push %ds
pushl �x
pushl �p
pushl �i
pushl %esi
pushl �x
pushl �x
pushl �x
movl $__USER_DS,�x
movl �x,%ds
movl �x,%es

SAVE_ALL保存中断处理程序可能用到的所有的CPU寄存器到堆栈上,除了eflags、cs、eip、ss和esp这些寄存器之外,因为这些寄存器是由CPU控制单元自动保存的。该宏用户代码段的选择符到ds寄存器中。

保存完所有的寄存器之后,栈顶位置就被存储在eax寄存器中;然后中断处理程序调用do_IRQ()函数。

do_IRQ()函数

函数do_IRQ()执行和中断有关的所有的服务例程,声明如下:

代码语言:javascript复制
__attribute__((regparm(3))) unsigned int do_IRQ(struct pt_regs *regs)

关键字regparm指示函数去eax寄存器中获取参数regs的值,如前所述,eax寄存器存储着中断使用的堆栈的栈顶位置。

函数do_IRQ()主要执行以下内容:

  1. 执行irq_enter()宏,增加嵌套中断计数;
  2. 如果堆栈的大小等于4KB,切换到硬IRQ堆栈;
  3. 调用__do_IRQ()函数,然后把regs指针和IRQ号(regs->orig_eax)传递给它;
  4. 如果在第2步切换到硬IRQ堆栈中,则拷贝ebx寄存器中的原始堆栈指针到esp寄存器中,以便切换回之前使用的异常堆栈或软IRQ堆栈中;
  5. 执行irq_exit()宏,减少中断计数,检查是否有可延时处理的函数正在等待处理;
  6. 终止:跳转到ret_from_intr()函数地址。

4.6.1.7 __do_IRQ()函数

__do_IRQ()函数接收IRQ号和指向pt_regs的指针作为参数,分别是通过eax和edx寄存器传递。然后,对中断作出应有的响应,代码片段如下所示:

代码语言:javascript复制
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));

上面的代码主要执行内容如下所示:

  1. 加锁,保护IRQ描述符数据结构 通过上面的代码,我们可以看出,在访问相应的IRQ描述符时,内核会请求自旋锁。这是防止不同CPU之间可能造成的并发访问。因为,在多核系统中,可能会发生同类型的其它CPU关心的中断,它们使用同一个IRQ描述符,所以造成访问冲突。
  2. 响应PIC中断控制器 加锁之后,函数调用IRQ描述符的ack方法,给中断控制器应答。如果使用的是旧的8259A中断控制器,使用mask_and_ack_8259A()响应PIC同时禁止IRQ线;屏蔽掉该IRQ线,保证CPU不再接收到这个类型的中断,直到中断处理程序完成处理。如果使用的是I/O-APIC,情况更为复杂。依赖于中断的类型,既可以使用ack方法响应PIC控制器也可以延时到中断处理程序结束再完成。
  3. 设置IRQ描述符的标志 设置IRQ_PENDING标志,因为此时已经应答过PIC中断控制器,但是还没有对其进行服务。也会清除IRQ_WAITINGIRQ_REPLAY标志。
  4. 真正执行中断处理。 此时,可能有三种意外情况需要处理: 假设没有上面的三种情况,中断被正式处理。设置IRQ_INPROGRESS标志,并启动循环处理。每次迭代过程,清除IRQ_PENDING标志,释放中断自旋锁,然后执行调用handle_IRQ_event()执行中断服务程序。
    1. 设置了IRQ_DISABLED 即使IRQ线被禁止,CPU还是有可能执行__do_IRQ()函数,所以需要特殊处理。
    2. 设置了IRQ_INPROGRESS 多核系统中,此时可能另外一个CPU可能正在处理先前发生的相同中断。Linux对此的处理方式就是延后处理。这样的处理方式使内核架构更为简单,因为设备驱动程序的中断服务程序是不需要可重入的(它们的执行一般都是序列化的)。
    3. irq_desc[irq].action为空 当没有与中断相关联的中断服务例程时,就会发生这种情况。通常,只有在内核探测硬件设备时才会发生这种情况。
  5. 中断服务程序完成。 释放自旋锁。

总结

其实内核经过这么多年的发展,在实现方式上已经发生了很大变化。但是其基本思想没变。比如我们以Linux4.4.203内核对于中断的处理为例,与上面的处理过程进行比较,理解其主要变化。

调用do_IRQ函数。其入口位于entry_32.S文件中,是C语言实现的。

do_IRQ函数原型为:

handle_irq函数最终调用的是下面的函数:

而我们之间已经说过desc->handle_irq的初始化在系统初始化时完成:

可见desc->handle_irq(irq, desc);执行的是handle_level_irq(irq, desc)。我们进入handle_level_irq(irq, desc)看看都做了哪些操作:

通过上面5步分析,我们知道,内核代码以及硬件设备在发生变化,但是中断处理的核心思想没有变。

0 人点赞