深入理解 Linux 内核---中断和异常

版权声明:本文为博主原创文章,转载请注明出处。 https://blog.csdn.net/u012319493/article/details/83315423

中断或异常会改变处理器执行的指令顺序。

异常:

  • 来源:CPU 控制单元,
  • 时机:只有在一条指令终止执行后 CPU 才会发出中断。
  • 原因:程序产生错误,或内核必须处理的异常条件。

中断:

  • 来源:间隔定时器或 I/O 设备。
  • 时机:随机产生。
  • 原因:依照 CPU 时钟信号。

中断信号的作用

为什么要引入中断信号?因为中断信可使得处理器转而去运行正常控制流之外的代码。

当中断信号到来时,CPU 需进行切换。在内核态堆栈保存程序计数器的当前值(eip、cs),并把中断类型相关的地址放入程序计数器(eip、cs)。

中断处理与进程切换的明显差异:中断或异常处理程序执行的代码不是进程,而是一个内核控制路径,比进程“轻”(中断的上下文很少,建立或终止中断处理所需的时间很少)。

中断处理满足如下约束:

  • 为了尽快处理完中断,需尽量推迟更多的处理。
  • 允许中断嵌套。
  • 在内核代码的某些临界区,中断处理程序以关中断方式运行,但这种情况尽量少。

中断和异常

中断类型:

  • 可屏蔽中断
  • 非屏蔽中断

异常类型:

  • 处理器探测异常
  • 故障,eip 保存引起故障的指令地址。
  • 陷阱,eip 保存随后要执行的指令的地址。
  • 终止,eip 不保存值。
  • 编程异常,软中断,2种用途:执行系统调用;向调试程序通报一个特定事件。

每个中断和异常由 0~255 之间的数标识。Intel 将该 8 位无符号整数称为向量。非屏蔽中断和异常的向量固定,可屏蔽中断的向量可通过对中断控制器的编程改变。

IRQ 和中断

每个能发出中断请求的硬件设备都有一条 IRQ(Interrupt ReQuest) 输出线,IRQ 线与 PIC(Programmable Interrpt Controuer, 可编程中断控制器)的输入引脚相连。PIC 执行下列动作:

  • 监视 IRQ 线是否产生信号。如有多条 IRQ 线产生信号,选择引脚编号较小的。
  • 如果监视到信号:
    • 将信号转换为对应的向量。
    • 将向量放入 PIC 的一个 I/O 端口,CPU 便可通过数据总线读该向量。
    • 将信号送到处理器的 INTR 引脚,引发中断。
    • 等待,直到 CPU 确认该信号(通过将其写入 PIC 的 I/O 端口);清除 INTR 线。
  • 返回第 1 步。

第一条 IRQ 通常为 IRQ0,与 IRQn 关联的 Intel 的缺省向量是 n+32,PIC 可修改 IRQ 和向量之间的映射。

可对 PIC 编程从而禁止 IRQ,禁止的中断不会丢失,一旦被激活,PCI 又把它们发送到 CPU,这允许中断处理程序逐次处理同一类型的 IRQ。

eflags 寄存器的 IF 标志被清 0 时,PCI 发布的可屏蔽中断会被 CPU 暂时忽略。cli 和 sti 分别清除、设置 IF 标志。

高级 PIC(Advanced PIC,APIC)

当系统包含多个 CPU 时,需要能把中断传递给每个 CPU。因此 APIC 取代了 PIC。

每个 CPU 都有一个本地 APIC,每个本地 APIC 有 32 位的寄存器、一个内部时钟、一个本地定时设备、额外的两条 IRQ 线 LINT0 和 LINT1(为本地 APIC 中断保留的)。

本地 APIC 连接到一个外部 IO APIC,形成一个多 APIC 系统。

在这里插入图片描述

I/O APIC 与 IRQ 引脚不同,中断优先级不与引脚关联,中断重定向表的每一项可被单独指明中断向量和优先级。

来自外部硬件的中断请求在可用 CPU 之间的分发方式:

  • 静态分发。中断会传递给一个特定的 CPU,或一组 CPU,或所有 CPU。
  • 动态分发。中断会传递给当前运行最低优先级进程的 CPU,如果有多个 CPU 满足,则使用仲裁技术分配。

多 APIC 系统还允许 CPU 产生处理器间中断(InterProcessor Interrupt,IPI)。

异常

内核必须为每种异常提供一个专门的异常处理程序。对于某些异常,CPU 控制单元在执行异常处理程序前会产生一个硬件出错码,并压入内核堆栈。

中断描述符表(Interrupt Descriptor Table,IDT)

每一项对应一个中断或异常向量,每个向量由 8 个字节组成。

IDT 中有中断或异常处理程序的入口地址。

idtr CPU 寄存器指定 IDT 的线性基址及最大长度。lidt 汇编指令初始化 idtr。

IDT 包含三种类型的描述符:

  • 任务门。中断信号发生时,存放新进程的 TSS 选择符。
  • 中断门。处理中断。处理器会清 IF 标志,从而关闭可能会发生的可屏蔽中断。
  • 陷阱门。处理异常。不修改 IF 标志。

Linux 相对于 Intel 多了系统门、系统中断门。

中断和异常的硬件处理

假设内核已被初始化,CPU 在保护模式下运行。

在处理下一条指令时,控制单元会检查在运行前一条指令时是否发生了一个中断或异常,如果发生,控制单元执行下列操作:

  • 确定中断或异常关联的向量 i(0~255)。
  • 读 idtr 寄存器指向的 IDT 表的第 i 项(假定包含一个中断门或陷阱门)。
  • 从 gdtr 获得 GDT 的基地址,在 GDT 中查找 IDT 表第 i 项中选择符标识的段描述符。该描述符指定中断或异常处理程序所在段的基地址。
  • 确信中断是由授权的中断发生源发出的。如果 CPL(cd 寄存器的低两位)> 段描述符(GDT 中)的描述符特权级,则产生异常,因为说明引起中断的处理程序的特权>中断处理程序的特权 。对于编程异常,还需比较 CPL 与 IDT 中的门描述符 DPL,大于则产生异常,可避免用户程序访问特殊的陷阱门或中断门。
  • 检查是否发生特权级的变化,即 CPU 不等于当前段描述符的 DPL。如果是,控制单元必须使用与新特权级相关的栈。
    – 读 tr 寄存器,访问运行进程的 TSS 段。
    – 将 TSS 中新特权级相关的栈段、栈指针装载 ss、esp 寄存器。
    – 新的栈中保存 ss、esp 以前的值。
  • 如果产生故障,用引起异常的指令的地址装载 cs 和 eip 寄存器。
  • 将 eflags、cs 及 eip 的内容保存到栈中。
  • 如果异常产生了一个硬件出错码,保存到栈中。
  • 用 IDT 表中第 i 项门描述符的段选择符和偏移量字段装载 cs 和 eip 寄存器,为中断或异常处理程序的第一条指令的逻辑地址。

总结:确定异常、中断向量;权限、特权检查;针对不同类型的异常、中断,保存不同的内容;将从异常、中断向量得到的中断或异常处理程序地址装入 cs、eip 寄存器。

中断或异常处理完后,处理程序产生 iret 指令,控制权交给被中断的进程,迫使控制单元:

  • 用保存在栈中的值装载 cs、eip 或 eflags 寄存器。如果一个硬件码被压入栈,并在 eip 上方,执行 iret 前弹出。
  • 检查处理程序的 CPL 是否等于 cs 中低两位,如果是,则 iret 终止;否则,转入下一步。
  • 返回到与就特权级相关的栈,用栈中内容装载 ss 和 esp 寄存器。
  • 检查 ds、es、fs 及 gs 段寄存器的内容,如果其中一个包含的选择符是段描述符,且其 DPL 小于 CPL,清相应的段寄存器,可禁止用户态程序(CPL=3)利用以前所用的段寄存器(DPL=0)。

总结:弹出保存在栈中的内容;根据特权级变化决定是否返回栈;清相关段寄存器,防止用户恶意访问内核空间。

中断和异常处理程序的嵌套执行

在这里插入图片描述

内核控制路径嵌套必须付出代价,那就是中断处理程序运行期间不能发生进程切换。因为嵌套的内核控制路径恢复执行时需要的所有数据都存在内核态堆栈中,而该堆栈属于当前进程。

大多数异常在 CPU 处于用户态时发生,发生在内核态的唯一异常是缺页异常。缺页异常不会进一步引起异常,所以至多两个内核控制路径堆叠。

与异常不同,I/O 设备产生的中断不引用当前进程的专有数据数据结构,因为中断发生时,无法预测哪个进程将会运行。

中断处理程序可抢占中断处理程序、异常处理程序;而异常处理程序只能抢占异常处理程序。中断处理程序从不执行可导致缺页的操作。

Linux 交错执行内核控制路径的原因:

  • 提供 PIC 和设备控制器的吞吐量。
  • 实现一组没有优先级的中断模型。多 CPU 系统中,内核控制路径可并发执行。异常先在一个 CPU 上执行,然后由于进程切换移到另一个 CPU。

初始化中断描述符表

内核启用中断前,必须把 IDT 表的初始地址装到 idtr 寄存器,并初始化表中的每一项。IDT 的初始化必须小心,中断门或陷阱门描述符的 DPL 字段需设置成 0。

少数情况下,用户态进程必须能发出一个编程异常,执行把中断或陷阱门描述符的 DPL 字段设置成 3。

实模式中,IDT 被初始化并被 BIOS 使用。一旦 Linux 接管,IDT 被移到 RAM 的另一个区域,进行二次初始化,因为 Linux 不使用 BIOS。

IDT 放在 idt_table 表中,共 256 个表项。

6 字节的 idt_descr 变量指定了 IDT 的大小和地址。

内核初始化过程中,setup_idt() 汇编语言函数用同一个中断门(ignore_int())填充所有 idt_table 表项。

setup_idt:
	lea ignore_int, %edx
	movl $(_ _ KERNEL_CS << 16), %eax   // cs
	movw %dx, %ax                       // 中断门,dpl = 0
	lea idt_table, %edi   
	mov $256, %ecx                      // 循环次数
rp_sidt:                                // 循环 256 次
	movl %eax, (%edi)
	movl %edx, 4(%edi)
	addl $8, %edi
	dec %ecx
	jne rp_sidt
	ret

预初始化后,内核将在 IDT 中第二次初始化,用有意义的陷阱和中断处理程序代替空处理程序。

异常处理

异常发生时,内核向引起异常的进程发送一个信号,向它通知一个反常条件,该进程采取必要步骤来恢复或终止运行。

Linux 利用 CPU 异常有效管理硬件资源的两种情况:

  • 保存和加载 FPU、MMX 和 XMM 寄存器。
  • 缺页异常。

异常处理标准结构:

  • 将大多数寄存器的内容保存在内核堆栈(汇编)。
  • C 函数处理异常。
  • ret_from_exception() 从异常处理程序中返回。

trap_init() 将一些处理异常的函数插入到 IDT 的非屏蔽中断及异常表项中。

“Double fault”异常表示内核有严重的非法操作,所以其处理是通过任务门而不是陷阱门或系统门完成的。

保存寄存器的值

handler_name:                // 代指异常处理程序的名字
	pushl $0                 // 异常发生时,如果控制单元没有自动地把一个硬件出错码压栈,则执行该语句
	pushl $do_handler_name   // 将 C 函数的地址压栈
	jmp error_code           // 对所有的异常处理程序都相同,除了“Device not availabler”异常

error_code:
	将 C 函数可能用到的寄存器压栈
	cld 清 elfags 的方向标志 DF,使得 edi 和 esi 寄存器的值自动增加
	将栈中 esp+36 处的硬件出错码拷贝到 edx,将栈中该位置置 -1,以将 0x80 异常与其他异常隔离开
	将栈中 esp+32 处的 do_handler_name() 的地址装入 edi 寄存器,将 es 中内容写入栈中 esp+32 处
	将内核栈的栈顶拷贝到 eax 寄存器。该地址中存放第 1 步保存的最后一个寄存器的值
	把用户数据段的选择符拷贝到 ds 和 es 寄存器
	调用 edi 中的 C 函数
	addl $8 %esp
	jmp ret_from_exception  // 离开异常处理程序

异常处理程序 C 函数

异常处理程序的 C 函数名的前缀为 do_,一般可描述为:

current->thread.error_code = error_code;  // 把硬件出错码保存在当前进程的描述符中
current->thread.trap_no = vector;         // 把异常向量保存在当前进程的描述符中
force_sig(sig_number, current);           // 向当前进程发送一个信号

异常处理程序一终止,当前进程就关注该信号。信号可能在用户态由进程自己的信号处理程序处理,也可能由内核处理(一般杀死该进程)。

异常处理程序会检查异常发生在用户态还是内核态。如果是内核态,检查是否由系统的无效参数引起,如果是,调用 die() 函数打印所有 CPU 寄存器的内容,并调用 do_exit() 终止当前进程。

从异常处理程序返回

addl $8 %esp
jmp ret_from_exception

中断处理

内核只要给引起异常的进程发送一个信号就能处理大多数异常,但不适用于中断。

中断的处理依赖于中断类型:

  • I/O 中断,相应的中断处理程序必须查询设备以决定如何处理。
  • 时钟中断,大部分作为 I/O 中断处理。
  • 处理器间中断。

IO 中断处理

PCI 总线的体系结构中,几个设备可共享同一个 IRQ 线,所以不能仅靠中断向量确定中断源。

中断处理程序有两种实现:

  • IRQ 共享。中断处理程序执行多个中断服务例程(ISR),每个 ISR 与单独的设备相关。产生 IRQ 时,每个 ISR 都被执行。
  • IRQ 动态分配。一条 IRQ 线在需要的时候才与一个设备关联。

Linux 把紧随中断执行的操作分为三类:

  • 紧急的。在关中断的情况下尽快执行。
  • 非紧急的。在开中断的情况下尽快执行。
  • 非紧急可延迟的。由独立的函数执行,通过“软中断及 tasklet”方式执行。

所有的 I/O 中断处理程序执行 4 个相同的基本操作:

  • 将 IRQ 值和寄存器内容压入内核态堆栈。
  • 为正给 IRQ 线服务的 PCI 发送应答,允许 PCI 进一步发出中断。
  • 执行共享该 IRQ 的所有 ISR。
  • 跳到 ret_from_intr() 后终止。

在这里插入图片描述

中断向量

物理 IRQ 可分配给 32~238 范围内的任何向量。128 用于系统调用。

IBM PC 兼容的体系结构要求,一些设备必须被静态地连接到指定的 IRQ 线。

为 IRQ 可配置设备选择一条线的三种方式:

  • 设置硬件跳接器(仅适用于旧式设备卡)。
  • 安装设备时执行一个程序,可让用户选择 IRQ 号。
  • 系统启动时启动一个硬件协议。

内核必须在启用中断前发现 IRQ 号与 I/O 设备之间的对应,该对应时在初始化每个设备驱动程序时建立。

IRQ 数据结构
在这里插入图片描述

每个中断向量都有自己的 irq_desc_t 描述符,存放于 irq_desc 数组。

irq_desc_t 描述符:

  • handler,指向 PIC 对象(hw_irq_controller 描述符)。面向对象的表达方式。
  • handler_data,指向 PIC 方法使用的数据。
  • action,要调用的中断服务例程。指向 IRQ 的 irqaction 描述符链表的第一个元素。
  • status,IRQ 线状态标志。系统初始化期间,init_IRQ() 设置为 IRQ_DISABLED,然后调用 setup_idt() 建立中断门。
  • depth,与 status=IRQ_DISABLED 表示 IRQ 线是否被禁用。
  • irq_count,中断的次数
  • irqs_unhandled,意外中断的次数,超过某值时,内核禁用该条 IRQ 线。
  • lock,用于串行访问 IRQ 描述符和 PIC 的自旋锁。

irq_desc_t 描述符中的 handler 字段:

 // hw_interrupt_type 也叫 hw_irq_controller 描述符。假设使用 8259A 芯片。
struct hw_interrupt_type i8259A_irq_type = {   
	.typename = "XT-PIC",           // PCI 的名字

	// 用于对 PCI 编程的 6 个函数指针
	.startup = startup_8259A_irq,   // 启动芯片的 IRQ 线
	.shutdown = shutdown_8259A_irq, // 关闭芯片的 IRQ 线
	.enable = enable_8259A_irq,     // 启用 IRQ 线
	.disable = disable_8259A_irq,   // 禁用 IRQ 线
	.ack = mask_and_ack_8259A,      // 通过把适当的字节发往 8259A I/O 端口来应答所接收的 IRQ。
	.end = end_8259A_irq,           // 在 IRQ 的中断处理程序终止时被调用。

	.set_affinity = NULL            // 特定 IRQ 所在 CPU 的亲和力,即哪些 CPU 用来处理特定的 IRQ。
};

irq_desc_t 描述符中的 action 字段,irqaction 描述符:

  • handler,指向一个 I/O 设备的中断服务例程。允许多个设备共享同一 IRQ。
  • flags,IRQ 与 I/O 设备之间的关系。取值:SA_INTERRUPT,SA_SHIRQ,SA_SAMPLE_RANDOM。
  • mask,未使用。
  • name,I/O 设备名。
  • dev_id,标识 I/O 设备。
  • next,指向 irqaction 描述符链表的下一个元素。链表中的元素指向共享同一 IRQ 的硬件设备。
  • irq,IRQ 线。
  • dir,指向与 IRQn 相关的 /proc/irq/n 目录的描述符。

irq_stat 数组包含 NR_CPUS 个元素,每个元素对应一个 CPU,每个元素类型为 irq_cpustat_t,其包含的字段为:

  • __softirq_pending,挂起的软中断。
  • idle_timestamp,CPU 变为空闲的时间。
  • __nmi_count,NMI 中断发生的次数。
  • apic_timer_irqs,本地 APIC 时钟中断发生的次数。

IRQ 在多处理器系统上的分发

多 APIC 系统有复杂的机制在 CPU 之间动态分发 IRQ 信号。

系统启动过程中,引导 CPU 执行 setp_IO_APIC_irqs() 来初始化 I/O APIC 芯片。所有 CPU 执行 setup_local_APIC() 初始化本地 APIC,每个芯片的任务优先级寄存器(TPR)初始化为固定值,即所有 CPU 有相同的优先级。

系统启动后,多 APIC 系统使用本地 APIC 仲裁寄存器中的值,该值每次中断后自动改变,使得 IRQ 信号公平地在所有 CPU 之间分发。

以上步骤不能保证 IRQ 在 CPU 间公平分发时,Linux 用 kirqd 内核线程进行纠正。

kirqd:

  • set_ioapic_affinity_irq(被重定向的 IRQ 向量,接收该 IRQ 的 CPU)
  • do_irq_balance() 周期性运行

多种类型的内核栈

每个进程的 thread_info 描述符与 thread_union 结构中的内核栈紧邻,如果 thread_union 大小为 4KB,内核使用 3 种类型的内核栈:

  • 异常栈。与进程关联。
  • 硬中断请求栈。与 CPU 关联,占一个单独的页框。
  • 软中断请求栈。与 CPU 关联,占一个单独的页框。

hardirq_stack 数组:硬中断请求。
softirq_stack 数组:软中断请求。

两个数组的元素都为 irq_ctx 类型。irq_ctx 跨一个单独的页框,thread_info 在该页的底部,其余空间为栈。

hardirq_ctx、softirq_ctx 数组可使内核快速确定 CPU 的硬中断请求栈和软中断请求栈,元素为 irq_ctx 指针类型。

为中断处理器保存寄存器的值

arch/i386.kernel/entry.S 用汇编建立 interrupt 数组,数组包括 NR_IRQS 个元素。数组中索引为 n 的元素存放如下汇编语言的指令地址:

pushl $n-256    // 负数表示中断,正数表示系统调用
jmp common_interrupt 
// 对所有的中断处理程序执行相同的代码
common_interrupt:
	SAVE_ALL   // 保存寄存器
	movl %esp, %eax
	call do_IRQ
	jmp ret_from_intr

// 宏,在栈中保存中断处理程序可能会用到的所有 CPU 寄存器
// 但 eflags、cs、eip、ss、esp 由控制单元自动保存
// 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                   // 把用户数据段的选择符装到 ds 和 es 寄存器
movl %edx, %es

然后将栈顶保存到 eax 寄存器,中断处理程序调用 do_IRQ() 函数。

do_IRQ 函数

do_IRQ() 执行某个中断的所有中断服务例程,声明:

// regparam 表示函数到 eax 寄存器找参数 regs 的值
__attribut__((regparm(3))) unsigned int do_IRQ(struct pt_regs *regs) 

do_IRQ():

  1. 执行 irq_enter() 宏 ,递增中断处理程序嵌套数量的计数器,计数器保存在当前进程 thread_info 的 preempt_count 字段。
  2. 如果 thread_union 大小为 4KB,函数切换到硬中断请求栈:
    – 2.1. 执行 current_thread_info() 函数,获取内核栈关联的 thread_info 描述符地址。
    – 2.2. 将 thread_info 描述符地址与 hardirq_ctx[smp_processor_id()] 中的地址比较。相等,则说明内核已使用硬中断请求栈,发生中断嵌套,跳到 3。
    – 2.3. 切换内核栈。保存当前进程描述符指针,该指针位于本地 CPU 的 irq_ctx 中的 thread_info 的 task 字段。
    – 2.4. 把 esp 内容存入本地 CPU 的 irq_ctx 的 thread_info 的 previous_esp 字段。
    – 2.5. 把本地 CPU 硬中断请求栈的栈顶(= hardirq_ctx[smp_processor_id()] + 4096) 装入 esp 寄存器;以前的 esp 值装入 ebx 寄存器。
  3. 调用 __do_IRQ() 函数,把指针 regs 和 regs->orig_eax 字段中的中断号传递给该函数。
  4. 如果在 2.5 中切换到硬中断请求栈,函数把 ebx 寄存器中的原始栈指针拷贝到 esp 寄存器,回到原来的异常栈后软中断请求栈。
  5. 执行 irq_exit() 宏,递减中断计数器并检查是否有可延迟函数正等待执行。
  6. 结束:ret_from_intr() 函数。

__do_IRQ() 函数

__do_IRQ() 参数为 IRQ 号(eax)和指向 pt_regs 结构的指针(edx)

__do_IRQ():

spin_lock(&irq_desc[irq].lock));  // 在访问主 IRQ 描述符前,内核获得相应的自旋锁,避免 CPU 并发访问
irq_desc[irq].handler->ack(irq)   // 屏蔽 IRQ 线,确保该中断处理程序结束前,CPU 不进一步接受该种中断,但可能被别的 CPU 接受
irq_desc[irq].status &= ~(IRQ_REPLAY | IRQ_WAITING);  // 清除 IRQ_REPLAY 和 IRQ_WAITING 标志
irq_desc[irq].status |= IRQ_PENDING;  // 表示中断已被应答,但还没处理

// IRQ_DISABLED:即使 IRQ 线被禁止,CPU 也可能执行 __do_IRQ() 函数,挽救丢失的中断
// IRQ_INPROGRESS:另一个 CPU 可能处理同一个中断的前一次出现,将本次中断推迟到那个 CPU
// irq_desc.action 为 NULL:中断没有相关的中断服务例程
if (!(irq_desc[irq].status & ( IRQ_DISABLED| IRQ_INPROGESS)) && irq_desc[irq].action)  
{
	irq_desc[irq].status |= IRQ_INPROGRESS;  
	do
	{
		irq_desc[irq].status &= ~IRQ_PENDING;  // 清 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_PENDING 标志

	irq_desc[irq].handler->end(irq);     // 准备终止
	spin_unlock(&(irq_desc[irq].lock));  // 释放自旋锁
}

挽救丢失的中断

多 APIC 系统,CPU 在应答中断前,该 IRQ 线被另一个 CPU 屏蔽,导致中断丢失,可通过 enable_irq() 函数挽救。

enable_irq():

spin_lock_irqsave(&(irq_desc[irq].lock), flags);

if(--irq_desc[irq].depth == 0)
{
	irq_desc[irq].status &= ~IRQ_DISABLED;

	// 通过检查 IRQ_PENDING 标志检测到一个中断丢失
	// 离开中断处理程序时,该标志总置为0
	// 因此,如果 IRQ 线被禁止,且该标志被设置,则中断出现但未被处理
	if(irq_desc[irq].status & (IRQ_PENDING | IRQ_REPLAY)) == IRQ_PENDING)  
	{
		irq_desc[irq].status |= IRQ_REPLAY;         // 确保只产生一个自我中断
		hw_resend_irq(irq_desc[irq].handler, irq);  // 强制本地 APIC 产生一个自我中断
	}

	irq_desc[irq].handler->enable(irq);
}
spin_lock_irqrestore(&(irq_desc[irq].lcok), flags);

中断服务例程 ISR

ISR 调用 handle_IRQ_event() 函数

  1. 如果 SA_INTERRUPT 标志清 0,用 sti 指令激活本地中断。
  2. 执行每个中断的 ISR。
retval = 0
do {
	// action 指向 irqaction 链表的开始,irqaction 表示接受中断后要采取的操作
	// irq:IRQ 号,允许一个单独的 ISR 处理几条 IRQ 线
	// dev_id:设备标识符,允许一个单独的 ISR 照顾几个同类型的设备
	// regs:指向内核(异常)栈的 pt_regs 的指针,栈种包含中断后随机保存的寄存器
	// regs 允许 ISR 访问被中断的内核控制路径的执行上下文。
	retval |= action->handler(irq, action->dev_id, regs); 

	action = action->next;
}while(action);
  1. cli 指令禁止本地中断。
  2. 返回 retval。即如果没有 ISR,返回 0,否则返回 1。

IRQ 线的动态分配

通过将一些设备的活动串行化,使得同一条 IRQ 线可让几个硬件设备使用,以便一次只能有一个设备拥有该 IRQ 线。

大体流程:

  • request_irq():由使用 IRQ 线的设备的驱动程序调用。建立一个新的 irqaction 描述符,并用参数初始化。
  • setup_irq():把描述符插入到合适的 IRQ 链表。如果返回一个出错码,设备驱动程序终止操作,因为 IRQ 线已被另一个设备使用。
  • free_irq():设备操作结束时,从 IRQ 链表中删除该描述符,并释放相应的内存区。

详细流程,以访问软盘为例:

  1. request_irq(6, floppy_interrupt, SA_INTERRUPT|SA_SAMPLE_RANDOM, “floppy”, NULL);
    floppy_interrupt:中断服务例程。
    SA_INTERRUPT:关中断。
    SA_SHIRQ:不共享 IRQ。
    SA_SAMPLE_RANDOM:对软盘的访问是内核用于产生随机数的一个较好的随机事件源。

  2. setup_irq(),参数为 irq_nr(IRQ 号)和 new(刚分配的 irqaction 描述符的地址)。

  • 检查 irq_nr 是否被使用,如果是,检查两个设备的 irqaction 描述符中的 SA_SHIRQ 标志是否都指定 IRQ 线能被共享,如果不能,返回出错码。
  • 把 *new 加到由 irq_desc[irq_nr]->action 指向的链表末尾。
  • 如果没有其他设备共享 irq_nr,清 *new 的 flags 的相关标志,并调用 irq_desc[irq_nr]->handler PIC 对象的 startup 方法激活 IRQ 信号。
  1. free_irq(6, NULL);
    驱动程序释放 IRQ6。

处理器间中断处理

处理器间中断(IPI)不通过 IRQ 线传输,而是作将信号直接放在连接所有 CPU 本地 APIC 的总线上。

Linux 定义了 3 种处理器间中断:

  • CALL_FUNCTION_VECTOR(向量 0xfb)。发送其他所有 CPU,强制它们运行传递过来的函数。
  • RESCHEDULE_VECTOR(向量 0xfc)。一个 CPU 接收该类型的中断后,处理程序只能自己应答中断。从中断返回后,所有调度自动进行。
  • INVALIDATE_TLB_VECTOR(向量 0xfd)。发往所有其他 CPU,强制它们的 快表(TLB)无效。处理程序刷新某些 TLB 表项。

BUILD_INTERRUPT:汇编语言,处理 IPI。保存寄存器,从栈顶压入向量号减256的值,然后调用 C 函数,其名字为低级处理程序的名字加前缀 smp_。C 函数应答本地 APIC 上的 IPI,然后指向由中断触发的特定操作。

软中断及 tasklet

把可延迟中断从中断处理程序中抽出来有助于内核保持较短的响应时间。

非紧迫、可中断的内核函数的应对方式:

  • 可延迟函数(软中断与 tasklets)
  • 通过工作队列执行的函数

tasklet 在软中断之上实现。

软中断是静态的,而 tasklet 的分配和初始化可在运行时进行。

软中断可并发地运行在多个 CPU 上,因此必须是可重入函数且使用自旋锁。而 tasklet 函数不必是可重入的。

一般,可延迟函数执行 4 种操作:

  • 初始化。定义新一个的可延迟函数,一般在内核初始化或加载模块时进行。
  • 激活。将一个可延迟函数标记为“挂起”,以便在内核对可延迟函数的下一轮调度中运行。
  • 屏蔽。有选择地屏蔽一个可延迟函数,即便激活,内核也不执行。
  • 执行。执行一个挂起的可延迟函数和同类型的其他所有挂起的可延迟函数。

软中断

软中断所使用的数据结构

softirq_vec 数组表示软中断,包含类型为 softirq_action 的 32 个元素,优先级为数组下标,只有前 6 个元素被使用。

softirq_action 字段:

  • action,指向软中断函数的指针。
  • data,指向软中断需要的通用数据结构的指针。

preempt_count 字段跟踪内核抢占和内核控制路径的嵌套,存放在 thread_info 字段中。

preempt_count 字段:

  • 位 0~7,抢占计数器,显式禁用本地 CPU 内核抢占的次数,0 表示允许内核抢占。
  • 位 8~15,软中断计数器,可延迟函数被禁用的程度,0 表示可延迟函数处于激活态。
  • 位 16~27,硬中断计数器,在本地 CPU 上中断处理程序的嵌套数。

每个 CPU 都有 32 位掩码(描述挂起的软中断),存放于 irq_cpustat_t 数据结构中。

处理软中断

open_softirq(软中断下标,指向要执行的软中断函数的指针,可能由软中断函数使用的数据结构的指针) 处理软中断的初始化

raise_softirq(软中断下标 nr) 激活软中断:

  1. local_irq_save 宏保存 eflags 寄存器的 IF 标志,禁用本地 CPU 上的中断。
  2. 把软中断标记为挂起状态,通过设置本地 CPU 的软中断掩码中与 nr 相关的位实现。
  3. in_interrupt() 产生的值为 1,跳到 5,说明:在中断上下文调用了 raise_softirq(),或当前禁用了软中断;否则,跳到 4。
  4. 需要时调用 wakeup_softirqd() 唤醒本地 CPU 的 ksoftirqd 内核线程。
  5. local_irq_restore 宏恢复第 1 步保持的 IF 标志。

在几个特殊的检查点上运行 raise_softirq() 函数。

  • 激活本地 CPU 的软中断时。
  • 完成中断处理时,包括 I/O 中断、本地定时器中断、处理器间中断。
  • 一个特殊的 ksoftirqd/n 内核线程被唤醒时。

do_softirq() 函数

检测到挂起的软中断时,调用 do_softirq() 。

  1. in_interrupt() 产生值 1,返回。说明:在中断上下文中调用了 do_softirq() 函数,或当前禁用软中断。
  2. local_irq_save 保存 IF 标志,禁用本地 CPU 上的中断。
  3. 如果 thread_union 的大小为 4KB,需要时切换到软中断请求栈。
  4. 调用__do_softirq() 函数。
  5. 如果第 3 步已切换到软中断请求栈,把最初的栈指针恢复到 esp 寄存器,切换回以前的异常栈。
  6. local_irq_restore 恢复保存的 IF 标志,返回。

__do_softirq() 函数

读取本地 CPU 的软中断掩码,执行与每个设置位相关的可延迟函数。

为避免运行时间过长,只做固定次数的循环,其余挂起的中断在 ksoftirqd 内核线程中处理。

  1. 循环计数器 = 10。
  2. pending = 本地 CPU 软中断位掩码。
  3. local_bh_disable() 使软中断计数器值加 1,禁用可延迟函数。
  4. 清本地 CPU 软中断位掩码,以便可激活新的软中断。
  5. local_irq_enable() 激活本地中断。
  6. 根据 pending 每一位的设置,执行对应的软中断处理函数。软中断函数地址:softirq_vec[nr]->action。
  7. local_irq_disable() 禁用本地中断。
  8. pending = 本地 CPU 软中断位掩码,递减循环计数器。
  9. if pending != 0,说明至少一个软中断被激活,当循环计数器仍然是正数时,返回 4。
  10. 如果还有更多挂起的软中断,调用 wakeup_softirqd() 唤醒 ksoftirqd 内核线程处理。
  11. 软中断计数器减 1,可重新激活可延迟函数。

ksoftirqd 内核线程

ksoftirqd/n 内核线程解决了难以平衡的问题:内核线程有较低的优先级,因此用户程序有机会运行;但机器空闲时,挂起的软中断很快被执行

for(;;) {
	// 如果没有挂起的软中断,将当前进程状态设置为 TASK_INTERRUPTIBLE
	set_current_state(TASK_INTERRUPTIBLE);  

	schedule();

	// 检查 local_softirq_pend() 中的软中断位掩码,必要时调用 do_softirq()
	while(local_softirq_pending()) {
		preempt_disable();

		// 确定哪些软中断是挂起的,然后执行这些软中断函数。
		// 如果已经执行的软中断又被激活,do_softirq() 唤醒内核线程并终止。
		do_softirq();  

		preempt_enable();

		// thread_info 的 TIF_NEED_RESCHED 标志被设置,调用 cond_resched() 切换进程
		cond_resched();
	}
}

tasklet

tasklet 是 I/O 驱动程序中实现可延迟函数的首选方式。

tasklet 建立在两个叫 HI_SOFTIRQ 和 TASKLET_SOFTIRQ 的软中断之上。

数据结构

tasklet_vec:tasklet 数组。
tasklet_hi_vec:高优先级的 tasklet 数组。

两个数组中元素个数为 NR_CPUS,元素的类型为 tasklet_struct *tasklet_head。

tasklet_struct,tasklet 描述符,字段:

  • next,指向链表中下一个描述符。
  • state,taslet 的状态,取值为 TASKLET_STATE_SCHED,TASKLET_STATE_RUN。
  • count,锁计数器。
  • func,指向 tasklet 函数的指针。
  • data,无符号长整数,由 tasklet 函数使用。

初始化

tasklet_init (tasklet 描述符的地址,tasklet 函数的地址,tasklet 函数的可选整型参数) 初始化 tasklet_struct 数据结构。

屏蔽

tasklet_disable_nosync() 或 tasklet_disable() 选择性地禁止 tasklet。增加 tasklet 描述符的 count 字段,但后一个函数在 tasklet 函数结束后再返回。

激活

tasklet_enable() 重新激活 tasklet。

tasklet_schedule() 或 tasklet_hi_schedule() 根据不同的优先级激活 tasklet:

  1. 检查 TASKLET_STATE_SCHED 标志,如果设置说明 tasklet 已被调度,返回。
  2. local_irq_save 保存 IF 标志,禁用本地中断。
  3. 在 tasklet_vec[n] 或 tasklet_hi_vec[n] 指向的链表的起始处增加 tasklet 描述符,n 为本地 CPU 逻辑号。
  4. raise_softirq_irqoff() 激活 TASKLET_SOFTIRQ 或 HI_SOFTIRQ 类型的软中断。
  5. local_irq_restore() 恢复 IF 标志。

执行

激活软中断后,do_softirq() 执行。与 HI_SOFTIRQ 软中断相关的软中断函数叫 tasklet_hi_action(),与 TASKLET_SOFTRIQ 相关的函数叫 tasklet_action(),这两个函数执行下列操作:

  1. 禁用本地中断。
  2. 获得本地 CPU 的逻辑号 n。
  3. 把 tasklet_vec[n] 或 tasklet_hi_vec[n] 指向的链表地址存入局部变量 list。
  4. tasklet_vec[n] = NULL 或 tasklet_hi_vec[n] = NULL。
  5. 打开本地中断。
  6. 对于 list 指向的链表中的每个 tasklet 描述符:
    6.1 在多 CPU 系统中,检查 tasklet 的 TASKLET_STATE_RUN 标志。
    – 6.1.1 如果被设置,说明同一类型的 tasklet 在另一个 CPU 上运行,将任务描述符重新插入 tasklet_vec[n] 或 tasklet_hi_vec[n] 指向的链表,再次激活 TASKLET_SOFTIRQ 或 HI_SIFTIRQ 软中断,使得当同类型的其他 tasklet 在其他 CPU 上运行时,该 tasklet 被延迟。
    – 6.1.2 如果该标志未被设置,设置该标志,使得 tasklet 不能在其他 CPU 上执行。
    6.2 查看 tasklet 描述符的 count 字段,检查 tasklet 是否被禁止。如果是,清 TASKLET_STATE_RUN 标志,将任务描述符重新插入 tasklet_vec[n] 或 tasklet_hi_vec[n] 指向的链表,再次激活 TASKLET_SOFTIRQ 或 HI_SIFTIRQ 软中断。
    6.3 如果 tasklet 被激活,清 TASKLET_STATE_SCHED 标志,执行 tasklet 函数。

工作队列

工作队列和可延迟函数的主要区别:可延迟函数运行在中断上下文中,工作队列中的函数运行在进程上下文中。执行可阻塞函数的唯一方式是在进程上下文中运行。

工作队列的数据结构

workqueue_struct 描述符,包括一个有 NR_CPUS 元素的数组,其中每个元素是 cpu_workqueue_struct 类型的描述符。

cpu_workqueue_struct 字段:

  • lock
  • remove_sequence
  • insert_sequence
  • worklist,挂起链表的头节点,链表元素为 work_struct
  • more_work,等待队列,其中的工作线程处于睡眠状态。
  • work_done
  • wq,指向 workqueue_struct 结构的指针。
  • thread
  • run_depth

work_struct 表示每一个挂起的函数,字段:

  • pending
  • entry
  • func
  • data
  • wq_data,通常是指向 cpu_workqueue_struct 描述符的父节点指针。
  • timer

工作队列函数

create_workqueue(“foo”) 返回新创建工作队列的 workqueue_struct 描述符的地址。还会创建 n 个工作者线程。

create_singlethread_workqueue() 不管有多少个 CPU,只创建一个工作者线程。

destroy_workqueue() 撤销工作队列。参数为指向 workqueue_struct 数组的指针。

queue_work(workqueue_struct * wq, work_struct * work) (封装于 work_struct 描述符中)把函数插入工作队列。

  1. 如果 work->pending == 1,说明函数已经在工作队列中,结束。
  2. 将 work 加到工作队列链表,work->pending = 1。
  3. 如果工作线程在本地 CPU 的 cpu_workqueue_struct 描述符的 more_work 等待队列上睡眠,该函数唤醒这个线程。

queue_delayed_work() 接收一个以系统滴答数表示时间延迟的参数。

工作线程被唤醒后,调用 run_workqueue() 函数,从工作者线程的工作队列链表中删除所有 work_struct 描述符并执行相应的挂起函数。

flush_workqueue(workqueue_struct 描述符的地址) ,在工作队列中的所有挂起函数结束之前使调用进程一直处于阻塞状态。

预定义工作队列

events:预定义工作队列,避免为运行一个函数而创建整个工作者线程的开销,所有内核开发者都可以随意使用。

不应该使预定义工作队列执行的函数长时间处于阻塞状态,因为其中挂起的函数是在每个 CPU 上串行方式执行的。

从中断和异常返回

在这里插入图片描述
入口点

ret_from_exception:
	cli    // 只有从异常返回时才使用 cli,禁用本地中断
ret_from_intr:
	movl $-8192, %ebp  // 将当前 thread_info 描述符的地址装载到 ebp 寄存器
	andl %esp, %ebp
	movl 0x30(%esp), %eax
	movb 0x2c(%esp), %al

	// 根据发生中断或异常压入栈中的 cs 和 eflags 寄存器的值,
	// 确定中断的程序在中断时是否运行在用户态
	testl $0x0002003, %eax  
	jnz resume_userspace
	jpm resume_kernel

恢复内核控制路径

rusume_kernel:
	cli
	cmpl $0, 0x14(%ebp)  // 如果 thread_info 描述符的 preempt_count 字段为0(运行内核抢占)
	jz need_resched      // 跳到 need_resched
restore_all:       // 否则,被中断的程序重新开始执行
	popl %ebx
	popl %ecx
	popl %edx
	popl %esi
	popl %edi
	popl %ebp
	popl %eax
	popl %ds
	popl %es
	addl $4, %esp
	iret   // 结束控制

检查内核抢占

need_resched:
	movl 0x8(%ebp), %ecx
	testb $(1<<TIF_NEED_RESCHED), %cl  // 如果 current->thread_info 的 flags 字段中的 TIF_NEED_RESCHED == 0,没有需要切换的进程
	jz restore_all                     // 因此跳到 restore_all
	testl $0x00000200, 0x30(%ebp)      // 如果正在被恢复的内核控制路径是在禁用本地 CPU 的情况下运行
	jz restore_all                     // 也跳到 restore_all,否则进程切换可能回破坏内核数据结构
	call preempt_schedule_irq          // 进程切换,设置 preempt_count 字段的 PREEMPT_ACTIVE 标志,大内核锁计数器暂时设置为 -1,调用 schedule()
	jmp need_resched 

恢复用户态程序

resume_userspace:
	cli  // 禁用本地中断
	movl 0x8(%ebp), %ecx

	// 检测 current->thread_info 的 flags 字段,
	// 如果只设置了 TIF_SYSCALL_TRACE,TIF_SYSCALL_AUDIT 或 TIF_SINGLESTEP 标志,
	// 跳到 restore_all
	andl $0x0000ff6e, %ecx
	je restore_all

	jmp work_pending

检测重调度标志

work_pending:
	testb $(1<<TIF_NEED_RESCHED), %cl
	jz work_notifysig
work_resched:
	call schedule  // 如果进程切换请求被挂起,选择另外 一个进程运行
	cli
	jmp resume_userspace  // 当前面的进程要恢复时

处理挂起信号、虚拟 8086 模式和单步执行

work_notifysig:
	movl %esp, %eax
	testl $0x00020000, 0x30(%esp)
	je 1f

// 如果用户态程序 eflags 寄存器的 VM 控制标志被设置
work_notifysig_v86:
	pushl %ecx
	call save_v86_state    // 在用户态地址空间建立虚拟8086模式的数据结构
	popl %ecx
	movl %eax, %esp
1:
	xorl %edx, %edx
	call do_notify_resume  // 处理挂起信号和单步执行
	jmp restore_all        // 恢复被中断的程序

猜你喜欢

转载自blog.csdn.net/u012319493/article/details/83315423
今日推荐