3. ARM体系结构基础:ARM Cortex-A系列处理器架构、内存管理单元(MMU)、中断控制器(GIC)

做嵌入式Linux移植,ARM体系结构是绕不开的坎。我刚开始接触这块时,也对着几百页的ARM手册发懵。后来发现,真正干活时你只需要抓住三个核心:处理器架构决定了你怎么跑代码,MMU决定了你怎么管内存,GIC决定了系统怎么响应外部事件。这三样搞明白,剩下的都是查手册的事。

3.1 ARM Cortex-A系列处理器架构

Cortex-A系列是ARM的应用处理器家族,专门跑Linux这类复杂OS。跟Cortex-M(单片机)最大的区别是——它支持MMU,能跑虚拟内存。你想想看,没有MMU的Linux,那还叫Linux吗?

3.1.1 处理器模式与寄存器组

Cortex-A有9种工作模式,但平时你只需要关心其中几个:

模式 缩写 用途
用户模式 USR 跑应用程序,权限最低
超级用户模式 SVC 跑操作系统内核,我最常用的模式
中断模式 IRQ 处理普通中断
快速中断模式 FIQ 处理高优先级中断
系统模式 SYS 特权任务,共享用户寄存器

每个模式都有自己的影子寄存器。比如IRQ模式有自己的R13(SP_irq)和R14(LR_irq),这样中断进来时不用拼命压栈。我在移植早期内核时,就踩过SP没切换对的坑——中断一进来,栈指针还指着用户栈,直接崩了。

3.1.2 异常向量表

ARM的异常向量表固定在0x00000000或0xFFFF0000(由SCTLR.V位控制)。Linux内核一般用高向量模式,也就是0xFFFF0000。每个向量占4字节,放一条跳转指令。

@ 典型的异常向量表布局
0xFFFF0000:  B   reset_handler      @ 复位
0xFFFF0004:  B   undef_handler      @ 未定义指令
0xFFFF0008:  B   svc_handler        @ 系统调用
0xFFFF000C:  B   pabt_handler       @ 预取中止
0xFFFF0010:  B   dabt_handler       @ 数据中止
0xFFFF0014:  B   hyp_handler        @ 超visor模式(可选)
0xFFFF0018:  B   irq_handler        @ 普通中断
0xFFFF001C:  B   fiq_handler        @ 快速中断
我的习惯:在QEMU仿真时,我会在向量表开头放一个调试断点指令(BKPT)。这样只要CPU跑飞了,GDB立刻就能逮住它,省得猜半天。

3.2 内存管理单元(MMU)

MMU是Cortex-A的灵魂。没有它,Linux的进程隔离、虚拟内存、按需分页全玩不转。说白了,MMU就是一张地址翻译表——CPU发出的虚拟地址,经过MMU查表,变成物理地址发给内存。

3.2.1 页表结构

ARMv7-A(Cortex-A8/A9/A15)使用两级页表。一级页表叫L1,二级页表叫L2。每个L1表项覆盖1MB空间,每个L2表项覆盖4KB空间。

/* 一级页表项格式(以段映射为例) */
#define L1_SECTION_MASK     0xFFF00000
#define L1_SECTION_DOMAIN   0x0000001E
#define L1_SECTION_AP       0x00000C00
#define L1_SECTION_TEX      0x00000300
#define L1_SECTION_CACHEABLE  0x00000008
#define L1_SECTION_BUFFERABLE 0x00000004
#define L1_SECTION_PXN      0x80000000  /* 特权不可执行 */

/* 构建一个段映射项 */
unsigned long build_section_entry(unsigned long phys_addr, int cache)
{
    unsigned long entry = phys_addr & L1_SECTION_MASK;
    entry |= 0x00000C02;  /* 域0, 读写权限, 段映射 */
    if (cache) {
        entry |= L1_SECTION_CACHEABLE | L1_SECTION_BUFFERABLE;
    }
    return entry;
}

我在做QEMU的vexpress-a9移植时,就发现MMU初始化顺序特别讲究。必须先建好页表,再开MMU,中间不能有任何内存访问——因为一开MMU,下一条指令的地址就要翻译,页表还没就绪就崩了。

3.2.2 TLB与缓存一致性

TLB是页表的硬件缓存。CPU查过一次虚拟地址后,结果就存在TLB里,下次直接命中,不用再查内存。但问题来了——你改了页表,TLB里的老条目怎么办?

我曾经踩过的坑:在动态映射DMA缓冲区时,只改了页表,忘了刷TLB。结果CPU读的还是旧映射,DMA写完内存后CPU看到的是垃圾数据。排查了两天才发现是TLB作祟。正确的做法是:改完页表后,立即执行TLB失效操作。
/* ARMv7 TLB维护操作 */
/* 失效单个TLB条目 */
static inline void tlb_invalidate_page(unsigned long vaddr)
{
    asm volatile("mcr p15, 0, %0, c8, c7, 1" : : "r" (vaddr));
    dsb();  /* 数据同步屏障 */
    isb();  /* 指令同步屏障 */
}

/* 失效全部TLB */
static inline void tlb_invalidate_all(void)
{
    asm volatile("mcr p15, 0, %0, c8, c7, 0" : : "r" (0));
    dsb();
    isb();
}

3.3 中断控制器(GIC)

GIC是ARM多核系统的中断管家。Cortex-A9用的是GICv1/v2,Cortex-A15/A53开始用GICv3/v4。咱们做QEMU仿真,GICv2最常见。

3.3.1 GIC架构

GIC分两大部分:分发器(Distributor)和CPU接口(CPU Interface)。分发器管所有中断的使能、优先级、目标CPU;CPU接口管当前核的中断屏蔽和应答。

GICv2 架构框图 SPI 外设中断 PPI 私有中断 SGI 软件中断 GIC Distributor 中断使能/屏蔽 优先级配置 目标CPU路由 中断状态跟踪 CPU0 Interface 优先级掩码 中断应答/结束 CPU1 Interface 优先级掩码 中断应答/结束 说明: SPI(共享外设中断):可路由到任意CPU,中断号32~1019 PPI(私有外设中断):每个CPU独享,中断号16~31

3.3.2 中断类型

GICv2定义了三种中断:

  • SGI(软件生成中断):CPU核之间互相发中断,用于核间通信(IPI)。Linux的smp_call_function就靠它。
  • PPI(私有外设中断):每个CPU核独享的中断,比如每个核的本地定时器。
  • SPI(共享外设中断):外设中断,可以路由到任意一个或一组CPU核。串口、网卡、DMA都用这个。

3.3.3 中断处理流程

一个典型的中断处理流程是这样的:

  1. 外设拉高中断信号,GIC分发器检测到并记录。
  2. 分发器根据优先级和目标CPU配置,把中断发给对应CPU的接口。
  3. CPU接口检查当前CPU的优先级掩码,如果中断优先级够高,就向CPU核发送IRQ信号。
  4. CPU核跳转到IRQ向量表,执行中断处理函数。
  5. 处理函数读取GICC_IAR寄存器,获取中断号,然后调用对应的驱动处理函数。
  6. 处理完成后,写GICC_EOIR寄存器,告诉GIC中断处理完毕。
/* GICv2 中断处理核心代码片段 */
void gic_handle_irq(void)
{
    unsigned int irqnr;

    /* 读取中断号,同时应答中断 */
    irqnr = readl(gic_cpu_base + GICC_IAR);
    
    if (irqnr < 16) {
        /* SGI中断,处理核间通信 */
        handle_sgi(irqnr);
    } else if (irqnr < 1020) {
        /* SPI/PPI中断,调用通用中断框架 */
        handle_domain_irq(irqnr, regs);
    } else {
        /* 伪中断(spurious),忽略 */
    }

    /* 写EOIR,结束中断 */
    writel(irqnr, gic_cpu_base + GICC_EOIR);
}

QEMU仿真中的GIC调试技巧:

在QEMU里,你可以用-d int参数打开中断日志。每次中断触发、应答、结束都会打印出来。我曾经靠这个日志,发现一个驱动在中断处理函数里忘了写EOIR,导致中断一直被挂起,CPU没法响应后续中断。日志里就看到连续的中断触发,但没有对应的结束记录。

3.4 本章小结

ARM Cortex-A的这三个核心模块,是嵌入式Linux移植的基石。处理器架构决定了你怎么写启动代码和异常处理;MMU决定了内存怎么布局、怎么隔离;GIC决定了中断怎么分发、怎么响应。在QEMU里做仿真,最大的好处是你可以随时停下来看寄存器状态——MMU的页表项、GIC的优先级配置、TLB的内容,全都可以用GDB读出来。这种调试手段,在真板上可没那么容易。