TLB与内存保护:页表翻译的性能与边界
发布时间:2026/10/28 20:21:37 锦皓数字建站

翻看内核里那几段与地址翻译相关的代码很多人第一次都会冒出同一个疑问页表已经把虚拟地址到物理地址的映射关系写得明明白白为什么处理器还要在中间再加一层TLB更让人困惑的是同一个虚拟地址有时候读没事、写就崩有时候用户态访问没事、换个特权级就崩——这背后的东西就是内存保护。TLB 决定了地址翻译跑得快还是慢内存保护决定了这次访问到底允不允许发生两者其实是同一套翻译流程的两面一面拼性能一面守边界。这篇内容会把 TLB 的表项结构、命中路径、容量边界、替换策略逐层拆开再把内存保护从页表项标志位一路讲到异常触发中间配上可以直接跑起来的观测代码和踩坑记录。不管你是刚学完页式存储、还在啃教材的学生还是天天对着性能曲线找瓶颈的开发者或者只是想搞明白为什么加个读写权限程序就崩了的工程人员下面这些内容都能直接拿去用。1. 从页表到 TLB为什么一次访存会变成五次1.1 页表走查的真实成本比教材上写的更肉疼先把这个账算清楚。以常见的 4KB 页、48 位虚拟地址为例页内偏移占低 12 位剩下 36 位用来做页表索引按每级 9 位切分正好切成 4 级。每一级页表本身占一页即 4KB能放 512 个表项每个表项 8 字节512 × 8 4096刚好一页这个自洽的尺寸设计不是巧合是为了让页表项数等于一页能装下的条目数索引位宽和页大小严格对齐。一次地址翻译处理器要从最高一级页表基址开始逐级取出下一级页表的物理地址一共走 4 次访存最后拿到目标页框号拼上偏移才是真正的物理地址。翻译完还不算数真正的读或写还要再访存一次。也就是说一条最普通的mov指令读一个变量理论最坏情况下会触发 5 次内存访问4 次页表走查 1 次数据读取。这个开销摊在任何一个循环里都是灾难。如果每执行一条访存指令都要走 4 次页表程序的执行时间会被放大到原来的数倍处理器的大部分周期都花在找地址而不是干活上。现代处理器当然不会这么傻页表走查的结果会被页表走查缓存和各级数据缓存拦住一部分但缓存本身也有容量和冲突问题一旦工作集超过缓存容量走查开销就会实打实地暴露出来。注意事项很多人算这个账时会忽略页面大小。4KB 页配合 4 级页表是 x86-64 的典型配置换成 16KB 页部分 ARMv8 配置偏移就是 14 位剩下 34 位切 4 级就不是整 9 位了实际常切成 4 级不等宽或改用 3 级。看到别人文章里的参数对不上先确认页大小和页表级数别急着怀疑对方写错。1.2 TLB 的本质一个装不下太多东西的翻译缓存TLBTranslation Lookaside Buffer做的事情非常朴素把最近翻译过的虚拟页号 → 物理页框号这条映射连同权限位一起存在处理器内部一小块高速存储里。下次再访问同一个页直接查这块存储命中就不用走页表省掉那 4 次访存。它和普通数据缓存的设计逻辑几乎一模一样只有一个关键差别数据缓存缓存的是数据TLB 缓存的是翻译结果。这个差别导致 TLB 的表项数量远少于数据缓存——翻译结果的价值在于覆盖的地址范围而不在于字节数。这里有个非常关键的概念叫TLB 覆盖范围reach。假设一级数据 TLB 有 64 个表项每项覆盖一个 4KB 页那它能覆盖的地址范围就是 64 × 4KB 256KB。二级 TLB 若有 1536 项覆盖范围就是 1536 × 4KB 6MB。这两个数字意味着什么意味着当一个程序的工作集活跃访问的地址集合超过 6MB 并且访问是随机分散的时候TLB 就会出现持续的缺失翻译开销立刻变成主要瓶颈。反过来算就更有意思了如果把页大小从 4KB 提升到 2MB同样的 1536 个二级 TLB 表项覆盖范围直接变成 1536 × 2MB 3GB。这就是大页huge page存在的全部理由——用更少的表项覆盖更大的地址范围从根本上提升 TLB 覆盖范围。1GB 页的情况下覆盖范围甚至能到 1.5TB 量级基本等于整个进程的地址空间一次性全塞进 TLB。下面这张表是我自己整理的习惯用法把常见层级的典型配置和覆盖范围放在一起对照实际型号会有出入但量级关系是稳定的层级典型表项数关联度4KB 页覆盖范围2MB 页覆盖范围一级数据 TLB644 路组相联256KB128MB一级指令 TLB644 路组相联256KB128MB二级统一 TLB153612 路组相联6MB3GB看这张表能得出一个很实用的结论程序的工作集只要超过 256KB一级 TLB 就已经不够用了开始依赖二级 TLB超过 6MB二级 TLB 也开始吃力。这就是为什么很多数据处理程序在数据量跨过某个门槛后性能突然掉一截——不是算法变了是 TLB 覆盖不住了。1.3 为什么不做成完全相联有人会问既然 TLB 这么小为什么不干脆做成完全相联fully associative任何虚拟页号都能映射到任何一个表项彻底避免冲突缺失答案在于硬件成本。完全相联意味着每次查找都要把所有表项的比较器同时打开64 项就是 64 个并行比较器1536 项就是 1536 个。这在延迟敏感的路径上是灾难而且面积和功耗都不可接受。组相联是在查找速度和冲突概率之间找的折中先用虚拟页号的低位算出一个组索引只在这个组内的几路里并行比较比较器数量只和关联度有关与总表项数无关。代价就是冲突缺失。如果程序同时活跃的页恰好映射到同一个组超过关联度的部分就会互相踢出即使 TLB 里空着一大堆别的组。这个现象在实际调优中非常常见后面第 5 节会用具体实验来验证它。2. TLB 表项拆解一行里到底存了什么2.1 从虚拟页号到物理页框号的完整字段打开一份处理器手册里 TLB 表项的字段说明会看到大致这么几类信息虚拟页号或其中的一部分、物理页框号、地址空间标识、权限位、有效位、全局位、页大小位。虚拟页号不一定要存完整的。组相联的 TLB 用虚拟页号的低位做组索引这些位就不必再存只存剩下的标签位用于比较。这个设计直接导致了一个经典问题如果页大小可变不同大小页的索引位宽不一样混在一起查会很麻烦。实际硬件要么把大小页分区存放要么用额外的标志位区分。地址空间标识x86 上叫 PCIDARM 上叫 ASID是解决进程切换开销的关键。没有它每次切换进程都要把整个 TLB 刷一遍因为新进程的虚拟地址和旧进程的虚拟地址可能指向完全不同的物理页留着旧表项会出错。有了标识位TLB 表项上打个属于哪个地址空间的标签切换时只改一个寄存器旧表项自然失效不需要物理刷新。实操心得PCID 这类特性的收益在进程切换频繁的场景下非常明显。我以前测过一个场景短连接服务每处理一个请求就切换一次上下文关掉 PCID 之后内核态时间涨了将近一成原因就是每次切换都要刷 TLB回来之后所有翻译全部重来。这类收益在压测报告里通常看不出来只有对比开关才会发现。权限位是内存保护落地的地方通常包含可读、可写、可执行、用户/内核可访问这几个维度。这些位在翻译的同时被检查命中 TLB 时直接用表项里缓存的权限不必再回去读页表项。这也是一个容易忽略的点修改页表项的权限位之后必须让对应的 TLB 表项失效否则处理器还在用旧的权限做判断。有效位和全局位分别表示这个表项是否可用、是否对所有地址空间都有效。内核代码的映射通常标为全局这样进程切换时不会被刷掉省下一次重新翻译。2.2 命中路径一次访问的完整旅程把一次正常的访存拆开路径大致是这样的处理器拿到虚拟地址用低位算出 TLB 组索引同时取出标签位在组内并行比较所有路的标签和地址空间标识若有匹配且有效位为 1则命中取出物理页框号与页内偏移拼接得到物理地址同时检查权限位权限通过就继续访问数据缓存权限不通过就抛异常。整条路径的关键在于比较和拼接是并行发生的。因为页内偏移在翻译前后不变处理器可以不等翻译完成就开始用偏移去索引数据缓存这叫虚拟索引物理标签VIPT翻译结果晚一点到也没关系只要在标签比较之前准备好就行。这个优化让 TLB 命中时的延迟几乎被完全隐藏。未命中的路径就长得多处理器要暂停这次访存启动硬件页表走查或者触发异常交给软件走查逐级读页表检查每一级的有效位和权限位把结果填回 TLB然后重新执行被中断的那次访存。整个过程短则十几个周期长则上百个周期取决于页表项在不在缓存里。2.3 页表走查也不是每次都从头来硬件页表走查本身也有缓存。中间级的页表项尤其是最高几级会被单独缓存起来因为同一进程的整个地址空间共用同一套高层页表命中率极高。这意味着真实的走查成本往往不是 4 次访存而是 1 到 2 次——只有最后一级页表项可能缺失。这也解释了一个常见现象小内存程序跑得飞快因为高层页表项和最后一级页表项都在缓存里大内存随机访问的程序慢因为最后一级页表项本身就占了大量内存缓存不住每次 TLB 缺失都要多一次访存去读页表项雪上加霜。3. 内存保护权限位是怎么拦住你的3.1 页表项里的保护位逐个数内存保护的执行点就在页表项上。以常见的配置为例一个页表项里的保护相关位大致包括存在位P、读写位R/W、用户/超级用户位U/S、执行禁用位NX、访问位A、脏位D、缓存控制位PWT/PCD。标志位含义典型用途P页是否在物理内存中为 0 触发缺页异常R/W0 只读1 可读写代码段、只读数据段设 0U/S0 仅内核可访问1 用户可访问内核空间映射设 0NX1 表示禁止取指栈、堆、数据段设 1A是否被访问过内存回收的活跃度参考D是否被写过换出时判断是否需要写回这里最容易踩的坑是 U/S 位不是只看最后一级。规则是如果最后一级页表项的 U/S 为 1用户可访问那么从最高级到最低级的每一级页表项的 U/S 都必须为 1。只要中间任何一级设成了 0整条路径对用户态就不可达。这条规则的初衷是让内核可以通过修改高层页表项一次性把一整片地址空间从用户态视野里屏蔽掉而不必去改每一个叶子页表项。我第一次遇到这个问题时明明叶子页表项权限写得没错用户态访问还是崩查了半天才发现中间某一级的标志位漏设了。这种错误最难查因为调试器看到的是访问合法地址却报错直觉上完全说不通。注意事项修改页表项之后一定要刷新 TLB。硬件不会自动感知你偷偷改了一个内存里的页表项旧表项还在 TLB 里活得好好的。规范的做法是修改后执行一次针对该地址的失效指令或者干脆整表刷新。忘了这一步改了权限但程序行为没变或者改了映射但读到旧数据就会反复出现。3.2 缺页异常和保护异常不是一回事很多人把这两个混为一谈实际上它们的语义完全不同。缺页异常是这个地址的翻译还没准备好。存在位为 0表示这一页当前不在物理内存里或者压根还没建立映射。处理方式通常是分配一个物理页、建立映射、填好页表项、刷新 TLB、重新执行那条指令。对程序来说这是完全透明的除了第一次访问会慢一点。保护异常是翻译准备好了但你不许这么干。页在内存里映射也在但权限不匹配往只读页写、在禁止执行的页上取指、用户态访问内核页、内核态写用户页但没开允许标志。处理方式通常是终止进程用户态或者触发内核错误内核态。这是不可恢复的程序拿到的就是一次崩溃。区分两者的实操意义在于排查方向完全不同。缺页说明内存布局或映射没建立好去看分配流程保护异常说明权限配置或访问方式有问题去看页表标志位和访问代码。把保护异常当缺页去查往往越查越迷。3.3 W^X 原则为什么你的栈上代码跑不起来现代系统普遍遵循 W^X 原则同一块内存不能同时可写和可执行。这条规则从安全角度很好理解——如果一块内存既能写又能执行攻击者只要往里写一段数据再跳过去就能执行任意代码。落到页表上就是代码段的页设为可执行但只读数据段、栈、堆的页设为可写但不可执行。于是就会出现这样的现象你在栈上放一段机器码然后把函数指针指过去程序直接崩在取指阶段。老教材里栈上执行代码的示例在新系统上基本全军覆没原因就在这里。这条规则在调试时经常造成困惑因为崩溃发生在取指那一刻报错信息看起来和代码逻辑毫无关系。判断方法很简单看崩溃地址落在哪个区域如果是栈或者堆的范围基本就是 NX 拦下来的。4. 实操亲手观测 TLB 和保护机制4.1 先确认基础参数动手之前先把环境参数确认清楚不然后面的所有推算都是空中楼阁。# 查看页大小 getconf PAGE_SIZE # 查看物理内存和 CPU 缓存层级信息 getconf -a | grep -i page # 查看处理器型号与缓存信息不同内核路径可能不同 cat /proc/cpuinfo | head -30页大小基本决定了一切。4KB 页是绝大多数 x86-64 环境的默认值如果输出是 4096那前面所有的推算可以直接套用如果输出 16384说明是 16KB 页覆盖范围要相应乘 4。接下来看 TLB 事件的性能计数器。主流处理器都提供了dTLB-load-misses、dTLB-store-misses、iTLB-load-misses这类事件能直接读出 TLB 缺失次数。# 统计某程序运行期间的 TLB 缺失 perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses ./your_program输出里会给出缺失次数和占比。一级 TLB 的缺失率在 0.1% 以下算正常超过 1% 就值得关注超过 5% 基本可以确定工作集超出了 TLB 覆盖范围。这个数字不是标准是我在多个项目里总结的经验区间不同类型程序差异很大做数值计算的和做字符串处理的完全不是一个量级。4.2 用步长扫描把 TLB 的容量边界测出来理论讲了半天不如直接测。下面这段代码的核心思路是分配一块足够大的数组按固定步长遍历每个步长对应一种同时活跃的页数观察耗时变化。当步长对应的活跃页数跨过 TLB 的相联度时耗时会出现明显跳变。#include stdio.h #include stdlib.h #include string.h #include time.h #define PAGE_SIZE 4096 #define TOTAL_SIZE (64 * 1024 * 1024) /* 64MB远超二级 TLB 覆盖范围 */ static double now_sec(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec ts.tv_nsec / 1e9; } int main(void) { char *buf aligned_alloc(PAGE_SIZE, TOTAL_SIZE); if (!buf) { perror(alloc); return 1; } memset(buf, 1, TOTAL_SIZE); printf(%-12s %-12s %-12s\n, stride, pages, sec); for (int stride_pages 1; stride_pages 32; stride_pages) { size_t stride (size_t)stride_pages * PAGE_SIZE; size_t pages TOTAL_SIZE / PAGE_SIZE; /* 数组头部先预热让第一页进去 */ volatile char sink buf[0]; double t0 now_sec(); /* 反复多轮摊薄噪声 */ for (int round 0; round 8; round) { for (size_t off 0; off stride TOTAL_SIZE; off stride) { sink buf[off round]; } } double t1 now_sec(); printf(%-12zu %-12zu %.4f\n, stride, pages, t1 - t0); } free(buf); return 0; }编译时务必关掉过度优化否则编译器可能把整个循环优化掉gcc -O2 -o tlb_scan tlb_scan.c -lrt ./tlb_scan实测下来你会看到一条很有特点的曲线步长在 1 到 4 页之间时耗时相对平稳一旦步长超过某个值耗时突然跳升然后在更大的步长上趋于平缓。这个跳变点的位置和 TLB 的关联度高度相关。原因是步长是页的整数倍时所有访问地址的低位组索引部分高度重复会集中砸在少数几个组上关联度一旦不够就互相踢出缺失率飙升。实操心得这个实验的噪声非常大跑一次的结果完全不可信。我的做法是每个步长跑 8 到 16 轮取中位数并且关掉机器上所有能关的后台任务。另外数组一定要真的初始化过否则分配到的可能是零页第一次写入才真正分配物理页测出来的是缺页开销而不是 TLB 开销。4.3 亲眼看着保护异常被触发下面这段代码演示三种典型的内存保护拦截每一段都能稳定崩溃正好用来观察异常现场。#include stdio.h #include stdlib.h #include sys/mman.h #include signal.h #include unistd.h static void handler(int sig, siginfo_t *si, void *ctx) { /* 打印出错地址便于判断落在哪个区域 */ printf(signal %d at address %p\n, sig, si-si_addr); _exit(1); } int main(void) { struct sigaction sa {0}; sa.sa_sigaction handler; sa.sa_flags SA_SIGINFO; sigaction(SIGSEGV, sa, NULL); sigaction(SIGBUS, sa, NULL); /* 场景一只读映射然后尝试写入 */ char *ro mmap(NULL, 4096, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ro MAP_FAILED) { perror(mmap); return 1; } printf(read ok: %d\n, ro[0]); /* 读没问题 */ ro[0] x; /* 这里会被内存保护拦下 */ return 0; }第二个场景测执行禁用把一段数据当函数调用栈或堆上的地址会被 NX 拦下。第三个场景测用户态访问内核地址一般直接段错误出错地址落在内核映射区间。/* 场景二堆上内存当代码执行 */ void *code malloc(4096); ((void (*)(void))code)(); /* 场景三用户态直接读内核地址地址值仅为演示实际会崩 */ volatile unsigned long *k (unsigned long *)0xffffffffff600000UL; unsigned long v *k;跑完这三段对着输出的地址范围看基本就能建立起这个地址属于哪个区域、这个区域的权限是什么、所以我能不能这么干的直觉。这个直觉在排查真实崩溃时极其有用。注意事项上面这段代码里修改信号处理函数之后调用_exit而不是exit是有意为之。在信号处理函数里调用复杂函数有重入风险虽然日常调试不太出事但养成习惯没坏处。另外 mmap 出来的只读页读操作是完全合法的这也是区分保护异常和缺页异常的一个小技巧——读得通、写就崩那一定是权限问题。5. 常见问题与排查实录5.1 大页为什么能立竿见影地提升性能大页的收益来自三个叠加的效应很多人只知道第一个。覆盖范围效应同样数量的 TLB 表项能覆盖大得多的地址范围缺失率直接下降。这是最主要的收益来源。走查层级效应2MB 页让页表从 4 级变 3 级一次完整走查少一次访存1GB 页再少一级。对于 TLB 缺失频繁的程序这个收益是乘法叠加的。页表开销效应一个 2GB 的进程用 4KB 页需要 52 万个页表项光页表本身就要占好几兆内存用 2MB 页只需要 1024 个表项页表内存开销降到原来的千分之一。这部分省下来的内存又能被数据缓存利用间接提升性能。代价同样要说清楚内部碎片。分配一个 2MB 大页哪怕你只用了几十 KB剩下的空间也浪费了而且不能被其他分配使用。对于大量小块内存的进程开大页反而会让内存占用暴涨。另外大页的分配需要连续物理内存系统长时间运行后内存碎片化分配成功率会下降甚至会触发内存整理带来不可预期的延迟抖动。我见过一个服务开了透明大页之后P99 延迟反而变差查下去就是内存整理导致的周期性卡顿。5.2 TLB 抖动和缓存抖动怎么区分两者表现都是数据量超过某个阈值后性能骤降但成因完全不同处理方式也完全相反。区分方法靠实验固定数组总大小改变访问步长看性能悬崖出现在哪里。如果悬崖出现在步长为页大小的整数倍、且步长对应的工作集在 256KB 到 6MB 之间基本是 TLB 的问题如果悬崖出现在步长等于或略小于缓存行大小的某个倍数、工作集在几十 KB 到几 MB 之间那是数据缓存的问题。另一个更直接的判据是看计数器TLB 缺失高而缓存缺失低就是 TLB 瓶颈反过来就是缓存瓶颈。两者同时高的情况通常是翻译缺失导致额外的页表访存把缓存也挤爆了属于连锁反应得先从 TLB 下手。现象大概率原因验证方式处理方向工作集超 256KB 后性能下降一级 TLB 覆盖不足对比步长扫描结果优化访问局部性工作集超 6MB 后陡降二级 TLB 覆盖不足观察 TLB 缺失率考虑大页或改数据结构随机访问远慢于顺序访问TLB 冲突缺失固定大小改步长对比调整数据布局、换大页进程切换频繁时内核态占比高TLB 刷新开销对比开关地址空间标识开启相关特性改了页表权限但行为不变TLB 未失效强制刷新后重试补上失效指令5.3 我在实际调优里踩过的几个坑坑一以为 TLB 是软件管的。TLB 的填充和替换绝大多数情况由硬件自动完成软件只在页表结构、权限修改、地址空间切换这几个点上介入。很多次有人问我怎么手动往 TLB 里塞一个表项答案是塞不了只能通过访问那个地址让硬件自然填进去。坑二忘记访问位和脏位的存在。做内存回收或页迁移的时候如果不正确处理这两个位会出现明明没写过却被标记为脏或者写过了却没标记的情况进而导致数据丢失或多余的写回。这两个位在很多架构上是硬件自动置位的修改页表项时要小心别把它们清掉。坑三把保护异常当成程序 bug 去查。前面提过保护异常的报错位置和真正的原因经常隔得很远。我遇到过一次崩溃在某个库函数里查了两天才发现是上游代码把一个只读映射的地址传了进去写入发生在很深的调用栈里。现在的做法是第一步就看崩溃地址属于哪个内存区域直接从区域权限反推比看调用栈快得多。坑四在容器或虚拟化环境里测 TLB 参数。嵌套页表会让地址翻译多一层TLB 的行为和裸机上完全不同测出来的容量边界、缺失率都不能直接套用。要在这种环境里做性能分析必须先搞清楚有没有多层翻译在生效。坑五盲目开大页。大页不是万能药前面说过内部碎片和分配抖动的问题。我的经验是先用性能计数器确认 TLB 缺失确实是瓶颈再小范围试点观察内存占用和延迟分布的变化确认收益大于代价再推广。直接全量开启然后指望性能变好大概率会得到一个更糟的结果。5.4 一个容易被忽略的细节指令 TLB 和数据 TLB 是分开的一级 TLB 通常分成指令侧和数据侧两套各自独立。这意味着一份代码如果体积很大、跳转很分散会先把指令 TLB 撑爆而数据侧的 TLB 可能还很空闲。表现就是数据不大但程序就是慢因为瓶颈在取指路径上。判断方法很简单分别看iTLB-load-misses和dTLB-load-misses两个计数。如果指令侧缺失异常高考虑的是代码布局优化——把热路径函数集中放置、减少跨页跳转、避免巨大的 switch 分支表散落在不同页上。这类优化在解释器、虚拟机这类取指密集的程序上收益特别明显。6. 最后分享几点个人体会TLB 和内存保护这两块内容教材上通常各写一小节就带过了但真正做性能优化或者排查诡异崩溃的时候它们出现的频率高得惊人。我自己最大的转变是不再把它们当成知道有这回事就行的背景知识而是当成可以量化、可以测量、可以优化的具体对象——覆盖范围能算出来缺失率能读出来容量边界能测出来权限拦截能复现出来。一旦这些东西从概念变成数字排查就有了明确的方向。另外一个体会是这两件事其实是同一个流程的两面。地址翻译和权限检查在硬件里是并行的TLB 命中时权限位直接来自缓存的表项所以改权限必须刷 TLB这条规则不是补充说明而是这套设计的必然结果。理解了这一点很多看起来莫名其妙的崩溃和性能抖动就都能串起来解释了。如果要在自己的工作里落地我的建议是先做一件事拿性能计数器跑一遍你手上最吃内存的那个程序看看 TLB 缺失率是多少。这个数字往往比任何理论分析都更能说明问题也更容易说服别人去改动那些看起来跑得好好的代码。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。