正文
以下是栈帧管理:
SUB SP, SP,
STR X29, [SP,
ADD X29, SP,
...
LDR X29, [SP,
ADD SP, SP,
RET @ 返回(使用X30中的地址)
这里再提一提条件执行,在ARM架构条件执行中,NZCV标志由CMP、ADDS等指令更新,用于控制条件分支,如B.EQ(相等时跳转)、B.NE(不相等时跳转)等指令的执行依赖这些标志的状态判断。
再讲讲ARM64 与 ARM32 的区别:
特性
|
ARM64(AArch64)
|
ARM32(AArch32)
|
寄存器位数
|
|
|
链接寄存器
|
|
|
程序计数器
|
|
|
状态寄存器
|
|
|
这里补充一个关于arm64寄存器的知识点,其实arm64寄存器除了X0到X30这种寄存器之外,还有W0到W30寄存器,而这两种寄存器的关系也十分简单,W0-W30 是 X0-X30 的低 32 位,两者共享同一物理寄存器,写入 W 寄存器时,X 寄存器的高 32 位会被清零,读取 W 寄存器时,仅访问低 32 位,高 32 位不参与运算。
在 ARM64 汇编中还有一种特殊的零寄存器WZR(32 位)和 XZR(64 位),当这两个寄存器作为源寄存器时值始终为零,举个例子:
ADD W0, W1, WZR ; W0 = W1 +0 → 等价于 MOV W0, W1
SUB X2, X3, XZR ; X2 = X3 -0 → 等价于 MOV X2, X3
而当 WZR 或 XZR 作为目标寄存器时,写入操作会被硬件忽略,这也举个例子:
MOV WZR, W1 ; 无效操作,WZR 的值仍为 0
STR X0, [XZR] ; 尝试写入内存地址 0,通常触发异常(取决于系统配置)
除了W寄存器和X寄存器之外还有其他的寄存器,如果要用到浮点数运算那就需要V寄存器,ARM64架构提供 32 个浮点寄存器,命名为 V0 至 V31,每个寄存器宽度为 128 位。浮点运算所使用的指令支持 单精度(F32) 和 双精度(F64) 浮点运算,以下是浮点运算指令:
指令
|
功能
|
示例
|
FADD
|
|
FADD S0, S1, S2
|
FSUB
|
|
FSUB D0, D1, D2
|
FMUL
|
|
FMUL S3, S4, S5
|
FDIV
|
|
FDIV D3, D4, D5
|
FABS
|
|
FABS S6, S7
|
FNEG
|
|
FNEG D6, D7
|
FSQRT
|
|
FSQRT S8, S9
|
FCMP
|
|
FCMP S10, S11
|
FMOV
|
|
FMOV D8, D9
|
操作数可以是标量或向量。什么是标量和向量呢?标量就是单精度(32 位,用S系列寄存器表示)和双精度(64 位,用D系列寄存器表示),向量就是通过 SIMD(NEON 技术) 支持并行操作,比如同时处理 4 个单精度浮点数(4S)或 2 个双精度浮点数(2D),举个例子:
FADD V0.4S, V1.4S, V2.4S ; 并行计算 4 个单精度浮点数的加法
FMUL D0, D1, D2 ; 双精度浮点数乘法
这些浮点寄存器可以通过不同的数据宽度后缀(如 B、H、S、D、Q)访问不同精度的数据。ARM64 的浮点/SIMD 寄存器支持多种数据宽度的访问方式,并非独立寄存器组,而是同一寄存器的不同视图:
后缀
|
数据宽度
|
描述
|
示例指令
|
Q
|
|
|
ADD V0.Q, V1.Q, V2.Q
|
D
|
|
|
FMUL D0, D1, D2
|
S
|
|
|
FADD S0, S1, S2
|
H
|
|
|
FCVT H0, S1
|
B
|
|
|
LD1 {B0}, [X0]
|
◆
Q0-Q31
:128 位完整视图,对应
V0.Q
到
V31.Q
。
◆
D0-D31
:64 位视图,对应
V0.D
到
V31.D
。
◆
S0-S31
:32 位视图,对应
V0.S
到
V31.S
。
◆
H0-H31
:16 位视图,对应
V0.H
到
V31.H
。
◆
B0-B31
:8 位视图,对应
V0.B
到
V31.B
。
除此之外浮点运算和整数运算还有一个区别,那就是需要进行类型转换,可以使用 FCVT 指令在不同精度之间转换。
FCVT H0, S1 ; 将 S1 的单精度浮点数转换为半精度(H0)
FCVT D0, S1 ; 将 S1 的单精度浮点数转换为双精度(D0)
ARM64架构和ARM32架构浮点寄存器使用的区别还是有不小的,在ARM32中Q0 是一个独立的 128 位寄存器,D0 是其低 64 位,S0 是 D0 的低 32 位,在ARM64中所有视图统一为 V0-V31,通过后缀指定数据宽度,没有独立的 Q0-Q31 寄存器组。
现在我们对寄存器有了一定的了解,接下来将讲解ARM汇编的寻址方式,第一种寻址方式寄存器寻址,这种方式直接使用寄存器中的值作为操作数,无需访问内存。
mov r1, r2 ; 将 r2 的值复制到 r1
这种方式操作速度最快,仅涉及寄存器间的数据传输。一般适用于频繁的数据交换或临时值保存。
第二种寻址方式立即寻址,该方式操作数是直接编码在指令中的常量(立即数),就像下面的代码一样:
ARM 立即数必须符合“8 位常数 + 4 位循环右移”格式(例如 0xFF00 是合法的,因为可以表示为 0xFF << 8)。而非法立即数需通过多次指令或内存加载实现。
第三种寻址方式寄存器移位寻址,这种方式对源寄存器的值进行移位操作后作为操作数。支持四种移位类型,分别是以下四种:
(1) 逻辑左移(LSL, Logical Shift Left)
◆
操作
:将二进制位向左移动,低位补 0,高位溢出丢弃。
◆
示例
:
(2) 逻辑右移(LSR, Logical Shift Right)
◆
操作
:将二进制位向右移动,高位补 0,低位溢出丢弃。
◆
示例
:
(3) 算术右移(ASR, Arithmetic Shift Right)
◆
操作
:保留符号位(最高位),其余位右移,高位补符号位。
◆
示例
:
(4) 循环右移(ROR, Rotate Right)
◆
操作
:将二进制位循环右移,最低位移出的位补到最高位。
◆
示例
:
这种方式能快速实现乘除运算(如
LSL #n
等效于乘 2n2
n
),也适用于位操作(如掩码提取、数据对齐)。
第四种寻址方式寄存器间接寻址,这种方式使用寄存器中的值作为内存地址,访问该地址处的数据。
ldr r1, [r2] ; 将 r2 指向的内存地址的值加载到 r1
其实这个可以理解为C 语言中的 int x = *p;
这种方式必须通过 ldr 或 str 指令访问内存,适用于动态内存操作(如指针遍历)。
第五种寻址方式基址变址寻址,这种方式是通过基址寄存器(Base Register)加偏移量(Offset)计算有效地址。
该种寻址方式还有两种变体:
前变址:先更新基址寄存器,再访问内存。
后变址:先访问内存,再更新基址寄存器。
这种方式常用于数组遍历、结构体成员访问。
第六种寻址方式为多寄存器寻址,该方式是单条指令批量操作多个寄存器。
ldmia r11, {r2-r7, r12} ; 从 r11 指向的地址连续加载数据到多个寄存器
模式:
IA(Increment After):操作后地址递增(默认模式)。
IB(Increment Before):操作前地址递增。
DA(Decrement After):操作后地址递减。
DB(Decrement Before):操作前地址递减。
这种方式常用于函数调用时批量保存/恢复寄存器(如 stmdb sp!, {r0-r12, lr})。
第七种方式为堆栈寻址,这种方式是基于堆栈指针(
sp
)的多寄存器操作,支持不同堆栈类型。
stmfd sp!, {r2-r7, lr} ; 将寄存器压入满递减堆栈(ARM 默认)
堆栈类型:
FD(Full Descending):堆栈向低地址增长(压栈时 sp 先减后存)。
ED(Empty Descending):堆栈向低地址增长(压栈时 sp 先存后减)。
FA/EA:类似逻辑,但方向不同。 这种方式常用于函数调用时保存上下文(如保存 lr 和局部变量)。
这里也多提一嘴,我们有时可以看到像这样的ARM汇编指令:
该指令中的**!**符号的作用是 自动更新基址寄存器(SP)的值,具体表现为:
1.
基址寄存器回写
!
表示指令执行后,基址寄存器(
sp
)的值会根据操作的内存偏移量自动更新。
◆
满递减栈(Full Descending Stack)
:
-
stmfd
-
存储完数据后,
sp
的值会被更新为递减后的新地址。
5.
具体操作流程
◆
无
!
:存储数据到内存,但
sp
的值不变。
◆
有
!
:
-
sp
先递减
4 * 4 = 16 字节
(每个寄存器占 4 字节,共 4 个寄存器)。
-
将
r1-r4
的值依次存储到
sp
指向的新地址。
-
sp
示例分析
stmfd sp!, {r1-r4} ; 存储前 sp -= 16,存储 r1-r4,sp 更新为新地址
◆
等效伪代码
:
应用场景
◆
函数调用
:保存寄存器到栈中,并自动更新栈指针。
◆
中断处理
:快速保存上下文,避免手动调整栈指针。
寻址方式总结
!
符号在 ARM 存储多寄存器指令中,表示
基址寄存器在操作后自动更新
。对于
stmfd sp!, {r1-r4}
,它确保栈指针
sp
在存储数据后指向新的栈顶位置,简化了堆栈管理的复杂性。
第八种寻址方式相对寻址,这种方式基于当前程序计数器(
PC
)的偏移量计算目标地址。
beq flag ; 若条件满足,跳转到标签 flag 处
flag: ; 目标地址 = PC + 偏移量(由汇编器自动计算)
这种方式有以下特点:
偏移量为有符号数,范围受指令格式限制(如 Thumb 模式为 ±2048)。
支持位置无关代码(PIC)。
这种方式常用于条件分支、循环控制、函数调用(如
bl func
)。
了解完了寻址方式,接下来聊聊一些常见的套路:
1、在ARM32函数调用中,被调用函数需保存并恢复
R4-R11
寄存器的值,以确保调用者的状态不被破坏。此外,若函数内部使用到
LR(链接寄存器)
,也需保存其值(例如通过压栈)。这一机制保证了函数返回后,调用者的寄存器和程序流程能正确恢复。而在arm64中被调用函数则是需要保存并恢复X19-X29寄存器的值,若被调用函数需要调用其他函数,需保存 LR(X30),通常通过 STP 指令压栈。
2、在ARM32中,
SP(R13)
是专用的栈指针寄存器。通过递减SP的值(如
SUB SP, SP, #N
),函数为局部变量分配栈空间;函数退出时需恢复SP(如
ADD SP, SP, #N
)。这种机制实现了栈内存的高效管理,确保局部变量和函数调用的隔离性。而在arm64中SP(X31)寄存器专门用于栈指针寄存器,必须 16 字节对齐。通过 SUB SP, SP,
#N
分配栈空间,N 需为 16 的倍数,函数退出前通过 ADD SP, SP, #N 恢复栈指针。
讲到这里我们来看一段arm64汇编代码:
.text:0000000000005318 ; jint JNI_OnLoad(JavaVM *vm, void *reserved)
.text:0000000000005318 EXPORT JNI_OnLoad
.text:0000000000005318 JNI_OnLoad ; DATA XREF: LOAD:0000000000000918↑o
.text:0000000000005318
.text:0000000000005318 var_30 = -0x30
.text:0000000000005318 var_28 = -0x28
.text:0000000000005318 var_20 = -0x20
.text:0000000000005318 var_10 = -0x10
.text:0000000000005318 var_8 = -8
.text:0000000000005318 var_s0 = 0
.text:0000000000005318 var_s8 = 8
.text:0000000000005318
.text:0000000000005318 ; __unwind {
.text:0000000000005318 SUB SP, SP, #0x40
.text:000000000000531C STR X21, [SP,#0x30+var_20]
.text:0000000000005320 STP X20, X19, [SP,#0x30+var_10]
.text:0000000000005324 STP X29, X30, [SP,#0x30+var_s0]
.text:0000000000005328 ADD X29, SP, #0x30
…………
.text:00000000000053C0 LDP X29, X30, [SP,#0x30+var_s0]
.text:00000000000053C4 LDP X20, X19, [SP,#0x30+var_10]
.text:00000000000053C8 LDR X21, [SP,#0x30+var_20]
.text:00000000000053CC ADD SP, SP, #0x40 ; '@'
.text:00000000000053D0 RET
我们可以看到首先通过SUB方法去把 SP 的值减去 0x40,通过这种方式对栈指针 SP 进行调整从而提升堆栈,也就是让栈顶指针向上提升 0x40 个字节。提升堆栈后通过STR命令将X21寄存器的值存到内存里,存放的位置为SP + 0x10 这个内存地址处,后续两次STR命令皆如此,第二行STR汇编代码把寄存器 X20 的值存到 SP + 0x20 处,把寄存器 X19 的值存到 SP + 0x28 处,第三行STR汇编代码把 X29 和 X30 的值分别存到 SP + 0x30 和 SP + 0x38 处,为什么0x28和0x38都是加8字节,因为 64 位寄存器占 8 个字节。
如此便把X21、X20、X19、X29、X39寄存器中的值压入堆栈中,保存的寄存器包括 被调用者保存寄存器(X19-X21) 和 栈帧指针(X29)、返回地址(X30)。接下来就是通过ADD指令把 SP 的值加上 0x30 后赋给 X29。这样一来,X29 就指向了 SP + 0x30 这个地址,也就是把 X29 当作栈底指针。
我们继续看函数调用的最后,可以看到先是通过LDP命令将之前压入堆栈的值重新读取出来赋值给原本的寄存器,这样便把这些寄存器原本的值还给了它,恢复顺序与保存顺序相反,接下来通过 ADD SP 释放之前分配的0x40个字节栈空间,恢复 SP 到函数入口时的位置,最后通过RET汇编代码跳转到链接寄存器X30保存的返回地址,结束函数调用。
接下来我们讲解资源重定位,当程序在编译时无法确定字符串的实际加载位置,就需要依赖资源重定位。也可以说资源重定位是程序加载到内存时,根据实际基地址调整代码和数据中引用地址的过程。其核心目的是解决程序在不同内存位置运行时地址不固定的问题。
编译后的程序通常假设从固定基地址运行,但实际加载地址可能不同。若代码中直接使用绝对地址,实际运行时地址会失效。所以要记录需要修正的地址,然后通过 PC 相对寻址 或 重定位条目修正,在运行时动态计算实际地址。
我们来看一段ARM 32汇编代码,展示资源重定位的实现过程。代码通过 PC 相对寻址 动态计算字符串地址,并调用 printf 函数输出结果:
; 代码段 (.text)
.text:0000072C LDR R2, =(sResult - 0x738) ; 加载字符串偏移量到 R2
.text:00000730 ADD R2, PC, R2 ; 计算字符串实际地址:R2 = PC + 偏移量
.text:00000734 MOV R0, R2 ; R0 = 字符串地址("Result: %d")
.text:00000738 MOV R1, #42 ; R1 = 要输出的数值(示例值 42)
.text:0000073C BL printf ; 调用 printf 函数
.text:00000740 ... ; 后续代码
; 只读数据段 (.rodata)
.rodata:00001