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

71

我正在处理一个相当庞大的代码库,其中C++功能从C#中进行P/Invoke调用。

在我们的代码库中有许多这样的调用...

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++ 代码中有 extern "C",而 C# 中有 CallingConvention.Cdecl。为什么不使用 CallingConvention.Stdcall 呢?又或者为什么 C++ 中还要用到 __stdcall?

提前感谢!

2个回答

183
这在Stack Overflow的问题中反复出现,我将尝试将其转化为(长篇)参考答案。32位代码背负着不兼容的调用约定的悠久历史。选择如何进行函数调用在很久以前是有意义的,但今天却大多是一种巨大的痛苦。64位代码只有一个调用约定,任何人想要添加另一个都将被送到南大西洋的小岛上。
我将尝试注释这些历史和相关性,超越Wikipedia文章中的内容。起点是如何进行函数调用的选择,包括传递参数的顺序、存储参数的位置以及如何在调用后进行清理。
  • __stdcall是通过16位Pascal调用约定进入Windows编程的,在16位Windows和OS/2中使用。所有Windows API函数以及COM都使用这种约定。由于大多数pinvoke旨在进行操作系统调用,因此如果您在[ DllImport]属性中未显式指定,则默认使用Stdcall。它存在的唯一原因是指定被调用者清理。这会产生更紧凑的代码,在将GUI操作系统压缩到640KB RAM的日子里非常重要。它最大的缺点是它是危险的。调用方假设函数的参数与被调用方实现的参数不匹配会导致堆栈不平衡。反过来可能会导致极难诊断的崩溃。

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

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

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

  • __clrcall是托管代码的调用约定。它是其他约定的混合体,例如__thiscall中的this指针传递,__fastcall中的优化参数传递,__cdecl中的参数顺序以及__stdcall中的调用方清理。托管代码的巨大优势是JIT中内置的验证器。这确保了调用方和被调用方之间永远不会存在不兼容性。因此,允许设计人员利用所有这些约定的优点,但没有麻烦。这是托管代码如何在使代码安全的开销下仍然能够与本机代码竞争的示

    你提到了extern "C",理解其重要性对于实现交互操作至关重要。编程语言编译器通常会在导出函数名中添加额外的字符,这被称为“名称修饰”。这是一个非常糟糕的技巧,经常会引起问题。你需要理解它来确定 [DllImport] 属性的 CharSet、EntryPoint 和 ExactSpelling 属性的正确值。有许多约定:
    • Windows API装饰。 Windows最初是一个非Unicode操作系统,使用8位编码来表示字符串。 Windows NT是第一个以Unicode为核心的操作系统。 这导致了一个相当大的兼容性问题,旧代码无法在新操作系统上运行,因为它会向期望utf-16编码的Unicode字符串的winapi函数传递8位编码的字符串。 他们通过编写每个winapi函数的两个版本来解决这个问题。 一个接受8位字符串,另一个接受Unicode字符串。 并通过在遗留版本的名称结尾添加字母A(A = Ansi)和新版本的名称结尾添加W(W = wide)来区分两者。 如果函数不需要字符串,则不添加任何内容。 PInvoke marshaller会自动处理此问题,它将简单地尝试找到所有3个可能的版本。 但是,您应该始终指定CharSet.Auto(或Unicode),遗留函数将字符串从Ansi转换为Unicode的开销是不必要的且有损失的。

    • __stdcall函数的标准修饰符是_foo@4。 前导下划线和一个后缀@n,指示参数的组合大小。 设计此后缀是为了帮助解决调用方和被调用方在参数数量上不一致的难题。 虽然效果很好,但错误消息并不好,PInvoke marshaller会告诉您找不到入口点。 值得注意的是,Windows虽然使用__stdcall,但不使用此修饰符。 这是有意为之的,以便让程序员有机会正确获取GetProcAddress()参数。 PInvoke marshaller也会自动处理这个问题,首先尝试使用带有@n后缀的入口点,然后尝试不带后缀的入口点。

    • __cdecl函数的标准修饰符是_foo。 单个前导下划线。 PInvoke marshaller会自动解决这个问题。 不幸的是,__stdcall的可选@n后缀无法告诉它您的CallingConvention属性是错误的,这是一个很大的损失。

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

    这段内容说明了如何使用EntryPoint来指定导出函数的确切名称,因为它可能与你在自己的代码中想要调用的名称不匹配,特别是对于C++的名称。同时使用ExactSpelling来告诉pinvoke marshaller不要尝试查找替代名称,因为你已经给出了正确的名称。
    答案应该很明显,Stdcall是默认设置,但不适用于使用C或C++编写的代码。你的[DllImport]声明与之不兼容。这应该会在PInvokeStackImbalance Managed Debugger Assistant中引发警告,这是一种设计用于检测错误声明的调试器扩展工具。如果关闭MDA,则会产生随机崩溃,尤其是在发布版本中。请确保没有关闭MDA。

5
感谢这个教训,我现在更加尊重P/Invoke。而且我更好地理解了C#中的__clrcall和缺乏多重继承的特点。 - Jacob Foshee
5
我有一些小题大做的评论。您提到了 __fastcall,但实际上这是 MS 调用约定的一部分。通用地,它可以被视为快速调用约定家族的一部分。MS 快速调用、Borland 快速调用等等。MS 版本 __fastcall 仅使用两个 x86 寄存器:ECX、EDX。而 Borland 版本则使用三个寄存器,现在在 Delphi 世界中以 register 约定形式存在。请注意,我已经将原文改成了简化易懂的表达方式,但没有改变原意。 - David Heffernan
1
Hans,谢谢你的解释!我只能通过筛选大量信息来收集一些碎片。在一个地方拥有所有这些信息真是太好了!还要感谢你们对此的评论!对我来说,这仍然需要时间消化,但这非常有帮助,特别是关于为什么事情处于当前混乱/混合示例状态的历史! - Kadaj Nakamura
1
这太棒了。我以前从未在同一个地方看到过所有这些信息。我希望我能给这个答案多次点赞。 - Agentlien
@HansPassant:也许我很无知,但__vectorcall到底有何意义?我目前正在一个使用向量内在函数的项目中,我们只是通过(const)引用来传递向量...除了MSDN之外,还有其他关于此的文档吗?(例如,它尚未出现在 Agner Fog 的优秀指南中...) - MFH
显示剩余25条评论

9

cdeclstdcall在C++和.NET之间都是有效且可用的,但它们应该在两个非托管和托管世界之间保持一致。所以你的InvokedFunction的C#声明是无效的,应该是stdcall。MSDN示例只是提供了两个不同的示例,一个使用stdcall(MessageBeep),另一个使用cdecl(printf)。它们没有关联。


同意;建议进行一些关于“调用约定”的研究,以了解这种差异的重要性。 - JerKimball

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接