
简介一份用C语言自制malloc内存分配函数的入门代码包主要面向想搞清动态内存管理底层逻辑、不再停留在API调用层面的C语言学习者和开发者。包内包含mymalloc.h头文件与mymalloc.c、test.c两个C源文件共3个文件压缩包仅3KB代码简短却覆盖了简易内存分配器的核心流程空闲内存块遍历、按需拆分、释放后合并相邻空闲块以及内存池不足时向系统申请扩展。配套测试文件可以直接编译运行验证分配、重分配与释放的基本行为也方便在此基础上尝试首次适配、最佳适配等不同空闲块选择策略。资源还顺带涉及内存对齐、碎片、多线程并发等真实malloc的复杂点帮助读者建立从简易实现到工业级设计的认知桥梁。目前已有1825人学习下载是一份精炼实用的动手资料适合想深挖内存管理的开发者。 自己动手写一个malloc函数这念头我在很多个深夜都有过。不是公司项目需要纯粹是好奇每个C程序员天天调malloc它到底从哪儿弄来内存的为什么有时候明明还剩十几个GB内存malloc却返回NULL为什么free之后看内存占用好像也没少与其翻各种二手资料不如自己实现一版看看。于是就有了这篇完整记录。我会把从零实现一个可用malloc的全过程写出来包括数据结构怎么设计、怎么向系统要内存、为什么必须对齐、碎片怎么处理以及我踩过的几个特别典型的坑。适合想彻底搞懂堆内存原理的人也适合正在准备系统方向面试的同学。读完你至少能回答三个问题malloc在底层干了什么、free怎么把内存还回去、一个最简单的分配器应该长什么样。1. 为什么值得自己实现malloc三个绕不开的理由1.1 搞懂“内存从哪来”系统调用这一层很多人以为malloc是从物理内存里“拿”一块空间这个理解其实偏了。malloc面对的是进程的虚拟地址空间它真正要做的是在虚拟内存里划出一块区域并且处理好这块区域的元数据。在Linux上malloc底层依赖brk/sbrk或mmap这两类系统调用。sbrk可以把进程堆区的边界program break往高地址推推出来的新区域就是你能用的堆空间mmap则更适合分配超大块直接在进程地址空间里找一块空闲映射。如果不亲手实现一遍这些系统调用永远只是课本上的名词。你只有自己调sbrk扩展堆、自己维护分配记录才会意识到“内存分配”本质上是一个管理虚拟地址空间的过程而不是什么神秘魔法。1.2 性能问题通用malloc不一定适合你glibc里的ptmalloc针对通用场景做了大量优化但它必须照顾各种负载小对象、大对象、多线程、高并发、低碎片。代价就是代码极其复杂而且某些特定模式下的表现并不理想。比如你在一个循环里反复分配释放固定大小的对象通用malloc每次都要走锁和搜索逻辑性能上会被那种为固定size做了freelist缓存的专用分配器甩开一大截。自己写malloc最大的价值不是替代glibc而是搞清楚“分配器性能差异”到底从哪来。当你自己实现一版再优化一版你才会真正理解为什么现代分配器要做分箱size class、线程本地缓存、无锁化这些设计。这是读多少篇源码分析都换不来的手感。1.3 排查malloc失败的基础网上有个很常见的崩溃信息native memory allocation (malloc) failed to allocate 2046256 bytes for chunk。Java、数据库、中间件里经常看到。很多人第一反应是“内存不够了”但实际原因往往更隐蔽要么是堆空间碎片化严重导致找不到连续区域要么是地址空间被虚拟内存限制卡住要么是堆元数据被越界写破坏了。当你自己实现过malloc就会有排查这类问题的直觉。你会知道“碎片”是什么形态、“元数据”为什么会被破坏、“连续地址空间”为什么重要。这些经验在平时调库时很难积累实现一遍自然就通了。2. 动手前必须理解的三件底层事2.1 虚拟内存与sbrk一张向上增长的纸可以把进程的虚拟地址空间想象成一张很长的纸堆区从低地址往高地址方向生长。系统用program break标出当前堆区的合法上限所有低于这个break的地址都可以访问高于这个break的地址一碰就是段错误。sbrk的作用就是把break往上推推多少就相当于向系统要了多少堆空间。一个关键概念是sbrk返回的是“移动之前”的break地址所以扩展堆的标准流程是先sbrk(0)拿到当前边界再sbrk(size)推进边界。这样新得到的地址一定是从旧边界开始的连续区域。第一版分配器不需要复杂映射用sbrk就够了。注意sbrk属于Unix/Linux接口Windows下对应的是VirtualAlloc或HeapAlloc但思路可以平移。2.2 内存对齐为什么不是按字节数分配CPU访问对齐的内存效率最高某些指令比如SSE系列甚至要求数据必须16字节对齐否则直接系统崩溃。所以malloc必须保证返回的地址满足CPU的对齐要求在x86-64上通常是16字节在32位平台上是8字节。这意味着分配35个字节时实际要分配40甚至48个字节。具体要对齐到多少取决于你的目标平台和底层硬件的max_align_t。自己实现时最简单的做法是定一个8字节或16字节的对齐常量然后把所有请求size向上取整到它的倍数。别小看这个取整很多后续的奇奇怪怪bug追到根上就是某次分配返回了一个没对齐的地址。2.3 数据结构选型为什么首选双向链表分配器维护已分配和空闲区域的方式有很多位图、空闲链表、伙伴系统、红黑树。第一版我强烈建议用双向链表。原因有二第一实现直观每个内存块前面放一个头部结构体记录大小和状态所有块通过next/prev指针串起来整个堆的状态一眼能看穿第二合并空闲块时双向链表能方便地访问前驱和后继虽然物理相邻的检查还得靠地址计算但链表能帮你快速找到相邻块。伙伴系统适合2的幂次分配位图适合固定大小对象红黑树适合追求查找效率。但这些对第一版来说都是过度设计。双向链表慢是慢一点胜在简单能让你把注意力集中在分配、拆分、合并、对齐这些核心问题上。3. 第一版实现基于双向链表的first-fit分配器3.1 核心数据结构block头与柔性数组先上结构体定义。#include stdio.h #include stddef.h #include unistd.h #include string.h #define ALIGNMENT 8 #define MAGIC 0xC0FFEE typedef struct block_header { size_t size; /* 数据区大小 */ struct block_header *next; /* 链表后继 */ struct block_header *prev; /* 链表前驱 */ int free; /* 1空闲, 0已分配 */ unsigned int magic; /* 校验字段 */ char data[0]; /* 柔性数组数据区从这里开始 */ } block_header_t; static block_header_t *list_head NULL; /* 所有块的链表头 */这里最关键的是char data[0]柔性数组。它不占用结构体空间但data的地址正好是头部结束的位置。分配器对外返回的指针就是block-data。而当我们拿到用户传回的地址时用offsetof(block_header_t, data)反推出头部地址这个操作在free时是核心。3.2 malloc对齐、查找、拆分、扩展malloc的完整逻辑分四步先对请求大小做对齐再遍历链表找一个足够大的空闲块找到就尝试拆分找不到就调用sbrk向系统扩展堆。static int is_adjacent(block_header_t *a, block_header_t *b) { return (char *)a sizeof(block_header_t) a-size (char *)b; } static block_header_t *find_free(size_t aligned) { block_header_t *p list_head; while (p) { if (p-free p-size aligned) return p; p p-next; } return NULL; } static void split(block_header_t *b, size_t size) { block_header_t *newb; if ((long)(b-size - size - sizeof(block_header_t)) 0) return; newb (block_header_t *)((char *)b-data size); newb-size b-size - size - sizeof(block_header_t); newb-free 1; newb-magic MAGIC; newb-next b-next; if (b-next) b-next-prev newb; newb-prev b; b-next newb; b-size size; } static block_header_t *extend_heap(size_t size) { void *p sbrk(0); if (sbrk(sizeof(block_header_t) size) (void *)-1) return NULL; block_header_t *block (block_header_t *)p; block-size size; block-free 1; block-magic MAGIC; block-next NULL; block-prev NULL; if (list_head) { block_header_t *tail list_head; while (tail-next) tail tail-next; tail-next block; block-prev tail; } else { list_head block; } return block; } void *my_malloc(size_t size) { if (size 0) size 1; size (size ALIGNMENT - 1) ~(ALIGNMENT - 1); block_header_t *p find_free(size); if (p) { split(p, size); p-free 0; return p-data; } p extend_heap(size); if (!p) return NULL; p-free 0; return p-data; }有几个细节值得强调。对齐计算的位运算(size ALIGNMENT - 1) ~(ALIGNMENT - 1)等价于向上取整到8的倍数。比如size35得到40size8还是8。比手写取模快也更符合分配器的调性。split时为什么要判断剩余空间是否大于一个头部因为如果剩余不够一个头部加最小数据区拆出来一个无法管理的小块反而制造碎片不如整个给用户。这个阈值判断是很多初学者最容易漏掉的。extend_heap有个容易踩的坑新块要挂到链表尾部否则后续free时链表遍历不完整合并逻辑也会出问题。我在第一版里偷懒没挂链表结果free后链表中找不到相邻块内存泄漏得莫名其妙。3.3 free标记、合并与magic校验free只有两件事把块状态改回空闲然后看看物理上相邻的前后块是不是也空闲是就合并。static block_header_t *get_header(void *ptr) { return (block_header_t *)((char *)ptr - offsetof(block_header_t, data)); } void my_free(void *ptr) { if (!ptr) return; block_header_t *b get_header(ptr); if (b-magic ! MAGIC) { fprintf(stderr, bad magic, check double free or overflow\n); return; } b-free 1; /* 向后合并 */ if (b-next b-next-free is_adjacent(b, b-next)) { b-size sizeof(block_header_t) b-next-size; b-next b-next-next; if (b-next) b-next-prev b; } /* 向前合并 */ if (b-prev b-prev-free is_adjacent(b-prev, b)) { b-prev-size sizeof(block_header_t) b-size; b-prev-next b-next; if (b-next) b-next-prev b-prev; } }链表中相邻的两个块不一定是物理相邻的所以合并前必须用is_adjacent验证地址是否连得上。这个检查我第一次写漏了导致合并出一块包含“空洞”的大块后来分配返回了错误地址程序直接崩。加一个地址判断就稳了。magic校验算是简易的防御机制。double free、越界写破坏头部时magic大概率会变能提前发现一大批问题。生产级分配器还会做canary金丝雀值原理类似。3.4 配套函数calloc和realloccalloc就是“malloc加清零”。void *my_calloc(size_t nmemb, size_t size) { size_t total nmemb * size; void *p my_malloc(total); if (p) memset(p, 0, total); return p; }realloc按“新开一块、拷贝数据、释放旧块”的朴素思路实现。void *my_realloc(void *ptr, size_t size) { if (!ptr) return my_malloc(size); block_header_t *b get_header(ptr); if (b-size size) return ptr; void *np my_malloc(size); if (!np) return NULL; memcpy(np, ptr, b-size); my_free(ptr); return np; }这个realloc有个明显缺点缩容时不处理扩容时可能因为拷贝大块数据而变慢。真正的realloc还会尝试原地扩展相邻空闲块但我第一版先保证功能正确之后再优化。够用就好别一口气吃成胖子。4. 进一步优化碎片控制、多线程与调试4.1 first-fit、best-fit与碎片控制我上面的实现用的是first-fit找到第一个大小满足的空闲块就用。优点是速度快缺点是可能把一个很大的空闲块拆得零零碎碎。best-fit则是遍历所有空闲块选大小最接近请求的那个内存利用率更高但耗时更长而且容易产生大量无法使用的小碎片。实际工程里没有银弹。ptmalloc采用的是分箱策略把小对象按大小分成多个链表大对象走mmap每个size class内部再配合切割和合并。你在自己的分配器里也可以做类似事比如小于64字节的请求走一个专门的freelist避免每次和大于几MB的空闲块纠缠。碎片问题的本质是分配和释放的顺序随机分配器只能在“快”和“省”之间找一个平衡点。4.2 加锁让分配器在多线程下存活我第一版完全没考虑线程安全。多线程同时调my_malloc两个线程可能同时拿到同一个空闲块轻则重复分配重则链表被改坏直接段错误。最简单的修复是加一个pthread_mutex_t进入my_malloc和my_free时全程加锁。#include pthread.h static pthread_mutex_t heap_lock PTHREAD_MUTEX_INITIALIZER; void *my_malloc_locked(size_t size) { pthread_mutex_lock(heap_lock); void *p my_malloc(size); pthread_mutex_unlock(heap_lock); return p; }全局锁的缺点是竞争激烈时所有线程互相等待。更高效的做法是线程本地缓存每个线程维护一个小的freelist优先从本地拿拿不到再从全局堆取。这已经接近tcmalloc的设计思路了。第一版用锁先保证正确多线程性能优化是后续的事。4.3 调试分配器的三板斧自己写的分配器出bug是常态调试手段必须跟上。我常用的有三招。第一招是magic检查。每个块的头部都写一个固定魔数每次分配和释放前校验魔数不对就说明有人越界写或者重复释放了。第二招是打印空闲链表。写一个dump函数遍历所有块打印地址、大小、空闲状态和前后继地址。内存泄露还是碎片问题看一遍链表就全清楚了。第三招是拿AddressSanitizer对比。ASan能直接抓出越界访问和use-after-free和你的magic检查交叉验证能更快锁定问题。啊对了如果你改过分配器之后程序还是诡异崩溃先检查是不是自己的代码用了系统malloc而你free时混用了my_free。这种“混用分配器”的坑比分配器本身的bug更隐蔽。5. 常见问题与排查经验实录5.1 double free的现场与排查double free最常见的症状是程序在某个free时报段错误或者链表遍历时死循环。用我上面的mallocdouble free的第一个症状通常是magic值不对因为第二次free时头部可能已经被复用改成别的数据了。真实项目里double free往往不是直接free两次而是两个指针指向同一块内存释放了两次。排查时先把magic校验打开配合打印调用栈基本十拿九稳。5.2 越界写让相邻块的magic“牺牲”数组越界是分配器最大的天敌。你分配了10个字节写入了20个多出来的10个字节会写到下一个块的头部区域把下一个块的size或magic改掉。表面现象是某个无辜的free突然崩溃或者下一次malloc遍历链表时拿到一个size大得离谱的块。我调试过程中靠magic快速定位过半次这类问题打印所有块的头部谁magic不对谁就是被越界写撞到的受害者。5.3 地址未对齐导致的总线错误如果在某些架构上返回了未对齐地址轻则性能下降重则直接SIGBUS。这类错误潜伏期很长可能跑很久才随机暴露。排查时用gdb打断点检查返回地址对8或16取余是否为零即可。别问我是怎么知道的问就是我在老版本里漏了分配时的对齐取整跑了一整天压力测试才崩。5.4 内存越用越大的真相碎片与泄漏程序内存单调上涨很多人第一反应是“内存泄漏”。但有时候泄漏的不是指针而是碎片。如果分配和释放顺序不佳空闲块被拆得七零八落每个碎片都小于接下来要分配的大小malloc就不得不持续向系统要新内存表现为RSS持续上涨。这时候单纯查泄漏是查不出来的得用我上面说的dump链表来看碎片化程度。区分泄漏和碎片的方法也很简单跑一段时间后把空闲链表总大小和实际分配大小分别打出来如果空闲总量很大但就是凑不出一个连续块那就是碎片问题。症状可能原因排查手段free崩溃double free或头部被越界写magic校验、打印调用栈返回异常大块链表被越界写破坏dump所有块的size和magic随机段错误地址未对齐或use-after-free检查返回地址8字节对齐、gdb断点内存持续上涨泄漏或碎片化打印空闲链表统计区分两种情况断断续续写完这个分配器我最大的一个感受是malloc失败往往不是系统内存真的不够而是我们的分配策略或者使用方式在某个角落里出了问题。自己实现的版本虽然简陋但每一步都能看到数据在变这种掌控感是直接调库给不了的。如果你也想练手我建议从单线程版开始加上magic和调试链表后跑一跑压力测试再逐步做分箱和锁优化。后续还能往分配分箱、线程本地缓存、mmap大块分配等方向扩展每一步都会让你对“内存”的理解更扎实一截。本文还有配套的精品资源点击获取