堆概述

什么是堆

在程序运行过程中,堆可以提供动态分布的内存,允许程序申请大小未知的内存。堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址方向增长。我们一般称管理堆的那部分程序为堆管理器。

堆管理器处于用户程序与内核中间,主要做以下工作:

1.响应用户的申请内存请求,向操作系统申请内存,然后将其返回给用户程序,同时为了保持内存管理的高效性,内核一般都会预先分配很大的一块连续的内存,然后让堆管理通过某种算法管理这块内存。只有当出现了堆空间不足的情况,堆管理器才会再次与操作系统进行交互。

2.管理用户所释放的内存。一般来说用户释放的内存并不是直接返还给操作系统的,而是由堆管理器进行管理。这些释放的内存可以来响应用户新申请的内存请求。

Linux 中早期的堆分配与回收由 Doug Lea 实现,但它在并行处理多个线程时,会共享进程的堆内存空间。因此,为了安全性,一个线程使用堆时,会进行加锁。然而,与此同时,加锁会导致其它线程无法使用堆,降低了内存分配和回收的高效性。同时,如果在多线程使用时,没能正确控制,也可能影响内存分配和回收的正确性。Wolfram Gloger 在 Doug Lea 的基础上进行改进使其可以支持多线程,这个堆分配器就是 ptmalloc 。在 glibc-2.3.x. 之后,glibc 中集成了 ptmalloc2。

目前 Linux 标准发行版中使用的堆分配器是 glibc 中的堆分配器:ptmalloc2。ptmalloc2 主要是通过 malloc/free 函数来分配和释放内存块。

需要注意的是,在内存分配与使用的过程中,Linux 有这样的一个基本内存管理思想,只有当真正访问一个地址的时候,系统才会建立虚拟页面与物理页面的映射关系。 所以虽然操作系统已经给程序分配了很大的一块内存,但是这块内存其实只是虚拟内存。只有当用户使用到相应的内存时,系统才会真正分配物理页面给用户使用。

堆的基本操作

这里我们主要介绍

  • 基本的堆操作,包括堆的分配,回收,堆分配背后的系统覅用
  • 介绍堆目前的多线程支持

malloc

在 glibc 的 malloc.c 中,malloc 的说明如下

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
malloc(size_t n)
Returns a pointer to a newly allocated chunk of at least n bytes, or null
if no space is available. Additionally, on failure, errno is
set to ENOMEM on ANSI C systems.
If n is zero, malloc returns a minumum-sized chunk. (The minimum
size is 16 bytes on most 32bit systems, and 24 or 32 bytes on 64bit
systems.) On most systems, size_t is an unsigned type, so calls
with negative arguments are interpreted as requests for huge amounts
of space, which will often fail. The maximum supported value of n
differs across systems, but is in all cases less than the maximum
representable value of a size_t.
*/

可以看出,malloc 函数返回对应大小字节的内存块的指针。此外,该函数还对一些异常情况进行了处理

  • 当 n=0 时,返回当前系统允许的堆的最小内存块。
  • 当 n 为负数时,由于在大多数系统上,size_t 是无符号数(这一点非常重要),所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。

free

在 glibc 的 malloc.c 中,free 的说明如下

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
malloc(size_t n)
Returns a pointer to a newly allocated chunk of at least n bytes, or null
if no space is available. Additionally, on failure, errno is
set to ENOMEM on ANSI C systems.
If n is zero, malloc returns a minumum-sized chunk. (The minimum
size is 16 bytes on most 32bit systems, and 24 or 32 bytes on 64bit
systems.) On most systems, size_t is an unsigned type, so calls
with negative arguments are interpreted as requests for huge amounts
of space, which will often fail. The maximum supported value of n
differs across systems, but is in all cases less than the maximum
representable value of a size_t.
*/

可以看出,free 函数会释放由 p 所指向的内存块。这个内存块有可能是通过 malloc 函数得到的,也有可能是通过相关的函数 realloc 得到的。

此外,该函数也同样对异常情况进行了处理

  • 当 p 为空指针时,函数不执行任何操作。
  • 当 p 已经被释放之后,再次释放会出现乱七八糟的效果,这其实就是 double free
  • 除了被禁用 (mallopt) 的情况下,当释放很大的内存空间时,程序会将这些内存空间还给系统,以便于减小程序所使用的内存空间。

内存分配背后的系统调用

在前面提到的函数中,无论是 malloc 函数还是 free 函数,我们动态申请和释放内存时,都经常会使用,但是它们并不是真正与系统交互的函数。这些函数背后的系统调用主要是 (s)brk 函数以及 mmap, munmap 函数。

如下图所示,我们主要考虑对堆进行申请内存块的操作。

1

(s)brk

对于堆的操作,操作系统提供了brk函数,glibc库提供了sbrk函数,我们可以通过增加brk的大小来向操作系统申请内存

初始时,堆的起始地址start_brk以及堆的当前末尾brk指向同一地址。根据是否开启ASLR,两者的具体位置会有所不同

  • 不开启 ASLR 保护时,start_brk 以及 brk 会指向 data/bss 段的结尾。
  • 开启 ASLR 保护时,start_brk 以及 brk 也会指向同一位置,只是这个位置是在 data/bss 段结尾后的随机偏移处。

具体效果如下图:

1

接下来实际操作来深入理解通过改变brk的大小来分配或释放堆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* sbrk and brk example */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
void *curr_brk, *tmp_brk = NULL;

printf("Welcome to sbrk example:%d\n", getpid());

/* sbrk(0) gives current program break location */
tmp_brk = curr_brk = sbrk(0);
printf("Program Break Location1:%p\n", curr_brk);
getchar();

/* brk(addr) increments/decrements program break location */
brk(curr_brk+4096);

curr_brk = sbrk(0);
printf("Program break Location2:%p\n", curr_brk);
getchar();

brk(tmp_brk);

curr_brk = sbrk(0);
printf("Program Break Location3:%p\n", curr_brk);
getchar();

return 0;
}

该程序用于演示程序如何手动控制 “程序中断点(Program Break)”(堆内存的边界),理解堆内存动态分配的底层原理。

1
2
3
4
5
6
curr_brk:记录当前程序中断点地址。
tmp_brk:临时保存初始中断点地址,用于后续恢复。
sbrk(0) 作用:返回当前程序中断点的地址(堆内存的 “边界”,堆向上生长的终点)。
getpid():获取当前进程 ID,方便调试时区分不同进程。
brk 函数作用:手动设置程序中断点的位置。传入新地址 curr_brk+4096 表示扩展堆内存

需要注意的是,在每一次执行完操作后,都执行了 getchar() 函数,这是为了我们方便我们查看程序真正的映射。

gcc编译链接

1
gcc -o sbrk sbrk.c

gdb调试sbrk程序观察堆边界变化情况

这里我们可以通过两种方式来查看内存分配情况

1
2
vmmap
cat /proc/进程号/maps

在第一次调用brk之前

1

可以看到并没有出现堆,结合输出可以发现

start_brk = brk = end_data = 0x555555559000

调用完第一个printf之后

出现了堆段,brk = 0x5555557a000

1

觉得应该是在printf函数内部改变了brk的大小

此时调用sbrk(0)可以发现rax存储了此时的brk

1

接下来我们尝试将brk变大,来扩展堆内存空间

扩展

brk(curr_brk+4096);扩大一页内存

1

可以看到此时brk变为了0x55555557b000堆空间得到了拓展

接下来将brk变小释放刚刚扩展的堆空间

释放

brk(tmp_brk);将brk变回初始的0x55555557a000

1

可以看到此时brk变为了0x55555557a000刚刚扩展的部分得到了释放

mmap

malloc 会使用 mmap 来创建独立的匿名映射段。匿名映射的目的主要是可以申请以 0 填充的内存,并且这块内存仅被调用进程所使用。

同样的我们通过一个程序来深入理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* Private anonymous mapping example using mmap syscall */
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

void static inline errExit(const char* msg)
{
printf("%s failed. Exiting the process\n", msg);
exit(-1);
}

int main()
{
int ret = -1;
printf("Welcome to private anonymous mapping example::PID:%d\n", getpid());
printf("Before mmap\n");
getchar();
char* addr = NULL;
addr = mmap(NULL, (size_t)132*1024, PROT_READ|PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED)
errExit("mmap");
printf("After mmap\n");
getchar();

/* Unmap mapped region. */
ret = munmap(addr, (size_t)132*1024);
if(ret == -1)
errExit("munmap");
printf("After munmap\n");
getchar();
return 0;
}

gcc编译链接

1
gcc -o mmap mmap.c

调用mmap前的内存段

1

可以看到调用mmap后内存段的变大了

1

调用munmap后内存段恢复

1

多线程支持

在原来的 dlmalloc 实现中,当两个线程同时要申请内存时,只有一个线程可以进入临界区申请内存,而另外一个线程则必须等待直到临界区中不再有线程。这是因为所有的线程共享一个堆。在 glibc 的 ptmalloc 实现中,比较好的一点就是支持了多线程的快速访问。在新的实现中,所有的线程共享多个堆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/* Per thread arena example. */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* threadFunc(void* arg) {
printf("Before malloc in thread 1\n");
getchar();
char* addr = (char*) malloc(1000);
printf("After malloc and before free in thread 1\n");
getchar();
free(addr);
printf("After free in thread 1\n");
getchar();
}

int main() {
pthread_t t1;
void* s;
int ret;
char* addr;

printf("Welcome to per thread arena example::%d\n",getpid());
printf("Before malloc in main thread\n");
getchar();
addr = (char*) malloc(1000);
printf("After malloc and before free in main thread\n");
getchar();
free(addr);
printf("After free in main thread\n");
getchar();
ret = pthread_create(&t1, NULL, threadFunc, NULL);
if(ret)
{
printf("Thread creation error\n");
return -1;
}
ret = pthread_join(t1, &s);
if(ret)
{
printf("Thread join error\n");
return -1;
}
return 0;
}

第一次申请之前, 没有任何任何堆段。

1
2
3
4
5
6
7
8
9
10
11
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread
Welcome to per thread arena example::6501
Before malloc in main thread
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
b7e05000-b7e07000 rw-p 00000000 00:00 0
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

第一次申请后, 从下面的输出可以看出,堆段被建立了,并且它就紧邻着数据段,这说明 malloc 的背后是用 brk 函数来实现的。同时,需要注意的是,我们虽然只是申请了 1000 个字节,但是我们却得到了 0x0806c000-0x0804b000=0x21000 个字节的堆。这说明虽然程序可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序。这样的话,就避免了多次内核态与用户态的切换,提高了程序的效率。我们称这一块连续的内存区域为 arena。此外,我们称由主线程申请的内存为 main_arena。后续的申请的内存会一直从这个 arena 中获取,直到空间不足。当 arena 空间不足时,它可以通过增加 brk 的方式来增加堆的空间。类似地,arena 也可以通过减小 brk 来缩小自己的空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
...
sploitfun@sploitfun-VirtualBox:~/lsploits/hof/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7e05000-b7e07000 rw-p 00000000 00:00 0
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在主线程释放内存后,我们从下面的输出可以看出,其对应的 arena 并没有进行回收,而是交由 glibc 来进行管理。当后面程序再次申请内存时,在 glibc 中管理的内存充足的情况下,glibc 就会根据堆分配的算法来给程序分配相应的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
...
sploitfun@sploitfun-VirtualBox:~/lsploits/hof/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7e05000-b7e07000 rw-p 00000000 00:00 0
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在第一个线程 malloc 之前,我们可以看到并没有出现与线程 1 相关的堆,但是出现了与线程 1 相关的栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
Before malloc in thread 1
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7604000-b7605000 ---p 00000000 00:00 0
b7605000-b7e07000 rw-p 00000000 00:00 0 [stack:6594]
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

第一个线程 malloc 后, 我们可以从下面输出看出线程 1 的堆段被建立了。而且它所在的位置为内存映射段区域,同样大小也是 132KB(b7500000-b7521000)。因此这表明该线程申请的堆时,背后对应的函数为 mmap 函数。同时,我们可以看出实际真的分配给程序的内存为 1M(b7500000-b7600000)。而且,只有 132KB 的部分具有可读可写权限,这一块连续的区域成为 thread arena。

注意:

当用户请求的内存大于 128KB 时,并且没有任何 arena 有足够的空间时,那么系统就会执行 mmap 函数来分配相应的内存空间。这与这个请求来自于主线程还是从线程无关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
Before malloc in thread 1
After malloc and before free in thread 1
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7500000-b7521000 rw-p 00000000 00:00 0
b7521000-b7600000 ---p 00000000 00:00 0
b7604000-b7605000 ---p 00000000 00:00 0
b7605000-b7e07000 rw-p 00000000 00:00 0 [stack:6594]
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在第一个线程释放内存后, 我们可以从下面的输出看到,这样释放内存同样不会把内存重新给系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
Before malloc in thread 1
After malloc and before free in thread 1
After free in thread 1
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7500000-b7521000 rw-p 00000000 00:00 0
b7521000-b7600000 ---p 00000000 00:00 0
b7604000-b7605000 ---p 00000000 00:00 0
b7605000-b7e07000 rw-p 00000000 00:00 0 [stack:6594]
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

堆相关数据结构

堆的操作就这么复杂,那么在 glibc 内部必然也有精心设计的数据结构来管理它。与堆相应的数据结构主要分为

  • 宏观结构,包含堆的宏观信息,可以通过这些数据结构索引堆的基本信息。
  • 微观结构,用于具体处理堆的分配与回收中的内存块。

微观结构

这里首先介绍堆的一些主要内部结构,堆的漏洞利用与这些结构密切相关

malloc_chunk

概述

在程序的执行过程中,我们称由malloc申请的内存为chunk。这块内存在ptmalloc内部用malloc_chunk结构体来表示。当程序申请的chunk被free后,会被加入到相应的空闲管理列表中。

无论一个chunk的大小如何,处于分配状态还是释放状态,它们都使用一个统一的结构。虽然它们使用了同一个数据结构,但是根据是否被释放,它们的表现形式会有所不同。

malloc_chunk 的结构体如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
This struct declaration is misleading (but accurate and necessary).
It declares a "view" into memory allowing access to necessary
fields at known offsets from a given base. See explanation below.
*/
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* 前一个chunk的大小(仅当前一个chunk空闲时有效) */
INTERNAL_SIZE_T size; /* 当前chunk的总大小(包含头部和数据区) */

struct malloc_chunk* fd; /* 双向链表指针:指向下一个空闲chunk */
struct malloc_chunk* bk; /* 双向链表指针:指向前一个空闲chunk */

/* 仅用于大内存块:维护更大尺寸的chunk链表 */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

一些必要的宏定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* INTERNAL_SIZE_T is the word-size used for internal bookkeeping of
chunk sizes.
The default version is the same as size_t.
While not strictly necessary, it is best to define this as an
unsigned type, even if size_t is a signed type. This may avoid some
artificial size limitations on some systems.
On a 64-bit machine, you may be able to reduce malloc overhead by
defining INTERNAL_SIZE_T to be a 32 bit `unsigned int' at the
expense of not being able to handle more than 2^32 of malloced
space. If this limitation is acceptable, you are encouraged to set
this unless you are on a platform requiring 16byte alignments. In
this case the alignment requirements turn out to negate any
potential advantages of decreasing size_t word size.
Implementors: Beware of the possible combinations of:
- INTERNAL_SIZE_T might be signed or unsigned, might be 32 or 64 bits,
and might be the same width as int or as long
- size_t might have different width and signedness as INTERNAL_SIZE_T
- int and long might be 32 or 64 bits, and might be the same width
To deal with this, most comparisons and difference computations
among INTERNAL_SIZE_Ts should cast them to unsigned long, being
aware of the fact that casting an unsigned int to a wider long does
not sign-extend. (This also makes checking for negative numbers
awkward.) Some of these casts result in harmless compiler warnings
on some systems. */
#ifndef INTERNAL_SIZE_T
# define INTERNAL_SIZE_T size_t
#endif

/* The corresponding word size. */
#define SIZE_SZ (sizeof (INTERNAL_SIZE_T))

/* The corresponding bit mask value. */
#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1)

/* MALLOC_ALIGNMENT is the minimum alignment for malloc'ed chunks. It
must be a power of two at least 2 * SIZE_SZ, even on machines for
which smaller alignments would suffice. It may be defined as larger
than this though. Note however that code and data structures are
optimized for the case of 8-byte alignment. */
#define MALLOC_ALIGNMENT (2 * SIZE_SZ < __alignof__ (long double) \
? __alignof__ (long double) : 2 * SIZE_SZ)

一般来说, size_t 被定义为 unsigned long ,其在 64 位中是 64 位无符号整数,32 位中是 32 位无符号整数。

接下来逐个解释chunk结构体的各个字段

结构体设计的 “误导性”

注释提到该结构体是一种 “视图(view)”,原因在于:

  • 双重角色:同一个结构体既要表示已分配的内存块,也要表示空闲的内存块

  • 字段复用

    • 当 chunk 被分配时,fdbk 指针不使用(被应用程序数据覆盖)。
    • 仅当 chunk 空闲时,fdbk 才构成双向链表,用于快速查找可用内存。
  • prev_size

    • 若物理相邻的前一个 chunk 是空闲的,则存储其大小(包括chunk头)。
    • 若物理相邻的前一个 chunk 已分配,则该字段被前一个 chunk 的数据覆盖(节省空间)。
  • **size**该chunk的大小,大小必须是MALLOC_ALIGNMENT的整数倍。如果申请的内存大小不是MALLOC_ALIGNMENT的整数倍,会被转换为满足大小的最小的MALLOC_ALIGNMENT的倍数,这通过request2size()宏完成。32位系统中,MALLOC_ALIGNMENT可能是 4 或 8 ;64位系统中,MALLOC_ALIGNMENT是 8 。该字段的低三个比特位对chunk的大小没有影响为标志位,他们从高到低分别表示:

    • NON_MAIN_ARENA,记录当前chunk是否不属于主线程,1表示不属于,0表示属于。
    • IS_MAPPED,记录当前chunk是否是由mmap分配的。
    • PREV_INUSE,记录前一个chunk块是否被分配。一般来说,堆中第一个被分配的内存块的size字段的p位都会设置为1,以便于防止访问前面的非法内存。当一个chunk的size的p位为0时,我们能通过prev_size字段来获取上一个chunk的大小以及地址。这也方便进行空闲chunk之间的合并
  • fd,bk chunk处于分配状态时,从fd字段开始是用户的数据。chunk空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下

    - fd 指向下一个(非物理相邻)空闲的`chunk`
    - bk 指向上一个(非物理相邻)空闲的`chunk`
    - 通过 fd 和 bk 可以将空闲的`chunk`块加入到空闲的`chunk`块链表进行统一管理
    
  • fd_nextsize,bk_nextsize 只有chunk空闲时才使用,不过其用于较大的chunklarge chunk大内存块)

    - fd_nextsize 指向前一个与当前 `chunk` **大小不同**的第一个空闲块,不包含 bin 的头指针
    - bk_nextsize 指向后一个与当前`chunk`**大小不同**的第一个空闲块,不包含 bin 的头指针
    - 一般空闲的`large chunk`在fd的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适chunk时挨个遍历 ,加速寻找过程
    

自 glibc 2.26 版本起,在 32 位 glibc 中,MALLOC_ALIGNMENT 宏的定义在编译时优先选择 sysdeps/i386/malloc-alignment.h 当中的定义,该值定义为一个常量:

1
#define MALLOC_ALIGNMENT 16

因此,对于自 2.26 版本起始的 32 位 glibc,其 MALLOC_ALIGNMENT 并非基于 SIZE_SZ 计算的 8 ,而是和 64 位 glibc 所用值相同的 16

一个已经分配的chunk的样子如下,我们称前两个字段为chunk header,后面的部分称为user data。每次malloc申请得到的内存指针,其实指向user data的起始处。

当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size 域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。

1

被释放的 chunk 被记录在链表中(可能是循环双向链表,也可能是单向链表)。具体结构如下

1

可以发现,如果一个 chunk 处于 free 状态,那么会有两个位置记录其相应的大小

  1. 本身的 size 字段会记录,
  2. 它后面的 chunk 会记录。

一般情况下,物理相邻的两个空闲 chunk 会被合并为一个 chunk 。堆管理器会通过 prev_size 字段以及 size 字段合并两个物理相邻的空闲 chunk 块。

chunk 相关宏

介绍一些宏定义里用到的内存计算指令

按位取反(~)

~7 = 0xFFFFFFF8

47 & ~7 = 47 & 0xFFFFFFF8 = 40(将数值 47 向下舍入到 8 字节对齐 的边界,即找到 ≤47 且为 8 的倍数 的最大数值)

&按位与(Bitwise AND)运算,用于将数值 47 对齐到 8 字节边界

这里主要介绍chunk的大小、对齐检查以及一些转换的宏

chunk 与 mem 指针头部的转换

mem 指向用户得到的内存的起始位置

1
2
3
/*从 malloc 头部(chunk)到用户指针(mem)的转换,以及反向转换。*/
#define chunk2mem(p) ((void *) ((char *) (p) + 2 * SIZE_SZ))
#define mem2chunk(mem) ((mchunkptr)((char *) (mem) -2 * SIZE_SZ))
  • chunk2mem(p):将 chunk 指针(指向 malloc_chunk 结构体)转换为用户实际可用的内存指针(跳过 prev_sizesize 字段,指向用户数据区起始位置)。
  • mem2chunk(mem):将用户内存指针反向转换为 chunk 指针(回溯两个 SIZE_SZ 大小的偏移,指向 malloc_chunk 结构体起始位置)。
  • 关键SIZE_SZINTERNAL_SIZE_T 的字节数(32 位系统为 4,64 位为 8),因此偏移量为 2 * SIZE_SZ(即 prev_sizesize 字段总长度)。

最小的chunk大小

1
2
/* 最小可能的 chunk 大小。 */
#define MIN_CHUNK_SIZE (offsetof(struct malloc_chunk, fd_nextsize))
  • 使用 offsetof 计算 malloc_chunk 结构体中 fd_nextsize 字段的偏移量,即 chunk 至少需包含 prev_sizesizefdbk 字段(fd_nextsize 为 large bin 专用,最小块不包含)。
  • 实际含义:最小 chunk 大小为 2 * SIZE_SZ(prev_size + size) + 2 * sizeof(void*)(fd + bk),确保双向链表基本结构。

最小申请的堆内存大小

用户最小申请的内存大小必须是 2*SIZE_SE 的最下整数倍

注:就目前而看 MIN_CHUNK_SIZE 和 MINSIZE 大小是一致的

1
2
3
4
5
/* malloc 能申请的最小大小为对齐后的最小块 */
//MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1
#define MINSIZE \
(unsigned long) (((MIN_CHUNK_SIZE + MALLOC_ALIGN_MASK) & \
~MALLOC_ALIGN_MASK))
  • MALLOC_ALIGN_MASK 为对齐掩码(值为 MALLOC_ALIGNMENT - 1,如 64 位系统为 0xf)。
  • 计算逻辑:将 MIN_CHUNK_SIZE 向上对齐到 MALLOC_ALIGNMENT 的倍数,确保内存地址对齐(如 64 位系统对齐 16 字节)。
  • 示例:若 MIN_CHUNK_SIZE0x20(32 字节),MALLOC_ALIGNMENT 为 16,则 MINSIZE0x20(已对齐)。

检查分配给用户的内存是否对齐

2 * SIZE_SZ 大小对齐

1
2
3
4
5
6
7
/* 检查 m 是否符合对齐要求 */
// MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1
#define aligned_OK(m) (((unsigned long) (m) & MALLOC_ALIGN_MASK) == 0)

#define misaligned_chunk(p) \
((uintptr_t)(MALLOC_ALIGNMENT == 2 * SIZE_SZ ? (p) : chunk2mem(p)) & \
MALLOC_ALIGN_MASK)

请求字节数判断

1
2
3
4
5
6
/*
检查请求是否过大,导致在填充和对齐后其值会绕零回环(即溢出)。为简化其他代码逻辑,边界值被设得足够低,确保加上最小尺寸(MINSIZE)后也不会发生绕零回环。
*/

#define REQUEST_OUT_OF_RANGE(req) \
((unsigned long) (req) >= (unsigned long) (INTERNAL_SIZE_T)(-2 * MINSIZE))

将用户请求内存大小转为实际分配内存大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 将用户请求的字节数转换为可用的块大小(内部版本) */
//MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1
#define request2size(req) \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) \
? MINSIZE \
: ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)

/* 相同,但同时执行参数检查 */

#define checked_request2size(req, sz) \
if (REQUEST_OUT_OF_RANGE(req)) { \
__set_errno(ENOMEM); \
return 0; \
} \
(sz) = request2size(req);
  • 步骤 1:判断请求大小 req 加上 SIZE_SZ(预留 size 字段空间)和对齐掩码后的总大小是否小于 MINSIZE
  • 步骤 2:若小于,直接返回 MINSIZE(强制分配最小块);否则,通过掩码对齐到 MALLOC_ALIGNMENT 的倍数。
  • 核心逻辑:确保用户数据区大小为 req,但整个 chunk 需包含头部(prev_sizesize)并满足对齐要求。

当一个 chunk 处于已分配状态时,它的物理相邻的下一个 chunk 的 prev_size 字段必然是无效的,故而这个字段就可以被当前这个 chunk 使用。这就是 ptmalloc 中 chunk 间的复用。具体流程如下

  1. 首先,利用 REQUEST_OUT_OF_RANGE 判断是否可以分配用户请求的字节大小的 chunk。
  2. 其次,需要注意的是用户请求的字节是用来存储数据的,即 chunk header 后面的部分。与此同时,由于 chunk 间复用,所以可以使用下一个 chunk 的 prev_size 字段。因此,这里只需要再添加 SIZE_SZ 大小即可以完全存储内容。
  3. 由于系统中所允许的申请的 chunk 最小是 MINSIZE,所以与其进行比较。如果不满足最低要求,那么就需要直接分配 MINSIZE 字节。
  4. 如果大于的话,因为系统中申请的 chunk 需要 2 * SIZE_SZ 对齐,所以这里需要加上 MALLOC_ALIGN_MASK 以便于对齐。

标记位相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* 当相邻的前一个内存块(chunk)被使用时,size字段会与PREV_INUSE进行按位或操作 */
#define PREV_INUSE 0x1

/* 提取前一个内存块的使用状态位 */
#define prev_inuse(p) ((p)->mchunk_size & PREV_INUSE)

/* 如果内存块是通过mmap()分配的,size字段会与IS_MMAPPED进行按位或操作 */
#define IS_MMAPPED 0x2

/* 检查内存块是否通过mmap()分配 */
#define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED)

/* 如果内存块来自非主分配区(non-main arena),size字段会与NON_MAIN_ARENA进行按位或操作。
该标志仅在必要时,在将内存块交给用户前设置。 */
#define NON_MAIN_ARENA 0x4

/* 检查内存块是否来自主分配区 */
#define chunk_main_arena(p) (((p)->mchunk_size & NON_MAIN_ARENA) == 0)

/* 将内存块标记为非主分配区 */
#define set_non_main_arena(p) ((p)->mchunk_size |= NON_MAIN_ARENA)

/*
提取内存块大小时需要屏蔽的位
注意:在设计上,对于不应该出现mmap内存块的宏,IS_MMAPPED标志不会从size字段中屏蔽。
这可以在有人意外扩展或修改此malloc实现时,通过核心转储(core dump)提供有效提示。
*/
#define SIZE_BITS (PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)

**获取 chunk size **

1
2
3
4
5
/* 获取内存块大小(忽略使用标志位) */
#define chunksize(p) (chunksize_nomask(p) & ~(SIZE_BITS))

/* 与 chunksize 功能类似,但不屏蔽 SIZE_BITS 标志位 */
#define chunksize_nomask(p) ((p)->mchunk_size)

获取下一个物理相邻的 chunk

1
2
/* 指向下一个物理相邻的 chunk 指针。 */
#define next_chunk(p) ((mchunkptr)(((char *) (p)) + chunksize(p)))

通过 chunksize(p) 获取当前块总大小(含头部),计算下一块起始地址(当前地址 + 块大小)

获取前一个 chunk 的信息

1
2
3
4
5
6
7
8
/* 获取 p 下方块的大小,仅当前一块空闲时有效。  */
#define prev_size(p) ((p)->mchunk_prev_size)

/* 设置 p 下方块的大小。仅当 !prev_inuse(P) 时有效。 */
#define set_prev_size(p, sz) ((p)->mchunk_prev_size = (sz))

/* 指向物理上前一个 malloc_chunk 的指针。仅当 !prev_inuse(P) 时有效 */
#define prev_chunk(p) ((mchunkptr)(((char *) (p)) - prev_size(p)))

当前 chunk 使用状态相关操作

1
2
3
4
5
6
7
8
9
10
11
/* 提取 p 的使用状态位 */
#define inuse(p) \
((((mchunkptr)(((char *) (p)) + chunksize(p)))->mchunk_size) & PREV_INUSE)

/* 提取 p 的使用状态位 */
#define inuse(p) \
((((mchunkptr)(((char *) (p)) + chunksize(p)))->mchunk_size) & PREV_INUSE)

/* 清除块的使用状态标记,不干扰其他标志 */
#define clear_inuse(p) \
((mchunkptr)(((char *) (p)) + chunksize(p)))->mchunk_size &= ~(PREV_INUSE)

设置 chunk 的 size 字段

1
2
3
4
5
6
7
8
9
10
/* 设置头部的大小,不干扰其使用标志位 */
#define set_head_size(p, s) \
((p)->mchunk_size = (((p)->mchunk_size & SIZE_BITS) | (s)))

/* 设置头部的大小/使用标志字段 */
#define set_head(p, s) ((p)->mchunk_size = (s))

/* 设置尾部的大小(仅当内存块未被使用时) */
#define set_foot(p, s) \
(((mchunkptr)((char *) (p) + (s)))->mchunk_prev_size = (s))

获取指定偏移的 chunk

1
2
/* 将 ptr + offset 位置的空间视为一个 chunk */
#define chunk_at_offset(p, s) ((mchunkptr)(((char *) (p)) + (s)))

指定偏移处 chunk 使用状态相关操作

1
2
3
4
5
6
7
8
9
10
11
/* 检查 p + s 位置的内存块的使用状态位 */
#define inuse_bit_at_offset(p, s) \
(((mchunkptr)(((char *) (p)) + (s)))->mchunk_size & PREV_INUSE)

/* 设置 p + s 位置的内存块为已使用状态 */
#define set_inuse_bit_at_offset(p, s) \
(((mchunkptr)(((char *) (p)) + (s)))->mchunk_size |= PREV_INUSE)

/* 清除 p + s 位置的内存块的使用状态标志 */
#define clear_inuse_bit_at_offset(p, s) \
(((mchunkptr)(((char *) (p)) + (s)))->mchunk_size &= ~(PREV_INUSE))

bin

概述

  • bin 本质是双向链表,用于链接空闲的chunk。每 bin的 header 包含两个指针:
    • fd(forward):指向下一个空闲 chunk
    • bk(backward):指向前一个空闲 chunk
  • 由于 malloc_chunk 结构体中天然包含 fdbk 字段(仅在空闲时使用),bin 的 header 可以直接作为链表的 “虚拟头结点”,方便插入、删除操作。

我们曾经说过,用户释放掉的 chunk 不会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区域中的空闲的 chunk。当用户再一次请求分配内存时,ptmalloc 分配器会试图在空闲的 chunk 中挑选一块合适的给用户。这样可以避免频繁的系统调用,降低内存分配的开销。

在具体的实现中,ptmalloc采用分箱式方法(bin)对空闲的chunk进行管理。首先,它会根据空闲的chunk的大小以及使用状态将chunk初步分为4类:fast bins,small bins,large bins,unsorted bin。在每类bin的内部仍然会有多个互不相关的链表来保存不同大小的chunk。

对于small bins,large bins,unsorted bin来说,ptmalloc将它们维护在同一个数组中。这些 bin 对应的数据结构在 malloc_state 中,如下

1
2
3
#define NBINS 128
/* 如上文所述打包的常规 bins */
mchunkptr bins[ NBINS * 2 - 2 ];

bins 主要用于索引不同 bin 的 fd 和 bk。

为了简化在双链接列表中的使用,每个 bin 的header都设置为 malloc_chunk 类型。这样可以避免 header 类型及其特殊处理。但是,为了节省空间和提高局部性,只分配 bin 的fd/bk 指针,然后使用repositioning tricks 将这些指针视为一个malloc_chunk*的字段。

以32位系统为例,bins前4项的含义如下

含义 bin1 的 fd/bin2 的 prev_size bin1 的 bk/bin2 的 size bin2 的 fd/bin3 的 prev_size bin2 的 bk/bin3 的 size
bin 下标 0 1 2 3

可以看到,bin2 的 prev_size、size 和 bin1 的 fd、bks是重合的。由于我们只会使用 fd 和 bk 来索引链表,所以该重合部分的数据其实记录的是 bin1 的 fd 、bk。也就是说,虽然后一个 bin 和前一个 bin 共用部分数据,但是其实记录的仍然是前一个 bin 的链表数据。通过这样的复用,可以节省空间。

数组中的 bin 依次如下

1.第一个为 unsorted bin(未排序的箱子),这里面的chunk没有进行排序,存储的chunk比较杂。

2.索引从2到63的 bin 称为 small bin,同一个 small bin 链表中的chunk的大小相同。两个相邻索引的 small bin 链表中的chunk大小相差的字节数为2个机器字长,即32位相差8字节,64位相差16字节。

3.small bins 后面的 bin 被称作 large bins。large bins中的每一个 bin 都包含一定范围内的 chunk,其中的 chunk 按 fd 指针的顺序从大到小排列。相同大小的chunk同样按照最近使用顺序排列。

此外,上述这些 bin 的排布都会遵循一个原则: 任意两个物理相邻的空闲 chunk 不能在一起

示例:若内存中有块 A(已用)、块 B(空闲)、块 C(空闲),则块 B 和 C 因物理相邻违反规则,需通过分配其他数据或合并为一个大块来调整。

需要注意的是,并不是所有的chunk被释放后就立即被放到 bin 中。ptmalloc 为了提高分配的速度,会把一些小的chunk先放到 fast bins 的容器内。而且,fastbin容器中的chunk的使用标记总是被置位的,所以不满足上面的原则。

bin 通用的宏如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct malloc_chunk *mbinptr;

/* addressing -- note that bin_at(0) does not exist */
#define bin_at(m, i) \
(mbinptr)(((char *) &((m)->bins[ ((i) -1) * 2 ])) - \
offsetof(struct malloc_chunk, fd))

/* analog of ++bin */
//获取下一个bin的地址
#define next_bin(b) ((mbinptr)((char *) (b) + (sizeof(mchunkptr) << 1)))

/* Reminders about list directionality within bins */
// 这两个宏可以用来遍历bin
// 获取 bin 的位于链表头的 chunk
#define first(b) ((b)->fd)
// 获取 bin 的位于链表尾的 chunk
#define last(b) ((b)->bk)

Fast Bin

大多数程序经常会申请以及释放一些比较小的内存块。如果将一些较小的chunk释放之后发现存在与之相邻的空闲的chunk并将它们进行合并,那么当下一次再次申请相应大小的chunk时,就需要对chunk进行分割,这样就大大降低了堆的利用效率。因为我们把大部分时间花在了合并、分割以及中间检查的过程中。因此,prmalloc中专门设计了 fast bin,对应的变量就是malloc state中的 fastbinsY

1
2
3
4
5
6
7
8
9
10
/*快速链表
一个存储最近释放的小块内存的链表数组。快速链表不是双向链表。将它们单向链接会更快,而且由于内存块永远不会从这些链表的中间移除,所以双向链接没有必要。此外,与常规链表不同,它们甚至不以先进先出(FIFO)顺序处理(它们使用更快的后进先出(LIFO)顺序),因为在通常使用快速链表的临时环境中,顺序并不是很重要。
快速链表中的内存块保持其使用位被设置,因此它们不能与其他空闲内存块合并。malloc_consolidate 函数会释放快速链表中的所有内存块,并将它们与其他空闲内存块合并*/
typedef struct malloc_chunk *mfastbinptr;

/*
This is in malloc_state.
/* Fastbins */
mfastbinptr fastbinsY[ NFASTBINS ];
*/

为了更加高效地利用 fast bin,glibc采用单向链表对其中的每个bin进行组织,并且每个bin采取LIFO策略(后进先出),最近释放的chunk会更早的被分配,所以会更加适合于局部性。也就是说,当用户需要的chunk的大小小于 fastbin 的最大大小时,ptmalloc会首先判断 fastbin 中相应的 bin 中是否有对应大小的空闲块,如果有的话,就会直接从这个 bin 中获取 chunk。如果没有的话,ptmalloc才会做接下来的一系列操作。

默认情况下(32位系统为例),fast bin中默认支持最大的chunk的数据空间大小为64字节。但是其可以支持的chunk的数据空间最大为0x80字节。除此之外,fast bin 最多可以支持的 bin 的个数为10个,从数据空间为8字节开始一直到80字节(这里说的大小是数据空间的大小,也就是除去prev_size和size字段部分的大小)

定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#define NFASTBINS (fastbin_index(request2size(MAX_FAST_SIZE)) + 1)

#ifndef DEFAULT_MXFAST
#define DEFAULT_MXFAST (64 * SIZE_SZ / 4)
#endif

/* 我们支持的最大快速堆块请求大小 */
#define MAX_FAST_SIZE (80 * SIZE_SZ / 4)

/*
由于max_fast的最低2位在尺寸比较中无关紧要,因此被用作标志位。
*/

/*
max_fast中存储的FASTCHUNKS_BIT标志表示可能存在一些快速箱块(fastbin chunk)。
当有块进入任何快速箱时,该标志设为真;仅在malloc_consolidate函数中清除该标志。

真值取反设计是为了在启动时have_fastchunks默认为真(因为静态变量初始化为零),简化初始化检查。
*/
// 判断分配区是否存在快速箱块,1表示不存在
#define FASTCHUNKS_BIT (1U)

#define have_fastchunks(M) (((M)->flags & FASTCHUNKS_BIT) == 0)
#define clear_fastchunks(M) catomic_or(&(M)->flags, FASTCHUNKS_BIT)
#define set_fastchunks(M) catomic_and(&(M)->flags, ~FASTCHUNKS_BIT)

/*
NONCONTIGUOUS_BIT标志表示MORECORE不会返回连续内存区域。
否则,在可能的情况下,会利用连续性合并连续MORECORE调用的结果。

初始值来自MORECORE_CONTIGUOUS,但如果使用mmap作为sbrk的替代方案,该值会动态改变。
*/
// 判断MORECORE是否返回连续内存区域:
// 主分配区的MORECORE对应sbrk(),默认返回连续虚拟地址空间;
// 非主分配区使用mmap()分配大块内存并切分,而mmap默认不保证地址连续,因此非主分配区默认非连续。
#define NONCONTIGUOUS_BIT (2U)

#define contiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) == 0)
#define noncontiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) != 0)
#define set_noncontiguous(M) ((M)->flags |= NONCONTIGUOUS_BIT)
#define set_contiguous(M) ((M)->flags &= ~NONCONTIGUOUS_BIT)

/*
若检测到分配区(arena)存在内存损坏,则设置ARENA_CORRUPTION_BIT标志。
此类分配区不再用于分配内存块,且检测到损坏前已分配的块不会被释放。
*/
#define ARENA_CORRUPTION_BIT (4U)

#define arena_is_corrupt(A) (((A)->flags & ARENA_CORRUPTION_BIT))
#define set_arena_corrupt(A) ((A)->flags |= ARENA_CORRUPTION_BIT)

/*
设置max_fast的值:
- 若参数为0,则使用极小值(避免影响正常分配)。
- 前置条件:当前无快速箱块存在。
- 设置该值时会清除fastchunk标志,但保留非连续标志(noncontiguous bit)。
*/
#define set_max_fast(s) \
global_max_fast = \
(((s) == 0) ? SMALLBIN_WIDTH : ((s + SIZE_SZ) & ~MALLOC_ALIGN_MASK))
#define get_max_fast() global_max_fast

ptmalloc 默认情况下会调用 set_max_fast(s) 将全局变量 global_max_fast 设置为 DEFAULT_MXFAST,也就是设置 fast bins 中 chunk 的最大值。当 MAX_FAST_SIZE 被设置为 0 时,系统就不会支持 fast bins 。

fastbin 的索引

1
2
3
4
5
6
7
#define fastbin(ar_ptr, idx) ((ar_ptr)->fastbinsY[ idx ])

/* offset 2 to use otherwise unindexable first 2 bins */
// chunk size=2*size_sz*(2+idx)
// 这里要减2,否则的话,前两个bin没有办法索引到。
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)

需要特别注意的是,fastbin 范围的 chunk 的 inuse 始终被置为 1。因此它们不会和其它被释放的 chunk 合并。

但是当释放的 chunk 与该 chunk 相邻的空闲 chunk 合并后的大小大于 FASTBIN_CONSOLIDATION_THRESHOLD 时,内存碎片可能比较多了,我们就需要把 fast bins 中的 chunk 都进行合并,以减少内存碎片对系统的影响。

1
2
3
4
5
/*
FASTBIN_CONSOLIDATION_THRESHOLD是free()函数中一个块的大小,当达到这个大小,就会触发对可能相邻的快速链表块进行自动合并。这是一种启发式方法,所以具体的值并没有太大影响。它被定义为默认修剪阈值的一半,这是一种折中的启发式策略,即只有在可能导致修剪的情况下才尝试合并。然而,它不能动态调整,因为即使不使用修剪,合并也能减少大块周围的碎片。
*/

#define FASTBIN_CONSOLIDATION_THRESHOLD (65536UL)

malloc_consolidate 函数可以将 fastbin 中所有能和其它 chunk 合并的 chunk 合并在一起。具体地参见后续的详细函数的分析。

1
2
3
/*
快速链表(fastbins)中的块会保持其已使用位(inuse bit)被设置,因此它们不能与其他空闲块合并。malloc_consolidate 函数会释放快速链表中的所有块,并将它们与其他空闲块合并。
*/

small bin

small bins中每个chunk的大小与其所在的 bin 的index的关系为: chunk_size = 2 * SIZE_SZ*index,具体如下

下标 SIZE_SZ=4(32 位) SIZE_SZ=8(64 位)
2 16 32
3 24 48
4 32 64
5 40 80
x 2*4*x 2*8*x
63 504 1008

small bins 中一共有62个循环双向链表,每个链表中存储的chunk大小都一致。比如对于32位系统来说,下标2对应的双向链表中存储的chunk大小均为16字节。每个链表都有链表头节点,这样可以方便对于链表内部结点的管理。此外,**small bins中每个 bin 对应的链表采用 FIFO 的规则(先进先出)**,所以同一个链表中先被释放的chunk会先被分配出去

small bin 相关的宏如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define NSMALLBINS 64
#define SMALLBIN_WIDTH MALLOC_ALIGNMENT
// 是否需要对small bin的下标进行纠正
#define SMALLBIN_CORRECTION (MALLOC_ALIGNMENT > 2 * SIZE_SZ)

#define MIN_LARGE_SIZE ((NSMALLBINS - SMALLBIN_CORRECTION) * SMALLBIN_WIDTH)
//判断chunk的大小是否在small bin范围内
#define in_smallbin_range(sz) \
((unsigned long) (sz) < (unsigned long) MIN_LARGE_SIZE)
// 根据chunk的大小得到small bin对应的索引。
#define smallbin_index(sz) \
((SMALLBIN_WIDTH == 16 ? (((unsigned) (sz)) >> 4) \
: (((unsigned) (sz)) >> 3)) + \
SMALLBIN_CORRECTION)

或许,大家会很疑惑,那 fast bin 与 small bin 中 chunk 的大小会有很大一部分重合啊,那 small bin 中对应大小的 bin 是不是就没有什么作用啊? 其实不然,fast bin 中的 chunk 是有可能被放到 small bin 中去的,我们在后面分析具体的源代码时会有深刻的体会。

Large Bin

large bins 中一共包括 63 个 bin,每个 bin 中的 chunk 的大小不一致,而是处于一定区间范围内。此外,这 63 个 bin 被分成了 6 组,每组 bin 中的 chunk 大小之间的公差一致,具体如下:

数量 公差
1 32 64B
2 16 512B
3 8 4096B
4 4 32768B
5 2 262144B
6 1 不限制

这里我们以32位平台的large bin 为例,第一个large bin 的其实chunk大小为512字节,位于第一组,所以该bin可以存储的chunk的大小范围为[512,512+64)。

关于large bin 的宏如下,这里我们以32位平台下,第一个large bin 的起始chunk大小为例,为512字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#define largebin_index_32(sz)                                                  \
(((((unsigned long) (sz)) >> 6) <= 38) \
? 56 + (((unsigned long) (sz)) >> 6) \
: ((((unsigned long) (sz)) >> 9) <= 20) \
? 91 + (((unsigned long) (sz)) >> 9) \
: ((((unsigned long) (sz)) >> 12) <= 10) \
? 110 + (((unsigned long) (sz)) >> 12) \
: ((((unsigned long) (sz)) >> 15) <= 4) \
? 119 + (((unsigned long) (sz)) >> 15) \
: ((((unsigned long) (sz)) >> 18) <= 2) \
? 124 + (((unsigned long) (sz)) >> 18) \
: 126)

#define largebin_index_32_big(sz) \
(((((unsigned long) (sz)) >> 6) <= 45) \
? 49 + (((unsigned long) (sz)) >> 6) \
: ((((unsigned long) (sz)) >> 9) <= 20) \
? 91 + (((unsigned long) (sz)) >> 9) \
: ((((unsigned long) (sz)) >> 12) <= 10) \
? 110 + (((unsigned long) (sz)) >> 12) \
: ((((unsigned long) (sz)) >> 15) <= 4) \
? 119 + (((unsigned long) (sz)) >> 15) \
: ((((unsigned long) (sz)) >> 18) <= 2) \
? 124 + (((unsigned long) (sz)) >> 18) \
: 126)

// XXX 目前尚不清楚,保持桶的宽度不变是否合适,还是也应该按系数 2 进行缩放。

#define largebin_index_64(sz) \
(((((unsigned long) (sz)) >> 6) <= 48) \
? 48 + (((unsigned long) (sz)) >> 6) \
: ((((unsigned long) (sz)) >> 9) <= 20) \
? 91 + (((unsigned long) (sz)) >> 9) \
: ((((unsigned long) (sz)) >> 12) <= 10) \
? 110 + (((unsigned long) (sz)) >> 12) \
: ((((unsigned long) (sz)) >> 15) <= 4) \
? 119 + (((unsigned long) (sz)) >> 15) \
: ((((unsigned long) (sz)) >> 18) <= 2) \
? 124 + (((unsigned long) (sz)) >> 18) \
: 126)

#define largebin_index(sz) \
(SIZE_SZ == 8 ? largebin_index_64(sz) : MALLOC_ALIGNMENT == 16 \
? largebin_index_32_big(sz) \
: largebin_index_32(sz))

Unsorted Bin

unsorted bin 可以视为空闲 chunk 回归其所属 bin 之前的缓冲区。

其在 glibc 中具体的说明如下

1
2
3
4
5
/*
未排序块
所有因块分割产生的剩余部分,以及所有返回的块,
首先会被放入 “未排序” 桶中。然后,在 malloc 为它们提供一次在入桶前被使用的机会后,这些块会被放入常规桶中。所以,基本上,未排序块列表就像一个队列,块在 free(以及 malloc_consolidate)操作中被放入该列表,而在 malloc 操作中被取出(要么被使用,要么被放入桶中)。未排序块永远不会设置 NON_MAIN_ARENA 标志,所以在进行大小比较时无需考虑该标志。
*/

在 ptmalloc 内存管理机制中,NON_MAIN_ARENA flag 是malloc_chunk结构体中size字段的一个标志位,用于表示当前chunk是否属于主线程的分配区(main arena

从下面的宏我们可以看出

1
2
/* 无法索引的 1 号 bin 用于存放未排序的块。 */
#define unsorted_chunks(M) (bin_at(M, 1))

unsorted bin 处于我们之前所说的 bin 数组下标 1 处。故而 unsorted bin 只有一个链表。unsorted bin 中的空闲 chunk 处于乱序状态,主要有两个来源

  • 当一个较大的 chunk 被分割成两半后,如果剩下的部分大于 MINSIZE,就会被放到 unsorted bin 中。
  • 释放一个不属于 fast bin 的 chunk,并且该 chunk 不和 top chunk 紧邻时,该 chunk 会被首先放到 unsorted bin 中。关于 top chunk 的解释,请参考下面的介绍。

此外,Unsorted Bin 在使用的过程中,采用的遍历顺序是 FIFO 。

common macro

这里介绍一些通用的宏。

根据 chunk 的大小统一地获得 chunk 所在的索引

1
2
#define bin_index(sz)                                                          \
((in_smallbin_range(sz)) ? smallbin_index(sz) : largebin_index(sz))

Top Chunk

glibc 中对于Top chunk 的描述如下

1
2
3
4
5
6
7
/*
顶级块
最顶端的可用块(即与可用内存末尾相邻的块)会被特殊处理。它从不包含在任何 bin 中,只有在没有其他可用块时才会被使用,并且如果它非常大(见 M_TRIM_THRESHOLD),就会被释放回系统。由于 top 最初指向其自身大小为零的 bin,因此在第一次 malloc 请求时会强制进行扩展,这样我们就无需在 malloc 中编写任何特殊代码来检查它是否存在。但是,在从系统获取内存时,我们仍然需要这样做,所以在初始化和第一次调用 sysmalloc 之间的时间段内,我们让 initial_top 将该 bin 视为一个合法但不可用的块。(这有点微妙,因为在此时间段内,它还依赖于前两个字为零。)
*/

/* 方便的是,在首次调用时,未分类的 bin 可用作虚拟的 top chunk。 */
#define initial_top(M) (unsorted_chunks(M))

程序第一次进行malloc的时候,heap会被分为两块,一块给用户,剩下的那块就是 top chunk 。其实所谓的 top chunk 就是处于当前堆的物理地址最高的chunk。这个chunk不属于任何一个bin,它的作用在于当所有的bin都无法满足用户请求的大小时,如果其大小不小于指定的大小,就进行分配,并将剩下部分作为新的 top chunk 。否则就对heap进行扩展后再进行分配。在main arena中通过sbrk扩展heap,而在thead arena中通过mmap分配新的heap。

需要注意的是,top chunk 的prev_inuse 比特位始终为1,否则其前面的 chunk 就会被合并到 top chunk 中。

初始情况下,我们可以将 unsorted chunk 作为 top chunk。

last remainder

在用户使用 malloc 请求分配内存时,ptmalloc2 找到的 chunk 可能并不和申请的内存大小一致,这时候就将分割之后的剩余部分称之为 last remainder chunk,unsorted bin 也会存这一块。top chunk 分割剩下的部分不会作为 last remainder chunk。

宏观结构

arena

arena(分配区) 是用于管理内存分配的重要结构,主要与多线程环境下的内存管理相关。

在我们之前介绍的例子中,无论是主线程还是新创建的线程,在第一次申请内存时,都会有独立的 arena。那么会不会每个线程都有独立的 arena 呢?下面我们就具体介绍。

arena 数量

对于不同系统,arena 数量的约束如下

1
2
3
4
For 32 bit systems:
Number of arena = 2 * number of cores.
For 64 bit systems:
Number of arena = 8 * number of cores.

显然,不是每一个线程都会有对应的 arena。至于为什么 64 位系统,要那么设置,我也没有想明白。此外,因为每个系统的核数是有限的,当线程数大于核数的二倍(超线程技术)时,就必然有线程处于等待状态,所以没有必要为每个线程分配一个 arena。

arena 分配规则

  • 线程首次分配内存时,优先使用主线程 Arena;若竞争激烈,会创建新的 Thread Arena。
  • 小内存分配(如小于 mmap_threshold)使用堆(sbrk),大内存分配直接通过 mmap 创建独立堆块。

区别

与 thread 不同的是,main_arena 并不在申请的 heap 中,而是一个全局变量,在 libc.so 的数据段。

heap_info

程序刚开始执行时,每个线程是没有 heap 区域的。当其申请内存时,就需要一个结构来记录对应的信息,而 heap_info 的作用就是这个。而且当该 heap 的资源被使用完后,就必须得再次申请内存了。此外,一般申请的 heap 是不连续的,因此需要记录不同 heap 之间的链接结构。

该数据结构是专门为从 Memory Mapping Segment(内存映射段) 处申请的内存准备的,即为非主线程准备的。

主线程可以通过 sbrk() 函数扩展 program break location(进程堆的当前边界) 获得(直到触及 Memory Mapping Segment),只有一个 heap,没有 heap_info 数据结构。

heap_info 的主要结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define HEAP_MIN_SIZE (32 * 1024)
#ifndef HEAP_MAX_SIZE
# ifdef DEFAULT_MMAP_THRESHOLD_MAX
# define HEAP_MAX_SIZE (2 * DEFAULT_MMAP_THRESHOLD_MAX)
# else
# define HEAP_MAX_SIZE (1024 * 1024) /* must be a power of two */
# endif
#endif

/* HEAP_MIN_SIZE 和 HEAP_MAX_SIZE 限制了为多线程程序动态创建的通过 mmap() 映射的堆的大小。最大大小必须是 2 的幂次方,以便快速确定某个内存块属于哪个堆。它应该比 mmap 阈值大得多,这样大小刚好低于该阈值的请求可以在不创建过多堆的情况下得到满足。 */

/***************************************************************************/

/* 堆是一个连续的内存区域,用于存放(可合并的)malloc块。它通过mmap()分配,并且总是从与HEAP_MAX_SIZE对齐的地址开始。 */

typedef struct _heap_info
{
mstate ar_ptr; /* 此堆的内存区域。 */
struct _heap_info *prev; /* 前一个堆 */
size_t size; /* 当前大小(以字节为单位) */
size_t mprotect_size; /* 已通过PROT_READ|PROT_WRITE设置内存保护的字节大小。 */
/* 确保以下数据正确对齐,特别是sizeof(heap_info) + 2 * SIZE_SZ是MALLOC_ALIGNMENT的倍数。 */
char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

该结构主要是描述堆的基本信息,包括

  • 堆对应的 arena 的地址
  • 由于一个线程申请一个堆之后,可能会使用完,之后就必须得再次申请。因此,一个线程可能会有多个堆。prev 即记录了上一个 heap_info 的地址。这里可以看到每个堆的 heap_info 是通过单向链表进行链接的。
  • size 表示当前堆的大小
  • 最后一部分确保对齐

pad 里负数的缘由是什么呢?

pad 是为了确保分配的空间是按照 MALLOC_ALIGN_MASK+1 (记为 MALLOC_ALIGN_MASK_1) 对齐的。在 pad 之前该结构体一共有 6 个 SIZE_SZ 大小的成员, 为了确保 MALLOC_ALIGN_MASK_1 字节对齐, 可能需要进行 pad,不妨假设该结构体的最终大小为 MALLOC_ALIGN_MASK_1*x,其中 x 为自然数,那么需要 pad 的空间为 MALLOC_ALIGN_MASK_1 * x - 6 * SIZE_SZ = (MALLOC_ALIGN_MASK_1 * x - 6 * SIZE_SZ) % MALLOC_ALIGN_MASK_1 = 0 - 6 * SIZE_SZ % MALLOC_ALIGN_MASK_1=-6 * SIZE_SZ % MALLOC_ALIGN_MASK_1 = -6 * SIZE_SZ & MALLOC_ALIGN_MASK

看起来该结构应该是相当重要的,但是如果如果我们仔细看完整个 malloc 的实现的话,就会发现它出现的频率并不高。

malloc_state

该结构用于管理堆,记录每个 arena 当前申请的内存的具体状态,比如说是否有空闲 chunk,有什么大小的空闲 chunk 等等。无论是 thread arena 还是 main arena,它们都只有一个 malloc state 结构。由于 thread 的 arena 可能有多个,malloc state 结构会在最新申请的 arena 中。

注意,main arena 的 malloc_state 并不是 heap segment(堆段)的一部分,而是一个全局变量,存储在 libc.so 的数据段。

其结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
struct malloc_state {
/* 序列化访问(用于保证多线程下对该结构体访问的同步,避免竞争条件)。
__libc_lock_define 是用于定义互斥锁相关的宏,这里会为该 malloc_state 实例创建对应的互斥锁,
确保在多线程环境中,对该结构体的操作是串行化的,防止并发访问导致的数据混乱 */
__libc_lock_define(, mutex);

/* 标志位(以前包含在 max_fast 相关逻辑里,现在单独用 flags 字段)。
用于记录一些 malloc 内部的状态标记,比如是否有快速 bin(fastbin)相关的特殊状态等 */
int flags;

/* 快速 bin(Fastbins)
这是一个数组,每个元素是指向 malloc_chunk 类型的指针,用于存储最近释放的小内存块。
快速 bin 采用单链表结构,且使用后进先出(LIFO)的策略,能加快小内存块的分配和释放速度,
这些小内存块不会立即与其他空闲块合并,以此提升针对小内存操作的效率 */
mfastbinptr fastbinsY[ NFASTBINS ];

/* 最顶部块(Topmost chunk)的起始地址 —— 不会被常规地存放在某个 bin 里
指的是当前分配区(arena)中,处于最高地址位置的空闲块。当其他 bin 无法满足内存分配请求时,
会尝试从这个 top chunk 中分割内存。它是堆空间扩展和分配的一个重要“边界参考”,
当需要新增堆空间时,top chunk 若不足,可能会触发向系统申请更多内存(比如通过 sbrk 调整 program break 来扩展堆 ),然后更新 top chunk */
mchunkptr top;

/* 最近一次分割小内存请求后剩余的部分
当对一个小内存请求进行处理,分割出满足需求的内存块后,若剩下的部分还满足一定条件(比如大小合规等 ),
就会把这部分剩余的块用 last_remainder 指向,方便后续可能的小内存分配复用,减少内存碎片 */
mchunkptr last_remainder;

/* 常规 bin(Normal bins),按前述规则排列(指按照 small bins、large bins 等分类规则组织 )
这是一个数组,用于存储不同大小分类的空闲块链表。包括 small bins(存储特定范围小尺寸空闲块,同尺寸块链表按先进先出 FIFO 管理 )、
large bins(存储较大尺寸空闲块,按一定大小区间分组管理 )以及 unsorted bin(未分类 bin,用于临时存放释放的块,
后续会再整理到对应大小的常规 bin 中 )等,通过这种分箱(bin)管理方式,提升内存分配时查找合适空闲块的效率 */
mchunkptr bins[ NBINS * 2 - 2 ];

/* bin 的位图(Bitmap of bins),用于加快判断某个给定 bin 是否绝对为空的过程。
本质是用位来标记对应 bin 的状态,比如某一位为 1 表示对应的 bin 可能有空闲块,为 0 表示大概率为空,
这样在遍历查找空闲块前,可先通过位图快速筛选,减少不必要的遍历,提升内存分配效率 */
unsigned int binmap[ BINMAPSIZE ];

/* 链表,指向另一个分配区(arena)
malloc 为了应对多线程等场景,会有多个分配区(arena),通过 next 指针将这些分配区串联起来,
方便进行统一的管理、遍历等操作,比如在需要寻找可用分配区时,可沿着 next 指针遍历查找 */
struct malloc_state *next;

/* 用于空闲分配区(free arenas)的链表。对该字段的访问由 arena.c 中的 free_list_lock 进行序列化(同步控制 )
当某个分配区当前没有线程在使用,处于空闲状态时,会被加入到这个由 next_free 串联的链表中,
后续需要新分配区时,可从该链表中选取,实现分配区的复用,减少频繁向系统申请新资源的开销 */
struct malloc_state *next_free;

/* 附加到该分配区的线程数量。若分配区在空闲链表(free list)中,则为 0。
对该字段的访问由 arena.c 中的 free_list_lock 进行序列化(同步控制 )
用于跟踪有多少线程正在使用这个分配区,当 attached_threads 减为 0 且该分配区不再有其他使用需求时,
可将其移入空闲分配区链表(next_free 串联的链表 ),以便后续复用 */
INTERNAL_SIZE_T attached_threads;

/* 该分配区从系统分配的内存总量 */
INTERNAL_SIZE_T system_mem;
/* 该分配区从系统分配内存的最大量
用于记录该分配区历史上向系统申请内存的峰值,可辅助进行内存使用情况的监控、
调整内存分配策略(比如是否需要向系统归还部分内存等 ) */
INTERNAL_SIZE_T max_system_mem;
};
  • __libc_lock_define(, mutex);
    • 该变量用于控制程序串行访问同一个分配区,当一个线程获取了分配区之后,其它线程要想访问该分配区,就必须等待该线程分配完成后才能够使用。
  • flags
    • flags 记录了分配区的一些标志,比如 bit0 记录了分配区是否有 fast bin chunk ,bit1 标识分配区是否能返回连续的虚拟地址空间。具体如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
FASTCHUNKS_BIT 存储在 max_fast 中,用于指示可能存在一些快速 bin(fastbin)块。当有块被放入任意一个快速 bin 时,该位会被置为 true(实际逻辑里是通过对应操作体现置位含义 ),且只有在 malloc_consolidate(malloc 中的内存合并函数,用于将快速 bin 等中的块进行合并操作 )中才会被清除。
这里采用了真值取反的方式,这样在程序启动时(因为静态变量会被初始化为零填充 ),have_fastchunks 宏的判断结果会为 true,从而简化了初始化检查的逻辑。
*/
#define FASTCHUNKS_BIT (1U)

// 判断是否存在快速 bin 块,由于前面说的真值取反,当 flags 与 FASTCHUNKS_BIT 按位与结果为 0 时,表示存在快速 bin 块
#define have_fastchunks(M) (((M)->flags & FASTCHUNKS_BIT) == 0)
// 将 FASTCHUNKS_BIT 对应的位设置为 1,用于标记相关状态(结合前面注释理解置位后的逻辑含义 ),catomic_or 是用于原子化按位或操作的函数(保证多线程等场景下操作的原子性 )
#define clear_fastchunks(M) catomic_or(&(M)->flags, FASTCHUNKS_BIT)
// 将 FASTCHUNKS_BIT 对应的位设置为 0,catomic_and 是用于原子化按位与操作的函数,这里通过与 ~FASTCHUNKS_BIT 按位与来实现对应位清 0
#define set_fastchunks(M) catomic_and(&(M)->flags, ~FASTCHUNKS_BIT)

/*
NONCONTIGUOUS_BIT 用于指示 MORECORE(用于扩展堆内存的内部函数,类似 sbrk 功能 )不会返回连续的内存区域。否则(即该位为 0 时 ),在可能的情况下,会利用内存的连续性,将连续调用 MORECORE 得到的内存结果合并到一起。
该位的初始值由 MORECORE_CONTIGUOUS(决定 MORECORE 是否返回连续内存的初始标识 )决定,但如果 mmap(一种内存映射的系统调用,可用于分配内存,这里作为 sbrk 的替代方式 )被用作 sbrk 的替代时,该位会被动态修改。
*/
#define NONCONTIGUOUS_BIT (2U)

// 判断内存区域是否连续,当 flags 与 NONCONTIGUOUS_BIT 按位与结果为 0 时,表示内存是连续的
#define contiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) == 0)
// 判断内存区域是否不连续,当 flags 与 NONCONTIGUOUS_BIT 按位与结果非 0 时,表示内存不连续
#define noncontiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) != 0)
// 将 NONCONTIGUOUS_BIT 对应的位设置为 1,标记内存为非连续状态
#define set_noncontiguous(M) ((M)->flags |= NONCONTIGUOUS_BIT)
// 将 NONCONTIGUOUS_BIT 对应的位设置为 0,标记内存为连续状态
#define set_contiguous(M) ((M)->flags &= ~NONCONTIGUOUS_BIT)

/* 如果在分配区(arena,malloc 中用于管理内存分配的一个逻辑区域 )上检测到内存损坏,就会设置 ARENA_CORRUPTION_BIT。
这样的分配区将不再用于分配内存块。在检测到损坏前,该分配区中已分配的内存块不会被释放(防止进一步的错误或数据混乱 )。
*/
#define ARENA_CORRUPTION_BIT (4U)

// 判断分配区是否已损坏,检查 flags 中 ARENA_CORRUPTION_BIT 对应的位是否被设置
#define arena_is_corrupt(A) (((A)->flags & ARENA_CORRUPTION_BIT))
// 将 ARENA_CORRUPTION_BIT 对应的位设置为 1,标记分配区已损坏
#define set_arena_corrupt(A) ((A)->flags |= ARENA_CORRUPTION_BIT)
  • fastbinsY[NFASTBINS]
    • 存放每个 fast chunk 链表头部的指针
  • top
    • 指向分配区的 top chunk
  • last_reminder
    • 最新的 chunk 分割之后剩下的那部分
  • bins
    • 用于存储 unstored bin,small bins 和 large bins 的 chunk 链表。
  • binmap
    • ptmalloc 用一个 bit 来标识某一个 bin 中是否包含空闲 chunk

malloc_par

在 glibc 的 ptmalloc 内存分配实现里,malloc_par 是用于管理堆内存分配器参数的结构体,作用是为 ptmalloc 提供全局的、影响内存分配行为的配置与状态记录 ,以下从关键层面详细解释:

1. 角色与定位

malloc_par参数管理中枢,为 ptmalloc 分配器的运行提供全局参数支撑。不同于 malloc_state(管理单个分配区 arena 的状态 ),malloc_par静态全局唯一的,影响整个进程中 ptmalloc 的行为逻辑,决定内存如何向系统申请、释放,以及空闲内存如何组织等核心流程。

2. 关联核心机制

它与 ptmalloc 关键组件深度绑定:

  • arena(分配区 )协同:多个 malloc_state(每个 arena 对应一个 )共享 malloc_par 的全局参数,比如内存申请阈值、对齐规则等,确保不同线程 / 分配区遵循统一策略。
  • 控制内存分配策略:像 mmap 分配阈值(超过多大内存直接用 mmap 而非堆 )、trim 阈值(堆内存何时归还给系统 )等,都由 malloc_par 相关参数决定,直接影响内存分配效率和系统资源占用。

3. 典型参数与功能(以常见逻辑为例 )

虽具体定义依赖 glibc 版本,但通常涵盖:

  • 内存阈值类

    • mmap_threshold:超过该大小的内存申请,ptmalloc 可能直接调用 mmap 分配独立内存块(而非从堆 arena 分配 ),避免堆碎片化。
    • trim_threshold:当堆中空闲内存超过此值,ptmalloc 会尝试调用 munmap 或调整堆边界,将内存归还给系统,优化内存占用。
  • 对齐与大小规则
    记录 MALLOC_ALIGNMENT(内存块对齐字节数,保证内存访问效率 )、MIN_CHUNK_SIZE(最小可分配内存块大小 )等基础规则,约束 chunk(内存块 )的创建与管理。

  • 调试与统计辅助
    可能包含内存分配统计标记、调试模式开关(如开启额外检查 ),辅助排查内存泄漏、溢出等问题(虽 ptmalloc 自身调试工具弱,但这些参数可配合外部工具分析 )。

4. 实际意义

  • 统一配置:让 ptmalloc 全局行为可控,开发者(或系统 )可通过调整 malloc_par 参数(部分支持运行时调整 ),适配不同场景(如高并发小内存分配、大内存低频申请 )。
  • 性能优化:合理设置 mmap_thresholdtrim_threshold 等,能减少堆碎片、降低系统调用开销,提升程序内存使用效率。
  • 问题排查:结合其参数与状态,可辅助分析内存分配异常(如频繁 mmap 导致的内存离散、堆内存过度占用未释放等 )。

简单说,malloc_parptmalloc 的 “全局配置中心”,默默调控着内存分配的规则、策略与资源交互,是理解 glibc 内存管理底层逻辑的关键结构体之一 。若想深入,建议结合具体 glibc 版本(如 2.23 等 )的源码,追踪 malloc_par 定义及与 arenachunk 交互的流程 。

深入理解Ptmalloc2

仔细想一下,任何堆的实现都需要从以下两个角度考虑相应的问题

  • 宏观角度
    • 创建堆
    • 堆初始化
    • 删除堆
  • 微观角度
    • 申请内存块
    • 释放内存块

当然,这些都是比较高层面的想法,不同的堆的底层实现会有所不同。

基础操作

unlink 用来将一个双向链表(只存储空闲的 chunk)中的一个元素取出来,可能在以下地方使用

  • malloc

    • 从恰好大小合适的 large bin 中获取 chunk。
      • 这里需要注意的是 fastbin 与 small bin 就没有使用 unlink,这就是为什么漏洞会经常出现在它们这里的原因。
      • 依次遍历处理 unsorted bin 时也没有使用 unlink 。
    • 从比请求的 chunk 所在的 bin 大的 bin 中取 chunk。
  • free

    • 后向合并,合并物理相邻低地址空闲 chunk。
    • 前向合并,合并物理相邻高地址空闲 chunk(除了 top chunk)。
  • malloc_consolidate

    • 后向合并,合并物理相邻低地址空闲 chunk。
    • 前向合并,合并物理相邻高地址空闲 chunk(除了 top chunk)。
  • realloc

    • 前向扩展,合并物理相邻高地址空闲 chunk(除了 top chunk)。

由于 unlink 使用非常频繁,所以 unlink 被实现为了一个宏,如下

所用到的相关宏或函数 解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#__builtin_expect(EXPRESSION, EXPECTED_VALUE)
/*
EXPRESSION:条件表达式(如 cond)。
EXPECTED_VALUE:预期的表达式结果(通常为 0 或 1,对应 “假” 或 “真”)。
*/

malloc_printerr(stderr);
/*
malloc_printerr 是 C 标准库中 malloc 相关的错误处理函数,主要用于处理内存分配失败时的错误信息输出。
*/
#define in_smallbin_range(size) ((size) < SMALLBIN_MAX_SIZE)
/*
SMALLBIN_MAX_SIZE 是小 bins 的最大允许大小(如 64 或 128,取决于 glibc 版本)。
若堆块大小 小于 该阈值,则返回 true,属于小 bins;否则属于大 bins。lloc_printerr 是 C 标准库中 malloc 相关的错误处理函数,主要用于处理内存分配失败时的错误信息输出。
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* Take a chunk off a bin list */
// p 代表一个空闲的内存块是 unlink 操作的目标对象
#define unlink(AV, P, BK, FD) { \
/* 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致。*/
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \
malloc_printerr ("corrupted size vs. prev_size"); \
FD = P->fd; \
BK = P->bk; \
// 防止攻击者简单篡改空闲的 chunk 的 fd 与 bk 来实现任意写的效果。
/*FD:P 的前一个块(P->fd,forward pointer)。
BK:P 的后一个块(P->bk,backward pointer)。*/
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \
else { \
FD->bk = BK; \
BK->fd = FD; \
// 下面主要考虑 P 对应的 nextsize 双向链表的修改
if (!in_smallbin_range (chunksize_nomask (P)) \
// 如果P->fd_nextsize为 NULL,表明 P 未插入到 nextsize 链表中。
// 那么其实也就没有必要对 nextsize 字段进行修改了。
// 这里没有去判断 bk_nextsize 字段,可能会出问题。
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \
// 类似于小的 chunk 的检查思路
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \
malloc_printerr (check_action, \
"corrupted double-linked list (not small)", \
P, AV); \
// 这里说明 P 已经在 nextsize 链表中了。
// 如果 FD 没有在 nextsize 链表中
if (FD->fd_nextsize == NULL) { \
// 如果 nextsize 串起来的双链表只有 P 本身,那就直接拿走 P
// 令 FD 为 nextsize 串起来的
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else { \
// 否则我们需要将 FD 插入到 nextsize 形成的双链表中
FD->fd_nextsize = P->fd_nextsize; \
FD->bk_nextsize = P->bk_nextsize; \
P->fd_nextsize->bk_nextsize = FD; \
P->bk_nextsize->fd_nextsize = FD; \
} \
} else { \
// 如果在的话,直接拿走即可
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \
} \
} \
} \
}

这里我们以 small bin 的 unlink 为例子介绍一下。对于 large bin 的 unlink,与其类似,只是多了一个 nextsize 的处理。

1

可以看出, P 最后的 fd 和 bk 指针并没有发生变化,但是当我们去遍历整个双向链表时,已经遍历不到对应的链表了。这一点没有变化还是很有用处的,因为我们有时候可以使用这个方法来泄漏地址

  • libc 地址
    • P 位于双向链表头部,bk 泄漏
    • P 位于双向链表尾部,fd 泄漏
    • 双向链表只包含一个空闲 chunk 时,P 位于双向链表中,fd 和 bk 均可以泄漏
  • 泄漏堆地址,双向链表包含多个空闲 chunk
    • P 位于双向链表头部,fd 泄漏
    • P 位于双向链表中,fd 和 bk 均可以泄漏
    • P 位于双向链表尾部,bk 泄漏

注意

  • 这里的头部指的是 bin 的 fd 指向的 chunk,即双向链表中最新加入的 chunk。
  • 这里的尾部指的是 bin 的 bk 指向的 chunk,即双向链表中最先加入的 chunk。

同时,无论是对于 fd,bk 还是 fd_nextsize ,bk_nextsize,程序都会检测 fd 和 bk 是否满足对应的要求。

1
2
3
4
5
6
7
8
9
10
// fd bk
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \

// next_size related
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \
malloc_printerr (check_action, \
"corrupted double-linked list (not small)", \
P, AV);

看起来似乎很正常。我们以 fd 和 bk 为例,P 的 forward chunk 的 bk 很自然是 P ,同样 P 的 backward chunk 的 fd 也很自然是 P 。如果没有做相应的检查的话,我们可以修改 P 的 fd 与 bk,从而可以很容易地达到任意地址写的效果。关于更加详细的例子,可以参见利用部分的 unlink 。

注意:堆的第一个 chunk 所记录的 prev_inuse 位默认为 1。

malloc_printerr

在 glibc malloc 时检测到错误的时候,会调用 malloc_printerr 函数。

1
2
3
4
static void malloc_printerr(const char *str) {
__libc_message(do_abort, "%s\n", str);
__builtin_unreachable();
}

主要会调用 __libc_message 来执行abort 函数,如下

1
2
3
4
5
6
7
if ((action & do_abort)) {
if ((action & do_backtrace))
BEFORE_ABORT(do_abort, written, fd);

/* Kill the application. */
abort();
}

abort 函数里,在 glibc 还是 2.23 版本时,会 fflush stream。

1
2
3
4
5
6
/* 刷新所有流。我们现在不能关闭它们,因为用户可能已经为 SIGABRT 注册了一个处理程序。  */
if (stage == 1)
{
++stage;
fflush (NULL);
}

堆初始化

堆初始化是在用户第一次申请内存时执行 malloc_consolidate 再执行 malloc_init_state 实现的。这里不做过多讲解。可以参见 malloc_state 相关函数。

申请内存块

__libc_malloc

一般我们会使用 malloc 函数来申请内存块,可是当仔细看 glibc 的源码实现时,其实并没有 malloc 函数。其实该函数真正调用的是 __libc_malloc 函数。为什么不直接写个 malloc 函数呢,因为有时候我们可能需要不同的名称。此外,__libc_malloc 函数只是用来简单封装 _int_malloc 函数。_int_malloc 才是申请内存块的核心。下面我们来仔细分析一下具体的实现。

该函数会首先检查是否有内存分配函数的钩子函数(__malloc_hook),这个主要用于用户自定义的堆分配函数,方便用户快速修改堆分配函数并进行测试。这里需要注意的是,用户申请的字节一旦进入申请内存函数中就变成了无符号整数

原子操作atomic_forced_read 是一个原子读取宏,确保读取 __malloc_hook 的值时不会被其他线程打断,保证数据一致性。

1
void* (*__malloc_hook)(size_t size, const void* caller);
  • size:申请的内存大小。
  • caller:调用 malloc 的函数地址(可选,取决于实现)。
  • 返回值:需返回分配好的内存地址,或 NULL 表示分配失败。
  • ar_ptr 通常是指向 mstate(在 glibc 源码中定义为 struct malloc_state) 类型的指针用于表示 内存分配器的 arena(内存区域)
1
2
3
4
5
6
7
8
// _int_malloc 的封装器
void *__libc_malloc(size_t bytes) {
mstate ar_ptr; // 声明一个指向 arena 的指针
void * victim;
// 检查是否有内存分配钩子,如果有,调用钩子并返回.
void *(*hook)(size_t, const void *) = atomic_forced_read(__malloc_hook);
if (__builtin_expect(hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS(0));

接着会寻找一个 arena 来试图分配内存。

1
arena_get(ar_ptr, bytes);

然后调用 _int_malloc 函数去申请对应的内存。

1
victim = _int_malloc(ar_ptr, bytes);

如果分配失败的话,ptmalloc 会尝试再去寻找一个可用的 arena,并分配内存。

1
2
3
4
5
6
7
/*若分配失败且之前成功获取过 Arena(即ar_ptr != NULL),则通过arena_get_retry获取另一个 Arena,并再次调用_int_malloc重试分配。
如果第一次调用arena_get时未找到任何可用的 Arena(例如程序未初始化 Arena 或内存耗尽),则无需尝试其他 Arena,直接跳过重试逻辑。 */
if (!victim && ar_ptr != NULL) {
LIBC_PROBE(memory_malloc_retry, 1, bytes);
ar_ptr = arena_get_retry(ar_ptr, bytes);
victim = _int_malloc(ar_ptr, bytes);
}

如果申请到了 arena,那么在退出之前还得解锁。

1
if (ar_ptr != NULL) __libc_lock_unlock(ar_ptr->mutex);

判断目前的状态是否满足以下条件

  • 要么没有申请到内存
  • 要么是 mmap 的内存
  • 要么申请到的内存必须在其所分配的 arena 中
1
2
assert(!victim || chunk_is_mmapped(mem2chunk(victim)) ||
ar_ptr == arena_for_chunk(mem2chunk(victim)));

最后返回内存。

1
2
    return victim;
}

_int_malloc

_int_malloc 是内存分配的核心函数,其核心思路有如下

  1. 它根据用户申请的内存块大小以及相应大小 chunk 通常使用的频度(fastbin chunk, small chunk, large chunk),依次实现了不同的分配方法。
  2. 它由小到大依次检查不同的 bin 中是否有相应的空闲块可以满足用户请求的内存。
  3. 当所有的空闲 chunk 都无法满足时,它会考虑 top chunk。
  4. 当 top chunk 也无法满足时,堆分配器才会进行内存块申请。

在进入该函数后,函数立马定义了一系列自己需要的变量,并将用户申请的内存大小转换为内部的 chunk 大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

static void *_int_malloc(mstate av, size_t bytes) {
INTERNAL_SIZE_T nb; /* 规范化请求大小 */
unsigned int idx; /* 关联的bin索引 */
mbinptr bin; /* 关联的bin */

mchunkptr victim; /* 检查/选择的块 */
INTERNAL_SIZE_T size; /* 块的大小 */
int victim_index; /* 块的bin索引 */

mchunkptr remainder; /* 分割产生的剩余块 */
unsigned long remainder_size; /* 剩余块的大小 */

unsigned int block; /* 位图遍历变量 */
unsigned int bit; /* 位图遍历位 */
unsigned int map; /* binmap的当前字 */

mchunkptr fwd; /* 链接用的临时变量 */
mchunkptr bck; /* 链接用的临时变量 */

const char *errstr = NULL;

/*
通过添加SIZE_SZ字节的开销将请求大小转换为内部形式,可能还需要更多字节以满足必要的对齐要求,
并确保大小至少为MINSIZE(可分配的最小尺寸)。此外,checked_request2size会捕获(返回0)
那些在填充和对齐后过大而导致回绕为零的请求大小。
*/

checked_request2size(bytes, nb);

arena

1
2
3
4
5
6
/* 没有可用的内存池。回退到 sysmalloc 从 mmap 获取一个内存块。  */
if (__glibc_unlikely(av == NULL)) {
void *p = sysmalloc(nb, av);
if (p != NULL) alloc_perturb(p, bytes);
return p;
}

在 glibc 的头文件(如 sys/cdefs.hbits/types.h)中,__glibc_unlikely 通常基于编译器的 分支预测指令 实现,常见定义如下:

1
2
3
4
5
#ifdef __GNUC__  // 仅当使用 GCC 系列编译器时有效
#define __glibc_unlikely(expr) (__builtin_expect((expr), 0))
#else
#define __glibc_unlikely(expr) (expr)
#endif
  • __builtin_expect 是 GCC 内置函数,用于告诉编译器某个表达式的结果大概率是 0false)或 1true)。
    • 语法:__builtin_expect(condition, expected_value)
    • 作用:让编译器优化代码,优先生成 expected_value 路径的指令,提升执行效率。
  • 示例解析
    当使用 __glibc_unlikely(av == NULL) 时,等价于 __builtin_expect((av == NULL), 0),即告诉编译器:
    av == NULL 这个条件大概率不成立expected_value=0)。

fast bin

如果申请的 chunk 的大小位于 fastbin 范围内,需要注意的是这里比较的是无符号整数此外,是从 fast bin 的头结点开始取 chunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    /*
如果大小符合快速链表(fastbin)的标准,首先检查相应的链表。
这段代码即使在av尚未初始化的情况下执行也是安全的,所以我们可以直接尝试执行,无需进行检查,这样在这条快速路径上能节省一些时间。
*/

if ((unsigned long) (nb) <= (unsigned long) (get_max_fast())) {
// 得到对应的fastbin的下标
idx = fastbin_index(nb);
// 得到对应的fastbin的头指针
mfastbinptr *fb = &fastbin(av, idx);
mchunkptr pp = *fb;
// 利用fd遍历对应的bin内是否有空闲的chunk块,
do {
victim = pp;
if (victim == NULL) break;
} while ((pp = catomic_compare_and_exchange_val_acq(fb, victim->fd,
victim)) != victim);
// 存在可以利用的chunk
if (victim != 0) {
// 检查取到的 chunk 大小是否与相应的 fastbin 索引一致。
// 根据取得的 victim ,利用 chunksize 计算其大小。
// 利用fastbin_index 计算 chunk 的索引。
if (__builtin_expect(fastbin_index(chunksize(victim)) != idx, 0)) {
errstr = "malloc(): memory corruption (fast)";
errout:
malloc_printerr(check_action, errstr, chunk2mem(victim), av);
return NULL;
}
// 细致的检查。。只有在 DEBUG 的时候有用
check_remalloced_chunk(av, victim, nb);
/* 将获取的到chunk转换为mem模式
mem: 用户视角的可用内存区域,即chunk数据区的起始地址。*/
void *p = chunk2mem(victim);
// 如果设置了perturb_type, 则将获取到的chunk初始化为 perturb_type ^ 0xff
alloc_perturb(p, bytes);
return p;
}
}

small bin

如果获取的内存块的范围处于 small bin 的范围,那么执行如下流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
    /*
如果是小内存请求,检查常规 bin。由于这些 “小 bin” 每个都只存放一种大小的内存块,所以无需在 bin 内部进行查找。(对于大内存请求,我们需要等待未排序的内存块被处理后,才能找到最合适的匹配。但对于小内存请求,无论如何都能精确匹配,所以我们现在就可以检查,这样更快。)
*/

if (in_smallbin_range(nb)) {
// 获取 small bin 的索引
idx = smallbin_index(nb);
// 获取对应 small bin 中的 chunk 指针
bin = bin_at(av, idx);
// 先执行 victim = last(bin),获取 small bin 的最后一个 chunk
// 如果 victim = bin ,那说明该 bin 为空。
// 如果不相等,那么会有两种情况
if ((victim = last(bin)) != bin) {
// 第一种情况,small bin 还没有初始化。
if (victim == 0) /* 初始化检查 */
// 执行初始化,将 fast bins 中的 chunk 进行合并
malloc_consolidate(av);
// 第二种情况,small bin 中存在空闲的 chunk
else {
// 获取 small bin 中倒数第二个 chunk 。
bck = victim->bk;
// 检查 bck->fd 是不是 victim,防止伪造
if (__glibc_unlikely(bck->fd != victim)) {
errstr = "malloc(): smallbin double linked list corrupted";
goto errout;
}
// 设置 victim 对应的 inuse 位
/*inuse 位:位于 Chunk 头部的最低位(最低 1 位)。
0:表示内存块空闲,可被分配或合并。
1:表示内存块已被使用,当前被程序占用。*/
set_inuse_bit_at_offset(victim, nb);
// 修改 small bin 链表,将 small bin 的最后一个 chunk 取出来
bin->bk = bck;
bck->fd = bin;
// 如果不是 main_arena,设置对应的标志
if (av != &main_arena) set_non_main_arena(victim);
// 细致的检查,非调试状态没有作用
check_malloced_chunk(av, victim, nb);
// 将申请到的 chunk 转化为对应的 mem 状态
void *p = chunk2mem(victim);
// 如果设置了 perturb_type , 则将获取到的chunk初始化为 perturb_type ^ 0xff
alloc_perturb(p, bytes);
return p;
}
}
}

large bin

当 fast bin、small bin 中的 chunk 都不能满足用户请求 chunk 大小时,就会考虑是不是 large bin。但是,其实在 large bin 中并没有直接去扫描对应 bin 中的 chunk,而是先利用 malloc_consolidate(参见 malloc_state 相关函数) 函数处理 fast bin 中的 chunk,将有可能能够合并的 chunk 先进行合并后放到 unsorted bin 中,不能够合并的就直接放到 unsorted bin 中,然后再在下面的大循环中进行相应的处理。为什么不直接从相应的 bin 中取出 large chunk 呢?这是 ptmalloc 的机制,它会在分配 large chunk 之前对堆中碎片 chunk 进行合并,以便减少堆中的碎片。

1
2
3
4
5
6
7
8
9
10
/*
如果这是一个大的请求,在继续之前合并快速链表。虽然在查看是否有可用空间之前就销毁所有快速链表看起来有些过度,但这避免了通常与快速链表相关的碎片化问题。此外,在实际情况中,程序倾向于连续发出小请求或大请求,较少出现混合请求,所以在大多数程序中,合并操作并不会经常被调用。而那些频繁调用合并操作的程序,否则往往会出现碎片化。
*/

else {
// 获取large bin的下标。
idx = largebin_index(nb);
// 如果存在fastbin的话,会处理 fastbin
if (have_fastchunks(av)) malloc_consolidate(av);
}

大循环 - 遍历 unsorted bin

如果程序执行到了这里,那么说明 与 chunk 大小正好一致的 bin (fast bin, small bin) 中没有 chunk 可以直接满足需求 ,但是 large chunk 则是在这个大循环中处理

在接下来的这个循环中,主要做了以下的操作

  • 按照 FIFO 的方式逐个将 unsorted bin 中的 chunk 取出来
    • 如果是 small request,则考虑是不是恰好满足,是的话,直接返回。
    • 如果不是的话,放到对应的 bin 中。
  • 尝试从 large bin 中分配用户所需的内存

该部分是一个大循环,这是为了尝试重新分配 small bin chunk,这是因为我们虽然会首先使用 large bin,top chunk 来尝试满足用户的请求,但是如果没有满足的话,由于我们在上面没有分配成功 small bin,我们并没有对 fast bin 中的 chunk 进行合并,所以这里会进行 fast bin chunk 的合并,进而使用一个大循环来尝试再次分配 small bin chunk。

1
2
3
4
5
6
7
    /*
处理最近释放或剩余的块,如果块大小恰好合适则取用一块;如果是小请求,取用最近一次非恰好匹配所剩余的块。将其他遍历过的块放入 bins 中。请注意,此步骤是所有例程中唯一将块放入 bins 的地方。
这里的外层循环是必要的,因为我们可能直到 malloc 接近尾声时才意识到应该进行合并,所以必须进行合并然后重试。这种情况最多发生一次,并且仅在我们否则需要扩展内存来满足 “小” 请求时才会发生。
*/

for (;;) {
int iters = 0;

unsorted bin 遍历

先考虑 unsorted bin,再考虑 last remainder (最后剩余块),但是对于 small bin chunk 的请求会有所例外。

注意 unsorted bin 的遍历顺序为 bk(从尾部到头部)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 如果 unsorted bin 不为空
// 先进先出
while ((victim = unsorted_chunks(av)->bk) != unsorted_chunks(av)) {
// victim 为 unsorted bin 的最后一个 chunk
// bck 为 unsorted bin 的倒数第二个 chunk
bck = victim->bk;
// 判断得到的 chunk 是否满足要求,不能过小,也不能过大
// 一般 system_mem 的大小为132K
if (__builtin_expect(chunksize_nomask(victim) <= 2 * SIZE_SZ, 0) ||
__builtin_expect(chunksize_nomask(victim) > av->system_mem, 0))
malloc_printerr(check_action, "malloc(): memory corruption",
chunk2mem(victim), av);
// 得到victim对应的chunk大小。
size = chunksize(victim);
small request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
如果是一个小请求,并且未排序 bin 中只有最后一个余数块,那么尝试使用它。这有助于提高连续小请求运行的局部性。这是最佳适配策略的唯一例外,仅适用于小内存块没有完全匹配的情况。
*/

if (in_smallbin_range(nb) && bck == unsorted_chunks(av) &&
victim == av->last_remainder &&
(unsigned long) (size) > (unsigned long) (nb + MINSIZE)) {
/* 分割并重新连接剩余部分 */
// 获取新的 remainder 的大小
remainder_size = size - nb;
// 获取新的 remainder 的位置
remainder = chunk_at_offset(victim, nb);
// 更新 unsorted bin 的情况
unsorted_chunks(av)->bk = unsorted_chunks(av)->fd = remainder;
// 更新 av 中记录的 last_remainder
av->last_remainder = remainder;
// 更新last remainder的指针
remainder->bk = remainder->fd = unsorted_chunks(av);
if (!in_smallbin_range(remainder_size)) {
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}
// 设置victim的头部,
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
// 设置 remainder 的头部
set_head(remainder, remainder_size | PREV_INUSE);
// 设置记录 remainder 大小的 prev_size 字段,因为此时 remainder 处于空闲状态。
set_foot(remainder, remainder_size);
// 细致的检查,非调试状态下没有作用
check_malloced_chunk(av, victim, nb);
// 将 victim 从 chunk 模式转化为mem模式
void *p = chunk2mem(victim);
// 如果设置了perturb_type, 则将获取到的chunk初始化为 perturb_type ^ 0xff
alloc_perturb(p, bytes);
return p;
}
初始取出
1
2
3
/* remove from unsorted list */
unsorted_chunks(av)->bk = bck;
bck->fd = unsorted_chunks(av);
exact fit

如果从 unsorted bin 中取出来的 chunk 大小正好合适,就直接使用。这里应该已经把合并后恰好合适的 chunk 给分配出去了。

1
2
3
4
5
6
7
8
9
/* 如果大小完全匹配,则直接取用,而非放入 bin 中 */
if (size == nb) {
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena) set_non_main_arena(victim);
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
alloc_perturb(p, bytes);
return p;
}
place chunk in small bin

如果是小请求把取出来的 chunk 放到对应的 small bin 中。

1
2
3
4
5
6
/* place chunk in bin */

if (in_smallbin_range(size)) {
victim_index = smallbin_index(size);
bck = bin_at(av, victim_index);
fwd = bck->fd;
place chunk in large bin

如果不是小请求把取出来的 chunk 放到对应的 large bin 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
} else {
// large bin 范围
victim_index = largebin_index(size);
bck = bin_at(av, victim_index); // 当前 large bin 的头部
fwd = bck->fd;

/* 按排序顺序维护大的内存块 */
/* 从这里我们可以总结出,largebin 以 fd_nextsize 递减排序。
同样大小的 chunk,后来的只会插入到之前同样大小的 chunk 后,
而不会修改之前相同大小的fd/bk_nextsize,这也很容易理解,
可以减低开销。此外,bin 头不参与 nextsize 链接。*/
// 如果 large bin 链表不空
if (fwd != bck) {
/*或者利用已使用位来加快比较 */
// 加速比较,应该不仅仅有这个考虑,因为链表里的 chunk 都会设置该位。
size |= PREV_INUSE;
/* 如果小于最小值,则跳过下面的循环 */
// bck->bk 存储着相应 large bin 中最小的chunk。
// 如果遍历的 chunk 比当前最小的还要小,那就只需要插入到链表尾部。
// 判断 bck->bk 是不是在 main arena。
assert(chunk_main_arena(bck->bk));
if ((unsigned long) (size) <
(unsigned long) chunksize_nomask(bck->bk)) {
// 令 fwd 指向 large bin 头
fwd = bck;
// 令 bck 指向 largin bin 尾部 chunk
bck = bck->bk;
// victim 的 fd_nextsize 指向 largin bin 的第一个 chunk
victim->fd_nextsize = fwd->fd;
// victim 的 bk_nextsize 指向原来链表的第一个 chunk 指向的 bk_nextsize
victim->bk_nextsize = fwd->fd->bk_nextsize;
// 原来链表的第一个 chunk 的 bk_nextsize 指向 victim
// 原来指向链表第一个 chunk 的 fd_nextsize 指向 victim
fwd->fd->bk_nextsize =
victim->bk_nextsize->fd_nextsize = victim;
} else {
// 当前要插入的 victim 的大小大于最小的 chunk
// 判断 fwd 是否在 main arena
assert(chunk_main_arena(fwd));
// 从链表头部开始找到不比 victim 大的 chunk
while ((unsigned long) size < chunksize_nomask(fwd)) {
fwd = fwd->fd_nextsize;
assert(chunk_main_arena(fwd));
}
// 如果找到了一个和 victim 一样大的 chunk,
// 那就直接将 chunk 插入到该chunk的后面,并不修改 nextsize 指针。
if ((unsigned long) size ==
(unsigned long) chunksize_nomask(fwd))
/* 始终插入到第二个位置。 */
fwd = fwd->fd;
else {
// 如果找到的chunk和当前victim大小不一样
// 那么就需要构造 nextsize 双向链表了
victim->fd_nextsize = fwd;
victim->bk_nextsize = fwd->bk_nextsize;
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
bck = fwd->bk;
}
} else {
// 如果空的话,直接简单使得 fd_nextsize 与 bk_nextsize 构成一个双向链表即可。
victim->fd_nextsize = victim->bk_nextsize = victim;
}
最终取出
1
2
3
4
5
6
// 放到对应的 bin 中,构成 bck<-->victim<-->fwd。
mark_bin(av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;
while 迭代次数

while 最多迭代 10000 次后退出。

1
2
3
    // #define MAX_ITERS 10000
if (++iters >= MAX_ITERS) break;
}

large chunk

注: 或许会很奇怪,为什么这里没有先去看 small chunk 是否满足新需求了呢?这是因为 small bin 在循环之前已经判断过了,这里如果有的话,就是合并后的才出现 chunk。但是在大循环外,large chunk 只是单纯地找到其索引,所以觉得在这里直接先判断是合理的,而且也为了下面可以再去找较大的 chunk。

如果请求的 chunk 在 large chunk 范围内,就在对应的 bin 中从小到大进行扫描,找到第一个合适的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/*
如果是一个大请求,按排序顺序遍历当前 bin 中的块,以找到符合要求的最小块。为此使用跳表。
*/
if (!in_smallbin_range(nb)) {
bin = bin_at(av, idx);
// 如果对应的 bin 为空或者其中的chunk最大的也很小,那就跳过
// first(bin)=bin->fd 表示当前链表中最大的chunk
if ((victim = first(bin)) != bin &&
(unsigned long) chunksize_nomask(victim) >=
(unsigned long) (nb)) {
// 反向遍历链表,直到找到第一个不小于所需chunk大小的chunk
victim = victim->bk_nextsize;
while (((unsigned long) (size = chunksize(victim)) <
(unsigned long) (nb)))
victim = victim->bk_nextsize;

/* 避免移除某个大小的首个条目,这样就无需重新路由跳跃表。 */
// 如果最终取到的chunk不是该bin中的最后一个chunk,并且该chunk与其前面的chunk
// 的大小相同,那么我们就取其前面的chunk,这样可以避免调整bk_nextsize,fd_nextsize
// 链表。因为大小相同的chunk只有一个会被串在nextsize链上。
if (victim != last(bin) &&
chunksize_nomask(victim) == chunksize_nomask(victim->fd))
victim = victim->fd;
// 计算分配后剩余的大小
remainder_size = size - nb;
// 进行unlink
unlink(av, victim, bck, fwd);

/* Exhaust */
// 剩下的大小不足以当做一个块
// 很好奇接下来会怎么办?
if (remainder_size < MINSIZE) {
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena) set_non_main_arena(victim);
}
/* Split */
// 剩下的大小还可以作为一个chunk,进行分割。
else {
// 获取剩下那部分chunk的指针,称为remainder
remainder = chunk_at_offset(victim, nb);
/* We cannot assume the unsorted list is empty and therefore
have to perform a complete insert here. */
// 插入unsorted bin中
bck = unsorted_chunks(av);
fwd = bck->fd;
// 判断 unsorted bin 是否被破坏。
if (__glibc_unlikely(fwd->bk != bck)) {
errstr = "malloc(): corrupted unsorted chunks";
goto errout;
}
remainder->bk = bck;
remainder->fd = fwd;
bck->fd = remainder;
fwd->bk = remainder;
// 如果不处于small bin范围内,就设置对应的字段
if (!in_smallbin_range(remainder_size)) {
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}
// 设置分配的chunk的标记
set_head(victim,
nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));

// 设置remainder的上一个chunk,即分配出去的chunk的使用状态
// 其余的不用管,直接从上面继承下来了
set_head(remainder, remainder_size | PREV_INUSE);
// 设置remainder的大小
set_foot(remainder, remainder_size);
}
// 检查
check_malloced_chunk(av, victim, nb);
// 转换为mem状态
void *p = chunk2mem(victim);
// 如果设置了perturb_type, 则将获取到的chunk初始化为 perturb_type ^ 0xff
alloc_perturb(p, bytes);
return p;
}
}