Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it. | |
Brian Kernighan |
Bash shell 没有自带调试器, 甚至没有任何调试类型的命令或结构. [1] 脚本里的语法错误或拼写错误会产生含糊的错误信息,通常这些在调试非功能性的脚本时没什么帮助.
例子 29-1. 一个错误的脚本
1 #!/bin/bash 2 # ex74.sh 3 4 # 这是一个错误的脚本. 5 # 哪里有错? 6 7 a=37 8 9 if [$a -gt 27 ] 10 then 11 echo $a 12 fi 13 14 exit 0 |
脚本的输出:
./ex74.sh: [37: command not found |
例子 29-2. 丢失关键字(keyword)
1 #!/bin/bash 2 # missing-keyword.sh: 会产生什么样的错误信息? 3 4 for a in 1 2 3 5 do 6 echo "$a" 7 # done # 第7行的必需的关键字 'done' 被注释掉了. 8 9 exit 0 |
脚本的输出:
missing-keyword.sh: line 10: syntax error: unexpected end of file |
出错信息可能在报告语法错误的行号时会忽略脚本的注释行.
如果脚本可以执行,但不是你所期望的那样工作怎么办? 这大多是由于常见的逻辑错误产生的.
例子 29-3. test24, 另一个错误脚本
1 #!/bin/bash 2 3 # 这个脚本目的是为了删除当前目录下的所有文件,包括文件名含有空格的文件。 4 # 5 # 但不能工作. 6 # 为什么? 7 8 9 badname=`ls | grep ' '` 10 11 # 试试这个: 12 # echo "$badname" 13 14 rm "$badname" 15 16 exit 0 |
为了找出 例子 29-3 的错误可以把echo "$badname" 行的注释去掉. echo 出来的信息对你判断是否脚本以你希望的方式运行时很有帮助.
在这个实际的例子里, rm "$badname" 不会达到想要的结果,因为$badname 没有引用起来. 加上引号以保证rm 命令只有一个参数(这就只能匹配一个文件名). 一个不完善的解决办法是删除A partial fix is to remove to quotes from $badname and to reset $IFS to contain only a newline, IFS=$'\n'. 不过, 存在更简单的办法.
1 # 修正删除包含空格文件名时出错的办法. 2 rm *\ * 3 rm *" "* 4 rm *' '* 5 # Thank you. S.C. |
总结该脚本的症状,
它能运行,运行的和期望的一样, 但有讨厌的副作用 (逻辑炸弹).
用来调试不能工作的脚本的工具包括
echo 语句可用在脚本中的有疑问的点上以跟踪了解变量的值, 并且也可以了解后续脚本的动作.
最好只在调试时才使用echo语句.
|
使用 tee 过滤器来检查临界点的进程或数据流.
设置选项 -n -v -x
sh -n scriptname 不会实际运行脚本,而只是检查脚本的语法错误. 这等同于把 set -n 或 set -o noexec 插入脚本中. 注意还是有一些语法错误不能被这种检查找出来.
sh -v scriptname 在实际执行一个命令前打印出这个命令. 这也等同于在脚本里设置 set -v 或 set -o verbose.
选项 -n 和 -v 可以一块使用. sh -nv scriptname 会打印详细的语法检查.
sh -x scriptname 打印每个命令的执行结果, 但只用在某些小的方面. 它等同于脚本中插入 set -x 或 set -o xtrace.
把 set -u 或 set -o nounset 插入到脚本里并运行它, 就会在每个试图使用没有申明过的变量的地方打印出一个错误信息.
使用一个"assert"(断言) 函数在脚本的临界点上测试变量或条件. (这是从C语言中借用来的.)
例子 29-4 用"assert"测试条件
1 #!/bin/bash 2 # assert.sh 3 4 assert () # 如果条件测试失败, 5 { #+ 则打印错误信息并退出脚本. 6 E_PARAM_ERR=98 7 E_ASSERT_FAILED=99 8 9 10 if [ -z "$2" ] # 没有传递足够的参数. 11 then 12 return $E_PARAM_ERR # 什么也不做就返回. 13 fi 14 15 lineno=$2 16 17 if [ ! $1 ] 18 then 19 echo "Assertion failed: \"$1\"" 20 echo "File \"$0\", line $lineno" 21 exit $E_ASSERT_FAILED 22 # else 23 # return 24 # 返回并继续执行脚本后面的代码. 25 fi 26 } 27 28 29 a=5 30 b=4 31 condition="$a -lt $b" # 会错误信息并从脚本退出. 32 # 把这个“条件”放在某个地方, 33 #+ 然后看看有什么现象. 34 35 assert "$condition" $LINENO 36 # 脚本以下的代码只有当"assert"成功时才会继续执行. 37 38 39 # 其他的命令. 40 # ... 41 echo "This statement echoes only if the \"assert\" does not fail." 42 # ... 43 # 余下的其他命令. 44 45 exit 0 |
捕捉exit.
脚本中的The exit 命令会触发信号0,终结进程,即脚本本身. [2] 这常用来捕捉exit命令做某事, 如强制打印变量值. trap 命令必须是脚本中第一个命令.
例子 29-5. 捕捉 exit
1 #!/bin/bash 2 # 用trap捕捉变量值. 3 4 trap 'echo Variable Listing --- a = $a b = $b' EXIT 5 # EXIT 是脚本中exit命令产生的信号的信号名. 6 # 7 # 由"trap"指定的命令不会被马上执行,只有当发送了一个适应的信号时才会执行。 8 # 9 10 echo "This prints before the \"trap\" --" 11 echo "even though the script sees the \"trap\" first." 12 echo 13 14 a=39 15 16 b=36 17 18 exit 0 19 # 注意到注释掉上面一行的'exit'命令也没有什么不同, 20 #+ 这是因为执行完所有的命令脚本都会退出. |
例子 29-6. 在Control-C后清除垃圾
1 #!/bin/bash 2 # logon.sh: 简陋的检查你是否还处于连线的脚本. 3 4 umask 177 # 确定临时文件不是全部用户都可读的. 5 6 7 TRUE=1 8 LOGFILE=/var/log/messages 9 # 注意 $LOGFILE 必须是可读的 10 #+ (用 root来做:chmod 644 /var/log/messages). 11 TEMPFILE=temp.$$ 12 # 创建一个"唯一的"临时文件名, 使用脚本的进程ID. 13 # 用 'mktemp' 是另一个可行的办法. 14 # 举例: 15 # TEMPFILE=`mktemp temp.XXXXXX` 16 KEYWORD=address 17 # 上网时, 把"remote IP address xxx.xxx.xxx.xxx"这行 18 # 加到 /var/log/messages. 19 ONLINE=22 20 USER_INTERRUPT=13 21 CHECK_LINES=100 22 # 日志文件中有多少行要检查. 23 24 trap 'rm -f $TEMPFILE; exit $USER_INTERRUPT' TERM INT 25 # 如果脚本被control-c中断了,则清除临时文件. 26 27 echo 28 29 while [ $TRUE ] #死循环. 30 do 31 tail -$CHECK_LINES $LOGFILE> $TEMPFILE 32 # 保存系统日志文件的最后100行到临时文件. 33 # 这是需要的, 因为新版本的内核在登录网络时产生许多日志文件信息. 34 search=`grep $KEYWORD $TEMPFILE` 35 # 检查"IP address" 短语是不是存在, 36 #+ 它指示了一次成功的网络登录. 37 38 if [ ! -z "$search" ] # 引号是必须的,因为变量可能会有一些空白符. 39 then 40 echo "On-line" 41 rm -f $TEMPFILE # 清除临时文件. 42 exit $ONLINE 43 else 44 echo -n "." # -n 选项使echo不会产生新行符, 45 #+ 这样你可以从该行的继续打印. 46 fi 47 48 sleep 1 49 done 50 51 52 # 注: 如果你更改KEYWORD变量的值为"Exit", 53 #+ 这个脚本就能用来在网络登录后检查掉线 54 # 55 56 # 练习: 修改脚本,像上面所说的那样,并修正得更好 57 # 58 59 exit 0 60 61 62 # Nick Drage 建议用另一种方法: 63 64 while true 65 do ifconfig ppp0 | grep UP 1> /dev/null && echo "connected" && exit 0 66 echo -n "." # 在连接上之前打印点 (.....). 67 sleep 2 68 done 69 70 # 问题: 用 Control-C来终止这个进程可能是不够的. 71 #+ (点可能会继续被打印.) 72 # 练习: 修复这个问题. 73 74 75 76 # Stephane Chazelas 也提出了另一个办法: 77 78 CHECK_INTERVAL=1 79 80 while ! tail -1 "$LOGFILE" | grep -q "$KEYWORD" 81 do echo -n . 82 sleep $CHECK_INTERVAL 83 done 84 echo "On-line" 85 86 # 练习: 讨论这几个方法的优缺点. 87 # |
当然, trap 命令除了调试还有其他的用处.
例子 29-8. 运行多进程 (在多处理器的机器里)
1 #!/bin/bash 2 # parent.sh 3 # 在多处理器的机器里运行多进程. 4 # 作者: Tedman Eng 5 6 # 这是要介绍的两个脚本的第一个, 7 #+ 这两个脚本都在要在相同的工作目录下. 8 9 10 11 12 LIMIT=$1 # 要启动的进程总数 13 NUMPROC=4 # 当前进程数 (forks?) 14 PROCID=1 # 启动的进程ID 15 echo "My PID is $$" 16 17 function start_thread() { 18 if [ $PROCID -le $LIMIT ] ; then 19 ./child.sh $PROCID& 20 let "PROCID++" 21 else 22 echo "Limit reached." 23 wait 24 exit 25 fi 26 } 27 28 while [ "$NUMPROC" -gt 0 ]; do 29 start_thread; 30 let "NUMPROC--" 31 done 32 33 34 while true 35 do 36 37 trap "start_thread" SIGRTMIN 38 39 done 40 41 exit 0 42 43 44 45 # ======== 下面是第二个脚本 ======== 46 47 48 #!/bin/bash 49 # child.sh 50 # 在多处理器的机器里运行多进程. 51 # 这个脚本由parent.sh脚本调用(即上面的脚本). 52 # 作者: Tedman Eng 53 54 temp=$RANDOM 55 index=$1 56 shift 57 let "temp %= 5" 58 let "temp += 4" 59 echo "Starting $index Time:$temp" "$@" 60 sleep ${temp} 61 echo "Ending $index" 62 kill -s SIGRTMIN $PPID 63 64 exit 0 65 66 67 # ======================= 脚本作者注 ======================= # 68 # 这不是完全没有bug的脚本. 69 # 我运行LIMIT = 500 ,在过了开头的一二百个循环后, 70 #+ 这些进程有一个消失了! 71 # 不能确定是不是因为捕捉信号产生碰撞还是其他的原因. 72 # 一但信号捕捉到,在下一个信号设置之前, 73 #+ 会有一个短暂的时间来执行信号处理程序, 74 #+ 这段时间内很可能会丢失一个信号捕捉,因此失去生成一个子进程的机会. 75 76 # 毫无疑问会有人能找出这个bug的原因,并且修复它 77 #+ . . . 在将来的某个时候. 78 79 80 81 # ===================================================================== # 82 83 84 85 # ----------------------------------------------------------------------# 86 87 88 89 ################################################################# 90 # 下面的脚本由Vernia Damiano原创. 91 # 不幸地是, 它不能正确工作. 92 ################################################################# 93 94 #!/bin/bash 95 96 # 必须以最少一个整数参数来调用这个脚本 97 #+ (这个整数是协作进程的数目). 98 # 所有的其他参数被传给要启动的进程. 99 100 101 INDICE=8 # 要启动的进程数目 102 TEMPO=5 # 每个进程最大的睡眼时间 103 E_BADARGS=65 # 没有参数传给脚本的错误值. 104 105 if [ $# -eq 0 ] # 检查是否至少传了一个参数给脚本. 106 then 107 echo "Usage: `basename $0` number_of_processes [passed params]" 108 exit $E_BADARGS 109 fi 110 111 NUMPROC=$1 # 协作进程的数目 112 shift 113 PARAMETRI=( "$@" ) # 每个进程的参数 114 115 function avvia() { 116 local temp 117 local index 118 temp=$RANDOM 119 index=$1 120 shift 121 let "temp %= $TEMPO" 122 let "temp += 1" 123 echo "Starting $index Time:$temp" "$@" 124 sleep ${temp} 125 echo "Ending $index" 126 kill -s SIGRTMIN $$ 127 } 128 129 function parti() { 130 if [ $INDICE -gt 0 ] ; then 131 avvia $INDICE "${PARAMETRI[@]}" & 132 let "INDICE--" 133 else 134 trap : SIGRTMIN 135 fi 136 } 137 138 trap parti SIGRTMIN 139 140 while [ "$NUMPROC" -gt 0 ]; do 141 parti; 142 let "NUMPROC--" 143 done 144 145 wait 146 trap - SIGRTMIN 147 148 exit $? 149 150 : <<SCRIPT_AUTHOR_COMMENTS 151 我需要运行能指定选项的一个程序, 152 能接受许多不同的文件,并在一个多处理器的机器上运行 153 所以我想(我也将会)使指定数目的进程运行,并且每个进程终止后都能启动一个新的 154 155 156 "wait"命令没什么帮助, 因为它是等候一个指定的或所有的后台进程. 157 所以我写了这个使用了trap指令的bash脚本来做这个任务. 158 159 --Vernia Damiano 160 SCRIPT_AUTHOR_COMMENTS |
trap '' SIGNAL (两个引号引空) 在脚本中禁用了 SIGNAL 信号的动作(即忽略了). trap SIGNAL 则恢复了 SIGNAL 信号前次的处理动作. 这在保护脚本的某些临界点的位置不受意外的中断影响时很有用. |
1 trap '' 2 # 信号 2是 Control-C, 现在被忽略了. 2 command 3 command 4 command 5 trap 2 # 再启用Control-C 6 |
[1] | Rocky Bernstein的 Bash debugger 实际上填补了这个空白. |
[2] | 依据惯例,信号0 被指定为退出(exit). |