从栈不平衡问题 理解 calling convention
最近在开发的过程中遇到了几个很诡异的问题,造成了栈不平衡从而导致程序崩溃。
经过几经排查发现是和调用规约(calling convention)相关的问题,特此分享出来。
首先,讲一下什么是调用规约。
函数调用规约,是指当一个函数被调用时,函数的参数会被传递给被调用的函数和返回值会被返回给调用函数。函数的调用规约就是描述参数是怎么传递和由谁平衡堆栈的,当然还有返回值。
名称 | 谁负责参数出栈 | 参数压栈顺序 |
Cdecl | Caller(调用者) | 从右往左 |
Pascal | Callee(被调用者) | 从左往右 |
Stdcall | Callee(被调用者) | 从右往左 |
Fastcall | Callee(被调用者) | 从右往左 |
Thiscall | Callee(被调用者) | 从右往左 |
下面,本文给出一个简短的代码示例:
1 #include<iostream> 2 3 typedef void(*funcPointer)(int); 4 5 void __stdcall testFunc(int i, int j) 6 { 7 std::cout << "i is:" << i << std::endl; 8 std::cout << "j is:" << j << std::endl; 9 return ; 10 } 11 12 void callFunc(void(*func)(int)) 13 { 14 func(1); 15 } 16 17 void main() 18 { 19 callFunc((funcPointer)testFunc); 20 getchar(); 21 }
在第12行代码定义的callFunc函数,它的参数是一个“返回值为void,参数为一个int型的函数指针”,并在内部调用这个函数指针传实参为1。
在地5行代码定义了函数testFunc,它的参数为两个int,同时为它定义了__stdcall的调用约定。
在main函数的19行中进行调用的时候,对testFunc使用了(funcPointer)进行强行类型转换,并将它传入callFunc作为实参进行调用。
在x86平台Debug版本运行这段程序的结果如下:
程序因为异常停在第19行了。
在X86平台Release版本运行这段程序的结果如下:
程序虽然也执行到结尾了,但是由于传参不正确,所以结果不对,而且也无法正常停机。
在X64平台Debug版本和Release版本运行这段程序的结果类似如下:
程序没有发生异常,顺利执行完毕,只是运行结果不正确。
为什么是这样一个结果呢?下面,本文就来细细讲解。
在计算机中有两个寄存器称为ebp和esp,它们分别称为基址指针寄存器和堆栈指针寄存器。
esp和ebp分别指向当前运行函数的栈顶和栈底。
由于callFunc是以cdecl的方式进行声明的,而testFunc是以stdcall的方式进行声明的。因此在callFunc调用testFunc时,调用者(caller)负责将参数push进栈。因为此时testFunc已经进行了强行类型转换,因此编译器认为它的输入参数即为1个int,所以在入栈时callFunc将1个int压入堆栈中,接着调用testFunc。当testFunc执行完毕之后,由于它是stdcall所以由被调用者(callee)即testFunc自身负责参数的pop退栈。而此时,由于testFunc函数本身只有2个int型参数,所以在出栈时即pop两个int,导致了栈不平衡问题的产生!(而且在执行完testFunc之后,由于callFunc是cdecl类型的所以它仍然会再进行退栈的操作)如下图所示。
此处,截取了实际程序的反汇编代码进行分析:
上图是testFunc的反汇编,为了使反汇编看起来没那么冗长作者将其中一些代码注释掉了。可以看到,最后在结束时进行了ret 8的操作,即向上退8(两个int的大小)。(此处可以看到stdcall声明的函数进行自行参数退栈的实现)
上图是callFunc的反汇编,可以看到在调用子函数结束之后它进行了esp+4的操作,即退栈1个int(因为栈的地址空间是从大向小增长的所以是加操作)。
而且最后在它ret时是没有跟参数的,代表cdecl的函数不进行自我参数退栈操作。
关于Debug和Release,X86和X64结果不一样的原因
①在Debug版本下,Visual Studio的编译器会自动在编译参数中加入/RTC,即Runtime Check。启用运行时错误检查。其中包括了:堆栈指针验证,该操作检测堆栈指针损坏。 调用约定不匹配可能导致堆栈指针损坏。 例如,使用函数指针调用 DLL 中作为 __stdcall 导出的函数,但将指向该函数的指针声明为 __cdecl。此时编译器会在每个函数的开始和结束处加入针对esp指针的检查。详见:MSDN_CL_编译参数_RTC。
因此在Debug版本下会报出上文所提到的异常。而在Release版本下,因为默认不进行太多检查即RTC被关闭,因此并不会出现弹出异常提示的情况。
Debug版反汇编代码如下:
可以从反汇编的代码中看到,在进入子函数之前先将esp的值保存在esi中,当执行完毕之后对比esi和现在的esp的值,即RTC。
②在X86版本下,在退栈时是以esp中的值为基址进行加减操作来进行的。而RTC又是对esp指针进行检查,因此此时会报出异常。
而在X64版本下,在退栈时是以ebp中的值为基址进行加减操作来进行的,RTC检查的是esp,毫不相关,所以不会抱任何异常。
诚然,这只是一个小“缺陷”,很多人认为不必在意。但是小小的问题也会在某一刻产生巨大的隐患,造成整个软件的崩溃。