Zum Inhalt springen

Robuste Skripte

Deine Skripte funktionieren, solange alles gut geht. Robust ist ein Skript, wenn es auch dann vernünftig reagiert, wenn etwas schiefläuft: ein fehlendes Argument, ein Dateiname mit Leerzeichen, ein Befehl, der scheitert. Diese Seite sammelt die Techniken dafür. Am Ende wendest du sie auf addscript.sh und menu.sh an.

Die häufigste Fehlerquelle in Shell-Skripten sind fehlende Anführungszeichen. Die Shell zerlegt Werte ohne Anführungszeichen an Leerzeichen und wertet * und ? als Muster aus:

Terminal-Fenster
datei="mein text.txt"
touch "$datei"
ls -l $datei # sucht zwei Dateien: mein und text.txt
ls -l "$datei" # sucht eine Datei
Regel Beispiel
Variablen beim Lesen immer in doppelte Anführungszeichen "$datei", "${menu[0]}"
Befehlssubstitution ebenso "$(basename "$0")"
Alle Argumente oder alle Array-Elemente einzeln "$@", "${menu[@]}"
Einfache Anführungszeichen, wenn nichts ersetzt werden soll '$HOME bleibt wörtlich'
Doppelte Anführungszeichen, wenn Variablen ersetzt werden sollen "Hallo $USER"
Innerhalb von [[ ]] und (( )) wären Anführungszeichen nicht nötig wir setzen sie trotzdem, der Konsistenz zuliebe

Im Zweifel: Anführungszeichen setzen. Es gibt praktisch keinen Fall, in dem "$var" falsch und $var richtig ist.

Ohne weitere Massnahmen läuft ein Skript nach einem gescheiterten Befehl einfach weiter. Ein cd ins falsche Verzeichnis, gefolgt von rm -rf *, ist der klassische Unfall. Drei Shell-Optionen ändern das. Sie stehen direkt nach dem Kopf, vor den Variablen:

skript.sh
set -euo pipefail
Option Wirkung
-e Das Skript endet sofort, wenn ein Befehl einen Status ungleich 0 liefert
-u Der Zugriff auf eine nicht gesetzte Variable ist ein Fehler, Tippfehler in Variablennamen fallen sofort auf
-o pipefail Eine Pipe gilt als gescheitert, wenn irgendein Befehl darin scheitert, nicht nur der letzte

Das ändert das Verhalten spürbar, darum ein paar Stolperfallen und ihre Lösungen:

  • Befehle, die scheitern dürfen. grep liefert Status 1, wenn es nichts findet, und -e beendet das Skript. Entweder grep muster datei || true, oder den Befehl direkt als Bedingung verwenden: if grep -q muster datei; then. In Bedingungen von if und while sowie links von && und || greift -e nicht.
  • Nicht gesetzte Argumente. dateiname="$1" ohne Argument ist mit -u ein Fehler. Erst $# prüfen, dann zuweisen, oder einen Standardwert angeben: "${1:-}" (siehe Parameter-Expansion unten).
  • Umgebungsvariablen. $EDITOR ist nicht überall gesetzt. "${EDITOR:-vim}" nimmt vim, wenn die Variable fehlt.
  • Zähler. (( i++ )) liefert bei i=0 den Status 1, weil der Ausdruck den alten Wert 0 ergibt, und beendet das Skript. Schreibe (( i += 1 )) oder i=$(( i + 1 )).
  • Interaktive Eingaben. read liefert Status 1 bei Ctrl+D. Das Skript endet dann, was meist in Ordnung ist.

Normale Ausgabe geht auf die Standardausgabe, Fehlermeldungen gehören auf die Standardfehlerausgabe. Das erreichst du mit >&2. Wer dein Skript in eine Pipe oder eine Datei umleitet, bekommt die Meldungen trotzdem zu sehen, und sie landen nicht in den Daten:

Terminal-Fenster
echo "$programm: Fehler, $dateiname existiert bereits." >&2
exit 1

Da sich das wiederholt, lohnt sich eine Funktion dafür:

Terminal-Fenster
fehler() {
echo "$programm: $1" >&2
exit "${2:-1}"
}

Aufgerufen als fehler "Datei existiert bereits" oder mit eigenem Status als fehler "Argument fehlt" 2.

Bei den Exit-Codes gibt es keine strenge Norm, aber eine verbreitete Praxis:

Code Bedeutung
0 Erfolg
1 allgemeiner Fehler (Datei fehlt, Befehl gescheitert)
2 falscher Aufruf (Argumente, Optionen), so machen es auch grep und die Bash
126, 127 von der Shell reserviert: nicht ausführbar, nicht gefunden

Wichtig ist nicht die genaue Zahl, sondern dass 0 nur bei Erfolg kommt und der Aufrufer Fehler erkennt.

Temporäre Dateien legt man nicht in /tmp/meinskript.tmp an, sondern mit mktemp, das einen eindeutigen, sicheren Namen erzeugt. Und man räumt sie auf, auch wenn das Skript vorzeitig endet. Dafür registriert trap eine Funktion, die beim Beenden läuft, egal ob regulär, durch exit 1 oder durch Ctrl+C:

skript.sh
set -euo pipefail
# Variablen
tmpdir=$(mktemp -d)
# Funktionen
cleanup() {
rm -rf "$tmpdir"
}
# Hauptprogramm
trap cleanup EXIT
echo "Arbeite in $tmpdir"
# ... hier passiert die eigentliche Arbeit ...

mktemp ohne -d erzeugt eine Datei statt eines Verzeichnisses. Das Muster «anlegen, trap setzen, arbeiten» gehört zu jedem Skript, das Zwischendateien braucht.

Drei Werkzeuge in dieser Reihenfolge:

Terminal-Fenster
bash -n skript.sh # nur Syntax prüfen, nichts ausführen
bash -x skript.sh # jede Zeile vor der Ausführung anzeigen

bash -x zeigt mit vorangestelltem + jeden Befehl, nachdem die Shell Variablen ersetzt hat. So siehst du, was wirklich ausgeführt wird. Wenn nur ein Abschnitt interessiert, schaltest du die Anzeige im Skript ein und aus:

Terminal-Fenster
set -x
# ... verdächtiger Abschnitt ...
set +x

Und Fehlermeldungen lesen: ./menu.sh: line 42: syntax error near unexpected token nennt die Zeile. Der eigentliche Fehler liegt oft ein paar Zeilen davor, etwa ein fehlendes fi oder ;;.

ShellCheck findet Fehler, die der Bash selbst nicht auffallen: fehlende Anführungszeichen, Variablen, die nie benutzt werden, Konstrukte, die anders funktionieren, als man denkt. Es läuft lokal, in VS Code über die gleichnamige Erweiterung und in der Pipeline für jede Abgabe unter labs/linux/.

Terminal-Fenster
sudo apt install shellcheck
shellcheck labs/linux/08-skripte/*.sh

Jede Meldung hat eine Nummer, die du im Wiki nachschlagen kannst. Die häufigsten:

Code Meldung Beispiel und Lösung
SC2086 Double quote to prevent globbing and word splitting chmod +x $dateiname wird zu chmod +x "$dateiname"
SC2046 Quote this to prevent word splitting rm $(cat liste) zerlegt die Ausgabe an Leerzeichen, besser eine Schleife über die Zeilen
SC2162 read without -r will mangle backslashes read antwort wird zu read -r antwort
SC2035 Use ./*glob* or -- *glob* so names with dashes won’t become options ls -l *.sh wird zu ls -l ./*.sh
SC2034 Variable appears unused Tippfehler im Namen oder Variable tatsächlich überflüssig
SC2006 Use $(…) notation instead of legacy backticks `date` wird zu $(date)

Eine Meldung lässt sich mit einem Kommentar in der Zeile davor unterdrücken, zum Beispiel # shellcheck disable=SC2034. Tu das nur, wenn du erklären kannst, warum die Meldung hier nicht zutrifft, und schreibe den Grund dazu.

Die Bash kann beim Lesen einer Variablen gleich etwas mit dem Wert anstellen. Das erspart viele Aufrufe von basename, cut oder sed:

Ausdruck Ergebnis bei pfad="labs/linux/08-skripte/menu.sh"
${pfad:-standard} der Wert, oder standard, falls die Variable leer oder nicht gesetzt ist
${pfad:?Meldung} der Wert, oder Abbruch mit Meldung, falls leer
${#pfad} die Länge: 24
${pfad##*/} alles bis zum letzten / entfernt: menu.sh (wie basename)
${pfad%/*} ab dem letzten / entfernt: labs/linux/08-skripte (wie dirname)
${pfad%.sh} Endung .sh entfernt: labs/linux/08-skripte/menu
${pfad/menu/haupt} erstes menu durch haupt ersetzt
${pfad^^}, ${pfad,,} alles gross, alles klein

Merkhilfe: # schneidet vorne ab (auf der Tastatur links von $), % hinten (rechts von $). Einfach heisst kürzeste Übereinstimmung, doppelt längste.

Terminal-Fenster
pfad="labs/linux/08-skripte/menu.sh"
echo "${pfad##*/}" "${pfad%.sh}" "${#pfad}"
echo "${EDITOR:-vim}"

Mehrzeilige Ausgaben oder Dateiinhalte schreibst du nicht mit einem echo über zwölf Zeilen, sondern mit einem Here-Doc. Alles zwischen <<EOF und der Zeile EOF wird zur Eingabe des Befehls:

cat > "$dateiname" <<EOF
#!/bin/bash
#
# $dateiname
# $zweck
#
# $datum / $verfasser
#
EOF

Variablen werden ersetzt wie in doppelten Anführungszeichen. Soll nichts ersetzt werden, setzt du das Schlusswort in Anführungszeichen: <<'EOF'. Praktisch für Hilfetexte, die Dollarzeichen enthalten. Das Wort EOF ist Konvention, erlaubt ist jedes Wort, es muss nur am Schluss allein auf der Zeile stehen.

echo reicht für einfache Ausgaben. Sobald Spalten oder Zahlenformate ins Spiel kommen, nimm printf. Der erste Parameter ist der Format-String, die weiteren füllen die Platzhalter:

Terminal-Fenster
printf "%-12s %6s\n" "Datei" "Grösse"
printf "%-12s %6d\n" "menu.sh" 1234
printf "%.2f Prozent\n" 93.456
printf "%s\n" "${menu[@]}"

%s steht für einen String, %d für eine ganze Zahl, %f für eine Kommazahl. %-12s füllt auf zwölf Zeichen auf, linksbündig. printf hängt keinen Zeilenumbruch an, den schreibst du als \n. Die letzte Zeile zeigt einen Trick: Hat der Format-String weniger Platzhalter als Argumente, wird er wiederholt, so landet jedes Array-Element auf einer eigenen Zeile.

  • Einrückung konsequent, in diesem Kurs vier Leerzeichen pro Ebene, keine Tabs.
  • Variablen klein mit Unterstrich (skript_name), Konstanten und exportierte Variablen gross (MAX_VERSUCHE). Funktionen beschreiben eine Tätigkeit (zeige_menu, pruefe_argumente).
  • Kommentare erklären das Warum, nicht das Was. # Zähler erhöhen über (( i += 1 )) hilft niemandem.
  • Ein Skript, eine Aufgabe. Wird es länger als ein Bildschirm, gehört die Logik in Funktionen, das Hauptprogramm bleibt eine lesbare Abfolge von Aufrufen.
  • Kopfzeilen nach der Konvention, Benutzungshinweis bei jedem Skript mit Argumenten.
Lösung anzeigen

Das gehärtete addscript.sh, identisch mit scripts/linux/08-skripte/addscript.sh:

addscript.sh
#!/bin/bash
#
# addscript.sh
# Erstellt ein neues Skript aus dem Kurs-Template, macht es
# ausführbar und öffnet es im Editor.
#
# Benutzung: addscript.sh skriptname.sh ["Zweck des Skripts"]
#
# 2026-11-05 / Vorname Nachname
#
set -euo pipefail
# Variablen
programm="${0##*/}" # Dateiname ohne Pfad
benutzung="Benutzung: $programm skriptname.sh [\"Zweck des Skripts\"]"
datum="$(date +%d.%m.%Y)"
# Vollständiger Name aus /etc/passwd (Feld 5, bis zum ersten Komma),
# falls dort nichts steht der Login-Name
verfasser="$(getent passwd "$USER" | cut -d: -f5 | cut -d, -f1)"
verfasser="${verfasser:-$(whoami)}"
editor="${EDITOR:-vim}" # Editor aus der Umgebung, sonst vim
# Hauptprogramm
# Aufruf prüfen: mindestens ein Parameter
if [[ $# -lt 1 ]]; then
echo "$benutzung" >&2
exit 2
fi
dateiname="$1"
# Abbruch, falls die Datei schon existiert
if [[ -e "$dateiname" ]]; then
echo "$programm: Fehler, $dateiname existiert bereits." >&2
exit 1
fi
# Zweck als zweiter Parameter, sonst nachfragen
if [[ -n "${2:-}" ]]; then
zweck="$2"
else
read -r -p "Zweck des Skripts: " zweck
fi
# Template schreiben (Here-Doc: alles bis EOF landet in der Datei)
cat > "$dateiname" <<EOF
#!/bin/bash
#
# ${dateiname##*/}
# $zweck
#
# $datum / $verfasser
#
# Variablen
# Funktionen
# Hauptprogramm
EOF
# Ausführbar machen und im Editor öffnen
chmod +x "$dateiname"
"$editor" "$dateiname"

Was sich gegenüber Teil 1 geändert hat und warum:

  • dateiname="$1" steht erst nach der Argumentprüfung, sonst meldet -u eine nicht gesetzte Variable.
  • "${2:-}" statt "$2" aus demselben Grund.
  • ${0##*/} ersetzt basename "$0", ein Prozess weniger.
  • ${verfasser:-$(whoami)} fängt ein leeres Namensfeld in /etc/passwd ab.

In menu.sh sind zwei Stellen betroffen, beide im case:

menu.sh
0)
echo "=> ${menu[0]}"
ls -l ./*.sh
ls -l ./*.sh 2>/dev/null || echo "Keine Skripte in $PWD gefunden."
;;
1)
echo "=> ${menu[1]}"
read -r -p "Name des Skripts: " skriptname
read -r -p "Zweck des Skripts: " skriptzweck
"$skriptpfad/addscript.sh" "$skriptname" "$skriptzweck"
"$skriptpfad/addscript.sh" "$skriptname" "$skriptzweck" \
|| echo "addscript.sh hat einen Fehler gemeldet."
;;

Ohne das || würde mit set -e ein Verzeichnis ohne Skripte oder ein Fehler in addscript.sh das ganze Menü beenden. Das vollständige Skript liegt unter scripts/linux/08-skripte/menu.sh.

Selbstcheck · 6 Fragen

1. Was bewirkt set -e?
2. Warum ist grep unter set -e eine Stolperfalle?
3. Wohin gehören Fehlermeldungen?
4. Was liefert ${pfad##*/} bei pfad="labs/linux/08-skripte/menu.sh"?
5. Was meldet ShellCheck mit SC2086?
6. Welche Aussagen zu trap cleanup EXIT stimmen? (mehrere Antworten möglich)