Skip to content
ALB Wiki

Diff — Shell Scripting

Comparing rev 3 → rev 4

{{infobox
| title = Shell Scripting
| chapter = Linux Handbook, chapter 14 — POSIX programs: variables, control flow, functions, the discipline
| core | A script is a program — shebang, exit codes, arguments, control flow
| dialects = POSIX sh (portable) · bash (the capable superset)
| skills | quoting hygiene · set flags · traps · functions · the idioms
| next | The C toolchain — where scripts end and compilers begin
}}
**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
```sh
#!/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
```sh
$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
```
```sh
if [ "$#" -lt 1 ]; then
    echo "usage: $0 <file>" >&2
    exit 1
fi
for f in "$@"; do ...; done      # "$@" iterates args safely
```
## Variables — scope and habits
```sh
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
```sh
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
```sh
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
```sh
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
```sh
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
```sh
# 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
```
## getopts — parsing flags
```sh
usage() { echo "usage: $0 [-v] [-o out] files..."; }
verbose=0; out=
while getopts "vo:h" opt; do
    case "$opt" in
        v) verbose=1 ;;
        o) out="$OPTARG" ;;
        h) usage; exit 0 ;;
        *) usage; exit 1 ;;
    esac
done
shift $((OPTIND - 1))     # remaining args in $@
```
`getopts` is POSIX — the `-v`, `-o arg` grammar handled properly. bash's `getopt` (no s) and manual `while`/`case` on `$1` handle `--long-flags` where getopts doesn't.
## The safe-script template — the professional skeleton
```sh
#!/bin/sh
# what this does — one honest line
set -eu                          # die on error, unset vars
umask 022
tmp=$(mktemp -d)                 # a private tempdir
trap 'rm -rf "$tmp"' EXIT        # guaranteed cleanup
trap 'echo "fail at line $LINENO" >&2' ERR   # bash only
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
require() { command -v "$1" >/dev/null 2>&1 || die "$1 required"; }
require tar
require awk
main() {
    [ "$#" -ge 1 ] || die "need an argument"
    for f in "$@"; do
        [ -f "$f" ] || die "not a file: $f"
        process "$f"
    done
}
main "$@"
```
The pieces: `set -eu`, `mktemp -d` + `trap`, `die()`, `require()` for tool checks, `main "$@"` as the entry — the shape every serious script takes.
## The signal traps — the shutdown machinery
```sh
trap 'cleanup' EXIT     # runs on any exit — even errors — THE idiom
trap 'kill $pid' TERM   # graceful shutdown
trap '' INT             # ignore Ctrl-C — the don't-interrupt-us zone
trap 'echo got sigusr1' USR1
trap - INT              # reset to default
```
## The debugging toolkit
```sh
sh -x script.sh          # trace — THE debugger
sh -n script.sh          # syntax-only check
bash -v script.sh        # print lines as read
set -x ... set +x        # trace a region
PS4='+ $LINENO: ' sh -x script.sh   # line-numbered trace
shellcheck script.sh     # the linter — the real debugging tool
```
`shellcheck` catches the quoting bugs, the `for x in $(ls)`, the unguarded `rm` — run it on every script.
## The idioms library
```sh
# default value
: "${VAR:=default}"
# required var
: "${VAR:?must set VAR}"
# command exists
command -v cmd >/dev/null || die "missing cmd"
# retry n times
for i in 1 2 3; do cmd && break || sleep 1; done
# run-as-user
sudo -u svcacct script.sh
# the lock — only one instance
exec 9>/tmp/myapp.lock; flock -n 9 || exit 0
# atomic write
tmp=$(mktemp) && write > "$tmp" && mv "$tmp" target
# the here-doc to file
cat > file <<'EOF'
content — quoted EOF = literal, unquoted = expands
EOF
# parallel jobs with wait
for x in a b c; do work "$x" & done; wait
# read config key=val pairs
while IFS='=' read -r k v; do case "$k" in name) NAME="$v";; esac; done < cfg
```
## POSIX vs bash — what you gain and lose
```sh
# 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
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
1. Write `backup.sh` — arg check, `cp -a`, `die` on failure, usage text.
2. The while-read vs for-in-cat difference — prove the subshell loss.
3. `trap` a temp dir's cleanup — exit early mid-script, verify it still cleaned.
4. `sh -x` a script you wrote — read the trace.
5. Port a bash script (`[[`, arrays) to strict POSIX — what's actually needed?
## See also
- [[Shell]] · [[Text processing]] · [[Core Utilities]] · [[Linux]]
[[Category:Linux]]
[[Category:Handbook]]
[[Category:Scripting]]
+
[[Category:Linux Handbook]]