简介:一份面向《深入理解计算机系统》(CSAPP)malloc实验的完整代码包,适合正在学习内存分配器原理的计算机专业学生或系统程序员。压缩包内含实验所需的全部源文件与测试材料,共70个文件,以rep(trace文件)、c/h(源码与头文件)、pl(脚本)、o(目标文件)为主,配有Makefile与README,整体约704KB,结构清晰便于直接编译运行。目前已有367人学习下载。其中涵盖malloc改写核心环节,包括内存池、空闲块数据结构、碎片管理、内存对齐、分配策略与释放逻辑,并附带多个代表性trace测试文件,可用于验证首次适配、最佳适配等策略的性能差异。通过研读代码与运行测试,能够深入理解mm.c中隐式/显式空闲链表等经典实现,掌握内存分配器调试与优化方法,是完成CSAPP malloc lab或自学内存管理的实用参考资料。
1. CSAPP Malloc Lab 到底在考什么:从 trace 文件到动态内存分配器
CSAPP Malloc Lab 是《深入理解计算机系统》配套实验里最磨人的一个:没有标准答案,只有一份 driver 程序、一堆 trace 文件和一个限时跑分的现实。你要在malloc、free、realloc三个函数名下实现自己的动态内存分配器,最终分数由空间利用率和吞吐量按比例算出。最容易翻车的地方往往不是分配算法,而是块头、脚部、对齐这些细节先把你逼到段错误。这篇笔记面向正在跑 malloc lab 的学生,也适合想补底层内存管理视角的工程场景。我会先给出一条能跑通全部 trace 的最短路径,再用显式空闲链表和分离空闲链表把它推到拿分区间,最后把几个最经典的坑原样写出来。
2. 先写隐式空闲链表:把 mm_malloc 从第一个字节跑通
2.1 为什么隐式实现是 malloc lab 的起跑线,不是终点
常见做法是先实现最朴素的隐式空闲链表,因为它的代码量最少,逻辑最容易追踪。malloc lab 的评分程序会在第一阶段先用一批「缺内存」的 trace 卡你:只要你有一个字节越界,driver 直接报 segmentation fault,分数归零。我一般会让初版分配器先保证所有 trace 不挂,再谈优化分数。隐式链表的意思是:空闲块的标识只靠块头部的 size 字段最低一位来表示,块之间没有指针串联,分配时需要从头遍历整个堆,直到找到满足条件的空闲块。这个遍历成本在最坏情况下是 O(n),但对小 trace 足够用。
为什么不要在第一版就上显式空闲链表?因为显式链表要求你在空闲块内部写入 prev/next 指针,一旦最小块小于两个指针的大小,写指针就会覆盖用户数据区域,调试起来非常痛苦。先让隐式版本跑通,等于先把块布局、对齐、合并这些地基练熟,后面把隐式改成显式就是一个数据结构替换,风险和收益都更可控。
2.2 块结构的四要素:头部、脚部、载荷、填充
在 malloc lab 里,每个堆块被组织成四段:头部字、载荷区、填充区、脚部字。头部字记录块大小和已分配标志;脚部字在合并时用来快速判断物理相邻的前一个块是否空闲;载荷区紧跟在头部后,地址必须对齐;填充区夹在载荷和脚部之间,用来凑对齐。标准实现里头部和脚部各占 4 字节,最小块大小为 16 字节,这样载荷至少能放下 8 字节,同时满足 8 字节对齐。
这里有个关键点:块大小包括头部和脚部,所以mm_malloc(8)实际要申请 16 字节,而mm_malloc(0)在默认 driver 里应该返回 NULL。我见过很多人把 size 当成载荷大小直接写进头部,结果分配的块比需求小,后面越界写把堆搞坏。块大小应该用((size + 8) + 7) & ~7这种公式算出合法块大小,其中 8 是头脚字节数,7 是 8 字节对齐的掩码。这个公式也是 trace 文件里最常见的边界条件。
注意:块大小换算必须把头部和脚部都算进去,否则分配器会少给载荷空间,后续 writes 很容易翻车。
2.3 可运行的 mm_init 与 mm_malloc:第一版 C 代码
下面这段是隐式空闲链表的第一版实现,只保留了能跑 trace 的核心逻辑,省略了多线程内容,因为 malloc lab 默认按单线程评分。堆以序言块开始,序言块是一个已分配的 8 字节块,头块表示堆尾的结束标记。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include "memlib.h" #define WSIZE 4 // 字大小,头部/脚部各占 4 字节 #define DSIZE 8 // 双字大小,也是对齐单位 #define CHUNKSIZE (1 << 6) // 默认扩展堆的增量:64 字节 #define MAX(x, y) ((x) > (y) ? (x) : (y)) /* 打包 size 和 allocated 位 */ #define PACK(size, alloc) ((size) | (alloc)) /* 读 / 写地址 p 处的字,强制转为 unsigned int 指针 */ #define GET(p) (*(unsigned int *)(p)) #define PUT(p, val) (*(unsigned int *)(p) = (val)) /* 从头部或脚部拿大小,从头部拿已分配位 */ #define GET_SIZE(p) (GET(p) & ~0x7) #define GET_ALLOC(p) (GET(p) & 0x1) /* 给定块指针 bp,计算头部、脚部地址 */ #define HDRP(bp) ((char *)(bp) - WSIZE) #define FTRP(bp) ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE) /* 给定块指针 bp,计算下一个和上一个块的地址 */ #define NEXT_BLKP(bp) ((char *)(bp) + GET_SIZE(HDRP(bp))) #define PREV_BLKP(bp) ((char *)(bp) - GET_SIZE(((char *)(bp) - DSIZE))) static char *heap_listp; // 指向序言块 int mm_init(void) { /* 初始堆:序言块 + 头块,共 16 字节 */ if ((heap_listp = mem_sbrk(4 * WSIZE)) == (void *)-1) return -1; PUT(heap_listp, 0); // 对齐填充 PUT(heap_listp + WSIZE, PACK(DSIZE, 1)); // 序言块头部 PUT(heap_listp + DSIZE, PACK(DSIZE, 1)); // 序言块脚部 PUT(heap_listp + WSIZE + DSIZE, PACK(0, 1)); // 头块 heap_listp += DSIZE; return 0; }这段代码里的宏是 CSAPP 配套的经典写法。HDRP从载荷指针倒推 4 字节拿头部,FTRP用头部大小减 8 拿到脚部,NEXT_BLKP直接靠当前块大小跳到下一个块。初始化时mem_sbrk申请 16 字节,其中序言块只有头部和脚部,没有载荷;头块大小写 0、分配位写 1,遍历时遇到头块就停。heap_listp指向序言块载荷地址,也就是堆中第一个真实块的前 4 字节处。注意PACK(DSIZE, 1)的 size 只有 8,正好装下头脚两字。
再补上mm_malloc和extend_heap。extend_heap负责在尾块后新加块并调用合并,mm_malloc用遍历找第一个足够大的空闲块:
static void *extend_heap(size_t words) { char *bp; size_t size; size = (words % 2) ? (words + 1) * WSIZE : words * WSIZE; if ((long)(bp = mem_sbrk(size)) == -1) return NULL; PUT(HDRP(bp), PACK(size, 0)); // 新块头部 PUT(FTRP(bp), PACK(size, 0)); // 新块脚部 PUT(HDRP(NEXT_BLKP(bp)), PACK(0, 1)); // 新头块 return coalesce(bp); } void *mm_malloc(size_t size) { size_t asize; size_t extendsize; char *bp; if (size == 0) return NULL; /* 小于最小块则对齐到最小块大小 */ if (size <= DSIZE) asize = 2 * DSIZE; else asize = DSIZE * ((size + DSIZE + (DSIZE - 1)) / DSIZE); for (bp = heap_listp; GET_SIZE(HDRP(bp)) > 0; bp = NEXT_BLKP(bp)) { if (!GET_ALLOC(HDRP(bp)) && asize <= GET_SIZE(HDRP(bp))) { /* 找到空闲块;剩余空间够一个块就分裂,否则整块使用 */ if (asize + DSIZE <= GET_SIZE(HDRP(bp))) { PUT(HDRP(bp), PACK(GET_SIZE(HDRP(bp)) - asize, 0)); PUT(FTRP(bp), PACK(GET_SIZE(HDRP(bp)) - asize, 0)); bp = NEXT_BLKP(bp); PUT(HDRP(bp), PACK(asize, 1)); PUT(FTRP(bp), PACK(asize, 1)); return bp; } else { PUT(HDRP(bp), PACK(GET_SIZE(HDRP(bp)), 1)); PUT(FTRP(bp), PACK(GET_SIZE(HDRP(bp)), 1)); return bp; } } } extendsize = MAX(asize, CHUNKSIZE); if ((bp = extend_heap(extendsize / WSIZE)) == NULL) return NULL; /* extend_heap 返回合并后的块指针,再次尝试放入 */ if (asize <= GET_SIZE(HDRP(bp))) { if (asize + DSIZE <= GET_SIZE(HDRP(bp))) { PUT(HDRP(bp), PACK(GET_SIZE(HDRP(bp)) - asize, 0)); PUT(FTRP(bp), PACK(GET_SIZE(HDRP(bp)) - asize, 0)); bp = NEXT_BLKP(bp); } PUT(HDRP(bp), PACK(asize, 1)); PUT(FTRP(bp), PACK(asize, 1)); } return bp; }第一个循环是隐式链表的分配核心:从堆的开头块往尾头块走,用GET_ALLOC判断空闲,用asize <= GET_SIZE判断容量。这里我采用「有足够空间就整块给出去」的策略,不主动分裂,逻辑最简单,也最容易和 trace 对上。第二层分裂判断asize + DSIZE <= GET_SIZE表示当前空闲块在分出一块后,剩余部分还能当独立块用,否则直接整体分配,减少小碎片。
extend_heap的入参是字数量而不是字节数,words % 2的处理保证扩展量是偶数字,等于保证了 8 字节对齐。它最后把新块交给coalesce,这样如果新块能和前面或后面的空闲块合成,分配器不会制造新的孤立碎片。如果你在这一版直接跑./driver,分数可能在 60 分上下,但所有 trace 都能通过,这就属于「没挂但分不高」的起跑线状态。
2.4 释放与合并:四种情况一次处理干净
mm_free要做的不只是把已分配位置零,还必须调用合并逻辑,否则连续多次 malloc/free 后堆会膨胀成一片不可用的小块。合并分四种情况:当前块前后都分配、前分配后空闲、前空闲后分配、前后都空闲。代码里常用coalesce返回合并后块的载荷指针:
static void *coalesce(void *bp) { size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp))); size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp))); size_t size = GET_SIZE(HDRP(bp)); if (prev_alloc && next_alloc) { return bp; // 前后都忙,不用合并 } else if (prev_alloc && !next_alloc) { size += GET_SIZE(HDRP(NEXT_BLKP(bp))); PUT(HDRP(bp), PACK(size, 0)); PUT(FTRP(bp), PACK(size, 0)); } else if (!prev_alloc && next_alloc) { size += GET_SIZE(HDRP(PREV_BLKP(bp))); PUT(FTRP(bp), PACK(size, 0)); PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0)); bp = PREV_BLKP(bp); } else { size += GET_SIZE(HDRP(PREV_BLKP(bp))) + GET_SIZE(HDRP(NEXT_BLKP(bp))); PUT(HDRP(PREV_BLKP(bp)), PACK(size, 0)); PUT(FTRP(NEXT_BLKP(bp)), PACK(size, 0)); bp = PREV_BLKP(bp); } return bp; } void mm_free(void *ptr) { if (ptr == NULL) return; size_t size = GET_SIZE(HDRP(ptr)); PUT(HDRP(ptr), PACK(size, 0)); PUT(FTRP(ptr), PACK(size, 0)); coalesce(ptr); }这段合并代码最常见的坑在PREV_BLKP:它要读上一个块的脚部,而脚部地址由当前块头部倒推获得。堆的最开头是序言块,序言块的脚部紧跟着第一个真实块,所以PREV_BLKP(第一个块)正好落到序言块脚部,序言块alloc=1,不会越界。如果你把序言块写成 0 大小,这里就会读出一块不属于你的内存。参数上,CHUNKSIZE用 64 字节在小 trace 里没问题,但后面第二阶段如果跑大负载,建议改成1 << 12减少系统调用次数,这个会在第 4 章再展开。
到此,一个能跑通全部 trace 的隐式分配器就结束了。如果你想直接交这份,能拿到及格线;但距离高分差的不是一两步,而是把空闲链表从「遍历」改成「指针直连」,这是第三章的内容。
3. 从隐式到显式空闲链表:吞吐量上分的关键替换
3.1 隐式链表的短板:分配耗时随堆块数量线性增长
隐式链表的最大缺点是分配一个块要遍历整个堆,即使只扫空闲块也要逐个看头部字。当 trace 里连续 malloc 几万次后,堆里块数量达到几万个,第一次适配遍历的代价会直接拖垮吞吐量评分。malloc lab 的 driver 会限制整体执行时间,超时会被判为 0。显式空闲链表就是把空闲块用指针串成链表,分配时只在空闲链表中找,不用扫描已分配块。代价是每个空闲块必须腾出 8 字节来保存 prev/next 指针,这样最小块大小从 16 字节变为 24 字节,内部碎片上升,空间利用率会略降。
这个取舍在评分里的权重很清晰:空间利用率和吞吐量各占 50%,显式链表通常能把吞吐量从 60 分拉到 90 分,空间利用率掉 2-3 分,总收益远大于损失。我一般会在隐式版本所有 trace 跑通后,立刻做这个替换,而非直接上分离链表。分离链表虽然更好,但调参维度也更多,出了 bug 很难定位;显式链表是中间难度中最稳的一档。
3.2 空闲块内嵌链表指针:为什么最小块必须变大
显式链表有两种组织方式:地址顺序和 LIFO。LIFO 实现简单,每次释放把块插到链表头部,分配时从头部开始找;地址顺序需要在释放时按地址大小插入,保持链表有序。malloc lab 的 trace 里,LIFO 配合「后进先出」的分配模式通常表现很好,但是遇到顺序分配时会反复扫描整条链表。更稳妥的做法是实现地址顺序链表,因为它的局部性更好,合并也更容易判断相邻块。这里的示例按地址顺序:释放时寻找合适插入点,使链表按地址增序排列。
链表指针放在空闲块的载荷区域里。在 64 位环境下指针是 8 字节,两个指针占 16 字节,而头部和脚部占 8 字节,所以最小块大小必须至少 24 字节。很多初版实现把指针写进载荷区,但最小块仍然按 16 字节定义,当最小空闲块被分配后,上层 memset 会把指针区域覆写,导致链表指针损坏。这是显式链表最著名的翻车点。
注意:最小块大小的定义要放得下两个指针,否则显式链表会被上层数据打穿。
3.3 显式链表版 mm_free:插入链表与合并顺序
下面是显式链表中的关键代码。只给出新加的宏和mm_free/insert_free_block,因为mm_malloc的分配循环要从free_listp开始。
/* 空闲链表头指针 */ static void *free_listp; /* 取空闲块的 prev/next 指针 */ #define GET_PREV(bp) (*(void **)(bp)) #define GET_NEXT(bp) (*(void **)((char *)(bp) + DSIZE)) /* 把空闲块插入空闲链表(按地址升序) */ static void insert_free_block(void *bp) { void *cur = free_listp; void *prev = NULL; while (cur != NULL && bp > cur) { prev = cur; cur = GET_NEXT(cur); } if (prev == NULL) { GET_PREV(bp) = NULL; GET_NEXT(bp) = free_listp; if (free_listp != NULL) GET_PREV(free_listp) = bp; free_listp = bp; } else { GET_PREV(bp) = prev; GET_NEXT(bp) = cur; GET_NEXT(prev) = bp; if (cur != NULL) GET_PREV(cur) = bp; } }这里GET_PREV(bp)直接把载荷起始地址当成void **读写,而指针大小为 8 字节,正好占用载荷前 8 字节;GET_NEXT则跳过 8 字节再读写。insert_free_block通过比较地址大小bp > cur维持地址升序。为什么要按地址排序?因为按地址排序后,合并相邻块时可以直接把物理相邻的空闲块从链表中摘除,不必全链表搜索。如果 LIFO 插入,两个物理相邻的块可能离得很远,合并时虽然知道要摘除哪一块,但还是要在链表里遍历找它,复杂度反而变高。
mm_free的步骤如下:先清块头,然后调用insert_free_block,最后做合并。合并时如果前后有空闲块,必须先把相邻空闲块从链表中删除,再合并,再插入合并后的大块:
static void *coalesce(void *bp) { size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp))); size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp))); if (!prev_alloc) { bp = PREV_BLKP(bp); remove_free_block(bp); } if (!next_alloc) { remove_free_block(NEXT_BLKP(bp)); } size_t size = GET_SIZE(HDRP(bp)) + (prev_alloc ? 0 : GET_SIZE(HDRP(PREV_BLKP(bp)))) + (next_alloc ? 0 : GET_SIZE(HDRP(NEXT_BLKP(bp)))); PUT(HDRP(bp), PACK(size, 0)); PUT(FTRP(bp), PACK(size, 0)); insert_free_block(bp); return bp; }remove_free_block的常规写法是:
static void remove_free_block(void *bp) { void *prev = GET_PREV(bp); void *next = GET_NEXT(bp); if (prev != NULL) GET_NEXT(prev) = next; else free_listp = next; if (next != NULL) GET_PREV(next) = prev; }注意coalesce里先合并前块还是后块会影响PREV_BLKP的结果。顺序是先看前块,再看后块;前块空闲时,bp变成了前块的载荷地址,后续NEXT_BLKP(bp)仍然是当前堆上的物理后继,所以后块判断不受影响。把两个remove_free_block放在更新头部之前,是为了保证它们还能读到正确的相邻块地址;如果先改写头部,再往后走就会算出偏移错误的位置。
分配时也要同步修改:找到目标空闲块后,若不需要分裂,则直接置为已分配并从空闲链表删除;若需要分裂,把剩余部分作为新空闲块插入链表。这一步很容易漏,漏掉的后果是空闲链表里出现一个已分配块,后续在链表上GET_NEXT会读到用户数据当指针,段错误只是早晚问题。
3.4 首次适配、最佳适配与最少适配:选型参数对照
显式链表解决的是「不扫已分配块」,但仍要决定怎么选空闲块。三种常见策略和它们的分值表现如下表:
| 策略 | 分配方式 | 空间利用率表现 | 吞吐量表现 | 适用场景 |
|---|---|---|---|---|
| 首次适配 | 从链表头开始,选第一个够大的块 | 中上 | 中 | trace 中分配/释放交错多 |
| 最佳适配 | 遍历整个链表,选最小够用的块 | 高 | 低 | 块大小范围宽时省空间 |
| 最少适配 | 按空闲块大小维护多个桶 | 高 | 高 | 大 trace 高分首选 |
在显式链表阶段推荐首次适配,因为实现简单,遍历成本也比隐式低得多。最佳适配需要每次都全链表扫描,吞吐量直接掉到 70 分档,除非 trace 里分配尺寸集中在几个值附近。分离空闲链表(也就是最少适配)会把空间和吞吐量都拉高,但它需要维护多个链表头,插入和删除逻辑翻三倍,这是第四章的内容。给个参数建议:显式链表配首次适配在标准 trace 集里通常能到 80-85 分,而隐式配最佳适配大约在 70-75 分。核心思路是「用一点内部碎片换查询速度」,这个交换在 malloc lab 的评分模型里几乎总是划算的。
4. 分离空闲链表与 realloc 优化:把 85 分推到 95 分的组合拳
4.1 分离空闲链表:分桶带来的分配复杂度下降
分离空闲链表的核心是按块大小分桶,比如按 2 的幂次分成 16-31、32-63、64-127、128-255 等若干类。分配时先算出请求大小属于哪个桶,只在对应桶里找;找不到再往更大的桶里一路找。这样每个桶里的候选块数量远小于全局空闲块数,吞吐量能再上一个台阶。分桶数量不是越多越好:桶太多,小桶里空闲块少,分配经常需要向上搜索多个桶,而且每个桶的插入删除都在链表操作上多了一层。
我一般用 8-10 个桶,覆盖从 16 字节到 4096 字节以上的范围。桶边界设成 2 的幂比较方便,int class = 0; while (size > 1 << (class + 4)) class++;一条语句就能算出来。注意最小桶从 16 字节起步,但显式链表的最小块是 24 字节,16 这个桶实际上承载 24-31 字节的块,也就是说最小块大小必须随着桶定义同步调整。如果最小块还是 16,载荷区的 prev/next 指针就会和用户数据打架,第二章说的翻车点会在分桶后再次出现。
4.2 分裂阈值:别为了零头制造不可用的碎块
分配块时如果空闲块远大于请求,一般会分裂。但分裂不是无条件做的:当剩余部分不足最小块大小时,分裂会产生一个谁都放不进去的「不可用块」,反而降低空间利用率。常见做法是设置阈值DSIZE + MINBLOCK,剩余大小大于阈值才分裂。在显式链表下最小块是 24 字节,阈值至少要 32 字节,否则剩下 24 字节虽然能当空闲块,但它的载荷区只有 8 字节,连下一次 malloc(8) 都接不住,最终还是碎片。这里有个值得调参的空间:把阈值从 32 提到 40,小块分配会多用一点空间,但大块分裂次数变少,分配器的元数据操作减少,反而带来吞吐量提升。分值测试里这种 8 字节的取舍经常能改变 1-2 分。
另一个影响分配器的参数是堆扩展粒度。extend_heap的CHUNKSIZE初始 64 字节太小,大批量分配时每次都要 sbrk 调内核,系统调用开销直接把吞吐量打没。建议把它调到1 << 12(4096 字节)或1 << 16,具体看 trace 峰值块数。堆扩展不是按请求大小逐个扩,而是成块扩展,让内核调用次数从几万次降到几十次。代价是每次扩展后堆尾部可能留下大块空闲,如果分配器不能复用它,空间利用率会掉;但如果扩展后立刻被后续 malloc 消费掉,这个代价几乎为零。driver 的评分里,这个参数对吞吐量分数影响非常明显,属于性价比最高的调参动作。
4.3 realloc:能原地扩展就别搬数据
realloc 的默认实现是「分配新块、拷贝旧数据、释放旧块」,一条到位但浪费。优化思路是:如果旧块的物理后继是空闲块,且合并后的总大小够用,就直接扩展旧块,不需要搬运数据。实现里要处理三种情况:
- 后继空闲且合并后足够:直接把旧块和后继合并,必要时再分裂出剩余部分。
- 后继空闲但合并后不够:拷贝数据到新块,释放旧块,再把旧块和后继合并。
- 后继已分配:只能走全新分配路径。
判断是否够用的核心代码是这样:
void *mm_realloc(void *ptr, size_t size) { if (ptr == NULL) return mm_malloc(size); if (size == 0) { mm_free(ptr); return NULL; } size_t old_size = GET_SIZE(HDRP(ptr)); size_t new_size; if (size <= DSIZE) new_size = 2 * DSIZE; else new_size = DSIZE * ((size + DSIZE + (DSIZE - 1)) / DSIZE); if (new_size <= old_size) { return ptr; // 新大小不超旧大小,直接复用 } void *next = NEXT_BLKP(ptr); size_t next_size = GET_SIZE(HDRP(next)); int next_free = !GET_ALLOC(HDRP(next)); if (next_free && old_size + next_size >= new_size) { remove_free_block(next); size_t combined = old_size + next_size; if (combined - new_size >= DSIZE + MINBLOCK) { // 分裂出剩余空闲块 PUT(HDRP(ptr), PACK(new_size, 1)); PUT(FTRP(ptr), PACK(new_size, 1)); void *rest = NEXT_BLKP(ptr); PUT(HDRP(rest), PACK(combined - new_size, 0)); PUT(FTRP(rest), PACK(combined - new_size, 0)); insert_free_block(rest); } else { PUT(HDRP(ptr), PACK(combined, 1)); PUT(FTRP(ptr), PACK(combined, 1)); } return ptr; } void *new_ptr = mm_malloc(size); if (new_ptr == NULL) return NULL; memcpy(new_ptr, ptr, old_size - DSIZE); mm_free(ptr); return new_ptr; }这段代码第一眼很啰嗦,但核心是两次大小换算完全一致。old_size是包含头部脚部的旧块总大小,memcpy拷贝的字节数只能是旧载荷长度,也就是old_size - DSIZE,否则会把旧块的脚部也盖到新块载荷里,产生一个「脏字节」,valgrind 会在后续操作上报 invalid write。分离链表版本的 realloc 额外要小心:当后继空闲但大小不够时,你仍然应该把旧块和后继合并成大块再释放,否则会留下两个分离的小空闲块,而它们本来可以合并成一个更大的区域,影响后续大分配。
4.4 让分数可复现:参数表与验证命令
做完了上述优化后,把参数统一成一表格,便于每次跑 driver 前后对照:
| 参数 | 隐式初版 | 显式推荐值 | 分离链表推荐值 |
|---|---|---|---|
| 对齐宽度 | 8 字节 | 8 字节 | 8 字节 |
| 最小块大小 | 16 字节 | 24 字节 | 24 字节 |
| 空闲链表结构 | 无 | 地址升序显式链表 | 8-10 个桶 |
| 分配策略 | 首次适配 | 首次适配 | 首次适配/最佳适配 |
| CHUNKSIZE | 64 | 4096 | 4096 |
| 分裂阈值 | 无条件分裂 | 32 字节 | 32-40 字节 |
每次修改参数后用同一套 trace 跑分对比,不要只在最后跑一次。推荐写法是保存一份好的 base 版本,然后只改一个变量逐次验证,因为 malloc lab 的 score 波动受 trace 顺序影响很大,多个参数一起改无法定位是谁把分数拉上来的。驱动命令一般裸跑./driver会跑全部 trace,想单跑用-f traces/xxx.rep指定。把 stdout 重定向到文件,比较前后两版的total points数字,而不是肉眼看 Log。
提示:每个参数改动后单独跑分对比,避免几个变量一起改导致分数波动无法归因。
5. 避坑与排查:malloc lab 最常见的五个翻车现场
5.1 段错误第一现场:先怀疑头部地址算错了 4 字节
现象:跑任何 trace 都直接 segmentation fault,甚至mm_init之后第一次malloc就挂。
原因:HDRP(bp)写成bp - DSIZE,或者FTRP在合并时对边界块算出了堆外地址。头部只占 4 字节,但 64 位环境下指针是 8 字节,char *运算的单位是字节,unsigned int读的是 4 字节,混杂在一起最容易错位。
解决:把宏打印成地址,或者在mm_malloc入口用断言(size_t)bp % 8 == 0先筛一轮。我用过一个笨办法:每个宏单独加一个标记位,在 driver 只跑了 3 个 trace 时开启 debug 打印,把每次HDRP读出来的 size 值和当前块地址打印出来,对比 trace 文件第几行 malloc 出的地址,十次中有八次能立刻定位到是哪个宏写错。段错误往往不是一行的错,而是几个宏的大小算错累加出来的,所以不要只盯mm_malloc主体,先从宏定义复查。
5.2 空闲链表的 next 指针被上层数据覆盖
现象:free 完再 malloc 后,链表循环里读到莫名奇妙的地址,debug 显示某个空闲块载荷区的指针变成 0x41414141。
原因:这个空闲块的最小块大小定义得太小。显式链表要求最小块至少放得下两个 8 字节指针加头脚,即 24 字节;如果还沿用隐式版的 16 字节,空闲块内嵌的 next 指针会落在用户载荷区里,用户memset一写就把链子打坏。
解决:全局搜索DSIZE*2或MINBLOCK的定义,统一改成24,并且同步修改mm_malloc的对齐换算,确保任何请求大小向上取整后不小于 24。release 版建议在insert_free_block里加一个防御判断:如果GET_ALLOC(HDRP(bp))为 1 就不插入,能从机制上避免把已分配块挂进空闲链表。这类 bug 在小 trace 时很难复现,只有跑到 1000 次分配以上才炸,所以第一次跑全部 trace 时不要跳过小 trace,直接冲大的,能更快暴露。
5.3 相邻块合并后剩下一个孤立空闲块,脚部没更新
现象:一次性分配多个大块后,空间利用率莫名只剩 20%,heap 末尾堆了一大片已标记为空闲但实际不可用的小块。
原因:合并时只更新了新合并块的头部,没更新脚部,或者反过来。这样物理上相邻的空闲块在遍历时中间隔着一个已分配标志,无法再次合并,碎片越积越多。另外在显式链表合并中,如果先删除前块再删除后块,删除后块的代码用了GET_NEXT访问一个已经被合并的块,也会导致链表断裂。
解决:每次PUT(HDRP(bp), PACK(size, 0))之后立刻对应执行PUT(FTRP(bp), PACK(size, 0)),二者缺一不可。显式合并中还要保证remove_free_block的参数是物理相邻的原始块指针,而不是合并后的bp。为了验证脚部正确,可以开着extra验证模式遍历堆,检查每个块的 size 是否等于下一个块头部减当前块头的距离;这个检查在 malloc lab 的 helper 里通常能找到,没有就自己写 20 行,非常值得。
5.4 用了分离链表后分数不升反降
现象:显式链表能拿 85 分,改成分离桶后反而掉到 75 分,主要是空间利用率暴跌。
原因:分桶本身没有错,但桶边界和块大小换算之间不一致。比如桶按 8 字节对齐划分,而实际块大小按size + 8再对齐到 16,两个边界错位会导致大量块被分到过大的桶,内部碎片上升。另外,分离链表的分配器需要处理向上搜索多个桶;如果找不到时顺序遍历后续所有桶加全局搜索,等于没省时间。
解决:先检查桶索引函数和块大小对齐函数是否使用同一个DSIZE和MINBLOCK。推荐给每个桶打印空闲块数量和总字节数,对比 trace 的峰值,能看出哪个桶负载过重。桶数量先用 8 固定,等分数稳定再试 16;改动桶数量时同步调整CHUNKSIZE,因为堆扩展粒度影响桶内块分布,两个参数需要联合调优。这块确实有点玄学,但我的经验是:分桶带来的吞吐量收益在 trace 很大时明显,在 1000 次以下的小 trace 里反而因额外开销拖慢,所以最好先跑全部 trace 再决定是否保留分离结构。
5.5 realloc 之后 valgrind 报 invalid write,但 printf 又看不出问题
现象:程序能跑通,但用 valgrind 查出一堆 invalid write,报错位置集中在memcpy附近。
原因:memcpy(new_ptr, ptr, old_size - DSIZE)里如果旧块的实际载荷小于old_size - DSIZE,就会把旧块的脚部当成数据拷出去。常见于分配时没做尾部填充对齐,而是直接按size写头部,导致old_size比真实分配大 8 字节。
解决:统一在mm_malloc入口把size对齐成asize,然后把asize - DSIZE作为载荷长度,这样旧块载荷长度永远是块大小减 8,memcpy的第三个参数就用这个值。release 版还可以再加一个GET_ALLOC(FTRP(ptr))断言,确认旧块仍处于已分配状态。valgrind 的报错行号往往偏移几行,追的时候别只看报错那行,把附近三行的内存读写都检查一遍,尤其是GET_SIZE读到的内容是否还是有效堆块。
6. 把 trace 当回归测试:最后一个能被分数看见的技巧
6.1 用批量跑分脚本代替肉眼 diff
我最后悔药的做法是:把 driver 的所有 trace 跑一遍,然后把每个 trace 的 points 存成文本,用 diff 对比两次修改。命令大约是这样:
./driver > before.txt 2>&1 # 改完代码重新编译后再跑一次 ./driver > after.txt 2>&1 diff before.txt after.txt | grep "trace" | head -30这样做有两个好处:第一,分数变化能被精确定位到具体 trace;第二,能发现某些改动 A trace 涨了 2 分、B trace 掉 3 分,从而决定要不要保留这个改动。malloc lab 的驱动脚本默认会把每个 trace 的分数打出来,只看总分很容易错过那些「被平均」掉的掉分点。
另一个值得养成的习惯:如果你在自己工程里复用了这套分配器,入口函数不要和系统malloc同名。你完全可以把mm_malloc包一层命名成l_malloc,这样既避免符号冲突,也方便日后替换成其他本地内存分配器。很多作业包里会出现l_malloc这样的符号,它本质上就是「本地 malloc」,不是另一个算法,只是命名隔离。
6.2 我最后想告诉你的调参习惯
反复调优后我发现,真正拉开分数的不是算法的华丽程度,而是把CHUNKSIZE、分裂阈值和最小块大小这三个参数稳定下来,再做结构选型。很多同学上来就写分离链表,结果一晚上都在调段错误;我后来养成的习惯是先隐式跑通、显式拿分、分离链表冲刺,并且每次只改一个变量。最后跑分的时候,我习惯把 debug 输出全关掉,重新make clean && make,因为编译器优化级别和未定义行为会影响最终分数。希望这些血泪经验能让你少走几遍我走过的弯路,希望帮到你。
本文还有配套的精品资源,点击获取