ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

一文搞定Linux页表映射:从原理到性能优化实战

一文搞定Linux页表映射:从原理到性能优化实战 1. 为什么搞懂页表映射能救命我最早被页表这个事儿狠狠教育了一回是在帮一个朋友排查线上服务的时候。当时那台机器内存明明还有十几个G空闲但进程就是申请不到内存数据库的查询延迟还忽高忽低。free看了一遍内存充足没到限制top看了一遍CPU也没跑满。折腾了一下午最后发现元凶是页表——进程数量太多每个进程都有一整套自己的地址映射关系这些页表把内存悄悄吃干抹净了。从那时候起我就意识到页表不是一个“知道概念就行”的面试题而是Linux下做性能优化、故障排查必须拿下的硬骨头。页表映射简单说就是把进程眼里看到的虚拟地址翻译成物理内存上真实地址的一套机制。操作系统给每个进程画了一张“虚拟地图”进程以为自己拥有从0到巨大地址空间的完整领地实际上这些领地都是操作系统做出来的幻象真正干活的是物理内存、磁盘交换分区和MMU硬件。CPU访问内存的时候必须先经过页表这道翻译关卡。这篇文章适合几类人看一是后端开发和性能优化工程师排查内存问题、优化大内存服务时会用到二是运维同学理解free、top、/proc/meminfo里那些内存相关参数时页表是绕不开的背景知识三是嵌入式Linux开发者和内核初学者进程地址空间、内存管理、缺页异常这些概念全部建立在页表之上。我会从原理层面开始讲但不会停留在“页表是多级结构”这种一句话结论而是带你把每一步地址转换拆开看再结合实际操作命令和踩坑案例让你看完能直接用在工作里。2. 页表映射的核心机制2.1 虚拟地址到物理地址的路由很多人第一次接触页表的时候最困惑的问题是“虚拟地址和物理地址就差了一个映射关系为什么非要搞这么复杂”这就要从CPU和操作系统协同工作的方式说起了。现代CPU里有一个叫MMUMemory Management Unit内存管理单元的硬件模块它专门负责地址翻译。进程执行指令、读写数据时CPU拿到的地址都是虚拟地址MMU看到这个地址后不会直接去内存里找而是先查一个叫“页表”的数据结构拿到真正的物理地址然后才发起对物理内存的访问。页表由操作系统维护存在内存里但MMU来负责查询。x86-64架构下虚拟地址默认是48位有效位页大小通常为4KB。这块地址空间被拆成了五段每级索引占9位一共四级索引最后12位是页内偏移。9加9加9加9加12正好等于48。每一级索引对应一张页表页表里装的是512个表项因为每个表项是8字节4KB一页正好放下512个8字节的项也就是2的9次方。所以你看9位这个数字不是随便选的它是由页大小和表项大小硬算出来的。为了让你更直观地理解我拿一个虚拟地址来演示。假设进程访问的虚拟地址是0x7f1234567890这个地址是48位……我先把它拆成二进制然后从高位往下按9位一组划分。经过计算之后我们会得到四个索引值分别对应PGD、PUD、PMD、PTE四级的表项下标最后的0x890则是页内偏移。MMU的操作流程是先从CR3寄存器拿到顶级页表的物理地址进程切换时CR3会跟着切换然后用第一级索引在PGD这张表里找到下一级页表的地址再用第二级索引继续往下找直到PTE这一级拿到最终的物理页帧号PFN最后把PFN加上页内偏移得出物理地址。这个过程看起来绕但设计得很巧妙。MMU在这一系列查找过程中其实有缓存机制——TLB翻译后备缓冲器。TLB就是页表项的缓存如果之前查过某个虚拟地址对应的物理地址下次直接命中TLB就不需要真的去内存里一级一级翻页表了。这也是为什么大页HugePage对性能影响那么大后面我会专门讲。2.2 多级页表到底解决了什么问题一个最简单直接的问题是为什么不用一张“大而全”的一级页表把整个虚拟地址空间一次性映射完我们来算一笔账。64位地址空间假设用4KB页如果做一张线性页表就需要把虚拟地址空间分成2的52次方个页每个页对应一个8字节的页表项一整张表的大小是2的55次方字节也就是32PB。一台机器物理内存才多大几百GB撑死了这显然是不可能的。就算把地址空间限制在48位一张线性页表也要占用2的39次方个表项也就是512GB依然不现实。所以多级页表不是“想不想”的问题而是“不得不”。多级页表的本质是按需分配。进程创建的时候内核只给它分配顶级页表PGD下面的PUD、PMD、PTE都是需要实际映射时才会分配。进程的空闲地址空间越大中间的空页表越多但它们并不占内存只是顶级页表里一个空指针而已。举个例子一个比较“瘦”的进程可能只用到了低地址的代码段、数据段高地址的栈还有一个堆区域。这些区域之间隔着海量的未使用地址空间。在多级页表下这些空洞完全不消耗内存如果是线性页表这些空洞也得一个个填上表项光空转的内存就够系统崩溃好几次了。这也解释了页表一个很重要的工作特点内存分配是惰性的。进程调用malloc时内核一般不会立刻给它分配物理页只是在页表里做个“未映射”的标记。真正发生读写的时候CPU触发缺页异常内核才在异常处理里分配物理页、建立PTE映射。这种机制叫“按需调页”它让系统能超额承诺内存也直接决定了你看到的内存占用和实际物理消耗并不总是一回事。2.3 页表项PTE里藏着哪些信息页表项PTE不只是把虚拟地址指向物理地址它还携带了内核管理内存所需的全部属性位。这一块是做内核开发和系统调优的必备知识也是面试题里容易翻车的地方。x86-64的PTE是64位低位是各种标志位高52位是物理页帧号PFN。我列几个关键标志位标志位含义实际作用bit 0Present页面是否驻留在物理内存为0时访问该页会触发缺页异常bit 1RW是否可写为0时页面只读写操作触发保护异常bit 2US用户态是否可访问为0时只有内核态能访问bit 3PWT写透缓存策略控制Cache行为bit 4PCD缓存禁用用于设备内存等特殊区域bit 5Accessed页面是否被访问过内核用它做页面回收的冷热判断bit 6Dirty页面是否被写过回写磁盘时靠它判断是否需要写bit 63NX禁止执行为1时该页不能放代码防溢出攻击这些标志位和硬件紧密配合。CPU访问一个虚拟地址时MMU会读PTE并检查标志位如果Present位是0CPU直接触发缺页异常把控制权交给内核由内核决定是分配新页还是从swap换入如果RW位是0但当前是写操作CPU触发保护异常。这些异常处理路径就是操作系统内存管理模块的“心脏”。操作系统的页面回收、脏页回写、COW写时复制等机制都依赖这些标志位。fork之后父子进程共享同一份物理页就是通过把PTE设置成只读实现的任何一方尝试写入会触发保护异常内核在异常处理里拷贝页面然后更新PTE。如果没有这些标志位现代操作系统的内存优化手段基本都得推倒重来。3. 实操在Linux上观察页表行为3.1 通过/proc/meminfo看页表开销“页表也占内存”这句话听起来抽象但在Linux上你可以直接看到数字。打开/proc/meminfo里面有一个PageTables字段就是当前系统所有进程的页表占用的内存总量单位是kB。grep PageTables /proc/meminfo我见过不少真实场景内存总量256GB的服务器PageTables能占到几个GB。这种情况通常发生在两种环境下一种是服务器上跑了大量独立进程每创建一个进程都要分配一套新的页表另一种是某些程序频繁调用mmap创建大量虚拟内存区域导致页表项数量暴增。认识这个字段的价值在于排查内存不足时不能只看used和free还得看PageTables。如果PageTables稳步上涨说明页表在持续膨胀这时候就算你关掉一些大进程内存都不一定能立刻腾出来。另外还要理解页表内存本身不能swap出去它们是内核在物理内存里直接分配的。所以页表占用的内存是系统的硬开销进程退出前不会主动释放。这也意味着如果你的业务模型是“大量进程短生命周期”那么CPU开销和内存开销都会比想象中高。3.2 用pmap观察进程地址空间如果想看单个进程的虚拟地址空间长什么样pmap是最直接的命令。pmap -x PID pmap -d PIDpmap -x输出里每一行代表一段地址空间映射区域。常见的区域包括程序的可执行文件、共享库、堆、栈、以及匿名映射。每次malloc大块内存、每次mmap文件只要触发了系统调用就会在pmap输出里生成一个新的区域。实际排查中我会特别关注Mapping为[anon]的区域因为匿名映射通常直接对应堆分配或者mmap分配是内存占用的大头。另外还要注意每段的RSSRSS表示这段映射里真正驻留在物理内存的页面数量。pmap能看到的东西本质上就是页表内容的一种“人话翻译”。Linux内核提供了/proc/PID/maps和/proc/PID/pagemap两个接口maps是文本格式的映射区域列表pagemap则记录了每个虚拟页对应的物理页帧号和标志位。pmap之所以能展示每一段的起止地址和权限是因为内核为这个进程维护了一个VMA虚拟内存区域链表每个VMA对应一段连续的、具有相同权限属性的虚拟地址区段。需要区分的是VMA是内核维护的高层管理结构页表是MMU真正查询的底层数据结构。VMA告诉内核“这个进程有哪些地址范围分别属于什么类型”页表告诉MMU“某个虚拟页实际落到哪个物理页”。两者通过缺页异常串起来访问一个属于VMA范围内、但没有页表映射的地址时内核根据VMA的类型决定怎么建立映射。3.3 pagemap与页表逆向如果你想看得更底层直接读/proc/PID/pagemap。这个文件里每个虚拟页对应一个64位的记录位信息包括present位、文件映射或匿名映射标志、物理页帧号等。读取时需要root权限。我给你写一段最简单的C代码读取指定进程、指定虚拟地址的页表信息#include stdio.h #include stdint.h #include fcntl.h #include unistd.h #include stdlib.h int main(int argc, char *argv[]) { if (argc ! 3) { fprintf(stderr, usage: %s pid vaddr\n, argv[0]); return 1; } int pid atoi(argv[1]); uint64_t vaddr strtoull(argv[2], NULL, 0); char path[64]; snprintf(path, sizeof(path), /proc/%d/pagemap, pid); int fd open(path, O_RDONLY); if (fd 0) { perror(open pagemap); return 1; } uint64_t entry; off_t offset (vaddr / 4096) * 8; if (pread(fd, entry, sizeof(entry), offset) ! sizeof(entry)) { perror(pread); close(fd); return 1; } close(fd); printf(pagemap entry: 0x%016llx\n, (unsigned long long)entry); printf(present: %d\n, (int)(entry (1ULL 63)) ? 1 : 0); printf(swapped: %d\n, (int)(entry (1ULL 62)) ? 1 : 0); if (entry (1ULL 63)) { uint64_t pfn entry ((1ULL 55) - 1); printf(pfn: 0x%llx\n, (unsigned long long)pfn); } return 0; }这段代码能直接告诉你某个虚拟地址是否驻留在物理内存以及对应的物理页帧号。在定位“某些页面为什么没有被回收”这类问题时pagemap能提供第一手证据。不过要注意读取pagemap时如果虚拟地址没有对应的VMA读出来的entry可能全零需要先通过maps确认地址范围合法。4. 内核页表与大页映射的特殊玩法4.1 内核态页表与用户态页表的区别前面讲的主要是用户态进程的页表但内核自己也有一套独立的页表体系。Linux的内核态虚拟地址空间和用户态是分开的在x86-64的经典布局下用户态占用低地址空间内核态占用高地址空间。你写的每个进程都有属于用户态的那部分页表同时共享同一份内核态的页表映射。内核态地址空间里有一个非常关键的“直接映射区”或者说线性映射区。物理内存从0开始到实际容量大小的范围会被直接映射到内核虚拟地址空间的一个连续区域映射关系基本就是“物理地址加上一个偏移常量”这么简单。这个区域的页表在系统启动时初始化以后基本不变极端高效。但内核不是所有内存都通过直接映射区管理。vmalloc区域用于需要“连续虚拟地址但不要求连续物理地址”的场景比如模块加载、一些驱动分配。这个区域通过独立的页表映射建立的代价相对高所以不会用于高频、大块的内核内存分配。还有一个值得注意的点进程切换的核心动作之一就是切换CR3寄存器让它指向新进程的顶级页表。CR3切换后TLB里的缓存全部失效新进程第一次访问每个页面都要重新走一遍页表查询。所以频繁的进程切换是有性能代价的——TLB总是被清空。这也是为什么有些高性能服务会尽量让CPU亲和性绑定减少进程在核间漂移间接降低TLB的失效频率。4.2 HugePage为什么对性能影响这么大默认4KB的页面大小对绝大多数场景够用但对于使用很大内存的应用程序比如数据库、搜索引擎、Redis4KB页面会带来两个问题一是页表项数量庞大管理开销高二是TLB覆盖范围有限命中率低。拿1GB内存来说用4KB页需要262144个页表项如果用2MB的大页只需要512个页表项。页表项少了TLB能覆盖的内存范围就大了这在随机访问大内存的场景下效果立竿见影。数据库类的应用普遍收益明显所以不少数据库运维都会配置大页。在Linux上开启大页的操作路径如下。先用系统参数查看当前状态grep Huge /proc/meminfo sysctl vm.nr_hugepages预留一定数量的大页可以通过修改内核参数或sysctl命令实现echo 2048 /proc/sys/vm/nr_hugepages预留之后进程可以用MAP_HUGETLB标志来映射大页。下面是一个用C语言映射2MB大页的简单示例#include stdio.h #include sys/mman.h #include stdlib.h int main(void) { size_t len 2 * 1024 * 1024; /* 2MB */ void *addr mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0); if (addr MAP_FAILED) { perror(mmap); return 1; } printf(hugepage mapped at %p\n, addr); /* 触碰内存触发实际建页 */ for (size_t i 0; i len; i 4096) { ((char *)addr)[i] 1; } munmap(addr, len); return 0; }需要注意使用大页时要提前预留否则mmap会失败。另外大页内存和普通内存是分开的池子不能混用。Cgroup里也需要相关配置否则容器里面使用不了宿主机预留的大页。4.3 地址空间随机化与页表安全页表不仅是内存映射的载体也是系统安全的重要阵地。操作系统在进程地址空间布局上做了随机化ASLR把mmap的基地址、栈的起始地址随机化让攻击者难以预测目标地址。同时内核的基地址也会被随机化KASLR这直接影响内核页表的初始化。从页表的角度看ASLR的调整范围属于“虚拟地址偏移”进程页表项里的PFN不变但虚拟地址变了整个页表结构跟着重建。如果你比较关注系统安全加固可以查看cat /proc/sys/kernel/randomize_va_space该值默认为2开启ASLR并且启用brk随机化。在生产环境关闭ASLR的时机很少除非是在调试某些依赖固定地址的工具。开启ASLR后pmap看到的地址每一次运行都可能不一样这也是正常现象。5. 页表相关的坑与排查实录5.1 页表碎片导致的内存浪费我调过一台服务器内存256GB业务方反馈“内存不够用”但free一看used才100多GBavailable有80多GB按理说完全够用。可当业务方启动新任务时进程直接OOM。后来我查了PageTables发现已经用掉了接近8GB而且还在缓慢上涨。这台机器上跑了几百个不同业务方的小任务每个任务都以独立进程方式启动每个进程都会创建自己的页表。有些任务虽然占用内存不多但进程数量极多页表开销积累下来非常吓人。解决办法有两个方向。一是减少进程数量用线程代替部分进程或者复用一个服务进程来批量处理任务二是排查哪些任务存在“大量mmap但只用一小部分”的情况因为页表是按页建立的如果mmap后又随机访问很多页页表项就会增长。经过调整PageTables降到了2GB以下内存压力明显缓解。5.2 频繁mmap/munmap的隐患另一个容易被忽略的问题是频繁的mmap和munmap调用。有些程序里大块内存通过mmap每次独立分配释放又调用munmap。这样的代码每执行一次内核就要为该地址区间建立或销毁一套页表CPU开销不少。你可以在进程运行时用strace观察系统的调用频率strace -f -e tracemmap,mprotect,munmap -p PID如果发现mmap调用密集通常说明内存分配模式不够合理。my_malloc等内存分配器默认对大块内存直接用mmap把阈值调高或改换内存池是常见优化手段。glibc的M_MMAP_THRESHOLD可以调合理设置后本来频繁调用mmap的小场景可以走到堆里分配减少页表抖动。5.3 与页表相关的性能排查命令速查命令/文件作用排查场景/proc/meminfoPageTables字段看页表内存消耗内存被页表吃光或异常增长pmap -x PID查看进程虚拟地址空间分布定位哪些映射区域占用异常/proc/PID/smaps查看每个映射段细粒度内存数据分析匿名内存、共享内存的构成/proc/vmstatpgfault、pgmajfault字段判断缺页异常频率区分主缺页/次缺页strace跟踪mmap/munmap系统调用定位页表频繁建立/销毁的路径perf采样TLB相关PMU事件分析TLB miss对性能的影响缺页异常分两种次缺页minor fault指页面已在内存中只需要建立映射主缺页major fault指页面不在内存中需要从磁盘读入。如果pgmajfault涨得快意味着磁盘IO是瓶颈。这个数据配合页表使用能帮你判断内存和IO之间到底谁在拖后腿。5.4 一次完整的“内存很多但申请失败”排查复盘最后我们复盘一个印象深刻的案例。现象服务器跑着一个数据处理程序16GB物理内存程序启动后运行一段时间报内存申请失败。但free显示MemAvailable还有4GB左右。排查步骤第一步看dmesg有没有OOM记录。结果没有排除物理内存耗尽。第二步看进程数量和PageTables。发现系统里同时跑了20个同类型进程每个进程都加载了近10GB的映射文件。由于是拷贝模式这些页表的虚拟内存区域巨大但RSS不高。第三步看limit。ulimit -v查虚拟内存限制发现果然设置了8GB的虚拟内存上限。20个进程每个8GB上限加起来虽然不是直接限制但相互之间在物理内存和页表开销的压力下最终有些进程触顶。第四步定位到根因后把虚拟内存上限调整到合理值同时优化进程数量程序恢复稳定。这个案例告诉我排查内存问题第一反应不能只有free和top还要有页表意识。虚拟内存限制、进程数量、页表开销、mmap行为这些因素交织在一起任何一个都能成为压垮内存的稻草。6. 结语页表这张“地图”值得你反复看几遍页表映射初看是一个操作系统理论概念但实际工作中它的影响无处不在。你在free里看到的used只是表面真正的物理内存还有一大块被内核拿去做页表、做缓存、做各种元数据你在top里看到的进程RSS是“实际物理驻留”但虚拟内存的消耗和页表的建立规则会决定这些数字是否可信。我个人在实际项目里的习惯是把/proc/meminfo的PageTables、/proc/vmstat的pgfault、pmap的映射段信息这三样东西当成一组三口之家谁有问题都能一起排查。页表管理看起来很深奥但只要你亲手用pagemap去看过一个地址的物理页帧号再亲手调过一次大页参数整个模型就能牢牢长在脑子里。最后再分享一个小技巧如果你负责的应用长期内存居高不下别急着加内存条先看一眼PageTables。如果它占系统内存比例超过5%通常值得调优了——要么减少进程数要么调整大页策略要么让内存分配器更合理。这一点点优化空间往往比盲目的容量规划更有效。
RELATED READING

延伸阅读

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