当前位置: 首页 > news >正文

[Linux系统编程——Lesson8.进程地址空间和区域划分]

目录

前言

一、🧐程序的地址空间是真实的物理空间吗❓

总结🔑

结论背后的关键逻辑支撑

1️⃣为何不直接使用物理地址?​

2️⃣为何图是进程地址空间分布图?​

二、进程地址空间 

2.1🔥 操作系统是如何建立起进程与物理内存之间的联系的呢?🔥

2.2🔥分⻚&虚拟地址空间🔥

三、什么是写时拷贝✍️❓

 操作系统是如何知道什么时候进行写时拷贝的呢❓

什么是缺页中断❓

四、🔥什么是进程地址空间❓

补充关键细节:进程地址空间到底是什么?为什么需要它?

1️⃣第一个核心作用:隔离进程,防止 “孩子抢资源打架”

2️⃣第二个核心作用:映射逻辑地址到物理内存,实现 “按需分配”

五、什么是区域划分

关键补充:“画的大饼”(逻辑地址)为什么要分区域?—— 地址空间的分段划分

六、再结合之前提到的 “写时拷贝(COW)”:让 “大饼” 更高效

🔑总结:进程地址空间的本质🧐

七、问题回顾

7.1🔥为什么不能直接去访问物理内存?(深度理解)

7.2🔥为什么有进程地址空间和页表的机制?

7.3🔥malloc和new开辟空间的原理

八、为什么要有进程地址空间

1️⃣让 “物理上乱序的内存” 在进程视角下 “有序化”

2️⃣作为内存安全访问的 “第一道防线”

3️⃣实现进程管理与内存管理的 “解耦”,降低系统复杂度

4️⃣保障进程的 “独立性”,支撑多任务并发

总结:进程地址空间的本质是 “中间层”

结束语


前言

        对于 C/C++ 来说,程序中的内存包括这几部分:栈区、堆区、静态区 等,其中各个部分功能都不相同,比如函数的栈帧位于 栈区动态申请的空间位于 堆区全局变量和常量位于 静态区 ,区域划分的意义是为了更好的使用和管理空间,那么 真实物理空间 也是如此划分吗?多进程运行 时,又是如何区分空间的呢?写时拷贝 机制原理是什么?本文将对这些问题进行解答 

一、🧐程序的地址空间是真实的物理空间吗❓

对于下面的图大家一定不陌生,这是在我们学习 C/C++内存管理的时候经常见到的,内存空间部署图


在 C/C++语言 学习阶段,我们可以通过对变量 & 取地址的方式,查看当前变量存储空间的首地址信息 。我们还是来验证一下数据是不是按如图所示进行排列存储的呢?

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> int g_unval;
int g_val = 100;int main(int argc, char *argv[], char *env[])
{printf("code addr:\t%p\n", main);//验证正文代码printf("init data addr:\t%p\n", &g_val);//验证初始化数据(全局)printf("uninit data addr: %p\n", &g_unval);//验证未初始化数据(全局)char *heap = (char*)malloc(20);//如图先创建的动态内存应该在堆底char *heap1 = (char*)malloc(20);//所以heap的地址应为最小char *heap2 = (char*)malloc(20);//heap3的地址应为最大char *heap3 = (char*)malloc(20);//一会观察是否是这样printf("heap addr: %p\n", heap);//验证堆区(动态内存)printf("heap1 addr: %p\n", heap1);printf("heap2 addr: %p\n", heap2);printf("heap3 addr: %p\n", heap3);printf("stack addr: %p\n", &heap);//验证栈区(指针变量)printf("stack addr: %p\n", &heap1);//如图先创建的heap指针应该在栈空间中地址最大printf("stack addr: %p\n", &heap2);//所以&heap应为最大printf("stack addr: %p\n", &heap3);//&heap3应为最小for(int i = 0; argv[i]; i++)//验证命令行参数{printf("argv[%d]=%p\n", i, argv[i]); }for(int i = 0; env[i]; i++)//验证环境变量{printf("env[%d]=%p\n", i, env[i]);}return 0;
}

通过运行后的结果可以看出,空间所谓的分步情况确实如此,但是接下来这段代码运行后的结果,会让让你很诧异。 

  • 我们之前在进程概念的学习中创建过子进程,那我们刚好可以观察一下当子进程修改某一共享变量时,父子进程读取到的该变量的值是否会发生改变,该变量的地址又呈现出什么样的内容❓
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>int g_val = 100;int main()
{if(fork() == 0){int ret = 5;while(ret){                                                                     printf("hello--- %d g_val = %d &g_val = %p\n", ret, g_val, &g_val);ret--;                         sleep(1);if(ret == 3){printf("################child更改数据###############\n");                              g_val = 200;printf("#############child更改数据完成##############\n");}   }                                          }                                  else{                  while(1){                                              printf("I am father:g_val = %d &g_val = %p\n", g_val, &g_val);sleep(1);                                                     }                                                              }                                                              return 0;                                               
}

解析代码3 秒 之前 父子进程 读取变量g_val的值,3秒 后 子进程 对该变量进行修改,观察修改之后父子进程读取该变量的值如何变化,并且是否符合我们之前所讲的写时拷贝,是否会拷贝一份给自己再修改? 

我们发现确实,当子进程对变量进行修改时,子进程对应的g_val发生了改变,而父进程没有改变,进程之间确实具有独立性。 

可是最令人费解的是,父子进程读取该变量的地址竟然相同!?

这也就证实了之前我们所学习的所谓的内存分布图是假的,打印出来的地址也是假的,因为如果是物理内存地址,同一物理地址是不可能存放两个值的!!


总结🔑

我们所有用到的语言上的地址,都不是物理地址,而是虚拟地址(线性地址)

  • 我们在各类编程语言(如 C、C++、Python、Java 等)中使用的地址,无论是变量的存储地址(&variable)、指针指向的地址,还是程序加载后指令的地址,均不属于物理地址,而是操作系统为进程分配的虚拟地址(也称为线性地址)。​
  • 物理地址是内存硬件(如内存条)实际存储数据的硬件地址,由 CPU 在最终访问内存时,通过内存管理单元(MMU)将虚拟地址转换而来,该转换过程对程序员和应用程序完全透明,无需手动干预。(后面详解)

此图不是物理内存分布图,而是进程地址空间分布图。

  • 此前提及的相关图示(如展示程序内存布局的图),并非物理内存的实际分布情况,而是进程地址空间的逻辑分布图。​
  • 物理内存分布图需体现内存硬件的真实存储区域划分(如不同内存条的地址范围、内存块的占用 / 空闲状态),而进程地址空间分布图仅反映单个进程能 “看到” 的虚拟地址范围及功能划分(如代码段、数据段、堆、栈、共享库区域等),是操作系统为进程构建的 “逻辑内存视图”。

结论背后的关键逻辑支撑

1️⃣为何不直接使用物理地址?​
  • 隔离性保障:若直接使用物理地址,多个进程可能访问或修改同一物理内存区域,导致进程间数据混乱、程序崩溃(如 A 进程误修改 B 进程的指令数据)。虚拟地址让每个进程拥有独立的 “地址空间”,进程间无法直接访问彼此的虚拟地址,从根本上实现了进程隔离,提升系统稳定性。​
  • 内存管理灵活性:虚拟地址可突破物理内存大小限制(如 32 位系统的虚拟地址空间最大为 4GB,即便物理内存仅为 2GB,进程仍可使用 4GB 虚拟地址范围),还支持内存分页、分段、虚拟内存(如 swap 分区)等机制,让内存分配、回收更高效,避免物理内存浪费。​
  • 安全性提升:虚拟地址通过 MMU 转换时,操作系统可对地址进行权限校验(如只读、可写、可执行),若进程试图访问无权限的虚拟地址(如修改代码段的只读地址),操作系统会触发异常(如段错误),阻止非法操作。​
2️⃣为何图是进程地址空间分布图?​
  • 进程地址空间的统一性:无论物理内存实际如何分配,每个进程的虚拟地址空间布局是固定的(如代码段在低地址、栈在高地址且向下生长、堆在数据段与栈之间向上生长),图示正是基于这一统一逻辑绘制,而非反映物理内存中数据的真实存放位置。​
  • 物理内存的动态性:物理内存中数据的存储位置是动态变化的 —— 同一进程的虚拟地址,在不同时间可能映射到不同的物理地址(如内存换入换出时,数据从硬盘加载到物理内存的新区域);不同进程的虚拟地址甚至可能 “重叠”(如两个进程的变量都显示虚拟地址0x08048000),但通过 MMU 转换后,会映射到物理内存的不同区域。这种动态性决定了图示无法是物理内存分布图,只能是进程固定的虚拟地址空间逻辑图。

以上的逻辑支撑可能大家现在还有点云里雾里,但是后续我们讲到虚拟地址就会一目了然了😶‍🌫️


二、进程地址空间 

现在我们就知道了文章开头给出的图片根本不是什么物理内存分布图,而是进程地址空间分布图。 

真实的物理内存为: 

完了,我们之前所学被颠覆了❌,那物理内存到底在哪里啊,进程是如何访问到物理内存的

所以我们继续往下看:

2.1🔥 操作系统是如何建立起进程与物理内存之间的联系的呢?🔥

每一个进程都会存在一个进程地址空间,操作系统如何管理这些进程地址空间呢❓

-----   先描述,再组织

所以进程地址空间 --- 本质上就是一种数据结构PCB(task_struct)中会有一个指针指向该数据结构,该数据结构中存储的就是对应的虚拟地址,所以操作系统对进程地址空间的管理也就变成了对该数据结构的管理。

描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)每个进程只有⼀ 个mm_struct结构,在每个进程的 task_struct 结构中,有⼀个指向该进程的mm_struct结构体指针

  • 可以说mm_struct 结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴mm_struct ,
  • 这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由 task_struct 到 mm_struct ,进程的地址空间的分布情况:

2.2🔥分⻚&虚拟地址空间🔥

操作系统会为我们维护一张映射表页表

  • 该表中存储的就是虚拟地址与物理地址之间的映射关系通过虚拟地址就可以找到物理地址,也就建立起来了进程与物理内存的联系

  • 创建子进程时子进程会继承父进程的进程地址空间、页表等。 
  • 所以我们说父子进程代码共享,数据共享,是因为他们的页表是相同的。 
  • 但对共享的变量进行修改时,会发生写时拷贝拷贝到的代码和数据也是新开辟在物理内存上的,此时子进程只需要修改页表,虚拟地址不变,而物理地址则是新开辟的物理地址。 

上⾯的图就⾜矣说明问题,同⼀个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映 射到了不同的物理地址!
  • 子进程需要对数据进行修改时,操作系统会为该数据进行写时拷贝在物理内存中重新申请一个空间,新申请的空间就有了新的物理地址,再将子进程中页表的映射关系进行修改,最后子进程再对新空间的内容进行修改,但是需要注意页表中的映射关系只是修改虚拟地址的映射关系,也就是修改物理地址,所以在后续父子进程读取数据时,它们的虚拟地址是相同的,但是映射出的物理地址却不相同,读取出来的数据也就不同了,这就解释了为什么同一个地址却能读取到不同的数据。
  • 前面在fork中也讲到过,将fork函数的两个返回值给一个int类型的变量id,在语言层面来说一个变量不能有两个值,而在系统层面上,这两个值分别在父子进程的进程地址空间中,所以两个进程分别读取id时,数据不同但是地址相同的原因。

 所以才会出现虚拟地址相同,而物理地址不同的情况。


三、什么是写时拷贝✍️❓

Linux 中存在一个很有意思的机制:写时拷贝 

写时拷贝(Copy-On-Write,简称 COW)是一种计算机内存管理技术。核心思想多个进程或线程在初始时共享相同的物理内存页,直到有进程或线程尝试修改数据时才进行实际复制,且仅复制被修改的页面,而非整个内存空间。

  • 这是一种 赌徒行为,操作系统(OS) 此时就赌你不会对数据进行修改,这样就可以 使多个 进程 在访问同一个数据时,指向同一块空间,当发生改写行为时,再新开辟空间进行读写 
  • 这种行为对于内置类型来说感知还不是很强,但如果是自定义类型的话,写时拷贝 行为可以在某些场景下减少 拷贝构造 函数的调用次数(尤其是 深拷贝),尽可能提高效率 

可以通过一个简单的例子来证明此现象 

//计算 string 类的大小
#include <iostream>
#include <string>
using namespace std;int main()
{string s;cout << sizeof(s) << endl;return 0;
}

原理系统页面方式管理内存,当进程访问页面时,若页面在内存中则直接访问,不在则从磁盘加载到内存并标记为只读。当某个进程试图修改只读页面时系统会将该页面复制一份到内存中,原页面仍标记为只读,以便其他进程继续使用。


 操作系统是如何知道什么时候进行写时拷贝的呢

  • 父进程创建子进程时,按之前所学子进程会继承父进程的进程地址空间和页表。 
  • 并且操作系统还会将父子进程的页表中数据对应的权限属性修改为只读!

父或子进程修改(写入)该数据时,会发生缺页中断,但其实缺页中断做的工作不仅会在物理内存上开辟空间建立映射关系,还会对我们的访问操作做判断: 

  • 操作系统会判断,页表权限为只读,但数据所在的进程地址空间属于可读可写的数据区,操作系统明白了,这是要写时拷贝啊!

所以这就是操作系统判断什么时候进行写时拷贝的原理,根据这个方法,操作系统就能实现按需拷贝!

  • 谁要使用(写入)给谁开辟新的物理空间,否则就不拷贝,共用物理内存空间。 
  • 优势:写时拷贝通过消除对同一数据的多个副本的需求来节省内存空间,减少了不必要的复制操作和内存分配开销。同时,由于初始时无需复制数据,可加速进程创建,提高了系统性能和资源利用率。
  • 应用场景:该技术在操作系统、文件系统在操作系统、文件系统等领域广泛应用。如 Linux 系统中调用 fork 系统调用创建子进程时,就运用了写时拷贝机制,子进程与父进程先共用相同内存页,后续再按需复制。此外,Oracle 和 PostgreSQL 等关系数据库也会使用写时拷贝来提高写入性能和减少 redo 日志的大小。

补充:

什么是缺页中断❓

缺页中断是现代操作系统中虚拟内存管理的核心机制之一,当程序试图访问的虚拟地址尚未映射到物理内存时,由 CPU 和操作系统共同协作触发的一种中断。

具体来说,当进程执行指令访问某个虚拟地址时,CPU 会通过内存管理单元(MMU)查询页表:

  • 如果页表中该虚拟地址对应的页表项有效(已建立虚拟地址到物理地址的映射),则正常访问物理内存;
  • 如果页表项无效(虚拟地址已分配但未映射到物理内存,或访问权限不匹配),MMU 会触发一个缺页中断。

此时操作系统会介入处理:

  1. 暂停当前进程的执行
  2. 在物理内存中寻找空闲页框(若没有则可能通过页面置换算法置换出不常用的物理页)
  3. 建立虚拟地址与物理页框的映射关系(更新页表)
  4. 恢复进程执行,让进程重新访问该虚拟地址

四、🔥什么是进程地址空间❓

什么是进程地址空间,我将以一个故事开始讲述:

  • 在国外有一个富豪,他有4个私生孩子,并且这四个孩子互相不知道其他孩子的存在,富豪有100亿的资产,有一天富豪分别对孩子说等他去世了,孩子就能继承他的全部财产。
  • 在后面的日子中,私生子1对富豪说自己需要100万资金来周转工厂,富豪给了私生子1。私生子2对富豪说自己需要500万来买车子,富豪也给了私生子2。私生女1对富豪说自己需要150万去全球旅行,富豪也给了私生女1。私生女2对富豪说自己需要50万买相机、玩人机以及各种镜头来拍照片,富豪也给了私生女2。
  • 这四个孩子都不会向富豪索要100亿,就算要了富豪也不会给,毕竟富豪还没走,但四个孩子在潜意识中都认为自己有100亿,我们从上帝视角来看,这四个孩子最多平分到25亿,所以这100亿就是富豪对他的四个孩子画的大饼,在往后的日子中,只要孩子们不提出太过分的要求,富豪都会满足他们。
  • 到这里,我将要使用Linux中的角色来代替这个故事中的角色了,在故事中的富豪就是操作系统,富豪的资产就是物理内存,富豪的孩子就是进程,而富豪的许诺(画的大饼)就是进程地址空间,每个进程都知道操作系统中的内存有多大,但是自己却不能使用全部的内存。
    故事中的角色 / 事物Linux 中的技术概念核心对应逻辑
    富豪操作系统(内核)拥有 “物理内存” 的实际控制权,负责分配、管理内存,避免 “孩子(进程)” 乱抢资源。
    富豪的 100 亿资产物理内存(实际硬件内存)总量固定(比如8GB、16GB),是所有进程共享的 “真实资源”。
    富豪对每个孩子 “画的 100 亿大饼”单个进程的进程地址空间给每个进程的 “逻辑内存视图”—— 进程以为自己独占全部内存(比如 32 位系统下以为有 4GB 逻辑地址空间),但这只是 “幻觉”。
    4 个私生子女4 个独立进程每个进程只关注自己的 “逻辑需求”(比如要 100 万周转 = 进程申请 100KB 内存),不关心其他进程和物理内存总量。
    孩子 “不索要 100 亿”进程不会申请超出物理内存的资源进程的实际内存需求是 “碎片化、按需申请” 的(比如加载代码、创建变量),不会直接索要全部物理内存;就算索要,内核也会拒绝。

补充关键细节:进程地址空间到底是什么?为什么需要它?

进程地址空间struct mm_struct结构体PCB 通过指针指向它”,这是它的核心技术点。它的两个核心作用 ——“隔离” 和 “映射”,这也是操作系统必须设计 “进程地址空间” 的根本原因。

1️⃣第一个核心作用:隔离进程,防止 “孩子抢资源打架”
  • 如果没有进程地址空间,会发生什么?就像 4 个孩子直接去抢富豪的 100 亿资产,没有规则约束 —— 孩子 A 可能把孩子 B 的钱花了,孩子 C 可能乱拿超出自己需求的钱,最后所有孩子的资产都会混乱。
  • 对应到 Linux 中:如果进程直接操作 “物理内存地址”(比如进程 1 直接写物理地址0x12345678,进程 2 也写同一个地址),就会导致进程间数据互相覆盖(比如进程 1 的代码被进程 2 篡改,直接崩溃)。

进程地址空间(struct mm_struct的解决思路是

  • 给每个进程一套 “独立的逻辑地址”(比如 32 位系统下,每个进程的逻辑地址范围是0x00000000 ~ 0xFFFFFFFF,共 4GB),进程所有操作(加载代码、存储变量)都基于这套 “逻辑地址”,完全看不到其他进程的逻辑地址。
  • 进程以为自己用的是 “独有的内存”,但实际上,这些 “逻辑地址” 并不会直接对应物理内存 —— 中间需要一层 “映射”(也就是你之前提到的页表),由操作系统内核控制。
2️⃣第二个核心作用:映射逻辑地址到物理内存,实现 “按需分配”

故事里提到 “孩子要 100 万、500 万,富豪会满足”,这个 “满足” 的过程,就是操作系统通过 “页表” 完成 “逻辑地址→物理地址” 映射的过程。

  • 我们用 “私生子女 1 要 100 万周转” 来拆解这个过程:
  1. 进程提出需求:进程 1 需要 100KB 内存(比如创建一个大数组),它会向内核申请 —— 但它申请的是 “自己逻辑地址空间里的一块区域”(比如逻辑地址0x00400000 ~ 0x004186A0),并不知道物理内存里有没有对应的空间。
  2. 内核检查并分配物理内存:操作系统内核(富豪)检查物理内存是否有空闲的 100KB 空间(比如找到物理地址0x10000000 ~ 0x100186A0)。
  3. 更新页表,建立映射:内核在进程 1 的 “页表”(struct mm_struct里包含页表指针)中,添加一条记录:“逻辑地址0x00400000对应的物理地址是0x10000000”。
  4. 进程使用内存:之后进程 1 访问逻辑地址0x00400000时,CPU 会自动通过页表找到物理地址0x10000000,最终操作实际的物理内存 —— 但进程全程感知不到 “映射” 的存在,还以为自己直接用的是 “自己的逻辑地址”。

总结:

每一个进程都会存在一个进程地址空间,在32位操作系统下,该空间的大小为[0,4]GB。  

进程地址空间其实就是一个数据结构,查看 PCB(task_struct) 

  • 所以,之前说‘程序的地址空间’是不准确的,准确的应该说成进程虚拟地址空间 ,每个进程都会有自己的地址空间,认为自己独占物理内存。操作系统在描述进程地址空间时,是以结构体的形式描述的,在linux中这种结构体是 struct mm_struct 。它在内核中是一个数据结构类型,具体进程的地址空间变量。

进程地址空间 就类似于一把尺子,每个空间都有对应的起始位置和结束位置。通过这个虚拟地址去间接访问内存; 


五、什么是区域划分

书接上文✍️

关键补充:“画的大饼”(逻辑地址)为什么要分区域?—— 地址空间的分段划分

孩子们分到了钱该如何花呢

  • 对应到进程中,不同类型的数据(代码、变量、堆、栈)需要在 “逻辑地址空间” 中分开存放 —— 这就是进程地址空间的 “分段划分”,也是struct mm_struct结构体里的核心字段。
  • 以 32 位 Linux 为例,每个进程的 4GB 逻辑地址空间会被划分为两大块:用户空间(3GB,0x00000000 ~ 0xBFFFFFFF) 和 内核空间(1GB,0xC0000000 ~ 0xFFFFFFFF),其中用户空间又细分出多个功能区:

逻辑地址区域存放内容类比故事中的 “孩子花钱用途”
代码段(.text)进程的可执行代码(比如main函数的指令)孩子 “维持自身生存” 的基础开销(比如日常吃饭、住房)
数据段(.data/.bss)已初始化 / 未初始化的全局变量、静态变量(比如int g_var = 10孩子 “固定的资产”(比如已买的房子、存款)
堆(heap)动态内存(比如malloc申请的内存)孩子 “临时周转的资金”(比如工厂周转的 100 万)
栈(stack)函数调用栈、局部变量(比如函数里的int a = 5孩子 “即时开销”(比如买相机的 50 万、旅行的 150 万)
内核空间内核代码、内核数据结构(比如mm_struct、页表),进程无法直接访问富豪的 “私人账户”,孩子(进程)不能碰,只能通过富豪(内核)间接申请

这种划分的意义在于:

  • 不同区域有不同的权限(比如代码段是 “只读” 的,防止进程意外修改自己的代码;堆是 “可读写” 的,支持动态申请);
  • 内核能更精准地管理内存(比如堆从低地址向高地址增长,栈从高地址向低地址增长,避免两个区域冲突)。

补充

  • 在进程地址空间中还存在一个区域叫做共享区,我们在上面测试过各个区域中地址,发现栈区与堆区中有很大一个间隔,中间就存在一个共享区。在未来进程地址空间时,我们不仅仅想使用代码区、数据区、堆区、栈区,也想想堆区和栈区间的空间也好好使用,例如我们想在共享区或其他空余区域中来划分出一个区域,但是在进程地址空间(mm_struct)中并没有发现更多能够划分区域的字段。
  • 在mm_struct中,不仅仅有各个区域的start与end划分空间,还有一个指针vm_area_struct* mmap,我们通过对结构体vm_area_struct的查看,发现该结构体中也有start与end字段,其实vm_area_struct结构体出现就说明了还可以划分更多的子区域。

  • 当我们想划分多个子区域时,就可以创建多个vm_area_struct对象,并用指针将对象们连接起来,就可以形成一个子区域划分的线性链表结构,将mm_struct(内存描述符)与vm_area_struct(线性空间)结合起来就是真正的进程地址空间。


    六、再结合之前提到的 “写时拷贝(COW)”:让 “大饼” 更高效

            之前提过 “写时拷贝”其实它和进程地址空间是紧密配合的 —— 可以用故事再延伸一个场景:

            如果富豪的两个孩子(进程 A 和进程 B)一开始都想 “用同一笔钱”(比如都要访问同一个程序的代码,比如/bin/ls的代码),富豪没必要给两个孩子各复制一份 100 万,而是先让他们 “共用同一笔钱”(共享物理内存页);直到其中一个孩子想 “修改这笔钱的用途”(比如进程 A 要修改代码段的某个数据),富豪才会 “复制一份新的钱” 给这个孩子(复制物理内存页),避免影响另一个孩子。

    这就是写时拷贝与进程地址空间的配合

    • 初始时,进程 A进程 B “逻辑地址” 对应同一个 “物理内存页”(通过页表映射到同一块物理地址);
    • 进程 A 尝试修改这个逻辑地址的数据时,内核会触发 “写时拷贝”复制一份新的物理内存页,更新进程 A 的页表(让其逻辑地址指向新物理页),而进程 B 的页表仍指向原物理页;
    • 这样既节省了物理内存(初始不复制),又保证了进程隔离(修改不影响其他进程)—— 本质是进程地址空间的 “逻辑独立性” 给了写时拷贝实现的基础。

    🔑总结:进程地址空间的本质🧐

    进程地址空间就是操作系统每个进程画的 “100 亿大饼”(逻辑地址空间),它通过struct mm_struct结构体 “描述” 每个进程的逻辑地址划分,再通过 “页表” 将逻辑地址 “映射” 到实际的物理内存(富豪的真实资产)。

    它的核心价值是两点:

    1. 隔离:每个进程只认自己的 “逻辑地址”,看不到其他进程,避免互相干扰;
    2. 高效:通过 “按需映射”(需要时才分配物理内存)和 “写时拷贝”(共享未修改的内存),让有限的物理内存能 “喂饱” 多个进程,就像富豪用 100 亿资产满足 4 个孩子的合理需求一样。

    七、问题回顾

    了解了进程地址空间和区域划分,我们再去思考以下的问题❓

    7.1🔥为什么不能直接去访问物理内存?(深度理解)

    保障内存隔离与系统安全

    若直接访问物理内存,所有程序共享同一物理地址空间,缺乏隔离边界:恶意程序可随意篡改其他进程(如浏览器、办公软件)的内存数据,窃取密码、密钥等敏感信息;即便非恶意但存在 Bug 的程序,也可能误写操作系统内核或其他程序的内存,导致目标程序崩溃、系统死机,无法满足 “单个任务失败不影响其他任务” 的用户需求。而通过虚拟地址空间与物理地址的映射,操作系统可严格管控每个进程的内存访问范围,实现进程间内存隔离,从根本上规避此类风险。

    解决物理内存碎片化问题,提升管理效率

    物理内存长期分配、释放后易产生 “碎片化”(即存在大量零散的空闲物理页,无法满足连续内存申请需求)。若直接使用物理地址,程序需自行寻找连续空闲区域,可能出现 “明明总空闲内存足够,却因碎片化无法分配” 的问题。引入虚拟内存后,内存管理单元(MMU)可将多个不连续的物理页 “拼接” 成逻辑连续的虚拟地址空间,掩盖物理内存碎片,高效满足程序对连续内存的需求,提升内存利用率。

    支持多任务并发执行,避免地址冲突

    多任务系统中,多个进程需同时运行,若直接使用物理地址,不同进程可能因使用相同物理地址导致冲突(如 A 进程写入 0x1234 地址,会覆盖 B 进程在该地址的数据),导致系统混乱。而虚拟地址空间为每个进程提供独立的 “地址假象”—— 不同进程可使用相同的虚拟地址,MMU 会将其映射到不同的物理地址,彻底避免地址冲突,保障多任务并发的稳定性。

    简化程序开发,提升兼容性与可移植性(解耦合)

    若程序直接访问物理内存,开发时需预知自身加载的物理地址,且需手动处理内存分配、地址冲突等问题,极大增加开发难度;同时,程序的运行依赖特定物理内存布局,在不同硬件或系统上难以移植。虚拟地址空间为每个进程提供 “从 0 开始的连续地址范围”,程序员无需关心物理内存实际布局,编译器、链接器可按固定逻辑处理内存地址,大幅简化开发流程,且程序可在不同系统上通过 MMU 适配物理内存,提升兼容性。

    7.2🔥为什么有进程地址空间和页表的机制?

    • 保障进程独立性与系统安全性:进程地址空间为每个进程提供了独立的内存空间,使进程之间相互隔离。通过页表映射物理内存,若进程代码出现非法访问,可在页表处进行拦截,防止一个进程访问或修改另一个进程的内存数据,避免因某个进程的错误或恶意行为导致整个系统崩溃,保证系统的稳定性和安全性。
    • 提高内存管理效率:一方面,页表可以将物理内存上不连续、无序的空间通过映射关系联系在一起,方便操作系统对内存进行分配和管理。操作系统可根据进程需求动态分配和回收内存,实现内存的高效利用。另一方面,进程地址空间支持延迟分配物理内存策略6。进程申请内存时,操作系统先在地址空间中分配虚拟地址,待进程真正使用时再分配物理内存并建立映射,提高了整机内存使用效率。
    • 实现进程管理与内存管理解耦合:进程通过进程地址空间访问物理内存,无需关心实际物理内存位置,只要能通过页表建立映射即可访问合法物理内存。这使得操作系统对进程地址空间的管理和对内存的管理得以分离,降低了进程管理和内存管理之间的耦合度,便于操作系统更灵活地进行资源管理。
    • 为进程提供统一内存视角:进程地址空间让每个进程都认为自己在独占内存,为进程提供了一个统一的、连续的内存视图。进程无需了解物理内存的实际布局和碎片化情况,简化了进程对内存的使用方式,也方便了程序的开发和运行,提高了程序的可移植性和兼容性。

    总结

    因为有了 进程地址空间 和 页表在物理内存空间上不连续、无序的空间就可以通过页表这一映射关系联系在一起,让进程以统一的视角看待内存。

    • 有了进程地址空间和页表后,每个进程都认为自己在独占内存,这样能更好的保障进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程管理与内存管理进行解耦合。
    • 进程地址空间 + 页表 的设计是保护内存安全的重要手段!

    7.3🔥malloc和new开辟空间的原理

    在之前的学习中,我们不知道进程地址空间的概念,所以 malloc 和 new 开辟空间我们总是默认为内存上的操作,而学习完进程地址空间后,你会发现并不是如此。 

    • 当代码执行到 malloc 和 new 时,操作系统(OS)不一定会直接将实际的物理内存分配给你,因为该进程可能不会立即使用该块内存,也就造成了内存浪费,OS一定要确保效率和资源使用率,所以OS给你分配的实际上是进程地址空间,地址也是虚拟地址,而且并不会在页表上建立有效的映射关系。 

    当检测到该进程实际要使用该块空间时(写入修改之类的操作,读取不算),会发生缺页中断,然后立即在页表中建立映射关系,此时该进程需要的物理内存空间才被申请。 

    这样做有什么好处呢?

    • 充分保证内存的使用率,不会造成空转;
    • 提升new或malloc的速度(因为没有实际在内存上开辟空间)。

    综上我们可以得出结论

    八、为什么要有进程地址空间

    1️⃣让 “物理上乱序的内存” 在进程视角下 “有序化”

    • 物理内存的实际状态是碎片化、动态变化的:系统运行中,频繁的内存分配 / 释放会产生大量零散的空闲物理页(比如物理地址 0x1000-0x2000 被占用、0x2000-0x3000 空闲、0x3000-0x4000 被占用),直接使用物理地址时,程序难以申请到 “连续的大内存”(即使总空闲内存足够)。
    • 而进程地址空间为每个进程提供了一个逻辑上连续、从固定起始地址(如 0x0)开始的虚拟地址范围(比如 32 位系统中,每个进程的虚拟地址空间都是 0x00000000-0xFFFFFFFF)。通过页表的映射,操作系统可以将物理内存中 “不连续的空闲页” 拼接成进程视角下 “连续的虚拟地址块”—— 比如把物理页 0x2000-0x3000 和 0x5000-0x6000,映射到进程虚拟地址 0x10000-0x12000 的连续区间。

    这种 “有序化” 彻底解决了物理内存碎片化问题,让程序无需关心物理内存的实际布局,只需按 “连续地址” 正常访问即可,极大降低了程序对内存的使用门槛。

    2️⃣作为内存安全访问的 “第一道防线”

    直接访问物理内存时,所有程序共享同一地址空间,缺乏 “访问边界”—— 恶意程序可随意修改其他进程的内存(如窃取浏览器缓存的密码),有 Bug 的程序也可能误写操作系统内核内存(导致系统崩溃)。

    进程地址空间通过虚拟地址范围划分页表权限控制,实现了严格的安全检查:

    • 每个进程虚拟地址空间被划分为 “用户区” “内核区”,用户进程默认只能访问自己的 “用户区虚拟地址”,无法直接访问内核区或其他进程的虚拟地址(否则触发 “非法访问” 异常,操作系统直接终止该进程);
    • 页表的每个 “页表项” 还会记录内存的访问权限(如 “只读”“读写”“执行”),比如程序的代码段被设置为 “只读”,若程序试图修改代码段内存,页表会拦截该操作并触发异常,防止恶意篡改代码。

    这种设计从底层阻断了 “越界访问”,是保障系统安全和进程数据隔离的核心手段。

    3️⃣实现进程管理与内存管理的 “解耦”,降低系统复杂度

    在操作系统中,“进程管理”(如进程创建、调度、销毁)和 “内存管理”(如物理内存分配、回收、碎片整理)是两个独立的核心模块,若二者强耦合(比如进程直接依赖物理内存地址),会导致:

    • 进程调度时,若物理内存布局变化(如内存回收),进程的地址需要重新调整,逻辑极其复杂;
    • 内存管理模块的修改(如引入新的内存分配算法),会直接影响所有进程的运行,扩展性差。

    进程地址空间的引入,让两个模块通过 “虚拟地址” 间接交互:

    • 进程管理模块只需要维护 “进程的虚拟地址空间”(比如记录进程申请了哪些虚拟地址块),无需关心物理内存的实际位置;
    • 内存管理模块只需要负责 “物理内存的分配” 和 “虚拟地址到物理地址的映射(页表维护)”,无需关心进程的调度逻辑。

    这种 “解耦” 让操作系统的架构更清晰,两个模块可独立优化(比如内存管理模块升级碎片整理算法,进程管理模块无需任何修改),大幅降低了系统设计和维护的复杂度。

    4️⃣保障进程的 “独立性”,支撑多任务并发

    现代操作系统的核心需求是 “多任务并发”(如同时打开浏览器、文档、音乐软件),而进程独立性是多任务稳定运行的前提 —— 用户期望 “一个进程崩溃,不影响其他进程”(比如文档软件闪退,浏览器仍能正常使用)。

    进程地址空间通过 “地址隔离” 实现了进程独立性:

    • 每个进程拥有独立的虚拟地址空间,且虚拟地址通过页表映射到不同的物理内存区域(比如进程 A 的虚拟地址 0x1000 映射到物理地址 0x2000,进程 B 的虚拟地址 0x1000 映射到物理地址 0x5000);
    • 即使某个进程因 Bug 篡改了自己的虚拟地址内存,也只会影响自身对应的物理内存,不会波及其他进程的物理内存或操作系统内核。

    这种 “独立性” 彻底解决了多任务下的 “地址冲突” 问题,让多个进程可以安全、稳定地共享物理内存,是多任务操作系统的基础。

    总结:进程地址空间的本质是 “中间层”

    首先,程序数据加载到内存后,由操作系统分配进程PCB(task_struct和mm_struct(进程虚拟地址空间))和页表。此时我们的进程就算是创建好了。

      进程地址空间的核心价值,本质是在 “进程” 和 “物理内存” 之间增加了一层抽象的虚拟地址中间层:

      • 进程而言:它看到的是 “连续、独立、安全的虚拟内存”,简化了内存使用;
      • 操作系统而言:它通过 “页表” 灵活管理虚拟地址与物理地址的映射,兼顾了内存效率、系统安全和架构解耦。

      没有这一层抽象,现代计算机的多任务并发、内存安全、高效管理都无从谈起 —— 这也是它成为现代操作系统内存管理核心设计的根本原因。


      结束语

      以上就是我对于【Linux系统编程】进程地址空间以及区域划分的理解

      感谢你的三连支持!!!

      http://www.dtcms.com/a/466399.html

      相关文章:

    • ModBus-TCP学习
    • 河北专业网站建设公司推荐红岗网站建设
    • ReactNative开发实战——ReactNative 开发中的图标管理方案:基于 Iconfont 的自定义图标库实现
    • 哪些公司提供微信做网站服务seo快速优化文章排名
    • 网站空间怎么弄百度产品推广
    • 做网站的ui框架大型网站架设需要考虑哪些问题
    • Docker网络全方位解析
    • 网站建设服务商都有哪些动漫设计中专学校
    • JAVA:Spring Boot 集成 FFmpeg 实现多媒体处理
    • 青岛可以做网站的公司家用电器销售的网站开发
    • pandas、numpy 和 matplotlib 三个数据科学常用库的核心指令整理
    • 【课堂笔记】稳定性和反向传播误差
    • 网站刷链接怎么做ui设计师是吃青春饭吗
    • Vue3大文件上传终极解决方案
    • 球极平面投影
    • Linux进程信号 --- 信号的产生方式、信号的保存
    • 织梦建站教程全集以net结尾的网站
    • C语言入门(九):二维数组的介绍
    • 深圳网站设计公司的seo优化的常用手法
    • 西安三桥网站建设重庆市建设考试报名网站
    • Unicode编码中的零宽空格0x200B
    • 实战指南:Stable Diffusion 图像生成模型
    • PyTorch的AI框架小白入门的学习点
    • 办公网站模板网站建设微信官网开发
    • 信誉好的合肥网站建设全网网站建设维护
    • 建设部网站 标准下载如何判断网站做的关键词
    • 诺奖相关的调节性T细胞怎么养?
    • 旺道网站优化重庆ppt制作
    • ps免抠素材网站大全免费制作h5页面平台
    • 反激电源实际设计