02-汇编基础(2)

前言

本篇文章主要以汇编的角度,分析函数的本质,在分析函数的过程中,就会解决上篇文章最后的死循环问题。

一、基础知识点

接着上篇文章01-汇编基础(1)的内容,我们再介绍几个常见的基础知识点。

1.1 栈

在讲函数之前,先来看看,因为函数的实现代码的作用域就对应在内存的中。
栈是一种具有特殊的访问方式的存储空间(后进先出, Last In Out Firt,LIFO)。我们都知道,栈的操作无非就是

  • 入栈 push
  • 出栈 pop

1.1.1 栈的结构

栈的结构就好比只有一个口的管子,如下图

  • 刚开始有2个指针 栈顶和栈底,同时指向空栈的底部
  • 开辟空间
    • 以前是:变量push(入栈),内存就开辟空间
    • 现在是:先开辟空间,再push(入栈)变量

那么问题来了 系统怎么知道开辟多大的空间呢?

编译器决定的,因为你的代码被编译后,编译器就知道要申请多少空间大小的栈了。

1.1.2 SP FP 寄存器

根据SP 和 FP 寄存器可以查看栈的空间大小,因为

  • sp寄存器在任意时刻会保存我们栈顶的地址
  • fp寄存器也称为x29寄存器,属于通用寄存器,但是在某些时刻我们利用它保存栈底的地址!
    • 没有出现函数嵌套调用的时候不需要fp,相当于分界点

⚠️ ARM64开始,取消32位的 LDM,STM,PUSH,POP指令! 取而代之的是ldr\ldp str\stp。
ARM64里面 对栈的操作是16字节对齐的!!

ARM64中,是先开辟一段栈空间,fp移动到栈顶再往栈中存放内容,原因上面说过,编译期就已经确定大小,所以不存在push操作,同时, iOS中内存是高地址向低地址的方向开辟栈空间的。

1.2 函数调用栈

我们都知道,函数的实现是在栈中进行的,函数执行完毕后栈的空间自动释放,那么在汇编中,常见的函数调用栈的开辟和恢复的代码

//开辟栈空间
sub    sp, sp, #0x40             ; 拉伸0x40(64字节)空间
stp    x29, x30, [sp, #0x30]     ;x29\x30 寄存器入栈保护
add    x29, sp, #0x30            ; x29指向栈帧的底部
... 
//恢复栈空间
ldp    x29, x30, [sp, #0x30]     ;恢复x29/x30 寄存器的值
add    sp, sp, #0x40             ;栈平衡
ret

上面的汇编代码的执行过程,如下图所示

  1. 通过sub减指令来开辟空间,此时sp指向低地址位置,x29也是fp指向栈底,即高地址位置
  2. 函数ret即调用完毕返回之前,需要通过add加指令,恢复sp寄存器地址指向,这就是所谓的栈平衡
  3. 恢复后数据并不销毁,下次再拉伸栈空间后,会先覆盖再读取。如果先读取,读取的就是垃圾数据

二、内存读写指令

注意⚠️ 读/写数据是都是往高地址进行操作。

读/写的指令主要有2个

  1. str(store register)指令 将数据从寄存器中读出来,存到内存中.
  2. ldr(load register)指令 将数据从内存中读出来,存到寄存器中

str ldr 是内存寄存器交互的专门的指令。

还有2个也很常用的指令stp和 ldp,意思是可以同时操作2个寄存器的读和写。

练习

写一个函数,功能很简单,就是x0和x1 交换数据,目的是熟悉上面的stp ldp指令意思。代码如下

.text
.global _C

_C:
    sub  sp, sp, #0x20        ;拉伸栈空间32个字节
    stp  x0, x1, [sp, #0x10]  
    ldp  x1, x0, [sp, #0x10]  
    add  sp, sp, #0x20        ;恢复栈空间
    ret

上面代码,第一行和倒数第二行,常规操作,对栈空间的拉伸与恢复,重点是中间2句代码(汇编代码从右往左看)

  • stp x0, x1, [sp, #0x10]
    • [sp, #0x10] []的意思是寻址,sp的地址再加0x10,但是注意⚠️sp本身的地址指向是不变的
    • stp x0, x1 这个上面讲过,操作2个寄存器x0和x1,将其中的值存储到内存中
  • ldp x1, x0, [sp, #0x10] 看完了上面的stp操作,再来看ldp,就很简单了,将sp的地址+0x10这个地址的值从内存中取出来,依次存储到寄存器x1和x0

至此,上面的代码就完成了x0和x1寄存器中值的交换,以前我们知道将a和b值交换时,需要用到第三个temp变量,那么这里内存就充当了temp的角色,如下图所示

示例调试

但是,注意⚠️内存中的值是没有变化的,sp寄存器的指向地址也没变,变化的只是 x0 和 x1寄存器中的值,不信?接下来我们可以调试看看。

上图在0x104631c8c断点处,对x0和x1分别赋值0xa和0xb,再读取sp的地址值,然后接着单步往下执行下面

上图发现,x0和x1已交换完毕,但是再次读取sp地址时,是没有变化的,依旧是0x000000016b7d1190。再继续单步执行

sp还原了,栈空间释放,这时候0xa,0xb还依然存在内存中,并没有释放,会有问题吗?其实仔细想想,我们每次sub拉伸栈空间后,都是通过str或stp对内存空间的值进行写数据覆盖的,所以不会有问题。我们可以通过view memory查看内存

上图中,输入0x000000016b7d1190地址查看,果然发现a和b均在内存中没有释放。

2.1 bl和ret指令

接下来我们看看bl和ret指令。

bl标号

  • 将下一条指令的地址放入lr(x30)寄存器
  • 转到标号处执行指令

b就是跳转,l就是将下一条指令的地址放入lr(x30)寄存器。还是看上面的例子,查看跳转C函数后,lr寄存器的地址,如下图

lr相当于保存的回家的路

ret

默认使用lr(x30)寄存器的值,通过底层指令提示CPU此处作为下条指令地址!

ret只会看lr
注意⚠️ ARM64平台的特色指令,它面向硬件做了优化处理。

2.2 x30寄存器

x30寄存器,就是我们上面说的lr寄存器,存放的是函数的返回地址。当ret指令执行时就会去寻找x30寄存器保存的地址值

案例演示

我们还是用一个案例演示给大家看看,很简单,C函数中bl跳转到D函数(C函数调用D函数)

.text
.global _C, _D

_C:
    mov x0,#0xaaaa
    bl _D
    mov x0,#0xaaaa
    ret

_D:
    mov x0,#0xbbbb
    ret

调用的代码

int C();
int D();
- (void)viewDidLoad {
    [super viewDidLoad];
    printf("C");
    C();
    printf("D");
}

C();这行打上断点,run,查看汇编

当前lr指向的是bl c()这条指令的地址,接着step into进入到C()中

然后跳转到D()中

此时lr的地址又发生了变化,变成了0x00000001021e5c78,接着往下执行,回到C()中

lr的地址和在D()中的一样,没变化,继续执行下去,你会发现,断点执行一直在0x1021e5c780x1021e5c7c这两句中跳转,返不回去viewDidLoad中了,发生了死循环。

这个就是我们上一篇01-汇编基础(1)中最后碰到的死循环问题,现在我们来分析一下:

既然我们知道了bl指令的作用,就是保存回去的地址(回家的路),那我们得想办法保存回到viewDidLoad的地址,而且必须在bl之前进行保存,因为上图中的现象可见 遇到bl,lr就会改变

现在我们知道了何时保存,即bl之前,但是保存在哪里呢?

如果保存到其它寄存器,是没法保证系统是否会覆盖其它寄存器的地址值的,那么接得想办法保存在自己的一个私有的区域,这个区域是哪里呢?很显然,就是函数本身的栈区

至此,我们知道了,在bl之前将lr的地址保存在函数自己的栈区内

接下来,就是如何写汇编实现这个保存操作了。既然不会写,那不如我们不写汇编,写OC,然后看汇编底层是如何处理的。

void a() {
    b();
    return;;
}

void b() {
    
}

- (void)viewDidLoad {
    [super viewDidLoad];
    a();
}

step into进入查看a()的汇编

看来,重点就是第一条和ret前一条的指令了,我们先来看第一条指令的含义,老规矩,从右往左看

  • stp x29, x30, [sp, #-0x10]!
    • [sp, #-0x10]! 因为是#-0x10负数,就是拉伸16个字节的空间,注意这个感叹号!,意思就是将这个值赋给sp,整体就相当于sp -= 0x10
    • stp x29, x30 很简单,sp的地址拉伸后,依次存入x29和x30寄存器,那么x29地址就是sp,x30地址就是sp - 0x08

分析完第一句指令后,再来看ldp指令,就没那么难了

  • ldp x29, x30, [sp], #0x10
    • [sp], #0x10 不难猜到,就是恢复sp指针指向,整体就相当于sp += 0x10,恢复栈空间
    • ldp x29, x30 就是将栈区的值给x29,x30

系统的整明白了,再回到自己定义的C()和D()中,照着写就行了

.text
.global _C, _D

_C:
    str x30, [sp,#-0x10]!
    mov x0,#0xaaaa
    bl _D
    mov x0,#0xaaaa
    ldr x30,[sp],#0x10
    ret

_D:
    mov x0,#0xbbbb
    ret

run,调试看看

step into进入C函数

接着step into进入D函数

接着单步往下执行

原来lr中保存的0x0000000100c7dc50,就是保存回到C函数的地址。我们再看看sp寄存器地址,是0x000000016f1851a0,通过view memory看看里面的值

我们知道,sp寄存器是指向栈顶的地址,再回过头来看看ViewDidLoad中bl跳转C()函数的汇编

上图中0x0100c7dcd0,不就是bl跳转C()函数的下一条指令的地址吗,这就验证了我们之前分析的,ViewDidLoad的lr寄存器的值被保存到了它自己的栈里面。

然后继续往下执行ldr x30,[sp],#0x10,x30的值就取到了0x0100c7dcd0,就能跳转回ViewDidLoad了,这个时候死循环就已经解决了。

综上所述
⚠️ 在函数嵌套调用的时候,需要将x30入栈

如果拉伸的是8字节

如果只拉伸8字节的空间,结果会怎样?

_C:
    str x30, [sp,#-0x8]!
    mov x0,#0xaaaa
    bl _D
    mov x0,#0xaaaa
    ldr x30,[sp],#0x8
    ret

这里 str x30, [sp,#-0x8]!只拉伸8字节,run

错误是报在ldr x30,[sp],#0x8这行,说明,拉伸空间没问题,但是要恢复内存,返回ViewDidLoad时报错了,即从内存读数据,存到x30寄存器的时候报错了

所以,栈中一定要保持16字节对齐的原则!

三、函数的参数和返回值

接下来,我们看看汇编是怎么处理带有参数和返回值的函数。例如

int sum(int a, int b) {
    return a + b;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    sum(10,20);
}

sum(10,20);这行打上断点,查看汇编

上图红框处,对w0,w1赋值的不就是10跟20吗,接着step into查看sum函数的汇编

最终,返回ViewDidLoad之前,结果是保存在寄存器w0中。于是,我们自己实现一个sum函数的汇编,可以这么写

.text
.global _sum

_sum:
    add x0,x0,x1
    ret

x0 = x0 + x1,因为参数就是保存在 x0和x1之中。

调用的

int sum(int a, int b);
- (void)viewDidLoad {
    [super viewDidLoad];
    printf("%d",sum(10,20));
}

运行看看

  1. ARM64下,函数的参数是存放在X0到X7(W0到W7)这8个寄存器里面的。
  2. 如果超过8个参数,就会入栈
  3. 函数的返回值是放在X0寄存器里面的。
参数超过8个的情况
int test(int a, int b, int c ,int d, int e, int f, int g, int h, int i) {
    return a + b + c + d + e + f + g + h + i;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    test(1, 2, 3, 4, 5, 6, 7, 8, 9);
}

参数分布与sp指向如下图所示

接着我们step into去到test函数中

整个累加的过程如下图所示

最终函数返回值放入w0中。

如果在release模式下test不会被调用(被优化掉,因为没有意义,且对app没有影响。)

返回值

  • 函数的返回值一般是一个指针,不会超过8字节。所以,x0寄存器就完全够用了。
  • 如果要返回一个结构体类型超过8字节

请看下面的实例

// str结构体占用24字节大小
struct str {
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
};

struct str getStr(int a, int b, int c, int d, int e, int f) {
    struct str str1;
    str1.a = a;
    str1.b = b;
    str1.c = c;
    str1.d = d;
    str1.e = e;
    str1.f = f;
    return str1;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    struct str str2 = getStr(1,2,3,4,5,6);
}

打上断点,查看汇编

接着step into进入到getStr函数中

getStr整个汇编赋值的过程如下图所示

最终会发现,这里并没有以 x0 作为返回值,而是将返回值写入上一个函数(ViewDidLoad函数)的栈x8寄存器中。

综上,如果返回值大于8字节,返回值会保存在上一个函数栈空间

结构体成员超过8个

如果结构体成员超过8个呢,是个什么情况?

struct str {
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
    int g;
    int h;
    int i;
    int j;
};

struct str getStr(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) {
    struct str str1;
    str1.a = a;
    str1.b = b;
    str1.c = c;
    str1.d = d;
    str1.e = e;
    str1.f = f;
    str1.g = g;
    str1.h = h;
    str1.i = i;
    str1.j = j;
    return str1;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    struct str str2 = getStr(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    printf("%d",func(10,20));
}

ViewDidLoad汇编

ASMPrj`-[ViewController viewDidLoad]:
    // 拉伸栈空间6*16=96字节大小
    0x100f31c80 <+0>:   sub    sp, sp, #0x60             ; =0x60 
    // 将x29 和x30的值存到栈中,地址是sp+0x50  `保存回家的路`
    0x100f31c84 <+4>:   stp    x29, x30, [sp, #0x50]
    // x29指向sp+0x50这个地址
    0x100f31c88 <+8>:   add    x29, sp, #0x50            ; =0x50 
    // 参数x0和x1 入栈
    0x100f31c8c <+12>:  stur   x0, [x29, #-0x8]
    0x100f31c90 <+16>:  stur   x1, [x29, #-0x10]
    // x8存入参数x0的值
    0x100f31c94 <+20>:  ldur   x8, [x29, #-0x8]
    // x9指向 x29 - 0x20
    0x100f31c98 <+24>:  sub    x9, x29, #0x20            ; =0x20 
    // x8 存入 x29 - 0x20
    0x100f31c9c <+28>:  stur   x8, [x29, #-0x20]
    // adrp  address page 内存中取数据
    // ADRP指令
    // * 编译时,首先会计算出当前PC到exper的偏移量#offset_to_exper 
    // * pc的低12位清零,然后加上偏移量,给register
    // * 得到的地址,是含有label的4KB对齐内存区域的base地址;
    0x100f31ca0 <+32>:  adrp   x8, 4 // 此处的偏移量是4

    // x8 所指的内存取出来
    0x100f31ca4 <+36>:  add    x8, x8, #0x4e0            ; =0x4e0 
    0x100f31ca8 <+40>:  ldr    x8, [x8]
    0x100f31cac <+44>:  str    x8, [x9, #0x8]
    0x100f31cb0 <+48>:  adrp   x8, 4
    0x100f31cb4 <+52>:  add    x8, x8, #0x458            ; =0x458 
    0x100f31cb8 <+56>:  ldr    x1, [x8]
    0x100f31cbc <+60>:  mov    x0, x9
    0x100f31cc0 <+64>:  bl     0x100f32524               ; symbol stub for: objc_msgSendSuper2

    // x8指向 sp + 0x8
    0x100f31cc4 <+68>:  add    x8, sp, #0x8              ; =0x8 
    0x100f31cc8 <+72>:  mov    w0, #0x1
    0x100f31ccc <+76>:  mov    w1, #0x2
    0x100f31cd0 <+80>:  mov    w2, #0x3
    0x100f31cd4 <+84>:  mov    w3, #0x4
    0x100f31cd8 <+88>:  mov    w4, #0x5
    0x100f31cdc <+92>:  mov    w5, #0x6
    0x100f31ce0 <+96>:  mov    w6, #0x7
    0x100f31ce4 <+100>: mov    w7, #0x8
    // sp的值给x9
    0x100f31ce8 <+104>: mov    x9, sp
    // w10中存储 9
    0x100f31cec <+108>: mov    w10, #0x9
    // w10中保存x9的地址
    0x100f31cf0 <+112>: str    w10, [x9]
    // w10中存储 10
    0x100f31cf4 <+116>: mov    w10, #0xa
    // x9偏移4字节,再存入w10
    0x100f31cf8 <+120>: str    w10, [x9, #0x4]
    // 跳转getStr函数
    0x100f31cfc <+124>: bl     0x100f31bf4               ; getStr at ViewController.m:30
    0x100f31d00 <+128>: ldp    x29, x30, [sp, #0x50]
    0x102499d04 <+132>: add    sp, sp, #0x60             ; =0x60 
    0x102499d08 <+136>: ret  

接着看getStr汇编

ASMPrj`getStr:
->  0x1004ddbf4 <+0>:   sub    sp, sp, #0x30             ; =0x30 
    // 从上一个栈空间 获取9 和 10
    0x1004ddbf8 <+4>:   ldr    w9, [sp, #0x30]
    0x1004ddbfc <+8>:   ldr    w10, [sp, #0x34]
    // 参数入栈
    0x1004ddc00 <+12>:  str    w0, [sp, #0x2c]
    0x1004ddc04 <+16>:  str    w1, [sp, #0x28]
    0x1004ddc08 <+20>:  str    w2, [sp, #0x24]
    0x1004ddc0c <+24>:  str    w3, [sp, #0x20]
    0x1004ddc10 <+28>:  str    w4, [sp, #0x1c]
    0x1004ddc14 <+32>:  str    w5, [sp, #0x18]
    0x1004ddc18 <+36>:  str    w6, [sp, #0x14]
    0x1004ddc1c <+40>:  str    w7, [sp, #0x10]
    0x1004ddc20 <+44>:  str    w9, [sp, #0xc]
    0x1004ddc24 <+48>:  str    w10, [sp, #0x8]
    // 获取参数分别存入上一个栈x8所指向的地址中
    0x1004ddc28 <+52>:  ldr    w9, [sp, #0x2c]
    0x1004ddc2c <+56>:  str    w9, [x8]
    0x1004ddc30 <+60>:  ldr    w9, [sp, #0x28]
    0x1004ddc34 <+64>:  str    w9, [x8, #0x4]
    0x1004ddc38 <+68>:  ldr    w9, [sp, #0x24]
    0x1004ddc3c <+72>:  str    w9, [x8, #0x8]
    0x1004ddc40 <+76>:  ldr    w9, [sp, #0x20]
    0x1004ddc44 <+80>:  str    w9, [x8, #0xc]
    0x1004ddc48 <+84>:  ldr    w9, [sp, #0x1c]
    0x1004ddc4c <+88>:  str    w9, [x8, #0x10]
    0x1004ddc50 <+92>:  ldr    w9, [sp, #0x18]
    0x1004ddc54 <+96>:  str    w9, [x8, #0x14]
    0x1004ddc58 <+100>: ldr    w9, [sp, #0x14]
    0x1004ddc5c <+104>: str    w9, [x8, #0x18]
    0x1004ddc60 <+108>: ldr    w9, [sp, #0x10]
    0x1004ddc64 <+112>: str    w9, [x8, #0x1c]
    0x1004ddc68 <+116>: ldr    w9, [sp, #0xc]
    0x1004ddc6c <+120>: str    w9, [x8, #0x20]
    0x1004ddc70 <+124>: ldr    w9, [sp, #0x8]
    0x1004ddc74 <+128>: str    w9, [x8, #0x24]
    // 栈平衡
    0x1004ddc78 <+132>: add    sp, sp, #0x30             ; =0x30 
    0x1004ddc7c <+136>: ret   

整个执行的过程如下图所示

上图所示,参数和返回值都存在上一个函数(ViewDidLoad)的栈中,并且返回值的地址在高位,参数在低位。

四、函数的局部变量

最后,我们来看看函数的局部变量,先看下面示例

int func(int a, int b) {
    int c = 6;
    return  a + b + c;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    func(10, 20);
}

首先看看func的汇编

上图可知 函数的局部变量放在函数自己的栈里面!

嵌套调用

如果是嵌套调用的场景呢?会是怎样的情况,例如

int func1(int a, int b) {
    int c = 6;
    int d = func2(a, b, c);
    int e = func2(a, b, c);
    return  d + e;
}

int func2(int a, int b, int c) {
    int d = a + b + c;
    printf("%d",d);
    return d;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    func1(10, 20);
}

汇编代码

上图可见,参数和返回值依然被保存到栈中。

现场保护包含:FP,LR,参数,返回值。

总结

    • 是一种具有特殊的访问方式的存储空间(后进先出,LIFO)
    • SP和FP寄存器
      • sp寄存器在任意时刻保存栈顶的地址
      • fp(x29)寄存器属于通用寄存器,在某些时刻利用它保存栈底的地址(嵌套调用)
    • ARM64里面栈的操作16字节对齐
    • 栈读写指令
      • 读:ldr(load register)指令LDR、LDP
      • 写:str(store register)指令STR、STP
    • 汇编指令:
      • sub sp, sp,#0x10 ;拉伸栈空间16个字节
      • stp x0,x1,[sp];往sp所在位置存放x0和x1
      • ldp x0,x1,[sp];读取sp存入x0和x1
      • add sp,#0x10;恢复栈空间
    • 简写:
      • stp x0, x1,[sp,#-0x10]!;前提条件是正好开辟的空间放满栈。先开辟空间,存入值,再改变sp的值。
      • ldp x0,x1,[sp],#0x10
  • bl指令
    • 跳转指令:bl标号,转到标号处执行指令并将下一条指令的地址保存到lr寄存器
    • B代表跳转
    • L代表lr(x30)寄存器
  • ret指令
    • 类似函数中的return
    • 让CPU执行lr寄存器所指向的指令
    • 有跳转需要“保护现场”
  • 函数
    • 函数调用栈
      • ARM64中栈是递减栈,向低地址延伸的栈
      • SP寄存器指向栈顶的位置
      • X29(fp)寄存器指向栈底的位置
    • 函数的参数
      • ARM64中,默认情况下参数是放在X0~X7的8个寄存器中
      • 如果是浮点数,会用浮点寄存器
      • 如果超过8个参数会用栈传递(多过8个的参数在函数调用结束后参数不会释放,相当于局部变量,属于调用方,只有调用方函数执行结束栈平衡后才释放。)
    • 函数的返回值
      • 一般情况下函数的返回值使用X0寄存器保存
      • 如果返回值大于了8个字节(放不下),就会利用内存。写入上一个调用栈内部,用X8寄存器作为参照。
    • 函数的局部变量
      • 使用栈保存局部变量
      • 函数的嵌套调用
        • 会将X29(fp),X30(lr)寄存器入栈保护。
        • 同时现场保护的还有:FP,LR,参数,返回值。

你可能感兴趣的:(02-汇编基础(2))