DllExports - 常见问题和解决方案






4.87/5 (72投票s)
了解如何从 DLL 导出函数,并实现完全的语言独立性
引言
有很多关于如何导出 DLL 函数的指南,但不知何故,当你自己开始编写 DLL 时,会遇到各种问题。如果你用一种语言创建 DLL,并想从另一种语言使用它,你需要注意一些陷阱。我们将探讨其中的一些。
解决问题的关键在于真正理解正在发生什么。我将从一个简单的 C++ DLL 开始,然后引导大家了解一些最常见的问题,之后我们将修改 DLL 来修复这些问题。
背景
迟早,你会发现需要使用带有导出函数的 DLL。可能是使用第三方库,也可能是你自己向第三方暴露了一个 `public` API。
让我们回到几年前。回到 VB6 风靡的时代。VB6 是一个非常棒的语言,可以进行快速原型开发。你可以在短短几天内学会如何处理 GUI 代码。VB6 最初被编译成 Pcode,在运行时进行解释。后来,添加了将代码编译成 EXE 的可能性。
VB6 的速度相当慢,并且缺少无符号整数和位移等基本功能,我认为如果没有能够使用其他语言编写的 DLL,VB6 永远不会取得今天的成功。
当确实需要速度时,人们就会用 C 或 C++,或者任何其他语言编写 DLL。与使用 GDI 或 MFC 相比,用 VB6 创建 GUI 是一种享受。你可以拥有两者最好的特性。
VB6 如何调用 C++ DLL?嗯。让我们以一个汇编示例来计算两个存储在 AX 和 BX 寄存器中的数字之和。
int Add(int a, int b)
{
__asm
{
ADD AX, BX // AX = AX + BX
RET
}
}
要从 VB6 或其他语言调用它,唯一需要做的就是跳转到代码位置并设置 AX 和 BX。参数传递给函数的方式称为“调用约定”。一些约定通过堆栈传递参数,另一些通过寄存器,或两者的组合。VB6 使用一种称为 `__stdcall` 的调用约定。这也是标准的 win32 约定。参数从右到左压入堆栈,返回值存储在 AX 中,函数负责平衡堆栈。C 和 C++ 编译器默认使用 `__cdecl` 调用约定,它与 `__stdcall` 类似,但函数调用者负责平衡堆栈。为了让 VB6 能够调用导出的函数,我们需要将调用约定更改为 `__stdcall`。
我在这里使用 VB6 作为示例,但我建议始终使用 `__stdcall` 约定,以实现更强的语言独立性,并符合操作系统的约定。
创建具有最小存根的 DLL 项目
本文主要关注管理导出的名称和序号。我将非常简略,只是快速展示演示 DLL 是如何创建的。
选择一个新的 C++ Win32 控制台应用程序
将类型设置为“DLL”
DLLMain
不仅程序有 `Main` 函数,DLL 也有。在这里,你通常会分配和初始化资源,然后释放资源。下面的代码是自动生成的,我没有对其进行任何修改。
// dllmain.cpp : Defines the entry point for the DLL application.
#include "stdafx.h"
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;
}
__declspec(export)
要导出函数,你只需要做很少的事情。你只需要用 `__declspec(dllexport)` 来修饰它。我将把它与 `__stdcall` 属性结合起来,以改变调用约定。
__declspec(dllexport) int __stdcall Add(int a, int b)
{
return a + b;
}
编译时,会生成一个 DLL 和一个 `.lib` 文件。如果你希望 DLL 在运行时自动加载到你的应用程序中,你需要在链接应用程序时添加 `.lib` 文件。
从应用程序中,你需要通过项目设置或通过 pragma link 指令以编程方式添加对 `.lib` 文件的引用。我个人更喜欢 pragma 指令,因为项目设置是针对每个构建配置的,你必须记住更新所有配置。
#include "../DemoLib1/DemoLib1.h" // __declspec(dllimport) int __stdcall Add(int a, int b);
#ifdef _DEBUG
#pragma comment(lib, "../Debug/SimpleMath.lib")
#else
#pragma comment(lib, "../Release/SimpleMath.lib")
#endif
int main()
{
int result = Add(1,2);
}
这是通过 DLL 进行函数导出/导入的最基本形式,但我不会认为它是一个好的 DLL。这个 DLL 与其他编译器和语言的互操作性非常差。
迈向互操作性的一步
如果我们的目的是从 C、Pascal、VB6、C# 或其他语言使用 DLL,我们会感到失望。这个 DLL 只能从 C++ 使用。
让我们使用 `bindump.exe`,它是 Windows SDK 和 Visual Studio 的一部分,来看看为什么其他语言不喜欢这个 DLL。
bindump.exe /EXPORTS
名称 `Foo` 是 C++ 符号修饰的。类型信息已由 C++ 链接器编码到函数名称中。只要你的应用程序也用 C++ 编写,并且使用相同的编译器和链接器进行编译,这就可以了。C 语言的符号修饰方式与 C++ 不同。
记住 `extern "C"` 关键字可以使 C/C++ 相互操作。
extern "C"
{
__declspec(dllexport) int __stdcall Foo(int a, int b);
}
现在导出的名称不再是 C++ 修饰的了。
现在名称是“`_Foo@8`”。为什么名称是 `_Foo@8` 而不是 `Foo`?嗯,现在它是 C 语言修饰的。C 用一个下划线开头,后面跟着函数名,最后以参数的大小(字节)结尾来修饰函数名。我们传递了两个整数(4 字节)。所以总大小是 `8`。现在你可以从 C 和 C++ 使用 DLL,而不是像上一个例子中那样只从 C++ 使用。这是一个改进,但仍然不够通用。
调用约定是严格指定的,但修饰后的名称则不那么严格。有些语言根本不修饰名称。我们可能可以通过在 DLL 上运行 `dumpbin.exe` 并查找修饰后的名称来使用此方法,但该名称可能会在你更改编译器或添加/修改任何参数时发生变化。这是一个巨大的缺点。
仔细看,修饰后的名称 “`_Foo@8`” 重复了两次。`_Foo@8 = @ILT+235(_Foo@8)` 左边的那个是导出的名称,右边的那个是内部名称。此时,它们是相同的。在下一节中,我们将学习如何更改导出的名称。
退一步,前进两步 - 使用 .def 文件
`.def` 文件可以让你更精确地控制函数的导出方式。我不知道为什么有些编写 DLL 指南的人会忽略它们。我总是会添加一个。
将一个文件添加到你的项目中,最好是以 `.def` 结尾。文件名不重要。我将我的文件命名为 `exports.def`,因为我总是这样做。
`export` 文件应具有以下格式
LIBRARY DemoLib3
EXPORTS
Foo @1
进入项目的“属性”页面,并在“链接器输入”下添加它。
添加此文件将“解除 C++ 修饰/还原”导出的函数名称。
`.def` 文件还能做什么
从 DLL 导出的函数会被分配一个数字,并可选择性地分配一个名称。对于 `public` 函数,添加名称是有意义的。对于不打算公开使用的内部函数,你可以为它们分配一个数字,但省略名称。让我们看看 `USER32.dll` 的导出。
有 1062 个导出的函数,但只有 894 个命名函数。这意味着你只能通过序号访问它们。序号通常是从 1 开始的数字。但它也可以选择从不同的数字开始,例如 1502。
让我们自己创建一个 DLL。一个导出两个函数的 DLL。`public` 函数 `Foo`,按名称和序号导出。我们还将添加一个 `private` 函数 `Foo`,它只能通过序号访问。为了更有趣,我们将使序号从 1502 开始,并且在数字系列中留下一个空位,将 `Bar` 的序号设为 1505。
为了隐藏名称,我们将使用 `NONAME` 指令。
LIBRARY DemoLib4
EXPORTS
Foo @1502
Bar @1505 NONAME
为了访问它,你可以这样写
HMODULE hLoadedLibrary = LoadLibrary("DemoLib4.dll");
// Notice __stdcall on function pointer typedef
typedef int (__stdcall* BarFunc)(int, int);
BarFunc Bar = (BarFunc) GetProcAddress(hLoadedLibrary, (LPCSTR)1505);
int result = Bar(6,3);
printf("#1505(6,3) = %i\n", result);
FreeLibrary(hLoadedLibrary);
这看起来很奇怪,但你只需传递序号而不是导出的名称。
与 .NET 和其他语言的互操作性
在 `DemoAppManaged.exe` 项目中,我将导入并使用所有三个演示库
- `DemoLib1` 具有 C++ 修饰的函数名
- `DemoLib2` 具有 C 修饰的名称
- `DemoLib3` 完全没有修饰
.NET 看起来很灵活,它似乎也接受 C 修饰名称的“`Foo`”这个名称,但其他语言可能就没有那么宽容了。所以我的建议是导出未修饰/未装饰的名称。这也是 `win32` 库中所有函数导出方式。
VB6 互操作性
应要求,我也会提到与 VB6 的互操作性。
声明很简单,只需注意一些陷阱。下面你将看到 C++ 导出的函数,然后是 VB6 中的相同声明。
// extern is used in order to export it as a C function
// __stdcall to is used to conform to calling convention
extern "C"
{
__declspec(dllexport) int __stdcall Mul(int a, int b);
}
// VB6
Private Declare Function MyMul Lib "MoreMath.dll" Alias "Mul" _
(ByVal op1 As Long, ByVal op2 As Long) As Long
VB6 代码看起来非常像 .Net 中的 PInvoke 声明。你为函数定义一个名称“MyMul”,并同时指定 DLL 名称和真实的导出名称(称为别名)“Mul”。
难点在于如何声明参数。函数在 C++ 中声明为 Integer,但为什么我在 VB6 中没有使用 Integer 呢?原因很简单。这两种语言对 Integer 的定义不同。所以我们需要选择一个与其等效的数据类型。在 VB6 中,Integer 是 16 位,Long 是 32 位。在 C++ 中,一个普遍的规则是大小遵循 CPU 寄存器的大小。所以对于 x86 架构,Integer 是 32 位。
有关 VB6 声明的更多阅读,请参阅以下链接 Declare Statement
历史
- 2014 年 5 月 25 日 - 第一版
- 2014 年 6 月 17 日 - 第二版
- 2014 年 8 月 8 日 - 第三版