最近在做技术攻关的时候,遇到一些问题很诡异。从源码看起来完全没有问题。这就有点头疼,无奈,只能从汇编看了。用汇编结合lldb 的调试技巧,成果还不错,总有一些突破。
学习汇编并没有想象的那么难,我们学习汇编,并不是要我们拿汇编来写程序,只是帮助我们定位问题所在,还可以帮助我们更加深刻的理解计算机。
工具
如果我们有源码就能解决的问题,又何须去看汇编呢。汇编不是你想看就能看的,因此我们需要一些反汇编的工具来辅助我们进行汇编代码查看。我推荐几个工具:Hopper Disassembler
收费应用、IDA Pro
也是收费应用,这两个工具看起来确实方便。还有不要忘了,Xcode 本身给我们带的工具:lldb
(lldb) disassemble
pthreadtest`main:
0x100000960 <+0>: push rbp
0x100000961 <+1>: mov rbp, rsp
0x100000964 <+4>: sub rsp, 0xc0
0x10000096b <+11>: lea rax, [rip + 0x10ee] ; empty_sem
效果也差不多,掌握一些调试技巧,这个还能动态调试。
寄存器
寄存器是CPU中的高速存储单元,要比内存中存取要快的多的一些存储单元,至于为什么比内存快,建议看看这篇文章 为什么寄存器比内存快?,让我大开眼界。
寄存器总共分为:通用寄存器、专用寄存器和控制寄存器。ARM64位架构有32个整数寄存器,包括1个专用的零寄存器,1个链接寄存器和1个帧指针寄存器,还有1个寄存器预留给平台,另外28 个则为通用整数寄存器。
R:Register;寄存器
PC:Program Counter;程序计数器
CPSR:Current Program Status Register;当前程序状态寄存器
SPSR:Saved Program Status Register;保存的程序状态寄存器
SP:Stack Pointer;数据栈指针
LR:Link Register;连接寄存器
SB:静态基址寄存器
SL:数据栈限制指针
FP:帧指针
IP:Intra-Procedure-call Scratch Register;内部程序调用暂存寄存器
理论联系实际
(lldb) register read
General Purpose Registers:
x0 = 0x0000000010004005
x1 = 0x0000000007000806
x2 = 0x0000000000000000
x3 = 0x0000000000000c00
x4 = 0x0000000000002303
x5 = 0x00000000ffffffff
x6 = 0x0000000000000000
x7 = 0x0000000000000001
x8 = 0x00000000fffffbbf
x9 = 0x0000000007000000
x10 = 0x0000000007000100
x11 = 0x0000000000041f88
x12 = 0x00005e0000005f03
x13 = 0x0000000000000000
x14 = 0x00005f0000005f00
x15 = 0x0000000000000000
x16 = 0xffffffffffffffe1
x17 = 0x00000001892ab2a4 CoreFoundation`-[__NSArrayM count]
x18 = 0x0000000000000000
x19 = 0x0000000000000000
x20 = 0x00000000ffffffff
x21 = 0x0000000000002303
x22 = 0x0000000000000c00
x23 = 0x000000016fd7eca8
x24 = 0x0000000007000806
x25 = 0x0000000000000000
x26 = 0x0000000007000806
x27 = 0x0000000000000c00
x28 = 0x0000000000000001
fp = 0x000000016fd7ebb0
lr = 0x00000001883ab09c libsystem_kernel.dylib`mach_msg + 72
sp = 0x000000016fd7eb60
pc = 0x00000001883ab224 libsystem_kernel.dylib`mach_msg_trap + 8
cpsr = 0x60000000
- R0 - R30
R0 - R30 是31个通用整形寄存器。每个寄存器可以存取一个64位大小的数。当使用x0 - x30 访问时,他就是一个64位的数,当使用w0 - w30 访问时,访问的是这些寄存器的低32 位。
其实通用寄存器有32个,低32个寄存器x31 ,在指令编码中,使用来做zero register
,即ZR
,XZR/WZR
分别代表64/32位,zero register
的作用就是0,写进去代表丢弃结果,拿出来是0.
其中r29
又被叫做fp
(frame pointer),r30
又被叫做lr
(link register).
- SP
SP 寄存器指向栈顶,也就是低地址。
- PC
PC 寄存器存的是当前执行的指令的地址
- SPRs
SPRs是状态寄存器,用于存放程序运行中的一些状态标识,根据状态寄存器中的一些状态来控制分支的执行。状态寄存器又分为 The Current Program Status Register (CPSR)
和 The Saved Program Status Registers (SPSRs)
。 一般都是使用CPSR
, 当发生异常时, CPSR
会存入SPSR
。当异常恢复,再拷贝回CPSR
- V0 - V31
V0 - V31 是向量寄存器,也可以说是浮点型寄存器。它的特点是每个寄存器的大小是 128 位的。 分别可以用Bn Hn Sn Dn Qn的方式来访问不同的位数
- 一般来说,arm64 上x0 - x7 分别会存放方法的前8个参数
- 如果方法的参数个数超过8个,多余的参数会存放在栈上,新方法会通过栈来读取。
- 方法的返回值一般都在x0 上。x86 的方法返回值会存放在
eax
上 - 如果方法返回值是一个较大的数据结构时,结果会存放在x8 执行的地址上。
指令
了解了寄存器后,来看看汇编指令,单条汇编指令是很简单的。汇编指令没有高级语言那么多的逻辑,每一条指令都是无条件执行,所以看起来就没有那么头晕。在这一节中,不讲那么多的概念,直接拎出几条比较经典常用的指令来加深理解,在具体实践中,遇到不会的就google。
所有指令大致可以分为3类,分别是数据操作指令、内存指令、分支指令。
- 数据操作指令
数据操作指令有以下2条规则:
-
所有操作数均为32bit;
-
所有结果均为32bit,且只能存放在寄存器中;
总的来说,数据操作指令的基本格式是:
op{cond} {s} Rd, Rn, op2
其中,”cond” 和 “s” 是两个可选后缀;”cond” 的作用是指定指令 “op” 在什么条件下执行。
数据操作指令可以分为以下4类:
- 算术操作
ADD R0, R1, R2 ;R0 = R1 + R2
SUB R0, R1, R2 ;R0 = R1 - R2
RSB R0, R1, R2 ;R0 = R2 - R1
- 逻辑操作
AND R0, R1, R2 ;R0 = R1 & R2
ORR R0, R1, R2 ;R0 = R1 | R2
EOR R0, R1, R2 ;R0 = R1 ^ R2
BIC R0, R1, R2 ;R0 = R1 &~ R2
MOV R0, R2 ;R0 = R2
MVN R0, R2 ;R0 = ~R2
- 比较操作
CMP R1, R2 ; 执行R1 - R2 并依结果设置flag
CMN R1, R2 ; 执行R1 + R2 并依结果设置flag
TST R1, R2 ; 执行R1 & R2 并依结果设置flag
TEQ R1, R2 ; 执行R1 ^ R2 并依结果设置flag
比较操作其实就是改变flag 的算术操作或逻辑操作,只是操作结果不保留在寄存器里而已
- 乘法操作
MUL R4, R3, R2 ;R4 = R3 * R2
MLA R4, R3, R2, R1 ;R4 = R3 * R2 + R1
乘法操作的操作数必须来自寄存器。
- 内存操作指令
内存操作指令的基本格式是:
op {cond}{type} Rd, [Rn,op2]
其中Rn 是基址寄存器,用于存放基地址;”cond” 的作用与数据操作指令相同,”type” 指定指令“op” 操作的数据类型,共有4中:
- B(unsigned Byte)
无符号(执行时扩展到32位,以0 填充); - SB(Signed Byte)
有符号byte(仅用于LDR 指令;执行时扩展到32bit,以符号位填充); - H(unsigned Halfword)
无符号halfword (执行时扩展到32位,以0 填充 - SH(signed Halfword)
有符号halfword (仅用于LDR 指令;执行时扩展到32bit,以符号位填充)。
如果不指定“type”,则默认数据类型是word
ARM 内存操作基础指定只有两个:LDR(Load from memory into a register) 将数据从内存中读出来,存到寄存器中;STR(Store from a register into memory) 将数据从寄存器中读出来,存到内存中。
由于ARM 是RSIC 结构,数据从内存到CPU 之间的移动很稚嫩通过L/S 指令来完成,也就是ldr/str 指令比如想把数据从内存中某处读取到寄存器中,只能使用ldr
比如:ldr r0,0x12345678
;就是把0x12345678
这个地址中的值存放到r0
而mov 不能干这个活,mov只能在寄存器之间移动数据,或者把立即数移动到寄存器中,这个和x86 这种CISC架构的芯片区别最大的地方x86 中没有ldr 这种指令,因为x86 的mov 指令可以将数据从内存中移动到寄存器中。
- STR 指令
str 指令是一个典型的存储指令,用于从源寄存器中讲一个32位的字数据传送到存储器中,该指令在程序设计中比较常用,寻址方式灵活多样。该指令的使用方式如下:
STR R0, [R1], #8 ; 将R0 中的字数据写入以R1 为地址的存储器中,并将新地址R1 + 8 写入R1.
STR R0, [R1, #8] ; 将R0 中的字数据写入以R1 + 8 为地址的存储器中。
STR R1, [R0] ; 将r1 寄存器的值,传送到地址值为r0 的内存中
- LDR 指令
sdr 指令将数据从内存中读取到寄存器中,ldr 只能在当前pc 的4KB 返回内跳转。该指令的使用方式如下:
LDR R0,[R1] ;将存储器地址为R1的字数据读入寄存器R0。
LDR R0,[R1,R2] ;将存储器地址为R1+R2的字数据读入寄存器R0。
LDR R0,[R1,#8] ;将存储器地址为R1+8的字数据读入寄存器R0。
LDR R0,[R1],R2 ;将存储器地址为R1的字数据读入寄存器R0,并将R1+R2的值存入R1。
LDR R0,[R1],#8 ;将存储器地址为R1的字数据读入寄存器R0,并将R1+8的值存入R1。
LDR R0,[R1,R2]! ;将存储器地址为R1+R2的字数据读入寄存器R0,并将R1+R2的值存入R1。
LDR R0,[R1,LSL #3] ;将存储器地址为R1*8的字数据读入寄存器R0。
LDR R0,[R1,R2,LSL #2] ;将存储器地址为R1+R2*4的字数据读入寄存器R0。
LDR R0,[R1,,R2,LSL #2]! ;将存储器地址为R1+R2*4的字数据读入寄存器R0,并将R1+R2*4的值存入R1。
LDR R0,[R1],R2,LSL #2 ;将存储器地址为R1的字数据读入寄存器R0,并将R1+R2*4的值存入R1。
LDR R0,Label ;Label为程序标号,Label必须是当前指令的-4~4KB范围内
此外,LDR 和STR 的变种LDRD 和 STRD 还可以操作双字(Doubleword),即一次操作两个寄存器,其基本格式如下:
op{cond} Rt, Rt2, [Rn {, #offset}]
如下:
STRD R4, R5, [R9, #offset] ;*(R9 + offset) = R4; *(R9 + offset + 4) = R5
LDRD R4, R5, [R9, #offset] ;R4 = *(R9 + offset); R5 = *(R9 + offset + 4)
此外,除了str 和ldr 外,还可以通过LDM(loaD Multiple) 和STM(STore Multiple)进行块传输,一次性操作多个寄存器。
另外还有一个就是ldr 伪指令,虽然伪指令和arm 的ldr 指令很像,但是作用不太一样,ldr 伪指令可以在立即数前加上=,以表示把一个值(一般是一个地址)写到某个寄存器中,比如:
ldr r0, =0x12345678
这样就把0x12345678 这个值写到r0 中了。所以ldr 伪指令和mov 是比较相似的。只不过mov 指令限制了立即数的长度为8位,也就是不能超过512.而伪指令没有这个限制。如果使用ldr 伪指令时,后面跟的立即数没有超过8位,那么在实际汇编的时候ldr 指令是被转换为mov 指令的。
其实ldr 指令可以装载一个32bit 立即数的说法并不确切,因为实际上并不是这一条语句装载了一个32bit立即数,真正的汇编代码是将某个地址的值传递给r1
,就是说需要一个地址存放0x12345678
这个立即数。而且如果这个立即数可以用mov 指令的形式来表达,会被编译器实际用mov 来代替比如:
ldr r1,=0x10
会变成
mov r1,#0x10
综上所述:ldr 伪指令用于加载32 位的立即数或一个地址值到指定寄存器。在汇编编译源程序时,ldr伪指令被编译器替换成一条合适的指令。若加载的常熟未超出mov 或者mvn 的范围,则使用mov 或mvn 指令代替该ldr 伪指令,否则汇编器将常量放入文字池,并使用一条程序相对偏移的ldr 指令从文字池读出常量。
- 分支指令
分支指令可以分为无条件分支和条件分支。
- 无条件分支
B Label ;PC = Label
BL Lable ;LR = PC - 4;PC = Label
BX Rd ;PC = Rd 并切换指令集
B 表示无返回的跳转,BL 表示有返回的跳转。有返回的意思就是会存lr,因此BL 的L也可以理解为LR的意思。存了LR也就意味着可以返回到本方法继续执行,一般用于不同方法的直接条用,B 相关的跳转没有LR,一般是本方法内的跳转,如while 循环,if else 等等。
- 条件分支
在条件分支指令前会有一条数据操作指令来设置flag,分支指令根据flag 的值来决定代码走向,如:
Label:
LDR R0, [R1], #4
CMP R0, 0 ;如果R0 == 0,Z = 1;否则Z = 0
BNE Label ;Z == 0 则跳转
栈
栈是从高地址到低地址的,栈顶是低地址,栈底是高地址。fp
指向当前frame 的栈底,也就是高地址,SP
指向栈顶,也就是低地址。一般来说arm64 上x0 - x7 分别存放方法的前8个参数,如果参数个数超过了8个,多余的参数会存放在栈上,新方法会通过栈来读取方法的返回值,一般都在x0上。如果方法返回值是一个较大的数据结构时,结果会存放在x8 执行的地址上。
寻址
既然是和内存相关的,那就是两种,一种是存,一种是取。一般来说
L打头的基本都是取值指令,如LDR LDP;
S打头的基本都是邨值指令,如STR STP;
如:
ldr x0, [x1] ; 从x1 指向的地址里取出一个64位大小的数存入x0 中
ldp x1, x2,[x10,#0x10] ;从x10 + 0x10指向的地址里取出2个64位的数,分别存入x1,x2
str x5, [sp,#24] ;把x5 的值(64位数值)存到sp+24 指向的内存地址上
stp x29, x30,[sp,#-16]! ;把x29,x30 的值存到sp-16 的地址上,并且把sp -= 16,其中 !代表writeback ,就是改变sp 的值
最后
在开发过程中难免会遇到一些令我们头疼的bug,学习一些汇编,让我们更加深刻的理解计算机,理解代码执行过程,可以定位一些疑难杂症的问题。汇编指令的执行是简单确定的,是无条件执行的。本人也是在学习过程中,经常会遇到一些疑难杂症,本想能躲开汇编就躲开,但是后来发现,有些使我们无法避免的。在学习过程中建议拿hooper
或者IDA Pro
多看看,理解每一行指令的意义,融会贯通。
学习过程中…..共勉!
参考资料:
iOS开发同学的arm64汇编入门
iOS 逆向工程