外观
动态内存分配:分裂、合并与不变量
分配器要把一片连续存储拆成大小不同、生命周期交错的对象。难点不在调用一次 malloc,而在任意合法申请释放序列之后,仍能保持块边界、对齐、链表和用户数据一致。Malloc Lab 的价值,就是让这些抽象变成可以被错误破坏的字节布局。
本仓库的 arena 模型
allocator.c 启动时向系统申请 4096 字节,后续所有块都从这片区域划分,不再增长。每块前有 header,保存载荷容量、空闲状态、物理前后邻居。链表包含所有块,寻找空闲块时线性扫描,因此属于隐式空闲链表;它不是仅链接空闲块的显式 free list。
text
arena 起点
[header A][payload A][header B][payload B][header C][payload C]
↔ ↔ ↔
prev / next 按物理地址顺序连接用户只得到 payload 地址。arena_free 从该地址减去对齐后的 header 大小,找到元数据。这里要求传入 NULL 或当前有效的本分配器返回值;它不负责防御任意非法指针、全部重复释放或被破坏的元数据,不能作为系统 malloc 替代品。
对齐与溢出必须先处理
对齐单位为 _Alignof(max_align_t);header 大小和载荷容量都向上取整到该单位。若对齐 A=8,请求 13 字节,实际载荷容量为 16。内部碎片为至少 3 字节,再加 header 开销。对齐不是为了好看,而是确保返回地址适合相应 C 对象。
向上取整表达式 (n + A - 1) / A * A 可能先在加法处溢出,因此先检查 n > SIZE_MAX - (A - 1)。同样,测试是否足够分裂时用 capacity - size >= HEADER_SIZE + ALIGNMENT,其中已确认 capacity≥size,避免盲目加大数。
first-fit 与分裂
分配从链表头开始,找到第一个空闲且足够大的块。如果剩余容量能容纳一个 header 和最小对齐载荷,就把剩余部分变成新空闲块;否则整个块交给请求者。
以 A=8、header=32 为本机示例,空闲块载荷 80,请求 16,剩余 64,可拆成新 header 32 加新载荷 32。分裂后必须同时更新新块 prev、next,原后继的 prev,以及旧块的 capacity 和 next。漏改一条反向指针,往往直到后续释放才暴露。
本仓库选择 first-fit 是为了简洁,不代表它普遍最优。best-fit 可能减少某些浪费却产生很多小碎片;分离适配用大小类别降低搜索成本但增加元数据复杂度。比较算法前,需要先保证完全相同的正确性约束。
合并为何要处理两侧
释放将块标为空闲,先与右侧空闲块合并,再尝试与左侧合并。合并时回收中间 header 的空间,因此容量相加还要加一个 HEADER_SIZE。最终不允许两个物理相邻的空闲块继续并存,这是立即合并策略的不变量。
flowchart TD
A[free 当前有效块] --> B[标记为空闲]
B --> C{右邻居空闲?}
C -- 是 --> D[吞并右块与其 header]
C -- 否 --> E{左邻居空闲?}
D --> E
E -- 是 --> F[由左块吞并当前块]
E -- 否 --> G[检查整个 arena]
F --> G
查看流程图文本
flowchart TD
A[free 当前有效块] --> B[标记为空闲]
B --> C{右邻居空闲?}
C -- 是 --> D[吞并右块与其 header]
C -- 否 --> E{左邻居空闲?}
D --> E
E -- 是 --> F[由左块吞并当前块]
E -- 否 --> G[检查整个 arena]
F --> G
注意“空闲链表相邻”与“物理内存相邻”不是同一个概念。在本实现中链表按物理顺序,所以 next 正好是物理邻居;若升级成独立显式空闲表,这个假设就不成立,必须另用边界标记或物理块信息确认。
堆检查器比猜测更有用
check_arena 验证:每块恰好从前一块结束处开始;prev/next 对称;header 和载荷满足对齐;容量为正且对齐;块没有越过区域;没有相邻空闲块;遍历结束恰好达到 arena 尾部。每次修改后运行它,把错误尽量定位在产生时,而不是几百次操作后崩溃时。
测试还给活跃 payload 填入不同字节模式,持续验证所有在用块内容,防止元数据检查通过但两个活跃块重叠。4000 步固定伪随机序列可重复制造碎片;可重复性让首次失败点能稳定重现。
sh
make -C labs/csapp allocator
./labs/csapp/allocator
make -C labs/csapp sanitize本机输出 alignment=8, header=32,其他 ABI 可以不同。预期消息包含 split/coalesce/payload checks passed。算法正确性不应依赖你记住这个 header 数字,而应使用程序实际计算的值。
c
/* Fixed arena with an implicit free list. Block headers follow physical order.
* Valid pointers from this allocator only; no realloc, threading, or heap growth. */
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct Block {
size_t capacity;
bool available;
struct Block *prev, *next;
} Block;
typedef struct { unsigned char *memory; size_t bytes; Block *first; } Arena;
#define ALIGNMENT _Alignof(max_align_t)
#define HEADER_SIZE ((sizeof(Block) + ALIGNMENT - 1) / ALIGNMENT * ALIGNMENT)
static bool arena_init(Arena *a, size_t bytes) {
bytes = bytes / ALIGNMENT * ALIGNMENT;
if (bytes < HEADER_SIZE + ALIGNMENT) return false;
a->memory = malloc(bytes);
if (!a->memory) return false;
a->bytes = bytes;
a->first = (Block *)(void *)a->memory;
*a->first = (Block){bytes - HEADER_SIZE, true, NULL, NULL};
return true;
}
static void check_arena(const Arena *a) {
const unsigned char *cursor = a->memory;
const Block *previous = NULL;
for (const Block *b = a->first; b; b = b->next) {
assert((const unsigned char *)(const void *)b == cursor);
assert(b->prev == previous);
assert((uintptr_t)b % ALIGNMENT == 0);
assert(b->capacity >= ALIGNMENT && b->capacity % ALIGNMENT == 0);
assert(cursor + HEADER_SIZE + b->capacity <= a->memory + a->bytes);
assert(!previous || !(previous->available && b->available));
cursor += HEADER_SIZE + b->capacity;
previous = b;
}
assert(cursor == a->memory + a->bytes);
}
static void *arena_alloc(Arena *a, size_t requested) {
if (!requested || requested > SIZE_MAX - (ALIGNMENT - 1)) return NULL;
size_t size = (requested + ALIGNMENT - 1) / ALIGNMENT * ALIGNMENT;
for (Block *b = a->first; b; b = b->next) {
if (!b->available || b->capacity < size) continue;
if (b->capacity - size >= HEADER_SIZE + ALIGNMENT) {
Block *rest = (Block *)(void *)((unsigned char *)(void *)b + HEADER_SIZE + size);
*rest = (Block){b->capacity - size - HEADER_SIZE, true, b, b->next};
if (rest->next) rest->next->prev = rest;
b->next = rest;
b->capacity = size;
}
b->available = false;
check_arena(a);
return (unsigned char *)(void *)b + HEADER_SIZE;
}
return NULL;
}
static void merge_next(Block *b) {
Block *next = b->next;
assert(next && b->available && next->available);
b->capacity += HEADER_SIZE + next->capacity;
b->next = next->next;
if (b->next) b->next->prev = b;
}
static void arena_free(Arena *a, void *pointer) {
if (!pointer) return;
Block *b = (Block *)(void *)((unsigned char *)pointer - HEADER_SIZE);
assert(!b->available); /* Some double frees; stale merged headers are not supported. */
b->available = true;
if (b->next && b->next->available) merge_next(b);
if (b->prev && b->prev->available) merge_next(b->prev);
check_arena(a);
}
int main(void) {
Arena arena;
assert(arena_init(&arena, 4096));
assert(arena_alloc(&arena, 0) == NULL);
assert(arena_alloc(&arena, SIZE_MAX) == NULL);
unsigned char *a = arena_alloc(&arena, 48);
unsigned char *b = arena_alloc(&arena, 80);
unsigned char *c = arena_alloc(&arena, 48);
assert(a && b && c);
memset(a, 0xa1, 48); memset(b, 0xb2, 80); memset(c, 0xc3, 48);
arena_free(&arena, b);
unsigned char *reuse = arena_alloc(&arena, 16);
assert(reuse == b); /* first-fit reuses the hole, potentially splitting it */
memset(reuse, 0xdd, 16);
for (size_t i = 0; i < 48; ++i) assert(a[i] == 0xa1 && c[i] == 0xc3);
arena_free(&arena, reuse);
arena_free(&arena, a);
arena_free(&arena, c); /* coalesce with both sides */
assert(arena.first->available && arena.first->next == NULL);
assert(arena.first->capacity == arena.bytes - HEADER_SIZE);
void *whole = arena_alloc(&arena, arena.bytes - HEADER_SIZE);
assert(whole && arena_alloc(&arena, 1) == NULL);
arena_free(&arena, whole);
/* Deterministic fragmentation workload; inspect every live payload. */
unsigned char *slots[32] = {0};
size_t sizes[32] = {0};
uint32_t rng = 7;
for (unsigned step = 0; step < 4000; ++step) {
rng = rng * UINT32_C(1664525) + UINT32_C(1013904223);
unsigned slot = (rng >> 16) % 32;
if (slots[slot]) {
for (size_t i = 0; i < sizes[slot]; ++i) assert(slots[slot][i] == (unsigned char)slot);
arena_free(&arena, slots[slot]); slots[slot] = NULL;
} else {
sizes[slot] = (rng >> 24) + 1;
slots[slot] = arena_alloc(&arena, sizes[slot]);
if (slots[slot]) memset(slots[slot], (int)slot, sizes[slot]);
}
check_arena(&arena);
for (unsigned j = 0; j < 32; ++j)
if (slots[j]) for (size_t i = 0; i < sizes[j]; ++i) assert(slots[j][i] == (unsigned char)j);
}
for (unsigned j = 0; j < 32; ++j) arena_free(&arena, slots[j]);
assert(arena.first->next == NULL && arena.first->capacity == arena.bytes - HEADER_SIZE);
printf("allocator: split/coalesce/payload checks passed (alignment=%zu, header=%zu)\n",
(size_t)ALIGNMENT, (size_t)HEADER_SIZE);
free(arena.memory);
return 0;
}怎样推进到 Malloc Lab
官方实验要求实现它规定的分配接口,并在给定模拟堆与 trace driver 下评价正确性和性能。Malloc Lab 说明
建议先做最简单可靠版本:堆布局与对齐 → 顺序分配 → free 标记 → 分裂 → 四类相邻合并 → realloc。实现 realloc 时,先处理 NULL 与零大小的接口约定,再尝试原地缩小或吞并右侧空闲空间;不成功时申请新块、复制 min(旧对象大小, 新请求大小) 字节、释放旧块。分配失败必须保留旧对象有效,不能先 free 再尝试申请。
之后再引入显式空闲链表:空闲载荷里存 prev_free/next_free,已分配块不需要这两个指针。分裂或合并前把相关块从空闲表移除,布局更新完成后插入新块。堆检查器增加“所有空闲块恰好在空闲表出现一次”和“表内没有已分配块”两条。
吞吐量与空间利用率有张力;先用短 trace 定位正确性,再用完整 trace 比较策略。一次测量应保存实现版本、编译选项、trace 和指标,避免只引用一个脱离环境的“很快”。
自测:总空闲空间 200 字节,为什么 120 字节请求仍可能失败?
如果空闲空间被活跃对象隔成多个不相邻小块,没有一块达到 120 字节加所需开销,就会出现外部碎片。合并只能合并物理相邻的空闲块,不能移动仍被用户指针引用的活跃对象。
自测:ASan 没报错,为什么还需要堆检查器?
ASan 通常看到的是一次合法的 4096 字节系统分配,并不知道 arena 内你划分的小块所有权。跨内部块的错误可能仍位于系统分配范围内。元数据不变量与载荷模式检查补上这一层语义。
下一章:并发与同步。