什麼是應用程序二進制接口ABI【轉】

轉自:https://zhuanlan.zhihu.com/p/386106883

ABI(Application Binary Interface)

ABI 是編譯器和鏈接器遵守的一組規則,以讓編譯後的程序可以正常工作。ABI裏包含很多方面的內容:

  • ABI 最大和最重要的部分是規定函數的調用順序,也稱爲“調用約定”。調用約定標準化瞭如何將“函數”轉換爲彙編代碼。
  • ABI 還規定了庫中公開函數的name(如printf)應該如何表示,以便在鏈接後可以正確的調用這些庫函數並接收參數。
  • ABI 還規定可以使用什麼類型的數據類型、它們必須如何對齊以及其他低級細節。
  • 此外,ABI還涉及操作系統的內容,如可執行文件的格式,虛擬地址空間佈局,還有Program Loading and Dynamic Linking等細節。

當然,如果是以上對ABI的理解,僅僅是“只知其然”。更加重要的“所以然”還需要深入瞭解其中的一些細節。

深入理解ABI最好的方式當然就是直接查看ABI的標準文檔,在Linux Standard Base (LSB)裏可以找到一些具體的ABI文檔,由於這個頁面存在很多的文檔鏈接,所以有必要知曉文檔之間的一些關係。下面是LSB網站列出衆多參考文檔鏈接的頁面,我們主要關注其中用紅色方框標記的文檔。

第一個圈出的LSB裏指向了Linux標準的系列版本,裏面包含了迄今爲止Linux制定的標準的各個版本,目前爲止最新的版本是LSB 5.0,但我們暫時不需要去看標準中定義的細節。提到這個文檔的原因是考慮知識鏈的完整性。因爲Linux的很多標準也不是憑空自己制定的,很多一部分也來自於老大哥UNIX的一些標準,比如Linux ABI標準中的一些內容就是遵循UNIX系統的System V版本所發佈的ABI標準。

如果我們打開上圖中的core/AMD64/PDF,可以在裏面看到很多內容都遵循System V ABI中的規範,例如下圖所示:

所以我們直接查看System V ABI文檔的內容就可以,因爲Linux也是遵循這個標準。但是需要說明的是, 由於二進制規範必須包含特定於計算機處理器體系結構的信息,因此一個文檔不可能將與所有處理器相關的ABI內容都包含在內。所以,System V ABI嚴格來說並不是指某個單一的文檔,而是一個包含很多規範文檔的家族。從組成上來說,System V ABI包含兩個基本的部分,一部分是是通用的規範,描述了System V在不同的處理器體系結構實現中保持不變的部分,這部分的內容是由SYSTEM V APPLICATION BINARY INTERFACE Edition 4.1描述,目前是4.1版。另一部分是與處理器體系結構相關的規範,因爲涉及到許多不同的處理器體系結構,所以這一部分會有很多的文檔,每個文檔都是專門用來描述一種特定的處理器體系結構的ABI規範,例如,X86 ABI規範由SYSTEM V APPLICATION BINARY INTERFACE Intel386 Architecture Processor Supplement來描述,而X86_64 ABI的規範由System V Application Binary Interface AMD64 Architecture Processor Supplement來描述。特定於處理器體系結構的ABI規範可以說是對通用ABI規範的補充。在通用ABI規範中涉及到具體的處理器體系結構的內容,都是由第二部分的處理器ABI規範負責補充描述,如下圖:

接下來我們就以X86_64的ABI規範爲例來查看其ABI文檔內包含了哪些內容,並通過簡要的閱讀其中的部分內容來更好的理解ABI的作用。下圖是X86_64 ABI規範的目錄,其中我們主要關注紅色方框標記出的內容:

紅色標記出的部分是需要關注的內容,接下來就按照紅色標記的順序,在文檔中查看這些規範都描述了哪些內容。

Data Representation(數據表示)

Data Representation主要定義了系統基本數據類型的數據寬度,規範中爲了描述的方便和準確,特說明在本規範中, byte指的是8位對象,twobyte指的是16位對象,fourbyte指的是32位對象,eightbyte指的是64位對象,sixteenbyte指的是128位對象。定義的系統基本數據類型寬度如下圖:

可以看到ABI規範裏明確規定了某些數據類型的寬度,在這裏面我們還是關注紅色標記的類型。

第一個類型是_Bool類型,從C99標準開始,C語言支持布爾類型,類型名字爲"_Bool",但是後來C++出現了bool關鍵字,C99爲了讓C和C++兼容,增加了一個頭文件stdbool.h,裏面定義了bool、true、false,讓我們可以像C++一樣直接使用"bool"來定義布爾類型。X86_64 System V ABI中關於布爾類型是這樣描述的: 當存儲在內存中時,布爾值被存儲爲單字節對象(所以布爾類型的變量只佔一個字節),其值總是0(假)或1(真)。當存儲在整數寄存器中(作爲參數傳遞時除外),寄存器的所有8個字節都是重要的,任何非零值都被認爲是真值。

第二個需要關注的類型是long類型。因爲這裏涉及到兩種不同的數據模型,分別是ILP32和LP64。在這兩種數據模型裏,long類型的寬度是不同的。ILP模型中的"I"代表int類型,L代表"long"類型,P是"Pointer"的意思,代表指針類型,所以ILP32表示的含義是int、long和指針類型的數據寬度是32位的。LP中的"L"同樣代表long類型,"P"代表指針類型,所以LP64要求long和指針類型的數據寬度必須是64位的。除了ILP32和LP64外,還有其他的數據模型,如LLP64,這種模型要求long long和指針類型的數據寬度是64位的。各種數據模型對比如下:

之所以long的數據寬度不一致,是因爲標準C中並沒有明確規定long類型的長度。在標準C中只規定了長整型(無論無符號或者有符號)至少佔用32位,但沒有具體說明long寬度。所以在操作系統的ABI中,要明確long類型的寬度。在32位下,Linux和Windows都採用ILP32,但在64位下,Linux採用的是LP64,而Windows採用的是LLP64。所以在Linux下編程,long和long long的寬度都是8個字節,但在Windows下,long是4個字節,long long是8個字節。我們分別在Linux環境和Windows環境裏打印long和long long的寬度驗證一下:

Windows:

Linux:

可以看到,Windows採用LLP64,long爲4字節,long long爲8字節。而Linux採用LP64,所以long爲8字節。

同時,System V ABI中還規定了size_t的類型,如下:

The type size_t is defined as unsigned long for LP64 and unsigned int for ILP32.

size_t類型是以字節爲單位來計算數據類型長度的。如果系統採用LP64,當我們使用C的sizeof關鍵字來打印long類型的變量時,其打印格式的佔位符就要是"%lu",如果是ILP32,則爲"%u"。

對於64位模式,關於不同系統所採用的數據模型和不同數據模型下數據寬度,下圖做了一個概要的總結:

第三個要關注的類型就是指針類型,ILP32是32位系統採用的,所以指針類型是"unsigned fourbyte",即無符號位的4個字節。對於64位系統,Linux採用的是LP64,所以指針類型是"unsigned eightbyte",即無符號位的8個字節。 ABI裏還規定了空指針(對於所有類型)的值爲零。

總結一下,ABI裏關於Data Representation的內容主要就是規定了系統基本數據類型的寬度。

Function Calling Sequence(函數調用約定)

ABI裏第二個比較重要的內容就是函數調用序列,其實就是調用約定。函數調用約定裏涉及到寄存器怎麼使用,參數如何傳遞(通過堆棧還是用寄存器),誰負責清理堆棧(是調用者清理還是被調用者清理),參數入棧的順序(從右向左還是其它),棧幀的佈局等。調用約定主要是由編譯器負責實現的,大概可以理解爲下面的過程:

當我們用C語言編寫一個函數時,編譯器會生成一行彙編代碼,如_MyFunction1:,這是一個標籤,最終會被彙編程序解析爲一個地址(所有的函數名本質都是一個標籤地址)。該標籤在彙編代碼中標記“函數”的“開始”。在C代碼中,當我們“調用”這個函數時,底層真正在發生的是讓 CPU跳轉到該標籤的地址並在那裏繼續執行。

爲了準備跳轉,編譯器必須考慮周全,比如如何爲被調用的函數準備參數,跳轉之前應該保存哪些東西。如何保證執行完被調用的函數後還能回到之前跳轉的地方繼續執行。此外,除了跳轉之前要做的準備,同樣要考慮跳轉進入被調用的函數後,要保存哪些東西,怎麼接收參數,返回之前又要做哪些清理工作,等等這些內容,其實就是調用約定所規定的。調用約定就像一個清單,編譯器遵循它來完成所有這些工作:

  • 首先,編譯器插入一些彙編代碼來保存當前地址,這樣當你的“函數”完成後,CPU 就可以返回到跳轉之前的地方繼續執行。
  • 接下來,編譯器生成彙編代碼以傳遞參數。一些調用約定規定參數應該放在堆棧上,並且按照從右向左的方向入棧。其他約定規定參數應該放在特定的寄存器中。還有其他約定規定應該使用堆棧和特定寄存器的組合。
  • 當然,如果以前這些寄存器中有任何重要的東西,那麼這些值現在會被覆蓋並永遠丟失,因此一些調用約定可能會要求編譯器在將參數放入其中之前保存其中的一些寄存器,比如可以可以將要保存的寄存器的值先保存在堆棧上。
  • 現在編譯器插入一條跳轉指令,告訴 CPU 轉到它之前創建的標籤 ( _MyFunction1:)。此時,CPU就開始執行MyFunction1函數。
  • 在函數的最後,編譯器會放入一些彙編代碼,讓 CPU 將返回值寫入正確的位置。調用約定將決定返回值是應該放入特定寄存器,還是放入堆棧中。
  • 最後要做的就是清理的工作了。調用約定將規定編譯器放置清理彙編代碼的位置。一些約定要求調用者清理堆棧。這意味着在“函數”完成並且 CPU 跳回到之前的位置後,接下來要執行的代碼應該是一些非常具體的清理代碼,比如之前如果是通過堆棧將參數傳遞給被調用的函數,那麼現在就將這些空間刪除,這種約定屬於調用者負責清理堆棧。其他的約定說清理代碼的某些特定部分應該在跳回之前位於“函數”的末尾,這種約定屬於被調用者清理堆棧。

函數調用約定基本上就是在約定類似於上面的內容,當然這只是形式上的約定,具體的實現則由編譯器負責。編譯器在實現的時候,遵循的就是函數調用約定裏所規定的內容。接下來先看一下System V ABI中對於函數調用約定的具體內容。因爲函數調用約定依賴於具體的處理器體系結構,所以在通用的System V ABI中沒有函數調用約定的具體內容,需要查看特定於處理器體系結構的System V ABI。因爲32位的時代已經過去,所以我們也只關心64位下的函數調用約定,以常見的X86_64體系結構爲例,在System V Application Binary Interface AMD64 Architecture Processor Supplement的Function Calling Sequence裏描述了X84_64的調用約定。其實調用約定裏主要包含了三個內容,寄存器如何使用,棧幀的佈局和參數如何傳遞。

以前的X86只有8個通用寄存器,分別是%eax、%ebx、%ecx、%edx、%esp、%ebp、%esi和%edi。X86_64在原來通用寄存器的基礎上擴展了位數外又新增了8個通用寄存器,一共16個64位的通用寄存器,分別是%rax、%rbx、%rcx、%rdx、%rsp、%rbp、%rsi、%rdi、%r8~r15。X86和X86_64寄存器的寬度關係如下圖所示:

關於寄存器如何使用,System V ABI裏規定了不同寄存器的用途,如下圖所示:

簡單總結一下:

  • %rax:接收函數的返回值。
  • %rsp: 堆棧指針%rsp,總是指向最新分配的堆棧幀的末尾。
  • %rdi,%rsi,%rdx,%rcx,%r8,%r9傳遞參數。當Caller調用一個函數的時候,如果向被調函數(Called)傳入多個實參,如foo(1,2,3),那麼這些寄存器就用來保存這些實參,並且是有順序的,%rdi保存第一個參數,%rsi保存第二個,以此類推,最多六個可以使用寄存器傳遞,超出六個的其它參數要使用堆棧傳遞,並且這些參數是從右向左壓入棧中(因爲從右向左的順序利於實現可變參數,下面再細說)。
  • %rbp:可選的用作幀指針。可選就是可有可無。這與編譯優化有關,%rbp一般被稱爲幀指針寄存器,用來指向一個新的函數幀的開始處,但這是在沒有開啓編譯優化的情況下%rbp的用途。如果編譯時使用了GCC的編譯優化,比如-O1,那%rbp就解放了,不再需要它指向棧幀了,所以%rbp和其他的普通寄存器沒區別了,可以被當作一個臨時的寄存器來使用(後面有實驗驗證)。
  • %rbx,%r10,%r11,%r12,%r13,%14,%15沒有特別的規定用途,屬於可以隨便用的寄存器。但是%rbx、%rbp(用做臨時寄存器時)、%12 ~ %r15“屬於”調用者, 被調用函數需要保留它們的值。換句話說,被調用的函數必須爲其調用者保留這些寄存器的值,也就是被調用函數不能使用這些寄存器。這些寄存器以外的寄存器“屬於”被調用的函數。如果調用函數(Caller)想要在函數調用中保留這些寄存器的值,Caller必須將寄存器值保存在它的本地堆棧幀中(後面有例子會看到)。

補充一下,在X86中,因爲通用寄存器並不多,所以在X86的調用約定中要求使用堆棧來傳遞參數。但是X86_64通用寄存器比較多,所以X86_64 System V ABI的調用約定中使用寄存器來傳遞參數,這樣當然會提高性能,因爲不需要訪問內存堆棧來獲取參數。正時因爲X86是使用堆棧來傳遞參數的,所以在一個函數調用結束後,就面臨誰來清理用來傳遞參數的堆棧,也就是調用者(Caller)清理還是被調用者(Called)清理。在X86_64中,雖然使用寄存器來傳遞參數,但是隻有前六個參數可以使用寄存器來傳遞,超過六個以後的參數同樣需要通過堆棧來傳遞,所以X86_64也會涉及誰負責清理堆棧的問題,但是X86_64 System V ABI規範中沒有說明(我在文檔中沒有看到)誰負責清理堆棧,所以爲了驗證這個問題,後面會通過實驗來觀察清理堆棧的行爲,來確定堆棧是由調用者(Caller)清理還是被調用者(Called)清理。

什麼是棧幀?

C語言屬於面向過程語言,它最大特點就是把一個程序分解成若干過程(函數),比如:入口函數是main,然後調用各個子函數。在對應彙編代碼中,GCC把過程(函數)轉化成棧幀(frame),簡單的說,每個棧幀對應一個過程(函數)。在X86_64的典型棧幀結構中,最新分配的堆棧幀實際上包含了兩個部分,一個返回地址和當前函數自己的堆棧空間,即一個函數的棧幀 = 返回地址 + 該函數的堆棧空間。但是,返回地址處並不是該函數棧幀開始的地方,%rbp指向的地址纔是該函數棧幀真正意義上的起始處,%rsp則始終指向棧幀的末尾,如下圖:

返回地址是call指令的下一條指令的地址。因爲當調用call指令去執行另一個函數時要跳轉到這個函數執行,在這個函數執行完後,還要繼續回來執行call指令後面的指令,所以在去執行新函數之前,要把這個地址保存好。保存的方式就是將call指令的下一條指令地址壓入被調函數的棧幀。

棧幀的佈局

在X86_64 System V ABI中,定義了棧幀的佈局,如下圖:

注意:調用函數前的返回地址屬於當前新建的棧幀,而不是屬於調用者的棧幀。

棧幀的建立

棧幀的建立需要關注調用者和被調用者的棧幀建立過程,因爲調用者的棧幀裏可以觀察調用者如何保存自己的局部變量,和如何爲被調用者準備參數。而被調用者的棧幀可以觀察被調用者如何接收自己的參數,以及如何銷燬自己的堆棧。

下面通過一個例子,將上述函數調用約定的內容應用到實踐中,通過觀察彙編之後的彙編代碼,理解編譯器如何實現上述的System V ABI調用約定,並通過分析調用約定理解函數調用過程的具體細節,如何參數傳遞,棧幀的建立和銷燬。一個簡單的但可以說明問題的代碼示例asm_rsp.c如下:

#include <stdio.h>

int foo(int i)
{
	int arry[] = {1, 3, 5};

	return arry[i];
}

int main(int argc, char *argv[])
{
	int i = 1;
	int j = foo(i);

	printf("%d %d\n", i, j);
	
	return 0;
}

通過彙編命令gcc -S asm_rsp.c -o asm_rsp.s生成彙編程序asm_rsp.s(未啓用任何優化)。主要看main函數調用foo函數的過程中,堆棧的變化情況。

下面先以main函數的棧幀建立過程爲例。如下是調用者main函數的彙編代碼:

圖中簡要的寫出了main函數棧幀建立過程中的概要描述,並且對於每個關鍵的步驟進行了標號,分別是1、2、3、4、5、6、7。接下來以圖示的方式展示main函數的棧幀建立完整過程。

一個函數被調用後,進入這個函數要做的第一件事就是先保存上一函數的棧幀,然後再保存自己的棧幀。要保存的棧幀地址有兩個,但是幀指針寄存器%rbp卻只有一個,所以就先將上一函數的棧幀地址保存在自己的堆棧上,然後用%rbp保存自己棧幀的地址。所以對於當前即將要創建的棧幀來說,%rbp始終保存的就是上一個函數棧幀的起始地址。而且這樣做有一個好處,因爲對即將創建的新棧幀來說%rbp中保存的始終是上一個函數的棧幀開始的地址,所以每次新棧幀中要保存上一棧幀的地址就變的簡單,直接將%rbp寄存器中的內容壓入堆棧就可以,然後將自己的棧幀地址保存在%rbp,對於接下來的新棧幀亦是如此。這感覺就像多米諾骨牌一樣,只要前一個可以撞倒後一個,那麼這骨牌就可以一直倒下去,回到函數調用上,就是函數可以一幀一幀的一直調用下去,對每個函數來說,這都是一個通用的做法。

現在回到main函數棧幀的創建上來,下圖的過程1表示main函數開始創建自己的棧幀。main先要保存上一棧幀的地址,即將上一棧幀地址壓入自己的堆棧,對應的就是彙編指令:pushq %rbp。此時%rsp指向的地址就是main函數棧幀開始的地方了,就是下圖中第二個堆棧示意裏的地址188。接下來main可以使用%rbp保存自己的棧幀了,因爲此時%rsp已經指向了main的棧幀了,所以直接將%rsp的值保存在%rbp就可以了,對應彙編指令:moveq %rsp, %rbp。這個時候%rbp保存的就是main自己的棧幀起始地址,如下圖中第三個堆棧示意圖:

注意%rsp不是向下減少了一個字節,而是減少了8個字節。pushq %rbp可以分解成兩步:

subq  $8, %rsp      # 先將%rsp向下移動8個字節
movq  %rbp, %rsp    # 然後將%rbp中的值複製到%rsp保存的地址處開始往後8個字節的空間內

AT&T彙編格式的指令後會跟一個後綴,如movq中,mov指令最後的'q','q'代表quad word(四字),表示數據的尺寸。 '四字'代表8個字節,這是因爲Intel將一個'字'定義爲16位寬。本身'字'這個含義在早期其實就等同於處理器字長的意思,處理器字長就是處理器內部寄存器的寬度,但是隨着32位和64位處理器的出現,'字'不能再等同於處理器字長的意思,所以這裏的'字(word)'變成了一個數據單位。那爲什麼字定義爲16位寬?因爲Intel是從16位體系結構擴展成32位的,所以早期Intel就用術語"字(word)”表示16位數據類型,並且後來在大多數IA-32處理器特定的文檔中也將‘字’定義爲16位對象,'雙字'定義爲32位對象,'四字'定義爲64位對象,'雙四字'定義爲128位對象。因此,稱32位數爲"雙字(double word)",稱64位數爲"四字(quad word)",現在'字(word)'變成了一個數據單位。下面是我之前總結的一張C語言數據類型與彙編指令中的數據類型對應的表格,如下:

main函數分配自己的棧幀空間:

接下來是被調用者fool函數的彙編代碼:(待整理)

棧幀的銷燬:(foo函數棧幀的銷燬)(待整理)

先寫到這裏了,時間比較緊,要去寫論文了,暫時沒時間寫完這篇文章了,還有規劃的好多內容沒寫,後面會找時間回來繼續寫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章