简介:这份资源通过实现一个简化版内存分配函数 my_malloc,帮助 C 语言开发者和希望对内存管理有更深理解的读者,掌握 malloc 的底层逻辑与堆管理机制。资源包共 3 个文件,包含 mymalloc.c、mymalloc.h 与 test.c,其中 .c 文件给出分配器实现与测试代码,.h 文件提供接口声明,适合对照着梳理从堆初始化、空闲块查找到内存释放与合并的完整流程。压缩包整体仅 3KB,短小精悍,便于直接阅读和编译运行。配套描述覆盖了内存碎片、内存块、内存池等核心概念,并提及首次适配、最佳适配等分配策略;通过阅读实现代码,可以理解真实 malloc 中边界对齐、空闲链表维护等问题的简化处理思路。编写者用清晰的分步实现将复杂堆管理拆解为可验证的小模块,读者可在此基础上扩展自己的分配策略。目前已有 1829 人学习下载,对于正在学习《深入理解计算机系统》中内存部分或准备操作系统相关面试的读者,是一份轻量的辅助材料。
1. 自己动手写 malloc:别再把它当黑匣子
第一次意识到该把 malloc 拆开看,是在某次排查内存泄漏时,盯着堆栈里几十层调用却说不清 glibc 到底做了哪些事。那时候才明白:malloc 看似只是一个返回指针的函数,背后其实是堆管理、对齐、碎片整理和系统调用的一整套博弈。与其对着黑匣子猜,不如自己动手复现一个教学版本的 malloc,把堆内存的分配和释放逻辑真正落到代码里。这篇笔记会讲清楚 free list 如何维护、首部块为什么必备、合并时机怎么判断,以及我自己调试时的三个踩坑现场。如果你是刚接触内存管理的开发者,照着走一遍就能看到 malloc 最核心的骨架;如果你已经写过类似代码,可以重点关注边界处理和性能取舍这两节。
2. 先立骨架:裸机环境的堆区与元数据设计
2.1 为什么不能直接拿结构体数组当堆
很多入门实现最容易犯的错,是定义一个足够大的static char heap[1024 * 1024],然后用一个指针在数组里划拉。这个做法的问题在释放环节暴露无遗:你无法判断某块内存是否空闲,更没法把相邻的空闲块合并成一个大块。原因很简单——数组本身不携带任何“管理信息”,谁分配了、谁释放了、块有多大,这些都需要额外的元数据来记录。
真正能用的堆管理器,底层思想是“把整块内存切成大小不一的块,每个块前面挂一个 header,header 里存块大小和空闲标志。”释放的时候修改标志位,分配时遍历所有块找第一个足够大的空闲块。这个结构在业界叫 free list,是几乎所有 malloc 教学的起点。
教学版本的 malloc 并不需要调用系统调用去扩展堆区,直接预分配一大块静态内存,在其内部做管理就够用。这样可以把注意力集中在分配策略和块管理算法上,避开操作系统底层的复杂性。
2.2 首部块与字节对齐的最小约定
我选择的元数据结构长这样:
typedef struct block_header { size_t size; // 当前块大小(不含 header 本身) int free; // 1 表示空闲,0 表示已分配 struct block_header *next; // 指向下一个块 } block_header_t;每申请一块内存,实际从堆里消耗的是“header 大小 + 用户请求大小”,返回给用户的是 header 之后的那段地址。这里有两个强制约定:第一,所有块地址必须 8 字节对齐,malloc 返回的指针也必须对齐,否则在 64 位机器上访问long long或double会触发总线错误;第二,每次分配的最小单位是 16 字节,即使你只申请 1 个字节,也要按 16 字节的粒度向上取整,为的是避免频繁切割出极小的碎片块。
初始化堆区时,我一般这样写:
static char heap_space[HEAP_SIZE]; static block_header_t *head = NULL; void init_heap() { head = (block_header_t *)heap_space; head->size = HEAP_SIZE - sizeof(block_header_t); head->free = 1; head->next = NULL; }注意heap_space是字符数组,强转成block_header_t *后直接操作。这里有个前提:数组首地址本身对齐到 8 字节。C 标准只保证char数组按 1 字节对齐,所以严谨的做法是用max_align_t类型的数组来声明,或者手动做一次对齐补偿。后面这副避坑里我把这个忘了,折腾了一个多小时。
3. 分配与释放的完整链路:从首次适应到块合并
3.1 分配路径:遍历、切割与标志位翻转
核心思路是首次适应算法:从头节点开始往后遍历,找到第一个free == 1且size >= 请求大小的块,就从该块上切下一段给用户,剩下的部分留在原位置,更新 header 里的 size 和 next 指针。切剩的大小必须大于等于sizeof(block_header_t) + MIN_ALLOC,否则切割就没有意义,不如整块给出去。
void *my_malloc(size_t bytes) { size_t total = align8(bytes) + sizeof(block_header_t); block_header_t *cur = head; while (cur != NULL) { if (cur->free && cur->size >= total) { if (cur->size - total >= MIN_ALLOC + sizeof(block_header_t)) { // 切割:新块放在原块尾部 block_header_t *new_block = (block_header_t *)((char *)cur + sizeof(block_header_t) + total); new_block->size = cur->size - total - sizeof(block_header_t); new_block->free = 1; new_block->next = cur->next; cur->size = total; cur->next = new_block; // 注意这一步别丢 } cur->free = 0; return (char *)cur + sizeof(block_header_t); } cur = cur->next; } return NULL; }这里最关键的是new_block->next = cur->next这一行,它把新块插在原块和下一个块之间。很多初版实现会漏掉这个赋值,导致链表断裂,free 的时候遍历直接越界。切割后,原块的 free 标志翻转为 0,返回数据区地址;分配失败返回 NULL,调用方自行判断。
head 节点在第一次 init 时是整块空闲,越往后链表越长,碎片越多。首次适应算法的优点是简单快速,缺点是每次都从头扫,如果头部的碎片块很多,扫描效率会直线下降。
3.2 释放路径:标记空闲、向后合并与向前合并
释放逻辑的核心是“把 free 标志改回 1,然后尝试和相邻空闲块合并”。合并的目的是防止碎片越来越多,最终堆区明明还剩很多零碎空间,却没有一个连续区段能满足大请求。
void my_free(void *ptr) { if (ptr == NULL) return; block_header_t *cur = (block_header_t *)((char *)ptr - sizeof(block_header_t)); cur->free = 1; // 向后合并:cur 与 cur->next 相邻且 next 空闲 block_header_t *next = cur->next; if (next != NULL && next->free) { cur->size += sizeof(block_header_t) + next->size; cur->next = next->next; } // 向前合并:找前一个块,若它空闲则与前块合并 block_header_t *prev = head; while (prev != NULL && prev->next != cur) { prev = prev->next; } if (prev != NULL && prev->free) { prev->size += sizeof(block_header_t) + cur->size; prev->next = cur->next; } }向后合并的公式很多人写错,把sizeof(block_header_t)漏掉。实际合并时,大块的总大小相当于“当前 size + header 大小 + 下一块 size”,因为中间隔着 next 块的那个 header。向前合并需要从头遍历找前驱,时间复杂度 O(n),但教学版本不用太纠结性能,先保证逻辑正确。
3.3 为什么块首地址不能直接传给用户
用户拿到的指针是(char *)cur + sizeof(block_header_t),而不是cur本身。这样做的原因很实际:free 的时候需要根据用户指针反推出块首部,如果直接把cur交出去,用户把它当成普通内存区来读写,header 会被覆盖,下次 malloc 遍历时整个链表就乱了。同样的道理,禁止用户越过返回边界写入,否则下一个块的 header 会被破坏,产生极其隐蔽的内存踩踏。
4. 避坑实录:对齐、切割与越界的三个翻车现场
4.1 现象:数组首地址不对齐,一访问就崩溃
第一次跑测试程序,在 32 位环境上一切正常,换到 64 位平台直接 segfault。排查了半天,发现问题出在static char heap_space[HEAP_SIZE]上,数组首地址没有 8 字节对齐,强转成block_header_t *后,结构体里的size_t字段跨越了缓存行边界。
原因:C 标准只保证char数组按 1 字节对齐,而block_header_t *需要 8 字节对齐。解决:把静态数组声明成static max_align_t heap_space[HEAP_SIZE / sizeof(max_align_t)],或者写一个手动对齐的初始化函数,先计算出对齐偏移,把 head 指针挪到正确的位置上。从那以后,凡是涉及堆区、缓冲区的强转,我都会先验证首地址的对齐属性。
4.2 现象:切割后链表断掉,free 遍历死循环
切割代码一开始没写new_block->next = cur->next,直接默认原块的 next 会传递下去。结果测试连续申请 5 次内存,释放第 2 块后再申请,链表遍历时走到了一个野指针上,程序直接卡死。
原因:新块是从原块尾部切出来的,它和原块后面的块是邻居,必须显式把 new_block 的 next 指向原块的 next。解决方式很简单,就是上面代码里的那一行赋值。类似的还有释放时向前合并后要重新设置 prev->next,否则前驱块会指向已“不存在”的 cur,造成悬垂。
4.3 现象:用户越界写,header 被改,free 时 size 异常
测试程序里有一个memcpy(dst, src, 64),但目标块实际只申请了 32 字节。运行几轮后,malloc 返回的地址开始乱跳,甚至 free 时报出荒谬的 size 值。
原因:用户数据越界,直接写入了下一块的 header,把 next 指针和 free 标志全污染了,整个 free list 的结构被破坏。这种错误无法在 my_free 内部拦下来,只能靠调用方守规矩。
解决:在 my_malloc 的返回值处可以用__builtin_object_size做编译期检查,但最有效的还是测试时多分配一个 padding 区,在 padding 里填特殊字节,释放前检查这些字节有没有被改动。我在 debug 版里加了这一层,定位了三个越界写的问题。
5. 回归验证与调试技巧:把 malloc 当成独立模块来测
5.1 测试矩阵:碎片化场景是试金石
把 my_malloc 和 my_free 单独编成一个 .c 文件,写一个测试驱动,覆盖以下四类场景:小对象循环申请释放、大小交错分配的碎片化压力测试、一次性申请大块再连续释放、释放全部块后检查堆区总空闲空间是否回到初始值。其中碎片化场景最关键:循环 10000 次,每次随机申请 8 到 256 字节,然后随机释放,完成后检查所有空闲块的 size 总和,看是否等于 HEAP_SIZE 减掉 header 占用。
for (int i = 0; i < 10000; i++) { int sz = 8 + rand() % 250; void *p = my_malloc(sz); if (p) records[i] = p; if (rand() % 3 == 0 && records[i]) { my_free(records[i]); records[i] = NULL; } } // 遍历 free list,累加 free 块的 size,对比初始值如果碎片化测试不通过,多半是向前合并没有生效或者切割条件写错。有一个快速定位的技巧:在每次 my_malloc 和 my_free 执行后,打印当前 free list 里所有块的 size、地址和 free 标志,排列成表格,人眼扫一遍就能看出哪里断了链。
5.2 用 memory tracer 记录每次调用的地址与大小
困扰最多的场景不只是崩溃,还有“内存没崩但不知道谁没释放”。我写了一个简单的 tracer,在 my_malloc 返回时把指针和大小记到一个全局日志数组里,my_free 时再标记释放。程序退出前扫描日志,所有未被标记释放的地址就是泄漏点。
注意 tracer 本身需要一段独立的存储空间,不能放在被管理的堆区里,否则 tracer 的日志写入会污染 free list。我单独开了一个静态数组来存日志记录,配合调用方的文件名和行号宏,定位效率比用调试器快很多。从那次之后,我给每个教学版内存模块都默认挂上 tracer,再复杂的测试都没翻过车。
写这个 malloc 的过程让我最大的收获不是学会了分配算法,而是明白了任何看似简单的系统接口背后都有一套值得拆开研究的设计取舍。如果你正在学内存管理,自己动手把 free list 跑通一次,比读十遍文档都有用。希望这份笔记能帮你的 malloc 之旅少走几个弯路。
本文还有配套的精品资源,点击获取