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