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 进程)完成。

  1. 页的类型

    • 匿名页:无对应文件(如堆、栈、mmap(MAP_ANONYMOUS)),换出时需写入交换区。
    • 文件页:对应磁盘文件(如代码段、数据段、mmap映射的文件),未修改的页可直接丢弃(需要时从文件重读),修改过的 "脏页" 需先写回文件再丢弃。
  2. 页的活跃度

    内核通过 "活跃链表"(active list)和 "不活跃链表"(inactive list)标记页的使用频率:

    • 被频繁访问的页在活跃链表(不会被换出)。
    • 很少访问的页在不活跃链表(优先被换出)。
    • 页被访问时,内核会将其从 "不活跃" 移到 "活跃" 链表(通过 PTE 的 "访问位" 判断)。
  3. 页置换算法

    Linux 采用改进的 LRU(最近最少使用)算法

    • 核心思想:优先换出 "最久未被访问" 的页。
    • 优化:通过 "二次机会算法"(clock 算法)避免频繁移动页链表,提高效率(检查页的 "访问位",若为 0 则换出,若为 1 则清零并移到链表尾部,给予 "二次机会")。

用户空间内存管理的核心机制

用户进程通过以下机制使用虚拟内存:

  1. 堆与栈的动态分配

    • :自动分配 / 释放,用于函数调用、局部变量(大小固定,超界会栈溢出)。
    • :手动分配 / 释放(如malloc/free),由brk()系统调用调整堆的边界(堆向高地址增长)。
  2. 内存映射(mmap)

    mmap()系统调用将文件或匿名内存映射到进程的虚拟地址空间,是高效的内存 I/O 方式:

    • 文件映射:将文件内容直接映射到虚拟内存,读写内存即读写文件(减少用户态与内核态的拷贝)。
    • 匿名映射:创建无对应文件的内存(如MAP_ANONYMOUS),用于进程间共享内存或大内存分配(比malloc更灵活)。
  3. 进程地址空间的描述:mm_struct 与 vm_area_struct

    每个进程的task_struct(进程控制块)中包含mm_struct结构体,描述其虚拟地址空间;mm_struct中通过链表管理多个vm_area_struct(VMA:虚拟内存区域),每个 VMA 对应地址空间的一个连续区域(如代码段、数据段、堆、栈、mmap 区等),记录区域的起始地址、大小、权限、映射类型等信息。

mmap()

mmap() 是核心系统调用之一,用于实现内存映射(Memory Mapping)。它让进程可以直接通过虚拟地址操作文件或共享内存,底层深度依赖虚拟内存的地址映射、缺页机制,是打通 "虚拟内存" 与 "文件 / 共享内存" 的关键桥梁。

mmap() 的本质是:在进程的虚拟地址空间中,创建一段虚拟内存区域,并建立它与 "磁盘文件" 或 "匿名内存" 的映射关系。后续进程访问这段虚拟地址时,就像操作普通内存一样读写数据,底层由虚拟内存机制自动处理物理内存的分配和数据加载。

作用

  1. 虚拟地址空间的 "扩展" 与 "映射"
    • 调用 mmap() 时,内核会在进程的虚拟地址空间中预留一段连续的虚拟地址(但不立即分配物理内存)。
    • 这段虚拟地址会与 "文件内容" 或 "匿名内存(全 0 初始化)"逻辑绑定,访问时通过虚拟内存的缺页机制动态加载物理页。
  2. 两种映射类型(按数据源分)
    • 文件映射:把磁盘文件的内容直接 "映射" 到虚拟地址。例如,映射一个 1GB 的大文件后,进程访问虚拟地址时,内核会自动从文件加载对应页到物理内存(类似 "把文件搬进内存操作")。
    • 匿名映射 :不关联实际文件,映射的是 "匿名内存"(初始全 0 的内存)。常见于:
      • 进程内部动态分配大内存(如 malloc 分配大内存时,底层可能调用 mmap 避免堆碎片);
      • 进程间共享内存(通过共享匿名映射实现高效 IPC)。
  3. 两种共享模式(按修改是否同步分)
    • MAP_SHARED(共享映射 ):
      进程对虚拟地址的修改会同步回磁盘文件(或对其他共享进程可见)。例如,多个进程映射同一个文件,一个进程修改虚拟地址,其他进程能立即看到变化,且数据会异步刷回磁盘(内核自动处理)。
    • MAP_PRIVATE(私有映射 ):
      修改采用 "写时复制 (Copy-On-Write)":进程修改虚拟地址时,内核会复制一份物理页,原页保持不变(不影响磁盘文件和其他进程)。典型场景是父子进程 fork:内存默认私有映射,写操作才复制,节省内存。

与虚拟内存的关联

  1. 地址隔离与共享的平衡
    • 每个进程的虚拟地址独立,但通过 mmap 的共享映射 ,多个进程可映射到同一块物理内存(或同一文件的物理页),实现高效进程间通信(IPC)。
  2. 内存的 "按需分配"
    • 虚拟内存的 "延迟加载" 特性,让 mmap 无需预先分配大量物理内存。只有进程实际访问虚拟地址时,才动态分配物理页并加载数据,极大优化内存利用率。
  3. 与页置换机制协同
    • 当物理内存不足时,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,功能是加载一个大文件到内存,进行简单处理后休眠:

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);  // 100MB
    if (!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.数据持久化与作用

  • 物理内存:
    • 易失性:断电丢失数据(程序运行的临时载体)。
    • 核心作用:作为程序和数据的实时运行载体,保证系统高效执行
  • 虚拟内存:
    • 易失性:数据可持久化到磁盘(换出的页会保存到磁盘,重启后可恢复)。
    • 核心作用:
      *
      1. 扩展内存空间:让程序突破物理内存容量运行(如 8GB 物理内存跑 16GB 程序);
        1. 隔离进程:虚拟地址空间独立,避免进程间地址冲突;
        1. 简化编程:程序无需关心物理内存的碎片化,只需操作连续虚拟地址。

物理内存不足:程序可能直接崩溃(无足够硬件内存运行),或强制杀死进程(OOM 杀手)。

虚拟内存不足:触发频繁页面置换(磁盘 IO 剧增),导致系统 "抖动"(性能严重下降,鼠标卡顿、程序无响应)。

物理内存是真实的硬件载体,决定系统的 "基础运行速度";虚拟内存是软件层的灵活扩展,解决 "容量不足" 和 "进程隔离" 问题。两者协同工作:物理内存负责 "实时高效运行",虚拟内存负责 "扩容和兜底",共同支撑现代操作系统的复杂需求。

相关推荐
此生只爱蛋9 小时前
【Linux】正/反向代理
linux·运维·服务器
qq_5470261799 小时前
Linux 基础
linux·运维·arm开发
zfj3219 小时前
sshd除了远程shell外还有哪些功能
linux·ssh·sftp·shell
废春啊9 小时前
前端工程化
运维·服务器·前端
我只会发热9 小时前
Ubuntu 20.04.6 根目录扩容(图文详解)
linux·运维·ubuntu
爱潜水的小L9 小时前
自学嵌入式day34,ipc进程间通信
linux·运维·服务器
保持低旋律节奏9 小时前
linux——进程状态
android·linux·php
zhuzewennamoamtf9 小时前
Linux I2C设备驱动
linux·运维·服务器
zhixingheyi_tian10 小时前
Linux 之 memory 碎片
linux
邂逅星河浪漫10 小时前
【域名解析+反向代理】配置与实现(步骤)-SwitchHosts-Nginx
linux·nginx·反向代理·域名解析·switchhosts