包含常见命令行使用,Bash 基础、Shell 进阶编程,以及实用范例!

初识脚本编程

到目前为止我们已经知道了 Linux 系统和命令行的基础知识,是时候开始编程了。本章讨论编写 shell 脚本的基础知识。在开始编写自己的 shell 脚本大作前,你必须了解这些基本概念。

使用多个命令

到目前为止,你已经了解了如何使用 shell 的命令行界面提示符来输入命令和查看命令的结果。shell 脚本的关键在于输入多个命令并处理每个命令的结果,甚至需要将一个命令的结果传给另一个命令。shell 可以让你将多个命令串起来,一次执行完成。如果要两个命令一起运行,可以把它们放在同一行中,彼此间用分号隔开。

$ date ; who
Sun Dec 20 08:59:29 AM CST 2020
testuser   tty1         2020-12-20 08:11 (:0)
testuser   pts/0        2020-12-20 08:11 (:0)
testuser   pts/1        2020-12-20 08:59 (:0)
$

恭喜,你刚刚已经写好了一个脚本。这个简单的脚本只用到了两个 bash shell 命令。date 命令先运行,显示了当前日期和时间,后面紧跟着 who 命令的输出,显示当前是谁登录到了系统上。使用这种办法就能将任意多个命令串连在一起使用了,只要不超过最大命令行字符数 255 就行。

这种技术对于小型脚本尚可,但它有一个很大的缺陷:每次运行之前,你都必须在命令提示符下输入整个命令。可以将这些命令组合成一个简单的文本文件,这样就不需要在命令行中手动输入了。在需要运行这些命令时,只用运行这个文本文件就行了。

创建 shell 脚本文件

要将 shell 命令放到文本文件中,首先需要用文本编辑器来创建一个文件,然后将命令输入到文件中。在创建 shell 脚本文件时,必须在文件的第一行指定要使用的 shell。其格式为:

#!/bin/bash

在通常的 shell 脚本中,井号(#)用作注释行。shell 并不会处理 shell 脚本中的注释行。然而,shell 脚本文件的第一行是个例外,#后面的惊叹号会告诉 shell 用哪个 shell 来运行脚本(是的,你可以使用 bash shell,同时还可以使用另一个 shell 来运行你的脚本)。

在指定了 shell 之后,就可以在文件的每一行中输入命令,然后加一个回车符。之前提到过,注释可用#添加。例如:

## This script displays the date and who's logged on
date
who

这就是脚本的所有内容了。可以根据需要,使用分号将两个命令放在一行上,但在 shell 脚本中,你可以在独立的行中书写命令。shell 会按根据命令在文件中出现的顺序进行处理。

还有,要注意另有一行也以#开头,并添加了一个注释。shell 不会解释以#开头的行(除了以#!开头的第一行)。留下注释来说明脚本做了什么,这种方法非常好。当两年后回过来再看这个脚本时,你还可以很容易回忆起做过什么。

将这个脚本保存在名为 test1 的文件中,基本就好了。在运行新脚本前,还要做其他一些事。现在运行脚本,结果可能会叫你有点失望。

$ test1
bash: test1: command not found

你要跨过的第一个障碍是让 bash shell 能找到你的脚本文件。如之前所述,shell 会通过 PATH 环境变量来查找命令。快速查看一下 PATH 环境变量就可以弄清问题所在。

$ echo $PATH
/usr/kerberos/sbin:/usr/kerberos/bin:/usr/local/bin:/usr/bin :/bin:/usr/local/sbin:/usr/sbin:/sbin:/home/user/bin

PATH 环境变量被设置成只在一组目录中查找命令。要让 shell 找到 test1 脚本,只需采取以下两种作法之一:

  • 将 shell 脚本文件所处的目录添加到 PATH 环境变量中;
  • 在提示符中用绝对或相对文件路径来引用 shell 脚本文件。

有些 Linux 发行版将$HOME/bin 目录添加进了 PATH 环境变量。它在每个用户的 HOME 目录下提供了一个存放文件的地方,shell 可以在那里查找要执行的命令。

在这个例子中,我们将用第二种方式将脚本文件的确切位置告诉 shell。记住,为了引用当前目录下的文件,可以在 shell 中使用单点操作符。

$ ./test1
bash: ./test1: Permission denied

现在 shell 找到了脚本文件,但还有一个问题。shell 指明了你还没有执行文件的权限。快速查看一下文件权限就能找到问题所在。

$ ls -l test1
-rw-rw-r--    1 user     user           73 Sep 24 19:56 test1

在创建 test1 文件时,umask 的值决定了新文件的默认权限设置。由于 umask 变量在 ArchLinux 中被设成了 022,所以系统创建的文件的文件属主只有读/写权限。

下一步是通过 chmod 命令赋予文件属主执行文件的权限。

$ chmod u+x test1
$ ./test1
Sun Dec 20 08:59:29 AM CST 2020
testuser   tty1         2020-12-20 08:11 (:0)
testuser   pts/0        2020-12-20 08:11 (:0)
testuser   pts/1        2020-12-20 08:59 (:0)

成功了!

显示消息

大多数 shell 命令都会产生自己的输出,这些输出会显示在脚本所运行的控制台显示器上。很多时候,你可能想要添加自己的文本消息来告诉脚本用户脚本正在做什么。可以通过 echo 命令来实现这一点。如果在 echo 命令后面加上了一个字符串,该命令就能显示出这个文本字符串。

$ echo This is a test
This is a test

注意,默认情况下,不需要使用引号将要显示的文本字符串划定出来。但有时在字符串中出现引号的话就比较麻烦了。

$ echo Let's see if this'll work
Lets see if thisll work

echo 命令可用单引号或双引号来划定文本字符串。如果在字符串中用到了它们,你需要在文本中使用其中一种引号,而用另外一种来将字符串划定起来。

$ echo "This is a test to see if you're paying attention"
This is a test to see if you're paying attention
$ echo 'Rich says "scripting is easy".'
Rich says "scripting is easy".

所有的引号都可以正常输出了。

如果想把文本字符串和命令输出显示在同一行中,该怎么办呢?可以用 echo 语句的-n 参数。只要将第一个 echo 语句改成这样就行:

echo -n "The time and date are: "
date
#输出: The time and date are: Mon Feb 21 15:42:23 EST 2014

完美!echo 命令是 shell 脚本中与用户交互的重要工具。你会发现在很多地方都能用到它,尤其是需要显示脚本中变量的值的时候。我们下面继续了解这个。

使用变量

运行 shell 脚本中的单个命令自然有用,但这有其自身的限制。通常你会需要在 shell 命令使用其他数据来处理信息。这可以通过变量来实现。变量允许你临时性地将信息存储在 shell 脚本中,以便和脚本中的其他命令一起使用。本节将介绍如何在 shell 脚本中使用变量。

环境变量

你已经看到过 Linux 的一种变量在实际中的应用。前面介绍了 Linux 系统的环境变量。也可以在脚本中访问这些值。shell 维护着一组环境变量,用来记录特定的系统信息。比如系统的名称、登录到系统上的用户名、用户的系统 ID(也称为 UID)、用户的默认主目录以及 shell 查找程序的搜索路径。可以用 set 命令来显示一份完整的当前环境变量列表。

$ set
BASH=/bin/bash
[...]
HOME=/home/Samantha
HOSTNAME=localhost.localdomain
HOSTTYPE=i386
IFS=$' \t\n'
LANG=en_US.utf8
LESSOPEN='|/usr/bin/lesspipe.sh %s'
LINES=24
LOGNAME=Samantha
[...]

在脚本中,你可以在环境变量名称之前加上美元符($)来使用这些环境变量。下面的脚本演示了这种用法。

$ cat test2
#!/bin/bash
## display user information from the system.
echo "User info for userid: $USER" #若为单引号包裹则不会变更$USER的值
echo UID: $UID
echo HOME: $HOME
$

$USER、$UID 和$HOME 环境变量用来显示已登录用户的有关信息。脚本输出如下:

$ chmod u+x test2
$ ./test2
User info for userid: Samantha
UID: 1001
HOME: /home/Samantha

你可能还见过通过${variable}形式引用的变量。变量名两侧额外的花括号通常用来帮助识别美元符后的变量名。

注意,echo 命令中的环境变量会在脚本运行时替换成当前值。另外,在第一个字符串中可以将$USER 系统变量放置到双引号中,而 shell 依然能够知道我们的意图。但采用这种方法也有一个问题。看看下面这个例子会怎么样。

$ echo "The cost of the item is $15"
The cost of the item is 5

显然这不是我们想要的。只要脚本在引号中出现美元符,它就会以为你在引用一个变量。在这个例子中,脚本会尝试显示变量$1(但并未定义),再显示数字 5。要显示美元符,你必须在它前面放置一个反斜线。

$ echo "The cost of the item is \$15"
The cost of the item is $15

看起来好多了。反斜线允许 shell 脚本将美元符解读为实际的美元符,而不是变量。下一节将会介绍如何在脚本中创建自己的变量。

用户变量

除了环境变量,shell 脚本还允许在脚本中定义和使用自己的变量。定义变量允许临时存储数据并在整个脚本中使用,从而使 shell 脚本看起来更像一个真正的计算机程序。用户变量可以是任何由字母、数字或下划线组成的文本字符串,长度不超过 20 个。用户变量区分大小写,所以变量 Var1 和变量 var1 是不同的。这个小规矩经常让脚本编程初学者感到头疼。 使用等号将值赋给用户变量。在变量、等号和值之间不能出现空格(另一个困扰初学者的用法)。这里有一些给用户变量赋值的例子。

var1=10
var2=-57
var3=testing
var4="still more testing"

shell 脚本会自动决定变量值的数据类型。在脚本的整个生命周期里,shell 脚本中定义的变量会一直保持着它们的值,但在 shell 脚本结束时会被删除掉。

与系统变量类似,用户变量可通过美元符引用。变量每次被引用时,都会输出当前赋给它的值。重要的是要记住,引用一个变量值时需要使用美元符,而引用变量来对其进行赋值时则不要使用美元符。通过一个例子你就能明白我的意思。

$ cat test4
#!/bin/bash
## assigning a variable value to another variable
value1=10
value2=$value1
echo The resulting value is $value2

在赋值语句中使用 value1 变量的值时,仍然必须用美元符。这段代码产生如下输出。

$ chmod u+x test4
$ ./test4
The resulting value is 10

要是忘了用美元符,使得 value2 的赋值行变成了这样:

value2=value1

那你会得到如下输出:

$ ./test4
The resulting value is value1

没有美元符,shell 会将变量名解释成普通的文本字符串,通常这并不是你想要的结果。

命令替换

shell 脚本中最有用的特性之一就是可以从命令输出中提取信息,并将其赋给变量。把输出赋给变量之后,就可以随意在脚本中使用了。这个特性在处理脚本数据时尤为方便。

有两种方法可以将命令输出赋给变量:

  • 反引号字符(`) testing=`date`
  • $()格式 testing=$(date)

shell 会运行命令替换符号中的命令,并将其输出赋给变量 testing。注意,赋值等号和命令替换字符之间没有空格。
下面这个例子很常见,它在脚本中通过命令替换获得当前日期并用它来生成唯一文件名。

#!/bin/bash
## copy the /usr/bin directory listing to a log file
today=$(date +%y%m%d)
ls /usr/bin -al > log.$today

today 变量被赋予格式化后的 date 命令的输出。这是提取日期信息来生成日志文件名常用的一种技术。+%y%m%d 格式告诉 date 命令将日期显示为两位数的年月日的组合。

$ date +%y%m%d
201220

这个脚本将日期值赋给一个变量,之后再将其作为文件名的一部分。文件自身含有/usr/bin 目录列表的重定向输出(将在后续详细讨论)。运行该脚本之后,应该能在目录中看到一个新文件。

-rw-r--r--    1 user     user          769 Jan 31 10:15 log.140131

目录中出现的日志文件采用$today 变量的值作为文件名的一部分。日志文件的内容是/usr/bin 目录内容的列表输出。如果脚本在明天运行,日志文件名会是 log.201221,就这样为新的一天创建一个新文件。

命令替换会创建一个子 shell 来运行对应的命令。子 shell(subshell)是由运行该脚本的 shell 所创建出来的一个独立的子 shell(child shell)。正因如此,由该子 shell 所执行命令是无法使用脚本中所创建的变量的。

重定向输入和输出

有些时候你想要保存某个命令的输出而不仅仅只是让它显示在显示器上。bash shell 提供了几个操作符,可以将命令的输出重定向到另一个位置(比如文件)。重定向可以用于输入,也可以用于输出,可以将文件重定向到命令输入。

输出重定向

最基本的重定向将命令的输出发送到一个文件中。bash shell 用大于号(>)来完成这项功能,之前显示器上出现的命令输出会被保存到指定的输出文件中。

$ date > test6
$ ls -l test6
-rw-r--r--    1 user     user           29 Feb 10 17:56 test6
$ cat test6
Thu Feb 10 17:56:58 EDT 2020

重定向操作符创建了一个文件 test6(通过默认的 umask 设置),并将 date 命令的输出重定向到该文件中。如果输出文件已经存在了,重定向操作符会用新的文件数据覆盖已有文件。

有时,你可能并不想覆盖文件原有内容,而是想要将新命令的输出追加到已有文件内容的后面,比如你正在创建一个记录系统上某个操作的日志文件。在这种情况下,可以用双大于号(»)来追加数据。

$ who > test6
$ date >> test6
$ cat test6
user     pts/0    Feb 10 17:55
Thu Feb 10 18:02:14 EDT 2020

test6 文件仍然包含早些时候 who 命令的数据,现在又加上了来自 date 命令的输出。

输入重定向

输入重定向和输出重定向正好相反。输入重定向将文件的内容重定向到命令,而非将命令的输出重定向到文件。输入重定向符号是小于号(<)。

一个简单的记忆方法就是:在命令行上,命令总是在左侧,而重定向符号“指向”数据流动的方向。小于号说明数据正在从输入文件流向命令。这里有个和 wc 命令一起使用输入重定向的例子。

$ wc < test6
 2      11      60
$

wc 命令可以对对数据中的文本进行计数。默认情况下,它会输出 3 个值:

  • 文本的行数
  • 文本的词数
  • 文本的字节数

通过将文本文件重定向到 wc 命令,你立刻就可以得到文件中的行、词和字节的计数。这个例子说明 test6 文件有 2 行、11 个单词以及 60 字节。

还有另外一种输入重定向的方法,称为内联输入重定向(inline input redirection)。这种方法无需使用文件进行重定向,只需要在命令行中指定用于输入重定向的数据就可以了。乍看一眼,这可能有点奇怪,但有些应用会用到这种方式。

内联输入重定向符号是远小于号(«)。除了这个符号,你必须指定一个文本标记来划分输入数据的开始和结尾。任何字符串都可作为文本标记,但在数据的开始和结尾文本标记必须一致。

在命令行上使用内联输入重定向时,shell 会用 PS2 环境变量中定义的次提示符(即’>’符号)来提示输入数据。下面是它的使用情况。

$ wc << EOF
> test string 1
> test string 2
> test string 3
> EOF
 3       9      42
$

次提示符会持续提示,以获取更多的输入数据,直到你输入了作为文本标记的那个字符串。wc 命令会对内联输入重定向提供的数据进行行、词和字节计数。

管道

有时需要将一个命令的输出作为另一个命令的输入。这可以用重定向来实现,只是有些笨拙。

$ rpm -qa > rpm.list
$ sort < rpm.list
abrt-1.1.14-1.fc14.i686
abrt-addon-ccpp-1.1.14-1.fc14.i686
abrt-addon-kerneloops-1.1.14-1.fc14.i686
abrt-addon-python-1.1.14-1.fc14.i686
...

rpm 命令通过 Red Hat 包管理系统(RPM)对系统(比如上例中的 Fedora 系统)上安装的软件包进行管理。配合-qa 选项使用时,它会生成已安装包的列表,但这个列表并不会遵循某种特定的顺序。如果你在查找某个或某组特定的包,想在 rpm 命令的输出中找到就比较困难了。 过标准输出重定向,rpm 命令的输出被重定向到了文件 rpm.list。命令完成后,rpm.list 保存着系统中所有已安装的软件包列表。接下来,输入重定向将 rpm.list 文件的内容发送给 sort 命令,该命令按字母顺序对软件包名称进行排序。

这种方法的确管用,但仍然是一种比较繁琐的信息生成方式。我们用不着将命令输出重定向到文件中,可以将其直接重定向到另一个命令。这个过程叫作管道连接(piping)。管道被放在命令之间,将一个命令的输出重定向到另一个命令中:

command1 | command2

不要以为由管道串起的两个命令会依次执行。Linux 系统实际上会同时运行这两个命令,在系统内部将它们连接起来。在第一个命令产生输出的同时,输出会被立即送给第二个命令。数据传输不会用到任何中间文件或缓冲区。

现在,可以利用管道将 rpm 命令的输出送入 sort 命令来产生结果。

rpm -qa | sort

除非你的眼神特别好,否则可能根本来不及看清楚命令的输出。由于管道操作是实时运行的,所以只要 rpm 命令一输出数据,sort 命令就会立即对其进行排序。等到 rpm 命令输出完数据,sort 命令就已经将数据排好序并显示了在显示器上。

可以在一条命令中使用任意多条管道。可以持续地将命令的输出通过管道传给其他命令来细化操作。

在这个例子中,sort 命令的输出会一闪而过,所以可以用一条文本分页命令(例如 less 或 more)来强行将输出按屏显示。

$ rpm -qa | sort | more

这行命令序列会先执行 rpm 命令,将它的输出通过管道传给 sort 命令,然后再将 sort 的输出通过管道传给 more 命令来显示,在显示完一屏信息后停下来。这样你就可以在继续处理前停下来阅读显示器上显示的信息。

如果想要更别致点,也可以搭配使用重定向和管道来将输出保存到文件中。

$ rpm -qa | sort > rpm.list

不出所料,rpm.list 文件中的数据现在已经排好序了。

到目前为止,管道最流行的用法之一是将命令产生的大量输出通过管道传送给 more 命令。这对 ls 命令来说尤为常见,ls -l 命令产生了目录中所有文件的长列表。对包含大量文件的目录来说,这个列表会相当长。通过将输出管道连接到 more 命令,可以强制输出在一屏数据显示后停下来。

执行数学运算

另一个对任何编程语言都很重要的特性是操作数字的能力。遗憾的是,对 shell 脚本来说,这个处理过程会比较麻烦。在 shell 脚本中有两种途径来进行数学运算。

expr 命令

最开始,Bourne shell 提供了一个特别的命令用来处理数学表达式。expr 命令允许在命令行上处理数学表达式,但是特别笨拙。

$ expr 1 + 5
6

expr 命令能够识别少数的数学和字符串操作符。尽管标准操作符在 expr 命令中工作得很好,但在脚本或命令行上使用它们时仍有问题出现。许多 expr 命令操作符在 shell 中另有含义(比如星号)。当它们出现在在 expr 命令中时,会得到一些诡异的结果。

$ expr 5 * 2
expr: syntax error

要解决这个问题,对于那些容易被 shell 错误解释的字符,在它们传入 expr 命令之前,需要使用 shell 的转义字符(反斜线)将其标出来。

$ expr 5 \* 2
10

现在,麻烦才刚刚开始!在 shell 脚本中使用 expr 命令也同样复杂:

$ cat test6
#!/bin/bash
## An example of using the expr command
var1=10
var2=20
var3=$(expr $var2 / $var1) #命令替换的方式
echo The result is $var3

要将一个数学算式的结果赋给一个变量,需要使用命令替换来获取 expr 命令的输出:

$ chmod u+x test6
$ ./test6
The result is 2

幸好 bash shell 有一个针对处理数学运算符的改进,那就是方括号。

使用方括号

bash shell 为了保持跟 Bourne shell 的兼容而包含了 expr 命令,但它同样也提供了一种更简单的方法来执行数学表达式。在 bash 中,在将一个数学运算结果赋给某个变量时,可以用美元符和方括号($[ operation ])将数学表达式围起来。

$ var1=$[1 + 5]
$ echo $var1
6
$ var2=$[$var1 * 2]
$ echo $var2
12
$

用方括号执行 shell 数学运算比用 expr 命令方便很多。这种技术也适用于 shell 脚本。

$ cat test7
#!/bin/bash
var1=100
var2=50
var3=45
var4=$[$var1 * ($var2 - $var3)]
echo The final result is $var4  #The final result is 500

同样,注意在使用方括号来计算公式时,不用担心 shell 会误解乘号或其他符号。shell 知道它不是通配符,因为它在方括号内。

在 bash shell 脚本中进行算术运算会有一个主要的限制。请看下例:

$ cat test8
#!/bin/bash
var1=100
var2=45
var3=$[$var1 / $var2]
echo The final result is $var3 #The final result is 2
$

bash shell 数学运算符只支持整数运算。若要进行任何实际的数学计算,这是一个巨大的限制。

z shell(zsh)提供了完整的浮点数算术操作。如果需要在 shell 脚本中进行浮点数运算,可以考虑看看 z shell。

浮点解决方案

有几种解决方案能够克服 bash 中数学运算的整数限制。最常见的方案是用内建的 bash 计算器,叫作 bc。

bash 计算器实际上是一种编程语言,它允许在命令行中输入浮点表达式,然后解释并计算该表达式,最后返回结果。bash 计算器能够识别:

  • 数字(整数和浮点数)
  • 变量(简单变量和数组)
  • 注释(以#或 C 语言中的/* */开始的行)
  • 表达式
  • 编程语句(例如 if-then 语句)
  • 函数

可以在 shell 提示符下通过 bc 命令访问 bash 计算器:

$ bc
bc 1.07.1
Copyright 1991-1994, 1997, 1998, 2000, 2004, 2006, 2008, 2012-2017 Free Software Foundation, Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type `warranty'.

12 * 5.4
64.8

3.156 * (3 + 5)
25.248
quit

$

浮点运算是由内建变量 scale 控制的。必须将这个值设置为你希望在计算结果中保留的小数位数,否则无法得到期望的结果。

$ bc -q
3.44 / 5
0
scale=4
3.44 / 5
.6880
quit
$

scale 变量的默认值是 0。在 scale 值被设置前,bash 计算器的计算结果不包含小数位。在将其值设置成 4 后,bash 计算器显示的结果包含四位小数。-q 命令行选项可以不显示 bash 计算器冗长的欢迎信息。

除了普通数字,bash 计算器还能支持变量。

$ bc -q
var1=10
var1 * 4
40
var2 = var1 / 5
print var2
2
quit
$

变量一旦被定义,你就可以在整个 bash 计算器会话中使用该变量了。print 语句允许你打印变量和数字。


现在你可能想问 bash 计算器是如何在 shell 脚本中帮助处理浮点运算的。还记得命令替换吗?是的,可以用命令替换运行 bc 命令,并将输出赋给一个变量。

$ cat test9
#!/bin/bash
var1=$(echo "scale=4; 3.44 / 5" | bc)
echo The answer is $var1  #The answer is .6880

也可以用 shell 脚本中定义好的变量进行运算:

$ cat test10
#!/bin/bash
var1=100
var2=45
var3=$(echo "scale=4; $var1 / $var2" | bc)
echo The answer for this is $var3 #The answer for this is 2.2222

当然,一旦变量被赋值,那个变量也可以用于其他运算。

$ cat test11
#!/bin/bash
var1=20
var2=3.14159
var3=$(echo "scale=4; $var1 * $var1" | bc)
var4=$(echo "scale=4; $var3 * $var2" | bc)
echo The final result is $var4
$

这个方法适用于较短的运算,但有时你会涉及更多的数字。如果需要进行大量运算,在一个命令行中列出多个表达式就会有点麻烦。

有一个方法可以解决这个问题。bc 命令能识别输入重定向,允许你将一个文件重定向到 bc 命令来处理。但这同样会叫人头疼,因为你还得将表达式存放到文件中。

最好的办法是使用内联输入重定向,它允许你直接在命令行中重定向数据,可以将所有 bash 计算器涉及的部分都放到同一个脚本文件的不同行。下面是在脚本中使用这种技术的例子。

$ cat test12
#!/bin/bash
var1=10.46
var2=43.67
var3=33.2
var4=71
var5=$(bc << EOF
scale = 4
a1 = ($var1 * $var2)
b1 = ($var3 * $var4)
a1 + b1
EOF
)
echo The final answer for this mess is $var5

将选项和表达式放在脚本的不同行中可以让处理过程变得更清晰,提高易读性。EOF 字符串标识了重定向给 bc 命令的数据的起止。当然,必须用命令替换符号标识出用来给变量赋值的命令。

你还会注意到,在这个例子中,你可以在 bash 计算器中赋值给变量。有一点很重要:在 bash 计算器中创建的变量只在 bash 计算器中有效,不能在 shell 脚本中使用。

退出脚本

迄今为止所有的示例脚本中,我们都是直接停止的。运行完最后一条命令时,脚本就结束了。其实还有另外一种更优雅的方法可以为脚本划上一个句号。

shell 中运行的每个命令都使用退出状态码(exit status)告诉 shell 它已经运行完毕。退出状态码是一个 0 ~ 255 的整数值,在命令结束运行时由命令传给 shell。可以捕获这个值并在脚本中使用。

查看退出状态码

Linux 提供了一个专门的变量$?来保存上个已执行命令的退出状态码。对于需要进行检查的命令,必须在其运行完毕后立刻查看或使用$?变量。它的值会变成由 shell 所执行的最后一条命令的退出状态码。

$ date
Sun Dec 20 01:35:39 PM CST 2020
$ echo $?
0
$

按照惯例,一个成功结束的命令的退出状态码是 0。如果一个命令结束时有错误,退出状态码就是一个正数值。

$ asdfg
-bash: asdfg: command not found
$ echo $?
127

无效命令会返回一个退出状态码 127。Linux 错误退出状态码没有什么标准可循,但有一些可用的参考。

  • 0 命令成功结束
  • 1 一般性未知错误
  • 2 不适合的 shell 命令
  • 126 命令不可执行
  • 127 没找到命令
  • 128 无效的退出参数
  • 128+x 与 Linux 信号 x 相关的严重错误
  • 130 通过 Ctrl+C 终止的命令
  • 225 正常范围之外的退出状态码

退出状态码 126 表明用户没有执行命令的正确权限。

$ ./myprog.c
-bash: ./myprog.c: Permission denied
$ echo $?
126
$

另一个会碰到的常见错误是给某个命令提供了无效参数。

$ date %t
date: invalid date '%t'
$ echo $?
1
$

这会产生一般性的退出状态码 1,表明在命令中发生了未知错误。

exit 命令

默认情况下,shell 脚本会以脚本中的最后一个命令的退出状态码退出。你可以改变这种默认行为,返回自己的退出状态码。exit 命令允许你在脚本结束时指定一个退出状态码。

$ cat test13
#!/bin/bash
## testing the exit status
var1=10
var2=30
var3=$[$var1 + $var2]
echo The answer is $var3
exit 5
$

当查看脚本的退出码时,你会得到作为参数传给 exit 命令的值。

$ chmod u+x test13
$ ./test13
The answer is 40
$ echo $?
5
$

也可以在 exit 命令的参数中使用变量。

$ cat test14
#!/bin/bash
## testing the exit status
var1=10
var2=30
var3=$[$var1 + $var2]
exit $var3
$

当你运行这个命令时,它会产生如下退出状态。

$ chmod u+x test14
$ ./test14
$ echo $?
40
$

在以往,exit 退出状态码最大只能是 255,如果超过了 255,最终的结果是指定的数值除以 256 后得到的余数。比如,指定的值是 300(返回值),余数是 44,因此这个余数就成了最后的状态退出码。但是在现在,此限制已经不存在,你可以使用 exit 指令指定更大的数值。

到目前为止,脚本中的命令都是按照有序的方式一个接着一个处理的。在下章中,你将学习如何用一些逻辑流程控制来更改命令的执行次序,也会了解到如何用 if-then 语句来检查某个命令返回的错误状态,以便知道命令是否成功。

脚本结构化命令

上一章给出的那些 shell 脚本里,shell 按照命令在脚本中出现的顺序依次进行处理。对顺序操作来说,这已经足够了,因为在这种操作环境下,你想要的就是所有的命令按照正确的顺序执行。然而,并非所有程序都如此操作。 许多程序要求对 shell 脚本中的命令施加一些逻辑流程控制。有一类命令会根据条件使脚本跳过某些命令。这样的命令通常称为结构化命令(structured command)。 结构化命令允许你改变程序执行的顺序。在 bash shell 中有不少结构化命令,我们会逐个研究。

if 语句

最基本的结构化命令就是 if-then 语句。if-then 语句有如下格式。

if command
then
commands
fi

如果你在用其他编程语言的 if-then 语句,这种形式可能会让你有点困惑。在其他编程语言中,if 语句之后的对象是一个等式,这个等式的求值结果为 TRUE 或 FALSE。但 bash shell 的 if 语句并不是这么做的。

bash shell 的 if 语句会运行 if 后面的那个命令。如果该命令的退出状态码是 0(该命令成功运行),位于 then 部分的命令就会被执行。如果该命令的退出状态码是其他值,then 部分的命令就不会被执行,bash shell 会继续执行脚本中的下一个命令。fi 语句用来表示 if-then 语句到此结束。

这里有个简单的例子可解释这个概念。

$ cat test1.sh
#!/bin/bash
## testing the if statement
if pwd
then
    echo "It worked"
fi
$

这个脚本在 if 行采用了 pwd 命令。如果命令成功结束,echo 语句就会显示该文本字符串。在命令行运行该脚本时,会得到如下结果。

$ ./test1.sh
/home/Christine
It worked
$

shell 执行了 if 行中的 pwd 命令。由于退出状态码是 0,它就又执行了 then 部分的 echo 语句。

你可能在有些脚本中看到过 if-then 语句的另一种形式:

if command; then
    commands
fi

通过把分号放在待求值的命令尾部,就可以将 then 语句放在同一行上了,这样看起来更像其他编程语言中的 if-then 语句。

在 then 部分,你可以使用不止一条命令。可以像在脚本中的其他地方一样在这里列出多条命令。bash shell 会将这些命令当成一个块,如果 if 语句行的命令的退出状态值为 0,所有的命令都会被执行;如果 if 语句行的命令的退出状态不为 0,所有的命令都会被跳过。

$ cat test3.sh
#!/bin/bash
## testing multiple commands in the then section
#
testuser=Christine
#
if grep $testuser /etc/passwd; then
    echo "This is my first command"
    echo "This is my second command"
    echo "I can even put in other commands besides echo:"
    ls -a /home/$testuser/.b*
fi
$

if 语句行使用 grep 命令在/etc/passwd 文件中查找某个用户名当前是否在系统上使用。如果有用户使用了那个登录名,脚本会显示一些文本信息并列出该用户 HOME 目录的 bash 文件。

$ ./test3.sh
Christine:x:501:501:Christine B:/home/Christine:/bin/bash
This is my first command
This is my second command
I can even put in other commands besides echo:
/home/Christine/.bash_history  /home/Christine/.bash_profile
/home/Christine/.bash_logout   /home/Christine/.bashrc
$

但是,如果将 testuser 变量设置成一个系统上不存在的用户,则什么都不会显示。看起来也没什么新鲜的。如果在这里显示的一些消息可说明这个用户名在系统中未找到,这样可能就会显得更友好。此时可以用 if-then-else 语句来做到这一点。当 if 语句中的命令返回非零退出状态码时,bash shell 会执行 else 部分中的命令。现在可以复制并修改测试脚本来加入 else 部分。

$ cp test3.sh test4.sh
$
$ vim test4.sh
$
$ cat test4.sh
#!/bin/bash
## testing the else section
#
testuser=NoSuchUser
#
if grep $testuser /etc/passwd
then
    echo "The bash files for user $testuser are:"
    ls -a /home/$testuser/.b*
    echo
else
    echo "The user $testuser does not exist on this system."
    echo
fi
$
$ ./test4.sh
The user NoSuchUser does not exist on this system.
$

这样就更友好了。跟 then 部分一样,else 部分也可以包含多条命令。

嵌套 if

有时你需要检查脚本代码中的多种条件。对此,可以使用嵌套的 if-then 语句。

要检查/etc/passwd 文件中是否存在某个用户名以及该用户的目录是否尚在,可以使用嵌套的 if-then 语句。嵌套的 if-then 语句位于主 if-then-else 语句的 else 代码块中。

$ ls -d /home/NoSuchUser/
/home/NoSuchUser/
$
$ cat test5.sh
#!/bin/bash
## Testing nested ifs
#
testuser=NoSuchUser
#
if grep $testuser /etc/passwd
then
echo "The user $testuser exists on this system."
else
    echo "The user $testuser does not exist on this system."
    if ls -d /home/$testuser/
    then
        echo "However, $testuser has a directory."
    fi
fi
$
$ ./test5.sh
The user NoSuchUser does not exist on this system.
/home/NoSuchUser/
However, NoSuchUser has a directory.
$

这个脚本准确无误地发现,尽管登录名已经从/etc/passwd 中删除了,但是该用户的目录仍然存在。在脚本中使用这种嵌套 if-then 语句的问题在于代码不易阅读,很难理清逻辑流程。 可以使用 else 部分的另一种形式:elif。这样就不用再书写多个 if-then 语句了。elif 使用另一个 if-then 语句延续 else 部分。elif 语句行提供了另一个要测试的命令,这类似于原始的 if 语句行。如果 elif 后命令的退出状态码是 0,则 bash 会执行第二个 then 语句部分的命令。使用这种嵌套方法,代码更清晰,逻辑更易懂。甚至可以更进一步,让脚本检查拥有目录的不存在用户以及没有拥有目录的不存在用户。这可以通过在嵌套 elif 中加入一个 else 语句来实现。

$ cat test5.sh
#!/bin/bash
## Testing nested ifs - use elif & else
#
testuser=NoSuchUser
#
if grep $testuser /etc/passwd
then
    echo "The user $testuser exists on this system."
#
elif ls -d /home/$testuser
then
    echo "The user $testuser does not exist on this system."
    echo "However, $testuser has a directory."
#
else
    echo "The user $testuser does not exist on this system."
    echo "And, $testuser does not have a directory."
fi
$
$ ./test5.sh
/home/NoSuchUser
The user NoSuchUser does not exist on this system.
However, NoSuchUser has a directory.
$
$ sudo rmdir /home/NoSuchUser
[sudo] password for Christine:
$
$ ./test5.sh
ls: cannot access /home/NoSuchUser: No such file or directory
The user NoSuchUser does not exist on this system.
And, NoSuchUser does not have a directory.
$

在/home/NoSuchUser 目录被删除之前,这个测试脚本执行的是 elif 语句,返回零值的退出状态。因此 elif 的 then 代码块中的语句得以执行。删除了/home/NoSuchUser 目录之后,elif 语句返回的是非零值的退出状态。这使得 elif 块中的 else 代码块得以执行。

记住,在 elif 语句中,紧跟其后的 else 语句属于 elif 代码块。它们并不属于之前的 if-then 代码块。

可以继续将多个 elif 语句串起来,形成一个大的 if-then-elif 嵌套组合。每块命令都会根据命令是否会返回退出状态码 0 来执行。记住,bash shell 会依次执行 if 语句,只有第一个返回退出状态码 0 的语句中的 then 部分会被执行。

if command1
then
    command set 1
elif command2
then
    command set 2
elif command3
then
    command set 3
elif command4
then
    command set 4
fi

尽管使用了 elif 语句的代码看起来更清晰,但是脚本的逻辑仍然会让人犯晕。在本章稍后你会看到如何使用 case 命令代替 if-then 语句的大量嵌套。

test 命令

到目前为止,在 if 语句中看到的都是普通 shell 命令。你可能想问,if-then 语句是否能直接测试命令退出状态码之外的条件。 答案是不能。但在 bash shell 中有个好用的工具可以帮你通过 if-then 语句测试其他条件。

test 命令提供了在 if-then 语句中测试不同条件的途径。如果 test 命令中列出的条件成立,test 命令就会退出并返回退出状态码 0。这样 if-then 语句就与其他编程语言中的 if-then 语句以类似的方式工作了。如果条件不成立,test 命令就会退出并返回非零的退出状态码,这使得 if-then 语句不会再被执行。

如果只执行 test 命令本身,不写 test 命令的条件部分,它会以非零的退出状态码退出,并执行 else 语句块。当你加入一个条件时,test 命令会测试该条件。例如,可以使用 test 命令确定变量中是否有内容。这只需要一个简单的条件表达式。

$ cat test6.sh
#!/bin/bash
## Testing the test command
#
my_variable="Full"
#
if test $my_variable
then
    echo "The $my_variable expression returns a True"
#
else
    echo "The $my_variable expression returns a False"
fi
$
$ ./test6.sh
The Full expression returns a True
$

变量 my_variable 中包含有内容(Full),因此当 test 命令测试条件时,返回的退出状态为 0。这使得 then 语句块中的语句得以执行。如你所料,如果该变量中没有包含内容,就会出现相反的情况。

bash shell 提供了另一种条件测试方法,无需在 if-then 语句中声明 test 命令。

if [ condition ]
then
    commands
fi

方括号定义了测试条件,是与 test 命令同义的特殊 bash 命令。注意,第一个方括号之后和第二个方括号之前必须加上一个空格,否则就会报错。 test 命令可以判断三类条件:

  • 数值比较
  • 字符串比较
  • 文件比较

接下来将会介绍如何在 if-then 语句中使用这些条件测试。

数值比较

使用 test 命令最常见的情形是对两个数值进行比较。如下列出了测试两个值时可用的条件参数。

  • n1 -eq n2 检查 n1 是否与 n2 相等
  • n1 -ge n2 检查 n1 是否大于或等于 n2
  • n1 -gt n2 检查 n1 是否大于 n2
  • n1 -le n2 检查 n1 是否小于或等于 n2
  • n1 -lt n2 检查 n1 是否小于 n2
  • n1 -ne n2 检查 n1 是否不等于 n2

数值条件测试可以用在数字和变量上。这里有个例子。

$ cat numeric_test.sh
#!/bin/bash
## Using numeric test evaluations
#
value1=10
value2=11
#
if [ $value1 -gt 5 ]
then
    echo "The test value $value1 is greater than 5"
fi
#
if [ $value1 -eq $value2 ]
then
    echo "The values are equal"
else
    echo "The values are different"
fi
#
$

第一个条件测试测试变量 value1 的值是否大于 5。第二个条件测试测试变量 value1 的值是否和变量 value2 的值相等。两个数值条件测试的结果和预想一致。

但是涉及浮点值时,数值条件测试会有一个限制。bash shell 只能处理整数。如果你只是要通过 echo 语句来显示这个结果,那没问题。但是,在基于数字的函数中就不行了,比如数值测试条件,不能在 test 命令中使用浮点值。

字符串比较

条件测试还允许比较字符串值。比较字符串比较烦琐。

  • str1 = str2 检查 str1 是否和 str2 相同
  • str1 != str2 检查 str1 是否和 str2 不同
  • str1 < str2 检查 str1 是否比 str2 小
  • str1 > str2 检查 str1 是否比 str2 大
  • -n str1 检查 str1 的长度是否非 0
  • -z str1 检查 str1 的长度是否为 0

记住,在比较字符串的相等性时,比较测试会将所有的标点和大小写情况都考虑在内。

要测试一个字符串是否比另一个字符串大就是麻烦的开始。当要开始使用测试条件的大于或小于功能时,就会出现两个经常困扰 shell 程序员的问题:

  • 大于号和小于号必须转义,否则 shell 会把它们当作重定向符号,把字符串值当作文件名;
  • 大于和小于顺序和 sort 命令所采用的不同。

在编写脚本时,第一条可能会导致一个不易察觉的严重问题。下面的例子展示了 shell 脚本编程初学者时常碰到的问题。

$ cat badtest.sh
#!/bin/bash
## mis-using string comparisons
#
val1=baseball
val2=hockey
#
if [ $val1 > $val2 ]
then
    echo "$val1 is greater than $val2"
else
    echo "$val1 is less than $val2"
fi
$
$ ./badtest.sh
baseball is greater than hockey
$ ls -l hockey
-rw-r--r-- 1 rich rich 0 Sep 30 19:08 hockey
$

这个脚本中只用了大于号,没有出现错误,但结果是错的。脚本把大于号解释成了输出重定向。因此,它创建了一个名为 hockey 的文件。由于重定向的顺利完成,test 命令返回了退出状态码 0,if 语句便以为所有命令都成功结束了。要解决这个问题,就需要使用反斜杠\>正确转义大于号。

第二个问题更细微,除非你经常处理大小写字母,否则几乎遇不到。sort 命令处理大写字母的方法刚好跟 test 命令相反。比如两个变量val1=Testing val2=testing,在 test 命令中,大写字母被认为是小于小写字母的。但 sort 命令恰好相反。当你将同样的字符串放进文件中并用 sort 命令排序时,小写字母会先出现。这是由各个命令使用的排序技术不同造成的。

test 命令中使用的是标准的 ASCII 顺序,根据每个字符的 ASCII 数值来决定排序结果。sort 命令使用的是系统的本地化语言设置中定义的排序顺序。对于英语,本地化设置指定了在排序顺序中小写字母出现在大写字母前。

test 命令测试表达式使用标准的数学比较符号来表示字符串比较,而用文本代码来表示数值比较。这个细微的特性被很多程序员理解反了。如果你对数值使用了数学运算符号,shell 会将它们当成字符串值,可能无法得到正确的结果。

最后,-n 和-z 可以检查一个变量是否含有数据。如果一个变量为空字符串,或其从未被定义,那么均会被认为它的字符串长度为 0。

空的和未初始化的变量会对 shell 脚本测试造成灾难性的影响。如果不是很确定一个变量的内容,最好在将其用于数值或字符串比较之前先通过-n 或-z 来测试一下变量是否含有值。

文件比较

最后一类比较测试很有可能是 shell 编程中最为强大、也是用得最多的比较形式。它允许你测试 Linux 文件系统上文件和目录的状态。

  • -d file 检查 file 是否存在并是一个目录
  • -e file 检查 file 是否存在(文件或目录)
  • -f file 检查 file 是否存在并是一个文件
  • -r file 检查 file 是否存在并可读
  • -s file 检查 file 是否存在并非空
  • -w file 检查 file 是否存在并可写
  • -x file 检查 file 是否存在并可执行
  • -O file 检查 file 是否存在并属当前用户所有
  • -G file 检查 file 是否存在并且默认组与当前用户相同
  • file1 -nt file2 检查 file1 是否比 file2 新
  • file1 -ot file2 检查 file1 是否比 file2 旧

这些测试条件使你能够在 shell 脚本中检查文件系统中的文件。它们经常出现在需要进行文件访问的脚本中。鉴于其使用广泛,建议熟练掌握。用于比较文件路径是相对你运行该脚本的目录而言的。

需要注意的是,-G 比较会检查文件的默认组,如果它匹配了用户的默认组,则测试成功。由于-G 比较只会检查默认组而非用户所属的所有组,这会叫人有点困惑。如果文件的组被改成了某个组,用户也是其中的一员,但用户并不以其为默认组,此时-G 比较会失败,因为它只比较默认组,不会去比较其他的组。

此外,在比较两个文件的新旧时,这些比较都不会先检查文件是否存在,如果你要检查的文件已经移走,就会出现问题。在你尝试使用-nt 或-ot 比较文件之前,必须先确认文件是存在的。

复合条件测试

if-then 语句允许你使用布尔逻辑来组合测试。有两种布尔运算符可用:

  • [ condition1 ] && [ condition2 ]
  • [ condition1 ]   [ condition2 ]

结合方括号测试方式和布尔逻辑组合,可以测试更多条件。

if 语句的高级特性

bash shell 提供了两项可在 if-then 语句中使用的高级特性:

  • 用于数学表达式的双括号
  • 用于高级字符串处理功能的双方括号

使用双括号

双括号命令允许你在比较过程中使用高级数学表达式。test 命令只能在比较中使用简单的算术操作。双括号命令提供了更多的数学符号,这些符号对于用过其他编程语言的程序员而言并不陌生。除了 test 命令使用的标准数学运算符,如下列出了双括号命令中还可以使用的其他运算符。

  • val++ 后增
  • val– 后减
  • ++val 先增
  • –val 先减
  • ! 逻辑求反
  • ~ 位求反
  • ** 幂运算
  • « 左位移
  • >> 右位移
  • & 位布尔和
  • 位布尔或
  • && 逻辑和
  •   逻辑或

可以在 if 语句中用双括号命令,也可以在脚本中的普通命令里使用来赋值。

$ cat test23.sh
#!/bin/bash
## using double parenthesis
#
val1=10
#
if (( $val1 ** 2 > 90 ))
then
    (( val2 = $val1 ** 2 ))
    echo "The square of $val1 is $val2"
fi
$
$ ./test23.sh
The square of 10 is 100
$

注意,不需要将双括号中表达式里的大于号转义。这是双括号命令提供的另一个高级特性。

使用双方括号

双方括号命令提供了针对字符串比较的高级特性。双方括号使用了 test 命令中采用的标准字符串比较。但它提供了 test 命令未提供的另一个特性——模式匹配(pattern matching)。

双方括号在 bash shell 中工作良好。不过要小心,不是所有的 shell 都支持双方括号。

在模式匹配中,可以定义一个正则表达式(后续将详细讨论)来匹配字符串值。

$ cat test24.sh
#!/bin/bash
## using pattern matching
#
if [[ $USER == r* ]]
then
    echo "Hello $USER"
else
    echo "Sorry, I do not know you"
fi
$
$ ./test24.sh
Hello rich
$

在上面的脚本中,我们使用了双等号(==)。双等号将右边的字符串(r*)视为一个模式,并应用模式匹配规则。双方括号命令$USER 环境变量进行匹配,看它是否以字母 r 开头。如果是的话,比较通过,shell 会执行 then 部分的命令。

case 命令

你会经常发现自己在尝试计算一个变量的值,在一组可能的值中寻找特定值。在这种情形下,你不得不写出很长的 if-then-else 语句,就像下面这样。

$ cat test25.sh
#!/bin/bash
## looking for a possible value
#
if [ $USER = "rich" ]
then
    echo "Welcome $USER"
    echo "Please enjoy your visit"
elif [ $USER = "barbara" ]
then
    echo "Welcome $USER"
    echo "Please enjoy your visit"
elif [ $USER = "testing" ]
then
    echo "Special testing account"
elif [ $USER = "jessica" ]
then
    echo "Do not forget to logout when you're done"
else
    echo "Sorry, you are not allowed here"
fi
$
$ ./test25.sh
Welcome rich
Please enjoy your visit
$

elif 语句继续 if-then 检查,为比较变量寻找特定的值。有了 case 命令,就不需要再写出所有的 elif 语句来不停地检查同一个变量的值了。case 命令会采用列表格式来检查单个变量的多个值。

case 命令会将指定的变量与不同模式进行比较。如果变量和模式是匹配的,那么 shell 会执行为该模式指定的命令。可以通过竖线操作符在一行中分隔出多个模式模式。星号会捕获所有与已知模式不匹配的值。这里有个将 if-then-else 程序转换成用 case 命令的例子。

$ cat test26.sh
#!/bin/bash
## using the case command
#
case $USER in
rich | barbara)
    echo "Welcome, $USER"
    echo "Please enjoy your visit";;
testing)
    echo "Special testing account";;
jessica)
    echo "Do not forget to log off when you're done";;
*)
    echo "Sorry, you are not allowed here";;
esac
$
$ ./test26.sh
Welcome, rich
Please enjoy your visit
$

case 命令提供了一个更清晰的方法来为变量每个可能的值指定不同的选项。

更多的结构化命令

上一章里,你看到了如何通过检查命令的输出和变量的值来改变 shell 脚本程序的流程。本章会继续介绍能够控制 shell 脚本流程的结构化命令。你会了解如何重复一些过程和命令,也就是循环执行一组命令直至达到了某个特定条件。本章将会讨论和演示 bash shell 的循环命令 for、while 和 until 等。

for 命令

重复执行一系列命令在编程中很常见。通常你需要重复一组命令直至达到某个特定条件,比如处理某个目录下的所有文件、系统上的所有用户或是某个文本文件中的所有行。for 命令有几种不同的方式来读取列表中的值,下面几节将会介绍各种方式。

读取列表中的值

for 命令最基本的用法就是遍历 for 命令自身所定义的一系列值。

$ cat test1
 #!/bin/bash
## basic for command
for test in Alabama Alaska Arizona Arkansas California Colorado
do
    echo The next state is $test
done
echo "The last state we visited was $test"
test=Connecticut
echo "Wait, now we're visiting $test"
$ ./test1
The next state is Alabama
The next state is Alaska
The next state is Arizona
The next state is Arkansas
The next state is California
The next state is Colorado
The last state we visited was Colorado
Wait, now we're visiting Connecticut
$

每次 for 命令遍历值列表,它都会将列表中的下个值赋给$test变量。$test 变量可以像 for 命令语句中的其他脚本变量一样使用。在最后一次迭代后,$test 变量的值会在 shell 脚本的剩余部分一直保持有效。它会一直保持最后一次迭代的值(除非你修改了它)。$test 变量保持了其值,也允许我们修改它的值,并在 for 命令循环之外跟其他变量一样使用

事情并不会总像你在 for 循环中看到的那么简单。有时会遇到难处理的数据。有时 for 循环的值列表中可能存在中间有空格的值,此时使用单引号或者双引号将中间存在空格的值括起来即可。有时候,有的值自身中存在单引号或双引号,这时需要用另外一种相反的引号将其括起来,或者使用反斜杠转义即可正常使用。

从变量读取列表

通常 shell 脚本遇到的情况是,你将一系列值都集中存储在了一个变量中,然后需要遍历变量中的整个列表。也可以通过 for 命令完成这个任务。

$ cat test4
#!/bin/bash
## using a variable to hold the list
list="Alabama Alaska Arizona Arkansas Colorado"
list=$list" Connecticut"
for state in $list
do
echo "Have you ever visited $state?"
done
$ ./test4
Have you ever visited Alabama?
Have you ever visited Alaska?
Have you ever visited Arizona?
Have you ever visited Arkansas?
Have you ever visited Colorado?
Have you ever visited Connecticut?

$list变量包含了用于迭代的标准文本值列表。注意,代码还是用了另一个赋值语句向$list 变量包含的已有列表中添加(或者说是拼接)了一个值。这是向变量中存储的已有文本字符串尾部添加文本的一个常用方法。

从命令读取值

生成列表中所需值的另外一个途径就是使用命令的输出。可以用命令替换来执行任何能产生输出的命令,然后在 for 命令中使用该命令的输出。

$ cat test5
#!/bin/bash
## reading values from a file
file="states"
for state in $(cat $file)
do
echo "Visit beautiful $state"
done
$ cat states
Alabama
Alaska
Arizona
Arkansas
Colorado
Connecticut
Delaware
Florida
Georgia
$ ./test5
Visit beautiful Alabama
Visit beautiful Alaska
Visit beautiful Arizona
Visit beautiful Arkansas
Visit beautiful Colorado
Visit beautiful Connecticut
Visit beautiful Delaware
Visit beautiful Florida
Visit beautiful Georgia
$

这个例子在命令替换中使用了 cat 命令来输出文件 states 的内容。你会注意到 states 文件中每一行有一个州,而不是通过空格分隔的。for 命令仍然以每次一行的方式遍历了 cat 命令的输出,假定每个州都是在单独的一行上。但这并没有解决数据中有空格的问题。如果你列出了一个名字中有空格的州,for 命令仍然会将每个单词当作单独的值。这是有原因的,下一节我们将会了解。

test5 的代码范例将文件名赋给变量,文件名中没有加入路径。这要求文件和脚本位于同一个目录中。如果不是的话,你需要使用全路径名(不管是绝对路径还是相对路径)来引用文件位置。

更改字段分隔符

造成这个问题的原因是特殊的环境变量 IFS,叫作内部字段分隔符(internal field separator)。IFS 环境变量定义了 bash shell 用作字段分隔符的一系列字符。默认情况下,bash shell 会将下列字符当作字段分隔符:

  • 空格
  • 制表符
  • 换行符

如果 bash shell 在数据中看到了这些字符中的任意一个,它就会假定这表明了列表中一个新数据字段的开始。在处理可能含有空格的数据(比如文件名)时,这会非常麻烦,就像你在上一个脚本示例中看到的。

要解决这个问题,可以在 shell 脚本中临时更改 IFS 环境变量的值来限制被 bash shell 当作字段分隔符的字符。例如,如果你想修改 IFS 的值,使其只能识别换行符,那就必须这么做:

IFS=$'\n'

将这个语句加入到脚本中,告诉 bash shell 在数据值中忽略空格和制表符。对前一个脚本使用这种方法,shell 脚本就能够使用列表中含有空格的值了。

在处理代码量较大的脚本时,可能在一个地方需要修改 IFS 的值,然后忽略这次修改,在脚本的其他地方继续沿用 IFS 的默认值。一个可参考的安全实践是在改变 IFS 之前保存原来的 IFS 值,之后再恢复它。这种技术可以这样实现: IFS.OLD=$IFS IFS=$’\n’ IFS=$IFS.OLD 这就保证了在脚本的后续操作中使用的是 IFS 的默认值。在代码中使用新的>

还有其他一些 IFS 环境变量的绝妙用法。假定你要遍历一个文件中用冒号分隔的值(比如在/etc/passwd 文件中)。你要做的就是将 IFS 的值设为冒号

IFS=:

如果要指定多个 IFS 字符,只要将它们在赋值行串起来就行。

IFS=$'\n':;"

这个赋值会将换行符、冒号、分号和双引号作为字段分隔符。如何使用 IFS 字符解析数据没有任何限制。

用通配符读取目录

最后,可以用 for 命令来自动遍历目录中的文件。进行此操作时,必须在文件名或路径名中使用通配符。它会强制 shell 使用文件扩展匹配。文件扩展匹配是生成匹配指定通配符的文件名或路径名的过程。

如果不知道所有的文件名,这个特性在处理目录中的文件时就非常好用。

$ cat test6
#!/bin/bash
## iterate through all the files in a directory
for file in /home/rich/test/*
do
    if [ -d "$file" ]
    then
        echo "$file is a directory"
    elif [ -f "$file" ]
    then
    echo "$file is a file"
    fi
done
$ ./test6
/home/rich/test/dir1 is a directory
/home/rich/test/myprog.c is a file
/home/rich/test/myprog is a file
/home/rich/test/myscript is a file
/home/rich/test/newdir is a directory
/home/rich/test/newfile is a file
/home/rich/test/newfile2 is a file
/home/rich/test/testdir is a directory
/home/rich/test/testing is a file
/home/rich/test/testprog is a file
/home/rich/test/testprog.c is a file
$

for 命令会遍历/home/rich/test/*输出的结果。该代码用 test 命令测试了每个条目(使用方括号方法),以查看它是目录(通过-d 参数)还是文件(通过-f 参数)

注意,我们在这个例子的 if 语句中做了一些不同的处理

if [ -d "$file" ]

在 Linux 中,目录名和文件名中包含空格当然是合法的。要适应这种情况,应该将$file 变量用双引号圈起来。如果不这么做,遇到含有空格的目录名或文件名时就会有错误产生。

./test6: line 6: [: too many arguments
./test6: line 9: [: too many arguments

在 test 命令中,bash shell 会将额外的单词当作参数,进而造成错误。

也可以在 for 命令中列出多个目录通配符,将目录查找和列表合并进同一个 for 语句。

$ cat test7
#!/bin/bash
## iterating through multiple directories
for file in /home/rich/.b* /home/rich/badtest
do
    if [ -d "$file" ]
    then
        echo "$file is a directory"
    elif [ -f "$file" ]
    then
        echo "$file is a file"
    else
        echo "$file doesn't exist"
    fi
done

$ ./test7
/home/rich/.backup.timestamp is a file
/home/rich/.bash_history is a file
/home/rich/.bash_logout is a file
/home/rich/.bash_profile is a file
/home/rich/.bashrc is a file
/home/rich/badtest doesn't exist
$

for 语句首先使用了文件扩展匹配来遍历通配符生成的文件列表,然后它会遍历列表中的下一个文件。可以将任意多的通配符放进列表中。

注意,你可以在数据列表中放入任何东西。即使文件或目录不存在,for 语句也会尝试处理列表中的内容。在处理文件或目录时,这可能会是个问题。你无法知道你正在尝试遍历的目录是否存在:在处理之前测试一下文件或目录总是好的。

C 语言风格的 for 命令

如果你从事过 C 语言编程,可能会对 bash shell 中 for 命令的工作方式有点惊奇。在 C 语言中,for 循环通常定义一个变量,然后这个变量会在每次迭代时自动改变。通常程序员会将这个变量用作计数器,并在每次迭代中让计数器增一或减一。bash 的 for 命令也提供了这个功能。本节将会告诉你如何在 bash shell 脚本中使用 C 语言风格的 for 命令。

C 语言的 for 命令有一个用来指明变量的特定方法,一个必须保持成立才能继续迭代的条件,以及另一个在每个迭代中改变变量的方法。当指定的条件不成立时,for 循环就会停止。条件等式通过标准的数学符号定义。比如,考虑下面的 C 语言代码:

for (i = 0; i < 10; i++) {
    printf("The next number is %d\n", i);
}

这段代码产生了一个简单的迭代循环,其中变量 i 作为计数器。第一部分将一个默认值赋给该变量。中间的部分定义了循环重复的条件。当定义的条件不成立时,for 循环就停止迭代。最后一部分定义了迭代的过程。在每次迭代之后,最后一部分中定义的表达式会被执行。在本例中,i 变量会在每次迭代后增一。

bash shell 也支持一种 for 循环,它看起来跟 C 语言风格的 for 循环类似,但有一些细微的不同,其中包括一些让 shell 脚本程序员困惑的东西。以下是 bash 中 C 语言风格的 for 循环的基本格式。

for (( variableassignment ; condition ; iterationprocess ))

C 语言风格的 for 循环的格式会让 bash shell 脚本程序员摸不着头脑,因为它使用了 C 语言风格的变量引用方式而不是 shell 风格的变量引用方式。C 语言风格的 for 命令看起来如下。

for (( a = 1; a < 10; a++ ))

注意,有些部分并没有遵循 bash shell 标准的 for 命令:

  • 变量赋值可以有空格;
  • 条件中的变量不以美元符开头;
  • 迭代过程的算式未用 expr 命令格式。

shell 开发人员创建了这种格式以更贴切地模仿 C 语言风格的 for 命令。这虽然对 C 语言程序员来说很好,但也会把专家级的 shell 程序员弄得一头雾水。在脚本中使用 C 语言风格的 for 循环时要小心。

以下例子是在 bash shell 程序中使用 C 语言风格的 for 命令

$ cat test8
#!/bin/bash
## testing the C-style for loop
for (( i=1; i <= 3; i++ ))
do
    echo "The next number is $i"
done
$ ./test8
The next number is 1
The next number is 2
The next number is 3
$

for 循环通过定义好的变量(本例中是变量 i)来迭代执行这些命令。在每次迭代中,$i 变量包含了 for 循环中赋予的值。在每次迭代后,循环的迭代过程会作用在变量上,在本例中,变量增一。

C 语言风格的 for 命令也允许为迭代使用多个变量。循环会单独处理每个变量,你可以为每个变量定义不同的迭代过程。尽管可以使用多个变量,但你只能在 for 循环中定义一种条件。

$ cat test9
#!/bin/bash
## multiple variables
for (( a=1, b=10; a <= 3; a++, b-- ))
do
    echo "$a - $b"
done
$ ./test9
1 - 10
2 - 9
3 - 8
$

变量 a 和 b 分别用不同的值来初始化并且定义了不同的迭代过程。循环的每次迭代在增加变量 a 的同时减小了变量 b。

while 命令

while 命令某种意义上是 if-then 语句和 for 循环的混杂体。while 命令允许定义一个要测试的命令,然后循环执行一组命令,只要定义的测试命令返回的是退出状态码 0。它会在每次迭代的一开始测试 test 命令。在 test 命令返回非零退出状态码时,while 命令会停止执行那组命令。

while 命令的格式是:

while test command
do
    other commands
done

while 命令中定义的 test command 和 if-then 语句中的格式一模一样。可以使用任何普通的 bash shell 命令,或者用 test 命令进行条件测试,比如测试变量值。 while 命令的关键在于所指定的 test command 的退出状态码必须随着循环中运行的命令而改变。如果退出状态码不发生变化,while 循环就将一直不停地进行下去。 最常见的 test command 的用法是用方括号来检查循环命令中用到的 shell 变量的值。

$ cat test10
#!/bin/bash
## while command test

var1=10
while [ $var1 -gt 0 ]
do
    echo $var1
    var1=$[ $var1 - 1 ]
done
$ ./test10
10
9
8
7
6
5
4
3
2
1
$

while 命令定义了每次迭代时检查的测试条件:while [ $var1 -gt 0 ] 。只要测试条件成立,while 命令就会不停地循环执行定义好的命令。在这些命令中,测试条件中用到的变量必须被修改,否则就会陷入无限循环。在本例中,我们用 shell 算术来将变量值减一:var1=$[ $var1 - 1 ] 。while 循环会在测试条件不再成立时停止。

while 命令允许你在 while 语句行定义多个测试命令。只有最后一个测试命令的退出状态码会被用来决定什么时候结束循环。如果你不够小心,可能会导致一些有意思的结果。下面的例子将说明这一点。

$ cat test11
#!/bin/bash
## testing a multicommand while loop
var1=10
while echo $var1
    [ $var1 -ge 0 ]
do
    echo "This is inside the loop"
    var1=$[ $var1 - 1 ]
done
$ ./test11
10
This is inside the loop
9
This is inside the loop
8
This is inside the loop
7
This is inside the loop
6
This is inside the loop
5
This is inside the loop
4
This is inside the loop
3
This is inside the loop
2
This is inside the loop
1
This is inside the loop
0
This is inside the loop
-1

while 语句中定义了两个测试命令。第一个测试简单地显示了 var1 变量的当前值。第二个测试用方括号来判断 var1 变量的值。在循环内部,echo 语句会显示一条简单的消息,说明循环被执行了。注意当你运行本例时输出最后还有一个-1。

while 循环会在 var1 变量等于 0 时执行 echo 语句,然后将 var1 变量的值减一。接下来再次执行测试命令,用于下一次迭代。echo 测试命令被执行并显示了 var 变量的值(现在小于 0 了)。直到 shell 执行 test 测试命令,whle 循环才会停止。

until 命令

until 命令和 while 命令工作的方式完全相反。until 命令要求你指定一个通常返回非零退出状态码的测试命令。只有测试命令的退出状态码不为 0,bash shell 才会执行循环中列出的命令。一旦测试命令返回了退出状态码 0,循环就结束了。和 while 命令类似,你可以在 until 命令语句中放入多个测试命令。只有最后一个命令的退出状态码决定了 bash shell 是否执行已定义的 other commands。下面是使用 until 命令的一个例子。

$ cat test12
#!/bin/bash
## using the until command
var1=100
until [ $var1 -eq 0 ]
do
    echo $var1
    var1=$[ $var1 - 25 ]
done
$ ./test12
100
75
50
25
$

本例中会测试 var1 变量来决定 until 循环何时停止。只要该变量的值等于 0,until 命令就会停止循环。同 while 命令一样,在 until 命令中使用多个测试命令时要注意。

$ cat test13
#!/bin/bash
## using the until command
var1=100
until echo $var1
    [ $var1 -eq 0 ]
do
    echo Inside the loop: $var1
    var1=$[ $var1 - 25 ]
done
$ ./test13
100
Inside the loop: 100
75
Inside the loop: 75
50
Inside the loop: 50
25
Inside the loop: 25
0
$

嵌套循环

循环语句可以在循环内使用任意类型的命令,包括其他循环命令。这种循环叫作嵌套循环(nested loop)。注意,在使用嵌套循环时,你是在迭代中使用迭代,与命令运行的次数是乘积关系。不注意这点的话,有可能会在脚本中造成问题。

$ cat test14
#!/bin/bash
## nesting for loops
for (( a = 1; a <= 3; a++ ))
do
    echo "Starting loop $a:"
    for (( b = 1; b <= 3; b++ ))
        do
            echo "   Inside loop: $b"
        done
done
$ ./test14
Starting loop 1:
    Inside loop: 1
    Inside loop: 2
    Inside loop: 3
Starting loop 2:
    Inside loop: 1
    Inside loop: 2
    Inside loop: 3
Starting loop 3:
    Inside loop: 1
    Inside loop: 2
    Inside loop: 3
$

这个被嵌套的循环(也称为内部循环,inner loop)会在外部循环的每次迭代中遍历一次它所有的值。注意,两个循环的 do 和 done 命令没有任何差别。bash shell 知道当第一个 done 命令执行时是指内部循环而非外部循环。

在混用循环命令时也一样,比如在 while 循环内部放置一个 for 循环。

$ cat test15
#!/bin/bash
## placing a for loop inside a while loop
var1=5
while [ $var1 -ge 0 ]
do
    echo "Outer loop: $var1"
    for (( var2 = 1; $var2 < 3; var2++ ))
    do
        var3=$[ $var1 * $var2 ]
        echo "  Inner loop: $var1 * $var2 = $var3"
    done
var1=$[ $var1 - 1 ]
done
$ ./test15
Outer loop: 5
    Inner loop: 5 * 1 = 5
    Inner loop: 5 * 2 = 10
Outer loop: 4
    Inner loop: 4 * 1 = 4
    Inner loop: 4 * 2 = 8
Outer loop: 3
    Inner loop: 3 * 1 = 3
    Inner loop: 3 * 2 = 6
Outer loop: 2
    Inner loop: 2 * 1 = 2
    Inner loop: 2 * 2 = 4
Outer loop: 1
    Inner loop: 1 * 1 = 1
    Inner loop: 1 * 2 = 2
Outer loop: 0
    Inner loop: 0 * 1 = 0
    Inner loop: 0 * 2 = 0
$

同样,shell 能够区分开内部 for 循环和外部 while 循环各自的 do 和 done 命令。如果真的想挑战脑力,可以混用 until 和 while 循环。

$ cat test16
#!/bin/bash
## using until and while loops
var1=3
until [ $var1 -eq 0 ]
do
    echo "Outer loop: $var1"
    var2=1
    while [ $var2 -lt 5 ]
    do
        var3=$(echo "scale=4; $var1 / $var2" | bc)
        echo "   Inner loop: $var1 / $var2 = $var3"
        var2=$[ $var2 + 1 ]
    done
    var1=$[ $var1 - 1 ]
done
$ ./test16
Outer loop: 3
    Inner loop: 3 / 1 = 3.0000
    Inner loop: 3 / 2 = 1.5000
    Inner loop: 3 / 3 = 1.0000
    Inner loop: 3 / 4 = .7500
Outer loop: 2
    Inner loop: 2 / 1 = 2.0000
    Inner loop: 2 / 2 = 1.0000
    Inner loop: 2 / 3 = .6666
    Inner loop: 2 / 4 = .5000
Outer loop: 1
    Inner loop: 1 / 1 = 1.0000
    Inner loop: 1 / 2 = .5000
    Inner loop: 1 / 3 = .3333
    Inner loop: 1 / 4 = .2500
$

外部的 until 循环以值 3 开始,并继续执行到值等于 0。内部 while 循环以值 1 开始并一直执行,只要值小于 5。每个循环都必须改变在测试条件中用到的值,否则循环就会无止尽进行下去。

循环处理文件数据

如果需要遍历存储在文件中的数据,则需要结合已经讲过的两种技术:

  • 使用嵌套循环
  • 修改 IFS 环境变量

通过修改 IFS 环境变量,就能强制 for 命令将文件中的每行都当成单独的一个条目来处理,即便数据中有空格也是如此。一旦从文件中提取出了单独的行,可能需要再次利用循环来提取行中的数据。

典型的例子是处理/etc/passwd 文件中的数据。这要求你逐行遍历/etc/passwd 文件,然后将 IFS 变量的值改成冒号,这样就能分隔开每行中的各个数据段了。

#!/bin/bash
## changing the IFS value
IFS.OLD=$IFS
IFS=$'\n'
for entry in $(cat /etc/passwd)
do
    echo "Values in $entry –"
    IFS=:
    for value in $entry
    do
        echo "   $value"
    done
done
$

这个脚本使用了两个不同的 IFS 值来解析数据。第一个 IFS 值解析出/etc/passwd 文件中的单独的行。内部 for 循环接着将 IFS 的值修改为冒号,允许你从/etc/passwd 的行中解析出单独的值。内部循环会解析出/etc/passwd 每行中的各个值。这种方法在处理外部导入电子表格所采用的逗号分隔的数据时也很方便。

控制循环

你可能会想,一旦启动了循环,就必须苦等到循环完成所有的迭代。并不是这样的。有两个命令能帮我们控制循环内部的情况:

  • break 命令
  • continue 命令

每个命令在如何控制循环的执行方面有不同的用法。下面几节将介绍如何使用这些命令来控制循环。

break 命令

break 命令是退出循环的一个简单方法。可以用 break 命令来退出任意类型的循环,包括 while 和 until 循环。有几种情况可以使用 break 命令,本节将介绍这些方法。

  1. 跳出单个循环

在 shell 执行 break 命令时,它会尝试跳出当前正在执行的循环。

cat test17
#!/bin/bash
## breaking out of a for loop
for var1 in 1 2 3 4 5 6 7 8 9 10
do
    if [ $var1 -eq 5 ]
    then
        break
    fi
    echo "Iteration number: $var1"
done
echo "The for loop is completed"


$ ./test17
Iteration number: 1
Iteration number: 2
Iteration number: 3
Iteration number: 4
The for loop is completed
$

for 循环通常都会遍历列表中指定的所有值。但当满足 if-then 的条件时,shell 会执行 break 命令,停止 for 循环。这种方法同样适用于 while 和 until 循环。

$ cat test18
#!/bin/bash
## breaking out of a while loop
var1=1
while [ $var1 -lt 10 ]
do
    if [ $var1 -eq 5 ]
    then
        break
    fi
    echo "Iteration: $var1"
    var1=$[ $var1 + 1 ]
done
echo "The while loop is completed"
$ ./test18
Iteration: 1
Iteration: 2
Iteration: 3
Iteration: 4
The while loop is completed
$

while 循环会在 if-then 的条件满足时执行 break 命令,终止。

  1. 跳出内部循环

在处理多个循环时,break 命令会自动终止你所在的最内层的循环。

$ cat test19
#!/bin/bash
## breaking out of an inner loop
for (( a = 1; a < 4; a++ ))
do
    echo "Outer loop: $a"
    for (( b = 1; b < 100; b++ ))
    do
        if [ $b -eq 5 ]
        then
            break
        fi
        echo "   Inner loop: $b"
    done
done
$ ./test19
Outer loop: 1
    Inner loop: 1
    Inner loop: 2
    Inner loop: 3
    Inner loop: 4
Outer loop: 2
    Inner loop: 1
    Inner loop: 2
    Inner loop: 3
    Inner loop: 4
Outer loop: 3
    Inner loop: 1
    Inner loop: 2
    Inner loop: 3
    Inner loop: 4
$

内部循环里的 for 语句指明当变量 b 等于 100 时停止迭代。但内部循环的 if-then 语句指明当变量 b 的值等于 5 时执行 break 命令。注意,即使内部循环通过 break 命令终止了,外部循环依然继续执行。

  1. 跳出外部循环

有时你在内部循环,但需要停止外部循环。break 命令接受单个命令行参数值:

break n

其中 n 指定了要跳出的循环层级。默认情况下,n 为 1,表明跳出的是当前的循环。如果你将 n 设为 2,break 命令就会停止下一级的外部循环。

$ cat test20
#!/bin/bash
## breaking out of an outer loop
for (( a = 1; a < 4; a++ ))
do
    echo "Outer loop: $a"
    for (( b = 1; b < 100; b++ ))
    do
        if [ $b -gt 4 ]
        then
            break 2
        fi
        echo "   Inner loop: $b"
    done
done
$ ./test20
Outer loop: 1
    Inner loop: 1
    Inner loop: 2
    Inner loop: 3
    Inner loop: 4
$

注意,当 shell 执行了 break 命令后,外部循环就停止了。

continue 命令

continue 命令可以提前中止某次循环中的命令,但并不会完全终止整个循环。可以在循环内部设置 shell 不执行命令的条件。这里有个在 for 循环中使用 continue 命令的简单例子。

$ cat test21
#!/bin/bash
## using the continue command
for (( var1 = 1; var1 < 15; var1++ ))
do
    if [ $var1 -gt 5 ] && [ $var1 -lt 10 ]
    then
        continue
    fi
    echo "Iteration number: $var1"
done
$ ./test21
Iteration number: 1
Iteration number: 2
Iteration number: 3
Iteration number: 4
Iteration number: 5
Iteration number: 10
Iteration number: 11
Iteration number: 12
Iteration number: 13
Iteration number: 14
$

当 if-then 语句的条件被满足时(值大于 5 且小于 10),shell 会执行 continue 命令,跳过此次循环中剩余的命令,但整个循环还会继续。当 if-then 的条件不再被满足时,一切又回到正轨。

也可以在 while 和 until 循环中使用 continue 命令,但要特别小心。记住,当 shell 执行 continue 命令时,它会跳过剩余的命令。如果你在其中某个条件里对测试条件变量进行增值,问题就会出现。

$ cat badtest3
#!/bin/bash
## improperly using the continue command in a while loop
var1=0
while echo "while iteration: $var1"
    [ $var1 -lt 15 ]
do
    if [ $var1 -gt 5 ] && [ $var1 -lt 10 ]
    then
        continue
    fi
    echo "   Inside iteration number: $var1"
    var1=$[ $var1 + 1 ]
done
$ ./badtest3 | more
while iteration: 0
    Inside iteration number: 0
while iteration: 1
    Inside iteration number: 1
while iteration: 2
    Inside iteration number: 2
while iteration: 3
    Inside iteration number: 3
while iteration: 4
    Inside iteration number: 4
while iteration: 5
    Inside iteration number: 5
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6
while iteration: 6

你得确保将脚本的输出重定向到了 more 命令,这样才能停止输出。在 if-then 的条件成立之前,所有一切看起来都很正常,然后 shell 执行了 continue 命令。当 shell 执行 continue 命令时,它跳过了 while 循环中余下的命令。不幸的是,被跳过的部分正是$var1 计数变量增值的地方,而这个变量又被用于 while 测试命令中。这意味着这个变量的值不会再变化了,从前面连续的输出显示中你也可以看出来。

和 break 命令一样,continue 命令也允许通过命令行参数指定要继续执行哪一级循环:

continue n

其中 n 定义了要继续的循环层级。下面是继续外部 for 循环的一个例子。

$ cat test22
#!/bin/bash
## continuing an outer loop
for (( a = 1; a <= 5; a++ ))
do
    echo "Iteration $a:"
    for (( b = 1; b < 3; b++ ))
    do
        if [ $a -gt 2 ] && [ $a -lt 4 ]
        then
            continue 2
        fi
        var3=$[ $a * $b ]
        echo "   The result of $a * $b is $var3"
    done
done

$ ./test22
Iteration 1:
    The result of 1 * 1 is 1
    The result of 1 * 2 is 2
Iteration 2:
    The result of 2 * 1 is 2
    The result of 2 * 2 is 4
Iteration 3:
Iteration 4:
    The result of 4 * 1 is 4
    The result of 4 * 2 is 8
Iteration 5:
    The result of 5 * 1 is 5
    The result of 5 * 2 is 10
$

此处用 continue 命令来停止处理循环内的命令,但会继续处理外部循环。注意,值为 3 的那次迭代并没有处理任何内部循环语句。尽管 continue 命令停止了内部处理过程,但外部循环依然会继续。

处理循环的输出

最后,在 shell 脚本中,你可以对循环的输出使用管道或进行重定向。这可以通过在 done 命令之后添加一个处理命令来实现。

for file in /home/rich/*
do
    if [ -d "$file" ]
    then
        echo "$file is a directory"
    elif
        echo "$file is a file"
    fi
done > output.txt

shell 会将 for 命令的结果重定向到文件 output.txt 中,而不是显示在屏幕上。考虑下面将 for 命令的输出重定向到文件的例子。

$ cat test23
#!/bin/bash
## redirecting the for output to a file
for (( a = 1; a < 10; a++ ))
do
    echo "The number is $a"
done > test23.txt
echo "The command is finished."
$ ./test23
The command is finished.
$ cat test23.txt
The number is 1
The number is 2
The number is 3
The number is 4
The number is 5
The number is 6
The number is 7
The number is 8
The number is 9
$

shell 创建了文件 test23.txt 并将 for 命令的输出重定向到这个文件。shell 在 for 命令之后正常显示了 echo 语句。

这种方法同样适用于将循环的结果管接给另一个命令。

$ cat test24
#!/bin/bash
## piping a loop to another command
for state in "North Dakota" Connecticut Illinois Alabama Tennessee
do
    echo "$state is the next place to go"
done | sort
echo "This completes our travels"
$ ./test24
Alabama is the next place to go
Connecticut is the next place to go
Illinois is the next place to go
North Dakota is the next place to go
Tennessee is the next place to go
This completes our travels
$

state 值并没有在 for 命令列表中以特定次序列出。for 命令的输出传给了 sort 命令,该命令会改变 for 命令输出结果的顺序。运行这个脚本实际上说明了结果已经在脚本内部排好序了。

实战例子

现在你已经看到了 shell 脚本中各种循环的使用方法,来看一些实际应用的例子吧。循环是对系统数据进行迭代的常用方法,无论是目录中的文件还是文件中的数据。下面的一些例子演示了如何使用简单的循环来处理数据。

查找可执行文件

当你从命令行中运行一个程序的时候,Linux 系统会搜索一系列目录来查找对应的文件。这些目录被定义在环境变量 PATH 中。如果你想找出系统中有哪些可执行文件可供使用,只需要扫描 PATH 环境变量中所有的目录就行了。如果要徒手查找的话,就得花点时间了。不过我们可以编写一个小小的脚本,轻而易举地搞定这件事。

首先是创建一个 for 循环,对环境变量 PATH 中的目录进行迭代。处理的时候别忘了设置 IFS 分隔符。

IFS=:
for folder in $PATH
do

现在你已经将各个目录存放在了变量$folder 中,可以使用另一个 for 循环来迭代特定目录中的所有文件。

for file in $folder/*
do

最后一步是检查各个文件是否具有可执行权限,你可以使用 if-then 测试功能来实现。

if [ -x $file ]
then
    echo "   $file"
fi

好了,搞定了!将这些代码片段组合成脚本就行了。

$ cat test25
#!/bin/bash
## finding files in the PATH
IFS=:
for folder in $PATH
do
    echo "$folder:"
    for file in $folder/*
    do
        if [ -x $file ]
        then
            echo "   $file"
        fi
    done
done
$

运行这段代码时,你会得到一个可以在命令行中使用的可执行文件的列表。输出显示了在环境变量 PATH 所包含的所有目录中找到的全部可执行文件。

创建多个用户账户

shell 脚本的目标是让系统管理员过得更轻松。如果你碰巧工作在一个拥有大量用户的环境中,最烦人的工作之一就是创建新用户账户。好在可以使用 while 循环来降低工作的难度。

你不用为每个需要创建的新用户账户手动输入 useradd 命令,而是可以将需要添加的新用户账户放在一个文本文件中,然后创建一个简单的脚本进行处理。这个文本文件的格式如下:

userid,user name

第一个条目是你为新用户账户所选用的用户 ID。第二个条目是用户的全名。两个值之间使用逗号分隔,这样就形成了一种名为逗号分隔值的文件格式(或者是.csv)。这种文件格式在电子表格中极其常见,所以你可以轻松地在电子表格程序中创建用户账户列表,然后将其保存成.csv 格式,以备 shell 脚本读取及处理。

要读取文件中的数据,得用上一点 shell 脚本编程技巧。我们将 IFS 分隔符设置成逗号,并将其放入 while 语句的条件测试部分。然后使用 read 命令读取文件中的各行。实现代码如下:

while IFS=’,’ read –r userid name

read 命令会自动读取.csv 文本文件的下一行内容,所以不需要专门再写一个循环来处理。当 read 命令返回 FALSE 时(也就是读取完整个文件时),while 命令就会退出。妙极了! 要想把数据从文件中送入 while 命令,只需在 while 命令尾部使用一个重定向符就可以了。将各部分处理过程写成脚本如下。

$ cat test26
#!/bin/bash
## process new user accounts
input="users.csv"
while IFS=',' read -r userid name
do
    echo "adding $userid"
    useradd -c "$name" -m $userid
done < "$input"
$

$input 变量指向数据文件,并且该变量被作为 while 命令的重定向数据。users.csv 文件内容如下。

$ cat users.csv
rich,Richard Blum
christine,Christine Bresnahan
barbara,Barbara Blum
tim,Timothy Bresnahan
$

必须作为 root 用户才能运行这个脚本,因为 useradd 命令需要 root 权限。执行此脚本后,看一眼/etc/passwd 文件,你会发现账户已经创建好了。


循环是编程的一部分。bash shell 提供了三种可用于脚本中的循环命令。for 命令允许你遍历一系列的值,不管是在命令行里提供好的、包含在变量中的还是通过文件扩展匹配获得的文件名和目录名。while 命令使用普通命令或测试命令提供了基于命令条件的循环。只有在命令(或条件)产生退出状态码 0 时,while 循环才会继续迭代指定的一组命令。until 命令也提供了迭代命令的一种方法,但它的迭代是建立在命令(或条件)产生非零退出状态码的基础上。这个特性允许你设置一个迭代结束前都必须满足的条件。可以在 shell 脚本中对循环进行组合,生成多层循环。bash shell 提供了 continue 和 break 命令,允许你根据循环内的不同值改变循环的正常流程。bash shell 还允许使用标准的命令重定向和管道来改变循环的输出。你可以使用重定向来将循环的输出重定向到一个文件或是另一个命令。这就为控制 shell 脚本执行提供了丰富的功能。下一章将会讨论如何和 shell 脚本用户交互。shell 脚本通常并不完全是自成一体的。它们需要在运行时被提供某些外部数据。下一章将讨论各种可用来向 shell 脚本提供实时数据的方法。

处理输入

到目前为止,你已经看到了如何编写脚本,处理数据、变量和 Linux 系统上的文件。有时,你编写的脚本还得能够与使用者进行交互。bash shell 提供了一些不同的方法来从用户处获得数据,包括命令行参数(添加在命令后的数据)、命令行选项(可修改命令行为的单个字母)以及直接从键盘读取输入的能力。本章将会讨论如何在你的 bash shell 脚本运用这些方法来从脚本用户处获得数据。

命令行参数

向 shell 脚本传递数据的最基本方法是使用命令行参数。命令行参数允许在运行脚本时向命令行添加数据。

$ ./addem 10 30

本例向脚本 addem 传递了两个命令行参数(10 和 30)。脚本会通过特殊的变量来处理命令行参数。后面几节将会介绍如何在 bash shell 脚本中使用命令行参数。

读取参数

bash shell 会将一些称为位置参数(positional parameter)的特殊变量分配给输入到命令行中的所有参数。这也包括 shell 所执行的脚本名称。位置参数变量是标准的数字:$0 是程序名,$1 是第一个参数,$2 是第二个参数,依次类推,直到第九个参数$9。

下面是在 shell 脚本中使用单个命令行参数的简单例子。

$ cat test1.sh
#!/bin/bash
## using one command line parameter
#
factorial=1
for (( number = 1; number <= $1 ; number++ ))
do
    factorial=$[ $factorial * $number ]
done
echo The factorial of $1 is $factorial
$
$ ./test1.sh 5
The factorial of 5 is 120
$

可以在 shell 脚本中像使用其他变量一样使用$1 变量。shell 脚本会自动将命令行参数的值分配给变量,不需要你作任何处理。

如果需要输入更多的命令行参数,则每个参数都必须用空格分开。

$ cat test2.sh
#!/bin/bash
## testing two command line parameters
#
total=$[ $1 * $2 ]
echo The first parameter is $1.
echo The second parameter is $2.
echo The total value is $total.
$
$ ./test2.sh 2 5
The first parameter is 2.
The second parameter is 5.
The total value is 10.
$

shell 会将每个参数分配给对应的变量。在前面的例子中,用到的命令行参数都是数值。也可以在命令行上用文本字符串。shell 将输入到命令行的字符串值传给脚本。但碰到含有空格的文本字符串时就会出现问题。记住,每个参数都是用空格分隔的,所以 shell 会将空格当成两个值的分隔符。要在参数值中包含空格,必须要用引号(单引号或双引号均可)。

将文本字符串作为参数传递时,引号并非数据的一部分。它们只是表明数据的起止位置。

如果脚本需要的命令行参数不止 9 个,你仍然可以处理,但是需要稍微修改一下变量名。在第 9 个变量之后,你必须在变量数字周围加上花括号,比如${10}。这种方法允许你根据需要向脚本添加任意多的命令行参数。加上花括号会显得比较直观,不过经过测试,目前版本的 bash 不加花括号也可以正常运行,只是不是那么直观罢了。

读取脚本名

可以用$0 参数获取 shell 在命令行启动的脚本名。这在编写多功能工具时很方便。

$ cat test5.sh
#!/bin/bash
## Testing the $0 parameter
#
echo The zero parameter is set to: $0
#
$
$ bash test5.sh
The zero parameter is set to: test5.sh
$

但是这里存在一个潜在的问题。如果使用另一种方式来运行 shell 脚本,命令会和脚本名混在一起,出现在$0 参数中。

$ ./test5.sh
The zero parameter is set to: ./test5.sh
$

这还不是唯一的问题。当传给$0 变量的实际字符串不仅仅是脚本名,而是完整的脚本路径时,变量$0 就会使用整个路径。

$ bash /home/Christine/test5.sh
The zero parameter is set to: /home/Christine/test5.sh
$

如果你要编写一个根据脚本名来执行不同功能的脚本,就得做点额外工作。你得把脚本的运行路径给剥离掉。另外,还要删除与脚本名混杂在一起的命令。 幸好有个方便的小命令可以帮到我们。basename 命令会返回不包含路径的脚本名。

$ cat test5b.sh
#!/bin/bash
## Using basename with the $0 parameter
#
name=$(basename $0)
echo
echo The script name is: $name
#
$
$ bash /home/Christine/test5b.sh
The script name is: test5b.sh
$
$ ./test5b.sh
The script name is: test5b.sh
$

现在好多了。可以用这种方法来编写基于脚本名执行不同功能的脚本。这里有个简单的例子。

$ cat test6.sh
#!/bin/bash
## Testing a Multi-function script
#
name=$(basename $0)
#
if [ $name = "addem" ]
then
    total=$[ $1 + $2 ]
#
elif [ $name = "multem" ]
then
    total=$[ $1 * $2 ]
fi
#
echo echo The calculated value is $total
#
$
$ cp test6.sh addem
$ chmod u+x addem
$
$ ln -s test6.sh multem
$
$ ls -l *em
-rwxrw-r--. 1 Christine Christine 224 Jun 30 23:50 addem
lrwxrwxrwx. 1 Christine Christine   8 Jun 30 23:50 multem -> test6.sh
$
$ ./addem 2 5
The calculated value is 7
$
$ ./multem 2 5
The calculated value is 10
$

本例从 test6.sh 脚本中创建了两个不同的文件名:一个通过复制文件创建(addem),另一个通过链接(参见第 3 章)创建(multem)。在两种情况下都会先获得脚本的基本名称,然后根据该值执行相应的功能。

测试参数

在 shell 脚本中使用命令行参数时要小心些。如果脚本不加参数运行,可能会出问题。

$ ./addem 2
./addem: line 8: 2 +  : syntax error: operand expected (error
token is " ")
The calculated value is
$

当脚本认为参数变量中会有数据而实际上并没有时,脚本很有可能会产生错误消息。这种写脚本的方法并不可取。在使用参数前一定要检查其中是否存在数据。

$ cat test7.sh
#!/bin/bash
## testing parameters before use
#
if [ -n "$1" ]
then
    echo Hello $1, glad to meet you.
else
    echo "Sorry, you did not identify yourself. "
fi
$
$ ./test7.sh Rich
Hello Rich, glad to meet you.
$
$ ./test7.sh
Sorry, you did not identify yourself.
$

在本例中,使用了-n 测试来检查命令行参数$1 中是否有数据。在下一节中,你会看到还有另一种检查命令行参数的方法。

特殊参数变量

在 bash shell 中有些特殊变量,它们会记录命令行参数。本节将会介绍这些变量及其用法。

参数统计

如在上一节中看到的,在脚本中使用命令行参数之前应该检查一下命令行参数。对于使用多个命令行参数的脚本来说,这有点麻烦。 你可以统计一下命令行中输入了多少个参数,无需测试每个参数。bash shell 为此提供了一个特殊变量。

特殊变量$#含有脚本运行时携带的命令行参数的个数。可以在脚本中任何地方使用这个特殊变量,就跟普通变量一样。

$ cat test8.sh
#!/bin/bash
## getting the number of parameters
#
echo There were $## parameters supplied.
$
$ ./test8.sh
There were 0 parameters supplied.
$
$ ./test8.sh 1 2 3 4 5
There were 5 parameters supplied.

现在你就能在使用参数前测试参数的总数了。

$ cat test9.sh
#!/bin/bash
## Testing parameters
#
if [ $## -ne 2 ]
then
    echo
    echo Usage: test9.sh a b
    echo
else
    total=$[ $1 + $2 ]
    echo
    echo The total is $total
    echo
fi
#
$
$ bash test9.sh
Usage: test9.sh a b
$ bash test9.sh 10 15
The total is 25
$

if-then 语句用-ne 测试命令行参数数量。如果参数数量不对,会显示一条错误消息告知脚本的正确用法。

这个变量还提供了一个简便方法来获取命令行中最后一个参数,完全不需要知道实际上到底用了多少个参数。不过要实现这一点,得稍微多花点工夫。如果你仔细考虑过,可能会觉得既然$#变量含有参数的总数,那么变量${$#}就代表了最后一个命令行参数变量。试试看会发生什么。

$ cat badtest1.sh
#!/bin/bash
## testing grabbing last parameter
#
echo The last parameter was ${$#}
$
$ ./badtest1.sh 10
The last parameter was 15354
$

怎么了?显然,出了点问题。它表明你不能在花括号内使用美元符。必须将美元符换成感叹号。很奇怪,但的确管用。

$ cat test10.sh
#!/bin/bash
## Grabbing the last parameter
#
params=$#
echo
echo The last parameter is $params
echo The last parameter is ${!#}
echo
#
$
$ bash test10.sh 1 2 3 4 5
The last parameter is 5
The last parameter is 5
$
$ bash test10.sh
The last parameter is 0
The last parameter is test10.sh
$

这个测试将$#变量的值赋给了变量params,然后也按特殊命令行参数变量的格式使用了该变量。两种方法都没问题。重要的是要注意,当命令行上没有任何参数时,$#的值为 0,params 变量的值也一样,但${!#}变量会返回命令行用到的脚本名。

抓取所有的数据

有时候需要抓取命令行上提供的所有参数。这时候不需要先用$#变量来判断命令行上有多少参数,然后再进行遍历,你可以使用一组其他的特殊变量来解决这个问题。 $*和$@变量可以用来轻松访问所有的参数。这两个变量都能够在单个变量中存储所有的命令行参数。 $*变量会将命令行上提供的所有参数当作一个单词保存。这个单词包含了命令行中出现的每一个参数值。基本上$*变量会将这些参数视为一个整体,而不是多个个体。 另一方面,$@变量会将命令行上提供的所有参数当作同一字符串中的多个独立的单词。这样你就能够遍历所有的参数值,得到每个参数。这通常通过 for 命令完成。 这两个变量的工作方式不太容易理解。看个例子,你就能理解二者之间的区别了。

$ cat test11.sh
#!/bin/bash
## testing $* and $@
#
echo
echo "Using the \$* method: $*"
echo
echo "Using the \$@ method: $@"
$
$ ./test11.sh rich barbara katie jessica
Using the $* method: rich barbara katie jessica
Using the $@ method: rich barbara katie jessica
$

注意,从表面上看,两个变量产生的是同样的输出,都显示出了所有命令行参数。下面的例子给出了二者的差异。

$ cat test12.sh
#!/bin/bash
## testing $* and $@
#
echo
count=1
#
for param in "$*"
do
    echo "\$* Parameter #$count = $param"
    count=$[ $count + 1 ]
done
#
echo
count=1
#
for param in "$@"
do
    echo "\$@ Parameter #$count = $param"
    count=$[ $count + 1 ]
done
$
$ ./test12.sh rich barbara katie jessica
$* Parameter #1 = rich barbara katie jessica
$@ Parameter #1 = rich
$@ Parameter #2 = barbara
$@ Parameter #3 = katie
$@ Parameter #4 = jessica
$

现在清楚多了。通过使用 for 命令遍历这两个特殊变量,你能看到它们是如何不同地处理命令行参数的。$*变量会将所有参数当成单个参数,而$@变量会单独处理每个参数。这是遍历命令行参数的一个绝妙方法。

移动变量

bash shell 工具箱中另一件工具是 shift 命令。bash shell 的 shift 命令能够用来操作命令行参数。跟字面上的意思一样,shift 命令会根据它们的相对位置来移动命令行参数。

在使用 shift 命令时,默认情况下它会将每个参数变量向左移动一个位置。所以,变量$3 的值会移到$2 中,变量$2 的值会移到$1 中,而变量$1 的值则会被删除(注意,变量$0 的值,也就是程序名,不会改变)。

这是遍历命令行参数的另一个好方法,尤其是在你不知道到底有多少参数时。你可以只操作第一个参数,移动参数,然后继续操作第一个参数。这里有个例子来解释它是如何工作的。

$ cat test13.sh
#!/bin/bash
## demonstrating the shift command
echo
count=1
while [ -n "$1" ]
do
    echo "Parameter #$count = $1"
    count=$[ $count + 1 ]
    shift
done
$
$ ./test13.sh rich barbara katie jessica
Parameter #1 = rich
Parameter #2 = barbara
Parameter #3 = katie
Parameter #4 = jessica
$

这个脚本通过测试第一个参数值的长度执行了一个 while 循环。当第一个参数的长度为零时,循环结束。测试完第一个参数后,shift 命令会将所有参数的位置移动一个位置。

使用 shift 命令的时候要小心。如果某个参数被移出,它的值就被丢弃了,无法再恢复。

另外,你也可以一次性移动多个位置,只需要给 shift 命令提供一个参数,指明要移动的位置数就行了。如shift 2一次性移动两个位置。

处理选项

如果你认真读过本书前面的所有内容,应该就见过了一些同时提供了参数和选项的 bash 命令。选项是跟在单破折线后面的单个字母,它能改变命令的行为。本节将会介绍 3 种在脚本中处理选项的方法。

查找选项

表面上看,命令行选项也没什么特殊的。在命令行上,它们紧跟在脚本名之后,就跟命令行参数一样。实际上,如果愿意,你可以像处理命令行参数一样处理命令行选项。

  1. 处理简单选项

在前面的 test13.sh 脚本中,你看到了如何使用 shift 命令来依次处理脚本程序携带的命令行参数。你也可以用同样的方法来处理命令行选项。在提取每个单独参数时,用 case 语句来判断某个参数是否为选项。

$ cat test15.sh
#!/bin/bash
## extracting command line options as parameters
#
echo
while [ -n "$1" ]
do
    case "$1" in
        -a) echo "Found the -a option" ;;
        -b) echo "Found the -b option" ;;
        -c) echo "Found the -c option" ;;
        *) echo "$1 is not an option" ;;
    esac
    shift
done
$
$ ./test15.sh -a -b -c -d
Found the -a option
Found the -b option
Found the -c option
-d is not an option
$

case 语句会检查每个参数是不是有效选项。如果是的话,就运行对应 case 语句中的命令。不管选项按什么顺序出现在命令行上,这种方法都适用。case 语句在命令行参数中找到一个选项,就处理一个选项。如果命令行上还提供了其他参数,你可以在 case 语句的通用情况处理部分中处理。

  1. 分离参数和选项

你会经常遇到在 shell 脚本中同时使用选项和参数的情况。Linux 中处理这个问题的标准方式是用特殊字符来将二者分开,该字符会告诉脚本何时选项结束以及普通参数何时开始。 对 Linux 来说,这个特殊字符是双破折线(–)。shell 会用双破折线来表明选项列表结束。在双破折线之后,脚本就可以放心地将剩下的命令行参数当作参数,而不是选项来处理了。

要检查双破折线,只要在 case 语句中加一项就行了。

$ cat test16.sh
#!/bin/bash
## extracting options and parameters
#
echo
while [ -n "$1" ]
do
    case "$1" in
        -a) echo "Found the -a option" ;;
        -b) echo "Found the -b option" ;;
        -c) echo "Found the -c option" ;;
        --) shift
            break ;;
        *) echo "$1 is not an option" ;;
    esac
    shift
done

count=1
for param in $@
do
    echo "Parameter #$count: $param"
    count=$[ $count + 1 ]
done

在遇到双破折线时,脚本用 break 命令来跳出 while 循环。由于过早地跳出了循环,我们需要再加一条 shift 命令来将双破折线移出参数变量。 对于第一个测试,试试用一组普通的选项和参数来运行这个脚本。

$
$ ./test16.sh -c -a -b test1 test2 test3
Found the -c option
Found the -a option
Found the -b option
test1 is not an option
test2 is not an option
test3 is not an option
$

结果说明在处理时脚本认为所有的命令行参数都是选项。接下来,进行同样的测试,只是这次会用双破折线来将命令行上的选项和参数划分开来。

$ ./test16.sh -c -a -b -- test1 test2 test3
Found the -c option
Found the -a option
Found the -b option
Parameter #1: test1
Parameter #2: test2
Parameter #3: test3
$

当脚本遇到双破折线时,它会停止处理选项,并将剩下的参数都当作命令行参数。

  1. 处理带值的选项

有些选项会带上一个额外的参数值。在这种情况下,命令行看起来像下面这样。

$ ./testing.sh -a test1 -b -c -d test2

当命令行选项要求额外的参数时,脚本必须能检测到并正确处理。下面是如何处理的例子。

$ cat test17.sh
#!/bin/bash
## extracting command line options and values
echo
while [ -n "$1" ]
do
    case "$1" in
        -a) echo "Found the -a option";;
        -b) param="$2"
            echo "Found the -b option, with parameter value $param"
            shift ;;
        -c) echo "Found the -c option";;
        --) shift
            break ;;
        *) echo "$1 is not an option";;
    esac
    shift
done
#
count=1
for param in "$@"
do
    echo "Parameter #$count: $param"
    count=$[ $count + 1 ]
done
$
$ ./test17.sh -a -b test1 -d
Found the -a option
Found the -b option, with parameter value test1
-d is not an option
$

在这个例子中,case 语句定义了三个它要处理的选项。-b 选项还需要一个额外的参数值。由于要处理的参数是$1,额外的参数值就应该位于$2(因为所有的参数在处理完之后都会被移出)。只要将参数值从$2 变量中提取出来就可以了。当然,因为这个选项占用了两个参数位,所以你还需要使用 shift 命令多移动一个位置。

只用这些基本的特性,整个过程就能正常工作,不管按什么顺序放置选项(但要记住包含每个选项相应的选项参数)。现在 shell 脚本中已经有了处理命令行选项的基本能力,但还有一些限制。比如,如果你想将多个选项放进一个参数中时,它就不能工作了。

$ ./test17.sh -ac
-ac is not an option
$

在 Linux 中,合并选项是一个很常见的用法,而且如果脚本想要对用户更友好一些,也要给用户提供这种特性。幸好,有另外一种处理选项的方法能够帮忙。

使用 getopt 命令

getopt 命令是一个在处理命令行选项和参数时非常方便的工具。它能够识别命令行参数,从而在脚本中解析它们时更方便。

  1. 命令的格式

getopt 命令可以接受一系列任意形式的命令行选项和参数,并自动将它们转换成适当的格式。它的命令格式如下:

getopt optstring parameters

optstring 是这个过程的关键所在。它定义了命令行有效的选项字母,还定义了哪些选项字母需要参数值。

首先,在 optstring 中列出你要在脚本中用到的每个命令行选项字母。然后,在每个需要参数值的选项字母后加一个冒号。getopt 命令会基于你定义的 optstring 解析提供的参数。

getopt 命令有一个更高级的版本叫作 getopts(注意这是复数形式)。getopts 命令会在本章随后部分讲到。因为这两个命令的拼写几乎一模一样,所以很容易搞混。一定要小心

下面是个 getopt 如何工作的简单例子。

$ getopt ab:cd -a -b test1 -cd test2 test3
-a -b test1 -c -d -- test2 test3
$

optstring 定义了四个有效选项字母:a、b、c 和 d。冒号(:)被放在了字母 b 后面,因为 b 选项需要一个参数值。当 getopt 命令运行时,它会检查提供的参数列表(-a -b test1 -cd test2 test3),并基于提供的 optstring 进行解析。注意,它会自动将-cd 选项分成两个单独的选项,并插入双破折线来分隔行中的额外参数。

如果指定了一个不在 optstring 中的选项,默认情况下,getopt 命令会产生一条错误消息。

$ getopt ab:cd -a -b test1 -cde test2 test3
getopt: invalid option -- e
-a -b test1 -c -d -- test2 test3
$

如果想忽略这条错误消息,可以在命令后加-q 选项。

$ getopt -q ab:cd -a -b test1 -cde test2 test3
-a -b test1 -c -d -- test2 test3
$

注意,getopt 命令选项必须出现在 optstring 之前。现在应该可以在脚本中使用此命令处理命令行选项了

  1. 在脚本中使用 getopt

可以在脚本中使用 getopt 来格式化脚本所携带的任何命令行选项或参数,但用起来略微复杂。方法是用 getopt 命令生成的格式化后的版本来替换已有的命令行选项和参数。用 set 命令能够做到。在环境变量的章节中,你就已经见过 set 命令了。set 命令能够处理 shell 中的各种变量。

set 命令的选项之一是双破折线(–),它会将命令行参数替换成 set 命令的命令行值。然后,该方法会将原始脚本的命令行参数传给 getopt 命令,之后再将 getopt 命令的输出传给 set 命令,用 getopt 格式化后的命令行参数来替换原始的命令行参数,看起来如下所示。

set -- $(getopt -q ab:cd "$@")

现在原始的命令行参数变量的值会被 getopt 命令的输出替换,而 getopt 已经为我们格式化好了命令行参数。利用该方法,现在就可以写出能帮我们处理命令行参数的脚本。

$ cat test18.sh
#!/bin/bash
## Extract command line options & values with getopt
#
set -- $(getopt -q ab:cd "$@")
#
echo
while [ -n "$1" ]
do
    case "$1" in
    -a) echo "Found the -a option" ;;
    -b) param="$2"
        echo "Found the -b option, with parameter value $param"
        shift ;;
    -c) echo "Found the -c option" ;;
    --) shift
        break ;;
    *) echo "$1 is not an option";;
    esac
    shift
done
#
count=1
for param in "$@"
do
    echo "Parameter #$count: $param"
    count=$[ $count + 1 ]
done
#
$

你会注意到它跟脚本 test17.sh 一样,唯一不同的是加入了 getopt 命令来帮助格式化命令行参数。现在如果运行带有复杂选项的脚本,就可以看出效果更好了。

$ ./test18.sh -ac
Found the -a option
Found the -c option
$

当然,之前的功能照样没有问题。

$ ./test18.sh -a -b test1 -cd test2 test3 test4
Found the -a option
Found the -b option, with parameter value 'test1'
Found the -c option
-d is not an option
Parameter #1: 'test2'
Parameter #2: 'test3'
Parameter #3: 'test4'
$

现在看起来相当不错了。但是,在 getopt 命令中仍然隐藏着一个小问题。看看这个例子。

$ ./test18.sh -a -b test1 -cd "test2 test3" test4
Found the -a option
Found the -b option, with parameter value 'test1'
Found the -c option
-d is not an option
Parameter #1: 'test2
Parameter #2: test3'
Parameter #3: 'test4'
$

getopt 命令并不擅长处理带空格和引号的参数值。它会将空格当作参数分隔符,而不是根据双引号将二者当作一个参数。幸而还有另外一个办法能解决这个问题。

使用更高级的 getopts

getopts 命令(注意是复数)内建于 bash shell。它跟近亲 getopt 看起来很像,但多了一些扩展功能。

与 getopt 不同,前者将命令行上选项和参数处理后只生成一个输出,而 getopts 命令能够和已有的 shell 参数变量配合默契。

每次调用它时,它一次只处理命令行上检测到的一个参数。处理完所有的参数后,它会退出并返回一个大于 0 的退出状态码。这让它非常适合用于解析命令行所有参数的循环中。 getopts 命令的格式如下:

getopts optstring variable

optstring 值类似于 getopt 命令中的那个。有效的选项字母都会列在 optstring 中,如果选项字母要求有个参数值,就加一个冒号。要去掉错误消息的话,可以在 optstring 之前加一个冒号。getopts 命令将当前参数保存在命令行中定义的 variable 中。

getopts 命令会用到两个环境变量。如果选项需要跟一个参数值,OPTARG 环境变量就会保存这个值。OPTIND 环境变量保存了参数列表中 getopts 正在处理的参数位置。这样你就能在处理完选项之后继续处理其他命令行参数了。让我们看个使用 getopts 命令的简单例子。

$ cat test19.sh
#!/bin/bash
## simple demonstration of the getopts command
#
echo
while getopts :ab:c opt
do
    case "$opt" in
        a) echo "Found the -a option" ;;
        b) echo "Found the -b option, with value $OPTARG";;
        c) echo "Found the -c option" ;;
        *) echo "Unknown option: $opt";;
    esac
done
$
$ ./test19.sh -ab test1 -c
Found the -a option
Found the -b option, with value test1
Found the -c option
$

while 语句定义了 getopts 命令,指明了要查找哪些命令行选项,以及每次迭代中存储它们的变量名(opt)。

你会注意到在本例中 case 语句的用法有些不同。getopts 命令解析命令行选项时会移除开头的单破折线,所以在 case 定义中不用单破折线。

getopts 命令有几个好用的功能。对新手来说,可以在参数值中包含空格。

$ ./test19.sh -b "test1 test2" -a
Found the -b option, with value test1 test2
Found the -a option
$

另一个好用的功能是将选项字母和参数值放在一起使用,而不用加空格。

$ ./test19.sh -abtest1
Found the -a option
Found the -b option, with value test1
$

getopts 命令能够从-b 选项中正确解析出 test1 值。除此之外,getopts 还能够将命令行上找到的所有未定义的选项统一输出成问号。

$ ./test19.sh -d
Unknown option: ?
$
$ ./test19.sh -acde
Found the -a option
Found the -c option
Unknown option: ?
Unknown option: ?
$

optstring 中未定义的选项字母会以问号形式发送给代码。

getopts 命令知道何时停止处理选项,并将参数留给你处理。在 getopts 处理每个选项时,它会将 OPTIND 环境变量值增一。在 getopts 完成处理时,你可以使用 shift 命令和 OPTIND 值来移动参数。

$ cat test20.sh
#!/bin/bash
## Processing options & parameters with getopts
#
echo
while getopts :ab:cd opt
do
    case "$opt" in
        a) echo "Found the -a option"  ;;
        b) echo "Found the -b option, with value $OPTARG" ;;
        c) echo "Found the -c option"  ;;
        d) echo "Found the -d option"  ;;
        *) echo "Unknown option: $opt" ;;
    esac
done
#
shift $[ $OPTIND - 1 ]
#
echo
count=1
for param in "$@"
do
    echo "Parameter $count: $param"
    count=$[ $count + 1 ]
done
#
$
$ ./test20.sh -a -b test1 -d test2 test3 test4
Found the -a option
Found the -b option, with value test1
Found the -d option
Parameter 1: test2
Parameter 2: test3
Parameter 3: test4
$

现在你就拥有了一个能在所有 shell 脚本中使用的全功能命令行选项和参数处理工具。

将选项标准化

在创建 shell 脚本时,显然可以控制具体怎么做。你完全可以决定用哪些字母选项以及它们的用法。但有些字母选项在 Linux 世界里已经拥有了某种程度的标准含义。如果你能在 shell 脚本中支持这些选项,脚本看起来能更友好一些。下面展示了 Linux 中用到的一些命令行选项的常用含义。

  • -a 显示所有对象
  • -c 生成一个计数
  • -d 指定一个目录
  • -e 扩展一个对象
  • -f 指定读入数据的文件
  • -h 显示命令的帮助信息
  • -i 忽略文本大小写
  • -l 产生输出的长格式版本
  • -n 使用非交互模式(批处理)
  • -o 将所有输出重定向到的指定的输出文件
  • -q 以安静模式运行
  • -r 递归地处理目录和文件
  • -s 以安静模式运行
  • -v 生成详细输出
  • -x 排除某个对象
  • -y 对所有问题回答 yes

通过学习本书时遇到的各种 bash 命令,你大概已经知道这些选项中大部分的含义了。如果你的选项也采用同样的含义,这样用户在使用你的脚本时就不用去查手册了。

获得用户输入

尽管命令行选项和参数是从脚本用户处获得输入的一种重要方式,但有时脚本的交互性还需要更强一些。比如你想要在脚本运行时问个问题,并等待运行脚本的人来回答。bash shell 为此提供了 read 命令。

基本的读取

read 命令从标准输入(键盘)或另一个文件描述符中接受输入。在收到输入后,read 命令会将数据放进一个变量。下面是 read 命令的最简单用法。

$ cat test21.sh
#!/bin/bash
## testing the read command
#
echo -n "Enter your name: "
read name
echo "Hello $name, welcome to my program. "
#
$
$ ./test21.sh
Enter your name: Rich Blum
Hello Rich Blum, welcome to my program.
$

相当简单。注意,生成提示的 echo 命令使用了-n 选项。该选项不会在字符串末尾输出换行符,允许脚本用户紧跟其后输入数据,而不是下一行。这让脚本看起来更像表单。

实际上,read 命令包含了-p 选项,允许你直接在 read 命令行指定提示符。

$ cat test22.sh
#!/bin/bash
## testing the read -p option
#
read -p "Please enter your age: " age
days=$[ $age * 365 ]
echo
"That makes you over $days days old! "
#
$
$ ./test22.sh
Please enter your age: 10
That makes you over 3650 days old!
$

你会注意到,在第一个例子中当有名字输入时,read 命令会将姓和名保存在同一个变量中。read 命令会将提示符后输入的所有数据分配给单个变量,要么你就指定多个变量。输入的每个数据值都会分配给变量列表中的下一个变量。如果变量数量不够,剩下的数据就全部分配给最后一个变量。

$ cat test23.sh
#!/bin/bash
## entering multiple variables
#
read -p "Enter your name: " first last
echo "Checking data for $last, $first..."
$
$ ./test23.sh
Enter your name: Rich Blum
Checking data for Blum, Rich...
$

也可以在 read 命令行中不指定变量。如果是这样,read 命令会将它收到的任何数据都放进特殊环境变量 REPLY 中。

$ cat test24.sh
#!/bin/bash
## Testing the REPLY Environment variable
#
read -p "Enter your name: "
echo
echo Hello $REPLY, welcome to my program.
#
$
$ ./test24.sh
Enter your name: Christine
Hello Christine, welcome to my program.
$

REPLY 环境变量会保存输入的所有数据,可以在 shell 脚本中像其他变量一样使用。

超时

使用 read 命令时要当心。脚本很可能会一直苦等着脚本用户的输入。如果不管是否有数据输入,脚本都必须继续执行,你可以用-t 选项来指定一个计时器。-t 选项指定了 read 命令等待输入的秒数。当计时器过期后,read 命令会返回一个非零退出状态码。

$ cat test25.sh
#!/bin/bash
## timing the data entry
#
if read -t 5 -p "Please enter your name: " name
then
    echo "Hello $name, welcome to my script"
else
    echo
    echo "Sorry, too slow! "
fi
$
$ ./test25.sh
Please enter your name: Rich
Hello Rich, welcome to my script
$

如果计时器过期,read 命令会以非零退出状态码退出,可以使用如 if-then 语句或 while 循环这种标准的结构化语句来理清所发生的具体情况。在本例中,计时器过期时,if 语句不成立,shell 会执行 else 部分的命令。

也可以不对输入过程计时,而是让 read 命令来统计输入的字符数。当输入的字符达到预设的字符数时,就自动退出,将输入的数据赋给变量。

$ cat test26.sh
#!/bin/bash
## getting just one character of input
#
read -n1 -p "Do you want to continue [Y/N]? " answer
case $answer in
Y | y)  echo
        echo "fine, continue on...";;
N | n)  echo
        echo OK, goodbye
        exit;;
esac
echo "This is the end of the script"
$
$ ./test26.sh
Do you want to continue [Y/N]? Y
fine, continue on...
This is the end of the script
$
$ ./test26.sh
Do you want to continue [Y/N]? n
OK, goodbye
$

本例中将-n 选项和值 1 一起使用,告诉 read 命令在接受单个字符后退出。只要按下单个字符回答后,read 命令就会接受输入并将它传给变量,无需按回车键。

隐藏方式读取

有时你需要从脚本用户处得到输入,但又不在屏幕上显示输入信息。其中典型的例子就是输入的密码,但除此之外还有很多其他需要隐藏的数据类型。

-s 选项可以避免在 read 命令中输入的数据出现在显示器上(实际上,数据会被显示,只是 read 命令会将文本颜色设成跟背景色一样)。这里有个在脚本中使用-s 选项的例子。

$ cat test27.sh
#!/bin/bash
## hiding input data from the monitor
#
read -s -p "Enter your password: " pass
echo
echo "Is your password really $pass? "
$
$ ./test27.sh
Enter your password:
Is your password really T3st1ng?
$

输入提示符输入的数据不会出现在屏幕上,但会赋给变量,以便在脚本中使用。

从文件中读取

最后,也可以用 read 命令来读取 Linux 系统上文件里保存的数据。每次调用 read 命令,它都会从文件中读取一行文本。当文件中再没有内容时,read 命令会退出并返回非零退出状态码。

其中最难的部分是将文件中的数据传给 read 命令。最常见的方法是对文件使用 cat 命令,将结果通过管道直接传给含有 read 命令的 while 命令。下面的例子说明怎么处理。

$ cat test28.sh
#!/bin/bash
## reading data from a file
#
count=1
cat test | while read line
do
    echo "Line $count: $line"
    count=$[ $count + 1]
done
echo "Finished processing the file"
$
$ cat test
The quick brown dog jumps over the lazy fox.
This is a test, this is only a test.
O Romeo, Romeo! Wherefore art thou Romeo?
$
$ ./test28.sh
Line 1: The quick brown dog jumps over the lazy fox.
Line 2: This is a test, this is only a test.
Line 3: O Romeo, Romeo! Wherefore art thou Romeo?
Finished processing the file
$

while 循环会持续通过 read 命令处理文件中的行,直到 read 命令以非零退出状态码退出。

处理输出

到目前为止,本书中出现的脚本都是通过将数据打印在屏幕上或将数据重定向到文件中来显示信息。之前演示了如何将命令的输出重定向到文件中。本章将会展开这个主题,演示如何将脚本的输出重定向到 Linux 系统的不同位置。

理解输入和输出

至此你已经知道了两种显示脚本输出的方法:

  • 在显示器屏幕上显示输出
  • 将输出重定向到文件中

这两种方法要么将数据输出全部显示,要么什么都不显示。但有时将一部分数据在显示器上显示,另一部分数据保存到文件中也是不错的。对此,了解 Linux 如何处理输入输出能够帮助你就能将脚本输出放到正确位置。

下面几节会介绍如何用标准的 Linux 输入和输出系统来将脚本输出导向特定位置。

标准文件描述符

Linux 系统将每个对象当作文件处理。这包括输入和输出进程。Linux 用文件描述符(filedescriptor)来标识每个文件对象。文件描述符是一个非负整数,可以唯一标识会话中打开的文件。每个进程一次最多可以有九个文件描述符。出于特殊目的,bash shell 保留了前三个文件描述符(0、1 和 2)。这三个特殊文件描述符会处理脚本的输入和输出。

  • 0 缩写:STDIN 含义:标准输入
  • 1 缩写:STDOUT 含义:标准输出
  • 2 缩写:STDERR 含义:标准错误

shell 用它们将 shell 默认的输入和输出导向到相应的位置。下面几节将会进一步介绍这些标准文件描述符。

  1. STDIN

STDIN 文件描述符代表 shell 的标准输入。对终端界面来说,标准输入是键盘。shell 从 STDIN 文件描述符对应的键盘获得输入,在用户输入时处理每个字符。

在使用输入重定向符号(<)时,Linux 会用重定向指定的文件来替换标准输入文件描述符。它会读取文件并提取数据,就如同它是键盘上键入的。

许多 bash 命令能接受 STDIN 的输入,尤其是没有在命令行上指定文件的话。下面是个用 cat 命令处理 STDIN 输入的数据的例子。

$ cat
this is a test
this is a test
this is a second test.
this is a second test.

当在命令行上只输入 cat 命令时,它会从 STDIN 接受输入。输入一行,cat 命令就会显示出一行。但你也可以通过 STDIN 重定向符号强制 cat 命令接受来自另一个非 STDIN 文件的输入。

$ cat < testfile
This is the first line.
This is the second line.
This is the third line.
$

现在 cat 命令会用 testfile 文件中的行作为输入。你可以使用这种技术将数据输入到任何能从 STDIN 接受数据的 shell 命令中。

  1. STDOUT

STDOUT 文件描述符代表 shell 的标准输出。在终端界面上,标准输出就是终端显示器。shell 的所有输出(包括 shell 中运行的程序和脚本)会被定向到标准输出中,也就是显示器。

默认情况下,大多数 bash 命令会将输出导向 STDOUT 文件描述符。你可以用输出重定向来改变输出位置。

$ ls -l > test2
$ cat test2
total 20
-rw-rw-r-- 1 rich rich 53 2014-10-16 11:30 test
-rw-rw-r-- 1 rich rich  0 2014-10-16 11:32 test2

通过输出重定向符号,通常会显示到显示器的所有输出会被 shell 重定向到指定的重定向文件。你也可以将数据追加到某个文件。这可以用»符号来完成

$ who >> test2
$ cat test2
total 20
-rw-rw-r-- 1 rich rich 53 2014-10-16 11:30 test
-rw-rw-r-- 1 rich rich  0 2014-10-16 11:32 test2
rich     pts/0        2014-10-17 15:34 (192.168.1.2)
$

who 命令生成的输出会被追加到 test2 文件中已有数据的后面。

但是,如果你对脚本使用了标准输出重定向,你会遇到一个问题。下面的例子说明了可能会出现什么情况。

$ ls -al badfile > test3
ls: cannot access badfile: No such file or directory
$ cat test3
$

当命令生成错误消息时,shell 并未将错误消息重定向到输出重定向文件。shell 创建了输出重定向文件,但错误消息却显示在了显示器屏幕上。注意,在显示 test3 文件的内容时并没有任何错误。test3 文件创建成功了,只是里面是空的。

shell 对于错误消息的处理是跟普通输出分开的。如果你创建了在后台模式下运行的 shell 脚本,通常你必须依赖发送到日志文件的输出消息。用这种方法的话,如果出现了错误信息,这些信息是不会出现在日志文件中的。你需要换种方法来处理。

  1. STDERR

shell 通过特殊的 STDERR 文件描述符来处理错误消息。STDERR 文件描述符代表 shell 的标准错误输出。shell 或 shell 中运行的程序和脚本出错时生成的错误消息都会发送到这个位置。
默认情况下,STDERR 文件描述符会和 STDOUT 文件描述符指向同样的地方(尽管分配给它们的文件描述符值不同)。也就是说,默认情况下,错误消息也会输出到显示器输出中。
但从上面的例子可以看出,STDERR 并不会随着 STDOUT 的重定向而发生改变。使用脚本时,你常常会想改变这种行为,尤其是当你希望将错误消息保存到日志文件中的时候。

重定向错误

你已经知道如何用重定向符号来重定向 STDOUT 数据。重定向 STDERR 数据也没太大差别,只要在使用重定向符号时定义 STDERR 文件描述符就可以了。有几种办法实现方法。

  1. 只重定向错误

你已经知道,STDERR 文件描述符被设成 2。可以选择只重定向错误消息,将该文件描述符值放在重定向符号前。该值必须紧紧地放在重定向符号前,否则不会工作。

$ ls -al badfile 2> test4
$ cat test4
ls: cannot access badfile: No such file or directory
$

现在运行该命令,错误消息不会出现在屏幕上了。该命令生成的任何错误消息都会保存在输出文件中。用这种方法,shell 会只重定向错误消息,而非普通数据。这里是另一个将 STDOUT 和 STDERR 消息混杂在同一输出中的例子。

$ ls -al test badtest test2 2> test5
-rw-rw-r-- 1 rich rich 158 2014-10-16 11:32 test2
$ cat test5
ls: cannot access test: No such file or directory
ls: cannot access badtest: No such file or directory
$

ls 命令的正常 STDOUT 输出仍然会发送到默认的 STDOUT 文件描述符,也就是显示器。由于该命令将文件描述符 2 的输出(STDERR)重定向到了一个输出文件,shell 会将生成的所有错误消息直接发送到指定的重定向文件中。

  1. 重定向错误和正常输出

如果想重定向错误和正常输出,必须用两个重定向符号。需要在符号前面放上待重定向数据所对应的文件描述符,然后指向用于保存数据的输出文件。

$ ls -al test test2 test3 badtest 2> test6 1> test7
$ cat test6
ls: cannot access test: No such file or directory
ls: cannot access badtest: No such file or directory
$ cat test7
-rw-rw-r-- 1 rich rich 158 2014-10-16 11:32 test2
-rw-rw-r-- 1 rich rich   0 2014-10-16 11:33 test3
$

shell 利用 1>符号将 ls 命令的正常输出重定向到了 test7 文件,而这些输出本该是进入 STDOUT 的。所有本该输出到 STDERR 的错误消息通过 2>符号被重定向到了 test6 文件。
可以用这种方法将脚本的正常输出和脚本生成的错误消息分离开来。这样就可以轻松地识别出错误信息,再不用在成千上万行正常输出数据中翻腾了。
另外,如果愿意,也可以将 STDERR 和 STDOUT 的输出重定向到同一个输出文件。为此 bash shell 提供了特殊的重定向符号&>。

$ ls -al test test2 test3 badtest &> test7
$ cat test7
ls: cannot access test: No such file or directory
ls: cannot access badtest: No such file or directory
-rw-rw-r-- 1 rich rich 158 2014-10-16 11:32 test2
-rw-rw-r-- 1 rich rich   0 2014-10-16 11:33 test3 $

当使用&>符时,命令生成的所有输出都会发送到同一位置,包括数据和错误。你会注意到其中一条错误消息出现的位置和预想中的不一样。badtest 文件(列出的最后一个文件)的这条错误消息出现在输出文件中的第二行。为了避免错误信息散落在输出文件中,相较于标准输出,bash shell 自动赋予了错误消息更高的优先级。这样你能够集中浏览错误信息了。

在脚本中重定向输出

可以在脚本中用 STDOUT 和 STDERR 文件描述符以在多个位置生成输出,只要简单地重定向相应的文件描述符就行了。有两种方法来在脚本中重定向输出:

  • 临时重定向行输出
  • 永久重定向脚本中的所有命令

临时重定向

如果有意在脚本中生成错误消息,可以将单独的一行输出重定向到 STDERR。你所需要做的是使用输出重定向符来将输出信息重定向到 STDERR 文件描述符。在重定向到文件描述符时,你必须在文件描述符数字之前加一个&:

echo "This is an error message" >&2

这行会在脚本的 STDERR 文件描述符所指向的位置显示文本,而不是通常的 STDOUT。下面这个例子就利用了这项功能。

$ cat test8
#!/bin/bash
## testing STDERR messages
echo "This is an error" >&2
echo "This is normal output"
$

如果像平常一样运行这个脚本,你可能看不出什么区别。

$ ./test8
This is an error
This is normal output
$

记住,默认情况下,Linux 会将 STDERR 导向 STDOUT。但是,如果你在运行脚本时重定向了 STDERR,脚本中所有导向 STDERR 的文本都会被重定向。

$ ./test8 2> test9
This is normal output
$ cat test9
This is an error
$

太好了!通过 STDOUT 显示的文本显示在了屏幕上,而发送给 STDERR 的 echo 语句的文本则被重定向到了输出文件。这个方法非常适合在脚本中生成错误消息。如果有人用了你的脚本,他们可以像上面的例子中那样轻松地通过 STDERR 文件描述符重定向错误消息。

永久重定向

如果脚本中有大量数据需要重定向,那重定向每个 echo 语句就会很烦琐。取而代之,你可以用 exec 命令告诉 shell 在脚本执行期间重定向某个特定文件描述符。

$ cat test10
#!/bin/bash
## redirecting all output to a file
exec 1>testout
echo "This is a test of redirecting all output"
echo "from a script to another file."
echo "without having to redirect every individual line"
$ ./test10
$ cat testout
This is a test of redirecting all output
from a script to another file.
without having to redirect every individual line
$

exec 命令会启动一个新 shell 并将 STDOUT 文件描述符重定向到文件。脚本中发给 STDOUT 的所有输出会被重定向到文件。

可以在脚本执行过程中重定向 STDOUT。

$ cat test11
#!/bin/bash
## redirecting output to different locations
exec 2>testerror
echo "This is the start of the script"
echo "now redirecting all output to another location"
exec 1>testout
echo "This output should go to the testout file"
echo "but this should go to the testerror file" >&2
$
$ ./test11
This is the start of the script
now redirecting all output to another location
$ cat testout
This output should go to the testout file
$ cat testerror
but this should go to the testerror file
$

这个脚本用 exec 命令来将发给 STDERR 的输出重定向到文件 testerror。接下来,脚本用 echo 语句向 STDOUT 显示了几行文本。随后再次使用 exec 命令来将 STDOUT 重定向到 testout 文件。注意,尽管 STDOUT 被重定向了,但你仍然可以将 echo 语句的输出发给 STDERR,在本例中还是重定向到 testerror 文件。

当你只想将脚本的部分输出重定向到其他位置时(如错误日志),这个特性用起来非常方便。不过这样做的话,会碰到一个问题。

一旦重定向了 STDOUT 或 STDERR,就很难再将它们重定向回原来的位置。如果你需要在重定向中来回切换的话,有个办法可以用,后文将会讨论该方法以及如何在脚本中使用。

在脚本中重定向输入

你可以使用与脚本中重定向 STDOUT 和 STDERR 相同的方法来将 STDIN 从键盘重定向到其他位置。exec 命令允许你将 STDIN 重定向到 Linux 系统上的文件中:

exec 0< testfile

这个命令会告诉 shell 它应该从文件 testfile 中获得输入,而不是 STDIN。这个重定向只要在脚本需要输入时就会作用。下面是该用法的实例。

$ cat test12
#!/bin/bash
## redirecting file input
exec 0< testfile
count=1
while read line
do
    echo "Line #$count: $line"
    count=$[ $count + 1 ]
done
$ ./test12
Line #1: This is the first line.
Line #2: This is the second line.
Line #3: This is the third line.
$

上一章介绍了如何使用 read 命令读取用户在键盘上输入的数据。将 STDIN 重定向到文件后,当 read 命令试图从 STDIN 读入数据时,它会到文件去取数据,而不是键盘。这是在脚本中从待处理的文件中读取数据的绝妙办法。Linux 系统管理员的一项日常任务就是从日志文件中读取数据并处理。这是完成该任务最简单的办法。

创建自己的重定向

在脚本中重定向输入和输出时,并不局限于这 3 个默认的文件描述符。我曾提到过,在 shell 中最多可以有 9 个打开的文件描述符。其他 6 个从 3~8 的文件描述符均可用作输入或输出重定向。你可以将这些文件描述符中的任意一个分配给文件,然后在脚本中使用它们。本节将介绍如何在脚本中使用其他文件描述符。

创建输出文件描述符

可以用 exec 命令来给输出分配文件描述符。和标准的文件描述符一样,一旦将另一个文件描述符分配给一个文件,这个重定向就会一直有效,直到你重新分配。这里有个在脚本中使用其他文件描述符的简单例子。

$ cat test13
#!/bin/bash
## using an alternative file descriptor
exec 3>test13out
echo "This should display on the monitor"
echo "and this should be stored in the file" >&3
echo "Then this should be back on the monitor"
$ ./test13
This should display on the monitor
Then this should be back on the monitor
$ cat test13out
and this should be stored in the file
$

这个脚本用 exec 命令将文件描述符 3 重定向到另一个文件。当脚本执行 echo 语句时,输出内容会像预想中那样显示在 STDOUT 上。但你重定向到文件描述符 3 的那行 echo 语句的输出却进入了另一个文件。这样你就可以在显示器上保持正常的输出,而将特定信息重定向到文件中(比如日志文件)。

也可以不用创建新文件,而是使用 exec 命令来将输出追加到现有文件中。

exec 3>>test13out

现在输出会被追加到 test13out 文件,而不是创建一个新文件。

重定向文件描述符

现在介绍怎么恢复已重定向的文件描述符。你可以分配另外一个文件描述符给标准文件描述符,反之亦然。这意味着你可以将 STDOUT 的原来位置重定向到另一个文件描述符,然后再利用该文件描述符重定向回 STDOUT。听起来可能有点复杂,但实际上相当直接。这个简单的例子能帮你理清楚。

$ cat test14
#!/bin/bash
## storing STDOUT, then coming back to it
exec 3>&1
exec 1>test14out
echo "This should store in the output file"
echo "along with this line."
exec 1>&3
echo "Now things should be back to normal"
$
$ ./test14
Now things should be back to normal
$ cat test14out
This should store in the output file
along with this line.
$

这个例子有点叫人抓狂,来一段一段地看。首先,脚本将文件描述符 3 重定向到文件描述符 1 的当前位置,也就是 STDOUT。这意味着任何发送给文件描述符 3 的输出都将出现在显示器上。
第二个 exec 命令将 STDOUT 重定向到文件,shell 现在会将发送给 STDOUT 的输出直接重定向到输出文件中。但是,文件描述符 3 仍然指向 STDOUT 原来的位置,也就是显示器。如果此时将输出数据发送给文件描述符 3,它仍然会出现在显示器上,尽管 STDOUT 已经被重定向了。
在向 STDOUT(现在指向一个文件)发送一些输出之后,脚本将 STDOUT 重定向到文件描述符 3 的当前位置(现在仍然是显示器)。这意味着现在 STDOUT 又指向了它原来的位置:显示器。
这个方法可能有点叫人困惑,但这是一种在脚本中临时重定向输出,然后恢复默认输出设置的常用方法。

创建输入文件描述符

可以用和重定向输出文件描述符同样的办法重定向输入文件描述符。在重定向到文件之前,先将 STDIN 文件描述符保存到另外一个文件描述符,然后在读取完文件之后再将 STDIN 恢复到它原来的位置。

$ cat test15
#!/bin/bash
## redirecting input file descriptors
exec 6<&0
exec 0< testfile
count=1
while read line
do
    echo "Line #$count: $line"
    count=$[ $count + 1 ]
done
exec 0<&6
read -p "Are you done now? " answer
case $answer in
    Y|y) echo "Goodbye";;
    N|n) echo "Sorry, this is the end.";;
esac
$ ./test15
Line #1: This is the first line.
Line #2: This is the second line.
Line #3: This is the third line.
Are you done now? y
Goodbye
$

在这个例子中,文件描述符 6 用来保存 STDIN 的位置。然后脚本将 STDIN 重定向到一个文件。read 命令的所有输入都来自重定向后的 STDIN(也就是输入文件)。在读取了所有行之后,脚本会将 STDIN 重定向到文件描述符 6,从而将 STDIN 恢复到原先的位置。该脚本用了另外一个 read 命令来测试 STDIN 是否恢复正常了。这次它会等待键盘的输入。

创建读写文件描述符

尽管看起来可能会很奇怪,但是你也可以打开单个文件描述符来作为输入和输出。可以用同一个文件描述符对同一个文件进行读写。不过用这种方法时,你要特别小心。由于你是对同一个文件进行数据读写,shell 会维护一个内部指针,指明在文件中的当前位置。任何读或写都会从文件指针上次的位置开始。如果不够小心,它会产生一些令人瞠目的结果。看看下面这个例子。

$ cat test16
#!/bin/bash
## testing input/output file descriptor
exec 3<> testfile
read line <&3
echo "Read: $line"
echo "This is a test line" >&3
$ cat testfile
This is the first line.
This is the second line.
This is the third line.
$ ./test16
Read: This is the first line.
$ cat testfile
This is the first line.
This is a test line
ine.
This is the third line.
$

这个例子用了 exec 命令将文件描述符 3 分配给文件 testfile 以进行文件读写。接下来,它通过分配好的文件描述符,使用 read 命令读取文件中的第一行,然后将这一行显示在 STDOUT 上。最后,它用 echo 语句将一行数据写入由同一个文件描述符打开的文件中。

在运行脚本时,一开始还算正常。输出内容表明脚本读取了 testfile 文件中的第一行。但如果你在脚本运行完毕后,查看 testfile 文件内容的话,你会发现写入文件中的数据覆盖了已有的数据。

当脚本向文件中写入数据时,它会从文件指针所处的位置开始。read 命令读取了第一行数据,所以它使得文件指针指向了第二行数据的第一个字符。在 echo 语句将数据输出到文件时,它会将数据放在文件指针的当前位置,覆盖了该位置的已有数据。

关闭文件描述符

如果你创建了新的输入或输出文件描述符,shell 会在脚本退出时自动关闭它们。然而在有些情况下,你需要在脚本结束前手动关闭文件描述符。

要关闭文件描述符,将它重定向到特殊符号&-。脚本中看起来如下:

exec 3>&-

该语句会关闭文件描述符 3,不再在脚本中使用它。这里有个例子来说明当你尝试使用已关闭的文件描述符时会怎样。

$ cat badtest
#!/bin/bash
## testing closing file descriptors
exec 3> test17file
echo "This is a test line of data" >&3
exec 3>&-
echo "This won't work" >&3
$ ./badtest
./badtest: 3: Bad file descriptor
$

一旦关闭了文件描述符,就不能在脚本中向它写入任何数据,否则 shell 会生成错误消息。在关闭文件描述符时还要注意另一件事。如果随后你在脚本中打开了同一个输出文件,shell 会用一个新文件来替换已有文件。这意味着如果你输出数据,它就会覆盖已有文件。考虑下面这个问题的例子。

$ cat test17
#!/bin/bash
## testing closing file descriptors
exec 3> test17file
echo "This is a test line of data" >&3
exec 3>&-
cat test17file
exec 3> test17file
echo "This'll be bad" >&3
$ ./test17
This is a test line of data
$ cat test17file
This'll be bad
$

在向 test17file 文件发送一个数据字符串并关闭该文件描述符之后,脚本用了 cat 命令来显示文件的内容。到目前为止,一切都还好。下一步,脚本重新打开了该输出文件并向它发送了另一个数据字符串。当显示该输出文件的内容时,你所能看到的只有第二个数据字符串。shell 覆盖了原来的输出文件。

列出打开的文件描述符

你能用的文件描述符只有 9 个,你可能会觉得这没什么复杂的。但有时要记住哪个文件描述符被重定向到了哪里很难。为了帮助你理清条理,bash shell 提供了 lsof 命令。lsof 命令会列出整个 Linux 系统打开的所有文件描述符。这是个有争议的功能,因为它会向非系统管理员用户提供 Linux 系统的信息。鉴于此,许多 Linux 系统隐藏了该命令,这样用户就不会一不小心就发现了。

有大量的命令行选项和参数可以用来帮助过滤 lsof 的输出。最常用的有-p 和-d,前者允许指定进程 ID(PID),后者允许指定要显示的文件描述符编号。

要想知道进程的当前 PID,可以用特殊环境变量$$(shell 会将它设为当前 PID)。-a 选项用来对其他两个选项的结果执行布尔 AND 运算,这会产生如下输出。

$ lsof -a -p $$ -d 0,1,2
lsof: WARNING: can't stat() tracefs file system /sys/kernel/debug/tracing
      Output information may be incomplete.
COMMAND    PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
bash    162540 testuser    0u   CHR  136,1      0t0    4 /dev/pts/1
bash    162540 testuser    1u   CHR  136,1      0t0    4 /dev/pts/1
bash    162540 testuser    2u   CHR  136,1      0t0    4 /dev/pts/1

上例显示了当前进程(bash shell)的默认文件描述符(0、1 和 2)。同时可以注意到,因为是以非 root 用户执行,没有权限查看全部文件描述符信息,lsof 提示了你信息可能不完整。lsof 的默认输出中有 7 列信息,详情如下。

  • COMMAND 正在运行的命令名的前 9 个字符
  • PID 进程的 PID
  • USER 进程属主的登录名
  • FD 文件描述符号以及访问类型(r 代表读,w 代表写,u 代表读写)
  • TYPE 文件的类型(CHR 代表字符型,BLK 代表块型,DIR 代表目录,REG 代表常规文件)
  • DEVICE 设备的设备号(主设备号和从设备号)
  • SIZE 如果有的话,表示文件的大小
  • NODE 本地文件的节点号
  • NAME 文件名

与 STDIN、STDOUT 和 STDERR 关联的文件类型是字符型。因为 STDIN、STDOUT 和 STDERR 文件描述符都指向终端,所以输出文件的名称就是终端的设备名。所有 3 种标准文件都支持读和写(尽管向 STDIN 写数据以及从 STDOUT 读数据看起来有点奇怪)。

现在看一下在打开了多个替代性文件描述符的脚本中使用 lsof 命令的结果。

$ cat test18
#!/bin/bash
## testing lsof with file descriptors
exec 3> test18file1
exec 6> test18file2
exec 7< testfile
lsof -a -p $$ -d0,1,2,3,6,7
$ ./test18
lsof: WARNING: can't stat() tracefs file system /sys/kernel/debug/tracing
      Output information may be incomplete.
COMMAND    PID   USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
tt      177709 testuser    0u   CHR  136,1      0t0        4 /dev/pts/1
tt      177709 testuser    1u   CHR  136,1      0t0        4 /dev/pts/1
tt      177709 testuser    2u   CHR  136,1      0t0        4 /dev/pts/1
tt      177709 testuser    3w   REG  259,4        0 33822498 /home/testuser/test18file1
tt      177709 testuser    6w   REG  259,4        0 33822697 /home/testuser/test18file2
tt      177709 testuser    7r   REG  259,4       73 33823059 /home/testuser/testfile

该脚本创建了 3 个替代性文件描述符,两个作为输出(3 和 6),一个作为输入(7)。在脚本运行 lsof 命令时,可以在输出中看到新的文件描述符。我们去掉了输出中的第一部分,这样你就能看到文件名的结果了。文件名显示了文件描述符所使用的文件的完整路径名。它将每个文件都显示成 REG 类型的,这说明它们是文件系统中的常规文件。

阻止命令输出

有时候,你可能不想显示脚本的输出。这在将脚本作为后台进程运行时很常见,尤其是当运行会生成很多烦琐的小错误的脚本时。要解决这个问题,可以将 STDERR 重定向到一个叫作 null 文件的特殊文件。null 文件跟它的名字很像,文件里什么都没有。shell 输出到 null 文件的任何数据都不会保存,全部都被丢掉了。

在 Linux 系统上 null 文件的标准位置是/dev/null。你重定向到该位置的任何数据都会被丢掉,不会显示。

$ ls -al > /dev/null
$ cat /dev/null
$

这是避免出现错误消息,也无需保存它们的一个常用方法。

$ ls -al badfile test16 2> /dev/null
-rwxr--r--    1 rich     rich          135 Oct 29 19:57 test16*
$

也可以在输入重定向中将/dev/null 作为输入文件。由于/dev/null 文件不含有任何内容,程序员通常用它来快速清除现有文件中的数据,而不用先删除文件再重新创建。

$ cat testfile
This is the first line.
This is the second line.
This is the third line.
$ cat /dev/null > testfile
$ cat testfile
$

文件 testfile 仍然存在系统上,但现在它是空文件。这是清除日志文件的一个常用方法,因为日志文件必须时刻准备等待应用程序操作。

创建临时文件

Linux 系统有特殊的目录,专供临时文件使用。Linux 使用/tmp 目录来存放不需要永久保留的文件。大多数 Linux 发行版配置了系统在启动时自动删除/tmp 目录的所有文件。系统上的任何用户账户都有权限在读写/tmp 目录中的文件。这个特性为你提供了一种创建临时文件的简单方法,而且还不用操心清理工作。

有个特殊命令可以用来创建临时文件。mktemp 命令可以在/tmp 目录中创建一个唯一的临时文件。shell 会创建这个文件,但不用默认的 umask 值 。它会将文件的读和写权限分配给文件的属主,并将你设成文件的属主。一旦创建了文件,你就在脚本中有了完整的读写权限,但其他人没法访问它(当然,root 用户除外)。

创建本地临时文件

默认情况下,mktemp 会在本地目录中创建一个文件。要用 mktemp 命令在本地目录中创建一个临时文件,你只要指定一个文件名模板就行了。模板可以包含任意文本文件名,在文件名末尾加上 2 个以上 X 就行了。

$ mktemp testing.XXXXXX
testing.4OnP2E
$ ls -al testing*
-rw-------   1 rich     rich      0 Oct 17 21:30 testing.UfIi13
$

mktemp 命令会用 6 个字符码替换这 6 个 X,从而保证文件名在目录中是唯一的。你可以创建多个临时文件,它可以保证每个文件都是唯一的。mktemp 命令的输出正是它所创建的文件的名字。在脚本中使用 mktemp 命令时,可能要将文件名保存到变量中,这样就能在后面的脚本中引用了。

$ cat test19
#!/bin/bash
## creating and using a temp file
tempfile=$(mktemp test19.XXXXXX)
exec 3>$tempfile
echo "This script writes to temp file $tempfile"
echo "This is the first line" >&3
echo "This is the second line." >&3
echo "This is the last line." >&3
exec 3>&-
echo "Done creating temp file. The contents are:"
cat $tempfile
rm -f $tempfile 2> /dev/null
$ ./test19
This script writes to temp file test19.vCHoya
Done creating temp file.
The contents are:
This is the first line
This is the second line.
This is the last line.
$ ls -al test19*
-rwxr--r--    1 rich     rich          356 Oct 29 22:03 test19*
$

这个脚本用 mktemp 命令来创建临时文件并将文件名赋给$tempfile 变量。接着将这个临时文件作为文件描述符 3 的输出重定向文件。在将临时文件名显示在 STDOUT 之后,向临时文件中写入了几行文本,然后关闭了文件描述符。最后,显示出临时文件的内容,并用 rm 命令将其删除。

在/tmp 目录创建临时文件

-t 选项会强制 mktemp 命令来在系统的临时目录来创建该文件。在用这个特性时,mktemp 命令会返回用来创建临时文件的全路径,而不是只有文件名。

$ mktemp -t test.XXXXXX
/tmp/test.xG3374
$ ls -al /tmp/test*
-rw------- 1 rich rich 0 2014-10-29 18:41 /tmp/test.xG3374
$

由于 mktemp 命令返回了全路径名,你可以在 Linux 系统上的任何目录下引用该临时文件,不管临时目录在哪里。

$ cat test20
#!/bin/bash
## creating a temp file in /tmp
tempfile=$(mktemp -t tmp.XXXXXX)
echo "This is a test file." > $tempfile
echo "This is the second line of the test." >> $tempfile
echo "The temp file is located at: $tempfile"
cat $tempfile
rm -f $tempfile
$ ./test20
The temp file is located at: /tmp/tmp.Ma3390
This is a test file.
This is the second line of the test.
$

在 mktemp 创建临时文件时,它会将全路径名返回给变量。这样你就能在任何命令中使用该值来引用临时文件了。

创建临时目录

-d 选项告诉 mktemp 命令来创建一个临时目录而不是临时文件。这样你就能用该目录进行任何需要的操作了,比如创建其他的临时文件。

$ cat test21
#!/bin/bash
## using a temporary directory
tempdir=$(mktemp -d dir.XXXXXX)
cd $tempdir
tempfile1=$(mktemp temp.XXXXXX)
tempfile2=$(mktemp temp.XXXXXX)
exec 7> $tempfile1
exec 8> $tempfile2
echo "Sending data to directory $tempdir"
echo "This is a test line of data for $tempfile1" >&7
echo "This is a test line of data for $tempfile2" >&8
$ ./test21
Sending data to directory dir.ouT8S8
$ ls -al
total 72
drwxr-xr-x    3 rich     rich         4096 Oct 17 22:20 ./
drwxr-xr-x    9 rich     rich         4096 Oct 17 09:44 ../
drwx------    2 rich     rich         4096 Oct 17 22:20 dir.ouT8S8/
-rwxr--r--    1 rich     rich          338 Oct 17 22:20 test21*
$ cd dir.ouT8S8
[dir.ouT8S8]$ ls -al
total 16
drwx------    2 rich     rich         4096 Oct 17 22:20 ./
drwxr-xr-x    3 rich     rich         4096 Oct 17 22:20 ../
-rw-------    1 rich     rich           44 Oct 17 22:20 temp.N5F3O6
-rw-------    1 rich     rich           44 Oct 17 22:20 temp.SQslb7
[dir.ouT8S8]$ cat temp.N5F3O6
This is a test line of data for temp.N5F3O6
[dir.ouT8S8]$ cat temp.SQslb7
This is a test line of data for temp.SQslb7
[dir.ouT8S8]$

这段脚本在当前目录创建了一个临时目录,然后它用 cd 命令进入该目录,并创建了两个临时文件。之后这两个临时文件被分配给文件描述符,用来存储脚本的输出。

记录消息

将输出同时发送到显示器和日志文件,这种做法有时候能够派上用场。你不用将输出重定向两次,只要用特殊的 tee 命令就行。tee 命令相当于管道的一个 T 型接头。它将从 STDIN 过来的数据同时发往两处。一处是 STDOUT,另一处是 tee 命令行所指定的文件名:tee filename

由于 tee 会重定向来自 STDIN 的数据,你可以用它配合管道命令来重定向命令输出。

$ date | tee testfile
Sun Oct 19 18:56:21 EDT 2014
$ cat testfile
Sun Oct 19 18:56:21 EDT 2014
$

输出出现在了 STDOUT 中,同时也写入了指定的文件中。注意,默认情况下,tee 命令会在每次使用时覆盖输出文件内容。

$ who | tee testfile
rich     pts/0        2014-10-17 18:41 (192.168.1.2)
$ cat testfile
rich     pts/0        2014-10-17 18:41 (192.168.1.2)
$

如果你想将数据追加到文件中,必须用-a 选项。

利用 tee,既能将数据保存在文件中,也能将数据显示在屏幕上。现在你就可以在为用户显示输出的同时再永久保存一份输出内容了。

$ cat test22
#!/bin/bash
## using the tee command for logging
tempfile=test22file
echo "This is the start of the test" | tee $tempfile
echo "This is the second line of the test" | tee -a $tempfile
echo "This is the end of the test" | tee -a $tempfile
$ ./test22
This is the start of the test
This is the second line of the test
This is the end of the test
$ cat test22file
This is the start of the test
This is the second line of the test
This is the end of the test
$

实例

文件重定向常见于脚本需要读入文件和输出文件时。下面的样例脚本两件事都做了。它读取.csv 格式的数据文件,输出 SQL INSERT 语句来将数据插入数据库。shell 脚本使用命令行参数指定待读取的.csv 文件。.csv 格式用于从电子表格中导出数据,所以你可以把数据库数据放入电子表格中,把电子表格保存成.csv 格式,读取文件,然后创建 INSERT 语句将数据插入 MySQL 数据库。

$cat test23
#!/bin/bash
## read file and create INSERT statements for MySQL
outfile='members.sql'
IFS=','
while read lname fname address city state zip
do
    cat >> $outfile << EOF
        INSERT INTO members (lname,fname,address,city,state,zip) VALUES
        ('$lname', '$fname', '$address', '$city', '$state', '$zip');
EOF
done < ${1}
$

这个脚本很短小,这都要感谢有了文件重定向!脚本中出现了三处重定向操作。while 循环使用 read 语句从数据文件中读取文本。注意在 done 语句中出现的重定向符号:

done < ${1}

当运行程序 test23 时,$1 代表第一个命令行参数。它指明了待读取数据的文件。read 语句会使用 IFS 字符解析读入的文本,我们在这里将 IFS 指定为逗号。

脚本中另外两处重定向操作出现在同一条语句中:

cat >> $outfile << EOF

这条语句中包含一个输出追加重定向(双大于号)和一个输入追加重定向(双小于号)。输出重定向将 cat 命令的输出追加到由$outfile 变量指定的文件中。cat 命令的输入不再取自标准输入,而是被重定向到脚本中存储的数据。EOF 符号标记了追加到文件中的数据的起止。

INSERT INTO members (lname,fname,address,city,state,zip) VALUES
('$lname', '$fname', '$address', '$city', '$state', '$zip');

上面的文本生成了一个标准的 SQL INSERT 语句。注意,其中的数据会由变量来替换,变量中内容则是由 read 语句存入的。所以 while 循环一次读取一行数据,将这些值放入 INSERT 语句模板中,然后将结果输出到输出文件中。

在这个例子中,使用以下输入数据文件。

$ cat members.csv
Blum,Richard,123 Main St.,Chicago,IL,60601
Blum,Barbara,123 Main St.,Chicago,IL,60601
Bresnahan,Christine,456 Oak Ave.,Columbus,OH,43201
Bresnahan,Timothy,456 Oak Ave.,Columbus,OH,43201
$

运行脚本时,显示器上不会出现任何输出:

$ ./test23 members.csv
$

但是在 members.sql 输出文件中,你会看到如下输出内容。

$ cat members.sql
INSERT INTO members (lname,fname,address,city,state,zip) VALUES ('Blum',  'Richard', '123 Main St.', 'Chicago', 'IL', '60601');
INSERT INTO members (lname,fname,address,city,state,zip) VALUES ('Blum',  'Barbara', '123 Main St.', 'Chicago', 'IL', '60601');
INSERT INTO members (lname,fname,address,city,state,zip) VALUES ('Bresnahan',  'Christine', '456 Oak Ave.', 'Columbus', 'OH', '43201');
INSERT INTO members (lname,fname,address,city,state,zip) VALUES ('Bresnahan',  'Timothy', '456 Oak Ave.', 'Columbus', 'OH', '43201');
$

结果和我们预想的一样!现在可以将 members.sql 文件导入 MySQL 数据表中了。

控制脚本

当开始构建高级脚本时,你大概会问如何在 Linux 系统上运行和控制它们。在本书中,到目前为止,我们运行脚本的唯一方式就是以实时模式在命令行界面上直接运行。这并不是 Linux 上运行脚本的唯一方式。有不少方法可以用来运行 shell 脚本。另外还有一些选项能够用于控制脚本。这些控制方法包括向脚本发送信号、修改脚本的优先级以及在脚本运行时切换到运行模式。本章将会对逐一介绍这些方法。

处理信号

Linux 利用信号与运行在系统中的进程进行通信。之前介绍了不同的 Linux 信号以及 Linux 如何用这些信号来停止、启动、终止进程。可以通过对脚本进行编程,使其在收到特定信号时执行某些命令,从而控制 shell 脚本的操作。

重温 Linux 信号

Linux 系统和应用程序可以生成超过 30 个信号。如下列出了在 Linux 编程时会遇到的最常见的 Linux 系统信号。

  • 1 SIGHUP 挂起进程
  • 2 SIGINT 终止进程
  • 3 SIGQUIT 停止进程
  • 9 SIGKILL 无条件终止进程
  • 15 SIGTERM 尽可能终止进程
  • 17 SIGSTOP 无条件停止进程,但不是终止进程
  • 18 IGTSTP 停止或暂停进程,但不终止进程
  • 19 SIGCONT 继续运行停止的进程

默认情况下,bash shell 会忽略收到的任何 SIGQUIT (3)和 SIGTERM (15)信号(正因为这样,交互式 shell 才不会被意外终止)。但是 bash shell 会处理收到的 SIGHUP (1)和 SIGINT (2)信号。

如果 bash shell 收到了 SIGHUP 信号,比如当你要离开一个交互式 shell,它就会退出。但在退出之前,它会将 SIGHUP 信号传给所有由该 shell 所启动的进程(包括正在运行的 shell 脚本)。

通过 SIGINT 信号,可以中断 shell。Linux 内核会停止为 shell 分配 CPU 处理时间。这种情况发生时,shell 会将 SIGINT 信号传给所有由它所启动的进程,以此告知出现的状况。

你可能也注意到了,shell 会将这些信号传给 shell 脚本程序来处理。而 shell 脚本的默认行为是忽略这些信号。它们可能会不利于脚本的运行。要避免这种情况,你可以脚本中加入识别信号的代码,并执行命令来处理信号。

生成信号

bash shell 允许用键盘上的组合键生成两种基本的 Linux 信号。这个特性在需要停止或暂停失控程序时非常方便。

  1. 中断进程

Ctrl+C 组合键会生成 SIGINT 信号,并将其发送给当前在 shell 中运行的所有进程。可以运行一条需要很长时间才能完成的命令,然后按下 Ctrl+C 组合键来测试它。

$ sleep 100
^C
$

Ctrl+C 组合键会发送 SIGINT 信号,停止 shell 中当前运行的进程。sleep 命令会使得 shell 暂停指定的秒数,命令提示符直到计时器超时才会返回。在超时前按下 Ctrl+C 组合键,就可以提前终止 sleep 命令。

  1. 暂停进程

你可以在进程运行期间暂停进程,而无需终止它。尽管有时这可能会比较危险(比如,脚本打开了一个关键的系统文件的文件锁),但通常它可以在不终止进程的情况下使你能够深入脚本内部一窥究竟。

Ctrl+Z 组合键会生成一个 SIGTSTP 信号,停止 shell 中运行的任何进程。停止(stopping)进程跟终止(terminating)进程不同:停止进程会让程序继续保留在内存中,并能从上次停止的位置继续运行。随后你会了解如何重启一个已经停止的进程。

当用 Ctrl+Z 组合键时,shell 会通知你进程已经被停止了。

$ sleep 100
^Z
[1]+ Stopped sleep 100
$

方括号中的数字是 shell 分配的作业号(job number)。shell 将 shell 中运行的每个进程称为作业,并为每个作业分配唯一的作业号。它会给第一个作业分配作业号 1,第二个作业号 2,以此类推。

如果你的 shell 会话中有一个已停止的作业,在退出 shell 时,bash 会提醒你。

$ sleep 100
^Z
[1]+  Stopped                 sleep 100
$ exit
exit There are stopped jobs.
$

可以用 ps 命令来查看已停止的作业。

$ sleep 100
^Z [1]+  Stopped                 sleep 100
$
$ ps -lF
S UID   PID  PPID  C PRI NI ADDR SZ WCHAN  TTY       TIME CMD
0 S 501  2431  2430  0  80  0 - 27118 wait   pts/0 00:00:00 bash
0 T 501  2456  2431  0  80  0 - 25227 signal pts/0 00:00:00 sleep
0 R 501  2458  2431  0  80  0 - 27034 -      pts/0 00:00:00 ps
$

在 S 列中(进程状态),ps 命令将已停止作业的状态为显示为 T。这说明命令已经被停止了。

如果在有已停止作业存在的情况下,你仍旧想退出 shell,只要再输入一遍 exit 命令就行了。shell 会退出,终止已停止作业。或者,既然你已经知道了已停止作业的 PID,就可以用 kill 命令来发送一个 SIGKILL 信号来终止它。

$ kill -9 2456
$ [1]+  Killed                  sleep 100
$

在终止作业时,最开始你不会得到任何回应。但下次如果你做了能够产生 shell 提示符的操作(比如按回车键),你就会看到一条消息,显示作业已经被终止了。每当 shell 产生一个提示符时,它就会显示 shell 中状态发生改变的作业的状态。在你终止一个作业后,下次强制 shell 生成一个提示符时,shell 会显示一条消息,说明作业在运行时被终止了。

捕获信号

也可以不忽略信号,在信号出现时捕获它们并执行其他命令。trap 命令允许你来指定 shell 脚本要监看并从 shell 中拦截的 Linux 信号。如果脚本收到了 trap 命令中列出的信号,该信号不再由 shell 处理,而是交由本地处理。

trap commands signals

非常简单!在 trap 命令行上,你只要列出想要 shell 执行的命令,以及一组用空格分开的待捕获的信号。你可以用数值或 Linux 信号名来指定信号。

这里有个简单例子,展示了如何使用 trap 命令来忽略 SIGINT 信号,并控制脚本的行为。

$ cat test1.sh
#!/bin/bash
## Testing signal trapping
#
trap "echo ' Sorry! I have trapped Ctrl-C'" SIGINT
#
echo This is a test script
#
count=1
while [ $count -le 10 ]
do
    echo "Loop #$count"
    sleep 1
    count=$[ $count + 1 ]
done
#
echo "This is the end of the test script" #

本例中用到的 trap 命令会在每次检测到 SIGINT 信号时显示一行简单的文本消息。捕获这些信号会阻止用户用 bash shell 组合键 Ctrl+C 来停止程序。

$ ./test1.sh
This is a test script
Loop #1
Loop #2
Loop #3
Loop #4
Loop #5
^C Sorry! I have trapped Ctrl-C
Loop #6
Loop #7
Loop #8
^C Sorry! I have trapped Ctrl-C
Loop #9
Loop #10
This is the end of the test script
$

每次使用 Ctrl+C 组合键,脚本都会执行 trap 命令中指定的 echo 语句,而不是处理该信号并允许 shell 停止该脚本。

捕获脚本退出

除了在 shell 脚本中捕获信号,你也可以在 shell 脚本退出时进行捕获。这是在 shell 完成任务时执行命令的一种简便方法。

要捕获 shell 脚本的退出,只要在 trap 命令后加上 EXIT 信号就行。

$ cat test2.sh
#!/bin/bash
## Trapping the script exit
#
trap "echo Goodbye..." EXIT
#
count=1
while [ $count -le 5 ]
do
    echo "Loop #$count"
    sleep 1
    count=$[ $count + 1 ]
done
#
$
$ ./test2.sh
Loop #1
Loop #2
Loop #3
Loop #4
Loop #5
Goodbye...
$

当脚本运行到正常的退出位置时,捕获就被触发了,shell 会执行在 trap 命令行指定的命令。如果提前退出脚本,同样能够捕获到 EXIT。

$ ./test2.sh
Loop #1
Loop #2
Loop #3
^CGoodbye...
$

因为 SIGINT 信号并没有出现在 trap 命令的捕获列表中,当按下 Ctrl+C 组合键发送 SIGINT 信号时,脚本就退出了。但在脚本退出前捕获到了 EXIT,于是 shell 执行了 trap 命令

修改或移除捕获

要想在脚本中的不同位置进行不同的捕获处理,只需重新使用带有新选项的 trap 命令。

$ cat test3.sh
#!/bin/bash
## Modifying a set trap
#
trap "echo ' Sorry... Ctrl-C is trapped.'" SIGINT
#
count=1
while [ $count -le 5 ]
do
    echo "Loop #$count"
    sleep 1
    count=$[ $count + 1 ]
done
#
trap "echo ' I modified the trap!'" SIGINT
#
count=1
while [ $count -le 5 ]
do
    echo "Second Loop #$count"
    sleep 1
    count=$[ $count + 1 ]
done
#
$

修改了信号捕获之后,脚本处理信号的方式就会发生变化。但如果一个信号是在捕获被修改前接收到的,那么脚本仍然会根据最初的 trap 命令进行处理。

$ ./test3.sh
Loop #1
Loop #2
Loop #3
^C Sorry... Ctrl-C is trapped.
Loop #4
Loop #5
Second Loop #1
Second Loop #2
^C I modified the trap!
Second Loop #3
Second Loop #4
Second Loop #5
$

也可以删除已设置好的捕获。只需要在 trap 命令与希望恢复默认行为的信号列表之间加上两个破折号就行了。

$ cat test3b.sh
#!/bin/bash
## Removing a set trap
#
trap "echo ' Sorry... Ctrl-C is trapped.'" SIGINT
#
count=1
while [ $count -le 5 ]
do
    echo "Loop #$count"
    sleep 1
    count=$[ $count + 1 ]
done
#
## Remove the trap
trap -- SIGINT
echo "I just removed the trap"
#
count=1
while [ $count -le 5 ]
do
    echo "Second Loop #$count"
    sleep 1
    count=$[ $count + 1 ]
done
#
$ ./test3b.sh
Loop #1
Loop #2
Loop #3
Loop #4
Loop #5
I just removed the trap
Second Loop #1
Second Loop #2
Second Loop #3 ^C
$

也可以在 trap 命令后使用单破折号来恢复信号的默认行为。单破折号和双破折号都可以正常发挥作用。

移除信号捕获后,脚本按照默认行为来处理 SIGINT 信号,也就是终止脚本运行。但如果信号是在捕获被移除前接收到的,那么脚本会按照原先 trap 命令中的设置进行处理。

$ ./test3b.sh
Loop #1
Loop #2
Loop #3
^C Sorry... Ctrl-C is trapped.
Loop #4
Loop #5
I just removed the trap
Second Loop #1
Second Loop #2
^C
$

在本例中,第一个 Ctrl+C 组合键用于提前终止脚本。因为信号在捕获被移除前已经接收到了,脚本会照旧执行 trap 中指定的命令。捕获随后被移除,再按 Ctrl+C 就能够提前终止脚本了。

以后台模式运行脚本

直接在命令行界面运行 shell 脚本有时不怎么方便。一些脚本可能要执行很长一段时间,而你可能不想在命令行界面一直干等着。当脚本在运行时,你没法在终端会话里做别的事情。幸好有个简单的方法可以解决。

在用 ps 命令时,会看到运行在 Linux 系统上的一系列不同进程。显然,所有这些进程都不是运行在你的终端显示器上的。这样的现象被称为在后台(background)运行进程。在后台模式中,进程运行时不会和终端会话上的 STDIN、STDOUT 以及 STDERR 关联。

也可以在 shell 脚本中试试这个特性,允许它们在后台运行而不用占用终端会话。之前简单讲述过后台模式,下面几节将会继续介绍如何在 Linux 系统上以后台模式运行脚本。

后台运行脚本

以后台模式运行 shell 脚本非常简单。只要在命令后加个&符就行了。


$ cat test4.sh
#!/bin/bash
## Test running in the background
#
count=1
while [ $count -le 10 ]
do
    sleep 1
    count=$[ $count + 1 ]
done
#
$
$ ./test4.sh &
[1] 3231
$

当&符放到命令后时,它会将命令和 bash shell 分离开来,将命令作为系统中的一个独立的后台进程运行。显示的第一行是:

[1] 3231

方括号中的数字是 shell 分配给后台进程的作业号。下一个数是 Linux 系统分配给进程的进程 ID(PID)。Linux 系统上运行的每个进程都必须有一个唯一的 PID。 一旦系统显示了这些内容,新的命令行界面提示符就出现了。你可以回到 shell,而你所执行的命令正在以后台模式安全的运行。这时,你可以在提示符输入新的命令。

当后台进程结束时,它会在终端上显示出一条消息:

[1]   Done                    ./test4.sh

这表明了作业的作业号以及作业状态(Done),还有用于启动作业的命令。注意,当后台进程运行时,它仍然会使用终端显示器来显示 STDOUT 和 STDERR 消息。

$ cat test5.sh
#!/bin/bash
## Test running in the background with output
#
echo "Start the test script"
count=1
while [ $count -le 5 ]
do
    echo "Loop #$count"
    sleep 5
    count=$[ $count + 1 ]
done
#
echo "Test script is complete"
#
$
$ ./test5.sh &
[1] 3275
$ Start the test script
Loop #1
Loop #2
Loop #3
Loop #4
Loop #5
Test script is complete
[1]   Done                    ./test5.sh
$

你会注意到在上面的例子中,脚本 test5.sh 的输出与 shell 提示符混杂在了一起,这也是为什么 Start the test script 会出现在提示符旁边的原因。
在显示输出的同时,你仍然可以运行命令。

$ ./test5.sh &
[1] 3319
$ Start the test script
Loop #1
Loop #2
Loop #3
ls myprog* myprog  myprog.c
$ Loop #4
Loop #5
st script is complete
[1]+  Done                    ./test5.sh
$$

当脚本 test5.sh 运行在后台模式时,我们输入了命令 ls myprog*。脚本输出、输入的命令以及命令输出全都混在了一起。真是让人头昏脑胀!最好是将后台运行的脚本的 STDOUT 和 STDERR 进行重定向,避免这种杂乱的输出。

运行多个后台作业

可以在命令行提示符下同时启动多个后台作业。

$ ./test6.sh &
[1] 3568
$ This is Test Script #1
$ ./test7.sh &
[2] 3570
$ This is Test Script #2
$ ./test8.sh &
[3] 3573
$ And...another Test script
$ ./test9.sh &[4] 3576
$ Then...there was one more test script
$

每次启动新作业时,Linux 系统都会为其分配一个新的作业号和 PID。通过 ps 命令,可以看到所有脚本处于运行状态。

$ ps
PID TTY          TIME CMD
2431 pts/0    00:00:00 bash
3568 pts/0    00:00:00 test6.sh
3570 pts/0    00:00:00 test7.sh
3573 pts/0    00:00:00 test8.sh
3574 pts/0    00:00:00 sleep
3575 pts/0    00:00:00 sleep
3576 pts/0    00:00:00 test9.sh
3577 pts/0    00:00:00 sleep
3578 pts/0    00:00:00 sleep
3579 pts/0    00:00:00 ps
$

在终端会话中使用后台进程时一定要小心。注意,在 ps 命令的输出中,每一个后台进程都和终端会话(pts/0)终端联系在一起。如果终端会话退出,那么后台进程也会随之退出。

本章之前曾经提到过当你要退出终端会话时,要是存在被停止的进程,会出现警告信息。但如果使用了后台进程,只有某些终端仿真器会在你退出终端会话前提醒你还有后台作业在运行。

如果希望运行在后台模式的脚本在登出控制台后能够继续运行,需要借助于别的手段。下一节中我们会讨论怎么来实现。

在非控制台下运行脚本

有时你会想在终端会话中启动 shell 脚本,然后让脚本一直以后台模式运行到结束,即使你退出了终端会话。这可以用 nohup 命令来实现。nohup 命令运行了另外一个命令来阻断所有发送给该进程的 SIGHUP 信号。这会在退出终端会话时阻止进程退出。

nohup 命令的格式如下:

$ nohup ./test1.sh &
[1] 3856
$ nohup: ignoring input and appending output to 'nohup.out'
$

和普通后台进程一样,shell 会给命令分配一个作业号,Linux 系统会为其分配一个 PID 号。区别在于,当你使用 nohup 命令时,如果关闭该会话,脚本会忽略终端会话发过来的 SIGHUP 信号。

由于 nohup 命令会解除终端与进程的关联,进程也就不再同 STDOUT 和 STDERR 联系在一起。为了保存该命令产生的输出,nohup 命令会自动将 STDOUT 和 STDERR 的消息重定向到一个名为 nohup.out 的文件中。

如果使用 nohup 运行了另一个命令,该命令的输出会被追加到已有的 nohup.out 文件中。当运行位于同一个目录中的多个命令时一定要当心,因为所有的输出都会被发送到同一个 nohup.out 文件中,结果会让人摸不清头脑。

nohup.out 文件包含了通常会发送到终端显示器上的所有输出。在进程完成运行后,你可以查看 nohup.out 文件中的输出结果。输出会出现在 nohup.out 文件中,就跟进程在命令行下运行时一样。

作业控制

在本章的前面部分,你已经知道了如何用组合键停止 shell 中正在运行的作业。在作业停止后,可以选择是终止还是重启。你可以用 kill 命令终止该进程。要重启停止的进程需要向其发送一个 SIGCONT 信号。

启动、停止、终止以及恢复作业的这些功能统称为作业控制。通过作业控制,就能完全控制 shell 环境中所有进程的运行方式了。本节将介绍用于查看和控制在 shell 中运行的作业的命令。

查看作业

作业控制中的关键命令是 jobs 命令。jobs 命令允许查看 shell 当前正在处理的作业。

$ cat test10.sh
#!/bin/bash
## Test job control
#
echo "Script Process ID: $$"
#
count=1
while [ $count -le 10 ]
do
    echo "Loop #$count"
    sleep 10
    count=$[ $count + 1 ]
done
#
echo "End of script..."
#
$

脚本用$$变量来显示 Linux 系统分配给该脚本的 PID,然后进入循环,每次迭代都休眠 10 秒。可以从命令行中启动脚本,然后使用 Ctrl+Z 组合键来停止脚本。

$ ./test10.sh
Script Process ID: 1897
Loop #1
Loop #2
^Z
[1]+  Stopped                 ./test10.sh
$

还是使用同样的脚本,利用&将另外一个作业作为后台进程启动。出于简化的目的,脚本的输出被重定向到文件中,避免出现在屏幕上。

$ ./test10.sh > test10.out &
[2] 1917
$

jobs 命令可以查看分配给 shell 的作业。jobs 命令会显示这两个已停止/运行中的作业,以及它们的作业号和作业中使用的命令。

$ jobs
[1]+  Stopped                 ./test10.sh
[2]-  Running                 ./test10.sh > test10.out &
$

要想查看作业的 PID,可以在 jobs 命令中加入-l 选项(小写的 L)。

$ jobs -l
[1]+  1897 Stopped                 ./test10.sh
[2]-  1917 Running                 ./test10.sh > test10.out &
$

jobs 命令使用一些不同的命令行参数如下

  • l 列出进程的 PID 以及作业号
  • n 只列出上次 shell 发出的通知后改变了状态的作业
  • p 只列出作业的 PID
  • r 只列出运行中的作业
  • s 只列出已停止的作业

你可能注意到了 jobs 命令输出中的加号和减号。带加号的作业会被当做默认作业。在使用作业控制命令时,如果未在命令行指定任何作业号,该作业会被当成作业控制命令的操作对象。当前的默认作业完成处理后,带减号的作业成为下一个默认作业。任何时候都只有一个带加号的作业和一个带减号的作业,不管 shell 中有多少个正在运行的作业。

下面例子说明了队列中的下一个作业在默认作业移除时是如何成为默认作业的。有 3 个独立的进程在后台被启动。jobs 命令显示出了这些进程、进程的 PID 及其状态。注意,默认进程(带有加号的那个)是最后启动的那个进程,也就是 3 号作业。

$ ./test10.sh > test10a.out &
[1] 1950
$ ./test10.sh > test10b.out &
[2] 1952
$ ./test10.sh > test10c.out &
[3] 1955
$
$ jobs -l
[1]   1950 Running                 ./test10.sh > test10a.out &
[2]-  1952 Running                 ./test10.sh > test10b.out &
[3]+  1955 Running                 ./test10.sh > test10c.out &
$

我们调用了 kill 命令向默认进程发送一个 SIGHUP 信号,终止了该作业。在接下来的 jobs 命令输出中,先前带有减号的作业成了现在的默认作业,减号也变成了加号。

$ kill 1955
$ [3]+  Terminated              ./test10.sh > test10c.out
$
$ jobs -l
[1]-  1950 Running                 ./test10.sh > test10a.out &
[2]+  1952 Running                 ./test10.sh > test10b.out &
$
$ kill 1952
$
[2]+  Terminated              ./test10.sh > test10b.out
$
$ jobs -l
[1]+  1950 Running                 ./test10.sh > test10a.out &
$

尽管将一个后台作业更改为默认进程很有趣,但这并不意味着有用。下一节,你将学习在不用 PID 或作业号的情况下,使用命令和默认进程交互。

重启停止的作业

在 bash 作业控制中,可以将已停止的作业作为后台进程或前台进程重启。前台进程会接管你当前工作的终端,所以在使用该功能时要小心了。要以后台模式重启一个作业,可用 bg 命令加上作业号。

$ ./test11.sh
^Z
[1]+  Stopped                 ./test11.sh
$
$ bg
[1]+ ./test11.sh &
$
$ jobs
[1]+  Running                 ./test11.sh &
$

因为该作业是默认作业(从加号可以看出),只需要使用 bg 命令就可以将其以后台模式重启。注意,当作业被转入后台模式时,并不会列出其 PID。如果有多个作业,你得在 bg 命令后加上作业号。

$ ./test11.sh
^Z
[1]+  Stopped                 ./test11.sh
$
$ ./test12.sh
^Z
[2]+  Stopped                 ./test12.sh
$
$ bg 2
[2]+ ./test12.sh &
$
$ jobs
[1]+  Stopped                 ./test11.sh
[2]-  Running                 ./test12.sh &
$

命令 bg 2 用于将第二个作业置于后台模式。注意,当使用 jobs 命令时,它列出了作业及其状态,即便是默认作业当前并未处于后台模式。

要以前台模式重启作业,可用带有作业号的 fg 命令。

$ fg 2
./test12.sh
This is the script's end...
$

由于作业是以前台模式运行的,直到该作业完成后,命令行界面的提示符才会出现。

调整谦让度

在多任务操作系统中(Linux 就是),内核负责将 CPU 时间分配给系统上运行的每个进程。调度优先级(scheduling priority)是内核分配给进程的 CPU 时间(相对于其他进程)。在 Linux 系统中,由 shell 启动的所有进程的调度优先级默认都是相同的。

调度优先级是个整数值,从 -20(最高优先级)到+19(最低优先级)。默认情况下,bash shell 以优先级 0 来启动所有进程。

最低值 -20 是最高优先级,而最高值 19 是最低优先级,这太容易记混了。只要记住那句俗语“好人难做”就行了。越是“好”或高的值,获得 CPU 时间的机会越低。

有时你想要改变一个 shell 脚本的优先级。不管是降低它的优先级(这样它就不会从占用其他进程过多的处理能力),还是给予它更高的优先级(这样它就能获得更多的处理时间),你都可以通过 nice 命令做到。

nice 命令

nice 命令允许你设置命令启动时的调度优先级。要让命令以更低的优先级运行,只要用 nice 的-n 命令行来指定新的优先级级别。

$ nice -n 10 ./test4.sh > test4.out &
[1] 4973
$
$ ps -p 4973 -o pid,ppid,ni,cmd
PID  PPID  NI CMD
4973  4721  10 /bin/bash ./test4.sh
$

注意,必须将 nice 命令和要启动的命令放在同一行中。ps 命令的输出验证了谦让度值(NI 列)已经被调整到了 10。

nice 命令会让脚本以更低的优先级运行。但如果想提高某个命令的优先级,你可能会吃惊。

$ nice -n -10 ./test4.sh > test4.out &
[1] 4985
$ nice: cannot set niceness: Permission denied
[1]+  Done                    nice -n -10 ./test4.sh > test4.out
$

nice 命令阻止普通系统用户来提高命令的优先级。注意,指定的作业的确运行了,但是试图使用 nice 命令提高其优先级的操作却失败了。

nice 命令的-n 选项并不是必须的,只需要在破折号后面跟上优先级就行了。

$ nice -10 ./test4.sh > test4.out &
[1] 4993
$
$ ps -p 4993 -o pid,ppid,ni,cmd
PID  PPID  NI CMD
4993  4721  10 /bin/bash ./test4.sh
$

renice 命令

有时你想改变系统上已运行命令的优先级。这正是 renice 命令可以做到的。它允许你指定运行进程的 PID 来改变它的优先级。

renice 命令会自动更新当前运行进程的调度优先级。和 nice 命令一样,renice 命令也有一些限制:

$ ./test11.sh &
[1] 5055
$
$ ps -p 5055 -o pid,ppid,ni,cmd
PID  PPID  NI CMD
5055  4721   0 /bin/bash ./test11.sh
$
$ renice -n 10 -p 5055
5055: old priority 0, new priority 10
$
$ ps -p 5055 -o pid,ppid,ni,cmd
PID  PPID  NI CMD
5055  4721  10 /bin/bash ./test11.sh
$
  • 只能对属于你的进程执行 renice
  • 只能通过 renice 降低进程的优先级
  • root 用户可以通过 renice 来任意调整进程的优先级。

如果想完全控制运行进程,必须以 root 账户身份登录或使用 sudo 命令。

定时运行作业

当你开始使用脚本时,可能会想要在某个预设时间运行脚本,这通常是在你不在场的时候。Linux 系统提供了多个在预选时间运行脚本的方法:at 命令和 cron 表。每个方法都使用不同的技术来安排脚本的运行时间和频率。接下来会依次介绍这些方法。

用 at 命令来计划执行作业

at 命令允许指定 Linux 系统何时运行脚本。at 命令会将作业提交到队列中,指定 shell 何时运行该作业。at 的守护进程 atd 会以后台模式运行,检查作业队列来运行作业。大多数 Linux 发行版会在启动时运行此守护进程(archlinux 需要安装at包)。

atd 守护进程会检查系统上的一个特殊目录(通常位于/var/spool/atd)来获取用 at 命令提交的作业。默认情况下,atd 守护进程会每 60 秒检查一下这个目录。有作业时,atd 守护进程会检查作业设置运行的时间。如果时间跟当前时间匹配,atd 守护进程就会运行此作业。

后面几节会介绍如何用 at 命令提交要运行的作业以及如何管理这些作业。

  1. at 命令的格式

at 命令的基本格式非常简单:

at [-f filename] time

默认情况下,at 命令会将 STDIN 的输入放到队列中。你可以用-f 参数来指定用于读取命令(脚本文件)的文件名。time 参数指定了 Linux 系统何时运行该作业。如果你指定的时间已经错过,at 命令会在第二天的那个时间运行指定的作业。在如何指定时间这个问题上,你可以非常灵活。at 命令能识别多种不同的时间格式。

  • 标准的小时和分钟格式,比如 10:15。
  • AM/PM 指示符,比如 10:15 PM。
  • 特定可命名时间,比如 now、noon、midnight 或者 teatime(4 PM)。

除了指定运行作业的时间,也可以通过不同的日期格式指定特定的日期。

  • 标准日期格式,比如 MMDDYY、MM/DD/YY 或 DD.MM.YY。
  • 文本日期,比如 Jul 4 或 Dec 25,加不加年份均可。
  • 你也可以指定时间增量。
    • 当前时间+25 min
    • 明天 10:15 PM
    • 10:15+7 天

在你使用 at 命令时,该作业会被提交到作业队列(job queue)。作业队列会保存通过 at 命令提交的待处理的作业。针对不同优先级,存在 26 种不同的作业队列。作业队列通常用小写字母 a~z 和大写字母 A~Z 来指代。

在几年前,也可以使用 batch 命令在指定时间执行某个脚本。batch 命令很特别,你可以安排脚本在系统处于低负载时运行。但现在 batch 命令只不过是一个脚本而已(/usr/bin/batch),它会调用 at 命令并将作业提交到 b 队列中。

作业队列的字母排序越高,作业运行的优先级就越低(更高的 nice 值)。默认情况下,at 的作业会被提交到 a 作业队列。如果想以更高优先级运行作业,可以用-q 参数指定不同的队列字母。

  1. 获取作业的输出

当作业在 Linux 系统上运行时,显示器并不会关联到该作业。取而代之的是,Linux 系统会将提交该作业的用户的电子邮件地址作为 STDOUT 和 STDERR。任何发到 STDOUT 或 STDERR 的输出都会通过邮件系统发送给该用户。

这里有个在 CentOS 发行版中使用 at 命令安排作业执行的例子。

$ cat test13.sh
#!/bin/bash
## Test using at command
#
echo "This script ran at $(date +%B%d,%T)"
echo sleep 5
echo "This is the script's end..."
#
$ at -f test13.sh now
job 7 at 2015-07-14 12:38
$

at 命令会显示分配给作业的作业号以及为作业安排的运行时间。-f 选项指明使用哪个脚本文件,now 指示 at 命令立刻执行该脚本。

使用 e-mail 作为 at 命令的输出极其不便。at 命令利用 sendmail 应用程序来发送邮件。如果你的系统中没有安装 sendmail,那就无法获得任何输出!因此在使用 at 命令时,最好在脚本中对 STDOUT 和 STDERR 进行重定向,如下例所示。

$ cat test13b.sh
#!/bin/bash
## Test using at command
#
echo "This script ran at $(date +%B%d,%T)" > test13b.out
echo >> test13b.out
sleep 5
echo "This is the script's end..." >> test13b.out
#
$
$ at -M -f test13b.sh now
job 8 at 2015-07-14 12:48
$
$ cat test13b.out
This script ran at July14,12:48:18
This is the script's end...
$

如果不想在 at 命令中使用邮件或重定向,最好加上-M 选项来屏蔽作业产生的输出信息。

  1. 列出等待的作业

atq 命令可以查看系统中有哪些作业在等待。

$ at -M -f test13b.sh teatime
job 17 at 2015-07-14 16:00
$
$ at -M -f test13b.sh tomorrow
job 18 at 2015-07-15 13:03
$
$ at -M -f test13b.sh 13:30
job 19 at 2015-07-14 13:30
$
$ at -M -f test13b.sh now
job 20 at 2015-07-14 13:03
$
$ atq
20      2015-07-14 13:03 = Christine
18      2015-07-15 13:03 a Christine
17      2015-07-14 16:00 a Christine
19      2015-07-14 13:30 a Christine
$

作业列表中显示了作业号、系统运行该作业的日期和时间及其所在的作业队列。

  1. 删除作业

一旦知道了哪些作业在作业队列中等待,就能用 atrm 命令来删除等待中的作业。

$ atq
18      2015-07-15 13:03 a Christine
17      2015-07-14 16:00 a Christine
19      2015-07-14 13:30 a Christine
$
$ atrm 18
$
$ atq
17      2015-07-14 16:00 a Christine
19      2015-07-14 13:30 a Christine
$

只要指定想要删除的作业号就行了。只能删除你提交的作业,不能删除其他人的

安排需要定期执行的脚本

用 at 命令在预设时间安排脚本执行非常好用,但如果你需要脚本在每天的同一时间运行或是每周一次、每月一次呢?用不着再使用 at 不断提交作业了,你可以利用 Linux 系统的另一个功能。

Linux 系统使用 cron 程序来安排要定期执行的作业。cron 程序会在后台运行并检查一个特殊的表(被称作 cron 时间表),以获知已安排执行的作业。

  1. cron 时间表

cron 时间表采用一种特别的格式来指定作业何时运行。其格式如下:

min hour dayofmonth month dayofweek command

cron 时间表允许你用特定值、取值范围(比如 1~5)或者是通配符(星号)来指定条目。例如,如果想在每天的 10:15 运行一个命令,可以用 cron 时间表条目:

15 10 * * * command

在 dayofmonth、month 以及 dayofweek 字段中使用了通配符,表明 cron 会在每个月每天的 10:15 执行该命令。要指定在每周一 4:15 PM 运行的命令,可以用下面的条目:

15 16 * * 1 command

可以用三字符的文本值(mon、tue、wed、thu、fri、sat、sun)或数值(0 为周日,6 为周六)来指定 dayofweek 表项。 这里还有另外一个例子:在每个月的第一天中午 12 点执行命令。可以用下面的格式:

00 12 1 * * command

dayofmonth 表项指定月份中的日期值(1~31)。

聪明的读者可能会问如何设置一个在每个月的最后一天执行的命令,因为你无法设置 dayofmonth 的值来涵盖所有的月份。这个问题困扰着 Linux 和 Unix 程序员,也激发了不少解决办法。常用的方法是加一条使用 date 命令的 if-then 语句来检查明天的日期是不是 01:00 12 * * * if [date +%d -d tomorrow = 01 ] ; then ; command 它会在每天中午 12 点来检查是不是当月的最后一天,如果是,cron 将会运行该命令。

命令列表必须指定要运行的命令或脚本的全路径名。你可以像在普通的命令行中那样,添加任何想要的命令行参数和重定向符号。

15 10 * * * /home/rich/test4.sh > test4out

cron 程序会用提交作业的用户账户运行该脚本。因此,你必须有访问该命令和命令中指定的输出文件的权限。

  1. 构建 cron 时间表

每个系统用户(包括 root 用户)都可以用自己的 cron 时间表来运行安排好的任务。Linux 提供了 crontab 命令来处理 cron 时间表。要列出已有的 cron 时间表,可以用-l 选项。archlinux 下需要安装 cronie 包

$ crontab -l
no crontab for rich
$

默认情况下,用户的 cron 时间表文件并不存在。要为 cron 时间表添加条目,可以用-e 选项。在添加条目时,crontab 命令会启用一个文本编辑器,使用已有的 cron 时间表作为文件内容(或者是一个空文件,如果时间表不存在的话)。如果想要以指定编辑打开,可以加上 EDITOR 前缀,如EDITOR=vim crontab -e

  1. 浏览 cron 目录

如果你创建的脚本对精确的执行时间要求不高,用预配置的 cron 脚本目录会更方便。有 4 个基本目录:hourly、daily、monthly 和 weekly。

$ ls /etc/cron.*ly
/etc/cron.daily:

/etc/cron.hourly:
0anacron

/etc/cron.monthly:

/etc/cron.weekly:

因此,例如如果脚本需要每天运行一次,只要将脚本复制到 daily 目录,cron 就会每天执行它。

  1. anacron 程序

cron 程序的唯一问题是它假定 Linux 系统是 7×24 小时运行的。除非将 Linux 当成服务器环境来运行,否则此假设未必成立。

如果某个作业在 cron 时间表中安排运行的时间已到,但这时候 Linux 系统处于关机状态,那么这个作业就不会被运行。当系统开机时,cron 程序不会再去运行那些错过的作业。要解决这个问题,许多 Linux 发行版还包含了 anacron 程序。

如果 anacron 知道某个作业错过了执行时间,它会尽快运行该作业。这意味着如果 Linux 系统关机了几天,当它再次开机时,原定在关机期间运行的作业会自动运行。

这个功能常用于进行常规日志维护的脚本。如果系统在脚本应该运行的时间刚好关机,日志文件就不会被整理,可能会变很大。通过 anacron,至少可以保证系统每次启动时整理日志文件

anacron 程序只会处理位于 cron 目录的程序,比如/etc/cron.monthly。它用时间戳来决定作业是否在正确的计划间隔内运行了。每个 cron 目录都有个时间戳文件,该文件位于/var/spool/anacron。

$ sudo cat /var/spool/anacron/cron.monthly
20150626
$

anacron 程序使用自己的时间表(通常位于/etc/anacrontab)来检查作业目录。

$ sudo cat /etc/anacrontab
## /etc/anacrontab: configuration file for anacron

## See anacron(8) and anacrontab(5) for details.

SHELL=/bin/sh
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root
## the maximal random delay added to the base delay of the jobs
RANDOM_DELAY=45
## the jobs will be started during the following hours only
START_HOURS_RANGE=3-22

#period in days   delay in minutes   job-identifier   command
1	5	cron.daily		nice run-parts /etc/cron.daily
7	25	cron.weekly		nice run-parts /etc/cron.weekly
@monthly 45	cron.monthly		nice run-parts /etc/cron.monthly

anacron 时间表的基本格式和 cron 时间表略有不同:

period delay identifier command

period 条目定义了作业多久运行一次,以天为单位。anacron 程序用此条目来检查作业的时间戳文件。delay 条目会指定系统启动后 anacron 程序需要等待多少分钟再开始运行错过的脚本。command 条目包含了 run-parts 程序和一个 cron 脚本目录名。run-parts 程序负责运行目录中传给它的任何脚本。

注意,anacron 不会运行位于/etc/cron.hourly 的脚本。这是因为 anacron 程序不会处理执行时间需求小于一天的脚本。identifier 条目是一种特别的非空字符串,如 cron-weekly。它用于唯一标识日志消息和错误邮件中的作业。

使用新 shell 启动脚本

如果每次运行脚本的时候都能够启动一个新的 bash shell(即便只是某个用户启动了一个 bash shell),将会非常的方便。有时候,你希望为 shell 会话设置某些 shell 功能,或者只是为了确保已经设置了某个文件。

回想一下当用户登入 bash shell 时需要运行的启动文件(参见第 6 章)。另外别忘了,不是所有的发行版中都包含这些启动文件。基本上,依照下列顺序所找到的第一个文件会被运行,其余的文件会被忽略

  • $HOME/.bash_profile
  • $HOME/.bash_login
  • $HOME/.profile

因此,应该将需要在登录时运行的脚本放在上面第一个文件中。每次启动一个新 shell 时,bash shell 都会运行.bashrc 文件。可以这样来验证:在主目录下的.bashrc 文件中加入一条简单的 echo 语句,然后启动一个新 shell。

$ cat .bashrc
## .bashrc
## Source global definitions
if [ -f /etc/bashrc ]; then
    . /etc/bashrc
fi
## User specific aliases and functions
echo "I'm in a new shell!"
$
$ bash
I'm in a new shell!
$
$ exit
exit
$

.bashrc 文件通常也是通过某个 bash 启动文件来运行的。因为.bashrc 文件会运行两次:一次是当你登入 bash shell 时,另一次是当你启动一个 bash shell 时。如果你需要一个脚本在两个时刻都得以运行,可以把这个脚本放进该文件中。

函数

在编写 shell 脚本时,你经常会发现在多个地方使用了同一段代码。如果只是一小段代码,一般也无关紧要。但要在 shell 脚本中多次重写大块代码段就太累人了。bash shell 提供的用户自定义函数功能可以解决这个问题。可以将 shell 脚本代码放进函数中封装起来,这样就能在脚本中的任何地方多次使用它了。本章将会带你逐步了解如何创建自己的 shell 脚本函数,并演示如何在 shell 脚本应用中使用它们。

函数基础

在开始编写较复杂的 shell 脚本时,你会发现自己重复使用了部分能够执行特定任务的代码。这些代码有时很简单,比如显示一条文本消息,或者从脚本用户那里获得一个答案;有时则会比较复杂,需要作为大型处理过程中的一部分被多次使用。在后一类情况下,在脚本中一遍又一遍地编写同样的代码会很烦人。如果能只写一次,随后在脚本中可多次引用这部分代码就好了。

bash shell 提供了这种功能。函数是一个脚本代码块,你可以为其命名并在代码中任何位置重用。要在脚本中使用该代码块时,只要使用所起的函数名就行了(这个过程称为调用函数)。本节将会介绍如何在 shell 脚本中创建和使用函数。

创建函数

有两种格式可以用来在 bash shell 脚本中创建函数。第一种格式采用关键字 function,后跟分配给该代码块的函数名。

function name {
    commands
}

name 属性定义了赋予函数的唯一名称。脚本中定义的每个函数都必须有一个唯一的名称。commands 是构成函数的一条或多条 bash shell 命令。在调用该函数时,bash shell 会按命令在函数中出现的顺序依次执行,就像在普通脚本中一样。

在 bash shell 脚本中定义函数的第二种格式更接近于其他编程语言中定义函数的方式。

name() {
    commands
}

函数名后的空括号表明正在定义的是一个函数。这种格式的命名规则和之前定义 shell 脚本函数的格式一样。

使用函数

要在脚本中使用函数,只需要像其他 shell 命令一样,在行中指定函数名就行了。

$ cat test1
#!/bin/bash
## using a function in a script
function func1 {
    echo "This is an example of a function"
}
count=1
while [ $count -le 5 ]
do
    func1
    count=$[ $count + 1 ]
done
echo "This is the end of the loop"
func1
echo "Now this is the end of the script"
$
$ ./test1
This is an example of a function
This is an example of a function
This is an example of a function
This is an example of a function
This is an example of a function
This is the end of the loop
This is an example of a function
Now this is the end of the script
$

每次引用函数名 func1 时,bash shell 会找到 func1 函数的定义并执行你在那里定义的命令。

函数定义不一定非得是 shell 脚本中首先要做的事,但一定要小心。如果在函数被定义前使用函数,你会收到一条错误消息。

$ cat test2
#!/bin/bash
## using a function located in the middle of a script
count=1
echo "This line comes before the function definition"
function func1 {
    echo "This is an example of a function"
}
while [ $count -le 5 ]
do
    func1
    count=$[ $count + 1 ]
done
echo "This is the end of the loop"
func2
echo "Now this is the end of the script"
function func2 {
    echo "This is an example of a function"
}
$
$ ./test2
This line comes before the function definition
This is an example of a function
This is an example of a function
This is an example of a function
This is an example of a function
This is an example of a function
This is the end of the loop
./test2: func2: command not found
Now this is the end of the script
$

第一个函数 func1 的定义出现在脚本中的几条语句之后,这当然没任何问题。当 func1 函数在脚本中被使用时,shell 知道去哪里找它。
然而,脚本试图在 func2 函数被定义之前使用它。由于 func2 函数还没有定义,脚本运行函数调用处时,产生了一条错误消息。
你也必须注意函数名。记住,函数名必须是唯一的,否则也会有问题。如果你重定义了函数,新定义会覆盖原来函数的定义,这一切不会产生任何错误消息。

$ cat test3
#!/bin/bash
## testing using a duplicate function name
function func1 {
    echo "This is the first definition of the function name"
}
func1
function func1 {
    echo "This is a repeat of the same function name"
}
func1
echo "This is the end of the script"
$
$ ./test3
This is the first definition of the function name
This is a repeat of the same function name
This is the end of the script
$

func1 函数最初的定义工作正常,但重新定义该函数后,后续的函数调用都会使用第二个定义。

返回值

bash shell 会把函数当作一个小型脚本,运行结束时会返回一个退出状态码。有 3 种不同的方法来为函数生成退出状态码。

默认退出状态码

默认情况下,函数的退出状态码是函数中最后一条命令返回的退出状态码。在函数执行结束后,可以用标准变量$?来确定函数的退出状态码。

$ cat test4
#!/bin/bash
## testing the exit status of a function

func1() {
    echo "trying to display a non-existent file"
    ls -l badfile
}
echo "testing the function: "
func1 echo
"The exit status is: $?"
$
$ ./test4
testing the function:
trying to display a non-existent file
ls: badfile: No such file or directory
The exit status is: 1
$

函数的退出状态码是 1,这是因为函数中的最后一条命令没有成功运行。但你无法知道函数中其他命令中是否成功运行。看下面的例子。

$ cat test4b
#!/bin/bash
## testing the exit status of a function
func1() {
    ls -l badfile
    echo "This was a test of a bad command"
}
echo "testing the function:"
func1
echo "The exit status is: $?"
$
$ ./test4b
testing the function:
ls: badfile: No such file or directory
This was a test of a bad command
The exit status is: 0
$

这次,由于函数最后一条语句 echo 运行成功,该函数的退出状态码就是 0,尽管其中有一条命令并没有正常运行。使用函数的默认退出状态码是很危险的。幸运的是,有几种办法可以解决这个问题。

使用 return 命令

bash shell 使用 return 命令来退出函数并返回特定的退出状态码。return 命令允许指定一个整数值来定义函数的退出状态码,从而提供了一种简单的途径来编程设定函数退出状态码。

$ cat test5
#!/bin/bash
## using the return command in a function
function dbl {
    read -p "Enter a value: " value
    echo "doubling the value"
    return $[ $value * 2 ]
}
dbl
echo "The new value is $?"
$

dbl 函数会将$value变量中用户输入的值翻倍,然后用return命令返回结果。脚本用$?变量显示了该值。但当用这种方法从函数中返回值时,要小心了。记住下面两条技巧来避免问题:

  • 记住,函数一结束就取返回值;
  • 记住,退出状态码必须是 0~255。

如果在用$?变量提取函数返回值之前执行了其他命令,函数的返回值就会丢失。记住,$?变量会返回执行的最后一条命令的退出状态码。

第二个问题界定了返回值的取值范围。由于退出状态码必须小于 256,函数的结果必须生成一个小于 256 的整数值。任何大于 256 的值都会产生一个错误值,输出为计算结果对 256 取模。

$ ./test5
Enter a value: 200
doubling the value The new value is 144
$

要返回较大的整数值或者字符串值的话,你就不能用这种返回值的方法了。我们在下一节中将会介绍另一种方法。

使用函数输出

正如可以将命令的输出保存到 shell 变量中一样,你也可以对函数的输出采用同样的处理办法。可以用这种技术来获得任何类型的函数输出,并将其保存到变量中:

result=`dbl`

这个命令会将 dbl 函数的输出赋给$result 变量。下面是在脚本中使用这种方法的例子。

$ cat test5b
#!/bin/bash
## using the echo to return a value
function dbl {
    read -p "Enter a value: " value
    echo $[ $value * 2 ]
}
result=$(dbl)
echo "The new value is $result"
$
$ ./test5b
Enter a value: 200
The new value is 400
$
$ ./test5b
Enter a value: 1000
The new value is 2000
$

新函数会用 echo 语句来显示计算的结果。该脚本会获取 dbl 函数的输出,而不是查看退出状态码。

这个例子中演示了一个不易察觉的技巧。你会注意到 dbl 函数实际上输出了两条消息。read 命令输出了一条简短的消息来向用户询问输入值。bash shell 脚本非常聪明,并不将其作为 STDOUT 输出的一部分,并且忽略掉它。如果你用 echo 语句生成这条消息来向用户查询,那么它会与输出值一起被读进 shell 变量中。

通过这种技术,你还可以返回浮点值和字符串值。这使它成为一种获取函数返回值的强大方法。

在函数中使用变量

你可能已经注意到,在上面的 test5 例子中,我们在函数里用了一个叫作$result 的变量来保存处理后的值。在函数中使用变量时,你需要注意它们的定义方式以及处理方式。这是 shell 脚本中常见错误的根源。本节将会介绍一些处理 shell 脚本函数内外变量的方法。

向函数传递参数

我们在之前提到过,bash shell 会将函数当作小型脚本来对待。这意味着你可以像普通脚本那样向函数传递参数。函数可以使用标准的参数环境变量来表示命令行上传给函数的参数。例如,函数名会在$0变量中定义,函数命令行上的任何参数都会通过$1、$2等定义。也可以用特殊变量$#来判断传给函数的参数数目。在脚本中指定函数时,必须将参数和函数放在同一行,像这样:

func1 $value1 10

然后函数可以用参数环境变量来获得参数值。这里有个使用此方法向函数传值的例子。

$ cat test6
#!/bin/bash
## passing parameters to a function
function addem {
    if [ $## -eq 0 ] || [ $## -gt 2 ]
    then
        echo -1
    elif [ $## -eq 1 ]
    then
        echo $[ $1 + $1 ]
    else
        echo $[ $1 + $2 ]
    fi
}
echo -n "Adding 10 and 15: "
value=$(addem 10 15)
echo $value
echo -n "Let's try adding just one number: "
value=$(addem 10)
echo $value
echo -n "Now trying adding no numbers: "
value=$(addem)
echo $value
echo -n "Finally, try adding three numbers: "
value=$(addem 10 15 20)
echo $value
$
$ ./test6
Adding 10 and 15: 25
Let's try adding just one number: 20
Now trying adding no numbers: -1
Finally, try adding three numbers: -1
$

test6 脚本中的 addem 函数首先会检查脚本传给它的参数数目。如果没有任何参数,或者参数多于两个,addem 会返回值-1。如果只有一个参数,addem 会将参数与自身相加。如果有两个参数,addem 会将它们进行相加。

由于函数使用特殊参数环境变量作为自己的参数值,因此它无法直接获取脚本在命令行中的参数值。下面的例子将会运行失败。

$ cat badtest1
#!/bin/bash
## trying to access script parameters inside a function
function badfunc1 {
    echo $[ $1 * $2 ]
}
if [ $## -eq 2 ]
then
    value=$(badfunc1)
    echo "The result is $value"
else
    echo "Usage: badtest1 a b"
fi
$
$ ./badtest1
Usage: badtest1 a b
$ ./badtest1 10 15
./badtest1: *  : syntax error: operand expected (error token is "*  ")
The result is
$

尽管函数也使用了$1 和$2 变量,但它们和脚本主体中的$1 和$2 变量并不相同。要在函数中使用这些值,必须在调用函数时手动将它们传过去。

$ cat test7
#!/bin/bash
## trying to access script parameters inside a function
function func7 {
    echo $[ $1 * $2 ]
}
if [ $## -eq 2 ]
then
    value=$(func7 $1 $2)
    echo "The result is $value"
else
    echo "Usage: badtest1 a b"
fi
$
$ ./test7
Usage: badtest1 a b
$ ./test7 10 15
The result is 150
$

通过将$1 和$2 变量传给函数,它们就能跟其他变量一样供函数使用了。

在函数中处理变量

给 shell 脚本程序员带来麻烦的原因之一就是变量的作用域。作用域是变量可见的区域。函数中定义的变量与普通变量的作用域不同。也就是说,对脚本的其他部分而言,它们是隐藏的。函数使用两种类型的变量:

  • 全局变量
  • 局部变量

下面几节将会介绍这两种类型的变量在函数中的用法。

  1. 全局变量

全局变量是在 shell 脚本中任何地方都有效的变量。如果你在脚本的主体部分定义了一个全局变量,那么可以在函数内读取它的值。类似地,如果你在函数内定义了一个全局变量,可以在脚本的主体部分读取它的值。

默认情况下,你在脚本中定义的任何变量都是全局变量。在函数外定义的变量可在函数内正常访问。

$ cat test8
#!/bin/bash
## using a global variable to pass a value
function dbl {
    value=$[ $value * 2 ]
}
read -p "Enter a value: " value
dbl
echo "The new value is: $value"
$
$ ./test8
Enter a value: 450
The new value is: 900
$

$value 变量在函数外定义并被赋值。当 dbl 函数被调用时,该变量及其值在函数中都依然有效。如果变量在函数内被赋予了新值,那么在脚本中引用该变量时,新值也依然有效。

但这其实很危险,尤其是如果你想在不同的 shell 脚本中使用函数的话。它要求你清清楚楚地知道函数中具体使用了哪些变量,包括那些用来计算非返回值的变量。这里有个例子可说明事情是如何搞砸的。

$ cat badtest2
#!/bin/bash
## demonstrating a bad use of variables
function func1 {
    temp=$[ $value + 5 ]
    result=$[ $temp * 2 ]
}
temp=4
value=6
func1
echo "The result is $result"
if [ $temp -gt $value ]
then
    echo "temp is larger"
else
    echo "temp is smaller"
fi
$
$ ./badtest2
The result is 22
temp is larger
$

由于函数中用到了$temp 变量,它的值在脚本中使用时受到了影响,产生了意想不到的后果。有个简单的办法可以在函数中解决这个问题,下面将会介绍。

  1. 局部变量

无需在函数中使用全局变量,函数内部使用的任何变量都可以被声明成局部变量。要实现这一点,只要在变量声明的前面加上 local 关键字就可以了。

local temp

也可以在变量赋值语句中使用 local 关键字:

local temp=$[ $value + 5 ]

local 关键字保证了变量只局限在该函数中。如果脚本中在该函数之外有同样名字的变量,那么 shell 将会保持这两个变量的值是分离的。现在你就能很轻松地将函数变量和脚本变量隔离开了,只共享需要共享的变量。

$ cat test9
#!/bin/bash
## demonstrating the local keyword
function func1 {
    local temp=$[ $value + 5 ]
    result=$[ $temp * 2 ]
}
temp=4
value=6
func1
echo "The result is $result"
if [ $temp -gt $value ]
then
    echo "temp is larger"
else
    echo "temp is smaller"
fi
$
$ ./test9 The result is 22
temp is smaller
$

现在,在 func1 函数中使用$temp变量时,并不会影响在脚本主体中赋给$temp 变量的值。

数组变量和函数

在之前我们讨论了使用数组来在单个变量中保存多个值的高级用法。在函数中使用数组变量值有点麻烦,而且还需要一些特殊考虑。本节将会介绍一种方法来解决这个问题。

向函数传数组参数

向脚本函数传递数组变量的方法会有点不好理解。将数组变量当作单个参数传递的话,它不会起作用。

$ cat badtest3
#!/bin/bash
## trying to pass an array variable
function testit {
    echo "The parameters are: $@"
    thisarray=$1
    echo "The received array is ${thisarray[*]}"
}
myarray=(1 2 3 4 5)
echo "The original array is: ${myarray[*]}"
testit $myarray
$
$ ./badtest3
The original array is: 1 2 3 4 5
The parameters are: 1
The received array is 1
$

如果你试图将该数组变量作为函数参数,函数只会取数组变量的第一个值。

要解决这个问题,你必须将该数组变量的值分解成单个的值,然后将这些值作为函数参数使用。在函数内部,可以将所有的参数重新组合成一个新的变量。下面是个具体的例子。

$ cat test10
#!/bin/bash
## array variable to function test
function testit {
    local newarray
    newarray=`echo "$@"`
    echo "The new array value is: ${newarray[*]}"
}
myarray=(1 2 3 4 5)
echo "The original array is ${myarray[*]}"
testit ${myarray[*]}
$
$ ./test10
The original array is 1 2 3 4 5
The new array value is: 1 2 3 4 5
$

该脚本用$myarray 变量来保存所有的数组元素,然后将它们都放在函数的命令行上。该函数随后从命令行参数中重建数组变量。在函数内部,数组仍然可以像其他数组一样使用。

$ cat test11
#!/bin/bash
## adding values in an array
function addarray {
    local sum=0
    local newarray
    newarray=($(echo "$@"))
    for value in ${newarray[*]}
    do
        sum=$[ $sum + $value ]
    done
    echo $sum
}
myarray=(1 2 3 4 5)
echo "The original array is: ${myarray[*]}"
arg1=$(echo ${myarray[*]})
result=$(addarray $arg1)
echo "The result is $result"
$
$ ./test11
The original array is: 1 2 3 4 5
The result is 15
$

addarray 函数会遍历所有的数组元素,将它们累加在一起。你可以在 myarray 数组变量中放置任意多的值,addarry 函数会将它们都加起来。

从函数返回数组

从函数里向 shell 脚本传回数组变量也用类似的方法。函数用 echo 语句来按正确顺序输出单个数组值,然后脚本再将它们重新放进一个新的数组变量中。

$ cat test12
#!/bin/bash
## returning an array value
function arraydblr {
    local origarray
    local newarray
    local elements
    local i
    origarray=($(echo "$@"))
    newarray=($(echo "$@"))
    elements=$[ $## - 1 ]
    for (( i = 0; i <= $elements; i++ ))
    {
        newarray[$i]=$[ ${origarray[$i]} * 2 ]
    }
    echo ${newarray[*]}
}
myarray=(1 2 3 4 5)
echo "The original array is: ${myarray[*]}"
arg1=$(echo ${myarray[*]})
result=($(arraydblr $arg1))
echo "The new array is: ${result[*]}"
$
$ ./test12
The original array is: 1 2 3 4 5
The new array is: 2 4 6 8 10

该脚本用$arg1 变量将数组值传给 arraydblr 函数。arraydblr 函数将该数组重组到新的数组变量中,生成该输出数组变量的一个副本。然后对数据元素进行遍历,将每个元素值翻倍,并将结果存入函数中该数组变量的副本。

arraydblr 函数使用 echo 语句来输出每个数组元素的值。脚本用 arraydblr 函数的输出来重新生成一个新的数组变量。

函数递归

局部函数变量的一个特性是自成体系。除了从脚本命令行处获得的变量,自成体系的函数不需要使用任何外部资源。

这个特性使得函数可以递归地调用,也就是说,函数可以调用自己来得到结果。通常递归函数都有一个最终可以迭代到的基准值。许多高级数学算法用递归对复杂的方程进行逐级规约,直到基准值定义的那级。

递归算法的经典例子是计算阶乘。一个数的阶乘是该数之前的所有数乘以该数的值。因此,要计算 5 的阶乘,可以执行如下方程:

5! = 1 * 2 * 3 * 4 * 5 = 120

使用递归,方程可以简化成以下形式:

x! = x * (x-1)!

也就是说,x 的阶乘等于 x 乘以 x1 的阶乘。这可以用简单的递归脚本表达为:

function factorial {
    if [ $1 -eq 1 ]
    then
        echo 1
    else
        local temp=$[ $1 - 1 ]
        local result=`factorial $temp`
        echo $[ $result * $1 ]
    fi
}

阶乘函数用它自己来计算阶乘的值:

$ cat test13
#!/bin/bash
## using recursion
function factorial {
    if [ $1 -eq 1 ]
    then
        echo 1
    else
        local temp=$[ $1 - 1 ]
        local result=`factorial $temp`
        echo $[ $result * $1 ]
    fi
}
read -p "Enter value: " value
result=$(factorial $value)
echo "The factorial of $value is: $result"
$
$ ./test13
Enter value: 5
The factorial of 5 is: 120
$

使用阶乘函数很容易。创建了这样的函数后,你可能想把它用在其他脚本中。接下来,我们来看看如何有效地利用函数。

创建库

使用函数可以在脚本中省去一些输入工作,这一点是显而易见的。但如果你碰巧要在多个脚本中使用同一段代码呢?显然,为了使用一次而在每个脚本中都定义同样的函数太过麻烦。

有个方法能解决这个问题!bash shell 允许创建函数库文件,然后在多个脚本中引用该库文件。

这个过程的第一步是创建一个包含脚本中所需函数的公用库文件。这里有个叫作 myfuncs 的库文件,它定义了 3 个简单的函数。

$ cat myfuncs
## my script functions
function addem {
    echo $[ $1 + $2 ]
}
function multem {
    echo $[ $1 * $2 ]
}
function divem {
    if [ $2 -ne 0 ]
    then
        echo $[ $1 / $2 ]
    else
        echo -1
    fi
}
$

下一步是在用到这些函数的脚本文件中包含 myfuncs 库文件。从这里开始,事情就变复杂了。

问题出在 shell 函数的作用域上。和环境变量一样,shell 函数仅在定义它的 shell 会话内有效。如果你在 shell 命令行界面的提示符下运行 myfuncs shell 脚本,shell 会创建一个新的 shell 并在其中运行这个脚本。它会为那个新 shell 定义这三个函数,但当你运行另外一个要用到这些函数的脚本时,它们是无法使用的。

这同样适用于脚本。如果你尝试像普通脚本文件那样运行库文件,函数并不会出现在脚本中。

$ cat badtest4
#!/bin/bash
## using a library file the wrong way
./myfuncs
result=$(addem 10 15)
echo "The result is $result"
$
$ ./badtest4
./badtest4: addem: command not found
The result is
$

使用函数库的关键在于 source 命令。source 命令会在当前 shell 上下文中执行命令,而不是创建一个新 shell。可以用 source 命令来在 shell 脚本中运行库文件脚本。这样脚本就可以使用库中的函数了。

source 命令有个快捷的别名,称作点操作符(dot operator)。要在 shell 脚本中运行 myfuncs 库文件,只需添加下面这行:

. ./myfuncs

这个例子假定 myfuncs 库文件和 shell 脚本位于同一目录。如果不是,你需要使用相应路径访问该文件。这里有个用 myfuncs 库文件创建脚本的例子。

$ cat test14
#!/bin/bash
## using functions defined in a library file
. ./myfuncs
value1=10
value2=5
result1=$(addem $value1 $value2)
result2=$(multem $value1 $value2)
result3=$(divem $value1 $value2)
echo "The result of adding them is: $result1"
echo "The result of multiplying them is: $result2"
echo "The result of dividing them is: $result3"
$
$ ./test14
The result of adding them is: 15
The result of multiplying them is: 50
The result of dividing them is: 2
$

该脚本成功地使用了 myfuncs 库文件中定义的函数。

在命令行上使用函数

可以用脚本函数来执行一些十分复杂的操作。有时也很有必要在命令行界面的提示符下直接使用这些函数。和在 shell 脚本中将脚本函数当命令使用一样,在命令行界面中你也可以这样做。这个功能很不错,因为一旦在 shell 中定义了函数,你就可以在整个系统中使用它了,无需担心脚本是不是在 PATH 环境变量里。重点在于让 shell 能够识别这些函数。有几种方法可以实现。

在命令行上创建函数

因为 shell 会解释用户输入的命令,所以可以在命令行上直接定义一个函数。有两种方法。一种方法是采用单行方式定义函数。

$ function divem { echo $[ $1 / $2 ];  }
$ divem 100 5
20
$

当在命令行上定义函数时,你必须记得在每个命令后面加个分号,这样 shell 就能知道在哪里是命令的起止了。

$ function doubleit { read -p "Enter value: " value; echo $[
    $value * 2 ]; }
$
$ doubleit Enter value: 20
40
$

另一种方法是采用多行方式来定义函数。在定义时,bash shell 会使用次提示符来提示输入更多命令。用这种方法,你不用在每条命令的末尾放一个分号,只要按下回车键就行。

$ function multem {
> echo $[ $1 * $2 ]
> }
$ multem 2 5
10
$

在函数的尾部使用花括号,shell 就会知道你已经完成了函数的定义。

在命令行上创建函数时要特别小心。如果你给函数起了个跟内建命令或另一个命令相同的名字,函数将会覆盖原来的命令。

在.bashrc 文件中定义函数

在命令行上直接定义 shell 函数的明显缺点是退出 shell 时,函数就消失了。对于复杂的函数来说,这可是个麻烦事。一个非常简单的方法是将函数定义在一个特定的位置,这个位置在每次启动一个新 shell 的时候,都会由 shell 重新载入。最佳地点就是.bashrc 文件。bash shell 在每次启动时都会在主目录下查找这个文件,不管是交互式 shell 还是从现有 shell 中启动的新 shell。

  1. 直接定义函数

可以直接在主目录下的.bashrc 文件中定义函数。许多 Linux 发行版已经在.bashrc 文件中定义了一些东西,所以注意不要误删了。把你写的函数放在文件末尾就行了。这里有个例子。

$ cat .bashrc
## .bashrc
## Source global definitions
if [ -r /etc/bashrc ]; then
    . /etc/bashrc
fi
function addem {
    echo $[ $1 + $2 ]
}
$

该函数会在下次启动新 bash shell 时生效。随后你就能在系统上任意地方使用这个函数了。

  1. 读取函数文件

只要是在 shell 脚本中,都可以用 source 命令(或者它的别名点操作符)将库文件中的函数添加到你的.bashrc 脚本中。

$ cat .bashrc
## .bashrc
## Source global definitions
if [ -r /etc/bashrc ]; then
    . /etc/bashrc
fi
    . /home/rich/libraries/myfuncs
$

要确保库文件的路径名正确,以便 bash shell 能够找到该文件。下次启动 shell 时,库中的所有函数都可在命令行界面下使用了。

$ addem 10 5
15
$ multem 10 5
50
$ divem 10 5
2
$

更好的是,shell 还会将定义好的函数传给子 shell 进程,这样一来,这些函数就自动能够用于该 shell 会话中的任何 shell 脚本了。你可以写个脚本,试试在不定义或使用 source 的情况下,直接使用这些函数。

$ cat test15
#!/bin/bash
## using a function defined in the .bashrc file
value1=10
value2=5
result1=$(addem $value1 $value2)
result2=$(multem $value1 $value2)
result3=$(divem $value1 $value2)
echo "The result of adding them is: $result1"
echo "The result of multiplying them is: $result2"
echo "The result of dividing them is: $result3"
$
$ ./test15
The result of adding them is: 15
The result of multiplying them is: 50
The result of dividing them is: 2
$

甚至都不用对库文件使用 source,这些函数就可以完美地运行在 shell 脚本中。

实例

函数的应用绝不仅限于创建自己的函数自娱自乐。在开源世界中,共享代码才是关键,而这一点同样适用于脚本函数。你可以下载大量各式各样的函数,并将其用于自己的应用程序中。本节介绍了如何使用 GNU shtool shell 脚本函数库。shtool 库提供了一些简单的 shell 脚本函数,可以用来完成日常的 shell 功能,例如处理临时文件和目录或者格式化输出显示。在 arch linux 下,可通过如下命令安装:

yay -S shtool

shtool 库提供了大量方便的、可用于 shell 脚本的函数。下面列出了库中可用的函数。

  • arx 创建归档文件(包含一些扩展功能)
  • echo 显示字符串,并提供了一些扩展构件
  • fixperm 改变目录树中的文件权限
  • install 安装脚本或文件
  • mdate 显示文件或目录的修改时间
  • mkdir 创建一个或更多目录
  • mkln 使用相对路径创建链接
  • mkshadow 创建一棵阴影树
  • move 带有替换功能的文件移动
  • ath 处理程序路径
  • platform 显示平台标识
  • prop 显示一个带有动画效果的进度条
  • rotate 转置日志文件
  • scpp 共享的 C 预处理器
  • slo 根据库的类别,分离链接器选项
  • subst 使用 sed 的替换操作
  • table 以表格的形式显示由字段分隔(field-separated)的数据
  • tarball 从文件和目录中创建 tar 文件
  • version 创建版本信息文件

每个 shtool 函数都包含大量的选项和参数,你可以利用它们改变函数的工作方式。更多内容可使用 man 查看。可以在命令行或自己的 shell 脚本中直接使用 shtool 函数。下面是一个在 shell 脚本中使用 platform 函数的例子。

$ cat test16
#!/bin/bash
shtool platform
$ ./test16
Arch rolling (AMD64)
$
platform 函数会返回 Linux 发行版以及系统所使用的 CPU 硬件的相关信息。另一个函数是 prop 函数。它可以使用\、 、/和-字符创建一个旋转的进度条。这是一个非常漂亮的工具,可以告诉 shell 脚本用户目前正在进行一些后台处理工作。要使用 prop 函数,只需要将希望监看的输出管接到 shtool 脚本就行了。
$ ls –al /usr/bin | shtool prop –p "waiting..."
wating......\
$

prop 函数会在处理过程中不停地变换进度条字符。在本例中,输出信息来自于 ls 命令。你能看到多少进度条取决于 CPU 能以多快的速度列出/usr/bin 中的文件!-p 选项允许你定制输出文本,这段文本会出现在进度条字符之前。好了,尽情享受吧!

图形化脚本编程

多年来,shell 脚本一直都被认为是枯燥乏味的。但如果你准备在图形化环境中运行脚本时,就未必如此了。有很多与脚本用户交互的方式并不依赖 read 和 echo 语句。本章将会深入介绍一些可以让交互式脚本更友好的方法,这样它们看起来就不那么古板了。

这部分优先级太低 之后想写了再写吧

创建文本菜单

创建交互式 shell 脚本最常用的方法是使用菜单。提供各种选项可以帮助脚本用户了解脚本能做什么和不能做什么。通常菜单脚本会清空显示区域,然后显示可用的选项列表。用户可以按下与每个选项关联的字母或数字来选择选项。

shell 脚本菜单的核心是 case 命令。case 命令会根据用户在菜单上的选择来执行特定命令。后面几节将会带你逐步了解创建基于菜单的 shell 脚本的步骤。

创建菜单布局

初识 sed 和 gawk

到目前为止, shell 脚本最常见的一个用途就是处理文本文件。检查日志文件、读取配置文件、处理数据元素,shell 脚本可以帮助我们将文本文件中各种数据的日常处理任务自动化。但仅靠 shell 脚本命令来处理文本文件的内容有点勉为其难。如果想在 shell 脚本中处理任何类型的数据,你得熟悉 Linux 中的 sed 和 gawk 工具。这两个工具能够极大简化需要进行的数据处理任务。

大多数情况,我们使用 vim 这类编辑器处理文本,但有时候,你会发现需要自动处理文本文件,可你又不想动用全副武装的交互式文本编辑器。在这种情况下,有个能够轻松实现自动格式化、插入、修改或删除文本元素的简单命令行编辑器就方便多了。

Linux 系统提供了两个常见的具备上述功能的工具。本节将会介绍 Linux 世界中最广泛使用的两个命令行编辑器:sed 和 gawk。

sed 编辑器

sed 编辑器被称作流编辑器(stream editor),和普通的交互式文本编辑器恰好相反。在交互式文本编辑器中(比如 vim),你可以用键盘命令来交互式地插入、删除或替换数据中的文本。流编辑器则会在编辑器处理数据之前基于预先提供的一组规则来编辑数据流。

sed 编辑器可以根据命令来处理数据流中的数据,这些命令要么从命令行中输入,要么存储在一个命令文本文件中。sed 编辑器会执行下列操作。

  • 一次从输入中读取一行数据。
  • 根据所提供的编辑器命令匹配数据。
  • 按照命令修改流中的数据。
  • 将新的数据输出到 STDOUT。

在流编辑器将所有命令与一行数据匹配完毕后,它会读取下一行数据并重复这个过程。在流编辑器处理完流中的所有数据行后,它就会终止。

由于命令是按顺序逐行给出的,sed 编辑器只需对数据流进行一遍处理就可以完成编辑操作。这使得 sed 编辑器要比交互式编辑器快得多,你可以快速完成对数据的自动修改。

sed 命令的格式如下。

sed options script file

选项允许你修改 sed 命令的行为,可以使用的选项已在下面列出

  • -e script 在处理输入时,将 script 中指定的命令添加到已有的命令中
  • -f file 在处理输入时,将 file 中指定的命令添加到已有的命令中
  • -n 不产生命令输出,使用 print 命令来完成输出

script 参数指定了应用于流数据上的单个命令。如果需要用多个命令,要么使用-e 选项在命令行中指定,要么使用-f 选项在单独的文件中指定。有大量的命令可用来处理数据。我们将会在本章后面介绍一些 sed 编辑器的基本命令,然后在后续章节中会看到另外一些高级命令。

在命令行定义编辑器命令

默认情况下,sed 编辑器会将指定的命令应用到 STDIN 输入流上。这样你可以直接将数据通过管道输入 sed 编辑器处理。这里有个简单的示例。

$ echo "This is a test" | sed 's/test/big test/'
This is a big test
$

这个例子在 sed 编辑器中使用了 s 命令。s 命令会用斜线间指定的第二个文本字符串来替换第一个文本字符串模式。在本例中是 big test 替换了 test。 在运行这个例子时,结果应该立即就会显示出来。这就是使用 sed 编辑器的强大之处。你可以同时对数据做出多处修改,而所消耗的时间却只够一些交互式编辑器启动而已。 当然,这个简单的测试只是修改了一行数据。不过就算编辑整个文件,处理速度也相差无几。

$ cat data1.txt
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
$
$ sed 's/dog/cat/' data1.txt
The quick brown fox jumps over the lazy cat.
The quick brown fox jumps over the lazy cat.
The quick brown fox jumps over the lazy cat.
The quick brown fox jumps over the lazy cat.
$

sed 命令几乎瞬间就执行完并返回数据。在处理每行数据的同时,结果也显示出来了。可以在 sed 编辑器处理完整个文件之前就开始观察结果。 重要的是,要记住,sed 编辑器并不会修改文本文件的数据。它只会将修改后的数据发送到 STDOUT。如果你查看原来的文本文件,它仍然保留着原始数据。

在命令行使用多个编辑器命令

要在 sed 命令行上执行多个命令时,只要用-e 选项就可以了。

$ sed -e 's/brown/green/; s/dog/cat/' data1.txt
The quick green fox jumps over the lazy cat.
The quick green fox jumps over the lazy cat.
The quick green fox jumps over the lazy cat.
The quick green fox jumps over the lazy cat.

两个命令都作用到文件中的每行数据上。命令之间必须用分号隔开,并且在命令末尾和分号之间不能有空格。

如果不想用分号,也可以用 bash shell 中的次提示符来分隔命令。只要输入第一个单引号标示出 sed 程序脚本的起始(sed 编辑器命令列表),bash 会继续提示你输入更多命令,直到输入了标示结束的单引号。

$ sed -e '
> s/brown/green/
> s/fox/elephant/
> s/dog/cat/' data1.txt
The quick green elephant jumps over the lazy cat.
The quick green elephant jumps over the lazy cat.
The quick green elephant jumps over the lazy cat.
The quick green elephant jumps over the lazy cat.
$

必须记住,要在封尾单引号所在行结束命令。bash shell 一旦发现了封尾的单引号,就会执行命令。开始后,sed 命令就会将你指定的每条命令应用到文本文件中的每一行上。

从文件中读取编辑器命令

最后,如果有大量要处理的 sed 命令,那么将它们放进一个单独的文件中通常会更方便一些。可以在 sed 命令中用-f 选项来指定文件。

$ cat script1.sed
s/brown/green/
s/fox/elephant/
s/dog/cat/
$
$ sed -f script1.sed data1.txt
The quick green elephant jumps over the lazy cat.
The quick green elephant jumps over the lazy cat.
The quick green elephant jumps over the lazy cat.
The quick green elephant jumps over the lazy cat.
$

在这种情况下,不用在每条命令后面放一个分号。sed 编辑器知道每行都是一条单独的命令。跟在命令行输入命令一样,sed 编辑器会从指定文件中读取命令,并将它们应用到数据文件中的每一行上。

我们很容易就会把 sed 编辑器脚本文件与 bash shell 脚本文件搞混。为了避免这种情况,可以使用.sed 作为 sed 脚本文件的扩展名。

更多的替换选项

你已经懂得了如何用 s 命令(substitute)来在行中替换文本。这个命令还有另外一些选项能让事情变得更为简单。关于替换命令如何替换字符串中所匹配的模式需要注意一点。看看下面这个例子中会出现什么情况。

$ cat data4.txt
This is a test of the test script.
This is the second test of the test script.
$
$ sed 's/test/trial/' data4.txt
This is a trial of the test script.
This is the second trial of the test script.
$

替换命令在替换多行中的文本时能正常工作,但默认情况下它只替换每行中出现的第一处。要让替换命令能够替换一行中不同地方出现的文本必须使用替换标记(substitution flag)。替换标记会在替换命令字符串之后设置。

s/pattern/replacement/flags

有 4 种可用的替换标记:

  • 数字,表明新文本将替换第几处模式匹配的地方;
  • g,表明新文本将会替换所有匹配的文本;
  • p,表明原先行的内容要打印出来;
  • w file,将替换的结果写到文件中。

在第一类替换中,可以指定 sed 编辑器用新文本替换第几处模式匹配的地方。

$ sed 's/test/trial/2' data4.txt
This is a test of the trial script.
This is the second test of the trial script.
$

将替换标记指定为 2 的结果就是:sed 编辑器只替换每行中第二次出现的匹配模式。而 g 替换标记使你能替换文本中匹配模式所匹配的每处地方。p 替换标记会打印与替换命令中指定的模式匹配的行。这通常会和 sed 的-n 选项一起使用。

$ cat data5.txt
This is a test line.
This is a different line.
$
$ sed -n 's/test/trial/p' data5.txt
This is a trial line.
$

-n 选项将禁止 sed 编辑器输出。但 p 替换标记会输出修改过的行。将二者配合使用的效果就是只输出被替换命令修改过的行。

w 替换标记会产生同样的输出,不过会将输出保存到指定文件中。

$ sed 's/test/trial/w test.txt' data5.txt
This is a trial line.
This is a different line.
$
$ cat test.txt
This is a trial line.
$

sed 编辑器的正常输出是在 STDOUT 中,而只有那些包含匹配模式的行才会保存在指定的输出文件中。


有时你会在文本字符串中遇到一些不太方便在替换模式中使用的字符。Linux 中一个常见的例子就是正斜线(/)。替换文件中的路径名会比较麻烦。比如,如果想用 C shell 替换/etc/passwd 文件中的 bash shell,必须这么做:

$ sed 's/\/bin\/bash/\/bin\/csh/' /etc/passwd

由于正斜线通常用作字符串分隔符,因而如果它出现在了模式文本中的话,必须用反斜线来转义。这通常会带来一些困惑和错误。要解决这个问题,sed 编辑器允许选择其他字符来作为替换命令中的字符串分隔符:

$ sed 's!/bin/bash!/bin/csh!' /etc/passwd

在这个例子中,感叹号被用作字符串分隔符,这样路径名就更容易阅读和理解了

使用地址

默认情况下,在 sed 编辑器中使用的命令会作用于文本数据的所有行。如果只想将命令作用于特定行或某些行,则必须用行寻址(line addressing)。在 sed 编辑器中有两种形式的行寻址:

  • 以数字形式表示行区间
  • 用文本模式来过滤出行

两种形式都使用相同的格式来指定地址:

[address]command

也可以将特定地址的多个命令分组:

address {
    command1
    command2
    command3
}

sed 编辑器会将指定的每条命令作用到匹配指定地址的行上。下面将会演示如何在 sed 编辑器脚本中使用两种寻址方法。

当使用数字方式的行寻址时,可以用行在文本流中的行位置来引用。sed 编辑器会将文本流中的第一行编号为 1,然后继续按顺序为接下来的行分配行号。在命令中指定的地址可以是单个行号,或是用起始行号、逗号以及结尾行号指定的一定区间范围内的行。这里有个 sed 命令作用到指定行号的例子。

$ sed '2s/dog/cat/' data1.txt
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy cat
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
$

sed 编辑器只修改地址指定的第二行的文本。这里有另一个例子,这次使用了行地址区间。

$ sed '2,3s/dog/cat/' data1.txt
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy cat
The quick brown fox jumps over the lazy cat
The quick brown fox jumps over the lazy dog
$

如果想将命令作用到文本中从某行开始的所有行,可以用特殊地址——美元符。可能你并不知道文本中到底有多少行数据,因此美元符用起来通常很方便。

$ sed '2,$s/dog/cat/' data1.txt
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy cat
The quick brown fox jumps over the lazy cat
The quick brown fox jumps over the lazy cat
$

另一种限制命令作用到哪些行上的方法会稍稍复杂一些。sed 编辑器允许指定文本模式来过滤出命令要作用的行。格式如下:

/pattern/command

必须用正斜线将要指定的 pattern 封起来。sed 编辑器会将该命令作用到包含指定文本模式的行上。举个例子,如果你想只修改用户 Samantha 的默认 shell,可以使用 sed 命令。

$ grep Samantha /etc/passwd
Samantha:x:502:502::/home/Samantha:/bin/bash
$
$ sed '/Samantha/s/bash/csh/' /etc/passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
[...]
Christine:x:501:501:Christine B:/home/Christine:/bin/bash
Samantha:x:502:502::/home/Samantha:/bin/csh
Timothy:x:503:503::/home/Timothy:/bin/bash
$

该命令只作用到匹配文本模式的行上。虽然使用固定文本模式能帮你过滤出特定的值,就跟上面这个用户名的例子一样,但其作用难免有限。sed 编辑器在文本模式中采用了一种称为正则表达式(regular expression)的特性来帮助你创建匹配效果更好的模式。
正则表达式允许创建高级文本模式匹配表达式来匹配各种数据。这些表达式结合了一系列通配符、特殊字符以及固定文本字符来生成能够匹配几乎任何形式文本的简练模式。正则表达式是 shell 脚本编程中令人心生退意的部分之一,下一章将会详细介绍相关内容。


如果需要在单行上执行多条命令,可以用花括号将多条命令组合在一起。sed 编辑器会处理地址行处列出的每条命令。

$ sed '2{
> s/fox/elephant/
> s/dog/cat/
> }' data1.txt
The quick brown fox jumps over the lazy dog.
The quick brown elephant jumps over the lazy cat.
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
$

两条命令都会作用到该地址上。当然,也可以在一组命令前指定一个地址区间。sed 编辑器会将所有命令作用到该地址区间内的所有行上。

$ sed '3,${
> s/brown/green/
> s/lazy/active/
> }' data1.txt
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
The quick green fox jumps over the active dog.
The quick green fox jumps over the active dog.
$

删除行

文本替换命令不是 sed 编辑器唯一的命令。如果需要删除文本流中的特定行,可以用删除命令。删除命令 d 名副其实,它会删除匹配指定寻址模式的所有行。使用该命令时要特别小心,如果你忘记加入寻址模式的话,流中的所有文本行都会被删除。

$ cat data1.txt
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
$
$ sed 'd' data1.txt
$

当和指定地址一起使用时,删除命令显然能发挥出最大的功用。可以从数据流中删除特定的文本行,通过行号指定:

$ cat data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
$
$ sed '3d' data6.txt
This is line number 1.
This is line number 2.
This is line number 4.
$

或者通过特定行区间指定:

$ sed '2,3d' data6.txt
This is line number 1.
This is line number 4.
$

或者通过特殊的文件结尾字符:

$ sed '3,$d' data6.txt
This is line number 1.
This is line number 2.
$

sed 编辑器的模式匹配特性也适用于删除命令。sed 编辑器会删掉包含匹配指定模式的行。

$ sed '/number 1/d' data6.txt
This is line number 2.
This is line number 3.
This is line number 4.
$

记住,sed 编辑器不会修改原始文件。你删除的行只是从 sed 编辑器的输出中消失了。原始文件仍然包含那些“删掉的”行。

也可以使用两个文本模式来删除某个区间内的行,但这么做时要小心。你指定的第一个模式会“打开”行删除功能,第二个模式会“关闭”行删除功能。sed 编辑器会删除两个指定行之间的所有行(包括指定的行)。

$ sed '/1/,/3/d' data6.txt
This is line number 4.
$

除此之外,你要特别小心,因为只要 sed 编辑器在数据流中匹配到了开始模式,删除功能就会打开。这可能会导致意外的结果。

$ cat data7.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
This is line number 1 again.
This is text you want to keep.
This is the last line in the file.
$
$ sed '/1/,/3/d' data7.txt
This is line number 4.
$

第二个出现数字“1”的行再次触发了删除命令,因为没有找到停止模式,所以就将数据流中的剩余行全部删除了。当然,如果你指定了一个从未在文本中出现的停止模式,显然会出现另外一个问题。

$ sed '/1/,/5/d' data7.txt
$

因为删除功能在匹配到第一个模式的时候打开了,但一直没匹配到结束模式,所以整个数据流都被删掉了。

插入和附加文本

如你所期望的,跟其他编辑器类似,sed 编辑器允许向数据流插入和附加文本行。两个操作的区别可能比较让人费解:

  • 插入(insert)命令(i)会在指定行前增加一个新行;
  • 附加(append)命令(a)会在指定行后增加一个新行。

这两条命令的费解之处在于它们的格式。它们不能在单个命令行上使用。你必须指定是要将行插入还是附加到另一行。格式如下:

sed '[address]command\
new line'

new line 中的文本将会出现在 sed 编辑器输出中你指定的位置。记住,当使用插入命令时,文本会出现在数据流文本的前面。

$ echo "Test Line 2" | sed 'i\Test Line 1'
Test Line 1
Test Line 2
$

当使用附加命令时,文本会出现在数据流文本的后面。

$ echo "Test Line 2" | sed 'a\Test Line 1'
Test Line 2
Test Line 1
$

在命令行界面提示符上使用 sed 编辑器时,你会看到次提示符来提醒输入新的行数据。你必须在该行完成 sed 编辑器命令。一旦你输入了结尾的单引号,bash shell 就会执行该命令。

$ echo "Test Line 2" | sed 'i\
> Test Line 1'
Test Line 1
Test Line 2
$

这样能够给数据流中的文本前面或后面添加文本,但如果要向数据流内部添加文本呢?要向数据流行内部插入或附加数据,你必须用寻址来告诉 sed 编辑器你想让数据出现在什么位置。可以在用这些命令时只指定一个行地址。可以匹配一个数字行号或文本模式,但不能用地址区间。这合乎逻辑,因为你只能将文本插入或附加到单个行的前面或后面,而不是行区间的前面或后面。下面的例子是将一个新行插入到数据流第三行前。

$ sed '3i\
> This is an inserted line.' data6.txt
This is line number 1.
This is line number 2.
This is an inserted line.
This is line number 3.
This is line number 4.
$

下面的例子是将一个新行附加到数据流中第三行后。

$ sed '3a\
> This is an inserted line.' data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is an inserted line.
This is line number 4.
$

它使用与插入命令相同的过程,只是将新文本行放到了指定的行号后面。如果你有一个多行数据流,想要将新行附加到数据流的末尾,只要用代表数据最后一行的美元符就可以了。

$ sed '$a\
> This is an inserted line.' data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
This is an inserted line.
$

同样的方法也适用于要在数据流起始位置增加一个新行。只要在第一行之前插入新行即可。
要插入或附加多行文本,就必须对要插入或附加的新文本中的每一行使用反斜线,直到最后一行。

$ sed '1i\
> This is one line of new text.\
> This is another line of new text.' data6.txt
This is one line of new text.
This is another line of new text.
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
$

指定的两行都会被添加到数据流中。

修改行

修改(change)命令允许修改数据流中整行文本的内容。它跟插入和附加命令的工作机制一样,你必须在 sed 命令中单独指定新行。

$ sed '3c\
> This is a changed line of text.' data6.txt
This is line number 1.
This is line number 2.
This is a changed line of text.
This is line number 4.
$

在这个例子中,sed 编辑器会修改第三行中的文本。也可以用文本模式来寻址。

$ sed '/number 3/c\
> This is a changed line of text.' data6.txt
This is line number 1.
This is line number 2.
This is a changed line of text.
This is line number 4.
$

文本模式修改命令会修改它匹配的数据流中的任意文本行。

$ cat data8.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
This is line number 1 again.
This is yet another line.
This is the last line in the file.
$
$ sed '/number 1/c\
> This is a changed line of text.' data8.txt
This is a changed line of text.
This is line number 2.
This is line number 3.
This is line number 4.
This is a changed line of text.
This is yet another line.
This is the last line in the file.
$

你可以在修改命令中使用地址区间,但结果未必如愿。

$ sed '2,3c\
> This is a new line of text.' data6.txt
This is line number 1.
This is a new line of text.
This is line number 4.
$

sed 编辑器会用这一行文本来替换数据流中的两行文本,而不是逐一修改这两行文本。

转换命令

转换(transform)命令(y)是唯一可以处理单个字符的 sed 编辑器命令。转换命令格式如下。

[address]y/inchars/outchars/

转换命令会对 inchars 和 outchars 值进行一对一的映射。inchars 中的第一个字符会被转换为 outchars 中的第一个字符,第二个字符会被转换成 outchars 中的第二个字符。这个映射过程会一直持续到处理完指定字符。如果 inchars 和 outchars 的长度不同,则 sed 编辑器会产生一条错误消息。这里有个使用转换命令的简单例子。

$ sed 'y/123/789/' data8.txt
This is line number 7.
This is line number 8.
This is line number 9.
This is line number 4.
This is line number 7 again.
This is yet another line.
This is the last line in the file.
$

如你在输出中看到的,inchars 模式中指定字符的每个实例都会被替换成 outchars 模式中相同位置的那个字符。转换命令是一个全局命令,也就是说,它会文本行中找到的所有指定字符自动进行转换,而不会考虑它们出现的位置。

$ echo "This 1 is a test of 1 try." | sed 'y/123/456/'
This 4 is a test of 4 try.
$

sed 编辑器转换了在文本行中匹配到的字符 1 的两个实例。你无法限定只转换在特定地方出现的字符。

回顾打印

之前介绍了如何使用 p 标记和替换命令显示 sed 编辑器修改过的行。另外有 3 个命令也能用来打印数据流中的信息:

  • p 命令用来打印文本行;
  • 等号(=)命令用来打印行号;
  • l(小写的 L)命令用来列出行。

跟替换命令中的 p 标记类似,p 命令可以打印 sed 编辑器输出中的一行。如果只用这个命令,也没什么特别的。

$ echo "this is a test" | sed 'p'
this is a test
this is a test
$

它所做的就是打印已有的数据文本。打印命令最常见的用法是打印包含匹配文本模式的行。

$ cat data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
$
$ sed -n '/number 3/p' data6.txt
This is line number 3.
$

在命令行上用-n 选项,你可以禁止输出其他行,只打印包含匹配文本模式的行。也可以用它来快速打印数据流中的某些行。

$ sed -n '2,3p' data6.txt
This is line number 2.
This is line number 3.
$

如果需要在修改之前查看行,也可以使用打印命令,比如与替换或修改命令一起使用。可以创建一个脚本在修改行之前显示该行。

$ sed -n '/3/{
> p
> s/line/test/p
> }' data6.txt
This is line number 3.
This is test number 3.
$

sed 编辑器命令会查找包含数字 3 的行,然后执行两条命令。首先,脚本用 p 命令来打印出原始行;然后它用 s 命令替换文本,并用 p 标记打印出替换结果。输出同时显示了原来的行文本和新的行文本。


等号命令会打印行在数据流中的当前行号。行号由数据流中的换行符决定。每次数据流中出现一个换行符,sed 编辑器会认为一行文本结束了。

$ cat data1.txt
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
$
$ sed '=' data1.txt
1
The quick brown fox jumps over the lazy dog.
2
The quick brown fox jumps over the lazy dog.
3
The quick brown fox jumps over the lazy dog.
4
The quick brown fox jumps over the lazy dog.
$

sed 编辑器在实际的文本行出现前打印了行号。如果你要在数据流中查找特定文本模式的话,等号命令用起来非常方便。

$ sed -n '/number 4/{
> =
> p
> }' data6.txt
4
This is line number 4.
$

利用-n 选项,你就能让 sed 编辑器只显示包含匹配文本模式的行的行号和文本。


列出(list)命令(l)可以打印数据流中的文本和不可打印的 ASCII 字符。任何不可打印字符要么在其八进制值前加一个反斜线,要么使用标准 C 风格的命名法(用于常见的不可打印字符),比如\t,来代表制表符。

$ cat data9.txt
This    line    contains        tabs.
$
$ sed -n 'l' data9.txt
This\tline\tcontains\ttabs.$
$

制表符的位置使用\t 来显示。行尾的美元符表示换行符。如果数据流包含了转义字符,列出命令会在必要时候用八进制码来显示。

$ cat data10.txt
This line contains an escape character.
$
$ sed -n 'l' data10.txt
This line contains an escape character. \a$
$

data10.txt 文本文件包含了一个转义控制码来产生铃声。当用 cat 命令来显示文本文件时,你看不到转义控制码,只能听到声音(如果你的音箱打开的话)。但是,利用列出命令,你就能显示出所使用的转义控制码。

使用 sed 处理文件

替换命令包含一些可以用于文件的标记。还有一些 sed 编辑器命令也可以实现同样的目标,不需要非得替换文本。

w 命令用来向文件写入行。该命令的格式如下:

[address]w filename0

filename 可以使用相对路径或绝对路径,但不管是哪种,运行 sed 编辑器的用户都必须有文件的写权限。地址可以是 sed 中支持的任意类型的寻址方式,例如单个行号、文本模式、行区间或文本模式。

下面的例子是将数据流中的前两行打印到一个文本文件中。

$ sed '1,2w test.txt' data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
$
$ cat test.txt
This is line number 1.
This is line number 2.
$

当然,如果你不想让行显示到 STDOUT 上,你可以用 sed 命令的-n 选项。
如果要根据一些公用的文本值从主文件中创建一份数据文件,比如下面的邮件列表中的,那么 w 命令会非常好用。

$ cat data11.txt
Blum, R       Browncoat
McGuiness, A  Alliance
Bresnahan, C  Browncoat
Harken, C     Alliance
$
$ sed -n '/Browncoat/w Browncoats.txt' data11.txt
$
$ cat Browncoats.txt
Blum, R       Browncoat
Bresnahan, C  Browncoat
$

sed 编辑器会只将包含文本模式的数据行写入目标文件。


你已经了解了如何在 sed 命令行上向数据流中插入或附加文本。读取(read)命令(r)允许你将一个独立文件中的数据插入到数据流中。读取命令的格式如下:

[address]r filename

filename 参数指定了数据文件的绝对路径或相对路径。你在读取命令中使用地址区间,只能指定单独一个行号或文本模式地址。sed 编辑器会将文件中的文本插入到指定地址后。

$ cat data12.txt
This is an added line.
This is the second added line.
$
$ sed '3r data12.txt' data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is an added line.
This is the second added line.
This is line number 4.
$

sed 编辑器会将数据文件中的所有文本行都插入到数据流中。同样的方法在使用文本模式地址时也适用。

$ sed '/number 2/r data12.txt' data6.txt
This is line number 1.
This is line number 2.
This is an added line.
This is the second added line.
This is line number 3.
This is line number 4.
$

如果你要在数据流的末尾添加文本,只需用美元符地址符就行了。

$ sed '$r data12.txt' data6.txt
This is line number 1.
This is line number 2.
This is line number 3.
This is line number 4.
This is an added line.
This is the second added line.
$

读取命令的另一个很酷的用法是和删除命令配合使用:利用另一个文件中的数据来替换文件中的占位文本。举例来说,假定你有一份套用信件保存在文本文件中:

$ cat notice.std
Would the following people:
LIST
please report to the ship's captain.
$

套用信件将通用占位文本 LIST 放在人物名单的位置。要在占位文本后插入名单,只需读取命令就行了。但这样的话,占位文本仍然会留在输出中。要删除占位文本的话,你可以用删除命令。结果如下:

$ sed '/LIST/{
> r data11.txt
> d
> }' notice.std
Would the following people:
Blum, R       Browncoat
McGuiness, A  Alliance
Bresnahan, C  Browncoat
Harken, C     Alliance
please report to the ship's captain.
$

现在占位文本已经被替换成了数据文件中的名单。

gawk 程序

虽然 sed 编辑器是非常方便自动修改文本文件的工具,但其也有自身的限制。通常你需要一个用来处理文件中的数据的更高级工具,它能提供一个类编程环境来修改和重新组织文件中的数据。这正是 gawk 能够做到的。

很多发行版中都没有默认安装 gawk 程序。如果你所用的 Linux 发行版中没有包含 gawk,请自行安装。arch linux 可直接安装包组 base-devel 来使用 gawk

gawk 程序是 Unix 中的原始 awk 程序的 GNU 版本。gawk 程序让流编辑迈上了一个新的台阶,它提供了一种编程语言而不只是编辑器命令。在 gawk 编程语言中,你可以做下面的事情:

  • 定义变量来保存数据;
  • 使用算术和字符串操作符来处理数据;
  • 使用结构化编程概念(比如 if-then 语句和循环)来为数据处理增加处理逻辑;
  • 通过提取数据文件中的数据元素,将其重新排列或格式化,生成格式化报告。

gawk 程序的报告生成能力通常用来从大文本文件中提取数据元素,并将它们格式化成可读的报告。其中最完美的例子是格式化日志文件。在日志文件中找出错误行会很难,gawk 程序可以让你从日志文件中过滤出需要的数据元素,然后你可以将其格式化,使得重要的数据更易于阅读。gawk 的强大之处在于程序脚本。可以写脚本来读取文本行的数据,然后处理并显示数据,创建任何类型的输出报告。

基础用法

gawk 程序的基本格式如下:

gawk options program file

如下显示了 gawk 程序的可用选项。命令行选项提供了一个简单的途径来定制 gawk 程序中的功能。我们会在探索 gawk 时进一步了解这些选项。

  • -F fs 指定行中划分数据字段的字段分隔符
  • -f file 从指定的文件中读取程序
  • -v var=value 定义 gawk 程序中的一个变量及其默认值
  • -mf N 指定要处理的数据文件中的最大字段数
  • -mr N 指定数据文件中的最大数据行数
  • -W keyword 指定 gawk 的兼容模式或警告等级

gawk 程序脚本用一对花括号来定义。你必须将脚本命令放到两个花括号({})中。如果你错误地使用了圆括号来包含 gawk 脚本,就会得到一条类似于下面的错误提示。

$ gawk '(print "Hello World!"}'
gawk: (print "Hello World!"}
gawk:  ^ syntax error

由于 gawk 命令行假定脚本是单个文本字符串,你还必须将脚本放到单引号中。下面的例子在命令行上指定了一个简单的 gawk 程序脚本:

$ gawk '{print "Hello World!"}'

这个程序脚本定义了一个命令:print 命令。这个命令名副其实:它会将文本打印到 STDOUT。如果尝试运行这个命令,你可能会有些失望,因为什么都不会发生。原因在于没有在命令行上指定文件名,所以 gawk 程序会从 STDIN 接收数据。在运行这个程序时,它会一直等待从 STDIN 输入的文本。
如果你输入一行文本并按下回车键,gawk 会对这行文本运行一遍程序脚本。跟 sed 编辑器一样,gawk 程序会针对数据流中的每行文本执行程序脚本。由于程序脚本被设为显示一行固定的文本字符串,因此不管你在数据流中输入什么文本,都会得到同样的文本输出。
要终止这个 gawk 程序,你必须表明数据流已经结束了。bash shell 提供了一个组合键来生成 EOF(End-of-File)字符。Ctrl+D 组合键会在 bash 中产生一个 EOF 字符。这个组合键能够终止该 gawk 程序并返回到命令行界面提示符下。

使用数据字段变量

gawk 的主要特性之一是其处理文本文件中数据的能力。它会自动给一行中的每个数据元素分配一个变量。默认情况下,gawk 会将如下变量分配给它在文本行中发现的数据字段:

  • $0 代表整个文本行;
  • $1 代表文本行中的第 1 个数据字段;
  • $2 代表文本行中的第 2 个数据字段;
  • $n 代表文本行中的第 n 个数据字段。

在文本行中,每个数据字段都是通过字段分隔符划分的。gawk 在读取一行文本时,会用预定义的字段分隔符划分每个数据字段。gawk 中默认的字段分隔符是任意的空白字符(例如空格或制表符)。

在下面的例子中,gawk 程序读取文本文件,只显示第 1 个数据字段的值。

$ cat data2.txt
One line of test text.
Two lines of test text.
Three lines of test text.
$
$ gawk '{print $1}' data2.txt
One
Two
Three
$

该程序用$1 字段变量来仅显示每行文本的第 1 个数据字段。

如果你要读取采用了其他字段分隔符的文件,可以用-F 选项指定。

$ gawk -F: '{print $1}' /etc/passwd
root
bin
daemon
adm
lp
sync
shutdown
halt
mail
[...]

这个简短的程序显示了系统中密码文件的第 1 个数据字段。由于/etc/passwd 文件用冒号来分隔数字字段,因而如果要划分开每个数据元素,则必须在 gawk 选项中将冒号指定为字段分隔符。

在程序脚本中使用多个命令

如果一种编程语言只能执行一条命令,那么它不会有太大用处。gawk 编程语言允许你将多条命令组合成一个正常的程序。要在命令行上的程序脚本中使用多条命令,只要在命令之间放个分号即可。

$ echo "My name is Rich" | gawk '{$4="Christine"; print $0}'
My name is Christine
$

第一条命令会给字段变量$4 赋值。第二条命令会打印整个数据字段。注意, gawk 程序在输出中已经将原文本中的第四个数据字段替换成了新值。
也可以用次提示符一次一行地输入程序脚本命令。

$ gawk '{
> $4="Christine"
> print $0}'
My name is Rich
My name is Christine
$

在你用了表示起始的单引号后,bash shell 会使用次提示符来提示你输入更多数据。你可以每次在每行加一条命令,直到输入了结尾的单引号。因为没有在命令行中指定文件名,gawk 程序会从 STDIN 中获得数据。当运行这个程序的时候,它会等着读取来自 STDIN 的文本。要退出程序,只需按下 Ctrl+D 组合键来表明数据结束。

从文件中读取程序

跟 sed 编辑器一样,gawk 编辑器允许将程序存储到文件中,然后再在命令行中引用。

$ cat script2.gawk
{print $1 "'s home directory is " $6}
$
$ gawk -F: -f script2.gawk /etc/passwd
root's home directory is /root
bin's home directory is /bin
daemon's home directory is /sbin
adm's home directory is /var/adm
lp's home directory is /var/spool/lpd
[...]
Christine's home directory is /home/Christine
Samantha's home directory is /home/Samantha
$

script2.gawk 程序脚本会再次使用 print 命令打印/etc/passwd 文件的主目录数据字段(字段变量$6),以及 userid 数据字段(字段变量$1)
可以在程序文件中指定多条命令。要这么做的话,只要一条命令放一行即可,不需要用分号。

$ cat script3.gawk
{
text = "'s home directory is "
print $1 text $6
}
$
$ gawk -F: -f script3.gawk /etc/passwd
root's home directory is /root
bin's home directory is /bin
daemon's home directory is /sbin
adm's home directory is /var/adm
lp's home directory is /var/spool/lpd
[...]
Christine's home directory is /home/Christine S
amantha's home directory is /home/Samantha
$

script3.gawk 程序脚本定义了一个变量来保存 print 命令中用到的文本字符串。注意,gawk 程序在引用变量值时并未像 shell 脚本一样使用美元符。

在处理数据前运行脚本

gawk 还允许指定程序脚本何时运行。默认情况下,gawk 会从输入中读取一行文本,然后针对该行的数据执行程序脚本。有时可能需要在处理数据前运行脚本,比如为报告创建标题。BEGIN 关键字就是用来做这个的。它会强制 gawk 在读取数据前执行 BEGIN 关键字后指定的程序脚本。

$ gawk 'BEGIN {print "Hello World!"}'
Hello World!
$

这次 print 命令会在读取数据前显示文本。但在它显示了文本后,它会快速退出,不等待任何数据。如果想使用正常的程序脚本中处理数据,必须用另一个脚本区域来定义程序。

$ cat data3.txt
Line 1
Line 2
Line 3
$
$ gawk 'BEGIN {print "The data3 File Contents:"}
> {print $0}' data3.txt
The data3 File Contents:
Line 1
Line 2
Line 3
$

在 gawk 执行了 BEGIN 脚本后,它会用第二段脚本来处理文件数据。这么做时要小心,两段脚本仍然被认为是 gawk 命令行中的一个文本字符串。你需要相应地加上单引号。

在处理数据后运行脚本

与 BEGIN 关键字类似,END 关键字允许你指定一个程序脚本,gawk 会在读完数据后执行它。

$ gawk 'BEGIN {print "The data3 File Contents:"}
> {print $0}
> END {print "End of File"}' data3.txt
The data3 File Contents:
Line 1
Line 2
Line 3
End of File
$

当 gawk 程序打印完文件内容后,它会执行 END 脚本中的命令。这是在处理完所有正常数据后给报告添加页脚的最佳方法。

可以将所有这些内容放到一起组成一个漂亮的小程序脚本文件,用它从一个简单的数据文件中创建一份完整的报告。

$ cat script4.gawk BEGIN {
print "The latest list of users and shells"
print " UserID \t Shell"
print "-------- \t -------"
FS=":"
}

{ print $1 "     \t "  $7
}

END {
print "This concludes the listing"
}
$

这个脚本用 BEGIN 脚本来为报告创建标题。它还定义了一个叫作 FS 的特殊变量。这是定义字段分隔符的另一种方法。这样你就不用依靠脚本用户在命令行选项中定义字段分隔符了。

下面是这个 gawk 程序脚本的输出(有部分删节)。


$ gawk -f script4.gawk /etc/passwd
The latest list of users and shells
UserID          Shell
--------         -------
root             /bin/bash
bin              /sbin/nologin
daemon           /sbin/nologin
[...]
Christine        /bin/bash
mysql            /bin/bash
This concludes the listing
$

与预想的一样,BEGIN 脚本创建了标题,程序脚本处理特定数据文件(/etc/passwd)中的信息,END 脚本生成页脚。

这个简单的脚本让你小试了一把 gawk 的强大威力。后续将继续介绍另外一些编写 gawk 脚本时的简单原则,以及一些可用于 gawk 程序脚本中的高级编程概念。学会了它们之后,就算是面对最晦涩的数据文件,你也能够创建出专业范儿的报告。


使用 sed 和 gawk 程序的关键在于了解如何使用正则表达式。正则表达式是为提取和处理文本文件中数据创建定制过滤器的关键。下一章将会深入经常被人们误解的正则表达式世界,并演示如何构建正则表达式来操作各种类型的数据。

正则表达式

在 shell 脚本中成功运用 sed 编辑器和 gawk 程序的关键在于熟练使用正则表达式。这可不是件简单的事,从大量数据中过滤出特定数据可能会(而且经常会)很复杂。本章将介绍如何在 sed 编辑器和 gawk 程序中创建正则表达式来过滤出需要的数据。

什么是正则表达式

理解正则表达式的第一步在于弄清它们到底是什么。本节将会解释什么是正则表达式并介绍 Linux 如何使用正则表达式。

定义

正则表达式是你所定义的模式模板(pattern template),Linux 工具可以用它来过滤文本。Linux 工具(比如 sed 编辑器或 gawk 程序)能够在处理数据时使用正则表达式对数据进行模式匹配。如果数据匹配模式,它就会被接受并进一步处理;如果数据不匹配模式,它就会被滤掉。

正则表达式模式利用通配符来描述数据流中的一个或多个字符。Linux 中有很多场景都可以使用通配符来描述不确定的数据。在本书之前你已经看到过在 Linux 的 ls 命令中使用通配符列出文件和目录的例子。

星号通配符允许你只列出满足特定条件的文件,例如:

$ ls -al da*
-rw-r--r--    1 rich     rich           45 Nov 26 12:42 data
-rw-r--r--    1 rich     rich           25 Dec  4 12:40 data.tst
-rw-r--r--    1 rich     rich          180 Nov 26 12:42 data1
-rw-r--r--    1 rich     rich           45 Nov 26 12:44 data2
-rw-r--r--    1 rich     rich           73 Nov 27 12:31 data3
-rw-r--r--    1 rich     rich           79 Nov 28 14:01 data4
-rw-r--r--    1 rich     rich          187 Dec  4 09:45 datatest
$

da*参数会让 ls 命令只列出名字以 da 开头的文件。文件名中 da 之后可以有任意多个字符(包括什么也没有)。ls 命令会读取目录中所有文件的信息,但只显示跟通配符匹配的文件的信息。

正则表达式通配符模式的工作原理与之类似。正则表达式模式含有文本或特殊字符,为 sed 编辑器和 gawk 程序定义了一个匹配数据时采用的模板。可以在正则表达式中使用不同的特殊字符来定义特定的数据过滤模式。

正则表达式的类型

使用正则表达式最大的问题在于有不止一种类型的正则表达式。Linux 中的不同应用程序可能会用不同类型的正则表达式。这其中包括编程语言(Java、Perl 和 Python)、Linux 实用工具(比如 sed 编辑器、gawk 程序和 grep 工具)以及主流应用(比如 MySQL 和 PostgreSQL 数据库服务器)。

正则表达式是通过正则表达式引擎(regular expression engine)实现的。正则表达式引擎是一套底层软件,负责解释正则表达式模式并使用这些模式进行文本匹配。在 Linux 中,有两种流行的正则表达式引擎:

  • POSIX 基础正则表达式(basic regular expression,BRE)引擎
  • POSIX 扩展正则表达式(extended regular expression,ERE)引擎

大多数 Linux 工具都至少符合 POSIX BRE 引擎规范,能够识别该规范定义的所有模式符号。遗憾的是,有些工具(比如 sed 编辑器)只符合了 BRE 引擎规范的子集。这是出于速度方面的考虑导致的,因为 sed 编辑器希望能尽可能快地处理数据流中的文本。

POSIX BRE 引擎通常出现在依赖正则表达式进行文本过滤的编程语言中。它为常见模式提供了高级模式符号和特殊符号,比如匹配数字、单词以及按字母排序的字符。gawk 程序用 ERE 引擎来处理它的正则表达式模式。

由于实现正则表达式的方法太多,很难用一个简洁的描述来涵盖所有可能的正则表达式。后续几节将会讨论最常见的正则表达式,并演示如何在 sed 编辑器和 gawk 程序中使用它们。

定义 BRE 模式

最基本的 BRE 模式是匹配数据流中的文本字符。本节将会演示如何在正则表达式中定义文本以及会得到什么样的结果。

纯文本

前面演示了如何在 sed 编辑器和 gawk 程序中用标准文本字符串来过滤数据。通过下面的例子来复习一下。

$ echo "This is a test" | sed -n '/test/p'
This is a test
$ echo "This is a test" | sed -n '/trial/p'
$
$ echo "This is a test" | gawk '/test/{print $0}'
This is a test
$ echo "This is a test" | gawk '/trial/{print $0}'
$

第一个模式定义了一个单词 test。sed 编辑器和 gawk 程序脚本用它们各自的 print 命令打印出匹配该正则表达式模式的所有行。由于 echo 语句在文本字符串中包含了单词 test,数据流文本能够匹配所定义的正则表达式模式,因此 sed 编辑器显示了该行。

第二个模式也定义了一个单词,这次是 trial。因为 echo 语句文本字符串没包含该单词,所以正则表达式模式没有匹配,因此 sed 编辑器和 gawk 程序都没打印该行。

你可能注意到了,正则表达式并不关心模式在数据流中的位置。它也不关心模式出现了多少次。一旦正则表达式匹配了文本字符串中任意位置上的模式,它就会将该字符串传回 Linux 工具。

关键在于将正则表达式模式匹配到数据流文本上。重要的是记住正则表达式对匹配的模式非常挑剔。第一条原则就是:正则表达式模式都区分大小写。这意味着它们只会匹配大小写也相符的模式。

$ echo "This is a test" | sed -n '/this/p'
$
$ echo "This is a test" | sed -n '/This/p'
This is a test
$

第一次尝试没能匹配成功,因为 this 在字符串中并不都是小写,而第二次尝试在模式中使用大写字母,所以能正常工作。

在正则表达式中,你不用写出整个单词。只要定义的文本出现在数据流中,正则表达式就能够匹配。

$ echo "The books are expensive" | sed -n '/book/p'
The books are expensive
$

尽管数据流中的文本是 books,但数据中含有正则表达式 book,因此正则表达式模式跟数据匹配。当然,反之正则表达式就不成立了。

$ echo "The book is expensive" | sed -n '/books/p'
$

完整的正则表达式文本并未在数据流中出现,因此匹配失败,sed 编辑器不会显示任何文本。

你也不用局限于在正则表达式中只用单个文本单词,可以在正则表达式中使用空格和数字。

$ echo "This is line number 1" | sed -n '/ber 1/p'
This is line number 1
$

在正则表达式中,空格和其他的字符并没有什么区别。

$ echo "This is line number1" | sed -n '/ber 1/p'
$

如果你在正则表达式中定义了空格,那么它必须出现在数据流中。甚至可以创建匹配多个连续空格的正则表达式模式。

$ cat data1
This is a normal line of text.
This is  a line with too many spaces.
$ sed -n '/  /p' data1
This is  a line with too many spaces.
$

单词间有两个空格的行匹配正则表达式模式。这是用来查看文本文件中空格问题的好办法。

特殊字符

在正则表达式模式中使用文本字符时,有些事情值得注意。在正则表达式中定义文本字符时有一些特例。有些字符在正则表达式中有特别的含义。如果要在文本模式中使用这些字符,结果会超出你的意料。

正则表达式识别的特殊字符包括:

.*[]^${}\+?|()

随着本章内容的继续,你会了解到这些特殊字符在正则表达式中有何用处。不过现在只要记住不能在文本模式中单独使用这些字符就行了。果要用某个特殊字符作为文本字符,就必须转义。在转义特殊字符时,你需要在它前面加一个特殊字符来告诉正则表达式引擎应该将接下来的字符当作普通的文本字符。这个特殊字符就是反斜线(\)。举个例子,如果要查找文本中的美元符,只要在它前面加个反斜线。

$ cat data2
The cost is $4.00
$ sed -n '/\$/p' data2
The cost is $4.00
$

由于反斜线是特殊字符,如果要在正则表达式模式中使用它,你必须对其转义,这样就产生了两个反斜线。

$ echo "\ is a special character" | sed -n '/\\/p'
\ is a special character
$

最终,尽管正斜线不是正则表达式的特殊字符,但如果它出现在 sed 编辑器或 gawk 程序的正则表达式中,你就会得到一个错误。

$ echo "3 / 2" | sed -n '///p'
sed: -e expression #1, char 2: No previous regular expression
$

要使用正斜线,也需要进行转义。

$ echo "3 / 2" | sed -n '/\//p'
3 / 2
$

现在 sed 编辑器能正确解释正则表达式模式了,一切都很顺利。

锚字符

默认情况下,当指定一个正则表达式模式时,只要模式出现在数据流中的任何地方,它就能匹配。有两个特殊字符可以用来将模式锁定在数据流中的行首或行尾。

脱字符(^)定义从数据流中文本行的行首开始的模式。如果模式出现在行首之外的位置,正则表达式模式则无法匹配。要用脱字符,就必须将它放在正则表达式中指定的模式前面。

$ echo "The book store" | sed -n '/^book/p'
$
$ echo "Books are great" | sed -n '/^Book/p'
Books are great
$

脱字符会在每个由换行符决定的新数据行的行首检查模式。

$ cat data3
This is a test line.
this is another test line.
A line that tests this feature.
Yet more testing of this
$ sed -n '/^this/p' data3
this is another test line.
$

只要模式出现在新行的行首,脱字符就能够发现它。
如果你将脱字符放到模式开头之外的其他位置,那么它就跟普通字符一样,不再是特殊字符了:

$ echo "This ^ is a test" | sed -n '/s ^/p'
This ^ is a test
$

由于脱字符出现在正则表达式模式的尾部,sed 编辑器会将它当作普通字符来匹配。

如果指定正则表达式模式时只用了脱字符,就不需要用反斜线来转义。但如果你在模式中先指定了脱字符,随后还有其他一些文本,那么你必须在脱字符前用转义字符。


跟在行首查找模式相反的就是在行尾查找。特殊字符美元符($)定义了行尾锚点。将这个特殊字符放在文本模式之后来指明数据行必须以该文本模式结尾。

$ echo "This is a good book" | sed -n '/book$/p'
This is a good book
$ echo "This book is good" | sed -n '/book$/p'
$

使用结尾文本模式的问题在于你必须要留意到底要查找什么。

$ echo "There are a lot of good books" | sed -n '/book$/p'
$

将行尾的单词 book 改成复数形式,就意味着它不再匹配正则表达式模式了,尽管 book 仍然在数据流中。要想匹配,文本模式必须是行的最后一部分。


在一些常见情况下,可以在同一行中将行首锚点和行尾锚点组合在一起使用。在第一种情况中,假定你要查找只含有特定文本模式的数据行。

$ cat data4
this is a test of using both anchors
I said this is a test
this is a test
I'm sure this is a test.
$ sed -n '/^this is a test$/p' data4
this is a test
$

sed 编辑器忽略了那些不单单包含指定的文本的行。

第二种情况乍一看可能有些怪异,但极其有用。将两个锚点直接组合在一起,之间不加任何文本,这样过滤出数据流中的空白行。考虑下面这个例子。

$ cat data5
This is one test line.

This is another test line.
$ sed '/^$/d' data5
This is one test line.
This is another test line.
$

定义的正则表达式模式会查找行首和行尾之间什么都没有的那些行。由于空白行在两个换行符之间没有文本,刚好匹配了正则表达式模式。sed 编辑器用删除命令 d 来删除匹配该正则表达式模式的行,因此删除了文本中的所有空白行。这是从文档中删除空白行的有效方法。

点号字符

特殊字符点号用来匹配除换行符之外的任意单个字符。它必须匹配一个字符,如果在点号字符的位置没有字符,那么模式就不成立。来看一些在正则表达式模式中使用点号字符的例子。

$ cat data6
This is a test of a line.
The cat is sleeping.
That is a very nice hat.
This test is at line four.
at ten o'clock we'll go home.
$ sed -n '/.at/p'
data6
The cat is sleeping.
That is a very nice hat.
This test is at line four.
$

你应该能够明白为什么第一行无法匹配,而第二行和第三行就可以。第四行有点复杂。注意,我们匹配了 at,但在 at 前面并没有任何字符来匹配点号字符。其实是有的!在正则表达式中,空格也是字符,因此 at 前面的空格刚好匹配了该模式。第五行证明了这点,将 at 放在行首就不会匹配该模式了。

字符组

点号特殊字符在匹配某个字符位置上的任意字符时很有用。但如果你想要限定待匹配的具体字符呢?在正则表达式中,这称为字符组(character class)。可以定义用来匹配文本模式中某个位置的一组字符。如果字符组中的某个字符出现在了数据流中,那它就匹配了该模式。

使用方括号来定义一个字符组。方括号中包含所有你希望出现在该字符组中的字符。然后你可以在模式中使用整个组,就跟使用其他通配符一样。这需要一点时间来适应,但一旦你适应了,效果可是令人惊叹的。下面是个创建字符组的例子。

$ sed -n '/[ch]at/p' data6
The cat is sleeping.
That is a very nice hat.
$

这里用到的数据文件和点号特殊字符例子中的一样,但得到的结果却不一样。这次我们成功滤掉了只包含单词 at 的行。匹配这个模式的单词只有 cat 和 hat。还要注意以 at 开头的行也没有匹配。字符组中必须有个字符来匹配相应的位置。

在不太确定某个字符的大小写时,字符组会非常有用。

$ echo "Yes" | sed -n '/[Yy]es/p'
Yes
$ echo "yes" | sed -n '/[Yy]es/p'
yes
$

可以在单个表达式中用多个字符组。

$ echo "Yes" | sed -n '/[Yy][Ee][Ss]/p'
Yes
$ echo "yEs" | sed -n '/[Yy][Ee][Ss]/p'
yEs
$ echo "yeS" | sed -n '/[Yy][Ee][Ss]/p'
yeS
$

正则表达式使用了 3 个字符组来涵盖了 3 个字符位置含有大小写的情况。
字符组不必只含有字母,也可以在其中使用数字。

$ cat data7
This line doesn't contain a number.
This line has 1 number on it.
This line a number 2 on it.
This line has a number 4 on it.
$ sed -n '/[0123]/p' data7
This line has 1 number on it.
This line a number 2 on it.
$

这个正则表达式模式匹配了任意含有数字 0、1、2 或 3 的行。含有其他数字以及不含有数字的行都会被忽略掉。

可以将字符组组合在一起,以检查数字是否具备正确的格式,比如电话号码和邮编。但当你尝试匹配某种特定格式时,必须小心。这里有个匹配邮编出错的例子。

$ cat data8
60633
46201
223001
4353
22203
$ sed -n '
>/[0123456789][0123456789][0123456789][0123456789][0123456789]/p
>' data8
60633
46201
223001
22203
$

这个结果出乎意料。它成功过滤掉了不可能是邮编的那些过短的数字,因为最后一个字符组没有字符可匹配。但它也通过了那个六位数,尽管我们只定义了 5 个字符组。

记住,正则表达式模式可见于数据流中文本的任何位置。经常有匹配模式的字符之外的其他字符。如果要确保只匹配五位数,就必须将匹配的字符和其他字符分开,要么用空格,要么像这个例子中这样,指明它们就在行首和行尾。

$ sed -n '

> /^[0123456789][0123456789][0123456789][0123456789][0123456789]$/p
> ' data8
60633
46201
22203
$

现在好多了!本章随后会看到如何进一步进行简化。

字符组的一个极其常见的用法是解析拼错的单词,比如用户表单输入的数据。你可以创建正则表达式来接受数据中常见的拼写错误。

$ cat data9
I need to have some maintenence done on my car.
I'll pay that in a seperate invoice.
After I pay for the maintenance my car will be as good as new.
$ sed -n '
/maint[ea]n[ae]nce/p
/sep[ea]r[ea]te/p
' data9
I need to have some maintenence done on my car.
I'll pay that in a seperate invoice.
After I pay for the maintenance my car will be as good as new.
$

本例中的两个 sed 打印命令利用正则表达式字符组来帮助找到文本中拼错的单词 maintenance 和 separate。同样的正则表达式模式也能匹配正确拼写的 maintenance。

排除型字符组

在正则表达式模式中,也可以反转字符组的作用。可以寻找组中没有的字符,而不是去寻找组中含有的字符。要这么做的话,只要在字符组的开头加个脱字符。

$ sed -n '/[^ch]at/p' data6
This test is at line four.
$

通过排除型字符组,正则表达式模式会匹配 c 或 h 之外的任何字符以及文本模式。由于空格字符属于这个范围,它通过了模式匹配。但即使是排除,字符组仍然必须匹配一个字符,所以以 at 开头的行仍然未能匹配模式。

区间

你可能注意到了,我之前演示邮编的例子的时候,必须在每个字符组中列出所有可能的数字,这实在有点麻烦。好在有一种便捷的方法可以让人免受这番劳苦。可以用单破折线符号在字符组中表示字符区间。只需要指定区间的第一个字符、单破折线以及区间的最后一个字符就行了。根据 Linux 系统采用的字符集,正则表达式会包括此区间内的任意字符。现在你可以通过指定数字区间来简化邮编的例子。

$ sed -n '/^[0-9][0-9][0-9][0-9][0-9]$/p' data8
60633
46201
45902
$

这样可是节省了不少的键盘输入!每个字符组都会匹配 0~9 的任意数字。如果字母出现在数据中的任何位置,这个模式都将不成立。

同样的方法也适用于字母。

$ sed -n '/[c-h]at/p' data6
The cat is sleeping.
That is a very nice hat.
$

新的模式[c-h]at 匹配了首字母在字母 c 和字母 h 之间的单词。这种情况下,只含有单词 at 的行将无法匹配该模式。

还可以在单个字符组指定多个不连续的区间。

$ sed -n '/[a-ch-m]at/p' data6
The cat is sleeping.
That is a very nice hat.
$

该字符组允许区间 a~c、h~m 中的字母出现在 at 文本前,但不允许出现 d~g 的字母。

$ echo "I'm getting too fat." | sed -n '/[a-ch-m]at/p'
$

该模式不匹配 fat 文本,因为它没在指定的区间。

特殊的字符组

除了定义自己的字符组外,BRE 还包含了一些特殊的字符组,可用来匹配特定类型的字符。下面介绍了可用的 BRE 特殊的字符组。

  • [[:alpha:]] 匹配任意字母字符,不管是大写还是小写
  • [[:alnum:]] 匹配任意字母数字字符 0~9、A~Z 或 a~z
  • [[:blank:]] 匹配空格或制表符
  • [[:digit:]] 匹配 0~9 之间的数字
  • [[:lower:]] 匹配小写字母字符 a~z
  • [[:upper:]] 匹配任意大写字母字符 A~Z
  • [[:print:]] 匹配任意可打印字符
  • [[:punct:]] 匹配标点符号
  • [[:space:]] 匹配任意空白字符:空格、制表符、NL、FF、VT 和 CR

可以在正则表达式模式中将特殊字符组像普通字符组一样使用。

$ echo "abc" | sed -n '/[[:digit:]]/p'
$
$ echo "abc" | sed -n '/[[:alpha:]]/p'
abc
$ echo "abc123" | sed -n '/[[:digit:]]/p'
abc123
$ echo "This is, a test" | sed -n '/[[:punct:]]/p'
This is, a test
$ echo "This is a test" | sed -n '/[[:punct:]]/p'
$

使用特殊字符组可以很方便地定义区间。如可以用[[:digit:]]来代替区间[0-9]。

星号

在字符后面放置星号表明该字符必须在匹配模式的文本中出现 0 次或多次。

$ echo "ik" | sed -n '/ie*k/p'
ik
$ echo "iek" | sed -n '/ie*k/p'
iek
$ echo "ieek" | sed -n '/ie*k/p'
ieek
$ echo "ieeek" | sed -n '/ie*k/p'
ieeek
$ echo "ieeeek" | sed -n '/ie*k/p'
ieeeek
$

这个模式符号广泛用于处理有常见拼写错误或在不同语言中有拼写变化的单词。举个例子,如果需要写个可能用在美式或英式英语中的脚本,可以这么写:

$ echo "I'm getting a color TV" | sed -n '/colou*r/p'
I'm getting a color TV
$ echo "I'm getting a colour TV" | sed -n '/colou*r/p'
I'm getting a colour TV
$

模式中的 u*表明字母 u 可能出现或不出现在匹配模式的文本中。类似地,如果你知道一个单词经常被拼错,你可以用星号来允许这种错误。

$ echo "I ate a potatoe with my lunch." | sed -n '/potatoe*/p'
I ate a potatoe with my lunch.
$ echo "I ate a potato with my lunch." | sed -n '/potatoe*/p'
I ate a potato with my lunch.
$

在可能出现的额外字母后面放个星号将允许接受拼错的单词。

另一个方便的特性是将点号特殊字符和星号特殊字符组合起来。这个组合能够匹配任意数量的任意字符。它通常用在数据流中两个可能相邻或不相邻的文本字符串之间。

$ echo "this is a regular pattern expression" | sed -n '
> /regular.*expression/p'
this is a regular pattern expression
$

可以使用这个模式轻松查找可能出现在数据流中文本行内任意位置的多个单词。

星号还能用在字符组上。它允许指定可能在文本中出现多次的字符组或字符区间。

$ echo "bt" | sed -n '/b[ae]*t/p'
bt
$ echo "bat" | sed -n '/b[ae]*t/p'
bat
$ echo "bet" | sed -n '/b[ae]*t/p'
bet
$ echo "btt" | sed -n '/b[ae]*t/p'
btt
$
$ echo "baat" | sed -n '/b[ae]*t/p'
baat
$ echo "baaeeet" | sed -n '/b[ae]*t/p'
baaeeet
$ echo "baeeaeeat" | sed -n '/b[ae]*t/p'
baeeaeeat
$ echo "baakeeet" | sed -n '/b[ae]*t/p'
$

只要 a 和 e 字符以任何组合形式出现在 b 和 t 字符之间(就算完全不出现也行),模式就能够匹配。如果出现了字符组之外的字符,该模式匹配就会不成立。

扩展正则表达式

POSIX ERE 模式包括了一些可供 Linux 应用和工具使用的额外符号。gawk 程序能够识别 ERE 模式,但 sed 编辑器不能。

记住,sed 编辑器和 gawk 程序的正则表达式引擎之间是有区别的。gawk 程序可以使用大多数扩展正则表达式模式符号,并且能提供一些额外过滤功能,而这些功能都是 sed 编辑器所不具备的。但正因为如此,gawk 程序在处理数据流时通常才比较慢。

本节将介绍可用在 gawk 程序脚本中的较常见的 ERE 模式符号。

问号

问号类似于星号,不过有点细微的不同。问号表明前面的字符可以出现 0 次或 1 次,但只限于此。它不会匹配多次出现的字符。

$ echo "bt" | gawk '/be?t/{print $0}'
bt
$ echo "bet" | gawk '/be?t/{print $0}'
bet
$ echo "beet" | gawk '/be?t/{print $0}'
$
$ echo "beeet" | gawk '/be?t/{print $0}'
$

如果字符 e 并未在文本中出现,或者它只在文本中出现了 1 次,那么模式会匹配。

与星号一样,你可以将问号和字符组一起使用。

$ echo "bt" | gawk '/b[ae]?t/{print $0}'
bt
$ echo "bat" | gawk '/b[ae]?t/{print $0}'
bat
$ echo "bot" | gawk '/b[ae]?t/{print $0}'
$
$ echo "bet" | gawk '/b[ae]?t/{print $0}'
bet
$ echo "baet" | gawk '/b[ae]?t/{print $0}'
$
$ echo "beat" | gawk '/b[ae]?t/{print $0}'
$
$ echo "beet" | gawk '/b[ae]?t/{print $0}'
$

如果字符组中的字符出现了 0 次或 1 次,模式匹配就成立。但如果两个字符都出现了,或者其中一个字符出现了 2 次,模式匹配就不成立。

加号

加号是类似于星号的另一个模式符号,但跟问号也有不同。加号表明前面的字符可以出现 1 次或多次,但必须至少出现 1 次。如果该字符没有出现,那么模式就不会匹配。

$ echo "beeet" | gawk '/be+t/{print $0}'
beeet
$ echo "beet" | gawk '/be+t/{print $0}'
beet
$ echo "bet" | gawk '/be+t/{print $0}'
bet
$ echo "bt" | gawk '/be+t/{print $0}'
$

如果字符 e 没有出现,模式匹配就不成立。加号同样适用于字符组,与星号和问号的使用方式相同。

$ echo "bt" | gawk '/b[ae]+t/{print $0}'
$
$ echo "bat" | gawk '/b[ae]+t/{print $0}'
bat
$ echo "bet" | gawk '/b[ae]+t/{print $0}'
bet
$ echo "beat" | gawk '/b[ae]+t/{print $0}'
beat
$ echo "beet" | gawk '/b[ae]+t/{print $0}'
beet
$ echo "beeat" | gawk '/b[ae]+t/{print $0}'
beeat
$

这次如果字符组中定义的任一字符出现了,文本就会匹配指定的模式。

使用花括号

ERE 中的花括号允许你为可重复的正则表达式指定一个上限。这通常称为间隔(interval)。可以用两种格式来指定区间。

  • 正则表达式准确出现 m 次。
  • m, n:正则表达式至少出现 m 次,至多 n 次。

这个特性可以精确调整字符或字符集在模式中具体出现的次数。

如果你的 gawk 版本过老,gawk 程序不会识别正则表达式间隔。必须额外指定 gawk 程序的–re- interval 命令行选项才能识别正则表达式间隔。

这里有个使用简单的单值间隔的例子。

$ echo "bt" | gawk --re-interval '/be{1}t/{print $0}'
$
$ echo "bet" | gawk --re-interval '/be{1}t/{print $0}'
bet
$ echo "beet" | gawk --re-interval '/be{1}t/{print $0}'
$

通过指定间隔为 1,限定了该字符在匹配模式的字符串中出现的次数。如果该字符出现多次,模式匹配就不成立。

很多时候,同时指定下限和上限也很方便。

$ echo "bt" | gawk --re-interval '/be{1,2}t/{print $0}'
$
$ echo "bet" | gawk --re-interval '/be{1,2}t/{print $0}'
bet
$ echo "beet" | gawk --re-interval '/be{1,2}t/{print $0}'
beet
$ echo "beeet" | gawk --re-interval '/be{1,2}t/{print $0}'
$

在这个例子中,字符 e 可以出现 1 次或 2 次,这样模式就能匹配;否则,模式无法匹配

间隔模式匹配同样适用于字符组。

$ echo "bt" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
$
$ echo "bat" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
bat
$ echo "bet" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
bet
$ echo "beat" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
beat
$ echo "beet" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
beet
$ echo "beeat" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
$
$ echo "baeet" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
$
$ echo "baeaet" | gawk --re-interval '/b[ae]{1,2}t/{print $0}'
$

如果字母 a 或 e 在文本模式中只出现了 1~2 次,则正则表达式模式匹配;否则,模式匹配失败。

管道符号

管道符号允许你在检查数据流时,用逻辑 OR 方式指定正则表达式引擎要用的两个或多个模式。如果任何一个模式匹配了数据流文本,文本就通过测试。如果没有模式匹配,则数据流文本匹配失败。

使用管道符号的格式如下:

expr1|expr2|...

这里有个例子。

$ echo "The cat is asleep" | gawk '/cat|dog/{print $0}'
The cat is asleep
$ echo "The dog is asleep" | gawk '/cat|dog/{print $0}'
The dog is asleep
$ echo "The sheep is asleep" | gawk '/cat|dog/{print $0}'
$

这个例子会在数据流中查找正则表达式 cat 或 dog。正则表达式和管道符号之间不能有空格,否则它们也会被认为是正则表达式模式的一部分。
管道符号两侧的正则表达式可以采用任何正则表达式模式(包括字符组)来定义文本。

$ echo "He has a hat." | gawk '/[ch]at|dog/{print $0}'
He has a hat.
$

这个例子会匹配数据流文本中的 cat、hat 或 dog。

圆括号

正则表达式模式也可以用圆括号进行分组。当你将正则表达式模式分组时,该组会被视为一个标准字符。可以像对普通字符一样给该组使用特殊字符。举个例子:

$ echo "Sat" | gawk '/Sat(urday)?/{print $0}'
Sat
$ echo "Saturday" | gawk '/Sat(urday)?/{print $0}'
Saturday
$

结尾的 urday 分组以及问号,使得模式能够匹配完整的 Saturday 或缩写 Sat。
将分组和管道符号一起使用来创建可能的模式匹配组是很常见的做法。

$ echo "cat" | gawk '/(c|b)a(b|t)/{print $0}'
cat
$ echo "cab" | gawk '/(c|b)a(b|t)/{print $0}'
cab
$ echo "bat" | gawk '/(c|b)a(b|t)/{print $0}'
bat
$ echo "bab" | gawk '/(c|b)a(b|t)/{print $0}'
bab
$ echo "tab" | gawk '/(c|b)a(b|t)/{print $0}'
$
$ echo "tac" | gawk '/(c|b)a(b|t)/{print $0}'
$
模式(c b)a(b t)会匹配第一组中字母的任意组合以及第二组中字母的任意组合

正则表达式实战

现在你已经了解了使用正则表达式模式的规则和一些简单的例子,该把理论用于实践了。随后几节将会演示 shell 脚本中常见的一些正则表达式例子。

目录文件计数

让我们先看一个 shell 脚本,它会对 PATH 环境变量中定义的目录里的可执行文件进行计数。要这么做的话,首先你得将 PATH 变量解析成单独的目录名。前面介绍过如何显示 PATH 环境变量。

$ echo $PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/ local/games
$

根据 Linux 系统上应用程序所处的位置,PATH 环境变量会有所不同。关键是要意识到 PATH 中的每个路径由冒号分隔。要获取可在脚本中使用的目录列表,就必须用空格来替换冒号。现在你会发现 sed 编辑器用一条简单表达式就能完成替换工作。

$ echo $PATH | sed 's/:/ /g'
/usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin /usr/games /usr/local/games
$

分离出目录之后,你就可以使用标准 for 语句中来遍历每个目录。

mypath=$(echo $PATH | sed 's/:/ /g')
for directory in $mypath
do
    ...
done

一旦获得了单个目录,就可以用 ls 命令来列出每个目录中的文件,并用另一个 for 语句来遍历每个文件,为文件计数器增值。
这个脚本的最终版本如下。

$ cat countfiles
#!/bin/bash
## count number of files in your PATH
mypath=$(echo $PATH | sed 's/:/ /g')
count=0
for directory in $mypath
do
    check=$(ls $directory)
    for item in $check
        do
            count=$[ $count + 1 ]
        done
    echo "$directory - $count"
    count=0
done
$ ./countfiles
/usr/local/sbin - 0
/usr/local/bin - 2
/usr/sbin - 213
/usr/bin - 1427
/sbin - 186
/bin - 152
/usr/games - 5
/usr/local/games – 0
$

现在我们开始体会到正则表达式背后的强大之处了!

验证电话号码

前面的例子演示了在处理数据时,如何将简单的正则表达式和 sed 配合使用来替换数据流中的字符。正则表达式通常用于验证数据,确保脚本中数据格式的正确性。
一个常见的数据验证应用就是检查电话号码。数据输入表单通常会要求填入电话号码,而用户输入格式错误的电话号码是常有的事。在美国,电话号码有几种常见的形式:

(123)456-7890
(123) 456-7890
123-456-7890
123.456.7890

这样用户在表单中输入的电话号码就有 4 种可能。正则表达式必须足够强大,才能处理每一种情况。

在构建正则表达式时,最好从左手边开始,然后构建用来匹配可能遇到的字符的模式。在这个例子中,电话号码中可能有也可能没有左圆括号。这可以用如下模式来匹配:

^\(?

脱字符用来表明数据的开始。由于左圆括号是个特殊字符,因此必须将它转义成普通字符。问号表明左圆括号可能出现,也可能不出现。
紧接着就是 3 位区号。在美国,区号以数字 2 开始(没有以数字 0 或 1 开始的区号),最大可到 9。要匹配区号,可以用如下模式。

[2-9][0-9]{2}

这要求第一个字符是 2~9 的数字,后跟任意两位数字。在区号后面,收尾的右圆括号可能存在,也可能不存在。

\)?

在区号后,存在如下可能:有一个空格,没有空格,有一条单破折线或一个点。你可以对它们使用管道符号,并用圆括号进行分组。

(| |-|\.)

第一个管道符号紧跟在左圆括号后,用来匹配没有空格的情形。你必须将点字符转义,否则它会被解释成可匹配任意字符。
紧接着是 3 位电话交换机号码。这里没什么需要特别注意的。

[0-9]{3}

在电话交换机号码之后,你必须匹配一个空格、一条单破折线或一个点。

( |-|\.)

最后,必须在字符串尾部匹配 4 位本地电话分机号。

[0-9]{4}$

完整的模式如下。

^\(?[2-9][0-9]{2}\)?(| |-|\.)[0-9]{3}( |-|\.)[0-9]{4}$

你可以在 gawk 程序中用这个正则表达式模式来过滤掉不符合格式的电话号码。现在你只需要在 gawk 程序中创建一个使用该正则表达式的简单脚本,然后用这个脚本来过滤你的电话薄。脚本如下,可以将电话号码重定向到脚本来处理。

$ cat isphone
#!/bin/bash
## script to filter out bad phone numbers
gawk --re-interval '/^\(?[2-9][0-9]{2}\)?(| |-|\.)[0-9]{3}( |-|\.)[0-9]{4}$/{print $0}'
$
$ echo "317-555-1234" | ./isphone
317-555-1234
$ echo "000-555-1234" | ./isphone
$ echo "312 555-1234" | ./isphone
312 555-1234
$

或者也可以将含有电话号码的整个文件重定向到脚本来过滤掉无效的号码。

$ cat phonelist
000-000-0000
123-456-7890
212-555-1234
(317)555-1234
(202) 555-9876
33523
1234567890
234.123.4567
$ cat phonelist | ./isphone
212-555-1234
(317)555-1234
(202) 555-9876
234.123.4567
$

只有匹配该正则表达式模式的有效电话号码才会出现。

解析邮件地址

如今这个时代,电子邮件地址已经成为一种重要的通信方式。验证邮件地址成为脚本程序员的一个不小的挑战,因为邮件地址的形式实在是千奇百怪。邮件地址的基本格式为:

username@hostname

username 值可用字母数字字符以及以下特殊字符:

  • 点号
  • 单破折线
  • 加号
  • 下划线

在有效的邮件用户名中,这些字符可能以任意组合形式出现。邮件地址的 hostname 部分由一个或多个域名和一个服务器名组成。服务器名和域名也必须遵照严格的命名规则,只允许字母数字字符以及以下特殊字符:

  • 点号
  • 下划线

服务器名和域名都用点分隔,先指定服务器名,紧接着指定子域名,最后是后面不带点号的顶级域名。
顶级域名的数量在过去十分有限,正则表达式模式编写者会尝试将它们都加到验证模式中。然而遗憾的是,随着互联网的发展,可用的顶级域名也增多了。这种方法已经不再可行。
从左侧开始构建这个正则表达式模式。我们知道,用户名中可以有多个有效字符。这个相当容易。

^([a-zA-Z0-9_\-\.\+]+)@

这个分组指定了用户名中允许的字符,加号表明必须有至少一个字符。下一个字符很明显是@,没什么意外的。

hostname 模式使用同样的方法来匹配服务器名和子域名。

([a-zA-Z0-9_\-\.]+)

这个模式可以匹配文本:

server
server.subdomain
server.subdomain.subdomain

对于顶级域名,有一些特殊的规则。顶级域名只能是字母字符,必须不少于二个字符(国家或地区代码中使用),并且长度上不得超过五个字符。下面就是顶级域名用的正则表达式模式。

\.([a-zA-Z]{2,5})$

将整个模式放在一起会生成如下模式。

^([a-zA-Z0-9_\-\.\+]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})$

这个模式会从数据列表中过滤掉那些格式不正确的邮件地址。现在可以创建脚本来实现这个正则表达式了。

$ echo "rich@here.now" | ./isemail
rich@here.now
$ echo "rich@here.now." | ./isemail
$
$
echo "rich@here.n" | ./isemail
$
$ echo "rich@here-now" | ./isemail
$
$ echo "rich.blum@here.now" | ./isemail
rich.blum@here.now
$ echo "rich_blum@here.now" | ./isemail
rich_blum@here.now
$ echo "rich/blum@here.now" | ./isemail
$
$ echo "rich#blum@here.now" | ./isemail
$
$ echo "rich*blum@here.now" | ./isemail
$