ICS09 虚拟内存
type
status
date
slug
summary
tags
category
icon
password
虚拟内存
虚拟内存 (Virtual Memory) 是现代操作系统中一项重要的内存管理机制。它为每个进程提供了一个独立的、线性的虚拟地址空间,并通过地址翻译机制将虚拟地址映射到物理内存。本文将详细介绍虚拟内存的工作原理、优势以及地址翻译的具体过程。
虚拟地址空间 (Address Space)
每个进程都拥有一个私有的虚拟地址空间,其中包含代码段、数据段、堆、共享库映射区域以及栈等 段(segments)。从进程的角度来看,它独占整个地址空间。进程使用虚拟地址访问内存。
操作系统通过地址映射机制,将不同进程的虚拟地址映射到不同的物理内存区域,从而实现进程间的内存隔离。
虚拟内存映射机制
虚拟内存的核心是地址翻译。操作系统内核维护一个页表 (Page Table) 数据结构,用于记录虚拟页 (Virtual Page, VP) 和物理页 (PP, 也称为页帧) 之间的映射关系。中央处理器 (CPU) 中的内存管理单元 (MMU,Memory Management Unit) 负责执行地址翻译。
虚拟内存的优势
高效利用物理内存: 从cache的角度看,虚拟内存机制是将内存作为磁盘的cache。操作系统将一部分虚拟地址空间的内容存储在磁盘上,并在需要时将其加载到物理内存中,从而有效地利用物理内存。
简化内存管理: 虚拟内存为每个进程提供了统一的线性地址空间,简化了内存分配和管理。程序员无需关心物理内存的具体布局。因而虚拟内存简化了链接和加载过程。
内存保护: 虚拟内存机制可以限制进程对内存的访问权限,防止进程访问其他进程的内存空间,从而提高系统的安全性。
虚拟内存作为缓存机制
虚拟内存可以被视为一种缓存机制。我们此时可以认为虚拟地址空间中的每个虚拟页都存放在磁盘上,物理内存作为磁盘的cache,磁盘上的虚拟页可以被缓存到物理内存上。这里的缓存块被称为页 (Page),通常大小为 4KB。Linux 系统也支持大页 (Huge Page),大小从 4MB 到 1GB 不等。
- 缓存策略: 虚拟内存采用全相联 (Fully Associative) 缓存策略,即(磁盘中的)虚拟页可以映射到任意物理页。操作系统需要管理虚拟页到物理页的映射关系,表现为在内核中存放数据结构页表(page table),每个条目(Page Table Entries,PTEs)记录映射关系,存放是否有效(valid)和对应的物理页索引。每个进程均有自己的页表。
- 缺页错误 (Page Fault): 当进程访问一个未缓存的虚拟页时,会触发缺页错误。操作系统会将所需的页面从磁盘加载到物理内存中。
- 按需调页 (Demand Paging): 操作系统仅在需要时才将页面加载到物理内存中,从而提高内存利用率。
VM Caching机制
虚拟地址总共有三种状态:未分配、已分配未缓存、已分配有缓存。进程通过虚拟地址访存时,MMU查找页表中对应该虚拟页的表项,如果找到valid位为1的对应表项,则说明命中(page hit),已经被缓存到物理内存中
如果无法找到,则说明miss,在这里称为页错误(page fault),也即该页还没有被缓存到物理内存中。MMU会触发一个异常,进入内核态处理。内核态的page fault handler在物理内存中选择一个空页/victim页,并将其替换为需要的页。
对于victim页,它会被转移到磁盘上一个特殊的分区(
swap
,大小约为内存的2倍,按页组织)。同时页表中对应victim页的页表项里,地址被换为磁盘中对应的页编号(SPN)。相当于被“冻结”。这种机制称为按需调页(demand paging)。
虚拟内存用于内存管理
虚拟内存简化了内存管理,主要体现在以下几个方面:
- 离散内存分配: 操作系统可以将连续的虚拟页映射到不连续的物理页,从而使物理页在物理内存中的组织更灵活(不必连续)。同时,虚拟地址空间的大小可以大于或小于物理内存的大小。
- 共享库: 我们在第七章中提到,操作系统可以只在物理内存中保留一份共享库的副本,而多个进程可以共享这同一个副本,节省内存空间。
- 简化链接和加载: 虚拟内存使得链接和加载过程更加简单,因为每个程序都使用相同的虚拟地址空间布局。加载器使用
execve
加载程序时,只需要重置页表(全部设为invalid)即可,按需调页机制会自动地将程序的数据和代码分时写入物理内存。
虚拟内存用于内存保护
页表项中包含权限位,用于控制对页面的访问权限 (读、写、执行)。此外,还有一个 Supervisor 位,用于限制用户态程序访问内核态内存。
VM地址翻译机制
仿照全相联cache的地址组织形式,我们可以得到VM下内存的组织形式:
- 虚拟地址 (VA): 由虚拟页号 (VPN) 和页内偏移 (VPO) 组成。
- 物理地址 (PA): 由物理页号 (PPN) 和页内偏移 (PPO) 组成。PPO 与 VPO 相同。
- 页表基址寄存器 (Page Table Base Register, PTBR / CR3): 存储页表的起始地址。注意,PTBE存储的是物理地址。
MMU 执行地址翻译的过程如下:
- MMU 根据 VA 中的 VPN 查阅页表。
- 如果找到有效的 PTE,则提取 PPN,并与 VPO 组合成 PA。
- 如果未找到有效的 PTE,则触发缺页错误。
快表 (TLB, Translation Lookaside Buffer)
页表存放在主存中,意味着CPU的每次访存都需要先从主存中提取相应页表项,获取物理页号,再从主存中提取出相应数据。由于程序的局部性,我们可以推测程序访问页表项也具有局部性,这使得我们可以建立cache机制来减少访存、加快页表的查询速度。TLB (Translation Lookaside Buffer) 是 MMU 中的一个高速缓存,用于存储最近使用的 PTE。由于PTEA优秀的局部性,TLB可以做的很小(存放几百到几千个页表项)。
- TLB 命中: MMU 直接从 TLB 中获取 PPN。
- TLB 未命中: MMU 需要访问内存中的页表,并将找到的 PTE 存储到 TLB 中。
多级页表
对于较大的虚拟地址空间,单级页表会占用过多的内存。例如对于支持48位虚拟地址空间的64位系统,页大小为4KB,每个页表项占据8B,则一页中可以存下512个页表项;而虚拟地址空间为
2^48
B,共有 2^36
个页面,一个进程的页表将占据 2^39
B (512GB),无法承受。页表占据过大的空间,直接原因是我们对于很多程序不会访问的页面都维护了页表项,造成了大量浪费。我们可以引入多级页表机制,将页表划分为多个层级,以此减少内存占用。
- 页表项: 在多级页表中,除了最后一级页表,其他级别的页表项存储的是下一级页表的起始物理地址。
- 地址翻译: 地址翻译过程需要多次访问内存来读取页表项,因此 TLB 对于多级页表尤为重要。
在上述的情形中,为了提高访存的效率,要保证每一级页表一张恰好占用一个页。每张页表只能用
2^9=512
个表项,故每级页表分管 9bit 地址空间。采用四级页表,页偏移12+页表9*4 = 48,即为现行x86-64的48位虚拟地址空间。一张一级页表占用4KB,只需要一二三四级页表各一张,页表共占用16KB,就能安排512个页(2MB);一二三级页表各一张,四级页表512张,共占用约2MB,就能安排256K个页(1G)。事实上,多级页表利用了虚拟内存的极大稀疏性。即
code
、data
、heap
、stack
都分散在很遥远的位置。多级页表下的地址翻译
以四级页表的地址翻译为例:
- 首先以VPN为索引访问TLB,查询TLB中是否已缓存了该页的PPN;若有(TLB hit),直接将PPN与VPO组合成物理地址即可;若没有(TLB miss),则需要进行四次页表项读取:
- 48位地址拆分为9+9+9+9+12,分别对应一、二、三、四级页表的索引和页偏移(VPO)。
- 通过PTBR访问一级页表,以头9位为索引,找到二级页表的物理起始地址
- 访问二级页表,以次9位为索引,找到三级页表的物理起始地址
- 访问三级页表,以再次9位为索引,找到四级页表的物理起始地址
- 访问四级页表,以最低9位为索引,找到对应页的物理PPN
- PPN加上12位页偏移PPO得到真实物理地址PA
由于四级页表需要访问PTE四次,所以TLB显得格外重要:TLB直接存放VPN到PPN的映射关系,TLB miss时才需要访问主存,进行四次访存(耗时长)。TLB hit时直接取到PPN,不需要访存四次
TLB存储的是特定进程的虚拟地址与物理地址的映射关系,与进程相关,因此在切换进程时也需要处理TLB。主要有两种方法:
- 当进程切换时也切换TLB,此时需要重新写入整个TLB。
- 将TLB中的表项与进程ID相关联,每个表项标示出它存储的是哪个进程的地址映射(也即下文中的ASID),查表时将VPN与ASID一并查找即可。此时进程切换时不需要重新写入整个TLB。
页表项 (PTE) 格式
PTE 包含以下信息:
字段名称 | 功能描述 |
虚拟页号(VPN) | 存储虚拟地址的页号部分,用于匹配虚拟地址查询是否命中。 |
物理页号(PPN) | 存储与虚拟页号对应的物理页号,用于生成物理地址。 |
有效位(Valid Bit) | 指示 TLB 表项是否有效。 |
权限位(Access Permissions) | 定义页面的访问权限,如读、写、执行等。 |
缓存属性(Cache Attributes) | 指定页面的缓存策略。 |
全局位(Global Bit) | 表示页面是否为全局页面(进程切换时无需刷新 TLB 表项)。 |
地址空间标识符(ASID) | 区分不同进程的地址空间,避免进程切换时刷新 TLB。 |
使用位(Accessed Bit) | 记录该页面是否被访问过,用于页面置换算法。 |
修改位(Dirty Bit) | 记录该页面是否被写入过,用于判断是否需要写回磁盘。 |
符号总结
VP = VPN (TLB Tag + TLB Index) + VPO
PP = PPN (Cache Tag) + PPO (Cache Index + Cache Offset)
VPO = PPO
使用相同的页内偏移 (VPO = PPO) 简化了地址翻译过程,并允许 MMU 并行地进行地址翻译和缓存访问。MMU拿到虚拟地址后,可以从VPO/PPO中直接得到Cache Index;可以同步启动页表查询和Cache访问,将Cache Index传递给Cache
Linux进程虚拟内存机制
虚拟内存管理是现代操作系统的核心机制之一,它通过抽象化物理内存资源,为每个进程提供独立且受保护的地址空间。本文将从内核空间与用户空间结构、内存映射机制以及关键系统调用三个维度,深入剖析Linux进程虚拟内存的实现原理。
内核空间(Kernel Space)组成
核心组件架构(仅了解)
组件 | 功能描述 |
内核代码 | 实现操作系统核心功能,包括进程调度、内存管理、设备驱动及文件系统等子系统 |
内核数据 | 维护内核运行时数据结构,如全局描述符表(GDT)、进程控制块(PCB)队列及内存管理单元(MMU)相关元数据 |
内存管理结构 | 进程级内存管理单元,包含:
• 多级页表体系实现虚拟-物理地址转换
• mm_struct 描述进程完整内存布局
• vm_area_struct 链表管理虚拟内存区域 |
系统调用接口 | 提供用户态访问内核服务的标准化接口,涵盖文件操作、进程控制、网络通信等关键系统功能 |
内核堆栈 | 为每个进程维护独立的内核态执行上下文存储区,保障系统调用和中断处理的安全隔离 |
中断管理模块 | 处理硬件中断和软件异常,包含中断描述符表(IDT)和分层式中断服务例程(ISR)体系 |
设备驱动层 | 提供硬件设备抽象接口,实现存储设备、网络接口等外设的统一访问控制 |
动态内核模块 | 支持运行时加载的可插拔内核组件,实现功能扩展而不影响核心系统稳定性 |
关键数据结构解析
mm_struct
是内核中用于描述进程内存布局的核心数据结构。它包含了该进程的所有虚拟地址空间信息,例如代码段、数据段、堆、栈等区域的起始地址、大小、权限等。
主要字段:mm_struct
作为进程地址空间描述符,通过以下机制实现精细内存控制:pgd
字段指向进程的页全局目录(也即一级页表起始地址),在上下文切换时通过写入PTBR/CR3寄存器完成地址空间切换。
mmap
链表维护所有虚拟内存区域(vm_area_struct
),每个VMA对应特定类型的内存段(如代码段、堆区等)。
- 边界字段(如
start_brk
、brk
)动态跟踪堆内存扩展状态,支持malloc
等内存分配操作。
用户空间(User Space)组成
内存区域分布
区域 | 功能特性 |
代码段 | 存储只读可执行指令,支持代码共享与写时复制(COW)保护 |
数据段 | 存放已初始化全局/静态变量,具有明确初始值的数据区 |
BSS段 | 零初始化的全局/静态变量存储区,通过匿名映射优化内存占用 |
堆区 | 动态增长的内存池,通过 brk /sbrk 系统调用实现运行时内存分配 |
共享库映射区 | 加载动态链接库( .so ),实现代码共享与延迟绑定机制 |
栈区 | 后进先出(LIFO)结构,管理函数调用栈帧、局部变量及返回地址,支持递归调用 |
环境变量区 | 存储进程执行环境参数,包括命令行参数和环境变量键值对 |
文件描述符表 | 维护进程打开的文件资源句柄,实现I/O重定向和管道通信 |
内存映射机制
- 按需加载:虚拟页仅在发生访问时加载至物理内存,未使用的页保持磁盘驻留状态。
- 存储介质关联:虚拟页的起始值可以是硬盘中存放的code/data,也可以为空白(anonymous page,匿名页)。操作系统开机时,在磁盘开辟一个页全设为空白(zero page)。所有匿名虚拟页在第一次访问时都会被映射到该空白物理页,并写时复制(COW)。
- 共享内存优化:对于共享的对象(例如动态链接中的共享库),不同进程虚拟内存中的共享库区域,可以映射到物理内存中的相同区域,实现了数据/代码共享。若某个进程需要修改共享库的数据段,采用写时复制(Copy-On-Write,COW)机制,为该进程单独开辟新的物理内存空间,拷贝一份共享库数据由其独享。同时也需要修改该进程的页表(也即映射关系)。
- 进程创建:当进程
fork
时,在物理内存中只需要为其新建页表等少量数据结构(复制父进程页表),同时为父子进程虚拟内存中的其他段设为只读、private COW状态。当写时,触发Page Fault - Gerneral Protection Fault,将该页面复制一份并设置为可写。
mmap系统调用
操作系统内核为用户态提供了物理内存接口,也即
mmap
函数。mmap
是一种内存映射机制,用于将文件或设备内容直接映射到进程的虚拟地址空间。通过 mmap
,可以在用户态直接访问文件内容,避免频繁的内核态与用户态切换,提升文件 I/O 的效率。函数接口规范
参数说明
参数 | 意义 |
addr | 指定映射在虚拟内存的起始地址(通常传 NULL ,由内核决定映射地址)。 |
length | 要映射的内存区域长度(以字节为单位,通常是页面大小的倍数)。 |
prot | 映射区域的保护属性,可取以下值(可组合):
PROT_READ :可读。
PROT_WRITE :可写。
PROT_EXEC :可执行。 |
flags | 映射选项,可取以下值(可组合):
MAP_SHARED :映射区域可被多个进程共享,修改会同步回文件。
MAP_PRIVATE :映射区域为私有,修改不会同步回文件(写时复制机制)。
MAP_ANONYMOUS :匿名映射,映射的内存不与文件关联(fd 应设为 -1)。 |
fd | 文件描述符,指向要映射的文件( MAP_ANONYMOUS 时忽略此参数)。 |
offset | 文件映射的偏移量,必须是页面大小的整数倍(通常为 4KB)。 |
返回值
- 成功:返回映射区域的起始地址。
- 失败:返回
MAP_FAILED
(通常值为(void *)-1
),并设置errno
。
典型应用场景
- 大文件处理:
- 进程间通信:
动态内存分配
动态内存管理是现代程序设计的核心挑战之一,其效率直接影响系统性能和资源利用率。本节从分配器架构设计、碎片优化策略到常见内存错误,系统解析动态内存管理的关键技术。
动态内存分配器体系架构
动态内存分配器的主要职责是维护一个堆(heap),其由内存块组成,并响应内存分配和回收请求。
动态内存分配器可分为两种实现范式:
- 显式分配器:要求开发者主动管理内存生命周期,典型代表为C标准库的
malloc
/free
函数
- 隐式分配器:通过垃圾回收机制自动识别并回收不可达内存,常见于Java、Python等托管语言环境
核心函数规范
分配器性能评估
核心性能指标
- 吞吐率(Throughput)
单位时间内完成的
malloc
/free
操作数量,反映分配器响应速度。- 内存利用率(Memory Utilization)
- 累积有效载荷 , 前k次请求分配内存的总和
- 堆空间开销 ,分配器维护的堆空间总量(单调非递减)
- 内存开销 ,为了k个请求而必须占用的总开销(包括记录信息、对齐填充等)。
定义关键参数:
- 峰值内存压力(Peak)
到目前为止曾经出现的最大的累积负载。
碎片 Fragmentation
碎片指的是堆空间中既不用于存放有效载荷,也不用于存储记录信息的部分,主要有以下几种类型:
碎片类型 | 成因描述 |
内部碎片 | 分配块尺寸与请求不匹配,内存分配的最小单位不整除负载 |
外部碎片 | 所有空闲块大小之和足以容纳新请求,但没有一整块足够大的空闲块 |
完美匹配(perfect fit):指碎片只因为对齐而产生。不存在除了对齐之外的额外开销。实际中不可能达到。
实现动态内存分配器
当我们实现自己的动态内存分配器时,需要考虑以下问题:
- 当数据块被
free
时,如何知道应释放多少内存。对此,我们可以在数据块的开头分配一个字存放malloc
块的字节数,在这个字后面再存放有效载荷。free(p)
时只需要查看(char*)p-8
处存放的值,即可知道应释放的字节数。
- 如何维护现在空闲的内存块,以便在
malloc
时查找空闲块。对此,主要有以下几种方案:
- 隐式空闲链表
- 空闲块:头标+脚标,存放(长度&是否已分配)
- 已分配块:头标存放(长度&本块是否已分配&上一个块是否已分配)。被free后需要合并时,即可检索前面的块、后面的块是否已被分配。
每个内存块开头都存放块的长度和块的状态(是否已分配)。由于长度有对齐要求,块的长度的后几位总是0(例如,16字节对齐,则后4位是0)。因此,可以在块长度字的最后一个bit存放块状态,块头可以表示为(长度&是否已分配),二者通过按位与合并。
在堆的开头/结尾,可以用(8&1)表示(正常情况不会出现(8&1))
分配策略对比:
策略 | 描述 | 优缺点 | 适用场景 |
First-fit | 遍历空闲块链表,选择第一个能满足需求的空闲块 | 速度较快,空间效率较好 | 通用场景 |
Next-fit | 从上次 malloc 的块开始,遍历空闲块链表,选择第一个能满足需求的空闲块 | 速度更快(因为忽略了前面的可能不够分配的内存块);但产生碎片更严重 | 流式处理 |
Best-fit | 遍历整个空闲块链表,找到最合适的内存块(大小最接近请求字节数) | 碎片最小化,提高内存利用率;但速度慢 | 内存敏感型应用 |
响应
malloc
请求时,查找链表并分配,当空闲块大小显著大于请求字节数时,需要进行 split(分割)。改变分配块头和分配块的下一块块头。响应
free
请求时,需要进行coalescing(合并)。查看释放块的下一个块,如果也是空闲块,就把两个空闲块合并。由于我们只在块头(header)存放块大小,只能获取下一个块的大小,无法合并上一个空闲块。解决方案:改单向块链表为双向块链表,在块尾(footer)也存放(长度&是否已分配)。然而引入块头和块尾后,使内部碎片现象更严重,需要更多的空间来存储块大小信息。解决方案:只在空闲块中有脚标。最终的方案为:
- 显式空闲链表
- LIFO策略:新空闲块放在链表头
- FIFO策略:放在链表尾
- 分配时只需遍历所有空闲块而非所有块,速度更快
- 增加了结构性开销
- 分配和释放时需要链表操作,时间开销略微增加
每个空闲内存块不止存放块大小,还作为空闲块双向链表中的一个结点,存储
prev
和 next
两个指针,分别指向链表中的上一个和下一个空闲块。malloc
分割时,新的空闲块需要继承旧的空闲块在链表中的位置。free
时,先合并,再把新的空闲块按策略放在链表的头/尾。如果前后有空闲块被本块合并,则需要将其从链表中删除。相比隐式空闲块链表的差异:
- 分离式空闲链表(Segregated List)
每种大小的空闲块都有自己的链表。例如,16字节空闲块链表;32-48字节空闲块链表。分配时,从请求字节数对应的链表开始,逐步向上查找合适的空闲块。释放时,先合并,再根据合并后空闲块的大小,将其插入对应的链表中。分配时间代价大幅降低,近似对数复杂度。
内存操作常见错误
典型错误类型
隐式内存管理:垃圾清理
- 使程序员不需要显式地释放内存,由编译器来回收
- 在Python、Java、Ruby等语言中常见
- 对C/C++而言,变量是一个保守的垃圾收集器(作用域结束后回收)
- Mask&Sweep 垃圾收集策略:
- mark/搜索:所有引用关系构成一个图
- sweep/清扫:清扫不在图中的结点
Prev
ICS08 异常控制流
Next
ICS10 系统级I/O
Loading...