本文采用的是arm汇编,从汇编的角度来加深对函数调用栈的理解
写在前面的话
尽管已经看过了很多关于调用栈的文章和视频,还是没有理解函数调用栈的过程,刚好这几天离职在家,就坐下来好好的分析了一下调用过程,特此记录一下,看万遍不如自己写一遍(这句话同样适合认识machO格式的我)。
本文使用的代码如下
void func3(int a, int b){
int c = a + b;
printf("--%d---", c);
}
void func2(){
int a = 1;
int b = 2;
func3(a, b);
int c = b * 2;
printf("---%d----",c);
}
void func1(){
func2();
}
对函数调用栈的认识
- 函数调用过程中,局部变量,lr(x30)函数返回值,fp(x29)寄存器不断的进栈出栈的过程.
- 函数的调用栈是从高地址往低地址分配的,是一块连续的内存.
- 函数的调用栈是高度平衡的
arm汇编的基础知识(64位系统)
1. fp(x29)寄存器
指向当前的函数调用栈的栈底(高地址)
2. sp寄存器
指向当前函数调用栈的栈顶(低地址)
3. lr(x30)寄存器
存储函数返回之后下一条指令的地址。
4. bl指令
函数调用指令
5. stp指令
将寄存器的值存储到内存
6. ldp指令
将内存的值存储到寄存器
7.sub指令
做减法
8.add指令
做加法
其它(重要)
函数a调用b,b调用c,那么b函数调用的时候,lr里面会存储a里面调用b的下一条指令地址;然后b调用c,此时lr里面又会存b调用c的下一条指令地址。此时的lr被修改了,在b执行完成之后回不到正确的地址.所以在进入函数b调用后的最开始,要将lr寄存器的值存储在内存.同理调用c的时候lr也会存储在内存,防止在后续的调用过程修改了lr的值。在b执行完返回的之前,要从内存取出下一条指令的地址存储在lr寄存器.
例如我在func2里面调用func3,func3执行完成之后,要返回到func2继续执行.在调用func3的时候,下一条执行的地址会存储在lr寄存器。
- 在调用函数func3的地方下个断点,进入汇编,输入si单步执行,执行到bl函数调用这个地方,如下图.发现下一条指令的地址为0x102fe9744.
2.输入si单步执行,会进入函数func3内如,如下图。此时查看lr寄存器的值为0x102fe9744,就是函数func3下一条指令的地址.
具体调用过程
1.调用func1
- 在调用func1的地方下个断点,进入func1内部,汇编代码如下图
执行stp之前,此时
sp=0x000000016d3877a0,
lr=0x0000000102a7d794,
fp=0x000000016d387860,
这几个值保存的是调用func1函数的时候,当前寄存器的值。我们对此时的调用栈画一个图如下。
2. 移动sp向下移动16个字节,然后将x29和x30的值存储在sp上面各八个字节的位置。 此时的调用栈如下图
stp x29, x30, [sp, #-0x10]! -> 这个[sp, #-0x10]!后面有感叹号,表示sp的值会改变
sp向下移动了0x10(16)个字节,同时下一条mov x29,sp将x29移动到了sp的位置,这里直接标注出来。stp将x29存储在sp开始的前8个字节,将x30存储在后8个字节,如图。同样可以使用lldb读取一下sp寄存器对应内存地址的内如,如下图.读取了16个字节,内容正好是x29和x30的值.
3.执行一下si.调用func2之前,我们此时我们看一下寄存器的值,如下图,此时的调用fp,sp的值就是我们上面调用栈所指向的位置。 并且下一条指令的地址为0x102a7d770,我们可以在进入func2之后,查看一下lr寄存器的内容
2.调用func2
1.开辟func2的调用栈,保存现场
1.查看一下当前fp,sp,lr的值,如下图。此时的lr已经是func1里面下一条指令的地址,sp,fp依旧是进入func1之前的地址。
2. 第一条指令将sp向下移动
sub sp, sp, #0x30 -> sp = sp-0x30,就是sp像下移动0x30(3*16)个字节
- 第二条指令将x29,x30的值存储在sp+0x20开始像上16个字节的位置.
stp x29, x30, [sp, #0x20] -> [sp, #0x20]后面没有!,表示sp的值不改变
- 第三条指令将x29移动到sp+0x20的位置.这三条指令执行完成之后,此时的调用栈如下图。图中的1,2,3分别表示每一条指令执行之后的状态.
add x29, sp, #0x20
- 此时读取一下x29 x39 sp寄存器的值如下.是和我们的调用栈对应的
- 读取一下sp+0x20存储的值,因为我们在第3步第二条指令将x29,x30存储在该位置了。可以看到存储的值就是第1步,func1执行之前fp,lr的值
2.局部变量入栈
- 第一条指令将1存储在w8(x8的低32位)寄存器里面,第二条指令将w8寄存器的值存储在x29-0x4的位置。
mov w8, #0x1
stur w8, [x29, #-0x4]
- 同样第三条第四条指令是将2的值存储在x29-0x8的位置.这四条指令执行完后的调用栈如图,红色框为这四条指令改变调用栈状态
- 此时查看一下x29-0x4和x29-0x8位置的值。int占4个字节,前面4个字节存储的是1,后面4个字节存储的是2
4.然后通过ldur指令将1存储在w0里面,将2存储在w1里面。接着调用func3.此时查看一下x29 x30 sp的值如下。此时的值和我们的调用栈是对上的
3.调用func3
1.开辟栈空间,保存现场
1.此时func3的汇编代码如下,并且x29 x30 sp的值如图,都是func2执行后当前寄存器的状态。
2.开始的三条指令同样和func2的三条指令是一样的,sp向下移动0x30个字节,x29,x30入栈,移动x29.执行完之后调用栈如下图.红色框为func3的调用栈
3.执行完前三条指令之后x29 x30 sp的值为.和我们分析的调用栈是一致的。
2.局部变量入栈
1.后面的两条stur指令是将w0,w1也就是参数入栈。入栈之后的调用栈如下,红色框为这两条指令改变的调用栈状态.
2.读取一下fp-0x8位置的内容,刚好就是存的值为1和2,就是我们的调用栈标出来的值。
3.函数返回,栈平衡
1.再看一下func3运行完成后,调用栈的状态如何.我们知道printf函数执行完成之后,func3就执行完成了,中间那一部分就是计算并且打印。我们直接看最后3条指令。
ldp x29, x30, [sp, #0x20]
add sp, sp, #0x30
ret
ldp表示将sp+0x20位置的值赋值给x29和x30,从func开始的汇编我们知道,sp+0x20开始8个字节存的就是x29(func2调用栈fp栈底)的值,sp+0x20后8个字节存的就是x30(func2下一条指令的地址)的值。 add 表示将sp向上增长0x30个字节,前两条指令执行完成之后。调用栈的状态如下图。可以看到调用ret之前。sp,fp已经指向了func2的调用栈。这就是栈平衡,函数执行后会恢复执行之前的状态。
- 看一下前两条指令执行完成之后,x29, x30 , sp的值。和我们分析的调用栈是吻合的。
- ret表示返回,该指令执行后pc(程序执行的当前指令的地址)寄存器会从lr寄存器开始执行。我们发现lr的值为0x102a7d730.我们输入si单步执行,看一下下一条执行的指令的地址.如下图。跳转到了func2里面调用func3之后的下一条指令的地址执行。就是lr里面的内容
func2调用func3后面的内容
后面的内容是一样的,就不分析了,也是局部变量入栈啊,栈回收啊这样的。
总结
函数调用栈其实就是栈顶寄存器fp(x29)、调用结束下一条指令的地址lr(x30)寄存器和函数参数、局部变量入栈,函数调用结束栈又恢复平衡的过程。调用栈是一段连续的地址,从高地址往低地址延伸。
栈溢出
理解了函数的调用栈,就不难理解栈溢出了,例如一个没有出口的递归调用,栈空间会不断的从高地址往地地址延伸,最终将栈空间消耗完了抛出异常。
该文章是笔者的从通过上面的示例代码对调用栈的理解,如果有错误的地方欢迎指正。