Previous Section  < Day Day Up >  Next Section

9.2. A bash Debugger

In this section we'll develop a very basic debugger for bash.[10] Most debuggers have numerous sophisticated features that help a programmer in dissecting a program, but just about all of them include the ability to step through a running program, stop it at selected places, and examine the values of variables. These simple features are what we will concentrate on providing in our debugger. Specifically, we'll provide the ability to:

[10] Unfortunately, the debugger will not work with versions of bash prior to 2.0, because they do not implement the DEBUG signal.

  • Specify places in the program at which to stop execution. These are called breakpoints.

  • Execute a specified number of statements in the program. This is called stepping.

  • Examine and change the state of the program during its execution. This includes being able to print out the values of variables and change them when the program is stopped at a breakpoint or after stepping.

  • Print out the source code we are debugging along with indications of where breakpoints are and what line in the program we are currently executing.

  • Provide the debugging capability without having to change the original source code of the program we wish to debug in any way.

As you will see, the capability to do all of these things (and more) is easily provided by the constructs and methods we have seen in previous chapters.

9.2.1. Structure of the Debugger

The 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 users—they 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 script

The 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=$1

     

if [ ! -r $1 ]; 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 name of guinea pig file. Any subsequent arguments are passed on to the guinea pig as its positional parameters.

If no arguments are given, bashdb prints out a usage line and exits with an error status. Otherwise, it checks to see if the file exists. If it doesn't, exist then bashdb prints a message and exits with an error status. If all is in order, bashdb constructs a temporary file in the way we saw in the last chapter. If you don't have (or don't have access to) /tmp on your system, then you can substitute a different directory for _tmpdir.[11] The variable _libdir is the name of the directory that contains files needed by bashdb (bashdb.pre and bashdb.fns). If you are installing bashdb on your system for everyone to use, you might want to place them in /usr/lib.

[11] All function names and variables (except those local to functions) in bashdb have names beginning with an underscore (_), to minimize the possibility of clashes with names in the guinea pig script.

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 chosen to wait until now to introduce it because—as we think you'll agree—it can be dangerous. exec takes its arguments as a command line and runs the command in place of the current program, in the same process. In other words, a shell that runs exec will terminate immediately and be replaced by exec's arguments.[12]

[12] exec can also be used with an I/O redirector only; this makes the redirector take effect for the remainder of the script or login session. For example, the line exec 2>errlog at the top of a script directs standard error to the file errlog for the rest of the script.

In our script, exec just runs the newly constructed shell script, i.e., the guinea pig with its debugger, in another shell. It passes the new script three arguments—the name of the original guinea pig file ($_guineapig), the name of the temporary directory ($_tmpdir), and the name of the library directory ($_libdir)—followed by the user's positional parameters, if any.

9.2.2. The Preamble

Now 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:

# $1 = the name of the original guinea pig script

# $2 = the directory where temporary files are stored

# $3 = the directory where bashdb.pre and bashdb.fns are stored

     

_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

trap '_steptrap $(( $LINENO -29 ))' DEBUG

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 size of the temporary file. We'll examine bashdb.fns shortly.

Next, bashdb.pre initializes a breakpoint array to empty and execution tracing to off (see the following discussion), then reads the original guinea pig script into an array of lines. We need the source lines from the original script for two reasons: to allow the debugger to print out the script showing where the breakpoints are, and to print out the lines of code as they execute if tracing is turned on. You'll notice that we assign the script lines to _lines from the environment variable $REPLY rather than reading them into the array directly. This is because $REPLY preserves any leading whitespace in the lines, i.e., it preserves the indentation and layout of the original script.

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 numbers in the debugger. However, if we just used LINENO as is, we'd get line numbers above 30 because LINENO would be including the lines in the preamble. To get around this, we can pass LINENO minus the number of lines in the preamble to the trap.[13]

[13] If you are typing or scanning in the preamble code from this book, make sure that the last line in the file is the call to set the trap, i.e., no blank lines should appear after the call to trap.

9.2.3. Debugger Functions

The 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=$1        # 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 Commands

We 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]

[14] There is nothing to stop you from changing the commands to something you find easier to remember. There is no "official" bash debugger, so feel free to change the debugger to suit your needs.

Table 9-3 summarizes the debugger commands.

Table 9-3. bashdb commands

Command

Action

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

Display the test script and breakpoints

g

Start/resume execution

s [N]

Execute N statements (default 1)

x

Toggle execution trace on/off

h, ?

Print the help menu

! string

Pass string to a shell

q

Quit


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 Stepping

When 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 supplies an argument to s, say 3. _steps is set to 3. Then the following happens:

  1. After the next statement runs, _steptrap is called again. It enters the first if clause, since _steps is greater than 0. _steptrap decrements _steps to 2 and exits, returning control to the shell.

  2. This process repeats, another step in the guinea pig is run, and _steps becomes 1.

  3. A third statement is run and we're back in _steptrap. _steps is decremented to 0, the second elif clause is run, and _steptrap breaks out to the command loop again.

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-related commands and the breakpoint mechanism in general. The bp command calls the function _setbp, which can do two things, depending on whether an argument is supplied or not. Here is the code for _setbp:

# Set a breakpoint at the given line number or list breakpoints

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

}

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 nonsensical point: a blank line, or at line 1,000 of a 10-line program? If the breakpoint is set well beyond the end of the program, it will never be reached and will cause no problem. If, however, a breakpoint is set at a blank line, it will cause problems. The reason is that the DEBUG trap only occurs after each executed simple command in a script, not each line. Blank lines never generate the DEBUG signal. The user could set a breakpoint on a blank line, in which case continuing execution with the g command would never break back out to the debugger.

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 numerically ascending order. The result of this is a list of ordered numbers which we then assign back to the _linebp array with a compound assignment.

[15] This isn't a complete solution. Certain other lines (e.g., comments) will also be ignored by the DEBUG trap. See the list of limitations and the exercises at the end of this chapter.

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 briefly at how this works; here is the code for the function that is called with the cb command, _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

}

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 echoed values once again form a compound statement, which can then be assigned to an array variable.[16]

[16] bash versions 2.01 and earlier have a bug in assigning arrays to themselves that prevents the code for setbp and clearbp from working. In each case, you can get around this bug by assigning _linebp to a local variable first, unsetting it, and then assigning the local variable back to it. Better yet, update to a more recent version of bash.

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 loops through the breakpoint array and checks the current line number against each one. If a match is found, it returns true (i.e., returns 0). Otherwise, it continues looping, looking for a match until the end of the array is reached. It then returns false.

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 fairly straightforward:

# 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 core of the subshell code loops through the lines of the guinea pig script. It first tests to see if the line it is about to display is in the array of breakpoints. If it is, a breakpoint character (*) is set and the local variable j is incremented. j was initialized to 0 at the beginning of the function; it contains the current breakpoint that we are up to. It should now be apparent why we went to the trouble of sorting the breakpoints in _setbp: both the line numbers and the breakpoint numbers increment sequentially, and once we pass a line number that has a breakpoint and find it in the breakpoint array, we know that future breakpoints in the script must be further on in the array. If the breakpoint array contained line numbers in a random order, we'd have to search the entire array to find out if a line number was in the array or not.

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 conditions

bashdb 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 tested. For example, you can break when a variable reaches a certain value—e.g., (( $x < 0 ))—or when a particular piece of text has been written to a file (grep string file). You will probably think of all kinds of uses for this feature.[17] To set a break condition, type bc string. To remove it, type bc without arguments—this installs the null string, which is ignored.

[17] Bear in mind that if your break condition sends anything to standard output or standard error, you will see it after every statement executed. Also, make sure your break condition doesn't take a long time to run; otherwise your script will run very, very slowly.

_steptrap evaluates the break condition $_brcond only if it's not null. If the break condition evaluates to 0, then the if clause is true and, once again, _steptrap calls the command loop.

9.2.3.5 Execution tracing

The 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 follows.

  1. Debuggers tend to run programs slower than if they were executed on their own. bashdb is no exception. Depending upon the script you use it on, you'll find the debugger runs everything anywhere from 8 to 30 times more slowly. This isn't so much of a problem if you are stepping through a script in small increments, but bear it in mind if you have, say, initialization code with large looping constructs.

  2. The debugger will not "step down" into shell scripts that are called from the guinea pig. To do this, you'd have to edit your guinea pig script and change a call to scriptname to bashdb scriptname.

  3. Similarly, nested subshells are treated as one gigantic statement; you cannot step down into them at all.

  4. The guinea pig itself should not trap on the fake signals DEBUG and EXIT; otherwise the debugger won't work.

  5. Command error handling could be significantly improved.

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 inspire a more comprehensive bash debugger maintained by Rocky Bernstein, which you can find at the Bash Debugger Project, http://bashdb.sourceforge.net/ .

9.2.4. A Sample bashdb Session

Now 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 marked by asterisks (*). The right angle bracket (>) indicates that line 2 was the most recent line executed.

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

We'll conclude this chapter with some suggested enhancements to our simple debugger and a complete listing of the debugger command source code.

  1. Improve command error handling in these ways:

    1. Check that the arguments to s are valid numbers and print an appropriate error message if they aren't.

    2. Check that a breakpoint actually exists before clearing it and warn the user if the line doesn't have a breakpoint.

    3. Any other error handling that you can think of.

  2. Add code to remove duplicate breakpoints (more than one breakpoint on one line).

  3. Enhance the cb command so that the user can specify more than one breakpoint to be cleared at a time.

  4. Implement an option that causes a break into the debugger whenever a command exits with non-zero status:

    1. Implement it as the command-line option -e.

    2. Implement it as the debugger command e to toggle it on and off. (Hint: when you enter _steptrap, $? is still the exit status of the last command that ran.)

  5. Implement a command that prints out the status of the debugger: whether execution trace is on/off, error exit is on/off, and the number of the last line to be executed. In addition, move the functionality for displaying the breakpoints from bp to the new option.

  6. Add support for multiple break conditions, so that bashdb stops execution whenever one of them becomes true and prints a message indicating which one became true. Do this by storing the break conditions in an array. Try to make this as efficient as possible, since the checking will take place after every statement.

  7. Add the ability to watch variables.

    1. Add a command aw that takes a variable name as an argument and adds it to a list of variables to watch. Any watched variables are printed out when execution trace is toggled on.

    2. Add another command cw that, without an argument, removes all of the variables from the watch list. With an argument, it removes the specified variable.

  8. Although placing an underscore at the start of the debugger identifiers will avoid name clashes in most cases, think of ways to automatically detect name clashes with the guinea pig script and how to get around this problem. (Hint: you could rename the clashing names in the guinea pig script at the point where it gets combined with the preamble and placed in the temporary file.)

  9. Add any other features you can think of.

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=$1        # 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 "$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

}

     

     

# 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 "$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

}

     

     

# 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

}

    Previous Section  < Day Day Up >  Next Section