内存管理-虚拟篇 I:页表操作与虚拟内存区域(VMA)深度解析

"虚拟内存是现代操作系统的基石,而 VMA 是进程地址空间的‘地图'。
本文将深入 x86 页表结构、VMA 设计原理,并对比 Linux 的实现细节,
构建一个支持按需分页、高半内核映射的工业级虚拟内存系统。"

引言:虚拟内存的核心价值

虚拟内存(Virtual Memory)是现代操作系统最伟大的抽象之一,它提供了三大核心能力:

  1. 地址空间隔离:每个进程拥有独立的 4GB 虚拟地址空间
  2. 内存保护:防止进程越界访问(如用户态访问内核)
  3. 内存扩展:通过交换(Swapping)支持大于物理内存的应用

而实现虚拟内存的关键组件是:

  • 页表(Page Table):硬件级地址翻译机制
  • VMA(Virtual Memory Area):软件级地址空间管理

本文将系统性地剖析这两个核心组件,从 x86 页表硬件细节到 VMA 软件设计,并深度对比 Linux 的实现,最终提供一个可运行的工业级框架。


第一章:x86 页表结构与硬件机制

1.1 32 位 x86 分页机制

x86 32 位分页采用两级页表结构

虚拟地址 (32 bits)
| 31        22 | 21        12 | 11        0 |
| 页目录索引   | 页表索引     | 页内偏移     |
| (10 bits)    | (10 bits)    | (12 bits)    |

地址翻译流程:

  1. CR3 寄存器:存储页目录基地址(物理地址)
  2. 页目录项(PDE):通过高 10 位索引,得到页表基地址
  3. 页表项(PTE):通过中间 10 位索引,得到物理页帧地址
  4. 物理地址 = 页帧地址 + 页内偏移

页目录项(PDE)结构(32 位):

| 位 | 名称 | 说明 | |—-|——|——| | 0 | P | 存在位:1=页在内存 | | 1 | R/W | 读写位:1=可写,0=只读 | | 2 | U/S | 用户/超级用户位:1=用户态可访问 | | 3 | PWT | 页写通(缓存策略) | | 4 | PCD | 页缓存禁用 | | 5 | A | 访问位(CPU 自动置 1) | | 6 | D | 脏位(仅页表项) | | 7 | PS | 页大小:1=4MB 大页(仅 PDE) | | 12-31 | 页表基地址 | 物理地址高 20 位(4KB 对齐) |

页表项(PTE)结构(32 位):

基本同 PDE,但 PS 位无效D 位有效(写时置 1)。

⚠️ 所有地址字段都是物理地址!虚拟地址仅用于索引

1.2 关键权限位详解

存在位(P)

  • P=0:触发 Page Fault(#PF)
  • 用途:实现按需分页、交换、内存保护

读写位(R/W)

  • R/W=0:只读页
  • 写时触发 Page Fault:实现写时复制(CoW)

用户/超级用户位(U/S)

  • U/S=0:仅内核态可访问(Ring 0)
  • U/S=1:用户态可访问(Ring 3)
  • 硬件自动检查:用户态访问 U/S=0 的页 → #PF

全局位(G)

  • G=1:TLB 条目在进程切换时不刷新
  • 用途:高半内核映射优化

1.3 TLB 与性能优化

TLB(Translation Lookaside Buffer)是页表的硬件缓存:

  • 命中:直接返回物理地址(1-2 时钟周期)
  • 未命中:遍历页表(100+ 时钟周期)

TLB 刷新时机:

  • 进程切换:加载新 CR3 → 刷新除 G=1 外的所有 TLB
  • 页表修改:需手动 invlpg 刷新特定地址

高半内核映射优化:

  • 内核映射设置 G=1
  • 进程切换时 TLB 保留内核条目 → 性能提升 30%+

第二章:页表操作 API 设计

2.1 页表数据结构

页目录与页表

// 页目录(1024 项)
typedef uint32_t pgd_t[1024] __attribute__((aligned(4096)));

// 页表(1024 项)
typedef uint32_t pte_t[1024] __attribute__((aligned(4096)));

页表项操作宏

// 提取物理地址
#define PTE_ADDR(pte) ((pte) & 0xFFFFF000)

// 设置页表项
#define SET_PTE(pte, addr, flags) ((pte) = (addr) | (flags))

// 权限标志
#define PAGE_PRESENT  0x001
#define PAGE_RW       0x002
#define PAGE_USER     0x004
#define PAGE_GLOBAL   0x100

2.2 核心页表操作函数

获取页表(按需创建)

pte_t *get_page_table(pgd_t *pgdir, uint32_t vaddr, bool create) {
    uint32_t pd_index = (vaddr >> 22) & 0x3FF;
    uint32_t *pde = &pgdir[pd_index];
    
    if (!(*pde & PAGE_PRESENT)) {
        if (!create) return NULL;
        
        // 分配新页表
        pte_t *new_pt = (pte_t*)buddy_alloc(0);
        memset(new_pt, 0, PAGE_SIZE);
        
        // 设置 PDE
        *pde = (uint32_t)new_pt | PAGE_PRESENT | PAGE_RW | PAGE_USER;
    }
    
    // 返回页表虚拟地址
    uint32_t pt_vaddr = (uint32_t)pgdir + (pd_index * PAGE_SIZE);
    return (pte_t*)pt_vaddr;
}

映射虚拟地址到物理页

int map_page(pgd_t *pgdir, uint32_t vaddr, uint32_t paddr, uint32_t flags) {
    pte_t *pt = get_page_table(pgdir, vaddr, true);
    if (!pt) return -1;
    
    uint32_t pt_index = (vaddr >> 12) & 0x3FF;
    uint32_t *pte = &pt[pt_index];
    
    // 检查是否已映射
    if (*pte & PAGE_PRESENT) return -1;
    
    // 设置 PTE
    *pte = paddr | flags;
    return 0;
}

取消映射

void unmap_page(pgd_t *pgdir, uint32_t vaddr) {
    pte_t *pt = get_page_table(pgdir, vaddr, false);
    if (!pt) return;
    
    uint32_t pt_index = (vaddr >> 12) & 0x3FF;
    uint32_t *pte = &pt[pt_index];
    
    if (*pte & PAGE_PRESENT) {
        // 释放物理页(可选)
        buddy_free(PTE_ADDR(*pte), 0);
        *pte = 0;
        
        // 刷新 TLB
        __asm__ volatile ("invlpg (%0)" :: "r"(vaddr) : "memory");
    }
}

获取物理地址

uint32_t get_phys_addr(pgd_t *pgdir, uint32_t vaddr) {
    pte_t *pt = get_page_table(pgdir, vaddr, false);
    if (!pt) return 0;
    
    uint32_t pt_index = (vaddr >> 12) & 0x3FF;
    uint32_t pte = pt[pt_index];
    
    if (!(pte & PAGE_PRESENT)) return 0;
    return PTE_ADDR(pte) | (vaddr & 0xFFF);
}

2.3 递归页表:页表自映射

问题:内核如何操作任意进程的页表?

  • 页表本身在虚拟内存中
  • 需要一种方式通过虚拟地址访问页表

解决方案:递归页表(Recursive Page Table)

  • 将页目录的最后一项(PDE 1023)指向页目录自身
  • 形成虚拟地址 → 页目录 → 页表 → 页 的映射

虚拟地址布局:

0xFFC00000 - 0xFFFFFFFF: 页表自映射区域
  - 0xFFFFF000: 页目录本身
  - 0xFFFFE000: 第 1022 个页表
  - ...

实现:

#define RECURSIVE_PDE_INDEX 1023
#define RECURSIVE_VADDR_BASE (RECURSIVE_PDE_INDEX * 4 * 1024 * 1024) // 0xFFC00000

void setup_recursive_mapping(pgd_t *pgdir) {
    pgdir[RECURSIVE_PDE_INDEX] = (uint32_t)pgdir | 
                                 PAGE_PRESENT | PAGE_RW | PAGE_GLOBAL;
}

// 通过虚拟地址访问页表
pte_t *get_pte_vaddr(pgd_t *pgdir, uint32_t vaddr) {
    uint32_t pd_index = (vaddr >> 22) & 0x3FF;
    uint32_t pt_index = (vaddr >> 12) & 0x3FF;
    return (pte_t*)(RECURSIVE_VADDR_BASE + 
                   (pd_index * 4096) + 
                   (pt_index * 4));
}

💡 Linux 使用 4 级页表,但递归映射思想相同


第三章:VMA 设计与进程地址空间

3.1 VMA 的核心作用

VMA(Virtual Memory Area)是进程虚拟地址空间的软件抽象

  • 描述连续虚拟区域:代码段、堆、栈、mmap 区域
  • 管理区域属性:权限、类型、文件映射
  • 支持高效查找:红黑树或链表

没有 VMA 的问题:

  • 无法知道虚拟地址属于哪个区域
  • 无法正确处理 Page Fault
  • 无法实现 munmap 系统调用

3.2 VMA 数据结构设计

基础 VMA 结构

#define VM_READ    0x00000001
#define VM_WRITE   0x00000002
#define VM_EXEC    0x00000004
#define VM_SHARED  0x00000008
#define VM_ANONYMOUS 0x00000010
#define VM_FILE    0x00000020

struct vm_area_struct {
    uint32_t vm_start;      // 虚拟地址起始
    uint32_t vm_end;        // 虚拟地址结束
    uint32_t vm_flags;      // 权限与类型标志
    struct file *vm_file;   // 如果是文件映射
    off_t vm_pgoff;         // 文件偏移(页对齐)
    
    // 链表指针(按地址排序)
    struct vm_area_struct *vm_next;
    
    // 红黑树指针(Linux 风格)
    struct rb_node vm_rb;
    struct vm_area_struct *vm_prev;
};

进程 PCB 扩展

typedef struct task {
    // ... 其他字段
    struct vm_area_struct *mm; // VMA 链表头
    struct rb_root mm_rb;      // VMA 红黑树根
    uint32_t heap_start;       // 堆起始地址
    uint32_t heap_top;         // 当前堆顶
    pgd_t *pgdir;              // 页目录
} task_t;

3.3 VMA 操作算法

查找 VMA(find_vma)

// 链表版本(O(n))
struct vm_area_struct *find_vma_list(task_t *task, uint32_t addr) {
    struct vm_area_struct *vma = task->mm;
    while (vma) {
        if (addr >= vma->vm_start && addr < vma->vm_end) {
            return vma;
        }
        vma = vma->vm_next;
    }
    return NULL;
}

// 红黑树版本(O(log n),Linux 风格)
struct vm_area_struct *find_vma_rb(struct rb_root *root, uint32_t addr) {
    struct rb_node *node = root->rb_node;
    struct vm_area_struct *vma = NULL;
    
    while (node) {
        vma = rb_entry(node, struct vm_area_struct, vm_rb);
        if (addr < vma->vm_start) {
            node = node->rb_left;
        } else if (addr >= vma->vm_end) {
            node = node->rb_right;
        } else {
            return vma;
        }
    }
    return NULL;
}

插入 VMA(insert_vma)

void insert_vma(task_t *task, struct vm_area_struct *new_vma) {
    // 1. 红黑树插入
    struct rb_node **link = &task->mm_rb.rb_node;
    struct rb_node *parent = NULL;
    
    while (*link) {
        parent = *link;
        struct vm_area_struct *vma = rb_entry(parent, struct vm_area_struct, vm_rb);
        
        if (new_vma->vm_end <= vma->vm_start) {
            link = &(*link)->rb_left;
        } else if (new_vma->vm_start >= vma->vm_end) {
            link = &(*link)->rb_right;
        } else {
            // 重叠!应合并或报错
            return;
        }
    }
    
    rb_link_node(&new_vma->vm_rb, parent, link);
    rb_insert_color(&new_vma->vm_rb, &task->mm_rb);
    
    // 2. 更新链表(Linux 双向链表)
    __vma_link_list(task, new_vma, parent);
}

合并相邻 VMA

void merge_vmas(task_t *task) {
    struct vm_area_struct *vma = task->mm;
    while (vma && vma->vm_next) {
        if (vma->vm_end == vma->vm_next->vm_start &&
            vma->vm_flags == vma->vm_next->vm_flags) {
            // 合并
            vma->vm_end = vma->vm_next->vm_end;
            __vma_remove(task, vma->vm_next);
        } else {
            vma = vma->vm_next;
        }
    }
}

3.4 进程地址空间布局

典型 32 位布局:

0xFFFFFFFF +------------------+
           |   内核栈         |
           +------------------+
           |   内核虚拟内存   | ← vmalloc
           +------------------+
           |   内核直接映射   | ← 物理内存线性映射
           +------------------+
0xC0000000 +------------------+ ← 内核空间起点
           |                  |
           |     空洞         |
           |                  |
0xBFFFFFFF +------------------+ ← 用户空间终点
           |   用户栈         | ← 高地址向下增长
           +------------------+
           |   内存映射区     | ← mmap
           +------------------+
           |   堆(Heap)     | ← brk 向上增长
           +------------------+
           |   数据段         |
           +------------------+
           |   代码段         |
0x00000000 +------------------+

关键常量:

#define KERNEL_VIRTUAL_BASE 0xC0000000
#define USER_VIRTUAL_MAX    0xBFFFFFFF
#define STACK_TOP           0xC0000000

第四章:按需分页与 Page Fault 处理

4.1 按需分页(Demand Paging)原理

传统分配问题:

  • 进程启动时分配所有页 → 内存浪费
  • 大部分页从未被访问

按需分页解决方案:

  • VMA 创建时不分配物理页
  • 首次访问时触发 Page Fault
  • 内核分配页并映射

优势:

  • 内存节省:仅分配实际使用的页
  • 启动加速:避免一次性分配大量页

4.2 Page Fault 处理流程

Page Fault 错误码:

| 位 | 说明 | |—-|——| | 0 | 0=不存在的页,1=保护错误 | | 1 | 0=读错误,1=写错误 | | 2 | 0=内核态,1=用户态 | | 3 | 1=保留位错误 | | 4 | 1=指令获取错误 |

处理逻辑:

void page_fault_handler(registers_t *regs, uint32_t error_code) {
    uint32_t fault_addr;
    __asm__ volatile ("mov %%cr2, %0" : "=r"(fault_addr));
    
    // 1. 检查是否用户地址
    if (fault_addr >= USER_VIRTUAL_MAX) {
        // 内核 Page Fault → 严重错误
        panic("Kernel page fault at 0x%x", fault_addr);
    }
    
    // 2. 查找 VMA
    task_t *task = current_task;
    struct vm_area_struct *vma = find_vma(task, fault_addr);
    if (!vma) {
        // 段错误
        send_sigsegv(task);
        return;
    }
    
    // 3. 检查权限
    if ((error_code & 0x2) && !(vma->vm_flags & VM_WRITE)) {
        // 写保护错误
        send_sigsegv(task);
        return;
    }
    
    // 4. 分配物理页
    void *page = buddy_alloc(0);
    if (!page) {
        // 内存不足
        send_sigkill(task, SIGKILL);
        return;
    }
    
    // 5. 映射到虚拟地址
    uint32_t vaddr = fault_addr & ~0xFFF;
    map_page(task->pgdir, vaddr, (uint32_t)page, 
             PAGE_PRESENT | PAGE_RW | PAGE_USER);
    
    // 6. 如果是文件映射,加载内容
    if (vma->vm_file) {
        load_file_page(vma, vaddr);
    } else {
        // 匿名页:清零
        memset((void*)vaddr, 0, PAGE_SIZE);
    }
}

4.3 文件映射的按需加载

mmap 文件映射流程:

  1. mmap 创建 VMA(标记 VM_FILE
  2. 首次访问触发 Page Fault
  3. 内核从文件读取对应页到物理页
  4. 映射并返回

实现:

void load_file_page(struct vm_area_struct *vma, uint32_t vaddr) {
    // 1. 计算文件偏移
    off_t offset = vma->vm_pgoff + ((vaddr - vma->vm_start) >> PAGE_SHIFT);
    
    // 2. 读取文件
    char *buffer = (char*)vaddr;
    vfs_pread(vma->vm_file, buffer, PAGE_SIZE, offset * PAGE_SIZE);
    
    // 3. 如果是可执行文件,可能需要重定位(简化版忽略)
}

第五章:高半内核映射与进程切换优化

5.1 高半内核映射设计

问题:每次进程切换都要刷新 TLB

  • 所有 TLB 条目失效 → 性能下降 30%+

解决方案:高半内核映射

  • 低地址(0-0xBFFFFFFF):用户空间(每个进程独立)
  • 高地址(0xC0000000-0xFFFFFFFF):内核空间(所有进程共享)

实现:

void setup_kernel_page_dir(pgd_t *pgdir) {
    // 1. 映射内核代码/数据(直接映射)
    for (uint32_t paddr = 0; paddr < kernel_size; paddr += PAGE_SIZE) {
        uint32_t vaddr = paddr + KERNEL_VIRTUAL_BASE;
        map_page(pgdir, vaddr, paddr, 
                 PAGE_PRESENT | PAGE_RW | PAGE_GLOBAL);
    }
    
    // 2. 映射硬件(LAPIC, IOAPIC)
    map_page(pgdir, 0xFEE00000, 0xFEE00000, 
             PAGE_PRESENT | PAGE_RW | PAGE_GLOBAL);
    
    // 3. 递归映射(可选)
    setup_recursive_mapping(pgdir);
}

创建进程页目录:

pgd_t *create_user_page_dir() {
    // 1. 分配新页目录
    pgd_t *pgdir = buddy_alloc(0);
    memset(pgdir, 0, PAGE_SIZE);
    
    // 2. 复制内核映射(高 256 项)
    memcpy(&pgdir[768], &kernel_page_dir[768], 256 * sizeof(uint32_t));
    // 768 = 0xC0000000 / 4MB → 768-1023 项为内核
    
    return pgdir;
}

5.2 进程切换优化

传统切换(低效):

void switch_to_task_slow(task_t *next) {
    asm volatile ("mov %0, %%cr3" :: "r"(next->pgdir));
    // → 所有 TLB 条目失效!
}

高半内核切换(高效):

void switch_to_task_fast(task_t *next) {
    // 仅当页目录真正改变时才加载 CR3
    if (next->pgdir != current_task->pgdir) {
        asm volatile ("mov %0, %%cr3" :: "r"(next->pgdir));
        // → 但内核 TLB 条目因 PAGE_GLOBAL 保留!
    }
    current_task = next;
}

📊 性能提升:TLB 未命中率降低 50%+,尤其在频繁系统调用时!

5.3 内核访问用户内存

问题:内核运行在高地址,如何安全访问用户内存?

  • 不能直接解引用用户指针(可能触发 Page Fault)

解决方案:临时映射

#define TEMP_WINDOW_VADDR 0xFFC00000

char *temp_user_map(void *user_ptr) {
    // 1. 获取物理地址
    uint32_t paddr = get_phys_addr(current_task->pgdir, (uint32_t)user_ptr);
    if (!paddr) return NULL;
    
    // 2. 映射到临时窗口
    map_page(kernel_page_dir, TEMP_WINDOW_VADDR, paddr, 
             PAGE_PRESENT | PAGE_RW | PAGE_GLOBAL);
    __asm__ volatile ("invlpg (%0)" :: "r"(TEMP_WINDOW_VADDR) : "memory");
    
    // 3. 返回映射地址
    return (char*)(TEMP_WINDOW_VADDR + ((uint32_t)user_ptr & 0xFFF));
}

// 系统调用中的使用
int sys_write(int fd, const void *buf, size_t count) {
    // 1. 验证用户指针
    if (!is_user_pointer((void*)buf)) return -1;
    
    // 2. 临时映射
    char *kernel_buf = temp_user_map((void*)buf);
    if (!kernel_buf) return -1;
    
    // 3. 内核直接操作 kernel_buf
    console_write(kernel_buf, count);
    return count;
}

⚠️ 绝对不要直接解引用用户指针!可能触发 Page Fault 或访问非法地址。


第六章:Linux VMA 实现深度对比

6.1 Linux VMA 数据结构

核心结构(mm_types.h):

struct vm_area_struct {
    unsigned long vm_start;     // 起始地址
    unsigned long vm_end;       // 结束地址
    
    struct vm_area_struct *vm_next, *vm_prev; // 双向链表
    struct rb_node vm_rb;       // 红黑树节点
    
    unsigned long rb_subtree_gap; // 红黑树间隙(用于 find_vma_gap)
    
    struct mm_struct *vm_mm;    // 所属内存描述符
    
    pgprot_t vm_page_prot;      // 页保护标志
    unsigned long vm_flags;     // VM_* 标志
    
    union {
        struct {
            struct rb_root_cached rb_subtree_gap;
            unsigned long rb_subtree_gap;
        } shared;
        struct {
            unsigned long shmid;
        } anon_shmem;
        struct {
            unsigned long pgoff;
        } file;
    };
    
    const struct vm_operations_struct *vm_ops; // VMA 操作函数
    struct file *vm_file;       // 文件对象
    unsigned long vm_pgoff;     // 文件偏移
    
    void *vm_private_data;      // 驱动私有数据
};

关键改进:

  • 双向链表 + 红黑树:兼顾遍历和查找
  • rb_subtree_gap:快速查找空闲区域(mmap 无地址指定时)
  • vm_ops:VMA 特定操作(如文件映射的 fault 回调)

6.2 VMA 操作函数(vm_operations_struct)

struct vm_operations_struct {
    void (*open)(struct vm_area_struct *area);
    void (*close)(struct vm_area_struct *area);
    int (*fault)(struct vm_fault *vmf); // Page Fault 处理
    int (*page_mkwrite)(struct vm_fault *vmf);
    int (*access)(struct vm_area_struct *vma, unsigned long addr, void *buf, int len, int write);
    // ...
};

文件映射的 fault 回调:

// fs/read_write.c
static int filemap_fault(struct vm_fault *vmf) {
    struct file *file = vmf->vma->vm_file;
    struct address_space *mapping = file->f_mapping;
    pgoff_t offset = vmf->pgoff;
    
    // 1. 从 page cache 获取页
    struct page *page = find_get_page(mapping, offset);
    if (!page) {
        // 2. 页不在 cache,从磁盘读取
        page = read_mapping_page(mapping, offset, NULL);
    }
    
    // 3. 插入页表
    vmf->page = page;
    return 0;
}

6.3 内存描述符(mm_struct)

Linux 使用 mm_struct 管理进程内存:

struct mm_struct {
    struct vm_area_struct *mmap;        // VMA 链表头
    struct rb_root mm_rb;               // VMA 红黑树
    unsigned long mmap_base;            // mmap 区域起始
    unsigned long task_size;            // 用户空间大小
    pgd_t *pgd;                         // 页目录
    atomic_t mm_users;                  // 主引用计数
    atomic_t mm_count;                  // 次引用计数
    // ...
};

引用计数设计:

  • mm_users:用户态引用(线程共享)
  • mm_count:内核态引用(如内核线程)

💡 Linux 的 VMA 设计是工业级复杂度的典范


结论:虚拟内存系统的工程艺术

虚拟内存系统是操作系统内核的皇冠明珠,它融合了:

  • 硬件机制(页表、TLB、Page Fault)
  • 软件抽象(VMA、mm_struct)
  • 算法优化(红黑树、按需分页)
  • 性能工程(高半内核、临时映射)

理解虚拟内存不仅有助于内核开发,更能培养系统级思维

  • 抽象层次:硬件页表 → 软件 VMA → 用户 mmap
  • 性能权衡:内存节省 vs TLB 压力
  • 安全边界:用户/内核隔离

对于希望深入 Linux 内核的开发者,建议:

  • 阅读 mm/mmap.cmm/memory.c 源码
  • 使用 pmap/proc/<pid>/maps 查看 VMA
  • 通过 perf 分析 Page Fault 开销

虚拟内存的故事,仍在继续。随着大页(HugeTLB)、用户态页表(Userfaultfd)等新特性的出现,这一古老而优雅的系统将焕发新的生机。


附录:关键数据结构与函数速查

核心数据结构

| 结构 | 作用 | 文件 | |——|——|——| | struct vm_area_struct | VMA 描述符 | mm_types.h | | struct mm_struct | 内存描述符 | mm_types.h | | pgd_t/pte_t | 页表类型 | pgtable.h |

关键函数

| 函数 | 功能 | 文件 | |——|——|——| | find_vma | 查找 VMA | mmap.c | | insert_vm_struct | 插入 VMA | mmap.c | | handle_mm_fault | Page Fault 处理 | memory.c | | do_mmap | mmap 系统调用 | mmap.c |

调试接口

| 接口 | 用途 | |——|——| | /proc/<pid>/maps | 查看进程 VMA | | /proc/<pid>/smaps | 详细 VMA 信息 | | pmap <pid> | 命令行工具 | | cat /proc/pagetypeinfo | 页类型分布 |