深入理解动态库
一、动态连接库的用途
动态连接库,dynamic-link libraries(DLL),是微软公司提供的一项软件技术。它实质上是包含了一些函数和数据的可执行模块,它可以被应用程序(.EXE)或其它DLL调用。这种技术有以下好处:共享资源、节省内存、支持多语种、可重复利用、便于大项目的开发等。这样说是不是有点老套,也是,教科书都有的嘛。咳,就当复习一下功课了....
下面说一下我的理解。
没有总结,就没有进步。这话好象听谁说过的。作为一种载体,用来对过去经验作个总结,动态库得天独厚。比方说你在以往的项目开发或编程中积累下了很多的经验、技巧、想法(?)和专业资料,而且它们在特定的领域很有价值。但是随着开发工具的发展、执行平台的升级,已往的这些经验、技巧和资料可能就会被丢弃。其实将它们作为对以前劳动成果的一种总结,汇集到特定的动态库中,不失为一种两全其
美的方法。由于动态库与编程语言无关,如此得到的资源可以得到更广泛地应用。作为一种长远考虑,资源的重复利用不但没有使以往的劳动浪费,而且使原来的劳动增值,使工作更有效。尤其是资源的重复利用问题,如果系统地考虑软件复用则是解决软件开发中重复劳动问题的一种方案,动态库则是一种途径和方法。以已有的工作为基础,充分利用过去应用系统开发中积累的知识和经验,将开发的重点集中于应用
的特有构成成分上,消除重复劳动,避免重新开发可能引入的错误,从而提高软件开发的效率和质量。
另外,作为混合编程的一种特例,动态库当仁不让。由于动态库与具体的编程语言无关,只要这种语言支持动态库技术,则这种语言就能拿来用,目的只有一个“取长补短”。各类编程语言的存在是由于它们各有所长。我们可以通过动态库将一个大的任务分割成一个个子任务,这些子任务可以分别由不同的语言来实现。
还有一个最成功的例子:微软的应用程序接口API。
二、动态连接库的有关约定
关于动态库输出函数的约定有两种:调用约定和名字修饰约定。
调用约定决定着函数参数传送时入栈和出栈的顺序,以及编译器用来识别函数名字的修饰约定。名字修饰约定随调用约定和编译种类(C或C++)的不同而变化。为了让不同的编程语言共享动态库带来的方便,函数输出时必须使用正确的调用约定,并且最好不带有任何由编译器生成的名字修饰。下面就以VC5和VB5为例,结合具体情况来说明如何实现这些要求。
(一)调用约定
VC++5.0支持的函数调用约定有多种,在这里仅讨论以下三种:__stdcall调用约定、C调用约定和__fastcall调用约定。
__stdcall调用约定相当于16位动态库中经常使用的PASCAL调用约定。在32位的VC++5.0中PASCAL调用约定不再被支持(实际上它已被定义为__stdcall。除了__pascal外,__fortran和__syscall也不被支持),取而代之的是__stdcall调用约定。两者实质上是一致的,即函数的参数自右向左通过栈传递,被调用的函数在返回前清理传送参数的内存栈,但不同的是函数名的修饰部分(关于函数名的修饰部分在后面将详细说明)。
C调用约定(即用__cdecl关键字说明)和__stdcall调用约定有所不同,虽然参数传送方面是一样的,但对于传送参数的内存栈却是由调用者来维护的(也正因为如此,实现可变参数的函数只能使用该调用约定),另外,在函数名修饰约定方面也有所不同。
__fastcall调用约定是“人”如其名,它的主要特点就是快,因为它是通过寄存器来传送参数的(实际上,它用ECX和EDX传送前两个双字或更小的参数,剩下的参数仍旧自右向左压栈传送,被调用的函数在返回前清理传送参数的内存栈),在函数名修饰约定方面,它和前两者均不同。
关键字 __stdcall、__cdecl和__fastcall可以直接加在要输出的函数前,也可以在编译环境的Setting.../C/C++ /Code Generation项选择。当加在输出函数前的关键字与编译环境中的选择不同时,直接加在输出函数前的关键字有效。它们对应的命令行参数分别为/Gz、/Gd和/Gr。缺省状态为/Gd,即__cdecl。
顺便说明一下,要完全模仿PASCAL调用约定首先必须使用__stdcall调用约定,至于函数名修饰约定,可以通过其它方法模仿。还有一个值得一提的是WINAPI宏,Windows.h支持该宏,它可以将输出函数翻译成适当的调用约定,在WIN32中,它被定义为__stdcall。
建议:使用WINAPI宏,这样你就可以创建自己的APIs了。
(二)函数名修饰约定
函数名修饰约定随编译种类和调用约定的不同而不同,下面分别说明。
对于C编译,__stdcall调用约定在输出函数名前加上一个下划线前缀,后面加上一个“@”符号和其参数的字节数,格式为_functionname@number。__cdecl调用约定仅在输出函数名前加上一个下划线前缀,格式为_functionname。__fastcall调用约定在输出函数名前加上一个“@”符号,后面也是一个“@”符号和其参数的字节数,格式为@functionname@number。它们均不改变输出函数名中的自符大小写,这和PASCAL
调用约定不同,PASCAL约定输出的函数名无任何修饰且全部大写。说到这里,我给出一种完全模仿PASCAL调用约定的方法,在.DEF文件的EXPORTS段通过别名来实现。例如:
int __stdcall MyFunc (int a, double b);
void __stdcall InitCode (void);
在 .DEF 文件中:
EXPORTS
MYFUNC=_MyFunc@12
INITCODE=_InitCode@0
C++编译输出的函数名修饰较为复杂,VC++5.0的随机文档中也没有给出说明。经过一些实验和摸索,我发现了C++编译时函数名修饰约定规则,现在说明如下。
__stdcall调用约定:
1、以“?”标识函数名的开始,后跟函数名;
2、函数名后面以“@@YG”标识参数表的开始,后跟参数表;
3、参数表以代号表示:
X--void ,
D--char,
E--unsigned char,
F--short,
H--int,
I--unsigned int,
J--long,
K--unsigned long,
M--float,
N--double,
_N--bool,
....
PA--表示指针,后面的代号表明指针类型,如果相同类型的指针连续出 现,以“0”代替, 一个“0”代表一次重复;
4、参数表的第一项为该函数的返回值类型,其后依次为参数的数据类型;
5、参数表后以“@Z”标识整个名字的结束,如果该函数无参数,则以“Z”标识结束。
其格式为“?functionname@@YG*****@Z”或“?functionname@@YG*XZ”,例如
int Test1(char *var1,unsigned long)-----“?Test1@@YGHPADK@Z”
void Test2() -----“?Test2@@YGXXZ”
__cdecl调用约定:
规则同上面的_stdcall调用约定,只是参数表的开始标识由上面的“@@YG”变为“@@YA”。
__fastcall调用约定
规则同上面的_stdcall调用约定,只是参数表的开始标识由上面的“@@YG”变为“@@YI”。
(三)得到没有修饰的函数名
VC++输出函数时使用__declspec(dllexport),而不再用_export修饰字。
__declspec(dllexport)在C调用约定、C编译情况下可以去掉输出函数名的下划线前缀。extern "C"使得在C++中使用C编译方式成为可能,在一个C++文件中,用extern "C"来指明该函数使用C编译方式。例如,在一个C++文件中,有如下函数:
extern "C" {void __declspec(dllexport) __cdecl Test(int var);}
其输出函数名为:Test
为了方便,你可以使用下列预处理语句:
#if defined(__cplusplus)
extern "C"
{
#endif
//函数原型说明
#if defined(__cplusplus)
}
#endif
如此以来,经过上面的特殊处理,不管在C中,还是在C++中都可以得到一个无任何修饰的函数名了。
下面再介绍另一条途径:不用__declspec(dllexport)修饰字输出函数,而用.DEF文件来输出函数。将要输出的函数修饰名罗列在EXPORTS之下,这个名字必须与定义函数的名字完全一致,如此就得到一个没有任何修饰的函数名了。
至此,我们已有至少三种方法可以获得“没有任何修饰的函数名”了。
我在开始时就提到过“函数输出时....最好不带有任何由编译器生成的名字修饰”,这一点在多语种混合编程时尤其重要。
(四)实验
下面做一个实验来加深一下上面介绍内容的印象。
实验设想:有这样一个软件系统,用VB5设计它的界面,用VC5写一个动态库,用于执行一些繁琐的计算,在计算过程中有一些中间结果要作简单的显示,我们用VB5来完成显示任务,于是在VB5中定义了一个显示函数,由动态库来回调它,并且将计算结果作为回调时的参数....
首先用VB5编写界面并定义显示函数。新建一个工程,添加一个模块文件,在该模块文件中定义我们的显示函数(即回调函数):
Public Sub ShowResult(result As Long)
Form1.Print result '简单模拟一下显示而已
End Sub
另外,给出动态库输出函数的描述:
Declare Sub TestShow Lib "test32.dll" (ByVal Show As Long, Param As Any)
之后,在窗体上放一个命令按钮并添加如下代码:
Private Sub Command1_Click()
Dim i As Long
TestShow AddressOf ShowResult, i
End Sub
现在用VC5写我们的动态库。
新建一个项目。选择New Projects | Win32 Dynamic-Link Library,并输入项目名Test32;然后添加下面内容到.CPP文件:
#include <windows.h>
BOOL WINAPI DllEntryPoint( HINSTANCE hinstDll,DWORD fdwRreason,
LPVOID plvReserved)
{
return 1; // Indicate that the DLL was initialized successfully.
}
void TestShow(int AppShow(int*),int *flag)
{
for(int i=0;i<10;i++)
{
*flag=11011+i; file://为简单起见,这里用直接赋值替代“复杂计算”的结果
AppShow(flag); file://回调
}
}
这里使用.DEF文件输出函数。添加下列内容到.DEF文件:
LIBRARY TEST32
DESCRIPTION 'TEST32.DLL'
EXPORTS
TestShow @1
将调用约定设置为__stdcall,编译生成Test32.dll,将其拷入系统目录。
最后运行上面编写的VB5项目。OK?!
实验一:将调用约定改为缺省设置,即C调用约定,其它不变,重新编译生成Test32.dll并将其拷入系统目录,试运行VB5项目看看......
实验二:将调用约定改为缺省设置,即C调用约定,在上面的TestShow函数前加上__stdcall关键字或WINAPI宏,其它不变,重新编译生成Test32.dll并将其拷入系统目录,试运行VB5项目看看......
实验三:将调用约定改为缺省设置,即C调用约定,在上面的TestShow函数前加上__stdcall关键字或WINAPI宏,并且在其第一个参数AppShow前加上__stdcall关键字,其它不变,即
void __stdcall TestShow(int __stdcall AppShow(int*),int *flag)
重新编译生成Test32.dll并将其拷入系统目录,试运行VB5项目看看......
提示:VB5的函数调用遵循API调用约定(__stdcall,即原来的PASCAL)。
关于回调函数的概念和约定请参阅相关书籍。
三、参数传递
有关WIN32动态库的输出函数的参数传递上面也说了一些,这里主要再进一步详细说明。在32位动态库中,所有的参数都被扩展为32位(如字符型参数、短整型参数),自右向左反向入栈。函数的返回值也被扩展为32位,放在EAX寄存器中,8字节的返回值放在EDX:EAX寄存器对中,返回值为更大结构时使用EAX作为指向隐形返回结构的指针返回。当函数用到一些相关寄存器(如ESI, EDI, EBX和 EBP)时,编译器会自动生成一个函数头和一个函数尾,用于保存和恢复这些用到的寄存器。下面举例描述参数传递的情况。我们已经知道,__stdcall和__cdecl调用约定的参数传递是相同的,__fastcall调用约定和它们有所不同。
void MyFunc( char c, short s, int i, double f );
.
.
.
void MyFunc( char c, short s, int i, double f )
{
.
.
.
}
.
.
.
MyFunc ('a', 22, 8192, 2.1418);
其执行时参数传递情况将是这样的:
__stdcall和__cdecl调用约定
位置 栈
ESP+0x14 2.1418
ESP+0x10
ESP+0x0c 8192
ESP+0x08 22
ESP+0x04 a
ESP 返回值
__fastcall调用约定
位置 栈
ESP+0x0c 2.1418
ESP+0x08
ESP+0x04 8192
ESP 返回值
ECX a
EDX 22
四、栈
前面曾提到不同的调用约定在传送参数时对栈的不同处理。这里再重点说一下不同的调用约定是如何来维护栈的正常工作的,同时也更深刻地理解保持相同调用约定的重要性。我们已经知道,上面所提到的三种调用约定传送参数时都是自右至左压栈,这里的压栈的动作是由调用者来完成的。当调用开始,被调用者得到控制权,它可以对寄存器操作,而当调用结束,被调用者失去控制权,调用者重新得到控制权,此时它期望它所用到的某些寄存器恢复其调用前的状态,尤其是栈指针,这就牵涉到栈的维护问题。前面提到__stdcall和__fastcall调用约定均是被调用的函数在返回前清理传送参数的内存栈,而__cdecl调用约定是由调用者来维护用于传送参数的栈。下面举例来说明。
void MyFunc1(int c );
.
.
.
void MyFunc2( )
{
int i=1;
....
MyFunc1( i );
....
}
我们看一下MyFunc2的实现过程:
1、__stdcall和__fastcall调用约定
....
mov eax,dword ptr [i]
push eax
call @ILT+445(?MyFunc1@@YGXH@Z)(0x014a11bd)
//调用结束栈指针已恢复,由被调用者在返回前恢复
....
2、__ cdecl调用约定
....
mov eax,dword ptr [i]
push eax
call @ILT+30(?MyFunc1@@YAXH@Z)(0x014a101e)
//调用结束栈指针未恢复
add esp,4
//调用者自己恢复栈指针
....
现在再回过头来看一下前面设计的实验,由于VB5支持的是标准API调用约定,类同于__stdcall调用约定,所以当动态库用__stdcall调用约定编译时,实验正常通过。而当动态库用__cdecl调用约定编译时,实验一和实验二的现象能很好地说明问题,其实此时由于调用约定的不统一,用于传送参数的栈已遭到破坏,现象就是工作不正常。实验三中虽然仍用__cdecl编译,但在函数名前的__stdcall才是真正起作用的调用约定,故它也顺利通过。
五、总结与补充
上面结合实验描述了动态库技术的几个关键点:调用约定(或称调用协议)、名字修饰约定、堆栈与参数传递等。目的就是为了更深刻地理解该项技术,更好地在实际应用中使用该项技术。
另外需要补充的是关于输出函数名的问题。前面一再强调,函数输出时“最好不带有任何由编译器生成的名字修饰”,这一点是受限于编程语言中对函数命名的规则。VB虽然也有此规则,但它仍然可以通过别名使用带修饰的输出函数。VB使用动态库的语法:
Declare Sub name Lib "libname" Alias "aliasname" (arglist)
Declare Function name Lib "libname" Alias "aliasname" (arglist) As type
其中Alias(别名)可以作为一条使用带修饰字函数的途径。例如
int Test1(char *var1,unsigned long)-----“?Test1@@YGHPADK@Z”
这是在C++环境__stdcall调用约定下得到的一个输出函数,在VB中可以如此描述:
Declare Function Test Lib "test32.dll" Alias "?Test1@@YGHPADK@Z"
(var1 as Byte,Byval var2 as long) As Long
这样一来,在VB应用程序中就可以使用Test来实际调用动态库Test32.dll中的Test1了。我在实际应用中有时也这样使用动态库,带修饰的函数名虽然有些复杂古怪,但它本身能够表达更多的可用信息。