Linux虚拟内存
进程中只能访问虚拟内存地址,操作系统会把虚拟内存地址翻译成真实的内存地址。这种内存管理方式,称为虚拟内存。
Linux 虚拟内存(Virtual Memory)是 Linux 内核提供的一种内存管理抽象层,它通过将进程的虚拟地址空间与物理内存、磁盘存储(交换区 / 文件)动态映射,实现了 “进程独占内存” 的假象,同时优化了内存利用率和系统稳定性。
进程如果访问的是真实的物理地址,多个进程之间会互相干扰。例如一个程序在一个物理地址上写入了一个新值,可能将另一个程序在这个位置的内容擦掉,程序就会崩溃。
操作系统为每个进程分配一套独立的虚拟地址,每个进程访问自己的虚拟地址,互不干涉,操作系统会提供将虚拟内存地址和物理内存地址映射的机制。
作用
- 进程隔离
每个进程拥有独立的虚拟地址空间,进程间无法直接访问对方的内存(除非通过共享内存机制),确保了程序运行的安全性。 - 内存 “扩容”
程序不需要全部加载到物理内存即可运行,暂时不用的内存页可被换出到磁盘(交换区或文件),释放物理内存给其他进程使用。这使得系统能同时运行远超物理内存容量的程序。 - 灵活的内存保护
通过页表项的权限位(读 / 写 / 执行),内核可限制进程对内存的操作(例如:代码段设为 “只读 + 执行”,防止意外修改;数据段设为 “读写”,禁止执行,防御缓冲区溢出攻击)。
原理
Linux 虚拟内存的核心是 “地址映射”:进程使用的虚拟地址(VA)需通过页表转换为物理地址(PA)后,才能访问实际的物理内存。
-
地址空间划分
Linux 将虚拟地址空间分为用户空间和内核空间,两者严格隔离:- 用户空间:进程私有,存放进程的代码、数据、堆、栈等。32 位系统通常为 0~3GB,64 位系统(如 x86_64)为 0~0x7FFFFFFFFFFF(约 128TB)。
- 内核空间:所有进程共享,存放内核代码、数据、页表等。32 位系统为 3GB~4GB,64 位系统为 0xFFFF800000000000 以上(约 128TB)。
-
页与页框
虚拟内存和物理内存均按 “固定大小的块” 管理:- 虚拟内存的块称为页(Page),物理内存的块称为页框(Page Frame),两者大小通常相同(默认 4KB,可配置为 2MB、1GB 等大页)。
- 页是虚拟地址的最小单位,页框是物理内存的最小分配单位。
-
页表:虚拟地址到物理地址的映射表
页表是实现地址转换的核心数据结构,Linux 采用多级页表(减少内存浪费):- 多级页表的必要性:若用单级页表,32 位系统需 4GB/4KB=100 万页表项,每个项 4 字节,仅页表就需 4MB 内存;64 位系统则完全不可行。多级页表仅为 “实际使用的虚拟页” 创建映射,大幅节省内存。
- x86_64 的四级页表(以 4KB 页为例):
虚拟地址被拆分为 5 个部分(前 4 个为页表索引,最后为页内偏移):- 页全局目录(PGD)索引(9 位)
- 页上级目录(PUD)索引(9 位)
- 页中间目录(PMD)索引(9 位)
- 页表(PTE)索引(9 位)
- 页内偏移(12 位,对应 4KB=2^12)
地址转换流程:CPU 通过 CR3 寄存器找到当前进程的 PGD → 用 PGD 索引查 PGD 表,得到 PUD 表地址 → 用 PUD 索引查 PUD 表,得到 PMD 表地址 → 用 PMD 索引查 PMD 表,得到 PTE 表地址 → 用 PTE 索引查 PTE 表,得到物理页框号 → 页框号 + 页内偏移 = 物理地址。
-
页表项(PTE)的核心信息
每个 PTE(页表项)不仅记录物理页框号,还包含控制位:- 存在位(Present):1 表示虚拟页已映射到物理页框(在内存中);0 表示未映射或被换出到磁盘(此时需触发页错误)。
- 权限位(R/W/X):控制页的读写执行权限(如:R=1 可读写,X=1 可执行)。
- 脏位(Dirty):1 表示页被修改过(换出时需写回磁盘,而非直接丢弃)。
- 访问位(Accessed):1 表示页最近被访问过(用于页置换算法判断 “活跃度”)。
页错误(Page Fault):映射缺失时的处理
当进程访问虚拟地址时,若对应的 PTE “存在位为 0”(未映射或被换出),CPU 会触发页错误中断,由内核处理:
- 合法页错误(虚拟地址属于进程的地址空间,但未映射):
- 若页是 “未分配的匿名页”(如堆 / 栈的新页):内核分配物理页框,更新 PTE,重试访问。
- 若页是 “被换出的页”(在交换区):内核从交换区读回页到物理内存,更新 PTE,重试访问。
- 若页是 “文件映射页”(如 mmap 映射的文件):内核从文件读入页到物理内存,更新 PTE,重试访问。
- 非法页错误(虚拟地址不属于进程的地址空间,或权限不匹配):
内核向进程发送SIGSEGV(段错误)信号,进程通常会崩溃(如 C 语言中访问NULL指针)。
内存页的状态与置换机制
当物理内存不足时,内核需将 “不常用的页” 换出到磁盘(交换区),这一过程由页回收器(kswapd 进程)完成。
-
页的类型
- 匿名页:无对应文件(如堆、栈、mmap(MAP_ANONYMOUS)),换出时需写入交换区。
- 文件页:对应磁盘文件(如代码段、数据段、mmap映射的文件),未修改的页可直接丢弃(需要时从文件重读),修改过的 “脏页” 需先写回文件再丢弃。
-
页的活跃度
内核通过 “活跃链表”(active list)和 “不活跃链表”(inactive list)标记页的使用频率:- 被频繁访问的页在活跃链表(不会被换出)。
- 很少访问的页在不活跃链表(优先被换出)。
- 页被访问时,内核会将其从 “不活跃” 移到 “活跃” 链表(通过 PTE 的 “访问位” 判断)。
-
页置换算法
Linux 采用改进的 LRU(最近最少使用)算法:- 核心思想:优先换出 “最久未被访问” 的页。
- 优化:通过 “二次机会算法”(clock 算法)避免频繁移动页链表,提高效率(检查页的 “访问位”,若为 0 则换出,若为 1 则清零并移到链表尾部,给予 “二次机会”)。
用户空间内存管理的核心机制
用户进程通过以下机制使用虚拟内存:
-
堆与栈的动态分配
- 栈:自动分配 / 释放,用于函数调用、局部变量(大小固定,超界会栈溢出)。
- 堆:手动分配 / 释放(如malloc/free),由brk()系统调用调整堆的边界(堆向高地址增长)。
-
内存映射(mmap)
mmap()系统调用将文件或匿名内存映射到进程的虚拟地址空间,是高效的内存 I/O 方式:- 文件映射:将文件内容直接映射到虚拟内存,读写内存即读写文件(减少用户态与内核态的拷贝)。
- 匿名映射:创建无对应文件的内存(如MAP_ANONYMOUS),用于进程间共享内存或大内存分配(比malloc更灵活)。
-
进程地址空间的描述:mm_struct 与 vm_area_struct
每个进程的task_struct(进程控制块)中包含mm_struct结构体,描述其虚拟地址空间;mm_struct中通过链表管理多个vm_area_struct(VMA:虚拟内存区域),每个 VMA 对应地址空间的一个连续区域(如代码段、数据段、堆、栈、mmap 区等),记录区域的起始地址、大小、权限、映射类型等信息。
mmap()
mmap() 是核心系统调用之一,用于实现内存映射(Memory Mapping)。它让进程可以直接通过虚拟地址操作文件或共享内存,底层深度依赖虚拟内存的地址映射、缺页机制,是打通 “虚拟内存” 与 “文件 / 共享内存” 的关键桥梁。
mmap() 的本质是:在进程的虚拟地址空间中,创建一段虚拟内存区域,并建立它与 “磁盘文件” 或 “匿名内存” 的映射关系。后续进程访问这段虚拟地址时,就像操作普通内存一样读写数据,底层由虚拟内存机制自动处理物理内存的分配和数据加载。
作用
- 虚拟地址空间的 “扩展” 与 “映射”
- 调用 mmap() 时,内核会在进程的虚拟地址空间中预留一段连续的虚拟地址(但不立即分配物理内存)。
- 这段虚拟地址会与 “文件内容” 或 “匿名内存(全 0 初始化)”逻辑绑定,访问时通过虚拟内存的缺页机制动态加载物理页。
- 两种映射类型(按数据源分)
- 文件映射:把磁盘文件的内容直接 “映射” 到虚拟地址。例如,映射一个 1GB 的大文件后,进程访问虚拟地址时,内核会自动从文件加载对应页到物理内存(类似 “把文件搬进内存操作”)。
- 匿名映射:不关联实际文件,映射的是 “匿名内存”(初始全 0 的内存)。常见于:
- 进程内部动态分配大内存(如 malloc 分配大内存时,底层可能调用 mmap 避免堆碎片);
- 进程间共享内存(通过共享匿名映射实现高效 IPC)。
- 两种共享模式(按修改是否同步分)
- MAP_SHARED(共享映射):
进程对虚拟地址的修改会同步回磁盘文件(或对其他共享进程可见)。例如,多个进程映射同一个文件,一个进程修改虚拟地址,其他进程能立即看到变化,且数据会异步刷回磁盘(内核自动处理)。 - MAP_PRIVATE(私有映射):
修改采用 “写时复制(Copy-On-Write)”:进程修改虚拟地址时,内核会复制一份物理页,原页保持不变(不影响磁盘文件和其他进程)。典型场景是父子进程 fork:内存默认私有映射,写操作才复制,节省内存。
- MAP_SHARED(共享映射):
与虚拟内存的关联
- 地址隔离与共享的平衡
- 每个进程的虚拟地址独立,但通过 mmap 的共享映射,多个进程可映射到同一块物理内存(或同一文件的物理页),实现高效进程间通信(IPC)。
- 内存的 “按需分配”
- 虚拟内存的 “延迟加载” 特性,让 mmap 无需预先分配大量物理内存。只有进程实际访问虚拟地址时,才动态分配物理页并加载数据,极大优化内存利用率。
- 与页置换机制协同
- 当物理内存不足时,mmap 映射的页(尤其是文件映射的干净页,即未修改过的页)可被内核置换到磁盘(换出),需要时再换入,与虚拟内存的页回收机制(如 kswapd)无缝协同。
优缺点与典型场景
优点
- 减少数据拷贝:传统 read/write 需要 “内核缓冲区 ↔ 用户缓冲区” 两次拷贝,mmap 直接映射,只需一次(磁盘 → 物理内存,进程直接访问)。
- 高效 IPC:共享映射让进程间共享物理内存,无需额外数据传输。
- 延迟加载:大文件无需一次性加载到内存,访问时才加载对应页,节省内存。
缺点
- 内存对齐问题:映射按页(4KB)对齐,小文件可能浪费内存(如 10 字节文件会占满 4KB 页)。
- 同步风险:MAP_SHARED 的写回是异步的,若程序异常退出,未刷回的数据可能丢失(需手动调用 msync 确保同步)。
典型场景
- 大文件读写:数据库、日志系统常用 mmap 映射文件,直接操作虚拟地址高效读写。
- 进程间共享内存:多个进程映射同一块内存,通过虚拟地址直接通信(比管道、消息队列更快)。
- 替代 malloc 分配大内存:匿名映射避免堆内存碎片,适合分配 GB 级内存。
内核虚拟内存管理
内核自身的虚拟内存管理与用户空间不同,需满足 “高效访问物理内存” 和 “处理大内存” 的需求:
- 直接映射区:内核虚拟地址的低地址部分(如 32 位系统 3GB~3GB + 物理内存大小)直接映射到物理内存(VA = PA + 3GB),用于访问低地址物理内存(高效,无需复杂页表)。
- vmalloc 区:用于分配 “非连续的物理内存”(虚拟地址连续,物理地址可分散),适用于大内存分配(但访问效率低于直接映射)。
- 高端内存(32 位系统特有):物理内存超过 4GB 时,内核无法直接映射全部内存,需通过 “临时映射”(kmap)或 “永久映射”(kmap_atomic)访问,64 位系统因地址空间足够大,无此问题。
流程
假设我们有一个简单的 C 程序demo.c,功能是加载一个大文件到内存,进行简单处理后休眠:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main() {// 1. 代码段:程序指令(由内核加载)printf("程序启动\n");// 2. 数据段:全局变量(预先分配的虚拟地址)int global_var = 100;// 3. 堆:动态分配100MB内存(虚拟地址,未实际分配物理内存)char *heap_data = malloc(100 * 1024 * 1024); // 100MBif (!heap_data) {perror("malloc failed");return 1;}// 4. 栈:局部变量(自动分配的虚拟地址)int stack_var = 200;// 5. 访问堆内存(首次访问,触发页错误)for (int i = 0; i < 100 * 1024 * 1024; i += 4096) { // 按页访问(4KB/页)heap_data[i] = 'a'; // 写入第1、2、...、n页}// 6. 休眠,模拟程序运行中sleep(3600);free(heap_data);return 0;
}
编译后运行:gcc demo.c -o demo && ./demo,我们来跟踪这个程序从启动到运行的虚拟内存行为。
1:程序启动,创建虚拟地址空间
当我们执行./demo时,内核会为demo进程创建独立的虚拟地址空间,主要做三件事:
- 分配进程控制块(task_struct):包含进程的所有信息,其中mm_struct结构体专门描述虚拟地址空间。
- 创建虚拟内存区域(VMA):mm_struct通过链表管理多个vm_area_struct(VMA),每个 VMA 对应一块连续的虚拟地址,比如:
- 代码段(.text):存放程序指令,权限为 “只读 + 执行”;
- 数据段(.data):存放全局变量(如global_var),权限为 “读写”;
- 堆(heap):malloc分配的内存区域,权限为 “读写”;
- 栈(stack):存放局部变量(如stack_var),权限为 “读写”;
- 动态链接库(如libc.so):共享库的代码和数据。
此时这些 VMA 仅记录了虚拟地址范围和权限,并未分配物理内存(物理内存按需分配)。
- 初始化页表:为进程创建多级页表(如 x86_64 的四级级页表),但此时页表项(PTE)的 “存在位” 为 0(表示未映射到物理内存)。
步骤 2:首次执行代码,触发 “页错误” 加载代码页
程序启动后,CPU 需要执行printf(“程序启动\n”)指令,此时会发生:
- 访问虚拟地址:CPU 使用代码段的虚拟地址(如0x55f8a5a4a120),通过 CR3 寄存器找到进程的页表。
- 页表查找失败:页表中该虚拟地址对应的 PTE “存在位为 0”(未加载到物理内存),CPU 触发页错误中断。
- 内核处理页错误:
- 内核检查虚拟地址是否属于进程的 VMA(这里属于代码段 VMA,合法);
- 内核从磁盘的demo可执行文件中读取对应的代码页(4KB)到物理内存(如物理地址0x100000);
- 更新页表:将虚拟地址0x55f8a5a4a120对应的 PTE “存在位” 设为 1,记录物理页框号0x100000/4096 = 0x40(页框号 = 物理地址 / 页大小),权限设为 “只读 + 执行”。
- 重试访问:页错误处理完成后,CPU 重新执行指令,此时虚拟地址成功映射到物理内存,程序继续运行。
步骤 3:访问堆内存,动态分配物理页
当程序执行到heap_data[i] = ‘a’(首次访问堆内存)时,会经历更复杂的页管理:
- 虚拟地址合法但未映射:malloc(100MB)仅扩展了堆的 VMA(虚拟地址范围从0x55f8a5c4b000到0x55f8a62c6000),但未分配物理内存,PTE “存在位为 0”。
- 触发匿名页错误:访问heap_data[i]时,因 PTE 不存在触发页错误。内核发现这是 “匿名页”(无对应磁盘文件,属于堆),处理流程:
- 从物理内存中分配一个空闲页框(如0x200000);
- 更新页表:将虚拟地址0x55f8a5c4b000对应的 PTE “存在位” 设为 1,记录物理页框号0x200000/4096 = 0x80,权限设为 “读写”;
- 标记 PTE “脏位为 0”(尚未修改)。
- 写入数据,更新脏位:当heap_data[i] = 'a’执行时,CPU 修改物理页内容,硬件自动将 PTE 的 “脏位” 设为 1(标记该页被修改过)。
- 批量访问的页分配:循环中每访问一个新的 4KB 页(i += 4096),都会重复上述过程,直到 100MB 内存(共 25600 个页)全部被分配物理内存。
步骤 4:内存不足时,页被换出到交换区
假设此时系统物理内存已占满(比如同时运行多个类似程序),内核的页回收器(kswapd) 会启动,将不常用的页换出到磁盘:
- 判断页的活跃度:内核通过 PTE 的 “访问位” 判断页的使用频率。假设demo进程的堆内存中,前 1000 个页最近被访问过(访问位 = 1),后 24600 个页很久未访问(访问位 = 0)。
- 换出不活跃页:
- 对于后 24600 个 “匿名页”(堆内存),因 “脏位 = 1”(被修改过),内核将其内容写入交换区(如/swapfile),记录页在交换区的位置(偏移量);
- 更新页表:将这些页的 PTE “存在位” 设为 0,同时记录交换区偏移量(用于后续换入);
- 释放物理页框(0x200000等),分配给其他进程。
- 查看交换区使用:通过free -h可看到交换区(Swap)使用量增加了约 96MB(24600 页 ×4KB≈96MB)。
步骤 5:访问被换出的页,触发 “换入” 操作
若demo进程后续需要访问被换出的页(比如循环继续处理后 24600 个页):
- 触发页错误:访问虚拟地址时,PTE“存在位 = 0”,但内核发现 PTE 记录了交换区偏移量(合法的换出页)。
- 换入页到物理内存:
- 内核从交换区读取该页内容到新的物理页框(如0x300000);
- 更新页表:将 PTE “存在位” 设为 1,记录新物理页框号0x300000/4096 = 0xc0,清除交换区偏移量;
- 若此时物理内存仍满,内核会先换出另一个不活跃页(保持物理内存空闲)。
- 程序继续执行:页换入后,CPU 可正常访问该页,程序无缝继续运行。
步骤 6:程序结束,释放虚拟地址空间
当sleep(3600)结束,程序执行free(heap_data)和return 0时:
- 内核释放进程的所有 VMA,删除页表;
- 物理页框被标记为空闲(若为匿名页),或解除映射(若为文件页);
- 若有页被换出到交换区,其交换区空间会被标记为可复用。
总结:
- 地址隔离:demo进程的虚拟地址(如0x55f8a5c4b000)与其他进程完全独立,即使映射到同一物理页框(如共享库),也通过页表隔离权限。
- 按需分配:物理内存仅在首次访问虚拟页时分配(通过页错误触发),避免内存浪费。
- 内存扩容:通过交换区,程序可使用远超物理内存的空间(100MB 堆内存可能部分在物理内存,部分在交换区)。
- 高效管理:通过页表、VMA、页回收机制,内核高效管理内存的分配、回收和置换。
虚拟内存与物理内存的区别
1.本质
- 物理内存:实际存在的硬件内存(如 RAM 内存条),是计算机运行程序的直接载体。简单来说就是真实的 “临时存储硬件”,程序运行时的数据和指令直接存这里。
- 虚拟内存:操作系统通过软件技术模拟的内存管理机制,将磁盘空间 “伪装” 为内存扩展。通俗来说就是逻辑上的 “扩展内存”,把硬盘 / SSD 当 “后备仓库”,缓解物理内存不足。
2.物理载体与容量
- 物理内存:
- 载体:真实的 RAM 芯片(如 DDR4/DDR5 内存条),直接连接 CPU 总线。
- 容量限制:受硬件成本、主板内存插槽限制(常见 8GB、16GB、64GB 等)。
- 虚拟内存:
- 载体:硬盘 / SSD 上的一块空间(如 Windows 页面文件、Linux 交换分区)。
- 容量限制:理论上受磁盘剩余空间限制(如 1TB 硬盘可划分几十 GB 虚拟内存)
3.访问速度与延迟
-
物理内存:
- 访问方式:CPU 通过地址总线直接访问物理地址(无需额外转换)。
- 速度:极快(纳秒级,如 DDR5 延迟约 30-40ns)。
-
虚拟内存:
- 访问方式:进程访问虚拟地址→ 需通过页表映射转为物理地址(可能触发磁盘 IO)。
- 速度:慢(毫秒级,磁盘 IO 延迟约 1-10ms,比物理内存慢 几个数量级)。
4.管理与地址空间
- 物理内存:
- 管理方式:操作系统直接分配物理页框(实际内存块),管理 “硬件级” 内存。
- 地址空间:物理地址全局唯一(不同进程访问时需严格隔离,否则冲突)。
- 虚拟内存:
- 管理方式:通过 分页 / 分段机制 + 页表映射 管理:
- 虚拟地址划分为 “页”,映射到物理页或磁盘;
- 结合缺页中断(访问未加载页时触发)和页面置换(内存不足时换出页到磁盘)。
- 地址空间:每个进程有独立的虚拟地址空间(如 64 位系统的虚拟地址可高达 128TB),通过页表映射到物理地址,天然隔离进程。
5.数据持久化与作用
- 物理内存:
- 易失性:断电丢失数据(程序运行的临时载体)。
- 核心作用:作为程序和数据的实时运行载体,保证系统高效执行
- 虚拟内存:
- 易失性:数据可持久化到磁盘(换出的页会保存到磁盘,重启后可恢复)。
- 核心作用:
-
- 扩展内存空间:让程序突破物理内存容量运行(如 8GB 物理内存跑 16GB 程序);
-
- 隔离进程:虚拟地址空间独立,避免进程间地址冲突;
-
- 简化编程:程序无需关心物理内存的碎片化,只需操作连续虚拟地址。
-
物理内存不足:程序可能直接崩溃(无足够硬件内存运行),或强制杀死进程(OOM 杀手)。
虚拟内存不足:触发频繁页面置换(磁盘 IO 剧增),导致系统 “抖动”(性能严重下降,鼠标卡顿、程序无响应)。
物理内存是真实的硬件载体,决定系统的 “基础运行速度”;虚拟内存是软件层的灵活扩展,解决 “容量不足” 和 “进程隔离” 问题。两者协同工作:物理内存负责 “实时高效运行”,虚拟内存负责 “扩容和兜底”,共同支撑现代操作系统的复杂需求。