Lazy loaded imageICS09 虚拟内存

type
status
date
slug
summary
tags
category
icon
password

虚拟内存

page icon
虚拟内存 (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): 操作系统仅在需要时才将页面加载到物理内存中,从而提高内存利用率。
page icon

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 执行地址翻译的过程如下:
  1. MMU 根据 VA 中的 VPN 查阅页表。
  1. 如果找到有效的 PTE,则提取 PPN,并与 VPO 组合成 PA。
  1. 如果未找到有效的 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)。事实上,多级页表利用了虚拟内存的极大稀疏性。即codedataheapstack都分散在很遥远的位置。

多级页表下的地址翻译

以四级页表的地址翻译为例:
  • 首先以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,不需要访存四次
page icon
TLB存储的是特定进程的虚拟地址与物理地址的映射关系,与进程相关,因此在切换进程时也需要处理TLB。主要有两种方法:
  1. 当进程切换时也切换TLB,此时需要重新写入整个TLB。
  1. 将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进程虚拟内存机制

page icon
虚拟内存管理是现代操作系统的核心机制之一,它通过抽象化物理内存资源,为每个进程提供独立且受保护的地址空间。本文将从内核空间与用户空间结构、内存映射机制以及关键系统调用三个维度,深入剖析Linux进程虚拟内存的实现原理。

内核空间(Kernel Space)组成

page icon

核心组件架构(仅了解)

组件
功能描述
内核代码
实现操作系统核心功能,包括进程调度、内存管理、设备驱动及文件系统等子系统
内核数据
维护内核运行时数据结构,如全局描述符表(GDT)、进程控制块(PCB)队列及内存管理单元(MMU)相关元数据
内存管理结构
进程级内存管理单元,包含: • 多级页表体系实现虚拟-物理地址转换 • mm_struct描述进程完整内存布局 • vm_area_struct链表管理虚拟内存区域
系统调用接口
提供用户态访问内核服务的标准化接口,涵盖文件操作、进程控制、网络通信等关键系统功能
内核堆栈
为每个进程维护独立的内核态执行上下文存储区,保障系统调用和中断处理的安全隔离
中断管理模块
处理硬件中断和软件异常,包含中断描述符表(IDT)和分层式中断服务例程(ISR)体系
设备驱动层
提供硬件设备抽象接口,实现存储设备、网络接口等外设的统一访问控制
动态内核模块
支持运行时加载的可插拔内核组件,实现功能扩展而不影响核心系统稳定性

关键数据结构解析

mm_struct 是内核中用于描述进程内存布局的核心数据结构。它包含了该进程的所有虚拟地址空间信息,例如代码段、数据段、堆、栈等区域的起始地址、大小、权限等。 主要字段:
mm_struct 作为进程地址空间描述符,通过以下机制实现精细内存控制:
  1. pgd字段指向进程的页全局目录(也即一级页表起始地址),在上下文切换时通过写入PTBR/CR3寄存器完成地址空间切换。
  1. mmap链表维护所有虚拟内存区域(vm_area_struct),每个VMA对应特定类型的内存段(如代码段、堆区等)。
  1. 边界字段(如start_brkbrk)动态跟踪堆内存扩展状态,支持malloc等内存分配操作。

用户空间(User Space)组成

内存区域分布

区域
功能特性
代码段
存储只读可执行指令,支持代码共享与写时复制(COW)保护
数据段
存放已初始化全局/静态变量,具有明确初始值的数据区
BSS段
零初始化的全局/静态变量存储区,通过匿名映射优化内存占用
堆区
动态增长的内存池,通过brk/sbrk系统调用实现运行时内存分配
共享库映射区
加载动态链接库(.so),实现代码共享与延迟绑定机制
栈区
后进先出(LIFO)结构,管理函数调用栈帧、局部变量及返回地址,支持递归调用
环境变量区
存储进程执行环境参数,包括命令行参数和环境变量键值对
文件描述符表
维护进程打开的文件资源句柄,实现I/O重定向和管道通信

内存映射机制

  1. 按需加载:虚拟页仅在发生访问时加载至物理内存,未使用的页保持磁盘驻留状态。
  1. 存储介质关联:虚拟页的起始值可以是硬盘中存放的code/data,也可以为空白(anonymous page,匿名页)。操作系统开机时,在磁盘开辟一个页全设为空白(zero page)。所有匿名虚拟页在第一次访问时都会被映射到该空白物理页,并写时复制(COW)。
  1. 共享内存优化:对于共享的对象(例如动态链接中的共享库),不同进程虚拟内存中的共享库区域,可以映射到物理内存中的相同区域,实现了数据/代码共享。若某个进程需要修改共享库的数据段,采用写时复制(Copy-On-Write,COW)机制,为该进程单独开辟新的物理内存空间,拷贝一份共享库数据由其独享。同时也需要修改该进程的页表(也即映射关系)。
  1. 进程创建:当进程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

典型应用场景

  1. 大文件处理
    1. 进程间通信

      动态内存分配

      page icon
      动态内存管理是现代程序设计的核心挑战之一,其效率直接影响系统性能和资源利用率。本节从分配器架构设计、碎片优化策略到常见内存错误,系统解析动态内存管理的关键技术。

      动态内存分配器体系架构

      动态内存分配器的主要职责是维护一个堆(heap),其由内存块组成,并响应内存分配和回收请求。
      动态内存分配器可分为两种实现范式:
      • 显式分配器:要求开发者主动管理内存生命周期,典型代表为C标准库的malloc/free函数
      • 隐式分配器:通过垃圾回收机制自动识别并回收不可达内存,常见于Java、Python等托管语言环境

      核心函数规范

      分配器性能评估

      核心性能指标

      1. 吞吐率(Throughput)
        1. 单位时间内完成的malloc/free操作数量,反映分配器响应速度。
      1. 内存利用率(Memory Utilization)
        1. 定义关键参数:
          • 累积有效载荷 , 前k次请求分配内存的总和
          • 堆空间开销 ,分配器维护的堆空间总量(单调非递减)
          • 内存开销 ,为了k个请求而必须占用的总开销(包括记录信息、对齐填充等)。
      1. 峰值内存压力(Peak)
        1. 到目前为止曾经出现的最大的累积负载。

      碎片 Fragmentation

      碎片指的是堆空间中既不用于存放有效载荷,也不用于存储记录信息的部分,主要有以下几种类型:
      碎片类型
      成因描述
      内部碎片
      分配块尺寸与请求不匹配,内存分配的最小单位不整除负载
      外部碎片
      所有空闲块大小之和足以容纳新请求,但没有一整块足够大的空闲块
      完美匹配(perfect fit):指碎片只因为对齐而产生。不存在除了对齐之外的额外开销。实际中不可能达到。

      实现动态内存分配器

      当我们实现自己的动态内存分配器时,需要考虑以下问题:
      1. 当数据块被 free 时,如何知道应释放多少内存。对此,我们可以在数据块的开头分配一个字存放malloc块的字节数,在这个字后面再存放有效载荷。free(p) 时只需要查看 (char*)p-8 处存放的值,即可知道应释放的字节数。
      1. 如何维护现在空闲的内存块,以便在 malloc 时查找空闲块。对此,主要有以下几种方案:

      1. 隐式空闲链表
        1. 每个内存块开头都存放块的长度和块的状态(是否已分配)。由于长度有对齐要求,块的长度的后几位总是0(例如,16字节对齐,则后4位是0)。因此,可以在块长度字的最后一个bit存放块状态,块头可以表示为(长度&是否已分配),二者通过按位与合并。
          在堆的开头/结尾,可以用(8&1)表示(正常情况不会出现(8&1))
          分配策略对比:
          策略
          描述
          优缺点
          适用场景
          First-fit
          遍历空闲块链表,选择第一个能满足需求的空闲块
          速度较快,空间效率较好
          通用场景
          Next-fit
          从上次 malloc 的块开始,遍历空闲块链表,选择第一个能满足需求的空闲块
          速度更快(因为忽略了前面的可能不够分配的内存块);但产生碎片更严重
          流式处理
          Best-fit
          遍历整个空闲块链表,找到最合适的内存块(大小最接近请求字节数)
          碎片最小化,提高内存利用率;但速度慢
          内存敏感型应用
          响应 malloc 请求时,查找链表并分配,当空闲块大小显著大于请求字节数时,需要进行 split(分割)。改变分配块头和分配块的下一块块头。
          响应 free 请求时,需要进行coalescing(合并)。查看释放块的下一个块,如果也是空闲块,就把两个空闲块合并。由于我们只在块头(header)存放块大小,只能获取下一个块的大小,无法合并上一个空闲块。解决方案:改单向块链表为双向块链表,在块尾(footer)也存放(长度&是否已分配)。
          然而引入块头和块尾后,使内部碎片现象更严重,需要更多的空间来存储块大小信息。解决方案:只在空闲块中有脚标。最终的方案为:
          • 空闲块:头标+脚标,存放(长度&是否已分配)
          • 已分配块:头标存放(长度&本块是否已分配&上一个块是否已分配)。被free后需要合并时,即可检索前面的块、后面的块是否已被分配。
      1. 显式空闲链表
        1. 每个空闲内存块不止存放块大小,还作为空闲块双向链表中的一个结点,存储 prevnext 两个指针,分别指向链表中的上一个和下一个空闲块。
          malloc 分割时,新的空闲块需要继承旧的空闲块在链表中的位置。
          free时,先合并,再把新的空闲块按策略放在链表的头/尾。如果前后有空闲块被本块合并,则需要将其从链表中删除。
          • LIFO策略:新空闲块放在链表头
          • FIFO策略:放在链表尾
          相比隐式空闲块链表的差异:
          • 分配时只需遍历所有空闲块而非所有块,速度更快
          • 增加了结构性开销
          • 分配和释放时需要链表操作,时间开销略微增加
      1. 分离式空闲链表(Segregated List)
        1. 每种大小的空闲块都有自己的链表。例如,16字节空闲块链表;32-48字节空闲块链表。分配时,从请求字节数对应的链表开始,逐步向上查找合适的空闲块。释放时,先合并,再根据合并后空闲块的大小,将其插入对应的链表中。分配时间代价大幅降低,近似对数复杂度。

      内存操作常见错误

      典型错误类型

      隐式内存管理:垃圾清理

      • 使程序员不需要显式地释放内存,由编译器来回收
      • 在Python、Java、Ruby等语言中常见
      • 对C/C++而言,变量是一个保守的垃圾收集器(作用域结束后回收)
      • Mask&Sweep 垃圾收集策略:
        • mark/搜索:所有引用关系构成一个图
        • sweep/清扫:清扫不在图中的结点
       
       
       
      Prev
      ICS08 异常控制流
      Next
      ICS10 系统级I/O
      Loading...
      Article List
      SunVapor的小站
      计算机系统导论
      文档