brk(sbrk)和mmap

  • 在 Linux 进程的虚拟内存空间中,glibcmalloc 作为用户态的内存分配器,其实自身并没有物理内存。当它发现内部的空闲链表不够用时,必须通过System Call向操作系统内核申请虚拟内存。
  • brk(包括其包装函数 sbrk)和 mmap 就是唯二用于扩展进程虚拟内存的系统调用。

    brk 与 sbrk:操纵程序断点Program Break

  • 堆的最高地址边界被称为 Program Break

    • int brk(void *addr);:将 Program Break 直接设置为 addr 指定的绝对地址
    • void *sbrk(intptr_t increment);:将 Program Break 增加 increment 个字节。increment 为正数时扩充堆,为负数时收缩堆。
    【1. 申请前】                   【2. 调用 brk(新地址) 后】
    
          高地址                             高地址
         +---------+                        +---------+
         |         |                        |         |
         | 未映射的 |                        | 未映射的 |
         | 空闲区域 |                        | 空闲区域 |
         |         |                        +---------+ <--- 新 brk 指针 (新堆顶)
         +---------+ <--- 原 brk 指针        |\\\\\\\\\\| 
         | 堆区    |                         |\新分配的\|
         | (Heap)  |                        |\堆内存\\\|
         +---------+                        +---------+ <--- 原 brk 指针 (原堆顶)
         | .bss /  |                        | 堆区    |
         | .data   |                        | (Heap)  |
         +---------+                        +---------+
          低地址                             | .bss /  |
                                            | .data   |
                                            +---------+
                                             低地址
  • 申请过程同理

mmap:创建独立的内存映射

  • Mmap的第一种用法是映射磁盘文件到内存中;第二种用法是匿名映射,不映射磁盘文件,而向映射区申请一块内存
  • Malloc使用的是mmap的第二种用法(匿名映射)
  • Munmap函数用于释放内存
  • 调用形式(简化):mmap(NULL, length, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
  • 它不依赖于连续的 Heap 区域,而是在虚拟内存空间中的 Memory Mapping Segment(内存映射段,通常位于堆和栈之间,动态链接库 .so 也加载于此) 中,寻找一块足够大的连续虚拟地址范围,创建一个全新的 VMA

VMA:虚拟内存
虚拟内存是操作系统内核与底层硬件(CPU 里的 MMU)联合制造的一个“内存寻址抽象层”。它让每一个运行的进程都以为自己独占了一块连续的、极其庞大的内存地址空间,而实际上,这些地址只是逻辑上的代号,它们被切成小块,散落在真实的物理内存(RAM)甚至硬盘(Swap)中
当你往虚拟内存里写入东西时,底层发生的真实情况是:你的写入指令,直接穿透了虚拟层,被硬件重定向到了真实的内存条引脚上。
所以往虚拟内存里写入的东西实际是写入的物理内存
缺页中断(Page Fault)与延迟分配:
当你 malloc(1GB) 时,系统只是在你的虚拟内存里划出了 1GB 的合法地址范围,但页表里并没有给它们分配真实的物理页(物理内存一点都没变少)。

当你的程序第一次尝试往这 1GB 的某个地址写入数据时,CPU 的 MMU 查页表发现:“哎?这个虚拟页没有对应的物理页啊!”。

此时,MMU 会立刻向 CPU 抛出一个硬件级别的异常,叫作缺页中断(Page Fault)。

操作系统内核捕获这个中断,暂停你的程序,跑去物理内存里找一页真实的空闲内存(4KB),把它清零,然后在页表里把你的虚拟地址和这块物理地址连起来。处理完后,让你的程序继续执行。你的程序完全感受不到中间发生的停顿。

  • mmap()和brk()/sbrk()这两种不同方式申请的堆内存是互相独立的,各自管理不同的内存区域,使用mmap时并不会自动调整brk指针。

匿名映射:

  • 创建一段没有关联任何文件 inodeVMA,这段虚拟内存完全由物理内存(RAM)或交换分区(Swap)支撑,虚拟内存有点类似于指针,往虚拟内存里写入的东西实际是写入的物理内存
  • 简单来说:操作系统给你分配了一段虚拟内存,但它不和任何硬盘文件绑定。它连向的是真实的物理内存条上一块完全空白、被清零的区域
  • 当程序结束或者调用 free(底层触发 munmap)时,这块内存里的数据会立刻丢失,物理内存被系统收回。它绝对不会像文件映射那样保存到硬盘上。
  • 过程:
【模式一:匿名映射 (Anonymous Mapping)】
        —— 场景:malloc 申请 >128KB 的大内存

   进程虚拟内存空间                       真实的物理内存
+--------------------+               +--------------------+
|     栈 (Stack)     |               |                    |
+--------------------+               |                    |
|       ...          |               |                    |
|                    |     映射      |                    |
| [ 新增的匿名内存 ]  | ============> [   全新物理页(清零)  ]
| (纯粹的空白飞地)    |               |                    |
|                    |               |                    |
|       ...          |               |                    |
+--------------------+               |                    |
|     堆 (Heap)      |               |                    |
+--------------------+               +--------------------+

文件映射:

  • 将进程虚拟地址空间中的一段 VMA(虚拟内存区域)与磁盘上某个文件的 inode 及物理数据块建立逻辑关联
  • 简单来讲:操作系统把一段虚拟内存地址,直接和硬盘上的某个文件(比如 /lib/x86_64-linux-gnu/libc.so.6)绑死在一起连线的另一头是:硬盘上的具体文件。
  • 几乎所有动态链接库(比如 libc.so)被加载到内存时,用的都是文件映射。
  • 当你往这段虚拟内存里写入数据时(如果是共享映射),操作系统会在后台自动把你的修改保存到硬盘的那个文件里。
       【模式二:文件映射 (File-backed Mapping)】
        —— 场景:程序读取大文件,或加载 libc.so

   进程虚拟内存空间                       硬盘 / 文件系统
+--------------------+               +--------------------+
|     栈 (Stack)     |               |                    |
+--------------------+               |                    |
|       ...          |               |                    |
|                    |     映射      |                    |
| [ 新增的文件内存 ]  | ============> [  硬盘上的文件数据   ]
| (文件直通缓冲区)    |               | (比如 libc.so)     |
|                    |               |                    |
|       ...          |               |                    |
+--------------------+               |                    |
|     堆 (Heap)      |               |                    |
+--------------------+               +--------------------+

chunk结构

chunk里面的数据:

  • prev_size: 如果前面一个块是空闲的,该区域表示前一个chunk的大小,如果前一个chunk不空闲,该域无意义
  • size:当前chunk的大小,并且记录了当前chunk和前一个chunk的一些属性(记录在size的最后3个位中),包括前一个chunk是否在使用中,当前chunk是否通过mmap获得的内存,当前chunk是否属于非主分配区
  • fd和bkchunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下:

    • fd 指向下一个(非物理相邻)空闲的 chunk
    • bk 指向上一个(非物理相邻)空闲的 chunk
  • fd_nextsizebk_nextsize:只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)

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

请输入图片描述
图片来源:wiki.wgpsec.org

malloc_chunk&Allocated chunk&Free chunk

特性malloc_chunkAllocated chunkFree chunk
概念层面glibc 源码中的 C 语言 struct 定义运行时被程序使用的内存状态运行时被系统回收的内存状态
Header 占用包含所有字段的定义最小只占 8 字节(仅保留 size至少 16 字节(包含 size + 被覆写的 fd/bk
fd 和 bk 指针存在于结构体定义中绝对不存在(被用户数据覆盖)绝对存在(覆盖了用户旧数据)
操作者是谁编译器与底层开发者你的代码(通过指针对用户数据区进行读写)系统内存管理器(操作 Header 和 fd/bk 维护链表)
  • 简单来说:

    • malloc_chunk:结构体定义
    • Allocated chunk:已分配块
    • Free chunk:已释放块
  • glibc 的核心源码 malloc/malloc.c中,堆块的结构体是这样定义的:
struct malloc_chunk {
  INTERNAL_SIZE_T      mchunk_prev_size;  /* 前一个空闲块的大小 */
  INTERNAL_SIZE_T      mchunk_size;       /* 当前块的大小 (带有 AMP 标志位) */
  
  struct malloc_chunk* fd;                /* 指向下一个空闲块的指针 */
  struct malloc_chunk* bk;                /* 指向前一个空闲块的指针 */
  
  /* 只有大型空闲块 (Large Bin) 才会用到的指针 */
  struct malloc_chunk* fd_nextsize; 
  struct malloc_chunk* bk_nextsize;
};

chunk 的标志位(A/M/P 位)

malloc_chunk 结构体中,mchunk_size 字段不仅记录当前 chunk 的大小。由于 Linux 下堆内存分配需要按 8 字节或 16 字节对齐,size 的最低 3bit 永远为 0

glibc 利用这低 3 位作为当前 chunk 的状态标志位(从低到高依次为 PMA):

宏 / 含义取值语义
PPREV_INUSEP = 1物理相邻的前一个 chunk 正在被使用(Allocated)。此时当前 chunkprev_size 字段无效(可能被前一个 chunk 用作数据区)。
PPREV_INUSEP = 0物理相邻的前一个 chunk 是空闲的(Free)。此时当前 chunkprev_size 记录前一个空闲 chunk 的大小;堆块合并(Coalescing)依赖该位。
MIS_MMAPPEDM = 1当前 chunk 通过 mmap 系统调用独立分配(mmap 区域)。
MIS_MMAPPEDM = 0当前 chunk 属于常规 heap(由 brk / sbrk 扩展)。
ANON_MAIN_ARENAA = 0当前 chunk 属于主分配区 main_arena(主线程堆)。
ANON_MAIN_ARENAA = 1当前 chunk 属于非主分配区(thread arena / 子线程 arena)。

堆内存管理核心:main_arenabins

当一个 chunkfree 后,为了高效复用,glibc 会将其挂载到特定的数据结构中进行管理。主线程的管理器称为 main_arena(类型为 struct malloc_state),位于 libc.so 的数据段。

main_arena 通过内部的指针数组维护多种空闲链表,这些链表统称为 bins

空闲链表bins

  • ptmalloc中定义了malloc_state结构体用来统一管理bins。(而不是分别声明fast_bin、unsorted_bin),这就可以使他们在内存上是线性关系的。
  • 当用户使用free函数释放掉内存,ptmalloc并不会马上将其交给操作系统,而是被ptmalloc本身的空闲链表bins管理起来。
  • 这样当下次进程需要使用malloc申请堆内存的时候,ptmalloc就会从空闲的bins上寻找一块合适大小的内存块分配给用户使用。可以避免频繁系统调用,提高程序运行效率,降低内存分配开销
  • malloc将相似大小的chunk用双向链表链接起来,这样的一个链表被称为一个bin。ptmalloc一共维护了128个bin(也就是说维护了128个双向链表)。每个bins都维护了大小相近的双向链表chunk。根据chunk大小分为以下几种bins

    • Fast bin
    • Unsorted bin
    • Small bin
    • Large bin
  • 保存这些数据结构为

    • fastbinsY:这个数组以保存fast_bins
    • bins: 这个数组以保存unsortedsmall以及large_bins,共计可容纳126个

    当用户调用malloc的时候,能很快找到用户需要分配的内存大小是否在维护的bin上,如果在某一个bin上,就可以通过双向链表去查找合适的chunk内存块给用户使用

  • fast_bins中的chunk,size最后一位始终置1,这是为了防止fast_bin中chunk的内存合并
+---------------------------+
|       malloc_state        |
|                           |
| +-----------------------+ |
| |       fastbinsY       | |
| | +-----+  +-----+     | | 
| | | * ->|->| * ->|->...| | 
| | +-----+  +-----+     | |
| +-----------------------+ |
|                           |
| +-----------------------+ |
| |     unsorted_bin      | |
| | +-----+              | | 
| | | * ->|-> NULL       | | 
| | +-----+              | |
| +-----------------------+ |
|                           |
| +-----------------------+ |
| |       smallbins       | |
| | +-----+  +-----+     | | 
| | | * ->|->| * ->|->...| | 
| | +-----+  +-----+     | |
| +-----------------------+ |
|                           |
| +-----------------------+ |
| |       largebins       | |
| | +-----+  +-----+     | | 
| | | * ->|->| * ->|->...| | 
| | +-----+  +-----+     | |
| +-----------------------+ |
|                           |
+---------------------------+

其实就是一个邻接表(太好了,是图论,我们有救了(

1. tcacheThread Local Cache

  • 引入版本:glibc 2.26 及以后
  • 机制原理:为提升多线程分配效率,每个线程维护独立缓存结构(线程本地)
  • 数据结构:单向链表(仅使用 fd 指针),遵循 LIFO(后进先出),头插
  • 容量限制:默认支持最大 0x410 字节的 chunk;每种大小的链表最多存放 7chunk
安全特性tcache 操作几乎没有强校验(早期实现对 double free 检查不足),且分配时优先从该链表取用,是现代堆利用(tcache poisoning)的常见目标。

2. fast binsfastbinsY

  • 物理位置:main_arena.fastbinsY 数组
  • 机制原理:管理较小尺寸的空闲块(默认最大约 0x80 字节)。当 tcache 满载或不可用时,小块可能进入此处
  • 数据结构:单向链表(仅 fd 指针),LIFO(后进先出)

核心逻辑(不合并机制)

  • 当一个 chunk 被放入 fast bin 时,系统不会修改其物理相邻的下一个 chunkPPREV_INUSE)标志位(仍保持 P = 1
  • 因此,处于 fast bin 中的 chunk 不会与相邻空闲 chunk 发生合并(Coalescing

安全检查(典型)

  • 仅检查当前放入的 chunk 是否与链表头的第一个 chunk 相同(可防范连续 double free
  • 不防范交替释放:free(A) -> free(B) -> free(A)

fast bin 链表示例(单向)

main_arena.fastbinsY[0] (size = 0x20) ---> [Chunk B] --fd--> [Chunk A] --fd--> NULL

3. unsorted bin

  • 物理位置:main_arena.bins 数组的第 1 个元素(bins[1]
  • 机制原理:空闲块的“中转分拣站”。当被释放的 chunk 大小超过 fast bin 限制且不进入 tcache 时,通常会先进入 unsorted bin
  • 数据结构:双向循环链表(使用 fdbk 指针)

分拣逻辑(发生在后续 malloc

  • malloc 无法从 tcachefast bins 满足需求时,会遍历 unsorted bin
  • 若找到大小精确匹配的 chunk:直接分配给用户
  • 若大小不匹配:将该 chunkunsorted bin 摘除,并按真实大小整理(sort)到 small binslarge bins

4. small bins

  • 物理位置:main_arena.bins[2]main_arena.bins[63]
  • 机制原理:管理常规大小的空闲块(通常 < 1024 字节,具体以实现为准)
  • 数据结构:双向循环链表,遵循 FIFO(先进先出):新释放的插入头部,分配时从尾部取
  • 特征:每个 small bin 索引对应的链表中,chunk 大小唯一且相等(例如 bins[2] 中只存在 0x20 大小的 chunk

5. large bins

  • 物理位置:main_arena.bins[64]main_arena.bins[126]
  • 机制原理:管理大尺寸空闲块。由于尺寸跨度大,一个索引通常对应一个大小范围(range
  • 数据结构:双向循环链表

特征(排序机制)

  • 同一 large bin 内含不同大小的 chunk,放入时通常按从大到小排序
  • 会使用 fd_nextsizebk_nextsize 指针跳过相同大小的 chunk,以加速遍历查找

堆分配(malloc)与释放(free)的宏观顺序

下述为便于理解的“宏观优先级”,不同 glibc 版本与配置可能存在细节差异。

malloc(size) 寻址优先级

  1. 检查 tcache 是否有对应大小的空闲块
  2. 检查 fast bins
  3. 检查 small bins(若 size 符合)
  4. 遍历 unsorted bin(并可能触发分类整理到 small bins / large bins
  5. 检查 large bins(寻找可满足需求的最小 chunk,必要时切割)
  6. 切割 top chunk(堆区最高地址处的未分配区域)
  7. top chunk 不足,触发 sysmalloc(调用 brk 扩展 top chunk,或大内存直接 mmap

free(ptr) 回收优先级

  1. 尝试挂入 tcache(若未满)
  2. size 符合且 tcache 已满:头插挂入 fast bins
  3. 若大于 fast bins 限制:触发合并(Coalescing)逻辑

    • 检查物理相邻前一个块是否空闲(通过 P 位),若空闲则合并
    • 检查物理相邻后一个块是否空闲,若空闲且不在 fast bin 中,则合并
    • 将合并后的 chunk 以双向链表形式挂入 unsorted bin,并清除物理相邻下一个块的 P