【Linux】进程概念(五)(虚拟地址空间----建立宏观认知)

目录

前言

书接上文【Linux】进程概念(四):(命令行参数和环境变量)详情请点击查看,今天继续介绍【Linux】进程概念(五)(虚拟地址空间----建立宏观认知)

一、铺垫知识

C/C++内存布局

  • 在学习C/C++的时候,我们了解过堆区、栈区、静态区等等,如下图所示
  • 验证C/C++内存布局情况
  • 定义一个未初始化的全局变量以及初始化了的全局变量;在main函数内使用malloc申请空间,定义静态成员变量
cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
	 const char *str = "helloworld";
	 printf("code addr: %p\n", main);
	 printf("init global addr: %p\n", &g_val);
	 printf("uninit global addr: %p\n", &g_unval);
	 static int test = 10;
	 char *heap_mem = (char*)malloc(10);
	 char *heap_mem1 = (char*)malloc(10);
	 char *heap_mem2 = (char*)malloc(10);
	 char *heap_mem3 = (char*)malloc(10);
	 printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
	 printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
	 printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
	 printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
	 printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)

	 return 0;
}

二、虚拟地址空间

进程地址空间(虚拟地址空间)的引入

  • 上面我们获得的地址是物理地址空间吗?答案是上面我们打印获得的地址不是物理地址,而是进程地址空间,也就是我们后面要主要介绍的虚拟地址空间
  • 现在我们编写一段代码:使用fork创建子进程,并定义全局变量,在父子进程中访问这个全局变量,已经打印该全局变量的地址
cpp 复制代码
#include <stdio.h>
#include <unistd.h>
int gval = 100; // 全局变量
int main()
{
     pid_t id = fork();
     if(id == 0)
    {
         //子进程
         while(1)
         {
             printf("我是子进程, pid: %d, ppid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);
             sleep(1);
         }
     }
     else
     {
         //父进程
         while(1)
         {                                                                                                                                                                                                     
             printf("我是父进程, pid: %d, ppid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);
             sleep(1);
         }
     }
     return 0;
 }
  • 从下面打印结果我们可以看到,父子进程都能访问到gval全局变量,且它们打印的地址是完全一样的。fork之后,父子进程代码、数据共享,默认情况下父子进程都能访问
  • 下面我们修改一下代码,子进程不仅仅是访问打印全局变量地址,还对全局变量进行修改,现在再观察打印结果
  • 我们可以看到父进程打印的gval一直是100,子进程打印是100、101、102...这是符合进程独立性原则的,一个进程运行时是不能影响另一个进程的运行的 ,但是它们打印的地址竟然是一样的
  • 读取同一个gval变量,地址也是一样的,怎么会父进程读取是100,子进程读取是100、101...呢,怎么会读取出来不同的值呢?
  • 因此我们可以确定这个地址一定不是物理内存的地址 ,这个地址就是虚拟地址,在我们学习C语言、C++中打印出来的地址都是虚拟地址,我们是无法看到物理地址的

对虚拟地址的理解

  • 我们以32位机器为例,虚拟地址空间范围[0,2^32],从000...000到FFF...FFF

  • 我们执行一个程序时,代码和数据加载到内存中(物理内存),创建进程就会创建task_struct,task_struct会保存进程的属性,上下文等数据,并指向虚拟地址空间----C/C++学习的地址(包括代码区、栈区、堆区等),同时通过页表将虚拟地址映射到物理地址 ,找到物理内存,这样才能访问到数据,如下图所示:

  • 当创建子进程后,子进程会以父进程的task_struct、虚拟地址空间、页表为模板拷贝为子进程的task_struct、虚拟地址空间、页表(会修改部分属性,比如:pid、ppid等),这样父子进程有一样的g_val的虚拟地址空间,映射到物理内存也是和父进程指向同一个物理地址上

  • 当父子进程要对数据进行修改时,由于进程间是独立的,所以当进程尝试对共享的数据进行修改时,操作系统会重新开辟一个空间(新的物理地址),将全局变量的值拷贝到新空间中,修改页表中虚拟地址到物理地址的映射,这样的一个过程叫做写时拷贝

  • 整个写时拷贝的过程全部由操作系统完成,用户不知道,这整个过程虚拟地址空间是没有变化的,但是底层物理地址已经是不一样的了。(同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址)

  • 上述知识点就能解释我们代码中id为什么既能等于0,又能大于0:id是父进程创建的变量,fork创建子进程,fork的返回值:return xxx,其本质就是对id变量进行修改写入,所以就会发生写时拷贝,所以同一个变量父进程看到的就是子进程pid,子进程看到的就是0,其本质都是同一个变量,虚拟地址空间相同,但是映射到底层的物理地址空间已经不一样了

理解空间划分

  • 每个进程的虚拟地址都是从000...000到FFF...FFF,但是进程并不是真的有这么多空间能使用,虚拟地址是虚拟的(就类似画大饼一样,操作系统虽然告诉你你有000...000到FFF...FFF这么多空间,但是如果操作系统本身只有4G,某个进程直接申请4G空间,操作系统是不会划分给该进程4G空间的)
  • 每个进程都会有虚拟地址空间,那么每个进程的虚拟地址空间需要操作系统进行管理,操作系统进行管理:先描述,再组织
  • 虚拟地址空间是操作系统给进程画的饼,本质是一个内核数据结构
  • 从上述图片我们知道虚拟内存空间是将000...000到FFF...FFF划分一个个区域:代码区、堆区、栈区...每个区都会有起始地址和最终地址
  • 用户空间指程序员能直接用地址访问的空间,访问内核空间,则必须要使用系统调用

三、虚拟内存管理

  • 描述linux下进程的地址空间的所有的信息的结构体是mm_struct(内存描述符)。每个进程只有一个mm_struct结构,在每个进程的task_struct 结构中,有一个指向该进程的mm_struct结构体指针
cpp 复制代码
struct task_struct
{
 	struct mm_struct *mm; 
}

struct mm_struct
{
	 /*...*/
	 struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */ 
	 struct rb_root mm_rb; /* red_black树 */ 
	 unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/ 
	 /*...*/
	 // 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。 
	 unsigned long start_code, end_code, start_data, end_data;
	 unsigned long start_brk, brk, start_stack;
	 unsigned long arg_start, arg_end, env_start, env_end;
}
  • mm_struct 结构是对整个用户空间的描述。每一个进程都会有自己独立的 mm_struct ,这样每一个进程都会有自己独立的地址空间才能互不干扰

虚拟空间的组织方式

  • 但是有一个问题,堆区空间我们并不是一次申请的,可以使用new,malloc多次申请堆上空间,那么又怎么管理堆上的这一小块小块申请的空间呢?
  • 每一个进程都会有自己独立的 mm_struct ,操作系统肯定是要将这么多进程的 mm_struct组织起来的!虚拟空间的组织方式有两种
  1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表
  2. 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树
  • linux内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各VMA,方便进程快速访问
cpp 复制代码
struct vm_area_struct {
	 unsigned long vm_start; //虚存区起始 
	 unsigned long vm_end; //虚存区结束 
	 struct vm_area_struct *vm_next, *vm_prev; //前后指针 
	 struct rb_node vm_rb; //红⿊树中的位置 
	 unsigned long rb_subtree_gap;
	 pgprot_t vm_page_prot;//访问权限
	 struct mm_struct *vm_mm; //所属的 mm_struct 
//.......
} __randomize_layout;
  • pgprot_t vm_page_prot代码:访问权限,vm_area_struct中会保存每个区域的访问权限(读、写...)

页表知识点补充与总结

  • 进程中,我们的代码、常量是不能修改的,但是变量是可以修改的,这是怎么实现的呢?
cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
	 const char *str = "helloworld";
	 printf("code addr: %p\n", main);
	 printf("init global addr: %p\n", &g_val);
	 printf("uninit global addr: %p\n", &g_unval);
	 static int test = 10;
	 char *heap_mem = (char*)malloc(10);
	 char *heap_mem1 = (char*)malloc(10);
	 char *heap_mem2 = (char*)malloc(10);
	 char *heap_mem3 = (char*)malloc(10);
	 printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
	 printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
	 printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
	 printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
	 printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
	 printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
	 printf("read only string addr: %p\n", str);
	 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;
}
  • 页表中,每一个区都会有一个权限 ,如果是代码区、常量区,那么只有读权限,不能修改,如果是变量,那么会设置权限为读写权限

  • 从上述代码的结果我们可以看到代码区和str常量字符串地址都在代码区,因此在代码区中会有一个小区域是字符常量区,都是只读的,数据是不可修改的,所以我们不能*str = 'C'将str中的helloworld修改为C,原因是如果我们要修改str内容,那么就需要拿到str的虚拟地址,再根据页表映射到物理地址,但是字符常量区是只读区域(权限检查),权限不匹配,因此无法转化为物理地址

  • 定义的全局变量是全局有效的 ,因为它们在数据区,地址空间存在,那么全局数据区就要存在,所以全局变量会一直存在,包括static静态成员变量

  • 命令行参数和环境变量属于父进程的地址空间内的数据资源,和代码区数据一样,子进程也会继承父进程的地址空间,所以子进程也能看到命令行参数和环境变量

四、为什么要有虚拟地址空间

  1. 有了虚拟地址就必须转换为物理地址。使用地址空间和页表进行映射,也一定要在OS的监管 之下来进行访问!!也顺便保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!(保护物理内存安全、维护进程独立性)
  2. 因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载 !在虚拟内存中都是按照区来保存的数据,代码就一定是在代码区,但是物理内存中代码数据都是在任意合法位置的,并不是按照代码放在一个区域,数据放在一个区域这样来加载的(进程看到自己的代码都是有序看待的,从"无序"变"有序"
  3. 创建一个进程时先创建内核数据结构,再加载进程的代码和数据(代码和数据可以全部加载到内存中,也可能是部分加载到内存中,也可能是使用的时候再加载进内存中)----惰性加载:提高内存使用率,这就是写时拷贝的原因(只有在修改的时候才进行,并不是创建出来就直接申请空间)
  4. 磁盘中代码和数据加载到内存中,就必须进行内存申请,物理内存的分配和进程的管理做到没有关系。进程管理模块和内存管理模块就完成了解耦合
  • 因为有地址空间的存在,所以我们在C、C++上new,malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知!!
相关推荐
L1624762 小时前
通用 Linux 系统存储选型总手册(MBR ,GPT,ext4,xfs)
linux·服务器
IT_Octopus2 小时前
Docker 镜像打的包有1.3个G 多阶段构建缩小镜像体积(不算成功)
运维·docker·容器
明洞日记2 小时前
【软考每日一练008】Web 服务器性能测试指标
运维·服务器·操作系统·软考
以太浮标3 小时前
华为eNSP模拟器综合实验之- AC+AP无线网络调优与高密场景
java·服务器·华为
真的想上岸啊3 小时前
1、全志h616板子介绍
linux
2401_890443023 小时前
Linux线程概念与控制
linux
wdfk_prog3 小时前
[Linux]学习笔记系列 --[drivers][base]map
linux·笔记·学习
Mr__Miss3 小时前
JAVA面试-框架篇
java·spring·面试
小马爱打代码3 小时前
SpringBoot:封装 starter
java·spring boot·后端