
mmap 了 1 页内存读它为什么一分钱不花写 C 的人大概都用过 mmap向内核申请一段内存拿到地址就能读写。直觉上申请了就是占了读一下总该分到物理内存吧下面这段程序只申请了 1 页先读一次再写一次#includesys/mman.h#includestdio.h#definePAGE_SIZE4096intmain(){size_tmap_sizePAGE_SIZE;void*addrmmap(NULL,map_size,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);volatilecharc*(char*)addr;/* 读触发缺页 */*(char*)addrA;/* 写再次触发缺页 */printf(%c\n,c);return0;}先把直觉摆到台面上申请了就是占了读了就该分 1 页RSS 涨 4KB。实测结果却不是这样读不花写才花。更怪的是“读完再写”和“上来就写”走的根本不是同一条内核路径。申请了 1 页读一下真的免费吗要回答这个问题不能只看代码猜得让内核自己说。我用 eBPF 给内核挂了几个探针专门盯着缺页处理里几个关键函数看它们到底有没有被触发。实验很简单三个小程序其他条件全一样每组只 mmap 1 页只改碰内存的方式只读、直接写、读后写。实验在 QEMU Linux 6.17 里做Ubuntu 25.10x86_64 KVM内核开了 BTF方便eBPF调试。结果整理成一张对照表碰法本以为会怎样内核实际做了什么结果只读分配函数会来读了就分 1 页只有 do_anonymous_pageflags0x1a54不带写标志分配事件、do_wp_page 都没出现读没分新页直接写分配函数会来写了就分 1 页do_anonymous_pageflags0x1a55带写标志 分配事件 vma_alloc_folio_noproforder0首触写真的分配一页读后写第二次写还会进 do_anonymous_page第二次写不再进 do_anonymous_page而是进了 do_wp_pageflags0x1a55后跟分配事件 vma_alloc_folio_noproforder0据此排除 wp_page_reuse、判定为 wp_page_copy读过的页一写还要拷一遍读和写的 flags 只差最后一位0x1a54 是读0x1a55 是写正好就是写标志这一位。这里要交代一个细节wp_page_copy 和 wp_page_reuse 在内核里都是 inline 函数探针挂不到它们本体。所以读后写那一行不是“探针抓到了 wp_page_copy”而是“探针抓到了 do_wp_page 分配事件”。copy 一定要分配新页reuse 不用分配有分配事件就能排除 reuse这才是判定为 copy 的依据。分配事件在 trace 里叫 vma_alloc_folio_noprof它是 wp_page_copy 经 folio_prealloc、vma_alloc_zeroed_movable_folio、vma_alloc_folio 走下来的下游。读后写这条路的调用关系画出来是这样handle_pte_fault └── do_wp_page ├── wp_page_reuse # 不分配只标可写 └── wp_page_copy # 必分配 └── folio_prealloc - vma_alloc_zeroed_movable_folio - vma_alloc_folio - vma_alloc_folio_noprof到这里第二个怪现象已经藏不住了**写就是写怎么还分两条路**答案在缺页处理里。三种碰法三条路先说一个前提mmap 返回后内核只是在进程的地址空间链表里记下了这段虚拟地址没有分配任何物理页也没有建立任何页表项。虚拟内存的“申请”和物理内存的“占用”是两回事缺页中断才是真正花钱的时刻。CPU 访问还没建页表的地址就会触发缺页经 do_user_addr_fault 进入 do_anonymous_page之后按碰法分流读先借一张零页。这不是写缺页没有写标志进程也没有禁用零页!mm_forbids_zeropage于是走零页分支entrypte_mkspecial(pfn_pte(my_zero_pfn(addr),vma-vm_page_prot));内核把虚拟地址映射到全局唯一的一张 Zero Page并把页表项标为 special。读到的全是 0需求满足了物理内存一分没花。直接写分一张新页。写缺页同样进 do_anonymous_page但走分配分支调用vma_alloc_zeroed_movable_folio申请新页实测 trace 里表现为下游的vma_alloc_folio_noprof再用set_ptes建立可写映射。这时才完成虚拟内存到物理内存的实质性绑定。读后写把零页复印一张。这时页表项已经存在了只不过指向零页且不可写。所以写缺页不再进 do_anonymous_page而是进 do_wp_page也就是写保护缺页的通用处理。do_wp_page 发现这是一个 special 页表项vm_normal_page取不到正常页于是走wp_page_copyinline实测中以 do_wp_page 后的分配事件为据见上表分配一个真正的匿名页建立可写映射。零页本身永远不会被写写操作永远发生在新页上。一句话读走零页分支首触写走分配分支读后写走 do_wp_page 的拷贝分支。三条路三种代价。两本账虚拟的“有”物理的“占”延迟分配的核心就一句话虚拟地址的“有”和物理内存的“占”是两本账缺页中断是结账的时刻而结账的方式取决于你怎么碰它——读记一笔零页的账写才真正掏钱。这篇实验还让我重新确认了一件事证据必须和机制有因果探针抓到 do_anonymous_page 还是 do_wp_page本身就是走了哪条路的判定函数本体被 inline 时区分依据就是它下游必然产生或不产生的事件比如 do_wp_page 后的分配事件PFN 和 RSS 只是可选的旁证。写应用时记住这几点说到底读是借写才是拥有内核把账单往后推写应用的人就得在账单到期那一刻想清楚别用虚拟大小衡量内存占用。mmap 成功不等于内存到手真正花钱的是你碰过的页。大块 mmap 加稀疏访问只花碰过的页反过来只看虚拟大小做容量评估会高估很多。第一次写的代价别留在关键路径上。首触写要缺页、分配、建页表时延敏感的代码最好在启动或空闲时把热页先写一遍热身反过来不用的大内存也别提前全量 memset那等于亲手把延迟分配的好处毁掉。只读的页别顺手写。读到的零页是大家共用的一写就变成自己的私有页还要走拷贝分支。哪怕写回相同的值也会触发分配确定要全页重写时直接写比先读后写少走一层分支。需要保证时别只靠 mmap 返回。写时分配才可能失败失败可能落在业务中途。要求稳的场景在启动时就把关键内存触碰一遍、尽早处理失败或者用 mlock 锁定把风险尽量留在启动时。你第一次被 mmap“骗”是什么时候欢迎在评论区说说。