原生 DLL 入门 - 第 2 部分:导出函数





5.00/5 (8投票s)
导出 DLL 函数的介绍
引言
在我 上一篇文章 中,我解释了 DllMain
样板代码以及适用于原生 DLL 项目的各种限制。在这篇文章中,我将探讨导出函数的实际技术。
可以导出类以及变量,但出于我在上一篇文章中解释的原因,我们将仅限于导出普通的函数。
背景
如我之前提到的,DLL 是一个导出函数的库,可以由客户端应用程序或依赖于您的 DLL 的更高级别的 DLL 调用,就像您的 DLL 也会依赖于其他 DLL 一样,例如
kernel32.dll 或(例如)vcruntimexxx.dll。这就是“DLL Hell”(DLL 冲突)一词的由来,它是一个由 DLL 组成的混乱集合,这些 DLL 会根据环境变量和相对位置从未知的地方加载。
依赖项
在使用 DLL 项目时,拥有一个好的工具来查看这些依赖项非常重要。在过去,Visual Studio 会附带 depends.exe。如今,有一个更强大的工具名为 Dependencies。我将在本文中使用它。
左侧是一个分层树,列出了 DLL 的依赖项,这些依赖项的依赖项,依此类推。您可以选择仅查看 DLL 名称或完整路径。这对于评估依赖关系树并找出文件加载位置非常有用。底部列出了这些 DLL 的文件信息。
如果您在左侧窗格中选择一个 DLL,首先您会看到该 DLL 导入的函数,然后在下面是该 DLL 导出的函数。
调用函数
有无数的脚本语言和开发环境可以加载原生 DLL 并执行导出的函数。为了本文的目的,我制作了一个小的 Powershell 脚本供您使用。这样,您就不需要为本文准备您系统上没有的任何东西。
在 Powershell 中,您可以通过 C# 进行一个小小的变通来调用原生 DLL 函数。您基本上定义一个空的 C# 类,该类使用 DllImport
加载 DLL 并将函数映射到方法。
为了本文的目的,我将其设置为您可以将脚本放在与 DLL 相同的位置并从那里执行它。如果我们不显式指定 DLL 位置,它将按照常规搜索顺序查找。
Add-Type -TypeDefinition @"
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
public static class fun
{
[DllImport("$($(Get-Location).Path.Replace('\','\\'))\\DynamicLibrary.dll", CharSet=CharSet.Auto)]
public static extern int getAnswerToLife();
}
"@
[fun]::getAnswerToLife()
构建一个简单的 DLL
假设我们有一个有用的函数,并且我们希望将其呈现给尽可能多的客户端应用程序,我们决定构建一个 DLL 并在 Visual Studio 中启动一个 win32 C++ 项目。我们要导出的函数是这个
int getAnswerToLife(void)
{
return 42;
}
默认情况下,所有函数都保留在编译它们的模块内部。为了导出它,我们必须按如下方式更改声明
//DynamicLibrary.h
__declspec(dllexport) int getAnswerToLife(void);
//DynamicLibrary.cpp
__declspec(dllexport) int getAnswerToLife(void)
{
return 42;
}
__declspec
关键字用于向编译器提供语言本身无法提供的额外信息。如果我们编译并构建 DLL 项目,我们会得到一个导出此函数的 DLL。当然,我们的 DLL 的一个潜在用户群是其他 C++ 项目,它们需要将 DynamicLibrary.h 文件添加到它们的项目中。由于它们需要导入 DLL,因此我们不使用 __declspec
关键字。相反,我们这样做
//DynamicLibrary.h
#ifdef DYNAMICLIBRARY_EXPORTS
#define DYNAMICLIBRARY_API __declspec(dllexport)
#else
#define DYNAMICLIBRARY_API __declspec(dllimport)
#endif
DYNAMICLIBRARY_API int getAnswerToLife(void);
//DynamicLibrary.cpp
DYNAMICLIBRARY_API int getAnswerToLife(void)
{
return 42;
}
构建 DLL 时,将 DYNAMICLIBRARY_EXPORTS
添加到 C++ -> Preprocessor -> Preprocessor definitions
字段。这样,您的项目将导出函数,而包含头文件的客户端项目将导入它。
现在我们可以构建 DLL,在 DependenciesGui.exe 中打开它,并查看我们劳动成果。
不完全符合我们的预期。函数在那里,但名称前面有一个问号,并且末尾附加了一些奇怪的字符。如果我们右键单击函数并选择“Undecorate C++ name”(取消修饰 C++ 名称),我们会得到这个。
看起来更像了。那么这是怎么回事?
名称修饰
我们在 C++ 中构建了 DLL。C++ 有一个称为名称修饰(Name Decoration),也称为 名称改编(Name Mangling) 的概念。C++ 允许函数重载,这意味着您可以拥有多个具有不同参数列表的 getAnswerToLife
函数。为了让编译器知道它需要确切哪个函数,它需要一种区分它们的方法,这就是它将源名称改编成更具体和唯一的名称的原因。
问题是,除非客户端应用程序期望这样做,否则它无法调用该函数。例如,在 Powershell 中,您可以像这样调用原生 DLL 函数
Add-Type -TypeDefinition @"
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
public static class fun
{
[DllImport("$($(Get-Location).Path.Replace('\','\\'))\\DynamicLibrary.dll",
CharSet=CharSet.Auto)]
public static extern int getAnswerToLife();
}
"@
[fun]::getAnswerToLife()
然后会发生这种情况
原因是 Powershell 尝试查找名为 getAnswerToLife
的函数,但找不到。我们可以显式定义改编函数(mangled functions)的名称。DllImport
允许这样做。即便如此,使用改编名称显然不是用户友好的。
为了解决这个问题,我们必须告诉编译器使用 C 风格的链接,这会导致编译器跳过名称改编。
//DynamicLibrary.h
#ifdef DYNAMICLIBRARY_EXPORTS
#define DYNAMICLIBRARY_API __declspec(dllexport)
#else
#define DYNAMICLIBRARY_API __declspec(dllimport)
#endif
#ifdef __cplusplus
extern "C" {
#endif
DYNAMICLIBRARY_API int getAnswerToLife(void);
#ifdef __cplusplus
}
#endif
//DynamicLibrary.cpp
#ifdef __cplusplus
extern "C" {
#endif
// This is an example of an exported function.
DYNAMICLIBRARY_API int getAnswerToLife(void)
{
return 42;
}
#ifdef __cplusplus
}
#endif
如果我们编译并运行脚本,我们将得到预期的结果
如果我们现在查看 DLL 依赖项,我们会看到这个
如果您将其与前一章中的未修饰 C++ 名称进行比较,您会注意到一个细微的差别:没有函数参数列表,没有返回类型,也没有调用约定。这是因为这些信息不再编码在函数名称中。现在,函数名称仅仅是一个标签,而不是一个包含元信息的标签。
因此,C 链接也使得无法重载导出的函数,因为调用者使用名称改编来区分它们。
决定这是否是您想要的,当然是一个特定于项目的决定,您需要自己做出,但总的来说,这是使您的 DLL 更易于使用的途径。
调用约定
此时,我们已经完成了大部分工作。还有最后一件事需要涵盖。如果您回顾我们原始导出函数的“未修饰”C++ 名称的屏幕截图,您会看到 __cdecl
出现在返回类型和函数名称之间。
这是函数的调用约定。调用约定实际上是函数调用者和被调用函数之间关于如何将函数参数传递到堆栈、谁负责清理堆栈、在函数调用之间保留哪些寄存器等等的约定(协议)。
存在 许多不同的约定。每种不同的体系结构至少有一种。在 32 位 Windows 上,由于历史原因,有几种。最常见的两种是 __cdecl
(C 调用约定)和 __stdcall
(标准调用约定)。
就实际用途而言,它们之间没有太大区别,只是在 C 调用约定中,调用者需要负责在函数调用之间保留和恢复堆栈。由于调用者不知道被调用者使用哪些寄存器,因此它需要保留和恢复所有寄存器,这会导致可执行文件稍大。
在 32 位构建中,两种约定都使用,这很烦人,因为您需要了解您使用的每个外部接口。win32 子系统使用 __stdcall
,所以每当您传递函数指针(例如,在调度 APC 时)时,您都必须确保函数使用正确的调用约定,否则您的应用程序将崩溃。如果您的 DLL 的导出函数需要作为函数指针传递给另一个子系统(例如,如果它们用于传递给 win32 api),那么您必须相应地选择 __stdcall
。否则,差别不大。
在 64 位构建中,仅使用 1 种调用约定。您可以指定调用约定,但编译器会忽略它。
为了添加这个最后的声明部分,我们再次修改我们的声明
//DynamicLibrary.h
#ifdef DYNAMICLIBRARY_EXPORTS
#define DYNAMICLIBRARY_API __declspec(dllexport)
#else
#define DYNAMICLIBRARY_API __declspec(dllimport)
#endif
#define DYNAMICLIBRARY_CALLING __stdcall
#ifdef __cplusplus
extern "C" {
#endif
DYNAMICLIBRARY_API int DYNAMICLIBRARY_CALLING getAnswerToLife(void);
#ifdef __cplusplus
}
#endif
//DynamicLibrary.cpp
#ifdef __cplusplus
extern "C" {
#endif
// This is an example of an exported function.
DYNAMICLIBRARY_API int DYNAMICLIBRARY_CALLING getAnswerToLife(void)
{
return 42;
}
#ifdef __cplusplus
}
#endif
至此,我们已经完成了所有需要明确导出函数的工作。
模块定义文件
还有第二种导出函数的方法。这是通过使用 模块定义文件(也称为 DEF 文件)来完成的。DEF 文件可用于让链接器执行某些操作,例如导出函数或配置每个导出函数的序号(在导出函数表中的确切位置)。
如果您愿意,可以通过序号仅导出函数,而不是通过名称。这理论上可以节省内存/磁盘空间,因为现在函数名称未嵌入文件中。在一个有很多函数的 DLL 中,这可以产生可衡量的差异。但在实际操作中,这已经变得毫无意义。存储和内存以 GB 为单位。担心节省单个字节会带来比解决问题更多的问题,因为如果您仅使用序号,许多环境就无法调用导出的函数,因为它们需要名称。
DEF 文件还允许您进行一些函数名称别名的小技巧,并以另一个名称导出现有函数或更改序号。除非您必须为依赖于此类内容的极其旧的用途提供 DLL,否则我不建议任何人使用 DEF 文件。
您的头文件将不得不包含 dllimport
指令,并指定调用约定和定义命名约定。如果您不使用 dllexport
指令,您不会获得任何好处,但您必须在多个位置维护 DLL 导出相关信息,这增加了复杂性。
结论
通过这里描述的技术,您可以构建导出函数的 DLL。这些 DLL 可以被客户端应用程序使用。
为了本次讨论的目的,我将文章限制在实际导出函数的过程。在我的下一篇文章中,我将讨论关于 DLL 的分发、运行时、参数传递以及与此主题相关的重要问题。
历史
- 2024 年 1 月 25 日:初版
- 2024 年 3 月 3 日:修正了 dependencies 的错误链接。