ARM64 多级页表映射机制与Linux内核实现剖析
1. 从“找房子”到“找内存”:理解ARM64页表映射的直观比喻
大家好,我是老K,在嵌入式系统和内核开发这块摸爬滚打了十几年。今天咱们来聊聊一个听起来很硬核,但理解后能让你对系统认知提升一个档次的话题:ARM64架构下的多级页表映射机制。我知道,一提到“页表”、“虚拟地址”、“物理地址”,很多朋友就开始头疼了,感觉是处理器和操作系统最晦涩的部分。别急,咱们换个方式理解。
你可以把整个计算机的物理内存想象成一个超大型的、连续编号的“公寓楼”,每个房间(内存单元)都有唯一的门牌号(物理地址)。而你的程序(进程)呢,就像一个住户,它并不关心自己实际住在哪栋楼的哪个房间,它只关心自己家里的布局:客厅在“0x1000”,卧室在“0x2000”。这个“家里的布局”就是虚拟地址空间。
那么问题来了,当程序说“我要去客厅(访问0x1000)”,CPU怎么知道实际该去物理内存的哪个房间拿东西呢?这就需要一张“地址翻译地图”,也就是页表。在ARM64架构里,这张地图不是简单的一张大表,而是一个多层的、像公司组织架构图一样的多级页表。为什么这么设计?因为如果整个48位的地址空间(256TB)都用一张平铺的表来映射,这张表会大到内存都装不下!多级页表就像查电话簿,先查省(L0),再查市(L1),接着查区(L2),最后找到具体的人(L3),这样每一级只需要存自己管辖范围内的那一小部分信息,大大节省了空间。
ARM64给了我们三种不同大小的“基础房间户型”,也就是页面粒度:4KB、16KB和64KB。这个选择就像你选择用大瓷砖还是小瓷砖铺地,会影响整个“地图”(页表结构)的绘制方式。今天,我们就以Linux 6.10内核(比如Xilinx的配置)为例,亲手“拆解”这张地图,从硬件设计原理一路跟到内核源码的实现,看看从__create_pgd_mapping这个入口函数开始,内核是如何一砖一瓦为我们搭建起虚拟到物理的桥梁的。无论你是驱动开发者、系统调优工程师,还是单纯对底层好奇的技术爱好者,搞懂这个机制,对你理解程序如何运行、内存如何管理,乃至调试一些诡异的内存问题,都会有质的帮助。
2. ARM64的三种“户型图”:4KB、16KB、64KB页表结构详解
前面我们把内存比作公寓,页表比作地图。现在来看看ARM64提供的三种“基础户型”——4KB、16KB和64KB页面粒度。这个“粒度”就是内存管理的最小单位,就像你铺地砖,可以用小块的(4KB),也可以用大块的(64KB)。选择不同大小的砖,砌墙的工艺和图纸(页表结构)也会不同。我刚开始接触时也犯迷糊,总觉得记数字很枯燥,后来发现结合着看,规律就出来了。
2.1 4KB小户型:最经典的四级查表法
4KB页面是Linux最常用、也是最经典的配置,很多桌面和服务器系统默认就是这个。它的页表结构是标准的4级查询(L0到L3)。我们拿一个48位的虚拟地址(比如0xffff000012345678)来走一遍查表过程,你就明白了。
想象这个48位地址被分成了5段。前4段各9位,最后一段12位。为什么是9位?因为2的9次方是512,这意味着每一级页表都是一个有512个条目的数组。最后12位正好是4KB(2^12)的偏移量,用来在找到的“房间”内定位具体的“字节”。
- L0查表(PGD):CPU取出虚拟地址的
[47:39]这9位作为索引,去L0页表(全局目录)里找到对应的条目。这个条目要么指向一个1GB的大内存块(块映射),但更常见的是指向下一张L1页表的基地址。每个L0条目负责管理512GB的地址空间。 - L1查表(PUD):接着用地址的
[38:30]这9位,在L1页表(上层目录)里索引。这里的条目可以指向一个1GB的块(如果这1GB是连续的、属性一致的大内存),或者指向L2页表。 - L2查表(PMD):然后用
[29:21]这9位索引L2页表(中间目录)。这里的条目可以指向一个2MB的块(这就是Linux里常说的“大页”),或者指向最后一级页表。 - L3查表(PTE):最后用
[20:12]这9位索引L3页表(页表项)。这里的条目直接指向一个4KB的物理内存页帧。再加上原始的[11:0]这12位偏移,就能精准定位到物理内存中的一个字节了。
整个过程就像快递分拣:国家(L0)-> 省分拨中心(L1)-> 市分拨中心(L2)-> 快递站点(L3)-> 具体门牌(偏移)。这种设计非常均衡,在内存占用和查找效率之间取得了很好的平衡。在Linux的.config里,你通常会看到CONFIG_ARM64_4K_PAGES=y和CONFIG_PGTABLE_LEVELS=4这样的配置,就对应了这个模式。
2.2 16KB中型户型:地址位分配的变化
当页面粒度变成16KB时,有趣的变化发生了。最小单位变大了,意味着用来表示页内偏移的比特位需要更多(因为16KB=2^14,需要14位偏移)。这样一来,分给各级页表做索引的比特位就减少了。
对于48位地址,扣除14位偏移后,剩下34位用于页表索引。ARM64在这里采用了4级页表,但每一级的索引位数不再是均匀的9位了。具体来看:
- L0查表:这一级非常“粗放”,只用虚拟地址的最高位
[47]这1位来索引。是的,只有1位,所以L0表只有2个条目!每个条目负责管理高达128TB的地址空间。这主要是因为16KB粒度下,剩余的地址位不够均匀地分给四级了,所以最高级被极度简化。 - L1查表:使用地址的
[46:36]这11位作为索引,因此L1表有2048个条目。 - L2查表:使用地址的
[35:25]这11位索引,同样有2048个条目。这一级的条目可以指向一个32MB的内存块。 - L3查表:使用地址的
[24:14]这11位索引,也是2048个条目,最终指向一个16KB的物理页。
你会发现,除了L0被简化,L1到L3都是2048(2^11)条目的“胖表”。这种设计减少了页表级数的遍历次数(还是4级),但每一级表的大小增加了。它适合那些需要中等大小页面,并且希望TLB(快表)覆盖率更高的场景。
2.3 64KB大型户型:三级页表与超大块映射
64KB是最大的标准页面粒度。这时候页内偏移需要16位(2^16=64KB)。留给页表索引的位只剩下32位。ARM64对此的优化是直接减少一级页表,采用3级查询(L1到L3,注意这里硬件层面常称L1为第一级,对应Linux的PUD,有时会与4KB情况下的编号有差异,理解结构是关键)。
- L1查表(相当于4KB情况下的L0/PGD):使用虚拟地址的
[47:42]这6位进行索引。因此L1表有64个条目,每个条目管理一个4TB的巨大地址空间。 - L2查表(相当于PUD/PMD):使用地址的
[41:29]这13位索引,这一级表非常庞大,有8192个条目。每个条目可以指向一个512MB的超大内存块,或者指向最后一级页表。 - L3查表(PTE):使用地址的
[28:16]这13位索引,同样有8192个条目,最终指向一个64KB的物理页。
选择64KB粒度通常是为了追求极致的TLB效率。想象一下,一个TLB条目能缓存64KB的映射,相比4KB,同样数量的TLB条目可以覆盖大得多的内存空间,这对于处理大规模连续数据(如科学计算、数据库)的应用非常有利。不过,这也带来了内部碎片可能增大的问题(比如一个进程只用1KB,但它会独占一个64KB页)。
简单总结一下规律:页面粒度越大,页内偏移占用的比特位越多,用于索引页表的比特位就越少。硬件和内核会动态调整页表级数和每一级索引的位数,目的是让最后一级页表(PTE)能够索引到足够多的页面,同时保持整体结构的效率。理解这三种“户型图”,是看懂后续内核如何“施工”的基础。
3. 页表描述符:地图上的“图例”与“指针”
知道了页表的结构是几级、每级多大,接下来我们得看看每一级页表里具体存的是什么。页表中的每一个条目,我们称之为页表描述符。它就像地图上的一个图例,告诉你这个条目代表的是什么——是直接指向一块地(物理内存块),还是指向另一张更详细的分区地图(下一级页表)。在ARMv8架构的VMSAv8-64转换表格式中,描述符的格式是统一的64位,但不同级别、不同页面粒度下,其含义有微妙而关键的差别。这部分内容很关键,是理解映射类型的核心。
3.1 无效描述符:未开垦的“荒地”
判断一个描述符是否有效,最简单粗暴的方法是看它的第0位(bit 0)。如果这一位是0,那么无论这个描述符在哪个级别(L0到L3),它都是一个无效描述符。这表示该描述符对应的虚拟地址范围尚未被映射到任何物理内存,或者之前的映射已被撤销。当CPU访问到这样的地址时,就会触发一个页面错误,操作系统内核会捕获这个错误,根据情况决定是分配物理内存建立映射,还是报错(段错误)。这就像在地图上看到一片标记为“未开发”的区域,你是无法直接进入的。
3.2 L0到L2描述符:岔路口的“方向牌”
对于L0、L1、L2这些中间级别的描述符,它们有一个共同的关键位:第1位(bit 1)。这一位决定了当前条目是“块映射”还是“表映射”,是理解多级页表如何节省空间和时间的核心。
当bit 1 = 0时,这是一个块描述符。这意味着当前这个条目直接给出了一个大块连续物理内存的基地址和属性。翻译过程到此为止,CPU不再继续查找下一级页表。这相当于地图上直接标出了一片完整的“森林公园”(比如1GB、2MB的区域),告诉你这片区域都是连续的,属性一致(比如都是可读可写)。块映射是性能优化的关键,它减少了页表遍历次数,提高了TLB的覆盖率。但能否使用块映射,取决于硬件支持和地址对齐。
- 4KB粒度下:L1描述符可以指向1GB块,L2描述符可以指向2MB块。L0不支持块映射。
- 16KB粒度下:只有L2描述符可以指向32MB块。L0和L1不支持块映射。
- 64KB粒度下:L1描述符可以指向4TB块,L2描述符可以指向512MB块。
当bit 1 = 1时,这是一个页表描述符。这意味着当前条目存储的是下一级页表的物理基地址。CPU需要拿着这个地址,继续去查找下一级页表。这就像地图上标着“详见A区地图”,你得找到另一张更细致的地图才能继续定位。这是最常见的映射方式,用于建立精细的、页面级别的映射。
描述符的其他位则用于存储权限位(是否可读、可写、可执行)、内存属性(是否可缓存、共享性等)以及物理地址的高位部分。这里有个细节:由于描述符是64位(8字节),且页表条目在内存中必须按8字节对齐,所以存储的物理地址的低位(比如bit[11:3])实际上是0,这些位就被用来存储上述的控制信息了。
3.3 L3描述符:最终的“门牌号”
到了最后一级L3,事情就简单了。L3描述符的bit 1位有特殊含义:
- bit 1 = 0:这个编码是保留的,并且被视为无效(行为同bit 0为0)。在L3表里绝对不能使用这个编码。
- bit 1 = 1:这是唯一的有效格式,称为页描述符。它直接存储着一个物理内存页的基地址。
由于页面粒度不同,页描述符中存储的物理地址位也有所不同:
- 4KB页:描述符的
[47:12]位存储物理页帧号的高36位。加上虚拟地址提供的12位页内偏移,得到完整的物理地址。 - 16KB页:描述符的
[47:14]位存储物理页帧号的高34位。加上虚拟地址的14位偏移。 - 64KB页:描述符的
[47:16]位存储物理页帧号的高32位。加上虚拟地址的16位偏移。
L3描述符同样包含页面的权限和属性位。至此,经过多级索引,我们终于从虚拟地址“翻译”到了最终的物理地址。理解这些描述符的格式,是阅读内核页表操作代码的前提,因为内核代码本质上就是在设置和修改这些描述符。
4. 深入Linux 6.10内核:页表构建的完整流程剖析
理论说得再多,不如看代码来得实在。现在,我们就以Linux 6.10内核(选取类似Xilinx的配置,即4KB页、4级页表)为例,顺着内核源码,一步步看看一个虚拟地址区间是如何被映射到物理内存的。这个过程始于一个关键函数:__create_pgd_mapping。我会用我调试内核时常用的方法,结合代码和注释,把每一步都掰开揉碎讲清楚。你可以把下面的代码片段当成一次内核源码的“导游”。
4.1 映射入口:__create_pgd_mapping函数
这个函数是内核为一段物理内存建立页表映射的顶层接口。想象一下,你要在内核的页表“地图集”里,为一块新的物理内存“绘制”对应的虚拟地址路线。这个函数就是你的画笔。
static void __create_pgd_mapping(pgd_t *pgdir, phys_addr_t phys, unsigned long virt, phys_addr_t size, pgprot_t prot, phys_addr_t (*pgtable_alloc)(int), int flags) { mutex_lock(&fixmap_lock); __create_pgd_mapping_locked(pgdir, phys, virt, size, prot, pgtable_alloc, flags); mutex_unlock(&fixmap_lock); }参数很直观:pgdir是顶级页表(PGD)的基地址,通常是swapper_pg_dir(内核空间)或进程的mm->pgd(用户空间)。phys和virt是物理和虚拟地址的起始点,size是要映射的大小。prot是保护位(读/写/执行)。pgtable_alloc是一个重要的回调函数,当需要分配新页表页(一张512个条目的4KB页)时,就调用它。flags是一些控制标志,比如是否允许创建块映射。
函数一上来就获取了一个fixmap_lock锁。这是因为内核在建立映射的过程中,可能会临时使用一块固定的虚拟地址空间(称为fixmap)来访问新分配的页表页本身。这个锁保证了操作的原子性。然后,它把核心工作交给了__create_pgd_mapping_locked。
4.2 核心循环:__create_pgd_mapping_locked函数
这是建立映射的真正核心,它负责处理PGD级别的遍历。
static void __create_pgd_mapping_locked(pgd_t *pgdir, phys_addr_t phys, unsigned long virt, phys_addr_t size, pgprot_t prot, phys_addr_t (*pgtable_alloc)(int), int flags) { unsigned long addr, end, next; pgd_t *pgdp = pgd_offset_pgd(pgdir, virt); // 1. 找到起始虚拟地址对应的PGD条目指针 /* 检查虚拟地址和物理地址的页内偏移是否一致,这是映射的基本要求 */ if (WARN_ON((phys ^ virt) & ~PAGE_MASK)) return; phys &= PAGE_MASK; // 对齐到页面边界 addr = virt & PAGE_MASK; end = PAGE_ALIGN(virt + size); // 计算映射的结束地址(页对齐) do { next = pgd_addr_end(addr, end); // 2. 计算当前PGD条目所管辖地址范围的结束地址 alloc_init_pud(pgdp, addr, next, phys, prot, pgtable_alloc, flags); // 3. 处理PUD级别 phys += next - addr; // 4. 物理地址同步前进 } while (pgdp++, addr = next, addr != end); // 5. 移动到下一个PGD条目,直到覆盖整个范围 }我们来分解这个循环:
pgd_offset_pgd:根据虚拟地址virt,在PGD页表中找到对应的条目指针pgdp。PGD每个条目管理512GB空间。pgd_addr_end:计算当前这个PGD条目所能覆盖的地址范围的末尾。比如,如果addr是0,那么next就是512GB。这个函数确保我们一次只处理一个PGD条目负责的区域。alloc_init_pud:这是进入下一级(PUD)处理的函数。我们把当前PGD条目指针、要处理的地址范围[addr, next)、对应的物理地址phys等信息传给它。phys += next - addr:虚拟地址前进了多少,物理地址也同步前进多少,保持线性映射关系。- 循环条件:移动到下一个PGD条目(
pgdp++),更新起始地址(addr = next),如果还没到总结束地址end,就继续循环。
这个模式会在每一级页表处理中重复出现:计算当前层级的管理边界 -> 调用下一级处理函数 -> 同步推进物理地址 -> 移动到当前层级的下一项。
4.3 处理PUD级别:alloc_init_pud函数
这个函数负责配置PUD(Page Upper Directory)级别的条目,对应硬件L1。
static void alloc_init_pud(pgd_t *pgdp, unsigned long addr, unsigned long end, phys_addr_t phys, pgprot_t prot, phys_addr_t (*pgtable_alloc)(int), int flags) { ... p4d_t *p4dp = p4d_offset(pgdp, addr); // 在5级页表启用时是P4D,4级时通常就是PGD本身 p4d_t p4d = READ_ONCE(*p4dp); // 如果PGD条目是空的(无效描述符),需要先分配一张PUD页表页 if (p4d_none(p4d)) { p4dval_t p4dval = P4D_TYPE_TABLE | P4D_TABLE_UXN; // 构造一个“页表描述符” phys_addr_t pud_phys; pud_phys = pgtable_alloc(PUD_SHIFT); // 分配一个页表页,PUD_SHIFT决定了大小 __p4d_populate(p4dp, pud_phys, p4dval); // 将PUD页表的物理地址填入PGD条目 p4d = READ_ONCE(*p4dp); } ... pudp = pud_set_fixmap_offset(p4dp, addr); // 通过fixmap临时映射,获得PUD条目的虚拟地址指针 do { next = pud_addr_end(addr, end); // PUD每个条目管理1GB空间 // 尝试进行1GB的块映射(Huge Page) if (pud_sect_supported() && ((addr | next | phys) & ~PUD_MASK) == 0 && (flags & NO_BLOCK_MAPPINGS) == 0) { pud_set_huge(pudp, phys, prot); // 直接设置PUD为块描述符,指向1GB物理块 } else { // 否则,需要继续向下级PMD细分 alloc_init_cont_pmd(pudp, addr, next, phys, prot, pgtable_alloc, flags); } phys += next - addr; } while (pudp++, addr = next, addr != end); ... }这个函数有几个关键点:
- 页表分配:通过
pgtable_alloc回调分配一个物理页作为PUD表。PUD_SHIFT在4KB页下对应30(1GB的对数),但这个参数主要是告诉分配器需要哪种大小的页,通常就是分配一个4KB的页。 - 描述符填充:
__p4d_populate将新分配的PUD页表的物理地址,加上P4D_TYPE_TABLE(表示这是表描述符)等属性,写入PGD条目。 - Fixmap技巧:
pud_set_fixmap_offset是内核的一个精妙设计。新分配的PUD页表本身也是物理页,内核需要访问它来设置其中的条目。但此时页表还没完全建立,怎么访问它?内核使用了一块预先建立好固定映射的虚拟地址区域(fixmap),临时将PUD页表的物理地址映射到这块虚拟地址上,从而能够用指针直接操作。 - 块映射尝试:在满足条件时(地址对齐到1GB边界、支持块映射、未禁止块映射标志),内核会调用
pud_set_huge直接建立1GB的块映射。这是性能优化的关键路径。否则,就调用alloc_init_cont_pmd进入下一级。
4.4 向下传递:PMD与PTE级别的处理
alloc_init_cont_pmd和init_pmd函数处理PMD(Page Middle Directory,硬件L2)级别,逻辑与PUD级别高度相似。它们会尝试建立2MB的块映射(PMD级别的Huge Page),如果不行,就继续调用alloc_init_cont_pte分配PTE表。
alloc_init_cont_pte函数处理PTE(Page Table Entry,硬件L3)级别的分配。它检查PMD条目是否为空,如果是,则分配一个页表页作为PTE表,并将其物理地址填入PMD条目。
最终,接力棒传到了init_pte函数,这是映射的最后一公里。
4.5 最后一公里:init_pte函数设置页表项
static void init_pte(pmd_t *pmdp, unsigned long addr, unsigned long end, phys_addr_t phys, pgprot_t prot) { pte_t *ptep; ptep = pte_set_fixmap_offset(pmdp, addr); // 获取PTE条目虚拟地址指针 do { pte_t old_pte = READ_ONCE(*ptep); // 核心操作:将物理页帧号和属性组合成PTE条目值,写入 set_pte(ptep, pfn_pte(__phys_to_pfn(phys), prot)); phys += PAGE_SIZE; // 物理地址前进一页 } while (ptep++, addr += PAGE_SIZE, addr != end); // PTE每个条目管理4KB pte_clear_fixmap(); }init_pte函数在一个循环中,为虚拟地址区间[addr, end)内的每一个4KB页面,设置对应的PTE条目。
pte_set_fixmap_offset:同样通过fixmap机制,获得当前地址对应的PTE条目指针。__phys_to_pfn(phys):将物理地址转换为物理页帧号。pfn_pte:将页帧号和页面属性(prot)组合成一个完整的、符合格式的PTE描述符值。set_pte:将这个描述符值写入PTE条目指针指向的位置。至此,一个虚拟页面到物理页面的映射正式建立。- 循环步进
PAGE_SIZE(4KB),处理下一个页面。
当这个函数返回,并层层返回到最外层的__create_pgd_mapping时,一段虚拟地址到物理地址的完整页表映射就建立好了。CPU后续访问这段虚拟地址时,MMU就会自动通过我们刚刚构建的这套多级页表,完成地址翻译。
5. 动态适配与性能考量:内核如何玩转三种粒度
看完4KB页面的详细流程,你可能会问:那16KB和64KB页面呢?内核代码难道要写三套吗?当然不是。Linux内核的精妙之处在于它通过一系列的宏和配置,动态适配了不同的页面粒度和页表层级。这种设计让我在移植和优化内核时省了不少力。
内核中与页表相关的关键宏,如PAGE_SHIFT、PMD_SHIFT、PUD_SHIFT、PGDIR_SHIFT,它们的值都是在编译时根据CONFIG_ARM64_4K_PAGES等配置自动确定的。例如,在4KB页面下,PAGE_SHIFT=12,PMD_SHIFT=21(2MB),PUD_SHIFT=30(1GB),PGDIR_SHIFT=39(512GB)。而在64KB页面下,这些值都会变化,PMD_SHIFT可能对应29或30(对应512MB或1GB块)。
函数中的条件判断,如pud_sect_supported()和地址对齐检查((addr | next | phys) & ~PUD_MASK) == 0,使得同一套代码逻辑(尝试块映射、分配下级页表)能够适用于不同粒度。在64KB+3级页表配置下,PGDIR_SHIFT可能就是PUD_SHIFT,原本的alloc_init_pud函数可能实际上在操作硬件意义上的L1表。
性能考量是选择页面粒度的核心。我在做高性能网络或数据库存储优化时,会特别关注这一点:
- TLB命中率:TLB是页表的高速缓存。64KB页面能将TLB覆盖率提升16倍(相比4KB),极大减少TLB未命中导致的页表遍历开销,对访问大块连续内存的应用提升显著。
- 页表内存开销:64KB页面减少了页表级数(3级 vs 4级)和PTE条目数量,节省了用于存储页表的内存。但内部碎片可能增加。
- 块映射机会:更大的页面粒度通常意味着在更高级别有机会使用更大的块映射(如64KB下的512MB块),进一步减少页表遍历。
- 内存压力:在内存紧张的系统上,分配64KB的连续物理页可能比分配4KB页更困难,可能导致内存压缩或回收压力增大。
因此,在实际项目中,你需要根据工作负载的特点来权衡。通用Linux发行版通常选择4KB以获得最大的灵活性。而一些嵌入式或特定应用场景(如Android早期的大内存设备)可能会选择64KB来提升性能。理解内核这套动态适配的机制,就能让你在需要时,游刃有余地进行定制和优化。
