第29章. 调试

 

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
上面的脚本有什么错误(线索: 注意if的后面)?


例子 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
 	
注意错误信息中说明的错误行不必一定要参考, 但那行是Bash解释器最终认识到是个错误的地方.

出错信息可能在报告语法错误的行号时会忽略脚本的注释行.

如果脚本可以执行,但不是你所期望的那样工作怎么办? 这大多是由于常见的逻辑错误产生的.


例子 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.

总结该脚本的症状,

  1. 终止于一个"syntax error"(语法错误)的信息, 或

  2. 它能运行, 但不是按期望的那样运行(逻辑错误).

  3. 它能运行,运行的和期望的一样, 但有讨厌的副作用 (逻辑炸弹).

用来调试不能工作的脚本的工具包括

  1. echo 语句可用在脚本中的有疑问的点上以跟踪了解变量的值, 并且也可以了解后续脚本的动作.

    Tip

    最好只在调试时才使用echo语句.
       1 ### debecho (debug-echo), by Stefano Falsetto ###
       2 ### 只有变量 DEBUG 设置了值时才会打印传递进来的变量值. ###
       3 debecho () {
       4   if [ ! -z "$DEBUG" ]; then
       5      echo "$1" >&2
       6      #         ^^^ 打印到标准出错
       7   fi
       8 }
       9 
      10 DEBUG=on
      11 Whatever=whatnot
      12 debecho $Whatever   # whatnot
      13 
      14 DEBUG=
      15 Whatever=notwhat
      16 debecho $Whatever   # (这儿就不会打印了.)

  2. 使用 tee 过滤器来检查临界点的进程或数据流.

  3. 设置选项 -n -v -x

    sh -n scriptname 不会实际运行脚本,而只是检查脚本的语法错误. 这等同于把 set -nset -o noexec 插入脚本中. 注意还是有一些语法错误不能被这种检查找出来.

    sh -v scriptname 在实际执行一个命令前打印出这个命令. 这也等同于在脚本里设置 set -vset -o verbose.

    选项 -n-v 可以一块使用. sh -nv scriptname 会打印详细的语法检查.

    sh -x scriptname 打印每个命令的执行结果, 但只用在某些小的方面. 它等同于脚本中插入 set -xset -o xtrace.

    set -uset -o nounset 插入到脚本里并运行它, 就会在每个试图使用没有申明过的变量的地方打印出一个错误信息.

  4. 使用一个"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

  5. 用变量$LINENO和内建的caller.

  6. 捕捉exit.

    脚本中的The exit 命令会触发信号0,终结进程,即脚本本身. [2] 这常用来捕捉exit命令做某事, 如强制打印变量值. trap 命令必须是脚本中第一个命令.

捕捉信号

trap

当收到一个信号时指定一个处理动作; 这在调试时也很有用.

Note

信号是发往一个进程的非常简单的信息, 要么是由内核发出要么是由另一个进程, 以告诉接收进程采取一些指定的动作 (一般是中止). 例如, 按Control-C, 发送一个用户中断( 即 INT 信号)到运行中的进程.

   1 trap '' 2
   2 # 忽略信号 2 (Control-C), 没有指定处理动作. 
   3 
   4 trap 'echo "Control-C disabled."' 2
   5 # 当按 Control-C 时显示一行信息.


例子 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 #

Note

trapDEBUG参数在每个命令执行完后都会引起一个指定的执行动作,例如,这可用来跟踪变量。.


例子 29-7. 跟踪变量

   1 #!/bin/bash
   2 
   3 trap 'echo "VARIABLE-TRACE> \$variable = \"$variable\""' DEBUG
   4 # 在每个命令行显示变量$variable 的值.
   5 
   6 variable=29
   7 
   8 echo "Just initialized \"\$variable\" to $variable."
   9 
  10 let "variable *= 3"
  11 echo "Just multiplied \"\$variable\" by 3."
  12 
  13 exit $?
  14 
  15 #  "trap 'command1 . . . command2 . . .' DEBUG" 的结构适合复杂脚本的环境
  16 #+ 在这种情况下多次"echo $variable"比较没有技巧并且也耗时.
  17 #
  18 #
  19 
  20 # Thanks, Stephane Chazelas 指出这一点.
  21 
  22 
  23 脚本的输出:
  24 
  25 VARIABLE-TRACE> $variable = ""
  26 VARIABLE-TRACE> $variable = "29"
  27 Just initialized "$variable" to 29.
  28 VARIABLE-TRACE> $variable = "29"
  29 VARIABLE-TRACE> $variable = "87"
  30 Just multiplied "$variable" by 3.
  31 VARIABLE-TRACE> $variable = "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" "[email protected]"
  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=( "[email protected]" )      # 每个进程的参数
 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" "[email protected]"
 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


Note

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).