一. 案例引入:从一段代码看透虚拟内存
本文通过一个简单的C程序实验,带你揭示Linux进程地址空间的奥秘,理解虚拟地址、物理地址和写时拷贝机制。
引言:从程序地址空间说起
在学习C语言时,我们都见过下面这样的内存布局图:
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
或者这样的环境变量获取代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
char * env = getenv("MYENV");
if(env){
printf("%s\n", env);
}
return 0;
}
老师会告诉我们,程序的内存分为代码段、数据段、BSS段、堆、栈等区域。
但那时候,我们可能并不真正理解这背后的机制。
今天,就让我们通过一段代码来深入理解这个概念。
实验:父子进程的内存谜题
初始代码:相同的值和地址
先来看第一段代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
运行结果如下:
parent[21177]: 0 : 0x601058
child[21178]: 0 : 0x601058
这个结果很好理解:子进程是父进程的副本,父子进程的变量值和地址完全相同。
修改后的代码:相同的地址,不同的值
现在对代码稍作修改:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child,子进程先运行,修改g_val
g_val=100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
sleep(3); // 确保子进程先执行
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
运行结果:
child[22462]: 100 : 0x601058
parent[22461]: 0 : 0x601058
这个结果就很有意思了!
我们发现:
- 父子进程输出的变量值不同(子进程是100,父进程是0)
- 但它们的地址却是相同的(都是0x601058)
这引出了两个关键结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个物理变量
- 地址值相同,说明该地址绝对不是物理地址!
揭秘:虚拟地址空间
-
在Linux系统中,我们使用C/C++语言看到的地址,全部都是虚拟地址。
当然,如果从C/C++语言层面理解的话,就是语言层面的地址,但是对于操作系统来说就是虚拟的!!!
从语言层面看内存布局:
C/C++内存布局 -
物理地址用户是看不到的,由操作系统统一管理。
-
操作系统负责将虚拟地址转换成物理地址。
什么是虚拟地址?
虚拟地址是进程视角中的内存地址,也称为逻辑地址。
每个进程都认为自己运行在一个独立的、连续的内存空间中,从地址0开始一直到最大地址。
与之相对的是物理地址,这是实际内存硬件中的地址,对应着内存条上的具体存储单元。

二. Linux地址空间
2.1 什么是地址空间?

地址空间是操作系统为每个进程提供的一个抽象的内存视图,它定义了进程可以访问的内存地址范围。
在32位系统中,这个范围通常是[0, 2^32-1],也就是4GB的线性地址空间。
-
简单来说,地址空间就像是给每个进程分配的一个"私有内存领地",进程只能在这个领地内活动,无法直接访问其他进程的内存空间。
-
验证地址空间的范围和权限:
// 验证地址空间的范围和权限
#include <stdio.h>
#include <stdlib.h>
int global_var; // 未初始化全局变量 - 在.bss段
int main() {
static int static_var = 100; // 已初始化静态变量 - 在.data段
int stack_var = 200; // 栈变量
int *heap_var = malloc(sizeof(int)); // 堆变量
// 所有这些变量都在当前进程的地址空间范围内
printf("代码地址: %p (可执行)\n", (void*)main);
printf("数据地址: %p (可读写)\n", (void*)&static_var);
printf("堆地址: %p (可读写)\n", (void*)heap_var);
printf("栈地址: %p (可读写)\n", (void*)&stack_var);
// 尝试访问非法地址(超出地址空间或无权访问)
// int *bad_ptr = (int*)0x00000000; // 通常为保留区,访问会段错误
// *bad_ptr = 100; // 这会触发 segmentation fault
free(heap_var);
return 0;
}
2.2 地址空间的内部结构
区域划分
地址空间并不是一整块连续的内存,而是被划分为多个功能不同的区域:
struct mm_struct {
// 代码区(只读)
long code_start;
long code_end;
// 常量区(只读)
long readonly_start;
long readonly_end;
// 数据段
long init_start, init_end; // 已初始化数据
long uninit_start, uninit_end; // 未初始化数据
// 堆区(动态增长)
long heap_start, heap_end;
// 栈区(动态增长)
long stack_start, stack_end;
};

各区域功能说明:
- 代码区:存放可执行指令,具有只读属性
- 常量区:存放字符串常量等,同样只读
- 数据段:存放全局变量和静态变量
- 堆区:动态内存分配区域,向高地址增长
- 栈区:函数调用、局部变量存储,向低地址增长
为什么需要区域划分?
// 示例:不同区域的变量存储
const char* str = "Hello"; // 字符串常量 -> 常量区(只读)
int global_var = 10; // 全局变量 -> 数据段
int main() {
int local_var = 20; // 局部变量 -> 栈区
int* ptr = malloc(100); // 动态内存 -> 堆区
// str[0] = 'h'; // 错误!常量区不可写
return 0;
}
最本质的回答:区域划分的三大核心原因
- 权限控制
不同内存区域需要不同的访问权限:
- 代码段:只读 + 可执行(防止被篡改)
- 数据段:读写 + 不可执行(防止代码注入)
- 栈/堆:读写 + 不可执行(安全隔离)
- 生命周期管理
不同内存区域有不同的生存周期:
- 代码段:程序整个生命周期(静态)
- 全局变量:程序整个生命周期(静态)
- 栈:函数调用期间(自动)
- 堆:手动控制(动态)
- 管理策略优化
不同区域需要不同的管理算法:
- 栈:简单的指针移动(高效)
- 堆:复杂的分配算法(灵活)
- 代码段:一次性映射 + 共享(节省内存)
一句话总结:
区域划分是为了给不同类型的内存内容提供最适合的"生存环境"——不同的权限、生命周期和管理策略。
2.3 进程的地址空间的实现机制
几个前置知识铺垫:
- CR3寄存器:
存储当前进程页表的起始地址- 每个进程有一个页表,CR3寄存器保存当前运行进程的页表在物理内存中的起始地址。
- 进程切换时,操作系统会更新CR3的值。
- MMU(内存管理单元):
硬件负责地址转换- CPU中的一个硬件单元,负责将虚拟地址转换为物理地址。
- 转换过程是:使用CR3找到页表,然后通过多级页表结构找到对应的物理页框,最后结合页内偏移得到物理地址。
- 页表项:
包含物理页框号、访问权限等信息- 页表中的每一个条目,存储了虚拟页到物理页的映射关系以及权限控制位(如是否可读、可写、可执行,是否已被访问等)
2.4 页表
什么是页表?
页表是操作系统用于实现虚拟内存到物理内存转换的核心数据结构。
它就像是内存世界的"翻译官",将进程看到的虚拟地址"翻译"成实际的物理地址。
// 最简单的理解:页表是一个映射函数
物理地址 = 页表[虚拟地址]
2.5 PCB与页表的关系及页表存储位置
PCB与页表的关系
- 包含关系
进程控制块(PCB)
├── 进程ID (PID)
├── 进程状态
├── 程序计数器
├── 寄存器值
├── 打开文件列表
├── **内存管理信息**
│ ├── 页表基地址 (CR3值)
│ ├── 内存映射信息
│ └── 内存限制
└── 调度信息
- 关联机制
// 在PCB中存储页表相关信息
struct task_struct { // Linux的PCB
pid_t pid; // 进程ID
// ...
struct mm_struct *mm; // 内存管理结构
// ...
};
struct mm_struct { // 内存管理结构
pgd_t *pgd; // 页全局目录地址(页表根)
unsigned long start_code, end_code;
unsigned long start_data, end_data;
// ...
};
Linux mm_struct 的简化视图
// Linux 内核中 mm_struct 的实际定义(简化)
struct mm_struct {
// 线性地址区间定义
unsigned long mmap_base; // 内存映射区域基址
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, env_start, env_end;
// 内存区域链表(更精细的管理)
struct vm_area_struct *mmap; // 虚拟内存区域链表
struct rb_root mm_rb; // 红黑树用于快速查找
};
- 图例:

- 进程切换时的操作
; 进程切换伪代码
switch_process(new_process):
; 1. 保存当前进程上下文到其PCB
save_registers(current_process->pcb);
; 2. 从新进程PCB中加载页表地址到CR3
mov cr3, new_process->pcb->mm->pgd
; 3. 恢复新进程上下文
load_registers(new_process->pcb);
; 4. 跳转到新进程执行
jmp new_process->pcb->program_counter
页表的存储位置
1. 页表存储在物理内存中

2. 页表存储的特点
// 页表本身的存储特性
struct page_table_storage {
void *physical_address; // 页表存储在物理内存中
size_t size; // 页表占用内存大小
int user_accessible; // 用户进程通常不能直接访问页表
};
关键点:
- 页表本身存储在物理内存中
- 每个进程的页表占用连续的物理内存区域
- 操作系统内核负责管理页表的内存分配和释放
3. 页表访问的层次结构
CPU访问流程:
1. CPU发出虚拟地址
2. MMU读取CR3寄存器获取当前页表物理地址
3. MMU直接访问物理内存中的页表进行地址转换
4. 得到物理地址后访问实际内存数据
注意:页表查询过程对软件完全透明,由硬件自动完成
** 完整的关系图示**

总结:
PCB与页表关系:
- PCB包含页表指针:每个PCB存储该进程页表的物理地址
- 进程切换依赖:切换进程时,通过更新CR3寄存器来切换页表
- 一对一关系:每个进程有自己独立的PCB和页表
页表存储位置:- 存储在物理内存:页表本身占用物理内存空间
- 内核管理:操作系统内核负责分配和管理页表内存
- 硬件访问:MMU直接访问物理内存中的页表进行地址转换
简单说:PCB是进程的"身份证",其中记录了页表的"住址",而页表本身"住"在物理内存中。
页表与地址转换
地址空间的核心在于虚拟地址到物理地址的转换,这个过程通过页表实现:
虚拟地址空间 → 页表映射 → 物理内存

缺页中断与惰性加载
现代操作系统采用惰性加载策略,不会一次性将程序全部加载到内存:

// 当进程访问尚未加载的页面时:
1. CPU触发缺页中断
2. 操作系统捕获中断
3. 从磁盘加载缺失的页面到内存
4. 更新页表映射
5. 重新执行引发中断的指令
这种机制使得:
- 大程序可以分段加载
- 节省内存空间
- 提高系统响应速度
2.6 地址空间的重要性
1. 内存保护与安全性
// 示例:非法访问被拦截
int* ptr = (int*)0x12345678; // 随机地址
*ptr = 100; // 地址空间会拦截这个非法访问
保护机制:
- 只读区域写操作 → 段错误
- 未映射地址访问 → 段错误
- 越界访问 → 被页表检查拦截
2. 进程独立性
每个进程拥有独立的地址空间和页表:
// 进程A和进程B可能有相同的虚拟地址
// 但通过各自的页表映射到不同的物理地址
进程A: 虚拟地址0x4000 → 物理地址0x8000
进程B: 虚拟地址0x4000 → 物理地址0x9000
3. 模块化解耦

地址空间实现了进程管理与内存管理的解耦:
- 进程管理器:负责调度、创建进程
- 内存管理器:负责物理内存分配、页面置换
- 两者通过地址空间这个抽象层进行交互
简单小结:
- 让进程以统一的视角看内存
- 增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存
- 因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合!
进程创建流程:
- 创建内核数据结构(task_struct、mm_struct)
- 建立页表映射
- 加载程序代码和数据(按需加载)
- 设置程序计数器,开始执行
2.7 实际应用场景
2.7.1. 内存共享
// 多个进程可以共享相同的物理页
// 通过将不同进程的虚拟地址映射到相同的物理页实现
// 常用于:动态库、进程间通信
2.7.2 写时复制(Copy-on-Write)
// fork()创建子进程时:
// 1. 父子进程共享相同的物理页
// 2. 当任一进程尝试写入时,才复制该页面
// 3. 为写入进程创建新的物理页副本

3072

被折叠的 条评论
为什么被折叠?



