第 2 章 软件基础


程序就是一组执行特定任务的计算机指令。程序既可以用非常低级的计算机语言--汇编语 言,也可以用高级的、独立于机器的语言如C来编写。操作系统是一种特殊的程序,它允 许用户运行各种应用程序如制表程序和字处理程序。本章介绍基本的程序设计原理,并对 操作系统的目标和功能做一综述。 2.1 计算机语言 2.1.1 汇编语言 CPU从内存中取出并运行的指令对人来说根本无法理解。它们是精确指示机器如何操作的 机器代码。例如,十六进制数0x89E5是Intel80486的一条指令,把ESP寄存器的内容拷 到EBP寄存器中。汇编器是最早发明的软件工具之一,它输入人类可以理解的源代码, 汇编为机器代码。汇编语言显式地处理寄存器和数据操作,与特定的微处理器相关(应为 与特定的处理器相关--译者注)。IntelX86微处理器的汇编语言就与Alpha AXP微处理器 的汇编语言大相径庭。以下Alpha AXP汇编代码表示了程序可以进行的一种操作: ldr r16, (r15) ; Line 1 ldr r17, 4(r15) ; Line 2 beq r16,r17,100 ; Line 3 str r17, (r15) ; Line 4 100: ; Line 5 第一条指令(见第一行)把寄存器15存放的地址中的内容装入寄存器16。下一条指令把 内存中下一个位置的内容装入寄存器17。第三行把寄存器16和寄存器17的内容比较,如 果相等,分支转向标号100。如果两个寄存器包含数值不等,程序继续运行第四行,把寄 存器17的内容存到内存。如果两个寄存器包含数值相等,那么没有数据需要保存。编写 汇编语言程序枯燥乏味、技巧性强而且易于出错。Linux核心只有很少的一点用汇编语言 编写,目的是为了效率,这些部份是与特定机器相关的。 2.1.2 C语言和编译器 用汇编语言编写大型程序十分困难而且消耗大量时间。这样做易于出错,得到的程序也无 法移植,限制在特定的处理器族上。用独立于机器的语言如C,会好得多。C允许你用逻 辑算法和其操作的数据结构来描述程序。叫作编译器的特定程序读入C程序,并把它翻译 成汇编语言,生成相应的机器代码。好的编译器所产生的汇编指令的效率接近于好的汇编 语言程序员编写的汇编语言程序。大部份Linux核心是用C语言编写的。以下的C片段: if (x != y) x = y ; 与前一个例子中汇编代码的操作完全相同。如果变量x和y的内容并不完全相同,就把y 的内容拷给x。C代码组织为例程,每一个例程执行一个任务。例程可以返回C支持的任 何数值或者数据类型。象Linux核心这样的大型程序包含很多独立的C源模块,每个模块 都有自己的例程和数据结构。这些C源代码模块把象文件系统处理这样的逻辑功能组合在 一起。 C支持很多类型的变量。所谓变量,就是内存中的一个位置,可以用符号名字来引用。在 以上C片段中,x和y指引了内存中的位置。程序员不关心变量究竟存放在内存中的何 处,这是连接器(见下面所述)的任务。一些变量含有不同类型的数据、整数和浮点数, 另一些则是指针。 指针就是包含地址--其它数据在内存中的位置,的变量。考虑叫做x的变量,它可能处于 内存地址0x80010000。你可以有一个指针,叫做px,指向x。px可能处于地址 0x80010030,而px的值是0x80010000,变量x的地址。 C允许你把相关的变量绑在一起,形成数据结构。例如, struct { int i ; char b ; } my_struct ; 是一个叫做my_struct的数据结构,它包含两个元素:叫做i的整数(32位数据)和叫做b 的字符(8位数据)。 2.1.3 连接器 连接器是一种程序,它可以把几个目标模块和库连接在一起,产生一个独立的、自洽的程 序。目标模块是汇编器或编译器生成的机器代码输出,含有可执行的机器代码和数据,以 及允许连接器把模块连接起来的信息。例如一个模块可能含有程序中所有的数据库函数, 而另外一个则含有命令行参数处理函数。连接器负责解决目标模块之间的引用,例如一个 模块中引用的例程或数据结构事实上在另外一个模块之中。Linux核心就是一个与很多成 员目标模块连接在一起的独立大程序。 2.2 什么是操作系统? 没有软件的计算机就是一堆发热的电子器件。如果说硬件是计算机的核心,那么软件就是 计算机的灵魂。所谓操作系统,就是允许用户在其上运行应用软件的一组系统程序。操作 系统对系统的真正硬件进行抽象,向系统的用户和应用程序给出一个虚拟机。在很现实的 意义上说,软件提供了系统的特点。绝大部份PC能运行一个或多个操作系统,每一个擦 系统都有一个完全不同的外观和风格。Linux是由一批功能上分离的部件组成,其中明显 的一个是核心。但是即使是核心,如果脱离库和外壳程序也是没有用的。 为了开始理解什么是操作系统,请考虑当你敲入以下的简单命令时会发生的情况: $ ls Mail c images perl docs tcl $ 这里$是由登录外壳程序(在此例为bash)给出的提示符。这意味着它在等待你--用户, 敲入命令。敲入ls后,键盘驱动程序识别出已经有字符输入。键盘驱动程序把这些字符传 给外壳程序,外壳程序则通过寻找可执行程序的映象来处理这个命令。它在/bin/ls发现了 映象,于是调用核心服务来把ls可执行程序的映象拖入虚拟内存,开始执行。ls的映象调 用核心的文件子系统,以找出有哪些文件可以获得。文件系统有可能要使用放在cache中 的文件系统的信息或者用磁盘驱动程序来从磁盘读出这些信息,甚至可能用网络驱动程序 与远程机器交换信息,以找出本系统能够存取的远程文件的细节(文件系统可以通过"网 络文件系统"NFS来远程mount)。无论是用哪种方式定位信息,ls都会把信息写出来,由 视频驱动程序把它显示在屏幕上。 以上看起来很复杂,但是说明了一个道理:即使是最简单的命令,也需要相当的处理,操 作系统事实上是一组互相合作的函数,它们在整体上给用户以一个系统的完整印象。(以 上一句是根据译者理解翻译的,未必忠实于原文--译者注) 2.2.1 内存管理 如果有无限的资源,例如内存,很多操作系统需要做的事情都是冗余的。操作系统的一个 基本技巧是使一小块内存看起来象很多内存。这种表面上的大内存称为虚拟内存。其思想 是使系统中运行的软件以为它在很多内存上运行。系统把内存分成很多容易控制的页面, 把一些页面交换到硬盘上。由于另外的一个技巧--多道处理,软件注意不到这一点。 2.2.2 进程 进程可以想象为一个活动中的程序。每一个进程是一个独立的实体,在运行一个特定程 序。如果你看看你的Linux系统中的进程,你就会发现一大堆。例如,在我的系统中敲入 ps可以显示如下进程: $ ps PID TTY STAT TIME COMMAND 158 pRe 1 0:00 -bash 174 pRe 1 0:00 sh /usr/X11R6/bin/startx 175 pRe 1 0:00 xinit /usr/X11R6/lib/X11/xinit/xinitrc -- 178 pRe 1 N 0:00 bowman 182 pRe 1 N 0:01 rxvt -geometry 120x35 -fg white -bg black 184 pRe 1 <0:00 xclock bg grey geometry 1500-1500 padding 0 185 pRe 1 < 0:00 xload bg grey geometry 0-0 label xload 187 pp6 1 9:26 /bin/bash 202 pRe 1 N 0:00 rxvt geometry 120x35 fg white bg black 203 ppc 2 0:00 /bin/bash 1796 pRe 1 N 0:00 rxvt geometry 120x35 fg white bg black 1797 v06 1 0:00 /bin/bash 3056 pp6 3 < 0:02 emacs intro/introduction.tex 3270 pp6 3 0:00 ps $ 如果我的系统有很多CPU,每个进程(至少在理论上)可以运行在一个不同的CPU上。 不幸的是,我只有一个CPU,所以系统只能求助于让每个进程轮流运行一小段时间的办 法。这一小段时间称为时间片。这种技巧称为多道处理或者调度,它使得每个进程以为自 己是唯一的进程。在进程之间进行保护,以便当一个进程崩溃或者错误时,不会影响其它 进程。操作系统给每个进程一个单独的、只有它自己能存取的地址空间,以达到这样的目 的。 2.2.3 设备驱动程序 设备驱动程序构成了Linux核心的主要部份。就象操作系统的其它部份一样,设备驱动程 序在高优先级的环境下运行,一旦发生错误就可能造成危险。设备驱动程序控制操作系统 和其控制的硬件设备之间的互相作用。例如,在把块写到IDE硬盘时,文件系统使用一个 通用块设备接口。驱动程序负责细节,控制与设备相关的事情。设备驱动程序是针对其控 制的特定芯片的,所以如果你有一个NCR810 SCSI控制器,那么你就需要一个NCR810 SCSI驱动程序。 2.2.4 文件系统 在Linux中,就象在Unix中一样,系统可以使用的不同的文件系统并非通过设备标识来 存取(例如驱动器号或驱动器名),而是被组织在一个单一的分层树结构里,每个文件系 统用一个实体来表示。文件系统通过把新的文件系统mount在某个目录下--例如 /mnt/cdrom,从而把新的文件系统加入树中。Linux的最重要特点之一是支持多个不同的文 件系统,这使得其伸缩性好,易于与其它操作系统共存。最广为人知的Linux文件系统是 EXT2,受到所发行的绝大部份的Linux的支持。 文件系统屏蔽了下层物理设备或文件系统类型的细节,给予用户察看硬盘上文件和目录的 一个清楚视角。Linux透明地支持多种不同文件系统(例如MS-DOS和EXT2),把所有 mount上的文件和文件系统都组织在一个虚拟文件系统之中。所以,一般而言,用户和进 程并不需要知道哪个文件在哪个文件系统之中,只需要直接去用它就可以了。 块设备驱动程序隐藏了物理块设备类型之间的差别(例如IDE和SCSI的差别),从文件 系统的角度来看,物理设备只是数据块的线形聚集。不同设备的块大小不同,例如软盘通 常是512字节,而IDE设备通常是1024字节,但是系统的用户是看不到这一点的。无论 放在什么设备上,EX2文件系统看起来都一样。 2.3 核心数据结构 操作系统必须保存关于系统当前状态的很多信息。在系统中发生了事情之后,这些数据结 构必须修改,以反映当前的真实状况。例如,在一个用户登录到系统之后,可能会创建一 个新的进程。核心必须创建表示新进程的数据结构,并把它和表示系统中所有其它进程的 数据结构连接在一起。 通常这些数据结构存在于物理内存之中,只能由核心及其子系统存取。数据结构包括数据 和指针--其它数据结构的地址或例程的地址。总而言之,Linux使用的数据结构看起来很 复杂难懂。虽然其中某些可能为几个核心子系统所使用,但是每个数据结构都有其目的, 所以这些数据结构事实上比刚看起来要简单。(以上一句是根据译者的理解翻译,未必正 确和符合原文--译者注) 理解Linux核心依赖于理解其数据结构以及核心中各种函数对数据结构的使用。本书对 Linux核心的描述就是基于其数据结构的。本书讨论了每个核心子系统的算法、完成工作 的方法和对核心数据结构的使用。 2.3.1 链表 Linux使用一系列软件工程技术来把数据结构连接起来。在很多情况下要使用链表。如果 每个数据结构描述某事物的一个实例或一次发生,例如一个进程或一个网络设备,那么核 心就必须能够找到所有的实例。在链表中,根指针含有表中第一个数据结构,或者称为 “元素”,的地址,而每个数据结构都含有一个指向表中下一个元素的next指针。最后一 个元素的next指针为0或NULL,以示它已经是表尾。在双链表中,每个元素含有指向表 中下一个元素的next指针和指向表中前一个元素的previous指针。使用双链表方便了增加 或删除表中间的元素,当然,内存的存取也增加了。这是一个典型的操作系统中的折中: 消耗内存存取与消耗CPU周期的折中。 2.3.2 Hash表 链表可以方便地把数据结构绑在一起,但是浏览链表效率很低。如果你想查找特定的一个 元素,很可能回不得不看完全表才找到需要的那个。Linux使用Hash技术来避开这个限 制。所谓Hash表,是一个指针的数组或者向量。这里说的数组或者向量,是指内存中的 一组顺序存放的东西。因此,书架可以说成是书的数组。数组使用索引进行存取,而索引 就是数组中位置的偏移量。把书架的比喻继续下去,你可以通过在书架上的位置来描述每 本书。例如,你可以要求拿第5本书。 所谓Hash表,是指向数据结构的指针数组,采用数据结构中的信息作为Hash表的索引。 如果你有描述村庄人口的数据结构,那么你可以采用人的年龄作为索引。为了寻找某个特 定人的数据,你可以采用年龄作为人口Hash表的索引,然后按照指针找到含有此人细节 的数据结构。(这里的指针相当于普通教科书上所说的Hash函数:Hash(ad)="*ad,--译者" 注)不幸的是,很可能村中的许多人年龄相同,所以Hash表的指针成了指向一个数据结 构链的指针,每个数据结构描述相同年龄的一个人。然而,搜索这些短链还是比搜索全部 数据结构要快。 由于Hash表加快了普通数据的存取,Linux经常使用Hash表来实现cache。cache通常是 总体信息的一部份,被抽出来需要加速存取。操作系统常用的数据结构要放在cache中保 存。cache的不利之处在于使用和维护都比简单链表或Hash表更加复杂。假如能在cache 中找到数据结构(称为“命中”),那么好得很。假如找不到,那么所有相关数据结构都 要被搜索,如果最终能找到,那么该数据结构将被加入cache中。把新的数据结构加入 cache 可能会需要挤出一个旧的cache项入口。Linux必须决定到底挤出哪一个才好,以尽 可能避免恰恰挤出下面要用的数据结构。 2.3.3 抽象接口 Linux核心经常对接口进行抽象。所谓接口,是例程和数据结构的集合,它通过某种特定 方式进行操作。例如所有的网络设备驱动程序必须提供操作某些特定数据结构的某些特定 例程。这样,就能有使用低层特殊代码的通用代码层。例如网络层是通用的,受到遵循标 准接口的与设备相关的代码的支持。 通常这些低层在启动时注册到高层。注册时一般要把一个数据结构加到一个链表中。例如 核心中内置的每个文件系统在启动时或者(如果使用模块的话)首次使用时注册到核心。 通过查看文件/proc/filesystems能够看到哪些文件系统已经注册。注册数据结构通常包含函 数指针。这些都是进行特定任务的软件函数的地址。再次用文件系统注册作为一个例子, 每个文件系统在注册时传给Linux核心的的数据结构包含与文件系统相关的函数地址,这 些函数在该文件系统mount时必须调用。