文章目录
- 栈的诞生
- 函数调用约定(Calling Convention)
- cdecl: C语言默认,变参函数
- stdcall:Windows API、内核驱动
- fastcall:x64
- thiscall:C++
- nakedcall: 底层HOOK
- 栈帧(活动记录)
栈的诞生
bababala…开启时空之门。
1946年,艾伦·图灵为解决程序调用问题和返回提出了【栈】。说起来,这多亏了我俩。
我们穿越时空抵达因果,找到图灵商讨这个事情(程序调用问题),图灵很客气的请我们去了他的很小的实验室讨论。
一见图灵,真是眼前一亮。图灵,总穿着一身运动服,周末经常用长跑来放空自己,偶尔会约个人下棋。
图灵不仅是一个科学家,其实还是一位世界级的长跑运动员。他的马拉松最好成绩是2小时46分,只比1948年奥运会金牌的成绩慢11分钟。在一次国际田径赛的万米比赛中,他战胜了同年奥运会银牌得主理查兹。
总的来说,图灵,是个自我封闭、严于律己的瘦子。见到陌生人时,总是一副拘谨的样子。
程序调用问题上,我们以排队打饭的顺序为始,【先打先拿】考虑到这样未免粗俗,我们改说 "FIFO"原则,“First In Fist Out ”的缩写,先进先出的意思。
我们称这个为【队列】。
可是遭到图灵的反驳,他说,计算机程度不需要和人一样讲究公平,计算机要的是【效率】。
图灵:如果把每一个排队打饭的人比喻为每一个程序,在一个窗口打饭(操作系统)。首先,人是会自己一个一个的前进,打完饭就出去,可程序是死的!
如果额外再设计一个管理调度程序前进的程序那岂不是大大增加了计算机设计的复杂性 ?
心里打鼓,忽然灵机一动,其实不要 人(程序) 动 打饭窗口(操作系统) 动就好。
可是又遭到图灵的反驳。
图灵:若是这样,更加麻烦了。每一个打完饭后不还得离开吗,程序调用结束后,也要释放资源。倘若不释放空间满了,电脑很快就死机。
心里打鼓,想不出个所以然来。你用手肘推了推我悄悄的说,“栈”。计算机学过,忘了??
如同打了鸡血,刚想说。
可是图灵就出去了。
哎呀,计算机界改朝换代的事情我不能拉下啊,说不定以后说到栈想的都是我的名字。(实际不可能,根据成功第四定律:如果是我们和图灵一起合作提出了栈解决程序调用问题,功劳全部是图灵的,因为图灵业界闻名而我们默默无名。所以,这就是一个团队的功劳为什么会往往归咎在一个人身上。)
也许,图灵在思考中。只是站在门口,我俩都走了出来。
俩声传出,我知道…
图灵说,刚刚是你们先进去的实验室,我是最后进去;我出来的时候却是第一个出来,你们也是先进去的后出来。用你们的话来说就是先进后出。
恩,坐电梯的时候,就是这样。先进去的最后出来因为要给后来进去的人腾位置,出去只能等前面的人出去才能出去。
图灵:电梯,哦,早闻大名。美国人伊莱沙·格雷夫斯·奥的斯在1852年发明的。如果程序先进后出,这样就可以解决上面的俩个问题了。
图灵:你们想,一个程序按照先进后出的顺序放进去后在处理完之前并不需要移动,其次处理完的任务自动出栈,新进来的任务直接占有腾出来的位置,不存在空间被浪费的问题。
后来,这个想法用在了程序里的函数调用上。
函数调用约定(Calling Convention)
假如在 C/C++ 中,定义下面这样一个函数:
int func(int x,int y, int z)
传递实参给函数 func()就可以使用了。但,在系统中,函数调用中参数的传递却是一门学问。
因为在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机用栈来支持参数传递。
函数调用时,调用者依次把参数压栈,而后调用函数,函数被调用以后,在栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改栈,使栈恢复原状。
- 有时候是编译没有问题但是链接时候总是报告不存在
- 有时候是编译和链接都没有但是只要库中就会出现堆栈异常
其实大多是【调用约定(Calling Convention)】和【名修饰(Decorated Name)】引起的。
调用约定不仅决定了发生时参数入栈顺序,还决定了是由调用者者还是被调用者负责清除栈中参数还原堆栈约定。
一个程序由若干个函数组成,程序的执行实际上就是函数之间的相互调用。
#include <stdio.h>
#define f __func__
void funcA( ){
puts(f);
}
void funcB( ){
funcA(f);
puts(f);
}
int main( ){
funcB();
puts(f);
return 0;
}
main() 调用了 funcB(),funcB() 又调用了 funcA()。
- main() 调用 funcB(), main() 是调用方,funcB() 是被调用方;
- funcB() 调用 funcA(),funcB() 是调用方,funcA() 是被调用方。
函数的参数(实参)由调用方压入栈中供被调用方使用,它们之间要有一致的约定。
例如,参数是从左到右入栈还是从右到左入栈,如果双方理解不一致,被调用方使用参数时就会出错。
函数调用方和被调用方必须遵守同样的约定,即调用约定(Calling Convention)。
一个调用惯例一般规定以下两方面的内容:
- [函数参数的传递方式]:是通过栈传递还是通过寄存器传递;
- [函数参数的传递顺序]:当参数个数多于一个时,按照什么顺序把参数压入栈?
是从左到右入栈还是从右到左入栈;
- [参数弹出方式]:函数调用后,由谁来把栈恢复原状?
函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。
- [函数名修饰方式]:函数名在编译时会被修改,调用惯例可以决定如何修改函数名。
函数调用惯例在函数声明和函数定义时都可以指定,语法格式为:
返回值类型 调用惯例 函数名(函数参数)
e.g.
int __cdecl max(int m, int n); // __cdecl是C语言默认的调用约定,在平时编程中,我们并没有去指定调用约定,就使用默认的 __cdecl。
p.s.
__cdecl 并不是标准关键字,是在 VC/VS 下有效,但在 GCC 下,要使用 __attribute__((cdecl))。 __attribute__ 是属性声明,告诉编译器此变量/函数需要检查或优化。
除了 cdecl,还有其他调用约定:
调用约定 | 参数传递方式 | 参数出栈方式 | 名字修饰(编译器重命名函数) |
cdecl | 从右到左的顺序入栈 | 调用方(caller) | _+function |
stdcall | 从右到左的顺序入栈 | 被调用方(callee) | _+function+@+参数的字节数 |
fastcall | 部分参数放入寄存器,剩下的参数按照从右到左的顺序入栈 | 被调用方(callee) | @+function+@+参数的字节数 |
pascal | 从左到右的顺序入栈 | 被调用方(callee) | \ |
cdecl: C语言默认,变参函数
cdecl调用约定又称为C调用约定,是C语言缺省的调用约定,它的定义语法是:
int func (int x ,int y)
等同于 int __cdecl func (int x,int y)
cdecl调用约定遵循下面的规则:
- 参数入栈顺序:从右到左;
- 还原栈者:调用者修改栈;
- 函数名:前加下划线:_func,如,max() 的修饰名为 _max。
由于每次函数调用都要由编译器产生还原栈的代码,所以使用 __cdecl 方式编译的程序比使用 __stdcall 方式编译的程序要大很多。
但是 __cdecl 调用方式是由函数调用者负责清除栈中的函数参数,所以这种方式支持可变参数,比如 printf()和 Windows 的 API wsprintf()就是 __cdecl调用方式。
由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据第一个后者后续的明确的参数确定下来,就可以使用不定参数了。
因为 __cdecl 参数入栈顺序是从右到左,所以最后一个参数第一个入栈,处于栈底。
返回地址eip寄存器代表,下一条要执行的指令(会跳出当前被调用函数,进入到调用函数继续执行)。
返回地址eip寄存器入栈后,就是 ebp寄存器 入栈(VS下调试版本才有,发布版没有)。
esp寄存器是记录栈顶位置的,入栈一个,esp就往上走一格。
当eip入栈后,esp会往上移动一些格子,之间空余的就用来储存被调用函数里的局部变量。
如果这时,函数调用完要退出了。
esp是直接往下走的,到返回地址eip位置,接着去调用函数里继续执行。
函数出栈只是在增加 esp 寄存器的值,使它指向上一个数据,并没有销毁之前的数据。
所有说,局部变量在函数运行结束后立即被销毁其实是错误的,只是为了让初学者更容易理解,对局部变量的作用范围有一个清晰的认识。
栈上的数据只有在后续函数继续入栈时才能被覆盖掉,这就意味着,只要时机合适,在函数外部依然能够取得局部变量的值。
#include <stdio.h>
int *p;
void func(int m){
int a = m;
p = &a;
}
int main(){
int n;
func(10);
n = *p;
printf("n = %d\n", n); // n = 10
return 0;
}
在 func() 中,将局部变量 a 的地址赋给 p,在 main() 函数中调用 func(),函数刚刚调用结束,还没有其他函数入栈,局部变量 a 所在的内存没有被覆盖掉,所以通过语句n = *p, 能够取得它的值。
stdcall:Windows API、内核驱动
格式:
int __stdcall func(int x)
- 参数入栈规则:参数从右向左压入栈
- 还原栈者:被调用函数自身修改栈
- 函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。如 max(int),就是 max@_4
在微软 Windows 的 C/C++ 编译器中,常常用 Pascal宏 来声明这个调用约定,类似的宏还有 WINAPI 和 CALLBACK。
fastcall:x64
格式:
int __fastcall func(int x)
- 参数入栈顺序:函数的第一个和第二个参数通过ecx和edx寄存器传递,剩余参数从右到左入栈
- 还原栈者:被调用者修改栈
- 函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的大小,如max(int 4),就是 max@_4
以 fastcall 声明执行的函数,具有较快的执行速度,因为前俩个参数通过寄存器来进行传递的。
x64平台,还有一些扩展…
- 一个函数在调用时,前四个参数是从左至右依次存放于RCX、RDX、R8、R9寄存器里面,剩下的参数从右至左顺序入栈;栈的增长方向为从高地址到低地址。
- 浮点前4个参数传入XMM0、XMM1、XMM2 和 XMM3 中,其他参数传递到堆栈中。
- 调用者负责在栈上分配32字节的“shadow space”,用于存放那四个存放调用参数的寄存器的值(亦即前四个调用参数);小于64位(bit)的参数传递时高位并不填充零(例如只传递ecx),大于64位需要按照地址传递;
- 调用者负责栈平衡;
- 被调用函数的返回值是整数时,则返回值会被存放于RAX;浮点数返回在xmm0中
- RAX,RCX,RDX,R8,R9,R10,R11是“易挥发”的,不用特别保护(所谓保护就是使用前要push备份),其余寄存器需要保护。(x86下只有eax, ecx, edx是易挥发的)
- 栈需要16字节对齐,“call”指令会入栈一个8字节的返回值(注:即函数调用前原来的RIP指令寄存器的值),这样一来,栈就对不齐了(因为RCX、RDX、R8、R9四个寄存器刚好是32个字节,是16字节对齐的,现在多出来了8个字节)。所以,所有非叶子结点调用的函数,都必须调整栈RSP的地址为16n+8,来使栈对齐。比如sub rsp,28h
- 对于 R8~R15 寄存器,我们可以使用 r8, r8d, r8w, r8b 分别代表 r8 寄存器的64位、低32位、低16位和低8位。
thiscall:C++
thiscall 是 C++ 类成员函数缺省的调用约定,但它没有显示的声明形式。
因为在C++类中,成员函数调用还有一个 this 指针参数,因此必须特殊处理。
- 参数入栈:参数从右向左入栈
- this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入栈
- 栈恢复:对参数个数不定的,调用者清理栈,否则函数自己清理栈
nakedcall: 底层HOOK
这是一个不常用的调用约定,编译器不会给这种函数增加初始化和清理代码,也不能用return语句返回值,只能用插入汇编返回结果,因此它一般用于实模式驱动程序设计。
如果定义的约定和使用的约定不一致,结果就是:则将导致栈被破坏。
最常见的调用规约错误是:
- 函数原型声明和函数体定义不一致
- DLL导入函数时声明了不同的函数约定
栈帧(活动记录)
CPU中有三个寄存器,分别是eip、ebp和esp。
- eip永远指向代码区将要执行的下一条指令,它的管控方式有两种,一种是“顺序执行”,即程序执行完一条指令后自动指向下一条执行;另一种是跳转,也就是执行完一条跳转指令后跳转到指定的位置;
- ebp和esp用来管控栈空间,ebp指向栈底,esp指向栈顶。
所谓的活动记录,就是在程序执行的过程中函数调用时栈上的内容变化。 一个函数被调用,反映在栈上的与之相关的内容被称为一个帧,其中包含了参数,返回地址,老ebp值,局部变量,以及esp,ebp。
下图就是程序执行时的一个活动记录。 C语言的默认调用约定为cdecl。因此C语言的活动记录中,参数是从右往左依次入栈。之后是函数的返回地址入栈,接着是ebp入栈。
可以用上图来分析很多实际问题。比如,可以用ebp+8取得第一个参数,然后依次取得第二个,第三个,第N个参数。也可以通过ebp-N来获得栈中的局部变量。