目录
一、前言
二、 程序的地址空间是真实的 --- 物理空间吗?
三、进程地址空间
🔥 操作系统是如何建立起进程与物理内存之间的联系的呢?
🔥什么是进程地址空间?
🔥为什么不能直接去访问物理内存?
🔥为什么有进程地址空间和页表?
🔥malloc和new开辟空间的原理
🔥什么是写时拷贝?
四、总结
五 、共勉
一、前言
对于 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发生了改变,而父进程没有改变,进程之间确实具有独立性。
可是最令人费解的是,父子进程读取该变量的地址竟然相同!?
这也就证实了之前我们所学习的所谓的内存分布图是假的,打印出来的地址也是假的,因为如果是物理内存地址,同一物理地址是不可能存放两个值的!!
结论:
- 我们所有用到的语言上的地址,都不是物理地址,而是虚拟地址(线性地址)。
- 此图不是物理内存分布图,而是进程地址空间分布图。
三、进程地址空间
现在我们就知道了文章开头给出的图片根本不是什么物理内存分布图,而是进程地址空间分布图。
真实的物理内存为:
完了,我们之前所学被颠覆了,那物理内存到底在哪里啊,进程是如何访问到物理内存的?
所以我们继续往下看:
🔥 操作系统是如何建立起进程与物理内存之间的联系的呢?
每一个进程都会存在一个进程地址空间,操作系统如何管理这些进程地址空间呢?
----- 先描述,再组织。
所以进程地址空间 --- 本质上就是一种数据结构,PCB中会有一个指针指向该数据结构,该数据结构中存储的就是对应的虚拟地址,所以操作系统对进程地址空间的管理也就变成了对该数据结构的管理。
另外操作系统会为我们维护一张映射表:页表。
- 该表中存储的就是虚拟地址与物理地址,通过虚拟地址就可以找到物理地址,也就建立起来了进程与物理内存的联系。
- 当创建子进程时,子进程会继承父进程的进程地址空间、页表等。
- 所以我们说父子进程代码共享,数据共享,是因为他们的页表是相同的。
- 但对共享的变量进行修改时,会发生写时拷贝,拷贝到的代码和数据也是新开辟在物理内存上的,此时子进程只需要修改页表,虚拟地址不变,而物理地址则是新开辟的物理地址。
所以才会出现虚拟地址相同,而物理地址不同的情况。
🔥什么是进程地址空间?
每一个进程都会存在一个进程地址空间,在32位操作系统下,该空间的大小为[0,4]GB。
上面说到:进程地址空间其实就是一个数据结构,那该数据结构中都存在有哪些内容呢?
查看Linux内核源码:查看 PCB(task_struct)
So :之前说‘程序的地址空间’是不准确的,准确的应该说成进程虚拟地址空间 ,每个进程都会有自己的地址空间,认为自己独占物理内存。操作系统在描述进程地址空间时,是以结构体的形式描述的,在linux中这种结构体是 struct mm_struct 。它在内核中是一个数据结构类型,具体进程的地址空间变量。
进程地址空间 就类似于一把尺子,每个空间都有对应的起始位置和结束位置。通过这个虚拟地址去间接访问内存;
🔥为什么不能直接去访问物理内存?
如果没有进程地址空间的加持,那么程序就会直接访问物理内存,没有区间可言,会存在恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有bug的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。
🔥为什么有进程地址空间和页表?
因为有了 进程地址空间 和 页表,在物理内存空间上不连续、无序的空间就可以通过页表这一映射关系联系在一起,让进程以统一的视角看待内存。
- 有了进程地址空间和页表后,每个进程都认为自己在独占内存,这样能更好的保障进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程管理与内存管理进行解耦合。
- 进程地址空间 + 页表 的设计是保护内存安全的重要手段!
🔥malloc和new开辟空间的原理
在之前的学习中,我们不知道进程地址空间的概念,所以 malloc 和 new 开辟空间我们总是默认为内存上的操作,而学习完进程地址空间后,你会发现并不是如此。
- 当代码执行到 malloc 和 new 时,操作系统(OS)不一定会直接将实际的物理内存分配给你,因为该进程可能不会立即使用该块内存,也就造成了内存浪费,OS一定要确保效率和资源使用率,所以OS给你分配的实际上是进程地址空间,地址也是虚拟地址,而且并不会在页表上建立有效的映射关系。
当检测到该进程实际要使用该块空间时(写入修改之类的操作,读取不算),会发生缺页中断,然后立即在页表中建立映射关系,此时该进程需要的物理内存空间才被申请。
这样做有什么好处呢?
- 充分保证内存的使用率,不会造成空转;
- 提升new或malloc的速度(因为没有实际在内存上开辟空间)。
🔥什么是写时拷贝?
Linux
中存在一个很有意思的机制:写时拷贝
- 这是一种 赌bo 行为,操作系统(OS) 此时就赌你不会对数据进行修改,这样就可以 使多个
进程
在访问同一个数据时,指向同一块空间,当发生改写行为时,再新开辟空间进行读写 - 这种行为对于内置类型来说感知还不是很强,但如果是自定义类型的话,
写时拷贝
行为可以在某些场景下减少拷贝构造
函数的调用次数(尤其是深拷贝
),尽可能提高效率
可以通过一个简单的例子来证明此现象
//计算 string 类的大小
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s;
cout << sizeof(s) << endl;
return 0;
}
原因:
g++
中的string
对象创建后,它就赌你不会直接改写,所以实际对象为一个指针类型(64位环境下为8字节),当发生改写行为时,触发写时拷贝
机制,再进行其他操作
操作系统是如何知道什么时候进行写时拷贝的呢?
- 在父进程创建子进程时,按之前所学子进程会继承父进程的进程地址空间和页表。
- 并且操作系统还会将父子进程的页表中数据对应的权限属性修改为只读!
当父或子进程修改(写入)该数据时,会发生缺页中断,但其实缺页中断做的工作不仅会在物理内存上开辟空间建立映射关系,还会对我们的访问操作做判断:
- 操作系统会判断,页表权限为只读,但数据所在的进程地址空间属于可读可写的数据区,操作系统明白了,这是要写时拷贝啊!
所以这就是操作系统判断什么时候进行写时拷贝的原理,根据这个方法,操作系统就能实现按需拷贝!
谁要使用(写入)给谁开辟新的物理空间,否则就不拷贝,共用物理内存空间。
四、总结
简而言之,首先,程序数据加载到内存后,由操作系统分配进程PCB(task_struct和mm_struct(进程虚拟地址空间))和页表。此时我们的进程就算是创建好了。
- 有了进程地址空间(虚拟地址),每个进程都认为自己独占内存资源,这样对于操作系统来讲,也更加偏于管理进程。
- 采用间接的地址访问方法访问物理内存。程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠。
- 如果没有进程地址空间的加持,那么程序就会直接访问物理内存,没有区间可言,会存在恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。反之有利于保护物理内存。
五 、共勉
以下就是我对【Linux系统编程】进程地址空间 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新【Linux系统编程】,请持续关注我哦!!!