内存管理-虚拟篇 I:页表操作与虚拟内存区域(VMA)深度解析
"虚拟内存是现代操作系统的基石,而 VMA 是进程地址空间的‘地图'。
本文将深入 x86 页表结构、VMA 设计原理,并对比 Linux 的实现细节,
构建一个支持按需分页、高半内核映射的工业级虚拟内存系统。"
引言:虚拟内存的核心价值
虚拟内存(Virtual Memory)是现代操作系统最伟大的抽象之一,它提供了三大核心能力:
- 地址空间隔离:每个进程拥有独立的 4GB 虚拟地址空间
- 内存保护:防止进程越界访问(如用户态访问内核)
- 内存扩展:通过交换(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) |
地址翻译流程:
- CR3 寄存器:存储页目录基地址(物理地址)
- 页目录项(PDE):通过高 10 位索引,得到页表基地址
- 页表项(PTE):通过中间 10 位索引,得到物理页帧地址
- 物理地址 = 页帧地址 + 页内偏移
页目录项(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 文件映射流程:
mmap创建 VMA(标记VM_FILE)- 首次访问触发 Page Fault
- 内核从文件读取对应页到物理页
- 映射并返回
实现:
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.c、mm/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 | 页类型分布 |