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等动态内存管理的速度,因为没有真的申请内存

相关文章
|
3月前
|
存储 Linux API
【Linux进程概念】—— 操作系统中的“生命体”,计算机里的“多线程”
在计算机系统的底层架构中,操作系统肩负着资源管理与任务调度的重任。当我们启动各类应用程序时,其背后复杂的运作机制便悄然展开。程序,作为静态的指令集合,如何在系统中实现动态执行?本文带你一探究竟!
【Linux进程概念】—— 操作系统中的“生命体”,计算机里的“多线程”
|
1月前
|
并行计算 Linux
Linux内核中的线程和进程实现详解
了解进程和线程如何工作,可以帮助我们更好地编写程序,充分利用多核CPU,实现并行计算,提高系统的响应速度和计算效能。记住,适当平衡进程和线程的使用,既要拥有独立空间的'兄弟',也需要在'家庭'中分享和并行的成员。对于这个世界,现在,你应该有一个全新的认识。
146 67
|
1月前
|
JavaScript Linux Python
在Linux服务器中遇到的立即重启后的绑定错误:地址已被使用问题解决
总的来说,解决"地址已被使用"的问题需要理解Linux的网络资源管理机制,选择合适的套接字选项,以及合适的时间点进行服务重启。以上就是对“立即重启后的绑定错误:地址已被使用问题”的全面解答。希望可以帮你解决问题。
100 20
|
2月前
|
存储 Linux 调度
【Linux】进程概念和进程状态
本文详细介绍了Linux系统中进程的核心概念与管理机制。从进程的定义出发,阐述了其作为操作系统资源管理的基本单位的重要性,并深入解析了task_struct结构体的内容及其在进程管理中的作用。同时,文章讲解了进程的基本操作(如获取PID、查看进程信息等)、父进程与子进程的关系(重点分析fork函数)、以及进程的三种主要状态(运行、阻塞、挂起)。此外,还探讨了Linux特有的进程状态表示和孤儿进程的处理方式。通过学习这些内容,读者可以更好地理解Linux进程的运行原理并优化系统性能。
74 4
|
3月前
|
存储 网络协议 Linux
【Linux】进程IO|系统调用|open|write|文件描述符fd|封装|理解一切皆文件
本文详细介绍了Linux中的进程IO与系统调用,包括 `open`、`write`、`read`和 `close`函数及其用法,解释了文件描述符(fd)的概念,并深入探讨了Linux中的“一切皆文件”思想。这种设计极大地简化了系统编程,使得处理不同类型的IO设备变得更加一致和简单。通过本文的学习,您应该能够更好地理解和应用Linux中的进程IO操作,提高系统编程的效率和能力。
143 34
|
2月前
|
Linux
Linux:守护进程(进程组、会话和守护进程)
守护进程在 Linux 系统中扮演着重要角色,通过后台执行关键任务和服务,确保系统的稳定运行。理解进程组和会话的概念,是正确创建和管理守护进程的基础。使用现代的 `systemd` 或传统的 `init.d` 方法,可以有效地管理守护进程,提升系统的可靠性和可维护性。希望本文能帮助读者深入理解并掌握 Linux 守护进程的相关知识。
81 7
|
2月前
|
Linux Shell
Linux 进程前台后台切换与作业控制
进程前台/后台切换及作业控制简介: 在 Shell 中,启动的程序默认为前台进程,会占用终端直到执行完毕。例如,执行 `./shella.sh` 时,终端会被占用。为避免不便,可将命令放到后台运行,如 `./shella.sh &`,此时终端命令行立即返回,可继续输入其他命令。 常用作业控制命令: - `fg %1`:将后台作业切换到前台。 - `Ctrl + Z`:暂停前台作业并放到后台。 - `bg %1`:让暂停的后台作业继续执行。 - `kill %1`:终止后台作业。 优先级调整:
118 5
|
2月前
|
Linux 应用服务中间件 nginx
Linux 进程管理基础
Linux 进程是操作系统中运行程序的实例,彼此隔离以确保安全性和稳定性。常用命令查看和管理进程:`ps` 显示当前终端会话相关进程;`ps aux` 和 `ps -ef` 显示所有进程信息;`ps -u username` 查看特定用户进程;`ps -e | grep &lt;进程名&gt;` 查找特定进程;`ps -p &lt;PID&gt;` 查看指定 PID 的进程详情。终止进程可用 `kill &lt;PID&gt;` 或 `pkill &lt;进程名&gt;`,强制终止加 `-9` 选项。
49 3
|
3月前
|
消息中间件 Linux C++
c++ linux通过实现独立进程之间的通信和传递字符串 demo
的进程间通信机制,适用于父子进程之间的数据传输。希望本文能帮助您更好地理解和应用Linux管道,提升开发效率。 在实际开发中,除了管道,还可以根据具体需求选择消息队列、共享内存、套接字等其他进程间通信方
90 16
|
4月前
|
消息中间件 Linux
Linux:进程间通信(共享内存详细讲解以及小项目使用和相关指令、消息队列、信号量)
通过上述讲解和代码示例,您可以理解和实现Linux系统中的进程间通信机制,包括共享内存、消息队列和信号量。这些机制在实际开发中非常重要,能够提高系统的并发处理能力和数据通信效率。希望本文能为您的学习和开发提供实用的指导和帮助。
297 20