3.1 虚拟内存管理
物理内存
物理内存就是我们常说的内存条. 当我们想要运行一个程序时, 操作系统需要先把程序读到内存里, 才能让 CPU 执行. 在早期计算机中, 每一个程序各自使用物理内存的一部分. 但是这样会产生一些问题: 不安全和低效.
安全性
在 C 语言我们可以很简单地写出一份内存越界访问代码, 在早期直接面对物理内存编程越界访问更加简单. 如果有一个恶意应用去尝试越界访问的话很容易获取到其它应用的信息.
低效
在多用户多程序的情况下, 操作系统分时复用物理内存资源. 同一块物理内存空间在不同时刻被不同的进程使用. 当进程切换时, 操作系统需要将内存写入硬盘中, 然后从硬盘加载下一个进程的内存状态. 但是硬盘的访问速度比内存的访问速度慢太多了.
注意不要把手机经常说的内存弄错了. 不知道为什么有这么个说法, 手机的内存指的是主存大小, 运存才是内存.
虚拟内存
为了解决内存分配的问题, 虚拟内存出现了.
在虚拟内存中, 每一个程序都认为自己独占整个内存空间, 并且使用虚拟地址来访问内存, 这些虚拟地址会被硬件自动地翻译成真实地址.
CPU 支持虚拟内存功能, 新增虚拟地址空间
操作系统 配置并使能虚拟内存机制
所有软件 都使用虚拟地址而不是真实地址
地址翻译过程
有一个硬件叫 MMU(内存管理单元) , 专门负责处理 CPU 的内存访问请求. CPU 把虚拟地址给 MMU, MMU 就会根据一定的规则来翻译地址, 取出数据给 CPU.
这个"一定的规则"则是由操作系统来提供.
地址翻译机制
有两种策略, 分段和分页.
分段
分段就是把虚拟地址空间分成若干个 不同 大小的段, 同时物理内存也分成多个段.
- 虚拟地址
由段号和段内地址(偏移)组成
- 物理内存
分成若干个段, 形成一个段表. 一个段表项里有段号和起始地址, 本段长度.
相邻的段对应的物理内存可以不相邻.
为了能找到这个段表, 有一个寄存器用来存储段表的基址, 叫段表基址寄存器
.
翻译过程
比如一个虚拟地址段号为 1, 段内地址为 0x350.
当传过来一个虚拟地址时, 会拿到里面的段号, 这里是 1. MMU 会根据段表基址寄存器的值找到段表, 然后找到段号为 1 的段表项, 得到了段号 1 的起始地址. 假设是 0x0500000. 这个起始地址加上段内地址就是物理内存地址了. 也就是 0x0500350.
存在的问题
之前提到过相邻段对应的物理内存不一定是连续的. 这就产生了一个问题, 假如我前几个段已经占据了大部分了, 剩下的碎片虽然够用但是不连续, 新段应该怎么分配? 这就降低了内存的利用率.
分页
物理内存和虚拟内存都分成连续, 等长的页. 任意虚拟页可以对应映射到任意物理页.
- 虚拟地址
由页号和页内地址组成
- 物理内存
均分成连续等长的物理页, 一个物理页跟一个虚拟页对应, 形成一个页表.
操作系统会为 每一个 应用维护一个页表.
单页页表的问题
对于一个 32 位系统, 一个页 4K 大小, 页表项大小 4 字节, 那么它的页表大小是这样的
对于一个 64 位系统, 一个页 4K 大小, 页表项大小 4 字节, 那么它的页表大小是这样的
这太离谱了. 需要使用多级页表来减少空间占用.
如果某级页表中的某条目为空, 那么对应的下一级页表没有必要存在
实际应用的虚拟地址空间大部分都没有被使用, 因此无需分配页表
允许页表出现空洞
arm64 的四级页表
假设一个页的大小是 4KB, 那页内地址需要 12 位二进制数表示. 在 arm64 中虚拟地址有效寻址空间只有 48 位(也有一些系统是 52 位), 减去 12 位的页内偏移还有 36 位可以用来指向页表项.
arm64 采用四级页表的方式, 36 位均分成 4 份, 每份可以指向 512 个页表项.
虚拟地址由虚拟页号和页内偏移组成, 虚拟页号由各级页号组成. 四级页表的话就有 4 级页表, 分别是 0 级页表, 1 级页表, 2 级页表和 3 级页表.
0 级页表只有一个, 0 级页表的页表项由虚拟页号和对应的 1 级页表基地址组成. 其它级类似.
当传过来一个虚拟地址时, MMU 会取出虚拟页号, 从页表基址寄存器取出对应的页表基址(TTBR0_EL1 & TTBR1_EL1, 根据虚拟地址第 63 位选择, 如果是 0 则选择 TTBR0_EL1), 然后根据虚拟页号_0 找到对应的 1 级页表基址, 然后根据虚拟页号_1 找到对应的 2 级页表基址, 然后根据虚拟页号_2 找到对应的 3 级页表基址, 然后根据虚拟页号 3 找到物理页的起始地址, 最后这个起始地址加上页内偏移就是真实的物理地址了.
注意拿到的页表基址不能直接使用的(L0 级除外), 还需要左移 12 位. (左移页内偏移的位数)
arm64 的页表项组成
对于 3 级页表的页表项,
第 0 位是有效位, 表示该项是否有效.
第 1 位必须是 1
第 4 到第 2 位表示内存类型
第 7 到第 6 位表示读写权限位
第 9 到第 8 位是 Shareability field, 用于核间、核与设备间的共享
第 10 位是 AF(Access Flag), 若设为 0 则访问时发生异常, 供软件追踪内存访问情况
第 51 位是 DBM, 表示该对应的页是否修改, 1 为修改了.
第 53 位是 PXN 位, 为 1 表示 EL1 不能执行
第 54 位是 XN 位, 为 1 表示 EL0 不能执行
页表使能
在上电后, CPU 默认进入物理寻址模式, 由系统软件配置控制寄存器, 使能页表, 进入虚拟寻址模式.
- ARM64
SCTLR_EL1(System Control Register, EL1), 第 0 位(M 位)置 1, 即在 EL0 和 EL1 权限级使能页表
- x86_64
CR0, 第 31 位(PG 位)置 1, 使能页表
页表的代价
多级页表的设计减小了页表所占的空间, 但是增加了内存的访问次数. 四级页表查询一次就得访问 4 次内存. 必须加一个缓存.
TLB 缓存
TLB 在 CPU 内部, 可以把它当作一个哈希表, 就跟之前的单页表一样.
TLB 缓存了虚拟页号到物理页号的映射关系, 在地址翻译过程中, MMU 首先查询 TLB, 如果 TLB 命中了, 就不查询页表了; 如果 TLB 没命中, 查询页表.
按照缓存结构, TLB 也采用分级结构.
在 ARM64 或者 x86_64 中, TLB 由硬件管理; 在一些体系结构中(如 MIPS), TLB 由软件管理.
TLB 刷新
TLB 使用虚拟地址作为索引, 但是每一个应用的页表是不一样的, 因此切换页表时, TLB 需要全部进行刷新.
ARM64 的内核和应用程序使用不同的页表, 分别保存在 TTBR0_EL1 和 TTBR1_EL1, 这样系统调用的时候就不用切换页表导致刷新了.
x86_64 只有一个基地址寄存器(CR3)来保存页表基址, 内核映射到应用页表的高地址, 这样避免系统调用时 TLB 的刷新.
降低 TLB 刷新的开销
给不同的页表打上标签, TLB 缓存项也打上标签. 这样切换页表时不用刷新 TLB 了.
- x86_64 PCID(process context id)
PCID 存在 CR3 的低位中, 在 KPTI 使用后非常重要
KPTI, Kernel Page Table Isolation, 就是内核和应用不共享页表了, 防御Meltdown 攻击.
- ARM64 ASID(address space id)
OS 为不同进程分配 16 位长的 ASID, 将 ASID 填写在 TTBR0_EL1 的高 16 位, ASID 位数由 TCR_EL1 的第 36 位(AS 位)决定.
ASID 有 16 位, 因此一般的操作系统最多支持 65536 个应用同时运行