中断

一、基本概念

1. 什么是中断

中断是指由于接收到来自 外围硬件(相对于中央处理器和内存)的 异步信号 或来自 软件同步信号,而进行相应的 硬件或软件处理。发出这样的信号称为进行 中断请求(interrupt request,IRQ)。

  • 硬件中断导致处理器通过一个 上下文切换(context switch)来保存执行状态(以程序计数器和程序状态字等寄存器信息为主);
  • 软件中断则通常作为 CPU 指令集中的一个指令,以可编程的方式直接指示这种上下文切换,并将处理导向一段中断处理代码。

中断在计算机多任务处理,尤其是实时系统中尤为有用。这样的系统,包括运行于其上的操作系统,也被称为 “中断驱动的”(interrupt-driven)。

2. 中断的分类

中断源 进行分类:发出中断请求的设备称为中断源。按中断源的不同,中断可分为:

  1. 外中断(硬件中断):即由外部设备(例如:磁盘、网卡、键盘,时钟等)引起的中断
  2. 内中断(软件中断):即程序运行错误或系统调用引起的中断

允许/禁止(开/关)中断: CPU 通过指令限制某些设备发出中断请求,称为屏蔽中断。从 CPU 要不要接收中断即能不能限制某些中断发生的角度 ,中断可分为

  1. 可屏蔽中断 :可被 CPU 通过指令限制某些设备发出中断请求的中断
  2. 不可屏蔽中断:不允许屏蔽的中断如电源掉电

3. 中断允许触发器

在 CPU 内部设置一个中断允许触发器,只有该触发器置 “1”,才允许中断;置 “0”,不允许中断。

  • 指令系统中,开中断指令,使中断触发器置 “1”
  • 关中断指令,使中断触发器置 “0”

4. 中断和异常

Intel 的官方文档里将中断和异常理解为两种中断当前程序执行的不同机制。这是中断和异常的共同点(一般来说,我们所说的 中断 指的是 外中断硬件中断 ,而 异常 则是 内中断软件中断)。

不同点在于:

  1. 中断(interrupt)是 异步 的事件,典型的比如由 I/O 设备触发;
  2. 异常(exception)是 同步 的事件,典型的比如处理器执行某条指令时发现出错了等等。

简而言之,CPU 指令流相关一定是内中断,也即所谓的异常,例如存储保护错,也是内存访问相关,属于异常,也叫内中断。而非 CPU 指令流导致的才是外中断,比如时钟中断,DMA 中断等才是真正的外中断。

5. 异常的分类

异常可分为 故障(fault)、陷阱(trap)和 终止(abort) 3 种,它们都是 执行当前指令流中的某条指令 的结果,是来自 指令流内部 的,从这个意义上讲它们都是同步的。

  1. 陷阱(trap)是有意而为之的异常,是明知有套还往里钻——人家要的就是这个结果,其最常见的用途就是操作系统的系统调用
  2. 故障(fault)是由错误条件引起的,可能被故障处理例程修复。如果可以修复,则啥事儿没有,继续干活;如果不能修复则会转化为终止,并进入下一步。常见的故障如缺页。
  3. 终止(abort)是不可恢复的致命的错误造成结果。终止处理程序不再将控制返回给引发终止的应用程序,而是交给了系统——其结果往往是系统终止应用程序。

5. 中断或异常返回点

良性的如中断和陷阱,只是在正常的工作流之外执行额外的操作,然后继续干没干完的活。因此处理程序完了后返回到原指令流的下一条指令,继续执行。恶性的如故障和终止,对于可修复故障,由于是在上一条指令执行过程中发生(是由正在执行的指令引发的)的,在修复故障之后,会重新执行该指令;至于不可修复故障或终止,则不会再返回。

6. 中断号和段错误

在 Pentium 体系结构中,系统可以有高达 256 种不同的异常类型。0-31 号对应体系结构定义的异常,对任何系统都一样。范围 32-255 的号码对应的是操作系统定义的中断和陷阱。13 号异常是一般保护性故障(General Protect Fault),许多原因都会引起该故障。通常是因为一个程序应用了一个未定义的虚拟存储器区域,或者因为程序试图写一个只读的文本段。unix/Linux 不会尝试恢复这类故障,典型地,Unix shell 将这种故障报告为 “段故障”(Segmentation Fault)

在 IA32 系统上,系统调用是通过一条称为 INT n 的陷阱指令来提供的,其中 n 可能是异常表中 256 个异常号的任何一个。但历史上,系统调用是通过 128(0x80)号提供的。

7. 为什么引入中断

中断机制的好处就是,话被动为主动,避免 CPU bus pooling 等待某条件成立。

中断机制是多道程序并发运行、实时处理、硬件故障报警的基石。

8. 中断处理流程

硬中断大致处理流程:

  1. 外设 将中断请求发送给 中断控制器
  2. 中断控制器 根据中断优先级,有序地将中断传递给 CPU
  3. CPU 终止执行当前执行流,并保存现场状态(寄存器入栈)
  4. CPU 根据中断向量,从中断向量表中查找中断处理程序的入口地址,执行中断处理程序
  5. CPU 恢复现场状态,返回原执行流停止位置继续执行

软中断大致处理流程:

  1. CPU 终止执行当前执行流,并保存现场状态(寄存器入栈)
  2. CPU 根据中断向量,从中断向量表中查找中断处理程序的入口地址,执行中断处理程序
  3. CPU 恢复现场状态,返回原执行流停止位置继续执行

可以发现,无论是软中断还是硬中断,CPU 对终端的处理机制本质上是完全一致的。

具体的处理过程如下:

(1)中断响应的事前准备:

  系统要想能够应对各种不同的中断信号,总的来看就是需要知道每种信号应该由哪个中断服务程序负责以及这些中断服务程序具体是如何工作的。系统只有事前对这两件事都知道得很清楚,才能正确地响应各种中断信号和异常。

系统将所有的中断信号统一进行了编号(一共256个:0~255),这个号称为 中断向量,具体哪个中断向量表示哪种中断有的是规定好的,也有的是在给定范围内自行设定的。 中断向量和中断服务程序的对应关系主要是由IDT(中断向量表)负责。操作系统在IDT中设置好各种中断向量对应的中断描述符(一共有三类中断门描述符:任务门、中断门和陷阱门),留待CPU查询使用。而IDT本身的位置是由idtr保存的,当然这个地址也是由OS填充的。

中断服务程序具体负责处理中断(异常)的代码是由软件,也就是操作系统实现的,这部分代码属于操作系统内核代码。也就是说从CPU检测中断信号到加载中断服务程序以及从中断服务程序中恢复执行被暂停的程序,这个流程基本上是硬件确定下来的,而具体的中断向量和服务程序的对应关系设置和中断服务程序的内容是由操作系统确定的。

(2)CPU检查是否有中断/异常信号

CPU在执行完当前程序的每一条指令后,都会去确认在执行刚才的指令过程中中断控制器(如:8259A)是否发送中断请求过来,如果有那么CPU就会在相应的时钟脉冲到来时从总线上读取中断请求对应的中断向量。

对于异常和系统调用那样的软中断,因为中断向量是直接给出的,所以和通过IRQ(中断请求)线发送的硬件中断请求不同,不会再专门去取其对应的中断向量。

(3)根据中断向量到IDT表中取得处理这个向量的中断程序的段选择符

CPU根据得到的中断向量到IDT表里找到该向量对应的中断描述符,中断描述符里保存着中断服务程序的段选择符。

(4)根据取得的段选择符到GDT中找相应的段描述符

CPU使用IDT查到的中断服务程序的段选择符从GDT中取得相应的段描述符,段描述符里保存了中断服务程序的段基址和属性信息,此时CPU就得到了中断服务程序的起始地址。这里,CPU会根据当前cs寄存器里的CPL和GDT的段描述符的DPL,以确保中断服务程序是高于当前程序的,如果这次中断是编程异常(如:int 80h系统调用),那么还要检查CPL和IDT表中中断描述符的DPL,以保证当前程序有权限使用中断服务程序,这可以避免用户应用程序访问特殊的陷阱门和中断门。

(5)CPU根据特权级的判断设定即将运行的中断服务程序要使用的栈的地址

CPU会根据CPL和中断服务程序段描述符的DPL信息确认是否发生了特权级的转换,比如当前程序正运行在用户态,而中断程序是运行在内核态的,则意味着发生了特权级的转换,这时CPU会从当前程序的TSS信息(该信息在内存中的首地址存在TR寄存器中)里取得该程序的内核栈地址,即包括ss和esp的值,并立即将系统当前使用的栈切换成新的栈。这个栈就是即将运行的中断服务程序要使用的栈。紧接着就将当前程序使用的ss,esp压到新栈中保存起来。也就说比如当前在某个函数中,使用的栈,在中断发生时,需要切换新的栈。

(6)保护当前程序的现场

CPU开始利用栈保护被暂停执行的程序的现场:依次压入当前程序使用的eflags,cs,eip,errorCode(如果是有错误码的异常)信息。

(8)跳转到中断服务程序的第一条指令开始执行

CPU利用中断服务程序的段描述符将其第一条指令的地址加载到cs和eip寄存器中,开始执行中断服务程序。这意味着先前的程序被暂停执行,中断服务程序正式开始工作。

(9)中断服务程序处理完毕,恢复执行先前中断的程序

在每个中断服务程序的最后,必须有中断完成返回先前程序的指令,这就是iret(或iretd)。程序执行这条返回指令时,会从栈里弹出先前保存的被暂停程序的现场信息,即eflags,cs,eip重新开始执行。

参考

中断和异常的总结

二、实例:在键盘敲下一个字符后发生了什么?

首先,键盘是一个 I/O 设备,那么它会有一些端口(类似于 CPU 的寄存器),可以存放一些数据(假设该端口的内存地址是 0x60)。键盘上的每个按键都对应一个扫描码,例如 a 的扫描码可能是 0x1E,按我们按下按键 a 时,扫描码就存储在端口 0x60 里了。同时,我们也会给 CPU 发送一个中断请求,这个中断请求是通过可编程中断控制器传输的。

可编程中断控制器一段连接着外设,每个外设对应着一个中断号,另一端连接着 CPU。当外设向中断控制器发出中断请求后,中断控制器就会在自己的寄存器中保存该外设对应的中端口,然后向 CPU 发送中断信号,CPU 收到中断请求后,会停止当前执行流,读取中断控制器中的中断号,然后根据中断向量表查找到中断处理程序的内存地址并执行。

中断处理程序会从键盘的端口中读取键盘扫描码并放到 CPU 的寄存器当中,然后进行输出或其他的行为。

参考:中断的具体过程是咋样的呢? 以键盘按键为例

三、中断的上下两部分理论

1. 中断为什么分为两阶段

中断发生时,CPU 会暂停当前正在执行的任务,跳转到预先注册的中断服务例程(ISR)执行。ISR 需要尽快完成,因为在它执行期间,通常会禁用当前 CPU 的中断,以避免嵌套中断带来的复杂性。然而,很多设备的中断处理工作量可能比较大,如果全部在 ISR 中完成,会导致中断禁用时间过长,严重影响系统对其他中断的响应,甚至可能导致数据丢失或系统不稳定。

为了解决这个问题,Linux 内核将中断处理分解为两个阶段:

  1. 上半部(Top Half): 立即响应中断,执行那些必须在中断禁用或严格限制的原子上下文中完成的工作。
  2. 下半部(Bottom Half): 在一个稍后且中断通常是启用的上下文中,完成那些不那么紧急但可能耗时的工作。

(1)上半部 (Top Half)

  • 执行时机: 中断发生后,CPU 会立即跳转到注册的中断服务例程(ISR)执行。ISR 就是上半部。

  • 运行上下文:

    • 中断上下文(Interrupt Context): 这是 ISR 运行的特殊上下文。
    • 原子上下文(Atomic Context): 意味着 ISR 不能睡眠(即不能调用可能导致当前任务被调度出去的函数,如kmalloc(GFP_KERNEL)mutex_lock()等)。
    • 中断禁用: 在 ISR 执行期间,通常当前 CPU 上的中断是禁用或被屏蔽的(至少是低于或同优先级的)。这是为了防止在 ISR 执行过程中再次被相同类型的中断打断,简化逻辑,但也带来了副作用——延长了中断禁用时间。
  • 主要职责:

    • 快速响应: 尽可能快地响应硬件中断。
    • 中断确认(Acknowledge): 通知中断控制器和/或设备,中断已经被接收,以便设备可以清除中断状态位。
    • 保存关键数据: 将设备产生的数据(例如,网卡接收到的数据包)从设备寄存器或 DMA 缓冲区中快速复制到内存中。
    • 清除中断标志: 清除设备上的中断标志,防止中断重复触发。
    • 调度下半部: 这是上半部最关键的职责之一。它会调用特定的函数(如tasklet_schedule()queue_work()等)来调度相应的下半部机制,以便后续处理。
    • 错误检查(可选): 进行一些基本的错误检查。
  • 特点:

    • 执行速度快: 必须以最快的速度完成,避免长时间占用 CPU 并禁用中断。
    • 功能受限: 不能执行任何可能导致睡眠的操作。
    • 中断嵌套(有限): 高优先级中断可能打断低优先级ISR,但通常同优先级中断会被屏蔽。
  • 例子:

    • 网卡驱动的 ISR:接收到数据包时,ISR 会快速地将数据从网卡 DMA 缓冲区复制到内核的 sk_buff 结构中,然后调度一个下半部(如 NAPI 的 poll 函数)来处理数据包的网络协议栈处理。
    • 键盘驱动的 ISR:当按键事件发生时,ISR 会读取键盘的扫描码,并将其放入一个输入事件缓冲区,然后调度下半部来处理输入事件。

(2)下半部 (Bottom Half)

  • 执行时机: 不在中断上下文中立即执行,而是在上半部调度后,由内核在合适的时机(例如,中断返回后、软中断上下文或内核线程中)异步执行。
  • 运行上下文:
    • 软中断上下文(Softirq Context): Tasklet 和一些特定的软中断运行在此上下文。此时中断通常是启用的,但仍然不能睡眠。
    • 进程上下文(Process Context): 工作队列(Workqueues)和线程化中断(Threaded Interrupts)运行在专用的内核线程中,因此它们处于进程上下文。在此上下文可以睡眠,可以使用各种同步原语,甚至可以访问用户空间内存。
  • 主要职责:
    • 执行耗时操作: 完成 ISR 中未完成的、耗时的、非紧急的任务。
    • 复杂的逻辑处理: 例如,网络协议栈处理、文件系统操作、大量数据处理等。
    • 任务调度: 如果需要,可以进一步调度其他任务。
    • 资源管理: 释放上半部占用的资源。
  • 特点:
    • 执行时间相对较长: 相对于上半部,可以执行更长的任务。
    • 中断通常是启用的: 提高了系统的并发性。
    • 可睡眠性(取决于机制):
      • Tasklet / Softirq: 不能睡眠。
      • Workqueues / Threaded Interrupts: 可以睡眠。
    • 并发性:
      • Softirq / Tasklet: 不同的软中断/Tasklet可以在不同的CPU上并行执行。同一个Tasklet在同一CPU上串行执行,但在不同CPU上可以并发执行。
      • Workqueues / Threaded Interrupts: 运行在独立的内核线程中,可以根据调度器的规则并发执行。
  • 下半部机制分类:
    • 软中断(Softirqs): 最底层的下半部机制,数量有限,通常用于实现高优先级、高并发的下半部,如网络子系统、定时器等。Tasklet 是基于软中断实现的。
    • Tasklets(小任务): 基于软中断实现,比直接使用软中断更方便。同一个 Tasklet 不会在同一CPU上并发执行,但可以在不同 CPU上 并发执行。
    • 工作队列(Workqueues): 将任务提交到一个或多个内核线程中执行。任务在进程上下文运行,可以睡眠,是最灵活的下半部机制。
    • 线程化中断(Threaded Interrupts): 将中断处理的大部分工作放入一个独立的内核线程中执行,也运行在进程上下文,可以睡眠。适用于需要精细控制中断优先级的场景。

为什么需要这种两部分机制?

  1. 提高系统响应速度: 缩短了中断禁用时间,使得系统能够更快地响应其他中断和事件。
  2. 避免数据丢失: 快速将关键数据从硬件转移到内存,减少了硬件缓冲区溢出的风险。
  3. 提高系统吞吐量: 将耗时工作异步化处理,使得 CPU 可以更快地回到被中断的任务,或者处理其他中断。
  4. 提高代码可维护性: 将复杂的中断处理逻辑分解为两个相对独立的部分,使代码结构更清晰,更容易理解和调试。
  5. 增强系统稳定性: 减少了在中断上下文中执行复杂逻辑的风险,降低了死锁或竞争条件的可能性。

2. tasklet(小任务)机制

tasklet 是 Linux 内核实现下半部(Bottom Half)机制的一种方式。它的主要目的是将中断服务例程(ISR,即上半部)中不那么紧急但又耗时的工作推迟到稍后执行,从而缩短中断禁用时间,提高系统的响应性和效率。

Tasklet 的特点

  1. 基于软中断(Softirq)实现: Tasklet 是构建在软中断机制之上的。Linux 内核中有专门用于 Tasklet 的软中断(HI_SOFTIRQTASKLET_SOFTIRQ)。

  2. 原子上下文执行:

    Tasklet 运行在软中断上下文(Softirq Context)中,这意味着:

    • 中断通常是启用的: 当 Tasklet 执行时,中断通常是启用的,因此其他中断可以正常发生和处理,提高了系统的并发性。
    • 不能睡眠: 尽管中断是启用的,但 Tasklet 仍然不能执行可能导致睡眠的操作(如获取信号量、内存分配 GFP_KERNEL、调度等)。它必须是非阻塞的。
  3. 串行化执行(per-CPU):

    • 同一 Tasklet 在同一 CPU 上串行执行: 如果一个 Tasklet 已经被调度,并且正在某个 CPU 上运行,即使再次调用 tasklet_schedule() 调度它,它也不会立即再次执行,而是等待当前执行完成后,在同一个 CPU 上再次被执行。这避免了同一 Tasklet 内部的竞态条件,简化了编程。
    • 不同 Tasklet 可以并发执行: 不同的 Tasklet 可以同时在不同的 CPU 上并发执行。
    • 同一个 Tasklet 可以在不同 CPU 上并发执行(如果被调度到不同 CPU): 虽然同一个 Tasklet 在同一 CPU 上是串行执行的,但如果它被调度到不同的 CPU 上,那么它可以在这些不同的 CPU 上并发执行。这意味着,如果 Tasklet 访问全局数据,仍然需要适当的同步机制(如自旋锁)。
  4. 动态创建和销毁: Tasklet 可以动态地创建和销毁,而软中断通常是静态定义的。

  5. 优先级: Tasklet 分为高优先级 Tasklet 和普通优先级 Tasklet,分别对应 HI_SOFTIRQTASKLET_SOFTIRQ 两种软中断。高优先级 Tasklet 会比普通 Tasklet 更早被执行。

Tasklet 的结构体

Struct  tasklet_struct {
    struct tasklet_struct *next;/*指向链表中的下一个结构*/
    unsigned long state;/* 小任务的状态*/
    atomic_t count;/* 引用计数器*/
    void(*func) (unsigned long);/* 要调用的函数*/
    unsigned long data;/* 传递给函数的参数*/
};
  • 结构中的 func 域就是下半部中要推迟执行的函数,data 是它唯一的参数。
  • count 域是小任务的引用计数器。如果它不为 0,则小任务被禁止,不允许执行;只有当它为零,小任务才被激活,并且在被设置为挂起时,小任务才能够执行。
  • state 域的取值为 TASKLET_STATE_SCHEDTASKLET_STATE_RUN
    • TASKLET_STATE_SCHED 表示小任务已被调度,正准备投入运行,TASKLET_STATE_RUN 表示小任务正在运行。TASKLET_STATE_RUN只有在多处理器系统上才使用,单处理器系统什么时候都清楚一个小任务是不是正在运行(它要么就是当前正在执行的代码,要么不是)。

1. 声明和使用小任务大多数情况下,为了控制一个寻常的硬件设备,小任务机制是实现下半部的最佳选择。小任务可以动态创建,使用方便,执行起来也比较快。

我们既可以静态地创建小任务,也可以动态地创建它。选择那种方式取决于到底是想要对小任务进行直接引用还是一个间接引用。如果准备静态地创建一个小任务(也就是对它直接引用),使用下面两个宏中的一个:

DECLARE_TASKLET(name,func, data);
DECLARE_TASKLET_DISABLED(name,func, data)

这两个宏都能根据给定的名字静态地创建一个 tasklet_struct 结构。当该小任务被调度以后,给定的函数 func 会被执行,它的参数由 data 给出。这两个宏之间的区别在于引用计数器的初始值设置不同。第一个宏把创建的小任务的引用计数器设置为 0,因此,该小任务处于激活状态。另一个把引用计数器设置为 1,所以该小任务处于禁止状态。例如:

DECLARE_TASKLET(my_tasklet,my_tasklet_handler, dev);
// 这行代码其实等价于
struct tasklet_struct my_tasklet = { NULL, 0, ATOMIC_INIT(0), tasklet_handler, dev};

这样就创建了一个名为 my_tasklet 的小任务,其处理程序为 tasklet_handler,并且已被激活。当处理程序被调用的时候,dev就会被传递给它。

2. 编写自己的小任务处理程序小任务处理程序必须符合如下的函数类型:

void tasklet_handler(unsigned long data);

由于小任务不能睡眠,因此不能在小任务中使用信号量或者其它产生阻塞的函数。但是小任务运行时可以响应中断。

3. 调度自己的小任务通过调用 tasklet_schedule() 函数并传递给它相应的 tasklt_struct 指针,该小任务就会被调度以便适当的时候执行:

tasklet_schedule(&my_tasklet); /*把my_tasklet标记为挂起 */

在小任务被调度以后,只要有机会它就会尽可能早的运行。在它还没有得到运行机会之前,如果一个相同的小任务又被调度了,那么它仍然只会运行一次

可以调用 tasklet_disable() 函数来禁止某个指定的小任务。如果该小任务当前正在执行,这个函数会等到它执行完毕再返回。调用 tasklet_enable() 函数可以激活一个小任务,如果希望把以 DECLARE_TASKLET_DISABLED() 创建的小任务激活,也得调用这个函数,如:

tasklet_disable(&my_tasklet); // 小任务现在被禁止,这个小任务不能运行
tasklet_enable(&my_tasklet);  // 小任务现在被激活

也可以调用 tasklet_kill() 函数从挂起的队列中去掉一个小任务。该函数的参数是一个指向某个小任务的 tasklet_struct 的长指针。在小任务重新调度它自身的时候,从挂起的队列中移去已调度的小任务会很有用。这个函数首先等待该小任务执行完毕,然后再将它移去。

4.实例

#include <linux/cdev.h>        // 字符设备文件操作,此处未使用,但常见于驱动开发
#include <linux/fs.h>          // 文件系统头文件,此处未使用
#include <linux/init.h>        // 内核模块初始化和退出相关的宏,如 module_init, module_exit
#include <linux/interrupt.h>   // Tasklet 相关的头文件,包括 tasklet_struct, tasklet_init 等
#include <linux/kdev_t.h>      // 设备号相关的头文件,此处未使用
#include <linux/kernel.h>      // 核心内核功能,如 printk
#include <linux/module.h>      // 内核模块相关的定义,如 MODULE_LICENSE

// 定义一个 Tasklet 结构体变量
static struct tasklet_struct my_tasklet;

// Tasklet 的处理函数,它将在 Tasklet 被调度后执行
static void tasklet_handler(unsigned long data)
{
    // 打印一条消息,表示 Tasklet 正在运行
    printk(KERN_ALERT, "tasklet_handler is running.\n");
}

// 模块初始化函数,当模块加载时执行
static int __init test_init(void)
{
    // 初始化 Tasklet
    // 参数1: 要初始化的 tasklet_struct 变量的地址
    // 参数2: Tasklet 的处理函数 (tasklet_handler)
    // 参数3: 传递给处理函数的数据 (这里是 0,表示没有额外数据)
    tasklet_init(&my_tasklet, tasklet_handler, 0);

    // 调度 Tasklet
    // 这会将 my_tasklet 添加到软中断队列中,等待内核调度执行
    tasklet_schedule(&my_tasklet);

    // 打印一条消息,表示模块初始化成功
    printk(KERN_ALERT, "test_init is running. Tasklet scheduled.\n");

    return 0; // 返回 0 表示模块初始化成功
}

// 模块退出函数,当模块卸载时执行
static void __exit test_exit(void)
{
    // 停止(杀死)Tasklet
    // 这个函数会确保 my_tasklet 不再被调度,并等待它当前如果正在运行的话完成
    tasklet_kill(&my_tasklet); // 注意:这里原文是 &tasklet,但正确的应该是 &my_tasklet
    printk(KERN_ALERT, "test_exit is running.\n");
}

// 设置模块的许可证为 GPL,这是加载大多数内核模块所必需的
MODULE_LICENSE("GPL");

// 注册模块初始化函数
module_init(test_init);
// 注册模块退出函数
module_exit(test_exit);

// 这是一个宏,用于声明并初始化一个 Tasklet。
// DECLARE_TASKLET(name, func, data) 宏等价于 static DECLARE_TASKLET_OLD(name, func, data)
// 它会在编译时创建一个 static struct tasklet_struct 变量并初始化它。
// 但是,在这个例子中,my_tasklet 已经通过 static struct tasklet_struct my_tasklet;
// 声明,并通过 tasklet_init() 显式初始化,所以这里的 DECLARE_TASKLET 是多余的或者说有冲突的。
// 如果使用 DECLARE_TASKLET,通常会这样用:
// DECLARE_TASKLET(my_tasklet, tasklet_handler, 0);
// 这样就不需要静态声明 my_tasklet 和调用 tasklet_init(&my_tasklet, ...) 了。
// 在这个特定的代码中,它可能会导致编译警告或不正确的行为,因为 my_tasklet 被声明了两次。
// 鉴于 my_tasklet 已经通过 tasklet_init 显式初始化,`DECLARE_TASKLET` 这一行应该被移除。

3. work queue 机制

参考工作队列(work queue)是另外一种将工作推后执行的形式,它和前面讨论的 tasklet 有所不同。工作队列可以把工作推后,交由一个内核线程去执行,也就是说,这个下半部分可以在进程上下文中执行。这样,通过工作队列执行的代码能占尽进程上下文的所有优势。最重要的就是工作队列允许被重新调度甚至是睡眠。

那么,什么情况下使用工作队列,什么情况下使用 tasklet。如果推后执行的任务需要睡眠,那么就选择工作队列。如果推后执行的任务不需要睡眠,那么就选择 tasklet。另外,如果需要用一个可以重新调度的实体来执行你的下半部处理,也应该使用工作队列。它是唯一能在进程上下文运行的下半部实现的机制,也只有它才可以睡眠。这意味着在需要获得大量的内存时、在需要获取信号量时,在需要执行阻塞式的I/O操作时,它都会非常有用。如果不需要用一个内核线程来推后执行工作,那么就考虑使用tasklet。

如前所述,我们把推后执行的任务叫做工作(work),描述它的数据结构为 work_struct,这些工作以队列结构组织成工作队列(workqueue),其数据结构为 workqueue_struct,而工作线程就是负责执行工作队列中的工作。系统默认的工作者线程为events,自己也可以创建自己的工作者线程。

<linux/workqueue.h> 中定义的 work_struct 结构表示:

struct work_struct
{
    unsigned long pending;   /* 这个工作正在等待处理吗?*/
    struct list_head entry;  /* 连接所有工作的链表 */
    void (*func)(void *);    /* 要执行的函数 */
    void *data;              /* 传递给函数的参数 */
    void *wq_data;           /* 内部使用 */
    struct timer_list timer; /* 延迟的工作队列所用到的定时器 */
};

这些结构被连接成链表。当一个工作者线程被唤醒时,它会执行它的链表上的所有工作。工作被执行完毕,它就将相应的work_struct对象从链表上移去。当链表上不再有对象的时候,它就会继续休眠。

参考

  1. Linux中断流程以及处理机制》
  2. 为什么Linux不能在中断中睡眠
  3. 中断下半部处理tasklet机制、workqueue机制和threaded irq》
posted @ 2025-06-05 22:10  光風霽月  阅读(30)  评论(0)    收藏  举报