Nightmare on (Overwh)Elm Street: 64 位调用约定






4.92/5 (28投票s)
64 位调用约定
64 位调用约定:他们称之为 __fastcall
,但当它在 Windows 中实际实现时,却没有任何快速之处。如果调用代码没有陷入噩梦般的堆栈操作中来调用一个函数,它可能会很快。“省小钱,花大钱”从未见过如此尽职的实现。(对于那些从未完全理解这个短语的人,请联想英镑货币。)
ABI(应用程序二进制接口)的一般规则表面上看起来很简单,但当人们开始在约定内实际工作时,问题就会出现。这里和那里关键的细节笼罩在神秘之中,而且往往找不到答案。
MSDN 提供了 ABI 的以下概述:
x64 应用程序二进制接口 (ABI) 默认使用四寄存器快速调用约定。在调用堆栈上分配空间作为被调用者保存这些寄存器的影子存储。函数调用参数与用于这些参数的寄存器之间存在严格的一一对应关系。任何不适合 8 字节或不是 1、2、4 或 8 字节的参数都必须通过引用传递。不尝试将单个参数分布在多个寄存器中。x87 寄存器堆栈未使用。它可以由被调用者使用,但必须被视为跨函数调用易失。所有浮点操作都使用 16 个 XMM 寄存器完成。整数参数在寄存器 RCX、RDX、R8 和 R9 中传递。浮点参数在 XMM0L、XMM1L、XMM2L 和 XMM3L 中传递。16 字节参数通过引用传递。参数传递在参数传递中详细描述。除了这些寄存器,RAX、R10、R11、XMM4 和 XMM5 被视为易失。所有其他寄存器都是非易失的。
不幸的是,上述解释在实际实现时留下了一些未解答的问题。
本文中的信息是在我尝试创建利用 DirectX 的全汇编 Win64 应用程序时,与 Visual Studio 2017 进行的血腥生死搏斗中拼凑而成的。(这是一场我最终获胜的核级冲突。)我遇到的最紧迫的问题是我称之为“代码破坏”的现象。WinDbg 以及 VS2017 调试器(就其而言)似乎对跳过指令、是否显示它们有明显的偏好,这取决于月相或其他最终决定因素。VS2017 还喜欢多次显示同一源代码行,这严重混淆了调试工作。如果将一个 C++ .DLL(仅用于学习目的)导出函数到纯 ASM 应用程序中,最终结果比硝化甘油更不稳定。我尝试了调试版本、发布版本、Bob the Builder,甚至鸭嘴兽构建,都收效甚微。大多数时候,我确信我一定是在做梦,因为现实不可能那么糟糕。
我错了。就是那么糟糕。
细节中的魔鬼
从没有内置 COM 支持的 64 位应用程序调用 COM(特别是 DirectX)方法时,第一个问题是要求 RCX(参数 0)为每次调用持有接口指针(“this
”)。如果开发环境没有固有地考虑这一点,这将使每个额外参数超出文档中描述的一个。更糟糕的是,DirectX 普遍使用 32 位 float
值,除了指针,即使在 64 位模式下也是如此。这导致了第一个未回答的问题:float
参数(在 DirectX 中大量存在)是 32 位单精度还是 64 位双精度?深入研究 VS2017 调试器相对较快地回答了这个问题:单精度,32 位。那么,在 ABI 中如何处理这些呢?
甚至内联函数调用(DirectXMath
库)也有一个未文档化的参数……有时。这完全取决于函数。通常(但并非总是),返回 XMMATRIX
结构的函数在调用时需要在 RCX 中指向输出矩阵目标的指针。如果你编写语句
mOut = XMMFoo ( Parameter1, Parameter 2);
那么,实际编写的代码是
RAX = XMMFoo ( &mOut, Parameter1, Parameter 2);
这很可能是编译器的一种诡计,旨在将内存访问(写入最终输出)移动到实际函数的 AVX 领域,但如果从 Visual Studio 外部尝试访问 DirectXMath
,它会无限地混淆问题。
关于函数调用,当调用方法时,会出现同样的问题:如果存在未文档化的参数,所有其他参数都会增加 1 个参数。哪些函数执行此操作可以通过仔细阅读定义它们的内联代码来发现,但谁有时间对每个函数都这样做呢?如果你的语言没有内置 COM 支持,你就别无选择。
从 Visual Studio 之外的角度来看,整个 DirectX(这意味着,很可能,所有其他基于 COM 的东西)显然是一个混乱、不统一的混淆和不一致的集群。别无选择,只能在其中导航。虽然这些问题不直接影响 64 位调用约定,但提及它们强调了在实际尝试遵守约定时可能遇到(且经常遇到)的有时是剧烈的不一致。
整数,还是非整数?
__notsofastcall
规定 float
类型数据放入 XMM 寄存器。但是如何放?XMM0 用于第一个 float
,无论它在参数列表中的位置如何?我找不到这个问题的答案,尽管我不得不承认我没有深入阅读搜索返回的每一个来源,因为在我看来,每个人都喜欢在学习曲线的中间开始写博客和文章,忠实地瞄准一个核心受众,而这些受众根本不会阅读它们,因为这些人已经知道他们在做什么了。
结果,下表适用:
上表对于前四个参数始终遵守。对于上述每个参数,int
或 float
列是分配每个参数值的唯一选项。如果(例如)参数 2 是 float
而其他参数不是,那么 XMM0、XMM1 和 XMM3 保持不变。参数 2 进入 XMM2,就是这样;参数 0、1 和 3 分别放置在 RCX、R8 和 R9 中。要传递的数据只占用 XMM 寄存器的低 64 位,或者可能是低 32 位(如 DirectX 及其使用单精度浮点数的情况)。如果参数 1 和 3 是 float
,它们进入 XMM1 和 XMM3;XMM0 和 XMM2 不用于调用。
堆栈布局
设置调用堆栈是浪费时间,一切都是相对而言。堆栈在内存中,内存访问是有成本的。如果你尝试将一个 64 位值写入一个没有正确对齐(在 8 字节边界上)的位置,这个价格会大大上涨。Windows 会在最初保持堆栈正确对齐,但当你的应用程序开始执行时,堆栈上放置的内容(从而修改 RSP)通常超出该应用程序的控制范围。
“红色区域”是 RSP 下方正式声明的 128 字节区域,保证不会被信号和中断处理程序破坏。如果一个函数是叶函数——它不调用其他函数——它可以安全地将该区域用作工作空间,而无需在使用前后调整 RSP。然而,由于进行调用的行为本身就排除了调用者是叶函数,因此它无法实际利用红色区域——除非该空间在函数调用之间使用,并且红色区域在任何其他函数调用期间都被认为是易失的(除了调用你编写的函数,你知道它们不会弄乱堆栈——但如果后来改变了怎么办?)。
当进行调用时,调用者必须为传递的数据保留堆栈空间(对于前四个参数完全多余)。我能找到的最接近的理由,根据对此的记载,是说在被调用的函数中“可能会发生”将前四个参数在堆栈上进行影子存储,因此每次调用都必须适应这种可能性。这与烦人的残疾人停车位概念不远,那个停车位可能每十年才使用一次,而且当它被使用时,通常是由一个实际没有残疾但方便使用挂着蓝色许可证的车辆的司机使用。所以,在其余的时间里,没有人可以停在那里。
返回时不会调整堆栈。调用者必须在每次调用返回后撤消其对堆栈所做的任何更改。
必须为调用的返回地址(在 [RSP])、参数 0 到 3(在 [RSP+8] 到 [RSP+32])以及可能传递的前四个参数之外的任何参数创建堆栈空间。前四个参数之外的任何参数必须在调用者进行调用之前放置在堆栈上,在为前四个参数留出空间之后。调用者不需要将前四个参数放置在堆栈上,但必须为它们保留空间。
下图显示了对函数 XMMFoo
进行 6 个参数调用的堆栈布局
通常,将打包值从内存加载到 XMM 寄存器需要源内存位置为 16 字节对齐;未能做到这一点会引发异常。(现在,是时候发表一些个人看法了:我不确定“throw
”这个术语在异常方面从何而来,但不知何故它成为了最终的常用术语。英特尔的 CPU 文档总是将异常称为“raised”(如,引发一个标志)或“generated”。我从未听说英特尔使用“throw
”这个术语,因为编程与体育之间没有直接关系。他们可能或可能没有在这个愚蠢的“throw
”术语成为主流使用后某个时候追随潮流。也许是另一个 CPU 制造商创造了这个术语?)然而,x64 架构提供了特定的指令,用于将未对齐(不在 16 字节边界上)的打包数据移动到 XMM 寄存器中。它的执行速度比其对齐的表亲慢;在内部,必须发生两次内存访问才能完成传输。对于本文的目的,整个问题无关紧要,因为只使用了标量(单个 64 或 32 位)值。对于这些值,16 字节对齐要求消失。Float
们会在堆栈上的小巢中安放,无论确切位置如何,是否 16 字节对齐。
结论
__fastcall
约定短期内不会消失,所以无论你喜欢它、讨厌它还是不在乎,都必须处理它。如果你使用 Visual Studio 及其系列语言,或者其他能够理解 64 位调用所有细微差别的语言,你就不需要关心其中的细节。然而,如果你是那些不幸的人,必须手动调整每次调用以适应每个被调用函数有时狂野的要求偏差,你就必须非常仔细地查看每个单独的函数。如果你不完全确定,不要认为自己是对的。寻找那些未文档化的参数;微软似乎很喜欢它们。参数错误不总是导致直接崩溃。有时,你只是得到错误的数据,你永远不会直接知道出了问题。我在撰写本文前一天就经历了这种情况;那次经历促使我撰写了本文:清空深度模板缓冲区的 DirectX 方法是如此简单,以至于在一次 16 小时的马拉松式调试会话中,它是唯二我没有深入审查的函数之一,目的是找出为什么我的立方体没有渲染。不可能是那个;那个调用太简单明了了。就是那个。永远不要假设。验证,验证,再验证。如果你在“外部”语言和/或环境中工作,你必须仔细检查所有内容;你必须去其他开发人员永远不会费心的地方。
对我来说,这种内部混乱的趋势似乎始于 Windows 8,而且在我看来,它每天都在加速,不断地从坏走向更坏。如果你不是 Visual Studio 的忠实拥护者,而且你没有使用同样适合与众多微软平台一起工作的工具,那么你的开发生活短期内不太可能变得更容易。你必须要么适应现有的,要么转向 Visual Studio,要么完全退出开发。最终,微软平台中明显的内部无政府状态会呈现出一种模式,你将能够预测比你需要研究的更多的事情。但你必须投入时间和积累经验才能达到那个境界。