Linux Device Drivers-第十章 中断处理
第十章 中断处理当外部硬件希望获得处理器对他的关注时就发一个信号叫中断当然处理器需要为自己管理的设备的中断注册一个处理程序。有一点需要关注本质上中断处理例程与其他代码并发运行不可避免引入并发问题竞争结构和硬件。可以参考本书第五章内容10.1 准备并口原文中为了实际体验中断需要准备一个并口但这对我来说已经过时了。10.2 安装中断处理例程设备中断通过硬件中断信号线通知处理器所以中断信号线是珍贵且有限的资源。内核中维护了一个中断信号线的注册列表模块在使用中断前需要先请求一个中断通道或中断请求IRQ使用完毕后释放该通道。在很大场合模块也会希望和其他驱动程序共享中断信号线。在 6.12 版本内核linux/interrupt.h中声明的函数实现了该接口staticinlineint__must_checkrequest_irq(unsignedintirq,irq_handler_thandler,unsignedlongflags,constchar*name,void*dev){returnrequest_threaded_irq(irq,handler,NULL,flags|IRQF_COND_ONESHOT,name,dev);}void*free_irq(unsignedint,void*);request_irq返回 0 成功负表示错误返回-EBUSY表示另一驱动程序已经占用了你要请求的中断信号线。参数解释unsigned int irq要申请的中断号内核分配的虚拟中断号非硬件中断号typedef irqreturn_t (*irq_handler_t)(int, void *)要安装的中断处理函数指针unsigned long flags与中断管理有关的位掩码选项在include/linux/interrupt.h中以IRQF_开头的宏定义第一类中断触发方式是上升沿还是下降沿。第二类中断处理行为如共享中断单次中断禁止线程化等dts中的IRQ_TYPE_LEVEL_HIGH配置也会转化成IRQF_TRIGGER_HIGH标志传递给驱动。const char *name传递给request_irq的字符串用于在/proc/interrrupts中显示中断的拥有者。void *dev在很多内核文档和源码中也常被称为dev_id在编写 Linux 驱动时强烈建议将代表该设备的结构体指针作为dev参数传入。作为释放中断的唯一凭证且能在中断处理函数中传递设备私有数据。10.2.1/proc接口/proc并不占用磁盘空间它是内核内存状态的直接映射是内核状态的窗口/proc已经从过去的单纯查看中断计数工具转变为**“性能调优与故障诊断的核心入口”**了。用于排查硬件与驱动问题的第一现场。10.2.1.1/proc/interrupts用于观察中断在各个 CPU 核心上的分布情况。示例rootimx8mp:~# cat /proc/interruptsCPU0 CPU1 CPU2 CPU3[中断控制器][硬件中断号][触发类型][设备名]11:5201752397321542426904578831GICv330Level arch_timer14:3361486907943743331843655GICv379Level timer306a000015:0000GICv334Level 30bd0000.dma-controller16:0000GICv358Level30860000.serial19:7000GICv3139Level 30bb0000.spi20:0000GICv3180Level 32f10100.usb85:0000gpio-mxc12Edge 30b50000.mmccd121:0000gpio-mxc14Edge User Button1227:0000PCI-MSI0Edge PCIe PME244:0000PCI-MSI524288Edge uiodma_efd_irq IPI0:5646271252334520408Rescheduling interrupts IPI1:850910176163616017211124581Function call interrupts IPI2:0000CPU stop interrupts IPI3:0000CPU stop NMIs IPI4:642461358178533719616936Timer broadcast interrupts IPI5:1695504964309752769713252IRQ work interrupts IPI6:0000CPU backtrace interrupts IPI7:0000KGDB roundup interrupts Err:0Linux 虚拟中断号虚拟IRQ numberLinux 内核为了兼容各种不同的硬件架构比如 x86 的 APIC、ARM 的 GIC设计了一套统一的中断管理框架。内核会把各种五花八门的硬件中断号映射成自己内部统一管理的、从 0 开始连续分配的虚拟编号。CPUx Columns每个 CPU 核心处理该中断的累计次数。这是现代系统调优的重点。中断控制器名称GIC (Generic Interrupt Controller)PCI-MSI(Message Signaled Interrupts)硬件中断号(Hardware IRQ Number) GIC 硬件内部用来识别该中断的编号。.dts文件的interrupts属性根据文档配置也是Hardware IRQ。根据 ARM 官方规范IHI0069B_GICv3v4_architecture_specificationINTIDs 小节有如下表格中断 ID (INTID) 范围中断类型官方定义与说明0 - 15SGI (Software Generated Interrupt)软件生成中断。通常用于多核 CPU 之间的通信即你看到的 IPI 中断。(仅限于CPU接口内用)16 - 31PPI (Private Peripheral Interrupt)私有外设中断。某个 CPU 核心“私有的”。比如每个 CPU Core 都有自己的本地定时器这个定时器的中断只能由该 Core 自己处理其他 Core 插不了手。32 - 1019SPI (Shared Peripheral Interrupt)共享外设中断。所有 CPU 核心共享的外部设备如 UART、USB、网卡等产生的中断。芯片厂家出场时会将外设与特定SPI硬件绑定参见10.5 中断共享。(Distributor 可将 SPI 路由至特定处理单元PE或路由至系统中任何作为参与节点的PE)1020 - 1023Special Interrupt number特殊用途如表示中断结束等。1024 - 8191Reserved8192LPI (Locality-specific Peripheral Interrupt)GICv3 新增特性主要用于 PCIe MSI/MSI-x 等基于消息的中断。中断触发类型Level代表关注电平状态只要信号线维持在高电平或低电平中断就会一直存在CPU就需要持续处理该中断通过.dts中interrupts属性可以看出是与之相对的是Edge边沿触发代表关注瞬间变化它只在意信号从低变高上升沿或从高变低下降沿的那一瞬间最后一列如arch_timer注册该中断的设备/驱动名称。相较于原书2.6版本内核早期 Linux 倾向于在CPU0上处理中断以最大化缓存局部性。但这样做在如今高并发场景会导致单核CPU占用率飙升而其他核心却闲置。现代 Linux 允许我们通过/proc/irq/接口来手动干预这种分配比如看到arch_timer中断如果在某个核心上中断数量过高就可以手动干预一下查看绑定cat /proc/irq/[IRQ号]/smp_affinity修改绑定通过向/proc/irq/[IRQ号]/smp_affinity写入位掩码可以将特定硬件中断绑定到指定的CPU核心。例如将网卡中断绑定到专用核心。极大减小缓存污染提升吞吐量。最后几行核间通信 IPI (Inter-Processor Interrupts)IPI 的计数能反映出你系统的多核协作频率。如果 IPI 异常高可能意味着系统存在严重的锁竞争或调度压力。这是现代多核系统独有的、非常硬核的诊断指标。10.2.1.2/proc/stat相比于interrupts的细致/proc/stat提供的是系统启动以来的宏观统计。示例rootimx8mp:~# cat /proc/statcpu84007774584860415788498903920218393000cpu02309847114741508553399001796213411000cpu124708313531151038021256771771855000cpu220177411155151107781283870121784000cpu316022229687151156741458370491341000intr5061893907887369090660033016536235878000021226415009033515000447807000002440000491127964240700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000037000000000000000000000000000000000000000000000058061065212007728754290000461440164109407451015908460017000000000000002475000000000000000000000000000000000ctxt54402845btime1779440703processes5642procs_running1procs_blocked0softirq2282100785659544201916473470026301001110245285200105CPU行单位jiffies从左到右依次为user用户态执行时间, nice调整优先级用户进程时间, system内核态执行时间驱动代码算这里, idle空闲时间, iowait, irq 硬中断处理时间, softirq 软中断处理时间, steal被虚拟机偷走的时间, guest, guest_nice。intr关键字第1个值 → 所有中断总数后面 → 每个 IRQ 的计数ctxt上下文切换总次数btime系统启动时间单位秒secondsprocesses创建过多少个 task_structprocs_running当前运行进程数用户 内核线程procs_blocked阻塞进程数用户 内核线程softirq软中断统计是/proc/softirqs所有值的总和跨类型 跨CPU结构TOTAL HI(高优先级 softirq) TIMER(内核 timer_list 定时器) NET_TX(网络收包最重要) NET_RX(网络发包) BLOCK(块设备) IRQ_POLL TASKLET(基于 softirq 的 lightweight 机制) SCHED(调度器) HRTIMER(高精度定时器) RCU(RCU回收)。[!IMPORTANT]/proc/interrupts更适合实时排查比如使用watch-n1cat /proc/interrupts可以动态观察某个中断更新频率是否异常。/proc/stat更适合历史总量分析它记录了所有处理器上的汇总数据。如果你需要计算系统运行期间的总中断负载或者编写监控脚本统计长期趋势解析intr行会更加方便。10.2.2 自动检测 IRQ 号Virtual IRQ书中提到的 IRQ 号指的是Linux内核层面的虚拟中断号在早期古老的x86ISA 总线时代硬件中断线非常有限且固定如并口固定用 IRQ 7内核的虚拟 IRQ 号和底层的硬件物理中断号往往是一一对应的所以当时写驱动常常把两者混为一谈。但现在复杂的 SoC如 ARM64 GIC中驱动程序申请、注册和探测的始终是经过内核抽象后的虚拟 IRQ 号。10.2.2.1 自动检测 IRQ 号从“暴力探测”到“设备树宣告”书中介绍了三种获取 IRQ 的方法在现代内核中它们的地位发生了翻天覆地的变化根据 I/O 地址猜中断书中根据并口地址硬编码猜测中断号现代观点严禁在驱动代码中硬编码任何硬件资源驱动必须与硬件描述解耦。设备自己“宣告”中断书中提到的 PCI 标准书中做法读取 PCI 配置空间来获取中断号。现代观点驱动不需要去“猜”或“探测”中断号。驱动只需要在probe()函数中通过标准的内核 APIplatform_get_irq()向内核申请虚拟IRQ内核代码会将设备树中描述的硬件中断号解析并映射为虚拟中断号最终返回随后这个虚拟中断号会给到request_irq使用。内核./drivers/tty/serial/amba-pl011.c中就有这两个函数主动探测Probe内核协助与 DIY这种方法在现代嵌入式驱动中已极少使用。这种“暴力探测”不仅效率低需要触发硬件、延时等待而且非常危险容易干扰其他设备且不支持现代普遍的中断共享机制。10.2.3 快速和慢速处理例程书中花费大量篇幅讲解的“快速中断”与“慢速中断”的区别在现代内核中已经几乎不复存在。废弃概念早期的“快/慢中断”区分及SA_INTERRUPT标志已退出历史舞台。现代最佳实践为了降低中断延迟现代驱动极力避免在硬中断上半部中做耗时操作。对于稍微复杂的处理应使用中断线程化Threaded IRQs技术将耗时逻辑移交到内核线程中执行从而实现极高的系统实时性和吞吐量。10.3 实现中断处理例程10.3.1 处理例程的参数及返回值中断处理例程中中断发生时有几个有用的参数传递给了中断处理例程很有用教你怎么看。书中大部分内容已经过时了我结合Linux最新发展总结一下有价值的地方结合串口中的中断处理函数作为例子./drivers/tty/serial/amba-pl011.cstaticirqreturn_tpl011_int(intirq,void*dev_id){uart_port_lock(uap-port);......uart_unlock_and_check_sysrq(uap-port);returnIRQ_RETVAL(handled);}遵循“快进快出”原则中断上下文严禁睡眠。不能使用非GFP_ATOMIC标志的内存分配如kmalloc、不能获取可能引起睡眠的信号量Semaphore。不能与用户空间交互。只做“清除中断标志、读取少量核心状态、唤醒中断下半部”这三件事。善用dev_id传递私有数据务必在注册时将设备结构体指针通过dev_id传入以便在中断处理函数中无锁、高效地访问设备资源。返回处理状态必须返回IRQ_HANDLED已处理或IRQ_NONE非本设备中断以便内核正确处理共享中断和虚假中断。现代内核中通常直接返回这两个枚举值或者使用return IRQ_RETVAL(条件)宏。拥抱中断线程化对于有耗时处理需求的现代驱动优先使用request_threaded_irq()将复杂的业务逻辑交给内核线程下半部去安全执行。下面会讲到。10.3.2 启用和禁用中断尽量不用严禁作为互斥锁在 SMP 多核系统中关中断只能锁住当前 CPU无法防止其他核心的并发访问。保护共享资源请始终使用自旋锁Spinlock或互斥锁Mutex。单中断禁止disable_irq属于高风险操作极易引发共享中断冲突或死锁。在现代驱动中应交由内核电源管理子系统PM自动处理驱动层尽量避免手动调用。全局本地中断禁止local_irq_\*仅用于保护极短时间的、跨中断上下文和进程上下文的本地 CPU 临界区。务必使用local_irq_save(flags)和local_irq_restore(flags)配对以确保中断状态的完美嵌套恢复。接口更新相关 API 统一位于linux/interrupt.h和linux/irqflags.h不再使用古老的asm/irq.h或asm/system.h。10.4 顶半部和底半部中断处理函数要完成一定量的工作但速度必须要快为了满足这两个看似矛盾的需求内核开发者把中断处理过程分成了两部分顶半部用于快速响应中断就是request_irq注册的中断例程。底半部被顶半部调度在稍后更安全的时间内执行的例程。Linux内核提供了两种机制①tasklet ②工作队列 ③软中断。已经在本书第七章介绍过了。10.4.1 tasklet softirq 的封装简化版详情请看本书【第7章 7.5 tasklet机制】。在 Linux 6.9 内核中随着 Workqueue工作队列子系统的改进特别是引入了 BH 下半部工作队列支持内核维护者们终于找到了 Tasklet 的完美替代品。Linus Torvalds 和内核开发者们多次在邮件列表中明确表示希望看到 Tasklet 彻底从内核中消失并正在积极推动这一“灭绝”进程。他有如下缺陷无法利用多核SMP 扩展性差同一个 Tasklet 在任何时刻只能在一个 CPU 上运行。这意味着即使你的系统有 8 个核心同一个设备的 Tasklet 也只能占满其中 1 个核无法并行处理严重限制了高吞吐量设备的性能。运行在软中断上下文不能睡眠Tasklet 运行在软中断Soft IRQ上下文中这属于原子上下文没有进程描述符。绝对不允许睡眠、不能调用可能引起阻塞的函数如分配内存不能用GFP_KERNEL不能使用互斥锁 Mutex。这给驱动开发带来了极大的限制和风险稍有不慎就会导致系统崩溃或死锁。容易引起系统卡顿由于软中断的优先级极高如果 Tasklet 处理稍微耗时就会严重延迟其他软中断甚至用户空间程序的执行导致系统响应变慢。10.4.2 工作队列 kthread 队列线程池模型详情请看本书【第7章 7.6 工作队列】。工作队列运行在进程上下文工作队列百分之百由内核线程kworker来执行kworker 就是内核的打工线程工作队列就是它的待办清单用于处理复杂耗时的任务工作队列可以休眠可用阻塞。但不能从工作队列向用户空间复制数据。10.5 中断共享在传统的老式 PC 架构如 ISA、早期的 PCI 总线中物理中断线IRQ的数量极其有限通常只有 16 条。为了能让越来越多的外设网卡、声卡、显卡等都能通知 CPU大家被迫挤在同一条中断线上导致共享中断非常普遍。而在ARM64平台上使用的是先进的GICv3通用中断控制器。GICv3 支持成百上千个 SPI共享外设中断号中断资源非常丰富。再加上现代总线如 PCIe普遍支持 **MSI / MSI-X消息信号中断**技术。MSI 不再依赖物理引脚而是设备直接向内存写入一个特定的数据包来触发中断这使得每个设备甚至设备的每个功能都能轻松分配到独立的中断号从根本上消灭了“抢线”的必要性。SPI共享外设中断的共享体现在哪er啊这个“共享”指的并不是“多个设备挤在同一条中断线上”那是我们上一轮聊的共享中断而是指“这条中断线可以被路由分发给多个 CPU 核心中的任意一个来处理”。它打破了外设与特定 CPU 核心的绑定关系可以被灵活地路由给系统中的任意 CPU 核心去处理。在 SMP对称多处理系统中这种机制非常有利于操作系统的负载均衡。GICv3 提供了海量的 SPI 中断号通常从 32 号一直排到 1019 号甚至更多。芯片设计厂商比如 NXP、Rockchip在设计 SoC 时会把 UART0、UART1、SPI、I2C、USB 等成百上千个外设一对一地连接到 GIC 的不同 SPI 编号上。因为资源极其丰富每个外设都能分到一条专属的“VIP 通道”比如 UART0 独占 32 号UART1 独占 33 号大家井水不犯河水。所以虽然它们都叫“共享外设中断SPI”但在物理连接上它们绝大多数时候都是独占一条中断线并不需要和其他设备“挤在一起”。在以前的通用计算机上操作系统经常需要靠“猜”或者 BIOS 的模糊分配来给外设分中断。但在嵌入式 SoC 中所有的硬件拓扑结构都是固定且已知的。通过设备树DTSLinux kernel 在启动时就极其精确的知道每一个外设UART, SPI, I2C 等物理上连接到了GIC哪一个中断号上既然硬件连接固定内核就可以为每个外设静态分配专属虚拟中断VIRQ自然不需要共享了。我在 IMX8mp 板子上发现的唯一的一个共享中断最后一项有逗号分隔# cat /proc/interrupts212:772000irqsteer0Level 32fd8000.hdmi, dw-hdmi-cec物理本质这个特例32fd8000.hdmi, dw-hdmi-cec属于**“同一物理设备内部的功能共享”**。HDMI 控制器和 CEC消费者电子控制用于电视遥控联动等功能模块在芯片内部往往是封装在同一个物理 IP 核里的或者它们共用同一根物理中断信号线连接到 GIC。驱动注册在 Linux 内核中HDMI 主驱动和 CEC 子系统驱动是两个相对独立的软件模块。当它们初始化时都会去请求同一个硬件中断号。两个驱动在调request_irq()时都需要声明IRQF_SHARED标志当硬件触发中断时内核会依次调用 HDMI 驱动和 CEC 驱动的中断处理函数ISR每个 ISR 内部会通过读取硬件寄存器来判断“这次中断是主 HDMI 产生的还是 CEC 产生的”如果不是自己的就返回IRQ_NONE。10.6 中断驱动的 I/O 例子10.6.1 打印机写缓冲区示例 (经AI整理)我们将整个过程分为三个角色写数据的人用户进程、干活的工人工作队列、报信的中断与定时器。角色一用户进程负责把数据搬进缓存当你在应用程序里调用write()时驱动里的shortp_write函数会被触发。它的任务不是直接操作硬件而是把数据先存到内存环形缓存里。// 用户进程调用 write() 时执行while(writtencount){// 1. 检查缓存有没有空位spaceshortp_out_space();// 2. 【关键点】如果缓存满了进程就“挂起”睡觉阻塞等待有空位再醒来if(space0){wait_event_interruptible(shortp_out_queue,...);}// 3. 把用户的数据拷贝到驱动的环形缓存里copy_from_user(...);writtenspace;// 4. 如果之前没有在往外发数据就启动“干活工人”工作队列if(!shortp_output_active){shortp_start_output();}}解读这一步实现了“缓存”。写操作非常快因为只是把数据从用户内存拷贝到驱动内存并没有去管慢吞吞的打印机。角色二干活的工人负责把缓存的数据发给硬件shortp_start_output会启动一个工作队列shortp_work承担中断下半部角色这个工人负责每次从缓存里拿一个字节发给打印机。// 工作队列函数负责实际把数据搬运给硬件承担了下半部的角色voidshortp_work_handler(...){spin_lock_irqsave(...);// 加锁保护数据// 1. 如果缓存空了说明活干完了if(shortp_out_headshortp_out_tail){shortp_output_active0;// 标记停止工作wake_up_interruptible(shortp_empty_queue);// 唤醒可能在等待“发完”的人del_timer(shortp_timer);// 删掉防丢包定时器}// 2. 如果还有数据就发一个字节else{shortp_do_write();// 调用下面的硬件操作函数}// 3. 既然发了一个字节缓存肯定有空位了唤醒之前因为缓存满而睡觉的“用户进程”if(有足够空间){wake_up_interruptible(shortp_out_queue);}spin_unlock_irqrestore(...);}// 真正操作硬件端口发数据的函数staticvoidshortp_do_write(void){// 1. 复位定时器防止误判中断丢失mod_timer(shortp_timer,jiffiesTIMEOUT);// 2. 往打印机数据端口写一个字节outb_p(*shortp_out_tail,shortp_baseSP_DATA);// 3. 更新缓存指针并给打印机发一个“选通”信号告诉打印机数据来了请接收outb_p(cr|SP_CR_STROBE,...);outb_p(cr~SP_CR_STROBE,...);}解读工人每次只发一个字节然后停下来等。为什么要停因为打印机很慢发太快它会处理不过来。角色三报信的中断与定时器打印机处理完那一个字节后会通过硬件线路给 CPU 发一个中断信号下面是中断信号对应的中断处理函数上半部你可以看到上半部会启动下半部再次把“干活的工人”工作队列拉起来干活。// 中断处理函数打印机喊“我处理完了给我下一个”// 该函数是通过 request_irq 注册给硬件中断号的绝对不能睡眠或阻塞必须“快进快出”属于上半部staticirqreturn_tshortp_interrupt(...){if(!shortp_output_active)returnIRQ_NONE;// 核心动作再次把“干活的工人”工作队列拉起来干活// 工人起来后会执行上面的 shortp_work_handler发下一个字节queue_work(shortp_workqueue,shortp_work);returnIRQ_HANDLED;}如果打印机“嗓门太小”中断信号丢了怎么办为了防止死机驱动在发数据时会设置一个定时器。// 定时器超时函数专门处理“中断丢失”的情况staticvoidshortp_timeout(...){// 1. 去查打印机的状态寄存器statusinb(shortp_baseSP_STATUS);// 2. 如果打印机还在忙说明没丢中断只是它太慢了重置定时器继续等if((statusSP_SR_BUSY)0){mod_timer(shortp_timer,jiffiesTIMEOUT);return;}// 3. 如果打印机显示“空闲/就绪”说明它早就处理完了但是中断信号丢了// 手动调用中断处理函数强行让系统继续发下一个字节shortp_interrupt(shortp_irq,NULL,NULL);}解读这是一个“兜底”机制。正常情况靠中断驱动异常情况靠定时器轮询状态来补救保证系统永远不会因为漏掉一个信号而卡死。总结整个流程是怎么跑起来的用户扔给驱动一大包数据驱动把它存进缓存然后喊一声“工人开工”。工人工作队列属于下半部 从缓存拿 1 个字节给打印机然后设个闹钟定时器就去休息了。打印机慢吞吞地打完这 1 个字节发个中断喊“打完了”。中断处理程序顶半部开始执行把工人工作队列属于下半部叫醒。工人醒来再拿 1 个字节给打印机重置闹钟继续休息。如果闹钟响了工人还没被叫醒中断丢失定时器就会直接踹工人一脚让他起来干活。