前言
如果你是一名嵌入式开发者或者系统程序员,可能会有这样的困惑:「我都用上 C/C++ 甚至 Rust 了,为什么还要学汇编语言?」
这个问题我被问过很多次。2018 年我在做一个手机相机 HAL 层的性能优化项目时,算法团队用 NEON intrinsics 写的图像处理函数在骁龙 845 上跑 12ms,离 8ms 的目标还差很远。我花了三天时间,把核心循环改成纯 AArch64 汇编,最终跑到了 6.8ms——这就是汇编的力量:你完全掌控了 CPU 的每一个周期、每一个寄存器。
今天,AArch64 已经无处不在:从你的智能手机到树莓派 4/5,从 AWS Graviton 服务器到 NVIDIA Jetson 开发板,甚至苹果 M 系列芯片本质上也是 AArch64 兼容架构。理解 AArch64 架构,不仅能让你写出更快的代码,更能让你真正理解现代 CPU 是如何工作的。
这篇文章不会教你「Hello World」级别的汇编入门。我要做的是:从架构设计哲学讲起,深入寄存器模型、指令集、寻址模式、函数调用约定,最后用实战项目教你如何写出高性能的 AArch64 汇编代码。
一、ARMv8-A 架构:从 32 位到 64 位的革命
1.1 ARM 架构演进简史
在深入 AArch64 之前,我们先快速回顾一下 ARM 架构的演进路线:
- ARMv4/v5 (1990s): 经典 ARM,32 位 ARM 状态 + 16 位 Thumb 状态
- ARMv6 (2001): 引入 SIMD 媒体处理扩展、未对齐内存访问
- ARMv7-A (2005): Cortex-A 系列诞生,NEON SIMD、虚拟化扩展
- ARMv8-A (2011): 革命性的 64 位架构,AArch64 执行状态
- ARMv8.1-A ~ ARMv8.5-A: 持续增强,加入 SVE、指针认证、内存标记等
- ARMv9-A (2021): SVE2、机密计算架构 (CCA)、更多安全增强
很多人误以为 ARMv8-A 「就是 64 位的 ARMv7」——这是完全错误的。ARMv8-A 不是在 ARMv7 基础上「加了几根地址线」,而是几乎重新设计了整个指令集架构。
1.2 两个执行状态:AArch32 与 AArch64
ARMv8-A 最独特的设计就是支持两个独立的执行状态:
| 特性 | AArch32 | AArch64 |
|---|---|---|
| 通用寄存器 | 15 个 (R0-R14) | 31 个 (X0-X30) |
| 寄存器宽度 | 32 位 | 64 位 |
| 指令集 | ARM (32位) / Thumb (16位) | A64 (固定 32 位) |
| 异常级别 | PL0/PL1/PL2 | EL0/EL1/EL2/EL3 |
| 栈指针 | R13 (共用) | SP_EL0 / SP_EL1 / SP_EL2 / SP_EL3 |
| 程序计数器 | R15 | PC (不直接作为通用寄存器) |
这里有个非常重要的设计决策:AArch64 状态下的程序计数器 (PC) 不再是通用寄存器。在 ARMv7 中,你可以直接对 PC 进行算术运算、用 PC 做基址寻址——这种灵活性带来了很多巧妙的编程技巧,但也严重限制了 CPU 的乱序执行优化。ARMv8 的设计者果断砍掉了这个特性,换来了更大的性能提升空间。
1.3 AArch64 的设计哲学
如果你仔细对比 AArch64 和 x86-64,会发现它们走了完全不同的路线:
x86-64 的路线:向后兼容到极致。32 位 x86 程序可以直接在 64 位模式下运行,甚至可以在同一个进程中混合 32 位和 64 位代码。代价是:指令编码极其复杂,解码器占用大量芯片面积。
AArch64 的路线:干净利落地切断包袱。AArch32 和 AArch64 是两个几乎独立的世界,异常级别之间的切换是唯一的桥梁。代价是:旧的 32 位应用必须重新编译才能运行。好处是:指令集可以从零开始优化,没有历史包袱。
这就是为什么 AArch64 汇编看起来如此「干净」——没有 x86 那些奇奇怪怪的指令后缀,没有复杂的 ModR/M 编码,所有指令都是固定 32 位宽度,字段位置高度规律。
1.4 ARMv8-A 异常级别模型
ARMv8-A 用 异常级别 (Exception Level, EL) 取代了传统的特权级模型,这是一个非常重要的架构概念:
- EL0: 用户态 (User),运行应用程序
- EL1: 内核态 (Kernel),运行操作系统内核
- EL2: 虚拟机监控器 (Hypervisor),运行虚拟化管理程序
- EL3: 安全监控器 (Secure Monitor),负责安全世界和普通世界切换
这个模型的巧妙之处在于:级别编号越高,特权越大。这和很多人直觉中的「Level 0 最高」正好相反。ARM 设计者的理由是:异常发生时,CPU 总是进入更高的 EL,从 0 往上加很自然。
每个 EL 都有自己独立的栈指针 (SP_ELn)、异常向量表、以及一整套系统寄存器。这种隔离设计是 ARMv8-A 安全架构的基石。
二、AArch64 寄存器模型:31 个寄存器的艺术
2.1 通用寄存器组
AArch64 有 31 个通用寄存器,编号 X0 到 X30。每个寄存器可以被当作 64 位使用 (Xn),或者当作 32 位使用 (Wn)。
X0 (W0) X8 (W8) X16 (W16) X24 (W24)
X1 (W1) X9 (W9) X17 (W17) X25 (W25)
X2 (W2) X10 (W10) X18 (W18) X26 (W26)
X3 (W3) X11 (W11) X19 (W19) X27 (W27)
X4 (W4) X12 (W12) X20 (W20) X28 (W28)
X5 (W5) X13 (W13) X21 (W21) X29 (W29) FP (帧指针)
X6 (W6) X14 (W14) X22 (W22) X30 (W30) LR (链接寄存器)
X7 (W7) X15 (W15) X23 (W23)
关键特性:
- 部分寄存器访问:当你写 Wn 寄存器时,高 32 位会被自动清零。这和 x86-64 完全不同(x86-64 写 32 位寄存器也会清零高 32 位,但这是后来加的)。
mov w0, #0x1234 // X0 = 0x0000000000001234
mov x0, #0x5678 // X0 = 0x0000000000005678
- 零寄存器 XZR/WZR:这是 AArch64 最巧妙的设计之一——寄存器编号 31,根据上下文不同,它可以是栈指针 (SP),也可以是永远返回 0 的零寄存器。
mov x0, xzr // X0 = 0,比 mov x0, #0 更高效
add x0, x1, xzr // X0 = X1,变相的 mov 指令
str xzr, [x2] // 向地址 X2 写入 0
零寄存器让很多指令变得更加简洁。比如你不需要专门的 nop 指令——mov x0, x0 就是 nop,而 orr x0, xzr, xzr 也是 nop。
- 程序计数器 (PC):PC 不再是通用寄存器,你不能像 ARMv7 那样
add pc, pc, #4。但 PC 仍然可以被某些寻址模式隐式引用,比如 ADR 指令和字面量加载。
2.2 特殊寄存器
栈指针 SP:每个异常级别有独立的栈指针。AArch64 的栈总是 16 字节对齐的——这是硬性要求,不对齐的 SP 访问会触发异常。
链接寄存器 LR (X30):保存函数返回地址。BL 指令会自动把返回地址写入 LR。
帧指针 FP (X29):虽然 AArch64 没有强制要求帧指针,但 GCC、Clang 等编译器在非优化模式下都会用 X29 作为 FP。
过程调用结果寄存器 X8:用于间接调用的返回地址,以及某些特殊的系统调用。
2.3 浮点与 NEON 寄存器
AArch64 的浮点/SIMD 寄存器组也是一大亮点:32 个 128 位寄存器 V0-V31。
每个寄存器可以被访问为:
- Bn: 8 位 (字节)
- Hn: 16 位 (半精度浮点)
- Sn: 32 位 (单精度浮点)
- Dn: 64 位 (双精度浮点)
- Qn: 128 位 (NEON 向量)
fadd s0, s1, s2 // 单精度浮点加法
fadd d0, d1, d2 // 双精度浮点加法
add v0.4s, v1.4s, v2.4s // NEON:4个单精度同时相加
32 个 128 位寄存器意味着什么?你可以在寄存器里放下 32 个双精度数,或者 128 个单精度数——对于很多内核级算法来说,这意味着整个循环的中间数据都可以放在寄存器里,完全不需要访问内存。
(第一部分完,约2400字)