Linux内核分析之内存管理-04
22.1 用户地址空间布局 —— mmap 与栈
每个进程一出生,内核就为它的用户半区规划了"城市规划图":哪里放代码与数据、哪里允许堆向上长、mmap 区从哪个边界向哪头分配、栈顶在哪。规划的核心参数是 mm->mmap_base(mmap 区边界)与布局模式(top-down 或 legacy),两者由架构代码 arch_pick_mmap_layout() 在进程创建时确定并掺入随机化。本节解析这张规划图。
22.1.1 x86_64 用户半区的标准版图
// include/linux/mm_types.h:1142;1149;1256-1257
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
...
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
x86_64 四级页表下 task_size = 2^47(128TB),五级 LA57 下扩大到 2^56。默认 top-down 布局的分段:
低地址 task_size
0 ┌─────────────┬───────┬──────────────┬──────────────────┬─────────┬──────┐
│ text+rodata │ data │ bss │ brk 堆(向上) │ (空洞) │ mmap │
│ 代码/常量 │ 已初始化│ 零页按需 │ │ │ 区 │
└─────────────┴───────┴──────────────┴──────────────────┴─────────┴──┬───┘
mmap_base ^ │ v 向下分配
─────────────────────────────────────────────────────────────────────────────
┌──────────────┬──────────────┐
│ 栈(向下生长) │ arg/env 块 │ 顶部
└──────────────┴──────────────┘
^ start_stack task_size
mmap_base 之下到 brk 之间的空档: MAP_FIXED 与库加载的战场
为什么 mmap 区要"贴着栈"向下长:top-down 布局让 mmap 区的空档与 brk 堆的空档各自独立增长、互不相撞的时间最大化;两者最终相遇即 ENOMEM(地址空间耗尽),位置由 mmap_base 与 RLIMIT_DATA 等边界共同控制。
22.1.2 布局模式:top-down 与 legacy
// arch/x86/mm/mmap.c:82-133(节选)
static unsigned long mmap_base(unsigned long rnd, unsigned long task_size,
const struct rlimit *rlim_stack)
{
unsigned long gap = rlim_stack->rlim_cur;
unsigned long pad = stack_maxrandom_size(task_size) + stack_guard_gap;
/* Values close to RLIM_INFINITY can overflow. */
if (gap + pad > gap)
gap += pad;
/*
* Top of mmap area (just below the process stack).
* Leave an at least ~128 MB hole with possible stack randomization.
*/
gap = clamp(gap, SIZE_128M, (task_size / 6) * 5);
return PAGE_ALIGN(task_size - gap - rnd); /* :99 */
}
static unsigned long mmap_legacy_base(unsigned long rnd,
unsigned long task_size)
{
return __TASK_UNMAPPED_BASE(task_size) + rnd; /* :104 */
}
static void arch_pick_mmap_base(unsigned long *base, unsigned long *legacy_base, ...)
{
*legacy_base = mmap_legacy_base(random_factor, task_size);
if (mmap_is_legacy())
*base = *legacy_base;
else
*base = mmap_base(random_factor, task_size, rlim_stack); /* :119 */
}
void arch_pick_mmap_layout(struct mm_struct *mm, const struct rlimit *rlim_stack)
{
if (mmap_is_legacy())
mm_flags_clear(MMF_TOPDOWN, mm); /* :125-130 */
else
mm_flags_set(MMF_TOPDOWN, mm);
...
}
两种模式的分野:
| 模式 | mmap 起点与方向 | 触发条件(mmap_is_legacy()) |
|---|---|---|
| top-down(默认) | task_size - gap - rnd,向下分配 |
正常情况(非 personality 标记 + RLIMIT_STACK 未"无限") |
| legacy | TASK_UNMAPPED_BASE(约 1/3 处),向上分配 |
personality & ADDR_COMPAT_LAYOUT 或栈 rlimit 近乎无限(旧版 ld.so 兼容) |
mmap_base() 的 gap 计算有三重意义:预留 RLIMIT_STACK 大小的栈空间、再垫上栈最大随机化幅度 + stack_guard_gap(栈向下的生长余量与保护洞),最后 clamp 到"至少 128MB、至多 5/6 地址空间"——注释原文"Leave an at least ~128 MB hole with possible stack randomization"(mmap.c:93-95)正是 32 位时代栈与 mmap 区冲撞事故的制度化反思。
22.1.3 随机化:两处熵的注入
// arch/x86/mm/mmap.c:77-79
unsigned long arch_mmap_rnd(void)
{
return arch_rnd(mmap_is_ia32() ? mmap32_rnd_bits : mmap64_rnd_bits);
}
mmap 区基址按 mmap64_rnd_bits(默认 28 位 ≈ 1TB 摆动范围)随机偏移。用户态 ASLR 与 20.5 节内核 KASLR 的关键差异在此显现:随机化是每进程独立的——arch_pick_mmap_layout() 在每个 exec/线程组创建时重新掷骰子,同一时刻系统内各进程的 mmap 区位置互不相同,一处泄露不再全局有效。栈的随机化(stack_maxrandom_size)叠加在 gap 之上,堆 start_brk 的随机化(brk_randomized)见 22.5.3 节。
randomize_va_space sysctl 三档:0 全关、1 保守(mmap/栈/库)、2 完整(额外堆随机化)——生产默认 2。
22.1.4 exec 装配的顺序
地址空间不是 exec 一步建成的,而是"清场 → 装载 → 补栈"三段:
execve → load_elf_binary() 内:
[1] 旧地址空间整体拆除 (exec_mmap 换新 mm)
[2] 逐 PT_LOAD 段 mmap:
text → MAP_PRIVATE|PROT_EXEC (文件页, 只读共享)
data → MAP_PRIVATE|PROT_WRITE (COW: 原始页来自文件)
bss → 匿名零页 (VM_ACCOUNT)
装配后 mm->start_brk/brk 定位 (heap 起点=end_data 对齐+随机)
[3] setup_arg_pages() (fs/exec.c:598-722):
把 argv/envp 从临时页搬到栈顶区
建立栈 VMA (VM_GROWSDOWN), 记录 arg_start/end, env_start/end
mm->start_stack 定位
[4] arch_pick_mmap_layout() 已在更早的 mm 初始化时定下 mmap_base
setup_arg_pages()(fs/exec.c:598)是栈 VMA 的"出生证明":它不是等缺页才创建栈区,而是先把参数页真实拷入顶部,后续向下生长才走 22.5 节的自动扩展路径。
22.1.5 布局的安全推论
布局不只是功能性分区,它直接决定两类经典攻击的可行性边界:
[1] 栈喷射与 mmap 区相邻性。top-down 布局中栈与 mmap 区被 gap 分开(22.1.2 节 stack_guard_gap 的 128MB 保底),这让"从栈精确落点到 mmap 区内的库数据"需要跨过随机化区间——32 位时代 task_size/6 的 clamp 就是为了在 3GB 地址空间里仍保住这个缓冲。ASLR 关闭(randomize_va_space=0)的系统里,布局退化为确定值——mmap_base = task_size - gap、库加载于固定点,这正是"禁用 ASLR 的容器/固件"被单独列为风险项的原因。
[2] 用户/内核地址位判别。canonical hole(20.1.1 节)让"一个 64 位值是不是内核地址"变成一次符号位检查——copy_from_user 的有效性预检、SMAP 缺席平台的软件兜底都建立其上。LAM(Linear Address Masking,mm.rst 注释提及)放宽用户指针的 canonical 检查以支持指针标签,但只放宽用户半区(LAM_SUP 单独控制内核侧)——布局的对称性成为新特性必须保留的接口。
布局退化实验 (只读观测, 无需改内核):
setarch -R ./app 关闭 ASLR 运行
cat /proc/self/maps 两次启动对比:
ASLR on: mmap 段基址每次不同 (22.1.3 节 28 位熵)
ASLR off: 段基址固定 — 库/栈/堆全部可预测
这一对比直观展示 top-down 布局 + 随机化的叠加关系
小结
用户半区的规划由 arch_pick_mmap_layout() 一锤定音:top-down(默认)把 mmap 区从 task_size - gap - rnd 向下生长,legacy 兼容模式从 1/3 处向上;gap 里显式预留了栈 rlimit、栈随机化幅度与 stack_guard_gap。mmap 基址按每进程独立的 arch_mmap_rnd() 随机化,与内核 KASLR 的全局性形成安全模型的对照。exec 的三段装配(清场、段映射、参数搬栈)把这张规划图填上第一批分区。下一节深入分区表本身——VMA 与它的 maple tree 索引。
22.2 VMA (vm_area_struct) 管理
VMA 是进程地址空间的最小管理单元:一段 [vm_start, vm_end) 的连续地址共享同一组权限与同一个后备存储。缺页处理、munmap、mprotect、fork 的页表复制——一切地址空间操作的第一步都是"找到(或拆出)相关 VMA"。本节拆解 vm_area_struct 的字段语义、maple tree 索引、并发模型(mmap_lock + per-VMA 锁),以及 anon_vma 反向映射挂点。
22.2.1 vm_area_struct 逐段解析
// include/linux/mm_types.h:913-980(节选)
struct vm_area_struct {
/* The first cache line has the info for VMA tree walking. */
union {
struct {
/* VMA covers [vm_start; vm_end) addresses within mm */
unsigned long vm_start; /* :919 */
unsigned long vm_end; /* :920 */
};
freeptr_t vm_freeptr; /* SLUB_TYPESAFE_BY_RCU 的释放指针 */
};
/*
* The address space we belong to.
* Unstable RCU readers are allowed to read this.
*/
struct mm_struct *vm_mm; /* :929 */
pgprot_t vm_page_prot; /* 本 VMA 的访问权限(PTE 模板) :930 */
/*
* Flags, see mm.h.
* To modify use vm_flags_{init|reset|set|clear|mod} functions.
*/
union {
const vm_flags_t vm_flags; /* :939 */
vma_flags_t flags;
};
...
unsigned int vm_lock_seq; /* :958 per-VMA 锁序号 */
...
struct list_head anon_vma_chain; /* Serialized by mmap_lock & :966
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock :968 */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops; /* :971 */
/* Information about our backing store: */
unsigned long vm_pgoff; /* 文件内偏移(页单位) :974 */
struct file * vm_file; /* 映射的文件(可 NULL) :976 */
void * vm_private_data; /* 私有数据(共享内存用) :980 */
...
};
字段分四组:范围与权限(vm_start/end、vm_page_prot、vm_flags——前者是建立 PTE 时的权限模板,后者是管理语义位);归属与并发(vm_mm、vm_lock_seq 与文件尾部的 vm_lock.refcnt 构成 per-VMA 锁);后备存储(vm_file/vm_pgoff 定位文件页缓存槽位,anon_vma 挂反向映射);多态分发(vm_ops->fault 决定缺页时找谁要数据——文件系统、共享内存、设备映射各有实现)。
vm_flags 的核心位(include/linux/mm.h:410-485):
| 标志 | 语义 | 典型来源 |
|---|---|---|
| VM_READ/WRITE/EXEC | 访问权限(与 vm_page_prot 呼应) | mmap prot |
| VM_SHARED | 共享映射(写穿透到后备) | MAP_SHARED |
| VM_MAYREAD/WRITE/EXEC | 权限"天花板"(mprotect 只能在此范围收紧放开) | do_mmap 恒置 |
| VM_GROWSDOWN/UP | 可向下/向上扩展(栈) | :410/:475 |
| VM_LOCKED | mlock 锁定不可换出 | :420 |
| VM_ACCOUNT | 计入 overcommit 统计 | :427 |
| VM_DONTCOPY / VM_DONTEXPAND | fork 不复制 / mremap 不扩张 | :424-425 |
| VM_SEQ_READ / VM_RAND_READ | 访问模式提示(预读策略) | madvise :422-423 |
22.2.2 mm_struct:地址空间容器
// include/linux/mm_types.h:1123-1260(节选)
struct mm_struct {
...
atomic_t mm_count; /* :1137 mm 本体引用(含 mm_users 折算 1) */
struct maple_tree mm_mt; /* :1140 VMA 索引: 按 vm_end 排的 maple tree */
unsigned long mmap_base; /* :1142 mmap 区边界 (22.1 节) */
unsigned long task_size; /* :1149 */
pgd_t * pgd; /* :1150 顶级页表 (19.2.3 节) */
...
atomic_t mm_users; /* :1171 使用者计数(线程/借 mm 者) */
...
struct rw_semaphore mmap_lock; /* :1196 全局地址空间写锁 */
...
seqcount_t mm_lock_seq; /* :1222 写锁代序号(per-VMA 锁协作) */
unsigned long hiwater_rss; /* :1235 RSS 高水位 */
unsigned long total_vm; /* :1238 已映射总页数 */
...
vm_flags_t def_flags; /* :1244 新 VMA 默认标志(如 VM_LOCKED) */
unsigned long start_brk, brk, start_stack; /* :1256 */
unsigned long arg_start, arg_end, env_start, env_end; /* :1257 */
struct rlimit rlim_stack; /* 栈上限 */
...
};
mm_users 与 mm_count 的两级计数(注释原文见 :1129-1137)是 17 章 kref 思想的变体:线程共享 mm 使 mm_users 增,mm_users 归零触发 exit_mm→mmput,此时 mm_count 仍在的持有者(如内核线程借 mm)保证结构不被释放,全部引用放完才 mmdrop。
mmap_lock 是整个地址空间的写锁:任何 VMA 增删改(mmap/munmap/mprotect/brk/栈生长)都要写锁;读侧(缺页、GUP)拿读锁。它是多线程进程可扩展性的头号瓶颈——由此催生 per-VMA 锁(22.2.4 节)。
22.2.3 maple tree:VMA 的索引结构
VMA 索引从 6.1 起由红黑树换成 maple tree(区间感知的 B 树变体):
// mm/mmap.c:902-907
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
unsigned long index = addr;
mmap_assert_locked(mm);
return mt_find(&mm->mm_mt, &index, ULONG_MAX);
}
EXPORT_SYMBOL(find_vma);
maple tree vs 红黑树 (VMA 场景):
红黑树 (旧) maple tree (7.0)
索引方式 按 vm_end 单点 按 [start, end) 区间
find_vma(addr)=首个 end>addr
再手查 start<=addr
遍历 树游标, 每步 O(log n) VMA_ITERATOR: 批量 walk,
节点内多个槽位缓存友好
fork 逐 VMA 复制+rebalance 树快照复制(maple 原生)
RCU 读 支持 原生支持 (mt_find 无锁路径)
vma_iter vma_iter_init/load/next (mm/mmap.c:926-940 的
find_vma_prev 即用例) find_vma_prev 用例)
find_vma_prev()(mmap.c:922-940)展示了迭代器风格:vma_iter_load 取当前、vma_prev 回退一格——分配/合并逻辑频繁需要"地址两侧的邻居",区间索引+迭代器让邻居查询从两次树搜索变成一次游标移动。fork 性能是换树的实际动机:大线程数进程(数万 VMA)的复制从"n 次插入"变为"一次快照"。
22.2.4 并发模型:mmap_lock + per-VMA 锁
7.0 的读侧路径(缺页)默认不再拿 mmap_lock 读锁,而是按 VMA 粒度加轻量锁:
// include/linux/mm_types.h:946-958(注释要点)
#ifdef CONFIG_PER_VMA_LOCK
/*
* vm_lock_seq: 可在 mmap_lock 写锁 + 特定位组合下用 WRITE_ONCE 修改;
* 读者持 vm_lock.refcnt 或比对 vm_lock_seq 与 mm->mm_lock_seq 判断
* "我这轮读到的 VMA 是否仍与写者视角一致"。
* 序号显式允许回绕: 回绕最多导致偶发回退慢路径。
*/
unsigned int vm_lock_seq;
#endif
读侧 (缺页) 快路径 写侧 (mmap/munmap)
拿 per-VMA 读锁(refcnt++) 拿 mmap_lock 写锁
├─ 若 VMA 正被写锁: 回退慢路径 mm->mm_lock_seq++ (奇偶翻转)
├─ 读 VMA 字段做缺页处理 改 VMA / 拆分 / 删除
└─ 释放 refcnt 遍历解锁所有读者(refcnt 清零)
释放写锁
一致性保证: 读者比对 vm_lock_seq != mm->mm_lock_seq
说明写者动过 → 弃用本次结果回退慢路径 (mmap_lock 读锁重做)
设计要点在 mm_types.h:946-957 注释中写明:写者修改 VMA 前必须把 vm_lock_seq 推进;读者要么持有 mmap_lock(全局安全)要么用"seq 比对 + 回退"的乐观协议。这把多线程进程的缺页并发从"一把全局读锁"提升为"每 VMA 独立"——不同线程同时在不同 VMA 上缺页互不阻塞,只有同一 VMA 被写者改动时才整体回退。22.4 节的缺页入口将看到这条快慢路径的实际分流。
22.2.5 anon_vma:为回收与迁移铺路
anon_vma/anon_vma_chain(mm_types.h:966-968)解决反向映射问题:"这一页物理页被哪些页表映射着?"——回收(24 章要 unmap/换出)与迁移(18.5 节要换 pte)都必须能从 page 找回全部映射点。匿名页的答案:分配页时建 anon_vma 节点挂进 page,VMA 侧经 anon_vma_chain 双向挂链;文件页则走 address_space 的 i_mmap 树(36 章)。COW 时 MAP_PRIVATE 文件 VMA 会同时挂在两棵结构里(mm_types.h:959-961 的注释原文正是这一规则),因为 COW 后的私有副本是匿名页。
小结
VMA 以 [start,end)+权限+后备 三元组描述地址空间的分区,四组字段分别服务范围、并发、后备与多态分发;mm_struct 以 maple tree mm_mt 索引全部 VMA(区间感知、fork 快照、RCU 读),以 mmap_lock 统摄写操作,并以 per-VMA 锁(vm_lock_seq/refcnt + mm_lock_seq 比对回退)把缺页读并发提升到 VMA 粒度。anon_vma 系统为回收与迁移提供反向映射通路。下一节看 VMA 是如何被 mmap 系统调用创建与合并的。
22.3 mmap 系统调用与映射类型
mmap 是进程地址空间的"万能编辑器":文件映射、匿名内存、共享内存、设备寄存器、线程栈全部经它进入分区表。本节沿 do_mmap()(mm/mmap.c:335)到 mmap_region()(mm/vma.c:2826)走通调用链,并区分 MAP_PRIVATE/MAP_SHARED × 匿名/文件四种组合在内核中的真实形态。
22.3.1 第一段:do_mmap 的校验与选址
// mm/mmap.c:335-470(节选)
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate,
struct list_head *uf)
{
struct mm_struct *mm = current->mm;
int pkey = 0;
*populate = 0;
mmap_assert_write_locked(mm); /* 调用者已持写锁 */
if (!len)
return -EINVAL;
/*
* PROT_READ 是否隐含 PROT_EXEC?
* (noexec 挂载的文件系统除外)
*/
if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
if (!(file && path_noexec(&file->f_path)))
prot |= PROT_EXEC;
...
/* Careful about overflows.. */
len = PAGE_ALIGN(len);
if (!len)
return -ENOMEM;
/* offset overflow? */
if ((pgoff + (len >> PAGE_SHIFT)) < pgoff)
return -EOVERFLOW;
/* Too many mappings? */
if (mm->map_count > sysctl_max_map_count) /* 默认 65530 */
return -ENOMEM;
...
vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(file, flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC; /* :404 */
/* 选址: 验证或挑选地址 */
addr = __get_unmapped_area(file, addr, len, pgoff, flags, vm_flags); /* :410 */
if (IS_ERR_VALUE(addr))
return addr;
if (flags & MAP_FIXED_NOREPLACE) {
if (find_vma_intersection(mm, addr, addr + len))
return -EEXIST;
}
...
}
校验清单浓缩了 mmap 的全部边界语义:READ_IMPLIES_EXEC 的历史兼容、页对齐与溢出检查、sysctl_max_map_count 配额、权限位到 VM 位翻译(calc_vm_prot_bits 把 PROT_* 折成 VM_READ|WRITE|EXEC 与 PKEY 保护键)、VM_MAY* 天花板恒置(此后 mprotect 只能在天花板内浮动)。选址三态:MAP_FIXED 用调用者地址(覆写既有映射!)、MAP_FIXED_NOREPLACE 有冲突即 EEXIST、无提示则 __get_unmapped_area 沿 mmap_base 向下(22.1 节布局)扫描空档。
22.3.2 第二段:mmap_region 建 VMA
// mm/vma.c:2729-2800(节选, __mmap_region 主体)
static unsigned long __mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma = NULL;
bool have_mmap_prepare = file && file->f_op->mmap_prepare;
VMA_ITERATOR(vmi, mm, addr);
...
error = __mmap_setup(&map, &desc, uf);
if (!error && have_mmap_prepare)
error = call_mmap_prepare(&map, &desc);
if (error)
goto abort_munmap;
...
/* Attempt to merge with adjacent VMAs... */
if (map.prev || map.next) {
VMG_MMAP_STATE(vmg, &map, /* vma = */ NULL);
vma = vma_merge_new_range(&vmg); /* :2756 */
}
/* ...but if we can't, allocate a new VMA. */
if (!vma) {
error = __mmap_new_vma(&map, &vma); /* :2761 */
if (error)
goto unacct_error;
allocated_new = true;
}
...
__mmap_complete(&map, vma);
return addr;
...
}
mmap_region 的执行序列:
__mmap_setup() 冲突区拆除(munmap 语义) + 记账(VM_ACCOUNT)
│
├─ f_op->mmap_prepare? 新式驱动钩子: 先描述后建 VMA
│ (7.0 的 desc 化重构: 驱动不再直接摸 VMA 内部)
v
vma_merge_new_range() 能合并? prev/next 与新区间同权限同后备
│ 且中间无洞 → 扩展旧 VMA, 免分配
├─ 合并成功 ────────────> 完成 (maple tree 原地改区间)
v 不能
__mmap_new_vma() vm_area_alloc 分配 VMA (21 章 slab!)
│ f_op->mmap() 驱动初始化
│ vma_store 挂入 mm->mm_mt
v
__mmap_complete() 填充 populate(POPULATE_MAP 预触) / 统计
合并(vma_merge_new_range)是长寿命进程防 map_count 膨胀的关键:同权限、同文件、pgoff 连续、标志兼容的相邻区间被并成一个 VMA。VM_DONTEXPAND(如特殊驱动映射)与 VM_SPECIAL 组合(VM_IO|VM_DONTCOPY|VM_PFNMAP 等)是合并的否决项——某些映射"长得像"但语义不可并。
22.3.3 四种映射形态
MAP_PRIVATE MAP_SHARED
┌───────────────────────────┬────────────────────────────┐
匿名 │ 普通 malloc/malloc'ed 大块 │ 共享匿名: fork 前建 │
(无文件)│ vm_file=NULL, anon_vma 挂链 │ (MAP_SHARED|MAP_ANONYMOUS │
│ 写时复制到 swap(24 章) │ + tmpfs 驱动 shmem) │
│ │ 进程间真正共享页帧 │
├───────────────────────────┼────────────────────────────┤
文件 │ 库/程序加载: 读走页缓存 │ 共享内存映射: 写回文件 │
(有文件)│ 写触发 COW → 私有匿名副本 │ 脏页经回写落盘(36 章) │
│ 原文件页永远只读共享 │ msync/msync 语义 │
└───────────────────────────┴────────────────────────────┘
vm_file / anon_vma 的组合判别 (mm_types.h:959-963 注释):
匿名 PRIVATE: anon_vma only 文件 PRIVATE+COW 后: 两者都挂
文件 SHARED: i_mmap 树 only brk/栈: anon_vma only (file=NULL)
MAP_PRIVATE 文件映射的两段式是理解库加载内存账目的钥匙:进程的"私有写的库数据段"由两部分物理页构成——未写过的页仍指向页缓存共享页帧(几百个进程共享同一份 libpthread 文本与只读数据),写过一次的页变成进程私有匿名页。RSS 的"共享/私有"拆分(/proc/pid/smaps 的 Shared_Clean/Private_Dirty)正是这个形态的报表。
MAP_SHARED 匿名并不"无中生有":内核用 tmpfs(shmem)充当后备文件——vm_file 指向 tmpfs inode,页在文件页缓存里天然跨进程共享。这也是它在 overcommit 记账里按"文件页"对待的原因。
22.3.4 驱动视角:f_op->mmap 与 mmap_prepare
设备驱动通过 file_operations->mmap 实现 remap_pfn_range()/vm_insert_page() 把自己的内存(MMIO、DMA 缓冲、DAX)暴露进用户空间;vm_ops->fault 则实现"缺页时供货"的设备端。7.0 引入的 mmap_prepare 钩子(vma.c:2742-2744 的 have_mmap_prepare 分支与 struct vm_area_desc)把驱动的参与时机从"VMA 已建好之后"提前到"建 VMA 之前"——驱动在 desc 上声明所需属性,内核统一完成 VMA 构造与 maple 挂载,杜绝驱动代码直接改 VMA 内部字段引发的竞态。旧 f_op->mmap 依然兼容,两类钩子在 __mmap_region 内分流。
22.3.5 munmap 的镜像操作
munmap(start, len):
mmap_write_lock
do_vmi_align_munmap():
├─ 找覆盖区间的全部 VMA
├─ 端点切割: 区间部分重叠时把首尾 VMA 一分为二
├─ 从 maple tree 摘除命中 VMA (批量 detach)
├─ zap_page_range: 清 pte + 刷 TLB (19.5 节 mmu_gather)
│ 匿名页引用减、文件页减映射
└─ RCU/引用时序释放 VMA 结构
切割(split)与合并(merge)是一对逆操作:munmap 把大 VMA 切小,mmap 把相邻同类并大;长寿命进程的碎片化程度取决于两者的净流量。sysctl_max_map_count 配额(do_mmap :388-390)最终约束的是这套结构的规模。
小结
mmap 的两段式实现——do_mmap 负责校验、权限翻译与选址,mmap_region 负责冲突拆除、合并或新建 VMA 并挂树——把"万能编辑器"落实为分区表上的原子编辑。四种映射形态由 MAP_PRIVATE/SHARED × 文件/匿名 组合决定页帧的共享与私有语义,tmpfs 为共享匿名充当隐形后备;COW 让文件私有映射在写之前零成本共享。7.0 的 mmap_prepare 钩子重构把驱动的参与时机前移、收敛 VMA 构造权。下一节看这张分区表如何被缺页异常逐页兑现。
22.4 页面换入 —— 按需分配与缺页处理
mmap 建立的只是分区表记录;真正把物理页填进页表的动作发生在第一次访问时的缺页异常里。这个"按需分页"(demand paging)设计让 fork+exec 的启动路径只需建结构、不供数据,进程的第一条指令触发缺页才开始供货。本节以分配者的视角走一遍缺页链——入口 do_user_addr_fault()、分发 handle_mm_fault() 与三个主要供货者(匿名页、文件页、COW 页)——异常入口的体系结构细节留给 25 章展开。
22.4.1 为什么按需:记账与供货分离
fork + exec 的时间账:
立即完成 (微秒级):
fork: 复制 mm_struct + maple tree 快照 + 页表页 (复制 pte 而非页!)
全部 pte 标只读 (COW 预埋)
exec: 拆旧分区表, 建 3-5 个 VMA, 搬参数页
延迟完成 (按访问):
第 1 条指令 → text 文件页缺页 → 页缓存供货
第 1 次写栈 → 栈匿名页缺页 → 伙伴系统供货
第 1 次写 data → COW 缺页 → 复制新页
malloc 大块 → brk/mmap 只建 VMA
逐页触碰 → 逐页缺页供货 (mmap POPULATE 可选择提前全付)
分离的收益是双重的:启动快(不触碰的页永不分配——RSS 远小于 VSZ 的根本原因),记账准(overcommit 允许承诺大于物理内存,22.5 节 acct_stack_growth 的最后一道检查即 overcommit 统计)。代价是缺页异常成为常态路径,异常入口必须优化到纳秒级——这正是 22.2.4 节 per-VMA 锁存在的理由。
22.4.2 入口:do_user_addr_fault
// arch/x86/mm/fault.c:1207(签名与主流程要点)
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
...
/* 1. 快速过滤: SMAP/SMEP、NX、内核地址误入等 → SIGSEGV 直达 */
...
/* 2. 找 VMA (per-VMA 锁快路径) */
vma = lock_vma_under_rcu(mm, address);
if (likely(vma)) {
/* VMA 就位且未被并发改动: 直接进入 handle_mm_fault */
...
goto handle;
}
/* 3. 回退: mmap_lock 读锁 + find_vma 重来 (22.2.4 节协议) */
...
handle:
fault = handle_mm_fault(vma, address, flags, regs);
...
}
x86 入口的职责是异常语义解码:error_code 的 P/W/U/I/RPK 等位区分"页不存在/权限违反、读/写、用户/内核、取指/数据、保护键违反",把硬件事件翻译成内存管理问题。地址合法性检查(find_vma 命中且区间含 fault 地址、访问模式不超 VMA 权限)不通过即进入 SIGSEGV 发送路径;通过则进入通用层 handle_mm_fault()(mm/memory.c:6602)。
22.4.3 分发:handle_mm_fault → handle_pte_fault
// mm/memory.c:6286 与 4484-4490(节选)
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{ /* :6286 */
...
if (!vmf->pte)
return do_pte_missing(vmf); /* pte 为空: 缺页 */
...
if (vmf->flags & FAULT_FLAG_WRITE) {
if (!pte_write(entry))
return do_wp_page(vmf); /* 写只读 pte: COW 判定 */
entry = pte_mkdirty(entry);
}
...
}
// mm/memory.c:4484-4490
static vm_fault_t do_pte_missing(struct vm_fault *vmf)
{
if (vma_is_anonymous(vmf->vma))
return do_anonymous_page(vmf); /* 匿名: 分配零页 */
else
return do_fault(vmf); /* 文件/设备: vm_ops->fault */
}
handle_pte_fault 的判别矩阵:
pte 状态 × 访问 匿名 VMA 文件 VMA
─────────────────────────────────────────────────────────────────
pte 空 读 do_anonymous_page do_fault →
写 (零页/COW 预埋) vm_ops->fault
(文件页入缓存)
pte 在但只读 写 do_wp_page: 同左, 但
(COW 预埋/换出/ 单映射直接改写; 先重取文件页
mprotect 效果) 多映射复制新页 再判定
pte 在且可写 写 只需置 dirty/accessed 位 (软缺页, 零分配)
pte 是 swap 槽 任意 do_swap_page: 从交换区换回 (24 章)
pte 是 marker 任意 userfaultfd/PTE marker 专用路径
"软缺页"(pte 存在、只补标志位)与"硬缺页"(真分配)的成本差一个数量级,回收器(24 章)利用 accessed/dirty 位的硬件置位(19.2.2 节)把大量冷访问留在软路径处理。
22.4.4 匿名页供货:do_anonymous_page
// mm/memory.c:5230(要点)
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
...
/* 首次读且页可整体零页化: 映射共享 zero page, 零成本 */
if (pte_alloc(vmf->vma->vm_mm, vmf->pmd))
...
/* 分配物理页 */
folio = vma_alloc_zeroed_movable_folio(vmf->vma, vmf->address); /* 18.3 节 */
if (!folio)
return VM_FAULT_OOM;
/* anon_vma 记账 + 页表映射 */
...
entry = mk_pte(page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (write) {
entry = maybe_mkwrite(pte_mkdirty(entry), vmf->vma); /* 只读预埋 */
...
}
...
}
三个细节体现"分配者"的精打细算:首次读匿名页映射共享零页(zero page,全进程共享一份全零页帧,第一次写才触发 COW 分配);分配走 vma_alloc_zeroed_movable_folio(GFP_HIGHUSER_MOVABLE,页可被迁移规整——18.5 节)并零化(信息残留消毒,21.5.4 节呼应);写路径的 maybe_mkwrite(pte_mkdirty(...)) 把"写过"状态一次性置齐,避免二次软缺页。
22.4.5 文件页供货:do_fault → vm_ops->fault
// mm/memory.c:5350(__do_fault 要点)
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
...
ret = vma->vm_ops->fault(vmf); /* 分发到后备实现 */
...
}
vm_ops->fault 的通用文件实现是 filemap_fault()(mm/filemap.c):按 vm_pgoff + 偏移 查页缓存(36 章),命中即映射;未命中则发起读 I/O(带预读——VM_SEQ_READ/RAND_READ 提示影响预读窗口,mm.h:422-423)。设备/共享内存 VMA 提供各自的 fault(如 shmem_fault 从 tmpfs 取页)。文件路径把"内存供货"无缝接进页缓存——这是 mmap I/O 比 read/write 少一次拷贝的结构原因(36.1 节对照)。
22.4.6 供货失败的三种表情
| 返回值 | 含义 | 用户可见 |
|---|---|---|
| VM_FAULT_OOM | 供货时内存耗尽 | 触发 OOM 流程(24.4 节) |
| VM_FAULT_SIGBUS | 非法访问(越界映射/IO 错误) | 进程收 SIGBUS(14 章) |
| VM_FAULT_SIGSEGV | 访问无 VMA/权限非法 | 进程收 SIGSEGV |
| VM_FAULT_RETRY | 需重试(mmap_lock 竞争/IO 阻塞) | 入口循环重来 |
VM_FAULT_RETRY 是 22.2.4 节并发协议的闭环:per-VMA 快路径发现冲突时,供货方以 RETRY 让入口放弃快状态、转 mmap_lock 慢路径重来——正确性不靠锁内完美,靠"可重试"的幂等设计。
22.4.7 缺页成本的量级账
把各路径的成本量级放在一起,"按需分页为什么可行、瓶颈在哪"就一目了然:
路径 量级 (现代 x86, 粗略)
───────────────────────────────────────────────────────
软缺页 (补 accessed/dirty) ~0.2-0.5μs
硬缺页-匿名 (分配+映射) ~1-3μs
硬缺页-文件 (页缓存命中) ~1-2μs
硬缺页-文件 (需读盘) ~100μs-数 ms (SSD/HDD)
swap-in (zswap 命中) ~10-50μs (解压)
swap-in (真读盘) ~100μs-数 ms
MGLRU look_around 摊派 接近零 (邻居批量分代)
设计含义:
- 异常本身 (进入/退出/查 VMA) 是固定税 → 热路径优化
都在压这段 (per-VMA 锁 22.2.4、CR2 读取 25.1.2)
- 命中页缓存的文件缺页已接近"免费" → mmap I/O 的
优势区间 (36 章对照)
- 磁盘 IO 的 100 倍落差是回收策略 (24 章) 的一切
— 把 IO 型缺页挡在稳态之外
/proc/vmstat 的 pgfault/pgmajfault 分别是总缺页与"需要 IO 的重大缺页"计数——两者的差值即软缺页量;性能调优的第一步永远是看 majflt 是否为零。
小结
按需分页把"分区表承诺"与"物理供货"解耦:入口解码异常语义并按 per-VMA 锁协议找到 VMA,handle_pte_fault 以 pte 状态×访问方式的矩阵分发给匿名供货(零页起步、写时分配、movable 页)、文件供货(页缓存 + vm_ops 多态)或 COW 判定(25.3 节展开),软缺页路径让标志位修复零分配完成。VM_FAULT_* 的返回契约把 OOM、SIGBUS/SIGSEGV 与重试语义编成统一协议。下一节看两个最活跃的动态扩张源——栈与堆——如何在这张分区表上做"增量记账"。
22.5 栈自动扩展与堆管理 (brk/mmap)
进程的地址空间不是 exec 时一次画完的静态图:栈向下生长、堆向上生长、glibc 的大块分配转投 mmap。这三个动态源各有一套内核约束——栈生长要过 acct_stack_growth() 的五道检查与 stack_guard_gap 的安全间隙,brk 的收缩要防"与别的映射交错",mmap 化的堆则完全复用 22.3 节的机制。本节解析两边的实现与它们的容量边界。
22.5.1 栈的自动扩展
栈 VMA 由 setup_arg_pages()(fs/exec.c:598)在 exec 时创建并标注 VM_GROWSDOWN(mm.h:410)。此后"栈用超了一页"不表现为越界错误,而是触发缺页异常走进扩展判定:
// mm/vma.c:3184-3240(节选)
int expand_downwards(struct vm_area_struct *vma, unsigned long address)
{
struct mm_struct *mm = vma->vm_mm;
...
if (!(vma->vm_flags & VM_GROWSUP))
return -EFAULT; /* 只对栈类 VMA 放行 */
...
mmap_assert_write_locked(mm);
/* Guard against exceeding limits of the address space. */
address &= PAGE_MASK;
if (address >= (TASK_SIZE & PAGE_MASK))
return -ENOMEM;
address += PAGE_SIZE;
/* Enforce stack_guard_gap */
gap_addr = address + stack_guard_gap;
/* Guard against overflow */
if (gap_addr < address || gap_addr > TASK_SIZE)
gap_addr = TASK_SIZE;
next = find_vma_intersection(mm, vma->vm_end, gap_addr);
if (next && vma_is_accessible(next)) {
...
/* 不能撞进别的可访问映射 */
}
...
if (unlikely(anon_vma_prepare(vma)))
return -ENOMEM;
...
error = acct_stack_growth(vma, size, grow); /* 五道检查 */
...
/* 通过后: vm_start 下移, 补建 pte */
}
// mm/vma.c:3059-3086(节选, 五道检查)
static int acct_stack_growth(struct vm_area_struct *vma,
unsigned long size, unsigned long grow)
{
struct mm_struct *mm = vma->vm_mm;
unsigned long new_start;
/* 1. address space limit tests */
if (!may_expand_vm(mm, vma->vm_flags, grow))
return -ENOMEM;
/* 2. Stack limit test */
if (size > rlimit(RLIMIT_STACK))
return -ENOMEM;
/* 3. mlock limit tests */
if (!mlock_future_ok(mm, vma->vm_flags & VM_LOCKED, grow << PAGE_SHIFT))
return -ENOMEM;
/* 4. 不得长进 hugetlb 专属区 */
new_start = (vma->vm_flags & VM_GROWSUP) ? vma->vm_start :
vma->vm_end - size;
if (is_hugepage_only_range(vma->vm_mm, new_start, size))
return -EFAULT;
/*
* 5. Overcommit.. 最后测, 因为它要更新安全统计
*/
if (security_vm_enough_memory_mm(mm, grow))
return -ENOMEM;
return 0;
}
guard gap 的安全问题(mm/mmap.c:932-944 的 stack_guard_gap,默认 256 页 = 1MB):扩展前的 find_vma_intersection(mm, vma->vm_end, gap_addr) 检查的不是紧邻的一页,而是 fault 地址之后一整个 gap——这是对历史漏洞(栈与相邻 mmap 区之间只隔一页时,用精确控制缺页地址的攻击"跨洞"探入相邻映射,如著名的 Guard-page 类漏洞)的修复。注释原文保留的语义:栈可以越过 gap 与同为栈类的映射相邻(vma_expand_down 路径的 VM_GROWSDOWN 判定,mmap.c:979-1010),但绝不越过可访问的普通映射。
22.5.2 sys_brk:堆的增删
// mm/mmap.c:116-200(节选)
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
unsigned long newbrk, oldbrk, origbrk;
struct mm_struct *mm = current->mm;
struct vm_area_struct *brkvma, *next = NULL;
unsigned long min_brk;
...
min_brk = mm->start_brk;
#ifdef CONFIG_COMPAT_BRK
/*
* CONFIG_COMPAT_BRK can still be overridden by setting
* randomize_va_space to 2, which will still cause mm->start_brk
* to be arbitrarily shifted
*/
if (!current->brk_randomized)
min_brk = mm->end_data;
#endif
if (brk < min_brk)
goto out;
/*
* Check against rlimit here...
*/
if (check_data_rlimit(rlimit(RLIMIT_DATA), brk, mm->start_brk,
mm->end_data, mm->start_data))
goto out;
newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);
if (oldbrk == newbrk) {
mm->brk = brk;
goto success;
}
/* Always allow shrinking brk. */
if (brk <= mm->brk) {
/* Search one past newbrk */
vma_iter_init(&vmi, mm, newbrk);
brkvma = vma_find(&vmi, oldbrk);
if (!brkvma || brkvma->vm_start >= oldbrk)
goto out; /* mapping intersects with an existing non-brk vma. */
...
mm->brk = brk;
if (do_vmi_align_munmap(&vmi, brkvma, mm, newbrk, oldbrk, &uf,
/* unlock = */ true))
...
}
...
ret = do_brk_flags(&vmi, vma, addr, len, vm_flags); /* :1236 扩张 */
brk 的两种流向:
增长: mm->brk ──页对齐──> do_brk_flags() vma.c:2874
├─ 堆 VMA 已存在且可扩: 直接推进 vm_end (等价一次 merge)
├─ 否则新建 brk VMA (匿名, VM_ACCOUNT)
└─ 只改分区表, 不供货 (页后到, 22.4 节)
收缩: mm->brk ──> do_vmi_align_munmap() 拆掉 [newbrk, oldbrk)
├─ 释放对应页帧 (清 pte + mmu_gather 刷 TLB)
└─ brk VMA 相应回缩或消失
两条越界防线:
brk < start_brk 不许低于堆底
RLIMIT_DATA (check_data_rlimit) 数据总量配额
收缩路径的防御性检查值得细读:!brkvma || brkvma->vm_start >= oldbrk 的判定确保"要缩掉的范围完全属于 brk VMA 自己"——如果中间混入了用户的 MAP_FIXED 映射,收缩被拒(mapping intersects with an existing non-brk vma)。brk 语义要求堆是"自己的一亩三分地",任何外来映射穿插都会使其不可收缩。
CONFIG_COMPAT_BRK 与 brk_randomized(mmap.c:130-136)是堆随机化的兼容开关:现代默认 randomize_va_space=2 时 exec 把 start_brk 在 end_data 之上随机偏移(brk_randomized 置位后 min_brk 用随机后的起点),防"堆地址可预测"的旧式利用。
22.5.3 malloc 的双层策略
用户态分配器不直接决定"走 brk 还是 mmap",但两者在 glibc 中的分工塑造了堆的形态:
小分配 (glibc 默认 < MMAP_THRESHOLD, 动态 128KB):
brk 堆内切分 (ptmalloc arena)
→ 内核视角: 一次 brk 扩张, 之后零系统调用
→ free 不还内核 (arena 保留), RSS 稳态高
大分配 (≥ MMAP_THRESHOLD):
每次 mmap 一个匿名段, free 即 munmap
→ 内核视角: 大量 VMA 创建/销毁 (map_count 压力)
→ 释放立即可见 (RSS 回落), 但每块多两次系统调用 + TLB 刷新
→ 阈值动态调节: 频繁 munmap 的大块会抬高阈值 (避免抖动)
内核侧的对应观测:
/proc/pid/smaps 的匿名段分布
mm->map_count / sysctl_max_map_count 配额 (22.3.1 节)
brk VMA 与匿名 mmap VMA 在 smaps 中可用地址区分
22.5.4 容量边界的对照表
| 边界 | 检查点 | 默认值/来源 |
|---|---|---|
| 栈上限 | acct_stack_growth 第 2 道 |
RLIMIT_STACK(软 8MB 常见) |
| 栈与邻居间隙 | stack_guard_gap |
256 页(mm/mmap.c:935) |
| 地址空间总量 | may_expand_vm |
task_size 与锁定配额 |
| mlock 总量 | mlock_future_ok 第 3 道 |
RLIMIT_MEMLOCK |
| overcommit | security_vm_enough_memory_mm 第 5 道 |
sysctl_overcommit_memory 策略 |
| 堆底 | brk < min_brk |
mm->start_brk(随机化后) |
| 堆/数据配额 | check_data_rlimit |
RLIMIT_DATA |
三套边界(栈五道、堆两道、mmap 的 max_map_count/overcommit)共同把"进程能吃掉多少地址空间"变成可配置的安全参数——这正是 overcommit 模型把"承诺"与"物理"分开后必须补上的账。
小结
栈与堆是分区表上仅有的两个"自动编辑"源:栈的向下生长由缺页驱动,经 expand_downwards 的边界、邻居(含 guard gap)与 acct_stack_growth 五道检查(空间限额、RLIMIT_STACK、mlock、hugepage 区、overcommit)后改 VMA 补页;brk 由用户显式驱动,增长走 do_brk_flags 的建/并,收缩走严格归属检查后的 munmap,堆底与 RLIMIT_DATA 构成双重下界。glibc 把小分配留在 brk arena、大分配转投 mmap,两种策略在系统调用成本、VMA 压力与内存归还及时性上互为镜像。至此进程地址空间全章闭环;下一章进入 Huge Pages 与透明大页——分区表上的"大块论"。