Linux Basic — Debugging Techniques Practice Exercises

5 minute read

Debugging Techniques — Practice Exercises

Five exercises across tracing, ShellCheck, debug modes, and the classic bug-hunt.


Exercise 1 — Trace With Custom PS4

Goal: Set a PS4 that includes the filename (basename only), line number, and function name. Then trace this snippet and capture the output:

greet() {
    local name=$1
    echo "Hello, $name"
}

set -x
greet Alice
greet Bob
💡 Show Answer
#!/usr/bin/env bash
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '

greet() {
    local name=$1
    echo "Hello, $name"
}

set -x
greet Alice
greet Bob

Run and capture:

$ bash script.sh 2> trace.log
Hello, Alice
Hello, Bob

$ cat trace.log
+ script.sh:9:main: greet Alice
+ script.sh:4:greet: local name=Alice
+ script.sh:5:greet: echo 'Hello, Alice'
+ script.sh:10:main: greet Bob
+ script.sh:4:greet: local name=Bob
+ script.sh:5:greet: echo 'Hello, Bob'

Three things to remember:

  • export PS4=… makes the new prompt take effect (also subshells)
  • ${BASH_SOURCE##*/} is “basename of current source file” — no fork
  • ${FUNCNAME[0]:-main} falls back to main outside any function (FUNCNAME is empty in top-level code)

Exercise 2 — Fix a Broken Script With ShellCheck

Goal: This script has at least 5 bugs that ShellCheck will catch. Run shellcheck on it, fix every warning, and end up with a script that handles filenames with spaces correctly.

#!/bin/sh

DIR=$1
cd $DIR
files=`ls *.log`
for f in $files; do
    if [ -s $f ]; then
        wc -l $f
    fi
done
💡 Show Answer

Issues ShellCheck flags:

  • SC2086 — $DIR and $f should be quoted
  • SC2006 — use $(...) not backticks
  • SC2010/SC2207 — don’t parse ls; use glob + array
  • SC2164 — cd without || exit
  • SC2148 — #!/bin/sh but using bashisms? Be explicit
  • SC2128 — $files is a single string treated as an array

Fixed version:

#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob

dir=${1:?Usage: $0 <dir>}
cd -- "$dir" || { echo "Cannot cd to $dir" >&2; exit 1; }

files=( *.log )
(( ${#files[@]} )) || { echo "No .log files in $dir" >&2; exit 0; }

for f in "${files[@]}"; do
    [[ -s "$f" ]] || continue
    wc -l -- "$f"
done

Improvements:

  • set -euo pipefail strict mode
  • shopt -s nullglob so empty glob doesn’t iterate literal *.log
  • ${1:?…} required-arg check via parameter expansion
  • cd … || exit with friendly message
  • Quoted "$dir" and "$f"
  • -- separator on cd and wc in case of path starting with -
  • Array for glob results
  • Explicit empty-array check with a useful message

Run shellcheck fixed.sh → no warnings.


Exercise 3 — Build a DEBUG-Aware Logger

Goal: Implement three log functions:

  • info() — always prints to stderr with INFO: prefix
  • debug() — prints only when DEBUG=1, with DEBUG: prefix
  • trace() — prints only when DEBUG=2, with TRACE: prefix

All three should accept any number of arguments and join with spaces.

Demonstrate by calling each with the same message under DEBUG unset, DEBUG=1, and DEBUG=2.

💡 Show Answer
#!/usr/bin/env bash
set -euo pipefail

LOG_LEVEL=${DEBUG:-0}

info()  { printf 'INFO:  %s\n' "$*" >&2; }
debug() { (( LOG_LEVEL >= 1 )) || return 0; printf 'DEBUG: %s\n' "$*" >&2; }
trace() { (( LOG_LEVEL >= 2 )) || return 0; printf 'TRACE: %s\n' "$*" >&2; }

info  "Always visible"
debug "Visible at DEBUG=1+"
trace "Visible only at DEBUG=2"

Test all three:

$ ./script.sh
INFO:  Always visible

$ DEBUG=1 ./script.sh
INFO:  Always visible
DEBUG: Visible at DEBUG=1+

$ DEBUG=2 ./script.sh
INFO:  Always visible
DEBUG: Visible at DEBUG=1+
TRACE: Visible only at DEBUG=2

Three things to notice:

  • All output goes to stderr (>&2) — keeps stdout clean for data
  • (( LOG_LEVEL >= N )) || return 0 — early-return when disabled costs almost nothing
  • "$*" joins args with a single space (uses $IFS’s first char — usually a space)

For a one-letter CLI variant:

while [[ $# -gt 0 ]]; do
    case "$1" in
        -v) LOG_LEVEL=1; shift ;;
        -vv) LOG_LEVEL=2; shift ;;
        *) break ;;
    esac
done

Exercise 4 — Diagnose: Why Is count 0?

Goal: This script should print 5, but prints 0. Explain why, then fix it without using shopt -s lastpipe.

#!/usr/bin/env bash
count=0
seq 1 5 | while read -r n; do
    (( count++ ))
done
echo "$count"
💡 Show Answer

Why it fails: The while loop runs in a subshell (the right-hand side of a pipe). (( count++ )) increments count inside the subshell. When the subshell ends, that local change vanishes. The outer count is still 0.

Fix with process substitution:

#!/usr/bin/env bash
count=0
while read -r n; do
    (( count++ ))
done < <(seq 1 5)
echo "$count"
# 5

< <(cmd) reads stdin directly from a pipe to cmd. The while loop runs in the current shell — no subshell, so count++ persists.

Other fixes:

# A. Direct file redirect (no subshell)
seq 1 5 > /tmp/nums
count=0
while read -r n; do (( count++ )); done < /tmp/nums

# B. Capture output, then iterate
count=0
output=$(seq 1 5)
while read -r n; do (( count++ )); done <<< "$output"

# C. mapfile + count array
mapfile -t nums < <(seq 1 5)
count=${#nums[@]}

The mapfile + array form is often the cleanest when you don’t actually need a while loop.


Exercise 5 — Capture Trace to a Separate File

Goal: Run a script in trace mode but route the trace output to /tmp/myscript.trace while keeping stdout and stderr clean for normal output.

Test script:

#!/usr/bin/env bash
echo "normal stdout"
echo "normal stderr" >&2

The trace file should contain the + echo … lines. Stdout and stderr should NOT include the trace.

💡 Show Answer
#!/usr/bin/env bash

# Open fd 3 to the trace file
exec 3>/tmp/myscript.trace

# Direct xtrace to fd 3
export BASH_XTRACEFD=3
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '

set -x

echo "normal stdout"
echo "normal stderr" >&2

Test:

$ ./script.sh
normal stdout
normal stderr

$ cat /tmp/myscript.trace
+ script.sh:9: echo 'normal stdout'
+ script.sh:10: echo 'normal stderr'

Stdout/stderr are clean. The trace lives in its own file.

BASH_XTRACEFD is the right knob — without it, traces always go to stderr and you’d see them on the console.

If you want trace + normal output both on the console while ALSO saving the trace:

exec 3> >(tee -a /tmp/myscript.trace >&2)
export BASH_XTRACEFD=3
set -x

>(cmd) is process substitution; tee -a … >&2 writes to both the trace file and stderr.


What’s Next?

You have the full diagnostic toolkit: strict mode, traps, traces, ShellCheck, debug modes, and the catalog of common bugs. The next chapter moves into advanced scripting primitives — here-documents and here-strings, process substitution, subshells, command grouping, eval, coprocesses, and exec.

Continue → Advanced Scripting

Updated: