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?

原因有三:

  1. 碎片化问题:前面说了,brk 只能从堆顶释放。如果分配和释放的顺序乱了,堆中间就会出现空洞,无法被重用。
  2. 线程安全:brk/sbrk 不是线程安全的。多线程环境下,两个线程同时调用 sbrk,会导致 program break 混乱。
  3. 缺乏灵活性:你没法释放中间的一块内存,也没法调整已分配块的大小。

所以,brk/sbrk 是地基,malloc/free 是房子。我们住房子,但不直接睡在地基上。

嗯,这一章的内容就到这里。brk/sbrk 虽然简单,但它是理解整个堆管理体系的起点。搞懂了它,后面再看 malloc 的实现,就会豁然开朗。

可可精品资料库