Linux内核中断机制

什么是中断?

中断是一种打断程序的正常执行流程的事件,这种事件以电信号的形式出现,可以由硬件设备或者CPU本身生成。

在中断发生后,正常的执行流被立即中止,转而执行中断处理程序(handler)。中断处理完成之后,先前的执行流将继续。

对中断进行分类:

同步的中断(exception),下文均称其为异常:由CPU自己在执行指令时生成(比如除以0或者系统调用。因为执行指令总是要依照系统时钟,所以叫它同步中断);
异步的中断(interrupt):由外部事件生成(比如敲键盘)。

可以屏蔽的中断:可以被CPU忽略,需要接到INT引脚。
不能屏蔽的中断:需要接到NMI引脚。

大多数的中断是可以屏蔽掉的。可以通过屏蔽中断电信号阻止相关中断的处理,直到我们放行中断信号。

异常

有两种原因可以导致异常:

  1. 处理器检测到的:比如fault(除0、缺页)、debug trap等
  2. 生成的:int n,这是一条汇编指令,功能是引发中断过程,从此处转去执行序号为n的处理程序。

fault可以在特定指令执行前就被触发(比如缺页),可以被纠正(比如换页)。EIP寄存器会存储对应的指令。于是在处理完异常之后可以继续执行。

trap是在指令执行后触发的异常。同样地,EIP寄存器会存储对应的指令。

一些硬件概念

可编程中断控制器(Programmable Interrupt Controller)

在这里插入图片描述
比如上图,不可屏蔽的中断走NMI引脚,可屏蔽的中断走INTR引脚。

能产生中断的设备有一个输出引脚,用来生成中断请求(Interrupt ReQuest, IRQ)。我们把这个引脚叫做IRQ引脚。设备的引脚接到可编程中断控制器(PIC)上,PIC和CPU的INTR引脚相连。

此外,PIC也有其他和CPU相连的(硬件)接口,用来交换信息。

设备产生中断的流程如下:

  1. 设备在对应的IRQ线(中断线)上生成一个中断。
  2. PIC将IRQ转换成一个向量号,把这个向量号写到硬件接口上,用来给CPU读。
  3. PIC在INTR线上生成一个中断。
  4. 在生成另外一个中断之前,PIC等待CPU确认当前这个中断。
  5. CPU确认并处理当前中断。

由此可见,根据PIC的设计,在CPU确认当前中断之前,PIC没法再开新的中断。

注意:CPU确认了当前中断和完成中断处理是两个概念。当CPU确认了一个中断后,PIC可以请求另一个中断。这意味着中断控制器可以在CPU还没有处理完前一个中断时请求新的中断。至于会不会出现嵌套中断,这取决于OS的管理方式。

PIC允许每一条IRQ线都被单独地开启或者禁用。

对称多处理器(Symmetric Multi Processor, SMP)系统中的中断控制器

SMP:其实可以理解为多核处理器。这些个核共享系统的所有资源(内存,总线,外设etc)

由于SMP有多个核,所以可能有多个中断控制器。比如,在x86架构中:

在这里插入图片描述
每个核都有局部的Advanced PIC,用来接收局部设备的IRQ(比如温度传感或者计时器)。同时,存在一个IO外设,用来向这些CPU核分发IO设备的异常。

中断的控制

一般来说,为了确保 中断处理函数 和 其他并行操作 之间访问数据的同步性,很多时候会需要启用/禁用某个中断线上的中断。有多种实现方式:

  1. 在设备级别,通常涉及对设备的控制寄存器或寄存器组进行编程。一般由OS调用设备驱动完成。
  2. 在PIC级别,可以通过禁用IRQ实现。
  3. 在CPU级别,可以用cli(CLear Interrupt)指令或者sti(SeT Interrupt)指令实现禁用中断或者开启中断。

中断的优先级

大多数的体系结构都支持中断优先级。一旦启用中断优先级,那么在一个中断执行过程中,有且仅有更高优先级的中断可以打断它。

在这里插入图片描述

并不是所有的体系结构都支持中断优先级。
为通用OS定义一般性的中断优先级也比较困难。
有的系统内核不用中断优先级。
RTOS一般都会用中断优先级。

x86平台的中断处理

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

中断描述符表(IDT)把中断或者异常的标识和处理相关事件的指令描述符相关联(可以理解为以特殊形式存在的回调)。

标识符称为向量号,相关的指令称为异常处理程序。

IDT的特征如下:

  1. 在给定向量号被触发时,IDT被CPU用作为跳表。
  2. IDT大小为256*8字节。
  3. 可能在物理地址的任何地方(由于虚拟内存映射)。
  4. CPU中有IDTR寄存器,用于存放IDT的基地址和表长度值。

在这里插入图片描述
这个图是IDT表。0-31项存放异常,32-127项存放设备中断,128用于系统调用,其他项的用来存放别的中断。

每一个数字代表一个表项。如下图,是一个表项中包含的中断的详细信息:

在这里插入图片描述

  1. segment selector:找出中断处理程序的代码段
  2. offset:在代码段中的偏移
  3. T:gate的类型
  4. DPL:使用段中内容需要的最小权限

其中,在x86机器上,一个IDT项有8字节,这个IDT项被称为gate。一共有三种gate:

  1. interrupt gate:此时IDT项中有中断/异常处理程序的地址。在跳转到对应的异常处理代码时,会屏蔽掉所有可屏蔽的中断。 x86 架构中的 EFLAGS 寄存器中有一个标志位叫做 “Interrupt Flag”(IF)标志位,这个标志位用来控制中断的开关,具体来说:当 IF 标志位被设置为 1 时,表示中断允许响应,当 IF 标志位被设置为 0 时,表示中断被禁用。当跳转到一个中断或异常处理程序时,一般会在进入处理程序之前禁用中断,也就是将 IF 标志位设置为 0。这是为了确保在处理当前中断或异常时不会被其他中断打断,从而保证了中断处理程序的执行的完整性和稳定性。在执行完中断或异常处理程序后,通常会根据需要重新启用中断,将 IF 标志位设置回 1,以便允许系统响应其他中断请求。
  2. trap gate:和interrupt gate类似,但是在跳转时不会屏蔽中断。
  3. task gate(linux中不用)

中断处理函数的地址

寻址过程如下图:

在这里插入图片描述
我们要找中断处理函数的地址:

首先基于IDT项中的segment selector找到GDT/LDT里对应的段描述符,基于段描述符里的基地址和IDT项中的偏移量,就可以找到对应的中断处理函数的起始地址。

中断处理函数的栈

正常函数的控制流转换,以函数调用为例,基于栈。中断处理函数也是基于栈的。用栈来存放调用中断处理函数之前的执行上下文。

如下图,中断首先保存EFLAGS寄存器内容,然后保存当前被打断的进程的上下文。有的异常也会在栈上保存错误码。

在这里插入图片描述

处理到来的IRQ

在生成中断请求IRQ之后,CPU要执行一系列准备工作,最终执行内核中的中断处理函数:

  • CPU查看特权级
  • 如果需要更改特权级,那么
    • 把栈换成新特权级的栈
    • 把老栈的信息存放在新栈上
  • 在栈上存放EFALGS、CS、EIP寄存器
  • 在栈上存放error code
  • 执行内核中的中断处理函数

从中断处理函数中返回

大多数体系结构会提供特殊指令,允许清空栈上中断处理的相关内容并且恢复之前的执行。

比如,x86上有IRET指令,负责从中断处理中恢复。

在执行完中断处理函数之后:

  • 弹出error code
  • 执行IRET指令(如果特权级别发生改变,那么恢复特权级别)

Linux中的中断处理

Linux中断处理的三个阶段:critical(关键阶段)、immediate(即时阶段)和deferred(延迟阶段)。

基本上是确认——即时处理——延时处理三个步骤

在第一个阶段,内核会进行通用的中断处理,确定中断号、中断处理函数和对应的中断控制器。内核和中断控制器进行交互,在中断控制器层面确认中断的到来。以确保中断不会被误报或丢失。此阶段,内核通常会禁用本地CPU的中断,以确保在中断处理的关键阶段中不会被其他中断打断。

在第二个阶段,所有和这个中断相关的设备驱动的处理函数都会被调用(有的设备驱动的处理函数发现这个中断不是自己生成的,那么它就会直接退出)。调用完成之后,中断控制器的end of interrupt方法被调用,直到此时,本地CPU的中断才会被启用,允许别的中断到来。

一个中断号可能对应多个设备,此时,中断被共享。这个时候,每个设备就要自己确认是不是自己生成了这个中断。

最后一个阶段,延迟工作部分,就是常说的中断处理的下半部分工作。此时,本地CPU启用中断。

在这里插入图片描述

嵌套的中断和异常

由于各种爆栈问题的复杂处理方式(比如允许一级嵌套、多级嵌套、加大内核栈深度等),中断嵌套目前已经被linux禁用了。有利于内核的维护。

但是,这不意味着中断和异常之间的嵌套不复存在。

中断和异常嵌套的一些原则,如下图所示:

  • 异常不能抢占中断
  • 中断可以抢占异常
  • 一个中断不能抢占另一个中断

在这里插入图片描述

中断上下文

定义:在中断被处理时(从 CPU跳转到中断处理函数 到 中断处理函数返回,即IRET指令执行 的这段时间)被称为中断上下文。

在这段时间里运行的代码有下列性质:

  • 运行在这段代码中,是因为有IRQ而不是有异常
  • 这段代码没有相关联的进程上下文
  • 不能做上下文切换(比如sleep,调度,访问用户内存)

处理中断时可以被延迟的操作(deferrable action)

可延迟操作用于在稍后的时间运行钩子函数。这些钩子函数通常用于执行与中断处理相关的任务或执行需要延迟执行的操作。

可延迟操作可以分为两个大类:在中断上下文中运行的,在进程上下文中运行的。

  • 在中断上下文中运行的:用于避免在中断处理程序函数中执行过多的工作。在中断处理期间会禁用其他中断,如果中断处理程序运行时间过长,会降低系统性能。比如,可能会因为无法及时处理网络数据包而导致丢包。

使用中断上下文的可延迟操作的主要目的是确保中断处理程序尽可能快速地执行,以减小系统响应时间并减少中断处理程序的持续时间。通过将某些任务推迟到稍后在进程上下文中执行,可以避免中断处理程序的过度运行,从而提高系统性能。

可延迟操作有相应的API,包括初始化、激活、调度、启用/禁用(用于上下文信息同步)等。

一般来说,设备驱动会在初始化设备实例时初始化可延迟操作的相关信息,在中断处理函数里进行可延迟操作的激活和调度。

软中断

在中断处理函数中启动可延迟操作,但是可延迟操作仍然在中断上下文中运行。软中断是一种允许在中断处理程序中排队和异步执行工作的方式,

API:

初始化: open_softirq()
激活: raise_softirq()
启用禁用: local_bh_disable(), local_bh_enable()

一旦激活,则钩子函数do_softirq() 可以在中断处理函数之后运行,也可以在ksoftirqd内核线程里运行。

因为软中断可以调度它们自己,比如触发了新的软中断,也可以被别的中断调度,所以有可能会导致软中断的饥饿。目前,linux内核里设置了

  • 软中断处理的最大允许时间MAX_SOFTIRQ_TIME 。如果软中断在执行时超过了这个时间限制,它会被中断,以确保系统能够处理其他重要的中断事件,避免过长的中断响应时间。
  • 软中断的最大调度次数MAX_SOFTIRQ_RESTART 。这是指允许软中断在连续执行多少次后重新排队或重调度。如果软中断连续执行次数超过了这个限制,它会被重新排队,以允许其他中断事件得到处理。

一旦上述限制条件触发,内核线程ksoftirqd 就会出马,把所有挂起的软中断全执行掉。

软中断一般被严格限制使用,只有少数需要低延迟高频性能的子系统会用软中断:

/* PLEASE, avoid to allocate new softirqs, if you need not _really_ high
   frequency threaded job scheduling. For almost all the purposes
   tasklets are more than enough. F.e. all serial device BHs et
   al. should be converted to tasklets, not to softirqs.
*/

enum
{
  HI_SOFTIRQ=0,
  TIMER_SOFTIRQ,
  NET_TX_SOFTIRQ,
  NET_RX_SOFTIRQ,
  BLOCK_SOFTIRQ,
  IRQ_POLL_SOFTIRQ,
  TASKLET_SOFTIRQ,
  SCHED_SOFTIRQ,
  HRTIMER_SOFTIRQ,
  RCU_SOFTIRQ,    /* Preferable RCU should always be the last softirq */

  NR_SOFTIRQS
};

chatGPT说,Tasklet 是软中断的一种特殊情况,用于执行快速的、低延迟的任务,而软中断是一种更通用的机制,可以用于执行各种类型的任务,包括相对复杂的操作。选择使用哪种机制取决于任务的复杂性和性能要求。

在Linux内核中,不同的软中断执行过程通常会共享相同的内核栈,而不是为每个软中断分配单独的栈空间。这是因为内核需要高效管理栈资源,分配单独的栈空间给每个软中断会占用大量的内存,并且会引入复杂性。共享栈的方法可以有效节省内存,并且已经在内核中实现。

软中断通常不会被定时器中断打断和重新调度。软中断在内核中运行于中断上下文,具有较高的优先级,因此它们通常不会被其他中断事件打断,除非发生了一些特殊情况。定时器中断(timer interrupt)通常以较低的优先级运行,用于触发定时器处理、调度延迟工作队列等任务。因此,定时器中断一般不会在执行软中断时打断软中断的执行,除非内核开启抢占模式,允许中断的抢占。