x64 调用约定

本文介绍一个函数(调用方)用于在 x64 代码中调用另一个函数(被调用方)的标准过程和约定。

有关__vectorcall调用约定的详细信息,请参阅__vectorcall
有关__preserve_none调用约定的详细信息,请参阅__preserve_none

调用约定默认值

默认情况下,x64 应用程序二进制接口(ABI)使用四寄存器的快速调用约定。 系统在调用堆栈上分配空间作为影子存储,供被调用方保存这些寄存器。

函数调用的参数与用于这些参数的寄存器之间有着严格的一一对应关系。 任何无法放入 8 字节或者不是 1、2、4 或 8 字节的参数都必须按引用传递。 单个参数永远不会分布在多个寄存器中。

未使用 x87 寄存器堆栈。 它可能由被调用方使用,但请注意,在函数调用之间它是易变的。 所有浮点数运算都使用 16 个 XMM 寄存器完成。

整数参数通过寄存器 RCXRDXR8R9 传递。 浮点参数通过 XMM0LXMM1LXMM2LXMM3L 传递。 16 字节参数按引用传递。 要了解参数传递,请参阅参数传递一文。 这些寄存器以及 RAXR10R11XMM4XMM5 都被视为 易失的,也就是说,它们在返回时可能会被被调用方更改。 要详细了解寄存器的使用方法,请参阅 x64 寄存器使用由调用方/被调用方保存的寄存器

对于原型函数,在传递参数之前,所有参数都将转换为所需的被调用方类型。 调用方负责为被调用方的参数分配空间。 调用方必须始终分配足够的空间来存储 4 个寄存器参数,即使被调用方不使用这么多参数。 此约定简化了对非原型 C 语言​​函数和 vararg C/C++ 函数的支持。 对于 vararg 或非原型函数,任何浮点值都必须在相应的通用寄存器中重复。 调用之前,必须将除前 4 个参数外的其他参数存储在影子存储后面的堆栈中。 可以在 Vararg 中找到 Vararg 函数的详细信息。 要了解非原型函数,请参阅非原型函数一文。

对齐

大多数结构都按其自然方式对齐。 主要的例外是堆栈指针和 mallocalloca 内存;为了提高性能,它们对齐到 16 字节。 若要对齐到 16 字节以上,则必须手动完成。 由于 16 字节是 XMM 运算的常见对齐大小,因此该值应当适用于大多数代码。 有关结构布局和对齐方式的详细信息,请参阅 x64 类型和存储布局。 有关堆栈布局的信息,请参阅 x64 堆栈使用

可展开性

叶函数是指那些不会更改任何非易失寄存器的函数。 非叶函数可能会更改非易失性寄存器 RSP,例如通过调用某个函数。 或者,它可以通过为局部变量分配更多堆栈空间来更改 RSP 。 在处理异常时,要恢复非易失性寄存器,需要对非叶函数使用静态数据进行注释。 数据描述如何在任意指令下正确展开函数。 此数据存储为 pdata(过程数据),后者又引用 xdata(异常处理数据)。 xdata 包含展开信息,并且可以指向其他 pdata 或异常处理程序函数。

Prolog 和 epilog 受到严格限制,因此可以在 xdata 中对其进行正确描述。 堆栈指针必须在任何不属于 epilog 或 prolog 的代码区域中保持 16 字节对齐,但在叶函数中除外。 只需模拟返回即可展开叶函数,因此 pdata 和 xdata 不是必需的。 有关函数 prolog 和 epilog 的正确结构的详细信息,请参阅 x64 prolog 和 epilog。 有关异常处理以及 pdata 和 xdata 的异常处理和展开的详细信息,请参阅 x64 异常处理

参数传递

默认情况下,x64 调用约定将前 4 个参数传递给寄存器中的函数。 用于这些参数的寄存器取决于参数的位置和类型。 其余参数按从右到左的顺序在堆栈上传递。 调用方保留所需的堆栈空间,并使用存储或移动指令将这些参数写入堆栈内存,为每个参数保持 8 字节对齐。

最左侧四个位置中的整数值参数按从左到右的顺序分别通过 RCXRDXR8R9 传递。 如前所述,第 5 个和更高位置的参数在堆栈上传递。 寄存器中的所有整型参数都是向右对齐的,因此被调用方可忽略寄存器的高位,只访问所需的寄存器部分。

前四个参数中的任何浮点和双精度参数都传入 XMM0 - XMM3,具体取决于位置。 仅在存在 varargs 参数时,浮点值才会被放入整数寄存器 RCXRDXR8R9 中。 有关详细信息,请参阅 Vararg。 同样,当相应的参数为整数或指针类型时, XMM0 - XMM3 将忽略寄存器。

__m128 类型、数组和字符串从不通过即时值传递。 而是将指针传递给调用方分配的内存。 尺寸为 8、16、32 或 64 位的结构体和联合体以及 __m64 类型被作为相同大小的整数传递。 其他大小的结构或联合作为指针传递给调用方分配的内存。 对于这些作为指针传递的聚合类型(包括__m128),调用方分配的临时内存必须是16字节对齐的。

不分配堆栈空间且不调用其他函数的内部函数,有时使用其他易失性寄存器来传递其他寄存器参数。 编译器与内部函数实现之间的紧密绑定使此优化成为可能。

如果需要,被调用方负责将寄存器参数转储到其影子空间中。

下表总结了如何从左侧按类型和位置传递参数:

参数类型 第 5 个和更高位置 第 4 个 第3 个 最左侧
浮点 堆叠 XMM3 XMM2 XMM1 XMM0
整数 堆叠 R9 R8 RDX RCX
聚合(8、16、32 或 64 位)和 __m64 堆叠 R9 R8 RDX RCX
其他聚合,作为指针 堆叠 R9 R8 RDX RCX
__m128,作为指针 堆叠 R9 R8 RDX RCX

参数传递示例 1 - 所有整数

func1(int a, int b, int c, int d, int e, int f);
// a in RCX, b in RDX, c in R8, d in R9, f then e passed on stack

参数传递示例 2 - 所有浮点数

func2(float a, double b, float c, double d, float e, float f);
// a in XMM0, b in XMM1, c in XMM2, d in XMM3, f then e passed on stack

参数传递示例 3 - 整数和浮点数混合

func3(int a, double b, int c, float d, int e, float f);
// a in RCX, b in XMM1, c in R8, d in XMM3, f then e passed on stack

参数传递示例 4 - __m64__m128 和聚合

func4(__m64 a, __m128 b, struct c, float d, __m128 e, __m128 f);
// a in RCX, ptr to b in RDX, ptr to c in R8, d in XMM3,
// ptr to f passed on stack, then ptr to e passed on stack

Varargs

如果通过 vararg(例如省略号参数)传递参数,则需遵守常规寄存器参数传递约定。 该约定规定了将第 5 个及后面的参数溢出到堆栈中。 被调用方负责转储带有其地址的参数。 (仅适用于浮点值)如果被调用方希望在整数寄存器中使用浮点值,则整数寄存器和浮点数寄存器都必须包含该值。

非原型函数

对于尚未完全原型化的函数,调用方将整数值作为整数传递,将浮点值作为双精度数传递。 (仅适用于浮点值)如果被调用方希望在整数寄存器中使用浮点值,则整数寄存器和浮点数寄存器都必须包含该值。

func1();
func2() {   // RCX = 2, RDX = XMM1 = 1.0, and R8 = 7
   func1(2, 1.0, 7);
}

返回值

可容纳于 64 位中的标量返回值(包括 __m64 类型)通过 RAX 返回。 包括浮点数、双精度数以及诸如 __m128__m128i__m128d 的向量类型在内的非标量类型在 XMM0 中返回。 在 RAXXMM0 中返回的值里的未使用位的状态是未定义的。

用户定义类型可以从全局函数和静态成员函数通过值返回。 若要按值 RAX返回用户定义的类型,其长度必须为 1、2、4、8、16、32 或 64 位。 它还必须没有用户定义的构造函数、析构函数或复制赋值运算符。 它不能有私有或受保护的非静态数据成员,也不能具有引用类型的非静态数据成员。 它不能具有基类或虚拟函数。 而且,它只能有同样满足这些要求的数据成员。 此定义实质上与 C++03 POD 类型相同。 由于定义在 C++11 标准中已更改,因此不建议用于 std::is_pod 此测试。 否则,调用方必须为返回值分配内存,并将指针作为第一个参数传递给它。 然后,其余参数将向右移动一个参数。 必须由被调用方在 RAX 中返回相同的指针。

这些示例说明如何为具有指定声明的函数传递参数和返回值:

返回值示例 1 - 64 位结果

__int64 func1(int a, float b, int c, int d, int e);
// Caller passes a in RCX, b in XMM1, c in R8, d in R9, e passed on stack,
// callee returns __int64 result in RAX.

返回值示例 2 - 128 位结果

__m128 func2(float a, double b, int c, __m64 d);
// Caller passes a in XMM0, b in XMM1, c in R8, d in R9,
// callee returns __m128 result in XMM0.

返回值示例 3 - 通过指针返回的用户类型结果

struct Struct1 {
   int j, k, l;    // Struct1 exceeds 64 bits.
};
Struct1 func3(int a, double b, int c, float d);
// Caller allocates memory for Struct1 returned and passes pointer in RCX,
// a in RDX, b in XMM2, c in R9, d passed on the stack;
// callee returns pointer to Struct1 result in RAX.

返回值示例 4 - 用户类型结果按值返回

struct Struct2 {
   int j, k;    // Struct2 fits in 64 bits, and meets requirements for return by value.
};
Struct2 func4(int a, double b, int c, float d);
// Caller passes a in RCX, b in XMM1, c in R8, and d in XMM3;
// callee returns Struct2 result by value in RAX.

由调用方或被调用方保存的寄存器

x64 ABI 将寄存器 RAXRCXRDXR8R9R10R11XMM0-XMM5 视为易失性寄存器。 如果存在,YMM0-YMM15ZMM0-ZMM15 的上部也都是易失的。 在AVX512VL,ZMMYMMXMM寄存器16-31也不稳定。 当 AMX 支持存在时, TMM 磁贴寄存器是可变的。 请假定函数调用时易失性寄存器会被销毁,除非可以通过诸如全程序优化的分析证明其安全性。

x64 ABI 将寄存器 RBXRBPRDIRSIRSPR12R13R14R15XMM6、- 和 XMM15 视为非易失性寄存器。 它们必须由使用它们的函数保存和还原。

当 APX 支持存在时,寄存器是可变的 R16-R29R30 并且 R31 是非易失性。

函数指针

函数指针只是指向相应函数标签的指针。 函数指针没有目录 (TOC) 要求。

对旧代码的浮点数支持

MMX 和浮点堆栈寄存器(MM0-MM7/ST0-ST7)在上下文切换之间保留。 这些寄存器没有明确的调用约定。 内核模式代码中严禁使用这些寄存器。

FPCSR

寄存器状态还包括 x87 FPU 控制字。 调用约定规定该寄存器是非易失性的。

开始执行程序时,x87 FPU 控制字寄存器使用以下标准值进行设置:

寄存器[位] 设置
FPCSR\[0:6] 异常掩码所有 1 (所有异常已屏蔽)
FPCSR\[7] 已保留 - 0
FPCSR\[8:9] 精度控制 - 10B(双精度)
FPCSR\[10:11] 舍入控制 - 0(舍入到最接近的值)
FPCSR\[12] 无穷控制 - 0(未使用)

修改 FPCSR 中任何字段的被调用方,在返回给其调用方之前,必须将这些字段恢复原状。 此外,在调用被调用方之前,除非协议规定被调用方需要修改后的值,否则修改了这些字段的调用方必须将它们还原为标准值。

有关控制标志的非易失性的规则有两个例外:

  • 在给定函数的记录用途是修改非易失性 FPCSR 标志的函数中。

  • 当可以证明违反这些规则的程序行为与不违反这些规则的程序一致时(例如通过全程序分析),这就是正确的。

尽管它被视为非易失的,但并没有静态展开描述符来说明它被保存在哪里,以及应从哪里恢复。 修改 FPCSR 的异常安全代码应借助异常终结器(例如 C++ 析构函数或 __finally 子句),以便在堆栈展开时显式将其恢复原状。

MXCSR

寄存器状态还包括 MXCSR。 调用约定将该寄存器分为易失部分和非易失部分。 可变部分由六个状态标志组成,而 MXCSR\[0:5]寄存器 MXCSR\[6:15]的其余部分则被视为非易失性。

开始执行程序时,非易失部分设置为以下标准值:

寄存器[位] 设置
MXCSR\[6] 非规格化数为零 - 0
MXCSR\[7:12] 异常掩码所有 1 (所有异常已屏蔽)
MXCSR\[13:14] 舍入控制 - 0(舍入到最接近的值)
MXCSR\[15] 对于已屏蔽的下溢刷新到零 - 0(关闭)

修改了 MXCSR 中任何非易失性字段的被调用方,必须在返回给其调用方之前恢复这些字段。 此外,在调用被调用方之前,除非协议规定被调用方需要修改后的值,否则修改了这些字段的调用方必须将它们还原为标准值。

有关控制标志的非易失性的规则有两个例外:

  • 在给定函数的记录用途是修改非易失性 MXCSR 标志的函数中。

  • 当可以证明违反这些规则的程序行为与不违反这些规则的程序一致时(例如通过全程序分析),这就是正确的。

除非函数文档明确说明,否则不要假设 MXCSR 寄存器在跨越函数边界时其易失部分的状态。

尽管 MXCSR 的某些部分被视为非易失性的,但并没有静态展开描述符说明其保存位置以及应从何处恢复。 修改 MXCSR 的非易失性部分的异常安全代码,应在堆栈展开时借助异常终结器(例如 C++ 析构函数或 __finally 子句)显式恢复其原有状态。

setjmp/longjmp

当您包含setjmpex.hsetjmp.h时,所有对setjmplongjmp的调用将导致展开,进而调用析构函数和__finally。 此行为不同于 x86,在这种情况下,包括 setjmp.h 会导致 __finally 子句和析构函数未被调用。

setjmp 的调用会保留当前栈指针、非易失性寄存器和 MXCSR 寄存器。 对 longjmp 的调用将返回到最近的 setjmp 调用点,并将堆栈指针、非易失寄存器和 MXCSR 寄存器重置为最近一次调用 setjmp 时所保存的状态。

如果支持 APX,则在函数中,从调用 setjmp 起,到发出最终导致 longjmp 的调用为止,都不应修改 R30R31。 此限制是由于 R30R31 未作为 jmp_buf 的一部分保存所致——此结构定义无法更改。 而是通过展开器还原它们。 以下示例演示了还原数据的方式的差异如何影响此限制:

jmp_buf jmpbuffer;

void function_a() {
    ...

    int val = setjmp(jmpbuffer);  // At this time R30 is 10

    ...

    if (val == 0) {
        function_b();  // At this time R30 is 20
    }

    ...
}

void function_b() {
    ...

    longjmp(jmpbuffer, 1);

    ...
}

在此示例中,R30 的值会在调用 setjmp 处与调用 function_b 处之间发生变化。 在 function_b中, longjmp 展开堆栈,直到它到达调用 setjmp 的函数(function_a 在本例中)。 为 R30 恢复的值将是 20(在调用 function_b 时的值),而不是 10(在调用 setjmp 时的值)。 这意味着,当 setjmp 第二次返回时(作为 longjmp 的结果),R30 的值将被设置为 20,而不是 10,这是不正确的。 这就是为什么编译器必须确保 R30R31 从调用 setjmp 开始一直保持不变,直到该函数中最后一个最终可能导致调用 longjmp 的位置。

由于 longjmp 可以从异常筛选器(而不仅仅是子例程)中调用,这实际上意味着,从调用 setjmp 的那一刻起直到函数结束,R30R31 都应保持不变。

另请参阅