前言: 本节内容就将进入linux进程里面的又一个大板块, 博主认为这个板块和PCB的板块是平级——两者独立;之前友友们可能认为进程分为PCB和代码与数据。 但是本节过后, 我们可以对进程重新定义——进程 = (PCB(linux下task_struct) + 虚拟地址空间 + 页表) + 代码与数据。
ps:本节内容和前面PCB小节是各自独立的。但是如果有前面知识的铺垫, 本节会更加好理解。 而本节最低适合已经了解进程概念的友友们进行观看。
目录
引出地址空间
空间地址划分
栈和堆增长趋势
static的本质
父子进程的相同变量不同结果——对fork的更深理解
地址空间概念
大体结构
实际工作中理解地址空间
页表
进程切换
权限
缺页中断
再次理解进程地址空间
重新理解进程
首先我们来看一下我们在学习语言的时候, 学习的内存划分结构:
我们在学习语言的时候, 认为我们调试窗口, 看到的地址就是真实内存的地址。 但是这个地址真的是真实内存的地址吗?——答案是不是, 那么为什么不是,就是本节要讨论的问题。
引出地址空间
空间地址划分
首先, 我们要抛出一个概念——地址空间——要理解这个概念, 我们这里写一个程序:
上面程序运行后, 就是打印下图这一串串的地址:
然后我们观察这些地址:
然后我们仔细观察它们的地址大小顺序, 就会发现, 我们在程序中定义的这些变量——不管是全局变量, 栈区变量、常量、代码段等等, 实际上都是遵循上面的左图的。
那么, 我们就可以信服一件事情——为什么对于堆区变量,全局变量能够一直有效, 就是因为它们在堆区或者全局变量区开辟空间。 而栈区变量在栈区开辟空间。 常量之所以不能够修改是因为常量保存在常量区。
栈和堆增长趋势
现在我们来看一下栈区和堆区的增长趋势——注意, 有些知识点博主讲到只是为了辅助理解, 和本节内容有关系, 但是不是直接相关。
如何验证栈区和堆区的地址增长趋势,我们只需要写两个下面这种程序:
针对栈区:
针对堆区:
两个程序的运行图分别是:
由上面两张运行图我们就能发现, 栈区是向低地址方向增长;堆区是向高地址方向增长。 并且由图中可以发现一个很奇怪的现象。那就是栈区和堆区的地址相差极大!——这个是运行时堆栈的内容,博主没学过, 但是和本节内容关系不大, 有兴趣的友友自行学习即可。
static的本质
、我们知道, 对于一个栈区变量来说, 如果加上static就能从栈区变量变成一个全局变量。 我们使用代码来看一下:
如下图:
从上面我们就能发现,static修饰的局部对象, 编译的时候已经编译到了全局数据区。
父子进程的相同变量不同结果——对fork的更深理解
上面是我们运行实验时的代码, 我们编译运行这串代码后, 就可以看到如下:
会隔一秒打印一次, 现在没有问题, 那么我们再来修改一下我们的代码:
运行后, 就能看到我们一个非常奇怪非常棘手的现象, 也是我们本节主要要解决的问题——进程父子进程变量相同, 结果不同:
看上图的蓝框框, 蓝框框内的内容, 明明变量名相同, 变量的地址也相同, 但是它们的结果却不相同。 ——我们学过fork, 可以解释为子进程原本和父进程用同一块数据, 但是当想要修改父进程数据的时候, 却发生了写实拷贝。 这个时候子进程的g_val和父进程的g_val不是同一份变量。 但是这里又有一个问题, 那就是我们从上图可以看到, 子进程发生写实拷贝后, 子进程的g_val和父进程的g_val的地址是一样的。 既然地址一样, 为什么变量却不是同一份呢?请问这是为什么?怎么可能同一份地址, 读取到了不同的值??
那么, 这里的地址真的是真实的物理地址吗? 如果是真实的物理地址, 就绝对不会出现上面的现象!!!所以, 这里的地址, 就绝对不是真实的物理地址!!
而这里的地址我们一般把它叫做线性地址&&虚拟地址, 而我们平时用的C/C++指针, 指着里面的地址全部不是物理地址, 而是虚拟的。
地址空间概念
大体结构
地址空间其实就是一个虚拟内存, 逻辑结构就如上图那样。 其实里面是一个个整形的数据范围。 而真正的内存是物理内存。 物理内存就是真正的内存空间。 它的物理结构就是如上图那样。
而物理内存和虚拟内存之间的练习是一个页表。 这是一个kv数据结构。 k是虚拟内存,v是物理内存。 整个结构图如下:
实际工作中理解地址空间
对于32位机器, 有32位的地址和数据总线, CPU和内存相连。 ——》我们从c语言中就学过, 每一根总线, 只有0、1概念。 一共有32根, 那么就有2 ^ 32种组合。 一共是4GB空间。
所以, 什么是地址空间? 我们的地址总线排列组合形成的地址个数(例如下图(0, 2^32))就叫做地址空间。
而现在我们从实际工作的角度上理解地址空间——这里有一个小故事
假如这里有两个小朋友——一个小胖, 一个小花。 这两个人是同桌。 下面是他们的桌子:
他们的桌子是100cm。 也就是说, 他们的地址空间是0~100. 但是呢, 小花很讨厌小胖, 所以呢, 小花就在桌子上面画了一根三八线。 结果, 小胖这个人不长记性, 总是越过三八线, 就导致小花总是胖揍小胖一顿。 ——现在, 请问,小花画三八线的本质是什么? ——本质就是区域划分!!!
我们可以使用计算机语言, 那么就是说, 小花和小胖画好三八线之后, 就要管理好自己的地址空间。 而我们知道, 管理的本质就是——先描述, 再组织。 所以据需要一个数据类型来描述这个地址空间, 供给两人进行管理。 而今天, 这个数据类型博主就告诉友友们大体如何定义:
struct area{ int start; int end;}struct dest_ares{ struct area xiaopang; struct area xiaohua;}
也可以:
struct dest_area{ int start_xiaopang; int end_xiaopang; int start_xiaopang; int end_xiaohua;}
那么, 现在小花和小胖可以管理自己的空间了。 但是呢, 有一天, 小胖又越界了, 小花呢,这一天心情不太好。 所以小花看到小胖越界就很愤怒, 她不仅将小胖打了一顿, 而且将三八线向小胖那边挪动了一下:
而对应他们的结构体, 是不是就是小胖管理的end_xiaopang -= 10, 小花管理的start_xiaohua += 10;
所以, 所谓的区域的调整变大或者变小, 本质上就是对这些struct结构体管理的end 还有start变大或者变小。
小胖是一个有强迫症的人, 这个桌子对于小胖来说,假如这个桌子的范围是0 ~ 40。又因为是他自己管理, 所以他就将它的铅笔放到了3厘米处, 将他的本子放到了10厘米处。 那么等到别人要借用他的本子或者笔的时候, 他就可以直接使用坐标3, 或者10.告诉别人本子或者笔在哪。 那么, 这个过程中, 我们不仅仅要看到给小胖划分了多少空间, 而且要看到在范围内连续的空间中, 每一个单位都有一个地址, 这个地址可以被小胖随意的使用!!!——即可以访问地址空间内的任意地址处。
所以, 什么是地址空间呢? 所谓的地址空间, 本质上就是一个描述进程可视范围的大小!我们地址空间内一定要存在各种区域划分, 对线性地址进行start和end即可。
由上面我们已经知道, 操作系统在创建进程的时候, 不仅要创建进程的PCB结构体, 而且要创建进程的地址空间。 而这个地址空间, 本质上就是一个数据结构对象。 类似于PCB一样, 地址空间也要被操作系统管理——先描述, 再组织。
而如何描述呢? 其实和小胖小花管理自己桌子是一样的, 就是使用一个有着start, end的结构体, 如下图:
理解地址空间这点是不够的, 但是我们先理解一下页表。 再回来更深一步理解地址空间。
页表
进程切换
、我们知道, 我们的程序, 只要创建了结构体, 并且开辟了mm_struct——这个mm_struct里面是一个个int类型的范围, 对应着物理内存的内存区域。——那么这个cpu中此时该进程一定处于被调度的状态。 而且我们知道, 为了让我们的进程的地址实现虚拟地址到物理地址的转化, 进程同时也维护了一个页表结构。
而我们如何找到这个页表? ——原因就是在cpu内部维护了一个寄存器, 这个寄存器叫做cr3寄存器。如下图:
这个寄存器会保存当前进程页表的起始地址。 ——既然这个地址不存在于内存中, 那么进程的切换, 下次cpu还会记得这个页表吗? 答案是会的, 为什么? 因为这个cpu中页表的起始地址对于进程来说, 本质上他也是这个进程的硬件上下文。 我们知道, 对于一个进程的上下文来说, 当进程切换的时候, 它会被进程带走!当下次再次运行, 这些上下文数据又会被放到cpu中进行执行。 所以,我们进程从始至终都会在自己进行执行的时候, 将自己需要的数据放到cpu中, 不会影响自己的执行。
权限
假入页表中有一组kv。 如下图:
那么我们就会发现, 最后一个格子是干什么的呢? 那么我们思考一下, 对于一个变量来说, 我们不同的身份会有不同的权限, 那么我们怎么知道, 我们对于某一个变量是可读还是可写的? 答案就在这个页表之中。
上面的0x1234处的地址如果是变量区, 那么这个变量的权限就是可读可写。 但是如果此时又来一串代码。这串代码在虚拟内存的0x123处。 他映射到了物理内存的0x112233处, 并且我们知道, 代码是可读但是不可写的。 那么对于这串代码来说, 他的操作权限就是r。
此时的页表就是如下:
当我们要访问这个代码, 并且对于这个代码进行修改的时候, 页表就会发现代码只有读, 没有写。 那么它就会拦截这个进程的操作。 并且操作系统会直接干掉这个进程。 ——这, 就是页表的权限的管理。
现在,我们就可以解释下面这个现象了:
看下面这串代码:
这串代码运行后显示之后会直接挂掉:
在没有学习这一节之前, 我们对于这个的理解可能就是——因为代码段只读, 修改代码程序直接挂掉。
但是那是语言层面的理解, 我们只要深究就会发现, 既然代码只读, 那么它是如何加载到内存中的呢? 现在我们利用新的知识, 对于这个东西就有了新的立即——代码段本身并没有什么可读可写的概念, 只是对于物理内存来说, 只要是数据, 它就会来者不拒, 将这些数据放到相应的内存区。 而之所以数据会有所谓的只读, 只写或者可读可写。 本质上是因为页表。 页表会根据数据的类型规定相应的权限, 只要越权,那么就会拦截, 操作系统进而会干掉进程。
缺页中断
这里思考一个问题, 就是我们平时玩一个很大型的游戏, 这个游戏可能一百G, 那么这么大的游戏, 显然不可能全部加载到内存。 这就说明了什么呢? ——说明操作系统对于大文件, 可以实现分批加载。
在我们的操作系统中, 我们的操作系统当中可以实现分批加载。比如说我们加载了500M的空间, 但是代码是一行一行跑的, 短期内代码跑不完。而加载的这部分数据是不会被释放的。 这就造成了空间上的浪费。 所以实际上, 操作系统对于内存的加载是采用惰性加载的方式。 ——也就是说虽然承诺加载了500M, 但是实际上可能只分配了几十kb。
当我们查地址的时候, 就要先看一下标志位, 如果标志位显示加载到内存中。 那么就是可以找到物理地址, 并且进行访问。 但是如果没有显示加载到内存, 那么就是缺页中断。
如果触发缺页中断, 就说明我们需要访问该处内存, 却没有数据加载到物理内存, 这个时候操作系统就会重新加载物理内存, 将数据重新加载到物理内存中, 然后将这个内存映射到页表之中。 这个时候进程就可以访问物理内存中的数据。
那么, 这里我们就可以重新理解下写时拷贝, 写时拷贝其实也是一个缺页中断的过程。
那么, 如果创建进程的时候, 只创建PCB, 进程地址空间, 页表, 但是不加载物理空间, 可以吗?
进程创建的时候, 实现创建内核数据结构呢? 还是先加载对应的可以执行程序呢?
现在我们来思考一个问题, 对于进程来说, 当进程加载到内存或者进程切换的时候, 就要将数据加载到物理内存, 并且物理内存还要映射到当前页表当中。 那么请问这个过程中, 进程需要管理吗? 答案是不需要, 对于进程来说, 进程不需要关心物理内存如何缓存数据, 页表如何被映射物理内存, 事实上, 她也不会去关心。 进程只需要管理好自己的PCB, 管理好自己的进程地址空间和页表的映射即可, 那么这个就形成了两个板块。
上面这两个板块, 一个是内存管理, 一个是进程管理。 对于进程来说, 进程只需要管理自己的地址空间, 地址空间去访问页表。 如果页表中没有映射value, 那么页表发生缺页中断, 就会自动区物理内存中缓存数据并且形成映射。 那么如何申请等等, 就是内存管理事情。 进程不需要关心。 所以虚拟地址空间的存在, 他把进程管理和内存管理实现了软件层面的解耦。
再次理解进程地址空间
学完页表, 我们就可以知道了一个问题的部分答案——为什么要有虚拟内存?因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
现在我们通过一个故事, 来重新理解一下进程地址空间:
就是说假如有一个大富翁, 这个大富翁有三个私生子, 这个大富翁有10亿家产。如下图:
这些私生子, 很显然并不知道大富翁有其他的孩子存在, 以为大富翁只有他们自己一个孩子。
有一天, 大富翁找到私生子1, 说以后自己的10亿家产是他的。
又有一天大富翁找到私生子2, 说以后自己的10亿家产是他的。
同样的一天, 大富翁找到私生子3, 说以后自己的10亿家产是他的。
现在, 这三个私生子都坚信着, 大富翁去世后,他的家产就是自己的了。 一天,私生子1找大富翁借1亿, 大富翁骂了他一顿, 没有借。 还有那么一天,私生子2, 或者3都借了1亿。 大富翁没有接。 请问这个时候, 这些私生子是不是相信大富翁的10亿家产是自己的? 肯定是相信的, 因为大富翁有10亿, 而他们是大富翁的孩子, 所以他们相信大富翁的家产未来是自己的。 而实际上呢? 可能吗——不可能, 因为大富翁本质上有3个孩子, 这些人顶多分3.3亿。
那么操作系统对于一个进程呢?大富翁其实就是这里的操作系统, 进程呢? 就是私生子。 每一个进程都被操作系统画了一张大饼, 这个大饼就是——进程地址空间。
这个进程地址空间就是用来标识操作系统所有内存的空间范围。 每一个进程都有一个虚拟地址空间, 但是要给进程可以全部占用物理内存吗? 不可能。
那么每一个进程都有一个虚拟地址空间后, 就能使用一样的视角——虚拟地址空间——来看待物理内存了。 进程1可以使用虚拟内存中的0x111, 进程1的子进程同样可以使用虚拟内存中的0x111——虚拟内存一样, 但是映射到物理内存就是不一样的内存。
所以, 我们就知道了为什么要有虚拟内存的第二点——有统一的视角。
同样的, 对于进程来说, 如果没有虚拟地址空间, 进程如果发生了非法访问, 那么自己的进程被操作系统干掉是小事。 而如果非法访问影响到了其他程序呢? 这个时候问题就大了,但是如果加上虚拟地址空间呢? 就能在物理内存和进程之间加一个缓冲, 如果进程发生了非法访问。 那么虚拟地址空间内有这个地址, 也就没有映射到页表当中, 没有映射到页表当中,页表就会对进程进行拦截, 然后操作系统就会直接干掉这个进程, 防止发生危险。 ——这就是为什么要有虚拟地址空间的第三个原因:因为增加虚拟地址空间, 可以在访问内存的时候,增加一个转化的过程。 在这个转化的过程中可以进行系统级别的检查, 所以一旦异常访问, 直接拦截, 该请求就不会直接到达物理内存, 保护物理内存。
重新理解进程
我们重新理解后的进程可以是: 进程 = 内核数据结构(task_strruct + mm_struct + 页表) + 进程的代码和数据。
而在进程切换的时候, 只要我们的进程一切换, 那么进程的PCB就切换了, PCB一切换, 因为PCB指向进程的地址空暗金, 所以PCB一切换, 地址空间就切换了。 而我们的cr3寄存器存在于进程的上下文中, 所以进程一切换, cr3也切换, 也就是页表发生切换。 所以进程整个就切换了。
-----------------------------以上就是本节全部内容, 下面是博主整理的本节笔记: