|
阅读:1847回复:9
Linux俱乐部成立喽!!!
我忍不住!
一定要有一个这样的组织,一定要。 要参加的续帖,并留下如下资料: 姓名(随便什么名也可,用于联系);QQ;级 系 专业;使用Linux的经历。 快快快,有志青年们!!! -------------------- 我是大汗,我怕谁! :-) |
|
|
1C#
发布于:2002-10-18 19:41
Re:Linux俱乐部成立喽!!!
第1章 Linux简介
让用户很详细地了解现有操作系统的实际工作方式是不可能的,因为大多数操作系统的源代码都是严格保密的。其例外是一些研究用的系统,另外一些是明确为操作系统教学而设计的系统。(还有一些系统则是同时出于这两种目的。)尽管研究和教学这两个目的都很好,但是这类系统很少能够通过对正式操作系统的小部分实现来体现操作系统的实际功能。对于操作系统的一些特殊问题,这种折衷系统所能够表现的就更是少得可怜了。 在以实际使用为目标的操作系统中,让任何人都可以自由获取系统源代码,无论目的是要了解、学习还是改进,这样的现实系统并不多。本书的主题就是这些少数操作系统中的一个:Linux。 Linux的工作方式类似于Uinx,是免费的,源代码也是开放的,符合标准规范的32位(在64位CPU上是64位)操作系统。Linux拥有现代操作系统的所具有的内容,例如:  真正的抢先式多任务处理,支持多用户  内存保护  虚拟内存  支持对称多处理机SMP(symmetric multiprocessing),即多个CPU机器,以及通常的单CPU(UP)机器  符合POSIX标准  联网  图形用户接口和桌面环境(实际上桌面环境并不只一个)  速度和稳定性 严格说来,Linux并不是一个完整的操作系统。当我们在安装通常所说的Linux时,我们实际安装的是很多工具的集合。这些工具协同工作以组成一个功能强大的实用系统。Linux本身只是这个操作系统的内核,是操作系统的心脏、灵魂、指挥中心。(整个系统应该称为GNU/Linux,其原因在本章的后续内容中将会给以介绍。)内核以独占的方式执行最底层任务,保证系统正常运行——协调多个并发进程,管理进程使用的内存,使它们相互之间不产生冲突,满足进程访问磁盘的请求等等。 在本书中,我们给大家揭示的就是Linux是如何完成这一具有挑战性的工作的。 Linux(和Unix)的简明历史 为了让大家对本书所讨论的内容有更清楚的了解,让我们先来简要回顾一下Linux的历史。由于Linux是在Unix的基础上发展而来的,我们的话题就从Unix开始。 Unix是由AT&T贝尔实验室的Ken Thompson和Dennis Ritchie于1969年在一台已经废弃了的PDP-7上开发的;它最初是一个用汇编语言写成的单用户操作系统。不久,Thompson和Ritchie成功地说服管理部门为他们购买更新的机器,以便该开发小组可以实现一个文本处理系统,Unix就在PDP-11上用C语言重新编写(发明C语言的部分目的就在于此)。它果真变成了一个文本处理系统——不久之后。只不过问题是他们先实现了一个操作系统而已… 最终,他们实现了该文本处理工具,而且Unix(以及Unix上运行的工具)也在AT&T得到广泛应用。在1973年,Thompson和Ritchie在一个操作系统会议上就这个系统发表了一篇论文,该论文引起了学术界对Unix系统的极大兴趣。 由于1956年反托拉斯法案的限制,AT&T不能涉足计算机业务,但允许它可以以象征性的费用发售该系统。就这样,Unix被广泛发布,首先是学术科研用户,后来又扩展到政府和商业用户。 伯克利(Berkeley)的加州大学是学术用户中的一个。在这里Unix得到了计算机系统研究小组(CSRG)的广泛应用。并且在这里所进行的修改引发了Unix的一大系列,这就是广为人知的伯克利软件开发(BSD)Unix。除了AT&T所提供的Unix系列之外,BSD是最有影响力的Unix系列。BSD在Unix中增加了很多显著特性,例如TCP/IP网络,更好的用户文件系统(UFS),工作控制,并且改进了AT&T的内存管理代码。 多年以来,BSD版本的Unix一直在学术环境中占据主导地位,但最终发展成为System V版本的AT&T的Unix则成为商业领域的主宰。从某种程度上来说,这是有社会原因的:学校倾向于使用非正式但通常更好用的BSD风格的Unix,而商业界则倾向于从AT&T获取Unix。 在用户需求驱动和用户编程改进特性的促进下,BSD风格的Unix一般要比AT&T的Unix更具有创新性,而且改进也更为迅速。但是,在AT&T发布最后一个正式版本System V Release 4(SVR4)时,System V Unix已经吸收了BSD的大多数重要的优点,并且还增加了一些自己的优势。这种现象的部分原因在于从1984年开始,AT&T逐渐可以将Unix商业化,而伯克利Unix的开发工作在1993年BSD4.4版本完成以后就逐渐收缩以至终止了。然而,BSD的进一步改进由外界开发者延续下来,到今天还在继续进行。正在进行的Unix系列开发中至少有四个独立的版本是直接起源于BSD4.4,这还不包括几个厂商的Unix版本,例如惠普的HP-UX,都是部分地或者全部地基于BSD而发展起来的。 实际上Unix的变种并不止BSD和System V。由于Unix主要使用C语言来编写,这就使得它相对比较容易地移植到新的机器上,它的简单性也使其相对比较容易重新设计与开发。Unix的这些特点大受商业界硬件供应商的欢迎,比如Sun、SGI、惠普、IBM、DEC(数字设备公司)、Amdahl等等;IBM还不止一次对Unix进行了再开发。厂商们设计开发出新的硬件并简单地将Unix移植到新的硬件上,这样新的硬件一经发布便具备一定的功能。经过一段时间之后,这些厂商都拥有了自己的专有Unix版本。而且为了占有市场,这些版本故意以不同的侧重点发布出来以更好的占有用户。 版本混乱的状态促进了标准化工作的进行。其中最主要的就是POSIX系列标准,它定义了一套标准的操作系统接口和工具。从理论上说,POSIX标准代码很容易移植到任何遵守POSIX标准的操作系统中,而且严格的POSIX测试已经把这种理论上的可移植性转化为现实。直到今天,几乎所有的正式操作系统都以支持POSIX标准为目标。 现在让我们回顾一下,在1984年,杰出的电脑黑客Richard Stallman独立开发出一个类Unix的操作系统,该操作系统具有完全的内核、开发工具和终端用户应用程序。在GNU(“GNU’s Not Unix”首字母的缩写)计划的配合下,Stallman开发这个产品有自己的技术理想:他想开发出一个质量高而且自由的操作系统。Stallman使用了“自由”(free)这个词,不仅意味着用户可以免费的获取软件;而且更重要的是,它将意味着某种程度的“解放”:用户可以自由使用、拷贝、查询、重用、修改甚至是分发这份软件,完全没有软件使用协议的限制。这也正是Stallman创建自由软件基金会(FSF)资助GNU软件开发的本意(FSF也在资助其它科研方面的开发工作)。 15年以来,GNU工程已经吸收、产生了大量的程序,这不仅包括Emacs,gcc(GNU的C编译器),bash(shell命令),还有大部分Linux用户所熟知的许多应用程序。现在正在进行开发的项目是GNU Hurd内核,这是GNU操作系统的最后一个主要部件(实际上Hurd内核早已能够使用了,不过当前的版本号为0.3的系统在什么时候能够完成,还是未知数)。 尽管Linux大受欢迎,但是Hurd内核还在继续开发。这种情况的原因有几个方面,其一是Hurd的体系结构十分清晰的体现了Stallman关于操作系统工作方式的思想,例如,在运行期间,任何用户都可以部分的改变或替换Hurd(这种替换不是对每个用户都是可见的,而是只对申请修改的用户可见,而且还必须符合安全规范)。另一个原因是据介绍Hurd对于多处理器的支持比Linux本身的内核要好。还有一个简单的原因是兴趣的驱动,因为程序员们希望能够自由地进行自己所喜欢的工作。只要有人希望为Hurd工作,Hurd的开发就不会停止。如果他们能够如愿以偿,Hurd有朝一日将成为Linux的强劲对手。不过在今天,Linux还是自由内核王国里无可争议的主宰。 在GNU发展的中期,也就是1991年,一个名叫Linus Torvalds的芬兰大学生想要了解Intel的新CPU——80386。他认为比较好的学习方法是自己编写一个操作系统的内核。出于这种目的,加上他对当时Unix变种版本对于80386类机器的脆弱支持十分不满,他决定要开发出一个全功能的、支持POSIX标准的、类Unix的操作系统内核,该系统吸收了BSD和System V的优点,同时摒弃了它们的缺点。Linus(虽然我知道我应该称他为Torvalds,但是所有人都称他为Linus)独立把这个内核开发到0.02版,这个版本已经可以运行gcc,bash和很少的一些应用程序。这些就是他开始的全部工作了。后来,他又开始在因特网络上寻求广泛的帮助。 不到三年,Linus的Unix—Linux—已经升级到1.0版本。它的源代码量也呈指数形式增长,实现了基本的TCP/IP功能(网络部分的代码后来重写过,而且还可能会再次重写)。此时Linux就已经拥有大约10万用户了。 现在的Linux内核由150多万行代码组成,Linux也已经拥有了大约1000万用户(由于Linux可以自由获取和拷贝,获取具体的统计数字是不可能的)。Linux内核GNU/Linux附同GNU工具已经占据了Unix 50%的市场。一些公司正在把内核和一些应用程序同安装软件打包在一起,生产出Linux的distribution(发行版本),这些公司包括Red Hat和Calera prominent公司。现在的GNU/Linux已经备受注目,得到了诸如Sun、IBM、SGI等公司的广泛支持。SGI最近决定在其基于Intel的Merced的系列机器上不再搭载自己的Unix变种版本IRIX,而是直接采用GNU/Linux;Linux甚至被指定为Amiga将要发布的新操作系统的基础。 GNU通用公共许可证 这样一个如此流行大受欢迎的操作系统当然值得我们学习。按照通用公共许可证(GPL,(General Public License))的规定,Linux的源代码可以自由获取,这使得我们学习该系统的强烈愿望得以实现。GPL这份非同寻常的软件许可证,充分体现了上面提到的Stallman的思想:只要用户所做的修改是同等自由的,用户可以自由地使用、拷贝、查询、重用、修改甚至重新发布这个软件。通过这种方式,GPL保证了Linux(以及同一许可证保证下的大量其它软件)不仅现在自由可用,而且以后经过任何修改之后都仍然可以自由使用。 请注意这里的自由并不是说没有人靠这个软件盈利,有一些日益兴起的公司,比如发行最流行的Linux发行版本的Red Hat,就是一个例子。(Red Hat自从面世以来,市值已经突破数十亿美元,每年盈利数十万美元,而且这些数字还在不断增长)。但是任何人都不能限制其它用户涉足本软件领域,而且所作的修改不能减少其自由程度。 本书的附录B中收录有GNU通用公共许可证协议的全文。 Linux开发过程 如上所述,由于Linux是一款自由软件,它可以免费获取以供学习研究。Linux之所以值得学习研究,是因为它是相当优秀的操作系统。如果Linux操作系统相当糟糕,那它就根本不值得被我们使用,也就没有必要去研究相关的书籍。(除非一种可能,为了追求刺激)。Linux是一款十分优秀的操作系统还在于几个相互关联的原因。 Linux优秀的原因之一在于它是基于天才的思想开发而成的。在学生时代就开始推动整个系统开发的Linus Torvads是一个天才,他的才能不仅展现在编程能力方面,而且组织技巧也相当杰出。Linux的内核是由世界上一些最优秀的程序员开发并不断完善的,他们通过Internet相互协作,开发理想的操作系统;他们享受着工作中的乐趣,而且也获得了充分的自豪感。 Linux优秀的另外一个原因在于它是基于一组优秀的概念。Unix是一个简单却非常优秀的模型。在Linux创建之前,Unix已经有20年的发展历史。Linux从Unix的各个流派中不断吸取成功经验,模仿Unix的优点,抛弃Unix的缺点。这样做的结果是Linux 成为了Unix系列中的佼佼者:高速、健壮、完整,而且抛弃了历史包袱。 然而,Linux最强大的生命力还在于其公开的开发过程。每个人都可以自由获取内核源程序,每个人都可以对源程序加以修改,而后他人也可以自由获取你修改后的源程序。如果你发现了缺陷(bug),你可以对它进行修正,而不用去乞求不知名的公司来为你修正。如果你有什么最优化或者新特点的创意,你也可以直接在系统中增加功能,而不用向操作系统供应商解释你的想法,指望他们将来会增加相应的功能。当发现一个安全漏洞后,你可以通过编程来弥补这个漏洞,而不用关闭系统直到你的供应商为你提供修补程序。由于你拥有直接访问源代码的能力,你也可以直接阅读代码来寻找缺陷,或是效率不高的代码,或是安全漏洞,以防患于未然。 除非你是一个程序员,否则这一点听起来仿佛没有多少吸引力。实际上即使你不是程序员,这种开发模型也将使你受益匪浅,这主要体现在以下两个方面:  可以间接受益于世界各地成千上万的程序员随时进行的改进工作。  如果你需要对系统进行修改,你可以雇用程序员为你完成工作。这部分人将根据你的需求定义单独为你服务。可以设想,这在源程序不公开的操作系统中它将是什么样子。 Linux这种独特的自由流畅的开发模型已被命名为bazaar(集市模型),它是相对于cathedral(教堂)模型而言的。在cathedral模型中,源程序代码被锁定在一个保密的小范围内。只有开发者(很多情况下是市场)认为能够发行一个新版本,这个新版本才会被推向市场。这些术语在Eric S. Raymond的The Cathedral and the Bazaar一文中有所介绍,大家可以在http://www.tuxedo.org/~esr/writings/找到这篇文章。Bazaar开发模型通过重视实验,征集并充分利用早期的反馈,对巨大数量的脑力资源进行平衡配置,可以开发出更优秀的软件。(顺便说一下,虽然Linux是最为明显的使用bazaar开发模型的例子,但是它却远不是第一个使用这个模型的系统。) 为了确保这些无序的开发过程能够有序地进行,Linux采用了双树系统。一个树是稳定树(stable tree),另一个树是非稳定树(unstable tree)或者开发树(development tree)。一些新特性、实验性改进等都将首先在开发树中进行。如果在开发树中所做的改进也可以应用于稳定树,那么在开发树中经过测试以后,在稳定树中将进行相同的改进。按照Linus的观点,一旦开发树经过了足够的发展,开发树就会成为新的稳定树,如此周而复始的进行下去。 源程序版本号的形式为x.y.z。对于稳定树来说,y是偶数;对于开发树来说,y比相应的稳定树大一(因此,是奇数)。截至到本书截稿时,最新的稳定内核版本号是2.2.10,最新的开发内核的版本号是2.3.12。对2.3树的缺陷修正会回溯影响(back-propagated)2.2树,而当2.3树足够成熟的时候会发展成为2.4.0。(顺便说一下,这种开发会比常规惯例要快,因为每一版本所包含的改变比以前更少了,内核开发人员只需花很短的时间就能够完成一个实验开发周期。) http://www.kernel.org及其镜像站点提供了最新的可供下载的内核版本,而且同时包括稳定和开发版本。如果你愿意的话,不需要很长时间,这些站点所提供的最新版本中就可能包含了你的一部分源程序代码。 |
|
|
|
2C#
发布于:2002-10-18 19:42
Re:Linux俱乐部成立喽!!!
第2章 代码初识
本章首先从较高层次介绍Linux内核源程序的概况,这些都是大家关心的一些基本特点。随后将简要介绍一些实际代码。最后以如何编译内核来检验个人所进行的修改的讨论来作为本章的收尾。 Linux内核源程序的部分特点 在过去的一段时期,Linux内核同时使用C语言和汇编语言实现的。这两种语言需要一定的平衡:C语言编写的代码移植性较好、易于维护,而汇编语言编写的程序则速度较快。一般只有在速度是关键因素或者一些因平台相关特性而产生的特殊要求(例如直接和内存管理硬件进行通讯)时才使用汇编语言。 正如同实际中所做的,即使内核并未使用C++的对象特性,部分内核也可以在g++(GNU的C++编译器)下进行编译。同其它面向对象的编程语言相比较,相对而言C++的开销是较低的,但是对于内核开发人员来说,这已经足够甚至太多了。 内核开发人员不断发展编程风格,形成了Linux代码独有的特色。本节将讨论其中的一些问题。 gcc特性的使用 Linux内核被设计为必须使用GNU的C编译器gcc来编译,而不是任何一种C编译器都可以使用。内核代码有时要使用gcc特性,伴随着本书的进程,我们将陆续介绍其中的一部分。 一些gcc特有代码只是简单地使用gcc语言扩展,例如允许在C(不只是C++)中使用inline关键字指示内联函数。也就是说,代码中被调用的函数在每次函数调用时都会被扩充,因而就可以节约实际函数调用的开销。 更为普遍的情况是代码的编写方式比较复杂。因为对于某些类型的输入,gcc能够产生比其它输入效率更高的执行代码。从理论上讲,编译器可以优化具有相同功能的两种对等的方法,并且得到相同的结果。因此,代码的编写方式是无关紧要的。但在实际上,用一些方法编写所产生的代码要比用其它方法编写所产生的代码的执行速度快得多。内核开发人员清楚如何才能产生更高效的执行代码的方法,而且这种知识也不断在他们编写的代码中反映出来。 例如,考虑内核中经常使用的goto语句——为了提高速度,内核中经常大量使用这种一般要避免使用的语句。在本书中所包含的不到40,000行代码中,一共有500多条goto语句,大约是每80行一个。除汇编文件外,精确的统计数字是接近每72行一个goto语句。公平的说,这是选择偏向的结果:比例如此高的原因之一是本书中涉及的是内核源程序的核心,在这里速度比其它因素都需要优先考虑。整个内核的比例大概是每260行一个goto语句。然而,这仍然是我不再使用Basic进行编程以来见过的使用goto频率最高的地方。 代码必需受特定编译器限制的特性不仅与普通应用程序的开发有很大不同,而且也不同于大多数内核的开发。大多数的开发人员使用C语言编写代码来保持较高的可移植性,即使在编写操作系统时也是如此。这样做的优点是显而易见的,最为重要的一点是一旦出现更好的编译器,程序员们可以随时进行更换。 内核对于gcc特性的完全依赖使得内核向新的编译器上的移植工作更加困难。最近Linus对这一问题在有关内核的邮件列表上表明了自己的观点。“记住,编译器只是一个工具。”这是对依赖于gcc特性的一个很好的基本思想的表述:编译器只是为了完成工作。如果通过遵守标准还不能达到工作要求,那么就不是工作要求有问题,而是对于标准的依赖有问题。 在大多数情况下,这种观点是不能够被人所接受的。通常情况下,为了保证和程序语言标准的一致,开发人员可能需要牺牲某些特性、速度或者其它相关因素。其它的选择可能会为后期开发造成很大的麻烦。 但是,在这种特定的情况下,Linus是正确的。Linux内核是一个特例,因为其执行速度要比向其它编译器的可移植性远为重要。如果设计目标是编写一个可移植性好而不要求快速运行的内核,或者是编写一个任何人都可以使用自己喜欢的编译器进行编译的内核,那么结论就可能会有所不同了;而这些恰好不是Linux的设计目标。实际上,gcc几乎可以为所有能够运行Linux的CPU生成代码,因此,对于gcc的依赖并不是可移植性的严重障碍。 在第3章中我们将对内核设计目标进行详细说明。 内核代码习惯用语 内核代码中使用了一些显著的习惯用语,本节将介绍常用的几个。当你通读源程序代码时,真正重要的问题是并不在这些习惯用语本身,而是这种类型的习惯用语的确存在,而且是不断被使用和发展的。如果你需要编写内核代码,你应该注意到内核中所使用的习惯用语,并把这些习惯用语应用到你的代码中。当通读本书(或者代码)时,注意你还能找到多少习惯用语。 为了讨论这些习惯用语,我们首先需要对它们进行命名。为了便于讨论,笔者创造了这些名字。而在实际中,大家不一定非要参考这些用语,它们只是对内核工作方式的描述而已。 一个普通的习惯用语笔者称之为“资源获取”(resource acquisition idiom)。在这个用语中,一个函数必须实现一系列资源的获取,包括内存、锁等等(这些资源的类型未必相同)。只有成功地获取当前所需要的资源之后,才能处理后面的资源请求。最后,该函数还必须释放所有已经获取的资源,而不必对没有获取的资源进行考虑。 我采用“错误变量”这一用语(error variable idiom)来辅助说明资源获取用语,它使用一个临时变量来记录函数的期望返回值。当然,相当多的函数都能实现这个功能。但是错误变量的不同点在于它通常是用来处理由于速度的因素而变得非常复杂的流程控制中的问题。错误变量有两个典型的值,0(表示成功)和负数(表示有错)。 这两个用语结合使用,我们就可以十分自然地得到符合模式的代码如下: Int f (void) { int err; resource *r1, *r2; err = -ERR1 /*assume failure*/ r1=acquire_ resource(); if (!r1) /*not aquired*/ goto out /*returns -ERR1*/ Got resource r1,try for r2.*/ err = - ERR2; r2 = acquire_ resource2(); if (!r2) /*not aquired*/ goto out1 /*returns –ERR2*/ /*have both r1 and r2.*/ err = 0; /* … use r1 and r2 … */ out2: release_resource(r2) out2: release_resource(r2) out: return err; } (注意变量err是使用错误变量的一个明确实例,同样,诸如out之类的标号则指明了资源获取用语的使用。) 如果执行到标号out2,则都已经获取了r1和r2资源,而且也都需要进行释放。如果执行到标号out1(不管是顺序执行还是使用goto语句进行跳转到),则r2资源是无效的(也可能刚被释放),但是r1资源却是有效的,而且必需在此将其释放。同理,如果标号out能被执行,则r1和r2资源都无效,err所返回的是错误或成功标志。 在这个简单的例子中,对于err的一些赋值是没有必要的。在实践中,实际代码必须遵守这种模式。这样做的原因主要在于同一行中可能包含有多种测试,而这些测试应该返回相同的错误代码,因此对错误变量统一赋值要比多次赋值更为简单。虽然在这个例子中对于这种属性的必要性并不非常迫切,但是我还是倾向于保留这种特点。有关的实际应用可以参考sys_shmctl(第21654行),在第9章中还将详细介绍这个例子。 减少#if和#ifdef的使用 现在的Linux内核已经移植到不同的平台上,但是我们还必须解决移植过程中所出现的问题。大部分支持各种不同平台的代码由于包含许多预处理代码现都已变得非常不规范,例如: #if defined(SOLARIS) /* … do things the solaris way … */ #elif defined(HPUX) /* … do things the HP-UX way … */ #elif defined(LINUX) /* … do things the right way … */ #endif 这个例子试图实现操作系统的可移植性,虽然Linux关注的焦点很明显是实现代码在各种CPU上的可移植性,但是二者的基本原理是一致的。对于这类问题来说,预处理器是一种错误的解决方式。这些杂乱的问题使得代码晦涩难懂。更为糟糕的是,增加对新平台的支持有可能要求重新遍历这些杂乱分布的低质量代码段(实际上你很难能找到这类代码段的全部)。 与现有方式不同的是,Linux一般通过简单函数(或者是宏)调用来抽象出不同平台间的差异。内核的移植可以通过实现适合于相应平台的函数(或宏)来实现。这样不仅使代码的主体简单易懂,而且在移植的过程中还可以比较容易地自动检测出你没有注意到的内容:如引用未声明函数时会出现链接错误。有时用预处理器来支持不同的体系结构,但这种方式并不常用,而相对于代码风格的变化就更是微不足道了。 顺便说一下,我们可以注意到这种解决方法和使用用户对象(或者C语言中充满函数指针的struct结构)来代替离散的switch语句处理不同类型的方法十分相似。在某些层次上,这些问题和解决方法是统一的。 可移植性的问题并不仅限于平台和CPU的移植,编译器也是一个重要的问题。此处为了简化,假设Linux只使用gcc来编译。由于Linux只使用同一个编译器,所以就没有必要使用#if块(或者#ifdef块)来选择不同的编译器。 内核代码主要使用#ifdef来区分需要编译或不需要编译的部分,从而对不同的结构提供支持。例如,代码经常测试SMP宏是否定义过,从而决定是否支持SMP机。 代码样例 上一节仅仅是一些讨论,了解Linux代码风格最好的方法就是实际研究一下它的部分代码。即使你不完全理解本节所讨论代码的细节也无关紧要,毕竟本节的主要目的不是理解代码,一些读者可以只对本节进行浏览。本节的主要目的是让读者对Linux代码进行初步了解,对今后的工作提供必要基础。而讨论将涉及部分广泛使用到的内核代码。 printk printk(25836行)是内核内部消息日志记录函数。在出现诸如内核检测到其数据结构出现不一致的事件时,内核会使用printk把相关信息打印到系统控制台上。对于printk的调用一般分为如下几类:  紧急事件(emergency)――例如,panic函数(25563行)多次使用了printk。当内核检测到发生不可恢复的内部错误时就会调用panic函数,然后尽其所能的安全关闭计算机。这个函数中调用printk以提示用户系统将要关闭。  调试――从3816行开始的#ifdef块使用printk来打印SMP逻辑单元(box)中每一个处理器的相关配置信息,但是此过程只有在使用SMP_DEBUG标志编译代码的情况下才能够被执行。  普通信息――例如,当机器启动时,内核必需估计系统速度以确保设备驱动程序能够忙等待(busy-waiting)一个精确的极短周期。计算这种估计值的函数名为calibrate_delay(19654行),它既在19661行使用printk声明马上开始计算,又在19693行报告计算结果。另外,在第4章将详细的介绍calibrate_delay函数。 如果你已经浏览过这些参照代码,你可能已经注意到printk和printf的参数十分类似:一个格式化字符串,后跟零个或者多个参数加入字符串中。格式化字符串可能是以一组“<N>”开始,这里的N是从0到7的数字,包括0和7在内。数字区分了消息的日志等级(log level),只有当日志等级高于当前控制台定义的日志等级(console_loglevel,25650行)时,才会打印消息。root可以通过适当减小控制台的日志等级来过滤不是很紧急的消息。如果内核在格式化字符串中检测不到日志等级序列,那么就会一直打印消息。(实际上,日志等级序列并不一定要在格式化字符串中出现,可以在格式化文本中查找到它的代码。) 从14946行开始的#define块说明了这些特殊序列,这些定义可以帮助调用者正确区分对printk的调用。简单的说,我称日志等级0到4为“紧急事件”,从等级5到等级6为“普通信息”,等级7自然就是我所说的“调试”。(这种分类方法并不意味着其它更好的分类方法没有用处,而只是目前我们还不关心它而已。) 在上面讨论的基础上,我们研究一下代码本身。 printk 25836:参数fmt是printf类型的格式化字符串。如果你对“…”部分的内容不熟悉,那就 需要参阅一本好的C语言参考书(在其索引中查找“变参函数,variadic function”)。另外,在安装的GNU/Linux中的stdarg帮助里也包含了一个有关变参函数的简明描述,在这儿只需要敲入“man stdarg”就可以看到。 简单的说,“…”部分提示编译器fmt后面可能紧跟着数量不定的任何类型的参数。由于这些参数在编译的时候还没有类型和名字,内核使用由三个宏va_start,va_arg和va_end组成的特殊组以及一个特殊类型――va_list对它们进行处理。 25842:msg_level记录了当前消息的日志等级。它是静态的,这看起来可能会有些奇怪――为什么下一次对printk的调用需要记录日志等级呢?问题的答案是只有打印出新行(\n)或者赋给一个新的日志等级序列以后,当前消息才会结束。这样通过在包含消息结束的新行里调用printk,就保证了在多个短期冲突的情况下,调用者只打印唯一一个长消息。 25845:在SMP逻辑单元中,内核可能试图从不同的CPU向控制台同时打印信息。(有时在单处理机(UP)逻辑单元中也会发生同样问题,但由于中断还未被覆盖掉,所以问题也并不十分明显。)如果不进行任何协同的话,结果就将处于完全无法让人了解的杂乱无章的状态,每个消息的各个部分都和其它消息的各个部分混杂交织在一起。 相反,内核使用旋转锁(spin-lock)来控制对控制台的访问。旋转锁将在第10章对它进行深入的介绍。 如果你对flags 在传送给spin_lock_irqsave之前为什么不对它初始化感到疑惑,请不要担心:spin_lock_irqsave(对于不同的版本请分别参看12614行,12637行,12716行,和12837行)是一个宏,而不是一个函数。该宏实际上是将值写入flags中,而不是从flags中读出值。(在25895行中,存储在flags中的信息被spin_lock_irqsave回读,请参看12616行,12639行,12728行和12841行) 25846:初始化变量args,该变量代表printk参数中的“…”部分。 25848:调用内核自身的vsprintf(为节省空间而省略)实现。该函数的功能与标准vsprintf函数非常相似,向buf中写入格式化文本(25634行)并返回写入字符串的长度(长度不包括最后一位终止字符0字节)。很快,你将可以看到为什么这种机制会忽略buf的前三个字符。 (正如25847行的注释中所述)我们应该注意到在这里并没有采取严格的措施来保证缓冲器不会过载。这里系统假定1024个字符长度的buf已经足够使用(参阅25634行)。如果内核在这里能够使用vsnprintf函数的话,情况就会好许多。然而,vsnprintf还有另外一个参数限制了它能够写入缓冲器的字符长度。 25849:计算buf中最近使用的元素,调用va_end终止对“…”参数的处理。 25851:开始格式化消息的循环。其中存在一个内部循环能够处理更多内容(这一点随后就能看到),因此,每次内循环开始,都开始一个新的打印行。由于通常情况下printk只用于打印单行,所以在每次调用中这种循环通常只执行一次。 25853:如果预先不知道消息的日志等级,printk会检查当前行是否以日志等级序列开头。 25860:如果不是,buf中开始未使用的三个字符就能够起作用了。(第一次以后的每次循环,都会覆盖部分消息文本,但是这样并不会引起问题,因为这里的文本只是前面行中的一部分,它们已经被打印过,而且以后也不再需要了。)这样,就可以将日志等级插入buf中。 25866:此处有如下属性:p指向日志等级序列(消息文本紧随其后),msg指向消息文本——请注意25852行和25865行中对msg的赋值。 由于已知p用来指示日志等级序列的开头――该日志等级序列可能是由函数自身所创建的――日志等级可以从p中抽出并存到msg_level中。 25868:没有检测到新行,清空line_feed标志。 25869:这是前面谈到过的内循环,循环将运行到本行结束(也就是检测到新行标志)或者缓冲器的末尾为止。 25870:除了将消息打印到控制台之外,printk还能够记录最近打印的长度为LOG_BUF_LEN的字符组。(LOG_BUF_LEN为16K,请参看25632行。)如果在控制台打开之前,内核就已经调用printk,则显然不能在控制台上正确打印消息,但是这些消息将被尽可能的存储到log_buf中(25656行)。当控制台打开以后,缓存在log_buf中的数据就可以转储并在控制台上打印出来,请参看25988行。 log_buf是一个循环缓冲器,log_start和log_size变量(25657行和25646行)分别记录当前缓冲器的开始位置和长度。本行中的按位与(AND)操作实际上是快速求模(%)运算,它的正确性依赖于LOG_BUF_LEN的值是2的幂。 25872:保存变量跟踪记录循环日志的值。显然,日志大小会不断增长,直至达到LOG_BUF_LEN的值为止。此后,log_size将保持不变,而插入新字符将导致log_start的增长。 25878:请注意logged_chars(25658行)记录从机器启动之后printk写入的所有字符的长度,它在每次循环中都会被更新,而不是在循环结束后才改变一次。基于同样的道理,log_start和log_size的处理方式也是一样。这实际上是一种优化的时机,但是我们将在结束对函数的介绍之后再对它详细讨论。 25879:消息被分为若干行,这当然要使用新行标志符来进行分割。一旦内核检测到新行标志符,就写入一个完整行,从而内循环的执行也可以提前终止。 25884:在这里我们先不考虑内部循环是否会提前退出,从msg到p的字符序列是专门提供给控制台使用的。(这种字符序列我称之为行,但是不要忘了,这里的行可能并不意味着新行终止,因为buf也许还没有终止。)如果该行的日志等级高于系统控制台定义的日志等级,而且当前又有控制台可供打印,那么就能够正确打印该行。(记住,printk可能在所有控制台打开之前就已经被调用过了。) 如果在该信息块中没有发现日志等级序列,并且在前面的printk调用中也没有对msg_level赋值,那么本行中的msg_level就是-1。由于console_leglevel总不小于1(除非root通过sysctl接口锁定),于是总是可以打印这些行。 25886:本行应该能够被打印。printk通过遍历打开的控制台驱动链表告知每一个控制台驱动去打印当前行。(因为虽然设备驱动在本书的讨论范围之外,但是控制台驱动代码则并不包含在内。) 25888:请注意这里消息文本的开头使用的是msg而不是p,这样就在没有日志等级序列的情况下写入消息了。然而,日志等级序列已经被存储到log_buf缓冲器中了。这样就可以使后来访问log_buf以获取信息日志等级的代码(请参看25998行)能够正确执行,不会再产生显示混乱信息序列的现象。 25892:如果内层for循环发现一新行,那么buf中的剩余字符(如果有的话)将被认为是新的消息,因此msg_level会被重置。但是无论怎样,外层循环都会持续到buf清空为止。 25895:释放在25845行获取的控制台锁(console lock)。 25896:唤醒等待被写入控制台日志的所有进程。注意即使没有文本被实际写入任何控制台,这个过程也仍然会发生。这样处理是正确的,因为无论是否要往控制台中写入文本,等待进程实际上都是在等待从log_buf中读出信息。在25748行,进程被转入休眠状态以等待log_buf的活动。在休眠、唤醒和等待队列中所使用的机制将在下一节中进行讨论。 25897:返回日志中写入的字符长度。 如果对于每个字符的处理工作都能减少一点,那么从25869行开始的for循环就能执行得更快一点。当循环存在时,我们可以通过只在循环退出时将logged_chars更新一次来稍微提高运行速度。然而我们还可以通过其它努力来提高速度。由于我们可以预知消息的长度,因此log_size和log_start可以到最后再增长。让我们来实验一下这样能否提高速度,下面是一段经过理想优化的代码: do { static int wrapped = 0; const int x = wrapped ? log_start : log_size; const int lim = LOG_BUF_LEN – x; int n = buf_end – p; if ( n>= lim) n = lim; memcpy(log_buf + x, p, n); p += n; if(log_size < LOG_BUF_LEN) log_size += n; else { wrapped = 1; log_start += n; ;og_start &= LOG_BUF_LEN – 1; } } while (p < buf_end); 请注意循环通常只需要执行一次,只有在log_buf末尾写入信息需要折行时才会多次执行。因而log_size和log_buf只需要更新一次(或者当写入需要换行时是两次)。 这时速度的确提高了,但是有两个原因使我们并不能这样做。首先,内核可能有自己特有的memcpy函数,我们必须确保对memcpy的调用不会再次进入对printk的调用。(有一部分内核移植版定义了自己特有的速度较快的memcpy函数版本,因此所有的移植都要在这一点上保持一致。)如果memecpy调用printk来报告失败,那么就有可能触发无限循环。 然而在这一点上也并不是真的无药可救。使用这种解决方案的最大问题在于该内核循环的形式中也要留意新行标志符,因此使用memcpy将整个消息拷贝到log_buf中是不正确的:如果此处存在新行,我们将无法对其进行处理。 我们可以试验一个一箭双雕的办法。下面这种替代的尝试虽然可能比前面那种初步解决方法速度要慢,但是它保持了内核版本的语意: /* in declaration section:*/ int n; char *start; static char *log = log_buf; /*……*/ for (start = p;p < buf_end;p++) { *log++ = *p; if (log >= (log_buf + LOG_BUF_LEN)) log = log_buf ; /* warp*/ if (*p == ‘/n’) { line_feed = 1; break; } } /* p - start is number of chars copied. */ n = p – start; logged_chars += n ; /* *exercise for the reader: *also use n to update log size and log_ start. *(it’s not as simple as may look.) */ (请注意gcc的优化器十分灵敏,它足以能检测到循环内部的表达式log_buf+LOG_BUF_LEN并没有改变,因此在上面的循环中试图手工加速计算是没有任何效果的。) 不幸的是,这种方法并不能比现在内核版本在速度上快许多,而且那样会使得代码晦涩难懂(如果你编写过更新log_size和log_start的代码,你就能清楚地了解这一点)。你可以自己决定这种折衷是否值得。然而无论怎样,我们学到了一些东西,这是通常的成果:不管成功与否,改进内核代码都可以加深你对内核工作原理的理解。 等待队列 前一节我们曾简要的提到进程(也就是正在运行的程序)可以转入休眠状态以等待某个特定事件,当该事件发生时这些进程能够被再次唤醒。内核实现这一功能的技术要点是把等待队列(wait queue)和每一个事件联系起来。需要等待事件的进程在转入休眠状态后插入到队列中。当事件发生之后,内核遍历相应队列,唤醒休眠的任务让它投入运行状态。任务负责将自己从等待队列中清除。 等待队列的功能强大得令人吃惊,它们被广泛应用于整个内核中。更重要的是,实现等待队列的代码量并不大。 wait_queue结构 18662:简单的数据结构就是等待队列节点,它包含两个元素:  tast――指向struct task_struct结构的指针,它代表一个进程。从16325行开始的struct task_struct结构将在第7章中进行介绍。  next――指向队列中下一节点的指针。因而,等待队列实际上是一个单链表。 通常,我们用指向等待队列队首的指针来表示等待队列。作为一个例子,请参看printk使用的等待队列log_wait(25647行)。 wait_event 16840:通过使用这个宏,内核代码能够使当前执行的进程在等待队列wq中等待直至给定condition(可能是任何的表达式)得到满足。 16842:如果条件已经为真,当前进程显然也就无需等待了。 16844:否则,进程必须等待给定条件转变为真。这可以通过调用__wait_event来实现(16824行),我们将在下一节介绍它。由于 __wait_event已经同wait_event分离,已知条件为假的部分内核代码可以直接调用__wait_queue,而不用通过宏来进行冗余的(特别是在这些情况下)测试,实际上也没有代码会真正这样处理。更为重要的是,如果条件已经为真,wait_event会跳过将进程插入等待队列的代码。 注意wait_event的主体是用一个比较特殊的结构封闭起来的: do { /* … */ } while (0) 使我惊奇的是,这个小技巧并没有得到应有的重视。这里的主要思路是使被封闭的代码能够像一个单句一样使用。考虑下面这个宏,该宏的目的是如果p是一个非空指针,则调用free: #define FREE1(p) if (p) free (p) 除非你在如下所述的情况下使用FREE1,否则所有调用都是正确有效的: if (expression) FREE1(p) else printf(“expression was false.\n”) ; FREE1经扩展以后,else就和错误的if――FREE1的if――联系在一起。 我曾经发现有些程序员通过如下途径解决这种问题: #define FREE2(p) if (p) { free(p); } #define FREE3(p) { if (p) { free(p); } } 这两种方法都不尽人意,程序员在调用宏以后自然而然使用的分号会把扩展信息弄乱。以FREE2为例,在宏展开之后,为了使编译器能更准确的识别,我们还需要进行一定的缩进调节,最终代码如下所示: if (expression) if (p) { free(p);} else printf(“expression was false./n”); 这样就会引起语法错误――else和任何一个if都不匹配。FREE3从本质上讲也存在同样的问题。而且在研究问题产生原因的同时,你也能够明白为什么宏体里是否包含if是无关紧要的。不管宏体内部内容如何,只要你使用一组括号来指定宏体,你就会碰到相同的问题。 这里是我们能够引入do/while(0)技巧的地方。现在我们可以编写FREE4,它能够克服前面所出现的所有问题。 #define FREE4(P) do { if (p) free(p); \ while (0) 将FREE4和其它宏一样插入相同代码之后,宏展开后其代码如下所示(为清晰起见,我们再次调整了缩进格式): if (expression) do { if (p) free(p); } while (0); /* “;” following macro.*/ 这段代码当然可以正确执行。编译器能够优化这个伪循环,舍弃循环控制,因此执行代码并没有速度的损失,我们也从而得到了能够实现理想功能的宏。 虽然这是一个可以接受的解决方案,但是我们不能不提到的是编写函数要比编写宏好得多。不过如果你不能提供函数调用所需的开销,那么就需要使用内联函数。这种情况虽然在内核中经常出现,但是在其它地方就要少得多。(无可否认,当使用C++,gcc或者任何实现了将要出现的修正版ISO标准C的编译器时,这种方案只是一种选择,就是最后为C增加内联函数。) __wait_event 16842:__wait_event使当前进程在等待队列wq中等待直至condition为真。 16829:通过调用add_wait_queue(16791行),局部变量__wait可以被链接到队列上。注意__wait是在堆栈中而不是在内核堆中分配空间,这是内核中常用的一种技巧。在宏运行结束之前,__wait就已经被从等待队列中移走了,因此等待队列中指向它的指针总是有效的。 16830:重复分配CPU给另一个进程直至条件满足,这一点将在下面几节中讨论。 16831:进程被置为TASK_UNINTERRUPTIBLE状态(16190行)。这意味着进程处于休眠状态,不应被唤醒,即使是信号量也不能打断该进程的休眠。信号量在第6章中介绍,而进程状态则在第7章中介绍。 16832:如果条件已经满足,则可以退出循环。 请注意如果在第一次循环时条件就已经满足,那么前面一行的赋值就浪费了(因为在循环结束之后进程状态会立刻被再次赋值)。__wait_event假定宏开始执行时条件还没有得到满足。而且,这种对进程状态变量state的延迟赋值也并没有什么害处。在某些特殊情况下,这种方法还十分有益。例如当__wait_event开始执行时条件为假,但是在执行到16832行时就为真了。这种变化只有在为有关进程状态的代码计算condition变量值时才会出现问题。但是在代码中这种情况我一处也没有发现。 16834:调用schedule(26686行,在第7章中讨论)将CPU转移给另一个进程。直到进程再次获得CPU时,对schedule的调用才会返回。这种情况只有当等待队列中的进程被唤醒时才会发生。 16836:进程已经退出了,因此条件必定已经得到了满足。进程重置TASK_RUNNING的状态(16188行),使其适合CPU运行。 16837:通过调用remove_wait_queue(16814行)将进程从等待队列中移去。wait_event_interruptible和__wait_event_interruptible(分别参见16868行和16847)基本上与wait_event和__wait_event相同,但不同的是它们允许休眠的进程可以被信号量中断。如前所述,信号量将在第6章中介绍。 请注意wait_event是被如下结构所包含的。 ({ /* … */ }) 和do/while(0)技巧一样,这样可以使被封闭起来的代码能够像一个单元一样运行。这样的封闭代码就是一个独立的表达式,而不是一个独立的语句。也就是说,它可以求值以供其它更复杂的表达式使用。发生这种情况的原因主要在于一些不可移植的gcc特有代码的存在。通过使用这类技巧,一个程序块中的最后一个表达式的值将定义为整个程序块的最终值。当在表达式中使用wait_event_interruptible时,执行宏体后赋__ret的值为宏体的值(参看16873行)。对于有Lisp背景知识的程序员来说,这是个很常见的概念。但是如果你仅仅了解一点C和其它一些相关的过程性程序设计语言,那么你可能就会觉得比较奇怪。 __wake_up 26829:该函数用来唤醒等待队列中正在休眠的进程。它由wake_up和wake_up_interruptible调用(请分别参看16612行和16614行)。这些宏提供mode参数,只有状态满足mode所包含的状态之一的进程才可能被唤醒。 26833:正如将在第10章中详细讨论的那样,锁(lock)是用来限制对资源的访问,这在SMP逻辑单元中尤其重要,因为在这种情况下当一个CPU在修改某数据结构时,另一个CPU可能正在从该数据结构中读取数据,或者也有可能两个CPU同时对同一个数据结构进行修改,等等。在这种情况下,受保护的资源显然是等待队列。非常有趣的是所有的等待队列都使用同一个锁来保护。虽然这种方法要比为每一个等待队列定义一个新锁简单得多,但是这就意味着SMP逻辑单元可能经常会发现自己正在等待一个实际上并不必须的锁。 26838:本段代码遍历非空队列,为队列中正确状态的每一个进程调用wake_up_process(26356行)。如前所述,进程(队列节点)在此可能并没有从队列中移走。这在很大程度上是由于即使队列中的进程正在被唤醒,它仍然可能希望继续存在于等待队列中,这一点正如我们在__wait_event中发现的问题一样。 内核模块(Kernel Modules) 整个内核并不需要同时装入内存。应该确认,为保证系统能够正常运行,一些特定的内核必须总是驻留在内存中,例如,进程调度代码就必须常驻内存。但是内核其它部分,例如大部分的设备驱动就应该仅在内核需要的时候才装载,而在其它情况下则无需占用内存。 举例来说,只有在内核真正和CD-ROM通讯时才需要使用完成内核与CD-ROM通讯的设备驱动程序,因此内核可以被设置为在和设备通讯之前才装载相应代码。内核完成和设备的通讯之后可以将这部分代码丢弃。也就是说,一旦代码不再需要,就可以从内存中移走。系统运行过程中可以增减的这部分内核称为内核模块。 内核模块的优点是可以简化内核自身的开发。假设你购买了一个新的高速CD-ROM驱动器,但是现有的CD-ROM驱动程序并不支持该设备。你自然就希望增加对这种高速模式的支持以提高系统光驱设备的性能。如果作为内核模块来编译驱动程序,你的工作将会方便得多:编译驱动程序,加载到内核,测试,卸载驱动程序,修改驱动程序,再次加载驱动程序到内核,测试,如此周而复始。如果你的驱动程序是直接编辑在内核中的,那么你就必须重新编译整个内核并且在每次修改驱动程序之后重新启动机器。这样慢得很多。 自然,你也必须留意内核模块。对于指明其它内核模块在磁盘上的驻留位置的那些模块,一定不能从内存中卸载,否则,内核将只能通过访问磁盘来装载处理磁盘访问的内核模块,这是不可能实现的。这也是我们要选择把部分内核作为模块编译还是直接编译进内核使其常驻内存的又一个原因。你知道自己系统的设置方式,因而也就可以自己选择正确使用的方式。(如果为了确保安全,你可以简单的忽略内核模块系统的优点,而把所有的内容都编译到内核里面。) 内核模块会带来一些速度上的损失,这是因为一些必需的代码现在并不在RAM中,必需要从磁盘读入。但是整个系统的性能通常会有所提高,这主要是因为通过丢弃暂时不使用的模块可以释放出额外的RAM供应用程序使用。如果这部分内存被内核所占用,应用程序将只能更加频繁地进行磁盘交换(swap),而这种磁盘交换会显著的降低应用程序的性能。(磁盘交换将在第8章中讨论。) 内核模块还会带来因复杂度的增加所造成的开销,这是因为在系统运行的过程中移进移出部分内核需要额外的代码。然而,正如你将在本节中看到的,复杂度的开销是可以管理的。通过使用外部程序来代理一些必需的工作还可以更进一步降低复杂度的开销。(更为确切的说法是,这样做不是减少了复杂度的开销,而是把复杂度的开销重新分配了一下。)这是对内核模块原理的一个小小的扩展:即使是内核的支持模块对于内核来说也只是外部的,部分可用的,只有在需要的时候才被装入内存。 通常用于这种目的程序称为modprobe。有关的modprobe代码超出了本书的范围,但是在Linux的每个发行版本中都有包含有它。本节的剩余部分将讨论同modprobe协同工作以装载内核模块的内核代码。 request_module 24432:作为函数说明之前的注释,request_module是一个函数。内核的其它模块在需要装载其它内核模块的时候,都必须调用这个函数。就像内核处理其它工作一样,这种调用也是为当前运行的进程进行的。从进程的角度来看,这种调用的请求通常是隐含的――正在执行进程其它请求的内核可能会发现必须调入一个模块才能够完成该请求。例如,请参看10070行,这里是一些将在第7章中讨论的代码。 24446:以内核中的一个独立进程的形式执行exec_modprobe函数(24384行,马上就会讨论到)。这并不能只通过函数的简单调用实现,因为exec_modprobe要继续调用exec来执行一个程序。因此,对函数exec_modprobe的简单调用将永远不会有返回。 这和使用fork以准备exec调用十分类似,你可以认为kernel_thread对内核来说就是较低版本的fork,虽然两者有很大不同。fork是从指定函数开始执行新的进程,而不是从调用者的当前位置开始运行。正如fork一样,kernel_thread返回的值是新进程的进程号。 24448:和fork一样,从kernel_thread返回的负值表示内部错误。 24455:正如函数中论述的一样,大部分的信号量将因当前进程而被暂时阻塞。 24462:等待exec_modprobe执行完毕,同时指出所需要的模块是已经成功装入内存还是装载失败了。 24465:结束运行,恢复信号量。如果exec_modpro返回错误代码,则打印错误消息。 exec_modprobe 24384:exec_modprobe运行为内核增加内核模块的程序。这里的模块名是一个void*的指针,而不是char*的指针。原因简单说来就是kernel_thread 产生的函数通常都使用void*指针参数。 24386:设置modprobe的参数列表和环境。Modprobe_path(24363行)用来定位modprobe程序的位置。它可以通过内核的sysctl特性来修改,这一点将在第11章中介绍(请参看30388行)。这意味着root可以动态选择不同于/sbin/modprobe的程序来运行,以适应当modprobe被安装到其它地方或者使用修改过的modprobe替换掉了原有的modprobe之类的情况。 24400:(正如代码中描述的一样)出于安全性考虑,丢弃所有挂起的信号量和信号量句柄(handlers)。这里最重要的部分是对flush_signal_handlers的调用(28041行),它使用内核默认的信号量句柄代替所有用户定义的信号量句柄。如果在此时有信号量被传送到内核,它将获得默认响应——通常是忽略信号量或杀死进程。但是不管怎样都不会引起安全风险。由于该函数从触发它的进程中分离出来(如前所述),所以不管原始进程在此处是否改变其原来分配的信号量句柄都不会产生任何影响。 24405:关闭调用进程打开的所有文件。最重要的是,这意味着modprobe程序不再从调用进程中继承标准输入输出和标准错误。这很有可能会引起安全漏洞。(这可能在替代modprobe的程序中引起的问题,但是modprobe本身实际上并不关心这个差异。) 24413:modprobe程序作为root运行,它拥有root所拥有的所有权限。和整个内核中其它地方一样,请注意root使用用户ID号0的假定在这里已经被写入程序。用户ID号和权能系统(capability system)(在接下来的几行中会用到)将在第7章中介绍。 24421:试图执行modprobe程序。如果尝试失败,内核将使用printk打印错误消息并返回错误代码。这里是可能产生printk的缓冲器过载的地点之一。module_name的长度并没有明确限制,就我们对该调用的看法而言,它可能长达一百万个字符。为防止printk缓冲器过载,你必需遍历所有对于该函数的调用(实际上是对request_module的调用)以保证每个调用者使用足够短的不会为printk造成麻烦的模块名。 24427:当execve成功执行时,它不会返回任何结果,因此本处是不可能执行到的。但是编译器却并不知道这一点,因此此处使用了return语句以保证gcc不出错。 对于内核的进一步讨论将超出本章的既定范围,因此在这个问题上我们到此为止。然而本书中也包括了其它必需的内核代码。在读完第4章和第5章之后,也许你会希望再次仔细研读一下这部分内容。有关这个问题的两个文件是include/linux/module.h(从15529行开始)和/kernel/module.c(从24476行开始)。和sys_create_module(24586行),sys_init_module(24637行),sys_delete_module(24860行)和sys_query_module(25148行)四个函数需要特别注意一样,struct module(15581行)也要特别引起注意。这些函数实现了modprobe以及insmod,lsmod和rmmod所使用的系统调用以完成模块的装载、定位和卸载。 内核触发直接回调内核程序的现象看起来很令人奇怪。但是,实际上进行的工作不止于此。例如,modprobe必须实际访问磁盘以搜寻要装载的模块。而且更为重要的一点是这种方法赋予root对内核模块系统更多的控制能力。这主要是因为root也可以运行modprobe以及相关程序。因此,root既可以手工装载、查询、卸载模块,也可以由内核自动完成。 配置与编译内核 你可能仅仅研读、欣赏而并不修改Linux内核源代码。但是,更普遍的情况是,用户有强烈的愿望去改进内核代码并完成相应的测试,这样我们就需要知道如何重建内核。本节就是要告诉你如何实现这一点,而最终则归结于如何把你所做的修改发行给别人,以使得每个人都能从你的工作中受益。 配置内核 编译内核的第一步就是配置内核,这是增加或者减少对内核特性的支持以及修改内核的一些特性发挥作用的方式的必要步骤。例如,你可以要求内核为自己的声卡指定一个不同的DMA通道。如果内核配置和你的需要相同,那么你可以直接跳过本节,否则请继续阅读以下内容。 为了完成内核的配置,请先切换到root用户,然后转入如下内核源程序目录: cd /usr/src/linux 接着敲入如下命令组: make config make menuconfig make xconfig 这三条命令都可以让你来配置内核,但它们发挥作用的方式各不相同:  make config――三种方法中最简单也是最枯燥的一种。但是最基本的一点是,它可以适应任何情况。这种方法通过为每一个内核支持的特性向用户提问的方式来决定在内核中需要包含哪些特性。对于大多数问题,你只要回答y(yes,把该特性编译进内核中),m(作为模块编译)或者n(no,根本不对该特性提供支持)。在决定之前用户应该考虑清楚,因为这个过程是不可逆的。如果你在该过程中犯了错误,就只能按Ctrl+C退出。你也可以敲入?以获取帮助。图2.1显示了这种方法正在X终端上运行的情况。 图2.1 运行中的make config 幸运的是,这种方法还有一些智能。例如,如果你对SCSI支持回答no,那么系统就不会再询问你有关SCSI的细节问题了。而且你可以只按回车键以接受缺省的选择,也就是当前的设置(因此,如果当前内核将对于SCSI的支持编译进了内核,在这个问题上按回车键就意味着继续把对SCSI的支持编译进内核中)。即使是这样,大部分用户还是宁愿使用另外的两种方法。  make menuconfig―一种基于终端的配置机制,用户拥有通过移动光标来进行浏览等功能。图2.2显示了在X终端上运行的make menuconfig。虽然在控制台上显示的是彩色,但是在终端上的显示仍然相当单调。使用menuconfig必须要有相应的ncurses类库。 图2.2 运行中的make menuconfig  make xconfig――这是我最喜欢的一种配置方式。只有你能够在X server上用root用户身份运行X应用程序时,这种配置方式才可以使用(有些偏执的用户就不愿意使用这种方式)。你还必须拥有Tcl窗口系统(Tcl windowing system),这实际上还意味着你必须拥有Tcl,Tk以及一个正在运行的X安装程序。作为补偿,用户获得的是更漂亮的,基于X系统的以及和menuconfig功能相同的配置方法。图2.3显示这种方法运行过程中打开“可装载模块支持(Loadable module support)”子窗口的情况。 图2.3 运行中的make xconfig 如上所述,这三种方法都实现了相同的功能:它们都生成在构建内核时使用的.config文件。而唯一的区别是在于创建这个文件时的难易程度不同。 构建内核 构建内核要做的工作要比配置内核所做的工作少得多。虽然有几种方式都能实现这一功能,但是选择哪一种依赖于你希望怎样对系统进行设置。长期以来,我已经形成了如下的习惯。虽然这种习惯比我所必须要做的略微多一些,但是它包含了所有基本的问题。首先,如果你还不在内核源程序目录中,请先再次转入这一目录: cd /usr/src/linux 现在,切换到root用户,使用下面显示的命令生成内核。现在在shell中敲入下面的命令,注意make命令因为空间关系分成了两行,但实际上这在shell输入时是一个只有一行的命令: make dep clean zlilo boot modules modules_install 当给出了如上多个目标时,除非前面所有的目标都成功了,否则make能够知道没有必要继续尝试下面的目标。因此,如果make能够运行结束,成功退出,那么这就意味着所有的目标都正确构建了。现在你可以重新启动机器以运行新的内核。 备份的重要性 当修改(fooling)内核时,你必须准备一个能够启动的备用内核。实现该目的的一种方式是通过配置Linux加载程序(LILO)以允许用户选择启动的内核映象,其中之一是从没有修改过的内核的备份(我总是这样做的)。 如果你比较有耐心,那么你就可以使用zdisk目标而不使用zlilo目标;它可以把能够启动的内核映象写入软盘中。这样你就可以通过在启动时插入软盘的方式启动你的测试内核;如果没有插入软盘,则启动正常的内核。 但是请注意:内核模块并没有被装载到软盘中,它们实际上是装在硬盘中的(除非你愿意承担更多的麻烦)。因此,如果你弄乱了内核模块,即使是zdisk目标也救不了你。实际上,上面提到的这两种方法都存在这个问题。虽然有比较好的解决方法可用,但是最简单的方法(也就是我所使用的方法)是把备份内核作为严格独立的内核来编译,而不使用可装载模块的支持。通过这种方法,即使我弄乱了内核而不得不使用备份启动系统,那么不管问题是实验性内核不正确还是内核模块的原因都无关紧要。不管怎样,在备份的内核中已经有我需要的所有东西了。 由于用户所作的修改可能导致系统的崩溃,如损坏磁盘上的数据等等,并不仅仅只是打乱设备驱动程序或文件系统,在测试新内核之前,备份系统的最新数据也是一个英明的决策。(虽然设备驱动程序的开发不是本书的主题,但是必需指出的是,设备驱动程序的缺陷可能会引起系统的物理损坏。例如显示器是不能备份的,而且因价格昂贵而不易替换。)作为一个潜在的内核黑客,你的最佳投资(当然是读过本书以后)是一个磁带驱动器和充足的磁带。 发行你的改进 下面是有关发行你所做修改的一些基本规则:  检查最新发行版本,确保你所处理的不是已经解决了的问题  遵守Linux 内核代码编写的风格。简要的说就是8字符缩进以及K&R括号风格(if,else,for,while,switch或者do后面同一行中紧跟着开括号)。在内核源程序目录下面的文档编写和代码风格文件给出了完整的规则,不过我们已经介绍了其中的关键部分。注意本书中包含的源程序代码为节省空间而进行了大量的重新编辑,在该过程中我可能打破了其中的一些规则。  独立发行相对无关的修改。这样,只想使用你所做的某部分修改的人就可以十分方便地获得想要的东西,而不用一次检验所有的修改内容。  让使用你所做修改的用户清楚他们可以从你的修改中获取什么。同样地,你也应该给出这些问题的可信度。你是15分钟之前才匆匆完成你的修改,甚至还没有时间对它们进行编译,还是已经在你和你的朋友的系统中从去年3月开始就长期稳定的运行过这个修改? 假设现在你已经准备好发行自己的修改版本了,那么要做的第一步是建立一个说明你所做的修改的文件。你可以使用diff程序自动创建这个文件。结果或者被称为diffs,也或者在Linux中更普遍的被称为补丁(patch)。 发布的过程十分简单。假设原来没有修改过的源程序代码在linux-2.2.5目录下,而你修改过的源程序代码在linux-my目录下,那么只要进行如下的简单工作就可以了(只有在链接不存在的情况下才需要执行ln): ln –s linux-my linux make –C linux-2.2.5 distclean make –C linux distclean diff –urN linux-2.2.5 linux >my.patch 现在,输出文件my.patch包含了其它用户应用这个修改程序时所必须的一切内容。(警告:如上所述,两个源程序间的所有差别都会包含在这个补丁文件中。Diff不能区分修改部分之间的关系,所以就把它们都罗列了出来。)如果补丁文件相对较小,你可以使用邮件直接发往内核邮件列表。如果补丁很大,那么就需要通过FTP或者Web站点发布。这时发给邮件列表的信件中就只需要包含一个URL。 Linux内核邮件列表的常见问题解答(FAQ)文件位于http://www.ececs.uc.edu/~rreilova/linux/lkmlfaq.html。该FAQ中包含了邮件列表的订阅,邮件发布以及阅读邮件列表的注意事项等等。 顺便提一下,如果你想随时了解内核更新开发的进程,我向你强烈推荐下面这个具有很高价值的内核交流站点Kernel Traffic:http://www.kt.opensrc.org。 |
|
|
|
3C#
发布于:2002-10-18 19:43
Re:Linux俱乐部成立喽!!!
第3章 内核体系结构概述
本章从较高层次上对内核进行说明。从顺序上来说,本章首先介绍内核设计目标,接下来介绍内核体系结构,最后介绍内核源程序目录结构。 内核设计目标 Linux 的内核展现出了几个相互关联的设计目标,它们依次是:清晰性(clarity),兼容性(compatibility),可移植性(portability),健壮性(robustness),安全性(security)和速度(speed)。这些目标有时是互补的,有时则是矛盾的。但是它们被尽可能的保持在相互一致的状态,内核设计和实现的特性通常都要回归到这些问题上来。本节接下来的部分将分别讨论这些设计目标,同时还将对它们之间的取舍与平衡进行简要的说明。 清晰性 稍微过于简化的说,内核目标是在保证速度和健壮性的前提下尽量清晰。这和现在的大多数应用程序的开发有所区别,后者的目标通常是在保证清晰性和健壮性的基础上尽量提高速度。因而在内核内部,速度和清晰性经常是一对矛盾。 在某种程度上,清晰性是健壮性的必要补充:一个很容易理解的实现方法比较容易证明是正确的;或者即使不正确,也能比较容易的找出其问题所在。从而这两个目标很少会发生冲突。 但是清晰性和速度通常却是一对矛盾。经过仔细手工优化的算法通常都使用了编译器生成代码的类似技术,很少可能是最清晰的解决方案。当内核中清晰性和速度要求不一致时,通常都是以牺牲清晰性来保证速度的。即便如此,程序员仍然清楚的知道清晰性的重要性,而且他们也做了大量完美的工作以使用最清晰的方法保证速度。 兼容性 正如第1章中所述,Linux最初的编写目的是为了实现一个完整的、与Unix兼容的操作系统内核。随着开发过程的展开,它也开始以符合POSIX标准为目标。就内核而言,兼容Unix(至少是同某一现代的Unix实现相兼容)和符合POSIX标准并没有什么区别,因此我们也不会在这个问题上详细追究。 内核提供了另外一种类型的兼容性。基于Linux 的系统能够提供可选择的对Java.class文件的本地运行支持。(据说Linux是第一个提供这种支持的操作系统。)尽管实际负责Java程序解释执行的是另外一个Java虚拟机进程,该虚拟机并没有内置到内核中。但是内核提供的这种机制可以使得这种支持对用户是透明的。通过内核本身提供的程度不同的支持(这并不代表大部分工作像Java的解决方式一样能够通过外部进程实现),对其它可执行文件格式的支持也能够以同样的方式插入内核中。这方面的内容将在第7章中详细介绍。 另外需要说明的是,GNU/Linux系统作为一个整体通过DOSEMU仿真机器提供了对DOS可执行程序的支持,而且也通过WINE设计提供了对Windows可执行程序的部分支持。系统还以同样的方式通过SAMBA提供了对Windows兼容文件和打印服务的支持。但是这些都不是同内核密切相关的问题,因此在本书中我们不再对它们进行讨论。 兼容性的另外一个方面是兼容异种文件系统,本章中稍后会有更为详细的介绍,但是大部分内容已经超出了本书的范围。Linux能够支持很多文件系统,例如ext2(“本地”文件系统),ISO-9660(CD-ROM使用的文件系统),MS-DOS,网络文件系统(NFS)等许多其它文件系统。如果你有使用其它操作系统格式的磁盘或者一个网络磁盘服务器,那么Linux将能够和这些不同的文件系统进行交互。 兼容性的另外一个问题是网络,这在当今Internet流行的时代尤为重要。作为Unix的一个变种,Linux自然从很早就开始提供对TCP/IP的支持。内核还支持其它许多网络协议,它们包括AppleTalk协议的代码,这使得Linux单元(box)可以和Macintosh机自由通讯;Novell的网络协议,也就是网络报文交换(IPX),分组报文交换(SPX),和NetWare核心协议(NCP);IP协议的新版本IPv6;以及其它一些不太出名的协议。 兼容性考虑的最后一个方面是硬件兼容性。似乎每个不常见的显卡,市场份额小的网卡,非标准的CD-ROM接口和专用磁带设备都有Linux的驱动程序。(只要它不是专为特定操作系统设计的专用硬件。)而且只要越来越多的厂商也逐渐认识到Linux的优势,并能够为更容易地实现向Linux上移植而开放相应的源程序代码,Linux对硬件支持会越来越好。 这些兼容性必须通过一个重要的子目标:模块度(Modularity)来实现。在可能的情况下,内核只定义子系统的抽象接口,这种抽象接口可以通过任何方法来实现。例如,内核对于新文件系统的支持将简化为对虚拟文件系统(VFS)接口的代码实现。第7章中介绍的是另外一个例子,内核对二进制句柄的抽象支持是实现对诸如Java之类的新可执行格式的支持的方法。增加新的可执行格式的支持将转变为对相应的二进制句柄接口的实现。 可移植性 与硬件兼容性相关的设计目标是可移植性(portability),也就是在不同硬件平台上运行Linux的能力。系统最初是为运行在标准IBM兼容机上的Intel x86 CPU而设计的,当时根本没有考虑到可移植性的问题。但是情况从那以后已经发生了很大的变化。现在正式的内核移植包括向基于Alpha,ARM,Motorola 69x0,MIPS,PowerPC,SPARC以及SPARC-64 CPU系统的移植。因而,Linux可以在Amigas,旧版或新版的Macintosh,Sun和SGI工作站以及NeXT机等机器上运行。而且这些还只是标准内核发行版本的移植范围。从老的DEC VAX到3Com掌上系列个人数字助理(例如Palm III)的非正式的移植工作也在不断进行中。成功的非正式移植版本后来通常都会变成正式的移植版本,因此这些非正式的移植版本很多最终都会出现在主开发树中。 广泛平台支持之所以能够成功的部分原因在于内核把源程序代码清晰地划分为体系结构无关部分和体系结构相关部分。在本章的后续部分将对这个问题进行更深入的讨论。 健壮性和安全性 Linux必须健壮、稳定。系统自身应该没有任何缺陷,并它还应该可以保护进程(用户)以防止互相干扰,这就像把整个系统从其它系统中隔离开来加以保护一样。后一种考虑很大程度上是受信任的用户空间应用程序领域的问题,但是内核至少也应该提供支撑安全体系的原语(primitive)。健壮性和安全性比任何别的目标都要重要,包括速度。(系统崩溃的速度很快又有什么好处呢?) 保证Linux健壮性和安全性的唯一一个最重要的因素是其开放的开发过程,它可以被看作是一种广泛而严格的检查。内核中的每一行代码、每一个改变都会很快由世界上数不清的程序员检验。还有一些程序员专门负责寻找和报告潜在的缺陷――他们这样做完全是出于自己的个人爱好,因为他们也希望自己的Linux系统能够健壮安全。以前检查中所没有发现的缺陷可以通过这类人的努力来定位、修复,而这种修复又合并进主开发树以使所有的人都能够受益。安全警告和缺陷报告通常在几天甚至几个小时内就能够得到处理和修复。 Linux可能并不一定是现有的最安全的操作系统(很多人认为这项桂冠应该属于OpenBSD,它是一个以安全性为主要目标的Unix变种),但是它是一个有力的竞争者。而且Linux健壮性远没有发展到尽头。 速度 这个术语经常自己就可以说明问题。速度几乎是最重要的衡量标准,虽然其等级比健壮性、安全性和(在有些时候)兼容性的等级要低。然而它却是代码最直观的几个方面之一。Linux内核代码经过了彻底的优化,而最经常使用的部分――例如调度――则是优化工作的重点。几乎在任何时候都有一些不可思议的代码,这是由于这种方式的执行速度比较快。(这并不总是很明显,但是你经常不得不通过自己的试验来对这种优化代码进行确认。)虽然有时一些更直接的实现方法速度也很快,但是我所见过的这种情况屈指可数。 在某些情况下,本书推荐用可读性更好的代码来替代那些以速度的名义而被故意扭曲了的代码。虽然速度是一个设计目标,但我基本上只在以下两种情况时才会这样做:a) 在所考虑的问题中,速度明显不是关键问题 b) 没有其它的办法。 内核体系结构初始 图3.1是一种类Unix操作系统的相当标准的视图,实际上,更细致的来说,该图能够说明所有期望具有平台无关特性的操作系统。它着重强调了内核的下面两个特性:  内核将应用程序和硬件分离开来。  部分内核是体系结构和硬件特有的,而部分内核则是可移植的。 图3.1 内核体系结构基本结构图 第一点我们在前面章节中已经讨论清楚了,在这里没有必要重复说明。第二点,也就是与体系结构无关和与体系结构相关代码的内容对于我们的讨论比较有意义。内核通过使用与处理用户应用程序相同的技巧来实现部分可移植性。这也就是说,如同内核把用户应用程序和硬件分离一样,部分内核将会因为与硬件的联系而同其它内核分离开来。通过这种分离,用户应用程序和部分内核都成为可移植的。 虽然这通常并不能够使得内核本身更清楚,但是源程序代码的体系结构无关部分通常定义了与低层,也就是体系结构相关部分(或假定)的接口。作为一个简单的例子,内存管理代码中的体系结构无关部分假定只要包含特定的头文件就可以获得合适的PAGE_SIZE 宏(参看10791行)的定义,该宏定义了系统的内存管理硬件用于分割系统地址空间的内存块的大小(参看第8章)。体系结构无关代码并不关心宏的确切定义,而把这些问题都留给体系结构相关代码去处理。(顺便一提,这比到处使用#ifdef/#endif程序块来定义平台相关代码要清晰易懂得多。) 这样,内核向新的体系结构的移植就转变成为确认这些特性以及在新内核上实现它们的问题。 另外,用户应用程序的可移植性还可以通过它和内核的中间层次――标准C库(libc)――的协助来实现。应用程序实际上从不和内核直接通讯,而只通过libc来实现。图3.1中显示应用程序和内核直接通讯的唯一原因在于它们能够和内核通讯。虽然在实际上应用程序并不同内核直接通讯――这样做是毫无意义的。通过直接和内核通讯所能处理的问题都可以通过使用libc实现,而且更容易。 Libc和内核通讯的方式是体系结构相关的(这和图中有一点矛盾),libc负责将用户代码从实现细节中解放出来。有趣的是,甚至大部分libc都不了解这些细节。大部分的libc,例如atoi和rand的实现,都根本不需要和内核进行通讯。剩余部分的大部分libc,例如printf函数,在涉及到内核之前或之后就已经处理大量的工作。(printf必需首先解释格式化字符串,分析相应参数,设定打印方法,在临时内部缓冲器中记录预期输出。直到此时它才调用底层系统调用write来实际打印该缓冲区。)其它部分的libc 则只是相应系统调用的简单代理。因而一旦发生函数调用时,它们会立即调用内核相应函数以完成主要工作。在最低层次上,大部分libc通过单通道同内核进行交流,而它们所使用的机制将第5章中进行详细介绍。 由于这种设计,所有的用户应用程序,甚至大部分的C库,都是通过体系结构无关的方式和内核通讯的。 内核体系结构的深入了解 图3.2显示了内核概念化的一种可能方式。该图和区分内核的体系结构无关和体系结构相关的方法有所不同,它是一种更具有普遍性的结构视图。在“Kernel”框内的本书中有所涉及的内核部分都用括号注明了相应的章节编号。虽然有关对称多处理(SMP)的支持也属于本书的范围,但是在这里我们却没有标明章号。部分原因在于相当多的SMP代码广泛地分布于整个内核中,因此很难将它与某一个模块联系起来。同样的道理,对于内核初始化的支持也属于本书的范围,但是也没有标明章号。这样做仅仅是因为从设计的观点上看,该问题并不重要。最后,虽然在图中我们将第6章和“进程间通讯”框联系在一起,但是该章只涉及一部分进程间通讯的内容。 图3.2 详细的内核体系结构图 进程和内核的交互通常需要通过如下步骤: 1. 用户应用程序调用系统调用,通常是使用libc。 2. 该调用被内核的system_call函数截获(第5章,171行),此后该函数会将调用请求转发给另外的执行请求的内核函数。 3. 该函数随即和相关内部代码模块建立通讯,而这些模块还可能需要和其它的代码模块或者底层硬件通讯。 4. 结果按照同样的路径依次返回。 然而,并不是所有内核和进程间的交互都是由进程发起的。内核有时也会自行决定同哪个进程交互,例如通过释放信号量或者简单的采用直接杀死进程的方法终止该进程的执行(如当进程用完所有可用的CPU时间片),以便使其它进程有机会运行。这些交互过程在该图中并没有表示,主要是因为它们通常都只是内核对自己的内部数据结构的修改(信号量传递对于这种规则来说是一个例外)。 是层次化(Layered),模块化(Modular)还是其它? 解决复杂性的所有方法都基于一个基本原理:问题分解和各个击破。也就是说,都是把大型的、难以解决的问题(或系统)分解成一定数量的复杂度较低的子问题(或子系统),再根据需要重复这一过程直到每一部分都小到可以解决为止,而各种方法只是这种原理的一些不同运用而已。 计算机科学中有三种经典的方法比较适合于构建大型系统(我首先必须说明的是,这些定义都是经过我深思熟虑的讨论对象)。  层次(Layer)――将解决方案分解成若干部分,在这些部分中存在一个问题域的最底层,它为上层的抽象层次较高的工作提供基础。较高层建立在其低层基础之上。OSI和TCP/IP协议堆栈是众所周知的层次化软件设计的成功的例子。操作系统设计的层次化解决方案可能会包含一个可以直接和硬件通讯的层次、然后在其上提供为更高层提供抽象支持的层次。这样更高层就可以对磁盘、网卡等硬件进行访问,而并不需要了解这些设备的具体细节。 层次化设计的一个特征是要逐步构建符号集(vocabulary)。随着层次的升高,符号集的功能将越来越强大。层次化设计的另外一个特征是完全可以在对其上下层透明的条件下替换某一层次。在最理想的情况下,移植层次化的操作系统只需要重写最低层的代码。纯层次化模型实现的执行速度可能会很慢,因为高层必须(间接的)通过调用一系列连续的低层才能处理完自己的任务――N层调用N-1层,N-1层调用N-2层,等等,直到实际的工作在0层被处理完成。接着,结果当然是通过同样的路径反向传递回来。因此,层次化设计通常会包含对某些高层直接和某些低层通讯的支持;这样虽然提高了速度,但是却使得各个层次的替换工作更加困难(因为不止一个高层会直接依赖于这个你所希望进行替换的层次)。  模块(Module)――模块将具体的一部分功能块隐藏在抽象的接口背后。模块的最大特点是将接口和其实现分离开来,这样就能够保证一个模块可以在不影响其它模块的情况下进行改变。这样也将模块之间的依赖关系仅仅限定于接口。模块的范围是试图反映求解域内一些方面的自然的概念性界限。纯模块化的操作系统因而就可能有一个磁盘子系统模块,一个内存管理子系统模块,等等。纯模块化和纯层次化的操作系统之间的主要区别是一个可以由其它模块自由调用,模块间没有上层和下层的概念。(从这个意义上来说,模块是广义的层次。按照纯粹的观点,层次是最多可供一个其它模块调用的模块,这个模块也就是它的直接上层模块。)  对象(Object)――对象和模块不同,因为对于初学者来说它们具有不同的问题考虑方式,实现的方法也可能各自独立。但是,就我们当前的目的来说,对象不过是结构化使用模块的方法。组件(Component)作为对象思想的进一步改进目前还没有在操作系统设计中广泛使用。即便如此(按照我们的观点),我们也没有足够的理由将其和模块划分在不同的范畴中。 图3.1强调了内核的层次化的视图,而且是体系结构无关层次位于体系结构相关层次之上。(更为精确的视图是在顶层增加一个附加的体系结构相关的层次。这是因为系统调用接口位于应用程序和内核之间,而且是体系结构相关的。)图3.2着重强调了更加模块化的内核视图。 从合理的表述层次上看,这两种观点都是正确的。但也可以说这两种观点都是错误的。我可以用大量的图片向你证明内核是遵从所有你所能够指出的设计原则集合的,因为它就是从众多思想中抽取出来的。简单说来,事实是Linux内核既不是严格层次化的,也不是严格模块化的,也不是严格意义上的任何类型,而是以实用为主要依据的。(实际上,如果要用一个词来概括Linux从设计到实现的所有特点,那么实用就是最确切的。)也许最保守的观点是内核的实现是模块化的,虽然这些模块有时会为了追求速度而有意跨越模块的界限。 这样,Linux的设计同时兼顾了理论和实际。Linux并没有忽视设计方法;相反,在Linux的开发基本思想中,设计方法的作用就像是编译器:它是完成工作的有力工具。选择一个基本的设计原则(例如对象)并完全使用这种原则,不允许有任何例外,这对于测试该原则的限制,或者构建以说明这些方法为目的的教学系统来说都是一个不错的方法。但是如果要用它来达到Linux的设计目标则会引起许多问题。而且Linux的设计目标中也并不包括要使内核成为一个完全纯化的系统。Linux开发者为了达到设计目标宁愿违背妨碍目标实现的原则。 实际上,如果对于Linux来说是正确的,那么它们对于所有最成功的设计来说都是正确的。最成功、应用最广泛的实际系统必然是实用的系统。有些开发人员试图寻找功能强大的可以解决所有问题的特殊方法。他们一旦找到了这种方法,所有的问题就都迎刃而解了。像Linux内核一样的成功设计通常需要为系统的不同部分和描述上的不同层次使用不同的方法。这样做的结果可能不是很清晰,也不是很纯粹,但是这种混合产物比同等功能的纯粹系统要强大而且优秀得多。 Linux大部分都是单内核的 操作系统内核可能是微内核,也可能是单内核(后者有时称之为宏内核Macrokernel)。按照类似封装的形式,这些术语定义如下:  微内核(Microkernel kernel)――在微内核中,大部分内核都作为独立的进程在特权状态下运行,它们通过消息传递进行通讯。在典型情况下,每个概念模块都有一个进程。因此,如果在设计中有一个系统调用模块,那么就必然有一个相应的进程来接收系统调用,并和能够执行系统调用的其它进程(或模块)通讯以完成所需任务。 在这些设计中,微内核部分经常只不过是一个消息转发站:当系统调用模块要给文件系统模块发送消息时,消息直接通过内核转发。这种方式有助于实现模块间的隔离。(某些时候,模块也可以直接给其它模块传递消息。)在一些微内核的设计中,更多的功能,如I/O等,也都被封装在内核中了。但是最根本的思想还是要保持微内核尽量小,这样只需要把微内核本身进行移植就可以完成将整个内核移植到新的平台上。其它模块都只依赖于微内核或其它模块,并不直接直接依赖硬件。 微内核设计的一个优点是在不影响系统其它部分的情况下,用更高效的实现代替现有文件系统模块的工作将会更加容易。我们甚至可以在系统运行时将开发出的新系统模块或者需要替换现有模块的模块直接而且迅速的加入系统。另外一个优点是不需要的模块将不会被加载到内存中,因此微内核就可以更有效的利用内存。  单内核(Monolithic kernel)――单内核是一个很大的进程。它的内部又可以被分为若干模块(或者是层次或其它)。但是在运行的时候,它是一个独立的二进制大映象。其模块间的通讯是通过直接调用其它模块中的函数实现的,而不是消息传递。 单内核的支持者声称微内核的消息传递开销引起了效率的损失。微内核的支持者则认为因此而增加的内核设计的灵活性和可维护性可以弥补任何损失。 我并不想讨论这些问题,但必须说明非常有趣的一点是,这种争论经常会令人想到前几年CPU领域中RISC和CISC的斗争。现代的成功CPU设计中包含了所有这两种技术,就像Linux内核是微内核和单一内核的混合产物一样。Linux内核基本上是单一的,但是它并不是一个纯粹的集成内核。前面一章所介绍的内核模块系统将微内核的许多优点引入到Linux的单内核设计中。(顺便提一下,我考虑过一种有趣的情况,就是Linux的内核模块系统可以将系统内核转化成为简单的不传递消息的微内核设计。虽然我并不赞成,但是它仍然是一个有趣的想法。) 为什么Linux必然是单内核的呢?一个方面是历史的原因:在Linus的观点看来,通过把内核以单一的方式进行组织并在最初始的空间中运行是相当容易的事情。这种决策避免了有关消息传递体系结构,计算模块装载方式等方面的相关工作。(内核模块系统在随后的几年中又进行了不断地改进。) 另外一个原因是充足的开发时间的结果。Linux既没有开发时间的限制,也没有深受市场压力的发行进度。 所有的限制只有并不过分的对内核的修改与扩充。内核的单一设计在内部实现了充分的模块化,在这种条件下的修改或增加都并不怎么困难。而且问题还在于没有必要为了追求尚未证实的可维护性的微小增长而重写Linux的内核。(Linus曾多次特别强调了如下的观点:为了这点利益而损耗速度是不值得的。)后面章节中的部分内容将详细的重新考虑充足开发时间的效果。 如果Linux是纯微内核设计,那么向其它体系结构上的移植将会比较容易。实际上,有一些微内核,如Mach微内核,就已经成功的证明了这种可移植性的优点。实际的情况是,Linux内核的移植虽然不是很简单,但也绝不是不可能的:大约的数字是,向一个全新的体系结构上的典型的移植工作需要30,000到60,000行代码,再加上不到20,000行的驱动程序代码。(并不是所有的移植都需要新的驱动程序代码。)粗略的计算一下,我估计一个典型的移植平均需要50,000行代码。这对于一个程序员或者最多一个程序小组来说是力所能及的,可以在一年之内完成。虽然这比微内核的移植需要更多的代码,但是Linux的支持者将会提出,这样的Linux内核移植版本比微内核更能够有效的利用底层硬件,因而移植过程中的额外工作是能够从系统性能的提高上得到补偿的。 这种特殊设计的权衡也不是很轻松就可以达到的,单内核的实现策略公然违背了传统的看法,后者认为微内核是未来发展的趋势。但是由于单一模式(大部分情况下)在Linux中运行状态良好,而且内核移植相对来说比较困难,但没有明显地阻碍程序员团体的工作,他们已经热情高涨地把内核成功的移植到了现存的大部分实际系统中,更不用说类似掌上型电脑的一些看起来很不实际的目标了。只要Linux的众多特点仍然值得移植,新的移植版本就会不断涌现。 设计和实现的关系 接下来的部分将介绍一些内核设计和实现之间的关系。本部分最重要的内容是对于内核源程序目录结构的概述,这一点随后就会提到。本章最后以实现中体系结构无关代码和体系结构相关代码的相对大小的估算作为总结。 内核源程序目录结构 按照惯例,内核源程序代码安装在/usr/src/linux目录下。在该目录下还有几个其它目录,每一个都代表一个特定的内核功能性子集(或者非常粗略的说是高层代码模块)。 Documentation 这个目录下面没有内核代码,只有一套有用的文档。但是这些文档的质量不一。有一部分内核文档,例如文件系统,在该目录下有相当优秀而且相当完整的文档;而另外一部分内核,例如进程调度,则根本就没有文档。但是在这里你可以不时的发现自己所最需要的东西。 arch arch目录下的所有子目录中都是体系结构相关的代码。每个体系结构特有的子目录下都又至少包含三个子目录:kernel,存放支持体系结构特有的诸如信号量处理和SMP之类特征的实现;lib,存放高速的体系结构特有的诸如strlen和memcpy之类的通用函数的实现;以及mm,存放体系结构特有的内存管理程序的实现。 除了这三个子目录以外,大多数体系结构在必要的情况下还都有一个boot子目录,该目录中包含有在这种平台上启动内核所使用的部分或全部平台特有代码。这些启动代码中的部分或全部也可以在平台特有的内核目录下找到。 最后,大部分体系结构所特有的目录还可以根据需要包含了供附加特性或改进的组织使用的其它子目录。例如,i386目录包含一个math-emu子目录,其中包括了在缺少数学协处理器(FPU)的CPU上运行模拟FPU的代码。作为另外一个例子,m68k移植版本中为每一个该移植版本所支持的基于680x0的机器建立了一个子目录,从而这些机器所特有的代码都有一个自然的根目录。 下面几个是arch目录下的子目录:  arch/alpha/――Linux内核到基于DEC Alphs CPU工作站的移植。  arch/arm/――Linux到ARM系列CPU 的移植,该类CPU主要用于诸如Corel的NetWinder和Acorn RiscPC之类的机器。  arch/i386/――最接近于Linux内核原始平台或标准平台。这是为Intel的80386结构使用的,当然包括对同一系列后来的CPU(80486,Pentium等等)的支持。它还包括了对AMD,Cyrix和IDT等公司的一些兼容产品的支持。 本书基本上将这种体系结构称为“x86”。即使这样,严格说来“x86”对于我们的目标来说还是要求得过于宽泛。早期的Intel CPU,例如80286,并没有包括Linux运行所需的所有特性。对于这些机器,Linux也没有正式的支持版本。(顺便提一下,Linux对这种CPU的独立移植版本是存在的,不过它在功能上有部分损失。)当本书中提到“x86平台”时,通常是指80386或更新的CPU。  arch/m68k/――到Motorola的680x0 CPU系列的移植。该版本可以提供对基于从68020(只要它同内存管理单元(MMU)68851一起使用)到68060的一切机器的支持。很多公司在他们的产品中使用680x0系列芯片,例如Commodore(现在是Gateway)的Amiga,Apple的Macintosh,Atari ST,等等。这些老机器中的很多现在正充当可靠的Linux工作站。另外,到NeXT工作站和SUN 3工作站的移植也正在进行中。  arch/mips/――到MIPS的CPU系列的移植。虽然有其它几个厂商也使用MIPS开发了一些系统,但是基于这种CPU的最出名的机器是Silicon Graphics(SGI)工作站。  arch/ppc/――到Motorola/IBM的PowerPC系列CPU的移植。这包括对基于PowerPC的Macintosh和Amiga以及BeBox、IBM的RS/6000等其它一些机器的支持。  arch/sparc/――到32位SPARC CPU的移植。这包括对从Sun SPARC 1到SPARC 20的全部支持。  arch/sparc64/——到基于64位SPARC CPU(UltraSPARC系)系统的移植。这里所能够支持的机器包括Sun的Ultra 1,Ultra 2和更高配置的机器,直到Sun的最新产品Enterprise 10000。注意32位和64位的SPARC的移植版本正在合并中。 不幸的是,本书必须将注意力集中在x86上,因此只应用到了arch/i386/目录下的代码,而其它体系结构所特有的代码将不再涉及了。 drivers 这个目录是内核中非常大的一块。实际上,drivers目录下包含的代码占整个内核发行版本代码的一半以上。它包括显卡、网卡、SCSI适配器、软盘驱动器,PCI设备和其它任何你可以说出的Linux支持的外围设备的软件驱动程序。 Drivers目录下的一些子目录是平台特有的,例如,zorro子目录中包含有和Zorro总线通讯的代码。而Zorro总线只在Amiga中使用过,因此这些代码必然是Amiga特有的。而其它一些子目录,例如pci子目录,则至少是部分平台无关的。 fs Linux支持的所有文件系统在fs目录下面都有一个对应的子目录。一个文件系统(file system)是存储设备和需要访问存储设备的进程之间的媒介。 文件系统可能是本地的物理上可访问的存储设备,例如硬盘或CD-ROM驱动器;在这两种情况下将分别使用ext2和isofs文件系统。文件系统也可能是可以通过网络访问的存储设备;这种情况下使用的文件系统是NFS。 还有一些伪文件系统,例如proc文件系统,可以以伪文件的形式提供其它信息(例如,在proc的情况下是提供内核的内部变量和数据结构)。虽然在底层并没有实际的存储设备与这些文件系统相对应,但是进程可以像有实际存储设备一样处理(NFS也可以作为伪文件系统来使用)。 include 这个目录包含了Linux源程序树中大部分的包含(.h)文件。这些文件按照下面的子目录进行分组:  include/asm-*/――这样的子目录有多个,每一个都对应着一个arch的子目录,例如include/asm-alpha,include/asm-arm,include/asm-i386等等。每个目录下的文件中包含了支持给定体系结构所必须的预处理器宏和短小的内联函数。这些内联函数很多都是全部或部分地使用汇编语言实现的,而且在C或者汇编代码中都会应用到这些文件。 当编译内核时,系统将建立一个从include/asm到目标体系结构特有的目录的符号链接。结果是体系结构无关内核源程序代码可以使用如下形式的代码来实现所需功能: #include <asm/some-file> 这样就能够将适当地体系结构特有的文件包含(#include)进来。  include/linux/――内核和用户应用程序请求特定内核服务时所使用的常量和数据结构在头文件中定义,而该目录中就包含了这些头文件。这些文件大都是平台独立的。这个目录被全部复制(更多的情况是链接)到/usr/inlude/linux下。这样用户应用程序就可以使用#include包含这些头文件,而且能够保证所包含进来的头文件的内容和内核中的定义一致。第9章将会给出有关的一个样例。  对这些文件的移植只有对于内核来说才是必须的,对用户应用程序则没有必要。移植工作可以按照如下的方式封装处理: /* … Stuff for user apps and kernel … */ #ifdef __KERNEL__ /* … Stuff for kernel only … */ #endif /* __KERNEL__ */  include/net/――这个目录供与网络子系统有关的头文件使用。  include/scsi/――这个目录供与SCSI控制器和SCSI设备有关的头文件使用。  include/video/――这个目录供与显卡和帧显示缓存有关的头文件使用。 init 这个目录下面的两个文件中比较重要的一个是main.c,它包含了大部分协调内核初始化的代码。第4章将详细介绍这部分代码。 ipc 这个目录下的文件实现了System V的进程间通讯(IPC)。在第9章中将会对它们进行详细介绍。 kernel 这个目录中包含了Linux中最重要的部分:实现平台独立的基本功能。这部分内容包括进程调度(kernel/sched.c)以及创建和撤销进程的代码(kernel/fork.c和kernel/exit.c);以上所有的以及其它部分内容将在第7章中有所涉及。但是我并不想给你留下这样的印象:需要了解的内容都在这个目录下。实际上在其它目录下也有很多重要的内容。但是,不管怎样说,最重要部分的代码是在这个目录下的。 lib lib目录包含两部分的内容。lib/inflate.c中的函数能够在系统启动时展开经过压缩的内核(请参看第4章)。lib目录下剩余的其它文件实现一个标准C库的有用子集。这些实现的焦点集中在字符串和内存操作的函数(strlen,mmcpy和其它类似的函数)以及有关sprintf和atoi的系列函数上。 这些文件都是使用C语言编写的,因此在新的内核移植版本中可以立即使用这些文件。正如本章前面部分说明的那样,一些移植提供了它们独有的高速的函数版本,这些函数通常是经过手工调整过的汇编程序,在移植后的系统使用这些函数来代替原来的通用函数。 mm 该目录包含了体系结构无关的内存管理代码。正如我们前面说明的那样,为每个平台实现最低层的原语的体系结构特有的内存管理程序是存储在arch/platform/mm中的。大部分平台独立和x86特有的内存管理代码将在第8章中介绍。 net 这个目录包含了Linux应用的网络协议代码,例如AppleTalk,TCP/IP,IPX等等。 scripts 该目录下没有内核代码,它包含了用来配置内核的脚本。当运行make menuconfig或者make xconfig之类的命令配置内核时,用户就是和位于这个目录下的脚本进行交互的。 体系结构相关和体系结构无关的代码 现在我们来估计一下体系结构相关和体系结构无关代码的相对大小。我们首先给出一些数字。完整的2.2.5的内核总共有1,725,645行代码。(顺便一提,请注意本书只包含了39,000行代码,但是我们仍然努力涵盖了相当部分的核心函数。)其中一共有392,884行代码在体系结构特有的目录之内,也就是arch/*和include/asm-*下面。我估计还有超过64,000行的代码是仅供一种体系结构专用的驱动程序。这意味着大约26%的代码是专用于特定体系结构的。 但是,对于单一一种体系结构,体系结构相关代码比例相对较小。不妨理想一点,如果某种体系结构所需要的特有代码约有50,000行,而体系结构无关代码则大约有1,250,000行,那么体系结构相关代码大概只占到4%。当然,在特定的一个内核中,并不是所有这些体系结构无关代码都会被用到,因此体系结构相关代码在特定内核中所占的比重与内核的配置有关。但是不管怎样,很显然大部分内核代码是平台独立的。 |
|
|
|
4C#
发布于:2002-10-18 20:27
Re:Linux俱乐部成立喽!!!
芦苇兄做得好,算你一个,来认识一下吧!!!
|
|
|
5C#
发布于:2002-11-23 18:14
Re:Linux俱乐部成立喽!!!
无奈!怎么无人响应呀!
--------------------
上辈子是只鱼! :-) |
|
|
6C#
发布于:2002-12-01 15:56
Re:Linux俱乐部成立喽!!!
我报名,email:[email protected]
用LINUX经历:1,年轻时候买了redhat6.0,安装失败而告终 2, 高四时(补习班)装renhat7.2,依然没有装上(怪我电脑配置太古老,我很穷) 3,考上了刷院,没有计算机,所以买到手的redhat8.0迫不得已买给别人去了 综上,本人在红帽子上没有半点经验。希望能在这里学到点有用的东西。我也希望越来越多的人挣脱微软温暖的怀抱,投入linux的怀中,享受自由和平的网络世界!欢迎大家给我发Email,我有信必复。 [ 2002-12-02 20:13:28 齐天大肾 修改 ] |
|
|
|
7C#
发布于:2003-05-24 19:14
Re:Linux俱乐部成立喽!!!
yue QQ 30748006 [email protected]
电子01计3我现在用的系统是win98+linux,linux感觉不错,是个透明的系统,学习操作系统时将很有用,可以试一试 |
|
|
8C#
发布于:2003-05-25 20:28
Re:Linux俱乐部成立喽!!!
我现在在学UNIX不知道这两者有什么区别和联系,我是不是也能参加你们的俱乐部呢?电脑这东西怎么学呀。如果你是高手能不能教教我们呢? |
|
|
|
9C#
发布于:2003-05-29 14:40
Re:Linux俱乐部成立喽!!!
yue QQ 30748006 [email protected] 活活 忘了在哪看到好像有 linux选修课了 万岁 |
|