1. 程式人生 > >Linux函式呼叫與棧

Linux函式呼叫與棧

原文地址:http://blog.csdn.NET/slvher/article/details/8831885

棧與函式呼叫慣例(又稱呼叫約定)— 基礎篇

        記得一年半前參加百度的校招面試時,被問到函式呼叫慣例的問題。當時只是懂個大概,比如常見函式呼叫約定型別及對應的引數入棧順序等。最近看書過程中,重新回顧了這些知識點,對整個呼叫棧又有了較深入的理解。作為筆記,記錄於此。

        NOTICE:本文筆記以32位Linux系統為背景,可能與Windows作業系統的底層機制有些小差異(比如程序虛擬空間的佈局),但總的來說,原理是相通的。

1. 程序虛擬地址空間

         猶記得當年一個困擾了自己很長時間的問題:“我的機器實體記憶體只有1G,為什麼很多資料提到32位系統下,每個程序都擁有4G的地址空間”?更不理解的是“為啥各程序的4G地址空間還相互獨立,不會衝突”?對於非計算機科班出身、當時沒學過作業系統的學生來說,實在是難以理解這其中的奧妙。後來讀到Andrew.S.Tanenbaum教授寫的《Modern Operating Systems》一書,才有種被醍醐灌頂、豁然開朗的感覺,原來都是作業系統在背後“作怪”!在此建議對這部分概念有疑問的同學去讀經典的作業系統教材,可以少走很多彎路。

         程序虛擬空間看似與本文主題無關,其實不然,若不對程序空間建立整體概念,理解函式呼叫棧容易陷入“只見樹木不見森林”的困境。這些都是我自學過程中的體會,因此廢話有點多,下面開始切入正題。

       Linux系統中,程序的虛擬地址空間典型佈局如下圖所示。

                           

          由上圖可知,32位平臺中,程序虛擬地址範圍為0x00000000-0xFFFFFFFF(共4GB),其中0x00000000-0xBFFFFFFF(共3GB)為使用者空間,位於高地址部分的1GB為核心空間,範圍為0xC0000000-0xFFFFFFFF。整個程序虛擬地址可分為幾個部分,下面從地址從低到高的方向進行說明:

       1)保留區

        它並不是一個單一的記憶體區域,而是對地址空間中受到作業系統保護而禁止使用者程序訪問的地址區域的總稱。大多數作業系統中,極小的地址通常都是不允許訪問的,如NULL。C語言將無效指標賦值為0也是出於這種考慮,因為0地址上正常情況下不會存放有效的可訪問資料。

        2)程式碼和只讀資料區

        對於所有程序來說,程式碼都是從同一固定地址開始,如Linux系統通常從0x08048000開始程式碼段(如前所述,從地址0到程式碼段起始地址的部分通常為作業系統保留區)。程式碼及只讀資料區是直接按照可執行目標檔案的內容初始化的,與目標檔案中的程式碼段(.text)、初始化段(.init)及只讀資料段(.rodata)相對應。

        3)可讀/寫資料區

        可執行檔案中的資料被對映至該區,包括.data和.bss。想進一步理解.data/.bss區別的同學,可查閱其它資料(比如這裡),此處略過。

        4)堆

        程式碼和資料區往上是執行時堆。與程式碼/資料段在程式載入時就確定了大小不同,堆可以在執行時動態擴充套件或收縮。呼叫如malloc/free、new/delete這樣的庫函式時,操作的記憶體區域就在堆中。堆的範圍通常較大,如在32位Linux系統中,堆的上限理論值可以達到2.9GB。

        5)共享庫

        該區域用於對映可執行檔案用到的動態連結庫。在Linux 2.4版本中,若可執行檔案依賴共享庫,則系統會為這些動態庫在從0x40000000開始的地址分配相應空間,並在程式裝載時將其載入到該空間。在Linux 2.6核心中,共享庫的起始地址被往上移動至更靠近棧區的位置(見下文加粗部分的特別說明)。

        6)棧

        棧用於維護函式呼叫的上下文,編譯器用棧來實現函式呼叫。跟堆一樣,使用者棧在程式執行期間可以動態擴充套件和收縮。與堆相比,棧通常較小,典型值為數MB。

        7)核心空間

        核心總是駐留在記憶體中,是作業系統的一部分。核心空間就是為核心保留的,不允許應用程式讀寫這個區域的內容或直接呼叫核心程式碼定義的函式。32位Linux系統中,預設將高地址的1GB分配為核心空間;而Windows預設將高地址的2GB分配為核心空間,當然,也可以配置為1GB。

        需要特別說明的問題:

        從程序地址空間的佈局可以看到,在有共享庫的情況下,留給堆的可用空間還有兩處:一處是從.bss段到0x40000000,約不到1GB的空間;另一處是從共享庫到棧之間的空間,約不到2GB。這兩塊空間大小取決於棧、共享庫的大小和數量。這樣來看,是否應用程式可申請的最大堆空間只有2GB?事實上,這與Linux核心版本有關。在上面給出的程序地址空間經典佈局圖中,共享庫的裝載地址為0x40000000,這實際上是Linux kernel 2.6版本之前的情況了,在2.6版本里,共享庫的裝載地址已經被挪到靠近棧的位置,即位於0xBFxxxxxx附近,因此,此時的堆範圍就不會被共享庫分割成2個“碎片”,故kernel 2.6的32位Linux系統中,malloc申請的最大記憶體理論值在2.9GB左右。

        2. IA32的通用暫存器組

        函式呼叫棧的實現與CPU的暫存器組密切相關,因此,有必要做簡單介紹。

        Intel 32位體系結構(簡稱IA32)的CPU包含一組通用暫存器,由8個32-bit暫存器構成,如下圖所示。

                  

         在最初的8086中,暫存器是16-bit的,每個都有特殊用途,這些暫存器的名字就是反映這些不同的用途。由於IA32平臺採用了平坦定址模式,其對特殊暫存器的需求大大降低,但由於歷史原因,這些暫存器的名稱就這樣保留下來。在大多數情況下,上圖所示的前6個暫存器均可作為通用暫存器使用。之所以說“大多數情況”,是因為有些指令以固定的暫存器作為源暫存器或目的暫存器(如一些特殊的算術操作指令imull/mull/cltd/idivl/divl要求一個引數必須在%eax中,其運算結果存放在%edx(higher 32-bit)和%eax (lower32-bit)中;又如函式返回值通常儲存在%eax中)。剩下兩個暫存器(%ebp和%esp)在函式棧中起著重要作用,具體內容後面介紹函式棧時會做說明。

        對於暫存器%eax, %ebx, %ecx和%edx,各自可被作為2個獨立16-bit的暫存器使用,而對於其中的lower 16-bit暫存器,還可以繼續分為2個獨立8-bit的暫存器使用。編譯器會根據運算元的大小選擇合適的暫存器來生成彙編碼。在組合語言層面,這組通用暫存器以%e(AT&T syntax)或直接以e(Intel syntax)開頭來引用,例如mov $5, %eax或mv eax, 5是指將立即數5賦值給register %eax。關於兩種主流assembly在語法上的區別,可參見wikipedia相關詞條

        本文介紹了兩部分基礎知識,為理解棧與函式呼叫慣例做鋪墊,正篇內容請見下篇筆記。^_^

=================== EOF ==================


棧與函式呼叫慣例(又稱呼叫約定)— 正篇

       在前篇筆記的基礎上,本文繼續介紹棧與函式呼叫約定的相關內容。

1. 函式呼叫的棧幀結構

        IA32程式用棧來實現函式呼叫。機器用棧來傳遞函式引數、儲存返回地址、儲存暫存器(即函式呼叫的上下文)及儲存本地區域性變數等。為單個函式呼叫分配的那部分棧稱為棧幀(stack frame),棧幀的邊界由2個指標界定:暫存器%ebp為幀指標(嚴謹的說法是,幀指標存放在%ebp中),指向當前棧幀的起始處,通常較固定;暫存器%esp為棧指標,指向當前棧幀的棧頂位置,當程式執行時,棧指標可以移動,因此大多數資料的訪問都是相對於幀指標的。

        下圖給出了棧幀的通用結構。

                             

        結合前篇筆記介紹的程序虛擬地址空間和上圖的棧幀結構,我們可以看到,在經典的作業系統(如各種類UNIX作業系統)中,棧總是向下增長的,壓棧(push)時棧頂地址減小,彈棧(pop)時棧頂地址增大。另外還可以注意到,堆通常是向上增長的,但在Windows系統中,大部分堆由HeapCreate()產生,而HeapCreate系列函式卻完全不遵照堆向上增長的規律。

       下面根據函式呼叫中典型的棧幀結構,對函式呼叫棧的實現過程做如下描述:

        若函式P(呼叫者caller)呼叫函式Q(被呼叫者,callee),則Q的引數存放在P的棧幀中(在對應的彙編程式碼中,表現為在跳轉至callee startaddress之前,通過push指令將函式Q所需的引數依次壓入棧中,壓棧順序與本文主題—函式呼叫約定有關,具體實現過程大家看到後面的彙編程式碼就會清楚)。另外,當P呼叫Q時,P中的返回地址被壓入棧中(call指令實現返回地址壓棧並跳轉至callee入口地址處),形成P的棧幀末尾。返回地址其實就是P中呼叫Q的指令執行完後下一條將要執行的指令地址。接著,需要儲存P的幀指標(用於從Q返回時恢復P的棧幀結構)並將P棧幀當前的棧頂地址值(存放在%esp中,是P的棧幀邊界之一)裝入幀指標暫存器%ebp,Q的棧幀邊界即從該%ebp開始,可見,經過這樣的操作流程,當前的%ebp指向的位置既是P棧幀的結束邊界,又是Q棧幀的開始邊界。接著是儲存其它暫存器的值。最後開始真正執行函式Q包含的功能指令。

        對上段讓人眼暈的關於函式呼叫棧實現的描述做個總結:

       1)將被呼叫函式的引數壓棧(注:在x86_64平臺中,CPU擁有16個通用64-bit暫存器,故呼叫函式時,前6個引數通常由暫存器來傳遞,剩下的才通過棧傳遞)

        2)將當前指令,即函式呼叫指令的下一條指令地址作為返回地址壓入棧中

        3)跳轉到函式體執行

        其中,第2、3步由call指令來實現。跳轉到函式體後,該函式體的開始指令通常是這樣的:

        1)push %ebp :將呼叫者的幀指標%ebp壓棧,即儲存舊棧幀的幀指標以便函式返回時恢復舊棧幀

        2)mov %esp, %ebp:將當前棧頂地址傳給%ebp,此時,%ebp既是舊棧幀的結束地址,又是被呼叫者的新棧幀的起始地址

        3)sub xxx, %esp:將棧頂下移,即為被呼叫函式開闢棧空間,xxx為立即數且通常為16的整數倍(這會浪費一些空間,但gcc採用該規則來保證資料的嚴格對齊)

        4)push xxx:該命令為可選項,如有必要,由被呼叫者負責儲存/恢復某些暫存器

        不難推匯出函式返回時通常由如下指令序列構成:

        1)pop xxx:可選項,與進入函式時是否push xxx儲存暫存器相對應

        2)mov %ebp, %esp:恢復呼叫者的棧頂指標,即呼叫者棧幀的結束邊界

        3)pop %ebp:呼叫者的幀指標彈棧,即恢復呼叫者棧幀的起始邊界

        4)ret:從棧中得到返回地址,並跳轉到該位置處繼續執行

        注意:函式退出前指令序列的第2、3步也可由指令leave來實現,具體用哪種方式,由編譯器決定。

       上面給出的是函式呼叫時,典型的進入/退出指令序列,某些情況下,編譯器生成的指令序列並不按照上面的方式進行。例如若C函式被宣告為static(只在本編譯單元內可見)且函式在編譯單元內被直接呼叫,沒有被顯示或隱式取地址(即沒有任何函式指標指向該函式),在這種情況下,編譯器確信該函式不會被其它編譯單元呼叫,因此可以隨意修改其進/出指令序列以達到優化的目的。

2. 暫存器使用約定

        從前面介紹的函式進入/退出指令序列,已經看到,在函式呼叫過程中,有些暫存器是由被呼叫者負責儲存/恢復的。在正式介紹函式呼叫慣例之前,有必要先對暫存器使用慣例做個說明。

        程式暫存器組是唯一能被所有函式共享的資源,雖然在給定時刻只有一個函式是活動的,但我們必須保證當某個函式(caller)呼叫另一個函式(callee)時,callee不會覆蓋caller稍後會使用到的暫存器值。為此,IA32採用了一套統一的暫存器使用慣例,所以的函式(包括庫函式)呼叫都必須遵守。

        根據慣例,暫存器%eax、%edx和%ecx為呼叫者儲存暫存器(caller-saved registers,當函式P呼叫Q時,Q可以覆蓋這些暫存器,而不會破壞任何P所需的資料。暫存器%ebx、%esi和%edi為被呼叫者儲存暫存器(callee-saved registers,即Q在覆蓋這些暫存器的值時,必須先把它們儲存到棧中,並在返回前恢復它們,因為P可能會用到這些值。此外,根據慣例,必須保持暫存器%ebp和%esp,即函式返回後,這兩個暫存器必須恢復到呼叫前的值,換句話說,必須恢復呼叫者的棧幀。

        只要各函式呼叫遵守上述慣例,就可以正常工作。

3. 函式呼叫慣例(calling convention,又稱呼叫約定)

        前面介紹的一系列基礎知識都是為能徹底搞清楚本節內容,下面開始切入正題。目前,我們已經掌握了函式呼叫時函式棧的實現原理及暫存器的使用慣例,在這些知識的支撐下,理解函式呼叫約定變得相當容易。

        根據前面的分析我們已知,編譯器根據一套簡單的慣例來產生棧結構程式碼。引數在棧上傳遞給函式,callee可以從棧中用相對於%ebp的正偏移量(+8,+12,…)來訪問引數。可以用push指令或棧指標下移(sub一個立即數)為callee分配棧空間。在callee返回前,函式必須將棧恢復到呼叫前的狀態(通過恢復所有的被呼叫者儲存暫存器和%ebp且重置%esp使其指向返回地址來實現)。為保證程式能正確執行,讓所有函式呼叫都遵守一套建立/恢復棧幀的一致慣例非常重要。

        一個函式呼叫慣例一般會規定如下幾方面內容:

       1)函式引數的傳遞順序和方式

        函式引數傳遞方式由多種,最常見的是通過棧傳遞。caller將引數壓入棧中,callee從棧中將引數取出。對於有多個引數的函式,呼叫慣例要規定caller將引數壓棧的順序(從左至右還是從右至左)。有些呼叫慣例還允許使用暫存器傳參以提高效能。

        2)棧的維護方式

        在caller將引數壓棧後,callee的函式體會被呼叫,返回時需要將被壓棧的引數全部彈出,以便保持棧在函式呼叫前後的一致。這個彈棧過程可以由caller負責完成,也可由callee負責完成。

        3)名字修飾(Name-mangling)策略

        為了連結時對呼叫慣例進行區分,呼叫慣例要對函式本身的名字做修飾。不同的呼叫慣例有不同的名字修飾策略。幾種主要的函式呼叫慣例總結如下:

         

        此外,不少編譯器還提供一種稱為naked call的呼叫慣例,這種呼叫慣例特點是編譯器不產生任何儲存暫存器的程式碼,故稱為naked call,用於一些特殊場合。

        對C++而言,以上幾種呼叫慣例的名字修飾策略有所改變,因為C++支撐函式過載、名稱空間和成員函式等新語法特徵,因此,一個函式名可以對應多個函式定義,上面提到的名字修飾策略顯然無法區分各個不同同名函式定義,故C++有一套更加複雜的名字修飾策略。此外,C++還有一種特殊的呼叫慣例,稱為thiscall,專用於類成員函式的呼叫。其特點隨編譯器不同而不同,對於gcc,thiscall和cdecl完全一樣,只是將this看做函式的第1個引數;而在VC中,this指標存放在%ecx中,引數從右至左壓棧。

4. 函式返回值傳遞

        除引數傳遞外,函式與呼叫方還可以通過返回值進行互動。對於函式返回值的實現細節,有以下幾條原則:

       1)對於小於4位元組的返回值,呼叫慣例通常將其存在%eax中,呼叫方通過讀取%eax獲取返回值。

       2)對於返回值為5-8位元組物件的情況,幾乎所有的呼叫約定都採用%eax和%edx聯合返回的方式進行,其中%edx儲存返回值的高4位元組,%eax儲存返回值的低4位元組。

       3)對於超過8位元組的返回型別,實現方式稍微複雜,可以總結為:a. 呼叫者在棧上額外開闢空間並將該空間的一部分作為傳遞返回值的臨時物件,這裡稱為temp; b. 將temp物件地址作為隱藏引數傳遞給被呼叫的函式;c. 被呼叫函式將待返回資料拷貝給temp物件並將temp物件的地址存入%eax;d. 被呼叫函式返回後,呼叫者將%eax指向的temp物件拷貝給接收返回值的物件。

        可見,如果返回值型別的size太大,C語言在函式返回時會使用一個臨時的棧上記憶體區域作為中轉,結構返回值物件會被拷貝兩次。因此,不到萬不得已,不要輕易返回size較大的物件,我們可以通過傳入相應指標來接收返回值的方法來代替函式直接返回大物件。

        此外還要注意,函式傳遞大size返回值所使用的方法是不可移植的,不同的編譯器、不同的平臺、不同的呼叫慣例甚至不同的編譯引數都可能採用不同的實現方法。

        關於大size返回值的實現細節分析,要涉及到較大篇幅的彙編程式碼分析,本文不再展開。感興趣或想深究的同學,建議閱讀《程式設計師的自我修養—連結、裝載與庫》一書10.2小節的內容。

【參考資料】

1. Computer Systems: A Programmer’sPerspective, 2E. 第3章

2. 程式設計師的自我修養—連結、裝載與庫. 第10章


================ EOF =================