简介:本资源是一份面向C语言进阶学习者与系统编程初学者的内存管理实践项目,聚焦于深入理解动态内存分配机制,通过手写malloc函数(my_malloc)掌握堆管理、空闲块查找、内存碎片处理及释放合并等核心原理。资源包含3个源码文件:2个C文件(mymalloc.c实现分配逻辑,test.c提供测试用例)和1个头文件(mymalloc.h定义接口与结构体),总大小仅3KB,轻量精炼,便于逐行阅读与调试。已有1831人学习下载,适合在Linux/类Unix环境下配合gdb单步跟踪,直观观察内存布局变化。读者可完整获得一个具备初始化、首次适配策略、基本合并逻辑的可运行malloc简化版实现,并附带清晰的内存块结构设计说明与关键注释,是理解glibc malloc底层思想、夯实操作系统与C运行时基础的优质入门实践材料。
1. malloc 不是“分配内存”,而是管理一块早已存在的、被悄悄映射进进程地址空间的匿名页:为什么你free之后还能读到旧数据,而malloc(1)却可能耗掉 4KB?
你写过int *p = malloc(1);,也见过free(p);,甚至用valgrind报过use after free。但有没有一瞬间疑惑过:malloc(1)真的只向操作系统要了 1 字节吗?free(p)真的把那 1 字节还回去了吗?如果真还了,为什么printf("%d", *p);在free后常常不崩,反而输出一个“合理”的旧值?更反直觉的是——你反复malloc(1)一万个次,进程的 RSS(常驻集大小)几乎不涨;但某次malloc(8192)之后,RSS 突然跳升 4KB。这背后没有魔法,只有一套被教科书长期简化的、精巧又务实的两级内存管理机制:内核只管页(page),用户态 libc 只管块(chunk)。malloc的核心任务从来不是“向内核申请字节”,而是在已有的、由mmap或brk拿到的大块虚拟内存里,切出、标记、复用、合并小块,并保证线程安全与局部性。这份《自己动手写 malloc 函数》资源,不是教你调用malloc,而是带你亲手实现一个具备真实世界约束的简化版:它暴露了sbrk的裸露调用、chunk头部的隐式链表结构、free时的合并逻辑、以及malloc如何在“快”与“省”之间做血泪取舍。搞懂它,你才能真正看懂glibc的ptmalloc为何有fastbin、unsorted bin、small bin这些玄学命名——它们全是从这个最朴素的原型里,为应对真实负载而长出来的枝干。
2. 内存布局:从sbrk(0)开始,一切虚拟地址都靠“骗”——brk系统调用如何成为用户态内存管理的唯一入口
malloc的起点,不是malloc自己,而是内核提供的brk系统调用。它不分配物理内存,只移动进程数据段(data segment)的“边界指针”(break)。这个指针指向的位置,就是当前进程能合法访问的最高虚拟地址。sbrk是brk的 C 封装,sbrk(0)返回当前 break 值,sbrk(n)将 break 向上移动n字节(若n > 0)或向下移动|n|字节(若n < 0)。关键在于:sbrk移动的是虚拟地址,内核只承诺“当你第一次访问该页内某个地址时,再给你分配一页物理内存并建立映射”——这就是经典的“按需分页”(demand paging)。malloc的所有“分配”,本质上都是对这块虚拟地址空间的精细切分与簿记。
2.1sbrk的裸调用:brk边界就是你的内存池起点
标准malloc实现中,sbrk是获取大块内存的首选(小块如mmap用于>128KB的请求,但本项目聚焦基础路径)。我们先看一个最简malloc的初始化逻辑:
// 全局变量:记录当前 break 位置,即空闲内存池的起始地址 static void *heap_start = NULL; // 初始化:首次调用 malloc 时,获取当前 break static void init_heap() { if (heap_start == NULL) { heap_start = sbrk(0); // 获取当前 break 地址 // 注意:此时 heap_start 指向的地址尚未被映射! // 第一次 malloc 请求会触发 sbrk(n) 扩展 } }heap_start并非一块“已分配”的内存,它只是一个地址标记。当malloc首次需要空间时,它会调用sbrk(N),内核将 break 向上推N字节,返回旧的 break 值——这个旧值,就是malloc可以自由支配的“新内存池”的起始地址。N的大小,通常远大于用户请求的字节数(比如请求 16 字节,却sbrk(4096)),这是为了摊薄系统调用开销。
2.2chunk结构体:每个内存块头上的“身份证”与“指针”
malloc管理的不是裸地址,而是带元数据的chunk。一个chunk由两部分组成:头部(header) + 用户数据区(user data)。头部必须紧邻用户数据区之前,且其大小必须对齐(通常是 8 或 16 字节),以保证用户数据区地址天然对齐。本项目采用最简设计:
// chunk 头部结构(32位系统示例,8字节对齐) typedef struct chunk_header { size_t size; // 当前 chunk 总大小(含 header),单位:字节,低 3 位用于标志位 struct chunk_header *next; // 仅用于显式空闲链表,本项目暂不用 } chunk_header_t; // 宏定义:从用户指针反推 header 地址;从 header 地址计算用户数据区起始 #define HEADER_SIZE sizeof(chunk_header_t) #define PTR_TO_HEADER(ptr) ((chunk_header_t*)((char*)(ptr) - HEADER_SIZE)) #define HEADER_TO_PTR(hdr) ((void*)((char*)(hdr) + HEADER_SIZE)) // size 字段的标志位含义(低 3 位) #define CHUNK_USED 0x1 // 1: 已分配;0: 空闲 #define CHUNK_PREV_USED 0x2 // 1: 前一个 chunk 已分配(用于合并判断) #define CHUNK_IS_MMAPPED 0x4 // 1: 由 mmap 分配(本项目忽略)size字段是核心。它存储的是整个chunk的字节数(包括 header),且最低位(bit 0)被复用为CHUNK_USED标志。这意味着size总是偶数(因为对齐要求),最低位永远可被安全借用。CHUNK_PREV_USED(bit 1)则记录前一个chunk的状态,这是free时能否向前合并的关键依据——free一个chunk时,它需要检查“前一个chunk是否空闲”,而这个信息不能去查前一个chunk的size字段(因为那个chunk可能已被释放,其size字段可能已被覆盖为用户数据),所以必须把“前一个是否已用”这个信息,固化在当前chunk的size字段里。这是一个经典的设计取舍:用 1 位空间换 O(1) 的合并判断。
2.3free后的内存:不是“归还”,而是“登记”与“等待合并”
free(p)的行为,远比“把内存还给内核”复杂。它的核心动作是:
- 通过
PTR_TO_HEADER(p)找到chunk头部; - 清除
size字段的CHUNK_USED标志(size &= ~CHUNK_USED); - 设置
CHUNK_PREV_USED标志为 0(因为当前chunk空闲了,后一个chunk的CHUNK_PREV_USED应为 0); - 最关键一步:尝试与前、后相邻的空闲
chunk合并,形成更大的空闲块。
合并逻辑依赖于CHUNK_PREV_USED和相邻chunk的size字段。例如,向前合并:
- 计算前一个
chunk的地址:prev_hdr = (chunk_header_t*)((char*)hdr - prev_size),其中prev_size是hdr->size & ~7(清除标志位后的纯大小); - 检查
prev_hdr->size & CHUNK_USED:若为 0,说明前一个chunk也是空闲的; - 若是,则将
prev_hdr的size增加hdr->size,并更新hdr的CHUNK_PREV_USED标志(因为合并后,新的prev_hdr的前一个chunk的CHUNK_PREV_USED应保持原样)。
提示:
free后*p仍可读,是因为p指向的用户数据区内容未被清零,free只修改了头部的size标志位。这既是性能优化(避免memset开销),也是use after free漏洞的根源——只要该页未被内核回收或重用,旧数据就还在那里。
3. 分配策略:first-fit与best-fit的权衡——为什么“找到第一个够用的”比“找到最合适的”更常用?
当malloc(n)被调用,malloc需要在空闲chunk链表中寻找一个大小 ≥n + HEADER_SIZE的块。搜索策略直接决定了性能与碎片率。本项目实现first-fit(首次适配),这是glibcptmalloc默认策略,也是工程实践中的主流选择。
3.1first-fit:简单、快速、局部性好
first-fit的逻辑极其朴素:遍历空闲链表,返回第一个满足chunk_size >= requested_size的chunk。它的优势在于:
- O(1) 平均查找成本:得益于局部性原理,最近
free的chunk往往位于链表前端,而新请求也倾向于在相近地址分配,因此“第一个够用的”往往就是“最近释放的”。 - 实现简单,无排序开销:不需要维护链表按大小排序,插入
free的chunk时只需 O(1) 头插或尾插。 - 减少碎片扩散:倾向于在内存池一端集中分配/释放,使另一端保持大块连续空闲,有利于后续大请求。
// 简化版 first-fit 分配逻辑 void *my_malloc(size_t size) { size_t total_size = size + HEADER_SIZE; // 对齐到 8 字节(确保用户数据区对齐) total_size = (total_size + 7) & ~7; chunk_header_t *curr = free_list_head; while (curr != NULL) { if ((curr->size & ~7) >= total_size && !(curr->size & CHUNK_USED)) { // 找到! size_t curr_size = curr->size & ~7; // 如果剩余空间足够塞下一个最小 chunk(HEADER_SIZE + 8),则分割 if (curr_size >= total_size + HEADER_SIZE + 8) { chunk_header_t *new_chunk = (chunk_header_t*)((char*)curr + total_size); new_chunk->size = curr_size - total_size | CHUNK_PREV_USED; // 新 chunk 的 prev_used=1 curr->size = total_size | CHUNK_USED | (curr->size & CHUNK_PREV_USED); // 将 new_chunk 加入空闲链表 insert_into_free_list(new_chunk); } else { // 不够分割,整个 chunk 都给用户 curr->size = curr->size | CHUNK_USED; } return HEADER_TO_PTR(curr); } curr = curr->next; } // 未找到,调用 sbrk 扩展堆 return allocate_from_sbrk(total_size); }3.2best-fit:理论最优,现实骨感
best-fit则寻找满足条件的chunk中尺寸最小的那个。理论上,它能最小化内部碎片(internal fragmentation)。但代价巨大:
- O(n) 查找成本:必须遍历整个空闲链表。
- 高外部碎片(external fragmentation)风险:频繁分割出大量微小、无法再利用的“缝隙”,这些缝隙散落在内存各处,难以合并。
- 缓存不友好:遍历链表是随机访问,破坏 CPU 缓存局部性。
注意:
glibc的ptmalloc为了解决best-fit的缺陷,引入了bins(桶)机制:将不同大小范围的空闲chunk分别链入不同的链表(fastbin,smallbin,largebin),并在每个bin内部使用first-fit。这本质上是best-fit思想的工程妥协——用空间(多个链表)换时间(缩小搜索范围)。
3.3split与coalesce:分配时的切割与释放时的缝合
malloc的智慧不仅在于找,更在于“怎么切”和“怎么缝”。
split(分割):当找到一个远大于请求的空闲chunk时,malloc会将其一分为二:前半部分作为用户数据区返回,后半部分作为一个新的、更小的空闲chunk,重新插入空闲链表。分割的阈值很重要:如果剩余空间小于HEADER_SIZE + 最小用户数据(如 8 字节),则不分割,整个chunk都给用户,避免产生无法使用的“碎渣”。coalesce(合并):free时,malloc必须检查前、后chunk是否空闲,并尽可能合并。合并不仅能减少空闲链表长度,更能“复活”大块连续内存,为未来的大请求铺路。coalesce的正确性完全依赖于CHUNK_PREV_USED标志和size字段的精确维护。
4. 线程安全:pthread_mutex_t不是万能解药——为什么malloc的锁粒度必须细到arena?
单线程malloc实现相对简单,但真实世界是多线程的。glibc的ptmalloc为解决并发问题,设计了复杂的arena(竞技场)机制。本项目虽为单线程,但理解其设计动机至关重要:粗粒度锁(global lock)是性能杀手,而无锁(lock-free)在通用malloc中几乎不可行。
4.1 全局锁的灾难:malloc成为程序瓶颈
设想一个简单的全局互斥锁:
static pthread_mutex_t global_lock = PTHREAD_MUTEX_INITIALIZER; void *my_malloc(size_t size) { pthread_mutex_lock(&global_lock); // ... 分配逻辑 ... pthread_mutex_unlock(&global_lock); return ptr; }在高并发场景下,所有线程都排队等待同一把锁。即使分配逻辑本身只有几条指令,锁的争用(contention)也会导致大量线程阻塞、上下文切换,malloc的吞吐量(allocs/sec)会随线程数增加而急剧下降,甚至出现负扩展(negative scaling)。这违背了多线程提升性能的初衷。
4.2arena:为每个线程(或每组线程)分配专属“内存沙盒”
ptmalloc的解决方案是arena。一个arena是一个独立的、包含自己heap、free list、bins的内存管理单元。主线程拥有一个main_arena,而每个新创建的线程,ptmalloc会为其分配一个thread_arena(或让其共享一个arena)。malloc请求首先尝试在当前线程的arena中分配;只有当该arena无法满足(如sbrk失败)时,才去竞争其他arena或创建新arena。
这种设计将锁的粒度从“全局”降到了“每个arena”。线程 A 在arena_A分配,线程 B 在arena_B分配,二者完全无锁竞争。arena之间的协调(如arena的创建、销毁、负载均衡)虽然复杂,但发生频率极低,远低于单个malloc调用的频率。这是一种典型的“用空间换时间”与“用复杂度换性能”的工程哲学。
4.3 常见问题:malloc为什么会mmap?brk和mmap的分工是什么?
现象:strace观察到malloc(1000000)触发了mmap系统调用,而非brk。
原因:glibc对malloc请求做了大小分类。小请求(默认< 128KB)走sbrk路径,复用主堆;大请求(>= 128KB)则直接调用mmap(MAP_ANONYMOUS)。
为什么?
brk是线性的,所有小请求共享一个heap,容易因一个大请求导致brk指针飙升,而后续free无法将brk指针拉回(sbrk只能向下移动,但内核不保证向下移动后释放的页会被立即回收),造成heap“膨胀”后无法收缩,浪费虚拟地址空间。mmap分配的内存是独立的虚拟内存区域(VMA),free时可直接munmap,立即将虚拟地址和物理页全部归还内核,不会污染主heap。- 大内存块本身碎片化影响小,
mmap的系统调用开销(一次)远小于为它维护复杂的free list和bins的开销。
排查:用
cat /proc/[pid]/maps查看进程内存映射,[heap]区域对应brk,而anon_inode:[heap]或无名anon区域则对应mmap分配的大块。
5. 避坑指南:那些让你malloc后崩溃、free后静默失败的“幽灵错误”
实现一个malloc,90% 的工作量不在主逻辑,而在处理各种边界和异常。以下是基于本项目及glibc源码分析总结的 5 条血泪经验。
5.1 现象:malloc(0)返回NULL或非法地址,程序崩溃
原因:malloc(0)是合法调用,POSIX 要求其返回一个有效的、可free的指针(或NULL,但free(NULL)必须安全)。若代码中size == 0时直接return NULL,后续free(NULL)虽安全,但malloc(0)返回的指针若被当作有效地址使用(如memcpy(p, src, 0)),则可能因p为NULL导致memcpy崩溃(某些实现不检查NULL)。
解决:malloc(0)应至少分配一个最小chunk(HEADER_SIZE + 8字节),返回其用户区指针。free时能正常处理。
5.2 现象:free一个从未malloc过的地址,程序立刻SIGSEGV
原因:free(p)会执行PTR_TO_HEADER(p),即(p - HEADER_SIZE)。如果p是栈上变量地址(如int x; free(&x);),&x - HEADER_SIZE可能指向非法内存,free尝试读取该地址的size字段时触发段错误。
解决:生产级malloc会做地址范围检查(如p > heap_start && p < brk_end),但本项目为简化可省略。根本规避方法是:绝不free非malloc/calloc/realloc返回的指针。
5.3 现象:free后再次free同一指针,程序行为诡异(有时没事,有时崩溃)
原因:free(p)会将p对应的chunk插入空闲链表。第二次free(p)时,p指向的内存已是空闲chunk的用户区,PTR_TO_HEADER(p)得到的size字段,其实是上次free后被malloc或其他操作写入的“脏数据”,不再是合法的chunk头部。free会用这个脏size去计算前一个chunk地址,导致指针乱飞,破坏链表或size字段。
解决:free后立即将指针置为NULL(p = NULL;),并在free函数开头添加if (p == NULL) return;。这是最廉价的防御性编程。
5.4 现象:malloc大量小对象后,free一部分,malloc新小对象却失败(OOM)
原因:first-fit策略和split逻辑导致内存池被切割成大量无法合并的小碎片(external fragmentation)。虽然总空闲内存充足,但没有一块连续的chunk足够大。
解决:无银弹。ptmalloc的bins机制(尤其是unsorted bin的延迟合并)和malloc_trim(主动调用sbrk尝试收缩heap)是缓解手段。开发时应避免过度malloc/free,优先使用对象池(object pool)或栈分配。
5.5 现象:多线程下malloc/free随机崩溃,valgrind报Invalid read/write
原因:未加锁的free list操作(如insert_into_free_list)在多线程下是竞态的。线程 A 正在修改free_list_head->next,线程 B 同时读取free_list_head->next,得到一个中间态的、指向已释放内存的指针,后续解引用即崩溃。
解决:必须为free list的所有操作(insert,remove,search)加锁。本项目为单线程,故省略,但这是任何多线程malloc实现的绝对前提。
6. 设计取舍:为什么malloc永远在“快”、“省”、“稳”三角中做不可能的平衡?
malloc是操作系统与应用程序之间最薄、也最厚的一层胶水。说它薄,是因为它只暴露malloc/free两个接口;说它厚,是因为它内部承载着对性能、内存效率、可靠性、可移植性、线程安全等所有维度的极致压榨。这份《自己动手写 malloc 函数》的价值,不在于它能替代glibc,而在于它强迫你直面每一个设计决策背后的 trade-off。
ptmalloc的fastbin是一个绝佳案例。它是一个 LIFO(后进先出)的单向链表,只存放<= 64字节的小chunk,且不进行coalesce。这带来了什么?
- 快:LIFO 插入/删除 O(1),无合并开销,
malloc/free几乎是纯指针操作。 - 省:牺牲了内存效率——
fastbin中的chunk即使前后都空闲,也不会合并,导致潜在的外部碎片。 - 稳:简化了逻辑,降低了并发冲突点(
fastbin操作通常在arena锁内完成,无需额外同步)。
fastbin的存在,是对“绝大多数malloc请求都很小,且生命周期很短”这一真实工作负载的精准建模。它承认:在高频小对象场景下,“省”可以暂时让位于“快”,因为碎片化带来的长期内存压力,远小于锁争用带来的即时性能雪崩。
另一个深刻取舍是mmapvsbrk的阈值(128KB)。这个数字不是拍脑袋定的。它源于对典型应用内存模式的统计:小对象占绝大多数,大对象相对稀疏。将大对象隔离出去,既保护了主heap的紧凑性,又避免了为大对象维护复杂bins的开销。但如果你的应用恰好是“大量 100KB 对象”,这个阈值就会成为性能瓶颈——你需要mallopt(M_MMAP_THRESHOLD, ...)去调整它。
我曾经在一个图像处理 Demo 中,因误用malloc分配数万个128KB的临时缓冲区,导致brk指针狂飙,free后RSS居高不下,最终 OOM。从那以后,我每次看到malloc,都会下意识问一句:“这个大小,走的是brk还是mmap?它的生命周期,是否值得我用一个static缓冲池来复用?” 这份亲手写的malloc,教会我的不是如何造轮子,而是如何带着敬畏之心,去阅读每一个malloc调用背后的汇编、strace和/proc/[pid]/maps。它让我明白,底层机制的“黑匣子”,拆开后不过是无数个清醒的、带着代价的“为什么”。
希望帮到你。
本文还有配套的精品资源,点击获取