QQ登录

只需一步,快速开始

 找回密码
 注册

QQ登录

只需一步,快速开始

查看: 23716|回复: 22

Linux内核注释

[复制链接]
发表于 2003-10-27 09:28:13 | 显示全部楼层 |阅读模式
Linux内核注释——序

《Linux内核注释》旨在给程序员和学生提供比以前更详细和更易理解的Linux内核
代码注释。作者分析了核心代码,并对重要的函数、系统调用和数据结构提供了大
量的注释。
    对《注释》系列丛书的写作灵感都来源于John Lions所著的大量流行的《
Lions' Commentary on Unix》一书。无数的计算机专业的学生在复制和使用这本
书。这本书对AT&T的Unix操作系统的早期版本的内幕进行了深刻的剖析。
    《Liunx内核注释》同样提供了对流行的功能强大的Liunx操作系统的结构和函
数实现的内幕介绍。本书的主要目标是:
    1.提供一个最新的和完整的服务器版本的完整源代码。(这本书分析的版本是
2.2.5版,也是写这本书时发布的最新版本。)
    2.提供一个对每个子系统功能的一般性概述。
    3.研究各个子系统主要的函数和数据结构。
    4.对开发者应怎样通过修改源代码来改进和扩展内核提出建议。
    本书的最后一项目标—定制--是你学习内核代码的最有说服力的原因。通过理
解内核是怎样工作的,你能够编写自己的代码用以在你的操作系统中实现所需要的
功能。如果允许其他人共享你的改进,你的代码甚至会在官方发行的内核代码中出
现,被全世界数百万计的人们所使用。
    开放源代码是指让开发者研究源代码并实现功能性扩展。Linux是全世界成长
最快的操作系统,开放源代码是其主要的原因之一。从玩游戏,到网上冲浪,到为
大大小小的ISP们提供稳定的Web服务器平台以至解决最庞大的科学难题,Linux都
能胜任全部工作。它之所以能如此强大是因为有像你一样的开发者在研究、学习并
且扩充这个系统。
你能从本书中学到什么
    这本书集中解释了Linux内核源代码的核心中专用代码行是如何运行的。你将
学习到内核最内部的子系统是怎样构造和这种构造能够实现系统功能的理由。
    本书的第一部分以易于阅读和交叉引用的格式复制了一个经过筛选的linux 内
核源代码的子集。在这本书稍后的注释中,无论一行代码在何处被引用,你都会在
这一行前面发现一个小箭头。这个箭头指出了对此行进行注释处的页号。
    源代码后是这本书的第二部分,即注释部分,注释部分对源代码进行了讨论。
注释部分的每一章讨论了一个不同的内核子系统,或者是其它的功能性逻辑组件,
例如系统调用或内存管理。注释部分大量的行号引用为你指明了所讨论代码行的确
切行号。
    在本书正文后的附录部分,简洁地覆盖了自本书主要部分完成以后内核的变化
。在附录中还包含了被内核用做软件许可证的完整的GNU常规公众许可证。最后,本
书为你提供了一个索引。通过该索引你可以查询术语或主题。这将让你更快更有效
的使用这本参考工具书。
本书的使用对象
    本书假设你能阅读C语言的代码,不怕偶尔读一些汇编语言代码。并且你想知道
一个快速的、坚固的、可靠的、健壮的、现代的、实用的操作系统是如何工作的。
一些读者也许是这样的程序员,他们想为前进中的Linux内核发展工作提供他们自己
的改进和添加内容。
如何使用本书
    用最适合你自己的方法放松地去看这本《linux 内核注释》。因为写这本书的
目的是为提供一个参考资料,你不必从头看到尾。因为注释和代码是一一对应的,
你可以从另外一个方向接近内核。
    欢迎你对我的第一本书提出意见。你可以通过e-mail和我联系。地址是:
[email protected]。 勘误表、更新和其它一些有用信息可以通过访问
http://www.ScottMaxwell.org/lckc.html得到。
 楼主| 发表于 2003-10-27 09:29:01 | 显示全部楼层
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及其镜像站点提供了最新的可供下载的内核版本,而且同
时包括稳定和开发版本。如果你愿意的话,不需要很长时间,这些站点所提供的最
新版本中就可能包含了你的一部分源程序代码。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:30:22 | 显示全部楼层
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。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:31:39 | 显示全部楼层
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%。当然,在特定的一个内核中
,并不是所有这些体系结构无关代码都会被用到,因此体系结构相关代码在特定内
核中所占的比重与内核的配置有关。但是不管怎样,很显然大部分内核代码是平台
独立的。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:32:43 | 显示全部楼层
Linux内核注释——第4章 系统初始化


当你想要运行程序时,你需要把程序的文件名敲入shell――或者更为流行的,在
如GNOME或者KDE等之类桌面环境中点击相应的图标――这样就能将其装载进内核并
运行。但是,首先必须有其它的软件来装载并运行内核;这通常是诸如LOADLIN或
者LILO之类的内核引导程序。更进一步,我们还需要其它的软件来装载运行内核引
导程序――称之“内核引导程序的引导程序”――而且看起来似乎运行内核引导程
序的引导程序也需要内核引导程序的引导程序的引导程序,等等,这个过程是无限
的。
这个无限循环的过程必然最终在某个地方终止,这就是硬件。因此,在最低的层次
上,启动系统的第一步是从硬件中获得帮助。该硬件总是运行一些短小的内置程序
――软件,但是这些软件是被固化在只读存储器中,存储在已知地址中。因此,在
这种情况下就不需要软件引导程序了――它能够运行更大更复杂的程序,直到内核
自身装载成功为止。按照这种方式,系统自己的引导过程(bootstrap)会引发系
统的启动,当然这只是术语“系统引导(booting)”的一个比喻。虽然不同体系
结构的引导过程的具体细节差异很大,但是它们的原则都基本相同。
前面的工作都完成以后,内核就已经成功装载了。随后内核可以初始化自身以及系
统的其它部分。
本章首先将简单介绍基于x86 PC机的典型自启动方式,接着回顾一下每一步工作在
什么时机发生,最后我们还要介绍的是内核的相应部分。
引导PC机
本节简要介绍x86 PC是如何引导的。本节的目的不是让你精通PC是怎样引导的――
这超出了本书的范围――而是向你展示特定体系结构一般的引导方式,为下文中的
内核初始化进行铺垫。
首先,机器中的每个CPU都要自行初始化,接着可能要用几分之一秒的时间来执行
自测试。在多处理器的系统中,这个过程会更复杂些――但是实际上也并不多。在
双处理器的Pentium系统中,一个CPU总是作为主CPU存在,另外一个CPU则是辅CPU
。主CPU执行启动过程中的剩余工作,随后内核才会激活辅CPU。在多处理器的
Pentium Pro系统中,CPU必须根据Intel定义的算法“抢夺标志”――来动态决定
由哪个CPU启动系统。取得标志的CPU启动系统,随后内核激活其它的CPU。无论是
哪种情况,启动程序的剩余部分只与一个CPU有关。这样,在随后的一段时间内,
我们可以认为该系统中只有一个CPU是可用的,而不考虑其它的CPU,或者说这些
CPU被暂时隐藏了。另一方面,内核还需要明确的激活所有其它的CPU――这一点你
可以在本章后续部分看到。
接下来,    CPU从0xfffffff0单元中取得指令并执行,这个地址非常接近于32位CPU
的最后可用的地址。因为大多数PC都没有4GB的RAM,所以通常在这个地址上并没有
实际内存的。内存硬件可以虚拟使用它。对那些确实有4GB内存的机器来说,它们
也只是仅仅损失了供BIOS使用的顶端地址空间末尾的少量内存(实际上BIOS在这里
只保留了64K的空间――这种损失在4GB的机器中是可以忽略的)。
该地址单元中存储的指令是一条跳转指令,这条指令跳转到基本输入输出(BIOS)
代码的首部。BIOS内置在主板中,它主要负责控制系统的启动。请注意CPU实际上
并不真正关心BIOS是否存在,这样就使得在诸如用户定制的嵌入系统之类的非PC体
系结构的计算机中使用Intel的CPU成为可能。CPU执行在目标地址中发现的任何指
令,在这里使用跳转指令转移到BIOS只是PC体系结构的一部分。(实际上,跳转指
令自己是BIOS的一部分,但是这不是考虑这个问题的最方便的方法。)
BIOS使用内置的规则来选择启动设备。通常情况下,这些规则是可以改变的,方法
是在启动过程开始时按下一个键(例如,在我的系统中是Delete键)并通过一些菜
单选项浏览选择。但是,通常的过程是BIOS首先试图从软盘启动,如果失败了,就
再试图从主硬盘上启动。如果又失败了,就再试图从CD-ROM上启动。为了使问题更
具体,这里讨论的情况假定是最普通的,也就是启动设备是硬盘。
从这种启动设备上启动,BIOS读取第一个扇区的信息――首512个字节――称之为
主引导记录(MBR)。接下来发生的内容有赖于Linux是怎样在系统上安装的。为使
讨论形象具体,我们假定LILO是内核的载入程序。在典型的设置中,BIOS检测MBR
中的关键数字(为了确认该数据段的确是MBR)并在MBR中检测引导扇区的位置。这
一扇区包含了LILO的开始部分,然后BIOS将其装入内存,开始执行。
注意我们现在已经实现了从硬件和内置软件的范围到实际软件范围的转变,从有形
范围到无形范围,也就是说从你可以接触的部分到不可接触的部分。
下面就是LILO的责任了。它把自己其余的部分装载进来,在磁盘上找到配置数据,
这些数据指明从什么地方可以得到内核,启动时要通过什么选项。LILO接着装载内
核到内存并跳转到内核。
通常,内核以压缩形式存储,只有少量指令足以完成解压缩的任务,也就是自解压
可执行文件,是以非压缩形式存储的。因此,内核的下一步工作是自解压缩内核镜
像。到这里,内核就已经完成了装载的过程。
下面是到现在进行的步骤地简要描述:
1.  CPU初始化自身,接着在固定位置执行一条指令。
2.  这条指令跳转到BIOS中。
3.  BIOS找到启动设备并获取MBR,该MBR指向LILO。
4.  BIOS装载并把控制权转交给LILO。
5.  LILO装载压缩内核。
6.  压缩内核自解压并把控制权转交给解压的内核
正如你所见到的,引导过程每一步都将你带入更大量更复杂的代码块中,一直到最
后成功地运行了内核为止。
依赖于你计算层次的方式,CPU成为内核引导程序的引导程序的引导程序的引导程
序(CPU装载BIOS,BIOS装载LILO,LILO装载压缩内核,压缩内核装载解压内核;
但是你可以合理的考虑是否这些步骤都满足引导程序的定义)。
初始化Linux内核
在内核成功装入内存(如果需要就解压缩)以及一些关键硬件,例如已经在低层设
置过的内存管理器(MMU,请参看第8章)之后,内核将跳转到start_kernel(
19802行)。这个函数完成其余的系统初始化工作――实际上,几乎所有的初始化
工作都是由这个函数实现的。因此,start_kernel就是本节的核心。
start_kernel
19802:__init标示符在gcc编译器中指定将该函数置于内核的特定区域。在内核完
成自身初始化之后,就试图释放这个特定区域。实际上,内核中存在两个这样的区
域,.text.init和.data.init――第一个是代码初始化使用的,另外一个是数据初
始化使用的。(诸如可以在进程间共享的代码和字符串常量之类的“文本(Text)
”是在可执行程序中的“纯区域”中使用的一个术语。)另外你也可以看到
__initfunc和__initdata标志,前者和__init类似,标志初始化专用代码,后者则
标志初始化专用数据。
19807:如前所述,即使在多处理器系统中,在启动时也只使用一个CPU。Intel称
之为引导程序处理器(Bootstrap Processor,简称为BSP),它在内核代码的某些
地方有时也称之为BP。BSP首次运行这一行时,跳过后面的if语句,并减小
boot_cpu标志,从而当其它CPU运行到此处时,都要运行if语句。等到其它CPU被激
活执行到这里时,BSP已经在idle循环中了(本章稍后会更详细的讨论这个问题)
,initialize_secondary(4355行)负责把其它CPU加入到BSP中。这样,其它CPU
就不用执行start_kernel的剩余部分了――这也是一件好事,因为这意味着不用再
进行对许多硬件进行冗余初始化等工作了。
    顺便说一下,这种奇异的小小的改动只有对于x86是必需的;对于其它平台,调用
smp_init完全可以处理SMP设置的其它部分,这一点马上就会讨论。因此,其它平
台的initialize_secondary的定义都是空的。
19816:打印内核标题信息(20099行),这里显示了有关内核如何编译的信息,包
括在什么机器上编译,什么时间编译,使用什么版本的编译器,等等。如果中间任
何一步发生了错误,在寻找机器不能启动的原因时查明内核的来源是一个有用的线
索。
19817:初始化内核自身的部分组件――内存,硬件中断,调度程序,等等。尤其
是setup_arch函数(19765行)完成体系结构相关的设置,此后在command_line(
传递到内核的参数,在下面讨论)、memory_start和memory_end(内核可用物理地
址范围)中返回结果。下面这些函数都希望驻留在内存低端的;它们使用
memory_start和memory_end来传递该信息。在函数获得所希望的值后,返回值指明
了新的memory_start的值。
19823:分析传给内核的各种选项。parse_options函数(19707行,在随后的分析
内核选项一节中讨论)也设置了argv和envp的初值。
19833:内核运行过程中也可以自行对所进行的工作进行记录,周期性地对所执行
的指令进行抽样,并使用所获得的结果更新表格。这在定时器中断过程中通过调用
x86_do_profile(1896行)来实现,该部分将在第6章中介绍。
    如图4.1中说明的那样,这个表格把内核划分为几个大小相同的范围,并简单跟踪
在一次中断的时间内每个范围中运行多少条指令。这种记录当然是非常粗糙的――
甚至不是依据函数和行号进行划分的,而只是使用近似的地址――但是这样代价很
低、快速、短小,而且有助于专家判断最关键的问题要点。每个表格条目所涉及到
地址的多少――还有问题发生地点的不确定性――可以通过简单修改prof_shift(
26142行)来调节。profile_setup(19076行,在本章中后面讨论)可以让你在启
动的时候设置prof_shift的值,这样比为修改这个数字而重新编译内核要清晰方便
得多。


图4.1 描述用缓存(profiling buffer)

    这个if程序块为记录表格分配内存,并把所有项都清零。注意到如果prof_shift
是0(缺省值),那么记录功能就被关掉了,if程序段不再被执行,也不为表格分
配空间。
19846:内核通过调用sti(13104行是UP版本的――注意该主题在第6章中有更详细
的介绍)开始接收硬件中断。首先需要激活定时器中断,以便后来对
calibrate_delay(19654行)的调用可以计算机器的BogoMIPS的值(在下一节“
BogoMIPS”中介绍)。因为一些设备驱动程序需要BogoMIPS的值,所以内核必需在
大部分硬件、文件系统等等初始化之前计算出这个值来。
19876:测试该CPU的各种缺陷,比如Pentium F00F缺陷(请参看第8章),记录检
测到的缺陷,以便于内核的其它部分以后可以使用它们的工作。(为了节省空间起
见,我们省略掉了check_bugs函数。)
19882:调用smp_init(19787行),它又调用了其它的函数来激活SMP系统中其它
CPU:在x86的平台上,smp_boot_cpus(4614行)初始化一些内核数据结构,这些
数据结构跟踪检测另外的CPU并简单的将其改为保持模式;最后smp_commence(
4195行)使这些CPU继续执行。
19883:把init函数作为内核线程终止,这比较复杂;请参看本章后面有关init的
讨论。
19885:增加idle进程的need_resched标志,这样做的原因在此时可能还比较模糊
。直到读完了第5、6、7章以后,才能有个清楚的概念;但是,在下一个定时器中
断结束之前(在第6章中讨论),system_call(171行,在第5章中讨论)函数中会
注意到idle进程的need_fesched标志增加了,并且调用schedule(26686行,第7章
)释放CPU,并将其赋给更应该获取CPU的进程。
19886:已经完成了内核初始化的工作――或者不管怎样,已经把需要完成的少量
责任传递给了init――所剩余的工作不过是进入idle循环以消耗空闲的CPU时间片
。因此,本行调用cpu_idle(2014行)――idle循环。正如你可以从cpu_idle本身
可以发现的一样,该函数从不返回。然而,当有实际工作要处理时,该函数就会被
抢占。
    注意到cpu_idle只是反复调用idle系统调用(下一章将讨论系统调用),它通过
sys_idle(2064行)实现真正的idle循环――2014行对应UP版本,2044行针对SMP
版本。它们通过执行hlt(对应“halt”)指令把CPU转入低功耗的“睡眠”状态。
只要没有实际的工作处理,CPU都将转入这种状态。
BogoMIPS
BogoMIPS的数字由内核计算并在系统初始化的时候打印。它近似的给出了每秒钟
CPU可以执行一个短延迟循环的次数。在内核中,这个结果主要用于需要等待非常
短周期的设备驱动程序――例如,等待几微秒并查看设备的某些信息是否已经可用

由于没有正确理解BogoMIPS的含义,BogoMIPS在各处都被滥用,就仿佛它可以满足
人类最原始、最深层次的需求:把所有计算机性能的信息简化为一个数字。“
BogoMIPS”中的“Bogo”部分来源于“伪(bogus)”,就正是为了防止这种用法
:虽然这个数字比大多数性能比较有效很多,但是它仍然是不准确的、容易引起误
解的、无用的和不真实的,根本不适合将它用于机器间差别的对比。但是这个数字
仍然非常吸引人,这也正是我们在这里讨论这个问题的原因。(顺便说一下,
BogoMIPS 中“MIPS”部分是“millions of instructions per second(百万条指
令每秒)”的缩写,这是计算机性能对比中的一个常用单位。)
calibrate_delay
19654:calibrate_delay是近似计算BogoMIPS数字的内核函数。
19622:作为第一次估算,calibrate_delay计算出在每一秒内执行多少次__delay
循环(6866行),也就是每个定时器滴答(timer tick)――百分之一秒――内延
时循环可以执行多少次。
19664:计算一个定时器滴答内可以执行多少次循环需要在滴答开始时就开始计数
,或者应该尽可能与它接近。全局变量jiffies(16588行)中存储了从内核开始保
持跟踪时间开始到现在已经经过的定时器滴答数;第6章中将介绍它的实现方式。
jiffies保持异步更新,在一个中断内——每秒一百次,内核暂时挂起正在处理的
内容,更新变量,然后继续刚才的工作。如果不这样处理,下一行的循环就永远不
可能退出。从而,如果jiffies不声明为volatile――简单的说,这个值变化的原
因对于编译器是透明的――gcc仍然可能对该循环进行优化,并引起该循环进入不
能退出的状态。虽然目前的gcc还没有如此高的智能,然而它的维护者应该完全能
够为它实现这种智能。
19669:定时器又前移了一个滴答,因此又产生一个新的滴答。下一步是要等待
loops_per_sec延时循环调用定时器循环,接着检测是否最少有一个完整的滴答已
经完成。如果是这样,就退出首次近似估算循环;如果没有,就把loops_per_sec
的值加倍,然后重新启动这个过程。
    这个循环的正确性依赖于如下的事实:现有的机器在任何地方都不能每秒执行
232次延时循环――对于64位机来说则远低于每秒264次――虽然这只是一个微不足
道的问题。
19677:现在内核已经清楚loops_per_sec循环调用延时循环在这台机器上要花费超
过百分之一秒的时间才能完成,因此,内核将重新开始进行估算。为了提高效率,
内核使用折半查找算法计算loops_per_sec的实际值,我们假定开始的时候,实际
值在现在计算结果和其一半之间――实际值不可能比现在计算值还大,但是可以(
而且可能)稍微小一点。
19681:和前面使用的方式一样,calibrate_delay查看是否这个loops_per_sec已
经减小了的值还是比较大,需要耗费一个完整的定时器间隔。如果还是相当大,实
际值应该小于当前计算值或者就是当前值,因此,使用更小的值继续查询;如果不
够大,就使用一个更大的值继续查询。
19691:内核有一种很好的方法来计算一个定时器滴答中执行延时循环的次数。这
个数字乘以一秒内滴答的数量就得到了每秒内可以执行的延时循环的次数。这种计
算只是一种估算,乘法也累积了误差,因此结果并不能精确到纳秒。但是这个数字
供内核使用已经足够精确了。
19693:为了让用户感到激动,内核打印出这个数字。注意这里明显省略了%f的格
式限定――内核尽量避免浮点数运算。这个计算过程中最有用的常量是500,000;
它是用一百万除以2得来,理由是每秒钟执行一百万条指令,而每个delay循环的核
心是2条指令(decl和一条跳转指令)。
分析内核选项
parse_options函数分析由内核引导程序发送给内核的启动选项,在初始化过程中
按照某些选项运行,并将剩余部分传送给init进程(在本章后面部分提到)。这些
选项可能已经存储在配置文件中了,也可能是由用户在系统启动时敲入的――内核
并不关心这些。类似的细节全部是内核引导程序应该关注的内容。
parse_options
19707:参数已经收集在一条长的命令行中,内核被赋给指向该命令行头部的一个
指针;内核引导程序在前面已经将该行存储在一个指定地址中。
19718:中断下一个参数,保持指向下一个参数的指针以供下一次循环使用。注意
系统使用空格而不是通常的空白来分隔内核参数;制表符并不能把当前参数和下一
个参数分隔开。如果发现了分隔字符空格,下一行就使用字节0覆盖,这样line可
以作为包含有唯一一个内核选项的标准C字符串来使用了。如果没有发现空格,就
该函数关心的内容而言,其余的部分都具有相同的属性――这只有在处理line中最
后一个选项的情况下才会发生,循环就会在下次开始时结束。
    注意该代码不会跳过多个空格。假设line值如下所述(两个空格):
    rw  debug
    这会被当作三个选项:“rw”,“”(空字符串)和“debug”。因为空字符串不
是有效的内核选项,它将会被传递到初始化的过程(这一点随后就可以看到)――
这当然不是用户所希望的。因此,内核引导程序应该负责对多个空格进行压缩。
LILO通过忽略用户多敲的空格,完美的解决了这个问题。
19721:现在开始解释这些选项。最前面的两个选项――ro和rw――指明内核要装
载根文件系统,也就是根目录( / 目录)所在的位置,而分别处于只读和读/写模
式。
19729:第三种可能性,debug,增加了调试信息的数量;这些调试信息要通过调用
do_syslog打印出来(25724行)。
19733:开始几个选项是简单的独立标志,它们并不使用参数。内核也可以辨认形
为option=value的选项。本行就是一个例子,这里内核引导程序定义了一个命令来
代替init运行;它使用init=/some/other/program的形式。这里的代码舍弃了
init= 部分,为随后init的使用而把剩余部分在execute_command中保存起来(
20044行,后面会讨论到)。和其它大部分参数的处理方法不同,本处功能不能在
checksetup(19612行,马上就讨论到)中实现,这是因为它改变了该函数的局部
变量。很快,你就可以看到前面三个选项之所以也在这里处理而不是在
checksetup中处理的原因。
19745:大部分内核选项都是由checksetup函数分析的。如果checksetup处理了某
个选项,就返回真值,循环继续进行。
19750:否则,line中没有已经被辨认的内核选项。在这种情况下,它被作为一个
供init进程使用的选项或者环境变量来处理――如果其形式为envar=value,就作
为环境变量处理;否则,就作为选项处理。如果argv_init和envp_init(分别见
19057和19059行)数组中有足够的空间,选项和环境变量就存储在里面供以后
init函数使用。
    这解释了从19736行开始的注释。字符串auto并不是任何内核选项的前缀,因此它
应该被作为init的一个参数存储在argv_init数组中――这在大多数情况下都是可
行的,因为auto是init可以识别的选项。但是,当使用init=的形式给出内核选项
时,通常是执行shell而不是init,auto会使shell混淆;因此,安全一点的方法是
,parse_options在此处忽略所有与此有关的init参数。
    奇怪的是,当argv_init或者envp_init空间用完时,整个循环就结束了。仅仅因
为argv_init的空间用完了并不意味着line中就不再含有init使用的环境变量,反
之亦然。此外,可能还剩下许多内核选项没有处理。当你考虑到MAX_INIT_ARGX(
19029行)和MAX_INIT_ENVS(19030行)都通过使用#define被预定义为8――这是
一个很容易超过的下限――这种行为就更奇怪了。如果在19752行和19756行的
break改成continue,那么循环可以继续处理内核选项,而不会写入超过
argv_init和envp_init数组界限的空间。如果command_line中仍然包含有并不是为
init而定义的内核选项,那么这一点就是非常重要的。
19760:所有的内核选项都处理完成了。最后一步是要使用NULL填充argv_init和
envp_init数组的末尾,从而使得init可以知道在哪里终止。
checksetup
19612:checksetup函数负责进行大部分内核选项的处理过程。它把这些内核选项
分为三类:一类使用内核普通参数来分析=sign之后的部分;另一类自行分析
=sign之后的部分;还有一类自行分析整个行,包括= sign前面的部分和= sign后
面的部分。第一类被认为是使用“现成”的参数,这与为第二类提供的“原始”参
数相对应。最后一类只由一个IDE驱动程序组成;内核首先在19619行检查并处理这
种情况,以使其不会在随后的处理中造成麻烦。
19625:接下来,checksetup扫描整个raw_params数组(19552行)并判断是否该内
核选项应该不加处理的保留。raw_params中的元素是struct kernel_param类型(
19223行)的,它把内核选项前缀和装载选项时调用的函数联系起来。如果数组中
的某些项的str成员以line为前缀,就会调用line后面的相应函数(也就是前缀之
后的部分),随后checksetup会返回一个非零值以表明它已经对该内核选项进行了
处理。raw_params数组以两个NULL结束,因此在检测到str成员是NULL时,循环就
可以结束了。在这种情况下,显然循环已经到达了raw_params数组的结尾,但是仍
然没有找到匹配的情况。当然,测试setup_func成员也可以取得同样好的效果。
    这个循环说明了一点:与大多数内核非常不同的是,这里的初始化并不需要尽可
能的快。如果内核比从前多用几微秒来启动,这并没有什么实际的损失――毕竟用
户应用程序还没有开始运行,所以他们并没有损失什么东西。
    最终结果是代码效率很低,而且存在很多优化的可能。例如,raw_params数组中
字符串的长度可以在raw_params中暂存,而不用在19626行多次重复计算。更好的
解决方法是,可以把raw_params数组中的项按照字符顺序排序,这样checksetup就
可以进行折半查找。
    在raw_params的情况中实现排序并没有什么障碍,但是这样也可能并不能获得很
大的优势,因为折半查找的优点只有在比较大的数组中才能充分表现出来(所谓比
较大的确切值在不同的环境中也有所不同)。raw_params的姊妹数组
cooked_params(19228行)当然是足够大的,可以显示出折半查找的优势;但是这
样就引发了一个新的问题:对cooked_params进行排序比较难用,因为这可能需要
分隔一些#ifdef程序段――请参看从19268行到19272行的例子。进一步说,因为算
法只是查找前缀,而不使用完全匹配,在遍历数组中的各个项时对遍历次序比较敏
感,所以这种特性在使用不同的查找次序时就很难再保持了。然而,这些问题并不
是不可克服的(程序员可以预先静态地为引导程序建立一颗前缀树),如果性能在
这里是主要因素,那么这种努力也是值得的。但是,由于性能在这里并不是主要问
题,所以简单性才被作为最重要的因素体现出来。
    即使这样,在类似的root_dev_names数组(19085行)中――这个数组把硬件设备
名的前缀映射到它们的主ID号上――开发者仍然可以简单地通过把比较常用的项(
IDE和SCSI磁盘)放在不太常用的项(串口IDE CDs)的前面以节省出一点性能。但
是我在raw_params或cooked_params中并没有发现与之类似的模式。
    另外一件需要注意的事是:现在你可以猜想一下为什么ro,rw和debug选项在
parse_options中测试而不在这里测试――parse_options要检测精确的匹配,但是
checksetup只检测前缀。作为一个特殊的情况,ro选项碰巧正好是root=(19553行
)的前缀,这样如果这三个选项彼此合并,就需要仔细处理了。这似乎仍然是一个
相当无力的原因。考虑一下noinitrd选项(19251行)。这是cooked_params的一个
项,因而只需要匹配前缀,而且与之相关联的设置函数(no_initrd,19902行)将
忽略所有可能已经传递给它们的参数――这正像ro,rw和debug被包含在
cooked_params中时所可能进行的工作一样。
19632:这个循环为cooked_params数组的处理工作和前面一个循环为raw_params数
组的处理工作相同。这两个循环(当然不包括循环使用的数组)间的唯一区别是本
循环在调用设置函数之前,使用get_options(19062行)处理line中=sign后面的
部分。简单的说,get_options使用10个负整数填充ints[1]到ints[10]。ints[0]
中是ints中使用元素的个数――也就是,它记录了存储在ints中的
intsget_options数量。接着这个数组将被传递给设置函数,该设置函数则会按照
自己喜欢的方式对该数组内容进行解释。
19640:返回0,说明line中所包含的内核选项不能被函数理解。
profile_setup
19076:profile_setup是checksetup调用的设置函数的一个完美的例子:这个函数
十分短小,使用ints参数处理了部分内容。而且到目前为止你也应该对它的目的有
了一定了解。正如前面提到的一样,用户可以在启动的时候设置prof_shift的值―
―好,这里正是它的实现方式。当内核启动过程提供profile=选项时,就调用
profile_setup函数。前缀字符串和函数在19235行被联系在一起。注意这是在
cooked_params中,因此profile_setup取得的是处理过的参数。
19079:如果参数中存在profile=的形式,就使用profile=后面的第一个数字作为
prof_shift的新值。选项给出的其它参数都被简单的忽略了。
19081:如果给出了profile=选项,但是没有为它提供参数,prof_shift的缺省值
就是2。这个缺省值有些奇怪,因为我们已经知道,这意味着使用四分之一的内核
可用内存来配置其余部分――这是一个很大的开销。但是另一方面,使用这些内存
有助于更精确的定位问题热点――只有很少的几条指令存在不确定性,这样应该比
较容易地把问题限制在一两行源程序代码内。那张图也并不是像我所画的那样简单
:因为图中只描述了内核代码,这种开销还不到内核所有内存空间的25%,但是对
于所覆盖的代码量来说却并不止25%。
init
init从许多方面看都是一个非常特殊的进程。这是内核运行的第一个用户进程,它
要负责触发其它必需的进程以使系统作为一个整体进入可用的状态。这些工作由
/etc/inittab文件控制,通常包括设置getty进程以接受用户登录;建立网络服务
,例如FTP和HTTP守护进程;等等。如果没有这些进程,用户就不可能完成多少工
作,这样成功启动内核就显得没有多大意义了。
这种设计的另外一个重要的副作用是init是系统中所有进程的祖先。init产生
getty进程,getty进程产生login进程,login进程产生你自己的shell,使用自己
的shell,可以产生每一个你运行的进程。在所有的结果中,这有助于确保内核进
程表中的所有项最终都能够得到处理。进程结束以后将其清除(回收)的工作首先
应由其父进程完成;如果父进程已经退出,那么祖父进程就要担负起这种责任;如
果祖父进程已经退出,那么曾祖父进程就要担负起这种责任,周而复始。通过这种
方式,从不退出的init进程就可能要负责回收其它进程。
因此,为了确保这些重要的工作都能正确执行,内核初始化进程所需要做的最后一
步工作就是创建init进程,接下来就加以描述。
init
20044:unused参数来源于该函数的非常规调用。init函数――不要和init进程搞
混了,后者是它随后要创建的――作为内核线程开始生命周期,一个作为内核的一
部分运行的进程。(如果你编写过多线程的程序,这里的内核线程可能会同你所已
经知道的线程意义有所不同――在那种意义下,它不是一个内核线程。)实际上,
init函数就像是新进程使用的剥离出来了的main函数,unused参数是一个独立的指
针,其值指向为给定进程所提供的信息――这比通常使用argc,argv和envp参数传
递的信息要少得多。init函数碰巧不需要额外的信息,因此这个参数命名为
unused,就是要强调这一点。
    为了确保在这一点上你不会产生困惑,我们在这里再对整个机制进行扼要重复:
init函数是内核的一部分;它在内核中作为内核的一个独立的执行部分运行;也就
是说,无论从哪个方面看它都是内核代码。但是,init进程就不是这样了。在某些
方面,init进程是一个特殊的进程,但是不属于内核本身;其代码存储在磁盘上单
独的可执行映像中,这和其它程序一样。因为init函数后来产生init进程,而它自
己又恰好作为进程运行,这样就很容易产生混淆。
    因为idle进程已经占据了进程ID号(PID)0,init(当然是init)就被赋值为下
一个可用的PID,也就是1。(进程ID在第7章中讨论。)内核重复假定PID为1的进
程是init,因此这种特性在没有充分地相互作用,也就是没有同步地进行修改的情
况下是不能改变的。
20046:调用lock_kernel(17492行对应UP版本;10174行对应SMP版本)执行后续
几行,而不会受到其它会受到随后工作的影响的内核模块的干扰。内核锁随后在
20053行被释放。
20047:调用do_basic_setup(19916行)初始化总线并随同其它工作产生一些其它
内核线程。
20052:内核已完全完成初始化了,因此free_initmen(7620行)可以舍弃内核的
.text.init节的函数和.data.init节的数据。所有使用__initfunc标记过的函数和
使用__initdata标记过的数据现在都不能使用了,它们曾经获得的内存现在也可能
重新用于其它目的了。
20055:如果可能,打开控制台设备,这样init进程就拥有一个控制台,可以向其
中写入信息,也可以从其中读取输入信息。实际上init进程除了打印错误信息以外
,并不使用控制台,但是如果调用的是shell或者其它需要交互的进程,而不是
init,那么就需要一个可以交互的输入源。如果成功执行open,/dev/console就成
为init的标准输入源(文件描述符0)。
20059:调用dup打开/dev/console文件描述符两次,这样,init就也使用它供标准
输出和标准错误使用(文件描述符1和2)。假设20055行的open成功执行(正常情
况),init现在就有三个文件描述符――标准输入、标准输出以及标准错误――全
都加载在系统控制台之上。
20067:如果内核命令行中给出了到init的直接路径(或者别的可替代的程序),
现在就试图执行init。
    因为当execve成功执行目标程序时并不返回,只有当前面的所有处理过程都失败
时,才能执行相关的表达式。接下来的几行在几个地方查找init,按照可能性由高
到低的顺序依次是:首先是/sbin/init,这是init标准的位置;接下来是两个可能
的位置,/etc/init和/bin/init。
20072:这些是init可能出现的所有地方。如果现在还没有出现,init就无法找到
它的这个同名者了,机器可能就崩溃了。因此,它就会试图建立一个交互的shell
(/bin/sh)来代替。现在init最后的希望就是root用户可以修复这种错误并重新
启动机器。(可以肯定,root也正是希望如此。)
20073:init甚至不能创建shell――一定是发生了什么问题!好,按照它们所说的
,当所有其它情况都失败时,调用panic(25563行)。这样内核就会试图同步磁盘
,确保其状态一致,然后暂停进程的执行。如果超过了内核选项中定义的时间,它
也可能会重新启动机器。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:33:51 | 显示全部楼层
Linux内核注释—— 第5章 系统调用

大部分介绍Unix内核的书籍都没有仔细说明系统调用,我认为这是一个失误。实际
上,我们实际需要的系统调用现在已经十分完美。因此,从某种意义上来说,研究
系统调用的实现是无意义的——如果你想为Linux内核的改进贡献自己的力量,还
有其它许多方面更值得投入精力。
然而,对于我们来说,仔细研究少量系统调用是十分值得的。这样就有机会初步了
解一些概念,这些概念将随本书发展而进行详细介绍,例如进程处理和内存。这使
得你可以趁机详细了解一下Linux内核编程的特点。这包括一些和你过去在学校里
(或工作中)所学的内容不同的方法。和其它编程任务相比,Linux内核编程的一
个显著特点是它不断同三个成见进行斗争——这三个成见就是速度、正确和清晰—
—我们不可能同时获取这三个方面…至少并不总是能够。
什么是系统调用
系统调用发生在用户进程(比如emacs)通过调用特殊函数(例如open)以请求内
核提供服务的时候。在这里,用户进程被暂时挂起。内核检验用户请求,尝试执行
,并把结果反馈给用户进程,接着用户进程重新启动,随后我们就将详细讨论这种
机制。
系统调用负责保护对内核所管理的资源的访问,系统调用中的几个大类主要有:处
理I/O请求(open,close,read,write,poll等等),进程(fork,execve,
kill,等等),时间(time,settimeofday等等)以及内存(mmap,brk,等等)
的系统调用。几乎所有的系统调用都可以归入这几类中。
然而,从根本上来说,系统调用可能和它表面上有所不同。首先,在Linux中,C库
中对于一些系统调用的实现是建立在其它系统调用的基础之上的。例如,waitpid
是通过简单调用wait4实现的,但是它们两个都是作为独立的系统调用说明的。其
它的传统系统调用,如sigmask和ftime是由C库而不是由Linux内核本身实现的;即
使不是全部,至少大部分是如此。
当然,从技巧的一面来看这是无害的——从应用程序的观点来看,系统调用就和其
它的函数调用一样。只要结果符合预计的情况,应用程序就不能确定是否真正使用
到了内核。(这种处理方式还有一个潜在的优点:用户可以直接触发的内核代码越
少,出现安全漏洞的机会也就越少。)但是,由于使用这种技巧所引起的困扰将会
使我们的讨论更为困难。实际上,系统调用这一术语通常被演讲者用来说明在第一
个Unix版本中的任何对系统的调用。但是在本章中我们只对“真正”的系统调用感
兴趣——真正的系统调用至少包括用户进程对部分内核代码的调用。
系统调用必须返回int的值,并且也只能返回int的值。为了方便起见,返回值如果
为零或者为正,就说明调用成功;为负则说明发生了错误。就像老练的C程序员所
知道的一样,当标准C库中的函数发生错误时会通过设置全局整型变量errno指明发
生错误的属性,系统调用的原理和它相同。然而,仅仅研究内核源程序代码并不能
够获得这种系统调用方式的全部意义。如果发生了错误,系统调用简单返回自己所
期望的负数错误号,其余部分则由标准C库实现。(正常情况下,用户代码并不直
接调用内核系统函数,而是要通过标准C库中专门负责翻译的一个小层次(thin
layer)实现。)我们随便举一个例子,27825行(sys_nanosleep的一部分)返回
-EINVAL指明所提供的值越界了。标准C库中实际处理sys_nanosleep的代码会注意
到返回的负值,从而设置errno和EINVAL,并且自己返回-1给原始的调用者。
在最近的内核版本中,系统调用返回负值偶尔也不一定表示错误了。在目前的几个
系统调用中(例如lseek),即使结果正确也会返回一个很大的负值。最近,错误
返回值是在-1到-4095范围之内。现在,标准C库实现能够以更加成熟和高级的方式
解释系统调用的返回值;当返回值为负时,内核本身就不用再做任何特殊的处理了

中断、内核空间和用户空间
我们将在第6章中介绍中断和在第8章中介绍内存时再次明确这些概念。但是在本章
中,我们只需要粗略地了解一些术语。
第一个术语是中断(interrupt),它来源于两个方面:硬件中断,例如磁盘指明
其中存放一些数据(这与本章无关);和软件中断,一种等价的软件机制。在x86
系列CPU中,软件中断是用户进程通知内核需要触发系统调用的基本方法(出于这
种目的使用的中断号是0x80,对于Intel芯片的研究者来说更为熟悉的是INT 80)
。内核通过system_call(171行)函数响应中断,这一点我们马上就会介绍。
另外两个术语是内核空间(kernel space)和用户空间(user space),它们分别
对应内核保留的内存和用户进程保留的内存。当然,多用户进程也经常同时运行,
而且各个进程之间通常不会共享它们的内存,但是,任何一个用户进程使用的内存
都称为用户空间。内核在某一个时刻通常只和一个用户进程交互,因此实际上不会
引起任何混乱。
由于这些内存空间是相互独立的,用户进程根本不能直接访问内核空间,内核也只
能通过put_user(13278行)和get_user(13254行)宏和类似的宏才可以访问用户
空间。因为系统调用是进程和进程所运行的操作系统之间的接口,所以系统调用需
要频繁地和用户空间交互,因此这些宏也就会不时的在系统调用中出现。在通过数
值传递参数的情况下并不需要它们,但是当用户把指针——内核通过这个指针进行
读写——传递给系统调用时,就需要这些宏了。
如何激活系统调用
系统调用的的激活有两种方法:system_call函数和lcall7调用门(call gate)(
请参看135行)。(你可能听说过还有一种机制,syscall函数,是通过调用
lcall7实现的——至少在x86平台上是如此——因此,它并不是一个特有的方法。
)本节将细致地讨论一下这两种机制。
在阅读的过程中请注意系统调用本身并不关心它们是由system_call还是由lcall7
激活的。这种把系统调用和其实现方式区别开来的方法是十分精巧的。这样,如果
出于某种原因我们不得不增加一种激活系统调用的方法,我们也不必修改系统调用
本身来支持这种方法。
在你浏览这些汇编代码之前要注意这些机器指令中操作数的顺序和普通Intel的次
序相反。虽然还有一些其它的语法区别,但是操作数反序是最令人迷惑的。如果你
还记得Intel的语法:
mov eax, 0
(本句代码的意思是把常数0传送到寄存器EAX中)在这里应该写作:
mov1 $0, %eax
这样你就能够正确通过。(内核使用的语法是AT&T的汇编语法。在GNU汇编文档中
有更多资料。)
system_call
system_call(171行)是所有系统调用的入口点(这是对于内部代码来说的;
lcall7用来支持iBCS2,这一点我们很快就会讨论)。正如前面标题注释中说明的
一样,目的是为普通情况简单地实现直接的流程,不采用跳转,因此函数的各个部
分都是离散的——整体的流量控制已经因为要避免普通情况下的多分支而变得非常
复杂。(分支的避免是十分值得的,因为它们引起的代价非常昂贵。它们可以清空
CPU管道,使现存CPU的并行加速机制失效。)
图5.1  system_call的流程控制

图5.1显示了作为system_call的一部分出现的分支目标标签以及它们之间的流程控
制方向,该图可以在你阅读本部分讨论内容时提供很大的帮助。图中system_call
和restore_all两个标签比其它标签都要大,因为这两处是该函数正常的出口点和
入口点;然而,还有另外两个入口点,这一点在本章的后续内容中很快就可以看到

system_call是由标准C库激活的,该标准C库会把自己希望传递的参数装载到CPU寄
存器中,并触发0x80软件中断。(system_call在这里是一个中断处理程序。)内
核记录了软件中断和6828行的system_call函数的联系(SYSCALL_VECTOR是在1713
行宏定义为0x80的)。
system_call
172:  system_call的第一个参数是所希望激活的系统调用的数目;它存储在EAX
寄存器中。system_call还允许有多达四个的参数和系统调用一起传送。在一些极
其罕见的情况下使用四个参数的限制是负担繁重的,通常可以建立一个指向结构的
指针参数来巧妙地完成同样功能,指针指向的结构中可以包含你所需要的一切信息

    随后可能需要EAX值的一个额外拷贝,因此通过将其压栈而保存起来;这个值就是
218行的ORIG_EAX(%esp)表达式的值。
173:   SAVE_ALL宏是在85行定义的;它把所有寄存器的值压入CPU的堆栈。随后,
就在system_call返回之前,使用RESTALL_ALL(100行)把栈中的值弹出。在这中
间,system_call可以根据需要自由使用寄存器的值。更重要的是,任何它所调用
的C函数都可以从栈中查找到所希望的参数,因为SAVE_ALL已经把所有寄存器的值
都压入栈中了。
    结果栈的结构从26行开始描述。象0(%esp)和4(%esp)一样的表达式指明了堆
栈指针(ESP寄存器)的一种替换形式——分别表示ESP上的0字节,ESP上的4字节
,等等。特别要注意的是在前面一行中压入堆栈的EAX的拷贝已经变成本标题注释
作为orig_eax所描述的内容;它们是由SAVE_ALL压入寄存器之上的堆栈的(
orig_eax之上的寄存器在这里早已就绪了)。
    还需注意:这可能有点令人迷惑——由于我们调用orig_eax时EAX的拷贝已经压入
了堆栈,它是否有可能在其它寄存器下面而不是在其它寄存器上面呢?答案既是肯
定的,也是否定的。x86的堆栈指针寄存器ESP在有数据压入堆栈时会减少——堆栈
会向内存低地址发展。因此,orig_eax逻辑上是在其它值的下面,但是物理上却是
在其它值的上面。
    从51行开始的一系列宏有助于使这些替换更容易理解。例如,EAX(%esp)就和
18(%esp)相同——然而前一种方法通过表达式引用存储在堆栈中的EAX寄存器副
本的决定可以使整个过程更加简单。
174:   从EBX寄存器中取得指向当前任务的指针。完成这个工作的宏GET_CURRENT(
131行)对于在大部分代码中使用的C函数get_current(10277行)来说是一个无限
循环。
    此后,当看到类似于foo(%ebx)或者foo(%esp)的表达式时,这意味着这些的
代码正在引用代表当前进程的结构的字段——16325行的struct task_struct——
这在第7章中将对它进行更详细的介绍。(更确切的描述是, %ebx的置换在struct
task_struct中,%esp的置换在与struct task_struct相关联的struct pt_regs结
构中。但是这些细节在这里都并不重要。)
175:   检查(EAX中的)系统调用的数目是否超过系统调用的最大数量。(此处
EAX为一个无符号数,因此不可能为负值。)如果的确超过了,就向前跳转到
badsys(223行)。
177:   检测系统调用是否正被跟踪。如strace之类的程序为有兴趣的人提供了系统
调用的跟踪工具,或者额外的调试信息:如果能够监测到正在执行的系统调用,那
么你就可以了解到当前程序正在处理的内容。如果系统调用正被跟踪,控制流程就
向前跳转到tracesys(215行)。
179:   调用系统函数。此处有很多工作需要处理。首先,SYSMOL_NAME宏不处理任
何工作,只是简单的为参数文本所替换,因此可以将其忽略。sys_call_table是在
当前文件(arch/i386/kernel/entry.S)的末尾从373行开始定义的。这是一张由
指向实现各种系统调用的内核函数的函数指针组成的表。
    本行中第二对圆括号中包含了三个使用逗号分割开的参数(第一个参数为空);
这里就是实现数组索引的地方。当然,这个数组是以sys_call_table作为索引的,
这称为偏移(displacement)。这三个参数是数组的基地址、索引(EAX,系统调
用的数目)和大小,或者每个数组元素中的字节数——在这里就是4。由于数组基
地址为空,就将其当作0——但是它要和偏移地址,sys_call_table,相加,简单
的说就是sys_call_table被当作数组的基地址。本行基本上等同于如下的C表达式

    /* Call a function in an array of functions. */
    (sys_call_table[eax])();
    然而, C当然还要处理许多繁重的工作,例如为你记录数组元素的大小。不要忘
记,系统调用的参数早已经存储在堆栈中了,这主要由调用者提供给system_call
并使用SAVE_ALL把它们压栈。
180:   系统调用已经返回。它在EAX寄存器中的返回值(这个值同时也是
system_call的返回值)被存储起来。返回值被存储在堆栈中的EAX内,以使得
RESTORE_ALL可以迅速地恢复实际的EAX寄存器以及其它寄存器的值。
182:   接下来的代码仍然是system_call的一部分,它是一个也可以命名为
ret_from_sys_call和ret_from_intr的独立入口点。它们偶尔会被C直接调用,也
可以从system_call的其它部分跳转过来。
185:   接下来的几行检测“下半部分(bottom half)”是否激活;如果激活了,
就跳转到handle_bottom_half标号(242行)并立即开始处理。下半部分是中断进
程的一部分,将在下一章中讨论。
189:   检查该进程是否为再次调度做了标记(记住表达式$0就是常量0的系统简单
表示)。如果的确如此,就跳转到reschedule标号(247行)。
191:   检测是否还有挂起的信号量,如果有的话,下一行就向前跳转到
signal_return(197行)。
193:   restore_all标号是system_call的退出点。其主体就是简单的RESTORE_ALL
宏(100行),该宏将恢复早先由SAVE_ALL存储的参数并返回给system_call的调用
者。
197:   当system_call从系统调用返回前,如果它检测到需要将信号量传送给当前
的进程时,才会执行到signal_return。它通过使中断再次可用开始执行,有关内
容将在第6章中介绍。
199:   如果返回虚拟8086模式(这不是本书的主题),就向前跳转到
v86_signal_return(207行)。
202:   system_call要调用C函数do_signal(3364行,在第6章中讨论)来释放信号
量。do_signal需要两个参数,这两个参数都是通过寄存器传递的;第一个是EAX寄
存器,另一个是EDX寄存器。system_call(在200行)早已把第一个参数的值赋给
了EAX;现在,就把EDX寄存器和寄存器本身进行XOR操作,从而将其清0,这样
do_signal就认为这是一个空指针。
203:   调用do_signal传递信号量,并且跳回到restore_all(193行)结束。
207:   由于虚拟8086模式不是本书的主题,我们将忽略大部分v86_signal_return
。然而,它和signal_return的情况非常类似。
215:   如果当前进程的系统调用正由其祖先跟踪,就像strace程序中那样,那么就
可以执行到tracesys标号。这一部分的基本思想如同179行一样是通过
syscall_table调用系统函数,但是这里把该调用和对syscall_trace函数的调用捆
绑在一起。后面的这个函数在本书中并没有涉及到,它能够中止当前进程并通知其
祖先注意当前进程将要激活一个系统调用。
    EAX操作和这些代码的交错使用最初可能容易令人产生困惑。system_call把存储
在堆栈中的EAX拷贝赋给-ENOSYS,调用syscall_trace,在172行再从所做的备份中
恢复EAX的值,调用实际的系统调用,把系统调用的返回值置入堆栈中EAX的位置,
再次调用syscall_trace。
    这种方式背后的原因是syscall_trace(或者更准确的说是它所要使用到的跟踪程
序)需要知道在它是在实际系统调用之前还是之后被调用的。-ENOSYS的值能够用
来指示它是在实际系统调用执行之前被调用的,因为实际中所有实现的系统调用的
执行都不会返回-ENOSYS。因此,EAX在堆栈中的备份在第一次调用syscall_trace
之前是-ENOSYS,但是在第二次调用syscall_trace之前就不再是了(除非是调用
sys_ni_syscall的时候,在这种情况下,我们并不关心是怎样跟踪的)。218行和
219行中EAX的作用只是找出要调用的系统调用,这和无须跟踪的情况是一致的。
222:   被跟踪的系统调用已经返回;流程控制跳转回ret_from_sys_call(184行)
并以与普通的无须跟踪的情况相同的方式结束。
223:   当系统调用的数目越界时,就可以执行到badsys标号。在这种情况下,
system_call必须返回-ENOSYS(ENOSYS在82行将它赋值为38)。正如前面提到的一
样,调用者会识别出这是一个错误,因为返回值在-1到-4,095之间。
228:   在诸如除零错误(请参看279行)之类的CPU异常中断情况下将执行到
ret_from_exception标号;但是system_call内部的所有代码都不会执行到这个标
号。如果有下半部分是激活的,现在就是它在起作用了。
233:   处理完下半部分之后或者从上面的情况简单的执行下来(虽然没有下半部分
是激活的,但是同样也触发了CPU异常),就执行到了ret_from_intr标号。这是一
个全局符号变量,因此可能在内核的其它部分也会有对它的调用。
237:   被保存的CPU的EFLAGS和CS寄存器在此已经被并入EAX,因而高24位的值(其
中恰好包含了一位在70行定义的非常有用的VM_MASK)来源于EFLAGS,其它低8位的
值来源于CS。该行隐式的同时对这两部分进行测试以判断进程到底返回虚拟8086模
式(这是VM_MASK的部分)还是用户模式(这是3的部分——用户模式的优先等级是
3)。下面是近似的等价C代码:
    /* Mix eflags and cs in eax. */
    eax = eflags & ~0xff;
    eax |= cs & ~0xff
    /* Simultaneously test lower 2 bits
       * and VM_MASK bit. */
    if  (eax & (VM_MASK | 3))
       goto ret_with_reschedule;
    goto restore_all;           
238:   如果这些条件中有一个能得到满足,流程控制就跳转到
ret_with_reschedule(188行)标号来测试在system_call返回之前进程是否需要
再次调度。否则,调用者就是一个内核任务,因此system_call通过跳转到
restore_all (193行)来跳过重新调度的内容。
242:   无论何时system_call使用一个下半部分服务时都可以执行到
handle_bottom_half标号。它简单的调用第6章中介绍的C函数bottom_half(
29126行),然后跳回到ret_from_intrr(233行)。
248:   system_call的最后一个部分在reschedule标号之下。当产生系统调用的进
程已经被标记为需要进行重新调度时,就可以执行到这个标号;典型地,这是因为
进程的时间片已经用完了——也就是说,进程到目前为止已经尽可能的拥有CPU了
,应该给其它进程一个机会来运行了。因此,在必要的情况下就可以调用C函数
schedule(26686行)交出CPU,同时流程控制转回249行。CPU调度是第7章中讨论
的一个主题。
lcall7
Linux支持Intel二进制兼容规范标准的版本2(iBCS2)。(iBCS2中的小写字母i显
然是有意的,但是该标准却没有对此进行解释;这样看来似乎和现实的Intel系列
的CPU例如i386,i486等等是一致的。)iBCS2的规范中规定了所有基于x86的Unix
系统的应用程序的标准内核接口,这些系统不仅包括Linux,而且还包括其它自由
的x86 Unix(例如FreeBSD),也还包括Solaris/x86,SCO Unix等等。这些标准接
口使得为其它Unix系统开发的二进制商业软件在Linux系统中能够直接运行,反之
亦然(而且,近期新开发软件向其它Unix移植的情况越来越多)。例如,Corel公
司的WordPerfect的SCO Unix的二进制代码在还没有Linux的本地版本的
WordPerfect之前就可以使用iBCS2在Linux上良好地运行。
iBCS2标准有很多组成部分,但是我们现在关心的是这些系统调用如何协调一致来
适应这些迥然不同的Unix系统。这是通过lcall7调用门实现的。它是一个相当简单
的汇编函数(尤其是和system_call相比而言更是如此),仅仅定位并全权委托一
个C函数来处理细节。(调用门是x86 CPU的一种特性,通过这种特性用户任务可以
在安全受控的模式下调用内核代码。)这种调用门在6802行进行设定。
lcall7
136: 前面的几行将通过调整处理器堆栈以使堆栈的内容和system_call预期的相
同——system_call中的一些代码将会完成清理工作,这样所有的内容都可以连续
存放了。
145:   基于同样的思想,lcall7把指向当前任务的指针置入EBX寄存器,这一点和
system_call的情况是相同的。但是,它的执行方式却与system_call不同,这就比
较奇怪了。这三行可以等价地按如下形式书写:
    push1 %esp
    GET_CURRENT(%ebx)
    这种实现的执行速度并不比原有的更快;在将宏展开以后,实际上这还是同样的
三条指令以不同的次序组合在一起而已。这样做的优点是可以和文件中的其它代码
更为一致,而且代码也许会更清晰一些。
148:   取得指向当前任务exec_domain域的指针,使用这个域以获取指向其lcall7
处理程序的指针,接着调用这个处理程序。
    本书中并没有对执行域(execution domains)进行详细说明——但是简单说来,
内核使用执行域实现了部分iBCS2标准。在15977行你可以找到struct
exec_domain结构。default_exec_domain(22807行)是缺省的执行域,它拥有一
个缺省的lcall7处理程序。它就是no_lcall7(22820行)。其基本的执行方式类似
于SVR4风格的Unix,如果调用进程没有成功,就传送一个分段违例信号量(
segmentation violation signal)给调用的进程,。
152:   跳转到ret_from_sys_call标号(184行——注意这是在system_call内部的
)清除并返回,就像是正常的系统调用一样。
系统调用样例
现在你已经知道了系统调用是如何激活的,接下来我们将通过几个系统调用例子的
剖析来了解一下它们的工作方式。注意系统调用foo几乎都是使用名为sys_foo的内
核函数实现的,但是在某些情况下该函数也会使用一个名为do_foo的辅助函数。
sys_ni_syscall
29185:sys_ni_syscall的确是最简单的系统调用;它只是简单的返回ENOSYS错误
。最初的时候这可能显得没有什么作用,但是它的确是有用的。实际上,
sys_ni_syscall在sys_call_table中占据了很多位置——而且其原因并不只有一个
。开始的时候,sys_ni_syscall在位置0(374行),因为如果漏洞百出的代码错误
地调用了system_call——例如,没有初始化作为参数传递给system_call的变量—
—在这种偶然的变量定义中,0是最可能的值。如果我们能够避免这种情况,那么
在错误发生时就不用采取象杀掉进程一样的剧烈措施。(当然,只要允许有用工作
的进行,就不可能防止所有的错误。)这种使用表的元素0作为抵御错误的手段在
内核中被作为良好的经验而广泛使用。
    而且,你还会发现sys_ni_syscall在表中明显出现的地方就多达十几处。这些条
目代表了那些已经从内核中移出的系统调用——例如在418行,就代替了已经废弃
了的prof系统调用。我们不能简单地把另外的实际系统调用放在这里,因为老的二
进制代码可能还会使用到这些已经废弃了的系统调用号。如果一个程序试图调用这
些老的系统调用,但是结果却与预期的完全不同,例如打开了一个文件,这会比较
令人感到奇怪的。
    最后,sys_ni_syscall将占据表尾部所有未用的空间;这一点是在从572行到574
行的代码实现的,它根据需要重复使用这些项来填充表。由于sys_ni_syscall只是
简单返回ENOSYS错误号,对它的调用和跳转到system_call中的badsys标号作用是
相同的——也就是说,使用指向这些表项的系统调用号和在表外对整个表进行全部
索引具有相同的作用。因此,我们不用改变NR_syscalls就可以在表中增加(或者
删除)系统调用,但是其效果与我们真的对NR_syscalls进行了修改一样(不管怎
样,这都是由NR_syscalls所建立的限制条件所决定的)。
    到现在也许你已经猜到了,sys_ni_syscall中的“ni”并不是指Monty Python的
“说 ‘Ni’ 的骑士”;而是指“not implemented(没有实现)”这一相较而言
并不太诙谐的短语。
    对于这个简单的函数我们需要研究的另外一个问题是asmlinkage标签。这是为一
些gcc功能定义的一个宏,它告诉编译器该函数不希望从寄存器中(这是一种普通
的优化 )取得任何参数,而希望仅仅从CPU堆栈中取得参数。回忆一下我们前面提
到过system_call使用第一个参数作为系统调用的数目,同时还允许另外四个参数
和系统调用一起传递。system_call通过把其它参数(这些参数是通过寄存器传递
过来的)滞留在堆栈中的方法简单的实现了这种技巧。所有的系统调用都使用
asmlinkage标签作了标记,因此它们都要查找堆栈以获得参数。当然,在
sys_ni_syscall的情况下这并没有任何区别,因为sys_ni_syscall并不需要任何参
数。但是对于其它大部分系统调用来说这就是个问题了。并且,由于在其它很多函
数前面都有asmlinkage标签,我想你也应该对它有些了解。
sys_time
31394:sys_time是包含几个重要概念的简单系统调用。它实现了系统调用time,
返回值是从某个特定的时间点(1970年1月1日午夜UTC)以来经过的秒数。这个数
字被作为全局变量xtime(请参看26095行;它被声明为volatile型的变量,因为它
可以通过中断加以修改,这一点我们在第6章中就会看到)的一部分,通过
CURRENT_TIME宏(请参看16598行)可以访问它。
31400:该函数非常直接的实现了它的简单定义。当前时间首先被存储在局部变量
i中。
31402:如果所提供的指针tloc是非空的,返回值也将被拷贝到指针指向的位置。
该函数的一个微妙之处就在于此;它把i拷贝到用户空间中而不是使用
CURRENT_TIME宏来重新对其进行计算,这基于两个原因:
?   CURRENT_TIME宏的定义以后可能会改变,新的实现方法可能会由于某种原因而速
度比较慢,但是对于i的访问至少应该和CURRENT_TIME宏展开的速度同样快。
?   使用这种方式处理,确保结果的一致性:如果代码刚好执行到31400行和31402行
之间时时间发生了改变,sys_time可能把一个值拷贝到*tloc中,但是在结束之后
却返回另一个值。
    另外还有一个小的方面需要注意,此处的代码不使用&&来编写而是使用两个if,
这可能有一点令人奇怪。内核中采用这些看起来非常特殊的代码的一般原因都是由
于速度的要求,但是gcc为&&版本和两个if版本的代码生成的代码是等同的,因此
这里的原因就不可能是速度的要求——除非这些代码是在早期gcc版本下开发的,
这样才有些意义。
31403:如果sys_time不能访问所提供的位置(一般都是因为tloc无效),它就把
-EFAULT的值赋给i,从而在31405行返回错误代码。
31405:为调用者返回的i或者是当前时间,或者是-EFAULT。
sys_reboot
29298:内核中其他地方可能都没有sys_reboot的实现方法这样先进。其原因是可
以理解为:根据调用的名字我们就可以知道,reboot系统调用可以用来重新启动机
器。根据所提供的参数,它还能够挂起机器,关闭电源,允许或者禁止使用
Ctrl+Alt_Del组合键来重启机器。如果你要使用这个函数编写代码,需要特别注意
它上面的注释标题的警告:首先同步磁盘,否则磁盘缓冲区中的数据可能会丢失。

    由于它可能为系统引发的潜在后果,sys_reboot需要几个特殊参数,这一点马上
就会讨论。
29305:如果调用者不具有CAP_SYS_BOOT(14096行)权能(capability),系统就
会返回EPERM错误。权能在第7章中会详细讨论。现在,简单的说就是:权能是检测
用户是否具有特定权限的方法。
29309:在这里,这种偏执的思想充分发挥了作用。syst_reboot根据从16002到
16005行定义的特殊数字检测参数magic1和magic2。这种思想是如果sys_reboot在
某种程度上是被偶然调用的,那么就不太可能再从由magic1和magic2组成的小集合
中同时提取值。注意这并不意味着这是一个防止粗心的安全措施。
    顺便说一下,这些特殊数字并不是随机选取的。第一个参数的关系是十分明显的
,它是“感受死亡(feel dead)”的双关语。后面的三个参数要用十六进制才能
了解它们全部的意思:它们分别是0x28121969,0x5121996,0x16041998。这似乎
代表Linus的妻子(或者就是Linus自己)和他两个女儿的生日。由此推论,当
Linus和他的妻子养育了更多儿女的时候,重启动需要的特殊参数可能在某种程度
上会增加。不过我想在他们用尽32位可能空间之前,他的妻子就会制止他的行为了

29315:请求内核锁,这样能保证这段代码在某一时间只能由一个处理器执行。使
用lock_kernel/unlock_kernel函数对所保护起来的任何其它代码对其它CPU都同样
是不可访问的。在单处理器的机器中,这只是一个no-op(不处理任何事情);而
详细讨论它在多处理器上的作用则是第10章的内容。
29317:在LINUX_REBOOT_CMD_RESTART的情况中,sys_reboot调用一系列基于
reboot_notifier_list的函数来通知它们系统正在重新启动。正常情况下,这些函
数都是操作系统关闭时需要清除的模块的一部分。这个列表函数似乎并不在内核中
的其它地方使用——至少在标准内核发行版本中是这样,也许此外的其它模块可能
使用这个列表。不管怎样,这个列表的存在可以方便其他人使用。
    LINUX_REBOOT_CMD_RESTART和其它cmd识别出的值从16023行开始通过#define进行
宏定义。这些值并没有潜在的意义,选用它们的简单原因是它们一般不会发生意外
并且相互之间各不相同。(有趣的是,LINUX_REBOOT_CMD_OFF是零,这是在意外情
况下最不可能出现的一个值。但是,由于LINUX_REBOOT_CMD_OFF简单的禁止用户使
用Ctrl+Alt+Del重新启动机器,它就是一种“安全”的意外了。)
29321:打印警告信息以后,sys_reboot调用machine_restart(2185行)重启机器
。正如你从2298行中所看到的一样,machine_restart函数从来不会返回。但是不
管怎样,对于machine_restart的调用后面都跟着一个break语句。
    这仅仅是经典的良好的编程风格吗?的确如此,但是却又不仅仅如此。文件
kernel/sys.c的代码是属于体系结构无关部分的。但是machine_restart,它显然
是体系结构所特有的,属于代码的体系结构特有的部分(
arch/i386/kernel/process.c)。因而对于不同的移植版本也有所不同。我们并不
清楚以后内核的每个移植版本的实现都不会返回——例如,它可能调度底层硬件重
启但是本身要仍然持续运行几分钟,这就需要首先从函数中返回。或者更为确切的
说法是,由于某些特定的原因,系统可能并不总是能够重启;或许某些软件所控制
的硬件根本就不能重启。在这种平台上,machine_restart就应该可以返回,因此
体系结构无关的代码应该对这种可能性有所准备。
    针对这个问题,正式的发行版本中都至少包含一个退出端口,使
machine_restart函数可以从这个端口返回:m68k端口。不同的基于m68k的机器支
持的代码也各不相同,由于本书主要是针对x86的,我不希望花费过多的时间来解
析所有的细节。但是这的确是可能的。(在其它情况下,machine_restart简单进
入一个无限循环——既不重新启动机器,也不返回。但是这里我们担心的是需要返
回的情况。)
    因此,我们毕竟是需要break的。前面看起来只是简单的习惯甚至是偏执的思想在
这里为了内核的移植性已经变成必须的了。
29324:接下来的两种情况分别允许和禁止臭名卓著的Ctrl+Alt+Del组合键(这三
个组合键也被称为“Vulcan神经收缩(Vulcan nerve pinch)”,“黑客之手(
hacker’s claw)”,“三指之礼(three-fingered salute)”,我最喜欢后面
这个)。这些只是简单的设置全局C_A_D标志(在29160行定义,在29378行检测)

29332:这种情况和LINUX_REBOOT_CMD_RESTART类似,但只是暂停系统而不是将其
重新启动。两者之间的一个区别是它调用machine_halt(2304行)——这是x86上
的一条no-op指令,但是在其它平台上却要完成关闭系统的实际工作——而不是调
用machine_restart。并且它会把machine_halt不能使之暂停的机器转入低功耗模
式运行。它使用do_exit(23267行)杀死内核本身。
29340:到现在为止,这已经是一种比较熟悉的模式了。这里,sys_reboot关闭机
器电源,除了为可以使用软件自行关闭电源的系统调用machine_power_off(2307
行)之外,其它的应该和暂停机器情况完全相同。
29348:LINUX_REBOOT_CMD_RESTART2的情况是已建立主题的一个变种。它接收命令
,将其作为ASCII字符串传递,该字符串说明了机器应该如何关闭。字符串不会由
sys_reboot本身来解释,而是使用machine_restart函数来解释;因而这种模式的
意义,如果有的话,就是这些代码是平台相关的。(我使用“如果有”的原因是启
动机器——特别是在x86中——一般只有一种方法,因此其它的信息都可以被
machine_restart忽略。)
29365:调用者传递了一个无法识别的命令。sys_reboot不作任何处理,仅仅返回
一个错误。因此,即使由magic1和magic2传递给sys_reboot正确的magic数值,它
也无须处理任何内容。
29369:一个可识别的命令被传递给sys_reboot。如果流程执行到这里,它可能就
是两个设置C_A_D的命令之一,因为其它情况通常都是停止或者重新启动机器。在
任何情况下,sys_reboot都简单把内核解锁并返回0以表示成功。
sys_sysinfo
24142:一个只能返回一个整型值的系统调用。如果需要返回更多的信息,我们只
需要使用类似于在系统调用中传递多于四个参数时所使用的技巧就可以了:我们通
过一个指向结构的指针将结果返回。收集系统资源使用情况的sysinfo系统调用就
是这种函数的一个样例。
24144:分配并清空一个struct sysinfo结构(15004行)以暂时存储返回值。
sys_sysinfo可以把结构中的每个域都独立地拷贝出来,但是这样会速度很慢、很
不方便,而且必然不容易阅读。
24148:禁止中断。这在第6章中会有详细的介绍;作为目前来说,我们只要说明这
种模式在使用的过程中能够确保sys_sysinfo正在使用的值不会改变就足够了。
24149:struct sysinfo结构的uptime域用来指明系统已经启动并运行了的秒数。
这个值是使用jiffies(26146行)和HZ来计算的。jiffies计算了系统运行过程中
时钟的滴答次数;HZ是系统相关的一个参数,它十分简单,就是每秒内部时钟滴答
的次数。
24151:数组avenrun(27116行)记录了运行队列的平均长度——也就是等待CPU的
平均进程数——在最后的1秒钟,5秒钟和15秒钟。calc_load(27135行)周期性的
重复计算它的值。由于内核中是要严格禁止浮点数运算的,所以只能通过计算变化
的次数这一修正值来计算。
24155:同样记录系统中当前运行的进程数。
24158:si_meminfo(07635行)写入这个结构中的内存相关成员,si_swapinfo(
38544行)写入与虚拟内存相关的部分。
24161:现在整个结构都已经全部填充了。sysinfo试图将其拷贝回用户空间,如果
失败就返回EFAULT,如果成功就返回0。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:36:59 | 显示全部楼层
Linux内核注释—— 第6章 信号量,中断

信号量(Signal)是进程间通讯(IPC)的一种形式——是一个进程给另一个进程
发送信息的方法。但是信息不可能很多——一个信号量不可能携带详细的信息,即
使是传送者的身份也不能被传递;唯一能够确定的事实是信号量的确被发送了。(
然而和经典信号量不同,POSIX实时信号量允许传送稍微多一点的信息。)实际上
,信号量对于双向通讯是没有用处的。还有,根据某些限定,信号量的接受者不必
以任何方式作出响应,甚至可以直接忽略大部分信号量。
虽然有这么多的限制,然而信号量仍然是一种功能强大的十分有用的机制——勿庸
置疑,这是Unix IPC中使用最频繁的机制。每当进程退出或者废弃一个空指针时,
每当使用Ctrl+C键终止程序运行时,都要传递信号量。
第9章会更详细的讨论IPC机制。对于本章的讨论来说,信号量的内容就足够讨论了

正如在Linux内核本身的代码注释中所说明的一样,中断(Interrupt)对于内核来
说和信号量是类似的。中断一般都是从磁盘之类的硬件设备送往内核,用以提示内
核该设备需要加以注意。一个重要的硬件中断源就是定时器设备,它周期性地通知
内核已经通过的时间。如同第5章中阐述的一样,中断也可以由用户进程通过软件
产生。
在本章中,我们首先讨论一下Linux中信号量和中断的实现,最后再浏览一下
Linux的时间处理方式。
虽然内核对代码的要求标准非常严格,本章所涉及的代码仍然特别清晰明白。本章
使用的一般方法是首先介绍相关的数据结构和它们之间的关系,接下来讨论操纵和
查询它们的函数。
锁的概述
锁的基本思想是限制对共享资源的访问——共享资源包括共享的文件,共享的内存
片,以及在一次只能由一个CPU执行的代码段。概括的说,在单处理器上运行的
Linux内核并不需要锁,这是因为在编写Linux内核时就已经注意到要尽量避免各种
可能需要锁的情况了。但是,在多处理器机器上,一个处理器有时需要防止其它处
理器对它的有害的介入。
include/asm-i386/spinlock.h文件(从12582行开始)并不使用难看的#ifdef把所
有对锁函数的调用封装起来,它包含一系列对单处理器平台(UP)基本为空的宏,
然而在多处理器平台(SMP)上这些宏将展开成为实际代码。因而内核的其它代码
对UP和SMP(当涉及到这种特性时)都是相同的,但是它们两个的效果却是迥然不
同的。
第10章中涉及SMP的部分会对锁做深入的介绍。但是,由于你在代码中将到处都能
够看到对锁宏的调用,特别是在本章所讨论到的代码中这一点尤为明显,所以你应
该首先对宏的用途有初步了解——以及为什么现在在大多数情况下我们都可以安全
地将其忽略(我们将在讨论的过程中对其中的异常情况进行说明)。
信号量
Linux内核将信号量分为两类:
?   非实时的(Nonrealtime)——大部分是些传统的信号量,例如SIGSEGV,
SIGHUP和SIGKILL。
?   实时的(realtime)——由POSIX 1003.1b标准规定,它们同非实时信号量有细
微的区别。特别是实时信号量具有进程可以配置的意义——就像是非实时信号量
SIGUSR1和SIGUSR2一样——额外的信息能够和这些信号量一起传送。它们也会排队
,因此如果在第一个信号量处理完成之前有多个信号量实例到达,所有的信号量都
能够被正确传送;这对于非实时信号量则是不可能的。
在第7章中我们将会对实时性对于Linux内核的意义进行更详细的介绍——特别是实
时性所不能够说明的内容。
信号量数目的宏定义从12048行开始。实时信号量的数目在SIGRTMIN和SIGRTMAX(
分别在12087行和12088行)所定义的范围之内。
数据结构
本节讨论信号量代码使用的最重要的数据结构。
sigset_t
12035:sigset_t表示信号量的集合。根据使用地点的不同,它的意思也不同——
例如,它可能记录着正在等待某一个进程的信号量(如16425行struct
task_struct的signal成员)的集合,也可能是某个进程已经请求阻塞了的信号量
(如同一行中定义的同一结构的blocked成员)的集合。随着本书的进行,我们会
逐渐看到这些类似的应用。
12036:sigset_t的唯一一个组成部分是一组unsigned long(无符号长整型数),
其中的每一位都代表一个信号量。注意到无符号长整型类型在整个内核代码中是作
为一个字来处理的,这和你所希望的可能有所出入——即使是在当前x86 CPU的讨
论中,有时候字也被用于说明16位类型。由于Linux是一个真32位操作系统,将32
位看作是一个字在绝大多数情况下是正确的。(将Linux称为真32位操作系统也有
一些不准确,因为在64位CPU上它也是一个真64位操作系统。)
    这个数组的大小_NSIG_WORDS在12031行直接计算。(_NSIG_BPW中的“BPW”是“
bits per word(每字位数)”的缩写。)在不同的平台上,_NSIG_WORDS的大小从
1(Alpha平台中)到4(MIPS平台中)不等。如你所见,在x86平台中,该值正好是
2,这意味着在x86平台上2个无符号数就可以包含足够的位数来代表所有Linux使用
的信号量。
struct sigaction
12165:struct sigaction代表信号量到达时进程应该执行的动作。它被封装在
struct k_sigaction(12172行)结构中,而该结构又是被封装在struct
signal_struct 结构中的,后者是struct task_struct结构的sig成员所指向的一
个实例(16424行)。如果这个指针为空,进程就会退出而不必接受任何信号量。
否则,每个进程对于每个信号量数目都需要若干_NSIG struct sigaction结构和一
个struct sigaction结构。
12166:sa_handler(__sighandler_t类型——一个在12148行定义的函数指针类型
)描述了进程希望处理信号量的方式。其值可以是下面中的一个:
?   SIG_DFL(12151行)申请处理信号量的缺省操作,不管该操作是什么——这是由
信号量所决定的。注意它和NULL是等同的。
?   SIG_IGN(12153行)意味着信号量应该忽略。但是,并不是所有的信号量都可以
被忽略的。
?   所有的其它值都是在信号量到达时所需要调用的用户空间函数的地址。
12167:sa_flags进一步调整信号量处理代码所完成的工作。可能的标志集合从
12108行开始定义。这些标志允许用户代码在信号量实例发送以后(或者保留用户
定制的操作时)请求恢复缺省操作,等等。这一点在宏定义块前面的标签注释中已
经说明了。
12168:sa_restorer是本书中所没有涉及的一些信号量处理代码细节所使用的。
12169:sa_mask是一系列其它信号量的集合,进程在处理这些信号量的过程中可能
需要进行锁定。例如,如果一个进程在处理SIGCHLD的时候希望锁定SIGHUP和
    SIGINT,进程的第SIGCHLD个sa_mask就会对与SIGHUP和SIGINT相关的位进行置位

siginfo_t
11851:struct siginfo(也称为siginfo_t)是伴随着信号量,特别是在实时信号
量,所传递的额外信息。
11852:勿庸置疑,si_signo是信号量的数目。
11853:si_errno应该是信号量传递时传送者的errno的值,这样接收者就可以对它
进行检测。内核本身并不关心这个值;当在某些情况下需要设置这个值时,内核将
其设置为0。我推测如果这样,即使调用者没有设置这个值,它们仍然会发现
si_error的值被设为已知状态。
11854:si_code记录了信号量的来源(不是发送者的进程ID号,也就是PID——它
在别处记录)。有效的信号量来源在11915行及其随后部分使用宏进行了定义。
11856:该结构的最后一部分是union类型的;该union类型依赖于si_code的值。
11857:union的第一部分是_pad,它将siginfo_t的长度扩展填充为128*sizeof(
int)字节(在x86平台上一共是512个字节)。留意一下这个数组的大小,也就是
SI_PAD_SIZE(11849行),代表了该结构的前三个成员——如果增加了更多的成员
,SI_PAD_SIZE就需要进行相应修改。
struct signal_queue
17132:struct signal_queue结构用来确保所有的实时信号量都被正确传送了,如
果可能,每一个都包含着额外信息(siginfo_t)。如同后面你将会看到的一样,
内核会为每个进程都设置一个队列,用来存放该进程的挂起的实时信号量。这个队
列类型本身很小,仅仅由一个指向下一个节点的指针和siginfo_t本身组成。
应用函数
有关信号量的一个最重要的数据结构是sigset_t,它是由一系列在
include/linux/signal.h文件中定义的简单函数所操纵的,这些函数的定义从
17123行开始。在x86平台上,这些相同的函数可以——而且已经——使用汇编语言
更加有效的实现了;这些更高效的版本从12204行开始。(m68k端口是唯一一个例
外的端口,它使用体系结构特有的代码实现。)由于平台无关的版本和x86特有的
版本都很重要,我们会对两者都加以介绍。
平台无关的sigset_t函数
配合sigset_t使用的平台无关的函数在include/linux/sigal.h文件中,从17123行
开始。称为“bitops”(位级的操作)的函数将在后面介绍。
sigaddset
17145:sigaddset把一个信号量加入集合——也就是说,它修改了集合中的一位。

17147:为了便于位操作,将基于0的信号量转化为基于1的信号量。
17149:如果信号量中填入一个无符号长整型数,恰当的位就会被设置。
17151:否则,sigaddset就需要绕很多弯路,首先装入恰当的数组元素,接着设置
该元素中相关位。
17148行的代码和该文件中后面的其它代码一样,第一次见到时可能会令人感到有
些困惑。在内核代码中,速度是压倒一切的因素。从而,也许你并不会看到类似于
下面的运行期间进行决定的代码:
    if (_NSIG_WORDS == 1)
       set->sig[0] |=1UL << sig;
    else
       set->sig[sig / NSIGBPW] |= 1UL << (sig % NSIGBPW);
而你看到的是类似于下面的在编译期间决定的代码:
    #if (_NSIG_WORDS == 1)
       set->sig[0] |=1UL << sig;
    #else
       set->sig[sig / NSIGBPW] |= 1UL << (sig % NSIGBPW);
    #endif
难道这样不会运行的更快些吗?不要忘了,if条件是能够在编译期间进行计算的,
因此预处理器可以使系统没有必要在运行期间执行检测工作。
当你认识到优化工作的实现方式时,这也就没有什么神秘的了。gcc的优化器的敏
锐程度足以注意到if表达式只有一个出口,因此它可以把那些不必要的代码移走。
作为内核“运行期间”版本的结果代码和“编译期间”的版本是等同的。
但是在我们使用优化器很糟糕的编译器时,基于预处理器的版本还会更好吗?这一
点并不确定。问题之一是,基于预处理器的(编译期间的)版本有一点更难懂。当
代码的复杂程度比前面的简单例子要高时,可读性的差别就会明显的显示出来。例
如,让我们考虑一下sigemptyset中的从17264行开始的switch。现在的switch类似
于这样:
swithc (_NSIG_WORDS) {
default:
  memset(set, 0, sizeof(sigset_t));
  break;
case 2:  set->sig[1] = 0;
case 1:  set->sig[0] = 0;
  break;
}
(请注意经周密考虑的case 2随case 1连续执行的情况。)为了更好的利用预处理
器而将其重写,它就可能类似于:
#if ((_NSIG_WORDS != 2)) && \ (_NSIG_WORDS != 1)
  memset(set, 0, sizeof(sigset_t));
#else /* (_NSIG_WORDS is 2 or 1). */
#if (_NSIG_WORDS == 2)
  set->sig[1] = 0;
#endif
  set->sog[0] = 0;
#endif /* _NSIG_WORDS test. */
gcc的优化器为两者产生的目标代码是相同的。你更希望读哪一种版本的源程序代
码呢?
另外,即使编译器的优化器并没有这么好——这种优化实在相当简单——那么编译
器就不可能生成很好的代码。不管我们提供多少帮助都注定是不够的,因此我们可
能要编写一些更容易读、更容易维护的代码——这是又一项工程技术的权衡。最后
,就象我们在前面的内容中已经看到而且还要不断看到的那样,使用除gcc之外的
编译器编译内核本身就是个挑战——增加一段gcc特有代码不会引起更多问题的。

sigdelset
17154:这些代码和sigaddset非常类似;区别在于这里从集合中删去了一位——就
是把相应的位设置为关。
sigismember
17163:这些代码和sigaddset也非常类似;这里是要测试某一位是否被设置。注意
到17167行可能和下面的这种写法有同样的好处:
    return set->sig[0] & (1UL << sig);
    这种写法与17169行非常相似。虽然这样能够和其它函数的编写风格更加一致,但
是这并不是什么改进。
    这些修改将对函数的行为方式稍有改动:它现在返回0或1,经过这种修改,就可
以在一个位被设置时返回其它的非0值。但是,这种改变不会终止没有退出的代码
,因为其调用者只关心返回值是否为0(它们并不特别在意是否为1)。
sigfindinword
17172:这个函数返回word中设置的第一个位的位置。函数ffz(在本书中没有涉及
)返回其参数中第一个0位的位置。在将位求补的字中的第一个0的位置——这正是
这个函数搜寻的内容——显然是原始顺序中的第一个1的位置。它从最小位0开始计
算。
sigmask
17177:最后,这个有用的sigmask宏简单的把信号量数目通过一个相应的位集合转
化为一个位掩码。
平台相关的sigset_t函数
即使平台无关的版本已经使用了简单有效的C代码,它也可以通过使用x86 CPU家族
的方便而功能强大的位集指令在x86平台上更加有效地实现,。这些函数中的大部
分都可以减少为单独的机器指令,因此这里的讨论也都很精简。
在x86平台(例如m68k)上平台无关的函数对于编译器甚至是不可见的。17126行包
含进了asm/signal.h文件,在x86上这个文件被分解为include/asm-i386/signal.
h,这都应该归功于设置文件所建立的符号链接。12202行定义了预处理器符号
__HAVE_ARCH_SIG_BITOPS,它消除了这些平台无关的函数的定义(请参看17140行
)。
sigaddset
12204:x86特有的使用btsl指令的sigaddset实现,它仅对操作数的一个位进行设
置。
sigdelset
12210:同样,这是x86特有的使用btrl指令的sigdelset实现,它对操作数的一个
位进行重置(清除)。
sigismember
12233:sigismember根据其sig参数是否是一个编译期常量表达式来选择实现方法
。文档中所没有说明的gcc编译器的强大的特殊参数__builtin_constant_p是一个
编译期操作符(就象sizeof一样),它能够报告是否可以在编译期间计算其参数值

    如果可以,sigismember使用__const_sigismember函数(12216行)完成这项工作
,因为它的大部分表达式都可以在编译期间计算。否则就使用更为普遍的版本
__gen_sigismember函数(12224行)来代替。更普遍的版本中使用的是x86的btl指
令,它需要测试其操作数中的某一位。
    注意到在编译期的常量合并和死锁代码消除通常意味着这样的完整测试只能在编
译期间执行——关键是sigismember要根据需要使用__const_sigismember或者
__gen_sigismember替换,在作为结果的目标代码中甚至完全看不出来根本就没有
对另一部分进行考虑。这样相当精简,难道不是吗?
sigmask
12238:x86特有的sigmask的实现,这与平台无关的版本是等同的。
sigfindinword
12240:最后,x86特有的sigfindinword实现只使用了x86的bsfl指令,它在自己的
操作数中寻找一个设置位。
设置函数
除了前面的那一组函数之外,还有一组对sigset_t执行设置操作的函数和宏。和前
面一组类似,这些函数使用__HAVE_ARCH_SIG_SETOPS预处理器符号保护起来。然而
现在没有一种体系结构能够提供自己独有的这些函数的实现,正因为如此,体系结
构无关的版本是现存的唯一版本。
_SIG_SET_BINOP
17184:我们希望定义的全部三个二进制操作——sigorsets,sigandsets和
signandsets——的实现方式从本质上来说是相同的。这个宏简单的把这三个函数
的共同代码分解出来,从而只给它们提供一个操作和一个名字。当然,这同C++模
版函数类似,不过这样我们不得不自己处理一些记录工作,而不能完全信任编译器
——这是我们使用C工作所付出的一部分代价。
17191:程序开始在sigset_t中全部四个字节的无符号长整型数的循环,同时对这
些操作数进行应用。这个循环是为了速度的原因而展开的——通过减少循环控制开
销来提高速度,这是很出名的一种增加速度的方法。然而,大多数情况下,这个循
环根本就不执行。例如,在x86平台上,编译器可以在运行期间就证实不会执行该
循环体,因为截断取整以后,_NSIG_WORDS/4的结果是0。(回忆一下_NSIG_WORDS
在x86平台上的值为2。)
17201:switch从循环末尾处理剩余工作的这行开始。如果在某些平台上
_NSIG_WORDS正好为6,那么该循环就可以执行一次,而且switch的情况2也可以被
执行。在x86平台上,循环永远不会执行;只有switch的情况2才可能执行。
    顺便说一下,我并不清楚为什么switch不和其类似的_SOG_SET_OP一样使用直接流
程的方式实现。通常情况下,现存的版本可以更充分的利用缓存(如果你试图重新
编写它,那么你就可以清楚的认识到这一点)——但是如果实际原因的确如此,那
么_SIG_SET_OP也应该使用相同的参数。
_SIG_SET_OP
17238:_SIG_SET_OP和_SIG_SET_BINOP类似,但是它使用的是一元操作而不是二元
操作,因此我们并不需要详细地介绍它。但是你应该注意的是,这只能使用一次—
—在17257行生成signotset——这和_SIG_SET_BINOP不同。因此,在某种程度上这
是不需要的——其实现者可以直接编写signotset,而不必借助_SIG_SET_OP,这并
没有产生任何重复代码。然而,二者生成的目标代码是相同的,这样如果我们以后
选择增加一元操作,意义也就不大了。
sigemptyset
17262:sigemptyset清空所提供的集合——要把其中的每一位都清空。(下面一个
函数sigfillset和这个函数功能相同,不过它要设置所有的位而不是清除所有的位
,因此我们就不再详细介绍了。)
17265:普通情况下使用memset把集合中的每一位都置为0。
17268:对于_NSIG_WORDS的一些比较小的值来说,简单的直接设置sigset_t的一两
个元素可能速度更快。在这里采用的就是这种直接流程实现。
sigaddsetmask
17292:该函数和下面的几个函数是更简单快速设置和读取最低的32位(或者根据
字的大小)信号量的一系列函数。sigaddsetmask简单地把mask所指定的位置位,
而不对剩余的位进行任何处理——这是一个集合的联合操作。
siginitset
17310:根据提供的掩码对最低32位(或者是别的)置位,并将其它位设置为0。下
面一个函数siginitsetinv(17323行)正好相反:它根据掩码的补数设置最低32位
(或者别的),并对其余的位置位。
传送信号量
从用户的观点来看,传送信号量相当简单:调用系统调用kill,该调用只需要进程
ID号和一个信号量。但是,正如本节中所显示的那样,其实现要复杂得多。
sys_kill
28768:sys_kill是系统调用kill的一个具有欺骗性的实现样例;真正的实际工作
是在kill_somethig-info中实现的,我们随后就将对这个方面进行研究。
sys_kill的参数是要传递的信号量sig和信号量的目的pid。就象你将看到的那样,
参数pid并不仅是进程ID。(PID和进程的其它概念都在第7章中详细介绍。)
28770:根据提供给sys_kill的信息声明并填充struct siginfo结构。特别要注意
的是si_code是SI_USER(因为只有用户进程才可以调用该系统调用;内核本身是不
会调用系统调用的,它更倾向于使用低层函数)。
28778:传递这些信息给kill_something_info,该函数处理实际的工作。
kill_something_info
28484:该函数的参数和sys_kill类似,但是增加了一项siginfo结构的指针。
28487:如果pid为0,就意味着当前进程希望把信号量传递给整个进程组,该工作
由kill_pg_info(28408行)完成。
28489:如果pid是-1,信号量(几乎)被送往系统中的每一个进程,这在下面的段
落中介绍。
28494:使用for_ech_task宏(在16898行宏定义,第7章中详细介绍)开始循环处
理现存进程列表的每一项。
28496:如果这不是idle进程(或init),就使用send_sig_info(28218行,后面
将会讨论)传递信号量。每次发现合适的任务时count的值都会增加,虽然
kill_something_info并不关心count的实际值,而是在意是否能够发现合适的进程
。如果所有试图发送信号量的努力都失败了,将记录失败的过程以使得
kill_something_info可以在28503行返回错误代码;如果发生了多次错误,则只返
回最后一次失败的情况。
28503:如果发现了合适的候选进程,kill_something_info就返回最近失败的错误
代码,或者成功就返回0。如果没有发现任何合适的候选进程,就返回ESRCH错误。

28504:其它负的pid(是负值,但不是-1)定义了接收信号量的进程组;pid的绝
对值是进程组号。和前面一样,kill_pg_info的使用就是出于这种目的。
28506:其它的所有可能性都已经进行了说明;pid必须为正数。在这种情况下,它
是信号量传送的目的进程的PID。这由kill_proc_info实现(28463行,很快就会讨
论)。
kill_pg_info
28408:这个函数给进程组中的每一个进程发送一个信号量和一个struct siginfo
结构。其函数体和前面介绍的kill_something_info类似,因此我只是简单介绍一
下。
28417:开始循环处理系统中的所有进程。
28418:如果进程在正确的进程组中,那么信号量就发送给它。
28427:如果信号量成功发送给任何进程,retval就设置为0,从而在28430行成功
返回。如果信号量不能被发往任何进程,那么要么是所给的进程组中没有进程,在
这种情况下,reval仍然会在28415行赋值为-ESRCH;或者kill_pg_info发送信号量
给一个或多个进程,但是每次都失败了,在这种情况下retval值为从
send_sig_info得到的最近错误代码。注意它和kill_something_info的细微区别,
后者如果发送信号量失败时就返回错误。但是这里的kill_pg_info即使在某些情况
下出错了,只要信号能成功地传递给任意进程,就会返回成功信息。
28430:在28410行中,如果进程组号无效,retval或者是如前所述的赋值,或者就
是-EINVAL。
kill_proc_info
28463:kill_proc_info是一个相当简单的函数,它把信号量和struct siginfo结
构传递给由PID定义的单个进程。
28469:通过所提供的PID查找相应的进程;如果成功find_task_by_pid(16570行
)返回一个指向该进程的指针,如果没有找到该进程就返回NULL。
28472:如果找到匹配进程,就使用send_sig_info把信号量传送给目的进程。
28474:返回错误指示,或者是在28470行由于没有发现匹配进程而返回-ESRCH,或
者是其它情况下从send_sig_info中返回的值。
send_sig_info
28218: 我们最后看的几个函数中最重要的显然是send_sig_info。这个函数使用
不同的方法装载进程并处理实际的工作。现在应该了解一下实际的工作是如何完成
的。send_sig_info将使用info指针(该指针也可能为NULL)指向额外信息的信号
量sig传送给t指针(调用者应该保证t不会为NULL)指向的进程。
28229:确保sig在范围之内。注意使用的是如下的测试
    sig > _NSIG
    而不是你可能预期的
    sig >= _NSIG
    这是因为信号量的计数是从1开始的,而不是从0开始的。因此虽然不存在对这个
信号量编号的定义,有效信号量的编号的标识符_NSIG本身也是有效的信号量编号

28233:这是另外一个严密性检查——实际上包含多个检验。基本的思想是检测信
号量的传送是否合法。虽然内
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:40:00 | 显示全部楼层
Linux内核注释——第7章 进程和线程  

操作系统的存在归根结底是为了提供一个运行程序的空间。按照Unix的术语,将正
在运行的程序为进程。Linux内核和其它Unix变种一样,都是采用了多任务技术;
它可以在许多进程之间分配时间片从而使这些进程看起来似乎在同时运行一样。这
里通常是内核对有关资源的访问作出仲裁;在这种情况下,资源就是CPU时间。
进程传统上都有唯一的执行程序的上下文——这是说明在某个时刻它正在处理一项
内容的流行的方法。在给定的时刻,我们可以精确地知道代码的哪一部分正在执行
。但是有时我们希望一个进程同时处理多件事情。例如,我们可能希望Web浏览器
获取并显示Web页,同时也要监视用户是否点击停止按钮。只为监视停止按钮而运
行一个全新的程序显然是不必要的,但是对于Web浏览器来说要对其时间进行分隔
也并不总是非常方便——获取一些Web页信息,检测停止按钮,再获取一些Web页信
息,再重新检测停止按钮,等等。
对于这个问题的比较流行的解决方法是线程。从概念上来说,线程是同一个进程中
独立的执行上下文——更简单一点地说,它们为单一进程提供了一种同时处理多件
事情的方法,就像是进程是一个自行控制的微缩化了的多任务操作系统。同一线程
组中的线程共享它们的全局变量并有相同的堆(heap),因此使用malloc给线程组
中的一个线程分配的内存可以被该线程组中的其它线程读写。但是它们拥有不同的
堆栈(它们的局部变量是不共享的)并可以同时在进程代码不同的地方运行。这样
,你的Web浏览器可以让一个线程来获取并显示Web页,同时另外一个线程观测停止
按钮是否被点击,并且在停止按钮被点击时停止第一个线程。
和线程等价的一种观点——这是Linux内核使用的观点——线程只是偶然的共享相
同的全局内存空间的进程。这意味着内核无需为线程创建一种全新的机制,否则必
然会和现在已经编写完成的进程处理代码造成重复,而且有关进程的讨论绝大多数
也都可以应用到线程上。
当然,以上的说明仅仅适用于内核空间的线程。实际中也有用户空间的线程,它执
行相同的功能,但是却是在应用层实现的。用户空间的线程和内核空间的线程相比
有很多优点,也有很多缺点,但是有关这些问题的讨论超出了本书的范围。而使人
更加容易造成混淆是一个名为kernel_thread(2426行)的函数,尽管该函数被赋
予了这样一个名字,但是它实际和内核空间的线程没有任何关系。
部分是由于历史的原因,部分是由于Linux内核并没有真正区分进程和线程这两者
在概念上的不同,在内核代码中进程和线程都使用更通用的名字“任务”来引用。
根据同样的思路,本书中所出现的 “任务”和“进程”具有相同的意义。
调度和时间片
对CPU访问的裁决过程被称为调度(Scheduling)。良好的调度决策要尊重用户赋
予的优先级,这可以建立一种所有进程都在同时运行的十分逼真的假象。糟糕的调
度决策会使操作系统变得沉闷缓慢。这是Linux调度程序必须经过高度优化的一个
原因。
从概念上来说,调度程序把时间分为小片断,并根据一定的原则把这些片断分配给
进程。你可能已经猜到,时间的这些小片断称为时间片。
实时进程
Linux提供了三种调度算法:一种传统的Unix调度程序和两个由POSIX.1b(原名为
POSIX.4)操作系统标准所规定的“实时”调度程序。因此,本书中有时会使用实
时进程(从技术上考虑,系统使用术语“非实时进程(nonrealtime process)”
来作为实时进程的对应,虽然我更倾向于使用另外一个术语unrealtime process)
。不要过分计较“实时”这个术语,虽然——如果从硬件的角度来看待这个问题,
实时意味着你可以得到有关操作系统的某种性能保证,例如有关中断等待时间的承
诺,但是这一点在Linux实时调度规则中并没有提供。相反的,Linux的调度规则是
“软件实时”,也就是说如果实时进程需要,它们就只把CPU分配给实时进程;否
则就把CPU时间让出给非实时进程。
但是如果你真正需要,一些Linux的变种也承诺提供一种“硬实时”。但是,在当
前的Linux内核中——因此也就是在本章中——“实时”仅指“软件实时”。
优先级
非实时进程有两种优先级,一种是静态优先级,另一种是动态优先级。实时进程又
增加了第三种优先级,实时优先级。优先级是一些简单的整数,它代表了为决定应
该允许哪一个进程使用CPU的资源时判断方便而赋予进程的权值——优先级越高,
它得到CPU时间的机会也就越大:
?   静态优先级——被称为“静态”是因为它不随时间而改变,只能由用户进行修改
。它指明了在被迫和其它进程竞争CPU之前该进程所应该被允许的时间片的最大值
。(但是也可能由于其它原因,在该时间片耗尽之前进程就被迫交出了CPU。)
?   动态优先级——只要进程拥有CPU,它就随着时间不断减小;当它小于0时,标记
进程重新调度。它指明了在这个时间片中所剩余的时间量。
?   实时优先级——指明这个进程自动把CPU交给哪一个其它进程:较高权值的进程
总是优先于较低权值的进程。因为如果一个进程不是实时进程,其优先级就是0,
所以实时进程总是优先于非实时进程的。(这并不完全正确;如同后面论述的一样
,实时进程也会明确地交出CPU,而在等待I/O时也会被迫交出CPU。前面的描述仅
限于能够交付CPU运行的进程)
进程ID(PIDs)
传统上每个Unix进程都有一个唯一的标志符,它是一个被称为进程标志符(PID)
的,范围在0到32,767之间的整数。PID 0和PID 1对于系统有特定的意义;其它的
进程标识符都被认为是普通进程。在本章后面对get_pid的讨论中,你会看到PID是
如何生成和赋值的。
在Linux中,PID不一定非要唯一——虽然通常都是唯一的,但是两个任务也可以共
享一个PID。这是Linux对线程支持的一个副作用,这些线程从概念上讲应该共享一
个PID,因为它们是同一个进程的一部分。在Linux中,你可以创建两个任务,并且
共享且仅共享它们的PID——从实际使用角度讲它们不会是线程,但是它们可以使
用同一个PID。这并没有多大的意义,但是如果你希望这样处理,Linux是支持的。

引用计数
引用计数是多个对象之间为共享普通信息而广泛使用的技术。使用更通用的术语来
说,一个或多个“容器对象”携带指向共享数据对象的指针,其中包含了一个称为
“引用计数(Reference Count)”的整数;这个引用计数的值和共享数据的容器
对象的个数相同。希望共享数据的新容器对象将被赋予一个指向同一结构的指针,
并且递增该共享数据对象的引用计数。
当容器对象离开时,就递减共享数据的引用计数,并做到“人走灯熄”——也就是
当引用计数减小到0时,容器对象回收共享对象。图7.1阐述了这种技术。


图7.1 引用计数

就象你随后会看到的那样,Linux通过使用引用计数技术来实现线程间的数据共享

权能
在早期的Unix中,你或者是root用户,或者不是。如果你是root,你几乎可以进行
任何希望进行的操作,即使你的想法实际上十分糟糕,例如删除系统引导盘上的所
有文件。如果你不是root,那么你就不可能对系统造成太大的损害,但是你也不能
执行任何重要的系统管理任务。
不幸的是,很多应用程序的需要都介于这两个安全性极端之间。例如,修改系统时
间是只有root才能执行的操作,因此实现它的程序必须作为root运行。但是因为是
作为root运行的,修改系统时间的进程也就能处理root可以完成的任何事情。对于
编写良好的程序来说并不会造成问题,但是程序仍然会有意无意地把系统搞得一团
糟。(数不清的计算机攻击事件都是欺骗root去运行一些看似值得信任的可执行代
码,造成了一些恶作剧。)
这些问题中有一些可以通过正确使用组和诸如sudo之类的程序而避免,但是有一些
则不行。对于某些重要的操作,虽然你可能只想允许它们执行一两种权限操作,你
也只能给予这些进程普通root访问许可。Linux对于这个问题的解决方法是使用从
现在已经舍弃了的POSIX草案标准中抽取出来的思想:权能。
权能使你可以更精确的定义经授权的进程所允许处理的事情。例如,你可以给一个
进程授予修改系统时间的权力,而没有授予它可以杀掉系统中的其它进程、毁坏你
的文件、并胡乱运行的权力。而且,为了帮助防止意外地滥用其优先级,长时间运
行的进程可以暂时获得权能(如果允许),只要时间足够处理特殊的零碎工作就可
以了,在处理完这个零碎的工作以后再收回权能。
在本书的编写期间,权能仍然处于开发状态。为了完全实现权能的预期功能,开发
者们还必须要实现一些新的特性——例如,目前还没有内核支持将程序的权能附加
到文件本身中。这样所造成的一个后果是Linux有时仍要检测进程是否作为root运
行,而不是检测所进程需要的特殊权能。但是迄今为止已经实现了的内容仍然是十
分有用的。
进程在内核中是如何表示的
内核使用几个数据结构来跟踪进程;其中有一些和进程自身的表示方法是密切相关
的,另外一些则是独立的。图7.2阐述了这些数据结构,随后就会对它们进行详细
介绍。
图7.2 管理任务使用的内核数据结构

16325:表示进程的内核数据结构是struct task_struct。我们暂时向前跳过这个
结构的定义,继续往下看。它相当大,但是可以从逻辑上划分为很多部分。随着本
章讨论的展开,你将会逐渐清楚它们每一部分的意义。在阅读的过程中,要注意这
个结构的很多部分都是指向其它结构的指针;这在子孙进程和祖先进程希望共享指
针所指向的信息时可以灵活运用——很多指针都指向正在被引用计数的信息。
16350:任务本身使用struct task_struct结构的next_task和prev_task成员组成
一个循环的双向链接列表,它被称为任务队列。的确,这忽略了一个事实,它们在
中心数组task(很快就会讨论)中早已存在了。最初这看起来可能有些奇怪,但实
际上这是十分有用的,因为这样允许内核代码可以遍历执行所有现存的任务——也
就是task中所有经过填充的时间片——而无须浪费时间跳过空时间片。实际上对这
个循环的访问是如此频繁,以至于在16898行单独为它定义了一个宏
for_each_task。
    虽然for_each_task是单向的,但是它有一些值得注意的特性。首先,注意到循环
的开始和末尾都是init_task。这是很安全的,因为init_task从来不会退出;因此
,作为标记它一直都是可用的。但是,注意到ini_task本身不是作为循环的一部分
而访问的——这恰好就是你使用这个宏时所需要的东西。还有,作为我们关心的一
小部分,你总是使用next_task成员直接向前遍历执行列表的;不存在相关的向后
执行的宏。也没有必要需要这样一个宏——只有在需要及时把任务从列表中处理清
除时才需要使用prev_task成员。
16351:Linux还保持一个和这个任务列表类似的循环的双向任务列表。这个列表使
用struct task_struct结构的prev_run成员和next_tun成员进行链接,基本上是作
为队列来处理的(这真值得让人举杯庆祝);出于这个原因,这个列表通常被称为
运行队列(run queue)。对于next_task来说,只是因为需要高效地将一个项移出
队列才会使用到prev_run成员;对于这个列表的遍历循环执行通常都是使用
next_run向前的。同样,在这个任务队列中也使用init_task来标记队列的开始和
末尾。
    通过使用add_to_runqueue(26276行)能够将任务加入队列,而使用
del_from_runqueue(26287行)则把任务移出队列。有时候分别使用
move_first_runqueue(26318行)和move_last_runqueue(26300行)把它们强制
移动到队列的开头和末尾。注意这些函数都是局限于kernel/sched.c的,在别的文
件中不会使用prev_run和next_run域(特别是在kernel/fork.c文件中的进程创建
期间);这是十分恰当的,因为只有在调度时才需要运行队列。
16370:首先,任务能够组成一个图,该图的结构表达了任务之间的家族关系;由
于我不清楚这个图所使用的通用术语,我就称它为进程图(process graph)。这
和next_task/prev_task之间的连接根本没有关系,在那里任务的位置是毫无意义
的——只是一个偶然的历史事件而已。每一个struct task_struct中有五个指向进
程图表中自己位置的指针。这五个指针在从16370行到16371行的代码中被定义。
?   p_opptr指向进程的原始祖先;通常和p_pptr类似。
?   p_pptr指向进程的当前祖先。
?   p_cptr指向进程的最年青(最近)子孙。
?   p_ysptr指向进程的下一个最年青(下一个最近)兄弟。
?   p_osptr指向进程的下一个最古老(下一个最远)兄弟。


图7.3  进程图

图7.3说明了它们之间的关系(整个链接集合都以标号为“Me”的节点为核心)。
这个指针的集合还提供了浏览系统中进程集合的另外一种方法;显然,在处理诸如
查找进程祖先或者查找列表中进程子孙时这个指针特别有用。这个指针是由两个宏
维护的:
?   REMOVE_LINKS(16876行)从图中移出指针。
?   SET_LINKS(16887行)向图中插入指针。
这两个宏都可以调整next_task/prev_task的链接。如果你仔细研究一下这两个宏
,你就会发现它们只是增加或者删除叶子进程——而从不对拥有子孙进程的进程进
行处理。
16517:task定义为由指向struct task_struct结构的指针组成的数组。这个数组
中的每一个项代表系统中的一个任务。数组的大小是NR_TASKS(在18320行设置为
512),它规定了系统中可以同时运行的任务数量的上限。由于一共有32,768个可
能的PID,由于数组不够大,要通过它们的PID直接索引系统中所有任务显然是不可
能的。(也就是task未必是由PID i指明的任务。)相反,Linux使用其它的数
据结构来帮助系统管理这种有限的资源。
16519:自由时间片列表tarray_freelist拥有一个说明task数组中自由位置的列表
(实际上是一个堆栈)。它在27966行和27967行初始化,接着被两个在16522行到
16542行定义的内联函数所使用。在SMP平台上,对于tarray_freelist的访问必须
受自旋锁taskslot_lock(23475行)的限制。(自旋锁在第10章中详细讨论。)
16546:pidhash数组有助于把PID映象到指向struct task_struct的指针。
pidhash在27969行和27970行初始化,此后它被一系列在16548行到16580行定义的
宏和内联函数所操纵。这些最终实现了一个普通的哈希表。注意,为了处理hush记
录,维护pidhash的函数使用了struct task_struct结构中的两个成员——
pidhash_next(16374行)和pidhash_pprev(16375行)。通过使用pidhash,内核
可以通过其PID有效地发现任务——虽然这种方式仍然比直接查找要慢。
    仅仅是为了好玩,你可以自己证明这个哈希函数——pid_hashfn,16548行——提
供了一个均匀覆盖其域0到32,767(所有有效的PID)的发行版本。除非你所谓的“
好玩”的概念和我不一样,否则你会同我一样感到有趣。
    这些数据结构提供了有关当前运行系统的很多信息,但是这也需要付出代价:每
当增加或删除进程时这些信息必须能够得到正确维护,否则系统就会变得混乱不堪
。部分出于实现这种正确的维护非常困难的考虑,进程只在一个地方创建(使用
do_fork,后面会讨论),也只在一个地方删除(使用release,也在后面中讨论)

如果我们能把task处理为32,768个struct task_struct结构组成的数组,其中的每
一项代表一个可能的PID,那么至少可以消除一部分这种类型的复杂性。但是这样
处理会大量增加内核对于内存的需求。每一个struct task_struct结构在UP平台上
占用964字节,在SMP平台上占用1,212字节——取整以后,近似的数字是1K。为了
容纳所有这些结构,task会像气球一样迅速膨胀到32,768K,也就是32M!(实际情
况会更糟糕:我们尚未提到的有关任务的额外内存开销会把这个数字增长8倍——
也就是256M——而且不要忘记了,这些开销实际上都还没有运行一个任务。)此外
,x86的内存管理硬件把活动任务的数量限制在4,000左右;这一主题在下一章介绍
。因此,数组中大多数的空间都会不可避免地被浪费了。
在目前的实现中,如果没有进程在运行,task仅仅是512个4字节的指针,总共才
2K。如果我们考虑到那些附加的数据结构会占用一些额外开销,可能有一些超过这
个数字,但是比起32M来还差得远呢。即使是task中的每一项都使用了,而且每个
struct task_struct结构也都分配了,总共使用的内存也才不过大约512K。应用程
序能够忽略这种微小的区别。
进程状态
在一个给定的时间,进程处于下面注释中描述的六种状态中的一种。进程的当前状
态被记录在struct task_struct结构的state成员中(16328行)。
16188:TASK_RUNNING意味着进程准备好运行了。即使是在UP系统中,也有不止一
个任务同时处于TASK_RUNNING状态——TASK_RUNNING并不意味着该进程可以立即获
得CPU(虽然有时候是这样),而是仅仅说明只要CPU一旦可用,进程就可以立即准
备好执行了。
16189:TASK_INTERRUPTIBLE是两种等待状态的一种——这种状态意味着进程在等
待特定事件,但是也可以被信号量中断。
16190:TASK_UNINTERRUPTIBLE是另外一种等待状态。这种状态意味着进程在等待
硬件条件而且不能被信号量中断。
16191:TASK_ZOMBIE意味着进程已经退出了(或者已经被杀掉了),但是其相关的
struct task_struct结构并没有被删除。这样即使子孙进程已经退出,也允许祖先
进程对已经死去的子孙进程的状态进行查询。在本章后面我们会详细介绍这一点。

16192:TASK_STOPPED意味着进程已经停止运行了。一般情况下,这意味着进程已
经接收到了SIGSTOP,SIGSTP,SITTIN或者SIGTTOU信号量中的一个,但是它也可能
意味着当前进程正在被跟踪(例如,进程正在调试器下运行,用户正在单步执行代
码)。
16193:TASK_SWAPPING主要用于表明进程正在执行磁盘交换工作。然而,这种状态
似乎是没有什么用处的——虽然该标志符在整个内核中出现了好几次,但是其值从
来没有被赋给进程的state成员。这种状态正在被逐渐淘汰。
进程来源:fork和_ _clone
传统的Unix实现方法在系统运行以后只给出了一种创建新进程的方法:系统调用
fork。(如果你奇怪第一个进程是哪里来的,实际上该进程是init,在第4章中已
经讨论过。)当进程调用fork时,该进程从概念上被分成了两部分——这就像是道
路中的分支——祖先和子孙可以自由选择不同的路径。在fork之后,祖先进程和其
子进程几乎是等同的——它们所有的变量都有相同的值,它们打开的文件都相同,
等等。但是,如果祖先进程改变了一个变量的值,子进程将不会看到这个变化,反
之亦然。子进程是祖先进程的一个拷贝(至少最初是这样),但是它们并不共享内
容。
Linux保留了传统的fork并增加了一个更通用的函数_ _clone。(前面的两个下划
线有助于强调普通应用程序代码不应该直接调用_ _clone,应该从在_ _clone之上
建立的线程库中调用这个函数。)鉴于fork创建一个新的子孙进程后,子孙进程虽
然是其祖先进程的拷贝,但是它们并不共享任何内容,_ _clone允许你定义祖先进
程和子孙进程所应该共享的内容。如果你没有给_ _clone提供它所能够识别的五个
标志,子孙进程和祖先进程之间就不会共享任何内容,这样它就和fork类似。如果
你提供了全部的五个标志,子孙进程就可以和祖先进程共享任何内容,这就和传统
线程类似。其它标记的不同组合可以使你完成介于两者之间的功能。
顺便提一下,内核使用kernel_thread函数(2426行)为了自己的使用创建了几个
任务。用户从来不会调用这个函数——实际上,用户也不能调用这个函数;它只在
创建例如kswapd(在第8章中介绍)之类的特殊进程时才会使用,这些特殊进程有
效地把内核分为很多部分,为了简单起见也把它们当作任务处理。使用
kernel_thread创建的任务具有一些特殊的性质,这些性质我们在此不再深入介绍
(例如,它们不能被抢占);但是现在主要需要引起注意的是kernel_thread使用
do_fork处理其垃圾工作。因此,即使是这些特殊进程,它们最终也要使用你我所
使用的普通进程的创建方法来创建。
do_fork
23953:do_fork是实现fork和_ _clone的内核程序。
23963:分配struct task_struct结构以代表一个新的进程。
23967:给新的struct task_struct结构赋予初始值,该值直接从当前进程中拷贝
而来。do_fork的剩余工作主要包含为祖先进程和子孙进程不会共享的信息建立新
的拷贝。(在本行和整个内核中你可以看到的current是一个宏,它把一个指针指
向代表当前正在执行的进程的struct task_struct结构。这在10285行定义,但实
际上只是对get_current函数的一个调用,而后者的定义在10277行。)
23981:新到达者需要task数组中的一个项;这个项是使用find_empty_process(
23598行——它严格依赖于16532行的get_free_taskslot)找到的。然而,它工作
的方式有点不明显:task数组没有使用的成员不是设置为空,而是设置为自由列表
的下一个元素(使用add_free_taskslot,16523行)。因此,task中没有使用的项
指向链接列表中另外一个task没有使用的项,而tarray_freelist仅仅指向这个列
表的表头。那么,返回一个自由位置就简单地变成了返回列表头的问题了(当然要
把这个头指针指向下一个元素)。更传统的方法是使用一个独立的数据结构来管理
这些信息,但是在内核中,空间总会显得有些不足。
23999:给新的任务赋PID(其中的细节很快就会介绍)。
24045:本行和下面几行,使用该文件中别处定义的辅助函数,根据所提供的
clone_flags参数的值为子孙进程建立祖先进程的数据结构中子孙进程所选择部分
的拷贝。如果clone_flags指明相关的部分应该共享而不是拷贝,这时辅助函数(
help function)就简单地增加引用计数接着返回;否则,它就创建新进程所独有
的新的拷贝。
24078:到现在为止,所有进程所有的数据结构都已经设置过了,但是大部分跟踪
进程的数据结构还没有被设置。系统将通过把进程增加到进程图表中开始设置它们

24079:通过调用hash_pid把新的进程置入pidhash表中。
24088:通过调用wake_up_process(26356行)把新的进程设置为TASK_RUNNING状
态并将其置入运行队列。
注意到现在不止是struct task_struct结构被设置了,而且所有相关的数据结构—
—自由时间片列表,任务列表、进程图、运行队列和PID hash表——这些都已经为
新的到达者正确地进行了修改。恭喜你,你现在已经得到了一个健康的子孙任务。

PID的分配
PID是使用get_pid函数(23611行)生成的,该函数能够返回一个没有使用的PID。
它从last_pid(23464行)开始——这是最近分配的PID。
内核中使用的get_pid的版本是内核复杂性和速度频繁折中的一个例子;这里速度
更为重要一些。get_pid经过了高度优化——它比直接向前的实现方法要复杂的多
,但是速度也要快的多。最直接的实现方法将遍历执行整个任务列表——典型的情
况可能有几十项,有时候也可能成百上千项——对每一个可能的PID进程检测并找
出适当的值。我们见到的版本有时是必须执行这些步骤的,但是在大多数情况下都
可以跳过。这一结果被用来帮助加速进程创建的操作,它在Unix上慢得臭名卓著。

如果我们所需要的只是要为每一个运行进程都快速计算一个各不相同的整数,那么
这里已经有现实可用的方法:只要取在task数组中进程的索引就可以了。平均说来
,这肯定要比现在的get_pid速度要快——毕竟,这无须遍历任务列表。不幸的是
,很多现存的应用程序都假定在一个PID可以再重用之前都需要等待一段时间。这
种假定在任何情况下都是不安全的,但是在如果
回复

使用道具 举报

 楼主| 发表于 2003-10-27 09:45:38 | 显示全部楼层
Linux内核注释——第8章 内存  

内存是内核所管理的最重要的资源之一。某进程区别于其它进程的一个特征是两个
进程存在于逻辑上相互独立的内存空间(与之相反,线程共享内存)。即使进程都
是同一程序的实例,比如,两个xterm或两个Emacse,内核都会为每个进程安排内
存空间,使得它们看起来像是在系统之上运行的唯一进程。当一个进程不可能偶然
或恶意的修改其它进程的执行空间时,系统的安全性和稳定性就会得到增强。
内核也生存在它自己的内存空间之中,即内核空间(kernel space)。与之对应的
是用户空间(user space),它是所有非内核任务所处的内存空间的一个通用术语

虚拟内存
计算机系统包括不同级别的存储器。图8-1说明了这些存储器中最重要的几项,并
且以我自己原有的Linux机器(Linux box)为例标注了一些参数的估计值。当你从
左向右观察该图时,会发现存储器容量越来越大而速度却越来越慢(而且每字节价
格也会更低)。尤其令人注意的是,访问速度跨越了3个数量级(乘数因子为1000
),而容量竟跨越了超过8个数量级(乘数因子为312500000)。(实际上有时速度
的差异是可以被掩盖的,不过这些数字足以很好的说明这一部分讨论的目的。)最
大的差距体现在最后两个:RAM和磁盘上,它们又分别可被称作主存和辅存。
额外附加的存储器空间总是十分诱人的,即使它们也很慢。如果在RAM被用完时,
通过暂时把不用的代码和数据转移到磁盘上以腾出更多空间的方法来使用磁盘代替
RAM的话,那将是很好的一件事情。正如读者可能已经知道的,Linux恰好能够做到
这一点,这被称之为虚拟内存(virtual memory)。
虚拟内存是一种对RAM和磁盘(或称之为:主存和辅存)进行无缝混合访问的技术
。所有这些(虚拟)内存对于应用程序来说就好像它真的存在一样。当然我们知道
它并非真的内存,这正是为什么它被称为是“虚拟的”,但是多亏了内核使得应用
程序无法分辨出它们的区别。对于应用程序来说,就好像真的有很大数量的RAM,
只不过有时候比较慢而已。
术语“虚拟内存”还有另外一层意思,从严格意义来讲是与前述的第一种意思没有
关系的。这里的虚拟内存指的是对进程驻留地址进行欺骗的方法。每个进程都会有
这样一种错觉,认为它的地址是从0开始并由此连续向上发展的。很明显,这一点
同时对所有进程都成立是不可能的,但是在生成代码的时候这个假定(fiction)
却能够带来很大方便,这是由于进程不必知道它们是否真正从0地址开始驻留,而
且它们也不必去关心此事。
这两种意思也不必相关,因为一个操作系统从理论上可以给每个进程分配一个独有
的逻辑地址空间而不用混合使用主存和辅存。然而在所有我已经知道的系统中(对
这两种虚拟内存的实现方式)要么都采纳要么都不采纳,这一点可能会在开始时令
人感到困惑。
为了避免这种意义上的分歧,有人倾向于术语“虚拟内存”代表逻辑地址空间(
logical- address-space)的意义,同时使用“分页(paging)”或“交换”表示
磁盘作为内存使用(disk-as-memory)的含义。尽管这种严格的区分具有充足的理
由,但是我更喜欢普通的用法。除非上下文要求,否则我很少花费精力对它们进行
区分。








图8-1 具有速度和容量的存储级别
交换和分页
早期的虚拟内存(VM)系统仅能够把整个应用程序代码和数据,即完整的进程从磁
盘上移出或移入磁盘。这种技术被称为交换(swapping),因为它是把一个进程同
另一个进程进行了对调。出于这个原因,磁盘上为VM所保留的区域通常被称为交换
空间(swap space),或简称为交换区(swap),尽管如我们所见,现代的系统已
不再使用这种最初意义上的交换技术。与此类似,读者通常会见到的术语是交换设
备(swap device)和交换分区(swap partition),它是磁盘分区的同义词,但
是被专门作为交换空间使用,以及术语交换文件(swap file),这是一个用于交
换的规则的、有固定长度的文件。
交换是很有用的,当然要比根本没有VM好的多,但是它也有一定局限性。首先,交
换需要把整个进程同时调入内存,所以当运行一个需要比系统所有RAM还要大的存
储空间的进程时,交换便于事无补了,即使磁盘有大量空间可供补充。
其次,交换可能会很低效。交换就必须把整个进程同时调出,这就意味着为了2K的
空间你不得不把一个8MB的进程整个调出。同样的道理,即使仅仅需要执行被调进
的应用程序代码的一小部分,你也必须把整个进程同时调进。
分页(paging)是把系统的内存划分成很小的块,即页面,每个页面可以独立的从
磁盘调入或调出磁盘。分页与交换技术相似,但它使用更加细小的粒度(
granularity)。分页比交换有更多的登记(book-keeping)开销,这是因为页面
数远比进程数要多,然而通过分页可以获得更多的灵活性。而且分页也更快一些,
原因之一就是不再需要把整个进程调进调出,而只需要交换必要的页面就足够了。
要记住前述的1000倍的速度差异,所以我们应该尽可能避免磁盘的I/O操作。
传统上特定平台上页面的大小是固定的,比如x86平台为4K,这可以简化分页操作
。不过,大多数CPU为可变大小的页面提供硬件支持,通常能够达到4M或者更大。
可变大小页面可以使分页操作执行更快和更有效,不过要以复杂性为代价。标准发
行的Linux内核不支持可变大小页面,所以我们仍然假定页面大小是4K。(已经有
支持Cyrix可变大小页面机制的补丁程序,但它们不是本书中官方发行版本的部分
。而且据闻由此获得的性能增益也并不非常显著。)
因为分页可以完成交换所能完成的所有工作,而且更加有效,所以类似于Linux一
样的现代操作系统已不再使用交换,严格的说是只使用分页技术。但是术语“交换
”已得到了广泛使用,以至于实际应用中术语“交换”和“分页”已经几乎可以通
用;由于内核使用分页技术,所以本书就遵从这种用法。
Linux能够交换到一个专用磁盘分区、或一个文件,或是分区和文件的不同组合。
Linux甚至允许在系统运行时增加和移去交换空间,当你暂时需要额外大量的交换
空间,或者假如你发现需要额外交换空间而又不想重启系统的时候,这就会很有用
了。另外,与一些Unix的风格(flavors)不同,Linux即使没有任何交换空间也能
运行得很好。
地址空间
地址空间(address space)是一段表示内存位置的地址范围。地址空间有三种:

?   物理地址空间
?   线性地址空间
?   逻辑地址空间,也被称为虚拟地址空间
(需要指出的是,I/O地址能够被看作是第四种地址空间,但是本书中对其不作讨
论。)
物理地址是一个系统中可用的真实的硬件地址。假如一个系统有64M内存,它的合
法地址范围是从0到0x4000000(以十六进制表示)。每个地址都对应于所安装的
SIMMs中的一组晶体管,而且对应于处理器地址总线上的一组特定信号。
分页可以在一个进程的生存期里,把它或它的片段移入或者移出不同的物理内存区
域(或不同物理地址)。这正是进程被分配一个逻辑地址空间的原因之一。就任何
特定的进程来说,从0开始扩展到十六进制地址0xc0000000共3GB的地址空间是绰绰
有余的。即使每个进程有相同的逻辑地址空间,相应进程的物理地址也都是不同的
,因此它们不会彼此重叠。
从内核的角度看来,逻辑和物理地址都被划分成页面。因此,就像我们所说的逻辑
和物理地址一样,可以称它们为逻辑和物理页面:每个合法的逻辑地址恰好处于一
个逻辑页面中,物理地址也是这样的。
与之相反,线性地址通常不被认为是分页的。CPU(实际是下文中的MMU)会以一种
体系结构特有的方式把进程使用的逻辑地址转换成线性地址。在x86平台上,这种
转换是简单地把虚拟地址与另一地址,即进程的段基址相加;因为每个任务的基址
都被设置为0,所以在这种体系结构中,逻辑地址和线性地址是相同的。得到的线
性地址接着被转换成物理地址并与系统的RAM直接作用。
内存管理单元
在逻辑地址和物理地址之间相互转换的工作是由内核和硬件内存管理单元(MMU—
memory management unit)共同完成的。MMU是被集成进现代的CPU里的,它们都是
同一块CPU芯片内的一个部分,但是把MMU当作一个独立的部分仍然非常有益。内核
告诉MMU如何为每个进程把某逻辑页面映射到某特定物理页面,而MMU在进程提出内
存请求时完成实际的转换工作。
当地址转换无法完成时,比如,由于给定的逻辑地址不合法或者由于逻辑页面没有
对应的物理页面的时候,MMU就给内核发出信号。这种情况称为页面错误(page
fault),本章后面会对此进行详细论述。
MMU也负责增强内存保护,比如当一个应用程序试图在它的内存中对一个已标明是
只读的页面进行写操作时,MMU就会通知OS。
MMU的主要好处在于速度。缺少MMU时为了获得同样的效果,OS将不得不使用软件为
每个进程的每一次内存引用进行校验,这种校验同时包括数据和指令在内,而这可
能还包括要用为进程创建其生存所需的虚拟机。(Java所进行的一些工作与此类似
。)这样做的结果将使系统慢得令人无法忍受。但是一个以这种内存访问合法性检
查方式集成在计算机硬件里的MMU却根本不会使系统变慢。在MMU建立起一个进程以
后,内核就只是偶尔参与工作,例如在发生页面错误时,而这与全部内存引用数量
相比是非常少的。
除此而外,MMU还可以协助保护内存自身。没有MMU,内核可能不能够防止一个进程
非法侵入它自己的内存空间或者是其它进程的内存空间。但是如何避免内核也会作
同样的操作呢?在Intel’s 80486或更新的芯片上(不是80386),MMU的内存保护
特性也适用于内核进程。
页目录和页表
在x86体系结构上,把线性地址(或者逻辑地址——记住在Linux上,这二者具有相
同的值)解析(resolving)到物理地址分为两个步骤,整个过程如图8-2所示。提
供给进程的线性地址被分为三个部分:一个页目录索引,一个页表索引和一个偏移
量。页目录(page directory)是一个指向页表的指针数组,页表(page table)
是一个指向页面的指针数组,因此地址解析就是一个跟踪指针链的过程。一个页目
录使你能够确定一个页表,继而得到一个页面,然后页面中的偏移量(offset)能
够指出该页面里的一个地址。
为了进行更详细因而也会更准确的描述:给定页目录索引中的页目录项保存着贮存
在物理内存上的一个页表地址;给定页表索引中的页表项保存着物理内存上相应物
理页面的基地址;然后线性地址的偏移量加到这个物理地址上形成最终物理页面内
的目的地址。
其它CPU使用三级转换方法,如图8-3所示。这在64位体系中尤其有用,以Alpha为
例,其更大的64位的地址空间意味着类似于x86体系的地址转换将要求大量的页目
录、大量页表、大量偏移量,或三者兼有。对于这种情况,Alpha的设计者们向线
性地址模式中引入了另一层次,即Linux所称的页面中间目录(page middle
directory),它位于页目录和页表之间。
这个方案与以前实际是一样的,只不过多增加了一级。这种三级转换方法同样具有
页目录,页目录的每一项包含一个页面中间目录的入口地址,页面中间目录的每一
项包含一个页表的入口地址,而页表也同以前一样每一项包含物理内存中一个页面
的地址,这个地址再加上偏移量就得到了最终的地址。
而使情况更为复杂的是,通过进一步观察可知,三部分地址模式与两级地址转换是
相关联的,而四部分地址模式则与三级地址转换相关联的,这是由于我们通常所说
的“级(或层次levels)”不包括索引到页目录的第一步(我想是因为这一步没有
进行转换的缘故)。
令人奇怪的是内核开发者们决定只用其中一种模式来处理问题。绝大部分的内核代
码对MMU一视同仁,就如同MMU都使用三级转换方法(也就是四部分地址模式)一样
。在x86平台上,通过将页面中间目录定义为1,页面相关的宏可以把三级分解过程
完美地转换到二级分解过程上去。这些宏认为页面中间目录和页目录是几乎可以进
行相互替换的等价品,以至于内核的主要代码可以认为其地址就是由四个部分组成
的。
在x86系统中,32位地址中10位是页目录索引,接下来10位是页表索引,剩下的12
位用作偏移量,这就构成了4K大小的页面(212等于4096个偏移量)。
用于创建和操作每一级项的函数和宏定义在头文件include/asm-i386/page.h(第
10786行)和include/asm-i386/pgtable.h(第10876行)之中。在读者浏览这些函
数和宏的时候,记住PGD通常代表“页目录项(page directory entry)”(不只
是“页目录”),PMD通常代表“页面中间目录项(page middle directory
entry)”(不只是“页面中间目录”),同样PTE也通常代表“页表项”。而且,
正如上面解释中限定词“通常”所暗示的那样,例外是存在的,例如下文将要提到
的pte_alloc就分配页表而不是(如你所可能会认为的)页表项。非常遗憾的是,
由于篇幅所限我们不能对全部例程进行讨论,我们将在后面对其中的一部分进行讨
论。
页表项不仅记录了一个页面的基地址,而且记录了它的保护信息(protections)
,也就是一组指定该页为可读、可写,和/或可执行的标志(这容易让人联想到文
件的保护位)。随着我们对页面保护信息的进一步剖析,读者会看到页表项所包括
的其它页面特有的标志。



转换后备缓存(Translation Lookaside Buffers:TLBs)
如果简单的执行从线性地址到物理地址的转换过程,在跟踪指针链时将会需要几个
内存引用。RAM虽然不像磁盘那么慢,但是仍然比CPU要慢的多,这样就容易形成性
能的瓶颈。为了减少这种开销,最近被执行过的地址转换结果将被存储在MMU的转
换后备缓存(translation lookaside buffers:TLBs)内。除了偶尔会通知CPU,
由于内核的某操作致使TLBs无效之外,Linux不用明确管理TLBs。
在作用于TLB的函数和宏中,我们只研究一下__flush_tlb,在x86平台上,它是其
它大部分函数和宏的基础。
__flush_tlb
10917:CR3(控制寄存器3)是x86CPU寄存器,它保存页目录的基地址。往这个寄
存器送入一个值将会使CPU认为TLBs变成无效,甚至写入与CR3已有值相同的值也是
这样。
       因此,__flush_tlb仅是两条汇编程序指令:它把CR3的值保存在临时变量
tmpreg里,然后立刻把tmpreg的值拷贝回CR3中,整个过程就这么简单!
       注意x86系统也允许使某一个单独的TLB项无效,而并不一定非要使全部项
,这种方法使用invipg指令——参见第10926行它的使用信息。

由于段不是在所有CPU中均可用,所以Linux内核中与体系结构无关的部分不能对段
进行辨识。在不同的CPU体系中,段的(如果段在体系中是可用的)处理方式大相
径庭,这一点是非常重要的。因此,我们在这个问题上不会花费太多时间,不过
x86系统上内核使用段的方式还是值得大概讨论一下的。
段可以被简单的看作是定义内存区域的另一种机制,有些类似于页。这两种机制可
以重叠:地址总是在页面之内,也可能处于段内。与页不同,段的大小可以变化,
甚至在其生存期里能够增长和收缩。与页相同的是,段可以被保护,而且其保护可
由硬件实施;当x86的段保护和同一地址的页保护发生冲突时,段保护优先。
X86系统使用一些寄存器和全局描述表(GDT)和局部描述表(LDT)这两种表来对
段进行跟踪。描述符(descriptor)是段的描述信息,它是用来保存段的大小和基
址以及段的保护信息的8字节的对象。GDT在系统中只有一个,而Linux可以为每个
任务建立一个LDT。
接下来我们将简单解释内核是如何使用这些表来建立段的。内核本身拥有分离的代
码和数据段,它们被记述在GDT的第2和第3行项里。每个任务也有分离的代码和数
据段。当前任务的代码段和数据段不仅在它自己的LDT的第0和第1行项被说明,而
且还被记述在GDT的第4和第5行项里。
在GDT里,每个任务占两行项,一个用来定位它的LDT,一个用来定位它的TSS(前
面章节曾简要提及的任务状态段)。因为x86CPU限制GDT的大小为8192个项,而且
Linux为每个任务占用两行GDT项,因此显而易见的是我们不能拥有超过4096个任务
,这也正是在第7章里提到的限制。事实上,任务的最大数目要稍小一点儿,不过
仍有4090个,这是由于GDT的前12行项被保留用于其它目的。
富有经验的x86程序员可能已经注意到Linux所使用的x86分段机制是采用最低限度
方式的;段的主要使用目的仅是为了避免用户代码出现在内核段中。Linux更倾向
于分页机制。从大的方面来看,对于处理器来说分页或多或少都是相同的,或者说
总的事实就是这样,因此内核越是以分页方式工作,它的可移植性就越好。
最后要提及的是,如果读者对于x86的分段机制很感兴趣的话,不妨阅读一下
Intel体系结构下的软件开发手册第3卷(Intel Architecture Software
Developer’s Manual Volume 3),该书可以从Intel站点上免费得到(
developer.intel.com/design/pentiumii/manuals/243192. htm)。
进程的内存组织
有三个重要的数据结构用于表示进程的内存使用:struct vm_area_struct(第
15113行)、struct vm_operations_struct(第15171行),和struct mm_struct
(第16270行)。我们随后将对这三个数据结构进行逐一介绍。
Struct vm_area_struct
内核使用一个或更多的struct vm_area_struct来跟踪进程使用的内存区域,该结
构体通常缩写为VMAs。每个VMA代表进程地址空间的一块单独连续的区间。对于一
个给定的进程,两个VMAs决不会重叠,一个地址最多被一个VMA所覆盖;进程从未
访问过的的一个地址将不会在任何一个VMA中。
两个VMA之间的区别有两个特征:
?   两个VMA可以不连续(Two VMAs may be discontiguous)——换句话说,一个
VMA的末尾不一定是另一个的开头。
?   两个VMA的保护模式可以不同(Two VMAs may have different protections)—
—例如,一个是可写的而另一个可能是不可写的。即使两个这样的VMA是连在一起
的,它们也必须被分开管理,因为其不同的保护信息。
应注意的一个重点是,一个地址可以被一个VMA所覆盖,即使内核并没有分配一个
页面来存贮这个地址。VMA的一个主要应用就是在页面错误时决定如何作出反应。
我们可以将VMAS看作是一个进程所占用的内存空间以及这些空间的保护模式的总体
视图。内核能够反复重新计算从页表而来的VMA中的大部分信息,不过那样速度会
相当慢。
进程的所有VMA是以一个排序的双向链表方式存储的,并且它们使用自己的指针来
管理该列表。当一个进程有多于avl_min_map_count数目(在第16286行定义为32)
的VAM时,内核也会创建一个AVL数来存储它们,此时仍然是使用VMAs自己的指针对
该树进行管理。AVL树是一个平衡二叉树结构,因此这种方法在VMA数量巨大时查找
效率十分高。不过,即使在AVL树被创建后,线性列表也会被保留以便内核即使不
使用递归也能轻松的遍历一个进程的所有VMA。
Struct_vm_area_struct的两个最重要的元素是它的vm_start和vm_end成员(分别
在第15115行和15116行),它们定义了VMA所覆盖的起止范围,其中vm_start是
VMA中的最小地址,而vm_end是比VMA最大地址大一位的地址。在本章后面我们会反
复提及这些成员。
注意,vm_start和vm_end的类型是unsigned long,而不是读者可能会认为的
void*。由于这个原因,内核在所有表示地址的地方都使用unsigned long类型,而
不用void*类型。采用这种方法的部分原因是可以避免因内核对诸如比特一级的地
址进行计算操作时引起的编译警告,还可能避免由于它们而偶然引起的间接错误。
在引用内核空间的一个数据结构的地址时,内核代码使用指针变量;在对用户空间
的地址进行操作时,内核却频繁的使用unsigned long——实际上,几乎只有本章
中所涉及的代码才是这样。
这样就给用来编译内核的编译器提出了要求。使用unsigned long作为地址类型就
意味着编译器必须使unsigned long的类型长度和void*的一样,尽管实践中对这一
点的要求不是十分严格。对于x86寄存器上的gcc来说,两种类型很自然的都是32位
长。在64位指针长度的体系中,比如Alpha,gcc的unsigned long类型长度通常也
是64位。尽管如此,在将来的体系结构上,gcc的一个端口可能提供与void*不同的
unsigned long类型长度,这是需要内核的移植版本开发人员(kernel porters)
注意的一点。
还要说明的是,除了gcc之外你不需要对编译器的性能有太多担心,因为其它大部
分与gcc相关的特性都已经包括在代码之中了。假如读者试图用某个其它的编译器
来编译内核的话,我想有关unsigned long和void*长度的错误将会占编译错误列表
的绝大多数。
Struct vm_operations_struct
一个VMA可能代表一段平常的内存区间,就像是malloc函数所返回的那样。但是它
也可以是对应于一个文件、共享内存、交换设备,或是其它特别的对象而建立的一
块内存区域;这种对应关系是由本章后面将要涉及的称为mmap的系统调用所确定的

我们不想牵扯太多关于VMA可以被映射的每一种对象的专门知识,这会使对内核代
码的剖析变得凌乱不堪,因为那样就不得不反复决定是否要关闭一个文件、分离共
享内存等等令人非常头疼的事情。与此不同,对象类型struct
vm_operations_struct抽象了各种可能提供给被映射对象的操作,比如打开、关闭
之类。一个struct vm_operations_struct结构体就是一组函数指针,它们之中可
能会是NULL用来表示一个操作对某个被映射对象是不可用的。举例来说,在一个共
享内存没有被映射的情况下,把该共享内存对象的页面与磁盘进行同步是没有意义
的,表示共享内存操作的struct vm_operations_struct里的sync成员就是NULL。

总之,一旦VMA映射为一个对象,那么它的vm_ops成员就会是一个非空的指针,指
向一个表示被映射对象所提供操作的struct vm_operations_struct结构体。对于
VMA可以映射的每一种对象类型,都有一个该VMA可能会在某处指向的静态static
struct vm _ operations_struct结构体。参见第21809行这样的一个例子。
Struct mm_struct
一个进程所保留的所有VMA都是由struct mm_struct结构体来管理的。指向这种结
构类型的指针在struct task_struct中,确切的说,它就是后者的mm成员。这个成
员被前一章中所讨论的goodness(第26388行)应用,来判断是否两个任务是在同
一个线程组中。两个具有相同mm成员(正如我们所见到的)的任务管理同一块全局
内存区域,这也是线程的一个特点。
struct mm_struct结构体的mmap成员(第16271行)就是前述的VMA的链接列表,而
它的mmap_avl成员,如果非空,就是VMA的AVL树。读者可以浏览struct
mm_struct的定义,会发现它还包括相当多的其它成员,它们中的几个会在本章中
涉及到。
VMA的操作
本小节介绍后面要用到的find_vma函数,并捎带简介它的同类函数find_vma_prev
。这将阐明VMA处理操作的一些方面,也为读者将要接触的代码做准备。
find_vma
33460:简单说来,find_vma函数的工作就是找到包含某特定地址的第一个VMA。更
准确   的说,它的工作是找到其vm_end比给定地址大的第一个VMA,这个地址可能
会在该VMA之外,因为它可以比VMA的vm_start要小。这个函数返回指向VMA的指针
,如果没有满足要求的VMA就返回NULL。
33468:首先,通过使用mm的mmap_cache成员,满足进程最近一次请求的同一VMA会
被检查,而mmap_cache正是为此目的而设。我没有亲自测试过,不过这个函数的文
档中说高速缓存的命中率可以达到35%,考虑到高速缓存只由一个VMA组成,那么这
个数字就相�
回复

使用道具 举报

发表于 2003-10-27 09:46:20 | 显示全部楼层
good
回复

使用道具 举报

 楼主| 发表于 2003-10-27 10:01:04 | 显示全部楼层
Linux内核注释——第9章System Ⅴ IPC


Unix从开发的早期就提供了管道的机制,管道在同一机器的两个进程间的双向通信
方面工作的相当出色。后来,BSD(Berkeley Software Development)的Unix版本
又提供了通用的套接字socket,它用来在不同机器的两个进程之间进行通信(或者
是同一机器的)。
Unix System V版本增加了被视为一体的三个机制,现在它们被统称为System V
IPC。像管道一样,这些机制都可以用于同一机器上的进程间通信,不过与管道和
套接字不同的是,System V的IPC特性使得同一机器上的许多进程之间都可以互相
通信,而不是仅限于两个进程。而且,管道——不是套接字——还有一个更大的限
制就是两个通信中的进程必须相关。它们必须有一个建立管道的共同祖先进程——
通常情况下,一个进程是另一个的父进程,或者这两个都是为它们建立管道的父进
程的子进程。System V IPC像套接字一样使得进程间通信(IPC)不需要有共同的
继承关系,只需要一个经过协商的的协议。
组成System V IPC的三个进程间通信机制是:消息队列、信号量和共享内存。
消息队列
System V的消息队列(message queues)是进程之间互相发送消息的一种异步(
asynchronously)方式,在这种情形之下,发送方不必等待接收方检查它的消息—
—即在发送完消息后,发送方就可以从事其它工作了——而接收方也不必一直等待
消息 。对消息进行编码和解码是发送者和接受者进程的工作;消息队列的执行并
不会给它们特别的帮助。这就形成了一个实现起来相对比较简单的通用机制,尽管
是以增加应用程序的复杂度为代价来获得这种简明性的。
这里是一个可能发生在SMP机器上的简单的应用情景。运行在一个CPU上的调度程序
把工作请求发送到一个特定的消息队列上。工作请求可能以各种形式出现:用来破
译代码的一组密码、需要进行计算的在不规则图形里的象素范围、在一个原子系统
里要更新的一部分空间,或者诸如此类的任务。与此同时,工作者进程在其它CPU
上运行,只要它们空闲就从消息队列中检索消息,然后再把结果消息发送到另一个
消息队列上去。
这种体系结构很容易实现,而且假定选择好了每个消息中被请求工作的粒度,就能
极大的提高机器中CPU的利用效率。(还要注意的是,因为调度进程可能不用做许
多工作,所以调度进程的CPU上大部分时间也可以运行一个工作者进程。)以这种
方式,消息队列可以被用作是远程过程调用(RPC)的一种低级形式。
新消息总是加在队列的末尾,不过它们并不总是从排头移出;你将能够在本章中看
到,消息可以从队列的任何地方被移出。在某个方面,消息队列与语音邮件类似:
新消息总是在末尾,不过消息接收方可以从列表的中间接收(以及删除)消息。
消息队列概述
首先对消息队列进行介绍是因为它的实现最简单,不过它仍然体现出了几个所有三
种System V IPC机制都具有的共同结构特征。
给进程提供了四种与队列相关的系统调用:
?   msgget——一个不合时宜的名字:读者可能认为这会得到一个等待的消息。但实
际它不会。调用者提供一个消息队列键标(key),如果存在一个队列,msgget就
用该键标为它返回一个标识号,如果没有队列,就用它为一个新的消息队列返回一
个标识号。因此,msgget所得到的不是一个消息,而是唯一标识一个消息队列的标
识号。
?   msgsnd——向一个消息队列发送一条消息。
?   msgrcv——从一个消息队列中接受一条消息。
?   msgctl——在消息队列上执行一组管理操作——检索关于它的限制的信息(比如
队列所允许的最大消息数据量)、删除一个队列,等等。
Struct msg
15919:struct msg代表在队列中等待的一个消息。它有如下成员:
?   msg_next——指向队列中的下一个消息——当然假如这是最后一个消息就为
NULL。
?   msg_type——用户指定类型编码;它的使用在本章讨论消息如何被接收时再进行
分析。
?   msg_spot——指向消息内容的开头。读者后面将看到,为消息分配的空间总是紧
靠在struct msg的上边,因此msg_spot恰恰指向struct msg末尾之后的位置。
?   msg_stime——记录消息被发送的时间。因为消息以先进先出(FIFO)顺序保存
,所以队列中的消息拥有的msg_stime值就是单调非递减的。
?   msg_ts——记录消息的大小容量(“ts”是“text size”的缩写,尽管消息不
一定非要是人们可以读懂的文本)。一条消息的最大容量是MSGMAX,它在15902行
定义为4056字节。推测一下,这应该是4K(4096字节)减去一个struct msg的结果
。不过b只有20字节,因此还有另外的20字节有待说明。
Struct msqid_ds
15865:msqid_ds代表一个消息队列。它有如下成员:
?   msg_perm——说明哪一个进程可以读写该消息队列。
?   msg_first和msg_last——指向队列中的第一个和最后一个消息。
?   msg_stime和msg_rtime——分别记录消息被发送入队列的最后时间和消息从队列
中读出的最后时间。(一项挑战:什么时候队列中最后一条消息的msg_ stime成员
不等于队列本身的msg_stime成员?至少有两个答案,但是你所掌握的信息现在只
能得出一个——你将不得不仔细阅读代码以寻求另一个解答。)
?   msg_ctime——上一次队列改变的时间——它被创建的时间,或是上一次利用
msgctl系统调用设置参数被确信的时间。
?   wwait——等待写消息队列的进程队列。因为消息发送是异步的,通常进程把一
个消息写入消息队列后就可离开。但是,为了避免拒绝服务(denial-of-service
)的攻击,队列有一个最大容量——若没有这个限制,一个进程就可以不断的向一
个没有读者的队列发送消息,强迫内核为每个消息分配内存直至空间耗尽。因此,
当一个队列达到其最大容量时,想要发送消息给该队列的进程必须等待,直到队列
中有了空间容纳新的消息,或者发送消息的尝试被立刻拒绝为止(读者将看到,进
程能够选择它所希望的执行方式)。wwait队列保留那些决定等待的进程。
?   rwait——与之类似,消息通常可以从消息队列中立刻读出。但是如果没有正等
待被读的消息将怎么办呢?进程再次进行选择:它们要么立刻重获控制(用一个错
误代码表示读消息失败)要么进入休眠等待消息到来。
?   msg_cbytes——当前在队列中的所有消息的总字节总数。
?   msg_qnum——队列中消息的总数。对于能够进入队列的消息数目没有明确的限制
——这也是一个问题,本章随后还要进行解释。
?   msg_qbytes——队列中允许存储的所有消息的最大字节数;把msg_cbytes和
msg_qbytes进行比较来确定是否还有空间容纳新消息。msg_qbytes缺省为MAGMNB,
尽管这个限制可以被有适当权限的用户动态地增加。
MAGMNB在15904行定义为16384。有四个理由说明为什么这个界限被定的这样低。第
一,实际上,你通常不需要把太多的信息包括在一个给定的消息中,所以这个界限
并不是十分苛刻的。第二,如果消息发送方的速度远远领先于接收方,那么让消息
能多包含些信息可能也没有意义——它们还将是接收方要费些时间才能得到的一大
块数据。第三,这个每队列16K的界限可以与潜在的128个队列相乘,总计达2MB。

但是采用这个界限的主要原因还是为了避免先前提及的拒绝服务攻击。然而,没有
什么能防止应用程序发送长度为零的(也就是空的)消息。msg_qbytes不会被这样
的消息影响,而且仍然要给消息头分配内存,因此拒绝服务攻击仍然是可行的。解
决这个问题的一个方案是引入一个独立的、对允许进入队列的消息总数进行限制的
界限;另一个方案是从msg_qbytes中减去整个消息长度——包括消息头。再一种解
决方法当然是不允许有空消息,但这又将同兼容性相抵触。
?   msg_lspid和msg_lrpid——最后消息发送方和最后消息接收方的PID。
Msgque
20129:消息队列实现中的主要数据结构是msgque,一个指向struct msqid_ds的指
针数组。这些指针有一个是MSGMNI(15900行定义为128),它等于128个消息队列
的最大值。为什么不只是用一个的数组而要用一个指针数组呢?一个原因是为了节
省空间:替代一个128个56字节结构体的数组(7168字节,7K),msgque是一个
128个4字节指针的数组(512字节)。在正常情况下,当很少的消息队列投入使用
,这能够节约好几千字节的空间。在最坏的情况时,所有的消息队列都被分配了,
最大的消耗也只是512字节。唯一会引发的真正缺点是附加了一层间接转换,这意
味着速度要有少许损失。
    主要消息队列数结构之间的关系如图9.1所示。









图9.1 消息队列数据结构

Msg_init
20137:msg_init用于消息队列实现时变量的初始化。它的大部分都是不必要,因
为同样的变量已经在函数前面紧挨本段代码的声明中被初始化为同样的值了。
20141:然而这个把msgque的条目设置为IPC_UNUSED的循环是必要的。IPC_UNUSED
不在本书讨论之列,值为-1(能够更好的被映射为 void*);它代表一个没有使用
的消息队列。msgque条目可能接纳的其它特殊值是IPC_NOID(也不在本书讨论之列
)——这只是暂时的,也就是在消息队列被创建的时候。
Real_msgsnd
20149:real_msgsnd实现sys_msgsnd的实质内容,即msgsnd系统调用。这里和内核
的约定有一些偏差,该约定要在命名系统调用的“内脏函数 ”时使用一个“do_”
前缀。
    在20338行调用了real_msgsnd函数,在那里它处于lock_kernel/unlock_kernel函
数对之中。(那两个函数在第10章中讨论——基本上,每次只能有一个CPU对内核
加锁,这与SMP机器有关。)这是一种确保unlock_kernel得到执行的最佳方式——
否则,real_msgsnd复杂的流程控制将因需要在它退出时确保调用unlock_kernel而
变得更加复杂。
    正如读者已经熟悉的,内核大多使用返回代码变量和goto语句来解决这样的问题
。虽然它不能很好的适应每种情况,但是sys_msgsnd函数的方法更加清晰。例如,
当一个函数必须获得多项资源,其中一些只有在以前所有资源请求都成功地被满足
时才能提出请求,考虑这样可能引发什么样的后果。简单扩展的解决方法将需要大
量函数——就像下边代码段所描述的:
P523 —1
    很快,这样的代码将变得臃肿不堪,内核不这样做的原因就在于此。
20158:开始一系列条件判断。假如有了第一个测试,本行三项测试中的第二项就
是不必要的——任何不能通过第二项测试的消息同样也不能通过第一项测试。虽然
以后这种说法可能会不成立,假如MSGMAX的界限增加到足够高的话。(事实上,在
写作本书时,完全消除MSGMAX界限的工作已在开展之中了。)
20164:消息队列标识号对两段信息进行编码:与之对应的msgque元素的索引在低
端7位,一个序列编号(其作用随后就将讨论到)就位于紧挨这7位之前的16位里。
现在所需要全部的就是数组下标部分。
20166:如果指定的数组下标处没有消息队列,或者正在创建一个,那么就没有消
息可以进入队列。
20171:保存在消息队列中的序列编号必须和那个msgque参数里的编码相匹配。其
思想是:在正确的数组下标处有一个消息队列并不代表它就是调用者所需要的消息
队列。自从调用者引用一个队列之后,原先处于那个下标的消息队列可能已经被移
去了而且在同一下标处创建了一个新的消息队列。16位序列编号被周期性的增加,
所以在同一下标处的新队列将有一个和旧队列不同的序列编号。(除非正好先创建
了65535个其它的新队列,这是相当不可能的——或者是131071个其它的新队列,
这就更不可能了。本章随后将对其进行解释,实际情况并非这样简单。)不管怎样
,只要序列编号不匹配,real_msgsnd就返回一个EIDRM错误来指示调用者所需要的
消息队列已经被移出了。
20174:确保调用者有写消息队列的权限。类似的一个方法将在第11章详细介绍;
在这里,可以简单的把它看作是类似于Unix文件权限应用的一个方法。
20177:检查如果提供的消息被写入队列,是否会超过队列所允许的最大容量。接
下来一行代码再次检查同一件事,这显然是当代码从2.0系列的内核版本被转换过
来时留下的一个编辑疏漏。在两次检查之间,曾经有过一些能够释放队列中的部分
空间的代码。
20180:队列中没有空间。如果在msgflg里的IPC_NOWAIT位被设置了,这种情况发
生时调用者就不会等待,这样的结果是返回EAGAIN错误。
20182:进程将要进入休眠状态。real_msgsnd首先检查是否一条消息正在等待该进
程。如果存在等待消息的话,就会用进程的休眠被该消息所中断的方式来处理它(
进程可能已经休眠,就如随后所示的那样)。
20184:假如没有正在等待进程的信号,进程就进入休眠状态,直到有信号到达或
移出队列中的一条消息时它才被唤醒。当进程被唤醒之后,它将再次向读列写入。

20190:为消息队列头(struct msg)和消息体分配足够的空间——正如前面所说
,消息体将紧接在消息头后面存放。消息头的msg_spot直接指向该头部之后消息体
开始的地方。
20196:从用户空间复制消息体。
20202:再次检查消息队列的合法性。Msgque入口可能已经在20184行这个进程休眠
时被其它进程修改过了,因此直到通过检查之前不能认为msg是有效的指针。
    即便如此,这里看起来也有一个潜在的缺陷。如果在当前进程执行到这一步之前
,该消息队列已被删除而另一个消息队列被设置在同一个数组下标的地方那又将怎
样呢?在UP机器上是不会发生这种情况的,因为销毁消息队列的函数,freeque(
20440行),在销毁它之前将唤醒任何休眠于该队列的进程,而且在real_msgsnd完
成之前freeque不会继续进行(本章后面将对freeque进行分析)。然而,在SMP机
器上,这仍然是一个小小的隐患。
    假如发生这种情形,msgque[id]将不是IPC_UNUSED或IPC_NOID,但是msq指向的内
存已经被freeque释放了,因此在20203行将废除无效的指针引用。
20209:填写消息头,将其入队,并更新队列自己相应的统计值(比如消息的总共
大小)。注意只要有可能就推迟填写消息头的工作,所以假如在分配和当前阶段之
间检测到错误时,这样就不会浪费时间。
20226:唤醒所有等待消息到达这个队列的进程,然后返回0以示成功。
Real_msgrcv
20230:同real_msgsnd函数一样,real_msgrcv函数实现msgrcv系统调用。Msgtyp
参数含义灵活,这可以从在20248行开始的标题注释之中看出。Struct msg的
msg_type域在这里发挥作用:在该函数中它要与msgtyp参数相比较。
    另一个与real_msgsnd相同的地方是real_msgrcv函数也是从20349行的
lock_kernel /unlock_kernel函数对内调用的。
20239:从msgid提取msgque下标并确保在那个下标所指的空间中有合法的一项。
20262:这个if/else语句对从队列中选择一个消息。第一种情况最简单:它只需要
得到队列中的第一条消息,使得nmsg或者为NULL或者指向队列的第一个元素。
20266:msgtyp为正值,并且msgflg里的MSG_EXCEPT位(15862行)被设置。real_
  msgrcv函数沿着队列搜索第一个类型和msgtyp不匹配的项。
20272:msgtyp为正值,但是MSG_EXCEPT位未被设置。real_msgrcv函数沿着队列搜
索第一个类型和msgtyp匹配的项。
20279:msgtyp是负值。real_msgrcv函数用最小的msgtyp编号来搜索消息,如果最
小值比msgtyp的绝对值还要小的话。注意20281行在比较时使用<而不是<=,这样队
列中消息的选择就不再有利于第一个消息了。这样的结果不仅令人满意——尽量遵
循FIFO方式是一个好的策略——而且效率也稍有提高,因为这种方式减轻了赋值工
作。如果比较采用<=,每个连接(tie)都将意味一次赋值操作。
20287:此时,如果有消息满足给定的标准,nmsg就指向它。否则,nmsg就是NULL

20288:即使找到一个合适的消息,它也有可能不被返回。如果调用程序的缓冲没
有足够大的空间来容纳整个消息体,调用者通常会得到E2BIG错误。然而,假如
msgflg的MSG_NOERROR位(15860行)被设置,那么这个错误就不会被公布。(我找
不出什么理由可以让一个应用程序去设置MSG_NOERROR标志位,我也找不出任何一
个使用它的应用程序。)
20292:如果msgsz指定了多于消息体的字节数,real_msgrcv函数就把msgsz减少到
消息的实际大小。当程序执行过这里之后,msgsz就是应该被复制到调用者缓冲区
的字节数。
    虽然此处代码的更加传统的写法有时比较慢,不过平均起来还是要更快一些:
   
    if ( msgsz > nmsg -> msg_ts )
        msgsz = nmsg -> msg_ts;

20294:把选中的消息从队列中移出。队列是一个单向链表,不是双向链表,所以
当不是队列中第一个的消息被移出时,real_msgrcv函数必须先要在队列中进行循
环以寻找它的前趋队列节点。
    通过将队列转换为双向链接,前趋节点就能在恒定时间里被找到。这个改变将引
入空间损耗(需要额外的指针),时间损耗(用来更新附加的指针),以及复杂度
的提高(需要增加完成这些工作的代码)。尽管如此,那些代价都是很小的,而在
被移出的消息处于队列中部的情况下,它们可以显著地提高速度。
    不过实际情况中,大部分应用程序从队列中移出的都是第一个消息。其结果是,
额外花费在管理msg _prev 指针(假定我们这样称呼它们)上的时间通常被完全的
浪费了。只在从队列中间移出消息时它才会有所补偿,但应用程序又很少这样做。
结论是为了提高特殊情形时的速度而降低了普遍情况下的效率——这几乎总是一个
坏主意。甚至于确实要移出队列中间节点的应用程序也不会等很长时间,因为通常
消息队列很短,典型情况下最多也就是几十个消息而已,而且平均在循环进行到一
半时就能找到选择的消息了。
    因此,只有当消息队列有成百上千条消息而且应用程序又要移出队列中间的节点
时,应用程序才会经历一次明显的速度减慢过程。考虑到这种情况的罕见程度,开
发者的决定就是正确的。除此而外,如果一个应用程序真的陷入这种困境,而且它
的开发者又不顾一切的需要这额外一点点速度——那么好吧,这就是Linux,他们
可以自己修改内核源代码以满足需要。
20305:处理移去队列中唯一节点的情况。
20308:更新消息队列统计值。
20313:唤醒所有等待写入这个消息队列的进程——也就是所有被real_msgsnd函数
设置为休眠状态的进程。
20314:把消息复制到用户空间并释放队列节点(头部和体部)。
20318:返回正被返回的消息的容量大小——这对可变长度消息来说至关重要,因
为应用程序的消息格式可能无法说明消息在哪里结束。
20320:没有符合调用程序标准的消息。接下来发生的操作将取决于调用者:如果
调用者设置msgflg的IPC_NOWAIT位�
回复

使用道具 举报

 楼主| 发表于 2003-10-27 10:03:08 | 显示全部楼层
Linux内核注释——第10章 对称多处理(SM

在全书的讨论过程中,我一直在忽略SMP代码,而倾向于把注意力集中在只涉及一
个处理器的相对简单的情况。现在已经到了重新访问读者已经熟悉的一些内容的时
候了,不过要从一个新的角度来审视它:当内核必须支持多于一个CPU的机器时将
发生什么?
在一般情况下,使用多于一个CPU来完成工作被称为并行处理(parallel
processing),它可以被想象成是一段频谱范围,分布式计算(distributed
computing)在其中一端,而对称多处理(SMP—symmetric multiprocessing)在
另一端。通常,当你沿着该频谱从分布式计算向SMP移动时,系统将变得更加紧密
耦合——在CPU之间共享更多的资源——而且更加均匀。在一个典型的分布式系统
中,每个CPU通常都至少拥有它自己的高速缓存和RAM。每个CPU还往往拥有自己的
磁盘、图形子系统、声卡,监视器等等。
在极端的情形下,分布式系统经常不外乎就是一组普通的计算机,虽然它们可能具
有完全不同的体系结构,但是都共同工作在某个网络之上——它们甚至不需要在同
一个LAN里。读者可能知道的一些有趣的分布式系统包括:Beowulf,它是对相当传
统而又极其强大的分布式系统的一个通用术语称谓;SETI@home,它通过利用上百
万台计算机来协助搜寻地外生命的证据,以及distributed.net,它是类似想法的
另一个实现,它主要关注于地球上产生的密码的破解。
SMP是并行处理的一个特殊情况,系统里所有CPU 都是相同的。举例来说,SMP就是
你共同支配两块80486或两块Pentium(具有相同的时钟速率)处理器,而不是一块
80486和一块Pentium,或者一块Pentium和一块PowerPC。在通常的用法中,SMP也
意味着所有CPU都是“在相同处境下的”——那就是它们都在同一个计算机里,通
过特殊用途的硬件进行彼此通信。
SMP系统通常是另一种平常的单一(single)计算机——只不过具有两个或更多的
CPU。因此,SMP系统除了CPU以外每样东西只有一个——一块图形卡、一个声音卡
,等等之类。诸如RAM和磁盘这样以及类似的资源都是为系统的CPU们所共享的。(
尽管现在SMP系统中每个CPU都拥有自己的高存缓存的情况已经变得愈发普遍了。)

分布式配置需要很少的或者甚至不需要来自内核的特殊支持;节点之间的协同是依
靠用户空间的应用程序或者诸如网络子系统之类未经修改的内核组件来处理的。但
是SMP在计算机系统内创建了一个不同的硬件配置,并由此需要特殊用途的内核支
持。比如,内核必须确保CPU在访问它们的共享资源时要相互合作——这是一个读
者在UP世界中所不曾遇到的问题。
SMP的逐渐普及主要是因为通过SMP所获得的性能的提高要比购买几台独立的机器再
把它们组合在一起更加便宜和简单,而且还因为它与等待下一代CPU面世相比要快
的多。
非对称多CPU的配置没有受到广泛支持,这是因为对称配置情况所需的硬件和软件
支持通常较为简单。不过,内核代码中平台无关的部分实际上并不特别关心是否
CPU是相同的——即,是否配置是真正对称的——尽管它也没有进行任何特殊处理
以支持非对称配置。例如,在非对称多处理系统中,调度程序应该更愿意在较快的
而不是较慢的CPU上运行进程,但是Linux内核没有对此进行区别。
谚语说得好,“天下没有白吃的午餐”。对于SMP,为提高的性能所付出的代价就
是内核复杂度的增加和协同开销的增加。CPU必须安排不互相干涉彼此的工作,但
是它们又不能在这种协同上花费太多时间以至于它们显著地耗费额外的CPU能力。

代码的SMP特定部分由于UP机器存在的缘故而被单独编译,所以仅仅因为有了SMP寄
存器是不会使UP寄存器慢下来的。这满足两条久经考验的原理:“为普遍情况进行
优化”(UP机器远比SMP机器普遍的多)以及“不为用不着的东西花钱”。
并行程序设计概念及其原语
具有两个CPU的SMP配置可能是最简单的并行配置,但就算是这最简单的配置也揭
开了未知问题的新领域——即使要两块相同的CPU在一起协调的工作,时常也都像
赶着猫去放牧一样困难。幸运的是,至少30年前以来,就在这个项目上作了大量和
非常熟悉的研究工作。(考虑到第一台电子数字计算机也只是在50年前建造的,那
这就是一段令人惊讶的相当长的时间了。)在分析对SMP的支持是如何影响内核代
码之前,对该支持所基于的若干理论性概念进行一番浏览将能够极大的简化这个问
题。
注意:并非所有这些信息都是针对SMP内核的。一些要讨论的问题甚至是由UP内核
上的并行程序设计所引起的,既要支持中断也要处理进程之间的交互。因此即使你
对SMP问题没有特别的兴趣,这部分的讨论也值得一看。
原子操作
在一个并行的环境里,某些动作必须以一种基本的原子方式(atomically)执行—
—即不可中断。这种操作必须是不可分割的,就象是原子曾经被认为的那样。
作为一个例子,考虑一下引用计数。如果你想要释放你所控制的一份共享资源并要
了解是否还有其它(进程)仍在使用它,你就会减少对该共享资源的计数值并把该
值与0进行对照测试。一个典型的动作顺序可能如下开始:
1.  CPU把当前计数值(假设是2)装载进它的一个寄存器里。
2.  CPU在它的寄存器里把这个值递减;现在它是1。
3.  CPU把新值(1)写回内存里。
4.  CPU推断出:因为该值是1,某个其它进程仍在使用着共享对象,所以它将不会
释放该对象。
对于UP,应不必在此考虑过多(除了某些情况)。但是对于SMP就是另一番景象了
:如果另一个CPU碰巧同时也在作同样的事情应如何处理呢?最坏的情形可能是这
样的:
1.  CPU A把当前计数值(2)装载进它的一个寄存器里。
2.  CPU B把当前计数值(2)装载进它的一个寄存器里。
3.  CPU A在它的寄存器里把这个值递减;现在它是1。
4.  CPU B在它的寄存器里把这个值递减;现在它是1。
5.  CPU A把新值(1)写回内存里。
6.  CPU B把新值(1)写回内存里。
7.  CPU A推断出:因为该值是1,某个其它进程仍在使用着共享对象,所以它将不
会释放该对象。
8.  CPU B推断出:因为该值是1,某个其它进程仍在使用着共享对象,所以它将不
会释放该对象。
内存里的引用计数值现在应该是0,然而它却是1。两个进程都去掉了它们对该共享
对象的引用,但是没有一个能够释放它。
这是一个有趣的失败,因为每个CPU都作了它所应该做的事情,尽管这样错误的结
果还是发生了。当然这个问题就在于CPU没有协调它们的动作行为——右手不知道
左手正在干什么。
你会怎样试图在软件中解决这个问题呢?从任何一个CPU的观点来看待它——比如
说是CPU A。需要通知CPU B它不应使用引用计数值,由于你想要递减该值,所以不
管怎样你最好改变某些CPU B所能见到的信息——也就是更新共享内存位置。举例
来说,你可以为此目的而开辟出某个内存位置,并且对此达成一致:若任何一个
CPU正试图减少引用计数它就包含一个1,如果不是它就为0。使用方法如下:
1.  CPU A从特殊内存位置出取出该值把它装载进它的一个寄存器里。
2.  CPU A检查它的寄存器里的值并发现它是0(如果不是,它再次尝试,重复直到
该寄存器为0为止。)
3.  CPU A把一个1写回特殊内存位置。
4.  CPU A访问受保护的引用计数值。
5.  CPU A把一个0写回特殊内存位置。
糟糕,令人不安的熟悉情况又出现了。以下所发生的问题仍然无法避免:
1.  CPU A从特殊内存位置出取出该值把它装载进它的一个寄存器里。
2.  CPU B从特殊内存位置出取出该值把它装载进它的一个寄存器里。
3.  CPU A检查它的寄存器里的值并发现它是0。
4.  CPU B检查它的寄存器里的值并发现它是0。
5.  CPU A把一个1写回特殊内存位置。
6.  CPU B把一个1写回特殊内存位置。
7.  CPU A访问受保护的引用计数值。
8.  CPU B访问受保护的引用计数值。
9.  CPU A把一个0写回特殊内存位置。
10. CPU B把一个0写回特殊内存位置。
好吧,或许可以再使用一个特殊内存位置来保护被期望保护初始内存位置的那个特
殊内存位置……。
面对这一点吧:我们在劫难逃。这种方案只会使问题向后再退一层,而不可能解决
它。最后,原子性不可能由软件单独保证——必须要有硬件的特殊帮助。
在x86平台上,lock指令正好能够提供这种帮助。(准确地说,lock是一个前缀而
非一个单独的指令,不过这种区别和我们的目的没有利害关系。)lock指令用于在
随后的指令执行期间锁住内存总线——至少是对目的内存地址。因为x86可以在内
存里直接减值,而无需明确的先把它读入一个寄存器中,这样对于执行一个减值原
子操作来说就是万事俱备了:lock内存总线然后立刻对该内存位置执行decl操作。

函数atomic_dec(10241行)正好为x86平台完成这样的工作。LOCK宏的SMP版本在
第10192行定义并扩展成lock指令。(在随后的两行定义的UP版本完全就是空的—
—单CPU不需要保护自己以防其它CPU的干扰,所以锁住内存总线将完全是在浪费时
间。)通过把LOCK宏放在内嵌编译指令的前边,随后的指令就会为SMP内核而被锁
定。如果CPU B在CPU A发挥作用时执行了atomic_dec函数,那么CPU B就会自动的
等待CPU A把锁移开。这样就能够成功了!
这样还只能说是差不多。最初的问题仍然没有被很好的解决。目标不仅是要自动递
减引用计数值,而且还要知道结果值是否是0。现在可以完成原子递减了,可是如
果另一个处理器在递减和结果测试之间又“偷偷的”进行了干预,那又怎么办呢?

幸运的是,解决这个部分问题不需要来自CPU的特殊目的的帮助。不管加锁还是未
锁,x86的decl指令总是会在结果为0时设置CPU的Zero标志位,而且这个标志位是
CPU私有的,所以其它CPU的所为是不可能在递减步骤和测试步骤之间影响到这个标
志位的。相应的,atomic_dec_and_test(10249行)如前完成一次加锁的递减,接
着依据CPU的Zero标志位来设置本地变量c。如果递减之后结果是0函数就返回非零
值(真)。
如同其它定义在一个文件里的函数一样,atomic_dec和atomic_dec_and_test都对
一个类型为atomic_t的(10205行)对象进行操作。就像LOCK,atomic_t对于UP和
SMP也有不同的定义方式——不同之处在于SMP情况里引入了volatile限定词,它指
示gcc不要对被标记的变量做某种假定(比如,不要假定它可以被安全的保存在一
个寄存器里)。
顺便提及一下,读者在这段代码里看到的垃圾代码__atomic_fool_gcc据报告已不
再需要了;它曾用于纠正在gcc的早期版本下代码生成里的一个故障。
Test-And-Set
经典的并行原语是test-and-set。test-and-set操作自动地从一个内存位置读取一
个值然后写入一个新值,并把旧值返回。典型的,该位置可以保存0或者1,而且
test-and-set所写的新值是1——因此是“设置(set)”。与test-and-set对等的
是test-and-clear,它是同样的操作除了写入的是0而不是1。一些test-and-set的
变体既能写入1也可以写入0,因此test-and-set和test-and-clear就能够成为一体
,只是操作数不同而已。
test-and-set原语足以实现任何其它并行安全的操作。(实际上,在某些CPU上
test-and-set是唯一被提供的此类原语。)比如,原本test-and-set是能够用于前
边的例子之中来保护引用计数值的。相似的方法以被尝试——从一个内存位置读取
一个值,检查它是否为0,如果是则写入一个1,然后继续访问受保护的值。这种尝
试的失败并不是因为它在逻辑上是不健全的,而是因为没有可行的方法使其自动完
成。假使有了一个原子的test-and-set,你就可以不通过使用lock来原子化decl的
方法而顺利通过了。
然而,test-and-set也有缺点:
?   它是一个低级的原语——在所有与它打交道时,其它原语都必须在它之上被执行

?   它并不经济——当机器测试该值并发现它已经是1了怎么办呢?这个值在内存里
不会被搞乱,因为只要用同样的值复写它即可。可事实是它已被设置就意味着其它
进程正在访问受保护的对象,所以还不能这样执行。额外需要的逻辑——测试并循
环——会浪费CPU时钟周期并使得程序变得更大一些(它还会浪费高速缓存里的空
间)。
x86的lock指令使高级指令更容易执行,但是你也可以在上执行原子test-and-set
操作。最直接的方式是把lock和btsl指令(位test-and-set)联合起来使用。这种
方法要被本章后边介绍的自旋锁(spinlock)所用到。
另一种在x86上实现的方法是用它的xchg(exchange)指令,它能够被x86自动处理
,就好像它的前面有一个lock指令一样——只要它的一个操作数是在内存里。
xchg要比lock/  btsl组合更为普遍,因为它可以一次交换8、16,或者32位而不仅
仅是1位。除了一个在arch/i386/kernel/entry.S里的使用之外,内核对xchg指令
的使用都隐藏在xchg宏(13052行)之后,而它又是在函数__xchg(13061行)之上
实现的。这样是便于在平台相关的代码里内核代码也可以使用xchg宏;每种平台都
提供它自己对于该宏的等价的实现。
有趣的时,xchg宏是另一个宏,tas(test-and-set——13054行)的基础。然而,
内核代码的任何一个地方都没有用到这个宏。
内核有时候使用xchg宏来完成简单的test-and-set操作(尽管不必在锁变得可用之
前一直循环,如同第22770行),并把它用于其它目的(如同第27427行)。
信号量
第9章中讨论了信号量的基本概念并演示了它们在进程间通信中的用法。内核为达
自己的目的有其特有的信号量实现,它们被特别的称为是“内核信号量”。(在这
一章里,未经修饰的名词“信号量”应被理解为是“内核信号量”。)第9章里所
讨论的基本信号量的概念同样适用于内核信号量:允许一个可访问某资源用户的最
大数目(最初悬挂在吊钩上钥匙的特定数目),然后规定每个申请资源者都必须先
获得一把钥匙才能使用该资源。
到目前为止,你大概应该已经发现信号量如何能够被建立在test-and-set之上并成
为二元(“唯一钥匙”)信号量,或者在像atomic_dec_and_test这样的函数之上
成为计数信号量的过程。内核正好就完成着这样的工作:它用整数代表信号量并使
用函数down(11644行)和up(11714行)以及其它一些函数来递减和递增该整数。
读者将看到,用于减少和增加整数的底层代码和atomic_dec_and_test及其它类似
函数所使用的代码是一样的。
作为相关历史事件的提示,第一位规范信号量概念的研究者,Edsger Dijistra是
荷兰人,所以信号量的基础操作就用荷兰语命名为:Proberen和Verhogen,常缩写
成P和V。这对术语被翻译成“测试(test)”(检查是否还有一把钥匙可用,若是
就取走)和“递增(increment)”(把一个钥匙放回到吊钩之上)。那些词首字
母正是在前一章中所引入的术语“获得(procure)”和“交出(vacate)”的来
源。Linux内核打破了这个传统,用操作down和up的称呼取代了它们。
内核用一个非常简单的类型来代表信号量:定义在11609行的struct semaphore。
他只有三个成员:
?   count——跟踪仍然可用的钥匙数目。如果是0,钥匙就被取完了;如果是负数,
钥匙被取完而且还有其它申请者在等待它。另外,如果count是0或负数,那么其它
申请者的数目就等于count的绝对值。
Sema_init宏(11637行)允许count被初始化为任何值,所以内核信号量可以是二
元的(初始化count为1)也可以是计数型的(赋予它某个更大的初始值)。所有内
核信号量代码都完全支持二元和计数型信号量,前者可作为后者的一个特例。不过
在实践中count总是被初始化为1,这样内核信号量也总是二元类型的。尽管如此,
没有什么能够阻止一个开发者将来增加一个新的计数信号量。
要顺便提及的是,把count初始化为正值而且用递减它来表明你需要一个信号量的
方法并没有什么神秘之处。你也可以用一个负值(或者是0)来初始化计数值然后
增加它,或者遵循其它的方案。使用正的数字只是内核所采用的办法,而这碰巧和
我们头脑中的吊钩上的钥匙模型吻合得相当好。的确,正如你将看到的那样,内核
锁采用的是另一种方式工作——它被初始化为负值,并在进程需要它时进行增加。

?   waking——在up操作期间及之后被暂时使用;如果up正在释放信号量则它被设置
为1,否则是0。
?   wait——因为要等待这个信号量再次变为可用而不得不被挂起的进程队列。
down
11644:down操作递减信号量计数值。你可能会认为它与概念里的实现一样简单,
不过实际上远不是这样简单。
11648:减少信号量计数值——要确保对SMP这是自动完成的。对于SMP来说(当然
也适于UP),除了被访问的整数是在一个不同类型的struct之内以外,这同在
atomic_dec_and_test中所完成的工作本质上是相同的。
    读者可能会怀疑count是否会下溢。它不会:进程总是在递减count之后进入休眠
,所以一个给定的进程一次只能获得一个信号量,而且int具有的负值要比进程的
数目多的多。
11652:如果符号位被设置,信号量就是负值。这意味着甚至它在被递减之前就是
0或者负值了,这样进程无法得到该信号量并因此而应该休眠一直到它变成可用。
接下来的几行代码十分巧妙地完成了这一点。如果符号位被设置则执行js跳转(即
若decl的结果是负的它就跳转),2f标识出跳转的目的地。2f并非十六进制值——
它是特殊的GNU汇编程序语法:2表示跳转到本地符号“2”,f表示向前搜索这个符
号。(2b将表示向后搜索最近的本地符号“2”。)这个本地符号在第11655行。
11653:分支转移没有执行,所以进程得到了信号量。虽然看起来不是这样,但是
这实际已经到达down的末尾。稍后将对此进行解释。
11654:down的技巧在于指令.section紧跟在跳转目标的前面,它表示把随后的代
码汇编到内核的一个单独的段中——该段被称为.text.lock。这个段将在内存中被
分配并标识为可执行的。这一点是由跟在段名之后的ax标志字符串来指定的——注
意这个ax与x86的AX寄存器无关。
    这样的结果是,汇编程序将把11655和11656行的指令从down所在的段里转移到可
执行内核的一个不同的段里。所以这些行生成的目标代码与其前边的行所生成的代
码从物理上不是连续的。这就是为什么说11653行是down的结尾的原因。
11655:当信号量无法得到时跳转到的这一目的行。Pushl $1b并不是要把十六进制
值1b压入栈中——如果要执行那种工作应该使用pushl $0x1b(也可以写成是不带
$的)。正确的解释是,这个1b和前边见到的2f一样都是GNU汇编程序语法——它指
向一个指令的地址;在此情形中,它是向后搜索时碰到的第一个本地标识“1”的
地址。所以,这条指令是把11653行代码的地址压入栈中;这个地址将成为返回地
址,以便在随后的跳转操作之后,执行过程还能返回到down的末尾。
11656:开始跳转到__down_failed(不包括在本书之内)。这个函数在栈里保存几
个寄存器并调用后边要介绍的__down(26932行)来完成等待信号量的工作。一旦
__down返回了,__down_failed就返回到down,而它也随之返回。一直到进程获得
了信号量__down才会返回;最终结果就是只要down返回,进程就得到信号量了,而
不管它是立刻还是经过等待后获得的它。
11657:伪汇编程序指令.previous的作用未在正式文档中说明,但是它的意思肯定
是还原到以前的段中,结束11654行里的伪指令.section的作用效果。
down_interruptible
11664:down_interruptible函数被用于进程想要获得信号量但也愿意在等待它时
被信号中断的情况。这个函数与down的实现非常相似,不过有两个区别将在随后的
两段里进行解释。
11666:第一个区别是down_interruptible函数返回一个int值来指示是否它获得了
信号量或者被一个信号所打断。在前一种情况里返回值(在result里)是0,在后
一种情况里它是负值。这部分上是由11675行代码完成的,如果函数未经等待获得
了信号量则该行把result设置为0。
11679:第二个区别是down_interruptible函数跳转到
__down_failed_interruptible(不包括在本书之内)而不是__down_failed。因循
__down_failed建立起来的模式,__down _failed_interruptible只是调整几个寄
存器并调用将在随后进行研究的__down_interruptible函数(26942行)。要注意
的是11676行为__down_failed_ interruptible设置的返回目标跟在xorl之后,
xorl用于在信号量可以被立刻获得的情况中把result归0。down_interruptible函
数的返回值再被复制进result中。
down_trylock
11687:除了调用__down_failed_trylock函数(当然还要调用26961行的
__down_trylock函数,我们将在后面对它进行检查)之外,down_trylock函数和
down_interruptible函数相同。因此,在这里不必对down_trylock函数进行更多解
释。
DOWN_VAR
26900:这是作为__down和_down_interruptible共同代码因子的三个宏中的第一个
。它只是声明了几个变量。
DOWN_HEAD
26904:这个宏使任务tsk(被DOWN_VAR所声明)转移到task_state给出的状态,然
后把tsk添加到等待信号量的任务队列。最后,它开始一个无限循环,在此循环期
间当__down和__down_interruptible准备退出时将使用break语句结束该循环。
DOWN_TAIL
26926:这个宏完成循环收尾工作,把tsk设置回task_state的状态,为再次尝试获
得信号量做准备。
26929:循环已经退出;tsk已或者得到了信号量或者被一个信号中断了(仅适于
__down_ interruptible)。无论哪一种方式,任务已准备再次运行而不再等待该
信号量了,因此它被转移回TASK_RUNNING并从信号量的等待队列里被注销。
__down
26932:__down和__down_interruptible遵循以下模式:
1.  用DOWN_VAR声明所需的本地变量,随后可能还有补充的本地变量声明。
2.  以DOWN_HEAD开始进入无穷循环。
3.  在循环体内完成函数特定的(function-specific)工作。
4.  重新调度。
5.  以DOWN_TAIL结束。注意对schedule的调用(26686行,在第7章里讨论过)可以
被移进DOWN_TAIL宏中。
6.  完成任何函数特定的收尾工作。
我将只对函数特定的步骤(第3和第6步)进行讨论。
26936:__down的循环体调用waking_non_zero(未包括),它自动检查
sem->waking来判断是否进程正被up唤醒。如果是这样,它将waking归零并返回1(
这仍然是同一个原子操作的一部分);如果不是,它返回0。因此,它返回的值指
示了是否进程获得了信号量。如果它获得了值,循环就退出,接着函数也将返回。
否则,进程将继续等待。
    顺便要说明的是,观察一下__down尝试获得信号量是在调用schedule之前。如果
信号量的计数值已知为负值时,为什么不用另一种相反的方式来实现它呢?实际上
它对于第一遍循环之后的任何一遍重复都是没有影响的,但是去掉一次没有必要的
检查可以稍微加快第一遍循环的速度。如果需要为此提出什么特别的理由的话,那
可能就是因为自从信号量第一次被检查之后的几个微秒内它就应该可以被释放(可
能是在另一个处理器上),而且额外获取标志要比一次额外调度所付出的代价少得
多。因此__down可能还可以在重新调度之前做一次快速检查。
__down_interruptible
26942:__down_interruptible除了允许被信号中断以外,它和__down在本质上是
一样的。
26948:所以,当获取信号量时对waking_non_zero_interruptible(未包括)进行
调用。如果它没能得到信号量就返回0,如果得到就返回1,或者如果它被一个信号
所中断就返回–EINTR。在第一种情况下,循环继续。
26958:否则,__down_interruptible退出,如果它得到信号量就返回0(不是1)
,或者假如被中断则返回–EINTR。
__down_trylock
26961:有时在不能立刻获得信号量的情况下,内核也需要继续运行。所以,
__down_trylock不在循环之内。它仅仅调用waking_nonzero_trylock(未包括),
该函数夺取信号量,如果失败就递增该信号量的count(因为内核不打算继续等待
下去)然后返回。
up
11714:我们已经详尽的分析了内核尝试获得信号量时的情况,也讨论了它失败时
的情况。现在是考察另一面的时候了:当释放一个信号量时将发生什么。这一部分
相对简单。
11721:原子性地递增信号量的计数值。
11722:如果结果小于等于0,就有某个进程正在等待被唤醒。up向前跳转到11725
行。
11724:up采用了down里同样的技巧:这一行进入了内核的单独的一段,而不是在
up本身的段内。up的末尾的地址被压入栈然后up跳转到__up_wakeup(未包括)。
这里完成如同__down_failed一样的寄存器操作并调用下边要讨论的__up函数。
__up
26877:__up函数负责唤醒所有等待该信号量的进程。
26897:调用wake_one_more(未包括在本书中),该函数检查是否有进程在等待该
信号量,如果有,就增加waking成员来通知它们可以尝试获取它了。
26880:利用wake_up宏(16612行),它只是调用__wake_up函数(26829行)来唤
醒所有等待进程。
__wake_up
26829:正如在第2章中所讨论的那样,__wake_up函数唤醒所有传递给它的在等待
队列上的进程,假如它们处于被mode所隐含的状态之一的话。当从wake_up被调用
时,函数唤醒所有处于TASK_UNINTERRUPTIBLE或TASK_INTERRUPTIBLE状态的进程;
当从wake_up_interruptible(16614行)被调用时,它只唤醒处于
TASK_INTERRUPTIBLE状态的任务。
26842:进程用wake_up_process(26356行)被唤醒,该函数曾在以前提到过,它
将在本章随后进行详细介绍。
现在所感兴趣的是唤醒所有进程后的结果。因为__wake_up唤醒所有队列里的进程
,而不仅仅是队列里的第一个,所以它们都要竞争信号量——在SMP里,它们可以
精确的同时做这件事。通常,获胜者将首先获得CPU。这个进程将是拥有最大“
goodness”的进程(回忆一下第7章中26338行对goodness的讨论)。 这一点意义
非常重大,因为拥有更高优先权的进程应该首先被给予继续其工作的机会。(这对
于实时进程尤其重要。)
这种方案的不足之处是有发生饥饿(starvation)的危险,这发生在一个进程永远
不能得到它赖以继续运行的资源时。这里可能会发生饥饿现象:假如两个进程反复
竞争同一个信号量,而第一个进程总是有比第二个更高的优先权,那么第二个进程
将永远不会得到CPU。这种场景同它应该的运行方式存在一定差距——设想一个是
实时进程而另一个以20的niceness运行。我们可以通过只唤醒队列里第一个进程的
方法来避免这种饥饿的危险,可是那样又将意味着有时候会耽误从各个方面来说都
更有资格的进程对CPU的使用。
以前对此没有讨论过,可是Linux的调度程序在适当的环境下也能够使得CPU的一个
进程被彻底饿死。这不完全是一件坏事——只是一种设计决策而已——而且至少应
用于通篇内核代码的原则是一致的,这就很好。还要注意的是使用前边讨论过的其
它机制,饥饿现象也同样会发生。例如说,test-and-set原语就是和内核信号量一
样的潜在饥饿根源。
无论如何,在实际中,饥饿是非常少见的——它只是一个有趣的理论案例。
Spinlocks
这一章里最后一个重要的并行程序设计原语是自旋锁(spinlock)。自旋锁的思想
就是在一个密封的循环里坚持反复尝试夺取一个资源(一把锁)直到成功为止。这
通常是通过在类似test-and-set操作之上进行循环来实现的——即,旋转(
spinning)——一直到获得该锁。
如果这听起来好像是一个二元信号量,那是因为它就是一个二元信号量。自旋锁和
二元信号量唯一的概念区别就是你不必循环等待一个信号量——你可以夺取信号量
,也可以在不能立刻得到它时放弃申请。因此,自旋锁原本是可以通过在信号量代
码外再包裹一层循环来实现的。不过,因为自旋锁是信号量的一个受限特例,它们
有更高效的实现方法。
自旋锁变量——其中的一位被测试和设置——总是spinlock_t类型(12785行)。
只有spinlock_t的最低位被使用;如果锁可用,则它是0,如果被取走,则它是1。
在一个声明里,自旋锁被初始化为值SPIN_LOCK_UNLOCKED(12789行);它也可以
用spin_lock_init函数(12791行)来初始化。这两者都把spinlock_t的lock成员
设置成0——也就是未锁状态。
注意12795行代码简洁地对公平性进行了考虑并最后抛弃了它——公平是饥饿的背
面,正如我们前面已经介绍过的(使得一个CPU或进程饥饿应被认为是“不公平的
”)。
自旋锁的加锁和解锁宏建立在spin_lock_string和sping_unlock_string函数之上
,所以这一小节只对spin_lock_string和sping_unlock_string函数进行详述。其
它宏如果有的话只是增加了IRQ加锁和解锁。
spin_lock_string
12805:这个宏的代码对于所有自旋锁加锁的宏都是相同的。它也被用于x86专用的
lock_ kernel和unlock_kernel版本之中(它们不在本书之列,不过其常规版本则
是包括的——参见10174和10182行)。
12807:尝试测试和设置自旋锁的最低位,这要把内存总线锁住以便对于任何其它
对同一个自旋锁的访问来说这个操作都是原子的。
12808:如果成功了,控制流程就继续向下运行;否则,spin_lock_string函数向
前跳转到第12810行(btsl把这一位的原值放入CPU的进位标志位(Carry flag),
这正是这里使用jc的原因)。同样的技巧我们已经看到过三次了:跳转目标放在内
核的单独一段中。
12811:在封闭的循环里不停地检测循环锁的最低位。注意btsl和testb以不同方式
解释它们第一个操作数——对于btsl,它是一个位状态(bit position),而对于
testb,它是一个位屏蔽(bitmask)。因此,12811行在测试spin_lock_string曾
在12807行已经试图设置(但失败了)的同一位,尽管一个使用$0而另一个使用$1

12813:该位被清除了,所以spin_lock_string应该再次夺取它。函数调转回第
12806行。
    这个代码可以只用加上lock前缀的两条代码加以简化:
    1: lock ; btsl $0, %0
      jc 1b
    不过,使用这个简化版本的话,系统性能将明显受到损害,这因为每次循环重复
内存总线都要被加锁。内核使用的版本虽然长一些,但是它可以使其它CPU运行的
更有效,这是由于该版本只有在它有充分理由相信能够获得锁的时候才会锁住内存
总线。
spin_unlock_string
12816:并不很重要:只是重新设置了自旋锁的锁定位(lock bit)。
读/写自旋锁
自旋锁的一个特殊情况就是读/写自旋锁。这里的思想是这样的:在某些情况中,
我们想要允许某个对象有多个读者,但是当有一个写者正在写入这个对象时,则不
允许它再有其它读者或者写者。
遵循基于spinlock_t的自旋锁的同样模式,读/写自旋锁是用rwlock_t(12853行)
来代表的,它可以在有RW_LOCK_UNLOCKED(12858行)的声明里被初始化。与
rwlock_t一起工作的最低级的宏是read_lock、read_unlock、write_lock,以及
write_unlock,它们在本小节中进行描述。很明显,那些跟随在这些宏之后并建立
在它们之上的宏,自然要在你理解了最初的这四个宏之后在去接触。
正如第12860行注释中所声明的,当写锁(write lock)被占有时,rwlock_t的
lock成员是负值。当既没有读者也没有写者时它为0,当只有读者而没有写者时它
是正值——在这种情况下,lock将对读者的数目进行计数。
read_lock
12867:开始于rwlock_t的lock成员的自动递增。这是推测性的操作——它可以被
撤销。
12868:如果它在增量之后为负,表示某个进程占用了写锁——或者至少是某个进
程正试图得到它。read_lock向前跳到第12870行(注意,在一个不同的内核段里)
。否则,没有写者退出(尽管还有可能有,或者也有可能没有其它读者——这并不
重要),所以可以继续执行读锁定(read-locked)代码。
12870:一个写者出现了。read_lock取消第12867行增值操作的影响。
12871:循环等待rwlock_t的lock变为0或正值。
12873:跳回到第12866行再次尝试。
read_unlock
12878:不太复杂:只是递减该计数值。
write_lock
12883:表示出有一个进程需要写锁:检测并设置lock的符号位并保证lock的值是
负的。
12884:如果符号位已经被设置,则另外有进程占有了写锁;write_lock向前跳转
到第12889行(同以前一样,那是在一个不同的内核段里)。
12885:没有别的进程正试图获得该写锁,可是读者仍可以退出。因为符号位被设
置了,读者不能获得读锁,但是write_lock仍然必须等待正在退出的读者完全离开
。它通过检查低端的31位中是否任何一位被设置过开始,这可以表示lock以前曾是
正值。如果没有,则lock在符号位反转之前曾是0,这意味着没有读者;因而,这
对于写者的继续工作是很安全的,所以控制流程就可以继续向下运行了。不过,如
果低端31位中任何一位被设置过了,也就是说有读者了,这样write_lock就会向前
跳转到第12888行等到它们结束。
12888:该进程是仅有的写者,但是有若干读者。write_lock会暂时清除符号位(
这个宏稍后将再次操纵它)。有趣的是,对符号位进行这样的胡乱操作并不会影响
读者操纵lock的正确性。考虑作为示例的下列顺序事件:
1.  两个读者增加了lock;lock用十六进制表示现在是0x00000002。
2.  一个即将成为写者的进程设置了符号位;lock现在是0x80000002。
3.  读者中的一个离开;lock现在是0x80000001。
4.  写者看到剩余的位不全部是0——仍然有读者存在。这样它根本没有写锁,因此
它就清除符号位;lock现在是0x00000001。
    这样,读和写可以任何顺序交错尝试操作而不会影响结果的正确程度。
12889:循环等待计数值降到0——也就是等待所有读者退出。实际上,0除了表示
所有读者已离开之外,它还表示着没有其它进程获得了写锁。
12891:所有读者和写者都结束了操作;write_lock又从头开始,并再次获得写锁

write_unlock
12896:不太重要:只是重置符号位。
APICs和CPU-To-CPU通信
Intel 多处理规范的核心就是高级可编程中断控制器(Advanced Programmable
Interrupt Controllers——APICs)的使用。CPU通过彼此发送中断来完成它们之
间的通信。通过给中断附加动作(actions),不同的CPU可以在某种程度上彼此进
行控制。每个CPU有自己的APIC(成为那个CPU的本地APIC),并且还有一个I/O
APIC来处理由I/O设备引起的中断。在普通的多处理器系统中,I/O APIC取代了第
6章里提到的中断控制器芯片组的作用。
这里有几个示例性的函数来让你了解其工作方式的风格。
smp_send_reschedule
5019: 这个函数只有一行,其作用将在本章随后进行说明,它仅仅是给其ID以参
数形式给出了的目标CPU发送一个中断。函数用CPU ID和RESCHEDULE_VECTOR向量调
用send_IPI_single函数(4937行)。RESCHEDULE_VECTOR与其它CPU中断向量是一
起在第1723行开始的一个定义块中被定义的。
send_IPI_single
4937: send_IPI_single函数发送一个IPI——那是Intel对处理器间中断(
interprocessor interrupt)的称呼——给指定的目的CPU。在这一行,内核以相
当低级的方式与发送CPU的本地APIC对话。
4949: 得到中断命令寄存器(ICR)高半段的内容——本地APIC就是通过这个寄存
器进行编程的——不过它的目的信息段要被设置为dest。尽管__prepare_ICR2(
4885行)里使用了“2”,CPU实际上只有一个ICR而不是两个。但是它是一个64位
寄存器,内核更愿意把它看作是两个32位寄存器——在内核代码里,“ICR”表示
这个寄存器的低端32位,所以“ICR2”就表示高端32位。我们想要设置的的目的信
息段就在高端32位,即ICR2里。
4950: 把修改过的信息写回ICR。现在ICR知道了目的CPU。
4953: 调用__prepare_ICR(4874行)来设置我们想要发送给目的CPU的中断向量
。(注意没有什么措施能够保证目的CPU不是当前CPU——ICR完全能够发送一个
IPI给它自己的CPU。尽管这样,我还是没有找到有任何理由要这样做。)
4957: 通过往ICR里写入新的配置来发送中断。
SMP支持如何影响内核
既然读者已经学习了能够成功支持SMP的若干原语,那么就让我们来纵览一下内核
的SMP支持吧。本章剩余的部分将局限于对分布在内核之中的那些具有代表性的
SMP代码进行讨论。
对调度的影响
schedule(26686行)正是内核的调度函数,它已在第7章中全面地介绍过了。
schedule的SMP版本与UP的相比有两个主要区别:
?   在schedule里从第26780开始的一段代码要计算某些其它地方所需的信息。
?   在SMP和UP上都要发生的对__schedule_tail的调用(26638行)实际上在UP上并
无作用,因为__schedule_tail完全是为SMP所写的代码,所以从实用的角度来说它
就是SMP所特有的。
schedule
26784:获取当前时间,也就是自从机器开机后时钟流逝的周期数。这很像是检查
jiffies,不过是以CPU周期而不是以时钟滴答作为计时方法的——显然,这要精确
得多。
26785:计算自从schedule上一次在此CPU上进行调度后过去了多长时间,并且为下
一次的计算而记录下当前周期计数。(schedule_data是每个CPU aligned_data数
组的一部分,它在26628行定义。)
26790:进程的avg_slice成员(16342行)记录该进程在其生命周期里占有CPU的平
均时间。可是这并不是简单的平均——它是加权平均,进程近期的活动远比很久以
前的活动权值大。(因为真实计算机的计算是有穷的,“很久以前”的部分在足够
远以后,将逐渐趋近于0。)这将在reschedule_idle中(26221行,下文讨论)被
用来决定是否把进程调入另一个CPU中。因此,在UP的情况下它是无需而且也不会
被计算的。
26797:记录哪一个CPU将运行next(它将在当前的CPU上被执行),并引发它的
has_cpu标志位。
26803:如果上下文环境发生了切换,schedule记录失去CPU的进程——这将在下文
的__schedule_tail中被使用到。
__schedule_tail
26654:如果失去CPU的任务已经改变了状态(这一点在前边的注释里解释过了),
它将被标记以便今后的重新调度。
26664:因为内核已经调度出了这个进程,它就不再拥有CPU了——这样的事实也将
被记录。
reschedule_idle
26221:当已经不在运行队列里的进程被唤醒时,wake_up_process将调用
reschedule_idle,进程是作为p而被传递进reschedule_idle中的。这个函数试图
把新近唤醒的进程在一个不同的CPU上进行调度——即一个空闲的CPU上。
26225:这个函数的第一部分在SMP和UP场合中都是适用的。它将使高优先级的进程
得到占用CPU的机会,同时它也会为那些处于饥饿状态的进程争取同样的机会。如
果该进程是实时的或者它的动态优先级确实比当前占有CPU进程的动态优先级要高
某个量级(强制选定的),该进程就会被标记为重新调度以便它能够争取占用CPU

26263:现在来到SMP部分,它仅仅适用于在上述测试中失败了的那些进程——虽然
这种现象经常发生。reschedule_idle必须确定是否要在另一个CPU上尝试运行该进
程。
    正如在对schedule的讨论中所提到的那样,一个进程的avg_slice成员是它对CPU
使用的加权平均值;因此,它说明了假如该进程继续运行的话是否它可能要控制
CPU一段相对来说较长的时间。
26264:这个if条件判断的第二个子句使用related宏(就在本函数之上的第26218
行)来测试是否CPU都在控制着——或想要控制——内核锁。如果是这样,那么不
管它们生存于何处,都将不大可能同时运行,这样把进程发送到另一个CPU上将不
会全面提高并行的效能。因此,假如这条子句或者前一条子句被满足,函数将不会
考虑使进程在另一CPU上进行调度并简单的返回。
26267:否则,reschedule_idle_slow(接下来讨论)被调用以决定是否进程应当
被删除。
reschedule_idle_slow
26157:正如注释中所说明的,reschedule_idle_slow试图找出一个空闲CPU来贮存
p。这个算法是基于如下观察结果的,即task数组的前n项是系统的空闲进程,机器
的n个CPU中每个都对应一个这样的空闲进程。这些空闲进程当(且仅当)对应CPU
上没有其它进程需要处理器时才会运行。如果可能,函数通常是用hlt指令使CPU进
入低功耗的“睡眠”状态。
    因此,如果有空闲CPU存在的话,对任务数组的前n个进程进行循环是找出一个空
闲CPU所必须的。reschedule_idle_slow函数只需简单的查询每个空闲进程是否此
刻正在运行着;如果是这样,它所在的CPU就一定是空闲的,这就为进程p提供了一
个很好的候选地点来运行。
    当然,这个被选中的明显空闲的CPU完全有可能只是暂时空闲而且必定会被一堆拥
有更高优先级的,CPU绑定的进程所充满,这些进程可能在一纳秒后就会被唤醒并
在该CPU上运行。所以,这并不是完美的解决方法,可是从统计的角度来说它已经
相当好了——要记住,像这样的选择是很符合调度程序“快餐店式(
quick-and-dirty)”的处理方式的。
26180:建立本地变量。best_cpu是此时正在运行的CPU;它是“最佳”的CPU,因
为p在其上会避免缓冲区溢出或其它的开销麻烦。this_cpu是运行
reschedule_idle_slow的CPU。
26182:idle和tsk将沿task数组进行遍历,target_tsk将是所找到的最后一个正在
运行的空闲进程(或者假如没有空闲进程它就为NULL)。
26183:i 从smp_num_cpus(前边被叫作n)开始并且在每一次循环后都递减。
26189:假如这个空闲进程的has_cpu标志被设置,它就正在它的CPU上运行着(我
们将称这样的CPU为“目标(target)CPU”)。如果该标志没有被设置,那么目标
CPU就正被某个其它进程占用着;因而,它也就不是空闲的,这样
reschedule_idle_slow将不会把p发送到那里。刚刚提及问题的反面在这里出现了
:现在仅因为CPU不空闲并不能表示它所有的进程都不会死亡而使其空闲下来。可
是reschedule_idle_slow无法知道这种情形,所以它最好还是假定目标CPU将要被
占用一段时间。无论如何,这都是可能的,就算并非如此,某个其它的进程也将很
快会被调度到另一个空闲CPU上运行。
26190:不过假如CPU目标就是当前CPU,它就会被跳过。这看来很怪,不过无论怎
样这都是“不可能发生”的情况:一个空闲进程的counter是负值,在第26226行的
测试将早已阻止这个函数执行到这一步了。
26192:找到一个可用的空闲CPU;相关的空闲进程被保存在target_tsk中。
    既然已找到了空闲CPU,为什么现在不中断循环呢?这是因为继续循环可能会发现
p当前所在的处理器也是空闲的,在两个CPU都空闲时,维持在当前处理器上运行要
比把它送往另一个好一些。
26193:这一步reschedule_idle_slow检查是否p所在的处理器空闲。如果刚才找到
的空闲CPU就是p所在的,函数将向前跳转到send标记处(26203行)来在那个CPU上
对p进行调度。
26199:函数已经转向另一个CPU;它要递减。
26204:如果循环遍历了所有空闲的CPU,该CPU的空闲任务就被标记为重新调度并
且smp_ send_reschedule(26205行)会给那个CPU发送一个IPI以便它可以重新对
其进程进行调度。
    正如读者所见到的,reschedule_idle_slow是CPU之间协调无需在UP系统中所进行
的工作的典范示例。对于UP机器来说,询问进程应占有哪一个CPU和询问它是否应
拥有系统的唯一的一个CPU或根本不应该占有CPU是等价的。SMP机器必须花费一些
代价来决定系统中哪一个CPU是该进程的最佳栖身之所。当然,换来的速度极大提
高使得这些额外的努力还是相当合算的。
release
22951:release中非SMP特有的部分在第7章中已经介绍过了——在这里,一个僵进
程(zombie)将被送往坟墓,而且其struct task_struct将被释放。
22960:查看是否该进程拥有一个CPU。(拥有它的CPU可能还没有清除这个标志;
但是它马上就将执行这个操作。)如果没有,release退出循环并像往常一样接着
释放struct task_ struct结构体。
22966:否则,release等待进程的has_cpu标志被清除。当它被清除后,release再
次进行尝试。这种貌似奇特的情况——某进程正被删除,然而它仍占有CPU——确
实少见,不过并非不可能。进程可能已经在一个CPU上被杀死,而且这个CPU还没来
得及清除has_cpu标志,但是它的父进程已经正在从另一个CPU对它进行释放了。
smp_local_timer_interrupt
对于UP专有的update_process_times函数(27382行)来说,这个函数就是它在
SMP上的对应。该函数能够完成update_process_times所完成的所有任务——更新
进程和内核在CPU使用方面的统计值——以及其它的一些操作。与众不同的地方在
于拥有这个特性的SMP版本并没有被添加到一个UP函数中去,而是采用了一个具有
同样功能,但却完全分离的功能程序。在浏览了函数之后,我们就能够很容易的知
道这是为什么了——它与UP版本差别甚大到以至于试图将二者融为一体都将是无意
义的。smp_local_timer_interrupt可从两个地方进行调用:
?   从smp_apic_timer_interrupt(5118行)调用,它用于SMP的时钟中断。这是通
过使用在第1856行定义的BUILD_SMP_TIMER_INTERRUPT宏于第919行建立起来的。
?   从第5776行通常的UP时钟中断函数里进行调用。只有当在UP机器上运行SMP内核
时此种调用方式才会发生。
smp_local_timer_interrupt
5059: prof_counter(4610行)用于跟踪到更新进程和内核统计值之前内核应该
等待多长时间;如果该计数器还没有到达0,控制流程会有效地跳转到函数的末尾
。正如代码中所证明的,prof_counter项目从1开始递减计数,除非由根(root)
来增加这个值,因此在缺省情况下每次时钟滴答都要完成此项工作。然后,
prof_counter[cpu]从prof_multiplier[cpu]处被重新初始化。
    明显的这是一个优化的过程:每次时钟滴答都在这个if语句块里完成所有工作将
相当的缓慢,所以我们可能想到以牺牲一些精确度的代价将工作分批完成。因为乘
法器是可调的,所以你可以指定你所需要的速度频率来放松对准确度的要求。
    然而,关于这段代码我总感到有些困惑:确定无疑的是,当
prof_multiplier[cpu]耗尽时,统计值应该被更新,就像prof_multiplier[cpu]的
计数流逝一样——既然它们已经如此。(除了prof_multiplier[cpu]本身刚刚被改
变时,不过这已经偏离了这里讨论的主题。)与此不同的是,这里代码表现出来的
就好像只经过了一次滴答计数。或许其用意是为了以后能把记录下来的滴答数目和
prof_multiplier[cpu]在某个地方相乘,不过现在并没有这样实现。
5068: 当时钟中断被触发时假如系统正在用户模式运行,
smp_local_timer_interrupt会假定全部滴答都是在用户模式里流逝的;否则,它
将假定全部滴答是在系统模式里流逝的。
5073: 用irq_enter(1792行)来夺取全局IRQ锁。这是我们要分批处理这项工作
的另一个原因:并不需要在每次时钟滴答时都要得到全局IRQ锁,这有可能成为
CPU之间争夺的一个重要根源,实际中函数是以较低的频度来争取该锁的。因此,
函数不经常夺取这个锁,可是一旦它获得了锁,就不会再使其被锁。在此我们又一
次以准确度的代价换来了这种效率上的提高。
5074: 不用为保存空闲进程的统计值而操心。这样做只会浪费CPU的周期。总之,
内核会跟踪系统处于空闲的总共时间,对空闲进程的更多细节进行统计价值不大(
比如我们知道它们总是在系统模式下执行的,所以就没有必要再明确计算它们的系
统时间了)。
5075: update_process_times和smp_local_timer_interrupt在这一点上是一致的
:它们都调用update_process_times来完成对单进程CPU使用统计的更新工作。
5077: 减少进程的counter(它的动态优先级),如果它被耗尽就重新调度该进程

5082: 更新内核的统计数字。如在update_process_times中一样,用户时间既可
以用内核的“最优时间”也可以用常规的用户时间来计算,这要取决于进程的优先
级是否低于DEF_PRIORITY。
5094: 重新初始化CPU的prof_counter并释放全局IRQ锁。该工作必须要以这种顺
序完成,当然——若以相反的方式,则可能在prof_counter被重新初始化之前发生
又一次时钟中断。
lock_kernel和unlock_kernel
这两个函数也有专门适应于x86平台的版本;但是在这里只介绍通用版本。
lock_kernel
10174:这个函数相当简单,它获得全局内核锁——在任何一对
lock_kernel/unlock_kernel函数里至多可以有一个CPU。显然这在UP机器上是一个
空操作(no-op)。
10176:进程的lock_depth成员初始为–1(参见24040行)。在它小于0时(若小于
0则恒为-1),进程不拥有内核锁;当大于或等于0时,进程得到内核锁。
    这样,单个进程可以调用lock_kernel,然后在运行到unlock_kernel之前可能又
将调用另一个要使用lock_kernel的函数。在这种情况中,进程将立刻被赋予内核
锁——而这正是我们所期望的。
    其结果是,一旦增加进程的lock_depth就会使lock_depth为0,那么进程以前就是
没有锁的。所以,函数在此情形下获得kernel_flag自旋锁(3587行)。
unlock_kernel
10182:同样的,如果丢弃内核锁就会使lock_depth低于0值,进程退出它所进入的
最后一对lock_kernel/unlock_kernel函数。此时,kernel_flag自旋锁一定要被解
锁以便其它进程可以给内核加锁。通过测试结果的符号位(即使用“<0”而不是“
== -1”)可以使gcc生成更高效的代码,除此之外,这还可能有利于内核在面对不
配对的lock_ kernel/unlock_kernel时可正确执行(或者不能,这取决于具体情况
)。
softirq_trylock
你可能能够回忆起在第6章的讨论中,softirq_trylock的作用是保证对于其它程序
段来说下半部分代码(bottom half)是原子操作——也就是说,保证在任何特定
时段的整个系统范围之内至多只有一个下半部分代码在运行。对于UP来说这相当容
易:内核只不过需要检查或者还要设置一下标志位就可以了。不过对于SMP来说自
然没有这样简单。
softirq_trylock
12528:测试并设置(tests-and-sets)global_bh_count的第0位。尽管读者可能
会从global _bh_count的名字上得到另外一种看法,实际它总是0或者1的——这样
的考虑是适当的,因为至多运行一个下半部分程序代码。不管怎样,如果
global_bh_count已经是1了,那么就已经有一个下半部分代码在运行着,因此控制
流程就跳转到函数末尾。
12529:如果还可得到global_bh_lock,那么下半部分代码就能够在这个CPU上运行
。这种情况与UP机器上使用的双锁系统非常类似。
12533:softirq_trylock无法获取global_bh_lock,因此它的工作失败了。
cli和sti
正如在第6章中解释过的,cli和sti分别用于禁止和启用中断。对于UP这简化为单
个cli或sti指令。而在SMP情况下,这就很不够了,我们不仅需要禁止本地CPU还要
暂时避免其它CPU处理IRQ。因此对于SMP,宏就变成了对__global_cli和
__global_sti函数的调用。
__global_cli
1220: 把CPU的EFLAGS寄存器复制到本地变量flags里。
1221: x86系统里的中断使能标志在EFLAGS寄存器的第9位——在第1205行解释了
EFLAG_IF_SHIFT的定义。它被用来检测是否已经禁止了中断,这样就不再需要去禁
止它们了。
1223: 禁止这个CPU的中断。
1224: 如果该CPU没有正在对IRQ进行处理,__global_cli就调用get_irqlock(
1184行)来获得全局IRQ锁。如果CPU已经在对IRQ进行处理了,那么正如我们马上
要看到的,它已经拥有了该全局IRQ锁。
    现在本CPU已经禁止了中断,而且它也拥有了全局IRQ锁,这样任务就完成了。
__global_sti
1233: 如果CPU没有正在对IRQ进行处理,__global_sti就在__global_cli中通过
release_irqlock(10752行)调用来实现对全局IRQ锁的释放工作。如果CPU已经在
对IRQ进行处理了,那么它已经拥有了该全局IRQ锁,正如在接下来的部分中将要解
释的那样,这个锁将在其它地方被释放掉。  
1235: 再次允许在本CPU上进行中断。
irq_enter和irq_exit
第6章中顺便提及了这两个函数的UP版本。包含在一对irq_enter/irq_exit之中的
代码段都是原子操作,这不仅对于其它这样的代码区域是原子的,而且对于
cli/sti宏对来说也是如此。
irq_enter
1794: 调用hardirq_enter(10761行)自动为本CPU增加全局IRQ计数和本地IRQ计
数。这个函数记录了CPU正在处理一个IRQ的情况。
1795: 执行循环直到这个CPU得到全局IRQ锁为止。这就是为什么我要在前面说明
如果CPU正在处理IRQ,那么它就已经获得了全局IRQ锁的原因:到这个函数退出时
,这两个特性都将被加强。对于内核代码来说,把这两个特性分离出去并没有太大
的意义——它可以直接调用hardirq_enter,而且也不用去争夺全局IRQ锁。函数只
是没有这样作而已。
irq_exit
1802: 这个函数转向hardirq_enter的相反函数hardirq_exit(10767行)。顺便
要提及的是,对irq_enter和irq_exit来说其irq参数都被忽略了——至少在x86平
台上如此。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 10:05:04 | 显示全部楼层
Linux内核注释——第11章 可调内核参数

遵循Unix的BSD 4.4版本所倡导的风格,Linux提供sysctl系统调用以便在系统运行
过程中对它所拥有的某些特性进行检查和重新配置,它并不需要你编辑内核的源代
码、重新编译,然后重启机器。这是对早期Unix版本的一个十分重要的改进,在早
期版本里调整系统经常是令人头痛的琐碎事务。Linux把可以被检查和重新配置的
系统特性有机地组织成了几个种类:常规内核参数、虚拟内存参数、网络参数,等
等。
同样的特性也可以从一个不同的接口进行访问:/proc文件系统。(因为它真正的
是系统的一个透视区(window)而不只是真实文件的一个容器,所以/proc是一个
“伪的文件系统”,不过那是一个蹩脚的词汇,而且无论如何这个区别在此并不重
要。)每种可调内核参数在/proc/sys下都表现为一个子目录,而每个单独的可调
系统参数由某个子目录下的一个文件来代表。这些子目录可能又包含一级子目录,
它们仍然含有更多的代表可调系统参数的文件和子目录,等等,但是这种嵌套级数
从来都不会很深。
/proc/sys绕过了通常的sysctl接口:一个可调内核参数的值可以简单的通过读取
相应的文件来得到,通过写入该文件可以设置它的值。普通Unix文件系统的许可被
应用于这些文件,以便对能够对它们进行读写的用户进行控制。大多数文件对所有
用户是可读的但是只对root(根用户)可写,不过也有例外:比如,
/proc/sys/vm下的文件(虚拟内存参数)只能被root来读写。如果不使用
/proc/sys,检查和调整系统将需要编写程序并使用必须的参数调用sysctl——虽
然不是任务艰巨的劳动,可是也比不上使用/proc/sys来得方便。
struct ctl_table
18274:这是本章涉及的代码中所使用的一个主要数据结构。struct ctl_tables通
常是由数组聚合起来的,每个这样的数组对应于/proc/sys下某处一个单独目录里
的条目。(依我之见,称它为struct ctl_table_entry可能更好。)root_table(
30328行)以及在它之后的数组通过struct ctl_table的child指针连结节点而形成
了一个数组树(child将在下边的列表中介绍)。注意所有这些都是ctl_table的数
组,它只是为struct ctl_table进行typedef;18184行完成这项工作。
    图11.1示意出了数组树间的关系。这幅图显示了由root_table形成的树的一小部
分以及它所指向的树。
    struct ctl_table具有如下成员:
?   ctl_name——是唯一标识表项的一个整数——在它所在的数组中是唯一的;这个
数字在不同的数组中是可以重用的。数组的任何一项都已经存在这样一个唯一的数
字了——就是它的数组下标——可是这个数字不能被用于该目的,因为我们想要维
护不同内核发布版本中的二进制兼容性。与某内核版本里一个数组项相关联的可调
内核参数可能不会出现在将来的内核版本里,所以假如参数是被它们的数组下标定
义的,对数组里废弃项目位置的重新使用将使还没有在新内核版本下编译过的程序
变得混乱。随着时间的推移,为了向后兼容而带上的只浪费空间但没有作用的元素
项将会使数组变得乱七八糟。相反的,这种方法只会“浪费”整数,而整数资源却
无疑是非常丰富的。(另一方面,查找也会更慢,因为一个简单的数组下标还不足
以满足这种方法。)
要注意的是这与有系统调用的情形相当类似:每个系统调用都与一个在系统调用表
里唯一标识它位置的数字相关联。但是在这种情况里使用了一个不同的解决办法,
可能由于速度在此并不重要的缘故。


图11.1 ctl_table 树的一部分

    struct ctl_table具有如下成员:
?   ctl_name——是唯一标识表项的一个整数——在它所在的数组中是唯一的;这个
数字在不同的数组中是可以重用的。数组的任何一项都已经存在这样一个唯一的数
字了——就是它的数组下标——可是这个数字不能被用于该目的,因为我们想要维
护不同内核发布版本中的二进制兼容性。与某内核版本里一个数组项相关联的可调
内核参数可能不会出现在将来的内核版本里,所以假如参数是被它们的数组下标定
义的,对数组里废弃项目位置的重新使用将使还没有在新内核版本下编译过的程序
变得混乱。随着时间的推移,为了向后兼容而带上的只浪费空间但没有作用的元素
项将会使数组变得乱七八糟。相反的,这种方法只会“浪费”整数,而整数资源却
无疑是非常丰富的。(另一方面,查找也会更慢,因为一个简单的数组下标还不足
以满足这种方法。)
要注意的是这与有系统调用的情形相当类似:每个系统调用都与一个在系统调用表
里唯一标识它位置的数字相关联。但是在这种情况里使用了一个不同的解决办法,
可能由于速度在此并不重要的缘故。
?   procname——是用于/proc/sys下的相应项的一个可供我们阅读的简短文件名。

?   data——一个指向与此表项关联的数据的指针。它通常指向一个int或者一个
char(当然,指向char的指针是字符串)。
?   maxlen——可以读取或者写入data的最大字节数。如果data指向一个单精度型的
int,举例来说,maxlen就应该是sizeof(int)。
?   mode——Unix类型的文件许可位,它对应于这一项的/proc文件(或目录)。对
此的解释需要少量文件系统的内容。就像其它Unix的实现一样,Linux使用三个三
元组,其中每一位都记录一个文件许可(在ls -l命令产生的列表里它们表现为r、
w,和x的三组字母)——参见图11.2。它们占据mode的低端9位。文件系统把文件
的mode里剩余的位留作它用,比如用来跟踪是否文件是常规文件(第16位,当它如
此时)、目录(第15位)、setuid或setgid执行程序(第12和11位),等等。不过
就本章的目的来说,那些其它位都不是我们所关心的内容。


图11.2 文件的mode位

这种方式的结果是,读者将经常见到八进制的常数004、002,和001与mode一起使
用——它们分别是在移位mode后可能得到的适当的三位组中检测读(r)、写(w)
,和执行(x)位。这种移位和检查工作基本上是在30544行的test_perm里完成的

注意如果一个表项的maxlen是0,那么不管它的mode是什么,从最终效果上看它都
是既不可读也不可写的。
?   child——如果这是一个目录类型的条目,那么它就是指向子表(child table)
的一个指针。在这样的情况下,因为没有数据与此条目相关联,data将是NULL,而
maxlen则将是0。
?   proc_handler——指针,指向对data成员实际进行读取和写入操作的一个函数;
它在通过/proc文件系统读写数据时被使用。以这种方法,任何类型的数据都可以
通过data来进行指向,而且proc_handler函数会正确的处理对它的工作。
Proc_handler通常指向proc_dostring函数(30820行)或proc_dointvec函数(
30881行);这两个以及其它被普遍适用的函数将在本章后面被讨论。(当然,任
何具有适当原型(prototype)的函数都可以使用。)对于目录类型的条目,proc_
handler是NULL。
?   strategy——指针,指向对data成员实际进行读取和写入操作的另一个函数;它
使用在通过sysctl系统调用进行读写的时候。它通常是sysctl_string(31121行)
,不过也可以是stringctl_intvec(31163行);这两个函数在本章后面进行讨论
。出于种种原因,大多数可调内核参数是通过/proc接口而不是sysctl系统调用进
行调整的,所以这个指针是NULL会比非空更为常见。
?   de——指向struct proc_dir_entry的一个指针,它在/proc文件系统代码中使用
以追踪文件系统里的文件或目录。如果它非空,struct ctl_table就在/proc下的
某处注册过了。
?   extra和extra2——指向在处理这个表元素时所需的任何补充数据。它们当前只
用于指定某些整数参数的最小和最大值。
/proc/sys 支持
不是所有实现用于可调内核参数/proc/sys接口的代码都包括在这本书中——的确
,大部分代码并没有包括在内,因为它们基本上属于/proc文件系统本身。尽管如
此,只要你不关心/proc剩下的部分是如何工作的,就不难理解在kernel/sysctl.
c里的代码,它们与/proc文件系统一起工作用来使/proc下的可调内核参数是可见
的。
register_proc_table
30689:register_proc_table函数在/proc/sys下注册一个ctl_table。注意这里并
不要求所提供的表是根一级的节点(即ctl_table没有双亲)——它本应该是,不
过这取决于调用者是否能够进行保证。
    这个表被直接建立在root之下,它应该对应于/proc/sys或者其下的一个子目录。
(在初次调用时,root总是指向proc_sys_root的,但是在递归调用时它的值改变
了。)
30696:开始在table数组的所有元素中进行循环;在当前元素的clt_name成员为0
时循环结束,表示这是数组的末尾。
30698:如果ctl_table的procname元素是NULL,那么即使同一数组的其它元素都可
以为用户所见,它也不可以在/proc/sys下被用户所见。这样的数组元素会被跳过

30701:如果表项有procname,表明它应该在/proc/sys下被注册,那么它一定还有
一个proc _handler(如果是一个叶子,或文件类型的节点)或者一个child(如果
是一个目录类型的节点)。如果它同时缺少这两者,那么系统将显示一条警告,而
后循环继续进行。
30711:若表项有一个proc_handler,它被标记成常规文件。
30713:否则,正如可从第30701行推断的那样,它一定有一个非空的child,这样
该条目将被看作是一个目录。注意并没有禁止ctl_table同时拥有非空
proc_handler和child这两者——在这种情形下,所有代码将对其一视同仁。
30715:用给定的名字搜索一个存在的子目录,如果找到就让de指向它,如果没找
到则de为NULL。为什么对文件不做类似的检查比较难于理解——这可能是我没有领
会的文件系统的某个细节问题,答案无疑就在那里。
30723:如果指定的子目录已经不存在了,或者假如table对应于一个文件而不是一
个目录,新的文件或者目录就会通过调用create_proc_entry(未包含在本书中)
来创建。
30728:如果表项是一个叶子节点,register_proc_table会告诉文件系统代码使用
由proc_sys_ inode_operations(30295行)定义的文件操作。
proc_sys_inode_operations只定义了两个操作,读和写(不是搜索、内存映射,
或者其它)。这些操作是用proc_readsys和proc_writesys函数(30802和30808行
)来执行的,在本章的后面章节中将对它们进行介绍。
30731:到了这一行,de就不可能是NULL了——它或者已经非空或者在第30723行被
初始化了。
30733:如果增加的条目是目录类型,register_proc_table会被递归调用来增加这
一项的所有子孙。这是内核里不多见的一次递归调用。
unregister_proc_table
30739:unregister_proc_table函数删除ctl_table数组树和/proc文件系统之间的
关联。ctl_  table里的条目以及它们下面所有的“子目录”里的条目也将会从
/proc/sys消失。
30743:同第30396行一样,这一行开始在给定的表项数组上进行循环。
30744:与/proc/sys下任意条目都不关联的表项具有一个为NULL的de成员;显然这
些表项可被忽略。
30748:如果/proc文件系统认为这是一个目录,但表项是一个叶子(非目录),这
两个结构就是不一致的。unregister_proc_table就会显示一条警告并继续循环,
而不会移去这一项。
30752:目录被逐层的进行释放——内核中另一次并不多见的递归过程。
30756:在递归调用结束之后,unregister_proc_table检查是否所有子目录和文件
都被逐层删除了——如果不是,当前元素就不能被安全的移去,接着要继续循环。

30762:这里就是为什么子目录(以及其中的文件)可能还没有被移去的原因:它
们可能当前还正被使用着。如果这个元素正在被使用,循环将继续,这样该元素就
不会被移走。
30765:节点通过proc_unregister(本书不进行介绍)从文件系统里被删除,接着
用于追踪该节点而分配的内存被释放。
do_rw_proc
30771:do_rw_proc实现proc_readsys(30802行)和proc_writesys(30806行)函
数的核心部分,这两个函数被/proc文件系统代码用于对ctl_table执行读取和写入
操作。
30782:确保一个表与/proc/sys下的这一条目相关联。
30785:注意这一行的第一个测试与第30782行的第二个测试是相重复的,这是因为
table是从de->data初始而来。
30788:确保调用进程有适当的读或写权限。
30795:调用该表项的proc_handler来完成真正的读操作或写操作。(要注意第
30785行证实了proc_ handler成员是非空的。)如前所述,proc_handler成员通常
是proc_dostring或proc_ dointvec(30820行和30792行),在随后的几段中我们
将对它们进行讨论。
30799:do_rw_proc返回实际读取或写入的字节数。注意到本地变量res完全是多余
的;它可以被参数count所替代。
proc_dostring
30820:proc_dostring是供文件系统代码调用以对C语言字符串型的内核参数进行
读取或写入操作的函数。
    注意write标志表示调用者正在写表元素,不过这主要是涉及从输入缓冲区里进行
读取——因此,用来写入的代码是受读控制的。类似的,如果write未被设置,调
用者正从该表项读取,这里主要涉及的是写入给定的缓冲区。
    这个函数在第31085行还实现了一个存根程序(stub);这个存根程序在/proc文
件系统被编译出内核时使用。大多数其它函数中的类似存根程序将在这个存根程序
之后被介绍。
30835:从输入缓冲区内读取字符直到一个表示结束的ASCII NUL(0)字节或者发
现新的一行,再或者到达了被允许从该输入缓冲区内读出数据的最大值(被lenp所
指定)为止。(为了不引起混淆,牢记NULL是一个C指针常量,而NUL——只有一个
L——是ASCII用于字符数字0的术语。)
30842:如果从缓冲区读出的字符数超出了可在表项里存储的限度,该数目会被降
低。在循环之前就限制最大输入长度(lenp)可能会更高效,因为不管怎样从
buffer里读取大于table->maxlen字节的数据是无意义的。实际上,循环可能读出
,假设是1024字节,然后降低计数到64,因为表项里只能存储这么多。
30844:该字符串从输入缓冲区里被读出,然后以NUL结束。
30847:内核为每个进程所拥有的每个文件维护一个“当前位置”变量;这就是
struct file的f_pos成员。它是tell系统调用返回的值并由seek系统调用进行设置
。因此,文件的当前位置是由写入的字节数所推进的。
proc_doutsstring
30871:在获得uts_sem信号量后(29975行),proc_doutsstring仅是调用
proc_dostring。这个函数被kern_table(30341行)里的一些条目用来设置
system_utsname结构体的不同部分(20094行)。
do_proc_dointvec
30881:proc_dointvec(30972行)把它的工作委托给了该函数。
do_proc_dointvec读或写一个被table的data成员所指向的int类型数组。要读写的
int类型数目通过lenp传递;它通常是1,所以本函数通常只被用于读写单独一个
int。
    用于int的值是被buffer指定的。这些int是不会被以一个未经加工的int数组传递
的;相反的,它们以ASCII文本给出,而这正是用户写入相关/proc文件的。
30898:在所有要读写的int中循环。left追踪调用者想要读写int的剩余数目,而
vleft追踪table->data里剩余的有效元素数目。在这二者中任何一个到达0,或它
从半途退出时,该循环结束。
    注意如果从循环中去掉第30899行的if语句,可以使整个循环的效率稍微提高一些
,尽管这样做的结果较难维护。取代的代码如下:
        
P556—1
   
这种方式使得并不在循环内改变的write的值将只需被检查一次,而不必在每次循
环重复检查。
30900:向前搜索一个不是空格的字符,它是输入(缓冲区)里下一个数字的开头

30913:从用户空间把一大块数据复制到本地缓冲区buf,然后以NUL结束buf。现在
buf里包含了所有输入缓冲里剩余的ASCII文本——或者是它所能容纳的那些文本。

    这种方法看起来不很有效率,原因在于它可能读取的超出了它所需要的。然而,
因为buf的容量仅为20(TMPBUFLEN,30885行),它就不可能读取比它所需多出许
多的数据。这里的思想可能是读入稍多一些数据要比检查每个字节以确定是否应该
停止读操作所付出的代价要少些。
    计划使buf足够大来包括任何64位整数的ASCII表示,以便这个函数不仅可以支持
32位平台还可以支持64位平台。的确,它只能满足最大的正64位整数,它有19个数
位(使终结的NUL字节是第20个字节)。可是要记住这些是有符号的整数,所以最
小的64位有符号整数,即-9,223,372,032,854,775,808也应是合法输入。这个数字
无法被正确的读取。但是幸运的是,补救方法工作量不大而且也非常明显。
    随后读者就能够看到当这个输入出现时代码将如何对其进行处理。
30919:处理打头的减号(-),如果发现一个减号就跳过它并设置一个标志。
30923:确保从buffer读取的文本(可能是打头的减号之后的部分)至少是以一个
数字开始的,这样它才能顺利的转换为一个整数。若没有这次检查,就不可能分辨
出第30925行调用simple_strtoul返回的0是因为输入就是“0”还是因为函数无法
转换任何文本。
30925:把文本转换为一个整数,用conv参数换算结果。这个换算步骤对于
proc_dointvec _jiffies这样的函数(31077行)比较有用,它用乘以常数HZ的简
单手段把它的输入从秒转换为一段时间值(jiffies)。然而一般情况里,这个比
例因子是1——即没有换算。
30927:如果还要从缓冲区读取更多的文本,而且下一个要读的字符不是分割参数
的(argument-separating)空格,那么整个参数(argument)就无法装进buf。这
样的输入是无效的,所以循环提早结束。(一种可以导致函数处于这种状态的方式
就是前边所描述的,输入表示的是最小的有符号64位整数。)不过,没有错误代码
会被返回,因此调用者可能会错误地认为一切正常。当然这也不完全正确:一个错
误代码将在第31070行被返回,不过这仅当无效参数是在第一次循环重复中被检测
到的时候;如果它在后续的循环里被检测到,错误就不会被注意到。
30929:参数被成功的读取。如果有前导的减号,那么现在就对它进行考虑,其它
的本地变量被调整转移到下一个参数上,然后这个参数通过指针i被存储在表项中

30936:调用者从表项里读取值——由于无需对ASCII文本进行语法分析,这就是一
种更为简单的情形。输出是由tab(制表符)分隔的,所以在除了第一次之外的任
何一次循环里都把一个tab写入临时缓冲区里(在最后一个参数之后也不用写,只
需在参数之间即可)。
30938:接着,当前的整数被conv因子按比例缩减并打印到临时缓冲区里。这段代
码同样会受读者前边已经见到的问题的损害:临时缓冲区buf的大小可能不足以容
纳打印到它里边的全部整数值。在这种情况下,实际问题还会因缓冲区的第一个位
置可以是一个tab制表符而被恶化。这会使buf的可用部分减少一个字符,进一步还
会降低可被正确处理的输入范围。
    在这里过大或过小的整数所造成的结果要比在写入情形里严重的多。在那种情形
中,代码只要抛弃一些本应接受的输入即可。而在这儿,sprintf会越过buf的末尾
继续写下去。
    然而令人惊讶的是,这正是实际工作中可能发生的。在一次典型的执行过程中将
有可能发生如下执行过程:从总体上来说,超过buf的末尾之后还要写入两个额外
的字节(一个是因为它可以写入比预期更长的数字,另一个是tab制表符)。在栈
里p通常是紧跟在buf之后的,所以超出buf末尾写入的部分将会覆盖p。可是由于p
没有先被重新初始化时它是不会再被使用的,因此暂时覆盖它的值并没有危害。
    这是一个看似有趣的事故,但是仅仅通过使buf稍微大一些就能够成为一个更好的
解决方式,这样便于代码为正确的而不是错误的前提(reason)而工作。依照原样
,对于gcc的代码生成器进行完全合法的很小的修改就能够揭示出潜在的缺陷。
30939:把当前int的文本型表示复制进输出缓冲区里——或者和它所能容纳的相等
的文本——接着更新本地变量使其转移到表项的下一个数组元素。
30949:如果调用者刚才在读取,输出就被新的一行结束。if条件语句也保证循环
不会在其第一遍执行而且还有空间来写入新行时就结束。注意输出缓冲区不是用
ASCII NUL字节(读者可能会这样猜测)来结束的,因为它无需如此:调用者能够
利用lenp被写入新值来减少返回字符串的长度。
30954:如果调用者正向表项里写入数值,则略过从输入缓冲区读取的最后参数之
后所有的空格。
30967:更新文件的当前位置和lenp,然后返回0表示成功。
proc_dointvec_minmax
30978:proc_dointvec_minmax函数类似于do_proc_dointvec,区别是这个函数还
把表项的extra1和extra2成员作为可以写入该表项的限制值数组来处理。extra1里
的值是最小限度,而extra2里的值则是最大限度。另一点区别是
proc_dointvec_minmax不使用conv参数。
    因为这两个函数颇为相似,所以这一段里只介绍其不同之处。
31033:最大的区别在于:当写入时,超过被min和max(在extra1和extra2数组上
循环得到)所定义的范围之外的值将悄无声息的被略过。这段代码的目的明显是要
使min和max伴随着val一起继续。当一个数值从输入缓冲里被读取时,它应该被下
一个min和max来检查,然后才能决定被接受或被忽略。可是,这并非是实际所发生
的那样。
    假设从buffer而来的当前值已经进行了语法分析并存入里val,它小于最小值;为
了更具体一些,再假设已是第三遍循环,以便min和max分别指向对应数组中的第三
个元素。然后val将用min来检查并发现它超出了范围(太小),接着循环还要继续
。可是min会作为检查的副作用被更新,而max则没有。现在,min指向它对应数组
的第四个元素了,可是max仍然指向它的数组的第三个元素。这两者不再同步,而
且它们还将保存这种状态,这样在下一个从buffer里读取的值被检验时采用的就是
错误的界限。下列代码是最简单的一种修补程序:
        P558—1
    正如读者将要在本章后边看到的,现在的Linux源代码永远不会暴露出这个缺陷。
(未来发行的版本情况将有所不同,尽管还未曾明确写出。)
sysctl系统调用
用于可调内核参数的另一个接口是sysctl系统调用,以及相关函数。我不很喜欢这
个接口。为什么不呢?对于大部分实际工作目的来说,使用sysctl——不过这种方
法比修改源代码的旧方法来调整内核能够获得更大的性能提高——只会比访问
/proc文件更为笨拙。通过sysctl来进行读写需要C程序(或相似的东西),而
/proc却很容易通过外壳(shell)命令(或等价的通过命令解释程序脚本)来进行
访问。
另一方面,如果你正在C环境下工作,调用sysctl就比打开文件、读取并/或写入,
以及再关闭它要方便的多,所以sysctl在今后也有它的用武之地。与此同时,还是
让我们来看一看它的实现吧。
do_sysctl
30471:do_sysctl实现sys_sysctl(30504行),即sysctl系统调用的主要内容。
注意sys_sysctl还在第31275行出现过——那个版本只是在sysctl系统调用被编译
出内核时所使用的一个简单的存根程序(stub)函数。
    如果oldval非空就用oldval返回内核参数原有的值,而它的新值在newval非空时
从newval来进行设置。oldlenp和newlen分别标识出有多少字节应被写入oldval和
从newval读出,这是在相应的指针不是NULL的时候;当指针为NULL的时候,它们将
被忽略。
    要注意这里的不对称性:函数对旧值的长度使用指针,而对新的长度不使用指针
。这是因为旧的长度既是输入参数也是输出参数;它的输入值是可以通过oldval返
回的最大字节数,而它的输出值是实际返回的字节数。与之相反,新的长度只是一
个输入参数。
30482:如果调用者需要旧的内核参数值,从oldlenp来对old_len进行设置。
30490:开始遍历表树的循环列表。(参见本章随后对register_sysctl_table的讨
论。)
30493:使用parse_table(30560行,在下一段里讨论)来定位可调内核参数,然
后读和/或写它的值。
30495:如果parse_table分配了所有环境信息,它就被释放。很难准确地说出这个
环境信息表示着什么。它不被本书所讨论的任何代码使用——据我所知,它目前甚
至没有被内核里的任何代码所使用。
30497:ENOTDIR错误表示没有在这一棵表树中找到指定的内核参数——它可能在另
一棵还没有查找过的表树中。否则,error将为某个其它的错误代码,或者是代表
成功的0;无论如何,函数应该返回了。
30499:用DLIST_NEXT宏(本书对此不做介绍)来增加循环控制变量的值(loop
iterator)。
30501:返回ENOTDIR错误,报告出指定的内核参数在任何一个表里都未找到。
parse_table
30560:parse_table用于在表树里查找一个条目,其方法类同于在一个目录树里解
析出一个完全合格的文件名的方法。其思想如下:沿着一个int数组(数组name)
进行查找,并在一个ctl_table数组里搜索每个int。当找到一个匹配项时,它对应
的子孙表就被递归查阅(如果匹配项是目录类型的条目),或者该条目被读和/或
写(如果它是文件类型的条目)。
30566:多少有些令人惊讶的是,这一行就开始了对整型数组name内所有元素的循
环。习惯上的方法原本是把从这一行到第30597行所有代码用一个for循环包括起来
,它的开始是这样的:
    for ( ; nlen ; ++name , --nlen , table = table -> child )
    (这个循环还需要删除第30567和30568行代码,并用一个语句来替代从30587直到
30590行的代码。)推测起来,可能是实际使用的版本可以生成更好的目标代码吧

30570:开始循环所有的表项,查找与当前name匹配的一项;当表已被遍历结束(
table->ctl_  name为0了)或者指定的表项已被找到并处理时本循环结束。
30572:把name数组的当前项读入n里,以便它可以与当前表项的ctl_name进行检查
。因为name在内层循环中没有变化,这个读取操作可以放在循环外边(也就是移至
30569行)以提高一点速度。
30574:核查是否当前ctl_table的名字与被找到的名字相匹配,或是否它有特殊的
“通配符(wildcard)”值,即CTL_ANY(17761行)。后者的使用目的还不清楚,
因为现在并没有内核源代码的任何一处使用过CTL_ANY。它可能用于将来的方案中
——我也不认为它是过去版本的一个遗留物,因为CTL_ANY在2.0内核里也没有被用
到,而且整个sysctl接口也只向后兼容到2.0以前的开发树版本。
30576:如果这个表元素有一个孩子,它就是一个“目录”。
30577:遵循标准Unix行为,检查目录的x(可执行)位来判断是否当前进程可被允
许对它进行访问。注意到这与文件系统所实现的工作非常类似,虽然这并不是(
/proc)文件系统接口。这样可以使这两种接口在施用于可调内核参数时能够得到
一致的结果——如果一个用户有通过一种接口来修改某个内核参数的权限而通过另
一种却没有该权限,那么将是非常不可思议的。
30579:如果表项有一个策略(strategy)函数,它可能需要覆盖允许该进程进入
目录的授权。这个策略函数将被访问,如果它返回一个非零值,整个查找就被中止

30587:进入目录。本行有效的继续外层循环,并转移到该名字的下一部分。
30592:这个表节点是一个叶子节点,因此内核参数就被找到了。注意这并不打扰
对name数组是否已到其最后元素的检查(也就是现在nlen是否为1),虽然可以证
明如果不是那样就会有某类型错误产生。不管哪一种情况,do_sysctl_strategy(
30603行)都要负责对当前表元素进行读和/或写操作。
30598:name数组非空,可是它的元素在叶子节点被找到之前均已用完。
parse_table就返回ENOTDIR错误,来表示查找指定节点失败。顺便提及一点,注意
前一行里的分号是多余的。
do_sysctl_strategy
30603:do_sysctl_strategy在单独一个ctl_table里读和/或写数据。计划使用该
表元素里的strategy成员,如果存在的话,来完成读/写工作。如果表元素没有它
自己的strategy例程,某些通用的读/写代码将被替代使用。不过读者将要看到,
它并不完全按照计划工作。
30610:如果oldval非空,调用者将读取旧值,这样r位就会在op里被设置。类似的
,如果newval非空则w位被设置。接着,第30614行核查许可,如果当前进程缺少所
需的授权就返回EPERM错误。
30617:如果表项有它自己的strategy例程,这个例程就要处理读/写请求。如果它
返回负数——一个错误——这个错误就被传送给调用者。如果返回的是正数,0(
成功)就会被传送给调用者。如果是0,strategy例程就拒绝由它自己来处理请求
,取而代之的将是缺省行为。(读者可以设想只返回0的strategy例程,如果它完
成一些其它诸如收集被调用次数的统计数据这样的工作,它仍然是有用处的。)
30630:这里是通用读取代码开始的地方。注意get_user(13254行)的返回值不被
检查。(类似的缺陷发生在第9537和31186行。)
30632:确保不会有多于与该表项的maxlen成员所指定的数值相等的数据被返回。

30634:通过oldval从表里复制所要求的数据,再将真正被写的数据总量存储在
oldlenp中。
30642:类似于oldlenp,要确保写入表项的数据不能多于它的maxlen成员所允许的
值。注意如果copy_from_user在中途的第30644行检测到一个错误,tabel->data可
能会在仅仅被部分更新的情况下就结束。
30648:返回0表示成功。以下三种情况都可以达到这一点:
?   调用者对这个表项既不读也不写。
?   调用者尝试读和/或写这个表项,而且所有步骤都被成功执行。
?   表项没有关联的数据,或者因为它的maxlen是0,所以它是只读的。
三种情形中的第一种有点儿奇怪,而最后一种则更令人奇怪。第一种情况有些奇特
是因为调用sysctl却要求它对指定的表项既不读也不写,这并没有多少意义,所以
可以正当的把它当作一个错误来处理。尽管如此,它要与其它系统调用的内核实现
保持基本一致,那就是把一个无操作请求不看作是一个错误。比如说,在第8章中
介绍的sys_brk(33155行)在由调用者指定的新brk值与旧值相同时并不产生一个
错误信号。
第三种情况要比第一种奇怪一些,因为它可能真的反映着一个错误。例如,调用代
码尝试写入一个maxlen是0的参数,而且由于系统调用返回成功值而认为该尝试已
被完成。看起来事情好像不是这样,因为不管怎样为0的maxlen都会使该条目失效
,不过还真的存在一个maxlen为0的表项——参见第30380行。最终,一切都归结为
sysctl是怎样在文档中描述的,但是man帮助程序中却对此没有任何记载。我仍然
认为do_sysctl_strategy在这种情况下应该返回一个EPERM错误。
register_sysctl_table
30651:把一个新的根已经被给出的ctl_table树插入到其它树所形成的循环链表里

30655:分配一个struct ctl_table_header用来管理新树的信息。
30659:把新的首部(跟踪ctl_tables数组形成的新树)插入到首部组成的链表里

30666:调用register_proc_table(30689行,本章前边讨论过)把新的表树注册
在/proc/sys目录下。如果没有内核在没有/proc文件系统支持的情况下进行编译时
,则这一行将被编译到内核以外。
30688:新分配的首部被返回给调用程序,以便调用程序能够在以后通过把该首部
转递给unregister_sysctl_table(30672行)来删除相应的树。
unregister_sysctl_table
30672:如前所述,这个简单函数只是把一个ctl_table的树从内核里这样的树所组
成的循环链表里删除。如果内核是在支持/proc的情况下编译的,它也用于从
/proc文件系统里删除相应的数据。
    回顾一下第30490和30500行,读者不难发现root_table_header(30256行)——
对应于root_table的列表节点——是在遍历树的循环链表时被用作头和尾节点的。
读者现在能够明白实际上在unregister_sysctl_table函数里没有什么可以避免
root_table_header被从表头列表里删除——它只是还没有这样做而已。
sysctl_string
31121:sysctl_string是ctl_table的策略例程之一。回忆一下,策略例程可以从
第30618行(在do_sysctl_strategy里)被调用来有选择的覆盖一个表项的缺省读
/写代码。(策略例程也可以从第30580行被调用,不过该例程却从不会被调用。)

31127:如果该表没有相关数据,或者如果可访问部分的长度是0,则返回ENOTDIR
错误。这与do_sysctl_strategy的做法是不一致的,在同样的情况里它返回的是成
功。
31138:当前字符串的值被复制到用户空间,然后结果以NUL来结束(这意味着比由
lenp指定值多一个字节的数据可能会被复制——依据文档记录,这可能是一个缺陷
)。因为当前值已经是NUL结束的,这四行代码可以被简化为两行:
    if ( copy_to_user ( oldval , table -> data , len + 1 ) )
        return –EFAULT ;
    这种改变的正确性部分上依赖于当写入table->data时代码剩余部分所遵循的三个
特征:
?   代码剩余部分不能把多于table->maxlen的char数据复制进table->data里。(这
也使得第31136行的测试变得没有必要。即使还需要该测试,那也只用检查>,而不
用检查>=了。)
?   然后table->data以NUL来结束,如果必要就复写最后一个拷贝进来的字节,以便
包括NUL在内的总长度不大于table->maxlen。
?   table->maxlen永不发生变化。
    因为所有三个特征都有效,所以在第31138行len将总是严格小于table->maxlen,
而且结束NUL字节一定会在table->data[len+1]或之前的位置出现。
31146:与前边的情况类似,从用户空间中复制新值,而且结果以NUL来结束。不过
在这种情形下,不从用户空间复制NUL字节是一种正确的做法,因为把它从用户空
间复制进来要比仅仅在data的适当字节安排一个NUL效率低。而且以这种方式,即
使输入不是NUL结束的,table->data也要如此。当然,从newval读出的字符串可能
已经是NUL结束的,在那种情况里第31154行的赋值就是多余的。这还是另一种情况
,直接完成工作比检查需要是否执行它还要快。
31156:返回0表示成功。相反,返回的值应该为正数,以便30618行代码认为结果
是成功的。而又相反,调用代码认为sysctl_string想让缺省处理发生,然后它就
继续从用户空间再次复制多余的数据。
sysctl_intvec
31163:sysctl_intvec是在kernel/sysctl.c里定义的另一个策略例程。它确保假
如调用程序正在写入表项,所有被写的int都应位于某个最小和最大值之间。(顺
便提及一下,sysctl_intvec在这个文件里只被使用了一次——在第30414行——尽
管它被广泛的用于本书所没有包括的内核的其它代码之中。)
31170:如果新的欲写数据总量不符合一个int大小的边界,它就无效,所以尝试被
抛弃。
31173:假如表项没有指定一组最大或最小值,输入的值就永远不可能超出范围,
这样调用程序里的普通写代码(do_sysctl_strategy,30603行)就足够好了。因
此在这种情况里sysctl_intvec返回0。
31184:进行循环以确保所有来自输入数组的值都位于适当范围之内。
31186:这行代码不检查get_user的返回值——没有迫切的需要去这样做。如果当
不能读取一个输入内存位置时,sysctl_intvec返回0(成功),那么当它试图读取
整个数组时do_sysctl_strategy就会注意到这个问题。作为另一选择,假如
get_user无法读取内存位置,无用信息(garbage)可能在value里结束并且数值可
能会不正确的被抛弃。在此情况里,调用程序将得到一个EINVAL错误而不是
EFAULT错误,这只是一个小缺陷(bug)。
31187:注意这一行不会被折磨第31033行相似代码的缺陷所困扰,该行中在最小值
和最大值之上进行的并行循环会产生不同步的情况。
    这一行代码能够避免位于31033行的缺陷被暴露出来。正如实际中所进行的,
sysctl_intvec和proc_dointvec_minmax都总是与同一个ctl_table条目相关联的。
因此,在调用处理例程(handler routine)proc_dointvec_minmax之前,任何超
出允许范围之外的数值将会被策略例程sysctl_intvec截获。所以,我们知道——
在给定内核里所有的ctl_tables最新定义的情况下——proc_dointvec_minmax将永
远不会遇到超出界限的数值,而那里是唯一可以触发该缺陷的数值种类。某个调用
程序或许可以注册一个使用proc_dointvec_minmax但没有策略例程的ctl_table,
但是尽管这样,在proc_dointvec_ minmax里的这个缺陷迟早会造成一定损害。
31193:返回0表示成功。这里不像在第31156行那样是一个错误,因为
sysctl_intvec并不向table->data里写入。从用户空间读出的值只是被读进一个临
时变量里作范围检查,然后就被删除;do_sysctl_strategy将完成那项工作,并只
向table->data里进行写入。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 10:05:43 | 显示全部楼层
Linux内核注释——附录A Linux 2.4
内核的开发人员们并没有因为我在写这本关于Linux的书而暂停他们的工作。(我
是多么希望他们这样做……。)说真的,内核的发展进展神速,就在这本书即将出
版之际,它的最新稳定版本,2.4.0,也应该面世了。尽管在写作本书时要将对内
核的成千种修改都一一涵盖到是不可能的,但是这篇附录却总结了发生在本书所涉
及内核部分的最令人感兴趣的那些改变。这些修改中的绝大部分已经在内核的2.
3.12版本里实现了,它们被包括在附赠的CD-ROM上。
作者十分感谢Joe Pranevich的论文,”Wonderful World of Linux 2.4”(
http://linuxtoday.Com/stories/8191.html),在准备这篇附录时它提供了无价
的帮助。
更少的“惊扰(stampedes )”
请读者回忆一下第2章里,函数__wake_up(26829行)唤醒等待在等待队列上的所
有进程。可以考虑一个诸如Apache一样的Web服务器,它试图通过在同一端口派生
出许多进程监听连接申请以缩短响应时间(正好与等待连接请求到达,并且与只通
过fork派生一个进程来响应的方式相反)。所有这些进程都在一个等待队列里,等
待连接请求到达,而当请求确实到达的时候,它们都会被唤醒。它们当中只有一个
能够为请求提供服务,所以剩下的又都将返回休眠状态。
这种“惊扰”现象会浪费CPU的时钟周期——只唤醒一个等待进程会更好,因为不
管怎样只有一个能够运行。因此,一个新的任务状态位TASK_EXCLUSIVE就被引进;
在一次调用__wake_up里,最多有一个设置了TASK_EXCLUSIVE位的任务会被唤醒。
TASK_EXCLUSIVE位是附加在其他任务状态位上的——它并不代表一个新的任务状态
,它只是为了方便而保存在任务状态信息组里的信息而已。
现在__wake_up要检查它正在唤醒的进程是否设置了TASK_EXCLUSIVE位,并且在它
唤醒该进程之后便不再唤醒其他进程(通过使用break中断循环)。
TASK_EXCLUSIVE位现在只被用在有关网络的等待队列上,而且它在那种环境中效果
也的确很好。对于大多数其他等待队列来说,你需要所有进程对等待着的资源都有
机会进行占用,这样可以避免饥饿现象。不过等待在同一个端口的服务程序通常与
Apache境遇相同:所有等待的任务都是一样的,而且重要的是只要它们中有一个能
够处理请求就可以,即使每次都是同一个任务(也无所谓)。
再见吧,Java
正如第7章里所预言的那样,Java二进制处理程序(binary handler)已经从内核
里消失了,它是因被杂项二进制处理程序所替代而失效的。二元处理程序的常规用
法没有变化,Java执行体仍然可被杂项二进制处理程序的适当配置所完全支持。
ELF权能位
已有非官方的修补程序可以用于(增强)ELF执行体中权能的存储。它们还没有成
为正式内核版本的一部分,部分原因在于是否ELF文件头是保存这些信息的恰当位
置的问题还处于讨论之中。不过,这些修补程序也有可能已经成为正式版本的一部
分了。
调度程序提速
已被高度优化了的schedule函数(26686行)又被作了进一步优化。大多数修改只
是在system_call(171行)系列调用的基础上进行了重新组织——也就是说,通过
允许普通情况直接通过以及把遍布在函数里的大部分if语句体都分散开来的方式提
高代码的(运行)速度。举例来说,第26706和26707行,如果又要运行的代码它们
就运行底下的下半部分(bottom halves),现在采用的是这种形式:
P563—1
这样一来,如果下半部分程序必须运行,控制流程就会跳转到新的handle_bh标记
处,然后在运行完下半部分之后再跳转回去。原有方式在无需运行下半部分时的正
常情况下也要产生一个分支转移,因为在那种情况下所产生的代码不得不跳过对
handle_bottom_halves的调用。在新的版本里,正常情况只需直接通过,不会产生
任何分支。
更多的进程
Linux 2.4几乎消除了对同时可以运行的进程数目的固有限制。唯一剩下来的硬编
码(hard-coded)限制就是PID的最大数目了(不要忘记PID可被共享)。这个改进
使得Linux能够更好的适合于高端(high-end)服务器应用程序,包括经常需要同
时运行大量进程的Web服务。
在第8章中说明过,原有4090个任务的最高限度是受可以同时保存在全局描述表(
GDT)里的任务状态段(TSS)和局部描述表(LDT)的最大数目所影响的:Linux
2.2在GDT里为每个进程存储一个TSS和一个LDT,而且GDT总共允许有最多8192个条
目。Linux 2.4通过存储TSS和LDT本身而不是把它们存储在GDT里的方法回避了这个
限制。现在,GDT只保持每CPU一个TSS和一个LDT,而且把这些条目设置为在每个
CPU上的当前运行任务所需要的信息。
虽然用于追踪每个CPU的空闲进程的init_tasks数组还存在,但是task数组(
26150行)现在却没有了。
日益进步的SMP支持
每一个锁都是一个SMP机器可以被有效地转化为UP机器的地方:正等待一个锁的
CPU对系统的整体性能没有任何贡献。因此,改善SMP性能的主要方法就是通过减小
锁的作用域或者完全消除它们来增加并行程序的运行机会。
Linux 2.0只有一个单独的全局内核锁,所以每次只有一个CPU可以在内核里执行。
Linux 2.2把这个全局内核锁使用的大部分地方都用更小的、子系统专用的锁来替
代了,其中的一些我们在第10章中已经见到过。Linux 2.4继续了这个趋势,它把
可能减小其作用域的锁都作了进一步的分割。比如现在每个等待队列就有一个锁,
而不是所有的等待队列才有唯一的一个锁。
回复

使用道具 举报

 楼主| 发表于 2003-10-27 10:06:47 | 显示全部楼层
Linux内核注释——附录B GNU通用公共

序言
    大多数软件许可证都有意剥夺你的共享和修改软件的自由。相对之下,GNU通
用公共许可证力图保证你的共享和修改自由软件的自由。——保证自由软件对所有
用户都是自由的。GPL适用于大多数自由软件基金会的软件,以及由愿意遵守该许
可证规定的作者所开发的软件。(自由软件基金会的其它一些软件受GNU库通用许
可证的保护)。你也可以将它应用到你的程序中。
    当我们谈到自由软件(free software)时,我们指的是自由而不是价格。我
们的GNU通用公共许可证目的是要保证你有发布自由软件的自由(如果你愿意,你
也可以对此项服务收取一定的费用);保证你能收到源程序或者在你需要时能得到
它;保证你能修改软件或将它的一部分用于新的自由软件;而且还保证你知道你能
做这些事情。
    为了保护你的权利,我们需要作出规定:禁止任何人不承认你的权利,或者要
求你放弃这些权利。如果你自行发布了软件的副本或者对其进行了修改,那么这些
规定就转化为你的责任。
    例如,如果你发布这样一个程序的副本,不管是收费的还是免费的,你必须将
你具有的一切权利给予你的接受者;你必须保证他们能收到或得到源程序;并且将
这些条款给他们看,使他们知道他们有这样的权利。
    我们采取两项措施来保护你的权利。
    (1)给软件以版权保护。
    (2)给你提供许可证。它给你复制,发布和修改这些软件的法律许可。
    同样,为了保护每个作者和我们自己,我们需要清楚地让每个人明白,自由软
件没有担保(no warranty)。如果由于其他某个人修改了软件,并继续加以传播
。我们需要它的接受者明白:他们所得到的并不是原来的自由软件。由其他人引入
的任何问题,不应损害原作者的声誉。
    最后,任何自由软件都不断受到软件专利的威胁。我们希望能够避免这样的风
险,自由软件的再发布者以个人名义获得专利许可证。事实上,将软件变为私有。
为防止这一点,我们必须明确:任何专利必须以允许每个人自由使用为前提,否则
就不准许有专利。
    下面是有关复制,发布和修改的确切的条款和条件。
复制,发布和修改的条款和条件
此许可证适用于任何包含版权所有者声明的程序和其它作品,版权所有者在声明中
明确说明程序和作品可以在本GPL条款的约束下发布。下面提到的“程序”指的是
任何这样的程序或作品。而“基于程序的作品”指的是程序或者任何受版权法约束
的衍生作品:也就是说包含程序或程序的一部分的作品。可以是原封不动的,也可
以是经过修改的和/或翻译成其它语言的(程序)。在下文中,翻译包含在修改的
条款中。每个许可证接受人(licensee)用“你”来称呼。
    许可证条款不适用于复制,发布和修改以外的活动。这些活动超出这些条款的
范围。运行程序的活动不受条款的限制。仅当程序的输出构成基于程序作品的内容
时,这一条款才适用(如果只运行程序就无关)。是否普遍适用取决于程序具体用
来做什么。
1.  只要你在每一副本上明显和恰当地给出版权声明和不承担担保的声明,保持此
许可证的声明和没有担保的声明完整无损,并和程序一起给每个其它的程序接受者
一份许可证的副本,你就可以用任何媒体复制和发布你收到的原始的程序的源代码

    你可以为转让副本的实际行动收取一定费用。你也有权选择提供担保以换取一
定的费用。
2.  你可以修改程序的一个或几个副本或程序的任何部分,以此形成基于程序的作
品。只要你同时满足下面的所有条件,你就可以按前面第一款的要求复制和发布这
一经过修改的程序或作品。
    a) 你必须在修改的文件中附有明确的说明:你修改了这一文件及具体的修改
日期。
    b) 你必须使你发布或出版的作品(它包含程序的全部或一部分,或包含由程
序的全部或部分衍生的作品)允许第三方作为整体按许可证条款免费使用。
    c) 如果修改的程序在运行时以交互方式读取命令,你必须使它在开始进入常
规的交互使用方式时打印或显示声明:包括适当的版权声明和没有担保的声明(或
者你提供担保的声明);用户可以按此许可证条款重新发布程序的说明;并告诉用
户如何看到这一许可证的副本。(例外的情况:如果程序自身以交互方式工作,但
是它并不像通常情况一样打印这样的声明,你的基于程序的作品也就不用打印声明
)。
    这些要求适用于修改了的作品的整体。如果能够确定作品的一部分并非程序的
衍生产品,可以合理地认为这部分是独立的,是不同的作品。当你将它作为独立作
品发布时,它不受此许可证和它的条款的约束。但是当你将这部分作为基于程序的
作品的一部分发布时,作为整体它将受到许可证条款约束。准予其他许可证持有人
的使用范围扩大到整个产品。也就是每个部分,不管它是谁写的。
    因此,本条款的意图不在于索取权利;或剥夺全部由你写成的作品的权利。而
是履行权利来控制基于程序的集体作品或衍生作品的发布。
    此外,将与程序无关的作品和该程序或基于程序的作品一起放在存贮介质或发
布媒体的同一卷上,并不导致将其它作品置于此许可证的约束范围之内。
3.  你可以以目标码或可执行形式复制或发布程序(或符合第2款的基于程序的作
品),只要你遵守前面的第1,2款,并同时满足下列3条中的1条。
    a)在通常用作软件交换的媒体上,和目标码一起附有机器可读的完整的源码
。这些源码的发布应符合上面第1,2款的要求。或者
    b)在通常用作软件交换的媒体上,和目标码一起,附有给第三方提供相应的
机器可读的源码的书面报价。有效期不少于3年,费用不超过完成源程序发布的实
际成本。源码的发布应符合上面的第1,2款的要求。或者
    c)和目标码一起,附有你收到的发布源码的报价信息。(这一条款只适用于
非商业性发布,而且你只收到程序的目标码或可执行代码和按b)款要求提供的报
价)。
    作品的源码指的是对作品进行修改最优先择取的形式。对可执行的作品讲,完
整的源码包括:所有模块的所有源程序,加上有关的接口的定义,加上控制可执行
作品的安装和编译的script。作为特殊例外,发布的源码不必包含任何常规发布的
供可执行代码在上面运行的操作系统的主要组成部分(如编译程序,内核等)。除
非这些组成部分和可执行作品结合在一起。
    如果采用提供对指定地点的访问和复制的方式发布可执行码或目标码,那么,
提供对同一地点的访问和复制源码可以算作源码的发布,即使第三方不强求与目标
码一起复制源码。
4.  除非你明确按许可证提出的要求去做,否则你不能复制、修改、转发许可证和
发布程序。任何试图用其它方式复制、修改、转发许可证和发布程序是无效的。而
且将自动结束许可证赋予你的权利。然而,对那些从你那里按许可证条款得到副本
和权利的人们,只要他们继续全面履行条款,许可证赋予他们的权利仍然有效。

5.  你没有在许可证上签字,因而你没有必要一定要接受这一许可证。然而,没有
任何其它东西赋予你修改和发布程序及其衍生作品的权利。如果你不接受许可证,
这些行为是法律禁止的。因此,如果你修改或发布程序(或任何基于程序的作品)
,你就表明你接受这一许可证以及它的所有有关复制,发布和修改程序或基于程序
的作品的条款和条件。
6.  每当你重新发布程序(或任何基于程序的作品)时,接受者自动从原始许可证
颁发者那里接到受这些条款和条件支配的复制,发布或修改程序的许可证。你不可
以对接受者履行这里赋予他们的权利强加其它限制。你也没有强求第三方履行许可
证条款的义务。
7.  如果由于法院判决或违反专利的指控或任何其它原因(不限于专利问题)的结
果,强加于你的条件(不管是法院判决,协议或其它)和许可证的条件有冲突。他
们也不能用许可证条款为你开脱。在你不能同时满足本许可证规定的义务及其它相
关的义务时,作为结果,你可以根本不发布程序。例如,如果某一专利许可证不允
许所有那些直接或间接从你那里接受副本的人们在不付专利费的情况下重新发布程
序,唯一能同时满足两方面要求的办法是停止发布程序。
    如果本条款的任何部分在特定的环境下无效或无法实施,就使用条款的其余部
分。并将条款作为整体用于其它环境。
    本条款的目的不在于引诱你侵犯专利或其它财产权的要求,或争论这种要求的
有效性。本条款的主要目的在于保护自由软件发布系统的完整性。它是通过通用公
共许可证的应用来实现的。许多人坚持应用这一系统,已经为通过这一系统发布大
量自由软件作出慷慨的供献。作者/捐献者有权决定他/她是否通过任何其它系统
发布软件。许可证持有人不能强制这种选择。
    本节的目的在于明确说明许可证其余部分可能产生的结果。
8.  如果由于专利或者由于有版权的接口问题使程序在某些国家的发布和使用受到
限制,将此程序置于许可证约束下的原始版权拥有者可以增加限止发布地区的条款
,将这些国家明确排除在外。并在这些国家以外的地区发布程序。在这种情况下,
许可证包含的限止条款和许可证正文一样有效。
9.  自由软件基金会可能随时出版通用公共许可证的修改版或新版。新版和当前的
版本在原则上保持一致,但在提到新问题时或有关事项时,在细节上可能存在一定
差别。
    每一版本都有不同的版本号。如果程序指定适用于它的许可证版本号以及“任
何更新的版本”。你有权选择遵循指定的版本或自由软件基金会以后出版的新版本
,如果程序未指定许可证版本,你可选择自由软件基金会已经出版的任何版本。

10.  如果你愿意将程序的一部分结合到其它自由程序中,而它们的发布条件不同
。写信给作者,要求准予使用。如果是自由软件基金会加以版权保护的软件,写信
给自由软件基金会。我们有时会作为例外的情况处理。我们的决定受两个主要目标
的指导。这两个主要目标是:我们的自由软件的衍生作品继续保持自由状态。以及
从整体上促进软件的共享和重复利用。
没有担保
11.  由于程序准予免费使用,在适用法准许的范围内,对程序没有担保。除非另
有书面说明,版权所有者和/或其它提供程序的人们“一样”不提供任何类型的担
保。不论是明确的,还是隐含的。包括但不限于隐含的适销和适合特定用途的保证
。全部的风险,如程序的质量和性能问题都由你来承担。如果程序出现缺陷,你承
担所有必要的服务,修复和改正的费用。
12.  除非适用法或书面协议的要求,在任何情况下,任何版权所有者或任何按许
可证条款修改和发布程序的人们都不对你的损失负有任何责任。包括由于使用或不
能使用程序引起的任何一般的、特殊的、偶然发生的或重大的损失(包括但不限于
数据的损失,或者数据变得不精确,或者你或第三方的持续的损失,或者程序不能
和其它程序协调运行等)。即使版权所有者和其他人提到这种损失的可能性也不例
外。
如何将这些条款用到你的新程序
    如果你开发了新程序,而且你需要它得到公众最大限度的利用。要做到这一点
的最好办法是将它变为自由软件。使得每个人都能在遵守条款的基础上对它进行修
改和重新发布。
    为了做到这一点,给程序附上下列声明。最安全的方式是将它放在每个源程序
的开头,以便最有效地传递拒绝担保的信息。每个文件至少应有“版权所有”行以
及在什么地方能看到声明全文的说明。
         <用一行空间给出程序的名称和它用来做什么的简单说明>
          版权所有(C)         19XX            <作者姓名>
    这一程序是自由软件,你可以遵照自由软件基金会出版的GNU通用公共许可证
条款来修改和重新发布这一程序。或者用许可证的第二版,或者(根据你的选择)
用任何更新的版本。
    发布这一程序的目的是希望它有用,但没有任何担保。甚至没有适合特定目的
的隐含的担保。更详细的情况请参阅GNU通用公共许可证。
    你应该已经和程序一起收到一份GNU通用公共许可证的副本。如果还没有,写
信给:
    The Free Software Foundation, Inc.,  59  Temple Place,  Suite,
    330, Boston, MA02111-1307,  USA

    还应加上如何和你保持联系的信息。

    如果程序以交互方式进行工作,当它开始进入交互方式工作时,使它输出类似
下面的简短声明:
    Gnomovision   第69版,  版权所有(C)  19XX,   作者姓名,
    Gnomovision绝对没有担保。 要知道详细情况,请输入‘show w’。
    这是自由软件,欢迎你遵守一定的条件重新发布它,要知道详细情况,
    请输入‘show c’。

    假设的命令‘show w’和‘show c’应显示通用公共许可证的相应条款。当然
,你使用的命令名称可以不同于‘show w’和‘show c’。根据你的程序的具体情
况,也可以用菜单或鼠标选项来显示这些条款。

    如果需要,你应该取得你的上司(如果你是程序员)或你的学校签署放弃程序
版权的声明。下面只是一个例子,你应该改变相应的名称:
         Yoyodyne公司以此方式放弃James Hacker
         所写的 Gnomovision程序的全部版权利益。
         <Ty coon签名>,1989.4.1
         Ty coon付总裁

    这一许可证不允许你将程序并入专用程序。如果你的程序是一个子程序库。你
可能会认为用库的方式和专用应用程序连接更有用。如果这是你想做的事,使用
GNU库通用公共许可证代替本许可证。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

GMT+8, 2024-11-2 06:27 , Processed in 0.141781 second(s), 15 queries .

© 2021 Powered by Discuz! X3.5.

快速回复 返回顶部 返回列表