在计算机科学的世界里,最精妙的魔法往往隐藏在最基础的机制之中。当我们编写一个简单的printf("Hello World")时,背后正上演着一场关于内存管理的交响乐。进程地址空间、页表、缺页中断——这些看似深奥的概念,实则是现代操作系统的智慧结晶,它们共同构筑了一个让每个进程都"自以为"独占整个计算机内存的完美幻境。理解这套机制,不仅是掌握操作系统原理的关键,更是窥见计算机系统设计美学的窗口。
因为一个字节(8比特位)可以用2个十六进制位数完整表示
0x00000000(共8位十六进制数),最高地址为 0xFFFFFFFF。0x0000000000000000(共16位十六进制数),最高地址为 0xFFFFFFFFFFFFFFFF。空间布局演进

我们先来看看程序地址空间的实例图:

1 #include <stdio.h>
2 #include <stdlib.h>
3
4 int g_val_1 = 0; // 已初始化全局变量
5 int g_val_2; // 未初始化全局变量
6
7 int main()
8 {
9 printf("代码段地址: %p\n", main); // 代码段地址
10 const char* str = "wobushidaitou";
11 printf("只读字符常量地址: %p\n", str); // 只读字符串常量地址
12 printf("已经初始化全局变量地址: %p\n", &g_val_1); // 已初始化全局变量地址
13 printf("未初始化全局变量地址: %p\n", &g_val_2); // 未初始化全局变量地址
14
15 char* heap = (char*)malloc(100);
16 printf("堆地址: %p\n", heap); // 堆地址
17 printf("栈地址: %p\n", &str); // 栈地址
18
19 free(heap); // 记得释放内存
20 return 0;
21 } 
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4
5 int g_val = 0;
6 int main()
7 {
8 pid_t id = fork();
9 if(id < 0)
10 {
11 perror("fork");
12 return 0;
13 }
14 else if(id == 0)//子进程
15 {
16 printf("child[%d]: %d: %p\n",getpid(),g_val,&g_val);
17 }
18 else
19 {
20 printf("parent[%d]: %d: %p\n",getpid(),g_val,&g_val);
21 }
22 sleep(2);
23
24 return 0;
25 }
我们能观察到输出的变量值和地址都是一模一样的。
再看以下进行修改的代码:
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4
5 int g_val = 0;
6 int main()
7 {
8 pid_t id = fork();
9 if(id < 0)
10 {
11 perror("fork");
12 return 0;
13 }
14 else if(id == 0)//子进程
15 {
16 g_val = 100;
17 printf("child[%d]: %d: %p\n",getpid(),g_val,&g_val);
18 }
19 else
20 {
21 sleep(3);
22 printf("parent[%d]: %d: %p\n",getpid(),g_val,&g_val);
23 }
24 sleep(2);
25
26 return 0;
我们观察输出发现:
这个矛盾引出了一个关键结论:我们在C/C++程序中通过 & 取地址运算符获得的地址,绝非物理内存的直接地址。
0x601050,是它们各自虚拟地址空间中的地址。虽然数值相同,但它们是两个不同“世界”里的坐标。

总结: 同一个变量,地址相同说明他们的虚拟地址相同;内容不同,说明虚拟地址映射到了不同的物理地址中。
上述我们已经引出了虚拟地址的概念,有了一个初步的认识,接下来我们通过一个例子来更深刻的理解虚拟地址空间!
这位富翁拥有一个庞大的公开家庭,他的孩子们都生活在同一座庄园里。他知道自己总共有10亿美元,孩子们也都知道彼此的存在。
富翁的困境: 孩子们开始竞争和攀比。他们不仅争夺当前的小额钞票,更因为知道“家底”总共就10亿,都想着“我现在拿得少,以后分家产时就亏了”。这让富翁头疼不已,因为他必须实时调解每一笔钱的归属,确保不会超支,还要维持公平——一个孩子的挥霍,会直接影响到其他孩子。

另一位富翁同样拥有10亿美元和很多孩子,但他的孩子们都是“私生子”,彼此不知道对方的存在。富翁为每个孩子都建造了一座一模一样的、独立的豪华庄园。
富翁的精明: 每个孩子都活在一个专属的世界里,坚信自己是唯一的继承人,拥有对“全部10亿美元”的未来所有权。当他们需要钱时(比如申请内存),富翁就从总财富中划出一部分给他们,但在每个孩子的“个人账本”(他们的认知世界里),他们看到的都是自己的需求被满足,并且自己仍然拥有那完整的“10亿”远景。孩子们之间无法也无意识去争夺,因为他们根本不知道对方的存在。

这个精妙的设计解决了多个关键问题:它实现了进程间的安全隔离,防止一个进程的错误影响其他进程;它简化了程序员的编程模型,无需关心物理内存的实际布局;它允许操作系统更高效地管理有限的物理内存资源。通过虚拟内存机制,进程可以使用比实际物理内存更大的地址空间,部分数据可以暂时存储在磁盘上,需要时再调入内存。
linux中的进程是十分多的,每一个进程都要有自己独立的进程地址空间,进程一旦十分多,那么就容易混乱,那么我们应该先描述再组织,使用结构体描述进程地址空间,在linux中是mm_struct描述进程地址空间
一开始,桌子是“公共的”,没有界限。结果:
这条线,就是他们桌子的“区域划分”。
课桌故事 | 对应计算机概念 | 核心思想 |
|---|---|---|
一整张课桌 | 一整块物理内存 | 初始状态是共享的、混沌的资源池。 |
小明和小红 | 进程A 和 进程B | 多个实体需要共享同一资源。 |
胳膊碰撞、物品入侵 | 内存访问冲突、数据被篡改 | 没有隔离会导致混乱和不安全。 |
谈判划线的行为 | 操作系统的内存管理 | 引入一个管理者来制定规则。 |
“三八线”本身 | 进程的地址空间边界 | 一条逻辑上的、强制性的边界。 |
“线左归明,线右归红”的规则 | 虚拟地址空间映射 | 操作系统通过页表,让进程A的地址空间映射到物理内存的A区,进程B的映射到B区。它们看到的都是“整张桌子”,但实际用的只是各自那一半。 |
小明在自己的区域随意摆放 | 进程在自己的地址空间内自由操作 | 进程无需关心其他进程在干什么,它认为自己独享整个内存空间。这简化了编程。 |
“越界=犯规”的共识 | 内存保护机制 | 如果进程A试图访问进程B的内存区域,硬件和操作系统会立刻拦截,并触发一个段错误/访问违规,强制该进程崩溃,从而保护了整个系统的安全。 |
如上我们已经知道了其地址上的区域划分,其实描述linux下进程的地址空间的所有的信息的结构体是 mm_struct(内存描述符)。
每个进程只有⼀个
mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的结构。
struct task_struct
{
struct mm_struct *mm;
//对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。
struct mm_struct *active_mm;
// 该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
}mm_struct结构是对整个用户空间的描述。每⼀个进程都会有自己独立的mm_struct。task_struct到mm_struct,进程的地址空间的分布情况:

进程 == 内核数据结构 + 代码和数据内核数据结构 == task_struct && mm_struct && 页表当CPU从一个进程切换到另一个进程时,它实际上是在切换一整套“执行上下文”。这个上下文的核心是“三件套”:
关键机制:CPU中有一个名为CR3的寄存器,它专门用来存放当前正在运行的进程的页表物理地址。当发生进程切换时,操作系统会将新进程的页表地址加载到CR3寄存器中。这一操作是硬件级地址空间隔离的基石——它确保了即使两个进程使用相同的虚拟地址,也会因为CR3指向不同的页表,最终访问到不同的物理内存区域,从而实现完全隔离。
重要认知:物理内存本身是没有权限概念的,只要知道物理地址,就可以进行读写。是页表这层“抽象壳” 在地址翻译的过程中附加了权限检查,从而实现了软件层面的内存保护。
这个标志位是操作系统知晓页面是否在内存中的根本依据。在Linux中,虽然没有一个直接的“挂起”状态,但一个进程的很多页面如果Present位为0,它在效果上就是被“挂起”了,因为它的部分代码和数据不在物理内存中。
问题: 一个10GB的游戏,如何在只有4GB物理内存的电脑上流畅运行?如果一次性全部加载,内存必然崩溃。
工作流程如下:
通过这个机制,10GB的游戏在运行时,实际上只有当前真正被使用到的部分(可能是几十MB)才会被加载到物理内存中,这就完美地解决了大程序在有限内存中运行的难题。
惰性加载和缺页中断机制带来了一个至关重要的架构优势:实现了进程管理模块与内存管理模块的解耦合。
页表和缺页中断机制充当了二者之间的“协调中间件”。这种设计使得两个核心模块可以独立发展和优化,大大提升了操作系统的稳定性、灵活性和资源利用效率。
问:进程在被创建的时候,是先创建内核数据结构,还是先加载可执行程序对应的代码和数据?
答:先创建内核数据结构。
得益于页表和缺页中断机制,操作系统的流程是:
注意:命令行参数和环境变量的地址是在栈的地址之上
从32位到64位的地址空间演进,从简单的内存划分到精巧的虚拟内存管理,我们看到的不仅是技术的进步,更是设计哲学的升华。进程地址空间为每个进程提供了独立的沙盒环境,页表机制实现了地址翻译、权限控制和状态监控的三重使命,而缺页中断与惰性加载的完美配合,则展现了"按需分配"这一效率至上的设计智慧。这套环环相扣的机制,如同一个精密的生态系统,在保证安全隔离的前提下,最大化地提升了资源利用率。正如一位智者所言,最好的系统设计是让复杂对用户不可见——当我们能够流畅运行远比物理内存庞大的程序时,正是这些底层机制在默默发挥着它们的魔力