主书名:《Linux内核设计的艺术》
副标题: 图解Linux操作系统架构设计与实现原理
作者: 新设计团队
出版社: 机械工业出版社华章公司
出版年: 2011-6-20
页数: 444
定价: 79.00元
装帧: 平装
ISBN: 9787111347446
豆瓣网讨论地址:http://book.douban.com/subject/6433169
China-pub地址:http://product.china-pub.com/198276
图书内容:
《Linux内核设计的艺术:图解Linux操作系统架构设计与实现原理》内容简介:很早就有一个想法,做中国人自己的、有所突破、有所创新的操作系统、计算机语言及编译平台。我带领的“新设计团队”(主要由中国科学院研究生院已毕业的学生组成)在实际开发自己的操作系统的过程中,最先遇到的问题就是如何培养学生真正看懂Linux操作系统的源代码的能力。开源的Linux操作系统的源代码很容易找到,但很快就会发现,培养学生看懂Linux操作系统的源代码是一件非常非常困难的事。
操作系统的代码量通常都是非常庞大的,动辄几百万行,即使是浏览一遍也要很长时间。比庞大的代码量更让学习者绝望的是操作系统有着极其错综复杂的关系。看上去,代码的执行序时隐时现,很难抓住脉络。代码之间相互牵扯,相互勾连,几乎无法理出头绪。更谈不上理解代码背后的原理、意图和思想。
对于学生而言,选择从源代码的什么地方开始分析本身就是一个难题。通常,学生有两种选择:一种是从main函数,也就是从C语言代码的总入口开始,沿着源代码的调用路线一行一行地看下去,学生很快就会发现源代码的调用路线莫名其妙地断了,但直觉和常识告诉他操作系统肯定不会在这个地方停止,一定还在继续运行,但却不知道后续的代码在哪里,这种方法很快就走进了死胡同;另一种则是从某一模块入手,如文件系统,但这样会无形中切断操作系统源码之间复杂的关系,如文件系统与进程管理的关系,文件系统与内存管理的关系,等等。使学生孤立地去理解一个模块,往往只能记住一些名词和简单概念,难以真正理解操作系统的全貌。用学生的话讲,他们理解的操作系统变成了“文科”的操作系统。
作者简介:
新设计团队始于世纪之交,不断发展、优胜劣汰、适者生存、自然形成。
团队始终不自量力地奉行高举高打的策略,只对计算机领域中基础的、有体系的事情感兴趣,且只做自己感兴趣的事。
团队不相信二流水平、三流心态,能作出世界顶级的工作。团队相信成功的决定因素是人,而不是物。
团队对艺术、对鉴赏力、对欣赏品味、对一切美好的事物都有着近乎狂热的追求。认为科学、技术的最高境界是艺术,认为世界的本源是通的。
团队崇尚理论体系、崇尚个性鲜明、崇尚独立思考。“没体系”是团队成员之间善意贬损的常用语,也是判断一件事是否值得关注的标准之一。
团队鄙视抄袭、弄虚作假。对别人热炒、做熟的事情不感兴趣,更不喜欢在别人的体系上狗尾续貂、移花接木,粉饰为“自主创新”。
团队强调理论,注重实践,讲究科学的研究方法,不屈膝权威,不迷信盲从。提出基础假设,构建逻辑自洽的体系,证伪、修正、再证伪、再修正,不断推进体系的完善。
团队钻研学术,但决不死板,学术和商业结合,彼此互为推进,最终改变整个时代的商业格局,是团队追求的目标。
团队特别注重诚信,提倡公平、公正。
团队内部是一方净土,既相互竞争,又相互帮助、相互协作。团队成员都在高高兴兴做自己喜欢的、感兴趣的事,没有时间顾及其他。勾心斗角、尔虞我诈、溜须拍马、拉帮结派……,在团队内部没有市场。
团队在上述思想的指导下,研发了基于图形、图像(而非基于字符、语句)的图示化程序设计集成开发环境,已成功的移植了linux 0.11,正确编译,正确boot,正确运行。现在正在研发新的操作系统,已初步实现了与现有的基于块概念的文件系统有较大差异的新的文件系统,据我们测试,相对于基于块设备的文件系统,在文件的读写速度上有较大优势。《Linux内核设计的艺术》一书,体现了我们设计新操作系统的过程中,对操作系统的理解。可能在不久的将来,我们也将我们在设计图示化程序设计平台中,对编译原理的理解,奉献给广大读者。
《Linux内核设计的艺术》前言
我带领的“新设计团队”(主要由中国科学院研究生院已毕业的学生组成)在实际开发自己的操作系统的过程中,最先遇到的问题就是如何培养学生真正看懂Linux操作系统的源代码的能力。开源的Linux操作系统的源代码很容易找到,但很快就会发现,培养学生看懂Linux操作系统的源代码是一件非常非常困难的事。
操作系统的代码量通常都是非常庞大的,动辄几百万行,即使是浏览一遍也要很长时间。比庞大的代码量更让学习者绝望的是操作系统有着极其错综复杂的关系。看上去,代码的执行序时隐时现,很难抓住脉络。代码之间相互牵扯,相互勾连,几乎无法理出头绪。更谈不上理解代码背后的原理、意图和思想。
对于学生而言,选择从源代码的什么地方开始分析本身就是一个难题。通常,学生有两种选择:一种是从main函数,也就是从C语言代码的总入口开始,沿着源代码的调用路线一行一行地看下去,学生很快就会发现源代码的调用路线莫名其妙地断了,但直觉和常识告诉他操作系统肯定不会在这个地方停止,一定还在继续运行,但却不知道后续的代码在哪里,这种方法很快就走进了死胡同;另一种则是从某一模块入手,如文件系统,但这样会无形中切断操作系统源码之间复杂的关系,如文件系统与进程管理的关系,文件系统与内存管理的关系,等等。使学生孤立地去理解一个模块,往往只能记住一些名词和简单概念,难以真正理解操作系统的全貌。用学生的话讲,他们理解的操作系统变成了“文科”的操作系统。
由于操作系统是底层系统程序,对应用程序行之有效的调试和跟踪等手段对操作系统的源代码而言,几乎无效。就算把每一行源代码都看懂了,对源代码已经烂熟于心,知道这一行是一个for循环,那一行是一个调用……但仍然不知道整个代码究竟在做什么,以及起什么作用,更不知道设计者的意图究竟是什么。
我们在操作系统的课程上学习过进程管理、内存管理、文件系统等基础知识,但是这些空洞的理论在一个实际的操作系统中是如何实现的却不得而知。他们在源代码中很难看出进程和内存之间有什么关联,内核程序和用户程序有什么区别,为什么要有这些区别。也很难从源代码中看清楚,我们实际经常用到的操作,比如打开文件,操作系统在其中都做了哪些具体的工作?想在与常见的应用程序的编程方法有巨大差异的、晦涩难懂的、浩瀚如海的操作系统底层源代码中找到这些问题的答案,似乎比登天还难。
对熟悉操作系统源代码的学生而言,他们也知道像分页机制这样知识点,但是未必能够真正理解隐藏在机制背后的深刻意义。
这些都是学生在学习Linux操作系统源代码时遇到的实际问题。中国科学院研究生院的学生应该是年轻人中的佼佼者,他们遇到的问题可能其他读者也会遇到。我萌发了一个想法,虽然学生的问题早已解决,但是否可以把他们曾经在学习、研发操作系统的过程中遇到的问题和心得体会得拿出来供广大读者分享?
当时,针对学生的实际问题,我的解决方法是以一个真实的操作系统为例,让学生理解源代码,把操作系统在内存中的运行时状态画出图来。实践证明,这个方法简单有效。
现在我们把这个解决方案体现在这本书中。就是以一个真实的操作系统的实际运行为主线;以图形、图像为核心,突出描述操作系统在实际运行过程中内存的运行时结构;强调站在操作系统设计者的视角,用体系的思想方法,整体把握操作系统的行为、作用、目的和意义。
在全书的讲解过程中,我们不仅详细分析了源代码、分析了操作系统的执行序,我们还特别分析了操作系统都作了哪些“事”,并且把“事”与“事”之间的关系和来龙去脉,这些“事”意味着什么,为什么要做这些“事”,这些“事”背后的设计思想是什么……都做了非常详细且深入的分析。
更重要的是,对于所有重要的阶段,我们几乎都用图解的方式把操作系统在内存中的实际运行状态精确地表示了出来。我们用600dpi的分辨率精心绘制了349张图,图中表现的运行时结构和状态与操作系统实际运行的真实状态完全吻合。每一条线、每一个色块、每一个位置、每一个地址及数字都经过了我们认真反复地推演和求证,并最终在计算机上进行了核对和验证。看了这些绘制精美的图后,在读者的头脑中就不再是一行行、一段段枯燥的、令人眩晕的源代码,而是立体呈现的一件件清晰的“事”,以及这些“事”在内存中直截了当、清晰鲜活的画面。用这样的方法讲解操作系统是本书的一大特色。理解这些图要比理解源代码和文字容易得多,毫不夸张地说,只要你能理解这些图,你就理解了操作系统的80%,这时你可以自豪的说,你比大多数用别的方法学过操作系统的人的水平都要高出一大截。
作者和机械工业出版社的编辑作了大量的检索工作,就我们检索的范围而言,这样的创作方法及具有这样特色的操作系统专著在世界范围都是第一次。
我们分三个部分来讲解Linux操作系统:第一部分由第1章和第2章组成,分析了从开机加电到操作系统启动完成并进入怠速状态的整个过程;第二部分由第3章、第4章、第5章、第6章、第7章组成,讲述了操作系统进入系统怠速后,在执行用户程序的过程中,操作系统和用户进程的实际运行过程和状态;第三部分由第8章组成,阐述整个Linux操作系统的设计指导思想,本章内容是从微观到宏观的回归。
第一部分,我们详细讲解了开机加电启动BIOS,通过BIOS加载操作系统程序,对主机的初始化,打开保护模式和分页,调用main函数,创建进程0、进程1、进程2以及shell进程,并且具备用文件的形式与外设交互。
第二部分,我们设计了几个尽可能简单又有代表性的应用程序,并以这些程序的执行为引导,详细讲解了安装文件系统、文件操作、用户进程与内存管理、多个进程对文件的操作以及进程间通信。
我们将操作系统的原理自然而然地融入到了讲解真实操作系统的实际运行过程中。在读者看来,操作系统原理不再是空对空的、“文科”概念的计算机理论,而是既有完整的、体系的理论,又有真实、具体、实际的代码和案例,理论与实际紧密耦合。
第三部分是全书水平最高的部分,我们尝试从操作系统设计者的视角讲解操作系统的设计指导思想。详细阐述了主奴机制以及实现主奴机制的三项关键技术:保护和分页、特权级、中断,分析了保障主奴机制实现的决定性因素——先机,还详细讲解了缓冲区、共享页面、信号、管道的设计指导思想。希望帮助读者用体系的思想理解、把握、驾驭整个操作系统以及背后的设计思想和设计意图。
在本书中,我们详细讲解了大家在学习操作系统的过程中可能会遇到的每一个难点,如main函数中的pause()调用,虽然已经找不到后续代码,但该调用结束后,程序仍然执行的原因是:中断一经打开,进程调度就开始了,而此时可以调度的进程只有进程1,所以后续的代码应该从进程1处继续执行……
我们还对一些读者不容易理解和掌握的操作系统特有的底层代码的编程技巧作了详细的讲解。如用模拟call的方法,通过ret指令“调用”main函数……
……
总之,我们所做的一切努力就是想真正解决读者遇到的实际问题和难题,给予读者有效的帮助。我们盼望即使是刚刚考入大学的学生也有兴趣和信心把这本书读下去;我们同样希望即使是对操作系统源代码很熟悉的读者,这本书也能给他们一些不同的视角、方法和体系性思考。
这本书选用的操作系统源代码是Linux 0.11。对为什么选用Linux 0.11而不是最新版本,赵炯先生有过非常精彩的论述,我们认为赵先生的论述是非常到位的。
我们不妨看一下Linux最新的版本2.6,代码量大约在千万行这个量级,去掉其中的驱动部分,代码量仍在百万行这个量级。一个人一秒钟看一行,一天看8小时,中间不吃、不喝、不休息,也要看上几个月,很难想象如何去理解。
就算我们硬要选用Linux 2.6,就算我们写上3000页(书足足会有十几厘米厚),所有的篇幅都用来印代码,也只能印上不到十分之一的代码。所以,即使是这么不切实际的篇幅,也不可能整体讲解Linux 2.6。读者会逐渐明白,对于理解和掌握操作系统而言,真正有价值的是整体、是体系,而不是局部。
Linux 0.11的内核代码虽然只有约两万行,但却是一个实实在在、不折不扣的现代操作系统,因为它具有现代操作系统最重要的特征——支持实时多任务,所以必然支持保护和分页……而且它还是后续版本的真正的始祖,有着内在的、紧密的传承关系。读者更容易看清设计者最初的、最根本的设计意图和设计指导思想。
Linux 0.11已经问世20年了,被世人广为研究和学习。换一个角度看,要想在众人熟悉的事物和领域讲出新意和特色,对作者来说也是一个强有力的挑战。
这本书能够顺利出版,我们首先要感谢机械工业出版社华章公司的副总经理温莉芳女士以及其他领导,是他们的决心和决策成就了这本书,并且在几乎所有方面给予了强有力的支持。特别令人感动的是他们主动承担了全部的出版风险,同时给予了作者最好的条件,让我们看到一个大出版社的气度和风范。
其次,我们还要感谢的是机械工业出版社华章公司的编辑杨福川。杨先生的鉴赏力和他的事业心以及他对工作认真负责的态度为这本书的出版打开了大门。杨先生对读者的理解以及他的计算机专业素养使得他有能力对这本书给予全方位的指导和帮助,使我们对这本书整体修改了6次,使之更贴近读者,可读性更好。
我们还要感谢我们和杨福川先生共同的朋友张国强先生和杨缙女士。
最后,我们要感谢我们的家人和朋友。是他们坚定的支持才使得我们的整个团队能够拒绝方方面面、形形色色的诱惑,放弃普遍追求的短期利益,在常人难以想象的艰苦条件下,长时间专注于操作系统、计算机语言、编译器、计算机体系结构等基础性学科的研究。认认真真、踏踏实实、不为名利,做了一点实在、深入的工作,有了一点能凑合拿得出手的10年积累,以及一支敢想、敢干、敢打、敢拼、不惧世界顶级强敌的队伍。这些是本书的基础。
杨力祥
中国科学院研究生院
2011年5月
《Linux内核设计的艺术》目录
本书导读
前 言
第1章 从开机加电到执行main函数之前的过程1
1.1 启动BIOS,准备实模式下的中断向量表和中断服务程序1
1.1.1 BIOS的启动原理2
1.1.2 BIOS在内存中加载中断向量表和中断服务程序3
1.2 加载操作系统内核程序并为保护模式做准备4
1.2.1 加载第一部分代码—引导程序(bootsect)5
1.2.2 加载第二部分代码—setup7
1.2.3 加载第三部分代码—system模块12
1.3 开始向32位模式转变,为main函数的调用做准备16
1.3.1 关中断并将system移动到内存地址起始位置0x0000016
1.3.2 设置中断描述符表和全局描述符表18
1.3.3 打开A20,实现32位寻址20
1.3.4 为在保护模式下执行head.s做准备21
1.3.5 head.s开始执行24
1.4 本章小结41
第2章 从main到怠速42
2.1 开中断之前的准备工作43
2.1.1 复制根设备号和硬盘参数表 44
2.1.2 物理内存规划格局45
2.1.3 虚拟盘设置与初始化46
2.1.4 内存管理结构mem_map初始化47
2.1.5 异常处理类中断服务程序挂接48
2.1.6 初始化块设备请求项结构50
2.1.7 与建立人机交互界面相关的外设的中断服务程序挂接52
2.1.8 开机启动时间设置55
2.1.9 系统开始激活进程056
2.1.10 进程相关事务初始化设置57
2.1.11 时钟中断设置59
2.1.12 系统调用服务程序挂接59
2.1.13 初始化缓冲区管理结构61
2.1.14 初始化硬盘63
2.1.15 初始化软盘65
2.1.16 开中断66
2.2 进程创建的最基本动作67
2.2.1 操作系统为进程0创建进程1做准备67
2.2.2 在进程槽中为进程1 申请一个空闲位置并获取进程号71
2.2.3 复制进程信息之前,先将一些数据压栈73
2.2.4 初步设置进程1管理结构74
2.2.5 进程0创建进程1的过程中发生时钟中断76
2.2.6 从时钟中断返回78
2.2.7 调整进程1管理结构79
2.2.8 设置进程1的线性地址空间及物理页面81
2.2.9 继续调整进程1管理结构84
2.2.10 操作系统如何区分进程0和进程187
2.2.11 进程0准备切换到进程189
2.2.12 系统切换到进程1执行90
2.3 加载根文件系统92
2.3.1 进程1如何开始执行96
2.3.2 进程1开始执行98
2.3.3 进程1开始以数据块的形式操作硬盘99
2.3.4 将找到的缓冲块与请求项挂接101
2.3.5 将请求项与硬盘处理函数挂接104
2.3.6 进行硬盘读盘前的准备工作105
2.3.7 给硬盘下达读盘指令106
2.3.8 进程1由于等待读盘操作挂起107
2.3.9 系统切换到进程0执行109
2.3.10 进程0的执行过程110
2.3.11 进程0执行过程中发生硬盘中断111
2.3.12 硬盘中断服务程序响应后,进程0继续执行113
2.3.13 再次响应硬盘中断并唤醒进程1114
2.3.14 读盘操作完成后,进程1继续执行116
2.3.15 进程1继续设置硬盘管理结构117
2.3.16 进程1获取软盘超级块,为加载根文件系统做准备118
2.3.17 进程1备份超级块数据119
2.3.18 进程1将根文件系统从软盘拷贝到虚拟盘120
2.3.19 进程1开始加载根文件系统122
2.3.20 进程1准备加载根文件系统超级块123
2.3.21 进程1加载根文件系统超级块124
2.3.22 进程1继续加载根文件系统126
2.3.23 进程1准备读取根目录i节点127
2.3.24 进程1加载根目录i节点128
2.3.25 进程1结束加载根文件系统的过程129
2.4 打开终端设备文件及复制文件句柄131
2.4.1 进程1与内核文件表挂接,为打开文件做准备133
2.4.2 确定打开操作的起点135
2.4.3 获得枝梢i节点—dev目录文件的i节点136
2.4.4 确定dev目录文件i节点为枝梢i节点137
2.4.5 继续返回枝梢i节点138
2.4.6 查找tty0文件的i节点138
2.4.7 将tty0设备文件的i节点返回给sys_open系统调用 139
2.4.8 分析tty0文件i节点140
2.4.9 设置文件管理结构并返回给用户进程141
2.4.10 进程1复制tty0文件句柄142
2.4.11 进程1继续复制tty0文件句柄144
2.5 创建进程2145
2.5.1 进程1准备创建进程2145
2.5.2 复制进程2管理结构并进行调整146
2.5.3 设置进程2的页目录项并复制进程2的页表146
2.5.4 调整进程2管理结构中与文件有关的内容146
2.5.5 进程1执行过程中发生时钟中断148
2.5.6 进程1从时钟中断返回,准备切换到进程2150
2.6 进程1等待进程2退出150
2.6.1 进程1查找它自己的子进程151
2.6.2 对进程2的状态进行处理151
2.6.3 切换到进程2执行153
2.7 shell程序的加载154
2.7.1 进程2开始执行156
2.7.2 为打开/etc/rc文件做准备156
2.7.3 进程2打开“/etc/rc”配置文件157
2.7.4 通过压栈为加载shell文件做准备158
2.7.5 为参数和环境变量设置做准备159
2.7.6 得到shell文件的i节点160
2.7.7 为加载参数和环境变量做准备161
2.7.8 根据i节点,对shell文件进行检测162
2.7.9 检测shell文件头163
2.7.10 备份文件头并进行分析163
2.7.11 对shell文件进行进一步分析165
2.7.12 拷贝参数和环境变量166
2.7.13 调整进程2的管理结构167
2.7.14 继续调整进程2管理结构168
2.7.15 释放进程2继承的页面169
2.7.16 检测协处理器170
2.7.17 调整shell程序所在的线性空间地址171
2.7.18 为shell程序准备参数和环境变量172
2.7.19 继续调整进程2管理结构173
2.7.20 调整EIP,使其指向shell程序入口地址173
2.7.21 shell程序执行引发缺页中断175
2.7.22 缺页中断中shell程序加载前的检测175
2.7.23 为即将载入的内容申请页面177
2.7.24 将shell程序载入新获得的页面177
2.7.25 根据shell程序的情况,调整页面的内容178
2.7.26 将线性地址空间与程序所在的物理页面对应179
2.8 系统实现怠速180
2.8.1 shell进程准备创建update进程180
2.8.2 进程2开始执行/etc/rc文件181
2.8.3 准备加载update进程181
2.8.4 update进程的作用182
2.8.5 shell程序检测“/etc/rc”文件183
2.8.6 shell进程退出184
2.8.7 shell进程退出善后处理185
2.8.8 进程1清理shell进程管理结构187
2.8.9 系统开始重建shell190
2.8.10 shell进程为何不会再次退出192
2.9 小结194
第3章 安装文件系统195
3.1 获取硬盘设备号196
3.1.1 用户发出安装硬盘文件系统指令196
3.1.2 从分析路径开始,准备查找hd1设备的挂接点197
3.1.3 以根目录i节点为依托,得到dev目录文件的i节点197
3.1.4 从dev目录文件中找到代表hd1设备文件的目录项198
3.1.5 得到hd1设备文件的i节点号199
3.1.6 释放dev目录文件的相关内容200
3.1.7 得到hd1设备文件的i节点200
3.1.8 获得hd1设备的设备号200
3.1.9 释放hd1设备文件的i节点201
3.2 获取虚拟盘上的挂接点202
3.3 得到hd1设备文件的超级块202
3.3.1 准备读取hd1设备文件超级块203
3.3.2 为hd1设备文件的超级块找到存储位置203
3.3.3 初始化空闲超级块并加锁203
3.3.4 从硬盘获得hd1设备文件的超级块204
3.3.5 加载逻辑块位图和i节点位图205
3.4 将hd1设备文件与mnt目录文件的i节点挂接206
3.5 小结207
第4章 文件操作208
4.1 打开文件211
4.1.1 用户程序调用open库函数产生软中断212
4.1.2 建立用户进程与文件管理表的关系213
4.1.3 从硬盘上获取helloc.txt文件的i节点214
4.1.4 将helloc.txt文件与文件管理表相挂接226
4.2 读文件227
4.2.1 为按照用户要求读入文件做准备228
4.2.2 确定要读入的数据块的位置230
4.2.3 将指定的数据块从硬盘读入到高速缓冲块233
4.2.4 将数据拷贝到用户指定的内存234
4.3 新建文件237
4.3.1 查找路径“/mnt/user/hello.txt”238
4.3.2 为hello.txt文件新建一个i节点240
4.3.3 为hello.txt文件新建目录项242
4.3.4 完成hello.txt新建操作并返回给用户进程245
4.4 写文件246
4.4.1 文件写入前的准备工作248
4.4.2 确定hello.txt文件的写入位置249
4.4.3 为数据的写入申请缓冲块252
4.4.4 将指定的写入数据从用户数据区拷贝到缓冲块253
4.4.5 数据同步到硬盘的方法1255
4.4.6 将文件写入硬盘的情况2257
4.5 修改文件260
4.5.1 对文件的当前操作指针进行重定位261
4.5.2 对文件进行修改261
4.6 关闭文件263
4.6.1 当前进程与文件管理表“脱钩”264
4.6.2 将文件管理表中hello.txt对应的引用次数减1265
4.6.3 hello.txt文件与文件管理表“脱钩”266
4.7 删除文件268
4.7.1 系统准备删除hello.txt文件268
4.7.2 删除hello.txt文件在硬盘上对应的数据和i节点270
4.7.3 对hello.txt文件所在的user目录做处理275
4.8 本章小结275
第5章 用户进程与内存管理277
5.1 用户进程的创建277
5.1.1 为创建进程str1准备条件277
5.1.2 为str1进程管理结构找到存储空间279
5.1.3 复制str1进程管理结构281
5.1.4 确定str1进程在线性空间中的位置282
5.1.5 复制str1进程页表并设置其对应的页目录项283
5.1.6 调整str1进程中与文件相关的结构285
5.1.7 建立str1进程与全局描述符表GDT的关联286
5.1.8 将str1进程设为就绪态287
5.2 为用户进程str1的加载做准备288
5.2.1 为str1进程加载自身对应的程序做准备288
5.2.2 读取str1可执行文件的i节点并统计参数和环境变量289
5.2.3 读取str1可执行文件的文件头290
5.2.4 对str1可执行程序文件头进行分析291
5.2.5 拷贝str1可执行程序的参数和环境变量292
5.2.6 调整str1进程管理结构中可执行程序对应的i节点292
5.2.7 继续调整str1进程管理结构—文件和信号相关的字段293
5.2.8 释放str1进程的页表294
5.2.9 重新设置str1的程序代码段和数据段295
5.2.10 创建环境变量和参数指针表296
5.2.11 继续根据str1可执行程序情况调整str1进程管理结构297
5.2.12 设置str1可执行程序的栈指针和eip值297
5.3 对缺页中断的处理298
5.3.1 产生缺页中断并由操作系统响应298
5.3.2 为str1程序申请一个内存页面299
5.3.3 将str1程序加载到新分配的页面中300
5.3.4 检测是否需要对页面剩余空间清0300
5.3.5 将str1程序占用的物理内存地址与str1进程的线性地址空间对应301
5.3.6 不断通过缺页中断加载str1程序的全部内容301
5.3.7 str1程序需要压栈302
5.3.8 str1程序第一次调用foo程序压栈302
5.3.9 str1程序第二次压栈,产生缺页中断302
5.3.10 处理str1程序第二次压栈产生的缺页中断302
5.3.11 str1程序继续执行,反复压栈并产生缺页中断303
5.3.12 str1程序运行结束后清栈303
5.4 str1用户进程的退出305
5.4.1 str1进程准备退出305
5.4.2 释放str1程序所占页面305
5.4.3 解除str1程序与文件有关的内容并给父进程发信号306
5.4.4 str1程序退出后执行进程调度307
5.5 多个用户进程“同时”运行308
5.5.1 依次创建str1、str2和str3进程308
5.5.2 str1进程压栈的执行效果309
5.5.3 str1运行过程中产生时钟中断并切换到str2执行309
5.5.4 str2执行过程遇到时钟中断切换到str3执行310
5.5.5 三个程序执行一段时间后在主内存的分布格局311
5.6 进程的调度与切换311
5.6.1 str1刚被shell创建并处于就绪态311
5.6.2 shell进程将自己挂起,然后准备切换到str1执行311
5.6.3 准备切换到str1进程执行312
5.6.4 str1执行时发生时钟中断314
5.6.5 时钟中断递减str1运行的时间片315
5.6.6 str1执行一段时间后挂起,shell进程新建str2进程315
5.6.7 str2运行期间发生时钟中断316
5.6.8 系统切换到str1程序执行317
5.7 内核的分页318
5.7.1 为设置内核的页目录表和页表做准备—所占空间清0318
5.7.2 设置内核对应的页目录项和页表项的内容319
5.7.3 设置内核对应的全局描述符表GDT320
5.8 页写保护321
5.8.1 进程A和进程B共享页面321
5.8.2 进程A准备进行压栈操作322
5.8.3 进程A的压栈动作引发页写保护322
5.8.4 将进程A的页表指向新申请的页面323
5.8.5 拷贝原页面内容到进程A新申请的页面324
5.8.6 进程B准备操作共享页面325
5.8.7 假设进程B先执行压栈操作的情况325
5.9 小结326
第6章 多个进程“同时”操作一个文件327
6.1 三个进程操作同一个文件327
6.1.1 进程A执行,hello.txt文件被打开328
6.1.2 进程A读取hello.txt文件并由于等待硬盘中断而被系统挂起328
6.1.3 进程B准备打开hello.txt文件330
6.1.4 系统准备为进程B获取hello.txt文件的i节点332
6.1.5 系统找到hello.txt文件已经载入的i节点333
6.1.6 系统准备为进程B从硬盘上读取hello.txt文件334
6.1.7 系统找到了正在操作的缓冲块,将进程B挂起335
6.1.8 系统再次切换到进程0执行337
6.1.9 进程C启动并打开hello.txt文件337
6.1.10 进程C也由于等待缓冲块解锁而被系统挂起338
6.1.11 缓冲块解锁后先唤醒进程C339
6.1.12 系统将进程B设为就绪状态340
6.1.13 系统将指定数据写入缓冲块341
6.1.14 写入完成后,进程C继续执行341
6.1.15 进程C准备切换到进程B342
6.1.16 进程C切换到进程B执行,进程B唤醒进程A342
6.1.17 进程B不断执行,直到时间片减为0后切换到进程A执行343
6.1.18 进程A、B、C退出,写入数据由update进程同步344
6.2 缓冲区与外设的数据同步344
6.2.1 系统不断为进程A向缓冲区写入数据346
6.2.2 继续执行引发缓冲块数据需要同步346
6.2.3 将缓冲区中的数据同步到硬盘上347
6.2.4 进程A由于等待空闲请求项而被系统挂起349
6.2.5 进程B开始执行350
6.2.6 进程B也被挂起351
6.2.7 进程C开始执行并随后被挂起352
6.2.8 进程A和进程C均被唤醒352
6.2.9 进程B切换到进程A执行354
6.3 小结356
第7章 IPC问题358
7.1 管道机制358
7.1.1 为管道文件在文件管理表中申请空闲项360
7.1.2 为管道文件与进程建立联系创造条件360
7.1.3 创建管道文件i节点361
7.1.4 将管道文件i节点与文件管理表建立联系362
7.1.5 将管道文件句柄返回给用户进程363
7.1.6 读管道进程开始操作管道文件363
7.1.7 写管道进程向管道中写入数据364
7.1.8 写管道进程继续向管道写入数据366
7.1.9 写管道进程已将管道空间写满366
7.1.10 写管道进程挂起366
7.1.11 读管道进程从管道中读出数据367
7.1.12 读管道进程继续执行,不断从管道中读出数据369
7.1.13 读管道进程执行中发生时钟中断369
7.1.14 读管道进程执行过程中再次发生时钟中断370
7.1.15 读管道进程切换到写管道进程执行371
7.1.16 写管道进程挂起切换到读管道进程执行371
7.1.17 读管道进程继续执行,直到把管道中的数据读完372
7.1.18 读取完成后,读进程挂起,写进程继续执行373
7.2 信号机制374
7.2.1 processig进程开始执行376
7.2.2 processig进程进入可中断等待状态377
7.2.3 sendsig进程开始执行并向processig进程发信号379
7.2.4 系统检测当前进程接收到信号并准备处理381
7.2.5 系统检测信号处理函数指针挂接是否正常382
7.2.6 调整processig进程的内核栈结构,使之先执行信号处理函数383
7.2.7 信号对进程执行状态的影响386
7.3 小结393
第8章 操作系统的设计指导思想395
8.1 运行一个最简单的程序,看操作系统为程序运行做了哪些工作395
8.2 操作系统的设计指导思想—主奴机制398
8.2.1 主奴机制中的进程及进程创建机制399
8.2.2 操作系统在内存管理中的主奴机制400
8.2.3 操作系统在文件系统中体现的主奴机制401
8.3 实现主奴机制的三种关键技术402
8.3.1 保护和分页402
8.3.2 特权级405
8.3.3 中断405
8.4 建立主奴机制的决定性因素—先机407
8.5 软件和硬件的关系:主机与进程、外设与文件408
8.5.1 非用户进程—进程0、进程1、shell进程408
8.5.2 文件与数据存储409
8.6 父子进程共享页面414
8.7 操作系统的全局中断与进程的局部中断—信号414
8.8 小结415
结束语415
“新设计团队”简介416
附录 搭建Linux 0.11系统环境421
第一章 从开机加电到执行main函数之前的过程
小贴士
实模式(Real mode)是Intel 80286和之后的80x86兼容CPU的操作模式。实模式的特性是一个20位的存储器地址空间(2^20=1048576,即1MB的存储器可被寻址),可以直接通过软件的方式访问BIOS以及周边硬件,没有硬件支持的分页机制和实时多任务的概念。从80286开始,所有的80x86 CPU的开机状态都是实模式;8086等早期的CPU只有一种操作模式,类似于实模式。
1.1 启动BIOS,准备实模式下的中断向量表和中断服务程序
计算机的运行是离不开程序的。然而,加电的一瞬间,计算机的内存中,准确地说是RAM中,空空如也,什么程序也没有。软盘里虽然有操作系统程序,但CPU的逻辑电路被设计为只能运行内存中的程序,它没有能力直接从软盘运行操作系统。如果要运行软盘中的操作系统,必须将软盘中的操作系统程序加载到内存(RAM)中。
小贴士
RAM(Random Access Memory):随机存取存储器。常见的内存条就是一类RAM,它的特点是加电状态下可任意读、写,断电后信息消失。
问题是在RAM中什么程序也没有的时候,谁来完成加载软盘中操作系统的任务呢?
答案是:BIOS。
1.1.1 BIOS的启动原理
答案是:0xFFFF0 !!!
从体系的角度看,不难得出这样的结论:既然软件方法不可能执行BIOS,那就只能靠硬件方法完成了。
从硬件角度看,Intel 80x86系列的CPU可以分别在16位实模式和32位保护模式下运行。为了兼容,也为了解决最开始的启动问题,Intel 将所有80x86系列的CPU(包括最新型号的CPU)的硬件都设计为加电即进入16位实模式状态运行。同时,还有一点非常关键,即将CPU硬件逻辑设计为加电瞬间强行将CS的值置为0xFFFF,IP的值置为0x0000,这样CS:IP就指向0xFFFF0这个地址位置,如图1-1 所示。从图1-1中可以清楚地看到,0xFFFF0指向了BIOS的地址范围。
小贴士
IP/EIP(Instruction Pointer):指令指针寄存器,存在于CPU中,记录将要执行的指令在代码段内的偏移地址,它与CS组合即为将要执行的指令的内存地址。实模式为绝对地址,指令指针为16位,即IP;保护模式下为线性地址,指令指针为32位,即EIP。
小贴士
CS(Code Segment Register):代码段寄存器,存在于CPU中,指向CPU当前执行代码在内存中所在的区域。
注意,这是一个纯硬件完成的动作!如果此时这个位置没有可执行代码,那就什么也不用说了,计算机就此死机。反之,如果这个位置有可执行代码,计算机将从这里的代码开始,沿着后续程序一直执行下去。
BIOS程序的入口地址恰恰就是0xFFFF0 !也就是说,BIOS程序的第一条指令就设计在这个位置上。
1.1.2 BIOS在内存中加载中断向量表和中断服务程序
BIOS程序的代码量并不大,却非常精深,需要对计算机的整个硬件体系结构非常熟悉才能将其看明白。要想把BIOS是如何运行的讲清楚,也得写很厚一本书,这显然超出了本书的主题和范围。我们的主题是操作系统,所以只会将与启动操作系统有直接关系的内容讲解一下。
BIOS程序被固化在计算机主机板上的一块很小的ROM芯片里。通常,不同的主机板所用的BIOS也有所不同,就启动部分而言,各种类型的BIOS的基本原理大致相似。为了便于大家理解,我们选用的BIOS程序只有8KB,所占地址段为0xFE000~0xFFFFF,如图1-1所示。现在CS:IP已经指向了0xFFFF0这个位置,这意味着BIOS开始启动了。随着BIOS程序的执行,屏幕上会显示显卡的信息、内存的信息……说明BIOS程序在检测显卡、内存……这期间,有一项对启动(boot)操作系统至关重要的工作,那就是BIOS在内存中建立中断向量表和中断服务程序。
小贴士
ROM(Read Only Memory):只读存储器,现在通常用闪存芯片做ROM。虽然闪存芯片在特定的条件下是可写的,但在谈到主机板上存储BIOS的闪存芯片时,业内人士把它看作ROM。ROM有一个特性,就是断电之后仍能保存信息,这一点与硬盘类似。
BIOS程序在内存最开始的位置(即:0x00000)用1KB的内存空间(0x00000~ 0x003FF)构建中断向量表,并在紧挨着它的位置用256字节的内存空间构建BIOS数据区(0x00400~0x004FF),在大约56KB以后的位置(0x0E05B)加载了8KB左右的与中断向量表相应的若干中断服务程序,图1-2中精确地标注了这些位置。
小贴士
一个容易计算的方法:0x00100是256个字节,0x00400就是4×256字节=1024字节,即1KB。因为是从0x00000开始计算,所以1KB的高地址端不是0x00400,而是0x00400-1,也就是0x003FF。
中断向量表中有256个中断向量,每个中断向量占4个字节,其中两个字节是CS的值,两个字节是IP的值,每个中断向量都指向一个具体的中断服务程序。
下面将详细讲解后续程序是如何利用这些中断服务程序把系统内核程序从软盘加载至内存的。
小贴士
中断 INT(INTerrupt):顾名思义中断就是中途打断一件正在进行中的事。其最初的意思是:外在的事件打断正在执行的程序,转而执行处理这个事件的特定程序,处理结束后,回到被打断的程序继续执行。现在,可以先将中断理解为一种技术手段,在这一点上与C语言的函数调用有些类似。
中断对操作系统来说意义重大,后面我们还会深入讨论
1.2 加载操作系统内核程序并为保护模式做准备
从现在开始就要执行真正的boot操作了,即把软盘中的操作系统程序加载至内存。对于Linux 0.11操作系统而言,计算机将分三批逐次加载操作系统的内核代码。第一批由BIOS中断int 0x19h把第一扇区bootsect的内容加载到内存;第二批和第三批在bootsect的指挥下,分别把其后的四个扇区和随后的240个扇区的内容加载至内存。
1.2.1 加载第一部分代码—引导程序(bootsect)
经过执行一系列BIOS代码之后,计算机完成了自检等操作(这些与我们讲的启动操作系统没有直接的关系,读者不必关心),由于我们把软盘设置为启动设备,计算机硬件体系结构的设计与BIOS联手操作,会让CPU接收到一个int 19h中断,CPU接收到这个中断后,会立即在中断向量表中找到int 19h中断向量。我们在图1-3的左下方可以看到int 19h中断向量在内存中所在的准确位置,这个位置几乎紧挨着内存的0x00000位置。
接下来,中断向量把CPU指向0x0E6F2,这个位置就是int 0x 19h相对应的中断服务程序的入口地址,即图1-3中所示“启动加载服务程序”的入口地址。这个中断服务程序的作用就是把软盘的第一个扇区中的程序(512B)加载到内存中的指定位置。这个中断服务程序的功能是BIOS事先设计好的,代码是固定的,与Linux操作系统无关,无论Linux 0.11的内核是如何设计的,这段BIOS程序所要做到就是“找到软盘”并“加载第一扇区”。其余的它什么都不知道,也不必知道。
小贴士
中断向量表(Interrupt Vector Table):实模式中断机制的重要组成部分,表中记录所有中断号对应的中断服务程序的内存地址。
中断服务程序(Interrupt Services):通过中断向量表的索引对中断进行响应服务,是一些具有特定功能的程序。
按照这个简单、“生硬”的规则,int 0x19h中断向量所指向的中断服务程序(即启动加载服务程序)将软驱0号磁头对应盘面的0磁道1扇区的内容拷贝至内存0x07C00处。我们可以在图1-4的左边看到第一扇区加载的具体位置。
这个扇区里的内容就是Linux 0.11操作系统的引导程序,也就是我们将要讲解的bootsect,其作用就是陆续把软盘中的操作系统程序载入内存。这样制作的第一扇区就称为启动扇区(boot sector)。第一扇区程序的载入标志着Linux 0.11操作系统中的代码即将发挥作用了。
这是非常关键的动作,从此计算机开始与软盘上的操作系统程序产生关联。第一扇区中的程序是由bootsect.s中的汇编程序汇编而成(以后简称bootsect),这是计算机自开机以来,内存中第一次有了Linux操作系统自己的代码,虽然只是启动代码。
至此,已经把第一批代码bootsect从软盘载入到计算机的内存了。下面的工作就是执行bootsect把软盘的第二批和第三批代码载入内存。
点评
注意,BIOS程序固化在主机板上的ROM中,是根据具体的主机板而不是根据具体的操作系统设计的。
理论上,计算机可以安装任何适合其安装的操作系统,既可以安装Windows,也可以安装Linux。不难想象每个操作系统的设计者都可以设计出一套自己的操作系统启动方案,而操作系统和BIOS通常是由不同的专业团队设计和开发的,为了能协同工作,必须建立操作系统和BIOS之间的协调机制。
与已有的操作系统建立一一对应的协调机制虽然麻烦,但尚有可能,难点在于如何与未来的操作系统建立协调机制,现行的方法是“两头约定”和“定位识别”。
对操作系统(这里指Linux 0.11)而言,“约定”操作系统的设计者必须把最开始执行的程序“定位”在启动扇区(软盘中的0盘面0磁道1扇区),其余的程序可以依照操作系统的设计顺序加载在后续的扇区中。
对BIOS而言,“约定”在接到启动操作系统的命令后,“定位识别”只从启动扇区把代码加载到0x07C00 (BOOTSEG)这个位置(参见Seabios 0.6.0/Boot.c文件中的boot_disk函数)。至于这个扇区中是否是启动程序,以及是什么操作系统,则不闻不问并一视同仁。如果不是启动代码,只会提示错误,其余是用户的责任,与BIOS无关。
这样构建协调机制的好处是站在整个体系的高度,统一设计和统一安排,简单而有效。只要BIOS和操作系统的生产厂商开发的所有系统版本全部遵循此机制的约定,就可以各自灵活地设计出具有自己特色的系统版本。
1.2.2 加载第二部分代码—setup
1. bootsect对内存的规划
现在,BIOS已经把bootsect(也就是引导程序)载入内存了,它的作用就是把第二批和第三批程序陆续加载到内存中。为了把第二批和第三批程序加载到内存中的适当位置,bootsect首先做的工作就是规划内存。
通常,我们是用高级语言编写应用程序,这些程序是在操作系统的平台上运行的。我们只管写高级语言的代码和数据,至于这些代码和数据在运行的时候放在内存的什么地方,是否会相互覆盖,我们都不用操心,因为操作系统和高级语言的编译器替我们做了大量的看护工作,以确保不会出错。现在我们讨论的是操作系统本身,启动代码使用的是汇编语言,没有高级语言编译器替其提供保障,只有靠操作系统的设计者把内存的安排想清楚,确保无论操作系统如何运行,都不会出现代码与代码、数据与数据、代码与数据之间相互覆盖的情况。为了更准确地理解操作系统的运行机制,我们必须清楚操作系统的设计者是如何规划内存的。
在实模式状态下,寻址的最大范围是1MB,为了规划内存,bootsect首先设计了如下的代码:
//代码路径:boot/bootsect.s
SETUPLEN = 4 ! nr of setup-sectors
BOOTSEG = 0x07c0 ! original address of boot-sector
INITSEG = 0x9000 ! we move boot here-out of the way
SETUPSEG = 0x9020 ! setup starts here
SYSSEG = 0x1000 ! system loaded at 0x10000 (65536)
ENDSEG = SYSSEG + SYSSIZE ! where to stop loading
这些源代码的作用就是对后续操作所涉及的内存位置进行设置,包括将要加载的setup程序的扇区数(SETUPLEN)和被加载到的位置(SETUPSEG)、启动扇区被BIOS加载的位置(BOOTSEG)和将要移动到的新位置(INITSEG)、内核(kernel)被加载的位置(SYSSEG)、内核的末尾位置(SYSEND)和根文件系统设备号(ROOT_DEV),这些位置在图1-5中都明确地标注了出来。设置这些位置就是为了确保将要载入内存的代码和已经载入内存的代码及数据各在其位,互不覆盖,并且它们各自有够用的内存空间。大家在后续的章节会逐渐看到内存规划的意义和作用。
从现在起,我们的头脑中要时刻牢记这样一个概念:操作系统的设计者要全面、整体地考虑内存的规划。我们会在后续的章节中不断地了解到,精心安排内存是操作系统设计者时时刻刻都要关心的事。我们带着这样的观念继续讲解bootsect程序的执行。
2.复制bootsect
接下来,bootsect启动程序将它自身(全部的512B内容)从内存0x07C00(BOOTSEG)处复制至内存0x90000(INITSEG)处,这个动作和目标位置如图1-6所示。
执行这个操作的代码如下:
//代码路径:boot/bootsect.s
mov ax, #BOOTSEG
mov ds, ax
mov ax, #INITSEG
mov es, ax
mov cx, #256
sub si, si
sub di, di
rep
movw
在这次复制过程中,ds(0x07C0)和si(0x0000)联合使用,构成了源地址0x07C00;es(0x9000)和di(0x0000)联合使用,构成了目的地址0x90000(参看图1-6左边),而mov cx, #256这一行循环控制量,提供了需要复制的“字”数(一个字为两个字节),256个字正好是512字节,即第一扇区的字节数。
通过代码我们还可以看出,图1-5提到的BOOTSEG和INITSEG现在开始发挥作用了。注意,此时CPU的段寄存器(CS)指向0x07C0 (BOOTSEG),即原来bootsect程序所在的位置。
点评
由于“两头约定”和“定位识别”的作用,所以bootsect在开始时“被迫”加载到0x07C00位置。现在将其自身移至0x90000处,说明操作系统开始根据自己的需要安排内存了。
bootsect复制到新位置后,bootsect会执行下面这行加粗的代码:
//代码路径:boot/bootsect.s
rep
movw
jmpi go, INITSEG
go: mov ax,cs
mov ds,ax
从图1-6中我们已经了解到当时CS的值为0x07C0,执行完这个跳转后,CS值变为0x9000 (INITSEG),IP的值为从0x9000(INITSEG)到go: mov ax, cs 这一行对应指令的偏移。换句话说,此时CS:IP指向go: mov ax, cs这一行,程序从这一行开始往下执行。图1-7形象地表示了跳转到go: mov ax, cs这一行执行时CS和IP的状态,如图右下方所示。
此前的0x07C00这个位置是根据“两头约定”和“定位识别”而确定的。从现在起,操作系统已经不需要完全依赖BIOS,可以按照自己的意志把自己的代码安排在内存中的某个位置了。
点评
jmpi go, INITSEG
go: mov ax, cs
这两行代码写得很巧。bootsect复制完成后,在内存的0x07C00和0x90000位置处有两段完全相同的代码。请大家注意,复制代码这件事本身也是要靠指令执行的,执行指令的过程就是CS和IP不断变化的过程。执行到jmpi go, INITSEG这行之前,代码的作用就是复制代码自身;执行了jmpi go, INITSEG之后,程序就转到执行0x90000这边的代码了。Linus的设计意图是跳转之后在新位置接着执行后面的mov ax, cs,而不是死循环。jmpi go, INITSEG与go: mov ax, cs配合,巧妙地实现了“到新位置后接着原来的执行序继续执行下去”的目的。
由于bootscet复制到了新的地方,并且要在新的地方继续执行。因为代码的整体位置发生了变化,所以代码中的各个段也会发生变化,前面已经改变了CS,现在对DS、ES、SS和SP进行调整。我们看看下面的代码:
//代码路径:boot/bootsect.s
go: mov ax, cs
mov ds, ax
mov es, ax
! put stack at 0x9ff00.
mov ss, ax
mov sp, #0xFF00 ! arbitrary value >>512
! load the setup-sectors directly after the bootblock.
! Note that 'es' is already set up.
上述代码的作用是通过ax,用CS的值0x9000来把数据段寄存器(DS)、附加段寄存器(ES)、栈基址寄存器(SS)设置成与代码段寄存器CS相同的位置,并将栈顶指针sp指向偏移地址为0xFF00处。图1-8对此做了非常直观的描述。
下面着重介绍一下与栈操作相关的寄存器的设置。SS和SP联合使用构成了栈数据在内存中的位置值,对这两个寄存器的设置为后面程序的栈操作(如push和pop等)打下了基础。
现在可以观察一下bootsect中的程序,在执行设置SS和SP的代码之前,没有出现过栈操作指令,而在此之后就陆续使用了。这里对SS和SP进行的设置是分水岭。它标志着从现在开始,程序可以执行更为复杂一些的数据运算类指令了。
栈操作是有方向的,图1-8中标识了压栈的方向。注意,是由高地址到低地址的方向。
小贴士
DS/ES/FS/GS/SS:数据段寄存器,存在于CPU中,其中SS(Stack Segment)指向栈段,此区域将按栈机制进行管理。
SP(Stack Pointer):栈顶指针寄存器,指向栈段的当前栈顶。
注意,很多计算机书上使用“堆栈”这个词。本书用堆、栈表示两个概念。栈表示stack,特指在C语言程序的运行时结构中,以“后进先出”机制运作的内存空间;堆表示heap,特指用C语言库函数malloc创建、free释放的动态内存空间。
至此,bootsect的第一步操作:规划内存并把自身从0x07C00的位置复制到0x90000的位置的动作已经完成了。
3. 将Setup程序加载到内存中
下面,bootsect程序就要执行它的第二步操作了:将setup程序加载到内存中。
加载setup这个程序,要借助BIOS提供的int 0x 13h中断向量所指向的中断服务程序(也就是磁盘服务程序)来完成,图1-9标注了int 0x 13h中断向量的位置以及这个中断向量所指向的磁盘服务程序的入口位置。
这个中断服务程序的执行过程与图1-3和图1-4中讲解过的int 0x 19h中断向量所指向的启动加载服务程序不同:
int 0x 19h中断向量所指向的启动加载服务程序是BIOS执行的,int 0x 13h的中断服务程序是Linux操作系统自身的启动代码bootsect执行的。
int 0x 19h的中断服务程序只负责把软盘的第一扇区的代码加载到0x07C00位置,int 0x 13h的中断服务程序则不然,它可以根据设计者的意图,把指定扇区的代码加载到内存的指定位置。
针对服务程序的这个特性,使用int 0x 13h中断时,就要事先将指定的扇区和加载的内存位置等信息传递给服务程序,即传参。
执行代码如下:
//代码路径:boot/bootsect.s
load_setup:
mov dx, #0x0000 ! drive 0, head 0
mov cx, #0x0002 ! sector 2, track 0
mov bx, #0x0200 ! address = 512, in INITSEG
mov ax, #0x0200+SETUPLEN ! service 2, nr of sectors
int 0x13 ! read it
jnc ok_load_setup ! ok-continue
mov dx, #0x0000
mov ax, #0x0000 ! reset the diskette
int 0x13
j load_setup
ok_load_setup:
从代码开始处的4个mov指令可以看出,系统给BIOS中断服务程序传参是通过几个通用寄存器实现的,这是汇编程序的常用方法,与C语言的函数调用形式有很大不同。
参数传递完毕后,执行int 0x13指令,产生0x13中断,通过中断向量表找到这个中断服务程序,将软盘从第2扇区开始的4个扇区,即setup.s对应的程序加载至内存的SETUPSEG (0x90200)处。根据图1-5的讲解,复制后的bootsect的起始位置是0x90000,占用512字节的内存空间,不难看出0x90200紧挨着bootsect的尾端,所以bootsect和setup是连在一起的。图1-10表示了软盘中所要加载的扇区位置和扇区数,以及载入内存的目标位置和占用的空间。
现在,操作系统已经从软盘中加载了5个扇区的代码。等bootsect执行完毕后,setup这个程序就要开始工作了。
注意,图1-8中SS:SP指向的位置为0x9FF00,这与setup程序的起始位置0x90200还有很大的距离,即便setup加载进来后,系统仍然有足够的内存空间用来执行数据压栈操作。而且,在启动部分,要压栈的数据毕竟也是有限的,大家在后续的章节中会逐渐体会到,设计者在此是进行过精密测算的。
1.2.3 加载第三部分代码—system模块
第二批代码已经载入内存,现在要加载第三批代码。仍然是使用BIOS提供的int 0x 13h中断,如图1-11所示,方法与图1-9所示的方法基本相同。
接下来bootsect程序就要执行第三批程序的载入工作,即将系统模块载入内存。
从底层技术上看,这次载入与前面的setup程序的载入没有本质的区别。比较突出的是这次加载的扇区数是240,是之前4个扇区的60倍,且所需时间也是之前的几十倍。为了防止加载期间用户误认为是机器故障,而执行不适当的操作,Linus在此设计了显示一行屏幕信息“Loading system ...”以提示用户计算机此时正在加载系统。值得注意的是,此时操作系统的main函数还没有开始执行,在屏幕上显示一行字符串远没有用C语言写一句printf(“Loading system ...\n”)调用那么容易,所有工作都要靠一行一行的汇编代码来实现,从体系结构的角度看,显示器也是一个外设,所以还要用到其他的BIOS中断。这些代码比较多,对理解操作系统的启动原理没有特别直接的帮助,只要知道大意就可以了。我们真正需要掌握的是:bootsect借着BIOS中断int 0x 13h,将240个扇区的system模块加载进内存。加载工作主要是由bootsect调用read_it子程序完成的,这个子程序将软盘第6扇区开始的约240个扇区的system模块加载至内存的SYSSEG(0x10000)处往后的120KB空间中。图1-12中对system模块所占用的内存空间给出了形象的说明。
由于是长时间操作软盘,所以需要对软盘设备进行更多的监控,需要不断地对读盘结果进行检测,因此read_it后续的调用步骤比较多,但读盘工作最终是由0x13对应的中断服务程序完成的。
到此为止,第三批程序已经加载完毕,整个操作系统的代码已全部载入内存。bootsect的主体工作已经做完了,还有一点小事,就是要再次确定一下根设备号,如图1-13所示。
经过一系列检测,得知软盘为根设备,所以就把根设备号保存在root_dev中,这个根设备号作为机器系统数据之一,将在第2章中讲到的“根文件系统加载”中发挥关键性的作用。
小贴士
根文件系统设备(Root Device):Linux 0.11使用Minix操作系统的文件系统管理方式,要求系统必须存在一个根文件系统,其他文件系统挂接其上,而不是同等地位。Linux 0.11没有提供在设备上建立文件系统的工具,故必须在一个正在运行的系统上利用工具(类似FDISK和Format)做出一个文件系统并加载至本机。故Linux 0.11的启动需要两部分数据,即系统内核镜像和根文件系统。
注意,这里的文件系统指的不是操作系统内核中的文件系统代码,而是有配套的文件系统格式的设备,如一张格式化好的软盘。
现在,bootsect程序的任务都已经完成!
下面要通过执行“jmpi 0, SETUPSEG”这行语句跳转至0x90200处,就是前面讲过的第二批程序—setup程序加载的位置。CS:IP指向setup程序的第一条指令,意味着由setup程序接着bootsect程序继续执行。图1-14形象地描述了跳转到setup程序后的起始状态,对应的代码如下:
//代码路径:boot/bootsect.s
jmpi 0, SETUPSEG
setup程序现在开始执行。它做的第一件事请就是利用BIOS提供的中断服务程序从设备上提取内核运行所需的机器系统数据,其中包括光标位置和显示页面等数据,并分别从中断向量0x41和0x46向量值所指的内存地址处获取硬盘参数表1和硬盘参数表2,把它们存放在0x9000:0x0080和0x9000:0x0090处。
这些机器系统数据被加载到内存的0x90000~0x901FC位置,图1-15标出了其内容及准确的位置。这些数据将在以后main函数执行时发挥重要作用。
提取机器系统数据的具体代码如下:
//代码路径:boot/setup.s
mov ax, #INITSEG ! this is done in bootsect already, but...
mov ds, ax
mov ah, #0x03 ! read cursor pos
xor bh, bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.
! Get memory size (extended mem, kB)
mov ah, #0x88
int 0x15
mov [2], ax
…
…
mov cx, #0x10
mov ax, #0x00
rep
stosb
这段代码大约70行,由于篇幅限制,我们省略了大部分代码。
注意,BIOS提取的机器系统数据将覆盖bootsect程序所在的部分区域。由于这些数据是要留用的,因此在它们失去使用价值之前,一定不能被覆盖掉。
点评
机器系统数据所占的内存空间为0x90000~0x901FD,共510个字节,即原来的bootsect只有2字节未被覆盖。可见,操作系统对内存的使用是非常严谨的。在空间上,操作系统对内存严格按需使用,要加载的数据刚好占用一个扇区的位置(只差2字节),而启动扇区bootsect又恰好是一个扇区,内存的使用规划像一个账本,前后对应;在时间上,使用完毕的空间立即挪作他用,启动扇区bootsect程序刚结束其使命,执行setup时立刻就将其用数据覆盖,内存的使用率极高。虽然这与当时的硬件条件有限不无关系,但这种严谨的内存规划风格是很值得学习的。
到此为止,操作系统内核程序的加载工作已经完成。接下来的操作对Linux 0.11而言具有战略意义,系统通过已经加载到内存中的代码,将实现从实模式到保护模式的转变,使Linux 0.11真正成为“现代”操作系统。
1.3 开始向32位模式转变,为main函数的调用做准备
1.3.1 关中断并将system移动到内存地址起始位置0x00000
如图1-16所示,这个准备工作先要关闭中断,即将CPU的标志寄存器(EFLAGS)中的中断允许标志(IF)置0。这意味着,在接下来的执行过程中,无论是否发生中断,系统都不再响应此中断,直到下一章要讲解的main函数中能够适应保护模式的中断服务体系被重建完毕才会打开中断,而那时候响应中断的服务程序将不再是BIOS提供的中断服务程序,取而代之的是由系统自身提供的中断服务程序。
代码为:
//代码路径:boot/setup.s
cli
小贴士
EFLAGS:标志寄存器,存在于CPU中,32位,包含一组状态标志、控制标志和系统标志。如第0位的CF(Carry Flag)为CPU计算用到的进位标志,以及图中关中断操作涉及的第9位IF(Interrupt Flag)中断允许标志。
点评
关中断(cli)和开中断(sti)操作将在操作系统代码中频繁出现,其意义深刻,用心良苦。慢慢地你会发现,cli和sti总是在一个完整的操作过程的两头出现,目的是为了避免中断在此期间介入。接下来的代码将为操作系统进入保护模式做准备,此处即将进行实模式下中断向量表和保护模式下中段描述符表(IDT)的交接工作。试想,如果没有cli,又恰好发生了中断,如用户不小心碰了一下键盘,中断就要切进来,就不得不面对实模式的中断机制已经废除,但保护模式的中断机制尚未完成的尴尬局面,结果就是系统崩溃。cli和sti保证了这个过程中中断描述符表能够完整创建,以避免不可预料的中断进入,从而造成中断描述符表创建不完整或新老中断机制混用。甚至可以理解为cli和sti是为了保护一个新的计算机生命的完整创建。
下面,setup程序做了一个影响深远的动作:将位于0x10000的内核程序拷贝至内存地址起始位置0x00000处!代码如下:
//代码路径:boot/setup.s
do_move:
mov es,ax ! destination segment
add ax, #0x1000
cmp ax, #0x9000
jz end_move
mov ds, ax ! source segment
sub di, di
sub si, si
mov cx, #0x8000
rep
movsw
jmp do_move
图1-17准确标识了复制操作系统内核代码的源位置和目标位置及复制动作的方向。
回顾一下图1-2的内容,0x00000这个位置原来存放着由BIOS建立的中断向量表及BIOS数据区,这个复制动作将BIOS中断向量表和BIOS数据区完全覆盖,使它们不复存在。直到新的中断服务体系构建完毕之前,操作系统不再具备响应并处理中断的能力。现在,我们开始体会到图1-16中的关中断操作的意义了。
点评
这样做能取得一箭三雕的效果:
(1)废除BIOS的中断向量表,等价于废除了BIOS提供的实模式下的中断服务程序。
(2)收回使用寿命刚刚结束的程序所占的内存空间。
(3)让内核代码占据内存物理地址最开始的、最天然的、最有利的位置。
“破旧立新”这个成语用在这里特别贴切,system模块复制到0x00000这个动作废除了BIOS的中断向量表,也就是废除了16位的中断机制。操作系统是不能没有中断的,对外设的使用、系统调用、进程调度都离不开中断。Linux操作系统是32位的现代操作系统,16位的中断机制对32位的操作系统而言显然是不合适的,这也是废除16位中断机制的根本原因。为了建立32位的操作系统,我们不但要“破旧”,还要“立新”—建立新的中断机制。
1.3.2 设置中断描述符表和全局描述符表
小贴士
GDT(Global Descriptor Table,全局描述符表):它是系统中唯一存放段寄存器内容(段描述符)的数组,配合程序进行保护模式下的段寻址。它在操作系统的进程切换中具有重要意义,可理解为所有进程的总目录表,其中存放着每一个任务(task)局部描述符表(LDT,Local Descriptor Table)地址和任务状态段(TSS,Task Structure Segment)地址,用于完成进程中各段的寻址、现场保护与现场恢复。
GDTR(Global Descriptor Table Register,GDT基地址寄存器):GDT可以存放在内存的任何位置,当程序通过段寄存器引用一个段描述符时,需要取得GDT的入口, GDTR所标识的即为此入口。在操作系统对GDT的初始化完成后,可以用LGDT(Load GDT)指令将GDT基地址加载至GDTR。
IDT(Interrupt Descriptor Table,中断描述符表):保存保护模式下所有中断服务程序的入口地址,类似于实模式下的中断向量表。
IDTR(Interrupt Descriptor Table Register,IDT基地址寄存器):IDT基地址寄存器,保存IDT的起始地址。
内核实现代码如下:
//代码路径:boot/setup.s
lidt idt_48
lgdt gdt_48
gdt:
.word 0,0,0,0 ! dummy
.word 0x07FF ! 8Mb-limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386
.word 0x07FF ! 8Mb-limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx
这些代码设置所需要的数据分别在idt_48和gdt_48所对应的标号处,它们与寄存器的对应方式如图1-18所示。
点评
32位的中断机制和16位的中断机制在原理上有比较大的差别,最明显的是16位的中断机制用的是中断向量表,中断向量表的起始位置在0x00000处,这个位置是固定的。32位的中断机制用的是中断描述符表IDT,位置是不固定的,可以由操作系统的设计者根据设计要求灵活安排,由IDTR寄存器来锁定其位置。
GDT表是保护模式下管理段描述符的数据结构,对操作系统自身的运行和管理,以及进程调度有重大意义,后面的章节会有详细讲解。
因为,此时此刻内核尚未真正运行起来,还没有进程,所以现在创建的GDT表的第一项为空,第二项为内核代码段描述符,第三项为内核数据段描述符,其余项皆为空。
IDT表虽然已经设置,实为一张空表,原因是目前已关中断,无须调用中断服务程序。此处反映的是数据“够用即得”的思想。
创建这两个表的过程可理解为是分两步进行的:
(1)在设计内核代码时,已经将两个表写好,并且把需要的数据也写好。
(2)将专用寄存器(IDTR和GDTR)指向表。
去此处的数据区域是在内核源代码中设定和编译并直接加载至内存形成的一块数据区域。专用寄存器的指向由程序中的lidt指令和lgdt指令完成,具体操作见图1-18。
值得一提的是,在内存中做出数据的方法有两种:
(1)划分一块内存区域并初始化数据,“看住”这块内存区域,使之能被找到;
(2)由代码做出数据,如用push代码压栈,“做出”数据,此处采用的是第一种方法。
1.3.3 打开A20,实现32位寻址
下面的动作是标志性的—打开A20! 打开A20,意味着CPU可以进行32位寻址,最大寻址空间为4GB,注意看图1-19中内存条范围的变化。从5个F扩展到8个F,即0xFFFFFFFF—4GB。
现在看来,Linux 0.11还显得有些稚嫩,最大只能支持16MB的物理内存,但是其线性寻址空间已经是不折不扣的4GB。
打开A20的代码如下:
//代码路径:boot/setup.s
call empty_8042
mov al,#0xD1 ! command write
out #0x64,al
call empty_8042
mov al,#0xDF ! A20 on
out #0x60,al
call empty_8042
点评
实模式下CPU的寻址范围为0至0xFFFFF,共1MB寻址空间,需要0~19号共20根地址线。进入保护模式后,将使用32位寻址模式,即采用32根地址线进行寻址,第21根至第32根地址线的选通,将意味着寻址模式的切换。
实模式下,当程序寻址超过0xFFFFF时,CPU将“回滚”至内存地址起始处寻址(注意:在只有20位地址线的条件下,0xFFFFF + 1 = 0x00000,最高位溢出)。例如,系统的段寄存器(如CS)的最大允许地址为0xFFFF,指令指针(IP)的最大允许段内偏移也为0xFFFF,两者确定的最大绝对地址为0x10FFEF,这将意味着程序中可产生的实模式下的寻址范围比1MB多出将近64KB(一些有特殊寻址要求的程序就利用了这个特点)。这样,此处对A20地址线的启用相当于关闭了CPU在实模式下寻址的“回滚”机制。在后续代码中也将看到利用此特点来验证A20地址线是否确实已经打开。
1.3.4 为在保护模式下执行head.s做准备
为了建立保护模式下的中断机制,setup程序将对可编程中断控制器8259A进行重新编程。
小贴士
8259A中断控制器:8259A是专门为了对8085A和8086/8088进行中断控制而设计的芯片,它是可以用程序控制的中断控制器。单个的8259A能管理8级向量优先级中断。在不增加其他电路的情况下,最多可以级联成64级的向量优先级中断系统。
具体代码如下:
//代码路径:boot/setup.s
mov al,#0x11 ! initialization sequence
out #0x20,al ! send it to 8259A-1
.word 0x00eb,0x00eb ! jmp $+2, jmp $+2
out #0xA0,al ! and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 ! start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 ! start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 ! 8259-1 is master
out #0x21,al
.word 0x00eb, 0x00eb
mov al, #0x02 ! 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 ! 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF ! mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
重新编程的结果在图1-20中有直观的表述。
在保护模式下,int 0x00~int 0x1F被Intel保留作为内部(不可屏蔽)中断和异常中断。如果不对8259A进行重新编程, int 0x00~int 0x1F中断将被覆盖。例如,IRQ0(时钟中断)为8号(int 0x08)中断,但在保护模式下此中断号是Intel保留的“Double Fault (双重故障)”。因此,必须通过8259A编程将原来的IRQ0x00~IRQ0x0F对应的中断号重新分布,即在保护模式下,IRQ0x00~IRQ0x0F的中断号是int 0x20~0x2F。
setup程序通过下面代码的前两行将CPU的工作方式设为保护模式。将CR0寄存器的第0位(PE)置1,即设定处理器的工作方式为保护模式。
小贴士
CR0寄存器:0号32位控制寄存器,存放系统控制标志。第0位为PE(Protected Mode Enable,保护模式使能)标志,置1时CPU工作在保护模式下,置0时为实模式。
//代码路径:boot/setup.s
mov ax,#0x0001 ! protected mode (PE) bit
lmsw ax ! This is it!
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
图1-21对此做出了直观的标示。
CPU工作方式转变为保护模式,一个重要的特征就是要根据GDT表来决定后续将执行哪里的程序。
注意看图1-18中对GDT表的设置,这些设置都是setup事先安排好了的默认设置。从setup程序跳转到head程序的方式,如图1-22所示。
//代码路径:boot/setup.s
jmpi 0, 8
这一句中的“0”是段内偏移,“8”是保护模式下的段选择符,用于选择描述符表和描述符表项以及所要求的特权级。这里的8的解读方式很有意思,如果把8当作十进制的8来看待,这行程序的意思就很难理解了。必须把8看成二进制的1000,再把前后相关的代码联合起来当作一个整体看,并在头脑中形成类似图1-23所示的图,才能真正明白这行代码究竟在说什么。注意,这是一个以位为操作单位的数据使用方式,4个bit的每一位都有明确的意义,这是底层源代码的一个特色。
这里1000的最后两位00表示内核特权级,与之相对的用户特权级是11,第三位的0表示GDT表,如果是1,则表示LDT。1000的1表示所选的表(在此就是GDT表)的1项(GDT表项号排序为0项、1项、2项,也就是第2项)来确定代码段的段基址和段限长等信息,从图1-21中我们可以看到,代码是从段基址0x00000000、偏移为0处开始执行的,也就是head程序的开始位置,这意味着将执行head程序。
到这里为止,setup就执行完毕了,它为系统能够在保护模式下运行做了一系列的准备工作,但这些准备工作还不够,后续的准备工作将由head程序来完成。
1.3.5 head.s开始执行
在讲解head程序之前,我们先介绍一下从bootsect到main函数执行的整体技术策略。
在执行main函数之前,先要执行三个由汇编代码生成的程序,即bootsect、setup和head。之后,才执行由main函数开始的用C语言编写的操作系统内核程序。
前面我们讲过,第一步:加载bootsect到0x07C00,然后复制到0x90000;第二步:加载setup到0x90200。值得注意的是,这两段程序是分别加载和分别执行的,head程序与它们的加载方式有所不同。
大致的过程是,先将head.s汇编成目标代码,将用C语言编写的内核程序编译成目标代码,然后链接成system模块。也就是说,system模块里面,既有内核程序,又有head程序,两者是紧挨着的。要点是,head程序在前,内核程序在后,所以head程序名字叫“head”,head程序在内存中占有25KB+184B的空间,请读者注意这个数字。前面讲解过,system模块加载到内存后,setup将system模块复制到0x00000位置,由于head程序在system的前面,所以实际上,head程序就在0x00000这个位置。head程序和以main函数开始的内核程序在system模块中的布局示意图如图1-24所示。
head程序除了做一些调用main的准备工作之外,还做了一件对内核程序在内存中的布局及内核程序的正常运行有重大意义的事,即用程序自身的代码在程序自身所在的内存空间创建了内核分页机制,即在0x000000的位置创建了页目录表、页表、缓冲区、GDT、IDT,并将head程序已经执行过的代码所占的内存空间覆盖,这意味着head程序自己将自己废弃,main函数即将开始执行。
以上就是head程序执行过程的整体策略,我们参照这个策略,看看head究竟是怎么执行的。
在讲解head程序执行之前,我们先来关注一个标号:_pg_dir。
如下面的代码所示:
//代码路径:boot/head.s
_pg_dir:
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
标号_pg_dir用于标识内核分页机制完成后的内核起始位置,也就是物理内存的起始位置0x000000。head程序马上就要在此处建立页目录表,为分页机制做准备。这一点非常重要,是内核能够掌控用户进程的基础之一,后续章节将逐步讲解。图1-25中描述了页目录表在内存中所占的位置。
现在head程序正式开始执行,一切都是为适应保护模式做准备。在图1-25中,其本质就是让CS的用法从实模式转变到保护模式。在实模式下时,CS本身就是代码段基址。在保护模式下时,CS本身并不是代码段基址,而是代码段选择符。通过图1-25的分析得知,jmpi 0, 8这句代码使CS和GDT表的第2项关联,并且使代码段的基址指向了0x000000。
从现在开始,要将DS、ES、FS和GS等其他寄存器从实模式转变到保护模式。执行代码如下:
//代码路径:boot/head.s
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
执行完毕后,DS、ES、FS和GS中的值都变为0x10。与前面提到的jmpi 0,8中8的分析方法相同,0x10也应看成二进制的00010000,最后三位与前面讲解的一样,其中最后两位的00表示内核特权级、第3位的0表示选择GDT表,第4、5两位的10是GDT表的2项,也就是第3项。也就是说,4个寄存器用的是同一个全局描述符,它们的段基址、段限长和特权级都是相同的,特别要注意的是,影响段限长的关键字段的值是0x7ff,段限长就是8MB。
图1-26的左下部给出了详细示意。
具体的设置方式与图1-23类似,即都要参考GDT表中的内容,movl $0x10,%eax中的0x10是GDT表中的偏移值(用二进制表示就是10000),即要参考GDT表中第2项的信息(GDT表项号排序为第0项、第1项、第2项)来设置这些段寄存器,这一项就是内核数据段描述符。
点评
各段重叠,这样的编码操作方式,需要头脑非常清楚!
SS现在也要转变为栈段选择符,栈顶指针也成为32位的esp,如下所示:
lss _stack_start,%esp
在kernel/sched.c中,stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 }这行代码将栈顶指针指向user_stack数据结构的最末位置,这个数据结构是在kernel/sched.c中定义的,如下所示:
long user_stack [ PAGE_SIZE>>2 ]
我们测算出其起始位置为0x1E25C。
小贴士
取段寄存器指令(Load Segment Instruction):该组指令的功能是把内存单元的一个“低字”传送给指令中指定的16位寄存器,把随后的一个“高字”传给相应的段寄存器(DS、ES、FS、GS和SS)。其指令格式如下:
LDS/LES/LFS/LGS/LSS Reg, Mem
指令LDS(Load Data Segment Register)和LES(Load Extra Segment Register)在8086CPU中就存在,而LFS和LGS(Load Extra Segment Register)、LSS(Load Stack Segment Register)是80386及其以后CPU中才有的指令。 如果Reg是16位寄存器,那么Mem必须是32位指针;如果Reg是32位寄存器,那么Men必须是48位指针,其低32位给指令中指定的寄存器,高16位给指令中的段寄存器。
0x10将SS的值设置为与前面4个段选择符的值相同。这样,SS与前面讲解过的4个段选择符相同,段基址都指向0x000000,段限长都是8MB,特权级都是内核特权级,后面的压栈动作就要在这里进行。
特别值得一提的是,现在刚刚从实模式转变到保护模式,段基址的使用方法和实模式差别非常大,要使用GDT产生段基址,前面讲到的那几行设置段选择符的指令本身都是要用GDT寻址的。现在就能清楚地看出,如果没有setup程序在16位实模式下模拟32位保护模式而创建的GDT,恐怕前面这几行指令都无法执行。
注意,栈顶的增长方向是从高地址向低地址的。参见图1-27的下部,注意栈段基址和ESP在图中的位置。
我们现在回忆一下图1-8中对栈指针寄存器的设置,那时是设置sp,而这时是设置esp,多了一个字母e,这是为适应保护模式而做的调整。这段内容对应的代码如下:
//代码路径:boot/head.s
lss _stack_start, %esp
head程序接下来对中断描述符表进行设置,代码如下所示:
//代码路径:boot/head.s
call setup_idt
……
setup_idt:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate-dpl=0, present */
lea _idt,%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
lidt idt_descr
ret
小贴士
一个中断描述符的结构如下:
中断描述符为64位,包含了其对应中断服务程序的段内偏移地址(OFFSET)、所在段选择符(SELECTOR)、段特权级(DPL)、段存在标志(P)、段描述符类型(TYPE)等信息,供CPU在程序中需要进行中断服务时找到相应的中断服务程序。其中,第0~15位和第48~63位组合成32位的中断服务程序的段内偏移地址;第16~31位为段选择符(SELECTOR),定位中断服务程序所在段;第47位为段存在标志(P),用于标识此段是否存在于内存中,为虚拟存储提供支持;第45~46位为特权级标志(DPL),特权级范围从0~3;第40~43位为段描述符类型标志(TPYE),中断描述符对应的类型标志为1110(0xE),即将此段描述符标记为“386中断门”。
这是重建保护模式下中断服务体系的开始,程序先让所有的中断描述符默认指向ignore_int这个位置(将来main函数里面还要让中断描述符对应具体的中断服务程序),之后还要对中断描述符表寄存器的值进行设置。图1-28显示了具体的操作状态。
点评
构造中断描述符表,并使所有中断服务程序指向同一段只显示一行提示信息就返回的服务程序,先使中断机制的整体架构搭建起来(实际的中断服务程序挂接则在main函数中完成)。从编程技术上讲,这是一个占位的操作方式,也防止了“野指针”。
现在,head程序要废除已有的GDT,并在内核中的新位置重新创建全局描述符表,如图1-29所示。其中第二项和第三项分别为内核代码段描述符和内核数据段描述符,其段限长均被设置为16MB,并设置全局描述符表寄存器的值。
代码如下:
//代码路径:boot/head.s
setup_gdt
............
setup_gdt:
lgdt gdt_descr
ret
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY-don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
点评
为什么要废除原来的GDT而重新设置一套GDT呢?
原来GDT所在的位置是设计代码时在setup.s里面设置的,将来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。如果不改变位置,GDT的内容将来肯定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,将来整个内存中唯一安全的地方就是现在head.s所在的位置了。
那么有没有可能在执行setup程序时直接把GDT的内容拷贝到head.s所在的位置呢?肯定不能,如果先复制GDT的内容,后移动system模块,它就会被后者覆盖掉;如果先移动system模块,后复制GDT的内容,它又会把head.s对应的程序覆盖掉,而这时head.s还没有执行呢。所以,无论如何,都要重新建立GDT。
全局描述符表GDT的位置和内容发生了变化,特别要注意最后的三位是fff,说明段限长不是原来的8MB,而是现在的16MB。如果后面的代码第一次使用这几个段选择符就是访问8MB以后的地址空间,将会产生段限长超限报警,为了防止这类可能发生的情况,这里再次对一些段选择符进行重新设置,包括DS、ES、FS、GS和SS,方法与图1-26类似,主要是段限长增加了一倍,变为了16MB。上述过程如图1-30所示。
调整DS和ES等寄存器的对应代码如下:
//代码路径:boot/head.s
movl $0x10,%eax # reload all the segment registers
mov %ax,%ds # after changing gdt. CS was already
mov %ax,%es # reloaded in 'setup_gdt'
mov %ax,%fs
mov %ax,%gs
通过测试我们发现,这是一种舍近求远的方法,其实只要在setup中构建第一个GDT表时把控制段限长的7ff直接设置为fff就可以一步到位了,不需要在这里重新设置段选择符。
现在user_stack数据结构的起始位置就是内核栈的栈底,栈顶指针esp指向user_stack数据结构的外边缘,也就是内核栈的栈顶。这样,当后面的程序需要压栈时,就可以最大限度地使用栈空间。栈顶的增长方向是从高地址向低地址的,如图1-31所示。设置esp的代码如下:
//代码路径:boot/head.s
lss _stack_start,%esp
因为A20地址线是否打开是保护模式与实模式的根本区别,所以,现在要检验A20地址线是否确实打开了。图1-32在左下部给出了直观的标示。
点评
A20如果没有打开,则计算机处于实模式下,超过0xFFFFF寻址必然“回滚”。一个特例是0x100000就会回滚到0x000000,也就是说,地址0x100000处存储的值必然和地址0x000000处存储的值完全相同(参见图1-30的描述)。通过在内存0x000000位置写入一个数据,然后比较此处和1MB(即0x100000,注意,已超过实模式寻址范围)处数据是否一致,就可以检验A20地址线是否已打开。
确定A20地址线已经打开之后,head程序如果检测到数学协处理器存在,则将其设置为保护模式工作状态,图1-33给出了示意。
小贴士
x87协处理器:为了弥补x86系列在进行浮点运算时的不足,Intel于1980年推出了x87系列数学协处理器,那时还是一个外置的、可选的芯片(笔者当时的80386计算机上就没有安装80387协处理器)。1989年,Intel发布了486处理器,自此之后,CPU一般都内置了协处理器。这样,对于486以前的计算机而言,操作系统检验x87协处理器是否存在就非常必要了。
检测数学协处理器对应的代码如下:
//代码路径:boot/head.s
movl %cr0,%eax
......
call check_x87
check_x87:
......
ret
head程序将为调用main函数做最后的准备,这是head程序执行的最后阶段,也是main函数执行前的最后阶段。
执行代码如下:
//代码路径:boot/head.s
jmp after_page_tables
after_page_tables:
pushl $0
pushl $0
pushl $0
Linus在代码中的原注释是“These are the parameters to main :-)”,但实际上我们并没有看到,也没有测试到main函数使用这三个参数,具体如图1-34下方的标示。
head程序将L6标号和main函数入口地址压栈,栈顶为main函数地址,目的是使head程序执行完后通过ret指令就可以直接执行main函数,具体请见图1-35下方的标示。
如果main函数退出,就会返回到这里的标号L6处继续执行下去,并产生死循环。
执行代码如下:
//代码路径:boot/head.s
pushl $L6
pushl $_main
这些压栈动作完成后,head程序将跳转至setup_paging:去执行,开始创建分页机制。
首先会将页目录表和4个页表放在物理内存的起始位置。从内存起始位置开始的5页空间内容全部清零(每页4KB),为初始化页目录和页表做准备。注意,这个动作起到了用一个页目录表和4个页表覆盖head程序自身所占内存空间的作用。图1-36下方给出了直观的示意图。
上述动作的执行代码如下:
//代码路径:boot/head.s
jmp setup_paging
setup_paging:
movl $1024*5,%ecx
xorl %eax,%eax
xorl %edi,%edi
cld;rep;stosl
点评
将页目录表和4个页表放在物理内存的起始位置,这个动作的意义重大,是操作系统能够掌控全局、掌控进程在内存中安全运行的基石之一,后续章节会逐步论述。
head程序将页目录表和4个页表所占物理内存空间清0后,设置页目录表的前4项,使之分别指向4个页表,如图1-37左下方所示。
上述动作的代码如下:
//代码路径:boot/head.s
movl $pg0+7,_pg_dir /* set present bit/user r/w */
movl $pg1+7,_pg_dir+4 /* --------- " " --------- */
movl $pg2+7,_pg_dir+8 /* --------- " " --------- */
movl $pg3+7,_pg_dir+12 /* --------- " " --------- */
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb-4096 + 7 (r/w user,p) */
head程序设置完页目录表后,Linux 0.11在保护模式下支持的最大寻址地址为0xFFFFFF(16MB),此处将第4张页表(由pg3指向的位置)的最后一个页表项(pg3+4902指向的位置)指向寻址范围的最后一个页面,即0xFFF000开始的4KB字节大小的内存空间。具体请看图1-38右下方的标示。
然后开始从高地址向低地址方向填写全部的4个页表,依次指向内存从高地址向低地址方向的各个页面,图1-38是首次设置页表。
继续设置页表。将第4张页表(由pg3指向的位置)的倒数第二个页表项(pg3-4+4902指向的位置)指向倒数第二个页面,即0xFFF000-0x1000(0x1000即4k,一个页面的大小)开始的4k字节内存空间。请读者认真对比图1-39和图1-38,图中有多处位置发生了变化。
最终,从高地址向低地址方向完成全部4个页表的填写,页表中的每一个页表项分别指向内存从高地址向低地址方向的各个页面,如图1-40下方所示。
这4个页表都是内核专属的页表,将来每个用户进程都有它们专属的页表,两者在寻址范围方面的区别,我们将在内存与进程一章中详细介绍。
图1-38~图1-40中所发生动作的相应代码如下:
//代码路径:boot/head.s
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb-4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards-more efficient :-) */
subl $0x1000,%eax
jge 1b
这些工作完成后,内存中的布局如图1-41所示。可以看出,只有184个字节的剩余代码,由此可见在设计head程序和system模块时,其计算是非常精确的,对head.s的代码量的控制非常到位。
head程序已将页表设置完毕了,但分页机制的建立还没有完成。需要设置页目录基址寄存器CR3,使之指向页目录表,再将CR0寄存器设置的最高位(31位)置为1,如图1-42的右中部CR0寄存器的示意图。
小贴士
PG(Paging)标志:CR0寄存器的第32位,分页机制控制位。当CPU的控制寄存器CR0第1位PE(保护模式)置为1时,可设置PG位为开启。在开启后,地址映射模式采取分页机制。当CPU的控制寄存器CR0第1位PE(保护模式)置为0时,此时设置PG位将引起CPU发出异常。
CR3寄存器:3号32位控制寄存器,高20位存放页目录的基地址。当CR0中的PG标志置位时,CPU使用CR3指向的页目录和页表进行虚拟地址到物理地址的映射。
执行代码如下:
//代码路径:boot/head.s
xorl %eax,%eax /* pg_dir is at 0x0000 */
movl %eax,%cr3 /* cr3-page directory start */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* set paging (PG) bit */
前两行的动作是将CR3指向页目录表,意味着操作系统认定0x0000这个位置就是页目录表的起始位置,后3行的动作是启动分页机制开关PG标志置位,以启用分页寻址模式,两个动作一气呵成。到这里为止,内核的分页机制构建完毕,后续章节还会讲解如何建立用户进程的分页机制。
最重要的是
xorl %eax,%eax /* pg_dir is at 0x0000 */
这一行代码,它看似简单,但用意深远。回过头来看,图1-17将system模块移动到0x00000,图1-25在内存的起始位置建立内核分页机制,最后就是上面的这行代码,认定页目录表在内存的起始位置,三个动作联合起来为操作系统中最重要的目的—内核控制用户程序奠定了基础,这个位置是内核通过分页机制能够实现线性地址等于物理地址的唯一起始位置。我们会在后续章节逐层展开讨论。
head程序执行最后一步:ret。跳入main函数程序执行。
在图1-35中,main函数的入口地址被压入了栈顶,现在执行ret了,正好将压入的main函数的执行入口地址弹出给EIP。图1-43中的下方标示了出栈动作。
这部分代码用了底层代码才会使用的技巧,我们结合图1-44对这个技巧进行详细讲解。
我们先看看普通函数的调用和返回方法,因为Linux 0.11 用返回方法调用main函数,返回位置和main函数的入口在同一段内,我们只讲解段内调用和返回。见图1-44(仿CALL示意图)的上半部分,CALL的调用与返回。
CALL指令会将EIP的值自动压栈,保护返回现场,然后执行被调函数的程序。等到执行被调函数的ret指令时,自动出栈给EIP并还原现场,继续执行CALL的下一行指令。这是通常的函数调用方法。对操作系统的main来说,这个方法就有些怪异了。main函数是操作系统的,如果用CALL调用操作系统的main函数,那么ret时返回给谁呢?难道还有一个更底层的系统程序接收操作系统的返回吗?操作系统已经是最底层的系统了,所以逻辑上不成立。那么如何既调用了操作系统的main函数,又不需要返回呢?操作系统的设计者采用了图1-44(仿CALL示意图)的下半部分所示的方法。
这个方法的妙处在于是用ret实现的调用操作系统的main函数,既然是ret调用,当然就不需要再用ret了。不过,CALL做的压栈和跳转的动作谁来完成呢?操作系统的设计者做了一个仿CALL的动作,手工编写压栈和跳转代码,模仿了CALL的全部动作,实现了调用setup_paging函数。注意,压栈的EIP值并不是调用setup_paging函数的下一行指令的地址,而是操作系统的main函数的执行入口地址_main。这样,当setup_paging函数执行到ret时,从栈中将操作系统的main函数的执行入口地址_main自动出栈给EIP,EIP指向main函数的入口地址,实现了用返回指令“调用”main函数。
在图1-44中,将压入的main函数的执行入口地址弹出给CS:EIP,这句话等价于CPU开始执行main函数程序。图1-45在左下方标示了这个状态。
点评
为什么没有最先调用main函数?
学过C语言的都知道,用C语言设计的程序都有一个main函数,而且是从main函数开始执行的。Linux 0.11的代码是用C语言编写的,奇怪的是,为什么在操作系统启动时先执行的是三个由汇编写成的程序,然后才开始执行main函数?为什么不是像我们熟知的C程序那样,从main函数开始执行呢?
通常,我们用C语言编写的程序都是用户应用程序,这类程序的执行有一个重要的特征,就是必须在操作系统的平台上执行,也就是说要由操作系统为应用程序创建进程,并把应用程序的可执行代码从硬盘加载到内存。现在我们讨论的是操作系统,不是普通的应用程序,这样就出现了一个问题,应用程序是由操作系统加载的,操作系统该由谁加载呢?
从前面的章节中我们知道,加载操作系统的时候,计算机刚刚加电,只有BIOS程序在运行,而且此时计算机处于16位实模式状态下,通过BIOS程序自身的代码形成的16位的中断向量表及相关的16位的中断服务程序,将操作系统在软盘上的第一扇区(512字节)的代码加载到内存,BIOS能主动操作的内容也就到此为止了。准确地说,这是一个约定,对于第一扇区代码的加载,不论是什么操作系统都是一样的。从第二扇区开始,就要由第一个扇区中的代码来完成后续的代码加载工作。
在加载工作完成后,好像仍然没有立即执行main函数,而是打开A20,打开pe和pg,建立IDT、GDT……然后才开始执行main函数,这是为什么?
原因是,Linux 0.11是一个32位的实时多任务的现代操作系统,main函数肯定要执行的是32位的代码,编译操作系统代码时,是有16位和32位两个不同的编译选项的,如果选了16位,C语言编译出来的代码是16位模式的,结果可能是一个int型变量只有2个字节,而不是32位的4个字节……这不是Linux 0.11想要的,Linux 0.11要的是32位的编译结果,只有这样才能成为32位的操作系统代码,这样的代码才能用到32位总线(即打开A20后的总线),才能用到保护模式和分页,才能成为32位的实时多任务的现代操作系统。
开机时的16位实模式与main函数执行需要的32位保护模式之间有很大的差距,这个差距谁来填补? head.s做的就是这项工作,这期间,head程序打开A20,打开pe和pg,废弃旧的、16位的中断响应机制,建立新的32位的IDT……这些工作都做完了,计算机已经处在32位的保护模式状态了,调用32位main函数的一切条件已经准备完毕,这时可顺理成章地调用main函数,后面的操作就可以用32位编译的main函数完成。
至此,Linux 0.11操作系统内核启动的一个重要阶段已经完成,接下来就要进入main函数对应的代码了。
需要特别提示的是,此时仍处在关闭中断的状态!
1.4 本章小结
从借助BIOS将bootsect.s文件加载到内存开始,相继加载了setup.s文件和system文件,从而完成操作系统程序的加载。接下来为32位保护模式和分页模式下的main函数的执行做准备。设置IDT、GDT、页目录表、页表,以及机器系统数据。
一切就绪后,跳转到main函数执行入口,开始执行main函数。
第4章 文件操作
在第3章中,已经加载了硬盘文件系统,标志着用户能够以文件的形式对硬盘上的数据进行访问了。为何要以文件的方式访问硬盘数据,直接以扇区的方式对硬盘进行访问难道不行吗?不能说不行,但是存在许多的问题。比如,通常来说,用户所要使用的数据都要大于一个扇区,这就意味着这些数据需要存储在多个扇区上。一个简单的方法是将这些数据存储在一段物理上连续的扇区中。但是,随着数据不断地被修改、删除和添加,硬盘中很快就会产生大量没有存储数据的碎片,而且理论和实际都表明碎片会越来越多,最终整个硬盘几乎被碎片填满,比碎片尺寸大的数据将无法利用这些碎片。如果不进行整理,硬盘虽然有大量的未用空间,却无法写入数据,浪费了宝贵的硬盘存储空间。整理碎片又非常耗时。
操作系统的设计者想出了“以碎制碎”的策略。既然对数据的频繁操作会使得硬盘存储空间变碎,索性就事先把硬盘的存储空间人为分成许多规则的、整齐划一的小块,把数据存放在这样的块中,这就是块设备的由来。通常,块的大小是扇区大小的整数倍,不同的操作系统块的大小略有不同,在Linux 0.11中,一个块的大小是1KB,就是两个扇区。
为了实现这个创意,操作系统的设计者提出了文件系统的设计方案。文件系统不仅实现了这个创意,还进一步解决了在存储数据时不会覆盖已经存在的数据的问题,这一点意义重大。
文件系统首先设计了一个数据结构,管理属于同一个文件的所有的块。对于Linux 0.11来说,这个数据结构就是i节点。由于i节点的数量极其庞大,需要一个数据结构来管理i节点的使用情况,这个数据结构就是i节点位图。
另外,硬盘上的块数量也是极其庞大的,同样需要一个数据结构来标识块的使用情况,这个数据结构就是逻辑块位图。
以上这些数据结构的功能、大小、所处位置各不相同,需要一个总的数据结构来管理,这个数据结构就是超级块。
通过以上介绍的这些数据结构来管理文件系统主要操作的具体表现为:
在写操作的过程中,通过“超级块→逻辑块位图→数据块”这条路线可以查找到硬盘上的空闲数据块;通过“超级块→i节点位图→i节点表中的i节点→数据块”这条路线可以访问到文件中指定的数据块,如图4-1所示:
当然,文件系统最终是为人的使用而设计的,人习惯于使用路径名、文件名,这就需要在文件系统中建立一个特殊的数据结构,将路径名、文件名与上述管理数据结构相关联,这个数据结构就是目录项。
值得一提的是文件系统中涉及i节点的代码非常多,其中的规律却不复杂,就是一个i节点对应一个文件,而且只对应一个文件,反之亦然。也就是说i节点和文件是严格的一一对应的,不存在任何形式的例外。另外还需要注意的是,目录文件也是文件,也是有i节点的。文件系统通常的组织规则是:
根目录文件i节点→根目录文件→根目录文件中A的目录项→A目录文件的i节点→A目录文件→A目录文件中B的目录项→B目录文件的i节点→B目录文件→B目录文件中C的目录项→……M目录文件→M目录文件中N的目录项→N文件的i节点→N文件内容
这个规律如图4-2所示:
这个规律在绝大多数情况下都是适用的,我们只要牢记这两点,就会比较容易掌握文件系统。
本章将通过3个实例的实际运行来详细讲解文件系统的工作原理和实际运行状况。我们假设本章中对文件的操作都是由一个用户进程完成的。
实例1:该用户进程对硬盘上一个已有的文件执行“打开”和“读取”这两个操作。
实例2:该用户进程在硬盘上新建一个文件,并将内容写入这个文件。
实例3:关闭此文件,之后将其从文件系统中删除。
这3个实例的内容涉及了Linux 0.11中对文件的所有基本操作,非常典型,掌握了这3个实例,就等于掌握了Linux 0.11中文件系统的绝大部分内容。
下面先来详细讲解实例1,我们设想的应用场景是,硬盘上已经存在一个事先写好的名为helloc.txt的文件。
实例1:用户进程打开一个在硬盘上已存在的文件,并读取文件的内容
本实例分为两部分:打开文件和读取文件,用户进程中所使用的代码如下:
void main()
{
//打开文件
char buffer[12000];
int fd = open("/mnt/user/helloc.txt", O_RDWR ,0644));
//读取文件
int size = read(fd,buffer,sizeof(buffer));
return;
}
这个用户进程的程序所要达到的最终目的就是将helloc.txt这个文件中的内容读出来。要想实现此目的,就要满足以下要求:
要确定读取哪个文件;
要确定具体读取文件的哪部分内容,是全部内容还是部分内容;
把文件内容读出后,要能够存储在用户事先指定的空间内。
以上3个要求就是Linux 0.11操作系统中所有文件读取操作的核心要求,这个进程程序中对open和read这两个库函数的调用就是为了满足以上要求,从而实 现最终读取文件内容的目的。
确定要读取哪个文件,用操作系统的术语表述就是“打开文件”;确定要写哪个文件,用操作系统的术语表述也是“打开文件”,这一点将在4.4节中讲解。准确地说,确定要操作哪个文件,用操作系统的术语表述都是“打开文件”。
open函数的核心目的就是实现第一个要求,我们将在4.1节中详细介绍。
read函数的核心目的就是实现第二个要求和第三个要求,我们将在4.2节中详细介绍。
下面,先来看实例1中打开文件的具体实现方法。
4.1 打开文件
本节要实现实例1中的第一个要求,要确定读取哪个文件,即“打开文件”。
因为在操作系统看来,文件是根据进程的需求来操作的,又因为文件的i节点是记载文件属性的最关键的数据结构,所以,只要使进程与指定的i节点建立关系,就可以实现实例1中的第一个要求:确定读取哪个文件,即“打开文件”。“打开文件”的本质就是为了建立这套关系。
具体表现为,以系统中的文件管理表file_table[64]为中转站,让进程管理结构task_struct中的文件管理指针表*filp[20]与将要打开的helloc.txt文件的i节点建立关系,从而使进程具备操作这个文件的能力。本节要讲述的就是这个过程的具体实现方法。
实现的具体过程分为三个步骤进行:
第一步:将用户进程管理结构task_struct中的文件管理指针表*filp[20]与内核中的文件管理表file_table[64]进行挂接;
第二步:以用户给定的路径名“/mnt/user/helloc.txt”为线索,找到helloc.txt文件的i节点;
第三步:将helloc.txt对应的i节点在文件管理表file_table[64]中进行登记,从而使进程具备操作helloc.txt文件的能力。
图4-3是本节将要讲述的主要内容的架构图:
4.1.1 用户程序调用open库函数产生软中断
具体的映射步骤与2.1.1节中的fork函数映射到sys_fork的步骤类似。区别在于,open函数需要传递3个参数,所以会执行_syscall3这个宏。对应代码如下:
//代码路径:include/unistd.h:
#define _syscall3(type,name,atype,a,btype,b,ctype,c)
在该宏函数中利用ebx、ecx和edx这3个寄存器,分别将open需要传递的3个参数传递给内核。对应代码如下:
//代码路径:include/unistd.h:
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
这个宏展开之后,看上去就像如下的形式:
int open(const char* filename, int flag, int mode)
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "0" (__NR_open),"b" ((long)( filename)),"c" ((long)( flag)),"d" ((long)(mode)));
if (__res>=0)
return (int) __res;
errno=-__res;
return -1;
}
最终,映射到sys_open函数中,打开文件的具体过程就是在sys_open函数中实现的。接下来,我们开始介绍文件打开的具体过程。
4.1.2 建立用户进程与文件管理表的关系
这里我们来介绍打开文件的第一步,即将用户进程管理结构task_struct中的文件管理指针表*filp[20]与内核中的文件管理表file_table[64]进行挂接,从而为用户进程操作文件打下基础。
图4-5中形象地表示了这个挂接动作:
执行步骤如下:
(1)先通过遍历当前进程管理结构task_struct中的文件管理指针表*filp[20]中的每一项,找到一个指针记录为空的项,哪一项指针记录为空,就表明该项为空闲项,执行代码为:
//代码路径:fs/open.c:
int sys_open(const char * filename,int flag,int mode)
{
...
for(fd=0 ; fd<NR_OPEN ; fd++)
if (!current->filp[fd])
break;
...
}
(2)再通过遍历内核文件管理表file_table[64]中的每一项,找到一个引用记数f_count为0的项,哪一项f_count为0,就表明该项为空闲项,对应代码为:
//代码路径:fs/open.c:
int sys_open(const char * filename,int flag,int mode)
{
...
f=0+file_table;
for (i=0 ; i<NR_FILE ; i++,f++)
if (!f->f_count) break;
...
}
(3)最后,将两个空闲项挂接,并把在内核文件表file_table[64]中找到的空闲项的引用计数加1,以表明该文件槽已经被使用了,对应代码为:
//代码路径:fs/open.c:
int sys_open(const char * filename,int flag,int mode)
{
...
(current->filp[fd]=f)->f_count++;
...
}
这样挂接的理由是:所有的进程,只要想操作文件,就要通过操作系统提供的文件管理表file_table[64]来实现;所有的文件,只要想被进程操作,也要登记在文件管理表file_table[64]中。可见,这个文件管理表file_table[64]就是进程操作文件的“中转站”,现在将两个空闲项挂接,就为进程最终具备操作helloc.txt的能力创造了条件。
4.1.3 从硬盘上获取helloc.txt文件的i节点
这里我们来介绍打开文件的第二步,即以用户给定的路径名“/mnt/user/helloc.txt”为线索,找到helloc.txt文件的的i节点。这一步的最终目的就是要把helloc.txt文件的i节点从硬盘上读出来,从而为把helloc.txt文件的i节点在文件管理表file_table[64]中找到的空闲项上登记创造条件。
Linux 0.11是通过对路径名的不断解析来找到指定文件的i节点的,这里我们先对将要参与解析的要素进行介绍。
我们在本章开头已经对文件系统的构成要件进行了介绍,每个文件都对应一个唯一的i节点,路径名中的“helloc.txt”是普通文件的文件名,helloc.txt文件毫无疑问会有一个唯一的i节点;路径名中的“mnt”和“user”是目录文件的文件名,mnt文件和user文件也都会有一个唯一的i节点。这就说明文件和i节点的一一对应性适用于整个文件系统中的全部文件。
目录文件与普通文件也是有区别的,它们存储的内容不一样。目录文件中存储着若干个目录项,每个目录项由两部分构成:一个是文件名,表明了该目录项所对应的文件名,这个文件名可以是一个普通文件的文件名,也可以仍然是一个目录文件的文件名;另一个是i节点号,表明了该目录项所对应的文件在i节点表中的项号,通过这个i节点号就可以在i节点表中找到所对应的文件的i节点。
参与解析的要素大体包括上述这些,即i节点、文件名、目录项、目录文件等。系统就是从路径名上不断地采集信息,然后根据这些信息对以上要素进行分析,最终找到helloc.txt这个普通文件的i节点的。我们这里首先大致给出一张“解析示意图”,以此标明解析的技术路线,如图4-6所示。
通过此示意图我们可以看出,路径名的解析具有相当的同构性,解析的技术路线是:寻找i节点→通过i节点找到目录文件→通过目录文件找到目录项→通过目录项找到后续路径名的i节点号。如此周而复始地解析,直到最终找到helloc.txt这个普通文件的i节点。这就意味着,只要我们把某一个“文件名→i节点”的前后解析步骤分析清楚,其余的步骤大体就可以推导出来。
接下来,我们就着重分析一下路径名中的一个文件名的解析步骤,以此贯穿到整个路径名的解析中。
4.1.3.1 准备查找枝梢i节点—user目录文件的i节点
//代码路径:fs/open.c:
int sys_open(const char * filename,int flag,int mode)
{
...
if ((i=open_namei(filename,flag,mode,&inode))<0)
...
}
进入open_namei函数后,调用dir_namei函数,通过解析路径名得到枝梢i节点,对于“/mnt/user/helloc.txt”这个路径名而言,枝梢i节点就是user这个目录文件的i节点,执行代码如下:
//代码路径:fs/namei.c:
int open_namei(const char * pathname, int flag, int mode,
struct m_inode ** res_inode)
{
...
if (!(dir = dir_namei(pathname,&namelen,&basename)))
...
}
进入dir_namei函数后,调用get_dir函数,同时将路径名也传递下去,开始实质性的i节点解析工作,执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * dir_namei(const char * pathname,
int * namelen, const char ** name)
{
...
if (!(dir = get_dir(pathname)))
...
}
4.1.3.2 确定查找操作的起点
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
if ((c=get_fs_byte(pathname))=='/') {
inode = current->root;
pathname++;
}
...
}
这几行代码的意思是,如果路径名的第一个字符是‘/’,就从根目录文件i节点(以下简称根i节点)进行解析,现在,根i节点就是解析路径名的起点。我们继续介绍解析的步骤。
4.1.3.3 获得名为mnt的目录项
具体执行步骤是这样的:
(1)先解析出mnt这个字符串的长度,执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
for(namelen=0;(c=get_fs_byte(pathname++))&&(c!='/');namelen++)
...
}
2)再以mnt这个名字为参照,在根目录文件中找到名字为mnt的目录项,执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
if (!(bh = find_entry(&inode,thisname,namelen,&de)))
...
}
find_entry函数的两个实参,thisname标识了mnt字符串的起始位置,namelen标识了该字符串的长度,这样就能锁定mnt这个字符串。
进入find_entry函数后,开始将根目录文件读取出来,并从中找出名字为mnt的目录项,读取并分析的过程是这样的:先确定这个目录文件的大小,因为确定了大小就可以确定这个目录文件会占用几个数据块,方法是通过根i节点中表示根目录文件大小的i_size字段除以每个目录项占用的字节数,执行代码如下:
//代码路径:fs/namei.c:
static struct buffer_head * find_entry(struct m_inode ** dir,
const char * name, int namelen, struct dir_entry ** res_dir)
{
...
entries = (*dir)->i_size / (sizeof (struct dir_entry));
...
}
(3)之后,就从目录文件的第一个数据块开始读取数据,将数据块读入到缓冲块,并调用match(namelen,name,de)函数分析这些数据中有没有名字为mnt的目录项,如果有,目的就达到了;如果没有,就接着从第二个数据块继续读取并分析,直到找到名字为mnt的目录项为止,执行代码如下:
//代码路径:fs/namei.c:
static struct buffer_head * find_entry(struct m_inode ** dir,
const char * name, int namelen, struct dir_entry ** res_dir)
{
...
if (!(bh = bread((*dir)->i_dev,block)))
...
de = (struct dir_entry *) bh->b_data;
...
while (i < entries) {
if ((char *)de >= BLOCK_SIZE+bh->b_data) {
brelse(bh);
bh = NULL;
if (!(block = bmap(*dir,i/DIR_ENTRIES_PER_BLOCK)) ||
!(bh = bread((*dir)->i_dev,block))) {
i += DIR_ENTRIES_PER_BLOCK;
continue;
}
de = (struct dir_entry *) bh->b_data;
}
if (match(namelen,name,de)) {
*res_dir = de;
return bh;
}
de++;
i++;
}
}
(4)最终找到了mnt这个目录项,值得注意的是,根目录文件在虚拟盘上,所以此时调用bread函数从设备上读取数据并不会产生中断。之后,find_entry函数就返回了,继续回到get_dir函数中执行。
4.1.3.4 获得虚拟盘到硬盘转换点—mnt目录文件的i节点
我们继续看4.1.3节起始部分的图4-6,得到了mnt目录项,就要从该目录项中解析出i节点号,如图4-8的右下脚所示:
执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
inr = de->inode;
...
}
通过4.1.3.2节中的介绍我们得知,现在inode表示根i节点,所以通过“idev = inode->i_dev”这行语句可获得根设备号,即虚拟盘的设备号。所以,接下来就要通过调用iget函数从虚拟盘中把mnt目录文件的i节点读取出来,执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
idev = inode->i_dev;
...
if (!(inode = iget(idev,inr)))
...
}
进入iget函数后,先要在系统的i节点管理表inode_table[32]中找到一个空闲项,以便存储将要读取的i节点。具体地,通过get_empty_inode函数在i节点管理表中找到一个i_count字段为0的空闲项,并在找到该空闲项后,把它对应的存储区域清0,这个动作的意义在于它实际上完成了内存中i节点管理结构的初始化。随后将该空闲项的引用计数置1,表示它已被使用。执行代码如下:
//代码路径:fs/inode.c:
struct m_inode * iget(int dev,int nr)
{
...
empty = get_empty_inode();
...
}
//代码路径:fs/inode.c:
struct m_inode * get_empty_inode(void)
{
...
do {
inode = NULL;
for (i = NR_INODE; i ; i--) {
if (++last_inode >= inode_table + NR_INODE)
last_inode = inode_table;
if (!last_inode->i_count) {
inode = last_inode;
if (!inode->i_dirt && !inode->i_lock)
break;
}
}
...
} while (inode->i_count);
memset(inode,0,sizeof(*inode));
inode->i_count = 1;
return inode;
}
然后,以“mnt”这个目录项中提供的i节点号为依托,遍历系统中的i节点管理表inode_table[32],看看mnt这个目录文件的i节点之前是不是已经存在于该管理表中了,如果存在,就不用再读取了。显然,我们在第3章中安装文件系统时,mnt这个目录文件的i节点被用于和硬盘进行挂接,这就意味着这个i节点早已被加载至i节点管理表inode_table[32]中了。如图4-8中的i节点表所示,图中用一个绿色竖线形象地表示了mnt目录文件i节点占用的位置。所以,用不着从设备上再读取一遍了,可以直接使用,执行代码如下:
//代码路径:fs/inode.c:
struct m_inode * iget(int dev,int nr)
{
...
if (inode->i_dev != dev || inode->i_num != nr)
...
}
到这里为止,我们以“mnt”这个目录文件名为例,把4.1.3节开始部分提到的解析技术路线“寻找i节点→通过i节点找到目录文件→通过目录文件找到目录项→通过目录项找到后续路径名的i节点号”的整个过程都介绍完了。
一般情况下,路径名中的每一个文件名都是以这个技术路线进行解析的,直到把最终指定的文件的i节点找到。查找方法都一样,通过这种查找方法,系统就可以根据用户提供的路径名查找到任意文件的i节点,并将结果返回。只要能得到i节点,就能得到对这个i节点所对应的文件的操作权。
4.1.3.5 完成从虚拟盘到硬盘的转换
但是,现在出现了特殊情况,我们先来回忆一下第3章安装硬盘文件系统的内容,最终硬盘超级块上的安装i节点与虚拟盘mnt目录文件的i节点相挂接,从而进程能够依托系统对硬盘上的文件进行访问。
如果mnt目录文件的i节点不是挂接点,那么4.1.3.4节最后在系统i节点管理表中得到这个i节点后,就会继续返回到get_dir函数中。通过这个i节点,继续查找mnt这个目录文件,然后再从这个目录文件中找到路径名中的下一个目录项,再找i节点号,再得到i节点……以此类推,不断重复地执行解析的技术路线,直到最终达到目的。
但现在的情况是,mnt目录文件的i节点是硬盘文件系统的“挂接点”,这就意味着,要到硬盘中去查找路径名中的下一个要找的文件名“user”,而非仍旧从虚拟盘中去查找。mnt目录文件的i节点是一个转折点,任何路径名,只要其中出现了“mnt”,就要转而去硬盘上解析后续的文件名。
当然,mnt目录文件本身还是存储在虚拟盘上的,但就因为它是挂接点,所以,原来在mnt目录下存储的所有目录项的内容,现在“全部作废”,即在安装于mnt目录下的硬盘文件系统卸载前,这些目录项的内容无法被用户获得。在安装了硬盘文件系统的前提下,mnt目录文件的主要功能就是作为访问硬盘数据的挂接点,至于mnt目录文件里面原来究竟有多少目录项,它们都是什么,与目前的路径名解析(即访问硬盘文件系统)一点关系都没有。直到将来把硬盘的文件系统卸载了,这些内容才能重新发挥作用,参与路径名的解析。
下面我们来看一下操作系统中处理这种特殊情况的代码:
//代码路径:fs/inode.c:
struct m_inode * iget(int dev,int nr)
{
...
if (inode->i_mount)
{
...
for (i = 0 ; i<NR_SUPER ; i++)
if (super_block[i].s_imount==inode)
break;
...
dev = super_block[i].s_dev;
nr = ROOT_INO;
inode = inode_table;
continue;
}
...
}
inode->i_mount这个标志为真,意味着mnt这个目录文件的i节点就是挂接点,既然是挂接点,就能在系统的超级块管理表super_block[8]中找到具体是哪个设备的超级块中的s_imount与这个挂接点相挂接,进而找到相应的超级块,现在这个超级块就是硬盘的超级块。这部分内容如图4-9所示:
在本章的开始部分我们介绍过,超级块是文件系统中处于顶端的管理结构,找到了超级块,就具备了与硬盘交互的能力,从iget函数中的以下两行代码(见加粗内容):
//代码路径:fs/inode.c:
struct m_inode * iget(int dev,int nr)
{
...
dev = super_block[i].s_dev;
nr = ROOT_INO;
}
...
}
我们可以看出,现在已经根据硬盘超级块中的信息得到了硬盘的设备号,同时利用根目录文件i节点定位识别的特性得到了硬盘根目录文件的i节点号,即ROOT_INO。系统接下来准备将硬盘的根目录文件i节点提取出来,然后再从这个i节点开始进一步解析路径名“/mnt/user/helloc.txt”中user以后的部分。
现在我们来看一下提取的过程,先要执行continue;这一行,表示接下来要按照“硬盘”的设备号和“硬盘根目录文件i节点”的i节点号继续在位于内核数据区的i节点表中查找硬盘根目录文件的i节点,执行代码如下:
//代码路径:fs/inode.c:
struct m_inode * iget(int dev,int nr)
{
...
while (inode < NR_INODE+inode_table){
if (inode->i_dev != dev || inode->i_num != nr) {
inode++;
continue;
}
...
}
...
}
由于从来没有载入过硬盘根目录文件的i节点,所以肯定找不到,最终就会跳出循环,并调用read_inode函数,准备从硬盘读出该i节点并载入到4.1.3.4节所介绍的“在i节点管理表inode_table[32]中获取的空闲项上”,执行代码如下:
//代码路径:fs/inode.c:
struct m_inode * iget(int dev,int nr)
{
...
inode=empty;
inode->i_dev = dev;
inode->i_num = nr;
read_inode(inode);
return inode;
}
4.1.3.6 获得硬盘根目录文件i节点
进入read_inode函数后,首先将申请到的这个空闲i节点表项加锁,以防止此时其他进程使用这一项;然后从硬盘上i节点的存储区域中把硬盘根目录文件的i节点读出来,并载入到位于内核数据区中的空闲i节点表项中;最后,将该i节点表项解锁。图4-10展示了将访问设备转换到硬盘并提取硬盘根目录文件的i节点的主要过程。
执行代码如下:
//代码路径:fs/inode.c:
static void read_inode(struct m_inode * inode)
{
...
lock_inode(inode);
...
block = 2 + sb->s_imap_blocks + sb->s_zmap_blocks +
(inode->i_num-1)/INODES_PER_BLOCK;
if (!(bh=bread(inode->i_dev,block)))
...
*(struct d_inode *)inode =
((struct d_inode *)bh->b_data)
[(inode->i_num-1)%INODES_PER_BLOCK];
...
unlock_inode(inode);
}
由于i节点是定位识别的,因此在得到硬盘设备号和硬盘根目录文件i节点号的基础上,就能够将硬盘根目录文件i节点所在的数据块从硬盘上读到高速缓冲区了。read_inode中的
block = 2 + sb->s_imap_blocks + sb->s_zmap_blocks
+(inode->i_num-1)/INODES_PER_BLOCK;
这行语句就是根据i节点号找到i节点在硬盘上所在块号的具体方法。
当包含硬盘根目录i节点的数据块从硬盘读入到高速缓冲区后,就以i节点号为线索,将硬盘根目录i节点的信息从缓冲块中提取出来并拷贝到刚找到的空闲i节点表项中,即 *(struct d_inode *)inode =((struct d_inode *)bh->b_data)[(inode->i_num-1)%INODES_PER_BLOCK]。
当然,此处拷贝到空闲i节点表项中的信息只是硬盘i节点的信息。而硬盘i节点中包含的字段比内存i节点要少,内存i节点中多出来的那部分字段的初始化工作正是在4.1.3.4节中找空闲i节点表项的过程中进行的。
之后read_inode函数就执行完了,iget函数也会将硬盘根目录文件的i节点返回,最终回到get_dir函数中继续执行,执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
while (1)
{
...
}
}
4.1.3.7 获得枝梢i节点—user目录文件的i节点
在这个while循环中,接下来将以硬盘根目录文件i节点为基准,继续解析“/mnt/user/helloc.txt”这个路径名,解析的技术路线仍然与解析mnt这个文件名的路线相同。我们在4.1.3.3节至4.1.3.5节中已经详细说明,这里我们只介绍一下解析的结果:最终将把user这个目录文件的i节点返回,user目录文件的i节点也就是枝梢i节点。图4-11形象地表示了从硬盘上把硬盘根目录文件的数据读入到缓冲块,在硬盘根目录文件中查找user目录项的过程。
这样,get_dir函数就执行完了,执行代码如下:
//代码路径:fs/namei.c:
static struct m_inode * get_dir(const char * pathname)
{
...
while (1)
{
if (!c)
return inode;
}
}
接下来将返回到dir_namei函数继续执行,对应代码如下:
//代码路径:fs/namei.c:
static struct m_inode * dir_namei(const char * pathname,
int * namelen, const char ** name)
{
...
basename = pathname;
while (c=get_fs_byte(pathname++))
if (c=='/')
basename=pathname;
*namelen = pathname-basename-1;
*name = basename;
return dir;
}
这段代码的意思是:计算出路径名“/mnt/user/helloc.txt”中“helloc.txt”字符串的长度,以此为最终获取该文件的i节点做准备。到此为止,dir_namei函数就执行完了,将返回到open_namei函数中去执行,现在已经获得了枝梢i节点,即user目录文件的i节点,这就为最终获得helloc.txt这个文件的i节点创造了条件。
4.1.3.8 找到helloc.txt文件的目录项
//代码路径:fs/namei.c:
int open_namei(const char * pathname, int flag, int mode,struct m_inode ** res_inode)
{
...
bh = find_entry(&dir,basename,namelen,&de);
...
}
其中de就承载着helloc.txt这个目录项。
4.1.3.9 找到helloc.txt文件的i节点
继续看图4-6,可知得到了helloc.txt这个目录项就可以从这个目录项中获取helloc.txt这个文件的i节点号,同时,当前设备就是硬盘,也能获取设备号,执行代码如下:
//代码路径:fs/namei.c:
int open_namei(const char * pathname, int flag, int mode,
struct m_inode ** res_inode)
{
...
inr = de->inode;
dev = dir->i_dev;
...
}
这样,就可以获取helloc.txt文件的i节点了,仍然是先把硬盘上存储helloc.txt文件的i节点的数据块读入到高速缓冲块,然后再从中把helloc.txt文件的i节点信息复制到在内存i节点表中找到的空闲项中。图4-12形象地表示了通过user目录文件的内容,获取到helloc.txt文件的目录项,再得到helloc.txt文件的i节点的整个过程。
执行代码如下:
//代码路径:fs/namei.c:
int open_namei(const char * pathname, int flag, int mode,
struct m_inode ** res_inode)
{
...
if (!(inode=iget(dev,inr)))
...
}
到这里为止,sys_open通过调用open_namei函数已经得到了helloc.txt文件的i节点,它的工作也就做完了,继续返回到sys_open函数中去执行。这标志着,“打开文件”的第二步,即以用户给定的路径名“/mnt/user/helloc.txt”为线索找到helloc.txt文件的i节点这项任务已经彻底完成。
4.1.4 将helloc.txt文件与文件管理表相挂接
执行代码如下:
//代码路径:fs/open.c:
int sys_open(const char * filename,int flag,int mode)
{
...
f->f_mode = inode->i_mode;
f->f_flags = flag;
f->f_count = 1;
f->f_inode = inode;
f->f_pos = 0;
return (fd);
}
这样,系统中的文件管理表file_table与helloc.txt文件就建立了关系,又因为在“打开文件”的第一步中当前进程与file_table也建立了关系,所以,当前进程就可以通过系统中的文件管理表file_table对helloc.txt文件进行操作了。本章后续小节所讲述的一切文件操作都是以这个关系的建立为基础的。这一步的完成标志着要实现实例1的最终目的所需的第一个要求(要确定读取哪个文件,即“打开文件”)达到了。