Linux工作原理11 Shell脚本

11 shell脚本

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

11.1 shell脚本基础

Bourne shell 脚本一般以下面一行开始,表示 /bin/sh 程序应执行脚本文件中的命令。(确保脚本文件开头没有空白)。

#!/bin/sh

在本书的其他脚本中,你会看到#!你可以在#/bin/sh行后列出任何希望shell执行的命令。例如

#!/bin/sh
#
# Print something, then run ls

echo About to run the ls command.
ls

shell 会忽略 # 字符之后一行的任何内容。使用注释来解释脚本中其他人阅读代码时可能难以理解的部分,或在日后重温代码时唤起自己的记忆。

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

$ chmod +rx script

这条 chmod 命令允许其他用户读取和执行脚本。如果不想这样,请使用绝对模式 700(有关权限的复习,请参阅第 2.17 节)。

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

例如,运行名为 myscript 的脚本会导致内核运行 /bin/sh myscript。

了解了基础知识后,让我们来看看 shell 脚本的一些限制。

Shebang,也称为 Hashbang 或 Hash-bang,是一种特殊的语法,通常出现在文本文件的第一行的前两个字符:#!。它的作用是指定脚本应该由哪个解释器来执行。用途如下:

  • 明确指定解释器: 不同的脚本语言有不同的解释器(比如 Bash、Python、Perl 等)。Shebang 告诉操作系统,应该使用哪个解释器来运行这个脚本,避免了歧义。
  • 直接执行脚本: 有了 Shebang,你就可以直接运行脚本文件,而不需要每次都指定解释器。
  • 跨平台兼容性: 不同的系统可能默认的 shell 或解释器不同,Shebang 确保了脚本在不同系统上的可移植性。

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或awk这样的脚本语言,甚至可能使用 C 这样的编译语言(这一点很重要,所以你会在本章中看到)。

最后,要注意 shell 脚本的大小。shell 脚本要简短。Bourne shell 脚本并不意味着要很大,尽管你无疑会遇到一些庞然大物。

11.2 引用和字面量

在使用 shell 和脚本的过程中,最令人困惑的一点就是不知道什么时候以及为什么要使用引号(quotes)和其他标点符号。假设您想打印 $100 字符串,请执行以下操作:

$ echo $100
00
$ echo "$100"
00
$ echo '$100'
$100

11.2.1 字面量

当你使用引号时,通常是要创建一个字面形式,即 shell 在将其传递到命令行之前不应分析(或试图更改)的字符串。除了刚才例子中的 $ 之外,当你想将 * 字符传递给 grep 等命令而不是让 shell 展开它,以及当你需要在命令中使用分号 (😉 时,也经常会遇到这种情况。

  • 在运行命令之前,shell 会查找变量、globs 和其他替换,如果出现这些变量、globs 和其他替换,shell 就会执行这些替换。
  • shell 会将替换结果传递给命令。
    涉及文字的问题可能很微妙。比方说,你要查找 /etc/passwd 中与正则表达式 r.*t 相匹配的所有条目(也就是说,一行中包含一个r,后面跟着一个 t,这样就能搜索到 root、ruth 和 robot 等用户名)。你可以运行这条命令
$ grep r.*t /etc/passwd

大多数时候都能正常运行,但有时会莫名其妙地失败。如果该目录包含名称为 r.input 和 r.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

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

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

当需要使用字面意义时,应首先使用单引号,因为这样可以保证 shell 不会尝试任何替换。因此,这通常是一种简洁的语法。不过,有时您需要更大的灵活性,因此可以使用双引号。

11.2.3 双引号

双引号(")的作用与单引号相同,但 shell 会展开双引号内的变量。运行下面的命令,然后用单引号替换双引号并再次运行,就能看出其中的区别。

$ echo "There is no * in my path: $PATH"
There is no * in my path: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

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

如果在处理大量文本时使用双引号,请考虑使用 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."

如果您遇到困难,需要一个不带替换的引用整个字符串的通用规则,请按照以下步骤操作:

  • 将'(单引号)改为'''(单引号、反斜杠、单引号、单引号)。
  • 用单引号括起整个字符串。
  • 因此,你可以引用一个别扭的字符串,如 this isn't a forward slash: \ 这样的字符串:
this isn't a forward slash: \

注意事项:值得重复的是,当你引用一个字符串时,shell 会将引号内的所有内容视为一个参数。因此,a b c 算作三个参数,但 “b c ”只是两个参数。

11.3 特殊变量

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

注意:读完下面几节,你就会明白为什么 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 $@

如果 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 反向操作。在前面的示例中,可以这样做

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

11.3.5 进程 ID: $$

$$ 变量表示 shell 的进程 ID。

11.3.6 退出代码 $?

$? 变量保存 shell 执行的最后一条命令的退出代码。退出代码是掌握 shell 脚本的关键,将在下面讨论。

11.4 退出代码

当一个 Unix 程序结束时,它会为启动该程序的父进程留下一个退出代码,这是一个数值,也称为错误代码或退出值。当退出代码为零(0)时,通常表示程序运行正常。不过,如果程序出错,它的退出值通常不是 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 传递回运行脚本的父进程。(如果脚本有各种异常退出条件,可以使用不同的非零数字)。

请注意,有些程序(如 diff 和 grep)使用非零退出代码来表示正常情况。例如,grep 在找到与模式匹配的内容时返回 0,找不到时返回 1。对于这些程序来说,退出代码为 1 并不是错误,因此 grep 和 diff 在遇到实际问题时会使用退出代码 2。如果你认为某个程序可能使用非零的退出代码来表示成功,请阅读其手册页面。退出代码通常会在 EXIT VALUE 或 DIAGNOSTICS 部分进行解释。

参考资料

11.5 条件

Bourne shell 有特殊的条件结构,包括 if/then/else 和 case 语句。例如,这个带有 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

前面脚本中的 if、then、else 和 fi 是 shell 关键字,其他都是命令。这种区别极为重要,因为人们很容易将条件 [ $1 = “hi” ] 误认为是特殊的 shell 语法。事实上,[ 字符是 Unix 系统中的一个实际程序。所有 Unix 系统都有一个名为 [ 的命令,用于对 shell 脚本条件进行测试。这个程序也被称为 test;test 和 [ 的手册页面是一样的。(你很快就会知道 shell 并不总是运行 [ 命令,但现在你可以把它当作一个单独的命令)。

在这里,理解第 11.4 节中解释的退出代码至关重要。让我们看看前面的脚本实际上是如何运行的:

  • shell 运行 if 关键字后的命令,并收集该命令的退出代码。
  • 如果退出代码为 0,shell 会执行 then 关键字后面的命令,在执行到 else 或 fi 关键字时停止。
  • 如果退出代码不是 0,并且有 else 子句,shell 就会执行 else 关键字后面的命令。
  • 条件在 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

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 的确认。

注意:不要过于迷恋 elif,因为 case 结构(将在第 11.5.6 节中介绍)通常更为合适。

11.5.4 逻辑结构体

有两种快速的单行条件结构,你可能会经常看到,它们使用的是 && (“和”)和 || (“或”)语法。&& 结构的工作原理如下

command1 && command2

在这里,shell 运行命令 1,如果退出代码为 0,则同时运行命令 2。

|| 结构类似;如果 || 前的命令返回非零退出代码,shell 就会运行第二条命令。

结构体 && 和 || 经常在 if 测试中使用,在这两种情况下,最后运行的命令的退出代码决定了 shell 如何处理条件。在 && 结构的情况下,如果第一条命令失败,shell 会使用其退出代码来处理 if 语句,但如果第一条命令成功,shell 会使用第二条命令的退出代码来处理条件。在 || 结构的情况下,如果第一条命令成功,shell 会使用第一条命令的退出代码;如果第一条命令失败,shell 会使用第二条命令的退出代码。

例如

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

如果您的条件包括测试命令 ([) (如图所示),您可以使用 -a 和 -o 代替 && 和 || 等命令:

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

将 ! 运算符放在测试之前,可以反转测试(即逻辑 not)。例如

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

在这种特殊的比较情况下,你可能会看到 != 作为一种选择,但 ! 可以用于下一节中描述的任何条件测试。

11.5.5 条件测试

我们已经了解了 [ 的工作原理:如果测试为真,则退出代码为 0;如果测试失败,则退出代码为非 0。您还知道如何使用 [ str1 = str2 ] 测试字符串是否相等。不过,请记住 shell 脚本非常适合对整个文件进行操作,因为许多有用的 [ 测试涉及文件属性。例如,下面一行检查 file 是否是普通文件(而不是目录或特殊文件):

[ -f file ]

在脚本中,你可能会在类似这个循环中看到 -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 

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

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

11.5.5.1文件测试

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

  • 如果文件存在,-e 返回 true
  • 如果文件不是空的,-s 返回 true

如表 11-1 所列,有几种操作可以检测文件类型,即确定文件是普通文件、目录还是某种特殊设备。表 11-2 中还列出了一些检查文件权限的一元操作。(有关权限的概述,请参见第 2.17 节)。

表 11-1: 文件类型操作符

如果测试命令用于符号链接,它测试的是实际链接对象,而不是链接本身(-h 测试除外)。也就是说,如果 link 是指向普通文件的符号链接,[ -f link ] 返回的退出代码为 true (0)。

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

最后,文件测试中还使用了三种二进制操作符(需要两个文件作为参数的测试),但它们并不常见。请看这条命令,其中包括 -nt(“比...新”):

[ file1 -nt file2 ]

如果文件 1 的修改日期比文件 2 新,则退出为 true。而 -ot(“比......旧”)操作符的作用正好相反。如果需要检测相同的硬链接,-ef 会比较两个文件,如果它们共享 inode 编号和设备,则返回 true。

11.5.5.2字符串测试

二进制字符串运算符 = 如果操作数相等则返回 true,而 != 运算符如果操作数不相等则返回 true。另外还有两个一元字符串操作:

  • -z 如果参数为空,则返回 true([ -z “” ] 返回 0)
  • 如果参数不为空,-n 返回 true([ -n “” ] 返回 1)

11.5.5.3算术测试

请注意,等号(=)是用来检测字符串是否相等,而不是数字是否相等。因此,[ 1 = 1 ] 返回 0(真),但 [ 01 = 1 ] 返回假。处理数字时,使用 -eq 代替等号:[ 01 -eq 1 ] 返回 true。表 11-3 提供了数字比较运算符的完整列表。

表 11-3: 算术比较运算符

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与用 ) 字符标出的每个大小写值进行匹配。
  • 如果某个 case 值与 $1 匹配,shell 会执行该 case 下面的命令,直到遇到 ;; 时跳转到 esac 关键字。
  • 条件以 esac 结束。

对于每个 case 值,可以匹配单个字符串(如上例中的 bye),也可以用 | 匹配多个字符串(如果 $1 等于 hi 或 hello,则 hi|hello 返回 true),还可以使用 * 或 ? 模式 (what*)。要创建一个能捕获指定大小写值以外所有可能值的默认大小写,可使用单个 *,如上例中的最后一个大小写所示。

注意:用双分号 (;😉 结束每个 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

在这个列表中,for、in、do 和 done 都是 shell 的关键字。shell 会执行以下操作

  • 将变量 str 设置为 in 关键字后四个空格分隔值中的第一个(one)。
  • 在 do 和 done 之间运行 echo 命令。
  • 返回 for 行,将 str 设为下一个值(二),运行 do 和 done 之间的命令,并重复该过程,直到完成 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
    # add lines to $FILE until tail -10 $FILE no longer prints "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 相同,只是当遇到零退出代码而不是非零退出代码时,它会中断循环。尽管如此,你应该不需要经常使用 while 和 until 循环。事实上,如果您发现需要使用 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="floating point unit"
                ;;
        3dnow)  MSG="3DNOW graphics extensions"
                ;;
        mtrr)   MSG="memory type range register"
                ;;
        *)      MSG="unknown"
                ;;
    esac
    echo $f: $MSG
done

这个示例有些复杂,因为它演示了在命令替换中既可以使用单引号,也可以使用管道。grep 命令的结果被发送到 sed 命令(有关 sed 的更多信息,请参见第 11.10.3 节),它会删除任何与表达式 .*: 匹配的内容,而 sed 命令的结果则被传给 head。

使用命令替换很容易过量。例如,不要在脚本中使用 $(ls),因为使用 shell 扩展 * 更快。另外,如果要对查找命令得到的多个文件名调用命令,可以考虑使用 xargs 管道而不是命令替换,或者使用 -exec 选项(第 11.10.4 节将讨论这两个选项)。

注意: 命令替换的传统语法是用反斜线 (``) 将命令括起来,这在许多 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 转换为一组唯一的字符,并以该名称创建一个空文件。请注意,该脚本使用变量名来存储文件名,因此如果要更改文件名,只需更改一行即可。

注意:并非所有 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 将在运行信号处理程序后继续照常运行。

注意:不需要为 mktemp 提供参数;如果不提供参数,模板将以 /tmp/tmp. 前缀开始。

11.9 Here 文档

假设您要打印一大段文本或将大量文本传送给另一条命令。与其使用多个 echo 命令,不如使用 shell 的 here 文档功能,如下脚本所示:

#!/bin/sh
DATE=$(date)
cat <<EOF
Date: $DATE

The output above is from the Unix date command.
It's not a very interesting command.
EOF

<告诉 shell 将所有后续行重定向到 < 前面命令的标准输入,在本例中是 cat。一旦 EOF 标记出现在某一行,重定向就会停止。实际上,标记可以是任何字符串,但要记住在文档的开头和结尾使用相同的标记。此外,按照惯例,标记必须使用大写字母。

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

执行结果:

$ sh test.sh
Date: Sat Jul 27 03:26:58 PM CST 2024

The output above is from the Unix date command.
It's not a very interesting command.

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
    # exit if there are no files
    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 命令不是一个简单的单一用途命令,它实际上是一种功能强大的编程语言。遗憾的是,由于被 Python 等更大型的语言所取代,awk 的使用已成为一门失传的艺术。

关于 awk 的书有很多,包括 Alfred V. Aho、Brian W. Kernighan 和 Peter J. Weinberger 合著的《AWK 编程语言》(Addison-Wesley,1988 年)。尽管如此,很多很多人使用 awk 只做一件事--从输入流中选取一个字段,就像下面这样:

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

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

11.10.3 sed

sed (“流编辑器 stream editor”)程序是一个自动文本编辑器,它接收输入流(文件或标准输入),根据某些表达式对其进行修改,并将结果打印到标准输出。在许多方面,sed 与最初的 Unix 文本编辑器 ed 相似。它有几十种操作、匹配工具和寻址功能。与 awk 一样,关于 sed 的书已经写了整整一本书,其中包括 Arnold Robbins(O'Reilly,2002 年)所著的《sed & awk Pocket Reference》(第 2 版)这两本书。

虽然 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,删除第三行至第六行,并将结果发送到标准输出:

$ 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 与查找命令一起使用。例如,下面的脚本可以帮助你确认当前目录树中每个以 .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 的一个内置功能,它可以用你在 exec 后命名的程序替换当前的 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 要执行的命令放在括号中。例如,下面一行会在 uglydir 中执行命令 uglyprogram,并保留原始 shell:

$ (cd uglydir; uglyprogram)

这个例子展示了如何在路径中添加一个可能会引起问题的组件,作为永久性更改:

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

使用子壳对环境变量进行一次性修改是一项非常常见的任务,甚至有一种内置语法可以避免使用子壳:

$ PATH=/usr/confusing:$PATH uglyprogram

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

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

警告:运行此类命令前,请仔细检查目标目录是否存在,是否与 orig 目录完全分开(在脚本中,可以使用 [ -d orig -a ! orig -ef target ] 进行检查)。

11.12 在脚本中包含其他文件

如果需要在 shell 脚本中包含其他文件的代码,请使用点(.)操作符。例如,运行文件 config.sh 中的命令:

. config.sh

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

11.13 读取用户输入

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

$ read var

这条内置 shell 命令可以与本书未提及的其他 shell 功能结合使用。使用 read,你可以创建简单的交互,例如提示用户输入,而不是要求他们列出命令行上的所有内容,还可以在危险操作前创建 “你确定吗?”的确认信息。

11.14 何时(不)使用 shell 脚本

shell 的功能非常丰富,很难将其重要元素浓缩到一章中。如果你对 shell 的其他功能感兴趣,可以参考一些有关 shell 编程的书籍,如 Stephen G. Kochan 和 Patrick Wood 合著的《Unix Shell Programming》(第 3 版)(SAMS 出版社,2003 年),或 Brian W. Kernighan 和 Rob Pike 合著的《UNIX 编程环境》(Prentice Hall,1984 年)中有关 shell 脚本的讨论。

不过,到了一定程度(尤其是当你开始过度使用内置读取功能时),你就必须问问自己,你是否还在使用适合这项工作的工具。记住 shell 脚本最擅长做什么:操作简单的文件和命令。如前所述,如果你发现自己写的东西看起来很复杂,尤其是涉及到复杂的字符串或算术运算时,不要害怕,可以参考 Python或awk等脚本语言。

posted @ 2024-07-28 10:53  磁石空杯  阅读(45)  评论(0编辑  收藏  举报