目录
一、程序地址空间回顾
我们以前在学C/C++的时候可能听过地址空间这样的概念,但是,当时我们可能不知道这个地址空间到底是什么。
我们以前可能将它视作内存,其实它并不是内存,我们这里先看一个现象。
编写一段代码,定义一个全局变量,让父子进程都循环打印,当达到某个要求时,让子进程将全局变量的值做更改,看更改后的全局变量的情况。
可以看出,当未对全局变量 global_value 的值做更改的时候,我们的打印结果如我们所料。并且在更改了全局变量的值后,可以看到父子进程中的 global_value 的值不一样了,这也能够理解,因为我们曾经说过父子进程必须保证独立性,那父子进程之间的数据也不会相互影响。
但是,我们却看到父子进程的 global_value 的地址居然是一样的!多进程在读取同一个地址的时候,怎么可能出现不同的结果呢?
那么,我们可以得到:这里的地址绝对不是物理地址。那么,我们曾经学习的语言级别的地址(指针),绝对不是对应的物理地址。而是虚拟地址(线性地址、逻辑地址)。那么,我们曾经打印出来的地址空间排布,全部都是虚拟地址。因此,我们曾经学的C/C++地址空间不是内存,而是虚拟地址空间。
二、进程地址空间
进程地址空间的全称叫做进程地址虚拟空间。所以说,我们可以将进程地址空间叫做虚拟地址空间。
我们先来感性理解一下虚拟地址空间。
1 感性认识虚拟地址空间
首先,我们要了解一个知识:进程它会认为自己是独占系统资源的,虽然事实上并不是。进程会认为它想用内存资源就可以用,想用CPU资源就可以用。但进程上下文切换的时候,会把一个进程投入休眠,但这个进程也不知道自己休眠了。它既然都休眠了,就相当于眼睛都闭上了。就比如说,你朋友晚上在宿舍床上睡觉,你们把他从宿舍挪出去,等到他快要醒了的时候,你们又把他挪回宿舍,此时,你朋友肯定会认为自己一直都在宿舍o(* ̄︶ ̄*)o。但其实,可能当他被搬出去的时候,他的床位可能还在被别人使用。但是,站在你朋友的角度上,它会认为他独占他的床位。类比可以得到,进程会认为自己独占系统资源。
然后,我们这里用一个故事来感性理解虚拟地址空间。
在漂亮国有一个大富翁 Jack ,他有10亿 dollars ,他没有结婚,但却有三个私生子 son1,son2,son3,他们之间互不认识,都认为大富翁只有自己一个私生子。也就是说,他们之间并不知道对方的存在。其中,son1是一个工厂老板,son2是金融领域的一个CEO,son3是MIT的一个大学生。
大富翁为了让他们做事都更有劲,他就给大儿子son1说:“儿子,你好好把你的工厂经营好,我这里有10亿美金,等我去世了,这10亿就都是你的了”。大儿子听了特别高兴,后面就没日没夜的待在工厂里面做事情。
然后,大富翁又找到二儿子son2说:“儿子,你好好的把我们家族的金融投资类工作做好,我这里有10亿美金,等我去世了,这10亿就都是你的了”。二儿子听了也很高兴,也去努力工作了。
然后,他又找到三儿子son3说:“儿子啊,我就你一个儿子,你好好读书,等你毕业后,你爹我会把你工作安排好的,而且,我这里有10个亿,等我去世了,这10个亿以及我们家族的所有产业就都是你的了”。三儿子听了也很高兴,就跑去学习了。
这样,大富翁就给三个儿子都画了一个饼。由于三个儿子都不知道对方的存在,并且大富翁画饼的时候是给它们独立画的饼,所以三个儿子都认为他们自己会独占这10个亿。所以,每个儿子都认为他们现在或者将来就拥有这10个亿。
假如当大儿子建工厂或者给员工发工资需要钱的时候,由于他只有一个父亲,他会找他的父亲要钱,但是,他不可能一下子直接问他父亲要10个亿,这个父亲肯定会说:“我还没走呐,你就这么过分,问我要10个亿”。大儿子肯定不会这么做的,而可能是要5万或者10万之类的。
后面,二儿子想要买个豪车给自己撑撑场面,他也去问他父亲要钱。同样地,他肯定也不会一下子要10亿,而是要个30万之类的。
再后面,三儿子也问他父亲要钱,说是要1万去交学费、3千去报培训班之类的。
上面三个儿子的要求,他们的父亲都答应了。那么,在这样的工作方式之下,这三个儿子和他们的父亲都相处的很好。
那么,这里的大富翁 Jack 就是操作系统,他要划分的10亿美金就相当于内存,这三个儿子就是所谓的进程,三个儿子所要的钱就是给进程分配的内存 或者 对象空间(语言层面上)。当某个儿子跑去给他父亲说:“爹呀,我问你要了这么多次钱,累积已经要了好几百万了,你要不干脆直接把那10个亿给我吧,反正我花也是花,来回要也麻烦”。这个父亲肯定会把这个儿子揍一顿,并拒绝了他。这个儿子会怎么想呢?他会想这10个亿不属于他吗?不会的,他只会想是不是自己提的要求太过分了。所以,我们将大富翁给他儿子画的大饼叫做进程地址空间(指操作系统给进程画的“大饼”)。
那么,操作系统是如何画的饼呢?
假设这个大富翁的儿子脑子不好使,总是记不住事怎么办,他父亲给他画饼,他第二天就忘记了,还反过来问他是谁?所以说,这样画饼就没有意义了。因此,画饼的前提条件是这个人的脑子要好使,记性要好。所以说,画饼的本质是在你的大脑中构建一幅蓝图。比如说在公司,老板告诉你说:“你好好干,以后升你当经理,我们一起努力,把公司做上市,然后我们就可以一起分红啦”。这就是老板在你的脑袋里构画的蓝图,它其实就是一个数据结构对象。
当大富翁给他的儿子画饼时,他们大脑里存放的是一个个对象。
所以,回到公司。可能会存在这样的情况:当一个老板有500位员工, 他要给500位员工都要画饼时,他对张三说:“张三啊,你好好干,明年我升你当经理”。张三说:“老板,不对呀,你昨天说的直接升我当部门总监哒,你是不是记错了?”老板脸一红,想:“好像真的记错了”。
所以说,员工太多的时候,不仅员工要被管理,饼也要被管理(要知道给谁画的饼,不能搞错了)。
那么,当操作系统内有500个进程,操作系统要给每个进程创建地址空间,进程需要被管理,地址空间同样也要被管理。
怎么样管理呢?
先描述,再组织。
所以说,地址空间的本质其实是内核的一种数据结构(mm_struct)。
然后,我们以32位计算机为例再进行讲解。
先了解一些共识:
- 地址空间描述的基本空间大小是字节;
- 32位下最多形成 2^32 次方个地址;
- 2^32*1字节 = 4GB 空间范围;
- 每一个字节都要有唯一的地址,故有 2^32 个地址;
- 地址最大的意义就是保证唯一性;
那么,怎么做到有 2^32个地址,并且彼此之间都不重复呢?
其实,我们要表示一个地址只需要 32 位的数据即可,也就是 32 个比特位,因为任何一个比特位要么为 0,要么为 1。所以 32 位的比特位,它的排列组合的个数就有 2^32 个。
我们上图做的动作叫做编址。那上图的地址是什么地址呢?是C/C++语言里面的地址吗?还是内存的地址?我们后面再讲。
2 理解区域划分与区域调整
这里我们先讲一个故事。
我们在上小学时,可能坐过那种长课桌,假设在一个小学有两个小朋友,一个叫小坤是个男孩,一个叫小菜是个女孩,他们是同桌。小菜是个很爱干净的女孩子,而小坤却是一个邋里邋遢的男孩子。小菜很讨厌小坤,但是,小坤确很喜欢招惹小菜。
有一天,小菜实在忍不住了,就在桌子上画了一条线,并告诉小坤不要超过这条线,如果超过了就打他。小坤最开始还不相信,结果后面被揍了几顿就老实了(我们知道小学的时候,很多女孩子都比男孩子要高,毕竟女孩子发育要快一点嘛,很多男孩子还打不过女孩子),他最后也只好屈服了。所以说,这个桌子的区域就划分好了。
那么,小菜做的在课桌上画三八线的操作就叫做区域划分。
那么,操作系统是如何进行区域划分的呢?
当然是创建一个结构体,然后用该结构体创建一个对象,然后给里面的变量赋不同的值就行了。
但是,有一天小坤实在受不了了,他就对小菜说:“能不能别这么频繁的揍我了,我就是有点胖,我有时候越线又不是故意的,我一越线你就揍我,我也不想啊。我不管,你赶紧想个法子解决一下我的问题” 。于是小菜就将小坤的 5cm 长度与她自己的 5cm 长度让出来作为缓冲地带。小坤只要不超过缓冲地带就不会被打,这样小坤就有 55cm 的活动空间了。
那么,我们怎么做区域调整呢?
只需要改这个对象内的变量值就行。
可是,小坤依然死性不改,还是越界,这可把小菜气坏了,于是她就给小坤说:“我都给你这么多空间了,你还越界,以后就只给你 30cm了,缓冲地带也不要了,如果你越过了这 30cm 的线,我也要打你”。
在这里,小菜所做的操作叫做区域扩大。
所以说,以后如果我们想要做区域的调整,本质上其实就是更改所对应的 start 与 end 变量里面所保存的值。
那回到进程地址空间,在32位系统下,会默认有 2^32 个地址,这就相当于上面的桌子默认有 100cm 。在这个桌子上,我们以厘米为单位,在旁边放一把尺子,这个尺子测量出的桌子的长度天然就是这个桌子的空间,小坤可以在 1-2cm 处放铅笔,在 2-8 cm处放文具盒……
反正,对于我们来讲,这个桌子天然为 100 cm ,我们可以在桌子当中以厘米为单位给它带上对应的编址,那我们要描述桌子上的任何东西时,我们就可以通过地址的方式去确认某个位置放的是什么了。
那 mm_struct 就相当于这个桌子,它的4GB空间就相当于 100cm ,这4GB空间经过编址后,就变成了一个个的访问最小字节单位的一个个地址。所以就有了地址空间4GB,以字节为单位,有2^32 个地址。
这 2^32 个地址只需要 2^32 个数据就可以表示了,每一个数据的类型为 unsigned int ,因为unsigned int在 32 位系统中占了32个比特位,它刚好可以表示 2^32 个数据。
当我们创建一个进程,操作系统就会给该进程malloc一个内核数据结构(task_struck),然后,操作系统再结合地址空间,再malloc一个空间,假设是 *mm,所以说,该进程就会包含一个变量mm,这个 mm 里面存放的是各个区域的起始地址。每块区域里面的无数个地址都可以被用户所使用,所以说,这些区域之间的无数个地址,包括各区域的地址,都被称为虚拟地址。所以说,对于32位系统,它的进程地址空间里面所有的地址都是虚拟地址。
- 地址的概念:以小菜为例,她最开始在桌面上的区域为[51, 100] ,由于在这个区域里包含多个数字(52、53......),每一个数字都代表一个桌面的位置,所以说,区域里的每一个数字都可以看做成一种地址。
然后,对于代码区、已初始化数据区、未初始化数据区与全局数据区,它们的大小是固定的,不会变化。
但是,堆区可以通过malloc调整大小,栈区也可以通过不断入栈调整大小。
所以说,堆栈之间所谓的区域的调整,本质就是修改各个区域的 end/start 。
因此,我们在学习C/C++时,执行定义局部变量、malloc/new 堆空间等操作,就是在扩大栈区或者堆区;函数调用完毕、free/delete 堆空间时,就是在缩小栈区或者堆区。
3 通过引入页表解决内核数据结构与内存的关联问题
每个进程都要有地址空间,并且,我们知道,创建进程时,操作系统为了管理进程,就必须给进程创建对应的 task_struck ,里面包含了进程的pid、ppid、优先级、状态以及其它属性。但是,每一个进程也被操作系统画了大饼呀,那这个大饼是什么呢?就是mm_struct。它的大小有多大呢?4GB空间。所以说,mm_struct 就是操作系统给进程画的大饼。因此,进程为了更快地找到自己的大饼,它内部也会有指针直接指向对应的自己的地址空间。这是一方面。
另一方面,我们来看看 task_struck 与 mm_struct 这两个结构体。
通过查看两个内核数据结构,我们会发现:task_struck 里面有个指针指向进程的地址空间。
另外,我们知道:我们自己编译出的程序实际上它就是磁盘上的一个文件(假设是 test.exe),这个可执行程序拥有自己的代码和数据。并且,我们之前也说过,程序运行之前必须先加载进内存里。所以说,该程序的代码和数据就会被加载进内存里,我们这里假设它们在内存里占了1k字节的空间,每个字节都有自己的地址,我们这里以 0x1111 1111 为其在物理内存的起始地址。
但是,内核数据结构与硬件(内存)是如何关联起来的呢?我们如何让进程找到我们的代码和数据呢?
所以说,这里需要引入页表这个概念。但是,由于其特别复杂,我们这里不具体去深入了解它。只做简单了解就足够了。
内存在使用的时候,它与我们的磁盘进行输入输出(IO)的时候,一般是一次传输4KB(4k字节),也就差不多4k多个地址。同时,内存会把自己的整个空间想象成一个一个的4KB的page(页),我们可以把内存想象成一块大的数组( struct page mem[4GB/4KB] )。
所以说,以后操作系统若是要访问页内的数据(也就是内存里的数据),只要知道页的起始地址,再加上页内偏移就可以找到任何一个数据的地址。这些我们先不谈,后面再讲。
这里我们直接就认为 mm 部分生成的地址规范就叫做虚拟地址,内存部分生成的地址规范就叫做物理地址。
假设这里有一块区域在堆区(虚拟地址里),它只有一个字节,里面保存了一个字符6,地址为 0x2222 2222 ,它这个虚拟地址在内存中可能被保存在一个位置处,我们假设它的地址为 0x3333 3333 ,同样地,里面的数据也是字符6。
这样,我们就可以将虚拟地址通过页表与物理地址进行映射了。
所以说,假设我们定义了一个变量:char c = 6;(也就是上面的) 当我们对其取地址时,取出来的地址是虚拟地址。当我们上层拿着 c 的虚拟地址去访问时,会先去查页表,然后通过页表查找到相关的物理内存,找到物理内存后,就将 6 写入到物理内存了。这样就完成了数据的写入。
另外,这里讲个题外话。显然页表不会这么简单。因为假设以字节为单位,32位系统下就有2^32个地址,那么,页表就会有2^32行,我们一行就包含两个数据,左右都是4bytes,一行就会占8bytes,那这么算下来就会是:2^32*8 = 32GB,物理内存连页表都会装不下,更别说创建内核数据结构以及其它了。所以说,页表其实不是按上面这么画的,它其实是一个多级页表,也就是呈现树状结构。具体的,我们以后再说吧。
因此,我们平时在使用代码的时候,我们就可以使用地址空间上的地址(也就是虚拟地址),通过页表映射到物理内存,包括把数据加载到内存等操作,这些都是由操作系统自动帮我们做的。我们不需要关心,我们只管在用户层面上编写代码,然后把代码和数据加载进内存,然后 ./ 运行一下就可以了,剩下的直接交给操作系统就行了。这一套机制就叫做进程的虚拟地址空间。
我们这里再深入讲解一下。
首先,我们要知道:在进程地址空间上的地址是按照正常的方式排布的(也就是从0开始,然后是1、2、3……一直到42亿多),那么,这些地址就全部的都是连续的地址,它既然是连续的地址,所以说,在很多的专业的文档或者教材中,地址空间有时不叫作虚拟地址,而是叫作线性地址。
此外,我们通过上面的学习,可以画出下面这幅图,这是只有一个进程的情况。
如果是两个进程就会是这样的。
然后,我们结合上面的大富翁的例子可以得到下图。
通过上图我们可以知道: 进程1与进程2是没法看见内存的,它们只可以通过自己的虚拟地址空间来访问对应的数据,在这个过程中,就相当于给每个进程画了一个饼,每个进程都认为自己有2^32个地址,然而,实际上我们可以看到堆和栈之间存在一大块空间,进程根本没用,进程在这里可以要多少用多少,操作系统知道每个进程不会一下子申请一大块内存,如果要多了就不给了。但是,每个进程仍认为自己独占系统资源。
4 存在进程地址空间与页表的原因
4.1 理由一
为什么会存在地址空间和页表呢?为什么不让进程直接访问物理内存呢?
因为如果让进程直接访问物理内存,如果越界非法操作了,就可能把其它数据给更改了或者引起其它问题。比如:假设有个恶意进程,它通过非法手段扫描我们的内存,找到了我们的用户名和密码,并把数据读取出来,那我们的信息不就泄漏了吗。所以说,直接访问物理内存是非常不安全的。
但是,这只能证明直接访问物理内存不行,但是,怎么证明使用虚拟地址空间就行了呢?
这是因为:假设存在虚拟地址空间,如果我们访问非法地址了,当我们拿着非法地址去页表当中索引的时候,页表会拦截我们,不去做映射。
我们举一个例子。
当我们小的时候,我们过年可能都会有压岁钱,当压岁钱在我们自己手上的时候,我们可能想买什么就买什么。
假设李华就是这小孩,他有了压岁钱后去店里买了棒棒糖和漫画书回家,他妈妈看了之后,生气地说:“小小年纪,看什么漫画书,那东西对你不好。”到了第二年过年的时候,李华妈妈就对他说:“华子啊,你以后收到了压岁钱就交给我吧,我替你保管,这样,以后你就不会丢钱了,并且你需要买什么东西就问我要,我到时候给你就行了。”李华听了非常高兴,然后就把收到的压岁钱给了妈妈。他的妈妈拿到钱也很高兴。^-^
有一天,李华想要买一辆玩具车,他就给妈妈说:“妈妈,妈妈,我要买玩具车。”妈妈听了后说:“好!多少钱,我给你。”李华最后就拿着钱去买了玩具车。李华最后就心想:“这感觉真好,身上不装钱还能花钱。”后来有一天,李华看见了一包很好吃的辣条,他就又给妈妈说:“妈妈,妈妈,我要买一包辣条。”妈妈就说:“买什么辣条,这么小一个孩子,不行!”所以说,他妈妈就拒绝了他。李华能怎么办呢?他不能怎么办。
所以说,李华妈妈拒绝李华的请求本质上是保护他,不让他受到不良商家的欺骗或者得到他现阶段不应该得到的东西。
那么,其中,李华直接把压岁钱自己拿着花不就行了吗,为什么还要经过他妈妈的手呢?
因为妈妈要对她的儿子做保护。所以说,我们使用直接访问物理内存的这种方式,它是可以,但是,容易出问题。就好比把压岁钱拿在李华自己手上。如果我们使用 虚拟地址空间+页表 的方式,那我们如果要访问某段空间,我们可以将访问需求发送给页表。当是合理的请求时,页表就会帮我们映射,如果请求不合理,就直接拦截我们。由于地址空间和页表都是在OS中实现的,所以,拦截和处理我们请求的是操作系统。
这上述的规则是所有进程都要遵守的,那么,也就是说,所有进程在通过页表进行映射的时候,只会映射到合法的内存当中,这样,我们就可以不用担心我们的物理内存被恶意的写坏或读取了。
这是第一个为什么会有虚拟地址空间的理由,它可以变相的保护我们的物理内存,不再让恶意进程随便访问了。
4.2 理由二
我们最开始编写过一个代码,发现在同一个地址处会有两个不同的值,这是为什么呢?我们这里就来解释一下。
首先,我们要知道:子进程由于是父进程创建的,而创建子进程会以父进程为模板,把子进程全部创建出来。所以,父进程会把自己的PCB和地址空间拷贝给子进程,所以,在地址空间的同样一个位置,子进程的也会有一个地址。
此时,父子进程打印出来的global_value的值是一样的,都是100。
10s后,子进程尝试着对global_value进行写入,此时,如果直接向物理内存内的global_value进行写入,那么,此时父进程在读取该变量的时候也会是改变后的值。
然而,因为进程具有独立性,一个进程对被共享的数据做修改,如果影响了其它进程,就不能称之为独立性了。 所以说,我们不能这么做。
OS为了保证进程的独立性,当父或子进程任何一方尝试对共享数据做写入时,OS会先为该进程在物理内存上开辟一块空间,然后把原来的值拷贝到新的空间里,再然后修改该进程曾经在物理内存上的映射关系,然后再改变新的空间里面的值(这种技术被叫做写时拷贝,也就是在要写的时候才为其拷贝一份,这些也是由OS做的,我们不需要关心其细节)。这样,就保证了独立性。
在整个修改期间,跟我们的虚拟地址是没有关系的,所以说,上层在用的时候依旧是用的同样的一个虚拟地址,但是,底层通过页表,被映射到了内存的不同区域。那么,此时,我们看到的就是:全局变量global_value在父子进程中的地址是一样的,但是内容却不一样。
从上面我们可以得到:OS为了保证进程的独立性,做了很多工作。其中最重要的是: 通过地址空间和页表,让不同的进程映射到不同的物理内存处。
所以,进程的独立性就体现在:
- 每个进程有自己独立的内核数据结构(PCB、地址空间和页表);
- 通过写时拷贝,让不同进程的数据进行分离;
- 代码在父子进程当中可以共享,但是,它们其实也是可以被独立开来的(以后说)。但如果是两个不相干的代码,它们的代码也是独立的;
- 进程 = 内核数据结构 + 代码和数据;
- 内核数据结构是独立的,数据是独立的,代码是独立的,得到进程具有独立性。
所以说,第二个理由是:地址空间的存在,可以方便的进行进程和进程的代码数据的解耦,保证了进程的独立性特征。
4.3 理由三(难点,了解即可)
我们要知道,当程序没有被加载到内存的时候,我们自己的可执行程序里面其实是有地址的。我们之前也看过汇编代码,而且我们之前也讲过程序的翻译,知道汇编在前,可执行在后,最后再是运行。既然汇编的时候我们的代码就已经有地址了,那么,在可执行程序里面肯定也早就有地址了呀。
而且,我们在链接的时候也会把我们要用的库函数地址填入到我们自己的程序里,最后让可执行程序运行时,可以找到它们。
(程序的翻译过程:预处理、编译、汇编、链接、可执行程序)
那么,可执行程序里面的地址又是什么地址呢?它有什么用呢?
其实,这个地址叫做逻辑地址。其实它在linux中就是虚拟地址。我们先称其为虚拟地址,后面再讲它们的区别。
我们要先知道:不是只有OS会遵守虚拟地址空间的相应规则,编译器其实也要遵守。
所以,编译器编译我们的代码的时候,就是按照虚拟地址空间的方式对我们的代码和数据进行编址的。也就是说,我们的可执行程序在形成之前早就存在了所谓的各个区域。(比如说:数据区、代码区等。数据和代码在虚拟地址的哪个位置上已经被确定了。堆和栈是在运行时才产生的,我们暂时先不考虑)。
假设存在如下代码:
当其成为可执行程序之前,也就是当该程序在编译器编译的时候,就已经给该程序里面的代码和数据产生了地址,它其实就是虚拟地址。 这个地址是我们程序内部使用的地址,它使得程序能够在代码内部进行跳转。
当我们将可执行程序加载进物理内存的时候,它的代码的存放方式也是和在虚拟地址空间上的代码的存放方式是一样的。地址的调用关系也不用变。
此外,当我们将可执行程序加载进物理内存的时候,它的代码也需要被保存在内存中。因此,代码也需要在内存中占空间(在OS看来,代码也属于数据,是数据就需要占空间)。那既然它需要在内存中占空间,那么,我们程序里的代码和数据也就需要在内存中具有物理地址。
所以说,我们这里加载进内存的程序就具备了两套地址:
- 标识物理内存中代码和数据的地址;
- 在程序内部互相跳转的时候,所具备的虚拟地址。
当程序加载进内存完成后,此时,我们代码的各个区域的地址我们就都知道了(包括全局变量的、func函数的和main函数的物理地址),它们会被存进页表当中。然后,既然我们可以将代码加载进内存里,那么,我们自然也可以读取代码里面的地址。
此外,我们上面也讲过,mm_struct 里面有一堆start与end,但是,我们之前是随便设的起始和结束地址。那么,在地址空间被定义出来的时候这些开始和结束地址应该是多少呢?
我们之前的可执行程序在编译的时候不就有地址吗。我们就可以用main函数的地址作为代码区的起始地址,整个代码区域有多长,加上长度就可以得到结束地址了(假设代码有10kb)。这样,我们就对这个区域做了初始化,确定了代码区的起始和结束地址。同样地,像我们的全局数据区以及其它一些区域也可以拿我们的可执行程序里面的地址去做初始化。
堆区和栈区的起始和结束地址可以设置成默认的,因为它们本来就是运行时自动产生的。
当我们地址空间初始化好后,我们再让进程的PCB(task_struck)指向它就可以了。
当我们要运行该程序的时候,会先从main函数开始跑。【CPU里面有一个eip寄存器(指令指针寄存器),它可以读取指令】但是,此时不是把main函数的物理地址加载进CPU里,CPU是看不到物理内存的,而是从地址空间里的 code_start 开始。等到CPU开始调度该进程的时候,操作系统会直接将code_start的地址加载进CPU里面的eip寄存器里,此时,CPU就结合该地址经过地址空间,然后查找页表,查到了物理地址,然后就从里面的main函数开始往后执行了。
紧接着就是调用func函数,此时,OS会先把func函数的虚拟地址加载进CPU里。然后,CPU根据func函数的虚拟地址和页表映射又回到物理内存,此时,会直接跳转到func函数,然后又开始跑。
由于我们的func函数里面调用了全局变量 i ,此时,OS会先将 i 的虚拟地址加载进CPU,并且附上要执行的操作,然后借助页表映射,找到物理地址,然后把变量的值进行修改就可以了。
在整个过程中,CPU都没有见到物理地址。
然后,我们这里讲一下虚拟地址和逻辑地址的区别。
我们一般把可执行程序编译好后,称其里面的地址叫做逻辑地址,也就是说,磁盘里的可执行程序里的地址被叫做逻辑地址,我们之前将其称之为虚拟地址是为了更好的理解。
我们上面说的是逻辑地址的一种实现方案,也就是线性的编址,它与我们的进程地址空间排布是几乎一样的,所以说,当我们将其加载进内存之后,我们直接就将其作为虚拟地址使用。
但是,其实还有很多种实现方案,比如:有些编译器在编译的时候不是按照线性的方式去编址的,而是按照区域的起始地址+偏移量的方式去编址的。程序内部使用的都是偏移量地址(逻辑地址)。当我们将可执行程序加载进内存的时候,每条指令都会具有物理地址。而我们程序内部的每个地址则需要被修改。程序内部的新地址会变成加载进物理内存时的起始地址+每条指令的偏移量,然后,我们就可以在物理内存中找到每条指令的地址了。这种方法是比较旧的。前面那种方法是比较新的。
两种方式形成的地址都是逻辑地址,只是前一种方式形成的逻辑地址刚好跟虚拟地址差不多。
所以说,在linux系统中,我们可以将逻辑地址、虚拟地址与线性地址理解是一样的,但是,它们在不同的场景中是不一样的概念。
因此,我们在这里得到第三理由:
让进程以统一的视角,来看待进程对应的代码和数据等各个区域(也就是每个进程都有代码区、数据区等),方便使用。编译器也以统一的视角来进行编译代码。由于使用和编译的规则是一样的(都是使用虚拟地址空间来进行编译或运行),所以编译完即可直接使用。