樊梓慕:个人主页

个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》

每一个不曾起舞的日子,都是对生命的辜负


目录

前言

1.程序地址空间

1.1验证地址空间的排布

1.2利用fork函数观察当子进程修改某个共享变量时父子进程分别读取到的值和地址

2.进程地址空间

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

2.2什么是进程地址空间?

2.3为什么有进程地址空间和页表

2.4malloc和new开辟空间的原理

2.5页表与写时拷贝的更多细节


前言

在之前学习进程概念时我们提到过fork函数,了解了如何创建进程,并且知道了fork之后的父子进程代码共享,当父子对共享的变量做修改时会拷贝一份到自己这再做修改(写时拷贝),但当时对于一个变量为什么能有两个值我们的讲解仍然十分局限,今天在学习完进程地址空间后,我想你就会明白原因所在。

欢迎大家收藏以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。

=========================================================================

GITEE相关代码:fanfei_c的仓库

=========================================================================


1.程序地址空间

在之前学习内存管理时我相信你一定见过这张图:


当时我们说这是底层物理内存的分布,那今天我可能要告诉你他其实并不是,而只是操作系统创造出来的一个虚拟的结构,而真实的物理内存分布其实并不是如此。

但正式开始之前:我们还是来验证一下数据是不是按如图所示进行排列的呢?


1.1验证地址空间的排布

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;}

打印出来看看是不是这样呢?

补充知识:当一个变量被定义为static变量时,其实该变量的地址就被放到了全局变量的区域,他在某种意义上来讲就是全局变量,但是由于编译器的原因会对他进行语法上的检查等,才呈现出了静态变量的特性。


1.2利用fork函数观察当子进程修改某个共享变量时父子进程分别读取到的值和地址

既然我们之前在进程概念的学习中创建过子进程,那我们刚好可以观察一下当子进程修改某一共享变量时,父子进程读取到的该变量的值是否会发生改变,该变量的地址又呈现出什么样的内容?

int g_val = 100;int main(){pid_t id = fork();if(id == 0){int cnt = 0;//子进程while(1){printf("child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);//获取子进程信息以及变量g_val的值与地址sleep(1);cnt++;if(cnt == 2)//2s后修改全局变量g_val的值为200{g_val = 200;printf("child change g_val: 100->200\n");}}}else{while(1){printf("father, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);//获取父进程信息以及变量g_val的值与地址sleep(1);}}}

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


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

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

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

结论:

  • 我们所有用到的语言上的地址,都不是物理地址,而是虚拟地址(线性地址)
  • 此图不是物理内存分布图,而是进程地址空间分布图。

2.进程地址空间

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

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

所以我们继续往下看:


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

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

先描述,再组织。

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

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

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

当创建子进程时,子进程会继承父进程的进程地址空间、页表等

所以我们说父子进程代码共享,数据共享,是因为他们的页表是相同的。

但对共享的变量进行修改时,会发生写时拷贝,拷贝到的代码和数据也是新开辟在物理内存上的,此时子进程只需要修改页表,虚拟地址不变,而物理地址则是新开辟的物理地址

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


2.2什么是进程地址空间?

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

上面说到:进程地址空间其实就是一个数据结构,那该数据结构中都存在有哪些内容呢?

查看Linux内核源码:


我们找到mm_struct的定义:

struct mm_struct{struct vm_area_struct* mmap;struct rb_root mm_rb; struct vm_area_struct* mmap_cache;//....unsingned long start_code, end_code, start_data, end_data;//代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_dataunsigned long start_brk, brk, start_stack;//start_brk和brk记录有关堆的信息,//start_brk是用户虚拟地址空间初始化,//brk是当前堆的结束地址,//start_stack是栈的起始地址unsigned long arg_start, arg_end, env_start, env_end; //参数段的开始arg_start,结束arg_end,//环境段的开始env_start,结束env_end}

那么如何理解各个数据存放的区域呢,如上面的源码所示:就是利用首尾的位置信息。

通过这些信息我们就可以:

  • 判断是否越界
  • 可以进行扩大和缩小范围

区域划分的本质就是区域内的地址我们可以使用。

可是我们又知道进程地址空间是不具备保存实际的代码和数据的能力的。

这些代码和数据实际是放置在物理内存上的。

所以就需要页表的存在来将虚拟地址转化为实际的物理内存地址

那转化的工作是谁来做呢?

  • 粗浅的说是CPU,在转化的过程中,CPU中的CR3寄存器会记录页表的地址(注意:CR3中存储的地址一定是真实的物理地址,如果是虚拟地址,那CPU还不知道页表在哪,那怎么通过映射关系找到CR3中虚拟地址映射到实际的物理地址呢),当CPU开始执行正文代码时,假设遇到了a++这样的指令,那么CPU就会根据CR3寄存器中页表的地址进行查表,从而就得到了物理内存地址,也就找到了a的值。
  • 准确的说,这个转化工作是由CPU中的硬件单元MMU(内存管理单元)完成的。

2.3为什么有进程地址空间和页表

  • 因为有了进程地址空间和页表,物理内存空间上不连续、无序的空间就可以通过页表这一映射关系联系在一起,让进程以统一的视角看待内存。
  • 有了进程地址空间和页表后,每个进程都认为自己在独占内存,这样能更好的保障进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程管理与内存管理进行解耦合。
  • 地址空间+页表的设计是保护内存安全的重要手段!

2.4malloc和new开辟空间的原理

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

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

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

这样做有什么好处呢?

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

2.5页表与写时拷贝的更多细节

页表其实不光存放虚拟地址和物理内存地址,还有其他的属性,比如会存放权限属性。

什么意思呢?

我们平时写代码时常量不可修改究竟是谁决定的?

  • 其实就是操作系统在页表中该数据的权限属性上放置的是’r’,当你要对该数据进行修改时(写入)时,首先需要进行虚拟地址与物理地址的转化,转化的过程中操作系统发现权限为只读,所以才不可修改不可写入。

那const修饰的数据是不是也是由页表决定的呢?

  • 不是const与系统没有任何关系,const是编译器检查前后语法的问题。const的意义是将可能在未来运行时出现的错误提前在编译阶段发现并报错。所以我们说const能加则加,是一种好的编程习惯,防御性编程。

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

在父进程创建子进程时,按之前所学子进程会继承父进程的进程地址空间和页表。

并且操作系统还会将父子进程的页表中数据对应的权限属性修改为只读!

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

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

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

谁要使用(写入)给谁开辟新的物理空间,否则就不拷贝,共用物理内存空间。


=========================================================================

如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容

博主很需要大家的支持,你的支持是我创作的不竭动力

~ 点赞收藏+关注 ~

=========================================================================