为什么 Cdecl 调用在“标准”P/Invoke 约定中经常不匹配?

Why are Cdecl calls often mismatched in the "standard" P/Invoke Convention?

提问人:Kadaj Nakamura 提问时间:3/27/2013 更新时间:7/7/2015 访问量:22719

问:

我正在开发一个相当大的代码库,其中 C++ 功能是从 C# P/调用的。

我们的代码库中有许多调用,例如...

C++:

extern "C" int __stdcall InvokedFunction(int);

使用相应的 C#:

[DllImport("CPlusPlus.dll", ExactSpelling = true, SetLastError = true, CallingConvention = CallingConvention.Cdecl)]
    private static extern int InvokedFunction(IntPtr intArg);

我已经在网上搜索了(在我能力范围内)寻找为什么存在这种明显不匹配的原因。例如,为什么 C# 中有 Cdecl,而 C++中有 __stdcall?显然,这会导致堆栈被清除两次,但是,在这两种情况下,变量都以相同的相反顺序推送到堆栈上,因此我没有看到任何错误,尽管在调试期间尝试跟踪时可能会清除返回信息?

来自 MSDN: http://msdn.microsoft.com/en-us/library/2x8kf7zx%28v=vs.100%29.aspx

// explicit DLLImport needed here to use P/Invoke marshalling
[DllImport("msvcrt.dll", EntryPoint = "printf", CallingConvention = CallingConvention::Cdecl,  CharSet = CharSet::Ansi)]

// Implicit DLLImport specifying calling convention
extern "C" int __stdcall MessageBeep(int);

同样,在 C++ 代码和 C# 中都有。为什么不是?或者,此外,为什么在 C++ 中存在?extern "C"CallingConvention.CdeclCallingConvention.Stdcall__stdcall

提前致谢!

C# C++ pinvoke std调用 cdecl

评论


答:

9赞 Simon Mourier 3/27/2013 #1

cdecl在 C++ 和 .NET 之间都有效且可用,但它们在两个非托管和托管环境之间应保持一致。因此,InvokedFunction 的 C# 声明无效。应该是 stdcall。MSDN 示例仅提供了两个不同的示例,一个使用 stdcall (MessageBeep),另一个使用 cdecl (printf)。它们是无关的。stdcall

评论

0赞 JerKimball 3/27/2013
同意;建议对“调用约定”进行一些研究,以了解为什么差异很重要。
183赞 Hans Passant 3/28/2013 #2

这在 SO 问题中反复出现,我将尝试将其变成一个(长)参考答案。32 位代码背负着不兼容调用约定的悠久历史。关于如何进行函数调用的选择在很久以前是有意义的,但今天主要是后端的巨大痛苦。64 位代码只有一个调用约定,无论谁要添加另一个调用约定,都会被发送到南大西洋的小岛。

我将尝试在维基百科文章中的内容之外注释这些历史和它们的相关性。首先,在如何进行函数调用时要做出的选择是传递参数的顺序、存储参数的位置以及如何在调用后进行清理。

  • __stdcall通过旧的 16 位 Pascal 调用约定进入 Windows 编程,用于 16 位 Windows 和 OS/2。它是所有 Windows API 函数以及 COM 使用的约定。由于大多数 pinvoke 旨在进行 OS 调用,因此如果未在 [DllImport] 属性中显式指定 Stdcall,则 Stdcall 是默认值。它存在的唯一原因是它指定被调用方进行清理。这会产生更紧凑的代码,这在他们必须将 GUI 操作系统压缩到 640 KB RAM 的时代非常重要。它最大的缺点是很危险。调用方假定的函数参数与被调用方实现的参数之间的不匹配会导致堆栈变得不平衡。这反过来又会导致极难诊断的崩溃。

  • __cdecl是用 C 语言编写的代码的标准调用约定。它存在的主要原因是它支持使用可变数量的参数进行函数调用。常见于 C 代码中,带有 printf() 和 scanf() 等函数。副作用是,由于调用方知道实际传递了多少参数,因此是调用方进行清理。在 [DllImport] 声明中忘记 CallingConvention = CallingConvention.Cdecl 是一个非常常见的错误。

  • __fastcall是一个定义相当差的调用约定,具有相互不兼容的选择。这在Borland编译器中很常见,这家公司曾经在编译器技术方面非常有影响力,直到他们解体。也是许多Microsoft员工的前雇主,包括C#成名的Anders Hejlsberg。它的发明是为了通过CPU寄存器而不是堆栈传递一些参数,从而使参数传递更便宜。由于标准化较差,托管代码不支持它。

  • __thiscall是为 C++ 代码发明的调用约定。与 __cdecl非常相似,但它也指定如何将类对象的隐藏 this 指针传递给类的实例方法。C++ 中超越 C 的额外细节。虽然它看起来很容易实现,但 .NET pinvoke 封送处理程序不支持它。无法调用 C++ 代码的主要原因。复杂程度不在于调用约定,而在于 this 指针的正确值。由于 C++ 对多重继承的支持,这可能会变得非常复杂。只有 C++ 编译器才能弄清楚到底需要传递什么。只有为 C++ 类生成代码的完全相同的 C++ 编译器,不同的编译器在如何实现 MI 以及如何优化它方面做出了不同的选择。

  • __clrcall是托管代码的调用约定。它是其他指针的混合体,这个指针像__thiscall一样传递,优化的参数像__fastcall一样传递,参数顺序像__cdecl一样,调用者清理像__stdcall一样。托管代码的最大优点是内置于抖动中的验证程序。这确保了调用方和被调用方之间永远不会出现不兼容的情况。因此,允许设计师利用所有这些惯例的优势,但没有麻烦的包袱。一个示例,说明托管代码如何保持与本机代码的竞争力,尽管使代码安全开销。

你提到,理解这一点的重要性对于在互操作中生存也很重要。语言编译器通常使用额外的字符来修饰导出函数的名称。也称为“名称篡改”。这是一个非常蹩脚的伎俩,永远不会停止造成麻烦。您需要了解它才能确定 [DllImport] 属性的 CharSet、EntryPoint 和 ExactSpelling 属性的正确值。有许多约定:extern "C"

  • Windows API 修饰。Windows 最初是一个非 Unicode 操作系统,对字符串使用 8 位编码。Windows NT 是第一个以 Unicode 为核心的 Windows NT。这导致了一个相当严重的兼容性问题,旧代码将无法在新操作系统上运行,因为它会将 8 位编码的字符串传递给需要 utf-16 编码的 Unicode 字符串的 winapi 函数。他们通过为每个 winapi 函数编写两个版本来解决这个问题。一个采用 8 位字符串,另一个采用 Unicode 字符串。并通过在旧版本名称末尾粘贴字母 A (A = Ansi) 和在新版本末尾粘贴字母 W (W = 宽) 来区分两者。如果函数不采用字符串,则不添加任何内容。pinvoke 编组程序会在没有您帮助的情况下自动处理此问题,它只会尝试找到所有 3 个可能的版本。但是,应始终指定 CharSet.Auto(或 Unicode),否则将字符串从 Ansi 转换为 Unicode 的旧函数的开销是不必要的,并且有损。

  • __stdcall功能的标准装饰是_foo@4。前导下划线和@n后缀,指示参数的组合大小。此后缀旨在帮助解决调用方和被调用方在参数数量上不一致时令人讨厌的堆栈不平衡问题。效果很好,虽然错误消息不是很好,但 pinvoke 封送程序会告诉你它找不到入口点。值得注意的是,Windows 在使用 __stdcall 时不使用此装饰。这是有意为之的,让程序员有机会正确地获得 GetProcAddress() 参数。pinvoke 封送处理程序也会自动处理这个问题,首先尝试找到带有@n后缀的入口点,然后尝试没有后缀的入口点。

  • __cdecl功能的标准装饰是_foo。单个前导下划线。pinvoke 编组程序会自动对此进行排序。可悲的是,__stdcall 的可选 @n 后缀不允许它告诉您 CallingConvention 属性是错误的,这是巨大的损失。

  • C++ 编译器使用名称修改,生成真正奇怪的名称,例如“??2@YAPAXI@Z“,即”运算符 new“的导出名称。这是一个必要的邪恶,因为它支持函数重载。它最初被设计为一个预处理器,使用传统的 C 语言工具来构建程序。这使得有必要通过给它们提供不同的名称来区分 a 和 a 重载。这就是语法发挥作用的地方,它告诉 C++ 编译器不要将名称修改应用于函数名称。大多数编写互操作代码的程序员都有意使用它来使其他语言的声明更易于编写。这实际上是一个错误,装饰对于捕捉不匹配非常有用。可以使用链接器的 .map 文件或 Dumpbin.exe /exports 实用工具来查看修饰的名称。undname.exe SDK 实用程序非常方便地将损坏的名称转换回其原始 C++ 声明。void foo(char)void foo(int)extern "C"

所以这应该清除属性。使用 EntryPoint 提供导出函数的确切名称,该名称可能与要在自己的代码中调用它的内容不匹配,尤其是对于 C++ 错误的名称。您使用 ExactSpelling 告诉 pinvoke 编组程序不要尝试查找替代名称,因为您已经给出了正确的名称。

我现在会暂时缓解我的写作抽筋。问题标题的答案应该很清楚,Stdcall 是默认的,但与用 C 或 C++ 编写的代码不匹配。并且 [DllImport] 声明兼容。这应该会在调试器中从 PInvokeStackImbalance 托管调试器助手(一个旨在检测错误声明的调试器扩展)中生成警告。并且可能会随机导致代码崩溃,尤其是在发布版本中。确保您没有关闭 MDA。

评论

5赞 Jacob Foshee 3/28/2013
谢谢你的教训。我对 P/Invoke 有了新的尊重。我更欣赏__clrcall和 C# 中缺少多重继承。
5赞 David Heffernan 3/28/2013
+1 我有几个温和的评论。你说,但这实际上是一个 MS 调用约定。通常,它可以被视为 fastcall 约定系列的一部分。MS fastcall、Borland fastcall 等。MS 版本仅使用两个 x86 寄存器:ECX、EDX。Borland版本,今天在德尔菲世界作为惯例存在,使用三个寄存器。EAX 被添加到组合中。__fastcall__fastcallregister
0赞 David Heffernan 3/28/2013
我的另一条评论与名称装饰有关。我最近在这里回答一个问题时发现,不同的编译器对 .似乎 MS、Borland 和 GNU 工具集都不同。我怀疑其他编译器引入了更多的变化。__stdcall
1赞 Kadaj Nakamura 3/28/2013
汉斯,谢谢你的解释!我只能通过筛选无数的点来获得点点滴滴。将所有这些信息集中在一个地方真是太好了!另外,也感谢你们对此的评论!对我来说,这一切都在沉沦,但这非常有帮助,特别是关于为什么事情处于当前混乱/混合例子状态的历史!
1赞 Agentlien 3/30/2013
这太棒了。我以前从未在同一个地方看到过所有这些信息。我希望我能给这个答案投多个赞。