简介:面向 Armv8-A 架构开发者的 AArch64 汇编完整参考手册,以 PDF 格式收录 Arm 官方 A64 Instruction Set for A-profile architecture 文档,覆盖基础算术、逻辑、访存与分支指令,寄存器组织、寻址模式、条件标志和指令编码格式;涵盖加载/存储、整数与浮点运算、位操作、系统控制等分类,每条指令均给出编码、语法与操作伪代码;并深入讲解 SIMD、可伸缩向量扩展 SVE 等高级特性。资源共 1 个 PDF 文件,压缩包约 9.7MB,为原版英文技术文档,便于离线查阅、按章节检索指令语义与异常处理机制。已有 2978 人学习下载。适合嵌入式开发、系统底层优化、移动端与服务器迁移场景中的开发者,以及希望系统掌握 64 位 ARM 汇编的读者使用;文档还涉及异常级别管理、二进制兼容、ABI 与工具链支持,可帮助理解从常规计算到高性能并行应用的完整指令生态。
1. 这份 A64 指令集手册:arm64 开发者的案头基准
干 arm64 汇编的人,多少都有过这种尴尬:网上教程把一条指令讲得头头是道,真到反汇编核对时,发现连编码格式都和权威手册对不上。aarch64 汇编指令的最终出处只有一份——面向 A-profile 架构的 A64 指令集手册,也就是常被简称为 DDI 0596 的那本。它按字母序把从 ADC 到 LD64B 的全部基础指令摊开,每条都带编码格式、别名关系、flag 影响和异常等级约束,SIMD、SVE、RME、SME 这些扩展也都有对应条目。适合三类人:读内核启动代码的人、写裸机程序的人、以及被隐晦崩溃折腾到失眠的逆向工程师。它解决的核心问题很简单:你缺的不是「某条指令大概什么意思」,而是一份能一字一句校验的权威底稿。
2. 寄存器与基础指令:X0-X30 的组织方式与条件标志传递规则
2.1 寄存器组织的三条铁律
AArch64 的通用寄存器一共 31 个,编号 X0 到 X30,X31 这个位置被拆成了两个身份:上下文里当作基址或栈操作时它是 SP,当作普通操作数时它是 ZR(零寄存器)。这个设计第一次见面会有点绕,但优点是省了一条指令编码位——mov w0, wzr就是最常见的清零操作,汇编器内部把它翻译成ORR w0, wzr, wzr,并没有独立的 mov 编码。
第二条铁律是 X30 承担链接寄存器 LR 的角色。BL指令把返回地址写进 X30,叶子函数可以直接ret,非叶子函数第一件事就是把 X30 压栈。这跟 x86 的 Call 指令把返回地址压栈完全不同,写 A64 汇编时最容易忘的就是这个。
第三条铁律是 W 寄存器规则:W0 到 W30 是 X0 到 X30 的低 32 位视图,但写 W 寄存器会同时把 X 寄存器的高 32 位清零。这是零扩展,不是符号扩展。我见过有人从 AArch32 迁过来,习惯性以为写 W0 只动低 32 位,结果后续用 X0 做 64 位地址时拿到的全是清零后的值,排查半天才发现是这条规则在起作用。
| 寄存器类别 | 编号范围 | 位数 | 职责 |
|---|---|---|---|
| 通用寄存器 | X0-X30 / W0-W30 | 64 / 32 | 整数运算、地址、返回值(X0) |
| 栈指针 | SP | 64 | 栈操作专用 |
| 零寄存器 | ZR / WZR | 64 / 32 | 读为 0,写被丢弃 |
| 链接寄存器 | X30(LR) | 64 | 保存子程序返回地址 |
| 程序计数器 | PC | 64 | 不可直接读写,只能通过 ADR 间接取 |
| 向量寄存器 | V0-V31 | 128 | SIMD、浮点、SVE 数据通路 |
| 条件标志 | NZCV | 4 | PSTATE 中的标志位域 |
条件标志是另一条重要规则。A64 里绝大多数基础指令默认不写标志:ADD、SUB不会动 NZCV,只有ADDS、SUBS、ANDS这类带 S 后缀的变体才更新标志,CMP和CMN分别是SUBS、ADDS的别名。这让编译器少了很多依赖旧标志值的隐式约束,也让手写汇编里的标志依赖全部显式化——查 bug 的时候不用猜上一条指令到底有没有碰 C 标志。
2.2 算术逻辑指令:立即数范围、别名关系与标志位显式化
A64 的ADD、SUB、AND、EOR、ORR基本都有两种形式:立即数形式和移位寄存器形式。立即数不是随意取的,它是 12 位无符号数,可选左移 0 位或 12 位。add x0, x0, #0x1000合法,add x0, x0, #0x1FFF不合法,因为 0x1FFF 超过 12 位,必须先movz再add或者用mov的扩展形式。写汇编时碰到「立即数无法编码」的报错,先别怀疑汇编器,大概率就是这个上限撞上了。
下面这段代码计算 1 到 N 的整数和,N 通过 w0 传入,结果通过 w0 返回,完整演示了ADDS家族和条件分支的配合:
// 计算 1+2+...+N 并返回,N 传入 w0 .globl sum_to_n sum_to_n: mov w1, wzr // w1 = 0,累加器 mov w2, wzr // w2 = 0,计数器 .loop: cmp w2, w0 // 比较计数器与 N,等价于 SUBS w2, w2, w0 的别名 b.ge .done // 若 w2 >= w0 则结束 add w1, w1, w2 // 累加:w1 += w2 add w2, w2, #1 // 计数:w2++,注意这里不置标志 b .loop .done: mov w0, w1 // 返回值放入 w0 ret逻辑说明:cmp w2, w0内部是SUBS,只更新 NZCV 标志,不写目的寄存器;b.ge读取 N 标志和 V 标志的异或结果,判断有符号大于等于。两个add都不带 S 后缀,所以循环体不会意外污染标志,这是 A64 设计上比 AArch32 干净的地方。
参数说明:mov w1, wzr里的wzr就是 WZR 零寄存器,写成mov w1, #0也可以,但wzr写法在汇编器里更常见;b.ge的.loop是局部标签,gas 里用.loop这种点开头的标签可以避免和其他文件的符号冲突。
再看移位寄存器形式:lsl x0, x1, #3其实是LSLV的别名,asr、lsr、ror同理。这在手册里标得很清楚,查指令时看到「an alias of」字段,说明该助记符没有独立编码,它是某个更底层的指令经移位修饰后的展示形式。理解这一点,读 objdump 反汇编输出时会少很多困惑——你写的是cmp,反汇编出来却可能是subs,不是工具坏了,是别名机制在工作。
3. 加载存储与寻址模式:LDR/STR 的四种变址与原子操作边界
3.1 四种寻址形式速查表与 literal 加载的隐藏开销
A64 的普通访存指令有四种寻址形式:无符号立即数偏移、寄存器偏移、预索引、后索引。它们解决不同的场景,写内核代码时四种都会碰到。
| 寻址形式 | 语法示例 | 行为 | 典型场景 |
|---|---|---|---|
| 立即数偏移 | ldr x0, [x1, #8] | 从 x1+8 处加载,x1 不变 | 访问结构体字段 |
| 寄存器偏移 | ldr x0, [x1, x2] | 从 x1+x2 处加载,x2 可为 W 零扩展 | 数组索引 |
| 预索引 | ldr x0, [x1, #8]! | 先 x1 += 8,再加载 | 栈帧分配后立即使用 |
| 后索引 | ldr x0, [x1], #8 | 先加载,再 x1 += 8 | 游标遍历缓冲区 |
注意 A64 没有 x86 那种隐式地址回写,所有要更新基址的行为都必须显式写!或选择后索引形式。我见过有人在ldr x0, [x1, #16]之后直接复用 x1 取下一个字段,拿到的一直是同一地址的数据,这就是没搞清立即数偏移形式根本不动基址寄存器。
literal 加载是另一个常见的坑。ldr x0, =0x1234567890ABCDEF0这种写法是汇编器提供的伪指令,gas 遇到它有两种处理路径:一是生成一条 PC-relative literal 加载,数据放在当前函数的 literal pool 里;二是展开成movz+movk序列。literal pool 的 PC-relative 范围是 ±1MB,如果数据被放到池外或超出范围,链接器会报 relocation truncated,这时候我一般会手动展开成movz+movk:
// 把一个 64 位常量拆成四条 mov 指令,避免 literal pool 依赖 movz x0, #0xDEF0 movk x0, #0xBC9A, lsl #16 movk x0, #0x7856, lsl #32 movk x0, #0x1234, lsl #48逻辑说明:movz把立即数写入寄存器并清零其余位,movk把立即数插入指定位置而保留其他位,通过 lsl 移位量组合出任意 64 位常量。参数说明:立即数范围是 0 到 65535,超出就需要拆到下一轮 movk,lsl 的合法取值是 0、16、32、48。如果你看到编译产物里有连续四条 mov,不要奇怪,这是编译器在避免 literal 池访问。
3.2 独占访存与内存屏障:从 LDAXR/STLXR 到 DMB/DSB/ISB
A64 的原子操作不是 x86 那种 lock 前缀,而是独占访问协议。LDXR标记独占监视,STXR尝试写回,成功返回 0,失败返回非 0。加上 Acquire 语义的LDAXR和 Release 语义的STLXR,就构成了完整的锁原语。下面的自旋锁是 arm64 内核里最常见的小实现:
// 简单自旋锁:0 表示空闲,1 表示占用,x0 为锁地址 spin_lock: mov w1, #1 1: ldxrb w2, [x0] // 独占读入旧值 cbnz w2, 1b // 非 0 说明被占用,重试 stxrb w3, w1, [x0] // 尝试写入 1 cbnz w3, 1b // 写入冲突,重试 ret逻辑说明:ldxrb到stxrb之间构成一个独占事务,任何其他处理器或中断干扰都会让stxrb失败返回非 0,从而跳回重试。cbnz是「非零则跳转」,1b 表示向后跳转到标签 1。参数说明:stxrb第一个目的寄存器 w3 接收是否成功的标志,第二个是源数据,第三个是内存地址。这里有个重要约束:独占访问之间不能穿插过多代码,否则本地监视器可能因为事件而失效,导致锁永远获取不到——这属于写出来的最简单、调起来最玄学的场景。
内存屏障三兄弟在这个场景里必须分清楚。DMB只保证访问的排序,不等待其他操作完成;DSB会等待屏障之前的所有显式访存完成;ISB冲刷流水线,让已经取到的指令全部作废。写驱动时我一般只在需要上下文切换语义时用DSB,普通外设寄存器访问DMB就够。手册里CLREX负责清独占监视,CAS、CASP这类比较交换指令属于 v8.1-Atomics 扩展,不是所有核心都支持。另外手册里还列了LD64B这种单拷贝 64 字节原子加载,以及LDADD、LDCLR、LDEOR这些原子读改写指令,用之前一定要查目标 CPU 的特性寄存器 ID_AA64ISAR0_EL1,确认到位了再上真机,否则就是非法指令。
4. 数据并行与安全扩展:SIMD、SVE 以及 RME/SME 的标注边界
4.1 向量寄存器与 SIMD 指令:后缀语法决定一切
AArch64 的 SIMD 数据通路是 32 个 128 位向量寄存器 V0-V31。一个 V 寄存器可以被看作不同元素宽度的数组:16 个字节、8 个半字、4 个单字或 2 个双字。指令后缀直接决定解释方式,v0.4s表示把 v0 当成 4 个 32 位单精度通道,v0.2d表示 2 个 64 位双精度通道。后缀写错不会报错,只是结果完全不可读,这是 SIMD 汇编最典型的踩坑来源。
手册里浮点相关指令也在这章:FDIV、FMLA、FADD、FMUL等。FMLA是乘加指令,fmul v3.4s, v0.4s, v1.4s完成四路并行乘法,fadd v3.4s, v3.4s, v2.4s再做加法。下面是两个数组的逐元素乘加示例:
// 计算 c[i] = a[i] * b[i] + c[i],每次处理 4 个单精度元素 // x0 = a 地址,x1 = b 地址,x2 = c 地址 loop: ld1 {v0.4s}, [x0], #16 // 载入 4 个 float,地址后移 16 字节 ld1 {v1.4s}, [x1], #16 ld1 {v2.4s}, [x2] fmul v3.4s, v0.4s, v1.4s // 四路并行乘法 fadd v2.4s, v2.4s, v3.4s // 累加 st1 {v2.4s}, [x2], #16 // 写回并后移 subs x3, x3, #4 // 剩余元素计数减 4 b.gt loop逻辑说明:ld1是 SIMD 载入指令,花括号里的{v0.4s}表示向量寄存器组;后索引[x0], #16每轮自动把地址推进 16 字节,正好是 4 个 float 的体积。参数说明:subs带 S 后缀更新标志,b.gt根据 N、Z、C、V 的组合判断大于,继续循环。SVE 出现之前这是 arm64 上做数据并行的主力写法,但现在新平台我更推荐直接考虑 SVE。
4.2 SVE 可伸缩向量与 MTE 标签指令:先查特性位再用
SVE 的核心思想是向量长度由实现决定,而不是由指令集写死。手册里的说法是向量长度可以在 128 到 2048 位之间按实现取定,程序通过读 VL 相关寄存器获知实际值。这意味着同样一份二进制,在支持 512 位向量的 CPU 上会一次处理更多数据,不用重编译。SVE 的谓词寄存器 p0-p15 控制每个通道是否参与运算,WHILELO生成循环谓词:
// SVE 向量加法:z0 = z1 + z2,处理 x0 指定数量的 32 位元素 whilelo p0.s, xzr, x0 // 生成谓词:通道编号小于 x0 的位置为真 loop: ld1w z0.s, p0/z, [x1] // 谓词控制的载入,0 通道清零 ld1w z1.s, p0/z, [x2] add z0.s, z0.s, z1.s // 向量加法 st1w z0.s, p0/z, [x1] incw x1, p0.s // 地址按活性通道数推进 incw x2, p0.s whilelo p0.s, x1, x0 // 重新生成谓词,剩余通道继续 b.ne loop逻辑说明:whilelo比较两个通用寄存器的值,生成一个每通道一比特的谓词掩码;ld1w后的/z表示 zeroing,未激活通道读成 0,还有/m表示 merging 保留旧值。参数说明:p0.s里的.s是通道描述符,对应 32 位元素宽度;incw把通用寄存器按当前激活通道数递增,而不是固定 +4,这正是可伸缩向量和固定宽度 SIMD 的本质区别。
手册里还有一类和内存安全相关的指令,比如ADDG、LDG、STG、GMI、IRG,它们属于内存标签扩展(MTE)体系,用来做内存越界和释放后使用的硬件检测。这类指令同样不是所有核心都有。另外手册把 RME、SME 这类较新特性明确标为 Beta 质量,甚至有个性能监视器接口标为 Alpha——意思是规范里大部分内容已经确定,但细节可能还有调整。我一般看到标注 Beta 的条目,会先去查 CPU 的 ID_AA64PFR0_EL1 和 ID_AA64SMFR0_EL1 特征位,确认支持再写进代码,不留遗憾。
5. 避坑指南:查手册与写汇编时最常见的五个翻车点
5.1 别名指令找不到:CMP 其实没有独立编码
现象:用 objdump 反汇编自己写的汇编程序,发现里面根本没有CMP、CSET、CMN这些助记符,全是SUBS、CSINC、ADDS。
原因:手册里明确写着CMP是SUBS的别名,CMN是ADDS的别名,CSET是CSINC的别名。这些助记符为了可读性存在,但编码层没有自己的位模式。反汇编器默认展示真名,于是你写的cmp w2, w0变成subs wzr, w2, w0。
解决:查手册时看到指令条目里有「an alias of」字样,立即意识到它在真名底下。调试时如果找不到某条指令,试试它的别名真名,这能省下大量搜索时间。
5.2 literal 池溢出:PC-relative 距离超过 ±1MB
现象:编译链接时报relocation truncated to fit,指向一条ldr x0, =...伪指令。
原因:ldrliteral 形式编码里的立即数是 19 位带符号偏移,范围只有 ±1MB。当伪指令对应的常量被放在 literal pool 里且距离超过这个范围,链接器直接拒绝。
解决:换成movz+movk组合手动构造常量,或者用adrp+ 加偏移的方式取符号地址。adrp按 4KB 页对齐取地址,配合:lo12:重定位可覆盖整个寻址空间:
adrp x0, my_data // 取 my_data 所在页的基地址 add x0, x0, :lo12:my_data // 加上页内偏移5.3 AArch32 习惯迁移失败:LDM/STM 在 A64 不存在
现象:把老的 arm32 汇编代码原样搬过来,LDMIA、STMDB全部报错「instruction not supported」。
原因:A64 没有一次加载/存储多个寄存器的指令,只有LDP/STP一次处理两个寄存器,而且要求成对。
解决:改用LDP/STP。函数入口保存 LR 和 FP 的典型写法是:
stp x29, x30, [sp, #-16]! // 先减栈再存储 add x29, sp, #0 // 建立帧指针5.4 标志位玄学:ADD 不置位,读 C 前必须用 ADDS
现象:做无符号加法溢出判断,写完add x0, x0, x1后紧跟b.cs溢出分支,结果从来没跳进去。
原因:A64 里ADD不更新 NZCV,b.cs读取的 C 标志是上一条会写标志的指令留下的旧值,溢出信号被吞了。
解决:改用ADDS x0, x0, x1再b.cs判断进位。带 S 后缀的版本才写标志。同理,要判断借位就用SUBS。
5.5 新扩展指令跑出 UNDEF:特性位没查就上真机
现象:在较老的 v8.0 平台上跑CASP、SVE 或 MTE 相关指令,直接触发未定义指令异常,程序秒挂。
原因:这些指令属于后续扩展,老核心的译码器根本不认识。手册里虽然按字母序列出了它们,但并不代表每个核都支持。
解决:先用一个简单程序读取ID_AA64ISAR0_EL1检查 Atomics 字段、读ID_AA64PFR0_EL1检查 SVE 字段,确认特性位后再使用对应指令。汇编阶段也要对版本做约束,gas 里写.arch armv8.2-a+sve能保证指令集匹配。
6. 进阶用法:用编码表和反汇编器互证,把手册当调试器用
6.1 一条指令从手册到机器码的闭环验证
手册每条指令的 Encoding 章节都给出了位模式,比如 ADD (immediate) 的布局就能在「sf 00 100010 shift imm12 Rn Rd」里找到。不过手工按位拼编码容易出错,更好的做法是让工具生成机器码,再拿手册核对,形成闭环。我一般用 LLVM 的集成汇编器做这件事:
printf 'add x0, x1, #0x10\n' | llvm-mc -triple=aarch64 -show-encoding -show-inst逻辑说明:-show-encoding输出每条指令的机器码字节,-show-inst输出解析后的指令表示。得到字节序列后,把手册中该指令的编码片段和输出对照,imm12 字段、Rn 字段、Rd 字段是否符合预期。反过来也可以做反汇编验证:
printf '0x91004020\n' | llvm-mc -triple=aarch64 -disassemble参数说明:这里的字节串可以用上面汇编得到的实际输出替换,-disassemble会把机器码还原成助记符。如果还原出的指令和你写的完全一致,说明你对编码结构的理解没有偏差。这个方法排查「为什么这条指令行为不对」特别有效——先把指令从二进制里还原出来,看它到底是不是你以为的那条。
查指令时还有一个小技巧:手册里每条指令末尾都有 See also 字段,指向相关的其他条目。顺着CMP的别名链跳到SUBS,再顺SUBS跳到ADDS和ADD,你会发现同一族指令的编码差异就在一个 S 位上。以手册为准、工具为辅,这个习惯能避免大多数汇编层的低级失误。从那以后,我每接触一个新的 arm64 平台,都会强制走一遍「查编码、写汇编、反汇编核对」的循环,希
本文还有配套的精品资源,点击获取