0. 前言
在操作系统中,内存是最核心的资源之一,而进程作为资源管理的基本单位,必须拥有对内存的'统一视角'。然而,真实的物理内存分布往往复杂且无序,直接暴露给进程会导致管理混乱、数据安全性不足。于是,Linux 通过 进程地址空间 和 页表机制,为进程营造了一个连续、独立且受保护的虚拟世界。
本文将以实验与源码为切入点,从 内存分区、、 到 ,系统地剖析进程是如何'看到'内存,以及操作系统如何在背后完成高效的管理与隔离。
本文深入解析 Linux 进程地址空间,涵盖内存分区验证、虚拟地址引入、写时拷贝机制及页表管理。通过实验代码演示堆栈增长方向与变量存储区域,阐述父子进程共享与隔离原理。重点讲解 mm_struct 结构体定义、CR3 寄存器作用、页表权限位及惰性加载策略,揭示操作系统如何通过虚拟内存实现统一视角、安全隔离及进程与内存管理的解耦。

在操作系统中,内存是最核心的资源之一,而进程作为资源管理的基本单位,必须拥有对内存的'统一视角'。然而,真实的物理内存分布往往复杂且无序,直接暴露给进程会导致管理混乱、数据安全性不足。于是,Linux 通过 进程地址空间 和 页表机制,为进程营造了一个连续、独立且受保护的虚拟世界。
本文将以实验与源码为切入点,从 内存分区、、 到 ,系统地剖析进程是如何'看到'内存,以及操作系统如何在背后完成高效的管理与隔离。
在 C/C++ 中,我们将内存分为以下几个区域(以 32 位操作系统为例):
malloc/free或new/delete),向上增长。static int)。"abcd")。从下向上,地址由低地址处向高地址增加。操作系统中,地址用 16 进制数表示,在 32 位 环境下,最低的地址为 0000 0000,最高的地址为 FFFF FFFF。
我们用以下代码来验证不同区的地址分布:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
int g_val_1; // 未初始化全局变量
int g_val_2 = 100; // 已初始化全局变量
int main()
{
printf("code addr: %p\n", main); // main 函数是代码,其地址代表代码区的地址
const char* str = "hello world"; // 字符串常量,存储在常量区
printf("read only string addr: %p\n", str);
printf("init global value addr: %p\n", &g_val_2);
printf("uninit global value addr: %p\n", &g_val_1);
char* mem = (char*)malloc(100); // 堆区的数据
printf("heap addr: %p\n", mem);
printf("stack addr: %p\n", &str);
return 0;
}
可以看到,地址值的大小正如分布图所示,地址值的大小符合上述内存分布规律。
我们用以下代码来验证栈区地址的向下增长:
void test(int n)
{
int local_var = n; // 每层递归都会有一个新的局部变量
printf("递归深度 %2d, 局部变量地址:%p\n", n, &local_var);
if(n > 0) test(n - 1); // 继续递归
}
int main()
{
test(10); // 从 10 层开始递归
return 0;
}
局部变量存储在栈区中。可以看到随着 递归不停的建立栈帧,新建立的栈帧中,局部变量的地址越来越小。
因此 栈区地址是向下增长的。
我们用以下代码来验证堆区地址的向上增长:
int g_val_1;
int g_val_2 = 100;
int main()
{
printf("code addr: %p\n", main);
const char* str = "hello world";
printf("read only string addr: %p\n", str);
printf("init global value addr: %p\n", &g_val_2);
printf("uninit global value addr: %p\n", &g_val_1);
char* mem = (char*)malloc(100);
char* mem1 = (char*)malloc(100);
char* mem2 = (char*)malloc(100);
printf("heap addr: %p\n", mem);
printf("heap addr: %p\n", mem1);
printf("heap addr: %p\n", mem2);
return 0;
}
可以看到最后三行中,堆区的三个地址
mem,mem1,mem2依次增大。
因此 堆区地址是向上增长的。
static 修饰的局部变量是 具有全局变量的属性 的,只不过是受到局部作用域的限制,static 修饰的局部变量 编译后会被存储到全局数据区。static 修饰的局部变量 存储在全局数据区,作用域为其局部作用域,只会在局部初始化一次,生命周期和全局变量一样长,不 会随着函数调用的结束而销毁。运行结果如上,static 修饰的局部变量和全局变量仅仅 相差 8 字节,存储地址几乎十分相近。
因此 static 修饰的局部变量 编译后会被存储到全局数据区。
观察以下代码的现象:
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0){ // 子进程读取
int cnt = 5;
while(1){
printf("i am child, pid: %d, ppid: %d, g_val = %d, &g_val = %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
if(cnt) cnt--;
else {
g_val = 200;
printf("子进程 change g_val: 100->200\n");
cnt--;
}
}
} else { // 父进程读取
while(1){
printf("i am parent, pid: %d, ppid: %d, g_val = %d, &g_val = %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
观察我们可以发现:
fork 之后,父子进程代码共享,子进程暂时和父进程共享同一份数据,但父子进程的数据不能互相干扰,子进程对数据做写入时,是对父进程数据的拷贝作写入,也就是 读时共享,写时拷贝。因此父子进程中全局变量的值分别为 100 和 200。因此我们得出:
C/C++ 用的指针,指针中保存的地址,全部都不是物理内存中的地址,而是 虚拟地址。g_val 后父子进程分别读取的情形,以下草图大致描述了父子进程是如何做到访问的是 同一个变量,同一个地址,同时读取,读取到的内容不同。前文中引入的 虚拟地址,便可以理解为我们所说的 进程地址空间。
每一个进程运行起来,操作系统都会为其创建内核数据结构,包括三个数据结构对象:
进程地址空间是内核为进程创建的一个结构体对象,进程的 PCB 中有对应的指针,指向该进程地址空间结构体。
进程地址空间,是一种软件层面的数据结构对象,是用结构体实现的。
结构体中 规定了不同的内存分区的起始位置,32 位系统中定义了一段 4GB 的连续的空间,分区过后就形成了我们常见的内存分区。
这里我们将他理解为一层软件结构即可。
task_struct、进程地址空间、页表。关于 进程地址空间的本质,以及 页表 的介绍,我们后文中会给出具体的说明。
父子进程刚开始是如何实现代码和数据共享的:
task_struct、进程地址空间、页表。
C/C++ 中使用的地址,实际上是进程地址空间中的 虚拟地址,程序编译过后,操作系统将数据的地址编排为 虚拟地址,保存在操作系统为进程创建的虚拟进程地址空间中。task_struct 中保存了一个指针,指向当前进程地址空间,方便进程 通过虚拟地址和页表访问数据。task_struct、进程地址空间、页表)。通过 task_strcut 中的指针,可以找到进程地址空间的结构体对象,从而实现父进程对数据的访问。
task_struct、进程地址空间、页表。task_struct、进程地址空间、页表,子进程用父进程的 task_struct 绝大多数 字段初始化自己的 task_struct,进程地址空间和页表的内容父子进程完全相同。写时拷贝的过程:
父子进程代码共享,父子再不写入时,数据也是共享的。当任意一方试图写入,便以写时拷贝的方式各自一份副本。
task_struct、进程地址空间、页表,子进程用父进程的 task_struct 绝大多数字段初始化自己的 task_struct,进程地址空间和页表的内容父子进程完全相同。
g_val = 200) 时,操作系统识别到当前子进程中 g_val 变量的虚拟地址映射到的物理地址中,这块数据是和父进程共享的,操作系统识别到子进程要对共享的那部分数据做写入,为了维护进程的独立性,会发生写时拷贝。写时拷贝:
g_val = 100 拷贝到新开辟的空间上 (在物理内存中),再将 新开辟的物理空间的地址填入到子进程页表中的物理地址。
g_val 的 虚拟地址不变,但映射的 物理地址已发生改变),子进程再来执行代码 g_val=200,子进程会 根据进程地址空间中 g_val 的虚拟地址,在页表中查找,找到映射的新物理地址,将物理内存新空间中的值改为 200。最开始的现象:
经过上文的分析我们可以轻松的得出原因:
g_val 做修改时,父子进程的 地址空间和页表的内容完全相同 (虚拟地址相同),虚拟地址到物理地址的映射也完全相同,共享一份数据和代码。因此访问数据时,g_val 的值相同。g_val 的值时,操作系统识别到后,会触发写时拷贝。在物理内存中新开辟一段空间,复制 g_val,并将新空间的物理地址填入到子进程的页表中。修改完页表后,子进程再通过虚拟地址去修改物理地址中的数据,将 g_val 的值改为 200。g_val 的 虚拟地址是相同的,但父子进程的虚拟地址在页表中映射到的物理地址不相同。在 访问数据时,相同的虚拟地址经过页表的映射,会到 物理内存中对应不同的物理地址 去访问 g_val 的值,所以就会呈现出变量的值不相同,但是地址是相同的这种现象。历史遗留问题:
int main(){
pid_t id = fork();
if(id == 0){ // 子进程...
} else { // 父进程...
}
}
有了以上内容的理解,我们对 fork() 两个返回值的理解就信手拈来了。
fork() 进行返回时,本质是向 id 值进行写入的过程,会发生写时拷贝。我们用 凝练的语言 表达该过程,具体细节上文已给出。
return 对 id 进行写入前,父子进程都已存在,各自有 独立的 PCB,地址空间,页表,父子进程三种结构内容几乎相同。id 变量,且 id 变量的虚拟地址相同,
return,操作系统识别到子进程是 对父子进程的共享数据做写入,发生 写时拷贝,经历以下流程:
id 开辟新空间。id 的 虚拟地址不变,但经过 页表映射到了新的物理地址处。id 变量,访问时,父子进程各自查 id 的虚拟地址,通过 不同的页表映射关系,到 不同的物理地址 处访问 id 变量,因此同一个变量名中有两个不同的值。最终物理内存中保存了两个不同值的 id 变量。CPU 访问内存的过程,本质是 CPU 对内存中的地址寄存器进行充放电的过程。32 位地址总线会分别识别出高低电平,最终产生 2^32 种高低电平组合,每一个组合都是一个 32 位的二进制数,这个数就是地址。byte(字节),那么 32 位计算机的可访问地址的范围总大小就是 2^32 * 1byte,而 1GB = 2^10 MB = 2^20 KB = 2^30 byte,所以 32 位计算机的内存大小就是 2^32 * 1byte = 2^30 byte * 4 = 4GB。总结上述内容可得:
Linux 中会运行数量众多的进程,每一个进程都要有自己独立的进程地址空间。在 Linux 中,使用 mm_struct 结构体描述进程地址空间,且 task_struct 中存放了一个指针 mm_struct*,为进程创建 task_struct 时,也 创建相应的进程地址空间。
// PCB task_struct
struct task_struct {
mm_struct* mm; // ...
};
// 32 位系统中,默认划分的区域是 4GB
struct mm_struct {
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, end_brk, start_stack, end_stack;
unsigned long arg_start, arg_end, env_start, env_end;
};
操作系统创建每一个进程时,要创建进程的 task_struct,同时要创建该进程的地址空间 mm_struct,且 task_struct 中存放了 mm_struct 的地址,可以方便的找到进程对应的代码和数据。
struct mm_struct 保存了各个区域的起始和结束地址,如:
unsigned long start_code, end_code;unsigned long start_brk, end_brk;unsigned long start_stack, end_stack;综合以上,可以得出 进程地址空间的最终理解:
mm_struct 来表示,结构体中 规定了各个虚拟内存分区的起始位置和结束位置,是线性地址。每一个进程在启动时,操作系统会为进程创建 task_struct,进程地址空间 (mm_struct),以及页表,用 进程地址空间 这个结构体,表示每一个进程 可使用内存的空间范围。
总结如下:
✅ 总结一句话: 进程地址空间的存在,让进程看到的是一个'统一且虚拟'的世界,操作系统则在背后通过页表把虚拟和物理关联起来,既降低了进程负担,又提升了系统安全性,同时实现了进程管理与内存管理的解耦。
0x1000,进程 B 的代码在物理地址 0x3000。那就需要进程自己去记录这些物理地址,否则一旦访问错误,就会直接读写到别的进程的数据。引入虚拟内存之后: 每个进程都拥有一个一模一样的 虚拟地址空间布局,比如:
代码段:0x400000~0x4FFFFF
数据段:0x500000~0x5FFFFF
堆 :0x600000~...
栈 :高地址向低地址增长
不管物理内存真实分布在哪里,对 进程来说看到的都是虚拟地址,总是一样的布局。
👉 好处:进程只需要'相信'自己看到的虚拟地址,不用关心代码和数据实际存放在哪个物理位置,也不需要维护额外的物理内存信息。这样减少了冗余和复杂度。
访问物理内存不是直接访问,而是先通过 页表 做一次'翻译':虚拟地址到物理地址的映射审查
虚拟地址 → 页表查找 → 物理地址
👉 好处:保证不同进程之间的隔离性和安全性,保护物理内存不被随意破坏。
👉 好处:
CPU 内部有一个寄存器,为 cr3 寄存器,该寄存器内保存当前 正在运行的进程的页表的起始地址,这里的地址是 页表的物理地址。进程切换时,担不担心找不到当前进程的页表呢?
答案是不担心。
CPU 进行 进程切换 时,会将 cr3 寄存器中的 页表地址保存 下来。当前进程 再次被调度 时,会把曾经保存的页表地址 再恢复到 cr3 寄存器中。
每个进程都有自己的页表地址,保存在自己的 上下文数据 中。CPU 想要访问某个变量,会先找到该变量的虚拟地址,再 去 cr3 寄存器中找到页表的地址,根据虚拟地址,在页表中查找对应的虚拟地址,最终映射到实际的物理地址。
r:当前地址中的内容为 只读。rw:当前地址中的内容为 可读可写。x:当前地址中的内容为 可执行。这里我们不考虑执行,因为 可执行在页表中并没有直接的体现。因为
CPU内本身就有一个eip寄存器,保存下一条指令的地址。
寄存器中保存的是哪条指令的地址,就代表对应地址中的内容是可以被执行的。
rw 可读可写。
r 只读。
以下代码会报错:
int main(){
char* str = "hello world";
*str = 'H';
return 0;
}
但我们的疑问是,如果代码区和字符常量区是只读的,那么代码和数据是 如何加载到物理内存的只读区域的?(只读区域不应该不能写入吗)
原因就在于:
r 只读,所以进行对代码区和字符常量区进行写入操作时,操作系统才会进行拦截。在操作系统中,进程是可以被挂起的,这引出两个问题:
0 → 当前虚拟页不在内存中,需要从磁盘调入;1 → 当前虚拟页已在内存中,可以直接访问。先补充一个知识,现代操作系统不做任何浪费时间和空间的行为。
现代操作系统几乎不做任何浪费时间和空间的事情,任何浪费时间和空间的事情,几乎都操作系统被优化了。
设想一个 场景:
我们在电脑上运行一个游戏,游戏安装后体积大小可能高达 几十 GB。而我们的电脑总内存只有 4GB,其中系统本身要占用一部分,实际可供应用使用的可能只有 3GB。
疑问: 👉 如果程序必须在运行前把 所有代码和数据一次性加载进内存,那么显然 10GB 的程序在 3GB 的内存里根本放不下,应该会卡死甚至崩溃。 但实际上,游戏运行得很流畅,说明操作系统在背后做了优化。
操作系统可以选择只把程序的一部分加载到内存中。例如:
这样,超大程序就能在有限内存中运行,看似占用 10GB,实际上只需 3GB 左右的物理内存。
问题: 如果每次都'批量'加载太多,比如加载 500MB,而代码是一行一行执行的,短时间只用到其中 15MB,那么剩余的 485MB 会被闲置,其他进程无法利用,造成 内存浪费。
现代操作系统采用更精细的策略 —— 惰性加载(Lazy Loading)。
原理:
1,建立虚拟地址和物理地址的映射。好处:
有了页表中的标记位,进程管理和内存管理可以做到 解耦:
这样:
👉 这就是为什么我们能在一台 4GB 内存的电脑上流畅运行一个占用几十 GB 的大型游戏。
当一个进程被创建时,操作系统 并不会立刻把所有代码和数据全部加载到物理内存中。 从技术角度上讲,完全可以做到以下行为:
等到进程真正要被调度执行时,操作系统再通过 缺页中断 + 惰性加载 的方式,边运行边加载。
也就是说,程序虽然看起来'一瞬间运行起来了',实际上代码和数据是逐步被加载到内存中的。真正要调度执行的时候,操作系统自动再将代码和数据慢慢惰性加载,此时就可以实现边使用边加载。
问题:进程在被创建时,是 先加载代码数据,还是先创建内核数据结构?
答案:
task_struct(进程控制块 PCB);mm_struct(进程的虚拟地址空间描述);这些过程全部由操作系统完成,进程本身既不需要感知,也不需要管理。
每一个进程都有进程地址空间,这是操作系统层面做的工作,和编程语言无关,正式因为页表和地址空间的存在,实现了进程管理和内存管理在软件层面的解耦。
综合来看,一个 进程 可以重新定义为:
task_struct + mm_struct + 页表) + 代码和数据。其中:
task_struct:进程控制块(PCB),记录进程的标识、状态、调度信息等;mm_struct:描述进程的虚拟地址空间结构;页表:实现虚拟地址到物理地址的映射。进程切换时,不仅仅要切换 PCB,还包含 地址空间和页表的切换:
task_struct 一旦切换,→ task_struct 所匹配的地址空间 mm_struct 自动被切换。因此,本质上:
👉 只要 CPU 切换了进程上下文数据(包括寄存器、CR3),那么该进程的 PCB、地址空间、页表就一并切换了。
进程的独立性体现在两个层面:内核数据结构层面 和 代码和数据层面。
task_struct)、地址空间(mm_struct)、页表;task_struct、mm_struct 和页表,就能保证该进程完全退出,而不影响其他进程。得益于页表的存在:
这就是'虚拟内存'的本质:把无序变成有序,让进程觉得自己独享一块完整、连续的内存空间。
可以看到,内存分区中命令行参数和环境变量的地址是在栈的地址的上面,我们 使用程序来验证他们之间的地址关系:
验证程序:
int g_val_1;
int g_val_2 = 100;
int main(int argc, char* argv[], char* env[])
{
printf("code addr: %p\n", main);
const char* str = "hello world";
printf("read only string addr: %p\n", str);
printf("init global value addr: %p\n", &g_val_2);
printf("uninit global value addr: %p\n", &g_val_1);
static int static_int = 100;
printf("static local value addr: %p\n", &static_int);
char* mem = (char*)malloc(100);
char* mem1 = (char*)malloc(100);
char* mem2 = (char*)malloc(100);
printf("heap addr: %p\n", mem);
printf("heap addr: %p\n", mem1);
printf("heap addr: %p\n", mem2);
// 打印栈区的地址
printf("stack addr:%p\n", &mem);
printf("stack addr:%p\n", &mem1);
printf("stack addr:%p\n", &mem2);
// 打印命令行参数和环境变量的地址
int i = 0;
for(; argv[i]; ++i) printf("argv[%d] addr: %p\n", i, argv[i]);
for(i = 0; env[i]; ++i) printf("env[%d] addr: %p\n", i, env[i]);
return 0;
}
运行结果如下:
fork 后,子进程会复制父进程的地址空间和页表;进程地址空间不仅是 Linux 内存管理的基石,更是进程独立性与系统安全性的体现。通过虚拟地址、页表和写时拷贝等机制,操作系统实现了 进程视角的统一、进程间的隔离,以及进程管理与内存管理的解耦。
理解这些概念,不仅能帮助我们更好地掌握系统编程的底层原理,也能为后续深入学习 进程调度、内存管理优化、操作系统内核设计 打下坚实基础。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online