临时小驻

求仁得仁,复无怨怼。

了解函数调用约定 (cdecl stdcall)

2018-08-18 16:15:00 +0800

在 C 语言里,我们通过阅读函数声明,就知道怎么携带参数去调用函数,也能在函数体定义内使用这些参数。但是 CPU 并不直接完成函数调用的传参操作,这需要人为的约定。这些约定被编译器识别和使用,生成所需的代码。

一般每个线程都维护着一个堆栈,称为线程栈。调用方想进行函数调用并传递参数,就往栈里压入参数。被调函数从栈顶取出一定数量的参数使用,这就完成了参数的传递。这个过程需要一些约定,使调用方和被调函数都能正确地识别对应参数的位置等。

顺便一提,函数返回值是怎么实现的呢?有一种最为常用的方法是往调用栈里压入了一个空值占位,被调函数往这个位置写入返回值,调用方再取出来使用,这就完成了返回值的传递。

函数调用约定主要包括两方面的内容:

  1. 参数传递使用哪些寄存器,使用栈时的压栈顺序(是从左至右还是从右至左)
  2. 调用完成后堆栈由谁清理(是调用方还是被调函数)

函数调用约定与编程环境是息息相关的,不同编译器的实现也不尽相同。

调用约定、类型表示和名称修饰三者合起来,即是所谓的 ABI (应用程序二进制接口)。

x86 编程环境

32 位 C/C++ 编程中,主要有 cdecl stdcall 两种不同的约定。

cdecl

cdecl (c declaration) 是 C 语言的事实标准,它表示:

  1. 实参从右至左压入线程栈
  2. 返回值保存在寄存器 EAX/AX/AL 中,如果是浮点值保存在寄存器 ST0 中
  3. 调用方负责清栈
  4. 告知 C 编译器,输出的函数名应该前加 _,即修饰为 _<func-name>
  5. 8 位或 16 位长度的整形实参,隐式提升为 32 位长
  6. 易失寄存器有:EAX, ECX, EDX, ST0 - ST7, ES, GS
  7. 非易失寄存器有:EBX, EBP, ESP, EDI, ESI, CS, DS
  8. 使用 RET 指令返回(实质上是读取 EBP 指向的线程栈处所保存的函数返回地址,将其加载到 IP 中)

易失寄存器(volatile register)是指在函数调用时不需要被保护和恢复的;非易失寄存器(non-volatile register)是指需要保护和恢复的,需要为此生成额外的代码。

cdecl 带来了两件事:

  1. 被调函数虽然不知道有多少参数入了栈,但会自上而下按需取走需要的参数。你可能想到了,使用可变参数(vararg/stdarg)的函数(如 printf)就只能使用 cdecl 约定。
  2. 每次函数调用后都要生成一段清栈代码,生成的目标代码会比较大

不同的编译器对标准的执行情况不同。

Visual C++ 规定,返回值如果是 POD 且长度不超过 4B 则用寄存器 EAX;长度不超过 8B,用寄存器 EAX:EDX 传递;长度超过 8B 或者不是 POD,则调用者为其预先分配一个空间,把该空间的地址作为第一个参数传递给被调函数。

GCC 的返回值则不作判断,都由调用者分配空间并把其地址作为第一个参数传递给被调函数。从 GCC 4.5 起,调用函数时,线程栈上的数据必须 16B 对齐。

cdecl 是 C/C++ 的默认函数调用约定。

在使用时,用特定的关键字来指定使用 cdecl,Visual C++ Compiler 使用 __cdecl 关键字;GCC 使用 __attribute__((cdecl)) 修饰。

stdcall

stdcall 是微软建立的约定,用于 Windows API。这是 Pascal 约定与 cdecl 的折中方案。

stdcall 表示:

  1. 以 cdecl 为基准
  2. 被调函数负责清栈
  3. 告知 C 编译器,输出的函数名应该前加 _ 后跟 @ 和参数占用的栈空间的长度(记为 <param-bytes>),即修饰为 _<func-name>@<param-bytes>
  4. 使用 RETN <param-bytes> 指令返回(实质上为了完成清栈操作)

这里的第三点,举例说明:在 win32 环境下,函数 int __stdcall foo(void * p) 就可以在外部使用 _foo@4 来引用。

在使用时,vc++ 下使用 __stdcall;gcc 下使用 __attribute__((stdcall))

事实上,vc++ 规定 PASCAL WINAPI APIENTRY FORTRAN CALLBACK STDCALL __far __pascal __fortran __stdcall 均是指 stdcall。

因为 stdcall 严格控制了参数的字节数,所以不能实现可变参数。

fastcall

该约定还未被标准化,但 vc++ 和 gcc 的实现是相同的,在这两者下,fastcall 表示:

  1. 以 stdcall 为基准
  2. 从左至右数,第一个不超过 32 位的参数通过寄存器 ECX/CX/CL 传递
  3. 从左至右数,第二个不超过 32 位的参数通过寄存器 EDX/DX/DL 传递
  4. C 编译器输出的函数名修饰为 @<func-name>@<param-bytes>

在使用时,vc++ 下使用 __fastcall;gcc 下使用 __attribute__((fastcall))

thiscall

thiscall 因 c++ 而引入,这是针对 c++ 的类成员函数需要 this 指针而定的。

该约定以 cdecl 为基准,vc++ 和 gcc 的实现是不同的。vc++ 使用寄存器 ECX 传递 this;gcc 将 this 视为左起第一个参数,即在最后将 this 压栈。

在使用时,vc++ 下使用 __thiscall;gcc 下使用 __attribute__((thiscall))

naked call

表示函数无需保护现场(prolog)和恢复现场(epilog)的代码。

这在 vc++ 下不是通过关键字实现的,而是通过 __declspec(naked) 函数声明;gcc 下通过 __attribute__((naked))

naked 标志跟 __inline 内联函数是不同的机制,不能一起使用。

x86-64 编程环境

有两种主流规则。

微软 x64 调用约定

参数传递

  • 从左至右的头 4 个参数使用寄存器传递。
  • 标量(scalar)寄存器依次是 RCX, RDX, R8, R9。标量寄存器中的值是右对齐的,这样可以允许访问 RCX/ECX/CX/CL 来得到特定位数的值。
  • 非标量(non-scalar)寄存器依次是 XMM0, XMM1, XMM2, XMM3。
  • 这 8 个寄存器中只用到 4 个,其他未用到的寄存器槽被忽略(见例子 1)。
  • 长度在 64b 内的整型、指针、数组、struct、union、__m64,使用标量寄存器。浮点数使用非标量寄存器。
  • 长度超过 64b 的,由调用方开辟空间(需要 16B 对齐)并传递指针(见例子 2)。
  • 如果超过 4 个变量,这些更多的参数从右至左入栈。
  • 无论有多少个参数,在入栈过程结束后,在栈上分配 32B 的影子空间(shadow space),预留给 4 个寄存器的值用于调试时存储其值。
// 例子 1
void func(int a, double b, int c, float d);
// a in RCX, b in XMM1, c in R8, d in XMM3, other registers are unused

// 例子 2
void func(__m64 a, _m128 b, struct c, float d);  
// a in RCX, ptr to b in RDX, ptr to c in R8, d in XMM3  

返回值传递

  • 标量结果放在 RAX 中,非标量结果放在 XMM0 中。
  • 长度在 64b 内的整型、指针、数组、struct、union、__m64,视为标量。浮点数和 __m128 视为非标量。
  • 长度超过 64b 的,由调用方开辟空间,并将其指针视为第一个参数传递在 RCX 中。被调函数必须在 RAX 中返回相同的指针。其他正常的参数顺延。
__int64 func(int a, double b, int c, float d);
// a in RCX, b in XMM1, c in R8, d in XMM3, others are unused
// retval in RAX

__m128 func(float a, double b, int c, __m64 d);
// a in XMM0, b in XMM1, c in R8, d in R9
// retval in XMM0, __m128 fits in float register

struct S1 {
   int j, k;        // fits in 64b
};

S1 func(int a, double b, int c, float d);
// a in RCX, b in XMM1, c in R8, d in XMM3
// retval in RAX, S1 fits in 64b

struct S2 {
   int j, k, l;     // exceeds 64b
};

void func(__m64 a, _m128 b, struct S2 c, float d);
// Caller allocates memory for retval and passes ptr to retval in RCX
// a in RDX, ptr to b in R8, ptr to c in R9, d pushed on the stack

S2 func(__m64 a, _m128 b, struct S2 c, float d);
// Caller allocates memory for retval and passes ptr to retval in RCX
// a in RDX, ptr to b in R8, ptr to c in R9, d pushed on the stack
// ptr to retval in RAX

保护现场和恢复现场

  • 调用方负责清栈
  • 易失寄存器有:RAX, RCX, RDX, R8, R9, R10, R11
  • 非易失寄存器有:RBX, RBP, RDI, RSI, RSP, R12, R13, R14, R15

System V AMD64 ABI

  • 以微软的 x64 约定为基准。
  • 头六个参数里,整型放在寄存器 RDI, RSI, RDX, RCX, R8, R9;浮点数放在寄存器 XMM0 - XMM7。
  • 栈是 16b 对齐的。
  • 其他参数从右至左入栈。
  • 不在栈上创建影子空间。

关于函数名修饰规则

编译器输出的函数名,在其他目标文件中引用该函数时使用。

C 语言采用上述规则,而 C++ 则采用另一套规则,该规则详尽描述了函数原型,因此显得有些复杂。

但是,在 C++ 中可以使用 extern "C" 来要求以 C 编译器的方式来编译和链接,这也会以 C 的方式来修饰函数名。

在编写可移植的代码时,常用以下代码段来实现这一目标:

#ifdef  __cplusplus 
extern "C" {
#endif

// code

#ifdef  __cplusplus
}
#endif  /* end of __cplusplus */

注意,64 位环境下,C 语言的函数名不做修饰。

拓展阅读

原文链接 https://blog.xupu.name//p/2018-08-function-calling-convention/

如无特别指明,本站原创文章均采用 CC BY-NC-ND 4.0 许可,转载或引用请注明出处,更多许可信息请查阅这里