开源应用程序架构(卷 1)
Bourne-Again Shell

Chet Ramey

3.1. 简介

Unix shell 提供了一个界面,允许用户通过运行命令与操作系统交互。但是 shell 也是一种相当丰富的编程语言:它包含用于流程控制、交替、循环、条件语句、基本数学运算、命名函数、字符串变量以及 shell 与它调用的命令之间的双向通信的结构。

Shell 可以交互式地使用,从终端或终端仿真器(如 xterm)中使用,也可以非交互式地使用,从文件中读取命令。大多数现代 shell,包括 bash,都提供命令行编辑功能,在这种功能中,可以使用类似 emacs 或 vi 的命令在输入命令行时对其进行操作,以及各种形式的保存的命令历史记录。

Bash 处理过程很像 shell 管道:在从终端或脚本中读取数据后,数据会通过多个阶段,在每个阶段进行转换,直到 shell 最终执行命令并收集其返回状态。

本章将从管道的角度探讨 bash 的主要组件:输入处理、解析、各种词扩展和其他命令处理以及命令执行。这些组件充当从键盘或文件读取数据的管道的角色,将数据转换为执行的命令。

[Bash Component Architecture]

图 3.1:Bash 组件架构

3.1.1. Bash

Bash 是出现在 GNU 操作系统中的 shell,通常在 Linux 内核之上实现,以及其他几个常见操作系统,最值得注意的是 Mac OS X。它提供了比 sh 的历史版本在交互式和编程使用方面更强大的功能。

这个名字是 Bourne-Again SHell 的首字母缩写,它是一个双关语,结合了 Stephen Bourne 的名字(当前 Unix shell /bin/sh 的直接祖先的作者,它出现在贝尔实验室第七版研究版的 Unix 中)以及通过重新实现而重生的概念。Bash 的最初作者是 Brian Fox,他是自由软件基金会的员工。我目前是开发人员和维护人员,一名在克利夫兰凯斯西储大学工作的志愿者。

与其他 GNU 软件一样,bash 具有很强的移植性。它目前几乎可以在所有版本的 Unix 和其他一些操作系统上运行——在托管的 Windows 环境(如 Cygwin 和 MinGW)中存在独立支持的移植版本,以及在类 Unix 系统(如 QNX 和 Minix)中存在移植版本是分发的一部分。它只需要一个 Posix 环境来构建和运行,例如微软的 Services for Unix (SFU) 提供的环境。

3.2. 语法单位和基元

3.2.1. 基元

对于 bash 来说,基本上有三种类型的标记:保留字、词和运算符。保留字是指对 shell 及其编程语言有意义的那些词;通常这些词引入流程控制结构,如 ifwhile。运算符由一个或多个元字符组成:对 shell 本身具有特殊意义的字符,例如 |>。shell 输入的其余部分由普通词组成,其中一些词在命令行中出现的位置不同,其含义也不同——例如赋值语句或数字。

3.2.2. 变量和参数

与任何编程语言一样,shell 提供变量:用于引用存储数据的名称并对其进行操作。shell 提供基本的用户可设置变量和一些称为参数的内置变量。shell 参数通常反映 shell 内部状态的某些方面,并自动设置或作为其他操作的副作用进行设置。

变量值是字符串。一些值根据上下文进行特殊处理;这些将在后面解释。变量使用 name=value 形式的语句进行赋值。value 是可选的;省略它将空字符串赋值给 name。如果提供了值,shell 将扩展该值并将其赋值给 name。shell 可以根据变量是否已设置执行不同的操作,但赋值是设置变量的唯一方法。未分配值的变量,即使已声明并赋予属性,也称为未设置变量。

以美元符号开头的词引入变量或参数引用。该词,包括美元符号,将被替换为命名变量的值。shell 提供了一套丰富的扩展运算符,从简单的值替换到更改或删除与模式匹配的变量值的某些部分。

提供了对局部变量和全局变量的支持。默认情况下,所有变量都是全局的。任何简单命令(最常见的命令类型——命令名称以及可选的参数集和重定向)都可以用一组赋值语句作为前缀,以使这些变量仅在该命令中存在。shell 实现存储过程或 shell 函数,这些函数可以具有函数局部变量。

变量可以进行最小类型化:除了简单的字符串值变量之外,还有整数和数组。整数类型变量被视为数字:分配给它们的任何字符串都将作为算术表达式扩展,结果将作为变量的值分配。数组可以是索引的或关联的;索引数组使用数字作为下标,而关联数组使用任意字符串。数组元素是字符串,如果需要,可以将其视为整数。数组元素不能是其他数组。

Bash 使用哈希表存储和检索 shell 变量,并使用这些哈希表的链接列表来实现变量作用域。shell 函数调用有不同的变量作用域,并且对由赋值语句设置的变量(在命令之前)有临时作用域。例如,当这些赋值语句位于内置于 shell 的命令之前时,shell 必须跟踪解析变量引用的正确顺序,而链接的作用域允许 bash 完成此操作。根据执行嵌套级别,可能需要遍历多个作用域,这令人惊讶。

3.2.3. shell 编程语言

一个简单的 shell 命令,大多数读者最熟悉的命令,包含一个命令名称(例如 echocd)以及一个包含零个或多个参数和重定向的列表。重定向允许 shell 用户控制调用的命令的输入和输出。如上所述,用户可以定义对简单命令局部的变量。

保留字引入更复杂的 shell 命令。有一些结构在任何高级编程语言中都很常见,例如 if-then-elsewhile、一个遍历值列表的 for 循环以及一个类似 C 语言的算术 for 循环。这些更复杂的命令允许 shell 执行命令或以其他方式测试条件,并根据结果执行不同的操作,或多次执行命令。

Unix 带给计算世界的礼物之一是管道:一个线性命令列表,其中列表中一个命令的输出成为下一个命令的输入。任何 shell 结构都可以用于管道,在管道中,命令将数据馈送到循环的情况并不少见。

Bash 实现了一种功能,允许在调用命令时,将命令的标准输入、标准输出和标准错误流重定向到另一个文件或进程。shell 程序员还可以使用重定向在当前 shell 环境中打开和关闭文件。

Bash 允许 shell 程序被存储并使用多次。shell 函数和 shell 脚本都是对一组命令进行命名并执行该组命令的方法,就像执行任何其他命令一样。shell 函数使用特殊语法声明,并存储和执行在同一 shell 的上下文中;shell 脚本通过将命令放入文件并执行新实例的 shell 来解释它们,从而创建。shell 函数与调用它们的 shell 共享大部分执行上下文,但 shell 脚本,因为它们由新 shell 调用进行解释,只共享环境中进程之间传递的内容。

3.2.4. 进一步说明

在您继续阅读时,请记住,shell 使用仅几个数据结构来实现其功能:数组、树、单向链接列表和双向链接列表以及哈希表。几乎所有 shell 结构都是使用这些基元实现的。

shell 用于将信息从一个阶段传递到下一个阶段,并在每个处理阶段对数据单元进行操作的基本数据结构是 WORD_DESC

typedef struct word_desc {
  char *word;           /* Zero terminated string. */
  int flags;            /* Flags associated with this word. */
} WORD_DESC;

词被组合成,例如,参数列表,使用简单的链接列表

typedef struct word_list {
  struct word_list *next;
  WORD_DESC *word;
} WORD_LIST;

WORD_LIST 在整个 shell 中无处不在。一个简单的命令是一个词列表,扩展的结果是一个词列表,并且内置命令都接受一个词列表作为参数。

3.3. 输入处理

bash 处理管道的第一阶段是输入处理:从终端或文件中获取字符,将它们分成行,并将这些行传递给 shell 解析器以将其转换为命令。正如您所料,这些行是由换行符终止的字符序列。

3.3.1. Readline 和命令行编辑

Bash 在交互式情况下从终端读取输入,在其他情况下从指定为参数的脚本文件中读取输入。在交互式情况下,bash 允许用户在输入命令行时编辑命令行,使用熟悉的键序列和类似于 Unix emacs 和 vi 编辑器的编辑命令。

Bash 使用 readline 库来实现命令行编辑。这提供了一组函数,允许用户编辑命令行,以及用于将命令行保存为输入状态、回忆以前命令以及执行类似 csh 的历史记录扩展的函数。Bash 是 readline 的主要客户端,它们是共同开发的,但 readline 中没有特定于 bash 的代码。许多其他项目已经采用 readline 来提供基于终端的行编辑界面。

Readline 还允许用户将无限长度的键序列绑定到大量 readline 命令中的任何一个。Readline 包含用于在行周围移动光标、插入和删除文本、检索以前行以及完成部分输入的词的命令。在此基础上,用户可以定义宏,宏是响应键序列插入到行中的字符字符串,使用与键绑定相同的语法。宏为 readline 用户提供了一种简单的字符串替换和速记功能。

Readline 结构

Readline 的结构是一个基本的读/分发/执行/重新显示循环。它使用 read 或等效函数从键盘读取字符,或从宏获取输入。每个字符都用作键映射或分发表的索引。尽管由单个八位字符索引,但每个键映射元素的内容可以是多个东西。这些字符可以解析为其他键映射,这就是多字符键序列成为可能的方式。解析为 readline 命令(例如 beginning-of-line)会导致该命令被执行。绑定到 self-insert 命令的字符将存储到编辑缓冲区中。还可以将键序列绑定到命令,同时将子序列绑定到不同的命令(一个相对较新的功能);键映射中有一个特殊的索引来指示这一点。将键序列绑定到宏提供了很大的灵活性,从将任意字符串插入命令行到为复杂的编辑序列创建键盘快捷方式。Readline 将绑定到 self-insert 的每个字符存储在编辑缓冲区中,该缓冲区在显示时可能占用屏幕上的一行或多行。

Readline 仅管理使用 C char 的字符缓冲区和字符串,并在必要时将它们构建成多字节字符。出于速度和存储的原因,它在内部不使用 wchar_t,而且因为编辑代码是在多字节字符支持普及之前就存在的。在支持多字节字符的区域设置中,readline 会自动读取整个多字节字符并将其插入编辑缓冲区。将多字节字符绑定到编辑命令是可能的,但是必须将这样的字符绑定为一个键序列;这是可能的,但很困难,通常不需要。例如,现有的 emacs 和 vi 命令集不使用多字节字符。

一旦键序列最终解析为编辑命令,readline 就会更新终端显示以反映结果。无论命令是否导致字符被插入缓冲区、编辑位置被移动或行被部分或完全替换,都会发生这种情况。一些可绑定的编辑命令,例如修改历史文件的命令,不会对编辑缓冲区的内容造成任何更改。

更新终端显示虽然看似简单,但实际上却很复杂。Readline 必须跟踪三件事:当前显示在屏幕上的字符缓冲区的内容,该显示缓冲区的更新内容以及实际显示的字符。在存在多字节字符的情况下,显示的字符与缓冲区不完全匹配,重新显示引擎必须考虑这一点。在重新显示时,readline 必须将当前显示缓冲区的内容与更新的缓冲区进行比较,找出差异,并确定如何最有效地修改显示以反映更新的缓冲区。这个问题多年来一直是大量研究的主题(字符串到字符串的校正问题)。Readline 的方法是识别缓冲区中不同部分的开头和结尾,计算仅更新该部分的成本,包括将光标向后和向前移动(例如,发出终端命令来删除字符然后插入新字符是否比简单地覆盖当前屏幕内容更费力?),执行最低成本的更新,然后通过在必要时删除行尾的任何剩余字符来清理并定位光标在正确的位置。

重新显示引擎毫无疑问是 readline 中修改最频繁的部分。大多数更改都是为了添加功能——最重要的是,能够在提示符中使用非显示字符(例如,更改颜色)以及处理占用多个字节的字符的能力。

Readline 将编辑缓冲区的内容返回给调用应用程序,然后调用应用程序负责将可能已修改的结果保存在历史列表中。

扩展 Readline 的应用程序

正如 readline 为用户提供各种方法来自定义和扩展 readline 的默认行为一样,它还提供了一些机制供应用程序扩展其默认功能集。首先,可绑定的 readline 函数接受一组标准参数并返回一组指定的結果,使应用程序能够轻松地使用应用程序特定的函数扩展 readline。例如,Bash 添加了三十多个可绑定命令,从 bash 特定的词语补全到 shell 内置命令的接口。

readline 允许应用程序修改其行为的第二种方式是通过广泛使用指向具有已知名称和调用接口的挂钩函数的指针。应用程序可以替换 readline 内部的一些部分,在 readline 前插入功能,并执行应用程序特定的转换。

3.3.2. 非交互式输入处理

当 shell 不使用 readline 时,它使用 stdio 或它自己的缓冲输入例程来获取输入。当 shell 不处于交互状态时,bash 缓冲输入包比 stdio 更可取,因为 Posix 对输入使用施加了一些比较奇怪的限制:shell 必须仅使用解析命令所需的输入,并将剩余的输入留给执行的程序。当 shell 从标准输入读取脚本时,这一点尤其重要。shell 被允许尽可能多地缓冲输入,只要它能够将文件偏移量回滚到解析器使用的最后一个字符之后即可。在实践中,这意味着 shell 在从非可搜索设备(例如管道)读取时必须一次读取一个字符的脚本,但在从文件读取时可以缓冲任意数量的字符。

除了这些特性外,非交互式输入部分的 shell 处理的输出与 readline 相同:一个以换行符结尾的字符缓冲区。

3.3.3. 多字节字符

多字节字符处理是在 shell 初始实现很久之后才添加到 shell 的,并且它是以一种旨在最大程度地减少其对现有代码的影响的方式完成的。在支持多字节字符的区域设置中,shell 将其输入存储在字节缓冲区(C char)中,但将这些字节视为潜在的多字节字符。Readline 了解如何显示多字节字符(关键是知道一个多字节字符占据多少个屏幕位置,以及在屏幕上显示一个字符时从缓冲区中消耗多少个字节),如何在一行中一次向前和向后移动一个字符,而不是一次移动一个字节,等等。除此之外,多字节字符对 shell 输入处理的影响并不大。shell 的其他部分将在后面介绍,它们需要了解多字节字符并在处理输入时考虑它们。

3.4. 解析

解析引擎的初始工作是词法分析:将字符流分成单词,并将含义应用于结果。单词是解析器操作的基本单位。单词是由元字符分隔的字符序列,其中元字符包括空格和制表符等简单分隔符,或对 shell 语言有特殊意义的字符,例如分号和与号。

关于 shell 的一个历史问题,正如 Tom Duff 在他的关于 rc(Plan 9 shell)的论文中所说,没有人真正知道 Bourne shell 语法是什么。Posix shell 委员会在最终发布了 Unix shell 的明确语法方面功不可没,尽管它有很多上下文依赖关系。该语法并非没有问题——它不允许一些历史上的 Bourne shell 解析器在没有错误的情况下接受的构造——但它是我们所拥有的最好的语法。

bash 解析器源自 Posix 语法的早期版本,据我所知,它是唯一一个使用 Yacc 或 Bison 实现的 Bourne 风格的 shell 解析器。这带来了它自己的困难——shell 语法实际上并不适合 yacc 风格的解析,需要一些复杂的词法分析,并且解析器和词法分析器之间需要大量协作。

无论如何,词法分析器从 readline 或其他来源获取输入行,在元字符处将它们分解成标记,根据上下文识别标记,并将它们传递给解析器,以将它们组装成语句和命令。其中涉及大量上下文——例如,单词 for 可以是保留字、标识符、赋值语句的一部分或其他单词,以下是完全有效的命令

for for in for; do for=for; done; echo $for

它显示 for

在这一点上,需要简要讨论一下别名。Bash 允许使用别名将简单命令的第一个单词替换为任意文本。由于它们完全是词法的,因此别名甚至可以用来更改 shell 语法:可以编写一个别名,它实现了一个 bash 没有提供的复合命令。bash 解析器完全在词法阶段实现别名,尽管解析器必须在允许别名扩展时通知分析器。

与许多编程语言一样,shell 允许对字符进行转义,以消除其特殊含义,以便元字符(如 &)可以出现在命令中。有三种类型的引用,每种引用略有不同,并且允许对引用的文本进行略有不同的解释:反斜杠,它转义下一个字符;单引号,它阻止解释所有包含的字符;双引号,它阻止某些解释,但允许某些词语扩展(并且对反斜杠的处理方式不同)。词法分析器解释引用的字符和字符串,并防止解析器将它们识别为保留字或元字符。还有两种特殊情况,$'…'$"…",它们以与 ANSI C 字符串相同的方式扩展反斜杠转义的字符,并允许使用标准国际化函数分别翻译字符。前者被广泛使用;后者,可能是因为很少有好的例子或用例,使用较少。

解析器和词法分析器之间的其余接口很简单。解析器对一定量的状态进行编码,并与分析器共享,以允许语法所需的上下文相关分析。例如,词法分析器根据标记类型对单词进行分类:保留字(在适当的上下文中)、单词、赋值语句,等等。为了做到这一点,解析器必须告诉它解析命令的进度,它是否正在处理多行字符串(有时称为“here-document”),它是否在 case 语句或条件命令中,或者它是否正在处理扩展的 shell 模式或复合赋值语句。

在解析阶段识别命令替换结束的大部分工作都封装在单个函数(parse_comsub)中,该函数了解大量 shell 语法,并且复制了比最佳情况更多的标记读取代码。此函数必须了解 here 文档、shell 注释、元字符和词语边界、引用以及保留字何时可接受(因此它知道何时处于 case 语句中);花了相当一段时间才使它正常工作。

在词语扩展期间扩展命令替换时,bash 使用解析器来找到构造的正确结束位置。这类似于将字符串转换为 eval 的命令,但在此情况下,命令不是以字符串的结束位置为界。为了使这能够工作,解析器必须将右括号识别为有效的命令终止符,这会导致许多语法生成中的特殊情况,并且要求词法分析器将右括号(在适当的上下文中)标记为表示 EOF。解析器还必须在递归调用 yyparse 之前保存和恢复解析器状态,因为命令替换可以在读取命令过程中的提示字符串中间被解析和执行。由于输入函数实现了预读,因此此函数必须最后负责将 bash 输入指针回滚到正确的位置,无论 bash 是从字符串、文件还是使用 readline 的终端读取输入。这不仅对防止输入丢失很重要,而且对命令替换扩展函数构建正确的执行字符串也很重要。

可编程词语补全带来了类似的问题,它允许在解析另一个命令的同时执行任意命令,并且通过在调用周围保存和恢复解析器状态来解决。

引用也是导致不兼容和争论的来源。在第一个 Posix shell 标准发布 20 年后,标准工作组的成员仍在争论模糊引用的正确行为。与以前一样,Bourne shell 除了作为观察行为的参考实现之外,没有任何帮助。

解析器返回一个表示命令的单个 C 结构(在循环等复合命令的情况下,可能反过来包含其他命令),并将其传递给 shell 操作的下一阶段:词扩展。命令结构由命令对象和词列表组成。大多数词列表会根据其上下文进行各种转换,如以下部分所述。

3.5. 词扩展

在解析之后但执行之前,解析阶段产生的许多词会经过一个或多个词扩展,以便(例如)$OSTYPE 被替换为字符串 "linux-gnu"

3.5.1. 参数和变量扩展

变量扩展是用户最熟悉的扩展。shell 变量几乎没有类型,除了少数例外,都被视为字符串。扩展会扩展和转换这些字符串,生成新的词和词列表。

有些扩展会作用于变量的值本身。程序员可以使用它们来生成变量值的子字符串、值的长度、从开头或结尾删除与指定模式匹配的部分、用新字符串替换与指定模式匹配的值的部分,或修改变量值中字母字符的大小写。

此外,还有一些扩展取决于变量的状态:根据变量是否设置,会发生不同的扩展或赋值。例如,${parameter:-word} 如果设置了 parameter,则会扩展为 parameter;如果 parameter 未设置或设置为空字符串,则会扩展为 word

3.5.2. 还有更多

Bash 执行许多其他类型的扩展,每种扩展都有其独特的规则。处理顺序中第一个是花括号扩展,它将

pre{one,two,three}post

转换为

preonepost pretwopost prethreepost

还有命令替换,它是 shell 运行命令和操作变量能力的巧妙结合。shell 运行命令,收集输出,并将该输出用作扩展的值。

命令替换的问题之一是它会立即运行包含的命令并等待它完成:shell 没有简单的方法向它发送输入。Bash 使用名为进程替换的功能,它是一种命令替换和 shell 管道的组合,以弥补这些缺陷。与命令替换一样,bash 会运行命令,但让它在后台运行,并且不会等待它完成。关键是 bash 会打开一个管道供命令读取或写入,并将其作为文件名公开,该文件名成为扩展的结果。

接下来是波浪号扩展。最初旨在将 ~alan 转换为对 Alan 主目录的引用,多年来它已发展成为引用大量不同目录的一种方式。

最后,还有算术扩展。$((expression)) 会根据与 C 语言表达式相同的规则对 expression 进行求值。表达式的结果成为扩展的结果。

变量扩展是单引号和双引号之间的区别最明显的体现。单引号会抑制所有扩展,引号中的字符会原封不动地传递给扩展,而双引号会允许一些扩展,并抑制其他扩展。词扩展以及命令、算术和进程替换会进行,双引号只影响结果的处理方式,而花括号和波浪号扩展则不会进行。

3.5.3. 词分割

词扩展的结果使用 shell 变量 IFS 的值中的字符作为分隔符进行分割。这是 shell 将单个词转换为多个词的方式。每当 $IFS1 中的一个字符出现在结果中时,bash 就会将该词分割成两个。单引号和双引号都会抑制词分割。

3.5.4. 通配符扩展

在分割结果之后,shell 会将前一次扩展产生的每个词解释为潜在的模式,并尝试将其与现有文件名(包括任何前导目录路径)进行匹配。

3.5.5. 实现

如果 shell 的基本架构与管道相似,那么词扩展本身就是一个小型管道。词扩展的每个阶段都接受一个词,在可能对其进行转换之后,将其传递给下一个扩展阶段。在执行完所有词扩展后,就会执行该命令。

Bash 对词扩展的实现建立在已经描述的基本数据结构之上。解析器输出的词会单独扩展,每个输入词会产生一个或多个词。WORD_DESC 数据结构已被证明足够灵活,可以保存封装单个词扩展所需的所有信息。标志用于编码在词扩展阶段使用和从一个阶段传递到下一个阶段的信息。例如,解析器使用一个标志来告诉扩展和命令执行阶段某个词是一个 shell 赋值语句,而词扩展代码在内部使用标志来抑制词分割或记录引号中的空字符串 ("$x",其中 $x 未设置或值为 null) 的存在。对于每个正在扩展的词,使用一个单字符字符串,并使用某种字符编码来表示其他信息,将非常困难。

与解析器一样,词扩展代码也处理表示需要多个字节的字符。例如,可变长度扩展 (${#variable}) 会以字符而不是字节为单位计算长度,并且代码能够在存在多字节字符的情况下正确识别扩展的结尾或扩展中特殊的字符。

3.6. 命令执行

内部 bash 管道的命令执行阶段是真正开始行动的地方。大多数情况下,扩展后的词集会被分解成一个命令名称和一组参数,并作为要读取和执行的文件传递给操作系统,其余词作为 argv 的其余元素传递。

迄今为止的描述有意集中在 Posix 所谓的简单命令上,即那些带有命令名称和一组参数的命令。这是最常见的命令类型,但 bash 提供了更多功能。

命令执行阶段的输入是解析器构建的命令结构和一组可能已扩展的词。这是真正 bash 编程语言发挥作用的地方。编程语言使用前面讨论过的变量和扩展,并实现人们在高级语言中预期的结构:循环、条件语句、交替、分组、选择、基于模式匹配的条件执行、表达式求值,以及一些特定于 shell 的更高级的结构。

3.6.1. 重定向

shell 作为操作系统接口的作用之一是能够将输入和输出重定向到它调用的命令,或从这些命令重定向输入和输出。重定向语法是揭示 shell 早期用户精明之处的内容之一:直到最近,它才要求用户跟踪他们使用的文件描述符,并明确指定除标准输入、输出和错误之外的任何其他文件描述符。

重定向语法最近的添加允许用户指示 shell 选择合适的文件描述符并将其分配给指定的变量,而不是让用户选择一个文件描述符。这减轻了程序员跟踪文件描述符的负担,但增加了额外的处理:shell 必须在正确的位置复制文件描述符,并确保它们被分配给指定的变量。这是另一个关于信息如何从词法分析器传递给解析器,再传递给命令执行的示例:分析器将该词分类为包含变量赋值的重定向;解析器在适当的语法产生式中创建重定向对象,并带有指示需要赋值的标志;而重定向代码会解释该标志并确保文件描述符编号被分配给正确的变量。

实现重定向最困难的部分是记住如何撤销重定向。shell 有意模糊了从文件系统执行导致创建新进程的命令和 shell 本身执行的命令(内置命令)之间的区别,但无论命令如何实现,重定向的效果都不应在命令完成之后持续存在2。因此,shell 必须跟踪如何撤销每个重定向的效果,否则重定向 shell 内置命令的输出将会改变 shell 的标准输出。Bash 知道如何撤销每种类型的重定向,方法是关闭它分配的文件描述符,或者保存要复制到的文件描述符,并在以后使用 dup2 恢复它。这些使用与解析器创建的重定向对象相同,并使用相同的函数进行处理。

由于多个重定向被实现为简单的对象列表,因此用于撤销的重定向保存在一个单独的列表中。当命令完成时,会处理该列表,但 shell 在处理时必须小心,因为附加到 shell 函数或 "." 内置命令的重定向必须保持有效,直到该函数或内置命令完成。当它不调用命令时,exec 内置命令会导致撤销列表被简单地丢弃,因为与 exec 关联的重定向会持续存在于 shell 环境中。

另一个复杂之处是 bash 自己造成的。Bourne shell 的历史版本允许用户只操作文件描述符 0-9,保留文件描述符 10 及以上供 shell 内部使用。Bash 放宽了此限制,允许用户操作任何文件描述符,直到进程的打开文件限制。这意味着 bash 必须跟踪它自己的内部文件描述符,包括由外部库打开而不是由 shell 直接打开的文件描述符,并准备根据需要移动它们。这需要大量的簿记工作,以及一些关于 close-on-exec 标志的启发式算法,以及另一个重定向列表,需要为命令持续时间维护,然后进行处理或丢弃。

3.6.2. 内置命令

Bash 将一些命令作为 shell 本身的一部分。这些命令由 shell 执行,不会创建新进程。

将命令设置为内置命令的最常见原因是维护或修改 shell 的内部状态。cd 就是一个很好的例子;Unix 入门课程的经典练习之一是解释为什么 cd 不能作为外部命令实现。

Bash 内置命令使用与 shell 其他部分相同的内部原语。每个内置命令都使用一个 C 语言函数实现,该函数接受一个词列表作为参数。这些词是由词扩展阶段输出的;内置命令将它们视为命令名称和参数。在大多数情况下,内置命令使用与任何其他命令相同的标准扩展规则,但也有一些例外:接受赋值语句作为参数的 bash 内置命令(例如 declareexport)对赋值参数使用与 shell 对变量赋值使用的相同的扩展规则。这是 WORD_DESC 结构的 flags 成员用于在 shell 内部管道的多个阶段之间传递信息的其中一个地方。

3.6.3. 简单命令执行

简单命令是最常见的命令。从文件系统读取命令并搜索和执行它们,以及收集它们的退出状态,涵盖了 shell 的许多剩余功能。

Shell 变量赋值(例如,形如 var=value 的单词)本身就是一种简单的命令。赋值语句可以放在命令名前面,也可以单独出现在命令行上。如果放在命令名前面,则将变量传递到执行命令的环境中(如果放在内置命令或 Shell 函数前面,它们会保留在内置或函数执行期间,除了少数例外)。如果后面没有命令名,则赋值语句将修改 Shell 的状态。

当遇到一个不是 Shell 函数或内置命令名称的命令名时,bash 会在文件系统中搜索具有该名称的可执行文件。PATH 变量的值用作一个冒号分隔的目录列表,在这些目录中进行搜索。包含斜杠(或其他目录分隔符)的命令名不会被查找,而是直接执行。

当使用 PATH 搜索找到一个命令时,bash 会将命令名和对应的完整路径名保存到一个哈希表中,并在进行后续 PATH 搜索之前查阅该哈希表。如果找不到命令,bash 会执行一个特殊命名的函数(如果定义了),并将命令名和参数作为函数的参数。一些 Linux 发行版使用此功能来提供安装缺少的命令。

如果 bash 找到要执行的文件,它会进行 fork 并创建一个新的执行环境,然后在这个新的环境中执行程序。执行环境是 Shell 环境的精确副本,对信号处理、重定向打开和关闭的文件等方面进行了一些细微的修改。

3.6.4. 作业控制

Shell 可以执行前台命令,在这种情况下,它会等待命令完成并收集其退出状态;也可以执行后台命令,在这种情况下,Shell 会立即读取下一条命令。作业控制是指能够在后台和前台之间移动进程(正在执行的命令)的能力,以及暂停和恢复其执行的能力。为了实现这一点,bash 引入了作业的概念,作业本质上是指一个或多个进程正在执行的命令。例如,管道对于其每个元素使用一个进程。进程组是一种将多个独立进程组合成一个作业的方法。终端有一个与之关联的进程组 ID,因此前台进程组是进程组 ID 与终端相同的进程组。

Shell 在作业控制实现中使用了一些简单的数据结构。有一个结构用于表示子进程,包括其进程 ID、其状态以及它在终止时返回的状态。管道只是一些这样的进程结构的简单链表。作业非常类似:有一个进程列表、一些作业状态(正在运行、暂停、退出等)以及作业的进程组 ID。进程列表通常只包含一个进程;只有管道会导致多个进程与一个作业关联。每个作业都有一个唯一的进程组 ID,作业中进程 ID 与作业进程组 ID 相同的进程称为进程组领导者。当前的作业集保存在一个数组中,从概念上来说,它与向用户呈现的方式非常相似。通过聚合组成进程的状态和退出状态,可以组装作业的状态和退出状态。

与 Shell 中的许多其他内容一样,实现作业控制的复杂之处在于簿记。Shell 必须小心地将进程分配到正确的进程组,确保子进程创建和进程组分配同步,以及终端的进程组设置得当,因为终端的进程组决定前台作业(如果它没有设置回 Shell 的进程组,那么 Shell 本身将无法读取终端输入)。由于它是如此面向进程的,因此实现 whilefor 循环等复合命令并不容易,这样整个循环就可以作为一个单元停止和启动,而且很少有 Shell 能够做到这一点。

3.6.5. 复合命令

复合命令由一个或多个简单命令列表组成,由一个关键字(如 ifwhile)引入。这是 Shell 的编程能力最明显和最有效的地方。

实现方式相当直观。解析器会构建与各种复合命令相对应的对象,并通过遍历对象来解释它们。每个复合命令都由一个相应的 C 函数实现,该函数负责执行适当的扩展、按照指定的方式执行命令,并根据命令的返回状态改变执行流程。实现 for 命令的函数说明了这一点。它首先必须扩展 in 保留字后面的单词列表。然后,该函数必须遍历扩展后的单词,将每个单词分配给相应的变量,然后执行 for 命令体中的命令列表。for 命令不必根据命令的返回状态来改变执行流程,但它必须注意 breakcontinue 内置命令的影响。一旦列表中的所有单词都被使用,for 命令就会返回。正如所示,在大多数情况下,实现方式与描述非常一致。

3.7. 教训

3.7.1. 我发现的重要内容

我在 bash 上工作了二十多年,我认为我发现了一些东西。最重要的是,我不能过分强调这一点,那就是拥有详细的更改日志至关重要。当你能够回到你的更改日志并提醒自己为什么进行了特定的更改时,这是件好事。当你能够将该更改与一个特定的错误报告联系起来,并提供可重复的测试用例或建议时,那就更好了。

如果合适,我建议从一开始就将广泛的回归测试构建到项目中。bash 有数千个测试用例,涵盖了几乎所有非交互式功能。我考虑过为交互式功能构建测试——Posix 在其一致性测试套件中包含了这些测试——但我不想不得不分发我认为它需要的框架。

标准很重要。bash 作为标准的实现而受益。参与你正在实施的软件的标准化非常重要。除了关于功能及其行为的讨论之外,将标准作为仲裁者来参考也可以很好地发挥作用。当然,它也可能发挥不好的作用——这取决于标准。

外部标准很重要,但拥有内部标准也很重要。我幸运地落入了 GNU 项目的标准集,它提供了关于设计和实现的许多良好、实用的建议。

良好的文档是另一个必不可少的要素。如果你希望一个程序被其他人使用,那么编写全面、清晰的文档是值得的。如果软件成功,最终会为它编写大量文档,重要的是开发人员编写权威版本。

有很多好的软件。尽可能地使用:例如,gnulib 拥有许多方便的库函数(一旦你能够从 gnulib 框架中解开它们)。BSD 和 Mac OS X 也是如此。毕加索说“伟大的艺术家偷窃”是有原因的。

与用户社区互动,但要做好偶尔受到批评的准备,其中一些批评会让你感到困惑。活跃的用户社区可能是一个巨大的优势,但随之而来的是人们会变得非常热情。不要把它当作个人攻击。

3.7.2. 我会做得不同的地方

bash 有数百万用户。我已经了解了向后兼容性的重要性。从某种意义上说,向后兼容性意味着永远不必说对不起。然而,世界并没有那么简单。我不得不时不时地进行一些不兼容的更改,几乎所有这些更改都引起了不同程度的用户投诉,尽管我总是认为自己有充分的理由,无论是纠正错误的决定、修复设计缺陷,还是纠正 Shell 各部分之间的不兼容性。我本应该早些时候引入类似正式的 bash 兼容性级别。

bash 的开发从未特别开放。我已经习惯了里程碑式发布(例如,bash-4.2)和单独发布的补丁。这样做有原因:我满足了那些发布周期比自由软件和开源世界更长的供应商的需求,而且我过去曾经遇到过 beta 软件比我希望的更广泛传播的问题。如果我必须重新开始,我会考虑更频繁的发布,使用某种公共仓库。

任何这样的列表都无法完整,没有实现上的考虑。我多次考虑过,但从未做过的一件事是用直接的递归下降重写 bash 解析器,而不是使用 bison。我曾经认为我必须这样做才能使命令替换符合 Posix,但我能够解决这个问题,无需进行如此广泛的更改。如果我从头开始编写 bash,我可能就会手工编写一个解析器。这肯定会让一些事情变得更容易。

3.8. 结论

bash 是一个大型复杂自由软件的很好例子。它已经从二十多年的开发中获益,并且已经成熟和强大。它几乎在任何地方运行,每天被数百万用户使用,其中许多人没有意识到这一点。

bash 受到许多来源的影响,可以追溯到由 Stephen Bourne 编写的最初的第 7 版 Unix Shell。最重大的影响是 Posix 标准,它规定了其行为的很大一部分。向后兼容性和标准符合性的这种结合带来了其自身的挑战。

bash 因成为 GNU 项目的一部分而获益,该项目提供了一个 bash 存在的运动和框架。没有 GNU,就不会有 bash。bash 也从其活跃、充满活力的用户社区中受益。他们的反馈帮助 bash 成为今天的它——证明了自由软件的好处。

脚注

  1. 在大多数情况下,一个字符的序列。
  2. exec 内置命令是此规则的一个例外。