Linux内核页表与SVM嵌套页表:从虚拟地址到物理地址的透明化
很多想啃KVM的人多半都是奔着SVMAMD的Secure Virtual Machine的源码去的然后普遍会在某个瞬间卡住看着arch/x86/kvm/svm/svm.c里 VMCB 的一堆字段完全不明白nCR3到底该填什么也不清楚客户机里的 CR3 是不是“真正的CR3”。这些问题绕来绕去最后都会落在同一块地基上——Linux内核页表。这篇“SVM相关的Linux内核基础”系列第2篇我打算把页表从头到尾捋一遍从地址转换的心智模型开始到多级页表的结构、标志位再到SVM环境下的嵌套页表最后给出一条能落地的源码阅读路线。适合那些准备深入KVM/SVM、但觉得内存在操作系统层面还有盲区的朋友。1. 为什么研究SVM之前必须先把页表啃下来1.1 这个系列的定位页表是SVM的内存地基先把概念对齐一下。这里的SVM不是支持向量机而是AMD提出的Secure Virtual Machine也就是AMD处理器上的硬件虚拟化扩展对标Intel的VT-x。KVM作为Linux内核里的虚拟化方案对AMD SVM的支持就是kvm_amd这个模块代码主要落在arch/x86/kvm/svm/目录下。虚拟化做的事情本质上是“抽象和隔离物理资源”CPU、内存、设备都要管。而内存虚拟化的核心载体就是页表。搞明白SVM为什么能跑起来关键就是搞明白客户机操作系统以为自己在管理物理内存但这份“以为”是通过页表机制构建出来的。我见过不少朋友一上来就追vmrun指令、看VMCB字段结果越看越迷糊。原因很简单虚拟化是构建在操作系统已有机制之上的如果连普通进程的地址空间都是怎么从虚拟地址变成物理地址的都不清楚那SVM里那些“第二层映射”就更没法理解了。所以这个系列先给内核基础打底页表就是第一块也是最关键的一块。1.2 虚拟地址到物理地址先建立正确的地址转换心智模型要进入虚拟化场景脑子里得先有一个牢固的模型CPU执行指令时候用的地址是虚拟地址Virtual AddressVA真正送到内存总线上读写数据的地址是物理地址Physical AddressPA。中间的翻译由MMU硬件完成MMU赖以翻译的“字典”就是页表。没有页表的系统进程直接操作物理地址后果是灾难性的每个进程都能改别人的内存系统完全没法隔离。页表给我们的好处可以简单列一下每个进程拥有独立的地址空间互不干扰。进程不需要全部加载进内存按需分配、按需换入就可以跑。物理内存的分配单位是页通常4KB可以分散在任意位置通过页表形成连续的虚拟视图。通过页表项的权限位实现用户态/内核态的访问控制以及可读/可写/可执行的控制。一个很好的类比是图书馆索引虚拟地址像书名物理地址像书架上的坐标。读者CPU只要报书名图书管理员MMU查索引页表就能定位到具体位置。早期没有多级索引每本书都登记在一张巨大的总表里内存浪费严重现在操作系统用的是“分类目录柜”——多级页表。1.3 MMU与TLB页表机制的硬件承担者页表是软件维护的数据结构但真正查表的是硬件MMU。一条普通的load/store指令CPU会把虚拟地址交给MMUMMU从当前进程的页表根指针出发逐级往下查到物理地址然后访问内存。这里必须提到TLBTranslation Lookaside Buffer。如果你理解了页表就理解了为什么需要TLB一次地址翻译要查好几级内存如果每次访问内存都来这么一遍性能根本没法看。TLB就是页表翻译结果的硬件缓存CPU先把常用的“虚拟地址到物理地址”翻译结果放在TLB里下次同样的虚拟地址访问直接命中省掉整个查表流程。你还会发现一个现象切换进程时因为页表根指针变了地址翻译结果就失效了需要刷新TLB这个开销是真实存在的。所以在SVM的语境下AMD引入ASID机制来避免每次VM切换都全量刷TLB——这个后面专门讲。现在只需要记住页表是软件数据MMU是硬件执行者TLB是为了不让MMU每次都完整走一遍页表而存在的缓存。这三个角色理解透了往下读就顺了。2. 从PGD到PTE内核多级页表的完整结构2.1 多级页表结构四级、五级以及为什么必须多级先回答一个看起来基础但很重要的问题为什么不用一张线性大页表以64位系统为例如果虚拟地址空间是48位页面大小4KB那需要2的36次方个页表项。每个页表项8字节一张线性表就要512GB这还不算每个进程一份。显然不可行。所以内核把地址翻译拆成多级。x86-64 Linux下经典的四级页表长这样PGDPage Global Directory顶层目录每个进程的mm_struct-pgd指向它。P4D四级页表引入后新增的一级在4级分页下P4D和PGD基本是一一对应的。PUDPage Upper Directory第三级。PMDPage Middle Directory第二级。PTEPage Table Entry叶子页表直接记录物理页帧号。一个4KB页面下虚拟地址的低12位是页内偏移中间字段依次作为PGD/PUD/PMD/PTE的索引。MMU从CR3寄存器里读出PGD基址按索引逐级下钻最后PTE里给出的物理页基址加上页内偏移得到真正的物理地址。这个结构是不是很像查快递先按省PGD、市PUD、区PMD、街道PTE逐级定位最后房门号就是页内偏移。每一级索引只需要很少的内存只有实际用到的地址范围才会分配对应的页表页内存浪费被压到极小。4KB页是最常见的情况但你也要知道系统里还有2MB大页、1GB大页。2MB大页由PMD直接指向一个2MB的物理块PTE这一级直接省掉1GB大页更狠PUD直接落地MMU少走两级。后面讲遍历页表的代码时必须处理这种特殊情况。2.2 页表项里的bit位present、RW、NX这些标志位到底怎么用页表项不只是“虚拟地址到物理地址”的映射它同时还是一张权限标签。x86-64的PTE是一个64位数高位存物理页帧号低位是各种标志位。下面这几个是日常打交道最多的位名称含义bit 0P / Present该页是否在内存中。不在内存就触发缺页异常bit 1R/W可写标志。0为只读bit 2U/S用户/超级用户访问权限。0表示只有内核态能访问bit 5AAccessed该页是否被访问过bit 6DDirty该页是否被写过bit 7PS / PAT目录项中为PSPage Size置1表示下一级不是表而是大页bit 8GGlobal全局页CR3切换时不刷新bit 63NXNo Execute禁止执行是硬件级缓解缓冲区溢出攻击的关键Present位和缺页机制捆在一起。用户进程访问一个虚拟地址MMU找PTE发现Present位是0就会触发缺页异常内核缺页处理程序再决定是分配物理页、从磁盘换入还是直接报段错误。这套机制是虚拟内存的基石也是虚拟化技术特别关注的地方——你的guest OS跑在KVM里同样要经历这套流程。A/D两个位也值得你注意。操作系统定期清理A位来判断哪些页最近没被用过从而作为内存回收的依据D位则用来判断是否需要把页面写回磁盘。在虚拟化场景下KVM同样利用Dirty位做脏页跟踪迁移虚拟机时只传输被写过的页面。从哲学上看页表项的每个bit都是一种“VM和MMU之间的通信协议”。你研究SVM时NPT页表里同样有这些标志位硬件和VMM就是靠它们协同工作的。2.3 内核页表与进程页表x86地址空间布局怎么划分每个进程都有自己完整的页表但并不是每个进程都从头建一遍内核映射。x86-64 Linux把虚拟地址空间从中间劈开低地址给用户态高地址给内核态。内核地址空间在所有进程看来都一样因此进程切换时顶层的PGD里内核半部分映射基本是共享的。这个过程可以这样理解你在一家公司上班每个员工有自己独立的工位和抽屉用户空间但公司的会议室、打印室是所有人共用的内核空间。切换员工时不需要把共用区域重新布置一遍只要换工位里的东西就行。页表也一样。写mm-pgd时内核会把内核空间对应的表项通常是PGD顶层靠后的条目预先填好进程的pgd一换用户空间立刻独立内核空间依然可用。不过自从Page Table Isolation引入后用户态运行时内核页表不再完整映射进用户进程的页表而是一套最小集这样访问内核地址会直接触发页错误内核地址就无法被用户态侧信道读取了。这个优化牺牲了部分系统调用/中断进出性能但换来了更强的隔离。2.4 struct page与PFN页表最终指向的“物理页描述符”PTE里存的其实不是完整物理地址而是物理页帧号PFN。4KB页对齐后物理地址右移12位就是PFN。内核拿到PFN后可以通过pfn_to_page()得到对应物理页的描述结构体struct page。struct page是内核内存管理的核心元数据它记录了物理页的状态是否空闲、是否被映射、映射了多少个进程、属于哪个文件或匿名页、回收状态等等。整个物理内存被切分成一个个4KB的页帧每个页帧对应一个struct page这些struct page构成数组叫vmemmap。为什么一定要提这个因为搞虚拟化的时候你要经常从“页表项”反查“物理页描述符”。比如KVM建立NPT时需要知道某个guest物理地址对应哪一页然后对该页设置只读、Dirty跟踪等操作。PTE、PFN、struct page三者之间的转换关系几乎是内核内存代码里最频繁出现的套路pte_pfn(pte) // 从PTE提取PFN pfn_to_page(pfn) // 从PFN得到struct page page_to_pfn(page) // 从struct page得到PFN PFN_PHYS(pfn) // PFN左移12位得到物理地址 __pa(ptr) // 内核线性映射虚拟地址转物理地址3. SVM虚拟化之下页表发生了怎样的变身3.1 虚拟化之前的页表GVA到GPA的单一转换没有虚拟化的年代一个操作系统管理整台物理机。进程的虚拟地址通过页表转换得到的就是真实的物理地址。我们把进程那个视角叫Guest Virtual AddressGVA把OS以为的物理地址叫Guest Physical AddressGPA。在没有VMM的情况下GPA就是真实的物理地址所以映射简单直接GVA - GPA真实PA。SVM出现以后问题变了。现在物理机上跑着一个VMM也就是KVMVMM上面创建了多个虚拟机。每个虚拟机内部都有自己的操作系统这个操作系统照样管理自己的页表它把进程的GVA映射到它认为的“物理地址”GPA。可是这个GPA不是真实的硬件物理地址真正的内存条地址是我们说的Host Physical AddressHPA或System Physical AddressSPA。于是地址翻译从一层变成了两层GVA - GPA 客户机页表由guest OS维护 GPA - SPA 宿主机页表由VMM维护SVM要解决的核心问题就是怎么让硬件MMU高效地完成这两层翻译同时让guest OS的页表维护尽量透明。3.2 影子页表的思路与致命缺陷最早期的虚拟化方案没有硬件支持用的叫影子页表Shadow Page Table。思路很直白既然MMU只认一个CR3只认一份页表那VMM就为每个guest进程手工打造一份“从GVA直接到SPA”的页表然后偷偷把真实CR3指向这份影子页表。guest OS自己维护的页表被放在一边当guest想改页表比如缺页、换页、进程退出时VMM截获这个操作帮它把改动同步到影子页表里。这个方案逻辑上很简单但代价极其惨重guest里每一次page fault都可能触发VMM的介入几十次甚至上百次trap/VM exit才能完成一次真实缺页处理。guest进程一切换VMM就要准备新的影子页表同步成本巨大。影子页表本身也要占内存一个guest可能有好几个进程每个进程一套影子页表。一句话总结影子页表把MMU的翻译工作从硬件推给了软件正确但慢。3.3 AMD SVM的NPT嵌套页表一次硬件完成的两次转换SVM的革命性改进在于硬件原生支持嵌套页表Nested Page TableNPT。核心思想是MMU自己认识两层页表。在这种模式下guest OS的CR3照常指向自己的页表负责GVA到GPA的转换VMM则另外准备一套页表叫nested页表或第二层页表负责GPA到SPA的转换。这套nested页表的根指针放到VMCB的一个字段里也就是开头提到的nCR3Nested CR3。当guest里的一个进程访问某个虚拟地址时硬件MMU执行的操作是用guest CR3指向的页表把GVA翻译成GPA。再用VMCB里的nCR3指向的nested页表把GPA翻译成SPA。用最终的SPA去访问物理内存。整个过程完全由硬件完成不需要VMM插手。guest OS改自己的页表时VMM不用像影子页表那样同步来同步去guest的CR3照常切换照常建立映射VMM只需要保证自己的nested页表正确覆盖guest的所有物理内存就行。这才是NPT最漂亮的地方——客户机内存管理的虚拟化被彻底透明化了。如果把NPT和影子页表做个对比就更直观了维度影子页表SVM NPT地址翻译GVA - SPA 一层GVA - GPA - SPA 两层页表维护者VMM软件同步guest自主维护第一层VMM维护第二层guest缺页处理频繁VM exitVMM介入guest自己处理VM exit大幅减少内存开销每个guest进程都需额外影子页表VMM只需维护一套nested页表性能损耗翻译时无额外开销但同步开销极大一次访问需要硬件走两套页表TLB miss时更贵3.4 NPT的性能代价与ASIDSVM如何缓解TLB压力NPT不是白拿的性能。一次地址翻译原来MMU走4级页表现在可能要走8级第一层4级第二层4级。如果每次TLB miss都这么走一遍性能损耗会很扎眼。SVM在硬件上给出了几个缓解手段TLB直接缓存最终结果GVA到SPA的映射一次翻译成功后后续访问直接命中不会重复走两级页表。ASIDAddress Space IdentifierTLB条目被打上地址空间标识。不同guest的相同虚拟地址翻译结果用ASID区分开VM切换时不需要全量刷新TLB只要ASID不同旧条目自然不会被命中。这个机制对密集的VM切换场景帮助非常大。大页支持如果guest或宿主机用了2MB/1GB大页两次翻译的级数都能减少TLB能覆盖的内存范围也更大。你在后面读SVM代码时会频繁看到asid这个字段。KVM维护一个ASID分配器每次VM entry时给vmcb填上对应的ASID。理解NPT之后再回去看VMCB那个nCR3字段就非常通透了它就是VMM为当前guest建立的第二层页表根指针。4. 在内核源码里把页表和SVM串起来4.1 源码导航地址转换宏与页表操作函数的地图理论说一千遍不如直接下源码跑一遍。我建议你从这几个文件入手arch/x86/include/asm/pgtable_types.h页表项类型、标志位宏定义。arch/x86/include/asm/pgtable.h各种页表操作函数如pgd_offset、pud_offset。arch/x86/include/asm/pgalloc.h页表页的分配和释放。include/linux/mm.hPFN、struct page、virt_to_page等核心接口。arch/x86/include/asm/svm.hVMCB结构体定义SVM硬件的各种控制字段。当你看到一个虚拟地址的时候第一反应应该去找它属于哪段映射。如果是内核线性映射区direct map__pa()和__va()直接互转如果是vmalloc区域就得通过page_address()先拿到struct page再转。很多初学者在vmalloc地址上用__pa得到错误结果然后怀疑内核算错了——其实是用错了宏。4.2 动手写一个内核模块手动遍历页表纸上谈兵没意思我直接给一个可以加载的内核模块它会遍历当前进程某个虚拟地址的完整页表路径打印每一级页表项并算出最终物理地址。模块代码不算长但信息量很足#include linux/module.h #include linux/kernel.h #include linux/mm.h #include linux/sched.h #include linux/init.h static unsigned long test_addr 0; module_param(test_addr, ulong, 0644); static void walk_page_table(struct mm_struct *mm, unsigned long addr) { pgd_t *pgd; p4d_t *p4d; pud_t *pud; pmd_t *pmd; pte_t *pte; if (!mm || !addr) return; pgd pgd_offset(mm, addr); pr_info(PGD: %px, value: %llx\n, pgd, pgd_val(*pgd)); if (pgd_none(*pgd) || pgd_bad(*pgd)) return; p4d p4d_offset(pgd, addr); pr_info(P4D: %px, value: %llx\n, p4d, p4d_val(*p4d)); if (p4d_none(*p4d) || p4d_bad(*p4d)) return; pud pud_offset(p4d, addr); pr_info(PUD: %px, value: %llx\n, pud, pud_val(*pud)); if (pud_none(*pud)) return; if (pud_large(*pud)) { pr_info(PUD 指向 1GB 大页结束遍历\n); return; } if (pud_bad(*pud)) return; pmd pmd_offset(pud, addr); pr_info(PMD: %px, value: %llx\n, pmd, pmd_val(*pmd)); if (pmd_none(*pmd)) return; if (pmd_large(*pmd)) { pr_info(PMD 指向 2MB 大页结束遍历\n); return; } if (pmd_bad(*pmd)) return; pte pte_offset_map(pmd, addr); pr_info(PTE: %px, value: %llx\n, pte, pte_val(*pte)); if (pte_present(*pte)) { unsigned long pfn pte_pfn(*pte); unsigned long phys PFN_PHYS(pfn) (addr ~PAGE_MASK); pr_info(PFN: 0x%lx, 物理页基址: 0x%lx, 页内偏移: 0x%lx, 物理地址: 0x%lx\n, pfn, PFN_PHYS(pfn), addr ~PAGE_MASK, phys); } else { pr_info(PTE 不存在该页不在内存中\n); } pte_unmap(pte); } static int __init test_init(void) { struct mm_struct *mm current-mm; pr_info(进程 %s (pid %d)遍历地址 0x%lx\n, current-comm, current-pid, test_addr); walk_page_table(mm, test_addr); return 0; } static void __exit test_exit(void) { } module_init(test_init); module_exit(test_exit); MODULE_LICENSE(GPL);加载方式很简单传一个用户态地址进去dmesg里就会打出每一级的页表项。比如你写个小程序分配一个变量打印它的地址然后把这个地址传给模块你就能在log里看到从PGD到PTE的完整翻译路径。这个模块写出来有三个目的让你真正“看”到页表长什么样而不是只背概念。让你理解大页判断的重要性——如果不处理pud_large和pmd_large遇到大页直接崩溃或误判。为后面理解NPT做铺垫客户机进程的页表遍历路径跟这里一模一样只不过硬件会再嵌套走一次nested页表。4.3 找到SVM的入口从VMCB到nested CR3在源码里找SVM相关的关键点我建议按这个顺序读打开arch/x86/include/asm/svm.h找到vmcb_control_area结构体。你会看到nCR3字段还有asid字段。不要跳过注释AMD的VMCB布局很多细节就靠注释说明。然后打开arch/x86/kvm/svm/svm.c搜索svm_set_cr3。这个函数处理guest写入CR3的事件。KVM会拦截guest的CR3写操作更新vcpu里保存的guest CR3同时标记需要重新加载nested页表。这里的逻辑直接体现了NPT的工作方式guest的CR3是guest自己的事而KVM要操心的是自己的nCR3该在什么时候同步。接着去arch/x86/kvm/mmu/mmu.c看kvm_mmu_load或mmu_set_spte。这里负责建立和更新KVM为guest准备的第二层页表。你会发现KVM的MMU层抽象了一套统一接口SVM的NPT和Intel的EPT都走同一套核心逻辑只在底层页表格式上有所区分。最后一步回到svm_vcpu_run你会看到VMCB指针如何被写入物理CPU硬件nCR3如何随着vcpu切换被填进硬件寄存器。到这里页表和SVM之间的那条线就连上了。4.4 建议的阅读路线与最小实验环境我建议的阅读路线是先看懂普通页表遍历用上面的模块验证再去看VMCB结构体了解硬件视角接着看svm_set_cr3理解guest和VMM的交互最后看MMU层怎么维护nested页表。实验环境方面你不需要很夸张的服务器一台支持AMD SVM的物理机或可以嵌套虚拟化的云主机确认/proc/cpuinfo里有svm标志。一个较新版本的Linux发行版内核源码树需要下载好对应版本。QEMU/KVM工具链用于创建guest虚拟机。root权限。模块编译需要kernel headers加载需要root。强烈建议在虚拟机里做实验因为好奇的内核模块搞挂系统是常有的事。5. 页表学习中的深水区几个容易踩的坑5.1 改完页表立刻生效醒醒还有TLB很多人第一次写页表相关代码改完PTE就以为万事大吉结果发现访问的还是旧地址或者触发奇怪的权限错误。原因就是TLB还在缓存旧的翻译结果。页表是软件数据结构但MMU的行为高度依赖TLB的状态。修改页表之后必须显式刷新对应的TLB条目否则硬件可能继续用旧缓存。内核提供的接口有flush_tlb_page、flush_tlb_mm等KVM代码里对guest的tlb flush也会做大量转发处理。在SVM语境下ASID的作用在这里充分体现不同guest的TLB条目用ASID隔离所以一个guest刷TLB不会影响另一个guest。但如果是同一ASID下的地址映射变化例如guest执行了invlpg指令KVM会截获并刷新硬件TLB。5.2 大页路径遍历时忘了判断pmd_large会怎样我上面给的模块里特意加了pud_large和pmd_large的判断这不是多余的防御是实打实的教训。THP透明大页在现代Linux里默认开启。一个用户进程访问地址时如果内核把anonymous memory映射成了2MB的PMD大页那你从PGD走到PMD就该停了PMD项直接给出物理基址不需要PTE。如果代码继续用pmd_offset去取PTE拿到的可能是垃圾数据甚至导致内核崩溃。我在一次页表扫描工具的调试中就踩过这个坑一个进程反复崩溃最后定位到是忘了检查THP大页。从那以后我总结了个习惯任何页表遍历逻辑每下一级之前都要检查是否为large page是就立刻终止。这个坑在虚拟化场景也一样存在。guest内部开启THPNPT第一层可能跳出2MB大页host上开启THPNPT第二层也可能出现2MB大页。KVM的MMU代码里到处是这类判断看得多了你就明白为什么这是必备逻辑了。5.3 页表常驻内存吗页表页同样可以被回收很多人在理解页表时有一个隐含假设页表是内核的数据结构所以页表页一定常驻内存。这是一个常见的误解。页表页本身也是物理页它受内存管理器的统一调度。用户进程的页表页在内存压力下同样可以被回收、释放甚至换出。缺页时CPU触发page walk如果发现某一级页表项是空就会触发缺页异常缺页处理程序负责把页表页重新建立起来。这也是为什么内核里有“页表缓存”的概念以及为什么频繁fork进程会带来页表分配的开销——因为每个新进程都需要复制/初始化顶级页表结构。在虚拟化场景下这个问题更复杂一层guest内部有guest的页表页归guest管理VMM为guest建立的nested页表页归KVM管理。nested页表页是宿主机的物理内存这部分内存如果占用过多会直接压缩宿主机可用内存。所以KVM对nested页表的内存占用是有约束的比如通过MMU页表缓存限制、回收旧页表等方式控制。5.4 权限位在虚拟化下的连锁反应页表权限位在普通进程里看起来很简单但到了SVM环境下它会产生连锁反应。举个例子KVM做live migration时需要知道guest的哪些内存页被写脏了。它的一个常用手段是在nested页表中把guest物理内存页设为只读清除RW位。guest一旦写这个页面MMU在第二层翻译时就会触发一个NPT violationKVM截获这个VM exit知道这个页面被写了然后把它标记为dirty再把权限恢复回可写。所以你会看到SVM的NPT里不只做地址翻译还承担了VM内存状态跟踪的功能。它其实是在硬件层面给了VMM一个“监视guest内存访问”的钩子这正是内存虚拟化能做得高效的重要原因。另外NX位在虚拟化中也很关键。KVM会对guest暴露的页表权限做校验如果guest想要把页面同时设为可写且可执行VMM的严格模式可能会拒绝。这种W^X策略配合NPT的权限位机制能在虚拟机里也提供可靠的缓解能力。理解页表权限位的传递逻辑后很多看似诡异的VM行为都能解释了。回到开头的问题SVM代码里那个nCR3为什么重要客户机CR3到底算不算数现在答案已经很清楚了——客户机的CR3负责客户机内部的地址翻译nCR3负责把“客户机以为的物理地址”翻译成“真正的物理地址”一台虚拟机能否正确运行、高效运行全靠两层页表的配合。读了这么久我最后想分享的是别怕花时间把页表搞透这可能是你读虚拟化代码时最值得投入的一笔时间。坑踩多了回头看每一步都有价值。