| < Day Day Up > |
9.2. A bash Debugger
In this section we'll develop a very basic debugger for
bash
.
[10]
Most
As you will see, the capability to do all of these things (and more) is easily provided by the constructs and
9.2.1. Structure of the DebuggerThe bashdb debugger works by taking a shell script and turning it into a debugger for itself. It does this by concatenating debugger functionality and the target script, which we'll call the guinea pig script, and storing it in another file that then gets executed. The process is transparent to usersthey will be unaware that the code that is executing is actually a modified copy of their script. The bash debugger has three main sections: the driver , the preamble , and the debugger functions . 9.2.1.1 The driver scriptThe driver script is responsible for setting everything up. It is a script called bashdb and looks like this:
# bashdb - a bash debugger
# Driver Script: concatenates the preamble and the target script
# and then executes the new script.
echo 'bash Debugger version 1.0'
_dbname=${0##*/}
if (($# < 1)) ; then
echo "$_dbname: Usage: $_dbname filename" >&2
exit 1
fi
_guineapig=
if [ ! -r ]; then
echo "$_dbname: Cannot read file '$_guineapig'." >&2
exit 1
fi
shift
_tmpdir=/tmp
_libdir=.
_debugfile=$_tmpdir/bashdb.$$ # temporary file for script that is
being debugged
cat $_libdir/bashdb.pre $_guineapig > $_debugfile
exec bash $_debugfile $_guineapig $_tmpdir $_libdir "$@"
bashdb
takes as the first argument the
If no arguments are given,
bashdb
prints out a usage line and exits with an error status.
The cat statement builds the modified copy of the guinea pig file: it contains the script found in bashdb.pre (which we'll look at shortly) followed by a copy of the guinea pig. 9.2.1.2 exec
The last line runs the newly created script with
exec
, a statement we haven't discussed yet. We've
In our script,
exec
just runs the newly
9.2.2. The PreambleNow we'll look at the code that gets prepended to the guinea pig script; we call this the preamble. It's kept in the file bashdb.pre and looks like this:
# bashdb preamble
# This file gets prepended to the shell script being debugged.
# Arguments:
# = the name of the original guinea pig script
# = the directory where temporary files are stored
# = the directory where bashdb.pre and bashdb.fns are stored
_debugfile=
The first few lines save the three fixed arguments in variables and shift them out of the way, so that the positional parameters (if any) are those that the user supplied on the command line as arguments to the guinea pig. Then, the preamble reads in another file,
bashdb.fns
, which contains all of the functions necessary for the operation of the debugger itself. We put this code in a separate file to minimize the
The last five lines of code set up the conditions necessary for the debugger to begin working. The first trap command sets up a clean-up routine that runs when the fake signal EXIT occurs. The clean-up routine, normally called when the debugger and guinea pig script finish, just erases the temporary file. The next line sets the variable _steps to 1 so that when the debugger is first entered, it will stop after the first line. The next line sets up the routine _steptrap to run when the fake signal DEBUG occurs.
The built-in variable
LINENO
, which we saw earlier in the chapter, is used to provide line
9.2.3. Debugger FunctionsThe function _steptrap is the entry point into the debugger; it is defined in the file bashdb.fns . Here is _steptrap :
# After each line of the test script is executed the shell traps to
# this function.
function _steptrap
{
_curline= # the number of the line that just ran
(($_trace)) && _msg "$PS4 line $_curline: ${_lines[$_curline]}"
if (($_steps >= 0)); then
let _steps="$_steps - 1"
fi
# First check to see if a line number breakpoint was reached.
# If it was, then enter the debugger.
if _at_linenumbp ; then
_msg "Reached breakpoint at line $_curline"
_cmdloop
# It wasn't, so check whether a break condition exists and is true.
# If it is, then enter the debugger.
elif [ -n "$_brcond" ] && eval $_brcond; then
_msg "Break condition $_brcond true at line $_curline"
_cmdloop
# It wasn't, so check if we are in step mode and the number of steps
# is up. If it is then enter the debugger.
elif (($_steps == 0)); then
_msg "Stopped at line $_curline"
_cmdloop
fi
}
_steptrap starts by setting _curline to the number of the guinea pig line that just ran. If execution tracing is on, it prints the PS4 execution trace prompt (like the shell's xtrace mode), line number, and line of code itself. It then decrements the number of steps if the number of steps still left is greater than or equal to zero. Then it does one of two things: it enters the debugger via _cmdloop , or it returns so the shell can execute the next statement. It chooses the former if a breakpoint or break condition has been reached, or if the user stepped into this statement. 9.2.3.1 CommandsWe will explain shortly how _steptrap determines these things; now we will look at _cmdloop . It's a simple combination of the case statements we saw in Chapter 5, and the calculator loop we saw in the previous chapter.
# The Debugger Command Loop
function _cmdloop {
local cmd args
while read -e -p "bashdb> " cmd args; do
case $cmd in
\? h) _menu ;; # print command menu
bc) _setbc $args ;; # set a break condition
bp) _setbp $args ;; # set a breakpoint at the given
# line
cb) _clearbp $args ;; # clear one or all breakpoints
ds) _displayscript ;; # list the script and show the
# breakpoints
g) return ;; # "go": start/resume execution of
# the script
q) exit ;; # quit
s) let _steps=${args:-1} # single step N times
# (default = 1)
return ;;
x) _xtrace ;; # toggle execution trace
!*) eval ${cmd#!} $args ;; # pass to the shell
*) _msg "Invalid command: '$cmd'" ;;
esac
done
}
At each iteration, _cmdloop prints a prompt, reads a command, and processes it. We use read -e so that the user can take advantage of the readline command-line editing. The commands are all one- or two-letter abbreviations; quick for typing, but terse in the UNIX style. [14]
Table 9-3 summarizes the debugger commands. Table 9-3. bashdb commands
Before looking at the individual commands, it is important that you understand how control passes through _steptrap , the command loop, and the guinea pig. _steptrap runs after every statement in the guinea pig as a result of the trap on DEBUG in the preamble. If a breakpoint has been reached or the user previously typed in a step command(s), _steptrap calls the command loop. In doing so, it effectively "interrupts" the shell that is running the guinea pig to hand control over to the user. The user can invoke debugger commands as well as shell commands that run in the same shell as the guinea pig. This means that you can use shell commands to check values of variables, signal traps, and any other information local to the script being debugged. The command loop continues to run, and the user stays in control, until he types g , q , or s . We'll now look in detail at what happens in each of these cases. Typing g has the effect of running the guinea pig uninterrupted until it finishes or hits a breakpoint. It simply exits the command loop and returns to _steptrap , which exits as well. The shell then regains control and runs the next statement in the guinea pig script. Another DEBUG signal occurs and the shell traps to _steptrap again. If there are no breakpoints then _steptrap will just exit. This process will repeat until a breakpoint is reached or the guinea pig finishes. The q command calls the function _cleanup , which erases the temporary file and exits the program. 9.2.3.2 SteppingWhen the user types s , the command loop code sets the variable _steps to the number of steps the user wants to execute, i.e., to the argument given. Assume at first that the user omits the argument, meaning that _steps is set to 1. Then the command loop exits and returns control to _steptrap , which (as above) exits and hands control back to the shell. The shell runs the next statement and returns to _steptrap , which then decrements _steps to 0. Then the second elif conditional becomes true because _steps is 0 and prints a "stopped" message and then calls the command loop.
Now assume that the user
The overall effect is that the three steps run and then the debugger takes over again. All of the other debugger commands cause the shell to stay in the command loop, meaning that the user prolongs the "interruption" of the shell. 9.2.3.3 Breakpoints
Now we'll examine the breakpoint-
# Set a breakpoint at the given line number or list breakpoints
function _setbp
{
local i
if [ -z "" ]; then
_listbp
elif [ $(echo grep '^[0-9]*') ]; then
if [ -n "${_lines[]}" ]; then
_linebp=($(echo $((for i in ${_linebp[*]} ; do
echo $i; done) sort -n)))
_msg "Breakpoint set at line "
else
_msg "Breakpoints can only be set on non-blank lines"
fi
else
_msg "Please specify a numeric line number"
fi
}
If no argument is supplied, _setbp calls _listbp , which prints the line numbers that have breakpoints set. If anything other than a number is supplied as an argument, an error message is printed and control returns to the command loop. Providing a number as the argument allows us to set a breakpoint; however, we have to do another test before doing so.
What happens if the user decides to set a breakpoint at a
We can fix both of these problems by making sure that breakpoints are set only on lines with text.
[15]
After making the tests, we can add the breakpoint to the breakpoint array,
_linebp
. This is a little more complex than it sounds. In order to make the code in other sections of the debugger simpler, we should maintain a sorted array of breakpoints. To do this, we echo all of the line numbers currently in the array, along with the new number, in a subshell and pipe them into the UNIX
sort
command.
sort -n
sorts a list into
To complement the user's ability to add breakpoints, we also allow the user to delete them. The
cb
command allows the user to clear single breakpoints or all breakpoints, depending on whether a line number argument is supplied or not. For example,
cb 12
clears a breakpoint at line 12 (if a breakpoint was set at that line).
cb
on its own would clear all of the breakpoints that have been set. It is useful to look
function _clearbp
{
local i
if [ -z "" ]; then
unset _linebp[*]
_msg "All breakpoints have been cleared"
elif [ $(echo grep '^[0-9]*') ]; then
_linebp=($(echo $(for i in ${_linebp[*]}; do
if ((!= $i)); then echo $i; fi; done)))
_msg "Breakpoint cleared at line "
else
_msg "Please specify a numeric line number"
fi
}
The structure of the code is similar to that used for setting the breakpoints. If no argument was supplied to the command, the breakpoint array is unset, effectively deleting all the breakpoints. If an argument was supplied and is not a number, we print out an error message and exit.
A numeric argument to the
cb
command means the code has to search the list of breakpoints and delete the specified one. We can easily make the deletion by following a procedure similar to the one we used when we added a breakpoint in
_setbp
. We execute a loop in a subshell, printing out the line numbers in the breakpoints list and ignoring any that match the provided argument. The
The function _at_linenumbp is called by _steptrap after every statement; it checks whether the shell has arrived at a line number breakpoint. The code for the function is:
# See if this line number has a breakpoint
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
}
The function simply
It is possible to find out exactly what line the debugger is up to and where the breakpoints have been set in the guinea pig by using the
ds
command. We'll see an example of the output later, when we run a sample
bashdb
debugging session. The code for this function is
# Print out the shell script and mark the location of breakpoints
# and the current line
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
}
This function contains a subshell, the output of which is piped to the UNIX more command. We have done this for user-friendly reasons; a long script would scroll up the screen quickly and the users may not have displays that allow them to scroll back to previous pages of screen output. more displays one screenful of output at a time.
The
The core of the subshell code then checks to see if the current line and the line it is about to display are the same. If they are, a "current line" character (>) is set. The current displayed line number (stored in i ), breakpoint character, current line character, and script line are then printed out. We think you'll agree that the added complexity in the handling of breakpoints is well worth it. Being able to display the script and the location of breakpoints is an important feature in any debugger. 9.2.3.4 Break conditionsbashdb provides another method of breaking out of the guinea pig script: the break condition . This is a string that the user can specify that is evaluated as a command; if it is true (i.e., returns exit status 0), the debugger enters the command loop.
Since the break condition can be any line of shell code, there's a lot of flexibility in what can be
_steptrap
9.2.3.5 Execution tracingThe final feature of the debugger is execution tracing , available with the x command. The function _xtrace "toggles" execution tracing simply by assigning to the variable _trace the logical "not" of its current value, so that it alternates between 0 (off) and 1 (on). The preamble initializes it to 0. 9.2.3.6 Debugger limitations
We have kept
bashdb
reasonably simple so that you can see the fundamentals of building a shell script debugger. Although it contains some useful features and is designed to be a real tool, not just a scripting example, it has some important limitations. Some are described in the list that
Many of these are not insurmountable and you can experiment with solving them yourself; see the exercises at the end of this chapter.
The debugger from an earlier version of this book helped
9.2.4. A Sample bashdb SessionNow we'll show a transcript of an actual session with bashdb , in which the guinea pig is the solution to Task 6-1, the script ndu . Here is the transcript of the debugging session:
[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]$
First, we display the script with
ds
and then perform a step, taking execution to line 2 of
ndu
. We then set breakpoints at lines 4, 8, and 11 and display the script again. This time the breakpoints are clearly
Next, we continue execution of the script that breaks at line 4. We print out the value of total now and decide to clear the breakpoint at line 8. Displaying the script confirms that the breakpoint at line 8 is indeed gone. We can also use the bp command, and it too shows that the only breakpoints set are at lines 4 and 11. At this stage we might decide that we want to check the logic of the if branch at line 11. This requires that $total be greater than or equal to 1,024, but less than 1,048,576. As we saw previously, $total is very large, so we set its value to 5,600 so that it will execute the second part of the if and continue execution. The script enters that section of the if correctly, prints out the value, and stops at the breakpoint. To finish off, we clear the breakpoints, display the script again, and then continue execution, which exits the script. 9.2.5. ExercisesWe'll conclude this chapter with some suggested enhancements to our simple debugger and a complete listing of the debugger command source code.
Finally, here is a complete source listing of the debugger function file bashdb.fns :
# After each line of the test script is executed the shell traps to
# this function.
function _steptrap
{
_curline= # the number of the line that just ran
(($_trace)) && _msg "$PS4 line $_curline: ${_lines[$_curline]}"
if (($_steps >= 0)); then
let _steps="$_steps - 1"
fi
# First check to see if a line number breakpoint was reached.
# If it was, then enter the debugger.
if _at_linenumbp ; then
_msg "Reached breakpoint at line $_curline"
_cmdloop
# It wasn't, so check whether a break condition exists and is true.
# If it is, then enter the debugger
elif [ -n "$_brcond" ] && eval $_brcond; then
_msg "Break condition $_brcond true at line $_curline"
_cmdloop
# It wasn't, so check if we are in step mode and the number of
# steps is up. If it is, then enter the debugger.
elif (($_steps == 0)); then
_msg "Stopped at line $_curline"
_cmdloop
fi
}
# The Debugger Command Loop
function _cmdloop {
local cmd args
while read -e -p "bashdb> " cmd args; do
case $cmd in
\? h) _menu ;; # print command menu
bc) _setbc $args ;; # set a break condition
bp) _setbp $args ;; # set a breakpoint at the given line
cb) _clearbp $args ;; # clear one or all breakpoints
ds) _displayscript ;; # list the script and show the
# breakpoints
g) return ;; # "go": start/resume execution of
# the script
q) exit ;; # quit
s) let _steps=${args:-1} # single step N times (default = 1)
return ;;
x) _xtrace ;; # toggle execution trace
*) eval ${cmd#!} $args ;; # pass to the shell
*) _msg "Invalid command: '$cmd'" ;;
esac
done
}
# See if this line number has a breakpoint
function _at_linenumbp
{
local i=0
# Loop through the breakpoints array and check to see if any of
# them match the current line number. If they do return true (0)
# otherwise return false.
if [ "$_linebp" ]; then
while (($i < ${#_linebp[@]})); do
if ((${_linebp[$i]} == $_curline)); then
return 0
fi
let i=$i+1
done
fi
return 1
}
# Set a breakpoint at the given line number or list breakpoints
function _setbp
{
local i
# If there are no arguments call the breakpoint list function.
# Otherwise check to see if the argument was a positive number.
# If it wasn't then print an error message. If it was then check
# to see if the line number contains text. If it doesn't then
# print an error message. If it does then echo the current
# breakpoints and the new addition and pipe them to "sort" and
# assign the result back to the list of breakpoints. This results
# in keeping the breakpoints in numerical sorted order.
# Note that we can remove duplicate breakpoints here by using
# the -u option to sort which uniquifies the list.
if [ -z "" ]; then
_listbp
elif [ $(echo grep '^[0-9]*') ]; then
if [ -n "${_lines[]}" ]; then
_linebp=($(echo $((for i in ${_linebp[*]} ; do
echo $i; done) sort -n)))
_msg "Breakpoint set at line "
else
_msg "Breakpoints can only be set on non-blank lines"
fi
else
_msg "Please specify a numeric line number"
fi
}
# List breakpoints and break conditions
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"
}
# Clear individual or all breakpoints
function _clearbp
{
local i bps
# If there are no arguments, then delete all the breakpoints.
# Otherwise, check to see if the argument was a positive number.
# If it wasn't, then print an error message. If it was, then
# echo all of the current breakpoints except the passed one
# and assign them to a local variable. (We need to do this because
# assigning them back to _linebp would keep the array at the same
# size and just move the values "back" one place, resulting in a
# duplicate value). Then destroy the old array and assign the
# elements of the local array, so we effectively recreate it,
# minus the passed breakpoint.
if [ -z "" ]; then
unset _linebp[*]
_msg "All breakpoints have been cleared"
elif [ $(echo grep '^[0-9]*') ]; then
bps=($(echo $(for i in ${_linebp[*]}; do
if ((!= $i)); then echo $i; fi; done)))
unset _linebp[*]
_linebp=(${bps[*]})
_msg "Breakpoint cleared at line "
else
_msg "Please specify a numeric line number"
fi
}
# Set or clear a break condition
function _setbc
{
if [ -n "$*" ]; then
_brcond=$args
_msg "Break when true: $_brcond"
else
_brcond=
_msg "Break condition cleared"
fi
}
# Print out the shell script and mark the location of breakpoints
# and the current line
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
}
# Toggle execution trace on/off
function _xtrace
{
let _trace="! $_trace"
_msg "Execution trace "
if (($_trace)); then
_msg "on"
else
_msg "off"
fi
}
# Print the passed arguments to Standard Error
function _msg
{
echo -e "$@" >&2
}
# Print command menu
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'
}
# Erase the temporary file before exiting
function _cleanup
{
rm $_debugfile 2>/dev/null
}
|
| < Day Day Up > |