SSD Nodes Learn Hosting plans →
Anleitungen Matt ConnorVon Matt Connor · Aktualisiert 2026-10-28

Bash-Befehlsersetzung: $() oder Backticks verwenden?

Erfahren Sie, warum cd und Variablen in $() verschwinden, Backticks schlecht verschachtelt werden und welche Prozesssubstitution den Shell-Zustand erhält.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 4, 2026.

Was Bash-Befehlssubstitution bewirkt

Die Bash-Befehlssubstitution ersetzt $(command) durch den Text, den der Befehl an die Standardausgabe gesendet hat. Die ältere Schreibweise mit Backticks erfüllt denselben Zweck. Alle überraschenden Verhaltensweisen ergeben sich aus zwei Fakten: Der Befehl läuft in einem separaten Prozess, einer sogenannten Subshell, und jeder Zeilenumbruch am Ende der Ausgabe wird entfernt.

mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"
3

Das ist der gesamte Funktionsumfang. wc -l gab 3 gefolgt von einem Zeilenumbruch aus, der Zeilenumbruch wurde entfernt und count enthält das eine Zeichen, das Sie wollten. Beachten Sie, dass wc -l < three.txt eine reine Zahl ausgibt, da GNU wc beim Lesen der Standardeingabe keinen Dateinamen ausgeben kann. Schreiben Sie stattdessen wc -l three.txt, um 3 three.txt zu erfassen; dies ist eine andere Zeichenkette und eine häufige Ursache für Berechnungen, die später fehlschlagen.

Der Rest dieses Leitfadens behandelt das Verhalten, das niemand erwartet, da eine Subshell ein separater Prozess ist und ein separater Prozess die Shell, in der Sie gerade arbeiten, nicht verändern kann.

Verwenden Sie $() und keine Backticks mehr

Beide Formen sind gültig. $() ist in POSIX definiert, daher unterstützen dash, ash und busybox sh diese Form. Es gibt keinen Grund mehr aus Gründen der Portabilität Backticks zu verwenden. Außerdem sprechen zwei konkrete Gründe dagegen.

Backticks lassen sich nicht verschachteln

echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"
hi
echo hi

Die zweite Zeile gab die wörtlichen Wörter echo hi aus. Die Shell sucht bis zum nächsten nicht maskierten Backtick weiter. Daher schloss der zweite eingegebene Backtick den ersten. Der tatsächlich ausgeführte Befehl war echo ohne Argumente. Er gab eine leere Zeile aus, deren abschließendes Newline anschließend entfernt wurde. Die Wörter echo hi blieben als einfacher Text zurück. Das letzte Backtick-Paar führte einen leeren Befehl aus.

Um Backticks zu verschachteln, müssen Sie jeden inneren Backtick maskieren:

echo "`echo \`echo hi\``"
hi

Mit jeder weiteren Verschachtelungsebene verdoppelt sich der erforderliche Maskierungsaufwand. $() benötigt das nicht, weil der Parser Klammern statt eines Begrenzungszeichens verwendet.

Backticks verändern Ihre Backslashes, bevor der Befehl ausgeführt wird

echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"
a\\b
a\b

Derselbe innere Befehl erzeugte eine andere Ausgabe. Innerhalb von Backticks entfernt die Shell eine Ebene der Backslash-Maskierung, bevor der innere Text geparst wird. Daher schützten die einfachen Anführungszeichen nichts. Innerhalb von $() wird der Text zwischen den Klammern als gewöhnliches Skript geparst. Einfache Anführungszeichen verhalten sich daher wie erwartet. Das ist besonders problematisch bei sed- und awk-Einzeilern, bei denen ein entfernter Backslash ein funktionierendes Muster unbemerkt verändert.

Auch die Maskierung beginnt innerhalb von $() neu. Dadurch können Sie doppelte Anführungszeichen verschachteln, ohne sie zu maskieren:

path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"
/etc/nginx

Die entsprechende Backtick-Variante benötigt \" um $path. Jede Maskierung ist eine mögliche Fehlerquelle.

Warum cd innerhalb von $() Ihr Shell-Verzeichnis nicht ändert

Weil $(...) einen neuen Prozess erzeugt. Die Subshell erhält eine Kopie Ihrer Variablen und eine Kopie Ihres Arbeitsverzeichnisses. Sie ändert ihre Kopie, gibt etwas aus und beendet sich. Die Kopie stirbt mit ihr.

cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd
/tmp/subst-demo
/etc
/tmp/subst-demo

Nichts ist fehlgeschlagen. Das cd hat funktioniert, und pwd innerhalb der Subshell hat tatsächlich /etc ausgegeben. Es gibt schlicht keinen Weg für diese Änderung, zum Elternprozess zurückzukehren, da eine Subshell nur ihre Standardausgabe und einen Exit-Status an den Elternprozess übergibt.

Variablenzuweisungen verhalten sich auf die gleiche Weise:

count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"
inside: 99
outside: 0

Die gleiche Regel erklärt die Version dieses Fehlers, auf die Anwender weitaus häufiger stoßen und die eine Pipeline anstelle einer Substitution betrifft:

n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"
0

Jede Stufe einer Pipeline läuft in einer eigenen Subshell, daher hat die while-Schleife eine Kopie von n inkrementiert und sich dann beendet. Ersetzen Sie den Pipe-Operator durch eine Umleitung, und die Schleife läuft in Ihrer Shell:

n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"
3

Bash kann die letzte Stufe einer Pipeline in der aktuellen Shell mit shopt -s lastpipe ausführen, aber nur, wenn die Job-Steuerung deaktiviert ist. In einer interaktiven Shell ist die Job-Steuerung standardmäßig aktiviert. Verwenden Sie die Umleitung.

Wohin ist die Eingabeaufforderung verschwunden? Interaktive Befehle innerhalb von $()

Eine Befehlssubstitution leitet die Standardausgabe in eine Pipe um und lässt die Standardeingabe unverändert. Ein Programm, das seine Frage auf der Standardausgabe ausgibt und anschließend auf eine Antwort wartet, verliert die Frage, nicht aber das Warten. Das Terminal wirkt eingefroren.

ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
ask
Username: deploy
deploy

Das Wort deploy in der ersten Zeile ist Ihre Eingabe. Führen Sie nun dieselbe Funktion innerhalb einer Substitution aus:

v=$(ask)
deploy

Es erscheinen nur die von Ihnen eingegebenen Zeichen, die vom Terminaltreiber und nicht vom Programm zurückgegeben werden. Die Eingabeaufforderung ist an eine andere Stelle gelangt:

echo "$v"
Username: deploy

Die Eingabeaufforderung befindet sich nun innerhalb der Variablen und ist direkt mit der Antwort verbunden, weil $() alles erfasst hat, was die Funktion auf die Standardausgabe geschrieben hat. Ihre Eingabe erreichte weiterhin read, weil die Standardeingabe nicht verändert wurde. Das ist das typische Fehlerbild hinter der Meldung „Mein Skript hängt und gibt nichts aus“.

Einige Tools schreiben Eingabeaufforderungen auf die Standardfehlerausgabe oder direkt nach /dev/tty, sodass sie auch in diesem Fall sichtbar bleiben. Viele tun das jedoch nicht. Wenn eine Funktion innerhalb einer Substitution ausgeführt werden muss, geben Sie ihre Eingabeaufforderung selbst auf der Standardfehlerausgabe aus:

ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"
Username: deploy
deploy

Die Standardfehlerausgabe wird nicht erfasst. Daher erreicht die Eingabeaufforderung Ihr Terminal, und nur die Antwort landet in v.

Warum sieht ls innerhalb von $() anders aus?

Weil ls isatty über Dateideskriptor 1 aufruft und das Ausgabeformat anhand dieser Antwort ändert. An der Eingabeaufforderung ist dieser Dateideskriptor Ihr Terminal. Daher verteilt ls die Namen zeilenweise in Spalten. Innerhalb einer Substitution ist er eine Pipe. Deshalb wechselt ls zu einem Namen pro Zeile.

mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"
alpha
beta
delta
gamma

Dieselbe Prüfung deaktiviert die Farbgebung in grep --color=auto und den Pager in git. Das ist beabsichtigt. Ein Skript erhält dadurch eine stabile, maschinenlesbare Ausgabe, ohne dies explizit anfordern zu müssen.

Damit lässt sich auch eine häufige Frage zu Pipelines beantworten. ls | sort an der Eingabeaufforderung und $(ls | sort) geben dasselbe aus, weil ls in beiden Fällen eine Pipe an seiner Ausgabe hatte. Innerhalb einer Substitution ändert sich die letzte Stufe der Pipeline. sort prüft nie, ob ein Terminal vorhanden ist. Seine Ausgabe bleibt daher unverändert. Wird am Ende der Pipeline ein terminalabhängiger Befehl verwendet, ändert sich die Ausgabe. Deshalb kann sich eine Pipeline, die Sie bei der direkten Anzeige geprüft haben, anders verhalten, sobald Sie sie in $() einschließen.

Beachten Sie daher: Analysieren Sie die Ausgabe von ls nicht in einem Skript, auch wenn dies praktisch aussieht. Dateinamen können Leerzeichen und Zeilenumbrüche enthalten. Verwenden Sie ein Glob-Muster oder find -print0 mit read -d ''.

Die abschließenden Zeilenumbrüche, die unbemerkt verschwinden

Die Befehlsersetzung entfernt jeden abschließenden Zeilenumbruch aus der Ausgabe. Nicht nur den letzten. Alle.

cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c
8
5

Die Datei enthält hello plus drei Zeilenumbrüche, also 8 Bytes. Die Variable enthält hello, also 5 Bytes. Drei Bytes sind ohne Warnung verschwunden.

Das Entfernen ist beabsichtigt und meistens hilfreich. Dadurch erzeugt stamp=$(date -u +%Y%m%dT%H%M%SZ) einen verwendbaren Dateinamenbestandteil statt eines Namens mit einem Zeilenumbruch. Deshalb ist dieses Muster beispielsweise in einem geplanten restic-Backup-Skript unbedenklich:

stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"
backup-20260804T031500Z.tar.gz

Ihr Zeitstempel wird abweichen. Entscheidend ist, dass der Name aus einer Zeile besteht.

Der Nachteil ist, dass Sie $() nicht verwenden können, um die exakten Bytes einer Datei zu verschieben. Wenn Sie die abschließenden Zeilenumbrüche benötigen, hängen Sie innerhalb der Substitution ein Sentinel-Zeichen an und entfernen Sie es anschließend:

v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c
8

x steht nach den Zeilenumbrüchen. Daher bleiben keine abschließenden Zeilenumbrüche zum Entfernen übrig. ${v%x} entfernt anschließend das Sentinel-Zeichen und lässt die ursprünglichen Bytes unverändert.

Zwei verwandte Details: Zum Einlesen einer vollständigen Datei erledigt v=$(<blanks.txt) dieselbe Aufgabe, ohne cat auszuführen, weil bash die Datei selbst öffnet. Abschließende Zeilenumbrüche werden identisch entfernt. Here-Strings verhalten sich dagegen umgekehrt: Sie fügen einen Zeilenumbruch hinzu, den Sie nicht geschrieben haben:

wc -c <<< 'abc'
4

Zitieren Sie das Ergebnis, sonst teilt und expandiert bash es

Eine nicht quotierte Substitution wird einer Wortaufteilung und anschließend einer Pfadnamenerweiterung unterzogen. Eine quotierte Substitution durchläuft beides nicht.

printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"
a b c d
a b	c
d

Ohne Quotes teilte bash die Ausgabe an den Zeichen in IFS auf. Standardmäßig sind das Leerzeichen, Tabulatoren und Zeilenumbrüche. Anschließend fügte echo die vier Teile mit einzelnen Leerzeichen zusammen. Mit Quotes kam der Text als ein Wort an. Der Tabulator und der enthaltene Zeilenumbruch blieben erhalten.

Globbing ist der gefährlichere Teil:

mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"
one.txt pattern.txt two.txt
*

Die Zuweisung selbst war sicher, weil Zuweisungen weder einer Wortaufteilung noch einem Globbing unterliegen. Der Schaden entstand bei echo $p, wo * anhand des aktuellen Verzeichnisses expandiert wurde. Ein Skript, das ein Muster aus einer Konfigurationsdatei liest und die Quotes vergisst, arbeitet dadurch problemlos mit jeder sichtbaren Datei. Quotieren Sie jede Expansion, dann verschwindet diese gesamte Fehlerklasse. Lassen Sie die Quotes nur weg, wenn Sie die Aufteilung tatsächlich benötigen. Das ist selten der Fall.

Warum gibt local x=$(cmd) immer 0 zurück?

Weil local selbst ein Befehl ist und $? den Status von local meldet, nicht den Status der darin enthaltenen Substitution.

check_bad() { local out=$(false); echo "status: $?"; }
check_bad
status: 0

false wurde mit 1 beendet. local konnte die Variable erfolgreich deklarieren, und der Wert 1 ging verloren. declare, export, typeset und readonly verhalten sich genauso. Auch set -e erkennt den Fehler nicht, weil aus Sicht der Shell kein Befehl fehlgeschlagen ist.

Trennen Sie Deklaration und Zuweisung:

check_good() { local out; out=$(false); echo "status: $?"; }
check_good
status: 1

Eine einfache Zuweisung auf der obersten Ebene meldet bereits den Status der letzten Befehls-Substitution:

out=$(exit 3)
echo $?
3

Das ist besonders bei einer Integritätsprüfung wichtig, die unter einem systemd-Dienst und Timer ausgeführt wird. Ein verdeckter Exit-Code führt dazu, dass die Unit bei jedem Lauf Erfolg meldet, obwohl die zu prüfende Aufgabe nie ausgeführt wurde.

Die tatsächlich benötigten Alternativen innerhalb der Shell

Die meisten greifen zu $(...), weil sie Daten in einer Variable speichern möchten. Häufig benötigen sie aber eigentlich Eingaben und keine Erfassung. Diese vier Formen behalten den Zustand in Ihrer aktuellen Shell bei.

Eine Umleitung in der Schleife

cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txt
got: alpha
got: beta
got: gamma

Für die Eingabe wird kein Prozess erstellt. Daher bleiben alle Variablenänderungen aus dem Schleifenkörper nach der Schleife erhalten.

Process Substitution

while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)
got: gamma
got: beta
got: alpha

<(command) stellt Ihnen einen Pfad bereit, etwa /dev/fd/63, der die Ausgabe des Befehls liest. Der Befehl läuft weiterhin in einem eigenen Prozess. Die while-Schleife tut das nicht. Genau das ist der Zweck. Das Leerzeichen in < <( ist erforderlich: <<( wird als Beginn eines Here-Dokuments gelesen und kann daher nicht geparst werden. Process Substitution ist eine bash-Funktion. Ein Skript mit #!/bin/sh läuft unter Ubuntu oder Debian daher mit dash und schlägt dort fehl. Verwenden Sie #!/bin/bash.

Here-Strings

read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"
alpha
beta gamma

<<< übergibt eine Zeichenfolge an die Standardeingabe eines Befehls. read läuft in Ihrer Shell. Daher sind beide Variablen dort gesetzt, wo Sie sie verwenden können. read -r a b <<< "$(some-command)" ist die übliche Methode, um zwei Felder aus einer Ausgabezeile zu extrahieren.

mapfile für ganze Dateien

mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"
3
beta

mapfile, auch als readarray bezeichnet, liest eine Datei in ein Array der aktuellen Shell ein. -t entfernt den abschließenden Zeilenumbruch aus jedem Element. mapfile benötigt bash 4 oder neuer. Ubuntu 24.04 enthält bash 5.2, und Ubuntu 26.04 enthält bash 5.3. Daher ist der Befehl in jedem aktuellen Server-Image verfügbar.

Eine Checkliste, bevor Sie das Skript festschreiben

  • Schreiben Sie $(command) und setzen Sie es in Anführungszeichen als "$(command)", sofern Sie nicht ausdrücklich eine Aufteilung wünschen.
  • Gehen Sie davon aus, dass abschließende Zeilenumbrüche entfernt wurden. Fügen Sie ein Sentinel-Zeichen hinzu, wenn Sie sie wieder benötigen.
  • Verwenden Sie keine interaktiven Befehle in Substitutionen oder leiten Sie deren Eingabeaufforderungen an den Standardfehler um.
  • Schreiben Sie local out in eine eigene Zeile, wenn der Exit-Status von out=$(command) relevant ist.
  • Um Variablen aus Eingaben zu setzen, verwenden Sie eine Umleitung oder eine Process Substitution statt einer Pipe.

Diese Muster treten in den ersten kleinen Skripten auf, die viele beim Einrichten eines neuen VPS schreiben, und die Fehler bleiben unbemerkt. Ein Backup-Skript, das eine Eingabeaufforderung in einer Dateinamenvariable erfasst, oder eine Zustandsprüfung, die einen Exit-Code verdeckt, meldet weiterhin Erfolg. Der Aufwand steigt, sobald Sie dasselbe Skript auf mehreren Servern ausführen, weil die Ausgabe, die Sie nie gelesen haben, nun auf zwanzig Rechnern ungelesen bleibt.

FAQ

Warum ändert cd innerhalb von $() nicht mein aktuelles Verzeichnis?

$(...) führt den Befehl in einer Subshell aus. Dies ist ein separater Prozess, der eine Kopie Ihres Arbeitsverzeichnisses und Ihrer Variablen hält. Das cd ändert diese Kopie, dann beendet sich der Prozess und die Kopie wird verworfen. Eine Subshell kann nur ihre Standardausgabe und einen Exit-Status zurückgeben. Es gibt daher keinen Mechanismus, mit dem die Verzeichnisänderung die übergeordnete Shell erreichen kann. Wenn Sie das Verzeichnis selbst benötigen, erfassen Sie es mit target=$(cd /etc && pwd) und verwenden Sie "$target". Wenn sich Ihre Shell bewegen soll, führen Sie cd direkt aus, ohne eine Substitution darum zu setzen.

Was ist der Unterschied zwischen $() und Backticks in bash?

Sie erzeugen bei einfachen Befehlen das gleiche Ergebnis, unterscheiden sich jedoch in zwei relevanten Punkten. $() lässt sich direkt verschachteln, da der Parser Klammern abgleicht, während Backticks für jede Verschachtelungsebene ein maskiertes Backtick benötigen. Backticks entfernen zudem eine Ebene von Backslash-Maskierungen, bevor der innere Befehl geparst wird, sodass ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh` in POSIX enthalten sind und dort funktionieren, weshalb es kein Portabilitätsargument für Backticks gibt.

Warum hängt mein Skript ohne Eingabeaufforderung, wenn ein Befehl eine Frage stellt?

Die Befehlssubstitution leitet die Standardausgabe in eine Pipe um, lässt aber die Standardeingabe mit Ihrem Terminal verbunden. Ein Programm, das seine Eingabeaufforderung in die Standardausgabe schreibt, lässt diese Aufforderung in der Variablen erfassen, während das read dahinter weiterhin auf Ihre Eingabe wartet. Das Terminal zeigt nur die Zeichen, die Sie tippen, welche vom Terminaltreiber gespiegelt werden. Verschieben Sie die Frage aus der Substitution heraus oder lassen Sie die Aufforderung mit printf 'Username: ' >&2 in die Standardfehlerausgabe schreiben, damit sie nicht erfasst wird.

Warum verschwanden die Leerzeilen am Ende meiner Variablen?

Die Befehlssubstitution entfernt alle nachgestellten Zeilenumbrüche, nicht nur den letzten. printf 'hello\n\n\n' > f; v=$(cat f) lässt v fünf Bytes halten, während die Datei acht enthält. Um sie zu behalten, hängen Sie ein Platzhalterzeichen innerhalb der Substitution an und entfernen es anschließend mit v=$(cat f; printf x) gefolgt von v=${v%x}. Das Platzhalterzeichen steht nach den Zeilenumbrüchen, sodass am Ende nichts mehr übrig bleibt, das bash entfernen könnte.

Warum meldet local out=$(cmd) immer Erfolg?

local ist ein eigenständiger Befehl, und $? nach dieser Zeile berichtet, ob local die Variable erfolgreich deklariert hat. Der Exit-Status der Substitution wird konsumiert und verworfen, was auch bedeutet, dass set -e das Skript nicht stoppt. declare, export, typeset und readonly verhalten sich auf die gleiche Weise. Schreiben Sie local out in eine Zeile und out=$(cmd) in die nächste, dann meldet $? den tatsächlichen Status.

War dieser Artikel hilfreich?

#bash#shell-scripting#linux#subshell#coreutils