ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

虚拟内存与内存保护:地址翻译、缺页异常与排查实践

虚拟内存与内存保护:地址翻译、缺页异常与排查实践

1. 先从一个让人“见鬼”的现象说起

1.1 两个进程打印同一个地址,读到的东西却不一样

很多年前我在调试一个多进程小工具,两个子进程各自打印了自己某个全局变量的地址,结果两个进程输出的地址一模一样。当时负责那个模块的同事一口咬定是打印格式写错了,或者是编译器做了手脚。但把打印换成长整型十六进制、把编译优化关到最低,地址还是那个地址,一模一样,而两个进程读到的值确实来自各自的数据。

这个现象背后就是虚拟内存。如果只是把它当成操作系统课本里的一句话记住,一到实际调试就会发懵:为什么地址会重复?为什么野指针有时候崩溃有时候不崩溃?为什么内存明明没分配,程序却跑得下去?这些问题全都指向同一个抽象——每个进程看到的地址都是假的,CPU 在真正访问内存之前会先做一次翻译,而内存保护就挂在这套翻译机制上,跟权限位一起工作的。

这篇东西我想把这两件事掰开讲清楚:虚拟内存到底解决了什么真实问题,地址翻译这条链路是怎么走完的,内存保护靠什么落地,以及我们自己动手能怎么把它“看”出来。适合写 C/C++ 的同学、做嵌入式与底层优化的同学、准备系统方向面试的同学,也适合那些看着崩溃日志里一串十六进制地址不知道从哪下手的人。不需要你事先背过页表结构,只要知道指针是什么就行。

1.2 一个类比:房卡上的房号和真实的房间

我最喜欢用的类比是酒店。你办理入住时拿到的房卡上写着“1208”,你只认这个号。但酒店有东楼西楼,真实的房间是有具体物理位置的。前台系统负责把“1208”映射到“西楼12层第8间”。如果你换一家酒店,房卡上还是“1208”,但你去的房间完全不同。更关键的是,你拿着自己酒店的房卡,刷不开别家酒店任何一间房。

这里每个对应关系都很清晰:房卡上的号码是虚拟地址,真实房间位置是物理地址,前台系统就是MMU(内存管理单元)加页表,而房卡能刷开哪些门、是只能看不能动还是能进能改,就是内存保护里的权限位。进程各有各的房卡,所以地址可以重复;不同进程的“1208”指向不同的物理页,互不干扰。

注意:这个类比里最容易被忽略的一点是“前台系统本身有成本”。每一次翻译都要查表,所以硬件才要在 MMU 旁边塞一个 TLB 做缓存。所有关于虚拟内存的性能问题,本质都是这套翻译机制的开销问题。

1.3 这篇内容能帮你解决什么

读完之后你至少能做到三件事。第一,看到崩溃日志里的地址和错误类型,能大致判断是空指针、越界、权限不符还是栈溢出,而不是盲目加打印。第二,写代码时对malloc、mmap、fork的行为有正确的心理预期,知道“分配了不等于占用了物理内存”。第三,做性能分析时不被 VSZ、RSS 这些指标带偏,能分清哪些数字是虚拟的、哪些是真实的。

后面我会按“为什么需要—怎么翻译—怎么保护—怎么观察—怎么排查”的顺序展开。中间会插入几段可以直接复制运行的代码,用几十行把虚拟内存的关键行为演示出来。这些实验我在多种类 Unix 环境上反复跑过,参数和结论都比较稳定。

2. 为什么非要有虚拟内存

2.1 直接使用物理地址的三个硬伤

假设我们回到没有 MMU 的年代,程序里的地址就是内存条上的真实位置。第一个硬伤是地址不确定。程序编译成机器码时,谁也不知道它会被加载到物理内存的哪个位置。如果别的程序已经占了 0x10000,你的程序就没法从那里开始放。为此早年甚至有“重定位表”这种东西,加载时把代码里所有绝对地址改一遍,非常笨重,而且改完之后代码段就没法在多个进程之间共享了。

第二个硬伤是没有隔离。你的程序里一个野指针写到 0x20000,那里可能正好是另一个程序的变量,甚至可能是操作系统自己的数据结构。一个程序写崩,整台机器跟着挂,这在今天的多任务环境里完全不可接受。

第三个硬伤是碎片和连续性问题。程序往往希望自己有一大段连续可用的地址空间,比如一个几百 MB 的数组。物理内存跑久了必然碎片化,找不出这么大的连续块,但程序并不需要物理上连续,它只需要“看起来连续”。这三个硬伤凑在一起,就注定了必须加一层间接。

2.2 虚拟内存真正解决的四个问题

把上面这些整理一下,虚拟内存带来的能力可以归成四条,每一条都对应现实中的开发痛点。

第一是隔离。每个进程有独立的地址空间,A 进程的指针永远碰不到 B 进程的物理页,因为页表里根本没这个映射。这带来的直接后果是,进程崩溃时你只需要重启它,不需要重启机器。

第二是重定位的自由。程序可以被加载到任意物理位置,因为编译时用的是虚拟地址,加载器只需要建立映射关系,代码段内容一个字节都不用改。共享库能被多个进程同时映射到各自的地址空间、指向同一份物理页,靠的就是这个。

第三是超配额。虚拟内存允许你申请到比物理内存更大的地址空间。原理很简单:没被真正访问的页,不给它物理页帧。这就是所谓的按需分配。你malloc一个 1 GB 的缓冲区,只要不去逐个写它,物理内存几乎不会增长。很多人第一次发现这件事时会非常惊讶。

第四是权限控制。既然每次访问都要经过页表,那就在页表项里放几个标记位,规定这块内存是可读、可写、可执行,还是只有内核能碰。内存保护就是靠这几个位落地的,后面单独讲。

2.3 一层间接带来的代价

加一层间接不是白给的。地址翻译要走多级页表,一次数据访问理论上要额外读好几次内存,这在早期硬件上是无法接受的性能损失。所以现代处理器做了两件事:一是把页表结构设计得尽量浅、尽量可缓存;二是在 MMU 里放一个专门缓存翻译结果的部件,也就是 TLB。

另一个代价是页表本身也要占内存。每个进程一张页表,进程一多,光是页表就很可观。这也是为什么现代系统对页表本身也采用按需分配:你不去碰的地址区域,二级三级页表压根不存在。这个设计思路很有意思,它说明“懒”是一种系统级的优化哲学。

代价之三是复杂度。内存管理成了内核里最容易出问题的子系统之一,缺页、写时复制、内存回收、透明大页,每一块都能单独写一本书。我们没必要全部吃透,但至少要清楚自己写的每一行代码在触碰到某个地址时,背后发生了什么。

3. 地址翻译这条链路是怎么走完的

3.1 分段先退场、分页上位的历史逻辑

早期的 x86 用分段做保护,段寄存器加偏移,段描述符里放基址、限长和权限。这套东西在 16 位时代很自然,但到了 32 位、64 位就变得很别扭:段的数量有限,管理复杂,而且段粒度太粗,一给就是一大块,没法做到细粒度的按页保护。

分页的优势在于粒度统一、结构规整。所有内存切成固定大小的页,通常是 4 KB,管理起来简单,回收和换出都很方便,还能天然支持共享和写时复制。所以现代 64 位系统基本把分段压成了一个平坦模型,段基址设 0、限长设满,真正干活的换成了分页。这个演进的逻辑其实很简单:规则越统一,实现越省事。

3.2 四级页表与地址位拆解

以常见的 x86-64 为例,虚拟地址实际使用的有效位是 48 位(高 16 位是符号扩展,必须和 bit 47 一致,否则硬件认为地址非法)。页大小 4 KB,也就是 2 的 12 次方,所以页内偏移占低 12 位。

算一下剩下的位数:48 − 12 = 36 位。页表每一级用 9 位做索引,因为 2 的 9 次方是 512,正好一页能放下 512 个页表项;每个页表项 8 字节,512 × 8 = 4096 字节,恰好一页。36 ÷ 9 = 4,所以是四级。

位区间名称作用
47–39PML4 索引找到四级页表的第一级表项
38–30PDPT 索引找到第二级页表
29–21PD 索引找到第三级页表
20–12PT 索引找到最终页表项
11–0页内偏移在 4 KB 页内定位字节

走一遍流程:CPU 拿到一个虚拟地址,先取 47–39 位,在 PML4 里找到对应项,取出下一级页表的物理基址;再取 38–30 位,在 PDPT 里找;再取 29–21 位,在 PD 里找;再取 20–12 位,在 PT 里找到最终的页表项,拿到物理页帧号;最后把物理页帧号左移 12 位,加上低 12 位的偏移,就是物理地址。

这个计算值得自己动手推一遍,因为很多看似玄乎的设计都能从这几个数字推出来。比如为什么一页是 4 KB 而不是 3 KB?因为要凑成 2 的幂,才能用位移运算代替乘法。为什么每级是 9 位?因为 512 项乘 8 字节正好一页,页表本身也不需要跨页存储。为什么是四级而不是五级?因为 4 × 9 = 36,加上 12 位偏移刚好覆盖 48 位虚拟地址,再深一级就是纯粹的浪费。

3.3 TLB:为什么每次访问还要多查一层缓存

按上面的走法,访问一个字节要额外读四五次内存,性能根本没法看。所以 MMU 内部有一个小容量、极高速的关联缓存,专门存“虚拟页号 → 物理页帧号 + 权限”的映射结果,这就是 TLB。命中一次翻译几乎不花时间,未命中才走完整的页表遍历。

TLB 的条目数不多,一级数据 TLB 常见只有几十项,二级联合 TLB 通常在一千多项的量级。这就解释了一个经常被提到的性能现象:顺序访问大数组比随机跨页访问快很多,因为顺序访问会让 TLB 的命中率维持在高位,而随机跳着访问会不断产生 TLB 未命中。也解释了大页的存在意义——一个 2 MB 的大页只需要一个 TLB 条目就能覆盖,等于把 TLB 的覆盖范围放大了 512 倍。

提示:如果你在做高性能遍历,并且数据规模明显超过几 MB,可以考虑用大页来降低 TLB 压力。但别急着上,先测量,很多场景下的瓶颈根本不在 TLB。

3.4 缺页异常:一次地址访问的完整时序

真正有意思的是“页表项不存在”这一刻。CPU 查表发现无效或者权限不符,会触发一个缺页异常,控制权交给内核。内核要判断这次缺页属于哪种情况,然后决定怎么处理。常见的有这么几类:

  • 匿名页首次访问:进程申请了内存但没写过,内核先给它一个全局共享的零页,只读映射。等它真的要写了,再分配一个物理页。
  • 文件映射页缺失:页表项指向的文件内容还没加载,内核去页缓存里找,找不到就从存储设备读上来。
  • 写时复制:父子进程共享的页被标记为只读,谁写谁触发,内核复制一份新页。
  • 栈自动增长:访问的地址在主栈区下方不远的位置,内核扩大栈映射。
  • 真正的非法访问:地址完全不在任何映射里,或者权限根本不匹配,内核只能给进程发信号。

这个判断顺序非常关键,也解释了为什么野指针有时崩有时不崩。指针指向的地址如果在某块合法映射范围内,哪怕逻辑上错了,硬件也不会拦你;只有越过映射边界,才会被抓到。这就是所谓的“未定义行为”在内存层面最常见的表现。

4. 内存保护到底保护了什么

4.1 页表项里的那几个权限位

既然每次访问都要查页表,那就在页表项里放标记位。以 x86-64 为例,关键是这么几位:存在位 P 表示这个页是否在物理内存里;读写位 RW 决定是可写还是只读;用户/超级用户位 US 决定普通用户态代码能否访问;还有 No-Execute 位,位于页表项的高位,用来禁止从这块内存取指令执行。

这四个位的组合覆盖了绝大部分保护需求。代码段映射成“可读 + 可执行 + 不可写”,数据段映射成“可读 + 可写 + 不可执行”,只读常量映射成“可读 + 不可写 + 不可执行”。一旦程序试图往代码段写,或者跳到一个数据区去执行,硬件立刻拦下。

NX 位的意义值得单独说一句。在它出现之前,栈上的数据是可以被当成指令执行的,很多老式攻击就靠这个。有了 NX,栈和堆默认不可执行,攻击面大幅缩小。这也是为什么现代编译器默认开启栈保护之类的选项。

4.2 用户态与内核态的隔离墙

US 位是隔离墙的砖。内核空间的页在页表里标记为“仅超级用户可访问”,用户态代码一碰就触发异常。攻击者想让用户态程序直接读写内核数据结构,这道墙就挡住了。

更彻底的做法是在硬件层面再加两道保险:一道禁止内核去执行用户空间的代码,另一道禁止内核随意读写用户空间的数据,需要访问时必须走专门的拷贝函数。这两道机制的意义在于,即便内核里出现了逻辑漏洞,被诱导去执行或访问用户控制的内存,也会被硬件拦下。这类细节在写驱动或者做安全加固时会频繁遇到。

需要强调的是,这道墙的方向是单向的。用户态不能访问内核,但内核可以访问用户态(通过受控方式)。原因很实际:内核要替进程读写缓冲区,比如读文件内容到用户提供的地址、把网络数据拷给应用。如果内核也完全碰不到用户空间,这些基本功能就没法实现了。

4.3 写时复制:保护位最漂亮的一次妙用

写时复制是权限位被“反向利用”的经典案例。fork创建子进程时,如果老老实实把父进程所有内存复制一遍,代价极其高昂,尤其父进程占了几百 MB 的时候。实际做法是:父子共享同一批物理页,把双方的页表项都改成只读,然后等着。

哪一方先写,就触发缺页。内核检查发现这是共享页,于是分配一个新物理页,把内容复制过去,改回可写权限,重新执行那条写指令。整个过程对程序完全透明,程序看到的仍然是“我自己的内存”。

这个机制的精妙之处在于用只读权限来表达“现在还不能写,但以后可能需要写”。一个保护位同时承担了隔离和延迟复制两个职责,实现上又极其干净。理解了这个案例,你就明白为什么权限位不能只有“能”和“不能”两种,而需要和异常处理配合起来。

4.4 保护失败时的现场长什么样

保护被触发的表现,在不同层面不太一样。最常见的是收到段错误信号,进程直接终止,日志里留一句简单的错误信息加一个地址。如果用调试器或者信号处理函数抓一下,能看到出错的具体地址。

区分几种典型情况很有用。地址接近 0,基本是空指针解引用或者结构体成员偏移取错了。地址是一串看起来随机的值,多半是野指针或者已经释放的对象还在被使用。地址落在栈区边界外一点点,可能是栈溢出或者大数组越界。地址落在代码段内却报写入错误,通常是想改字符串常量。

我踩过的一个坑是:某个结构体在头文件里改了字段顺序,但有一个模块是用旧头文件编译的,两边对字段偏移的理解不一致,往一个本来不该写的地址写了数据。报错地址看起来毫无规律,排查了两天才定位到编译产物不一致。这类问题靠读代码是看不出来的,只能靠对比构建产物。

5. 动手把它“看”出来

5.1 打印各类变量的地址,再看进程映射视图

先写一个最简单的程序,把代码、全局变量、堆、栈的地址都打出来。为了能从容观察,打印完让它睡一会儿。

#include <stdio.h> #include <stdlib.h> #include <unistd.h> int g_var = 1; int main(void) { int stack_var = 2; int *heap_var = malloc(sizeof(int)); printf("pid = %d\n", getpid()); printf("main = %p\n", (void *)main); printf("global = %p\n", (void *)&g_var); printf("heap = %p\n", (void *)heap_var); printf("stack = %p\n", (void *)&stack_var); fflush(stdout); sleep(30); return 0; }

编译运行后另开一个终端,读一下这个进程的映射视图文件。类 Unix 系统一般在/proc/<pid>/maps提供这块信息:

cat /proc/<pid>/maps

你会看到几行区间,每行格式是“起始地址-结束地址 权限 偏移 设备 inode 路径”。权限那列是四个字符,前三个是读、写、执行,第四个表示是否私有。代码段通常是只读可执行,数据段可读写不可执行,堆区标记为匿名,栈区也是匿名但权限里带一个向下的增长标记。

把打印出来的地址区间和文件里的行一一对照,你会立刻明白一件事:进程看到的地址空间是一段一段的,不是一整块。段与段之间有大量空洞,这些空洞里的地址一旦被访问,就是非法访问。这也解释了为什么相邻两个malloc拿到的地址可能不连续,中间可能隔着一整页甚至更多。

5.2 申请一块大内存,观察物理占用是否增长

这个实验能直观感受到“虚拟”和“物理”的分离。用匿名映射申请 1 GB,然后把第一页碰一下,再看看实际占用的物理内存。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/mman.h> #include <unistd.h> int main(void) { size_t len = (size_t)1 << 30; /* 1 GB */ char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("mmap"); return 1; } printf("mapped at %p, len = %zu\n", (void *)p, len); printf("pid = %d, 现在去看 RSS\n", getpid()); fflush(stdout); sleep(15); /* 只碰第一页 */ p[0] = 'A'; printf("已写入 p[0],再看一次 RSS\n"); fflush(stdout); sleep(15); /* 每 4 KB 碰一页,共 256 MB */ for (size_t i = 0; i < (size_t)256 << 20; i += 4096) p[i] = 'B'; printf("已触碰 256 MB,再看一次 RSS\n"); fflush(stdout); sleep(15); munmap(p, len); return 0; }

观察点在这三个时刻读一下该进程的状态文件里的内存统计。第一次mmap之后,虚拟大小(VSZ)立刻涨了 1 GB,但常驻物理内存(RSS)几乎没变。写入第一页之后,RSS 增加 4 KB 左右。循环触碰 256 MB 之后,RSS 增加约 256 MB。

这个实验解释了很多新手疑惑的现象:程序启动时看到的虚拟内存很大,不代表真的消耗了那么多物理内存。真正影响机器压力的是 RSS 和共享内存,不是 VSZ。很多监控系统默认告警看的是 VSZ,结果天天误报,我在好几个项目里都改过这个阈值。

注意:触碰的页数是按 4 KB 步长算的,如果你所在的平台默认页大小不同(比如某些架构用 16 KB),步长要相应调整,否则统计出来的增长会对不上。

5.3 fork 之后看写时复制

写时复制的效果可以这样验证:父进程先申请一块内存并填满,然后fork,子进程只改其中一个字节,再对比父子双方的私有脏页统计。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> int main(void) { size_t len = (size_t)64 << 20; /* 64 MB */ char *p = malloc(len); if (!p) return 1; memset(p, 'P', len); printf("父 pid = %d,已填满 64 MB\n", getpid()); fflush(stdout); sleep(10); pid_t pid = fork(); if (pid == 0) { p[0] = 'C'; /* 只改一个字节 */ printf("子 pid = %d,已改 p[0]\n", getpid()); fflush(stdout); sleep(15); _exit(0); } wait(NULL); free(p); return 0; }

在子进程睡着的这段时间,分别看父和子状态文件里的详细映射统计,重点看私有脏页那一项。你会发现子进程的私有脏页只是几页的量级,而不是 64 MB。这说明大部分物理页仍然是共享的,只有被写过的那一页被复制了出来。

这个实验还顺带说明了一个实践中的问题:fork之后如果父子都大量写内存,复制开销会在写的那一刻集中爆发,表现为延迟毛刺。子进程如果只是想执行另一个程序,应该用“先 fork 再立刻 exec”的模式,因为 exec 会丢弃整个地址空间,几乎不需要复制。

5.4 用 mprotect 人为制造一次保护异常

想看权限位真的在起作用,把一块可写内存改成只读,再写一次就行。为了不让程序直接挂掉,装一个信号处理函数,把出错地址打出来。

#include <stdio.h> #include <signal.h> #include <setjmp.h> #include <string.h> #include <sys/mman.h> #include <unistd.h> static sigjmp_buf jb; static void on_segv(int sig, siginfo_t *info, void *ctx) { (void)ctx; printf("收到信号 %d,出错地址 %p\n", sig, info->si_addr); siglongjmp(jb, 1); } int main(void) { long ps = sysconf(_SC_PAGESIZE); char *p = mmap(NULL, ps, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) return 1; struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = on_segv; sa.sa_flags = SA_SIGINFO; sigaction(SIGSEGV, &sa, NULL); p[0] = 'X'; printf("写入成功,页大小 %ld\n", ps); if (mprotect(p, ps, PROT_READ) != 0) { perror("mprotect"); return 1; } printf("已改为只读,尝试再写一次\n"); fflush(stdout); if (sigsetjmp(jb, 1) == 0) { p[0] = 'Y'; /* 这里会触发保护异常 */ printf("居然写成功了,说明平台行为不同\n"); } else { printf("写入被拦截,符合预期\n"); } munmap(p, ps); return 0; }

跑下来你会看到,出错的地址正是p本身。这个实验的价值在于建立一种直觉:保护异常是有精确地址的,不是模糊的“程序崩了”。真实排查时,拿到地址后去映射视图里一查,看它落在哪个区间、那个区间的权限是什么,基本就能判断出问题类型。

反过来说,如果地址落在某个可写区间内,硬件不会拦你,程序会静默地把错误写下去,然后在很久之后以完全无关的形式表现出来。这种问题才是最难查的,也是为什么条件允许时,尽量用内存检查工具跑一遍。

6. 常见问题与排查实录

6.1 VSZ、RSS 和共享内存到底看哪个

这块指标最容易把人带偏。虚拟大小包含所有已映射区域,包括巨大的匿名映射、文件映射、预留区域,数字可以非常夸张但机器毫无压力。常驻集只算当前真正占了物理页的部分,是判断内存压力的主要依据。共享部分则涉及多个进程共同映射的物理页,统计时需要留意不要重复计算。

指标含义什么时候看常见误区
虚拟大小已映射的地址空间总量排查地址空间耗尽、映射泄漏数值大就以为内存不够
常驻集当前占用的物理页总量判断真实内存压力忽略其中含共享页
私有脏页该进程独占且被写过的页分析写时复制、进程拆分与常驻集混为一谈
页缓存文件内容缓存占用的内存分析 IO 性能与回收误认为是应用内存泄漏

一个实际案例:某服务上线后,监控显示虚拟内存持续上涨,告警天天响。实际去查,是代码里反复用匿名映射申请小块内存,用完没有释放对应映射,虚拟地址空间一直在泄。但常驻集很稳定,因为那些映射从没被写过。修掉泄漏点之后告警消失,机器的实际内存压力从头到尾就没变过。

6.2 内存不释放的几种典型假象

第一种是“以为泄漏了其实没有”。很多分配器内部有缓存机制,free之后内存没有立刻还给系统,而是留在自己的空闲链表里复用。这时候常驻集不降是正常现象,只要进程的长期趋势平稳,就不用紧张。

第二种是“真的泄漏但增长很慢”。有些对象泄漏得很隐蔽,比如在错误处理分支里漏掉释放。这种只能靠周期性采样对比,看同一类分配的调用栈是不是在单调增长。

第三种是“页缓存背锅”。读写大量文件会让页缓存涨起来,但页缓存是可回收的,系统有压力时会自动释放。把页缓存算进应用内存,然后去优化代码,纯属做无用功。

第四种是“透明大页导致的数字跳动”。开启透明大页之后,内存以 2 MB 为单位管理,统计数字会出现比较明显的阶梯式变化。曾经有个同事盯着常驻集突然跳了 2 MB 研究半天,最后发现只是大页合并触发的正常行为。

提示:分析内存问题,先确定自己是关心地址空间、物理占用,还是文件缓存。这三件事的排查路径完全不同,混在一起看只会浪费时间。

6.3 一份可以贴在显示器边上的速查表

我把常见现象和判断方向整理成了一张表,实际排查时按这个顺序过一遍,大部分问题能快速收敛。

现象优先怀疑下一步动作
地址接近 0 的段错误空指针、未初始化指针检查结构体指针成员是否赋值
地址规律性偏移的段错误字段偏移不一致、类型不匹配核对头文件版本与构建产物
偶发段错误、栈附近地址栈溢出、递归过深、大局部变量缩小栈上大对象,改到堆上
只读区间报写错误改字符串常量、写代码段检查常量声明与指针类型
常驻集持续单调上涨真实泄漏周期性采样,对比分配调用栈
虚拟大小涨、常驻集平稳映射泄漏或预留空间检查映射创建与释放是否配对
申请成功但访问时才崩超额分配、空间不足调整分配策略,处理失败路径

6.4 几个我自己踩过的坑

第一个坑是申请成功不代表能用。开启了超额分配机制之后,申请一块很大的内存可能立刻返回成功,真正分配物理页是在第一次写入的时候。如果你的进程在这种情况下被系统选中终止,表现就是“莫名其妙被杀”。对策是给关键进程设置合适的内存策略,或者启动后主动预热一遍关键缓冲区。

第二个坑是大页带来的启动抖动。开启透明大页之后,某些程序在内存整理时会出现明显的延迟尖峰。我在一个延迟敏感的采集程序上遇到过,表现为每隔一段时间有一次几十毫秒的卡顿。关掉透明大页之后现象消失,后来改成只对特定区域显式使用大页,两边都兼顾了。

第三个坑是信号处理里做不安全的事。上面的实验里我用跳转从信号处理函数返回,这是为了演示。真实代码里在信号处理函数中调用非异步信号安全的函数(比如打印、分配内存),很容易造成死锁或二次崩溃。正确做法是只做标记,把处理逻辑挪回主流程。

第四个坑是以为地址重复就是 bug。回到开头那个现象,两个进程打印同一个地址,那不是 bug,是虚拟内存的正常表现。判断两个进程是不是共享了物理内存,不能看虚拟地址,要看映射的底层对象和共享标记。

6.5 最后分享一个我常用的定位套路

拿到一次内存相关的崩溃,我一般按四步走。第一步,从日志或调试器里取出出错地址和访问类型,是读还是写还是取指。第二步,去进程的映射视图里查这个地址落在哪个区间,那个区间的权限是什么。第三步,对照权限和访问类型,立刻能判断是“地址非法”还是“权限不符”,这两类的排查方向完全不同。第四步,如果是权限不符,看这块区域是谁映射的、为什么映射成这个权限,问题基本就锁定了。

这套流程听起来朴素,但比漫无目的地读代码高效得多。我在几个团队里推过这个顺序,新人上手排查内存问题的平均时间能缩短一半以上。虚拟内存和内存保护这两个概念,说到底不是用来背的,是用来当尺子量问题的。你脑子里有那张“地址空间分段 + 每段权限”的图,看崩溃日志就像看地图找路,而不是在一堆十六进制数字里瞎猜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进