堆
堆概述
什么是堆
在程序运行过程中,堆可以提供动态分布的内存,允许程序申请大小未知的内存。堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址方向增长。我们一般称管理堆的那部分程序为堆管理器。
堆管理器处于用户程序与内核中间,主要做以下工作:
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 | /* |
可以看出,malloc 函数返回对应大小字节的内存块的指针。此外,该函数还对一些异常情况进行了处理
- 当 n=0 时,返回当前系统允许的堆的最小内存块。
- 当 n 为负数时,由于在大多数系统上,size_t 是无符号数(这一点非常重要),所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。
free
在 glibc 的 malloc.c 中,free 的说明如下
1 | /* |
可以看出,free 函数会释放由 p 所指向的内存块。这个内存块有可能是通过 malloc 函数得到的,也有可能是通过相关的函数 realloc 得到的。
此外,该函数也同样对异常情况进行了处理
- 当 p 为空指针时,函数不执行任何操作。
- 当 p 已经被释放之后,再次释放会出现乱七八糟的效果,这其实就是
double free
。 - 除了被禁用 (mallopt) 的情况下,当释放很大的内存空间时,程序会将这些内存空间还给系统,以便于减小程序所使用的内存空间。
内存分配背后的系统调用
在前面提到的函数中,无论是 malloc 函数还是 free 函数,我们动态申请和释放内存时,都经常会使用,但是它们并不是真正与系统交互的函数。这些函数背后的系统调用主要是 (s)brk 函数以及 mmap, munmap 函数。
如下图所示,我们主要考虑对堆进行申请内存块的操作。
(s)brk
对于堆的操作,操作系统提供了brk函数,glibc库提供了sbrk函数,我们可以通过增加brk的大小来向操作系统申请内存
初始时,堆的起始地址start_brk
以及堆的当前末尾brk
指向同一地址。根据是否开启ASLR,两者的具体位置会有所不同
- 不开启 ASLR 保护时,start_brk 以及 brk 会指向 data/bss 段的结尾。
- 开启 ASLR 保护时,start_brk 以及 brk 也会指向同一位置,只是这个位置是在 data/bss 段结尾后的随机偏移处。
具体效果如下图:
接下来实际操作来深入理解通过改变brk的大小来分配或释放堆
1 | /* sbrk and brk example */ |
该程序用于演示程序如何手动控制 “程序中断点(Program Break)”(堆内存的边界),理解堆内存动态分配的底层原理。
1 | curr_brk:记录当前程序中断点地址。 |
需要注意的是,在每一次执行完操作后,都执行了 getchar() 函数,这是为了我们方便我们查看程序真正的映射。
gcc编译链接
1 | gcc -o sbrk sbrk.c |
gdb调试sbrk程序观察堆边界变化情况
这里我们可以通过两种方式来查看内存分配情况
1 | vmmap |
在第一次调用brk之前
可以看到并没有出现堆,结合输出可以发现
start_brk = brk = end_data = 0x555555559000
调用完第一个printf之后
出现了堆段,brk = 0x5555557a000
觉得应该是在printf函数内部改变了brk的大小
此时调用sbrk(0)可以发现rax存储了此时的brk
接下来我们尝试将brk变大,来扩展堆内存空间
扩展
brk(curr_brk+4096);
扩大一页内存
可以看到此时brk变为了0x55555557b000堆空间得到了拓展
接下来将brk变小释放刚刚扩展的堆空间
释放
brk(tmp_brk);
将brk变回初始的0x55555557a000
可以看到此时brk变为了0x55555557a000刚刚扩展的部分得到了释放
mmap
malloc 会使用 mmap 来创建独立的匿名映射段。匿名映射的目的主要是可以申请以 0 填充的内存,并且这块内存仅被调用进程所使用。
同样的我们通过一个程序来深入理解
1 | /* Private anonymous mapping example using mmap syscall */ |
gcc编译链接
1 | gcc -o mmap mmap.c |
调用mmap前的内存段
可以看到调用mmap后内存段的变大了
调用munmap后内存段恢复
多线程支持
在原来的 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. */
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 | /* |
一些必要的宏定义
1 | /* INTERNAL_SIZE_T is the word-size used for internal bookkeeping of |
一般来说,
size_t
被定义为unsigned long
,其在 64 位中是 64 位无符号整数,32 位中是 32 位无符号整数。
接下来逐个解释chunk结构体的各个字段
结构体设计的 “误导性”
注释提到该结构体是一种 “视图(view)”,原因在于:
双重角色:同一个结构体既要表示已分配的内存块,也要表示空闲的内存块。
字段复用
:
- 当 chunk 被分配时,
fd
和bk
指针不使用(被应用程序数据覆盖)。 - 仅当 chunk 空闲时,
fd
和bk
才构成双向链表,用于快速查找可用内存。
- 当 chunk 被分配时,
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
之间的合并
- NON_MAIN_ARENA,记录当前
fd,bk
chunk
处于分配状态时,从fd字段开始是用户的数据。chunk
空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下- fd 指向下一个(非物理相邻)空闲的`chunk` - bk 指向上一个(非物理相邻)空闲的`chunk` - 通过 fd 和 bk 可以将空闲的`chunk`块加入到空闲的`chunk`块链表进行统一管理
fd_nextsize,bk_nextsize
只有chunk空闲时才使用,不过其用于较大的chunk
(large 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因此,对于自 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 中的空间复用。
被释放的 chunk
被记录在链表中(可能是循环双向链表,也可能是单向链表)。具体结构如下
可以发现,如果一个 chunk
处于 free 状态,那么会有两个位置记录其相应的大小
- 本身的 size 字段会记录,
- 它后面的
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 | /*从 malloc 头部(chunk)到用户指针(mem)的转换,以及反向转换。*/ |
chunk2mem(p)
:将chunk
指针(指向malloc_chunk
结构体)转换为用户实际可用的内存指针(跳过prev_size
和size
字段,指向用户数据区起始位置)。mem2chunk(mem)
:将用户内存指针反向转换为chunk
指针(回溯两个SIZE_SZ
大小的偏移,指向malloc_chunk
结构体起始位置)。- 关键:
SIZE_SZ
为INTERNAL_SIZE_T
的字节数(32 位系统为 4,64 位为 8),因此偏移量为2 * SIZE_SZ
(即prev_size
和size
字段总长度)。
最小的chunk
大小
1 | /* 最小可能的 chunk 大小。 */ |
- 使用
offsetof
计算malloc_chunk
结构体中fd_nextsize
字段的偏移量,即chunk
至少需包含prev_size
、size
、fd
、bk
字段(fd_nextsize
为 large bin 专用,最小块不包含)。 - 实际含义:最小
chunk
大小为2 * SIZE_SZ(prev_size + size) + 2 * sizeof(void*)(fd + bk)
,确保双向链表基本结构。
最小申请的堆内存大小
用户最小申请的内存大小必须是 2*SIZE_SE 的最下整数倍
注:就目前而看 MIN_CHUNK_SIZE 和 MINSIZE 大小是一致的
1 | /* malloc 能申请的最小大小为对齐后的最小块 */ |
MALLOC_ALIGN_MASK
为对齐掩码(值为MALLOC_ALIGNMENT - 1
,如 64 位系统为0xf
)。- 计算逻辑:将
MIN_CHUNK_SIZE
向上对齐到MALLOC_ALIGNMENT
的倍数,确保内存地址对齐(如 64 位系统对齐 16 字节)。 - 示例:若
MIN_CHUNK_SIZE
为0x20
(32 字节),MALLOC_ALIGNMENT
为 16,则MINSIZE
为0x20
(已对齐)。
检查分配给用户的内存是否对齐
2 * SIZE_SZ 大小对齐
1 | /* 检查 m 是否符合对齐要求 */ |
请求字节数判断
1 | /* |
将用户请求内存大小转为实际分配内存大小
1 | /* 将用户请求的字节数转换为可用的块大小(内部版本) */ |
- 步骤 1:判断请求大小
req
加上SIZE_SZ
(预留size
字段空间)和对齐掩码后的总大小是否小于MINSIZE
。 - 步骤 2:若小于,直接返回
MINSIZE
(强制分配最小块);否则,通过掩码对齐到MALLOC_ALIGNMENT
的倍数。 - 核心逻辑:确保用户数据区大小为
req
,但整个chunk
需包含头部(prev_size
和size
)并满足对齐要求。
当一个 chunk
处于已分配状态时,它的物理相邻的下一个 chunk
的 prev_size 字段必然是无效的,故而这个字段就可以被当前这个 chunk
使用。这就是 ptmalloc 中 chunk
间的复用。具体流程如下
- 首先,利用 REQUEST_OUT_OF_RANGE 判断是否可以分配用户请求的字节大小的 chunk。
- 其次,需要注意的是用户请求的字节是用来存储数据的,即
chunk header
后面的部分。与此同时,由于 chunk 间复用,所以可以使用下一个chunk
的 prev_size 字段。因此,这里只需要再添加 SIZE_SZ 大小即可以完全存储内容。 - 由于系统中所允许的申请的
chunk
最小是 MINSIZE,所以与其进行比较。如果不满足最低要求,那么就需要直接分配 MINSIZE 字节。 - 如果大于的话,因为系统中申请的
chunk
需要 2 * SIZE_SZ 对齐,所以这里需要加上 MALLOC_ALIGN_MASK 以便于对齐。
标记位相关
1 | /* 当相邻的前一个内存块(chunk)被使用时,size字段会与PREV_INUSE进行按位或操作 */ |
**获取 chunk size **
1 | /* 获取内存块大小(忽略使用标志位) */ |
获取下一个物理相邻的 chunk
1 | /* 指向下一个物理相邻的 chunk 指针。 */ |
通过 chunksize(p)
获取当前块总大小(含头部),计算下一块起始地址(当前地址 + 块大小)
获取前一个 chunk 的信息
1 | /* 获取 p 下方块的大小,仅当前一块空闲时有效。 */ |
当前 chunk 使用状态相关操作
1 | /* 提取 p 的使用状态位 */ |
设置 chunk 的 size 字段
1 | /* 设置头部的大小,不干扰其使用标志位 */ |
获取指定偏移的 chunk
1 | /* 将 ptr + offset 位置的空间视为一个 chunk */ |
指定偏移处 chunk 使用状态相关操作
1 | /* 检查 p + s 位置的内存块的使用状态位 */ |
bin
概述
bin
本质是双向链表,用于链接空闲的chunk
。每 bin的 header 包含两个指针:fd
(forward):指向下一个空闲chunk
。bk
(backward):指向前一个空闲chunk
。
- 由于
malloc_chunk
结构体中天然包含fd
和bk
字段(仅在空闲时使用),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 |
|
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 | typedef struct malloc_chunk *mbinptr; |
Fast Bin
大多数程序经常会申请以及释放一些比较小的内存块。如果将一些较小的chunk
释放之后发现存在与之相邻的空闲的chunk
并将它们进行合并,那么当下一次再次申请相应大小的chunk
时,就需要对chunk
进行分割,这样就大大降低了堆的利用效率。因为我们把大部分时间花在了合并、分割以及中间检查的过程中。因此,prmalloc中专门设计了 fast bin,对应的变量就是malloc state 中的 fastbinsY
1 | */快速链表 |
为了更加高效地利用 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 |
|
ptmalloc 默认情况下会调用 set_max_fast(s) 将全局变量 global_max_fast 设置为 DEFAULT_MXFAST,也就是设置 fast bins 中 chunk
的最大值。当 MAX_FAST_SIZE 被设置为 0 时,系统就不会支持 fast bins 。
fastbin 的索引
1 |
|
需要特别注意的是,fastbin 范围的 chunk
的 inuse 始终被置为 1。因此它们不会和其它被释放的 chunk
合并。
但是当释放的 chunk
与该 chunk
相邻的空闲 chunk
合并后的大小大于 FASTBIN_CONSOLIDATION_THRESHOLD 时,内存碎片可能比较多了,我们就需要把 fast bins 中的 chunk 都进行合并,以减少内存碎片对系统的影响。
1 | /* |
malloc_consolidate 函数可以将 fastbin 中所有能和其它 chunk
合并的 chunk
合并在一起。具体地参见后续的详细函数的分析。
1 | /* |
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 |
|
或许,大家会很疑惑,那 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 |
|
Unsorted Bin
unsorted bin 可以视为空闲 chunk
回归其所属 bin 之前的缓冲区。
其在 glibc 中具体的说明如下
1 | /* |
在 ptmalloc 内存管理机制中,
NON_MAIN_ARENA
flag 是malloc_chunk
结构体中size
字段的一个标志位,用于表示当前chunk
是否属于主线程的分配区(main arena
)
从下面的宏我们可以看出
1 | /* 无法索引的 1 号 bin 用于存放未排序的块。 */ |
unsorted bin 处于我们之前所说的 bin 数组下标 1 处。故而 unsorted bin 只有一个链表。unsorted bin 中的空闲 chunk
处于乱序状态,主要有两个来源
- 当一个较大的
chunk
被分割成两半后,如果剩下的部分大于 MINSIZE,就会被放到 unsorted bin 中。 - 释放一个不属于 fast bin 的 chunk,并且该
chunk
不和 topchunk
紧邻时,该chunk
会被首先放到 unsorted bin 中。关于 topchunk
的解释,请参考下面的介绍。
此外,Unsorted Bin 在使用的过程中,采用的遍历顺序是 FIFO 。