funwithlinux guide

Building a Bash Script Debugger: Step-by-Step Guide

Bash scripts are the workhorses of automation, powering everything from simple file backups to complex system administration tasks. However, as scripts grow in complexity, debugging becomes a critical skill. A misplaced `;`, an uninitialized variable, or a logical error can turn a reliable script into a source of frustration. While Bash provides built-in debugging tools (e.g., `set -x`), they often lack granular control—like breakpoints, step-by-step execution, or variable inspection. In this guide, we’ll build a custom Bash script debugger from scratch. By the end, you’ll understand how debuggers work under the hood, gain hands-on experience with Bash’s advanced features, and have a functional tool to debug your scripts more effectively. Whether you’re a system administrator, developer, or DevOps engineer, this project will deepen your Bash expertise and make you a more confident script troubleshooter.

Table of Contents

  1. Why Build a Custom Bash Debugger?
  2. Prerequisites
  3. Understanding Bash Debugging Fundamentals
  4. Designing Our Debugger: Core Features
  5. Step 1: Parsing the Target Script
  6. Step 2: Implementing Breakpoints
  7. Step 3: Adding Step-by-Step Execution
  8. Step 4: Building a Debugger Prompt
  9. Step 5: Testing the Debugger with a Sample Script
  10. Enhancing the Debugger: Advanced Features
  11. Conclusion
  12. References

Why Build a Custom Bash Debugger?

Bash’s built-in debugging tools are limited:

  • set -x prints every command before execution (verbose, no control).
  • set -e exits on error (but doesn’t help diagnose why).

A custom debugger lets you:

  • Pause execution at specific lines (breakpoints).
  • Inspect variables at runtime.
  • Step through code line-by-line.
  • Add custom logic (e.g., conditional breakpoints).

Plus, building one teaches you Bash internals: signal handling, command evaluation, and script parsing.

Prerequisites

  • Basic Bash knowledge (variables, loops, functions).
  • A Unix-like environment (Linux/macOS).
  • Bash 4.0+ (for associative arrays; check with bash --version).

Understanding Bash Debugging Fundamentals

Before building, let’s recap key Bash concepts:

  • Script Execution: Bash reads scripts line-by-line, executing each command in sequence.
  • Signal Handling: Traps (trap) can catch signals (e.g., SIGINT) to pause/resume execution.
  • Command Evaluation: eval "command" executes a string as a Bash command.
  • Associative Arrays: declare -A breakpoints stores key-value pairs (e.g., line_number=true).

Designing Our Debugger: Core Features

Our minimal debugger will support:

  1. Breakpoints: Pause at specified line numbers.
  2. Step Execution: Run one line at a time.
  3. Variable Inspection: Print values of variables.
  4. Interactive Prompt: Accept commands like next, continue, print, quit.

Step 1: Parsing the Target Script

First, the debugger needs to read and parse the target script. We’ll store lines in an array, skipping comments and empty lines (to avoid debugging noise).

Code Snippet: Parsing the Script

#!/bin/bash

# Debugger variables
declare -a script_lines  # Stores non-empty, non-comment lines
declare -i current_line=0  # Tracks current execution line
declare -A breakpoints  # Associative array: key=line_number, value=true

# Parse target script into script_lines array
parse_script() {
    local target_script="$1"
    local line_num=0

    # Read script line-by-line
    while IFS= read -r line; do
        ((line_num++))
        # Skip empty lines and comments (starting with #)
        if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then
            continue
        fi
        # Store line with original line number (for breakpoints)
        script_lines+=("$line_num:$line")
    done < "$target_script"
}

Explanation:

  • parse_script reads the target script, skips comments/empty lines, and stores lines in script_lines with their original line numbers (e.g., 5:echo "Hello").
  • script_lines uses line_num:line to track original line numbers (critical for breakpoints).

Step 2: Implementing Breakpoints

Breakpoints let users pause execution at specific lines. We’ll let users set breakpoints via the break command (e.g., break 10).

Code Snippet: Breakpoint Management

# Add a breakpoint
add_breakpoint() {
    local line_num="$1"
    breakpoints["$line_num"]=true
    echo "Breakpoint set at line $line_num"
}

# Check if current line has a breakpoint
has_breakpoint() {
    local line_num="$1"
    [[ -n "${breakpoints[$line_num]}" ]]
}

Explanation:

  • add_breakpoint stores line numbers in the breakpoints associative array.
  • has_breakpoint checks if the current line is in breakpoints.

Step 3: Adding Step-by-Step Execution

Next, we need to run the script line-by-line. We’ll loop through script_lines, execute each line, and pause if:

  • The line is a breakpoint.
  • The user requested step (single-line execution).

Code Snippet: Execution Loop

execute_script() {
    local target_script="$1"
    parse_script "$target_script"

    # Main execution loop
    for entry in "${script_lines[@]}"; do
        # Split entry into original line number and line content (e.g., "5:echo ...")
        local line_num="${entry%%:*}"
        local line_content="${entry#*:}"
        ((current_line++))

        # Pause if breakpoint or step mode is active
        if has_breakpoint "$line_num" || [[ "$step_mode" == true ]]; then
            echo -e "\n[PAUSED] Line $line_num: $line_content"
            interactive_prompt
        fi

        # Execute the line
        echo -e "\nExecuting: $line_content"
        eval "$line_content"  # Execute the line as a Bash command
        local exit_code=$?

        # Handle errors
        if [[ $exit_code -ne 0 ]]; then
            echo "Error: Line $line_num exited with code $exit_code"
            exit $exit_code
        fi
    done
}

Explanation:

  • execute_script loops through script_lines, splits each entry into line_num (original line) and line_content (code to run).
  • Before executing, it checks for breakpoints or step_mode (enabled via step command) and pauses.
  • eval "$line_content" executes the line. We capture the exit code to handle errors.

Step 4: Building a Debugger Prompt

When paused, the debugger needs an interactive prompt to accept user commands.

Code Snippet: Interactive Prompt

# Interactive prompt for user commands
interactive_prompt() {
    local command
    while true; do
        read -p "(debugger) " command
        case "$command" in
            next|n)  # Step to next line
                step_mode=true
                break
                ;;
            continue|c)  # Resume until next breakpoint
                step_mode=false
                break
                ;;
            print|p)  # Print variable (usage: print var)
                local var="${command#* }"
                if [[ -z "$var" ]]; then
                    echo "Usage: print <variable>"
                    continue
                fi
                echo "$var=${!var}"  # Indirect expansion to get variable value
                ;;
            list|l)  # List surrounding lines
                local start=$((current_line - 2))
                local end=$((current_line + 2))
                echo "Lines $start-$end:"
                for ((i=start; i<=end; i++)); do
                    if [[ -n "${script_lines[$i-1]}" ]]; then
                        echo "  ${script_lines[$i-1]}"
                    fi
                done
                ;;
            quit|q)  # Exit debugger
                echo "Exiting debugger."
                exit 0
                ;;
            *)
                echo "Unknown command: $command. Use: next (n), continue (c), print (p), list (l), quit (q)"
                ;;
        esac
    done
}

Explanation:

  • interactive_prompt reads user input and handles commands:
    • next/n: Steps to the next line (enables step_mode).
    • continue/c: Resumes execution until the next breakpoint.
    • print/p var: Prints the value of var (uses indirect expansion ${!var}).
    • list/l: Shows 2 lines before/after the current line.
    • quit/q: Exits the debugger.

Step 5: Testing the Debugger with a Sample Script

Let’s test our debugger with a buggy script. Create sample_script.sh:

#!/bin/bash

# A script with a logical error
count=0
for i in {1..3}; do
    count=$((count + 1))  # Correct: should increment by 1 each loop
    # Bug: The next line mistakenly sets count to 10 instead of adding
    count=10
done
echo "Final count: $count"  # Expected: 3, Actual: 10

Goal: Use the debugger to find why count is 10 instead of 3.

Running the Debugger

  1. Save the debugger code as bash_debugger.sh and make it executable:

    chmod +x bash_debugger.sh
  2. Run the debugger with the sample script:

    ./bash_debugger.sh sample_script.sh
  3. Set a breakpoint at line 5 (the loop body):

    (debugger) break 5
  4. Use continue to run to the breakpoint, then next to step through lines. Use print count to inspect the variable:

    [PAUSED] Line 5: count=$((count + 1))
    (debugger) print count
    count=0
    (debugger) next
    Executing: count=$((count + 1))
    
    [PAUSED] Line 6: count=10
    (debugger) print count
    count=1
    (debugger) next
    Executing: count=10
    
    [PAUSED] Line 5: count=$((count + 1))
    (debugger) print count
    count=10  # Oh! Line 6 overwrites count to 10. Found the bug!

Enhancing the Debugger: Advanced Features

To make the debugger more powerful, add:

1. Conditional Breakpoints

Pause only if a condition is met (e.g., break 5 if $count > 5).

2. Watchpoints

Break when a variable changes (e.g., watch count).

3. Backtraces

Show the call stack (useful for debugging functions).

Example: Conditional Breakpoints

# Add to add_breakpoint function
add_breakpoint() {
    local line_num="$1"
    local condition="${2:-true}"  # Default: always break
    breakpoints["$line_num"]="$condition"  # Store condition instead of "true"
    echo "Breakpoint set at line $line_num (condition: $condition)"
}

# Modify has_breakpoint to check condition
has_breakpoint() {
    local line_num="$1"
    local condition="${breakpoints[$line_num]}"
    [[ -n "$condition" ]] && eval "$condition"  # Evaluate condition
}

Now users can set: break 5 if $count -gt 2 (pause line 5 only if count > 2).

Conclusion

You’ve built a functional Bash script debugger! This tool helps you pause execution, inspect variables, and step through code—skills that translate to debugging real-world scripts.

Key takeaways:

  • Bash’s eval and associative arrays enable dynamic execution and state management.
  • Interactive prompts turn static scripts into interactive tools.
  • Parsing and filtering script lines is critical for focused debugging.

To expand further, explore bashdb (a full-featured Bash debugger) or add watchpoints/backtraces to your custom tool.

References


Happy debugging! 🐞