前阵子帮人排查一个很反直觉的现象两个进程加载同一份可执行文件打印出来的全局变量地址一模一样都是0x7f...开头的高位值可读出来的内容完全不同。第一次碰到这种情况的人通常会怀疑编译器优化、怀疑链接脚本、怀疑共享库加载顺序其实答案就藏在虚拟内存这层抽象里——CPU 发出的那条地址跟内存条上真实存在的那个位置压根不是一回事。这篇想聊清楚的就是这条链路一个指针从被解引用到真正落到某根内存颗粒上中间经过了哪些转换、由谁负责、出错时又是谁在喊停。后半程会把重点放在内存保护上这里说的保护不是应用层那种if (ptr NULL)的参数校验而是每条访存指令执行瞬间、由硬件直接裁决的硬约束。写过驱动、调过段错误、被mprotect坑过的人读起来会比较顺完全没接触过底层内存模型的同学也不用担心我会尽量用日常能见到的东西做类比把页表、TLB、缺页中断这些概念一个个拆开。1. 同一个地址在两份进程里为何互不干扰1.1 进程看到的地址空间是一份私有地图先把结论摆出来每个进程都拥有一份独立的虚拟地址空间从 0 开始一路排到某个上限32 位机器上通常是 3GB 用户空间加 1GB 内核空间64 位机器上用户态可用的虚拟地址范围要大得多。进程里所有代码、数据、堆、栈、共享库全都落在这张私有地图的某个区段上。两个进程打印出同一个数值地址只能说明它们的地图布局恰好类似不代表它们指向同一块物理内存。真正决定这条虚拟地址对应哪块物理内存的是每个进程各自持有的页表。页表由操作系统维护CPU 在访存时通过一个专用寄存器x86 上是CR3找到当前进程的页表根节点。进程切换时这个寄存器被换掉整个地址映射关系就跟着换了一套。所以同一个数值的虚拟地址在不同进程里落到不同物理位置是再正常不过的事情。这也解释了开头那个现象两个进程加载了同一份共享库虚拟地址相同是因为布局算法一致但其中的可写数据段被写时复制机制分成了两份读出来的值自然不同。1.2 没有这层抽象会糟糕到什么程度不少人以为虚拟内存主要作用是让内存看起来比实际大其实那只是附带收益。如果拿掉虚拟内存回到早期那种程序直接操作物理地址的模式会立刻面临三个绕不开的麻烦。第一个是重定位程序编译时没法预知自己会被加载到物理内存的哪个位置所有绝对地址跳转和全局变量引用都得在加载时再修补一遍链接器和加载器的工作量陡增。第二个是碎片多个程序先后加载释放物理内存很快被切成一段段不连续的空洞想找一块足够大的连续区域越来越难。第三个也是最要命的越界踩踏指针算错了、数组下标溢出了改的是别人的数据程序当场毫无反应故障可能在几小时后才以完全无关的形式爆出来排查成本高到离谱。虚拟内存把这三个问题一次性解决掉了。程序的编译链接按固定虚拟地址进行加载器只需要建立映射、不用搬移代码物理内存可以按页为单位离散分配不要求连续而越界访问会在映射层面直接失败异常立即抛出故障现场被完整保留下来。1.3 隔离、抽象、超额承诺三层收益各管一段这三层收益我习惯分开讲因为它们的实现机制不同出问题时的表现也不同。隔离依靠的是页表项的权限位进程 A 的页表里没有进程 B 的物理页映射A 就算拿到 B 的虚拟地址也访问不到硬件会当场拦下来。抽象依靠的是地址翻译本身程序员只需要按虚拟地址写代码不用关心物理布局同一份二进制可以在不同机器上跑。超额承诺overcommit靠的是按需分页进程调用malloc申请一大片内存时内核只登记虚拟地址范围、分配虚拟内存区域VMA并不立刻分配物理页等程序真正写入某一页时才触发缺页中断、现场分配。这也是为什么一个进程的虚拟内存可以轻松显示为几十 GB而实际占用只有几百 MB。理解这三层收益的分工很重要因为线上排查内存问题时虚拟内存大和物理内存大是两个完全不同的信号混为一谈会得出完全错误的结论这一点后面还会详细说。2. 一次访存要过几道关卡地址翻译全链路2.1 一张大表为什么不可行多级页表的由来最朴素的页表设计是一条数组下标是虚拟页号值是物理页帧号。听起来很简洁算一下就知道行不通。以 48 位虚拟地址、4KB 页大小为例虚拟页号有 2^36 个每个页表项按 8 字节算单是一个进程的页表就要 512GB。而实际程序往往只用到几 MB 到几百 MB 的地址范围绝大部分页表项是空的这五百多 GB 里超过 99.9% 是浪费。多级页表的思路是把这条大数组切成树48 位地址拆成 9 9 9 9 12前四段各 9 位作为四级页表的索引最后 12 位是页内偏移。每级页表本身占 4KB512 项 × 8 字节恰好一页。这样只有实际被用到的地址范围才会分配下级页表页稀疏的地址空间就稀疏地占内存。代价是访问一次数据理论上要读五次内存四级页表加目标数据性能开销巨大。这个代价必须靠缓存来填于是引出了下一个角色。2.2 MMU 与 TLB硬件层面的两位主角MMU内存管理单元是 CPU 内部负责地址翻译的硬件程序完全感知不到它的存在。它挂在地址总线上CPU 核心发出的虚拟地址先交给它它查页表、算出物理地址再发给内存控制器。如果 MMU 不存在翻译工作就得由软件模拟性能会跌到无法接受。TLB 则是 MMU 内部的一块小容量高速缓存专门缓存虚拟页号到物理页帧号的翻译结果。命中 TLB 时翻译几乎零开销可以全速跑未命中才需要走完整的页表遍历page walk。典型的现代处理器会配两级 TLBL1 几十项、L2 上千项覆盖的地址范围有限所以程序的内存访问局部性对性能影响极大。这解释了为什么遍历一个大数组的耗时远低于随机跳着访问同样数量的元素——后者几乎每次都在 TLB 未命中。多核场景下还有个隐藏成本某个核修改了页表其他核的 TLB 里可能还残留旧翻译必须发TLB shootdown通知其他核失效对应表项。这类操作是跨核同步开销不小在高并发服务里批量修改映射时要格外小心。2.3 缺页中断从异常触发到指令重试的完整现场当一个虚拟页还没有建立映射或者映射存在但对应的物理页不在内存里MMU 遍历页表时会发现页表项的有效位为 0随即触发缺页异常x86 上叫#PF并把出错的虚拟地址记录到CR2寄存器。控制权交给内核的缺页处理程序它要做几件事先看这个地址落在哪个 VMA 里如果压根没有对应的 VMA说明是非法访问直接给进程发SIGSEGV如果有 VMA 但属性不匹配比如往只读区域写同样报错。地址合法的情况下分两种路径一种叫次缺页minor fault物理页已经在内存里只是页表项没建好比如写时复制、或者页面刚从交换区换回来但还在页缓存中内核填好页表项即可另一种叫主缺页major fault需要从磁盘读数据耗时从几十微秒到几毫秒不等。处理完成后内核返回CPU 重新执行那条引发异常的指令这次翻译能成功程序继续跑。整个过程中用户态程序完全无感知它只是觉得那一条指令执行得慢了一点。也正因为无感知缺页频率高的时候故障表现是卡顿而不是报错排查时容易走偏。2.4 页表项里那 64 个比特到底装了什么页表项只有 8 字节却塞进了地址和保护的全部信息值得单独拆开看。物理页帧号占据高位其余低位是标志位不同架构的位定义有差异但核心几个是一致的标志位名称作用PPresent页是否在内存中为 0 触发缺页R/WRead/Write0 表示只读1 表示可写U/SUser/Supervisor0 表示仅内核可访问1 表示用户态也可访问PWT/PCD缓存控制控制该页是否走写通、是否禁用缓存AAccessed被访问过则硬件置 1供置换算法参考DDirty被写过则置 1决定换出时是否需要回写PAT页属性表配合 PCD/PWT 选具体的缓存策略GGlobal全局页进程切换时不失效NX/XDNo Execute置 1 表示该页不可执行防代码注入的关键A位和D位由硬件自动维护内核靠它们判断哪些页是热的、哪些页被改过需要回写页面置换算法就建立在这两个位之上。NX位是硬件级的执行保护栈上的数据即使被成功写入也无法直接跳过去执行这一点让大量传统的缓冲溢出攻击失效。我在调试内核模块时踩过一次坑手工构造页表项忘了置P位结果每次访问都缺页内核反复尝试修复、反复失败日志里刷出一堆信息排查了很久才发现是自己漏了一个比特。3. 内存保护不是防君子是每条指令的硬约束3.1 三个权限位撑起了九成保护逻辑前面表格里的R/W、U/S、NX这三个位组合起来基本覆盖了日常能见到的所有内存保护场景。往只读页写会触发保护错误用户态访问U/S为 0 的页会触发错误跳到NX为 1 的页执行会触发错误。粒度是页级也就是 4KB大页场景下是 2MB 或 1GB页内的偏移不受单独控制。这就带来一个有意思的后果数组越界如果只跨出几个字节、还落在同一个页内硬件层面完全不拦程序照样能读写数据被悄悄改坏。只有当越界跨过了页边界、撞上未映射或者只读的区域才会当场崩溃。这也是为什么很多内存越界 bug 表现得极其诡异——有时崩、有时不崩改一下编译选项或者内存分配器的行为现象就变了。3.2 用户态与内核态的边界是怎么焊死的现代处理器至少有两个特权级用户态代码跑在低特权级内核跑在高特权级。这个划分不是靠软件约定而是靠硬件状态位当前特权级存在访问内存时 MMU 会拿当前特权级和页表项的U/S位做比对不满足就拒绝。所以用户态程序想读内核数据哪怕它精确知道那块物理内存的虚拟地址也会在访存指令执行的瞬间被拦下。这个机制的另一面是内核态的保护CR0寄存器里有个WP位Write Protect置 1 之后内核态往只读页写同样会触发错误。早期有些系统把它关掉导致内核可以随意改写用户传进来的只读缓冲区安全问题频出。现在主流的做法是打开WP再配合SMEP、SMAP禁止内核执行用户页、禁止内核直接访问用户页把用户态和内核态的隔离做得更彻底。这些机制平时感知不到但它们决定了提权漏洞这种攻击到底有多难写。3.3 写时复制fork 之后内存为什么没有立刻翻倍fork创建子进程时如果老老实实把父进程的内存整份复制一遍代价会大到无法接受尤其是父进程占了几百 MB 的场景。实际做法是把父子进程的页表项都指向同一批物理页同时把双方的R/W位都改成只读。真正复制发生在某一方尝试写入的时候写操作触发保护错误内核检查发现这是共享页于是分配一个新物理页、把内容拷过去、把这一方的页表项改成指向新页并恢复可写另一方不受影响。这就是写时复制Copy-On-Write。它把复制成本从fork时刻推迟到了实际写入时刻而且只复制真正被改过的页。这个设计对forkexec这种常见组合几乎零成本——子进程立刻被新程序替换压根不会写父进程的页。但反过来如果一个进程fork出子进程后双方都大量写内存物理占用会迅速翻倍容器里内存超限被OOM Killer干掉往往就是这么来的。我见过有服务在fork之后立刻在子进程里遍历一个大字典做清理结果整个内存瞬间翻了一番这类写法在内存吃紧的环境里非常危险。3.4 上层软件如何借力硬件保护硬件提供了页级权限这套底座上层再在它上面搭各种机制。W^X可写与可执行互斥就是典型的例子正常情况下一个内存页要么可写不可执行数据页要么可执行不可写代码页不允许同时具备两个属性。JIT 编译器的做法是先用可写权限生成机器码再用mprotect把该页改成可执行、去掉可写属性最后跳过去执行。堆栈保护栈金丝雀依赖的是在返回地址前面放一个随机值函数返回前校验被溢出改写就会检测到并主动终止进程这是软件层的机制不是硬件页保护。地址空间布局随机化ASLR则是让每次运行时的基址不同攻击者没法硬编码目标地址。还有一道比较新的防线是影子栈CET 机制的一部分把返回地址单独存一份返回时比对直接拦下返回地址劫持类的攻击。这些机制各有分工页保护负责粗粒度隔离和权限校验软件层机制负责细粒度的完整性校验谁都不能替代谁。4. 在真实系统上把虚拟内存看个明白4.1 从 /proc/pid/maps 读懂一个进程的地图想搞明白一个进程的地址空间长什么样最直接的办法是看它的内存映射文件。类 Unix 系统上/proc/pid/maps会逐行列出当前进程所有 VMA# 查看当前 shell 自身的内存映射 cat /proc/self/maps | head -20输出每一行是起始地址-结束地址 权限 偏移 设备 inode 路径的格式。权限那四位分别是读、写、执行、私有/共享标记r-xp表示只读可执行私有典型的代码段rw-p表示可读写私有通常是数据段和堆r--p一般是只读数据或者只读映射的文件。看这个文件能立刻确认几件事共享库被映射到了哪些地址、堆的起止范围、栈的大小、有没有匿名映射异常增长。排查内存泄漏时我经常先对比进程启动时和运行几小时后的 maps看哪个区段在持续变长如果是堆段在涨问题在业务代码的内存分配如果是一堆零散的匿名映射在涨多半是某处mmap之后没munmap。更细的信息在/proc/pid/smaps里每个 VMA 会列出Rss、Pss、Shared_Clean、Shared_Dirty等字段这些才是判断真实内存占用的关键数据。4.2 用 mmap 和 mprotect 做一次保护实验光看不如动手。下面这段代码演示了页级保护的实际效果先映射两页内存、写入数据然后把第一页改成只读再尝试写入观察会发生什么。#include stdio.h #include stdlib.h #include string.h #include sys/mman.h #include unistd.h #include signal.h static void on_segv(int sig) { /* 只做最简处理真实项目里要谨慎 */ write(2, caught SIGSEGV, exiting\n, 24); _exit(1); } int main(void) { long page sysconf(_SC_PAGESIZE); /* 通常是 4096 */ char *p mmap(NULL, page * 2, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p MAP_FAILED) { perror(mmap); return 1; } memset(p, A, page * 2); printf(write ok, addr%p size%ld\n, p, page * 2); /* 把第一页改成只读 */ if (mprotect(p, page, PROT_READ) ! 0) { perror(mprotect); return 1; } signal(SIGSEGV, on_segv); printf(first byte%c\n, p[0]); /* 读没问题 */ p[0] B; /* 触发保护错误 */ printf(not reached\n); return 0; }编译运行后能稳定看到读操作正常、写操作触发SIGSEGV。这个实验的价值在于把只读保护从概念变成可观测行为。要注意mprotect的第一个参数必须是页对齐的传入未对齐地址会返回EINVAL长度会向上取整到页边界。另外mprotect修改的权限不能超过原始映射的权限用PROT_READ映射出来的区域后面再怎么调也加不出写权限这是常见的新手误区。4.3 缺页和 TLB 未命中是可以量化的不用靠猜这两个指标都能测。进程级的缺页次数在/proc/pid/stat里第 10 和第 12 个字段分别是次缺页和主缺页计数getrusage系统调用也能拿到同样的数据。系统级的统计在/proc/vmstat里pgfault、pgmajfault两个字段可以直接读。# 进程级看某进程的缺页次数 awk {print minor$10, major$12} /proc/$$/stat # 系统级全局缺页统计 grep -E ^(pgfault|pgmajfault) /proc/vmstatTLB 层面的信息需要硬件性能计数器支持主流处理器上可以用性能分析工具直接采# 统计某命令执行期间的缺页与 TLB 未命中 perf stat -e page-faults,dTLB-load-misses,dTLB-loads ./your_program我一般会先跑一遍看基线再改代码看变化。如果dTLB-load-misses占dTLB-loads的比例超过百分之几基本可以确定数据结构的访问模式不够局部常见原因是链表的节点散落在堆上各处遍历时几乎每一步都在跳页。把链表改成数组、或者改用内存池让节点集中分配这个比例能降下来一个数量级实际耗时改善非常明显。4.4 用资源限制给地址空间装上保险虚拟地址空间本身也是可以设限的。ulimit -v控制进程能申请的最大虚拟内存总量超过之后mmap、brk这类系统调用直接失败。容器环境里通常通过控制组cgroup做更精细的限制内存上限、交换区使用量都能分别设置。这里有个经验值得记给进程设虚拟内存上限时要考虑它申请的映射总量而不是实际物理占用。很多运行时环境会预留一大片虚拟地址比如垃圾回收器自己管理堆映射量远大于真实用量把ulimit -v卡得太紧会直接导致启动失败错误信息还往往只说无法分配内存非常具有迷惑性。物理内存的限制应该用控制组的内存限额来做两者不要混用。5. 那些年在这套机制上踩过的坑5.1 虚拟内存大不等于内存泄漏这是最高频的误判。一个进程虚拟内存显示 20GB很多人第一反应是泄漏了实际上大多数情况下只是映射了一堆从来没写过的地址。判断是否泄漏要看Rss常驻物理内存和Pss按共享比例分摊后的物理内存而不是Vsize。Pss这个指标尤其容易被忽略多个进程共享同一份库的时候Rss会把共享部分各自算一遍加起来严重虚高Pss才是分摊之后的真实值。排查时我会把两个数并排打印出来对照Vsize涨但Rss不动基本可以排除泄漏Rss持续单调上涨、且不随负载波动而回落才值得深挖。另外要区分增长和泄漏一个服务启动后从 200MB 涨到 500MB 然后稳定下来那是缓存预热不是泄漏。5.2 透明大页带来的延迟毛刺大页能把 4KB 的映射合并成 2MBTLB 能覆盖的地址范围瞬间扩大五百多倍对内存密集型应用是实实在在的性能提升。但透明大页THP的分配是内核在背后自动做的分配 2MB 连续物理内存可能需要内存整理compaction这个过程会造成几十毫秒甚至更长的停顿。在延迟敏感的服务上这种偶发的长尾抖动比平均性能下降更致命因为它会打破上层设置的超时假设引发连锁重试。我一般会建议延迟敏感型的服务把 THP 调成madvise模式只在自己明确知道有收益的内存区域上主动开启而不是全局always。查看和调整方式读取/sys/kernel/mm/transparent_hugepage/enabled看当前策略用它旁边的defrag文件看整理策略。这类系统级参数改动前一定要在预发环境压测直接上生产风险太大。5.3 写了半年的代码越界却没崩过前面提过页级保护的粒度问题这里展开说说为什么它会造成越界不崩的假象。假设有一个 4KB 的堆块程序往后多写了 16 个字节只要这 16 个字节还落在同一个页里、且后面还有已映射的空间硬件完全不报错。数据被悄悄改掉了可能是下一个对象的元信息也可能是分配器的链表指针。故障会在后续某次malloc或者free时才爆发崩溃点离真正的元凶可能相隔几百行代码。更麻烦的是加上调试器、换个分配器、改一下环境变量内存布局一变问题就消失了。我的经验是这类问题不要指望复现要靠工具主动发现。地址检查工具如编译器的地址检查器能在越界发生的瞬间报出精确位置代价是几倍的性能开销可以在单元测试和预发环境跑线上环境用轻量的堆检查或者分配器自带的检测模式来兜底。5.4 超额承诺下的内存申请成功并不代表能用开启超额承诺后malloc返回非空只能说明虚拟地址分配成功不代表物理内存真的可用。真正的问题在写入那一刻才暴露——如果此时系统物理内存加交换区都不够内核的 OOM 机制会挑一个进程杀掉。这就导致一个非常恶劣的故障模式某个进程申请了远超实际需要的内存把整个机器拖进 OOM被杀掉的却是另一个占用正常的无辜进程。规避办法有两个方向一是给关键服务单独设内存限额让它在自己超限时先被杀不影响别人二是对超大块的分配做显式的物理内存预热mmap之后memset一遍让问题在申请阶段就暴露出来而不是拖到运行中途。后一种做法会增加启动时间用在关键路径上通常是值得的。5.5 页表本身也是要占内存的这个坑比较隐蔽。页表页本身是内核为了维护映射而分配的内存一个进程映射的虚拟地址范围越大需要的页表页越多。按四级页表算每覆盖 2MB 地址范围大约需要一个页表页4KB也就是大约千分之二的额外开销看起来不多。但如果一个进程映射了几百 GB 的地址空间比如某些数据库会预留超大映射区光是页表就要占掉上 GB 内存这部分开销算在被杀进程头上看业务层的统计完全看不出来。排查时我会看/proc/meminfo里的PageTables字段这个值异常增长往往意味着某个进程在疯狂扩张映射范围。另外内核里那些用vmalloc分配的区域需要单独建立映射粒度更细页表开销比常规映射更高热路径上不要滥用。我个人在这套机制上折腾这么多年最大的体会是虚拟内存和内存保护真正的价值不在于多给了一块空间而在于它把一类错误从静默的数据污染变成了当场可见的异常。崩溃本身不是坏事那些不崩溃的越界才是。所以每次遇到内存相关的诡异问题我都会先把四件事拉出来对照一遍——Vsize、Rss、缺页次数、内存映射列表——这四组数据往往能把猜测范围压缩到一两个方向。至于页表的具体位域和各级索引怎么拆真要手写的时候再查手册就够了理解它为什么这么设计比背下来更有用。下一篇打算聊物理内存分配那部分包括伙伴系统和 slab 分配器怎么解决外部碎片以及为什么申请一小块内存的开销远比想象中大。
阅读完成 · 觉得有帮助?