很多人读内核源码,第一道坎不是调度器也不是内存管理,而是中断子系统。一堆概念堆在一起:irq、hwirq、vector、desc、domain、chip、action,代码翻半天也不知道谁和谁是一伙的。这篇文章我以 Linux 6.6 为基础,把 IRQ 子系统最核心的数据结构一件件拆开,讲清楚每个结构到底管什么、字段怎么用、中断从触发到执行到底走了哪条路。
这篇文章适合两类人:一类是正在啃内核源码、看到irq_desc和irq_domain就发懵的读者;另一类是写驱动时被中断相关问题折磨过、想系统补一补底层知识的开发。读完你能自己看/proc/interrupts、能读懂内核模块里的中断注册代码、能在出问题时知道该往哪个结构体里翻。
1. IRQ子系统到底在解决什么问题:先建立全局认知
1.1 从一次按键中断说起
先别急着抠数据结构。我们站在硬件角度想一个问题:一个 GPIO 按键按下去,从硬件信号变成你驱动里的handler函数执行,中间经过了多少层?
简单说:按键电平变化 -> 中断控制器检测到 -> 中断控制器告诉 CPU -> CPU 跳转到异常向量 -> 内核读取中断号 -> 找到对应的处理函数 -> 执行并清中断。每一步都需要有数据结构承载信息,IRQ 子系统就是把这些环节串起来的骨架。
在 Linux 6.6 里,这套骨架的核心设计思路是:用编号索引一切。每一个中断源在内核里都有一个逻辑编号,叫irq number,可以理解成中断源的“身份证号”。内核所有中断相关的核心数据结构,基本都围绕这个编号展开。
1.2 两个中断号:硬件中断号与Linux逻辑中断号
这是理解整个 IRQ 子系统的关键,很多人一开始就在这里栽跟头。硬件中断号(hwirq)是中断控制器硬件侧使用的编号,比如 GIC(Generic Interrupt Controller)上的 SPI 中断号 32、PPI 中断号 16,这都是芯片手册里写死的。而 Linux 逻辑中断号(irq)是内核自己分配的一套编号,驱动里request_irq()传的、/proc/interrupts第一列显示的,都是这个逻辑号。
为什么要分成两套?因为系统里可能挂多个中断控制器,每个控制器都有自己的hwirq编号空间,如果直接用hwirq做全局索引,两个控制器都叫hwirq 16就冲突了。Linux 给每个中断源分配一个全局唯一的逻辑号,然后用一套映射机制把hwirq翻译成irq。这套翻译机制的核心就是struct irq_domain。
1.3 为什么要关心这些数据结构
中断子系统不像内存管理那样有一个集中的“核心对象”,它是分散配合的。你要理解一个中断的完整生命周期,至少得看懂五个结构:irq_desc、irq_data、irq_chip、irqaction、irq_domain。这五个结构各司其职:
| 结构体 | 职责 | 归属层次 |
|---|---|---|
irq_desc | 一个中断源的全部家当,中断处理流程的枢纽 | 内核全局 |
irq_data | 描述单个中断的硬件信息、芯片信息、域映射信息 | 附属在 desc 内 |
irq_chip | 中断控制器的操作回调集合 | 控制器驱动 |
irqaction | 一个具体设备的中断处理函数及参数 | 设备驱动 |
irq_domain | 维护 hwirq 到 irq 的映射关系 | 控制器驱动与内核之间 |
下面逐个拆。
2. 核心数据结构逐个拆解:五个结构一台戏
2.1 struct irq_desc:中断世界的主心骨
struct irq_desc定义在include/linux/irqdesc.h,是 IRQ 子系统的“中央枢纽”。每个 Linux 逻辑中断号对应一个irq_desc,它把中断的硬件信息、处理函数、状态标志、统计信息全集中在一起。我摘一段 Linux 6.6 中简化后的关键字段:
struct irq_desc { struct irq_common_data irq_common_data; struct irq_data irq_data; unsigned int __percpu *kstat_irqs; irq_flow_handler_t handle_irq; struct irqaction *action; /* IRQ action list */ unsigned int status_use_accessors; ... };逐字段看:
irq_common_data和irq_data是内嵌的两个子结构,前者存放跨芯片通用的信息(如亲和性、handler_data),后者存放和具体中断控制器相关的信息。这样设计是为了让通用代码只关心irq_data,而控制器驱动也只需要操作irq_data,不用知道外层irq_desc的存在。
kstat_irqs是 per-CPU 的中断计数,这就是/proc/interrupts里那些数字的来源。你敲cat /proc/interrupts看到的每个 CPU 列,其实就是在统计每个irq_desc的kstat_irqs。
handle_irq是中断流处理函数(irq_flow_handler_t),它不是一个设备驱动注册的普通 handler,而是描述“这种中断该如何处理”的流程函数。比如handle_level_irq()用于电平触发中断,handle_edge_irq()用于边沿触发中断。它决定了中断发生后是先 mask 再处理,还是先 ack 再处理,以及什么时候调用 action 链表。
action是struct irqaction链表头。一个中断可能被多个设备共享,所以一个irq_desc的action可能是链表,中断发生时内核会遍历这个链表,挨个调用设备注册的 handler。
2.2 struct irq_data与irq_common_data:芯片和域之间的桥梁
irq_data是irq_desc中最重要的一个内嵌结构,它把“逻辑中断号”“硬件中断号”“所属 domain”“所属 chip”四种关键信息绑定在了一起。Linux 6.6 中的关键字段:
struct irq_data { u32 mask; unsigned int irq; unsigned long hwirq; struct irq_common_data *common; struct irq_chip *chip; struct irq_domain *domain; void *chip_data; };irq就是前面说的 Linux 逻辑中断号,全局唯一。hwirq是对应到中断控制器的硬件中断号。chip指向处理这个中断的控制器操作集。domain指向这个中断所在的映射域,chip_data是芯片驱动私有数据,通常用来存放控制器自定义的信息,比如寄存器地址或硬件状态。
mask这个字段比较有意思,它在某些控制器实现里用来缓存中断屏蔽状态,避免每次都去读寄存器,属于芯片驱动可以自由使用的高速缓存位。
irq_common_data则存放不太依赖具体芯片的通用数据:
struct irq_common_data { unsigned int __percpu *affinity; cpumask_var_t affinity_pending; cpumask_var_t effective_affinity; void *handler_data; struct msi_desc *msi_desc; ... };affinity是中断亲和性掩码,表示这个中断可以被投递到哪些 CPU 上。effective_affinity是实际生效的亲和性,因为有些控制器不支持任意 CPU 路由,最终会被限制到某个子集。handler_data是给中断流处理函数预留的私有数据,msi_desc只在 MSI/MSI-X 中断里使用。
为什么要拆成 common 和 data 两层?因为irq_common_data对同一个irq_desc是唯一的,而irq_data在层级 domain 架构下可能有多个。一个设备中断经过 GIC 到 CPU 时,可能涉及中间层控制器,每层都有自己的irq_data,此时通过parent_data指针串起来。通用字段放在 common 里,避免每层重复拷贝。
2.3 struct irq_chip:中断控制器的操作抽象
如果说irq_desc是数据中枢,那么irq_chip就是“操作中枢”。它把中断控制器的各种硬件操作抽象成函数指针。控制器驱动只需要实现这个结构体,内核通用代码就能以统一方式操作任何中断控制器。
struct irq_chip { const char *name; void (*irq_mask)(struct irq_data *data); void (*irq_unmask)(struct irq_data *data); void (*irq_ack)(struct irq_data *data); void (*irq_eoi)(struct irq_data *data); void (*irq_set_affinity)(struct irq_data *data, const struct cpumask *dest, bool force); int (*irq_set_type)(struct irq_data *data, unsigned int flow_type); int (*irq_set_wake)(struct irq_data *data, unsigned int on); ... };irq_mask/irq_unmask控制中断的屏蔽与解屏蔽。irq_ack用于通知控制器硬件“中断已收到”,通常在边沿触发中断里必须做。irq_eoi用于通知中断控制器“中断处理完毕”,在 GIC 这种需要 EOI 的控制器上特别关键——如果驱动忘了写 EOI,后续同优先级中断可能全被堵住。
irq_set_affinity设置中断亲和性,它会把逻辑 CPU 掩码翻译成控制器硬件能理解的路由配置。irq_set_type设置触发方式,比如IRQF_TRIGGER_RISING或IRQF_TRIGGER_LOW。irq_set_wake用于电源管理,配置中断能否把系统从 suspend 中唤醒。
需要注意的是,irq_chip里的回调绝大多数传的都是struct irq_data *,而不是irq_desc *。这是一层有意为之的解耦:芯片驱动不需要知道irq_desc的完整布局,只需要操作irq_data就能完成全部工作。如果你在写控制器驱动,尽量不要越过irq_data去访问irq_desc,否则后续内核调整内部结构时你的驱动大概率要碎。
2.4 struct irqaction:真正干活的处理函数
irqaction是设备驱动注册中断时要打交道的结构。虽然驱动通常通过request_irq()或request_threaded_irq()来注册,但内核最终会把参数封装成irqaction挂到irq_desc的 action 链表上。
struct irqaction { irq_handler_t handler; void *dev_id; void __percpu *percpu_dev_id; struct irqaction *next; irq_handler_t thread_fn; struct task_struct *thread; struct irqaction *secondary; unsigned int irq; unsigned int flags; ... };handler就是中断到来时执行的回调函数,在原子上下文运行,不能睡觉。thread_fn配合线程化中断使用,如果注册时传了thread_fn,内核会创建一个内核线程,真正的处理逻辑在线程上下文运行,可以睡眠。thread指向那个内核线程的task_struct。
dev_id是设备驱动传入的私有参数。为什么要有它?因为多个设备可以共享同一个中断号。中断触发时,内核会遍历 action 链表,把所有 handler 都跑一遍,每个 handler 通过dev_id判断是不是自己的设备发生了中断。如果request_irq()时传了唯一的dev_id,也能在free_irq()时准确释放自己想释放的那一个 action,而不是误删别人的。
next指针把同一个中断号下的所有 action 串成单链表。flags保存中断标志,比如IRQF_SHARED(共享中断)、IRQF_TRIGGER_RISING(上升沿触发)、IRQF_NO_THREAD(禁止线程化)等。
如果你在驱动里调用request_irq()之后发现cat /proc/interrupts里对应的中断计数不涨,不要先怀疑硬件,先检查一下irqaction挂上没有、handler 返回的IRQ_NONE次数是不是太多。
2.5 struct irq_domain:hwirq到irq number的翻译官
irq_domain是整个映射机制的核心。没搞懂它之前,很多驱动里的platform_get_irq()、设备树里的interrupts = <&gic GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>就只能靠死记硬背。搞懂它之后,这些 API 背后的逻辑就通透了。
struct irq_domain { struct list_head link; const char *name; const struct irq_domain_ops *ops; void *host_data; unsigned int flags; struct fwnode_handle *fwnode; enum irq_domain_bus_token bus_token; struct irq_chip_generic *gc; struct xarray revmap_tree; struct irq_domain *parent; struct irq_domain *...; unsigned int linear_revmap[]; };link链接到全局 domain 链表,name是 domain 名称,ops是操作方法集,包含map、unmap、xlate等回调。fwnode是对应设备树节点或 ACPI 节点的固件句柄,host_data通常是控制器驱动的私有数据结构。
核心部分是revmap_tree和linear_revmap。这两者就是映射关系的存储介质。linear_revmap是一张线性表,按下标 hwirq 直接取 irq 号,查找时间复杂度是 O(1)。revmap_tree是一个 xarray(早期内核是 radix tree),用于存线性表放不下的稀疏映射。关于这两种映射方式,下一节详细说。
这里先记住一个关键结论:irq_domain就是一张“翻译表”,输入是硬件中断号,输出是 Linux 逻辑中断号。irq_data里的domain和hwirq两个字段合在一起,就可以在这张表里查到对应的irq号。
3. irq_domain的三种映射方式:线性、树形与层级
3.1 线性映射(linear revmap)
线性映射是最高效的方式。一个 domain 在创建时如果知道硬件中断号是连续且有限的,就会从linear_revmap[]数组里分配一块连续空间,数组下标就是hwirq,存储的内容就是对应的 Linuxirq号。
比如一个中断控制器只支持 0 到 31 号中断,那么linear_revmap数组长度就是 32,hwirq 5对应的逻辑中断号就是linear_revmap[5]。映射和反查都是 O(1),快得离谱。
内核里对应的是irq_domain_create_linear()接口。GIC、GPIO 中断控制器等大多数“中断号连续且范围不大”的控制器都用这种方式。
3.2 树形映射(xarray tree revmap)
有些控制器硬件中断号极其稀疏,比如一个芯片上只有 3 个中断源,但硬件编号是 1000、2000、4000。如果依然用线性表,就得开 4000 多个槽位,浪费严重。这时候用revmap_tree来存 (hwirq, irq) 映射对,查找时走 xarray 树。
对应的创建接口是irq_domain_create_tree()。查找效率从 O(1) 变成 O(log n),但这种场景下中断数量本来就不多,性能损失可以忽略。
3.3 层级domain与no-map模式
现代 SoC 里中断控制器往往不止一层。比如外设产生中断后,先到一个 GPIO 控制器,再汇聚到 GIC,最后给 CPU。硬件上是嵌套关系,映射逻辑也必须嵌套。于是 Linux 提出了 hierarchy domain 机制,每个 domain 可以有一个parent。
在这种架构下,一个中断会从子 domain 逐级往上路由。此时irq_data里的parent_data就派上用场了,它指向上一层 domain 对应的irq_data。irq_chip中的很多回调也会沿着parent_data一级级向上调用。
还有一种特殊情况叫no-map,即创建 domain 时通过IRQ_DOMAIN_FLAG_NO_MAP声明“不做 irq 号映射”。这种场景适用于部分 MSI 控制器,中断号完全由驱动自己管理,不需要走全局翻译表。
3.4 通过设备树理解映射流程
设备树里常见这么一段:
&gic { interrupts = <GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>; };或者在外设节点里:
uart0: serial@10000000 { interrupt-parent = <&gic>; interrupts = <GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>; };内核解析这段设备树时,看到interrupt-parent指向gic,就会找到 GIC 对应的irq_domain,然后调用 domain 的xlate回调,把<GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>翻译成hwirq(此时是 32),再在 domain 里分配一个新的 Linux 逻辑中断号,并建立映射。完成之后,驱动通过platform_get_irq()拿到的就是那个 Linux 逻辑中断号,随后request_irq()把它和irqaction绑定起来。
整个过程可以用一个表格总结:
| 阶段 | 输入 | 输出 | 关键角色 |
|---|---|---|---|
| 设备树解析 | 设备树 interrupts 属性 | hwirq | irq_domain->ops->xlate |
| 创建映射 | hwirq | Linux irq | irq_domain(线性或树形) |
| 驱动注册 | Linux irq + handler | irqaction链表 | irq_desc->action |
| 中断触发 | 硬件信号 | 调用链表上的handler | irq_desc->handle_irq |
4. 中断处理流程中数据结构如何协同工作
4.1 从硬件中断触发到handle_irq
当硬件中断信号到达 CPU 时,CPU 跳到内核异常向量,进入架构相关的中断入口代码。以 ARM64 为例,入口代码会调用handle_arch_irq(),最终通过读取中断控制器寄存器拿到 hwirq,然后调generic_handle_domain_irq()进入通用中断处理层。
这一步是irq_domain的表演时间。generic_handle_domain_irq(domain, hwirq)会先调用irq_find_mapping(domain, hwirq),在irq_domain的linear_revmap或revmap_tree里查到对应的 Linuxirq号,再通过irq_to_desc(irq)拿到irq_desc。
拿到irq_desc之后,内核调用generic_handle_irq_desc(desc),最终执行desc->handle_irq(desc)。这里handle_irq指向的是handle_level_irq()、handle_edge_irq()、handle_fasteoi_irq()这类流处理函数。
流处理函数会做三件标准事:首先根据触发类型决定要不要 ack/mask,防止中断风暴;然后遍历desc->action链表,逐个调用设备注册的handler;最后处理完后调用irq_finalize_irq()之类的清理函数,必要时 unmask 中断,使中断可以再次触发。
4.2 action链表遍历与共享中断
共享中断是驱动开发里最容易出问题的地方,背后其实就是irqaction链表的遍历逻辑。假设两个设备共享同一个 irq 号,desc->action就指向两个irqaction串成的链表。中断到来时,内核从链表头开始挨个调用 handler。
每个 handler 执行后返回irq_handler_t枚举值,也就是IRQ_NONE或IRQ_HANDLED。如果返回IRQ_NONE,内核会给desc->irq_count之类的统计累加一次“无人认领”计数。当无人认领次数超过阈值时,内核会报告spurious interrupt,甚至可能考虑 disable 这个中断。
所以共享中断的 handler 必须先判断是不是自己的设备产生的中断。判断的方式就是比较dev_id,或者直接读设备的中断状态寄存器。如果设备没有中断挂起,立即返回IRQ_NONE。这既是性能要求,也是正确性要求——你不认领,内核就不会清中断,但你也别乱清理别人的中断。
4.3 线程化中断与IRQ affinity
中断线程化是irqaction中thread_fn和thread字段发挥作用的地方。request_threaded_irq()注册时如果提供thread_fn,内核会创建工作线程。硬件中断触发后,handler在原子上下文中快速完成少量工作(比如读取状态、禁止该中断再次触发),然后唤醒thread去执行thread_fn。
这是怎么实现的?irqaction里既有handler又有thread_fn,desc->action链表照常被遍历,但最终如果存在线程化处理,内核会通过irq_wake_thread()把irqaction->thread唤醒。thread是一个被wake_up_process()唤醒的内核线程,它在自己的进程上下文里执行thread_fn,享受调度器管理,可以睡眠、可以持锁。
至于 IRQ affinity,流程里主要涉及irq_data和irq_common_data。用户通过/proc/irq/{irq}/smp_affinity写入 CPU 掩码时,内核会先更新irq_common_data里的亲和性掩码,然后调用irq_chip->irq_set_affinity(),让控制器把该中断路由到指定 CPU。实际生效的掩码会写回effective_affinity,你在调试时如果发现设置的 affinity 不生效,多半是控制器不支持精确路由,最终被限制到了effective_affinity指示的集合。
5. 实操:怎么观察和验证这些数据结构
5.1 /proc/interrupts与smp_affinity
理论说再多,不如直接上机器看一眼。先找一个带中断的设备,比如网卡或磁盘控制器,执行:
cat /proc/interrupts输出大致如下:
CPU0 CPU1 CPU2 CPU3 16: 12345 0 0 0 GICv3 27 Level vmmc 17: 0 0 0 0 GICv3 30 Level virtio0第一列就是 Linux 逻辑中断号,第二到第五列是每个 CPU 上的中断计数,后面依次是中断控制器名称(也就是irq_chip->name)、硬件中断号(hwirq)、触发方式、设备名。这个文件本质上是遍历所有irq_desc,把kstat_irqs里的 per-CPU 计数格式化输出。
查看和修改亲和性:
cat /proc/irq/17/smp_affinity echo 2 > /proc/irq/17/smp_affinity写入的数值是 CPU 掩码的十六进制。比如2表示二进制0010,即将中断固定到 CPU1。修改后可以再观察/proc/interrupts里中断计数在不同 CPU 间的分布变化。
5.2 写一个内核模块遍历irq_desc
/proc/interrupts只展示基本信息,如果你想看irq_desc内部字段,可以写个小模块。内核提供了遍历接口:
#include <linux/module.h> #include <linux/irq.h> #include <linux/irqdesc.h> static int __init dump_irq_init(void) { struct irq_desc *desc; int irq; for_each_irq_desc(irq, desc) { struct irq_data *data = &desc->irq_data; struct irq_chip *chip = irq_data_get_irq_chip(data); if (!chip) continue; pr_info("irq %d hwirq %lu chip %s domain %s\n", irq,>trace-cmd record -e irq:irq_handler_entry -e irq:irq_handler_exit trace-cmd reportirq_handler_entry会记录 irq 号和 handler 名称,irq_handler_exit会记录执行结果。你可以用它确认某个中断号是否真的触发了 handler、handler 执行了多久、是否返回了 IRQ_NONE。
查看 irq_domain 的映射关系,可以挂载 debugfs:
mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/irq/domains这个文件会把内核里所有irq_domain的层级关系、映射方式列出来。配合设备树里的interrupt-parent,你可以逐一对应,搞清楚外设中断最终是经过哪个 domain 映射到哪个逻辑中断号的。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 中断触发但handler不执行 | irqaction 未正确挂载 | 查看/proc/interrupts该 irq 计数是否增长;检查 request_irq 返回值 |
| 中断风暴,CPU 被打满 | 缺少 ack/mask,或共享中断无人认领 | 看irq_handler_exit的返回;检查芯片回调是否完整 |
| 设置 affinity 不生效 | 控制器不支持精确路由 | 查看effective_affinity,确认实际路由结果 |
cat /proc/interrupts出现 unhandled 计数 | 共享中断 handler 未正确判断自己的设备 | 确认驱动注册时传入的dev_id,并检查 handler 清中断逻辑 |
| 多个中断共用 irq 号导致干扰 | IRQF_SHARED 使用不当 | 检查所有相关驱动是否都注册了共享标志 |
6. 避坑指南与调试心得
6.1 中断风暴的定位与处理
我遇到的绝大多数中断问题,最终都归结为一种形态:中断反复触发,CPU 软锁或负载飙升。原因通常是硬件中断没有被正确清除,或者流处理函数里缺了 ack/mask。
定位时先看/proc/interrupts里哪个 irq 的计数在疯狂增长,然后看它对应的irq_chip是哪个控制器。如果是自己写的控制器驱动,优先检查irq_ack和irq_eoi是否完整。很多新手写 GPIO 中断控制器驱动,只知道 unmask,忘了在irq_ack里清除 pending 状态,结果中断处理完立刻又触发一次,形成死循环。
另一个隐蔽原因是共享中断的 handler 不认领中断却也不清中断状态,内核反复调用所有 handler,全都返回IRQ_NONE,最终触发 spurious interrupt 检测。此时先确认驱动里是否把所有可能产生中断的设备都注册了 handler。
6.2 共享IRQ的注意事项
共享中断在 x86 平台非常常见,PCI 设备经常挤在同一个 irq 上。这里有几个硬性要求:
第一,所有共享这个 irq 的驱动注册时都必须传IRQF_SHARED,否则request_irq()会失败,返回-EBUSY。第二,handler 必须能快速判断“这不是我的中断”,且不能修改其它设备共享的中断状态。第三,free_irq()时传入的dev_id必须和注册时完全一致,否则内核无法从 action 链表中精确删除你这个 action,可能误删别人的。
6.3 关于irq_domain的一些坑
写中断控制器驱动时,我踩过最大的坑是 domain 创建接口选错。如果硬件中断号范围不大且连续,坚持用irq_domain_create_linear()。如果图省事一开始就用 tree 方式,性能虽然不至于崩,但那种“看起来也没问题,数据就是不对”的诡异 bug 往往就藏在查找路径里。
还有层级 domain 下,irq_chip回调里的irq_data不一定是你初始化的那个,它可能是 parent 域的irq_data。回调里如果要访问控制器私有数据,必须用irq_data_get_irq_chip_data()而不是直接拿irq_data->chip_data硬来,否则拿到的指针可能来自上层 domain,解引用直接 panic。
6.4 我的几条实践经验
调试中断问题时,我习惯先开 tracepoint 而不是直接看代码。irq_handler_entry和irq_handler_exit能最快告诉你硬件中断到底有没有进到 Linux 这一层。如果事件都没触发,说明问题在更底层,要么中断号映射错了,要么控制器根本没把中断送上来;如果触发了但 handler 没执行,再去查irqaction链表。
另一个习惯是看/proc/interrupts时留意不同 CPU 列的变化。中断计数集中在一个 CPU 上是正常的,但如果某个 CPU 上毫秒级上涨几万次,基本可以断定有中断风暴。此时先临时把该 irq 的 affinity 移到别的 CPU 上验证,如果新 CPU 也立刻被打满,就是上游触发逻辑的问题,和 CPU 路由无关。
最后再分享一个小技巧:如果用request_threaded_irq()注册线程化中断,handler里尽量只做“判断是不是我的中断、屏蔽本中断、唤醒线程”这三件事,剩下的事全部扔给thread_fn。这样既能保证中断响应速度,又能避免在原子上下文里做长时间操作。我在实际项目中靠这个策略解决过一个触摸屏驱动在中断里做 I2C 读取导致系统卡顿的问题——把 I2C 读取挪到线程化上下文之后,触摸响应反而更流畅了。