您现在的位置是:首页 >其他 >虚幻or现实?堆区、栈区真实存在吗?是操作系统在骗你罢了...网站首页其他

虚幻or现实?堆区、栈区真实存在吗?是操作系统在骗你罢了...

花想云 2024-06-17 11:26:46
简介虚幻or现实?堆区、栈区真实存在吗?是操作系统在骗你罢了...

在这里插入图片描述

?专栏导读

?作者简介:花想云 ,在读本科生一枚,C/C++领域新星创作者,新星计划导师,阿里云专家博主,CSDN内容合伙人…致力于 C/C++、Linux 学习。

?专栏简介:本文收录于 Linux从入门到精通,本专栏主要内容为本专栏主要内容为Linux的系统性学习,专为小白打造的文章专栏。

?相关专栏推荐:C语言初阶系列C语言进阶系列 C++系列数据结构与算法

?文章导读

本章我们将对程序地址空间进行讲解,理解虚拟地址的运作逻辑,认识虚拟地址与物理地址如何建立联系以及虚拟地址存在的意义~

在这里插入图片描述

?引例

——为什么一个变量拥有两个不同的值?

之前在第一次认识fork时,我们就知道fork是用来创建子进程的。先来感受一段代码,我们定义一个全局变量g_val,在父进程与子进程中分别查看g_val的值以及它的地址。

#include <stdio.h>
#include <unistd.h>

int g_val = 100;

int main()
{
  pid_t id = fork();

  if(id < 0)
  {
    perror("fork");
    return 0;
  }
  else if(id == 0)
  {
    // 子进程
    while(1)
    {
       printf("我是子进程,pid=%d,ppid=%d,g_val=%d,&g_val=%d
",getpid(),getppid(),g_val,&g_val);
       sleep(1);
    }
  }
  else 
  {
    // 父进程
    while(1)
    {
      printf("我是父进程,pid=%d,ppid=%d,g_val=%d,&g_val=%d
",getpid(),getppid(),g_val,&g_val);
      sleep(1);
    }
  }

  return 0;
}

在这里插入图片描述

如图所示,我们发现父子进程打印的值与地址完全相同。接着再来做测试,倘若在子进程中改变g_val的值,那么父进程中的g_val会不会一起跟着变化呢?

#include <stdio.h>
#include <unistd.h>

int g_val = 100;

int main()
{
  pid_t id = fork();

  if(id < 0)
  {
    perror("fork");
    return 0;
  }
  else if(id == 0)
  {
    // 子进程
    while(1)
    {
       printf("我是子进程,pid=%d,ppid=%d,g_val=%d,&g_val=%d
",getpid(),getppid(),g_val,&g_val);
       sleep(1);
       g_val = 200;
    }
  }
  else 
  {
    // 父进程
    while(1)
    {
      printf("我是父进程,pid=%d,ppid=%d,g_val=%d,&g_val=%d
",getpid(),getppid(),g_val,&g_val);
      sleep(1);
    }
  }
  return 0;
}

在这里插入图片描述

结果如图所示,我们发现在子进程中修改变量的值,并不会影响父进程。我们知道进程具有独立性,父子进程之间互不影响好像也能说得过去,但是两个进程中g_val的地址都是相同的,一个地址怎么能存两个不同的值呢只能说明这两个地址其实并不是真正的物理地址。

对此我们可以得出结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量;
  • 但它们地址值是一样的,说明该地址绝对不是物理地址!
  • 在Linux地址中,这种地址叫做 虚拟地址
  • 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。

接下来我们就来看看何为虚拟地址,它又为什么而存在。

?进程地址空间

或许我们之前都是这么看待内存的——
在这里插入图片描述
如图所示,我们以前经常把内存区域作划分,并强调哪些变量应该存储在内存的哪些区域,其实这些区域的划分都是虚拟内存。

那么这些不同的区域(栈区、堆区、常量区等)又是如何进行划分的呢?

  • 进程地址空间本质上就是一个内核数据结构——struct mm_struct,由操作系统所管理。

我们知道内存的地址是连续的,也就意味着内存空间是一种线性结构。OS通过对内核数据结构的划分完成对内存区域的划分,例如:

struct mm_struct
{
	// 代码区
	long code_start; // [0,100]
	long code_end;
	// 初始化区域
	long init_start; // [200,500]
	long init_end;
	// 栈区
	long stack_start; //[x,x+n]
	long start_end;
	//...
}

?虚拟地址与物理内存的联系

虚拟地址是虚拟的、不存在的。可是我们所写的那些变量常量等等总得找个地方存起来吧。它们可都是实实在在的存储在物理内存上的。难道虚拟内存所有的内容都与物理内存一一对应吗?那当然是不可能的。

其实在虚拟内存与物理地址之间还存在这一个媒介——页表。它负责将虚拟地址与物理地址形成一种映射关系。

在这里插入图片描述

?回答引例中的问题

有了上面的概念,我们现在可以清楚。子进程是被父进程创建出来的,所以子进程没有自己的代码和数据,只能继承自父进程。所以子进程与父进程中的g_val地址相同,现在我们知道这两个地址其实都是虚拟地址。

  • 父进程与子进程中的g_val会被页表映射到同一个物理地址。

有的小伙伴可能会疑问,不对呀,那这样刚才岂不是白讲了。这样的话我们改变子进程中的g_val,父进程不也会跟着变吗?

答案是,还差非常重要的一点没有提出来——写时拷贝

?写时拷贝

由于父进程和子进程的g_val一开始内容是完全相同的,没必要再新开一块空间存储一个重复的数据,避免浪费。所以,只要我们不修改g_val的值,两个g_val存在共用一块内存区域是合理的。

但是,一旦有一方需要修改g_val的值,为了避免一方修改会影响到另一方,此时OS会在内存中重新找到一块区域,将修改后的值放进去,并让页表将虚拟地址从旧的物理地址,映射到新的虚拟地址。这就叫做写时拷贝

在这里插入图片描述

  • 写时拷贝概念图

在这里插入图片描述

?虚拟地址存在的意义

举个例子,假设现在有100个班级,需要去一个水果商店免费水果。现在校长把任务交给了这100个班主任,让他们自行组织安排。班主任们并不需要考虑所有学生的人数或者男女比例或者强壮程度等等,只需要安排好自己班级的每个学生应该完成什么任务。这样,每个班主任各自拟好了一个任务安排表,并把它交给商店老板。商店老板会根据任务安排表,分配好每个班级应领取的水果数量、种类等等,到时候直接同时学生们过来取走即可。

在这个小例子中,学生就相当于代码和数据任务安排表相当于虚拟地址商店老板就相当于页表商店的水果相当于物理内存

假设不存在虚拟地址,也就是任务安排表不存在,商店老板就不能对着安排表做出规划,同学们与领取水果时,势必会杂乱无章甚至可能起了冲突。例如几个同学一起盯上了为数不多的榴莲…况且,同学们不知道商店中哪些水果可以拿。哪些已经被顾客预定了,不能拿…

对比这个例子,虚拟地址的作用有:

  • 防止地址随意访问,保护物理内存与其他进程(防止争抢水果)
  • 将进程管理与内存管理进行解耦合(班主任管学生,商店老板管水果)
  • 可以让进程以统一的视角看待自己的代码和数据(每个班主任只管自己的学生)

?malloc的本质

站在一个进程的角度

在很多时候,我们malloc申请了一块内存空间(现在我们知道这其实是虚拟内存),并没有立即对它进行使用,但是这块空间已经在某个变量的名下了,此时这块空间是不能再被其他变量所占用的,这是合理的。

站在OS的角度

那么当一个进程向OS申请内存时,请问OS是直接给它呢?还是在进程需要用的时候再给它呢?答案是在需要时再给它。

因为OS不允许有任何的浪费或者不高效的行为。假设进程一申请内存,OS就给它分配内存,但是进程又暂时不用。那么它自己不用也就算了,别的进程也用不了。一个进程这样做也就罢了,当大量的进程都这样做时就会造成严重的浪费与效率低下。

  • 其实,当我们的程序在编译完成后,就已经按照虚拟地址对代码和数据进行编制了

本章的内容就到这里了,如果觉得对你有帮助的话就支持一下博主吧~

在这里插入图片描述
在这里插入图片描述

点击下方个人名片,交流会更方便哦~
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。