1、栈介绍
通过以上资料理解栈帧(stack frame)的概念
2、栈在内存中的位置
3、函数调用在栈上的实现
例如下面这段测试代码:
#inclued <stdio.h>
void swap(int* a,int* b)
{
int tmp;
tmp = *a;
*a = *b;
*b = tmp;
}
int main()
{
int c = 1,d = 2;
swap(&c, &d);
return 0;
}
执行到 ← 方向时,调用者main()准备调用swap()
接着压swap()的参数,从右往左压,esp往下移
push完参数后,通过call指令,建个传送门,跳转到swap():
- push eip:压入下一条指令的地址
- jmp swap:跳到swap()函数里去执行
被调用者swap() 首先要把ebp调用过来,但是此时的ebp要保存上一个函数的Old ebp,所以需要先存着old ebp,然后把ebp指向esp的位置,和esp指向同一个地址。
此时ebp和esp共同指的old ebp,应该指向main栈帧的Old ebp所在的地址
随后esp先行去开路(sub 指令减去的值,是根据被调用者swap()声明的局部变量、还有一些其他的因素来定的),ebp守家
之后tmp的三条指令里面涉及到的变量访问,都是通过ebp加上或者减去某个值来访问的
随后,任务完成了,swap()函数就要让esp回去了
此时ebp 因为有点事要先撤了,于是ebp就指向了之前的old ebp所在
(若esp 没有开辟空间,可以直接pop ebp)
由于pop,栈指针寄存器 esp 自动递增,所以就指向了上一个地址
swap()函数的最后,是通过一条ret 指令,将原调用者main()函数的下一条指令的地址pop 到eip 中执行。同理,由于pop,栈指针寄存器 esp 自动递增,所以就又指向了上一个地址
现在程序执行流又回到了main()的层次,现在需要把没有用的当初压入的参数给清除掉(只需要把esp 加上某个值就ok了)
这样,ebp和esp就回到了最初的位置,本来eax 是用来返回 返回值 的,但是swap()函数没有返回值,eax就没有用了
由于最开始main()函数也没有给swap()函数分配寄存器,所以这里也不用还原寄存器。
如果以后main()函数还用调用其他函数的话,那么流程与调用swap()函数基本一致
若在进入函数前,如果有要保存的寄存器:
- 要先把寄存器压入栈中
- 之后压入被调用者的参数
- 再压入调用者下一条指令的地址
- 之后将执行流跳转到函数去执行
作为 被调用函数 :
- 首先将old ebp压入栈中,为了方便退出函数时原来栈帧的模样
- 之后将ebp 拉入新的栈帧中
- 接着esp 再开辟栈空间
被调用的函数退出时:
- 首先将esp 找回
- 之后pop 栈中的ebp,使ebp 回到old ebp的位置(此时esp会指向 原调用者 下一条指令的地址)
- 此时再ret ,将下一条指令的地址pop到eip 中,也就能回到调用者的执行流
- 最后再让调用者清除之前调用函数时压入的参数(栈平衡)
main()函数执行完后也是一样的返回方式,会一层一层的返回,栈帧也会一层一层的清除掉,知道最后退出程序。
概念图与实际进程图对比:
测试代码:
#include <stdio.h>
int test1()
{
return 123;
}
void test2(int a1, int a2, int a3, int a4, int a5)
{
int a = a5;
int b = a4;
}
void __attribute__((__fastcall__)) test3(int a1, int a2, int a3, int a4, int a5)
{
int a = a5;
int b = a4;
}
void test4(int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8)
{
int a = 100;
int b = a8;
int c = a7;
int d = 2;
int e = 3;
}
int main()
{
int a = 10;
int b = test1();
test2(1, 2, 3, 4, a);
test3(1, 2, 3, 4, a);
test4(1, 2, 3, 4, 5, 6, 7, b);
return 0;
}
4、 函数调用约定
函数调用约定通常规定如下几方面内容:
- 函数参数的传递顺序和方式
最常见的参数传递方式是通过堆栈传递。主调函数将参数压入栈中,被调函数以相对于帧基指针的正偏移量来访问栈中的参数。对于有多个参数的函数,调用约定需规定主调函数将参数压栈的顺序(从左至右还是从右至左)。某些调用约定允许使用寄存器传参以提高性能。
- 栈的维护方式
主调函数将参数压栈后调用被调函数体,返回时需将被压栈的参数全部弹出,以便将栈恢复到调用前的状态。该清栈过程可由主调函数负责完成,也可由被调函数负责完成。
- 名字修饰(Name-mangling)策略
又称函数名修饰(Decorated Name)规则。编译器在链接时为区分不同函数,对函数名作不同修饰。
若函数之间的调用约定不匹配,可能会产生堆栈异常或链接错误等问题。因此,为了保证程序能正确执行,所有的函数调用均应遵守一致的调用约定。
常见调用约定
Windows下可直接在函数声明前添加关键字__stdcall、__cdecl或__fastcall等标识确定函数的调用方式,如 int __stdcall main()
Linux下可借用函数attribute 机制,如 int __attribute__((__stdcall__)) main()
5、使用函数调用栈来实现栈溢出
函数调用约定是一种规范,用于定义函数调用的方式和标准。它包括函数调用时参数的传递方式、栈帧的创建和销毁、返回值的处理等。函数调用约定并不能直接解决栈溢出问题,但可以在一定程度上减少栈溢出的可能性。
栈溢出通常是由于递归调用层次过深或函数内部使用大量的局部变量导致的。函数调用约定可以通过优化参数的传递方式,如使用寄存器传递参数,减少栈上的数据压栈操作,从而减少栈空间的使用。
此外,编译器也可以通过尾递归优化等技术来减少函数调用产生的栈空间消耗。
虽然函数调用约定可以在一定程度上减少栈溢出的可能性,但它并不能完全解决栈溢出问题。对于涉及大量递归或大量局部变量的场景,仍然需要注意栈空间的使用情况,避免发生栈溢出。在这种情况下,可能需要考虑使用动态内存分配或其他技术来替代栈空间的使用,以避免栈溢出问题。