讓我們編寫一個malloc函式,看看它在既有程式中如何工作!
本教程假定你瞭解指標,知道C語言中 *ptr
間接引用一個指標, ptr->foo
表示 (*ptr).foo
,malloc用於記憶體動態分配,並且熟悉連結串列的概念。如果想要學習本教程但你不瞭解C,請告知我哪些部分需要更詳細的論述。如果你想要馬上瀏覽所有程式碼,可以再這裡檢視。該測試程式碼由Andrew Roth提供,他的github程式碼倉庫中存放了一些malloc函式的測試程式碼。
暫且不管引導部分,malloc函式的定義如下:
1 |
void *malloc(size_t size); |
函式輸入位元組大小,返回指向輸入位元組大小記憶體的指標。
實現方法有很多。我們直接選擇使用sbrk系統呼叫。作業系統為程式預留了堆和棧空間,sbrk允許我們操作堆。sbrk(0)會返回指向當前堆頂部的指標。sbrk(foo)會增加foo位元組的堆空間並返回指向當前堆頂部的指標。
如果想要實現一個很簡單的malloc,我們可以這樣做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <assert.h> #include <string.h> #include <sys/types.h> #include <unistd.h> void *malloc(size_t size) { void *p = sbrk(0); void *request = sbrk(size); if (request == (void*) -1) { return NULL; // sbrk failed } else { assert(p == request); // Not thread safe. return p; } } |
當程式呼叫malloc進行空間分配時,malloc呼叫sbrk增加堆空間並返回指向堆上新分配區域起始位置的指標。這裡丟失了一些技術細節, malloc(0)
應該返回 NULL
或者另一個可以傳遞給free函式而不造成破壞的指標,但它基本上可以工作。
但說到free函式,free是如何工作的?Free的原型如下:
1 |
void free(void *ptr); |
當free函式中傳入一個由malloc返回的指標時,它應該釋放這塊空間。但如果傳入的指標由我們編寫的malloc函式返回,我們就無法獲取指標關聯的空間大小。我們在哪裡儲存相關資訊?如果我們的malloc正常工作,我們可以分配一些空間並在那儲存這些資訊,但如果每次呼叫malloc函式分配更多空間都必須呼叫malloc函式來分配更多空間,我們將陷入困境。
解決上述問題常見的方法是在返回指標之後某處儲存當前記憶體區域的元資訊。假設當前堆頂地址為 0x1000
,我們需要分配 0x400
位元組空間。我們當前的malloc函式會使用sbrk函式申請 0x400
位元組空間並返回指向0x1000的指標。如果說使用 0x10
位元組空間儲存塊資訊,我們的malloc需要呼叫 sbrk
分配 0x410
位元組空間並返回一個指向 0x1010
地址的指標,將0x10位元組的元資訊與呼叫malloc函式的程式碼分離開。
這就允許我們釋放記憶體單元,但接著該怎麼做?從作業系統中分配的堆空間必須是連續的,所以我們不能返回作業系統中間的記憶體塊。即使我們願意複製新釋放區域上的所有內容向下填補這塊空白,這樣我們可以返回空間的尾部,但依舊沒辦法通知的所有程式碼的堆指標必須進行調整。
相反,我們可以標記已經釋放的記憶體塊而不必將其返回作業系統,這樣以後呼叫malloc可以使用回收的記憶體塊。但那樣做必須能訪問每一塊記憶體的元資訊。可行的方案有很多,為簡單起見這裡我們直接選用單連結串列。
那麼,對於每一塊記憶體,我們需要有以下資訊:
1 2 3 4 5 6 7 8 |
struct block_meta { size_t size; struct block_meta *next; int free; int magic; // For debugging only. TODO: remove this in non-debug mode. }; #define META_SIZE sizeof(struct block_meta) |
我們需要知道記憶體塊的大小,無論是否空閒,也不管下一塊記憶體是什麼。magic引數是為了便於調,但實際是不必要的;我們將其設為任意值,方便我們檢視最後修改結構體的程式碼。
我們還需要給連結串列新增一個頭指標:
1 |
void *global_base = NULL; |
對於編寫的malloc,我們希望儘可能的重用空閒空間,在不能重新使用已存在空間時進行空間分配。假設我們有這樣的連結串列結構,檢查其中是否包含空閒記憶體並直接返回。當需要分配記憶體空間時,我們會遍歷整個連結串列來檢視是否存在足夠的空閒空間。
1 2 3 4 5 6 7 8 |
struct block_meta *find_free_block(struct block_meta **last, size_t size) { struct block_meta *current = global_base; while (current && !(current->free && current->size >= size)) { *last = current; current = current->next; } return current; } |
如果找不到空閒記憶體塊,我們必須使用sbrk從作業系統中申請空間並將新申請的記憶體塊新增至連結串列結尾。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct block_meta *request_space(struct block_meta* last, size_t size) { struct block_meta *block; block = sbrk(0); void *request = sbrk(size + META_SIZE); assert((void*)block == request); // Not thread safe. if (request == (void*) -1) { return NULL; // sbrk failed. } if (last) { // NULL on first request. last->next = block; } block->size = size; block->next = NULL; block->free = 0; block->magic = 0x12345678; return block; } |
和原來的實現一樣,我們使用sbrk申請空間。但我們增加了一些額外空間來儲存結構體並對結構體變數進行了合理設定。
既然我們已經擁有函式能夠檢查是否擁有空閒空間並申請空間,malloc非常簡單。如果全域性頭指標為 NULL
,我們需要分配空間並將頭指標指向新分配的空間。如果頭指標非空,我們需要檢視能否重用任何已存在的空間。如果能,那麼就重用;如果不能,那麼我們分配空間並使用新分配的空間。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
void *malloc(size_t size) { struct block_meta *block; // TODO: align size? if (size <= 0) { return NULL; } if (!global_base) { // First call. block = request_space(NULL, size); if (!block) { return NULL; } global_base = block; } else { struct block_meta *last = global_base; block = find_free_block(&last, size); if (!block) { // Failed to find free block. block = request_space(last, size); if (!block) { return NULL; } } else { // Found free block // TODO: consider splitting block here. block->free = 0; block->magic = 0x77777777; } } return(block+1); } |
對於那些不熟悉C的人,我們返回block+1,因為我們想在block_meta結構之後返回一個指向該區域的指標。因為block是指向 struct block_meta
型別的指標,所以+1
會將指標的地址向後增加 sizeof(struct(block_meta))
個位元組。
如果我們僅僅要一個malloc,不需要free,我們可以使用原來的malloc函式,更簡單。所以我們來編寫free函式!free函式主要是要設定 ->free
引數。
因為在程式碼中,我們需要在很多地方獲取結構體的地址,所以下面我們來定義這個函式。
1 2 3 |
struct block_meta *get_block_ptr(void *ptr) { return (struct block_meta*)ptr - 1; } |
既然實現了這個函式,下面給出free函式的實現:
1 2 3 4 5 6 7 8 9 10 11 12 |
void free(void *ptr) { if (!ptr) { return; } // TODO: consider merging blocks once splitting blocks is implemented. struct block_meta* block_ptr = get_block_ptr(ptr); assert(block_ptr->free == 0); assert(block_ptr->magic == 0x77777777 || block_ptr->magic == 0x12345678); block_ptr->free = 1; block_ptr->magic = 0x55555555; } |
除設定 ->free
引數外,呼叫free函式釋放空指標是合法的,所以我們必須檢查空指標。因為free函式不應該被任意已釋放的地址或記憶體塊呼叫,所以我們可以斷言這些情況永遠不會發生。
你其實不必做出任何斷言,但這通常會讓除錯變得更加容易。事實上,在編寫程式碼時,我曾遇到過bug,如果沒有斷言這些bug將導致不明的資料崩潰。然而,程式碼在斷言處出錯,這使得除錯更加細緻。
既然已實現malloc和free函式,我們可以使用我們編寫的記憶體分配函式來編寫程式!但在將我們的分配函式新增到已有程式碼前,需要實現一些更加常用的函式,realloc和calloc。Calloc僅僅是在malloc之後將分配的記憶體初始化為0,所以我們首先來看看realloc。Realloc應該可以調整使用malloc,calloc以及realloc分配的記憶體塊大小。
Realloc的函式原型如下:
1 |
void *realloc(void *ptr, size_t size) |
如果傳遞一個空指標給realloc,它應該向malloc一樣工作。如果傳入一個已分配指標,如果空間小於已分配大小應釋放當前空間,如果空間大於已分配大小應分配更大空間並複製已存在的資料。
當空間減小時,如果我們不調整空間大小,不釋放任何空間,一切都將正常工作,但當空間增大時,我們必須分配更多空間,所以下面我們來實現這個功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void *realloc(void *ptr, size_t size) { if (!ptr) { // NULL ptr. realloc should act like malloc. return malloc(size); } struct block_meta* block_ptr = get_block_ptr(ptr); if (block_ptr->size >= size) { // We have enough space. Could free some once we implement split. return ptr; } // Need to really realloc. Malloc new space and free old space. // Then copy old data to new space. void *new_ptr; new_ptr = malloc(size); if (!new_ptr) { return NULL; // TODO: set errno on failure. } memcpy(new_ptr, ptr, block_ptr->size); free(ptr); return new_ptr; } |
至於calloc,該函式僅僅是在指標返回之前清空記憶體。
1 2 3 4 5 6 |
void *calloc(size_t nelem, size_t elsize) { size_t size = nelem * elsize; void *ptr = malloc(size); memset(ptr, 0, size); return ptr; } |
在linux下新分配頁空間(不是重用的空閒塊)可以刪除memset,因為linux保證新分配的記憶體全部初始化為0,至少到目前為止是這樣的。
好了,我們現在的程式碼足以用到既有的程式(而且我們甚至不需要重新編譯)!
首先,我們需要編譯程式碼。linux下使用如下命令編譯:
1 |
clang -O0 -g -W -Wall -Wextra -shared -fPIC malloc.c -o malloc.so |
應該能夠工作。
-g
新增除錯標識,這樣我們可以使用 gdb
或 lldb
檢視程式碼。-O0
可以防止區域性變數被優化,便於除錯。 -W -Wall -Wextra
新增額外警告。 -shared -fPIC
允許程式碼動態連結,這樣我們可以在程式碼中使用已存在的二進位制程式碼庫!
在macs下,我們使用如下編譯命令:
1 |
clang -O0 -g -W -Wall -Wextra -dynamiclib malloc.c -o malloc.dylib |
注意最新版本的OS X上sbrk已被廢棄。蘋果對已廢棄的函式使用非正統定義——一些廢棄的系統呼叫被嚴重破壞。我並沒有在Mac上進行測試,所以這可能會在mac引起奇怪的錯誤或者不能工作。
現在,為了在linux下呼叫二進位制庫來使用我們的malloc函式,我們需要設定LD_PRELOAD
環境變數。如果你正在使用bash,你可以這樣做:
1 |
export LD_PRELOAD=/absolute/path/here/malloc.so |
如果你使用的是mac,你可以這樣做:
1 |
export DYLD_INSERT_LIBRARIES=/absolute/path/here/malloc.so |
如果一切正常,你可以執行一些任意二進位制檔案,它能夠正常工作(除了有點慢)。
1 2 |
$ ls Makefile malloc.c malloc.so README.md test test-0 test-1 test-2 test-3 test-4 wrapper wrapper.c |
如果有bug,你可能會看到如下資訊:
1 2 |
$ ls Segmentation fault (core dumped) |
除錯
下面我們來談談除錯!如果熟悉使用偵錯程式斷點的設定,記憶體檢視以及單步除錯,你可以跳過本節內容,直接閱讀練習部分。
本節假設你知道如何在你的系統上安裝gdb。如果你使用的是mac,你可能只需要使用lldb併合理轉換指令。因為不知道你可能會遇到哪些bug,我將介紹一些bug並說明我是如何解決的。
首先,需要明白如何執行gdb而不遇到段錯誤。如果ls出錯,我們執行 gdb ls
,gdb幾乎肯定也會出現段錯誤。
Andrew Roth在github上有這方面用途的程式碼,所以我們直接使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
int main(int argc, char **argv) { // Check that we have at least one arg. if (argc == 1) { printf("You must supply a program to be invoked to use your replacement malloc() script.n"); printf("...you may use any program, even system programs, such as `ls`.n"); printf("n"); printf("Example: %s /bin/lsn", argv[0]); return 1; } /* * Set up the environment to pre-load our 'malloc.so' shared library, which * will replace the malloc(), calloc(), realloc(), and free() that is defined * by standard libc. */ char **env = malloc(2 * sizeof(char *)); env[0] = malloc(100 * sizeof(char)); sprintf(env[0], "LD_PRELOAD=./malloc.so"); env[1] = NULL; /* * Replace the current running process with the process specified by the command * line options. If exec() fails, we won't even try and recover as there's likely * nothing we could really do; however, we do our best to provide useful output * with a call to perror(). */ execve(argv[1], argv + 1, env); /* Note that exec() will not return on success. */ perror("exec() failed"); free(env[0]); free(env); return 2; } |
既然檔案中設定了LD_PRELOAD巨集,我們可以在gdb下執行該檔案,這樣gdb會使用標準malloc而該檔案中的程式碼會呼叫我們有bug的malloc。我要首先介紹的bug是在free中沒有檢查NULL
指標。
1 2 |
$ ./wrapper /bin/ls # note that we need the full path here Segmentation fault (core dumped) |
run
向wrapper
傳遞引數。
1 2 3 4 5 6 7 8 |
$ gdb wrapper (gdb) run /bin/ls Starting program: /home/dluu/dev/dump/malloc/wrapper /bin/ls process 26604 is executing new program: /bin/ls Program received signal SIGSEGV, Segmentation fault. 0x00007ffff7bd7dbd in free (ptr=0x0) at malloc.c:113 113 assert(block_ptr->free == 0); |
和預期的一樣,我們遇到了段錯誤。我們可以使用 list
檢視段錯誤附近的的程式碼。
1 2 3 4 5 6 7 8 9 10 11 |
(gdb) list 108 } 109 110 void free(void *ptr) { 111 // TODO: consider merging blocks once splitting blocks is implemented. 112 struct block_meta* block_ptr = get_block_ptr(ptr); 113 assert(block_ptr->free == 0); 114 assert(block_ptr->magic == 0x77777777 || block_ptr->magic == 0x12345678); 115 block_ptr->free = 1; 116 block_ptr->magic = 0x55555555; 117 } |
然後我們可以使用引數p
(用於列印資訊)來檢視這裡的變數發生了什麼變化:
1 2 3 4 |
(gdb) p ptr $6 = (void *) 0x0 (gdb) p block_ptr $7 = (struct block_meta *) 0xffffffffffffffe8 |
ptr
值為 0
,即為 NULL
,這是導致這個問題的原因:我們忘記檢查空指標。
既然弄明白原因,我們要嘗試稍微難一點的bug。假設我們決定用以下結構體替換我們的結構體:
1 2 3 4 5 6 7 |
struct block_meta { size_t size; struct block_meta *next; int free; int magic; // For debugging only. TODO: remove this in non-debug mode. char data[1]; }; |
然後malloc會返回 block->data
而不是 block+1
,其餘保持不變。這和我們已經做的極為相似——我們只需在結構體末尾定義一個成員,並返回一個指向該變數的指標。
但如果我們嘗試使用新的malloc函式,會出現下面的問題:
1 2 3 4 5 6 7 8 9 10 11 |
$ ./wrapper /bin/ls Segmentation fault (core dumped) gdb wrapper (gdb) run /bin/ls Starting program: /home/dluu/dev/dump/malloc/wrapper /bin/ls process 26633 is executing new program: /bin/ls Program received signal SIGSEGV, Segmentation fault. _IO_vfprintf_internal (s=s@entry=0x7fffff7ff5f0, format=format@entry=0x7ffff7567370 "%s%s%s:%u: %s%sAssertion `%s' failed.n%n", ap=ap@entry=0x7fffff7ff718) at vfprintf.c:1332 1332 vfprintf.c: No such file or directory. 1327 in vfprintf.c |
這並不像上一個錯誤一樣簡單——我們可以看到其中一個斷言失敗,但斷言失敗時gdb會丟擲一些呼叫的print函式。但print函式也使用了有bug的malloc並且失敗!
這裡我們可以檢視 ap
的值來弄清楚 assert
要列印什麼資訊:
1 2 |
(gdb) p *ap $4 = {gp_offset = 16, fp_offset = 48, overflow_arg_area = 0x7fffff7ff7f0, reg_save_area = 0x7fffff7ff730} |
這樣就可以了;我們可以溜達一會直到我們弄明白哪些資訊應該列印輸出以及為什麼會失敗。其他的一些解決方案是編寫自定義斷言或使用掛鉤避免斷言使用我們的malloc。
但在這種情況下,我們知道程式碼中有一些斷言。malloc函式中的一個斷言檢查我們沒有在多執行緒程式中使用該函式,free中的兩個斷言檢查我們沒有釋放不該釋放的。我們首先設定斷點檢視free函式。
1 2 3 4 5 6 7 8 9 |
$ gdb wrapper (gdb) break free Breakpoint 1 at 0x400530 (gdb) run /bin/ls Starting program: /home/dluu/dev/dump/malloc/wrapper /bin/ls process 26700 is executing new program: /bin/ls Breakpoint 1, free (ptr=0x61c270) at malloc.c:112 112 if (!ptr) { |
block_ptr
尚未被置位,但是如果我們使用幾次 s
進行單步除錯直到它被置位,我們可以看到它的具體值:
1 2 3 4 5 6 7 |
(gdb) s (gdb) s (gdb) s free (ptr=0x61c270) at malloc.c:118 118 assert(block_ptr->free == 0); (gdb) p/x *block_ptr $11 = {size = 0, next = 0x78, free = 0, magic = 0, data = ""} |
我使用 p/x
引數替代 p
引數,這樣可以檢視十六進位制形式。 magic
域值為0,對於我們要釋放的有效結構體來說是不可能的。也許 get_block_ptr
會返回一個錯誤的偏移量?我們可以檢視 ptr
的值,所以我們能夠檢視不同偏移量。因為它是void *
型別,我們必須進行型別轉換,這樣gdb才知道如何計算結果。
1 2 3 4 5 6 7 8 |
(gdb) p sizeof(struct block_meta) $12 = 32 (gdb) p/x *(struct block_meta*)(ptr-32) $13 = {size = 0x0, next = 0x78, free = 0x0, magic = 0x0, data = {0x0}} (gdb) p/x *(struct block_meta*)(ptr-28) $14 = {size = 0x7800000000, next = 0x0, free = 0x0, magic = 0x0, data = {0x78}} (gdb) p/x *(struct block_meta*)(ptr-24) $15 = {size = 0x78, next = 0x0, free = 0x0, magic = 0x12345678, data = {0x6e}} |
如果從我們使用的地址往後退一點,我們可以看到正確的偏移量是24而不是32。這是因為結構體後有附加資料,所以 sizeof(struct block_meta)
的值為32,儘管最終有效的成員偏移為24。如果想要去掉附加空間,我們必須修改 get_block_ptr
。
以上是除錯的內容!
練習
就個人而言,我從未遇到這種問題直到做了一些練習,所以這裡為感興趣的任何人提供一些練習。
- malloc應該返回一個指標,該指標能夠和任意內建型別對齊。我們的malloc函式是這樣嗎?如果是,為什麼?如果沒有,修改對齊。注意C中“任意內建型別”基本上達到8個位元組,因為SSE/AVX型別不是內建型別。
- 如果嘗試重新使用一塊已有空間但又不需要整塊空間,我們的malloc函式很低效。實現一個能分割記憶體塊的函式,這樣就能夠使用所需的最小空間。
- 在完成
2
後,如果我們頻繁呼叫malloc和free分配釋放任意空間大小,最終會產生大量小記憶體塊,這些記憶體塊只有在我們分配少量空間時才能被重用。實現一種合併相鄰空閒記憶體的機制,這樣任意連續的空閒記憶體塊可以合併成一個完整的記憶體空間。 - 在現有程式碼中查詢bug!我並沒有過多測試,所以我肯定這裡有很多bug,儘管基本上八九不離十。
Parts 2-N
接下來,我們要弄明白如何提高速度並實現執行緒安全。
資源
在坐下來編寫自己的實現之前,我閱讀了Marwan Burelle的教程,所以實現非常相似。實現的主要不同之處在與我的版本更加簡單,但更容易產生記憶體碎片。在解釋方面,那個不同風格作者的教程可能會更加適合你。
更多關於Linux如何進行記憶體管理的內容,可以閱讀Gustavo Duarte的文章。
更多關於實際使用malloc函式實現的工作原理,dlmalloc和tcmalloc值得閱讀。我沒有閱讀過jemalloc的程式碼,據說有點難以理解,但它仍然是廣泛使用的高效能malloc實現。
為方便除錯,Address Sanitizer功能強大。如果你想要編寫一個執行緒安全的版本,Thread Sanitizer也是一個很棒的工具。
致謝
非常感謝Gustavo Duarte提供的sbrk函式說明圖,Ian Whitlock和Danielle Sucher找出文中的拼寫錯誤,以及Nathan Kurz建議的額外資源。如果你發現本文中的其他問題,請告訴我(無論是寫作或者程式碼)。