动态链接库

動態連結函式庫英語:,缩写为DLL)是微软公司Windows 系统中实现共享函数库概念的一种實作方式。这些函式庫函数的扩展名.DLL.OCX(包含ActiveX控制的函式庫)或者.DRV(舊式的系统驱动程序)。

所謂動態链接,就是把一些經常會共用的程式碼(靜態链接的OBJ程式庫)製作成DLL檔,當執行檔呼叫到DLL檔內的函數時,Windows作業系統才會把DLL檔載入記憶體內,DLL檔本身的結構就是可執行檔,當程式有需求時函數才進行链接。透過動態链接方式,記憶體浪費的情形將可大幅降低。靜態連結函式庫則是直接連結到執行檔。

DLL的文件格式与视窗EXE文件一样——也就是说,等同于32位视窗的可移植执行文件(PE)和16位视窗的New Executable(NE)。作为EXE格式,DLL可以包括原始碼数据资源的多种组合。

在更广泛的意义上说,任何同样檔案格式電腦檔案都可以称作资源DLL。这样的DLL的例子有扩展名为ICL图标函式庫、扩展名为FONFOT字型檔案。

背景

DLL的最初目的是节约应用程序所需的磁盘和内存空间。在一个传统的非共享函式庫中,一部分代码简单地附加到调用的程序上。如果两个程序调用同一个子程序,就会出现两份那段代码。相反,许多应用共享的代码能够切分到一个DLL中,在硬盘上存为一个文件,在内存中使用一个实例(instance)。DLL的广泛应用使得早期的视窗能够在紧张的内存条件下运行。

DLL提供了如模組化这样的共享函式庫的普通好处。模块化允许仅仅更改几个应用程序共享使用的一个DLL中的代码和数据而不需要更改应用程序自身。这种模块化的基本形式允许如Microsoft OfficeMicrosoft Visual Studio、甚至Microsoft Windows自身这样大的应用程序使用较为紧凑的补丁和服务包

模块化的另外一个好处是插件的通用接口使用。单个的接口允许旧的模块与新的模块一样能够与以前的应用程序运行时无缝地集成到一起,而不需要对应用程序本身作任何更改。这种动态扩展的思想在ActiveX中发挥到了极致。

尽管有这么多的优点,使用DLL也有一个缺点:DLL地獄,也就是几个应用程序在使用同一个共享DLL函式庫发生版本冲突。这样的冲突可以通过将不同版本的问题DLL放到应用程序所在的文件夹而不是放到系统文件夹来解决;但是,这样将抵消共享DLL节约的空间。目前,Microsoft .NET将解决DLL hell问题当作自己的目标,它允许同一个共享函式庫的不同版本并列共存(WinSxS)。由于现代的计算机有足够的磁盘空间和内存,这也可以作为一个合理的实现方法。

技术

内存管理

Win32中,DLL文件按照片段(sections)进行组织。每个片段有它自己的属性,如可写或是只读、可执行(代码)或者不可执行(数据)等等。这些section可分为两种,一个是与绝对地址寻址无关的,所以能被多进程公用;另一个是与绝对地址寻址有关的,这个就必须由每个进程有自己的副本专用。sections的这种二分类,在编译DLL时就已经由编译器、链接器给标注好了。所以在装入DLL时,装入器知道哪些sections在内存物理地址空间只需要有一份,供多个进程共用(映射到各个进程的内存逻辑地址空间,所以逻辑地址可以不同); 哪些sections必须是进程使用自己的专用副本。

也可在程序编译时通过编译选项/section 的S (Shared)属性,显式指定哪个节是跨进程共享的。[1]默认情况下,DLL的数据节都是写时复制(COW)。

具体说,DLL装入时需考虑下述情形:

  1. 局部变量——每个线程都有自己的,DLL内部的局部变量随所在函数被执行而在各自线程的调用栈上开辟存储空间。
  2. 全局变量
    1. DLL内部定义的全局变量
      1. const全局变量——放入const节中,但不是各个进程共享;因为进程加载DLL时会初始化只读全局变量的值,这个值由可能是依赖于所在的进程,如DLL的函数在该进程中的逻辑地址。
      2. 非const全局变量——放入各个进程各自专用的data节中。即DLL装入时各个进程复制一份自己专用的DLL的data节。但是,对于一个进程内的多个线程并发访问这种进程空间全局变量,仍然存在线程安全问题。例如,在一个COM的DLL加载入一个进程的空间后,该进程的多个线程可能会并发访问该COM库的COM对象。为此,Windows与COM引入了线程“套间”(apartment)技术。一个进程内,应用程序与加载的各个DLL分属于不同的Module,如果DLL使用所在Module的全局变量,例如动态链接MFC的regular dll在访问自己的MFC全局变量时,应该明确声明。
    2. 访问DLL以外定义的全局变量——使用间址技术,在DLL的data节中用一个指针数据类型的内存空间来保存一个外部全局变量的地址。
  3. 函数调用
    1. 调用DLL内部定义的函数。这不是问题。
    2. 调用DLL外部定义的函数。例如,DLL内部调用一个外部函数foo()。这个foo函数在进程1中可能实现为“四舍五入”,在进程2中实现为“下取整”。所以调用外部函数是各个进程私用的事情。解决办法是使用间址技术,在data节中用一个“函数指针”数据类型的内存空间来保存这种外部函数的入口地址。
  4. 跳转指令
    1. DLL内部跳转,不是问题
    2. 跳转到DLL外部,解决同上述3.2

DLL代码段通常被使用这个DLL的所有进程所共享。如果代码段所占据的物理内存被收回,它的内容就会被放弃,后面如果需要的话就直接从DLL文件重新加载。

与代码段不同,DLL的数据段通常是私有的;也就是说,每个使用DLL的进程都有自己的DLL数据副本。作为选择,数据段可以设置为共享,允许通过这个共享内存区域进行进程间通信。但是,因为用户权限不能应用到这个共享DLL内存,这将产生一个安全漏洞;也就是一个进程能够破坏共享数据,这将导致其它的共享进程异常。例如,一个使用访客账号的进程将可能通过这种方式破坏其它运行在特权账号的进程。这是在DLL中避免使用共享片段的一个重要原因。

当DLL被如UPX这样一个可执行的packer压缩时,它的所有代码段都标记为可以读写并且是非共享的。可以读写的代码段,类似于私有数据段,是每个进程私有的并且被页面文件备份。这样,压缩DLL将同时增加内存和磁盘空间消耗,所以共享DLL应当避免使用压缩DLL。

符号解析和绑定

DLL输出的每个函数都由一个数字序号唯一标识,也可以由可选的名字标识。同样,DLL引入的函数也可以由序号或者名字标识。对于内部函数来说,只输出序号的情形很常见。对于大多数视窗API函数来说名字是不同视窗版本之间保留不变的;序号有可能会发生变化。这样,我们不能根据序号引用视窗API函数。

按照序号引用函数并不一定比按照名字引用函数性能更好:DLL输出表是按照名字排列的,所以对半查找可以用来在在这个表中根据名字查找这个函数。另外一方面,只有线性查找才可以用于根据序号查找函数。

将一个可执行文件绑定到一个特定版本的DLL也是可能的,这也就是说,可以在编译时解析输入函数(imported functions)的地址。对于绑定的输入函数,连结工具保存了输入函数绑定的DLL的时间戳和校验和。在运行时Windows检查是否正在使用同样版本的函式庫,如果是的话,Windows将绕过处理输入函数;否则如果函式庫与绑定的函式庫不同,Windows将按照正常的方式处理输入函数。

绑定的可执行文件如果运行在与它们编译所用的环境一样,函数调用将会较快,如果是在一个不同的环境它们就等同于正常的调用,所以绑定输入函数没有任何的缺点。例如,所有的标准Windows应用程序都绑定到它们各自的Windows发布版本的系统DLL。将一个应用程序输入函数绑定到它的目的环境的好机会是在应用程序安装的过程。

运行时通知DLL进程/线程加载

进程/线程加载时,可以通过DllMain函数通知DLL相关信息,提供对应处理的机会。

BOOL WINAPI DLLMain(HINSTANCE hinstDLL,DWORD fdwReason,LPVOID fImpLoad)
{
  switch(fdwReason)
  { 
    case DLL_PROCESS_ATTACH:
        //当这个DLL第一次被映射到了这个进程的地址空间时。DLLMain函数的返回值为FALSE,说明DLL的初始化没有成功,系统就会终结整个进程,去掉所有文件映象,之后显示一个对话框告诉用户进程不能启动。
    break;
    case DLL_THREAD_ATTACH:
        //一个线程被创建,新创建的线程负责执行这次的DllMain函数。系统不会让进程已经存在的线程以DLL_THREAD_ATTACH的值来调用DllMain函数。主线程永远不会以DLL_THREAD_ATTACH的值来调用DllMain函数。系统是顺序调用DllMain函数的,一个线程执行完DllMain函数才会让另外一个线程执行DllMain函数。
    break;
    case DLL_THREAD_DETACH:
       //如果线程调用了ExitThread来结束线程(线程函数返回时,系统也会自动调用ExitThread)。线程调用了TerminateThread,系统就不会用值DLL_THREAD_DETACH来调用所有DLL的DllMain函数。
    break;
    case DLL_PROCESS_DETACH:
        //这个DLL从进程的地址空间中解除映射。如果进程的终结是因为调用了TerminateProcess,系统就不会用DLL_PROCESS_DETACH来调用DLL的DllMain函数。这就意味着DLL在进程结束前没有机会执行任何清理工作。
    break;
  }
  return(TRUE);
}

动态链接库搜索顺序

对于Windows,加载动态链接库时:

  • 如果内存中已经有同module名的DLL,除非是DLL redirection或manifest,否则直接就用内存中这个DLL而不再搜索。
  • 如果DLL名字属于当前Windows版本的Known DLL,则必须用Known DLL。清单见 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs.
  • 如果DLL有依赖DLL,操作系统按缺省标准规则根据module名字搜索依赖DLL。即使第一个DLL指定了全路径。

Windows Desktop应用程序的DLL标准搜索序:

  1. 应用程序所在目录;
  2. 系统目录。GetSystemDirectory函数返回该目录。
  3. 16比特系统目录;
  4. Windows目录。使用GetWindowsDirectory函数返回该目录。
  5. 当前(工作)目录;
  6. 环境变量PATH中列出的目录。

如果SafeDllSearchMode被禁止,则当前目录成为第二个被搜索的目录。

Windows Desktop应用程序的DLL替代搜索序。这包括两种情况:

  • LoadLibraryEx函数使用参数LOAD_WITH_ALTERED_SEARCH_PATH:LoadLibraryEx函数不是从应用程序所在目录开始,而是从参数lpFileName(即要加载的dll)所在目录开始搜索所有依赖DLL。
  • 用SetDllDirectory函数修改搜素序。

当所有DLL都完成搜索,要开始执行DLL的初始化时,替代搜索序结束,恢复标准搜索序。

运行时显式链接

对于一个进程,每個被加载的DLL在进程的PEB中有一個引用計數器,在当前进程中每多一次加载该计数器便加一。LoadLibraryFreeLibrary 指令影響每一個行程內含的計數器;動態連結則不影響。因此藉由呼叫 FreeLibrary 而从記憶體卸载一 DLL 是很重要的。一個行程可以從它自己的 VAS 註銷此計數器。该计数器变为0时,这个DLL从当前进程中卸载,但并不意味着会从物理内存中卸载,因为其它进程可能使用这个DLL。

DLL 文件能够在运行时使用 LoadLibrary(或者 LoadLibraryEx)API 函数进行显式调用,这个的过程微软简单地称为运行时动态调用。如果在上述API的参数中指明了库文件的全路径,则不再搜索这个库文件。API 函数GetProcAddress 查找具有某名称的输出函数、FreeLibrary卸载 DLL(实际上是引用计数器减一)。这些函数类似于 POSIX 标准 API 中的 dlopendlsym、和 dlclose

注意微软简单称为“运行时动态链接”的运行时隐式链接,如果不能找到链接的 DLL 文件,Windows 将提示一个错误消息并且调用应用程序失败(准确地说是不能创建进程)。应用程序开发人员不能通过编译链接来处理这种缺少 DLL 文件的隐式链接问题,而且在更改了实现后还必须重新编译链接整个程序——默认的增量链接模式会一直保留着那条缺少的函数引用,只有重新编译链接才能去掉。相反,虽然显式链接的代码量增多了,但开发人员有机会提供一个完善的出错处理机制。

运行时显式链接的过程在所有语言中都是相同的,因为它依赖于 Windows API 而不是语言结构。只要一种语言能够调用上述的 LoadLibrary 等函数,就能执行运行时显示链接。

Win16下的DLL

16位Windows,所有进程共用同一个内存地址空间。如果一个DLL被多个进程使用,它只被加载到内存中一次,只有一份数据节(data segment)。也就是说,DLL是系统全局而不是进程内的;进程不能得到DLL的一份独立的拷贝。每个DLL在内存中只有一份实例(instance)。[2]

如果一个EXE文件同时执行多次,那么在内存中只有该EXE的一套只读拷贝(如代码或资源),但这个EXE的每个进程都有自己的一套数据段,即有多份实例(instance)。实际上,进程的实例句柄就是data segment(存放进程的全局变量)的内存起始地址。

模块(module)是指一个硬盘文件,可被加载到内存中。模块句柄是一个数据结构,表示这个硬盘文件的各部分(section)出自哪里,是否已经加载到内存中。

所以进程只能用实例句柄标识,而不能用模块句柄标识。

Win32的进程使用自己专用的逻辑内存空间。进程的全局变量不再是跨进程边界可见的。实例句柄与模块句柄相同,都是指向模块加载后的内存基地址。这样规定实际上也兼容了Win16时实例句柄与模块句柄的含义。

资源加载

EXE和DLL都有其自己的资源(如对话框资源),而且这些资源的ID可能重复,默认使用EXE的资源。如果需要加载、使用DLL中的资源,需要通过DLL加载后的实例句柄(HINSTANCE)来找到DLL的资源。

应用程序进程本身及其调用的每个DLL模块都具有一个全局唯一的HINSTANCE句柄,它们代表了EXE或DLL模块在进程逻辑地址空间中的起始地址。进程本身的模块句柄一般为0x400000,而DLL模块默认加载地址为0x10000000。如果程序同时加载了多个DLL,则每个DLL模块都会有不同的HINSTANCE。

几种可行的办法:

方法1:

// in MFC DLL
void CDLL::ShowDlg(void)
{
       AFX_MANAGE_STATE(AfxGetStaticModuleState());
       CDialog dlg(IDD_DLL_DIALOG); //打开ID为2000的对话框
       dlg.DoModal();
}

AFX_MANAGE_STATE(AfxGetStaticModuleState());必须作为接口函数的第一条语句。功能是在栈上创建一个AFX_MAINTAIN_STATE2类的实例,利用其构造函数和析构函数对_afxThreadState.GetData()所指向内存存储块的模块状态(AFX_MODULE_STATE类型)设置现场(AfxGetModuleState函数返回的值)及恢复现场。

方法2:

// in MFC DLL
void CDLL::ShowDlg(void)
{
       HINSTANCE save_hInstance = AfxGetResourceHandle();    //当前资源句柄
       AfxSetResourceHandle(theApp.m_hInstance);             //设置为当前DLL的实例句柄所对应的资源句柄
       CDialog dlg(IDD_DLL_DIALOG);                          //打开指定ID的对话框
       dlg.DoModal();
       AfxSetResourceHandle(save_hInstance);
}

方法3:

// in EXE
void CEXE::OnButtonClick()
{
       HINSTANCE exe_hInstance = GetModuleHandle(NULL);
      HINSTANCE dll_hInstance = GetModuleHandle("SharedDll.dll");
      AfxSetResourceHandle(dll_hInstance); //切换状态
       ShowDlg();
      AfxSetResourceHandle(exe_hInstance); //恢复状态
}

编译器和语言考虑

Delphi

在源文件的开头使用关键词library而不是program,在文件的末尾输出函数使用exports排列。

Delphi不需要LIB文件以从DLL中输入函数。为了链接一个DLL,在函数声明中使用关键词external

微软 Visual Basic.Net 框架

在Visual Basic(VB)中只支持运行时链接;但是除了使用LoadLibraryGetProcAddress这两个API函数之外,允许使用输入函数的声明来引入DLL函数,如果找不到DLL文件,VB将产生一个运行时异常。开发人员可以捕获该异常并且进行适当的处理。

Visual Basic 6这样的较老的语言,只能调用__stdcall调用约定修饰的函数,并需要在声明DLL的输出函数时使用Alias,否则就会出现在DLL中找不到函数入口点的错误。例如:

Public Declare Function test2 Lib "PackingDLL.dll" Alias "_test2@4" (ByVal param As Integer) As Integer

也可以用def文件来定义dll输出函数的名字。[3]。另外需要注意,VB6调用的dll,如果还依赖其他的dll,那么这些间接依赖的dll必须在VB6程序的dll搜索路径上,否则会报无法找到(被直接依赖的)dll的错误。

为了兼容,Declare Function 的方式一直沿用到 VB.net。不过在 .net 中,平台调用提供了一种新的声明DLL中的输出函数方式,通过 System.Runtime.InteropServices.DllImportAttribute 提供。例如:

<DllImportAttribute("user32.dll", EntryPoint:="MessageBoxW", SetLastError:=True, CharSet:=CharSet.Unicode, ExactSpelling:=True, CallingConvention:=CallingConvention.StdCall)>
Public Function MessageBox(hWnd As Integer, lpText As String, lpCaption As String, uType As UInteger) As Integer
End Function

在同为 .net 语言的 C# 中,这是仅有的处理方式。当然托管 C/C++ 就不必受此限制,见下节。

CC++

微软Visual C++(MSVC)提供了许多标准C++的扩展,它允许直接在C++代码中将函数(类、数据变量)声明为输入或输出;这种做法已经被Windows平台上其他的C和C++编译器采纳,包括Windows平台上的GCC。这种扩展在函数声明前使用__declspec属性:

  • _declspec(dllexport)用于在DLL源文件中声明要输出的C++类、函数以及数据。
  • _declspec(dllimport)用于在外部程序声明由DLL输出的C++类、函数以及数据。很多情况下不使用__declspec(dllimport)也能正确编译代码。但使用__declspec(dllimport)使编译器可以生成更好的代码,因为它可以确定函数是否存在于DLL中,这使得在跨DLL边界的函数调用时编译器可以生成跳过间接寻址级别的代码。需要特别注意的是,必须使用__declspec(dllimport)才能导入DLL中输出的变量。[註 1]例如,DLL输出一个C++类,该类有一个静态变量,那么在外部文件使用这个类时必须用__declspec(dllimport)声明。

如果是遵从C命名规范(naming convention)的外部名字,它们必须在C++代码中声明为extern "C"以避免它们使用C++命名规范。如果使用dll的语言(如FortranVisual Basic 6)不能识别C命名规范,那么需要采取办法指出在DLL中输出函数的名字。也可以通过DEF文件来定义输出函数的名字与序号。[3]或者使用如下的链接指令来制定dll输出函数名字:

#pragma comment(linker, "/export:add=@add@8")

除了使用__declspec属性定义输入输出函数之外,它们也可以列在项目DEF文件的IMPORT或者EXPORTS部分。DEF文件不是由编译器进行处理,而是由链接器据此生成DLL文件中输出函数的名字与顺序(ordinal number),这样DEF文件就不是C/C++特有的,其它语言写的程序如果想要编译为DLL也可以使用DEF。

DLL的编译将生成DLLLIB两个文件。LIB文件被称为输入库(import library),在编译时为调用DLL的程序提供“桩”(stub)实际上是间接跳转到运行时载入的DLL的对应的函数上再继续执行。这种通过输入库来使用DLL的方式,在程序运行时启动进程时就会自动(隐式)加载所有用到的DLL。另一种使用DLL的方式是通过LoadLibrary(或者LoadLibraryEx) API函数进行显式加载DLL,用GetProcAddress API函数通过函数名称获取其加载后的内存地址、通过FreeLibrary卸载DLL。

DLL一般说来必须放在PATH环境变量、缺省系统路经或者是使用它的程序所在路径三个的一个之内。COM服务器DLL使用regsvr32.exe注册,它将DLL的路径和全局唯一身份(GUID)记录在注册表中。应用程序能够通过在注册表中查找GUID、找到它的路径从而使用这个DLL。

编程实例

创建DLL输出函数

下面的例子展示了与特定语言相关的从DLL输出符号表的方法。

Delphi

 library Example;
 
 // Function that adds two numbers
 function AddNumbers(a, b: Double): Double; cdecl;
 begin
     AddNumbers := a + b
 end;
 
 // Export this function
 exports
     AddNumbers;
 
 // DLL initialization code: no special handling needed
 begin
 end.

C 或 C++

 #include <windows.h>
 
 // Export this function
 extern "C" __declspec(dllexport) double AddNumbers(double a, double b);
 
 // DLL initialization function
 BOOL APIENTRY DllMain(HANDLE hModule, [[DWORD]] dwReason, LPVOID lpReserved)
 {
 	return TRUE;
 }
 
 // Function that adds two numbers
 double AddNumbers(double a, double b)
 {
 	return a + b;
 }

使用DLL输入

下面的例子展示了与特定语言相关的如何在编译时链接DLL输入符号表的方法。

Delphi
 program Example;
 {$APPTYPE CONSOLE}
 
 // Import function that adds two numbers
 function AddNumbers(a, b: Double): Double; cdecl; external 'Example.dll';
 
 var result: Double;
 begin
 result := AddNumbers(1, 2);
 Writeln('The result was: ', result)
 end.

C 或 C++

 #include <windows.h>
 #include <stdio.h>
 
 // Import function that adds two numbers
 extern "C" __declspec(dllimport) double AddNumbers(double a, double b);
 
 int main(int argc, char **argv)
 {
 	double result = AddNumbers(1, 2);
 	printf("The result was: %f\n", result);
 	return 0;
 }

运行时使用显式调用

下面的例子展示了如何使用不同语言特有的WIN32 API绑定进行运行时的调用和链接。

Microsoft Visual Basic

 Option Explicit
 Declare Function AddNumbers Lib "Example.dll" (ByVal a As Double, ByVal b As Double) As Double
 
 Sub Main()
     Dim Result As Double
     Result = AddNumbers(1, 2)
     Debug.Print "The result was: " & Result
 End Sub

C 或 C++

 #include <windows.h>
 #include <stdio.h>
 
 // DLL function signature
 typedef double (*importFunction)(double, double); 

 int main(int argc, char **argv)
 {
     importFunction addNumbers;
     double result;
 
     // Load DLL file
     HINSTANCE hinstLib = LoadLibrary("Example.dll");
     if (hinstLib == NULL) {
         printf("ERROR: unable to load DLL\n");
         return 1;
     }
 
     // Get function pointer
     addNumbers = (importFunction)GetProcAddress(hinstLib, "AddNumbers");
     if (addNumbers == NULL) {
         printf("ERROR: unable to find DLL function\n");
         return 1;
     }
 
     // Call function.
     result = addNumbers(1, 2);
 
     // Unload DLL file
     FreeLibrary(hinstLib);
 
     // Display result
     printf("The result was: %f\n", result);
 
     return 0;
 }

组件对象模型

组件对象模型(COM)将DLL概念扩充到了面向对象编程。对象能够从另外一个进程调用或者在另外一台机器上运行。COM对象有一个唯一的GUID并且能够实现强大的后台以简化如Visual Basic和ASP这样的GUI前台应用。它们也可以使用脚本语言编程。COM对象的创建和使用比DLL更为复杂。

参见

備註

  1. 如果不使用__declspec(dllimport),那么导入的动态链接库全局变量,诸如extern int dllGlobalVar;, 实际上是该变量的指针值。因此在使用它时,必须:printf("%d ", *(int*)dllGlobalVar); *(int*)dllGlobalVar = 1; 也可以使用GetProcAddress函数来显示导入一个DLL定义的全局输出变量,例如:int &my_int = *(int*)GetProcAddress(hInstLibrary, "DLLData");

外部链接

参考文献

    • Hart, Johnson. Windows System Programming Third Edition. Addison-Wesley, 2005. ISBN 0-321-25619-0
    • Rector, Brent et al. Win32 Programming. Addison-Wesley Developers Press, 1997. ISBN 0-201-63492-9.
    1. . [2019-11-27]. (原始内容存档于2020-08-20).
    2. . [2022-01-25]. (原始内容存档于2019-03-06).
    3. MSDN:Exporting from a DLL Using DEF Files
    This article is issued from Wikipedia. The text is licensed under Creative Commons - Attribution - Sharealike. Additional terms may apply for the media files.