Bash · Exit-Codes · POSIX · Fehlerbehandlung · Shell-API
Exit-Codes als API zwischen Skripten verstehen
0–255, POSIX-Bedeutungen, $?, set -e und Pipe-Exit-Codes

Exit-Codes sind die einzige strukturierte Schnittstelle zwischen Bash-Skripten. Wer versteht, was 0–255 bedeuten, wie $? funktioniert, wann set -e nicht auslöst und wie pipefail Pipe-Exit-Codes rettet, schreibt Skripte, die zuverlässig miteinander kommunizieren – statt Fehler still zu übersehen.

13 Min. Lesezeit Exit-Codes · POSIX · $? · set -e · pipefail · PIPESTATUS Bash 4.x · 5.x · POSIX Shell · Linux

1. Was Exit-Codes wirklich sind

Exit-Codes sind ganzzahlige Werte zwischen 0 und 255, die ein Prozess beim Beenden an seinen Elternprozess zurückgibt. In Bash ist dieser Wert über die spezielle Variable $? nach jedem Befehl verfügbar. Der Wert 0 bedeutet Erfolg – jeder andere Wert signalisiert einen Fehler oder eine bestimmte Bedeutung, abhängig vom Programm. Diese einfache Konvention ist die fundamentale Kommunikationsschnittstelle zwischen Prozessen in Unix-Systemen und damit zwischen Bash-Skripten.

Das Konzept der Exit-Codes als API zu begreifen bedeutet: Wenn ein Bash-Skript ein anderes Skript oder Programm aufruft, ist der Exit-Code des aufgerufenen Prozesses der einzige standardisierte Rückgabekanal. Natürlich gibt es auch stdout und stderr – aber während diese freitext-basiert und programm-spezifisch sind, sind Exit-Codes ein universeller Standard. Jedes POSIX-konforme Programm, jedes Shell-Builtin, jedes Bash-Skript kommuniziert über denselben Mechanismus. Wer diesen Mechanismus versteht und konsequent nutzt, baut Skripte, die verlässlich miteinander interagieren.

In der Praxis sieht man häufig Bash-Skripte, die Exit-Codes ignorieren: Ein Befehl wird aufgerufen, das Ergebnis wird nicht geprüft, das Skript läuft weiter. Das ist equivalent zu einem API-Aufruf, bei dem die HTTP-Statusantwort ignoriert wird. Die Folgen sind dieselben: Das Skript führt möglicherweise Folgeschritte aus, die auf einer falschen Ausgabe oder einem nicht vorhandenen Ergebnis aufbauen, ohne dass ein Fehler signalisiert wird.

2. POSIX-Exit-Code-Bedeutungen: 0–255 im Detail

POSIX definiert für den Wertebereich der Exit-Codes einen groben Standard. 0 bedeutet Erfolg. 1 ist der generische Fehlercode – die meisten Unix-Programme verwenden ihn für allgemeine Fehler. 2 wird oft für falsche Verwendung oder ungültige Argumente genutzt. Der Bereich 3–125 ist für programmspezifische Exit-Codes frei. Der Bereich 126–127 ist für Shell-spezifische Fehlermeldungen reserviert: 126 bedeutet, dass ein Programm gefunden, aber nicht ausführbar war. 127 bedeutet, dass ein Programm nicht gefunden wurde. 128 + N ist das Muster für signalbedingte Beendigungen: Ein durch Signal 9 (SIGKILL) beendeter Prozess gibt 137 zurück (128 + 9). Ein durch Signal 15 (SIGTERM) beendeter Prozess gibt 143 zurück (128 + 15).

Bash selbst setzt Exit-Codes für spezifische Situationen: 130 für Unterbrechung durch CTRL+C (SIGINT, Signal 2), 141 für Broken Pipe (SIGPIPE, Signal 13). Das Verstehen dieser Bedeutungen ist entscheidend für die korrekte Behandlung von Signalen in Trap-Handlern. Ein Skript, das mit trap 'cleanup; exit 130' INT SIGINT abfängt, gibt den korrekten Exit-Code für Ctrl+C weiter und ermöglicht dem Aufrufer, zwischen normalem Fehler und benutzerinitiiertem Abbruch zu unterscheiden.


#!/usr/bin/env bash
# exit-code-demo.sh — Demonstrate POSIX exit code conventions
set -euo pipefail

# Define meaningful exit codes as named constants
readonly EXIT_SUCCESS=0
readonly EXIT_GENERAL_ERROR=1
readonly EXIT_USAGE_ERROR=2
readonly EXIT_CONFIG_MISSING=3
readonly EXIT_NETWORK_UNREACHABLE=4
readonly EXIT_PERMISSION_DENIED=5
readonly EXIT_TIMEOUT=6
readonly EXIT_LOCK_HELD=7

usage() {
  echo "Usage: $(basename "$0") <command> [args...]" >&2
  exit "$EXIT_USAGE_ERROR"
}

validate_config() {
  local config_file="$1"
  if [[ ! -f "$config_file" ]]; then
    echo "[ERROR] Config file not found: $config_file" >&2
    exit "$EXIT_CONFIG_MISSING"
  fi
  if [[ ! -r "$config_file" ]]; then
    echo "[ERROR] Config file not readable: $config_file" >&2
    exit "$EXIT_PERMISSION_DENIED"
  fi
}

# Caller can distinguish between error types
[[ $# -lt 1 ]] && usage
validate_config "${CONFIG_FILE:-/etc/app/config.yml}"

# Check exit code of external command
if ! curl -sf --max-time 10 "https://api.mironsoft.de/health" >/dev/null 2>&1; then
  last_exit=$?
  if [[ $last_exit -eq 28 ]]; then  # curl timeout = 28
    echo "[ERROR] API health check timed out" >&2
    exit "$EXIT_TIMEOUT"
  else
    echo "[ERROR] API health check failed (curl: $last_exit)" >&2
    exit "$EXIT_NETWORK_UNREACHABLE"
  fi
fi

echo "[OK] All checks passed"
exit "$EXIT_SUCCESS"

3. $? auswerten: die richtige und die falsche Art

Die spezielle Variable $? enthält den Exit-Code des zuletzt ausgeführten Befehls. Die wichtigste Eigenschaft von $?: Sie wird sofort durch den nächsten ausgeführten Befehl überschrieben. Wer also $? nach mehreren Befehlen auswerten will, muss sie in einer normalen Variable sichern: exit_code=$?. Das sofortige Sichern von $? nach einem Befehl ist die einzig korrekte Art, den Exit-Code eines bestimmten Befehls zu verwerten.

Ein häufiger Fehler: befehl; if [ $? -ne 0 ] ist anfällig, weil zwischen dem Befehl und dem if-Check andere Befehle ausgeführt werden könnten (z.B. durch Redirections oder Subshell-Aufrufe, die $? überschreiben). Der robustere Ansatz ist entweder das direkte Testen des Befehls in der if-Bedingung (if ! befehl; then) oder das sofortige Speichern: befehl; rc=$?; if [[ $rc -ne 0 ]]. Der direkteste Weg ist die Verwendung von if befehl; then – das ist idiomatisches Bash und lässt $? im Hintergrund.

4. set -e: Verhalten, Grenzen und Fallen

set -e ist eine der wichtigsten Sicherheitsmechanismen bei der Verwendung von Exit-Codes in Bash-Skripten. Es weist Bash an, das Skript sofort zu beenden, wenn ein Befehl einen Nicht-Null-Exit-Code zurückgibt. Ohne set -e ignoriert Bash fehlgeschlagene Befehle standardmäßig und setzt die Ausführung fort – ein Verhalten, das in interaktiven Shells sinnvoll ist, in Automatisierungsskripten aber Fehler verdeckt.

Die wichtigste Einschränkung: set -e löst in Bedingungskontexten nicht aus. Wenn ein Befehl Teil einer if-Bedingung ist (if fehlschlagender_befehl; then), nach || steht (fehlschlagender_befehl || fallback) oder nach && kommt (erfolg && fehlschlagender_befehl), wird der Nicht-Null-Exit-Code nicht als Abbruchbedingung behandelt. Das ist beabsichtigt – in diesen Kontexten ist der Exit-Code ein Bedingungsergebnis, kein Fehler. Wer diese Stellen dennoch absichern will, muss explizit || { echo "Fehler"; exit 1; } schreiben.


#!/usr/bin/env bash
# set-e-behavior.sh — Understanding set -e edge cases
set -euo pipefail

# CASE 1: set -e triggers — script exits immediately
# false  # Uncomment to see: script stops here with exit code 1

# CASE 2: set -e does NOT trigger in if-context
if false; then
  echo "unreachable"
fi
echo "After if: still running (exit code was NOT treated as error)"

# CASE 3: set -e does NOT trigger after ||
false || echo "Fallback executed — false's exit code was consumed by ||"

# CASE 4: set -e does NOT trigger after &&
true && false || echo "Right side of && failed — consumed by ||"

# CASE 5: Correctly saving exit code before set -e can fire
run_and_check() {
  local cmd="$1"
  local rc=0
  # Subshell so set -e doesn't kill parent
  ( eval "$cmd" ) && rc=$? || rc=$?
  if [[ $rc -ne 0 ]]; then
    echo "[WARN] Command '$cmd' exited with code $rc"
  fi
  return "$rc"
}

run_and_check "ls /nonexistent" || true

# CASE 6: Explicit exit code propagation
deploy_step() {
  local step_name="$1"
  shift
  if ! "$@"; then
    echo "[FAIL] Step '$step_name' failed with exit code $?" >&2
    return 1
  fi
  echo "[OK] Step '$step_name' succeeded"
}

deploy_step "health-check" curl -sf http://localhost/health

5. Pipe-Exit-Codes und pipefail

Pipes sind eine der mächtigsten Funktionen von Bash, haben aber eine kritische Eigenschaft bei Exit-Codes: Ohne set -o pipefail ist der Exit-Code einer Pipeline immer der Exit-Code des letzten Befehls in der Pipe. Ein Fehler in einem frühen Schritt der Pipeline wird vollständig verdeckt, solange der letzte Befehl erfolgreich ist. Das klassische Beispiel: failing_command | grep "pattern" gibt Exit-Code 0 zurück, wenn grep das Muster findet oder wenn grep keine Ausgabe vom fehlgeschlagenen Befehl zu verarbeiten hatte.

Mit set -o pipefail gibt eine Pipeline den Exit-Code des letzten Befehls mit Nicht-Null-Exit-Code zurück. Wenn alle Befehle in der Pipe erfolgreich sind, ist der Exit-Code der Pipeline 0. Das Zusammenspiel von set -e und set -o pipefail stellt sicher, dass kein fehlgeschlagener Befehl in einer Pipeline verborgen bleibt. Die Kombination set -euo pipefail deckt damit die häufigsten Quellen von unerkannten Fehlern in Bash-Skripten ab.

6. PIPESTATUS: jeden Schritt in einer Pipe auswerten

PIPESTATUS ist ein Bash-Array, das die Exit-Codes aller Befehle in der letzten Pipeline enthält. Nach cmd1 | cmd2 | cmd3 enthält ${PIPESTATUS[0]} den Exit-Code von cmd1, ${PIPESTATUS[1]} den von cmd2 und ${PIPESTATUS[2]} den von cmd3. Das erlaubt granulare Fehlerdiagnose: Welcher Schritt in einer komplexen Pipe-Kette ist gescheitert? Diese Information ist bei pipefail allein nicht verfügbar, weil nur der Code des zuletzt fehlgeschlagenen Befehls zurückgegeben wird.

PIPESTATUS muss sofort nach der Pipe gesichert werden, da es durch den nächsten Befehl (auch durch ein einfaches echo) überschrieben wird. Das Muster: cmd1 | cmd2; pipe_codes=("${PIPESTATUS[@]}") sichert alle Exit-Codes der Pipe-Stufen. Anschließend kann jeder Code einzeln ausgewertet werden. Dieses Muster ist besonders in Build-Pipelines wertvoll, wo man wissen muss, ob ein Compilerfehler, ein Linting-Fehler oder ein Ausgabe-Filterschritt fehlgeschlagen ist.


#!/usr/bin/env bash
# pipestatus-demo.sh — Granular pipe exit code analysis
set -uo pipefail

check_pipeline() {
  local description="$1"
  shift
  # Run pipeline and capture PIPESTATUS immediately
  eval "$@" || true
  local -a statuses=("${PIPESTATUS[@]}")

  local all_ok=true
  for i in "${!statuses[@]}"; do
    if [[ "${statuses[$i]}" -ne 0 ]]; then
      echo "[FAIL] Pipeline '$description': step $((i+1)) exited with ${statuses[$i]}" >&2
      all_ok=false
    fi
  done

  $all_ok || return 1
  echo "[OK] Pipeline '$description' succeeded"
}

# Example: multi-step pipeline with individual exit code tracking
process_logs() {
  local logfile="$1"
  local pattern="$2"
  local output="$3"

  grep -E "$pattern" "$logfile" \
    | sort -u \
    | awk '{print NR": "$0}' \
    > "$output"

  local -a statuses=("${PIPESTATUS[@]}")

  # grep exits 1 if no match (not an error per se)
  if [[ "${statuses[0]}" -eq 1 ]]; then
    echo "[INFO] No matches found for pattern '$pattern'" >&2
    return 0
  fi

  # sort or awk failing is a real error
  for i in 1 2; do
    if [[ "${statuses[$i]}" -ne 0 ]]; then
      echo "[FAIL] Step $((i+1)) in log processing failed with code ${statuses[$i]}" >&2
      return 1
    fi
  done

  echo "[OK] Processed $(wc -l < "$output") matching lines"
}

process_logs "/var/log/syslog" "ERROR|CRITICAL" "/tmp/errors.txt"

7. Eigene Exit-Codes als API definieren

Das Definieren eigener Exit-Codes als dokumentierte API ist ein Qualitätsmerkmal professioneller Shell-Skripte. Anstatt pauschal Exit-Code 1 für alle Fehlertypen zu verwenden, reserviert man spezifische Codes für verschiedene Fehlerzustände: 2 für Konfigurationsfehler, 3 für Netzwerkfehler, 4 für Timeout, 5 für fehlende Abhängigkeiten. Aufrufer – andere Skripte, Cron-Jobs, CI-Pipelines – können den Exit-Code auswerten und entsprechend reagieren: Bei Timeout automatisch erneut versuchen, bei Konfigurationsfehlern Alarm schlagen, bei Netzwerkfehlern eine Wartezeit einlegen.

Diese Exit-Code-API sollte in einem Header-Kommentar des Skripts dokumentiert werden und als readonly-Konstanten definiert sein. Das verhindert versehentliches Überschreiben und macht die möglichen Rückgabewerte sofort sichtbar. Wenn mehrere Skripte zusammenarbeiten, empfiehlt sich eine gemeinsame Bibliotheksdatei exit-codes.sh, die von allen Skripten per source eingebunden wird – so sind die Exit-Codes konsistent über alle Skripte eines Projekts.

8. Exit-Codes in Subshells, Funktionen und Hintergrundprozessen

Bash-Funktionen geben ihren letzten Befehlsstatus als Exit-Code zurück, oder den explizit per return N gesetzten Wert. Wichtig: return kann in Funktionen nur Werte von 0–255 zurückgeben – keine Strings, keine Objekte. Wenn eine Funktion einen komplexen Status kommunizieren muss, ist die Ausgabe nach stdout oder das Setzen einer globalen Variable der übliche Weg. Der Exit-Code kommuniziert nur Erfolg (0) oder einen von mehreren definierten Fehlerzuständen.

Bei Hintergrundprozessen (befehl &) ist der Exit-Code nicht sofort über $? verfügbar. Er wird erst nach dem wait PID-Aufruf gesetzt. Ein häufiger Fehler: job &; pid=$!; echo "started"; wait $pid; if [[ $? -ne 0 ]] – das echo zwischen & und wait überschreibt $?, aber das ist hier korrekt, weil $? erst nach wait ausgewertet wird. Werden mehrere Hintergrundprozesse gestartet, muss jede PID einzeln mit wait abgewartet und ihr Exit-Code gesichert werden.

9. Exit-Code-Verhalten im direkten Vergleich

Das Verhalten von Exit-Codes unterscheidet sich in verschiedenen Bash-Kontexten erheblich. Diese Tabelle zeigt die wichtigsten Unterschiede, die häufig zu Missverständnissen führen.

Kontext Nicht-Null Exit-Code set -e Reaktion Empfehlung
Normaler Befehl false, ls /nofile Skript bricht ab Gewünscht – Standardverhalten
if-Bedingung if false; then Kein Abbruch Explizit mit || exit 1 absichern wenn nötig
Nach || Operator false || echo fallback Kein Abbruch Bewusst nutzen für kontrollierte Fallbacks
Pipe ohne pipefail false | grep x Kein Abbruch (verdeckt!) Immer set -o pipefail nutzen
Hintergrundprozess false & Kein Abbruch wait $! und Exit-Code manuell prüfen

Die Tabelle zeigt, warum Exit-Codes als API konsequent verstanden werden müssen: In jedem Kontext verhält sich Bash anders. Die wichtigste Regel: set -euo pipefail deckt normale Befehle und Pipes ab, aber Bedingungskontexte und Hintergrundprozesse erfordern immer explizite Behandlung. Wer das verinnerlicht, vermeidet die häufigsten Quellen von stillen Fehlern in Bash-Skripten.

Mironsoft

Shell-Automatisierung, Fehlerbehandlung und Deployment-Infrastruktur

Exit-Codes konsequent als API zwischen Skripten nutzen?

Wir analysieren bestehende Bash-Skripte auf unsichere Exit-Code-Behandlung und implementieren eine dokumentierte Exit-Code-API mit konsistenter Fehlerbehandlung, pipefail und PIPESTATUS-Auswertung für euren Deployment-Stack.

Code-Review

Analyse aller Exit-Code-Verwendungen auf stille Fehler und verdeckte Pipe-Failures

API-Design

Dokumentierte Exit-Code-Bibliothek für konsistente Fehler-Kommunikation zwischen Skripten

Pipeline-Härtung

pipefail, PIPESTATUS und trap ERR nachrüsten – kein Fehler bleibt mehr verborgen

10. Zusammenfassung

Exit-Codes als API zwischen Skripten zu verstehen bedeutet: 0 ist Erfolg, alles andere ist ein spezifischer Zustand, der kommuniziert werden soll. Die Kombination set -euo pipefail stellt sicher, dass kein Nicht-Null-Exit-Code in normalen Befehlen und Pipes verborgen bleibt. PIPESTATUS erlaubt granulare Diagnose innerhalb von Pipe-Ketten. Eigene Exit-Codes als benannte Konstanten definieren macht Skripte zu dokumentierten APIs.

Die Grenzen von set -e zu kennen ist mindestens so wichtig wie es zu nutzen: Bedingungskontexte, ||-Operatoren und Hintergrundprozesse werden nicht automatisch abgefangen. Wer diese Ausnahmen versteht, kann entscheiden, welche Fehlerbehandlung wo explizit nötig ist – und schreibt Skripte, die Fehler weder verschweigen noch übereifrig abbrechen, wo Fehler erwartet und normal sind.

Exit-Codes als API — Das Wichtigste auf einen Blick

Wertebereich

0 = Erfolg. 1 = generischer Fehler. 2 = falsche Verwendung. 126 = nicht ausführbar. 127 = nicht gefunden. 128+N = Signal N.

set -e Grenzen

Löst nicht in if, while, nach || oder && aus. Hintergrundprozesse erfordern explizites wait + Exit-Code-Prüfung.

PIPESTATUS

Sofort nach der Pipe sichern: statuses=("${PIPESTATUS[@]}"). Gibt jeden Schritt einzeln aus – unverzichtbar für Diagnose komplexer Pipes.

Eigene Exit-Code-API

Exit-Codes als readonly-Konstanten definieren, in exit-codes.sh auslagern, dokumentieren. Aufrufer können Fehlertypen unterscheiden und gezielt reagieren.

11. FAQ: Exit-Codes als API zwischen Skripten verstehen

1Was bedeutet Exit-Code 127?
Befehl nicht gefunden (command not found). Ursachen: falscher PATH, fehlendes Binary, Tippfehler.
2Warum gibt eine Pipeline exit 0 trotz Fehler zurück?
Ohne pipefail ist der Pipeline-Exit-Code immer der des letzten Befehls. set -o pipefail löst das Problem.
3PIPESTATUS nutzen?
Sofort nach Pipe sichern: statuses=("${PIPESTATUS[@]}"). Enthält Exit-Code jedes Pipe-Schritts. Wird beim nächsten Befehl überschrieben.
4set -e löst nicht in if-Bedingungen aus?
In Bedingungskontexten ist Nicht-Null kein Fehler, sondern Bedingungsergebnis. POSIX-konformes Verhalten. Explizit absichern mit || { exit 1; }.
5$? korrekt sichern?
befehl; rc=$? — sofort nach dem Befehl speichern, bevor irgendein weiterer Befehl $? überschreibt. Idiomatisch: if ! befehl; then direkt testen.
6Exit-Code von Hintergrundprozessen?
pid=$! speichern, dann wait $pid; rc=$?. wait blockiert bis Prozess endet und setzt $? auf dessen Exit-Code.
7Was bedeutet Exit-Code 130?
Beendigung durch SIGINT (Ctrl+C): 128 + 2 = 130. In trap INT-Handlern exit 130 setzen für korrekte Signalweiterleitung.
8Eigene Exit-Codes als API definieren?
readonly EXIT_CONFIG_MISSING=3 als Konstante. In exit-codes.sh auslagern, per source einbinden. Bereich 3–125 frei für eigene Codes.
9exit vs. return in Bash?
exit beendet das gesamte Skript. return beendet nur die aktuelle Funktion. Beide akzeptieren 0–255. Außerhalb einer Funktion verhält return sich wie exit.
10Exit-Codes in CI-Pipelines testen?
BATS (Bash Automated Testing System): run ./script.sh; assert_equal $status 0. Als regulärer Test-Step in CI ausführen.