Linux 系统编程 进程篇(五)
文章目录Linux 系统编程 进程篇五1 进程地址空间1.1 进程地址空间1.2 理解进程地址空间1.3 进程地址空间怎么办1.4 进程地址空间为什么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 里面有指针指向这个它自己的程序地址空间。那么我们是怎么使用这个程序地址空间的呢一般的定义变量从栈上申请动态开辟从堆上开辟这个具体怎么弄我们先不管。我们知道这个代码段和数据段存着我们程序的代码和数据但是不同从程序代码和数据肯定是不同的。我们现在写一个小练习也才多少kbmb人家要是写个 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。拷贝之后这个页表对应的地址权限就被设置成只读了如左图父子都是。当子进程要修改的时候但是这个页表是只读的这个时候就会报错然后触发写实拷贝页表先变成可读可写的然后物理内存再开辟一个地方拷贝变量的值修改页表的值然后修改变量的值。这样就形成了写实拷贝可以看到写实拷贝其实就是由一个权限错误来触发的。这是一个特点当然这个内部会做判断因为查页表的时候会出现很多错误嘛。那么为什么要选择写实拷贝如果我们在创建子进程的时候把父进程的代码和数据原封不动内存的拷一份创建子进程的时候就会慢还有父进程里面不是所有变量都需要改也不用全部内存里拷一份。先变成可读可写的然后物理内存再开辟一个地方拷贝变量的值修改页表的值然后修改变量的值。这样就形成了写实拷贝可以看到写实拷贝其实就是由一个权限错误来触发的。这是一个特点当然这个内部会做判断因为查页表的时候会出现很多错误嘛。那么为什么要选择写实拷贝如果我们在创建子进程的时候把父进程的代码和数据原封不动内存的拷一份创建子进程的时候就会慢还有父进程里面不是所有变量都需要改也不用全部内存里拷一份。所有写实拷贝是最精细化的内存控制。 第一个减少创建时间第二个减少内存浪费。