第0章 一些你可能正感到迷惑的问题

第0章 一些你可能正感到迷惑的问题

正如计算机中数组下标是从0开始的,我们的内容也从0开始,尽量做到低基础学习(负责地说,不是0基础,而且还只是尽量),解释一些学习过程中经常被问到的问题。

0.1 操作系统是什么

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

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

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

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

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

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

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

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

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

0.2 你想研究到什么程度

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

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

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

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

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

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

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

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

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

0.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接口给咱们提供的接口。人家接口电路也有自己的思维(系统),看到寄存器中写了什么就做出相应的反应。接口提供接口,哈哈,有意思。不过这是人家的约定,没有约定就乱了,各干各的,大家都累,咱们只要遵循人家的规定就能访问成功。

0.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存储。

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

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

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

..\15-1444 图\0001.tif

▲图0-1 陷入内核

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

0.7 内存访问为什么要分段

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

..\15-1444 图\0002.tif

▲图0-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位段基址寄存器的容量),这个真的不用,段基址可以是任意的。这就是段可以重叠的原因。

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

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

..\15-1444 图\0003.tif

▲图0-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空间内自由翱翔了。

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

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

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

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

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

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

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

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

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

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

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

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

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

code_seg.S

1      mov ds,ax
2      mov ax,[var]  
3 label:
4      jmp label
5      var dw 0x99

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

8E D8 A1 07 00 EB FE 99 00

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

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

表0-1 机器码和源码对照表

地 址 机 器 码 源 码
00000000 8ED8 mov ds,ax
00000002 A10700 mov ax,[0x7]
00000005 EBFE jmp short 0x5
00000007   var dw 0x99
00000008  

表0-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。

代码0-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,ax
11
12   ;初始化栈段寄存器SS
13     mov ax,section.my_stack.start
14     add ax,0x900  ;加0x900是因为本程序会被mbr加载到内存0x900处
15     shr ax,4    ;提前右移4位,因为段基址会被CPU段部件左移4位
16     mov ss,ax
17     mov sp,stack_top   ;初始化栈指针
18
19   ;此时CS、DS、SS段寄存器已经初始化完成,下面开始正式工作
20     push word [var2]   ;变量名var2编译后变成0x4
21     jmp $
22
23   ;自定义的数据段
24 section my_data align=16 vstart=0
25     var1 dd 0x1
26     var2 dd 0x6
27
28   ;自定义的栈段
29 section my_stack align=16 vstart=0
30     times 128 db 0
31 stack_top:  ;此处用于栈顶,标号作用域是当前section,
                 ;以当前section的vstart为基数
32

代码0-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=16和vstart=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的地址来初始化的。由于代码0-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和segment的内容请参见本书相关章节。

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

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

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

..\15-1444 图\0004.tif

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

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

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

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

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

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

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

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

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

..\15-1444 改图\0005.tif

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

0.10 什么是段重叠

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

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

..\15-1444 图\0006.tif

▲图0-6 段重叠

0.11 什么是平坦模型

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

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

0.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位宽。

0.13 什么是工程,什么是协议

这两个小问题,一些非开发型技术人员经常会问到,做过开发的同学肯定了解。想想还是简单说一下吧(因为这名词似乎也没法说复杂)。

软件中的工程是指开发一套软件所需要的全部文件,包括配置环境。

在一般的集成开发环境中如eclipse或vc++,在程序的开始都是先建立一个project,这就是所谓的工程,它相当于一个大目录,以后写的代码都在这里面。

全部文件包含实际代码和环境配置两部分。实际代码部分,除了自己写的代码文件之外,一般都要包含其他同事写的头文件,若是与他方合作,还要包含第三方头文件。环境配置部分,一般是配置一些模板、库文件目录,具体还要根据所用的实际框架来配置,包含一些服务器的地址,端口之类也都在配置文件中。还是那句话,工程就是为了完成软件编写所涉及的全部相关文件。

协议是一种大家共同遵守的规约,主要用来实现通信、共享、协作;起初是为避免大家各干各的,无法彼此调用对方成果的情况,从而给大家统一一种接口、一组数据调用或者分析的约定。

大家达成一致后,都遵守这个约定开发自己的产品,别人只要也按照这个约定就能够享用自己的成果,从而实现了彼此兼容。只要是技术人员都对TCP/IP有所了解,这就是我们目前赖以生存的网络协议。根据OSI七层模型,它规定数据的第一层,也就是最外层物理层,这一层包含的是电路相关的数据。发送方和接收方都彼此认同最外层的就是电路传输用的数据。每一层中的前几个固定的字节必须是描述当前层的属性,根据此属性就能找到需要的数据。各层中的数据部分都是更上一层的数据,如第一层(物理层)中的数据部分是第二层(数据链路层)的属性+数据,第三层(网络层)的数据部分是第四层(传输层)TCP或UDP的属性+数据。各层都是如此,直到第七层(应用层)的数据部分才是真正应用软件所需要的数据。由此可见,对方一大串数据发过来后,经过层层剥离处理,到了最终的接收方(应用软件),只是一小点啦。

如图0-7所示,两边的应用程序互发数据时,其实发的就是最顶层的那一小点“数据”,每下一层,便加了各层的报文头,上层整个(包括自己的报文头和报文体)全部成了下一层的数据部分。

这样说似乎还是很抽象,具体地说,就是需要的数据是在偏移文件固定大小的字节处,这个固定字节是多少,就是协议中所规定的。不了解TCP/IP的同学可以参看各层报文格式,自行查阅吧。

..\15-1444 图\0007.tif

▲图0-7 OSI七层模型

0.14 为什么Linux系统下的应用程序不能在Windows系统下运行

其实,Windows下的程序也无法直接在Linux下运行。

对于这个问题,很多同学都会马上给出答案:格式不同。其实……答对啦,确实是格式不同,不过这只是一方面,还有另一方面,系统API不同,API即Application Programming Interface,应用程序编程接口。

先说说格式。其实格式也算是协议,就是在某个固定的位置有固定意义的数据。Linux下的可执行程序格式是elf,也就是 “Executable and Linking Format”平时咱们用readelf命令可以查看elf文件头,里面有节(section)信息、段(segment)信息、程序入口(entry_point)、哪个段由哪些节组成等信息。

而Windows下的可执行程序是PE格式(portable executable,可移植的可执行文件),因为我没了解过,所以具体文件头咱们就不关注了,有兴趣的同学自行查看。

那如果Linux支持了PE格式就可以运行Wndows程序了吗?也不行,因为在上面说过了,还有系统API不同。Linux中的API称为系统调用,是通过int 0x80这个软中断实现的。而Windows中的API是存放在动态链接库文件中的,也就是Windows开发人员常说的DLL,即Dynamic Link Library 的缩写。LL是一个库,里面包含代码 和数据,可供用户程序调用,DLL不是可执行文件 ,不能够单独运行。也就是说,Linux中的可执行程序获得系统资源的方法和Windows不一样,所以显然是不能在Windows中运行的。

除以上原因外,这还和编译器、标准库有关,不再列举。

0.15 局部变量和函数参数为什么要放在栈中

局部变量,顾名思义其作用域属于局部,并不是像static那样属于全局性的。全局的变量,意味着谁都可以随时随地访问,所以其放在数据段中。而局部变量只是自己在用,放在数据段中纯属浪费空间,没有必要,故将其放在自己的栈中,随时可以清理,真正体现了局部的意义。这个就是堆栈框架,提到了就说一点吧,栈由于是向下生长的,堆栈框架就是把esp指针提前加一个数,原esp指针到新esp指针之间的栈空间用来存储局部变量。解释一个概念,堆是程序运行过程中用于动态内存分配的内存空间,是操作系统为每个用户进程规划的,属于软件范畴。栈是处理器运行必备的内存空间,是硬件必需的,但又是由软件(操作系统)提供的。堆是堆,而堆栈就是栈,和堆没关系,只是都这么叫。栈和堆栈都是指的栈,在C程序的内存布局中,由于堆和栈的地址空间是接壤的,栈从高地址往低地址发展,堆是从低地址往高地址发展,堆和栈早晚会碰头,它们各自的大小取决于实际的使用情况,界限并不明朗,所以这可能是堆栈常放在一直称呼的原因吧。

函数参数为什么会放到栈区呢?第一也是其局部性导致的,只有这个函数用这个参数,何必将其放在数据段呢。第二,这是因为函数是在程序执行过程中调用的,属于动态的调用,编译时无法预测会何时调用及被调用的次数,函数的参数及返回值都需要内存来存储,如果是递归调用的话,参数及返回值需要的内存空间也就不确定了,这取决于递归的次数。也许这么说您也依然觉得费解,如果完全明白,需要了解一下编译原理,很多知识都是通过实践后才搞明白的。当然我不是说让您为了搞明白这个问题而去尝试写个编译器。

总之,在函数的编译阶段根本无法确定它会被调用几次,其参数和函数的返回地址也要内存来存储,所以也不知道其会需要多少内存。我想,即使神通广大的编译器设计者可以预测这些了,那提前准备好内存也是一种浪费,而且您想啊,在系统中可用内存紧缺的情况下,提前把内存分配给目前并不使用内存的进程(只因为要存储其函数参数),而眼前需要内存的程序若无内存可用,引用罗永浩老师的一句话:“我想不到比这个更伤感的事情了”所以编译器为了让世界更美好一些,选择将为函数参数动态分配内存,也就是在每次调用函数时才为它在栈中分配内存。

0.16 为什么说汇编语言比C语言快

首先说这是谬论(有没有想喷我的冲动?大人且慢,请听我慢慢道来)。

不管用什么语言,程序最终都是给CPU运行的,只有CPU才能让程序跑起来。CPU不知道什么是汇编语言、C语言,甚至Java、PHP、Python等,它根本不知道交给它的指令曾经经历过那么多的解释、编译工序。不管什么语言,编译器最终翻译出来的都是机器指令。所以在这一点来说,汇编语言编译器编译出来的机器指令和C编译器编译出来的机器指令无异。

那为什么还说汇编语言更快呢?

我觉得应该说汇编语言生成的指令数更少,从而“显得”执行得快,并不是汇编语言本身有多少威武霸气,而是因为汇编语言本身就是机器指令的符号化,意思是说,一个汇编语言中的符号对应一个机器指令,它们是一一对应的。用汇编语言写程序就相当于直接在写机器指令,汇编语言编译器并不会添加额外的语句,因此汇编语言写的程序会更直接,CPU不会因多执行一些无关的指令而浪费时间,当然会快。

再看看C编译器为咱们做了什么。为了让C程序员更加方便地编程,C编译器在背后做了大量的工作,不仅如此,出于通用性、易用性或者其他方面的考虑,C编译器往往会在背后加入额外的C语言代码来支撑,因此实际的C代码量就变得很大。另外在编译阶段,C代码会率先被编译成汇编代码,然后再由汇编器将汇编代码翻译成机器指令,由于C代码已经变得冗余了,编译出的汇编代码自然也会冗余,其机器指令也会多很多。

大多数人愿意用C语言写程序是因为C语言强大且更容易掌握。但这份优势是有代价的。C程序员不用考虑切换栈,不用考虑用哪个段。这些必须要考虑的事情,程序员不考虑,只好由编译器帮着考虑了。而且为了通用性、功能,甚至安全方面的考虑,自然在背后要多写一些代码。就拿打印字符串来说,C语言的printf(),这里面的工作可多了去了,不仅要检查打印的数据类型,还要负责格式,小数点保留位数……而在汇编语言中只要往显存地址处mov一个字符就行了,字符串也就是多几个mov操作而已。您说,C语言为了让开发者用得爽,自己在背后做了多少贡献。

总结:高级语言如C语言为了通用性等,需要兼顾的东西比较多,往往还加入了一些额外的代码,因此编译出来的汇编代码比较多,很多部分都是一些周边功能,并不是直接起作用的,不如用汇编语言直接写功能相关的部分效果来得更直接,C语言被编译成机器指令后,生成的机器指令当然也包括这些额外的部分,相当于多执行了一些“看似没用”的指令,因此会比直接用汇编语言慢。

0.17 先有的语言,还是先有的编译器,第1个编译器是怎么产生的

首先肯定的是先有的编程语言,哪怕这个语言简单到只有一个符号。先是设计好语言的规则,然后编写能够识别这套规则的编译器,否则若没有语言规则作为指导方向,编译器编写将无从下笔。

第1个编译器是怎么产生的?这个问题我并没有求证,不过可以谈下自己的理解,请大伙儿辩证地看。

这个问题属于哲学中鸡生蛋、蛋生鸡的问题,这种思维回旋性质的本源问题经常让人产生迷惑。可是现实生活中这样的例子太多了。

(1)英语老师教学生英语,学生成了英语老师后又可以教其他学生英语。

(2)写新的书需要参考其他旧书,新的书将来又会被更新的书参考,就像本书编写过程一样,要参考许多前辈的著作。

(3)用工具可以制造工具,被制造出来的工具将来又可以制造新的工具。

(4)编译器可以编译出新的编译器。

这种自己创造自己的现象,称为自举。

自举?是不是自己把自己举起来?是的,人是不能把自己举起来的,这个词很形象地描述了这类“后果必须有前因”的现象。

以上前三个列举的都是生活例子,似乎比第4个更容易接受。即使这样,对于前三个例子大家依然会有疑问。

(1)第一个会英语的人是谁教的?

(2)第一本书是怎样产生的?

(3)第一个工具是如何制造出来的?

其实看到第2个例子大家就可能明白了,世界上的第一本书,它的知识来源肯定是人的记忆,通过向个人或群众打听,把大家都认同的知识记录到某个介质上,这样第一本书就出生了。此后再记录新的知识时,由于有了这本书的参考,不需要重新再向众人打听原有知识了,从此以后便形成了书生书的因果循环。

从书的例子可以证明,本源问题中的第一个,都是由其他事物创建出来的,不是自己创造的自己。

就像先有鸡还是先有蛋一样,一定是先有其他生命体,这个生命体不是今天所说的鸡。伴随这个生命体漫长的进化中,突然有一天它具备了生蛋的能力(也许这个蛋在最初并不能孵化成鸡,这个生命体又经过漫长的进化,最终可以生出能够孵化成鸡的蛋),于是这个蛋可以生出鸡了。过了很久之后,才有的人类。人一开始接触的便是现在的鸡而不知道那个生命体的存在,所以人只知道鸡是由蛋生出来的。

很容易让人混淆的是编译C语言时,它先是被编译成汇编代码,再由汇编代码编译为机器码,这样很容易让人误以为一种语言是基于一种更底层的语言。

似乎没有汇编语言,C语言就没有办法编译一样。拿gcc来说,其内部确实要调用汇编器来完成汇编语言到机器码的翻译工作。因为已经有了汇编语言编译器,那何必浪费这个资源不用,自己非要把C语言直接翻译成机器码呢,毕竟汇编器已经无比健壮了,将C直接变成机器码这个难度比将C语言翻译为汇编语言大多了,这属于重新造轮子的行为。

曾经我就这样问过自己,PHP解释器是C语言写的,C编译器是汇编写的(这句话不正确),汇编是谁写的呢?后来才知道,编译器GCC其实是用C语言写的。乍一听,什么?用C语言写C编译器?自己创造自己,就像电影超验骇客一样。当时的思维似乎陷入了死循环一样,现在看来这不奇怪。其实编译器用什么语言写是无所谓的,关键是能编译出指令就行了。编译出的可执行文件是要写到磁盘上的,理论上,只要某个进程,无论其是不是编译器,只要其关于读写文件的功能足够强大,可以往磁盘上写任意内容,都可以生成可执行文件,直接让操作系统加载运行。想象一下,用Python写一个脚本,功能是复制一个二进制可执行文件,新复制出来的文件肯定是可以执行的。那Python脚本直接输出这样的一个二进制可执行文件,它自然就是可以直接执行的,完全脱离Python解释器了。

编译器其实就是语言,因为编译器在设计之初就是先要规划好某种语言,根据这个语言规则来写合适的编译器。所以说,要发明一种语言,关键是得写出与之配套的编译器,这两者是同时出来的。最初的编译器肯定是简单粗糙的,因为当时的编程语言肯定不完善,顶多是几个符号而已,所以难以称之为语言。只有功能完善且符合规范,有自己一套体系后才能称之为语言。不用说,这个最初的编译器肯定无法编译今天的C语言代码。编程语言只是文本,文本只是用来看的,没有执行能力。最初的编译器肯定是用机器码写出来的。这个编译器能识别文本,可以处理一些符号关键字。随着符号越来越多,不断地改进这个编译器就是了。

以上的符号就是编程语言。后来这个编译器支持的关键字越来越多了,也就是这个编译器支持的编程语言越发强大了,可以写出一些复杂的功能的时候,干脆直接用这个语言写个新的编译器,这个新的编译器出生时,还是需要用老的编译器编译出来的。只要有了新的编译器,之后就可以和老的编译器说拜拜了。发明新的编译器实际上就是为了能够处理更多的符号关键字,也就是又有新的开发语言了,这个语言可以是全新的,也可以是最初的语言,这取决于编译器的实现。这个过程不断持续,不断进化,逐渐才有了今天的各种语言解释器,这是个迭代的过程。

图0-8所示这张图片在网络上非常火,它常常与励志类的文字相关。起初看到这个雕像在雕刻自己时,我着实被感动了,感受到的是一种成长之痛。今天把它贴过来的目的是想告诉大家,起初的编译器也是功能简单,不成规范,然而经过不断自我“雕刻”,它才有了今天功能的完善。

图片 1

▲图0-8 雕刻(来源网络)

下面的内容我参考了别人的文章,由于找不到这位大师的署名,只好在此先献上我真挚的敬意,感谢他对求知者的奉献。

要说到C编译器的发展,必须要提到这两位大神——C语言之父Dennis Ritchie和Ken Thompson。Dennis和Ken在编程语言和操作系统的深远贡献让他们获得了计算机科学的最高荣誉——Dennis和Ken于1983年赢得了ACM图灵奖 。

编译器是靠不断学习、积累才发展起来的,这是自我学习的过程,下面来看看他们是如何让编译器长大的。

起初的C编译器中并没有处理转义字符,为叙述方便,我们现在称之为老编译器。如果待编译的代码文件中有字符串'\',在老编译器眼里,这就是'\'字符串,并不是转义后的单个字符'\'。为了表明编译器与作为其输入的代码文件的关系,我们称作为输入的代码文件为应用程序文件,毕竟虽然待编译的代码文件实现了一个编译器,但在编译器眼里,它只是一个应用程序级角色。例如,gcc –c a.c中,a.c就是应用程序文件。

现在想在编译器中添加对转义字符的支持,那就需要修改老编译器的源代码,假设老编译器的源代码文件名为compile_old.c。被修改后的编译器代码,已不属于老编译器的源代码,故我们命名其文件名为compile_new_a.c,下面是修改后的内容。

代码compile_new_a.c

图片 3

用老编译器将新编译器的源代码compile_new_a.c编译,生成可执行文件,该文件就是新的编译器,我们取名为新编译器_a。为了方便理清它们的关系,将它们列入表格中。

编译器自身源代码

编译器

应用程序源代码

输出文件名

compile_old.c

老编译器

compile_new_a.c

新编译器_a,支持'\'

这下编译出来的新编译器_a可以编译含有转义字符'\'的应用程序代码了,也就是说,待编译的文件(也就是应用程序代码)中,应该用'\'来表示'\'。而单独的字符'\'在新编译器_a中未做处理而无法通过编译。所以此时新编译器_a是无法编译自己的源代码compile_new_a.c的,因为该源文件中只是单个'\'字符,新编译器_a只认得'\'。

先更新它们的关系,见下表。

编译器自身源代码

编译器

应用程序源代码

输出文件名

compile_old.c

老编译器

compile_new_a.c

新编译器_a,支持'\'

compile_new_a.c

新编译器_a

compile_new_a.c

编译失败

也就是说,现在新编译器_a无法编译自己的源文件compile_new_a.c,只有老编译器才能编译它。

分析一下,新编译器_a无法正确编译自己的源文件compile_new_a.c,其原因是compile_new_a.c中'\'字符应该用转义字符的方式来引用,即所有用'\'的地方都应该替换为'\'。再啰嗦一下,请见新编译器_a的源代码compile_new_a.c,它只处理了字符串'\',单个'\'没有对应的处理逻辑。下面修改代码,将新修改后的代码命名为compile_new_b.c。

代码compile_new_b.c

图片 4

其实compile_new_b.c只是更新了转义字符的语法,这是新编译器_a所支持的新的语法,此文件是否是编译器源码没什么关系。所以下面还是以新编译器_a来编译新的编译器。

用新编译器_a编译此文件,将生成新编译器_b,将新的关系录入到表格中。

编译器自身源代码

编译器

应用程序源代码

输出文件名

compile_old.c

老编译器

compile_new_a.c

新编译器_a,支持'\'

compile_new_a.c

新编译器_a

compile_new_a.c

编译失败

compile_new_a.c

新编译器_a

compile_new_b.c

新编译器_b,支持'\'

现在想加上换行符'\n'的支持。

图片 5

由于现在编译器还不认识'\n',故这样做肯定不行,不过可以用其ASCII码来代替,将其命名为compile_new_c.c。

代码compile_new_c.c

图片 32

用新编译器_a来编译compile_new_c.c,将生成新编译器_c。

编译器自身源代码

编译器

应用程序源代码

输出文件名

compile_old.c

老编译器

compile_new_a.c

新编译器_a,支持'\'

compile_new_a.c

新编译器_a

compile_new_a.c

编译失败

compile_new_a.c

新编译器_a

compile_new_b.c

新编译器_b,支持'\'

compile_new_a.c

新编译器_a

compile_new_c.c

新编译器_c,间接支持“\n”

最后再修改compile_new_c.c为compile_new_d.c,将10用'\n'替代。

代码compile_new_d.c

图片 8

用新编译器_c编译compile_new_d.c,生成新编译器d,将直接识别'\n'。

编译器自身源代码

编译器

应用程序源代码

输出文件名

compile_old.c

老编译器

compile_new_a.c

新编译器_a,支持'\'

compile_new_a.c

新编译器_a

compile_new_a.c

编译失败

compile_new_a.c

新编译器_a

compile_new_b.c

新编译器_b,支持'\'

compile_new_a.c

新编译器_a

compile_new_c.c

新编译器_c,间接支持“\n”

compile_new_c.c

新编译器_c

compile_new_d.c

新编译器d,直接支持“\n”

编译器经过这样不断的训练,功能越来越强大,不过体积也越来越大了。

0.18 编译型程序与解释型程序的区别

解释型语言,也称为脚本语言,如JavaScript、Python、Perl、PHP、Shell脚本等。它们本身是文本文件,是某个应用程序的输入,这个应用程序是脚本解释器。

由于只是文本,这些脚本中的代码在脚本解释器看来和字符串无异。也就是说,脚本中的代码从来没真正上过CPU去执行,CPU的cs:ip寄存器从来没指向过它们,在CPU眼里只看得到脚本解释器,而这些脚本中的代码,CPU从来就不知道有它们的存在。这些脚本代码看似在按照开发人员的逻辑执行,本质上是脚本解释器在时时分析这个脚本,动态根据关键字和语法来做出相应的行为。因此脚本中若出现错误,先前正确的部分也会被正常执行,这和编译型程序有很大区别。

顺便猜想一下解释型语言是如何执行的。我们在执行一个PHP脚本时,其实就是启动一个C语言编写出来的解释器而已,这个解释器就是一个进程,和一般的进程是没有区别的,只是这个进程的输入则是这个php脚本,在php解释器中,这个脚本就是个长一些的字符串,根本不是什么指令代码之类。只是这种解释器了解这种语法,按照语法规则来输出罢了。

举个例子,假设下面是文件名为a.php的PHP代码。

<?php    这是php语法中的固定开始标签
    echo "abcd"; 输出字符串abcd
?>    固定结束标签

PHP解释器分析文本文件a.php时,发现里面的echo关键字,将其后面的参数获取后就调用C语言中提供的输出函数,如printf((echo的参数))。PHP解释器对于PHP脚本,就相当于浏览器对于JavaScript一样,不过这个可完全是我猜测的,我不知道PHP解释器里面的具体工作,以上为了说清楚我的想法,请大家辩证地看。

而编译型语言编译出来的程序,运行时本身就是一个进程。它是由操作系统直接调用的。也就是由操作系统加载到内存后,操作系统将CS:IP寄存器指向这个程序的入口,使它直接上CPU运行。总之调度器在就绪队列中能看到此进程。而解释型程序是无法让调度器“入眼”的,调度器只会看到该脚本语言的解释器。

0.19 什么是大端字节序、小端字节序

先说一下为什么会产生字节序的问题。

内存是以字节为单位读写的,其最小的读写单位就是字节。故如果在内存中只写入一个字节,一个内存的存储单元便可将其容纳了,只要访问这一内存地址就能够完整取出这1字节。可是1字节要能够表示的范围只有0~255(先只考虑无符号数),超过这个范围的数,只好用多个字节连在一起来表示。因此,在我们的32位程序中,定义的数据类型很多。1字节的数据类型只有char型,像int型要占4字节,double型要占用8字节。正如解决了一个问题又抛出了新的问题一样,解决了数值范围的问题,那带来的新的问题是这么多个字节该以怎样的顺序排放呢。一个超过255的数字必然要占用2个字节以上,这两个字节,在物理内存中,哪个在前?哪个在后?拿0x1234举例,数值中的高位12是放在内存的高地址处,还是低地址处?

于是就产生了这两种相反的排列顺序。

(1)小端字节序是数值的低字节放在内存的低地址处,数值的高字节放在内存的高地址。

(2)大端字节序是数值的低字节放在内存的高地址处,数值的高字节放在内存的低地址。

为了让大家理解得更直观,我在虚拟机bochs中操作一下,咱们看一下真正的0x12345678在内存中是怎样存储的,如图0-9所示。

上面的b 0x7c00是我在内存的0x7c00处插入了一个断点,其实这与要说明的问题无关,怕有同学好奇就稍带说一句,因为0x7c00是BIOS把mbr加载到内存后会跳转过去的地址,所以在此处能停下来。咱们只要关注xp/4 0x200000,这是显示以物理内存0x200000开始处的4个字节,可见其为00、00、00、00,地址是从左到右逐渐升高的,其中每一对00就占用1个字节,它们的值都是0。现在用setpmem命令在该地址处写入0x12345678后,再用xp/4命令查看内存地址0x200000处的内容,可见已经不是4个00了,由内存的低地址到高地址,依次变成了0x78、0x56、0x34、0x12。这说明bochs模拟的x86体系结构虚拟机是小端字节序,即数值上的低字节0x78在物理内存上的低地址,其他数值也依次符合小端字节序。

选择哪种字节序,这是硬件厂商考虑的问题,对于这种二选一的选择,选择了一方的时候,就必然丢了另一方。

看看这两种字节序的优势。

(1)小端:因为低位在低字节,强制转换数据型时不需要再调整字节了。

(2)大端:有符号数,其字节最高位不仅表示数值本身,还起到了符号的作用。符号位固定为第一字节,也就是最高位占据最低地址,符号直接可以取出来,容易判断正负。

简要说明一下小端的优势。因为在做强制数据类型转换时,如果转换是由低精度转向高精度,这数值本身没什么变化,如short 是2字节,将其转换为4字节的int类型,无非是由0x1234变成了0x00001234,数值上是不变的,只是存储形式上变了。如果转换是高精度转向低精度,也就是多个字节的数值要减少一些存储字节,这必然是要丢弃一部分数值。编译器的转换原则是强制转换到低精度类型,丢弃数值的高字节位,只保留数值的低字节,如图0-10所示。

图片 1

▲图0-9 内存中存储形式

图片 2

▲图0-10 强制类型转换与字节序

由图0-10上输出可见,0x12345678由4字节的int型强制转向了2字节的short型后,只保留了低字节的0x5678。

对于大端的优势,就硬件而言,就是符号位的判断变得方便了。最高位在最低地址,也就是直接就可以取到了,不用再跨越几个字节,减少了时钟周期。另外,对于人类来说,还是大端看上去顺眼,毕竟咱们存储0x12345678到内存时,它在内存中的存储顺序也是0x12345678,而不是0x78563412,这样看上去才直观。

常见CPU的字节序如下。

(1)大端字节序:IBM、Sun、PowerPC。

(2)小端字节序:x86、DEC。

ARM体系的CPU则大小端字节序通吃,具体用哪类字节序由硬件选择。

字节序不仅是在CPU访问内存中的概念,而且也包括在文件存储和网络传输中。bmp格式的图片就属于小端字节序,而jpeg格式的图片则为大端字节序,这没什么可说的,采用什么序列完全是开发者设计产品时的需要。

网络字节序就是大端字节序,所以在x86架构上的程序在发送网络数据时,要转换字节顺序。

关于字节序就介绍到这里,读者若觉得意犹未尽可以自行查阅。

0.20 BIOS中断、DOS中断、Linux中断的区别

在计算机系统中,无论是在实模式,还是在保护模式,在任何情况下都会有来自外部或内部的事件发生。如果事件来自于CPU内部就称为异常,即Exception。例如,CPU在计算算法时,发现分母为0,就抛出了除0异常。如果事件来自于外部,也就是该事件由外部设备发出并通知了CPU,这个事件就称为异常。

BIOS和DOS都是存在于实模式下的程序,由它们建立的中断调用都是建立在中断向量表(Interrupt Vector Table,IVT)中的。它们都是通过软中断指令int 中断号来调用的。

中断向量表中的每个中断向量大小是4字节。这4字节描述了一个中断处理例程(程序)的段基址和段内偏移地址。因为中断向量表的长度为1024字节,故该表最多容纳256个中断向量处理程序。计算机启动之初,中断向量表中的中断例程是由BIOS建立的,它从物理内存地址0x0000处初始化并在中断向量表中添加各种处理例程。

BIOS中断调用的主要功能是提供了硬件访问的方法,该方法使对硬件的操作变得简单易行。这句话是否也表明了不通过BIOS调用也是可以访问硬件的?必须是的,否则BIOS中断处理程序又是如何操作硬件呢?操作硬件无非是通过in/out指令来读写外设的端口,BIOS中断程序处理是用来操作硬件的,故该处理程序中一定到处都是in/out指令。

BIOS为什么添加中断处理例程呢?

(1)给自己用,因为BIOS也是一段程序,是程序就很可能要重复性地执行某段代码,它直接将其写成中断函数,直接调用多省心。

(2)给后来的程序用,如加载器或boot loader。它们在调用硬件资源时就不需要自己重写代码了。

BIOS是如何设置中断处理程序的呢?

BIOS也要调用别人的函数例程。

BIOS够底层吧?难道它还要依赖别人?是啊,BIOS也是软件,也要有求于别人。首先硬件厂商为了让自己生产的产品易用,肯定事先写好了一组调用接口,必然是越简单越好,直接给接口函数传一个参数,硬件就能返回一个输出,如果不易用的话,厂商肯定倒闭了。

那这些硬件自己的接口代码在哪里呢?

每个外设,包括显卡、键盘、各种控制器等,都有自己的内存(主板也有自己的内存,BIOS就存放在里面),不过这种内存都是只读存储器ROM。硬件自己的功能调用例程及初始化代码就存放在这ROM中。根据规范,第1个内存单元的内容是0x55,第2个存储单元是0xAA,第3个存储单位是该rom中以512字节为单位的代码长度。从第4个存储单元起就是实际代码了,直到第3个存储单元所示的长度为止。

有问题了,CPU如何访问到外设的ROM呢?

访问外设有两种方式。

(1)内存映射:通过地址总线将外设自己的内存映射到某个内存区域(并不是映射到主板上插的内存条中)。

(2)端口操作:外设都有自己的控制器,控制器上有寄存器,这些寄存器就是所谓的端口,通过in/out指令读写端口来访问硬件的内存。

控制显卡用的便是内存映射+端口操作的方式,这个以后会在操作显卡时介绍。

从内存的物理地址0xA0000开始到0xFFFFF这部分内存中,一部分是专门用来做映射的,如果硬件存在,硬件自己的ROM会被映射到这片内存中的某处,至于如何映射过去的,咱们暂时先不要深入了,这是硬件完成的工作。

如图0-11所示,BIOS在运行期间会扫描0xC0000到0xE0000之间的内存,若在某个区域发现前两个字节是0x55和0xAA时,这意味着该区域对应的rom中有代码存在,再对该区域做累加和检查,若结果与第3个字节的值相符,说明代码无误,就从第4个字节进入。这时开始执行了硬件自带的例程以初始化硬件自身,最后,BIOS填写中断向量表中相关项,使它们指向硬件自带的例程。

图片 1

▲图0-11 rom area

中断向量表中第0H~1FH项是BIOS中断。

有没有新的疑问?外设的内存是如何被映射的?我也不知道,这是早期硬件工程师们大胆且天才的做法,他们在很久以前就解决了。有知道的同学希望你告诉我,哈哈,在这里,我就先当它是我的公设了。

另外,上面说的是BIOS在填写中断向量表,那该表是谁创建的呢?答案就是CPU原生支持的,不用谁负责创建。之前我曾说过,软件是靠硬件来运行的,软件能实现什么功能,很大程度上取决于硬件提供了哪些支持。软件中只要执行int 中断向量号,CPU便会把向量号当作下标,去中断向量表中定位中断处理程序并执行。

如果哪位同学想查看下BIOS在中断向量表IVT中建立了哪些中断例程,可以在虚拟机bochs或qume中查看,我在这里贴个表,即表0-2,大家可以先了解下。

表0-2 中断向量表

中断向量

中断处理例程地址

中断描述

INT# 00

F000:FF53 (0x000fff53)

DIVIDE ERROR ; dummy iret

INT# 01

F000:FF53 (0x000fff53)

SINGLE STEP ; dummy iret

INT# 02

F000:FF53 (0x000fff53)

NON-MASKABLE INTERRUPT ; dummy iret

INT# 03

F000:FF53 (0x000fff53)

BREAKPOINT ; dummy iret

INT# 04

F000:FF53 (0x000fff53)

INT0 DETECTED OVERFLOW ; dummy iret

INT# 05

F000:FF53 (0x000fff53)

BOUND RANGE EXCEED ; dummy iret

INT# 06

F000:FF53 (0x000fff53)

INVALID OPCODE ; dummy iret

INT# 07

F000:FF53 (0x000fff53)

PROCESSOR EXTENSION NOT AVAILABLE ; dummy iret

INT# 08

F000:FEA5 (0x000ffea5)

IRQ0 - SYSTEM TIMER

INT# 09

F000:E987 (0x000fe987)

IRQ1 - KEYBOARD DATA READY

INT# 0a

F000:E9DF (0x000fe9df)

IRQ2 - LPT2

INT# 0b

F000:E9DF (0x000fe9df)

IRQ3 - COM2

INT# 0c

F000:E9DF (0x000fe9df)

IRQ4 - COM1

INT# 0d

F000:E9DF (0x000fe9df)

IRQ5 - FIXED DISK

INT# 0e

F000:EF57 (0x000fef57)

IRQ6 - DISKETTE CONTROLLER

INT# 0f

F000:E9DF (0x000fe9df)

IRQ7 - PARALLEL PRINTER

INT# 10

C000:014A (0x000c014a)

VIDEO

INT# 11

F000:F84D (0x000ff84d)

GET EQUIPMENT LIST

INT# 12

F000:F841 (0x000ff841)

GET MEMORY SIZE

INT# 13

F000:E3FE (0x000fe3fe)

DISK

INT# 14

F000:E739 (0x000fe739)

SERIAL

INT# 15

F000:F859 (0x000ff859)

SYSTEM

INT# 16

F000:E82E (0x000fe82e)

KEYBOARD

INT# 17

F000:EFD2 (0x000fefd2)

PRINTER

INT# 18

F000:969B (0x000f969b)

CASETTE BASIC

INT# 19

F000:E6F2 (0x000fe6f2)

BOOTSTRAP LOADER

INT# 1a

F000:FE6E (0x000ffe6e)

TIME

INT# 1b

F000:FF53 (0x000fff53)

KEYBOARD - CONTROL-BREAK HANDLER ; dummy iret

INT# 1c

F000:FF53 (0x000fff53)

TIME - SYSTEM TIMER TICK ; dummy iret

INT# 1d

0000:0000 (0x00000000)

SYSTEMDATA-VIDEO PARAMETER TABLES

INT# 1e

F000:EFDE (0x000fefde)

SYSTEM DATA - DISKETTE PARAMETERS

INT# 1f

C000:1378 (0x000c1378)

SYSTEM DATA - 8x8 GRAPHICS FONT

INT# 20

F000:FF53 (0x000fff53)

; dummy iret

INT# 21

F000:FF53 (0x000fff53)

; dummy iret

INT# 22

F000:FF53 (0x000fff53)

; dummy iret

INT# 23

F000:FF53 (0x000fff53)

; dummy iret

INT# 24

F000:FF53 (0x000fff53)

; dummy iret

INT# 25

F000:FF53 (0x000fff53)

; dummy iret

INT# 26

F000:FF53 (0x000fff53)

; dummy iret

INT# 27

F000:FF53 (0x000fff53)

; dummy iret

INT# 28

F000:FF53 (0x000fff53)

; dummy iret

INT# 29

F000:FF53 (0x000fff53)

; dummy iret

INT# 2a

F000:FF53 (0x000fff53)

; dummy iret

INT# 2b

F000:FF53 (0x000fff53)

; dummy iret

INT# 2c

F000:FF53 (0x000fff53)

; dummy iret

INT# 2d

F000:FF53 (0x000fff53)

; dummy iret

INT# 2e

F000:FF53 (0x000fff53)

; dummy iret

INT# 2f

F000:FF53 (0x000fff53)

; dummy iret

INT# 30

F000:FF53 (0x000fff53)

; dummy iret

INT# 31

F000:FF53 (0x000fff53)

; dummy iret

INT# 32

F000:FF53 (0x000fff53)

; dummy iret

INT# 33

F000:FF53 (0x000fff53)

; dummy iret

INT# 34

F000:FF53 (0x000fff53)

; dummy iret

INT# 35

F000:FF53 (0x000fff53)

; dummy iret

INT# 36

F000:FF53 (0x000fff53)

; dummy iret

INT# 37

F000:FF53 (0x000fff53)

; dummy iret

INT# 38

F000:FF53 (0x000fff53)

; dummy iret

INT# 39

F000:FF53 (0x000fff53)

; dummy iret

INT# 3a

F000:FF53 (0x000fff53)

; dummy iret

INT# 3b

F000:FF53 (0x000fff53)

; dummy iret

INT# 3c

F000:FF53 (0x000fff53)

; dummy iret

INT# 3d

F000:FF53 (0x000fff53)

; dummy iret

INT# 3e

F000:FF53 (0x000fff53)

; dummy iret

INT# 3f

F000:FF53 (0x000fff53)

; dummy iret

INT# 40

F000:EC59 (0x000fec59)

 

INT# 41

9FC0:003D (0x0009fc3d)

 

INT# 42

F000:FF53 (0x000fff53)

; dummy iret

INT# 43

C000:2578 (0x000c2578)

 

INT# 44

F000:FF53 (0x000fff53)

; dummy iret

INT# 45

F000:FF53 (0x000fff53)

; dummy iret

INT# 46

9FC0:004D (0x0009fc4d)

 

INT# 47

F000:FF53 (0x000fff53)

; dummy iret

INT# 48

F000:FF53 (0x000fff53)

; dummy iret

INT# 49

F000:FF53 (0x000fff53)

; dummy iret

INT# 4a

F000:FF53 (0x000fff53)

; dummy iret

INT# 4b

F000:FF53 (0x000fff53)

; dummy iret

INT# 4c

F000:FF53 (0x000fff53)

; dummy iret

INT# 4d

F000:FF53 (0x000fff53)

; dummy iret

INT# 4e

F000:FF53 (0x000fff53)

; dummy iret

INT# 4f

F000:FF53 (0x000fff53)

; dummy iret

INT# 50

F000:FF53 (0x000fff53)

; dummy iret

INT# 51

F000:FF53 (0x000fff53)

; dummy iret

INT# 52

F000:FF53 (0x000fff53)

; dummy iret

INT# 53

F000:FF53 (0x000fff53)

; dummy iret

INT# 54

F000:FF53 (0x000fff53)

; dummy iret

INT# 55

F000:FF53 (0x000fff53)

; dummy iret

INT# 56

F000:FF53 (0x000fff53)

; dummy iret

INT# 57

F000:FF53 (0x000fff53)

; dummy iret

INT# 58

F000:FF53 (0x000fff53)

; dummy iret

INT# 59

F000:FF53 (0x000fff53)

; dummy iret

INT# 5a

F000:FF53 (0x000fff53)

; dummy iret

INT# 5b

F000:FF53 (0x000fff53)

; dummy iret

INT# 5c

F000:FF53 (0x000fff53)

; dummy iret

INT# 5d

F000:FF53 (0x000fff53)

; dummy iret

INT# 5e

F000:FF53 (0x000fff53)

; dummy iret

INT# 5f

F000:FF53 (0x000fff53)

; dummy iret

INT# 60

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 61

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 62

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 63

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 64

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 65

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 66

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 67

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

INT# 68

F000:FF53 (0x000fff53)

; dummy iret

INT# 69

F000:FF53 (0x000fff53)

; dummy iret

INT# 6a

F000:FF53 (0x000fff53)

; dummy iret

INT# 6b

F000:FF53 (0x000fff53)

; dummy iret

INT# 6c

F000:FF53 (0x000fff53)

; dummy iret

INT# 6d

F000:FF53 (0x000fff53)

; dummy iret

INT# 6e

F000:FF53 (0x000fff53)

; dummy iret

INT# 6f

F000:FF53 (0x000fff53)

; dummy iret

INT# 70

F000:FE93 (0x000ffe93)

IRQ8 - CMOS REAL-TIME CLOCK

INT# 71

F000:E9D6 (0x000fe9d6)

IRQ9 - REDIRECTED TO INT 0A BY BIOS

INT# 72

F000:E9E5 (0x000fe9e5)

IRQ10 - RESERVED

INT# 73

F000:E9E5 (0x000fe9e5)

IRQ11 - RESERVED

INT# 74

F000:95C9 (0x000f95c9)

IRQ12 - POINTING DEVICE

INT# 75

F000:E2C7 (0x000fe2c7)

IRQ13 - MATH COPROCESSOR EXCEPTION

INT# 76

F000:9A60 (0x000f9a60)

IRQ14-HARD DISK CONTROLLER OPERATION COMPLETE

INT# 77

F000:E9E5 (0x000fe9e5)

IRQ15-SECONDARYIDE CONTROLLER OPERATION COMPLETE

INT# 78

0000:0000 (0x00000000)

此项为空,未添加中断处理例程

DOS是运行在实模式下的,故其建立的中断调用也建立在中断向量表中,只不过其中断向量号和BIOS的不能冲突。

0x20~0x27是DOS中断。因为DOS在实模式下运行,故其可以调用BIOS中断。

DOS中断只占用0x21这个中断号,也就是DOS只有这一个中断例程。

DOS中断调用中那么多功能是如何实现的?是通过先往ah寄存器中写好子功能号,再执行int 0x21。这时在中断向量表中第0x21个表项,即物理地址0x21*4处中的中断处理程序开始根据寄存器ah中的值来调用相应的子功能。

而Linux内核是在进入保护模式后才建立中断例程的,不过在保护模式下,中断向量表已经不存在了,取而代之的是中断描述符表(Interrupt Descriptor Table,IDT)。该表与中断向量表的区别会在讲解中断时详细介绍。所以在Linux下执行的中断调用,访问的中断例程是在中断描述符表中,已不在中断向量表里了。

Linux的系统调用和DOS中断调用类似,不过Linux是通过int 0x80指令进入一个中断程序后再根据eax寄存器的值来调用不同的子功能函数的。再补充一句:如果在实模式下执行int指令,会自动去访问中断向量表。如果在保护模式下执行int指令,则会自动访问中断描述符表。

以上主要对BIOS中断多介绍了一点,尽管对DOS说得不多,不过有了BIOS中断的表述,相信同学们对DOS中断调用也清楚了,其原理介于BIOS中断调用和Linux中断调用之间。后面在实现系统调用时,全是基于Linux思想的,所以在此对Linux系统调用的介绍点到为止。

0.21 Section和Segment的区别

C程序大体上分为预处理、编译、汇编和链接4个阶段。预处理阶段是预处理器将高级语言中的宏展开,去掉代码注释,为调试器添加行号等。编译阶段是将预处理后的高级语言进行词法分析、语法分析、语义分析、优化,最后生成汇编代码。汇编阶段是将汇编代码编译成目标文件,也就是转换成了目标机器平台上的机器指令。链接阶段是将目标文件连接成可执行文件。这里我们只关注汇编和链接这两个阶段。

在汇编源码中,通常用语法关键字section或segment来表示一段区域,它们是编译器提供的伪指令,作用是相同的,都是在程序中“逻辑地”规划一段区域,此区域便是节。注意,此时所说的section或segment都是汇编语法中的关键字,它们在语法中都表示“节”,不是段,只是不同编译器的关键字不同而已,关键字segment在语法中也被认为与section意义相同。首先汇编器根据语法规则,会将汇编源码中表示“节”的语法关键字section或segment在目标文件中编译成“节”,此“节”便是我们要讨论的section。经过汇编生成目标文件之后,由这些section或segment修饰的程序区域便成为了“节”(section)。但操作系统加载程序时并不关心节的数量和大小,操作系统只关心节的属性,因为程序必然是要加载到内存中才能运行的,而内存的访问会涉及到全局描述符表中段描述符的访问权限等属性,保护模式下对任何内存的访问都要经过段描述符才行。比如程序代码所在的段描述符权限属性必须是只读,数据所在的段描述符的权限属性必然是可读写,程序中那些只读的节(比如代码区域)必然不能指向可读写的段描述符,同样,程序中的数据也不能用只读权限的段描述符去访问。如果此时您对段描述符不了解,以后咱们在介绍保护模式下全局描述表时就明白了。操作系统在加载程序时,不需要对逐个节进行加载,只要给出相同权限的节的集合就行了,例如把所有只读可执行的节(如代码节.text和初始化代码节.init)归并到一块,所有可读写的节(如数据节.data和未初始化节.bss)归并到一块,这样操作系统就能为它们分配不同的段选择子,从而指向不同段描述符,实现不同的访问权限了。为了程序能在操作系统上运行,操作系统和编译器需要相互配合,此时汇编器只生成了目标文件,尚未链接,因此这个将“节”合并的工作是由链接器来完成的,链接器将目标文件中属性相同的节合并成一个大的section集合,此集合便称为segment,也就是段,此段便是我们平时所说的可执行程序内存空间中的代码段和数据段。

现在总结一下。

section称为节,是指在汇编源码中经由关键字section或segment修饰、逻辑划分的指令或数据区域,汇编器会将这两个关键字修饰的区域在目标文件中编译成节,也就是说“节”最初诞生于目标文件中。

segment称为段,是链接器根据目标文件中属性相同的多个section合并后的section集合,这个集合称为segment,也就是段,链接器把目标文件链接成可执行文件,因此段最终诞生于可执行文件中。我们平时所说的可执行程序内存空间中的代码段和数据段就是指的segment。

在大多数情况下,这两者都被混为一谈,现在咱们做个实际测试,通过实验结果来展示出这两者的不同。其实用一个测试样例就能得出结果,不过为了消除大家的疑虑,测试得更彻底一点,在这里给大家准备了两个小汇编文件,将它们编译链接后,我们通过readelf命令查看其信息来得出结论。上菜了。

文件1.asm

图片 4

这个汇编文件是在本地中声明了字符串,并调用外部的打印函数print,大家可以参考注释,弄个大概明白就行。

文件2.asm

..\15-1444 改图\p026.tif

在文件2.asm中声明了函数print。下面将这两个文件分别编译成elf格式,这样方便我们通过readelf来查看其编译结果。开始编译,链接成可执行文件12。

[work@localhost test]$nasm -f elf 1.asm -o 1.o
[work@localhost test]$nasm -f elf 2.asm -o 2.o
[work@localhost test]$ld 1.o 2.o -o 12

没问题,再执行一下。

[work@localhost test]$ ./12
Hello,world!

打印出了Hello,world!,结果正确。让我们用readelf查看下文件12的头信息,如图0-12所示。

图片 6

readelf输出信息1

图片 8

readelf输出信息2

▲图0-12 头信息

结果好长,为了方便查看,我对关键部分加以注释,如图0-13和图0-14所示。

在上面重点部分我都用文字标出了,要注意section headers的部分,此部分显示可执行文件中所有的section,也包括我们在两个汇编文件中用关键字section定义的部分。从第2个section到第5个section,是1.asm中的自定义数据section: file1data,自定义代码section: file1text和2.asm中的自定义数据section: file2data和自定义代码section: file2text。

再往下看Program Headers部分,此处一共有两个段,第一个段是我们的代码段,通过其Flg值为RE便可推断,只读(Readonly)可执行(Execute),其MemSiz为0x000c3。此段对应Section to Segment mapping部分中的第00个Segment,此segment中包括section: .text file1data file1text file2data file2text。

..\15-1444 改图\0013.tif

▲图0-13 节和段

..\15-1444 改图\0014.tif

▲图0-14 节合并到段

第二个段便是我们的数据段,但此数据段中只包含.bss节(section),它用于存储全局未初始化数据,故其Flg必然可读写,其属性为RW。此段MemSiz大小为0x40,即十进制的64,可见,这和1.asm中定义的bss大小一致,而在2.asm中未定义.bbs section,所以此bss指的就是1.asm中的定义。此段对应Section to Segment mapping部分中的第01 个Segment,而此segment只包括.bss节,独立成一个段了。

到此文件分析完毕,总结一下。

自定义的section名,会在elf的section header 中显示出来。下面是几个标准的section(节)名,不是segment(段)名,segment没有名称。

 节名     说明
.data   用于存入数据,可读可写
.text   用于存入代码,只读可执行
.bss    全局未初始化区域

在汇编代码中,若以标准节名定义section,如我们定义的.bss便是标准节名。编译器会按照以上说明中的要求使用section内的数据。

不管定义了多少节名,最终要把属性相同的section,或者编译认为可以放到一块的,合并到一个大的segment中,也就是elf中说的 program header 中的项。由此可见,某个节(section)属于某个段(segment),段是由节组成的。另外多说一句,最终给加载器用的也是program header中显示的段,这才是进程的资源,这部分内容将在加载内核时展开。在第3章中介绍了section在地址分配上的内容,大家有兴趣可以提前了解下。

0.22 什么是魔数

魔数,magic number,这让一部分人感觉到迷惑,也让另一部分人迷惑。哈哈,两个迷惑,把我们都搞迷惑了,作者你到底想表达什么意思啊。没错,其实魔数的本意就是让人感到迷惑的数,看到某个数,不知道其代表何意,用东北话说,都蒙圈了。一部分人对这个概念迷惑的原因是这有什么好解释的,一种司空见惯的东西,即使不知道是怎么来的,但由于大脑经常被其训练,对其已经形成深刻的印象,似乎理所当然地接受了。当我向别人请教一个类似的问题时,如果被回复“这是规定”时,我就很无语。任何规定都是出自于某种原因才做出的,很少有规定是靠拍脑门或抓阄决定的。就像国外的电视剧,一部称为一季,季是由season翻译过来的,表示季节,一个时段。一个季节过去了,这和电视剧整体情节暂告一段落是一样的,这较容易理解。

另一部分人感到迷惑的原因是真心想搞清楚概念是什么意思,我也属于这一类。

魔数,其实也称为神奇数字,我们大多数人是在学习计算机过程中接触到这个词的。它被用来为重要的数据定义标签,用独特的数字唯一地标识该数据,这种独特的数字是只有少数人才能掌握其奥秘的“神秘力量。”

对魔数简单的阐述就是:不明就理地出现一个数字,不知道其是什么意思,感觉看不透,猜不出,就像魔法一样很神秘。了解一定上下文的人肯定知道是什么意思,一般局外人绞尽脑汁也不解其意。就像小姑娘对着小伙子伸出大拇指和食指,小伙子马上就意会了,这是让我晚上8点在村口东边老槐树下见。

如果程序中出现这样的代码:

int a = 2014 – 1987;

根据直觉,似乎这是在求年龄,因为2014是和现在很接近的年份,而1987似乎是生日。但这只是主观估计,万一这两个数字表示的是这个月和上个月的电表计数呢,人家在查电费不行吗……修改一下代码。

#define birthday 1987;
int a = 2014 – birthday;

由于1987用了一个宏代替,即使变量名称不改为age,还叫作a,大家也明确了这是在求年纪呢。

故,直接出现的一个数字,只要其意义不明确,感觉很诡异,就称之为魔数。魔数应用的地方太多了,如elf文件头。

ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00

这个Magic后面的一长串就是魔数,elf解析器(通常是程序加载器)用它来校验文件的类型是否是elf。

主引导记录最后的两个字节的内容是0x55,0xaa,这表明这个扇区里面有可加载的程序,BIOS就用它来校验该扇区是否可引导。

有人说只要为这些数字赋予实际的意义不就行了吗。其实,无论怎么给这组陌生的数字赋予名称,它都不像熟悉的出生日期那样直观易懂(如对于19590318,不解释大家也会知道0318是3月18日),反而还要额外增加一些内容来解释,得不偿失,所以这就是魔数不得不存在的原因。

可见,计算机中处处是协议、约定。不过为了程序意义清晰可维护性强,尽量还是少用魔数。

0.23 操作系统是如何识别文件系统的

我们知道,一个硬盘上可以有很多分区,每个分区的格式又可以不同。就拿Linux来说,既能识别ext3,又能识别ext4。可能有同学会说,这两个分区的文件系统都是Linux自己专用的,当然认得自己的东西了。可是自己的东西也得有个辨别的地方,否则凭什么说“认得”呢。

其实这是之前介绍过的魔数的作用,文件系统也有自己的魔数,魔数的神秘力量在此施展了。各分区都有超级块,一般位于本分区的第2个扇区,比如若各分区的扇区以0开始索引,其第1个扇区便是超级块的起始扇区。超级块里面记录了此分区的信息,其中就有文件系统的魔数,一种文件系统对应一个魔数,比对此值便知道文件系统类型了。

0.24 如何控制CPU的下一条指令

其实此问题我一直犹豫要不要写出来,因为大部人都觉得这个问题有些匪夷所思,CPU是负责执行指令的,它会按照程序的执行流程走,此问题的目的其实就是想知道如何牵着CPU的鼻子走。当初我被问这个问题时也觉得很诧异,甚至我觉得自己可能没理解人家的意思。后来他这样跟我说:“CPU要执行的下一条指令是在CS:IP寄存器吧?”我说:“是啊”。他又问:“CS和IP寄存器,是用mov指令修改的吗?”我听后,顿时觉得他这个问题很有意义,暗自对他有些小敬佩,我相信很多人都没想过,CS和IP能不能用mov指令去修改。

是这样的,我们常说的用于存放下一条指令地址的寄存器称为程序计数器PC(Program Counter)。这个名词在我看来是个概念级别的内容,它只是CPU中有关下一条指令存放地址的统称,也就是说PC是用来表示下一条指令的存放地址,具体的实现形式不限,后面会有所讨论。

CPU按照指令集可以分为很多种,由于PC只是个概念,所以在不同种类的CPU中,有不同的实现。注意啦,这里的“不同种类”不是指CPU品牌,而是指CPU体系结构,如INTEL和AMD同属x86构架,如果您对此不了解,细心的我早已在下面为您准备好了体系结构、指令集的相关内容。由于此方面内容较独立,我专门将其组织成一个小节供大伙儿参考,如果您现在感兴趣,可以先参阅“指令集、体系结构、微架构、编程语言”这一节。

在x86体系结构的CPU中,也就是咱们大多数人使用的INTEL或AMD公司出品的桌面处理器,程序计数器PC并不是单一的某种寄存器,它是一种寄存器组合,指的段寄存器CS和指令指令寄存器IP。

CS和IP是CPU待执行的下一条指令的段基址和段内偏移地址,不能直接用mov指令去改变它们,我想可能的一个原因是:mov指令一次只能改变一个寄存器,不能同时将cs和ip都改变。如果只改变了其中一个会引起错误。如改变了cs的值后,ip的值还是原先cs段的偏移,很难保证新的cs段内的偏移地址ip处的指令是正确的。因此,有专门改变执行流的指令,如jmp、call、int、ret,这些指令可以同时修改cs和ip,它们在硬件级别上实现了原子操作。

以上说的是x86体系的CPU,其他类型的CPU是怎样的呢?这就取决于具体实现啦,咱们这里拿ARM举例,它的程序计数器有个专门的寄存器,名字就叫PC,想要改变程序流程,直接对该寄存器赋值便可。

与x86不同的是在ARM中可以用mov指令来修改程序流,在ARM体系CPU的汇编器中,寄存器的名称在汇编语言中是以“r数字”的形式命名的,例如汇编代码:mov pc,r0,表示将寄存器r0中的内容赋值给程序寄存器PC,这样就直接改变了程序的执行流。

总结一下,程序计数器PC负责处理器的执行方向,它只是获取下一条指令的方法形式,在不同体系结构的CPU中有不同的实现方法。

0.25 指令集、体系结构、微架构、编程语言

指令集是什么?表面上看它是一套指令的集合。集合的意思显而易见,那咱们说说什么是指令。

在计算机中,CPU只能识别0、1这两个数,甚至它都不知道数是什么,它只知道要么“是”,要么“不是”,恰好用0、1来表示这两种状态而已。

人发明的东西逃不出人的思维,所以,先看看我们人类的语言是怎么回事。

不同的语言对同一种事物有不同的名字,这个名字其实就是代码。比如说人类的好朋友:狗,咱们在中文里称之为狗,但在英文中它被称为dog,虽然用了两种语言,但其描述的都是这种会汪汪叫、对人类无比忠诚的动物。人是怎样识别小狗的呢?识别信息来自听觉、视觉等,这是因为人天生具备处理声音和图像的能力,能够识别出各种不同的声音和颜色不同的图像。可是计算机只能处理0、1这两个数,所以让计算机识别某个事物,只有用01这两个数来定义。也就是说,要用0、1来为各种事物编码。

为了更好地说明指令集,咱们这里不再用现有的语言举例子,当然也不是要自创指令集。下面举个简单的例子来演示指令集的模型。

咱们拿表达式A=B+C为例。假设A、B、C都是内存变量的值,它们的地址分别是0x3000、0x3004、0x3008。在此用Ra表示寄存器A,Rb表示寄存器B,Rc表示寄存器C。

完成这个加法的步骤是先将B和C载入到Ra和Rb寄存器中,再将两个寄存器的值相加后送入寄存器Ra,之后再将寄存器Ra的值写入到地址为0x3000的内存中。

步骤有了,咱们再设计完成这些步骤的指令。

步骤1:将内存中的数据载入到寄存器,咱们假设它的指令名为load。

操作码寄存器操作数1寄存器操作数2寄存器操作数3立即数

步骤2:两个寄存器的加法指令,假设指令名为add。

步骤3:将寄存器中的内容存储到内存,假设指令名为store。

以上指令名都是假设的,名字可以任意取,因为CPU不识别指令名。指令名是编译器用来给人看的,为的是方便人来编程,CPU它只认编码。目前CPU中的指令,无论是哪种指令集,都由操作码和操作数两部分组成(有些指令即使指令格式中没有列出操作数,也会有隐含的操作数)。咱们也采用这种操作码+操作数的思路,分别为这两部分编码。

咱们先为操作码设计编码。

操作码名称

二进制编码

load

00

add

01

store

10

接下来为操作数编码,操作数一般是立即数、寄存器、内存等,咱们这里主要是为寄存器编码。

寄存器名称

二进制编码

Ra

00

Rb

01

Rc

10

好啦,操作码和操作数都有了,其实指令集已经完成了。不过在一长串的二进制01中,哪些是操作码,哪些是操作数呢?这就是指令格式的由来啦。我们人为规定个格式,规定操作码和操作数的大小及位置,然后在CPU硬件电路中写死这些规则,让CPU在硬件一级上识别这些格式,从而能识别出操作码和操作数。

假设我们的指令格式最大支持三个寄存器参数和一个立即数参数。其中操作码和各寄存器操作数各占1字节,立即数部分占4字节。各条指令并不是完全按照此格式填充,不同的指令有不同的参数,只有操作码部分是固定的,其他操作数部分是可选的。当CPU在译码阶段识别出操作码后,CPU自然知道该指令需要什么样的操作数,这是写死在硬件电路中的,所以不同的指令其机器码长度很可能不一致。

为了演示指令集模型,我们在上面假设了寄存器名、指令名、格式。按理说这对于指令集来说已经全了,不过,为方便咱们了解编译器,不如咱们再假设个指令的语法吧,咱们这里学习Intel的语法格式:“指令目的操作数,源操作数”。目的操作数在左,源操作数在右,此赋值顺序比较直观。Intel想表达的是 a=b这种语序,如a=b,便是mov a,b。

以上三个步骤的机器码按照十六进制表示为:

步 骤 自定义的指令 十六进制机器码
1 load Rb,0x3004 000104300000
load Rc,0x3008 001008300000
2 add Ra,Rb,Rc 01000110
3 store 0x300c,Ra 10000c300000

以上自定义的指令便是按照咱们假设的语法来生成的。对于机器码的大小,由于指令不同,需要的操作数也不同,所以机器码大小也不同。另外,机器码中的立即数是按照x86架构的小端字节序写的,这一点大家要注意。小端字节序是数值中的低位在低地址,高位在高地址,数位以字节为单位。前面有一小节说明大小端字节序问题。

步骤2的机器码为01 00 01 10。操作码占1字节,CPU识别出第1字节的二进制01是add指令,知道此指令的操作数是3个寄存器,并且第1个寄存器操作数是目的寄存器,另外两个寄存器是源操作数(这都是我们假定的,并且是写死在硬件中的规则,不同的指令有不同的规则,您也可以创造出内存和寄存器混合作为操作数的加法指令)。于是到第2字节去读取寄存器编码,发现其值为二进制00,就是寄存器Ra对应的编码。接着到下一个字节处继续读出寄存器编码,发现是二进制01,也就是寄存器Rb,Rc同理。于是将寄存器Rb和Rc的值相加后存入到寄存器Ra。

步骤3中,机器码为10 00 0c300000,CPU读取机器码的第1 字节发现其为二进制10,知道其为指令store,于是便确定了,目的操作数是个立即数形式的内存地址,源操作数是个寄存器。接着到指令格式中的寄存器操作数1的位置去读取寄存器编码,发现其值为00,这就是寄存器Ra的编码。机器码中剩下的部分便作为立即数,这样便将寄存器Ra的值写入到内存0x0000300c中了。

以上指令集的模型,确实太过于简单了,也许称之为模型都非常勉强。现实中的指令格式要远远复杂得多。下面我们看看目前世面上的指令集有哪些。

最早的指令集是CISC(Complex Instruction Set Computer),意为复杂指令集计算机。从名字上看,这套指令集相当复杂,当初这套指令集问世的时候,它的研发者们都没想过要给它起名,只是因为后来出现了相对精简高效的指令集,所以人们为了加以区分,才将最初的这套相对复杂的指令集命名为CISC,而后来精简高效的指令集称为RISC(Reduced Instruction Set Computer)。

CISC和RISC并不是具体的指令集,而是两种不同的指令体系,相当于指令集中的门派,是指令的设计思想。举个例子,就像中医与西医,中医讲究从整体上调理身体,西医则更多的是偏向局部。这就是两种不同的医疗思路,类似于CISC和RISC这两种指令体系。那什么是指令集呢?拿中医举例,像华佗、张仲景这两位医圣,他们虽然都是基于中医的思想治病,但医术各有特色,水平也不尽相同,这就相当于不同的指令集。一会儿咱们会介绍具体的指令集。

为什么说CISC复杂呢?

首先,因为它是最早的指令集,当初都是摸着石头过河,肯定有一些瑕疵在里面。其次,当初的程序员都是用汇编语言开发程序,他们当然希望汇编语言强大啦,尽量多一些指令,尽量一个指令能多干几件事,所以指令集中的指令越来越多,越来越复杂。不过这样的好处是程序员同学很爽。最后,CISC是Intel使用的指令集,Intel公司在兼容性方面做得最好,指令集在发展的过程中,还要兼容过去有瑕疵的古董,以至于最后的指令集变得有点“奇形怪状”了。

作为后起之秀的RISC,借鉴了前辈CISC的经验,取其精华,弃其糟粕,当然要更好更轻量啦。它是怎么来的呢?

CISC不是做得很全很强吗,可是很多时候,程序员并不会用到那些复杂的指令和寻址方式,即使用到了,编译器有时候为了优化,未必“全”将其编译为复杂的形式。这就导致了CPU中的复杂的指令和寻址方式无用武之地。根据二八定律,指令集中20%的简单指令占了程序的80%,而指令集中80%的复杂指令占了程序的20%。根据这个特性,处理器及指令集被重新设计,保留了那些基本常用的指令,减少了硬件电路的复杂性。这样,大部分指令都能在一个时钟周期内完成,更有利于提升流水线的效率。而且,指令采用了定长编码,这样译码工作更容易了。由于其太优秀了,后来的处理器,如MIPS,ARM,Power都采用RISC指令体系,做得最好的就是MIPS处理器,它严格遵守RISC思想,业界公认其优雅。

我们常用的CPU是Intel和AMD公司的产品,它们用的指令集便是基于CISC思想的x86。AMD的x86指令架构是Intel授权给他们的,为区别于此,Intel在官方手册上称自己的指令集为IA32。

虽然AMD采用的也是x86指令集,但Intel可没把硬件实现方法也告诉AMD,否则AMD的CPU和Intel的CPU不就完全一样了吗,人家Intel也不肯呢。指令集是一套约定,里面规定的是有哪些指令、指令的二进制编码、指令格式等,如何实现这套约定,这是硬件自己的事。打个比方,这就像和朋友约好了在某餐厅吃饭,咱是坐车去,还是走着去,这是咱们的事,与吃饭是无关的。说白了,在Intel的CPU上运行的软件也能够在AMD的CPU上运行,原因就是它们共用了同用一套指令集,也就是对二进制编码达成了共识。它们面对相同的需求,可能采取了不同的行动,但都完成了任务。比如机器码是b80000,Intel的CPU经过译码,知道这是将0赋值给寄存器ax,相当于汇编语言mov ax,0。AMD的CPU在译码时,也得将此机器码认为是将0赋值给寄存器ax。至于它们在物理上是怎么将0传入寄存器ax中的,这是它们各自实现的方式,与指令集无关。它们各自实现的方式,就叫微架构。

总结一下,指令集是具体的一套指令编码,微架构是指令集的物理实现方式。

发展到后来,x86指令集越来越复杂。它本属于CISC体系,但由于效率低下,最终在其内部实现上采取了RISC内核,即一条CISC指令在译码时,分解成多条RISC指令,这样其执行效率便可与RISC媲美啦。

目前市面上常见的指令集有五种,除x86是CISC指令体系外,ARM、MIPS、Power、C6000都是RISC指令体系的指令集。

CPU与指令集是对应的,一种CPU只能识别一种指令集,所以很多CPU都以其支持的指令集来称呼。比如ARM、MIPS,它们本身是CPU名称,又是指令集名称。

ARM主要用在手机中,作为手机的处理器。Power是IBM用于服务器上的处理器。C6000是数字信号处理器,广泛用于视频处理。而MIPS虽然本身很优秀,但其在各领域起步都较晚,并没有广泛应用的领域。

由于MIPS本身的优越性,龙芯用的就是mips指令集,有没有人问,为什么咱们自主研发的CPU还要用人家国外的指令集?就不能也研发出一套指令集吗?能倒是能,不过语言不通用。就像我自己可以发明一门语言,语言本身没什么问题,问题是我用自己发明的语言和别人交流,谁听得懂呢,谁又愿意去学这门语言呢?大家都很忙,不通用的东西没人愿意花精力去学。如果龙芯也自立门户创造新的指令集,那有谁愿意给它写编译器呢?即使有了编译器,操作系统也要重新编译发布,应用程序也要重新编译发布,指令集背后不仅是个计算机生态链,更重要的是全球经济链。

平时所说的编程语言,虽然其上层表现各异,归根结底是要在具体的CPU上运行的,所以必须由编译器按照该CPU的指令集,翻译成符合该CPU的指令。说到这,不得不说一下交叉编译,本质上交叉编译就是用在A平台上运行的编译器,编译出符合B平台CPU指令集的程序,编译出的程序直接能在B平台上运行啦。这里的平台指的就是CPU指令体系结构。

0.26 库函数是用户进程与内核的桥梁

在讨论此问题之前,我们应该明白此问题的始作俑者是操作系统本身。我们用了操作系统,就理应遵守它的规范。任何操作系统都有自己的一套做事规则,在其上的所有应用程序,都按照它定下的规矩做事。

我们讨论的环境是Linux,所以,以下所有的内容都是在Linux系统的规则之中讨论,我们所讨论的内容便是搞清楚这些规则。

在Linux下C编程时,我们写的程序通常是用户级程序。为了输出文本,我们一般会在文件开始include <stdio.h>,这样程序就可以使用printf这样的函数完成打印输出。这背后的原理是什么?为什么简单包含stdio.h后就能够打印字符呢?

揭晓这些答案必须要交待一个事实,用户程序不具备独立打印字符的功能,它必须借助操作系统的力量才可以,如何借助呢?操作系统提供了一套系统调用接口,用户进程直接调用这些接口就行啦。简单来说,接口就是某个功能模块的入口,通过接口给该模块一个输入,它就返回一个输出,模块内部实现的过程就像个黑盒子一样,咱们看不到,也无需关心。我们能够打印字符的原因就是调用了系统调用,但是大家确实没有亲手写下调用系统调用的代码(后面章节会说),这就是库函数的功劳,它帮你写下了这些。

但我们并没有看到库函数的实现,我们只是包含了所需要的库函数所在的头文件,该头文件中有这样一句函数的声明。比如printf函数所在的头文件是stdio.h,该文件位于磁盘/usr/include/目录下,其中第361行是对printf的声明。

extern int printf (__const char *__restrict __format,...);

注意上面括号中的“…”不是我人为加上的省略号,并不是函数声明太长我省略了,这是变长参数的语法。有了这句声明,咱们可以直接把它贴在调用printf的文件中就可以啦,不用把整个stdio.h包含进来了,毕竟里面声明的函数太多了,stdio.h文件共942行,无关的内容太多会给我们带来困扰。

头文件被包含进来后,其内容也是原样被展开到include所在的位置,就是把整个头文件中的内容挪了过来,所以在头文件中的内容是什么都可以,未必一定要是函数声明,你愿意的话完全可以把函数定义在头文件中,而且也可以不用.h作为文件名。来,咱们做个实验。

func_inc.d

1 void myfunc(char* str){
2     printf(str);
3 }

您看,我们的测试文件名为func_inc.d,它甚至都不是以.c结尾的。说明include指令不关心所包含的文件名是啥,只是原方不动地将所包含的文件内容在此处展开。它只包含这三行代码。再看函数main.c。

main.c

1 extern int printf (__const char *__restrict __format,...);
2 #include "func_inc.d"
3
4 void main() {
5    myfunc("hello world\n");
6 }

main.c中第1行声明了外部函数printf,平时我们include <stdio.h>就是这个目的,只不过咱们这里让其精简了。

第2行将func_inc.d包含进来,之后第4~6行调用定义在func_inc.d中的myfunc函数进行打印。

不说别的,先看执行结果,如图0-15所示。

图片 2

▲图0-15 包含其他文件运行结果

为了证明include指令确实与所包含的文件名无关,咱们看看预处理后的文件内容。gcc编译时加-E参数就可以获取预处理后的文件内容。

[work@localhost tmp]$ gcc  -E main.c
# 1 "main.c"
# 1 "<built-in>"
# 1 "<命令行>"
# 1 "main.c"
extern int printf (__const char *__restrict __format, ...);
# 1 "func_inc.d" 1
void myfunc(char* str){
    printf(str);
}
# 3 "main.c" 2
void main() {
   myfunc("hello world\n");
}
[work@localhost tmp]$

您看到了,确实include功能只是将文件搬运过来。另外说明一下,如果main.c中添加了include<stdio.h>,此处通过-E生成的文件可老长了,所以咱们只加了printf函数的声明。

到现在为止,似乎还没有进入正题,只是想告诉大家头文件中可以写任何内容,甚至是函数体。

一下子就进入正题了,再交待另外一个事实,函数一定要有函数体才能被调用,必须有相应的函数实现,仅仅凭个头文件中的声明肯定是不行的。

如果在头文件中定义的是printf函数的实现,也许就容易理解头文件帮我们做了什么,可是事实不是这样的,头文件中一般仅仅有函数声明,这个声明告诉编译器至少两件事。

(1)函数返回值类型、参数类型及个数,用来确定分配的栈空间。

(2)该函数是外部函数,定义在其他文件,现在无法为其分配地址,需要在链接阶段将该函数体所在的目标文件一同链接时再安排地址。

这第二件事是我们所说的重点。

如果预处理后,主调函数所在的文件中找不到所调用函数的函数体,一定要在链接阶段把该函数体所在的目标文件链接进来,否则程序在道理上都讲不通,怎么能通过编译呢。

您看到了,main.c中我把func_inc.d包含进来,include后面并不是尖括号而是双引号“?”,这用的是自定义文件的包含,并不是包含标准文件(也就是平时我们所说的标准库头文件)。如果用了尖括号,系统就会到默认路径下去搜索该头文件。搜索到头文件后,找到其中被调函数的声明,再到另一默认文件中找该函数体的实现。

另一默认文件,按理来说应该是目标文件。它到底在哪里呢?

gcc编译时加-v参数会将编译、链接两个过程详细地打印出来,如图0-16所示。

图片 6

▲图0-16 gcc编译、链接过程

gcc内部也要将C代码经过编译、汇编、链接三个阶段。

(1)编译阶段是将C代码翻译成汇编代码,由最上面的框框中的C语言编译器cc1来完成,它将C代码文件main.c翻译成汇编文件ccymR62K.s。

(2)汇编阶段是将汇编代码编译成目标文件,用第二个框框中的汇编语言编译器as完成,as将汇编文件ccymR62K.s编译成目标文件cc0yJGmy.o。

(3)链接阶段是将所有使用的目标文件链接成可执行文件,这是用左边最下面框框中的链接器collect2来完成的,它只是链接命令ld的封装,最终还是由ld来完成,在这一堆.o文件中,有咱们上面的目标文件cc0yJGmy.o。

以上我们想展开说的是第3点:链接阶段。

大家看到了,实际参与链接的有多个.o文件,这些都是目标文件,也就是函数体所在的文件。printf的函数体就在这里面其中某个.o文件中,而且,printf中也要调用其他函数,这些被调用的函数也分布在这些.o文件之中。

这些咱们不认识的.o文件从哪来?为什么链接器要链接它们?

大家看中间框框中的LIBRARY_PATH,这是个库路径变量,里面存储的是库文件所在的所有路径,这就是编译器所说的标准库的位置,自动到该变量所包含的路径中去找库文件。以上所说的.o文件就是在这些路径下找到的。

不知道大家注意到了没有,在图-16中的链接阶段,链接器collect2的参数除了有咱们的main.c生成的目标文件cc0yJGmy.o以外,还有以下这几个以crt开头的目标文件:crt1.o,crti.o,crtbegin.o,crtend.o,crtn.o。

crt是什么?CRT,即C Run-Time library,是C运行时库。

什么是运行时库?

运行时库是程序在运行时所需要的库,该库是由众多可复用的函数文件组成的,由编译器提供。

所以,C运行时库,就是C程序运行时所需要的库文件,在我们的环境中,它由gcc提供。

大家这下应该明白了,我们在程序中简单地一句include <标准头文件>之所以有效,是因为编译器提供的C运行库中已经为我们准备好了这些标准函数的函数体所在的目标文件,在链接时默默帮我们链接上了。

顺便说一句,这些目标文件都是待重定位文件,重定位文件意思是文件中的函数是没有地址的,用file命令查看它们时会显示relocatable,它们中的地址是在与用户程序的目标文件链接成一个可执行文件时由链接器统一分配的。所以C运行时库中同样的函数与不同的用户程序链接时,其生成的可执行文件中分配给库函数的地址都可能是不同的。每一个用户程序都需要与它们链接合并成一个可执行文件,所以每一个可执行文件中都有这些库文件的副本,这些库文件相当于被复制到每个用户程序中。所以您清楚了,即使咱们的代码只有十几个字符,最终生成的文件也要几KB,就是这个道理。

还有一点内容要解释,前面说过用户程序要使用系统调用才能使用操作系统的功能,我们的func_inc.d中,也用到了printf函数,照我这么说的话,打印字符是内核的功能,那么生成的main.bin文件在执行printf函数时,内部一定会执行系统调用?没错!我们来验证一下。

我们可以用ltrace命令跟踪一下程序main.bin的执行过程就好啦。ltrace命令用来跟踪程序运行时调用的库函数,我们的printf函数绝对是个标准的库函数,让我们先尝尝鲜,看看不加参数执行时的输出是否是我们想要的。走起,如图0-17所示。

图片 2

▲图0-17 用ltrace跟踪进程调用的库函数

图0-17中用方框框出来的printf就是咱们调用的函数。大家机器上若没有这个命令,可以在http://www.ltrace.org/下载,目前最新版本是0.7.3,下载后的包是ltrace_0.7.3.orig.tar.bz2,我把它放在了ltrace目录中,大家可以执行这样的命令一次性搞定。

tar jxvf ltrace_0.7.3.orig.tar.bz2 && cd ltrace-0.7.3 && ./configure --prefix=/your_path/ltrace && make && make install

验证通过之后,咱们再看看printf用了哪些系统调用。-S参数查看系统调用,命令执行走起,如图0-18所示。

大家看到了方框中的SYS_write了吧,这个就是系统调用啦。Linux的系统调用号定义在/usr/include/asm/ unistd_32.h中,大家可以自行查看。

图片 4

▲图0-18 用ltrace跟踪进程系统调用

如果大家不想安装ltrace命令,可以用本机自带的strace命令代替,它是专门用来查看系统调用和信号的命令,不过它查看的并不是最终的系统调用,而是系统调用的封装函数。不解释啦,大家眼见为实吧,如图0-19所示。

图片 8

▲图0-19 strace实例

如图0-19所示,画框框的write是系统调用。原本输出的信息非常多,这里我只截了部分。write函数是系统调用SYS_write的封装,所以你懂了我更喜欢用ltrace的原因。

顺便说一句,大家可以用-e trace=write来限制只看write系统调用,免得输出无关的信息太多。

该说的都说啦,现在总结一下。

(1)操作系统有自己支持、加载用户进程的规则,而C运行时库是针对此操作系统的规则,为了让用户程序开发更加容易,用来支持用户进程的代码库。大家要明白,之所以我们写个程序又链接这又链接那的,完全是因为操作系统规定这样做,人在屋檐下,不得不低头。

(2)用户进程要与C运行时库的诸多目标文件链接后合并成一个可执行文件,也就是说我们的用户进程被加进了大量的运行库中的代码。

(3)C运行时库作用如其名,是提供程序运行时所需要的库文件,而且还做了程序运行前的初始化工作,所以即使不包含标准库文件,链接阶段也要用到c运行时库。

(4)用户程序可以不和操作系统打交道,但如果需要操作系统的支持,必须要通过系统调用,它是用户进程和操作系统之间的“钩子”,用户进程顶多算是个半成品,只有通过钩子挂上了操作系统,加了上所需要的操作系统的那部分代码,用户程序才能做完一件事,这才算完整,后面章节会有详解。

(5)尽管系统调用封装在库函数中,但用户程序可以直接调用“系统调用”,不过用库函数会比较高效(后面章节会有详解)。

0.27 转义字符与ASCII码

计算机世界中是以二进制来运行的,无论是指令、数据,都是以二进制的形式提交给硬件处理的,字符也一样,必须转换成二进制才能被计算机识别。所以各种各样的字符编码产生,简单来说,字符编码就是用唯一的一个二进制串表示唯一的一个字符。其中最著名的字符编码就是ASCII码。

ASCII码表中字符按可见分成两大类,一类是不可见字符,共33个,它们的ASCII码值是0~31和127,属于控制字符或通信专用字符。表中其余的字符是可见字符,它们的ASCII码值是32~126,属于数字、字母、各种符号。

对于计算机来说,任何字符都是用ASCII码表示的,人要是与计算机交流,虽然可以直接输入字符的ASCII码,但这太不人道了,计算机的发明是为了给人解决问题而并非制造问题。人习惯用所见即所得的方式使用字符,我要输入字符a的时候,直接按下键盘上的a键就行了,不要让我输入其ASCII码0x61。这要求是合理的,我们在键盘上键入的每个按键,都会由输入系统根据ASCII码表转换成对应的二进制ACSII码形式。这对普通用户来说够用了,他们很少写程序,可是作为程序员,我们经常要输出字符串,字符串中的可见字符直接从键盘敲入就行了,对于那些不可见字符,如回车换行符等,肯定不能用键盘在字符串中直接敲下一个回车键。

我们的问题是不可见字符如何写出来,也就是说我们在写字符串时,如何在其中加入不可见的控制符,这就需要编译器或解释器的支持了。

由于可见字符本身是看得见的,所见即所得,大家在使用中并不会有陌生感。对于那些不可见的控制字符,如果想使用它们时,该怎样表示它们呢?比如我就是要让程序输出一段话,在结束处换行。控制字符看不见摸不着,怎么写出来?所以在使用这些不可见字符时必须想办法让其可见,但又不能表示成其他可见字符,所以,只能让可见字符不表示自身了,哈哈,有点难是吗?这么艰巨的任务显然只用一个可见字符是不可能完成的,于是编译器想出了一个办法,它引用了另一个可见字符'\'来搭配其他可见字符,用这种可见字符组合的形式表达不可见字符。表面上看,字符'\'是让其他可见字符的意义变了,所以称'\'为转义字符,但本质上,这两个可见字符合起来才是完整的不可见字符,比如换行符'\n','\'和'n'放到一起才是换行符的意义,并不是因为'n'前面有个'\','n'就不再是'n',而是换行符,一定要清楚不是这样的。

ASCII码表中任何字符都是1个字节大小,在字符串中不可见字符虽然用“转义字符+可见字符”两个字符来表示,但这只是编译器为了让人们能写出不可见字符的方式,目的是让不可见字符变得“可见”,针对的是人,这样人们写程序时就能在字符串中用到不可见字符。不可见字符本身在编译后还是那1个字节的ASCII码。说白了,我们能够将不可见字符显示出来,原因就是编译器在给我们做支持,它将“转义字符+可见字符”这种形式的不可见字符转换成了该不可见字符的ASCII码。

为了说清楚,咱们以编译器为界限,在编译器左边的是人,这里的字符串是供人使用的,转义字符是存在于这一边的。编译器右边的是机器,这里的字符串使用的都是ASCII码。

在编译器左边:

char* ptr=”abc\n”;

此部分对应的内容是0x61 0x62 0x63 0x5c 0x6e。

编译器右边:

“abc\n”对应的内容是0x61 0x62 0x63 0xa

编译器的左边和右边是不一样的,区别是对“\n”的处理。编译器左边把它当成了两个字符,编译器右边把它当成了一个字符。想想也是,毕竟代码只是文本字符串,字符串”abc\n”中的'\'和'n'肯定是两个字符,编译器会把'\'和'n'组合到一起成为'\n'而解释成回车换行。可能您还是觉得怀疑,那我说一下编译器对字符串的解释过程。

编译器对字符串的处理一般是逐个字符处理的,这样便于处理转义字符。若发现字符为'\',就意识到这是转义字符,按常理说后面肯定要跟着另一可见字符,于是先不做任何处理,马上把后面的字符读进来,分析这两个字符的组合是哪个控制字符后一并处理。

咱们这里拿编译器解释字符串”abc\n”举例。

代码中的'\n'本身由两个字符'\'和'n'组成,'\n'是给人看的,用于在字符串中使用,其ASCII码是0xa,是给机器看的。在计算机中,所有的字符都已经成了ASCII码,字符串”abc\n”则变成了ASCII码:0x61 0x62 0x63 0x5c 0x6e。

编译器要逐个对比字符串中每个字符,前几个字符是'a'、'b'、'c',这都是可见字符,没有异议,直接处理。当发现字符是'\',知道这是转义字符,得知道'\'后面的字符是什么才能确定是哪个不可见字符,于是暂停处理'\',把后面的字符读进来,发现是'n',便知道这是'\n',表示一个换行符,于是将'\'和'n'用换行符的ASCII代替,原来字符串”abc\n”的ASCII码就变成了0x61 0x62 0x63 0xa。

说得足够多了,我也嫌自己啰嗦了,大家看以下的例子吧,就在图0-20中全部解释清楚了。

代码ASCII.c过于简单,纯粹是为演示。大家可能注意到了xxd.sh这个脚本,它就是xxd命令的封装,xxd命令可以逐字节查看文件,xxd.sh脚本内容如下。

#usage: sh xxd.sh 文件起始地址长度
xxd -u -a -g 1 -s $2 -l $3 $1
#以下为参数解释。
#-u  use upper case hex letters. Default is lower case.
#
#-a | -autoskip
#          toggle autoskip: A single '*' replaces nul-lines.  Default off.
#
#-g bytes | -groupsize bytes
#       separate the output of every <bytes> bytes (two hex characters or eight bit-digits each) by a whitespace.  Specify -g 0 to
#      suppress grouping.  <Bytes> defaults to 2 in normal mode and 1 in bits mode.  Grouping does not  apply  to  postscript  or
#      include style.
#
#-c cols | -cols cols
#                    format <cols> octets per line. Default 16 (-i: 12, -ps: 30, -b: 6). Max 256.
#
#-s [+][-]seek
#     start at <seek> bytes abs. (or rel.) infile offset.  + indicates that the seek is relative to the current stdin file position
#     (meaningless when not reading from stdin).  - indicates that the seek should be that many characters from the end of
#     the input (or if combined with +: before the current stdin file position).
#     Without -s option, xxd starts at  the  current file position.

图片 4

▲图0-20 查看编译后的转义字符

希望对大家理解转义字符有帮助。

0.28 MBR、EBR、DBR和OBR各是什么

这几个概念主要是围绕计算机系统的控制权交接展开的,整个交接过程就是个接力赛,咱们从头梳理。

计算机在接电之后运行的是基本输入输出系统BIOS,大伙儿知道,BIOS是位于主板上的一个小程序,其所在的空间有限,代码量较少,功能受限,因此它不可能一人扛下所有的任务需求,也就是肯定不能充当操作系统的角色(比如说让BIOS运行QQ是不可能的),必须采取控制权接力的方式,一步步地让处理器执行更为复杂强大的指令,最终把处理器的使用权交给操作系统,这才让计算机走上了正轨,从而可以完成各种复杂的功能,方便人们的工作和生活。采用接力式控制权交接,BIOS只完成一些简单的检测或初始化工作,然后找机会把处理器使用权交出去。交给谁呢?下一个接力棒的选手是MBR,为了方便BIOS找到MBR,MBR必须在固定的位置等待,因此MBR位于整个硬盘最开始的扇区。

MBR是主引导记录,Master或Main Boot Record,它存在于整个硬盘最开始的那个扇区,即0盘0道1扇区,这个扇区便称为MBR引导扇区。注意这里用CHS方式表示MBR引导扇区的地址,因此扇区地址以1开始,顺便说一句,LBA方式是以0为起始为扇区编址的,有关CHS和LBA的内容会在后面章节介绍。一般情况下扇区大小是512字节,但大伙儿不要把这个当真理,有的硬盘扇区并不是512字节。在MBR引导扇区中的内容是:

(1)446字节的引导程序及参数;

(2)64字节的分区表;

(3)2字节结束标记0x55和0xaa。

在MBR引导扇区中存储引导程序,为的是从BIOS手中接过系统的控制权,也就是处理器的使用权。任何一棒的接力都是由上一棒跳到下一棒,也就是上一棒得知道下一棒在哪里才能跳过去,否则权利还是交不出去。BIOS知道MBR在0盘0道1扇区,这是约定好的,因此它会将0盘0道1扇区中的MBR引导程序加载到物理地址0x7c00,然后跳过去执行,这样BIOS就把处理器使用权移交给MBR了。

既然MBR称为“主”引导程序,有“主”就得有“次”, MBR的作用相当于下一棒的引导程序总入口,BIOS把控制权交给MBR就行了,由MBR从众多可能的接力选手中挑出合适的人选并交出系统控制权,这个过程就是由“主引导程序”去找“次引导程序”,这么说的意思是“次引导程序”不止一个。也许您会问,为什么BIOS不直接把控制权交给“次引导程序”?原因是BIOS受限于其主板上的存储空间,代码量有限,本身的工作还做不过来呢,因此心有余而力不足。好啦,下面开始下一轮的系统控制权接力。不要忘了,MBR引导扇区中除了引导程序外,还有64字节大小的分区表,里面是分区信息。分区表中每个分区表项占16字节,因此MBR分区表中可容纳4个分区,这4个分区就是“次引导程序”的候选人群,MBR引导程序开始遍历这4个分区,想找到合适的人选并把系统控制权交给他。

通常情况下这个“次引导程序”就是操作系统提供的加载器,因此MBR引导程序的任务就是把控制权交给操作系统加载器,由该加载器完成操作系统的自举,最终使控制权交付给操作系统内核。但是各分区都有可能存在操作系统,MBR也不知道操作系统在哪里,它甚至不知道分区上的二进制01串是指令,还是普通数据,好吧,它根本分不清楚上面的是什么,谈何权利交接呢。

为了让MBR知道哪里有操作系统,我们在分区时,如果想在某个分区中安装操作系统,就用分区工具将该分区设置为活动分区,设置活动分区的本质就是把分区表中该分区对应的分区表项中的活动标记为0x80。MBR知道“活动分区”意味着该分区中存在操作系统,这也是约定好的。活动分区标记位于分区表项中最开始的1字节(有关分区内容,后面介绍分区的章节中会细说),其值要么为0x80,要么为0,其他值都是非法的。0x80表示此分区上有引导程序,0表示没引导程序,该分区不可引导。MBR在分析分区表时通过辨识“活动分区”的标记0x80开始找活动分区,如果找到了,就将CPU使用权交给此分区上的引导程序,此引导程序通常是内核加载器,下面就直接以它为例。

“控制权交接”是处理器从“上一棒选手”跳到“下一棒选手”来完成的,内核加载器的入口地址是这里所说的“下一棒选手”,但是内核加载器在哪里呢?虽然分区那么大,但MBR最想去看的是内核加载器,不想盲目地看看。因此您想到了,为了MBR方便找到活动分区上的内核加载器,内核加载器的入口地址也必须在固定的位置,这个位置就是各分区最开始的扇区,这也是约定好的。这个“各分区起始的扇区”中存放的是操作系统引导程序——内核加载器,因此该扇区称为操作系统引导扇区,其中的引导程序(内核加载器)称为操作系统引导记录OBR,即OS Boot Record,此扇区也称为OBR引导扇区。在OBR扇区的前3个字节存放了跳转指令,这同样是约定,因此MBR找到活动分区后,就大胆主动跳到活动分区OBR引导扇区的起始处,该起始处的跳转指令马上将处理器带入操作系统引导程序,从此MBR完成了交接工作,以后便是内核的天下了。

不过OBR中开头的跳转指令跳往的目标地址并不固定,这是由所创建的文件系统决定的,对于FAT32文件系统来说,此跳转指令会跳转到本扇区偏移0x5A字节的操作系统引导程序处。不管跳转目标地址是多少,总之那里通常是操作系统的内核加载器。

计算机历史中向来把兼容性放在首位,这才是计算机蒸蒸日上的原因。OBR是从DBR遗留下来的,要想了解OBR,还是先从了解DBR开始。DBR是DOS Boot Record,也就是DOS操作系统的引导记录(程序),DBR中的内容大概是:

(1)跳转指令,使MBR跳转到引导代码;

(2)厂商信息、DOS版本信息;

(3)BIOS参数块BPB,即BIOS Parameter Block;

(4)操作系统引导程序;

(5)结束标记0x55和0xaa。

在DOS时代只有4个分区,不存在扩展分区,这4个分区都相当于主分区,所以各主分区最开始的扇区称为DBR引导扇区。后来有了扩展分区之后,无论分区是主分区,还是逻辑分区,为了兼容,分区最开始的扇区都作为DOS引导扇区。但是其他操作系统如UNIX,Linux等为了兼容MBR也传承了这个习俗,都将各分区最开始的扇区作为自己的引导扇区,在里面存放自己操作系统的引导程序。由于现在这个“分区最开始的扇区”引导的操作系统类型太多了,而且DOS还退出历史舞台了,所以DBR也称为OBR。

这里提到了扩展分区就不得不提到EBR。当初为了解决分区数量限制的问题才有了扩展分区,EBR是扩展分区中为了兼容MBR才提出的概念,主要是兼容MBR中的分区表。分区是用分区表来描述的,MBR中有分区表,扩展分区中的是一个个的逻辑分区,因此扩展分区中也要有分区表,为扩展分区存储分区表的扇区称为EBR,即Expand Boot Record,从名字上看就知道它是为了“兼容”而“扩展”出来的结构,兼容的内容是分区表,因此它与MBR结构相同,只是位置不同,EBR位于各子扩展分区中最开始的扇区(注意,各主分区和各逻辑分区中最开始的扇区是操作系统引导扇区),理论上MBR只有1个,EBR有无数个。有关扩展分区的内容还是要参见后面有关分区的章节,那里介绍得更细致。

现在总结一下。

EBR与MBR结构相同,但位置和数量都不同,整个硬盘只有1个MBR,其位于整个硬盘最开始的扇区——0道0道1扇区。而EBR可有无数个,具体位置取决于扩展分区的分配情况,总之是位于各子扩展分区最开始的扇区,如果此处不明白子扩展分区是什么,到了以后跟踪分区的章节中大伙儿就会明白。OBR其实就是DBR,指的都是操作系统引导程序,位于各分区(主分区或逻辑分区)最开始的扇区,访扇区称为操作系统引导扇区,即OBR引导扇区。OBR的数量与分区数有关,等于主分区数加逻辑分区数之和,友情提示:一个子扩展分区中只包含1 个逻辑分区。

MBR和EBR是分区工具创建维护的,不属于操作系统管理的范围,因此操作系统不可以往里面写东西,注意这里所说的是“不可以”,其实操作系统是有能力读写任何地址的,只是如果这样做的话会破坏“系统控制权接力赛”所使用的数据,下次开机后就无法启动了。OBR是各分区(主分区或逻辑分区)最开始的扇区,因此属于操作系统管理。

DBR、OBR、MBR、EBR都包含引导程序,因此它们都称为引导扇区,只要该扇区中存在可执行的程序,该扇区就是可引导扇区。若该扇区位于整个硬盘最开始的扇区,并且以0x55和0xaa结束,BIOS就认为该扇区中存在MBR,该扇区就是MBR引导扇区。若该扇区位于各分区最开始的扇区,并且以0x55和0xaa结束,MBR就认为该扇区中有操作系统引导程序OBR,该扇区就是OBR引导扇区。

DBR、OBR、MBR、EBR结构中都有引导代码和结束标记0x55和0xaa,因此很多同学都容易把它们搞混。不过它们最大的区别是分区表只在MBR和EBR中存在,DBR或OBR中绝对没有分区表。MBR、EBR、OBR的位置关系如图0-21所示。

..\15-1444 改图\0021.tif

▲图0-21 MBR、EBR、OBR位置关系

您看,MBR位于整个硬盘最开始的块,EBR位于每个子扩展分区,各子扩展分区中只有一个逻辑分区。MBR和EBR位于分区之外的扇区,而OBR则属于主分区和逻辑分区最开始的扇区,每个主分区和逻辑分区中都有OBR引导扇区。有关分区更详细的内容请参阅后面跟踪分区表的章节。

目录

相关技术

推荐用户