3、brk/sbrk系统调用:堆的起点,如何通过调整program break来分配内存
讲内存分配,绕不开一个最原始、最底层的机制——program break。
说白了,它就是进程堆区的边界指针。你想想看,一个进程启动后,内核会给它一块连续的内存区域作为堆(heap)。这块区域的终点,就叫 program break。初始状态下,它和数据段的末尾挨在一起。
我们要分配堆内存,本质上就是把这个边界往高地址方向推。推上去,就有了新空间。释放呢?把边界拉回来就行。
嗯,这里要注意:brk 和 sbrk 就是干这个活的两个系统调用。它们直接操作 program break,是 Linux 下最原始、最底层的堆管理接口。
3.1 brk 和 sbrk 的接口定义
先看它们的函数原型:
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);
这两个函数的行为其实很简单:
- brk(void *addr):直接把 program break 设置到 addr 指定的位置。成功返回 0,失败返回 -1。
- sbrk(intptr_t increment):在当前的 program break 基础上增加 increment 字节。返回的是调整前的 program break 地址。如果 increment 为 0,就返回当前 program break 的地址(不修改)。
我个人习惯用 sbrk,因为它返回旧地址,方便我们记录分配的内存起点。brk 用得少,除非你要精确控制堆的终点。
核心理解:sbrk(0) 可以获取当前堆的结束地址,这是一个非常常用的调试技巧。我在排查内存泄漏时,经常用它在关键位置打点,看堆有没有异常增长。
3.2 分配与释放的本质
分配内存时,我们调用 sbrk(n),内核把 program break 往上推 n 字节。返回的地址就是新分配内存的起始地址。
释放呢?理论上调用 brk 把边界拉回来就行。但实际中,brk 只能从堆顶释放。什么意思?你想想看,如果你先分配了 A,再分配了 B,然后想释放 A——但 B 在 A 的上面,你没法把 program break 降到 A 的起点,因为那样会把 B 也砍掉。
这就是 brk/sbrk 的天然缺陷:它只支持 LIFO(后进先出)的释放模式。实际项目中,我们几乎不会直接用它来管理内存,而是用它作为底层基石,上面再盖 malloc/free 这样的分配器。
我曾经踩过的坑:早期写一个嵌入式系统时,我直接用 sbrk 分配内存,然后手动管理。结果因为释放顺序不对,堆空间越来越碎片化,最后程序跑着跑着就 OOM 了。从那以后,我再也不敢在生产代码里直接裸用 brk/sbrk。
3.3 一个简单的示例
下面这个例子,演示了如何用 sbrk 分配和释放一块内存:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
void *old_break = sbrk(0);
printf("初始 program break: %p\n", old_break);
// 分配 1024 字节
void *p = sbrk(1024);
if (p == (void*)-1) {
perror("sbrk");
return 1;
}
printf("分配后 program break: %p\n", sbrk(0));
printf("分配的内存地址: %p\n", p);
// 使用这块内存
strcpy((char*)p, "Hello, brk/sbrk!");
printf("内容: %s\n", (char*)p);
// 释放:把 program break 拉回原来的位置
brk(old_break);
printf("释放后 program break: %p\n", sbrk(0));
return 0;
}
运行结果大致如下:
初始 program break: 0x555555559000
分配后 program break: 0x555555559400
分配的内存地址: 0x555555559000
内容: Hello, brk/sbrk!
释放后 program break: 0x555555559000
你看,分配后 program break 增加了 0x400(1024 字节),释放后又回到了原点。这就是最原始的堆操作。
3.4 brk/sbrk 与 mmap 的对比
你可能听说过,现代 malloc 实现(比如 glibc 的 ptmalloc)不只依赖 brk,还会用 mmap。为什么?
我整理了一个对比表格:
| 特性 | brk/sbrk | mmap |
|---|---|---|
| 分配方式 | 调整 program break | 映射匿名内存页 |
| 释放方式 | 只能从堆顶释放(LIFO) | 可以单独解除任意映射 |
| 性能 | 非常快(只需修改一个指针) | 较慢(涉及页表操作) |
| 适用场景 | 小内存、连续分配 | 大内存、需要独立释放 |
| 地址空间 | 堆区连续增长 | 可以在任意位置映射 |
说白了,brk 适合小内存的快速分配,mmap 适合大块内存的灵活管理。现代 malloc 的策略通常是:小于 128KB 的分配走 brk,大于 128KB 的走 mmap。这个阈值在不同实现里可能不一样,但思路是一致的。
3.5 内核视角:program break 的实现
从内核角度看,program break 对应的是进程的 mm_struct 中的 brk 字段。每次调用 brk/sbrk,内核会检查新的地址是否合法(不能超过 rlimit(RLIMIT_DATA) 的限制),然后更新这个字段。
如果新的 program break 跨越了页边界,内核还需要分配或释放物理内存页。注意,brk 是按页对齐的——即使你只申请 1 字节,内核也会分配一整页(通常是 4KB)。这就是为什么 sbrk(1) 之后,实际可用的堆空间可能比 1 字节多得多。
一个小技巧:如果你用 sbrk(0) 观察 program break,会发现它总是页对齐的。这不是巧合,而是内核强制对齐的结果。所以,不要以为 sbrk(1) 只消耗了 1 字节的地址空间——它实际上占用了 4KB。
3.6 为什么我们不直接使用 brk/sbrk?
讲到这里,你可能会问:既然 brk/sbrk 这么底层、这么高效,为什么我们平时写代码都用 malloc,而不是直接调 sbrk?
原因有三:
- 碎片化问题:前面说了,brk 只能从堆顶释放。如果分配和释放的顺序乱了,堆中间就会出现空洞,无法被重用。
- 线程安全:brk/sbrk 不是线程安全的。多线程环境下,两个线程同时调用 sbrk,会导致 program break 混乱。
- 缺乏灵活性:你没法释放中间的一块内存,也没法调整已分配块的大小。
所以,brk/sbrk 是地基,malloc/free 是房子。我们住房子,但不直接睡在地基上。
嗯,这一章的内容就到这里。brk/sbrk 虽然简单,但它是理解整个堆管理体系的起点。搞懂了它,后面再看 malloc 的实现,就会豁然开朗。