Linux相关概念和易错知识点(13)(进程地址空间、页表、虚拟地址)

1.进程地址空间

(1)虚拟地址与物理地址

我们先来看一个现象

这里就需要我们接受一个概念,即我们用户以任何方式看到的地址都是虚拟地址(线性地址),真正的物理地址是通过一种类似map的内核数据结构给映射起来了,这个内核数据结构管理着虚拟地址和物理地址,同时这个内核数据结构也起着保护物理空间,拦截非法请求等作用。

这个内核数据结构叫做页表。

我们从一个常见的例子说起。

这里编译器并没有报错,程序运行起来了,但又因为野指针被终止了,是谁终止的呢?这与页表有关。

编译器、用户访问、进程管理,你能想到的一切操作,它们都只能拿着虚拟地址进行访问和操作,这些操作都会传到页表的手上,一切对物理空间的访问都需要页表来管理。驱动是系统内核和输入输出设备的沟通的媒介,页表则是系统内核和内存沟通的媒介。系统和硬件之间都有一层软件层进行集中管理,保护硬件安全,规范硬件的使用。

当有任何对内存访问的需求时,都需要经过页表的映射,页表提供映射信息,内存管理单元(MMU)根据信息进行地址转换。页表也充当管理作用,将一切违法的操作终止,甚至直接将请求的进程杀掉。

因此,我们常见的栈区、堆区、代码段、数据段等都是指的进程地址空间中的区域,而不是实际的物理地址。

(2)struct mm_struct

①总体理解

我们勉强知道了页表、进程地址空间这些东西的存在,但它在进程的哪里?如何维护?

PCB里面有一个struct mm_struct* mm指针,这个mm指向的这个结构体划分了进程地址空间的区域分配情况。大概方法是用unsigned long划分该进程的栈区、堆区、数据段、代码段的起止位置(从0x00000000到0xffffffff线性划分,这也是线性地址名字的由来)。每个进程都有一块自己的进程地址空间。

PCB划分的进程地址空间的区域大小都有差异,比如某个进程new了一些空间,struct mm_struct划分时就会给堆区多划分一些,这是很灵活的。

注意,虽然struct mm_struct划分存在动态调节,但总体的划分大小不变,都是按理论最大内存分配,即32位系统32根地址线,理论最大4G内存。即每字节一个编号,从0x00000000到0xffffffff,就算这个人的电脑有多个进程争夺内存,或是只装了2G内存,但每个进程都会以为自己有完整的4G,都会以为内存中就只有自己一个进程。换言之,我们当今的手机基本上都是64位了,每个进程都以为自己有16EB的内存,但实际上现在大众的设备也就在8G ~ 16G的水平。

进程地址空间本质就是抽象出来的,实质上没有开辟实质性的空间。struct mm_struct负责描述这个虚拟的空间(大小,划分等属性),页表负责映射这个空间到物理内存。这也体现出先描述、再组织的思想。更直接一点,struct mm_struct描述的是根本不存在的、概念上的空间。但我们必须要struct mm_struct来描述它,我们的所有操作都是以修改struct mm_struct描述信息来模拟修改进程地址空间,组织时需要页表来帮我们,但那部分的操作我们不需要关心。这也体现出解耦合的思想,即页表和系统管理的分离。

页表同样以指针的形式存在于struct mm_struct中,进程地址空间整体结构如下图:

这样整个进程地址空间就被我们的PCB管理起来了,它也作为进程的属性被存储了起来。之后的所有相关知识都是围绕上面的图进行深入和补充。

②mm_struct区域划分的方式

在struct mm_struct里面,存的有一个结构体struct destproom专门用来管理进程地址空间的区域划分。

每个destproom里面又有多个mem_region用于每个区域的划分,mem_region就使用线性划分将0x00000000到0xffffffff全部划分。每当程序执行到某一个语句,存在空间开辟的情况,就会来更新mem_region里面的值的信息,同时也会修改页表。

③进程地址空间的继承、写时拷贝(懒拷贝)

进程创建子进程时会从父进程拷贝一份PCB,并稍微修改(如pid、ppid等),但大部分属性都被保留了下来,进程地址空间的管理属性就是其一。当子进程被创建时,struct mm_struct,包括页表都会完整地继承下来。但这就产生了一个问题:不是说子进程会从父进程那里拷贝代码和数据吗?页表的映射都一样,难道不是共用一套代码和数据吗?

事实上,子进程会从父进程那里拷贝代码和数据是一种等效说法,我们按照这句话来处理进程的其他问题没有一点问题只不过系统希望作出一些优化,让子进程和父进程在一开始数据代码完全共享。当出现分歧的时候,系统会单独开辟一块物理空间进行拷贝,修改页表映射规则,让数据保持独立,这也叫做写时拷贝或者懒拷贝。

我们可以更深入地来了解系统是如何判断何时需要拷贝的。

当我们fork时,子进程继承父进程的虚拟内存管理方案后,它会将父子进程数据代码全部转为只读权限。父子进程的任意一个如果要对内存进行任何写入时,就会触发系统错误。触发错误的时候触发缺页中断,这个时候系统就会去做检测,判定是我们违规访问还是系统需要写时拷贝。

如果是违规访问,我们的程序就会报错,甚至直接被杀掉;如果不是,系统就会申请内存,进行写时拷贝,修改页表映射,同时将父子进程的权限恢复为fork之前的默认权限,之后数据在内存中独立,不会出现任何冲突了。这是典型的用时间换空间的做法,因此它叫懒拷贝。

当冲突时必须要两块空间,因为父进程对一个变量++,但子进程没有,如果后续两个进程都要用到这个变量,就必须要有两份不一样的数据,所以写时拷贝是最后的妥协,不会有更简省的方法了。

从上我们可以解决一个一开始我们就引出的问题,为什么不同进程打印的地址一样?

这是因为当子进程被创建时整个虚拟内存的管理方案都被原封不动的传下来了。当发生写时拷贝之后,映射规则改变,但只会改变映射的物理空间地址

这也就意味着页表map的key始终保持不变,无论我们怎么改变数值,只要父子进程执行的还是同一套代码,它们针对同一变量或函数的地址永远相同。

写时拷贝不会一次性全部拷贝,它只会拷贝相关的数据,也就是说像代码段基本上不会存在被拷贝的情况,也就意味着无论我们修改数据,实际指向的代码大概率是不会被拷贝的,就算被拷贝,只要内容不变,也不影响上述结论,同时局部拷贝也是保证写时拷贝高效性的必要处理方法。

通过上述内容,我们对进程的独立性又有了新的认识,了解到了代码数据独立性的维护方案。同时也加深对页表的认识。

④页表

页表是一种内核数据结构。除了虚拟地址和物理地址的映射map中,还进行了改良,使其在映射时会提供更多信息。map的value物理地址的表达中,是用unsigned long来表示物理地址,unsigned long中还抽了几位数约定为标志位,其中包括rwx属性、数据是否还在物理内存中的标志(isexist,用于标志进程换入换出、分批加载等)等。

当有数据要写入或访问时,系统会带着虚拟地址到页表中找对应项。找不到就说明越界访问,是野指针。如果找到了,会先到映射的value条目中检查权限等,在通过标志位的检查后页表才会提供完整的物理地址,这时我们才能进行访问或写入操作。因此操作系统一开始是看不到物理地址的,它也受页表管理。

我们可以页表解释一些代码语法现象:char* arr = "Hello, world!";为什么只读?

因为字符串常量存储位置(只读数据段)对应的页表权限标志位被设定为r。*arr  = "a"这种操作编译能通过,因为编译器不会去检查页表,这是系统应该干的事。只有代码运行起来了,当到页表中找物理地址的时候,才会被系统拦住,触发运行时错误,严重时还会直接杀掉进程。编译器的本职是检查语法等和语言相关问题,而不是让编译器干系统干的事。这样也是为了降低耦合度,让系统层面和语法层面分离,各干各的事情。类似地,强转也只是是编译层面(语法层面)的事,从系统层面写入数据的角度来看强不强转没有任何区别,写的东西没啥变化。

⑤分批加载

了解完页表的知识,我们就能聊聊分批加载的原理了。

当进程创建时,PCB首先被创建,页表随着struct mm_struct也一起被创建,创建过程中一些很关键的虚拟地址会被预加载并设置初始映射(isexist标志位为假),PCB加载完之后再加载代码数据。由于代码和数据是从硬盘中被加载的,速度就是个不可忽略的因素。如果加载的代码数据很大,有的代码和数据暂时不会调度,就没必要加载到内存中。但这个时候要怎么区分数据代码有没有被加载呢?页表就能完成这个任务。

页表的value中有一个isexist标志,标志对应代码数据是否有加载到内存中。如果没有被加载到内存,有两种情况:有可能被切出去了(切入切出操作,进程的阻塞挂起状态),也有可能还没导入内存(关键的虚拟地址)。当访问到或者马上要访问到这块内存时,就会临时去导入代码和数据,这就叫分批加载。页表做了调整,因为页表不会一次性导入全部虚拟地址,一般都是按需加载,但它会将一些关键的虚拟地址会预加载并设置初始映射,用isexist标志,以顺应分批加载的功能。只要页表的虚拟地址和物理地址对应关系准备好了,分批加载就成为了可能,这也是大型游戏能在内存中运行的原因。

但是还有个问题,页表怎么知道该预加载并设置初始映射哪些虚拟地址呢?这要在可执行程序里找答案了。

⑥struct mm_struct的初始化

管理进程地址空间的struct mm_struct是结构体,在进程被创建时,进程PCB必须初始化,struct mm_struct也必须被初始化。但要初始化struct mm_struct,最重要的就是栈区、堆区等区域大小的划分,并且不同程序有不同的划分。面对先加载PCB,再导入数据的模式,区域划分的大小怎么在数据导入前就确认的呢?

当可执行程序编译时,可执行程序会记录各个区域的大小。

readelf -S (可执行程序)可以看到可执行程序记录下来的每个区域的大小,包括数据段、代码段区域的大小属性等,这些属性在编译时编译器都会自己生成并保存到可执行程序里面。

可执行程序按特定大小分段,可以保证我们分批加载;它还有一些属性,保证了当加载PCB时,不需要读代码和数据,就可以得到一些可执行程序的属性,比如进程地址空间每个区域的划分,这些区域的权限等,这帮助我们初始化struct mm_struct

通过这一点我们也能体会到,进程地址空间在编译好时就已经有相关的信息保存了,它们的大小等属性就存在可执行程序里面。但实际上这些区域根本不存在,它只有在运行的时候被用于mm_struct初始化,通过页表动态创建。我们new出来的堆区空间本质上也不是在开辟物理空间,而只是在进程地址空间里做文章,这个空间本质是虚拟的,最终是靠页表帮我们创建的物理空间。

编译器、操作系统允许我们随便使用虚拟地址,对于编译器和系统来说,调大堆区、栈区空间就是修改数字的小事,万事还得由页表来映射。并且由于物理内存可以延迟开辟(分批加载),可以用的时候再开辟。因此只需要在页表预加载相应的虚拟地址和初始映射,标志位isexist设为假即可。实际使用的时候再申请物理空间。这能极大提高内存利用,总体而言提高了程序效率和性能。

通过可执行程序的这个属性,我们能进一步感受到系统和可执行程序的属性之间的联系同时,由于这些属性本质都是编译器实现的,背后又有编译原理支撑,我们也能证明系统和编译器、编译原理之间存在联系。

(3)进程地址空间存在的意义

对于系统进程管理而言,它只需要管好进程部分,用好虚拟内存就可以了。内存管理它也只需要好好转换虚拟地址和物理地址就行了。进程管理根本不需要知道物理空间要干什么,也不知道物理空间的真实情况,它只能看到虚拟地址空间;内存管理也是如此,它也不知道进程为什么要访问这些数据,如何管理也不知道,它只知道如何管理物理内存和如何转换。

如此一来,进程管理和内存管理在系统层面解耦合,两者各司其职。

除了进程管理和内存管理解耦合之外,进程地址空间和页表相当于在系统和硬件之间加了一层软件层面的转换。

可执行程序的代码和数据可以加载到物理内存的任意位置处(相对而言没那么严格,但也不会太跳跃地存放),但经过页表的映射在进程地址空间里又显得很规范且有序。页表映射管理使得数据的查看从无序变得有序

进程地址空间保证数据代码能被继承,同时也提出了解决独立性的方案。如命令行参数环境变量也在虚拟内存里映射,进程地址空间保证其能随着页表的继承而被继承下去。

2.虚拟地址

(1)区域的识别

进程地址空间中的区域划分不唯一,且不同的系统、编译器存在不同程度的优化,因此我们要接受理论和实践上的差别。我们先来认识常见的划分方式。我会省去一些目前不需了解的区域。

其中数据段、BSS段、代码段我们在逻辑上了解即可。我们重点关注栈区和堆区的地址变化。特别注意我们观察函数栈帧的变化时,不能直接打印函数的地址,否则就会得到代码段中的地址。

我们借助下面的代码来加深印象


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


void Fun1()
{
    int s4;//栈区变量,它的地址代表函数栈帧的地址    
    printf("%p (递减)\n", &s4);
}

void Fun2()
{
    int s5 = 0;//栈区变量,它的地址代表函数栈帧的地址    
    printf("%p -> ", &s5);
}

//全局

//BSS段
int b1;
static int b2;
//初始化数据段
int d1 = 1;
static int d2 = 1;
//只读数据段
const int r1 = 1;
//注意,const和static合用的情况(如const static int a)
//既可以按照static,也可以按const处理,处理方式不唯一,这里就不再展示


int main()
{

    int s1 = 0;
    int s2 = 0;
    int s3 = 0;
    printf("函数内栈区变量地址变化:%p -> %p -> %p (递减)\n", &s1, &s2, &s3);//函数内栈区变量地址从高到低    
    printf("函数之间的栈帧地址变化:%p -> ", &s1);//从调用函数栈帧的顺序从高地址到低地址    
    Fun2();
    Fun1();


    int* h1 = (int*)malloc(1);
    int* h2 = (int*)malloc(1);
    int* h3 = (int*)malloc(1);
    printf("堆区变量地址变化:%p -> %p -> %p(递增)\n", h1, h2, h3);//堆区从低地址到高地址    
    free(h1);
    free(h2);
    free(h3);

    //局部

    //BSS段
    int b3;
    static int b4;
    //初始化数据段
    int d3 = 1;
    static int d4 = 1;
    //只读数据段
    const int r2 = 1;

    printf("\n\n参考:\nBSS段的地址区间:%p <-> %p <-> %p <-> %p\n", &b1, &b2, &b3, &b4);
    printf("初始化数据段的地址区间:%p <-> %p <-> %p <-> %p\n", &d1, &d2, &d3, &d4);
    printf("只读数据段的地址区间:%p <-> %p <-> %p\n", &r1, &r2, "Hello");
    printf("代码段的地址区间:%p <-> %p <-> %p\n", main, Fun2, Fun1);
    //直接用打印函数地址得到的是函数在代码段中存的地址,而不是实际在栈区开辟的地址    
    //只有函数才有这个特性,其余对象表达式的代码虽然在代码段,但打印地址都是打印实际开辟空间的地址


    return 0;
}

结果是

再次强调,代码段存的是指令的地址,相当于我们代码的存放地。当我们显式获取函数地址时,获取的是函数指令本身的地址,是在代码段;而指令执行时要在栈区开辟空间,这是两个完全不同的地址。从上图也能得到验证。真正满足由高到低的是指函数指令执行时在栈区开辟的空间,所以我们只能借助函数内定义变量来间接获取函数在栈帧中的地址变化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值