函数调用和平栈约定

函数的调用约定,顾名思义就是对函数调用的一个约束和规定(规范),描述了函数参数是怎么传递和由谁清除堆栈的。它决定以下内容:(1)函数参数的压栈顺序,(2)由调用者还是被调用者把参数弹出栈,(3)以及产生函数修饰名的方法。

历史背景

在微机出现之前,计算机厂商几乎都会提供一份操作系统和为不同编程语言编写的编译器。平台所使用的调用约定都是由厂商的软件实现定义的。

在Apple Ⅱ出现之前的早期微机几乎都是“裸机”,少有一份OS或编译器的,即是IBM PC也是如此。

IBM PC兼容机的唯一的硬件标准是由Intel处理器(8086, 80386)定义的,并由IBM分发出去。

硬件扩展和所有的软件标准(BIOS调用约定)都开放有市场竞争。

一群独立的软件公司提供了操作系统,不同语言的编译器以及一些应用软件。基于不同的需求,历史实践和开发人员的创造力,这些公司都使用了各自不同的调用约定,往往差异很大。

在IBM兼容机市场洗牌后,微软操作系统和编程工具(有不同的调用约定)占据了统治地位,此时位于第二层次的公司如Borland和Novell,以及开源项目如GCC,都还各自维护自己的标准。

互操作性的规定最终被硬件供应商和软件产品所采纳,简化了选择可行标准的问题。

在VC++中,调用约定是函数类型的一部分,因此函数的声明和定义处调用约定要相同,不能只在声明处有调用约定,而定义处没有或与声明不同。

i386/x86调用约定

  • 函数的参数以相反的顺序传递到堆栈上,以便第一个参数是被压入堆栈的最后一个值,然后将成为堆栈上的最小值(译注:这里的值指地址)。
  • 可以通过修改被调函数的参数来修改在堆栈上传递的参数。
  • 使用call指令来调用函数,该指令将下一条指令的地址压入堆栈并跳转到操作数。
  • 函数使用ret指令返回调用者,该指令从堆栈中弹出一个值并跳转到该值。
  • 在调用call指令之前,堆栈是16字节对齐的。
  • 函数保留寄存器ebx,esi,edi,ebp和esp;而eax,ecx,edx是暂存器。
  • 返回值存储在eax寄存器中,或者如果返回值是64位的,则高32位进入edx,低32位进入eax。
  • 被调函数将ebp推入堆栈,这样紧挨着主调函数栈帧的栈顶,即此时caller-return-eip位于ebp上方4个字节处,然后将ebp设置为已保存ebp的地址。
  • 这允许遍历现有堆栈帧。通过指定-fomit-frame-pointer GCC选项可以消除此问题。
  • 作为特殊的例外,GCC假定堆栈未正确对齐,并在输入main或在函数上设置了属性((force_align_arg_pointer))时将其重新对齐。

三种调用区别

在32位的体系结构中,有3种调用约定,分别是cdecl,stdcall,fastcall,GCC的默认调用约定为cdecl。

不同点 __stdcall __cdecl __fastcall
使用场合 WindowsAPI默认的函数调用协议 C/C++默认的函数调用协议 适用于对性能要求较高的场合
参数入栈 函数参数由右向左入栈 函数参数由右向左入栈 从左开始不大于4字节的参数放入CPU的ECX和EDX寄存器,其余参数从右向左入栈
平栈方式 内平:由被调用函数清除栈内数据 外平:由函数调用者清除栈内数据 内平:由被调用函数清除栈内数据
C函数修饰 _functionname@number _functionname @functionname@nmuber
c++函数修饰 ?functionname@@YG******@Z ?functionname@@YA******@Z ?functionname@@YI******@Z

cdecl

cdecl(C declaration,即C声明)是源起C语言的一种调用约定,也是C语言的事实上的标准。在x86架构上,其内容包括:

  • 函数实参在线程栈上按照从右至左的顺序依次压栈。
  • 函数结果保存在寄存器EAX/AX/AL中
  • 浮点型结果存放在寄存器ST0中
  • 编译后的函数名前缀以一个下划线字符
  • 调用者负责从线程栈中弹出实参(即清栈)
  • 比特或者16比特长的整形实参提升为32比特长。
  • 受到函数调用影响的寄存器(volatile registers):EAX, ECX, EDX, ST0 - ST7, ES, GS
  • 不受函数调用影响的寄存器: EBX, EBP, ESP, EDI, ESI, CS, DS
  • ET指令从函数被调用者返回到调用者(实质上是读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)

Visual C++规定函数返回值如果是POD值且长度如果不超过32比特,用寄存器EAX传递;

长度在33-64比特范围内,用寄存器EAX:EDX传递;

长度超过64比特或者非POD值,则调用者为函数返回值预先分配一个空间,把该空间的地址作为隐式参数传递给被调函数。

GCC的函数返回值都是由调用者分配空间,并把该空间的地址作为隐式参数传递给被调函数,而不使用寄存器EAX。

GCC自4.5版本开始,调用函数时,堆栈上的数据必须以16B对齐(之前的版本只需要4B对齐即可)。

cdecl调用约定通常作为x86 C编译器的默认调用规则,许多编译器也提供了自动切换调用约定的选项。如果需要手动指定调用规则为cdecl,编译器可能会支持如下语法:

return_type _cdecl funct();

stdcall

stdcall是由微软创建的调用约定,是Windows API的标准调用约定。非微软的编译器并不总是支持该调用协议。GCC编译器如下使用:

int __attribute__((__stdcall__ )) func()

stdcall是Pascal调用约定与cdecl调用约定的折衷:被调用者负责清理线程栈,参数从右往左入栈。

其他各方面基本与cdecl相同。但是编译后的函数名后缀以符号"@",后跟传递的函数参数所占的栈空间的字节长度。

寄存器EAX, ECX和EDX被指定在函数中使用,返回值放置在EAX中。stdcall对于微软Win32 API和Open Watcom C++是标准。

微软的编译工具规定:PASCAL, WINAPI, APIENTRY, FORTRAN, CALLBACK, STDCALL, __far __pascal, __fortran, __stdcall均是指此种调用约定。

fastcall

__fastcall调用的主要特点就是快,因为它是通过寄存器来传送参数的。

实际上__fastcall用ECX和EDX传送前两个DWORD或更小的参数,剩下的参数仍自右向左压栈传送,被调用的函数在返回前清理传送参数的内存栈。

__fastcall调用约定在输出函数名前加上一个“@”符号,后面也是一个“@”符号和其参数的字节数,格式为@function@number,如double multi(double a, double b)的修饰名是@multi@16

__fastcall和__stdcall很象,唯一差别就是头两个参数通过寄存器传送。注意通过寄存器传送的两个参数是从左向右的,即第1个参数进ECX,第2个进EDX,其他参数是从右向左的入栈,返回仍然通过EAX。

Microsoft或GCC的__fastcall约定(也即__msfastcall),把第一个(从左至右)不超过32比特的参数通过寄存器ECX/CX/CL传递,第二个不超过32比特的参数通过寄存器EDX/DX/DL,其他参数按照自右到左顺序压栈传递。

Borland fastcall约定从左至右,传入三个参数至EAX, EDX和ECX中。剩下的参数推入栈,也是从左至右。在32位编译器Embarcadero Delphi中,这是缺省调用约定,在编译器中以register形式为人知。在i386上的某些版本Linux也使用了此约定。

thiscall

__thiscall是C++类成员函数缺省的调用约定,但它没有显示的声明形式。因为在C++类中,成员函数调用还有一个this指针参数,因此必须特殊处理

  • 参数入栈:参数从右向左入栈
  • this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入栈。
  • 栈恢复:对参数个数不定的,调用者清理栈,否则函数自己清理栈。

在调用C++非静态成员函数时使用此约定。基于所使用的编译器和函数是否使用可变参数,有两个主流版本的thiscall。

对于GCC编译器,thiscall几乎与cdecl等同:调用者清理堆栈,参数从右到左传递。差别在于this指针,thiscall会在最后把this指针推入栈中,即相当于在函数原型中是隐式的左数第一个参数。

在微软Visual C++编译器中,this指针通过ECX寄存器传递,其余同cdecl约定。当函数使用可变参数,此时调用者负责清理堆栈(参考cdecl)。thiscall约定只在微软Visual C++ 2005及其之后的版本被显式指定。其他编译器中,thiscall并不是一个关键字(反汇编器如IDA使用__thiscall)。

x64调用约定

x86-64调用约定得益于更多的寄存器可以用来传参。而且,不兼容的调用约定也更少了,不过还是有2种主流的规则。

微软x64调用约定

微软x64调用约定使用RCX, RDX, R8, R9这四个寄存器传递头四个整型或指针变量(从左到右),使用XMM0, XMM1, XMM2, XMM3来传递浮点变量。其他的参数直接入栈(从右至左)。

整型返回值放置在RAX中,浮点返回值在XMM0中。少于64位的参数并没有做零扩展,此时高位充斥着垃圾。

在Windows x64环境下编译代码时,只有一种调用约定----就是上面描述的约定,也就是说,32位下的各种约定在64位下统一成一种了。

在微软x64调用约定中,调用者的一个职责是在调用函数之前(无论实际的传参使用多大空间),在栈上的函数返回地址之上(靠近栈顶)分配一个32字节的“影子空间”;并且在调用结束后从栈上弹掉此空间。影子空间是用来给RCX, RDX, R8和R9提供保存值的空间,即使是对于少于四个参数的函数也要分配这32个字节。

例如, 一个函数拥有5个整型参数,第一个到第四个放在寄存器中,第五个就被推到影子空间之外的栈顶。当函数被调用,此栈用来组成返回值----影子空间32位+第五个参数。

在x86-64体系下,Visual Studio 2008在XMM6和XMM7中(同样的有XMM8到XMM15)存储浮点数。结果对于用户写的汇编语言例程,必须保存XMM6和XMM7(x86不用保存这两个寄存器),这也就是说,在x86和x86-64之间移植汇编例程时,需要注意在函数调用之前/之后,要保存/恢复XMM6和XMM7。

System V AMD64 ABI

此约定主要在Solaris,GNU/Linux,FreeBSD和其他非微软OS上使用。

头六个整型参数放在寄存器RDI, RSI, RDX, RCX, R8和R9上;

同时XMM0到XMM7用来放置浮点变元。对于系统调用,R10用来替代RCX。

同微软x64约定一样,其他额外的参数推入栈,返回值保存在RAX中。

与微软不同的是,不需要提供影子空间。在函数入口,返回值与栈上第七个整型参数相邻。

  • 在windows X64中,前4个参数通过rcx,rdx,r8,r9来传递,其余的参数按照从右向左的顺序压栈。
  • 在Linux上,则是前6个参数通过rdi,rsi,rdx,rcx,r8,r9传递,其余的参数按照从右向左的顺序压栈。
  • 可以通过修改被调用函数的参数来修改在堆栈上传递的参数。
  • 使用call指令来调用函数,该指令将下一条指令的地址压入堆栈并跳转到操作数。
  • 被调函数使用ret指令返回调用者,该指令从堆栈中弹出一个值并跳转到该值。
  • 在调用调用指令之前,堆栈是16字节对齐的。
  • 函数保留寄存器rbx,rsp,rbp,r12,r13,r14和r15; rax,rdi,rsi,rdx,rcx,r8,r9,r10,r11是暂存寄存器。
  • 返回值存储在rax寄存器中,或者如果它是128位值,则高64位进入rdx。
  • 可选地,被调函数推入rbp,以使caller-return-rip在其上方8个字节,并将rbp设置为已保存的rbp的地址。
  • 这允许遍历现有堆栈帧。 通过指定-fomit-frame-pointer GCC选项可以消除此问题。
  • 信号处理程序在同一堆栈上执行,但是在将任何内容压入堆栈之前,会从堆栈中减去称为红色区域的128个字节。
  • 这允许小的子函数使用128字节的堆栈空间,而无需通过从堆栈指针中减去来保留堆栈空间
  • 众所周知,红色区域会给x86-64内核开发人员造成问题,因为在调用中断处理程序时,CPU本身并不尊重红色区域。
  • 由于ABI与CPU行为相矛盾,这会导致微妙的内核损坏。 解决方案是使用-mno-red-zone或通过在内核模式下在当前堆栈以外的其他堆栈上处理中断来构建所有内核代码(从而实现ABI)。