分析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

Source Code Here

将代码下载下来后,笔者使用 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维护了两个sizepsizecsize

psize:前一个chunk的大小

csize:当前chunk的大小

这样就可以计算出上一个chunk的起始位置和下一个chunk的起始位置

同时psizecsize的最低位也被设计为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)
  1. 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))
  1. 这个宏是每一个chunk所需要的Meta Data大小

    #define MMAP_THRESHOLD (0x1c00*SIZE_ALIGN)
    
  2. MMAP_THRESHOLD定义了一个临界值,如果申请的内存大于这个值,就会采用mmap分配

运行路径解析

让我们来开启一个场景,跟随malloc(0)时,musl-libc会如何处理

  1. 对请求大小进行预处理

    目的是为了防止整数溢出和指针运算越界,转化为最小有效分配并对齐

    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)大小

Tags:

⋅˚₊‧ ୨ on this page ୧ ‧₊˚ ⋅

recent-work

数字电路知识点记录

TL;DR 最近太无聊,重新拾起cs61c课程,开始有些痛苦的数字电路学习。 我的仓库 这个博客就用来记录一下各种硬件的引脚和电路设计等知识。 Warm up Bssic 在更高抽象的程序中,一般情况下都是串行的。 但是在硬件视角中,都是并行的。(电路很快,所以要有负反馈/回授等 …

Read more →

动态调试xv6内核,复习OS知识

TL;DR 很早之前,我就做完了xv6内核的实验,也算是入门了操作系统 xv6小而精的设计思路的确适合初学者入门这样一个复杂的计算机分支 今天经过一定时间的沉淀后,重新来看看当时没有被完全理解,还有一些被一笔带过的知识 固件代码 过去,我们只关注到了内核被加载之后的内容,每次调试 …

Read more →