Skip to content

虚拟内存映射

CPU 访问内存必须经过 MMU 进行地址翻译,操作系统负责维护页表映射规则。内核和用户空间都需要通过虚拟地址访问物理内存,但两者的映射方式不同:内核使用线性映射直接访问物理内存,用户空间通过多级页表按需映射。

内核地址空间

内核虚拟地址空间的高位部分通过线性映射直接对应物理地址(虚拟地址 = 物理地址 + PAGE_OFFSET 偏移量),这段区域称为直接映射区。x86_64 上 PAGE_OFFSET0xffff888000000000,直接映射区覆盖所有可用物理内存。

直接映射区的好处是内核可以通过指针直接访问任意物理页,无需额外的页表查找。但直接映射区只映射了可用物理内存,物理地址空间中被硬件设备占用的 MMIO 空间(如 IO APIC、HPET、PCI 设备 BAR 空间)不参与线性映射。内核需要通过 ioremap 将这些 MMIO 区域映射到直接映射区之外的内核虚拟地址空间。

内核虚拟地址空间除了直接映射区外,还包括几个特殊区域:

  • vmalloc 区域:用于 vmalloc 分配,映射不一定物理连续的页框,通过独立的页表项映射。适用于分配大块内存(如内核模块加载、大型内核缓冲区),代价是每次访问需要额外的页表查找,且不能用于 DMA。
  • kmap 区域:用于临时映射高端内存页(物理地址超出直接映射范围的页),容量有限,主要用于中断上下文中访问单个页面。
  • 固定映射区:编译时确定的专用映射,用于内核特定用途(如临时页表、中断栈等)。

缺页异常

缺页异常(Page Fault)是虚拟内存机制的核心事件。当 CPU 访问一个虚拟地址时,如果页表项标记为不存在(present 位为 0)或权限不匹配,MMU 触发缺页异常,CPU 自动跳转到内核的缺页异常处理函数 do_page_fault

内核需要区分缺页异常的来源和类型:

合法缺页:进程通过 malloc/mmap 分配了虚拟内存但尚未访问,首次访问时触发缺页。内核分配物理页并建立映射,然后重新执行触发异常的指令。这是正常行为,称为按需分页(demand paging)。

写时复制(Copy-on-Write, COW):fork 创建子进程时,父子进程共享相同的物理页(页表项标记为只读)。当任一进程尝试写入时,触发缺页,内核复制该物理页给写入进程,并修改其页表项为可写。这避免了 fork 时复制全部内存的开销。

栈增长:进程的栈空间按需扩展,当栈指针超出当前栈页的映射范围时触发缺页,内核自动分配新的栈页并扩展栈的映射范围。栈的最大大小由 ulimit -s 限制(默认 8MB)。

非法访问:访问未映射的虚拟地址(如空指针解引用)、写入只读页面、超出栈大小限制等。内核向进程发送 SIGSEGV(段错误)信号终止进程。

do_page_fault 的处理流程:首先检查异常地址是否在内核空间或用户空间的合法范围内;然后查找该地址对应的 VMA(Virtual Memory Area,虚拟内存区域);根据缺页类型和 VMA 的属性决定是分配新页面、执行 COW 还是发送信号。

VMA 与 mmap

VMA(Virtual Memory Area)是进程虚拟地址空间中的一段连续区域,用 struct vm_area_struct 描述。每个 VMA 有起始地址、结束地址、访问权限(读/写/执行)和关联的操作函数。进程的虚拟地址空间由多个 VMA 组成,内核通过红黑树(按地址查找)和链表(按地址顺序遍历)管理所有 VMA。

c
struct vm_area_struct {
    unsigned long vm_start;           // 区域起始虚拟地址
    unsigned long vm_end;             // 区域结束虚拟地址
    struct vm_area_struct *vm_next;   // 链表下一个 VMA
    struct file *vm_file;             // 关联的文件(可为 NULL)
    unsigned long vm_flags;           // 权限标志(VM_READ, VM_WRITE, VM_EXEC)
    const struct vm_operations_struct *vm_ops; // VMA 操作函数
    struct anon_vma *anon_vma;        // 匿名页反向映射
    ...
};

/proc/<pid>/maps 可以查看进程的所有 VMA,每行对应一个 VMA,显示地址范围、权限、偏移量、关联文件等信息。

mmap 系统调用

mmap 是用户空间创建新 VMA 的主要接口,可以将文件映射到内存中,也可以分配匿名内存。

c
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

参数说明:addr 为期望的映射地址(通常传 NULL 由内核选择);length 为映射长度;prot 为访问权限(PROT_READ、PROT_WRITE、PROT_EXEC);flags 为映射标志(MAP_SHARED 共享映射、MAP_PRIVATE 私有写时复制映射、MAP_ANONYMOUS 匿名映射);fd 为文件描述符(匿名映射时传 -1);offset 为文件偏移量。

mmap 的两种典型用法:

文件映射:将文件内容映射到进程虚拟地址空间,访问该内存区域等同于读写文件。MAP_SHARED 模式下,对映射区域的修改直接反映到文件(多个进程共享同一映射);MAP_PRIVATE 模式下,修改触发 COW,不影响原文件。文件映射是页缓存的核心机制——内核将文件内容缓存到物理页中,多个进程映射同一文件时共享同一组物理页。

匿名映射:不关联任何文件,分配一块全零的虚拟内存。进程的堆(brk 扩展)和线程栈都是匿名映射。匿名映射的页面在被换出时写入 swap 交换分区。

共享内存

共享内存是进程间通信(IPC)最快的方式。两个进程通过 mmap 映射同一文件(或 /dev/zero)到各自的虚拟地址空间,内核让这两个映射指向同一组物理页。一个进程写入的数据,另一个进程可以立即看到,无需经过内核复制。POSIX 共享内存通过 shm_open + ftruncate + mmap 创建,System V 共享内存通过 shmget + shmat 创建。

Linux 提供了 memfd_create 系统调用创建匿名内存文件(不对应磁盘上的实际文件),配合 mmap 可以在进程间共享内存而不依赖 /dev/shm 或文件系统。这比传统方式更安全,避免了 /dev/shm 上的权限和命名冲突问题。