中断控制器:GIC 架构与中断类型详解

说到中断处理,GIC(Generic Interrupt Controller)是 ARM 体系里绕不开的核心部件。我最早接触 GIC 是在做一款多核网络处理器的时候,当时被 SPI、PPI、SGI 这些缩写搞得晕头转向。后来亲手调过几轮中断亲和性、踩过电平触发的坑,才算真正摸透了它。

说白了,GIC 就是 CPU 和中断源之间的“总调度员”。它负责接收各个外设发来的中断信号,按优先级排队,再分发给一个或多个 CPU 核心去处理。没有它,多核系统的中断就乱套了。

核心要点:GIC 把中断分为三种类型——SPI、PPI、SGI,每种类型服务于不同的场景。触发方式则分为电平触发和边沿触发,选错了会出大问题。

GIC 架构概览

GIC 从 v2 发展到 v4,但基本框架没变。以最常用的 GICv2 为例,它由两部分组成:

  • Distributor(分发器)——负责中断的使能、优先级配置、目标 CPU 选择。我习惯把它想象成“路由表”。
  • CPU Interface(CPU 接口)——每个 CPU 核心独享一份,负责把中断信号送给对应的核心。

你想想看,一个外设触发中断,信号先到 Distributor,Distributor 查表决定“这个中断该给哪个核”,然后通过 CPU Interface 通知那个核。流程很清晰,对吧?

GICv2 架构示意图 外设中断源 软件触发中断 核间中断 Distributor 中断使能 优先级配置 目标 CPU 选择 中断分组 触发方式配置 CPU Interface 中断确认 中断完成 优先级掩码 抢占控制 运行优先级 IRQ/FIQ CPU Core 每个 CPU 核心拥有独立的 CPU Interface,Distributor 负责全局中断路由决策

三种中断类型

GIC 把中断分成三类,我当年刚接触时觉得这分类有点多余,后来才发现——每种类型都有它不可替代的用途。

中断类型 ID 范围 目标 典型用途
SPI(Shared Peripheral Interrupt) 32 ~ 1019 可路由到任意一个或多个 CPU 外设中断(UART、I2C、GPIO 等)
PPI(Private Peripheral Interrupt) 16 ~ 31 固定绑定到某个 CPU 本地定时器、性能监测单元
SGI(Software Generated Interrupt) 0 ~ 15 由软件写入触发,可定向到指定 CPU 核间通信、IPI

SPI——共享外设中断

这是最常用的中断类型。板子上的 UART、I2C、SPI、GPIO 等外设,默认都走 SPI。它的特点是“共享”——一个外设的中断线可以被路由到任意一个 CPU 核心。

我在项目中遇到过一个问题:两个 CPU 核心同时收到了同一个 SPI 中断,导致处理函数重入。后来查手册才发现,SPI 虽然可以路由到多个核心,但实际使用中最好只配给一个核心,或者用中断亲和性绑定到特定核。否则你要自己处理重入保护。

实战建议:多核系统中,把 SPI 中断绑定到 CPU0,其他核专心跑业务。如果某个核负载高,再动态调整亲和性。我习惯在 /proc/irq/ 下查看和修改中断亲和性掩码。

PPI——私有外设中断

PPI 是每个 CPU 核心“独享”的中断线。同一个中断号,在不同核心上可以对应不同的物理中断源。最典型的例子就是每个核心的本地定时器。

你想想看,如果本地定时器用 SPI,那所有核心的定时器中断都挤在一个号上,根本分不清是哪个核的定时器到期了。PPI 完美解决了这个问题——每个核有自己的定时器中断,互不干扰。

注意:PPI 的 ID 范围是 16~31,但具体哪个 ID 对应哪个功能,由芯片厂商决定。换芯片时一定要重新看 datasheet,别想当然。

SGI——软件触发中断

SGI 是“写寄存器就能触发”的中断。你往 GIC 的 SGI 触发寄存器里写一个值,就能让指定 CPU 核心收到一个中断。这玩意儿在 Linux 内核里用得特别多,比如 TLB 刷新、进程调度、CPU 热插拔等场景。

我记得有一次调试多核死锁问题,就是用 SGI 在某个核心上触发一个调试中断,然后 dump 出那个核的寄存器状态。没有 SGI,这种调试手段根本没法实现。

/* Linux 内核中发送 SGI 的示例(简化) */
static void send_sgi_to_cpu(unsigned int cpu, unsigned int sgi_id)
{
    unsigned long flags;
    void __iomem *gic_dist_base = get_gic_dist_base();

    raw_spin_lock_irqsave(&gic_lock, flags);

    /* 写入 SGI 触发寄存器 */
    writel_relaxed(1UL << cpu, gic_dist_base + GIC_DIST_SGI + (sgi_id * 4));

    raw_spin_unlock_irqrestore(&gic_lock, flags);
}

中断触发方式

触发方式就两种:电平触发和边沿触发。但选错了,后果很严重。我见过一个同事因为把电平触发配成了边沿触发,导致按键中断只能响应一次,按第二次就没反应了。

触发方式 信号特征 优点 缺点
电平触发(Level-triggered) 中断信号保持高/低电平,直到被清除 不会丢失中断;适合共享中断线 必须及时清除,否则反复触发
边沿触发(Edge-triggered) 检测到上升沿或下降沿时触发一次 适合脉冲信号;不会因电平持续而重复触发 容易丢失中断(脉冲太窄时)

电平触发

电平触发的中断,只要外设把中断线拉高(或拉低),GIC 就会一直认为有中断 pending。直到 CPU 处理完中断,通知外设把电平拉回去,中断才会消失。

这种方式的优点是“可靠”——只要电平还在,中断就不会丢。但缺点也很明显:如果你在中断处理函数里忘了清除中断源,或者清除顺序不对,中断会立刻再次触发,形成死循环。

我曾经踩过的坑:做 I2C 控制器驱动时,用了电平触发。中断处理函数里先清除了 GIC 的中断标志,再去操作 I2C 控制器清除中断源。结果 GIC 看到中断线还拉着,又触发了一次中断。正确的顺序应该是:先清外设中断源,再清 GIC 中断标志。

边沿触发

边沿触发只关心信号跳变的那一瞬间。上升沿来了,触发一次;下降沿来了,再触发一次。信号保持高电平或低电平都不会重复触发。

这种方式的优点是“干净”——不会因为电平持续而反复进中断。但缺点是:如果脉冲宽度太窄,CPU 还没来得及响应,脉冲就消失了,这个中断就丢了。

我建议:对于 GPIO 按键、外部事件触发这类场景,用边沿触发比较合适。对于需要可靠传输的外设(比如 DMA 完成中断),用电平触发更稳妥。

如何选择触发方式

这里没有绝对的标准,但我个人有几个经验:

  • 电平触发:适合中断源需要 CPU 主动确认的场景,比如 DMA 传输完成、FIFO 非空。中断处理函数里必须清除外设的中断源。
  • 边沿触发:适合脉冲式信号,比如按键按下、外部事件跳变。注意脉冲宽度要足够宽,至少大于几个 CPU 时钟周期。
  • 共享中断线:多个设备共用一根中断线时,必须用电平触发。边沿触发无法区分是哪个设备触发了中断。
一句话总结:电平触发靠得住,但处理要小心;边沿触发很干净,但脉冲别太短。