Linux:进程地址空间

简介: Linux:进程地址空间

进程地址空间

你大概率在C/C++学习过程中,见过如下内存分布图:

简单来说,就是从低地址往高地址,内存分区分别是:

  • 代码段:存储可执行代码只读常量
  • 数据段:存储全局变量静态数据
  • 堆区:用于动态内存管理,堆区内存往高处增长
  • 栈区:大部分局部变量,栈区内存往低处增长
  • 内核空间命令行参数argv环境变量env

我可以用一段代码来证明这张图片的正确性:

#include <stdio.h>                                                                                          
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

int g_unval;
int g_val = 100;
                                                                                                          
int main(int argc, char* argv[], char* env[])
{
    printf("正文代码: %p\n", main);
    printf("初始化数据: %p\n", &g_val);
    printf("未初始化数据: %p\n", &g_unval);

    int* heap1 = (int*)malloc(sizeof(int) * 1);
    int* heap2 = (int*)malloc(sizeof(int) * 1);
    int* heap3 = (int*)malloc(sizeof(int) * 1);

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

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

    printf("命令行参数地址 &argv[0]: %p\n", &argv[0]);
    printf("环境变量地址 &env[0]: %p\n", &env[0]);

    return 0;
}

我们首先输出了main函数的地址,此处有一个小知识点:函数名的本质就是地址,所以main和&main是一样的。函数存储在代码段,所以此处也代表代码段的地址。


其中变量g_val是一个全局变量,存储在数据段,g_unval也是一个全局变量,存储在数据段,不过两者一个初始化了,一个没初始化。它们两个代表常量区的地址。


随后我们malloc了三个地址出来,分别赋值给三个指针,然后输出了三条堆区地址,之所以要输出三条,是为了展示堆区的内存增长方向。


然后再输出了&heap1,&heap2,&heap3,虽然三个指针指向堆区内存,可指针变量本身是存储在栈区的,所以这三个语句代表了栈区的地址。

最后分别输出了一个环境变量&env[0]和命令行参数&argv[0],它们代表Linux内核数据

输出结果:

可以看到,不同分区地址是越来越大的,也就是:

代码段 < 数据段 < 堆区 < 栈区 < OS内核

其中还有三个区域内部的小问题:

  1. 同为数据段的内存,初始化过的g_val地址比未初始化的g_unval地址更低,也就是数据段中初始化过的数据会存在更低的地址
  2. 同为堆区的内存,先开辟的heap1出现在最低的地址,后开辟的heap3出现在最高的地址,也就是堆区中越后开辟的内存,地址越高,地址是向高处增长的
  3. 同为栈区的内存,先开辟的&heap1出现在最高的地址,后开辟的&heap3出现在最低的地址,也就是栈区中越后开辟的内存,地址越低,地址是向低处增长的

这样一套体系,叫做进程地址空间,那么这是真实的内存空间吗?

为了解决这个问题,那就要先说说什么是虚拟地址了。


虚拟地址

先看到以下案例:

#include <stdio.h>    
#include <unistd.h>    
#include <stdlib.h>    
#include <sys/types.h>    
    
int main()    
{    
    int val = 3;    
    pid_t id = fork();    
    
    if(id == 0)    
    {    
        printf("child: val = %d, &val = %p\n", val, &val);                                                  
        val = 5;    
        sleep(1);    
        printf("child: val = %d, &val = %p\n", val, &val);    
    
        return 0;    
    }    
    
    
    printf("parent:val = %d, &val = %p\n", val, &val);    
    sleep(2);    
    printf("parent:val = %d, &val = %p\n", val, &val);    
                                                      
    return 0;                                         
}    

以上代码中,先定义了一个变量val = 3,然后通过fork创建了子进程。对于子进程,先输出val的值和val的地址,然后再修改val的值为5,再输出一次val的值和val的地址;对于父进程,也输出两次val的值和val的地址,由于父进程sleep两秒,子进程sleep一秒,所以父进程第二次输出,子进程已经修改过val了。

输出结果:

以上输出结果中,父进程的val一直为3,这是毫无疑问的,因为进程具有独立性,父子进程的数据互不影响。子进程刚被创建时,和父进程共用数据和代码,因此父子进程第一次输出val的值的时候,不论值和地址都是一样的。

但是问题就出在子进程修改了val的值之后,为什么父子进程的val明明不同了,&val还是一样的?


那么有可能一个地址存储两个不同的值吗?显然是不可能的,这已经不是语法问题了,而是计算机组成原理的问题,一块内存毫无疑问同时只能存储一个值。那为什么此处父子进程的val值不同,但是地址相同?那就只有一个可能:这个地址不是物理地址,而是假的地址!

在语言层面接触到的地址,都不是物理地址,而是虚拟地址

也就是说,不论是C/C++,以及任何语言,所使用的地址都是虚拟地址,而不是在内存中真正的地址


进程地址空间的管理

那么我们再回到一开始的进程地址空间,既然我们拿到的不是真实的地址,但是我们又要去物理地址中存储数据怎么办?


实际中,操作系统中有一个叫做页表的东西,其会维护虚拟地址与物理地址之间的映射关系,当进程通过虚拟地址在进程地址空间中查找数据,其实本质上是拿着虚拟地址到页表中查找映射关系,进而找到真实的物理地址,再对数据进行访问。

那么我们再看看当时讲虚拟地址的时候遇到的问题:为什么父子进程会让同一个虚拟地址存储不同的数据

子进程被创建的时候,会继承父进程的大量数据,其中页表也会被继承

子进程继承到父进程的页表时,大部分内容都不会改动,而是直接拷贝,包括虚拟地址物理地址的映射关系在内!!!

也就是说,子进程继承到的页表,其虚拟地址和父进程是一样的,比如上图中,两进程对val的虚拟地址是一样的,因为子进程继承到了父进程中val的虚拟地址。

当子进程对val进行修改的时候,此时发送写时拷贝:

子进程会把原先与父进程共用的val拷贝一份到别的地方,然后修改val = 5。这个过程中,对于子进程来说,val的物理地址改变了,于是对页表的映射关系进行修改,此时val的虚拟地址不变,但是虚拟地址对应的物理地址改变了!因此这个过程只修改物理地址,不修改虚拟地址。


所以我们在修改了子进程中的val之后,观察到父子进程的val的地址一样,这是因为父子进程对val的虚拟地址是一样的,但是这个时候由于父子进程的页表不同,映射关系不同,最后访问到的物理地址其实是不一样的。因此我们输出的时候看到了一个地址两个值的情况。

接下来我们看看Linux是如何管理这个进程地址空间的:


系统层面管理

在系统层面,也就是Linux系统中,进程地址空间被存储在PCB中,作为进程的一项属性。而进程地址空间本身被一个叫做mm_struct结构体管理,

我们一开始就给出了进程地址空间的视图:


88cca2f0ae1043b2ac2c7d475e20440f.png


那么毫无疑问mm_struct的第一大要务,就是给进程地址空间进行分区操作。在mm_struct内部,会存储每一个分区的开始和结束的地址,然后根据这个地址的范围,来判断该地址属于哪一个区域。


比如以下代码是Linux 2.6内核中mm_struct的一部分源码:

unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stackvm, reserved_vm, def_flags, nr_ptes; 
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;

比如其中start_code表示代码段的开始,end_code表示代码段的结束,start_data表示数据段的开始,end_data表示数据段的结束。

mm_struct大致视图如下:

从左往右task_struct就是进程PCBmm_struct就是进程地址空间,page table就是页表,physical memory就是物理内存,这一套体系我们已经在本博客前面都讲过了。


硬件层面管理

虚拟地址是不具备存储数据的能力的,而我们在语言中得到的地址都是虚拟地址,那么CPU是如何通过页表把虚拟地址物理地址的呢?

CPU中,存在一个叫做MMU的单元,Memory Management Unit - 内存管理单元,其可以把虚拟地址转换为物理地址

当CPU拿到要执行的代码时,拿到的是虚拟地址,然后通过MMU将虚拟地址转化为物理地址,最后到内存中去访问物理地址。

那么MMU又是怎么把虚拟地址转化为物理地址的?在MMU中,有一个叫做CR3的寄存器,CR3中存储了页表,因此MMU可以通过CR3访问页表,进而到页表中查询映射关系,找到物理地址。



进程地址空间意义

  1. 由于页表的存在,无序的地址变为了有序的地址

在为进程分配内存的时候,分配的内存是比较散乱的,此时就会导致地址非常杂乱无章可循。当通过页表映射,把指向相同功能的内存地址放到一起,此时我们就有的栈区堆区静态区等等区域,更好地统一管理地址了

  1. 将进程管理和内存管理解耦

由于页表的存在,此时进程管理和内存管理就是互不影响的。进程只需要去读取内存,申请内存等,无需考虑硬件层面的内存是如何管理的。对于磁盘,只需要做好加载数据到内存的工作,加载完数据后,无需考虑进程是如何读取地址,如何获取数据的。

  1. 保护了内存安全

当用于向内存发出非法访问时,进程地址空间就可以检测出来,比如访问越界的内存等等。此时内存中的数据不会受到任何影响,因为该错误已经被进程地址空间检测并处理了。


比如我们通过指针向非法的内存进行写入,那么进程地址空间就可以检测出来该地址是超出了某个范围的,在操作系统层面就直接报错,而不会真的等到对内存写入了数据之后,才发现该访问非法。

  1. 确保了进程的独立性

进程 = 内核数据结构 + 进程自己的代码和数据。通过进程地址空间的映射,每个进程都有自己的内核数据结构,自己的代码,自己的数据,相互之间完全独立互不影响。

比如下图:

左右侧是不同的两个进程,它们的页表,PCB等等内核数据结构都是独立的,互相之间不会影响。


动态内存管理底层机制

C/C++中,有着动态内存管理机制,给了用户足够高的自由度去自定义内存。比如C通过函数malloc/free,以及C++通过操作符new/delete来完成。

当用户向内存申请空间,但是用户很有可能还没有这么快就使用这块内存,那么如果操作系统直接把这一块内存分配给该进程,就会导致内存的浪费。


操作系统要为效率和资源利用率负责,因此当用户进行内存申请的时候,操作系统不会直接分配内存。操作系统会先给用户一个虚拟地址,比如malloc和new都会返回指针,这个指针就是虚拟地址。但是虽然有了虚拟地址,但是页表中没有该虚拟地址的映射关系。


直到用户尝试对这个内存进行访问,只要访问合法,就会去页表中查找该虚拟地址的物理地址。当发现该虚拟地址不存在页表中,此时就会向操作系统报错,这个过程叫做缺页中断。


一旦发生缺页中断,操作系统就会进行分析,发现是用户想要访问之前动态开辟的内存,于是操作系统此时才真正开辟内存,并且在页表中建立映射关系。

因此:动态内存管理的本质,是在虚拟地址中申请内存

这么做有两个好处:

  1. 保证了内存的使用率,直到用户对内存写入,才真正开辟内存
  2. 提升了mallocnew等动态内存管理的速度,因为没有真的申请内存

相关文章
|
21天前
|
算法 Linux 调度
深入理解Linux操作系统的进程管理
本文旨在探讨Linux操作系统中的进程管理机制,包括进程的创建、执行、调度和终止等环节。通过对Linux内核中相关模块的分析,揭示其高效的进程管理策略,为开发者提供优化程序性能和资源利用率的参考。
46 1
|
9天前
|
存储 监控 Linux
嵌入式Linux系统编程 — 5.3 times、clock函数获取进程时间
在嵌入式Linux系统编程中,`times`和 `clock`函数是获取进程时间的两个重要工具。`times`函数提供了更详细的进程和子进程时间信息,而 `clock`函数则提供了更简单的处理器时间获取方法。根据具体需求选择合适的函数,可以更有效地进行性能分析和资源管理。通过本文的介绍,希望能帮助您更好地理解和使用这两个函数,提高嵌入式系统编程的效率和效果。
61 13
|
16天前
|
SQL 运维 监控
南大通用GBase 8a MPP Cluster Linux端SQL进程监控工具
南大通用GBase 8a MPP Cluster Linux端SQL进程监控工具
|
24天前
|
运维 监控 Linux
Linux操作系统的守护进程与服务管理深度剖析####
本文作为一篇技术性文章,旨在深入探讨Linux操作系统中守护进程与服务管理的机制、工具及实践策略。不同于传统的摘要概述,本文将以“守护进程的生命周期”为核心线索,串联起Linux服务管理的各个方面,从守护进程的定义与特性出发,逐步深入到Systemd的工作原理、服务单元文件编写、服务状态管理以及故障排查技巧,为读者呈现一幅Linux服务管理的全景图。 ####
|
1月前
|
缓存 监控 Linux
linux进程管理万字详解!!!
本文档介绍了Linux系统中进程管理、系统负载监控、内存监控和磁盘监控的基本概念和常用命令。主要内容包括: 1. **进程管理**: - **进程介绍**:程序与进程的关系、进程的生命周期、查看进程号和父进程号的方法。 - **进程监控命令**:`ps`、`pstree`、`pidof`、`top`、`htop`、`lsof`等命令的使用方法和案例。 - **进程管理命令**:控制信号、`kill`、`pkill`、`killall`、前台和后台运行、`screen`、`nohup`等命令的使用方法和案例。
146 4
linux进程管理万字详解!!!
|
29天前
|
缓存 算法 Linux
Linux内核的心脏:深入理解进程调度器
本文探讨了Linux操作系统中至关重要的组成部分——进程调度器。通过分析其工作原理、调度算法以及在不同场景下的表现,揭示它是如何高效管理CPU资源,确保系统响应性和公平性的。本文旨在为读者提供一个清晰的视图,了解在多任务环境下,Linux是如何智能地分配处理器时间给各个进程的。
|
1月前
|
存储 运维 监控
深入Linux基础:文件系统与进程管理详解
深入Linux基础:文件系统与进程管理详解
85 8
|
1月前
|
网络协议 Linux 虚拟化
如何在 Linux 系统中查看进程的详细信息?
如何在 Linux 系统中查看进程的详细信息?
93 1
|
1月前
|
Linux
如何在 Linux 系统中查看进程占用的内存?
如何在 Linux 系统中查看进程占用的内存?
|
1月前
|
算法 Linux 定位技术
Linux内核中的进程调度算法解析####
【10月更文挑战第29天】 本文深入剖析了Linux操作系统的心脏——内核中至关重要的组成部分之一,即进程调度机制。不同于传统的摘要概述,我们将通过一段引人入胜的故事线来揭开进程调度算法的神秘面纱,展现其背后的精妙设计与复杂逻辑,让读者仿佛跟随一位虚拟的“进程侦探”,一步步探索Linux如何高效、公平地管理众多进程,确保系统资源的最优分配与利用。 ####
71 4