如何實現一個malloc

發表於2014-08-20

任何一個用過或學過C的人對malloc都不會陌生。大家都知道malloc可以分配一段連續的記憶體空間,並且在不再使用時可以通過free釋放掉。但是,許多程式設計師對malloc背後的事情並不熟悉,許多人甚至把malloc當做作業系統所提供的系統呼叫或C的關鍵字。實際上,malloc只是C的標準庫中提供的一個普通函式,而且實現malloc的基本思想並不複雜,任何一個對C和作業系統有些許瞭解的程式設計師都可以很容易理解。

這篇文章通過實現一個簡單的malloc來描述malloc背後的機制。當然與現有C的標準庫實現(例如glibc)相比,我們實現的malloc並不是特別高效,但是這個實現比目前真實的malloc實現要簡單很多,因此易於理解。重要的是,這個實現和真實實現在基本原理上是一致的。

這篇文章將首先介紹一些所需的基本知識,如作業系統對程式的記憶體管理以及相關的系統呼叫,然後逐步實現一個簡單的malloc。為了簡單起見,這篇文章將只考慮x86_64體系結構,作業系統為Linux。

1 什麼是malloc

在實現malloc之前,先要相對正式地對malloc做一個定義。

根據標準C庫函式的定義,malloc具有如下原型:

這個函式要實現的功能是在系統中分配一段連續的可用的記憶體,具體有如下要求:

  • malloc分配的記憶體大小至少為size引數所指定的位元組數
  • malloc的返回值是一個指標,指向一段可用記憶體的起始地址
  • 多次呼叫malloc所分配的地址不能有重疊部分,除非某次malloc所分配的地址被釋放掉
  • malloc應該儘快完成記憶體分配並返回(不能使用NP-hard的記憶體分配演算法)
  • 實現malloc時應同時實現記憶體大小調整和記憶體釋放函式(即realloc和free)

對於malloc更多的說明可以在命令列中鍵入以下命令檢視:

2 預備知識

在實現malloc之前,需要先解釋一些Linux系統記憶體相關的知識。

2.1 Linux記憶體管理

2.1.1 虛擬記憶體地址與實體記憶體地址

為了簡單,現代作業系統在處理記憶體地址時,普遍採用虛擬記憶體地址技術。即在彙編程式(或機器語言)層面,當涉及記憶體地址時,都是使用虛擬記憶體地址。採用這種技術時,每個程式彷彿自己獨享一片2N位元組的記憶體,其中N是機器位數。例如在64位CPU和64位作業系統下,每個程式的虛擬地址空間為264Byte。

這種虛擬地址空間的作用主要是簡化程式的編寫及方便作業系統對程式間記憶體的隔離管理,真實中的程式不太可能(也用不到)如此大的記憶體空間,實際能用到的記憶體取決於實體記憶體大小。

由於在機器語言層面都是採用虛擬地址,當實際的機器碼程式涉及到記憶體操作時,需要根據當前程式執行的實際上下文將虛擬地址轉換為實體記憶體地址,才能實現對真實記憶體資料的操作。這個轉換一般由一個叫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指標。兩個系統呼叫的原型如下:

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:

其中rlimit是一個結構體:

每種資源有軟限制和硬限制,並且可以通過setrlimit對rlimit進行有條件設定。其中硬限制作為軟限制的上限,非特權程式只能設定軟限制,且不能超過硬限制。

3 實現malloc

3.1 玩具實現

在正式開始討論malloc的實現前,我們可以利用上述知識實現一個簡單但幾乎沒法用於真實的玩具malloc,權當對上面知識的複習:

這個malloc每次都在當前break的基礎上增加size所指定的位元組數,並將之前break的地址返回。這個malloc由於對所分配的記憶體缺乏記錄,不便於記憶體釋放,所以無法用於真實場景。

3.2 正式實現

下面嚴肅點討論malloc的實現方案。

3.2.1 資料結構

首先我們要確定所採用的資料結構。一個簡單可行方案是將堆記憶體空間以塊(Block)的形式組織起來,每個塊由meta區和資料區組成,meta區記錄資料塊的元資訊(資料區大小、空閒標誌位、指標等等),資料區是真實分配的記憶體區域,並且資料區的第一個位元組地址即為malloc返回的地址。

可以用如下結構體定義一個block:

由於我們只考慮64位機器,為了方便,我們在結構體最後填充一個int,使得結構體本身的長度為8的倍數,以便記憶體對齊。示意圖如下:

3.2.2 尋找合適的block

現在考慮如何在block鏈中查詢合適的block。一般來說有兩種查詢演算法:

  • First fit:從頭開始,使用第一個資料區大小大於要求size的塊所謂此次分配的塊
  • Best fit:從頭開始,遍歷所有塊,使用資料區大小大於size且差值最小的塊作為此次分配的塊

兩種方法各有千秋,best fit具有較高的記憶體使用率(payload較高),而first fit具有更好的執行效率。這裡我們採用first fit演算法。

find_block從frist_block開始,查詢第一個符合要求的block並返回block起始地址,如果找不到這返回NULL。這裡在遍歷時會更新一個叫last的指標,這個指標始終指向當前遍歷的block。這是為了如果找不到合適的block而開闢新block使用的,具體會在接下來的一節用到。

3.2.3 開闢新的block

如果現有block都不能滿足size的要求,則需要在連結串列最後開闢一個新的block。這裡關鍵是如何只使用sbrk建立一個struct:

3.2.4 分裂block

First fit有一個比較致命的缺點,就是可能會讓很小的size佔據很大的一塊block,此時,為了提高payload,應該在剩餘資料區足夠大的情況下,將其分裂為一個新的block,示意如下:

實現程式碼:

3.2.5 malloc的實現

有了上面的程式碼,我們可以利用它們整合成一個簡單但初步可用的malloc。注意首先我們要定義個block連結串列的頭first_block,初始化為NULL;另外,我們需要剩餘空間至少有BLOCK_SIZE + 8才執行分裂操作。

由於我們希望malloc分配的資料區是按8位元組對齊,所以在size不為8的倍數時,我們需要將size調整為大於size的最小的8的倍數:

3.2.6 calloc的實現

有了malloc,實現calloc只要兩步:

  1. malloc一段記憶體
  2. 將資料區內容置為0

由於我們的資料區是按8位元組對齊的,所以為了提高效率,我們可以每8位元組一組置0,而不是一個一個位元組設定。我們可以通過新建一個size_t指標,將記憶體區域強制看做size_t型別來實現。

3.2.7 free的實現

free的實現並不像看上去那麼簡單,這裡我們要解決兩個關鍵問題:

  1. 如何驗證所傳入的地址是有效地址,即確實是通過malloc方式分配的資料區首地址
  2. 如何解決碎片問題

首先我們要保證傳入free的地址是有效的,這個有效包括兩方面:

  • 地址應該在之前malloc所分配的區域內,即在first_block和當前break指標範圍內
  • 這個地址確實是之前通過我們自己的malloc分配的

第一個問題比較好解決,只要進行地址比較就可以了,關鍵是第二個問題。這裡有兩種解決方案:一是在結構體內埋一個magic number欄位,free之前通過相對偏移檢查特定位置的值是否為我們設定的magic number,另一種方法是在結構體內增加一個magic pointer,這個指標指向資料區的第一個位元組(也就是在合法時free時傳入的地址),我們在free前檢查magic pointer是否指向引數所指地址。這裡我們採用第二種方案:

首先我們在結構體中增加magic pointer(同時要修改BLOCK_SIZE):

然後我們定義檢查地址合法性的函式:

當多次malloc和free後,整個記憶體池可能會產生很多碎片block,這些block很小,經常無法使用,甚至出現許多碎片連在一起,雖然總體能滿足某此malloc要求,但是由於分割成了多個小block而無法fit,這就是碎片問題。

一個簡單的解決方式時當free某個block時,如果發現它相鄰的block也是free的,則將block和相鄰block合併。為了滿足這個實現,需要將s_block改為雙向連結串列。修改後的block結構如下:

合併方法如下:

有了上述方法,free的實現思路就比較清晰了:首先檢查引數地址的合法性,如果不合法則不做任何事;否則,將此block的free標為1,並且在可以的情況下與後面的block進行合併。如果當前是最後一個block,則回退break指標釋放程式記憶體,如果當前block是最後一個block,則回退break指標並設定first_block為NULL。實現如下:

3.2.8 realloc的實現

為了實現realloc,我們首先要實現一個記憶體複製方法。如同calloc一樣,為了效率,我們以8位元組為單位進行復制:

然後我們開始實現realloc。一個簡單(但是低效)的方法是malloc一段記憶體,然後將資料複製過去。但是我們可以做的更高效,具體可以考慮以下幾個方面:

  • 如果當前block的資料區大於等於realloc所要求的size,則不做任何操作
  • 如果新的size變小了,考慮split
  • 如果當前block的資料區不能滿足size,但是其後繼block是free的,並且合併後可以滿足,則考慮做合併

下面是realloc的實現:

3.3 遺留問題和優化

以上是一個較為簡陋,但是初步可用的malloc實現。還有很多遺留的可能優化點,例如:

  • 在分配較大快記憶體時,考慮使用mmap而非sbrk,這通常更高效
  • 可以考慮維護多個連結串列而非單個,每個連結串列中的block大小均為一個範圍內,例如8位元組連結串列、16位元組連結串列、24-32位元組連結串列等等。此時可以根據size到對應連結串列中做分配,可以有效減少碎片,並提高查詢block的速度
  • 可以考慮連結串列中只存放free的block,而不存放已分配的block,可以減少查詢block的次數,提高效率

還有很多可能的優化,這裡不一一贅述。下面附上一些參考文獻,有興趣的同學可以更深入研究。

4 其它參考

  1. 這篇文章大量參考了A malloc Tutorial,其中一些圖片和程式碼直接引用了文中的內容,這裡特別指出
  2. Computer Systems: A Programmer’s Perspective, 2/E一書有許多值得參考的地方
  3. 關於Linux的虛擬記憶體模型,Anatomy of a Program in Memory是很好的參考資料,另外作者還有一篇How the Kernel Manages Your Memory對於Linux核心中虛擬記憶體管理的部分有很好的講解
  4. 對於真實世界的malloc實現,可以參考glic的實現
  5. 本文寫作過程中大量參考了維基百科,再次感謝這個偉大的網站,並且呼籲大家在手頭允許的情況下可以適當捐助維基百科,幫助這個造福人類的系統執行下去

相關文章