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

混合 .NET 和原生代码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (48投票s)

2009 年 4 月 6 日

CPOL

9分钟阅读

viewsIcon

168655

downloadIcon

7225

通过 C++/CLI 网关,首次尝试混合 .NET 和原生代码。

引言

在许多企业中,保存过去的成果是一种指导方针;他们是对的!对于程序员来说,保存投资通常代表着数千个男人日的工作量。为什么要抛弃一个已被证明其价值的代码呢?

程序员可以采取的一种选择是逐步转向新技术。对于 .NET,解决方案是混合托管代码和原生代码。这种方法可以从顶向下(从 UI 到低级层)进行,也可以从底向上(从低级到 UI)进行。

本文档的目的是通过两个简单的例子,展示如何将这两种技术结合使用,以及需要避免的第一个“陷阱”或需要考虑的限制。

将介绍两种方法

  1. 如何从原生代码调用托管代码
  2. 如何从托管代码调用原生代码

本文无意涵盖混合环境的方方面面、陷阱和技巧。它专为混合 CLR 初学者提供“首次接触”体验。如需全面了解开发问题,我只能建议您阅读 Stephen Phraser 的书:“Pro Visual C++/CLI and the .NET 2.0 Platform”(Apress 出版社),特别是第三部分:“Unsafe/Unmanaged C++/CLI”。

从原生代码调用托管代码

mixnetnative/Fig1_-_Native_calling_managed.png

本示例展示了原生代码(C++)如何使用用 C# 编写的托管类库,方法是使用一个中间“混合代码”DLL,该 DLL 导出一个使用托管代码的 API。

在某些情况下,这似乎有些繁重,但却是唯一的方法。

  • 如果原生客户端是用 Visual Studio 2005/2008 编译的,一些新的编译器选项允许更改原生模块如何使用托管代码,而中间 C++/CLI DLL 就不再需要了。例如,自 Visual Studio 2008 起,我们就有了“/clr”选项。
  • 如果原生客户端是用“旧版编译器”(即 Visual C++ 6)编译的,则之前的特定编译器选项不可用;应用程序设计者将不得不设计一个如上所示的中间模块。

纯原生客户端

这是控制台客户端的代码

#include "stdafx.h"
#include <iostream>
 
using namespace std;

 
#ifdef _UNICODE
   #define cout wcout
   #define cint wcin
#endif
 
 

int _tmain(int argc, TCHAR* argv[])
{
   UNREFERENCED_PARAMETER(argc);
   UNREFERENCED_PARAMETER(argv);
 
   SYSTEMTIME st = {0};
   const TCHAR* pszName = _T("John SMITH");

 
   st.wYear = 1975;
   st.wMonth = 8;
   st.wDay = 15;
 
   CPerson person(pszName, &st);
 
   cout << pszName << _T(" born ") 
        << person.get_BirthDateStr().c_str()
        << _T(" age is ") << person.get_Age() 
        << _T(" years old today.")
        << endl;
   cout << _T("Press ENTER to terminate...");
   cin.get();
 
#ifdef _DEBUG
   _CrtDumpMemoryLeaks();
#endif

   return 0;
}

这里没有什么特别的……这是经典的纯原生 C++ 代码。

它导入头文件和 LIB 文件(在用于预编译头文件的 StdAfx.h 文件中)。

纯托管程序集

这是一个用 C# 编写的经典程序集。

using System;
 
namespace AdR.Samples.NativeCallingCLR.ClrAssembly
{
   public class Person
   {
      private string _name;
      private DateTime _birthDate;
 
      public Person(string name, DateTime birthDate)
      {
         this._name = name;
         this._birthDate = birthDate;
      }

      public uint Age
      {
         get
         {
            DateTime now = DateTime.Now;
            int age = now.Year - this._birthDate.Year;

            if ((this._birthDate.Month > now.Month) ||
                ((this._birthDate.Month == now.Month) &&
                 (this._birthDate.Day > now.Day)))
            {
               --age;
            }

            return (uint)age;
         }
      }

      public string BirthDateStr
      {
         get
         {
            return this._birthDate.ToShortDateString();
         }
      }

      public DateTime BirthDate
      {
         get
         {
            return this._birthDate;
         }
      }
   }
}

正如您所见,这是纯 CLR。

混合原生/CLI 模块

所有困难都集中在这里。Visual Studio 环境提供了一组头文件,可以帮助开发人员连接两个世界。

#include <vcclr.h>

但是,事情并没有就此结束。我们将看到还有其他陷阱需要避免,尤其是在 CLR 和原生世界之间进行字符串封送时。

这是导出到纯原生模块的类头文件。

#pragma once

#ifdef NATIVEDLL_EXPORTS
   #define NATIVEDLL_API __declspec(dllexport)
#else
   #define NATIVEDLL_API __declspec(dllimport)
#endif

#include <string>

using namespace std;

#ifdef _UNICODE
   typedef wstring tstring;
#else
   typedef string tstring;
#endif


class NATIVEDLL_API CPerson
{
public:
   // Initialization
   CPerson(LPCTSTR pszName, const SYSTEMTIME* birthDate);
   virtual ~CPerson();

   // Accessors
   unsigned int get_Age() const;
   tstring get_BirthDateStr() const;
   SYSTEMTIME get_BirthDate() const;

private:
   // Embedded wrapper of an instance of a CLR class
   // Goal: completely hide CLR to pure unmanaged C/C++ code
   void* m_pPersonClr;
};

我们在这里付出了努力,向 CLR 环境的原生调用者展示了所有内容。例如,为了避免看到 vcclr.h 文件中导出了什么。这就是为什么我们使用 void 指针作为包装的 CLR 对象。这样,调用者就认为它是一个经典的 C++ 类。

打开一个奇异世界的大门……

正如我之前所说,事情始于包含 vcclr.h 文件。但是,由于我们将内部使用 CLR 代码并需要封送复杂类型(如字符串、数组等),因此以下是 .NET “头文件”。

using namespace System;
using namespace Runtime::InteropServices;
using namespace AdR::Samples::NativeCallingCLR::ClrAssembly;

当然,我们需要声明使用纯原生程序集。

首先,让我们看看构造函数。

CPerson::CPerson(LPCTSTR pszName, const SYSTEMTIME* birthDate)
{
   DateTime^ dateTime = gcnew DateTime((int)birthDate->wYear,
                                       (int)birthDate->wMonth,
                                       (int)birthDate->wDay);
   String^ str    = gcnew String(pszName);
   Person^ person = gcnew Person(str, *dateTime);
   // Managed type conversion into unmanaged pointer is not
   // allowed unless we use "gcroot<>" wrapper.
   gcroot<Person^> *pp = new gcroot<Person^>(person);
   this->m_pPersonClr = static_cast<void*>(pp);
} 

这个原生类允许存储托管类的引用指针,但这不是我们的目标,因为我们不想将托管代码展示给用户代码。

此外,由于我们使用 void 指针来隐藏对象,因此出现了一个新问题:我们不允许将托管类型转换为非托管指针。这就是为什么我们使用 gcroot<> 模板助手类。

还要注意我们如何用 ^ 字符书写托管对象的“指针”,这意味着我们正在使用托管类的“引用指针”。请记住,.NET 中的 class 对象在用作函数参数时被视为引用。

还要注意 .NET 分配的关键字:gcnew。这意味着我们正在垃圾回收器保护的环境中分配,而不是在进程堆中分配。

请注意,任何时候,进程堆与垃圾回收器保护的环境都是完全不同的。我们将看到需要进行封送任务,这对代码和性能有巨大影响。

与所有堆分配对象一样,在不再需要时,我们将不得不释放分配的内存;这在类析构函数中完成。

CPerson::~CPerson()
{
   if (this->m_pPersonClr)
   {
      // Get the CLR handle wrapper
      gcroot<Person^> *pp =  static_cast<gcroot<Person^>*>(this->m_pPersonClr);
      // Delete the wrapper; this will release the underlying CLR instance
      delete pp;
      // Set to null
      this->m_pPersonClr = 0;
   }
} 

我们在这里使用标准的 C++ 类型转换,通过 static_cast 关键字。对象的删除将释放底层包装的 CLR 对象,使其可以被垃圾回收。

提醒:声明析构函数会导致在编译时实现 IDisposable 接口及其 Dispose() 方法。

后果:不要忘记调用 Dispose() 或对这样的 CPerson 实例使用 C# 的 using 关键字。忘记这一点将导致严重的内存泄漏,因为 C++ 对象将不会被销毁(析构函数未被调用)。

调用简单的 CLR 类成员很容易,而且几乎相同。

unsigned int CPerson::get_Age() const
{
   if (this->m_pPersonClr != 0)
   {
      // Get the CLR handle wrapper
      gcroot<Person^> *pp = static_cast<gcroot<Person^>*>(this->m_pPersonClr);
      // Get the attribute
      return ((Person^)*pp)->Age;
   }

   return 0;
}

但是,当必须返回复杂类型时,事情就复杂得多,例如使用这个类成员。

tstring CPerson::get_BirthDateStr() const
{
   tstring strAge;
   if (this->m_pPersonClr != 0)
   {
      // Get the CLR handle wrapper
      gcroot<Person^> *pp = static_cast<gcroot<Person^>*>(this->m_pPersonClr);

      // Convert to std::string
      // Note:
      // - Marshaling is mandatory
      // - Do not forget to get the string pointer...
      strAge = (const TCHAR*)Marshal::StringToHGlobalAuto(
                         ((Person^)*pp)->BirthDateStr
                        ).ToPointer();
   }

   return strAge;
}

我们不能直接将 System::String 对象返回到原生字符串。它必须分解为几个步骤。

  1. 获取 System::String 对象。
  2. 使用 Marshal::StringToHGlobalAuto() 全局句柄。请注意,我们在这里使用的是“auto”版本,它会获取返回的 Unicode 字符串,并根据需要将其转换为 ANSI 字符串。
  3. 最后,获取句柄底层对象的指针。

我们在这里有三个步骤,而不是一个!

阅读关于 C++/CLI 的参考书籍,您会遇到其他特定的关键字,如 pin_ptr<>internal_ptr<>,它们允许您在短时间内获取对象的底层指针。有关详细信息,请参阅文档。

大混合

这个独立的示例展示了如何使用 CLR 构建一个带有 MFC 的原生控制台应用程序!除了从控制台应用程序初始化 MFC 的特殊性之外,本示例还使用了前面已经讨论过的概念。本示例仅出于“好玩”的目的。

结论(原生代码调用托管代码)

在原生代码中使用托管代码是最复杂的事情之一。这里展示的示例非常简单。尽管它很简单,但您已经看到了其中一些复杂的考虑因素。希望您在混合代码的实践中会遇到更多。

从托管代码调用原生代码

mixnetnative/Fig2_-_Managed_calling_native.png

本示例展示了 CLR 代码(C#)如何使用用 C++ 编写的原生类库,方法是使用一个中间“混合代码”DLL,该 DLL 导出一个使用非托管代码的 API。

如果 .NET 客户端是用 C++/CLI 编写的,它可以转换为调用纯原生 C++ 代码;但是,编写混合 C++/CLI 相当困难,这可能会是一次昂贵的体验。最小化中间混合 DLL 是集成原生代码的最快方法。

原生 C++ DLL

该 DLL 仅导出

  • 一个 C++ 类
  • 一个 C 风格函数
  • 一个 C 风格变量

本段介绍对象声明。由于它们尽可能简单,因此无需注释。

该模块被编译成一个常规 DLL,没有任何特定选项供 .NET 模块将来使用。

C++ 类

class NATIVEDLL_API CPerson {
public:
   // Initialization
   CPerson(LPCTSTR pszName, SYSTEMTIME birthDate);
   // Accessors
   unsigned int get_Age();

private:
   TCHAR m_sName[64];
   SYSTEMTIME m_birthDate;

   CPerson();
};

get_Age() 访问器仅计算当前日期和出生日期之间的时长。

导出的 C 函数

int fnNativeDLL(void);

导出的 C 变量

int nNativeDLL;

.NET 客户端

关于这个模块没有什么可说的。一切都很常规。

混合原生/托管 C++ DLL

艰苦的工作从这里开始……

注意 1

C++ .NET 类(托管)不能继承自原生 C++ 类。编写 C++ 托管类迫使我们在内部嵌入任何原生 C++ 对象的实例。此外,为了被其他托管代码使用,C++ 托管类不能使用非托管类型作为参数或属性。

注意 2

声明一个成员 CPerson _person2; 会生成 C4368 编译器错误(不能将 'member' 定义为托管 'type' 的成员:不支持混合类型)。

这就是为什么内部使用了指针(在 C# 中被视为 'unsafe')。

文档说了什么

您不能在 CLR 类型中嵌入原生数据成员。但是,您可以声明一个指向原生类型的指针,并在托管类的构造函数、析构函数和终结器中控制其生命周期(有关更多信息,请参阅 Visual C++ 中的析构函数和终结器)。

这就是为什么嵌入的对象是

CPerson* _pPerson;

CPerson person;

构造函数上的特殊信息

公共构造函数接受一个 System::String 字符串(托管类型)和一个 SYSTEMTIME 结构(Win32 API 类型,但仅为数值;封送显而易见)。

由于原生 C++ CPerson 构造函数接受一个 LPCTSTR 字符串指针,因此不能直接将托管字符串传递给非托管对象。

这是构造函数的代码。

SYSTEMTIME st = { (WORD)birthDate.Year,
                  (WORD)birthDate.Month,
                  (WORD)birthDate.DayOfWeek,
                  (WORD)birthDate.Day,
                  (WORD)birthDate.Hour,
                  (WORD)birthDate.Minute,
                  (WORD)birthDate.Second,
                  (WORD)birthDate.Millisecond };

// Pin 'name' memory before calling unmanaged code
pin_ptr<const TCHAR> psz = PtrToStringChars(name);

// Allocate the unmanaged object
_pPerson = new CPerson(psz, st);

注意 pin_ptr 关键字的使用,以保护字符串免受 CLR 操作的影响。

固定指针是一种内部指针,它阻止指针所指向的对象在垃圾回收堆中移动(固定指针的值不会被公共语言运行时改变)。当将托管类的地址传递给非托管函数时,这是必需的,因为在解析非托管函数调用期间,地址不会意外更改。

当固定指针超出范围或被设置为 nullptr 时,对象不再被固定。

C 风格 API

C 风格 API 可以通过两种方式使用:

  1. 使用包装器方法/属性。
  2. [DllImport] 属性用作方法修饰符。

请注意,第二种方式只能用于函数。它不能用于变量导出。为了调用变量导出,开发人员必须使用第一种方式。

结论(托管代码调用原生代码)

虽然我们可以看到将原生代码导入托管代码比反之更简单,但请考虑编写“中间程序集”并非易事。

您必须确保投资确实比完全迁移代码要少。考虑重新设计一个完整的应用程序,将其视为 ISO 功能性重写为托管代码(C# 与 C++ 非常相似)可能比迁移更经济。此外,最终的应用程序架构通常更清晰。

历史

  • 2009 年 4 月 6 日,星期一:文章发布;首次发布。
  • 2014 年 4 月 5 日,星期六:修复了内存泄漏,迁移到 VS2013 和 .Net Framework 4.0,添加了 x64 目标。
© . All rights reserved.