我们希望已经使你相信bash可被用做一种重要的UNIX编程环境。它有足够的特性、控制结构等。但编程环境的另一基本部分是功能强大的集成支持工具。例如,对于C和C++这样的语言有广泛的屏幕编辑器、编译器、调试器、配置器、交叉引用器等。如果以这些语言编程,采用这些工具是理所应当的,而决不会求助于诸如ed编辑器和adb机器语言调试器来开发代码。
bash的编程支持工具是什么呢?当然,可以使用类似vi和emacs的编辑器,而且因为shell是一种解释性语言,所以不需要编译器。但没有其他可用的工具了。
本章介绍用来调试shell程序的可用特性。在本章的第一部分我们会介绍如何使用它们。然后会介绍一些bash的强大的新特性,它们在大多数Bourne shell软件中是没有的,它们有助于构建shell脚本调试工具。本章最后给出了建立一个bash调试器的步骤。该调试器称为bashdb,是一个基本但功能强大的程序,不但可用做各种shell编程技术的扩展实例,也是一个可以检验shell脚本工作的可用工具。
**基本调试助手
调试一个程序需要什么功能?从经验上讲,需要判断程序出错的原因,即代码中出错的位置。通常开始要给出一个明显的信息(如一个错误信息、不恰当的输出、无限循环等),然后不断的向后追溯,直至你发现和实际问题最接近的原因(例如,变量取值不对,命令选项错误),并最终到达程序中出错的位置,再找出办法加以解决。
注意,这些步骤表示的是开始时给出一个明显信息,最后给出由推论和直觉得到的模糊事实。调试助手通过给出相关信息轻易甚至是自动的得出结论,而不需要修改你的代码。
在shell中最简单的调试助手(对任何语言)是输出语句echo。实际上,老一代的程序员通过在代码中插入WRITE语句调试他们的FORTRAN代码。可以通过把许多echo语句放到代码中进行调试(以后再删除它们),但必须花费足够的时间以定位要查看的信息。可能必须通过许多的输出才能发现要查找的信息。
**set选项
幸好,shell有一些基本特性提供了除echo之外的调试功能。最基本的是set -o命令的选项(第三章中介绍)。当运行脚本时,这些选项可以用在命令行上,如表9-1所示。
表9-1 调试选项
set -o选项 命令行选项 行为
noexec -n 不运行命令,只检查语法错误
verbose -v 在运行命令前回送它们
xtrace -x 在命令行处理后回送命令
verbose选项只回送(到标准错误)shell得到的输入内容。在查找脚本运行出错的精确位置时,它非常有用。例如,假定脚本如下:
alice
hatter
march
teatime
treacle
well
这些命令都不是标准的UNIX程序,都不输出内容。比如脚本出错时回送一个模糊信息“段冲突”,它们不会告诉你哪一命令引起了错误。如果键入bash -v scriptname,会看到如下内容:
alice
hatter
march
segmentation violation
teatime
treacle
well
现在知道可能出错的是march——但可能march出错的原因是因为alice或hatter执行的动作(例如创建一个输入文件)不正确所引起的。
xtrace选项功能更加强大:它将回送经过参数替换、命令替换和其他命令行处理(在第七章)步骤后得到的命令行结果。例如:
$ set -o xtrace
$ alice=girl
+ alice=girl
$ echo "$alice"
+ echo girl
girl
$ ls -l $(type -path vi)
++ type -path vi
+ ls -F -l /usr/bin/vi
lrwxrwxrwx 1 root root 5 Jul 26 20:59 /usr/bin/vi -> elvis*
$
正如所见,xtrace在每一行开始都打印+(每个加号表示扩展一个层次),这实际上是可以定制的:它是内置shell变量PS4的值。如果要将PS4设置为"xtrace—>"(例如,在.bash_profile或.bashrc中),那么就会得到如下的xtrace列表:
$ ls -l $(type -path vi)
xxtrace--> type -path vi
xtrace—> ls -l /usr/bin/vi
lrwxrwxrwx 1 root root 5 Jul 26 20:59 /usr/bin/vi -> elvis*
$
注意,对于多层扩展,只打印PS4的第一个字符,这增加了输出的可读性。
定制PS4的一种更好的方式是使用我们还没有介绍的内置变量:LINENO。它保存shell脚本中当前运行行的编号。把下面这一行放入你的.bash_profile或环境文件中:
PS4='line $LINENO: '
这里使用与我们在第三章中对PS1使用的相同的技术:使用单引号延迟对字符串的求值,使其直到shell打印提示符时才进行。这会把形式line N:的信息打印到所跟踪的输出中。甚至可以把正在调试的shell脚本名包含在该提示符下,只要使用位置参数$0:
PS4='$0 line $LINENO: '
另一个例子假设你试图跟踪脚本alice中的故障,其代码为:
dbfmq=$1.fmq
...
fndrs=$(cut -f3 -d' ' $dfbmq)
键入alice teatime以正常方式运行它,它会挂起,然后键入bash -x alice teatime,你会看到:
+ dbfmq=teatime.fmq
...
+ + cut -f3 -d
在该位置它再次挂起,注意cut没有文件名参数,这表明变量dbfmq一定有些错误。但它已经正确执行了赋值语句dbfmq=teatime.fmq,那就一定是命令替换结构中变量名敲错了。解决它后,脚本运行正确。
最后的选项是noexec,它读取shell脚本,检查语法错误,但不执行任何内容。如果脚本的语法很复杂(包括许多循环、命令块、字符串操作符等)并且故障有很大的副作用(如创建一个大的文件或挂起系统),则它就非常有用了。
可以在shell脚本中使用set -o option打开这些选项,并像第三章介绍的,使用set +o option关闭这些选项。例如,如果你正在调试一个代码块,就可以在该代码块前面加上set -o xtrace打印执行的命令,并在结尾处加上set +o xtrace。
然而要注意,一旦打开了noexec,就不可能再关闭它;set +o noexec再也不被执行。
**伪信号
调试助手的一个高级集合是shell的“伪信号”。它用在trap语句中,使shell在一定条件下执行动作。上一章曾介绍过,trap允许你安装在特定信号发送到脚本时运行的代码。
伪信号的工作方式一样,但它们由shell本身产生,与其他外部产生的信号相反。它们表示可能对调试器(无论是自己编写的,还是现成的软件工具)有用的运行时事件,可以像对待shell脚本里的实际信号一样对待它。表9-2列出bash中可用的两种伪信号。
表9-2 伪信号
伪信号 发送时间
EXIT shell从脚本中退出后
DEBUG shell已经执行了一个语句
**EXIT
当发送EXIT陷阱时,运行其代码使设置该信号的脚本退出。
下面是一个简单例子:
trap 'echo exiting from the script' EXIT
echo 'start of the script'
如果运行该脚本,输出如下:
start of the script
exiting from the script
换句话说,脚本由对自己的退出设置陷阱开始,然后打印信息。接着脚本退出,使得shell生成信号EXIT,随后它运行代码echo exiting from the script。
无论脚本退出方式如何,EXIT都会发生——或者是正常情况(执行完最后一条语句),或通过显式的exit或return语句,或通过收到如INT或TERM的信号。考虑下面的猜数程序:
trap 'echo Thank you for playing!' EXIT
magicnum=$(($RANDOM%10+1))
echo 'Guess a number between 1 and 10:'
while read -p 'Guess: ' guess ; do
sleep 4
if [ "$guess" = $magicnum ]; then
echo 'Right!'
exit
fi
echo 'Wrong!'
done
该程序通过抽取一个随机数(内置变量RANDOM),除以10得到最后一位,再加1。这样挑选出1到10之间的一个数,然后提示你进行猜测,4秒钟后,它会告诉你是否猜对了。
如果你猜对了,程序退出,并给出信息"Thank you for playing!",即运行EXIT陷阱代码。如果你错了,它会提示你再次重复该过程,直到你猜对为止。如果在等待它告诉你是否猜对时感到厌烦,可以键入CTRL-C或CTRL-D,将出现前面所说的消息。
EXIT陷阱在脚本要退出时打印变量值时非常有用。例如,通过打印循环计数变量取值,你可以发现在一个带有许多嵌套for循环的复杂脚本中启用xtrace或加入调试输出的合适位置。
**DEBUG
另一个伪信号DEBUG,用来在一个函数或脚本内所有语句后执行陷阱代码,它有两个主要用途,第一个是用来作为一种跟踪错误的程序状态元素的强制性方法。
例如,你注意到一个特定变量的值在运行时变化很乱,可以想到的方法是在几个位置键入许多echo语句检查变量值。使用DEBUG可以很容易的实现,方式是:
function dbgtrap
{
echo "badvar is badvar"
}
trap dbgtrap DEBUG
...section of code in which the problem occurs...
trap - DEBUG # turn off the DEBUG trap
该代码会在两个traps之间每个语句后打印该变量的取值。
使用DEBUG时值得注意的重要一点是它不能被设置它的shell所调用的函数继承。换句话说,如果你的shell设置了DEBUG陷阱,然后调用一个函数,函数内的语句不会执行该陷阱。如果要使用它,则必须显式的在函数内设置DEBUG陷阱。
DEBUG信号第二个更为重要的用途是作为实现bash调试器的原语。实际上,公平的讲,无论是实现一个大规模的软件开发项目,还是一个可管理的练习程序,DEBUG都可以缩减shell调试的任务量。
**一个bash调试器
这一节我们开发一个基本的bash调试器。大多数调试器都具有大量的高级特性,可以帮助程序员分析程序,但基本都包括按步执行一个程序,在选定位置停止程序以及检查变量值的功能。这些简单的特性是我们这里的调试器集中要提供的功能。我们会特别提供如下功能:
·指定停止执行的程序位置。这里称为断点。
·执行程序中指定数目的语句。这里称为步进。
·检查和改变程序执行期间的状态。其中包括在某一断点停止程序时或步进后打印变量值和改变它们的功能。
·打印调试的源代码,同时指出断点位置以及在当前执行的程序中的行数。
·在不改变我们希望调试的程序源代码的情况下,提供调试程序的功能。
正如所见,通过前面几章介绍的结构和方法可以很容易实现上述所有功能。
**调试器的结构
bashdb调试器的工作方式是针对一个shell脚本,把其放在调试器本身内执行。实现方式是把调试器功能和我们称之为试验脚本的目标脚本结合起来,把其保存在另一文件中,然后再执行。该过程对用户是透明的——用户不知道执行的代码实际上是其脚本的已修改版本。
bash调试器有三个主要部分:驱动器、前导部分和调试器函数。
**驱动器脚本
驱动器脚本负责设置一切内容,该脚本称为bashdb,内容如下:
# bashdb - bash调试器
# 驱动程序脚本:拼接前导部分的目标脚本,然后执行这个新脚本
echo 'bash Debugger version 1.0'
_dbname=${0##*/}
if (( $# < 1 )) ; then
echo "$_dbname: Usage: $_dbname filename" >&2
exit 1
fi
_guineapig=$1
if [ ! -r $1 ]; then
echo "$_dbname: Cannot read file '$_guineapig'." >&2
exit 1
fi
shift
_tmpdir=/tmp
_libdir=.
_debugfile=$_tmpdir/bashdb.$$ # 正在被调试的脚本的临时文件
cat $_libdir/bashdb.pre $_guineapig > $_debugfile
exec bash $_debugfile $_guineapig $_tmpdir $_libdir "$@"
bashdb接受试验文件名作为第一个参数。后面的任意参数被传递给试验作为其位置参数。
如果没有给出参数,bashdb打印一个用法行,退出,并带有一个错误退出状态。否则,它检查文件是否存在,如果不存在,退出,然后bashdb打印信息,退出,并带有错误状态。如果都没问题,bashdb以上一章介绍的方式构建一个临时文件。如果系统上没有(或不能访问)/tmp,则可将_tmpdir替换为其他目录。变量_libdir是包含bashdb所需文件的目录名(bashdb.pre和bashdb.fns)。如果你要将bashdb安装在系统上所有人都可以使用的位置,则其位置可以为/usr/lib。
cat语句构建试验文件的已修改副本:它包含bashdb.pre中的脚本(下面会介绍),后跟试验文件的副本。
**exec
最后一行使用exec运行新创建的脚本,我们还没有讨论过exec。我们之所以一直没有介绍它,是因为——你也将会同意——它很危险。exec把其参数作为一个命令行,并在同一进程中运行该命令替换当前程序。换句话说,运行exec的shell会立即终止,并由exec的参数替代。
在这里的脚本中,exec在另一shell中运行新构建的shell脚本,即带有调试器的试验文件脚本。它向新脚本传递三个参数——最初的试验文件名($_guineapig),临时目录名($_tmpdir)和库目录名($_libdir)——如果存在,后跟用户的位置参数。
**前导部分
下面介绍对试验脚本所做的预先处理代码;我们称之为前导部分。它保存在文件bashdb.pre中,内容如下:
# bashdb前导部分
# 这个文件被预先处理为被调试的shell脚本
# 参数:
# $1 = 初始试验脚本的名字
# $2 = 临时文件所保存在的目录
# $3 = bashdb.pre和bashdb.fns被保存在的目录
_debugfile=$0
_guineapig=$1
_tmpdir=$2
_libdir=$3
shift 3
source $_libdir/bashdb.fns
_linebp=
let _trace=0
let _i=1
while read; do
_lines[$_i]=$REPLY
let _i=$_i+1
done < $_guineapig
trap _cleanup EXIT
let _steps=1
LINENO=-2
trap '_steptrap $LINENO' DEBUG
:
前面几行把3个参数保存在变量中,并将其移出,这样位置参数(如果存在)即为用户在命令行上对试验文件所给出的参数。然后,前导部分读取另一文件bashdb.fns,它包含调试器操作本身需要的所有函数。我们把该代码放到一个单独的文件中以最小化临时文件的大小。下面会对bashdb.fns做出检验。
接着,bashdb.pre将断点数组初始化为空,执行跟踪(见后面的讨论),然后把最初的试验文件脚本读到一个行数组。这里需要最初的试验文件的源代码有两个原因:允许调试器打印断点位置所在的脚本,以及打印打开跟踪后脚本执行时的代码行。注意这里使用环境变量中的$REPLY把脚本行赋值给_lines,而不是直接读入数组。这是因为$REPLY保存了行中任意前导空白,即它保存了最初脚本的缩进和布局。
代码的最后5行建立调试器运行时所需条件。第一个trap命令建立伪信号EXIT发生时运行的清除过程。正常情况下在调试器和试验文件脚本完成时调用清除过程,只删除临时文件。下一行将变量_steps设置为1,这样当第一次进入调试器时,它会停在首行后面。
本章前面介绍的内置变量LINENO用于提供调试器里的行编号。然而,如果按其本意使用LINENO,只会得到大于30的编号。因为LINENO包含了前导部分的行,要使行编号符合这里的要求,可将LINENO设置为新的值,它会从该值开始对行编号。这里将其设置为-2,这样试验文件的第一行就成为行1。
下一行建立过程_steptrap,在伪信号DEBUG发生时运行它。_steptrap被调用时传递的参数是$LINENO。
最后一行为空语句(:),shell执行该语句并第一次进入_steptrap。因为我们将_steps设置为1,调试器会停止,等待用户命令。下一节会说明上述代码的工作效果。
**调试器函数
函数_steptrap是进入调试器的入口点。它在文件bashdb.fns中定义。内容如下:
# After each line of the test script is executed the shell traps to
# this function.
function _steptrap
{
_curline=$1 # 正运行的行的编号
(( $_trace )) && _msg "$PS4 line $_curline: ${_lines[$_curline]}"
if (( $_steps >= 0 )); then
let _steps="$_steps - 1"
fi
# 首先查看是否达到行编号断点
# 如果达到,则进入调试器
if _at_linenumbp ; then
_msg "Reached breakpoint at line $_curline"
_cmdloop
# 如果没有达到,那么检查是否中断条件存在且为真
# 如果是,则进入调试器
elif [ -n "$_brcond" ] && eval $_brcond; then
_msg "Break condition $_brcond true at line $_curline"
_cmdloop
# 如果不是,那么检查是否我们采用步进的方式,步数是否达到。
# 如果是,则进入调试器。
elif (( $_steps == 0 )); then
_msg "Stopped at line $_curline"
_cmdloop
fi
}
_steptrap开始时将_curline设置为已经运行的试验文件行。如果打开执行跟踪,则打印执行跟踪提示符(如shell的xtrace模式),行编号和代码行本身。然后如果步骤数大于或等于0,则该步骤数递减。
然后做下面两个操作之一:通过_cmdloop进入调试器;或返回以使shell就执行下一条语句。如果断点或断点条件到达,或者是用户步进该语句,则进入前者。
**命令
稍后将解释_steptrap如何对这些事件作出判断;现在介绍_cmdloop。它是第五章介绍的case语句、流控制和上一章介绍的计算器循环的简单结合。
# 调试器命令循环
function _cmdloop {
local cmd args
while read -e -p "bashdb> " cmd args; do
case $cmd in
\? | h ) _menu ;; # 打印命令菜单
bc ) _setbc $args ;; # 设置中断条件
bp ) _setbp $args ;; # 在给定的行设置断点
cb ) _clearbp $args ;; # 清除一个或所有断点
ds ) _displayscript ;; # 列出脚本并显示断点
g ) return ;; # "go": 开始/恢复脚本的执行
q ) exit ;; # 退出
s ) let _steps=${args:-1} # 单步N次(默认值为1)
return ;;
x ) _xtrace ;; # 切换执行跟踪
!* ) eval ${cmd#!} $args ;; # 传递给shell
* ) _msg "Invalid command: '$cmd'" ;;
esac
done
}
每一次循环中,_cmdloop打印一个提示符,读取一个命令,并处理它。这里使用read -e,这样用户就可以利用readline命令行编辑的优点了。命令都是一个或两个字母的缩写;键入时很方便,在UNIX风格中也算简明。
表9-3总结了调试器命令。
表9-3 bashdb命令
命令 行为
bp N 在行N设置断点
bp 列出断点和停顿条件
bc string 设置断点条件为string
bc 清除停顿条件
cb N 清除行N的断点
cb 清除所有断点
ds 显示测试脚本和断点
g 启动/恢复执行
s [N] 执行N条语句(默认为1)
x 切换执行跟踪的打开/关闭
h, ? 打印帮助菜单
! string 传递string给shell
q 退出
在介绍每个命令前,重要的是要理解控制经过_steptrap、命令循环和试验文件的方式。
_steptrap在试验文件的每个语句后运行,作为前导部分DEBUG陷阱的结果出现。如果到达断点或用户键入步进命令,_steptrap调用命令循环。这样,它有效的中断了运行的shell,把控制权交给用户。
用户可以调用调试器命令,即运行与试验文件在同一shell内的shell命令。这意味着你可以使用shell命令检查变量值、信号陷入和任何其他被调试的脚本的局部信息。命令循环继续运行,用户仍拥有控制权,直至用户键入g,q或s。下面会详细介绍每一种情况。
键入g使得试验文件运行直至其完成或到达一个断点。它会简单的退出命令循环并返回到_steptrap,然后退出。然后shell再次获得控制权,运行试验文件脚本中下一语句。另一DEBUG信号发生,shell再次陷入到_steptrap中。如果没有断点,那么_steptrap退出。此过程一直重复,直到到达断点或试验文件完成。
**步进
当用户键入s,命令循环代码将变量_steps设置为用户要执行的步进数,即给定的参数。假设开始时用户省略了参数,意味着_steps设置为1,然后命令循环退出,控制权返回给_steptrap,它再退出(如上),控制权返回给shell。shell运行下一语句,返回到_steptrap,它递减_steps为0,然后第二个elif条件为真,因为_steps为0,_steptrap打印信息“停止”,然后调用命令循环。
现在假定用户向s给出参数,比如3,_steps设置为3,则发生下列步骤:
1.下一语句运行后,_steptrap被再次调用,它进入第一个if子句,因为_steps大于0,_steptrap递减_steps为2,退出,控制权返回给shell。
2.重复该过程,运行试验文件的另一步,_steps变成1。
3.运行第三个语句,返回到_steptrap,_steps递减为0,运行第二个elif,_steptrap再次退出命令循环。
从整体上说就是运行三步,然后调试器继续运行。
其他的调试器命令使得shell保持在命令循环中,意味着用户拖延了shell的“中断”。
**断点
下面检验与断点相关的命令以及通常的断点机制。bp命令调用函数_setbp,根据是否给出参数它可以做两个操作。下面是_setbp的代码:
# 设置断点在给定的行编号或列出断点
function _setbp
{
local i
if [ -z "$1" ]; then
_listbp
elif [ $(echo $1 | grep '^[0-9]*') ]; then
if [ -n "${_lines[$1]}" ]; then
_linebp=($(echo $( (for i in ${_linebp[*]} $1; do
echo $i; done) | sort -n) ))
_msg "Breakpoint set at line $1"
else
_msg "Breakpoints can only be set on non-blank lines"
fi
else
_msg "Please specify a numeric line number"
fi
}
如果未给出参数,_setbp调用_listbp,它打印具有断点集的行的编号。如果给出除数字外的其他内容作为参数,则打印一条错误信息,控制返回到命令循环。给出一个数字做参数即允许用户设置断点。然而,在这之前必须做另一测试。
如果用户决定在一个没有意义的位置:一个空行或一个10行程序的第1000行设置一个断点该怎么办?如果断点设置超出了程序末尾的范围,它将永远不会达到,不会产生任何问题。然而,如果断点设置为一个空行,就会有问题。原因是DEBUG捕获只有在脚本中一个简单命令执行后才会发生,而不是每行都发生。空行永远不会生成DEBUG信号。用户可以在空行上设置一个断点,这种情况下使用g命令将不能中断回调试器。
可以通过确保断点只设置在有文本的行来解决这些问题。测试后,我们可以把断点加入到断点位置数组_linebp,它比想像中复杂。为了使调试器其他部分代码变得简单,我们应对断点的排序数组进行维护。为此,我们把当前数组中所有的行号以及新的编号回送到一个子shell,并管道输出到UNIX sort命令,使用sort -n将列表按数字递增次序进行排列。最后再使用一个符合赋值语句将已排序的编号列表赋值回_linebp数组。
要使得用户能够添加断点,还要允许用户删除它们,cb命令允许用户依据是否给出一个行编号参数清除单个断点或所有断点。例如,cb 12删除行12上的断点(如果在该行上设置了断点),而仅cb命令本身将删除设置的所有断点。下面是使用cb命令调用的函数代码_clearbp:
function _clearbp
{
local i
if [ -z "$1" ]; then
unset _linebp[*]
_msg "All breakpoints have been cleared"
elif [ $(echo $1 | grep '^[0-9]*') ]; then
_linebp=($(echo $(for i in ${_linebp[*]}; do
if (( $1 != $i )); then echo $i; fi; done) ))
_msg "Breakpoint cleared at line $1"
else
_msg "Please specify a numeric line number"
fi
}
该代码的结构类似于设置断点的代码。如果未向命令给出参数,断点数组未被设置,则删除所有的断点。如果给出一个非数字参数,将打印错误信息并退出。
向cb命令给出数字参数意味着代码必须搜索断点列表并删除指定断点。通过类似于在_setbp中加入断点时用到的过程可以很容易的实现删除。在子shell中执行一个循环,打印断点列表中的行号并忽略任何匹配给定参数的内容。回送值再次通过符合语句被赋值给一个数组变量。
函数_at_linenumbp在每个语句后被_steptrap调用;它检查shell是否到达一个行编号断点。函数代码为:
# 查看这个行编号是否有一个断点
function _at_linenumbp
{
local i=0
if [ "$_linebp" ]; then
while (( $i < ${#_linebp[@]} )); do
if (( ${_linebp[$i]} == $_curline )); then
return 0
fi
let i=$i+1
done
fi
return 1
}
函数遍历断点数组,将当前行号与其中每个作比较,如果找到匹配,返回true(亦即返回0),否则,继续循环,查找匹配直到到达数组的结尾,然后返回false。
通过bs命令可以精确的找出调试器目前到达哪一行,并找出试验文件设置的断点位置。后面运行实例bashdb调试器会话时,会给出一个输出的例子。此函数代码很直接:
# 打印出shell脚本并标出断点的位置以及当前行
function _displayscript
{
local i=1 j=0 bp cl
( while (( $i < ${#_lines[@]} )); do
if [ ${_linebp[$j]} ] && (( ${_linebp[$j]} == $i )); then
bp='*'
let j=$j+1
else
bp=' '
fi
if (( $_curline == $i )); then
cl=">"
else
cl=" "
fi
echo "$i:$bp $cl ${_lines[$i]}"
let i=$i+1
done
) | more
}
此函数包含一个子shell,其输出被管道输出到UNIX more命令。这样做的原因很简单:一个长的脚本会很快的滚动屏幕,用户的显示设备也许不会允许他们回退到当前屏幕输出以前的页面。more会一次显示一屏输出。
该子shell代码的核心是遍历试验文件脚本行。它首先测试要显示的行是否在断点数组内,如果在,则设置断点字符(*),局部变量j增加。j在函数开始时最初为0,它包含目前到达的断点。我们在_setbp中将断点排序的原因现在已经很明显了:行编号和断点编号顺序递增。一旦我们传递一个具有断点的行编号并在断点数组中发现它,我们就可以知道脚本中后续断点也必须在数组中往后的部分。如果断点数组以随机次序包含行编号,我们必须搜索整个数组才能找到行编号是否在数组中。
子shell代码的核心检查当前行和要显示的行是否一样。如果一样,设置当前行字符(>),然后打印当前显示的行编号(存储在i中)、断点字符、当前行字符以及脚本行。
在断点处理中加入这些复杂处理语句是值得的。显示脚本和断点位置在任何调试器中都是很重要的特性。
**中断条件
bashdb提供了另一种在试验文件脚本中进行中断的方法:中断条件。它是一个用户可以指定的字符串,它作为一个命令被求值,如果为真(即返回退出状态0),调试器进入命令循环。
因为中断条件可设置在shell代码的任意行,所以具有许多测试代码的灵活性。例如,当变量到达某值时——例如,(( $x < 0 ))——或当特定文本被写入一个文件时(grep string file),你可以进行中断。可以考虑一下该特性的各种用法。要设置一个中断条件,键入bc string即可;要删除它,则键入bc,不带参数——这会将条件设置为null,即被忽略。
只有当其为非null时,_steptrap才对切断条件$_brcond求值,如果中断条件求值为0,则if子句为真,_steptrap再次调用命令循环。
**执行跟踪
调试器的最后一个特性是执行跟踪,可使用x命令完成。
函数_xtrace激活执行跟踪,方式是通过将变量_trace赋值为其当前取值的逻辑非,这样它就会在0和1之间交替,前导部分将其初始化为0。
**调试器限制
这里尽量使bashdb简单化,这样你才能明白构建一个shell脚本调试器的基本原理。虽然它包含了许多有用的特性,可被设计为一个实际的工具,而不只是一个脚本实例,但它有许多重要的限制。下面列出了这些限制。
1.用调试器运行程序比程序本身运行要慢。bashdb也不例外。依据所用到的脚本,你会发现调试器运行任何程序基本都要慢8~10倍。如果以较小的增量步进一个脚本,此问题还不算严重,但要记住,如果有一个带有较大循环结构的初始化代码的程序,就很成问题了。
2.设置断点的一个问题是当它们在没有简单命令(实际的UNIX命令、shell内置命令、函数调用和别名)的行上被设置时,不会生成DEBUG信号,陷阱代码不会执行。这种情况包括保留while、if、for等,除非一个简单命令与它们在同一行上。
3.调试器不会步进到从试验文件中被调用的shell脚本。为此,必须编辑试验文件脚本,将对scriptname的调用改为bashdb scriptname。
4.类似的,嵌套子shell被看作是一条巨大的语句,你不能步进到其内部。
5.试验文件不应捕获伪信号DEBUG和EXIT,否则,调试器无法工作。
6.命令错误处理需要大幅增强。
7.shell实际上具有在每个语句前捕获的能力,而不止是在其后。这是大部分商业源代码调试器工作的方式。至少,shell应提供一个包含即将运行的行的编号,而不是已经运行的行编号。
许多限制都是无法克服的,见本章最后的练习。
**示例bashdb会话
下面给出一个使用bashdb的实际会话的副本。在该会话中,试验文件为任务6-1的解决方案,即脚本ndu。下面是调试器会话的副本:
[bash]$ bashdb ndu
bash Debugger version 1.0
Stopped at line 0
bashdb> ds
1: for dir in ${*:-.}; do
2: if [ -e $dir ]; then
3: result=$(du -s $dir | cut -f 1)
4: let total=$result*1024
5:
6: echo -n "Total for $dir = $total bytes"
7:
8: if [ $total -ge 1048576 ]; then
9: echo " ($((total/1048576)) Mb)"
10: elif [ $total -ge 1024 ]; then
11: echo " ($((total/1024)) Kb)"
12: fi
13: fi
14: done
bashdb> s
Stopped at line 2
bashdb> bp 4
Breakpoint set at line 4
bashdb> bp 8
Breakpoint set at line 8
bashdb> bp 11
Breakpoint set at line 11
bashdb> ds
1: for dir in ${*:-.}; do
2: > if [ -e $dir ]; then
3: result=$(du -s $dir | cut -f 1)
4:* let total=$result*1024
5:
6: echo -n "Total for $dir = $total bytes"
7:
8:* if [ $total -ge 1048576 ]; then
9: echo " ($((total/1048576)) Mb)"
10: elif [ $total -ge 1024 ]; then
11:* echo " ($((total/1024)) Kb)"
12: fi
13: fi
14: done
bashdb> g
Reached breakpoint at line 4
bashdb> !echo $total
6840032
bashdb> cb 8
Breakpoint cleared at line 8
bashdb> ds
1: for dir in ${*:-.}; do
2: if [ -e $dir ]; then
3: result=$(du -s $dir | cut -f 1)
4:* > let total=$result*1024
5:
6: echo -n "Total for $dir = $total bytes"
7:
8: if [ $total -ge 1048576 ]; then
9: echo " ($((total/1048576)) Mb)"
10: elif [ $total -ge 1024 ]; then
11:* echo " ($((total/1024)) Kb)"
12: fi
13: fi
14: done
bashdb> bp
Breakpoints at lines: 4 11
Break on condition:
bashdb> !total=5600
bashdb> g
Total for . = 5600 bytes (5 Kb)
Reached breakpoint at line 11
bashdb> cb
All breakpoints have been cleared
bashdb> ds
1: for dir in ${*:-.}; do
2: if [ -e $dir ]; then
3: result=$(du -s $dir | cut -f 1)
4: let total=$result*1024
5:
6: echo -n "Total for $dir = $total bytes"
7:
8: if [ $total -ge 1048576 ]; then
9: echo " ($((total/1048576)) Mb)"
10: elif [ $total -ge 1024 ]; then
11: > echo " ($((total/1024)) Kb)"
12: fi
13: fi
14: done
bashdb> g
[bash]$
首先,使用ds显示脚本,然后执行一步,执行到ndu的第二行。然后在行4,行8和行11设置断点,再次显示脚本。这次清晰标记了断点为*。右边的>符号暗示行2是最新执行的行。
接着,继续执行脚本,在第4行切断,打印total取值,并决定清除行8上的断点。显示脚本以确保行8上的断点已消失。还可以使用bp命令,它也显示出只有在行4和行11上设置了断点。
下面我们要检查行11上的if分支的逻辑。它需要$total大于或等于1024,但要小于1048576。正如前面所见,$total很大,因此我们将其值设置为5600,这样它会执行if的第二部分,继续执行,脚本正确进入if部分,打印该值,并停止在断点上。
最后,清除断点,再次显示脚本,然后继续执行,结果是退出了该脚本。
**练习
bashdb调试器可通过匿名FTP得到,在附录E中给出了其具体方法。如果你不能访问Internet,可以自己键入或查找该代码。你可以使用bashdb调试自己的shell脚本,随意增加其功能。本章最后给出了一些建议增加的功能和调试器命令源代码的完整程序清单。
1.以如下方式改善命令错误处理:
a.检查s的参数为有效数字,如果不是则打印适当的错误信息。
b.在清除断点前检查断点是否存在,如果该行没有断点则向用户发出警告。
c.可以想到的其他错误处理。
2.加入删除重复断点的代码(一行上多个断点)。
3.增强cb命令,使得用户可以指定一次清除多个断点。
4.实现当命令以非0状态退出时中断到调试器的选项:
a.将其实现为命令行选项-e。
b.将其实现为调试器命令e,切换其打开和关闭状态(提示:当进入_steptrap, 时,$?即为运行的最后一个命令的退出状态)。
5.打印调试器状态:执行跟踪打开/关闭,错误退出打开/关闭,以及要被执行的最后一行的编号。另外,把bp的显示断点的功能加入到新选项。
6.加入对多中断条件的支持,这样当其中一个条件为真时,bash就可以停止执行并打印信息指出哪一个为真。通过把切断条件保存到一个数组实现。尽量使其效率高一些,因为检查会发生在每条语句运行后。
7.加入观察变量的功能。
a.加入命令aw,接受变量名作为参数,并将其加入到要观察的变量列表。任何被观察的变量在打开执行跟踪后都会被打印出来。
b.加入另一命令cw,不带参数,删除所有观察列表中的变量。如果带参数,则删除指定变量。
8.前面介绍过,除非在带有简单命令的行上设置断点,否则它们都将被忽略,不会使程序退出到调试器。加入代码解决该问题(提示:如果用户在这样的行上设置断点,将之前移到包含简单命令的一行。或者考虑采用在从试验文件和前导脚本中创建临时文件时插入“空”命令(:)的方式)。
9.虽然在调试器标识的开始位置加一个下划线可以表明大多数情况下的名字冲突,考虑自动检测和试验文件脚本的名字冲突以及解决该问题的方式(提示:可以在试验文件脚本和前导部分结合放入临时文件的时候,将试验文件脚本中产生冲突的名字重新命名)。
10.加入你想到的其他特性。
最后,给出调试器函数文件bashdb.fns的完整代码清单:
#测试脚本的每一行被执行之后,shell陷入这一函数
function _steptrap
{
_curline=$1 #正在运行的行编号
(( $_trace )) && _msg "$PS4 line $_curline: ${_lines[$_curline]}"
if (( $_steps >= 0 )); then
let _steps="$_steps - 1"
fi
#首先查看是否达到行编号断点
#如果达到,则进入调试器
if _at_linenumbp ; then
_msg "Reached breakpoint at line $_curline"
_cmdloop
#如果没有达到,那么检查是否中断条件存在且为真
#如果是,则进入调试器
elif [ -n "$_brcond" ] && eval $_brcond; then
_msg "Break condition $_brcond true at line $_curline"
_cmdloop
#如果不是,那么检查是否我们采用步进的方式,步数是否达到
#如果是,则进入调试器
elif (( $_steps == 0 )); then
_msg "Stopped at line $_curline"
_cmdloop
fi
}
#调试器命令循环
function _cmdloop {
local cmd args
while read -e -p "bashdb> " cmd args; do
case $cmd in
\? | h ) _menu ;; #打印命令菜单
bc ) _setbc $args ;; #设置中断条件
bp ) _setbp $args ;; #设置断点在指定的行
cb ) _clearbp $args ;; #清除一个或所有断点
ds ) _displayscript ;; #列出脚本并显示断点
g ) return ;; #"go":开始/再继续脚本的执行
q ) exit ;; #退出
s ) let _steps=${args:-1} #步进N次(默认值为1)
return ;;
x ) _xtrace ;; #切换执行追踪
* ) eval ${cmd#!} $args ;; #传递给shell
* ) _msg "Invalid command: '$cmd'" ;;
esac
done
}
#查看这个行编号是否有一个断点
function _at_linenumbp
{
local i=0
#循环遍历断点数组并查看它们是否与当前行编号匹配。
#如果匹配返回真(0),否则返回假
if [ "$_linebp" ]; then
while (( $i < ${#_linebp[@]} )); do
if (( ${_linebp[$i]} == $_curline )); then
return 0
fi
let i=$i+1
done
fi
return 1
}
#设置断点在给定的行编号或列出断点
function _setbp
{
local i
#如果没有参数,调用断点列表函数。否则查看参数是否为正数。
#如果不是,则打印错误消息。如果是,则查看行编号是否包含文本。
#如果不是,则打印错误消息。如果是,则回应当前断点和新的附加,
#并且将它们输送到“排序”。并将结果赋值给断点列表。
#这将导致断点按数字顺序排序。
#注意:使用-u选项我们可以删除重复的断点
if [ -z "$1" ]; then
_listbp
elif [ $(echo $1 | grep '^[0-9]*') ]; then
if [ -n "${_lines[$1]}" ]; then
_linebp=($(echo $( (for i in ${_linebp[*]} $1; do
echo $i; done) | sort -n) ))
_msg "Breakpoint set at line $1"
else
_msg "Breakpoints can only be set on non-blank lines"
fi
else
_msg "Please specify a numeric line number"
fi
}
#列出断点及中断条件
function _listbp
{
if [ -n "$_linebp" ]; then
_msg "Breakpoints at lines: ${_linebp[*]}"
else
_msg "No breakpoints have been set"
fi
_msg "Break on condition:"
_msg "$_brcond"
}
#清除单个或所有断点
function _clearbp
{
local i bps
#如果没有参数,那么删除所有断点。否则查看参数是否为正数。
#如果不是,则打印错误消息。如果是,则回应除被传递的那个之外的所有当前断点,
#并将它们赋值给局部变量。(我们需要这样做是因为将它们赋值给_linebp,
#将使数组保持在同一大小并将值向回移动一个位置,导致重复值)。
#然后销毁旧数组,并将局部数组中的元素赋值。
#于是我们高效的重创了它,减掉了被传递的断点。
if [ -z "$1" ]; then
unset _linebp[*]
_msg "All breakpoints have been cleared"
elif [ $(echo $1 | grep '^[0-9]*') ]; then
bps=($(echo $(for i in ${_linebp[*]}; do
if (( $1 != $i )); then echo $i; fi; done) ))
unset _linebp[*]
_linebp=(${bps[*]})
_msg "Breakpoint cleared at line $1"
else
_msg "Please specify a numeric line number"
fi
}
#设置或清除中断条件
function _setbc
{
if [ -n "$*" ]; then
_brcond=$args
_msg "Break when true: $_brcond"
else
_brcond=
_msg "Break condition cleared"
fi
}
#打印出shell脚本并标出断点的位置以及当前行
function _displayscript
{
local i=1 j=0 bp cl
( while (( $i < ${#_lines[@]} )); do
if [ ${_linebp[$j]} ] && (( ${_linebp[$j]} == $i )); then
bp='*'
let j=$j+1
else
bp=' '
fi
if (( $_curline == $i )); then
cl=">"
else
cl=" "
fi
echo "$i:$bp $cl ${_lines[$i]}"
let i=$i+1
done
) | more
}
#切换执行追踪on/off
function _xtrace
{
let _trace="! $_trace"
_msg "Execution trace "
if (( $_trace )); then
_msg "on"
else
_msg "off"
fi
}
#打印传进来的参数到标准错误
function _msg
{
echo -e "$@" >&2
}
#打印命令菜单
function _menu {
_msg 'bashdb commands:
bp N set breakpoint at line N
bp list breakpoints and break condition
bc string set break condition to string
bc clear break condition
cb N clear breakpoint at line N
cb clear all breakpoints
ds displays the test script and breakpoints
g start/resume execution
s [N] execute N statements (default 1)
x toggle execution trace on/off
h, ? print this menu
! string passes string to a shell
q quit'
}
#退出之前删除临时文件
function _cleanup
{
rm $_debugfile 2>/dev/null
}