天天看點

調用約定__cdecl、__stdcall和__fastcall的差別什麼是調用約定__cdecl的特點__stdcall的特點__fastcall的特點__thiscall總結

什麼是調用約定

函數的調用約定,顧名思義就是對函數調用的一個限制和規定(規範),描述了函數參數是怎麼傳遞和由誰清除堆棧的。它決定以下内容:(1)函數參數的壓棧順序,(2)由調用者還是被調用者把參數彈出棧,(3)以及産生函數修飾名的方法。

曆史背景

在微機出現之前,計算機廠商幾乎都會提供一份作業系統和為不同程式設計語言編寫的編譯器。平台所使用的調用約定都是由廠商的軟體實作定義的。 在Apple Ⅱ出現之前的早期微機幾乎都是“裸機”,少有一份OS或編譯器的,即是IBM PC也是如此。IBM PC相容機的唯一的硬體标準是由Intel處理器(8086, 80386)定義的,并由IBM分發出去。硬體擴充和所有的軟體标準(BIOS調用約定)都開放有市場競争。 一群獨立的軟體公司提供了作業系統,不同語言的編譯器以及一些應用軟體。基于不同的需求,曆史實踐和開發人員的創造力,這些公司都使用了各自不同的調用約定,往往差異很大。 在IBM相容機市場洗牌後,微軟作業系統和程式設計工具(有不同的調用約定)占據了統治地位,此時位于第二層次的公司如Borland和Novell,以及開源項目如GCC,都還各自維護自己的标準。互操作性的規定最終被硬體供應商和軟體産品所采納,簡化了選擇可行标準的問題。

調用者清理

在這些約定中,調用者自己清理堆棧上的參數(arguments),這樣就允許了可變參數清單的實作,如printf()。

cdecl

cdecl(C declaration,即C聲明)是源起C語言的一種調用約定,也是C語言的事實上的标準。在x86架構上,其内容包括:

  1. 函數實參線上程棧上按照從右至左的順序依次壓棧。
  2. 函數結果儲存在寄存器EAX/AX/AL中
  3. 浮點型結果存放在寄存器ST0中
  4. 編譯後的函數名字首以一個下劃線字元
  5. 調用者負責從線程棧中彈出實參(即清棧)
  6. 8比特或者16比特長的整形實參提升為32比特長。
  7. 受到函數調用影響的寄存器(volatile registers):EAX, ECX, EDX, ST0 - ST7, ES, GS
  8. 不受函數調用影響的寄存器: EBX, EBP, ESP, EDI, ESI, CS, DS
  9. RET指令從函數被調用者傳回到調用者(實質上是讀取寄存器EBP所指的線程棧之處儲存的函數傳回位址并加載到IP寄存器)

Visual C++規定函數傳回值如果是POD值且長度如果不超過32比特,用寄存器EAX傳遞;長度在33-64比特範圍内,用寄存器EAX:EDX傳遞;長度超過64比特或者非POD值,則調用者為函數傳回值預先配置設定一個空間,把該空間的位址作為隐式參數傳遞給被調函數。

GCC的函數傳回值都是由調用者配置設定空間,并把該空間的位址作為隐式參數傳遞給被調函數,而不使用寄存器EAX。GCC自4.5版本開始,調用函數時,堆棧上的資料必須以16B對齊(之前的版本隻需要4B對齊即可)。

考慮下面的C代碼片段:

int callee(int, int, int);
  int caller(void)
  {
      register int ret;
      
      ret = callee(1, 2, 3);
      ret += 5;
      return ret;
  }
           

在x86上, 會産生如下彙編代碼(AT&T 文法):

.globl  caller
  caller:
        pushl   %ebp
        movl    %esp,%ebp
        pushl   $3
        pushl   $2
        pushl   $1
        call    callee
        addl    $12,%esp
        addl    $5,%eax
        leave
        ret
           

在函數傳回後,調用的函數清理了堆棧。 在cdecl的了解上存在一些不同,尤其是在如何傳回值的問題上。結果,x86程式經過不同OS平台的不同編譯器編譯後,會有不相容的情況,即使它們使用的都是“cdecl”規則并且不會使用系統調用。某些編譯器傳回簡單的資料結構,長度大緻占用兩個寄存器,放在寄存器對EAX:EDX中;大點的結構和類對象需要異常處理器的一些特殊處理(如一個定義的構造函數,析構函數或指派),存放在記憶體上。為了放置在記憶體上,調用者需要配置設定一些記憶體,并且讓一個指針指向這塊記憶體,這個指針就作為隐藏的第一個參數;被調用者使用這塊記憶體并傳回指針----傳回時彈出隐藏的指針。 在Linux/GCC,浮點數值通過x87僞棧被推入堆棧。像這樣:

sub esp, 8      ; 給double值一點空間
        fld [ebp + x]   ; 加載double值到浮點堆棧上
        fstp [esp]      ; 推入堆棧
        call funct
        add esp, 8
           

使用這種方法確定能以正确的格式推入堆棧。 cdecl調用約定通常作為x86 C編譯器的預設調用規則,許多編譯器也提供了自動切換調用約定的選項。如果需要手動指定調用規則為cdecl,編譯器可能會支援如下文法:

return_type _cdecl funct();
           

其中_cdecl修飾符需要在函數原型中給出,在函數聲明中會覆寫掉其他的設定。

syscall

與cdecl類似,參數被從右到左推入堆棧中。EAX, ECX和EDX不會保留值。參數清單的大小被放置在AL寄存器中(?)。 syscall是32位OS/2 API的标準。

optlink

參數也是從右到左被推入堆棧。從最左邊開始的三個字元變元會被放置在EAX, EDX和ECX中,最多四個浮點變元會被傳入ST(0)到ST(3)中----雖然這四個參數的空間也會在參數清單的棧上保留。函數的傳回值在EAX或ST(0)中。保留的寄存器有EBP, EBX, ESI和EDI。 optlink在IBM VisualAge編譯器中被使用。

被調用者清理

如果被調用者要清理棧上的參數,需要在編譯階段知道棧上有多少位元組要處理。是以,此類的調用約定并不能相容于可變參數清單,如printf()。然而,這種調用約定也許會更有效率,因為需要解堆棧的代碼不要在每次調用時都生成一遍。 使用此規則的函數容易在asm代碼被認出,因為它們會在傳回前解堆棧。x86 ret指令允許一個可選的16位參數說明棧位元組數,用來在傳回給調用者之前解堆棧。代碼類似如下:

ret 12
           

pascal

基于Pascal語言的調用約定,參數從左至右入棧(與cdecl相反)。被調用者負責在傳回前清理堆棧。 此調用約定常見在如下16-bit 平台的編譯器:OS/2 1.x,微軟Windows 3.x,以及Borland Delphi版本1.x。

register

Borland fastcall的别名。

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

此約定還未被标準化,不同編譯器的實作也不一緻。

Microsoft/GCC fastcall

Microsoft或GCC的__fastcall約定(也即__msfastcall)把第一個(從左至右)不超過32比特的參數通過寄存器ECX/CX/CL傳遞,第二個不超過32比特的參數通過寄存器EDX/DX/DL,其他參數按照自右到左順序壓棧傳遞。

Borland fastcall

從左至右,傳入三個參數至EAX, EDX和ECX中。剩下的參數推入棧,也是從左至右。 在32位編譯器Embarcadero Delphi中,這是預設調用約定,在編譯器中以register形式為人知。 在i386上的某些版本Linux也使用了此約定。

調用者或被調用者清理

thiscall

在調用C++非靜态成員函數時使用此約定。基于所使用的編譯器和函數是否使用可變參數,有兩個主流版本的thiscall。 對于GCC編譯器,thiscall幾乎與cdecl等同:調用者清理堆棧,參數從右到左傳遞。差别在于this指針,thiscall會在最後把this指針推入棧中,即相當于在函數原型中是隐式的左數第一個參數。

在微軟Visual C++編譯器中,this指針通過ECX寄存器傳遞,其餘同cdecl約定。當函數使用可變參數,此時調用者負責清理堆棧(參考cdecl)。thiscall約定隻在微軟Visual C++ 2005及其之後的版本被顯式指定。其他編譯器中,thiscall并不是一個關鍵字(反彙編器如IDA使用__thiscall)。

Intel ABI

根據Intel ABI,EAX、EDX及ECX可以自由在過程或函數中使用,不需要保留。

x86-64調用約定

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中。 與微軟不同的是,不需要提供影子空間。在函數入口,傳回值與棧上第七個整型參數相鄰。

以上内容來源中文維基:https://zh.wikipedia.org/zh-hans/X86%E8%B0%83%E7%94%A8%E7%BA%A6%E5%AE%9A

我們知道函數由以下幾部分構成:傳回值類型 函數名(參數清單),如: 

【code1】

void function();
int add(int a, int b);
           

以上是大家所熟知的構成部分,其實函數的構成還有一部分,那就是調用約定。如下: 

【code2】

void __cdecl function();
int __stdcall add(int a, int b);
           

上面的__cdecl和__stdcall就是調用約定,其中__cdecl是C和C++預設的調用約定,是以通常我們的代碼都如 【code1】中那樣定義,編譯器預設會為我們使用__cdecl調用約定。常見的調用約定有__cdecl、__stdcall、fastcall,應用最廣泛的是__cdecl和__stdcall,下面我們會詳細進行講述。。還有一些不常見的,如 __pascal、__thiscall、__vectorcall。

聲明和定義處調用約定必須要相同

在VC++中,調用約定是函數類型的一部分,是以函數的聲明和定義處調用約定要相同,不能隻在聲明處有調用約定,而定義處沒有或與聲明不同。如下: 

【code3】 錯誤的使用一:

int __stdcall add(int a, int b);
int add(int a, int b)
{
    return a + b;
}
           

報錯:

error C2373: ‘add’: redefinition; different type modifiers 

error C2440: ‘initializing’: cannot convert from ‘int (__stdcall *)(int,int)’ to ‘int’

補充:

int __cdecl add(int a, int b);
int add(int a, int b)
{
    return a + b;
}
           

以上就沒問題,因為預設是__cdecl。

【code4】 錯誤的使用二:

int  add(int a, int b);
int __stdcall add(int a, int b)
{
    return a + b;
}
           

報錯:

error C2373: ‘add’: redefinition; different type modifiers 

error C2440: ‘initializing’: cannot convert from ‘int (__cdecl *)(int,int)’ to ‘int’

【code5】 錯誤的使用三:

int __stdcall add(int a, int b);
int __cdecl add(int a, int b)
{
    return a + b;
}
           

報錯:

error C2373: ‘add’: redefinition; different type modifiers 

error C2440: ‘initializing’: cannot convert from ‘int (__stdcall *)(int,int)’ to ‘int’

【code6】 正确的用法:

int __stdcall add(int a, int b);
int __stdcall add(int a, int b)
{
    return a + b;
}
           

函數的調用過程

要深入了解函數調用約定,你須要了解函數的調用過程和調用細節。 

假設函數A調用函數B,我們稱A函數為”調用者”,B函數為“被調用者”。如下面的代碼,ShowResult為調用者,add為被調用者。

int add(int a, int b)
{
    return a + b;
}

void ShowResult()
{
    std::cout << add(5, 10) << std::endl;
}
           

函數調用過程可以這麼描述: 

(1)先将調用者(A)的堆棧的基址(ebp)入棧,以儲存之前任務的資訊。 

(2)然後将調用者(A)的棧頂指針(esp)的值賦給ebp,作為新的基址(即被調用者B的棧底)。 

(3)然後在這個基址(被調用者B的棧底)上開辟(一般用sub指令)相應的空間用作被調用者B的棧空間。 

(4)函數B傳回後,從目前棧幀的ebp即恢複為調用者A的棧頂(esp),使棧頂恢複函數B被調用前的位置;然後調用者A再從恢複後的棧頂可彈出之前的ebp值(可以這麼做是因為這個值在函數調用前一步被壓入堆棧)。這樣,ebp和esp就都恢複了調用函數B前的位置,也就是棧恢複函數B調用前的狀态。 

這個過程在AT&T彙編中通過兩條指令完成,即: 

調用約定__cdecl、__stdcall和__fastcall的差別什麼是調用約定__cdecl的特點__stdcall的特點__fastcall的特點__thiscall總結
leave
   ret
  這兩條指令更直白點就相當于:
  mov   %ebp , %esp
  pop    %ebp
           

__cdecl的特點

__cdecl 是 C Declaration 的縮寫,表示 C 和 C++ 預設的函數調用約定。是C/C++和MFCX的預設調用約定。

  • 按從右至左的順序壓參數入棧、。
  • 由調用者把參數彈出棧。切記:對于傳送參數的記憶體棧是由調用者來維護的,傳回值在EAX中。是以對于像printf這樣可變參數的函數必須用這種約定。
  • 編譯器在編譯的時候對這種調用規則的函數生成修飾名的時候,在輸出函數名前加上一個下劃線字首,格式為_function。如函數int add(int a, int b)的修飾名是_add。

(1).為了驗證參數是從右至左的順序壓棧的,我們可以看下面這段代碼,Debug進行單步調試,可以看到我們的調用棧會先進入GetC(),再進入GetB(),最後進入GetA()。 

調用約定__cdecl、__stdcall和__fastcall的差別什麼是調用約定__cdecl的特點__stdcall的特點__fastcall的特點__thiscall總結

(2).第二點“調用者把參數彈出棧”,這是編譯器的工作,暫時沒辦法驗證。要深入了解這部分,需要學習彙編語言相關的知識。

(3).函數的修飾名,這個可以通過對編譯出的dll使用VS的”dumpbin /exports ProjectName.dll”指令進行檢視(後面章節會進行詳細介紹),或直接打開.obj檔案查找對應的方法名(如搜尋add)。

從代碼和程式調試的層面考慮,參數的壓棧順序和棧的清理我們都不用太觀注,因為這是編譯器的決定的,我們改變不了。但第三點卻常常困擾我們,因為如果不弄清楚這點,在多個庫之間(如dll、lib、exe)互相調用、依賴時常常出出現莫名其妙的錯誤。這個我在後面章節會進行詳細介紹。

__stdcall的特點

__stdcall是Standard Call的縮寫,是C++的标準調用方式,當然這是微軟定義的标準,__stdcall通常用于Win32 API中(可檢視WINAPI的定義)。   microsoft的vc預設的是__cdecl方式,而windows API則是__stdcall,如果用vc開發dll給其他語言用,則應該指定__stdcall方式。堆棧由誰清除這個很重要,如果是要寫彙編函數給C調用,一定要小心堆棧的清除工作,如果是__cdecl方式的函數,則函數本身(如果不用彙編寫)則不需要關心儲存參數的堆棧的清除,但是如果是__stdcall的規則,一定要在函數退出(ret)前恢複堆棧。

  • 按從右至左的順序壓參數入棧。
  • 由被調用者把參數彈出棧。切記:函數自己在退出時清空堆棧,傳回值在EAX中。
  • __stdcall調用約定在輸出函數名前加上一個下劃線字首,後面加上一個“@”符号和其參數的位元組數,格式為[email protected]。如函數int sub(int a, int b)的修飾名是[email protected]。

__fastcall的特點

__fastcall調用的主要特點就是快,因為它是通過寄存器來傳送參數的。

  • 實際上__fastcall用ECX和EDX傳送前兩個DWORD或更小的參數,剩下的參數仍自右向左壓棧傳送,被調用的函數在傳回前清理傳送參數的記憶體棧。
  • __fastcall調用約定在輸出函數名前加上一個“@”符号,後面也是一個“@”符号和其參數的位元組數,格式為@[email protected],如double multi(double a, double b)的修飾名是@[email protected]。
  • __fastcall和__stdcall很象,唯一差别就是頭兩個參數通過寄存器傳送。注意通過寄存器傳送的兩個參數是從左向右的,即第1個參數進ECX,第2個進EDX,其他參數是從右向左的入棧,傳回仍然通過EAX。

__thiscall

__thiscall是C++類成員函數預設的調用約定,但它沒有顯示的聲明形式。因為在C++類中,成員函數調用還有一個this指針參數,是以必須特殊處理,thiscall調用約定的特點:

  • 參數入棧:參數從右向左入棧
  • this指針入棧:如果參數個數确定,this指針通過ecx傳遞給被調用者;如果參數個數不确定,this指針在所有參數壓棧後被壓入棧。
  • 棧恢複:對參數個數不定的,調用者清理棧,否則函數自己清理棧。

總結

這裡主要總結一下_cdecl、_stdcall、__fastcall三者之間的差別:

要點 __cdecl __stdcall __fastcall
參數傳遞方式 右->左 右->左 左邊開始的兩個不大于4位元組(DWORD)的參數分别放在ECX和EDX寄存器,其餘的參數自右向左壓棧傳送
清理棧方 調用者清理 被調用函數清理 被調用函數清理
适用場合 C/C++、MFC的預設方式; 可變參數的時候使用; Win API 要求速度快
C編譯修飾約定 _functionname [email protected] @[email protected]

以上内容參考:https://blog.csdn.net/luoweifu/article/details/52425733#commentBox

繼續閱讀