在 .NET 应用程序中使用非托管 C++ 库 (DLL)
一篇关于如何在没有源代码的情况下,使用预先构建的库 (DLL) 中导出的非托管 C++ 类文章。
目录
1. 引言
本文已修订。请查看 此处 获取更新。
重用非托管 C/C++ 库有很多原因;其中最重要的可能是您想使用用非托管 C/C++ 编写的现有工具、实用程序和类。它们可以是第三方工具或内部库。在选择重用非托管库的方法时,您通常有三种选择:
- IJW 或“它就行了”。这是 .NET Framework 为开发人员提供的最强大的功能之一。您只需在新 .NET 平台上重新编译旧代码。所需的更改很少或无需更改。但请记住;它仅适用于 C++ 语言。
- COM。COM 模型同时适用于非托管和托管环境。在 .NET 上执行 COM 调用非常直接。但是,如果您的非托管类不是 COM 兼容的,您可能不会重写所有旧代码来支持 COM。
- P/Invoke 或平台调用。此机制允许您在属性级别导入类作为函数。基本上,您逐个将类方法导入为单个函数,就像处理 Win32 API 一样。
如果您的非托管 C++ 库不是 COM 兼容的,您可以在 IJW 和 P/Invoke 之间进行选择。此外,您也可以在导入实践中结合这两种方法。由于 IJW 需要 C++ 源代码,如果您没有源代码,P/Invoke 可能是唯一可用的选项。通过 [DllImport]
属性使用 Win32 API 是 .NET 开发中 P/Invoke 的典型示例。
本文将讨论如何使用从 DLL 导出的非托管 C++ 类。不需要非托管 C++ 库的源代码。特别是,我将演示如何将非托管类包装到托管类中,以便任何 .NET 应用程序都可以直接使用它们。我将采取一种实用的方法,尽可能省略理论讨论。本文提供的所有示例和源代码都非常简单,仅用于教学目的。为了使用文章中包含的源代码,您应该安装 Visual Studio 2005 和 .NET Framework 2.0。但是,封装技术在 VS 2003 和 .NET Framework 1.x 上保持不变。非托管 DLL 已在 Visual C++ 6.0 上编译,如果您不重新编译非托管源,则不需要它。
2. 示例非托管 C++ 库
以下片段是基类“Vehicle
”及其派生类“Car
”的定义。
// The following ifdef block is the standard way of creating macros which make exporting
// from a DLL simpler. All files within this DLL are compiled with the CPPWIN32DLL_EXPORTS
// symbol defined on the command line. this symbol should not be defined on any project
// that uses this DLL. This way any other project whose source files include this file see
// CPPWIN32DLL_API functions as being imported from a DLL, whereas this DLL sees symbols
// defined with this macro as being exported.
#ifdef CPPWIN32DLL_EXPORTS
#define CPPWIN32DLL_API __declspec(dllexport)
#else
#define CPPWIN32DLL_API __declspec(dllimport)
#endif
// This class is exported from the CppWin32Dll.dll
class CPPWIN32DLL_API Vehicle
{
public:
Vehicle(char* idx);
// Define the virtual destructor
virtual ~Vehicle();
char* GetId() const;
// Define a virtual method
virtual void Move();
protected:
char* id;
};
class CPPWIN32DLL_API Car : public Vehicle
{
public:
~Car();
// Override this virtual method
void Move();
};
这两个类非常简单。但是,它们具有两个最重要的特征:
- 基类包含一个虚析构函数。
- 派生类重写了基类的虚方法。
为了演示调用顺序,我在每个方法中都插入了一个 printf
语句。供您参考,“CppWin32Dll.cpp”的完整源代码如下:
#include "stdafx.h"
#include "CppWin32Dll.h"
BOOL APIENTRY DllMain( HANDLE 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;
};
// This is the constructor of a class that has been exported.
// see CppWin32Dll.h for the class definition
Vehicle::Vehicle(char* idx) : id(idx)
{
printf("Called Vehicle constructor with ID: %s\n", idx);
};
Vehicle::~Vehicle()
{
printf("Called Vehicle destructor\n");
};
char* Vehicle::GetId() const
{
printf("Called Vehicle::GetId()\n");
return id;
};
void Vehicle::Move()
{
printf("Called Vehicle::Move()\n");
};
Car::~Car()
{
printf("Called Car destructor\n");
};
void Car::Move()
{
printf("Called Car::Move()\n");
};
我已在 Visual C++ 6.0 上将这两个类构建成一个名为“CppWin32Dll.dll”的 Win32 DLL。我们之后的所有导入工作都将基于此 DLL 和头文件“CppWin32Dll.h”。此后我们不会再使用非托管源。
与所有非托管 DLL 一样,我们不能将“CppWin32Dll.dll”用作程序集/引用。虽然 P/Invoke 允许我们导入 DLL 导出的函数,但我们无法导入类。我们可以做的是导入类中的所有方法,并将它们包装到一个托管类中,然后任何 .NET 兼容语言(C++、C#、VB 或 J#)的 .NET 应用程序都可以使用它。
3. 从 DLL 中检索导出的信息
作为第一步,我们将从 DLL 中导入类方法。由于我们无法访问源代码,因此我们使用 Microsoft 的转储工具“dumpbin.exe”从 DLL 中检索每个函数的修饰名称。执行“dumpbin /exports CppWin32Dll.dll”后,我们得到:
序号段包含所有函数的名称。虽然它列出了 DLL 中的所有函数,但您应该根据头文件中的类定义来确定哪些函数是可访问的方法。变形名称到类成员的映射如下表所示:
C++ 修饰名称 |
类成员 |
注意 |
---|---|---|
??0Vehicle@@QAE@ABV0@@Z |
默认构造函数 |
编译器添加 |
??0Vehicle@@QAE@PAD@Z |
|
|
??1Vehicle@@UAE@XZ |
|
|
??4Vehicle@@QAEAAV0@ABV0@@Z |
类默认结构 |
编译器添加 |
??_7Vehicle@@6B@ |
虚函数表 (VTB) |
编译器添加 |
?GetId@Vehicle@@QBEPADXZ |
|
|
?Move@Vehicle@@UAEXXZ |
|
|
??0Car@@QAE@ABV0@@Z |
默认构造函数 |
编译器添加 |
??1Car@@UAE@XZ |
|
|
??4Car@@QAEAAV0@ABV0@@Z |
类默认结构 |
编译器添加 |
??_7Car@@6B@ |
虚函数表 (VTB) |
编译器添加 |
?Move@Car@@UAEXXZ |
|
|
|
|
|
请注意,“名称修饰”的确切详细信息取决于编译器,并且可能因版本而异。有趣的是,如果您向 Win32 项目添加/删除/更改类成员,您会发现新 DLL 的构造函数或其他类成员的“修饰名称”可能不同。这是因为“修饰名称”包含有关类成员及其与类其余部分关系的所有信息。对此关系的任何更改都会反映在新 DLL 的“修饰名称”中。
总而言之,似乎在 NT 平台 (NT/2000/XP) 上由 VC++ 6.0 构建的非托管 DLL 可以与 .NET 应用程序配合使用。在撰写本文时,很难验证在旧版 Windows 上由旧版编译器构建的非托管 DLL 是否仍然可用。这更像是一个兼容性问题。
4. 执行平台调用
我导入了四个方法:构造函数、析构函数、GetId
和 Move
,并将它们放入另一个名为“VehicleUnman
”的非托管类中。
/// Create a unmanaged wrapper structure as the placeholder for unmanaged class
/// members as exported by the DLL. This structure/class is not intended to be
/// instantiated by .NET applications directly.
public struct VehicleUnman
{
/// Define the virtual table for the wrapper
typedef struct
{
void (*dtor)(VehicleUnman*);
void (*Move)(VehicleUnman*);
} __VTB;
public:
char* id;
static __VTB *vtb;
/// Perform all required imports. Use "ThisCall" calling convention to import
/// functions as class methods of this object (not "StdCall"). Note that we
/// pass this pointer to the imports. Use the "decorated name" retrieved from
/// the DLL as the entry point.
[DllImport("CppWin32Dll.dll",
EntryPoint="??0Vehicle@@QAE@PAD@Z",
CallingConvention=CallingConvention::ThisCall)]
static void ctor(VehicleUnman*, char*);
[DllImport("CppWin32Dll.dll",
EntryPoint="??1Vehicle@@UAE@XZ",
CallingConvention=CallingConvention::ThisCall)]
static void dtor(VehicleUnman*);
[DllImport("CppWin32Dll.dll",
EntryPoint="?GetId@Vehicle@@QBEPADXZ",
CallingConvention=CallingConvention::ThisCall)]
static char* GetId(VehicleUnman*);
[DllImport("CppWin32Dll.dll",
EntryPoint="?Move@Vehicle@@UAEXXZ",
CallingConvention=CallingConvention::ThisCall)]
static void Move(VehicleUnman*);
/// Delegates of imported virtual methods for the virtual table.
/// This basically is hacking the limitation of function pointer (FP),
/// as FP requires function address at compile time.
static void Vdtor(VehicleUnman* w)
{
dtor(w);
}
static void VMove(VehicleUnman* w)
{
Move(w);
}
static void Ndtor(VehicleUnman* w)
{
///Do nothing
}
};
/// Create a unmanaged wrapper structure as the placeholder for unmanaged class
/// members as exported by the DLL. This structure/class is not intended to be
/// instantiated by .NET applications directly.
public struct CarUnman
{
/// Define the virtual table for the wrapper
typedef struct
{
void (*dtor)(CarUnman*);
void (*Move)(CarUnman*);
} __VTB;
public:
static __VTB *vtb;
/// Perform all required imports. Use "ThisCall" calling convention to import
/// functions as class methods of this object (not "StdCall"). Note that we
/// pass this pointer to the imports. Use the "decorated name" retrieved from
/// the DLL as the entry point.
[DllImport("CppWin32Dll.dll",
EntryPoint="??1Car@@UAE@XZ",
CallingConvention=CallingConvention::ThisCall)]
static void dtor(CarUnman*);
[DllImport("CppWin32Dll.dll",
EntryPoint="?Move@Car@@UAEXXZ",
CallingConvention=CallingConvention::ThisCall)]
static void Move(CarUnman*);
/// Delegates of imported virtual methods for the virtual table.
/// This basically is hacking the limitation of function pointer (FP),
/// as FP requires function address at compile time.
static void Vdtor(CarUnman* w)
{
dtor(w);
}
static void VMove(CarUnman* w)
{
Move(w);
}
};
请注意以下几点:
- 仅导入导出的公共方法/成员。
- 不要导入编译器添加的成员。它们大多是内部成员,并且并非所有成员都可访问。
- 每个导入的函数都接受当前指针作为输入参数,以及原始输入参数。DLL 使用此指针通过修饰名称“
@Vehicle
”或“@Car
”来正确调用函数,这就是 C++ 编译器在内部处理类的方式。 - 我手动添加了一个虚函数表或 VTB 来处理虚方法,以模拟 C++ 虚成员的内部处理。VTB 包含所有虚方法的功能指针。
您可能会注意到,我定义了两个额外的函数:Vdtor
和 VMove
,分别用于调用其相应的导入。这实际上是对 P/Invoke 中函数指针的一种 hack/patch。如我们所知,函数指针指向一个函数(的地址)。这里,它将指向一个导入,该导入在编译时没有地址。它仅通过运行时动态绑定来获取地址。这两个委托有助于延迟函数指针与实际函数之间的绑定。
请注意,源文件应包含静态 VTB 数据的初始化。
/// Unmanaged wrapper static data initialization
VehicleUnman::__VTB *VehicleUnman::vtb = new VehicleUnman::__VTB;
CarUnman::__VTB *CarUnman::vtb = new CarUnman::__VTB;
5. 将所有导入包装到托管类中
现在,我们准备编写一个新的托管 C++ 类,它将包含上面定义的每个非托管类的一个对象。源文件如下:
/// Managed wrapper class which will actually be used by .NET applications.
public ref class VehicleWrap
{
public:
/// User-defined managed wrapper constructor. It will perform a few tasks:
/// 1) Allocating memory for the unmanaged data
/// 2) Assign the v-table
/// 3) Marshall the parameters to and call the imported unmanaged class constructor
VehicleWrap(String ^str)
{
tv = new VehicleUnman();
VehicleUnman::vtb->dtor = VehicleUnman::Vdtor;
VehicleUnman::vtb->Move = VehicleUnman::VMove;
char* y = (char*)(void*)Marshal::StringToHGlobalAnsi(str);
VehicleUnman::ctor(tv, y);
}
/// Let the v-table handle virtual destructor
virtual ~VehicleWrap()
{
VehicleUnman::vtb->dtor(tv);
}
/// Let the v-table handle method overriding
String^ GetId()
{
char *str = VehicleUnman::GetId(tv);
String ^s = gcnew String(str);
return s;
}
virtual void Move()
{
VehicleUnman::vtb->Move(tv);
}
private:
VehicleUnman *tv;
};
/// Managed wrapper class which will actually be used by .NET applications.
public ref class CarWrap : public VehicleWrap
{
public:
/// User-defined managed wrapper constructor. It will perform two tasks:
/// 1) Allocating memory for the unmanaged data
/// 2) Assign the v-table
CarWrap(String ^str) : VehicleWrap(str)
{
tc = new CarUnman();
CarUnman::vtb->dtor = CarUnman::Vdtor;
CarUnman::vtb->Move = CarUnman::VMove;
}
/// Let the v-table handle virtual destructor
~CarWrap()
{
CarUnman::vtb->dtor(tc);
/// After the DLL code handled virtual destructor, manually turn off
/// the managed virtual destrctor capability.
VehicleUnman::vtb->dtor = VehicleUnman::Ndtor;
}
/// Let the v-table handle method overriding
virtual void Move () override
{
CarUnman::vtb->Move(tc);
}
private:
CarUnman *tc;
};
源代码中有几个地方值得注意:
- 不要将托管“
VehicleWrap
”从非托管“VehicleUnman
”派生。非托管包装器仅提供原始类成员(包括数据和方法)的存储,而托管包装器处理类关系。更重要的是,您将非托管对象传递给 DLL,而不是托管对象。 - 在托管类中尽可能使用托管数据类型,特别是在输入/输出参数和返回值中。这不仅仅是良好的实践,更是必需的,因为其他 .NET 开发人员不必在应用程序级别进行非托管数据类型的封送。
- 将“
CarWrap
”从“VehicleWrap
”派生,以恢复两个非托管类之间的原始继承关系。这样,我们就无需在托管类中手动处理继承。 - 在
~Car()
析构函数中,将“什么都不做”的函数分配给VehicleUnman::vtb->dtor
。这是一个 hack,用于缓解非托管 DLL 内部与托管类继承之间的冲突。我将在下一节详细讨论此问题。
现在,我们将所有类放入一个名为“CppManagedDll.dll”的 DLL 中。“VehicleWrap
”和“CarWrap
”是两个托管类,它们已准备好供 .NET 应用程序使用。为了测试“VehicleWrap
”和“CarWrap
”类,我创建了一个 .NET C++ CLR 控制台应用程序项目,其源代码如下:
// TestProgram.cpp : main project file.
#include "stdafx.h"
using namespace System;
using namespace CppManagedDll;
int main(array<System::String ^> ^args)
{
/// Create an instance of Car and cast it differently to test polymorphism
CarWrap ^car1 = gcnew CarWrap("12345");
String ^s = car1->GetId();
Console::WriteLine(L"GetId() returned: {0:s}", s);
car1->Move();
/// Delete instances to test virtual destructor
delete car1, s;
return 0;
}
6. 继承、多态性和虚析构函数
如前所述,我从“VehicleWrap
”派生了“CarWrap
”,以避免手动实现“Car
”和“Vehicle
”类之间的原始继承关系,假设 C++ DLL 分解了派生类之间的所有关系。事实证明并非如此。测试表明,它仅破坏了“Vehicle
”和“Car
”的两个 Move()
方法之间的绑定,但保留了虚析构函数的绑定。也就是说,每当从 DLL 外部调用 ~Car()
时,~Vehicle()
都会自动调用。这会对我们的托管类产生不利影响,因为 ~Vehicle()
会被调用两次,一次由托管类虚析构函数调用,另一次由 DLL 内部的原始析构函数调用。要测试这一点,您可以注释/取消注释 ~CarWrap()
中的以下行:
VehicleUnman::vtb->dtor = VehicleUnman::Ndtor;
这一行允许托管类使用自己的绑定,同时禁用 DLL 中通过 VTB 和函数指针实现的意外绑定!
运行“TestProgram.exe”后,我们会得到如下输出:
Called Vehicle constructor with ID: 12345
Called Vehicle::GetId()
GetId() returned: 12345
Called Car::Move()
Called Car destructor
Called Vehicle destructor
为了验证多态性,请修改 main 中的第二行:
VehicleWrap ^car1 = gcnew CarWrap("12345");
您将获得相同的输出。如果将该行更改为:
VehicleWrap ^car1 = gcnew VehicleWrap ("12345");
您将得到:
Called Vehicle constructor with ID: 12345
Called Vehicle::GetId()
GetId() returned: 12345
Called Vehicle::Move()
Called Vehicle destructor
如前所述,如果您注释掉 ~Car()
中的 VTB 赋值,DLL 中的“Vehicle
”析构函数将会被调用两次。
Called Vehicle constructor with ID: 12345
Called Vehicle::GetId()
GetId() returned: 12345
Called Car::Move()
Called Car destructor
Called Vehicle destructor
Called Vehicle destructor
令人惊讶的是,尽管两次调用同一个析构函数在逻辑上是不正确的,但并未导致任何崩溃。这怎么可能发生?这是因为导入时并未在 DLL 中创建任何对象。我们将在下一节中详细讨论这个问题。
现在,一切似乎都顺利进行。我们准备扩展到多重继承,这是另一个重要的非托管 C++ 规范。嗯,不完全是。此扩展不可行,不是因为我们无法模仿多重继承,而是因为托管 C++ 完全放弃了这种复杂概念。为了符合托管 C++ 标准,您应避免在 .NET 应用程序中使用旧的多重继承。
7. 导入的资源处理
要完全理解为什么对 DLL 中导出的析构函数调用两次没有导致任何内存问题,我们首先分析非托管资源在哪里分配:
- 由您的非托管包装器创建。您有责任处理在非托管包装器内分配的所有资源。
- 由导入创建?不。当我们导入时,我们不会在 DLL 内部创建任何实例。相反,实例将在我们的托管程序集中创建,以非托管包装器(结构)作为所有导入函数和其他类数据的占位符。导出的函数由 DLL 在堆栈上分配。导入期间不执行动态分配,因为您不会在 DLL 中“
new
”任何对象。因此,导入本身不需要处理。 - 由 DLL 内部创建和处理。我们假设 DLL 已经妥善处理了这些问题;换句话说,DLL 是没有 bug 的。有一点需要注意。如果 DLL 析构函数包含处理任何其他资源的逻辑,多次调用它可能会导致 DLL 出现问题。
8. 结论
本教程提供了一种重用非托管 C++ 库的替代方法,特别是在需要直接从非托管 DLL 进行导入时。我演示了将非托管 C++ DLL 包装到 .NET 应用程序中使用的三个步骤:
- 从 DLL 中检索类成员数据。
- 导入所需的类方法。
- 将所有导入包装在一个托管类中。
本教程还表明,该方法的实现并非易事,主要原因在于您必须恢复非托管类之间的原始关系,例如继承、虚函数和多态性。托管 C++ 可以提供帮助,但在出现冲突时,您必须模拟一些 C++ 内部工作机制。在处理 C++ 内部机制时,您会发现虚函数表和函数指针很有用。
9. 修订历史
- 2006 年 5 月 21 日:文章和源代码的首次修订。
- 2006 年 5 月 28 日:修订并进行了以下更新:
- 添加了章节:“7. 导入的资源处理”。
- 在源代码中添加了注释。
- 将非托管包装器从类更改为结构。
- 2006 年 6 月 2 日:修订并添加了以下内容:
- 在第 3 节中添加了多重继承讨论。
- 在第 6 节中添加了“名称修饰”讨论。
感谢 **vmihalj** 提出了启发性的问题,促成了此次更新。
感谢 **lsanil** 提出了启发性的问题,促成了此次更新。