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
上面的汇编代码的执行过程,如下图所示👇
- 通过
sub减指令
来开辟空间,此时sp指向低地址位置,x29也是fp指向栈底,即高地址位置 - 函数
ret
即调用完毕返回之前,需要通过add加指令
,恢复sp寄存器地址指向,这就是所谓的栈平衡
- 恢复后
数据并不销毁
,下次再拉伸栈空间后,会先覆盖再读取
。如果先读取,读取的就是垃圾数据
。
二、内存读写指令
注意⚠️
读/写
数据是都是往高地址
进行操作。
读/写
的指令主要有2个👇
-
str(store register)指令
👉 将数据从寄存器中读出来,存到内存中. -
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
,将其中的值存储到内存中
- [sp, #0x10] 👉
- 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()中的一样,没变化,继续执行下去,你会发现,断点执行一直在0x1021e5c78
和0x1021e5c7c
这两句中跳转,返不回去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));
}
运行看看👇
- ARM64下,函数的参数是存放在
X0到X7(W0到W7)
这8个寄存器里面的。- 如果
超过8个
参数,就会入栈
。- 函数的
返回值是放在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,参数,返回值。
- 函数调用栈