Psaní shellkódu

Psaní shellových skriptů

Hello world

V programátorských příručkách bývá zvykem začínat primitivním programem hello world. Psaní shellových skriptů je programování hodně podobné, a proto si zkusíme na primitivním programu ukázat náležitosti shellového skriptu:

#!/bin/bash

echo "Hello world."

Jednoduché, že? První řádka označuje, který interpret se má použít ke "spuštění" příslušného souboru. Na rozdíl od programovacích jazyků s kompilátory, které produkují spustitelný strojový kód, potřebují shellové skripty interpret, tedy program, který daný skript vykoná.

Takto opatřený soubor můžeme učinit spustitelným:

chmod a+x skript

Pak postačí jej spustit:

./skript

Všimněte si, že programy v aktuálním adresáři spouštíme tak, že se na aktuální adresář odkážeme v cestě. V unixových systémech totiž aktuální adresář není součástí cesty, která se prohledává při zadání příkazu. Je to vhodné bezpečnostní opatření pro případ, že někdo vytvoří škodlivý skript se stejným jménem jako některý z často používaných příkazů.

Výchozí cesta většinou obsahuje adresáře jako /bin či /usr/bin, kde se nachází spustitelné soubory všech nainstalovaných uživatelských programů. V GNU/Linuxu si tedy nemusíte dělat starosti s tím, kam se vám nainstaloval třeba Firefox, protože jeho spustitelný soubor se nachází v cestě, která se po zadání příkazu bude prohledávat. Není tedy problém spustit Firefox přímo z příkazového řádku:

[michal@griffon ~]$ firefox

Skripty lze samozřejmě spouštět "ručně", pomocí interpretu, takto:

bash skript

Druhy příkazů

Dosud jsme nerozlišovali typ použitých příkazů. Vlastní příkaz může být název spustitelného souboru, který tvoří nějaký program, může to být ale i vestavěná funkce shellu nebo třeba alias. Příkazy jako ls, grep, cat či ssh jsou názvy spustitelných souborů, tedy programy. Naopak třeba příkaz cd je vestavěnou funkcí shellu. Není to žádný konkrétní program. Podobně jako třeba echo. Této znalosti můžeme využít, pokud budeme v prostředí, kde máme sice shell, ale už ne jednotlivé programy jako ls. Program ls můžeme nahradit právě použitím vestavěné funkce shellu echo:

echo *

Poslední kategorií jsou aliasy. Ty můžete specifikovat vedle dalších nastavení ve svém ~/.bashrc. Alias je jiné jméno pro nějaký příkaz. Můžete si tak namapovat nějaký komplikovanější příkaz na snadno zapamatovatelný alias:

alias la='ls -la'

Pokud by se vám stýskalo po logických označeních jednotek, můžete si pomoci třeba takto:

alias c:='cd /mnt/windows'

Pokud nevíte, co je daný příkaz zač, můžete zkusit vestavěnou funkci shellu type:

type prikaz

Proměnné

Proměnné slouží k uložení určitých hodnot či řetězců. Začneme proměnnými prostředí. O jedné jsme už několikrát mluvili, a sice o cestě, která se prohledává při zadání nějakého příkazu, který shell nerozpozná jako vestavěnou funkci. Proměnná se jmenuje PATH a její obsah můžeme zobrazit takto:

echo $PATH

Všechny proměnné prostředí můžeme vypsat pomocí příkazu set:

set

Další zajímavou proměnnou prostředí je PS1, která určuje tvar výzvy (prompt). Výzvu už jsme tu mnohokrát měli, vypadá třeba takto:

[michal@griffon ~]$

Mnoho informací o proměnných prostředí naleznete v manuálu bashe:

man bash

Proměnné nemusí ale být jenom proměnné prostředí. Můžete si je tvořit sami, což se zejména ve skriptech velice hodí. Proměnnou vytvoříte pomocí znaku =:

PROMENNA="Moje proměnná."

Vlevo je název proměnné, vpravo za = je její hodnota. Tento zápis netoleruje mezery. Pokud byste = oddělili mezerami, nebude to fungovat.

Hodnotu proměnné můžete dosadit kamkoliv, pokud před název proměnné zapíšete znak dolaru:

echo $PROMENNA

Ukažme si malý skriptík s proměnnou:

#!/bin/bash
         
LOG="~/log.txt"
cp soubor /tmp 2&>$LOG

Omlouvám se za takto primitivní příklad. Ano, jak tušíte, tento skript překopíruje soubor soubor do adresáře /tmp a veškeré případné chybové hlášky zapíše do souboru, který je specifikován v proměnné LOG. Pokud budete tvořit složitější skript, vyplácí se nějaké statické hodnoty (třeba adresáře, soubory, apod.) specifikovat v proměnných. Pak, když budete potřebovat hodnoty změnit, nemusíte procházet celý soubor, změníte pouze hodnotu v proměnné.

Do proměnných můžete přirozeně ukládat i výstup nějakého příkazu:

PROMENNA=$(ps au | grep "bash")

Matematika

Základní matematiku (celočíselné operace) provádí následující syntax:

$((vyraz))

Za vyraz stačí dosadit nějakou matematickou operaci, třeba:

$((4+5*(5-20/5)))

Operace vyžadující plovoucí desetinnou čárku můžete provádět pomocí programu bc. Jeho použití je ale trošku složitější:

echo "scale=2; 4*4-6*(1/25)" | bc

Proměnná scale nastavuje počet desetinných míst. Přirozeně, na místě pro jednotlivá čísla můžete uvádět proměnné a výsledek ukládat do jiných proměnných.

Funkce

Pokud často používáte nějaký sled příkazů, hodí se jej umístit do funkce, kterou lze pak spouštět jediným příkazem. Třeba takto:

#!/bin/bash

function nase_funkce {
   echo "Kopiruji soubor ..."
   cp soubor.txt /tmp
   du -h /tmp/soubor
}

nase_funkce

Tohle je opět značně primitivní příklad, ale svůj účel plní. Vidíme, jak se funkce vytváří i jak se používá. Funkcím lze předávat parametry:

#!/bin/bash

function funkce {
   echo "Parametr funkce c. 1: $1"
   echo "Parametr funkce c. 2: $2"
}

funkce parametr1 parametr2

O parametrech funkcí i skriptů se ale dozvíme více později.

Podmínky

Průběh skriptu může někdy narazit na situaci, kde bude třeba o něčem rozhodnout. Za tímto účelem máme k dispozici podmínku:

if [ podminka ]; then
  prikaz
fi

To je nejjednodušší možná konstrukce podmínky. Pokud je podmínka podminka pravdivá, provede se prikaz. Je však možné konstruovat i složitější podmínky, třeba jako je tato:

if [ vyraz1 ]; then
  prikaz1
elif [ vyraz2 ]; then
  prikaz2
else
  prikaz3
fi

V tomto případě se provede prikaz1, jestliže je podmínka vyraz1 pravdivá. Pokud není, ale je pravdivá podmínka vyraz2, provede se prikaz2. Pokud ani tato podmínka pravdivá, provede se prikaz3. Ještě než zkusíme nějaký reálný příklad, povíme si více o podmínkách.

Výrazy (podmínky)

Podmínka je nějaký výraz, který nabývá určitých logických hodnot (pravda či nepravda). V zásadě, všechny podmínky interpretuje program test, takže když se podíváte do jeho manuálové stránky, objevíte přehledný výpis všeho, co lze testovat. Já se pokusím o stručný vytah.

Tabulka 8.1. Podmínky obecně

VýrazVýznam

(výraz)

podmínka je pravdivá, je-li výraz pravdivý

! výraz

podmínka je pravdivá, pokud výraz není pravdivý (negace)

výraz1 -a výraz2

podmínka je pravdivá, pokud jsou oba výrazy pravdivé (logická spojka AND)

výraz1 -o výraz2

podmínka je pravdivá, pokud je alespoň jeden z výrazů pravdivý (logická spojka OR)


 

Tabulka 8.2. Podmínky vztažené k řetězcům

VýrazVýznam

-n řetězec

řetězec je neprázdný

-z řetězec

řetězec je prázdný

řetězec1 = řetězec2

řetězce jsou shodné

řetězec1 != řetězec2

řetězce jsou různé


 

Tabulka 8.3. Podmínky vztažené k (celým) číslům

VýrazVýznam

číslo1 -eq číslo2

číslo1 = číslo2 (equals)

číslo1 -ge číslo2

číslo1 ≥ číslo2 (greater or equal)

číslo1 -gt číslo2

číslo1 > číslo2 (greater than)

číslo1 -le číslo2

číslo1 ≤ číslo2 (lower or equal)

číslo1 -lt číslo2

číslo1 < číslo2 (lower than)

číslo1 -ne číslo2

číslo1 ≠ číslo2 (not equal)


 

Tabulka 8.4. Některé podmínky vztažené k souborům

VýrazVýznam

-e soubor

soubor existuje

-f soubor

soubor existuje a je to soubor

-d soubor

soubor existuje a je to adresář

-h soubor

soubor existuje a je to symbolický link

-b soubor

soubor existuje a je to blokové zařízení

-c soubor

soubor existuje a je to znakové zařízení

-p soubor

soubor existuje a je to pojmenovaná roura

-s soubor

soubor existuje a má nenulovou velikost

-S soubor

soubor existuje a je to socket

soubor1 -nt soubor2

soubor1 je novější (newer than) soubor2

soubor1 -ot soubor2

soubor1 je starší (older than) soubor2


 

Seznam podmínek není úplný (v případě souborů), ale jak už jsem podotýkal, man test je vhodné si projít. Příklady různých výrazů si ukážeme v následující sekci.

Příklady různých podmínek

Pro začátek zkusme něco jednoduchého:

if [ `grep -c ^michal /etc/passwd` -eq 0 ]; then
  echo "Uživatel 'michal' neexistuje."
else
  echo "Uživatel 'michal' existuje."
fi

Příkaz grep -c ^michal /etc/passwd vypíše počet řádků souboru /etc/passwd, které začínají na michal. Pokud je toto číslo nula, je jasné, že žádný uživatel michal v systému není. Jiným příkladem může být:

if [ -f /var/run/mysqld.pid ]; then
  echo "MySQL server pravděpodobně běží."
else
  echo "MySQL server neběží."
fi

Podmínka testuje existenci souboru, který by měl být vytvořen po startu MySQL serveru (obsahuje PID běžící instance mysqld) a vymazán při jeho ukončení. Jistější by bylo použití programu ps, ale to by byl jednak příklad podobný předchozímu, a pak bych nemohl poukázat na to, že v GNU/Linuxu a shellu lze jednotlivé úlohy řešit různým způsobem, přičemž každá z variant může vést k cíli.

if [ -z "$PROMENNA" ]; then
  echo "Proměnná je prázdná."
else
  echo "Proměnná obsahuje '$PROMENNA'"
fi

Tato podmínka testuje, je-li proměnná PROMENNA prázdná, přičemž pokud není, vypíše, co obsahuje.

Jak řešit chyby

Řekněme, že máme následující skript:

#!/bin/bash

PROMENNA="dve slova"               

if [ -z $PROMENNA ]; then
  echo "Promenna je prazdna."
else
  echo "Promenna obsahuje $PROMENNA"
  fi

Pokud ho spustíme, dostaneme následující hlášku:

skript: line 5: [: dve: binary operator expected

Problém nastal na řádce 5 (tj. řádce z podmínkou). Jelikož proměnná, kterou testujeme, obsahuje dvě slova oddělená mezerou a po dosazení proměnné do podmínky při běhu skriptu se tato mezera interpretuje jako oddělovač, otestuje se pouze řetězec "dve" a řetězec "slova" se Bash pokusí mylně interpretovat jako další část podmínky:

if [ -z dve slova ]; then

Tento problém lze odstranit uzavřením proměnné do uvozovek:

if [ -z "$PROMENNA" ]; then

Pak, po dosazení proměnné se Bash dopracuje k výrazu, kde "uvidí" pouze jediný řetězec:

if [ -z "dve slova" ]; then

Uzavření proměnné do uvozovek pomůže třeba i v situaci, kdy je příslušná proměnná prázdná. Další problém může nastat velmi snadno v případě, kdy je proměnná nesprávného typu. I když jsou v Bashi všechny proměnné považovány za řetězce, v některých případech může Bash očekávat pouze určité znaky. Třeba v situaci, kdy testujeme nějakou číselnou hodnotu, očekává Bash řetězec složený pouze z čísel:

#!/bin/bash

CISLO="ahoj"

if [ "$CISLO" -eq 5 ]; then
   echo "Cislo je rovne 5."
else
   echo "Cislo neni rovne 5."
fi

V tomto případě dostaneme hlášku:

skript: line 5: [: ahoj: integer expression expected

Tou se vám Bash snaží naznačit, že tam, kde očekával číslo, číslo není. V tomto případě můžeme před touto podmínkou testovat příslušnou proměnnou pomocí regulárních výrazů, zda-li obsahuje pouze čísla. Většinou ale postačí, pokud si tuto záležitost pouze sami ohlídáte.

Další velmi typickou chybou je zapomenutí nějaké párové značky, třeba uvozovek:

#!/bin/bash
PROMENNA="ahoj

if [ -z "$PROMENNA" ]; then
   echo "Promenna je prazdna."
else
   echo "$PROMENNA"
fi

Po spuštění tohoto skriptu nám Bash zahlásí:

./skript: line 7: unexpected EOF while looking for matching `"'
./skript: line 9: syntax error: unexpected end of file

V tomto případě nezbyde než problém najít a odstranit. V delších skriptech vám pomohou editory s barevným zvýrazněním syntaxe.

Ne vždycky ale bude chyba zcela zřejmá. Může se stát, že nevíme přesně, kde chyba nastala. Pak můžeme použít parametr -x, buď takto:

bash -x skript

Nebo umístíme do záhlaví skriptu lehce pozměněnou úvodní řádku:

#!/bin/bash -x

Tak nám Bash bude vypisovat průběh skriptu. Můžeme takto označit třeba jen některou část skriptu:

#!/bin/bash

CISLO="ahoj"

set -x

if [ "$CISLO" -eq 5 ]; then
   echo "Cislo je rovne 5."
else
   echo "Cislo neni rovne 5."
fi

set +x

[...]

Jiným trikem je třeba zakomentování nějakého podezřelého úseku:

#!/bin/bash

CISLO="ahoj"

if [ "$CISLO" -eq 5 ]; then
   echo "Cislo je rovne 5."
#else
#   echo "Cislo neni rovne 5."
fi

Pokud pracujeme s nějakým nebezpečným programem, můžeme při ladění skriptu nechat použití inkriminovaného programu nechat vypisovat:

#!/bin/bash

cp /var/log/messages /tmp
echo rm /tmp/messages

Což se bude hodit ještě více, pokud danému nebezpečnému programu budete předhazovat nějakou proměnnou, kde s jistotou nevíte, co se v ní bude v danou chvíli nacházet.

Větvení a cykly

Pokud se rozhodujeme mezi dvěma možnostmi, je konstrukce if optimální. Pokud se naopak potřebujeme rozhodovat mezi více možnostmi podle obsahu proměnné, můžeme použít konstrukci case:

case $promenna in
   1 ) echo "Promenna je rovna 1" ;;
   2 ) echo "Promenna je rovna 2" ;;
   3 ) echo "Promenna je rovna 3" ;;
   * ) echo "Promenna neni rovna 1, 2 ani 3"
esac

Můžeme použít i složitější výrazy se zástupnými znaky:

case $promenna in
   [a-z] | [A-Z] ) echo "Jedno male nebo velke pismeno" ;;
   [0-9] ) echo "Jednociferne cislo" ;;
   * ) echo "Neco jineho"
esac

Někdy potřebujeme určitou činnost opakovat. A to je úloha pro cykly. Ty můžeme rozdělit do dvou kategorií. Cykly provádějící určitý počet opakování (cyklus for) a cykly založené na podmínce (cykly while a until).

Cyklus for

Syntaxe cyklu for vypadá takto:

for promenna in 1 2 3 4 5; do
   echo "$promenna"
done

Cyklus pracuje tak, že každý z prvků za in vždy postupně dosadí do proměnné promenna, provede kód uvnitř svého těla a tento postup opakuje do té doby, dokud nevyčerpá všechny prvky. V tomto případě jsme za prvky dosadili čísla od 1 do 5. Výstup tohoto cyklu by vypadal takto:

1
2
3
4
5

Pokud chceme použít určitou sekvenci čísel, nemusíme se namáhat s jejich vyplňováním. Postačí použít příkaz seq:

for i in `seq 1 50`; do
   echo $i
done

Tento úsek kódu vypíše čísla od 1 do 50. Cyklus for však umožňuje, aby cyklem procházely i zcela jiné hodnoty. Kupříkladu, následující skript vypíše obsah aktuálního adresáře:

for i in *; do
   echo $i            
done

Cykly while a until

Cykly while a until pracují tak, že opakují určitý postup, dokud je splněna určitá podmínka (cyklus while) nebo dokud určitá podmínka splněna není (cyklus until. Syntaxe je prakticky shodná:

cislo=4
until [ $cislo -eq 5 ]; do
  echo "$cislo"
  cislo=$((cislo+1))
done

while [ $cislo -gt 0 ]; do
  echo "$cislo"
  cislo=$((cislo-1))
done

Tento program vypíše:

4
5
4
3
2
1

První cyklus se opakuje tak dlouho, dokud nenastane jeho podmínka, tj. dokud proměnná cislo nabyde hodnoty 5. Druhý cyklus se opakuje tak dlouho, dokud je jeho podmínka splněná, tj dokud je proměnná cislo větší než 0.

Pomocí cyklu while můžeme třeba zpracovávat po řádcích nějaký soubor:

cat soubor | while read radka; do
  echo $radka
done

break a continue

Někdy můžeme mít zájem běh nějakého cyklu ukončit úplně nebo nedokončit iteraci a provést další opakování. K tomu slouží příkazy break a continue:

#!/bin/bash

for i in 1 2 3 4 5 6; do
   if [ $i -eq 3 ]; then
      continue
   elif [ $i -eq 5 ]; then
      break
   fi
   echo $i
done

Výstupem tohoto skriptu bude:

1
2
4

Proč? Když se do proměnné i dostala trojka, použili jsme příkaz continue, který přeskočil zbytek těla konstrukce for a přistoupil k další hodnotě, tedy ke čtyřce. V momentě, kdy se do proměnné i dostala pětka, provedl se příkaz break, který ukončil provádění celého cyklu.

Parametry a uživatelský vstup

Skriptům nebo funkcím můžeme předávat parametry. Ty jsou pak k dispozici buď jako pole všech parametrů reprezentované proměnnou $@ nebo jako jednotlivé parametry v proměnných $1, $2, atd. Proměnná $0 obsahuje jméno souboru s vlastním skriptem.

Vše si ozřejmíme na následujícím příkladě. Mějme skript:

#!/bin/bash
         
echo "Soubor se skriptem: $0"
echo "Prvni parametr: $1"
echo "Druhy parametr: $2"
echo "Treti parametr: $3"
echo "Pole vsech parametru: $@"

Tento skript spustíme následujícím způsobem:

./skript jedna dve tri

Následně obdržíme tento výpis:

Soubor se skriptem: ./skript
Prvni parametr: jedna
Druhy parametr: dve
Treti parametr: tri
Pole vsech parametru: jedna dve tri

Někdy můžeme dát přednost interaktivnímu programu, který požádá uživatele o zadání nějaké hodnoty. V takové situaci můžeme použít příkaz read, který uloží uživatelský vstup do proměnné. Ukažme si to na příkladu:

#!/bin/bash

while true; do
  echo -n "Zadejte polomer (Ctrl-C ukonci skript): "
  read POLOMER
  echo -n "Obsah kruhu s polomerem $POLOMER je"
  echo "scale=3; 3.14*$POLOMER^2" | bc
done

Tento program využívá mnohé z toho, co jsme se naučili už dříve. Umožňuje spočítat obsah kruhu. Cyklus while má v tomto případě místo podmínky dosazený program, který navrací pravdivostní hodnotu 1 nebo true (pravda). To znamená, že cyklus se bude opakovat, dokud uživatel násilně neukončí skript pomocí Ctrl+C.

Dosavadních znalostí můžeme využít i pro vytvoření lepšího řešení:

#!/bin/bash

function obsah {
  echo -n "Obsah kruhu s polomerem $1 je "
  echo "scale=3; 3.14*$1^2" | bc
}

if [ -z "$1" ]; then
  while true; do
    echo -n "Zadejte polomer (Ctrl-C ukonci skript): "
    read POLOMER
    obsah $POLOMER
  done
else
  obsah "$1"
fi

Tento skript využívá funkci, která provádí vlastní výpočet, dále podmínku, která hlídá, zda-li uživatel zadal skriptu při spuštění parametr nebo ne. Pokud ano, provede výpočet na hodnotě získané z parametru, pokud uživatel parametr nezadal, nabídne mu "interaktivní" režim, kdy může hodnotu zadat sám. Interaktivní režim pak pracuje stejně jako příklad výše.

Ošetření chybových stavů a reakce na signály

Pokud budeme pracovat s běžnými příkazy, může se stát, že některý z nich nebude schopen příslušnou operaci provést a selže:

#!/bin/bash

cp soubor adresar
rm soubor

Tento skript sice nic smysluplného nedělá, ale můžeme si na něm ukázat, o co jde. Pokud totiž první příkaz (cp) selže, kopie původního souboru se nevytvoří, a pokud bude skript pokračovat dál (což bude), smaže zdrojový soubor. V tomto případě by se tedy hodilo zjistit, jestli první příkaz proběhl úspěšně, a pokud ne, zastavit průběh skriptu.

Každý správně napsaný unixový program po sobě zanechá tzv. konečný stav (exit status). To je číslo, které nám pomůže zjistit, jestli program úspěšně skončil nebo jestli skončil s chybou. Nula představuje bezchybný konec, nenulové číslo poukazuje na chybu při práci programu. Hodnota konečného stavu naposledy spuštěného programu se uchovává v proměnné $?. Toho můžeme využít a skript adekvátně upravit:

#!/bin/bash

cp soubor adresar
if [ $? -gt 0 ]; then
  echo "Soubor se nepodarilo prekopirovat."
  exit 1
fi
rm soubor

Na tomto příkladě jsme si ukázali dvě věci. Jednak zpracování chybového stavu a jednak příkaz exit, který okamžitě ukončí běh skriptu. Jako parametr lze tomuto příkazu předat hodnotu, kterou má vrátit jako konečný stav. V tomto případě jsme zvolili nenulovou hodnotu, protože náš program v této situaci skončil s chybou.

Chybové stavy můžeme ošetřovat v rámci jednoho příkazu takto:

cp soubor adresar && echo "Soubor se zkopiroval."

Dva ampersandy za sebou fungují jako speciální oddělovač příkazů. Příkaz za nimi se provede pouze v případě, že příkaz před nimi bude korektně ukončen. Opačnou možnost nabízí dvě roury:

cp soubor adresar || echo "Nastala chyba."

V tomto případě se příkaz po dvou rourách provede pouze v situaci, kdy příkaz před nimi vrátí nenulový konečný stav.

Reakce na signály

Skripty mohou reagovat určitým způsobem na signály, které jim pošleme třeba příkazem kill. Tato znalost se nám hodí třeba proto, abychom mohli po sobě uklidit, když někdo požádá o násilné ukončení našeho skriptu.

K tomu, abychom mohli reagovat na určité signály, slouží příkaz trap. Syntaxe je následující:

trap "příkaz" signály

Příkladem tedy může být výmaz dočasného souboru při jakémkoliv násilném ukončení skriptu:

trap "rm $TEMP; exit" SIGHUP SIGINT SIGTERM

Pokud náš skript opatříme touto řádkou, v případě, že dojde k násilnému ukončení běhu skriptu, nezůstane po něm žádné "smetí".

nohup

Pokud provádímě nějaký příkaz na pozadí a odhlásíme se, pošle se všem programům či skriptům, které jsme nechali pracovat na pozadí, signál SIGHUP. Ten zpravidla programy zpracují tak, že se ukončí. Pokud tedy chceme spustit nějaký příkaz (program) na pozadí tak, aby se při našem odhlášení neukončil, použijeme k tomu program nohup:

nohup prikaz &