进程地址空间:操作系统中的虚拟世界与心灵映射,深入解析进程地址空间
文章目录
- 引言
- 📘正文
- 📖问题引入
- 📖虚拟空间划分
- 📖真实空间分布
- 🖋️代码实现
- 🖋️问题反思
- 📖进程地址空间
- 🖋️虚拟地址
- 🖋️页表+MMU
- 🖋️写时拷贝
- 📕各部分结构的详细说明
- 🖊地址空间布局图
- 🖊代码段
- 🖊数据段
- 🖊 BSS段
- 🖊堆(Heap)
- 🖊 栈(Stack)
- 🖊C代码示例:进程地址空间
- 结语:进程地址空间的哲学意义

引言
在操作系统的浩瀚宇宙中,进程地址空间犹如一片神秘的荒原,深藏着无数的秘密与可能性。它是每一个程序运行的根基,是虚拟与现实交汇的地方,是计算机内存的魔法世界。它存在于操作系统的心脏之中,伴随着进程的诞生与死灭,演绎着一场关于资源管理与控制的永恒故事。
今天,我们将一起穿越这片无形的疆域,去探讨进程地址空间的奥秘,去感受它在操作系统中的重要地位与深刻影响。
对于 C/C++ 来说,程序中的内存包括这几部分:栈区
、堆区
、静态区
等,其中各个部分功能都不相同,比如函数的栈帧位于 栈区
,动态申请的空间位于 堆区
,全局变量和常量位于 静态区
,区域划分的意义是为了更好的使用和管理空间。
他们的结构排布如图所示。那么真实物理空间
也是如此划分吗?多进程运行 时,又是如何区分空间的呢?写时拷贝
机制原理是什么?本文将对这些问题进行解答.
内存条:真实的物理空间,用来存储各种数据
📘正文
📖问题引入
地址是唯一的,对地址进程编号的目的是为了不冲突
这是个耳熟能详的概念,在 C语言 学习阶段,我们可以通过对变量 & 取地址的方式,查看当前变量存储空间的首地址信息
#include <stdio.h>
int main()
{
const char* ps = "这是一个常量字符串";
printf("字符串地址:%p\n", ps); //%p 专门用来打印地址信息
return 0;
}
linux下的执行结果
利用前面学习的 fork 函数创建子进程,使得子进程和父进程共同使用一个变量
代码示例如下:
1#include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 #include<stdlib.h>
6 int main()
7 {
8 int val=10;
9 pid_t id=fork();
10 if(id==0)//子进程
11 {
12 val*=2;
13 printf("我是一个子进程,pid: %d ppid : %d 共享值: %d 共享值地址: %p\n",getpid(),getppid(),val,&val);
14 exit(0);
15 }
16 waitpid(id,0,0);
17 printf("我是父进程,pid: %d ppid: %d 共享值: %d 共享值地址: %p\n",getpid(),getppid(),val,&val);
18 return 0;
19 }
可以看到,针对同一个地址相同的值val,二者打印出来的共享值却不同,难度一个数可以同时拥有两个不同的值吗,这显然是不可能的。
这与之前提到的fork
有两个返回值原理类型,都利用了写时拷贝
因为真实地址
都是 唯一 的,分析:
- 不同的空间出现同名的情况
- 父子进程使用的真实物理空间并非同一块空间!
原因:
- 当子进程尝试修改共享值时,发生
写时拷贝
机制 - 语言层面的程序空间地址不是真实物理地址
- 一般将此地址称为
虚拟地址
或线性地址
结论: 语言层面的地址都是虚拟地址,用户无法看到真实的物理地址,由 OS 统一管理
📖虚拟空间划分
一般用户的认知中,C/C++ 程序内存分布如下图所示,直接表示内存中的各个部分
📖真实空间分布
但实际上的空间分布是这样的:
如果有多个进程(真实地址空间只有一份),此时情况是这样的:
🖋️代码实现
在实现虚拟地址空间时,是用结构体mm_struct
实现的
同task_struct
一样,mm_struct
中也包含了很多成员,比如不同区域的边界值
//简单展示其中的成员信息
mm_struct
{
//代码区域划分
unsigned long code_start;
unsigned long code_end;
//堆区域划分
unsigned long heap_start;
unsigned long heap_end;
//栈区域划分
unsigned long stack_start;
unsigned long stack_end;
//还有很多其他信息
……
}
因此其程序地址空间的管理,也就是区域划分,只需要修改不同区域的start
和end
即可.
每个进程都会有这样一个 mm_struct
,其中的区域划分就是虚拟地址空间
-
通过对边界值的调整,可以做到不同区域的增长,如堆区、栈区扩大
-
mm_struct 中的信息配合 页表+MMU 在对应的真实空间中使内存(程序寻址)
🖋️问题反思
此时可以理解为什么会发生同一块空间能读取到不同值的现象了
父子进程有着各自的 mm_struct,其成员起始值
一致
- 对于同一个变量,如果未改写,则两者的虚拟地址通过 页表 + MMU 转换后指向同一块空间
- 发生改写行为,此时会在真实空间中再开辟一块空间,拷贝变量值,让其中一个进程的虚拟地址空间映射改变,这种行为称为
写时拷贝
刚开始,父子进程共同使用同一块空间
在发生改写行为后,子进程在真实空间内重新开辟一块空间,拷贝变量值改下,虽然此时的虚拟地址仍为初始值,蛋映射关系已经发生变化。
📖进程地址空间
下面来好好谈谈 进程地址空间 (虚拟地址)
🖋️虚拟地址
在早期程序中,是没有虚拟地址空间
的,对于数据的写入和读取,是直接在物理地址
上进行的,程序与物理空间直接打交道,存在以下问题:
- 假设存在野指针问题,此时可能直接对物理内存造成越界读写
- 程序运行时,每次都需要大小为 4GB 的内存使用,当进程过多时,资源分配就会很紧张,引起进程阻塞,导致执行效率下降
- 动态申请内存后,需要依次释放,影响整体效率
为了解决各种问题,大佬们提出了 虚拟地址空间
这个概念,有了 虚拟空间
后,当进程创建时,系统会为其分配属于自己的 虚拟空间
,需要使用内存时,通过 寻址
的方式,使用物理地址上的空间即可
- 多个进程互不影响,动态使用,做到
效率、资源
双赢 - 发生越界行为时,
寻址
机制会检测出是否发生越界行为,如果发生了,能在其对物理地址造成影响前进行拦截 - 因为每个进程都有属于自己的空间,
OS
在管理进程时,能够以统一的视角进行管理,效率很高
光有 虚拟地址空间
是不够的,还需要一套完整的 ‘‘翻译’’ 机制进行程序寻址
,如 Linux 中的 页表 + MMU
🖋️页表+MMU
页表本质上是一张表,一侧存储虚拟地址,另一侧存储所映射的真实物理地址。
操作系统
会为每个 进程
分配一个 页表
,该 页表
使用 物理地址
存储。当 进程
使用类似 malloc 等需要 映射代码或数据
的操作时,操作系统 会在随后马上 修改页表
以加入新的 物理内存。当 进程
完成退出时,内核会将相关的页表项删除掉,以便分配给新的 进程
系统底层机制的研究是非常生涩的,这里简言之就是 页表 记录信息,通过 MMU 机制进行寻址使用内存.
假设目标空间为只读区域(比如数据段、代码段),在进行空间开辟时,会打上只读权限标签。后续对这块进行写入操作时,会直接拒绝
🖋️写时拷贝
思考:为什么要采取写时拷贝映射开辟的方式进行存储呢?每一个数据直接存储不可行吗?
写时拷贝是一种为了优化空间和时间应运而生的操作,带有一定赌博成分。
- 操作系统认为你对于数据的修改与存储操作相比,是较为低频的,因此采取
偷懒
的方式进行映射存储,多个进程中的共享数据
均指向同一块存储空间
,用来优化存储冗余
和多次构造
的情况 - 这一点在自定义类型时较为明显,内置类型的优化效率并不算高。
可以通过一个简单的例子来证明此现象
//计算 string 类的大小
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s;
cout << sizeof(s) << endl;
return 0;
}
VS2022中
linux环境中
分析:
在代码中,string
s;定义了一个C++的字符串对象。虽然你没有给字符串分配任何内容,但是字节大小却不为0。这是因为不同的编译器和标准库实现可能会有不同的内存布局,通常会包括指向动态内存的指针、当前大小、容量和一些额外的管理信息。
📕各部分结构的详细说明
操作系统将进程地址空间划分为多个区域,每个区域用于存储特定类型的数据。以下是典型的地址空间布局:
🖊地址空间布局图
以32位操作系统为例,地址空间布局如下:
+---------------------------+ 0xFFFFFFFF
| 内核空间 |
+---------------------------+ 0xC0000000
| 用户栈 |
+---------------------------+
| 动态分配的堆(Heap) |
+---------------------------+
| BSS段 |
+---------------------------+
| 数据段 |
+---------------------------+
| 代码段 |
+---------------------------+ 0x00000000
🖊代码段
- 存储内容:存放程序的可执行代码。
- 访问权限:只读,防止程序意外修改指令。
- 特点:多个进程可以共享同一段代码段(如共享库)
🖊数据段
- 存储内容:存储
已初始化
的全局变量
和静态变量
。 - 访问权限:读写权限。
- 特点:程序运行时大小固定。
🖊 BSS段
- 存储内容:存储
未初始化
的全局变量
和静态变量
。 - 特点:初始值默认为0,占用物理内存时才分配。
🖊堆(Heap)
- 存储内容:
动态分配
的内存(如malloc、new分配的内存)。 - 特点:向高地址增长;由程序员手动分配和释放。
🖊 栈(Stack)
- 存储内容:
局部变量
、函数调用参数
、返回地址
等。 - 特点:向低地址增长;由操作系统自动管理,超出范围会触发栈溢出。
上面的几种是主要的几种,还有几个小的内存区,比如字符段常量区
,字符常量区的内容不能修改,只有读权限
🖊C代码示例:进程地址空间
以下代码展示了不同段的地址空间位置。
#include <stdio.h>
#include <stdlib.h>
int global_var = 10; // 全局变量(数据段)
int uninit_var; // 未初始化变量(BSS段)
void print_addresses() {
int local_var = 20; // 局部变量(栈)
void *heap_var = malloc(10); // 动态内存(堆)
printf("代码段地址: %p\n", (void*)print_addresses);
printf("全局变量地址: %p\n", (void*)&global_var);
printf("未初始化全局变量地址: %p\n", (void*)&uninit_var);
printf("局部变量地址: %p\n", (void*)&local_var);
printf("堆变量地址: %p\n", heap_var);
free(heap_var);
}
int main() {
print_addresses();
return 0;
}
输出示例:
代码段地址: 0x401000
全局变量地址: 0x601020
未初始化全局变量地址: 0x601030
局部变量地址: 0x7ffd25d3f8bc
堆变量地址: 0x55d3ecf1b260
结语:进程地址空间的哲学意义
进程地址空间的概念,超越了技术的范畴,它涉及到操作系统如何管理和控制内存,如何为每个进程提供一个独立而安全的运行环境。在这个过程中,虚拟内存技术则如同魔法师一样,巧妙地将虚拟世界与现实世界结合在一起,为开发者创造出一个更加自由和高效的编程环境。
在这个隐形的、却至关重要的空间中,进程的每一次启动、每一次执行、每一次销毁,都在演绎着一场关于资源管理、内存控制与程序执行的深刻哲学思考。正如人生的每一个阶段,进程地址空间也在默默地展示着操作系统的智慧与魅力,成为计算机科学中不可或缺的一部分。
本篇关于进程地址空间的介绍就暂告段落啦,希望能对大家的学习产生帮助,欢迎各位佬前来支持斧正!!!