栈
栈:是一种具有特殊的访问方式的存储空间,具有后进先出的特性(
Last In Out Firt
,LIFO
)
SP和FP寄存器
sp
寄存器:在任意时刻会保存栈顶的地址(栈的开口方向)fp
寄存器:也称为x29
寄存器,属于通用寄存器,但是在某些时刻我们利用它保存栈底的地址(有局部变量且嵌套调用的时候)注意:
ARM64
开始,取消32位
的LDM
、STM
、PUSH
(入栈)、POP
(出栈)指令。 取而代之的是ldr\ldp
、str\stp
32位
模式下,空栈时栈顶和栈底指向同一个地方。当数据入栈,栈顶指针跟随向上移动。数据出栈,栈顶指针跟随向下移动但是在
ARM64
中,栈的开口方向是向下的,由高地址到低地址。对栈的操作是16字节
对齐存储数据前先拉伸栈,也就是开辟栈空间,然后再往里面存储数据。使用完毕后恢复栈平衡,里面存储的数据不需要回收,因为下次开辟栈空间后,新数据会直接覆盖
栈空间的拉伸,在代码编译过程中,由编译器决定,将局部变量、参数等放入栈区
死循环和死递归的区别
- 死循环:如果死循环内没有开辟任何空间,不会造成程序崩溃
- 死递归:死递归将不断开辟栈空间,最终因为堆栈溢出导致程序崩溃
堆栈溢出(
Stack Overflow
)
- 栈的开口方向是向下的,而堆区是向上的。当栈区与堆区边界碰撞,就会造成堆栈溢出
函数调用栈
常见的函数调用开辟和恢复的栈空间
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
指令,减一个地址,相当于开辟栈空间- 使用
add
指令,加一个地址,相当于恢复栈平衡- 写数据时,必须先拉伸栈,有了栈空间后,才能存放
关于内存读写指令
读/写数据是都是往高地址读/写
str
(store register
)指令
- 将数据从寄存器中读出来,存到内存中
ldr
(load register
)指令
- 将数据从内存中读出来,存到寄存器中
ldr
和str
的变种指令,ldp
和stp
,可以操作2
个寄存器
案例:
开辟
32字节
作为这段程序的栈空间。利用栈将x0
、x1
寄存器中的值进行交换搭建
Demo
项目
创建
asm.s
文件,写入以下代码:.text .global _A _A: sub sp, sp, #0x20 mov x0, #0xa0 mov x1, #0xb0 stp x0, x1, [sp,#0x10] ldp x1, x0, [sp,#0x10] add sp, sp, #0x20 ret
sub sp, sp, #0x20
:开辟栈空间,sp
拉伸32字节
栈空间mov x0, #0xa0
:将#0xa0
写入x0
寄存器mov x1, #0xb0
:将#0xb0
写入x1
寄存器stp x0, x1, [sp,#0x10]
:将x0
、x1
寄存器的值,写入到sp
向上偏移16字节
的内存地址中ldp x1, x0, [sp,#0x10]
:读取sp
向上偏移16字节
后内存中的值,写入到x1
、x0
寄存器,相当于交换add sp, sp, #0x20
:将拉伸后的sp
加32字节
,恢复栈平衡ret
:返回打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" int A(void); @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; A(); } @end
真机运行项目,使用断点单步调试,来到
A
函数
单步调试,向下执行
1
步。开辟栈空间,拉伸32字节
- 拉伸后,
sp
指向地址0x000000016d250fd0
向下执行
2
步,将#0xa0
写入x0
寄存器,将#0xb0
写入x1
寄存器
向下执行
1
步,将x0
、x1
寄存器的值,写入到sp
向上偏移16字节
的内存地址中
单步调试,向下执行
1
步。读取sp
向上偏移16字节
后内存中的值,写入到x1
、x0
寄存器,相当于交换
x0
、x1
寄存器的值交换,但内存的数据并没有发生任何变化- 内存充当临时变量的作用
向下执行
1
步,将拉伸后的sp
加32字节
,恢复栈平衡
- 恢复栈平衡,
sp
指向地址0x000000016d250ff0
- 内存中的数据依然存在,它们并不需要被回收。当下一轮栈空间开辟后,新数据会将其覆盖
bl和ret指令
bl
bl 地址
- 将下一条指令的地址放入
lr
(x30
)寄存器- 转到标号处执行指令
ret
- 默认使用
lr
(x30
)寄存器的值,通过底层指令提示CPU
此处作为下条指令地址
ARM64
平台的特色指令,它面向硬件做了优化处理
x30寄存器
x30
寄存器存放的是函数的返回地址,当ret
指令执行时刻,会寻找x30
寄存器保存的地址值在函数嵌套调用的时候,需要将
x30
入栈
案例1:
演示
lr
寄存器,在函数嵌套调用时的作用延用上述
Demo
案例打开
asm.s
文件,写入以下代码:.text .global _A,_B _A: sub sp, sp, #0x20 bl _B add sp, sp, #0x20 ret _B: ret
A
函数:拉伸栈空间- 跳转
B
函数- 恢复栈平衡
B
函数:直接返回真机运行项目,来到
viewDidLoad
方法
- 即将执行的指令是
bl 0x104bee9bc
,也就是跳转到A
函数bl
指令的特性,一旦执行,先将下一条指令地址0x104bee644
放入lr
寄存器,然后进行跳转单步调试,向下执行
1
步。跳转到A
函数
- 打印
lr
寄存器的值,已经赋值为0x104bee644
向下执行
1
步,开辟栈空间
- 即将执行的又是
bl
指令- 一旦执行,将下一条指令地址
0x104bee9c4
放入lr
寄存器,然后进行跳转B
函数向下执行
1
步,跳转到B
函数
- 打印
lr
寄存器的值,已经赋值为0x104bee9c4
- 即将执行
B
函数中的ret
指令ret
指令的特性,使用lr
寄存器的值作为下条指令地址。即:跳转至0x104bee9c4
向下执行
1
步,顺利回到了A
函数的0x104bee9c4
指令地址
向下执行
1
步,恢复栈平衡
- 即将执行的是
A
函数的ret
指令- 按照正常逻辑,应该跳转回
viewDidLoad
方法的0x104bee644
指令地址- 但是,打印
lr
寄存器的值,保存的依然是A
函数的0x104bee9c4
指令地址向下执行
1
步,产生死循环,又回到A
函数的0x104bee9c4
指令地址
此时取消断点,让程序继续运行。程序会在
add sp, sp, #0x20
和ret
两句指令上,循环往复的执行,从而形成一个死循环
上述问题的产生,牵扯到
lr
寄存器的现场保护
lr
寄存器保存的相当于回家的路
。函数嵌套调用过程中,在bl
到另一个函数前,必须保护好当前的lr
寄存器,否则函数返回势必出现问题如果当前函数为叶子函数,里面没有其他函数的调用,例如案例中的
B
函数,则无需保护
当
bl
指令执行,lr
寄存器会被替换,如何对它进行现场保护?能否将
lr
寄存器的值,存储在其他寄存器中?
- 肯定是
不行
的。因为寄存器的数量有限,在后续的函数嵌套调用中,有可能被任意函数覆盖掉,这种做法是不安全的
正确的作法是什么?我们参考
llvm
编译器生成的汇编代码,看看它是如何对lr
寄存器现场保护的案例2:
打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" void D(void){ } void C(void){ D(); } @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; C(); } @end
真机运行项目,使用断点单步调试,来到
C
函数
stp x29, x30, [sp, #-0x10]!
:该指令完成两个功能,先将sp
减16字节
,开辟栈空间,等同于sub sp, sp, #0x10
指令。然后将x29
、x30
寄存器写入到内存ldp x29, x30, [sp], #0x10
:该指令同样完成两个功能,先读取内存中的值,写入x29
、x30
寄存器。然后将sp
加16字节
,恢复栈平衡,等同于add sp, sp, #0x10
指令单步调试,向下执行
1
步。开辟栈空间,同时x29
、x30
寄存器的值写入内存
- 当前
lr
寄存器的值为0x000000010081263c
向下执行
2
步,进入B
函数,同时lr
寄存器的值被覆盖
- 当前
lr
寄存器的值被覆盖为0x00000001008125ec
向下执行
2
步,回到A
函数。先读取内存中的值,写入x29
、x30
寄存器,然后恢复栈平衡
- 当前
lr
寄存器的值恢复为0x000000010081263c
向下执行
1
步,成功ret
到viewDidLoad
方法的0x10081263c
指令地址
由此可见,编译器在开辟栈空间后,先将
x29
、x30
寄存器保存在当前函数栈中。然后在ret
指令前,读取内存中的值,写入x29
、x30
寄存器。最后恢复栈平衡,ret
回到正确的指令地址将数据入栈保护的行为,统称:
现场保护
案例3:
按上述
llvm
编译器的作法,重新修改案例1
的汇编代码打开
asm.s
文件,写入以下代码:.text .global _A,_B _A: sub sp, sp, #0x10 stp x29, x30, [sp] bl _B ldp x29, x30, [sp] add sp, sp, #0x10 ret _B: ret
真机运行项目,来到
viewDidLoad
方法
- 下一条指令地址
0x10211a63c
单步调试,向下执行
3
步。跳转到A
函数,开辟栈空间,将x29
、x30
寄存器写入到内存
- 下一条指令地址
0x10211a9c0
向下执行
1
步,跳转到B
函数
lr
寄存器被覆盖为0x10211a9c0
向下执行
2
步,回到A
函数。读取内存中的值,写入x29
、x30
寄存器
向下执行
2
步,恢复栈平衡,成功ret
到viewDidLoad
方法的0x10211a63c
指令地址
上述案例中,将简写指令拆解,以便理解:
stp x29, x30, [sp, #-0x10]!
:
sub sp, sp, #0x10
stp x29, x30, [sp]
ldp x29, x30, [sp], #0x10
:
ldp x29, x30, [sp]
add sp, sp, #0x10
案例4:
如果只有一个
x30
寄存器需要现场保护,开辟16字节
栈空间过于浪费,只开辟8字节
可以吗?打开
asm.s
文件,将栈空间的开辟和恢复都改为8字节
- 当前
sp
寄存器的地址为0x000000016d54cff0
单步调试,向下执行
1
步。开辟栈空间,将x30
寄存器入栈保护
sp
寄存器的地址为0x000000016d54cfe8
向下执行
2
步,先跳转到B
函数,又返回到A
函数
截止到
此时此刻
,一切都是正常
的向下执行
1
步,从栈中取值写入x30
寄存器,恢复栈平衡
- 异常出现了。在
ARM64
中,对栈的操作是16字节
对齐。所以栈空间的开辟,必须为16字节
的倍数此处还有一个细节,上述代码使用简写指令,
x30
寄存器成功写入内存。但如果将指令拆解,在写入时就会报出异常
函数的参数和返回值
- 在
ARM64
中,函数的参数是存放在x0
至x7
这8
个寄存器里面。如果超过8
个参数,就会入栈- 函数的返回值是放在
x0
寄存器里面
案例1:
查看编译器是如何传递参数并计算返回的
打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" int sum(int a, int b){ return a + b; } @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; sum(10, 20); } @end
真机运行项目,来到
viewDidLoad
方法
- 传递给
sun
函数的10
、20
两个参数,分别写入w0
、w1
两个寄存器单步调试,向下执行
4
步。跳转到sun
函数,开辟16字节
栈空间,将w0
、w1
寄存器入栈保护
- 相当于函数内存储了两个局部变量
向下执行
2
步,从内存中取值,写入w8
、w9
寄存器
向下执行
1
步,将w8
、w9
两个寄存器的值相加,赋值给w0
寄存器
- 由于
CPU
无法直接对内存中的数据进行计算操作,故此先将内存数据写入寄存器,然后进行相加,赋值给x0
寄存器x0
寄存器之前存储的是参数,此刻被结果覆盖为0x000000000000001e
案例2:
使用汇编代码实现
sum
函数打开
asm.s
文件,写入以下代码:.text .global _sum _sum: add x0, x0, x1 ret
打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" int sum(int a, int b); @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; printf("sum:%d",sum(10, 20)); } @end
真机运行项目,来到
viewDidLoad
方法
- 传递给
sun
函数的10
、20
两个参数,分别写入w0
、w1
两个寄存器单步调试,向下执行
2
步。跳转到sun
函数,直接将x0
、x1
寄存器进行相加,将结果赋值给x0
打印结果:
sum:30
案例3:
当函数超过
8
个参数,编译器是如何传递的?打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" @implementation ViewController 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]; printf("sum:%d",test(1, 2, 3, 4, 5, 6, 7, 8, 9)); } @end
- 注释
[super viewDidLoad]
方法,减少生成的汇编代码,避免干扰真机运行项目,来到
viewDidLoad
方法
- 对
bl
指令前的汇编代码进行分析sub sp, sp, #0x30
:开辟48字节
栈空间stp x29, x30, [sp, #0x20]
:现场保护,将x29
、x30
在sp + #0x20
位置入栈add x29, sp, #0x20
:将sp + #0x20
位置设为栈底stur x0, [x29, #-0x8]
:将x0
在fp - #0x8
位置入栈。stur
指令:把寄存器的值(32位
)写进内存str x1, [sp, #0x10]
:将x1
在sp + #0x10
位置入栈mov w0, #0x1
~mov w7, #0x8
:将前8
个参数,分别写入到w0
~w7
mov x8, sp
:将sp
所在位置,写入到x8
mov w9, #0x9
:将第9
个参数,写入到w9
str w9, [x8]
:将w9
入栈到x8
所存储的sp
位置
bl
指令执行前View Memory
中的内存数据
bl
指令执行前的栈图
继续执行代码,跳转到
test
函数
- 对
add sp, sp, #0x30
指令前的汇编代码进行分析sub sp, sp, #0x30
:开辟48字节
栈空间ldr w8, [sp, #0x30]
:将sp + #0x30
位置的值写入w8
。sp + #0x30
是viewDidLoad
方法调用栈的位置,这里写入的是w9
的值,也就是第9
个参数的值str w0, [sp, #0x2c]
~str w8, [sp, #0xc]
:现场保护,将w0
至w8
的9
个寄存器分别入栈ldr w8, [sp, #0x2c]
:将sp + #0x2c
位置的值写入w8
,也就是第1
个参数的值ldr w9, [sp, #0x28]
:将sp + #0x28
位置的值写入w9
,也就是第2
个参数的值add w8, w8, w9
:w8
、w9
相加,将结果赋值给w8
- 依次从内存中将后续
6
个参数值写入w9
,然后和w8
相加,将结果赋值给w8
add w0, w8, w9
:最后一个参数的相加,w8
、w9
相加,将结果赋值给w0
add sp, sp, #0x30
指令执行前View Memory
中的内存数据
add sp, sp, #0x30
指令执行前的栈图
打印结果:
sum:45
上述案例中,函数参数超过
8
个,超出的参数不再使用寄存器传递,而是直接入栈。当下一个函数使用时,从上一个函数调用栈中读取从栈中读取数据效率并不高,所以在开发中,应避免函数超过
8
个参数
C
函数:最好不要超过8
个参数OC
方法:最好不要超过6
个参数。因为objc_msgSend(id self, SEL _cmd, ...)
自身还有两个隐含参数
案例4:
上述案例,使用
Release
模式运行,编译器会如何优化?选择
Release
模式运行
真机运行项目,来到
viewDidLoad
方法
- 在
viewDidLoad
方法调用栈中,经过编译器优化只剩下少量代码,甚至连test
函数的调用也被优化掉了。编译器直接使用mov w8, #0x2d
指令,将计算结果45
写入x8
寄存器
案例5:
使用汇编代码实现带参数的函数嵌套调用
打开
asm.s
文件,写入以下代码:.text .global _funcA,_sum _funcA: stp x29, x30, [sp, #-0x10]! bl _sum ldp x29, x30, [sp], #0x10 ret _sum: add x0, x0, x1 ret
_funcA
函数嵌套调用_sum
函数,需要现场保护_sum
函数直接使用add x0, x0, x1
指令进行相加,结果写入x0
更简单的实现方式,将
bl
替换为b
指令.text .global _funcA,_sum _funcA: b _sum _sum: add x0, x0, x1 ret
b
指令:用于不返回的跳转,仅跳转到标号处,不改变lr
寄存器的值b
指令常用于破解的地方,可以绕过代码执行
案例6:
返回值一般是
8字节
指针。如果返回一个结构体,大小超过8字节
,编译器会如何处理?打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" @implementation ViewController struct str { int a; int b; int c; int d; int f; int g; }; struct str getStr(int a,int b,int c,int d,int f,int g){ struct str str1; str1.a = a; str1.b = b; str1.c = d; str1.d = d; str1.f = f; str1.g = g; return str1; } - (void)viewDidLoad { // [super viewDidLoad]; struct str str2 = getStr(1, 2, 3, 4, 5, 6); } @end
真机运行项目,来到
viewDidLoad
方法
sub sp, sp, #0x40
:开辟64字节
栈空间stp x29, x30, [sp, #0x30]
:现场保护,将x29
、x30
在sp + #0x30
位置入栈add x29, sp, #0x30
:将sp + #0x30
位置设为栈底stur x0, [x29, #-0x8]
:将x0
在fp - #0x8
位置入栈stur x1, [x29, #-0x10]
:将x1
在fp - #0x10
位置入栈add x8, sp, #0x8
:将x8
指向sp + #0x8
位置
bl
指令执行前的栈图
继续执行代码,跳转到
getStr
函数
sub sp, sp, #0x20
:开辟32字节
栈空间str w0, [sp, #0x1c]
~str w5, [sp, #0x8]
:现场保护,将w0
至w5
的6
个寄存器分别入栈ldr w9, [sp, #0x1c]
:将sp + #0x1c
位置的值写入w9
,也就是第1
个参数的值str w9, [x8]
:将w9
入栈到x8
所存储的位置,x8
存储的是viewDidLoad
方法调用栈的位置- 后续逻辑同上,使用
w9
依次获取参数值,写入到viewDidLoad
方法调用栈中- 最后
add sp, sp, #0x20
恢复栈平衡并ret
add sp, sp, #0x20
指令执行前的栈图
上述案例,当返回值超过
8字节
,则不再使用x0
寄存器作为返回值,而是将返回的数据写入到上一个函数调用栈中所以在开发中,应避免返回的数据超过
8字节
。如果函数需要返回结构体,可以将返回类型定义为结构体指针,将大小控制在8字节
,这样可以使用x0
寄存器传递,效率会更高struct str * getStr(int a,int b,int c,int d,int f,int g){ struct str * str1 = malloc(24); str1 -> a = a; str1 -> b = b; str1 -> c = d; str1 -> d = d; str1 -> f = f; str1 -> g = g; return str1; }
函数的局部变量
函数的局部变量放在栈里面
案例1:
编译器如何存储函数的局部变量?
打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" @implementation ViewController int funcB(int a, int b){ int c = 6; return a + b + c; } - (void)viewDidLoad { // [super viewDidLoad]; printf("sum:%d",funcB(10, 20)); } @end
真机运行项目,来到
funcB
方法
mov w8, #0x6
:#0x6
的值为6
,相当于局部变量c
,将其写入x8
寄存器str w8, [sp, #0x4]
:将w8
寄存器入栈- 相加时,先从栈中取值,写入
w9
寄存器,然后运算- 恢复栈平衡后,函数调用栈中的局部变量就不存在了
案例2:
编译器如何处理函数嵌套调用时的局部变量、参数和返回值?
打开
ViewController.m
文件,写入以下代码:#import "ViewController.h" @implementation ViewController int funcB(int a, int b){ int c = 6; int d = sumD(a, b, c); int e = sumD(a, b, c); return d + e; } int sumD(int a, int b, int c){ int d = a + b + c; return d; } - (void)viewDidLoad { // [super viewDidLoad]; printf("sum:%d",funcB(10, 20)); } @end
真机运行项目,来到
funcB
方法
sub sp, sp, #0x30
:开辟48字节
栈空间stp x29, x30, [sp, #0x20]
:现场保护,将x29
、x30
入栈add x29, sp, #0x20
:设置栈底stur w0, [x29, #-0x4]
:将w0
入栈,即:第1
个参数stur w1, [x29, #-0x8]
:将w1
入栈,即:第2
个参数mov w8, #0x6
:将#0x6
写入w8
,即:局部变量c
stur w8, [x29, #-0xc]
:将w8
入栈,即:局部变量c
ldur w0, [x29, #-0x4]
~ldur w2, [x29, #-0xc]
:从栈中读取两个参数和局部变量c
的值,分别写入w0
~w2
bl 0x102c925c4
:调用sumD
函数str w0, [sp, #0x10]
:sumD
函数使用w0
作为返回值,将其入栈,即:局部变量d
ldur w0, [x29, #-0x4]
~ldur w2, [x29, #-0xc]
:再次从栈中读取两个参数和局部变量c
的值,分别写入w0
~w2
bl 0x102c925c4
:再次调用sumD
函数str w0, [sp, #0xc]
:sumD
函数依然使用w0
作为返回值,将其入栈,即:局部变量e
ldr w8, [sp, #0x10]
:从栈中读取局部变量d
的值,写入w8
ldr w9, [sp, #0xc]
:从栈中读取局部变量e
的值,写入w9
add w0, w8, w9
:将w8
、w9
相加,结果赋值给w0
ldp x29, x30, [sp, #0x20]
:恢复x29
、x30
的值add sp, sp, #0x30
:恢复栈平衡ret
:返回
add sp, sp, #0x30
指令执行前的栈图
总结
栈
- 栈:存储空间,具有后进先出的访问方式
sp
寄存器:在任意时刻会保存我们栈顶的地址fp
寄存器:也称x29
寄存器,属于通用寄存器,但是在某些时刻我们利用它保存栈底的地址- 在
ARM64
中,栈是递减栈,由高地址向低地址延伸。对栈的操作是16字节
对齐的栈的读写指令
- 读:
ldr
(load register
)指令- 写:
str
(store register
)指令ldr
和str
的变种指令,ldp
和stp
,可以操作2
个寄存器简写指令:
stp x0, x1, [sp, #-0x10]!
- 数据入栈,一般从栈底开始存入。所以使用简写的条件是,不需要额外的栈空间,存入的数据刚好放满
- 简写的执行顺序,一定是先开辟空间,再入栈
- 等同于:
sub sp, sp, #0x10
:拉伸16字节
栈空间
stp x0, x1, [sp]
:在sp
所在位置存放x0
和x1
bl
指令
- 跳转指令:
bl 地址
。表示程序执行到标号处。将下一条指令的地址保存到lr
寄存器b
:代表跳转l
:代表lr
(x30
)寄存器
ret
指令
- 类似函数中的
return
- 让
CPU
执行lr
寄存器所指向的指令- 当函数嵌套调用时,需要现场保护。
lr
寄存器入栈函数嵌套调用
- 会将
x29
、x30
寄存器入栈保护函数的参数
- 在
ARM64
中,参数是放在x0
至x7
的8
个寄存器中- 如果是浮点数,使用浮点寄存器
- 如果超过
8
个参数就会用栈传递函数的返回值
- 默认情况下,函数的返回值放在
x0
寄存器- 如果返回值大于
8字节
,就会利用内存,写入上一个调用栈的内部,用x8
寄存器作为参照函数的局部变量
- 使用栈保存局部变量