书名:操作系统真象还原
ISBN:978-7-115-41434-2
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
• 著 郑 钢
责任编辑 张 涛
• 人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
• 读者服务热线:(010)81055410
反盗版热线:(010)81055315
郑钢,毕业于北京大学,前百度运维高级工程师,对操作系统有深入的研究。好运动,喜钻研,热衷于尝试前沿技术,乐于分享学习成果。
本书共分16章,讲解了开发一个操作系统需要的技术和知识,主要内容有:操作系统基础、部署工作环境、编写MBR主引导记录、完善MBR错误、保护模式入门、保护模式进阶和向内核迈进、中断、内存管理系统、线程、输入输出系统、用户进程、完善内核、编写硬盘驱动程序、文件系统、系统交互等核心技术。
本书适合程序员、系统底层开发人员、操作系统爱好者阅读,也可作为大专院校相关专业师生用书和培训学校的教材。
本书面向操作系统基础知识薄弱,但又想把操作系统搞清楚、喜欢刨根问底的技术人,在此向你们致敬,本书用诙谐幽默的语言,把深奥的操作系统尽量讲解清楚,读者在轻松阅读中就学通了深奥的知识,是一本难得的好书。
多数学习操作系统的读者都会有这样的感受:
(1)“太难了,对于操作系统这个庞然大物我简直无从下手”;
(2)“很后悔选了这门课(大学一些专业中操作系统是选修课),甚至不想学习计算机了”;
(3)“上课完全听不懂,我都不想继续听下去了”;
(4)“即使实验做出来了,由于只是完成了局部功能,我依然不明白操作系统是怎样运行起来的,甚至不知道自己在做什么”。
以上的感受我都有过,坦白说,这门课并不是很难,但想把这门课完全搞明白真不容易。我是个喜欢刨根问底的人,为了弄清楚这背后的真相,我花了大量时间学习课程之外的内容,甚至付出了惨痛的代价——大学中第一次考试不及格,操作系统这门课我是第二次才考过的。这确实很“讽刺”——操作系统不及格的人在写操作系统书籍!但转念一想,考试过了的同学并不代表能够写出操作系统,因为试卷上并不是在考如何写一个操作系统。和技术能力相比,卷面成绩并不重要。
想象一下,如果是爱因斯坦那样的天才给我们讲物理知识,我们会觉得物理更容易理解吗?肯定是不会的,因为在爱因斯坦眼中比较容易的内容也许对我们来说非常深奥,他用B解释A的时候也许会让我们更迷惑,因为B我们也不懂,这就是基础的问题了。幸运的是阅读本书时读者只要有C语言和部分汇编语言的基础就行了,涉及的其他方面的知识我都会详细介绍,并以更易懂的方式去解释技术难点,读者不必担心看不懂本书。
回忆一下学车的经历:教练让学员先踩离合器再挂档,然后再踩油门,车子就开动啦。如果学员总是学不会这些,有可能是学员根本不知道什么是离合器,或者不知道离合器的作用是什么。即使把这些操作背下来,也会对驾车感到心有余而力不足,可见,只有了解了背后的原理,才会知道自己在做什么,驾车才变得游刃有余。
以上情况对我们学习操作系统来说也同样存在,比如当老师介绍中断发生时的上下文保护时,我们更多的疑问不是如何保存CPU的上下文数据,而是想知道为什么在不同的特权级下会使用不同的栈,这背后的原理是什么,并且这是如何做到的。
诸如此类的疑问需要了解硬件原生支持的运行机制,因为很多操作都是硬件自动完成的,比如处理器进入0特权级时,会自动在任务状态段TSS中获得0特权级的栈地址,这不需要人工干涉,完全由处理器维护。我们想知道的是,硬件在背后自动完成了哪些工作,这样才便于我们理解操作系统的全貌。
操作系统受制于硬件的支持,很大程度上它的能力取决于硬件的能力,因此,要想全面理解操作系统,不仅需要了解上层软件的算法、原理、实现,还要了解很多硬件底层的内容。和硬件相关的知识是在微机接口电路中讲解的,而绝大多数读者在学习这门课时,根本不知道它有何用,只有学习操作系统课程时才用到它,因此,本书内容兼顾相关的硬件知识。
除硬件外,本书还把操作系统中的理论付诸于实践,让读者真正学到包含在操作系统中的实实在在的技术,比如在代码中实现了著名的生产者消费者问题,还有进程、线性、阻塞、信号量、锁、文件系统、目录、shell、管道等。各个章节的代码都可独立运行,方便调试,本书更让读者有成就感的是,我们最终完成的一个操作系统总共代码量只有几千行左右,极大地减少了操作系统源码阅读的工作量。
操作系统还是比较庞大的,因此,大部分介绍操作系统原理的书中,对各个部分都是分拆出来介绍的,这导致我们学习操作系统时犹如盲人摸象、管中窥豹。本书的封面是一个完整的大象的拼图,就像封面展示的那样,本书内容我们不再局部学习,而是把所有局部还原成一个整体,做出一个真正的操作系统。
为了让读者不再惧怕操作系统,同时也为了完成我自己的心愿,我辞职专心进行本书的编写,在此期间也曾拒绝了多份回报丰厚的工作,现在想想真是疯狂……苦了我的父母和女朋友,在这里跟你们说声抱歉,你们“纵容”我的偏执,真心不容易,辛苦啦,我爱你们!
感谢我在北京大学就读期间的Linux内核课程老师(同时也是我的研究生导师)荆琦教授和操作系统课程老师陈向群教授,很荣幸能够成为您们的学生,时至今日我常常回想起课堂上您们言传身教并为我解答问题的身影,您们渊博的知识和教学上严谨的态度深深影响了我,仅以此书向我这两位恩师致谢。
感谢父母给予我的理解和宽容,以后我一定加倍努力回报您们的养育之恩!
最后,感谢女朋友给予我的陪伴和照顾,在写此书的过程中我深深体会到:爱并不仅仅体现在相信对方一定能成功,更多是体现在支持对方去做想做的事,即使失败了也不会嫌弃。尽管在这漫长枯燥的19个月当中,如果没有你的“唠唠叨叨”本书早就写完了,但恰恰是这种“唠唠叨叨”下的不离不弃让我相信这世上还有真爱。
我爱你王小兔(对我女朋友的昵称),本书是我送给你的礼物。
本书中出现的“兄弟”“大伙儿”“同学”和“咱们”的称谓,是作者为了活泼写作风格故意为之,别无他意,在此说明一下。本书读者交流QQ群为:148177180,编辑联系邮箱:zhangtao@ptpress.com.cn。
作 者
于北京大学图书馆
正如计算机中数组下标是从0开始的,我们的内容也从0开始,尽量做到低基础学习(负责地说,不是0基础,而且还只是尽量),解释一些学习过程中经常被问到的问题。
我并没有给你提供教科书上对操作系统的定义,因为解释得太抽象了,看了之后似乎只是获得一些感性认识,好奇心强的读者反而会产生更多迷惑。为了说清楚问题,让我给您举个例子。
让我们扯点远的……在盘古开天之际,除动物以外,世界上只有土地、荒草、树木、石头等资源。人们为了躲避天灾、野兽攻击等危险,开始住进了山洞,为了获取食物,用石头和树木等材料打造一些武器。当时所有人都在做这些相同的事。这就是没有组织的人类社会,所有人都在重复“造轮子”。
后来各个地区有了自己权威性的部落,部落都专门找人打造武器,谁需要武器就直接申请领取便可,大部分人不需要自己打造武器了。后来嫌打猎太麻烦了,干脆养一些家畜好了,直接供给人们,谁需要可以过来交换。这就是把大家的重复性劳动集中到了一起,让人们可以专注于自己的事情。
再后来,部落之间为了通信,开始有信使了,这是最原始的通信方式。到后来发展到有社会组织,通信越来越频繁了,干脆搞个驿站吧,谁需要通信,直接写信,由驿站代为送达。
随着人口越来越多,社会组织需要了解到底有多少人,为了方便人口管理,于是就在各地建了“户籍办事”处,人们的生老病死都要到那里登记申报。
说到这我估计您已经猜出我所说的了,上面提到的部落其实就是最原始的操作系统雏形,它将大家都需要的工作找专人负责,大家不用重复劳动。而以上的社会组织其实就是代表现代操作系统,除了把重复性工作集中化,还有了自己的管理策略。
把上面的例子再具体一下,人们想狩猎时,可不可以自己先打造武器,然后拿着自己的武器去狩猎?当然可以,自己制造武器完全没有问题,但部落既然有现成的武器可用,何必自己再费事呢。另外,部落担不担心你随意制造武器会对他人造成伤害?当然会,所以部落不允许你自己制造武器了,人们只有申请的资格,给不给还是要看人家部落的意愿。这就是操作系统提供给用户进程一些系统调用,当用户进程需要某个资源时,直接调用便可,不用自己再费尽心思考虑硬件的事情了,由操作系统把资源获取到后交给用户进程,用户进程可以专注于自己的工作。但操作系统为了保护计算机系统不被损害,不允许用户进程直接访问硬件资源,比如用户进程将操作系统所占据的内存恶意覆盖了,操作系统也就不复存在了,没有操作系统的话,计算机将会瘫痪无法运作。
当人们想和远方的朋友说话时,虽然可以徒步走到亲朋好友身边再对其表达想说的话,但社会组织已经给提供了邮局和电话,何必自己再大老远跑一趟呢。这就是操作系统(社会组织)提供的资源。两个人想在一起生活,要不要一定先结婚呢?完全不用,领不领证都不会阻碍人们在一起生活,但是社会组织为了方便人口管理做了额外约束。不领证的话,至少社会组织无法预测未来人口数量趋势,无法做出宏观调控,甚至这是找到你家人的一种方法。这就如Linux系统中的内存管理,分别要记录哪些页是Active,哪些是“脏页”。不记录会不会影响程序执行,当然不会,记录这些状态还不是为了更好地管理内存吗。
以上说的社会组织和人们之间的关系,正是操作系统和用户进程的关系,希望大家能对操作系统有个初步印象,后面的实践中我们将实例化各个部分。
学无止境,学习没有说到头的那天。学习到任何程度都是存有疑惑的,就像中学和大学都讲物理,但学的深度不一样,各个阶段都会产生疑问。我们只是基于一些公认的知识,使其作为学习的起点,并以此展开上层的研究。
比如我对太空很感兴趣,大伙儿都知道地球围绕太阳做周期性公转,后来又知道电子围绕原子核来做周期性公转运动,这和地球绕太阳公转的行为如出一辙,甚至我在想太阳是不是相当于原子核,地球相当于一个电子,我们只是生活在一个电子上……而我们身体里有那么多的原子和电子,对那些我们身体中更为细微的生物来说,我们的身体是不是一个宇宙,无尽的猜想,无尽的疑惑。想法虽然有些荒诞,但基于现有科技目前谁也无法证明这是错的,而且近期已有科学文献证明人的大脑就像个宇宙。如果无止境地刨根问底下去,虽然会对底层科学更加清晰,但这对上层知识的学习非常不利,从而我们需要一个公设,我们认为原子是不可再分的,没有更微小的对象了,一切理论研究以此为基础展开。比如乘法是基于加法的,我们研究3×4等于多少,必须要承认1+1等于2,并认为其为真理,不用再去质疑1+1为什么等于2了,这就是我们的公设,至于为什么1+1等于2,还是由专门研究基础科学的学者们去探究吧。
学习操作系统也一样,不必纠结于硬件内部是如何工作的,我们只要认为给硬件一个输入,硬件就会给我一个输出就行了,因为即使你学到了硬件内部电子电路,随着你不断进步,钻研不断深入,也许有一天你的求知欲到了物理领域,并产生了物理科学方面的质疑……这让我想到一个笑话,某人准备去买自行车,结果被销售人员不断劝说,加点钱就能买摩托啦,等决定买摩托时,销售人员又说既然都决定买摩托车了,不如再加点买汽车吧,给出了各种汽车方面的优势,欲望需求不断升级,不断被销售劝说,最后居然花了几百万元买车,最后才想起自己是来买自行车的,甚至他还没有驾照……于是,咱们赶紧就此打住,我们是来学操作系统的。
你想学到哪个程度呢,你的公设是什么,要不咱们还是走一步说一步吧。
首先应该明确,在计算机中有分层的概念,也就是说,计算机是一个大的组合物,由各个部分组合成一个系统。每个部分就是一层功能模块,各司其职,它只完成一定的工作,并将自己的工作结果(也就是输出)交给下一层的模块,这里的模块指的是各种外设、硬件。
这样,各种工作成果不断累加,通过这种流水线式的上下游协作,便实现了所谓的系统。可见,系统就是各种功能组合到一起后,产生最终输出的组合物。就像人的身体,胃负责搅拌食物,将这些食物变食糜后交给小肠,因为小肠只能处理流食,所以上游的输出一定要适合作为下游的输入,是不是有点类似管道操作了,哈哈,分工协作是大自然的安排,并不是只有计算机世界才有。我们人类的思想是大自然安排好的,所以人类创造的事物也是符合大自然规律的。
好,赶紧回到正题,操作系统是管理资源的软件,操作系统能做什么,取决于主机上硬件的功能。就像用Maya造一个人体模型出来,首先我得知道Maya这个软件提供曲线曲面各种建模方法才行,换句话说,对于人体建模,你不可能会想到用QQ,因为它不是干这个的。我想说的是硬件不支持的话,操作系统也没招……操作系统一直是所谓的底层,拥有至高无上的控制权,一副牛气轰轰的样子,原来也要依仗他人啊。是啊,操作系统毕竟是软件,而软件的逻辑是需要作用在硬件上才能体现出来的。
所以说,写操作系统需要了解硬件,这些硬件提供了软件方面的接口,这样我们的操作系统通过软件(计算机指令)就能够控制硬件。我们需要做的就是知道如何通过计算机指令来控制硬件,参考硬件手册这下少不了啦。
硬件是各种各样的,发展速度还是非常快的。各个硬件都有自己的个性,操作系统不可能及时更新各种硬件的驱动方法吧。比如,刚出来某个新硬件,OS开发者们便开始为其写驱动,这不太现实,会把人累死的。于是乎,便出现了各种硬件适配设备,这就是IO接口。接口其实就是标准,大家生产出来的硬件按照这个标准工作就实现了通用。
硬件在输入输出上大体分为串行和并行,相应的接口也就是串行接口和并行接口。串行硬件通过串行接口与CPU通信,反过来也是,CPU通过串行接口与串行设备数据传输。并行设备的访问类似,只不过是通过并行接口进行的。
访问外部硬件有两个方式。
(1)将某个外设的内存映射到一定范围的地址空间中,CPU通过地址总线访问该内存区域时会落到外设的内存中,这种映射让CPU访问外设的内存就如同访问主板上的物理内存一样。有的设备是这样做的,比如显卡,显卡是显示器的适配器,CPU不直接和显示器交互,它只和显卡通信。显卡上有片内存叫显存,它被映射到主机物理内存上的低端1MB的0xB8000~0xBFFFF。CPU访问这片内存就是访问显存,往这片内存上写字节便是往屏幕上打印内容。看上去这么高大上的做法是怎么实现的,这个我们就不关心了,前面说过,计算机中处处是分层,我们要充分相信上一层的工作。
(2)外设是通过IO接口与CPU通信的,CPU访问外设,就是访问IO接口,由IO接口将信息传递给另一端的外设,也就是说,CPU从来不知道有这些设备的存在,它只知道自己操作的IO接口,你看,处处体现着分层。
于是问题来了,如何访问到IO接口呢,答案就是IO接口上面有一些寄存器,访问IO接口本质上就是访问这些寄存器,这些寄存器就是人们常说的端口。这些端口是人家IO接口给咱们提供的接口。人家接口电路也有自己的思维(系统),看到寄存器中写了什么就做出相应的反应。接口提供接口,哈哈,有意思。不过这是人家的约定,没有约定就乱了,各干各的,大家都累,咱们只要遵循人家的规定就能访问成功。
应用程序是软件(似乎是废话,别急,往后看),操作系统也是软件。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-1所示。
▲图0-1 陷入内核
应用程序处于特权级3,操作系统内核处于特权级0。当用户程序欲访问系统资源时(无论是硬件,还是内核数据结构),它需要进行系统调用。这样CPU便进入了内核态,也称管态。看图中凹下去的部分,是不是有陷进去的感觉,这就是“陷入内核”。
按理说咱们应该先看看段是什么,不过了解段是什么之前,先看看内存是什么样子,如图0-2所示。
▲图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所示。
▲图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空间内自由翱翔了。
首先,程序不是一定要分段才能运行的,分段只是为了使程序更加优美。就像用饭盒装饭菜一样,完全可以将很多菜和米饭混合在一起,或者搅拌成一体,哈哈,但这样可能就没什么胃口啦。如果饭盒中有好多小格子,方便将不同的菜和饭区分存放,这样会让我们胃口大开增加食欲。
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所示。
▲图0-4 程序中分段在平坦模型和 分段模型中的区别
总结一下,程序中的段只是逻辑上的划分,用于不同数据的归类,但是可以用CPU中的段寄存器直接指向它们,然后用内存分段机制去访问程序中的段,在这一点上看,它们很像相片和相框的关系:程序中的段是内存中的内容,相当于相片,属于被展示的内容,而内存分段机制则是访问内存的手段,相当于相框,有了相框,照片才能有地摆放。
我想大家应该已经搞清楚了内存分段和程序分段的关系,其实就是一回事,内存分段指的是处理器为访问内存而采用的机制,称之为内存分段机制,程序分段是软件中人为逻辑划分的内存区域,它本身也是内存,所以处理器在访问该区域时,也会采用内存分段机制,用段寄存器指向该区域的起始地址。
物理地址就是物理内存真正的地址,相当于内存中每个存储单元的门牌号,具有唯一性。不管在什么模式下,不管什么虚拟地址、线性地址,CPU最终都要以物理地址去访问内存,只有物理地址才是内存访问的终点站。
在实模式下,“段基址+段内偏移地址”经过段部件的处理,直接输出的就是物理地址,CPU可以直接用此地址访问内存。
而在保护模式下,“段基址+段内偏移地址”称为线性地址,不过,此时的段基址已经不再是真正的地址了,而是一个称为选择子的东西。它本质是个索引,类似于数组下标,通过这个索引便能在GDT中找到相应的段描述符,在该描述符中记录了该段的起始、大小等信息,这样便得到了段基址。若没有开启地址分页功能,此线性地址就被当作物理地址来用,可直接访问内存。若开启了分页功能,此线性地址又多了一个名字,就是虚拟地址(虚拟地址、线性地址在分页机制下都是一回事)。虚拟地址要经过CPU页部件转换成具体的物理地址,这样CPU才能将其送上地址总线去访问内存。
无论在实模式或是保护模式下,段内偏移地址又称为有效地址,也称为逻辑地址,这是程序员可见的地址。这是因为,最终的地址是由段基址和段内偏移地址组合而成的。由于段基址已经有默认的啦,要么是在实模式下的默认段寄存器中,要么是在保护模式下的默认段选择子寄存器指向的段描述符中,所以只要给出段内偏移地址就行了,这个地址虽然只是段内偏移,但加上默认的段基址,依然足够有效。
线性地址或称为虚拟地址,这都不是真实的内存地址。它们都用来描述程序或任务的地址空间。由于分页功能是需要在保护模式下开启的,32位系统保护模式下的寻址空间是4GB,所以虚拟地址或线性地址就是0~4GB的范围。转换过程如图0-5所示。
▲图0-5 虚拟地址、物理地址等
其实上面已经提到了段重叠,也许有的读者已经明白了,但还是在此特意解释一下吧。
依然假设在实模式下(并不是说在保护模式下就不存在段重叠,只是这样就会少解释了相关数据结构,如段描述符,不过这不重要,原理是一样的),一个段最大为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所示。
▲图0-6 段重叠
平坦模型是相对于多段模型来说的,所以说平坦模型指的就是一个段。比如在实模式下,访问超过64KB的内存,需要重新指定不同的段基址,通过这种迂回变通的方式才能达到目的。在保护模式下,由于其是32位的,寻址范围便能够达到4GB,段内偏移地址也是地址,所以也是32位。可见,在32位环境下用一个段就能够访问到硬件所支持的所有内存。也就是说,段的大小可以是地址总线能够到达的范围。既然平坦模型是相对于多段模型来说的,为什么不称为单段模型,而称为平坦呢,我估计很多读者已经明白了,用多个小段再加上不断换段基址的方式访问内存确实够麻烦的,可能换着换着就晕了,别忘记了,这种多段模型为了访问到1MB地址空间,还需要额外打开A20地址线呢,这种访存方式本身就是种补救措施,相当于给硬件打了个补丁,既然是补丁,访问内存的过程必然是不顺畅的。相对于那么麻烦的多段模型,平坦模型不需要额外打开A20地址线,不需要来回切换段基址就可以在地址空间内任意翱翔。如果把内存段比喻成小格子的话,平坦模型下的内存访问,没有众多小格子成为羁绊,可谓一路“平坦”。
所以“平坦”这两个字,突显了当时的程序员受多段模型折磨之苦,迫不及待地想表达其优势的喜悦之情。
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位宽。
这两个小问题,一些非开发型技术人员经常会问到,做过开发的同学肯定了解。想想还是简单说一下吧(因为这名词似乎也没法说复杂)。
软件中的工程是指开发一套软件所需要的全部文件,包括配置环境。
在一般的集成开发环境中如eclipse或vc++,在程序的开始都是先建立一个project,这就是所谓的工程,它相当于一个大目录,以后写的代码都在这里面。
全部文件包含实际代码和环境配置两部分。实际代码部分,除了自己写的代码文件之外,一般都要包含其他同事写的头文件,若是与他方合作,还要包含第三方头文件。环境配置部分,一般是配置一些模板、库文件目录,具体还要根据所用的实际框架来配置,包含一些服务器的地址,端口之类也都在配置文件中。还是那句话,工程就是为了完成软件编写所涉及的全部相关文件。
协议是一种大家共同遵守的规约,主要用来实现通信、共享、协作;起初是为避免大家各干各的,无法彼此调用对方成果的情况,从而给大家统一一种接口、一组数据调用或者分析的约定。
大家达成一致后,都遵守这个约定开发自己的产品,别人只要也按照这个约定就能够享用自己的成果,从而实现了彼此兼容。只要是技术人员都对TCP/IP有所了解,这就是我们目前赖以生存的网络协议。根据OSI七层模型,它规定数据的第一层,也就是最外层物理层,这一层包含的是电路相关的数据。发送方和接收方都彼此认同最外层的就是电路传输用的数据。每一层中的前几个固定的字节必须是描述当前层的属性,根据此属性就能找到需要的数据。各层中的数据部分都是更上一层的数据,如第一层(物理层)中的数据部分是第二层(数据链路层)的属性+数据,第三层(网络层)的数据部分是第四层(传输层)TCP或UDP的属性+数据。各层都是如此,直到第七层(应用层)的数据部分才是真正应用软件所需要的数据。由此可见,对方一大串数据发过来后,经过层层剥离处理,到了最终的接收方(应用软件),只是一小点啦。
如图0-7所示,两边的应用程序互发数据时,其实发的就是最顶层的那一小点“数据”,每下一层,便加了各层的报文头,上层整个(包括自己的报文头和报文体)全部成了下一层的数据部分。
这样说似乎还是很抽象,具体地说,就是需要的数据是在偏移文件固定大小的字节处,这个固定字节是多少,就是协议中所规定的。不了解TCP/IP的同学可以参看各层报文格式,自行查阅吧。
▲图0-7 OSI七层模型
其实,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中运行的。
除以上原因外,这还和编译器、标准库有关,不再列举。
局部变量,顾名思义其作用域属于局部,并不是像static那样属于全局性的。全局的变量,意味着谁都可以随时随地访问,所以其放在数据段中。而局部变量只是自己在用,放在数据段中纯属浪费空间,没有必要,故将其放在自己的栈中,随时可以清理,真正体现了局部的意义。这个就是堆栈框架,提到了就说一点吧,栈由于是向下生长的,堆栈框架就是把esp指针提前加一个数,原esp指针到新esp指针之间的栈空间用来存储局部变量。解释一个概念,堆是程序运行过程中用于动态内存分配的内存空间,是操作系统为每个用户进程规划的,属于软件范畴。栈是处理器运行必备的内存空间,是硬件必需的,但又是由软件(操作系统)提供的。堆是堆,而堆栈就是栈,和堆没关系,只是都这么叫。栈和堆栈都是指的栈,在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语言被编译成机器指令后,生成的机器指令当然也包括这些额外的部分,相当于多执行了一些“看似没用”的指令,因此会比直接用汇编语言慢。
首先肯定的是先有的编程语言,哪怕这个语言简单到只有一个符号。先是设计好语言的规则,然后编写能够识别这套规则的编译器,否则若没有语言规则作为指导方向,编译器编写将无从下笔。
第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所示这张图片在网络上非常火,它常常与励志类的文字相关。起初看到这个雕像在雕刻自己时,我着实被感动了,感受到的是一种成长之痛。今天把它贴过来的目的是想告诉大家,起初的编译器也是功能简单,不成规范,然而经过不断自我“雕刻”,它才有了今天功能的完善。
▲图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
用老编译器将新编译器的源代码compile_new_a.c编译,生成可执行文件,该文件就是新的编译器,我们取名为新编译器_a。为了方便理清它们的关系,将它们列入表格中。
编译器自身源代码 |
编译器 |
应用程序源代码 |
输出文件名 |
---|---|---|---|
|
老编译器 |
|
新编译器 |
这下编译出来的新编译器_a可以编译含有转义字符'\'的应用程序代码了,也就是说,待编译的文件(也就是应用程序代码)中,应该用'\'来表示'\'。而单独的字符'\'在新编译器_a中未做处理而无法通过编译。所以此时新编译器_a是无法编译自己的源代码compile_new_a.c的,因为该源文件中只是单个'\'字符,新编译器_a只认得'\'。
先更新它们的关系,见下表。
编译器自身源代码 |
编译器 |
应用程序源代码 |
输出文件名 |
---|---|---|---|
|
老编译器 |
|
新编译器 |
|
新编译器 |
|
编译失败 |
也就是说,现在新编译器_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
其实compile_new_b.c只是更新了转义字符的语法,这是新编译器_a所支持的新的语法,此文件是否是编译器源码没什么关系。所以下面还是以新编译器_a来编译新的编译器。
用新编译器_a编译此文件,将生成新编译器_b,将新的关系录入到表格中。
编译器自身源代码 |
编译器 |
应用程序源代码 |
输出文件名 |
---|---|---|---|
|
老编译器 |
|
新编译器 |
|
新编译器 |
|
编译失败 |
|
新编译器 |
|
新编译器 |
现在想加上换行符'\n'的支持。
由于现在编译器还不认识'\n',故这样做肯定不行,不过可以用其ASCII码来代替,将其命名为compile_new_c.c。
代码compile_new_c.c
用新编译器_a来编译compile_new_c.c,将生成新编译器_c。
编译器自身源代码 |
编译器 |
应用程序源代码 |
输出文件名 |
---|---|---|---|
|
老编译器 |
|
新编译器 |
|
新编译器 |
|
编译失败 |
|
新编译器 |
|
新编译器 |
|
新编译器 |
|
新编译器 |
最后再修改compile_new_c.c为compile_new_d.c,将10用'\n'替代。
代码compile_new_d.c
用新编译器_c编译compile_new_d.c,生成新编译器d,将直接识别'\n'。
编译器自身源代码 |
编译器 |
应用程序源代码 |
输出文件名 |
---|---|---|---|
|
老编译器 |
|
新编译器 |
|
新编译器 |
|
编译失败 |
|
新编译器 |
|
新编译器 |
|
新编译器 |
compile_new_c.c |
新编译器 |
|
新编译器 |
|
新编译器 |
编译器经过这样不断的训练,功能越来越强大,不过体积也越来越大了。
解释型语言,也称为脚本语言,如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运行。总之调度器在就绪队列中能看到此进程。而解释型程序是无法让调度器“入眼”的,调度器只会看到该脚本语言的解释器。
先说一下为什么会产生字节序的问题。
内存是以字节为单位读写的,其最小的读写单位就是字节。故如果在内存中只写入一个字节,一个内存的存储单元便可将其容纳了,只要访问这一内存地址就能够完整取出这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所示。
▲图0-9 内存中存储形式
▲图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架构上的程序在发送网络数据时,要转换字节顺序。
关于字节序就介绍到这里,读者若觉得意犹未尽可以自行查阅。
在计算机系统中,无论是在实模式,还是在保护模式,在任何情况下都会有来自外部或内部的事件发生。如果事件来自于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填写中断向量表中相关项,使它们指向硬件自带的例程。
▲图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系统调用的介绍点到为止。
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
这个汇编文件是在本地中声明了字符串,并调用外部的打印函数print,大家可以参考注释,弄个大概明白就行。
文件2.asm
在文件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所示。
readelf输出信息1
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。
▲图0-13 节和段
▲图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在地址分配上的内容,大家有兴趣可以提前了解下。
魔数,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日),反而还要额外增加一些内容来解释,得不偿失,所以这就是魔数不得不存在的原因。
可见,计算机中处处是协议、约定。不过为了程序意义清晰可维护性强,尽量还是少用魔数。
我们知道,一个硬盘上可以有很多分区,每个分区的格式又可以不同。就拿Linux来说,既能识别ext3,又能识别ext4。可能有同学会说,这两个分区的文件系统都是Linux自己专用的,当然认得自己的东西了。可是自己的东西也得有个辨别的地方,否则凭什么说“认得”呢。
其实这是之前介绍过的魔数的作用,文件系统也有自己的魔数,魔数的神秘力量在此施展了。各分区都有超级块,一般位于本分区的第2个扇区,比如若各分区的扇区以0开始索引,其第1个扇区便是超级块的起始扇区。超级块里面记录了此分区的信息,其中就有文件系统的魔数,一种文件系统对应一个魔数,比对此值便知道文件系统类型了。
其实此问题我一直犹豫要不要写出来,因为大部人都觉得这个问题有些匪夷所思,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中有不同的实现方法。
指令集是什么?表面上看它是一套指令的集合。集合的意思显而易见,那咱们说说什么是指令。
在计算机中,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指令体系结构。
在讨论此问题之前,我们应该明白此问题的始作俑者是操作系统本身。我们用了操作系统,就理应遵守它的规范。任何操作系统都有自己的一套做事规则,在其上的所有应用程序,都按照它定下的规矩做事。
我们讨论的环境是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所示。
▲图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所示。
▲图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所示。
▲图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中,大家可以自行查看。
▲图0-18 用ltrace跟踪进程系统调用
如果大家不想安装ltrace命令,可以用本机自带的strace命令代替,它是专门用来查看系统调用和信号的命令,不过它查看的并不是最终的系统调用,而是系统调用的封装函数。不解释啦,大家眼见为实吧,如图0-19所示。
▲图0-19 strace实例
如图0-19所示,画框框的write是系统调用。原本输出的信息非常多,这里我只截了部分。write函数是系统调用SYS_write的封装,所以你懂了我更喜欢用ltrace的原因。
顺便说一句,大家可以用-e trace=write来限制只看write系统调用,免得输出无关的信息太多。
该说的都说啦,现在总结一下。
(1)操作系统有自己支持、加载用户进程的规则,而C运行时库是针对此操作系统的规则,为了让用户程序开发更加容易,用来支持用户进程的代码库。大家要明白,之所以我们写个程序又链接这又链接那的,完全是因为操作系统规定这样做,人在屋檐下,不得不低头。
(2)用户进程要与C运行时库的诸多目标文件链接后合并成一个可执行文件,也就是说我们的用户进程被加进了大量的运行库中的代码。
(3)C运行时库作用如其名,是提供程序运行时所需要的库文件,而且还做了程序运行前的初始化工作,所以即使不包含标准库文件,链接阶段也要用到c运行时库。
(4)用户程序可以不和操作系统打交道,但如果需要操作系统的支持,必须要通过系统调用,它是用户进程和操作系统之间的“钩子”,用户进程顶多算是个半成品,只有通过钩子挂上了操作系统,加了上所需要的操作系统的那部分代码,用户程序才能做完一件事,这才算完整,后面章节会有详解。
(5)尽管系统调用封装在库函数中,但用户程序可以直接调用“系统调用”,不过用库函数会比较高效(后面章节会有详解)。
计算机世界中是以二进制来运行的,无论是指令、数据,都是以二进制的形式提交给硬件处理的,字符也一样,必须转换成二进制才能被计算机识别。所以各种各样的字符编码产生,简单来说,字符编码就是用唯一的一个二进制串表示唯一的一个字符。其中最著名的字符编码就是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.
▲图0-20 查看编译后的转义字符
希望对大家理解转义字符有帮助。
这几个概念主要是围绕计算机系统的控制权交接展开的,整个交接过程就是个接力赛,咱们从头梳理。
计算机在接电之后运行的是基本输入输出系统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所示。
▲图0-21 MBR、EBR、OBR位置关系
您看,MBR位于整个硬盘最开始的块,EBR位于每个子扩展分区,各子扩展分区中只有一个逻辑分区。MBR和EBR位于分区之外的扇区,而OBR则属于主分区和逻辑分区最开始的扇区,每个主分区和逻辑分区中都有OBR引导扇区。有关分区更详细的内容请参阅后面跟踪分区表的章节。
如果您觉得操作系统已属于很底层的东西,我双手赞成。但是如果您像我之前一样,觉得底层的东西无法用上层高级的东西来构建,现在可以睁大眼睛好好看看下面要介绍的东西了。
首先,操作系统是软件。软件是由编程语言来实现的,即使是编译器本身,它的开发人员都不愿意用底层语言去构建(GCC是用C语言完成的),只有到万不得已的时候才会用汇编语言来写。我们也是一样,能用省事的方法就不要自找麻烦,如果某位大神能直接写机器码,小弟真心恳求与您见上一面,希望您收我为徒,我要当面磕头拜师。不过话又说回来了,直接写机器码也并不是什么明智的做法,毕竟费力不讨好,不过毅力还是值得钦佩的。同学们不要被我虔诚的态度误解为直接写机器码是不可能的事,这个能,必须能,写汇编编译器的同学做的就是这样的事,原则上只要按照IA-32指令格式往二进制文件中写指令,就一定能让CPU理解自己,能够直接同CPU对话了……停,赶紧回来,咱们是来写操作系统的,赶紧进入主题。
C语言虽然不是为设计大型软件而生的,但其却被用来开发大型软件。
现代操作系统基本上是用C语言再结合汇编语言开发的,所以C语言编译器,我们选择的是gcc。而汇编语言编译器,我们选择的是nasm。为什么选择这两个,首先因为它们都是开源软件,其次其强大的功能不亚于同类的商业软件。
秉着简单至上的原则,我们在开发过程中,能用简单的工具就不用复杂的。所以我们的系统,绝大部分是C语言实现的,而且并不需要多么高深的算法及数据结构功底。
另外我们在Linux下开发,所以首先的编译器就是GCC,基本上没有人不了解这个大名鼎鼎的开源编译器了。出于对这个编译器的膜拜,我还是引用wiki上的介绍:
GNU编译器套装(GNU Compiler Collection,GCC),是一套由GNU开发的编程语言编译器。它是一套以GPL及LGPL许可证所发行的自由软体,也是GNU计划的关键部分,亦是自由的类Unix及苹果电脑Mac OS X 操作系统的标准编译器。GCC(特别是其中的C语言编译器)也常被认为是跨平台编译器的标准。
GCC是由理查德·马修·斯托曼在1985年开始的。他首先扩展一个旧有的编译器,使它能编译C,这个编译器一开始是以Pastel语言所写的。Pastel是一个不可移植的Pascal语言特殊版,这个编译器也只能编译Pastel语言。为了让自由软件有一个编译器,后来此编译器由斯托曼和Len Tower在1987年以C语言重写并成为GNU专案的编译器。GCC的建立者由自由软件基金会直接管理。
GCC原名为GNU C语言编译器(GNU C Compiler),因为它原本只能处理C语言。GCC很快扩展,以2011年10月26日释出的4.6.2版为准,可处理的编程语言有:
1.Ada(GNAT)
2.C(GCC)
3.C++(G++)
4.Fortran(Fortran 77: G77,Fortran 90: GFORTRAN)
5.Java(编译器:GCJ;解释器:GIJ)
6.Objective-C(GOBJC)
7.Objective-C++
8.Go
好啦,介绍结束,看上去GCC很厉害,居然可以支持这么多语言。不愧是出自理查德·马修·斯托曼(Richard Matthew Stallman)之手,只要学过计算机的读者便了解此人,他到底有多厉害呢,看网友对他的评价:“曾独自一人与一众lisp黑客高手进行比赛……”好了,多说已无益,简单的半句话便彻底表达了此人深厚的计算机功力。
回到正题,Linux系统会自带GCC,如果您的发行版中没有,可以到网站http://gcc.gnu.org/下载。
新是相对于旧来说的,旧的汇编器MASM和TASM已经过时了,从名称上可以看出字母n是在m之后,其功能必然有所超越才会被大家接受。
请用一句话概括NASM优势在哪里。免费+语法简洁使人舒适+支持Linux平台。这里所说的任何一个理由都是其他同类产品不具备的,敏锐的同学是不是察觉到了什么……哈哈,怎么给人的感觉是:其他编译器不是花钱,就是语法怪异让人不爽,要么就不支持Linux,看上去选择nasm是没得可选了?我就不自问自答了,反正NASM语法很接近咱们当初学的Intel语法,我是用得很爽呢。这里就不再比较其优越性了,大家若感兴趣还是自行查阅吧。
同样是为了抒发一下对这位新贵的“爱慕之情”,简要介绍还是很有必要的。
NASM是一个为可移植性与模块化而设计的一个80x86的汇编器。它支持相当多的目标文件格式,包括Linux和'NetBSD/FreeBSD','a.out','ELF','COFF',微软16位的'OBJ'和'Win32'。它还可以输出纯二进制文件。它的语法设计得相当的简洁易懂,和Intel语法相似但更简单。它支持'Pentium','P6','MMX','3DNow!','SSE'和'SSE2'指令集。
介绍完了之后,咱们讨论下为什么要用汇编语言开发系统呢?就目前来看,无论再怎么要求开发过程简单,也避免不了用汇编语言,尤其是开发操作系统这类底层软件。越底层的软件就越要与硬件直接打交道,这就要求在语言层面上给开发人员提供访问端口寄存器的方法。显然,目前的高级语言都做不到这一点,像C语言这类偏底层的语言都不支持修改寄存器,用汇编语言则是不可避免的事了。
包括我在内的很多同学一听要用汇编了,都有一种小小的恐惧感,认为这是一种不好掌握的东西(我没有称之为语言而是称之为东西,是因为曾经有个女同学都不知道汇编是什么),而且程序编写起来特别麻烦,要考虑的东西太多了,代码逻辑写起来不够直接,似乎总是在迂回……以至于我们经常被汇编语言“搞定”。我个人的感觉是当我熟悉了汇编语言后,甚至觉得有一点亲切呢。当然了,任何陌生的事物经过熟悉的过程后都会变得有亲切感,关键是咱们得扛到对它熟悉为止,不能让心里的畏惧战胜自己。用汇编语言和CPU直接对话,想想就有点小兴奋呢。
不过好在我们需要用汇编的地方只是一些硬件访问、中断调用、端口读写、线程切换之类(怎么看上去好多……),我们可以写出一些通用的代码来减少汇编的枯燥。总之,只有不得不用时我们才会向汇编语言屈服。
操作系统虽然是软件,但其可不是一般的软件。我们平时写出来的程序都是基于操作系统之上的,程序本身是由操作支持的,开发人员只要专注于自己这块业务逻辑就好了,很多复杂的问题是不需要开发人员考虑的。而操作系统这个软件靠谁来支持呢?是靠你自己……这是用一身老骨头扛出来的,现在明白为什么Linux之父Linus那么强壮了吧,不是谁都能随随便便成功的,所以,写操作系统那可是要有个好身板,要多锻炼身体才能熬得住,看完这章赶紧出去跑步吧,玩笑玩笑。如果一般应用软件能称得上鱼香肉丝的话,那操作系统得相当于龙虾鲍鱼,这可是硬菜,不出去跑个几公里都啃不下来呢。哈哈,其实也没那么夸张,现在有很多计算机大牛写了好多开源软件帮助我们调试操作系统。话说,自从有了虚拟机,我再也用不着锻炼身体了,每次出现bug时不需要重启真机了,只需要重启虚拟机就好。
虚拟机在当今已经不是陌生的概念了,要是在几年前,我还得搬出个概念放在这给大家看看。个人觉得,要解释一个东西是什么,不如直接解释这个东西解决了哪些问题,这样大家自然就从本质上真正理解了它是什么。
没有虚拟机的时候,一台机器只有交给一个用户使用,而且一个人根本无法将这台机器的性能完全发挥出来,造成了极大的浪费不算,还有很多人正等着用呢。于是出现了虚拟机的需求:将一台物理机通过软件逻辑分割成几个虚拟的计算机,每个计算机之间互不干涉,即使一台虚拟计算机崩溃了也只是影响了它自身,不会让整个物理机瘫痪,安全可靠,可以自由测试而不必担心损伤物理机。这不仅在硬件投入上节省了大笔开销,还让更多的人同时使用了计算机资源。
现在很多厂商都在搞虚拟化,如域名的虚拟空间,还有如火如荼的阿里云,这是虚拟机的应用。虚拟机就是用软件来模拟硬件。虚拟机只是一个普通的进程,该进程模拟了硬件资源,在虚拟机中运行的程序其所做出的任何行为都先被虚拟机检查,由虚拟机分析后,代为向操作系统申请。
上面对虚拟机的解释是主观上的理解,我可不愿意说概念了,因为概念是对事物的抽象。抽象就意味着不易理解,容易把简单的事复杂化。我举个简单的例子来说明什么是虚拟机。
假设V是虚拟机进程,U是普通的用户程序。程序运行起来才叫进程,进程是要有pcb的,程序老实地放在磁盘上不动,那可不叫进程。虚拟机跑起来后,就形成了进程V,在它被调度期间,CPU执行的是此进程中的指令。让虚拟机执行U程序,有如解释器进程在解析脚本文件一样,此时的U程序被当作参数传给了V程序,U程序就像文章一样由V进程阅读。还是拿解释型语言举例子,比如python语言,其脚本从来就没有直接作用于CPU上,而是将其字节码交给了python解释器,这个解释器将通过python虚拟机来代为完成python脚本中的代码行为。
让我们说得再具体一点,比如在Linux平台上,写了一个python脚本文件file.py,其中有这样一句代码:fh=open(“hello.txt”,'w'),这是在用可写的方式打开hello.txt文件,将其句柄返回给fh。自此操作文件句柄fh便操作了文件hello.txt。python虚拟机是一个进程,它是直接作用在硬件之上的。当它分析python脚本file.py中上面的那句代码时,发现有关键字open(当然关键字得是python解释器支持的,此解释器为它们而生才行),于是执行了open(“hello.txt”,'w')函数,其内部是封装的系统调用(系统调用这方面内容以后咱们在自己的系统中细说),通过系统调用,python虚拟机替python脚本完成了打开hello.txt的工作。
选择虚拟机的原因如下。
1.运行方便
它在宿主系统上只是一个进程,在宿主系统如Linux眼里,它与一般的用户进程是没任何区别的。进程咱们都可以随意启动,虚拟机也是一样的,在这一点保证了使用上的方便性。
2.保护计算机
如果您有一般的软件开发经验,就会了解,很少有程序能一下就编译通过。当然,如果您的编程经验无比丰富,代码无比规范,无比了解编译器,确实不需要虚拟机来调试了,编写完成后直接就能运行。以上我用了三个“无比”,打造了似乎没有人能达到这种水平的假象,其实是有的。不知道大家听说过Jon Skeet没有,他是谷歌软件工程师,《C# In Depth》就是他的作品。看看别人对他是怎样评价的,看完之后您就知道我说的并不夸张了。
“他并不需要调试器,只要他盯着代码看几眼,Bug自己就跑出来了”。
“他根本不需要什么编程规范,他的代码就是规范”。
“如果他的代码没有通过编译,编译器厂商就会道歉”。
如果咱们都不能保证写出这样质量出色的代码,咱们还是老老实实地装虚拟机吧。因为如果要把操作系统装在真机器上,每次调试的时候,无论代码是否崩溃,都是要重启计算机的。为了保护咱们的爱机,虚拟机必装不可。不知道你们心疼电脑吗,反正我要是一天开电脑三次以上,我就会很自责,不知道这种性格和水瓶座有没有关系。
说了虚拟机的好处,那咱们有哪些虚拟机可用呢。一般的有qemu、bochs、virtualBox、xen和vmware等。
大家都这么忙,能有时间拿起书不容易啊,为了不浪费您宝贵的时间,我就不说和本书不相关的虚拟机了,种类再多,也只是选一个。我们要用的就是bochs。选择bochs的理由如下。
(1)开源,有感于作者的奉献精神,我们要支持作者(当然qemu也是)。
(2)支持调试,不仅原生支持调试,还支持gdb远程调试(当然qemu也是)。
(3)我只会这个。
对于虚拟机的选择,能工作能调试够用就行了,遇到问题时再寻求新方案也不迟,毕竟咱们的重点是后面的写操作系统,学太多的虚拟机也没啥用。
介绍一下bochs吧,怎么也得让大家有个初步的印象。下面的内容是我从维基百科翻译过来的,其实就是从繁体中文翻译成了简体中文,而且只有几个繁体字,哈哈。
Bochs(发音:box)是一个以LGPL许可证发放的开放源代码的x86、x86-64IBM PC兼容机模拟器和调试工具。它支持处理器(包括保护模式)、内存、硬盘、显示器、以太网、BIOS、IBM PC兼容机的常见硬件外设的仿真。
许多客户操作系统能通过该仿真器运行,包括DOS、Microsoft Windows的一些版本、AmigaOS 4、BSD、Linux、MorphOS、Xenix和Rhapsody(Mac OS X的前身)。Bochs能在许多主机操作系统运行,例如Windows、Windows Mobile、Linux、Mac OS X、iOS和PlayStation 2。
Bochs主要用于操作系统开发(当一个模拟操作系统崩溃,它不崩溃主机操作系统,所以可以调试仿真操作系统)和在主机操作系统运行其他来宾操作系统。它也可以用来运行不兼容的旧的软件(如电脑游戏)。
它的优点在于能够模拟跟主机不同的机种,例如在Sparc系统里模拟x86,但缺点是它的速度却慢得多。
介绍完了,不知道您看了吗,不看也行,反正以后咱们实际应用时还会细说的。
很多同学电脑的系统都是Windows,个别的是Mac OS,还有的同学用的是Linux。作为一名Linux粉丝,我的开发环境必然建立在Linux平台下。那对于其他系统的用户,你们可以自己部署相应平台的开发环境,用的工具都是一样的,无非是换个相应平台的版本。不过我担心由于平台不同而造成这样那样的问题,会减退大家学习的积极性,毅力不足的同学还没开始写操作系统就急流勇退了,我后面精彩的内容没有观众该怎么办。为了减少学习的困难,也为了让大家继续为我后面的内容捧场,我在睡觉的时候想到一个好办法,能让大家的开发环境极大限度地得到统一,就是我们加一层,再安装一个虚拟机。
首先虚拟机是个软件,不会伤害咱们的爱机。我一度认为虚拟机是一项非常伟大的发明,甚至认为它是给像我这样的穷人最好的礼物,当初学习思科(网络、路由器等方面知识)时,幸亏有虚拟机来模拟多台计算机,否则还真买不起第二台电脑,所以大家一定要好好学习,不要像我当初那样辜负了虚拟机。我们的方案是这个虚拟机就用virtualBox吧,虽然在结尾加了个“吧”,但我丝毫没有征求大家意见的意思,哈哈,抱歉,我这也绝对不是强硬。让小弟我给大家个交待。
(1)个人觉得virtualBox比vmware更轻量,配置起来更简单。
(2)virtualBox是免费的,不需要破解,这一点很重要。
(3)因为我不想改成别的了,嫌麻烦,请大家原谅。
交待过了之后,大家还是根据自己喜好选择虚拟机,大家觉得哪个方便就用哪个。
方案再多也总该选择一个,我选择的方案是在virtualBox中安装个操作系统。因为要在Linux下开发,所以选的是与redhat很接近的CentOS,我用的版本是6.3,本书中以后便以virtualBox+CentOS 6.3为例。由于我后续的环境部署都是在此版本上进行的,没遇到什么大问题,确实感觉很稳定,简单可依赖。
在CentOS中再装个bochs,最终我们的代码运行在bochs中。
想当初莱欧纳多的电影《盗梦空间》上映时,很多朋友都被故事的新颖所吸引,大概意思是通过潜入别人的梦中去窃取机密,如果第一层梦境窃取不到,还可以在梦中继续睡觉,再进入第二层,也就是梦中梦。这就是标题中所说的,虚拟机中再装个虚拟机,您看,描述还是有些形象的。
virtualBox官方下载地址是http://download.virtualbox.org/virtualbox,大家选择一个适合自己系统平台的版本,我安装的是4.2.12 mac版本,具体下载地址是http://download.virtualbox.org/virtualbox/4.2.12/,大家可以自行选择。
MacOS和Windows基本上virtualBox的安装是一路回车,没什么可说的。如果您用的系统是Linux,我更觉得说什么都显得多余,因为能用Linux办公,说明您完全有能力安装成功。
可以在mirrors.163.com这个国内的镜像源去下载自己喜欢的版本。
由于CentOS官方的告示,目前6系列的版本只有6.5可用,其他低版本不再支持。喜欢CentOS的朋友可以趁机装个新版本。如果您像我一样执拗非6.3版本不装,我也给出了官方的链接地址,大家斟酌安装。
http://vault.centos.org/6.3/isos/i386/CentOS-6.3-i386-bin-DVD1.iso
大家根据自己虚拟机的种类开始安装Linux吧,由于版本和宿主系统种类较多我不便将安装步骤一一给出,大家若有不懂的问题请自行查阅,百度经验上有很多方法,若第一次用虚拟机,大家可以参考下面链接的方法。
http://jingyan.baidu.com/article/414eccf61d12cc6b431f0ae7.html。
在完成了Linux发行版的安装后,现在到了安装bochs的环节,这是我们的操作系统最终的宿主机。
由于我的工作是运维,所以练就了任何软件包都要从源码安装的“陋习”,从来不信任任何软件包。因为只有从源码安装的版本才会在其配置和编译过程中根据所在的平台的特性去优化,这些是其他形式的软件包不可比拟的。举个例子,将别人的Windows系统直接ghost到自己的机器上和从光盘安装Windows比,哪个装的Windows系统用得更稳定,哪个安装方法能让Windows坚持到半年才重装一次……我不能再说了,我作为Linux粉丝的事实已表露无遗。虽然我个人偏爱Linux,但绝对不能否认,是Windows把我带入计算机世界的。这个IT世界若没有Windows将暗淡70%的光芒。其实原先我写的是90%,我怕有人问我这个数是怎么来的,这是我一拍脑门随口说出来的,所以我稳妥起见改为了70%,总之,不能无视Windows的伟大功绩。
bochs的安装相对要麻烦一些,不光是装上去就行了,还需要配置一下。
软件包得传到虚拟机上才能安装到虚拟机里,如何传上去呢。下面建议了3个方案。
(1)给虚拟机装个ftp,通过ftp上传。
(2)让虚拟机连网,直接下载。
(3)虚拟机支持USB,通过U盘上传软件包。
第1个方案需要配置ftp服务器,我用的是proftpd,相对来说有点麻烦,也是需要单独配置的。而且默认Linux的iptables会有一些规则,需要手动将其关闭。
第2个方案较简单,在您的宿主系统可以连网的情况下,需要您自己配置一下virtualBox的网卡,将网卡部分改为NAT可以通过宿主系统连网,将网卡改为桥接可以直接连网。由于大家的版本不统一,虽然不知道界面是否接近,但菜单名称总该是一样的。我用的是mac版virtual Box,给大家截个图看看,如图1-1所示。
▲图1-1 virtual Box
将网卡模式改为NAT后,虚拟机就可以连网了。
第3个方案最方便了,大家自己试一下吧。
好了,下面就假设大家能够把安装包上传到虚拟机中,安装走起。
1.下载bochs
官方地址是http://sourceforge.net/projects/bochs/files/bochs/,我安装的版本是2.6.2,下载后的文件是bochs-2.6.2.tar.gz。
2.解压压缩包tar zxvf bochs-2.6.2.tar.gz
3.编译
先进入到目录cd bochs-2.6.2,开始configure、make、make install三步曲。
./configure \
--prefix=/your_path/bochs \
--enable-debugger\
--enable-disasm \
--enable-iodebug \
--enable-x86-debugger \
--with-x \
--with-x11
注意各行结尾的'\'字符前面有个空格。下面简要说明一下configure的参数。
--prefix=/your_path/bochs是用来指定bochs的安装目录,根据个人实际情况将your_path替换为自己待安装的路径。
--enable-debugger打开bochs自己的调试器。
--enable-disasm 使bochs支持反汇编。
--enable-iodebug启用io接口调试器。
--enable-x86-debugger支持x86调试器。
--with-x使用x windows。
--with-x11使用x11图形用户接口。
上面的编译参数是不支持gdb远程调试的,如果想用gdb调试,就要将参数--enable-debugger替换为--enable-gdb-stub。
--enable-gdb-stub用来打开对gdb的支持,这样我们就可以用gdb来远程调试了。
不过,需要注意的是不能同时打开这两个开关,否则bochs会报错,即configure:error:--enable-debugger and --enable-gdb-stub are mutually exclusive。
也就是说,bochs本身是支持调试的,要么用本身的调试功能,要么用gdb的调试功能,鱼和熊掌在一台模拟器上不可兼得。我说的是一台模拟器上不可兼得,所以,如果您愿意的话,可以用这两个参数各编译一版,只要--prefix指向不同的路径就行了,想用哪个就启用哪个。
不过我在开发过程中,只用过不超过5次的gdb调试,还是习惯bochs自己的调试功能,个人觉得它更强大,调试粒度更细微,反而更灵活。个人建议,直接用给出的configure参数就行,不要打开--enable-gdb-stub。
configure之后,会生成Makefile,可以开始编译了。
make
若编译时没有问题,就直接执行下面这句。
make install
完成安装。
补充一下,在编译用bochs调试功能的版本时(用--enable-debugger),曾经安装失败过,如果您也在安装过程中失败了,恰好出现类似下面的报错:
undefined reference to 'pthread_create'
undefined reference to 'pthread_join'
您可以按照下面的方法解决。如果不是这个报错,亲,您可能要辛苦一下自行解决啦。
上面报错的原因:
pthread库不是Linux系统默认的库,连接时需要使用静态库 libpthread.a,所以在使用pthread_create()创建线程,以及调用 pthread_atfork()函数建立fork处理程序时,需要链接该库。
解决方案:
在编译中要加 -lpthread参数。用vim编译makefile,vim是Linux下功能最为强大的文本编辑器。vim Makefile回车:
编辑第92行,将thread库加入,将其放在行末尾就行了。
IBS =-lm -lgtk-x11-2.0 -lgdk-x11-2.0 -latk-1.0 -lgio-2.0 -lpangoft2-1.0 -lgdk_pixbuf-2.0 –lpangocairo -1.0 -lcairo -lpango-1.0 -lfreetype -lfontconfig -lgobject-2.0 -lgmodule-2.0 -lglib-2.0 –lpthread
重新编译,make 回车,看问题是否解决,成功解决后直接make install回车。
安装完成后该配置bochs了,它是通过配置文件完成的。
要说这个配置文件,它有点类似BIOS。我们在开机时按下的del、esc,或者F2键,各个机型进入BIOS方式有所不同,但差不多就那几种方式。BIOS中会显示各种硬件的信息,还有启动顺序等。Bochs既然是模拟硬件的,它就得知道,您需要它模拟的计算机是什么样的,换句话说,在这个虚拟机中有哪些硬件,启动顺序是什么,是从软盘开始,还是从硬盘开始?人家也得像模像样地跟BIOS差不多才行。给bochs配置硬件的方法,就是写一个配置文件给它,bochs启动时会找到此文件,根据文件内容创建自己,这样咱们的虚拟机就健全了。
在安装目录下有样本文件:share/doc/bochs/bochsrc-sample.txt。由于此文件有1130行,确实有些长,就不贴出来了,摘点重点内容,关于启动顺序,可参见该文件的以下几行(左列的数字是行号)。
…
…
531 #================================================
532 # BOOT:
533 # This defines the boot sequence. Now you can specify up to 3 boot drives,
534 # which can be 'floppy', 'disk', 'cdrom' or 'network' (boot ROM).
535 # Legacy 'a' and 'c' are also supported.
536 # Examples:
537 # boot: floppy
538 # boot: cdrom, disk
539 # boot: network, disk
540 # boot: cdrom, floppy, disk
541 #===============================================
542 #boot: floppy
543 boot: disk
下面是能够支持gdb的bochs配置文件,给大家当作参考。
[work@localhost bochs]$ cat bochsrc.disk 用cat命令显示bochsrc.disk
###############################################
# Configuration file for Bochs
###############################################
# 第一步,首先设置Bochs在运行过程中能够使用的内存,本例为32MB。
# 关键字为:megs
megs: 32
# 第二步,设置对应真实机器的BIOS和VGA BIOS。
# 对应两个关键字为:romimage 和 vgaromimage
romimage: file=/实际路径/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/实际路径/bochs/share/bochs/VGABIOS-lgpl-latest
# 第三步,设置Bochs所使用的磁盘,软盘的关键字为floppy。
# 若只有一个软盘,则使用floppya即可,若有多个,则为floppya,floppyb…
#floppya: 1_44=a.img, status=inserted
# 第四步,选择启动盘符。
#boot: floppy #默认从软盘启动,将其注释
boot: disk #改为从硬盘启动。我们的任何代码都将直接写在硬盘上,所以不会再有读写软盘的操作。
# 第五步,设置日志文件的输出。
log: bochs.out
# 第六步,开启或关闭某些功能。
# 下面是关闭鼠标,并打开键盘。
mouse: enabled=0
keyboard_mapping: enabled=1,
map=/实际路径/bochs/share/bochs/keymaps/x11-pc-us.map
# 硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
# 下面的是增加的bochs对gdb的支持,这样gdb便可以远程连接到此机器的1234端口调试了
gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0
################### 配置文件结束 #####################
好了,现在将上面的配置文件存为bochsrc.disk放在bochs安装目录下。(bochs配置文件位置不固定,名字也不要求固定),后缀.disk是我人为加的,为了表示此配置文件配置的内容是从硬盘启动,这样较明确。
终于安装完成了,虽然这过程中有可能会出现各种各样的问题,但还是值得庆祝的,对Linux不熟的朋友第一次就搞定了这么个硬货,我理解您此时的喜大普奔之情,哈哈,给大家点赞。顺便说一句,其实平时我们的运维人员为开发环境付出了远比这更多的努力,所有奋战在一线的系统工程师和运维工程师,您们辛苦了。
不过好奇心让我们按捺不住想一探bochs容貌,说实在的,我现在就想先运行一下看看,失败又能怎样,无非是报错退出呗,又不会造成实质性的损失。我非常理解大家的心情,虽然现在还差点东西没完成,但作为求知欲强的技术人必须得获得理解和支持,那现在咱们先运行一下bochs试试,至少检测下是不是安装正确了,反正不会破坏咱们的电脑,缺什么的时候咱们再创建也不迟。
怕被读者埋怨我太啰嗦,赶紧在bochs安装路径下键入bin/bochs并赶紧按下了回车,运行效果如图1-2所示。
▲图1-2 bochs启动界面1
看,bochs界面中给出的提示符默认选项是[2],Read options from…,这是bochs要读取选项的节奏啊,也就是说要读取配置文件,直接按回车键。运行结果如图1-3所示。
▲图1-3 bochs启动界面2
我们键入的是上面长方形框框中的部分:bochsrc.disk。由于我们刚刚把此文件放到了bochs的安装路径下,bochs找到了它并加载成功。紧接着下面给出的默认选项变成了[6],也就是Begin simulation选项,开始模拟x86硬件平台。
再多说一句,bochs如果加载不到配置,它是不会向下运行的,所以在图1-3中,白色方框中若不键入配置文件名而直接回车,还是会回到图1-2所示的界面,必须给出配置让bochs知道您想模拟的硬件是什么才行。
继续回车,马上就有效果了,不过是报错了,如图1-4所示。
▲图1-4 bochs启动时找不到启动盘
哎哟,不错哦,果然没白测试,报的这是个PANIC级别的错误,BIOS说:“没有启动设备”。
缺什么我们就创建什么,提示没有的这个“bootable device”就是启动盘,现在就创建启动盘吧。
bochs先生说:“作为一个负责任的模拟器,既然干的就是模拟硬件的工作,那就要把硬件都模拟全了”,所以bochs给咱们提供了创建虚拟硬盘的工具bin/bximage。我们先看下这个命令的帮助,如图1-5所示。
▲图1-5 bximage工具
-fd创建软盘。
-hd创建硬盘。
-mode创建硬盘的类型,有flat、sparse、growing三种。
-size指创建多大的硬盘,以MB为单位。
-q以静默模式创建,创建过程中不会和用户交互。
按照上面的帮助,那咱们就开工啦,如图1-6所示。
bin/bximage -hd -mode="flat" -size=60 -q hd60M.img
这个命令串中最后一个hd60M.img是咱们创建的虚拟硬盘的名称。
▲图1-6 用bximage创建虚拟硬盘
如果大家觉得以上键入命令繁琐,不想用命令行的话,可以直接键入bin/bximage回车,后面的提示很清楚,很容易帮助大家创建硬盘。
硬盘创建好了,该如何安装到虚拟机中呢?
看图1-6下面的白色方框中的内容,bochs说:“The following line should appear in your bochsrc:下面的内容应该出现在你的配置文件中”。可见bochs的良苦用心,连硬盘的配置都给我们写好了,我们要做的就是复制这些到我们的bochsrc.disk中。可见,在bochs中有哪些硬件,就是通过配置文件来反映出来的。
事不宜迟,赶紧更新bochsrc.disk,找到第33行注释部分,将内容添加到35行,保存,如图1-7所示。
▲图1-7 在bochs配置文件中增加硬盘
此刻的我已经迫不及待地想看看bochs现在的运行情况,不过如果每次启动bochs后都要通过Read options from选项读取配置文件,这就太麻烦了,其实启动bochs的时候,有个更简便的方法,我们用-f来指定其配置文件便可。
bin/bochs –f bochsrc.disk 回车,观察效果,如图1-8所示。
▲图1-8 效果图
看上去和图1-4报错一样,都是提示没有启动盘。这是怎么回事呢?仔细看过之后,发现这里的报错和图1-4还是有些不同的,虽然结果是一样的错误,但原因是不同的。图1-4中的报错原因是boot failed: could not read the boot disk,这是无法读取启动盘。而现在这里的报错是boot failed: not a bootable disk,这不是一个启动盘。这两个原因明显不是一码事,就像某件衣服穿着不合适一样,原因是一个人是太胖了,另一个人是太瘦了。
不要灰心,这正是我们在下一章要讲的内容,什么才算启动盘,真正的启动盘上有什么。本章到此结束,下章我们再见。
不知道大家对“载入内存”这4个字的理解是怎样的。以下这两点是我曾经的疑问:第一,为什么程序要载入内存。第二,什么是载入内存。
先回答第一个。
CPU的硬件电路被设计成只能运行处于内存中的程序,这是硬件基因的问题,这样做的原因,首先肯定是内存比较快,且容量大。
其次,操作系统可以存储在软盘上,也可以存储在硬盘上,甚至U盘,当然还有很多存储介质都可以。但由于各个硬件特性不同,操作系统要分别考虑每种硬件的特性才行。所以,都在内存中运行程序,操作系统和硬件设计都省事了,这可能也是为了方式的统一吧,否则总不能出现某种存储介质后,操作系统和硬件就要付出额外努力去支持。当然,具体原因只有硬件工程师才知道,咱们在此先打住,继续咱们的内容。
马上回答第二个。
老听说“程序载入内存”,我不知道有多少同学对这个词仅仅是感性认识。
我隐约觉得很多同学都会将“载入内存”和“程序执行”画等号。所谓的载入内存,大概上分两部分。
(1)程序被加载器(软件或硬件)加载到内存某个区域。
(2)CPU的cs:ip寄存器被指向这个程序的起始地址。
操作系统在加载程序时,是需要某个加载器来将用户程序存储到内存中的。其实“加载器”这只是人为起的名字,突显了其功能,并不是多么神秘的东西,本质上它就是一堆函数组成的模块,不要因为未知的东西而感到畏惧。
从按下主机上的power键后,第一个运行的软件是BIOS。于是产生了三个问题。
(1)它是由谁加载的。
(2)它被加载到哪里。
(3)它的cs:ip是谁来更改的。
BIOS全称叫Base Input & Output System,即基本输入输出系统。
人们给任何事物起名字,肯定都不是乱起的,必然是根据该事物的特点,通过总结,精练出一些文字来标识此事物,这个便是对一般事物取名的方法。通过名字,就能够反应出该事物的特性。最符合特性的名字就是昵称和外号了,比如抽油机是用来开采石油的一种机器,因为其工作时,就像“磕头”一样,所以大家给其起了更形象的名字—“磕头机”。
回到BIOS上,输入输出我理解,命名中加上系统二字也明白,可为什么还要用“基本”来修饰呢?不知道您是不是和我一样喜欢咬文嚼字,我们必须得把它搞清楚。
先来点背景知识,很久很久以前:
Intel 8086有20条地址线,故其可以访问1MB的内存空间,即2的20次方=1048576=1MB,地址范围若按十六进制来表示,是0x00000到0xFFFFF。不知道硬件工程师当时设计的初衷是什么,总之人家有自己的理由,这1MB的内存空间被分成多个部分。
为了让大家先有个印象,免得太抽象不容易理解,先把实模式下1MB内存给大家梳理一下,很辛苦的,各位看官要仔细看哈,所以感兴趣或有强迫症的同学一定要背下来(玩笑),见表2-1。
表2-1 实模式下的内存布局
起始 |
结 束 |
大 小 |
用 途 |
---|---|---|---|
FFFF0 |
FFFFF |
16B |
BIOS入口地址,此地址也属于BIOS代码,同样属于顶部的640KB字节。只是为了强调其入口地址才单独贴出来。此处16字节的内容是跳转指令jmp f000:e05b |
F0000 |
FFFEF |
64KB-16B |
系统BIOS范围是F0000~FFFFF共640KB,为说明入口地址,将最上面的16字节从此处去掉了,所以此处终止地址是0XFFFEF |
C8000 |
EFFFF |
160KB |
映射硬件适配器的ROM或内存映射式I/O |
C0000 |
C7FFF |
32KB |
显示适配器BIOS |
B8000 |
BFFFF |
32KB |
用于文本模式显示适配器 |
B0000 |
B7FFF |
32KB |
用于黑白显示适配器 |
A0000 |
AFFFF |
64KB |
用于彩色显示适配器 |
9FC00 |
9FFFF |
1KB |
EBDA(Extended BIOS Data Area)扩展BIOS数据区 |
7E00 |
9FBFF |
622080B约608KB |
可用区域 |
7C00 |
7DFF |
512B |
MBR被BIOS加载到此处,共512字节 |
500 |
7BFF |
30464B约30KB |
可用区域 |
400 |
4FF |
256B |
BIOS Data Area(BIOS数据区) |
000 |
3FF |
1KB |
Interrupt Vector Table(中断向量表) |
先从低地址看,地址0~0x9FFFF处是DRAM(Dynamic Random Access Memory),即动态随机访问内存,我们所装的物理内存就是DRAM,如DDR、DDR2等。又要开始咬文嚼字了,动态是什么意思?动态指此种存储介质由于本身电气元件的性质,需要定期地刷新。内存中的每一位都是由电容和晶体管来组成的,您想,单条内存现在都到4GB,内存条的体积大小您也清楚,那么小的面积得集成多少电容才能够拼凑出4GB的内存容量,不包括相关电路元件,也得是4GB×8个电容了。如此小的电容,其缺点也是明显的,漏电很快,所以漏电了就要及时把电补充上去,这样数据才不至于丢失。这个补充电的过程就称为刷新。其实不仅是电容需要刷新,就连电信号也是一样的,不知道您注意了没有,我们平时使用的网线,也是需要在每隔一定长度距离时接个中继放大器,这个就是来放大电信号的,因为物理链路一长,信号衰减就特别严重,只好通过这种“打气”的方式来保持稳定了。终于把动态这一词搞定了,不过我们最终要搞定的词是BIOS中的“基本”,所以咱们还得接着看。
见表2-1,内存地址0~0x9FFFF的空间范围是640KB,这片地址对应到了DRAM,也就是插在主板上的内存条。有没有人开始小声嘀咕了:为什么是对应到了DRAM,难道不是直接访问到我的物理内存DRAM吗?难道我的内存条不是全部的内存?还可以访问到别处吗?如果您有这样的疑问,我除了回答是啊是啊之外,还是很欣慰的,终于有人和我之前想的一样了。
一会再解释这个,否则咱们离“基本”越来越远了。表2-1,看顶部的0xF0000~0xFFFFF,这64KB的内存是ROM。这里面存的就是BIOS的代码。BIOS的主要工作是检测、初始化硬件,怎么初始化的?硬件自己提供了一些初始化的功能调用,BIOS直接调用就好了。BIOS还做了一件伟大的事情,建立了中断向量表,这样就可以通过“int中断号”来实现相关的硬件调用,当然BIOS建立的这些功能就是对硬件的IO操作,也就是输入输出,但由于就64KB大小的空间,不可能把所有硬件的IO操作实现得面面俱到,而且也没必要实现那么多,毕竟是在实模式之下,对硬件支持得再丰富也白搭,精彩的世界是在进入保护模式以后才开始,所以挑一些重要的、保证计算机能运行的那些硬件的基本IO操作,就行了。这就是BIOS称为基本输入输出系统的原因。
现在开始解释另一个问题,在CPU眼里,为什么我们插在主板上的物理内存不是它眼里“全部的内存”。
地址总线宽度决定了可以访问的内存空间大小,如16位机的地址总线为20位,其地址范围是1MB,32位地址总线宽度是32位,其地址范围是4GB。但以上的地址范围是指地址总线可以触及到的边界,是指计算机在寻址上可以到达的疆域。可是人家并没有说要寻哪里,就拿16位机来说,并没有说这1MB的寻址范围必须得是物理内存(内存条),难道人家20位的地址总线就认得这一亩三分地?完全不是。
归根结底的原因是这样的:在计算机中,并不是只有咱们插在主板上的内存条需要通过地址总线访问,还有一些外设同样是需要通过地址总线来访问的,这类设备还很多呢。若把全部的地址总线都指向物理内存,那其他设备该如何访问呢?由于这个原因,只好在地址总线上提前预留出来一些地址空间给这些外设用,这片连续的地址给显存,这片连续的地址给硬盘控制器等。留够了以后,地址总线上其余的可用地址再指向DRAM,也就是指插在主板上的内存条、我们眼中的物理内存。示意如图2-1所示。
▲图2-1 地址映射
物理内存多大都没用,主要是看地线总线的宽度。还要看地址总线的设计,是不是全部用于访问DRAM。所以说,地址总线是决定我们访问哪里、访问什么,以及访问范围的关键。我们平时用的机器一般是32位,上面的内存条并不是全部都用到了,按理说内存条大小超过4GB就没意义了,超过了地址总线的势力就是浪费。不过通过前面的介绍,即使内存条大小没有超过地址总线的范围,也不会全都能被访问到,毕竟要预留一些地址用来访问其他外设,所以最终还得看地址总线把地址指向哪块内存了。这就是安装了4GB内存,电脑中只显示3.8GB左右的原因。
总之,表示地址的那串数字是地址总线的输入,相当于其参数,和内存条没关系。CPU能够访问一个地址,这是地址总线给做的映射,相当于给该地址分配了一个存储单元,而该存储单元要么落在某个rom中,要么落到了某个外设的内存中,要么落到了物理内存条上。可以想像成,CPU给地址总线提交一个数字,在地址总线看来,这串数字就是地址。地址分配电路根据此地址的范围,决定在哪个存储介质中分配一个存储单元,最后将此地址与此存储单元对应起来。当然事实上未必是这样,我刚才说了,可以想像成这样。我们学习新的知识,很多时候都是建立在原有的知识上,用原有的知识帮助学习新的知识,就像第一次听说电动车的时候,我们潜意识里是用车和蓄电池的概念在联想电动车的形象。如果要学的是一种全新的知识,并且无从用旧的知识来辅助学习时,试图靠想像力是非常有效的。对于知识的掌握,这并没有什么标准,每个人对知识的理解都是不同的,即使两个人都考了满分,其思考过程也是不同的。所以,对于一个新知识的掌握,本质上是给了一个能够说服自己的理由,能够自圆其说,这就够了。
BIOS其实一直睡在某个地方,直到被唤醒……
前面热火朝天地说了BIOS的功能和内存布局,似乎还没说到正题上,BIOS是如何启动的呢?因为BIOS是计算机上第一个运行的软件,所以它不可能自己加载自己,由此可以知道,它是由硬件加载的。那这个硬件是谁呢?其实前面已经提到过了,相当于是只读存储器ROM,因为它一直就睡在那里不动。
大家知道,只读存储器中的内容是不可擦除的,也就是它不像动态随机访问存储器DRAM那样,掉电后,里面的数据就会丢失。这种存储介质是用来存储一成不变的数据的,当数据写进去后,便与日月同辉,庭前坐看花开花落,不朽于天地万物之间,哈哈,有点夸张了。
BIOS代码所做的工作也是一成不变的,而且在正常情况下,其本身是不需要修改的,平时听说的那些主板坏了要刷BIOS的情况属于例外。于是BIOS顺理成章地便被写进此ROM。ROM也是块内存,内存就需要被访问。此ROM被映射在低端1MB内存的顶部,即地址0xF0000~0xFFFFF处,可以参考表2-1顶部的BIOS部分。只要访问此处的地址便是访问了BIOS,这个映射是由硬件完成的。
BIOS本身是个程序,程序要执行,就要有个入口地址才行,此入口地址便是0xFFFF0。
最重要的一点来了,知道了BIOS在哪里后,CPU如何去执行它,即CPU中的cs:ip值是如何组合成0xFFFF0的。
如果大家不了解内存的分段访问机制,可以参考第0章,里面有讲解CPU为什么分段方式内存。说正事,CPU访问内存是用段地址+偏移地址来实现的,由于在实模式之下,段地址需要乘以16后才能与偏移地址相加,求出的和便是物理地址,CPU便拿此地址直接用了。这个“段基址:段内偏移地址”的组合是0xffff:0吗?或者是0xF000:0xFFF0?或者是更奇葩一点的组合:0xFEEE:0x1110?或者您想出的组合比我的还奇葩,好啦,不折磨大家了,还是说正事要紧。既然作为第一个运行的程序都没开始执行,自然就没办法用软件搞定这件事了,还是得靠硬件支持才行。
在开机的一瞬间,也就是接电的一瞬间,CPU的cs:ip寄存器被强制初始化为0xF000:0xFFF0。由于开机的时候处于实模式,再重复一遍加深印象,在实模式下的段基址要乘以16,也就是左移4位,于是0xF000:0xFFF0的等效地址将是0xFFFF0。上面说过了,此地址便是BIOS的入口地址。
当我给出这个地址后,不知道大家意识到什么没有。BIOS是在实模式下运行的,而实模式只能访问1MB空间(20位地址线,2的20次方是1MB)。而地址0xFFFF0距1MB只有16个字节了(见表2-1除标题外的第一行),这么小的空间够干吗?BIOS又要检测硬件,做各种初始化工作,还要建立中断向量表……16字节的机器指令肯定干不了这么多事。也许有的同学会问,超过寄存器宽度会怎么样呢?比如0xFFFF0+16,这样就溢出了,由于实模式下的寄存器宽度是16位,0xFFFF0+16已经超过了其最大值0xFFFFF。溢出的部分就会回卷到0,又会重新开始,即0xFFFF0+16等于0,0xFFFF0+17等于1。
既然此处只有16字节的空间了,这只能说明BIOS真正的代码不在这,那此处的代码只能是个跳转指令才能解释得通了。好,既然心里有了推断,那咱们就来证明这个推断正确与否。
图2-2是我在bochs中抓的图,下面给大家分析一下这图中的信息都代表什么。
▲图2-2 bochs开机界面
首先得承认,这张图有点超前了,这是在有了MBR后才能抓到的,否则会提示boot failed: not a bootable disk,而我们还没有MBR,还没有写主引导记录。先不管这张图是怎么来的啦,反正大家立即就能够在自己的虚拟机里看到这张图了。大家先注意框框中的内容。一共有3个,最上面左边第1个标有cs:ip的那个框,cs寄存器的值是0xf000,ip寄存器的值是0xfff0,也就是段基址0xf000,段内偏移地址0xfff0,这个组合出来的地址便是0xffff0,这是处理器下一条待执行指令的地址。这与上面所说的BIOS入口地址是吻合的。另外,因为cs和ip寄存器中存储的是下一条要执行的指令,目前还没有执行,也就是说,当前还没有执行BIOS,这是机器刚开机的那一刻。这一刻还是值得庆祝的,因为即使是计算机行业的同学都很少看到这一刻,何况我们让这一刻停了下来,成为永恒。
按理说,既然让CPU去执行0xFFFF0处的内容(目前还不知道其是指令,还是数据),此内容应该是指令才行,否则这地址处的内容若是数据,而不是指令,CPU硬是把它当成指令来译码的话,一定会弄巧成拙铸成大错。现在咱们又有了新的推断,物理地址0xFFFF0处应该是指令,继续探索。
继续看第二个框框,里面有条指令jmp far f000:e05b,这是条跳转指令,也就是证明了在内存物理地址0xFFFF0处的内容是一条跳转指令,我们的判断是正确的。那CPU的执行流是跳到哪里了呢?段基址0xf000左移4位+0xe05b,即跳向了0xfe05b处,这是BIOS代码真正开始的地方。
第三个框框cs:f000,其意义是cs寄存器的值是f000,与我们刚刚所说的加电时强制将cs置为f000是吻合的,正确。
接下来BIOS便马不停蹄地检测内存、显卡等外设信息,当检测通过,并初始化好硬件后,开始在内存中0x000~0x3FF处建立数据结构,中断向量表IVT并填写中断例程。
好了,终于到了接力的时刻,这是这场接力赛的第一棒,它将交给谁呢?咱们下回再说。
计算机执行到这份上,BIOS也即将完成自己的历史使命了,完成之后,它又将睡去。想到这里,心中不免一丝忧伤,甚至有些许挽留它的想法。可是,这就是它的命,它生来被设计成这样,在它短暂的一生中已经为后人创造了足够的精彩。何况,在下一次开机时,BIOS还会重复这段轮回,它并没有消失。好了,让伤感停止,让梦想前行。
先说重点,BIOS最后一项工作校验启动盘中位于0盘0道1扇区的内容。
在此插播一段小告示:在计算机中是习惯以0作为起始索引的,因为人们已经习惯了偏移量的概念,无论是机器眼里和程序员眼里,用“相对”的概念,即偏移量来表示位置显得很直观,所以很多指令中的操作数都是用偏移量表示的。0盘0道1扇区本质上就相当于0盘0道0扇区。为什么称为1呢,因为硬盘扇区的表示法有两种,我们描述0盘0道1扇区用的便是其中的一种:CHS方法,即柱面Cylinder 磁头Header 扇区Sector(另外一种是LBA方式,暂不关心),“0盘”说的是0磁头,因为一张盘是有上下两个盘面的,一个盘面上对应一个磁头,所以用磁头Header来表示盘面。“0道”是指0柱面,柱面Cylinder指的是所有盘面上、编号相同的磁道的集合,形象一点描述就是把很多环叠摞在一起的样子,组合在一起之后是一个立体的管状。“1扇区”才是我们要解释的部分,将磁道等距划分成一段段的小区间,由于磁道是圆形的,确切地说是圆环,这些被划分出来的小区间便是扇形,所以称为扇区。好了,背景交待完了,重点来了,在CHS方式中扇区的编号是从1开始的,不是0,不是0,原谅我说了两次,良苦用心你懂的,所以0盘0道1扇区其实就相当于0盘0道0扇区,它就是磁盘上最开始的那个扇区。而LBA方式中,扇区编号是从0开始的。关于硬盘的知识我会在以后章节专门来讲,这里我若没表达清楚,大家先不要着急,只要知道MBR所在的位置是磁盘上最开始的那个扇区就行了。
继续说,如果此扇区末尾的两个字节分别是魔数0x55和0xaa,BIOS便认为此扇区中确实存在可执行的程序(在此先剧透一下,此程序便是久闻大名的主引导记录MBR),便加载到物理地址0x7c00,随后跳转到此地址,继续执行。
这里有个小细节,BIOS跳转到0x7c00是用jmp 0:0x7c00实现的,这是jmp指令的直接绝对远转移用法,段寄存器cs会被替换,这里的段基址是0,即cs由之前的0xf000变成了0。
如果此扇区的最后2个不是0x55和0xaa,即使里面有可执行代码也无济于事了,BIOS不认,它也许还认为此扇区是没格干净呢。
不过,这就又抛出两个问题。
(1)为什么是0盘0道1扇区的内容?
(2)为什么是物理地址0x7c00,而不是个好记或好看的其他地址?
先回答第1个,我想这个问题不用官方解释了,因为官方确实没什么好说的,不过他们出于尊重客户,还是会像我一样说出类似下面的话。
我就个人观点给大家一个理由,未经核实,仅是自己一面之词,请大家提高警惕,小心谨慎^—^。
在计算机中处处充满了协议、约定,所以,将0盘0道1扇区作为mbr的栖身之地,我完全可以理解为规定。我们反证一下,如果不存在这个“规定”,会发生什么。当然,此扇区最初是给BIOS使用的,咱们设想一下BIOS的工作将变成怎样。
主引导记mbr是段程序,无论位于软盘、硬盘或者其他介质,总该有个地方保存它。Ok,现在不告诉BIOS它存储在哪个位置了。BIOS只好将所有检测到的存储设备上的每一个存储单位都翻一遍,挨个对比,如果发现该存储单位最后的两个字节是0x55和0xaa,就认为它是mbr。这就好比查字典一样,不用偏旁部首和拼音检索的方法,只能一页一页翻了。
几经花开花落,找到mbr的那一刻,BIOS满脸疲惫地说:“你是我找了好久好久的那个人”。mbr抬起经不起岁月等待的脸:“难得你还认得我,我等你等到花儿都谢了”。其实BIOS的心声是:“看我手忙脚乱的样子,你们这是要闹哪样啊。就那么512字节的内容,害我找遍全世界,我们是在跑接力赛啊,下一棒的选手我都不知道在哪里……以后让它站在固定的位置等我!”
由于0盘0道1扇区是磁盘的第一个扇区,mbr选择了离BIOS最近的位置站好了,从此以后再也不担心被BIOS骂了。
计算机中处处有固定写死的东西,还用举个例子吗?不用了吧?因为任何一个魔数都是啊,有请下一个魔数0x7c00登场。
至于0x7c00,很久之前,比我好奇心大的人查遍了Intel开发手册都没找到相关的说明。要想知道事情的来龙去脉,还是要从个人计算机的初始说起,同样是很久很久以前……
1981年8月,IBM公司生产了世界上第一台个人计算机PC 5150,所以它就是现代x86个人计算机兼容机的祖先。说到有关历史的东西,不给来点真相就感觉气场不足,图2-3所示便是IBM PC 5150,有没有感受到计算机文化底蕴呢?
▲图2-3 IBM PC 5150
既然Intel开发手册中没有相关说明,那咱们就朝其他方向找答案,换句话说,既然不是CPU的硬性规定,那很可能就是代码中写死的。为了搞清楚0x7c00是哪里来的,咱们先探索下“IBM PC 5150”的BIOS的秘密。请先深深呼吸一大口气,“0x7C00”最早出现在IBM 公司出产的个人电脑PC5150的ROM BIOS的 INT19H中断处理程序中,说了这么多定语,感觉气都喘不上来了。
通电开机之后,BIOS处理程序开始自检,随后,调用BIOS中断0x19h,即 call int 19h。在此中断处理函数中,BIOS要检测这台计算机有多少硬盘或软盘,如果检测到了任何可用的磁盘,BIOS就把它的第一个扇区加载到0x7c00。
现在应该搞清楚了为什么在x86手册里找不到它的说明了,它是属于BIOS中的规范。似乎这下好办了,既然是BIOS中的规范,那肯定是IBM PC 5150 BIOS 开发团队规定的这个数。
个人计算机肯定要运行操作系统,在这台计算机上,运行的操作系统是DOS 1.0,不清楚此系统要求的最小内存是16KB,还是32KB,反正PC 5150 BIOS研发工程师就假定其是32KB的,所以此版本BIOS是按最小内存32KB研发的。
MBR不是随便放在哪里都行的,首先不能覆盖已有的数据,其次,不能过早地被其他数据覆盖。不覆盖已有数据,这个好理解。说一下后面这个“其次”。通常,MBR的任务是加载某个程序(这个程序一般是内核加载器,很少有直接加载内核的)到指定位置,并将控制权交给它。所谓的交控制权就是jmp过去而已。之后MBR就没用了,被覆盖也没关系。我说的过早被覆盖,是指不能让mbr破坏自己,比如被加载的程序,如内核加载器,其放置的内存位置若是MBR自己所在的范围,这不就是破坏自己了吗,这就是我所说的“过早”了,怎么也得等MBR执行完才行。
重现一下当时的内存使用情况。
8086CPU要求物理地址0x0~0x3FF存放中断向量表,所以此处不能动了,再选新的地方看看。
按DOS 1.0要求的最小内存32KB来说,MBR希望给人家尽可能多的预留空间,这样也是保全自己的作法,免得过早被覆盖。所以MBR只能放在32KB的末尾。
MBR本身也是程序,是程序就要用到栈,栈也是在内存中的,MBR虽然本身只有512字节,但还要为其所用的栈分配点空间,所以其实际所用的内存空间要大于512字节,估计1KB内存够用了。
结合以上三点,选择32KB中的最后1KB最为合适,那此地址是多少呢?32KB换算为十六进制为0x8000,减去1KB(0x400)的话,等于0x7c00。这就是倍受质疑的0x7c00的由来,这下清楚了。
可见,加载MBR的位置取决于操作系统本身所占内存大小和内存布局。
我想大家现在都心痒痒了吧,说了这么久,CPU中运行的都是BIOS的代码,连自己一句代码都没跑起来呢。事不宜迟,马上写一个MBR,先让它跑起来再说。
虽说主引导记录mbr是咱们能够掌控的第一个程序,但这并不是让我们为之激动的理由。我们平时所写的程序都要依赖于操作系统,而我们即将实现的这个程序是独立于操作系统的,能够直接在裸机上运行,这才是让我们激动的理由,对咱们来说这无疑是历史性的一刻。还记得当初我的MBR跑起来时,那可真是发自内心的高兴呀。
好了,不再抒情了,说正事要紧。MBR的大小必须是512字节,这是为了保证0x55和0xaa这两个魔数恰好出现在该扇区的最后两个字节处,即第510字节处和第511字节处,这是按起始偏移为0算起的。由于我们的bochs模拟的是x86平台,所以是小端字节序,故其最后两个字节内容是0xaa55,写到一起后似乎有点不认识了,不要怕,拆开就是0x55和0xaa。
$和$$是编译器NASM预留的关键字,用来表示当前行和本section的地址,起到了标号的作用,它是NASM提供的,并不是CPU原生支持的,相当于伪指令一样,对CPU来说是假的。
指令本来没有真伪之别,就像酒一样,因为有了假酒,所以才有了真酒之说。伪指令是相对于CPU可识别的指令来说的,它(伪指令)只是编译器定义的,CPU中并不存在这个指令,愣让CPU执行这些伪指令,CPU会抛出“UD(未定义的操作码)”异常。伪指令是编译器为了开发人员写代码方便而提供的一些符号,这些符号在编译时,会由编译器转换成CPU可识别的东西,如指令或地址等。
汇编语言中的标号是程序员“显式地”写在明处的,如:
……
code_start:
mov ax, 0
……
code_start这个标号被nasm认为是一个地址,此地址便是“mov ax,0”这条指令所在的地址,即其指令机器码存放的内存位置是code_start。code_start只是个标记,CPU并不认识,和伪指令类似,它是假的,CPU不认。所以nasm会用为其安排的地址来替换标号code_start,到了CPU手中,已经被替换为有意义的数字形式的地址了。
$属于“隐式地”藏在本行代码前的标号,也就是编译器给当前行安排的地址,看不到却又无处不在,$在每行都有。或者这种说法并不是很正确,只有“显示地”用了$的地方,nasm编译器才会将此行的地址公布出来。如果上面的例子改为:
……
code_start:
jmp $
……
这就和jmp code_start是等效的。$和code_start是同一个值。
$$指代本section的起始地址,此地址同样是编译器给安排的。
对于$和$$的意义,我强调过了,是编译器给安排的地址,默认情况下,它们的值是相对于本文件开头的偏移量。至于实际安排的是多少,还要看程序员同学是否在section中添加了vstart。这个关键字可以影响编译器安排地址的行为,如果该section用了vstart=xxxx修饰,$$的值则是此section的虚拟起始地址xxxx。$的值是以xxxx为起始地址的顺延。如果用了vstart关键字,想获得本section在文件中的真实偏移量(真实地址)该怎么做?nasm编译器提供了这个方法。
section.节名.start。
如果没有定义section,nasm默认全部代码同为一个section,起始地址为0。
稍带说一下section。很多东西从名字上就能理解它的功能,毕竟名字不是乱起的。section也称为节、段,故名思义,是程序中的一小块,形象一点地说,就是用section这个关键字在程序中圈出一块地,并向编译器宣称,这块地我要做些规划,至于我用来干什么您就不用操心了,编译时请您合理安排。
为什么说合理安排呢,因为section是伪指令,是nasm提供的,具体解释权还是人家nasm说了算。比如以下代码:
section data
var dd 0
section code
jmp start
… ...
编译器一看这两个section,data中定义的是变量,code中是代码,于是把这两个section的内容分别归入最终的数据段和代码段。
有时候nasm并不会完全听您的,如改为下面的例子:
section data_a
var dd 0
section code
jmp start
section data_b
var dd 1
……
虽然人为定义了三个section,但nasm发现data_a和data_b这两个section完全能够合并到一起,于是在编译阶段会被“合理”地安排到一起。
在第0章中有说明section和segment的区别。section是伪指令,CPU运行程序是不需要这个东西的,这个只是用来给程序员规划程序用的,有了section,就可以将自己的代码分成一段一段的,当然这只是在逻辑上的段,实际上编译出来的程序还是完整的一体。逻辑上划分成段的好处是方便开发人员梳理代码,方便管理。想像一下,把一大片农田按亩来划分成一个个的小段,一眼望去,是不是显得井然有序呢?单是简短的几行汇编代码是无法体现出这一优势的,就像如果农田本来就不大,还要划分成多个段,那自然是得不偿失的。当代码量上去的时候,会发现如果不在逻辑上将其拆分成几块,对一锅粥似的代码进行维护,代价还是很大的,可能一会儿脑子也像一锅粥了呢。
划分成section后,编译器便根据您的意图,将这些section中的内容安排位置,它被安排到哪里咱们是不需要关心的,咱们也不必管,因为程序内部的关联是通过地址实现的。想想看,无非是section被安排到A位置,其他用到此section中内容的相关指令,其操作数为A地址,若section被安排到B位置,操作数便是B地址,这些都是编译器安排的,它会帮您圆上的。
关于section地址更详细的说明,大家可以参照第3章,这里只是抛砖引玉。
总之,section是给开发人员逻辑上规划代码用的,只起到思路清晰的作用,最终还是在编译阶段由nasm在物理上的规划说了算。
在咱们的实际工程中只用到了nasm的一些简单功能,所以不必担心连操作系统的一句代码都没写呢,却先要为学习其他的东西而付出额外的精力。
nasm -f <format><filename> [-o <output>]
以上是nasm的基本用法,对咱们来说,够用了。注意我说的是“基本”,还有好多其他参数呢,不过咱们用不着。甚至,大多数时候连-f都不用呢。
-o 就是指定输出可执行文件的名称。
查看一下nasm的帮助,ok,执行man nasm回车,输出的信息太多了,我们只看-f的说明就行了。
-f format
Specifies the output file format.
To see a list of valid output formats, use the -hf option.
瞧,人家说啦,-f是用来指定输出文件的格式。要想知道有多少种有效的输出格式,用-hf选项。那咱们还是用nasm –hf来查看一下吧,见表2-2。
表2-2 nasm编译输出的格式
格 式 |
描 述 |
---|---|
bin |
flat-form binary files (e.g. DOS .COM, .SYS),此项为默认 |
ith |
Intel hex |
srec |
Motorola S-records |
aout |
Linux a.out object files |
aoutb |
NetBSD/FreeBSD a.out object files |
coff |
COFF (i386) object files (e.g. DJGPP for DOS) |
elf32 |
ELF32 (i386) object files (e.g. Linux) |
elf64 |
ELF64 (x86_64) object files (e.g. Linux) |
elfx32 |
ELFX32 (x86_64) object files (e.g. Linux) |
as86 |
Linux as86 (bin86 version 0.3) object files |
obj |
MS-DOS 16-bit/32-bit OMF object files |
win32 |
Microsoft Win32 (i386) object files |
win64 |
Microsoft Win64 (x86-64) object files |
rdf |
Relocatable Dynamic Object File Format v2.0 |
ieee |
IEEE-695 (LADsoft variant) object file format |
macho32 |
NeXTstep/OpenStep/Rhapsody/Darwin/MacOS X (i386) object files |
macho64 |
NeXTstep/OpenStep/Rhapsody/Darwin/MacOS X (x86_64) object files |
dbg |
Trace of all info passed to output stage |
elf |
ELF (short name for ELF32) |
macho |
MACHO (short name for MACHO32) |
win |
WIN (short name for WIN32) |
一共列出了21个,不过大部分格式和咱们关系不大,咱们只关注bin和elf格式就好啦。
既然bin是默认输出格式,也就是不用-f bin来明确指定了,所以以后咱们只在输出elf格式时才用-f指定。
bin是指纯二进制。二进制就二进制吧,还有不纯的?就像前面的拿酒举例一样,本来没有真酒之说,由于有了假酒的出现,才有了真的说法。纯二进制就是不掺杂其他的东西,直接给CPU后就能用,也就是可执行文件中什么样,内存中就什么样。我们平时所说的elf或pe格式的二进制可执行文件,那里面有好多和指令无关的东西,里面掺杂了程序的内存布局、位置等信息,这是给操作系统中的程序加载器用的,是属于操作系统规划的范畴了。
有点不好意思了,说了好久,才说到实质性的东西,好了,赶紧说正题。
代码2-1 (c2/a/boot/mbr.S)
1 ;主引导程序
2 ;------------------------------------------------------------
3 SECTION MBR vstart=0x7c00
4 mov ax,cs
5 mov ds,ax
6 mov es,ax
7 mov ss,ax
8 mov fs,ax
9 mov sp,0x7c00
10
11 ; 清屏利用0x06号功能,上卷全部行,则可清屏。
12 ; -----------------------------------------------------------
13 ;INT 0x10 功能号:0x06 功能描述:上卷窗口
14 ;------------------------------------------------------
15 ;输入:
16 ;AH 功能号= 0x06
17 ;AL = 上卷的行数(如果为0,表示全部)
18 ;BH = 上卷行属性
19 ;(CL,CH) = 窗口左上角的(X,Y)位置
20 ;(DL,DH) = 窗口右下角的(X,Y)位置
21 ;无返回值:
22 mov ax, 0x600
23 mov bx, 0x700
24 mov cx, 0 ; 左上角: (0, 0)
25 mov dx, 0x184f ; 右下角: (80,25),
26 ; VGA文本模式中,一行只能容纳80个字符,共25行。
27 ; 下标从0开始,所以0x18=24,0x4f=79
28 int 0x10 ; int 0x10
29
30 ;;;;;;;;; 下面这三行代码获取光标位置 ;;;;;;;;;
31 ;.get_cursor获取当前光标位置,在光标位置处打印字符。
32 mov ah, 3 ; 输入: 3号子功能是获取光标位置,需要存入ah寄存器
33 mov bh, 0 ; bh寄存器存储的是待获取光标的页号
34
35 int 0x10 ; 输出: ch=光标开始行,cl=光标结束行
36 ; dh=光标所在行号,dl=光标所在列号
37
38 ;;;;;;;;; 获取光标位置结束 ;;;;;;;;;;;;;;;;
39
40 ;;;;;;;;; 打印字符串 ;;;;;;;;;;;
41 ;还是用10h中断,不过这次调用13号子功能打印字符串
42 mov ax, message
43 mov bp, ax ; es:bp 为串首地址,es此时同cs一致,
44 ; 开头时已经为sreg初始化
45
46 ; 光标位置要用到dx寄存器中内容,cx中的光标位置可忽略
47 mov cx, 5 ; cx 为串长度,不包括结束符0的字符个数
48 mov ax, 0x1301 ;子功能号13显示字符及属性,要存入ah寄存器,
49 ; al设置写字符方式 ah=01: 显示字符串,光标跟随移动
50 mov bx, 0x2 ; bh存储要显示的页号,此处是第0页,
51 ; bl中是字符属性,属性黑底绿字(bl = 02h)
52 int 0x10 ; 执行BIOS 0x10 号中断
53 ;;;;;;;;; 打字字符串结束 ;;;;;;;;;;;;;;;
54
55 jmp $ ; 使程序悬停在此
56
57 message db "1 MBR"
58 times 510-($-$$) db 0
59 db 0x55,0xaa
简短说一下代码功能,在屏幕上打印字符串“1 MBR”,背景色为黑色,前景色为绿色。
由于还没有给大家讲解显卡的使用方法,故本段代码中关于“打印显示”的操作都利用BIOS给我们建立好的例程就好了,这里第0x10号中断便是负责有关打印的例程。
0x10中断是最为强大的BIOS中断了,调用的方法是把功能号送入ah寄存器,其他参数按照BIOS中断手册的要求放在适当的寄存器中,随后执行int 0x10即可。我们不用太细致琢磨BIOS功能调用了,大家可以参数代码中的注释了解下即可,毕竟咱们这里用BIOS中断只是临时的,以后也用不到了。
第3行的“vstart=0x7c00”表示本程序在编译时,告诉编译器,把我的起始地址编译为0x7c00。
第4~8行是用cs寄存器的值去初始化其他寄存器。由于BIOS是通过jmp 0:0x7c00跳转到MBR的,故cs此时为0。对于ds、es、fs、gs这类sreg,CPU中不能直接给它们赋值,没有从立即数到段寄存器的电路实现,只有通过其他寄存器来中转,这里我们用的是通用寄存器ax来中转。例如mov ds:0x7c00,这样就错了。
第9行是初始化栈指针,在CPU上运行的程序得遵从CPU的规则,mbr也是程序,是程序就要用到栈。目前0x7c00以下暂时是安全的区域,就把它当作栈来用。
第11~28行是清屏。因为在BIOS工作中,会有一些输出,如检测硬件的结果信息。为了让大家看清楚我们在MBR中的输出字符串,故先把BIOS的输出清掉,这里演示的是BIOS中断int 0x10的用法。
第30~35行是做打印前的工作,先获取光标位置,目的是避免打印字符混乱,覆盖别人的输出。其实这是防君子不防小人的做法,万一别人不在光标处打印,自己打印的内容同样也会被别人覆盖。不管别人了,咱们做好自己的就行,老老实实地只在光标处打印。不知道这是否能提醒大家,字符打印的位置,不一定要在光标处,字符的位置只和显存中的地址有关,和光标是没关系的,这只是人为地加个约束,毕竟光标在视觉上告诉了我们当前字符写到哪里了,完全是为了好看,不要以为光标就是新打印字符的位置。更多细节,以后讲显卡时会提到。
这里还用到了页的概念,您看第33行,往bh寄存器中写入了0,这是告诉BIOS例程,我要获取第0页当前的光标。什么是页呢?
显示器有很多种模式,如图形模式、文本模式等,在文本模式中,又可以工作于80*25和40*25等显示方式,默认情况下,所有个人计算机上的显卡在加电后都将自己置为80*25这种显示方式。80*25是指一屏可以显示25行、每行80列的字符,也就是2000个字符。但由于一个字符要用两字节来表示,低字符是字符的ASCII编码,高字节是字符属性,故显示一屏字符需要用4000字节(实际上,分配给一屏的容量是4KB),这一屏就称为一页,0页是默认页。
第38~52行是往光标处打印字符。说一下第48行的mov ax,0x1301,13对应的是ah寄存器,这是调用0x13号子功能。01对应的是al寄存器,表示的是写字符方式,其低2位才有意义,各位功能描述如下。
(1)al=0,显示字符串,并且光标返回起始位置。
(2)al=1,显示字符串,并且光标跟随到新位置。
(3)al=2,显示字符串及其属性,并且光标返回起始位置。
(4)al=3,显示字符串及其属性,光标跟随到新位置。
第55行执行了个死循环,$是本行指令的地址,这属于伪指令,是汇编器在编译期间分配的地址。在最终编译出来的程序中,$会被替换为指令实际所在行的地址。jmp是个近跳转,$是jmp自己的地址,于是跳到自己所在的地址再执行自己,又是跳到自己所在的地址再继续执行跳转,这样便实现了死循环。可见CPU可乖了,它只会埋头做事,并不会觉得有什么不妥,靠谱,值得依赖。
第57行是定义打印的字符串。
第58行的$$是指本section的起始地址,上面说过了$是本行所在的地址,故$-$$是本行到本section的偏移量。由于MBR的最后两个字节是固定的内容,分别是0x55和0xaa,要预留出这2个字节,故本扇区内前512-2=510字节要填满,那到底要用多少字节才能填满此扇区呢。用510字节减去上面通过$-$$得到的偏移量,其结果便是本扇区内的剩余量,也就是要填充的字节数。由此可见第50行的“times 510-($-$$) db 0”是在用0将本扇区剩余空间填充。
代码说完了,可还有两件大事要做,1是编译,2是如何将编译后的文件存储到0盘0道1扇区中成为MBR,以供BIOS大神加载之用。
前面介绍了nasm的用法,咱们马上来编译汇编代码。
nasm -o mbr.bin mbr.S回车,您看,这样就编译成功了,我连-f都没有指定吧。按理说此文件大小是512字节,咱们用ls命令验证一下:ls -lb mbr.bin回车,以下是ls的输出。
-rw-rw-r--. 1 work work 512 7月 26 21:10 mbr.bin
用过Linux的同学对这个输出还是很熟悉的,若头一次用Linux的同学也不要慌张,这里面好多的信息并不重要,只要看看中间部分就好了,512,果然是512字节,这下心里踏实了,下一步是考虑如何将此文件写入0盘0道1扇区。
这里再给大家介绍另一个Linux命令:dd。dd是用于磁盘操作的命令,功能太强大了,有如穿甲弹一样,可以深入磁盘的任何一个扇区,无坚不摧。所以,它也可以删除Linux操作系统自己的文件,是把双刃剑。
还是先看帮助文件,man dd回车,为了节约大家的时间,我只把咱们今后用到的几个选项摘了出来,还是那句话,够用就行了,需要时再学。
if=FILE
read from FILE instead of stdin
此项是指定要读取的文件。
of=FILE
write to FILE instead of stdout
此项是指定把数据输出到哪个文件。
bs=BYTES
read and write BYTES bytes at a time (also see ibs=,obs=)
此项指定块的大小,dd是以块为单位来进行IO操作的,得告诉人家块是多大字节。此项是统计配置了输入块大小ibs和输出块大小obs。这两个可以单独配置。
count=BLOCKS
copy only BLOCKS input blocks
此项是指定拷贝的块数。
seek=BLOCKS
skip BLOCKS obs-sized blocks at start of output
此项是指定当我们把块输出到文件时想要跳过多少个块。
conv=CONVS
convert the file as per the comma separated symbol list
此项是指定如何转换文件。
append append mode (makes sense only for output; conv=notrunc suggested)
这句话建议在追加数据时,conv最好用notrunc方式,也就是不打断文件。
齐了,dd的介绍就到这了,赶紧试验一下这个神奇的工具吧。
dd if=/your_path/mbr.bin of=/your_path/bochs/hd60M.img bs=512 count=1 conv=notrunc
各位看官,请将上面命令行中的your_path替换为您自己的实际路径。
输入文件是刚刚编译出来的mbr.bin,输出是我们虚拟出来的硬盘hd60M.img,块大小指定为512字节,只操作1块,即总共1*512=512字节。由于想写入第0块,所以没用seek指定跳过的块数。
执行上面的命令后,会有如下输出。
记录了1+0 的读入
记录了1+0 的写出
512字节(512 B)已复制,0.313312 秒,1.6 KB/秒
这就说明命令执行成功了,mbr.bin已经写进hd60M.img的第0块了。借鉴美国宇航员阿姆斯特朗的一句话:虽然这只是简单的一小步,但却是实现我们自己系统的一大步。记得当初我可是非常激动呢。
启动bochs测试一下,我习惯到bochs安装目录下启动它,bin/bochs –f bochsrc.disk回车,接着会显示如图2-4所示的界面。
▲图2-4 bochs启动
默认是[6],开始模拟啦。回车。
由于咱们编译的是可调试的版本,所以会停下来,bochs等待咱们键入下一步的命令,如图2-5所示。
大家看到,这一下弹出了两个界面,前面的那个是bochs所模拟的机器,可以认为它就是台电脑了,不仅仅是电脑的显示器。后面的界面是bochs的控制台,咱们控制bochs运行就要在这里输入命令。现在激活后面的bochs控制台,输入字符c后,回车。bochs所模拟的机器就开始运行了。这里键入的c是continue,调试方法同gdb类似,详细的bochs操作方法咱们会在下一章中介绍。
▲图2-5 bochs运行
MBR运行起来后,就会出现下面的效果,如图2-6所示。
▲图2-6 bochs运行mbr
下一章正式开讲细节部分。