程式的記憶體布局——函數調用棧的那點事,記憶體布局函數調用
[注]此文是《程式員的自我修養》的讀書總結,其中摻雜著一些個人的理解,若有不對,歡迎拍磚。
程式的記憶體布局
現代的應用程式都運行在一個虛擬記憶體空間裡,在32位的系統裡,這個記憶體空間擁有4GB的定址能力。現代的應用程式可以直接使用32位的地址進行定址,整個記憶體是一個統一的地址空間,使用者可以使用一個32位的指標訪問任意記憶體位置。
在進程的不同地址區間上有著不同的地位,Windows在預設情況下會將高地址的2GB空間分配給核心,而Linux預設將高地址的1GB空間分配給核心,具體的記憶體布局如:
(1)代碼區:這個地區儲存著被裝入執行的二進位機器代碼,處理器會到這個地區取指並執行。
(2)資料區:用於儲存全域變數、常量。
(3)堆區:進程可以在堆區動態地請求一定大小的記憶體,並在用完之後歸還給堆區。動態分配和回收是堆區的特點。
(4)棧區:用於動態地儲存函數之間的關係,以保證被調用函數在返回時恢複到母函數中繼續執行。
進階語言寫出的程式經過編譯連結,最終會變成可執行檔。當可執行檔被裝載運行後,就成了所謂的進程。
可執行檔程式碼片段中包含的二進位層級的機器代碼會被裝入記憶體的代碼區(.text);
處理器將到記憶體的這個地區一條一條地取出指令和運算元,並送入運算邏輯單元進行運算;
如果代碼中請求開闢動態記憶體,則會在記憶體的堆區分配一塊大小合適的地區返回給代碼區的代碼使用;
當函數調用發生時,函數的調用關係等資訊會動態地儲存在記憶體的棧區,以供處理器在執行完被調用函數的代碼時,返回母函數。
如果把電腦看成一個有條不紊的工廠,我們可以得到如下類比:
* CPU是幹活的工人。
* 資料區、堆區、棧區等則是用來存放原料、半成品、成品等各種東西的場所。
* 存放在代碼區的指令則告訴CPU要做什麼,怎麼做,到哪裡去領原材料,用什麼工具來做,做完以後把成品放到哪個貨倉去。
棧
在經典的作業系統裡,棧總是向下增長的。棧頂由esp寄存器定位。壓棧操作使棧頂的地址減小,彈出操作使棧頂地址增大。
當函數調用的時候發生了什嗎?
例如:
int main(void){foo(1,2,3) ;return 0 ;}
當方法main需要調用foo時,它的標準行為:
1、在main方法的調用棧中,將 foo的參數從右向左 依次push到棧中。
2、把main方法當前指令的 下一條指令地址 (即return address)push到棧中。(隱藏在call指令中)
3、使用call指令調用目標函數體foo。
請注意,以上3步都處於main的調用棧,其中ebp儲存其棧底,而esp儲存其棧頂。
接下來,在foo函數中:
1、push ebp: 將ebp的當前值push到棧中,即儲存ebp。
2、mov ebp,esp: 將esp的值賦給ebp,則意味著進入了foo方法的調用棧。
3、[可選]sub esp, XXX: 在棧上分配XXX位元組的臨時空間。(抬高棧頂)(編譯器根據函數中的局部變數的總大小確定臨時空間的大小)
4、[可選]push XXX: 儲存(push)一些寄存器的值。
【注意:push寄存器的值,這一操作,可以在分配臨時空間之前,也可在其之後,《程式員的自我修養》寫的是在開闢臨時變數之後】
(編譯器中儲存的有相應的變數名對應的臨時空間中的位置)
而在foo方法調用完畢後,便執行前面階段的逆操作:
1、儲存返回值: 通常將函數的返回值儲存在寄存器eax中。
2、[可選]恢複(pop)一些寄存器的值。
3、mov esp,ebp: 恢複esp同時回收局部變數空間。(恢複原棧頂)
4、pop ebp: 將棧頂的值賦給ebp,即恢複main調用棧的棧底。(恢複原棧底)
5、ret: 從棧頂獲得之前保留的return address,並跳轉到此位置繼續執行。
main方法先將foo方法所需的參數壓入棧中,然後再改變ebp,進入foo方法的調用棧。
因此,如果在foo方法中需要訪問那些參數,則需要根據當前ebp中的值,再向高地址位移後進行訪問——因為高地址才是main方法的調用棧。
也就是說,地址ebp + 8存放了foo方法的第1個參數,地址ebp + 12存放了foo方法的第2個參數,以此類推。那麼地址ebp + 4存放了什麼呢?它存放的是return address,即foo方法返回後,需要繼續執行下去的main方法指令的地址。
【注意】
若需在函數中儲存被調函數儲存寄存器(如ESI、EDI),則編譯器在儲存EBP值時進行儲存,或延遲儲存直到局部變數空間被分配。在棧幀中並未為被調函數儲存寄存器的空間指定標準的儲存位置。
【註:幾個相關的寄存器(關於詳細的介紹,見王爽彙編)】
(1)esp:棧指標寄存器(extended stack pointer),其記憶體放著一個指標,該指標永遠指向系統棧最上面一個棧幀的棧頂。
(2)ebp:基址指標寄存器(extended base pointer),其記憶體放著一個指標,該指標永遠指向系統棧最上面一個棧幀的底部。(ebp在當前棧幀內位置固定,故函數中對大部分資料的訪問都基於ebp進行)
(3)eip:指令寄存器(extended instruction pointer),其記憶體放著一個指標,該指標永遠指向下一條等待執行的指令地址。 可以說如果控制了EIP寄存器的內容,就控制了進程——我們讓eip指向哪裡,CPU就會去執行哪裡的指令。eip可被jmp、call和ret等指令隱含地改變(事實上它一直都在改變)(ret指令就是把當前棧頂儲存的返回值地址 彈到eip中)
函數棧幀的大小並不固定,一般與其對應函數的局部變數多少有關。函數運行過程中,其棧幀大小也是在不停變化的。
調用慣例
函數的調用方和被呼叫者對於函數如何調用需要遵守同樣的約定,函數才能被正確地調用,這樣的約定稱為**調用慣例**。
* 函數參數的行程順序和方式
調用慣例要規定參數壓棧的順序:是從左至右,還是從右至左。有些調用慣例還允許使用寄存器傳遞參數,以提高效能。
* 棧的維護方式
(誰負責彈出形參?)
在被調函數返回時,需要將被壓入棧中的參數全部彈出,以使得棧在函數調用前後保持一致。這個彈出的工作可以由函數的調用方完成,也也可以由被函數完成。
* 名字修飾規則
為了連結的時候對調用慣例進行區分,調用慣例要對函數本身的名字進行修飾,不同的調用慣例有不同的名字修飾策略。
| 調用慣例 |
誰彈形參 |
參數壓棧方向 |
名字修飾 |
| cdecl |
調用方 |
從右至左 |
底線+函數名 |
| stdcall |
被調方 |
從右至左 |
底線+函數名@參數位元組數 |
| pascal |
被調方 |
從左至右 |
較複雜 |
| fastcall |
被調方 |
頭兩個參數放入寄存器,其它從右至左 |
@函數名字名@參數位元組數 |
_cdecl
是CDeclaration的縮寫,表示C語言預設的函數調用方法:所有參數從右至左依次入棧,這些參數由調用者清除,稱為手動清棧。被調用函數無需要求調用者傳遞多少參數,調用者傳遞過多或者過少的參數,甚至完全不同的參數都不會產生編譯階段的錯誤。(典型的如printf函數)
_stdcall
是Standard Call的縮寫,是C++的標準調用方式:所有參數從右至左依次入棧。這些堆棧中的參數由被調用的函數在返回後清除,使用的指令是 retn X,X表示參數佔用的位元組數,CPU在ret之後自動彈出X個位元組的堆棧空間。稱為自動清棧。函數在編譯的時候就必須確定參數個數,並且調用者必須嚴格的控制參數的產生,不能多,不能少,否則返回後會出錯。
幾乎我們寫的每一個WINDOWS API函數都是_stdcall類型的,因為不同的編譯器產生棧的方式不盡相同,調用者不一定能正常的完成清除工作。如果使用_stdcall,上面的問題就解決了,函數自己解決清除工作。所以,在跨平台的調用中,我們都使用_stdcall(雖然有時是以WINAPI的樣子出現)。
但當我們遇到這樣的函數如printf()它的參數是可變的,不定長的,被調用者事先無法知道參數的長度,事後的清除工作也無法正常的進行,因此,這種情況我們只能使用\_cdecl。到這裡我們有一個結論,如果你的程式中沒有涉及可變參數,最好使用_stdcall關鍵字。
函數返回值傳遞
一般情況下,寄存器eax是傳遞返回值的通道,函數將返回值儲存在eax中,返回後函數的調用方再讀取eax。
但是eax本身只有4位元組,那麼大於4位元組的返回值是如何傳遞的呢?
對於返回5~8位元組資料的情況,一般採用eax和edx聯合返回的方式進行的。其中eax儲存返回值的低4位元組,edx儲存返回值的高4位元組。
對於超過8位元組的傳回型別:
typedef struct big_thing{char buf[128] ;} big_thing ;big_thing return_test();//---------------------------int main(void){big_thing n = return_test() ;}big_thing return_test(){big_thing b ;b.buf[0] = 0 ;return b ;}分析這段代碼:
首先,在主調函數main中,肯定有一個128位元組的變數n,在被調函數return_test中,肯定有一個128位元組的變數b。
那被調函數如何返回128位元組的變數?直接從b拷貝到n嗎?你這樣直接改變主調函數中變數的值,似乎不符合返回值傳值的規則。
那麼實際上,編譯器是怎麼設計大尺寸返回值傳遞的呢?
* main函數在其棧中的局部變數地區中額外開闢一片空間,將其一部分作為傳遞返回值的臨時對象temp。
* 將temp對象的地址作為隱藏參數傳遞給return_test函數。
* return_test函數將資料拷貝給temp對象,並
將temp對象的地址用eax傳出。
* return_test返回後,main函數將eax指向的
temp對象的內容拷貝給n。
(return_test是沒有真正的參數的,只有一個“偽參數”由函數的調用方悄悄傳入)
【總結】
函數返回值的傳遞:小於8位元組的返回值,以**寄存器**為中轉。大於8位元組的,以主調函數中新開闢的同樣大小的
中間變數temp為中轉。
C語言對於尺寸太大的返回值類型,會使用一個臨時的棧上記憶體地區作為中轉,結果返回值對象會被拷貝兩次。故不到萬不得已,不要輕易返回大尺寸對象。
C++函數的返回值傳遞
C++處理大返回值略有不同,其可能是像C那樣,1次拷貝到棧上的臨時對象裡,然後把臨時對象拷貝到儲存返回值的對象裡。
但,有些編譯器會進行返回值最佳化RVO(Return Value Optimization),這樣,對象拷貝會減少一次,即沒有臨時對象temp了,直接拷貝到主調函數的相應對象中。
例如:
#include <iostream>using namespace std ;struct cpp_obj{ cpp_obj() { cout<< "ctor\n" ; } cpp_obj(const cpp_obj& c) { cout<< "copy ctor\n" ; } cpp_obj& operator=(const cpp_obj& rhs) { cout<< "operator=\n" ; return *this ; } ~cpp_obj() { cout<< "dtor\n" ; }} ;cpp_obj foo(){ cpp_obj b ; cout << "before foo return\n" ; return b ;}int main(){ cpp_obj n ; n = foo() ; cout << "before main return\n" ; return 0 ;}//---------運行結果---------ctorctorbefore foo returnoperator=dtorbefore main returndtor此例子是在g++下編譯運行。
此例就沒有設定一個臨時變數temp,而是直接把被調函數局部變數的值直接拷貝到主調函數中去。
NRV
C++對於返回值還有一種更“激進”的最佳化策略——NRV(Named Return Value)具名返回值最佳化
這種最佳化是甚至連被調函數中的局部變數都不要了!直接在主調函數中操作對象(根據隱藏參數傳入的對象的引用)。
關於NRV要注意兩點:(自己總結的,若有不對,請拍磚)
1、在被調函數foo中,其局部變數聲明處即是調用主調函數main中對象的預設建構函式處。main中的對象定義處,只是開闢一個空間,當時並不調用建構函式。
2、為何在主調函數中 CObj obj = foo() 會觸發NRV最佳化
而分開寫: CObj obj ; obj = foo() ; 沒有NRV最佳化呢?
因為:
程式員必須給class X定義拷貝建構函式才能觸發NRV最佳化,不然還是按照最初的較慢的方式執行。(我們的第二種方式沒有涉及到拷貝建構函式,故不會觸發NRV最佳化)
但現在的編譯器即使去掉類中的拷貝建構函式,也一樣會有NRV最佳化,但必須是向在對象初始化時調用子函數才會有NRV。
(若沒有NRV最佳化,則被調函數中會產生局部對象,但這個局部對象直接拷貝到主函數相應的對象中,也不會像C那樣還要產生一個臨時變數)
若把上面的例子的調用方式改為: cpp_obj n = foo() ;
則會觸發NRV最佳化,執行結果就是:
//cpp_obj n = foo() ;改為:foo(n) ;//foo實際就被改為:void foo(cpp_obj& __result) { // 調用__result的預設建構函式 __result.cpp_obj::cpp_obj(); // 處理__result return; }//---------NRV後的運行結果---------ctorbefore foo returnbefore main returndtor(一定注意:只有CObj obj = foo();形式的調用才會有NRV最佳化!)
關於NRV最佳化詳細見《深入理解C++物件模型》
堆
堆是一塊巨大的記憶體空間,常常佔據整個虛擬位址空間的絕大部分。在這片空間裡,程式可以請求一塊連續記憶體,並自由地使用,這塊記憶體在程式主動放棄之前都會一直保持有效。在C語言中我們可以用malloc函數在堆上申請空間。
malloc的實現:
作業系統核心管理著進程的地址空間,它通過的有系統調用,若讓malloc調用這個系統調用實現申請記憶體,可完成這個工作。
但是,這樣做效能較差,因為每次進行申請釋放空間都需要進行系統調用,系統調用的開銷比較大,會進行核心態和使用者態的切換。
比較好的做法是程式向作業系統申請一塊適當大小的堆空間,然後由程式自己管理這塊空間,管理著堆空間分配的往往是程式的運行庫(一般是作業系統提供的共用庫)。
malloc實際上就是對這共用庫中函數的封裝。
"批發-零售"類比:
運行庫相當於是向作業系統批發了一塊較大的堆空間,然後零售給程式用。運行庫在向程式零售空間時,必須管理此空間,不能把一塊空間出售兩次。
當空間不夠用時,運行庫再向作業系統批發(調用OS相應的系統調用)。
注意:這個運行庫一般也是作業系統或語言提供給我們的,其包含了管理堆空間的演算法,其運行在使用者態下。
(我們自己也可以實現這個分配演算法,但常用的分配演算法已經被各種系統、庫實現了無數遍,沒有必要重複發明輪子)
每個進程在建立時都會有一個預設堆,這個堆在進程啟動時建立,並且直到進程結束都一直存在。在Windows中預設堆大小為1MB。
(注意:在Windows中堆不一定是向上增長的)
問:malloc申請的空間是不是連續的?
答:若“空間”指的是虛擬空間的話,那麼答案是連續的,即每一次malloc分配後返回的空間都可以看做是一塊連續的地址。(進程中可能存在多個堆,但一次能夠分配的最大堆空間取決於最大的那個堆)
如果空間值的是物理空間,則不一定連續,因為一塊連續的虛擬位址空間有可能是若干個不連續的物理頁拼湊成的。
堆空間管理演算法
* 1、空閑鏈表法
把堆中各個空閑塊按鏈表的方式串連起來,當使用者請求時遍曆鏈表找到合適的塊。
* 2、位元影像(這個思想好)
將整個堆劃分為大量的大小相同的塊。當使用者請求時分配整數個空間給使用者。我們可以用一個整數數組的位來記錄分配狀況。
(每個塊只有頭/使用/空閑三種狀態,即用兩個位就可表示一個塊,因此稱為位元影像。頭是用來標記定界的作用)