【Android安全-安卓逆向基础知识之ARM汇编和so层动态调试】此文章归类为:Android安全。
在进行so层的动态调试前需要学习些知识点,比如说arm汇编,我们在用ida反编译so文件可以看到arm汇编指令,这个指令是CPU机器指令的助记符。这是什么意思呢?首先CPU机器指令其实也就是那由1和0组成的二进制,如果要我们通过CPU的机器指令来编程那是非常难且枯燥无聊了的,反正我是做不到。但我使用汇编代码来编程就还是可以编写些简单的程序的,嘿嘿!我们先来看一段反编译出来的简单的汇编代码:
我们可以在图中看到汇编指令的左边有所对应的机器码,如push rbp这条指令所对应的机器码就是55,这个55是用十六进制表示的,它最终会被转换成二进制,也就是0和1,55占一个字节也就是8位,转换为二进制为01010101,而这串01010101就可以表示push rbp这段汇编指令出来。这里多提一嘴,从机器码01010101到汇编指令push rbp的过程就是反汇编,而从汇编指令到机器码的过程就是汇编。
arm的CPU是支持四种指令集的,分别是ARM32指令集(32-bit)、Thumb指令集(16-bit)、Thumb2指令集(16-bit&32-bit)、ARM64指令集(64-bit),但是ARM32的CPU是只支持前三种指令集的。在同一个函数中是不会出现多种指令集的,也就是某一个函数不会出现既有ARM32指令集又有Thumb指令集的情况。不过在不同的函数中可以,也就是这个函数是一个指令集,另一个函数是另外一个指令集的情况。Thumb2指令集大致情况如图所示:
在Thumb指令集中一条指令用两个字节就可以表示了,并且和arm指令集中用四个字节表示的效果是一样的。这时有些朋友可能会疑惑,那对比arm指令集节省了不少的字节,那为什么不都用Thumb指令集呢?其实原因很简单,因为有些东西并不能两个字节搞定,比如说异常处理,异常处理在arm的CPU中是要占四个字节的,遇到这种情况Thumb指令集就需要用两条Thumb指令去搞定异常处理,这么一来就需要用到两条指令,从而导致所花时间比起一条指令解决的arm指令集就更长了。所以后来就出现了Thumb2指令集,该指令集就有用两字节和四字节来表示指令,Thumb2 支持 16位和32位混合编码,处于两个字节和四个字节共存的状态。
不过怎么说Thumb2指令集的本质还是Thumb指令集,所以我们在对Thumb和Thumb2指令集进行hook时地址是需要加一的,而arm指令集则不需要,因为由LSB标识指令集状态,Thumb系地址末位为1。总之Hook 地址处理大概是这样的:
Thumb 函数地址:0x1000 → Hook 地址为 0x1001。
ARM 函数地址:0x2000 → Hook 地址为 0x2000。
这里多提一嘴,arm的CPU是向下兼容的,所以arm64的CPU依旧会支持这三种指令集。
ARM架构本质上是RISC,而RISC是精简指令集,指令复杂度是简单且单操作的,内存访问是仅通过 LDR/STR 指令实现的,寄存器使用一般通用寄存器数量多(如ARM32有16个)
因为精简指令减少晶体管数量和动态功耗,所以适合移动设备。X86的CPU是可以直接操作内存中的数据,但ARM的CPU是没法直接操作内存中的数据,就是因为ARM架构是精简指令集。
我们先来看两段arm汇编代码,第一段:
第二段:
这两段汇编代码中可以看到些看似一样却又有些不一样的指令,比如说ADD和ADDS这两种指令,这两种指令有什么区别吗?其实ADDS中的S就是指令后缀,ARM汇编是很喜欢给指令加后缀的,这样一来同一指令加不同的后缀就会变成不同的指令,但功能是差不多的啦!
下面罗列了 ARM 汇编指令中常见的后缀:
一、条件执行后缀(Condition Codes)
ARM 指令可通过条件后缀实现 条件执行(根据 CPSR 状态寄存器中的标志位决定是否执行指令):
后缀 | 全称 | 触发条件 | 典型应用场景 |
---|---|---|---|
EQ |
Equal | Z=1(结果为零) | 比较相等后的跳转或操作 |
NE |
Not Equal | Z=0(结果非零) | 循环退出条件判断 |
CS/HS |
Carry Set / Higher or Same | C=1(进位标志置位或无符号数大于等于) | 无符号数比较后的分支 |
CC/LO |
Carry Clear / Lower | C=0(进位标志清零或无符号数小于) | 无符号数小于时的操作 |
MI |
Minus | N=1(结果为负数) | 负数处理逻辑 |
PL |
Plus | N=0(结果非负) | 非负数条件操作 |
VS |
Overflow Set | V=1(有符号溢出) | 溢出错误处理 |
VC |
Overflow Clear | V=0(无符号溢出) | 安全运算检查 |
HI |
Higher | C=1 且 Z=0(无符号数大于) | 无符号数的大于判断 |
LS |
Lower or Same | C=0 或 Z=1(无符号数小于等于) | 无符号数的循环终止条件 |
GE |
Greater or Equal | N=V(有符号数大于等于) | 有符号数比较后的分支 |
LT |
Less Than | N≠V(有符号数小于) | 有符号数的小于判断 |
GT |
Greater Than | Z=0 且 N=V(有符号数大于) | 有符号数的大于判断 |
LE |
Less or Equal | Z=1 或 N≠V(有符号数小于等于) | 有符号数的循环终止条件 |
示例:
1 2 | ADDEQ R0, R1, R2 @ 当 Z = 1 时执行 R0 = R1 + R2 BNE loop @ 当 Z = 0 时跳转到 loop 标签 |
二、数据操作后缀
这些后缀指定 操作的数据类型 或 内存访问模式:
后缀 | 含义 | 用途 |
---|---|---|
B |
Byte | 操作 8 位数据(如 LDRB , STRB ) |
H |
Halfword | 操作 16 位数据(如 LDRH , STRH ) |
SB |
Signed Byte | 加载有符号 8 位数据(如 LDRSB ) |
SH |
Signed Halfword | 加载有符号 16 位数据(如 LDRSH ) |
T |
User Mode (Translated) | 在用户模式下访问内存(如 LDRT , STRT ) |
D |
Doubleword | 操作 64 位数据(ARMv8+,如 LDP , STP ) |
示例:
1 2 | LDRB R0, [R1] @ 从 R1 地址加载 8 位数据到 R0(高位补零) LDRSH R2, [R3] @ 从 R3 地址加载 16 位有符号数据到 R2 |
三、标志位更新后缀
后缀 | 作用 | 典型指令 |
---|---|---|
S |
更新 CPSR 中的标志位(Z, N, C, V) | ADDS , SUBS |
! |
更新基址寄存器(写回地址) | LDR R0, [R1, #4]! |
示例:
1 2 | ADDS R0, R1, R2 @ R0 = R1 + R2,并更新 CPSR 标志 LDMIA R0!, {R1 - R3} @ 从 R0 加载数据到 R1 - R3,R0 自动递增 |
四、特殊操作后缀
后缀 | 用途 | 示例指令 |
---|---|---|
L |
长跳转(用于 BL 指令,保存返回地址到 LR) |
BL subroutine |
X |
交换指令集模式(如 ARM ↔ Thumb) | BX LR |
W |
32位操作(ARMv8,如 ADDW ) |
ADDW R0, R1, #42 |
N |
否定条件(仅限 Thumb-2) | IT NE |
五、协处理器操作后缀
后缀 | 用途 | 示例指令 |
---|---|---|
P |
协处理器数据传输(如 MCR , MRC ) |
MCR p15, 0, R0, c1, c0, 0 |
L |
协处理器加载/存储(如 LDC , STC ) |
LDC p2, c3, [R0] |
六、浮点运算后缀(VFP/NEON)
后缀 | 用途 | 示例指令 |
---|---|---|
F32 |
单精度浮点操作(32位) | VADD.F32 S0, S1, S2 |
F64 |
双精度浮点操作(64位) | VMOV.F64 D0, #3.14 |
I8/I16 |
整数向量操作(8/16位) | VADD.I8 Q0, Q1, Q2 |
指令后缀总结
后缀类型 | 核心功能 |
---|---|
条件执行后缀 | 根据标志位决定是否执行指令 |
数据操作后缀 | 指定操作的数据类型或内存模式 |
标志位更新后缀 | 更新状态寄存器或基址寄存器 |
特殊操作后缀 | 支持长跳转、模式切换等高级操作 |
协处理器后缀 | 控制协处理器行为 |
浮点运算后缀 | 定义浮点或向量运算的数据类型 |
说回正题,前面讲过ARM架构本质上是RISC,这也导致ARM的CPU是没法直接操作内存中的数据,但是CPU要操作内存中的数据又是刚需,那该怎么办呢?前面也提到过LDR/STR 是 ARM 内存访问的核心指令,所以ARM的CPU先把内存中的数据加载到寄存器中,这样CPU就可以操作寄存器中的数据,操作完成后再把寄存器中的数据存到内存中。
LDR是将内存中的数据加载到通用寄存器。STR是将通用寄存器中的数据存储到内存。这里多提一嘴,LDR/STR能处理的字节是一个字,一个字在arm32中是4字节,在arm64中是8字节,而在x86和IDA中一个字都是占两个字节,但是IDA会根据反汇编的目标架构动态调整“字”的显示,比如ARM模式显示4或8字节。接下来我们回归正题,ARM当中的寄存器数量是要比X86当中的寄存器数量要多的,arm32架构中一共有17个寄存器,而arm64架构中总共有34个寄存器。
上图是arm64架构,左边是X0-X30 通用寄存器 以及 SP、PC、PSR 寄存器,下面大致讲解这些寄存器:
一、X0-X30 通用寄存器
寄存器 | 名称/用途 | 详细说明 |
---|---|---|
X0-X7 | 参数/返回值寄存器 | 用于函数调用时传递参数(前8个参数),X0 同时用于函数返回值。 |
X8 | 间接结果寄存器 | 当函数返回结构体等较大数据时,X8 保存返回结构的地址。 |
X9-X15 | 临时寄存器 | 临时存储数据,调用者无需保存,函数调用后可能被覆盖。 |
X16-X17 | 平台专用寄存器(IP0/IP1) | 内部过程调用(Intra-Procedure Call)临时寄存器,通常由链接器或编译器使用。 |
X18 | 平台保留寄存器 | 保留给操作系统或特定平台使用,应用程序不应修改。 |
X19-X28 | 被调用者保存寄存器 | 函数调用时,若需使用这些寄存器,被调用者必须保存其原始值并在返回前恢复。 |
X29 | 帧指针(FP) | 指向当前函数的栈帧基址,用于调试和栈回溯。 |
X30 | 链接寄存器(LR) | 保存函数返回地址(如 BL 指令的返回地址)。 |
二、特殊寄存器
寄存器 | 全称 | 详细说明 |
---|---|---|
SP | 堆栈指针(Stack Pointer) | 指向当前栈顶地址,用于函数调用时的局部变量分配和寄存器保存。 |
PC | 程序计数器(Program Counter) | 指向当前执行指令的地址。在 ARM 中不可直接修改,但可通过分支指令(如 B 、BL )间接控制。 |
PSR | 程序状态寄存器(Program Status Register) | 包含处理器状态信息,ARM64 中分为多个专用寄存器: - NZCV: 条件标志(Negative, Zero, Carry, oVerflow) - DAIF: 中断屏蔽标志(Debug, SError, IRQ, FIQ) - CurrentEL: 当前异常等级(EL0-EL3) |
上面的是比较枯燥的,所以我们大致模拟一下寄存器的使用场景,当ARM64指令调用函数时就会进行参数传递,在此过程中X0-X7 传递前8个参数,多余参数通过堆栈传递。当函数调用结束返回值的时候,X0 返回基本类型数据,X8 返回结构体地址。在 ARM64 函数调用规范中,有一套“寄存器保存” 规则,这套规则简单来说就是被调用者需保存 X19-X28,调用者需保存 X0-X18(若需保留值)。什么意思呢?其实就是当一个函数被调用时,若它需要使用 X19 - X28 寄存器,必须先将这些寄存器的原始值保存,通常会保存到堆栈。在函数执行结束前,再从堆栈中恢复这些寄存器的原始值。这样做是为了确保调用者在调用函数前后,X19 - X28 寄存器的值不受被调用函数影响。
X0 - X18 是 “调用者保存寄存器”。调用其他函数时,调用者若希望在函数调用后继续使用 X0 - X18 中的值,需自行负责保存,比如提前将值存到堆栈或其他安全位置。被调用函数无需关心这些寄存器的原始值,可直接使用。这样做是为了减轻被调用函数的负担,提高函数调用效率。
以下是栈帧管理:
1 2 3 4 5 6 7 | SUB SP, SP, #16 @ 分配16字节栈空间 STR X29, [SP, #8] @ 保存帧指针 ADD X29, SP, #8 @ 设置新帧指针 ... LDR X29, [SP, #8] @ 恢复帧指针 ADD SP, SP, #16 @ 释放栈空间 RET @ 返回(使用X30中的地址) |
这里再提一提条件执行,在ARM架构条件执行中,NZCV标志由CMP、ADDS等指令更新,用于控制条件分支,如B.EQ(相等时跳转)、B.NE(不相等时跳转)等指令的执行依赖这些标志的状态判断。
再讲讲ARM64 与 ARM32 的区别:
特性 | ARM64(AArch64) | ARM32(AArch32) |
---|---|---|
寄存器位数 | 64位(X0-X30) | 32位(R0-R15) |
链接寄存器 | X30(LR) | R14(LR) |
程序计数器 | PC 不可直接访问 | PC 为 R15,可直接修改 |
状态寄存器 | 分解为 NZCV、DAIF 等专用寄存器 | CPSR(单一程序状态寄存器) |
这里补充一个关于arm64寄存器的知识点,其实arm64寄存器除了X0到X30这种寄存器之外,还有W0到W30寄存器,而这两种寄存器的关系也十分简单,W0-W30 是 X0-X30 的低 32 位,两者共享同一物理寄存器,写入 W 寄存器时,X 寄存器的高 32 位会被清零,读取 W 寄存器时,仅访问低 32 位,高 32 位不参与运算。
在 ARM64 汇编中还有一种特殊的零寄存器WZR(32 位)和 XZR(64 位),当这两个寄存器作为源寄存器时值始终为零,举个例子:
1 2 | ADD W0, W1, WZR ; W0 = W1 + 0 → 等价于 MOV W0, W1 SUB X2, X3, XZR ; X2 = X3 - 0 → 等价于 MOV X2, X3 |
而当 WZR 或 XZR 作为目标寄存器时,写入操作会被硬件忽略,这也举个例子:
1 2 | 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),举个例子:
1 2 | FADD V0. 4S , V1. 4S , V2. 4S ; 并行计算 4 个单精度浮点数的加法 FMUL D0, D1, D2 ; 双精度浮点数乘法 |
这些浮点寄存器可以通过不同的数据宽度后缀(如 B、H、S、D、Q)访问不同精度的数据。ARM64 的浮点/SIMD 寄存器支持多种数据宽度的访问方式,并非独立寄存器组,而是同一寄存器的不同视图:
后缀 | 数据宽度 | 描述 | 示例指令 |
---|---|---|---|
Q | 128 位 | 访问整个 128 位寄存器 | ADD V0.Q, V1.Q, V2.Q |
D | 64 位 | 访问寄存器的低 64 位 | FMUL D0, D1, D2 |
S | 32 位 | 访问寄存器的低 32 位 | FADD S0, S1, S2 |
H | 16 位 | 访问寄存器的低 16 位 | FCVT H0, S1 |
B | 8 位 | 访问寄存器的低 8 位 | LD1 {B0}, [X0] |
V0.Q
到 V31.Q
。V0.D
到 V31.D
。V0.S
到 V31.S
。V0.H
到 V31.H
。V0.B
到 V31.B
。除此之外浮点运算和整数运算还有一个区别,那就是需要进行类型转换,可以使用 FCVT 指令在不同精度之间转换。
1 2 | 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汇编的寻址方式,第一种寻址方式寄存器寻址,这种方式直接使用寄存器中的值作为操作数,无需访问内存。
1 | mov r1, r2 ; 将 r2 的值复制到 r1 |
这种方式操作速度最快,仅涉及寄存器间的数据传输。一般适用于频繁的数据交换或临时值保存。
第二种寻址方式立即寻址,该方式操作数是直接编码在指令中的常量(立即数),就像下面的代码一样:
1 | mov r0, #0xFF00 ; 将立即数 0xFF00 加载到 r0 |
ARM 立即数必须符合“8 位常数 + 4 位循环右移”格式(例如 0xFF00 是合法的,因为可以表示为 0xFF << 8)。而非法立即数需通过多次指令或内存加载实现。
第三种寻址方式寄存器移位寻址,这种方式对源寄存器的值进行移位操作后作为操作数。支持四种移位类型,分别是以下四种:
(1) 逻辑左移(LSL, Logical Shift Left)
操作:将二进制位向左移动,低位补 0,高位溢出丢弃。
示例:
1 | mov r0, r1, lsl #1 ; 将 r1 的值左移 1 位后存入 r0 |
假设 r1 = 0b1011
(4 位简化示例),左移 1 位后:
1 | 原始值: 1 0 1 1 |
1 | 左移 1 位: 0 1 1 0 (最高位 1 被丢弃,最低位补 0 ) |
1 | 结果:r0 = 0b0110 (即 6 ) |
实际 ARM 环境:寄存器为 32 位,左移 1 位等效于乘以 2。例如:
1 | r1 = 0x0000000F (十进制 15 ) |
1 | r0 = r1, lsl #1 → r0 = 0x0000001E(十进制 30) |
(2) 逻辑右移(LSR, Logical Shift Right)
操作:将二进制位向右移动,高位补 0,低位溢出丢弃。
示例:
1 | mov r0, r1, lsr #2 ; 将 r1 的值右移 2 位后存入 r0 |
假设 r1 = 0b1100
(4 位简化示例),右移 2 位后:
1 | 原始值: 1 1 0 0 |
1 | 右移 2 位: 0 0 1 1 (最低两位 00 被丢弃,高位补 0 ) |
1 | 结果:r0 = 0b0011 (即 3 ) |
(3) 算术右移(ASR, Arithmetic Shift Right)
操作:保留符号位(最高位),其余位右移,高位补符号位。
示例:
1 | mov r0, r1, asr #1 ; 将 r1 的值算术右移 1 位后存入 r0 |
假设 r1 = 0b1011
(4 位有符号数,即十进制 -5),右移 1 位后:
1 | 原始值: 1 0 1 1 |
1 | 右移 1 位: 1 1 0 1 (符号位 1 被保留,低位 1 丢弃) |
1 | 结果:r0 = 0b1101 (即十进制 - 3 ) |
(4) 循环右移(ROR, Rotate Right)
操作:将二进制位循环右移,最低位移出的位补到最高位。
示例:
1 | mov r0, r1, ror #1 ; 将 r1 的值循环右移 1 位后存入 r0 |
假设 r1 = 0b1011
(4 位简化示例),循环右移 1 位后:
1 | 原始值: 1 0 1 1 |
1 | 循环右移 1 位: 1 1 0 1 (最低位 1 移到最高位) |
1 | 结果:r0 = 0b1101 (即 13 ) |
这种方式能快速实现乘除运算(如 LSL #n
等效于乘 2n2n),也适用于位操作(如掩码提取、数据对齐)。
第四种寻址方式寄存器间接寻址,这种方式使用寄存器中的值作为内存地址,访问该地址处的数据。
1 | ldr r1, [r2] ; 将 r2 指向的内存地址的值加载到 r1 |
其实这个可以理解为C 语言中的 int x = *p;
这种方式必须通过 ldr 或 str 指令访问内存,适用于动态内存操作(如指针遍历)。
第五种寻址方式基址变址寻址,这种方式是通过基址寄存器(Base Register)加偏移量(Offset)计算有效地址。
1 | ldr r1, [r2, #4] ; 访问 r2 + 4 地址处的值 |
该种寻址方式还有两种变体:
前变址:先更新基址寄存器,再访问内存。
1 | ldr r1, [r2, #4]! ; 等效于 r2 = r2 + 4,然后 r1 = [r2] |
后变址:先访问内存,再更新基址寄存器。
1 | ldr r1, [r2], #4 ; 先 r1 = [r2],然后 r2 = r2 + 4 |
这种方式常用于数组遍历、结构体成员访问。
第六种寻址方式为多寄存器寻址,该方式是单条指令批量操作多个寄存器。
1 | ldmia r11, {r2 - r7, r12} ; 从 r11 指向的地址连续加载数据到多个寄存器 |
模式:
IA(Increment After):操作后地址递增(默认模式)。
IB(Increment Before):操作前地址递增。
DA(Decrement After):操作后地址递减。
DB(Decrement Before):操作前地址递减。
这种方式常用于函数调用时批量保存/恢复寄存器(如 stmdb sp!, {r0-r12, lr})。
第七种方式为堆栈寻址,这种方式是基于堆栈指针(sp
)的多寄存器操作,支持不同堆栈类型。
1 | stmfd sp!, {r2 - r7, lr} ; 将寄存器压入满递减堆栈(ARM 默认) |
堆栈类型:
FD(Full Descending):堆栈向低地址增长(压栈时 sp 先减后存)。
ED(Empty Descending):堆栈向低地址增长(压栈时 sp 先存后减)。
FA/EA:类似逻辑,但方向不同。 这种方式常用于函数调用时保存上下文(如保存 lr 和局部变量)。
这里也多提一嘴,我们有时可以看到像这样的ARM汇编指令:
1 | stmfd sp!, {r1 - r4} |
该指令中的**!**符号的作用是 自动更新基址寄存器(SP)的值,具体表现为:
!
表示指令执行后,基址寄存器(sp
)的值会根据操作的内存偏移量自动更新。stmfd
在存储数据前,堆栈指针 sp
先递减(预递减)。sp
的值会被更新为递减后的新地址。!
:存储数据到内存,但 sp
的值不变。!
:sp
先递减 4 * 4 = 16 字节
(每个寄存器占 4 字节,共 4 个寄存器)。r1-r4
的值依次存储到 sp
指向的新地址。sp
最终指向存储后的新栈顶地址。示例分析
1 | stmfd sp!, {r1 - r4} ; 存储前 sp - = 16 ,存储 r1 - r4,sp 更新为新地址 |
等效伪代码:
1 | sp = sp - 16 ; / / 预递减 |
1 | * (sp + 0 ) = r1; / / 存储 r1 |
1 | * (sp + 4 ) = r2; / / 存储 r2 |
1 | * (sp + 8 ) = r3; / / 存储 r3 |
1 | * (sp + 12 ) = r4; / / 存储 r4 |
1 | sp = sp; / / 由于有 `!`,sp 已更新为递减后的地址 |
应用场景
寻址方式总结
!
符号在 ARM 存储多寄存器指令中,表示 基址寄存器在操作后自动更新。对于 stmfd sp!, {r1-r4}
,它确保栈指针 sp
在存储数据后指向新的栈顶位置,简化了堆栈管理的复杂性。
第八种寻址方式相对寻址,这种方式基于当前程序计数器(PC
)的偏移量计算目标地址。
1 2 | 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汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | .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 函数输出结果:
1 2 3 4 5 6 7 8 9 10 | ; 代码段 (.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: 00001F88 sResult DCB "Result: %d" , 0 ; 字符串定义 |
我们一行一行代码来看,首先是LDR R2, =(sResult - 0x738) ,此行代码是 编译时计算字符串与某指令的偏移量。sResult是字符串的编译时地址,0x738是ADD R2, PC, R2 指令的下一条指令地址。假设编译时地址为0x00001F88,那么偏移量是如此计算:
sResult - 0x738 = 0x1F88 - 0x738 = 0x1850(编译时固定值)
接下来是ADD R2, PC, R2 ; R2 = PC + 0x1850,这里提一点,在流水线效应下PC 指向当前指令地址 + 8,当前指令地址为 0x00000730,因此 PC = 0x730 + 8 = 0x738。所以实际地址为:
R2 = 0x738 + 0x1850 = 0x1F88(即字符串的实际运行时地址)
刚才提到了流水线效应,那什么是流水线效应呢?其实就是ARM 处理器采用 三级流水线 提升指令执行效率,一共有三个阶段,分别是取指、解码、执行。取指阶段是从内存中读取下一条指令到指令寄存器,所以在 ARM 状态下,PC 总指向当前指令地址 + 8,在Thumb 状态下,PC 总指向当前指令地址 + 4;解码阶段是解析指令的操作码和操作数,确定执行逻辑。执行阶段是执行指令的实际操作。所以在 ADD R2, PC, R2 指令执行时,PC 已提前指向后续指令(0x738)。
以上是arm32的资源重定位方式,前面提到 PC 指向当前指令地址 + 8,这是 ARM32 三级流水线的特性,其实ARM64 中 PC 的行为与 ARM32 类似,但地址计算通常依赖ADRP + ADD/LDR 指令组合,而非直接通过 LDR + ADD 操作。
ARM64 通过 PC 相对寻址 实现地址计算的关键指令如下:
ADRP:计算目标地址的页基地址(高 21 位)。
1 | ADRP X0, target_label ; X0 = (PC 的页基地址) + (target_label 的页偏移) |
ADD 或 LDR:补充低 12 位地址。
1 2 | ADD X0, X0, :lo12:target_label ; 组合完整地址 LDR X1, [X0] ; 加载目标数据 |
访问全局变量 global_var
示例:
1 2 3 | ADRP X0, global_var ; 获取 global_var 的页基地址(高 21 位) ADD X0, X0, :lo12:global_var ; 补全低 12 位地址 LDR X1, [X0] ; 加载 global_var 的值到 X1 |
我们再来看一段arm64的汇编代码:
1 2 3 | .text: 00000000000051E8 ADRP X8, #isOurApk_ptr@PAGE .text: 00000000000051EC LDR X8, [X8, #isOurApk_ptr@PAGEOFF] .text: 00000000000051F0 LDR W8, [X8] |
ADRP指令用于计算符号 isOurApk_ptr 所在内存页的基地址,@PAGE 表示获取符号 isOurApk_ptr 的页基地址(高 21 位),并将其写入寄存器 X8。
ARM64 地址空间按 4KB 页对齐,ADRP 将当前 PC 值的页基地址与目标符号的页偏移相加,生成目标符号的页基地址。在程序加载时,链接器根据实际基地址修正 @PAGE 的页偏移,这样就可以确保 X8 指向正确的页。
LDR指令从内存加载数据到寄存器,@PAGEOFF 表示符号 isOurApk_ptr 在页内的低 12 位偏移。第二行代码就是将 X8(页基地址)与 @PAGEOFF(页内偏移)相加,形成完整地址,并从中加载数据到 X8。
第三行代码将X8 指向的地址加载 32 位数据 到 W8,而W8 存储的是 isOurApk_ptr 指针所指向的值。
通过 ADRP + LDR 组合,将符号 isOurApk_ptr 的地址从 编译时假设的地址 转换为 运行时实际地址,ADRP 处理高 21 位页基地址偏移,LDR 处理低 12 位页内偏移。
重定位表(如 .rela.dyn 和 .rela.plt)记录了需要修正的地址及其类型。在这里链接器生成重定位表(如 .rela.dyn),记录 @PAGE 和 @PAGEOFF 的修正信息,加载器根据实际基地址修正指令中的偏移量,使程序能正确访问内存。加载器的工作流程是先根据程序实际加载的基地址,计算目标符号的运行时地址,然后遍历重定位表,按类型修正指令中的偏移量。
动态链接 通过 GOT 和 PLT 减少启动开销,支持延迟绑定。全局偏移表GOT会存储外部符号(如全局变量、函数)的实际地址。当首次访问时,通过重定位动态解析地址并填充 GOT。过程链接表PLT能实现延迟绑定,减少启动开销。
1 2 | ; 调用外部函数 printf BL printf@PLT ; 首次调用跳转到 PLT 桩代码 |
PLT 桩代码先从 GOT 中读取函数地址,若地址未解析,触发动态链接器解析并更新 GOT,最后跳转到实际函数地址。
我们来模拟一下在动态库中访问全局变量 global_var
的场景,以下是编译时生成的代码:
1 2 | ADRP X0, global_var ; 编译时假设 global_var 地址为 0x1000 ADD X0, X0, :lo12:global_var |
在运行时进行修正,假设程序加载到基地址 0x5500000000,实际 global_var 地址为 0x5500001000。那么重定位表条目应当如此:
1 2 | Offset: 0x200 Type : R_AARCH64_ADR_PREL_PG_HI21 Symbol: global_var Offset: 0x204 Type : R_AARCH64_ADD_ABS_LO12_NC Symbol: global_var |
加载器进行操作会修正 ADRP 指令的高 21 位偏移,使其指向 0x5500001000 的页基地址,然后修正 ADD 指令的低 12 位偏移,补全地址。
在 ARM64 中,执行某条指令时,PC 指向当前指令地址 + 8依旧与 ARM32 类似。
现在我们搞清楚了资源重定位,我们接下来了解ARM64 汇编中全局变量与静态变量的存储与访问,我们需要知道.bss 段存储未初始化或初始化为 0 的全局变量、静态变量,这种方式不占用可执行文件的实际磁盘空间,仅在加载到内存时分配空间并清零,这样一来也比较节省存储资源。
.data 段存储初始化且值不为 0 的全局变量、静态变量,包含变量的初始值,占用可执行文件的实际磁盘空间,这种方式比较适合需要显式初始化的数据,如 int global_var = 42;
全局变量会定义在函数外部,作用域为整个程序。静态变量在定义时使用 static 关键字,作用域为定义它的文件或函数。全局变量和静态变量的地址在编译时或通过重定位表动态计算确定,函数通过 LDR/STR 指令从内存加载或存储数据。
1 2 3 4 5 | ; 假设 global_var 存储在 .data 段,地址为 0x1000 LDR X0, = global_var ; 将 global_var 的地址加载到 X0 LDR W1, [X0] ; 将 global_var 的值加载到 W1 ADD W1, W1, #1 ; 修改值 STR W1, [X0] ; 将新值存回 global_var |
当函数需要多次操作全局变量的值,编译器可能将值加载到寄存器或栈中进行临时保存。
1 2 3 4 5 | ; 假设需要频繁读取 global_var 的值 LDR X0, = global_var LDR W1, [X0] ; 将 global_var 的值加载到 W1 STR W1, [SP, #0] ; 临时保存到栈中(非必需,通常直接使用寄存器) ... ; 后续操作可能使用栈中的值 |
有一点要注意,全局变量和静态变量本身仍存储在 .bss 或 .data 段,栈仅用于临时保存其值的副本。将变量值保存到栈中是编译器的优化行为,并非变量本身的存储位置发生变化。
前面讲过在 ARM64 架构中前 8 个参数通过 X0-X7 传递,这里我们详细讲讲不同类型参数传递和返回值处理。
首先是基本数据类型,如果是基本数据类型作为函数参数那么就是和前面说的一样前 8 个参数通过 X0-X7 传递,我们来举个例子。
C 代码示例:
1 2 3 4 5 6 7 | / / 函数定义:接受两个整数并返回它们的和 int add( int a, int b) { return a + b; } / / 调用示例 int result = add( 10 , 20 ); |
ARM64 汇编:
1 2 3 4 5 6 7 8 9 10 | / / C 函数 add 的汇编实现 add: ADD X0, X0, X1 ; X0 = a (X0) + b (X1) RET ; 返回值通过 X0 返回 / / 调用 add( 10 , 20 ) MOV X0, #10 ; a = 10 -> X0 MOV X1, #20 ; b = 20 -> X1 BL add ; 调用函数 ; 返回值在 X0 中 |
除了基本数据类型之外肯定少不了浮点型,而浮点型则是前 8 个浮点参数通过 V0-V7 传递,返回值通过 V0 返回。这里还是举个例子。
C 代码示例:
1 2 3 4 5 6 7 | / / 函数定义:接受两个双精度浮点数并返回它们的和 double add_double(double a, double b) { return a + b; } / / 调用示例 double result = add_double( 3.14 , 2.71 ); |
ARM64 汇编:
1 2 3 4 5 6 7 8 9 10 | / / C 函数 add_double 的汇编实现 add_double: FADD D0, D0, D1 ; D0 = a (D0) + b (D1) RET ; 返回值通过 D0 (V0) 返回 / / 调用 add_double( 3.14 , 2.71 ) FMOV D0, #3.14 ; a = 3.14 -> D0 FMOV D1, #2.71 ; b = 2.71 -> D1 BL add_double ; 调用函数 ; 返回值在 D0 中 |
基本数据类型和浮点型都有了那自然少不了结构体,结构体≤16 字节的称为小结构体,而>16 字节的叫做大结构体。小结构体的结构体成员按顺序拆解到 X0-X7 或 V0-V7 寄存器,返回值通过 X0-X1 或 V0-V3 返回。
C 代码示例:
1 2 3 4 5 6 7 8 9 10 11 | / / 定义小结构体 struct Point { int x; int y; }; / / 函数定义:接受结构体并返回其成员的乘积 int multiply(struct Point p) { return p.x * p.y; } / / 调用示例 struct Point pt = { 3 , 4 }; int result = multiply(pt); |
ARM64 汇编:
1 2 3 4 5 6 7 8 9 10 | / / C 函数 multiply 的汇编实现 multiply: MUL X0, X0, X1 ; X0 = p.x (X0) * p.y (X1) RET ; 返回值通过 X0 返回 / / 调用 multiply({ 3 , 4 }) MOV X0, #3 ; p.x = 3 -> X0 MOV X1, #4 ; p.y = 4 -> X1 BL multiply ; 调用函数 ; 返回值在 X0 中 |
大结构体的结构体则是通过 隐式指针(调用者分配内存,地址通过 X8 传递)传递,返回值需调用者预先分配内存,地址通过 X8 传递。
C 代码示例:
1 2 3 4 5 6 7 8 9 10 11 | / / 定义大结构体(假设占用 32 字节) struct BigData { char data[ 32 ]; }; / / 函数定义:初始化结构体 void init_bigdata(struct BigData * bd) { for ( int i = 0 ; i < 32 ; i + + ) bd - >data[i] = i; } / / 调用示例 struct BigData bd; init_bigdata(&bd); |
ARM64 汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | / / C 函数 init_bigdata 的汇编实现 init_bigdata: MOV X1, #0 ; i = 0 loop: STRB W1, [X0, X1] ; bd - >data[i] = i ADD X1, X1, #1 ; i++ CMP X1, #32 B.LT loop RET / / 调用 init_bigdata(&bd) SUB SP, SP, #32 ; 在栈上分配 32 字节空间 MOV X0, SP ; X0 指向栈空间 BL init_bigdata ; 调用函数 ADD SP, SP, #32 ; 释放栈空间 |
结构体都讲了,那么数组自然是少不了的,数组作为指针传递(等同于传递首地址),若数组是结构体的一部分,按结构体规则处理。
C 代码示例:
1 2 3 4 5 6 7 8 9 10 | / / 函数定义:计算数组元素之和 int sum ( int * arr, int size) { int total = 0 ; for ( int i = 0 ; i < size; i + + ) total + = arr[i]; return total; } / / 调用示例 int arr[ 4 ] = { 1 , 2 , 3 , 4 }; int total = sum (arr, 4 ); |
ARM64 汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | / / C 函数 sum 的汇编实现 sum : MOV X2, #0 ; total = 0 MOV X3, #0 ; i = 0 loop: CMP X3, X1 ; 比较 i 和 size B.GE end LDR W4, [X0, X3, LSL #2] ; 加载 arr[i] ADD X2, X2, X4 ; total + = arr[i] ADD X3, X3, #1 ; i++ B loop end: MOV X0, X2 ; 返回值 X0 = total RET / / 调用 sum (arr, 4 ) / / 在栈上分配并初始化数组 SUB SP, SP, #16 ; 分配 16 字节栈空间 MOV X0, SP ; X0 指向数组首地址 MOV W1, #1 STR W1, [X0] ; arr[ 0 ] = 1 MOV W1, #2 STR W1, [X0, #4] ; arr[1] = 2 MOV W1, #3 STR W1, [X0, #8] ; arr[2] = 3 MOV W1, #4 STR W1, [X0, #12] ; arr[3] = 4 MOV X1, #4 ; size = 4 -> X1 BL sum ; 调用函数 ADD SP, SP, #16 ; 释放栈空间 |
除此之外函数传参一般都是混合类型参数,就是啥类型都可能会有,我们也来举个例子。
C 代码示例:
1 2 3 4 5 6 7 8 | / / 函数定义:混合整型、浮点、结构体参数 double mixed( int a, double b, struct Point p) { return a * b + p.x * p.y; } / / 调用示例 struct Point pt = { 3 , 4 }; double result = mixed( 10 , 3.14 , pt); |
ARM64 汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | / / C 函数 mixed 的汇编实现 mixed: SCVTF D0, X0 ; 将整数 a (X0) 转换为浮点 D0 FMUL D0, D0, D1 ; a * b (D1) MUL X2, X2, X3 ; p.x (X2) * p.y (X3) SCVTF D2, X2 ; 将乘积转换为浮点 D2 FADD D0, D0, D2 ; 最终结果 D0 = a * b + p.x * p.y RET / / 调用 mixed( 10 , 3.14 , pt) MOV X0, #10 ; a = 10 -> X0 FMOV D1, #3.14 ; b = 3.14 -> D1 MOV X2, #3 ; p.x = 3 -> X2 MOV X3, #4 ; p.y = 4 -> X3 BL mixed ; 调用函数 ; 返回值在 D0 中 |
到此为止我们对arm汇编有了一定的了解,接下来我将对ARM32 和 ARM64 的主要指令集进行个总结。
ARM32指令集详解
1. 通用指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
数据处理 | MOV |
寄存器间数据移动 | MOV R0, R1 → R0 = R1 |
ADD |
加法运算 | ADD R0, R1, R2 → R0 = R1 + R2 |
|
SUB |
减法运算 | SUB R0, R1, #5 → R0 = R1 - 5 |
|
MUL |
乘法运算 | MUL R0, R1, R2 → R0 = R1 × R2 |
|
SDIV |
有符号整数除法 | SDIV R0, R1, R2 → R0 = R1 / R2 |
|
UDIV |
无符号整数除法 | UDIV R0, R1, R2 → R0 = R1 / R2 |
|
逻辑运算 | AND |
按位与 | AND R0, R1, #0xFF → R0 = R1 & 0xFF |
ORR |
按位或 | ORR R0, R1, R2 → R0 = R1 | R2 |
|
EOR |
按位异或 | EOR R0, R1, R2 → R0 = R1 ^ R2 |
|
BIC |
位清除 | BIC R0, R1, R2 → R0 = R1 & ~R2 |
|
分支跳转 | B |
无条件跳转 | B label → 跳转到标签 label |
BL |
带链接跳转(保存返回地址到 LR) | BL func → 调用函数 func |
|
BX |
跳转到寄存器指定地址 | BX LR → 返回调用者 |
|
BLX |
带链接跳转并切换指令集 | BLX R0 → 调用 R0 指向的函数 |
|
加载存储 | LDR |
从内存加载数据到寄存器 | LDR R0, [R1] → R0 = *R1 |
STR |
将寄存器值存储到内存 | STR R0, [R1] → *R1 = R0 |
|
LDM |
批量加载多个寄存器 | LDMIA R1!, {R0-R3} → 从 R1 加载到 R0-R3 |
|
STM |
批量存储多个寄存器 | STMIA R1!, {R0-R3} → 存储 R0-R3 到 R1 |
|
PUSH |
压栈 | PUSH {R0, R1} → 压入 R0 和 R1 |
|
POP |
弹栈 | POP {R0, R1} → 弹出到 R0 和 R1 |
|
移位操作 | LSL |
逻辑左移 | LSL R0, R1, #2 → R0 = R1 << 2 |
LSR |
逻辑右移 | LSR R0, R1, #3 → R0 = R1 >> 3 |
|
ASR |
算术右移(保留符号位) | ASR R0, R1, #4 → R0 = (int)R1 >> 4 |
|
ROR |
循环右移 | ROR R0, R1, #1 → R0 = 循环右移1位 |
|
条件执行 | CMP |
比较两个值并设置标志位 | CMP R0, R1 → 设置 Z/C/V 标志 |
TEQ |
异或比较(测试相等性) | TEQ R0, R1 → 若 R0 ^ R1 = 0,则 Z=1 |
|
TST |
位测试(按位与) | TST R0, #0x80 → 测试最高位是否为1 |
|
协处理器 | MCR |
写协处理器寄存器 | MCR p15, 0, R0, c1, c0, 0 → 配置 MMU |
MRC |
读协处理器寄存器 | MRC p15, 0, R0, c1, c0, 0 → 读取 MMU 状态 |
|
CDP |
协处理器数据处理指令 | CDP p10, 0, c0, c1, c2, 0 → 浮点协处理器操作 |
2. 浮点与 SIMD 指令(VFP/NEON)
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
浮点运算 | FADD |
浮点加法 | FADD S0, S1, S2 → S0 = S1 + S2 |
FSUB |
浮点减法 | FSUB D0, D1, D2 → D0 = D1 - D2 |
|
FMUL |
浮点乘法 | FMUL S0, S1, S2 → S0 = S1 × S2 |
|
FDIV |
浮点除法 | FDIV D0, D1, D2 → D0 = D1 / D2 |
|
FSQRT |
浮点平方根 | FSQRT S0, S1 → S0 = √S1 |
|
类型转换 | FCVT |
浮点精度转换 | FCVT D0, S0 → D0 = (double)S0 |
FSITOD |
有符号整数转双精度浮点 | FSITOD D0, R0 → D0 = (double)R0 |
|
FUITOS |
无符号整数转单精度浮点 | FUITOS S0, R0 → S0 = (float)R0 |
|
向量运算 | VADD |
向量加法 | VADD.I16 D0, D1, D2 → D0 = D1 + D2 |
VSUB |
向量减法 | VSUB.F32 Q0, Q1, Q2 → Q0 = Q1 - Q2 |
|
VMUL |
向量乘法 | VMUL.I32 D0, D1, D2 → D0 = D1 × D2 |
|
VDOT |
向量点积 | VDOT.S32 D0, D1, D2 → D0 = Σ(D1[i]×D2[i]) |
3. 系统与控制指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
系统寄存器 | MRS |
读取系统寄存器到通用寄存器 | MRS R0, CPSR → R0 = CPSR |
MSR |
写通用寄存器值到系统寄存器 | MSR CPSR, R0 → CPSR = R0 |
|
异常处理 | SVC |
触发系统调用 | SVC #0 → 调用内核服务 |
CPSID |
禁用中断 | CPSID I → 禁用 IRQ 中断 |
|
CPSIE |
使能中断 | CPSIE I → 使能 IRQ 中断 |
|
原子操作 | LDREX |
独占加载内存值 | LDREX R0, [R1] → R0 = *R1(原子标记) |
STREX |
尝试独占存储内存值 | STREX R2, R0, [R1] → *R1 = R0(若成功,R2=0) |
ARM64(AArch64)指令集详解
1. 通用指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
数据处理 | ADD |
加法运算 | ADD X0, X1, X2 → X0 = X1 + X2 |
SUB |
减法运算 | SUB X0, X1, #5 → X0 = X1 - 5 |
|
MUL |
乘法运算 | MUL X0, X1, X2 → X0 = X1 × X2 |
|
SDIV |
有符号整数除法 | SDIV X0, X1, X2 → X0 = X1 / X2 |
|
UDIV |
无符号整数除法 | UDIV X0, X1, X2 → X0 = X1 / X2 |
|
逻辑运算 | AND |
按位与 | AND X0, X1, #0xFF → X0 = X1 & 0xFF |
ORR |
按位或 | ORR X0, X1, X2 → X0 = X1 | X2 |
|
EOR |
按位异或 | EOR X0, X1, X2 → X0 = X1 ^ X2 |
|
BIC |
位清除 | BIC X0, X1, X2 → X0 = X1 & ~X2 |
|
分支跳转 | B |
无条件跳转 | B label → 跳转到标签 label |
BL |
带链接跳转(保存返回地址到 X30) | BL func → 调用函数 func |
|
RET |
从函数返回 | RET → 返回调用者(等效于 BR X30 ) |
|
BR |
跳转到寄存器指定地址 | BR X0 → 跳转到 X0 指向的地址 |
|
加载存储 | LDR |
从内存加载数据到寄存器 | LDR X0, [X1] → X0 = *X1 |
STR |
将寄存器值存储到内存 | STR X0, [X1] → *X1 = X0 |
|
LDP |
批量加载两个寄存器 | LDP X0, X1, [X2] → X0=X2, X1=(X2+8) |
|
STP |
批量存储两个寄存器 | STP X0, X1, [X2] → *X2=X0, *(X2+8)=X1 |
|
LDUR |
支持非对齐地址加载 | LDUR X0, [X1, #3] → X0 = *(X1+3) |
|
STUR |
支持非对齐地址存储 | STUR X0, [X1, #3] → *(X1+3) = X0 |
|
移位操作 | LSL |
逻辑左移(64位) | LSL X0, X1, #2 → X0 = X1 << 2 |
LSR |
逻辑右移(64位) | LSR X0, X1, #3 → X0 = X1 >> 3 |
|
ASR |
算术右移(保留符号位) | ASR X0, X1, #4 → X0 = (int64_t)X1 >> 4 |
|
ROR |
循环右移(64位) | ROR X0, X1, #1 → X0 = 循环右移1位 |
|
位域操作 | BFM |
位域移动与合并 | BFM X0, X1, #4, #7 → 将 X1 的位 [4:7] 插入 X0 |
SBFM |
有符号位域操作 | SBFM X0, X1, #8, #15 → 提取有符号位域 |
|
UBFM |
无符号位域操作 | UBFM X0, X1, #8, #15 → 提取无符号位域 |
2. 浮点与 SIMD 指令(NEON/Advanced SIMD)
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
浮点运算 | FADD |
浮点加法 | FADD D0, D1, D2 → D0 = D1 + D2 |
FSUB |
浮点减法 | FSUB S0, S1, S2 → S0 = S1 - S2 |
|
FMUL |
浮点乘法 | FMUL V0.2D, V1.2D, V2.2D → 双精度向量乘法 |
|
FDIV |
浮点除法 | FDIV D0, D1, D2 → D0 = D1 / D2 |
|
FSQRT |
浮点平方根 | FSQRT D0, D1 → D0 = √D1 |
|
向量运算 | ADD |
SIMD 加法 | ADD V0.4S, V1.4S, V2.4S → 4 路单精度加法 |
MUL |
SIMD 乘法 | MUL V0.8H, V1.8H, V2.8H → 8 路半字乘法 |
|
类型转换 | FCVT |
浮点精度转换 | FCVT S0, D1 → S0 = (float)D1 |
SCVTF |
有符号整数转浮点 | SCVTF D0, X1 → D0 = (double)X1 |
|
UCVTF |
无符号整数转浮点 | UCVTF S0, W1 → S0 = (float)W1 |
|
加密扩展 | AESE |
AES 加密单轮 | AESE V0.16B, V1.16B → AES 加密单轮 |
AESD |
AES 解密单轮 | AESD V0.16B, V1.16B → AES 解密单轮 |
|
SHA256H |
SHA-256 哈希混合操作 | SHA256H Q0, Q1, Q2 → SHA-256 计算 |
3. 系统与控制指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
系统寄存器 | MRS |
读取系统寄存器到通用寄存器 | MRS X0, SCTLR_EL1 → X0 = SCTLR_EL1 |
MSR |
写通用寄存器值到系统寄存器 | MSR SCTLR_EL1, X0 → SCTLR_EL1 = X0 |
|
异常处理 | SVC |
触发系统调用 | SVC #0 → 调用内核服务 |
HVC |
触发虚拟机监控调用 | HVC #0x1234 → 虚拟机监控程序处理 |
|
ERET |
从异常返回 | ERET → 返回用户模式 |
|
原子操作 | LDXR |
独占加载内存值 | LDXR X0, [X1] → X0 = *X1(原子标记) |
STXR |
尝试独占存储内存值 | STXR W2, X0, [X1] → *X1 = X0(若成功,W2=0) |
|
CAS |
原子比较交换 | CAS X0, X1, [X2] → 若 *X2 = X0,则 *X2 = X1 |
|
内存屏障 | DMB |
数据内存屏障 | DMB SY → 全系统内存顺序同步 |
DSB |
数据同步屏障 | DSB ISH → 内部共享域同步 |
|
ISB |
指令同步屏障 | ISB → 清空流水线 |
以上只是一些主要的指令集,如若有误,还请大佬指正。arm汇编我们算是讲完了,那么接下来就开始讲如何使用IDA进行动态调试吧。
关于IDA动态调试我们需要进行一些准备工作,不管是模拟器还是真机都需要先将IDA调试服务器push到其上面去,当你进入到IDA文件夹下的dbgsrv文件夹中就可以看到有不少的IDA调试服务器。
具体使用哪个就要根据你模拟器或者真机的架构选择对应的IDA调试服务器,比如手机是v8a架构就可以选择android_server,当然你那可能是android_server64,当然现在的真机大部分都是v8a架构的。
选择好架构对应的IDA调试服务器后,我们需要通过adb将IDA调试服务器给push到手机或者模拟器上,命令如下:
1 | adb push . / android_server / data / local / tmp |
android_server这里需要是你IDA服务器的位置,当然你也可以在dbgsrv文件夹下直接这样运行,/data/local/tmp为你要push到的位置,一般服务器我都放这个位置,但其实存放位置随意,导入成功后最好是进行重命名,这样可以避免一些反调试,重命名完成后可以长按IDA服务器,点击属性,将权限从666更改为777。当我们把服务器push成功后,接下来我们要动态调试某个APP可以选择像之前一样添加可调试权限,也可以通过XAppDebug去hook要调试的APP,XAppDebug项目地址:
GitHub - Palatis/XAppDebug: toggle app debuggable
hook成功后就可以通过adb shell命令进入 Android 设备的 shell 环境,然后通过su命令切换到超级用户权限,接下来通过cd data/local/tmp 命令切换到存储IDA服务器的目录下,最后通过./android_service命令来启动IDA服务器,在启动的时候我们可以选择在该命令后面加上-p <端口号>来指定端口号,这样做可以一定程度上避免一些反调试手段。我们成功启动IDA服务之后,接下来就应该进行端口转发,可以使用以下命令进行端口转发:
1 2 3 4 5 | / / 端口号为默认的 23946 adb forward tcp: 23946 tcp: 23946 / / 端口号被指定 adb forward tcp:<端口号> tcp:<端口号> |
成功完成端口转发后,就可以进行动态调试,动态调试分为两种模式启动,分别是以debug模式启动和以普通模式启动,我们先来尝试以普通模式启动,先进入IDA打开我们要调试的so文件,进入后点击左上的“debugger”选项,这时就会弹出“select debugger...”选项后点击,或者也可以直接按F9直接弹出如下弹窗:
因为我们要调试so文件,所以选择"Remote ARM Linux/Android debugger"选项,如果要将该选项所使用的调试器设置为默认调试器就可以勾选“Set as default debugger”,最后点击OK即可。
再次点击“debugger”选项你就会发现当中选项变多了,接下来还要配置点东西,在“debugger”选项下选择“process options...”选项,这时就需要配置主机名和端口号:
主机名选择127.0.0.1即可,端口号默认23946,如果指定了其他端口号就需要修改为指定的端口号。配置好后就可以点击“Attach to process”选项,点击后会显示模拟器/真机中运行的进程,这时直接搜索我们要附加的进程的包名,直接选择Name为包名的那个点击 ok 打开即可。普通模式配置流程大致如此,其实我不咋爱用动态调试,因为反调试手段贼多而且问题也特别多,我还是用frida进行hook用的更多,我再跟大伙聊聊以debug模式进行动态调试,为什么会有debug模式的动态调试呢?因为程序中有的代码只在加载阶段会执行,有的参数只在加载阶段会生成,加载过一次之后就不再会执行了,所以就有了通过debug模式去挂起app。
debug模式启动就需要在端口转发之后运行命令:
1 | adb shell am start - D - n 包名 / .类名 |
运行以上命令后就通过IDA附加要调试APP的进程,在执行接下来的命令之前我们需要先获取进程的PID,可以使用以下命令:
1 | adb shell pidof 包名 |
获取到进程的PID后,我们就需要打开DDMS查看应用端口8700,打开DDMS后就可以运行以下命令:
1 | adb forward tcp: 8700 jdwp:<PID> |
命令中的"
1 | jdb - connect com.sun.jdi.SocketAttach:hostname = 127.0 . 0.1 ,port = 8700 |
如果运行报致命错误:无法附加到目标 VM,或者遇到其他问题,也可以试着先运行jdb命令再F9运行程序,如果还不行就试试先运行jdb命令进行连接然后再快速使用ida附加到进程,慢了的话应用可能会跑起来。
我还是说一下比较好,我在使用动态调试的时候遇到了不少的问题,而且反调试手段也颇多,我还是推荐尝试去使用frida,效果不比这个差的,甚至效果可能还会更好。
更多【Android安全-安卓逆向基础知识之ARM汇编和so层动态调试】相关视频教程:www.yxfzedu.com