iOS逆向学习之十(arm64汇编入门)

4,745 阅读28分钟

iOS汇编

iOS汇编语音有很多钟。常见的有8086汇编、arm汇编、x86汇编等等。

arm汇编

iOS的架构从最初的armv6发展到后来的armv7和armv7s,最后发展到现在的arm64,不管是armv6还是后来的armv7,以及arm64都是arm处理器的指令集。armv7和armv7s是真机32位处理器使用的架构,而arm64是真机64位处理器使用的架构。

iPhone 5C是最后一款arm32位版本的iPhone,在iPhone5s之后,所有的iPhone设备都采用arm64架构。arm64汇编在真机上使用,如下:

TestFont`-[ViewController test]:
    0x10286e574 <+0>:  sub    sp, sp, #0x20             ; =0x20 
    0x10286e578 <+4>:  mov    w8, #0x14
    0x10286e57c <+8>:  mov    w9, #0xa
    0x10286e580 <+12>: str    x0, [sp, #0x18]
    0x10286e584 <+16>: str    x1, [sp, #0x10]
->  0x10286e588 <+20>: str    w9, [sp, #0xc]
    0x10286e58c <+24>: str    w8, [sp, #0x8]
    0x10286e590 <+28>: add    sp, sp, #0x20             ; =0x20 
    0x10286e594 <+32>: ret  

x86汇编

x86汇编是模拟器使用的汇编语言,它的指令和arm64汇编的语法不同,如下

TestFont`-[ViewController test]:
    0x10b089520 <+0>:  pushq  %rbp
    0x10b089521 <+1>:  movq   %rsp, %rbp
    0x10b089524 <+4>:  movq   %rdi, -0x8(%rbp)
    0x10b089528 <+8>:  movq   %rsi, -0x10(%rbp)
->  0x10b08952c <+12>: movl   $0xa, -0x14(%rbp)
    0x10b089533 <+19>: movl   $0x14, -0x18(%rbp)
    0x10b08953a <+26>: popq   %rbp
    0x10b08953b <+27>: retq   

为什么要学习arm64汇编?

代码调试

在平常开发中,在调试程序的时候,如果程序crash,通常会定位到具体的崩溃代码。但是有时候也会遇到一些比较诡异的crash,比如说崩溃在了系统库中,这个时候定位到具体的crash原因会非常困难。如果利用汇编调试技巧来进行调试,可能会让我们事半功倍。

逆向调试

在逆向别人App过程中,我们可以通过LLDB对内存地址进行断点操作,但是当执行到断点时,LLDB展现给我们的是汇编代码,而不是OC代码,所以想要逆向并且动态调试别人的App,就需要学习汇编的知识。

arm64汇编入门

想要学习arm64汇编,需要从以下三个方面入手,寄存器、指令和堆栈。

寄存器

arm64中有34个寄存器,如下

通用寄存器

  • 64 bit的通用寄存器哟28个,分别是x0 ~ x28
  • 32 bit的也有28个,分别是w0 ~ w28(属于x0 ~ x28的低32位)

  • 其中x0 ~ x7通常拿来存放函数的参数,如果参数更多,则采用堆栈来进行传递
  • x0中通常存放函数的返回值

也会有人将x0 ~ x30叫做通用寄存器,但是在实际使用中x29和x30并没有对应的低32位的寄存器w29、w30,而且x29和x30寄存器有着特殊的用途,所以在此我只讲x0 ~ x28记为通用寄存器

程序计数器

pc (Program Counter)寄存器,它记录着当前CPU正在执行的指令的地址,通过register read pc查看寄存器中存储的值

(lldb) register read pc
      pc = 0x000000010286e588  TestFont`-[ViewController test] + 20 at ViewController.m:28
(lldb) 

堆栈指针

  • sp (Stack Pointer)
  • fp (Frame Pointer),也就是之前所说的x29

链接寄存器

lr (Link Register)寄存器,也就是之前所说的x30寄存器,它存储着函数的返回地址

程序状态寄存器

arm体系中包含一个当前程序状态寄存器cpsr (Current Program Status Register)和五个备份的程序状态寄存器spsr (Saved Program Status Registe),备份的程序状态寄存器用来进行异常处理。

  • 程序状态寄存器的每一位都有特定的用途,此处只介绍几种常用的标志位

  • 其中N、Z、C、V均为条件码标志位,他们的内容可被算数或者逻辑运算的结果所改变,并且可以决定某条指令是否被执行。条件码标志各位的具体含义如下

指令

ARM指令列表

ARM指令如下:

助记符ARM指令及功能描述
ADC带进位加法指令
ADD加法指令
AND逻辑与指令
B跳转指令
BIC位清除指令
BL带返回的跳转指令
BLX带返回和状态切换的跳转指令
BX带状态切换的跳转指令
CDP协处理器数据操作指令
CMN比较反值指令
CMP比较指令
EOR异或指令
LDC存储器带协处理器的数据传输指令
LDM加载多个寄存器指令
LDR存储器到寄存器的数据传输指令
MCR从ARM寄存器到协处理器寄存器的数据传输指令
MLA乘加运算指令
MOV数据传送指令
MRC从协处理器寄存器到ARM寄存器的数据传输指令
MRS传送CPSR或SPSR的内容到通用寄存器指令
MSR传送通用寄存器到CPSR或SPSR指令
MUL32位乘法指令
MLA32位乘加指令
MVN数据反传送指令
ORR逻辑或指令
RSB逆向减法指令
RSC带借位的逆向减法指令
SBC带借位减法指令
STC协处理器寄存器写入存储器指令
STM批量内存字写入指令
STR寄存器到寄存器的数据传输指令
SUB减法指令
SWI软件中断指令
SWP交换指令
TEQ相等测试指令
TST位测试指令

常用指令介绍

mov指令

指令介绍

mov指令可以将另一个寄存器、被移位的寄存器或者将一个立即数加载到目的寄存器

mov指令在arm64汇编中的实际使用
  • 在xcode中新建test.s文件,在test.s文件中添加以下代码
; 此处.text表示此代码放在text段中
.text
; .global表示将后面跟随的方法给暴露出去,不然外部无法调用,方法名以_开头
.global _test

; 此处为_test方法
_test:
; mov指令,将立即数4加载到x0寄存器中
mov x0, #0x4
mov x1, x0
; 汇编指令中,ret表示函数的终止
ret
  • 在xcode中新建test.h头文件,将test.s中的_test方法暴露出来
#ifndef test_h
#define test_h

void test(void);

#endif /* test_h */
  • 在viewDidLoad中调用test()函数,然后在LLDB中使用register read x0 读取寄存器中存放的值
(lldb) register read x0
      x0 = 0x000000010320c980
(lldb) si
(lldb) register read x0
      x0 = 0x0000000000000004
(lldb) register read x1
      x1 = 0x00000001e60f3bc7  "viewDidLoad"
(lldb) si
(lldb) register read x1
      x1 = 0x0000000000000004

通过对汇编指令增加断点,一步一步调试可以看出,在执行完mov指令后,x0和x1寄存器的值都被修改了

ret指令

ret指令表示函数的返回,而且它还有一个非常重要的作用,就是将lr(x30)寄存器的值赋值给pc寄存器

  • 在viewDidLoad中调用test()函数,在test()函数上打上断点,执行程序如下

  • 使用register read 查看lr和pc寄存器的值
(lldb) register read lr
      lr = 0x00000001021965a4  TestFont`-[ViewController viewDidLoad] + 68 at ViewController.m:23
(lldb) register read pc
      pc = 0x00000001021965a4  TestFont`-[ViewController viewDidLoad] + 68 at ViewController.m:23
(lldb) 

此时,lr寄存器和pc寄存器的值都是test()函数起始地址

  • 使用si指令跳转到test()函数中

  • 再次查看lr和pc寄存器的值,发现lr的值变成了test()函数的下一条指令的地址,也就是test()函数执行完成之后,主程序需要执行的下一条指令。pc寄存器保存了当前即将执行的指令的地址,如下
(lldb) register read lr
      lr = 0x00000001021965a8  TestFont`-[ViewController viewDidLoad] + 72 at ViewController.m:24
(lldb) register read pc
      pc = 0x0000000102196abc  TestFont`test
  • 执行完test()函数,发现程序跳转到了lr寄存器所保存的指令地址,也就是0x00000001021965a8,此时再次查看lr和pc寄存器的值,发现pc寄存器存放的地址已经变成了lr寄存器存放的地址
(lldb) register read lr
      lr = 0x00000001021965a8  TestFont`-[ViewController viewDidLoad] + 72 at ViewController.m:24
(lldb) register read pc
      pc = 0x00000001021965a8  TestFont`-[ViewController viewDidLoad] + 72 at ViewController.m:24
(lldb) 

add指令

add指令是将两个操作数相加,并将结果存放到目标寄存器中。具体说明如下

在arm64汇编中,相应的就是操作x0~x28,执行如下汇编代码

.text
.global _test

_test:

mov x0, #0x4
mov x1, #0x3

add x0, x1, x0

ret

执行完test()函数,通过register read查询x0的值,最后可以看到x0存放的值为7,如下

(lldb) register read x0
      x0 = 0x0000000000000004
(lldb) si
(lldb) register read x1
      x1 = 0x0000000000000003
(lldb) si
(lldb) register read x0
      x0 = 0x0000000000000007

sub指令

sub指令是将操作数1减去操作数2,再减去cpsr中的C条件标志位的反码,并将结果存放到目标寄存器中

cmp指令

cmp指令是把一个寄存器的内容和另一个寄存器的内容或者立即数做比较,同时会更新CPSR寄存器中条件标志位的值

  • 执行如下汇编代码
.text
.global _test

_test:

mov x0, #0x4
mov x1, #0x3

cmp x0, x1

ret
  • 在执行cmp代码之前和之后各打印一次CPSR寄存器的值如下
(lldb) register read cpsr
    cpsr = 0x60000000
(lldb) si
(lldb) si
(lldb) si
(lldb) register read cpsr
    cpsr = 0x20000000
(lldb) 

可以发现,在执行cmp操作之后,cpsr寄存器的值变成了0x20000000,转换成16进制后,得到32位标志位如下

可以发现第31位,也就是N位的值为0,同时第30位,也就是Z位的值也为0,这就表示,x0和x1寄存器相比较之后的值为非零非负,而使用x0 - x1得到的结果是1,符合非零非负的条件。

  • 修改汇编代码,调换x0和x1寄存器的位置,如下
_test:

mov x0, #0x4
mov x1, #0x3

cmp x1, x0

ret
  • 再次在cmp代码执行前后读取CPSR寄存器的值
(lldb) register read cpsr
    cpsr = 0x60000000
(lldb) s
(lldb) register read cpsr
    cpsr = 0x80000000
(lldb) 

这个时候,cpsr寄存器的值变成了0x80000000,转换成16进制后,如下

可以看出,第31位N位的值变成了1,第30位Z位的值为0,这表示,x0和x1寄存器相比较之后的值为非零负数,使用x1-x0得到的结果是-1,符合非零负数的条件

跳转指令

B指令

B指令是最简单的跳转指令,一旦遇到B指令,程序会无条件跳转到B之后所指定的目标地址处执行。

BL指令

BL指令是另一个跳转指令,但是在跳转之前,它会先将当前标记位的下一条指令存储在寄存器lr(x30)中,然后跳转到标记处开始执行代码,当遇到ret时,会将lr(x30)中存储的地址重新加载到PC寄存器中,使得程序能返回标记位的下一条指令继续执行。

  • 首先执行以下汇编代码
.text
.global _test

label:
mov x0, #0x1
mov x1, #0x8
ret

_test:
mov x0, #0x4
bl label
mov x1, #0x3
cmp x1, x0
ret
  • 断点到bl label指令时,读取lr寄存器和PC寄存器的值

  • 执行bl label指令,跳转到label标记处,再次读取lr(x30)寄存器和PC寄存器的值,这个时候会发现lr(x30)寄存器存放的地址已经变成mov x1, #0x3这条指令的内存地址

  • 执行完label标记中的所有代码,发现程序再次回到lr寄存器所存储的地址,也就是mov x1, #0x3这句指令继续执行,并且此时pc寄存器所存储的地址也变成了mov x1, #0x3这句指令的地址。

条件域指令

当处理器工作在arm状态时,几乎所有的指令均根据CPSR寄存器中条件码的状态和指令的条件域有条件的执行,当指令的执行条件满足时,指令被执行,否则指令被忽略。
每一条ARM指令包含4位的条件码,位于指令的最高四位[31:28]。条件码共有16种,每种条件码可用两个字符表示,这两个字符可用添加在指令助记符的后面和指令同时使用。例如:跳转指令B后可用加上后缀EQ变为BEQ,表示相等则跳转,即当CPSR寄存器中的Z标志置位时发生跳转。

OC代码演示条件域指令的作用
  • 在ViewController中增加以下代码
- (void)test{
    int a = 1;
    int b = 2;
    if (a == b) {
        NSLog(@"a==b");
    }else{
        printf("a!=b");
    }
}
  • 断点到test方法中,得到关键汇编代码如下

  • 其中w8,w9分别存放这0x2和0x1,cmp指令则对比w8和w9寄存器的值,并且修改CPSR寄存器对应的标志位
  • 执行cmp w8, w9指令后,查看CPSR寄存器的值如下
(lldb) register read cpsr
    cpsr = 0x80000000

得到对应16进制的值为

  • b.ne 0x102522584这条指令表示如果CPSR中的Z标志位(即第30位)为0,则执行跳转操作,跳转到0x102522584地址处指令执行,如上图所示。通常也可以理解为w8和w9两个寄存器存放的立即数不相等时,则执行跳转操作。此处因为1!=2,所以跳转到0x102522584处执行。
条件标志码

在16种条件标志码中,只有15种可以使用,如下图,第16种(1111)为系统保留,暂时不能使用

内存操作指令

内存操作指令分为内存读取和内存写入指令

内存读取指令LDR、LDUR、LDP
LDR指令格式为
LDR(条件) 目的寄存器, <存储器地址>

LDR指令用于从存储器中将一个32位的字数据传送到目的寄存器中。该指令通常用于从存储器中读取32位字数据到通用寄存器中,然后对数据进行处理。当程序计数器PC作为目的寄存器时,指令从存储器中读取的字数据被当做目的地址,从而实现程序流程的跳转。该指令在程序设计中比较常用,切寻址方式灵活多样。示例如下:

LDR x0, [x1]        ;将存储器地址为x1的字数据读入寄存器x0
LDR x0, [x1, x2]    ;将存储器地址为x1+x2的字数据读入寄存器x0
LDR x0, [x1, #8]    ;将存储器地址为x1+8的字数据读入寄存器x0
LDR x0, [x1, x2]!   ;将存储器地址为x1+x2的字数据读入寄存器x0,并将新地址x1+x2写入x1
LDR x0, [x1, #8]!   ;将存储器地址为x1+8的字数据读入寄存器x0,并将新地址x1+8写入x1
LDR x0, [x1], x2    ;将存储器地址为x1的字数据读入寄存器x0,并将新地址x1+x2写入x1
LDR x0, [x1, x2, LSL#2]!    ;将存储地址为x1+x2*4的字数据写入寄存器x0,并将新地址x1+x2*4写入x1
LDR x0. [x1], x2, LSL#2     ;将存储地址为x1的字数据写入寄存器x0,并将新地址x1+x2*4写入x1

通过一个简单的例子来了解LDR的作用:

  • 首先创建test.s文件,在文件中添加如下代码
.text
.global _test

_test:
; ldr指令,找到x1寄存器中存储的地址,从该地址开始读取8个字节的数据,存放到x0寄存器中
ldr x0, [x1]

ret

为什么此处是读取8个字节的数据呢?因为目标寄存器x0可以存放8个字节的数据,如果将x0换成w0,则读取4个字节的数据存放到w0中

  • 在viewDidLoad中调用test()函数,同时在test()函数之前声明一个局部变量,如下
- (void)viewDidLoad{
    [super viewDidLoad];

    int a = 5;
    test();

}
  • 断点到test()函数处,运行程序,首先读取变量a的内存地址,将其内存地址存放到x1寄存器中,操作如下

可以发现,此时的x1寄存器存放着a变量的地址。

  • 输入si执行语句ldr x0, [x1],查看x0寄存器的值,此时发现x0寄存器的值变为0x31e09a5000000005,而不是5,这是因为变量a是int类型,而int类型为4个字节,但是LDR指令会将x1寄存器存放地址开始的8个字节的数据读取出来存放到x0寄存器中,所以x0寄存器中存放的值不是5,通过x 0x000000016f2c52ec也可以看出
(lldb) x 0x000000016f2c52ec
0x16f2c52ec: 05 00 00 00 50 9a e0 31 01 00 00 00 58 0f b4 00  ....P..1....X...
0x16f2c52fc: 01 00 00 00 c7 3b 0f e6 01 00 00 00 50 9a e0 31  .....;......P..1

前4个字节存放的是5,也就是变量a的值

  • 将x0寄存器换成w0,重新执行上面的步骤,最后会发现w0中存放的是变量a的值,也就是5
LDUR指令

LDUR指令用法和LDR指令相同,区别在于LDUR后的立即数为负数,如下

LDR x0, [x1, #8]

LDUR x0, [x1, #-8]
LDP指令

LDP中的P是pair的简称,可以看出LDP可以同时操作两个寄存器

; 以下命令表示,从sp寄存器的地址加上0x30后的地址开始,读取前8个字节的数据存放到寄存器x29中,读取后8个字节的数据放入x30寄存器中
ldp    x29, x30, [sp, #0x30]
内存写入指令STR、STUR、STP
STR指令

STP指令的格式为:

STR{条件} 源寄存器, <存储器地址>

STR指令用于从源寄存器中将一个32位的字数据传送到存储器中。示例如下

STR x0, [x1], #8        ;将x0中的字数据写入以x1为地址的存储器中,并将新地址x1+8写入x1
STR x0, [x1, #8]        ;将x0中的字数据写入以x1+8为地址的存储器中
STUR指令

STUR指令和STR指令用法相同,区别在于STUR后的立即数为负数

STR x0, [x1, #8]

STUR x0, [x1, #-8]
STP指令

STP指令可以同时操作两个寄存器

; 以下指令表示,将x29+x30的字数据写入以sp+0x8为地址的存储器中,
stp    x29, x30, [sp, #0x8]

零寄存器

零寄存器中存放的值为0,主要作用是进行寄存器的置0操作

  • wzr(32位零寄存器)
  • xzr(64位零寄存器)
  • 在OC代码中如果给变脸赋值为0,其实是执行如下指令
#OC代码
int a = 0;

; 汇编代码
str    wzr, [sp, #0xc]

具体效果是将wzr寄存器中的字数据,也就是0,写入sp+0xc为地址的存储器中

寻址方式

所谓寻址方式就是处理器根据指令中给出的地址信息来寻找物理地址的方法,目前ARM支持以下几种常见的寻址方式

立即寻址

立即寻址也叫做立即数寻址,是一种特殊的寻址方式,操作数本身就在指令中给出来,只要取出指令也就取到了操作数,这个操作数被称为立即数,对应的寻址方式也叫做立即寻址,例如以下指令:

ADD x0, x1, #1          ; x0 ← x1+1
ADD x0, x1, #0x3f       ; x0 ← x1+0x3f

在以上两条指令中,第二个操作数即为立即数,要求以“#”号为前缀,对于以16进制表示的立即数,还要求在“#”后加上“0x”或“&”。

寄存器寻址

寄存器寻址就是利用寄存器中的数值作为操作数,这种寻址方式是各类微处理器经常采用的一种方式,也是一种执行效率较高的寻址方式,指令如下

ADD x0, x1, x2          ; x0 ← x1+x2

该指令的执行效果是将寄存器x1和x2的内容相加,其结果存放在寄存器x0中

寄存器间接寻址

寄存器间接寻址就是以寄存器中的值作为操作数的地址,而操作数本身存放在存储器中,例如如下指令

ADD x0, x1, [x2]        ; x0 ← x1+[x2]
LDR x0, [x1]            ; x0 ← [x1]
STR x0, [x1]            ; [x1] ← x0
  • 第一条指令中,以寄存器x2的值作为操作数的地址,在寄存器中取得一个操作数后与x1相加,结果存储到寄存器x0中
  • 第二条指令是将以x1的值为地址的存储器中的数据传送到x0中
  • 第三条指令是将x0的值传送到以x1的值为地址的存储器中

基址变址寻址

基址变址寻址就是将寄存器(该寄存器一般称作基址寄存器)的内容与指令中给出的地址偏移量相加,从而得到一个操作数的有效地址。变址寻址方式常用于访问某基地址附近的地址单元。采用变址寻址方式的指令有以下常见的几种形式:

LDR x0, [x1, #4]        ; x0[x1+4]
LDR x0, [x1, #4]!       ; x0[x1+4]x1x1+4
LDR x0, [x1], #4        ; x0[x1]x1x1+4
LDR x0, [x1, x2]        ; x0[x1+x2]
  • 第一条指令中,将寄存器x1的内容加上4形成操作数的有效地址,从而取得操作数存入寄存器x0中
  • 第二条指令中,将寄存器x1的内容加上4形成操作数的有效地址,从而取得操作数存入寄存器x0中,让x1寄存器的内容自增4个字节
  • 第三条指令中,以寄存器x1的内容作为操作数的有效地址,从而取得操作数存入寄存器x0中,然后寄存器x1的内容自增4个字节
  • 第四条指令中,将寄存器x1的内容加上寄存器x2的内容形成操作数的有效地址,从而取得操作数存入寄存器x0中

多寄存器寻址

采用多寄存器寻址方式,一条指令可以完成多个寄存器值的传送,这种寻址方式可以用一条指令完成传送最多16个通用寄存器的值,指令格式如下:

LDMIA x0, [x1, x2, x3, x4]      ; x1 ← [x0]
                                ; x2 ← [x0+4]
                                ; x3 ← [x0+8]
                                ; x4 ← [x0+12]

该指令的后缀IA表示在每次执行完加载/存储操作后,x0按字长度增加,因此,指令可以将连续存储单元的值传送到x1~x4

相对寻址

与基址变址寻址方式相类似,相对寻址以程序计数器PC的当前值为基地址,指令中的地址标号为偏移量,将两者相加之和得到操作数的有效地址。以下程序段完成子程序的调用和返回,跳转指令BL就是采用了相对寻址方式:

    BL  NEXT        ; 跳转到子程序NEXT处执行
    ......
NEXT
    ......
    MOV PC, LR      ; 从子程序返回

堆栈寻址

堆栈是哟中数据结构,按先进后出(FILO)的方式工作,使用一个称作堆栈指针的专用寄存器指示当前的操作位置,堆栈指针总是指向栈顶位置。
当栈顶指针指向最后压入堆栈的数据时,称为满堆栈(Full Stack),而当堆栈指针指向下一个将要放入数据的空位置时,称为空堆栈(Empty Stack)
同时、根据堆栈的生成方式,又可以分为递增堆栈(Ascending Stack)和递减堆栈(Decending Stack),当堆栈由低地址向高地址生成时,称为递增堆栈,当堆栈由高地址向低地址生成时,称为递减堆栈。这样就有四种类型的堆栈工作方式,ARM微处理器支持这四种类型的堆栈工作方式。即:

  • 满递增堆栈:堆栈指针指向最后压入的数据,且由低地址向高地址生成。
  • 满递减堆栈:堆栈指针指向最后压入的数据,且由高地址向低地址生成
  • 空递增堆栈:堆栈指针指向下一个将要放入数据的空位置,且由低地址向高地址生成
  • 空递减堆栈:堆栈指针指向下一个将要放入数据的空位置,且由高地址向低地址生成

堆栈操作

函数的类型

在了解堆栈操作之前,首先得了解函数的类型,函数类型主要分为两种:叶子函数、非叶子函数

  • 叶子函数是指在此函数中,没有调用其它任何函数
  • 非叶子函数是值在此函数中,有调用其它函数

了解了什么是叶子函数和非叶子函数,那么我们就要从汇编代码的层面来深入理解叶子函数和非叶子函数的区别,以及堆栈指针在其中起到的作用。

叶子函数

上文介绍过叶子函数的具体定义,下面通过具体的汇编代码来深入了解叶子函数

  • 首先在Xcode中创建MyTest.c文件,在文件中添加如下代码
void leafFuncion(){
    int a = 1;
    int b = 2;
}
  • 进入MyTest.c文件所在目录,使用以下指令生成MyTest.s文件
xcrun --sdk iphoneos clang -S -arch arm64 MyTest.c -o MyTest.s
  • 得到MyTest.s中的关键汇编代码如下
    sub	sp, sp, #16             ; sp = sp - 16
    
	orr	w8, wzr, #0x2
	orr	w9, wzr, #0x1
	str	w9, [sp, #12]
	str	w8, [sp, #8]
	
	add	sp, sp, #16             ; sp = sp + 16
	ret
  • 得到汇编代码之后,我们就来一句一句分析汇编代码
    • sub sp, sp, #16指令表示将堆栈指针sp向前偏移#16

堆栈指针sp开始指向0x10010,偏移之后指向0x10000,相当于开辟了从0x10000到0x10010这一段内存供函数使用。

  • orr指令用于在两个操作数上进行逻辑或运算,并把结果放置到目的寄存器中。上文中orr w8, wzr, #0x2指令是将wzr寄存器的值与#0x2做逻辑或运算,得到的结果存放在w8寄存器中。通俗一点就是将#0x2赋值给了寄存器w8。指令orr w9, wzr, #0x1就是将#0x1赋值给了寄存器w9。

  • str w9, [sp, #12]指令表示将寄存器w9的值写入到以sp + 12的地址开始4个字节大小的内存中去,str w8, [sp, #8]指令同上。具体操作流程如下图:

  • 执行完内存的存储操作之后,当前栈空间的工作已经完成,为了保持堆栈平衡,需要将堆栈指针sp的位置还原成函数调用之前的初始位置。add sp, sp, #16的作用就是将sp指针的位置向后偏移16个字节,重新指向0x10010的位置。

为什么要维持堆栈平衡?因为在函数调用之前,堆栈指针sp会偏移一段内存地址,为当前需要调用的函数分配一段内存空间,在函数调用完成之后将sp指针重置到开始位置,这样,刚刚分配的那段内存空间就是垃圾内存,下一次再有函数调用的时候,这段内存空间可重复利用。这就做到了堆栈平衡。如果函数调用完成之后不重置sp指针,那么,如果有足够多的函数一直调用,最后肯定会出现栈溢出的问题。

非叶子函数

非叶子函数和叶子函数的区别在于是否有调用其它函数,下面同样通过具体的汇编代码来深入了解非叶子函数

  • 首先在Xcode中创建MyTest.c文件,在文件中添加以下代码
void leafFuncion(){
    int a = 1;
    int b = 2;
}

void nonLeafFunction(){
    int a = 3;
    int b = 4;
    leafFuncion();
}
  • 进入MyTest.c文件所在目录,使用以下指令生成MyTest.s文件
xcrun --sdk iphoneos clang -S -arch arm64 MyTest.c -o MyTest.s
  • 得到MyTest.s中的关键汇编代码如下
	sub	sp, sp, #32             ; sp=sp-32
	stp	x29, x30, [sp, #16]     ; 8-byte Folded Spill
	add	x29, sp, #16            ; x29=sp+16

	orr	w8, wzr, #0x4
	orr	w9, wzr, #0x3
	stur	w9, [x29, #-4]
	str	w8, [sp, #8]
	bl	_leafFuncion

	ldp	x29, x30, [sp, #16]     ; 8-byte Folded Reload
	add	sp, sp, #32             ; sp=sp+32
	ret
  • 开始分析汇编代码

    • sub sp, sp, #32指令是执行内存分配的操作,将sp指针向前偏移32位,得到一片连续的内存空间

    • stp x29, x30, [sp, #16]指令是将x29(fp)和x30(lr)寄存器中存放的值写入以sp+16的地址为起始地址的一段内存空间中去,每个寄存器占8个字节的空间。

    • add x29, sp, #16指令是将sp + 16的地址存放在x29(fp)寄存器中,由此,可以得到从sp到x29(fp),这两个地址之间的一段内存空间就是当前函数可以使用的内存空间。

    如上图所示,橙色的那段内存就是我们可以使用的内存空间。

    • orr w8, wzr, #0x4和orr w9, wzr, #0x3其实就是将4赋值给寄存器w8,将3赋值给寄存器w9

    • stur w9, [x29, #-4]指令是将w9中存储的值,也就是3,写入到以x29(fp)- 4的地址为开始地址的4个字节的内存中去。str w8, [sp, #8]指令则是将w8中存储的值4,写入到以sp+8为起始地址的4个字节的内存中去,如下

    • bl _leafFuncion指令则表示跳转到_leadFunction函数的操作,前面提到过,执行bl指令之前,会将bl指令的下一条汇编指令ldp x29, x30, [sp, #16]的地址存放到lr寄存器中,以便执行完_leadFunction函数之后,能跳转回ldp x29, x30, [sp, #16]指令继续执行。这就可以明白为什么之前需要先存储lr寄存器中的值,因为一旦执行完bl _leafFuncion指令之后,如果不将lr指令重置为初始值的话,一旦执行到后面的ret函数,会重新跳到ldp x29, x30, [sp, #16]指令的地址处重新执行,如此反复。

    • 执行完_leadFunction函数之后,会回到lr中存储的地址,也就是ldp x29, x30, [sp, #16]指令继续执行。ldp x29, x30, [sp, #16]指令的作用是以sp+16的地址为开始地址,依次读取16个字节的数据,前8个字节的数据存放到x29(fp)寄存器中去,后8个字节的数据存放到x30(lr)寄存器中去。其实就是将x29(fp)和x30(lr)寄存器的值恢复到调用函数之前所存放的值。

    • 最后add sp, sp, #32指令是将sp+32的地址值赋值给sp,其实就是还原sp指针的值,至此整个函数就调用完毕,给当前函数分配的内存空间就成了垃圾内存空间,可以给之后的函数重复使用。至此,我们就可以明白叶子函数和非叶子函数的区别,以及堆栈指针在当前函数调用过程中起到的作用。

    在非叶子函数调用过程中,sp指针一直指向被分配栈空间的栈顶,所以又叫做栈顶指针,而fp指针指向可用栈空间的栈底,所以又叫做栈底指针。两个指针所指地址的中间一段内存就是函数可以使用的内存空间。

    函数执行开始和结束的汇编指令就是用来分配内存以及维持堆栈平衡的操作。