文章目录
- [Linux 系统编程 进程篇(五)](#Linux 系统编程 进程篇(五))
-
- [1 进程地址空间](#1 进程地址空间)
-
- [1.1 进程地址空间](#1.1 进程地址空间)
- [1.2 理解进程地址空间](#1.2 理解进程地址空间)
- [1.3 进程地址空间怎么办](#1.3 进程地址空间怎么办)
- [1.4 进程地址空间为什么](#1.4 进程地址空间为什么)
- [1.5 补充细节](#1.5 补充细节)
Linux 系统编程 进程篇(五)
1 进程地址空间
1.1 进程地址空间
在上一篇的结尾,我们做了一个小实验,子进程去修改父进程里面的一个变量,而父进程不修改。我们发现,这个变量的地是一样的,但是值却是不一样的。
由此,我们可以得出结论,我们取地址取到的地址绝对不是物理地址,而是虚拟地址,C/C++程序里面,我们对地址操作,取到的地址全是虚拟地址,不是物理地址。 物理地址,用户是不能直接看见和操作的。
既然存在这样一个关系,那么就必然存在着一个从虚拟地址到物理地址的一个映射,谁来完成这个映射,操作系统。
我们之前也提到过,我们之前一直说的程序地址空间,其实真正的名字是进程地址空间,或者虚拟地址空间,不是语言层面的概念,而是系统层面的概念。
还是经典老三问,是什么,为什么,和怎么做。
基于我们之前的理解,我们先从怎么做开始谈,这里我们要引入一个叫页表的概念。
页表是什么呢?页表就是专门用来做虚拟和物理地址映射的

比如说,我们在修改一个变量假设叫 val 的值的时候,这个变量的虚拟地址,在 0x11111 处, 页表的左边放的就是这个变量的虚拟地址,右边放的就是这个变量的物理地址,假设叫0x112233,当我们对虚拟地址操作的时候,页表就会映射到对对应的物理地址的操作。 这个映射的过程其实要通过硬件,现阶段我们理解为操作系统来操作也行。
这里我们复习一下以前的关于程序地址空间,也就是进程地址空间相关的知识。首先,我们一般书上,或者拿来做例子的这个进程地址空间的图,都是32位下的,一共是 4G, 内核占 1G, 用户区 3G。 64 位下因为这个进程地址空间太大了,不好做演示。32 位机器下一共有 2^32 个地址,一个地址是一个字节, 64 位下就有 2^64 个地址,和这个地址总线有关嘛,不过多赘述。
我们知道一个整形占四个字节,也就是四个地址,取地址的时候只能取一个,取到的是最低那个位置的地址。
因为这个页表的作用是虚拟内存到物理内存的映射,所以,每一个虚拟地址空间都要搭配一个页表,当然这个页表通过虚拟地址空间也能找到。关于页表的详细知识后续再谈,这里先知道有这一个作用就好。
所以,我们之前提到的父子进程里面同一个变量,取到的地址相同,但是值不同是怎么实现的呢?
我们之前提到过,进程具有独立性,即,内核数据结构独立,加载到内存里面的代码和数据独立。在我们的例子里面,父进程和子进程共用代码和数据,而父进程的代码和数据是存在父进程程序地址空间里面的数据区和代码段的,
所以,子进程和父进程共用,其实是把父进程的程序地址空间又一模一样地拷贝了一份,包括页表。
当子进程想要对数据进行修改的时候,会发生什么? 写实拷贝。 由于,子进程和父进程程序地址空间一模一样的,页表也一模一样的,子进程想要修改的变量,假设还是 g_val ,要被修改,操作系统去查页表,映射到物理地址,但是这里已经有值了,所以,操作系统会在内存里面再开辟一块空间,拷贝一份 g_val 然后再做修改,最后,修改一下页表的映射关系就好,

这样,也就完成了之前看起来很怪的地址相同但是值不同的效果,本质上其实就是同一个虚拟地址映射到了不同的物理地址。这也是写实拷贝的原理,当然写实拷贝还有一些细节,后面详细说。
1.2 理解进程地址空间
之前说了那么多,那么这个进程地址空间到底是什么呢?
我们举个例子,比如说,有一个大富翁,有十亿刀乐。大富翁这个精力充沛,有四个私生子,私生子之间相互不知道,都以为大富翁就他一个孩子。

这四个私生子呢,平时也没什么工作,就知道找大富翁要钱,老爹的就是我的,我找老爹要钱老爹还能不给我吗?所以,私生子们,就认为自己也有 10 亿刀乐。大富翁呢,为了稳住这些私生子,就给他们画大饼,让他们每个人都真的以为自己有 10 亿刀乐。
其实到这里,已经初见端倪了,大富翁呢,就是这个物理内存, 私生子就是进程, 虚拟地址空间就是大富翁给私生子画的大饼。 所以,虚拟地址空间的存在,让每一个进程都认为自己有 4GB 的物理内存,或者说,让每一个进程都以为自己独占物理内存。

那么问题又来了,假如某天,私生子 1 和大富翁说,我想要个跑车,大富翁答应了。私生子2 和大富翁说,我想要个手表,大富翁也答应了。结果,过了几天,私生子 1 找大富翁问跑车,大富翁记迷糊了,问他,你要的不是手表吗?
这不就露馅了吗? 所以,大富翁除了要管理私生子,这个很好理解,也要管理给每个私生子画的大饼。
换句话说,虚拟地址空间,也是要被管理起来的。怎么管理? 先描述,在组织 !
所以,在代码实现角度,进程地址空间,本质就是一个结构体。被管理成一个数据结构。在 linux 里面, 描述进程地址空间的 结构体就叫 mm_struct 。 mm_struct 里面有指针,把所以 mm_struct 穿起来,形成一个链表或者红黑树管理起来。 和 task_struct 一个道理。
之前提到过,每一个进程要有一个进程地址空间,是物理内存画的大饼,所以 ,在描述进程的结构体,也就是 tasK_struct 里面,一定有一个指向这个进程 mm_struct 的指针。
我们就可以完善一下之前的图:

1.3 进程地址空间怎么办
有了上述理论的铺垫,我们就可以更进一步地了解如何实现进程地址空间了。思考一个问题,既然描述进程地址空间的结构体是 mm_struct, 那么 mm_struct 里面都有什么呢?
回答这个问题之前呢,我们得先来聊聊什么叫区域划分?
举个例子,比如说,这个之前上小学的时候,和小女生同桌。同桌呢就把桌子中间分开,说,你不能越过这条线,这个三八线,估计大家可能都挺熟的。所以,从桌子最左边到这个三八线,是我们的,从三八线到桌子的最右边是这个同桌的。
这就是一种区域划分。于是,我们可以观察到,我们并不在意我们的区域是如何使用的,我们只需要知道开头和结尾就可以区域划分,这一点非常重要。
所以,桌子这个结构体,就可以写成这样:(用计算机量化一下)


只需要开头和结尾。那调整区域呢? 比如我们老是越界,小女孩忍无可忍了,直接把这个桌子三七分了,我们三,原来还是五五分的来着。 这就是一种调整区域。所以,可以看出,调整区域也是只用调整区域的开头和结尾即可。

所以,我们可以类比一下。我们之前说的程序地址空间里面分为什么栈区,堆区,静态区等等,又是用这个 mm_struct 来描述的。如果我们把这个桌子看成是程序地址空间,那 mm_struct 里面应该存的就是各个区域的开始和终止位置。

事实也确实是这样的,linux源码里面就是这样只存了各个区域的开始和结束。
但是,问题还没有结束啊,我们平时使用的时候可不是这样只用开始和结尾的。比方说,还是这个桌子,我们给这个桌子画上刻度,一共一百厘米,一厘米一厘米地来,然后第一根笔放在一厘米的地方,第二根笔放在两厘米的地方。
这个给桌子画上刻度的过程,我们就叫给桌子编址。同理,给进程地址空间 一共是 2^32 ,分成一个一个字节一个字节,每个字节都有地址,这个就叫做给地址空间编址。
我们在linux源码里面见一下:

可以看到,我们的结论确实是正确的。
有了上面的认识,所以,我们知道

这个图里面的这个东西其实就是一个mm_struct。 task_struct 里面有指针指向这个它自己的程序地址空间。

那么我们是怎么使用这个程序地址空间的呢?一般的定义变量从栈上申请,动态开辟从堆上开辟,这个具体怎么弄我们先不管。
我们知道,这个代码段和数据段存着我们程序的代码和数据,但是,不同从程序代码和数据肯定是不同的。
我们现在写一个小练习也才多少kb,mb,人家要是写个 Genshinimpact 说不定光代码就两个G。 那代码段就两个 G 吗?肯定不是。

当我们运行程序的时候,我们的磁盘里面的代码和数据加载到内存,肯定要在内存里面开辟对应大小的空间。先去找虚拟地址。
所以,我们程序在加载的时候,要现在进程地址空间申请指定大小的空间,然后加载程序,申请物理空间,最后通过页表进行映射。所以,也就是把这个物理地址转化为了虚拟地址。
虚拟地址就提供给上层使用,当访问虚拟地址的时候,查表映射肯定能找到物理地址。
那么怎么申请空间呢?现阶段,我们就理解是要调整区域,修改开始和结尾。
由于 mm_struct 是个结构体,也要开辟空间,也要初始化。初始化的值从哪里来 ? 其实是加载到内存的时候进行出事后,根据物理地址需要多少,来初始化虚拟地址空间的大小。这个,栈和堆动态开辟的,我们可以认为一开始是 0 ,然后慢慢再往上加。 比方说有栈顶栈底指针。 这个话题比较多,后期再细说。
1.4 进程地址空间为什么
为什么有进程地址空间?首先第一个,我们思考一些,进程地址空间里面的地址是连续的吧,那么我们的程序加载到内存里面不管怎么加载,只要有这个映射关系,我们通过虚拟地址访问都是有序的,所以,第一个好处,就是把地址从无序变成有序。
第二个是什么呢?我们举个例子,比如说找家长要零花钱,家长说要什么零花钱,你要买东西的时候就和我说,我给你钱去买。但是,比方说你想买一个辣条了,和家长说家长又说不卫生,不让买。
其实,页表里面不只有虚拟地址和物理地址的映射,还包括每一个地址处的权限,使用这种地址的映射关系可以对地址和操作的合法化进行判定,进而保护物理内存。
举个例子,野指针,有些野指针会让程序崩溃,比方说有一个地方已经释放掉了,我们再去访问,查页表的时候发现虚拟地址还在,物理地址没有了,是不是就程序终止了。 这个话题还牵扯到别的知识,后面再说。
再比方说像我要修改一个只读字符串,这个字符串是只读的,编译的时候可以编过,顶多编译器报个警告。但是代码段的权限是只读的,从哪里体现,页表体现。查页表的时候发现要访问的地址是只读的,就会直接报错,权限拦截了。
还有一个,我们之前提到过,我们的自己些的程序如果代码段和数据段非常多,会直接全部加载到内存里面来吗?其实不会的。其实会只加载一部分,在页表里面只映射一部分,等这部分运行完了,下一部分运行的时候,发现虚拟地址有,物理地址没有,然后操作系统就会把新的代码和数据加载进来。这个过程是自动的,叫缺页中断。
其实,这个页表里面还有表示这段代码和数据在不在内存里面的一个标志。
有了这个操作我们发现,程序缺页中断加载到内存里面,是操作系统里面的内存管理部分,而虚拟地址空间是操作系统的进程管理部分,有了这个映射关系之后,我们就可以让这个进程管理和内存管理, 进行一定程度的解耦合。
然后,我们再来澄清一些问题,
因为这个解耦合,所以,我们可以先不加载代码和数据,只有 task_struct 和 mm_struct。
创建进程时,先加载 task_struct 和 mm_struct ,然后再加载代码和数据。
那么,如何理解进程挂起呢?这里我们先谈阻塞挂起,
当这个内存严重不足的时候,一个阻塞的进程,操作系统首先,把页表清空,然后把代码和数据,唤出到磁盘里面的分区。等到需要的时候再唤入,需要多少唤入多少。
1.5 补充细节
上面的内容讲完,程序地址空间就讲了差不多五分之四了。但是,还有一个问题,我们在使用堆区的时候,好像,不止一个起始地址吧。比方说,我们在 malloc 很多次,起始地点应该有很多才对。
所以,只有一个堆区整个的开始和地址是不够的吧?是的。
打开 linux内核源码里面的 mm_struct 的结构体,会发现,里面有这样一张表:

有一个指向 vm_area_struct 的一张表的指针。 那么这个 vm_area_struct 是什么呢?
转到定义,我们看到:

里面也有一个起始地址和一个终止地址。答案已经呼之欲出了, vm_area_struct 就是来解决我们之前说的堆是一段一段的问题的。每一段,都有一个 vm_area_struct 来描述,然后放到一张表里面,交给这个进程的 mm_struct 来管理。这样,我们使用 mm_struct 遍历整个表,需要那一段用哪一段。注意,这个 vm_area_struct 如果很多的话,也可以放到红黑树里面, vm_area_struct 就是节点,存着指向下一个节点的指针。

画图表示:

好的,下一个问题,为什么要写实拷贝?写实拷贝怎么实现的。

首先,创建子进程的时候,子进程会拷贝一份父进程的,程序地址空间,页表,PCB,然后改改自己的PCB。拷贝之后,这个页表对应的地址权限就被设置成只读了,如左图,父子都是。
当子进程要修改的时候,但是,这个页表是只读的,这个时候就会报错,然后触发写实拷贝,页表先变成可读可写的,然后物理内存再开辟一个地方,拷贝变量的值,修改页表的值,然后修改变量的值。
这样就形成了写实拷贝,可以看到,写实拷贝其实就是由一个权限错误来触发的。这是一个特点,当然,这个内部会做判断,因为查页表的时候会出现很多错误嘛。
那么为什么要选择写实拷贝,如果我们在创建子进程的时候,把父进程的代码和数据原封不动内存的拷一份,创建子进程的时候就会慢,还有,父进程里面不是所有变量都需要改,也不用全部内存里拷一份。
先变成可读可写的,然后物理内存再开辟一个地方,拷贝变量的值,修改页表的值,然后修改变量的值。
这样就形成了写实拷贝,可以看到,写实拷贝其实就是由一个权限错误来触发的。这是一个特点,当然,这个内部会做判断,因为查页表的时候会出现很多错误嘛。
那么为什么要选择写实拷贝,如果我们在创建子进程的时候,把父进程的代码和数据原封不动内存的拷一份,创建子进程的时候就会慢,还有,父进程里面不是所有变量都需要改,也不用全部内存里拷一份。
所有,写实拷贝是最精细化的内存控制。 第一个,减少创建时间,第二个减少内存浪费。