1
ARM汇编指令
在进行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 |
|
|
|
NE |
|
|
|
CS/HS |
|
|
|
CC/LO |
|
|
|
MI |
|
|
|
PL |
|
|
|
VS |
|
|
|
VC |
|
|
|
HI |
|
|
|
LS |
|
|
|
GE |
|
|
|
LT |
|
|
|
GT |
|
|
|
LE |
|
|
|
示例:
ADDEQ R0, R1, R2 @ 当 Z=1 时执行 R0 = R1 + R2BNE loop @ 当 Z=0 时跳转到 loop 标签
二、数据操作后缀
这些后缀指定操作的数据类型或内存访问模式:
后缀 | 含义 | 用途 |
---|---|---|
B |
|
LDRB ,STRB ) |
H |
|
LDRH ,STRH ) |
SB |
|
LDRSB ) |
SH |
|
LDRSH ) |
T |
|
LDRT ,STRT ) |
D |
|
LDP ,STP ) |
示例:
LDRBR0, [R1] @ 从 R1 地址加载 8 位数据到 R0(高位补零)LDRSHR2, [R3] @ 从 R3 地址加载 16 位有符号数据到 R2
三、标志位更新后缀
后缀 | 作用 | 典型指令 |
---|---|---|
S |
|
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 |
|
BX LR |
W |
ADDW ) |
ADDW R0, R1, #42 |
N |
|
IT NE |
五、协处理器操作后缀
后缀 | 用途 | 示例指令 |
---|---|---|
P |
MCR ,MRC ) |
MCR p15, 0, R0, c1, c0, 0 |
L |
LDC ,STC ) |
LDC p2, c3, [R0] |
六、浮点运算后缀(VFP/NEON)
后缀 | 用途 | 示例指令 |
---|---|---|
F32 |
|
VADD.F32 S0, S1, S2 |
F64 |
|
VMOV.F64 D0, #3.14 |
I8/I16 |
|
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 | 参数/返回值寄存器 |
|
X8 | 间接结果寄存器 |
|
X9-X15 | 临时寄存器 |
|
X16-X17 | 平台专用寄存器(IP0/IP1) |
|
X18 | 平台保留寄存器 |
|
X19-X28 | 被调用者保存寄存器 |
|
X29 | 帧指针(FP) |
|
X30 | 链接寄存器(LR) |
BL 指令的返回地址)。 |
二、特殊寄存器
寄存器 | 全称 | 详细说明 |
---|---|---|
SP | 堆栈指针(Stack Pointer) |
|
PC | 程序计数器(Program Counter) |
B 、BL )间接控制。 |
PSR | 程序状态寄存器(Program Status Register) |
|
上面的是比较枯燥的,所以我们大致模拟一下寄存器的使用场景,当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) |
---|---|---|
寄存器位数 |
|
|
链接寄存器 |
|
|
程序计数器 |
|
|
状态寄存器 |
|
|
这里补充一个关于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 |
|
|
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
这种方式操作速度最快,仅涉及寄存器间的数据传输。一般适用于频繁的数据交换或临时值保存。
第二种寻址方式立即寻址,该方式操作数是直接编码在指令中的常量(立即数),就像下面的代码一样:
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
的值不变。
◆有!
:
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: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 X0, X0, :lo12:global_var
在运行时进行修正,假设程序加载到基地址 0x5500000000,实际 global_var 地址为 0x5500001000。那么重定位表条目应当如此:
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 指令从内存加载或存储数据。
; 假设 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(10, 20);
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.14, 2.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 = {3, 4};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] = {1, 2, 3, 4};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 = {3, 4};double result = mixed(10, 3.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
|
ADD |
|
ADD R0, R1, R2
|
|
SUB |
|
SUB R0, R1, #5
|
|
MUL |
|
MUL R0, R1, R2
|
|
SDIV |
|
SDIV R0, R1, R2
|
|
UDIV |
|
UDIV R0, R1, R2
|
|
逻辑运算 | AND |
|
AND R0, R1, #0xFF
|
ORR |
|
ORR R0, R1, R2
|
|
EOR |
|
EOR R0, R1, R2
|
|
BIC |
|
BIC R0, R1, R2
|
|
分支跳转 | B |
|
B label
label |
BL |
|
BL func
func |
|
BX |
|
BX LR
|
|
BLX |
|
BLX R0
|
|
加载存储 | LDR |
|
LDR R0, [R1]
|
STR |
|
STR R0, [R1]
|
|
LDM |
|
LDMIA R1!, {R0-R3}
|
|
STM |
|
STMIA R1!, {R0-R3}
|
|
PUSH |
|
PUSH {R0, R1}
|
|
POP |
|
POP {R0, R1}
|
|
移位操作 | LSL |
|
LSL R0, R1, #2
|
LSR |
|
LSR R0, R1, #3
|
|
ASR |
|
ASR R0, R1, #4
|
|
ROR |
|
ROR R0, R1, #1
|
|
条件执行 | CMP |
|
CMP R0, R1
|
TEQ |
|
TEQ R0, R1
|
|
TST |
|
TST R0, #0x80
|
|
协处理器 | MCR |
|
MCR p15, 0, R0, c1, c0, 0
|
MRC |
|
MRC p15, 0, R0, c1, c0, 0
|
|
CDP |
|
CDP p10, 0, c0, c1, c2, 0
|
2. 浮点与 SIMD 指令(VFP/NEON)
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
浮点运算 | FADD |
|
FADD S0, S1, S2
|
FSUB |
|
FSUB D0, D1, D2
|
|
FMUL |
|
FMUL S0, S1, S2
|
|
FDIV |
|
FDIV D0, D1, D2
|
|
FSQRT |
|
FSQRT S0, S1
|
|
类型转换 | FCVT |
|
FCVT D0, S0
|
FSITOD |
|
FSITOD D0, R0
|
|
FUITOS |
|
FUITOS S0, R0
|
|
向量运算 | VADD |
|
VADD.I16 D0, D1, D2
|
VSUB |
|
VSUB.F32 Q0, Q1, Q2
|
|
VMUL |
|
VMUL.I32 D0, D1, D2
|
|
VDOT |
|
VDOT.S32 D0, D1, D2
|
3. 系统与控制指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
系统寄存器 | MRS |
|
MRS R0, CPSR
|
MSR |
|
MSR CPSR, R0
|
|
异常处理 | SVC |
|
SVC #0
|
CPSID |
|
CPSID I
|
|
CPSIE |
|
CPSIE I
|
|
原子操作 | LDREX |
|
LDREX R0, [R1]
|
STREX |
|
STREX R2, R0, [R1]
|
ARM64(AArch64)指令集详解
1. 通用指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
数据处理 | ADD |
|
ADD X0, X1, X2
|
SUB |
|
SUB X0, X1, #5
|
|
MUL |
|
MUL X0, X1, X2
|
|
SDIV |
|
SDIV X0, X1, X2
|
|
UDIV |
|
UDIV X0, X1, X2
|
|
逻辑运算 | AND |
|
AND X0, X1, #0xFF
|
ORR |
|
ORR X0, X1, X2
|
|
EOR |
|
EOR X0, X1, X2
|
|
BIC |
|
BIC X0, X1, X2
|
|
分支跳转 | B |
|
B label
label |
BL |
|
BL func
func |
|
RET |
|
RET
BR X30 ) |
|
BR |
|
BR X0
|
|
加载存储 | LDR |
|
LDR X0, [X1]
|
STR |
|
STR X0, [X1]
|
|
LDP |
|
LDP X0, X1, [X2]
|
|
STP |
|
STP X0, X1, [X2]
|
|
LDUR |
|
LDUR X0, [X1, #3]
|
|
STUR |
|
STUR X0, [X1, #3]
|
|
移位操作 | LSL |
|
LSL X0, X1, #2
|
LSR |
|
LSR X0, X1, #3
|
|
ASR |
|
ASR X0, X1, #4
|
|
ROR |
|
ROR X0, X1, #1
|
|
位域操作 | BFM |
|
BFM X0, X1, #4, #7
|
SBFM |
|
SBFM X0, X1, #8, #15
|
|
UBFM |
|
UBFM X0, X1, #8, #15
|
2. 浮点与 SIMD 指令(NEON/Advanced SIMD)
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
浮点运算 | FADD |
|
FADD D0, D1, D2
|
FSUB |
|
FSUB S0, S1, S2
|
|
FMUL |
|
FMUL V0.2D, V1.2D, V2.2D
|
|
FDIV |
|
FDIV D0, D1, D2
|
|
FSQRT |
|
FSQRT D0, D1
|
|
向量运算 | ADD |
|
ADD V0.4S, V1.4S, V2.4S
|
MUL |
|
MUL V0.8H, V1.8H, V2.8H
|
|
类型转换 | FCVT |
|
FCVT S0, D1
|
SCVTF |
|
SCVTF D0, X1
|
|
UCVTF |
|
UCVTF S0, W1
|
|
加密扩展 | AESE |
|
AESE V0.16B, V1.16B
|
AESD |
|
AESD V0.16B, V1.16B
|
|
SHA256H |
|
SHA256H Q0, Q1, Q2
|
3. 系统与控制指令
类别 | 指令 | 功能 | 示例代码 |
---|---|---|---|
系统寄存器 | MRS |
|
MRS X0, SCTLR_EL1
|
MSR |
|
MSR SCTLR_EL1, X0
|
|
异常处理 | SVC |
|
SVC #0
|
HVC |
|
HVC #0x1234
|
|
ERET |
|
ERET
|
|
原子操作 | LDXR |
|
LDXR X0, [X1]
|
STXR |
|
STXR W2, X0, [X1]
|
|
CAS |
|
CAS X0, X1, [X2]
|
|
内存屏障 | DMB |
|
DMB SY
|
DSB |
|
DSB ISH
|
|
ISB |
|
ISB
|
以上只是一些主要的指令集,如若有误,还请大佬指正。arm汇编我们算是讲完了,那么接下来就开始讲如何使用IDA进行动态调试吧。
2
IDA 动态调试
关于IDA动态调试我们需要进行一些准备工作,不管是模拟器还是真机都需要先将IDA调试服务器push到其上面去,当你进入到IDA文件夹下的dbgsrv文件夹中就可以看到有不少的IDA调试服务器。
具体使用哪个就要根据你模拟器或者真机的架构选择对应的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直接弹出如下弹窗:
因为我们要调试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模式启动就需要在端口转发之后运行命令:
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,效果不比这个差的,甚至效果可能还会更好。
看雪ID:黎明与黄昏
https://bbs.kanxue.com/user-home-926486.htm
#
原文始发于微信公众号(看雪学苑):安卓逆向基础知识之ARM汇编和so层动态调试
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论