一、背景知识
1.1 什么是 Program Break
在 Linux 进程的用户空间内存布局中,堆(heap)位于 BSS 段之上、栈之下。Program break(简称 break)是堆的末尾地址,也就是堆顶指针。堆从低地址向高地址增长,每次增长都是通过调整这个 break 指针来实现的。
高地址 ┌──────────────┐
│ 栈 │ ← 向低地址增长
│ ↓ │
├──────────────┤
│ ↑ │
│ 堆 │ ← 向高地址增长
├──────────────┤
│ BSS 段 │ (未初始化全局变量)
├──────────────┤
│ 数据段 │ (已初始化全局变量)
├──────────────┤
│ 文本段 │ (代码)
低地址 └──────────────┘
注意:上述是经典内存布局示意图。在现代 Linux 系统中,由于 ASLR(地址空间布局随机化)的存在,各段的实际位置会被随机化,且堆和栈之间还存在 mmap 映射区域等,布局远比此图复杂。
1.2 brk 与 sbrk
Linux 提供了 brk 系统调用来操作 program break,而 C 标准库(如 glibc)在此基础上封装了 sbrk 函数:
| 函数 | 原型 | 性质 | 作用 | 返回值 |
|---|---|---|---|---|
brk() | int brk(void *addr) | 系统调用 | 绝对定位:直接将堆顶设置到 addr | 成功返回 0,失败返回 -1 并设置 errno |
sbrk() | void *sbrk(intptr_t increment) | C库函数(glibc封装) | 相对增量:在当前堆顶基础上增加 increment 字节 | 增加前的旧堆顶地址;失败返回 (void*)-1 并设置 errno |
sbrk(0)是一个特殊用法:增量为 0,不改变堆大小,仅返回当前堆顶地址,常用于查询 program break。重要提醒:
sbrk并非系统调用,而是 glibc 等 C 库在brk系统调用之上封装的便捷函数。它通过先调用sbrk(0)获取当前地址,再调用brk()设置新地址来实现相对增量。
1.3 ASLR(地址空间布局随机化)
ASLR 是一种安全机制,通过在每次程序启动时随机化内存区域(栈、堆、mmap 区域、库加载基址等)的起始地址,使攻击者无法依赖固定的内存地址来构造缓冲区溢出攻击。
通过以下文件控制:
# 查看当前状态
cat /proc/sys/kernel/randomize_va_space
# 关闭 ASLR
echo 0 > /proc/sys/kernel/randomize_va_space
# 开启 ASLR(默认值 2,表示完全随机化)
echo 2 > /proc/sys/kernel/randomize_va_space
randomize_va_space 的取值含义:
0:关闭 ASLR1:随机化栈、共享库映射区、mmap 区域2:在 1 的基础上,增加堆的随机化(即 brk 基址随机化)
二、实验代码
#include <stdio.h> // 标准输入输出库,提供 printf()、getchar() 等函数
#include <unistd.h> // POSIX 标准库,提供 brk()、sbrk()、getpid() 等声明
#include <stdint.h> // 提供 intptr_t 等整数类型定义
/**
* @brief 主函数:演示 brk 和 sbrk 对堆内存的操作
* @return int 程序退出状态码,0 表示成功
*/
int main(void) {
// 定义三个 void* 指针,用于存储堆的结束地址(program break)
void *curr_brk, *tmp_brk, *pre_brk;
// getpid() 获取当前进程 PID,方便后续查看 /proc/PID/maps
printf("当前进程 PID:%d\n", getpid());
// ==================== 第一步:初始化 ====================
// sbrk(0) 不改变堆大小,只返回当前 program break
// tmp_brk 保存初始地址,用于最后恢复
tmp_brk = curr_brk = sbrk(0);
printf("初始化后的结束地址:%p\n", curr_brk);
getchar(); // 暂停,等待在另一个终端查看内存映射
// ==================== 第二步:brk() 增加堆空间 ====================
// brk() 是绝对定位:直接将堆顶设置到 curr_brk + 4096
// 注意:将 void* 转换为 char* 后再做指针运算,符合 C 标准
brk((char *)curr_brk + 4096);
curr_brk = sbrk(0);
printf("brk 之后的结束地址:%p\n", curr_brk);
getchar();
// ==================== 第三步:sbrk() 增加堆空间 ====================
// sbrk(4096) 是相对增量:在当前堆顶基础上增加 4096 字节
// 返回值是增加前的旧堆顶地址
pre_brk = sbrk(4096);
curr_brk = sbrk(0);
printf("sbrk 返回值(即之前的结束地址):%p\n", pre_brk);
printf("sbrk 之后的结束地址:%p\n", curr_brk);
getchar();
// ==================== 第四步:恢复堆空间 ====================
// brk(tmp_brk) 将堆顶收缩回初始地址,释放之前分配的空间
brk(tmp_brk);
curr_brk = sbrk(0);
printf("恢复到初始化时的结束地址:%p\n", curr_brk);
getchar();
return 0; // 主函数正常返回
}
三、实验操作
3.1 两个终端配合
本实验需要开启两个终端:
| 终端 | 用途 |
|---|---|
| 终端 1 | 运行 ./a.out,程序会在每个 getchar() 处暂停 |
| 终端 2 | 运行 `cat /proc//maps |
3.2 操作流程
终端 1 终端 2
───────────────────────────── ────────────────────────────────
$ ./a.out $ cat /proc/27759/maps | grep heap
当前进程 PID:27759 56558000-56579000 rw-p ... [heap]
初始化后的结束地址:0x56579000
(程序暂停,等待输入...) → (观察堆地址)
(按回车继续)
brk 之后的结束地址:0x5657a000
(程序暂停...) → $ cat /proc/27759/maps | grep heap
56558000-5657a000 rw-p ... [heap]
(堆结束地址增加了 4KB)
(按回车继续)
sbrk 返回值:0x5657a000
sbrk 之后的结束地址:0x5657b000
(程序暂停...) → $ cat /proc/27759/maps | grep heap
56558000-5657b000 rw-p ... [heap]
(堆结束地址又增加了 4KB)
...
注意:PID 每次运行可能不同,终端 2 要使用终端 1 输出的实际 PID。
四、实验结果分析
4.1 关闭 ASLR(randomize_va_space = 0)
第一步:初始化
当前进程 PID:27759
初始化后的结束地址:0x56579000
# cat /proc/27759/maps
...
56557000-56558000 rw-p 00001000 08:01 28587506 /home/a.out ← 数据段
56558000-56579000 rw-p 00000000 00:00 0 [heap] ← 堆
...
- 数据段范围:
0x56557000 - 0x56558000,大小 =0x1000= 4KB - 堆范围:
0x56558000 - 0x56579000,大小 =0x21000= 132KB - 数据段结束地址
0x56558000= 堆开始地址0x56558000→ 在本实验环境中两者紧密相连 sbrk(0)返回0x56579000→ 这就是当前的 program break
补充说明:为何堆初始大小已有 132KB?这是因为程序中调用了
printf()等标准库函数,这些函数内部会调用malloc()进行内存分配,从而触发 glibc 的内存分配器预先扩展了堆空间。如果程序完全不调用任何会触发 malloc 的函数,初始堆大小会更小(通常为 0 或仅 1 页)。
第二步:brk(curr_brk + 4096)
brk 之后的结束地址:0x5657a000
56558000-5657a000 rw-p 00000000 00:00 0 [heap]
- 堆结束地址:
0x56579000→0x5657a000 - 差值 =
0x5657a000 - 0x56579000=0x1000= 4096 字节 = 1 页 - 堆开始地址
0x56558000不变 brk()是绝对定位:直接告诉内核"把堆顶设到这个地址"
第三步:sbrk(4096)
sbrk 返回值(即之前的结束地址):0x5657a000
sbrk 之后的结束地址:0x5657b000
56558000-5657b000 rw-p 00000000 00:00 0 [heap]
sbrk(4096)返回0x5657a000→ 这是增加前的旧堆顶- 堆结束地址:
0x5657a000→0x5657b000 - 差值 =
0x5657b000 - 0x5657a000=0x1000= 4096 字节 = 1 页 sbrk()是相对增量:在当前堆顶基础上增加指定字节数
第四步:brk(tmp_brk) 恢复
恢复到初始化时的结束地址:0x56579000
56558000-56579000 rw-p 00000000 00:00 0 [heap]
tmp_brk保存的是第一步的初始值0x56579000brk(0x56579000)将堆顶收缩回初始位置- 堆大小回到
0x21000= 132KB - 证明
brk()既可以扩大也可以缩小堆
地址变化汇总
| 步骤 | 操作 | 堆开始 | 堆结束 | 堆大小 | 变化量 |
|---|---|---|---|---|---|
| 初始化 | sbrk(0) | 0x56558000 | 0x56579000 | 132KB | — |
| brk | brk(curr+4096) | 0x56558000 | 0x5657a000 | 136KB | +4KB |
| sbrk | sbrk(4096) | 0x56558000 | 0x5657b000 | 140KB | +4KB |
| 恢复 | brk(tmp_brk) | 0x56558000 | 0x56579000 | 132KB | -8KB |
4.2 开启 ASLR(randomize_va_space = 2)
当前进程 PID:28025
初始化后的结束地址:0x578ad000
# cat /proc/28025/maps
...
5663f000-56640000 rw-p 00001000 08:01 28587506 /home/a.out ← 数据段
5788c000-578ad000 rw-p 00000000 00:00 0 [heap] ← 堆
...
关键发现
| 项目 | 关闭 ASLR | 开启 ASLR |
|---|---|---|
| 数据段结束 | 0x56558000 | 0x56640000 |
| 堆开始 | 0x56558000 | 0x5788c000 |
| 堆结束 | 0x56579000 | 0x578ad000 |
| 数据段与堆的间隙 | 0(本例中紧密相连) | 0x5788c000 - 0x56640000 = 0x124C000 ≈ 19.2MB |
| 堆大小 | 132KB | 132KB(相同) |
分析:
-
数据段结束地址 ≠ 堆开始地址(开启 ASLR 时)
- 关闭 ASLR:
0x56558000 == 0x56558000→ 本例中相等,紧密相连 - 开启 ASLR:
0x56640000 ≠ 0x5788c000→ 相差约 19.2MB
注意:即使关闭 ASLR,堆的起始地址也不一定精确等于数据段结束地址。内核会将
start_brk对齐到页边界,如果数据段结束地址本身不是页对齐的,两者之间会存在一个小于 1 页的间隙。 - 关闭 ASLR:
-
ASLR 在数据段和堆之间插入了随机间隙
- 这个随机间隙是安全机制的一部分
- 使得攻击者无法通过已知的数据段地址推算出堆地址
- 每次运行程序,这个间隙大小都会不同
-
堆的相对行为不变
- 无论 ASLR 是否开启,
brk()和sbrk()的增长行为完全一致 - 堆大小仍然是
0x21000= 132KB(0x578ad000 - 0x5788c000 = 0x21000) - ASLR 只影响起始位置的随机化,不影响堆的管理逻辑
- 无论 ASLR 是否开启,
五、brk 与 sbrk 对比总结
| 对比项 | brk(addr) | sbrk(increment) |
|---|---|---|
| 性质 | 系统调用 | C 库函数(glibc 封装 brk) |
| 参数含义 | 新的堆顶绝对地址 | 堆顶的增量(可正可负) |
| 返回值 | 成功返回 0,失败返回 -1 | 返回旧的堆顶地址;失败返回 (void*)-1 |
| 典型用法 | 直接设置堆顶到指定位置 | 分配内存,返回值可直接作为指针 |
| 缩小堆 | brk(较小的地址) | sbrk(-N) |
| 查询堆顶 | 不支持,需配合 sbrk(0) | sbrk(0) |
sbrk 返回值的设计意义
sbrk() 返回旧地址而非新地址,这个设计非常巧妙——返回值正好是新分配内存区域的起始指针:
// 分配 4096 字节,ptr 直接指向新分配区域的起始位置
void *ptr = sbrk(4096);
if (ptr != (void *)-1) {
// ptr 可以安全使用,范围是 [ptr, ptr+4096)
memset(ptr, 0, 4096);
}
注意:在多线程环境下,
brk/sbrk不是线程安全的,因为它们操作的是全局的堆指针。实际应用中应使用malloc等线程安全的内存分配函数。
六、延伸:与 malloc 的关系
6.1 为什么 malloc 不直接调用 brk/sbrk
我们在前面实验中看到,brk()/sbrk() 是非常底层的原语——它们只能整体移动堆顶,没有"分配指定大小块"的能力,更没有"释放某一块"的能力。如果每次分配都直接调 sbrk(size),会导致:
- 无法精确释放:
sbrk(-N)只能从堆顶收缩,中间释放的碎片无法归还内核 - 性能极差:每次分配/释放都要陷入内核态(系统调用开销)
- 无内存复用:释放的内存无法被后续分配重用
因此 glibc 的 ptmalloc2 分配器在 brk()/sbrk() 和 mmap() 之上构建了一整套内存管理层,核心思想是:向内核批量申请大块内存,再在用户态切分为小块分配。
6.2 malloc 的两种底层内存来源
┌─────────────────────────────────────────────────────┐
│ 用户程序 │
│ malloc() / free() │
├─────────────────────────────────────────────────────┤
│ ptmalloc2 分配器 │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ Arena │ │ mmap 区域 │ │
│ │ (brk 堆) │ │ (大块匿名映射) │ │
│ └──────┬───────┘ └─────────┬─────────┘ │
├───────────┼────────────────────────┼────────────────┤
│ ▼ ▼ │
│ brk() 系统调用 mmap() 系统调用 │
├─────────────────────────────────────────────────────┤
│ Linux 内核 │
└─────────────────────────────────────────────────────┘
| 分配方式 | 底层系统调用 | 适用场景 | 特点 |
|---|---|---|---|
| Arena(brk 堆) | brk() | 小块内存(默认 < 128KB) | 内存连续、可扩展收缩、通过 top chunk 管理 |
| mmap 匿名映射 | mmap() | 大块内存(默认 >= 128KB) | 独立映射、free 时直接 munmap 归还内核 |
阈值说明:glibc 默认的
M_MMAP_THRESHOLD初始值为 128KB(DEFAULT_MMAP_THRESHOLD)。但从 glibc 2.18 开始引入了动态 mmap 阈值调整机制:当释放一个大于当前阈值的 mmap 块时,分配器会自动将阈值提升到该块的大小(上限为DEFAULT_MMAP_THRESHOLD_MAX,32 位系统 512KB,64 位系统 32MB)。因此"小于 128KB 用 brk,大于等于 128KB 用 mmap"只是初始行为,运行中阈值会动态变化。
6.3 ptmalloc2 的核心数据结构
6.3.1 内存块(chunk)结构
ptmalloc2 将堆内存划分为连续的"块"(chunk),每个 chunk 的结构如下:
┌────────────────────────────────────────────────────┐
│ malloc_chunk 结构 │
├──────────┬──────────┬───────────┬──────────────────┤
│ prev_size │ size │ fd/bk │ 用户数据区 │
│ (8 字节) │ (8 字节) │ (8 字节) │ (实际分配大小) │
└──────────┴──────────┴───────────┴──────────────────┘
← 元数据(header,16 字节)→ ← malloc 返回的指针 →
各字段含义(以 64 位系统为例):
| 字段 | 大小 | 作用 |
|---|---|---|
prev_size | 8 字节 | 前一个 chunk 的大小。仅当前一个 chunk 空闲时有意义(用于合并相邻空闲块) |
size | 8 字节 | 当前 chunk 的大小(包含 header)。低 3 位为标志位:bit0 = PREV_INUSE,bit1 = IS_MMAPPED,bit2 = NON_MAIN_ARENA |
fd | 8 字节 | Forward pointer:指向 bins 双向链表中的下一个空闲 chunk(仅空闲时有效) |
bk | 8 字节 | Backward pointer:指向 bins 双向链表中的上一个空闲 chunk(仅空闲时有效) |
关键设计:当一个 chunk 被分配时,
fd和bk字段的位置就是用户数据区的起始位置,所以malloc返回的指针紧挨在size字段之后。这意味着用户数据区的起始地址 = chunk 起始地址 + 16 字节。这也是为什么每个 malloc 分配都有至少 16 字节的内部开销。
6.3.2 size 字段的三个标志位
size 字段的低 3 位被复用为标志位(因为所有 chunk 都按 16 字节对齐,低 3 位恒为 0):
| 标志位 | 含义 |
|---|---|
PREV_INUSE(bit0) | 前一个 chunk 是否正在使用。为 1 表示前一个 chunk 是已分配的 |
IS_MMAPPED(bit1) | 该 chunk 是否通过 mmap() 分配 |
NON_MAIN_ARENA(bit2) | 该 chunk 是否属于非主 arena(线程 arena) |
实际大小 = size & ~0x7(清除低 3 位标志后得到真实大小)。
6.4 Bins:空闲块的管理
ptmalloc2 使用 bins(箱)来管理不同大小的空闲 chunk,是 malloc 高效分配的核心。
6.4.1 Bins 分类
Bins
├── Unsorted Bin(1 个)
│ └── 最近释放的 chunk 暂存区,释放时先放入这里
│
├── Small Bins(62 个)
│ └── 索引 2~63,每个 bin 管理固定大小的 chunk
│ 大小范围:16 字节 ~ 504 字节(64 位系统),步长 16 字节
│
└── Large Bins(63 个)
└── 索引 64~126,每个 bin 管理一个大小区间的 chunk
大小范围:512 字节以上,按区间递增
工作流程:
- 分配时:先在对应大小的 bin 中查找,找到则从链表摘除并返回
- 释放时:并非立即归还内核,而是将 chunk 放入 Unsorted Bin 暂存
- 后续分配扫描 Unsorted Bin 时,会将 chunk 分类移动到对应的 Small/Large Bin 中
这种"延迟分类"的设计减少了释放时的开销。
6.4.2 Fast Bins:小内存快速通道
对于非常小的分配(默认 <= 128 字节,共 10 个 fast bin),ptmalloc2 使用 Fast Bins 做进一步加速:
- 单链表(非双向链表),操作更快(LIFO 栈式)
- 不合并相邻空闲块,避免碎片合并的开销
- 释放的 chunk 直接放入 fast bin,分配时优先从 fast bin 取
这就是为什么频繁分配/释放小对象时 malloc 性能很好的原因——绝大多数操作都在用户态的 fast bin 单链表上完成,不涉及系统调用。
6.5 Top Chunk 与堆扩展
每个 Arena(brk 堆)的顶部有一个特殊的 Top Chunk,它是堆中最高地址的空闲 chunk,充当"预留空间":
┌──────────┐
│ chunk 1 │ ← 已分配
├──────────┤
│ chunk 2 │ ← 空闲(在某个 bin 中)
├──────────┤
│ chunk 3 │ ← 已分配
├──────────┤
│Top Chunk │ ← 空闲,但不属于任何 bin
│ │ 新分配从这里切割
├──────────┤ ← program break(brk 堆顶)
│ (未映射) │
└──────────┘
分配流程:
- 在 bins 中查找合适大小的空闲 chunk
- 若 bins 中没有,尝试从 Top Chunk 切割
- 若 Top Chunk 不够大,调用
brk()/sbrk()扩展堆,增大 Top Chunk - 若通过 brk 扩展失败(堆已达上限或地址空间不足),尝试用
mmap()分配
收缩流程:
free()释放的内存通常只放回 bins,不归还内核- 当 Top Chunk 超过一定阈值(
trim_threshold,默认 128KB)时,ptmalloc2 会调用brk()收缩堆顶,将多余页面归还内核 - mmap 分配的大块内存在
free时会直接munmap()归还内核
6.6 多线程支持:Arena 机制
ptmalloc2 通过 Arena 机制支持多线程并发分配,避免所有线程竞争同一个堆:
┌─────────────────────────────────────────────┐
│ 主线程 │
│ ┌─────────────────────────────────┐ │
│ │ Main Arena(使用 brk 堆) │ │
│ │ - 所有线程共享 │ │
│ │ - 通过 mutex 保护 │ │
│ └─────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 线程 1 │
│ ┌─────────────────────────────────┐ │
│ │ Thread Arena 1(使用 mmap 堆) │ │
│ └─────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 线程 2 │
│ ┌─────────────────────────────────┐ │
│ │ Thread Arena 2(使用 mmap 堆) │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────┘
- Main Arena:主线程使用,对应进程的 brk 堆
- Thread Arena:其他线程各自拥有(或共享),使用
mmap()创建独立堆 - Arena 数量有上限(默认 32 位系统
2 × CPU核心数,64 位系统8 × CPU核心数),超出时线程需要排队等待已有的 Arena
对比 brk/sbrk:这就是为什么说
brk/sbrk不是线程安全的——它们操作的是全局唯一的 program break,而 malloc 通过 Arena 机制将不同线程的堆隔离,实现了线程安全(每个 Arena 有自己的锁)。
6.7 malloc 与 brk/sbrk 的交互实验
下面通过一段代码直观观察 malloc 是如何间接使用 brk 的:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
void *brk_before, *brk_after;
printf("PID: %d\n", getpid());
// ====== 阶段 1:首次 malloc,观察堆是否扩展 ======
brk_before = sbrk(0);
printf("[阶段1] malloc 前,program break: %p\n", brk_before);
// 分配一块小内存(256 字节)
void *p1 = malloc(256);
memset(p1, 'A', 256);
brk_after = sbrk(0);
printf("[阶段1] malloc 256B 后,program break: %p\n", brk_after);
printf("[阶段1] 堆增长量: %ld 字节\n\n",
(char *)brk_after - (char *)brk_before);
// ====== 阶段 2:多次 malloc 小块,观察是否需要再次 brk ======
brk_before = sbrk(0);
void *p2 = malloc(64); // 64 字节
void *p3 = malloc(128); // 128 字节
void *p4 = malloc(32); // 32 字节
brk_after = sbrk(0);
printf("[阶段2] 再 malloc 64+128+32B 后,program break: %p\n", brk_after);
printf("[阶段2] 堆增长量: %ld 字节", (char *)brk_after - (char *)brk_before);
printf("(若为 0,说明复用了阶段 1 预分配的 Top Chunk)\n\n");
// ====== 阶段 3:free 后 malloc 同样大小,观察是否复用 ======
free(p1);
free(p2);
free(p3);
// p1 释放后放入 unsorted bin / small bin
// 再次分配 256 字节,应从 bin 中复用,不需要 brk
brk_before = sbrk(0);
void *p5 = malloc(256);
brk_after = sbrk(0);
printf("[阶段3] free 后再 malloc 256B,program break: %p\n", brk_after);
printf("[阶段3] 堆增长量: %ld 字节", (char *)brk_after - (char *)brk_before);
printf("(若为 0,说明从 bin 中复用了释放的 chunk)\n\n");
// ====== 阶段 4:大块 malloc,观察是否使用 mmap ======
printf("[阶段4] 尝试分配 1MB 大内存...\n");
void *big = malloc(1024 * 1024); // 1MB,远超 mmap 阈值
if (big) {
printf("[阶段4] 分配成功,指针: %p\n", big);
printf("[阶段4] 1MB 大块通常由 mmap 分配,不在 brk 堆中\n");
printf("[阶段4] 此时 program break: %p(应与之前相同)\n\n", sbrk(0));
}
free(big);
free(p4);
free(p5);
printf("按回车退出...\n");
getchar();
return 0;
}
预期输出分析:
| 阶段 | 预期行为 | 原因 |
|---|---|---|
| 阶段 1 | 堆不增长(增量为 0) | glibc 在程序启动时(main() 之前)已预分配堆空间,malloc(256) 直接从现有 Top Chunk 切割 |
| 阶段 2 | 堆不增长(增量为 0) | 64+128+32 字节远小于 Top Chunk,直接从 Top Chunk 切割 |
| 阶段 3 | 堆不增长(增量为 0) | 释放的 256B chunk 被放入 bin,再次 malloc 256B 直接从 bin 取出复用 |
| 阶段 4 | program break 不变 | 1MB 超过 mmap 阈值,通过 mmap() 分配独立映射区域,不走 brk 堆 |
注意:文章第 4.1 节中提到的"初始堆大小 132KB"是在
printf()等库函数调用后观察到的。现代 glibc 在程序启动代码(__libc_start_main)中就会初始化堆,因此当main()开始执行时,堆已经存在。如果要观察堆从 0 开始增长的过程,需要使用更底层的程序(不链接标准库或使用静态链接并避免任何库调用)。
6.8 brk/sbrk 与 malloc/free 的对比总结
| 对比项 | brk()/sbrk() | malloc()/free() |
|---|---|---|
| 抽象层次 | 系统调用 / 库函数(极底层) | C 标准库函数(高层封装) |
| 分配粒度 | 只能移动堆顶,无分块能力 | 任意大小,精确分配 |
| 释放能力 | 只能从顶部收缩 | 可释放任意位置的块 |
| 碎片处理 | 无 | bins 管理、合并相邻空闲块 |
| 性能 | 每次调用都陷入内核态 | 大部分操作在用户态完成(fast bin) |
| 线程安全 | 不安全 | 安全(Arena + 锁) |
| 大块内存 | 不支持 | 自动切换到 mmap |
| 适用场景 | 实现内存分配器、教学实验 | 日常开发 |
核心结论:brk/sbrk 是构建内存分配器的"原材料",而 malloc/free 是在这之上建造的"成品"。除非你在写一个自定义的内存分配器,否则永远应该使用 malloc/free。
七、结论
brk()是系统调用,支持绝对定位,直接设置 program break 到指定地址sbrk()是 C 库函数,在brk基础上封装了相对增量操作,返回旧堆顶,适合作为 malloc 的底层实现- 堆向高地址增长,内核以页为单位管理,因此堆大小的实际调整通常以页(4096 字节)为最小单位
- 关闭 ASLR 时,数据段和堆通常紧邻(具体取决于数据段结束地址的页对齐情况),地址可预测
- 开启 ASLR 时,数据段和堆之间被插入随机间隙(本实验中约 19.2MB),地址不可预测,但堆的相对增长行为不变
brk()既可以扩大也可以缩小堆,将参数设为较小的地址即可释放堆空间- 实际开发中应使用 malloc/free,而非直接调用 brk/sbrk,因为 malloc 提供了更完善的内存管理和线程安全保障
- ptmalloc2 通过 chunk + bins + arena 三层架构实现高效分配:chunk 是基本单位,bins 管理空闲块,arena 实现线程隔离
- malloc 有两种底层内存来源:小块走 brk 堆(通过 Top Chunk 扩展),大块走 mmap 匿名映射,阈值会动态调整

2339

被折叠的 条评论
为什么被折叠?



