简介:针对C语言动态内存分配学习需求,这份资源提供一套基于my_malloc的完整实现,面向希望从只会调用malloc进阶到理解其底层原理的开发者,可服务于课程实验、内存管理专题学习或小型项目参考。代码将堆、内存块与控制信息等抽象概念落为具体结构,涵盖内存池初始化、空闲块遍历、首次适配切分、释放回收及相邻块合并等关键步骤,并附带基本测试用例,便于读者对照分析分配与释放过程中的行为变化。压缩包共3个文件,包含2个C源文件与1个头文件,整体仅3KB,不依赖外部库,结构紧凑无冗余,适合快速阅读和二次修改,尤其适合初学数据结构与操作系统的学生。目前已有1827人浏览学习,该示例能直观展示内存碎片产生与合并策略,帮助读者建立内存管理直觉,也为后续理解实际glibc malloc、slab分配器及并发控制打下基础。
1. 自己动手写 malloc:为什么 glibc 替你做好的事还要重来一遍
“自己动手写 malloc 函数”听起来像重复造轮子,但只要你在裸机环境、RTOS 上做过开发,或者在面试时被问过一句“malloc 到底怎么工作的”,就会明白这条路有多值得走。系统的 malloc 是个黑匣子:你调用它、拿到指针、用完释放,它背后做了什么、为什么有时候分配 1 个字节却占用 16 字节内存、为什么 free 之后进程的 RSS 不降,这些只有自己写一遍才能真正有体感。这篇文章我会带你把 malloc 的最小实现从零写出来,基于 brk/sbrk 系统调用、空闲链表、首次适应算法,每一个参数都讲清楚它能怎么调、调错了有什么后果。适合正在学操作系统、准备嵌入式面试、或者想理解性能剖析工具里那些内存指标的开发者,照着复现大约半小时,踩坑却可能花掉你一个下午——但这些坑才是这篇笔记真正值钱的部分。
2. 堆从哪来:用 brk 和 sbrk 拿到一块可用内存
2.1 进程地址空间里的堆:malloc 的内存源头
在 Linux 下,一个进程的虚拟地址空间大致是:代码段、数据段、堆、内存映射区、栈。堆是一段动态增长的区域,它的上边界由一个指针维护,这个指针就是 brk。malloc 拿到的内存,本质上就是操作系统通过调整这个边界“划”给你的。老一点的教科书会讲brk(addr)系统调用直接设置堆顶,但更常用的是sbrk(increment):它在当前堆顶的基础上增加或减少若干字节,并返回调整前的旧堆顶地址。注意理解这个返回值——sbrk(4096)返回的不是新区域的起点,而是旧堆顶,这个旧堆顶恰好就是新区域的第一字节,所以实践中它就是新内存的起始地址。如果调用失败,返回(void *)-1,这一点非常关键,很多人第一次写裸的堆分配器时都忘了判断。
常见做法是先用sbrk(0)查询当前堆顶位置,不做任何修改,这个技巧在调试自己的分配器时经常用到。你可以把堆想象成一块只往后生长的土地:你向系统要,它就给你划一块;如果你觉得不够想要更多,还可以再要。真正的 malloc 实现里,sbrk和mmap是配合使用的:小内存走 brk,大内存(通常超过 128KB)直接 mmap 一块匿名内存,用完 munmap 归还。但自己动手写 malloc 的第一版,只用 sbrk 就足够了,先把路径走通,再考虑其他手段。
2.2 亲手实验:sbrk 是怎么扩展堆的
下面这段代码演示了 sbrk 的三个基本用法:查询、扩展、收缩,建议先跑通它,对堆的边界有一个直观感受:
#include <unistd.h> #include <stdio.h> int main(void) { void *before = sbrk(0); printf("当前堆顶: %p\n", before); void *p = sbrk(4096); if (p == (void *)-1) { perror("sbrk 失败"); return 1; } printf("sbrk(4096) 返回旧堆顶: %p\n", p); void *after = sbrk(0); printf("扩展后堆顶: %p (+4096)\n", after); sbrk(-4096); /* 把堆顶推回去,释放刚拿的内存 */ return 0; }sbrk(0)本身不分配内存,它只是“看一眼”当前堆顶在哪。sbrk(4096)把堆顶向上推 4096 字节,返回值指向这 4096 字节的起点。最后sbrk(-4096)把边界收回,模拟内存释放。跑完这段代码你会注意到,三个地址之间的差值恰好是 4096,而 p 和 before 相等——这就是“调整前旧顶”的含义。
有一点要特别注意:sbrk 是按字节粒度调整边的,但内核管理内存的最小单位是页,一般 4KB。这意味着如果你只调用sbrk(1),内核也会把整页映射进来,页内多出来的空间其实已经可用,只是你的“堆顶”标记只前进了 1 字节,下一次 sbrk 会从下一字节开始划分,前面那 4095 字节就永远浪费了。这个细节直接影响后面第 4 章里 arena 大小的设计——一次多要一点,远远好过频繁小步扩充。
2.3 为什么不直接用 mmap:教学选型与真实世界的分界
有人会问:既然 mmap 更干净,为什么第一版不直接用它?原因是 mmap 每次都要做一次系统调用、建立新的 VMA,开销比 sbrk 大一个量级,而且它拿到的内存地址不保证连续——每次 mmap 的地址都是内核帮你挑的,几乎不可能紧挨着上一次。而堆内存管理的核心逻辑是“把一块连续的大区域切切成小块,再按需合并”,这块连续区域用 sbrk 来拿最自然。真实世界的 glibc malloc 之所以大分配走 mmap,是因为大块内存用完直接还给操作系统,避免长期占用堆造成 RSS 只涨不降;但这是优化层面的决策,不是原理层面的差异。你先把 sbrk 版本写明白,之后想模仿真实 malloc 的行为,只需要在分配大小超过某个阈值时切到 mmap 分支即可,两者的接口都是“给我一段内存”,对上层算法透明。
3. 一个最小可运行的 minimalloc:完整代码与验证
3.1 数据结构的起点:一个 header 把用户块包起来
malloc 发给用户的必须是“直接可用”的指针,它背后一定要能管理这块内存的大小和状态。最朴素的设计是在每块内存前面放一个头部(header),记录块大小和是否空闲,然后把空闲块串成链表——就像快递包裹上的面单,没有面单,仓库就不知道这箱货是谁的、往哪发。header 设计有几个硬性要求:一是大小要足够存管理字段;二是它本身的地址必须满足对齐,否则用户拿到 payload 地址也会错位;三是字段布局要紧凑,太大浪费率就高。
下面给出一个我自己惯用的 header 写法,用 union 强制对齐:
typedef union header { struct { size_t size; /* 有效载荷的字节数,不含 header 本身 */ int is_free; /* 1=空闲,0=已分配 */ union header *next; /* 链表后继 */ union header *prev; /* 链表前驱 */ } s; long align; /* 强制 header 至少按 8 字节对齐 */ } header_t;注意 union 里那个long align字段:它本身不存储业务数据,唯一作用是让编译器把整个 union 的大小撑到 8 的倍数、把起始地址强制对齐到 8 字节。在 64 位系统上,size 占 8 字节、指针各占 8 字节、is_free 占 4 字节,加上 padding 后整个 header 通常是 32 字节。这个 32 字节的“管理税”是后面MIN_SPLIT参数计算的基础,读者可以照着数一遍,不要只背结论。
3.2 核心实现:链表、分裂、arena 扩充
接下来是整个笔记的核心——自己动手写 malloc 的最小但完整实现。这个版本我把链表设计成总是按地址升序排列,块一旦创建就永远在链表里,分配只是把is_free置 0,释放再置 1 并合并相邻块。这样 free 的时候不需要重新扫描插入位置,合并时 prev/next 指针天然指向物理相邻块,逻辑闭环、不容易写出隐蔽 bug:
/* minimalloc.c —— 自己动手写 malloc:最小完整实现 */ #include <unistd.h> #include <stdio.h> #include <stdint.h> #include <string.h> #define ALIGN 8 #define ARENA_SIZE 4096 #define MIN_SPLIT (sizeof(header_t) + ALIGN + 32) typedef union header { struct { size_t size; int is_free; union header *next; union header *prev; } s; long align; } header_t; static header_t head; /* 哨兵节点,不参与分配 */ static size_t align_up(size_t n) { return (n + (ALIGN - 1)) & ~(ALIGN - 1); } /* 按地址升序把 block 插入链表 */ static void insert_linked(header_t *block) { header_t *prev = &head; header_t *cur = head.s.next; while (cur && (uintptr_t)cur < (uintptr_t)block) { prev = cur; cur = cur->s.next; } block->s.prev = prev; block->s.next = cur; if (cur) cur->s.prev = block; prev->s.next = block; } /* 向内核要一块新的 arena,并整体作为空闲块挂入链表 */ static header_t *new_arena(void) { void *p = sbrk(ARENA_SIZE); if (p == (void *)-1) return NULL; header_t *block = (header_t *)p; block->s.size = ARENA_SIZE - sizeof(header_t); block->s.is_free = 1; block->s.next = NULL; block->s.prev = NULL; insert_linked(block); return block; } /* 从 block 中切出 size 字节给用户,剩余部分留着 */ static void split_block(header_t *block, size_t size) { size_t remain = block->s.size - size; if (remain < sizeof(header_t) + ALIGN) { block->s.size = size; /* 剩余不够建新块,整体被占用 */ return; } header_t *rest = (header_t *)((char *)block + sizeof(header_t) + size); rest->s.size = remain - sizeof(header_t); rest->s.is_free = 1; rest->s.next = block->s.next; rest->s.prev = block; if (block->s.next) block->s.next->s.prev = rest; block->s.next = rest; block->s.size = size; } void *my_malloc(size_t n) { if (n == 0) return NULL; size_t size = align_up(n); if (size < sizeof(header_t) + ALIGN) size = sizeof(header_t) + ALIGN; header_t *cur = head.s.next; while (cur) { /* 首次适应扫描 */ if (cur->s.is_free && cur->s.size >= size) { split_block(cur, size); cur->s.is_free = 0; return (void *)((char *)cur + sizeof(header_t)); } cur = cur->s.next; } header_t *arena = new_arena(); /* 找不到就扩容 */ if (!arena) return NULL; split_block(arena, size); arena->s.is_free = 0; return (void *)((char *)arena + sizeof(header_t)); } void my_free(void *ptr) { if (!ptr) return; header_t *block = (header_t *)((char *)ptr - sizeof(header_t)); block->s.is_free = 1; /* 向前合并:prev 在链表上就是物理前驱 */ if (block->s.prev != &head && block->s.prev->s.is_free) { header_t *prev = block->s.prev; prev->s.size += sizeof(header_t) + block->s.size; prev->s.next = block->s.next; if (block->s.next) block->s.next->s.prev = prev; block = prev; } /* 向后合并 */ if (block->s.next && block->s.next->s.is_free) { header_t *next = block->s.next; block->s.size += sizeof(header_t) + next->s.size; block->s.next = next->s.next; if (next->s.next) next->s.next->s.prev = block; } }逻辑说明:my_malloc先做对齐和最小尺寸兜底,然后沿空闲链表用首次适应算法找块;找到就分裂、标记占用、返回 payload 指针;找不到就sbrk(ARENA_SIZE)整块开拓新区。my_free拿到用户指针后必须回退一个 header才能找到管理块,先标记空闲,再把物理相邻的空闲块合并成一个更大的块。链表按地址排序这个约束非常关键:它保证prev/next不只是链表邻居,也真的是内存里的邻居,否则合并时会把不相邻的内存拼在一起,写坏整个堆。
参数说明:MIN_SPLIT决定剩余空间小于多少就放弃分裂。这里的sizeof(header_t) + ALIGN + 32意思是:如果切完剩不下“至少一个 header + 8 字节对齐 + 32 字节净空间”,就干脆不切,避免制造一个只有十几个字节、谁都放不下的铅笔屑块。ARENA_SIZE = 4096是为了跟常见内存页大小对齐,一次系统调用拿到一整页,后续分配都在这页内部切分。ALIGN = 8对应 64 位系统指针宽度,保证任何返回地址都能安全存放一个long或double。
3.3 验证:用一个小 main 查看块的状态迁移
一段代码能不能跑,要用数据说话。写一个调试辅助函数,把链表中每个块的状态打出来,再模拟“分配、释放、再分配”的完整流程:
void dump_heap(void) { header_t *cur = head.s.next; printf("--- heap dump ---\n"); while (cur) { printf("addr=%p size=%zu %s\n", (void *)cur, cur->s.size, cur->s.is_free ? "FREE" : "USED"); cur = cur->s.next; } } int main(void) { void *a = my_malloc(100); void *b = my_malloc(1000); dump_heap(); my_free(b); dump_heap(); void *c = my_malloc(500); dump_heap(); (void)a; (void)c; return 0; }预期结果应当是:第一次 dump 出现两块 USED,大小分别约 100 和 1000(按对齐取整);释放 b 后它变成 FREE;第三次 malloc(500) 会复用 b 释放出来的那块区域,再按 500 的大小做分裂。如果第三块地址落在第二块范围内、并且 dump 里多出一个新的 FREE 小尾巴,说明分裂和释放合并逻辑都是对的。这个验证比打印“malloc 成功了”有用得多——它把你堆内部的形态直接摊开来看,后续排查问题全靠它。
4. 参数怎么定:对齐、最小分块与 Arena 大小
4.1 对齐选 4、8 还是 16:从崩溃到性能
对齐不是小事,它在某些架构上是“对错决定生死”的问题。32 位 ARM 上如果返回一个 4 字节对齐的地址给一个存uint64_t的变量,硬件直接触发总线错误;x86 上虽然不崩,但跨 cache line 的未对齐访问会让一次读操作变成两次,性能削掉一大截。选择多少取决于你的目标平台:老 32 位系统 4 字节就够,64 位系统普遍用 8,如果你要给 SSE/AVX 指令传数据,那 16 或 32 才是正确选择。自研 malloc 里对齐方式最常见的写法是“向上取整到 N 的倍数”,也就是代码里的(n + ALIGN-1) & ~(ALIGN-1),只对 2 的幂有效。
建议新手直接拍ALIGN = sizeof(void *),在 64 位机器上就是 8,多数场景不会出错。若想让自定义结构体在 malloc 出来的数组里也能自然对齐,8 字节已经覆盖了绝大多数内建类型;16 字节对齐会额外增加浪费,除非你有明确的 SIMD 需求,否则没必要。
4.2 最小分配块:为什么 malloc(1) 也要吃掉几十字节
sizeof(header_t) + ALIGN这个下限是我代码里的一道兜底。它说的意思是:任何一个分配请求,对齐之后如果连“一个 header + 最小 payload 槽位”都不到,就强制拉到这个值。这是所有实际 malloc 共有的特征——分配 1 字节时系统给你的 usable 空间远大于 1,因为 header 的税绕不开。在我的 64 位实现里,header 占 32 字节,加上 8 字节对齐余量,最小分配块是 40 字节。也就是说,如果你程序里有大量几字节的小对象,这个分配器的浪费率会接近 97%,比系统 malloc 惨得多。
这是理解“为什么不要自己 malloc 替代系统 malloc”的关键数据点之一。真实 glibc malloc 对极小对象有专门的 tcache bin,头结构开销被摊得很薄;但自研版本如果没做这些优化,就别硬上生产。优化路径只有两条:要么把 header 压缩到 16 字节(用 32 位 size + 16 位 flag + 指针),要么把“空闲块”和“已分配块”拆成两套结构,已分配块不挂链表、也没有 next/prev,从而把单块税降下来。这两条都属于进阶改造,第一版先把 40 字节这个数字记在心里就好。
4.3 分裂阈值 MIN_SPLIT:一块 1024 字节的内存,分还是不分
分裂阈值是个典型的“两难参数”。设太大会导致大量剩余空间被已分配块吃掉、变成内部碎片;设太小会产生一堆十几个字节的空闲“铅笔屑”,后面谁都用不上,还拖慢链表遍历。一个直观的例子:空闲块 1024 字节,请求 100 字节,剩余 924 字节。如果MIN_SPLIT设成 800,那么 924 > 40 + 800 成立,会分裂;如果设成 2000,就不分裂,后续 924 字节全被当前分配挪用。选择策略是:只要剩余空间足够容纳一个新的完整管理块,就一定要分裂,阈值在“完整管理块”之上再叠一点净空间,防止产出只剩 40~50 字节的鸡肋块。我代码里给的+32就是这层保险。
从这个视角看,真正 malloc 的 bin 机制就是把“分裂出来的碎片”按大小分桶存放,分配小对象时直接在对应桶里找,不用遍历整个堆。你的第一版没有桶,那么 MIN_SPLIT 稍微调大一点,减少碎块数量,对整体性能反而更友好。调参没有万能值,但可以用最后的小节里的 dump_heap 跑一轮“随机分配-释放”脚本,统计 FREE 块数量和平均大小,反复试几组值。
4.4 Arena 大小:一次从内核拿多少最划算
ARENA_SIZE决定每次 sbrk 要多大。设 4096 的好处是页对齐、系统调用成本一次摊销;但如果你程序平均分配 1024 字节的块,一页只分 3 块就得再次 sbrk,系统调用次数就上来了。我观察过不少自研分配器翻车的现场:一个人把 ARENA_SIZE 设成 128,结果 malloc 循环分配一万次,底层调了 sbrk 一万次,性能比系统 malloc 慢了几十倍还不自知——看 strace 才恍然大悟。
经验上,ARENA_SIZE 建议至少等于 8 个最大常见分配对象的大小,宁可一次多拿、内部慢慢切,也不要频繁触碰内核。内存碎片是可控的,系统调用开销是不可控的。如果你在跑一个只分配固定大小对象的场景,直接把 ARENA_SIZE 设为“对象大小 × 数量 + 一点余量”,然后按需扩容,效果远好于一个拍脑袋的常数。另一个实用技巧是先sbrk(0)探查堆起点,再决定 arena 大小是否需要跟页大小对齐,避免跨页边界让块需要处理物理不连续的问题。
5. 自己写 malloc 的 5 个翻车现场与排查路径
5.1 对齐翻车:一切正常,指针一解引用就崩
现象:malloc 返回的指针看起来没问题,但往它里面写double或uint64_t时,程序在特定平台上报总线错误;x86 上不报错但性能骤降。原因:header 结构里没有对齐强制字段,或者用了__attribute__((packed))把 header 压扁了,导致 header 末尾 + payload 起点的地址变成 6 的倍数、10 的倍数等非法对齐位置。解决:header 用 union 里放一个long或max_align_t字段来撑对齐;另外在分配时对请求大小先做align_up,而不是直接塞进 block。这个坑几乎是所有初版分配器的通病,排查时先打印(uintptr_t)ptr % 8,如果非 0,十有八九是 header 布局问题而不是业务 bug。
5.2 指针偏移算错:free 时把堆搞成环
现象:malloc 返回的指针是对的,但 free 之后程序再跑几步就死循环或 Segmentation Fault。原因:释放时忘了用户指针要减sizeof(header_t),或者减的偏移和分配时加的偏移不一致。分配时我用(char *)cur + sizeof(header_t),释放时却减了 8 字节而不是sizeof(header_t)(32 字节),header 找错位后,里面的 size 值和链表指针全是垃圾,合并时把不相邻的块拼起来,堆就被写坏了。解决:在 my_free 入口加断言:
header_t *block = (header_t *)((char *)ptr - sizeof(header_t)); /* 调试期检查 size 是否合理 */ assert(block->s.size < ARENA_SIZE * 2);这类问题用内存检测工具通常查不出来,因为它是“逻辑合法但地址错位”,最好的排查手段是 dump_heap 对比 free 前后的链表形态。另外约定一个写死不变的原则:分配和释放的偏移都必须写sizeof(header_t),不许一边用宏一边手写数字。
5.3 越界写把 header 打烂:magic number 的救场
现象:用户代码里把一个 200 字节的字符串拷进只分配了 100 字节的缓冲区,逾界写把下一个块的 header 覆盖了;这个块被释放时,合并逻辑读到被污染的 size 值,以为合并目标有几十 GB 大小,循环直接卡死。原因非常明确,就是用户越界,但你的 malloc 没有任何机制暴露这个错误,它只会默默把堆腐蚀掉,过很久才莫名崩溃。解决:调试期在每个 header 里塞一个 magic number,例如0x5A5A5A5A的低 16 位,每次 malloc、free、合并时校验,一旦发现被改写就打印出错块地址——这一步能帮你把“诡异崩溃”快速定位成“谁越界写”。代价是每个块多 4 字节,生产版本去掉即可,但它是我见过性价比最高的调试手段。
5.4 只做单向合并:碎块越调越多
现象:程序按“分配 1 个空闲块 A → 分配 B → 释放 A → 释放 B → 再分配一个略小于 A+B 的对象”的节奏运行,理论上内存够用,malloc 却一路 sbrk 扩展,堆越来越大。原因:free 里只做了向后合并(合并 next),没做向前合并(合并 prev),导致 A 和 B 释放后虽然物理相邻,却因为中间隔了个“已释放但没合并进去”的边界,永远合成不了一大块。解决:合并必须双向,向前看 prev、向后看 next;而要让 prev 是物理前驱,就必须维护链表按地址排序这条不变量,这也是我这套实现里刻意用双向链表、按地址插的原因。这个坑在教科书实现里经常被故意省略,但实际负载跑几个小时就会出现 RSS 不断上涨的假“泄漏”。
5.5 free 之后 RSS 不降:不是泄漏,但要有说辞
现象:运行监控里 RSS 一直在涨,free 之后也不回落,被当成内存泄漏一顿排查。原因:brk 堆释放只是把块标成空闲,堆顶没有收回去,进程的 RSS 当然不会降。这不是 bug,是所有基于 sbrk 的分配器的天性。解决:如果你确定自己是“分配大块、用完释放”的模式,可以给分配器加一个 mmap 分支,超过阈值的大块用 mmap 分配、munmap 释放,解除后立刻归还系统;小块继续走堆,接受 RSS 只涨不降。这样既保留了堆的连续切分能力,又解决了大块内存长期占用的尴尬,这也是真实 malloc 的经典组合策略。别急着改,先把自己的负载模式摸清楚。
6. 给 malloc 加上线程安全之后,它离生产还差多远
6.1 最轻量的线程安全:全局锁包一层
自研分配器默认不是线程安全的:两个线程同时 malloc,可能拿到同一个块,也可能在链表插入时互相覆盖指针。最省事的做法是包一层全局互斥锁,把整个堆的访问串行化:
#include <pthread.h> static pthread_mutex_t heap_lock = PTHREAD_MUTEX_INITIALIZER; void *my_malloc_threadsafe(size_t n) { pthread_mutex_lock(&heap_lock); void *p = my_malloc(n); pthread_mutex_unlock(&heap_lock); return p; } void my_free_threadsafe(void *ptr) { if (!ptr) return; pthread_mutex_lock(&heap_lock); my_free(ptr); pthread_mutex_unlock(&heap_lock); }这样做逻辑上完全正确,代价是并发度为零:多线程场景下所有分配请求都在抢同一把锁,线程越多瓶颈越明显。测试时很容易观察到,4 线程的分配吞吐可能比单线程还差。它适合作为“先正确、后优化”的过渡版本,但不该停在原地。
6.2 从全局锁到 per-thread cache:生产级 malloc 在解决什么问题
真实 malloc 的高并发秘密不在锁本身,而在“少竞争”:每个线程有自己私有的小对象缓存,分配时先在本地找,找不到才去全局堆;全局堆也做了分桶锁而不是一把大锁。你可以给自研版本做第一级演进——_Thread_local空闲链表,小对象优先从线程本地链表取,大对象和缓存未命中时再回到全局堆加锁分配。这一步做完,分配吞吐通常能追平甚至超过系统 malloc 的五六成水平,作为自研项目已经相当能打。做到这里你会真切体会到,自研 malloc 的价值不在替代标准库,而在于你看任何内存性能问题时,脑子里不再是一个黑匣子,而是一张清晰的链表图。某开发者告诉我,他写这个 malloc 之后去排查线上服务 RSS 过高,第一反应不再是“泄漏”,而是“先看看有没有碎块没合并”,几分钟就定位到了业务侧的峰值缓冲问题。这个习惯我一直保留着——每次看内存指标异常,我都会先想清楚底层分配器到底在做什么,再下结论。希望帮到你。
本文还有配套的精品资源,点击获取