原生 DLL 入门 - 第 1 部分:样板代码





5.00/5 (5投票s)
在本文中,我将解释如何创建 win32 风格的 DLL 以及各种重要的细节。
引言
这是关于构建原生 DLL 的系列文章的第一部分,原生 DLL 意指使用纯 C 或 C++ 构建的 DLL。在第一部分中,我将解释样板代码以及各种限制和注意事项。本文不会有很多代码。它主要是解释您在开始项目之前需要了解和理解的事项。
鉴于本文已经相当长,我决定将其拆分,因为解释构建 DLL 的实际过程和各种技术也可能很长,我不想把所有内容都放在一篇巨型文章中。
背景
作为一名软件开发人员,您很有可能在某个时候实现需要被其他程序调用的代码。这可能意味着软件算法,但同样可能您是硬件公司的开发人员,为第三方软件开发人员提供执行电压测量、打开阀门或打开跨维度门户的方法。
您可以通过多种方式实现这一点。有各种各样的技术,例如 .NET 组件库、ActiveX 对象、DCOM 等。所有这些方法都有其优点,但就可访问性而言,问题在于它们限制了可以使用它们的客户端应用程序的范围。.NET 库不能轻易被非 .NET 应用程序使用。DCOM 对象不能被所有脚本语言使用。ActiveX 和 DCOM 都需要类型库和安全配置等。
然而,有一种技术几乎可以被所有编程语言和脚本语言普遍使用,那就是导出函数调用的动态链接库 (DLL) 的概念。原因很简单:每种语言都需要能够与 Windows 平台 API 交互。这些 API 以导出函数的形式在各种 DLL 中提供,因此任何语言都需要支持该功能才能存在。
如果您提供 DLL 功能接口,那么围绕它包装 .NET 类库或 DCOM 对象也是微不足道的,从而为客户端应用程序提供全方位的接口技术。DLL 的概念相当简单。您有一个 C 或 C++ 项目,其中实现了一些导出的函数,这些函数被编译成扩展名为 .DLL 的二进制文件。然后,客户端应用程序可以加载此文件,并调用以前导出的任何函数。
根据客户端应用程序的制作方式,DLL 可以在客户端构建过程中作为依赖项添加(这意味着它是静态加载的,始终如此),或者客户端应用程序可以编程为在运行时在某个特定点手动加载 DLL(动态加载)。为什么选择一种方式而不是另一种方式将在后面解释。
DLLMain 函数
就像任何 Windows 可执行文件都有一个作为程序开始执行的入口点(通常是 main 或 winmain)的函数一样,DLL 也有一个主函数。此函数由 Windows DLL 加载程序在其生命周期中的不同点调用。
默认函数体如下所示
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
hModule
是一个句柄,用于向 Windows 子系统标识 DLL。lpReserved
在大多数情况下可以忽略,除了进程附加/分离期间,它用于指示 DLL 是静态加载/卸载,而 ul_reason_for_call
标识了在该特定时间执行 DllMain
函数的原因。
DLLMain 函数调用原因
在 DLL 的生命周期中,DllMain
函数将在特定线程的上下文中在不同时间点被调用。
DLL_PROCESS_ATTACH
和 DLL_PROCESS_DETACH
是 DLL 加载到应用程序内存空间和从应用程序内存空间卸载时的调用原因,发生在发生此操作时处于活动状态的线程中。
DLL_THREAD_ATTACH
是每当线程第一次使用 DLL 时的调用原因。如果多个线程使用 DLL,则 DllMain
函数将被 Loader
调用多次。DLL_THREAD_DETACH
为每个线程执行。请注意,如果 DllMain
因 DLL_PROCESS_...
原因而执行,则执行该操作的线程不会因 DLL_THREAD_...
原因而调用 DllMain
。
您可以使用该 switch
语句来实现一些变量的初始化,例如计数器、线程本地存储等。但您需要注意几个问题。
不可靠
第一个问题是,DllMain
函数不会为 DLL 加载时已经存在的线程执行。当 DLL 静态链接到客户端并且总是在任何线程创建之前加载时,这并不是问题。但如果它是动态加载的,则可能存在尚未完成初始化的线程。并且可能发生 DLL_THREAD_DETACH
情况在一个线程上执行,即使其初始 DLL_THREAD_ATTACH
情况未执行。
此外,DLL_PROCESS_DETACH
可能在与执行 DLL_PROCESS_ATTACH
的线程不同的线程上执行。如果发生这种情况,将为调用 DLL_PROCESS_ATTACH
的线程调用 DLL_THREAD_DETACH
,并且 DLL_PROCESS_DETACH
将在任何活动线程上执行。该线程将不会收到 DLL_THREAD_DETACH
调用。
当然,如果一个进程只是终止,则不会执行任何操作。
显然,您无法阻止进程终止,因此这并不是您需要担心的事情。如果线程级初始化或全局 DLL 初始化适用于您的用例,那么您应该担心它们。我将在后续文章中介绍。
加载顺序问题
DllMain
在加载和卸载模块的过程中被调用。在单个进程中,任何给定时间只能进行 1 次此类操作。
文档指出,永远不要依赖 DllMain
函数中除 DLL 本身或 kernel32.dll 之外的任何内容。Kernel32.dll 是唯一保证已存在于地址空间中的 DLL。这意味着您在 DLL 中执行的任何初始化或清理,在任何情况下都不能依赖位于另一个 DLL 中的代码。
例如,如果您的 DllMain
函数调用 User、Shell 和 COM 函数(或依赖此类函数的其他代码),这可能会导致调用尚未加载和初始化的 DLL,从而导致访问冲突。
加载程序锁定
锁定问题归结为:在任何特定的应用程序上下文中,DllMain
函数在加载程序锁定的保护下执行。因此,如果两个不同的线程同时尝试加载 DLL,系统将使用该锁定来保证在同一时间只能执行 1 个 DllMain
函数。
您不太可能以同时或递归地在多个线程中使用 LoadLibrary
的方式设计程序,但它可能会被隐式调用。
在我过去的职业生涯中,我曾经维护一个通过 ODBC 连接与数据库接口的库。我认为在 DLL_PROCESS_DETACH
发生时关闭所有打开的 ODBC 句柄是明智的。它在我的系统上运行良好并通过了所有测试。然而,我的一位同事报告了应用程序退出时挂起的问题。结果发现,他正在使用一个数据库,该数据库的 ODBC 驱动程序使用了一个特定的 DLL,该 DLL 在最后一个连接句柄关闭后立即在后台卸载。
该 DLL 的卸载将触发立即尝试获取加载程序锁定以保护对其 DllMain
函数的调用。但该锁定已在 DllMain
函数中持有,我在该函数中关闭 ODBC 句柄,从而导致死锁。
全局对象构造函数/析构函数
适用于 DllMain
的相同限制也适用于在全局作用域中执行的构造函数和析构函数,因为它们的执行与 DllMain
不同步。全局对象的构造和析构不应依赖于 kernel32.dll 以外的 DLL。
结论
虽然这确实很诱人,但除了简单的值初始化和初始化线程局部存储或同步对象之外,不要将 DllMain
用于任何其他目的。任何超出此范围的操作都可能导致难以诊断的问题。
DLL 设计限制
解决了这一部分之后,重要的是要注意还有一些其他限制需要考虑。
运行时/内存所有权
每个 C++ 或 C 项目都链接到一个运行时。该运行时负责许多事情,例如分配和释放内存。应用程序及其使用的 DLL 可以使用不同的语言或相同语言的不同版本构建。即使它们使用相同的开发工具构建,它们也可能使用相同运行时的不同版本(调试与发布)。
这意味着在某个模块(DLL 或 EXE)中 new
的一块内存,必须在该同一模块中 delete
。内存所有权归分配该模块的模块所有,否则将导致运行时异常。
如果需要在 DLL 中分配内存并由客户端清理,可以通过在 DLL 中导出专用的内存释放函数来实现,或者(如果您使用 win32 API 分配动态内存)记录客户端需要调用哪个 win32 函数来释放内存。
使用类/结构
C++ 没有二进制接口标准。每个编译器和编译器版本都可以自由地以自己的方式实现事物。这意味着您不能将指向对象的指针传递给客户端(或从客户端传递到 DLL)并期望解除引用该指针。不能保证两个模块对类如何映射到内存以及如何解除引用对象指针有相同的概念。
如果您将 struct
用作不使用成员函数、构造函数等的普通旧数据结构,则可以使用 struct
。这就是 win32 API 本身在许多函数调用中使用 struct
的原因。如果您确实使用 struct
,请务必明确指定并记录内存打包,以便客户端应用程序即使其默认打包参数不同也能正确使用 struct
。
错误处理
毫无疑问,在为客户端应用程序实现库时,报告错误的能力至关重要。遗憾的是,我们受制于相同的运行时限制。您不能使用 C++ 异常,因为没有这样做的二进制标准。即使这不是问题,如果您的客户端应用程序是 Powershell 脚本、LabVIEW 项目或 VB 应用程序,它们根本无法处理 C++ 异常。
结构化异常处理 (SEH) 也是如此。并非所有客户端应用程序都能使用它们,因此不建议这样做。
总而言之,唯一真正的选择是使用返回码。处理函数返回值的能力是普遍的。
所有这些限制的例外
前面的限制都解释了您不能/不应该做的事情。此规则有一个例外。如果您有一个庞大的代码库,有多个团队在上面工作,并且出于组织和管理原因将其拆分为不同的项目,那么所有内容都可以在相同的构建环境中编译和链接。
如果是这种情况,所有这些限制都可以忽略,因为您有组织保证编译器版本相同。这意味着特定于供应商的实现细节可以被认为是通用的,这意味着可以忽略这些限制。
当然,在一个模块中 new 内存而在另一个模块中 delete 内存不一定是明智的设计决策。但这使得跨模块边界使用类和对象成为可能。
关注点
总而言之,将代码放在不同模块中的现实对您可以做什么或不能做什么施加了大量的设计约束。上面的解释相当无聊,但最终重要的是要牢记它们。
下一篇文章将更有趣,并展示了各种编码技术和注意事项。
历史
- 2024 年 1 月 11 日:第一版