【Linux】 - 深度理解进程地址空间

测试环境

Linux kernel 2.6.32
32位平台

1.程序地址空间回顾

相信大家在学习C/C++或者其它语言的时候,一定见到过一幅类似于这样空间布局图:
在这里插入图片描述
语言阶段我们应该是将这幅图称之为“程序地址空间分布图”,不过这幅图可能和你在学习语言阶段所见到的图有些许的差别。大家以前见到的图应该不是完整的,那是因为如果涉及系统知识可能会不太好讲解。

首先我想先写一段代码验证一下这幅图结构布局,顺便带大家回顾一下以前所学的知识:

#include <stdio.h>
#include <stdlib.h>

int g_val = 10;
int g_unval;

int main(int argc, char* argv[], char* env[])
{
   printf("code addr: %p\n", main);  // <=== 正文代码区
  printf("g addr: %p\n", &g_val);  // <=== 初始化数据区
  printf("g uninit addr: %p\n", &g_unval);  // <=== 未初始化数据区
  
  char* mem = (char*)malloc(10);
  printf("heap addr: %p\n", mem);  // <=== 堆区

  printf("stack addr: %p\n", &mem);  // <=== 栈区

  printf("opt addr: %p\n", argv[0]);  // <=== 命令行参数
  printf("opt addr: %p\n", argv[argc - 1]);

  printf("env addr: %p\n", env[0]);  // <=== 环境变量

  return 0;
}

这是一段C语言代码,代码实现的功能是在程序地址分布图的每个区域定义变量,然后通过打印这些变量的地址,观察这些变量的地址是不是如图中所示,从正文代码区到命令行参数区由低到高增长(由于共享区里存放的是动态库和共享内存,这些地址不方便打印,所以不包括共享区)。

运行结果:
在这里插入图片描述
我们发现的确如图中一样,这些区域的地址是由低到高增长的。

接下来我想在这里纠正一个概念,之前我们一直叫的“程序地址空间”严格来说并不准确,准确的叫法应该是“进程地址空间”,因为程序加载进内存后就变成了一个进程,所以地址空间应该站在进程的角度去分析。

程序地址空间我们就先回顾到这里,接下来我会从系统的角度出发,带大家更深刻的认识地址空间。

2. 地址空间的“领地意识”

一开始我们先来对地址空间有一个浅度的认识,这里我来提一个问题:从地址空间分布图中我们看到,进程地址空间划分了许多区域。那么我想问,这块空间的区域是不是每时每刻都在被进程使用?

为了回答这个问题,我为大家举一个“森林之王”老虎的例子。

我们应该知道,动物世界中像老虎这样的霸主一般情况下都有强烈的“领地意识” 。一只老虎一定会拥有一块领地,这块领地属于该老虎的活动范围,供自己栖息生活。
在这里插入图片描述
上图我为大家简化出了一块老虎的领地,试问该领地内的区域是随时都被老虎占据的吗?

显然不是的,老虎对该领地的使用应该是这样的。当老虎想要休息的时候就去占据休息区,想要吃饭的时候就去占据进食区,想锻炼身体的时候就去占据锻炼区。这些区域仅仅是属于老虎活动范围,但这并不意味着老虎会一直占据它们,老虎只会在特定的情况下去占据特定的区域。

同理,回到系统中,进程就像这只老虎,进程的地址空间就像这块老虎的领地。进程地址空间的作用仅仅是为进程衡量了一块空间,这块空间属于进程的使用范围,但并不意味着进程占据了该地址空间的所有部分。

这是我们应该对进程地址空间的第一层理解。

3. 地址空间是物理内存吗?

我们来看下面这段代码:

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

int g_val = 100;

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

  if(id == 0)
  {
    //child
    printf("g_val: %d , g_val addr: %p, child\n", g_val, &g_val);
    sleep(1);
  }

  else if(id > 0)
  {
    //father
    sleep(1);
    printf("g_val: %d , g_val addr: %p, father\n", g_val, &g_val);
    sleep(1);
  }

  else
  {
    //error
    printf("error\n");
  }

  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)
  {
    //child
    g_val = 1000;
    printf("g_val: %d , g_val addr: %p, child\n", g_val, &g_val);
    sleep(1);
  }

  else if(id > 0)
  {
    //father
    sleep(1);
    printf("g_val: %d , g_val addr: %p, father\n", g_val, &g_val);
    sleep(1);
  }

  else
  {
    //error
    printf("error\n");
  }

  return 0;
}

我在子进程的分流中,将全局变量g_val的值改成了1000。我们先来推测一下,当我再次运行这段代码之后,会发生什么?是不是父进程和子进程打印出来的值都变成了1000,来看运行结果:
在这里插入图片描述
我们惊讶的发现,当子进程修改全局变量之后,子进程所打印出来的全局变量值发生了改变,而父进程中全局变量的值并未发生改变,但是这两个进程所打印出来的全局变量的地址确实一样的!!

非常奇怪,现在我来问一个问题,进程地址空间它是物理内存吗?

我们先来假设进程地址空间是物理内存。

如果地址空间是物理内存,那么g_val这个全局变量一定存放在内存的某一块空间中,而这块空间的地址肯定是唯一确定的。也就意味着子进程和父进程打印这个变量时,打印的同一块空间的值。根据上述代码中发生的情况,两个进程打印变量的地址是一样的,说明是同一块空间,但值却不一样。那么试想一下,内存中同一块空间的值,有没有可能在同一时刻被不同进程读取,表现出不同的值?

不可能!!!举一个很简单例子,就像同一间教室,你和你的舍友同时走进教室,你看到的是张三老师在上课,而你的舍友看到的是李四老师在上课,这种情况可能发生吗?根本就是不可能时间。

因此我们推翻上面地址空间是物理内存的假设,得出结论:进程地址空间一定不是物理内存。

接下来问题又来了,你说地址空间不是内存,但我变量的值的确是存到了某一个地方,并且这个地方还有对应的地址,这些又怎么来解释呢?

这里要告诉大家的是,进程地址空间实际是一块虚拟出来空间,下面我就来为大家介绍虚拟地址空间。

4. 虚拟地址空间

在这里插入图片描述

如上图所示,我们看到进程地址空间实际上是进程和物理内存之间的一层虚拟层,进程地址空间是物理内存的一种虚拟化表示,最终一定要以某种方式转换到物理内存,进程看到内存空间是被虚拟空间解释的。

拿上面的代码举例,全局变量g_val看起来被保存在虚拟地址空间的初始化区,实际上是虚拟地址空间通过某种方式把变量的内容映射到了物理内存当中。

我们还看到,地址空间实际上是一个进程特有的。那么也就意味着有多少个进程,就有多少个进程地址空间。

所以在上述代码中,父子进程看到的都只是各自的虚拟地址空间,因此他们看到的全局变量的地址仅仅是各自虚拟空间中全局变量的地址。但这个地址绝对不是物理地址,操作系统最终会把虚拟地址转化为物理地址,但是父子进程访问的数据,最后绝对会被保存到不同的物理内存中。

接下来我再来回答一个问题,为什么这两个进程的地址空间是不相同的,但是变量保存的地址确实一样的?

这是因为父进程在创建子进程的时候,父进程的地址空间会给子进程拷贝一份,所以子进程拿到了一份和父进程类似的地址空间,并且在同一个位置看到了一个全局变量g_val。

到这里,我们就可以自己将上述代码中的内存变化来捋一遍了。

首先创建父进程,父进程开辟一块虚拟地址空间,在虚拟空间的初始化区创建一个全局变量g_val,操作系统会将这个虚拟地址映射到物理内存的某个区域。

接下来子进程创建,拷贝父进程的地址空间,于是子进程在虚拟空间相同位置看到了一个全局变量g_val,。此时注意,由于数据并未发生变化,所以子进程和父进程的代码还是共享的,因此子进程的g_val和父进程映射的是同一个位置。

接下来子进程修改g_val的值,我们知道进程之间是相互独立的,而这个独立首先就要体现在数据独立上。一开始子进程的值和父进程相同,相互之间还可以共享物理内存。如果子进程的值发生变化,操作系统就会在内存中重新为子进程的g_val开辟一块空间。但是这里仅仅是虚拟地址映射的物理内存发生变化,而虚拟地址并未发生变化。

这也就不难理解为什么父子进程地址相同,但是值不相同。因为虚拟地址相同,但是映射到物理内存的地址却是不一样的。

5. 为什么要存在地址空间?

讲到这里,相信大家有对于地址空间已经有了初步的认识。不过我想有人可能还是会疑惑,为什么要有地址空间的存在?进程直接和物理内存交互难道不好吗?

接下来我就来向大家讲述地址空间的两大好处。

好处1:保护内存

我们在学习语言阶段肯定发生过数组越界访问,指针越界访问的情况。这个时候程序一定会报错或者直接崩溃掉,以避免我们非法访问内存,这其中就是地址空间在起作用。

我们要知道,物理内存是没有辨别越界能力的。所以如果进程直接访问内存,当发生越界的时候就不会出现报错,这样你就有可能写坏其它空间的内容。

下面我再来讲地址空间是如何来保护内存。

前面我一直在说物理内存和地址空间直接存在一层映射关系,这层关系被保留的地方我们一般将它称之为页表

在这里插入图片描述
上图所示为一个简易的页表模型,比方说你的进程申请了一个数组,这个时候你会获得一个虚拟地址的范围,这时页表就会保存你的虚拟地址范围和映射到内存中的物理地址范围。同时页表还会保存数据的一些相关信息,比方说“可读可写”、“只可读不可写”。

这样当你通过地址访问数组的时候,操作系统会就会在页表中找有没有该虚拟地址和物理地址的对应关系。如果地址正常,页表中一定会有映射关系存在,这样就可以正常访问数组。如果发生越界情况,操作系统在页表中找不到该虚拟地址和物理地址的对应关系,说明非法访问,然后报错。

还有如果当你试图更改一个不可写的数据时,操作系统会判断当前数据是否可写,如果不可写就会报错。比方说:字符串、常量等等…

这就是地址空间的第一个作用,保护内存。

好处2:将空间连续化处理

如果进程直接存放进物理内存中,数据就可能会出现离散现象。
在这里插入图片描述
上图为数据保存的一个模拟过程,红色代表当前进程内存,黑色代表其它被占用的内存。当你申请空间的时候总会出现内存块不足的情况,这时你就必须重新找一片区域开辟内存。这样就会导致内存数据离散化,访问起来特别不方便。

那么地址空间如何来解决这个问题呢?
我们知道地址空间是只属于进程自己的,因此即使虚拟地址映射的物理内存不连续,但我一定可以保证我的虚拟地址是连续的。而我在实际访问的时候也只关心虚拟地址,只要虚拟地址连续,我访问起来就方便。至于怎样通过虚拟地址去访问不连续的物理地址,这是操作系统为我们解决的事情,就不用我们再费心了。

因此我们总结出地址空间的第二个好处:将空间连续化处理

为了方便大家理解,这里我再为大家举一个生活中的例子。

你去银行存钱,是不是直接把你的钱放到银行的金库里面去?当然不是。如果每个人都直接把钱存到银行的金库中,而金库只有存钱的功能,这样如果有些坏人拿走别人的钱怎么办?因此银行金库和用户之间会存在一个柜台,用户只需把钱交给柜台服务人物,然后服务人员帮你存钱,这样是不是可以保护金库的安全。

还有一点,比方说你每个月往银行存3000元,现在你要取出10000元出来,你会发现你零零散散存进去的钱,可以被整取出来。这就是银行柜台的好处,可以保证用户零存整取。

银行这一系列操作和我们的操作系统十分类似,银行金库就像是物理内存,而银行柜台就像是虚拟地址空间,用户就像一个进程,希望大家可以好好感受一下。

6. 地址空间的本质

现在我想再从另外一个角度带大家认识一下地址空间。前面我曾经说过,每个进程都有一个地址空间。也就是说100个进程,就有100个地址空间,请问这些地址空间需不需要被管理起来?

当然需要被管理。请问,怎么管理?

六个字:“先描述,再组织”。请问怎么描述一个地址空间?

用一个结构体来描述。所以说地址空间的本质是就是一个结构体struct

这里可能说的大家有些懵了,用结构体描述一块地址空间,这怎么能做到。

我先来为大家举一个简单的例子:
在一所小学中有一个小男孩和小女孩是同桌关系,这个小男孩平时不怎么讲卫生,每天看起来脏脏的,臭臭的。而同桌的小女孩特别爱干净,小女孩就很讨厌这个小男孩。于是有一天小女孩做了这样一件事,拿笔在桌子上画了一条线,就是我们俗称的“三八线”。小女孩对小男孩说,你不要越过这条线,敢过来我就打你。

现在我的问题来了,画三八线的本质是在干什么?

本质就是划分区域

那么我们能不能先试着用结构体表示出小男孩和小女孩的区域。

既然是区域,那就一定要有范围。比方说小男孩的范围是[1, 40],小女孩的范围是[40, 100].于是我们可以这样表示。

struct area
{
    int start;
    int end
}struct area b_a = {1, 40};
struct area g_a = {40, 100};

然后在回到我们的地址空间,我们发现地址空间实际上也是在划分区域,堆区一块区域,栈区一块区域…因此我们可以这样来定义地址空间的结构:

struct mm_struct
{
    unsigned long code_start;
    unsigned long code_end;
    unsigned long init_data_start;
    unsigned long init_data_end;
    unsigned long uninit_data_start;
    unsigned long uninit_data_end;
    unsigned long heap_start;
    unsigned long heap_end;
    ...
    ...
    ...
};

所以申请空间的本质是:向内存所要空间得到物理地址,然后在特定的区域申请没有被使用的虚拟地址,建立映射关系,再返回虚拟地址即可。

本篇文章到这里就全部结束了,虽然我已经尽可能的为大家去剖析地址空间了,但肯定有些地方还是没有讲到,因为系统部分涉及的知识面实在是太广了,不易全部展开。当然,我相信如果大家能看完本篇文章一定会有所收益的。对于本篇文章如果有问题的话可以私信我,最后希望这篇文章能够为大家带来帮助。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值