9. 重复性任务

9.1 for 循环

9.1.1 工作方式

for NAME [in LIST ]; do COMMANDS; done

按照 LIST 列表,逐次执行 COMMANDS,直到 LIST 结束。

如果 [in LIST ] 不存在,则会用 in $@ 替换,并用每个位置参数来执行一次 COMMANDS。

执行的最后一条命令的退出状态作为该循环的返回状态。如果因为 LIST 没扩展出任何条目,而导致没有执行任何命令,返回状态为 0。

NAME 可以是变量名,经常习惯地使用 i 做变量名。

LIST 可以是任何字段的列表,列表也可以由命令生成。

COMMANDS 可以是任何命令、脚本、程序、语句。

9.1.2 范例

for i in {1..100..2}
do  
    let "sum+=i"  
done  

echo "sum=$sum"

i 按步数 2 不断递增,计算 sum 值。

用命令替换来指定列表条目
for i in `cat list`; do
	cp "$i" "$i".bak ;
done

for i in `ls /sbin`; do
	file /sbin/$i | grep ASCII;
done
使用变量内容来指定列表条目
LIST="$(ls *.html)"
for i in "$LIST"; do
     NEWNAME=$(ls "$i" | sed -e 's/html/php/')
     cat beginfile > "$NEWNAME"
     cat "$i" | sed -e '1,25d' | tac | sed -e '1,21d'| tac >> "$NEWNAME"
     cat endfile >> "$NEWNAME"
done
计次循环
for(( i = 1; i <= 100; i = i + 2 ))  
do  
     let "sum += i"  
done  
echo "sum=$sum"  

i = 1 :循环变量赋初值

i <= 100 :条件表达式,退出状态为 0 则继续循环,否则退出循环

i = i + 2 :循环变量自增

9.2 while 循环

也称为 前测试循环语句,利用一个 条件控制 是否继续 重复 执行这个语句。

9.2.1 工作方式

while CONTROL-COMMAND; do CONSEQUENT-COMMANDS; done

只要 CONTROL-COMMAND 条件为真,就不停地执行执行 CONSEQUENT-COMMANDS。一旦条件为假,立即退出循环,继续执行 done 之后,即循环之外的命令。

为了 避免死循环,必须保证循环体中包含 循环出口条件,即表达式存在退出状态为非 0 的情况。

9.2.2 范例

一般用法
i="0"

while [ $i -lt 4 ]
do
xterm &
i=$[$i+1]
done
嵌套

以下脚本目的是把网络摄像头拍摄的照片自动归档。摄像头每 5 分钟会拍一张照片。

每天创建一个当天的目录,每小时新建一个目录,用于保存这一小时内拍摄的照片。

PICSDIR=/home/carol/pics
WEBDIR=/var/www/carol/webcam

while true; do
	DATE=`date +%Y%m%d`
	HOUR=`date +%H`
	mkdir $WEBDIR/"$DATE"

	while [ $HOUR -ne "00" ]; do
		DESTDIR=$WEBDIR/"$DATE"/"$HOUR"
		mkdir "$DESTDIR"
		mv $PICDIR/*.jpg "$DESTDIR"/
		sleep 3600
		HOUR=`date +%H`
	done
done

使用 true 语句时,代表无条件执行,直到被强制中断,也许是被 kill 信号,或 ^C

下面的脚本可以用于生成测试文件:

while true; do
touch pic-`date +%s`.jpg
sleep 300
done

每 5 分钟生成一个 .jpg 文件。

用键盘输入来控制循环

以下脚本可以用 ^C 来终止运行:

FORTUNE=/usr/games/fortune

while true; do
echo "On which topic do you want advice?"
cat << topics
politics
startrek
kernelnewbies
sports
bofh-excuses
magic
love
literature
drugs
education
topics

echo
echo -n "Make your choice: "
read topic
echo
echo "Free advice on the topic of $topic: "
echo
$FORTUNE $topic
echo

done
计算平均值
SCORE="0"
AVERAGE="0"
SUM="0"
NUM="0"

while true; do

  echo -n "Enter your score [0-100%] ('q' for quit): "; read SCORE;

  if (("$SCORE" < "0"))  || (("$SCORE" > "100")); then
    echo "Be serious.  Common, try again: "
  elif [ "$SCORE" == "q" ]; then
    echo "Average rating: $AVERAGE%."
    break
  else
    SUM=$[$SUM + $SCORE]
    NUM=$[$NUM + 1]
    AVERAGE=$[$SUM / $NUM]
  fi

done

echo "Exiting."

9.3 until 循环

until TEST-COMMAND; do CONSEQUENT-COMMANDS; done

只要条件不满足,就一直循环运行,一旦满足,退出循环。

范例

还是网络摄像头那个例子,加上磁盘空间的检测,一于磁盘空间不够用(占用率 > 90%),就自动转移图片。

while true; do
	DISKFUL=$(df -h $WEBDIR | grep -v File | awk '{print $5 }' | cut -d "%" -f1 -)

	until [ $DISKFUL -ge "90" ]; do

        	DATE=`date +%Y%m%d`
        	HOUR=`date +%H`
        	mkdir $WEBDIR/"$DATE"

        	while [ $HOUR -ne "00" ]; do
                	DESTDIR=$WEBDIR/"$DATE"/"$HOUR"
                	mkdir "$DESTDIR"
                	mv $PICDIR/*.jpg "$DESTDIR"/
                	sleep 3600
                	HOUR=`date +%H`
        	done

	DISKFULL=$(df -h $WEBDIR | grep -v File | awk '{ print $5 }' | cut -d "%" -f1 -)
	done

	TOREMOVE=$(find $WEBDIR -type d -a -mtime +30)
	for i in $TOREMOVE; do
		rm -rf "$i";
	done

done

9.4 I/O 重定向和循环

9.4.1 输入重定向

除了通过一条命令的测试结果或用户的输入来控制循环以外,还可以指定一个文件,通过它来读取输入,借此控制循环。这种情况下,read 通常是控制命令。只要有输入行的数据交给循环,循环的命令就一直执行。读完所有输入行以后,循环退出。

因为循环结构基本上可以看作是一条命令的结构,因此重定向应该放在 done 语句之后。

command < file

what the fuck????

9.4.2 输出重定向

下例中,find 命令的输出作为 read 命令的输入,用来控制 while 循环:

ARCHIVENR=`date +%Y%m%d`
DESTDIR="$PWD/archive-$ARCHIVENR"

mkdir "$DESTDIR"

find "$PWD" -type f -a -mtime +5 | while read -d $'\000' file

do
gzip "$file"; mv "$file".gz "$DESTDIR"
echo "$file archived"
done

在当前目录创建子目录,把旧文件都移到那里。

9.5 breakcontinue

9.5.1 break

break 语句用于在正常结束之前退出当前循环。

注意:break 只是退出当前循环,而非脚本。

在嵌套循环中,可以指定要退出哪个循环。

9.5.2 continue

continue 语句用于继续迭代循环。

for 循环中使用时,控制变量会取列表中下一个元素的值。

whileuntil 中使用时,则继续执行 TEST-COMMAND。

以下脚本会把当前目录中所有文件名有含有大写字母的,一律修改为小写字母:

LIST="$(ls)"

for name in "$LIST"; do

if [[ "$name" != *[[:upper:]]* ]]; then
continue  
# 如果文件名中不含大写字母,循环运行到这一步就不再往下运行,
# 而是直接取列表中下一个文件名
fi

ORIG="$name"
NEW=`echo $name | tr 'A-Z' 'a-z'`

mv "$ORIG" "$NEW"
echo "new name for $ORIG is $NEW"
done

9.6 用 select 命令做选择菜单

select 命令

select 结构可以生成菜单:

select WORD [in LIST]; do RESPECTIVE-COMMANDS; done

LIST 会被扩展开,生成一个项目列表。该扩展会被输出到标准错误,每个项目前面会自动加上一个序号。如果 in LIST 不存在,则会把位置参数做为项目打印出来。

在打印出所有项目后,会打印 PS3 提示符,并且会从标准输入读取一行。如果该行包含与项目对应的数字之一,WORD 的值会被设置成该项目的名称。如果读取到的输入是空行,这些项目列表和提示符会重新显示一次。

如果读取到 EOF 字符,循环会退出。

因为大部分用户都不了解什么组合键是用来产生 EOF 的,因此,如果能单独添加一个含有 break 命令的项目,算是对用户比较友好了。

读取到的所有其它的值都会把 WORD 设置为空字符串。

读取到的这一行内容被保存到变量 REPLY 中。

用户每次做出选择,都会执行 RESPECTIVE-COMMANDS 中的命令,直到做出的选择导致了 break 语句的运行,才会退出循环。

#!/bin/bash

echo "This script can make any of the files in this directory private."
echo "Enter the number of the file you want to protect:"

PS3="Your choice: "
QUIT="QUIT THIS PROGRAM - I feel safe now."
touch "$QUIT"

select FILENAME in *;
do
  case $FILENAME in
        "$QUIT")
          echo "Exiting."
          break
          ;;
        *)
          echo "You picked $FILENAME ($REPLY)"
          chmod go-rwx "$FILENAME"
          ;;
  esac
done
rm "$QUIT"

子菜单

通过 select 结构的嵌套就可以实现子菜单。

默认情况下,进行嵌套 select 循环时,PS3 变量不会改变,如果需要,可以在子 select 结构中单独设定。

9.7 shift

9.7.1 shift 简介

shift 为 bash 内建命令,接受一个数字 N 做为参数。所有的位置参数会由于这个数字 N 而整体向左移动。于是原来的位置参数 $# 变成了 $# - N+1

假如有个命令使用了 10 个参数,而且 N=4,则 $4 变成了 $1$5 变成了 $2 等等。而原来的 $1$2$3丢弃

如果 N 为 0 或大于 $#,位置参数则不会改变,shift 命令也没有什么效果。如果 N 不存在,会假定 N=1。

如果 N 介于 0 和 $# 之间,则返回状态为 0,否则为非 0。

shift 应用的场合主要是某个命令要使用的参数不能提前预知时,用户则可以提前准备许多参数。这种情况下,通常使用 while 循环来处理这些参数,把 (( $# )) 做为测试条件。只要参数的总数大于 0,条件就为真。$1 变量和 shift 语句会处理每一个参数。每次执行 shift 以后,参数的总数就会减少,直到最终为 0,while 循环就会退出。

范例

以下脚本用于删除超过一年以上未访问的文件,使用 shift 语句来处理由 find 生成的文件列表中的每个文件。

USAGE="Usage: $0 dir1 dir2 dir3 ... dirN"

if [ "$#" == "0" ]; then
	echo "$USAGE"
	exit 1
fi

while (( "$#" )); do

if [[ $(ls "$1") == "" ]]; then
	echo "Empty directory, nothing to be done."
  else
	find "$1" -type f -a -atime +365 | xargs rm -i
fi

shift

done

以下脚本用于安装软件包:

if [ $# -lt 1 ]; then
        echo "Usage: $0 package(s)"
        exit 1
fi
while (($#)); do
	yum install "$1" << CONFIRM
y
CONFIRM
shift
done

10. 变量

10.1 变量的类别

10.1.1 一般的赋值方法

VARIABLE=string

通常在脚本开始处对常量进行赋值,以便整个脚本中可以引用变量名。

10.1.2 delcare 命令

declare 为内建命令,用于限制为该变量赋值的范围。一旦使用了 delcare 对变量的赋值范围进行限定,它就只能接受这类的数据了。

常用的限制为整数、常量或数组。

declare OPTION(s) VARIABLE=value

选项 用途
-a 变量是数组
-f 仅使用函数名做变量
-i 变量会被作为整数处理
-p 显示每个变量的属性和值,只要使用了 -p 选项,其它所有选项都会被忽略
-r 让变量为只读。不能被后续命令所赋值,也不能被 unset
-t 为变量设置 trace 属性
-x 标记变量,用于通过环境变量导出到后续命令

以上选项,如果把 - 变成 + 就会关闭该选项。

如果 declare 是在函数中进行的,会创建局部变量。

$ declare -i VARIABLE=12
$ VARIABLE=string
$ echo $VARIABLE
0
$ declare -p VARIABLE
declare -i VARIABLE="0"

bash 可以用 -i 声明数字变量,但没有选项可以声明字符串变量。因为每个变量默认可以赋予任何数据。

$ OTHERVAR=blah
$ declare -p OTHERVAR
declare -- OTHERVAR="blah"

10.1.3 常量

bash 中,通过 readonly 命令来创建只读变量,来创建常量。

readonly OPTION VARIABLE(s)

一经该命令的赋值,该变量的值就再也无法被后续命令修改。

如果使用了 -f 选项,则每个变量都是指一个 shell 函数。

如果使用了 -a 选项,每个变量都是指一个数组变量。

如果使用了 -p 选项,输出可以被当作输入来重用。

如果没有使用参数,或使用了 -p 选项,会列出所有只读变量。

10.2 数组变量

10.2.1 创建数组

数组变量包含多个值,所有的变量都可以做为数组来使用。没有数组大小的限制,也不必连续赋值。

数组是从 0 开始索引的。

为数组的某个索引赋值

间接声明数组的一种方式。

ARRAY[INDEXNR]=valueSHIT[0]=thor

INDEXNR 会以算术表达式被对待,必须最终算出一个正数。

显式声明数组

可以用 declare 显式声明一个数组:

declare -a SHIT

声明时如果使用了索引编号,会被忽略。

可以使用 declarereadonly 来为数组设定属性。属性会应用到数组中的所有变量中。

为数组全部索引赋值

间接声明数组的另一种方式。

ARRAY=(value1 value2 ... valueN)

赋值时如果没有指定索引值,则索引会从 0 开始。

10.2.2 引用数组变量

要想引用数组中某个项目的内容,需要用大括号 {ARRAY[5]}

$ ARRAY=(one two three)
$ echo ${ARRAY[*]}
one two three

$ echo $ARRAY[*]
one[*]

$ echo ${ARRAY[2]}
three

如果索引号为 @* 则相当于引用了数组中所有成员。

如果没有索引号,则相当于引用第一个索引,即 ARRAY[0]。

10.2.3 删除数据变量

使用 unset 可以清除整个数组或某个变量成员。

$ unset ARRAY[1]
$ echo ${ARRAY[*]}
one three four

$ unset ARRAY
$ echo ${ARRAY[*]}
<--no output-->

10.2.4 范例

把 Apache 配置文件分发到各主机。

if [ $(whoami) != 'root' ]; then
        echo "Must be root to run $0"
        exit 1;
fi
if [ -z $1 ]; then
        echo "Usage: $0 </path/to/httpd.conf>"
        exit 1
fi

httpd_conf_new=$1
httpd_conf_path="/usr/local/apache/conf"
login=htuser

farm_hosts=(web03 web04 web05 web06 web07)

for i in ${farm_hosts[@]}; do
        su $login -c "scp $httpd_conf_new ${i}:${httpd_conf_path}"
        su $login -c "ssh $i sudo /usr/local/apache/bin/apachectl graceful"

done
exit 0

某公司网站上有一些演示程序,每周要由人对所有这些程序进程测试。

#!/bin/bash
# This is get-tester-address.sh
#
# First, we test whether bash supports arrays.
# (Support for arrays was only added recently.)
#
whotest[0]='test' || (echo 'Failure: arrays not supported in this version of
bash.' && exit 2)

#
# Our list of candidates. (Feel free to add or
# remove candidates.)
#
wholist=(
     'Bob Smith <bob@example.com>'
     'Jane L. Williams <jane@example.com>'
     'Eric S. Raymond <esr@example.com>'
     'Larry Wall <wall@example.com>'
     'Linus Torvalds <linus@example.com>'
   )
#
# Count the number of possible testers.
# (Loop until we find an empty string.)
#
count=0
while [ "x${wholist[count]}" != "x" ]
do
   count=$(( $count + 1 ))
done

#
# Now we calculate whose turn it is.
#
week=`date '+%W'`    	# The week of the year (0..53).
week=${week#0}       	# Remove possible leading zero.

let "index = $week % $count"   # week modulo count = the lucky person

email=${wholist[index]}     # Get the lucky person's e-mail address.

echo $email     	# Output the person's e-mail address.

10.3 对变量的操作

10.3.1 变量长度

${#VAR} 来表示变量的字符总数。

$ echo $SHELL
/bin/bash

$ echo ${#SHELL}
9

如果变量是 *@,其变量值会用位置参数的总数或数组元素的数量来替换。

$ ARRAY=(one two three)
$ echo ${#ARRAY}
3

10.3.2 变量的转换

变量替换
${VAR:-WORD}${VAR:=WORD}

如果 VAR 未定义或为空,就用 WORD 的扩展来替换,否则用变量值替换。即 只替换空变量

常用来做条件测试。

$ TEST='mother fucker!'
$ echo ${TEST}
mother fucker!
$ echo ${TEST:-hello}
mother fucker!
$ unset TEST
$ echo ${TEST:-hello}
hello
$ echo ${TEST}

如果 TEST 未定义或为空,则结果会显示 shit,否则显示 TEST 变量的值。

${var+$OTHER}${var:+$OTHER}

如果变量已定义,表达式求值结果为 $OTHER,否则为空。即 只替换非空变量

删除子串
${VAR:OFFSET:LENGTH}

😈 想像在 WORD 中编辑文字,光标在该字符串最前面,现在你想从字符串中截取一部分留下,其余的删除。该语法是指光标向右移动 OFFSET 个字符,从新位置向右保留 LENGTH 个字符。其余的删除。如果 LENGTH 被省略,则从新光标处到字符串结尾的字符都保留。

image-center

$ export STRING="thisisaverylongname"
$ echo ${STRING:4}
isaverylongname
$ echo ${STRING:11:4}
long
${VAR#WORD}${VAR##WORD}

VAR 中删除子串 WORDWORD 会被扩展以生成一个模板,用来匹配子串。

${VAR#WORD} :从 VAR 的开头删除 WORD 的最短匹配

${VAR##WORD} :从 VAR 的开头删除 WORD 的最长匹配

$ VAR=abcdefg
$ echo ${VAR#a*}
bcdefg
$ echo ${VAR##a*}

如果 VAR 是 *@,则删除模板的操作符被依次应用于每个位置参数。

如果 VAR 是用 *@ 表示的数组变量,操作符则依次应用于数组中的每个成员。

$ echo ${ARRAY[*]}
one two one three one four

$ echo ${ARRAY[*]#one}
two three four

$ echo ${ARRAY[*]#t}
one wo one hree one four

$ echo ${ARRAY[*]#t*}
one wo one hree one four

$ echo ${ARRAY[*]##t*}
one one one four
${VAR%WORD}${VAR%%WORD}

与上面相反,这是从字符串的尾部开始匹配的。

$ echo $STRING
thisisaverylongname

$ echo ${STRING%name}
thisisaverylong
对变量值进行部分替换

${VAR/PATTERN/STRING} 替换第一个匹配

${VAR//PATTERN/STRING} 替换所有匹配

STRING="thisisaverylongname"
echo ${STRING/name/string}
thisisaverylongstring

11. 函数

11.1 函数简介

函数就是把命令组合在一起,便于随后执行。给组合(即 routine)起个名字。函数的名字在 shell 或脚本内必须是 唯一 的。

函数比别名使用起来更灵活,而且适应用户的环境也更加简单和容易。

组成函数的所有命令都像一般命令一样地执行。用简单命令名调用一个函数时,与该函数关联的命令列表即被执行。

如果函数是在当前 shell 被声明,它也会仅在当前 shell 执行,不会产生新进程。

在进行命令查找时,这些内建命令会先于函数被查找:

break:.continueevalexecexitexportreadonlyreturnsetshifttrapunset

11.1.1 函数的语法

function TEST { COMMANDS; } function 为内建命令

TEST () { COMMANDS; } 不使用 function 命令,就必须要使用括号

以上语法都可以定义函数 TEST。

括号左右都要有 空格

大括号中的命令列表构成了函数的主体,只要 TEST 作为命令名称被指定,这些命令就会执行。

函数主体必须以 分号换行符 结束。

退出状态以函数中最后一个命令为准。

11.1.2 函数中的位置参数

函数很像迷你的脚本,它们可以 接受参数、使用 内部变量,还可以向调用它的 shell 返回值

函数也会解析位置参数,但其位置参数与传递给命令或脚本的有些不同。

函数被执行时,传递给函数的参数成为位置参数。$# 扩展成位置参数的总个数,$0 不变,bash 变量 FUNCNAME 更新为该函数名称。

如果在函数中执行了 return 内建命令,则函数立即结束。返回继续执行调用该函数命令的下一个命令。

函数结束时,其位置参数的值与参数的个数被恢复成调用函数之前的值。

函数的返回值经常保存在变量里,以便之后可以引用。函数的返回值经常做为整个脚本的退出码来使用。如 exit $RETVAL

如果随 return 命令给了一个数字,则将其作为返回值。

范例:以下为脚本 showparams.sh 的内容:

echo "Positional parameter 1 for the script is $1."


test ()
{
echo "Positional parameter 1 in the function is $1."
RETURN_VALUE=$?
echo "The exit code of this function is $RETURN_VALUE."
}

test FUNCPARA

运行脚本以测试:

$ ./showparams.sh SCRPARA
Positional parameter 1 for the script is SCRPARA.
Positional parameter 1 in the function is FUNCPARA.
The exit code of this function is 0.

11.1.4 查看函数

所有当前 shell 已知的函数都可以通过 set 命令查看。这些函数通常在 启动配置文件 中定义。

默认情况下,函数被使用后仍然存在,除非用 unset 来取消定义。

which 可以查看具体的函数内容。

$ which zless
zless is a function
zless ()
{
    zcat "$@" | "$PAGER"
}

11.2 范例:脚本中的函数

11.2.1 循环使用

系统中有很多脚本是 把使用函数作为一个结构化的方法 来管理一系列的命令的。

例如,在某些 Linux 中,会使用 /etc/rc.d/init.d/functions 文件来专门定义函数,该文件会在所有初始化脚本中被调用。通过这种方法,类似于 “检查进程是否在运行、启动或暂停服务” 等比较通用的任务就只需编写一次就可以了,平时在需要时尽可循环使用。

用户可以创建自己的 /etc/functions 文件,把自己会用到的函数写到其中,使用时,只需在脚本的开始处加上 . /etc/functions 即可循环使用这些函数了。

11.2.2 设置路径

这个例子取自 /etc/profilepathmunge 函数用于为 root 和其他用户设置路径。

pathmunge () {
        if ! echo $PATH | /bin/egrep -q "(^|:)$1($|:)" ; then
           if [ "$2" = "after" ] ; then
              PATH=$PATH:$1
           else
              PATH=$1:$PATH
           fi
        fi
}

# Path manipulation
if [ `id -u` = 0 ]; then
        pathmunge /sbin
        pathmunge /usr/sbin
        pathmunge /usr/local/sbin
fi

pathmunge /usr/X11R6/bin after

unset pathmunge

该函数将其第一个参数做为路径名。如果在当前路径尚未加入 PATH,则会加进去。

第二个参数用来指定要加到 PATH 的前面还是后面。

如果是超级用户,则加入 4 个路径,如果是普通用户,只加入最后那个路径。

11.2.3 远程备份

本例使用 SSH 密钥来进行远程连接,定义了两个函数 buplinuxbupbash,各自都会生成一个 .tar 文件,然后会进一步压缩,发送给远程服务端。最后删除本地副本。

星期天的时候只执行 bupbash

#/bin/bash

LOGFILE="/nethome/tille/log/backupscript.log"
echo "Starting backups for `date`" >> "$LOGFILE"

buplinux()
{
DIR="/nethome/tille/xml/db/linux-basics/"
TAR="Linux.tar"
BZIP="$TAR.bz2"
SERVER="rincewind"
RDIR="/var/www/intra/tille/html/training/"

cd "$DIR"
tar cf "$TAR" src/*.xml src/images/*.png src/images/*.eps
echo "Compressing $TAR..." >> "$LOGFILE"
bzip2 "$TAR"
echo "...done." >> "$LOGFILE"
echo "Copying to $SERVER..." >> "$LOGFILE"
scp "$BZIP" "$SERVER:$RDIR" > /dev/null 2>&1
echo "...done." >> "$LOGFILE"
echo -e "Done backing up Linux course:\nSource files, PNG and EPS images.\nRubbish removed." >> "$LOGFILE"
rm "$BZIP"
}

bupbash()
{
DIR="/nethome/tille/xml/db/"
TAR="Bash.tar"
BZIP="$TAR.bz2"
FILES="bash-programming/"
SERVER="rincewind"
RDIR="/var/www/intra/tille/html/training/"

cd "$DIR"
tar cf "$TAR" "$FILES"
echo "Compressing $TAR..." >> "$LOGFILE"
bzip2 "$TAR"
echo "...done." >> "$LOGFILE"
echo "Copying to $SERVER..." >> "$LOGFILE"
scp "$BZIP" "$SERVER:$RDIR" > /dev/null 2>&1
echo "...done." >> "$LOGFILE"

echo -e "Done backing up Bash course:\n$FILES\nRubbish removed." >> "$LOGFILE"
rm "$BZIP"
}

DAY=`date +%w`

if [ "$DAY" -lt "2" ]; then
  echo "It is `date +%A`, only backing up Bash course." >> "$LOGFILE"
  bupbash
else
  buplinux
  bupbash
fi


echo -e "Remote backup `date` SUCCESS\n----------" >> "$LOGFILE"

把脚本交给 cron 来定期运行,无需用户干预,因此把标准错误重定向给 /dev/null

虽然完全可以压缩成一条命令:

tar c dir_to_backup/ | bzip2 | ssh server "cat > backup.tar.bz2"

但是,把命令分解会得到更详细的中间记录,有利于排错。

12. 捕捉信号

12.1 信号

12.1.1 信号简介

查阅信号帮助文档

一般的 Linux 系统都会有所有可用信号的帮助文档列表,多数情况可以用 man 7 signal 来查阅。

kill -l 可以查看所有信号的名字。

给 bash 发送的信号

如果交互式 bash shell 不包含任何的 trap 命令,则会忽略 SIGTERMSIGQUIT 信号。

如果启用了作业控制,会捕捉并处理 SIGINT 信号,忽略 SIGTTINSIGTTOUSIGTSTP

如果是键盘产生的信号,作为命令替换的结果来运行的命令,也会忽略它们。

SIGHUP 信号默认会 退出 shell。交互式 shell 会给所有运行和暂停的作业发送一个 SIGHUP 信号,如果希望为了某个进程而禁用该默认行为,可以查看 disown 命令的帮助文档。

shopt -s huponexit 可以实现收到 SIGHUP 信号时杀掉所有作业。

用 shell 发信号

用 bash shell 可以发送以下信号:

快捷键 作用
^C 中断信号,发送 SIGINT 给前台运行的作业
^Y 延迟休眠信号,运行的进程要从终端读取数据时会被暂停,把控制权交还 shell,然后用户可以把进程放到前台或后台运行,或杀掉。
^Z 休眠信号,给运行中进程发送 SIGTSTP 信号,将其暂停,把控制权交还 shell。

12.1.2 用 kill 发送信号

现今大多数 shell,包括 bash,都有 kill 这个内建命令。在 bash 中,信号的名称和编号都可以做为选项,可以把作业或进程 ID 做为参数。启用 -l 选项可以反馈退出状态,只要有一个信号成功发送就会返回 0,否则返回非 0。

如果以 /usr/bin/kill 绝对路径运行,可以启用一些额外的选项,如可以杀掉不属于你的进程,可以用进程的名称来做参数。

执行 kill 命令时,如果不指定信号,则默认会发送 SIGTERM 信号。

常用的信号:

信号名 编号 作用
SIGHUP 1 告诉进程其控制终端已关闭
SIGINT 2 中断进程
SIGKILL 9 杀掉进程
SIGTERM 15 优雅终止进程
SIGSTOP 17, 19, 23 暂停进程

更多信号的解释

杀掉一个或一系列进程时,普通的认知是先用危险性最小的信号 SIGTERM 来尝试。于是,那些比较在意 按特定顺序关闭 的程序,收到 SIGTERM 信号时,就有机会按照其设计的流程有序地执行。比如 清除临时文件、关闭打开的文件 等操作。

反之,如果直接给进程发送 SIGKILL 信号,就会剥夺了它清除临时文件再关闭的机会,有可能会造成不可预期的后果。

如果用 SIGTERM 信号无法优雅地结束进程,也许只能用 SIGINTSIGKILL 了。例如,当进程对 ^C 没有响应时,最好用 kill -9 来结束。

$ ps -ef | grep stuck_process
maud    5607   2214  0 20:05 pts/5    00:00:02 stuck_process

$ kill -9 5607

$ ps -ef | grep stuck_process
maud    5614    2214 0 20:15 pts/5    00:00:00 grep stuck_process

如果同一个进程启动了多个实例,更适合用 killall 来干掉。它会作用于指定进程的所有实例。

12.2 Trap

12.2.1 trap 简介

在某些情况下,你可能不希望使用脚本的用户过早地用快捷键退出,例如因为要接受一些输入,或清理临时文件等。trap 语句会捕捉这些信号,并可以设计成根据捕捉到的信号执行不同的命令。

trap [COMMANDS] [SIGNALS]

trap 会捕捉 SIGNALS 信号,信号的表示可以用标准的信号名称或编号。

  • 如果信号是 EXIT 或 0,shell 退出时会执行 COMMANDS。
  • 如果其中一个信号是 DEBUG,则 COMMANDS 会在每个简单命令之后执行。
  • 如果信号是 ERR,则每当一个简单命令的退出状态为非 0 时才会执行 COMMANDS。
  • 如果非 0 的退出状态是从 ifwhileuntil 语句内部得到的,COMMANDS 不会执行。
  • 如果逻辑与 && 或逻辑或 || 得到的是非 0 退出状态,COMMANDS 也不会执行。

trap 命令自身的退出状态是 0,除非指定了无效的信号。

脚本中可以有多个 trap ,可以为不同的信号定义不同的行为,也可以修改、删除已定义的 trap 。每个 trap 也有其作用范围,如果把它放在函数中,它将只在这个函数里有效。

12.2.2 EXIT

虽然 EXIT 通常被当作信号来使用,但实际上它不是真正意义上的信号。

一个针对 EXIT 定义的 trap 会在 脚本因任何原因退出 时被执行。

EXIT 这个特殊的名字是 POSIX 定义的,可以被任何信号处理器解析,其作用仅仅是在退出脚本时进行一些 清理 工作,它不会做复杂的操作。

要想退出前进行清理,只需针对 EXIT 定义一个 trap,调用一个清理函数。虽然 bash 支持,也不要一次 trap 太多信号,其它 shell 中,只有在其它信号都无法促成退出时才会使用它。

警告:EXIT 只支持 非交互式 shell,即脚本,在交互式 shell 中是不会被调用的。

12.2.3 trap 是捕捉信号的时机

当 bash 收到一个被 trap 定义的信号时,如果当前正有一个外部命令在 前台 执行,那么 trap 会等待当前命令 结束以后 再处理信号队列中的信号。如果 bash 正通过 wait 命令在等待一个 异步 命令,则该信号会促使 wait 立即返回,退出状态会大于 128。

外部命令前台运行完才处理信号

当 bash 在 前台 执行一个外部命令时,直到 进程终止,它才会处理接收到的信号。

trap 'echo "doing some cleaning"' EXIT
echo "waiting a bit"
sleep 10000

该脚本执行后,如果在终端上直接按 ^C 会立即退出脚本。

但,如果登陆另一终端,用 kill -SIGINT pid 来尝试终止该进程,bash 会一直等到 sleep 10000 运行完毕,才肯调用 trap 退出脚本,这可能不是你希望看到的结果。

【 为什么同一信号会出现不同的结果?】 使用 ^C 按键组合会向进程组发送 SIGINT 信号,因此在同一个进程组中的子进程 sleep 也会收到信号,所以它会退出,返回主进程后 trap 捕捉到了信号,脚本退出。 而使用 kill -SIGINT pid 只会针对脚本这个进程发信号,需要等到 sleep 这个外部命令运行完毕,才能捕捉到信号。

针对这个问题,一个解决办法是把 sleep 切割成 1 秒的 小 sleep 循环

trap 'echo "doing some cleaning"' EXIT
echo "waiting a bit"
while :
do
	sleep 1
done

这样一来,脚本对信号做出反应的机会就大了很多。

内部命令立即处理信号

所有的 bash 内部命令 都会被非忽略的信号所中断。

若想实现对信号能立即做出反应,另一个解决办法是使用可被中断的内建命令,比如 wait

trap 'echo "doing some cleaning"' EXIT
echo waiting a bit
sleep 10000 & wait $!

$! 扩展为新放入后台的进程 ID。把 sleep 放到后台运行,再用内建的 wait 去等待其执行结束。

注意:虽然这样一来 ^C 终于可以让脚本退出了,但此时 sleep 10000 还在后台运行呢,不会被杀掉,而是继续运行。如果希望脚本被终止时,sleep 这样的 后台作业也能够被杀掉

pid=
trap '[[ $pid ]] && kill $pid' EXIT
sleep 10000 & pid=$!
wait
pid=

这样,当脚本无论因何退出时,不仅会立即退出,而且会终止后台运行的 sleep 作业。

如果不喜欢 wait,还可以使用 read。可以通过从一个永远不会给出任何数据的 管道 中读取数据,来实现一个 无休止的睡眠read 会一直阻塞下去,无需像外部命令 sleep 一样还得持续跟踪。

trap 'echo "we get signal"; rm -f ~/fifo' EXIT
mkfifo ~/fifo || exit
chmod 400 ~/fifo
echo "sleeping"
read < ~/fifo

mkfifo 创建临时管道,然后将其重定向给标准输入,交给 read。退出时自动删除临时管道。

12.2.4 SIGINT 注意事项

如果选择不用 EXIT 来 trap,而是用 SIGINT,应该要注意的是,进程收到该信号退出时,应该把自己杀掉,而不是单纯的退出,以防止对其调用者造成问题。

trap 'rm -f "$tempfile"; trap - INT; kill -INT $$' INT

rm 删除临时文件,trap - INT 恢复 INT 默认动作,kill 杀掉自己。

12.2.5 其它范例

检测变量什么时候被使用了

在调试比较长的脚本时,可能会需要给变量赋予 trace 属性,以便追踪该变量的调试信息。

declare -t VARIABLE=value

trap "echo VARIABLE is being used here." DEBUG
退出时删除垃圾文件
LOCKFILE=/var/lock/makewhatis.lock

[ -f $LOCKFILE ] && exit 0

trap "{ rm -f $LOCKFILE ; exit 255; }" EXIT

touch $LOCKFILE
makewhatis -u -w
exit 0

……….