前言
程序运行的本质是进程的执行,而进程地址空间正是支撑进程独立运行的核心骨架。它划分内存区域、隔离不同进程,决定了代码、数据该如何存放与调用。搞懂它,才算真正触碰到程序运行的底层逻辑。
⚙️ Linux 进程篇
目录
[小故事:富豪的 "空头支票"](#小故事:富豪的 “空头支票”)
[3、 如何实现这种只读保护的?](#3、 如何实现这种只读保护的?)
[小tip:进程创建时,是先创建内核数据结构(如 PCB),还是先加载可执行程序?](#小tip:进程创建时,是先创建内核数据结构(如 PCB),还是先加载可执行程序?)
一、重新认识C/C++内存

这是我们再C/C++学习的内存区域划分,具体详看:C/C++内存管理


不妨大胆猜想一下:我在栈上创建的临时变量 a,它打印出的这个地址,是物理内存地址吗?
接下来,我通过一段代码来验证一下


哎,这就有点奇怪了 ------ 子进程修改了全局变量 a 的值,父进程没跟着变我能理解,毕竟父子进程是相互独立的;但问题是,他俩的 a 值明明不一样,对应的地址怎么会是相同的呢?
到这里就能得出一个结论了:我们平时在代码里看到的地址,其实并不是物理地址。
二、Fork遗留问题探讨
当时我们在学习 fork 函数时,有个这样的问题:一个变量怎么会有不同的内容?我们当时讲的是,通过写时拷贝为父子进程的 id 变量各自创建了一个副本,但还是不太理解具体是怎么回事 ------ 毕竟看起来依旧只有一个 id 变量,父子进程好像依旧在用同一个 id 变量

这时候我们就可以先浅显地了解一下,进程地址空间到底是什么了

首先啊,咱创建进程的时候,操作系统肯定得给它整个task_struct(相当于进程的 "身份证 + 档案袋"),顺道还得配俩关键玩意儿:进程地址空间 和页表 。具体咋实现的先不管,咱先搞懂它们是干嘛的 ------ 其实咱写代码时看到的 "地址",全是进程地址空间里的虚拟地址(也叫线性地址),根本不是物理内存的真实门牌号!
那虚拟地址咋对应到真实内存呢?全靠页表这个 "翻译官"------ 它就像个对照表,左边列着虚拟地址,右边对着物理地址(实际页表还有权限、状态这些花里胡哨的信息哈)。比如咱定义个全局变量int a = 100,这货会乖乖待在进程地址空间的 "已初始化数据区" 里,然后页表会给它分配个物理内存的随机位置(真实门牌号)。平时咱访问a,其实是让页表把虚拟地址翻译成物理地址,再去真实内存里揪出这货~
等咱调用fork生个 "子进程娃" 的时候,这娃可鸡贼了 ------ 直接把父进程的进程地址空间和页表复制了一份(主打一个 "拿来吧你"),所以刚开始父子俩的代码、数据都是共享的,连页表的映射关系都一模一样。
但这娃要是敢动a(比如想把a改成 200),好戏就来了:它先让页表翻译虚拟地址找物理内存,结果发现这地址是 "共享只读" 的(系统怕它霍霍父进程的数据)。这时候 "写时拷贝" 就跳出来了 ------ 系统光速给子进程新划一块物理内存,把原来a的值(100)复制过去,再改成 200,最后更新子进程的页表:把a的虚拟地址对应的物理地址,换成刚新划的这块地儿。
这下好了,父子俩的a虚拟地址看着一样,实际早躺在不同物理内存里了 ------ 你改你的 200,我留我的 100,互不耽误~这就是为啥 "同一个变量名,能存俩值" 的骚操作!
现在我们已经大概搞懂是怎么回事啦,接下来就聊聊进程地址空间和页表的具体细节~
三、进程地址空间
1、什么是进程地址空间?
进程地址空间是操作系统为每个进程虚拟出来的、独立的内存地址范围(比如 32 位系统中通常是 0~4GB),它让进程以为自己独占了内存,但实际是操作系统通过 "虚拟地址→物理地址" 的映射来管理真实内存。
【拓展知识】
32 位架构中,地址总线、数据总线均为 32 位:
地址总线:通过 "高 / 低电平" 表示 0/1,32 根总线可组合出 2^32 种地址(每根线对应 1 位二进制信号);
数据总线:以同样的 0/1 编码方式,传输地址对应的数据。
32 位系统的地址空间是 [0, 2^32)(即 0 到 2^32-1),对应4GB 的可寻址内存(CPU 最多能访问 4GB 内存)。
CPU 与内存的交互逻辑
- CPU 将目标地址存入地址寄存器(32 位,暂存地址的二进制值);
- 地址寄存器通过 32 根地址总线,将地址以 "0/1 电平信号" 传给内存;
- 内存识别地址后,通过数据总线将对应数据回传 CPU。
2、如何理解进程地址空间的区域划分?

这个过程的本质就是区域划分,那在操作系统中,是如何描述这种划分方式的呢?

可见,操作系统描述区域划分的本质,就是通过结构体定义 "边界范围",再通过结构体变量管理具体的区域分配与调整------ 这和 "划三八线管理桌面区域" 的逻辑是一致的。
cpp
// 直接用一个结构体存小胖、小花的区域起止
struct destop_area
{
int start_xp; // 小胖区域起始
int end_xp; // 小胖区域结束
int start_xh; // 小花区域起始
int end_xh; // 小花区域结束
};
像这样把两人的区域边界直接放在一个结构体里,就是更简洁的区域划分描述方式 ------ 操作系统里管理进程地址空间的区域,也是用类似的极简结构体来定义起止范围的
我们不仅要明确给小胖划分的地址空间范围,更要注意:在这个连续的空间里,每一个最小单位(比如桌面的 1cm)都对应一个独立地址,小胖可以直接使用这些地址对应的空间。
进程地址空间是操作系统以 "起止边界" 描述、管理的,进程可访问的内存范围(本质是内核中被 "先描述再组织" 的数据结构对象)

每个进程的 PCB(task_struct)中会通过struct mm_struct *mm字段存储mm_struct的内存地址,以此关联专属的mm_struct结构体,专门用来管理该进程的地址空间(包括各内存段的起止边界、4GB 地址范围等)。
总结
- 关联方式:一个进程(PCB)通过存储
mm_struct的地址,对应一个专属的mm_struct,管理自身地址空间;- 核心作用:
mm_struct通过start/end字段定义进程内各内存段范围,描述 4GB 地址空间;- 管理逻辑:操作系统先通过
mm_struct描述进程地址空间,再通过 PCB 中存储的地址找到所有进程的mm_struct,组织起来统一管控。
四、为什么要有进程地址空间?
小故事:富豪的 "空头支票"
北美有个身家 50 亿的富豪,偷偷养了仨私生子,这仨孩子互相都不知道对方的存在。
某天他叫老大来:"看你小子做生意挺来劲,好好干!等你做出名堂,我这 50 亿家产全是你的!"
没几天又找着老二:"你弹钢琴这么有天赋,好好练!等成了世界级钢琴家,我那 50 亿就留给你!"
最后见着三女儿,看她成绩顶好,也拍胸脯:"闺女,接着努力!要是考上哈佛,我的 50 亿就归你!"
听到这儿你肯定懂了 ------ 这富豪纯纯画大饼呢!他敢这么吹,就是吃准了:孩子们现在只会埋头奔目标,压根不会立马找他兑现这 50 亿。
故事与虚拟内存的对应关系
这个故事正好能类比操作系统的虚拟内存机制:
- 富豪 = 操作系统:掌握核心的 "资源分配权"(就像富豪握有家产分配权);
- 50 亿美元家产 = 物理内存:容量有限(家产总量固定,物理内存空间也有限);
- 富豪给每个孩子的 "家产承诺" = 虚拟地址空间:是给进程画的 "大饼"(每个进程都觉得自己独占 4GB);
- 三个互不相识的私生子 = 系统中的进程:进程之间相互独立、隔离(像私生子们互不了解)。
虚拟内存的 "错觉" 逻辑
就像富豪让每个孩子都觉得 "50 亿最终是自己的",操作系统也让每个进程产生这样的错觉:
- 独占感:每个进程都认为自己独占一整块连续的内存空间(比如 32 位系统下,每个进程都觉得自己有 4GB 虚拟内存);
- "画饼" 本质 :这些虚拟地址空间只是 "纸面承诺"------只有当进程真正需要访问数据时,操作系统才会把虚拟地址映射到实际的物理内存(就像只有孩子真达成目标,富豪才需要兑现承诺)。
这个类比正好戳中虚拟内存的本质:用 "虚拟的地址表象" 让进程方便管理内存,同时通过操作系统的底层调度,高效且安全地共享有限的物理内存资源。
1、统一进程的内存视角,降低开发 / 运行成本
进程不需要关心物理内存的实际分布(比如是否连续、具体地址是多少),只需要以 "统一的虚拟地址" 访问内存 ------ 就像所有进程都用 "0~4GB" 的固定范围,不用管物理内存的碎片化,让程序开发和运行更简单。
2. 拦截非法访问,保护物理内存安全
虚拟地址→物理地址的转换过程中,操作系统会对内存请求做 "审查":
- 比如进程访问了没有权限的地址(像写只读的代码段)、越界访问了不属于自己的地址,操作系统会直接拦截这个请求,不让它到达物理内存;
- 相当于给物理内存加了一层 "安全过滤",避免进程的错误 / 恶意操作破坏其他进程或系统的内存。
3. 解耦进程管理与内存管理,提升系统灵活性
地址空间(+ 页表)把 "进程怎么用内存" 和 "物理内存怎么分配" 拆成了两个独立模块:
- 进程管理模块只需要管 "进程的虚拟地址范围、段划分";
- 内存管理模块只需要管 "物理内存的分配、回收";两者通过 "虚拟地址映射" 配合,既让进程用内存更自由,也让物理内存的调度更高效(比如可以灵活把空闲物理内存分配给不同进程)。
总结
进程地址空间是为了让进程 "简单用内存、安全用内存",同时让系统 "灵活管内存"------ 既统一了进程的内存视角,又保护了物理内存,还解耦了系统模块,是操作系统内存管理的核心设计。
五、页表存在的意义!
1、进程为什么具有独立性?
核心原因是操作系统通过 "进程地址空间 + 页表" 的机制,让每个进程拥有独立的虚拟地址范围,且虚拟地址到物理地址的映射相互隔离。
具体来说:
1、每个进程都有专属的虚拟地址空间(比如 32 位下的 0~4GB),进程只在自己的虚拟地址范围内操作;
2、页表是进程专属的,不同进程的相同虚拟地址,会被页表映射到不同的物理内存区域;
3、操作系统会拦截越界 / 越权的内存访问请求,确保进程无法直接操作其他进程的物理内存。
这就像每个进程都被 "关在自己的虚拟地址笼子里",只能访问自己对应的物理内存,自然就相互独立了。
2、代码段、字符串常量区为什么要设为只读?
核心是保障程序安全与内存高效利用:
1、防止意外 / 恶意篡改:代码是程序的执行逻辑、字符串常量是固定值,若被修改会导致程序崩溃(比如代码指令被改)或逻辑混乱(比如常量字符串被篡改);设为只读可避免这类风险。
2、支持内存共享:多个进程运行同一程序时,代码段 / 只读常量区可共享同一份物理内存(无需每个进程都存一份),大幅节省内存资源。
3、 如何实现这种只读保护的?

页表项(PTE)中的权限标记位(如 x86 架构的R/W位)是核心载体:
1、操作系统标记权限: 进程加载程序时,操作系统会给代码段(.text)、只读数据段(.rodata)对应的页表项,设置
R/W=0(代表 "只读");堆 / 栈等区域则设为R/W=1(代表 "读写")。2、CPU 硬件强制检查 :进程访问内存时,CPU 会读取对应页表项的
R/W位:
- 若对
R/W=0的区域发起写操作,CPU 直接触发 "内存访问越权异常",由操作系统终止进程,阻止非法修改。
4、惰性加载(延迟加载):操作系统分批加载大文件的核心机制
核心逻辑 :不是一次性把整个大文件(或程序)加载到内存,而是用到哪部分,才临时加载哪部分 ;页表会预先覆盖进程的全部虚拟地址,但对应的物理地址只有 "被用到的部分" 才会实际分配,以此节省内存资源。
1. 为什么需要惰性加载?
大文件 / 程序(比如几个 GB 的软件)如果一次性全加载到内存,会直接占满物理内存,导致其他进程无法运行 ------ 惰性加载能 "按需加载",只把当前要用的部分放进内存,契合 "现代操作系统不浪费空间 / 时间" 的共识。
2. 实现流程(结合 cr3 寄存器 + 页表 + 磁盘 + 内存):
以程序加载为例:
初始状态:
1、进程创建后,操作系统会为其分配并初始化页表,同时把页表的物理地址存入 cr3 寄存器(cr3 是 CPU 的控制寄存器,用于存放当前进程的页表基地址,本质属于进程的硬件上下文);
2、页表预先 "覆盖进程的全部虚拟地址范围"(比如 32 位进程的 0~4GB),但这些虚拟地址对应的物理地址是 "未分配" 状态(页表标记为 "无效 / 未加载");程序的完整数据仍存在磁盘中。

触发加载:当进程执行到某段代码(或访问某段数据)时,CPU 根据虚拟地址,通过 cr3 寄存器找到当前进程的页表,查页表项的 "存在位(Present Bit)":
- 若存在位为
0(标记该虚拟地址对应的内容未加载到内存),则判定 "该虚拟地址无有效物理地址映射",触发缺页中断(属于内存管理);- 操作系统捕获中断后,从磁盘加载对应内容到物理内存,同时将页表项的存在位设为
1(标记已在内存)。
分批加载 + 映射:操作系统捕获缺页中断后,从磁盘中把 "当前需要的这一小块数据(比如 4KB 的页)" 加载到物理内存,同时更新页表:
- 给对应的虚拟地址分配实际物理地址;
- 将页表项的存在位设为 1(标记已在内存)。
重复过程:后续进程访问新的未加载虚拟地址时,重复 "CPU 通过 cr3 查页表→触发缺页中断→加载磁盘对应块→更新页表映射" 的流程,直到程序运行完毕。
3. 核心关联(cr3 + 页表 + 硬件上下文):
cr3 寄存器是进程切换时的关键硬件上下文:当操作系统切换进程时,会把新进程的页表基地址写入 cr3,让 CPU 后续能通过 cr3 找到新进程的页表 ------ 这也保证了 "每个进程的页表独立,虚拟地址空间相互隔离"。
小tip:进程创建时,是先创建内核数据结构(如 PCB),还是先加载可执行程序?
因为 PCB 会关联页表,而页表是实现 "代码 / 数据不一次性加载" 的核心 ------ 进程创建时,PCB、页表等内核结构先建好,但代码 / 数据不会全加载到内存,而是在页表中标记 "未加载(存在位 = 0)";等进程运行访问对应虚拟地址时,才通过缺页中断从磁盘加载。
从今天开始,咱们对进程的认知又升级啦~之前以为进程的内核数据结构只有 PCB,现在知道了:进程的内核数据结构其实包含task_struct(PCB)+ mm_struct(进程地址空间)+ 页表这一套组合,再加上程序的代码和数据,才是完整的进程~
