第11章:Shell脚本入门

如果你能向 shell 输入命令,那么你就能编写 shell 脚本。shell 脚本(也称为 Bourne shell 脚本)是一系列写在文件中的命令;shell 会从文件中读取这些命令,就像你在终端中键入它们一样。

11.1 Shell 脚本基础

Bourne shell 脚本通常以下面这一行开头,它指明 /bin/sh 程序应执行脚本文件中的命令。(确保脚本文件开头没有空白字符。)

#!/bin/sh

#! 部分称为 shebang;你在本书的其他脚本中也会见到它。你可以将希望 shell 执行的任何命令列在 #!/bin/sh 行之后。例如:

#!/bin/sh
#
# 打印一些内容,然后运行 ls
echo About to run the ls command.
ls

NOTE

除了脚本顶部的 shebang 之外,行首的 # 字符表示注释;也就是说,shell 会忽略该行中 # 之后的所有内容。请使用注释来解释脚本中他人阅读时可能难以理解的部分,或者供你日后回顾代码时提醒自己。

与 Unix 系统上的任何程序一样,你需要为 shell 脚本文件设置可执行位,但还必须设置读取位,以便 shell 能够读取该文件。最简单的方法如下:

$ chmod +rx script

chmod 命令允许其他用户读取和执行 script。如果你不希望这样,可以使用绝对模式 700(相关权限知识回顾请参考第 2.17 节)。

创建 shell 脚本并设置好读取和执行权限后,你可以将脚本文件放在命令路径中的某个目录下,然后在命令行上运行该脚本名称。如果脚本位于当前工作目录中,你也可以运行 ./script,或者使用完整路径名。

使用 shebang 运行脚本与直接使用 shell 运行命令几乎(但不完全)相同;例如,运行名为 myscript 的脚本会使内核执行 /bin/sh myscript

了解了基础知识之后,我们来谈谈 shell 脚本的一些局限性。

NOTE

shebang 不一定是 #!/bin/sh;它可以指向你系统上任何接受脚本输入的程序,例如 #!/usr/bin/python 用于运行 Python 程序。另外,你可能还会遇到包含 /usr/bin/env 的不同模式的脚本。例如,你可能会看到类似 #!/usr/bin/env python 作为第一行。这指示 env 工具来运行 python。原因很简单:env 会在当前命令路径中查找要运行的程序,因此你不需要指定可执行文件的标准化位置。缺点是命令路径中第一个匹配的可执行文件可能不是你想要的。

11.1.1 Shell 脚本的局限性

Bourne shell 可以相对轻松地操作命令和文件。在第 2.14 节中,你看到了 shell 进行输出重定向的方式,这是 shell 脚本编程的重要元素之一。然而,shell 脚本只是 Unix 编程的一种工具,虽然脚本功能强大,但也有一些局限性。

Shell 脚本的主要优势之一是,它可以简化和自动化你原本可以在 shell 提示符下执行的任务,例如批量操作文件。但是,如果你要拆分字符串、执行重复的算术计算、访问复杂数据库,或者需要函数和复杂的控制结构,那么最好使用像 Python、Perl 或 awk 这样的脚本语言,甚至可能是像 C 这样的编译语言。(这很重要,因此你会在本章中反复看到这一点。)

最后,要注意你的 shell 脚本大小。保持 shell 脚本简短。Bourne shell 脚本本不设计为大型程序,不过你无疑会遇到一些庞然大物。

11.2 引号与字面量

使用 shell 和脚本时最令人困惑的元素之一是,知道何时以及为何要使用引号和其他标点符号。假设你想打印字符串 $100,你这样做:

$ echo $100
00

为什么打印出 00?因为 $1 带有 $ 前缀,shell 会将其解释为一个 shell 变量(我们很快就会讲到)。你心想,也许用双引号括起来,shell 就会忽略 $1

$ echo "$100"
00

仍然没用。你询问一个朋友,朋友说你需要使用单引号:

$ echo '$100'
$100

为什么这个特定的咒语有效?

11.2.1 字面量

当你使用引号时,你通常是在尝试创建一个字面量,即一个 shell 在传递给命令行之前不应分析(或尝试修改)的字符串。除了刚才示例中的 $,这种情况还经常出现在你想向 grep 等命令传递 * 字符(而不是让 shell 展开它),以及需要在命令中使用分号 (;) 的时候。

编写脚本以及在命令行上工作时,请记住 shell 运行命令时发生的事情:

  1. 在运行命令之前,shell 会查找变量、通配符和其他替换,如果存在则执行替换。
  2. shell 将替换后的结果传递给命令。

涉及字面量的问题可能很微妙。假设你想在 /etc/passwd 中查找所有匹配正则表达式 r.*t 的条目(即包含一个 r 后面跟着一个 t 的行,这样可以搜索像 rootruthrobot 这样的用户名)。你可以运行以下命令:

$ grep r.*t /etc/passwd

大多数情况下它都能正常工作,但有时会神秘地失败。为什么?答案可能就在你的当前目录中。如果该目录包含像 r.inputr.output 这样的文件名,那么 shell 会将 r.*t 展开为 r.input r.output,从而创建如下命令:

$ grep r.input r.output /etc/passwd

避免此类问题的关键是,首先识别哪些字符可能让你陷入麻烦,然后应用正确类型的引号来保护这些字符。

11.2.2 单引号

创建字面量并让 shell 保持字符串原样的最简单方法,是将整个字符串用单引号 (') 括起来,例如下面这个包含 * 字符的 grep 示例:

$ grep 'r.*t' /etc/passwd

就 shell 而言,两个单引号之间的所有字符(包括空格)构成一个单一参数。因此,下面的命令不起作用,因为它要求 grep 命令在标准输入中搜索字符串 r.*t /etc/passwd(因为 grep 只有一个参数):

$ grep 'r.*t /etc/passwd'

当你需要使用字面量时,应该总是首先考虑单引号,因为可以保证 shell 不会尝试任何替换。因此,这是一种通常很清晰的语法。不过,有时你需要更多的灵活性,这时可以使用双引号。

11.2.3 双引号

双引号 (") 的工作原理与单引号类似,只是 shell 会展开双引号内出现的任何变量。你可以通过运行以下命令,然后将双引号替换为单引号再次运行,来观察区别。

$ echo "There is no * in my path: $PATH"

运行该命令时,注意 shell 会替换 $PATH,但不会替换 *

NOTE

如果使用双引号处理大量文本,请考虑使用 here 文档,如第 11.9 节所述。

11.2.4 字面单引号

在使用 Bourne shell 时,向命令传递一个字面单引号可能会比较棘手。一种方法是在单引号字符前放置一个反斜杠:

$ echo I don\'t like contractions inside shell scripts.

反斜杠和引号必须出现在任何单引号对之外。像 'don\'t 这样的字符串会导致语法错误。奇怪的是,你可以将单引号放在双引号内,如下例所示(输出与前面的命令相同):

$ echo "I don't like contractions inside shell scripts."

如果你陷入困境,需要一个通用规则来引用整个字符串且不进行任何替换,请遵循以下步骤:

  1. 将所有 '(单引号)替换为 '\''(单引号、反斜杠、单引号、单引号)。
  2. 将整个字符串括在单引号中。

因此,你可以像下面这样引用一个笨拙的字符串 this isn't a forward slash: \

$ echo 'this isn'\''t a forward slash: \'

NOTE

值得重申的是:当你引用一个字符串时,shell 会将引号内的所有内容视为一个单一参数。因此,a b c 计为三个参数,但 a "b c" 只有两个。

11.3 特殊变量

大多数 shell 脚本都能理解命令行参数,并与它们运行的命令进行交互。要将脚本从简单的命令列表转变为更灵活的 shell 脚本程序,你需要知道如何使用 Bourne shell 的特殊变量。这些特殊变量与第 2.8 节中描述的任何其他 shell 变量类似,只是你不能更改某些变量的值。

NOTE

阅读完后面几节后,你就会明白为什么 shell 脚本在编写时会积累大量特殊字符。如果你正在尝试理解一个 shell 脚本,看到一行完全无法理解的代码,请逐部分分解它。

11.3.1 单个参数:$1$2

$1$2 以及所有名称是正非零整数的变量,都包含脚本的参数(也称为位置参数)的值。例如,假设以下脚本的名称为 pshow

#!/bin/sh
echo First argument: $1
echo Third argument: $3

尝试按如下方式运行该脚本,看看它是如何打印参数的:

$ ./pshow one two three
First argument: one
Third argument: three

内置的 shell 命令 shift 可以与参数变量一起使用,用来移除第一个参数 ($1) 并将剩余参数向前移动,使 $2 变为 $1$3 变为 $2,以此类推。例如,假设以下脚本的名称为 shiftex

#!/bin/sh
echo Argument: $1 
shift
echo Argument: $1
shift
echo Argument: $1

像这样运行它来查看效果:

$ ./shiftex one two three 
Argument: one
Argument: two
Argument: three

如你所见,shiftex 通过先打印第一个参数、移动剩余参数并重复此过程,打印出了所有三个参数。

11.3.2 参数个数:$#

$# 变量保存传递给脚本的参数个数,在循环中使用 shift 逐个处理参数时尤其重要。当 $# 为 0 时,表示没有剩余参数,因此 $1 为空(参见 11.6 节关于循环的描述)。

11.3.3 所有参数:$@

$@ 变量代表脚本的所有参数,对于在脚本内部将这些参数传递给某个命令非常有用。例如,Ghostscript 命令 (gs) 通常又长又复杂。假设你想要一个快捷方式,以 150 dpi 对 PostScript 文件进行光栅化,使用标准输出流,同时留出向 gs 传递其他选项的空间。你可以编写如下脚本来支持额外的命令行选项:

#!/bin/sh
gs -q -dBATCH -dNOPAUSE -dSAFER -sOutputFile=- -sDEVICE=pnmraw $@

11. 第11章:Shell脚本入门

NOTE

如果Shell脚本中的某一行过长,在文本编辑器中难以阅读和操作,可以用反斜杠(\)将其拆分。例如,可以将前面的脚本修改如下:

#!/bin/sh
gs -q -dBATCH -dNOPAUSE -dSAFER \
   -sOutputFile=- -sDEVICE=pnmraw $@

11.3.4 脚本名称:$0

$0 变量保存脚本的名称,常用于生成诊断消息。例如,假设脚本需要报告存储在 $BADPARM 变量中的无效参数,可以这样输出诊断消息,使错误信息中包含脚本名称:

echo $0: bad option $BADPARM

所有诊断错误消息都应发送到标准错误输出。如第2.14.1节所述,2>&1 将标准错误重定向到标准输出。若要将错误写入标准错误,可以反过来使用 1>&2。对于上面的例子,可以这样写:

echo $0: bad option $BADPARM 1>&2

[Chapter 11, page 298]

11.3.5 进程ID:$$

$$ 变量保存Shell的进程ID。

11.3.6 退出码:$?

$? 变量保存Shell执行的最后一条命令的退出码。退出码对于掌握Shell脚本至关重要,将在下一节讨论。

11.4 退出码

Unix程序执行完毕后,会留下一个退出码(也称为错误码或退出值),供启动该程序的父进程使用。退出码为0通常表示程序运行无误;但如果程序出错,通常退出码为非0(但并非总是如此,下文会看到)。

Shell将最后一条命令的退出码保存在特殊变量 $? 中,因此可以在Shell提示符下检查:

$ ls / > /dev/null
$ echo $?
0
$ ls /asdfasdf > /dev/null
ls: /asdfasdf: No such file or directory
$ echo $?
1

可以看到,成功命令返回0,不成功命令返回1(前提是系统中没有名为 /asdfasdf 的目录)。

如果要使用命令的退出码,必须在运行该命令后立即使用或存储它(因为运行的下一条命令会覆盖先前的退出码)。例如,连续运行两次 echo $?,第二次的输出总是0,因为第一次的 echo 命令成功完成。

编写Shell代码时,可能会遇到脚本因错误(如文件名错误)需要停止的情况。在脚本中使用 exit 1 可以终止并将退出码1返回给运行脚本的父进程。(如果脚本有多种异常退出情况,也可以使用不同的非零数字。)

注意,某些程序(如 diffgrep)使用非零退出码表示正常情况。例如,grep 在找到匹配模式的内容时返回0,未找到时返回1。对于这些程序,退出码1不是错误,因此 grepdiff 在遇到实际问题时使用退出码2。如果认为某个程序可能使用非零退出码表示成功,请阅读其手册页。退出码通常会在 EXIT VALUEDIAGNOSTICS 部分中说明。

[Chapter 11, page 299]

11.5 条件语句

Bourne Shell具有专门的条件结构,包括 if/then/elsecase 语句。例如,下面这个包含 if 条件的简单脚本检查脚本的第一个参数是否为 hi

#!/bin/sh
if [ $1 = hi ]; then
   echo 'The first argument was "hi"'
else
   echo -n 'The first argument was not "hi" -- '
   echo It was '"'$1'"'
fi

脚本中的 ifthenelsefi 是Shell关键字;其他都是命令。这个区分非常重要,因为很容易将条件 [ $1 = "hi" ] 误认为是特殊的Shell语法。实际上,[ 字符是Unix系统上的一个实际程序。所有Unix系统都有一个名为 [ 的命令,用于Shell脚本条件测试。该程序也称为 testtest[ 的手册页相同。(稍后会了解到,Shell并不总是运行 [,但目前可以将其视为一个单独的命令。)

理解第11.4节中的退出码在这里至关重要。来看前面的脚本实际上是如何工作的:

  1. Shell运行 if 关键字后面的命令,并收集该命令的退出码。
  2. 如果退出码为0,Shell执行 then 关键字后面的命令,直到遇到 elsefi 关键字为止。
  3. 如果退出码非0并且存在 else 子句,Shell执行 else 关键字后面的命令。
  4. 条件在 fi 处结束。

我们已经确定 if 后面的测试是一个命令,现在来看分号(;)。它只是Shell中命令结束的常规标记,因为我们将 then 关键字放在了同一行。如果没有分号,Shell会将 then 作为参数传递给 [ 命令,这通常会导致难以追踪的错误。也可以通过将 then 放在单独行来避免分号:

if [ $1 = hi ]
then
   echo 'The first argument was "hi"'
fi

11.5.1 空参数列表的解决方法

前面的例子中的条件存在一个潜在问题,因为一个常被忽视的场景:$1 可能为空(用户可能不带参数运行脚本)。如果 $1 为空,测试将变为 [ = hi ][ 命令会因错误而终止。可以通过将参数用引号括起来来解决,常见方法有两种:

if [ "$1" = hi ]; then
if [ x"$1" = x"hi" ]; then

[Chapter 11, page 300]

11.5.2 用于测试的其他命令

除了 [ 之外,还可以使用其他命令进行测试。以下是一个使用 grep 的例子:

#!/bin/sh
if grep -q daemon /etc/passwd; then
    echo The daemon user is in the passwd file.
else
    echo There is a big problem. daemon is not in the passwd file.
fi

11.5.3 elif

还有 elif 关键字,可以让你串联多个 if 条件。示例如下:

#!/bin/sh
if [ "$1" = "hi" ]; then
   echo 'The first argument was "hi"'
elif [ "$2" = "bye" ]; then
   echo 'The second argument was "bye"'
else
   echo -n 'The first argument was not "hi" and the second was not "bye"-- '
   echo They were '"'$1'"' and '"'$2'"'
fi

请记住,控制流只会经过第一个成功的条件分支。因此,如果使用参数 hi bye 运行此脚本,只会得到关于 hi 参数的确认。

NOTE

不要过度使用 elif,因为 case 结构(见第11.5.6节)通常更合适。

11.5.4 逻辑结构

有两种快捷的单行条件结构,有时会用到:&&(“与”)和 ||(“或”)语法。&& 结构的工作方式如下:

command1 && command2

[Chapter 11, page 301]

这里,Shell先运行 command1,如果退出码为0,Shell再运行 command2

|| 结构类似:如果 || 之前的命令返回非零退出码,Shell运行第二个命令。

&&|| 常用于 if 测试中。在这两种情况下,最后运行的命令的退出码决定了Shell如何处理条件。对于 && 结构,如果第一个命令失败,Shell将其退出码用于 if 语句;但如果第一个命令成功,Shell使用第二个命令的退出码作为条件。对于 || 结构,如果第一个命令成功,Shell使用其退出码;如果第一个不成功,则使用第二个命令的退出码。

例如:

#!/bin/sh
if [ "$1" = hi ] || [ "$1" = bye ]; then
    echo 'The first argument was "'$1'"'
fi

如果条件中包含 test 命令([)如所示,也可以使用 -a-o 代替 &&||。例如:

#!/bin/sh
if [ "$1" = hi  -o "$1" = bye ]; then
   echo 'The first argument was "'$1'"'
fi

可以通过在测试前放置 ! 运算符来反转测试(即逻辑非)。例如:

#!/bin/sh
if [ ! "$1" = hi  ]; then
   echo 'The first argument was not hi'
fi

在这种特定的比较情况下,也可以使用 != 作为替代,但 ! 可以与下一节描述的任何条件测试一起使用。

11.5.5 条件测试

你已经了解了 [ 的工作方式:测试为真时退出码为0,测试失败时为非零。你还知道如何用 [ str1 = str2 ] 测试字符串相等性。不过,请记住Shell脚本非常适合对整个文件进行操作,因为许多有用的 [ 测试涉及文件属性。例如,下面这行检查 file 是否为常规文件(不是目录或特殊文件):

[ -f file ]

[Chapter 11, page 302]

在脚本中,你可能会看到 -f 测试用于类似这样的循环中,该循环测试当前工作目录中的所有项目(关于循环的更多内容将在第11.6节介绍):

for filename in *; do
    if [ -f $filename ]; then
        ls -l $filename
        file $filename
    else
        echo $filename is not a regular file.
    fi
done  

NOTE

由于 test 命令在脚本中广泛使用,许多版本的Bourne Shell(包括 bash)都内置了它。这可以加快脚本速度,因为Shell不必为每个测试运行单独的命令。

有几十种测试操作,全部属于三大类:文件测试、字符串测试和算术测试。info 手册包含完整的在线文档,但 test(1) 手册页是快速参考。以下各节概述了主要测试(省略了一些不太常用的测试)。

文件测试

大多数文件测试(如 -f)被称为一元操作,因为它们只需要一个参数:要测试的文件。例如,两个重要的文件测试:

  • -e:如果文件存在则返回真
  • -s:如果文件不为空则返回真

几个操作检查文件类型,即它们可以判断某对象是常规文件、目录还是某种特殊设备,如表11-1所示。还有一些一元操作用于检查文件权限,如表11-2所示(关于权限的概述见第2.17节)。

表11-1:文件类型运算符

运算符测试内容
-f常规文件
-d目录
-h符号链接
-b块设备
-c字符设备
-p命名管道
-S套接字

[Chapter 11, page 303]

NOTE

如果 test 命令用于符号链接,它测试的是链接所指向的实际对象,而不是链接本身(-h 测试除外)。也就是说,如果 link 是一个指向常规文件的符号链接,那么 [ -f link ] 返回的退出码为真(0)。

表 11-2:文件权限操作符

操作符权限
-r可读
-w可写
-x可执行
-u设置用户 ID
-g设置组 ID
-k“粘滞位”

最后,文件测试中还使用了三个二元操作符(需要两个文件作为参数的测试),但它们并不常见。考虑以下命令,其中包含 -nt(“newer than”,比……新):

[ file1 -nt file2 ]

如果 file1 的修改日期比 file2 新,则此命令退出为真。-ot(“older than”,比……旧)操作符则相反。如果你需要检测相同的硬链接,-ef 会比较两个文件,如果它们共享 inode 号码和设备,则返回真。

字符串测试

你已经见过二元字符串操作符 =,如果操作数相等则返回真;以及 != 操作符,如果操作数不相等则返回真。还有两个额外的一元字符串操作:

  • -z 如果其参数为空则返回真([ -z "" ] 返回 0)
  • -n 如果其参数非空则返回真([ -n "" ] 返回 1)

算术测试

注意等号(=)检查的是字符串相等性,而不是数值相等性。因此,[ 1 = 1 ] 返回 0(真),但 [ 01 = 1 ] 返回假。当处理数字时,应使用 -eq 而不是等号:[ 01 -eq 1 ] 返回真。表 11-3 提供了完整的数值比较操作符列表。

表 11-3:算术比较操作符

操作符第一个参数相对于第二个参数为真时
-eq等于
-ne不等于
-lt小于
-gt大于
-le小于或等于
-ge大于或等于

11.5.6 case

case 关键字形成了另一种条件结构,对于匹配字符串特别有用。它不执行任何测试命令,因此不评估退出码。然而,它可以进行模式匹配。以下示例说明了大部分内容:

#!/bin/sh
case $1 in
    bye)
        echo Fine, bye.
        ;;
    hi|hello)
        echo Nice to see you.
        ;;
    what*)
        echo Whatever.
        ;;
    *)
        echo 'Huh?'
        ;;
esac

shell 的执行过程如下:

  1. 脚本将 $1 与每个以 ) 字符分隔的 case 值进行匹配。
  2. 如果某个 case 值与 $1 匹配,shell 执行该 case 下方的命令,直到遇到 ;;,此时它会跳转到 esac 关键字。
  3. 条件以 esac 结束。

对于每个 case 值,你可以匹配单个字符串(如前面例子中的 bye),或使用 | 匹配多个字符串(hi|hello$1 等于 hihello 时返回真),也可以使用 *? 模式(what*)。要创建一个默认的 case 来捕获除指定 case 值之外的所有可能值,请使用单个 *,如前面例子中的最后一个 case 所示。

NOTE

每个 case 以双分号(;;)结束,以避免可能的语法错误。

11.6 循环

Bourne shell 中有两种循环:for 循环和 while 循环。

11.6.1 for 循环

for 循环(是一种“for each”循环)是最常见的。下面是一个例子:

#!/bin/sh
for str in one two three four; do
    echo $str
done

在这个列表中,forindodone 都是 shell 关键字。shell 执行以下操作:

  1. 将变量 str 设置为 in 关键字后面四个以空格分隔的值中的第一个(one)。
  2. 运行 dodone 之间的 echo 命令。
  3. 返回到 for 行,将 str 设置为下一个值(two),运行 dodone 之间的命令,并重复该过程,直到处理完 in 关键字后面的所有值。

该脚本的输出如下:

one
two
three
four

11.6.2 while 循环

Bourne shell 的 while 循环使用退出码,与 if 条件类似。例如,以下脚本执行 10 次迭代:

#!/bin/sh
FILE=/tmp/whiletest.$$;
echo firstline > $FILE
while tail -10 $FILE | grep -q firstline; do
    # 向 $FILE 添加行,直到 tail -10 $FILE 不再输出 "firstline"
    echo -n Number of lines in $FILE:' '
    wc -l $FILE | awk '{print $1}'
    echo newline >> $FILE
done
rm -f $FILE

这里,grep -q firstline 的退出码是测试条件。一旦退出码非零(在本例中,当字符串 firstline 不再出现在 $FILE 的最后 10 行中时),循环退出。

你可以使用 break 语句跳出 while 循环。Bourne shell 还有一个 until 循环,其工作方式与 while 相同,只是当遇到零退出码而不是非零退出码时它会终止循环。也就是说,你不需要经常使用 whileuntil 循环。事实上,如果你发现自己需要使用 while,那么你可能应该使用更适合任务的语言,例如 Python 或 awk。

11.7 命令替换

Bourne shell 可以将命令的标准输出重定向回 shell 自身的命令行。也就是说,你可以将一个命令的输出用作另一个命令的参数,或者通过将命令括在 $() 中来将命令输出存储在 shell 变量中。

以下示例将命令的输出存储在 FLAGS 变量中。第二行中的粗体代码显示了命令替换。

#!/bin/sh
FLAGS=$(grep ^flags /proc/cpuinfo | sed 's/.*://' | head -1)
echo Your processor supports:
for f in $FLAGS; do
    case $f in
        fpu)    MSG="浮点单元"
                ;;
        3dnow)  MSG="3DNOW 图形扩展"
                ;;
        mtrr)   MSG="内存类型范围寄存器"
                ;;
        *)      MSG="未知"
                ;;
    esac
    echo $f: $MSG
done

这个例子有点复杂,因为它展示了你可以在命令替换中使用单引号和管道。grep 命令的结果被发送到 sed 命令(更多关于 sed 的内容请参见第 11.10.3 节),sed 会删除匹配表达式 .*: 的任何内容,然后 sed 的结果被传递给 head

很容易过度使用命令替换。例如,不要在脚本中使用 $(ls),因为使用 shell 扩展 * 更快。另外,如果你希望对通过 find 命令获得的多个文件名调用某个命令,请考虑使用管道传递给 xargs 而不是命令替换,或者使用 -exec 选项(两者都在第 11.10.4 节中讨论)。

NOTE

命令替换的传统语法是将命令括在反引号(`)中,你会在许多 shell 脚本中看到这种用法。$() 语法是一种较新的形式,但它是 POSIX 标准,通常(对人类来说)更易于阅读和编写。

11.8 临时文件管理

有时需要创建一个临时文件来收集输出,供后面的命令使用。创建此类文件时,请确保文件名足够独特,其他程序不会意外写入。有时使用像 shell 的 PID($$)这样的简单内容放入文件名是可行的,但当需要确保没有冲突时,像 mktemp 这样的实用程序通常是更好的选择。

以下是使用 mktemp 命令创建临时文件名的方法。以下脚本显示了过去两秒内发生的设备中断:

#!/bin/sh
TMPFILE1=$(mktemp /tmp/im1.XXXXXX)
TMPFILE2=$(mktemp /tmp/im2.XXXXXX)
cat /proc/interrupts > $TMPFILE1
sleep 2
cat /proc/interrupts > $TMPFILE2
diff $TMPFILE1 $TMPFILE2
rm -f $TMPFILE1 $TMPFILE2

mktemp 的参数是一个模板。mktemp 命令将 XXXXXX 转换为一组唯一字符,并创建一个具有该名称的空文件。请注意,此脚本使用变量名来存储文件名,这样如果你想更改文件名,只需更改一行即可。

NOTE

并非所有 Unix 变体都附带 mktemp。如果你遇到可移植性问题,最好为你的操作系统安装 GNU coreutils 包。

使用临时文件的脚本一个常见问题是,如果脚本被中止,临时文件可能会被遗留下来。在前面的例子中,在第二个 cat 命令之前按下 CTRL-C 会在 /tmp 中留下一个临时文件。如果可能,应避免这种情况。相反,使用 trap 命令创建一个信号处理器来捕获 CTRL-C 生成的信号并删除临时文件,如下面的处理器所示:

#!/bin/sh
TMPFILE1=$(mktemp /tmp/im1.XXXXXX)
TMPFILE2=$(mktemp /tmp/im2.XXXXXX)
trap "rm -f $TMPFILE1 $TMPFILE2; exit 1" INT
 --snip--

你必须在处理器中使用 exit 明确结束脚本执行,否则 shell 会在运行信号处理器后照常继续运行。

NOTE

你不需要为 mktemp 提供参数;如果不提供,模板将以 /tmp/tmp. 为前缀。

11.9 这里文档

假设你想打印一大段文本,或者向另一个命令提供大量文本。你可以使用 shell 的 here 文档功能,而无需使用多个 echo 命令,如下面的脚本所示:

#!/bin/sh
DATE=$(date)
cat <<EOF
Date: $DATE
上面的输出来自 Unix date 命令.
这不是一个很有趣的命令.
EOF

粗体部分控制了 here 文档。<<EOF 告诉 shell 将所有后续行重定向到 <<EOF 前面命令的标准输入,在这种情况下是 cat。当 EOF 标记单独出现在一行时,重定向停止。该标记实际上可以是任何字符串,但请记住在 here 文档的开头和结尾使用相同的标记。另外,惯例是标记全部使用大写字母。

注意 here 文档中的 shell 变量 $DATE。shell 会在 here 文档内展开 shell 变量,这在打印包含许多变量的报告时特别有用。

11.10 重要的 Shell 脚本实用程序

有几个程序在 shell 脚本中特别有用。某些实用程序(如 basename)实际上只有与其他程序一起使用时才实用,因此通常不会在 shell 脚本之外找到用途。然而,其他一些实用程序(如 awk)在命令行上也相当有用。

11.10.1 basename

如果你需要从文件名中去除扩展名,或者从完整路径名中去除目录,请使用 basename 命令。在命令行上尝试以下示例,以了解该命令的工作原理:

$ basename example.html .html
$ basename /usr/local/bin/example

在这两种情况下,basename 都返回 example。第一个命令从 example.html 中去除 .html 后缀,第二个命令从完整路径名中去除目录。

以下示例展示了如何在脚本中使用 basename 将 GIF 图像文件转换为 PNG 格式:

#!/bin/sh
for file in *.gif; do
    # 如果没有文件则退出
    if [ ! -f $file ]; then
        exit
    fi
    b=$(basename $file .gif)
    echo Converting $b.gif to $b.png...
    giftopnm $b.gif | pnmtopng > $b.png
done

11.10.2 awk

awk 命令不是一个简单的单一用途命令;它实际上是一种强大的编程语言。不幸的是,awk 的使用现在已经成为一种失传的艺术,已被 Python 等更庞大的语言所取代。

关于 awk 有整本书的著作,包括 Alfred V. Aho、Brian W. Kernighan 和 Peter J. Weinberger 合著的《The AWK Programming Language》(Addison-Wesley, 1988)。尽管如此,许多人使用 awk 只做一件事——从输入流中选取单个字段,如下所示:

$ ls -l | awk '{print $5}'

此命令打印 ls 输出的第五个字段(文件大小)。结果是文件大小的列表。

11.10.3 sed

sed(“流编辑器”)程序是一种自动文本编辑器,它接收输入流(文件或标准输入),根据某些表达式对其进行修改,并将结果打印到标准输出。在许多方面,sed 与 ed(最初的 Unix 文本编辑器)相似。它拥有数十种操作、匹配工具和寻址能力。与 awk 一样,有整本书专门介绍 sed,包括一本涵盖两者的快速参考手册:sed & awk Pocket Reference, 2nd edition,作者 Arnold Robbins (O’Reilly, 2002)。

虽然 sed 是一个大型程序,深入分析超出了本书范围,但了解它的工作原理并不难。通常,sed 接受一个地址和一个操作作为单个参数。地址是一组行,命令决定对这些行执行什么操作。

sed 的一个非常常见的任务是用某些文本替换正则表达式(参见第 2.5.1 节),如下所示:

$ sed 's/exp/text/'

如果你想将 /etc/passwd 中每一行的第一个冒号替换为 % 并将结果输出到标准输出,可以这样操作:

$ sed 's/:/%/' /etc/passwd

要替换 /etc/passwd 中的所有冒号,请在操作末尾添加 g(全局)修饰符,如下所示:

$ sed 's/:/%/g' /etc/passwd

下面是一个基于行的命令;它读取 /etc/passwd,删除第 3 到第 6 行,并将结果发送到标准输出:

$ sed 3,6d /etc/passwd

在这个例子中,3,6 是地址(一个行范围),d 是操作(删除)。如果省略地址,sed 将操作输入流中的所有行。sed 最常用的两个操作可能是 s(搜索并替换)和 d

你也可以使用正则表达式作为地址。以下命令删除任何匹配正则表达式 exp 的行:

$ sed '/exp/d'

在所有示例中,sed 都写入标准输出,这是最常见的用法。如果没有提供文件参数,sed 将从标准输入读取,这种模式在 shell 管道中经常遇到。

11.10.4 xargs

当你需要对大量文件运行一个命令时,命令或 shell 可能提示无法将所有参数放入缓冲区。使用 xargs 可以解决这个问题:它在标准输入流中针对每个文件名运行一个命令。

许多人将 xargs 与 find 命令一起使用。例如,以下脚本可以帮助你验证当前目录树中所有以 .gif 结尾的文件是否确实是 GIF 图像:

$ find . -name '*.gif' -print | xargs file

这里,xargs 运行 file 命令。然而,这种调用可能导致错误或使系统面临安全风险,因为文件名可能包含空格和换行符。编写脚本时,应改用以下形式,它将 find 的输出分隔符和 xargs 的参数分隔符从换行符改为 NULL 字符:

$ find . -name '*.gif' -print0 | xargs -0 file

xargs 会启动大量进程,因此如果文件列表很大,不要期望有很高的性能。

如果某些目标文件可能以单个破折号(-)开头,你可能需要在 xargs 命令末尾添加两个破折号(--)。双破折号告诉程序其后的任何参数都是文件名,而不是选项。但请记住,并非所有程序都支持双破折号。

在使用 find 时,还有一个替代 xargs 的选项:-exec 选项。然而,其语法有些棘手,因为你需要提供花括号 {} 来替换文件名,以及一个字面的 ; 来表示命令结束。以下是仅使用 find 完成上述任务的方法:

$ find . -name '*.gif' -exec file {} \;

11.10.5 expr

如果你需要在 shell 脚本中执行算术运算,expr 命令可以提供帮助(甚至还能执行一些字符串操作)。例如,命令 expr 1 + 2 会打印出 3。(运行 expr --help 查看完整操作列表。)

expr 命令是一种笨拙、缓慢的数学运算方式。如果你发现自己频繁使用它,或许应该改用 Python 等语言而非 shell 脚本来完成任务。

11.10.6 exec

exec 命令是一个内建的 shell 特性,它用你指定的程序替换当前 shell 进程。它执行第 1 章描述的 exec() 系统调用。该特性旨在节省系统资源,但请记住,没有返回值:当你在 shell 脚本中运行 exec 时,脚本和运行脚本的 shell 都消失了,被新命令取代。

要在 shell 窗口中测试这一点,可以尝试运行 exec cat。在你按下 CTRL-D 或 CTRL-C 终止 cat 程序后,你的窗口应该会消失,因为它的子进程已不存在。

11.11 子 shell

假设你需要稍微改变 shell 中的环境,但又不希望永久改变。你可以使用 shell 变量更改并恢复环境的一部分(如路径或工作目录),但这是一种笨拙的做法。更简单的选择是使用子 shell:一个全新的 shell 进程,你可以只为运行一两个命令而创建它。新 shell 拥有原始 shell 环境的副本,当新 shell 退出时,你对它的 shell 环境所做的任何更改都会消失,初始 shell 则正常运行。

要使用子 shell,请将要由子 shell 执行的命令放在括号中。例如,以下行在 uglydir 目录中执行 uglyprogram,同时保持原始 shell 不变:

$ (cd uglydir; uglyprogram)

以下示例显示了如何向 PATH 添加一个可能会造成永久性问题的组件:

$ (PATH=/usr/confusing:$PATH; uglyprogram)

使用子 shell 对环境变量进行一次性修改是一项常见任务,甚至存在一种避免子 shell 的内建语法:

$ PATH=/usr/confusing:$PATH uglyprogram

管道和后台进程也可以与子 shell 一起使用。以下示例使用 tar 将 orig 目录内的整个目录树归档,然后将存档解压到新目录 target 中,这实际上复制了 orig 中的文件和文件夹(这样做很有用,因为它保留了所有权和权限,并且通常比使用 cp -r 等命令更快):

$ tar cf - orig | (cd target; tar xvf -)

警告

在运行此类命令之前,请仔细检查,确保 target 目录存在且与 orig 目录完全分离(在脚本中,你可以使用 [ -d orig -a ! orig -ef target ] 进行检查)。

11.12 在脚本中包含其他文件

如果你需要在 shell 脚本中包含来自另一个文件的代码,请使用点(.)运算符。例如,以下命令会运行 config.sh 文件中的命令:

. config.sh

这种包含方法也称为引用(source) 一个文件,对于读取变量(例如在共享配置文件中)以及其他类型的定义非常有用。这与执行另一个脚本不同:当你作为命令运行一个脚本时,它会在一个新 shell 中启动,除了输出和退出码之外,你无法获取其他任何内容。

11.13 读取用户输入

read 命令从标准输入读取一行文本,并将文本存储在变量中。例如,以下命令将输入存储在 $var 中:

$ read var

这个内建 shell 命令与本书未提及的其他 shell 特性结合使用时可以非常有用。借助 read,你可以创建简单的交互,例如提示用户输入而不是要求他们在命令行上列出所有内容,并构建危险操作前的“你确定吗?”确认提示。

11.14 何时(不)使用 Shell 脚本

Shell 功能如此丰富,以至于很难将其重要元素浓缩到一章中。如果你对 Shell 还能做什么感兴趣,请查阅一些 Shell 编程方面的书籍,例如 Unix Shell Programming, 3rd edition,作者 Stephen G. Kochan 和 Patrick Wood (SAMS Publishing, 2003),或者 The UNIX Programming Environment 中关于 Shell 脚本的讨论,作者 Brian W. Kernighan 和 Rob Pike (Prentice Hall, 1984)。

然而,在某个时刻(特别是当你开始过度使用 read 内建时),你必须问自己是否仍然在正确的工具。记住 shell 脚本最擅长什么:操作简单的文件和命令。如前所述,如果你发现自己正在编写看起来复杂混乱的代码,尤其是涉及复杂的字符串或算术运算时,不要害怕转向 Python、Perl 或 awk 等脚本语言。


图像上下文(第 317 页):

  • Image 2327
  • Image 2321
  • Image 2325
  • Image 2317
  • Image 2323
  • Image 2316
  • Image 2320
  • Image 2315
  • Image 2319