☰
自己动手写malloc:理解堆内存管理、对齐与碎片化的底层实践
2026/9/29 16:03:53 网站建设 项目流程

简介:以 my_malloc 为例演示 C 语言动态内存分配函数实现原理的微型学习项目,面向想深入理解内存管理的 C 程序员与系统编程入门者。代码从零搭建堆区内存池,用空闲块链表组织可用空间,清晰讲解分配时的首次适配与块拆分、释放时的状态标记与相邻空闲块合并,也提及对齐约束、内存碎片和并发控制等真实 malloc 需要处理的复杂问题。压缩包体积仅 3KB,共 3 个文件,包含 2 个 C 源文件与 1 个头文件,其中两个 C 文件分别负责分配器主体和基础功能验证,头文件定义接口与常量,结构适合边阅读边实验。已有 1826 人学习下载,可作为操作系统课程设计、编译原理实验或自研内存池的入门参考,能帮助读者快速理解堆空间增长、空闲块查找与碎片抑制,并为进一步优化内存管理策略打下基础。

1. 自己动手写 malloc:一个让你不再害怕内存崩溃的底层练习

prt=(char*)malloc(10.2*1024*sizeof(char))这种写法我看着就会皱眉——浮点数算分配大小、C 语言里手动强转、10.2 倍这种魔幻系数,每一处都在挑战 malloc 的边界。可现实是很多人平时就这么写内存代码,直到线上越界写把相邻块的标志位踩坏,才意识到自己根本不了解 malloc 背后发生了什么。自己动手写一个 malloc,不是为了造轮子替代 glibc,而是把操作系统虚拟内存、堆区管理、对齐、碎片化这些黑匣子一个个撬开。这份代码用 sbrk 扩容、用边界标记合并相邻空闲块,编译完可以挂到自己的测试程序上做实验。写完之后,你对"内存对齐为什么重要""free 为什么只需要传指针本身""为什么 C/C++ 要把内存安全当大事"这些问题的判断,会比读十篇博客都扎实。适合想搞懂底层机制、排查线上内存问题、或者正在学 OS 课程需要做实验的同学。

2. 动手前的底料:进程堆与 sbrk / mmap 的取舍

2.1 进程地址空间里,malloc 的那块内存到底在哪

先抛开代码,用两个命令把堆的位置看清楚。Linux 下每个进程的虚拟地址空间都可以在/proc/self/maps里看到,下面这段是把"自己"的内存布局打出来:

cat /proc/self/maps | head -20

输出里会看到这么几段:

00400000-00401000 r-xp 00000000 ... /bin/cat 00600000-00601000 r--p 00000000 ... /bin/cat 00601000-00602000 rw-p 00001000 ... /bin/cat 7f...-7f... r-xp ... /lib/x86_64-linux-gnu/libc.so.6 7f...-7f... rw-p ... [heap] 7ffe...-7ffe... rw-p [stack]

先说明一个常见误区:我们平时说的"堆"并不是编译期就定死的一段区域,而是由brk指针圈出来的动态区。[heap]这一行就是进程堆的映射,它的起点通常在数据段之后,终点由内核维护的brk值决定。malloc 默认就是在这段区域里做分配,区域不够了,就调用系统调用把brk往后推,扩大堆。理解这一点是写 malloc 的前提:你手头的内存是一段连续增长的区域,所有分配和释放都在这一亩三分地里完成。

2.2 sbrk 扩容与 mmap 大分配:什么时候该用谁

扩大堆的系统调用有两个:brk和sbrk。brk(void* addr)直接把堆顶设置到指定地址;sbrk(intptr_t increment)则在当前堆顶上增加指定字节数。两者本质同一件事,sbrk用起来更顺手,因为返回值就是扩展前的老堆顶,正好可以作为新块的起始地址。

void *old_brk = sbrk(0); // 查看当前堆顶,不扩展 void *p = sbrk(4096); // 堆顶后移 4096 字节,p 指向新区域起点 if (p == (void *)-1) { // sbrk 失败返回 -1 perror("sbrk"); }

参数说明:sbrk(0)并不是申请 0 字节,而是 Linux 提供的"查询"用法,返回当前 program break 的位置;sbrk(4096)才是真正扩展。返回值是扩展前的堆顶地址,所以新分配的 4096 字节从 p 开始,到老的堆顶 + 4096 结束。

不过,glibc 的 malloc 并不只用 sbrk。超过MMAP_THRESHOLD(默认 128 KB)的大块分配会走mmap系统调用,直接向内核要一段独立的匿名内存映射,释放时通过 munmap 把内存还给操作系统。这样做的好处是:大块分配不会在堆里留下碎片,释放后立刻归还系统,堆本体保持紧凑。代价是每次 mmap/munmap 都有页对齐和页表操作的开销,比 sbrk 推指针重得多。

我这份教学实现刻意只用 sbrk,原因有三:第一,sbrk 分配出来的区域连续,天然适合演示"相邻块合并";第二,代码路径短,找块、拆分、合并的逻辑不会被 mmap 的分支干扰;第三,mmap 分配出来的块与堆不相邻,需要额外的数据结构记录,会让第一版代码难读。理解 sbrk 版本之后,改成混合模式只是加一个大小判断的事。

2.3 对齐这个隐含契约:16 字节不是玄学

malloc 返回的地址是有对齐要求的。x86-64 下 glibc 保证返回地址 16 字节对齐,也就是地址最低 4 位为 0。为什么?现代 CPU 加载double(8 字节)和__m128这类 SIMD 类型时,如果地址没有对齐到它的自然边界,轻则多次访存,重则直接触发总线错误。C 标准只要求 malloc 返回的指针能装下任何基本类型,实践中编译器默认对齐是 16 字节。

自己写 malloc 第一个容易翻车的地方就在这:sbrk 返回的地址是页对齐的(4096 的倍数),16 字节对齐天然满足。但你从分配块里切出去的子块可不一定对齐。所以设计块结构时,头部大小本身就要对齐到 16,每个块的总大小也必须是 16 的倍数。具体做法我在第 4 章展开,这里先记住一个结论:块头的大小、块的大小、返回地址,三者必须同时满足对齐。少一个,你的 malloc 在double数组和结构体上就会不定期崩溃,而且是那种换个编译器就消失的玄学崩溃。

3. 数据结构与策略:先画图再写码

3.1 块头与块尾:为什么必须用边界标记

每个分配出去的内存块,头部要记录元信息。最朴素的设计是只放一个块头:

+----------------+-----------------------------+ | size | free | 用户可用数据区 | +----------------+-----------------------------+

头部记录块大小和是否空闲。问题是 free 的时候怎么知道"前一个块"在哪?只有大小没有反向指针,你只能从头遍历整个堆去找前一个块的地址,复杂度 O(n)。更糟的是,如果前一个块是空闲的,你要把两个空闲块合并成一个,遍历找前驱会非常慢,而且容易出错。

业界标准解法是边界标记(boundary tag):在每个块的尾部也存一份"大小 + 空闲标志"。这样当前块地址减去 8 字节,就能从尾部读出前一个块的大小,进而定位前一个块的头部。尾部这个字段叫 footer 或 boundary tag。

+--------+---------------------------+--------+ | header | 数据区(空闲时存链表指针) | footer | +--------+---------------------------+--------+

header 和 footer 都存同样的大小和标志位。malloc 时只需把 header 填好,free 时同时把 footer 更新。要合并前块时,直接读当前块前 8 字节的 footer,O(1) 判断前块是否空闲、前块多大。这是所有现代 malloc 实现的基础,glibc 里对应的字段就是 chunk 的prev_size和size。你可以把 footer 理解成给内存块装了一面后视镜,free 时才不会撞车。

3.2 空闲链表的组织方式与首次适配

有了 footer 能往前合并,还需要一个结构把所有空闲块串起来。我选择把所有空闲块组织成双向链表,用一个全局指针指向链表头。双向的原因很简单:从链表中摘除一个块(分配它)时,需要改前驱的 next 和后继的 prev,单向链表做不到 O(1) 摘除。

空闲链表指针放在哪?这里有个经典技巧:已经分配出去的块,数据区归用户所有,不能再放任何东西;而空闲块的数据区没有用户数据,正好可以借用前 16 字节存 next 和 prev 指针。所以 malloc 返回给用户的地址始终是"块头之后",空闲链表指针只在块空闲时存在于数据区开头,两者不冲突。这省下了每个块永久保留两个指针的开销。

分配策略我选 first-fit:从链表头开始遍历,找到第一个大小足够的块就用。为什么不选 best-fit?理论上 best-fit 碎片最少,但要遍历整个链表且维护"最接近"的候选,开销大;worst-fit 更离谱,需要找最大块,碎片化极其严重。first-fit 在配合合并之后表现足够好,而且代码简单。glibc 实际是分箱(bins)管理不同大小,那是工程优化,不是机制差异。

3.3 拆分与合并:内存碎片化的第一道防线

分配时找到的块往往比请求大,比如用户要 100 字节,空闲块是 512 字节。直接把整块给用户,堆里全是超大空闲块的"膨胀"状态,后续小请求会越来越难满足。所以必须拆分:把 512 的块切成"给用户的 used 块 + 剩余 free 块",剩余部分重新挂回空闲链表。

拆分有个下限:剩余部分至少要能放下"头部 + 两个指针 + 尾部"。如果你把剩余 20 字节也硬拆出来,这块连链表指针都放不下,既不能分配也不能管理,就成了死区。通常MIN_BLOCK_SIZE至少是 32 或 40 字节,我在第 4 章给出精确计算。

合并的时机固定在 free 时:释放一个块后,立即检查它物理相邻的前块和后块。前块空闲就合并,后块空闲就合并,合并后的块重新计算大小并同步更新 footer。这个过程把 free 操作从"往链表里塞一块"变成"让堆保持紧凑的一个动作"。只要 free 时坚持合并,碎片化就不会无限恶化。

4. 核心实现:一个可运行的 malloc 与 free 全代码

4.1 基础宏与工具函数

先定义块布局所需的所有常量和工具。这块代码是整个实现的地基,每一个宏的含义下面逐条说明。

#include <unistd.h> #include <stdint.h> #include <string.h> #define ALIGNMENT 16 #define ALIGN(x) (((x) + (ALIGNMENT - 1)) & ~(ALIGNMENT - 1)) #define FLAG_FREE 0x1UL #define HEADER_SIZE ALIGN(sizeof(block_t)) #define FOOTER_SIZE (sizeof(size_t)) #define POINTERS_SIZE (2 * sizeof(block_t *)) #define MIN_BLOCK_SIZE (HEADER_SIZE + FOOTER_SIZE + POINTERS_SIZE) typedef struct block { size_t size; // 块总大小(含header和footer),低1位标记是否空闲 size_t _pad; // 填充,保证数据区16字节对齐 } block_t; typedef struct footer { size_t size; // 和header的size保持一致,也含free标志 } footer_t; static block_t *free_list = NULL; static block_t *heap_base = NULL; static block_t *heap_end = NULL; static size_t blk_size(block_t *b) { return b->size & ~FLAG_FREE; } static int blk_is_free(block_t *b) { return b->size & FLAG_FREE; } static void blk_set_free(block_t *b) { b->size |= FLAG_FREE; } static void blk_set_used(block_t *b) { b->size &= ~FLAG_FREE; } static footer_t *blk_footer(block_t *b) { return (footer_t *)((char *)b + blk_size(b) - FOOTER_SIZE); } static block_t *blk_next(block_t *b) { return (block_t *)((char *)b + blk_size(b)); } static block_t *blk_prev(block_t *b) { footer_t *f = (footer_t *)((char *)b - FOOTER_SIZE); return (block_t *)((char *)b - (f->size & ~FLAG_FREE)); } static block_t **flink_ptr(block_t *b) { return (block_t **)((char *)b + HEADER_SIZE); } static block_t **blink_ptr(block_t *b) { return (block_t **)((char *)b + HEADER_SIZE + sizeof(block_t *)); }

逻辑说明:size字段的低位同时承担"空闲标志"的角色,所以取真实大小要用& ~FLAG_FREE。这一步省掉一个独立的 free 标志字段,也让 footer 同步时只需要复制一个size_t。blk_prev读的是当前块头部往前 8 字节处的 footer,从那里解析出前一个块的大小和空闲标志,从而定位前块头部。flink_ptr和blink_ptr返回的是空闲块数据区里 next / prev 指针的存放位置,仅当块空闲时使用。

参数说明:ALIGNMENT取 16,与 glibc 的返回地址对齐保持一致;ALIGN(x)用位操作实现向上取整到 16 的倍数,比(x + 15) / 16 * 16少一次除法;MIN_BLOCK_SIZE计算上是 16 + 8 + 16 = 40 字节,这是拆分后剩余块的最小规模。注意HEADER_SIZE用ALIGN(sizeof(block_t))而不是直接写 16——万一将来扩字段,对齐计算会自动跟上。

4.2 扩展堆与空闲链表操作

堆空间不够时,需要 sbrk 扩容并把新区域纳入块管理。这里有一个隐藏的初始化问题:进程启动时 brk 不一定在 16 字节边界上,第一次扩展前必须手动对齐。

static block_t *extend_heap(size_t need) { if (heap_base == NULL) { uintptr_t cur = (uintptr_t)sbrk(0); size_t rem = cur % ALIGNMENT; if (rem != 0 && sbrk(ALIGNMENT - rem) == (void *)-1) { return NULL; } heap_base = (block_t *)sbrk(0); } void *raw = sbrk(need); if (raw == (void *)-1) return NULL; block_t *nb = (block_t *)raw; nb->size = need; blk_set_free(nb); nb->_pad = 0; blk_footer(nb)->size = need; blk_set_free(blk_footer(nb)); if (heap_end != NULL) { if (blk_is_free(heap_end)) { list_remove(heap_end); size_t total = blk_size(heap_end) + need; heap_end->size = total; blk_set_free(heap_end); blk_footer(heap_end)->size = total; blk_set_free(blk_footer(heap_end)); return heap_end; } heap_end = nb; return nb; } heap_base = nb; heap_end = nb; return nb; } static void list_insert(block_t *b) { *flink_ptr(b) = free_list; *blink_ptr(b) = NULL; if (free_list) *blink_ptr(free_list) = b; free_list = b; } static void list_remove(block_t *b) { block_t *nx = *flink_ptr(b); block_t *pv = *blink_ptr(b); if (nx) *blink_ptr(nx) = pv; if (pv) *flink_ptr(pv) = nx; else free_list = nx; *flink_ptr(b) = NULL; *blink_ptr(b) = NULL; }

逻辑说明:extend_heap先处理首次调用的对齐,然后 sbrk 申请need字节。新扩展的区域被包装成一个空闲块。如果原来的堆尾heap_end本身是空闲的,就不该产生两个相邻的空闲块,而是直接把新区域并入堆尾块,修改它的 size 和 footer。这种"扩容时顺带合并"的优化,避免了一次分配就把堆切成两半的情况。list_insert永远把新块插到链表头部,实现 LIFO 顺序,最近释放的块最可能被下次分配命中,局部性好。

4.3 malloc 与 free 主流程

主流程反而是最短的代码,但要注意拆分的调用顺序和堆尾指针的更新。

void *malloc(size_t n) { if (n == 0) return NULL; size_t need = ALIGN(n + HEADER_SIZE + FOOTER_SIZE); block_t *b = find_free(need); if (!b) { b = extend_heap(need); if (!b) return NULL; } else { list_remove(b); } blk_set_used(b); blk_footer(b)->size = b->size; blk_set_used(blk_footer(b)); if (blk_size(b) - need >= MIN_BLOCK_SIZE) { block_t *rest = (block_t *)((char *)b + need); rest->size = blk_size(b) - need; blk_set_free(rest); blk_footer(rest)->size = rest->size; blk_set_free(blk_footer(rest)); b->size = need; blk_set_used(b); blk_footer(b)->size = b->size; blk_set_used(blk_footer(b)); if (b == heap_end) { heap_end = rest; } list_insert(rest); } return (void *)((char *)b + HEADER_SIZE); } void free(void *ptr) { if (!ptr) return; block_t *b = (block_t *)((char *)ptr - HEADER_SIZE); if (blk_is_free(b)) return; blk_set_free(b); blk_footer(b)->size = b->size; blk_set_free(blk_footer(b)); if ((char *)b != (char *)heap_base) { block_t *pv = blk_prev(b); if (blk_is_free(pv)) { list_remove(pv); size_t total = blk_size(pv) + blk_size(b); pv->size = total; blk_set_free(pv); blk_footer(pv)->size = total; blk_set_free(blk_footer(pv)); b = pv; } } block_t *nx = blk_next(b); if (nx <= heap_end && blk_is_free(nx)) { list_remove(nx); size_t total = blk_size(b) + blk_size(nx); b->size = total; blk_set_free(b); blk_footer(b)->size = total; blk_set_free(blk_footer(b)); if (nx == heap_end) heap_end = b; } list_insert(b); }

逻辑说明:malloc 的流程是"找块 → 找不到就扩堆 → 标记 used → 按需拆分 → 返回数据区"。need已经把头部和尾部的开销算进去,并做 16 字节对齐。拆分时先建 rest 块、更新原块大小,再插回空闲链表;如果原块正好是堆尾,拆分出的 rest 成为新的堆尾。free 的流程是"标记空闲 → 合并前块 → 合并后块 → 挂回链表"。因为有了 footer 和 heap_base / heap_end 两个哨兵,前后合并都不需要遍历堆,全部 O(1)。

参数说明:n + HEADER_SIZE + FOOTER_SIZE是把用户请求加上块管理开销,ALIGN之后得到块的完整大小。MIN_BLOCK_SIZE是拆分门槛,剩余少于 40 字节就不拆,把整块交给用户,省得产生一个没法管理的微型空闲块。free 里的if (blk_is_free(b)) return;是双 free 的第一道防线,能拦住一部分错误调用,但拦不住"重复 free 一个已经合并进别的块的指针"。

4.4 参数怎么调:对齐、最小块、双指针开销

这张表是这份实现里所有可调参数的含义,调试时逐个改,观察对内存占用和性能的影响。

参数取值作用改大/改小的影响
ALIGNMENT16块大小与返回地址对齐改到 8 在某些平台能省空间但 SIMD 可能崩;改 32 增加 padding 浪费
HEADER_SIZE16块头大小,含 size 和 pad由 block_t 结构决定,不要手动改
FOOTER_SIZE8尾部边界标记固定为 size_t 大小,用于 O(1) 前向合并
MIN_BLOCK_SIZE40拆分最低剩余量改大减少碎片但难以利用小块;改小会产生无法分配的死区
POINTERS_SIZE16空闲链表 next/prev受 64 位指针大小约束,别改成 8

这表里最容易忽略的是 MIN_BLOCK_SIZE 和 ALIGNMENT 的联动:如果 ALIGNMENT 改成 32,MIN_BLOCK_SIZE 自动变成 16 + 8 + 16 = 40 不变,但每个块的 header 可能因为对齐要加 padding,实际开销会变大。调对齐参数之前,先算一遍块头是否跟着对了齐,否则返回地址会在 8 字节边界上摇摆不定。

5. 避坑指南:自己写 malloc 最容易翻车的五个场景

5.1 返回地址不对齐,struct 直接段错误

现象:malloc 出来的指针能被整除,但(uintptr_t)ptr % 16 != 0。往这个指针里存double数组,运行时偶尔崩溃,GDB 看是SIGSEGV,但代码逻辑怎么看都没问题。

原因:sbrk 首次返回的地址做了对齐,但块结构里block_t大小为 16、块大小也是 16 的倍数,这两条做到了,唯独忘了 footer 在计算块内偏移时占用了空间,导致真实返回地址偏移了 8 字节。另一种常见情况是堆的初始 brk 没对齐就开始第一次扩展。

解决:强制在extend_heap首次调用时对齐 brk,并用ALIGN(sizeof(block_t))计算 head 大小。我把检查地址对齐写进了测试代码:assert(((uintptr_t)malloc(1) & 0xF) == 0),这条断言从第一次分配就一直挂在测试里,任何一个改动都可能把它打破。

5.2 free 找前一个块,读到野指针

现象:free 一块内存后,紧接着再 malloc 一个稍大的块,程序在后续操作时出现 "corrupted double-linked list" 或链表成环,死循环卡在 find_free 里。

原因:blk_prev的实现依赖当前块前 8 字节的 footer,这个 footer 只在"当前块地址 - 8"确实落在前一个块尾部时才有效。如果你 free 的是堆的第一个块,b - 8指向 heap_base 之前,那里是别的映射区域,读出来的 size 是垃圾值,前块定位完全错乱。

解决:在 free 合并前加(char *)b != (char *)heap_base的判空,第一个块禁止向前合并。同样的,向后合并时检查nx <= heap_end,避免越过堆顶访问未映射内存。这两个哨兵判断是我踩过最深的坑——从逻辑上看blk_next和blk_prev只是指针算术,但边界块根本没有"相邻块",必须显式排除。

5.3 拆分后剩余块太小,白白丢掉一大片内存

现象:跑一个反复分配 24 字节小对象的测试,内存占用却一路涨到几十 MB,但所有分配加起来不到 1 MB。查看空闲链表发现大量大小只有 16 或 24 字节的"残块"。

原因:拆分条件写成了blk_size(b) - need >= HEADER_SIZE,只保证剩余能放下头部,没考虑空闲块数据区要存放 next/prev 两个指针。于是拆出来的残块既不能响应任何合法分配请求,又占据了堆空间,还挂着链表指针自相矛盾。

解决:用MIN_BLOCK_SIZE = HEADER_SIZE + FOOTER_SIZE + 2 * sizeof(block_t *)作为硬门槛。小于这个值绝不拆分,宁可浪费当前块的一部分空间,也不制造一个无法管理的小碎片。从那以后我每次写拆分逻辑,都会追问一句"剩余部分能不能独立 free 并重新分配",能才算拆分成功。

5.4 溢写和重复 free:自己的 malloc 没有 glibc 的"后悔药"

现象:程序里有一处memcpy(dst, src, 200)而 dst 只 malloc 了 128 字节。在 glibc 下可能过很久才爆,在自己写的 malloc 下立刻把下一个块的 footer 覆盖掉,导致下一次 free 时链表遍历直接崩溃。更阴险的是 double free:同一个指针 free 两次,第二次 free 时它已经在空闲链表里,list_remove把 next/prev 改乱,链表成环。

原因:glibc 的 malloc 在每个 chunk 里放了校验信息,free 时做完整性检查,能在部分场景拦住越界写和 double free,只是不保证。我这份教学实现为了代码清晰没有加 magic number,任何溢写都会静默破坏块的元数据。

解决:自己实现里至少加两道便宜防线。第一,free 入口判空闲标志,能拦住"同一个块连续 free 两次";第二,在 header 里加一个固定魔数,free 时校验,魔数不对直接abort(),把错误暴露在 free 调用点而不是随机延迟到下一次 malloc。代码里我给 header 预留了_pad字段,想加魔数就把它用起来。

5.5 CUDA 环境下 malloc 可能被禁用,别把 host 分配当万能

现象:在 CUDA 的 host 代码里直接malloc一块内存当设备缓冲区用,运行时环境报出与cuda malloc disabled类似的错误,或者性能骤降。把同样的代码换成cudaMallocManaged/cudaHostAlloc后正常。

原因:CUDA 的统一内存和零拷贝内存有自己对齐、页粒度和地址映射的要求,普通 glibc malloc 返回的 host 内存并不满足设备端的访问约束。部分受限环境甚至会禁用 host 侧的普通 malloc 路径,让依赖它的程序在初始化阶段就失败。这跟手写 malloc 没有直接关系,但它说明了"malloc 只保证 C 语言层面的可用性,不保证任何特殊硬件场景的可用性"。

解决:遇到这类环境,先查运行日志确认是不是分配器被接管,再看目标内存是否需要设备可见性。需要就改用 CUDA 提供的分配接口(如cudaMallocManaged),不需要就保持 host malloc,但用cudaMemcpy搬运数据。写自己的堆分配器时也记住:换环境、换硬件,分配器的契约会变,不能假设 glibc 的行为处处成立。

6. 验证与进阶:从能跑到能用

先写一个冒烟测试,把最基本的正确性锁住:

#include <assert.h> #include <stdio.h> #include <stdlib.h> int main() { for (int i = 0; i < 10000; i++) { size_t sz = rand() % 512 + 1; char *p = malloc(sz); assert(p && "malloc returned NULL"); assert(((uintptr_t)p & 0xF) == 0 && "alignment broken"); for (size_t j = 0; j < sz; j++) p[j] = (char)j; free(p); } printf("smoke test passed\n"); return 0; }

这段代码每个块都做一次全量写和全量校验,free后交给分配器做合并,连续执行万次不崩、不泄漏、地址对齐不出问题,说明基本正确。跑一遍同时观察内存峰值,如果只增不减,多半是合并逻辑漏了情况。

进阶验证:把这份实现编成共享库,用LD_PRELOAD挂到一个真实程序上。注意LD_PRELOAD替换 malloc 需要同时导出calloc、realloc和free,缺符号程序会在动态链接阶段失败。我这套实现没有calloc/realloc,直接用LD_PRELOAD会踩动态链接的坑,所以更稳妥的做法是在测试程序里用宏替换:

cc -o test test.c my_malloc.c -DMALLOC="my_malloc" -DFREE="my_free" -Wall -g

替换到实际问题程序里能看出不少工程细节:比如realloc是先分配新块再 memcpy,还是尝试原地扩展相邻空闲空间;比如频繁的小块分配会让 sbrk 一直推高堆顶,堆空间不还给 OS。一个简单改进是 free 堆尾空闲块时,用brk把堆顶收回去:

if (heap_end == b && blk_is_free(b) && b != heap_base) { brk((char *)heap_end); heap_end = blk_prev(heap_end); list_remove(b); }

这段能回收堆尾的空闲空间,代价是堆尾不能再 free,因为 OS 收回的是整段虚拟地址。更陡的改进方向是学习 glibc 的分箱设计:按大小分 64 个 bins,小块从对应的 bin 里取,省去遍历;大块用 mmap。这些留着当课后作业。我自己每次写完一段内存管理代码,都会强制走一遍"对齐检查 → 边界块合并 → 拆分下限 → 双向链表成环"四个关卡,确认过关才敢提交。这次你也照着这个清单跑一轮,希望帮到你。

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

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

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

立即咨询