SSD Nodes Learn 🎉 VPS od $5.50/mies.
Przewodniki Matt ConnorAutor: Matt Connor · Zaktualizowano 2026-08-13

Bash: $(...) czy backticks? Różnice i subshell

Dowiedz się, dlaczego $(...) uruchamia subshell, przez co komendy cd i zmienne tracą stan. Poznaj problemy z zagnieżdżaniem backticks oraz jak używać procesów do zachowania danych.

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

Na czym polega podstawianie wyników poleceń w bash

Podstawianie wyników poleceń w bash zastępuje $(command) tekstem, który dane polecenie wypisało na standardowe wyjście. Starsza składnia z użyciem grawisów (backticks) działa w ten sam sposób. Wszystkie zaskakujące zachowania wynikają z dwóch faktów: polecenie uruchamia się w oddzielnym procesie zwanym subshell, a każdy znak nowej linii na końcu wyjścia jest usuwany.

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

To cała funkcjonalność. wc -l wypisało 3 wraz z końcowym znakiem nowej linii, znak ten został usunięty, a count przechowuje dwa pożądane znaki. Należy zauważyć, że wc -l < three.txt wypisuje samą liczbę, ponieważ GNU wc czytające ze standardowego wejścia nie posiada nazwy pliku do wyświetlenia. Należy użyć wc -l three.txt, aby przechwycić 3 three.txt, co stanowi inny ciąg znaków i jest częstą przyczyną błędów w późniejszych operacjach arytmetycznych.

Reszta tego przewodnika opisuje zachowania, których nikt się nie spodziewa, ponieważ subshell jest oddzielnym procesem, a oddzielny proces nie może zmienić powłoki, w której użytkownik aktualnie pracuje.

Używaj $() zamiast odwrotnych apostrofów

Obie formy są poprawne. $() jest zgodne ze standardem POSIX, więc dash, ash oraz busybox sh w pełni je obsługują. Nie ma już powodów związanych z przenośnością, aby stosować odwrotne apostrofy, istnieją natomiast dwa konkretne powody, by z nich zrezygnować.

Odwrotne apostrofy nie obsługują zagnieżdżania

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

Druga linia wypisała dosłownie słowa echo hi. Powłoka szuka kolejnego niepoprzedzonego znakiem ucieczki odwrotnego apostrofu, więc drugi wpisany znak zamknął pierwszy. Wykonane polecenie to w rzeczywistości echo bez argumentów, które wypisało pustą linię, a następnie usunęło znak nowej linii. Słowa echo hi pozostały jako zwykły tekst, a ostatnia para odwrotnych apostrofów wykonała puste polecenie.

Aby zagnieździć odwrotne apostrofy, należy poprzedzić każdy wewnętrzny znak ucieczką:

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

Każdy kolejny poziom podwaja liczbę wymaganych znaków ucieczki. $() nie wymaga takich zabiegów, ponieważ parser dopasowuje nawiasy zamiast szukać znaku ograniczającego.

Odwrotne apostrofy zmieniają backslashe przed uruchomieniem polecenia

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

To samo wewnętrzne polecenie wygenerowało inny wynik. Wewnątrz odwrotnych apostrofów powłoka usuwa jedną warstwę znaków ucieczki zanim wewnętrzny tekst zostanie przeanalizowany, więc pojedyncze cudzysłowy nie zapewniły ochrony. Wewnątrz $() tekst między nawiasami jest analizowany jako zwykły skrypt, więc pojedyncze cudzysłowy zachowują się zgodnie z oczekiwaniami. Jest to szczególnie problematyczne w jednowierszowych poleceniach sed oraz awk, gdzie utracony backslash zmienia działający wzorzec w inny, co dzieje się bez żadnego komunikatu o błędzie.

Cytowanie wewnątrz $() również zaczyna się od nowa, co oznacza, że można zagnieżdżać podwójne cudzysłowy bez konieczności ich poprzedzania znakiem ucieczki:

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

Odpowiednik z odwrotnymi apostrofami wymaga użycia \" wokół $path. Każdy znak ucieczki to miejsce, w którym można popełnić błąd.

Dlaczego cd wewnątrz $() nie zmienia bieżącego katalogu powłoki

Ponieważ $(...) tworzy nowy proces. Podpowłoka otrzymuje kopię zmiennych oraz kopię bieżącego katalogu roboczego. Zmienia ona swoją kopię, wypisuje wynik i kończy działanie. Kopia ta zostaje usunięta wraz z procesem.

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

Nic nie zawiodło. Polecenie cd zostało wykonane, a pwd wewnątrz podpowłoki faktycznie wypisało /etc. Nie istnieje jednak mechanizm przekazania tej zmiany do procesu nadrzędnego, ponieważ jedyne dane, jakie podpowłoka zwraca do rodzica, to standardowe wyjście oraz kod zakończenia.

Przypisania zmiennych działają w ten sam sposób:

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

Ta sama zasada wyjaśnia wersję tego błędu, z którą użytkownicy spotykają się znacznie częściej, a która dotyczy potoku zamiast podstawienia:

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

Każdy etap potoku uruchamiany jest w osobnej podpowłoce, więc pętla while zwiększyła wartość kopii zmiennej n, po czym zakończyła działanie. Należy zastąpić potok przekierowaniem, aby pętla wykonała się w bieżącej powłoce:

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

Bash może uruchomić ostatni etap potoku w bieżącej powłoce za pomocą shopt -s lastpipe, ale tylko wtedy, gdy kontrola zadań (job control) jest wyłączona, co nigdy nie ma miejsca w interaktywnej powłoce. Należy użyć przekierowania.

Gdzie podział się znak zachęty? Polecenia interaktywne wewnątrz $()

Podstawienie polecenia przekierowuje standardowe wyjście do potoku, pozostawiając standardowe wejście bez zmian. Program, który wypisuje pytanie na standardowe wyjście, a następnie oczekuje na odpowiedź, traci pytanie, ale nie przestaje czekać. Terminal sprawia wrażenie zawieszonego.

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

Słowo deploy w pierwszej linii to wpisana treść. Teraz należy uruchomić tę samą funkcję wewnątrz podstawienia:

v=$(ask)
deploy

Widoczne są tylko własne znaki, wyświetlane przez sterownik terminala, a nie przez program. Znak zachęty trafił gdzie indziej:

echo "$v"
Username: deploy

Znak zachęty znajduje się teraz w zmiennej, doklejony do odpowiedzi, ponieważ $() przechwyciło wszystko, co funkcja wypisała na standardowe wyjście. Wpisane znaki nadal docierają do read, ponieważ standardowe wejście nie zostało naruszone. Jest to dokładna sygnatura zgłoszenia błędu o treści: „mój skrypt zawiesza się i nic nie wypisuje”.

Niektóre narzędzia wypisują znaki zachęty na standardowe wyjście błędów lub bezpośrednio do /dev/tty, dzięki czemu przetrwają one ten proces. Wiele z nich jednak tego nie robi. Jeśli funkcja musi pozostać wewnątrz podstawienia, należy samodzielnie przekierować znak zachęty na standardowe wyjście błędów:

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

Standardowe wyjście błędów nie jest przechwytywane, więc znak zachęty dociera do terminala, a w v ląduje wyłącznie odpowiedź.

Dlaczego ls wygląda inaczej wewnątrz $()?

Ponieważ ls wywołuje isatty na deskryptorze pliku 1 i zmienia format wyjściowy w zależności od odpowiedzi. W wierszu poleceń tym deskryptorem jest terminal, więc ls rozmieszcza nazwy w kolumnach w poprzek linii. Wewnątrz podstawienia jest to potok, więc ls przełącza się na jedną nazwę w linii.

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

Ta sama kontrola wyłącza kolory w grep --color=auto oraz wyłącza pager w git. Jest to zamierzone rozwiązanie. Dzięki niemu skrypt otrzymuje stabilne wyjście nadające się do odczytu maszynowego bez konieczności dodatkowej konfiguracji.

Wyjaśnia to również kwestię często zadawaną w kontekście potoków. ls | sort w wierszu poleceń oraz $(ls | sort) wypisują to samo, ponieważ ls w obu przypadkach miało potok na wyjściu. Wewnątrz podstawienia zmienia się ostatni etap potoku. sort nigdy nie sprawdza, czy wyjście trafia do terminala, więc jego wyjście nigdy się nie zmienia. Umieszczenie polecenia rozpoznającego terminal na końcu potoku powoduje zmianę zachowania, dlatego potok przetestowany wizualnie może działać inaczej po umieszczeniu go w $().

Ostrzeżenie wynikające z powyższego: nie należy parsować wyjścia ls w skryptach, nawet jeśli wydaje się to wygodne. Nazwy plików mogą zawierać spacje i znaki nowej linii. Należy użyć globów lub find -print0 wraz z read -d ''.

Końcowe znaki nowej linii, które znikają bez ostrzeżenia

Podstawienie polecenia usuwa wszystkie znaki nowej linii znajdujące się na końcu wyjścia. Nie tylko ostatni. Wszystkie.

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

Plik zawiera hello oraz trzy znaki nowej linii, co daje 8 bajtów. Zmienna przechowuje hello, czyli 5 bajtów. Trzy bajty zniknęły bez żadnego ostrzeżenia.

To usuwanie jest celowe i zazwyczaj użyteczne. Dzięki niemu stamp=$(date -u +%Y%m%dT%H%M%SZ) generuje użyteczny fragment nazwy pliku zamiast nazwy zawierającej znak podziału wiersza, dlatego ten wzorzec jest bezpieczny w zastosowaniach takich jak zaplanowany skrypt kopii zapasowej restic:

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

Twój znacznik czasu będzie inny. Istotne jest to, że nazwa stanowi jedną linię.

Koszt tego rozwiązania polega na tym, że nie można użyć $() do przeniesienia dokładnej zawartości bajtowej pliku. Jeśli potrzebujesz końcowych znaków nowej linii, dodaj znak kontrolny wewnątrz podstawienia, a następnie usuń go:

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

Znak x znajduje się za znakami nowej linii, więc nie ma już końcowych znaków nowej linii do usunięcia. ${v%x} następnie usuwa znak kontrolny i pozostawia oryginalne bajty.

Dwa powiązane szczegóły. Do odczytu całego pliku v=$(<blanks.txt) wykonuje tę samą pracę bez uruchamiania cat, ponieważ bash otwiera plik samodzielnie. Usuwa on końcowe znaki nowej linii w identyczny sposób. Z kolei here-strings działają w przeciwnym kierunku, dodając znak nowej linii, którego nie wpisałeś:

wc -c <<< 'abc'
4

Używaj cudzysłowów dla wyników, w przeciwnym razie bash wykona dzielenie wyrazów i globbing

Nieobjęte cudzysłowem podstawienie przechodzi przez proces dzielenia wyrazów, a następnie przez rozwijanie nazw ścieżek. Podstawienie w cudzysłowie nie podlega żadnemu z tych procesów.

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

Bez cudzysłowów bash podzielił wynik na podstawie znaków zawartych w IFS, którymi domyślnie są spacja, tabulacja i znak nowej linii, a następnie echo połączył cztery fragmenty pojedynczymi spacjami. W cudzysłowie tekst dotarł jako jedno słowo z zachowaną tabulacją i wewnętrznym znakiem nowej linii.

Globbing jest bardziej niebezpieczną częścią tego procesu:

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
*

Samo przypisanie było bezpieczne, ponieważ przypisania nie podlegają dzieleniu wyrazów ani globbingowi. Szkoda wydarzyła się w echo $p, gdzie * zostało rozwinięte względem bieżącego katalogu. Skrypt, który odczytuje wzorzec z pliku konfiguracyjnego i pomija cudzysłowy, bez przeszkód zadziała na każdy widoczny plik. Objęcie każdego rozwinięcia cudzysłowem eliminuje całą klasę błędów. Pomijaj cudzysłowy tylko wtedy, gdy faktycznie wymagasz dzielenia, co zdarza się rzadko.

Dlaczego polecenie local x=$(cmd) zawsze zwraca 0?

Ponieważ local jest samodzielnym poleceniem, a $? raportuje status wykonania local, a nie status podstawienia, które zawierało.

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

local x=$(false)
echo $?

status: 0

false zakończyło się błędem 1, local poprawnie zadeklarowało zmienną, a kod 1 został odrzucony. declare, export, typeset oraz readonly zachowują się w ten sam sposób. set -e również nie przechwyci tego błędu, ponieważ z punktu widzenia powłoki żadna operacja nie zakończyła się niepowodzeniem.

Należy rozdzielić deklarację od przypisania:

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

local x
x=$(false)

status: 1

Zwykłe przypisanie na najwyższym poziomie raportuje status ostatniego podstawienia polecenia:

out=$(exit 3)
echo $?

x=$(false)
echo $?

3

Ma to kluczowe znaczenie w przypadku kontroli stanu działającej w ramach usługi i timera systemd, gdzie zamaskowany kod wyjścia powoduje, że jednostka raportuje sukces przy każdym uruchomieniu, podczas gdy zadanie, które miała zweryfikować, w ogóle nie zostało wykonane.

Alternatywy w powłoce, których faktycznie potrzebujesz

Większość użytkowników sięga po $(...), gdy chce zapisać dane w zmiennej. Często jednak celem jest przetworzenie danych wejściowych, a nie ich przechwycenie. Poniższe cztery metody pozwalają zachować stan w bieżącej powłoce.

Przekierowanie w pętli

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

Dla danych wejściowych nie jest tworzony osobny proces, więc wszelkie zmiany stanu wprowadzone wewnątrz pętli są zachowywane po jej zakończeniu.

Podstawienie procesu

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

<(command) udostępnia ścieżkę, na przykład /dev/fd/63, z której odczytywane jest wyjście polecenia. Polecenie nadal uruchamia się we własnym procesie. Pętla while nie tworzy osobnego procesu, co jest kluczową zaletą tego rozwiązania. Spacja w < <( jest wymagana: <<( zostanie zinterpretowane jako początek here-document i spowoduje błąd parsowania. Podstawienie procesu jest funkcją bash, więc skrypt z #!/bin/sh na systemach Ubuntu lub Debian uruchamia się pod dash i zakończy się błędem. Należy użyć #!/bin/bash.

Here-strings

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

<<< przekazuje ciąg znaków na standardowe wejście polecenia. read wykonuje się w bieżącej powłoce, dzięki czemu obie zmienne są dostępne do dalszego wykorzystania. read -r a b <<< "$(some-command)" to standardowy sposób na wyodrębnienie dwóch pól z jednej linii wyjściowej.

mapfile dla całych plików

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

mapfile, znane również jako readarray, wczytuje plik do tablicy w bieżącej powłoce. -t usuwa końcowy znak nowej linii z każdego elementu. Polecenie wymaga bash w wersji 4 lub nowszej; system Ubuntu 24.04 dostarcza bash 5.2, więc funkcja ta jest dostępna na każdym współczesnym obrazie serwerowym.

Lista kontrolna przed zatwierdzeniem skryptu

  • Zapisz $(command) i użyj cudzysłowu "$(command)", chyba że wymagany jest podział.
  • Przyjmij, że końcowe znaki nowej linii zostały usunięte. Dodaj znak kontrolny, jeśli są one potrzebne.
  • Nie umieszczaj poleceń interaktywnych w podstawieniach lub przekieruj ich znaki zachęty do standardowego wyjścia błędów.
  • Zapisz local out w osobnej linii, gdy status wyjścia out=$(command) ma znaczenie.
  • Aby ustawić zmienne na podstawie danych wejściowych, użyj przekierowania lub podstawienia procesu zamiast potoku.

Te schematy pojawiają się w pierwszych prostych skryptach tworzonych podczas konfiguracji nowego VPS, a błędy pozostają niezauważone. Skrypt kopii zapasowej, który przechwycił znak zachęty do nazwy pliku, lub test sprawności, który zamaskował kod wyjścia, stale raportuje sukces. Koszt rośnie, gdy uruchamiasz ten sam skrypt na wielu serwerach, ponieważ dane wyjściowe, których nigdy nie czytasz, stają się danymi wyjściowymi, których nigdy nie czytasz na dwudziestu maszynach.

FAQ

Dlaczego polecenie cd wewnątrz $() nie zmienia bieżącego katalogu?

$(...) uruchamia polecenie w podpowłoce (subshell), która jest oddzielnym procesem posiadającym kopię bieżącego katalogu oraz zmiennych. cd zmienia tę kopię, po czym proces kończy działanie, a kopia zostaje odrzucona. Podpowłoka może zwrócić jedynie standardowe wyjście oraz kod wyjścia, dlatego nie istnieje mechanizm przekazania zmiany katalogu do powłoki nadrzędnej. Aby uzyskać ścieżkę katalogu, należy przechwycić ją za pomocą target=$(cd /etc && pwd) i użyć "$target". Jeśli celem jest zmiana katalogu w bieżącej powłoce, należy wykonać cd bezpośrednio, bez użycia podstawienia.

Jaka jest różnica między $() a odwrotnymi apostrofami w bash?

Dla prostych poleceń dają one ten sam wynik, jednak różnią się w dwóch istotnych kwestiach. $() pozwala na bezpośrednie zagnieżdżanie, ponieważ parser dopasowuje nawiasy, podczas gdy odwrotne apostrofy wymagają użycia znaku ucieczki dla każdego poziomu zagnieżdżenia. Odwrotne apostrofy usuwają również jedną warstwę znaków ucieczki przed parsowaniem wewnętrznego polecenia, więc ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, co oznacza, że nie ma argumentów przemawiających za przenośnością odwrotnych apostrofów.

Dlaczego skrypt zawiesza się bez znaku zachęty, gdy polecenie zadaje pytanie?

Podstawienie polecenia przekierowuje standardowe wyjście do potoku, ale pozostawia standardowe wejście podłączone do terminala. Program wypisujący znak zachęty na standardowe wyjście powoduje przechwycenie tego znaku do zmiennej, podczas gdy read w tle nadal oczekuje na dane wejściowe. Terminal wyświetla tylko znaki wpisywane przez użytkownika, powielane przez sterownik terminala. Należy przenieść pytanie poza podstawienie lub skierować znak zachęty do standardowego wyjścia błędów za pomocą printf 'Username: ' >&2, aby nie został przechwycony.

Dlaczego puste linie na końcu zmiennej zniknęły?

Podstawienie polecenia usuwa wszystkie końcowe znaki nowej linii, a nie tylko ostatni. printf 'hello\n\n\n' > f; v=$(cat f) sprawia, że v przechowuje pięć bajtów, podczas gdy plik zawiera ich osiem. Aby je zachować, należy dodać znacznik wewnątrz podstawienia, a następnie usunąć go za pomocą v=$(cat f; printf x) i v=${v%x}. Znacznik znajduje się za znakami nowej linii, więc na końcu nie ma nic, co bash mógłby usunąć.

Dlaczego local out=$(cmd) zawsze zgłasza sukces?

local jest samodzielnym poleceniem, a $? po tej linii informuje, czy local zakończyło się sukcesem w deklaracji zmiennej. Kod wyjścia podstawienia jest konsumowany i odrzucany, co oznacza również, że set -e nie zatrzyma skryptu. declare, export, typeset oraz readonly zachowują się w ten sam sposób. Należy zapisać local out w jednej linii, a out=$(cmd) w kolejnej, dzięki czemu $? poprawnie zgłosi rzeczywisty kod wyjścia.

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