X86呼叫慣例

本條目描述x86架構微處理器調用約定。 調用約定描述了被調用代碼的接口:

  • 原子(標量)參數或複雜參數獨立部分的分配順序
  • 參數是如何被傳遞的(放置在堆棧上,或是寄存器中,亦或兩者混合)
  • 被調用者應保存調用者的哪個寄存器
  • 調用函數時如何為任務準備堆棧,以及任務完成如何恢復

這與編程語言中對於大小和格式的分配緊密相關。另一個密切相關的是名字修飾,這決定了代碼中的符號名稱如何映射到鏈接器中的符號名。調用約定,類型表示和名稱修飾這三者的統稱,即是眾所周知的應用二進制接口(ABI)。

不同編譯器在實現這些約定總是有細微的差別存在,所以在不同編譯器編譯出來的代碼很難接合起來。另一方面,有些約定被當作一種API標準(如stdcall),編譯器實現都較為一致。

歷史背景

微型電腦出現之前,計算機廠商幾乎都會提供一份操作系統和為不同編程語言編寫的編譯器。平台所使用的調用約定都是由廠商的軟件實現定義的。

在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的標準。

參數也是從右到左被推入堆棧。從最左邊開始的三個字符變元會被放置在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相同。但是編譯後的函數名前綴以下劃線(_),其後綴以符號@及其參數所占的棧空間的字節長度。[1]寄存器EAX、ECX和EDX被指定在函數中使用,返回值放置在EAX中。stdcall對於微軟Win32 API和Open Watcom C++是標準。

微軟的編譯工具規定:PASCALWINAPIAPIENTRYFORTRANCALLBACKSTDCALL__far __pascal__fortran__stdcall均是指此種調用約定。[2]

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)。

WINAPI

WINAPI是平台的缺省調用約定。Windows操作系統上默認是StdCall;Windows CE上默認是Cdecl。

Intel ABI

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

x86-64調用約定

x86-64調用約定得益於更多的寄存器可以用來傳參。而且,不兼容的調用約定也更少了,不過還是有2種主流的規則。

微軟x86-64調用約定

在Windows x64環境下編譯代碼時,只有一種調用約定,也就是說32位下的各種約定在64位下統一成一種了。

微軟x64調用約定使用RCX、RDX、R8、R9四個寄存器用於存儲函數調用時的4個參數(從左到右),使用XMM0、XMM1、XMM2、XMM3來傳遞浮點變量。其他的參數直接入棧(從右至左)。整型返回值放置在RAX中,浮點返回值在XMM0中。少於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中。 與微軟不同的是,不需要提供影子空間。在函數入口,返回值與棧上第七個整型參數相鄰。

參考資料

  1. ^ /Gd, /Gr, /Gv, /Gz (Calling Convention). 2021-08-03 [2024-03-01]. (原始內容存檔於2024-03-31) (美國英語). 
  2. ^ MSDN:Adjusting Calling Conventions. [2015-11-14]. (原始內容存檔於2015-12-01).