Linux堆管理實現原理學習筆記 (上半部)

wyzsk發表於2020-08-19
作者: 阿里移動安全 · 2016/05/13 11:37

Author:[email protected]

0x00 前言


前段時間偶然學習了這篇文章:

https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/comment-page-1/

該文是我近段時間以來讀到的最好文章之一,文章淺顯易懂,條例清晰,作為初學者的我從中學到了很多linux堆記憶體管理相關的知識。但是估計由於篇幅的限制,該文對很多難點一帶而過,造成部分知識點理解上的困難。因此我決定以該文為藍本,結合其他參考資料和自己的理解,寫一篇足夠詳細、完整的linux堆管理介紹文章,希冀能夠給其他初學者獻上微末之力。所以就內容來源而言,本文主要由兩部分組成:一部分是翻譯的上面提及的文章;另一部分是筆者結合其他參考資料和自己的理解新增的補充說明。鑑於筆者知識能力上的不足,如有問題歡迎各位大牛斧正!

同樣的,鑑於篇幅過長,我將文章分成了上下兩部分,上部分主要介紹堆記憶體管理中的一些基本概念以及相互關係,同時也著重介紹了堆中chunk分配和釋放策略中使用到的隱式連結串列技術。後半部分主要介紹glibc malloc為了提高堆記憶體分配和釋放的效率,引入的顯示連結串列技術,即binlist的概念和核心原理。其中使用到的原始碼在:

https://github.com/sploitfun/lsploits/tree/master/glibc

0x01 堆記憶體管理簡介


當前針對各大平臺主要有如下幾種堆記憶體管理機制:

  • dlmalloc – General purpose allocator
  • ptmalloc2 – glibc
  • jemalloc – FreeBSD and Firefox
  • tcmalloc – Google
  • libumem – Solaris

本文主要學習介紹在linux glibc使用的ptmalloc2實現原理。

本來linux預設的是dlmalloc,但是由於其不支援多執行緒堆管理,所以後來被支援多執行緒的prmalloc2代替了。

當然在linux平臺*malloc本質上都是透過系統呼叫brk或者mmap實現的。關於這部分內容,一定要學習下面這篇文章:

https://sploitfun.wordpress.com/2015/02/11/syscalls-used-by-malloc/

鑑於篇幅,本文就不加以詳細說明了,只是為了方便後面對堆記憶體管理的理解,擷取其中函式呼叫關係圖:

p1 圖1-1 函式呼叫關係圖

系統記憶體分佈圖:

p2 圖1-2系統記憶體分佈圖

0x02 實驗演示


試想有如下程式碼:

#!cpp
/* Per thread arena example. */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* threadFunc(void* arg) {
        printf("Before malloc in thread 1\n");
        getchar();
        char* addr = (char*) malloc(1000);
        printf("After malloc and before free in thread 1\n");
        getchar();
        free(addr);
        printf("After free in thread 1\n");
        getchar();
}

int main() {
        pthread_t t1;
        void* s;
        int ret;
        char* addr;

        printf("Welcome to per thread arena example::%d\n",getpid());
        printf("Before malloc in main thread\n");
        getchar();
        addr = (char*) malloc(1000);
        printf("After malloc and before free in main thread\n");
        getchar();
        free(addr);
        printf("After free in main thread\n");
        getchar();
        ret = pthread_create(&t1, NULL, threadFunc, NULL);
        if(ret)
        {
                printf("Thread creation error\n");
                return -1;
        }
        ret = pthread_join(t1, &s);
        if(ret)
        {
                printf("Thread join error\n");
                return -1;
        }
        return 0;
}

下面我們依次分析其各個階段的堆記憶體分佈狀況。

1. Before malloc in main thread:

在程式呼叫malloc之前程式程式中是沒有heap segment的,並且在建立在建立執行緒前,也是沒有執行緒堆疊的。

2. After malloc in main thread:

在主執行緒中呼叫malloc之後,就會發現系統給程式分配了堆疊,且這個堆疊剛好在資料段之上:

p3

這就說明它是透過brk系統呼叫實現的。並且,還可以看出雖然我們只申請了1000位元組的資料,但是系統卻分配了132KB大小的堆,這是為什麼呢?原來這132KB的堆空間叫做arena,此時因為是主執行緒分配的,所以叫做main arena(每個arena中含有多個chunk,這些chunk以連結串列的形式加以組織)。由於132KB比1000位元組大很多,所以主執行緒後續再聲請堆空間的話,就會先從這132KB的剩餘部分中申請,直到用完或不夠用的時候,再透過增加program break location的方式來增加main arena的大小。同理,當main arena中有過多空閒記憶體的時候,也會透過減小program break location的方式來縮小main arena的大小。

3. After free in main thread:

在主執行緒呼叫free之後:從記憶體佈局可以看出程式的堆空間並沒有被釋放掉,原來呼叫free函式釋放已經分配了的空間並非直接“返還”給系統,而是由glibc 的malloc庫函式加以管理。它會將釋放的chunk新增到main arenas的bin(這是一種用於儲存同型別free chunk的雙連結串列資料結構,後問會加以詳細介紹)中。在這裡,記錄空閒空間的freelist資料結構稱之為bins。之後當使用者再次呼叫malloc申請堆空間的時候,glibc malloc會先嚐試從bins中找到一個滿足要求的chunk,如果沒有才會向作業系統申請新的堆空間。如下圖所示:

p4

4. Before malloc in thread1:

在thread1呼叫malloc之前:從輸出結果可以看出thread1中並沒有heap segment,但是此時thread1自己的棧空間已經分配完畢了:

p5

5. After malloc in thread1:

在thread1呼叫malloc之後:從輸出結果可以看出thread1的heap segment已經分配完畢了,同時從這個區域的起始地址可以看出,它並不是透過brk分配的,而是透過mmap分配,因為它的區域為b7500000-b7600000共1MB,並不是同程式的data segment相鄰。同時,我們還能看出在這1MB中,根據記憶體屬性分為了2部分:0xb7500000-0xb7520000共132KB大小的空間是可讀可寫屬性;後面的是不可讀寫屬性。原來,這裡只有可讀寫的132KB空間才是thread1的堆空間,即thread1 arena。

p6

6. 在thread1呼叫free之後:同main thread。

0x03 Arena介紹


3.1 Arena數量限制

在第2章中我們提到main thread和thread1有自己獨立的arena,那麼是不是無論有多少個執行緒,每個執行緒都有自己獨立的arena呢?答案是否定的。事實上,arena的個數是跟系統中處理器核心個數相關的,如下表所示:

#!cpp
For 32 bit systems:
     Number of arena = 2 * number of cores + 1.
For 64 bit systems:
     Number of arena = 8 * number of cores + 1.

3.2 多Arena的管理

假設有如下情境:一臺只含有一個處理器核心的PC機安裝有32位作業系統,其上執行了一個多執行緒應用程式,共含有4個執行緒——主執行緒和三個使用者執行緒。顯然執行緒個數大於系統能維護的最大arena個數(2*核心數 + 1= 3),那麼此時glibc malloc就需要確保這4個執行緒能夠正確地共享這3個arena,那麼它是如何實現的呢?

當主執行緒首次呼叫malloc的時候,glibc malloc會直接為它分配一個main arena,而不需要任何附加條件。

當使用者執行緒1和使用者執行緒2首次呼叫malloc的時候,glibc malloc會分別為每個使用者執行緒建立一個新的thread arena。此時,各個執行緒與arena是一一對應的。但是,當使用者執行緒3呼叫malloc的時候,就出現問題了。因為此時glibc malloc能維護的arena個數已經達到上限,無法再為執行緒3分配新的arena了,那麼就需要重複使用已經分配好的3個arena中的一個(main arena, arena 1或者arena 2)。那麼該選擇哪個arena進行重複利用呢?

  1. 首先,glibc malloc迴圈遍歷所有可用的arenas,在遍歷的過程中,它會嘗試lock該arena。如果成功lock(該arena當前對應的執行緒並未使用堆記憶體則表示可lock),比如將main arena成功lock住,那麼就將main arena返回給使用者,即表示該arena被執行緒3共享使用。
  2. 而如果沒能找到可用的arena,那麼就將執行緒3的malloc操作阻塞,直到有可用的arena為止。
  3. 現在,如果執行緒3再次呼叫malloc的話,glibc malloc就會先嚐試使用最近訪問的arena(此時為main arena)。如果此時main arena可用的話,就直接使用,否則就將執行緒3阻塞,直到main arena再次可用為止。

這樣執行緒3與主執行緒就共享main arena了。至於其他更復雜的情況,以此類推。

0x04 堆管理介紹


4.1 整體介紹

在glibc malloc中針對堆管理,主要涉及到以下3種資料結構:

1. heap_info: 即Heap Header,因為一個thread arena(注意:不包含main thread)可以包含多個heaps,所以為了便於管理,就給每個heap分配一個heap header。那麼在什麼情況下一個thread arena會包含多個heaps呢?在當前heap不夠用的時候,malloc會透過系統呼叫mmap申請新的堆空間,新的堆空間會被新增到當前thread arena中,便於管理。

#!cpp
typedef struct _heap_info
{
  mstate ar_ptr; /* Arena for this heap. */
  struct _heap_info *prev; /* Previous heap. */
  size_t size;   /* Current size in bytes. */
  size_t mprotect_size; /* Size in bytes that has been mprotected
                           PROT_READ|PROT_WRITE.  */
  /* Make sure the following data is properly aligned, particularly
     that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
     MALLOC_ALIGNMENT. */
  char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

2. malloc_state: 即Arena Header,每個thread只含有一個Arena Header。Arena Header包含bins的資訊、top chunk以及最後一個remainder chunk等(這些概念會在後文詳細介紹):

#!cpp
struct malloc_state
{
  /* Serialize access.  */
  mutex_t mutex;

  /* Flags (formerly in max_fast).  */
  int flags;

  /* Fastbins */
  mfastbinptr fastbinsY[NFASTBINS];

  /* Base of the topmost chunk -- not otherwise kept in a bin */
  mchunkptr top;

  /* The remainder from the most recent split of a small request */
  mchunkptr last_remainder;

  /* Normal bins packed as described above */
  mchunkptr bins[NBINS * 2 - 2];

  /* Bitmap of bins */
  unsigned int binmap[BINMAPSIZE];

  /* Linked list */
  struct malloc_state *next;

  /* Linked list for free arenas.  */
  struct malloc_state *next_free;

  /* Memory allocated from the system in this arena.  */
  INTERNAL_SIZE_T system_mem;
  INTERNAL_SIZE_T max_system_mem;
};

3. malloc_chunk: 即Chunk Header,一個heap被分為多個chunk,至於每個chunk的大小,這是根據使用者的請求決定的,也就是說使用者呼叫malloc(size)傳遞的size引數“就是”chunk的大小(這裡給“就是”加上引號,說明這種表示並不準確,但是為了方便理解就暫時這麼描述了,詳細說明見後文)。每個chunk都由一個結構體malloc_chunk表示:

#!cpp
struct malloc_chunk {
  /* #define INTERNAL_SIZE_T size_t */
  INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */
  struct malloc_chunk* fd;         /* double links -- used only if free. 這兩個指標只在free chunk中存在*/
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

可能有很多讀者會疑惑:該結構體裡面並沒有一個類似於data的欄位來表示使用者申請到的堆記憶體空間啊?且該結構體明確含有2個size_t型別的成員,4個指標,這不就意味著malloc_chunk的大小是固定的了麼?那它又如何能夠根據使用者的請求分配不同大小的記憶體呢?要想回答清楚這個問題,需要我們完全理解整個glibc malloc的堆記憶體管理機制,同時,本文的主要目的之一就是希冀解釋清楚這個概念,鑑於這部分內容較多,我將在後文的第5章加以詳細介紹。

NOTE:
1. Main thread不含有多個heaps所以也就不含有heap_info結構體。當需要更多堆空間的時候,就透過擴充套件sbrk的heap segment來獲取更多的空間,直到它碰到記憶體mapping區域為止。 2. 不同於thread arena,main arena的arena header並不是sbrk heap segment的一部分,而是一個全域性變數!因此它屬於libc.so的data segment。

4.2 heap segment與arena關係

首先,透過記憶體分佈圖理清malloc_state與heap_info之間的組織關係。

下圖是隻有一個heap segment的main arena和thread arena的記憶體分佈圖:

p7 圖4-1 只含一個heap segment的main arena與thread arena圖

下圖是一個thread arena中含有多個heap segments的情況:

p8 圖4-1 一個thread arena含有多個heap segments的記憶體分佈圖

從上圖可以看出,thread arena只含有一個malloc_state(即arena header),卻有兩個heap_info(即heap header)。由於兩個heap segments是透過mmap分配的記憶體,兩者在記憶體佈局上並不相鄰而是分屬於不同的記憶體區間,所以為了便於管理,libc malloc將第二個heap_info結構體的prev成員指向了第一個heap_info結構體的起始位置(即ar_ptr成員),而第一個heap_info結構體的ar_ptr成員指向了malloc_state,這樣就構成了一個單連結串列,方便後續管理。

0x05 對chunk的理解


在glibc malloc中將整個堆記憶體空間分成了連續的、大小不一的chunk,即對於堆記憶體管理而言chunk就是最小操作單位。Chunk總共分為4類:1)allocated chunk; 2)free chunk; 3)top chunk; 4)Last remainder chunk。從本質上來說,所有型別的chunk都是記憶體中一塊連續的區域,只是透過該區域中特定位置的某些識別符號加以區分。為了簡便,我們先將這4類chunk簡化為2類:allocated chunk以及free chunk,前者表示已經分配給使用者使用的chunk,後者表示未使用的chunk。

眾所周知,無論是何種堆記憶體管理器,其完成的核心目的都是能夠高效地分配和回收記憶體塊(chunk)。因此,它需要設計好相關演算法以及相應的資料結構,而資料結構往往是根據演算法的需要加以改變的。既然是演算法,那麼演算法肯定有一個最佳化改進的過程,所以本文將根據堆記憶體管理器的演變歷程,逐步介紹在glibc malloc中chunk這種資料結構是如何設計出來的,以及這樣設計的優缺點。

PS:鑑於時間和精力有限,後文介紹的演變歷程並沒有加以嚴格考證,筆者只是按照一些參考書籍、自己的理解以及便於文章內容安排做出的“善意的捏造”,如有錯誤,歡迎大家斧正!

5.1 隱式連結串列技術

前文說過,任何堆記憶體管理器都是以chunk為單位進行堆記憶體管理的,而這就需要一些資料結構來標誌各個塊的邊界,以及區分已分配塊和空閒塊。大多數堆記憶體管理器都將這些邊界資訊作為chunk的一部分嵌入到chunk內部,典型的設計如下所示:

p9 圖5-1 簡單的allocated chunk格式

p10 圖5-2 簡單的free chunk格式

堆記憶體中要求每個chunk的大小必須為8的整數倍,因此chunk size的後3位是無效的,為了充分利用記憶體,堆管理器將這3個位元位用作chunk的標誌位,典型的就是將第0位元位用於標記該chunk是否已經被分配。這樣的設計很巧妙,因為我們只要獲取了一個指向chunk size的指標,就能知道該chunk的大小,即確定了此chunk的邊界,且利用chunk size的第0位元位還能知道該chunk是否已經分配,這樣就成功地將各個chunk區分開來。注意在allocated chunk中padding部分主要是用於地址對齊的(也可用於對付外部碎片),即讓整個chunk的大小為8的整數倍。

透過上面的設計,我們就能將整個堆記憶體組織成一個連續的已分配或未分配chunk序列:

p11 圖5-3 簡單的chunk序列

上面的這種結構就叫做隱式連結串列。該連結串列隱式地由每個chunk的size欄位連結起來,在進行分配操作的時候,堆記憶體管理器可以透過遍歷整個堆記憶體的chunk,分析每個chunk的size欄位,進而找到合適的chunk。

細心的讀者可能發現:這種隱式連結串列效率其實是相當低的,特別是在記憶體回收方面,它難以進行相鄰多個free chunk的合併操作。我們知道,如果只對free chunk進行分割,而不進行合併的話,就會產生大量小的、無法繼續使用的內部碎片,直至整個記憶體消耗殆盡。因此堆記憶體管理器設計了帶邊界標記的chunk合併技術。

1.帶邊界標記的合併技術

試想如下場景:假設我們要釋放的chunk為P,它緊鄰的前一個chunk為FD,緊鄰的後一個chunk為BK,且BK與FD都為free chunk。將P於BK合併在一起是很容易的,因為可以透過P的size欄位輕鬆定位到BK的開始位置,進而獲取BK的size等等,但是將P於FD合併卻很難,我們必須從頭遍歷整個堆,找到FD,然後加以合併,這就意味著每次進行chunk釋放操作消耗的時間與堆的大小成線性關係。為了解決這個問題,Knuth提出了一種聰明而通用的技術——邊界標記。

Knuth在每個chunk的最後新增了一個腳部(Footer),它就是該chunk 頭部(header)的一個副本,我們稱之為邊界標記:

p12 圖5-4 改進版的chunk格式之Knuth邊界標記

顯然每個chunk的腳部都在其相鄰的下一個chunk的頭部的前4個位元組處。透過這個腳部,堆記憶體管理器就可以很容易地得到前一個chunk的起始位置和分配狀態,進而加以合併了。

但是,邊界標記同時帶來了一個問題:它要求每個塊都包含一個頭部和腳部,如果應用程式頻繁地進行小記憶體的申請和釋放操作的話(比如1,2個位元組),就會造成很大的效能損耗。同時,考慮到只有在對free chunk進行合併的時候才需要腳部,也就是說對於allocated chunk而言它並不需要腳部,因此我們可以對這個腳部加以最佳化——將前一個chunk的已分配/空閒標記位儲存在當前chunk的size欄位的第1,或2位元位上,這樣如果我們透過當前chunk的size欄位知道了前一個chunk為free chunk,那麼就可得出結論:當前chunk地址之前的4個位元組為前一個free chunk的腳部,我們可以透過該腳部獲取前一個chunk的起始位置;如果當前chunk的size欄位的標記位表明前一個chunk是allocated chunk的話,那麼就可得出另一個結論:前一個chunk沒有腳部,即當前chunk地址之前的4個位元組為前一個allocated chunk的payload或padding的最後部分。新的chunk格式圖如下:

p13 圖5-5 改進版的Knuth邊界標記allocated chunk格式

p14 圖5-6 改進版的Knuth邊界標記free chunk格式

2.再進化——支援多執行緒

隨著技術的發展,特別是堆記憶體管理器新增對多執行緒的支援,前述的chunk格式已經難以滿足需求,比如,我們需要標誌位來標記當前chunk是否屬於非主執行緒即thread arena,以及該chunk由mmap得來還是透過brk實現等等。但此時chunk size只剩下一個位元位未使用了,怎麼辦呢?這需要對chunk格式進行大手術!

首先思考:是否有必要同時儲存當前chunk和前一個chunk的已分配/空閒標記位?答案是否定的,因為我們只需要儲存前一個chunk的分配標誌位就可以了,至於當前chunk的分配標誌位,可以透過查詢下一個chunk的size欄位得到。那麼size欄位中剩下的兩個位元位就可以用於滿足多執行緒的標誌需求了:

p15 圖5-7 多執行緒版本Knuth邊界標記allocated chunk格式

p16 圖5-8 多執行緒版本Knuth邊界標記free chunk格式

這裡的P,M,N的含義如下:

  • PREV_INUSE(P): 表示前一個chunk是否為allocated。
  • IS_MMAPPED(M):表示當前chunk是否是透過mmap系統呼叫產生的。
  • NON_MAIN_ARENA(N):表示當前chunk是否是thread arena。

再進一步,發現沒必要儲存chunk size的副本,也就是說Footer的作用並不大,但是如果前一個chunk是free的話,在合併的時候我們又需要知道前一個chunk的大小,怎麼辦呢?將Footer從尾部移到首部,同時其不再儲存當前chunk的size,而是前一個free chunk的size不就行了。同樣的,為了提高記憶體利用率,如果前一個chunk是allocated chunk的話,這個Footer就作為allocated chunk的payload或padding的一部分,結構圖如下:

p17 圖5-9 當前glibc malloc allocated chunk格式

p18 圖5-10 當前glibc malloc free chunk格式

至此,glibc malloc堆記憶體管理器中使用的隱式連結串列技術就介紹完畢了。現在我們再回過頭去看malloc_chunk結構體就很好理解了:該結構體透過每個chunk的prev_size和size構成了隱式連結串列,而後續的fd, bk等指標並不是作用於隱式連結串列的,而是用於後文會介紹的用於加快記憶體分配和釋放效率的顯示連結串列bin(還記得bin麼?用於記錄同一型別free chunk的連結串列),並且這些指標跟prev_size一樣只在free chunk中存在。關於顯示連結串列bin的原理比較複雜,讓我們帶著疑惑,暫時略過這部分資訊,等介紹完所有chunk之後再加以詳細介紹。

5.2 Top Chunk

當一個chunk處於一個arena的最頂部(即最高記憶體地址處)的時候,就稱之為top chunk。該chunk並不屬於任何bin,而是在系統當前的所有free chunk(無論那種bin)都無法滿足使用者請求的記憶體大小的時候,將此chunk當做一個應急消防員,分配給使用者使用。如果top chunk的大小比使用者請求的大小要大的話,就將該top chunk分作兩部分:1)使用者請求的chunk;2)剩餘的部分成為新的top chunk。否則,就需要擴充套件heap或分配新的heap了——在main arena中透過sbrk擴充套件heap,而在thread arena中透過mmap分配新的heap。

5.3 Last Remainder Chunk

要想理解此chunk就必須先理解glibc malloc中的bin機制。如果你已經看了第二部分文章,那麼下面的原理就很好理解了,否則建議你先閱讀第二部分文章。對於Last remainder chunk,我們主要有兩個問題:1)它是怎麼產生的;2)它的作用是什麼?

先回答第一個問題。還記得第二部分文章中對small bin的malloc機制的介紹麼?當使用者請求的是一個small chunk,且該請求無法被small bin、unsorted bin滿足的時候,就透過binmaps遍歷bin查詢最合適的chunk,如果該chunk有剩餘部分的話,就將該剩餘部分變成一個新的chunk加入到unsorted bin中,另外,再將該新的chunk變成新的last remainder chunk

然後回答第二個問題。此型別的chunk用於提高連續malloc(small chunk)的效率,主要是提高記憶體分配的區域性性。那麼具體是怎麼提高區域性性的呢?舉例說明。當使用者請求一個small chunk,且該請求無法被small bin滿足,那麼就轉而交由unsorted bin處理。同時,假設當前unsorted bin中只有一個chunk的話——就是last remainder chunk,那麼就將該chunk分成兩部分:前者分配給使用者,剩下的部分放到unsorted bin中,併成為新的last remainder chunk。這樣就保證了連續malloc(small chunk)中,各個small chunk在記憶體分佈中是相鄰的,即提高了記憶體分配的區域性性。

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章