BASH jako język skryptowy

Komendy języka Bash, które mają być wykonane należy umieścić w pliku, którego pierwszy wiersz jest postaci:

#!/bin/bash

Znak komentarza # wraz ze znakiem ! służą do wskazania ścieżki do programu, który zostanie użyty przy wykonywaniu komend zawartych w pliku (ten program to interpreter komend ze skryptu). #! określa się jako shebang (/ʃəˈbæŋ/), czasami także jako hashbang, shbang lub shabang. Dla ułatwienia pracy ze skryptem powinien on mieć atrybut wykonywalności (chmod a+x skrypt).

Pełny opis języka bash można znaleźć na stronach podręcznika systemowego (man bash) lub w wielu artykułach i książkach dostępnych w Internecie, np. Mendel Cooper, Advanced Bash-Scripting Guide. An in-depth exploration of the art of shell scripting lub Gentoo Linux Development Guide. Warte polecenia są materiały opracowane przez Fahmida Yesmina, w szczególności początkujący programiści powinni zapoznać się z przykładami zawartymi na stronie 30_bash_script_examples i bash_scripting_interview_questions.

Poniżej zebrano tylko najpotrzebniejsze informacje.

Podstawienie komend

Zwykle wynik działania komendy jest wyświetlany na monitorze, czyli wyprowadzany na standardowe wyjście. Bash umożliwia wykonanie komendy przekazanie jej wyniku w celu utworzenia innej komendy. Np.

start=`date`  # także  start=$(date)
files=$(ls)

Trzeba pamiętać, że przy nadawaniu zmiennym wartości nie może się pojawić spacja ani przed ani za znakiem =.

Jeśli w skrypcie pojawi się wiersz:

VARIABLE =value

to skrypt probuje uruchomić komendę VARIABLE z jednym argumentem “=value”.

Jeśli natomiast pojawi się wiersz:

VARIABLE= value

to skrypt probuje uruchomić komendę value i nadaje zmiennej środowiskowej VARIABLE wartość ‘’.

Czytanie danych

Jeśli zmienna ma przyjąć wartość podaną przez użytkownika, to trzeba użyć komendy:

read data
read -p "Podaj datę" data

Instrukcje warunkowe

Instrukcje warunkowe mogą być wykorzystane do sprawdzenia

  • czy dwa ciągi znaków lub dwie zmienne znakowe są sobie równe, czy też nie

  • jak mają się do siebie dwie wartości całkowite lub dwie zmienne numeryczne

  • jaki jest status plików (czy istnieją, czy są zwykłymi plikami, czy też katalogami, itp.)

Często w takiej roli wykorzystuje się komendę test (można ją zastąpić przez [...]):

test -z "string"         # prawda, jeśli długość łańcucha "string" wynosi zero
test -n "string"         # prawda, jeśli długość łańcucha "string" jest różna od zera
test string1 = string2   # prawda, jeśli łańcuchy są sobie równe
test string1 != string2  # prawda, jeśli łańcuch nie są sobie równe
test int1 -eq int2       # prawda, jeśli liczby całkowite int1 i int2 są sobie równe
test int1 -gt int2       # prawda, jeśli liczba całkowita int1 jest większa niż int2
test int1 -ge int2       # prawda, jeśli liczba całkowita int1 jest większa lub równa int2
test int1 -lt int2
test int1 -le int2
test -a PLIK             # prawda, jeśli PLIK istnieje
test -e PLIK             # prawda, jeśli PLIK istnieje
test -f PLIK             # prawda, jeśli PLIK istnieje i jest regularnym plikiem
test -d PLIK             # prawda, jeśli PLIK istnieje i jest katalogiem
test -b PLIK             # prawda, jeśli PLIK istnieje i jest plikiem urządzenia blokowego
test -c PLIK             # prawda, jeśli PLIK istnieje i jest plikiem urządzenia znakowego
test -h PLIK             # prawda, jeśli PLIK istnieje i jest dowiązaniem symbolicznym
test -s PLIK             # prawda, jeśli PLIK istnieje i niezerową długość

Zob. także niżej “Użycie test”.

Ogólna postać prostej instrukcji warunkowej if jest następująca

if WARUNEK
then
   KOMENDY_GDY_PRAWDA
fi

Np.:

if [[ $1 == $2 ]]
then
   echo "Argumenty 1 i 2 są równe"
fi

if [ -d /data/January ]
then
   echo "January directory exists at `date`" | mail root
fi

W przypadku kiedy trzeba wykonać jakieś komendy, gdy warunek logiczny nie jest prawdziwy trzeba zastosować instrukcję warunkową postaci:

if WARUNEK
then
  KOMENDY_GDY_PRAWDA
else
  KOMENDY_GDY_FALSZ
fi

lub

if WARUNEK1
then
  KOMENDY_GDY_PRAWDA1
elif WARUNEK2
  KOMENDY_GDY_PRAWDA2
else
 KOMENDY_GDY_FALSZ
fi

Oto prosty przykład:

if [[ $USER == "xen" ]]
then
   echo "Użytkownik $USER uprawniony do korzystania ze skryptu"
else
   echo "Użytkownik $USER nie może korzystać ze skryptu"
   exit 1
fi

Bardzo przydatna jest także instrukcja wyboru case, bo pozwala łatwo wybrać komendy do wykonania w zależności od wartości zmiennej łańcuchowej

case SLOWO in
   WZORZEC1) komenda1; komenda2; ... ;;
   WZORZEC2) komenda1; komenda2; ... ;;
     ...
esac

Często stosowana przy przetwarzaniu opcji i argumentów

case $option in
  m ) echo "wybrano opcję #1: -m-";;
  n | o) echo "wybrano opcję #2 lub #3 : -${option}-";;
  p ) echo "Wybrano opcję #4 z wartością: -p-, z wartością  \"$OPTARG\"";;
 \? ) echo "nieznana opcja: -${option}-";;
  * ) echo "inna opcja: -${option}-";;
esac

Instrukcje iteracyjne

Instrucja iteracyjna while pozwala na cykliczne sprawdzanie warunku i tak długo, jak pozostaje on prawdziwy, wykonywany jest pewien ciąg instrukcji. Np.:

while [[ $args -gt 0 ]]
do
   eval echo \$$(echo $args)
   let args--
done

Jeśli zachodzi potrzeba wykonania jakiś operacji dla szeregu znanych elementów, to wówczas należy zastosować instrukcję iteracyjną for:

for name [ [ in [ word ... ] ] ; ] do list ; done

for (( expr1 ; expr2 ; expr3 )) ; do list ; done

Np. zakładanie kont dla kilkorga użytkowników może być zrobione tak:

for user in ala ola jas stas
do
   useradd $user
done

Wszystkie argumenty wywołania skryptu mogą być wypisane w następujący sposób

for a in $*; do
    echo $a
done

lub

for a in $@; do
    echo $a
done

(Warto porównać działanie tych komend, jeśli $* i $@ zostaną zastąpione przez “$*” i “$@”.)

Ale jeśli trzeba wypisać wszystkie lub co drugi element z tablicy, to można to zrobić tak:

for ((i=1; i<=10; i++))
do
  echo ${tablica[$i]}
done

for ((i=2; i<=10; i=$i+2))
do
  echo ${tablica[$i]}
done

Zob. Przykłady przetwarzania pliku wiersz po wierszu.

Pliki inicjalizacyjne basha

Kiedy tworzona jest powłoka logowania to wykonywane są komendy z następujących plików (w podanej kolejności): /etc/profile, /etc/profile.d/*.sh, ~/.bash_profile, ~/.bash_login, ~/.profile.

Jeśli tworzona jest powłoka interaktywna to wykonywane są pliki ~/.bashrc oraz /etc/bashrc. W pliku ~/.bashrc należy umieszczać potrzebne ustawienia wartości zmiennych środowiskowych oraz aliasy do często stosowanych komend. Wszelkie zmiany w pliku ~/.bashrc stają się widoczne w powłoce po wydaniu komendy source ~/.bashrc. Żeby ustawienia te były automatycznie widoczne po zalogowania się na serwerze, trzeba utworzyć w katalogu domowym plik ~.bash_login zawierający następujący wiersz:

. .bashrc

W pliku .bash_login należy umieścić tylko takie komendy, które mają być wykonywanie podczas logowania do systemu. Jeśli pracujemy w powłoce logowania, to echo $0 daje -bash. Jeśli mamy do czynienia ze zwykłą powłoką interaktywną, to wartość zmiennej $0 wynosi bash (z powłoką nieinteraktywną mamy do czynienia podczas wykonywania skryptów).

Warto uczynić plik .bash_profile dowiązaniem sztywnym do .bash_login.

Śledzenie działania (debugowanie) skryptów bashowych

W celu dokładnego prześledzenia działania całego skryptu należy go wywoływać w następujący sposób: bash -x skrypt. Jeśli chcemy śledzić wykonanie fragmentu (długiego) skryptu, to należy użyć konstrukcji

#!/bin/bash

# fragment nieśledzony
...
...
...
set -x
# włączenie śledzenia
...
...
...
set +x
# wyłączenie śledzenia
...
...
...

Obsługa opcji

Działanie skryptów jest regulowane opcjami w postaci pojedynczej litery lub cyfry poprzedzonej myślnikiem, po której może (choć nie musi) pojawić się wartość (ciąg znaków), czyli -o [wartość]. Są to tak zwane opcje krótkie, w odróżnieniu od opcji długich postaci --długa-opcja [wartość]. Przy wywołaniu skryptu opcje i ew. argumenty są wszystkie umieszczane w zmiennej $@ oraz dostępne są w zmiennych $1, $2, etc (liczbę wszystkich argumentów przechowuje zmienna $#. Zatem analizując liczbę i zawartość tych zmiennych możemy obsłużyć dowolne opcje w pożądany sposób.

Żeby lepiej zrozumieć przetwarzanie parametrów wywołania skryptu (opcji i argumentów właściwych) przeanalizuj działanie następującego skryptu (wywołując go np. z takimi parametrami: -a -b -c X Y Z):

#!/bin/bash

args=$@

echo 'args: ' $args

while [[ $# -gt 0 ]]; do
  echo '$@: ' $@
  echo '$1: ' $1
  shift
done
echo '$@: ' $@

Porównaj go ze zmodyfikowanym skryptem postaci:

 #!/bin/bash

 args=$@

 echo 'args: ' $args

 while [[ $# -gt 0 ]]; do
   echo '$@: ' $@
   echo '$1: ' $1
   shift
 done
 echo '$@: ' $@

 set -- $args

 while [[ $# -gt 0 ]]; do
  echo '$@: ' $@
  echo '$1: ' $1
  shift
done

Można ułatwić sobie zadanie parsowania opcji wykorzystując dostępną w systemie komendę getopt. Użyj poniższego skryptu do porównania zawartości dwóch zmiennych $@ oraz $args, jeśli skrypt zostanie wywołany z argumentami postaci -a -b -c X Y Z oraz -abc X Y Z:

#!/bin/bash

echo '$@:' $@
args=`getopt "abc" $@`
echo 'args: ' $args

Dlaczego zmienna $args zawiera -- (podwójny myślnik)?

Powłoka bash dostarcza własnej funkcji do parsowania opcji o nazwie getopts. Jej typowe użycie wygląda następująco

while getopts ":mnop:" option; do
    echo $OPTIND
    case $option in
        m ) echo "wybrano opcję #1: -m-";;
        n | o) echo "wybrano opcję #2 lub #3 : -${option}-";;
        p ) echo "Wybrano opcję #4 z wartością: -p-, z wartością  \"$OPTARG\"";;
        \? ) echo "nieznana opcja: -${option}-";;
        * ) echo "inna opcja: -${option}-";;
    esac
done

Jeśli chcemy, żeby skrypt obsługiwał długie opcje, to musimy skorzystać z programu getopt w następujący sposób:

set -- $(getopt                   \
          --longoptions=debug     \
          --longoptions=dhcp:     \
          --longoptions=help      \
          -- --  "$@")


 while [ $# -gt 0 ]; do
    case "$1" in
      (--debug) debug=yes;;
      (--dhcp) dhcp=yes; shift; dhcpval=$1;;
      (--help) help;;
      (--) shift; break;;
      (-*) echo "$0: błąd - nierozpoznana opcja $1" 2>&1; exit 1;;
      (*) echo "$0: błąd - nierozpoznany argument $1" 2>&1; exit 1;;
    esac
   shift
 done

Użycie test [ … ] oraz [[ … ]]

W systemach Unix/Linux jest dostępna komenda test, która służy do testowania typów plików oraz porównywania wartości. Oto przykład typowego użycia takiej komendy:

/usr/bin/test -e /etc/hosts &&  echo "Plik /etc/hosts istnieje"

Powyższa komenda jest równoważna komendzie

/usr/bin/[ -e /etc/hosts ] &&  echo "Plik /etc/hosts istnieje"

Bash posiada swoje wersje tych komend, co można sprawdzić przy pomocy komed type '[' oraz type test, które powinny zwócić komunikat test jest wewnętrznym poleceniem powłoki. W skrypcie bashowym należy unikać korzystania z zewnętrznych komend (programów), jeśli można to zrobić szybciej przy wykorzystaniu możliwości tkwiących w powłoce. Zatem w skrypcie mogą pojawić się np. takie instrukcje:

test -e /etc/hosts &&  echo "Plik /etc/hosts istnieje"

[ -e /etc/hosts ] &&  echo "Plik /etc/hosts istnieje"

if [ -e /etc/hosts ]; then echo "Plik /etc/hosts istnieje"; fi

Lepiej jednak do testowania używać konstrukcji [[ ... ]] (tzw. rozszerzonej komendy testującej przejętej z ksh88; sprawdź wynik działania komendy type '[['), gdyż nie prowadzi ona do pojawienia się błędów w przypadku posługiwania się zmiennym zawierającymi spacje lub znaki wieloznaczności (dzikie znaki takie jak * i ?), a także posługiwania się operatorami takimi jak &&, ||, < i >, które przy użyciu konstrukcji [ ... ] powodują wystąpienie błędów. Oto kilka przykładów:

if [[ -e /etc/hosts && -e /etc/passwd ]]; then
   echo "Można kontynuować"
else
   echo "Brak jednego z wymaganych plikow"
fi
if [[ /etc/hosts < /etc/passwd ]]; then
   echo "/etc/hosts poprzedza w porządku leksykograficznym /etc/passwd"
else
   echo "/etc/passwd poprzedza w porządku leksykograficznym /etc/hosts"
fi

Porównaj

foo=bbbr
match=b*r
if [[ $foo == "$match" ]]; then
   echo '$foo' and '$match' matches
else
   echo '$foo' and '$match' do not match
fi

z

foo=bbbr
match=b*r
if [[ $foo == $match ]]; then
   echo '$foo' and '$match' matches
else
   echo '$foo' and '$match' do not match
fi

Zamiast

ll=10
lp=20

if [[ $ll -le $lp ]]; then
   echo "liczby uporządkowane rosnąco"
else
   echo "liczby uporządkowane malejąco"
fi

lepiej użyć konstrukcji z (( ... ))

if (( $llewa < $lprawa )); then
   echo "liczby uporządkowane rosnąco"
else
   echo "liczby uporządkowane malejąco"
fi

gdzie operatory <, =, > zachowują się typowo.

Użycie konstrukcji [[ $ll < $lp ]] powoduje porównanie liczb jako ciągu znaków (leksykograficznie). Porównaj wynik działania skryptu, który zawiera następujące instrukcje warunkowe:

if (( $ll > $lp )) ; then
   echo "prawda"
else
   echo "falsz"
fi


if [[ $ll -gt $lp ]] ; then
   echo "prawda"
else
   echo "falsz"
fi


if [[ $ll > $lp ]] ; then
   echo "prawda"
else
   echo "falsz"
fi

jeśli zmienne przyjmują następujące wartości

ll=22222
lp=11111

lub

ll=21
lp=11111

Operacje na liczbach stało- i zmiennopozycyjnych

Język BASH pozwala na wykonywanie operacji arytmetycznych tylko na liczbach całkowitych, np.

$ a=20; b=10; let c=$a-$b
$ a=20; b=10; c=$(($a-$b))
$ a=20; let a++
$ a=20; ((a++))

Jeśli zachodzi potrzeba wykonywania działań arytmentycznych na liczbach rzeczywistych, to trzeba skorzystać z zewnętrznego narzędzia (filtru), np. programu bc. Program ten służy do wykonywania operacji w dowolnej precyzji i w różnych systemach liczbowych (m.in. dwójkowym, ósemkowym, dziesiętnym i szestnastkowym). Oto kilka przykładów wykorzystania tego programu:

$ echo "4*a(1)" | bc -l
$ echo "scale=200; 4*a(1)" | bc -l
$ echo "scale=3; 123456789/2^10" | bc -l
$ echo "scale=3; 123456789/2^20" | bc -l
$ echo "scale=3; 123456789/2^30" | bc -l
$ echo "ibase=10; obase=2;  16" | bc -l
$ echo "ibase=2; obase=2;  1000-100" | bc -l
$ echo "ibase=8; 71" | bc -l
$ echo "ibase=16; obase=10; FF"    | bc -l
$ echo "ibase=16; obase=10; FF-AA" | bc -l

Skrypt na sucho (dry run)

Czasami jest wygodnie sprawdzić, co skrypt będzie robił bez faktycznego wykonywania komend. Można to dość wygodnie zrobić korzystając z następującego przykładu (zob. Implementing dry run in bash scripts).

#!/bin/bash

function run () {
    if [[ "$DRY_RUN" == "yes" ]]; then
        echo $@
    else
        $@
    fi
}

DRY_RUN=yes
run ls /bin|head

DRY_RUN=no
run ls /bin|head

Pułapki basha

Zob. Bash Pitfalls.