本文的主要目的是理解函数栈
以及涉及的相关指令
在讲函数的本质之前,首先需要讲下以下几个概念栈、SP、FP
常识
栈
- 栈:是一种
具有特殊的访问方式的存储空间
(即先进后出 Last In First Out,LIFO
)
高地址往低地址存数据(
存:高-->低
)栈空间开辟:往低地址开辟(
开辟:高-->低
)
SP和FP寄存器
SP寄存器
:在任意时刻会保存栈顶的地址
FP寄存器
(也称为x29
寄存器):属于通用寄存器,但是在某些时刻(例如函数嵌套调用时)可以利用它保存栈底的地址
注意:
arm64开始,取消了32位的LDM、STM、PUSH、POP指令,取而代之的是
ldr/ldp、str/stp
(r和p的区别在于处理的寄存器个数,r表示处理1个寄存器,p表示处理两个寄存器)arm64中,对栈的操作是
16字节对齐
的!!!
以下是arm64之前和arm64之后的一个对比
在arm64之前,栈顶指针是压栈时一个数据移动一个单元
在arm64开始,首先是
从高地址往低地址开辟一段栈空间(由编译器决定)
,然后再放入数据,所以不存在push、pop操作。这种情况可以通过内存读写指令(ldr/ldp、str/stp
)对其进行操作
函数调用栈
以下是常见的函数调用开辟 (sub)
以及恢复栈空间 (add)
的汇编代码
//开辟栈空间
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
内存读写指令
str(store register)指令
(能和内存和寄存器交互的专门的指令):将数据从寄存器中读出来,存到内存中 (即一个寄存器是8字节-64位
)ldr(load register)指令
:将数据从内存中读出来,存到寄存器中此时ldr和str的变种
ldp和stp
还可以操作2个
寄存器(即128位-16字节
)
注意:
读/写数据都是往
高地址
读/写写数据:先拉伸栈空间,再拿sp进行写数据,即
先申请空间再写数据
练习
使用32个字节空间作为这段程序的栈空间,然后利用栈将x0和x1的值进行交换
sub sp, sp, #0x20 ;拉伸栈空间32个字节
stp x0, x1, [sp, #0x10] ;sp往上加16个字节,存放x0和x1
ldp x1, x0, [sp, #0x10] ;将sp偏移16个字节的值取出来,放入x1和x0,内存是temp(寄存器里面的值进行交换了)
add sp, sp, #0x20 ;栈平衡
ret ;返回
栈的操作如下图所示
调试查看栈
-
重写x0、x1的值
-
register read sp
【查看栈的存储情况:debug - debug workflow - view Memory
】
-
然后单步往下执行,发现x0、x1已经变成我们写入的值
)
查看内存变化,发现sp拉伸了32字节
-
stp x0, x1, [sp, #0x10]
:将x0、x1写入fp偏移0x10的位置,继续往下执行一步
此时sp的值并没有变化,还是指向40
-
ldp x1, x0, [sp, #0x10]
:读取x0,x1的数据并交换,继续往下执行一步,此时内存并没有变化
疑问:再来看sp是否有变化?
从结果来看,也没有变化。所以这里只是读出来进行的交换,并不会导致内存变化
-
add sp, sp, #0x20
:继续执行一步,走到栈平衡
,即sp恢复
了,此时的a和b仍然在内存中,等待着下一轮栈拉伸后数据的写入覆盖。如果此时读取,读取到的是垃圾数据
疑问:栈空间不断开辟,死循环,会不会崩溃?
在这里我们将会处理上篇iOS逆向 01:初识汇编文章中文末遗留的问题
下面我们通过一个汇编代码来演示
<!--asm.s-->
.text
.global _B
_B:
sub sp,sp,#0x20
stp x0,x1,[sp,#0x10]
ldp x1,x0,[sp,#0x10];寄存器里面的值进行交换
bl _B
add sp,sp,#0x20
ret
<!--调用-->
int B();
int main(int argc, char * argv[]) {
B();
}
运行结果发现:死循环会崩溃,会导致堆栈溢出
bl 、ret指令
b 标号 :跳转
-
bl标号
- 将下一条指令的地址放入
lr(x30)寄存器
(lr保存的是回家的路)(即l) -
转到标号处执行指令(即b)
等到B函数ret时,通过lr获取回家的路(注:lr就是保存回家的路)
- 将下一条指令的地址放入
-
ret
默认使用
lr(x30)寄存器
的值,通过底层指令提示CPU此处作为下条指令地址arm64平台的特色指令
,它面向硬件做了优化处理的
练习
下面通过汇编代码来演示bl、ret指令
.text
.global _A, _B
_A:
mov x0. #0xaaaa
bl _B
mov x0, #0xaaaa
ret
_B:
mov x0, #0xbbbb
ret
-
断点运行
疑问:发现A和print之间你还有几个汇编操作,这个是什么意思呢?
-
执行
mov x0. #0xaaaa
:x0变成aaaa,此时此刻lr寄存器保存的是5f34
-
验证
lr
是否保存的是5f34,通过查看寄存器发现结果与预期是一致的
-
继续执行
bl _B
,跳转到B,此时的lr会变成A中bl的下一条指令的地址5eb8
-
执行完B中的
mov x0, #0xbbbb
,x0变成bbbb
-
执行B中的
ret
,会回到A中5eb8
-
继续执行A中的ret,会再次回到5eb8
走到这里,发现死循环了,主要是因为lr
一直是5eb8,ret只会看lr
。其中pc
是指接下来要执行的内存地址,ret是指让CPU将lr作为接下来执行的地址(相当于将lr赋值给pc)
疑问1:此时B回到A没问题,那么A回到viewDidload怎么回呢?
- 需要在A的bl之前
保护lr寄存器
-
疑问2:是否可以保存到其他寄存器上?
答案是不可以,原因是不安全,因为你不确定这个寄存器会在什么时候被别人使用 - 正确做法:保存到栈区域
-
疑问2:是否可以保存到其他寄存器上?
系统中函数嵌套是如何返回?
下面我们来看下系统是如何操作的,例如:d -> c -> viewDidLoad
void d(){
}
void c(){
d();
return;
}
- (void)viewDidLoad{
[super viewDidLoad];
printf("A");
c();
printf("B");
}
-
查看汇编,断点断在c函数
-
进入c函数的汇编
stp x29,x30,[sp,#-0x10]!
:边开辟栈,边写入,其中x29就是fp,x30是lr
。!
表示将这里算出来的结果,赋值给splsp x29,x30,[sp],#0x10
:读取sp指向地址的数据,放入x29、x30,然后,,#0x10
表示将sp+0x10,赋值给sp
-
结论:当有函数嵌套调用时,将
上一个函数的地址
通过x30(即lr)
放在栈中保存
,保证可以找到回家的路,如下图所示
自定义汇编代码完善:_A中保存回家的路
所以根据系统的函数嵌套操作,最终在_A中增加了如下汇编代码,用于保存回家的路
<!--导致死循环的汇编代码-->
_A:
mov x0. #0xaaaa
bl _B
mov x0, #0xaaaa
ret
<!--增加lr保存:可以找到回家的路-->
_A:
sub sp, sp, #0x10 //拉伸
str x30, [sp] //存
mov x0, #0xaaaa
//保护lr寄存器,存储到栈区域
bl _B
mov x0, #0xaaa
ldr x30, [sp] //修改lr,用于A找到回家的路
add sp, sp, #0x10 //栈平衡
ret
修改_A、_B:改成简写形式
- 其中
lr
是x30
的一个别名
_A:
sub sp, sp, #0x10 //拉伸
str x30, [sp] //存
mov x0, #0xaaaa
//保护lr寄存器,存储到栈区域
bl _B
mov x0, #0xaaa
ldr x30, [sp] //修改lr,用于A找到回家的路
add sp, sp, #0x10 //栈平衡
ret
_B:
mov x0, #0xbbbb
ret
<!--改成简写形式-->
_A:
//sub sp, sp, #0x10 //拉伸
//str x30, [sp] //存
str x30, [sp, #-0x10]
mov x0, #0xaaaa
//保护lr寄存器,存储到栈区域
bl _B
mov x0, #0xaaa
//ldr x30, [sp] //修改lr,用于A找到回家的路
//add sp, sp, #0x10 //栈平衡
ldr x30, [sp], #0x10 //将sp的值读取出来,给到x30,然后sp += 0x10
ret
_B:
mov x0, #0xbbbb
ret
断点调试
-
查看此时sp寄存器的地址
-
执行
str x30, [sp, #-0x10]
,继续查看sp,发现sp变化了,但是此时lr没变
查看0x16f5a1c50
的memory,此时放入的是lr
的值861f2c
,即ViewDidLoad中的bl下一条指令的地址,目前只放了8个字节(1个寄存器)
-
执行A中的
mov x0, #0xaaaa
:x0变成aaaa
-
执行A中的
bl _B
,跳转到B,此时lr
变成 1e94,x0变成bbbb
-
执行B的
ret
:从B回到A,此时lr还是 1e94
-
执行A中的
ldr x30, [sp], #0x10
发现此时sp也变了,从0x16f5a1c50->0x16f5a1c60
。从这里可以看出,A找到了回家的路
疑问:为什么是拉伸16字节,而不是8字节呢?
通过手动尝试,有以下说明:
写入没问题
-
读取时会崩溃:因为sp中,对栈的操作
必须是16字节对齐
的,所以会在做栈的操作时就会崩溃
x30寄存器
x30
寄存器存放
的是函数的返回地址
,当ret指令执行时刻,会寻找x30寄存器保存的地址值注意:
在函数嵌套调用时,需要将x30入栈
lr是x30的别名
sp栈里面的操作必须是16字节对齐,崩溃是在栈的操作时挂的
总结
-
栈:是一种具有特殊的访问方式的存储空间(后进先出,Last in First out,
LIFO
)- ARM64里面对
栈的操作
是16字节对齐
的
- ARM64里面对
-
SP
和FP
寄存器-
SP
寄存器在任意时刻会保存栈顶的地址
-
FP
寄存器也称为x29
寄存器,属于通用寄存器,但是在某些时刻利用它保存栈底的地址
-
-
栈的读写指令
读:
ldr
(load register)指令 LDR、LDP写:
str
(store register)指令 STR、STP
-
汇编练习
- 指令
sub sp,sp,$0x10 ;拉伸栈空间18字节
stp x0,x1,[sp] ;sp所在位置存放x0、x1
- 简写
- str x0,x1,[sp,$-0x10]!(!就是将[]里面的结果赋值给sp)
- 指令
-
bl
指令跳转指令:
bl 标号
,表示程序执行到标号处,将下一条指令的地址保存到lr寄存器B
代表着跳转
L
表示lr
(x30)寄存ios_reverse_02器
-
ret
指令- 类似函数的
return
- 让CPU执行lr寄存器所指向的指令
- 类似函数的
避免嵌套函数无法回去:需要保护bl(即
lr
寄存器,存放回家的路),保存在当前函数自己的栈空间