虚拟地址空间:从概念到内存管理的底层逻辑

发布于:2025-09-07 ⋅ 阅读:(17) ⋅ 点赞:(0)

虚拟地址空间:从概念到内存管理的底层逻辑

虚拟地址把物理内存的细节藏在内核后面。每个进程都看到一套独立、连续的地址。内核用页表和 MMU 建立映射,并用按需分配、写时复制和交换机制提升安全与效率。本文用清晰概念、可运行代码、流程图、表格与排查方法说明这套机制如何工作。

关键词:虚拟地址|虚拟地址空间|页表|MMU|VMA|COW|ASLR


目录

  • 概念与目标
  • 虚拟地址与虚拟地址空间
  • 地址转换:页表、MMU、TLB、缺页
  • 进程视角的内存布局:VMA 与权限
  • 写时复制(COW):父子进程为何地址相同内容不同
  • 动手实践:两段可运行示例
  • 工具与排查方法
  • 内核视角:mm_structvm_area_struct

概念与目标

  • 你会理解虚拟地址和物理地址的区别。你会知道进程为何看到独立地址空间。
  • 你会掌握地址转换的核心路径。你会认识页表、TLB 和缺页中断的作用。
  • 你会用示例代码验证“地址相同、内容不同”的现象。你会知道这就是 COW。
  • 你会用工具查看进程的映射与统计信息。你会有一套排查清单。

虚拟地址与虚拟地址空间

  • 虚拟地址(VA)是进程看到的地址。它是编号。它不直接指向内存条上的点位。
  • 物理地址(PA)是硬件的真实地址。用户态不会直接操作它。
  • 虚拟地址空间(VAS)是一段连续的地址范围。每个进程各有一份。内核把它映射到物理页。

价值很直接:

  • 安全。进程彼此隔离,越界会被阻止。
  • 简化。装载器以稳定布局组织段与库。
  • 效率。可以按需、延迟分配;可以共享只读段;可以换出到磁盘。

说明:32 位进程的理论空间是 4 GiB。64 位更大。实际可用范围由平台与内核设置决定。


地址转换:页表、MMU、TLB、缺页

  • 页(Page):内核以页为单位管理内存。常见大小 4 KiB。也有大页。
  • 页表(Page Table):记录“虚拟页”到“物理页框”的映射与权限。
  • MMU:CPU 的内存管理单元。执行 VA→PA 转换。
  • TLB:页表项缓存。命中快。不命中再查主内存中的页表。
  • 缺页中断:访问未映射或权限不符时触发。内核建立映射或终止进程。

地址转换流程(简化):

存在且权限允许
不存在或权限不符
按需分配/读入页面/修正权限
CPU发起虚拟地址VA
TLB命中?
得到物理地址PA
查页表
缺页中断/陷入内核
更新页表与TLB

进程视角的内存布局:VMA 与权限

典型布局(示意):

flowchart TB
  subgraph "进程虚拟地址空间"
    A[低地址\n代码 .text] --> B[只读数据 .rodata]
    B --> C[已初始化数据 .data]
    C --> D[未初始化数据 .bss]
    D --> E[堆 Heap → 向高地址增长]
    E --> F[mmap/共享库]
    F --> G[← 栈 Stack 向低地址增长]
  end
  • 这些区域在虚拟空间中连续。对应的物理内存不要求连续。
  • 内核用 VMA(虚拟内存区域)对象描述每段的起止、权限与来源。

常见区段对照:

区域 典型权限 说明
代码段 r-x 可执行,通常只读,可被多个进程共享
只读数据 r– 常量、字符串字面量
数据段 rw- 已初始化的全局/静态变量
BSS rw- 未初始化的全局/静态变量,按需置零
rw- malloc/new 申请的内存
映射区 视映射而定 文件映射、共享库、匿名映射
rw- 局部变量、调用帧、参数和环境

写时复制(COW):父子进程为何地址相同内容不同

fork() 之后,父子进程会共享大量相同的物理页。这些页暂时标记为只读。写入时会触发缺页。内核会为写入方复制一个新页。于是:

  • 地址打印看起来一样。
  • 内容更新彼此独立。
  • 复制发生在“真的需要写”的那一刻。

动手实践:两段可运行示例

环境:Linux 或 WSL。请安装 gcc

示例一:打印主要区域的地址

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_uninit;           // BSS
int g_init = 100;       // .data

int main(int argc, char *argv[], char *envp[]) {
    static int s_val = 10;           // .data
    const char *ro = "hello";       // .rodata

    void *code_addr = (void*)main;   // .text
    void *heap1 = malloc(16);
    void *heap2 = malloc(16);

    printf("code: %p\n", code_addr);
    printf("data.init: %p\n", (void*)&g_init);
    printf("data.uninit: %p\n", (void*)&g_uninit);
    printf("static: %p\n", (void*)&s_val);
    printf("heap1: %p\n", heap1);
    printf("heap2: %p\n", heap2);
    printf("ro string: %p\n", (void*)ro);
    printf("stack argv: %p\n", (void*)&argv);

    for (int i = 0; i < argc; ++i) {
        printf("argv[%d]: %p\n", i, (void*)argv[i]);
    }
    for (int i = 0; envp[i]; ++i) {
        if (i > 3) break; // 仅演示
        printf("env[%d]: %p\n", i, (void*)envp[i]);
    }

    free(heap2);
    free(heap1);
    return 0;
}

运行:

gcc addr_demo.c -O0 -g -o addr_demo && ./addr_demo a b
cat /proc/$$/maps | head -n 40
pmap $$ | head -n 20

示例二:验证 COW(地址相同,内容不同)

#include <stdio.h>
#include <unistd.h>

int gval = 100;

int main(void) {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        for (int i = 0; i < 3; ++i) {
            printf("child: pid=%d gval=%d &gval=%p\n", getpid(), gval, (void*)&gval);
            ++gval;
            sleep(1);
        }
    } else {
        for (int i = 0; i < 3; ++i) {
            printf("parent: pid=%d gval=%d &gval=%p\n", getpid(), gval, (void*)&gval);
            sleep(1);
        }
    }
    return 0;
}

观察映射:

cat /proc/$(pgrep -n a.out)/maps | sed -n '1,50p'

说明:ASLR 会让每次运行的地址不同。这是正常的安全特性。


工具与排查方法

# 查看映射与权限
cat /proc/$PID/maps | nl | sed -n '1,80p'

# 统计RSS/匿名页/共享页
cat /proc/$PID/smaps | awk '/^(Size|Rss|Pss|Shared|Private)/{print}' | head -n 60

# 观察系统整体内存与缺页
vmstat 1 5

# 只看进程的段分布
pmap $PID | head -n 30

常见观察点:

  • 长时间大量缺页要先看 vmstat 与负载。
  • 匿名页过多且无法共享要复查内存策略与映射方式。
  • I/O 峰值下出现 D 状态要结合 iostatdmesg

内核视角:mm_structvm_area_struct

每个进程的 task_struct 指向一个 mm_struct。它描述整段用户态虚拟地址空间。mm_struct 里保存 VMA 的链表与红黑树。两种结构支持遍历与快速查找。

字段节选(不同内核版本名称会有差异):

struct mm_struct {
    struct vm_area_struct *mmap;  // VMA 链表
    struct rb_root mm_rb;         // VMA 红黑树
    unsigned long task_size;      // 用户态空间上限
    unsigned long start_code, end_code;
    unsigned long start_data, end_data;
    unsigned long start_brk, brk; // 堆范围
    unsigned long start_stack;    // 栈顶虚拟地址
    unsigned long arg_start, arg_end;
    unsigned long env_start, env_end;
    // ... 其余统计、锁与页表信息
};

VMA 描述一段连续地址及其权限与来源:

struct vm_area_struct {
    unsigned long vm_start;
    unsigned long vm_end;         // 半开区间
    struct mm_struct *vm_mm;
    pgprot_t vm_page_prot;
    unsigned long vm_flags;       // 可读/可写/可执行/私有/共享
    unsigned long vm_pgoff;       // 文件偏移(页)
    struct file *vm_file;         // 文件映射(若有)
    const struct vm_operations_struct *vm_ops; // 缺页等回调
    // 链接指针与红黑树节点等
};

工作要点:

  • 缺页时,内核以命中的 VMA 为依据建立映射或拒绝访问。
  • VMA 数量多时,红黑树可以加速定位。

思考题:当一个进程第一次对共享只读页执行写入时,会发生什么?你可以结合“缺页中断”和“COW”来回答,并用 straceperf 做一次验证。