ARM64汇编
寄存器
通用寄存器
| 寄存器 | 位宽 | 别名 | 用途 |
|---|---|---|---|
X0-X30 |
64 位 | - | 通用数据存储、地址计算、函数参数/返回值 |
W0-W30 |
32 位 | X0-X30 的低 32 位 |
32 位操作时使用 |
- 特殊用途:
- **
X0-X7**:函数参数传递(第 1-8 个参数)和返回值 - **
X8**(XR):间接返回值寄存器(如返回大结构体时存放地址) - **
X9-X15**:临时寄存器(调用者保存) - **
X16-X17**(IP0,IP1):临时寄存器,供链接器在函数调用间隙使用 - **
X18**:平台保留(某些操作系统如 Android 用作 TLS 寄存器,Thread-Local Storage) X19-X28:通用寄存器(被调用者保存)- **
X29**(FP):帧指针(Frame Pointer) - **
X30**(LR):链接寄存器(Link Register),保存函数返回地址
- **
浮点寄存器
| 寄存器类型 | 位宽 | 数量 | 用途 |
|---|---|---|---|
S0-S31 |
32 位 | 32 个 | 单精度浮点(float) |
D0-D31 |
64 位 | 32 个 | 双精度浮点(double) |
Q0-Q31 |
128 位 | 32 个 | SIMD/向量运算(如 NEON) |
S/D/Q是同一组寄存器的不同视图:S0是D0的低 32 位,D0是Q0的低 64 位
特殊寄存器
XZR / WZR零寄存器(Zero Register):读取总是 0,写入无效果非常有用!
sp寄存器(Stack Pointer):保存栈顶地址
fp寄存器(x29):Frame Pointer,保存栈底地址
lr寄存器(x30):Link Register,存放函数的返回地址
pc寄存器: Program Counter,保存当前正在执行的指令的地址(注:在 AArch64 中,你不能像 ARM32 那样直接读写
PC寄存器)
cpsr寄存器(状态寄存器)
CPSR (Current Program Status Register) 记录程序运行时的状态
最常见的是NZCV标志位,们通常由 CMP 指令或数据处理指令(如 ADDS/SUBS)设置,用于决定后续条件跳转(如 B.EQ)或条件执行(如 CSEL)的行为
- N:Negative Condition Flag,代表运算结果是负数(即最高位为 1)
- Z:Zero Condition Flag,代表运算结果为 0
- C:Carry Condition Flag, 进位标志
ADD:无符号运算产生上溢出时 C 为 1SUB:无符号运算产生下溢出(即“借位”)时 C 为 0
- V:Overflow Condition Flag, 溢出标志有符号运算(补码)产生溢出时 V 为 1
xzr(零寄存器)
xzr/wzr分别表示64/32位,存储的值为0,一般用于清零操作(如 mov x0, xzr)或作为比较/计算的基准
指令
ARM64 指令集(AArch64)采用 RISC(精简指令集) 设计,所有指令长度统一为 32 位(4 字节),这与 x86 等 CISC 架构(指令长度可变)完全不同
大多数 ARM64 指令遵循 操作码 目标寄存器, 源操作数1, 源操作数2 的格式
加载(Load)和存储(Store)
加载/存储指令⽤于在寄存器和存储器之间传送数据,加载指令⽤于将存储器中的数据传送到寄存器,存储指令则完成相反的操作
这是 ARM 架构(Load/Store 架构)的核心:计算必须在寄存器中进行,CPU 不能直接操作内存中的数据你必须先 LDR 进来,计算完,再 STR 回去
加载指令(Load)
作用:从内存读取数据到寄存器
格式:
LDR{后缀} 目标寄存器, [基址寄存器, 偏移]ldr指令
1 | ldr x8, [sp] ; 将存储地址为sp的数据(64位)读取到x8寄存器中 |
ldrb指令(只操作一个字节)
1 | ldrb w8, [sp, #0x8] ; 将存储器地址为sp+0x8的1个字节数据读⼊寄存器w8,并将w8的⾼24位清零 |
ldrh指令(只操作两个字节)
1 | ldrh w8, [sp, #0x8] ; 将存储器地址为sp+0x8的2个字节数据读⼊寄存器w8,并将w8的⾼16位清零 |
ldur指令 (u和负地址运算相关)
1 | ldurb w0, [x29, #-0x8] ; 将存储地址为x29-0x8的数据(1字节)读取到w0寄存器中 |
存储指令(Store)
作用:将寄存器数据写入内存
格式:
STR{后缀} 源寄存器, [基址寄存器, 偏移]str指令
1 | str x0, [sp] ; 将x0寄存器中的值(64位)存储到sp的存储地址 |
strb指令(只操作一个字节)
1 | strb w0, [sp, #0x10] ; 将w0寄存器中的低8位的字节的数据存储到sp+0x10的存储地址 |
strh指令(只操作两个字节)
1 | strh w0, [sp, #0x10] ; 将w0寄存器中的低16位的字节的数据存储到sp+0x10的存储地址 |
stur指令 (u和负地址运算相关)
1 | stur w0, [x29, #-0x8] ; 将w0寄存器中的数据(32位)存储到x29-0x8的存储地址 |
入栈/出栈 (STP / LDP)
在 ARM64 架构中,入栈(Push)和出栈(Pop) 操作通过 STP(Store Pair)和 LDP(Load Pair)指令实现,结合栈指针寄存器 SP 的调整来完成STP/LDP 可以一次操作两个寄存器,效率很高
注意事项
- 栈增长方向:ARM64 中栈通常向低地址增长(满递减栈 Full-descending)
- 栈指针(SP):始终指向栈顶(最新压入的数据地址)
- 对齐要求:
SP必须保持 16 字节对齐(硬件强制要求) STP/LDP操作两个 64 位寄存器(如X0,X1)时,总共 16 字节,正好符合对齐要求
入栈(Push)操作
通用寄存器入栈
1 | ; 这是最标准的函数序言 (prologue) |
- 效果:
SP的新值 = 原SP - 16- 内存
[SP](新) =X29 - 内存
[SP+8]=X30
浮点寄存器入栈
1 | stp d8, d9, [sp, #-16]! ; 双精度浮点 d8/d9 压栈 |
出栈(Pop)操作
通用寄存器出栈
1 | ; 这是最标准的函数尾声 (epilogue) |
- 效果:
X29=[SP]X30=[SP+8]SP的新值 = 原SP + 16
浮点寄存器出栈
1 | ldp d8, d9, [sp], #16 ; 弹出双精度浮点 d8/d9 |
栈空间分配与释放
分配栈空间(如局部变量)
1 | sub sp, sp, #32 ; 分配 32 字节栈空间 (必须是 16 的倍数) |
释放栈空间
1 | add sp, sp, #32 ; 释放 32 字节 |
地址计算指令 (ADR, ADRP)
问题:LDR/STR 需要一个内存地址如果我想访问一个全局变量或一个字符串常量,的地址是固定的,但我写的代码(尤其是 .so 动态库)可能被加载到内存的任意基地址(这称为位置无关代码, PIC)我该如何获取这个变量的运行时地址?
答案就是使用 PC 相对寻址:ADR 和 ADRP
adr指令 (Address)- 作用:获取一个近距离标签(label)的确切地址
- 原理:
ADR计算目标地址与当前指令(PC)的字节偏移量,并将结果(PC + offset)存入目标寄存器 - 范围:有限,只能寻址
PC周围±1MB的范围 - 使用:常用于加载函数内或模块内较近的字符串、常量
1 | ; 假设 my_string 标签在 0x10080 |
adrp指令 (Address of Page)- 作用:获取一个远距离标签所在的内存页 (Page) 的基地址
- 原理:现代操作系统以 4KB(
0x1000字节)为一页ADRP计算目标地址与当前指令(PC)所在的页的页偏移量(即相差多少个 4KB) - 关键:只计算并加载高 52 位地址(页基址),并将低 12 位(页内偏移)清零
- 范围:极大,可以寻址
PC周围±4GB的范围
ADRP+ADD:黄金组合ADRP几乎从不单独使用帮你拿到页基址(高位),你还需要ADD来加上页内偏移(低位),才能得到确切地址
1 | ; 目标:获取 my_global_var 的确切地址到 X0 |
这个 ADRP + ADD 的组合是 AArch64 访问全局变量的标准方式
数据处理指令
mov指令(移动数据)
1 | ; --- 基本 MOV --- |
add/adds指令 (加法)
1 | add sp, sp, #0x20 ; sp = sp + 0x20 |
sub/subs指令 (减法)subs常用于cmp(cmp x0, x1等价于subs xzr, x0, x1)
1 | sub sp, sp, #0x20 ; sp = sp - 0x20 (分配栈空间) |
mul指令 (乘)
1 | mul x0, x1, x2 ; x0 = x1 * x2 |
madd指令 (乘加)
1 | ; Multiply-Add |
msub指令 (乘减)
1 | ; Multiply-Subtract |
sdiv/udiv指令 (有符号除 / 无符号除)
1 | sdiv x0, x1, x2 ; x0 = x1 / x2 (有符号) |
and/ands指令 (位与)ands常用于测试某位是否为 1(如ands x0, x0, #1,然后用b.eq判断奇偶)
1 | and x0, x1, x2 ; x0 = x1 & x2 |
orr指令 (位或)
1 | orr x0, x1, x2 ; x0 = x1 | x2 |
eor指令 (位异或)
1 | eor x0, x1, x2 ; x0 = x1 ^ x2 (常用于不通过立即数反转某些位) |
lsl指令 (逻辑左移)LSL(Logical Shift Left) 等价于乘以 2 的 N 次方
1 | lsl x0, x1, #3 ; x0 = x1 << 3 (即 x1 * 8) |
lsr指令 (逻辑右移)LSR(Logical Shift Right) 等价于无符号除以 2 的 N 次方(高位补 0)
1 | lsr x0, x1, #3 ; x0 = x1 >> 3 (无符号) |
asr指令 (算术右移)ASR(Arithmetic Shift Right) 等价于有符号除以 2 的 N 次方(高位补充符号位)
1 | ; 假设 x1 = -16 (0xFF...F0) |
cbnz指令 (和⾮ 0 ⽐较跳转)
Compare and Branch if Non-Zero
1 | cbnz x0, 0x100002f70 ; 如果 x0 不为 0,跳转到 0x100002f70 指令执行 |
cbz指令 (和0 ⽐较跳转)
Compare and Branch if Zero
1 | cbz x0, 0x100002f70 ; 如果 x0 为 0,跳转到 0x100002f70 指令执行 |
条件执行指令
AArch32 中几乎所有指令都可以条件执行AArch64 取消了这个设计,改为使用专门的条件指令,这让 CPU 流水线更简单高效
这些指令依赖 CMP 或 SUBS 等设置的 NZCV 标志位
cset指令 (Conditional Set)- 作用:如果条件满足,将寄存器设为 1,否则设为 0
1 | cmp w0, #10 |
csel指令 (Conditional Select)- 作用:如果条件满足,从一个寄存器中选择值,否则从另一个中选择这是 AArch64 中实现
if-then-else和三目运算符的核心 - 格式:
csel Xd, Xn, Xm, <cond>(如果<cond>满足,Xd = Xn, 否则Xd = Xm)
- 作用:如果条件满足,从一个寄存器中选择值,否则从另一个中选择这是 AArch64 中实现
1 | ; C 语言: int x = (a > b) ? 100 : 200; |
跳转指令
cmp指令
Compare将两个寄存器相减(cmp x0, x1等价于subs xzr, x0, x1),不保存结果,但会更新cpsr寄存器的NZCV标志位,为B.cond或CSEL做准备
1 | cmp x0, x1 ; 比较 x0 和 x1 |
b指令 (直接跳转, Branch)b指令可以接上后缀,用来和cmp比较后待条件的跳转EQ:equal 相等 (Z=1)
NE:not equal,不相等 (Z=0)
GT:great than,大于 (有符号, Z=0 and N=V)
GE:greate equal,大于等于 (有符号, N=V)
LT:less than,小于 (有符号, N!=V)
LE:less equal,.小于等于 (有符号, Z=1 or N!=V)
(还有用于无符号的 HI, HS, LO, LS)
1 | b 0x100002fd0 ; 无条件跳转到 0x100002fd0 |
bl指令 (Branch with Link)
带链接的跳转- 跳转到目标地址(通常是一个函数)
- 在跳转前,将下一条指令的地址(即返回地址)保存到
LR(X30) 寄存器
1 | bl 0x100002f54 ; 调用 0x100002f54 处的函数 |
br指令(Branch to Register)
寄存器跳转
直接跳转到某寄存器中的值所指向的地址不会改变LR(x30) 寄存器的值
常用于函数指针调用或switch-case跳转表
1 | ; 假设 X10 中存着地址 0x123456 |
blr指令 (Branch with Link to Register)
带链接的寄存器跳转- 跳转到某寄存器中的值所指向的地址
- 在跳转前,将返回地址保存到
LR(X30)
常用于虚函数调用或高阶函数(函数指针)调用
1 | ; 假设 X10 中存着一个函数A的地址 |
ret指令 (函数返回)
Return from subroutineret是一条伪指令,实际上是br lr的别名
直接跳转到LR(X30) 寄存器中保存的地址,实现函数返回
1 | ; 在函数末尾, 配合 ldp x29, x30, [sp], #16 使用 |
相对跳转(Relative Jump)
特点:
- 目标地址基于当前
PC(程序计数器)计算,与代码位置无关(地址无关代码) - 适用于:函数内分支、短距离跳转
核心指令:
| 指令 | 作用 | 跳转范围 | 示例 |
|---|---|---|---|
B label |
无条件相对跳转 | ±128MB(26 位偏移) | B loop_start |
B.cond |
条件相对跳转(如 B.EQ) |
±1MB(19 位偏移) | B.NE exit |
BL label |
跳转并保存返回地址到 LR |
±128MB | BL subroutine |
底层原理:
1 | B label // 实际编码为:PC = PC + (sign_extend(offset) << 2) |
- 偏移量是 有符号整数,左移 2 位(因指令为 4 字节对齐)
优势:
- 位置无关代码(PIC):动态库、共享代码无需重定位
- 代码紧凑:指令长度固定(4 字节)
绝对跳转(Absolute Jump)
特点:
- 目标地址为固定值,直接指定内存地址
- 适用于:动态调用(函数指针)、长距离跳转
核心指令:
| 指令 | 作用 | 示例 |
|---|---|---|
BR Xn |
跳转到寄存器 Xn 中的地址 |
BR X0(函数指针调用) |
BLR Xn |
跳转并保存返回地址到 LR |
BLR X1(虚函数调用) |
RET |
返回(等价于 BR LR) |
RET |
地址加载方式:
绝对地址需先加载到寄存器:
1 | // 方法1:PC相对地址加载(位置无关,推荐) |
伪指令 (Pseudo-instructions)
这是一个非常重要的概念你写的汇编代码中,有一些指令并不是真实的 CPU 硬件指令,而是汇编器 (Assembler) 提供给开发者的“语法糖”汇编器在编译时,会自动将这些“伪指令”转换成一条或多条真实的硬件指令
ret- 伪指令:
ret - 真实指令:
br lr - 作用:汇编器帮你简化了 “return” 这个常用操作
- 伪指令:
mov(加载大立即数)- 伪指令:
mov x0, #0x123456789ABC - 真实指令:ARM64 指令长度固定 32 位,无法在一条指令里塞下一个 64 位的立即数汇编器会将其转换为多条指令:
1
2
3
4; 汇编器可能会这么做:
movz x0, #0x5678, lsl 16 ; M(OV) with Z(ero): X0 = 0x...000056780000
movk x0, #0x1234, lsl 32 ; M(OV) with K(eep): X0 = 0x...123456780000
; (这只是一个例子,实际组合更复杂) - 作用:让你感觉可以
mov任意立即数,而不用关心底层的MOVZ/MOVK组合
- 伪指令:
ldr(加载地址或常量)伪指令:
ldr x0, =0x123456789ABCDEF0或ldr x0, =my_label真实指令:这是最强大的伪指令汇编器会“智能”选择最高效的方式去加载:
- 情况1 (加载小立即数):
ldr w0, =123-> 汇编器转为mov w0, #123 - 情况2 (加载近地址):
ldr x0, =my_nearby_label-> 汇编器转为adr x0, my_nearby_label - 情况3 (加载远地址):
ldr x0, =my_far_away_label-> 汇编器转为adrp x0, ...+add x0, ...(黄金组合) - 情况4 (加载绝对常量):
ldr x0, =0x123456789ABCDEF0- 汇编器无法用
MOVZ/MOVK(因为太长) 或ADRP(因为不是 PC 相对地址) 来生成 - 汇编器会在代码段附近自动生成一个“**字面量池 (Literal Pool)**”(一块数据区)
- 将
0x123456789ABCDEF0这个 8 字节的值存入池中 - 然后将
ldr伪指令转为一个PC 相对的真实 LDR 指令,从那个池子里把数据加载进来
- 汇编器无法用
1
2
3
4
5
6
7
8
9
10
11; 你写的代码:
ldr x0, =0x123456789ABCDEF0
b somewhere_else
; 汇编器实际生成的(示意):
.L_actual_code:
ldr x0, .L_literal_pool_1 ; 真实 LDR, PC 相对加载
b somewhere_else
.L_literal_pool_1:
.quad 0x123456789ABCDEF0 ; 汇编器自动插入的数据- 情况1 (加载小立即数):
理解伪指令,能帮你弄清为什么有些看似简单的操作(如 mov 一个大数)在反汇编时会变成好几条指令
函数调用
三层函数调用反汇编示例
C语言源码:
1 | int level3(int a, int b) { |
反汇编代码:
1 | ; ===== level3 函数 (最底层) ===== |
ARM64 多层调用关键机制
1. 调用约定 (Calling Convention)
- 参数传递:
- 前8个整型参数:寄存器 w0-w7
- 前8个浮点参数:寄存器 d0-d7
- 额外参数通过栈传递
- 返回值:
- 整型:w0 寄存器
- 浮点:d0 寄存器
- 保留寄存器:
调用者保存寄存器 (Caller-Saved Registers): 包括
X0-X18。如果一个函数(调用者)在调用另一个函数之前,希望X0-X18中的某个值在子函数返回后仍然保持不变,那么调用者自己有责任在调用前将这个值保存起来(比如压入栈中),并在调用返回后再恢复。被调用的子函数可以随意使用这些寄存器,无需担心破坏调用者的值。被调用者保存寄存器 (Callee-Saved Registers): 包括
X19-X28。这个约定规定,如果一个函数(被调用者,也就是我们正在分析的这个函数)想要在自己的代码中使用X19-X28中的任何一个寄存器来存储自己的局部变量或中间计算结果,那么必须承担起责任:在函数开头(序言)将这些寄存器的原始值保存到栈上,并在函数结尾(尾声)必须将这些原始值从栈上恢复回对应的寄存器。保证调用者状态不变: 这样做的目的是为了给调用者提供保障。调用者可以将一些重要的、需要在多次函数调用之间保持不变的值(比如循环计数器、基址指针等)放心地存放在
X19-X28中,因为知道,无论调用哪个遵循约定的子函数,当子函数返回时,X19-X28里的值都会和调用前一模一样。
2. 栈帧管理
- **帧指针 (FP)**:
- x29 寄存器指向当前栈帧基址,与栈指针
SP共同界定函数的局部变量区和保存的寄存器区;在函数执行期间,SP可能变化(如压栈/出栈),而X29保持固定,便于通过 固定偏移 访问局部变量和参数 - 每个函数通过
mov x29, sp设置自己的帧指针
- x29 寄存器指向当前栈帧基址,与栈指针
- 栈操作:
- 使用
stp/ldp批量保存/恢复寄存器 - 栈分配/释放:
sub sp, sp, #size/add sp, sp, #size - 栈指针必须保持 16 字节对齐
- 使用
3. 调用链管理
- **返回地址 (LR)**:
- x30 寄存器存储返回地址
- 非叶子函数必须保存 LR (
str x30, [sp]) bl指令自动更新 LR 为下条指令地址
- 调用深度限制:
- 硬件无直接限制,但受栈空间约束
- 每次调用平均消耗 16-64 字节栈空间
4. 优化技术
- **尾调用优化 (Tail Call)**:
- 如果函数最后操作是调用其他函数,可优化为跳转
- 示例:
b level2替代bl level2; ret
- 叶子函数优化:
- 不调用其他函数的叶子函数可省略栈帧
- 如 level3 未保存 FP/LR
- 寄存器重用:
- 编译器尽量在寄存器间传递中间结果
- 减少内存访问次数
常见问题分析
Q1: 为什么 level3 没有保存 FP/LR?
A: level3 是叶子函数(不调用其他函数),不需要保存返回地址(LR),因此可以省略栈帧设置,减少开销
Q2: 栈对齐如何保证?
A: ARM64 要求 sp 必须 16 字节对齐所有栈操作都遵守:
stp/ldp使用 16 字节倍数偏移- 栈分配大小通常是 16 的倍数
- 函数序言/尾声保证对齐
Q3: 参数如何跨层传递?
A: 参数通过寄存器链式传递:
- main 设置 w0=10 调用 level1
- level1 计算 y*2 存入 w0 调用 level2
- level2 设置 w1=5 调用 level3
寄存器传递比内存访问高效得多
Q4: 如何调试调用栈?
A: 使用帧指针回溯:
- 当前 FP 指向保存的前一 FP
- FP-8 位置是返回地址 (LR)
- 递归解析直到 FP=0
