【Linux】进程(6)虚拟地址空间

hello~ 很高兴见到大家! 这次带来的是Linux系统中关于进程这部分的一些知识点,如果对你有所帮助的话,可否留下你宝贵的三连呢?
个 人 主 页 : 默|笙


文章目录


接上回进程(5)初识命令行参数和环境变量


一、之前所学的程序地址空间

1. 1 空间分布图

  1. 经过c语言的学习,相信大家对这张图肯定并不陌生。自下而上,地址递增,分别是正文代码区、字符串常量区、初始化数据区、未初始化数据区、堆区、共享区和栈区。但其实这个图也并不完整,只有在接触了系统之后才能明白完整的图。
  1. 栈区:是用来存储局部变量和函数调用上下文信息的,无论是整型变量、浮点型变量又或是指针变量,都是存储在栈里面的。地址从高向低增长。
  2. 堆区:用来存储动态申请的内存的。地址从低向高增长。
  3. 未初始化数据区即BBS段:是用来存储没有初始化的全局/静态变量,程序启动时会被系统自动初始化为0。
  4. 初始化数据区:用来存储已经初始化的全局/静态变量。
  5. 字符串常量区:用于存储字符串常量。
  6. 正文代码区:存放程序的机器指令(即编译后的二进制可执行代码),是 CPU 执行的操作序列。它包含函数体、控制流(分支、循环)对应的指令等。
  • 未初始化数据区/初始化数据区、字符串常量区和正文代码区的大小的固定的
  • 堆、栈相对而生

1.2 验证

  1. 接下来用下面的一段代码来进行验证上面的空间分布图:
cpp 复制代码
	1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <stdlib.h>
    4 int g_unval;
    5 int g_val = 100;
    6 int main(int argc, char *argv[], char *env[])
    7 {
    8  const char *str = "helloworld";
    9  printf("code addr: %p\n", main);
   10  printf("read only string addr: %p\n", str);
   11  printf("init global addr: %p\n", &g_val);
   12  printf("uninit global addr: %p\n", &g_unval);
   13  static int test = 10;
   14  char *heap_mem = (char*)malloc(10);
   15  char *heap_mem1 = (char*)malloc(10);

   16  char *heap_mem2 = (char*)malloc(10);
   17  char *heap_mem3 = (char*)malloc(10);
   18  printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
   19  printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
   20  printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
   21  printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
   22  printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)                                                                  
   23  printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
   24  printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
   25  printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
   26  printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
   27 return 0;
   28 }
  1. main是函数名,存放在正文代码区。g_unval 是未初始化的全局变量,存放在未初始化数据区,g_val是已经初始化的全局变量,存放在初始化数据区。str是指针变量,str就是字符串"helloworld"的地址,而&str是指针变量的地址,前者存储在字符串常量区,后者在栈区。test是已经初始化的静态变量,存储在初始化数据区。heap_mem,heap_men1,heap_men2和heap_mem3是指针变量,本身也是地址,它指向堆申请的空间,&heap_mem在栈区,heap_mem在堆区。

二、进程/虚拟地址空间

在我们对进程和内存有了一定了解之后,那么这个程序地址空间是指内存吗?若说它是内存,它把空间划分的这样绝对,PCB应该存储在哪里?若说它不是内存,那它究竟是什么东西?根据冯-诺依曼体系,代码和数据就是要放在内存里面的。之所以会有这样的矛盾,这是因为之前学习的东西并不完整,接下来我们来了解进程/虚拟地址空间。

1.1虚拟地址

  1. 进程/虚拟地址空间一定离不开虚拟地址,那进程/虚拟地址是什么呢?我们通过实验认识来认识它:
cpp 复制代码
//实验代码
	int num = 100;
  6 int main()
  7 {
  8   pid_t id = fork();
  9   if (id == 0)
 10   {
 11     printf("我是一个子进程,pid:%d, ppid:%d, num = %d, &num = %p\n", getpid(), getppid(), num, &num);
 12     num = 200;
 13     printf("我是一个子进程,我修改了num的值, num = %d, &num = %p\n", num, &num);
 14   }
 15   else
 16   {
 17     while(1)                                                                                                                             
 18     {
 19       printf("我是一个父进程,pid:%d, ppid:%d, num = %d, &num = %p\n", getpid(), getppid(), num, &num);
 20       sleep(1);
 21     }
 22   }
 23   return 0;
 24 }
  1. 我设置了一个全局变量num,这个变量子进程和父进程都能够获取到(父子进程代码共享),在子进程修改num这个值之前,父子进程对应num的值都是100,对应num地址都是0x601054。但是在子进程修改了num这个值之后,子进程里显示num值为200,父进程里显示num值为100,子进程没能影响父进程num的值,但它们的地址还是一样的。
  2. 但一个变量能够存储两个不同的值?这显然是不可能的,这说明父子进程输出的并不是同一个变量。也说明这个地址并不是真正的物理地址,而是系统给我们看的一个虚拟地址
  3. 我们所能看到的一切地址都是经过系统处理之后得到的虚拟地址,真正的物理地址我们是看不到的,它被系统给隐藏起来了。

2.1进程/虚拟地址空间

进程/虚拟地址空间不是真正意义上的物理内存,也不是程序地址空间,但对于进程来说它就是内存 ,完整图如下,相对于程序地址空间它多出来命令行参数环境变量区以及内核空间。

  1. 进程地址空间在32位操作系统下总大小为4G,分为两个区域,一个是1G大小的内核空间,另一个是3G大小的用户空间。其中内核空间作为用户的我们是无法直接进行访问的。

  2. 上面的内核空间就是用来"存放"PCB等其他数据的。其实还是存在物理内存里。

  3. 每执行一个进程,OS都会给这个进程分配一个进程地址空间作为这个进程的内存和一个PCB。之后再将代码和数据加载到这个进程虚拟空间对应的虚拟分区。

  4. 这个虚拟地址空间并不是真正用来存储数据的地方,存储数据的地方依然是物理内存。不过虚拟地址会通过页表与物理地址建立联系。页表接下来会讲到。

  • 命令行参数和环境变量的地址更高

三、进程地址空间-页表-物理内存

3.1 联系

介绍

  1. 在要执行某个进程的时候,OS会先给这个进程创建一套虚拟地址空间规则(不是真实存在的)和一个PCB即task_struct,这个PCB是存储在物理内存里面的,之后把程序的代码和数据按需加载到物理内存里面。物理内存会在加载时(系统进行分配)给代码和数据分配物理地址,而虚拟内存是提前(编译时由编译器分配)给代码和数据分配了虚拟地址。最后通过页表建立起对虚拟地址和物理地址的联系。这样,在查询num的地址时,cpu会将虚拟地址传给MMU(一个硬件,内存管理单元)会在页表里面查找然后用虚拟地址映射到其对应的物理地址,再用这个物理地址到物理内存里面拿到数据。
  2. 虚拟地址空间里面有严格的区域分块,对应的代码和数据存放在不同的区域里面,而物理空间则不同,它没有像虚拟地址空间对应的逻辑分区,也就是部分为栈区、堆区等区域。

数据的独立性

  1. 进程之间具有独立性,这也包含父子进程。进程 = 内核数据结构 + 代码和数据。之前说过,不同的进程拥有不同的task_struct,代码虽然共享但权限为只读,不会互相影响,剩下的就是保证数据之间的独立性。也就是上面我们所看到的修改了子进程num的值但是却不会影响到父进程num的值。但是OS是如何做到的?
  1. 创建一个子进程,会拷贝一份它父进程的task_struct、虚拟地址空间和页表作为自己的task_struct、虚拟地址空间和页表。所以父子进程的虚拟地址才会相同。 这个子进程的虚拟地址一开始跟父进程映射到相同的物理地址的。这一个拷贝相当于是一个浅拷贝

    2.而当子进程想修改某个值的时候,OS就会在物理内存里面给子进程要修改的数据分配出一块新的存储空间,并修改子进程虚拟地址所对应的物理地址,这次类似于一次深拷贝,这也就是写时拷贝
  • 每一个进程都拥有一套虚拟空间地址和页表
  • 子进程拷贝了父进程的页表,发生了类似于浅拷贝的过程,这是fork之后父子进程共享代码和数据的原因
  • 父子进程中,任何一个进程尝试对共享的数据进行修改时,就会发生写时拷贝,会为修改后的数据分配新的物理地址
  1. 我们知道,进程 = 内核数据结构 + 代码和数据。由于每个进程都拥有一个虚拟地址空间,那么OS要不要对这些虚拟地址空间进行管理呢?肯定是需要的,而OS要管理,一定是先描述再组织,将虚拟空间地址的属性组合起来形成一个新的结构体mm_struct,之后组织起来。那么现在这个内核数据并不是单指PCB了,还包括一些描述了虚拟地址空间的结构体。

懒加载/按需加载

  1. 在Linux系统中,执行一个进程,是先创建内核数据结构,然后再加载数据跟代码到内存。这个加载数据跟代码的过程就存在一个可以操作的空间。因为物理空间是有限的,那么为了节省物理空间,OS就会进行一个懒加载:将由编译器分配好的代码和数据的虚拟地址映射到虚拟地址空间里面,此时页表中为每个虚拟页创建了页表项,页表项标记为 "缺页(未加载)" 状态,且记录该虚拟页对应的磁盘位置,不把代码和数据加载到物理内存里面。比如,这部分的代码和数据暂时还用不到,那么就先不加载,等要用到的时候再给它加载到物理内存里面去,分配对应的物理地址。

3.2 页表

  1. 为什么一些数据像字符串常量是只读的?是谁提供的限制?---页表。

  2. 页表不光是存储虚拟地址和对应的物理地址,还会存储权限和状态标记 。权限标记比如读写标记,状态标记比如缺页标记。

  3. 其中权限标记就限制了这段数据的读写权限,像字符串常量对应的权限标记就是只读。它的本质是不让进程修改数据。

  4. 谁来查页表?---硬件MMU(cpu的内存管理单元),当进程尝试修改只读权限的数据时,MMU会检测到权限不匹配然后将控制权交给OS,这时OS通常会终止进程,这就是为什么字符串常量是只读的底层原因。

  5. 缺页位为1,代表对应数据已经被加载到物理内存里面,为0时,代表没有被加载到内存里面或者被挂起到磁盘swap分区里面去了。

  6. 页表还有更多的标志位,这里就不做讲解了。

四、加深认识

4.2 虚拟地址空间

  1. 打个形象的比方,有一个大富翁,它有许多个私生子,这些私生子都不知道其他私生子的存在。一天,这个大富翁找到了他其中一个私生子,他告诉这个私生子要好好读书,将来我死了之后家产全部都是你的。不久之后大富翁如法炮制给他的每一个私生子都传递了这个消息。这个过程中大富翁在干什么?大富翁在给他的子女们画饼。假设他总共只有10亿,但是他给这些子女们承诺的金额已经远大于这个数。子女们也不会一下子就向这个大富翁要10亿,因为这个承诺是大富翁死了之后才会兑现的,他们只会一点一点的申请(按需申请)。在这个过程里,大富翁就是操作系统,他的10亿财产就是物理内存,物理内存跟他的财产一样是有限的,而这些子女就是一个个的进程,大富翁给这些子女们画的饼就是虚拟地址空间,饼不是真实存在的,虚拟地址空间也不是真实存在的;一个饼大小是固定的,饼加起来却是无穷大的,每个虚拟地址空间的大小是固定的,但这些进程加起来的虚拟地址空间也是远大于物理内存的。

4.3 如何理解虚拟地址空间里面的区域划分

  1. 有没有想过,为什么全局变量、静态变量它们的生命周期是全局的?本质是它们的生命周期随进程。因为初始化数据区到正文代码区这部分区域再虚拟地址空间里面已经严格划分好,且划分后位置和大小固定不变。进程启动时,这些区域会被映射到虚拟地址空间并加载(或懒加载)到物理内存,且全程不会被 OS 回收;直到进程终止,虚拟地址空间被销毁、对应的物理内存被释放,这些变量才会消失。
  2. 前面提到虚拟地址空间也是由结构体mm_struct所描述起来的,那么这个mm_struct里面存储的是什么东西?核心是虚拟地址空间中每个分区的起始地址和结束地址
  3. 比如一张 1m 的桌子,你把它分成 "吃饭区、放书区、置物区" 等几块,每一块都有明确的起始位置和结束位置,不会重叠也不会混乱;mm_struct就相当于记录这些分区边界的 "桌区规划图",OS 通过它精准管理虚拟地址空间的每一块区域。

4.4 为什么要有虚拟地址空间?

  1. 控制进程的非法行为,如果进程对一个野指针进行解引用操作,那么在页表那里这种非法行为就会被拦截下来,能够有效的保护物理内存。

  2. 有了虚拟空间和页表,可以将物理空间上的无序状态变为有序状态。物理内存是不分栈区、堆区的即物理内存是无序的,而虚拟地址空间是严格划分了区域的,通过页表能够将有序的虚拟地址空间和无序的物理内存联系起来。

  3. 可以将进程管理和内存管理解耦合。对于进程来说,虚拟地址空间就是它的内存,进程只需要管好虚拟地址空间就好。对于OS而言,它只需要管理好物理内存的分配和回收便好。解耦合后,物理内存的的变化不会影响进程的运行,进程的虚拟地址空间的变化也不会影响对物理内存的管理。


今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!

相关推荐
董世昌412 小时前
JavaScript 中 undefined 和 not defined 的区别
java·服务器·javascript
Norach2 小时前
Ubuntu升级opencv版本至4.9.0
linux·经验分享·opencv·yolo·ubuntu·dnn
linzihahaha2 小时前
vmware-ubuntu 虚拟机共享文件及复制拖动配置
linux·运维·ubuntu
重生之我在番茄自学网安拯救世界2 小时前
网络安全中级阶段学习笔记(十一):服务器解析漏洞全解析(原理、利用与防御)
运维·服务器·web安全·网络安全·渗透测试·服务器解析漏洞
韩金群2 小时前
centos离线安装配置clickhouse
linux·clickhouse·centos
HIT_Weston2 小时前
70、【Ubuntu】【Hugo】搭建私人博客:新建站点
linux·运维·ubuntu
·云扬·2 小时前
MySQL服务器性能优化:硬件与存储配置全指南
服务器·mysql·性能优化
麦麦Max2 小时前
Docker
运维·docker·容器
Danileaf_Guo2 小时前
让Ubuntu服务器变身OSPF路由器!实现服务器与网络设备直接对话
linux·运维·服务器·ubuntu