前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Linux进程地址空间

Linux进程地址空间

作者头像
用户11173787
发布2024-06-24 11:24:25
620
发布2024-06-24 11:24:25
举报
文章被收录于专栏:破晓破晓

Linux进程地址空间是学习Linux的过程中,我们遇见的第一个难点,也是重中之重的重点。虽然它很难,但是,等我们真正懂得了这样设计的原理,我们不禁会感叹:这真的是太妙了。接下来,就让我么开启这一段学习之旅吧!

一.程序地址空间

大家在系统学习C/C++时,有没有见过这张图:

这就是著名的内存地址模型。越往上地址越高。这些区域为什么按照这种顺序排列呢?这种排列顺序对吗?接下来,我们验证一下:

代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_unval;     // 未初始化全局变量
int g_val = 100; // 已初始化全局变量
int main()
{
  printf("code addr代码段地址: %p\n", main);
  printf("init data addr初始化全局变量地址: %p\n", &g_val);
  printf("uninit data addr未初始化全局变量地址: %p\n", &g_unval);

  char *heap = (char *)malloc(sizeof(char)); // 堆区开辟空间

  printf("heap addr/堆区地址: %p\n", heap);

  printf("stack addr栈区地址: %p\n", &heap);
  return 0;
}

所以根据结果,我们发现stack>heap>uninit data>init data>code;所以,我们的结果和这张图是相互对应的。

但是,堆区和栈区的增长的方向是怎样呢?这个好办;方向是比对出来的,我们只需要多申请几次堆空间和栈空间,然后比较地址大小变化。

代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_unval;     // 未初始化全局变量
int g_val = 100; // 已初始化全局变量
int main()
{
  printf("code addr代码段地址: %p\n", main);
  printf("init data addr初始化全局变量地址: %p\n", &g_val);
  printf("uninit data addr未初始化全局变量地址: %p\n", &g_unval);

  char *heap1 = (char *)malloc(sizeof(char)); // 堆区开辟空间
  char *heap2 = (char *)malloc(sizeof(char)); // 堆区开辟空间
  char *heap3 = (char *)malloc(sizeof(char)); // 堆区开辟空间
  char *heap4 = (char *)malloc(sizeof(char)); // 堆区开辟空间

  printf("heap addr/堆区地址: %p\n", heap1);
  printf("heap addr/堆区地址: %p\n", heap2);
  printf("heap addr/堆区地址: %p\n", heap3);
  printf("heap addr/堆区地址: %p\n", heap4);

  printf("stack addr栈区地址: %p\n", &heap1);
  printf("stack addr栈区地址: %p\n", &heap2);
  printf("stack addr栈区地址: %p\n", &heap3);
  printf("stack addr栈区地址: %p\n", &heap4);

  return 0;
}

由此,我们得出了堆区是往上增长的,栈区是往下增长的,堆栈相应 。

我觉得:我们有必要再试一下环境变量和命令行参数。

代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int g_unval;     // 未初始化全局变量
int g_val = 100; // 已初始化全局变量
int main(int argc,char *argv[],char *env[])
{
  printf("code addr代码段地址: %p\n", main);
  printf("init data addr初始化全局变量地址: %p\n", &g_val);
  printf("uninit data addr未初始化全局变量地址: %p\n", &g_unval);

  char *heap1 = (char *)malloc(sizeof(char)); // 堆区开辟空间
  printf("heap addr/堆区地址: %p\n", heap1);
  printf("stack addr栈区地址: %p\n", &heap1);


  for(int i=0;i<argc;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;
}

我们看到:环境变量的地址和命令行参数的地址都要比堆区和栈区的地址大;环境变量的地址比命令行选项的地址大。这恰恰说明了:先命令行选项表,再创建环境变量表。


这里,我还想和大家达成几个共识:

  • 地址空间描述的基本空间大小为字节。
  • 在32位环境下,一共需要2^32个地址。
  • 2^32*1字节=4GB的空间大小。
  • 每个字节都有唯一的地址。

1. 如何编址

地址的最大意义就是不重复,具有唯一性即可。所以就要对整个程序地址空间进行编址。使用unsigned int即可。

为了理解如何编址,我们讲一个小故事:

有一个上小学的女孩,非常爱干净。她有一个男同桌,很不爱干净,而且平时喜欢靠近她,抢她的位置,同桌嘛。有一天女孩实在受不了了,大吼道:"在桌子中间画一条线,你越过这一条线,我就打你"。但是,因为男孩很胖,总是一不小心就越界了,不出意外的被女孩带来几顿。有一次,男孩说:"你别打我了,我真不是故意的,我太胖了"。无奈之下,女孩说:"这样吧,我挪5厘米,你挪5厘米,建立一个10厘米的缓冲地带,你可以占缓冲区,但不能越过缓冲区,要不然我还打你,而且打的更狠"。

小孩子的世界总是这么天真,这么有趣。但在他们的世界里没有代码给我们带来的快乐,而我们有!!哈哈哈哈哈

如今的我们体会到了敲代码的乐趣,任何东西想用代码来搞一搞。在我们这批未来互联网的精英们看来,两个小孩子的画清边界的行为,也可以用一个结构体实现:

我们假设桌子常1米,那么两个小孩子最后商定的方法为:

代码语言:javascript
复制
struct boundary
{
	int boy_start;//男孩起始位置
	int boy_end;//男孩终止位置

	int buffer_start;//缓冲地带起始位置
	int buffer_end;//缓冲地带终止位置

	int girl_start;//女孩起始位置
	int girl_end;//女孩终止位置

};
代码语言:javascript
复制
struct boundary bd{0,45,46,55,56,100};

划分好位置,我们就可以准确的说明一个在桌面上的物体准确具体 ,比如我们可以说:在35处,有一个橡皮。

我们是不是也可以用这种来标定整个程序地址空间呢?把整个4GB的空间比作一个桌面。需要用到2^32个地址,int在32位环境下正好是32个比特位,可以表示的最大数据就是2^32。如此我们就可以具体列出一个数据所在的地址。

我们来看看内核中是怎样设计的:

确实跟我们说的描述方式一模一样。

二.进程地址空间

我们在C/C++中的取地址操作,取的是内存中的地址?

其实,不是的!!

代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int num = 0;
int wer = 100;
int main()
{
    pid_t fd = fork();
    if (fd < 0)
    {
        printf("fork fail!\n");
    }
    else if (fd == 0)
    {
        while (2)
        {
            printf("我是子进程,wer:%d,&wer:%p\n", wer, &wer);
            num++;
            if (num == 10)
            {
                printf("wer数据已被修改,修改是由子进程完成的\n");
                wer=300;
            }
            sleep(1);
        }
    }
    else
    {
        while (1)
        {
            printf("我是父进程,wer:%d,&wer:%p\n", wer, &wer);
            sleep(3);
        }
    }
    return 1;
}

我们来看:数据被子进程修改之后,竟然出现了从一个地址中读取的数据不一样的情况。

,这是什么鬼!!如果读取的是内存中的地址,肯定不会出现这样的情况,所以,我们有理由怀疑:读取的根本不是内存中的地址。

但是由此我们就可以知道,程序地址空间并 不是 内存,它的正确叫法为 进程地址空间

我们读取的地址是虚拟地址(也叫做逻辑地址)。虚拟地址空间就是操作系统内核中的一个名为mm_struct结构体。

1.mm_struct

每一个进程都只有1个内存描写符mm_struct。在每一个进程的task_struct结构中,有1个指向mm_struct的变量,这个变量常常是mm_struct。 mm_struct是对进程的地址空间(虚拟内存)的描写。1个进程的虚拟空间中可能有多个虚拟区间,对这些虚拟空间的组织方式有两种,当虚拟区较少时采取单链表,由mmap指针指向这个链表,当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树。由于程序中用到的地址常常具有局部性,因此,最近1次用到的虚拟区间极可能下1次还要用到,因此把最近用到的虚拟区间结构放到高速缓存,这个虚拟区间就由mmap_cache指向。 指针pgt指向该进程的页目录(每一个进程都有自己的页目录),当调度程序调度1个程序运行时,就将这个地址转换成物理地址,并写入控制寄存器。 由于进程的虚拟空间及下属的虚拟区间有可能在不同的上下文中遭到访问,而这些访问又必须互斥,所以在该结构中设置了用于P,V操作的信号量mmap_sem。另外,page_table_lock也是为类似的目的而设置。 虽然每一个进程只有1个虚拟空间,但是这个虚拟空间可以被别的进程来同享。如:子进程同享父进程的地址空间,而mm_user和mm_count就对其计数。 另外,还描写了代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。

代码语言:javascript
复制
struct mm_struct
{
     struct vm_area_struct *mmap;    //指向虚拟区间(VMA)链表
     struct rb_root mm_rb;           //指向red_black树
     struct vm_area_struct *mmap_cache;    //找到最近的虚拟区间
 
     unsigned long(*get_unmapped_area)(struct file *filp,unsigned long addr,unsigned long len,unsigned long pgoof,unsigned long flags);
 
     void (*unmap_area)(struct mm_struct *mm,unsigned long addr);
 
     unsigned long mmap_base;
 
     unsigned long task_size;   //具有该结构体的进程的虚拟地址空间的大小
     unsigned long cached_hole_size;
     unsigned long free_area_cache;
 
     pgd_t *pgd;  //指向页全局目录
 
     atomic_t mm_users;         //用户空间中有多少用户
     atomic_t mm_count;         //对"struct mm_struct"有多少援用
 
     int map_count;            //虚拟区间的个数
     struct rw_semaphore mmap_sem;
     spinlock_t page_table_lock;       //保护任务页表和mm->rss
 
     struct list_head mmlist;          //所有活动mm的链表
     mm_counter_t _file_rss;
     mm_counter_t _anon_rss;
     unsigned long hiwter_rss;
     unsigned long hiwater_vm;
 
 
     unsigned long total_vm,locked_vm,shared_vm,exec_vm;
     usingned long stack_vm,reserved_vm,def_flags,nr_ptes;
 
     unsingned long start_code,end_code,start_data,end_data;  //代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_data
 
     unsigned 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
     unsigned long saved_auxv[AT_VECTOR_SIZE];
 
     struct linux_binfmt *binfmt;
 
     cpumask_t cpu_vm_mask;
     mm_counter_t context;
     unsigned int faultstamp;
     unsigned int token_priority;
     unsigned int last_interval;
 
     unsigned long flags;
     struct core_state *core_state;
}

那,什么原因会造成我们看到的这种情况呢?虚拟地址空间和真正的内存之间是什么关系呢?

2.父子两个进程修改同一变量的原理

写时拷贝技术

我们在取地址操作中得到的地址都是虚拟地址,虚拟地址通过一张表格和内存之间建立映射关系,进而通过虚拟地址找到真正的内存中的地址,得到代码和数据。

有一进程在调用fork函数创建子进程时,操作系统会将父进程的PCB,mm_struct结构体,包括页表复制一份给子进程,所以父子进程中相同的虚拟地址通过页表映射会找到同一块内存空间,读取到相同的数据。如果其中一个进程想要对数据进行修改,我们知道进程之间必须确保独立性,一个进程修改的数据极有可能会影响到另一个进程。这时,操作系统会为要修改数据的进程开辟一段空间,然后将原来的数据拷贝一份放入新开辟的空间中,然后改变页表的映射关系(虚拟地址相同,但内存地址不同),之后对数据进行修改。这就造成了一个虚拟地址访问到两个不同的物理地址,进而得到两份不同的数据。

其中,这种父子进程先共享一份内存数据,然后等到父或子进程要对共享数据进行修改时,操作系统为了保证进程之间的独立性,才为其分配各种的内存空间,这种方式叫做写时拷贝。

写实拷贝有哪些意义呢? 写时拷贝技术实际上是一种拖延战术,是为了提高效率而产生的技术,这怎么提高效率呢?实际上就是在需要开辟空间时,假装开了空间,实际上用的还是原来的空间,减少开辟空间的时间,等到真正要使用新空间的时候才去真正开辟空间。   举一个例子理解写时拷贝技术:我们小时候经常会遇到这种情况:家里面有哥哥姐姐的,大人们经常会让我们穿哥哥姐姐穿剩的衣服,这样看起来我们就有了新衣服,但实际上我们穿的还是哥哥姐姐的旧衣服,等到我们真的长大了,才会给我们买属于自己的新衣服,这样节省了给我们买衣服的时间和财力。从而节省了很多资源(提高效率)。等我们真的需要时才不得不买新衣服(拖延战术)。

3.什么是进程地址空间

我们先讲一个小故事:

美国有一个富豪,资产高达十亿美金。这个大富豪有三个私生子,他们相互不知道彼此的存在,都认为自己是富豪唯一的儿子,大儿子负责打理自己的农场,富翁对大儿子说:"你好好干,将来让你继承我的资产"。二儿子负责一家金融公司,是这家公司的CEO,富翁语重心长的对他说:"儿子,你最努力,加油相信你,我的资产将来都是你的"。三儿子还在学校读书,学习很努力,就像我一样,

,富翁也深情的对三儿子说:"我已经决定把我的资产将来都交给你了"。儿子们听到富翁老爹给自己画的大饼,都很高兴,无论是工作还是学习,都很努力。富翁心里知道:他们不可能一下子给他要很多的钱的。

在这个故事中,每个儿子都收到了自己的富翁老爹给自己画的一张大饼。但这些饼说到底是一个虚幻的,是一个愿景。

要想画一个好的饼,需要具备哪些条件

  • 被画饼的人要有记忆能力,要记住别人给他画的什么饼。
  • 画饼的人也要有记忆能力,记住给哪些人画过哪些饼,对号入座。

真正的物理地址就像真正的奖励,而虚拟地址空间(mm_struct)就是那一张张大饼,如果有需要,可以向富翁老爹进行申请,其实就相当于 虚拟地址空间向操作系统申请物理地址空间

那这些饼操作系统必须有效的组织起来:

3.为什么会存在虚拟进程地址空间?

原因:

  1. 如果让进程直接访问物理内存,可能回越界非法访问不属于该进程的代码和数据,非常不安全。
  2. 虚拟地址空间的存在,可以更方便的进行进程和进程代码和数据的解耦,更好的保证了 进程的独立性特征。
  3. 让进程以统一的视角来看待进程所对应的代码和数据等各个区域,方便编译器也以统一的视角进行代码的编译。

下面,我们逐条来解释。

原因1:

如果让进程直接访问物理内存,可能会越界非法访问不属于该进程自己的代码和数据,非常不安全。

如图所示:如果让一个进程直接访问内存,如果内存中存放着与登录有关的数据(username,password等等),此时如果有一个恶意进程通过扫描内存拿到了和登录有关的数据,那就回造成数据泄露的危险情况。

但是,这仅仅可以证明直接访问物理内存的这种方式不行,并不代表采用虚拟内存的方式就可以!!接下来,我们看看通过虚拟内存映射是如何解决这个问题的:

我们不要仅仅认为:页表的作用只有映射。我们来讲个故事来说明一下:

小时候。我们会受到压岁钱。每到过年,我们都会很开心,拿着自己的压岁钱,总喜欢去小卖部逛逛,小卖部老板看着我们小,就糊弄我们花钱,买些无用的东西。对此,经常会得到棍棒伺候。有一次妈妈对我说:"你的压岁钱我帮你拿着,等到你要花钱时,给我说,我觉得你可以买,就给你钱;觉得你不应该买,就不给你钱"。

妈妈就相当于一个页表,所以页表不仅仅是映射作用,还要对访问内存空间的行为进行审查,对内存空间进行保护!!这一套规则是所有的进程都必须遵守的。

所以,通过虚拟内存映射这种方式,只会访问到合法地址,不需要担心内存中的数据被写坏,可以很好的保护内存中的代码和数据!!

原因2

虚拟内存空间的存在,可以更好的进行进程和进程代码和数据的解耦,更好的保证了内存独立性

之所以会出现父子进程修改同一数据,会从同一地址处,读出不同数据,是因为有了虚拟内存映射策略,可以做到既节省了内存空间,又使父子进程数据互相独立,保证了独立性。

原因3

让进程以统一的视角来看待进程所对应的代码和数据等各个区域。方便编译器也以统一的视角来进行代码编译。

问大家:代码在没有被加载到内存之前,代码内部有地址吗?是什么地址?

其实,在未加载内存之前时,代码内部是有地址的,是虚拟地址。

大家在学习C语言时,看过代码的汇编语言?

代码语言:javascript
复制
// main.c
int g(int x)
{
    return x + 3;
}
    
int f(int x)
{
    return g(x);
}
    
int main(void)
{
    return f(8) + 1;
}
代码语言:javascript
复制
1 	g:
2 		pushl	%ebp
3 		movl	%esp, %ebp
4 		movl	8(%ebp), %eax
5 		addl	$3, %eax
6 		popl	%ebp
7 		ret
8 	f:
9 		pushl	%ebp
10		movl	%esp, %ebp
11		pushl	8(%ebp)
12		call	g
13		addl	$4, %esp
14		leave
15		ret
16	main:
17		pushl	%ebp
18		movl	%esp, %ebp
19		pushl	$8
20		call	f
21		addl	$4, %esp
22		addl	$1, %eax
23		leave
24		ret

所以,CPU在运行进程时,看到的全是虚拟地址,物理地址的毛都没看见。

编译器编译代码和虚拟进程地址空间使用虚拟地址的规则是一样的。方便使用。

因作者水平有限,难免会有错误,请各位指正!!!

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2024-06-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一.程序地址空间
    • 1. 如何编址
    • 二.进程地址空间
      • 1.mm_struct
        • 2.父子两个进程修改同一变量的原理
          • 3.什么是进程地址空间
            • 3.为什么会存在虚拟进程地址空间?
              • 原因1:
              • 原因2
              • 原因3
          相关产品与服务
          轻量应用服务器
          轻量应用服务器(TencentCloud Lighthouse)是新一代开箱即用、面向轻量应用场景的云服务器产品,助力中小企业和开发者便捷高效的在云端构建网站、Web应用、小程序/小游戏、游戏服、电商应用、云盘/图床和开发测试环境,相比普通云服务器更加简单易用且更贴近应用,以套餐形式整体售卖云资源并提供高带宽流量包,将热门软件打包实现一键构建应用,提供极简上云体验。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档