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.
Was die Befehlsersetzung in Bash macht
Die Befehlsersetzung in Bash ersetzt $(command) durch den Text, den der Befehl auf die Standardausgabe geschrieben hat. Die ältere Schreibweise mit Backticks erfüllt denselben Zweck. Alles, was dabei überrascht, ergibt sich aus zwei Tatsachen: Der Befehl wird in einem separaten Prozess, einer sogenannten Subshell, ausgeführt, und jeder Zeilenumbruch am Ende seiner 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"3Das ist die gesamte Funktion. wc -l gab 3 gefolgt von einem Zeilenumbruch aus, der Zeilenumbruch wurde entfernt, und count enthält die beiden gewünschten Zeichen. Beachten Sie, dass wc -l < three.txt nur eine Zahl ohne weiteren Text ausgibt, weil GNU wc beim Lesen aus der Standardeingabe keinen Dateinamen ausgeben kann. Schreiben Sie stattdessen wc -l three.txt, erfassen Sie 3 three.txt. Das ist eine andere Zeichenfolge und eine häufige Ursache für später fehlschlagende arithmetische Ausdrücke.
Im weiteren Verlauf dieses Leitfadens geht es um das Verhalten, das niemand erwartet, weil eine Subshell ein separater Prozess ist und ein separater Prozess die Shell, in die Sie Befehle eingeben, nicht ä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 hiDie 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\``"hiMit 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\bDerselbe 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/nginxDie entsprechende Backtick-Variante benötigt \" um $path. Jede Maskierung ist eine mögliche Fehlerquelle.
Warum cd innerhalb von $() Ihre Shell nicht ändert
Weil $(...) einen neuen Prozess forkt. Die Subshell erhält eine Kopie Ihrer Variablen und eine Kopie Ihres Arbeitsverzeichnisses. Sie ändert ihre Kopie, gibt etwas aus und wird beendet. Die Kopie wird dabei ebenfalls beendet.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demoEs ist kein Fehler aufgetreten. cd wurde ausgeführt, und pwd innerhalb der Subshell hat tatsächlich /etc ausgegeben. Es gibt lediglich keinen Weg, auf dem diese Änderung an den übergeordneten Prozess zurückgegeben werden kann. Eine Subshell übergibt ihrem übergeordneten Prozess nur die Standardausgabe und einen Exit-Status.
Variablenzuweisungen verhalten sich genauso:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0Dieselbe Regel erklärt auch die Variante dieses Fehlers, die wesentlich häufiger auftritt. Dabei wird eine Pipeline statt einer Substitution verwendet:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0Jede Stufe einer Pipeline läuft in einer eigenen Subshell. Daher hat die while-Schleife eine Kopie von n erhöht und wurde anschließend beendet. Ersetzen Sie die Pipe durch eine Umleitung. Dann läuft die Schleife in Ihrer Shell:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash kann die letzte Stufe einer Pipeline mit shopt -s lastpipe in der aktuellen Shell ausführen, allerdings nur bei deaktivierter Jobsteuerung. Das ist in einer interaktiven Shell nie der Fall. 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"; }
askUsername: deploy
deployDas Wort deploy in der ersten Zeile ist Ihre Eingabe. Führen Sie nun dieselbe Funktion innerhalb einer Substitution aus:
v=$(ask)deployEs 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: deployDie 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
deployDie 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
gammaDieselbe 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 -c8
5Die 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.gzIhr 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 -c8x 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'4Zitieren 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
dOhne 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_badstatus: 0false 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_goodstatus: 1Eine einfache Zuweisung auf der obersten Ebene meldet bereits den Status der letzten Befehls-Substitution:
out=$(exit 3)
echo $?3Das 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 In-Shell-Alternativen, die Sie tatsächlich benötigen
Die meisten verwenden $(...), weil sie Daten in einer Variable benötigen. Häufig benötigen sie tatsächlich Eingaben und keine Erfassung. Diese vier Formen behalten den Status in der aktuellen Shell bei.
Eine Umleitung an der Schleife
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaFür die Eingabe wird kein Prozess erstellt. Daher bleiben alle Variablenänderungen aus dem Schleifenkörper nach der Schleife erhalten.
Prozesssubstitution
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) liefert Ihnen einen Pfad wie /dev/fd/63, über den die Ausgabe des Befehls gelesen wird. Der Befehl läuft weiterhin in einem eigenen Prozess. Die while-Schleife tut das nicht. Genau darauf kommt es an. Das Leerzeichen in < <( ist erforderlich: <<( wird als Beginn eines Here-Dokuments interpretiert und kann nicht geparst werden. Prozesssubstitution ist eine bash-Funktion. Daher läuft ein Skript mit #!/bin/sh unter Ubuntu oder Debian 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 Zeichenkette 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 übernehmen.
mapfile für ganze Dateien
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile, auch als readarray bezeichnet, liest eine Datei in ein Array der aktuellen Shell ein. -t entfernt den abschließenden Zeilenumbruch aus jedem Element. Dafür ist bash 4 oder neuer erforderlich. Ubuntu 24.04 enthält bash 5.2, daher ist die Funktion 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 outin eine eigene Zeile, wenn der Exit-Status vonout=$(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 $() mein aktuelles Verzeichnis nicht?
$(...) führt seinen Befehl in einer Subshell aus. Das ist ein separater Prozess mit einer Kopie des Arbeitsverzeichnisses und der Variablen. cd ändert diese Kopie. Danach wird der Prozess beendet und die Kopie verworfen. Eine Subshell kann nur ihre Standardausgabe und einen Exit-Status zurückgeben. Daher gibt es keinen Mechanismus, mit dem die Verzeichnisänderung die übergeordnete Shell erreicht. Wenn Sie das Verzeichnis selbst benötigen, erfassen Sie es mit target=$(cd /etc && pwd) und verwenden Sie "$target". Wenn sich Ihre Shell ändern soll, führen Sie cd direkt aus, ohne es in eine Substitution einzuschließen.
Was ist der Unterschied zwischen $() und Backticks in bash?
Bei einfachen Befehlen liefern sie dasselbe Ergebnis. Es gibt jedoch zwei relevante Unterschiede. $() lässt sich direkt verschachteln, weil der Parser die Klammern zuordnet. Bei Backticks ist für jede Verschachtelungsebene ein maskierter Backtick erforderlich. Backticks entfernen außerdem eine Ebene der Backslash-Maskierung, bevor der innere Befehl geparst wird. Daher liefern ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh` unterschiedliche Ergebnisse. Es gibt daher keinen Grund aus Gründen der Portabilität, Backticks zu verwenden.
Warum hängt mein Skript ohne Eingabeaufforderung, wenn ein Befehl eine Eingabe anfordert?
Eine Command Substitution leitet die Standardausgabe in eine Pipe um. Die Standardeingabe bleibt jedoch mit Ihrem Terminal verbunden. Ein Programm, das seine Eingabeaufforderung auf die Standardausgabe schreibt, speichert diese Eingabeaufforderung in der Variablen. Der dahinterliegende read wartet weiterhin auf Ihre Eingabe. Das Terminal zeigt nur die von Ihnen eingegebenen Zeichen an, die vom Terminaltreiber zurückgeschrieben werden. Verschieben Sie die Frage außerhalb der Substitution. Alternativ können Sie die Eingabeaufforderung mit printf 'Username: ' >&2 auf die Standardfehlerausgabe schreiben, damit sie nicht erfasst wird.
Warum sind die Leerzeilen am Ende meiner Variablen verschwunden?
Eine Command Substitution entfernt jeden abschließenden Zeilenumbruch, nicht nur den letzten. printf 'hello\n\n\n' > f; v=$(cat f) hinterlässt in v fünf Bytes, während die Datei acht Bytes enthält. Um die Zeilenumbrüche beizubehalten, hängen Sie innerhalb der Substitution ein Sentinel an. Entfernen Sie es anschließend mit v=$(cat f; printf x) gefolgt von v=${v%x}. Das Sentinel steht hinter den Zeilenumbrüchen. Daher gibt es am Ende nichts, was bash entfernen kann.
Warum meldet local out=$(cmd) immer Erfolg?
local ist ein eigenständiger Befehl. $? nach dieser Zeile meldet, ob local die Variable erfolgreich deklariert hat. Der Exit-Status der Substitution wird verbraucht und verworfen. Das bedeutet auch, dass set -e das Skript nicht beendet. declare, export, typeset und readonly verhalten sich genauso. Schreiben Sie local out in eine Zeile und out=$(cmd) in die nächste. $? meldet dann den tatsächlichen Status.