news 2026/10/20 7:32:31

手写 malloc 实战:从 free list 到堆内存管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写 malloc 实战:从 free list 到堆内存管理

简介:这份资源通过实现一个简化版内存分配函数 my_malloc,帮助 C 语言开发者和希望对内存管理有更深理解的读者,掌握 malloc 的底层逻辑与堆管理机制。资源包共 3 个文件,包含 mymalloc.c、mymalloc.h 与 test.c,其中 .c 文件给出分配器实现与测试代码,.h 文件提供接口声明,适合对照着梳理从堆初始化、空闲块查找到内存释放与合并的完整流程。压缩包整体仅 3KB,短小精悍,便于直接阅读和编译运行。配套描述覆盖了内存碎片、内存块、内存池等核心概念,并提及首次适配、最佳适配等分配策略;通过阅读实现代码,可以理解真实 malloc 中边界对齐、空闲链表维护等问题的简化处理思路。编写者用清晰的分步实现将复杂堆管理拆解为可验证的小模块,读者可在此基础上扩展自己的分配策略。目前已有 1829 人学习下载,对于正在学习《深入理解计算机系统》中内存部分或准备操作系统相关面试的读者,是一份轻量的辅助材料。

1. 自己动手写 malloc:别再把它当黑匣子

第一次意识到该把 malloc 拆开看,是在某次排查内存泄漏时,盯着堆栈里几十层调用却说不清 glibc 到底做了哪些事。那时候才明白:malloc 看似只是一个返回指针的函数,背后其实是堆管理、对齐、碎片整理和系统调用的一整套博弈。与其对着黑匣子猜,不如自己动手复现一个教学版本的 malloc,把堆内存的分配和释放逻辑真正落到代码里。这篇笔记会讲清楚 free list 如何维护、首部块为什么必备、合并时机怎么判断,以及我自己调试时的三个踩坑现场。如果你是刚接触内存管理的开发者,照着走一遍就能看到 malloc 最核心的骨架;如果你已经写过类似代码,可以重点关注边界处理和性能取舍这两节。

2. 先立骨架:裸机环境的堆区与元数据设计

2.1 为什么不能直接拿结构体数组当堆

很多入门实现最容易犯的错,是定义一个足够大的static char heap[1024 * 1024],然后用一个指针在数组里划拉。这个做法的问题在释放环节暴露无遗:你无法判断某块内存是否空闲,更没法把相邻的空闲块合并成一个大块。原因很简单——数组本身不携带任何“管理信息”,谁分配了、谁释放了、块有多大,这些都需要额外的元数据来记录。

真正能用的堆管理器,底层思想是“把整块内存切成大小不一的块,每个块前面挂一个 header,header 里存块大小和空闲标志。”释放的时候修改标志位,分配时遍历所有块找第一个足够大的空闲块。这个结构在业界叫 free list,是几乎所有 malloc 教学的起点。

教学版本的 malloc 并不需要调用系统调用去扩展堆区,直接预分配一大块静态内存,在其内部做管理就够用。这样可以把注意力集中在分配策略和块管理算法上,避开操作系统底层的复杂性。

2.2 首部块与字节对齐的最小约定

我选择的元数据结构长这样:

typedef struct block_header { size_t size; // 当前块大小(不含 header 本身) int free; // 1 表示空闲,0 表示已分配 struct block_header *next; // 指向下一个块 } block_header_t;

每申请一块内存,实际从堆里消耗的是“header 大小 + 用户请求大小”,返回给用户的是 header 之后的那段地址。这里有两个强制约定:第一,所有块地址必须 8 字节对齐,malloc 返回的指针也必须对齐,否则在 64 位机器上访问long long或double会触发总线错误;第二,每次分配的最小单位是 16 字节,即使你只申请 1 个字节,也要按 16 字节的粒度向上取整,为的是避免频繁切割出极小的碎片块。

初始化堆区时,我一般这样写:

static char heap_space[HEAP_SIZE]; static block_header_t *head = NULL; void init_heap() { head = (block_header_t *)heap_space; head->size = HEAP_SIZE - sizeof(block_header_t); head->free = 1; head->next = NULL; }

注意heap_space是字符数组,强转成block_header_t *后直接操作。这里有个前提:数组首地址本身对齐到 8 字节。C 标准只保证char数组按 1 字节对齐,所以严谨的做法是用max_align_t类型的数组来声明,或者手动做一次对齐补偿。后面这副避坑里我把这个忘了,折腾了一个多小时。

3. 分配与释放的完整链路:从首次适应到块合并

3.1 分配路径:遍历、切割与标志位翻转

核心思路是首次适应算法:从头节点开始往后遍历,找到第一个free == 1且size >= 请求大小的块,就从该块上切下一段给用户,剩下的部分留在原位置,更新 header 里的 size 和 next 指针。切剩的大小必须大于等于sizeof(block_header_t) + MIN_ALLOC,否则切割就没有意义,不如整块给出去。

void *my_malloc(size_t bytes) { size_t total = align8(bytes) + sizeof(block_header_t); block_header_t *cur = head; while (cur != NULL) { if (cur->free && cur->size >= total) { if (cur->size - total >= MIN_ALLOC + sizeof(block_header_t)) { // 切割:新块放在原块尾部 block_header_t *new_block = (block_header_t *)((char *)cur + sizeof(block_header_t) + total); new_block->size = cur->size - total - sizeof(block_header_t); new_block->free = 1; new_block->next = cur->next; cur->size = total; cur->next = new_block; // 注意这一步别丢 } cur->free = 0; return (char *)cur + sizeof(block_header_t); } cur = cur->next; } return NULL; }

这里最关键的是new_block->next = cur->next这一行,它把新块插在原块和下一个块之间。很多初版实现会漏掉这个赋值,导致链表断裂,free 的时候遍历直接越界。切割后,原块的 free 标志翻转为 0,返回数据区地址;分配失败返回 NULL,调用方自行判断。

head 节点在第一次 init 时是整块空闲,越往后链表越长,碎片越多。首次适应算法的优点是简单快速,缺点是每次都从头扫,如果头部的碎片块很多,扫描效率会直线下降。

3.2 释放路径:标记空闲、向后合并与向前合并

释放逻辑的核心是“把 free 标志改回 1,然后尝试和相邻空闲块合并”。合并的目的是防止碎片越来越多,最终堆区明明还剩很多零碎空间,却没有一个连续区段能满足大请求。

void my_free(void *ptr) { if (ptr == NULL) return; block_header_t *cur = (block_header_t *)((char *)ptr - sizeof(block_header_t)); cur->free = 1; // 向后合并:cur 与 cur->next 相邻且 next 空闲 block_header_t *next = cur->next; if (next != NULL && next->free) { cur->size += sizeof(block_header_t) + next->size; cur->next = next->next; } // 向前合并:找前一个块,若它空闲则与前块合并 block_header_t *prev = head; while (prev != NULL && prev->next != cur) { prev = prev->next; } if (prev != NULL && prev->free) { prev->size += sizeof(block_header_t) + cur->size; prev->next = cur->next; } }

向后合并的公式很多人写错,把sizeof(block_header_t)漏掉。实际合并时,大块的总大小相当于“当前 size + header 大小 + 下一块 size”,因为中间隔着 next 块的那个 header。向前合并需要从头遍历找前驱,时间复杂度 O(n),但教学版本不用太纠结性能,先保证逻辑正确。

3.3 为什么块首地址不能直接传给用户

用户拿到的指针是(char *)cur + sizeof(block_header_t),而不是cur本身。这样做的原因很实际:free 的时候需要根据用户指针反推出块首部,如果直接把cur交出去,用户把它当成普通内存区来读写,header 会被覆盖,下次 malloc 遍历时整个链表就乱了。同样的道理,禁止用户越过返回边界写入,否则下一个块的 header 会被破坏,产生极其隐蔽的内存踩踏。

4. 避坑实录:对齐、切割与越界的三个翻车现场

4.1 现象:数组首地址不对齐,一访问就崩溃

第一次跑测试程序,在 32 位环境上一切正常,换到 64 位平台直接 segfault。排查了半天,发现问题出在static char heap_space[HEAP_SIZE]上,数组首地址没有 8 字节对齐,强转成block_header_t *后,结构体里的size_t字段跨越了缓存行边界。

原因:C 标准只保证char数组按 1 字节对齐,而block_header_t *需要 8 字节对齐。解决:把静态数组声明成static max_align_t heap_space[HEAP_SIZE / sizeof(max_align_t)],或者写一个手动对齐的初始化函数,先计算出对齐偏移,把 head 指针挪到正确的位置上。从那以后,凡是涉及堆区、缓冲区的强转,我都会先验证首地址的对齐属性。

4.2 现象:切割后链表断掉,free 遍历死循环

切割代码一开始没写new_block->next = cur->next,直接默认原块的 next 会传递下去。结果测试连续申请 5 次内存,释放第 2 块后再申请,链表遍历时走到了一个野指针上,程序直接卡死。

原因:新块是从原块尾部切出来的,它和原块后面的块是邻居,必须显式把 new_block 的 next 指向原块的 next。解决方式很简单,就是上面代码里的那一行赋值。类似的还有释放时向前合并后要重新设置 prev->next,否则前驱块会指向已“不存在”的 cur,造成悬垂。

4.3 现象:用户越界写,header 被改,free 时 size 异常

测试程序里有一个memcpy(dst, src, 64),但目标块实际只申请了 32 字节。运行几轮后,malloc 返回的地址开始乱跳,甚至 free 时报出荒谬的 size 值。

原因:用户数据越界,直接写入了下一块的 header,把 next 指针和 free 标志全污染了,整个 free list 的结构被破坏。这种错误无法在 my_free 内部拦下来,只能靠调用方守规矩。

解决:在 my_malloc 的返回值处可以用__builtin_object_size做编译期检查,但最有效的还是测试时多分配一个 padding 区,在 padding 里填特殊字节,释放前检查这些字节有没有被改动。我在 debug 版里加了这一层,定位了三个越界写的问题。

5. 回归验证与调试技巧:把 malloc 当成独立模块来测

5.1 测试矩阵:碎片化场景是试金石

把 my_malloc 和 my_free 单独编成一个 .c 文件,写一个测试驱动,覆盖以下四类场景:小对象循环申请释放、大小交错分配的碎片化压力测试、一次性申请大块再连续释放、释放全部块后检查堆区总空闲空间是否回到初始值。其中碎片化场景最关键:循环 10000 次,每次随机申请 8 到 256 字节,然后随机释放,完成后检查所有空闲块的 size 总和,看是否等于 HEAP_SIZE 减掉 header 占用。

for (int i = 0; i < 10000; i++) { int sz = 8 + rand() % 250; void *p = my_malloc(sz); if (p) records[i] = p; if (rand() % 3 == 0 && records[i]) { my_free(records[i]); records[i] = NULL; } } // 遍历 free list,累加 free 块的 size,对比初始值

如果碎片化测试不通过,多半是向前合并没有生效或者切割条件写错。有一个快速定位的技巧:在每次 my_malloc 和 my_free 执行后,打印当前 free list 里所有块的 size、地址和 free 标志,排列成表格,人眼扫一遍就能看出哪里断了链。

5.2 用 memory tracer 记录每次调用的地址与大小

困扰最多的场景不只是崩溃,还有“内存没崩但不知道谁没释放”。我写了一个简单的 tracer,在 my_malloc 返回时把指针和大小记到一个全局日志数组里,my_free 时再标记释放。程序退出前扫描日志,所有未被标记释放的地址就是泄漏点。

注意 tracer 本身需要一段独立的存储空间,不能放在被管理的堆区里,否则 tracer 的日志写入会污染 free list。我单独开了一个静态数组来存日志记录,配合调用方的文件名和行号宏,定位效率比用调试器快很多。从那次之后,我给每个教学版内存模块都默认挂上 tracer,再复杂的测试都没翻过车。

写这个 malloc 的过程让我最大的收获不是学会了分配算法,而是明白了任何看似简单的系统接口背后都有一套值得拆开研究的设计取舍。如果你正在学内存管理,自己动手把 free list 跑通一次,比读十遍文档都有用。希望这份笔记能帮你的 malloc 之旅少走几个弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 7:32:00

Linux服务器防火墙实战:Web端口放行与SSH加固指南

做运维这些年&#xff0c;几乎每次部署一台新的Linux服务器&#xff0c;头一件事就是确认防火墙规则是否已经到位。尤其是在服务器既要对外提供Web服务、又要保留SSH远程管理入口的场景下&#xff0c;防火墙到底怎么配、规则先后顺序怎么排、改了端口之后还要联动哪些配置&…

作者头像 李华
网站建设 2026/10/20 7:31:55

AgentCore架构解析:面向生产环境的确定性智能体设计

1. 这不是又一个“智能体框架”的简单升级最近在某云厂商技术峰会现场听到“AgentCore”这个词时&#xff0c;我下意识摸了出去的U盘——不是为了拷资料&#xff0c;是条件反射想插进笔记本跑个本地验证。这反应背后&#xff0c;是过去两年里被至少七套标榜“全栈Agent开发平台…

作者头像 李华
网站建设 2026/10/20 7:31:54

AgentCore:智能体工程化的四大支柱与生产实践

1. 这不是又一个“智能体框架”&#xff0c;而是Agent工程化的分水岭最近刷技术社区&#xff0c;几乎每条高赞帖都绕不开“AgentCore”三个字。阿里云在QCon上海站正式发布AgentCore后&#xff0c;我第一时间拉了源码、搭了本地环境、跑了三个典型场景——不是为了赶热度&#…

作者头像 李华
网站建设 2026/10/20 7:30:23

基于人工势场法的无人机集群三维避障Matlab仿真实现与调参

做无人机集群仿真&#xff0c;最难的不是让某一架飞机飞起来&#xff0c;而是让一群飞机在同一个三维空间里既不撞障碍物、又不互相撞&#xff0c;还能整体到达目标区域。我最早接触这个课题时&#xff0c;第一反应是找现成的开源方案&#xff0c;结果翻了一圈&#xff0c;工业…

作者头像 李华
网站建设 2026/10/20 7:30:21

SQL窗口函数巧解连续日期状态区间问题

LeetCode 数据库题单做到后面&#xff0c;会发现大量题目其实是在为真实世界的报表需求做“降维”。这道“报告系统状态的连续日期”就是非常典型的一道。题面很短&#xff1a;系统每天运行&#xff0c;成功日期和失败日期分别记在两张表里&#xff0c;要求把某个时间段内所有“…

作者头像 李华
网站建设 2026/10/20 7:28:45

两级式三相光伏逆变并网系统Simulink仿真完整搭建与调试

几个月前我把一套“两极式三相光伏逆变并网系统”的Simulink仿真模型完整跑通了。从最开始的光伏阵列建模&#xff0c;到Boost升压、三相逆变、并网控制&#xff0c;再到最后的说明文档和参考文献整理&#xff0c;整个过程踩了不少坑。这篇记录打算把这套模型的搭建思路、控制链…

作者头像 李华