☰
手写 malloc:从虚拟内存到 brk/mmap 与 chunk/bin 的底层揭秘
2026/10/20 2:50:11 网站建设 项目流程

简介:《自己动手写malloc函数》是一份面向C语言开发者与系统编程入门者的迷你源码包,聚焦动态内存分配原理。资源体积仅3KB,却用mymalloc.h、mymalloc.c与test.c三个文件搭建了一个可运行的自研内存分配器示例:通过空闲链表维护内存块,演示分配时的遍历切分、释放后的标记回收与相邻空闲块合并,并讨论堆区增长与内存碎片处理思路。

资源共3个文件,包含2个C源码和1个头文件。mymalloc.c实现核心分配逻辑,test.c提供基础测试路径,头文件则给出对外接口。代码结构简短清晰,适合结合《深入理解malloc:自己动手写内存分配函数》一文逐行对照,理解堆、内存块、内存池等概念。

已有1829人学习下载。对初学者而言,它能帮你打破malloc的“黑盒”印象;对进阶开发者,也可作为对比复杂分配器设计(如首次适配、最佳适配策略)的起点。

1. malloc 的反直觉起点:调用它,内核其实什么都没给你

先抛一个结论:调用malloc(1024)那一刻,操作系统并没有真的给你 1024 字节物理内存。它只是在进程地址空间的账本上做了一笔修改——要么把堆顶指针往上推一截,要么建立一段匿名映射。真正把物理页框分出来、填上页表项的,是你第一次往这块内存写入数据时触发的缺页异常。所以 malloc 几乎总是瞬间返回,而你 malloc 之后立刻 memset 一个超大块,程序会明显卡一下。

理解 malloc 的底层机制,就是看清这套「虚拟内存承诺」如何兑现:谁在管理空闲块、怎么切分与合并、什么时候才真正触碰内核。这条链路里任何一环设计错了,轻则内存碎片膨胀,重则堆损坏、coredump 连排查入口都找不到。下面手写一个可运行的 malloc,把这套机制从用户态一路拆到内核的缺页处理。

2. 堆的底层机制:brk 与 mmap 两条路径,为什么大分配不走堆顶

2.1 sbrk 的本质:堆顶指针上移,内存只是记账

malloc 的第一条路径是 brk 系统调用及其封装sbrk()。进程地址空间里有一段叫堆的区域,堆顶由一个叫 program break 的指针维护,sbrk的作用就是把这个指针往上推。往上推了多少,进程就「多」了相应长度的虚拟地址区间。关键在于,这个操作里只有指针移动和进程内存布局记录的更新,没有物理页框的分配。

调用sbrk(4096)和调用sbrk(4096 * 10000)在虚拟地址层面几乎一样快——真正慢下来的是你逐页访问这些地址时触发的缺页处理。缺页是个很重的路径:内核要找到空闲物理页、清零、建立页表映射,还要处理 TLB 失效。所以一个常见误解是「malloc 慢是因为要跟内核交涉」。其实小分配根本不碰内核,用户态分配器在已有堆空间里做切分就能满足请求,只有空闲块不够时才调 sbrk 扩展。

这个设计让 malloc 拿到两个好处:小分配延迟低到纳秒级,虚拟地址空间可以「承诺」得比物理内存大得多——因为承诺本身是廉价的。代价是把兑现风险后移到了缺页那一刻:你 malloc 成功,访问时物理内存却不够了,进程就可能被内核的直接内存回收机制处理掉。这就是虚拟内存承诺背书的后果:malloc 成功不代表内存真的到位了。

维度brk 路径mmap 路径
系统调用brk / sbrkmmap / munmap
释放策略空间内部复用,仅堆顶可收缩释放即归还内核
速度用户态切分,快但碎片风险高每次完整内核往返,慢但干净
适用场景小分配、高频分配超过阈值的大分配

2.2 mmap 路径:大块分配为什么直接找内核

brk 有个天然缺陷:堆顶只能往后推,释放时如果释放的不是堆顶那一块,堆顶无法收缩,被释放的块只能留在堆里等下次复用。这对小分配无所谓,但对大分配是灾难——一个大数组释放后,堆的空闲区永远无法归还内核,堆会随生命周期波动无限膨胀。解决方式就是 mmap 匿名映射:直接在地址空间开出一段独立区间,不参与堆顶的线性增长。

malloc 内部对大于 mmap_threshold 的请求直接走 mmap。典型默认阈值是 128KB,但 glibc 会让它在约 128KB 到 32MB(64 位下由DEFAULT_MMAP_THRESHOLD_MAX决定)之间动态调整。mmap 的好处是释放即归还:munmap 直接把这段映射撤销,物理页也交回系统。代价是每次 mmap/munmap 都是一次完整的内核态往返,涉及页表、VMA 的建立与拆除,开销比用户态切分高一个数量级。

阈值本身也会因地制宜:glibc 观察到 mmap 出去的块还没用完就被释放,说明阈值偏小,会逐步上调;如果堆顶长时间无法收缩、空闲块堆积,会下调阈值,把更大范围的分配引导到 mmap 路径。这里体现的其实是一个反直觉的设计哲学:不是所有内存都「从堆里出」最划算,有时引入一次昂贵的内核调用反而是整体最优。

2.3 边界情况:堆顶收缩与 trim 机制

即使走 brk 路径,也不是完全不能还给内核。当堆顶附近恰好是一连串空闲块时,分配器会尝试把堆顶往前收,这个动作叫 trim。前提是释放的块正好贴着堆顶,否则物理页即使空了也只能留在进程地址空间里,直到它最终成为堆顶的一部分。

所以你会看到一种典型现象:程序反复 malloc/free 一个大数组,RSS(驻留内存)却不降——不是泄漏,而是那块内存被「焊死」在堆中部,只有后续分配恰好把它用掉才会「复活」。排查内存占用过高时,先别急着怀疑泄漏,要看有没有大块分配长期占用堆中段的模式。另外注意,走 mmap 的块释放时直接 munmap,不存在这种焊死问题,这就是为什么真实 malloc 宁可付出内核往返代价也要把大分配隔离到 mmap——它换的是堆整体形态的确定性。

提示:sbrk(0)可以读取当前堆顶而不改变它,手写分配器首次初始化堆起点时这是个常用技巧。

3. chunk 与 bin:glibc malloc 的核心数据结构是一张链表网

3.1 chunk 的内存布局:prev_size、size 与三个标志位

用户 malloc 拿到的地址背后是一个个 chunk。chunk 不是一段裸内存,而是一个带元数据的结构体。在 glibc 实现里,每个 chunk 开头是两个 size_t 字段:prev_size 记录前一个 chunk 的大小,size 记录当前 chunk 的大小。size 字段的低 3 位还被用来存标志:P 位表示前一个 chunk 是否被使用,M 位表示是否由 mmap 映射而来,A 位表示是否属于非主分配区。

P 位很关键:它决定 prev_size 字段有没有意义。如果前一块正在被使用,prev_size 就可以被当作普通数据区的一部分复用——这是 malloc 能把元数据开销压到很小的手段之一。一个 chunk 的实际数据区大小是 size 减去头部那 16 字节,整个 chunk 按 16 字节对齐。

为什么是 16 字节?因为 size 字段低 4 位要被标志位占用,16 字节对齐在处理器层面也更友好:x86 的 cache line 是 64 字节,SSE 指令要求 16 字节对齐,16 恰好同时兼容二者。你调用 malloc(1) 时,实际分配的是一个对齐后至少 32 字节的 chunk(16 字节头部 + 16 字节最小数据区)。所以小分配的「真实成本」往往超出直觉——不是 1 字节,而是 32 字节起步。

3.2 空闲 chunk 的秘密:fd/bk 指针藏在用户数据区

一个正在使用的 chunk,数据区完全属于用户;而它被释放后,分配器需要用链表结构组织空闲块,于是它把用户数据区的前 16 字节拿来存放两个指针:fd 指向链表中的前一个空闲块,bk 指向前驱。

也就是说,同一段内存位置,在用期间是用户数据,释放后变成分配器的链表节点。这个复用很精妙:不需要额外元数据空间,空闲块越多、链表越长,但那些块本来也没人用。代价是释放后的前 16 字节内容被破坏——任何仍持有原指针去读这块内存的程序,都会读到链表指针而不是原来的数据。这就是 free 之后再访问属于未定义行为的原因之一,也是 glibc 在空闲块里放随机 key 检测 double free 的依据:它赌这个位置只有分配器会碰。

3.3 bin 家族:fastbin、unsorted bin、small bin、large bin 的分工

空闲 chunk 不是塞进一个链表,而是按大小分门别类挂进不同的 bin。fastbin 管小请求,64 位下 chunk 总大小不超过约 128 字节的块进这里,链表单向、后进先出、不参与合并,因为块太小切分不划算。unsorted bin 是周转区:所有被释放的非 fastbin 块先挂进来,下一次分配就近检查一遍,找不到合适尺寸才被分流到 small bin 或 large bin。

small bin 覆盖较小的固定大小档位,64 位下每个 bin 对应一个 16 字节对齐的档位,总覆盖到约 1KB,因为大小固定,分配时 O(1) 取头尾即可。更大的块进 large bin,多个大小段共享链表,需要在链表中做大小匹配,最慢但也最不频繁。这套隔离设计的动机很直白:把不同 size 的请求分流到不同速度的路径上,小块分配频次最高、走最快的路,大块数量少、慢一点无所谓。

真实 malloc 的性能不靠单一数据结构,而靠这套分桶把大量请求挡在最便宜的路径上。对照之下,最典型的低效实现是:把所有空闲块塞进一个全局链表,每次 malloc 从头扫到尾找 first-fit——功能正确,但吞吐能比 glibc 慢一个数量级。

3.4 unlink 宏:摘节点的四个指针操作与安全校验

从 bin 里取一个 chunk,核心操作是 unlink。glibc 的宏把 chunk 从双向链表中摘除:

#define unlink(P, BK, FD) { \ FD = P->fd; /* 读取后向指针 */ \ BK = P->bk; /* 读取前向指针 */ \ FD->bk = BK; /* 后继的前驱指向自己 */ \ BK->fd = FD; /* 前驱的后继指向自己 */ \ }

这里 P 是要摘除的 chunk,fd 指向链表后继,bk 指向前驱。双向链表的摘除只需要修改两个邻居的指针,不需要遍历。这个宏在 glibc 源码里出现频率极高,也是历史上各类堆漏洞利用的焦点——早期实现里,如果 P 是伪造的 chunk,宏会用 P 里可控的fd、bk两个字段去执行两次任意地址写。

后来 glibc 加了双重检查:摘除前必须验证P->fd->bk == P,防止伪造指针造成任意写。一个四指针的链表操作,在安全视角下演化出一整套防御机制——这说明 malloc 的复杂度从来不在 allocation 本身,而在 chunk 元数据的维护:标志位、链表、合并逻辑,任何一处写错,都会在远离开销的位置爆炸,比如对象写入时覆盖相邻 chunk 的 size 字段,free 时链表指针就全乱了。

4. 手写一个简化 malloc:元数据、首次适配与边界合并

4.1 元数据与内存对齐:为什么头部必须多留 16 字节

先定义最小可用的分配器。核心数据结构是一个块头:

typedef struct block_header { size_t size; // 整个块大小(含头部),按 16 字节对齐 struct block_header *next; // 空闲链表指针,仅空闲时有效 int free; // 1 = 空闲,0 = 已分配 } block_header_t; #define HEADER_SIZE sizeof(block_header_t) // 64 位下等于 24 字节 #define ALIGN_TO 16 #define ALIGN(n) (((n) + (ALIGN_TO - 1)) & ~(ALIGN_TO - 1))

size 字段记录整个块大小,数据区大小就是size - HEADER_SIZE。对齐到 16 字节的原因前面说过:size 字段的低 4 位在 glibc 里要留作标志位,且 16 字节对齐满足 SSE 指令和 cache line 的双重要求。而对我们这个简单实现来说,对齐保证了相邻块地址可以靠(char *)cur + cur->size直接推算而不产生错位——这是堆管理的基石。malloc 实际分配的空间永远是 size 对齐后的结果,你请求 1 字节,块头加数据区一共 32 字节起步,这是任何实现都绕不开的元数据开销。

注意这里最容易犯的错:把块头放在返回指针之后,或者没有把 size 字段的正确值写进新块,导致 free 时按错误的偏移解析出垃圾地址。块头是分配器和用户程序之间的契约,契约坏了,后续一切行为都不可预测。

4.2 分配路径:first-fit 扫描、切分与空闲链表维护

分配函数核心逻辑是:扫空闲链表找 first-fit,命中就切分,没命中就向内核扩展堆:

void *my_malloc(size_t size) { if (size == 0) return NULL; size_t need = ALIGN(size + HEADER_SIZE); block_header_t *cur = free_head, *prev = NULL; // 1. 首次适配扫描:找第一个 size 够用的空闲块 while (cur) { if (cur->free && cur->size >= need) { // 2. 切分:剩余空间能塞下新块头加最小数据区,就拆 size_t remaining = cur->size - need; if (remaining >= HEADER_SIZE + ALIGN_TO) { block_header_t *split = (block_header_t *)((char *)cur + need); split->size = remaining; split->free = 1; split->next = cur->next; cur->next = split; } cur->size = need; cur->free = 0; // 3. 把自己从空闲链表摘掉 if (prev) prev->next = cur->next; else free_head = cur->next; return (char *)cur + HEADER_SIZE; } prev = cur; cur = cur->next; } // 4. 空闲块不够,用 sbrk 扩展堆 block_header_t *fresh = (block_header_t *)sbrk(need); if (fresh == (void *)-1) return NULL; fresh->size = need; fresh->free = 0; fresh->next = NULL; return (char *)fresh + HEADER_SIZE; }

切分逻辑里有个关键判断:remaining >= HEADER_SIZE + ALIGN_TO。如果剩余空间连一个块头加最小对齐数据区都放不下,硬切只会制造一个无法满足任何请求的「瘤子」空洞,所以直接整块给用户。这个判断就是碎片策略的最小投影:宁可大材小用,也不制造不可用碎片。首次适配偏向选堆前段的块,容易把堆前部切成细碎片;改成最佳适配(遍历全部找最接近的)能降碎片,但每次分配都要 O(n) 完整扫描——手写分配器里每一毫秒都在做这个取舍。

另一个隐含的问题:分配路径完全没有锁。单线程能跑,一旦多线程同时调用,free_head 就会产生竞态,这个坑后面单独讲。还有个隐藏细节:sbrk返回的是旧堆顶地址,也就是新区域的起点,成功时直接当块头用即可;首次调用前要用sbrk(0)记录堆起点,否则后续合并逻辑没有参照。

4.3 释放路径:标记空闲、挂回链表与邻居合并

free 做三件事:标记空闲、挂回链表、尝试合并:

void my_free(void *ptr) { if (!ptr) return; block_header_t *block = (block_header_t *)((char *)ptr - HEADER_SIZE); block->free = 1; block->next = free_head; free_head = block; // 头插法:O(1) 挂回链表 coalesce(block); } static void remove_from_free_list(block_header_t *target) { block_header_t *cur = free_head, *prev = NULL; while (cur && cur != target) { prev = cur; cur = cur->next; } if (cur == target) { if (prev) prev->next = cur->next; else free_head = cur->next; } } static void coalesce(block_header_t *block) { // 从堆起点遍历,把地址相邻的空闲块合并 block_header_t *cur = heap_start; while (cur && (char *)cur < (char *)heap_end) { block_header_t *next = (block_header_t *)((char *)cur + cur->size); if (next >= heap_end) break; if (cur->free && next->free) { cur->size += next->size; // 合并:size 相加 remove_from_free_list(next); // 摘掉被吞并的块 continue; // 继续检查合并后的下一个邻居 } cur = next; } }

合并是为了对抗外碎片:连续释放的相邻块如果不合并,会被切得越来越碎,最后大请求来了,即使总空闲空间足够也分配不出来。这里用了朴素方案——每次释放后从堆头扫描一遍全堆,找到地址相邻的空闲块就并。真正的 glibc 不需要全堆扫描,因为每个 chunk 的 size 低位 P 位能直接告诉你前一个邻居是否空闲,合并是 O(1) 的邻居检查。

手写版用全堆扫描换实现简单,多付出的代价是 free 变成 O(n)。这也解释了为什么真实程序里 free 的耗时分布可能极不均匀——某些释放会触发一次整堆扫描,而 glibc 的释放路径几乎恒定。如果你要优化简化版,方向就是引入类似 P 位的机制,把合并变成只检查左右邻居的常数时间操作。

4.4 验证方法:用真实负载确认没有堆损坏

分配器写完了得验证。直接替换调用,跑一个小的冒烟测试:

int main(void) { void *p[100]; for (int i = 0; i < 100; i++) p[i] = my_malloc(16 + i * 7); for (int i = 0; i < 100; i += 2) my_free(p[i]); // 写模式验证:分配后填数据再读回,确认边界没被破坏 char *buf = my_malloc(64); for (int j = 0; j < 63; j++) buf[j] = 'a' + j % 26; buf[63] = '\0'; for (int j = 0; j < 63; j++) { if (buf[j] != 'a' + j % 26) return 1; // 数据被改写,说明有越界 } return 0; }

验证的重点不是「能跑」,而是确认边界行为:malloc(1) 后写入 1 字节不会损坏邻居,free 后 16 字节对齐依然成立,多轮随机分配释放后空闲链表还能完整遍历。把这些断言写进测试,再用 valgrind 的 memcheck 做一轮外部对照。但 memcheck 报的是你已经踩到的坑,不保证你没踩过——它只能证明错误发生了,不能证明错误不存在。

更稳的验证方式是故意构造越界写与 double free 用例,确认分配器以可预测的方式出问题而不是随机崩溃。下一章就说这几个典型的坑。

5. 手写分配器避坑记录:你会从哪里翻车、怎么排查

5.1 越界写覆盖邻居的 size 字段:free 时段错误

现象:程序正常跑了一下午,某次 free 突然 coredump,gdb 回溯却指向一个看似无关的调用点。

原因:这是手写分配器最典型的翻车场景。某段代码向分配的内存块写入了超出 size 的数据,覆盖了下一个 chunk 的块头,尤其把 size 字段改坏了。free 时按块头解析地址和大小,计算邻居cur + cur->size得到垃圾地址,后续链表操作立刻段错误。glibc 在 free 里加了密度很高的防御检查——chunk 对齐校验、size 范围校验、next chunk 的 P 位一致性——就是为了让这类错误尽早暴露。手写版没有这些检查,损坏会潜行到下一次操作才爆炸。

解决:排查要盯写入方,不是盯 free。用 AddressSanitizer 编译时加-fsanitize=address,先定位越界发生的第一现场。没有工具时,实用技巧是在块头旁边放一个 canary 值(比如 0xDEADBEEF),free 时校验是否被改写,被改写就打印当前块和数据区首字节,直接锁定肇事代码。这个断言只留在 debug 构建。

5.2 double free 在简化版里静默通过:同一地址被两次"分配"

现象:同一块内存 free 两次,简化版不报错,程序照常跑,但后续某个 malloc 返回了这块地址,两个持有者互相踩数据。

原因:简化版 free 只做标记、挂链表、合并三件事。第二次 free 同一指针时,它把已经空闲的块再标记一次、再挂一次链表,于是链表里出现两个指向同一地址的节点。这个块随后可能被「分配」给两个新请求,逻辑上等于两个指针指向同一块内存,谁写谁改,乱套。glibc 能抓出部分 double free,靠的是 tcache 里对 chunk key 字段的校验,发现同一块已在空闲链表中就 abort——复杂实现把防御性检查当安全需求,简化版把它们当成了理所当然。

解决:给 free 加一个「块是否已在空闲链表」的线性扫描,命中就 abort,代价是 free 从 O(1) 变 O(n)。更实际的做法是养成契约:内存块谁分配谁释放,绝不跨模块归还。血泪经验是,跨模块 free 往往是 double free 的温床,因为你不知道对方内部是否已经释放过。

提示:double free 检测做成 debug-only 配置,release 版保留可选的 abort 开关,是性能和确定性之间较实际的平衡。

5.3 RSS 居高不下:是碎片,不是泄漏

现象:程序生命周期内 RSS 持续上升,用泄漏检测工具查不到泄漏点,因为所有块都被正常释放了。

原因:多半是外碎片与堆顶无法收缩共同造成的。brk 方式分配的堆空间只能整体伸缩,堆顶那块的物理页不空出来,前面再多的空闲块也无法归还内核。另一个来源是 4KB 页与 16 字节对齐之间的错位:一次 16 字节分配占一个 32 字节的块,但内核按 4KB 页分配物理内存,一堆小块的页只用了不到一半,RSS 自然虚高。glibc 用多 arena 加 trim 逻辑缓解,但对大量小而长命的对象也基本无能为力。

解决:先看对象生命周期分布。长命对象和短命对象混在同一个堆里是碎片的温床,把长命大对象改成 mmap 或独立对象池,让它们不参与堆的线性空间。其次,定期调用malloc_trim(0)可以主动收缩堆顶,把高位空闲块还给内核,代价是一次全堆整理。更值得做的是监控堆的空闲率而不是只看 RSS 绝对值——空闲率持续高于七八成且堆大,该怀疑碎片而不是泄漏。

5.4 多线程偶发崩溃:全局 free_head 的竞态

现象:多线程压测偶尔崩,单线程跑相同用例不崩,加日志后崩溃频率反而降低。

原因:典型的竞态。free_head 是全局变量,两个线程同时 free 时都执行block->next = free_head; free_head = block;,后一个线程的赋值覆盖前一个刚挂进去的块,空闲链表丢节点。丢节点不会立即报错,直到某个分配走了断链路径才崩。更隐蔽的场景是两个线程同时 free 同一块内存——这已经不是分配器的锅,而是对象所有权设计的问题。glibc 维护多个 arena,线程被分散绑定到不同 arena 上,每个 arena 独立锁,不同线程大概率不撞同一把锁,这是复杂实现与简化实现的分水岭。

解决:最省事的修复是给 my_malloc 和 my_free 的链表操作加互斥锁。更细的粒度是每桶一把锁,或者引入线程局部缓存——glibc 的 tcache 就是每线程维护的分档缓存,命中时连锁都不用拿。但加锁不等于线程安全:同一块内存被两个线程释放属于逻辑 bug,分配器层面无法防御。所以排查线程相关崩溃时,第一件事不是怀疑 malloc,而是审查对象所有权契约。

6. 设计取舍:为什么真实 malloc 拒绝「简单全局链表」这条捷径

把整个手写过程走完,回头再看 glibc,会发现它处处在跟「简单全局链表」作对:多个 arena 按线程分散、每线程 tcache、按大小分桶的 bin 家族、动态调整的 mmap 阈值。这些设计的动机都指向同一个核心矛盾——分配器的性能与内存利用率难以兼得,还要在这个矛盾上叠加线程安全与攻击防御。

真实场景里,小对象分配占绝大多数,所以 tcache 和 fastbin 把最热的路径做到无锁、近乎 O(1);大对象靠 mmap 的释放即归还避免长期占用堆形态;中间层用 unsorted bin 做周转,让释放后的内存能被下一次分配就近复用。这背后是对真实负载分配模式做了大量剖析后的统计结论,不是纸上谈兵。值得记住的取舍是:简单分配器的优势是行为可预测、代码可审计,缺陷是碎片和并发;复杂分配器的优势是吞吐和内存效率,代价是晦涩难懂且攻击面巨大。

很多嵌入式系统与游戏引擎在想明白这个交换之后,依然选择自己写固定大小的对象池,而不是引入通用 malloc——因为他们清楚自己的分配模式更可预测。选择的标准不是「哪个更标准」,而是「你的分配模式更接近哪套假设」。从那以后,我每次接手带内存管理的代码,都强制自己先画一遍块的生命周期图,再决定分配策略,最后才谈数据结构。写分配器本身不难,难的是知道自己的场景该在哪一档复杂度上停手。希望这次拆解能帮你看清 malloc 身后那条完整链路——从用户态一次调用到内核一页物理内存——每个环节都在做取舍,而理解取舍,才算真正把 malloc 搞懂了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询