可执行程序是如何运行的,进程地址空间(虚拟/线性)

进程地址空间

地址空间这一概念,我们在以往的学习中肯定是有这么一个概念的,如果记忆有点模糊,那看看下面这个图也应该懂了:

 早在我们c++的内存管理的博客那我们就谈论过这个地址空间,为了证明我们的地址空间果真如我们想象的一样是这样排布的,我们用下面的代码输出我们的各个区域数据所在地址:

  //数据所在虚拟地址空间位置代码    
  #include<stdio.h>    
  #include<stdlib.h>    
      
  int g_data=10;    
  int uninit_g_data; 
      
W>int main(int argc,char*argv[],char*env[])    
  {    
    printf("代码段地址(main函数也是代码):%p\n",main);    
    char *str= "abcde";    
    static s_val=1;                                                                                                                    
    printf("常量数据地址:%p\n",str);    
    printf("已初始化全局变量地址:%p\n",&g_data);    
    printf("未初始化全局变量地址:%p\n",&uninit_g_data);    
    printf("静态数据地址:%p\n",&s_val);    
    int *val=(int*)malloc(sizeof(4));    
    int *val1=(int*)malloc(sizeof(4));    
    int *val2=(int*)malloc(sizeof(4));    
    int *val3=(int*)malloc(sizeof(4));    
    printf("堆区数据1地址:%p\n",val);    
    printf("堆区数据2地址:%p\n",val1);    
    printf("堆区数据3地址:%p\n",val2);    
    printf("堆区数据4地址:%p\n",val3);    
    printf("栈区数据1地址:%p\n",&val);    
    printf("栈区数据2地址:%p\n",&val1);    
    printf("栈区数据3地址:%p\n",&val2);    
    printf("栈区数据4地址:%p\n",&val3);    
    printf("命令行参数地址:%p\n",argv[0]);    
    printf("全局环境变量参数地址:%p\n",env[0]);    
      
    return 0;    
  }

 现象:

 我们使用代码证明出来了我们这些数据的地址;这些地址确实是按照我们上面的图片所示排布的,我们的地址空间也确实可以这么划分!

进程地址空间就是是虚拟地址空间

一开始看到这个标题是不是非常震惊,为什么我们的划分的这么好的地址空间是虚拟的呢?但是我们仔细一想又发现我们的进程地址空间是大小恒定的,在32位机器下一值是4g可是我们现实生活中的内存往往都有2,4,6,8甚至是16这些不同的内存大小,那这不就于进程地址空间的恒定大小冲突了吗;这只是我们的想法,接下来我们看看这样的代码;

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
  int count=0;
  int val=100;
  int id=fork();
  if(id==0)
  {
    while(1)
    {
      printf("I am child pid=%d ppid=%d\n",getpid(),getppid());
      printf("val=%d 地址是:%p\n",val,&val);
      sleep(3);
      if(count==1)
      {
        val=0;//第二次进入循环后修改val的值
        printf("修改成功\n");
      }
      count++;                                                                                                     
    }
  }
  else{
    while(1)
    {
      printf("I am father pid=%d ppid=%d\n",getpid(),getppid());
      printf("val=%d 地址是:%p\n",val,&val);
      sleep(3);
    }
  }
  return 0;
}

现象:

我们发现了,我们在父进程中创建子进程之后,我们的父子的val的值和地址是一样的,但是在我们的子进程中修改了我们的val值之后,我们的父进程的val值还是原来的值,但是我们父子进程的val值的地址居然是一样的,这说明了什么?说明了一个地址上的值是不同的!可是这种情况存在吗?在物理地址上这是一定不可能的!因为一个物理地址上对应的数据一定是固定的,那么这样的话,我们得出结论:我们输出的现象中的地址一定不是物理地址,也就是说进程空间地址不是我们的物理地址,那这个现象的地址是什么呢?

我们叫这个现象中的地址一般叫做虚拟地址(线性地址),那什么是虚拟地址呢,虚拟地址有什么用呢?不要着急,我们接下来会慢慢解释的。

虚拟地址

现在我们知道了我们现象中的地址是虚拟地址了,并且我还想说我们的的高级语言如c++/c/java等这样的语言中的地址空间都指的是虚拟地址空间,他们对应的都不是物理地址!

虽然虚拟地址不是物理地址,但是我们的写的代码产生的可执行程序一定是会产生数据的(代码也是数据)我们的可执行程序也一定需要被加载到内存上才能运行,那么这样的话,我们是一定会使用物理空间的,那么既然要使用物理空间,我们的现象中出现的又不是物理空间,那我们的数据到底是怎么存储的呢?其实我们的虚拟地址空间和我们的物理空间是相对应的,虚拟地址空间和我们的物理地址空间之间有一个叫页表的东西,操作系统会通过页表将我们虚拟地址空间上的数据一一对应到我们的物理地址空间上;

虚拟地址的补充:

我们在编译好了可执行程序的时候,我们的变量,代码等数据都是已经被安排好了虚拟地址的,因为地址是虚拟的,在编程时我们定义数据是需要先申请的(否则语法上都不会编译成功),我们申请就是向虚拟地址空间申请,虚拟地址空间会在它自己这个4g大小的空间上安排好每个申请好的数据的地址,代码也会放到相应的代码区,总之我们的可执行程序有自己的虚拟空间,我们的数据已经在虚拟空间上被编写好了地址了;每个进程都有自己的一份虚拟空间和页表!

就像这张图一样我们的可执行程序中的数据是由操作系统管理通过页表映射到我们的内存上的;也就是说我们费了好大的力气理解的内存空间居然不是真正的内存而是假的内存,我们将数据编排到这个假的内存上,然后由我们的操作系统把我们编排好的数据放到我们真正的内存上面;

为什么要这么做呢?这样做不是复杂了我们的数据加载到内存中吗?

这样做的目的有很多,我们先来解释第一点:

安全性

在早期的计算机中,我们的进程加载到内存中,实际上就是直接加载到物理空间上的;

 如果发生图中的情况,因为我们的进程直接加载物理内存中,我们的进程1在编写时故意或者失误的编写了这样的野指针指向了别的进程甚至是我们的操作系统的内核空间,我们如果解引用修改我们的指针指向的数据,可以就会导致我们的操作系统或者其他进程崩溃(但现在的进程都是具有独立性的这也是区别),或者我们使用野指针读区进程2中的数据例如密码从而达到破解进程的效果这样的行为,这些是直接将进程加载到内存中所带来的安全风险;

而我们的虚拟地址空间的出现就解决了这一风险;

我们的代码编译成可执行程序时,它自己就已经拥有了一套地址这套地址叫做虚拟地址,我们的可执行程序被加载到内存中时,由我们的操作系统通过页表将可执行程序中的指令分批加载到我们的内存中,这个时候因为有操作系统的干预,我们的数据在加载的过程中由操作系统的进程管理模块进行管理,如果是非法的访问会被我们操作系统所拒绝,所以提高了我们内存的安全性;

效率

由于虚拟地址的出现,我们的进程只需要在意自己的这套虚拟地址空间的使用,当我们需要空间时我们会向虚拟地址空间申请,而操作系统的进程管理模块会对我们申请的需求进行判断,如果需求合理则同意申请,我们就可以使用这块空间,我们就在虚拟地址空间上就已经拥有了这块空间;如果申请不合理就会拒绝甚至中断进程;当申请通过时我们的真正的物理空间会在我们需要的时候给我们腾出位置来放置我们的进程数据;而这个腾出空间放置数据这些工作则交给我们的操作系统的内存管理模块;这样我们的模块之间有不同的工作,进程管理模块负责对我们的进程行为进行操作,内存管理模块负责对我们的数据的写入进行操作,相互之间的工作是独立的,这样每个模块做自己的工作,解耦合,提高了计算机的工作效率;

另外,在另一方面我们向虚拟地址申请空间时虚拟地址空间把空间给我了,我们也默许了这块空间是我们的,但是如果我们并没有使用这片空间,就比如我们数组我们int a[1000];我们向虚拟地址空间申请了1000*4字节的空间,可是我们暂时还没有给它填充数据,这个时候如果直接使用的是物理空间那么我们肯定不能动这4000字节的空间;但是这个时候我们是向虚拟地址空间申请的;所以在物理地址空间上我们是没有这片空间的(因为我们没有写入数据),只有当我们真正写入数据到这4000个字节上时,物理空间上真正存储了我们的数据的时候,物理空间才被我们使用了;就是说我们只有占用了物理空间时物理空间才被使用了;这样我们的物理空间永远是被使用时才会被消耗,这样大大的提高了我们内存的利用效率;

 独立性

在进程的学习中,我们就知道了进程是具有独立性的,那进程是如何具有独立性的呢,这就是我们的虚拟地址空间的作用了,每个进程都拥有自己的虚拟地址空间,进程管理模块对我们每个进程的操作进程管理,而数据在物理空间上的存储由内存管理模块去处理,在物理内存空间上安排;这样我们每个进程只需要知道我们自己的虚拟地址空间上的数据是怎么样存放的,cpu读取指令的时候也是通过我们虚拟地址空间上的地址来找到我们下一段代码的虚拟空间地址然后通过页表映射在内存中的物理地址,cpu再把物理地址上的数据读入到寄存器中,这就是进程运行的过程;由此我们进程之间不知道对方的存在只在虚拟空间上申请空间,这样就成功的使得进程具有独立性了;

具体找下一条指令的过程在这里(这是询问chat gpt给出的答案)

我认为和我的思路大致相同,可以看下面的回答补充我的不足

1. 当前运行的程序通过PC指针指向当前指令的虚拟地址。
2. 当CPU执行完当前指令后,PC指针会自动递增,指向下一条指令的虚拟地址。
3. 当访问下一条指令时,首先会通过页表将虚拟地址转换为物理地址。
4. 虚拟地址转物理地址的过程中,页表会进行查找和映射,确定虚拟页号对应的物理页号。
5. 在页表映射成功后,物理页号会与页内偏移量组合成物理地址。
6. CPU从物理地址中读取对应的指令,并继续执行下一条指令。
7. 执行完下一条指令后,重复以上步骤,通过PC指针和页表实现指令的连续加载与执行 

可执行程序也就是这么一步一步的运行下去的;

虚拟地址空间是一种内核的数据结构

我们的编译器把我们的各种语言写的代码编译成可执行程序时,会给给这些数据附上虚拟地址空间,这些虚拟地址空间的范围在内核中是一种叫mm_struct的结构体,这个结构体中有着各个区域的划分,我们的操作系统也就可以结合这个结构体对我们的进程进行管理;怎么管理呢?先描述再组织,把地址空间描述成一个个结构体,再把每个进程的结构体组织起来形成数据结构;对这些数据增删查改,这就是管理;(具体细节还需要深入学习)

写时拷贝

我们知道了虚拟地址的这些作用以及原理之后,我们对一开始的实验的现象也可以做出解释了,在我们的父进程创建出子进程之后,父进程和子进程的代码是一模一样的所以进程地址空间上数据的位置也相同,页表上的映射甚至也是相同的,所以他们的val数据所在的虚拟地址也是相同的,当我们更改val的数据的时候,我们的进程地址空间上的位置不变化,因为进程不知道对方进程的存在,我们的操作系统会改变我们子进程的数据在页表上的映射关系,使得子进程的val映射到另外的物理空间上,这个过程就叫做写时拷贝;所以我们这个现象上的val虚拟地址相同,但是val的值不同,这也是由不同的映射导致的;

我们之前进程中讲到的fork自然也是这样在父子进程中页表的映射关系变了,所以fork的返回值储存在了两个不同的物理地址上面,我们通过虚拟地址上的映射关系找到id的不同的值;

2023.11.17

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值