Shell Scripting
A shell script is a program — the same grammar from chapter 9, saved and executable. This chapter turns the grammar into software: the shebang contract, arguments, control flow, functions, the safety flags, and the idioms real scripts are built from — all in portable POSIX sh, with bash's extensions marked.
The skeleton — every script's preamble
#!/bin/sh
# what this script does — one honest comment line
set -eu # -e die on error · -u die on unset vars
IFS='
' # optional: strict word-splitting hygiene
#!/bin/sh — the shebang — makes it a program; the kernel reads #! and hands the file to the named interpreter. chmod +x and it's runnable. The set flags are the professional preamble: -e stops at the first failure (no zombie scripts continuing after a cd failed), -u catches typo'd variables, -o pipefail (bash) makes pipelines propagate inner failures. #!/usr/bin/env bash for bash scripts specifically.
Arguments — the script's input
$0 # the script's own name
$1 $2 ... # positional args
$# # count
$@ # all args as separate words ("$@" — always quoted)
$* # all args as one word
shift # drop $1, everything moves down
if [ "$#" -lt 1 ]; then
echo "usage: $0 <file>" >&2
exit 1
fi
for f in "$@"; do ...; done # "$@" iterates args safely
Variables — scope and habits
var=value # NO SPACES around =
echo "$var" # always quoted
readonly VERSION=1.0 # constants
local x # function-local — bash; POSIX uses distinct names
export PATH="$PATH:/x" # into the environment
cmd=$(command) # capture output — the universal idiom
Control flow — the complete set
if [ -f "$file" ]; then
echo exists
elif [ -d "$file" ]; then
echo dir
else
echo missing
fi
case "$1" in
start) daemon_start ;;
stop) daemon_stop ;;
-h|--help) usage ;;
*) echo "unknown: $1"; exit 1 ;;
esac
for f in *.txt; do # glob-driven
process "$f"
done
for i in 1 2 3; do echo "$i"; done
for i in $(seq 1 10); do ...; done # numeric via seq
while IFS= read -r line; do # the file-reading idiom — THE way to read lines
printf '%s\n' "$line"
done < input.txt
until [ -f ready ]; do sleep 1; done # until-loop
The while IFS= read -r line loop is the canonical file iterator — read splits on IFS, -r keeps backslashes, the IFS= prefix preserves whitespace. Never for line in $(cat file) — it word-splits.
Functions
die() { echo "error: $*" >&2; exit 1; }
usage() { cat <<EOF
usage: $0 <args>
EOF
}
backup() {
local src="$1" dst="$2" # bash 'local'
cp -a "$src" "$dst" || die "backup failed: $src"
}
backup a/ b/ || exit 1 # functions return exit codes like commands
The safety machinery
set -eu # the preamble
set -x # trace — the debugging mode
trap 'cleanup' EXIT # guaranteed cleanup — THE pattern
trap 'echo "line $LINENO failed"' ERR # bash ERR trap
trap '' INT # ignore signals
tmp=$(mktemp -d) # safe tempdirs — always mktemp, never /tmp/foo.$$
trap 'rm -rf "$tmp"' EXIT
trap ... EXIT is the idiom that separates careful scripts from sloppy ones — temp files, lock files, mounted things — cleaned even on early failure.
printf over echo
printf '%s\n' "$var" # portable, predictable
printf '%-10s %5d\n' name 42 # formatted columns
echo "$var" # fine for simple; printf for format control
echo has portability landmines (-e, -n, backslashes); printf has none — use it for anything beyond a plain string.
The idioms that earn their keep
# arg-count check
[ "$#" -ge 1 ] || die "need a file"
# default value
name=${1:-default}
# required command
command -v gcc >/dev/null || die "gcc missing"
# quiet a command
cmd >/dev/null 2>&1
# the lock — prevent double-run
lock=/tmp/myapp.lock
exec 200>"$lock"; flock -n 200 || exit 0
# config override
: "${CONF:=/etc/myapp.conf}" # bash/POSIX default-assign
# dry-run flag
[ "$DRY" = 1 ] && echo "would rm $f" || rm "$f"
# the pipe-safe read
cat file | while read -r l; do x="$l"; done # subshell — x dies outside!
while read -r l; do x="$l"; done < file # same shell — x survives
POSIX vs bash — what you gain and lose
# bash powers worth the shebang
arr=(a b c); echo "${arr[1]}" # arrays
[[ "$s" =~ ^[0-9]+$ ]] # regex test
${var/pat/rep} # substitution
(( i++ )) # arithmetic syntax
select opt in a b; do ...; done # menus
declare -A map; map[k]=v # associative arrays
Write POSIX unless you need the extension — then say #!/bin/bash plainly and use it.
Debugging — the honest workflow
sh -x script.sh # trace every step — the debugger
bash -n script.sh # syntax-check without running
set -x; ...; set +x # trace a region
echo "DEBUG: var=$var" >&2 # printf-style probes to stderr
set -x plus trap plus >&2 diagnostics covers most script debugging without a real debugger.
Where scripts go wrong
| Symptom | Cause |
|---|---|
var=x: command not found | Spaces around = — var = x isn't assignment |
| Loop var empty outside the loop | | while = subshell — < file instead |
[ $x = 1 ] errors on empty x | Unquoted — [ "$x" = 1 ] |
| Script says "file not found" | CRLF line endings — the shebang line has \r |
| Works in bash, dies in sh | #!/bin/sh script with bashisms |
| Temp file collision | Used a fixed name — mktemp |
| Script keeps going after failure | Missing set -e |
read ate the pipe | for+$(cat) instead of while-read |
Exercises
- Write
backup.sh— arg check,cp -a,dieon failure, usage text. - The while-read vs for-in-cat difference — prove the subshell loss.
trapa temp dir's cleanup — exit early mid-script, verify it still cleaned.sh -xa script you wrote — read the trace.- Port a bash script (
[[, arrays) to strict POSIX — what's actually needed?