安卓逆向基础知识之ARM汇编和so层动态调试

admin 2025年6月1日21:30:58评论22 views字数 23412阅读78分2秒阅读模式

1

ARM汇编指令

在进行so层的动态调试前需要学习些知识点,比如说arm汇编,我们在用ida反编译so文件可以看到arm汇编指令,这个指令是CPU机器指令的助记符。这是什么意思呢?首先CPU机器指令其实也就是那由1和0组成的二进制,如果要我们通过CPU的机器指令来编程那是非常难且枯燥无聊了的,反正我是做不到。但我使用汇编代码来编程就还是可以编写些简单的程序的,嘿嘿!我们先来看一段反编译出来的简单的汇编代码:

安卓逆向基础知识之ARM汇编和so层动态调试

我们可以在图中看到汇编指令的左边有所对应的机器码,如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指令集大致情况如图所示:

安卓逆向基础知识之ARM汇编和so层动态调试

在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汇编代码,第一段:

安卓逆向基础知识之ARM汇编和so层动态调试

第二段:

安卓逆向基础知识之ARM汇编和so层动态调试

这两段汇编代码中可以看到些看似一样却又有些不一样的指令,比如说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(有符号数小于等于)
有符号数的循环终止条

示例

ADDEQ R0, R1, R2   @ 当 Z=1 时执行 R0 = R1 + R2BNE 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

示例

LDRBR0[R1]    @ 从 R1 地址加载 8 位数据到 R0(高位补零)LDRSHR2[R3]   @ 从 R3 地址加载 16 位有符号数据到 R2

三、标志位更新后缀

后缀 作用 典型指令
S
更新 CPSR 中的标志位(Z, N, C, V)
ADDS

,SUBS
!
更新基址寄存器(写回地址)
LDR R0, [R1, #4]!

示例

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个寄存器。

安卓逆向基础知识之ARM汇编和so层动态调试

上图是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 中不可直接修改,但可通过分支指令(如BBL)间接控制。
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 中的值,需自行负责保存,比如提前将值存到堆栈或其他安全位置。被调用函数无需关心这些寄存器的原始值,可直接使用。这样做是为了减轻被调用函数的负担,提高函数调用效率。

以下是栈帧管理:

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 位),当这两个寄存器作为源寄存器时值始终为零,举个例子:

ADD W0, W1, WZR   ; W0 = W1 +0 → 等价于 MOV W0, W1SUB X2, X3, XZR   ; X2 = X3 -0 → 等价于 MOV X2, X3

而当 WZR 或 XZR 作为目标寄存器时,写入操作会被硬件忽略,这也举个例子:

MOV WZR, W1   ; 无效操作,WZR 的值仍为 0STR 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
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]

Q0-Q31:128 位完整视图,对应V0.QV31.Q

D0-D31:64 位视图,对应V0.DV31.D

S0-S31:32 位视图,对应V0.SV31.S

H0-H31:16 位视图,对应V0.HV31.H

B0-B31:8 位视图,对应V0.BV31.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

这种方式操作速度最快,仅涉及寄存器间的数据传输。一般适用于频繁的数据交换或临时值保存。

第二种寻址方式立即寻址,该方式操作数是直接编码在指令中的常量(立即数),就像下面的代码一样:

mov r0, #0xFF00  ; 将立即数 0xFF00 加载到 r0

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等效于乘 2n2n),也适用于位操作(如掩码提取、数据对齐)。

第四种寻址方式寄存器间接寻址,这种方式使用寄存器中的值作为内存地址,访问该地址处的数据。

ldr r1, [r2]  ; 将 r2 指向的内存地址的值加载到 r1

其实这个可以理解为C 语言中的 int x = *p;

这种方式必须通过 ldr 或 str 指令访问内存,适用于动态内存操作(如指针遍历)。

第五种寻址方式基址变址寻址,这种方式是通过基址寄存器(Base Register)加偏移量(Offset)计算有效地址。

ldr r1, [r2, #4]  ; 访问 r2 + 4 地址处的值

该种寻址方式还有两种变体:

前变址:先更新基址寄存器,再访问内存。

ldr r1, [r2, #4]!  ; 等效于 r2 = r2 + 4,然后 r1 = [r2]

后变址:先访问内存,再更新基址寄存器。

ldr r1, [r2], #4  ; 先 r1 = [r2],然后 r2 = r2 + 4

这种方式常用于数组遍历、结构体成员访问。

第六种寻址方式为多寄存器寻址,该方式是单条指令批量操作多个寄存器。

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汇编指令:

stmfd sp!, {r1-r4}

该指令中的**!**符号的作用是 自动更新基址寄存器(SP)的值,具体表现为:

1.基址寄存器回写!表示指令执行后,基址寄存器(sp)的值会根据操作的内存偏移量自动更新。

满递减栈(Full Descending Stack)

  • stmfd
    在存储数据前,堆栈指针sp先递减(预递减)。
  • 存储完数据后,sp的值会被更新为递减后的新地址。

5.具体操作流程

!:存储数据到内存,但sp的值不变。

!

  1. sp
    先递减4 * 4 = 16 字节(每个寄存器占 4 字节,共 4 个寄存器)。
  2. r1-r4的值依次存储到sp指向的新地址。
  3. 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: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 位)。

ADRP X0, target_label   ; X0 = (PC 的页基地址) + (target_label 的页偏移)

ADD 或 LDR:补充低 12 位地址。

ADD X0, X0, :lo12:target_label  ; 组合完整地址LDR X1, [X0]                    ; 加载目标数据

访问全局变量global_var示例:

ADRP X0, global_var      ; 获取 global_var 的页基地址(高 21 位)ADD X0, X0, :lo12:global_var ; 补全低 12 位地址LDR X1, [X0]             ; 加载 global_var 的值到 X1

我们再来看一段arm64的汇编代码:

.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能实现延迟绑定,减少启动开销。

; 调用外部函数 printfBL printf@PLT    ; 首次调用跳转到 PLT 桩代码

PLT 桩代码先从 GOT 中读取函数地址,若地址未解析,触发动态链接器解析并更新 GOT,最后跳转到实际函数地址。

我们来模拟一下在动态库中访问全局变量global_var的场景,以下是编译时生成的代码:

ADRP X0, global_var      ; 编译时假设 global_var 地址为 0x1000ADD X0X0:lo12:global_var

在运行时进行修正,假设程序加载到基地址 0x5500000000,实际 global_var 地址为 0x5500001000。那么重定位表条目应当如此:

Offset0x200  TypeR_AARCH64_ADR_PREL_PG_HI21  Symbol: global_var  Offset0x204  TypeR_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 指令从内存加载或存储数据。

; 假设 global_var 存储在 .data 段,地址为 0x1000LDR X0, =global_var  ; 将 global_var 的地址加载到 X0LDR W1, [X0]        ; 将 global_var 的值加载到 W1ADD W1, W1, #1      ; 修改值STR W1, [X0]        ; 将新值存回 global_var

当函数需要多次操作全局变量的值,编译器可能将值加载到寄存器或栈中进行临时保存。

; 假设需要频繁读取 global_var 的值LDR X0, =global_varLDR W1, [X0]        ; 将 global_var 的值加载到 W1STR W1, [SP, #0]    ; 临时保存到栈中(非必需,通常直接使用寄存器)...                 ; 后续操作可能使用栈中的值

有一点要注意,全局变量和静态变量本身仍存储在 .bss 或 .data 段,栈仅用于临时保存其值的副本。将变量值保存到栈中是编译器的优化行为,并非变量本身的存储位置发生变化。

前面讲过在 ARM64 架构中前 8 个参数通过 X0-X7 传递,这里我们详细讲讲不同类型参数传递和返回值处理。

首先是基本数据类型,如果是基本数据类型作为函数参数那么就是和前面说的一样前 8 个参数通过 X0-X7 传递,我们来举个例子。

C 代码示例:

// 函数定义:接受两个整数并返回它们的和int add(int a, int b) {return a + b;}// 调用示例int result = add(1020);

ARM64 汇编:

// 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 代码示例:

// 函数定义:接受两个双精度浮点数并返回它们的和doubleadd_double(double a, double b) {return a + b;}// 调用示例double result = add_double(3.142.71);

ARM64 汇编:

// 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 代码示例:

// 定义小结构体struct Point { int x; int y; };// 函数定义:接受结构体并返回其成员的乘积intmultiply(struct Point p) {return p.x * p.y;}// 调用示例struct Point pt = {34};int result = multiply(pt);

ARM64 汇编:

// 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 代码示例:

// 定义大结构体(假设占用 32 字节)struct BigData { char data[32]; };// 函数定义:初始化结构体voidinit_bigdata(struct BigData *bd) {for (int i = 0; i < 32; i++) bd->data[i] = i;}// 调用示例struct BigData bd;init_bigdata(&bd);

ARM64 汇编:

// C 函数 init_bigdata 的汇编实现init_bigdata:    MOV  X1, #0           ; i = 0loop:    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 代码示例:

// 函数定义:计算数组元素之和intsum(int *arr, int size) {int total = 0;for (int i = 0; i < size; i++) total += arr[i];return total;}// 调用示例int arr[4] = {1234};int total = sum(arr, 4);

ARM64 汇编:

// C 函数 sum 的汇编实现sum:    MOV  X2, #0           ; total = 0    MOV  X3, #0           ; i = 0loop:    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    loopend:    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 代码示例:

// 函数定义:混合整型、浮点、结构体参数doublemixed(int a, double b, struct Point p) {return a * b + p.x * p.y;}// 调用示例struct Point pt = {34};double result = mixed(103.14, pt);

ARM64 汇编:

// 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进行动态调试吧。

2

IDA 动态调试

关于IDA动态调试我们需要进行一些准备工作,不管是模拟器还是真机都需要先将IDA调试服务器push到其上面去,当你进入到IDA文件夹下的dbgsrv文件夹中就可以看到有不少的IDA调试服务器。

安卓逆向基础知识之ARM汇编和so层动态调试

具体使用哪个就要根据你模拟器或者真机的架构选择对应的IDA调试服务器,比如手机是v8a架构就可以选择android_server,当然你那可能是android_server64,当然现在的真机大部分都是v8a架构的。

选择好架构对应的IDA调试服务器后,我们需要通过adb将IDA调试服务器给push到手机或者模拟器上,命令如下:

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服务之后,接下来就应该进行端口转发,可以使用以下命令进行端口转发:

// 端口号为默认的23946adb forward tcp:23946 tcp:23946 // 端口号被指定adb forward tcp:<端口号> tcp:<端口号>

成功完成端口转发后,就可以进行动态调试,动态调试分为两种模式启动,分别是以debug模式启动和以普通模式启动,我们先来尝试以普通模式启动,先进入IDA打开我们要调试的so文件,进入后点击左上的“debugger”选项,这时就会弹出“select debugger...”选项后点击,或者也可以直接按F9直接弹出如下弹窗:

安卓逆向基础知识之ARM汇编和so层动态调试

因为我们要调试so文件,所以选择"Remote ARM Linux/Android debugger"选项,如果要将该选项所使用的调试器设置为默认调试器就可以勾选“Set as default debugger”,最后点击OK即可。

再次点击“debugger”选项你就会发现当中选项变多了,接下来还要配置点东西,在“debugger”选项下选择“process options...”选项,这时就需要配置主机名和端口号:

安卓逆向基础知识之ARM汇编和so层动态调试

主机名选择127.0.0.1即可,端口号默认23946,如果指定了其他端口号就需要修改为指定的端口号。配置好后就可以点击“Attach to process”选项,点击后会显示模拟器/真机中运行的进程,这时直接搜索我们要附加的进程的包名,直接选择Name为包名的那个点击 ok 打开即可。普通模式配置流程大致如此,其实我不咋爱用动态调试,因为反调试手段贼多而且问题也特别多,我还是用frida进行hook用的更多,我再跟大伙聊聊以debug模式进行动态调试,为什么会有debug模式的动态调试呢?因为程序中有的代码只在加载阶段会执行,有的参数只在加载阶段会生成,加载过一次之后就不再会执行了,所以就有了通过debug模式去挂起app。

debug模式启动就需要在端口转发之后运行命令:

adb shell am start -D -n 包名/.类名

运行以上命令后就通过IDA附加要调试APP的进程,在执行接下来的命令之前我们需要先获取进程的PID,可以使用以下命令:

adb shell pidof 包名

获取到进程的PID后,我们就需要打开DDMS查看应用端口8700,打开DDMS后就可以运行以下命令:

adb forward tcp:8700 jdwp:<PID>

命令中的PID是我们前面获取的APP进程的PID,接下来IDA中运行程序,然后再在命令行中执行以下命令:

jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700

如果运行报致命错误:无法附加到目标 VM,或者遇到其他问题,也可以试着先运行jdb命令再F9运行程序,如果还不行就试试先运行jdb命令进行连接然后再快速使用ida附加到进程,慢了的话应用可能会跑起来。

我还是说一下比较好,我在使用动态调试的时候遇到了不少的问题,而且反调试手段也颇多,我还是推荐尝试去使用frida,效果不比这个差的,甚至效果可能还会更好。

安卓逆向基础知识之ARM汇编和so层动态调试

看雪ID:黎明与黄昏

https://bbs.kanxue.com/user-home-926486.htm

*本文为看雪论坛优秀文章,由 黎明与黄昏 原创,转载请注明来自看雪社区

#

原文始发于微信公众号(看雪学苑):安卓逆向基础知识之ARM汇编和so层动态调试

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年6月1日21:30:58
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   安卓逆向基础知识之ARM汇编和so层动态调试https://cn-sec.com/archives/4122489.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息