开发 Linux 命令行实用程序

本文将指导您学习如何编写即使对最终用户而言也足够简单的 Linux 命令行实用程序。本文以概述可靠的命令行最佳实践开始,并以详细地研究一个有效的选页工具结束,为您提供动手编写自己的实用程序所需要的背景知识。

本文演示如何编写与 cat、ls、pr 和 mv 等标准命令类似的 Linux 命令行实用程序。我选择了一个名为 selpg 的实用程序,这个名称代表 SELect PaGes。selpg 允许用户指定从输入文本抽取的页的范围,这些输入文本可以来自文件或另一个进程。selpg 是以在 Linux 中创建命令的事实上的约定为模型创建的,这些约定包括:

    * 独立工作
    * 在命令管道中作为组件工作(通过读取标准输入或文件名参数,以及写至标准输出和标准错误)
    * 接受修改其行为的命令行选项

不久前我为一位客户开发了 selpg。随后我将它公布在一个 UNIX 邮件列表上,结果有许多成员告诉我他们发现这是一个有用的工具。

该实用程序从标准输入或从作为命令行参数给出的文件名读取文本输入。它允许用户指定来自该输入并随后将被输出的页面范围。例如,如果输入含有 100 页,则用户可指定只打印第 35 至 65 页。这种特性有实际价值,因为在打印机上打印选定的页面避免了浪费纸张。另一个示例是,原始文件很大而且以前已打印过,但某些页面由于打印机卡住或其它原因而没有被正确打印。在这样的情况下,则可用该工具来只打印需要打印的页面。

除了包含 Linux 实用程序现实的示例外,本文还有以下特性:

    * 它用实例说明了 Linux 软件开发环境的能力。
    * 它演示了对一些系统调用和 C 库函数的适当使用,其中包括 fopen、fclose、access、setvbuf、perror、strerror 和 popen。
    * 它实现了打算用于通用目的的实用程序(而不是一次性程序)所应有的那种彻底的错误检查。
    * 它对潜在的问题提出警告,如在 C 中编程时可能出现的缓冲区溢出,并就如何预防这些问题提供了建议。
    * 它演示了如何进行手工编码的命令行参数解析。
    * 它演示了如何在管道中以及在输入、输出和错误流重定向的情况下使用该工具。

命令行准则
通用 Linux 实用程序的编写者应该在代码中遵守某些准则。这些准则经过了长期发展,它们有助于确保用户以更灵活的方式使用实用程序,特别是在与其它命令(内置的或用户编写的)以及 shell 的协作方面 — 这种协作是利用 Linux 作为开发环境的能力的手段之一。selpg 实用程序用实例说明了下面列出的所有准则和特性。(注:在接下来的那些示例中,“$”符号代表 shell 提示符,不必输入它。)

准则 1. 输入
应该允许输入来自以下两种方式:

    *

      在命令行上指定的文件名。例如:

      $ command input_file

      在这个例子中,command 应该读取文件 input_file。
    *

      标准输入(stdin),缺省情况下为终端(也就是用户的键盘)。例如:

      $ command

      这里,用户输入 Control-D(文件结束指示符)前输入的所有内容都成为 command 的输入。

但是,使用 shell 操作符“<”(重定向标准输入),也可将标准输入重定向为来自文件,如下所示:

    $ command < input_file

这里,command 会读它的标准输入,不过 shell/内核已将其重定向,所以标准输入来自 input_file。

使用 shell 操作符“|”(pipe)也可以使标准输入来自另一个程序的标准输出,如下所示:

    $ other_command | command

这里,other_command 的标准输出(stdout)被 shell/内核透明地传递至 command 的标准输入。

准则 2. 输出
输出应该被写至标准输出,缺省情况下标准输出同样也是终端(也就是用户的屏幕):

    $ command

在这个例子中,command 的输出出现在屏幕上。

同样,使用 shell 操作符“>”(重定向标准输出)可以将标准输出重定向至文件。

    $ command > output_file

这里,command 仍然写至它的标准输出,不过 shell/内核将其重定向,所以输出写至 output_file。

或者,还是使用“|”操作符,command 的输出可以成为另一个程序的标准输入,如下所示:

    $ command | other_command

在这个例子中,shell/内核安排 command 的输出成为 other_command 的输入。

准则 3. 错误输出
错误输出应该被写至标准错误(stderr),缺省情况下标准错误同样也是终端(也就是用户的屏幕):

    $ command

这里,运行 command 时出现的任何错误消息都将被写至屏幕。

但是使用标准错误重定向,也可以将错误重定向至文件。例如:

    $ command 2>error_file

在这个例子中,command 的正常输出在屏幕显示,而任何错误消息都被写至 error_file。

可以将标准输出和标准错误都重定向至不同的文件,如下所示:

    $ command >output_file 2>error_file

这里,将标准输出写至 output_file,而将所有写至标准错误的内容都写至 error_file。

如果已将标准输出重定向至某一位置,也可以将标准错误重定向至同一位置。例如:

    $ command 2>&1

在这个例子中,符号“2>&1”表示“将标准错误发送至标准输出被重定向的任何位置”,因此错误和正常的消息都将在屏幕上显示。当然,这是多余的,因为下面简单的调用

    $ command

将做同样的事。在标准输出已被重定向至其它源,而您希望在同一命令行上将标准错误也写至同一目的地时,该特性就非常有用。例如:

    $ command >output_file 2>&1

在这个例子中,已首先将标准输出重定向至 output_file;因此“2>&1”将使标准错误也被重定向至 output_file。

准则 4. 执行
程序应该有可能既独立运行,也可以作为管道的一部分运行,如上面的示例所示。该特性可以重新叙述如下:不管程序的输入源(文件、管道或终端)和输出目的地是什么,程序都应该以同样的方式工作。这使得在如何使用它方面有最大的灵活性。

准则 5. 命令行参数
如果程序可以根据其输入或用户的首选参数有不同的行为,则应将它编写为接受名为选项的命令行参数,这些参数允许用户指定什么行为将用于这个调用。

作为选项的命令行参数由前缀“-”(连字符)标识。另一类参数是那些不是选项的参数,也就是说,它们并不真正更改程序的行为,而更象是数据名称。通常,这类参数代表程序要处理的文件名,但也并非一定如此;参数也可以代表其它东西,如打印目的地或作业标识(有关的示例,请参阅“man cancel”)。

可能代表文件名或其它任何东西的非选项参数(那些没有连字符作为前缀的)如果出现的话,应该在命令的最后出现。

通常,如果指定了文件名参数,则程序把它作为输入。否则程序从标准输入进行读取。

所有选项都应以“-”(连字符)开头。选项可以附加参数。

Linux 实用程序语法图看起来如下:

    $ command mandatory_opts [ optional_opts ] [ other_args ]

其中:

    * command 是命令本身的名称。
    * mandatory_opts 是为使命令正常工作必须出现的选项列表。
    * optional_opts 是可指定也可不指定的选项列表,这由用户来选择;但是,其中一些参数可能是互斥的,如同 selpg 的“-f”和“-l”选项的情况(详情见下文)。
    * other_args 是命令要处理的其它参数的列表;这可以是任何东西,而不仅仅是文件名。

在以上定义中,术语“选项列表”是指由空格、跳格或二者的结合所分隔的一系列选项。

以上在方括号中显示的语法部分可以省去(在此情况下,必须将括号也省去)。

各个选项看起来可能与下面相似:

    -f (单个选项)
    -s20 (带附加参数的选项)
    -e30 (带附加参数的选项)
    -l66 (带附加参数的选项)

有些实用程序对带参数的选项采取略微不同的格式,其中参数与选项由空格分隔 — 例如,“-s 20” — 但我没有选择这么做,因为它会使编码复杂化;这样做的唯一好处是使命令易读一些。

以上是 selpg 支持的实际选项。

selpg 程序逻辑
如前面所说的那样,selpg 是从文本输入选择页范围的实用程序。该输入可以来自作为最后一个命令行参数指定的文件,在没有给出文件名参数时也可以来自标准输入。

selpg 首先处理所有的命令行参数。在扫描了所有的选项参数(也就是那些以连字符为前缀的参数)后,如果 selpg 发现还有一个参数,则它会接受该参数为输入文件的名称并尝试打开它以进行读取。如果没有其它参数,则 selpg 假定输入来自标准输入。

参数处理

“-sNumber”和“-eNumber”强制选项:
selpg 要求用户用两个命令行参数“-sNumber”(例如,“-s10”表示从第 10 页开始)和“-eNumber”(例如,“-e20”表示在第 20 页结束)指定要抽取的页面范围的起始页和结束页。selpg 对所给的页号进行合理性检查;换句话说,它会检查两个数字是否为有效的正整数以及结束页是否不小于起始页。这两个选项,“-sNumber”和“- eNumber”是强制性的,而且必须是命令行上在命令名 selpg 之后的头两个参数:

    $ selpg -s10 -e20 ...

(... 是命令的余下部分,下面对它们做了描述)。

“-lNumber”和“-f”可选选项:
selpg 可以处理两种输入文本:

类型 1:该类文本的页行数固定。这是缺省类型,因此不必给出选项进行说明。也就是说,如果既没有给出“-lNumber”也没有给出“-f”选项,则 selpg 会理解为页有固定的长度(每页 72 行)。

选择 72 作为缺省值是因为在行打印机上这是很常见的页长度。这样做的意图是将最常见的命令用法作为缺省值,这样用户就不必输入多余的选项。该缺省值可以用“-lNumber”选项覆盖,如下所示:

    $ selpg -s10 -e20 -l66 ...

这表明页有固定长度,每页为 66 行。

类型 2:该类型文本的页由 ASCII 换页字符(十进制数值为 12,在 C 中用“\f”表示)定界。该格式与“每页行数固定”格式相比的好处在于,当每页的行数有很大不同而且文件有很多页时,该格式可以节省磁盘空间。在含有文本的行后面,类型 2 的页只需要一个字符 — 换页 — 就可以表示该页的结束。打印机会识别换页符并自动根据在新的页开始新行所需的行数移动打印头。

将这一点与类型 1 比较:在类型 1 中,文件必须包含 PAGELEN - CURRENTPAGELEN 个新的行以将文本移至下一页,在这里 PAGELEN 是固定的页大小而 CURRENTPAGELEN 是当前页上实际文本行的数目。在此情况下,为了使打印头移至下一页的页首,打印机实际上必须打印许多新行。这在磁盘空间利用和打印机速度方面效率都很低(尽管实际的区别可能不太大)。

类型 2 格式由“-f”选项表示,如下所示:

    $ selpg -s10 -e20 -f ...

该命令告诉 selpg 在输入中寻找换页符,并将其作为页定界符处理。

注:“-lNumber”和“-f”选项是互斥的。

“-dDestination”可选选项:
selpg 还允许用户使用“-dDestination”选项将选定的页直接发送至打印机。这里,“Destination”应该是 lp 命令“-d”选项(请参阅“man lp”)可接受的打印目的地名称。该目的地应该存在 — selpg 不检查这一点。在运行了带“-d”选项的 selpg 命令后,若要验证该选项是否已生效,请运行命令“lpstat -t”。该命令应该显示添加到“Destination”打印队列的一项打印作业。如果当前有打印机连接至该目的地并且是启用的,则打印机应打印该输出。这一特性是用 popen() 系统调用实现的,该系统调用允许一个进程打开到另一个进程的管道,将管道用于输出或输入。在下面的示例中,我们打开到命令

    $ lp -dDestination

的管道以便输出,并写至该管道而不是标准输出:

    selpg -s10 -e20 -dlp1

该命令将选定的页作为打印作业发送至 lp1 打印目的地。您应该可以看到类似“request id is lp1-6”的消息。该消息来自 lp 命令;它显示打印作业标识。如果在运行 selpg 命令之后立即运行命令 lpstat -t | grep lp1,您应该看见 lp1 队列中的作业。如果在运行 lpstat 命令前耽搁了一些时间,那么您可能看不到该作业,因为它一旦被打印就从队列中消失了。

输入处理
一旦处理了所有的命令行参数,就使用这些指定的选项以及输入、输出源和目标来开始输入的实际处理。

selpg 通过以下方法记住当前页号:如果输入是每页行数固定的,则 selpg 统计新行数,直到达到页长度后增加页计数器。如果输入是换页定界的,则 selpg 改为统计换页符。这两种情况下,只要页计数器的值在起始页和结束页之间这一条件保持为真,selpg 就会输出文本(逐行或逐字)。当那个条件为假(也就是说,页计数器的值小于起始页或大于结束页)时,则 selpg 不再写任何输出。瞧!您得到了想输出的那些页。

代码注释
是详细研究源代码的时候了。此刻,如果您还没有 selpg.c,则可能需要下载它(请参阅参考资料)。我将向您介绍代码,一次介绍一段。

以注释“==== includes =====”开始的行
这些行指定所需的头文件。stdio.h 是 C 标准输入输出库的头文件。文件的打开、关闭、读和写函数(fopen()、fclose()、fgets()、getc() 等等)、printf() 函数系列和 setvbuf() 函数都需要它。

stdlib.h 是 atoi() 函数(将 ASCII 码转换为整数)所需要的,该函数将字符串转换为整数。

string.h 是 strcpy() 和 strcmp() 这样的字符串函数所需要的。

unistd.h 是 access() 函数所需要的。

limits.h 是定义 INT_MAX 所需要的,INT_MAX 指定您的编译器/操作系统/硬件平台上 int 的最大可取值。使用它而不是硬编码的值可以提高代码的可移植性。

assert.h 用于 assert() 调试宏。

errno.h 是声明 errno 所需要的,errno 是全局系统调用错误号变量(下面会有更多介绍)。

以注释“==== types =====”开始的行
这里只定义了一个类型,即:selpg_args 结构。指向该类型变量的指针被传递到 process_args() 函数,返回该指针时,它包含从参数处理过程获得的值。typedef 用来给类型一个短名 sp_args。

我们还定义了宏 INBUFSIZ,它是在读取输入时用作缓冲区的字符数组的大小。这是为了有更好的性能。

以注释“==== globals ======”开始的行
Progname 是保存名称(命令就是通过该名称被调用)的全局 char* 变量,作为在错误消息中显示之用。用这种方法,即使您将 selpg 命令重命名为别的名称,新的名称也将在消息中显示;您不必修改该代码。

以注释“==== prototypes ===”开始的行
按照 ANSI C 约定,这些行声明了代码中所有函数的函数原型。这是现在的常规作法,它可以帮助编译器检测函数定义/声明和函数使用之间的类型不匹配。

main() 函数
这很简单:它声明所需变量,然后用变量 ac、av 和 &sa 调用函数 process_args()。ac 代表“参数计数”,它包含命令行参数的数目,包括命令名本身;av 代表“参数向量”,它是字符指针的指针;它以字符串数组的形式包含所有的命令行参数。&sa 是指向类型为 sp_args 的结构的指针。process_args() 返回后,已解析的参数值在 sa 结构中;我们将该变量传递至函数 process_input(),该函数选择所需的页并将其写至指定的目的地。

如果代码中任何一处出现了使处理不能继续进行之类的错误,那么我们会检索系统错误消息(如果有的话),然后将它与我们自己的消息一起显示。随后我们用错误码调用 exit() 函数;对于本实用程序,我们已经选择了对每个不同错误条件返回不同数字。不过这不是必须的;有些实用程序对任何错误条件都简单地返回 1,若成功则返回 0,而其它实用程序则将错误分类,并根据类别返回较小范围代码(比如 1、2 或 3)中的一个。规定的唯一约定是返回 0 应该表示成功而返回非零值表示失败。有关系统调用中出现错误的更多信息,请参阅系统调用错误一节。

既然所有错误都会导致退出,那么如果我们从 process_input() 函数返回,则意味着没有错误,因此我们从 main() 返回 0。

系统调用错误
当调用 Linux 系统调用(或 C 库函数)时,与调用您自己的函数时一样,有可能出现错误。按照对 C 函数的约定,在成功的情况下,函数的返回值通常要给出关于结果的一些信息,而在失败的情况下,返回值要给出关于失败原因的信息。对此有一个普遍遵守的约定。

在成功的函数或系统调用中,返回值的意义是由例程而定的;例如,read() 返回实际所读的字节数(与所要求的字节数相对,后者是 read() 的参数之一),而 fopen() 返回指向“struct FILE”的指针。

这里,我们主要讨论不成功调用的返回值。

大多数调用返回 -1、NULL 或 EOF 来表示错误。(正如我们在上面看到的,fgets() 返回 NULL 而 getc() 返回 EOF。)它们还设置名为 errno 的全局整数变量。这在 errno.h 中被声明为“extern int errno;”。该整数是对系统错误消息表的索引,这个表名为 sys_errlist(字符串数组),其最高元素的索引为 sys_nerr - 1。sys_errlist 和 sys_nerr 也都在 errno.h 中声明。

当系统调用中出现错误时,您至少可以用以下两种方式的一种来访问和显示与该错误对应的消息:通过使用 perror() 或 strerror() 函数。(这两种方式在 selpg 中都有演示。)

perror(请参阅“man perror”)接受单个字符串参数。它先将您的字符串参数写至标准错误(提供它用于定制消息),接下来写入一个冒号和一个空格,然后是系统错误消息,后面是新的一行。

strerror() 函数(请参阅“man strerror”)略有不同。它允许您将系统错误消息作为字符串进行访问。它需要整数参数 errno,该参数应该是全局 errno 变量的实际数值。您可以通过包括头文件 errno.h 来用该变量的实际名称访问它,所有使用系统调用的 C 程序(或调用系统调用的库函数)都应该包括头文件 errno.h。strerror() 将系统错误消息作为字符串返回。使用这些函数时要遵守一些条件。有关的详细信息,请参阅它们的手册页。

selpg 广泛利用这些函数以给予用户正确消息。我还提供了名为 showsyserr 的小实用程序(请参阅参考资料),它允许您查看系统错误消息。它能以两种方式之一运行,带单个命令行参数,该参数应该是 errno 值,或不带任何参数。如果向该程序传递一个参数,则它将只显示对应于该参数的消息。如果不给出参数,则它会显示所有定义的错误消息。

通过不带参数地运行该实用程序,您可以将它用作了解不同错误消息是什么的工具。在有些情况下,它还可作为有用的调试工具使用:有些 Linux(至少是 UNIX)实用程序(甚至是守护程序)不显示错误消息,而只显示 errno 值。在这种情况下,只要使用那个 errno 值运行 showsyserr,它就会向您显示相应的错误消息。我已经在解决 UNIX 问题时使用了这个工具,在那些问题中控制台上会有持续的消息流,并且是“Panic - error 2”之类的消息。该消息源明显是编写得不好的模块 — 它本该给出更有意义的消息 — 但我能够通过以值 2 为参数运行 showsyserr 来调试它。结果表明:由于一个无赖进程不断写磁盘,文件系统已经满了。

一旦我知道是磁盘空间问题,我就能够识别这个进程并用其它命令(如 ps 和 kill)将它杀掉。

process_args() 函数
因为这是打算用于通用目的而不是只被一小群人使用的实用程序,所以我们进行大量的错误检查以使它尽可能的健壮。

以注释“==== check the command-line arguments ===”开始的行
我们检查是否传递了最小数目(三)的命令行参数。这三个参数是:

    * 命令名本身
    * -sNumber 选项
    * -eNumber 选项

如果少于三个参数,则打印消息并退出。请注意对 usage() 函数的调用;这让用户知道调用实用程序的正确方法。这是编写通用实用程序时应该遵守的又一个约定。

以注释“handle 1st arg”开始的行
注释“!!! PBO”用来指出可能的缓冲区溢出(Possible Buffer Overflow),不论是在 Linux 或任何其它平台上,这是在 C 程序中应该经常检查的条件。当复制到 s1 的字符串长于 s1 的分配大小(即 BUFSIZE,一个在 stdio.h 中定义的常数)时,就会发生缓冲区溢出情况。若非意外或故意,发生这种情况的可能性很小,但值得一查。缓冲区溢出是许多安全性问题的起因。复制的正确方法是用 strncpy() 函数代替 strcpy(),并将要复制的最大字节数定为 BUFSIZ - 1。然后,我们可以在复制到 s1 的最后一个字符后添加 NULL 字符。

在所有出现 PBO 注释的地方,都必须遵守同一过程。

这里,我们将由 argno(初始时被设为 1 以指向命令本身后的第一个实际参数)索引的当前参数复制到字符串变量 s1。然后我们检查指示起始页选项的 s1 的头两个字符是否为“-s”。如果不是,则进行出错退出。

如果没有问题,则将 s1 中的余下字符转换成整数。用 INT_MAX 检查它是否为这个平台的有效整数。(请记住,必须用 INT_MAX 而不是硬编码的数值,因为 C 中整数的大小随平台不同而不同 — 甚至同一平台的不同编译器也是如此)。

如果没有问题,则在 psa 所指结构的字段中保存起始页。

以注释“handle 2nd arg”开始的行
进行的检查和操作与第一个参数基本相同。唯一的额外工作是检查所给的结束页不小于起始页。

以注释“now handle optional args”开始的行
这里,如果还有余下的参数,我们将处理它们。请注意这里使用的编码术语,因为这是 Linux 中使用的典型风格。我们所做的是,用一个循环遍历参数列表检查每个参数来看它是否为选项。如果是的话,则 switch 语句会找出它是哪个选项。如果是有效选项,则在结构中设置标志。我们还设置变量以记住与该选项相关联的所有数据的值,例如,有“-l”选项时记下页长,有 “-f”时则记下打印目的地。如果选项无效,则给出错误消息并退出。

以注释“there is one more arg”开始的行
处理完所有以“-”为前缀的参数后,应检查是否还有剩余的参数。对于 selpg,最多有一个这样的参数,它被用作输入的文件名。我们调用 access() 函数两次来检查该参数是否存在以及是否可读。这确实有些多余,因为第二次调用也检查存在性(文件不存在就不可读),但我要说明的是人们也可以只检查存在性。

以注释“check some post-conditions”开始的行
这里,我们利用 C“断言”。这是 C 语言而不是 Linux 的特性,我演示它因为它是产生可靠代码的有用工具。

有关这一点的详细信息,请参阅按合同设计一节。

process_input() 函数
我们首先声明一些变量。

以注释“set the input source”开始的行
如果命令行上没有给出文件名参数,我们就使用标准输入。否则,我们打开指定的文件进行读取。如果文件不能打开,则进行出错退出。若能打开,则调用 setvbuf() 函数来设置大小为 INBUFSIZ 的缓冲区(以前用 #defined 定义为 16KB)以从 fin 进行读取。这是为了能更快地读取输入。选择 16KB 作为大小是因为:在用从 1KB 到 64KB 的不同大小进行实验时,我发现在缓冲区大小从 1KB 增加到 16KB 的过程中,性能有某种程度的提高,但 16KB 以后就没有明显改善。您做了各种不同的实验,您想要通过更改缓冲区大小进行实验以找到适合您 Linux 系统的最佳大小。作为练习,您可以修改代码以使 selpg 再接受一个参数,比如说“-bNumber” — 以表示缓冲区大小。这允许您在实验时不必每次都更改 #define 宏的值并且重新编译。这可以成为又一个可选选项,并且当没有指定该选项时,将使用您更喜欢的值作为缺省的缓冲区大小,就象我对“-l”数字选项所做的那样。

按合同设计
断言在测试和调试期间非常有用。断言宏的作用在于检查它的布尔参数是否为真(也就是非零);若为真,则不做任何工作;若为假,则程序以说明断言失败的消息终止。导致断言失败的布尔条件也会被显示。可以通过定义 NDEBUG 宏关闭断言:既可以通过编辑源代码添加一行“#define NDEBUG”(无需给出 #define 的值),也可以通过在 cc 或 gcc 编译器命令行上包含命令行开关“-DNDEBUG”在编译过程中实时完成,如下所示:

    $ cc -DNDEBUG -o selpg selpg.c

断言可用于代码中的任何地方,不过函数的入口和出口是使用它们的好地方,您可以在那里检查前置条件和后置条件,因为您知道如果代码正确这些条件应该存在。那就是说,如果函数有确定的有效值集合或范围可作为参数,可以将检验参数是否有效的断言放在该函数代码的第一行。例如,如果计算平方根的函数只能处理非负数,您可以设置一个输入参数 >= 0 的断言。这意味着调用函数的职责是确保只传递非负参数。如果给出一个负数,就会触发断言并以条件“输入参数 >= 0”失败的消息终止该程序。类似地,在函数的末尾,如果您知道应该有某些后置条件,那就编写对应于各项条件的断言。如果由于该函数中代码的错误,您期望退出时为真的条件并不为真,那么这些断言能够确保您知道这一点。

使用断言的好方法是构建两个版本的最终应用程序 — 一个版本禁用断言(称其为发行版),而另一个版本启用断言(称其为调试版)。最初,将发行版给用户。如果用户在其它位置,则将调试版也给用户,但要放在单独的目录中。当在应用程序中发现问题时,告诉他们临时用调试版代替发行版并再次模拟该问题。这一次,断言所引起的消息将可能有助于查明错误来源。

可通过 make 实用程序将调试版本和发行版本的制作自动化。有关这一点的简单示例,请参阅随附的 makefile(在本文后面的参考资料中)。make 是将从一个或多个文件构建应用程序的过程自动化的实用程序。它可以处理相关性,例如有这样的规则:如果源文件被修改,那么必须通过重新编译该源文件来重新生成其对应的目标文件。它还可以做许多其它的事情。

以注释“set the output destination”开始的行
如果没有给出“-dDestination”选项,则写至标准输出。否则,我们尝试用命令字符串“lp -dDestination”打开到 lp 命令的管道。这是用系统调用 popen() 完成的,它打开到另一个进程的管道以用于读或写。在此情况下,我们以写方式打开到 lp 的管道。这意味着所有来自 selpg 进程的标准输出将进入 lp 进程的标准输入。如果 popen() 失败,则进行出错退出。

以注释“begin one of two main loops”开始的行
我们根据输入的类型执行两个循环中的一个。

若页类型是每页行数固定的,则使用 fgets() 库函数逐行读取输入。(对于错误或 EOF [文件结束],它都返回 NULL,这是在 stdio.h 中定义的;循环结束后,我们会检查究竟是哪种情况。)如果不是 NULL,则增加行计数器。然后检查行的总数是否超过页长。若是,则将页计数器加 1 并将行计数器复位为 0。然后检查页计数器是否在请求的页范围之内,若是,则写当前行;否则不写。重复该循环直至 fgets() 返回 NULL。注:fgets() 最多从 fin 读取 BUFSIZ - 1 个字符并将它们存储在字符串行。它附加一个 NULL 字符以使字符串行包含一个正确的以空值终止的 C 字符串。

用于换页定界的页的逻辑大体上相同,只是稍稍简单一些,因为不需要行计数器:使用 getc() 库函数逐个读取字符。该函数对于错误或文件结束都返回 EOF。检查每个读取的字符是否为换页符;若是,则增加页计数器。与每页行数固定的情况一样,仅当页在指定范围之内才写输出。重复该循环直至 getc() 返回 EOF。

您或许想知道,当我们逐行或逐字符地读时,对 setvbuf 的调用是如何起作用的。好吧,下面就此进行介绍:因为我们在使用 stdio 库,库中更低级别的程序从磁盘一次一块地读取数据 — 这里块的大小对应于我们已经指定的缓冲区大小 — 并将它置入我们的缓冲区 inbuf。然后,每当我们调用 fgets() 或 getc(),数据被传递到我们的程序变量(line 或 c)中。因为以更大的块从磁盘读取数据效率更高,而将数据从内存中的一个位置(inbuf)传递到另一个位置(我们的程序变量)是相当快的,因此输入输出更快 — 至少理论上如此。实际上,缓冲可能发生在其它不同的级别,例如硬盘本身、控制器或内核磁盘驱动设备驱动程序,所以我们的工作可能对性能影响不大。使用 setvbuf() 甚至可能使代码运行得更慢。我们想说的是:性能调整是一件麻烦而复杂的事,对任何更改都要进行测试以了解它们是否有影响。

以注释“end main loop”开始的行
我们检查起始页号和结束页号是否大于总页数,若是,则给出适当的消息。然后检查输入流上是否发生错误,若是,则给出消息。最后,关闭输入流,清空输出流,如果输出流是管道,则用 pclose() 函数关闭它。这样做的结果是向 lp 进程发送 EOF,lp 随之终止。

如果输出流是标准输出,并且没有被重定向至文件或管道,则选定的页将出现在屏幕上。

如果输出流是标准输出,并且被重定向至一个文件,则选定的页将在该文件中。

如果输出流是标准输出,并且由管道输送至另一个进程(在命令行级别),则选定的页将成为该进程的输入。例如,您可以将输出由管道输送至分页程序“less”,这样您可以一次一页地查看输出并来回滚动。

如果输出流由管道输送至 lp(从程序内部使用“-dDestination”选项和 popen()完成),这些选定的页将在给定的打印目的地(假设已启用)打印出来。

使用 selpg
为了演示最终用户可以如何应用我们所介绍的一些原则,下面给出了可使用的 selpg 命令字符串示例:

   1.

      $ selpg -s1 -e1 input_file

      该命令将把“input_file”的第 1 页写至标准输出(也就是屏幕),因为这里没有重定向或管道。
   2.

      $ selpg -s1 -e1 < input_file

      该命令与示例 1 所做的工作相同,但在本例中,selpg 读取标准输入,而标准输入已被 shell/内核重定向为来自“input_file”而不是显式命名的文件名参数。输入的第 1 页被写至屏幕。
   3.

      $ other_command | selpg -s10 -e20

      “other_command”的标准输出被 shell/内核重定向至 selpg 的标准输入。将第 10 页到第 20 页写至 selpg 的标准输出(屏幕)。
   4.

      $ selpg -s10 -e20 input_file >output_file

      selpg 将第 10 页到第 20 页写至标准输出;标准输出被 shell/内核重定向至“output_file”。
   5.

      $ selpg -s10 -e20 input_file 2>error_file

      selpg 将第 10 页到第 20 页写至标准输出(屏幕);所有的错误消息被 shell/内核重定向至“error_file”。请注意:在“2”和“>”之间不能有空格;这是 shell 语法的一部分(请参阅“man bash”或“man sh”)。
   6.

      $ selpg -s10 -e20 input_file >output_file 2>error_file

      selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至“output_file”;selpg 写至标准错误的所有内容都被重定向至“error_file”。当“input_file”很大时可使用这种调用;您不会想坐在那里等着 selpg 完成工作,并且您希望对输出和错误都进行保存。
   7.

      $ selpg -s10 -e20 input_file >output_file 2>/dev/null

      selpg 将第 10 页到第 20 页写至标准输出,标准输出被重定向至“output_file”;selpg 写至标准错误的所有内容都被重定向至 /dev/null(空设备),这意味着错误消息被丢弃了。设备文件 /dev/null 废弃所有写至它的输出,当从该设备文件读取时,会立即返回 EOF。
   8.

      $ selpg -s10 -e20 input_file >/dev/null

      selpg 将第 10 页到第 20 页写至标准输出,标准输出被丢弃;错误消息在屏幕出现。这可作为测试 selpg 的用途,此时您也许只想(对一些测试情况)检查错误消息,而不想看到正常输出。
   9.

      $ selpg -s10 -e20 input_file | other_command

      selpg 的标准输出透明地被 shell/内核重定向,成为“other_command”的标准输入,第 10 页到第 20 页被写至该标准输入。“other_command”的示例可以是 lp,它使输出在系统缺省打印机上打印。“other_command”的示例也可以 wc,它会显示选定范围的页中包含的行数、字数和字符数。“other_command”可以是任何其它能从其标准输入读取的命令。错误消息仍在屏幕显示。
  10.

      $ selpg -s10 -e20 input_file 2>error_file | other_command

      与上面的示例 9 相似,只有一点不同:错误消息被写至“error_file”。

在以上涉及标准输出或标准错误重定向的任一示例中,用“>>”替代“>”将把输出或错误数据附加在目标文件后面,而不是覆盖目标文件(当目标文件存在时)或创建目标文件(当目标文件不存在时)。

以下所有的示例也都可以(有一个例外)结合上面显示的重定向或管道命令。我没有将这些特性添加到下面的示例,因为我认为它们在上面示例中的出现次数已经足够多了。例外情况是您不能在任何包含“-dDestination”选项的 selpg 调用中使用输出重定向或管道命令。实际上,您仍然可以对标准错误使用重定向或管道命令,但不能对标准输出使用,因为没有任何标准输出 — 正在内部使用 popen() 函数由管道将它输送至 lp 进程。

   1.

      $ selpg -s10 -e20 -l66 input_file

      该命令将页长设置为 66 行,这样 selpg 就可以把输入当作被定界为该长度的页那样处理。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。
   2.

      $ selpg -s10 -e20 -f input_file

      假定页由换页符定界。第 10 页到第 20 页被写至 selpg 的标准输出(屏幕)。
   3.

      $ selpg -s10 -e20 -dlp1 input_file

      第 10 页到第 20 页由管道输送至命令“lp -dlp1”,该命令将使输出在打印机 lp1 上打印。

      最后一个示例将演示 Linux shell 的另一特性:
   4.

      $ selpg -s10 -e20 input_file > output_file 2>error_file &

      该命令利用了 Linux 的一个强大特性,即:在“后台”运行进程的能力。在这个例子中发生的情况是:“进程标识”(pid)如 1234 将被显示,然后 shell 提示符几乎立刻会出现,使得您能向 shell 输入更多命令。同时,selpg 进程在后台运行,并且标准输出和标准错误都被重定向至文件。这样做的好处是您可以在 selpg 运行时继续做其它工作。

      您可以通过运行命令 ps(代表“进程状态”)检查它是否仍在运行或已经完成。该命令会显示数行信息,每行代表一个从该 shell 会话启动的进程(包括 shell 本身)。如果 selpg 仍在运行,您也将看到表示它的一项信息。您也可以用命令“kill -l5 1234”杀死正在运行的 selpg 进程。如果这不起作用,可以尝试用“kill -9 1234”。警告:在对任何重要进程使用该命令前,请阅读“man kill”。

系统调用与库函数比较 — 以及一点历史知识
Linux 系统调用与 C 库函数有何不同?下面是一些介绍:

首先,就其在 C 中的最初字面意义而言,两者都是“函数”,即:一段单独定义的代码,只定义该代码一次,并可以通过名称(在其作用域内的代码中任何地方以任何次数)调用它,可以向它传递参数,而且它可以返回数值。

有趣的历史注解:Linux 操作系统的大部分都是用 C 编写,而且 C 是 Linux 的“母语”,对于操作系统层次编程(如编写内核与设备驱动程序)以及关于编写利用系统调用的应用程序(如 RDBMS、网络工具或定制的商业应用程序)的 Linux 系统编程都是如此。

这就是为什么 Linux 是非常强大的操作系统和开发环境的原因之一。作为开发人员,您可以免费并且以透明的方式获得大量操作系统的功能 — 内存管理、进程管理、文件和目录管理、设备管理、联网和线程等等。所有您要做的就是:包括那些声明您希望使用的调用的头文件并(在某些情况下)将您的代码与实现调用的库链接。

这部分上是由于历史偶然,因为 UNIX 和 C 最初基本上由 Bell Labs 同一组人员开发。事实上,C 是作为系统编程的高级语言开发的,尤其是用于编写操作系统。那以前的大多数操作系统都是用汇编语言编写,而 UNIX 自(大约)第二版以来就大部分用 C 编写,只有极少数与硬件相关的代码必须用目标平台的汇编语言编写。

用高级语言编写大部分操作系统的这一能力就是为什么 UNIX/Linux 传播如此之广并且如此成功的原因之一。所有其它情况都基本相同,研究表明:若不考虑编码所用语言的级别(是汇编语言还是高级语言),程序员的生产率(以每天的代码行计)基本上相同。平均起来,一行高级语言代码要转换成多行汇编语言代码。因此,用高级语言编写相同数量的功能所花的时间少于用汇编语言编写所花时间,更不用说用较高级别的语言编写的代码更易于调试和维护了。

其次,Linux 系统调用基本上是一个函数,它属于操作系统代码的一部分。它本身可以在内核中,也可以在静态或动态链接到内核的设备驱动例程中。因此,当从用户代码调用系统调用时(假定从程序中函数 foo() 调用),实际上是在调用内核中的例程。这就引起所谓的从“用户空间”到“内核空间”的上下文切换,不过这仅供了解之用。就调用方式而言,系统调用与库函数之间没有区别。C 库函数本身不是操作系统代码的一部分。它基本上运行在用户空间中,而它的部分实现可能运行在内核空间中。请参阅下一点。

第三,C 库函数可能有也可能没有相关的底层系统调用。有些函数需要使用系统调用来完成其工作,其它函数则不需要。

没有相关系统调用的库函数的一个示例是 string.h 中声明的 strlen() 函数。不需要操作系统功能;该函数的实现只是遍历字符串,并对每个字符增加计数器,直到到达终止该字符串的终止 NULL 字符。请记住,在 C 语言中,字符串只是一个带有终止 NULL 字符(ASCII 0)的字符数组。该函数然后返回计数器的值。这是完全与操作系统无关的代码,并且在所有 C 平台(Linux 或其它)上都以同样的方式工作。

有相关系统调用的库函数的一个示例是 stdio.h 中声明的 fopen() 函数。该函数是打开文件的高层次方式;但是,因为在 Linux 中打开文件的唯一方法是使用 open() 系统调用,所以 fopen() 在其实现中利用 open()。类似地,printf() 和 fprintf() 这样的函数调用 write() 系统调用完成其工作。

结束语
本文应该给了您足够的预备知识来开始自己编写 Linux 命令行实用程序,但如果您想要进一步学习更多知识的话,请参阅以下参考资料中的建议。