前言
如果你是一名嵌入式开发者或者系统程序员,可能会有这样的困惑:「我都用上 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)。
关键特性:
- 部分寄存器访问:当你写 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字)