从电平跳变到C函数执行:ARM64外部中断全链路手撕指南
你有没有遇到过这样的时刻?
UART接收中断明明触发了,irq_handler也进了,但ICC_IAR1_EL1读出来却是0x0;
或者更糟——系统跑着跑着突然“静音”,串口没输出、定时器停摆、看门狗也不喂,debugger一连上,发现CPU卡死在eret指令上,SPSR_EL1里M位乱码,ELR_EL1指向一片未初始化内存……
这不是玄学,是ARM64中断链路上某一个环节没对齐——可能是向量表没放对位置,可能是SP_EL1压根没初始化,也可能是GIC Distributor刚使能就急着写ICC_IGRPEN1_EL1,而Redistributor还在sleep状态。
本文不讲Linux内核怎么封装request_irq(),也不复述ARM ARM手册第8章的PDF截图。我们从零开始,在裸机环境下,用汇编搭骨架、用C填血肉、用寄存器说话,把“外部中断从中断引脚电平变化 → CPU响应 → GIC分发 → C函数执行”这条路径,一寸一寸地铺出来。每一步都可验证、可调试、可移植到RK3588、Orin或你手头那块还没跑起Linux的开发板。
向量表不是摆设:它必须精确落在0x200对齐地址上
很多人以为“写个b irq_handler就行”,结果烧录后第一中断就进不了——因为ARM64异常向量表(Exception Vector Table)有铁律:基地址必须是0x200字节对齐(即4KB页内偏移为0),且每个向量入口占0x80字节。这不是建议,是硬件强制要求;错一位,整个EL1 IRQ路径就失效。
为什么是0x200?因为ARM64定义了4组向量(Current EL / Lower EL / Same EL / AArch32),每组4个异常类型(Reset / IRQ / FIQ / SError),共16个向量 ×0x80=0x800字节。而0x200对齐,确保无论当前运行在哪一级EL,硬件都能通过VBAR_ELx寄存器快速索引到对应向量块。
所以你的链接脚本里必须显式声明:
SECTIONS { . = ALIGN(0x200); /* 关键!向量表起始地址必须0x200对齐 */ .vectors : { *(.vectors) } . = ALIGN(0x1000); /* 后续代码按4KB对齐 */ .text : { *(.text) } }然后在汇编中严格布局:
.section .vectors, "ax" .balign 0x200 // 强制对齐到0x200边界 .global vectors_start vectors_start: // Group 0: Current EL with SP_ELx (AArch64) b reset_handler // 0x000 —— 复位向量,必须实现 b undefined_handler // 0x080 —— 未定义指令 b sysreg_handler // 0x100 —— 系统寄存器访问异常 b irq_handler // 0x180 —— 我们真正关心的外部中断入口! b fiq_handler // 0x200 b serror_handler // 0x280 // ... 其余10个向量(省略,但必须存在!)⚠️ 注意:irq_handler必须落在0x180偏移处——不是0x100,不是0x200,就是0x180。这是硬件硬编码的,改不了。如果你把irq_handler标在0x200,那它实际响应的是FIQ,不是IRQ。
eret不是return:它是原子性上下文切换的唯一钥匙
很多裸机教程在irq_handler末尾写ret或bx lr,然后纳闷为什么返回后系统崩溃。真相是:eret指令才是ARM64异常返回的唯一合法方式。
它干了三件事,且必须原子完成:
- 从ELR_EL1加载PC(程序计数器);
- 从SPSR_EL1加载PSTATE(包括DAIF、M域等所有状态位);
- 自动将栈指针切回原EL使用的SP(比如从SP_EL1切回SP_EL0,如果是从EL0被中断)。
这三步缺一不可。你手动mov x30, elr_el1; msr spsr_el1, x0; ret?不行。流水线会乱序,状态不同步,大概率触发SError。
所以你的汇编入口必须这样写:
irq_handler: // 保存x0-x30(除sp外)到EL1栈 sub sp, sp, #256 // 预留256字节空间(31×8 + 一些padding) stp x0, x1, [sp, #0] stp x2, x3, [sp, #16] stp x4, x5, [sp, #32] // ... 一直存到x28, x29, x30(注意x29=fp, x30=lr) mov x0, sp // 把当前栈顶传给C函数 bl do_irq_handler // 调用C层分发器 // 恢复寄存器(顺序与保存严格相反!) ldp x28, x29, [sp, #224] ldp x26, x27, [sp, #208] // ... 依次恢复到x0, x1 add sp, sp, #256 // 栈平衡 eret // 唯一正确的返回方式!✅ 验证方法:在eret前加一句mrs x0, spsr_el1,用JTAG读x0,确认bit[3:0](M域)是0b0101(EL1 AArch64),bit[7](I位)是1(IRQ被屏蔽,正常);eret后立刻再读,I位应恢复为0(已开中断),M域不变。
GICv3不是“配完就能用”的模块:Redistributor醒来比Distributor更重要
GICv3最常被忽略的坑,不在Distributor,而在Redistributor。
Distributor可以配置好就等着发中断,但每个CPU Core的Redistributor,初始状态是WAKER.Sleep=1——它在睡觉。你往GICD_ISENABLER写1,SPI使能了;往ICC_IGRPEN1_EL1写1,CPU说“我准备好了”;但Redistributor闭着眼睛,根本收不到Distributor转发来的中断。
所以初始化顺序必须是:
先让Redistributor醒过来
c writel(0, GICR_BASE + GICR_CTLR); // 确保CTLR初始为0 writel(1, GICR_BASE + GICR_WAKER); // 写1唤醒 while (!(readl(GICR_BASE + GICR_WAKER) & BIT(2))) ; // 等待ACK=1(bit2)GICR_WAKER.ACK置1表示Redistributor已退出sleep,此时它的本地寄存器(如GICR_IPRIORITYR0)才可安全访问。再配置Distributor
c writel(0, GICD_BASE + GICD_CTLR); // 关闭Distributor(安全起见) // 配置SPI#32(UART):level-high, priority=0x0a, enable writel(0x00000002, GICD_BASE + GICD_ICFGR + (32/16)*4); // level-triggered writel(0x0000000a, GICD_BASE + GICD_IPRIORITYR + (32/4)*4); writel(0x00000001, GICD_BASE + GICD_ISENABLER + (32/32)*4); writel(1, GICD_BASE + GICD_CTLR); // 最后打开Distributor最后激活CPU Interface
c write_sysreg(0x00000001, ICC_IGRPEN1_EL1); // 使能Group 1(IRQ) write_sysreg(0x00000000, ICC_BPR1_EL1); // 所有8位都用于preemption isb(); // 关键屏障!确保ICC_*寄存器写入立即生效
💡 经验之谈:如果你的中断始终不触发,readl(GICR_BASE + GICR_WAKER)返回值里ACK位还是0,那别查UART引脚了——Redistributor根本没醒。
ICC_IAR1_EL1读出来是0?先检查EOI是否误写成了IAR
这是现场调试最高频的“幽灵bug”。
ICC_IAR1_EL1(Interrupt Acknowledge Register)的作用是:告诉GIC“我要处理这个中断了,请把它的ID给我,并暂时屏蔽同ID后续中断”。它返回的ID范围是0x000–0x3FF(SPI)、0x400–0x41F(PPI)、0x000–0x00F(SGI)。但如果读出来是0x000,99%的情况不是没中断,而是你之前错误地往ICC_EOIR1_EL1写了0。
因为GICv3规定:ICC_EOIR1_EL1写入的值,必须和之前ICC_IAR1_EL1读出的值完全一致。如果你在上一次中断处理中写了write_sysreg(0, ICC_EOIR1_EL1),GIC会认为“ID=0的中断已结束”,下次ICC_IAR1_EL1就会返回0x000(表示“无有效中断”),哪怕SPI#32早已挂起。
所以你的C分发器必须严格配对:
void do_irq_handler(uint64_t *regs) { uint32_t irqid = read_sysreg(ICC_IAR1_EL1) & 0xffffff; if (irqid == 0) return; // 真正无中断,直接返回 if (irqid < 1024 && irq_table[irqid].handler) { irq_table[irqid].handler(irqid, irq_table[irqid].dev_id); } // ⚠️ 必须写回刚才读到的irqid!不能写死0,不能写错ID! write_sysreg(irqid, ICC_EOIR1_EL1); }🔧 调试技巧:在ICC_IAR1_EL1读取后立刻printf("IAR=%#x\n", irqid),如果总是0x0,立刻检查上一轮EOIR是否写错了;如果有时是0x20(SPI#32),有时是0x0,说明你的驱动在某个分支里漏写了EOIR。
中断延迟不是玄学:它由三段确定性时间构成
在RK3588上实测UART SPI#32中断从引脚上升沿到uart_irq_handler()第一行C代码执行,典型值为1.8μs。这个数字不是测出来的,是算出来的:
| 阶段 | 时间来源 | 典型值(RK3588@2GHz) | 可控性 |
|---|---|---|---|
| GIC传播延迟 | Distributor→Redistributor→CPU Interface信号走线 | < 5ns | 硬件固定,无法优化 |
| CPU异常进入开销 | 保存SPSR/ELR、切栈、跳转向量表 | ~120ns | 由向量表位置、栈缓存命中率决定 |
| 软件处理延迟 | 寄存器压栈(256B)、C函数调用、ICC_IAR1_EL1读取 | ~1.7μs | 完全可控:压栈越少越快;避免在中断里malloc;EOI越早写入,下个中断越早来 |
所以,要压低中断延迟,重点不在“换更快的CPU”,而在:
-精简汇编压栈:只存真正会被C函数修改的寄存器(x0-x7通常够用),其余在C里用volatile约束;
-避免中断嵌套:ICC_BPR1_EL1=0时,新中断必须等当前处理完才能抢占,所以把高优先级中断(如timer)和低优先级(如UART)分开配置;
-EOI写入时机:不要等到整个uart_irq_handler()执行完才写EOI,数据拷贝完、FIFO清空后立即写,释放GIC带宽。
现在,你可以亲手点亮第一个中断了
把以下五段代码粘贴进你的工程,按顺序编译链接:
vectors.S:含.balign 0x200的向量表,irq_handler必须位于0x180;entry.S:irq_handler汇编体,严格stp/ldp,结尾eret;gicv3_init.c:按“唤醒Redistributor→配Distributor→开ICC”三步走;irq.c:request_irq()注册表 +do_irq_handler()分发器,ICC_IAR1/EOIR严格配对;main.c:local_irq_enable()开全局中断,gicv3_init(),然后while(1)等待中断。
接上逻辑分析仪,抓UART RX引脚和CPU的IRQ信号线——你会看到:引脚一抬,IRQ线120ns后拉低;再过1.7μs,UART TX引脚开始吐出响应字符。
那一刻,你不再调用API,你在指挥硬件。
如果你在RK3588上跑通了SPI#32,试试把GICD_IROUTER32写成0x0000000100000000UL,把UART中断路由到CPU1;或者把ICC_PMR_EL1设为0xff,观察高优先级中断如何抢占当前处理——这些不再是文档里的概念,而是你指尖可调的旋钮。
欢迎在评论区贴出你的read_sysreg(ICC_RPR_EL1)读数,或者分享那个让你debug三天的eret陷阱。