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 @ 快速中断
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里的老条目怎么办?
/* 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接口管当前核的中断屏蔽和应答。
3.3.2 中断类型
GICv2定义了三种中断:
- SGI(软件生成中断):CPU核之间互相发中断,用于核间通信(IPI)。Linux的smp_call_function就靠它。
- PPI(私有外设中断):每个CPU核独享的中断,比如每个核的本地定时器。
- SPI(共享外设中断):外设中断,可以路由到任意一个或一组CPU核。串口、网卡、DMA都用这个。
3.3.3 中断处理流程
一个典型的中断处理流程是这样的:
- 外设拉高中断信号,GIC分发器检测到并记录。
- 分发器根据优先级和目标CPU配置,把中断发给对应CPU的接口。
- CPU接口检查当前CPU的优先级掩码,如果中断优先级够高,就向CPU核发送IRQ信号。
- CPU核跳转到IRQ向量表,执行中断处理函数。
- 处理函数读取GICC_IAR寄存器,获取中断号,然后调用对应的驱动处理函数。
- 处理完成后,写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读出来。这种调试手段,在真板上可没那么容易。