分析musl libc 1.2.0 malloc实现
musl libc
最近决定着手malloc的实现,一方面是比较感兴趣,另一方面可以加强对内存管理的理解,同时也可以推进对堆安全的理解。
相对于glibc的复杂,或许应该先抛开复杂的性能优化,从最简单的开始学习
所以我选择了musl libc 1.2.0版本
musl libc的malloc在1.2.1的版本进行了重构,重写了新的内存分配器,但是要复杂的多。
所以我选择了重构前的最新版本,也就是1.2.0进行学习
Source Code
将代码下载下来后,笔者使用 clangd + neovim 作为开发环境,所以先生成 compile_commands.json 数据库,便于使用跳转等高级功能
cd musl-1.2.0 && ./configure
bear -- make
动态调试环境
Clone下代码后,编译时开启添加调试信息
CFLAGS="-g -O1" ./configure --prefix=/opt/musl-debug
make -j
sudo make install
然后创建一个main.c
#include <stdio.h>
#include <stdlib.h>
int main() {
void *m = malloc(0);
printf("%p\n", m);
}
/opt/musl-debug/bin/musl-gcc -g main.c -o main
gdb ./main
可以将musl-gcc的路径写入到PATH变量中,便于后续调试
因为使用了-g参数,所以gdb中打开layout src是直接可以查看源代码进行调试的
数据结构
malloc的实现源码在 src/malloc 目录中,我们先来查看数据结构设计
struct chunk {
size_t psize, csize;
struct chunk *next, *prev;
};
struct bin {
volatile int lock[2];
struct chunk *head;
struct chunk *tail;
};
bin维护了一个带锁空闲双向链表,维护空闲chunk
In-band Metadata allocator
这个分配器的设计解决了好几个问题:
边界标记(Boundary Tags)
为了应对内存碎片化,每个chunk中都记录了前一个chunk大小(psize),和当前chunk大小(csize)
这样就可以在O(1)的复杂度中,快速找到上一个chunk和下一个chunk的起始地址
空间复用(Space Multiplexing)
一个chunk只有两个状态,一个是被分配,一个是未未分配
在空闲时,chunk就需要prev和next指针。 但是分配器在把内存交给用户时,直接把用户的起始写入地址指向了
next指针原本所在的位置。用户的数据直接覆盖并占用了原本存放指针的空间。动态切割
如果申请32字节,会从大块所在的 Bin 中拿出一个 1024 字节的 chunk,进行分割,切出 32 字节封装成新的 chunk 交给用户,剩下的 992 字节重新封装成一个更小的空闲 chunk,挂回到对应 992 字节大小的 Bin 里。
大小分级与隔离池
当堆里有成千上万个大小不一的空闲 chunk 时,当用户调用
malloc(32),系统不能从头到尾遍历整个堆去寻找合适的内存引入
bin数组。系统按大小范围对空闲块进行分类,比如 Bin 1 专放 16-24 字节,Bin 2 专放 32-40 字节,以此类推。当申请特定大小的内存时,系统直接计算出对应的 Bin 索引,去该 Bin 的
head处摘取第一个空闲块。这也把查找时间降到了几乎 O(1)。
分析
一个chunk维护了两个size,psize和csize。
psize:前一个chunk的大小
csize:当前chunk的大小
这样就可以计算出上一个chunk的起始位置和下一个chunk的起始位置
同时psize和csize的最低位也被设计为inuse标志,用来确定chunk是否在使用
#define CHUNK_SIZE(c) ((c)->csize & -2)
所以就可以知道这个宏的目的就是通过-2mask掉inuse标志来读取chunk的大小
chunk在存储的时候会存储一些元数据,用户不能直接访问它们
当用户调用malloc(32)时,会有额外16字节的头开销,也就是psize和csize
存储头部是为了检查chunk的状态,在回收的时候就有依据。
所以malloc(0)也会返回一个地址(在malloc执行的第一个adjust_size()函数就会调整用户输入的大小),而不是NULL
Bin and Chunk
Chunk是内存分配单位,Bin是管理这些Chunk状态的容器
宏定义解析
#define SIZE_ALIGN (4*sizeof(size_t))
#define SIZE_MASK (-SIZE_ALIGN)
chunk的对齐宏:64位
size_t数据类型是8字节,所以64位chunkj是32字节对齐SIZE_MASK的作用是将低5位清零,强制对齐到32字节
为什么需要对齐:
chunk 最低位存 inuse 标志,所以 chunk 大小必须至少 32 字节且 32 对齐,确保最低 5 位永远为 0,可以安全存标记
#define OVERHEAD (2*sizeof(size_t))
这个宏是每一个chunk所需要的Meta Data大小
#define MMAP_THRESHOLD (0x1c00*SIZE_ALIGN)MMAP_THRESHOLD定义了一个临界值,如果申请的内存大于这个值,就会采用mmap分配
运行路径解析
让我们来开启一个场景,跟随malloc(0)时,musl-libc会如何处理
对请求大小进行预处理
目的是为了防止整数溢出和指针运算越界,转化为最小有效分配并对齐
static int adjust_size(size_t *n) { /* Result of pointer difference must fit in ptrdiff_t. */ if (*n-1 > PTRDIFF_MAX - SIZE_ALIGN - PAGE_SIZE) { if (*n) { errno = ENOMEM; // 超出最大可分配范围,报错返回 return -1; } else { *n = SIZE_ALIGN; // 处理 malloc(0) 的特殊情况 return 0; } } /* * 内存对齐/开销公式: * (原先申请大小 + chunk元数据大小 + 向上取整) / 最后进行对齐 * SIZE_ALIGN - 1无论什么值加上它,都不会发生对齐进位的情况,为了配合后面的向上取整 + 内存对齐 * (直接 & mask就是向下取整) */ *n = (*n + OVERHEAD + SIZE_ALIGN - 1) & SIZE_MASK; return 0; }因为
n的数据类型是size_t,所以如果用户申请内存为0的时候,*n -1就会造成下溢为size_t类型的最大值,这样就可以额外去处理超过范围和申请值为0的特殊情况所以实际上申请0字节时,malloc也会为你分配32字节(SIZE_ALIGN)大小
