自学内容网 自学内容网

Linux进程地址空间

目录

Linux进程地址空间

程序地址空间分配回顾

进程地址空间

虚拟地址

地址空间

区域划分

分页与数据独立

页表基本介绍

进程地址空间结构初始化时机

总结


Linux进程地址空间

程序地址空间分配回顾

在前面C语言以及C++部分介绍过二者的内存分配如下图所示:

全局变量区和未初始化全局变量区也被称为数据区,数据区中除了有全局变量,还有静态变量和常量

使用下面的代码演示不同的内容所处的地址:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

// 初始化的全局变量
int global = 0;
// 未初始化的全局变量
int value;

int main() {
    // 环境变量
    char* envi = getenv("PWD");
    
    // 堆区
    int* ptr = (int*)malloc(sizeof(int)*10);
    // 栈区
    int a = 0;
    int b = 0;
    int c = 0;
    
    printf("%p\n", &global);
    printf("%p\n", &value);
    printf("%p\n", ptr);
    // 代码区
    printf("%p\n", main); // 函数名即函数地址
    printf("%p\n", envi);
    printf("%p\n", &a);
    printf("%p\n", &b);
    printf("%p\n", &c);
    
    return 0;
}

输出结果:
0x601048
0x60104c
0x7e1010
0x4005cd
0x7ffc99757f43
0x7ffc9975694c
0x7ffc99756948
0x7ffc99756944

实际上,所谓的程序地址空间就是进程地址空间,进程地址空间是如何产生的就是下面需要探讨的问题

进程地址空间

虚拟地址

前面提到,父进程和子进程会共享代码和数据,尤其是两个进程不进行数据修改时,数据不会产生两份,那么这样理解就可以直观地认为当子进程修改了数据,对应的变量内存地址就会发生改变,但是改变的是不是程序读取到的地址,看下面例子的演示结果:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

// 初始化的全局变量
int global = 0;
// 未初始化的全局变量
// int value;

int main() {
    printf("父进程, pid = %d, &global = %p\n", getpid(), &global);
    
    pid_t p = fork();
    
    if(p == 0) {
    // 子进程
    while(1) {
            printf("子进程, pid = %d, &global = %p\n", getpid(), &global);
            // 子进程修改
            global++;
            sleep(1);
            }
        } else {
            while(1) {
            
            }
    }
    
    return 0;
}

输出结果:
父进程, pid = 12969, &global = 0x601050
子进程, pid = 12970, &global = 0x601050

可以看到,尽管子进程修改了与父进程共享的代码中的变量,子进程读取到的变量的地址与父进程读取到的变量的地址是完全相同的。

实际上,在C语言程序中使用&获取到的地址是一个虚拟地址(也称线性地址),对应虚拟地址的就是物理地址。

在上面提到的「子进程改变代码中的数据,对应的内存地址会发生改变」,本质是因为此处的内存地址指的是物理地址,而不是虚拟地址

地址空间

地址空间,可以理解为是操作系统为每一个进程开辟的一块运行空间,示意图如下:

为了保证所有的进程都有一个完整的地址空间,操作系统为每个进程提供一个独立的虚拟地址空间。例如,如果操作系统支持3GB的虚拟地址空间,每个进程都会认为自己拥有独立的3GB空间,不会受到其他进程占用内存的影响。这一过程暂且可以理解为操作系统为每一个进程“画的一张大饼”,而这个“饼”即为进程地址空间

区域划分

进程地址空间中存在着多个区域,每一个区域有着自己的作用。每一块区域的划分实际上是根据区域的起始值和终止值进行决定,示意图如下:

为了方便管理,在Linux中,操作系统在底层的task_struct内部存在着一个结构体指针,该结构体指针的类型是mm_struct结构体,该结构体中存在着一些变量用于存储指定区域的起始值和终止值。这些值本质也是地址值,但是这个地址并不是实际的物理内存的地址,而是通过映射后的虚拟地址,源码如下:

struct mm_struct {
    // ...
    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;
    unsigned long rss, total_vm, locked_vm;
    // ...
};

因为变量的地址都是在这些指定的进程地址空间区域中,所以也可以解释为什么程序获取到的是虚拟地址而非物理地址

分页与数据独立

在上面探讨了虚拟地址和物理地址,操作系统为了管理这两个地址之间的映射,从而保证虚拟地址能够正确访问到实际的物理地址对应的内容,在创建一个进程PCB时会同时创建一个页表。

在执行前面的代码的过程中,子进程未创建之前,父进程拥有自己的页表和进程地址空间,此时全局变量global会由操作系统在物理地址上开辟一块空间,并将映射后的虚拟地址填充到页表中被进程获取,如下图所示:

当创建子进程之后,子进程会与父进程共享代码和数据,此时意味着子进程拷贝父进程中的task_struct和页表,对task_struct中的内容进行针对性地修改,在子进程没有改变复制过来的数据之前,由于页表内容相同,所以子进程页表中的映射与父进程完全相同,示意图如下:

当子进程对global变量进行修改时,操作系统就会在物理内存中开辟一块新的空间,将共享的global数据拷贝到该空间,这个拷贝的过程也被称为写时拷贝。物理内存虽然开辟了一块新的空间,实际上对于子进程的页表来说,还是通过同样的虚拟地址映射物理地址:

上面的过程也就可以解释为什么最开始的代码中,子进程和父进程获取到的变量值不同,但是地址却相同,也就更加验证了一个变量中不可能存在两个不同的值

页表基本介绍

在Linux中,页表不仅仅有虚拟地址和物理地址的映射,还有物理地址的属性RWX以及是否有数据的标识isExists,所以更加细致的页表应该如下所示:

RWX属性:代表虚拟地址对应的物理地址是否具有读(R)、写(W)和执行权限(X)。

前面提到,每一个进程地址空间区域都由指定的起始值和终止值进行划分,而这些区域有的是可以写,有的不可以写只能读,但是对于物理内存来说,绝大部分的空间都是可以写的,所以对于限制指定的物理地址是否可以写入就是通过RWX属性进行控制

例如,前面学习到的栈区和堆区,在程序代码运行时,可以在栈区和堆区申请空间并进行写入,但是对于字符串常量等具有常性的值就不可以进行随意修改和写入。而之所以在语言层面无法检测到这种问题,原因就是页表并不是在编译期间就创建的,而是在程序运行开始由操作系统创建,但是为了语言层面更加容易看出这种错误,就可以把不想被修改的内容或者本身不可以修改的内容修饰为const,从而让编译器可以在编译器检查出错误

此刻也便可以解释为什么程序会有野指针的概念,在语言层面,野指针表示指针中的地址对应的空间已经被释放并归还给操作系统进行管理,此时不可以通过这个指针访问对应的地址。在此时的系统层面,之所以可以限制野指针写入就是通过RWX属性,因为虚拟地址对应的物理地址具有的属性已经是R

isExists属性:代表虚拟地址对应的物理地址是否存在有效数据。在操作系统加载进程时,会创建对应的进程地址空间和页表,在页表中将虚拟地址和物理地址进行映射。然而,这个过程中某些进程的代码可能特别多,导致正文代码区占用的空间变得很大,而实际使用时,可能有很大一部分的代码长时间不会被执行,造成资源浪费。为了解决这个问题,操作系统通常采用按需加载(惰性加载,Lazy Loading)策略。操作系统会先加载一部分内容,在运行过程中,通过检查页表中的有效位(Valid Bit)来判断虚拟地址访问的物理地址是否已经加载。如果没有加载,则触发缺页中断(Page Fault),操作系统会从磁盘加载相应的数据到内存中,再继续执行。这一过程也适用于交换分区(Swap Partition),决定何时将数据换入或换出内存

结合前面的两个属性,操作系统就实现先告诉进程自己已经开辟好了空间,这个空间的地址由多个虚拟地址组成,但是实际上可能物理地址并没有全部与指定的虚拟地址对应,当程序运行到指定的部分再进行开辟映射,这个操作就可以实现将内存空间利用率最大化

进程地址空间结构初始化时机

任何一个结构体在创建时一定要进行初始化,而进程地址空间也是由一个结构进行描述,这个结构的初始化由操作系统在可执行程序加载到内存时完成,可执行程序加载到内存变成进程时,操作系统可以获取到部分区域的起始值和终止值,例如正文代码区、数据区(包括全局变量区、常量区、静态变量区)和命令行与环境变量区。所以这就可以解释为什么静态变量、全局变量和常量一直持续到进程结束

但是这其中有两个不同的区域:栈区和堆区,栈区在函数创建时会开辟对应的栈帧,堆区在申请时会在已有的堆区上额外开辟需要的空间,所以这两个区域在程序刚加载到内存时是不存在的

总结

之所以需要存在页表和进程地址空间有以下三个原因:

  1. 保护内存:如果让进程直接访问物理内存,会导致在物理层面的野指针情况,并且这个情况在物理层面并不容易被发现和阻止
  2. 存在页表可以达到进程管理和内存管理耦合度降低:因为页表主要作用的是虚拟地址和物理地址之间的映射,操作系统在创建进程时初始化对应的虚拟地址即可,但是虚拟地址是否有对应的物理地址可以不用关心,只要没有被使用。而对于内存管理,操作系统只需要考虑在需要的时候将物理地址加载到页表,此时对应的虚拟地址有映射就可以正常执行,在不需要的时候,将物理地址设置为只读或者给其他进程使用
  3. 让进程以统一的视角看待物理内存(无序变有序):因为操作系统会为每一个进程开辟一个独立的页表和进程地址空间,让每一个进程都认为自己拥有操作系统分配的全部内存空间,并且在进程访问地址空间时,实际上这个地址空间的地址是一个虚拟地址,这个虚拟地址可以由操作系统自主决定为连续地址,此时就可以不用考虑物理地址是否需要连续,因为进程只能获取到虚拟地址,只要虚拟地址有物理地址映射并且拥有指定的权限,就不会出现问题,从而实现让「让无序的物理内存地址变为有序的地址」
物理地址在开辟时,一般也会尽量是连续开辟,保证CPU在缓冲中的数据命中率

原文地址:https://blog.csdn.net/m0_73281594/article/details/142716819

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!