程序员的自我修养--链接、装载与库笔记:内存
1. 程序的內存布局
現代的應用程序都運行在一個內存空間里,在32位的系統里,這個內存空間擁有4GB(2的32次方)的尋址能力。應用程序可以直接使用32位的地址進行尋址,這被稱為平坦(flat)的內存模型。在平坦的內存模型中,整個內存是一個統一的地址空間,用戶可以使用一個32位的指針訪問任意內存位置。大多數操作系統都會將4GB的內存空間中的一部分挪給內核使用,應用程序無法直接訪問這一段內存,這一部分內存地址被稱為內核空間。Windows在默認情況下會將高地址的2GB空間分配給內核(也可配置為1GB),而Linux默認情況下將高地址的1GB空間分配給內核。
用戶使用的剩下2GB或3GB的內存空間稱為用戶空間。在用戶空間里,也有許多地址區間有特殊的地位,一般來講,應用程序使用的內存空間里有如下”默認”的區域:
(1). 棧:用于維護函數調用的上下文,離開了棧函數調用就沒法實現。棧通常在用戶空間的最高地址處分配,通常有數兆字節的大小。
(2). 堆:用來容納應用程序動態分配的內存區域,當程序使用malloc或new分配內存時,得到的內存來自堆里。堆通常存在于棧的下方(低地址方向),在某些時候,堆也可能沒有固定統一的存儲區域。堆一般比棧大很多,可以有幾十至數百兆字節的容量。
(3). 可執行文件映像:這里存儲著可執行文件在內存里的映像,由裝載器在裝載時將可執行文件的內存讀取或映射到這里。
(4). 保留區:并不是一個單一的內存區域,而是對內存中受到保護而禁止訪問的內存區域的總稱,例如,大多數操作系統里,極小的地址通常都是不允許訪問的,如NULL。通常C語言將無效指針賦值為0也是出于這個考慮,因為0地址上正常情況下不可能有有效的可訪問數據。
2. 棧與調用慣例
什么是棧(stack):幾乎每一個程序都使用了棧,沒有棧就沒有函數,沒有局部變量,也就沒有我們如今能夠看見的所有的計算機語言。在經典的計算機科學中,棧被定義為一個特殊的容器,用戶可以將數據壓入棧中(入棧,push),也可以將已經壓入棧中的數據彈出(出棧,pop),但棧這個容器必須遵守一條規則:先入棧的數據后出棧(First In Last Out, FIFO)。在計算機系統中,棧則是一個具有以上屬性的動態內存區域。程序可以將數據壓入棧中,也可以將數據從棧頂彈出。壓棧操作使棧增大,而彈出操作使棧減少。在經典的操作系統里,??偸窍蛳略鲩L的。壓棧的操作使棧頂的地址減少,彈出的操作使棧頂地址增大。
棧保存了一個函數調用所需要的維護信息,這常常被稱為堆棧幀(Stack Frame)或活動記錄(Activate Record)。堆棧幀一般包括如下幾方面內容:(1).函數的返回地址和參數;(2).臨時變量:包括函數的非靜態局部變量以及編譯器自動生成的其它臨時變量;(3).保存的上下文:包括在函數調用前后需要保持不變的寄存器。
在i386中,一個函數的活動記錄用ebp和esp這兩個寄存器劃定范圍。esp寄存器始終指向棧的頂部,同時也就是指向了當前函數的活動記錄的頂部。而相對的,ebp寄存器指向了函數活動記錄的一個固定位置,ebp寄存器又被稱為幀指針(Frame Pointer)。
在VC下調試程序的時候,常??吹揭恍]有初始化的變量或內存區域的值是”燙”。之所以會出現”燙”這么一個奇怪的字,就是因為Debug模式,將所有的分配出來的??臻g的每一個字節都初始化為0xCC。0xCCCC(即兩個連續排列的0xCC)的漢字編碼就是燙,所以0xCCCC如果被當作文本就是”燙”。將未初始化數據設置為0xCC的理由是這樣可以有助于判斷一個變量是否沒有初始化。如果一個指針變量的值是0xCCCCCCCC,那么我們就可以基本相信這個指針沒有經過初始化。當然這個信息僅供參考,編譯器查未初始化變量的方法并不能以此為證據。有時編譯器還會使用0xCDCDCDCD作為未初始化標記,此時我們就會看到漢字”屯屯”。
鉤子(Hook)技術:允許用戶在某些時刻截獲特定函數的調用。
調用慣例(Calling Convention):函數的調用方和被調用方對于函數如何調用需要有一個明確的約定,只有雙方都遵守同樣的約定,函數才能被正確地調用,這樣的約定就稱為調用慣例。一個調用慣例一般會規定如下幾個方面的內容:
(1). 函數參數的傳遞順序和方式:函數參數的傳遞有很多種方式,最常見的一種是通過棧傳遞。函數的調用方將參數壓入棧中,函數自己再從棧中將參數取出。對于有多個參數的函數,調用慣例要規定函數調用方將參數壓棧的順序:是從左至右,還是從右至左。有些調用慣例還允許使用寄存器傳遞參數,以提高性能。
(2). 棧的維護方式:在函數將參數壓棧之后,函數體會被調用,此后需要將被壓入棧中的參數全部彈出,以使得棧在函數調用前后保持一致。這個彈出的工作可以由函數的調用方來完成,也可以由函數本身來完成。
(3). 名字修飾(Name-mangling)的策略:為了鏈接的時候對調用慣例進行區分,調用管理要對函數本身的名字進行修飾。不同的調用慣例有不同的名字修飾策略。事實上,在C語言里,存在著多個調用慣例,而默認的調用慣例是cdecl。任何一個沒有顯式指定調用慣例的函數都默認是cdecl慣例。_cdecl是非標準關鍵字,在不同的編譯器里可能有不同的寫法,例如在GCC里就不存在__cdecl這樣的關鍵字,而是使用__attribute__((cdecl))。
下圖介紹了幾項C語言主要的調用慣例的內容:
此外,不少編譯器還提供一種稱為naked call的調用慣例,這種調用慣例用在特殊的場合,其特點是編譯器不產生任何保護寄存器的代碼,故稱為naked call。對于C++語言,以上幾種調用慣例的名字修飾策略都有所改變,因為C++支持函數重載以及命名空間和成員函數等等,因此實際上一個函數名可以對應多個函數定義,那么上面提到的名字修飾策略顯然是無法區分各個不同同名函數定義的。所以C++自己有更加復雜的名字修飾策略。C++自己還有一種特殊的調用慣例,稱為thiscall,專用于類成員函數的調用。其特點隨編譯器不同而不同,在VC里是this指針存放于ecx寄存器,參數從右到左壓棧,而對于gcc,thiscall和cdecl完全一樣,只是將this看做是函數的第一個參數。
函數返回值傳遞:除了參數的傳遞之外,函數與調用方的交互還有一個渠道就是返回值。如果返回值類型的尺寸太大,C語言在函數返回時會使用一個臨時的棧上內存區域作為中轉,結果返回值對象會被拷貝兩次。因而不到萬不得已,不要輕易返回大尺寸的對象。函數傳遞大尺寸的返回值所使用的方法并不是可移植的,不同的編譯器、不同的平臺、不同的調用慣例甚至不同的編譯參數都有權利采用不同的實現方法。
在C++里返回一個對象的時候,對象要經過兩次拷貝構造函數的調用才能夠完成返回對象的傳遞。一次拷貝到棧上的臨時對象里,另一次把臨時對象拷貝到存儲返回值的對象里。在某些編譯器里,返回一個對象甚至要經過更多的步驟。這樣帶來的惡果就是返回一個較大對象會有非常多的額外開銷。因此C++程序中都盡量避免返回對象。此外,為了減少返回對象的開銷,C++提出了返回值優化(Return Value Optimization, RVO)這樣的技術,可以將某些場合下的對象拷貝減少一次。
3. 堆與內存管理
什么是堆(Heap):光有棧對于面向過程的程序設計還遠遠不夠,因為棧上的數據在函數返回的時候就會被釋放掉,所以無法將數據傳遞至函數外部。而全局變量沒有辦法動態地產生,只能在編譯時候定義,在很多情況下缺乏表現力。在這種情況下,堆是唯一的選擇。堆是一塊巨大的內存空間,常常占據整個虛擬空間的絕大部分。在這片空間里,程序可以請求一塊連續內存,并自由地使用,這塊內存在程序主動放棄之前都會一直保持有效。
Linux進程堆管理:進程的地址空間中,除了可執行文件、共享庫和棧之外,剩余的未分配的空間都可以被用來作為堆空間。Linux下提供了兩種堆空間的分配方式,即兩個系統調用:一個是brk()系統調用,另外一個是mmap()。
brk()的作用實際上就是設置進程數據段的結束地址,即它可以擴大或者縮小數據段(Linux下數據段和BSS合并在一起統稱數據段)。如果我們將數據段的結束地址向高地址移動,那么擴大的那部分空間就可以被我們使用,把這塊空間拿來作為堆空間是最常見的做法之一。Glibc中還有一個函數叫sbrk,它的功能與brk類似,只不過參數和返回值略有不同。sbrk以一個增量(Increment)作為參數,即需要增加(負數為減少)的空間大小,返回值是增加(或減少)后數據段結束地址,這個函數實際上是對brk系統調用的包裝,它是通過brk()實現的。
mmap()的作用和Windows系統下的VirtualAlloc很相似,它的作用就是向操作系統申請一段虛擬地址空間,當然這塊虛擬地址空間可以映射到某個文件(這也是這個系統調用的最初的作用),當它不將地址空間映射到某個文件時,我們又稱這塊空間為匿名(Anonymous)空間,匿名空間就可以拿來作為堆空間。它的聲明如下:mmap的前兩個參數分別用于指定需要申請的空間的起始地址和長度,如果起始地址設置為0,那么Linux系統會自動挑選合適的起始地址。prot/flags這兩個參數用于設置申請的空間的權限(可讀、可寫、可執行)以及映射類型(文件映射、匿名空間等),最后兩個參數是用于文件映射時指定文件描述符和文件偏移的。
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
Glibc的malloc函數是這樣處理用戶的空間請求的:對于小于128KB的請求來說,它會在現有的堆空間里面,按照堆分配算法為它分配一塊空間并返回;對于大于128KB的請求來說,它會使用mmap()函數為它分配一塊匿名空間,然后在這個匿名空間中為用戶分配空間。
由于mmap()函數與VirtualAlloc()類似,它們都是系統虛擬空間申請函數,它們申請的空間的起始地址和大小都必須是系統頁的大小的整數倍,對于字節數很小的請求如果也使用mmap的話,無疑是會浪費大量的空間的。
Windows進程堆管理:Windows的進程將地址空間分配給了各種EXE、DLL文件、堆、棧。每個線程的棧都是獨立的,所以一個進程中有多少個線程,就應該有多少個對應的棧,對于Windows來說,每個線程默認的棧大小是1MB,在線程啟動時,系統會為它在進程地址空間中分配相應的空間作為棧,線程棧的大小可以由創建線程時CreateThread的參數指定。在分配完上面這些地址以后,Windows的進程地址空間已經是支離破碎了。當程序向系統申請堆空間時,只好從這些剩下的還沒有被占用的地址上分配。Windows系統提供了一個API叫做VirtualAlloc(),用來向系統申請空間,它與Linux下的mmap非常相似。實際上VirtualAlloc()申請的空間不一定只用于堆,它僅僅是向系統預留了一塊虛擬地址,應用程序可以按照需要隨意使用。在使用VirtualAlloc()函數申請空間時,系統要求空間大小必須為頁的整數倍,即對于x86系統來說,必須是4096字節的整數倍。在Windows中,堆管理器(Heap Manager)提供了一套與堆相關的API可以用來創建、分配、釋放和銷毀堆空間:HeapCreate,創建一個堆;HeapAlloc,在一個堆里分配內存;HeapFree,釋放已經分配的內存;HeapDestroy,銷毀一個堆。
堆管理器實際上存在于Windows的兩個位置:一份是位于NTDLL.DLL中,這個DLL是Windows操作系統用戶層的最底層DLL,它負責Windows子系統DLL與Windows內核之間的接口,所有用戶程序、運行時庫和子系統的堆分配都是使用這部分的代碼;而在Windows內核Ntoskrnl.exe中,還存在一份類似的堆管理器,它負責Windows內核中的堆空間分配(內核堆和用戶的堆不是同一個),Windows內核、內核組件、驅動程序使用堆時用到的都是這份堆分配代碼,內核堆管理器的接口都由RtlHeap開頭。
每個進程在創建時都會有一個默認堆,這個堆在進程啟動時創建,并且直到進程結束都一直存在。默認堆的大小為1MB,不過我們可以通過鏈接器的/HEAP參數指定可執行文件的默認堆大小,這樣系統在創建進程時就會按照可執行文件所指定的大小創建默認堆。當然1MB的堆空間對很多程序來說是不夠用的,如果用戶申請的空間超過1MB,堆管理器就會擴展堆的大小,它會通過VirtualAlloc向系統申請更多的空間。
一個進程中能夠分配給堆用的空間不是連續的。所以當一個堆的空間已經無法再擴展時,我們必須創建一個新的堆。但是這一切都不需要用戶操作,因為運行庫的malloc函數已經解決了這一切,它實際上是對Heapxxxx系列函數的包裝,當一個堆空間不夠時,它會在進程中創建額外的堆。所以進程中可能存在多個堆,但是一個進程中一次性能夠分配的最大的堆空間取決于最大的那個堆。
一塊連續的虛擬地址空間有可能是若干個不連續的物理頁拼湊而成的。
堆分配算法:如何管理一大塊連續的內存空間,能夠按照需求分配、釋放其中的空間,這就是堆分配的算法。堆的分配算法有很多種:
(1). 空閑鏈表(Free List):實際上就是把堆中各個空閑的塊按照鏈表的方式連接起來,當用戶請求一塊空間時,可以遍歷整個列表,直到找到合適大小的塊并且將它拆分;當用戶釋放空間時將它合并到空閑鏈表中??臻e鏈表是這樣一種結構,在堆里的每一個空閑空間的開頭(或結尾)有一個頭(header),頭結構里記錄了上一個(prev)和下一個(next)空閑塊的地址,也就是說,所有的空閑塊形成了一個鏈表。
這樣的空閑鏈表實現盡管簡單,但在釋放空間的時候,給定一個已分配塊的指針,堆無法確定這個塊的大小。一個簡單的解決方法是當用戶請求k個字節空間的時候,我們實際分配k+4個字節,這4個字節用于存儲該分配的大小,即k+4。這樣釋放該內存的時候只要看看這4個字節的值,就能知道該內存塊的大小,然后將其插入到空閑鏈表里就可以了。當然這僅僅是最簡單的一種分配策略,這樣的思路存在很大問題。例如,一旦鏈表被破壞,或者記錄長度的那4字節被破壞,整個堆就無法正常工作,而這些數據恰恰很容易被越界讀寫所接觸到。
(2). 位圖:針對空閑鏈表的弊端,另一種分配方式顯得更加穩健。這種方式稱為位圖(Bitmap),其核心思想是將這個堆劃分為大量的塊(block),每個塊的大小相同。當用戶請求內存的時候,總是分配整數個塊的空間給用戶,第一個塊我們稱為已分配區域的頭(Head),其余的稱為已分配區域的主體(Body)。而我們可以使用一個整數數組來記錄塊的使用情況,由于每個塊只有頭/主體/空閑三種狀態,因此僅僅需要兩位即可表示一個塊,因此稱為位圖。優點:速度快,由于整個堆的空閑信息存儲在一個數組內,因此訪問該數組時cache容易命中;穩定性好,為了避免用戶越界讀寫破壞數據,我們只需簡單地備份一下位圖接口,而且即使部分數據被破壞,也不會導致整個堆無法工作;塊不需要額外信息,易于管理。缺點:分配內存的時候容易產生碎片;如果堆很大,或者設定的一個塊很小(這樣可以減少碎片),那么位圖將會很大,可能失去cache命中率高的優勢,而且也會浪費一定的空間,針對這種情況,我們可以使用多級的位圖。
(3). 對象池:在一些場合,被分配對象的大小是較為固定的幾個值,這時候可以針對這一特征設計一個更為高效的堆算法,稱為對象池。對象池的思路很簡單,如果每一次分配的空間大小都一樣,那么就可以按照這個每次請求分配的大小作為一個單位,把整個堆空間劃分為大量的小塊,每次請求的時候只需要找到一個小塊就可以了。對象池的管理方法可以采用空閑鏈表,也可以采用位圖,與它們的區別僅僅在于它假定了每次請求的都是一個固定的大小,因此實現起來很容易。由于每次總是只請求一個單位的內存,因此請求得到滿足的速度非常快,無須查找一個足夠大的空間。
實際上很多現實應用中,堆的分配算法往往是采取多種算法符合而成的。比如對于Glibc來說,它對于小于64字節的空間申請是采用類似于對象池的方法;而對于大于512字節的空間申請采用的是最佳適配算法;對于大于64字節而小于512字節的,它會根據情況采取上述方法中的最佳折中策略;對于大于128KB的申請,它會使用mmap機制直接向操作系統申請空間。
GitHub:https://github.com/fengbingchun/Messy_Test
總結
以上是生活随笔為你收集整理的程序员的自我修养--链接、装载与库笔记:内存的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 程序员的自我修养--链接、装载与库笔记:
- 下一篇: 程序员的自我修养--链接、装载与库笔记: