FreeBSD 中文社区 2025 第二季度问卷调查
FreeBSD 中文社区(CFC)
VitePress 镜像站QQ 群 787969044视频教程Ⅰ视频教程Ⅱ
  • FreeBSD 从入门到追忆
  • 中文期刊
  • 状态报告
  • 发行说明
  • 手册
  • 网络文章集锦
  • 笔记本支持报告
  • Port 开发者手册
  • 架构手册
  • 开发者手册
  • 中文 man 手册
  • 文章与书籍
  • UNIX 四分之一世纪
  • Unix 痛恨者手册
  • Unix 痛恨者手册中文版
  • 前言
  • 序言
  • 事情还没到最糟,接下来只会更糟
    • 我们是谁
    • Unix 痛恨者手册往事
    • 贡献者与致谢
    • 排版惯例
    • Unix 痛恨者手册免责声明
  • 反序言(作者: Dennis Ritchie)
  • 第一部分:用户友好?
    • Unix:世界上第一款计算机病毒
    • 欢迎,新用户!就像装满六发子弹的俄罗斯轮盘赌
    • 文档?什么文档?
    • 邮件:别跟我说话,我不是打字机
    • 无聊的网络:我发帖,故我在
    • 终端错乱:靠!又挂了!
    • X-Windows 灾难:教你把 50 MIPS 工作站慢成 4.77MHz IBM PC
  • 第二部分:程序员的系统?
    • csh、管道和 find:强力工具,大力出奇迹
    • 编程:别动,这一点儿也不疼
    • C++ 九十年代的 COBOL
  • 第三部分:系统管理员的噩梦
    • 系统管理:Unix 的隐形成本
    • 安全:哦,抱歉,先生,请继续,我没意识到您是 root 用户
    • 文件系统:它确实会损坏你的文件,但你看看它有多快!
    • NFS:噩梦文件系统(Nightmare File System)
  • 第四部分:等等
    • 尾声:通过 Unix 获得的启示
    • 作者坦言 C 和 Unix 是骗局。新闻稿:立即发布
    • “宁拙勿巧”的崛起(作者:Richard P. Gabrie)
    • 参考文献:正当你以为已经脱离困境时
  • 附录
    • Unix 的流行病学(Philip E. Agre 于 1994)
    • 评论《Unix 痛恨者手册》(Andrew Kuchling 于 1997)
    • 重新审视《Unix 痛恨者手册》(Raymond, Eric S 于 2008)
由 GitBook 提供支持
LogoLogo

FreeBSD 中文社区(CFC) 2025

在本页
  • 奇妙的 Unix 编程环境
  • 在柏拉图的洞穴中编程
  • 使用 yacc 进行解析
  • “Don’t know how to make love. Stop.”(不知道怎么 make love,停止)
  • 头文件
  • 工具和手册页
  • 源码即文档。哦,太棒了
  • “这不可能是个 bug,我的 Makefile 就指望着它呢!”
  • 处理转储文件
  • 错误遗物馆
  • 文件名扩展
  • 健壮性,或者说“所有行都短于 80 个字符”
  • 异常情况
  • 捕捉错误在社会上是不被接受的
  • 如果你修不好,那就重启一下吧
在GitHub上编辑
导出为 PDF
  1. 第二部分:程序员的系统?

编程:别动,这一点儿也不疼

上一页csh、管道和 find:强力工具,大力出奇迹下一页C++ 九十年代的 COBOL

最后更新于4天前

“不要干涉 Unix 的事务,因为它微妙且容易迅速发生核心转储。”

——佚名

如果你是通过在 Unix 机器上编写 C 程序学会编程的,那么你可能会觉得本章内容一开始有些令人费解。遗憾的是,Unix 已经如此彻底地占据了全球计算机科学教育体系,以至于如今很少有学生意识到 Unix 的种种失误其实并非真正合理的设计决策。

例如,一位 Unix 爱好者在为 Unix 和 C 辩护时,针对我们提出的存在比 C 更强大语言且这些语言拥有比 Unix 提供的环境更强大高效的编程环境的观点,发表了如下声明:

1991 年 11 月 9 日

确实,Scheme、Smalltalk 和 Common Lisp 这样的语言附带了强大的编程环境。然而,Unix 内核、shell 和 C 语言合起来,解决了一些那些语言和环境中未能很好处理(或者根本没有涉及)的宏观问题。

这些宏观问题的例子包括内存管理和局部性的某些方面(通过进程创建和退出实现)、持久性(通过将文件作为数据结构实现)、并行性(通过管道、进程和进程间通信实现)、保护和恢复(通过独立地址空间实现),以及可供人类编辑的数据表示形式(文本)。

从实际角度来看,这些在 Unix 环境中处理得相当好。

Thomas Breuel 认为 Unix 提供了一种解决计算机科学复杂问题的方法。幸运的是,其他科学领域在解决人类问题时并没有采用这种方法。

1991 年 11 月 12 日 星期二 11:36:04 -0500

收件人:Unix 痛恨者

主题:随机的 Unix 比喻

通过进程创建和退出来处理内存管理,就像医学通过生与死来治疗疾病,也就是说,这实际上是在忽视问题。

把 Unix 文件(即“字节袋”)作为持久性的唯一接口,就像把你所有东西都扔进衣柜里,然后希望你需要时能找到你想要的东西(不幸的是,我就是这么干的)。

通过管道、进程和进程间通信实现并行?Unix 进程开销太大,这并不是有用的并行来源。这就像雇主为了解决人员短缺问题,让员工多生孩子一样。

是的,Unix 确实擅长处理文本。它也确实擅长处理文本。哦,对了,我说过 Unix 擅长处理文本吗?

——Mark

奇妙的 Unix 编程环境

Unix 狂热分子对 Unix 的“编程环境”大加赞赏。

他们宣称 Unix 拥有一套丰富的工具集,使编程变得更加容易。以下是 Kernighan 和 Mashey 在他们那篇开创性的文章《Unix 编程环境》中对此的说法:

Unix 环境中最富成效的方面之一,是它提供了一套丰富的小型、通用程序——工具——以协助日常的编程任务。 下面展示的程序是其中一些比较有用的。我们将在文章后面的部分用它们来说明其他观点。

  • wc 文件:统计文件中的行数、单词数和字符数。

  • pr 文件:打印文件,带标题和多栏等格式。

  • lpr 文件:将文件发送到行式打印机。

  • grep pattern 文件:打印所有包含指定模式的行。

程序员的工作大部分不过是运行这些程序及其相关工具罢了。比如说:

wc *.c

统计一组 C 源文件的字数;

grep goto *.c

找出所有的 GOTO。

这些算是“最有用的”工具?!?!

没错。这正是这个程序员的大部分工作内容。事实上,今天我花了那么多时间在数我的 C 文件上,根本没时间做别的事。我想我再去数一遍好了。

同一期《IEEE Computer》杂志中还有一篇文章,《The Interlisp Programming Environment》,作者是 Warren Teitelman 和 Larry Masinter。Interlisp 是一个非常复杂的编程环境。早在 1981 年,Interlisp 就拥有一套工具,而这些工具到了 1994 年还只能让 Unix 程序员流口水地幻想一番。

Interlisp 环境的设计者采取了完全不同的思路。他们决定开发大型的、复杂的工具,虽然学习使用这些工具需要很长时间,但他们的设想是:程序员如果投入时间学会这些工具,最终将变得更加高效。这个思路听起来相当合理。

遗憾的是,如今很少有程序员真正体会过如此辉煌的环境。

在柏拉图的洞穴中编程

我感觉计算机语言设计和工具开发的目标是把每个人生产力水平提升到最高,而非最低或中等水平。

——摘自 comp.lang.c++ 论坛的一则帖子

其他行业广泛自动化后,情况并非如此。当人们走进现代自动化的快餐店时,他们期望的是稳定一致,而不是高级美食。大规模提供稳定的平庸,比所有小规模的高效都更有利可图。

——一位匿名公司的技术人员对该网络新闻消息的回复

Unix 不是世界上最好的软件环境——甚至连好的环境都算不上。Unix 的编程工具稀少且难以使用;大多数 PC 调试器都让大部分 Unix 调试器相形见绌;解释器仍然是富人们的玩具;而变更日志和审计记录则取决于被审计者的心情。然而,Unix 却以程序员的梦想环境闻名。也许它让程序员梦想变得高效,而不是真的让他们高效。Unix 程序员就像数学家,这是一种奇特的现象,我们称之为“通过暗示编程”。有一次,我们与一位 Unix 程序员谈论拥有一个可以检查程序、回答诸如“哪些函数调用了函数 foo?”或“哪些函数修改了全局变量 bar?”这样问题的实用工具会有多好,他认同这很有用,但接着说:“你可以写一个这样的程序。”

公平地说,他说“你可以写一个这样的程序”而不是真正去写,是因为 C 语言和 Unix“编程环境”的某些特性相互作用,使得写出这样一个工具极其困难。

你可能觉得我们夸大了,认为这个工具可以通过写几个小工具程序并用管道连接起来轻松实现,但事实并非如此,也做不到。

使用 yacc 进行解析

“Yacc”(呕吐)正是我学会使用 yacc(1) 后的感觉。

——佚名

“YACC”的意思是“Yet Another Compiler Compiler(又一个编译器编译器)”。它接受一个描述语言的上下文无关文法,并为一个通用下推自动机计算出一个状态机。当这个状态机运行时,就会得到该语言的解析器。这套理论是非常成熟的,因为在计算机科学的早期研究中,一个主要的问题就是如何减少编写编译器所需的时间。

不过,这种方案有个小问题:大多数编程语言并不是上下文无关的。因此,yacc 的用户必须在某些状态转换时插入代码片段,以处理上下文无关文法搞不定的情况(例如类型检查通常就是通过这种方式实现的)。如今的大多数 C 编译器都使用 yacc 生成的解析器;GCC 2.1(一款由自由软件基金会编写的优秀编译器)使用的 yacc 文法大约有 1650 行。yacc 输出的实际代码,加上运行 yacc 输出的那个通用下推自动机的代码,要大得多。

有些编程语言更容易解析。比如 Lisp,可以使用递归下降解析器来解析。所谓“递归下降”,在计算机术语中其实就是“简单到可以边喝一升可乐边写出来”。作为一个实验,

我们用 C 写了一款 Lisp 的递归下降解析器,一共大约 250 行代码。如果是用 Lisp 来写这个解析器,甚至都塞不满一页纸。

前文提到的“早年”,大概就是本书几位编辑出生的那个时代。那时的机房里是恐龙横行的年代,真正的程序员还在用前面板上的开关编程。如今,社会学家和历史学家已无法解释,为何那个时代看似理性的程序员竟然设计、实现并推广了那么难解析的语言。也许他们需要有一些公开的研究课题,而编写这些难以解析语言的解析器,看上去是个不错的研究方向。

你不禁会想,他们当年到底吸了些什么。

能够解析 C 程序、并找出哪些函数调用了哪些函数、全局变量在何处被读取和修改的程序,本质上就是一款 C 编译器前端。而 C 编译器前端是一种非常复杂的结构物;这既是因为 C 语言本身的复杂性,也因为 yacc 之类工具的使用难度带来的额外负担。难怪没人愿意去写这个程序。

顽固的 Unix 崇拜者会说,你根本不需要这个程序,因为 grep 完全能胜任。更棒的是,你还能把 grep 放进 shell 的管道中使用。好吧,前几天我们正好在 BSD 的内核代码里查找所有对 min 函数的调用。下面就是我们得到的一个例子:

% grep min netinet/ip_icmp.c
icmplen = oiplen + min(8, oip->ip_len);
* that not corrupted and of at least minimum length.
* If the incoming packet was addressed directly to us,
* to the incoming interface.
* Retrieve any source routing from the incoming packet;
%

没错,grep 确实找出了所有对 min 的调用 —— 还有一些它不该找出来的。

“Don’t know how to make love. Stop.”(不知道怎么 make love,停止)

理想的编程工具应当在处理常见任务时快速且易于使用,同时又足够强大,能够胜任超出其最初设计用途的任务。不幸的是,许多 Unix 工具在追求通用性的狂热中,忘记了“快速”和“易用”这两个基本要素。

make 就是这样一款工具。从抽象角度看,make 的输入依赖图的描述。依赖图中的每个节点都包含一组命令,这些命令会在该节点相对于它所依赖的节点来说“过时”时被执行。每个节点对应一个文件,文件的时间戳决定了它们彼此之间是否过时。下面是一个小型的依赖图或 Makefile:

program: source1.o source2.o
cc -o program source1.o source2.o
source1.o: source1.c
cc -c source1.c
source2.o: source2.c
cc -c source2.c

在这张图中,节点包括:program、source1.o、source2.o、source1.c 和 source2.c。节点 program 依赖于 source1.o 和 source2.o 这两个节点。以下是这个 Makefile 的图形表示方式:

source1.c   source2.c
     |          |
     v          v
source1.o   source2.o
     \         /
      v       v
        program

当 source1.o 或 source2.o 的时间比 program 新时,make 会通过执行命令 cc -o program source1.o source2.o 来重新生成 program。当然,如果 source1.c 被修改了,那么 source1.o 和 program 都将变得过时,因此需要重新编译和重新链接。

尽管 make 的模型非常通用,但它的设计者忽略了让它在常见场景中易于使用这一点。事实上,几乎没有哪个 Unix 新手程序员知道使用 make 把自己搞砸到底究竟有多简单——直到他们真的这么干了。

我们继续上面的例子,假设我们的程序员(叫他 Dennis 吧)正在试图找出 source1.c 中的 bug,因此他希望为这个文件启用调试信息进行编译。他把 Makefile 修改成了这样:

program: source1.o source2.o
cc -o program source1.o source2.o
# I'm debugging source1.c -Dennis
source1.o: source1.c
cc -c -g source1.c
source2.o: source2.c
cc -c source2.c

以 # 开头的那一行是注释,make 程序会忽略它们。可是,当可怜的 Dennis 运行 make 时,程序却报错了:

Make: Makefile: Must be a separator on line 4.
Stop

然后 make 就退出了。他盯着他的 Makefile 看了好几分钟,接着几个小时,却始终没弄明白哪里出了问题。他觉得可能是注释行有问题,但又不确定。Dennis 的 Makefile 出错的原因是,当他添加注释行时,不小心在第 2 行开头的制表符(tab)前插入了一个空格。制表符是 Makefile 语法中非常重要的一部分。所有命令行(例如示例中以 cc 开头的行)必须以制表符开始。他修改后,第 2 行没有以制表符开头,因此出错了。

你可能会问:“那又怎样?这有什么问题?”

其实单独看,这没什么问题。只是当你考虑到 Unix 中其他编程工具的工作方式时,使用制表符作为语法的一部分,就像《绿色贝雷帽》中的那种陷阱:堪萨斯来的那个可怜孩子走在约翰·韦恩前面,却没看到绊线。毕竟,堪萨斯的玉米地里可没什么绊线。砰!

你看,制表符、空格和换行符通常被称为空白字符。空白字符是技术术语,意思是“你应该忽略它们”,而且大多数程序确实如此。大多数程序把空格和制表符当作一样对待。除了 make(还有 cu、uucp 和其他几个程序)。现在,堪萨斯那个可怜孩子除了给他一枪了结他痛苦之外,别无他法。

Dennis 从来没找到他 Makefile 的问题。现在他被困在一份死胡同般的工作里,戴着纸帽子,负责维护美国中西部一所大型州立大学的 sendmail 配置文件。真是太可惜了。

头文件

C 有一种叫做头文件的东西。它们是包含定义的文件,在编译时被包含到源文件中。和 Unix 中的大多数东西一样,当只有一两个头文件时,它们工作得相当不错,但当你尝试做一些较复杂的事情时,很快就变得难以处理。

通常很难计算出应该在源文件中包含哪些头文件。头文件是通过 C 预处理器的 #include 指令包含的。这个指令有两种语法:

#include <header1.h>

和

#include "header2.h"

这两种语法之间的区别取决于具体实现。这基本上意味着实现者可以随心所欲地处理。

假设 Dennis 有位名叫 Joey 的朋友,他也是位 Unix 新手程序员。Joey 有款叫 foo.c 的 C 程序,其中包含一些数据结构定义,这些定义位于同一目录下的 foo.h 文件中。你可能知道,foo 是程序员中一个很常见的名字。事实是,Joey 所用机器的系统程序员也创建了文件 foo.h,并把它存放在默认的头文件目录 /usr/include 中。

可怜的 Joey 编译他的 foo.c 程序时,惊讶地看到多个语法错误。他很困惑,因为每次提到 foo.h 中定义的任何数据结构时,编译器都会报语法错误。但 foo.h 中的定义看起来没问题。

你和我可能都知道,Joey 很可能遇到了:

#include <foo.h>

在他的 C 文件中,而不是

#include "foo.h"

但 Joey 并不知道这点。或者他可能用了引号,但使用的编译器对包含文件的搜索规则略有不同。关键是,Joey 遇到了麻烦,而且这很可能不是他的错。

拥有大量的头文件是件很头疼的事。不幸的是,只要你试图写一个有用的 C 程序,这种情况就会发生。头文件通常定义数据结构,且许多头文件依赖于其他头文件中定义的数据结构。作为程序员的你,得承担起整理这些依赖关系并按正确顺序包含头文件的艰巨任务。

当然,编译器会帮你。如果包含顺序错了,编译器会不耐烦地告诉你出现了语法错误。编译器是个忙碌且重要的程序,没时间去区分是数据结构定义缺失还是单纯的拼写错误。事实上,如果你甚至遗漏一个小东西,比如一个分号,C 编译器往往会因为过于混乱和烦躁,直接“崩溃”,抱怨它根本无法编译文件的其余部分,因为这个缺失的分号让它无法继续。可怜的编译器实在无法集中精力处理剩下的代码。

在编译器界,这种现象被称为“级联错误”(cascade errors),这是编译器术语,意思是“我摔倒了,爬不起来了”。缺失的分号让编译器的解析器与程序文本不同步。编译器对语法错误反应如此激烈,可能是因为它基于 yacc,而 yacc 是个非常适合为语法正确(罕见情况)程序生成解析器的好工具,但却是生成健壮、能检测和纠正错误解析器的糟糕工具。经验丰富的 C 程序员都知道,要忽略编译器报出的除第一个解析错误以外的所有错误。

工具和手册页

Unix 工具是独立的;每款程序都可以自由地根据自己的理解来解释命令行参数。这种自由令人烦恼;你无法只学一套命令行参数的约定,而是得为每个程序阅读 man 页来弄清楚如何使用。 幸好 man 页写得都很出色。

看看下面的例子。“SYNOPSIS”部分总结得很到位,你不觉得吗?

LS(1) Unix 程序员手册 LS(1)

名称

ls - 列出目录内容

用法

ls [ -acdfgilqrstu1ACLFR ] name ...

描述

对于每个目录参数,ls 列出该目录的内容;对于每个文件参数,ls 重复显示其名称及任何请求的其他信息。默认情况下,输出按字母顺序排序。若无参数,则列出当前目录。当给出多个参数时,参数先按适当方式排序,但文件参数会先于目录及其内容被处理。

有大量选项:

[...]

缺陷(BUGS)

换行符和制表符被视为文件名中的可打印字符。输出设备假定宽度为 80 列。基于输出是否为电传打字机而设置选项是不理想的,因为 ls -s 和 ls -s | lpr 的行为差异很大。另一方面,不进行这种设置会使得以前使用 ls 的旧 shell 脚本几乎肯定无法正常工作。

你可以在阅读 man 页时玩的一个游戏是查看 BUGS 部分,试着想象每个错误是如何产生的。来看这个来自 shell man 页的例子:

SH(1) Unix 程序员手册 SH(1)

名称

sh、for、case、if、while、:、.、break、continue、cd、eval、exec、exit、export、login、read、readonly、set、shift、times、trap、umask、wait — 命令语言

用法

sh [ -ceiknrstuvx ] [ 参数 ] ...

描述

sh 是一种命令编程语言,用于执行从终端或文件读取的命令。有关 shell 参数的含义,请参见调用部分。

[...]

缺陷(BUGS)

如果使用 << 为由 & 启动的异步进程提供标准输入,shell 会混淆输入文档的命名。会创建垃圾文件 /tmp/sh*,且 shell 会报错找不到另一个名称的文件。

我们花了好几分钟试图看懂这个 BUGS,但连他们到底在说什么都没弄明白。我们给一位 Unix 专家看了这段,他评论说:“当我盯着它挠头时,我想到的是,比起追踪这个 bug 并写出 BUGS 条目所花的时间,程序员本来早就能把该死的 bug 修好了。”

不幸的是,修复一个 bug 并不够,因为每次操作系统发布新版本时,它们又会重新出现。早在上世纪八十年代初,在 Unix 中的这些 bug 尚未成为大规模关注的“邪教”问题之前,BBN 的一位程序员实际上修复了伯克利的 make 中的一个 bug——该 bug 要求规则行必须以制表符(tab)开头,而不能是任何空白字符。这个修复并不难——只需几行代码。

像任何负责任的公民一样,BBN 的黑客们将补丁发送回伯克利,以便将修复合并进 Unix 的主代码库。一年后,伯克利发布了新版本的 Unix,make 的 bug 依旧存在。BBN 的黑客们第二次修复了这个 bug,并再次将补丁发回伯克利……

当伯克利第三次发布带有相同 bug 的 make 版本时,BBN 的黑客们放弃了。与其继续修复伯克利 make 中的这个 bug,他们转而检查了所有的 Makefile,找到以空格开头的行,将空格改成了制表符。毕竟,BBN 雇他们是写新程序的,不是反复修老 bug 的。

(传说中,Stu Feldman 之所以没有修复 make 的语法问题,是因为他发现语法确实有问题时,已经有十个用户依赖这个语法了)。

源码即文档。哦,太棒了

如果写得很难,理解起来也应该很难。

——一位 Unix 程序员

在文档章节中,我们提到 Unix 程序员认为操作系统的源代码才是终极文档。“毕竟,”一位著名的 Unix 历史学家说,“源代码就是操作系统自己在试图弄清下一步该做什么时参考的文档。”

但试图通过阅读 Unix 源代码来理解 Unix,就像试图驾驶 Ken Thompson 那辆著名的 Unix 车(仪表盘上只有一个“?”)横穿全国一样困难。

Unix 内核源码(尤其是可从 ftp.uu.net 获取的 Berkeley Network Tape 2 源码)大多没有注释,代码“段落”之间不留空行,频繁使用 goto,整体上极力让想理解它的人感到不友好。正如一位黑客所说:“阅读 Unix 内核源码就像走进一条黑暗的小巷。我突然停下来想,‘天哪,我要被打劫了。’”

当然,内核源码中也有它们自己的警示灯。到处散布着这样的简短注释:

/* XXX */

这些注释意味着有问题。你应该能够准确地弄清每种情况下具体出了什么问题。

“这不可能是个 bug,我的 Makefile 就指望着它呢!”

BBN 的程序员通常是例外。大多数 Unix 程序员不会修复 bug:因为大多数人没有源代码。那些拥有源代码的人也知道,修复 bug 并没有太大帮助。这就是为什么大多数 Unix 程序员遇到 bug 时,只会绕开它们编程。

这是一种令人难过的状况:如果要解决某问题,为什么不一次性彻底解决,而是针对每个新程序反复重复同样的修复呢?也许早期的 Unix 程序员是隐藏的形而上学者,相信尼采的永恒轮回学说。

调试思想有两派。一派是“调试器如医生”学派,这在早期的 ITS 和 Lisp 系统中很流行。在这些环境中,调试器始终伴随着正在运行的程序,当程序崩溃时,调试器/医生能诊断问题,让程序恢复健康。

Unix 则遵循更古老的“调试如尸检”模型。在 Unix 中,程序崩溃死掉,留下一个转储文件,这在很多方面都像是一具尸体。随后,Unix 调试器会出现,查明死因。有趣的是,Unix 程序的死亡原因往往像人一样,是可治愈的疾病、事故和疏忽。

处理转储文件

在你的程序写出转储文件之后,首要任务就是找到它。这个任务不应该太难,因为转储文件相当大——4、8,甚至 12 MB 的转储文件并不少见。

转储文件之所以大,是因为它包含了你调试程序时几乎需要的所有信息:堆栈、数据、代码指针……实际上,除了程序的动态状态外,几乎一切都有。如果你在调试网络程序,当转储文件生成时已经太晚了;程序的网络连接已经断开。更糟的是,任何打开的文件此时也都关闭了。

不幸的是,在 Unix 下只能是这样。

比如,当操作系统生成异常时,不能作为命令解释器运行调试器或将控制权转交给调试器 。让调试器在程序崩溃时接管程序的唯一方法是从调试器中运行每个程序。如果你想调试中断,你的调试器程序必须拦截每个中断,并将相应的中断转发给你的程序。你能想象每敲一个键 emacs 就要进行三次上下文切换吗?显然,常规调试的想法对 Unix 哲学来说是陌生的。

日期:1991 年 1 月 2 日 星期三 07:42:04 PST

收件人:Unix 痛恨者

主题:调试器

有没有想过为什么 Unix 的调试器这么差劲?那是因为如果它们有什么功能的话,它们可能会有漏洞;如果有漏洞,它们可能会生成核心转储;如果生成核心转储,噗嗤,那个你正试图调试的应用程序的转储文件就没了。

要是有办法让应用程序控制它们何时、如何、在哪里生成核心转储就好了。

错误遗物馆

与其他操作系统不同,Unix 将其 bug 视为标准操作程序加以保留。Unix bug 长期不修复的最常被引用的原因是,这些修复会破坏已有程序的正常运行。这尤其具有讽刺意味,因为 Unix 程序员在实现新功能时几乎从不考虑向上兼容性。

针对这些问题,Michael Tiemann 总结了 10 条原因,说明为什么 Unix 调试器在自己生成转储文件时,会覆盖已有的“转储”文件。

日期:1991 年 1 月 17 日 星期四 10:28:11 PST

收件人:Unix 痛恨者

主题:Unix 调试器

David Letterman 的十大冷笑话答案是:

10. 它会破坏已有代码。

9. 需要更改文档。

8. 实现起来太难。

7. 为什么调试器要做那事?不如写个“工具”来做。

6. 如果调试器崩溃了,你应该忘掉调试你的应用,去调试调试器吧。

5. 太难理解了。

4. 奶油蛋糕在哪?

3. 现在为什么要修复?

2. Unix 不可能做到一切都正确。

1. 什么问题?

“修复漏洞会破坏已有代码”是 Unix 程序员不想修复漏洞的有力借口。但背后可能还有隐情。修复漏洞不仅会破坏已有代码,还会改变那些极端支持者认为简单易懂的 Unix 接口。接口是否有效无关紧要。Unix 程序员宁愿反复念叨“Unix 接口是简单且优美的”这一咒语,也不愿认认真真想办法做得更好,或者仅仅是修复已有漏洞。简单且优美,简单且优美,简单且优美!(听起来还不错吧?)

不幸的是,绕开漏洞编程尤其恶心,因为它让有漏洞的行为成了操作系统规范的一部分。越拖越难修,因为无数程序已经依赖这些漏洞的工作方式,一旦修正,程序就会崩溃。因此,改变操作系统接口的成本更高,因为会有大量工具程序需要修改来适应新的、虽然正确但不同的接口行为(这部分也解释了为什么像 ls 这样的程序有这么多不同选项来完成差不多的功能,每个选项都有些许不同)。

如果你把青蛙放进沸腾的水里,它会立刻跳出来。沸水很烫,这是常识。但如果你把青蛙放进冷水里,慢慢加热直到沸腾,青蛙不会察觉,最终会被煮死。

Unix 接口已经在沸腾了。以前完整的输入/输出编程接口只有 open、close、read 和 write。网络功能的加入又添了柴火。现在,发送数据的方式至少有五种:write、writev、send、sendto 和 sendmsg。每一种都对应内核不同的代码路径,这意味着有五倍的漏洞机会和五套性能特性要记忆。读取数据的接口也是如此(read、recv、recvfrom 和 recvmsg)。结果是,青蛙已死。

文件名扩展

Unix 的“每个程序各自独立”规则有一个例外:文件名扩展。通常情况下,人们希望 Unix 工具能够操作一个或多个文件。Unix shell 提供了一种简写方式来命名一组文件,由 shell 进行扩展,生成一个文件列表传递给工具程序。

例如,假设你的目录中有文件 A、B 和 C。要删除所有这些文件,你可以输入 rm *。shell 会将 * 扩展为 A B C,并将这些参数传递给 rm。这种方法存在许多问题,我们在上一章中已经讨论过。不过你应该知道,使用 shell 来扩展文件名并不是历史上的偶然,而是经过深思熟虑的设计决策。在 Kernighan 和 Mashey 撰写的《Unix 编程环境》(IEEE Computer,1981 年 4 月)中,作者指出:“将这一机制集成到 shell 中,比在各处重复实现更高效,也确保程序能够以统一的方式使用它。”

抱歉?标准输入输出库(Unix 中称为 stdio)是“以统一方式提供给程序的”。那为什么不通过库函数来实现文件名扩展呢?难道他们没听说过可链接的代码库吗?而且,他们所谓的效率说法完全空洞,因为根本没有给出任何性能数据来支持。甚至也没解释“效率”具体指什么。把文件名扩展放在 shell 中,是为了让程序员写小程序时系统更高效,还是仅仅为了让不懂行的新用户在删除文件时更高效?

大多数情况下,让 shell 进行文件名扩展没什么区别,因为结果和由工具程序自己扩展是一样的。但像 Unix 的许多设计一样,这种做法有时会带来很严重的问题。

比如,你是新用户,目录里有两个文件 A.m 和 B.m。你习惯了 MS-DOS,想把文件名改成 A.c 和 B.c。嗯,没有 rename 命令,但有个 mv 命令,好像也能实现同样效果。于是你输入 mv *.m *.c。shell 会将这条命令展开成 mv A.m B.m,然后 mv 就直接用 A.m 覆盖了 B.m。这样就麻烦了,因为你刚刚花了几个小时在 B.m 上工作,而这却是你唯一的备份。

花点时间思考这个问题,你会发现,从理论上来说,不可能修改 Unix 的 mv 命令,使其具备 MS-DOS 中 rename 命令的功能。软件工具就是这么让人无奈。

健壮性,或者说“所有行都短于 80 个字符”

1990 年 12 月刊《ACM 通讯》上有一篇有趣的文章,题为《Unix 工具可靠性的实证研究》,作者是 Miller、Fredriksen 和 So。他们向多个 Unix 工具程序输入随机数据,发现有 24% 到 33%(取决于所测试的 Unix 供应商)的程序会崩溃或宕机,偶尔甚至导致整个操作系统恐慌(panic)。

这篇文章最初是个玩笑。作者之一在嘈杂的电话线路上工作,线路噪声不断导致各种工具程序崩溃,于是决定系统地研究这一现象。

大多数错误都是由 C 语言的若干众所周知的惯用法引起的。实际上,Unix 固有的许多问题都可以归咎于 C 语言。Unix 的内核和所有工具程序都是用 C 写的。著名语言学家 Benjamin Whorf 曾说过,我们的语言决定了我们能思考的概念。C 语言对 Unix 产生了类似影响;它阻止程序员编写健壮的软件,因为这根本无法成为一种思维方式。

C 语言非常简约,设计时为了能高效地编译到各种计算机硬件,因此语言结构能轻松映射到底层硬件。

在 Unix 创建之初,用高级语言编写操作系统内核是一项革命性的想法。如今,应该用支持某种错误检测的语言来编写内核。

C 是一种最低公分母语言,诞生于那个“最低公分母”相当低的时代。如果 PDP-11 没有的,那么 C 也没有。过去几十年编程语言研究显示,增加对错误处理、自动内存管理和抽象数据类型的语言支持,可以极大简化编写健壮可靠软件的难度。C 完全不具备这些特性。由于 C 的普及,几乎没有动力在当前及未来的微处理器中加入数据标签或垃圾回收的硬件支持;这些特性对于绝大多数用 C 写的程序来说是浪费硅片资源。

回想一下,C 语言根本无法处理整数溢出。解决方案通常是使用比实际问题范围更大的整数类型,并希望程序生命周期内问题规模不要变大。

C 也没有真正的数组,它有一种看似数组的东西,实际上是指向内存位置的指针。数组索引表达式 array[index] 只是 *(array + index) 的简写,因此写成 index[array] 也是合法的,它同样是 *(array + index) 的简写。巧妙吧?这种双重性质在 C 程序处理字符数组时尤其明显。数组变量可以在指针和数组间互换使用。

举个例子,如果你有:

char *str = "bugy”;

……那么以下等价关系也成立:

0[str] == 'b' *
    (str + 1) == 'u' *
    (2 + str) == 'g'
str[3] == 'y'

C 真是厉害啊!

这个做法的问题在于,C 并不会自动检查数组引用的边界。为什么要检查呢?因为数组实际上就是指针,而指针可以指向内存中的任意位置,对吧?不过,你可能希望保证某段代码不会随意篡改任意内存,尤其是那些重要的内存区域,比如程序的栈空间。

这就引出了 Miller 论文中提到的第一个错误来源。很多程序崩溃,是因为它们在往分配在调用栈上的字符缓冲区读入输入时发生了错误。许多 C 程序都会这样做;下面这个 C 函数就是将一行输入读入到栈上分配的数组里,然后调用 do_it 处理这行输入。

a_function() {
    char c, buff[80];
    int i = 0;
    while ((c = getchar()) != '\n')
        buff[i++] = c;
    buff[i] = '\000';
    do_it(buff);
}

这类代码在 Unix 里随处可见。注意这里栈上分配的缓冲区长度是 80 个字符——因为大多数 Unix 文件的行长都不会超过 80 个字符。还要注意,存入字符数组之前没有做边界检查,也没有检测文件结尾(EOF)条件。程序员之所以省略边界检查,可能是因为他喜欢把赋值语句(c = getchar())直接写在 while 循环的条件里,这样就没地方检测 EOF 了,因为这行代码本身已经在检测行尾。信不信由你,有些人还真就赞赏 C 语言这种简洁写法——至于是否易懂和维护,管他呢!最后,调用了 do_it 函数,字符数组就变成了指针,被传入了函数的第一个参数。

读者练习:如果输入行中途出现了 EOF,会发生什么?

当 Unix 用户发现这些内置限制时,通常不会想着修复这些 bug,而是想办法应对。举例来说,Unix 的“磁带归档器” tar 不能处理路径名超过 100 个字符(含目录)。解决方法:不要用 tar 归档目录,改用 dump。更好的办法是不要用太深的子目录,这样文件的绝对路径永远不会超过 100 字符。最极端的例子可能是在 2038 年 1 月 18 日晚上 10:14:07,当 Unix 32 位的 timeval 字段溢出时……

继续我们的例子,假如函数要读入一行 85 字符的输入。函数会成功读完这 85 个字符,但后面多出的 5 个字符会写入字符数组之后的内存中。这段内存原先是什么?可能是两个变量 c 和 i,它们就紧挨着字符数组,因此可能会被破坏。如果输入达到 850 字符,可能会覆盖 C 运行时系统在栈上保存的重要管理信息,比如子程序返回地址。最坏情况是程序崩溃。

我们说“可能”,是因为你还可以利用这个栈破坏造成程序执行原作者没想到的效果。假设输入一行超过 2000 字符,且故意覆盖了调用栈上的管理信息,这样当函数返回时,会跳转到这行代码中嵌入的一段恶意代码。这段代码可能做些“有用”的事情,比如启动 shell,在机器上执行命令。

Robert T. Morris 的 Unix 蠕虫就是利用了这种机制(以及其他手段)来攻入 Unix 计算机。至于为什么有人要这么做,仍是一个谜。

日期:1991 年 5 月 2 日 星期四 18:16:44 PDT

收件人:Unix 痛恨者

主题:你的手有多少根手指?

遗憾的是,今天我给经理发了这样一条消息:

有个程序用来更新 Makefile,但它有个指针越过了它本该索引的数组,结果乱写到了用来计算依赖列表的数据结构上,这些依赖列表是程序自动写入 Makefile 的。结果是,后来被破坏的 Makefile 没有编译它应该编译的所有内容,所以必要的 .o 文件没有生成,最终构建失败了。这完全是浪费了一整天,因为某个弱智认为最多有 10 个 include,接着对那段运行时间不到一毫秒的代码进行了危险的优化,而这段代码就是用来生成多个 Makefile 的!

在网上工作的一大劣势就是,你没法轻易走进别人的办公室,把他那该死的心剖出来。

异常情况

编写健壮软件的主要挑战是优雅地处理错误和其他异常情况。不幸的是,C 语言几乎不提供处理异常情况的支持。因此,如今在学校和大学里学习编程的人,很少知道什么是异常。

异常是指函数行为不符合预期时可能出现的情况。异常通常发生在请求系统服务时,比如分配内存或打开文件。由于 C 语言不支持异常处理,程序员必须为每个服务请求编写多行异常处理代码。

例如,所有 C 教科书中都会教你应该这样使用 malloc() 内存分配函数:

struct bpt *another_function()
{
    struct bpt *result;
    result = malloc(sizeof(struct bpt));
    if (result == 0) {
        fprintf(stderr, "error: malloc: ???\n");
        /* 从错误中优雅地恢复 */
        [...]
        return 0;
    }
    /* 执行一些有趣的操作 */
    [...]
    return result;
}

函数 another_function 分配了一个类型为 bpt 的结构体,并返回指向该结构体的新指针。所示代码片段为新结构体分配内存。由于 C 语言没有提供显式的异常处理支持,C 程序员不得不为每一个系统服务请求编写异常处理代码(即加粗部分的代码)。

或者不写。许多 C 程序员选择不去理会这些琐事,直接省略异常处理代码。他们的程序看起来像这样:

struct bpt *another_function()
{
    struct bpt *result = malloc(sizeof(struct bpt));
    /* 执行一些有趣的操作 */
    return result;
}

这更简单、更干净,而且大多数时候操作系统的服务请求不会返回错误,对吧?因此程序通常看起来没有 bug,直到它们遇到非常规的情况,才会诡异地失败。

Lisp 实现通常有真正的异常处理系统。异常情况有像 OUT-OF-MEMORY(内存耗尽)这样的名称,程序员可以为特定类型的异常设置处理程序。当异常被触发时,这些处理程序会自动调用——程序员无需特别干预或进行特殊测试。合理使用这些处理程序可以让软件更加健壮。

编程语言 CLU 也内置了异常处理支持。每个函数定义都会附带一个可能被该函数触发的异常列表。对异常的语言级支持允许编译器在异常未被处理时发出警告。CLU 程序通常相当健壮,因为 CLU 程序员会花时间考虑异常处理,以让编译器不再报错。而 C 程序呢……

日期:1988 年 12 月 16 日 16:12:13 GMT

主题:回复:GNU Emacs

我同意,但不幸的是,实际上很少有程序会这样做来检查 read 和 write。在 Unix 工具中,常见的做法是检查 open 系统调用的结果,然后就假设写入和关闭操作都会顺利完成。

原因显而易见:程序员有点懒,而且如果不检查,程序会变得更小更快(所以不检查还会让你的系统在使用标准工具的基准测试中表现更好……)。

作者接着指出,由于大多数 Unix 工具不会检查 write() 系统调用的返回码,因此系统管理员必须确保所有文件系统始终有足够的空闲空间。这一点非常重要。确实,大多数 Unix 程序假设只要能打开文件进行写入,就能写入所需的所有字节。

类似这样的情况应该会让你觉得“嗯……”(值得深思)。更令人害怕的是,Miller 等人的文章《Unix 工具可靠性的实证研究》紧接着的一篇文章却报道了休斯顿约翰逊航天中心的任务控制中心正在切换到 Unix 系统,用于实时数据采集。嗯……

捕捉错误在社会上是不被接受的

不检查也不报告错误会让厂商的机器看起来比实际更稳健、更强大。更重要的是,如果 Unix 机器逐个报告错误和故障,没人会买它们!这确实是一种现实存在的现象。

日期:1990 年 1 月 11 日 星期四 09:07:05 PST

收件人:Unix 痛恨者

主题:现在,难道还不清楚吗?

由于惠普工程师的设计,我的惠普 Unix 机器会报告它们在网络上看到的影响它们的错误。这些惠普机器和 SUN、MIPS 以及 DEC 工作站共用同一个网络。我们经常会因为另一台机器的问题而遇到麻烦,但当我们通知那台机器的所有者时,(由于他的机器会丢弃错误信息,他并不知道他的机器出现故障并且一半时间都在重新传输数据包),他却会声称问题出在我们这边,因为我们的机器报告了这个问题!

在 Unix 世界里,传话的人会被“枪毙”。

如果你修不好,那就重启一下吧

那么系统管理员和其他人该如何处理那些无法正确处理错误、坏数据和恶劣运行环境的重要软件呢?如果软件能在短时间内正常运行,你可以通过定期重启它来让它运行更长时间。这个解决方案并不可靠,也难以扩展,但足够让 Unix 继续勉强运转。

下面是这类权宜之计的一个例子,它被用来保证邮件服务在面对不可靠的 named 程序时依然能够运行:

日期:1991 年 5 月 14 日 05:43:35 GMT

主题:回复:DNS 性能计量:bind 4.8.4 的愿望清单

新闻组:comp.protocols.tcp-ip.domains

我们目前解决此问题的方法是:我写了一款叫“ninit”的程序,它以 nofork 模式启动 named 并等待它退出。当 named 退出时,ninit 会重新启动一个新的 named。此外,每隔 5 分钟,ninit 会唤醒并向 named 发送 SIGIOT 信号。这会使 named 将统计信息转储到 /usr/tmp/named.stats。每 60 秒,ninit 会尝试使用本地的 named 进行一次名称解析。如果在很短的时间内未得到响应,它会终止现有的 named 并启动一个新的。

我们在 MIT 的名称服务器和邮件中心运行这个程序。发现它对于捕获神秘死机或未知原因挂起的 named 非常有用。特别是在我们的邮件中心非常有用,因为如果名称解析丢失哪怕短时间,邮件队列就会爆炸。

当然,这种解决方案留下了个明显的问题:如何处理有缺陷的 ninit 程序?写另一个程序在 ninits 因“未知原因”退出时重新 fork 它们?但又该如何保证那个程序本身持续运行呢?

对待出错软件的这种态度并非独一无二。我们最近看到了一份 man 手册页,至今还没弄清楚它是真实的还是开玩笑的。其中的 BUGS 部分很有启发性,列举的错误正是 Unix 程序员似乎永远无法从他们的服务器代码中根除的常见问题:

NANNY(8) Unix 程序员手册 NANNY(8)

名称

nanny — 一款用于运行所有服务器的守护程序

用法

/etc/nanny [开关 [参数]] [...开关 [参数]]

描述

大多数系统都有若干服务器为系统及其用户提供服务。遗憾的是,这些服务器偶尔会“挂掉”,导致系统及其用户失去某项服务。nanny 的创建和实现目的就是监控(看护)这些服务器,希望能够防止服务器提供的关键服务丢失,而无需系统管理员或操作员不断干预。

此外,大多数服务器会输出日志数据。日志数据会占用存储它的磁盘空间,带来不便。另一方面,日志数据对于事件追踪至关重要,应尽可能保存。nanny 通过充当中间人,定期将日志数据重定向到新的文件来处理这种溢出。这样,日志数据被分割成多个部分,旧日志可以在不影响新数据的情况下删除。

最后,nanny 提供若干控制功能,允许操作员或系统管理员在运行时动态操作 nanny 及其监控的服务器。

开关

……

缺陷(BUGS)

服务器不能从 nanny 进行分离的 fork 操作,否则 nanny 会误以为服务器已经死掉,从而不断重启服务器。

当前版本的 nanny 无法容忍配置文件中的错误。因此,错误的文件名或非配置文件会导致 nanny 崩溃。

并非所有开关都已实现。

nanny 严重依赖系统提供的网络功能以实现进程间通信。如果网络代码出现错误,nanny 无法容忍,会卡死或进入死循环。

重启有缺陷的软件已经成为一种普遍的做法,以至于麻省理工学院的 Athena 项目现在会在每周日上午 4 点自动重启其 Andrew 文件系统(AFS)服务器。希望那时没人熬夜做周一早上截止的大作业……

来自:(Thomas M. Breuel)

来自:

发件人:Michael Tiemann <cygint! >

发件人:Michael Tiemann

发件人:Jim McDonald

发件人:

在文章 中,(Lars Pensj)写道:……所有程序都必须自己检查系统调用(如 write)的结果,这一点至关重要……

发件人:Daniel Weise

发件人:(Theodore Ts’o)

tmb@ai.mit.edu
markf@altdorf.ai.mit.edu
tiemann@labrea.stanford.edu
tiemann@cygnus.com
jlm%missoula@lucid.com
debra@alice.UUCP
448@myab.se
lars@myab.se
daniel@mojave.stanford.edu
tytso@athena.mit.edu