三、分页 & 虚拟地址空间
经过上面的学习,我们明白了【区域划分】的意义所在,但是光划分出来区域是不够的,还需要有数据在里面存在,那我们必须明白一点:数据和代码真正只能在内存中!
1、页表的概念
- 上面我们有谈到【虚拟地址】和【物理地址】,但是对它们之间的联系还是不太清楚,现在我们再通过引入 ==页表== 这个概念来进一步理解一下:
- 可以看到对于
task_struct
来说它是指向一个内存中的地址空间,我们的有一块在这个地址空间中的 虚拟地址,但是呢我们的目的不是找到这个地址,而是要拿到这块地址中的内容,那么在Linux中呢,就会使用到 ==页表== 这个东西来做一个【映射】的操作,经过页表的转化变为物理内存之后,继而得到这个内存中的地址,确定清楚地址后内容也就读出来了,放到CPU里就可以被操作执行了
💬 那对于上面这个呢,就是 ==页表== 的基本模型了,它所呈现出来的是一个KV的结构,类似于我们在C++中所学习过的《map》,一个 key 值对应一个 value 值
2、疑难解答:为何父子进程没有发生同步修改?
那有了页面这个概念之后呢,我们就可以通过其来解释一下我们在前面所提出的问题了:父子进程访问的是同一个地址,那为何子进程在修改了
g_val
后父进程并没有发生同步改变呢?
- 这里的话我们就需要通过父子两个进程来进一同观察了。通过下图我们可以看出,子进程按照着父进程拷贝了一份代码和数据,并且连虚拟地址也是一样的,那么通过页表的映射之后,它们便指向了物理内存中的同一块空间
💬 那为何子进程在修改完数据之后父进程也跟着一同修改了呢?
- 还记得我们在 进程基本概念 的时候所说到过的【写时拷贝】这个 概念吗,因为进程在运行的时候是具有独立性的,所以为了不引起 ==并发修改异常==,当子进程需要对父进程中的数据做修改时,就会在系统的某一个位置开辟一段空间,将父进程中的数据拷贝到此处,在此处去做一个修改,然后再去改变一下页表的映射值,此时就不会对原先父进程中的数据产生影响
- 在物理内容中申请完空间之后,并且修改的是当前进程所对应的 value值【物理地址】,不会影响到 key值【虚拟地址】,所以即使两个进程所访问的地址是同一块,所看到的也是不同的两个值
💬 解答:为何在fork之后父子进程得到的值不一样?
- fork在返回的时候,父子都有了,return两次。id是不是
pid_t
类型的变量呢?返回的本质就是写入!谁先返回,谁就让OS发生写时拷贝 - 所以父进程读到的就是子进程的pid,而给子进程返回的就是0
3、无进程地址空间的危害
上面我们谈及的是【页表】,但其实【进程地址空间】也同样得重要,我们再来看看这块
💬 如果没有地址空间,我们的OS是如何工作的
- 如果没有虚拟地址空间的话,我们平常写代码用到的地址全都是 物理地址,那么若有些进程在访问地址的时候发生了越界,便可通过指针的方式修改这个地址中的数据,那就影响了其他进程的正常运行
- 例如这里有个用于【客户端登录】的进程,就需要让用户输账号和密码,所以有的人在通过某种手段故意越界访问别的地址,就可以获取到别的用户的账号和密码,这就导致了安全问题
⭐ 但是当我们有了【页表】和【虚拟地址空间】之后,任何一个被CPU读进来的数据进行访问的时候,不是直接去访问这个物理内存了,而是需要通过 虚拟地址结合页表 进行映射才能访问到物理内存 ⭐ 只要我们需要映射的话,就可以决定你是否能成功进行映射,比方说当前的这个进程通过页表映射去访问了一个错误的位置,那么就会被操作系统给检测出来
4、页表的意义所在💡
明白了进程地址空间的重要性之后呢,页表也同样得重要,接下去就让我们来学习一下 页表的意义所在
① 谨防滥操,保护物理内存
- 还是一样,我会通过一个案例来进行引出:到了春节我们都要走亲访友,去给长辈拜年的时候难免会收到一些压岁钱,现在可不像从前只是一、两百得给,而是几千几千得给了,那么此时我们的手上就会有一大笔钱了💴,于是就可能会拿着这笔钱去买一些自己喜欢的东西。
- 那这时呢店家看你是小孩子就会狠狠地敲诈你一笔,说:小朋友,你看你现在已经XX年级了,比你高一年级的同学都要买这个书籍的,看看你是不是也有必要买一本呢😁
- 那你的这种行为被你妈妈知道之后呢,就开始对你进行『制裁』,开始帮你去 “保管” 手上的这笔前,那妈妈这么做也是有道理的:核心工作是为了拦住你,不让你乱花钱
💬 那这对应到我们所说的【页表映射机制】其实也是同样的道理
- 进程地址空间想要访问物理内存需要先经过 ==页表映射==,但页表映射的时候不合理时就会拦截你的映射,操作系统识别到就会不让你访问物理内存。
光是感知层面的事,我们再来看看在代码层面该如何去进行理解:
- 还记得我们在C语言中所学习过的
常量字符串
吗,那学习了 C/C++内存分布 之后我们明白了这些字符串都是存放在进程地址空间中【常量区】,且都是不可修改的,因此*str = 'H'
就属于 非法操作 了 - 于是此刻 ==页表== 的功能就显现出来了,当我们通过代码去做这样非法的操作时,就会在页表映射的时候发生被OS检测到,从而告知用户此操作是非法的,
char* str = "hello world"; *str = 'H';
- 所以在CPU操控进程访问内存的时候,中间相当于加了一层转化的过程,这转化的过程就是由OS帮我们去转的,它相当于在进程和内存之间呢加了一层 软件层,你想做转换的话如果合法的话我就
load
把你加载进去,但如果你并不合法的话我就就会拦截你,这就叫做 保护物理内存与其他进程
所以在学习了【进程地址空间】之后,我们之前在C语言中所学习的一些知识就可以进一步作加深和理解了,这些边界性的知识在我们学习了《操作系统》这门课后就可以更加地融汇贯通了
⇒ 因此我们可以得出页表的第一个意义所在:防止地址随意访问,保护物理内存与其他进程
当然除此之外页表还存在着其他的意义,不知大家对C语言中
malloc
是否还有印象?
💬 那首先我想问一个问题:当我们使用malloc
去申请内存的时候,操作系统立马给你,还是 需要的时候 给你呢?
- 可能我们平常不会去关注这个点,因此在调用了
malloc()
之后执行了程序后,感觉操作系统立马就将内存空间分配给我们了,但其实呢并不是这样的 - 我们在上面通过这幅图已经理清了内部的访问机制,那我们在使用
malloc
申请内存的过程其实也类似:当一个申请内存的进程访问时,就在物理内存中为其申请一块空间,然后通过也页表映射,建立起【物理地址】和【虚拟地址】之间的关系,然后从虚拟地址映射出来堆空间的起始地址返回给到这个进程,此时我们就通过代码申请到了这个空间的地址
理清了内部的这个流程之后呢,我们更要清楚的一点是:这块内存虽然会给到你,但并不是立即给你的,因为在操作系统内部一般有着这么几点共识:
- OS一般不允许任何的浪费或者不高效
- 申请内存不一定立马使用
- 在你申请成功之后,和你使用之前,就会存在一段小小的时间窗口,这个空间就没有被正常使用,但是别人用不了,因此我们将其称作为【闲置状态】
- 于是呢操作系统内部就产生了这么一个机制,当有进程想要在物理内存中申请一块空间的时候,页表会先为虚拟地址先建立映射,相当于是 做个标记🔰,代表你需要申请内存空间,但是呢先不设置
value值
即相对应的不在内存中申请空间 - 说得再通俗一点:就是当OS在识别到有下面这段代码的时候,知道了你将会有申请内存空间的这个需求,但是呢先不给你申请,知道你将所有的代码写完之后,将程序给运行起来了,此时当进程执行到这句代码的时候,就会通过一开始在页表中建立的映射关系,对应地在物理内存中也申请一块空间并返回起始地址,将两个地址通过页表再关联起来✌
int* a = (int*)malloc(sizeof(int) * 10);
💬 那有同学此时就要问了:这样子确实蛮好的,在执行的再去申请具体的空间,但感觉意义也不是特别得大╮(╯▽╰)╭
- 同学,这你就不懂了吧,其实它内部还蕴含着很重大的意义呢,再往深层次一面去想:因为有着 ==页表映射== 的存在,所以进程在执行的时候完全不需要关系操作系统为其在物理内存中所分配的是哪一块空间,只需要去执行当前进程中的那个代码块即可
- 那由这个页表为中心,我们可以很好地将【地址空间】和【物理内存】分为两块来进行看待,左侧为
进程管理
,右侧呢即为内存管理
,如果有学习过《操作系统》这门课的同学就可以清楚这两种管理 - 因为有了 ==页表映射机制== 所以作为一个进程来讲,它永远知道自己虚拟地址的这个范围,但具体在哪个物理内存当中它并不关心,对于进程的代码和数据可以放在任意的位置,只要最终能找到就可以。因此这就使得
进程管理
和内存管理
进行了一个 解耦合 操作,二者既有关联、又互不影响
举个很简单易懂的例子:
🎯 比方说你这个月向你爸要生活费,然后你爸给你大了1000块,但是呢你并不需要关心这笔钱是从哪里来的,是你爸工作赚来的、或者是打零工赚来的,这是你爸应该关心的事,要怎么给你去弄到这一千块钱的生活费,而你要关心的则是如何拿到这笔钱并且怎么合理地使用它
⇒ 因此我们可以得出页表的第二个意义所在:将进程管理和内存管理进行解耦合,使二者既有关联、又互不影响
② 解耦『进程管理』和『内存管理』
接下去我们再来继续挖挖【页表】这个东西,它的存在还有这什么更加深层次的意义
💬 首先还是先以问题引入:我们的程序在编译完成之后,没有加载到内存,那么此时程序的内部还有没有地址呢?
- 答案是:有的! 那有些同学就会很疑惑?程序都还没有装入到内存,何来地址呢?
- 其实对于我们的程序而言,它在编译完之后并不是混乱地放在磁盘内部的,而是也会像进程地址空间那样去做一一的区域划分,类似于:已初始化全局数据段、未初始化全局数据段等等,内部的代码和数据在加载到内存的时候是以分批的形式进行加载的
③ 统一视角,进程循环
所以读者在看了虚拟地址空间之后不要认为这样的策略只会影响OS,==对于我们的编译器而言,其实也遵守着这样的规则==
- 当源代码被编译的时候,就是按照虚拟地址空间的方式对代码和数据早就编号了对应的编址,不过具体是怎么去编译的读者如果有兴趣的话可以去学习一下 《编译原理》 这门课,里面会有相关的涉及
- 可以带读者真实地来到Linux下看一看,对于可执行程序为何会存在编址
objdump -S 可执行程序
- 我们透过一个具体的案例再来理解一下,现在我们的磁盘中有一个可执行程序,它是一个函数调用,然后当这个
call 0x1122ff80
加载到内存中的时候,就会产生一个物理地址。此时当外界的进程开始通过进程地址空间进行访问的时候,就可以由 ==页表映射== 找到这块物理地址,从而找到里面的这个函数调用
- 接下去呢,便可以通过将读取到的这个数据返回,因为它是个地址调用,所以CPU会将其有不一样的看法,在这里就会将其看作为是
虚拟地址
- 那么既然它是一个虚拟地址的话,就可以通过【进程地址空间】去转换到 页表 进行映射,然后再去取到下一个物理地址中的内容,发现地址中还是一个虚拟地址,读取到CPU内再度调用,这也就开启了一个
[进程读取循环]
- 以进程地址空间【正文代码】中的
main()
函数作为起始地址开始执行,一句句执行下去,若是碰到有函数调用的话就进行跳转执行。这就使得每一个进程都遵循一个统一的规则去进行运转。
⇒ 因此我们可以得出页表的第三个意义所在:可以让进程以统一的视角,看待自己的代码和数据!
🎁小彩蛋:手机为何会发烫呢?
看那么久文章脑瓜子一定嗡嗡的吧😵 马上进入我们的彩蛋时刻
💬 那么再问一个小问题:进程的代码和数据必须一直在内存中吗?
- 答案是:不一定。因为有【进程地址空间】的存在,你用多少我给你加载多少,其中执行完的代码就直接扔掉了,再重新去加载。这样就可以边加载边执行,需要的时候就把数据提前加载到内存里,不需要就把它扔掉,这样就可以使操作系统中的内存在很低的使用量的同时还可以把大软件给跑起来
- 现在市面上有很多的游戏,像:LOL、王者荣耀、刀塔传奇、原神、炉石传说等等,这些都是大型的网游,下载下来需要很大内容空间10/20G。不知大家日常在打游戏的时候会不会越玩越卡,然后手机就渐渐发烫然后掉帧呢(处理器好的同学请忽略)
- 这是因为我们的手机在运行起来的时候就会加载一些系统自带的内容,大部分情况下我们的时候正在进行网络IO,还有一种情况呢我们的手机可能把当前进程正在加载的进程中的数据换出到
固态硬盘
中,也就是我们之前所学习过的【进程的换入换出】操作 ,不断地在做数据交换,说白了就是在做 二进制写入,所以我们手机的CPU在工作久了之后压力就比较大,就导致了发热的情况
四、总结与提炼
那么经过上面的这么一番分析之后呢,大家应该对页表在操作系统管理的时候所呈现的重要性有了一定的认识,最后来总结以下本文所学习的内容:book:
本文我们主要是在讲Linux下的『进程地址空间』,我们可以通过三个方面来回顾一下:
💬 进程地址空间是什么?
- 在操作系统内部为进程创建出来的一种具体的
数据结构对象
,用来让进程以统一的视角去看待物理内存。因为有进程地址空间的存在,便可以让进程管理和内存管理独立开来 - 并且有进程地址空间的存在,我们可以在磁盘当中编译程序时就把编译好的程序以地址空间的形式把它排布好,这样加载到内存CPU在进行读取识别时,它读到的就都是
虚拟地址
。根据这个虚拟地址经过页表映射之后再进行相应的读取,内部的就开始转起来了
💬 为什么要有进程地址空间?
- 防止地址随意访问,保护物理内存与其他进程
- 将进程管理和内存管理进行解耦合
- 可以让进程以统一的视角,看待自己的代码和数据!
💬 怎么去用这个进程地址空间?
- 在内核当中定义
mm_struct
这个数据结构,这个数据结构里充满了大量的区域,所谓的区域就是start
和end
这样的指针,用来限定各种区域的起始和结束。然后通过页表再经过映射到物理内存
以上就是本文要介绍的所有内容,感谢您的阅读:rose::rose::rose: