65.9K
CodeProject 正在变化。 阅读更多。
Home

在 .NET 应用程序中使用非托管 C++ 库 (DLL)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.81/5 (41投票s)

2006 年 5 月 21 日

CPOL

11分钟阅读

viewsIcon

600799

downloadIcon

8005

一篇关于如何在没有源代码的情况下,使用预先构建的库 (DLL) 中导出的非托管 C++ 类文章。

目录

1. 引言

本文已修订。请查看 此处 获取更新。

重用非托管 C/C++ 库有很多原因;其中最重要的可能是您想使用用非托管 C/C++ 编写的现有工具、实用程序和类。它们可以是第三方工具或内部库。在选择重用非托管库的方法时,您通常有三种选择:

  1. IJW 或“它就行了”。这是 .NET Framework 为开发人员提供的最强大的功能之一。您只需在新 .NET 平台上重新编译旧代码。所需的更改很少或无需更改。但请记住;它仅适用于 C++ 语言。
  2. COM。COM 模型同时适用于非托管和托管环境。在 .NET 上执行 COM 调用非常直接。但是,如果您的非托管类不是 COM 兼容的,您可能不会重写所有旧代码来支持 COM。
  3. 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();
};

这两个类非常简单。但是,它们具有两个最重要的特征:

  1. 基类包含一个虚析构函数。
  2. 派生类重写了基类的虚方法。

为了演示调用顺序,我在每个方法中都插入了一个 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”后,我们得到:

Sample screenshot

序号段包含所有函数的名称。虽然它列出了 DLL 中的所有函数,但您应该根据头文件中的类定义来确定哪些函数是可访问的方法。变形名称到类成员的映射如下表所示:

C++ 修饰名称

类成员

注意

??0Vehicle@@QAE@ABV0@@Z

默认构造函数

编译器添加

??0Vehicle@@QAE@PAD@Z

Vehicle::Vehicle(char *)

??1Vehicle@@UAE@XZ

Vehicle::~Vehicle()

??4Vehicle@@QAEAAV0@ABV0@@Z

类默认结构

编译器添加

??_7Vehicle@@6B@

虚函数表 (VTB)

编译器添加

?GetId@Vehicle@@QBEPADXZ

Vehicle::GetId()

?Move@Vehicle@@UAEXXZ

Vehicle::Move()

??0Car@@QAE@ABV0@@Z

默认构造函数

编译器添加

??1Car@@UAE@XZ

Car::~Car()

??4Car@@QAEAAV0@ABV0@@Z

类默认结构

编译器添加

??_7Car@@6B@

虚函数表 (VTB)

编译器添加

?Move@Car@@UAEXXZ

Car::Move()

请注意,“名称修饰”的确切详细信息取决于编译器,并且可能因版本而异。有趣的是,如果您向 Win32 项目添加/删除/更改类成员,您会发现新 DLL 的构造函数或其他类成员的“修饰名称”可能不同。这是因为“修饰名称”包含有关类成员及其与类其余部分关系的所有信息。对此关系的任何更改都会反映在新 DLL 的“修饰名称”中。

总而言之,似乎在 NT 平台 (NT/2000/XP) 上由 VC++ 6.0 构建的非托管 DLL 可以与 .NET 应用程序配合使用。在撰写本文时,很难验证在旧版 Windows 上由旧版编译器构建的非托管 DLL 是否仍然可用。这更像是一个兼容性问题。

4. 执行平台调用

转到顶部

我导入了四个方法:构造函数、析构函数、GetIdMove,并将它们放入另一个名为“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);
    }
};

请注意以下几点:

  1. 仅导入导出的公共方法/成员。
  2. 不要导入编译器添加的成员。它们大多是内部成员,并且并非所有成员都可访问。
  3. 每个导入的函数都接受当前指针作为输入参数,以及原始输入参数。DLL 使用此指针通过修饰名称“@Vehicle”或“@Car”来正确调用函数,这就是 C++ 编译器在内部处理类的方式。
  4. 我手动添加了一个虚函数表或 VTB 来处理虚方法,以模拟 C++ 虚成员的内部处理。VTB 包含所有虚方法的功能指针。

您可能会注意到,我定义了两个额外的函数:VdtorVMove,分别用于调用其相应的导入。这实际上是对 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;
};

源代码中有几个地方值得注意:

  1. 不要将托管“VehicleWrap”从非托管“VehicleUnman”派生。非托管包装器仅提供原始类成员(包括数据和方法)的存储,而托管包装器处理类关系。更重要的是,您将非托管对象传递给 DLL,而不是托管对象。
  2. 在托管类中尽可能使用托管数据类型,特别是在输入/输出参数和返回值中。这不仅仅是良好的实践,更是必需的,因为其他 .NET 开发人员不必在应用程序级别进行非托管数据类型的封送。
  3. 将“CarWrap”从“VehicleWrap”派生,以恢复两个非托管类之间的原始继承关系。这样,我们就无需在托管类中手动处理继承。
  4. ~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 中导出的析构函数调用两次没有导致任何内存问题,我们首先分析非托管资源在哪里分配:

  1. 由您的非托管包装器创建。您有责任处理在非托管包装器内分配的所有资源。
  2. 由导入创建?不。当我们导入时,我们不会在 DLL 内部创建任何实例。相反,实例将在我们的托管程序集中创建,以非托管包装器(结构)作为所有导入函数和其他类数据的占位符。导出的函数由 DLL 在堆栈上分配。导入期间不执行动态分配,因为您不会在 DLL 中“new”任何对象。因此,导入本身不需要处理。
  3. 由 DLL 内部创建和处理。我们假设 DLL 已经妥善处理了这些问题;换句话说,DLL 是没有 bug 的。有一点需要注意。如果 DLL 析构函数包含处理任何其他资源的逻辑,多次调用它可能会导致 DLL 出现问题。

8. 结论

转到顶部

本教程提供了一种重用非托管 C++ 库的替代方法,特别是在需要直接从非托管 DLL 进行导入时。我演示了将非托管 C++ DLL 包装到 .NET 应用程序中使用的三个步骤:

  1. 从 DLL 中检索类成员数据。
  2. 导入所需的类方法。
  3. 将所有导入包装在一个托管类中。

本教程还表明,该方法的实现并非易事,主要原因在于您必须恢复非托管类之间的原始关系,例如继承、虚函数和多态性。托管 C++ 可以提供帮助,但在出现冲突时,您必须模拟一些 C++ 内部工作机制。在处理 C++ 内部机制时,您会发现虚函数表和函数指针很有用。

9. 修订历史

转到顶部

  • 2006 年 5 月 21 日:文章和源代码的首次修订。
  • 2006 年 5 月 28 日:修订并进行了以下更新:
    • 添加了章节:“7. 导入的资源处理”。
    • 在源代码中添加了注释。
    • 将非托管包装器从类更改为结构。

    感谢 **vmihalj** 提出了启发性的问题,促成了此次更新。

  • 2006 年 6 月 2 日:修订并添加了以下内容:
    • 在第 3 节中添加了多重继承讨论。
    • 在第 6 节中添加了“名称修饰”讨论。

    感谢 **lsanil** 提出了启发性的问题,促成了此次更新。

© . All rights reserved.