4. 内存管理源码分析:页表、伙伴系统、slab分配器、malloc/free内核路径

内存管理,说白了就是内核的“仓库管理员”。它管着物理内存怎么分、怎么回收,还要给每个进程一个“我独占整个内存”的幻觉。我早年调试一个网络驱动时,就遇到过slab分配器把对象踩坏了,查了三天才定位到——从那以后,我对内存管理源码就多了一份敬畏。

这一章,我们沿着一条主线走:从虚拟地址到物理页,再到小块内存的分配与释放。你会看到页表怎么翻译地址、伙伴系统怎么管理大块内存、slab怎么处理小对象,最后用户态的malloc/free在内核里到底走了什么路。

内存管理核心路径 用户态 malloc/free brk / mmap 系统调用 伙伴系统 (页分配) slab 分配器 (小对象) 页表 / TLB 物理内存 / 页帧 关键路径说明: 1. 用户态 malloc → 调用 brk() 或 mmap() 进入内核 2. 内核根据请求大小选择:大块走伙伴系统,小块走 slab 3. 分配物理页后,更新页表映射,刷新 TLB 4. free 释放时,内存归还给 slab 或伙伴系统

4.1 页表:虚拟地址到物理地址的翻译官

每个进程都有自己独立的虚拟地址空间,从0到TASK_SIZE。但物理内存是共享的。页表就是那张“翻译对照表”。

x86_64架构下,页表是四级结构:PGD → P4D → PUD → PMD → PTE。每次内存访问,CPU都会硬件遍历这四级页表。你想想看,一次内存访问背后要查四次表,这开销可不小。所以TLB(快表)就派上用场了——它缓存最近用过的页表项。

核心数据结构:

  • pgd_t — 页全局目录项
  • pud_t — 页上级目录项
  • pmd_t — 页中间目录项
  • pte_t — 页表项,指向物理页帧

我个人习惯在调试内存问题时,先检查页表是否建立。比如用cr3寄存器拿到PGD基址,然后手工翻译一个虚拟地址,看PTE里有没有_PAGE_PRESENT标志。缺页异常(#PF)八成就是页表项没设对。

// 虚拟地址分解示例 (x86_64, 4KB页)
// 地址: 0x7f1234567890
// PGD索引: (addr >> 39) & 0x1ff
// PUD索引: (addr >> 30) & 0x1ff
// PMD索引: (addr >> 21) & 0x1ff
// PTE索引: (addr >> 12) & 0x1ff
// 页内偏移: addr & 0xfff

避坑指南:我曾经在移植内核到新平台时,忘了配置页表层级数,结果系统一启动就挂。记住:CONFIG_PGTABLE_LEVELS必须和硬件MMU匹配。

4.2 伙伴系统:物理页的“分分合合”

伙伴系统管理的是物理页帧(page frame)。它的核心思想很简单:把空闲页按2的幂次方分组(order 0 ~ order 10),分配时从合适大小的块里切,释放时合并回更大的块。

order页数大小典型用途
014KB小页分配
128KBDMA缓冲区
3832KB文件系统缓存
532128KB中等内存分配
1010244MB大块连续内存

分配时,内核调用__alloc_pages_nodemask()。它会先找当前zone的空闲链表,如果没有合适大小的块,就向上找更大的块,然后切分。释放时调用__free_pages(),检查“伙伴”是否也空闲,是就合并。

// 伙伴系统核心分配路径 (简化)
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
    struct page *page;
    // 1. 选择zone (ZONE_DMA / ZONE_NORMAL / ZONE_HIGHMEM)
    // 2. 从free_area[order]链表取块
    // 3. 如果没有,尝试更高order,切分后插入低order链表
    // 4. 返回第一个page的struct page指针
    page = __rmqueue(zone, order, migratetype);
    return page;
}

注意:伙伴系统分配的是物理连续的页。如果系统运行久了,内存碎片化严重,order >= 4的分配很容易失败。我遇到过数据库服务器因为无法分配order-4的页而OOM,后来加了compact_memory手动压缩才缓解。

4.3 slab分配器:小对象的“专属管家”

内核里大量分配的是小对象——几十字节到几KB。如果用伙伴系统直接分配4KB页,太浪费了。slab分配器就是干这个的:它从伙伴系统拿一整页,然后切成等大小的对象,维护一个空闲链表。

slab有三种状态:full(全用满)、partial(部分空闲)、empty(全空闲)。分配时优先从partial里取,释放时如果slab变空了,就还给伙伴系统。

// slab分配器核心结构
struct kmem_cache {
    const char *name;        // 缓存名称,如 "task_struct"
    unsigned int object_size; // 对象大小
    unsigned int size;        // 对齐后大小
    struct list_head slabs_full;    // 全满slab链表
    struct list_head slabs_partial; // 部分空闲slab链表
    struct list_head slabs_free;    // 全空闲slab链表
    // ...
};

常用的kmalloc()底层就是slab。它根据请求大小选择对应的kmem_cache(比如16字节、32字节、64字节……)。我建议你调试内存泄漏时,用slabtop命令看看哪个缓存对象数量异常增长。

个人经验:有一次我写内核模块,频繁分配释放一个128字节的结构体。一开始用kmalloc,性能很差。后来改成kmem_cache_create创建专用缓存,吞吐量提升了3倍。记住:频繁分配固定大小对象,一定要用slab。

4.4 malloc/free内核路径:从用户态到物理页

用户态调用malloc(100),glibc会怎么处理?

  1. 小请求(< 128KB):glibc用brk()系统调用,调整堆顶指针。内核在do_brk_flags()里建立vma,但此时并不分配物理页——只是预留虚拟地址。
  2. 大请求(>= 128KB):glibc用mmap()系统调用,内核分配一段匿名映射。同样,页表项标记为“未驻留”。
  3. 真正分配物理页:当进程第一次访问这块内存时,CPU触发缺页异常。内核的do_anonymous_page()调用伙伴系统分配一页,然后建立页表映射。

这就是所谓的按需调页(demand paging)。你malloc了1GB内存,只要不读写,物理内存一点都不会少。

// 缺页异常处理路径 (匿名页)
// do_page_fault() → handle_mm_fault() → __handle_mm_fault()
// → handle_pte_fault() → do_anonymous_page()
static int do_anonymous_page(struct vm_fault *vmf)
{
    struct page *page;
    // 1. 分配一个物理页
    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
    // 2. 建立页表映射 (PTE)
    entry = mk_pte(page, vma->vm_page_prot);
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
    return 0;
}

free()释放时,glibc通常不会立即把内存还给内核。它把小块内存放回自己的空闲链表,大块内存用munmap()解除映射。内核在munmap时,会遍历页表,释放物理页,并更新伙伴系统的空闲链表。

完整路径总结:

  • malloc(100) → brk() → 扩展堆vma → 首次访问 → 缺页 → 伙伴系统分配页 → 建立页表
  • malloc(10MB) → mmap() → 建立匿名vma → 首次访问 → 缺页 → 伙伴系统分配页 → 建立页表
  • free(ptr) → glibc合并空闲块 → 如果整块都释放 → munmap() → 释放页 → 伙伴系统回收

嗯,这里要注意:malloc返回的地址是虚拟地址,不是物理地址。用户态程序永远看不到物理地址。这也是内核内存管理的精髓——隔离与抽象。

我曾经踩过的坑:在写性能测试程序时,malloc了1GB内存后马上memset,发现耗时很长。原因就是缺页异常逐页分配。后来改用MAP_POPULATE标志提前分配物理页,初始化时间从2秒降到0.1秒。但代价是内存立刻被占用——这就是典型的“时间换空间”权衡。