大家好~我是蟹老板,五一第一天,宅家里爽翻了!
从我写Bug这么多年的成功经验来看,Linux 内存里最绕人的,虚拟地址和物理地址在我心里排第一!——当年调试一个进程崩溃问题,查了三天三夜,最后发现是CR3寄存器没刷对,页表切换漏了一步,当月绩效直接全没!说多了都是泪啊。一、为啥要有虚拟地址?
操作系统的核心任务,说白了两样:管CPU、管内存。就像我们写的程序,物理上就那么一块儿,进程一多咋分?总不能让大家直接抢物理地址吧——那还不得打起来??
所以祖师爷们想了个招:让每个进程都觉得自己独占整个内存。
这就是虚拟内存的由来。每个用户进程都有一套完整的虚拟地址空间,互不干扰。
但有个问题啊,程序最终要存在物理内存里才能跑,虚拟地址和物理地址这俩“门牌号”,总得有个对应关系吧?这个对应过程,就叫地址转换。你可能会问,这转换谁来做?总不能让程序员手动算吧?还真不是,得靠硬件帮忙——内存管理单元,也就是MMU,这货就是专门干这个的。
给你们看个图,进程1和进程2,内核区域的虚拟地址都是K,映射到物理内存的同一个地方;但用户区域就不一样了,同一个虚拟地址,俩进程对应完全不同的物理地址。这就是虚拟内存的精髓,隔离又高效。
进程1和进程2的用户空间可以映射到不同的物理内存(VA1→PA1,VA2→PA2),但内核空间那块儿大家共享(VA K→PA K)。为啥?因为内核是公共厕所嘛,谁都能进,但规矩都一样。
那这个地址转换到底咋实现?硬件必须得给力——内存管理单元(MMU)就是干这活儿的。
| 要求 | 说明 |
|---|
| 特权模式 | 区分内核空间和用户空间,用户进程不能直接碰内核地址,碰了就崩 |
| 基址/界限寄存器 | 记着页表的起始地址,MMU找页表全靠它 |
| 地址转换 | 核心活儿,把虚拟地址翻译成物理地址 |
| 检查越界 | 转换的时候顺便查一查,有没有访问不该访问的内存 |
| 基址/界限寄存器特权操作指令 | 改基址得用特权指令,不然进程乱改,系统就炸了 |
| 触发异常 | 越权、越界了,就触发异常,通知操作系统来收拾烂摊子 |
| 异常处理特权操作指令 | 操作系统处理异常的入口,普通进程碰不了 |
有MMU撑腰,操作系统直接如鱼得水:
隔离用户态和内核态:用户程序想碰内核数据?门都没有。要走系统调用、中断、异常这些正道才能进高特权级。
逻辑地址→物理地址映射:靠基址/界限寄存器里存的页目录起始位置,加上指令里的逻辑地址,MMU硬给你算出物理位置。
每个进程独立虚拟空间:切换进程的时候,操作系统顺手把相关寄存器的段基址和范围给换了。MMU自然就加载新进程的页表。
延迟分配物理内存:你程序声明了1GB内存?先不急着真给你。等到你真去访问某个地址的时候,缺页中断一来,操作系统才慢悠悠地给你分配物理页。这叫“用到才给”,抠门但高效。
顺便说一句,页表里那些属性位还能帮你抓bug——权限校验失败、执行违规、越界访问,统统能触发异常。调试内存问题的时候,这玩意儿救过我的命。😭
今天的重点,就是地址转换的核心——页表映射机制。咱们先从最基础的分页说起。
二、分页
分页机制就是把物理内存、虚拟地址空间,都分成等长的“块”。虚拟地址里的块叫“页”,物理内存里的块叫“页帧”。
为啥要这么分?
还不是为了解决分段机制的外部碎片问题——当年用分段,内存用着用着就碎成一堆小碎片,没法用,分页一出来,这问题就解决了大半。
但分页也有坑啊,后面我会说到。
先看虚拟地址怎么转成物理地址,假设是32位地址,看下面这个图,是不是一下子就懂了?
虚拟地址被分成两部分:高31-X位是虚拟页号(VPN),低X位是偏移量(VA Offset);物理地址也一样,高31-X位是物理页帧号(PFN),低X位是偏移量(PA Offset)。重点来了,VA Offset和PA Offset是一样的,不用转换,只要通过VPN找到对应的PFN,加起来就是物理地址。
那VPN怎么找PFN?
靠页表啊!MMU就是查页表,找到对应关系,完成转换。这就是分页的核心逻辑,说难不难,说简单也不简单,当年我就是没搞懂页表存在哪,踩了第一个大坑。
2.1 页表存在哪里?
说出来你可能不信,页表全在物理内存里,记得刚工作那会,写一个简单的进程管理demo,没考虑页表占用,同时跑100个模拟进程,直接把内存吃满——页表这东西,占内存可真不少。
以32位地址、4KB分页为例,X是12,VPN就是20位。20位意味着有2^20个映射关系,每个映射条目4字节,算下来一个进程的页表就要4MB。100个进程,就是400MB,这还没算进程本身的内存,能不卡吗?
所以啊,所有页表都存在物理内存里——MMU转换地址的时候,得去物理内存里查页表。这也是分页的一个劣势,太占内存。后来才有了多级页表,结合缺页异常,能省不少内存,这个后面再讲。
2.2 页表长啥样?
页表的作用,就是存VPN到PFN的映射,所以核心字段肯定少不了这俩。但光有这俩还不够,还有几个关键字段,少一个都不行,我当年就因为漏看了“存在位”,调试了半天。
第一个是物理页帧号(PFN),这个不用多说,就是物理内存的“门牌号”。第二个是有效位(valid),判断这个页表条目是不是有效的,无效的话MMU会直接报错。第三个是存在位(present),说明这个页是不是已经加载到物理内存了——如果是0,说明页在磁盘上,MMU会触发缺页异常,让操作系统把页加载到内存。
还有特权标记,限制访问权限,比如用户进程不能访问内核页表;脏位(Dirty bit),页被写过就置1,后面页面交换的时候有用,避免重复写入磁盘。这些字段,不同架构下名字可能有点不一样,但是功能都基本差不多,记住就好。
2.3 进程切换时,页表怎么切换?
每个进程都有自己的页表,切换进程的时候,总不能把整个页表都换一遍吧?那也太慢了。其实很简单,就改一个寄存器——页表基址寄存器,告诉MMU“新进程的页表从这开始查”。
不同架构,这个寄存器的名字不一样,记好了,面试常考:X86里叫CR3,ARM-v7里是CP15协处理器的TTBR寄存器,ARM-v8里是系统寄存器TTBR。当年我就是切换进程时,忘了更新CR3,导致两个进程地址冲突,程序直接崩了,现在想起来都觉得蠢。
2.4 实际用的分页机制:多级页表
刚才说过,一级页表太占内存,一个进程就4MB,100个进程就400MB,这在当年内存不大的时候,根本扛不住。所以实际应用中,都用多级页表,我当年做嵌入式开发,内存只有64MB,全靠多级页表才撑住。
以二级页表为例,看下面这个图,MMU先靠页表基址寄存器和虚拟地址的PGD index,找到一级页表;再靠一级页表和PTE index,找到二级页表;最后靠二级页表和偏移量,找到物理地址。
多级页表的精髓,就是“按需分配”——一级页表必须在内存里,二级页表只有用到的时候,才会被加载到内存,触发缺页异常的时候分配。比如图里这个例子,一级页表4096项,每项4字节,才16KB,比一级页表的4MB省太多了。
2.5 多级页表的缺点
省内存是省了,但时间开销就上来了,这就是计算机里常说的“空间换时间,时间换空间”。
第一个坑是缺页处理的开销——二级页表不在内存里,MMU转换地址的时候,发现页表缺失,就触发缺页异常,操作系统要把页表加载到内存,这一过程要花时间,系统会卡顿一下。第二个坑是多次访问内存——MMU查一级页表、二级页表,每次都要访问物理内存,而物理内存的访问速度,比CPU寄存器、缓存慢多了。一级页表还好,多级页表查的次数越多,越慢。
我当年做实时系统开发,就因为多级页表的时间开销,导致系统实时性不达标,最后不得不调整页表层级,踩了不少坑才搞定。
2.6 TLB:解决多级页表慢的“救星”
TLB的工作流程很简单,我用大白话讲一遍:先从虚拟地址里拿VPN,查TLB里有没有这个VPN的映射;有,就直接拿PFN,组合成物理地址,这叫TLB命中;没有,就触发TLB未命中,要么硬件自动更新TLB,要么软件触发异常,查页表更新TLB,然后重新执行指令。
这里有个小细节,软件处理TLB未命中的时候,返回后要重新执行触发异常的指令,这样才能保证TLB命中,当年我写异常处理函数,忘了这一步,导致程序死循环,查了半天都没找到问题。
TLB是好,但也带来了新麻烦——进程切换的时候,TLB里的映射还是上一个进程的,怎么更新?TLB满了怎么办?用mmap映射的内存,unmap的时候怎么同步TLB?这些问题,后面我会专门写一篇文章讲,有兴趣的朋友可以蹲一下。
2.7 页表多大合适?
很多人问我,页表越大越好吗?
还真不是,大页表和小页表,各有各的优缺点,得看场景。
大页表的好处,一是省内存,和多级页表差不多;二是TLB命中率高,映射项少,TLB能存更多常用映射。但缺点也明显,容易产生内存碎片——一次性分配大块内存,哪怕用一点,剩下的也没法用,内存浪费严重。
小页表就反过来,内存碎片少,但页表项多,占内存多,TLB命中率低。所以操作系统默认用4KB页表,兼顾内存占用和TLB性能。
插一句,DPDK技术里,就用1GB大页,这样DPDK进程的页表映射,只需要一个TLB条目,彻底避免TLB未命中,速度特别快。我当年做网络开发,用DPDK的时候,就是因为没配置大页,导致性能上不去,后来改了大页,性能直接翻倍。
三、X86中的分页:四种模式
讲完通用的分页机制,再说说具体架构,先说X86吧,这是最常用的架构,面试考得也最多。
X86的分页机制,核心就是把线性地址映射到物理地址,还要管访问权限和缓存方式。
X86有四种分页模式,看下面这个图,具体取决于CR0、CR4、IA32_EFER MSR这几个寄存器的配置,有点绕,多看几遍也能记住。
简单说一下,记重点就行:CR0.PG=0,分页禁用,线性地址直接当物理地址用;CR0.PG=1、CR4.PAE=0,32位分页,支持4KB和4MB页;CR0.PG=1、CR4.PAE=1,PAE分页,物理地址能扩展到52位;再加上IA32_EFER.LME=1,就是四级分页,线性地址48位;再加CR4.LA57=1,就是五级分页,线性地址57位。
3.1 32-bit Paging:最基础的分页模式
32位分页模式,就是CR0.PG=1、CR4.PAE=0、IA32_EFER.LME=0,支持4KB和4MB页,咱们重点说4KB页,最常用。
看下面这个图,32位线性地址被分成三部分:最高10位是Directory(页目录索引),中间10位是Table(页表索引),最低12位是Offset(偏移量)。
工作流程和二级页表一样,CR3寄存器存着页目录(PDE)的物理地址,MMU靠CR3和Directory,找到页目录条目;再靠页目录条目和Table,找到页表(PTE)条目;最后靠页表条目和Offset,找到物理地址。是不是和前面讲的二级页表一模一样?
3.2 32-bit Paging页表结构:CR3、PDE、PTE
页表的结构,直接决定了地址转换的正确性,我当年就是因为没吃透PDE的PS位,配置错了页大小,导致程序崩了。看下面这个图,CR3、PDE、PTE的结构都在这了。
先看CR3寄存器,重点是[31:12]位,存着页目录的物理地址,还有PCD和PWT位,分别控制页面缓存禁用和写穿透策略,一般默认就行,不用瞎改。
再看PDE(页目录条目),重点是PS位(第7位),PS=0是4KB页,PS=1是4MB页,咱们重点看PS=0。Present位(第0位)很关键,=1表示页在内存里,=0表示不在,会触发缺页异常,把地址存到CR2寄存器。Read/write位(第1位)控制读写权限,User/supervisor位(第2位)控制访问等级,这些都要记牢。
最后看PTE(页表条目),[5:0]位和PDE一样,重点是Dirty位(第6位),写操作会置1;Global位(第8位),CR4.PGE=1时有效,防止常用页被TLB清除;[31:12]位是物理页帧地址,结合Offset就是最终的物理地址。
3.3 4-Level Paging:应对大内存的“神器”
32位地址空间,最大只能支持4GB物理内存,现在随便一台电脑都是8GB、16GB内存,32位分页肯定不够用,所以就有了四级分页。
四级分页支持48位线性地址、52位物理地址,页大小支持4KB、2MB、1GB,看下面这个图,47位线性地址被分成五部分:PML4(9位)、Directory Ptr(9位)、Directory(9位)、Table(9位)、Offset(12位)。
工作流程和32位分页差不多,还是从CR3出发,依次查PML4、Directory Ptr、Directory、Table,最后找Offset,不多说,重点是地址范围扩大了,能支持更大的内存。当年我第一次接触四级分页,就是因为项目里用了16GB内存,32位分页搞不定,不得不改成四级分页,调试了好久才搞定。
四、ARM架构分页机制
现在嵌入式开发、手机开发,全是ARM架构,必须得会。ARM和X86的分页机制,细节有差异,但核心逻辑都是一样的——通过页表映射,把虚拟地址转成物理地址。
4.1 ARMv7 Paging:32位ARM,二级页表为主
ARMv7是32位架构,支持1MB、64KB、4KB页,还有LPAE功能,能把物理地址扩展到40位。咱们重点说4KB页,未启用LPAE的情况,看下面这个图。
32位线性地址被分成三部分:最高12位是L1表索引,中间8位是L2表索引,最低12位是页面索引。地址转换流程和X86类似,TTBR寄存器的Translation base[31:14],加上L1表索引,找到一级页表;一级页表的Page table base address[31:10],加上L2表索引,找到二级页表;二级页表的Small page base address[31:12],加上页面索引,找到物理地址。
4.2 ARMv7 4KB Paging页表结构
ARMv7的页表结构,和X86有点不一样,看下面这个图,一级页表的核心是Page table base address[31:10],用来定位二级页表,还有Domain[8:5]位,用于设置内存域的访问权限,访问越权就会触发Permission fault。
二级页表的核心是小页面基址[31:12],结合偏移量找物理地址,还有XN位(执行禁用)、TEX位(外部缓存特性)、C/B位(内部缓存特性),这些都是控制缓存和执行权限的,当年我做嵌入式开发,就是因为TEX位配置错了,导致缓存异常,程序跑飞了。
还有AP位,控制访问权限,看下面这个表,不同的AP组合,访问权限不一样,我当年就是因为AP位配置错了,导致用户进程不能访问内存,调试了半天。
另外,S位是可共享属性,nG位是非全局标识,在TLB里有用,防止进程切换时,TLB里的全局页被清除,这些细节虽然小,但很重要,踩过坑就知道了。
4.3 ARMv8 Paging:64位ARM,分页更灵活
ARMv8的AArch64模式,是64位架构,支持4KB、16KB、64KB页,分页层级根据页大小变化:64KB页是三级页表,4KB、16KB页是四级页表。核心寄存器是TCR,用来配置页大小、缓存属性等,看下面这个图,TCR的bit位很多,重点记几个关键的。
T0SZ和T1SZ,控制TTBR0和TTBR1的内存区域大小,用公式2^(64-T0SZ)计算;TG0和TG1,控制页大小,0是4KB,1是64KB,2是16KB;IRGN和ORGN,控制内部和外部缓存属性;ASID是进程标识符,减少TLB刷新次数,这些都是重点。
以4KB页、48位虚拟地址为例,虚拟地址被分成五部分:级别0索引[47:39]、级别1索引[38:30]、级别2索引[29:21]、级别3索引[20:12]、OA[11:0],和X86的四级分页很像,地址转换流程也差不多,从TTBR_ELx出发,依次查各级页表,找到物理地址。
五、Kernel中的分页
Linux内核为了适配X86、ARM等多种架构,采用了五级分页模型,分别是PGD(页全局目录)、P4D(页4级目录)、PUD(页上级目录)、PMD(页中间目录)、PTE(页表),看下面这个图。
内核里有对应的宏定义,用来表示各级页表的偏移量,比如PGDIR_SHIFT、P4D_SHIFT等,不同架构下,这些宏的值不一样,内核会根据架构自动适配。如果架构只支持四级、三级分页,就会省略对应的宏定义,很灵活。
内核里操作页表,主要靠一系列宏和函数,比如pgd_offset、p4d_offset、set_pgd等,下面这个表,是常用的宏和函数,记下来,写内核代码的时候能省不少事。
| 宏或函数 | 说明 |
|---|
| pgd_offset(mm, addr) | 根据内存描述符mm和虚拟地址addr,找addr在PGD中的线性地址 |
| pgd_offset_k(addr) | 只用于内核页表,根据虚拟地址addr和init_mm,找PGD中的线性地址 |
| p4d_offset(pgd, addr) | 根据PGD和addr,找addr在P4D中的线性地址 |
| pud_offset(p4d,addr) | 根据P4D和addr,找addr在PUD中的线性地址 |
| pmd_offset(pud, address) | 根据PUD和addr,找addr在PMD中的线性地址 |
| pte_index(address) | 根据addr,找addr在PTE中的索引 |
| set_pgd(pgdp, pgd) | 向PGD写入指定值 |
| set_pte(ptep, pte) | 向PTE写入指定值 |
| pte_dirty(pte) | 读取PTE的Dirty标志 |
| pte_mkdirty(pte) | 设置PTE的Dirty标志 |
5.1 X86分页:内核中的实现,宏定义是关键
X86支持四种分页模式,内核里通过宏定义来区分,比如CONFIG_X86_5LEVEL,就是五级分页的开关。下面这段代码,是内核里X86分页的宏定义,我加了详细注释,你们可以看看,重点记PGDIR_SHIFT、PUD_SHIFT这些宏的数值,面试常考。
/* 开启五级分页的情况 */#ifdef CONFIG_X86_5LEVEL/* PGDIR_SHIFT决定顶级页表条目能映射的范围 */#define PGDIR_SHIFT pgdir_shift#define PTRS_PER_PGD 512表 */#define P4D_SHIFT 39#define MAX_PTRS_PER_P4D _P4D ptrs_per_p4d#define P4D_SIZE < P4D_SHIFT) /* P4D能映射的大小:2^39 = 512GB */#define P4D_MASK (~(P /* P4D掩码,用于提取P4D索引 */#define MAX_POSSIBLE_PHYSMEM_BITS 52 /* 最大物理地址位数:52位 */#else/* ,默认四级分页 */#define PGDIR_SHIFT 39 ML4,偏移量39 */#define PTRS_PER_PGD 512个条目 */#define MAX_PTRS_PER_P4D 1 /* P4D条目 */#endif/* CONFIG_X86_5LEVEL *//* 第三级页表PUD */#define PUD_SHIFT 30 /* PUD偏移量30,映射大小2^30=1GB */#defi_PER_PUD 512/* 第二级页表PMD */#define PMD_SHIFT 量21,映射大小2^21=2MB */#define PTRS_PER_PMD 512表的条目数 */#define PTRS_PER_PTE 512/* 大小和掩码 */#define PMD_SIZE (_AC(1< PMD_SHIFT)#define PMD_MASK (~(PMD_SIZE - 1))#define PUD_SIZE < PUD_SHIFT)#define PUD_MASK (~(PUD_SIZE - 1))#define PGDIR_SIZE << PGDIR_SHIFT)#define PGDIR_MASK (~(PGDIR_SIZE - 1)) (_AC(1, UL) (_AC(1, UL) <, UL) <计算各级页表的/* 每个页 21 /* PMD偏移ne PTRS折叠,只有1个 /* 每个PGD有512/* PGD对应四级分页的P不开启五级分页4D_SIZE - 1)) (_AC(1, UL) < 512#define PTRS_PER/* 五级分页中的第四级页
四级分页下,虚拟地址的划分和前面讲的一样,PGDIR_SHIFT=39(对应PML4),PUD_SHIFT=30(对应Directory Ptr),PMD_SHIFT=21(对应Directory),PAGE_SHIFT=12(对应Table),每个页表条目都是512个,很好记。
5.2 ARMv7分页:内核适配,二级页表封装
ARMv7是32位架构,硬件上是二级页表,但Linux内核采用三级页表结构,再封装成二级页表,这样能兼容内核的统一框架。下面这段代码,是ARMv7内核中页表的宏定义,我也加了注释。
/* PMD_SHIFT决定二级页表能映射的范围,PGDIR_SHIFT决定三级页表条目能映射的范围 */#define PMD_SHIFT 21#define PGDIR_SHIFT 21/* 计算PMD和PGD的大小、掩码 */#define PMD_SIZE << PMD_SHIFT) /* 2^21=2MB */#define PMD_MASK (~(PMD_SIZE-1))#define PGDI< PGDIR_SHIFT)#define PGDIR_MASK (~(PGDIR_SIZE-1))/* 各级页表的条目数 */#define PTRS_PER_PTE 个条目 */#define PTRS_PER_PMD 1 /* P#define PTRS_PER_PGD 2048 /* PGD有2048TE表的相关定义 */#define PTE_HWTABLE_PTRS (PTRS_PE#define PTE_HWTABLE_OFF (PTE_HWTABLE_PTRS * sizeof(p PTE_HWTABLE_SIZE (PTRS_PER_PTE * sizeof(u32))te_t))#defineR_PTE)个条目 *//* 硬件PMD折叠,只有1个条目 */ 512 /* PTE有512R_SIZE (1UL < (1UL
这里有个坑,你们可能会问,为什么PMD和PGD的偏移量都是21?
因为ARMv7硬件是二级页表,内核把PMD折叠了,所以PTRS_PER_PMD=1,相当于PGD直接指向PTE,变成了二级页表。
另外,ARMv7硬件页表没有Dirty位和Young位,内核是靠两套PTE表模拟的——一套是Linux PTE,一套是ARM硬件PTE。当Linux PTE设置为可写和Dirty时,硬件PTE设置为只读,写操作会触发缺页异常,内核在异常处理函数里,把Linux PTE标记为Dirty,再刷新TLB,这样就模拟出了Dirty位。
下面这段代码,是ARMv7内核中设置PTE的函数,用汇编写的,我加了详细注释,你们可以看看,重点理解两套PTE表的交互。
/* * cpu_v7_set_pte_ext(ptep, pte, ext) * 功能:设置二级页表条目 * 参数: * - ptep:指向二级页表条目的指针(Linux PTE在ptep指向地址,ARM硬件PTE在ptep+2048地址) * - pte:要写入的Linux PTE值 * - ext:扩展PTE位的值 */ENTRY(cpu_v7_set_pte_ext)#ifdef CONFIG_MMU str @ 把Linux PTE的值(r1)写入ptep指向的内存(r0) 清空r3的无关位,准备构建ARM硬件PTE bic r03f0 bic r3, r3, #PTE_TYPE_MASK orr r3(r2) orr r3, r3, #PTE_EXT_AP0 | 2 @ 设置AP0Linux PTE的bit4,设置TEX位 tst < 4 orrne r3, r3, #PTE_EXT_TEX(1) @ 模拟DirtPTE的APX位,设为只读 eor r1, r1, #L_PTE_DIRTY DONLY | L_PTE_DIRTY orrne r3, r3, #PTE_EXT_AP PTE的USER位,设置硬件PTE的AP1位 tst r1, #L_PTE3, r3, #PTE_EXT_AP1 @ 检查Linux PTE的X tst r1, #L_PTE_XN orrne r3, r3如果Young位清除且PTE有效,清空硬件PTE tst OUNG tstne r1, #L_PTE_VALID eorne r1 #L_PTE_NONE moveq r3, #0 @ 清空硬件PTE的值(r3)写入ptep+2048指向的内存 ARM( s048]! ) @ 前索引寻址,r0 = r0 + 2048 THUMB( add THUMB( str r3, [r0] ) ALT_SMP(W(nop)7, c10, 1) @ 清空数据缓存,刷新PTE#endiENDPROC(cpu_v7_set_pte_ext)f bx lr @ 返回) ALT_UP (mcr p15, 0, r0, c r0, r0, #2048 )tr r3, [r0, #2 @ 把ARM硬件PTE, r1, #L_PTE_NONE tstne r1, r1, #L_PTE_Y, #PTE_EXT_XN @ 模拟Young位:N位,设置硬件PTE的XN位_USER orrne rX @ 检查Linux tst r1, #L_PTE_Ry位:如果Linux PTE的Dirty位置位,设置硬件 r1, #1 <位,默认权限 @ 检查, r3, r2 @ 加入扩展位ext3, r1, #0x0000 @ r1, [r0]
这里插一句,ARMv7的协处理器指令,比如mcr p15, 0, r0, c7, c10, 1,是用来清空数据缓存的,当年我就是忘了加这条指令,导致TLB里的PTE没有更新,程序跑飞了。
5.3 ARMv8分页
ARMv8的AArch64模式,内核中的页表实现是用C语言写的,比ARMv7的汇编直观多了,支持4KB、16KB、64KB页,四级或三级页表。下面这段代码,是ARMv8内核中页表的宏定义,重点记PGDIR_SHIFT、PUD_SHIFT、PMD_SHIFT的计算方式。
#define VA_BITS (CONFIG_ARM64_VA_BITS) /* 虚拟地表的偏移量,n是页表级别(0~3) */#define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3)TE的条目数,PAGE_SHIFT是页大小偏移量(4KB是12) */#define PTRS_PER_PTE < (PAGE_SHIFT - 3))/* 二级页表PMD,只有页表层级大于2时才定义 */#if CONFIG_PGTABLE_LEVELS > 2#define PMD_SHIFT 4_HW_PGTABLE_LEVEL_SHIFT(2)#define PMD_SIZE < PMD_SHIFT)#define PMD_MASK (~(PMD_SIZE-1))#define PTRS_PER_PMD #endif/* 一级页表PUD,只有页表层级大于3时才定义 */#if CONFIG_PGTABLE_LEVELS > 3#define PUD_SHIFT ARM64_HW_PGTAB(1)#define PUD_SIZE < PUD_SHIFT)#define PUD_MASK (~(PUD_SIZE-1))#define PTRS_PER_PUD PTD,根据页表层级计算偏移量 */#define PGDIR_SHIFT GTABLE_LEVEL_SHIFT(4 - CONFIG_PGTABLE_LEVELS)#define PGDIR_SIZE (1, UL) << PGDIR_SHIFT)#define PGDIR_MASK (~(PGDIR_SIZE-1))#define PTRS< (VA_BITS - PGDIR_SHIFT))_PER_PGD (1 < (_AC ARM64_HW_PRS_PER_PTE#endif/* 顶级页表PG (_AC(1, UL) <LE_LEVEL_SHIFT PTRS_PER_PTE (_AC(1, UL) < ARM6 (1 </* 每个P址位数,默认48位 *//* 计算各级页
以4KB页、四级页表为例,PAGE_SHIFT=12,CONFIG_PGTABLE_LEVELS=4,计算一下:
PGDIR_SHIFT = (12-3)*(4-0)+3 = 39(对应0级转换表索引)
PUD_SHIFT = (12-3)*(4-1)+3 = 30(对应1级转换表索引)
PMD_SHIFT = (12-3)*(4-2)+3 = 21(对应2级转换表索引)
和硬件分页的定义完全一致,很好记。
ARMv8内核中,设置页表的函数都是C语言写的,比如set_pgd、set_pud、set_pmd、set_pte,核心就是把页表条目写入对应的内存地址,再加上内存屏障,确保指令执行顺序。下面这段代码,是set_pgd函数的实现,很直观。
/* * 向内存写入PGD页表 * 参数: * - pgdp:PGD页表的虚拟地址 * - pgd:要写入的PGD表项值 */staticinlinevoidset_pgd(pgd_t *pgdp, pgd_t pgd){ /* 如果是swapper页目录,写入swapper_pg if (in_swapper_pgdir(pgdp)) { set_swapper_pgd( } WRITE_ONCE(*pgdp, pgd); /* dsb(ishst); /* 数据内存屏障,确保写入完成 */ 指令内存屏障,确保后续指令看到最新的PGD */} isb(); /* 原子写入,避免并发问题 */pgdp, pgd); return;_dir */
ARM架构中配置了两个页表基址寄存器TTBR0与TTBR1,于Linux系统中分别对应用户模式与内核模式的地址映射。在内核模式下,地址的最高16位全部为1,而在用户模式下,该部分则全部为0。如图所示,TTBR1负责管理从0xffff000000000000至0xffffffffffffffff的地址范围,而TTBR0则对应从0x0000000000000000到0x0000ffffffffffff的区域。对于超出上述两个范围的其他地址访问行为,系统将触发异常处理机制。在MMU执行地址转换操作时,其选择使用TTBR1或TTBR1依据的是虚拟地址VA的第63位数值:若该位为1,则优先调用TTBR1;若为零,则转而使用TTBR1进行后续处理。
ARMv8架构中所使用的页面尺寸为4KB,并采用四级页表进行地址映射,关于虚拟地址的划分方式,相关内容已在3.4节中予以阐述。
Linux中所提及的0级、1级、2级和3级索引转换表等相关数据的定义,均源自ARM体系结构文档,具体内容如下所示。
#define VA_BITS (CONFIG_ARM64_VA_BITS) #define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3) #define PTRS_PER_PTE (1 << (PAGE_SHIFT - 3)) /* * PMD_SHIFT determines the size a level 2 page table entry can map. */ #if CONFIG_PGTABLE_LEVELS > 2 #define PMD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(2) #define PMD_SIZE (_AC(1, UL) << PMD_SHIFT) #define PMD_MASK (~(PMD_SIZE-1)) #define PTRS_PER_PMD PTRS_PER_PTE #endif /* * PUD_SHIFT determines the size a level 1 page table entry can map. */ #if CONFIG_PGTABLE_LEVELS > 3 #define PUD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(1) #define PUD_SIZE (_AC(1, UL) << PUD_SHIFT) #define PUD_MASK (~(PUD_SIZE-1)) #define PTRS_PER_PUD PTRS_PER_PTE #endif /* * PGDIR_SHIFT determines the size a top-level page table entry can map * (depending on the configuration, this level can be 0, 1 or 2). */ #define PGDIR_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(4 - CONFIG_PGTABLE_LEVELS) #define PGDIR_SIZE (_AC(1, UL) << PGDIR_SHIFT) #define PGDIR_MASK (~(PGDIR_SIZE-1)) #define PTRS_PER_PGD (1 << (VA_BITS - PGDIR_SHIFT))
PGDIR_SHIFT 宏用于标识第0级转换表的索引,当采用4KB页大小并进行4级页表映射时(PAGE_SHIFT = 12;CONFIG_PGTABLE_LEVELS = 4),经计算可知PGDIR_SHIFT宏的值为39,这与硬件分页机制中的定义相吻合。
PGDIR_SHIFT= (12-3)*(4-0)+3 = 39
CONFIG_PGTABLE_LEVELS用于标识所采用的页表层级数量,目前系统配置为4级页表,因此CONFIG_PGTABLE_LEVELS的值大于3。基于此设定,PUD被定义为:
#define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3) #define PUD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(1)
PUD_SHIFT 宏用于标识一级转换表的索引位置,通过相关计算可得其数值为30,这一结果与硬件分页机制中的定义相吻合。
PUD_SHIFT = (12-3)*(4-1)+3 = 30
CONFIG_PGTABLE_LEVELS用于标识所采用的页表层级数量,目前系统采用的是四级页表结构,因此CONFIG_PGTABLE_LEVELS的值大于2。基于这一设定,PMD被定义为:
#define ARM64_HW_PGTABLE_LEVEL_SHIFT(n) ((PAGE_SHIFT - 3) * (4 - (n)) + 3) #define PMD_SHIFT ARM64_HW_PGTABLE_LEVEL_SHIFT(2)
PMD_SHIFT 宏用于标识第三级转换表的索引,通过代入计算可得 PUD_SHIFT 的数值为21,这一结果与硬件分页机制中的定义相吻合。
PMD_SHIFT = (12-3)*(4-2)+3 = 21
截至目前,PGD、PUD、PMD的相关信息已明确,其他宏对应的数值也能够准确得出计算结果。
#define PGDIR_SIZE 512GB #define PTRS_PER_PGD 512 #define PUD_SIZE 1GB #define PTRS_PER_PUD 512 #define PMD_SIZE 2MB #define PTRS_PER_PMD 512
在ARMv8 Linux系统中,PGD、PUD、PMD以及PTE的实现并未采用汇编语言,而是通过C语言完成,相关函数的具体实现方式均为将对应表项的数据内容写入其所在地址位置。
/* 向内存下发PGD页表,入参分别为pgd页表虚拟地址和pgd表项*/ staticinlinevoidset_pgd(pgd_t *pgdp, pgd_t pgd) { if (in_swapper_pgdir(pgdp)) { set_swapper_pgd(pgdp, pgd); /* 将pgd写入swapper_pg_dir所指地址 */ return; } WRITE_ONCE(*pgdp, pgd); /* 将pgd写入pgdp所指地址 */ dsb(ishst); /* 数据内存屏障 */ isb(); /* 指令内存屏障 */ } staticinlinevoidset_pud(pud_t *pudp, pud_t pud) { #ifdef __PAGETABLE_PUD_FOLDED if (in_swapper_pgdir(pudp)) { set_swapper_pgd((pgd_t *)pudp, __pgd(pud_val(pud))); return; } #endif/* __PAGETABLE_PUD_FOLDED */ WRITE_ONCE(*pudp, pud); /* 将pud写入pudp所指地址 */ if (pud_valid(pud)) { dsb(ishst); isb(); } } staticinlinevoidset_pmd(pmd_t *pmdp, pmd_t pmd) { #ifdef __PAGETABLE_PMD_FOLDED if (in_swapper_pgdir(pmdp)) { set_swapper_pgd((pgd_t *)pmdp, __pgd(pmd_val(pmd))); return; } #endif/* __PAGETABLE_PMD_FOLDED */ WRITE_ONCE(*pmdp, pmd); /* 将pmd写入pmdp所指地址 */ if (pmd_valid(pmd)) { dsb(ishst); isb(); } } staticinlinevoidset_pte(pte_t *ptep, pte_t pte) { WRITE_ONCE(*ptep, pte); /* 将pte写入ptep所指地址 */ /* * Only if the new pte is valid and kernel, otherwise TLB maintenance * or update_mmu_cache() have the necessary barriers. */ if (pte_valid_not_user(pte)) { dsb(ishst); isb(); } }