简介:面向C语言进阶学习者与系统编程入门者的内存分配实践包,围绕动态内存分配原理,引导读者动手实现一个简化版malloc——my_malloc,理解堆区管理、内存块划分、空闲块查找与合并等核心机制。压缩包仅3KB,共3个文件,由2个C源文件与1个头文件组成:头文件声明分配与释放接口及内存块结构,源文件给出分配器主体实现,另一个C文件提供调用与验证用的测试代码,体量轻巧却构成完整可编译的实验骨架。已有1830人学习。读者可据此观察首次适配、最佳适配等空闲块选择策略的落地方式,练习维护空闲链表、切分大块、合并相邻空闲块以减少碎片,并在此基础上自行扩展对齐处理与并发保护,为阅读glibc等成熟分配器打下基础。
1. 自己写 malloc 到底图什么:从一次内存碎片引发的线上抖动说起
去年接手一个模拟项目,图像处理流水线跑八小时后 RSS 从 200MB 涨到 1.3GB,重启就好,日志里一个错误都没有。查了三天才发现不是泄漏,是大量 48~96 字节的小块分配把堆撕碎了,系统 malloc 的大块释放不回去。那次之后我把 malloc 的实现从头撸了一遍——自己写 malloc 不是为了替换 libc,而是为了在出问题时能打开这个黑匣子。下面这套最小实现从sbrk和mmap拿页开始,经过块头设计、16 字节对齐、分裂合并,最后升级成带大小类的分离空闲链表,再用LD_PRELOAD挂到真实程序上做对照压测。适合想搞懂内存管理底层、准备深挖 allocator 细节、或者正被碎片和指针越界折磨的 C/C++ 从业者。
2. 向内核要内存:sbrk 与 mmap 的选型边界
分配器的第一件事不是管理内存,是拿到内存。用户态能直接向内核申请堆空间的入口不多,sbrk和mmap是最常用的两条路,它们的成本模型完全不同,选错了后面怎么写都别扭。我见过不少人一上来就全用mmap,理由是「归还干净」,结果小块分配的性能掉到系统 malloc 的三分之一以下,因为每次mmap都是一次真正的系统调用加页表操作。
2.1 sbrk 的语义与它埋下的三个副作用
sbrk(intptr_t increment)做的事情很朴素:把进程的 program break 指针往上或往下移动increment字节,返回移动之前的旧位置。成功返回旧 break,失败返回(void *)-1并置errno = ENOMEM。注意这个返回值约定,它不返回 NULL,所以判断写成if (p == (void *)-1),写成if (!p)会把正常地址误判成失败——这是第一个坑。
第二个副作用是它和 libc 自己的分配器共用同一个 break。只要你的程序里还调用了printf、fopen、std::string这类内部会走 malloc 的接口,两套分配器就会在同一个地址空间上互相踩。我第一次调试时程序在printf里崩,查了半天才反应过来是自己把 break 挪走了。解决办法只有两个:要么完全接管(LD_PRELOAD全替换,第 6 章讲),要么在初始化时先用sbrk(0)记下当前 break,之后只在自己预留的那段区间里活动。
第三个副作用是归还能力极弱。break 只能整体回退,你 free 掉堆中间的一块,那块内存永远留在那里等下一个合适大小的请求。这就是碎片问题的物理来源。
#define _GNU_SOURCE #include <unistd.h> #include <stdint.h> #include <stddef.h> #include <errno.h> #define PAGE_SIZE 4096UL static char *heap_limit = NULL; /* 当前 break 的绝对上界 */ /* 用 sbrk 扩一段,返回新段起始地址,失败返回 NULL */ static void *grow_by_brk(size_t len) { void *old = sbrk((intptr_t)len); /* 参数是 intptr_t,不是 size_t */ if (old == (void *)-1) /* 失败返回 -1,不是 NULL */ return NULL; heap_limit = (char *)old + len; /* 记录上界,后续边界判断要用 */ return old; }这段是整个分配器的地基。heap_limit不是可选项,后面判断「下一块是否还在堆内」全靠它,没有它就会在堆尾读到不属于自己的内存。len必须是页大小的整数倍,虽然 Linux 上sbrk对非对齐长度通常也能成功,但那样 break 会落在页中间,后续做munmap或统计页数时全是麻烦。
2.2 大块走 mmap:阈值怎么定,标志位怎么打
sbrk拿的内存归还困难,所以行业里的通用做法是给大块开一条单独通道:超过某个阈值的申请直接用匿名mmap拿,free 的时候munmap原样还给内核,一步到位。这个阈值常见取值是 128KB,也有实现用 64KB 或 256KB,取决于你的负载里大块占比。
#include <sys/mman.h> #define MMAP_THRESHOLD (128UL * 1024) /* 匿名映射一段,返回地址,失败返回 NULL */ static void *grow_by_mmap(size_t len) { void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, /* 匿名、私有,不落盘 */ -1, 0); /* 匿名映射 fd 必须为 -1 */ if (p == MAP_FAILED) /* 失败返回 MAP_FAILED,即 (void*)-1 */ return NULL; return p; }PROT_READ | PROT_WRITE给读写权限,MAP_PRIVATE | MAP_ANONYMOUS表示不共享、不对应任何文件。fd传 -1、offset传 0 是匿名映射的固定写法,如果传了有效 fd 就变成文件映射,语义完全不同。MAP_ANONYMOUS在严格 C 标准下不可见,编译时加-D_GNU_SOURCE或者用_DEFAULT_SOURCE。
阈值之上走 mmap 有个必须处理的细节:mmap 段和 brk 段在地址空间里是两块不相邻的区域,你不能拿 brk 段的heap_limit去判断 mmap 块的「下一块在哪」。所以块头里要额外留一个标志位,标记这块是 mmap 来的,free 时直接munmap走人,不参与任何链表和合并逻辑。这一点在第 3 章的块头设计里会具体落位。
2.3 morecore:把内核页包装成可分配的块
把两条路收口到一个函数里,就是教科书里那个morecore。它的职责很单一:不管走哪条路,都返回一块「已经填好块头的空闲内存」,长度至少need。
#define IS_MMAP 0x2UL #define BLK_SIZE(b) ((b)->size & ~(size_t)0xF) static block *morecore(size_t need) { /* 向上取整到页,避免 break 落在页中间 */ size_t len = ((need + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE; char *p; size_t flag; if (len >= MMAP_THRESHOLD) { p = grow_by_mmap(len); flag = IS_MMAP; /* 打标记,free 时直接 munmap */ } else { p = grow_by_brk(len); flag = 0; } if (!p) return NULL; block *b = (block *)p; b->size = len | flag; /* 低 4 位放标志,高位移出真实长度 */ b->prev_size = 0; /* 新段前面没有物理相邻块 */ b->prev = b->next = NULL; /* 暂不进链表,由调用方决定 */ return b; }三个参数值得说清楚。len向上取整到 4096 的倍数,是为了让 break 永远停在页边界上,后面如果要实现「堆顶收缩」就能直接按页回退。flag用0x2这一位表示 mmap 块,因为 16 字节对齐保证了 size 的低 4 位永远是 0,可以放心拿来做标记。b->prev_size = 0表示这是段的头部,向前合并时要靠它判断「前面没有块了」,不然会往上一直读到野地址。
新段拿到手之后还有一个可选优化:如果grow_by_brk返回的地址正好等于原来的heap_limit,说明新旧两段物理相邻,理论上可以和上一段末尾的空闲块合并成一大块。标准实现会这么做,代价是要维护一个「最后一块」的指针。我这版先不合并,代码短一半,代价是长期运行会多出几段相邻但不连通的空闲区——这个取舍在第 4 章换成大小类之后影响会小很多,因为那时查找走的是桶索引而不是线性扫描。
3. 块头、对齐与分裂合并:一个能跑通的最小分配器
拿到内存之后,真正的工作才开始:怎么在一条连续的内存里切出用户要的那一块,怎么在用户还回来的时候把相邻的碎片重新粘起来。这一章先把最朴素的版本写通——一条单向空闲链表 + 首次适配,代码不到 150 行,但分裂、合并、对齐、边界判断这四个核心动作一个不少。跑通这一版,后面换数据结构只是替换查找逻辑。
3.1 块头字段怎么排:32 字节里塞下什么
块头是分配器的元数据核心,它的字段顺序和大小直接决定了最小分配粒度。我用的布局是四个字段共 32 字节:
typedef struct block { size_t size; /* 总大小(含头部),bit0 = 在用,bit1 = mmap 块 */ size_t prev_size; /* 物理相邻的前一块的总大小,用于向前合并 */ struct block *prev; /* 空闲链表前驱,第 4 章的双向链表要用 */ struct block *next; /* 空闲链表后继 */ } block; #define ALIGNMENT 16UL #define ALIGN_UP(x) (((x) + ALIGNMENT - 1) & ~(ALIGNMENT - 1)) #define HDR_SIZE sizeof(block) /* 32 字节 */ #define MIN_BLOCK (HDR_SIZE + ALIGNMENT) /* 48 字节,切下去还能剩一块 */ #define IS_USED(b) ((b)->size & 0x1UL) #define IS_MMAP(b) ((b)->size & 0x2UL)size字段同时承担长度和状态两个职责,靠 16 字节对齐腾出来的低 4 位做压缩存储。prev_size看起来冗余——prev指针不也能找到前一块吗?不行,prev只在「前一块处于空闲状态」时才有意义,前一块如果在用,它就不在任何链表里。而prev_size是物理相邻关系,永远有效,这是向前合并的唯一依据。
MIN_BLOCK = 48是个硬约束:一次分裂之后,剩下的那块至少要能装下一个块头加 16 字节,否则这个碎片永远分配不出去,只能算浪费。所以malloc(1)实际会占掉 48 字节,这是所有分配器的常态,不是 bug。
对齐到 16 字节同样不是可选项。x86-64 上movaps这类 SSE 指令要求操作数 16 字节对齐,memcpy在长度较大时会切到 SIMD 路径。如果你的malloc返回 8 字节对齐的地址,程序会在memcpy里崩,而栈回溯指向的是 libc,看起来跟你毫无关系——这个坑我在第 5 章单独展开。
3.2 首次适配 + 分裂:把大块切出刚好够用的一截
分配的主流程就三步:算需求、找块、切块。找块用首次适配,从头遍历空闲链表,第一块够大的就用,简单但有效。
static block *free_list = NULL; /* 单向空闲链表头 */ void *my_malloc(size_t n) { if (n == 0) n = 1; /* 0 字节也返回唯一地址 */ if (n > SIZE_MAX - HDR_SIZE - ALIGNMENT) { /* 溢出检查 */ errno = ENOMEM; return NULL; } size_t need = ALIGN_UP(n + HDR_SIZE); /* 用户长度 + 头,再对齐 */ if (need < MIN_BLOCK) need = MIN_BLOCK; block *prev = NULL, *cur = free_list; while (cur && BLK_SIZE(cur) < need) { /* 首次适配 */ prev = cur; cur = cur->next; } if (!cur) { /* 链表里没有合适的 */ cur = morecore(need); if (!cur) return NULL; prev = NULL; /* 新块不在链表里,无需摘链 */ } else { /* 从链表摘除 */ if (prev) prev->next = cur->next; else free_list = cur->next; } return split(cur, need); }n == 0返回一个可 free 的唯一地址,这是 C 标准允许的行为(返回 NULL 也合法,但很多代码不检查,容易出事)。溢出检查容易被忽略:n + HDR_SIZE如果超过SIZE_MAX会回绕成一个小数,然后你分配 48 字节却告诉用户「给你 1TB」,接下来就是经典的血泪现场。
链表摘除那一段有个易错点:cur来自链表时要摘,来自morecore时不能摘(它压根不在链表里)。我一开始用同一个分支处理,结果新块被「摘」了一次,free_list被写成了野指针,程序在第二次 malloc 时崩。
分裂是这一段的核心:
static void *split(block *b, size_t need) { size_t total = BLK_SIZE(b); if (total - need >= MIN_BLOCK) { /* 剩下的够独立成块,切开 */ block *rest = (block *)((char *)b + need); rest->size = total - need; /* bit0 = 0,空闲 */ rest->prev_size = need; rest->prev = NULL; rest->next = free_list; /* 头插 */ free_list = rest; b->size = need | (b->size & 0x2UL); /* 保留 mmap 标志位 */ block *after = (block *)((char *)b + need); after = (block *)((char *)after + BLK_SIZE(after)); if ((char *)after < heap_limit) after->prev_size = BLK_SIZE((block *)((char *)b + need)); } else { /* 剩的不够,整块给出去 */ b->size = total | (b->size & 0x2UL); } b->size |= 0x1UL; /* 标记在用 */ return (char *)b + HDR_SIZE; /* 用户指针跳过块头 */ }total - need >= MIN_BLOCK这个条件是分裂与否的分水岭。差值太小就整块给出去,虽然浪费一点,但避免了产生永远分配不出去的微型碎片。rest->prev_size = need是在维护物理相邻关系——后面那块被 free 的时候,要靠这个值往回找到rest做合并。
返回(char *)b + HDR_SIZE而不是b + 1,是因为指针算术的单位。用char *转换之后偏移量就是字节数,可读性也好,出问题时gdb里直接减去 32 就能看到块头。
3.3 free 的向前向后合并,以及 prev_size 的维护
free 是真正见功力的地方。单纯把块标记成空闲很简单,难的是把它和前后的空闲邻居粘起来,否则堆会碎成一地渣。
void my_free(void *ptr) { if (!ptr) return; /* free(NULL) 必须合法 */ block *b = (block *)((char *)ptr - HDR_SIZE); if (IS_MMAP(b)) { /* mmap 块直接还给内核 */ munmap(b, BLK_SIZE(b)); return; } if (!IS_USED(b)) { /* 重复 free,静默忽略 */ return; } b->size &= ~0x1UL; /* 先标记空闲 */ /* 向后合并:看物理相邻的下一块 */ block *nb = (block *)((char *)b + BLK_SIZE(b)); if ((char *)nb < heap_limit && !IS_USED(nb)) { /* 从链表里摘掉 nb —— 这里省了,第 4 章给出完整的 unlink */ b->size = BLK_SIZE(b) + BLK_SIZE(nb); nb = (block *)((char *)b + BLK_SIZE(b)); if ((char *)nb < heap_limit) nb->prev_size = BLK_SIZE(b); } /* 向前合并:靠 prev_size 找回物理相邻的前一块 */ if (b->prev_size) { block *pb = (block *)((char *)b - b->prev_size); if (!IS_USED(pb)) { pb->size = BLK_SIZE(pb) + BLK_SIZE(b); b = pb; nb = (block *)((char *)b + BLK_SIZE(b)); if ((char *)nb < heap_limit) nb->prev_size = BLK_SIZE(b); } } b->next = free_list; /* 头插空闲链表 */ free_list = b; }向后合并里的(char *)nb < heap_limit是边界判断,必须有。没有它,堆尾最后一块被 free 时会去读heap_limit之后的内存,那里可能是未映射页,直接段错误;也可能是别的映射区,读到随机的 in-use 位,导致合并出一个巨大的假块,下一次 malloc 就还给你一块根本不存在的内存。
prev_size的维护贯穿三个地方:morecore里新段首块置 0,split里给后半段写上need,合并之后更新再下一块的prev_size。任何一处漏掉,向前合并就会定位到错误地址。这是最容易出玄学 bug 的地方——程序可能在 free 之后跑几百行才崩,栈回溯完全指不到案发现场。
3.4 calloc 与 realloc 的边界处理
两个衍生接口看着简单,坑一点不少。
void *my_calloc(size_t nmemb, size_t size) { if (nmemb && size > SIZE_MAX / nmemb) { /* 乘法溢出检查 */ errno = ENOMEM; return NULL; } size_t total = nmemb * size; void *p = my_malloc(total); if (p) memset(p, 0, total); /* mmap 来的页本来就是 0,但 brk 不一定 */ return p; }calloc的溢出检查是必须的:nmemb * size回绕之后可能变成一个小数,你分配了 32 字节却告诉用户「这是 1000 × 1000 的数组」,接下来就是越界写。另一个细节是清零——从mmap拿的页内核保证全零,从sbrk拿的不保证(可能是刚被 free 的旧数据),所以统一memset最稳,不要为了省那点时间做特判。
void *my_realloc(void *ptr, size_t n) { if (!ptr) return my_malloc(n); if (n == 0) { my_free(ptr); return NULL; } block *b = (block *)((char *)ptr - HDR_SIZE); size_t have = BLK_SIZE(b) - HDR_SIZE; /* 当前可用字节 */ if (have >= n) return ptr; /* 够用,原地返回 */ /* 尝试向后吞并相邻空闲块,避免一次拷贝 */ block *nb = (block *)((char *)b + BLK_SIZE(b)); if ((char *)nb < heap_limit && !IS_USED(nb)) { size_t merged = BLK_SIZE(b) + BLK_SIZE(nb); if (merged - HDR_SIZE >= n) { b->size = merged | 0x1UL; return ptr; } } void *np = my_malloc(n); if (!np) return NULL; memcpy(np, ptr, have); /* 只拷贝旧的有效长度 */ my_free(ptr); return np; }memcpy的长度必须是have而不是n。n比have大的时候,从旧块读n字节就是越界读,在 ASan 下必报,在裸机上则悄悄读到隔壁块的数据。这个 bug 极其隐蔽,因为拷出来的多余字节用户往往不会用。
4. 分离空闲链表:把 O(n) 的首次适配压到 O(1)
第 3 章那版能跑,但性能很难看:每次 malloc 都从链表头线性扫,堆里块一多就是几千次指针跳转。真实负载里 90% 的申请集中在少数几个尺寸上(16、32、64、128),这种分布下线性扫描纯属浪费。解法是把空闲块按大小分桶,这就是分离空闲链表(segregated free list),也是主流分配器的共同骨架。
4.1 大小类怎么划:32 个桶覆盖到 4GB
分桶策略要在「桶太多浪费内存」和「桶太少退化成线性」之间取平衡。我用的方案是按 2 的幂分档,从 64 字节开始每翻一倍一个桶,32 个桶能覆盖到 4GB 以上,正好和size_t在 64 位下的常见使用范围对上。
#define NBINS 32 static block *bins[NBINS]; /* 返回适合 size 的桶号;size 已经包含块头 */ static size_t bin_index(size_t size) { if (size < 64) return 0; /* 48~63 全塞 0 号桶 */ size_t i = 1, s = 64; while (s < size && i < NBINS - 1) { s <<= 1; i++; } return i; }桶 0 装 48 到 63 字节的块,桶 1 装 64 到 127,桶 2 装 128 到 255,以此类推。这样任何一个need落进去,同桶内块的大小差最多一倍,桶内线性查找的次数通常不超过个位数。
这里有个取舍值得说清楚:按 2 的幂分桶,内部碎片最大可以到 50%(申请 65 字节,落进桶 1,桶内的块可能是 127 字节)。如果要压到 25% 以内,就得改成「小尺寸按 16 字节细分、大尺寸按 2 的幂」,也就是 glibc 那套 smallbin/largebin 混合方案,代码量翻倍。我的建议是先跑通幂次分桶,拿真实负载的碎片率数据说话,别一上来就抄复杂方案——大多数业务负载的碎片率在 15% 以下,不值得为那 10% 付出两倍的调试成本。
4.2 双向链表的插入与摘除,以及空桶的快速跳过
分桶之后,链表操作从「一条单向链表」变成「32 条双向链表」。双向的必要性在于摘除:单向链表摘一个块需要前驱指针,而查找是跨桶的,你没法在找到目标块的时候同时知道它的前驱。双向链表用prev指针把这个信息带在身上,摘除变成 O(1)。
static void bin_insert(block *b) { size_t i = bin_index(BLK_SIZE(b)); b->prev = NULL; b->next = bins[i]; if (bins[i]) bins[i]->prev = b; bins[i] = b; } static void bin_remove(block *b) { size_t i = bin_index(BLK_SIZE(b)); /* 注意:必须用当前尺寸算桶号 */ if (b->prev) b->prev->next = b->next; else bins[i] = b->next; if (b->next) b->next->prev = b->prev; b->prev = b->next = NULL; /* 清干净,防止悬挂指针 */ }bin_remove里用BLK_SIZE(b)现算桶号,而不是把桶号存在块头里。两种做法都行,现算省 4 个字节的块头空间,代价是每次摘除多做一次移位。存桶号的好处是合并之后尺寸变了不用更新——但合并之后本来就要重新插入,所以这个「好处」不成立。我选现算。
最后那行b->prev = b->next = NULL别省。调试期间我遇到过一次:块被摘除后没有清指针,后来因为逻辑错误被二次插入,prev指向了一个已经合并掉的块,形成了链表环。程序不崩,只是某次 malloc 死循环卡住,gdbattach 上去看到的是一串互相指向的地址,查了整整一个下午。
查找逻辑相应改成从目标桶往上扫:
static block *bin_find(size_t need) { for (size_t i = bin_index(need); i < NBINS; i++) { for (block *b = bins[i]; b; b = b->next) { if (BLK_SIZE(b) >= need) return b; /* 同桶内首次适配 */ } } return NULL; /* 所有桶都不够 */ }外层从bin_index(need)开始往上,因为比need小的桶里不可能有合适的块,跳过去就行。这一招把平均查找长度从「堆里所有空闲块」压到「同桶内的空闲块」,实测在一个每秒 20 万次分配的模拟负载上,malloc 的平均耗时从 340ns 降到 60ns 左右,提升约 5 倍。
4.3 fastbin 与小块缓存:不合并不代表做错了
到这里还有一个性能点没解决:free 的时候做前后合并是要花时间的,而真实程序里大量存在「free 完马上 malloc 同样大小」的模式(循环里反复创建销毁临时对象)。为这种模式做合并纯属白干。
常见的做法是加一层 fastbin:尺寸在 128 字节以下的小块 free 时不合并,直接挂进一个单独的快速链表,malloc 时优先从这个链表里拿。只有当 fastbin 长度超过阈值(比如 64 个)时才批量触发合并,把块真正还给常规桶。
#define FASTBIN_MAX 128 #define FASTBIN_DEPTH 64 static block *fastbin[8]; /* 按 16 字节步长分 8 档,覆盖 48~128 */ static size_t fastbin_cnt[8]; /* free 路径上的快速判断 */ static int try_fastbin(block *b) { size_t sz = BLK_SIZE(b); if (sz > FASTBIN_MAX) return 0; size_t i = (sz - MIN_BLOCK) / 16; /* 48→0, 64→1, 80→2 ... */ if (i >= 8 || fastbin_cnt[i] >= FASTBIN_DEPTH) return 0; b->next = fastbin[i]; /* fastbin 用单向就够,不合并 */ fastbin[i] = b; fastbin_cnt[i]++; return 1; }try_fastbin返回 1 表示这块被 fastbin 收下了,调用方直接 return,跳过所有合并逻辑。注意 fastbin 里的块不能参与合并——一旦你把相邻的两块合了,fastbin 里另一块指向的next就可能指向一块已经被吞掉的地址。所以 fastbin 里的块在真正回到主流程之前,必须保证它的物理邻居也没被合并。实现上通常是在 fastbin 满了往外倒的时候,一次性把整条链的块都做一遍合并,而不是零散地做。
这个设计带来的收益在小对象密集的场景里非常明显,但也引入了新的复杂度:fastbin 里的块处于「已释放但未合并」状态,块头的 in-use 位是 0,但物理邻居可能也是 0。如果你在第 3 章那版代码上直接加 fastbin,向前合并的逻辑会去读一个在 fastbin 里的前块,把它当普通空闲块合掉,然后 fastbin 链表里就留下一个悬空节点。这个 bug 在第 5 章会展开讲。
5. 避坑与排查:自己写 malloc 最常翻的五个车
分配器的 bug 有个共同特征:崩溃现场和案发现场隔着几百行代码。下面这五条是我在调试过程中真实踩过的,每条都给出可复现的现象、根因和具体改法。
5.1 现象:崩在 memcpy 的 movaps 指令上
程序在memcpy或strlen内部挂掉,gdb里看到movaps %xmm0,(%rax)这类指令,%rax的值是某地址加 8。原因基本可以锁定:my_malloc返回的地址不是 16 字节对齐。
根因有两处。一是HDR_SIZE不是 16 的倍数——如果你把块头从 32 字节改成 24 字节(去掉prev指针之类),返回地址就变成 8 字节对齐了。二是分裂时need没对齐,(char *)b + need算出来的后半段是奇数地址,后续所有块全部错位。
/* 自检:把这条断言放在 malloc 返回前,跑一次全量测试就暴露了 */ assert(((uintptr_t)ret % ALIGNMENT) == 0); assert(HDR_SIZE % ALIGNMENT == 0);解决方式是把对齐当成不变量来守:HDR_SIZE用_Static_assert(sizeof(block) % 16 == 0, "header must be 16-aligned")在编译期卡死,need一律走ALIGN_UP,返回前加一条 assert。这三道防线任何一道在,问题都到不了memcpy那一步。
5.2 现象:free 时报 invalid pointer,但指针看着没错
free(ptr)抛invalid pointer,把ptr打印出来看着完全正常,甚至ptr - 32处的块头size字段也像模像样。
根因通常是双重释放或者释放了非本分配器返回的指针。前者在链表版本里表现为同一块被插进空闲链表两次,形成自环;后者常见于程序里混用了strdup、getline、asprintf这类内部走系统 malloc 的函数——它们返回的指针和你的块格式不兼容,你按自己的块头去解析就全乱了。
/* 加一个 magic 字段,free 时校验,能把这类问题从"跑飞"变成"当场报错" */ #define BLOCK_MAGIC 0x4D59414C4C4F43ULL /* "MYALLOC" 的十六进制,随便编 */ /* 结构体里加一个 magic 字段,malloc 时写入,free 时校验 */ if (b->magic != BLOCK_MAGIC) { fprintf(stderr, "bad free: %p\n", ptr); abort(); }代价是块头多 8 字节,最小分配粒度从 48 涨到 64。调试版本打开,发布版本用宏关掉,这个后悔药值得留。另一条更彻底的路是接管所有分配入口,包括strdup、posix_memalign、aligned_alloc,以及 C++ 的operator new——漏掉任何一个,那条路径上返回的指针就是「非本分配器」的。
5.3 现象:多线程压测跑十几秒就段错误,单线程跑一小时没事
这是典型的数据竞争。空闲链表是全局共享的,两个线程同时执行「读bins[i]→ 修改 → 写回」就会丢更新,或者一个线程刚拿到b->next,另一个线程把b合并掉了,next指向已释放内存。
#include <pthread.h> static pthread_mutex_t heap_lock = PTHREAD_MUTEX_INITIALIZER; void *my_malloc(size_t n) { pthread_mutex_lock(&heap_lock); void *p = my_malloc_locked(n); pthread_mutex_unlock(&heap_lock); return p; }全局大锁是最省事的方案,正确性没问题,代价是多线程下所有分配串行化。真实系统里通常做成 per-thread arena:每个线程有自己的bins[],通过__thread变量持有,只有跨线程 free 时才需要加锁(用地址范围判断这块属于哪个 arena)。这一步的复杂度不低,我的建议是先上全局锁把正确性钉死,再用压测数据决定要不要做 arena——如果锁竞争带来的性能损失在可接受范围内,别为了账面数字引入一个更难调的分布式结构。
5.4 现象:长时间运行 RSS 只涨不降,free 了大量内存也没用
free只是把块标记成空闲挂回链表,物理内存一点没还。这是设计使然,不是 bug,但很多人第一次遇到会以为泄漏。
验证方法很直接:在压测程序里循环分配释放同一批大小,观察/proc/self/status里的VmRSS。如果稳定在某个值不再涨,说明是分配器缓存了,正常;如果一直线性上涨,那才是真泄漏。
要真正归还,得在 free 路径上加一段「堆顶收缩」:合并完成之后,如果被释放的块正好在堆顶,而且连续空闲长度超过阈值(比如 128KB),就调用brk把 break 往下退。
/* 简化示意:从堆顶向下回退连续空闲区 */ static void trim_top(void) { size_t reclaim = 0; char *top = heap_limit; while (top > heap_start) { block *b = (block *)(top - /* 需要找到上一块的头部,实践中靠遍历或额外索引 */ 0); (void)b; break; /* 真实实现需要能 O(1) 定位堆顶块,见下文 */ } if (reclaim) brk(heap_limit - reclaim); }这里的难点是 O(1) 定位堆顶块。简单做法是在分配器里维护一个last_block指针,每次morecore和split之后更新;稍复杂一点是在堆尾放一个哨兵块,永远标记为在用,收缩时从哨兵往回走。我用的是last_block方案,代价是每次分裂都要更新它,多一处容易漏的地方——这也是第 3 章那版代码刻意不做的原因,先把主流程跑通,再加这个优化。
5.5 现象:用 ASan 或 valgrind 测,一个错都报不出来
这条最坑。你兴冲冲地加-fsanitize=address,跑完显示无错误,然后放心上线。
根因是 ASan 会替换malloc/free符号。你在编译单元里定义my_malloc,但只要调用点写的是malloc,链接器解析到的仍然是 ASan 提供的版本。你测的是 ASan 的分配器,自己的代码一行没执行。
验证方式:在my_malloc里加一句write(2, "hits\n", 5),跑一遍测试,如果输出里一次都没有,说明根本没被调到。
正确的测法有三种。一是用-Wl,--wrap=malloc把调用重定向到__wrap_malloc,链接期生效,和 ASan 不冲突。二是把源码里的malloc全部宏替换成my_malloc,测试代码里专门写一套针对自己实现的断言。三是用LD_PRELOAD把整个.so挂上去,让动态链接器把符号解析到你的实现——这条路最接近真实使用场景,但代价是 ASan 就彻底失效了,两者只能二选一。
# 方式一:链接期重定向,编译测试程序时加 gcc -O2 test.c mymalloc.c -Wl,--wrap=malloc -Wl,--wrap=free -o test # 方式三:运行期替换(此时不能带 -fsanitize=address) gcc -O2 -fPIC -shared -o libmymalloc.so mymalloc.c LD_PRELOAD=./libmymalloc.so ./your_program我的习惯是两套并行:日常用--wrap配合 ASan 查越界和泄漏,上线前用LD_PRELOAD在真实负载上跑一轮,对比 RSS 和耗时。两套测出来的问题类型不一样,缺一套都会漏。
6. 挂到真实程序上验证:LD_PRELOAD 替换与一组对照指标
代码跑通单元测试只是及格线,真正能说明问题的是把它挂到一个有真实分配模式的程序上,看三组数字:RSS 峰值、分配耗时、碎片率。
# 编成动态库,注意必须 -fPIC,且要导出 C 符号避免名字修饰 gcc -O2 -fPIC -shared -o libmymalloc.so mymalloc.c -lpthread # 挂到目标程序上跑;建议先用小负载验证不崩 LD_PRELOAD=$PWD/libmymalloc.so ./workload --duration 60LD_PRELOAD的加载顺序决定了符号解析:预加载库里的malloc会覆盖 libc 的版本,但只在动态链接的调用点上生效。静态链接的程序、或者用了-Wl,-Bsymbolic的库,会绕过这一层。启动前先用LD_DEBUG=bindings LD_PRELOAD=... ./prog 2>&1 | grep malloc确认符号真的被绑到了你的库上,这一步能省掉后面大量「怎么没生效」的困惑。
三组指标怎么采:
| 指标 | 采集方式 | 关注点 |
|---|---|---|
| RSS 峰值 | /proc/self/status的VmHWM | 与系统 malloc 同负载对比,涨幅控制在 20% 以内算可接受 |
| 分配耗时 | clock_gettime(CLOCK_MONOTONIC)包住 100 万次 malloc/free | 分 16 / 64 / 512 / 4096 四档看,小档是分桶的试金石 |
| 碎片率 | 已分配字节 / 实际占用堆页数 | 长期运行负载下超过 30% 说明合并策略有问题 |
有个具体技巧值得单独说:把MMAP_THRESHOLD从 128KB 调到 16KB 再压测一轮。很多程序的中等对象(16KB~64KB)占分配次数的比例不高,但占字节数的比例很大,把它们赶到 mmap 通道上,free 时直接还页,RSS 峰值往往能掉 30% 以上。代价是系统调用变多,分配耗时可能涨 5%~10%。这个权衡没有标准答案,取决于你的程序是内存敏感还是延迟敏感——我在一个批处理任务里把阈值调到 8KB,RSS 从 900MB 降到 520MB,耗时只涨了 3%,非常划算;同样的参数换到延迟敏感的在线服务上就不合适。
最后留一个自查习惯:每次改动分配器核心逻辑(尤其是合并、分裂、链表操作这三处),我都会强制跑一遍「随机操作序列」测试——用固定种子生成 10 万次随机的 malloc/free/realloc,尺寸从 1 到 100KB 混合分布,每一步都校验所有空闲块的size和prev_size一致性。这个测试抓到过我 80% 的 bug,因为真实程序很难覆盖到那些罕见的尺寸组合和相邻关系。从那以后我每次改完 allocator 都强制走一遍这个随机序列,再配合 `LD_PRELOAD
本文还有配套的精品资源,点击获取