欢迎有条件的同学访问墙外的地址:http://lzsblog.appspot.com/%3Fp%3D280001
前些天搞dr.com的破解时有很多收获,一直想总结一下,以后也许有用,今天开个头,以后有时间再慢慢整理。
写过dll的同学都知道stdcall 函数和一般的C函数是不一样的。最直观的不同就是在声明时stdcall函数要加上宏WINAPI。研究过导出表的同学可能会对这个问题有更深入的理解,因为当你输出一个C函数时,通常会加上特定的名字修饰。比如_foo@4(这个修饰方式和编译器实现相关)。你必须强制使用正确的函数名以防止LoadLibrary函数无法访问你的C函数。
那么究竟什么是stdcall,为什么有stdcall和cdecl的区别,为什么cdecl(也就是一般的C函数)要加上如此奇怪的名字装饰呢。本文将从编译和执行的角度解释以上问题。阅读本文你需要一定的汇编基础。不太明白的同学请参看有关资料。
一下叙述默认使用IA32结构。汇编代码会使用AT&T风格描述。如果你熟悉Intel风格,其实你要做的只是去掉每个指令的修饰后缀(如movl 中的l),它代表操作数的大小,l表示long,也就是4Byte。另外你需要忽略寄存器前的%和立即数前的$。
1、过程(函数)调用的一般步骤
如果你不是使用不靠谱的国内汇编教材,你都会在学习汇编语言或计算机组成原理时了解过程调用的一般步骤。如果你已经熟悉过程调用的步骤,你可以直接跳到下一节。
就像没有灯光的时候,你女伴的长相不是需要关注的第一问题一样。在较底层的空间里徘徊时,我们通常不需要区别函数和过程。因为函数只不过是有返回值的过程,而在机器层面,返回不过是把需要返回的值赋给指定的寄存器而已。
典型的过程调用有以下几个步骤。
1、将参数放到指定的区域,对于我们今天讨论的问题,这一步骤相当于以指定方式将参数压栈。而对于fastcall,参数是通过寄存器传递的。这也解释了它名字的来历。
2、将记录当前程序运行位置的寄存器值(IA32中为EIP)压栈。跳转到被调用过程所在的内存区域。IA32提供call指令完成上述过程。虽说call看起来只有一个指令,可是它处理的问题有很多。首先,它会将%EIP压栈,这是用来返回的,因为当被调过程完成时需要知道如何回到主调过程。然后他会执行一个类似于jmp的指令跳转到指定程序的位置。
现在出现的一个问题是,当被调用过程开始执行时,栈顶指针和栈基址貌似与正常不符,一般情况下,一个过程应该能确定启动时自己的栈基址和栈顶指针相同。而对于被调用过程,这一假设通常由于局部变量的使用(他们位于栈中)而不成立。因此,一个过程开始时,会把栈基址压栈,然后将栈基址设为当前的栈顶指针。这就是为什么每个过程前面有类似pushl %ebp和mov %esp, %ebp
3、在返回之前,你需要处理一些善后的事宜,包括恢复系统栈的栈基址和栈顶指针。你可以使用leave指令来完成恢复栈基址的工作。
4、当过程完成时,从栈里弹出主调用过程停止处的地址。并跳转过去,使得主调用过程继续进行。IA32提供ret完成上述过程。有时你可以使用retn n来执行上述步骤,后面跟的n是在返回之后esp将会增加的值。
2、关键问题:谁来善后栈顶指针
刚才说过leave指针可以用来恢复栈基址。它的工作方式是先将栈顶指针设为栈基址,这里如前面所说,保存着主调进程的栈基址。然后将这个栈基址弹出,这时栈顶指针会指向返回地址,而栈基址会恢复到主调进程调用之前的位置。
好吧,如果你晕了,那你可能需要找一个汇编的书研究一下,而且这与我们所讨论的问题没什么关系。
那么,如何恢复栈顶指针呢?
栈顶指针之所以会改变,是由于在调用被调过程之前主调过程会通过压栈来传递参数(在被调过程中对于栈的改变,哪怕是调用另外一个过程,都会由于leave的调用而恢复)。那么如何将这些参数清空就是我们需要考虑的问题了。这也正是stdcall和cdecl的区别。
一个典型的stdcall过程调用汇编代码如下所示:
主调用过程 :
pushl (arg1) ;Step
1
pushl (arg2)
call foo ; Step
2
被调用过程:
pushl
%
ebp
movl
%
esp,
%
ebp
… ;Here's the body of procedure
leave ;Step
3
retn
8
;Step
3
and
4.
Here
8
is
the size of arguments
in
Byte.
刚才说过retn会才执行完成之后(也就是执行主调过程中call之后那个指令之前),对%esp进行减运算。减数就是它的参数。
嗯,大家应该明白了, stdcall中被调用过程将会自己处理%esp的善后问题。那么,这就很容易理解为什么大家都说stdcall不能传递可变长度的参数了。因为只有主调过程知道到底push了多少参数到栈里,所以被调函数无法进行清理操作。
那cdecl是什么各位应该明白了。是的,是在主调过程中恢复%esp。我们来看一下。
主调用过程:
pushl (arg2) ;Step
1
pushl (arg1)
call bar ; Step
2
addl
%
esp,
8
被调用过程:
pushl
%
ebp
movl
%
esp,
%
ebp
… ;Here's the body of procedure
leave ;Step
3
ret ;Step
4
看出来了,主调过程自己处理了参数所占的栈空间。细心的同学可能会发现参数是以倒序压入的。很多人说为什么要后面的参数的先push。这个问题我们可以这样看。
学过C语言的知道,函数一般用第一个参数指明究竟有多少参数被传递。比如main中的argc,printf中的format字符串等。因此第一个参数的地址必须容易确定。当参数全部push完成后,离被调函数的栈基址最近的应该是最后一个被push的参数。那么如果使用倒序的push顺序。第一个参数的位置很简单的可以确定为%ebp-4(有一个%eip也被压栈了,记得么)。这是的被调函数更容易编写。
3、关于修饰符
最开始我们讲到,用C没有加stdcall修饰时导出的函数名会被修饰。如void foo(int arg1, int arg2)会被修饰为_foo@8,现在我们知道了。因为 stdcall有一个固定的参数列表,如果参数数量不一样会造成参数的错传(cdecl因为是倒序输入,即使参数数量过多,只要顺序正确就不会有问题),所以当C函数 被导出成stdcall时会明确的加上指明参数大小的修饰符。因此@8就是说foo函数的参数共占了 8个字节。
转载请注明出处:http://lzsblog.appspot.com/%3Fp%3D280001