☰
手写malloc教学实现:从sbrk到空闲链表的内存分配器原理
2026/10/28 11:18:43 网站建设 项目流程

简介:本资源是一份面向C语言进阶学习者与系统编程初学者的内存管理实践项目,聚焦于深入理解动态内存分配机制,通过手写简化版malloc函数(my_malloc)掌握堆管理、空闲块查找、内存碎片处理等底层原理。资源包含3个核心文件:2个C源文件(mymalloc.c实现分配/释放逻辑,test.c提供完整测试用例)和1个头文件(mymalloc.h定义接口与数据结构),总大小仅3KB,轻量易读,适合逐行调试与教学演示。已有1831人学习下载,反映出开发者对内存管理底层实践的持续关注。读者可直接编译运行,观察内存池初始化、首次适配策略、块分割与合并等关键行为,获得从理论到代码的闭环认知;同时代码结构清晰、注释充分,便于拓展支持对齐、多线程或不同分配策略,是理解glibc malloc设计思想的优质入门范例。

1. 自己动手写malloc函数:不是为了替代glibc,而是为了看懂内存分配器怎么在黑匣子里呼吸

你有没有试过,在一个嵌入式裸机环境里,printf能用,malloc却直接链接失败?或者在调试一个内存泄漏时,发现valgrind报的地址根本对不上源码行号?又或者,某次线上服务突然卡住 3 秒,pstack显示所有线程都堵在__libc_malloc里——但你连malloc内部到底维护了几条空闲链表、什么时候会触发mmap、brk和sbrk的边界怎么协商,都只能靠猜?这项目就是为这些时刻准备的:它不提供开箱即用的高性能分配器,而是一份可单步调试、可打断点、可修改策略、可注入故障的malloc教学级实现。核心就一个目标:把malloc/free/realloc/calloc四个接口背后那层“看不见的系统契约”,变成你能亲手捏扁搓圆的 C 代码。适合刚学完《深入理解计算机系统》第9章、正在啃 glibc 源码却卡在arena.c里的开发者;也适合需要在资源受限设备(比如无MMU的MCU)上定制轻量分配策略的嵌入式工程师。它不追求吞吐压测跑分,但每行代码都经得起gdb单步和nm符号检查。


2. 架构设计与目录结构:从一块原始内存到四套API的完整映射

这个项目不是“重写 libc”,而是构建一个最小可行、自包含、零外部依赖的堆管理器。它把整个堆空间视为一块连续的、由sbrk(0)获取起始地址的原始字节数组,然后在其上建立两级管理结构:元数据区(metadata)和用户数据区(user data)。所有逻辑封装在单个heap.c文件中,头文件heap.h对外暴露标准 malloc 接口,同时提供内部调试钩子(如heap_dump())。没有 Makefile 依赖链,没有 configure 脚本,gcc -g heap.c test.c -o test即可编译运行。这种极简结构不是偷懒,而是为了让你一眼看清:当malloc(1024)返回地址时,那个地址前面 16 字节存的是什么?free()是如何根据用户指针反向定位到这块内存的 size 字段的?下面拆解它的骨架。

2.1 内存布局:brk 边界、块头、用户区、对齐约束

整个堆从sbrk(0)开始向上增长。每次malloc请求,分配器先检查空闲链表是否有合适块;若无,则调用sbrk()向系统申请新页(通常 4KB),并将新页首部划出一个chunk_t结构体作为元数据,剩余部分作为用户可用空间。关键约束有三:

  • 块头大小固定为 16 字节:含size_t size(总块长,含头)、int is_free(是否空闲)、struct chunk_t *next/*prev(双向链表指针);
  • 用户数据区强制 8 字节对齐(x86_64 下为 16 字节):通过在块头后插入 padding 实现,确保malloc(1)返回的地址也能被int64_t*安全解引用;
  • 最小分配单元为 32 字节:防止块头+padding 后用户区过小,避免碎片化失控。
// heap.h 中定义的块结构(精简版) typedef struct chunk_t { size_t size; // 总长度:头 + 用户数据 + 可选尾部 padding int is_free; // 1 表示空闲,0 表示已分配 struct chunk_t *next; struct chunk_t *prev; } chunk_t; // heap.c 中计算用户数据起始地址的宏 #define USER_PTR(chunk) ((void*)((char*)(chunk) + sizeof(chunk_t))) // 反向:从用户指针推回 chunk 头 #define CHUNK_PTR(ptr) ((chunk_t*)((char*)(ptr) - sizeof(chunk_t)))

提示:USER_PTR和CHUNK_PTR是整个实现的基石。所有malloc返回的指针,都是USER_PTR(chunk);所有free(ptr)接收的指针,都必须能通过CHUNK_PTR(ptr)精确还原出原始chunk_t*。这是“指针合法性校验”的唯一依据,也是后续所有边界检查的起点。

2.2 四大接口的职责切分:malloc 做查找与分割,free 做合并与标记

malloc(size)的主干流程是:
① 将size向上对齐到 8 字节倍数,并加上sizeof(chunk_t)得到所需总空间;
② 遍历空闲链表,寻找第一个size >= requested_size的块;
③ 若找到,检查该块是否过大(size - requested_size > MIN_SPLIT_SIZE,默认 64 字节):过大则执行分割(split),将尾部切出新空闲块并插入链表,前部返回给用户;
④ 若未找到,调用sbrk(requested_size)扩展堆,初始化新块为已分配状态,返回用户区。

free(ptr)的主干流程是:
① 用CHUNK_PTR(ptr)还原块头;
② 将is_free置为 1;
③ 检查物理相邻块是否空闲:向前看prev_chunk(通过chunk - prev_chunk->size计算地址),向后看next_chunk(通过chunk + chunk->size计算地址);
④ 若任一邻块空闲,则执行合并(coalesce):更新 size,调整链表指针,将被合并块从空闲链表中移除。

realloc(ptr, new_size)本质是:先malloc(new_size),再memcpy数据,最后free(ptr);但做了优化——若原块后紧邻空闲块且足够大,则直接扩展原块(chunk->size += extra_needed),避免拷贝。

calloc(nmemb, size)则是malloc(nmemb * size)后,对USER_PTR(chunk)区域memset(0)。

2.3 空闲链表管理:显式双向链表 + 首次适配策略

本项目采用最朴素的显式空闲链表(explicit free list),而非分离式存储(segregated list)或位图。所有空闲块通过next/prev指针串成一个全局双向链表,头节点为静态变量free_list_head。分配时使用首次适配(first-fit):从链表头开始遍历,取第一个满足大小的块。不排序、不索引,胜在代码清晰、易于调试。链表操作全部内联,无函数调用开销。

// heap.c 片段:free() 中的合并逻辑(简化) static void coalesce(chunk_t *chunk) { chunk_t *prev = NULL; chunk_t *next = NULL; // 计算前一块地址:当前 chunk 地址 - 前一块的 size 字段值 char *prev_addr = (char*)chunk - ((chunk_t*)((char*)chunk - sizeof(size_t)))->size; if (prev_addr >= heap_start && ((chunk_t*)prev_addr)->is_free) { prev = (chunk_t*)prev_addr; // 合并:prev 吞掉 chunk prev->size += chunk->size; prev->next = chunk->next; if (chunk->next) chunk->next->prev = prev; chunk = prev; // 后续操作以 prev 为基准 } // 计算下一块地址:当前 chunk 地址 + 当前 chunk->size char *next_addr = (char*)chunk + chunk->size; if (next_addr < sbrk(0) && ((chunk_t*)next_addr)->is_free) { next = (chunk_t*)next_addr; // 合并:chunk 吞掉 next chunk->size += next->size; chunk->next = next->next; if (next->next) next->next->prev = chunk; } }

注意:prev_addr的计算是易错点。不能直接chunk - 1,因为前一块的size字段可能不在紧邻位置(中间有 padding)。正确做法是:先读取chunk地址前sizeof(size_t)字节处的值(即前一块的size),再用该值反推前一块起始地址。这段代码暴露了“内存布局即协议”的本质——分配器和用户代码必须对同一块内存的解释达成绝对一致。


3. 编译与运行:从零开始跑通第一个 malloc 测试

这个项目最大的优势是“零配置启动”。它不依赖任何构建系统,甚至不需要libc的malloc—— 因为它自己就是malloc。你只需要一个支持sbrk系统调用的 POSIX 环境(Linux/macOS 均可,WSL 也完全兼容)。下面是从下载源码到看到malloc(100)成功返回地址的完整路径。

3.1 环境准备:确认 sbrk 可用性与编译器版本

首先验证你的系统支持sbrk。在终端执行:

$ cat > test_sbrk.c << 'EOF' #include <unistd.h> #include <stdio.h> int main() { void *p = sbrk(0); printf("Current break: %p\n", p); return 0; } EOF $ gcc test_sbrk.c -o test_sbrk && ./test_sbrk

如果输出类似Current break: 0x7f8b3c000000,说明环境就绪。若报undefined reference to 'sbrk',请确认你用的是gcc(非clang默认链接模式)且未加-nostdlib。本项目要求gcc >= 5.0,因使用了__attribute__((constructor))初始化堆。

3.2 源码结构与最小可运行集

项目解压后目录如下(共 3 个文件):

heap/ ├── heap.h # 公共接口声明,含 malloc/free/realloc/calloc 原型 ├── heap.c # 核心实现:sbrk 管理、空闲链表、分割/合并逻辑 └── test.c # 5 个测试用例:基础分配、释放、realloc、calloc、压力测试

heap.c是唯一业务文件,全文仅 420 行(含注释和空行)。test.c不调用libc的malloc,所有内存申请均走heap.c中的my_malloc(即重命名后的malloc)。这种“自举”设计确保你调试时看到的每一行,都是你自己写的逻辑。

3.3 编译与调试:用 gdb 看清 malloc 的每一次呼吸

编译命令极其简单:

gcc -g -O0 heap.c test.c -o heap_test

-g保证调试信息完整,-O0关闭优化以防内联打乱调用栈。运行前,建议先设置ulimit -s unlimited避免栈溢出干扰。运行:

./heap_test

预期输出:

[TEST 1] malloc(100) -> 0x7f8b3c001010 [TEST 2] free(0x7f8b3c001010) -> OK [TEST 3] realloc(0x7f8b3c001010, 200) -> 0x7f8b3c001010 (same addr) [TEST 4] calloc(10, 8) -> 0x7f8b3c0010a0, all zero: YES [TEST 5] 1000x malloc/free -> PASS

要真正理解流程,必须进gdb:

gdb ./heap_test (gdb) b malloc Breakpoint 1 at 0x4011a6: file heap.c, line 123. (gdb) r # 程序停在 malloc 入口 (gdb) step # 单步进入 (gdb) p/x $rdi # 查看第一个参数(size=100) $1 = 0x64 (gdb) n # 下一行,走到 size 对齐计算 (gdb) p aligned_size $2 = 0x70 # 100 -> 104 -> 对齐到 8 的倍数 = 112? 等等,这里要手动算!

提示:gdb中p aligned_size可能显示未定义,因为优化关闭后变量名仍存在。更可靠的是p *(size_t*)($rbp-0x8)(查看局部变量栈槽)。但比寄存器更直观的是——在heap.c的malloc函数开头加一句fprintf(stderr, "malloc(%zu) called\n", size);,重新编译后运行,stderr 输出会实时打印调用轨迹。这是我在某高校嵌入式课设中教学生的第一课:日志比断点更快定位问题根源。

3.4 堆状态快照:用 heap_dump() 揭开内存布局面纱

heap.c提供了一个隐藏武器:heap_dump()。它不输出到 stdout,而是写入stderr,格式为纯文本表格,展示当前所有块的状态:

// 在 test.c 末尾添加: #include "heap.h" int main() { // ... 原有测试 heap_dump(); // 新增 }

重新编译运行,你会看到类似:

ADDR SIZE IS_FREE NEXT PREV 0x7f8b3c000000 4096 0 0x0 0x0 # 初始 brk 块,已分配 0x7f8b3c001010 112 0 0x0 0x0 # malloc(100) 分配块 0x7f8b3c001080 4096-112 1 0x0 0x7f8b3c000000 # 剩余空闲块

这个表格直接对应你在gdb里x/20gx看到的内存内容。ADDR列是chunk_t*地址,SIZE是chunk->size值,IS_FREE是chunk->is_free。当你free()一个块后,IS_FREE会从0变1,且NEXT/PREV会填入链表指针。这是检验你是否真正理解“空闲链表”概念的黄金标准——如果heap_dump()输出的链表指针无法构成闭环,说明你的free()合并逻辑有 bug。


4. 常见问题排查:那些让我熬夜到凌晨三点的 malloc 玄学翻车现场

写内存分配器不是写 Hello World,它是一场与地址、对齐、边界、竞态持续搏斗的过程。下面列出我在这项目上踩过的 4 个真实坑,每个都附带gdb下的复现步骤和修复逻辑。它们不是理论假设,而是某次make test突然挂掉时,我对着core dump文件逐行objdump找到的血泪经验。

4.1 现象:malloc(1)返回地址无法被int*解引用,程序 SIGSEGV

原因:USER_PTR(chunk)计算错误,导致返回地址未对齐。malloc(1)请求 1 字节,加上 16 字节头,总需 17 字节;但若只按 8 字节对齐,17→24,USER_PTR返回地址为chunk + 16,而chunk本身地址是 8 字节对齐的,chunk + 16必然也是 8 字节对齐——等等,为什么还会崩?
深挖:sbrk(0)返回的初始brk地址本身可能不是 16 字节对齐的(x86_64 ABI 要求栈和 malloc 返回地址 16 字节对齐)。chunk头若直接放在brk位置,其地址就不满足USER_PTR(chunk)的对齐要求。
解决:在init_heap()中,首次sbrk()后,不直接将brk地址作为第一个chunk起始,而是先sbrk(ROUND_UP(sizeof(chunk_t), 16)),确保chunk头地址 16 字节对齐,再sbrk()剩余空间。ROUND_UP宏必须用位运算:(x + 15) & ~15。

4.2 现象:连续malloc(100); free(p1); malloc(100)返回相同地址,但第二次malloc后free(p1)导致段错误

原因:free()中coalesce()合并时,错误地修改了已被malloc重用的块的next/prev指针。典型场景:块 A 被free,其后块 B 是空闲的,合并时 A 吞掉 B,A 的next指向 B 的next;但此时 B 已被另一个malloc分配出去,B 的next字段已被用户写为任意值(比如0xdeadbeef),导致链表指针污染。
解决:合并前必须双重校验——不仅检查next_chunk->is_free == 1,还要检查next_chunk地址是否在合法堆范围内(next_chunk >= heap_start && (char*)next_chunk + sizeof(chunk_t) <= sbrk(0)),且next_chunk->size值合理(大于sizeof(chunk_t)且小于堆总大小)。heap.c第 287 行的is_valid_chunk()就是为此而生。

4.3 现象:realloc(p, larger_size)后,原数据前 8 字节被篡改

原因:realloc的“就地扩展”分支中,只增加了chunk->size,但未将新扩展区域的is_free字段置为 0。导致后续malloc可能误将这块“新扩区域”当作空闲块分配出去,覆盖用户数据。
解决:在realloc的扩展分支里,增加memset(USER_PTR(chunk) + old_user_size, 0, extra_bytes)清零新区域,并确保新区域的chunk_t头(如果存在)被正确初始化。但更安全的做法是:永远不信任扩展区域的元数据,强制将其标记为已分配——即((chunk_t*)((char*)chunk + old_size))->is_free = 0;。

4.4 现象:多线程环境下malloc随机崩溃,gdb显示free_list_head->next为野指针

原因:项目默认是单线程的,free_list_head是全局变量,无锁访问。但在test.c的stress_test()中,若用pthread_create启动多个线程并发调用malloc/free,必然出现链表指针被撕裂。这不是 bug,而是设计约束。
解决:若需线程安全,必须加锁。在heap.c顶部添加#include <pthread.h>,声明static pthread_mutex_t heap_mutex = PTHREAD_MUTEX_INITIALIZER;,然后在malloc/free/realloc/calloc入口加pthread_mutex_lock(&heap_mutex),出口加unlock。注意:init_heap()必须在锁初始化之后调用,否则死锁。这是“从单线程到多线程”的第一道坎,跨过去你就懂了tcmalloc为何要搞 per-CPU arena。

注意:以上所有问题,都能通过heap_dump()+gdb的x/20gx free_list_head命令快速定位。记住一个原则:所有指针操作,必须有双向校验——正向从 head 遍历,反向从 tail 回溯,两者必须能闭环。这是我从某导师那里学到的最硬核的内存调试心法。


5. 二次开发实战:如何把教学版 malloc 改造成你的生产环境利器

现在你已经能跑通、能调试、能看懂每一块内存的来龙去脉。下一步不是“写得更好”,而是“让它解决你手头的真实问题”。下面以三个典型场景为例,给出可直接抄作业的改造方案。每个方案都包含:改动点定位、代码补丁、验证方法、性能权衡。不讲虚的,只给能立刻生效的指令。

5.1 场景一:嵌入式 MCU 上无sbrk,需对接硬件 RAM 池

很多 Cortex-M 系列 MCU(如 STM32F4)没有sbrk系统调用,RAM 是静态划分的。你需要把heap.c的底层内存源从sbrk切换为一块静态数组。

改动点定位:heap.c中所有sbrk()调用,以及heap_start全局变量初始化。

代码补丁:

// heap.c 开头,注释掉原来的 sbrk 相关声明 // static void *heap_start = NULL; // 新增静态 RAM 池(大小按需调整,例如 64KB) #define HEAP_SIZE (64 * 1024) static uint8_t ram_pool[HEAP_SIZE]; static void *heap_start = ram_pool; static size_t heap_used = 0; // 替换 init_heap():不再调用 sbrk,直接初始化首块 static void init_heap() { if (heap_start == NULL) return; chunk_t *first = (chunk_t*)heap_start; first->size = HEAP_SIZE; first->is_free = 1; first->next = NULL; first->prev = NULL; free_list_head.next = first; free_list_head.prev = first; first->next = &free_list_head; first->prev = &free_list_head; }

验证方法:在test.c中#define HEAP_SIZE 1024,运行test.c的所有用例。重点验证stress_test()是否在分配满后返回NULL(而非崩溃)。用gdb查看ram_pool内存,确认chunk_t头正确写入。

性能权衡:失去动态扩容能力,但启动零延迟、无系统调用开销。RAM 池大小需静态预估,建议预留 20% 碎片余量。

5.2 场景二:诊断内存泄漏,需记录每次 malloc 的调用栈

valgrind在嵌入式或实时系统上不可用。你需要让malloc自动记录__FILE__、__LINE__和backtrace()。

改动点定位:malloc函数签名、chunk_t结构体、heap_dump()输出逻辑。

代码补丁:

// heap.h 中扩展 chunk_t #include <execinfo.h> typedef struct chunk_t { size_t size; int is_free; struct chunk_t *next; struct chunk_t *prev; const char *file; // 新增 int line; // 新增 void *bt[16]; // 新增:最多存 16 层调用栈 int bt_size; // 新增 } chunk_t; // malloc 中新增记录逻辑(在分配成功后) chunk->file = file; chunk->line = line; chunk->bt_size = backtrace(chunk->bt, 16); // 修改 malloc 原型为:void* malloc_debug(size_t size, const char *file, int line) // 并用宏 #define malloc(s) malloc_debug((s), __FILE__, __LINE__)

验证方法:编译时加-rdynamic,运行后heap_dump()会输出每块的file:line和bt[0](即malloc_debug的调用者)。用addr2line -e heap_test -f -C <addr>可还原符号。

性能权衡:每次malloc多一次backtrace()系统调用,耗时约 1~5μs。仅用于调试阶段,发布前用宏开关控制。

5.3 场景三:应对高频小对象(< 16 字节),添加 slab 分配器

当前实现对malloc(1)这类请求,每次都要分配至少 32 字节(头+用户区),浪费严重。Slab 能将一页(4KB)切分为固定大小的 slot,复用率极高。

改动点定位:新增slab.c,修改malloc主逻辑加入 slab 分支判断。

代码补丁框架:

// slab.h typedef struct slab_cache { size_t obj_size; // 对象大小,如 8/16/32/64... size_t num_objs; // 每页对象数 void *page; // 指向 4KB 页面 uint8_t *bitmap; // 位图,1 bit per object struct slab_cache *next; } slab_cache_t; // malloc 中新增判断(在查找空闲链表前) if (size <= 64) { for (cache = slab_caches; cache; cache = cache->next) { if (cache->obj_size >= size && (ptr = slab_alloc(cache))) { return ptr; } } // 未命中则创建新 cache slab_cache_t *new_cache = create_slab_cache(ROUND_UP(size, 8)); return slab_alloc(new_cache); }

验证方法:编写test_slab.c,循环malloc(8)1000 次,对比heap_dump()中块数量(应远少于 1000,因复用)和总内存占用(应接近1000*8 + metadata_overhead)。

性能权衡:小对象分配从 O(n) 链表遍历降为 O(1) 位图扫描,但需额外维护slab_cache链表和位图。适用于已知对象尺寸分布的场景(如网络包 buffer、GUI widget)。

从那以后我每次接手一个新项目的内存模块,都强制走一遍heap_dump()+gdb单步malloc的流程——不是为了炫技,而是为了亲手确认:这片内存,到底是我的,还是系统的,还是别人的。这行代码教会我的,从来不是怎么写 malloc,而是怎么敬畏指针。希望帮到你。

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

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

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

立即咨询