关于【操作系统】,一些你可能感到迷惑的问题

举报
feichaiyu 发表于 2020/02/16 15:26:51 2020/02/16
【摘要】 本书共分16章,讲解了开发一个操作系统需要的技术和知识,主要内容有:操作系统基础、部署工作环境、编写MBR主引导记录、完善MBR错误、保护模式入门、保护模式进阶和向内核迈进、中断、内存管理系统、线程、输入输出系统、用户进程、完善内核、编写硬盘驱动程序、文件系统、系统交互等核心技术。 本书适合程序员、系统底层开发人员、操作系统爱好者阅读,也可作为大专院校相关专业师生用书和培训学校的教材。

1 操作系统是什么

我不会给你提供教科书上对操作系统的定义,因为解释得太抽象了,看了之后似乎只是获得一些感性认识,好奇心强的读者反而会产生更多迷惑。为了说清楚问题,让我给您举个例子。

让我们扯点远的……在盘古开天之际,除动物以外,世界上只有土地、荒草、树木、石头等资源。人们为了躲避天灾、野兽攻击等危险,开始住进了山洞,为了获取食物,用石头和树木等材料打造一些武器。当时所有人都在做这些相同的事。这就是没有组织的人类社会,所有人都在重复“造轮子”。

后来各个地区有了自己权威性的部落,部落都专门找人打造武器,谁需要武器就直接申请领取便可,大部分人不需要自己打造武器了。后来嫌***太麻烦了,干脆养一些家畜好了,直接供给人们,谁需要可以过来交换。这就是把大家的重复性劳动集中到了一起,让人们可以专注于自己的事情。

再后来,部落之间为了通信,开始有信使了,这是最原始的通信方式。到后来发展到有社会组织,通信越来越频繁了,干脆搞个驿站吧,谁需要通信,直接写信,由驿站代为送达。

随着人口越来越多,社会组织需要了解到底有多少人,为了方便人口管理,于是就在各地建了“户籍办事”处,人们的生老病死都要到那里登记申报。

说到这我估计您已经猜出我所说的了,上面提到的部落其实就是最原始的操作系统雏形,它将大家都需要的工作找专人负责,大家不用重复劳动。而以上的社会组织其实就是代表现代操作系统,除了把重复性工作集中化,还有了自己的管理策略。

把上面的例子再具体一下,人们想狩猎时,可不可以自己先打造武器,然后拿着自己的武器去狩猎?当然可以,自己制造武器完全没有问题,但部落既然有现成的武器可用,何必自己再费事呢。另外,部落担不担心你随意制造武器会对他人造成伤害?当然会,所以部落不允许你自己制造武器了,人们只有申请的资格,给不给还是要看人家部落的意愿。这就是操作系统提供给用户进程一些系统调用,当用户进程需要某个资源时,直接调用便可,不用自己再费尽心思考虑硬件的事情了,由操作系统把资源获取到后交给用户进程,用户进程可以专注于自己的工作。但操作系统为了保护计算机系统不被损害,不允许用户进程直接访问硬件资源,比如用户进程将操作系统所占据的内存恶意覆盖了,操作系统也就不复存在了,没有操作系统的话,计算机将会瘫痪无法运作。

当人们想和远方的朋友说话时,虽然可以徒步走到亲朋好友身边再对其表达想说的话,但社会组织已经给提供了邮局和电话,何必自己再大老远跑一趟呢。这就是操作系统(社会组织)提供的资源。两个人想在一起生活,要不要一定先结婚呢?完全不用,领不领证都不会阻碍人们在一起生活,但是社会组织为了方便人口管理做了额外约束。不领证的话,至少社会组织无法预测未来人口数量趋势,无法做出宏观调控,甚至这是找到你家人的一种方法。这就如Linux系统中的内存管理,分别要记录哪些页是Active,哪些是“脏页”。不记录会不会影响程序执行,当然不会,记录这些状态还不是为了更好地管理内存吗。

以上说的社会组织和人们之间的关系,正是操作系统和用户进程的关系,希望大家能对操作系统有个初步印象,后面的实践中我们将实例化各个部分。

2 你想研究到什么程度

学无止境,学习没有说到头的那天。学习到任何程度都是存有疑惑的,就像中学和大学都讲物理,但学的深度不一样,各个阶段都会产生疑问。我们只是基于一些公认的知识,使其作为学习的起点,并以此展开上层的研究。

比如我对太空很感兴趣,大伙儿都知道地球围绕太阳做周期性公转,后来又知道电子围绕原子核来做周期性公转运动,这和地球绕太阳公转的行为如出一辙,甚至我在想太阳是不是相当于原子核,地球相当于一个电子,我们只是生活在一个电子上……而我们身体里有那么多的原子和电子,对那些我们身体中更为细微的生物来说,我们的身体是不是一个宇宙,无尽的猜想,无尽的疑惑。想法虽然有些荒诞,但基于现有科技目前谁也无法证明这是错的,而且近期已有科学文献证明人的大脑就像个宇宙。如果无止境地刨根问底下去,虽然会对底层科学更加清晰,但这对上层知识的学习非常不利,从而我们需要一个公设,我们认为原子是不可再分的,没有更微小的对象了,一切理论研究以此为基础展开。比如乘法是基于加法的,我们研究3×4等于多少,必须要承认1+1等于2,并认为其为真理,不用再去质疑1+1为什么等于2了,这就是我们的公设,至于为什么1+1等于2,还是由专门研究基础科学的学者们去探究吧。

学习操作系统也一样,不必纠结于硬件内部是如何工作的,我们只要认为给硬件一个输入,硬件就会给我一个输出就行了,因为即使你学到了硬件内部电子电路,随着你不断进步,钻研不断深入,也许有一天你的求知欲到了物理领域,并产生了物理科学方面的质疑……这让我想到一个笑话,某人准备去买自行车,结果被销售人员不断劝说,加点钱就能买摩托啦,等决定买摩托时,销售人员又说既然都决定买摩托车了,不如再加点买汽车吧,给出了各种汽车方面的优势,欲望需求不断升级,不断被销售劝说,最后居然花了几百万元买车,最后才想起自己是来买自行车的,甚至他还没有驾照……于是,咱们赶紧就此打住,我们是来学操作系统的。

你想学到哪个程度呢,你的公设是什么,要不咱们还是走一步说一步吧。

3 写操作系统,哪些需要我来做

首先应该明确,在计算机中有分层的概念,也就是说,计算机是一个大的组合物,由各个部分组合成一个系统。每个部分就是一层功能模块,各司其职,它只完成一定的工作,并将自己的工作结果(也就是输出)交给下一层的模块,这里的模块指的是各种外设、硬件。

这样,各种工作成果不断累加,通过这种流水线式的上下游协作,便实现了所谓的系统。可见,系统就是各种功能组合到一起后,产生最终输出的组合物。就像人的身体,胃负责搅拌食物,将这些食物变食糜后交给小肠,因为小肠只能处理流食,所以上游的输出一定要适合作为下游的输入,是不是有点类似管道操作了,哈哈,分工协作是大自然的安排,并不是只有计算机世界才有。我们人类的思想是大自然安排好的,所以人类创造的事物也是符合大自然规律的。

好,赶紧回到正题,操作系统是管理资源的软件,操作系统能做什么,取决于主机上硬件的功能。就像用Maya造一个人体模型出来,首先我得知道Maya这个软件提供曲线曲面各种建模方法才行,换句话说,对于人体建模,你不可能会想到用QQ,因为它不是干这个的。我想说的是硬件不支持的话,操作系统也没招……操作系统一直是所谓的底层,拥有至高无上的控制权,一副牛气轰轰的样子,原来也要依仗他人啊。是啊,操作系统毕竟是软件,而软件的逻辑是需要作用在硬件上才能体现出来的。

所以说,写操作系统需要了解硬件,这些硬件提供了软件方面的接口,这样我们的操作系统通过软件(计算机指令)就能够控制硬件。我们需要做的就是知道如何通过计算机指令来控制硬件,参考硬件手册这下少不了啦。

4 软件是如何访问硬件的

硬件是各种各样的,发展速度还是非常快的。各个硬件都有自己的个性,操作系统不可能及时更新各种硬件的驱动方法吧。比如,刚出来某个新硬件,OS开发者们便开始为其写驱动,这不太现实,会把人累死的。于是乎,便出现了各种硬件适配设备,这就是IO接口。接口其实就是标准,大家生产出来的硬件按照这个标准工作就实现了通用。

硬件在输入输出上大体分为串行和并行,相应的接口也就是串行接口和并行接口。串行硬件通过串行接口与CPU通信,反过来也是,CPU通过串行接口与串行设备数据传输。并行设备的访问类似,只不过是通过并行接口进行的。

访问外部硬件有两个方式。

(1)将某个外设的内存映射到一定范围的地址空间中,CPU通过地址总线访问该内存区域时会落到外设的内存中,这种映射让CPU访问外设的内存就如同访问主板上的物理内存一样。有的设备是这样做的,比如显卡,显卡是显示器的适配器,CPU不直接和显示器交互,它只和显卡通信。显卡上有片内存叫显存,它被映射到主机物理内存上的低端1MB的0xB8000~0xBFFFF。CPU访问这片内存就是访问显存,往这片内存上写字节便是往屏幕上打印内容。看上去这么高大上的做法是怎么实现的,这个我们就不关心了,前面说过,计算机中处处是分层,我们要充分相信上一层的工作。

(2)外设是通过IO接口与CPU通信的,CPU访问外设,就是访问IO接口,由IO接口将信息传递给另一端的外设,也就是说,CPU从来不知道有这些设备的存在,它只知道自己操作的IO接口,你看,处处体现着分层。

于是问题来了,如何访问到IO接口呢,答案就是IO接口上面有一些寄存器,访问IO接口本质上就是访问这些寄存器,这些寄存器就是人们常说的端口。这些端口是人家IO接口给咱们提供的接口。人家接口电路也有自己的思维(系统),看到寄存器中写了什么就做出相应的反应。接口提供接口,哈哈,有意思。不过这是人家的约定,没有约定就乱了,各干各的,大家都累,咱们只要遵循人家的规定就能访问成功。

5 应用程序是什么,和操作系统是如何配合到一起的

应用程序是软件(似乎是废话,别急,往后看),操作系统也是软件。CPU会将它们一视同仁,甚至,CPU不知道自己在执行的程序是操作系统,还是一般应用软件,CPU只知道去cs:ip寄存器中指向的内存取指令并执行,它不知道什么是操作系统,也无需知道。

操作系统是人想出来的,为了让自己管理计算机方便而创造出来的一套管理办法。

应用程序要用某种语言编写,而语言又是编译器来提供的。其实根本就没有什么语言,有的只是编译器。是编译器决定怎样解释某种关键字及某种语法。语言只是编译器和大家的约定,只要写入这样的代码,编译器便将其翻译成某种机器指令,翻译成什么样取决于编译器的行为,和语言无关,比如说C语言的printf函数,它的功能不是说一定要把字符打印到屏幕上,这要看编译器对这种关键字的处理。

编译器提供了一套库函数,库函数中又有封装的系统调用,这样的代码集合称之为运行库。C语言的运行库称为C运行库,就是所谓的CRT(C Runtime Library)。

应用程序加上操作系统提供功能才算是完整的程序。由于有了操作系统的支持,一些现成的东西已经摆在那了,但这些是属于操作系统的,不是应用程序的,所以咱们平时所写的应用程序只是半成品,需要调用操作系统提供好的函数才能完整地做成一件事,而这个函数便是系统调用。

用户态与内核态是对CPU来讲的,是指CPU运行在用户态(特权3级)还是内核态(特权0级),很多人误以为是对用户进程来讲的。

用户进程陷入内核态是指:由于内部或外部中断发生,当前进程被暂时终止执行,其上下文被内核的中断程序保存起来后,开始执行一段内核的代码。是内核的代码,不是用户程序在内核的代码,用户代码怎么可能在内核中存在,所以“用户态与内核态”是对CPU来说的。

当应用程序陷入内核后,它自己已经下CPU了,以后发生的事,应用程序完全不知道,它的上下文环境已经被保存到自己的0特权级栈中了,那时在CPU上运行的程序已经是内核程序了。所以要清楚,内核代码并不是成了应用程序的内核化身,操作系统是独立的部分,用户进程永远不会因为进入内核态而变身为操作系统了。

应用程序是通过系统调用来和操作系统配合完成某项功能的,有人可能会问:我写应用程序时从来没写什么系统调用的代码啊。这是因为你用到的标准库帮你完成了这些事,库中提供的函数其实都已经封装好了系统调用,你需要跟下代码才会看到。其实也可以跨过标准库直接执行系统调用,对于Linux系统来说,直接嵌入汇编代码“int 0x80”便可以直接执行系统调用,当然要提前设置好系统调用子功能号,该子功能号用寄存器eax存储。

会不会有人又问,编译器怎么知道系统调用接口是什么,哈哈,您想啊,下载编译器时,是不是要选择系统版本,编译器在设计时也要知道自己将来运行在哪个系统平台上,所以这都是和系统绑定好的,各个操作系统都有自己的系统调用号,编译器厂商在代码中已经把宿主系统的系统调用号写死了,没什么神奇的。

6 为什么称为“陷入”内核

前面提到了用户进程陷入内核,这个好解释,如果把软件分层的话,最外圈是应用程序,里面是操作系统,如图1所示。


1581837788427376.png

▲图1 陷入内核

应用程序处于特权级3,操作系统内核处于特权级0。当用户程序欲访问系统资源时(无论是硬件,还是内核数据结构),它需要进行系统调用。这样CPU便进入了内核态,也称管态。看图中凹下去的部分,是不是有陷进去的感觉,这就是“陷入内核”。

7 内存访问为什么要分段

按理说咱们应该先看看段是什么,不过了解段是什么之前,先看看内存是什么样子,如图2所示。


1581837768586908.png

▲图2 内存示例

内存按访问方式来看,其结构就如同上面的长方形带子,地址依次升高。为了解释问题更明白,我们假设还在实模式下,如果读者不清楚什么是实模式也不要紧,这并不影响理解段是什么,故暂且先忽略。

内存是随机读写设备,即访问其内部任何一处,不需要从头开始找,只要直接给出其地址便可。如访问内存0xC00,只要将此地址写入地址总线便可。问题来了,分段是内存访问机制,是给CPU用的访问内存的方式,只有CPU才关注段,那为什么CPU要用段呢,也就是为什么CPU非得将内存分成一段一段的才能访问呢?

说来话长,现实行业中有很多问题都是历史遗留问题,计算机行业也不能例外。分段是从CPU 8086开始的,限于技术和经济,那时候电脑还是非常昂贵的东西,所以CPU和寄存器等宽度都是16位的,并不是像今天这样寄存器已经扩展到64位,当然编译器用的最多的还是32位。16位寄存器意味着其可存储的数字范围是2的16次方,即65536字节,64KB。那时的计算机没有虚拟地址之说,只有物理地址,访问任何存储单元都直接给出物理地址。

编译器在编译程序时,肯定要根据CPU访问内存的规则将代码编译成机器指令,这样编译出来的程序才能在该CPU上运行无误,所以说,在直接以绝对物理地址访问内存的CPU上运行程序,该程序中指令的地址也必须得是绝对物理地址。总之,要想在该硬件上运行,就要遵从该硬件的规则,操作系统和编译器也无一例外。

若加载程序运行,不管其是内核程序,还是用户程序,程序中的地址若都是绝对物理地址,那该程序必须放在内存中固定的地方,于是,两个编译出来地址相同的用户程序还真没法同时运行,只能运行一个。于是伟大的计算机前辈们用分段的方式解决了这一问题,让CPU采用“段基址+段内偏移地址”的方式来访问任意内存。这样的好处是程序可以重定位了,尽管程序指令中给的是绝对物理地址,但终究可以同时运行多个程序了。

什么是重定位呢,简单来说就是将程序中指令的地址改写成另外一个地址,但该地址处的内容还是原地址处的内容。

CPU采用“段基址+段内偏移地址”的形式访问内存,就需要专门提供段基址寄存器,这些是cs、ds、es等。程序中需要用到哪块内存,只要先加载合适的段到段基址寄存器中,再给出相对于该段基址的偏移地址便可,CPU中的地址单元会将这两个地址相加后的结果用于内存访问,送上地址总线。

注意,很多读者都觉得段基址一定得是65536的倍数(16位段基址寄存器的容量),这个真的不用,段基址可以是任意的。这就是段可以重叠的原因。

举个例子,看图2,假设段基址为0xC00,要想访问物理内存0xC01,就要将用0xC00:0x01的方式来访问才行。若将段基址改为0xc01,还是访问0xC01,就要用0xC01:0x00的方式来访问。同样,若想访问物理内存0xC04,段基址和段内偏移的组合可以是:0xC01:0x030xC02:0x020xC00:0xC04等,总之要想访问某个物理地址,只要凑出合适的段基地址和段内偏移地址,其和为该物理地址就行了。这时估计有人会问这样行不行,0xC05:-1,能这样提问的同学都是求知欲极强的,可以自己试一下。

说了这么多,我想告诉你的是只要程序分了段,把整个段平移到任何位置后,段内的地址相对于段基址是不变的,无论段基址是多少,只要给出段内偏移地址,CPU就能访问到正确的指令。于是加载用户程序时,只要将整个段的内容复制到新的位置,再将段基址寄存器中的地址改成该地址,程序便可准确无误地运行,因为程序中用的是段内偏移地址,相对于新的段基址,该偏移地址处的内存内容还是一样的,如图3所示。


1581837744367002.png

▲图3 段的重定位

所以说,程序分段首先是为了重定位,我说的是首先,下面还有其他理由呢。

偏移地址也要存入寄存器,而那时的寄存器是16位的,也就是一个段最多可以访问到64KB。而那时的内存再小也有1MB,改变段基址,由一个段变为另一个段,就像一个段在内存中飘移,采用这种在内存中来回挪位置的方式可以访问到任意内存位置。

所以说,程序分段又是为了将大内存分成可以访问的小段,通过这样变通的方法便能够访问到所有内存了。

但想一想,1M是2的20次方,1MB内存需要20位的地址才能访问到,如何做到用16位寄存器访问20位地址空间呢?

在8086的寻址方式中,有基址寻址,这是用基址寄存器bx或bp来提供偏移地址的,如“mov [bx],0x5;”指令便是将立即数0x5存入ds:bx指向的内存。

大家看,bx寄存器是16位的,它最大只能表示0~0xFFFF的地址空间,即64KB,也就是单一的一个寄存器无法表示20位的地址空间——1MB。也许有人会说,段基址和段内偏移地址都搞到最大,都为0xFFFF,对不起,即使不溢出的话,其结果也只是由16位变成了17位,即两个n位的数字无论多大,其相加的结果也超不过n+1位,因为即使是两个相同的数相加,其结果相当于乘以2,也就是左移一位而已,依然无法访问20位的地址空间。也许读者又有好建议了:CPU的寻址方式又不是仅仅这一种,上面的限制是因为寄存器是16位,只要不全部通过寄存器不就行了吗。既然段寄存器必须得用,那就在偏移地址上下功夫,不要把偏移地址写在寄存器里了,把它直接写成20位立即数不就行啦。例如mov ax[0x12345],这样最终的地址是ds+0x12345,肯定是20位,解决啦。不错,这种是直接寻址方式,至少道理上讲得通,这是通过编程技巧来突破这一瓶颈的,能想到这一点我觉得非常nice。但是作为一个严谨的CPU,既然宣称支持了通过寄存器来寻址,那就要能够自圆其说才行,不能靠程序员的软实力来克服CPU自身的缺陷。于是,一个大胆的想法出现了。

16位的寄存器最多访问到64KB大小的内存。虽然1MB内存中可容纳1MB/64KB=16个最大段,但这只是可以容纳而已,并不是说可以访问到。16位的寄存器超过0xffff后将会回卷到0,又从0重新开始。20位宽度的内存地址空间必然只能由20位宽度的地址来访问。问题又来了,在当时只有16位寄存器的情况下是如何做到访问20位地址空间的呢?

这是因为CPU设计者在地址处理单元中动了手脚,该地址部件接到“段基址+段内偏移地址”的地址后,自动将段基址乘以16,即左移了4位,然后再和16位的段内偏移地址相加,这下地址变成了20位了吧,行啦,有了20位的地址便可以访问20位的空间,可以在1MB空间内自由翱翔了。

8 代码中为什么分为代码段、数据段?这和内存访问机制中的段是一回事吗

首先,程序不是一定要分段才能运行的,分段只是为了使程序更加优美。就像用饭盒装饭菜一样,完全可以将很多菜和米饭混合在一起,或者搅拌成一体,哈哈,但这样可能就没什么胃口啦。如果饭盒中有好多小格子,方便将不同的菜和饭区分存放,这样会让我们胃口大开增加食欲。

x86平台的处理器是必须要用分段机制访问内存的,正因为如此,处理器才提供了段寄存器,用来指定待访问的内存段起始地址。我们这里讨论的程序代码中的段(用sectionsegment来定义的段,不同汇编编译器提供的关键字有所区别,功能是一样的)和内存访问机制中的段本质上是一回事。在硬件的内存访问机制中,处理器要用硬件——段寄存器,指向软件——程序代码中用sectionsegment以软件形式所定义的内存段。

分段是必然的,只是在平坦模型下,硬件段寄存器中指向的内存段为最大的4GB,而在多段模式下编程,硬件段寄存器中指向的内存段大小不一。

对于在代码中的分段,有的是操作系统做的,有的是程序员自己划分的。如果是在多段模型下编程,我们必然会在源码中定义多个段,然后需要不断地切换段寄存器所指向的段,这样才能访问到不同段中的数据,所以说,在多段模型下的程序分段是程序员人为划分的。如果是在平坦模型下编程,操作系统将整个4GB内存都放在同一个段中,我们就不需要来回切换段寄存器所指向的段。对于代码中是否要分段,这取决于操作系统是否在平坦模型下。

一般的高级语言不允许程序员自己将代码分成各种各样的段,这是因为其所用的编译器是针对某个操作系统编写的,该操作系统采用的是平坦模型,所以该编译器要编译出适合此操作系统加载运行的程序。由于处理器支持了具有分页机制的虚拟内存,操作系统也采用了分页模型,因此编译器会将程序按内容划分成代码段和数据段,如编译器gcc会把C语言写出的程序划分成代码段、数据段、栈段、.bss段、堆等部分。这会由操作系统将编译器编译出来的用户程序中的各个段分配到不同的物理内存上。对于目前咱们用高级语言编码来说,我们之所以不用关心如何将程序分段,正是由于编译器按平坦模型编译,而程序所依赖的操作系统又采用了虚拟内存管理,即处理器的分页机制。像汇编这种低级语言允许程序员为自己的程序分段,能够灵活地编排布局,这就属于人为将程序分成段了,也就是采用多段模型编程。

这么说似乎不是很清楚,一会再用例子和大伙儿解释就明白了。在这之前,先和大家明确一件事。

CPU是个自动化程度极高的芯片,就像心脏一样,给它一个初始的收缩,它将永远地跳下去。突然想到Intel的广告词:给你一颗奔腾的心。

只要给出CPU第一个指令的起始地址,CPU在它执行本指令的同时,它会自动获取下一条的地址,然后重复上述过程,继续执行,继续取址。假如执行的每条指令都正确,没有异常发生的话,我想它可以运行到世界的尽头,能让它停下来的唯一条件就是断电。

它为什么能够取得下一条指令地址?也就是说为什么知道下一条指令在哪里。这是因为程序中的指令都是挨着的,彼此之间无空隙。有同学可能会问,程序中不是有对齐这回事吗?为了对齐,编译器在程序中塞了好多0。是的,对齐确实是让程序中出现了好多空隙,但这些空隙是数据间的空隙,指令间不存在空隙,下一条指令的地址是按照前面指令的尺寸大小排下来的,这就是Intel处理器的程序计数器cs:eip能够自动获得下一条指令的原理,即将当前eip中的地址加上当前指令机器码的大小便是内存中下一条指令的起始地址。即使指令间有空隙或其他非指令的数据,这也仅仅是在物理上将其断开了,依然可以用jmp指令将非指令部分跳过以保持指令在逻辑上连续,我们在后面会通过实例验证这一原理。

为了让程序内指令接连不断地执行,要把指令全部排在一起,形成一片连续的指令区域,这就是代码段。这样CPU肯定能接连不断地执行下去。指令是由操作码和操作数组成的,这对于数据也一样,程序运行不仅要有操作码,也得有操作数,操作数就是指程序中的数据。把数据连续地并排在一起存储形成的段落,就称为数据段。

指令大小是由实际指令的操作码决定的,也就是说CPU在译码阶段拿到了操作码后,就知道实际指令所占的大小。其实说来说去,本质上就是在解释地址是怎么来的。这部分在第3章中的“什么是地址”节中有详解。

给大家演示个小例子,代码没有实际意义,是我随便写的,只是为方便大家理解指令的地址,代码如下。

code_seg.S1      mov ds,ax
2      mov ax,[var]  
3 label:
4      jmp label5      var dw 0x99

本示例一共就5行,简单纯粹为演示。将其编译为二进制文件,程序内容是:

8E D8 A1 07 00 EB FE 99 00

就这9个字节的内容,有没有觉得一阵晕炫。如果没有,目测读者兄弟的技术水平远在我之上,请略过本书。

其实这9个字节的内容就是机器码。为了让大家理解得更清晰,给大家列个机器码和源码对照表,见表1。

表1 机器码和源码对照表

地 址

机 器 码

源 码

00000000

8ED8

mov ds,ax

00000002

A10700

mov ax,[0x7]

00000005

EBFE

jmp short 0x5

00000007


var dw 0x99

00000008


var dw 0x99

表1第1行,地址0处的指令是“mov ds,ax”,其机器码是8ED8,这是十六进制表示,可见其大小是2字节。前面说过,下一条指令的地址是按照前面指令的尺寸排下来的,那第2行指令的起始地址是0+2=2。在第2行的地址列中,地址确实是2。这不是我故意写上去的,编译器真的就是这样编排的。第2列的指令是“mov ax,[0x7]”(0x7是变量var经过编译后的地址),其机器码是A10700,这是3字节大小。所以第3条指令的地址是2+3=5。后面的指令地址也是这样推算的。程序虽然很短,但麻雀虽小,五脏俱全,完美展示了程序中代码紧凑无隙的布局。

现在大伙儿明白为什么CPU能源源不断获取到指令了吧,如前所述,原因首先是指令是连续紧凑的,其次是通过指令机器码能够判断当前指令长度,当前指令地址+当前指令长度=下一条指令地址。

上面给出的例子,其指令在物理上是连续的,其实在CPU眼里,只要指令逻辑上是连续的就可以,没必要一定得是物理上连续。所以,明确一点,即使数据和代码在物理上混在一起,程序也是可以运行的,这并不意味着指令被数据“断开”了。只要程序中有指令能够跨过这些数据就行啦,最典型的就是用jmp跳过数据区。

比如这样的汇编代码:

1     jmp start    ;跳转到第三行的start,这是CPU直接执行的指令2     var dd  1    ;定义变量var并赋值为1。分配变量不是CPU的工作  
                  ;汇编器负责分配空间并为变量编址3     start:     ;标号名为start,会被汇编器翻译为某个地址4     mov ax,0    ;将ax赋值为0

这几行代码没有实际意义,只是为了解释清楚问题,咱们只要关注在第2行的定义变量var之前为什么要jmp start。如果将上面的汇编代码按纯二进制编译,如果不加第1行的jmp,CPU也许会发出异常,显示无效指令,也许不知道执行到哪里去了。因为CPU只会执行cs:ip中的指令,这两个寄存器记录的是下一条待执行指令的地址,下一个地址var处的值为1,显然我们从定义中看出这只是数据,但指令和数据都是二进制数字,CPU可分不出这是指令,还是数据。保不准某些“数据”误打误撞恰恰是某种指令也说不定。既然var是我们定义的数据,那么必须加上jmp start跳过这个var所占的空间才可以。

加个jmp指令,这样做一点都不影响运行,只不过这样写出来的程序,其中引用的地址大部分是不连续的,也就是程序在取地址时会显得跳来跳去。就美观层面上看,这样的结构显得很凌乱,不利于程序员阅读与维护。如果把第2行的var换到第1行,数据和代码就分开了,没有混在一起,标号都不用了,代码简洁多了,如下。

        var dd 1
        mov ax,0

做过开发的同学都清楚,尽量把同一属性的数据放在一起,这样易于维护。这一点类似于MVC,在程序逻辑中把模型、视图、控制这三部分分开,这样更新各部分时,不会影响到其他模块。

将数据和代码分开的好处有三点。

第一,可以为它们赋予不同的属性。

例如数据本身是需要修改的,所以数据就需要有可写的属性,不让数据段可写,那程序根本就无法执行啦。程序中的代码是不能被更改的,这样就要求代码段具备只读的属性。真要是在运行过程中程序的下一条指令被修改了,谁知道会产生什么样的灾难。

第二,为了提高CPU内部缓存的命中率。

大伙儿知道,缓存起作用的原因是程序的局部性原理。在CPU内部也有缓存机制,将程序中的指令和数据分离,这有利于增强程序的局部性。CPU内部有针对数据和针对指令的两种缓存机制,因此,将数据和代码分开存储将使程序运行得更快。

第三,节省内存。

程序中存在一些只读的部分,比如代码,当一个程序的多个副本同时运行时(比如同时执行多个ls命令时),没必要在内存中同时存在多个相同的代码段,这将浪费有限的物理内存资源,只要把这一个代码段共享就可以了。

后两点较容易理解,咱们深入讨论下第一点,不知您有没有想过,数据段或代码段的属性是谁给添加上的呢,是谁又去根据属性保护程序的呢,是程序员吗?是编译器吗?是操作系统吗?还是CPU一级的硬件支持?

首先肯定不是程序员,人家操作系统设计人员为了让程序员编写程序更加容易,肯定不会让他们分心去处理这些与业务逻辑无关的事。看看编译器为我们做了什么,它将程序中那些只读的代码编译出来后,放在一片连续的区域,这个区域叫代码段。将那些已经初始化的数据也放在一片连续的区域,这个区域叫数据段,那些具有全局属性的但又未初始化的数据放在bss段。总之,程序中段的类型可多了,用“readelf –e elf”命令便可以看到很多段的类型,感兴趣的读者请自行查阅。好了,编译器的工作到此就完事了,显然,数据段和代码段的属性到现在还没有体现出来。

先看CPU为我们提供了哪些原生的支持。在保护模式下,有这样一个数据结构,它叫全局描述符表(Global Descriptor Table,GDT),这个表中的每一项称为段描述符。先递归学习一下,什么是描述符?描述符就是描述某种数据的数据结构,是元信息,属于数据的数据。就像人们的身份证,上面有写性别、出生日期、地址等描述个人情况的信息。在段描述符中有段的属性位,在以后的章节中可以看到,其实是有2个,一个是S字段,占1bit大小,另外一个是占4bit大小的TYPE字段,这两个字段配合在一起使用就能组合出各种属性,如只读、向下扩展、只执行等。提供归提供,可得有人去填写这张表啊,谁来做这事呢,有请操作系统登场。

接着看操作系统为我们做了什么。

操作系统在让CPU进入保护模式之前,首先要准备好GDT,也就是要设置好GDT的相关项,填写好段描述符。段描述符填写成什么样,段具备什么样的属性,这完全取决于操作系统了,在这里大家只要知道,段描述符中的S字段和TYPE字段负责该段的属性,也就是该属性与安全相关。

说到这里,答案似乎浮出水面了。

(1)编译器负责挑选出数据具备的属性,从而根据属性将程序片段分类,比如,划分出了只读属性的代码段和可写属性的数据段。再补充一下,编译器并没有让段具备某种属性,对于代码段,编译器所做的只是将代码归类到一起而已,也就是将程序中的有关代码的多个section合并成一个大的segment(这就是我们所说的代码段),它并没有为代码段添加额外的信息。

(2)操作系统通过设置GDT全局描述符表来构建段描述符,在段描述符中指定段的位置、大小及属性(包括S字段和TYPE字段)。也就是说,操作系统认为代码应该是只读的,所以给用来指向代码段的那个段描述符设置了只读的属性,这才是真正给段添加属性的地方。

(3)CPU中的段寄存器提前***作系统赋予相应的选择子(后面章节会讲什么是选择子,暂时将其理解为相当于段基址),从而确定了指向的段。在执行指令时,会根据该段的属性来判断指令的行为,若有返回则发出异常。

总之,编译器、操作系统、CPU三个配合在一起才能对程序保护,检测出指令中的违规行为。如果GDT中的代码段描述符具备可写的属性,那编译器再怎么划分代码段都没有用,有判断权利的只有CPU。

好,现在大家对GDT有个感性认识,随着以后章节中讲GDT的时候,大家就会有深刻的理解了。

以上说明了程序按内容分段的原因,那么编译器编译出来的段和内存访问中的段是一回事吗?

其实算一回事,也不算一回事。怎么说呢,我觉得当初Intel公司在设计CPU时,其采用分段机制访问内存的原因,肯定不是为了上层软件的优美,毕竟那只是逻辑上的东西。那为什么也算一回事呢?

分析一下,编译出来的代码段是指一片连续的内存区域。这个段有自己的起始地址,也有自己的大小范围。用户进程中的段,只是为了便于管理,而编译器或程序员在“美学方面”做出的规划,本质上它并不是CPU用于内存访问的段,但它们都是描述了一段内存,而且程序中的段,其起始地址和大小可以理解为CPU访问内存分段策略中的“段基址:段内偏移地址”,这么说来,至少它们很接近了,让我们更近一步:程序是可以被人为划分成段的,并且可以将划分出来的段地址加载到段寄存器中,见下面的代码0-1。

代码1 程序分段

 1 section my_code vstart=0
 2   ;通过远跳转的方式给代码段寄存器CS赋值0x90
 3     jmp 0x90:start 4     start:       ;标号start只是为了jmp跳到下一条指令 5
 6   ;初始化数据段寄存器DS 7     mov ax,section.my_data.start 8     add ax,0x900   ;加0x900是因为本程序会被mbr加载到内存0x900处 9     shr ax,4     ;提前右移4位,因为段基址会被CPU段部件左移4位10     mov ds,ax1112   ;初始化栈段寄存器SS13     mov ax,section.my_stack.start14     add ax,0x900  ;加0x900是因为本程序会被mbr加载到内存0x900处15     shr ax,4    ;提前右移4位,因为段基址会被CPU段部件左移4位16     mov ss,ax17     mov sp,stack_top   ;初始化栈指针1819   ;此时CS、DS、SS段寄存器已经初始化完成,下面开始正式工作20     push word [var2]   ;变量名var2编译后变成0x421     jmp $2223   ;自定义的数据段24 section my_data align=16 vstart=025     var1 dd 0x126     var2 dd 0x62728   ;自定义的栈段29 section my_stack align=16 vstart=030     times 128 db 031 stack_top:  ;此处用于栈顶,标号作用域是当前section,
                 ;以当前section的vstart为基数32

代码1是实模式下运行的程序,其中自定义了三个段,为了和标准的段名(.code.data等)有所区别,这里代码段取名为my_code,数据段取名为my_data,栈段取名为my_stack。这段代码是由MBR加载到物理内存地址0x900后,mbr通过“jmp 0x900”跳过来的,我们的想法是让各段寄存器左移4位后的段基址与程序中各分段实际内存位置相同,所以对于代码段,希望其基址是0x900,故代码段CS的值为0x90(在实模式下,由CPU的段部件将其左移4位后变成0x900,所以要初始化成左移4位前的值)。但没有办法直接为CS寄存器赋值,所以在代码0-1开头,用“jmp 0x90:0”初始化了程序计数器CS和IP。这样段寄存器CS就是程序中咱们自己划分的代码段了。

在此提醒一下,各section中的定义都有align=16vstart=0,这是用来指定各section按16位对齐的,各section的起始地址是16的整数倍,即用十六进制表示的话,最后一位是0。所以右移操作如第9行的shr ax,4,结果才是正确的,只是把0移出去了。否则不加align=16的话,section的地址不能保证是16的整数倍,右移4位可能会丢数据。vstart=0是指定各section内数据或指令的地址以0为起始编号,这样做为段内偏移地址时更方便。具体vstart内容请参阅本书相应章节。

第6~10行是初始化数据段寄存器DS,是用程序中自已划分的段my_data的地址来初始化的。由于代码1本身是脱离操作系统的程序,是MBR将其加载到0x900后通过跳转指令“jmp 0x900”跳入执行的,所以要将my_data在文件内的地址section.my_data.start加上0x900才是最终在内存中的真实地址。右移4位的原因同代码段相同,都是CPU的段部件会自动将段基址左移4位,故提前右移4位。此地址作为段基址赋值给DS,这样段寄存器DS中的值是程序中咱们自己划分的数据段了。

第12~17行是初始化栈段寄存器,原理和数据段差不多,唯一区别是栈段初始化多了个针指针SP,为它初始化的值stack_top是最后一行,因为栈指针在使用过程中指向的地址越来越低,所以初始化时一定得是栈段的最高地址。

经过代码段、数据段、栈段的初始化,CPU中的段寄存器CS、DS、SS都是指向程序中咱们自己划分的段地址,之后CPU的内存分段机制“段基址:段内偏移地址”,段基址就是程序中咱们自己划分的段,段内偏移地址都是各自定义段内的指令和数据地址,由于在section中有vstart=0限制,地址都是从0开始编号的。所以,程序中的分段和CPU内存访问的分段又是一回事。

让我们对此感到疑惑的原因,可能是我们一般都是用高级语言开发程序,在高级语言中,程序分段这种工作不由我们控制,是由编译器在编译阶段完成的。而且现代操作系统都是在平坦模型(整个4GB空间为1个段)下工作,编译器也是按照平坦模型为程序布局,程序中的代码和数据都在同一个段中整齐排列。大家可以用readelf –e /bin/ls查看一下ls命令,结果太长,就不截图啦。咱们主要关注三段内容。

  • Section Headers:列出了程序中所有的section,这些section是gcc编译器帮忙划分的。

  • Program Headers:列出了程序中的段,即segment,这是程序中section合并后的结果。

  • Section to Segment mapping:列出了一个segment中包含了哪些section。

在Section Headers和Program Headers中您会发现,这些分段都是按照地址由低到高在4GB空间中连续整洁地分布的,在平坦模型下和谐融洽。

显然,不用程序员手工分段,并且采用平坦模型,这种操作上的“隔离”固然让我们更加方便,但也让我们更加感到进程空间布局的神秘。如果程序分段像代码0-1那样地直白、亲民,大家肯定不会感到迷惑了。其实我想说的是无论是否为平坦模型,程序中的分段和CPU中的内存分段机制,它们属于物品与容器的关系。

举个例子,程序中划分的段相当于各种水果,比如代码段相当于香蕉,数据段相当于葡萄,栈段相当于西瓜。CPU内存分段策略中的段寄存器相当于盛水果的盘子。可以用一个大盘子将各种水果都放进来,但依然是分门别类地摆放,不能失去美感混成一锅粥,这就是段大小为4GB的平坦模型。也可以把每种水果分别放在一个小盘子里一块儿端上来,这就是普通的分段模型,如图4所示。


1581837704831733.png

▲图4 程序中分段在平坦模型和 分段模型中的区别

总结一下,程序中的段只是逻辑上的划分,用于不同数据的归类,但是可以用CPU中的段寄存器直接指向它们,然后用内存分段机制去访问程序中的段,在这一点上看,它们很像相片和相框的关系:程序中的段是内存中的内容,相当于相片,属于被展示的内容,而内存分段机制则是访问内存的手段,相当于相框,有了相框,照片才能有地摆放。

我想大家应该已经搞清楚了内存分段和程序分段的关系,其实就是一回事,内存分段指的是处理器为访问内存而采用的机制,称之为内存分段机制,程序分段是软件中人为逻辑划分的内存区域,它本身也是内存,所以处理器在访问该区域时,也会采用内存分段机制,用段寄存器指向该区域的起始地址。

9 物理地址、逻辑地址、有效地址、线性地址、虚拟地址的区别

物理地址就是物理内存真正的地址,相当于内存中每个存储单元的门牌号,具有唯一性。不管在什么模式下,不管什么虚拟地址、线性地址,CPU最终都要以物理地址去访问内存,只有物理地址才是内存访问的终点站。

在实模式下,“段基址+段内偏移地址”经过段部件的处理,直接输出的就是物理地址,CPU可以直接用此地址访问内存。

而在保护模式下,“段基址+段内偏移地址”称为线性地址,不过,此时的段基址已经不再是真正的地址了,而是一个称为选择子的东西。它本质是个索引,类似于数组下标,通过这个索引便能在GDT中找到相应的段描述符,在该描述符中记录了该段的起始、大小等信息,这样便得到了段基址。若没有开启地址分页功能,此线性地址就被当作物理地址来用,可直接访问内存。若开启了分页功能,此线性地址又多了一个名字,就是虚拟地址(虚拟地址、线性地址在分页机制下都是一回事)。虚拟地址要经过CPU页部件转换成具体的物理地址,这样CPU才能将其送上地址总线去访问内存。

无论在实模式或是保护模式下,段内偏移地址又称为有效地址,也称为逻辑地址,这是程序员可见的地址。这是因为,最终的地址是由段基址和段内偏移地址组合而成的。由于段基址已经有默认的啦,要么是在实模式下的默认段寄存器中,要么是在保护模式下的默认段选择子寄存器指向的段描述符中,所以只要给出段内偏移地址就行了,这个地址虽然只是段内偏移,但加上默认的段基址,依然足够有效。

线性地址或称为虚拟地址,这都不是真实的内存地址。它们都用来描述程序或任务的地址空间。由于分页功能是需要在保护模式下开启的,32位系统保护模式下的寻址空间是4GB,所以虚拟地址或线性地址就是0~4GB的范围。转换过程如图5所示。


1581837682926694.png

▲图5 虚拟地址、物理地址等

10 什么是段重叠

其实上面已经提到了段重叠,也许有的读者已经明白了,但还是在此特意解释一下吧。

依然假设在实模式下(并不是说在保护模式下就不存在段重叠,只是这样就会少解释了相关数据结构,如段描述符,不过这不重要,原理是一样的),一个段最大为64KB,其大小由段内偏移地址寻址范围决定,也就是2的16次方。其起始位置由段基地址决定。CPU的内存寻址方式是:给我一个段基址,再给我一个相对于该段起始位置的偏移地址,我就能访问到相应内存。它并不要求一个内存地址只隶属于某一个段,所以在上面的图0-2中,欲访问内存0xC03,段基址可以选择0xC000xC010xC020xC03,只不过是段内偏移量要根据段基地址来调整罢了。用这种“段基地址:段内偏移”的组合,0xC00:30xC02:1是等价的,它们都访问到同一个物理内存块。但段的大小决定于段内偏移地址寻址范围,假设段A的段基址是从0xC00开始,段B的段基址是从0xC02开始,在16位宽度的寻址范围内,这两个段都能访问到0xC05这块内存。用段A去访问,其偏移为5,用段B去访问,其偏移量为3。这样一来,用段B和段A在地址0xC02之后,一直到段B偏移地址为0xfffe的部分,像是重叠在一起了,这就是段重叠了,如图6所示。


1581837663570084.png

▲图6 段重叠

11 什么是平坦模型

平坦模型是相对于多段模型来说的,所以说平坦模型指的就是一个段。比如在实模式下,访问超过64KB的内存,需要重新指定不同的段基址,通过这种迂回变通的方式才能达到目的。在保护模式下,由于其是32位的,寻址范围便能够达到4GB,段内偏移地址也是地址,所以也是32位。可见,在32位环境下用一个段就能够访问到硬件所支持的所有内存。也就是说,段的大小可以是地址总线能够到达的范围。既然平坦模型是相对于多段模型来说的,为什么不称为单段模型,而称为平坦呢,我估计很多读者已经明白了,用多个小段再加上不断换段基址的方式访问内存确实够麻烦的,可能换着换着就晕了,别忘记了,这种多段模型为了访问到1MB地址空间,还需要额外打开A20地址线呢,这种访存方式本身就是种补救措施,相当于给硬件打了个补丁,既然是补丁,访问内存的过程必然是不顺畅的。相对于那么麻烦的多段模型,平坦模型不需要额外打开A20地址线,不需要来回切换段基址就可以在地址空间内任意翱翔。如果把内存段比喻成小格子的话,平坦模型下的内存访问,没有众多小格子成为羁绊,可谓一路“平坦”。

所以“平坦”这两个字,突显了当时的程序员受多段模型折磨之苦,迫不及待地想表达其优势的喜悦之情。

12 cs、ds这类sreg段寄存器,位宽是多少

CPU中存在段寄存器是因为其内存是分段访问的,这是设计之初决定的,属于基因里的东西。前面已经介绍过了内存分段访问的方法,这里不再赘述。

CPU内部的段寄存器(Segment reg)如下。

(1)CS——代码段寄存器(Code Segment Register),其值为代码段的段基值。

(2)DS——数据段寄存器(Data Segment Register),其值为数据段的段基值。

(3)ES——附加段寄存器(Extra Segment Register),其值为附加数据段的段基值,称为“附加”是因为此段寄存器用途不像其他sreg那样固定,可以额外做他用。

(4)FS——附加段寄存器(Extra Segment Register),其值为附加数据段的段基值,同上,用途不固定,使用上灵活机动。

(5)GS——附加段寄存器(Extra Segment Register),其值为附加数据段的段基值。

(6)SS——堆栈段寄存器(Stack Segment Register),其值为堆栈段的段值。

32位CPU有两种不同的工作模式:实模式和保护模式。

每种模式下,段寄存器中值的意义是不同的,但不管其为何值,在段寄存器中所表达的都是指向的段在哪里。在实模式下,CS、DS、ES、SS中的值为段基址,是具体的物理地址,内存单元的逻辑地址仍为“段基值:段内偏移量”的形式。在保护模式下,装入段寄存器的不再是段地址,而是“段选择子”(Selector),当然,选择子也是数值,其依然为16位宽度。

可见,在32位CPU中,sreg无论是工作在16位的实模式,还是32位的保护模式,用的段寄存器都是同一组,并且在32位下的段选择子是16位宽度,排除了段寄存器在32位环境下是32位宽的可能,综上所述,sreg都是16位宽。


本文节选自《操作系统真象还原》

本文转载自异步社区。

原文链接:https://www.epubit.com/articleDetails?id=NC7E3EF9386F00001751B136010101E50

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。