栈为什么比堆快:从分配方式到「批发-零售」链条

在同一个进程内,栈和堆使用相同的内存硬件,访问速度本身没有区别。真正的性能差异来自内核在分配和管理内存时为两者采取的不同策略。本文从分配方式、物理内存管理、缓存友好性三个角度说明原因,并借 sbrk、Slab、malloc 梳理从内核到用户态的内存「批发-零售」链条;最后讨论「栈比堆快」这一经验法则的适用边界。

1. 内存分配方式

栈:近乎零成本

栈上分配只需修改栈指针寄存器。在 x86-64 上,函数序言用 sub rsp, N 预留空间(如 sub rsp, 0x10 即 16 字节),一条 CPU 指令、不涉及内核,成本极低12。需要澄清:sub rsp, N 本身不会触发任何异常,只是寄存器算术;触发缺页的是后续对该新栈空间的首次访问(见下节)。

; x86-64 函数序言示例:分配 0x20 字节栈帧
    push    rbp
    mov     rbp, rsp
    sub     rsp, 0x20

堆:系统调用的开销

通过 malloc 申请内存时,若分配器内部池子不足,会通过 brkmmap系统调用向内核申请。用户态/内核态切换带来微秒级开销,相比一条 sub rsp 可高出数百倍甚至更多3

2. 物理内存管理

栈:缺页异常与按需映射

修改指针(sub rsp, N):只是“账面上的分配”。执行 sub rsp, 0x1000 时,CPU 只做寄存器运算,内核对此一无所知。进程虚拟地址空间中这段新栈区在页表里尚未映射到物理页(或映射到只读零页),只是被“预留”出一个地址范围,成本就是一条指令。

首次访问(例如 mov [rsp-8], rax):才是真正的“物理分配”。当第一次使用这片新栈空间时:(1) CPU 尝试写入该虚拟地址;(2) MMU 查页表发现该页无有效物理页框,无法完成转换;(3) MMU 触发缺页异常(#PF,x86-64 上为中断 14),CPU 转去执行内核的缺页处理(如 do_page_fault());(4) 内核从 CR2 读出故障地址,检查是否在进程合法栈区内(如 mm_structstart_stackulimit -s 限制),若合法则分配物理页、在页表中建立映射并标为可读写;(5) 返回用户态后,原指令重试,此时已有映射,写入成功。这一过程对开发者透明,且每个页只在首次触及该页时发生一次,即惰性分配(Lazy Allocation):只为实际使用的栈页分配物理内存,若函数分配了大数组但从未访问,就不会占用物理页2

读与零页:若首次操作是,内核可先将该虚拟页映射到全局只读的零页;只有后续发生时才触发写时拷贝(COW),分配真正的物理页并清零,与匿名堆区的零页机制一致。

主线程栈与线程栈:主线程栈可在合法范围内按需增长(访问新区域触发合法缺页即可);通过 pthread_create 创建的线程,其栈通常在创建时用 mmap 一次性映射固定大小(如 8MB),虚拟范围固定,不会像主线程那样向低地址方向动态增长,访问未映射区域仍会触发缺页并分配物理页。

需要强调的是:在发生缺页的那一刻,栈和堆走的是同一条内核路径(#PF → 分配物理页 → 建立映射,必要时清零),单看这一次缺页本身,栈并不比堆快。栈的「快」体现在:分配虚拟空间无需系统调用(§1);缺页通常只在首次触及该页时发生一次,成本被摊薄;一旦物理页已常驻,栈与堆的访问就是普通内存访问,没有差别。

类比sub rsp, N 像在借书卡(页表)上登记一个新书名(虚拟地址),只是记录;首次访问像第一次去书架上取书——管理员发现书(物理页)还在仓库,于是取书、上架、更新借书卡,你才能拿到;若该地址不在进程合法地址空间内,则相当于”查无此书”,会引发 SIGSEGV 等错误。

内核视角:栈与堆的本质区别是 VMA 生命周期

从内核角度看,并不区分”栈”与”堆”,只区分虚拟内存区域(VMA)的类型和生命周期。理解这一点是理解性能差异的关键。

栈 VMA:进程级生命周期

栈在进程启动时由内核创建(fs/exec.c:setup_arg_pages()),设置 VM_GROWSDOWN 标志,表明这是一个”向下增长”的区域:

// 简化自 fs/exec.c:778
static int setup_arg_pages(struct linux_binprm *bprm, ...) {
    vma = vm_area_alloc(mm);
    vma->vm_start = stack_top - STACK_TOP_MAX;  // 通常 8MB
    vma->vm_end = stack_top;
    vma->vm_flags = VM_STACK_FLAGS | VM_GROWSDOWN;  // 唯一特殊标志
    insert_vm_struct(mm, vma);
    // 关键:只创建 VMA,不分配物理页
    return 0;
}

关键点

  1. VMA 在进程启动时创建,进程退出时销毁(生命周期 = 进程)
  2. VM_GROWSDOWN 只是一个标志位,告诉内核这个 VMA 可以向低地址扩展
  3. 创建时不分配任何物理页,物理页在首次访问时按需分配
  4. 函数调用期间,VMA 始终存在——这就是为什么栈分配不需要系统调用

堆 VMA:两种生命周期

brk 堆(小块分配):

// 简化自 mm/mmap.c:115
SYSCALL_DEFINE1(brk, unsigned long, brk) {
    // 扩展堆顶,可能扩展已有 VMA 或创建新 VMA
    if (do_brk_flags(&vmi, brkvma, oldbrk, newbrk - oldbrk, 0) < 0)
        goto out;
    mm->brk = brk;  // 更新堆顶指针
    // 关键:也只修改 VMA,不分配物理页(除非 VM_LOCKED)
}

mmap 堆(大块分配,通常 ≥128KB):

// 简化自 mm/mmap.c:337
unsigned long do_mmap(struct file *file, unsigned long addr, ...) {
    vma = vm_area_alloc(mm);
    vma->vm_start = addr;
    vma->vm_end = addr + len;
    vma_link(mm, vma, ...);  // 插入红黑树
    return addr;
}

// munmap 销毁
int do_munmap(...) {
    unmap_page_range(vma, ...);   // 删除页表项
    free_pgtables(...);            // 释放页表
    remove_vma(vma);               // 删除 VMA
}

缺页处理:栈与堆完全相同

无论是栈、brk 堆还是 mmap 堆,首次访问时都走同一条缺页路径:

// mm/memory.c:5022
static vm_fault_t do_anonymous_page(struct vm_fault *vmf) {
    folio = alloc_anon_folio(vmf);        // 分配物理页
    __folio_mark_uptodate(folio);         // 清零
    entry = folio_mk_pte(folio, ...);
    set_ptes(vma->vm_mm, addr, ...);      // 建立页表映射
    // 内核不关心这是栈还是堆!处理流程完全相同
    return 0;
}

结论:在缺页处理层面,栈和堆没有任何区别。单次缺页的成本相同(~20-50μs),都需要分配物理页、清零、建立页表。

性能差异的真正来源

维度 栈 VMA brk 堆 VMA mmap 堆 VMA
VMA 标志 VM_GROWSDOWN
生命周期 进程级别 进程级别 malloc/free 级别
创建/销毁 进程启动/退出 首次 brk/进程退出 每次 mmap/munmap
页表持久性 持久(扩展时保留) 持久(扩展时保留) 临时(munmap 删除)
缺页处理 do_anonymous_page do_anonymous_page do_anonymous_page
运行时系统调用 0 次 0 次(扩展后) 每次分配/释放 2 次

性能差异不是因为内核对栈和堆的”处理方式”不同,而是:

  1. VMA 生命周期不同:栈的 VMA 在进程启动时创建,持续到进程结束;mmap 堆的 VMA 每次 malloc/free 都要创建/销毁
  2. 系统调用频率不同:栈分配只需改栈指针(CPU 指令),mmap 堆每次都要 mmap()/munmap() 系统调用
  3. 页表持久性不同:栈扩展(expand_stack_locked)只修改 VMA 范围,页表映射保留;munmap 会删除页表,下次 mmap 必须重建

栈的”只增不减”特性与物理页缓存

VMA 层面:内核没有 shrink_stack 函数,栈的虚拟地址范围(vma->vm_start - vma->vm_end)在进程运行期间只增不减,永远保持历史最大值:

// 深度递归扩展栈后
VMA: [0x7FEF7000, 0x7FFFFFFF]  // 16MB

// 递归返回,rsp 上移,但 VMA 不缩小
VMA: [0x7FEF7000, 0x7FFFFFFF]  // 仍是 16MB

物理页层面:更关键的是,函数返回后物理页默认不释放,页表映射保持不变。这是栈性能的核心优势。需要区分两种场景:

场景 1:持续访问新页(栈也会缺页)

深度递归访问新栈区域:
    第 1 层 → 访问虚拟页 A → 缺页 #1
    第 2 层 → 访问虚拟页 B(新页)→ 缺页 #2
    ...
    第 100 层 → 访问虚拟页 Z(新页)→ 缺页 #100

持续访问新页时,栈也会持续缺页

场景 2:重复访问已访问页(栈的优势)

第 1 次深度递归(100 层):
    触发缺页 × 100 → 分配 100 个物理页 → 建立页表映射
    递归返回 → rsp 上移 → 但页表映射保留
    成本:100 × 30μs = 3ms

第 2-1000 次相同深度递归(100 层):
    rsp 下移到相同虚拟地址 → 页表已有映射 → 0 次缺页
    成本:0μs  ← 物理页”缓存”在页表中

对比 mmap 堆(相同大小的重复 malloc/free):
    第 1 次:mmap() → 缺页 × 32 → munmap() 删除页表
    第 2 次:mmap() → 缺页 × 32 → munmap() 删除页表
    ...
    1000 次迭代:1000 × (32 × 30μs) = 960ms

实际应用中,大部分函数调用是相同深度的重复,因此栈表现出显著的性能优势。

内核允许用户态通过 madvise(MADV_DONTNEED) 显式释放栈的物理页(保留 VMA),但默认行为是保留以优化性能。进程退出时,exit_mmap() 才释放所有 VMA 和物理页。

维度 mmap 堆
VMA 创建 进程启动 1 次 每次 malloc
物理页分配 首次访问该页 每次访问
物理页释放 默认不释放 每次 free 都释放
再次访问同一页 无缺页(页表复用) 重新缺页(页表已删除)
访问新页 缺页(首次访问) 缺页(首次访问)

这种”懒惰”策略(VMA 不缩小、物理页不释放)正是栈性能优势的根本来源:对于重复访问的栈区域,首次缺页后物理页常驻在页表中,避免反复的分配-释放-再分配循环;但访问新的更深栈区域时,栈也会缺页。实际应用中函数调用多是相同深度的重复,因此栈表现出显著优势。

堆:mmap 与安全清零

通过 mmap 获取匿名内存时,内核会保证进程看到的是「零填充」:要么在缺页时分配并清零,要么先映射到全局零页,写时再分配(copy-on-write),避免读到其他进程残留数据45。Gorman《Understanding the Linux Virtual Memory Manager》Ch4 对用户态区段的描述5

With a process, space is simply reserved in the linear address space by pointing a page table entry to a read-only globally visible page filled with zeros. On writing, a page fault is triggered which results in a new page being allocated, filled with zeros, placed in the page table entry and marked writable.

无论哪种方式都会在首次写时产生分配/清零或 COW 开销。malloc 往往通过 mmapsbrk 拿到大块后再在用户态切分、复用,以摊薄这类成本。

3. 缓存友好性

栈:局部性更好

栈的访问模式是典型的 LIFO,当前活跃的局部变量多集中在栈顶附近,容易落在 CPU 的 L1/L2 缓存中,命中率高。

堆:访问模式更分散

堆上对象由程序显式管理,链表、树等结构容易在地址空间内分散,导致缓存行利用率低、更多访问主存。


4. 从内核到用户态:「批发-零售」链条

结合 sbrkSlabmalloc,可以把内存分配看成一条从内核到 CPU 的链条;栈之所以「快」,是因为它处在链条末端,几乎不经中间层。

4.1 一级批发:内核 Buddy(伙伴系统)

物理内存以(通常 4KB)为最小单位管理,由伙伴系统负责分配和回收:按 2^order 页块管理,不足时分裂大块、释放时与伙伴合并。粒度较粗,不适合直接满足「几十字节」的小请求65

4.2 二级批发:内核 Slab

Slab 分配器从伙伴系统拿到整页,再切成固定大小的对象并缓存,主要服务内核自身(如 task_structinode 等)。对象用完后可留在 Slab 中复用,减少对伙伴系统的调用,并缓解内碎片、提高缓存利用率65

4.3 用户态代理:malloc 与 sbrk

用户程序通过 malloc 获取堆内存。当内部池不足时,malloc 会调用 sbrkmmap

4.4 栈:无中间商的「自家后院」

栈不经过上述任何一层:分配就是改栈指针,无需系统调用;物理页在首次访问时按需分配(§2),LIFO 访问模式又利于缓存。因此处在链条最末端,面向 CPU,成本最低。

4.5 开销大致顺序(从慢到快)

层级 机制 特点
最慢 系统调用(sbrk/mmap) 用户态/内核态切换,微秒级
中等 用户态堆管理(malloc/free) 无模式切换,但有锁与查找
较快 内核 Slab(kmem_cache_alloc) 内核内复用,无系统调用
最快 栈指针调整(sub rsp) 纯用户态指令,纳秒级

5. 「栈比堆快」的边界

单纯比较「栈和堆谁快」容易误导,因为两者不在同一维度:栈更多是「使用已就绪内存」,堆还涉及「获取」和「管理」。

5.1 分配模式才是关键

事先在堆上分配好一块内存,再反复读写,其访问速度与栈上同规模数据可以非常接近——此时差异主要在「分配方式」,而非「存储介质」。

// 栈:分配 + 使用
void stack_func(void) {
    int arr[1000];   // 分配:改栈指针
    arr[0] = 42;     // 使用:普通内存访问
}

// 堆:一次性分配,反复使用
static int *heap_arr;

void heap_init(void) {
    heap_arr = malloc(1000 * sizeof(int));  // 仅此一次有系统调用/分配器开销
}

void heap_func(void) {
    heap_arr[0] = 42;   // 使用:与栈上访问同属「已就绪内存」
}

5.2 堆可以模拟栈的分配模式

Arena、pool 等分配器本质是在堆上模拟栈:一次性向系统要一大块,用指针顺序分配,最后整体释放。在这种模式下,堆上的「分配」成本可以接近栈。

5.3 值得关注的维度

维度
分配速度 固定、极快 视是否命中缓存、是否触发系统调用而定
可预测性 可能受碎片、锁竞争影响
适用场景 小数据、生命周期与调用栈一致 大数据、生命周期动态

栈的「快」是用约束换来的:大小有限、生命周期必须 LIFO。堆的灵活则伴随分配与管理开销。工程上更值得关心的是:在给定场景下,应优先用栈、对象池还是堆。

5.4 缺页路径上栈与堆等价,但缺页频率不同

若只比较「第一次访问某页、触发缺页」的那条路径,栈和堆没有区别:都是 #PF → 内核分配物理页 → 映射(堆上匿名区还可能多一步清零或 COW)。因此在单次缺页场景下,栈并不比堆快(单次成本都是 ~20-50μs)。

「栈比堆快」指的是:

  1. 分配虚拟空间的成本:栈几乎为零(改栈指针),堆可能涉及系统调用
  2. 缺页发生频率(典型场景):栈访问新页时也会缺页,但实际应用中多是相同深度的重复调用,物理页默认不释放、页表映射持久保留,因此重复访问 0 次缺页;mmap 堆每次 munmap 删除页表,相同大小的重复分配每次都要重建页表并重新缺页
  3. 物理页”缓存”机制:栈在首次访问某深度后,该范围内的物理页常驻页表(除非显式释放);堆每次 malloc/free 都要释放物理页并删除页表

用数字说明典型差异:1000 次相同深度的栈调用可能只触发 1 次缺页(首次访问该深度),而 1000 次相同大小的 mmap 堆分配会触发 1000 次缺页循环。但若持续访问更深的栈区域(新页),栈也会持续缺页。

5.5 用户态申请堆内存是否一定触发缺页?

不一定。 内核源码可以验证两点:

  1. 默认情况:用户态通过 brk/sbrkmmap(MAP_ANONYMOUS)「申请」堆内存时,内核只建立或扩展 VMA(虚拟区间),并不立刻分配物理页。mm/vma.c 中的 do_brk_flags() 仅做 vm_area_alloc、设置区间与 flags、挂入红黑树,没有任何 alloc_pagesmm_populate。因此物理页要等到首次访问该区间时由缺页处理程序分配,那时才会触发一次 #PF。

  2. 会预填页、从而首次访问不触发缺页的情况

    • mmap(..., MAP_POPULATE)mm/mmap.cdo_mmap 在成功建立映射后,若 flags 含 MAP_POPULATE(且非 MAP_NONBLOCK),会设置 *populate = len,返回用户态前由 mm_populate(ret, populate) 在内核里把页 fault in,所以用户第一次访问时页已在,不会 #PF。
    • 扩展 brk 且进程曾 mlockallmm->def_flags & VM_LOCKEDmm/mmap.cSYSCALL_DEFINE1(brk, ...)do_brk_flags() 成功后若 mm->def_flags & VM_LOCKED,会调用 mm_populate(oldbrk, newbrk - oldbrk),在 brk 返回前就预填新堆区间的页,用户首次访问同样不会触发缺页。

因此:「申请」堆内存本身通常不触发缺页;缺页发生在首次访问新区间时。 只有在使用 MAP_POPULATEVM_LOCKED 时,内核会在申请路径上预填页,此时首次访问不再触发缺页(代价是 brk/mmap 变慢、可能失败)。

5.6 实验验证:栈增长模式对比

为验证「持续访问新栈页会持续缺页」这一关键观察,在 stack-vs-heap-benchmark 项目中实现了对比实验(src/stack_growth_comparison.c),测试两种栈使用模式的缺页行为。

实验配置

// 关键:使用 -O0 编译,禁用优化以确保真实的栈分配
gcc -O0 -Wall -Wextra -g -o stack_growth_comparison src/stack_growth_comparison.c

#define PAGES_PER_CALL 4  // 每次调用占用 4 页(16KB)
#define ITERATIONS 100

// 场景 1:固定深度重复调用(页表复用)
void fixed_depth_call(void) {
    char buffer[16384];  // 4 页
    // 访问每个页的首尾字节,确保触发缺页...
}
for (int i = 0; i < 100; i++) {
    fixed_depth_call();  // 每次调用相同栈位置
}

// 场景 2:持续增长递归深度(持续缺页)
void growing_depth_call(int depth) {
    char buffer[16384];  // 每层 4 页
    // 访问每个页...
    if (depth > 0) growing_depth_call(depth - 1);  // 递归到更深
}
growing_depth_call(100);  // 100 层递归

实验结果(在 Docker Alpine Linux 环境中,使用 perf 统计):

$ perf stat -e page-faults ./stack_growth_comparison

=== 场景 1: 固定深度重复调用 ===
配置: 100 次调用,每次 4 页(16KB)
预期: 第 1 次缺页 4 次,后续 99 次无缺页(页表保留)
执行时间: 0.012 ms
平均每次: 118 ns

=== 场景 2: 持续增长递归深度 ===
配置: 100 层递归,每层 4 页(16KB)
预期: 持续缺页 400 次(每层访问新页)
执行时间: 0.272 ms        ← 慢 23 倍!
平均每层: 2715 ns

Performance counter stats for './stack_growth_comparison':

               424      page-faults    ← 接近预期 400 次(100 层 × 4 页)

       0.000999083 seconds time elapsed

关键发现

场景 缺页次数 执行时间 平均每次 差异倍数
场景 1(固定深度) ~4 次 0.012 ms 118 ns 基准
场景 2(持续增长) ~400 次 0.272 ms 2715 ns 23 倍

实验验证的核心观察

  1. 持续访问新栈页会持续缺页:场景 2 产生 424 次缺页,接近理论预期 400 次(100 层 × 4 页/层)。多出的 ~24 次来自程序启动、库初始化及场景 1 的栈分配。

  2. 重复访问已访问区域几乎不缺页:场景 1 重复调用 100 次相同深度函数,仅首次触发约 4 次缺页,后续 99 次调用 0 次缺页。

  3. 性能差异显著:持续缺页(场景 2)比页表复用(场景 1)慢 23 倍(0.272 ms vs 0.012 ms),平均每次 2715 ns vs 118 ns。

  4. 实测单次缺页成本:从时间差计算,单次缺页成本约 (2715 - 118) ns ≈ 2.6 μs,低于内核文档中提到的理论值 20-50 μs,得益于现代内核的优化(TLB 缓存、页预取、批量操作等)。

结论:这个实验完美验证了「栈的快不是因为永远不缺页」这一关键观察:


总结

  1. 同一进程内,栈和堆的「访问」速度无本质差别;差异主要来自分配方式物理页的建立方式(栈按需缺页,堆常伴随清零或 COW)。
  2. 内核不区分”栈”与”堆”,只区分 VMA 的类型和生命周期:栈的特殊性仅是 VM_GROWSDOWN 标志;真正的性能差异来自 VMA 生命周期——栈 VMA 在进程启动时创建、进程退出时销毁(0 次运行时系统调用),mmap 堆 VMA 每次 malloc/free 都要创建/销毁(频繁系统调用)。
  3. 栈的”只增不减”与物理页缓存机制:VMA 范围在运行期间只增不减(没有 shrink_stack),更关键的是物理页默认不释放——函数返回后页表映射保持不变,这是性能核心:相同栈深度的重复访问首次缺页后,物理页”缓存”在页表中,后续访问 0 次缺页(但持续访问新的更深栈区域仍会缺页);mmap 堆每次 munmap 删除页表,相同大小的重复分配每次都要重建页表并重新缺页。实验验证(§5.6):固定深度重复调用(100 次)vs 持续增长递归(100 层),缺页次数 ~4 vs ~400,性能差异 23 倍(0.012 ms vs 0.272 ms)。
  4. 在缺页发生的那一刻,栈与堆走同一条内核路径(do_anonymous_page),单次成本相同(~20-50μs);栈的快体现在:分配虚拟空间零成本(改栈指针)、VMA 持久(无系统调用)、页表持久(expand_stack 保留映射,避免反复缺页)、LIFO 带来的缓存局部性。
  5. 从内核 Buddy → Slab → sbrk/mmap → malloc 到栈,是一条「批发-零售」链;栈在末端、无中间层,分配成本最低。
  6. 「栈比堆快」是有用的经验法则,但不是普适真理;工程上更值得关心的是「为什么快」和「在什么情况下快」,再按场景选择栈、池或堆。从选型与系统视角看,「谁快」往往不是唯一维度,I/O、并发与内存同内核的交互方式同样关键,可参见本博客《为什么「语言速度」是伪命题》8

扩展阅读

Intel SDM Vol.3A 第 6 章2

§6.14.2「64-Bit Mode Stack Frame」原文:

In IA-32e mode, the RSP is aligned to a 16-byte boundary before pushing the stack frame. The stack frame itself is aligned on a 16-byte boundary when the interrupt handler is called.

§6.15「Exception and Interrupt Reference」中 Interrupt 14—Page-Fault Exception (#PF):Exception Class 为 Fault;P=0、权限/写/保留位等触发。SDM 原文:

The exception handler can recover from page-not-present conditions and restart the program or task without any loss of program continuity.

Mel Gorman《Understanding the Linux Virtual Memory Manager》5

Linux 内核源码(代码片段与文件说明)

1. 栈 VMA 的创建:setup_arg_pagesfs/exec.c

进程启动时创建栈 VMA,设置 VM_GROWSDOWN 标志,生命周期 = 进程。关键:只创建 VMA,不分配物理页。

// 简化自 fs/exec.c:778
static int setup_arg_pages(struct linux_binprm *bprm, ...) {
    vma = vm_area_alloc(mm);
    vma->vm_start = stack_top - STACK_TOP_MAX;  // 通常 8MB
    vma->vm_end = stack_top;
    vma->vm_flags = VM_STACK_FLAGS | VM_GROWSDOWN;  // 栈的唯一特殊标志
    vma->vm_page_prot = vm_get_page_prot(vma->vm_flags);
    insert_vm_struct(mm, vma);
    mm->stack_vm += vma_pages(vma);
    return 0;
}

2. 栈扩展:expand_stack_lockedmm/mmap.c

栈向下增长时只修改 VMA 范围,不删除页表,物理页映射保留。这是栈分配快的关键:页表持久,避免反复缺页。

// 简化自 mm/mmap.c:961
int expand_stack_locked(struct vm_area_struct *vma, unsigned long address) {
    if (!(vma->vm_flags & VM_GROWSDOWN))
        return -EFAULT;  // 检查是否是栈 VMA

    vma->vm_start = address;  // 只修改 VMA 起始地址
    mm->stack_vm += grow;
    // 关键:不删除页表!已分配的物理页映射保留
    return 0;
}

3. 缺页处理:do_anonymous_pagemm/memory.c

栈、brk 堆、mmap 堆首次访问时都调用此函数,处理流程完全相同。单次缺页成本相同(~20-50μs),差异在于缺页频率

// 简化自 mm/memory.c:5022
static vm_fault_t do_anonymous_page(struct vm_fault *vmf) {
    folio = alloc_anon_folio(vmf);        // 分配物理页(栈、堆相同)
    __folio_mark_uptodate(folio);         // 清零(栈、堆相同)
    entry = folio_mk_pte(folio, vma->vm_page_prot);
    set_ptes(vma->vm_mm, addr, vmf->pte, entry, nr_pages);  // 建立页表
    add_mm_counter(vma->vm_mm, MM_ANONPAGES, nr_pages);
    // 内核不关心这是栈还是堆!
    return 0;
}

4. 进程地址空间:堆与栈的起止include/linux/mm_types.h

mm_struct 中描述堆与栈的字段;sys_brk 通过 mm->brkmm->start_brk 管理堆顶75

// 简化自 include/linux/mm_types.h(约 1100 行起)
struct mm_struct {
    // ...
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;   /* 堆起止、栈底 */
    unsigned long arg_start, arg_end, env_start, env_end;
    // ...
};

2. Buddy:zone 与 free_areainclude/linux/mmzone.hmm/page_alloc.c

每 zone 有 free_area[NR_PAGE_ORDERS],按 2^order 页块管理;分配入口为 __alloc_pages()9

// 简化自 include/linux/mmzone.h(约 133 行)
struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long    nr_free;
};

// 每个 zone 含(同文件约 980 行):
// struct free_area free_area[NR_PAGE_ORDERS];

3. sys_brk 系统调用mm/mmap.c

用户态 brk/sbrk 的内核入口;通过 mm->brkmm->start_brk 与 VMA 扩展堆。默认只调 do_brk_flags() 扩展 VMA,不分配物理页;仅当 mm->def_flags & VM_LOCKED(如进程曾 mlockall)时才在返回前调用 mm_populate(oldbrk, newbrk - oldbrk) 预填页,此时用户首次访问新区间不会触发缺页7

// 简化自 mm/mmap.c(约 115 行起)
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
    struct mm_struct *mm = current->mm;
    bool populate = false;
    // ...
    if (do_brk_flags(&vmi, brkvma, oldbrk, newbrk - oldbrk, 0) < 0)
        goto out;
    mm->brk = brk;
    if (mm->def_flags & VM_LOCKED)
        populate = true;
success:
    mmap_write_unlock(mm);
    if (populate)
        mm_populate(oldbrk, newbrk - oldbrk);   /* 仅 VM_LOCKED 时预填页 */
    return brk;
}

4. do_brk_flags 只建 VMAmm/vma.c

扩展堆时仅创建/扩展匿名 VMA,不分配物理页;物理页在首次访问时由缺页处理分配。

// 简化自 mm/vma.c(约 2714 行)— do_brk_flags 仅做 VMA 分配与合并,无 alloc_pages/mm_populate
int do_brk_flags(struct vma_iterator *vmi, struct vm_area_struct *vma,
                 unsigned long addr, unsigned long len, unsigned long flags)
{
    // ... may_expand_vm, security_vm_enough_memory_mm ...
    vma = vm_area_alloc(mm);   /* 只分配 VMA 结构 */
    vma_set_anonymous(vma);
    vma_set_range(vma, addr, addr + len, ...);
    vm_flags_init(vma, flags);
    // ... vma_iter_store_gfp, vma_link ... 无 mm_populate
    return 0;
}

5. Slab 分配接口mm/slub.c

当前默认 Slab 实现;kmem_cache_alloc 从指定 cache 取对象(如 task_structvm_area_struct 等)10

// 简化自 mm/slub.c(约 4202 行)
void *kmem_cache_alloc_noprof(struct kmem_cache *s, gfp_t gfpflags)
{
    void *ret = slab_alloc_node(s, NULL, gfpflags, NUMA_NO_NODE, _RET_IP_,
                                s->object_size);
    trace_kmem_cache_alloc(_RET_IP_, ret, s, gfpflags, NUMA_NO_NODE);
    return ret;
}
EXPORT_SYMBOL(kmem_cache_alloc_noprof);

6. mmap 与 MAP_POPULATEmm/mmap.c

默认 mmap(MAP_ANONYMOUS) 只建立 VMA,不预填页;若带 MAP_POPULATEdo_mmap 成功后会设 *populate = len(约 562–565 行:(flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE),返回前在 vm_mmap_pgoff 里调 mm_populate(ret, populate),在内核内把页 fault in,用户首次访问不再触发缺页。

本文引用已用 pdftotext 与本地 kernel 源码校对。

References

  1. System V ABI - AMD64 - Register and Stack Layout - x86-64 调用约定与栈布局(RSP、red zone、16 字节对齐) 

  2. Intel® 64 and IA-32 Architectures Software Developer’s Manual, Vol. 3A - 第 6 章 Interrupt and Exception Handling、§6.14.2/§6.15 #PF  2 3

  3. mmap(2) - Linux manual page - mmap 系统调用;brk(2) - 堆顶与 sbrk/brk 

  4. What is the purpose of MAP_ANONYMOUS in mmap? - 匿名映射与零填充语义;匿名区采用 demand paging,读时映射零页或分配并清零,写时 COW/分配 

  5. Mel Gorman, Understanding the Linux® Virtual Memory Managerkernel.org PDFHTML 目录。Ch4/6/8 见扩展阅读  2 3 4 5 6 7

  6. Memory Management - The Linux Kernel documentation - Slab 分配器;Understanding the Linux Virtual Memory Manager - Slab 附录 - Buddy 与 Slab 概述  2

  7. Linux 内核 mm/mmap.cSYSCALL_DEFINE1(brk,...)mm->brk/mm->start_brkexpand_stack_locked)、fs/exec.csetup_arg_pages 创建栈 VMA)、mm/memory.cdo_anonymous_page 缺页处理)。Bootlin - mmap.cexec.cmemory.c  2 3

  8. 本博客 为什么「语言速度」是伪命题:I/O、并发、内存与内核 - 系统调用成本、内存池与 I/O 对实际性能的影响  2

  9. Linux 内核 mm/page_alloc.c__alloc_pageszone->free_area)、include/linux/mmzone.hstruct free_area)。Bootlin - page_alloc.c 

  10. Linux 内核 mm/slub.ckmem_cache_alloc)、mm/slab.cinclude/linux/sched.hBootlin - slub.c 

My Github Page: https://github.com/liweinan

Powered by Jekyll and Theme by solid

If you have any question want to ask or find bugs regarding with my blog posts, please report it here:
https://github.com/liweinan/liweinan.github.io/issues