這篇文章經過實現一個簡單的malloc來描述malloc背後的機制。固然與現有C的標準庫實現(例如glibc)相比,咱們實現的malloc並非特別高效,可是這個實現比目前真實的malloc實現要簡單不少,所以易於理解。重要的是,這個實現和真實實如今基本原理上是一致的。linux
這篇文章將首先介紹一些所需的基本知識,如操做系統對進程的內存管理以及相關的系統調用,而後逐步實現一個簡單的malloc。爲了簡單起見,這篇文章將只考慮x86_64體系結構,操做系統爲Linux。git
1 什麼是malloc
在實現malloc以前,先要相對正式地對malloc作一個定義。程序員
根據標準C庫函數的定義,malloc具備以下原型:算法
- void* malloc(size_t size);
這個函數要實現的功能是在系統中分配一段連續的可用的內存,具體有以下要求:編程
- malloc分配的內存大小至少爲size參數所指定的字節數
- malloc的返回值是一個指針,指向一段可用內存的起始地址
- 屢次調用malloc所分配的地址不能有重疊部分,除非某次malloc所分配的地址被釋放掉
- malloc應該儘快完成內存分配並返回(不能使用NP-hard的內存分配算法)
- 實現malloc時應同時實現內存大小調整和內存釋放函數(即realloc和free)
對於malloc更多的說明能夠在命令行中鍵入如下命令查看:緩存
- man malloc
2 預備知識
在實現malloc以前,須要先解釋一些Linux系統內存相關的知識。數據結構
2.1 Linux內存管理
2.1.1 虛擬內存地址與物理內存地址
爲了簡單,現代操做系統在處理內存地址時,廣泛採用虛擬內存地址技術。即在彙編程序(或機器語言)層面,當涉及內存地址時,都是使用虛擬內存地址。採用這種技術時,每一個進程彷彿本身獨享一片2N字節的內存,其中N是機器位數。例如在64位CPU和64位操做系統下,每一個進程的虛擬地址空間爲264Byte。app
這種虛擬地址空間的做用主要是簡化程序的編寫及方便操做系統對進程間內存的隔離管理,真實中的進程不太可能(也用不到)如此大的內存空間,實際能用到的內存取決於物理內存大小。ide
因爲在機器語言層面都是採用虛擬地址,當實際的機器碼程序涉及到內存操做時,須要根據當前進程運行的實際上下文將虛擬地址轉換爲物理內存地址,才能實現對真實內存數據的操做。這個轉換通常由一個叫MMU(Memory Management Unit)的硬件完成。
2.1.2 頁與地址構成
在現代操做系統中,不管是虛擬內存仍是物理內存,都不是以字節爲單位進行管理的,而是以頁(Page)爲單位。一個內存頁是一段固定大小的連續內存地址的總稱,具體到Linux中,典型的內存頁大小爲4096Byte(4K)。
因此內存地址能夠分爲頁號和頁內偏移量。下面以64位機器,4G物理內存,4K頁大小爲例,虛擬內存地址和物理內存地址的組成以下:
上面是虛擬內存地址,下面是物理內存地址。因爲頁大小都是4K,因此頁內便宜都是用低12位表示,而剩下的高地址表示頁號。
MMU映射單位並非字節,而是頁,這個映射經過查一個常駐內存的數據結構頁表來實現。如今計算機具體的內存地址映射比較複雜,爲了加快速度會引入一系列緩存和優化,例如TLB等機制。下面給出一個通過簡化的內存地址翻譯示意圖,雖然通過了簡化,可是基本原理與現代計算機真實的狀況的一致的。
2.1.3 內存頁與磁盤頁
咱們知道通常將內存看作磁盤的的緩存,有時MMU在工做時,會發現頁表代表某個內存頁不在物理內存中,此時會觸發一個缺頁異常(Page Fault),此時系統會到磁盤中相應的地方將磁盤頁載入到內存中,而後從新執行因爲缺頁而失敗的機器指令。關於這部分,由於能夠看作對malloc實現是透明的,因此再也不詳細講述,有興趣的能夠參考《深刻理解計算機系統》相關章節。
最後附上一張在維基百科找到的更加符合真實地址翻譯的流程供你們參考,這張圖加入了TLB和缺頁異常的流程(圖片來源頁)。
2.2 Linux進程級內存管理
2.2.1 內存排布
明白了虛擬內存和物理內存的關係及相關的映射機制,下面看一下具體在一個進程內是如何排布內存的。
以Linux 64位系統爲例。理論上,64bit內存地址可用空間爲0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF,這是個至關龐大的空間,Linux實際上只用了其中一小部分(256T)。
根據Linux內核相關文檔描述,Linux64位操做系統僅使用低47位,高17位作擴展(只能是全0或全1)。因此,實際用到的地址爲空間爲0x0000000000000000 ~ 0x00007FFFFFFFFFFF和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,其中前面爲用戶空間(User Space),後者爲內核空間(Kernel Space)。圖示以下:
對用戶來講,主要關注的空間是User Space。將User Space放大後,能夠看到裏面主要分爲以下幾段:
- Code:這是整個用戶空間的最低地址部分,存放的是指令(也就是程序所編譯成的可執行機器碼)
- Data:這裏存放的是初始化過的全局變量
- BSS:這裏存放的是未初始化的全局變量
- Heap:堆,這是咱們本文重點關注的地方,堆自低地址向高地址增加,後面要講到的brk相關的系統調用就是從這裏分配內存
- Mapping Area:這裏是與mmap系統調用相關的區域。大多數實際的malloc實現會考慮經過mmap分配較大塊的內存區域,本文不討論這種狀況。這個區域自高地址向低地址增加
- Stack:這是棧區域,自高地址向低地址增加
下面咱們主要關注Heap區域的操做。對整個Linux內存排布有興趣的同窗能夠參考其它資料。
2.2.2 Heap內存模型
通常來講,malloc所申請的內存主要從Heap區域分配(本文不考慮經過mmap申請大塊內存的狀況)。
由上文知道,進程所面對的虛擬內存地址空間,只有按頁映射到物理內存地址,才能真正使用。受物理存儲容量限制,整個堆虛擬內存空間不可能所有映射到實際的物理內存。Linux對堆的管理示意以下:
Linux維護一個break指針,這個指針指向堆空間的某個地址。從堆起始地址到break之間的地址空間爲映射好的,能夠供進程訪問;而從break往上,是未映射的地址空間,若是訪問這段空間則程序會報錯。
2.2.3 brk與sbrk
由上文知道,要增長一個進程實際的可用堆大小,就須要將break指針向高地址移動。Linux經過brk和sbrk系統調用操做break指針。兩個系統調用的原型以下:
- int brk(void *addr);
- void *sbrk(intptr_t increment);
brk將break指針直接設置爲某個地址,而sbrk將break從當前位置移動increment所指定的增量。brk在執行成功時返回0,不然返回-1並設置errno爲ENOMEM;sbrk成功時返回break移動以前所指向的地址,不然返回(void *)-1。
一個小技巧是,若是將increment設置爲0,則能夠得到當前break的地址。
另外須要注意的是,因爲Linux是按頁進行內存映射的,因此若是break被設置爲沒有按頁大小對齊,則系統實際上會在最後映射一個完整的頁,從而實際已映射的內存空間比break指向的地方要大一些。可是使用break以後的地址是很危險的(儘管也許break以後確實有一小塊可用內存地址)。
2.2.4 資源限制與rlimit
系統對每個進程所分配的資源不是無限的,包括可映射的內存空間,所以每一個進程有一個rlimit表示當前進程可用的資源上限。這個限制能夠經過getrlimit系統調用獲得,下面代碼獲取當前進程虛擬內存空間的rlimit:
- int main() {
- struct rlimit *limit = (struct rlimit *)malloc(sizeof(struct rlimit));
- getrlimit(RLIMIT_AS, limit);
- printf("soft limit: %ld, hard limit: %ld\n", limit->rlim_cur, limit->rlim_max);
- }
其中rlimit是一個結構體:
- struct rlimit {
- rlim_t rlim_cur; /* Soft limit */
- rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
- };
每種資源有軟限制和硬限制,而且能夠經過setrlimit對rlimit進行有條件設置。其中硬限制做爲軟限制的上限,非特權進程只能設置軟限制,且不能超過硬限制。
3 實現malloc
3.1 玩具實現
在正式開始討論malloc的實現前,咱們能夠利用上述知識實現一個簡單但幾乎無法用於真實的玩具malloc,權當對上面知識的複習:
- /* 一個玩具malloc */
- #include <sys/types.h>
- #include <unistd.h>
- void *malloc(size_t size)
- {
- void *p;
- p = sbrk(0);
- if (sbrk(size) == (void *)-1)
- return NULL;
- return p;
- }
這個malloc每次都在當前break的基礎上增長size所指定的字節數,並將以前break的地址返回。這個malloc因爲對所分配的內存缺少記錄,不便於內存釋放,因此沒法用於真實場景。
3.2 正式實現
下面嚴肅點討論malloc的實現方案。
3.2.1 數據結構
首先咱們要肯定所採用的數據結構。一個簡單可行方案是將堆內存空間以塊(Block)的形式組織起來,每一個塊由meta區和數據區組成,meta區記錄數據塊的元信息(數據區大小、空閒標誌位、指針等等),數據區是真實分配的內存區域,而且數據區的第一個字節地址即爲malloc返回的地址。
能夠用以下結構體定義一個block:
- typedef struct s_block *t_block;
- struct s_block {
- size_t size; /* 數據區大小 */
- t_block next; /* 指向下個塊的指針 */
- int free; /* 是不是空閒塊 */
- int padding; /* 填充4字節,保證meta塊長度爲8的倍數 */
- char data[1] /* 這是一個虛擬字段,表示數據塊的第一個字節,長度不該計入meta */
- };
因爲咱們只考慮64位機器,爲了方便,咱們在結構體最後填充一個int,使得結構體自己的長度爲8的倍數,以便內存對齊。示意圖以下:
3.2.2 尋找合適的block
如今考慮如何在block鏈中查找合適的block。通常來講有兩種查找算法:
- First fit:從頭開始,使用第一個數據區大小大於要求size的塊所謂這次分配的塊
- Best fit:從頭開始,遍歷全部塊,使用數據區大小大於size且差值最小的塊做爲這次分配的塊
兩種方法各有千秋,best fit具備較高的內存使用率(payload較高),而first fit具備更好的運行效率。這裏咱們採用first fit算法。
- /* First fit */
- t_block find_block(t_block *last, size_t size) {
- t_block b = first_block;
- while(b && !(b->free && b->size >= size)) {
- *last = b;
- b = b->next;
- }
- return b;
- }
find_block從frist_block開始,查找第一個符合要求的block並返回block起始地址,若是找不到這返回NULL。這裏在遍歷時會更新一個叫last的指針,這個指針始終指向當前遍歷的block。這是爲了若是找不到合適的block而開闢新block使用的,具體會在接下來的一節用到。
3.2.3 開闢新的block
若是現有block都不能知足size的要求,則須要在鏈表最後開闢一個新的block。這裏關鍵是如何只使用sbrk建立一個struct:
- #define BLOCK_SIZE 24 /* 因爲存在虛擬的data字段,sizeof不能正確計算meta長度,這裏手工設置 */
- t_block extend_heap(t_block last, size_t s) {
- t_block b;
- b = sbrk(0);
- if(sbrk(BLOCK_SIZE + s) == (void *)-1)
- return NULL;
- b->size = s;
- b->next = NULL;
- if(last)
- last->next = b;
- b->free = 0;
- return b;
- }
3.2.4 分裂block
First fit有一個比較致命的缺點,就是可能會讓很小的size佔據很大的一塊block,此時,爲了提升payload,應該在剩餘數據區足夠大的狀況下,將其分裂爲一個新的block,示意以下:
實現代碼:
- void split_block(t_block b, size_t s) {
- t_block new;
- new = b->data + s;
- new->size = b->size - s - BLOCK_SIZE ;
- new->next = b->next;
- new->free = 1;
- b->size = s;
- b->next = new;
- }
3.2.5 malloc的實現
有了上面的代碼,咱們能夠利用它們整合成一個簡單但初步可用的malloc。注意首先咱們要定義個block鏈表的頭first_block,初始化爲NULL;另外,咱們須要剩餘空間至少有BLOCK_SIZE + 8才執行分裂操做。
因爲咱們但願malloc分配的數據區是按8字節對齊,因此在size不爲8的倍數時,咱們須要將size調整爲大於size的最小的8的倍數:
- size_t align8(size_t s) {
- if(s & 0x7 == 0)
- return s;
- return ((s >> 3) + 1) << 3;
- }
- #define BLOCK_SIZE 24
- void *first_block=NULL;
- /* other functions... */
- void *malloc(size_t size) {
- t_block b, last;
- size_t s;
- /* 對齊地址 */
- s = align8(size);
- if(first_block) {
- /* 查找合適的block */
- last = first_block;
- b = find_block(&last, s);
- if(b) {
- /* 若是能夠,則分裂 */
- if ((b->size - s) >= ( BLOCK_SIZE + 8))
- split_block(b, s);
- b->free = 0;
- } else {
- /* 沒有合適的block,開闢一個新的 */
- b = extend_heap(last, s);
- if(!b)
- return NULL;
- }
- } else {
- b = extend_heap(NULL, s);
- if(!b)
- return NULL;
- first_block = b;
- }
- return b->data;
- }
3.2.6 calloc的實現
有了malloc,實現calloc只要兩步:
- malloc一段內存
- 將數據區內容置爲0
因爲咱們的數據區是按8字節對齊的,因此爲了提升效率,咱們能夠每8字節一組置0,而不是一個一個字節設置。咱們能夠經過新建一個size_t指針,將內存區域強制看作size_t類型來實現。
- void *calloc(size_t number, size_t size) {
- size_t *new;
- size_t s8, i;
- new = malloc(number * size);
- if(new) {
- s8 = align8(number * size) >> 3;
- for(i = 0; i < s8; i++)
- new[i] = 0;
- }
- return new;
- }
3.2.7 free的實現
free的實現並不像看上去那麼簡單,這裏咱們要解決兩個關鍵問題:
- 如何驗證所傳入的地址是有效地址,即確實是經過malloc方式分配的數據區首地址
- 如何解決碎片問題
首先咱們要保證傳入free的地址是有效的,這個有效包括兩方面:
- 地址應該在以前malloc所分配的區域內,即在first_block和當前break指針範圍內
- 這個地址確實是以前經過咱們本身的malloc分配的
第一個問題比較好解決,只要進行地址比較就能夠了,關鍵是第二個問題。這裏有兩種解決方案:一是在結構體內埋一個magic number字段,free以前經過相對偏移檢查特定位置的值是否爲咱們設置的magic number,另外一種方法是在結構體內增長一個magic pointer,這個指針指向數據區的第一個字節(也就是在合法時free時傳入的地址),咱們在free前檢查magic pointer是否指向參數所指地址。這裏咱們採用第二種方案:
首先咱們在結構體中增長magic pointer(同時要修改BLOCK_SIZE):
- typedef struct s_block *t_block;
- struct s_block {
- size_t size; /* 數據區大小 */
- t_block next; /* 指向下個塊的指針 */
- int free; /* 是不是空閒塊 */
- int padding; /* 填充4字節,保證meta塊長度爲8的倍數 */
- void *ptr; /* Magic pointer,指向data */
- char data[1] /* 這是一個虛擬字段,表示數據塊的第一個字節,長度不該計入meta */
- };
而後咱們定義檢查地址合法性的函數:
- t_block get_block(void *p) {
- char *tmp;
- tmp = p;
- return (p = tmp -= BLOCK_SIZE);
- }
- int valid_addr(void *p) {
- if(first_block) {
- if(p > first_block && p < sbrk(0)) {
- return p == (get_block(p))->ptr;
- }
- }
- return 0;
- }
當屢次malloc和free後,整個內存池可能會產生不少碎片block,這些block很小,常常沒法使用,甚至出現許多碎片連在一塊兒,雖然整體能知足某此malloc要求,可是因爲分割成了多個小block而沒法fit,這就是碎片問題。
一個簡單的解決方式時當free某個block時,若是發現它相鄰的block也是free的,則將block和相鄰block合併。爲了知足這個實現,須要將s_block改成雙向鏈表。修改後的block結構以下:
- typedef struct s_block *t_block;
- struct s_block {
- size_t size; /* 數據區大小 */
- t_block prev; /* 指向上個塊的指針 */
- t_block next; /* 指向下個塊的指針 */
- int free; /* 是不是空閒塊 */
- int padding; /* 填充4字節,保證meta塊長度爲8的倍數 */
- void *ptr; /* Magic pointer,指向data */
- char data[1] /* 這是一個虛擬字段,表示數據塊的第一個字節,長度不該計入meta */
- };
合併方法以下:
- t_block fusion(t_block b) {
- if (b->next && b->next->free) {
- b->size += BLOCK_SIZE + b->next->size;
- b->next = b->next->next;
- if(b->next)
- b->next->prev = b;
- }
- return b;
- }
有了上述方法,free的實現思路就比較清晰了:首先檢查參數地址的合法性,若是不合法則不作任何事;不然,將此block的free標爲1,而且在能夠的狀況下與後面的block進行合併。若是當前是最後一個block,則回退break指針釋放進程內存,若是當前block是最後一個block,則回退break指針並設置first_block爲NULL。實現以下:
- void free(void *p) {
- t_block b;
- if(valid_addr(p)) {
- b = get_block(p);
- b->free = 1;
- if(b->prev && b->prev->free)
- b = fusion(b->prev);
- if(b->next)
- fusion(b);
- else {
- if(b->prev)
- b->prev->prev = NULL;
- else
- first_block = NULL;
- brk(b);
- }
- }
- }
3.2.8 realloc的實現
爲了實現realloc,咱們首先要實現一個內存複製方法。如同calloc同樣,爲了效率,咱們以8字節爲單位進行復制:
- void copy_block(t_block src, t_block dst) {
- size_t *sdata, *ddata;
- size_t i;
- sdata = src->ptr;
- ddata = dst->ptr;
- for(i = 0; (i * 8) < src->size && (i * 8) < dst->size; i++)
- ddata[i] = sdata[i];
- }
而後咱們開始實現realloc。一個簡單(可是低效)的方法是malloc一段內存,而後將數據複製過去。可是咱們能夠作的更高效,具體能夠考慮如下幾個方面:
- 若是當前block的數據區大於等於realloc所要求的size,則不作任何操做
- 若是新的size變小了,考慮split
- 若是當前block的數據區不能知足size,可是其後繼block是free的,而且合併後能夠知足,則考慮作合併
下面是realloc的實現:
- void *realloc(void *p, size_t size) {
- size_t s;
- t_block b, new;
- void *newp;
- if (!p)
- /* 根據標準庫文檔,當p傳入NULL時,至關於調用malloc */
- return malloc(size);
- if(valid_addr(p)) {
- s = align8(size);
- b = get_block(p);
- if(b->size >= s) {
- if(b->size - s >= (BLOCK_SIZE + 8))
- split_block(b,s);
- } else {
- /* 看是否可進行合併 */
- if(b->next && b->next->free
- && (b->size + BLOCK_SIZE + b->next->size) >= s) {
- fusion(b);
- if(b->size - s >= (BLOCK_SIZE + 8))
- split_block(b, s);
- } else {
- /* 新malloc */
- newp = malloc (s);
- if (!newp)
- return NULL;
- new = get_block(newp);
- copy_block(b, new);
- free(p);
- return(newp);
- }
- }
- return (p);
- }
- return NULL;
- }
3.3 遺留問題和優化
以上是一個較爲簡陋,可是初步可用的malloc實現。還有不少遺留的可能優化點,例如:
- 同時兼容32位和64位系統
- 在分配較大快內存時,考慮使用mmap而非sbrk,這一般更高效
- 能夠考慮維護多個鏈表而非單個,每一個鏈表中的block大小均爲一個範圍內,例如8字節鏈表、16字節鏈表、24-32字節鏈表等等。此時能夠根據size到對應鏈表中作分配,能夠有效減小碎片,並提升查詢block的速度
- 能夠考慮鏈表中只存放free的block,而不存放已分配的block,能夠減小查找block的次數,提升效率
還有不少可能的優化,這裏不一一贅述。下面附上一些參考文獻,有興趣的同窗能夠更深刻研究。
4 其它參考
- 這篇文章大量參考了A malloc Tutorial,其中一些圖片和代碼直接引用了文中的內容,這裏特別指出
- Computer Systems: A Programmer's Perspective, 2/E一書有許多值得參考的地方
- 關於Linux的虛擬內存模型,Anatomy of a Program in Memory是很好的參考資料,另外做者還有一篇How the Kernel Manages Your Memory對於Linux內核中虛擬內存管理的部分有很好的講解
- 對於真實世界的malloc實現,能夠參考glibc的實現
- 本文寫做過程當中大量參考了維基百科,再次感謝這個偉大的網站,而且呼籲你們在手頭容許的狀況下能夠適當捐助維基百科,幫助這個造福人類的系統運行下去