Run-Time Error Check Failure #0—The value of ESP was not properly saved across a function call. This is usually a result of calling a function declared with one calling convention with a function pointer declared with a different calling convention.
我如何解决这个问题?
我得到许多关于 .NET 框架和本机 C++ 之间的互操作问题,所以我不介意再次复习这个(well-covered)主题。有两条路可走:从 C++ 中调用框架;或者从框架调用 C++。我不打算在此涉及 COM 的互用性,我把它放在以后单独的一期专栏里讨论。
让我先从最简单的一种开始:从 C++ 调用框架。从 C++ 程序中调用框架最简单,最轻松的方法是使用托管扩展(Managed Extensions)。这是微软专用的 C++ 语言扩展,它被设计专门用来调用框架,只要包含两个头文件即可,然后象使用 C++ 类一样来使用它们。下面是一个非常简单的调用框架 Console 类的 C++ 程序:
#using <mscorlib.dll> #using <System.dll> // implied using namespace System; void main() { Console::WriteLine("Hello, world"); }
为了使用托管扩展,你只需引入 <mscorlib.dll> 和你打算使用的框架类所附着的程序集。不要忘了用 /clr 编译。
cl /clr hello.cpp
你的 C++ 代码可以或多或少地使用托管类,就像普通的 C++ 类一样。例如,你可以用操作符 new 创建框架对象,并用 C++ 指针语法存取它们,象下面这样:
DateTime d = DateTime::Now; String* s = String::Format("The date is {0}/n", d.ToString()); Console::WriteLine(s); Console::WriteLine(s->Length);
这里,String s 被声明为 String 指针,因为 String::Format 返回一个新的 String 对象。
“Hello,world”和日期/时间程序似乎很简单——它们确实简单——不过要记住不管你的程序多复杂,使用的类和 .NET 程序集有多少,其基本思路是一样的:用 <mscorlib.dll> 以及其它所需的程序集,然后用 new 创建托管对象,并使用指针语法来存取它们。
以上讨论的是如何从 C++ 调用框架。那么反过来从框架调用 C++ 该如何做呢?根据你是否想调用外部 C函数或 C++ 类成员函数,有两个选择。我们还是首先从最简单的开始:从 .NET 调用 C 函数。最轻松的方法是使用 P/Invoke。使用 P/Invoke,你将外部函数声明为某个类的静态成员,用 DLLImport 来指定外部 DLL 中的函数。在 C# 是这样做的:
public class Win32 { [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int MessageBox(IntPtr hWnd, String text, String caption, int type); }
这段代码告诉编译器 MessageBox 是 user32.dll 中的一个函数,参数是 IntPtr (HWND),两个 String 和一个 int。这样你便可以在 C# 程序中调用:
Win32.MessageBox(0, "Hello World", "Platform Invoke Sample", 0);
当然,使用 MessageBox 你不必通过 P/Invoke,因为 .NET 框架已经具备一个 MessageBox 类,但是大量的 API 函数框架是不直接支持的,调用这些函数时需要 P/Invoke。并且,你还可以用 P/Invoke 调用自己 DLL中输出的 C 函数。尽管在例子中我用的是 C#,但 P/Invoke 支持任何基于 .NET 的语言,如:Visual Basic .NET 或 JScript.NET。函数名称都相同,只是语法有差别。
注意我用 IntPtr 来声明 HWND。尽管使用 int 也可能行,但对于任何象 HWND,HANDLE 或 HDC 这样的句柄,你应该始终用 IntPtr,根据平台的不同,IntPtr 会默认为 32 位或 64 位,所以你根本不用担心句柄的大小。
DllImport 具备各种修饰符,你可以用来说明有关引入函数的细节。在上面的例子中,CharSet=CharSet.Auto 告诉框架根据目标操作系统的具体情况,将 String 作为 Unicode 或 Ansi 来传递。另一个鲜为人知的修饰符是 CallingConvention,回想一下在 C 语言中,我们会有不同的调用规范,通过这些规范来说明编译器如何在函数间通过堆栈传递参数以及返回值的规则。DllImport 默认的 CallingConvention 是 CallingConvention.Winapi。实际上,这是一个伪规范,对于目标平台来说,它用默认规范;例如, Windows 平台上的 StdCall(被调用者负责清除堆栈)以及 Windows CE .NET 上的 CDecl(调用者负责清除堆栈)。CDecl 还可以用于带有可变参数的函数,如:printf。
Giuseppe 碰到的就是调用规范的问题。C++ 还使用第三种调用规范:即 thiscall。用这种调用规范,编译器借助硬件的 ECX 寄存器来向不带可变参数的类成员函数传递“this”指针。我们对 Giuseppe 程序的细节并不了解,从出错信息来分析,他企图从使用 StdCall 规范的 C# 程序中调用使用 thiscall 规范的 C++ 函数——这样当然不行啦!
除了调用规范,另一个从框架调用 C++ 方法时存在的互用性问题是链接:C 和 C++ 使用不同形式的链接,因为 C++ 需要名字修饰来支持函数重载。这就是为什么当在 C++ 程序中声明 C 函数时,你得用 extern "C":这样编译器才不会修饰函数名。在 Windows 里,整个 windows.h 文件(现在是 winuser.h)都包含在 extern "C" 里。
虽然使用 P/Invoke 和 DllImport 以及完全修饰过的名称和 CallingConvention=ThisCall 也有办法直接调用某个 DLL 中的 C++ 成员函数,但如果你是一个正常的人,不要去这么做。从托管代码中 调用 C++ 类的正确方法——第二种选择——是在托管包装器中包装你的 C++ 类。如果你的类很多,包装可能很繁琐,但别无选择。假设你有一个 C++ 类 CWidget 并想包装它,以便 .NET 客户端能使用它,其基本套路如下:
public __gc class Widget { private: CWidget* m_pObj; // ptr to native object public: Widget() { m_pObj = new CWidget; } ~Widget() { delete m_pObj; } int Method(int n) { return m_pObj->Method(n); } // etc. };
任何类都是这种模式:
- 写一个托管类(__gc)保存一个指向本地类的指针;
- 编写构造函数和析构函数分配和销毁对象实例;
- 编写对应于 C++ 成员函数的包装器方法;
你不必包装所有的成员函数,仅仅包装那些打算暴露给托管环境的函数即可。
Figure 2 所示的是一个简单完整而具体的例子。CPerson 是一个本地 C++ 类,包含人名,有两个成员函数:GetName 和 SetName,后者用于修改人名。Figure 3 所示的是 CPerson 的托管包装器。在这个例子中,我将 Get/SetName 转换为属性,这样一来,基于 .NET 的程序员就可以用属性语法。在 C# 中是这样用的:
// C# client MPerson.Person p = new MPerson.Person("Fred"); String name = p.Name; p.Name = "Freddie";
用不用属性纯粹是编程风格问题,我完全可以照搬本地 C++ 类的做法也输出两个方法:GetName 和 SetName。但属性给人的感觉更像 .NET。包装器类就是一个程序集,只不过与本地 DLL 链接。这是托管扩展一个很酷的特性之一:你可以直接与本地 C/C++ 代码链接。如果你下载并编译我的 CPerson 例子源代码,你会发现 makefile 产生两个单独的 DLLs:person.dll 和 mperson.dll,前者实现常规的本地 DLL,后者是包装前者的托管程序集。还有两个测试程序:testcpp.exe,此为调用 person.dll 的本地 C++ 程序;testcs.exe,此为用 C# 编写的程序,它调用托管包装器 mperson.dll(它又调用本地 person.dll)。
以上我用非常简单的例子着重说明了托管和本地之间跨边界通讯的仅有的几种方法。如 Figure 4 所示:
Figure 4 互用性途径
如果 C++ 类太复杂,你碰到的最大的互用性问题将会是本地和托管类型之间的参数转换问题,这个过程称为封送(marshaling)。托管扩展所做的一个令人赞誉的工作是使这一过程尽可能轻松(例如,自动转换原始数据类型和字符串 String),但有时你必须了解自己正在做什么。
例如,你不能在没有固定(pinning)住托管对象或嵌入对象的前提下,将其地址传递给本地函数。那是因为托管对象存在于托管堆中,垃圾收集器在托管堆中可以随意重整对象。如果垃圾收集器移动某个对象,它能更新所有针对该对象的托管引用——但它对托管环境以外的原始指针一无所知。那就是 __pin 的作用之所在;它告诉垃圾收集器:不要移动这个对象。对于字符串来说,框架有一个专门的函数 PtrToStringChars,返回一个被固定住的本地字符指针。(顺便提一下,对于那些好奇者来说,PtrToStringChars 是到目前为止定义在<vcclr.h>文件中的唯一一个函数)。其代码如下:
// PtrToStringChars, from vcclr.h // get an interior gc pointer to the first character contained in a // System::String object // inline const System::Char * PtrToStringChars(const System::String *s) { const System::Byte *bp = reinterpret_cast<const System::Byte *>(s); if( bp != 0 ) { unsigned offset = System::Runtime::CompilerServices:: RuntimeHelpers::OffsetToStringData; bp += offset; } return reinterpret_cast<const System::Char*>(bp); }我在 MPerson 中使用 PtrToStringChars 来设置 Name,详细代码参见 Figure 3。
指针固定并不是你将遇到的仅有的互用性问题。如果你要处理数组,引用,结构和回调,或者存取某个对象中的嵌入对象,还会碰到其它的问题。这是一些将来要讨论的更高级的技术,如:StructLayout,框入/框出(boxing),__value 类型等等。你还需要专门的代码来处理异常(本地或托管)以及回调/委托。但不要让这些户用性细节遮住了大方向。首先确定你的调用方式(是从托管调用本地还是从本地调用托管),如果你是从托管调用本地,是使用 P/Invoke 还是包装器。
Visual Studio 2005 中(有些人已经开始用beta版了),托管扩展已更名并升级到 C++/CLI。你可以把 C++/CLI 看成是 Managed Extensions Version 2,或者是 Managed Extensions 演变成的一个什么。这个改变几乎都是语法上的,虽然也有一些重要的语义变化。总体上讲,C++/CLI 是设计用来突出而不是模糊托管和本地对象间的差异。使用托管对象的指针语法是明智的想法,但最终也许做的有些过于明智,因为它淡化了托管和本地对象之间的重要差异。C++/CLI 引入了一个处理托管对象的关键概念,CLI 处理托管对象时使用 ^(读作 hat)来代替 C 语言的指针语法:
// handle to managed string String^ s = gcnew String;
正像你已经明确注意到的,还有一个 gcnew 操作符用以来表示你是在托管堆中分配对象,而不是在本地分配。这样做有一个额外的好处是 gcnew 不会与 C++ 的 new 发生冲突,它能被重载或者甚至被重定义成一个宏。C++/CLI 有许多其它很棒的特性,专门用来使互用性尽可能简单明了。