【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}

进程地址空间

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第1张图片

一、地址空间的布局

进程地址空间排布图:

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第2张图片

验证地址空间排布:

int main(int argc, char** argv, char** env){    
    //代码区    
    cout << "code addr:" << (void*)main << endl;    
    cout << "const string addr:" << (void*)"hello" << endl;    
    cout << endl;    
    //初始化数据区    
    cout << "init global addr:" << &g_init_val << endl;    
    static int s_init_val = 20;    
    cout << "init static addr:" << &s_init_val << endl;                                                                          
    //未初始化数据区    
    static int s_uninit_val;    
    cout << "uninit global addr:" << &g_uninit_val << endl;    
    cout << "uninit static addr:" << &s_uninit_val << endl;    
    cout << endl;    
    //堆区和栈区    
    int* pheap1 = new int;    
    int* pheap2 = new int;    
    int* pheap3 = new int;    
    cout << "heap addr:" << pheap1 << endl;    
    cout << "heap addr:" << pheap2 << endl;    
    cout << "heap addr:" << pheap3 << endl;    
    cout << "stack addr:" << &pheap1 << endl;
    cout << "stack addr:" << &pheap2 << endl;
    cout << "stack addr:" << &pheap3 << endl;
    cout << endl;
    //命令行参数和环境变量
    cout << "argv addr:" << (void*)argv[0] << endl;
    cout << "env addr:" << (void*)env[0] << endl;
    return 0;
  }

测试结果:

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第3张图片

说明:

  1. 代码区用于存储代码和字面常量,属于只读区域不能修改。
  2. 静态区分初始化和未初始化两个区域,用于存储全局变量和静态局部变量
  3. static修饰变量的本质是将变量空间在静态区开辟。
  4. 堆栈相向而生。在栈空间上,无论是开辟函数栈帧还是定义局部变量都是从高地址向低地址发展。
  5. 堆空间之所以比要求空间大,是因为需要多余的空间保存堆cookie——包含堆空间的属性信息。

内核空间

  • 在32位系统下,一个进程通常会被分配4GB的虚拟内存空间。这是因为32位系统使用32位的地址空间,每个地址可以表示2^32个不同的内存位置,即4GB。地址范围是0x00000000~0xFFFFFFFF。

  • 在这4GB的虚拟内存空间中有大约1GB的内核空间会被操作系统保留,用于存储操作系统本身的代码和数据。剩下的大约3GB空间才是该进程的用户空间。

注意:以上所有的测试结果及结论均只在Linux系统中有效,Windows系统有自己的更复杂的内存机制因此得到的结果可能有所不同,但其底层原理是相同的。


二、什么是地址空间?

先观察一个现象:

int g_val = 100;    
    
void Test1(){    
  pid_t id = fork();    
  if(id == 0)    
  {    
    //child:    
    int cnt = 0;    
    while(1)    
    {    
      cout << "I'm child process "<< "pid:" << getpid() << " ppid:" << getppid();    
      cout << "g_val:" << g_val << " &g_val:" << &g_val << endl;    
      sleep(1);    
      ++cnt;    
      if(cnt == 3)                                                                                                 {    
        g_val = 200;    
        cout << "child change g_val 100->200 success!" << endl;    
      }    
    }    
  }    
  else{
    //father:
    while(1)
    {
      cout << "I'm father process "<< "pid:" << getpid() << " ppid:" << getppid();
      cout << "g_val:" << g_val << " &g_val:" << &g_val << endl;
      sleep(1);
    }
  }
}

运行结果:

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第4张图片

为什么父子进程g_val的地址相同但值不同?

  • 这个地址不是物理内存地址,而是虚拟内存地址。
  • 几乎所有的语言,如果他有“地址”的概念,就一定指的是虚拟地址(线性地址)!
  • 物理地址,用户一概看不到,由OS统一管理。同时OS必须负责将虚拟地址转化成物理地址。

提示:

  1. 上面我们研究的内存布局,实际上指的是虚拟内存。

  2. 下文中的地址空间指的就是进程的虚拟地址空间、虚拟内存,这里我做了简化。

  3. 在Linux系统中所谓的逻辑地址,虚拟地址,线性地址实际是一回事,只不过是在不同阶段的不同称谓罢了。


三、地址空间是如何设计的?

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第5张图片

  1. 操作系统为每个进程都分配了一个地址空间和对应的映射页表。
  2. 每个进程都认为自己拥有整个系统的内存,即虚拟地址范围从0x00000000到0xFFFFFFFF。
  3. Linux内核中的地址空间本质上是一个名为mm_struct的结构,其中就包括了地址空间的区域划分及其他属性。 以下是部分源码:

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第6张图片

  1. 地址空间中数据区域的变化,实际上就是对区域的边界start或end值进行调整。
  2. task_struct进程控制块中记录了指向mm_struct结构体的指针struct mm_struct *mm, *active_mm;。也就是说可以通过PCB找到进程对应的地址空间。
  3. 映射页表中记录了虚拟内存和物理内存的映射关系,通过查表OS可以将虚拟地址转化成物理地址。

完善进程的概念:
一个进程包括两大部分:

  1. 内核数据结构:进程PCB(task_struct),虚拟内存地址空间(mm_struct),映射页表
  2. 内存块:用于存储代码和数据

为什么父子进程g_val的地址相同但值不同?

【Linux进程】进程地址空间 {地址空间的布局;什么是地址空间?地址空间是如何设计的?为什么要有地址空间?拓展内容}_第7张图片

  1. 因为子进程是以父进程为模版创建的,所以父子进程的地址空间和页表映射关系也相同。因此父子进程g_val的虚拟地址相同,甚至在子进程进行写入操作之前,页表会将其虚拟内存映射到与父进程相同的物理内存。
  2. 但在子进程试图进行写入操作时会在物理内存中拷贝形成一块子进程所独有的新的内存,这样的拷贝称为写时拷贝。同时映射关系也会发生改变:子进程的虚拟内存将会映射到新的物理内存中。因此子进程中的g_val发生了改变但不影响父进程中的值。
  3. 父子进程之间的独立性由此而来。

fork();函数为什么会有两个返回值?一个变量怎么会同时保存不同的值呢?

答:在fork()函数的return语句之前,子进程就已经被成功创建了。所以return会被执行两次,函数返回实际就是在对外部接收变量进行写入,此时物理内存发生写时拷贝。所以父子进程其实已经有属于各自的物理内存了,只不过在用户层面,在同一份代码中用同一个变量,同一个虚拟地址对其进行标识罢了。


四、为什么要有地址空间?

想要访问物理内存必须通过地址空间和映射页表,而他们又是操作系统创建并维护的。也就是说必须要在操作系统的监管之下访问物理内存。虚拟内存技术的作用如下:

  1. 内存保护:凡是非法访问,操作系统都能够识别到,并终止这个进程。

    1. 其实映射页表除了维护虚拟和物理内存之间的映射关系,还会管理每条映射关系的访问权限
    2. 根据虚拟地址所在的不同内存区域,就可以确定该虚拟地址映射关系的权限。如指向代码常量区的地址,其页表中的映射关系是只读权限。
    3. 如果向只读代码区进行写入操作,操作系统能够识别到,并终止这个进程。
    4. 对映射关系的权限管理有效的保护了物理内存中所有的合法数据、其他进程的代码和数据以及内核的相关数据。
  2. 内存隔离:保证进程之间互不干扰,实现了进程的独立性。

    1. 每个进程都认为自己独占整个系统的4GB内存(32),即虚拟地址范围从0x00000000到0xFFFFFFFF。

    2. 每个进程的页表映射的是物理内存的不同区域,因此进程之间能做到互不干扰。

    3. 每个进程都不知道其他进程的存在

  3. 内存管理:地址空间和页表的存在使得进程的内存分布有序化

    1. 物理内存不存在区块划分,也就是说物理内存中的代码和数据是乱序的。

    2. 但是映射页表可以将有序的虚拟地址空间映射到分散无序的物理内存上。

    3. 这就使得进程的内存分布有序化,便于进行内存管理。

  4. 地址空间的存在使得内存管理和进程管理模块完成了解耦合

    1. 内存管理模块负责将进程的虚拟地址映射到物理内存中的页框,而进程管理模块负责管理进程的创建、调度和终止等操作。

    2. 通过地址空间的存在,内存管理模块可以独立于进程管理模块进行工作。内存管理模块只需要关注虚拟地址和物理地址之间的映射关系,而不需要关心具体的进程信息。同样地,进程管理模块也不需要关心内存管理的具体实现细节。

    3. 这种解耦合的设计使得内存管理和进程管理模块可以独立开发、测试和维护,提高了系统的可扩展性和可维护性。同时,也使得操作系统能够更好地支持多任务和虚拟内存的功能。

    举例说明:

    1. 当我们在代码中使用malloc, new申请内存空间时,实际上是在申请虚拟地址空间。(进程管理)
    2. 操作系统采用延迟内存分配的策略,来提高内存的使用效率:如果申请的地址空间不立马使用,操作系统就不会为其马上分配物理内存;只有当进程真正对物理内存进行访问的时候,才会执行内存的相关管理算法申请物理内存空间,构建页表映射关系。(内存管理)
    3. 对物理内存的分配和管理,完全是独立于进程之外的另一个模块。用户包括进程对内存管理完全0感知。

五、拓展内容

可执行程序文件内部存在虚拟地址吗?

  1. 可执行程序在编译过程中,编译器就会根据变量、指令等的存储类型将他们划分到不同的地址空间区域,并为他们分配逻辑地址(虚拟地址)。因此可执行程序文件中保存着各变量、指令的虚拟地址。
  2. 当程序运行起来后,可执行程序被加载到物理内存,所以每个变量、指令又同时具有了物理地址。然后将这些变量、指令的虚拟、物理地址对应的填入到页表中,虚拟、物理内存之间的映射关系就被建立起来了。
  3. 同时还可以利用可执行程序中保存的虚拟地址初始化进程的地址空间mm_struct,这样就完成了地址空间的区块划分,CPU就可以直接操作虚拟内存了。

提示:在Linux系统中所谓的逻辑地址,虚拟地址,线性地址实际是一回事,只不过是在不同阶段的不同称谓罢了。

重新理解进程的创建状态和挂起状态

  • 在进程处于创建状态时,程序的代码和数据通常并没有完全加载到内存中。在创建进程的过程中,操作系统会为进程分配必要的资源和虚拟地址空间(这里指的就是进程的内核数据结构),但并不会立即将整个程序的代码和数据加载到内存中,极端情况下甚至不会加载。
  • 相反,操作系统通常会采用延迟加载的策略,即只有在进程被调度准备执行时,操作系统才会根据需要将进程的代码和数据分批加载到内存中,以供CPU执行。这样可以提高内存的利用率和系统的性能。
  • 而进程的挂起状态则是由于进程长时间不执行或者处于低优先级状态,操作系统会将其全部或部分代码和数据换出内存。从内存分配的角度来看,挂起状态和创建状态是类似的。

分批换入换出内存

  • 操作系统通常使用虚拟内存技术,将程序的代码和数据分为多个页面(或者多个段),并根据需要将这些页面从磁盘加载到内存中。

  • 虚拟内存允许操作系统将程序的部分代码和数据加载到内存中,而将其他部分保留在磁盘上。当程序需要访问未加载到内存的页面时,操作系统会将相应的页面从磁盘加载到内存中,并将不再使用的页面换出内存上。

  • 页表不仅可以映射内存地址,还可以直接映射磁盘等外设上的位置。所以程序的换入实际上是将页表上记录的磁盘位置下的代码和数据加载到内存中,并更新页表的映射关系使其指向内存中的位置。而程序的换出实际上是直接将内存中相应的代码和数据释放,并更新页表映射关系使其重新指向磁盘中的位置。

  • 这种分批换入换出的方式使得程序可以使用比实际可用内存更大的虚拟地址空间,从而允许运行更大的程序。

你可能感兴趣的:(Linux,linux,运维,服务器,虚拟内存,进程)