Linux学习之路 -- 进程篇 -- 进程地址空间

目录

一、背景介绍

二、进程地址空间

1.看现象

2.先简单描述一下地址空间(地址空间全在操作系统的内部)

3.地址空间详细一点的描述

4.进程地址空间里面的内容(部分)

三、进程地址空间的转换机制

1.页表

2.进程地址空间和页表存在的意义

<1>将物理内存从无序变为有序

<2>将进程管理和内存管理进行解耦合

<3>页表+地址空间是保护物理内存的重要手段

四、解释一些问题


一、背景介绍

在了解完环境变量后,我们再来谈一谈进程地址空间这个概念

在正式介绍这个概念之前,我们先看一张图

在学习c语言阶段时,这张内存图想必大家都不陌生,接下的话题都主要围绕这张图展开。在此之前我们会验证一些东西。其次我们主要讲解内容时用户空间,不是内核空间,内核空间涉及内容过多,且复杂,暂不谈论。

这里我们先验证一下这张图的合理性,看看每个区域是否如上图一样分布。先用一段代码来实现

#include<stdio.h>
#include<stdlib.h>
int num;
int nums = 100;

int main()
{
    const char* str = "hello";
    printf("代码区:%p\n",main);
    printf("常量区:%p\n",str);
    printf("初始化区域:%p\n",&nums);
    printf("未初始化区域:%p\n",&num);
    char* heap = (char*)malloc(10);
    printf("堆区区域:%p\n",heap);
    printf("栈区区域:%p\n",&heap);
    return 0;
}

运行结果:

这里我们看见,这几个区依次增长,其中我们可以发现,堆区和栈区中间存在大量的镂空。这些现象符合上图的规则。验证完它们的分布,我们再验证一下堆区和栈区的增长方向问题。下面我们再用一段代码来对这个问题进行验证。

int main()
{
    char* heap1 = (char*)malloc(1);
    char* heap2 = (char*)malloc(1);
    char* heap3 = (char*)malloc(1);
    printf("heap1 addr:%p\n",heap1);
    printf("heap2 addr:%p\n",heap2);
    printf("heap3 addr:%p\n",heap3);
    
    printf("stack head1 addr: %p\n",&heap1);
    printf("stack head2 addr: %p\n",&heap2);
    printf("stack head3 addr: %p\n",&heap3);
}

这里我们发现堆区地址依次增长,栈区地址依次减小

我们再对命令行参数和环境变量进行验证

int main(int argc, char* argv[],char* env[])
{

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

运行结果

我们可以看见,这里命令行参数地址比环境变量地址小,且环境变量的地址是向上增长的。

无论是命令行参数,还是命令含参数表里面的内容都是在栈的上面。

二、进程地址空间

1.看现象

在正式介绍进程地址空间之前,我们先来看一个奇怪的现象

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
int num;
int main(int argc, char* argv[],char* env[])
{
    int ret = 0;
    pid_t id = fork();
    if(id == 0)
    {
        while(1)
        {
            printf("child, pid:%d, ppid: %d,num: %d,&num: %p\n",getpid(),getppid(),num,&num);
            ret++;
            if(ret == 7)
            {
                num = 100;
                printf("child change nums\n");
            }
            sleep(2);
        }
    }
    else
    {
        while(1)
        {
            printf("father, pid:%d, ppid: %d,num: %d,&num: %p\n",getpid(),getppid(),num,&num);
            sleep(2);
        }
    }
}

运行结果:

一开始的运行结果还算是正常的,父子进程有相同的代码段和数据段。到后面num改变后,num有两个值也还可以理解,发生了写时拷贝,但是为什么不同的num值却有相同的地址?

根据上面的现象我们可以推出一个结论,我们打印出来的地址肯定不是存东西的地址(物理地址)。一个地址是不可能存两个值的,所以这个打印出来的地址就是虚拟地址/线性地址。我们现在用的地址全部不是物理地址。

上面我们第一张图,也不是物理空间分布图,而叫进程地址空间,每一个进程都有一个这样的空间。

2.先简单描述一下地址空间(地址空间全在操作系统的内部)

这里我们使用虚拟地址时,需要通过一张表,讲虚拟地址上一一映射到物理内存上,所以我们在使用虚拟地址时,是要在表上查找对应的物理内存。这也就能解释前面的现象。子进程拷贝父进程的进程地址空间和对应的映射表,所以父子继承就可以指向一段代码和数据。如果对子进程的一个变量进行修改,系统会重新开一段物理内存,也就是写时拷贝。这里会改变映射关系,但是不会修改虚拟地址,也就是说父子进程的虚拟地址是没变的,所以这里也就能解释上面的现象了,同时,这也是我们前面通过fork返回值进行分流的原因。

3.地址空间详细一点的描述

这个地址空间全程叫进程地址空间,每一个进程,都会存在一个进程地址空间,在32位机器下,这个地址空间的大小是[0,4GB]。我们都知道,普通机器上的物理内存也就是8G或4G,如果每一个进程地址空间上的所有虚拟地址都得到映射,肯定是不现实的。所以进程地址空间的大小其实就是操作系统对进程画的一张“饼”,让进程误以为自己拥有那么多的内存空间(实际上一个进程也用不到那么多的空间)。当然,操作系统也需要对这些”饼“进行管理,以防真出现内存不够用的情况。操作系统该如何管理这些空间呢?

根据"先描述,再组织",我们可以推出,我们可以用一个数据结构对其进行描述,具体到进程,就是特定数据结构的对象。

以上图为例,我们可以用上图所示结构对进程地址空间进行管理,对于进程地址空间的管理就变成了对链表的管理,每个进程PCB里面又存在属于自己的进程地址空间指针(也就是”struct 进程地址空间* “的指针,在内核中,这个描述进程地址空间的结构叫" struct mm_struct ")指向自己的地址空间,进程就可以通过这个指针找到自己的进程地址空间。

本质上来说,进程地址空间就是一个数据结构。

4.进程地址空间里面的内容(部分)

前面说了一大堆,我们大概的了解了一下进程地址空间是啥,接下来,我将介绍一下,进程地址空间里面存了啥东西,也就是进程地址空间里面的属性到底是啥?

要解释这个问题,其实我们可以通过本文的第一张图来解释,

我们可以看到,进程地址空间被分成了很多块,进程地址空间的主要属性也就是对区块的管理信息,我们可以把这个东西形象地比喻成对地盘划分,而进程地址空间的属性就像是对各个国家国界的标注信息,比如相邻国家的国界线是从哪里到哪里。所以实质上进程地址空间的属性就是每个区域的范围,从一段区域的开始,到一段区域的结束。而进程地址空间实际上是一段有界区间,所以我们可以通过下图方式,表示每个区域。

我们可以使用上图一样的方法就能把一段区间用表示出来,不过需要注意的是,在系统中,表示区间类型的是无符号长整型,这里为了方便演示,所以使用int类型。依次类推,我们可以把所有的区间表示出来,这些区间范围就是进程地址空间的主要属性。当然,肯定还有其他的属性,只不过这里不做介绍。

mm_struct参数(看看就行),下面两张图均取自《内核设计与实现》

我们对进程地址空间进行区域划分的本质是让区域内的地址都可以使用。

三、进程地址空间的转换机制

1.页表

进程地址空间,本质是线性/虚拟地址,它是不能存东西的,所以我们必需通过映射来实现对虚拟地址的转换,从而获取到对应的物理内存中的真实数据。这里我们就需要通过名为页表的映射表来实现上述的功能。下面用一张图简单描述一下系统通过页表找到物理内存地址的过程。

cpu从进程地址空间取得虚拟地址,通过CR3这个寄存器(这个寄存器存放的是页表的物理内存地址)可以找到页表(页表存在于物理地址中,每个进程也有对应的页表),通过页表上的映射关系,就可以找到虚拟地址所对应的数据。这些过程,包括虚拟地址的转换,查找,增加等等,都是由CPU上的MMU(内存管理单元) 完成的。

2.进程地址空间和页表存在的意义

<1>将物理内存从无序变为有序

通常,我们物理内存可能是不连续的,系统有时候也提供不了一整块连续的物理内存,所以系统会用一些零散的物理内存存放信息,通过页表,我们能把这些零散的物理地址存放的信息连到一起,让无序的物理内存信息变为有序,这样进程就能以统一视角看待内存。

<2>将进程管理和内存管理进行解耦合

因为有页表的存在,所以我们可以把物理内存的管理操作独立于进程管理,也就是说,我物理内存怎么分配,进程是不需要关心的,跟你进程也没有半毛钱关系。操作系统管物理内存时,就不需要考虑进程相关信息,而系统进行进程管理时,也不用关心物理内存的分配和使用。这样就降低了进程管理和内存管理的耦合性(耦合性:简单看成关联度)

<3>页表+地址空间是保护物理内存的重要手段

当用户进行非法的用户访问时,页表没法将虚拟地址转化成合理的物理内存地址,此时系统就会拒绝用户访问物理内存,这也就是平时在写C语言时,我们访问野指针时,系统并没有崩溃的原因。

四、解释一些问题

在上述知识的支撑下,我们可以解释一些问题

1.new / malloc的内存开辟相关问题

首先我们要思考一个问题,我们申请了内存后,我们会直接使用吗?显然是不一定的。

对于系统来说,系统并不知道用户开辟内存后是否会直接使用这些内存,如果用户没有使用这些内存,系统也得不到这些内存的使用权,这就会造成一定浪费。但这对于以操作系统来说是不可接受的,操作系统是必需追求高效的,所以为了保证系统的高效性,系统不会先给用户在物理内存上开空间,当用户malloc和new的时候是从进程地址空间中申请虚拟地址,此时的虚拟地址没有建立对应的映射关系。只有当用户真的要使用物理内存空间时(这个过程中还要检查请求是否合理),系统才会开辟对应的物理内存。如果用户真要使用物理内存时,系统发现该用户提供的虚拟地址没有对应的映射关系,系统中断用户的下一步操作,去建立新的映射关系并申请物理内存,这个过程称为缺页中断(这里不详谈),执行完该操作后,继续用户的下一步操作。

上述这样操作的好处什么呢?

<1>充分的保证了内存的使用率,不会导致空转

<2>提升了new和malloc的速度

我们在申请内存时,只需要从进程地址空间中申请虚拟地址即可,真要使用物理内存时,再进行物理内存的申请和页表映射关系的构建,这个过程并不会降低操作系统的速度,因为这些工作本来就是要做,只不过分成了两个阶段去执行罢了,时间成本上并无差异。


如果向具体了解进程地址空间的相关内容和操作,参看《Linux内核设计与实现》一书,本文仅仅是简单介绍了进程地址空间。

文中如有不对之处,还望各位大佬指正,谢谢!!!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值