浅谈虚拟存储器及 Linux 的内存管理机制
下文中存储器(Memory)指主存,即内存。
虚拟内存
虚拟内存(VM):一种对存储空间的抽象。它也能存东西,而且它的大小一般可以远远超过内存条的实际容量。(最大取决于你的 CPU 的字长)
你可以把虚拟内存当作一种超大数组。当虚拟内存的占用超过物理内存大小时,虽然对于用户(进程)而言好像还能往里面放东西,但实际上这个时候,虚拟内存管理程序会把一些不常用的东西移动到更慢的存储介质(比如硬盘)中,从而腾出空间放新的东西。这种移动的过程称为页面置换,移动的策略称为置换策略,每次移动的最小单位称为虚拟页。后面详细说。
寻址方式
早期计算机采用物理寻址:
现代计算机采用虚拟寻址:
变化在于增加了 MMU(存储器管理单元),负责将 VA(虚拟地址) 转换为 PA(物理地址)。
MMU 寻址过程
TLB:页表缓存。一个从虚拟地址到物理地址的散列表,便于查询。
在 MMU 启用的情况下,CPU 访问只使用虚拟地址,MMU 根据虚拟地址查询物理地址。MMU 首先通过虚拟地址在 TLB 中查找物理地址,找到则返回;没有找到,就会通过虚拟地址从页表基地址寄存器(PTBR)保存的页表基地址开始查询多级页表,最终查询到找到相应表项,并将表项缓存到 TLB 中,然后返回物理地址。
1function mmu_trans(virtual_addr) {
2 let tlb : Map<VirtualAddr, PhysicalAddr> = this.tlb;
3 let physical_addr = tlb[virtual_addr]
4 if(NOT_FOUND != physical_addr){
5 return physical_addr;
6 }
7 physical_addr = PTBR.search(virtual_addr)
8 tlb[virtual_addr] = physical_addr;
9 return physical_addr;
10}
分页
虚拟内存和物理内存被分割为固定大小的片段,就像一本书被分成很多页。所以虚拟内存分割后就有了虚拟页,物理内存分割后就有了物理页(PP or PF,Physical Page or Page Frame)
物理页也叫页帧
**虚拟页(VP)**是主存和外存之间置换数据的最小单位。
也就是说,即便你只是读取了 1 个字节的数据,一旦引发页面置换,也必须得交换整整一页数据。
分页的大小如何确定?
拜托,不要啥都想要个计算公式!操作系统爱咋定咋定,一般是 4KB 的整数倍。Linux 默认是 4KB。
重要的是,物理页大小 == 虚拟页大小
扇区:由磁盘的物理特性决定。扇区是磁盘的**物理存储单元**。读写磁盘的最小单位是扇区。
簇/块(Block):文件系统的文件存储单元。(簇是微软家的说法,其实就是块)每个块由 $2^n$ 个扇区组成,具体多大文件系统自己定,但不能超过页的大小。(这也是磁盘是块设备的原因)读写文件的最小单位是块/簇。
而页是内存管理的概念。将一个文件读入内存,占用的最小单位是页。
对于任意虚拟页面,其状态有:
-
未分配:也即空闲的。
-
已分配:
-
未缓存:仅加载到磁盘
-
已缓存:已经加载到主存
-
SRAM 缓存:CPU 和主存之间的 L1、L2、L3 高速缓存
DRAM 缓存:主存中的缓存
直写(Write through):数据从 CPU 写入高速缓存,并写入低速缓存。即一致性更强,效率更低。
回写(Write back):数据从 CPU 写入高速缓存,当高速中数据被换出时,才写入低速缓存。一致性更弱,效率更高。
就
高速缓存=SRAM,低速缓存=DRAM
而言,通常两种策略都有采用。而对于高速缓存=DRAM,低速缓存=硬盘
而言,只采用回写法,因为直写太慢了!
Linux 的页设计
原始代码见:mm_types.h - include/linux/mm_types.h - Linux source code (v5.15.5) - Bootlin
Linux 内核用 page
结构体表示每个页框:
1struct page {
2 unsigned long flags;
3 atomic_t _count;
4 atomic_t _mapcount;
5 unsigned long private;
6 struct address_space *mapping;
7 pgoff_t index;
8 struct list_head lru;
9 void *virtual;
10}
-
flag
域用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。 -
_count
域存放页的引用计数。-1
表示不被引用。其它代码通过page_count()
函数进行检查。 -
virtual
域是页的虚拟地址。 -
mapping
指向页高速缓存。详情见 【内核】address_space与基树 - visayafan - 博客园 (cnblogs.com)
分区
Linux 内核把页划分为不同的区(zone),从而将性质相同的页放在一起。
一些硬件只能用某些特定的内存地址来执行 DMA(直接内存访问)。
一些体系结构的内存的物理寻址范围比虚拟寻址范围大得多。这样,就有一些内存不能永久地映射到内核空间上。
Linux 主要使用了四种区:
-
ZONE_DMA 这个区包含的页能用来执行 DMA 操作。
-
ZONE_DMA32 和 ZONE_DMA 类似,该区包含的页面可用来执行 DMA 操作;而和 ZONE_DMA 不同之处在于,这些页面只能被 32 位设备访问。在某些体系结构中,该区将比 ZONE_DMA 更大。
-
ZONE_NORMAL 这个区包含的都是能正常映射的页。
-
ZONE_HIGHEM 这个区包含“高端内存”,其中的页并不能永久地映射到内核地址空间。
Linux 内核直接映射空间最多映射 896M 物理内存,而现代计算机往往使用远大于此的内存,比如高达 1TB 内存。因此,通过高端内存相关设计,就能够间接访问到那些更大的内存。
相关源码见:mmzone.h - include/linux/mmzone.h - Linux source code (v5.15.5) - Bootlin
下图是 Linux 的内存组织。
-
task_struct
是进程表项即 PCB,每个进程一个 -
mm_struct mm
是虚拟内存。-
pgd
指向第一级页表(页全局目录)的基址 -
mmap
指向一个vm_area_structs
(内存分区)的链表
-
页表
页表是操作系统负责维护的,位于物理内存中的数据结构。由页表项(PTE,Page table entry)组成。
页表项
操作系统可以自由决定页表项的设计。
CSAPP 的页表项设计
在 CSAPP 一书的抽象中,每个 PTE 由 有效位 | 地址(页框号)
组成:
有效位(又称存在位)表示是否已缓存。
-
无效,无地址:表示未分配
-
无效,有地址:表示未缓存,数据在磁盘中。
-
有效,有地址:表示已缓存,数据在主存中。
MOS 的页表项设计
《现代操作系统》的页表项设计:
-
页框号:可以看作向物理内存的指针。
-
存在位(也称有效位、驻留位、中断位):表示是否已缓存(是否在主存中)。
-
保护位:表示访问权限。0 表示允许读写,1 表示只读。或者用三位,分别表示读、写、执行。
-
修改位(脏位):表示页面是否被修改过。从而决定是否要回写到磁盘。
-
访问位:在访问后设置。从而置换时,未访问过的优先换出。
-
缓存禁用位:例如特殊 I/O 设备,不应当被缓存,则应该设置此位。
如果页表页在内存中不连续存放,则需要对页表页进行索引。这个索引表称为页目录(Page Directory)
二级页表结构
-
页目录(每个进程一个):表项为页表 i 的地址
-
页表 i:表项为 PTE,包含页框号等,页框号指向物理内存。
-
物理内存
这种情况下,虚拟地址的构成是:页目录偏移 | 页表偏移 | 页内偏移
-
页目录偏移,确定了页目录表项
-
页表偏移,确定了页框号
-
页框号和页内偏移经过拼接,形成物理地址
二级页表最大可以表示 4G 的虚拟空间。所以目前主流采用的是四级页表。
运行 Linux 的 Core i7 的页表结构
Core i7 采用四级页表结构
位数 9 9 9 9 12
VPN1 VPN2 VPN3 VPN4(页框号) OFFSET(页内偏移)
VPN:虚拟页号
VPN1~4,每部分占 9 位。每一级的表项都是下一级的指针。最后到了四级页表,就查到了页框号,页框号(PPN),拼接页内偏移(PPO),就得到物理地址。
另外如果 TLB 命中,则可以直接得到 PPN,拼接 PPO,同样得到物理地址。
四级页表结构,一共 48 位,因此 Core i7 只支持 48 位(256TB)虚拟地址空间。
PPN+PPO 一共 52 位,因此 Core i7 只支持 52 位的物理地址空间。
拿到物理地址,并不是直接就去访存了。接下来是去访问 SRAM,未命中,才访问 DRAM。
Core i7 的第四级页表条目
此PTE 有三个访问位(权限位):
-
R/W 控制读/写权限
-
U/S 是否能在用户/内核模式中访问
-
XD 禁止从某些内存页取指令,防止缓冲区溢出攻击。
Linux 进程的虚拟空间
从右侧看,进程地址空间分为内核虚拟内存和进程虚拟内存。
-
内核虚拟内存包含内核中的代码和数据结构。
-
内核虚拟内存的某些区域被映射到所有进程共享的物理页面。例如,每个进程共享内核的代码和全局数据结构。
-
内核虚拟内存的其他区域包含每个进程都不相同的数据,比如说页表和内核在进程的上下文中执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。
-
-
进程虚拟内存 包括进程的代码和数据段、堆和共享库以及栈段。
有趣的是,Linux 也将一组连续的虚拟页面(大小等于DRAM)映射到相应的一组连续的物理页面,为内核提供了便利的方法来访问物理内存中的任何特定位置
而看地址空间的布局:
-
首先是用户栈,以 SP 为栈顶。其内容为栈帧。其大小随函数的调用和返回向下增减。
-
库内存映射区。存放诸如 GLIBC 的动态库,由动态链接器装载。可参考 链接、装载与库 — 动态链接 | Technology Blog (markrepo.github.io)
-
堆区。动态分配,向上增减。用于存一些独立于函数之间的状态。
-
bss。存放编译阶段无法确定的全局数据,包括未初始化的全局变量和静态变量。
-
data。存放编译时就能确定的全局数据,包括已初始化的全局变量和静态变量。可读可写。
-
rodata。存放只读数据,比如字符串常量、全局const变量。常量区在程序中大小确定;在进程中内存只读。
-
text。存放程序代码。
Linux 缺页异常处理
当引发缺页中断时,内核的_缺页处理程序_,进行如下处理:
-
(1)判断虚拟地址 A 合法性。缺页处理程序_搜索_内存分区链表(在链表中构建树来查找),如果不合法,触发一个段错误(经典 Segmentation Fault),终止进程。对应图中情况 1
-
(2)试图进行的内存访问是否合法?权限对吗?出错触发页错误,对应图中情况 2
-
(3)如果是对合法虚拟地址的合法操作,那么就选择一个牺牲(被置换)页面,如果这个牺牲页面被修改过,就将它交换出去。换入新的页面并更新页表。
Linux 的内存映射
Linux 说白了就是把一个虚存区域对应到一个文件(未必是磁盘文件,比如可以是显存)。支持如下文件:
-
普通文件映射:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行的目标文件。文件区被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
-
匿名文件映射:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页面表,将该页面标记为是驻留在内存中的。注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页。
匿名映射这里可能不太好理解。说白了就是把一块内存区域当作文件使用,而我们不需要实现创建这个文件。通过这个方法可以实现进程之间的共享内存。
GDT 和内存分段机制
段式内存管理
在早期内存较小的时候,可以通过物理地址直接访问内存。后来在 8086 处理器上,寄存器只有 16 位,而地址总线是 20 位。为了访问这个地址,就引入了 段寄存器,用于存放段基地址。
实际访存地址 = 段基地址 << 4 + 段内偏移
但后来有了保护模式,需要判断地址是否允许访问。全局描述符表用于存放段描述符,其中主要包含:
-
属性
-
段基地址
-
段界限
GDT
全局描述符表 (GDT) 是 x86 架构用于内存定界的数据结构,它的作用是描述内存分段。又称段描述符表。格式如下:
1struct gdt_entry {
2 uint16_t limit_low;
3 uint16_t base_low;
4 uint8_t base_middle;
5 uint8_t access;
6 unsigned limit_high: 4;
7 unsigned flags: 4;
8 uint8_t base_high;
9} __attribute__((packed));
而引入段描述符之后,段寄存器不再存放段基地址,而是存放选择器,相当于段描述符指针(索引)。指向 GDT 中的一个段描述符。当某个进程访存时,段描述符选择器指向该程序的段描述符,CPU 会检查内存地址是否在段描述符所限定的区域内,从而避免了非法访问。
GDT 的入口地址通过 GDTR 寄存器告知 x86 CPU.
LDT
相对于全局的 GDT,LDT 是局部描述符表,只对单个进程可见。操作系统可以自行决定 LDT 的存放位置,并告诉 CPU。每个任务最多可以拥有一个LDT。另外,每一个LDT自身作为一个段存在,它们的段描述符被放在GDT中。
类比多级分页,你可以理解 GDT 和 LDT 构成二级内存分段。
LDT 的入口地址放在 LDTR。
段选择器结构
段选择器是一个 16 位的结构体:
15 3 2 1 0
+--------------------------+----+-----+
| Index | TI | RPL |
+--------------------------+----+-----+
-
RPL:请求特权级别,通俗的讲我用什么权限来请求。
-
TI,Table Indicator:TI=0时,查 GDT 表;TI=1时,查 LDT 表。
-
Index:处理器将索引值 « 3 + DT表入口基地址(位于 LDTR/GDTR),就是要加载的段描述符。
参考
(1)Computer Systems: A Programmer’s Perspective, 3/E (CS:APP3e) p837
(2)《现代操作系统》3.3
(3)linux的高端内存是什么? - 知乎 (zhihu.com)
(4)页表及页表项的设计 - 存储模型(2) | Coursera
(5)[第9章 虚拟内存之Linux内存系统 | 野渡 的博客 (wendeng.github.io)](https://wendeng.github.io/2019/05/22/操作系统/第9章 虚拟内存之Linux内存系统/)