混合 .NET 和原生代码






4.85/5 (48投票s)
通过 C++/CLI 网关,首次尝试混合 .NET 和原生代码。
引言
在许多企业中,保存过去的成果是一种指导方针;他们是对的!对于程序员来说,保存投资通常代表着数千个男人日的工作量。为什么要抛弃一个已被证明其价值的代码呢?
程序员可以采取的一种选择是逐步转向新技术。对于 .NET,解决方案是混合托管代码和原生代码。这种方法可以从顶向下(从 UI 到低级层)进行,也可以从底向上(从低级到 UI)进行。
本文档的目的是通过两个简单的例子,展示如何将这两种技术结合使用,以及需要避免的第一个“陷阱”或需要考虑的限制。
将介绍两种方法
- 如何从原生代码调用托管代码
- 如何从托管代码调用原生代码
本文无意涵盖混合环境的方方面面、陷阱和技巧。它专为混合 CLR 初学者提供“首次接触”体验。如需全面了解开发问题,我只能建议您阅读 Stephen Phraser 的书:“Pro Visual C++/CLI and the .NET 2.0 Platform”(Apress 出版社),特别是第三部分:“Unsafe/Unmanaged C++/CLI”。
从原生代码调用托管代码
本示例展示了原生代码(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
对象返回到原生字符串。它必须分解为几个步骤。
- 获取
System::String
对象。 - 使用
Marshal::StringToHGlobalAuto()
全局句柄。请注意,我们在这里使用的是“auto”版本,它会获取返回的 Unicode 字符串,并根据需要将其转换为 ANSI 字符串。 - 最后,获取句柄底层对象的指针。
我们在这里有三个步骤,而不是一个!
阅读关于 C++/CLI 的参考书籍,您会遇到其他特定的关键字,如 pin_ptr<>
和 internal_ptr<>
,它们允许您在短时间内获取对象的底层指针。有关详细信息,请参阅文档。
大混合
这个独立的示例展示了如何使用 CLR 构建一个带有 MFC 的原生控制台应用程序!除了从控制台应用程序初始化 MFC 的特殊性之外,本示例还使用了前面已经讨论过的概念。本示例仅出于“好玩”的目的。
结论(原生代码调用托管代码)
在原生代码中使用托管代码是最复杂的事情之一。这里展示的示例非常简单。尽管它很简单,但您已经看到了其中一些复杂的考虑因素。希望您在混合代码的实践中会遇到更多。
从托管代码调用原生代码
本示例展示了 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 可以通过两种方式使用:
- 使用包装器方法/属性。
- 将
[DllImport]
属性用作方法修饰符。
请注意,第二种方式只能用于函数。它不能用于变量导出。为了调用变量导出,开发人员必须使用第一种方式。
结论(托管代码调用原生代码)
虽然我们可以看到将原生代码导入托管代码比反之更简单,但请考虑编写“中间程序集”并非易事。
您必须确保投资确实比完全迁移代码要少。考虑重新设计一个完整的应用程序,将其视为 ISO 功能性重写为托管代码(C# 与 C++ 非常相似)可能比迁移更经济。此外,最终的应用程序架构通常更清晰。
历史
- 2009 年 4 月 6 日,星期一:文章发布;首次发布。
- 2014 年 4 月 5 日,星期六:修复了内存泄漏,迁移到 VS2013 和 .Net Framework 4.0,添加了 x64 目标。