一般情況下,應用程式使用的記憶體空間裡有以下“預設”的區域:
1)棧:用於維護函式呼叫的上下文,離開了棧函式呼叫就沒法實現。棧通常在使用者空間的最高地址處分配,通常有數兆位元組的大小;
2)堆:用來容納應用程式動態分配的記憶體區域,當程式使用malloc或new分配記憶體時,得到的記憶體來自堆裡。堆通常存在於棧的下方(低地址方向),在某些時候,堆也可能沒有固定統一的儲存區域,堆一般比棧大很多,可以有幾十到數百兆位元組的容量;
3)可執行檔案映像:儲存著可執行檔案在記憶體中的映像。由裝載器在裝載時將可執行檔案的記憶體讀取或對映到這裡。
4)保留區:這並不是一個單一的記憶體區域,而是對記憶體中受到保護而禁止訪問的記憶體區域的總稱,例如,大多數作業系統裡,極小的地址通常都是不允許訪問的,如NULL。通常C語言將無效指標賦值為0也是出於這個考慮,因為0地址上正常情況下不可能有有效的可訪問資料。
在Linux下,如果可執行檔案依賴其他共享庫,那麼系統就會為它在從0x40000000開始的地址分配相應的空間,並將共享庫載入該空間。在Linux中,棧向低地址方向增長,堆向高地址方向增長。
棧:
在經典的作業系統中,棧總是向下增長的。在i386下,棧頂由稱為esp的暫存器進行定位。壓棧操作使棧頂地址減小,彈出操作使棧頂地址增大,即棧的生長方向由高地址到低地址。
棧在程式設計中具有舉足輕重的地位,棧儲存了一個函式呼叫所需要的維護資訊,這常常被稱為堆疊幀(Stack Frame)或活動記錄(Active Record)。堆疊幀一般包括如下幾方面的內容:
1)函式的返回地址和引數;
2)臨時變數:包括函式的非靜態區域性變數以及編譯器自動生成的其他臨時變數;
3)儲存的上下文:包括在函式呼叫前後需要保持不變的暫存器;
在i386中,一個函式的活動記錄用ebp和esp這兩個暫存器劃定範圍。esp暫存器始終指向棧的頂部,同時也指向了當前函式的活動記錄的頂部,而相對地,ebp暫存器指向了函式活動記錄的一個固定位置,ebp暫存器又被稱為幀指標(Frame Pointer)。
一個i386下的函式總是這樣呼叫的:
1)把所有或一部分引數壓入棧中,如果有其他引數沒有入棧,那麼使用某些特定的暫存器傳遞;
2)把當前指令的下一條指令的地址壓入棧中;
3)跳轉到函式體執行。
i386函式體的標準開頭是這樣的:
1)push ebp:把ebp壓入棧中(稱為old ebp);
2)mov ebp, esp:ebp=esp(這時ebp指向棧頂,而此時棧頂就是old ebp);
3)【可選】sub esp, XXX:在棧上分配XXX位元組的臨時空間;
4)【可選】push YYY:如有必要,儲存名為YYY的暫存器(可重複多個);
把ebp壓入棧中,是為了在函式返回時便於恢復以前的ebp值;之所以可能要儲存一些暫存器,在於編譯器可能要求某些暫存器在呼叫前後保持不變。
在函式返回時,所進行的標準結尾與標準開頭正好相反:
1)【可選】pop YYY:如有必要,恢復儲存過的暫存器(可重複多個);
2)mov esp ebp:恢復esp同時回收區域性變數空間;
3)pop ebp:從棧中恢復儲存的ebp的值;
4)ret:從棧中取得返回地址,並跳轉到該位置。
我們在VC下除錯程式時,常常會看到一些沒有初始化的變數或記憶體區域的值是“燙”,這是因為分配的棧空間的每一位元組都被初始化為0xCC,而0xCCCC(即兩個連續排列的0xCC)的漢字編碼就是“燙”,所以0xCCCC如果被當作文字看待就是“燙”。將未初始化資料設定為0xCC的理由是這樣可以有助於判斷一個變數是否沒有初始化。如果一個指標變數的值是0xCCCCCCCC,那麼我們就可以基本相信這個指標沒有經過初始化。當然,有時編譯器還會使用0xCDCDCDCD作為未初始化標記,此時我們就會看到漢字“屯屯”。
堆:
Windows的程式將地址空間分配給了各種EXE、DLL檔案、堆、棧。其中EXE檔案一般位於0x00400000起始的地址;而一部分DLL位於0x10000000起始的地址,如執行庫DLL;還有一部分DLL位於接近0x80000000的位置,如系統DLL,NTDLL.dll,Kernel32.dll。棧的位置則在0x00030000和EXE檔案後面都有分佈,這是因為Windows中每個執行緒的棧都是獨立的,一個程式中有多少個執行緒,就應該有多少個對應的棧,每個執行緒預設的棧大小是1MB,線上程啟動時,系統會為它在程式地址空間中分配相應的空間作為棧,執行緒棧的大小由函式CreateThread函式指定。
在分配完上面這些地址後,Windows的地址空間已經是支離破碎了,當程式向系統申請堆空間時,就只好從這些剩下的還沒有被佔用的地址空間上分配了。Windows提供VirtualAlloc()函式來向系統申請空間,事實上,VirtualAlloc()申請的空間不一定只用於堆,它僅僅是向系統預留了一塊虛擬地址,應用程式可以按照需要隨意使用。
在使用VirtualAlloc()函式申請空間時,系統要求空間大小必須是頁的整數倍,即對於x86系統來說,必須有是4096位元組的整數倍。顯然,這將會造成記憶體碎片。此時Windows為我們提供了一個更合理的分配演算法,這個演算法實現位於堆管理器(Heap Manager)中,堆管理器提供了一套與堆相關的API用於建立、分配、釋放和銷燬堆空間:
HeapCreate、HeapAlloc、HeapFree、HeapDestroy(詳見:http://blog.csdn.net/ACE1985/archive/2010/07/25/5764917.aspx)
堆管理器實際上存在於Windows的兩個位置,一份是位於NTDLL.DLL中,這個DLL是Windows作業系統使用者層的最底層DLL,它負責Windows子系統DLL與Windows核心之間的介面;而在Windows核心Ntoskrnl.exe中,還存在一份類似的堆管理器,它負責Windows核心的堆空間分配,核心堆管理器的介面都是由RtlHeap開頭的。
堆分配演算法:如何管理一大塊連續的記憶體空間,如何能夠按照需求分配空間,如何釋放已申請的空間。
1)空閒連結串列:
實際上就是把堆中各個空閒的塊按照連結串列的方式連線起來,當使用者請求一塊空間時,可以遍歷連結串列,直到找到合適大小的塊並且將它拆分;當使用者釋放空間時將它合併到空閒連結串列中。
空閒連結串列是這樣一種結構,在堆裡的每一個空閒空間的開頭有一個頭(header),頭結構裡記錄了上一個(prev)和下一個(next)空閒塊的地址,所有空閒塊形成了一個連結串列。
2)點陣圖:
核心思想是將整個堆劃分為大量的塊,每個塊的大小相同。當使用者申請記憶體時,總是分配整數個塊的空間給使用者,第一個塊我們稱為已分配區域的頭(head),其餘的稱為已分配區域的主體(body)。而我們可以使用一個整數陣列來記錄塊的使用情況,由於每個塊只有頭/主體/空閒三種狀態,因此僅僅需要兩位即可表示一個塊,如用11表示Head,10表示Body,00表示Free;因此稱為點陣圖。
3)物件池:
如果每一次分配的空間大小是一樣的,那麼就可以按照這個每次請求分配的大小作為一個單位,將整個堆空間劃分為大量的小塊,每次請求的時候只需要找到一個小塊就可以了。物件池的管理方法可以採用空閒連結串列,也可以採用點陣圖,與它們的區別僅僅在於它假定了每次請求的都是一個固定的大小。