每个 C 程序员都知道 malloc() 返回可用内存的指针。但在指针到达之前,至少要经过三层:glibc 的内部 bin、一个系统调用(brkmmap),以及内核的页分配器。每层都有自己的约束,理解它们就是"我的进程 RSS 一直在涨"和"我知道为什么"的区别。

本文聚焦于系统调用边界:当 glibc 决定需要从内核拿更多内存时,实际发生了什么。

malloc 之前:内存从哪里来

内核加载 ELF 二进制时,进程的地址空间被划分成区域,内核用 struct mm_struct 追踪:

struct mm_struct {
    unsigned long start_code, end_code;   // text 段边界
    unsigned long start_data, end_data;   // data 段边界
    unsigned long start_brk, brk;         // 堆边界
    unsigned long start_stack;            // 栈顶
    unsigned long arg_start, arg_end;     // argv
    unsigned long env_start, env_end;     // environ
    struct vm_area_struct *mmap;          // VMA 链表
    pgd_t *pgd;                           // 页表根
};

堆分配相关的两个字段:

  • start_brk——初始 program break,exec 时设定一次。ASLR 关闭时,start_brk == end_data(BSS 段末尾)。ASLR 级别 2 时,两者之间有随机偏移。
  • brk——当前 program break,即堆顶。初始等于 start_brk,随堆增长向上移。可以下移,但只能从顶部——不能在中间打洞。

堆位于数据段和 mmap 区域之间:

高地址
+-----------------------------+
| 内核空间                    |  (32 位 3GB+)
+-----------------------------+
| 栈(向下增长)              |
| mmap 区域(库、线程)       |  ← libc.so, ld.so, 线程栈
| 堆(通过 brk 向上增长)     |  ← brk 指针上移
+-----------------------------+
| BSS(未初始化全局变量)     |
+-----------------------------+
| Data(已初始化全局变量)    |
+-----------------------------+
| Text(代码)                 |
+-----------------------------+
低地址

brk:简单的分配器

brk 是最古老的 Unix 系统调用之一。它只做一件事:移动 program break 指针。内核响应:在新旧 break 之间映射或取消映射页面。

brk 在系统调用层面的样子

void *sbrk(intptr_t increment);   // 库封装的包装
int brk(void *addr);              // 设置 break 到绝对地址

sbrk(0) 返回当前 break 而不改变它——用于自省。brk(new_addr) 设置 break 到任意值。新 break 高于旧 break 时内核映射匿名页。低于则取消映射。粒度不低于页:break 移动 1 字节也会映射完整 4KB 页面。

实验

#include <stdio.h>
#include <unistd.h>

int main() {
    void *curr;

    printf("PID: %d\n", getpid());
    curr = sbrk(0);
    printf("Initial program break: %p\n", curr);
    getchar();  // ← 在这里检查 /proc/PID/maps

    brk(curr + 4096);        // 扩展一页
    curr = sbrk(0);
    printf("After brk(+4KB): %p\n", curr);
    getchar();  // ← 再次检查

    brk(curr - 4096);        // 缩回
    curr = sbrk(0);
    printf("After shrink: %p\n", curr);
    getchar();
    return 0;
}

brk 之前:进程没有独立的 [heap] 段。最后映射的区域是 end_data

0804a000-0804b000 rw-p 00001000 08:01 539624  /home/user/sbrk_test

brk(+4096) 之后:出现 [heap]

0804b000-0804c000 rw-p 00000000 00:00 0       [heap]

start_brk = 0x0804b000 = end_databrk = 0x0804c000。

收缩后:[heap] 消失。页面被取消映射。

关键限制:brk 是个栈

只能从顶部收缩。如果线程 A 在地址 X 分配,线程 B 在 X+1000 分配,线程 A 释放了它的内存,brk 不能降低——因为线程 B 的分配还在顶部。这就是为什么 glibc 的 free() 很少用负增量调 brk。内存留在 glibc 的 bin 里供后续分配复用,但内核拿不回去。

实际后果:一个进程分配了 100MB,从中间释放了 99MB,然后闲置,RSS 仍然显示 ~100MB。堆不能收缩,因为剩下的 1MB 在顶部。

glibc 如何为 main arena 使用 brk

main() 第一次调 malloc(1000) 时,glibc 不调 sbrk(1000)。它调 sbrk(132*1024)(32 位默认 arena 大小;64 位更小但仍然远大于请求)。这就是 main arena。

为什么程序只要 1KB 却分配 132KB?因为系统调用代价很高(上下文切换,加内核页表和 VMA 操作)。glibc 把内核当批发商,自己处理零售。多余的 131KB 留在 glibc 的内部空闲链表里,服务后续的 malloc() 调用,不用再碰内核。

这就解释了常见的"问题":你 malloc(100),看 /proc/pid/maps,发现有 132KB 的堆。你没有泄漏——glibc 只是预先分配了。

只有当 132KB arena 用尽时,glibc 才再次调 sbrk 扩展。你 free() 大部分内存后,brk 几乎从不往回走。内核的 RSS 追踪显示这些页面仍然驻留——这就是为什么有突发分配模式的长时间运行进程会保持高水位 RSS。

brk 何时失败

brk 不能扩展到 mmap 区域。如果共享库和线程栈映射在堆附近,program break 可能撞到 mmap 基址。实践中,这发生在 32 位系统上——3GB 用户地址空间很紧张。64 位上地址空间足够大,这不是问题。

brk 不能扩展时,glibc 回退到 mmap——即使对于小请求。这就是为什么进程在大量分配后可能有多个类似堆的区域。

mmap:直接分配器

对于超过阈值的分配(默认 128KB,由 M_MMAP_THRESHOLD 控制),glibc 完全绕过 brk,调 mmap 创建匿名映射。特性不同:

  • 立即释放free() 时通过 munmap 立即还给内核。
  • ASLR 随机化:地址独立于 brk 堆。
  • 无主 arena 碎片:每个大分配是自己的映射。
  • 开销:每次 mmap/munmap 需要创建/销毁 VMA,某些架构上需要 TLB 刷新。比从 arena 分配慢。

线程 arena 也通过 mmap 分配。新线程第一次调 malloc() 时,glibc 创建 1MB 的 mmap 区域(仅 132KB 是活跃 arena,其余保留)。

实验

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    printf("PID: %d\n", getpid());
    getchar();  // 检查 maps

    char *p = mmap(NULL, 132*1024,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    printf("mmap'd at: %p\n", p);
    getchar();  // 再次检查 maps

    munmap(p, 132*1024);
    printf("freed\n");
    getchar();  // 检查 free 后的 maps
    return 0;
}

mmap 之前:

08048000-0804b000 r-xp/rw-p  ... /home/user/mmap_test  (binary)
b7e21000-b7e22000 rw-p ... (anonymous, likely from ld.so)

mmap 之后:

b7e00000-b7e22000 rw-p 00000000 00:00 0

出现了一个 136KB 的区域(0x22000 = 136KB,从 132KB 对齐上取)。地址(0xb7e00000)在 mmap 区域,远离 brk 堆。

munmap 之后:

b7e21000-b7e22000 rw-p 00000000 00:00 0

132KB 区域消失了,只剩一个已有映射的残余。这是与 brk 的关键区别:munmap 真的把内存还给操作系统。RSS 下降。

正确读取 /proc/pid/maps

address           perms   offset   dev   inode  pathname
559a7a0e9000-559a7a0ea000 r-xp 00000000 08:01 539691  /bin/somebinary
7f8c4a0e9000-7f8c4a0ea000 r-xp 00000000 08:01 123456  /lib/x86_64-linux-gnu/libc.so
7ffc9a0e9000-7ffc9a0ea000 rw-p 00000000 00:00 0       [stack]
字段 含义
559a... 虚拟地址范围(现代系统 ASLR 随机化)
r-xp 权限:r/w/x,p=私有 s=共享
offset 在 backing 文件中的偏移(匿名映射为 0)
dev:inode backing 文件的设备号和 inode(匿名映射为 0:0)
pathname [heap][stack][vdso]、文件路径,或空白

没有路径名且 rw-p 的映射是匿名内存——通常由 malloc、线程栈或加载器自身通过 mmap 分配。

内核侧的分配:系统调用之后

用户空间调 brkmmap 时,内核不会立即分配物理页。它只在 mm_struct 里设置 VMA 结构。物理页通过缺页处理器在首次访问时延迟分配。

Buddy 分配器(页级)

内核的页分配器用 buddy system 管理物理页。空闲页按 order-0(4KB)、order-1(8KB)、order-2(16KB)到 order-10(4MB)分组。分配请求通过分割大块满足;释放时合并相邻 buddy。

用户态 API:

struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);
// 返回 2^order 个连续物理页

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
// 同上,但返回内核虚拟地址

这些返回物理连续的内存。虚拟地址和物理地址有固定关系:

#define __pa(x)    ((unsigned long)(x) - PAGE_OFFSET)   // virt → phys
#define __va(x)    ((void *)((unsigned long)(x) + PAGE_OFFSET))  // phys → virt

Slab 分配器(子页)

kmalloc 是内核版的 malloc。基于 slab 分配器,管理固定大小对象的缓存:

void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *p);

kmalloc 返回物理连续内存,适合 DMA 和硬件交互。大多数架构上限约 4MB。

Slab 缓存提供特定大小的对象,无内部碎片。例如,64 字节缓存总是返回 64 字节对象,即使你只申请 20 字节。kmalloc 根据大小分派到对应缓存。

vmalloc(非连续)

不需要物理连续时(大多数数据缓冲区),vmalloc 从保留的虚拟地址范围分配:

void *vmalloc(unsigned long size);
void vfree(const void *addr);

虚拟地址连续,但物理页是分散的。需要通过页表映射,所以 vmallockmalloc__get_free_pages 慢。用于大分配(内核模块、帧缓冲、大数据结构),不需要连续场景。

kmem_cache_create / kmem_cache_alloc

对于频繁分配的同大小内核对象(比如 struct inodestruct task_structstruct file),slab 分配器提供专用接口:

struct kmem_cache *c = kmem_cache_create("my_object", sizeof(struct my_obj), 0, 0, NULL);
struct my_obj *o = kmem_cache_alloc(c, GFP_KERNEL);
// ... 使用 o ...
kmem_cache_free(c, o);

这避免了 kmalloc 中大小类分派的开销,并在 per-CPU slab 缓存中保留热对象。仅 inode 缓存就能在繁忙的文件系统上省下数百万次分配循环。

为什么用户态开发者需要关心

内核分配器不能直接从用户态调用,但它们的行为影响你:

  • 页分配是惰性的。 malloc + 写入才会触及物理页。malloc + 读(从零页)映射到共享只读零页直到写入。这就是 malloc 了 1GB 但只写了 10MB 的进程只显示 10MB RSS 的原因。

  • 大分配会碎片化 buddy 分配器。 如果你 malloc(2MB+1),而系统已运行数月,内核可能找不到 2MB+1 的连续物理页(即使总空闲内存够)。这就是 vmalloc 存在的原因,但用户态用不了——于是内核有了 COMPACTION 机制,重排页面以满足大连续请求。

  • Overcommit。 Linux 默认过量分配内存:malloc(1GB) 即使只剩 100MB 物理内存也会成功。内核承诺如果进程实际使用内存且系统耗尽,就杀掉进程(OOM killer)。由 vm.overcommit_memoryvm.overcommit_ratio 控制。

从 malloc 到物理页的完整路径

malloc(1000)
glibc: 检查 fastbin → small bin → unsorted bin → large bin → top chunk
top chunk 太小 → 需要更多内存
if size <= MMAP_THRESHOLD(默认 128KB):
    sbrk(size)  →  brk 系统调用  →  内核扩展 VMA  →  缺页时分配物理页
  else:
    mmap(...)   →  mmap 系统调用 →  内核创建 VMA  →  缺页时分配物理页
glibc 切分新区域,返回 chunk 给用户

“缺页时分配物理页"是关键:系统调用本身只更新元数据。物理 RAM 在进程首次访问返回的指针时才分配。

FAQ

Q1:我的进程 free 后 RSS 仍然很高,发生了什么?
A:glibc 没有把内存还给内核。释放的 chunk 进了 glibc 的 bin 里等待复用。只有 brk 顶部空洞释放或 mmap/munmap 能降低 RSS。这是正常的,通常不是泄漏。

Q2:能强制 glibc 把内存还给操作系统吗?
A:malloc_trim(0) 告诉 glibc 调负值 sbrk(仅当堆顶有空闲空间时有效)。对于 mmap 分配,free 总是调 munmap

Q3:怎么知道分配用了 brk 还是 mmap?
A:超过 128KB 的分配用 mmap。小的用 brk arena。检查 /proc/pid/maps[heap] 条目是 brk 堆;其外的匿名映射是 mmap 分配。

Q4:M_MMAP_THRESHOLD 是什么?该改吗?
A:决定 brk/mmap 分割的大小阈值。默认 128KB。设更高迫使更多分配走 brk 堆(更少系统调用,但更多碎片)。设更低迫使更多用 mmap(free 时立即释放,但更多 TLB 刷新)。API:mallopt(M_MMAP_THRESHOLD, value)

Q5:为什么我的堆地址在 /proc/pid/maps 里没有 [heap] 标签?
A:现代内核带 ASLR 级别 2 时,brk 堆仍然标 [heap]。但线程 arena(mmap 分配)是无标签的匿名映射。如果主 arena 已创建但堆标签没出现,检查 ASLR 级别是否为 2。

参考链接