搞过SoC FPGA的兄弟应该都有这种体会:FPGA端和HPS端各干各的活时,系统跑得挺顺,但一旦两边需要握手协同,中断机制这块就特别容易卡住。尤其是Agilex 5这种较新的平台,资料相对少,网上能搜到的例程大多停留在Cyclone V或者Stratix 10的老思路上。这篇我直接接着之前三篇的进度,把第四篇补完,讲清楚HPS端如何通过Linux中断驱动程序响应FPGA端产生的中断。
如果你是从头开始跟这个系列,前期工作大概是这样的:硬件上已经搭好了FPGA到HPS的中断通路,设备树里也已经把中断节点配好了,裸机环境下验证过FPGA端能拉高中断信号。这篇文章要解决的问题就是:到了Linux系统里,我写一个标准的中断驱动程序,把这个中断接住,并且在驱动里完成对应的业务处理。换句话说,就是把“硬件上电信号能通”升级成“软件上系统能自动响应并跑业务逻辑”。
这套东西适合谁看?正在做Agilex 5 SoC平台开发、尤其是HPS端跑Linux的工程师,以及准备从裸机过渡到Linux驱动开发的FPGA工程师。就算你不用Agilex 5,用的是Cyclone V或Stratix 10,这篇文章的思路也可以直接搬过去,无非就是中断号映射关系略有区别。
1. 中断链路全景图:从FPGA逻辑到HPS内核函数
很多FPGA工程师第一次写Linux中断驱动时会有一个误区:在驱动里request_irq注册一个中断号,就觉得万事大吉。实际上,一个中断从FPGA内部逻辑产生,到最终触发你写的handler函数,中间要穿过好几层硬件和软件关卡。搞清楚这条链路长什么样,排查问题才能有的放矢。
1.1 硬件侧:FPGA逻辑如何把中断信号送进HPS
在Agilex 5内部,FPGA fabric和HPS之间通过一组叫做F2H(FPGA to HPS)的桥接信号互联。中断信号不在AXI总线上跑,而是走一组独立的专用中断线,通常叫f2h_irq或者类似的命名,具体要看你在Platform Designer(也就是以前的QSys)里怎么连的。
这组中断线在硬件层面会接到HPS内部的GIC(Generic Interrupt Controller,通用中断控制器)上。GIC就是HPS侧所有中断的汇聚点,你可以把它想象成公司前台:所有外部来电(中断请求)先打到前台,前台根据来电类型分配到不同的分机(CPU core)上去。
这里有个关键点:F2H中断线在GIC里属于SPI(Shared Peripheral Interrupt,共享外设中断)类型,不是PPI(Private Peripheral Interrupt,私有外设中断)。SPI可以被多个中断源共享,而且它可以从GIC路由到任何一个CPU核心,这个特性在做多核负载均衡时会用到。PPI则是每个CPU核心私有的,比如每个核自己的定时器中断,跟FPGA中断没关系。
我在实际项目中看到不少人在这个环节踩坑:在设备树里中断号填错了,填成PPI的编号范围。要记住,FPGA中断一定走SPI这条路。
1.2 软件侧:Linux内核里中断是怎么路由到驱动函数的
中断信号到达GIC之后,GIC会对中断进行优先级仲裁,然后选择一个CPU core,通过IRQ线触发中断。CPU收到中断后,会跳转到异常向量表执行中断处理入口,Linux内核在入口处保存现场,然后调用GIC驱动提供的处理函数,GIC驱动查询中断号,最终调用到你注册的那个handler。
用大白话讲:GIC好比一个总机,Linux内核里的irq domain负责查分机号,而你写的驱动里那个中断处理函数,就是最终接电话的人。这一条链路中任何一环断了,中断就进不来。
驱动层面你需要关心的重点是:如何拿到一个正确的Linux IRQ号,然后把这个IRQ号和你写的handler绑定。Linux IRQ号不是设备树里填的那个数字,它是由内核的irq domain动态分配的,正常情况下驱动代码里不直接写死数字,而是通过platform_get_irq()这类API从设备树节点里解析出来。
提示:不要在驱动里硬编码中断号。我在调试时见过有人图省事,在驱动里直接写irq = 168,结果换了设备树或者换了芯片版本,根本对不上。正确做法永远是运行期从设备树解析。
2. 设备树里中断信息的配置思路与陷阱
设备树在SoC Linux开发里的地位,相当于PCB的网表——它描述了硬件“长什么样”。你FPGA里生成了什么外设、HPS有哪些中断源,都必须告诉Linux内核,内核才知道去哪里找到这些资源。
2.1 interrupt-parent和interrupts属性的含义
设备树里描述中断核心就两个属性:interrupt-parent和interrupts。前者指定你的设备挂在哪个中断控制器下,后者描述中断的具体信息。
比如你有这样一个FPGA侧的中断源节点:
fpga_irq_controller: fpga-irq@0 { compatible = "altr,fpga-irq"; interrupt-parent = <&intc>; interrupts = <0 126 4>; };这里interrupt-parent = <&intc>表示该设备的中断接到intc这个中断控制器上,也就是HPS的GIC。GIC在socfpga.dtsi里是提前定义好的,你只需要引用它就行。
interrupts = <0 126 4>这3个数字的含义是:第一个数字0表示这是一个SPI类型中断(GIC的SPI编号从32开始,所以第0个spi就是GIC的32号中断,需要减去基址才能得到Linux的IRQ号。在Altera/Intel的GIC设备树描述里,0代表SPI),第二个数字126是这个SPI在GIC里的编号,第三个数字4表示触发电平类型(Linux的device tree标准里,4代表高电平触发,8代表低电平触发,1代表上升沿触发,2代表下降沿触发,13和14是双沿触发)。
在Altera官方手册里,F2H中断线在GIC中的分配是固定的,从IRQ 96到IRQ 111(对应SPI号),也就是FPGA中断的编号通常是96~111这个区间。如果你的设计里用了F2H_IRQ[0],在SPI编号里对应的可能是某个固定数字,这个一定要查你所用芯片的HPS Technical Reference Manual,不同型号的Agilex 5变体可能不同,千万别想当然。
2.2 中断号的计算与常见偏差
设备树里写的<0 126 4>,驱动里用platform_get_irq()拿到的IRQ号是多少?这里有个容易搞混的地方。
在标准的ARM GIC设备树实现中,interrupts属性里SPI的中断号是以“从32开始”的绝对编号标示。例如GIC硬件上有SPI 94,设备树里写0 94 4。Linux irq domain的GIC驱动会把设备树里的SPI编号减去32(因为GIC内部把SPI的硬件中断号从32开始编号,SGI占0~15,PPI占16~31),映射到Linux内部的IRQ号上。
所以你在设备树里写interrupts = <0 126 4>,驱动里拿到的IRQ号是126 - 32 = 94。我看到过有人直接在驱动里写“我的中断号是126”,然后request_irq(126, ...),结果死活触发不了,这就是没搞清楚这个映射关系。
在实际调试中,我推荐大家在probe函数里把这个irq号打印出来,比如:
dev_info(&pdev->dev, "irq = %d\n", irq);然后用cat /proc/interrupts查看实际生效的中断号,两下一对,就能确认你的设备树配置和驱动代码是否匹配。
2.3 设备树里中断触发方式的匹配问题
第三个数字是触发类型,这个别看它不起眼,搞错了中断完全不通或者中断风暴。FPGA端的逻辑设计决定了实际中断信号是电平有效还是边沿有效,设备树里必须如实描述。
比如你在FPGA内部设计了一个中断发生器,给HPS的信号是“高电平有效”的中断请求,那么在设备树里就应该写4。如果你的FPGA逻辑产生的是一个短暂的高脉冲,你在设备树里就应该写8(低电平有效)或者写1/2(边沿触发),关键是要和实际硬件信号吻合,不要照抄别人的设备树。
这个信息一定要在硬件设计阶段就跟FPGA工程师对齐。我在项目中就遇到过:FPGA工程师实现的是高电平有效的中断请求,设备树里却配了8(低电平有效),结果IRQ一直处于触发状态,系统起来之后CPU占用率直接飙到100%,因为中断风暴了。最后查了一圈,改设备树触发方式,五分钟解决。
3. 中断驱动程序的结构设计与核心代码实现
进入正题,开始设计中断驱动程序。
3.1 驱动的整体架构选择
在Linux里写中断驱动的框架有好几种选择:传统的miscdevice/chardevice、platform_driver、以及基于设备树的新型驱动模型。对于SoC FPGA这种平台,我从头到尾推荐使用platform_driver。
原因有三点:
第一,platform_driver和设备树天然契合,驱动里的probe函数会在设备树匹配成功后自动调用,省去了手动加载时的资源注册流程。
第二,platform_driver内置了电源管理框架的支持,后续如果需要做suspend/resume,直接实现对应的回调函数就行,不用另外折腾。
第三,platform_get_resource、platform_get_irq这些API专门为平台设备设计,从设备树解析硬件信息非常方便。
驱动的基本骨架如下:
static const struct of_device_id fpga_irq_of_match[] = { { .compatible = "altr,fpga-irq" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, fpga_irq_of_match); static struct platform_driver fpga_irq_driver = { .probe = fpga_irq_probe, .remove = fpga_irq_remove, .driver = { .name = "fpga_irq", .of_match_table = fpga_irq_of_match, }, }; module_platform_driver(fpga_irq_driver);注意compatible字段必须和设备树里写的字符串一致,否则probe函数压根不会被调用。我自己经常犯的错误是改了设备树忘记改驱动,或者反过来,结果模块加载半天设备树匹配不上。
3.2 probe函数:申请中断前的准备工作
probe函数是驱动的入口,相当于硬件上电后的初始化流程。在这里我们要完成:获取设备树里的硬件信息、注册中断处理函数、初始化业务相关的数据结构。
static int fpga_irq_probe(struct platform_device *pdev) { struct fpga_irq_dev *dev; struct device *dev_node = &pdev->dev; int irq; int ret; // 分配私有数据结构 dev = devm_kzalloc(dev_node, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->dev = dev_node; platform_set_drvdata(pdev, dev); // 从设备树解析中断号 irq = platform_get_irq(pdev, 0); if (irq < 0) { dev_err(dev_node, "failed to get irq: %d\n", irq); return irq; } dev->irq = irq; dev_info(dev_node, "IRQ number = %d\n", irq); // 注册中断处理函数 ret = request_irq(irq, fpga_irq_handler, IRQF_TRIGGER_HIGH, "fpga_irq", dev); if (ret) { dev_err(dev_node, "failed to request irq %d: %d\n", irq, ret); return ret; } // 初始化工作队列(用于下半部) INIT_WORK(&dev->work, fpga_irq_work_handler); dev_info(dev_node, "fpga_irq driver probed successfully\n"); return 0; }关于request_irq的flags参数,我要多说一句。第3个参数是中断标志,常见的有IRQF_TRIGGER_HIGH、IRQF_TRIGGER_LOW、IRQF_TRIGGER_RISING、IRQF_TRIGGER_FALLING、IRQF_SHARED等。这些标志必须和设备树里配置的触发方式保持一致,系统启动阶段会统一校验。
IRQF_SHARED比较特殊,表示这个中断线可以被多个设备共享。如果你的中断控制器设计支持共享中断,那么所有注册这个中断号的驱动都必须在request_irq时带上IRQF_SHARED,否则注册会失败。同时,中断处理函数需要额外判断中断是否来自自己的设备,不是就直接返回IRQ_NONE。
还有一个小小的经验:不建议在probe里做太多耗时操作,比如初始化大块内存或者启动DMA。这种活儿放到工作队列或者单独的内核线程里去做会更稳。
3.3 中断处理函数的编写:上半部与下半部的拆分
中断处理函数是整个驱动里对性能要求最高的地方,因为它运行在中断上下文中,有什么规矩必须严守:不能睡眠、不能调用可能导致睡眠的函数(比如mutex_lock、kmalloc(...,GFP_KERNEL))、不能做太耗时的计算。
中断处理函数的标准姿势是“上半部快速响应,下半部慢速处理”。上半部在中断上下文里只做最必要的操作:清中断标志、读取必要的硬件状态、然后触发下半部执行。
下半部可以选的机制有tasklet、工作队列(workqueue)、以及中断线程化(threaded IRQ)。我个人的推荐优先级是:工作队列 > tasklet > threaded IRQ,尤其在你需要在下半部里访问I2C/SPI这类可能睡眠的总线设备时,工作队列是最稳妥的。
static irqreturn_t fpga_irq_handler(int irq, void *data) { struct fpga_irq_dev *dev = data; // 清中断标志:向FPGA端写一个寄存器,告诉FPGA“中断收到了” // 这里的地址是你的FPGA逻辑里映射到HPS地址空间的寄存器 // 具体操作根据你的硬件设计而定 // writel(0x1, dev->clear_reg); // 如果有必要,读FPGA端的状态寄存器 // dev->status = readl(dev->status_reg); // 调度工作队列处理业务逻辑 schedule_work(&dev->work); return IRQ_HANDLED; }这里最关键的一步是清中断标志。如果FPGA端的中断信号一直保持拉高,而你处理完之后没有把它清掉,GIC会认为中断一直没有被处理,于是会反复进入中断处理函数,形成中断风暴。轻则CPU占用率飙升,重则系统假死。
我在设计中习惯的做法是:FPGA逻辑里有一个“中断状态寄存器”,状态位写1表示有中断;驱动处理完业务后,往同一个寄存器的对应位写1来清除中断状态。FPGA检测到清中断操作后,就会拉低中断信号。这个“写1清0”的方式是硬件设计的经典做法,比“写0清1”在安全性上更好,能避免误操作。
工作队列里的处理函数才是真正干重活的地方:
static void fpga_irq_work_handler(struct work_struct *work) { struct fpga_irq_dev *dev = container_of(work, struct fpga_irq_dev, work); // 这里可以放心地睡眠、访问I2C/SPI、做复杂计算 // 比如读取FPGA里的采集数据 dev_info(dev->dev, "FPGA interrupt processed.\n"); }有一点值得注意:schedule_work并不保证立刻执行,它只是把工作挂到系统默认的工作队列上。所以如果你的业务对实时性有硬性要求,建议使用自己的高优先级工作队列或者内核线程。但对大多数FPGA中断处理场景来说,默认工作队列足够了。
3.4 remove函数:驱动的退出清理
remove函数在模块卸载时被调用,主要做和probe相反的事情:释放中断、取消工作、释放资源。
static int fpga_irq_remove(struct platform_device *pdev) { struct fpga_irq_dev *dev = platform_get_drvdata(pdev); // 等待可能正在执行的工作队列处理完 cancel_work_sync(&dev->work); // 释放中断 free_irq(dev->irq, dev); // 其他资源释放交给devm框架自动处理 return 0; }这里有个细节:cancel_work_sync必须在free_irq之后调用,或者说至少要保证时序上不能有新的中断再来调度这个work了。如果先free_irq再cancel,最坏情况下free_irq有可能要等当前正在执行的中断处理函数返回,而那个函数可能正在等待work完成,于是形成死锁。我在调试时有一次系统卸载模块直接卡死,就是这个顺序搞反了。
4. 中断驱动的编译、加载与验证流程
代码写完了,下一步是把它编译成内核模块,加载到目标板上跑起来。
4.1 交叉编译环境准备与Makefile编写
编译内核模块必须依赖目标平台的内核源码树(或者至少得有内核头文件和生成好的构建信息)。在Agilex 5这种SoC上,通常是先从Intel官网下载对应的Linux内核源码,然后交叉编译。
Makefile的写法我贴一下,这个是可以直接改改就能用的:
obj-m := fpga_irq.o KERNEL_DIR := /path/to/your/kernel/source CROSS_COMPILE := aarch64-linux-gnu- ARCH := arm64 all: $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) modules clean: $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) cleanAgilex 5的HPS是ARM架构,所以ARCH写arm64,交叉编译工具链前缀通常是aarch64-linux-gnu-,具体看你的工具链安装路径。编译过程中会执行内核的Kbuild系统,如果KERNEL_DIR和工具链配置正确,会生成fpga_irq.ko模块文件。
编译过程中容易遇到的问题是内核源码没有先配置或者没有先编译过。Kbuild会检查内核源码树的状态,如果没有生成过Module.symvers或者没有配置过,你需要先到内核源码目录执行make ARCH=arm64 socfpga_defconfig(或者你们项目自己的配置),再执行make scripts之类的准备步骤。
4.2 加载模块与验证中断是否生效
把编译好的fpga_irq.ko拷贝到目标板,以root权限执行:
insmod fpga_irq.ko加载成功后,dmesg里应该能看到类似“fpga_irq driver probed successfully”的日志。如果报错,把dmesg末尾的完整日志拿来看,最常见的几个报错后面我会整理成表格。
接下来验证中断是否真实可用,我建议分两步走:
第一步,查看/proc/interrupts:
cat /proc/interrupts找到你的中断号对应的一行(可以对照probe函数打印出来的irq号),此时中断计数器是0或者很小的值。
第二步,在FPGA端触发一次中断。如果你的FPGA逻辑里有一个软件可控制的触发寄存器,你可以用devmem工具往那个地址写一个值,让FPGA拉高中断信号。如果没有这样的寄存器,那就在FPGA的调试工具里强行拉一下信号。
触发之后,再次查看/proc/interrupts,如果对应的中断计数增加了,说明中断链路是通的,驱动已经成功响应对应的中断事件。如果计数没变,那就有得排查了。
dmesg里也会打印“FPGA interrupt processed”,能看到这个日志就说明从硬件到驱动工作队列整个链路都跑通了。
4.3 使用devmem手动触发中断的调试技巧
在实际调试里,手动触发中断是高频操作。这里分享一个我自己在用的方法:在FPGA逻辑里保留一个32位的控制寄存器,比如offset 0x100,把它映射到某个地址,驱动或者用户态通过devmem写这个寄存器,就可以模拟FPGA内部事件产生中断。
例如:
devmem 0xff200100 32 1这条命令往地址0xff200100写一个1,FPGA逻辑检测到这个写操作后拉高中断信号,HPS侧就能收到中断。用这种方式调试驱动,完全不需要反复综合下载FPGA工程,调试效率提升非常明显。
这个思路其实也提醒我们:在FPGA设计阶段,就预留一些软件可控的测试寄存器,对后续软硬件联调有极大的帮助。
5. 常见问题与排查技巧实录
开发过程中我把能踩的坑基本都踩了一遍,下面把这些实际问题和排查思路整理出来,方便遇到类似情况时快速定位。
5.1 中断号与设备树不匹配导致注册失败
现象:insmod的时候,probe函数里面打印的IRQ号明显不对,或者request_irq返回-EINVAL(无效参数)。
排查思路:首先确认设备树节点和驱动匹配上了——dmesg里有没有probe的日志?如果连probe都没进去,先检查compatible字符串是否一致。如果probe进去了但request_irq失败,看设备树里的interrupt-parent是否指向了正确的GIC节点。在Agilex 5的设备树里,intc节点一般长这样:
intc: interrupt-controller@fffc1000 { compatible = "arm,gic-400"; reg = <0xfffc1000 0x1000>, <0xfffc2000 0x2000>, <0xfffc4000 0x2000>, <0xfffc6000 0x2000>; interrupt-controller; #interrupt-cells = <3>; };如果interrupt-parent写错成别的控制器,那IRQ号自然对不上。
5.2 中断风暴与CPU占用率飙升
现象:系统刚起来,CPU占用率就100%,dmesg里大量重复的中断日志,或者/proc/interrupts里某个中断号的计数飞快增长。
排查思路:这几乎可以肯定是中断触发方式配置错误或者清中断标志的逻辑有问题。先用cat /proc/interrupts找到疯狂增长的那个中断号,然后用cat /proc/irq/<序号>/trigger查看触发方式(某些平台支持动态修改触发方式),确认设备树里的触发类型和FPGA实际输出的信号一致。
还有一个可能:FPGA端的中断信号在驱动清掉之后没有被真正拉低。这时候需要回看FPGA逻辑里中断状态寄存器的清除逻辑。常见错误是清的是“屏蔽寄存器”而不是“状态寄存器”。在硬件设计上,状态寄存器写1清0,而不是写0清0,这个要提前约定好。
5.3 中断处理函数里调用函数导致崩溃
现象:中断触发后系统直接oops或者整个系统卡死。
排查思路:中断上下文里不能睡眠。很多人在handler里顺手调用了printk(尤其是有循环打印情况)、mutex、kmalloc(...,GFP_KERNEL),这些都有可能导致睡眠或者触发内核警告。printk偶尔用一下问题不大,但如果你的handler执行时间太长,会在中断上下文里抢占太多CPU时间,影响系统实时性。
还有一个隐蔽的问题:在handler里访问了尚未映射的寄存器地址,导致page fault。操作硬件寄存器之前,确保地址已经在probe里通过ioremap映射好了。
下面我把常见问题整理成速查表:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| probe没有执行 | compatible不匹配 | 检查设备树节点compatible和of_match_table |
| request_irq返回-EINVAL | 中断号无效或flags冲突 | 确认platform_get_irq返回值,检查IRQF_SHARED |
| 中断不触发 | 设备树触发类型错误 | 确认与FPGA实际信号一致 |
| 中断计数不增长 | 中断被屏蔽或路由错误 | 检查GIC配置和CPU亲和性 |
| 系统启动后CPU 100% | 中断风暴,清中断逻辑错误 | 检查FPGA侧清状态寄存器逻辑 |
| 卸载模块时卡死 | free_irq和cancel_work顺序错误 | 确保free_irq在cancel_work之后 |
5.4 中断响应延迟过高
现象:FPGA端触发中断后,HPS侧的handler过了几百微秒甚至几毫秒才跑起来。
这个在Linux系统里其实是常态,特别是对于非实时场景。中断响应时间受多个因素影响:当前CPU是否在关中断的临界区、是否有更高优先级的中断在处理、GIC的优先级配置、CPU负载等。
如果你的应用对延迟极其敏感,有几个方向可以优化:
第一,在设备树里给这个中断设置较高的GIC优先级。GIC优先级越低数字数值越小优先级越高(数值小优先级高),这个在设备树的interrupts属性里可以通过第四个字段配置,但只有GIC支持额外的优先级字段时才行。Agilex 5的GIC-400支持。
第二,把中断绑定到某个专用CPU核上。可以通过设备的interrupts-affinity属性或者驱动里的irq_set_affinity_hint实现。
第三,考虑用PREEMPT_RT内核补丁,把系统变成软实时。对于很多数据采集类的应用,这算是比较彻底的方案。
但在做这些优化之前,建议先用irqsoff或者ftrace测一下中断延迟的实际分布,不要盲目优化。很多时候瓶颈根本不在中断路径上,而在下半部的工作队列里。
6. 其他使用建议与个人经验
写驱动这几年,我最大的一个感触是:中断驱动本身代码量不大,框架搭好之后真正花时间的往往是联调。FPGA工程师改一版逻辑,HPS工程师这边就得跟着调设备树、调驱动、重新验证。所以这里有几个建议,能帮你省不少事。
第一,设备树里尽量把中断节点单独出来,不要和别的功能节点绑在一起。这样改中断配置时不用动其他外设,减少回归测试范围。
第二,驱动里多打印一些上下文信息。不只是打印irq号,建议把触发中断时FPGA侧的状态寄存器内容一起打印出来。很多时候一次中断来了,但FPGA那边不止一个事件的bit被置位,你要能看出来是哪一路触发的。这些信息对定位问题非常有用。
第三,如果你的FPGA工程还处于频繁迭代阶段,建议在驱动里加一个debugfs或者sysfs节点,可以从用户态手动触发一次中断,也可以读当前的中断计数。这样每次综合完都不需要专门跑到FPGA工具里去手动触发。
还有一点是在中断处理函数里做统计。我的习惯是在handler里用kstat_irqs或者自己维护一个计数器,区分“硬件中断次数”和“工作队列实际处理次数”。有时候会发现中断来了很多次,但工作队列只跑了几次,这说明中断合并或者丢失了,需要进一步排查。
Agilex 5的中断体系和之前Altera SoC FPGA相比,GIC这层的处理逻辑基本是一致的,但桥接层的时钟和复位配置有所变化。如果你是从Cyclone V迁移过来的,建议重新核对一下Platform Designer里F2H中断线的连接方式,不要直接沿用老工程的配置。
最后提一个跟本文主题稍微有点偏离但很实用的建议:在写中断驱动之前,强烈建议先在裸机环境(比如U-Boot或者简单的裸核程序)里把中断通路验证一遍。裸机环境没有Linux那些复杂的层级,能最快定位硬件链路的问题。等裸机确认信号能通能清,再上层就安心了。我见过太多Linux驱动调不通,最后发现是FPGA侧中断信号压根没拉出来的情况——这种问题放到Linux环境里去查,效率真的不高。