编写DLL已经变得很简单的了。Win32已经把编程模型做了极大的简化,应用程序向导和MFC库中有更多更好的支持。本章将告诉你如何用C++编写 DLL,并让客户程序调用DLL。你将会看到Wineew是如何将DLL映射到你的进程中的,你还将学习MFC库常规DLL与MFC库扩展DLL的区别。你将看到一个简单的DLL例子及一个更为复杂的DLL实现客户控制的例子。
DLL基础理论
在使用应用程序框架的DLL支持之前,你必须理解Win32如何将DLL集成到你的进程中。你可以阅读第10章来弄清进程和虚拟内存。记住进程是一个程序正在运行的一个实例,程序是从磁盘中的EXE文件开始的。
本质上,DLL就是磁盘上的一个文件(通常以DDL为扩展名),由一些将进程的一部分的全局数据、函数和资源组成。它被编译成载入一个预选的基址,如果那里没有别的DLL相冲突,这个文件就被映射到与你的进程相同的虚拟地址中。DLL具有许多exported(导出)函数,客户程序import (导入)这些函数。Windows在读取DLL的时候会匹配导入与导出。
注意:Win32 DLL允许像导出函数一样,导出全局变量。
在Win32中,每个进程都会得到一份属于自己的DLL的读/写全局变量的拷贝。如果需要在进程间共享内存,你需要使用内存映射文件或是声明一个共享数据段(shared data section ),这在Jeffrey Richter的《Advanced Windows》 (Microsoft Press, 1997)一书有介绍。DLL无论在什么时候需要堆内存,它都在客户进程的堆中进行分配。
导入与导出如何匹配
DLL包含了一个导出函数表。这些函数以它们的符号名和(可选的)序列号对外界标识。函数表也包含了DLL中的函数的地址。当客户程序第一次读取 DLL的时候,并不知道要调用的函数的地址,但它知道符号或是序列号。之后,动态链接进程建立一个表将客户的调用与DLL中的函数地址联系起来。如果你编辑并重建了DLL,你不需要重建你的客户程序,除非你修改了函数名或是参数表。
注意:在简化的世界里,你可能使一个EXE文件从一个或多个DLL中导入函数。在现实世界中,许多DLL调用别的DLL中的函数。这样,一个特殊的DLL可能同时具有导入和导出。动态链接进程可以处理交叉依赖。
在DLL代码中,你必须明确地声明你的导出函数:
__declspec(dllexport) int MyFunction(int n);
(另一种方法是将你的导出函数列在一个模块定义文件 [DEF]中,但这通常会造成一些麻烦。)在客户端,你需要声明相应的导入:
__declspec(dllimport) int MyFunction(int n);
如果你使用的是C++,编译器对MyFunction产生一个修饰名,而别的语言不能使用它。修饰名是一个长名字,是编译器基于类名、函数名和参数类型产生的。它们被列在工程的MAP文件中。如果你希望使用明文名字的MyFunction, 你就需要将声明写成以下的格式:
extern "C" __declspec(dllexport) int MyFunction(int n);
extern "C" __declspec(dllimport) int MyFunction(int n);
默认情况下,编译器使用__cdecl参数来传递约定,也就是说调用程序要负责从栈中弹出参数。一些客户语言可能要求使用__stdcall约定,以取代 Pascal调用约定,这意味着被调用函数负责弹出栈。因此,你可能需要在你的DLL导出声明中使用__stdcall修饰符。
仅仅有导入声明并不足以使客户连接到DLL上。客户工程必须为链接器指定导入库(LIB),并且客户程序必须实际包含对DLL中导入函数的至少一个调用。此调用语句必须在程序中的可执行到的路径中。
隐式连接与显式连接
之前的小节主要讲述的是隐式连接,这是你做为一个C++程序员有可能使用DLL的方式。在你编译一个DLL时,连接器产生一个伙伴导入 (companion import) LIB文件,它包含每个DLL的导出符号和(可选的)序列号,但不含代码。这个LIB文件是加入到客户程序的工程中的DLL的代理。当你编译(静态连接)客户程序时,导入的符号与LIB文件中的导出符号进行匹配,这些导入的符号(或序列号)捆绑入EXE文件。LIB文件也包含了DLL的文件名(不是它的完全路径),它会存放到EXE文件中。当客户程序启动时,Windows查找并载入DLL然后通过符号或序列号动态连接它。
显式连接更适合于解释语言,如VB,但你也可以在C++中使用。使用显式连接,你不需要使用导入文件,而是调用Win32的 LoadLibrary 函数,指定DLL的路径名作为参数。LoadLibrary返回一个HINSTANCE参数,你可以在调用GetProcAddress 时使用它。GetProcAddress将一个符号(或是序列号)转换成一个DLL内部的地址。假设你有一个DLL,它的一个导出函数是这样的:
extern "C" __declspec(dllexport) double SquareRoot(double d);
以下是一个客户程序的显示连接函数示例:
typedef double (SQRTPROC)(double);
HINSTANCE hInstance;
SQRTPROC* pFunction;
VERIFY(hInstance = ::LoadLibrary("c:\\winnt\\system32\\mydll.dll"));
VERIFY(pFunction = (SQRTPROC*)::GetProcAddress(hInstance, "SquareRoot"));
double d = (*pFunction)(81.0); // Call the DLL function
在隐式连接中,所有的DLL在客户程序装入的时候也被装入,但使用显式连接时,你可以决定什么时候DLL被装入和卸出。显式连接允许你决定在运行时装入那个DLL。比如,你可以建立一个英文件版的DLL和一个中文版的DLL,在用户选择了语言之后决定装入哪个。
符号连接与序列号连接
在Win16中,更有效的序列号连接是受欢迎的连接选项。在Win32中,符号连接的效率被改进了。Microsoft现在更建议使用符号连接而不是序列号连接。尽管如此,MFC库的DLL还是使用序列号连接。一个典型的MFC程序可能连接到许多MFC DLL中的函数。序列号连接允许程序的EXE文件更小,因为它不需要包含所要导入的长符号名。如果你使用序列号连接来编译自己的DLL,你必须在项目的 DEF文件中指定序列号,这个文件对于Win32环境下没有什么别的用处。如果你的导出是C++函数,你在DEF文件中必须使用修饰名(或是使用 extern "C"来声明你的函数)。以下是一个MFC库DEF文件的一小部分:
?ReadList@CRecentFileList@@UAEXXZ @ 5458 NONAME
?ReadNameDictFromStream@CPropertySection@@QAEHPAUIStream@@@Z @ 5459 NONAME
?ReadObject@CArchive@@QAEPAVCObject@@PBUCRuntimeClass@@@Z @ 5460 NONAME
?ReadString@CArchive@@QAEHAAVCString@@@Z @ 5461 NONAME
?ReadString@CArchive@@QAEPADPADI@Z @ 5462 NONAME
?ReadString@CInternetFile@@UAEHAAVCString@@@Z @ 5463 NONAME
?ReadString@CInternetFile@@UAEPADPADI@Z @ 5464 NONAME
在(@)符号后面的数字就是序列号。你现在是不是宁可使用符号连接了呢?呵呵。
DLL的入口点—DllMain
在默认情况下,连接器为你的DLL分配主入口点 _DllMainCRTStartup。当Windows载入DLL时,它调用这个函数,它首先调用全局对象的构造函数,然后调用全局函数 DllMain,这个函数你必须编写。不仅是在DLL注入进程时DllMain会被调用,在DLL卸出的时候也要用到。以下是一个DllMain函数的框架:
HINSTANCE g_hInstance;
extern "C" int APIENTRY
DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved)
{
if (dwReason == DLL_PROCESS_ATTACH)
{
TRACE0("EX22A.DLL Initialing!\n");
// Do initialization here
}
else if (dwReason == DLL_PROCESS_DETACH)
{
TRACE0("EX22A.DLL Terminating!\n");
// Do cleanup here
}
return 1; // ok
}
如果你不为你的DLL写一个DllMain函数,那么将会由运行时库产生一个什么也不做的DllMain版本。
DllMain函数在单个线程启动和结束时也会被调用,调用原因由dwReason 参数指出。Richter的书会告诉你所有你需要知道的有关这个复杂问题的东西。
实例句柄——装载资源
在一个进程中的每个DLL都有一个唯一的32位HINSTANCE 值。另外进程自己有一个HINSTANCE值.所有这些实例句柄都只在特定的进程中有效,它们代表DLL或是EXE的起始虚拟地址。在Win32中, HINSTANCE与HMODULE值是一样的类型是可互换使用的。进程(EXE)实例句柄几乎永远是0x400000,DLL的句柄默认载入到基址 0x10000000。如果你的程序有多个DLL,每个都有不同的HINSTANCE值,这可能是因为在编译时指定了不同的基址,或是因为载入器复制并重新分配了DLL代码。
实例句柄对于装载资源特别重要。Win32的FindResource函数有一个HINSTANCE参数。EXE和DLL可以各自有它们自己的资源。如果你想要一个DLL中的资源,你就指定DLL的实例句柄。如果你想要一个EXE中的资源,你就指定EXE的实例句柄 。
如何获得实例句柄?如果你想要EXE的句柄,调用Win32的 GetModuleHandle 函数(给一个NULL参数)。如果你要DLL的句柄,调用GetModuleHandle 函数(给出DLL名)。稍后你会看到MFC库有自己的方法通过查找队列中的各模块来读取资源。
客户程序如何寻找DLL
如果你使用LoadLibrary来显示的连接,就可以指定DLL的完全路径。如果你没有指定路径,或是你隐式连接,Windows将按以下的规则来定位DLL:
1. 包含EXE文件的目录
2. 进程的当前目录
3. Windows的系统目录
4. Windows目录
5. 在PATH环境变量中的目录
这里要注意的是:你建立一个DLL,将DLL文件复制到系统目录。然后你重编译了这个DLL,但忘了复制到系统目录下,这时你的应用程序还是读取旧的DLL。
DLL的调试
VC++ 使调试DLL变得很容易。只要从DLL工程中运行调试器。你第一次做的时候,调试器会询问客户EXE文件所在的路径。此后,每次你从调试器中"运行" DLL时,调试器读取EXE,但是EXE使用搜索队列来查找DLL。这就是说,你必须将DLL的路径设置在PATH环境变量中,或是将DLL拷贝到搜索队列中。