虚拟地址空间:从概念到内存管理的底层逻辑
虚拟地址把物理内存的细节藏在内核后面。每个进程都看到一套独立、连续的地址。内核用页表和 MMU 建立映射,并用按需分配、写时复制和交换机制提升安全与效率。本文用清晰概念、可运行代码、流程图、表格与排查方法说明这套机制如何工作。
关键词:虚拟地址|虚拟地址空间|页表|MMU|VMA|COW|ASLR
目录
- 概念与目标
- 虚拟地址与虚拟地址空间
- 地址转换:页表、MMU、TLB、缺页
- 进程视角的内存布局:VMA 与权限
- 写时复制(COW):父子进程为何地址相同内容不同
- 动手实践:两段可运行示例
- 工具与排查方法
- 内核视角:
mm_struct
与vm_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
。
示例一:打印主要区域的地址
c
#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;
}
运行:
bash
gcc addr_demo.c -O0 -g -o addr_demo && ./addr_demo a b
cat /proc/$$/maps | head -n 40
pmap $$ | head -n 20
示例二:验证 COW(地址相同,内容不同)
c
#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;
}
观察映射:
bash
cat /proc/$(pgrep -n a.out)/maps | sed -n '1,50p'
说明:ASLR 会让每次运行的地址不同。这是正常的安全特性。
工具与排查方法
bash
# 查看映射与权限
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 状态要结合
iostat
与dmesg
。
内核视角:mm_struct
与 vm_area_struct
每个进程的 task_struct
指向一个 mm_struct
。它描述整段用户态虚拟地址空间。mm_struct
里保存 VMA 的链表与红黑树。两种结构支持遍历与快速查找。
字段节选(不同内核版本名称会有差异):
c
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 描述一段连续地址及其权限与来源:
c
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"来回答,并用 strace
与 perf
做一次验证。