很多想啃KVM的人,多半都是奔着SVM(AMD的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 Address,VA);真正送到内存总线上读写数据的地址,是物理地址(Physical Address,PA)。中间的翻译由MMU硬件完成,MMU赖以翻译的“字典”就是页表。
没有页表的系统,进程直接操作物理地址,后果是灾难性的:每个进程都能改别人的内存,系统完全没法隔离。页表给我们的好处可以简单列一下:
- 每个进程拥有独立的地址空间,互不干扰。
- 进程不需要全部加载进内存,按需分配、按需换入就可以跑。
- 物理内存的分配单位是页(通常4KB),可以分散在任意位置,通过页表形成连续的虚拟视图。
- 通过页表项的权限位,实现用户态/内核态的访问控制,以及可读/可写/可执行的控制。
一个很好的类比是图书馆索引:虚拟地址像书名,物理地址像书架上的坐标。读者(CPU)只要报书名,图书管理员(MMU)查索引(页表)就能定位到具体位置。早期没有多级索引,每本书都登记在一张巨大的总表里,内存浪费严重;现在操作系统用的是“分类目录柜”——多级页表。
1.3 MMU与TLB:页表机制的硬件承担者
页表是软件维护的数据结构,但真正查表的是硬件MMU。一条普通的load/store指令,CPU会把虚拟地址交给MMU,MMU从当前进程的页表根指针出发,逐级往下查到物理地址,然后访问内存。
这里必须提到TLB(Translation 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下经典的四级页表长这样:
- PGD(Page Global Directory):顶层目录,每个进程的
mm_struct->pgd指向它。 - P4D:四级页表引入后新增的一级,在4级分页下P4D和PGD基本是一一对应的。
- PUD(Page Upper Directory):第三级。
- PMD(Page Middle Directory):第二级。
- PTE(Page 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 0 | P / Present | 该页是否在内存中。不在内存就触发缺页异常 |
| bit 1 | R/W | 可写标志。0为只读 |
| bit 2 | U/S | 用户/超级用户访问权限。0表示只有内核态能访问 |
| bit 5 | A | Accessed,该页是否被访问过 |
| bit 6 | D | Dirty,该页是否被写过 |
| bit 7 | PS / PAT | 目录项中为PS(Page Size),置1表示下一级不是表而是大页 |
| bit 8 | G | Global,全局页,CR3切换时不刷新 |
| bit 63 | NX | No 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 Address(GVA),把OS以为的物理地址叫Guest Physical Address(GPA)。在没有VMM的情况下,GPA就是真实的物理地址,所以映射简单直接:GVA -> GPA(真实PA)。
SVM出现以后,问题变了。现在物理机上跑着一个VMM(也就是KVM),VMM上面创建了多个虚拟机。每个虚拟机内部都有自己的操作系统,这个操作系统照样管理自己的页表,它把进程的GVA映射到它认为的“物理地址”GPA。可是这个GPA不是真实的硬件物理地址,真正的内存条地址是我们说的Host Physical Address(HPA)或System Physical Address(SPA)。
于是地址翻译从一层变成了两层:
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 Table,NPT)。核心思想是:MMU自己认识两层页表。
在这种模式下,guest OS的CR3照常指向自己的页表,负责GVA到GPA的转换;VMM则另外准备一套页表,叫nested页表或第二层页表,负责GPA到SPA的转换。这套nested页表的根指针放到VMCB的一个字段里,也就是开头提到的nCR3(Nested 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 exit,VMM介入 | guest自己处理,VM exit大幅减少 |
| 内存开销 | 每个guest进程都需额外影子页表 | VMM只需维护一套nested页表 |
| 性能损耗 | 翻译时无额外开销,但同步开销极大 | 一次访问需要硬件走两套页表,TLB miss时更贵 |
3.4 NPT的性能代价与ASID:SVM如何缓解TLB压力
NPT不是白拿的性能。一次地址翻译,原来MMU走4级页表,现在可能要走8级(第一层4级+第二层4级)。如果每次TLB miss都这么走一遍,性能损耗会很扎眼。
SVM在硬件上给出了几个缓解手段:
- TLB直接缓存最终结果:GVA到SPA的映射,一次翻译成功后,后续访问直接命中,不会重复走两级页表。
- ASID(Address Space Identifier):TLB条目被打上地址空间标识。不同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.h:PFN、struct page、virt_to_page等核心接口。arch/x86/include/asm/svm.h:VMCB结构体定义,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内部开启THP,NPT第一层可能跳出2MB大页;host上开启THP,NPT第二层也可能出现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 violation,KVM截获这个VM exit,知道这个页面被写了,然后把它标记为dirty,再把权限恢复回可写。
所以你会看到,SVM的NPT里不只做地址翻译,还承担了VM内存状态跟踪的功能。它其实是在硬件层面给了VMM一个“监视guest内存访问”的钩子,这正是内存虚拟化能做得高效的重要原因。
另外,NX位在虚拟化中也很关键。KVM会对guest暴露的页表权限做校验,如果guest想要把页面同时设为可写且可执行,VMM的严格模式可能会拒绝。这种W^X策略配合NPT的权限位机制,能在虚拟机里也提供可靠的缓解能力。理解页表权限位的传递逻辑后,很多看似诡异的VM行为都能解释了。
回到开头的问题:SVM代码里那个nCR3为什么重要,客户机CR3到底算不算数?现在答案已经很清楚了——客户机的CR3负责客户机内部的地址翻译,nCR3负责把“客户机以为的物理地址”翻译成“真正的物理地址”,一台虚拟机能否正确运行、高效运行,全靠两层页表的配合。读了这么久,我最后想分享的是:别怕花时间把页表搞透,这可能是你读虚拟化代码时最值得投入的一笔时间。坑踩多了回头看,每一步都有价值。