Shell-Scripting

Dieser Artikel soll einen Einstieg in das Schreiben von Shell-Skripten geben. Die Beispiele sind oft FreeBSD-spezifisch, die beschriebenen Techniken sind aber universell verwendbar und sollten durch die Erklärungen nachvollziehbar sein. Wer den ganzen Artikel liest sollte am Schluss in der Lage sein mächtige Skripte zu schreiben.

Bei vielen Beispielen beginnen die Zeilen mit einem $ oder >. Das bedeutet dann, dass das Beispiel dafür gedacht ist, direkt in einer Shell ausprobiert zu werden.

Skripte anlegen

Als erstes wird mit dem Editor der Wahl eine neue Datei angelegt. Die erste Zeile enthält den Aufruf des Interpreters. Im Rahmen dieses Artikels ist das immer /bin/sh.

#!/bin/sh

Hier können allerdings in der tat beliebige Interpreter verwendet werden. Für awk sähe die erste Zeile folgendermaßen aus:

#!/usr/bin/awk -f

Nach dem Anlegen der Datei muss sie noch als ausführbar markiert werden.

$ chmod +x MYSCRIPT

Hier ist MYSCRIPT mit dem Dateinamen des gerade angelegten Skripts zu ersetzen.

Variablen

Variablen werden benötigt um Werte zwischenzuspeichern. Auch die Übergabeparameter werden als Variablen übergeben. Es gibt verschiedene Schreibweisen um auf Variablen zuzugreifen. Beim setzen einer Variable geht aus der Syntax hervor, dass es sich um eine Variable handelt, deshalb wird nur der Variablenname verwendet. Bei Shell-Skripten sind Variablen immer Strings.

i=0
for i in 0 1 2 3 4 5 6 7 8 9; do ... done

Bei beiden Beispielen handelt es sich um eine Variablenzuweisung für die Variable mit dem Namen i.

Um diese Variable irgendwo einzufügen wird die Syntax ${i} verwendet. Nur zwischen einfachen Anführungszeichen oder wenn vor dem $ ein \ ist, wird die Variable nicht ausgewertet. Häufig kann die vereinfachte Syntax $i verwendet werden. Das funktioniert natürlich nur, wenn die Syntax eindeutig ist. $itest würde natürlich zu einer Variablen mit dem Namen itest aufgelöst, ${i}test allerdings zu 0test (vorrausgesetzt i=0).

Zur Erzeugung formatierter Ausgaben wird oft die Länge einer Variable benötigt, diese Länge lässt sich mit Hilfe des #-Zeichens auslesen.

$ test='This is some useless text.'
$ echo ${#test}
26

vordefinierte Variablen

Es gibt einige vordefinierte Variablen, die auch häufiger benötigt werden.

  • $0 enthält den Aufruf des Programms, im interaktiven Terminal ist das Normalerweise der Aufruf der Shell, in Skripten der Name des Skripts.
  • $1 enthält den ersten Parameter, der dem Skript übergeben wurde. Die folgenden Parameter sind fortlaufend nummeriert.
  • $$ enthält die PID (Process ID) des laufenden Skripts.
  • $? enthält das Fehlerbyte des letzten Kommandos. Die 0 bedeutet true oder kein Fehler. Alles andere ist ein Fehlercode. An diese Konvention sollte sich jeder halten.
  • $@ wird zu allen Parametern des Skripts expandiert. In doppelten Anführungszeichen werden die Parameter so übergeben, als seien sie alle in Anführungszeichen.
  • $! enthält die PID des zuletzt im Hintergrund (mit &) gestarteten Prozesses.

Diese Liste ist nicht vollständig und enthält nur die häufiger gebrauchten vordefinierten Variablen.

Variablen mit Standardwerten belegen

Um einer Variable einen Standardwert zuzuweisen, der nur dann Gültigkeit hat, wenn kein anderer Wert vorhanden ist wird folgende Schreibweise verwendet:

${variable=value}

Der Wert value der Variable kann dabei in die üblichen einfachen oder doppelten Anführungszeichen gesetzt werden.

In dieser Form gibt es jedoch einen meist unerwünschten Nebeneffekt. Die Variable wird an Ort und Stelle auch gleich ausgewertet und der Interpreter /bin/sh interpretiert sie als Kommando. Das kann mit folgender Syntax umgangen werden:

: ${variable=value}

: ist ein Kommando, das nichts tut. Die Variable wird dem Kommando als Parameter übergeben und hat so keinen Effekt. Der Standardwert wird aber wie erwünscht zugewiesen.

Ausgabe

Shell-Kommandos haben oft eine Ausgabe, dieser Abschnitt beschäftigt sich damit, wie eigene Ausgaben erzeugt oder Ausgaben von Kommandos in Pipes weitergeleitet werden können.

echo

Das echo Kommando gibt die übergebenen Parameter wieder aus.

$ echo Hello $USER!
Hello kamikaze!

Unter Umständen ist es nützlich Parameter in Anführungsstriche zu setzen. Zum Beispiel um mehrere aufeinander folgende Leerzeichen darzustellen.

$ echo "Hello    $USER!"
Hello   kamikaze!

Variablenersetzung findet mit einfachen Anführungszeichen nicht statt.

$ echo 'Hello $USER!'
Hello $USER!

printf

Mit dem Kommando printf können formatierte Ausgaben erzeugt werden. Das Kommando nimmt mindestens einen Parameter. Dieser ist ein einfacher String, an den kein Zeilensprung angehängt wird. Dieser kann mit \n einkodiert werden. Auch ein Carriage Return ist mit \r möglich (der Begriff stammt noch von den Schreibmaschinen). Damit wird der Cursor an den Anfang der Zeile gesetzt.

Zusätzlich können formatierte Variablen in den Text eingefügt werden. Dazu werden Platzhalter in den Text eingesetzt, die später mit den folgenden Parametern des printf-Aufrufs substituiert werden. Für Zeichenfolgen wird der Platzhalter %s verwendet. Weitere Möglichkeiten, vor allem zur Zahlendarstellung, sind in der Manpage dokumentiert, wichtig sind vor allem %d für ganzzahlige Werte und %f für Gleitkommawerte. Der Clou an den Platzhaltern ist, dass man sie mit Formatierungsinformationen ausstatten kann.

$ printf 'name: %15s\nvorname: %12s\n' Hans Meise
name:            Hans
vorname:        Meise

Die Zahl zwischen den Zeichen % und s gibt die Mindestbreite des Ausdrucks an. In diesem Fall werden die Fehlenden Zeichen von Links mit Leerzeichen aufgefüllt. Bei der Wahl einer negativen Zahl werden die fehlenden Zeichen von Rechts aufgefüllt.

$ for file in $(ls); do printf '%-45sX\n' $file; done
DragonForce-My_Spirit_Will_Go_On.mp3         X
DragonForce-Through_the_Fire_and_Flames.mp3  X
DragonForce-Valley_of_the_Damned.mp3         X
machinae_supremacy-fury.ogg                  X
machinae_supremacy-loot_burn_rape_kill_repeat.oggX
machinae_supremacy-march_of_the_undead_2.ogg X
machinae_supremacy-sidology_2-trinity.ogg    X

Dieses Beispiel verdeutlicht nicht nur das Auffüllen von Rechts, sondern auch, dass überschüssige Zeichen nicht abgeschnitten werden. Dinge wie die for-Schleife werden später erklärt.

Befehlsersetzung

Befehle können an Ort und Stelle mit ihrer Ausgabe ersetzt werden. Zum Ersetzen wird der Befehl mit dem Linksapostroph ` umgeben. Alternativ kann der Befehl auch mit $( und ) umgeben werden. Das ist oft besser lesbar. Beides funktioniert auch in doppelten Anführungszeichen .

$ echo date
date
$ echo `date`
Wed 27 Feb 2008 09:17:26 CET
$ echo $(date)
Wed 27 Feb 2008 09:17:26 CET
$ echo "$(date)"
Wed 27 Feb 2008 09:17:26 CET
$ echo '$(date)'
$(date)

Befehlsersetzung kann zwischen einfachen Anführungszeichen und in Kommentaren nicht verwendet werden. Abgesehen davon kann sie beliebig eingesetzt werden.

Im Gegensatz zur Ersetzung mit Linksapostroph ` kann die Ersetzung mit Klammern $() auch verschachtelt werden.

Shell-Arithmetik

Die Shell kann einfache Integer-Arithmetik durchführen. Diese ist jedoch, auch unter 64 Bit Systemen auf 32 Bit beschränkt. Für die meisten Shell aufgaben reicht das aber. Mathematische Ausdrücke werden einfach zwischen $(( und )) geklammert. Das funktioniert auch innerhalb von doppelten Anführungszeichen.

$ a=1
$ a=$((a + 1))
$ echo $a
2

Suffix- und Präfix-Entfernung

Die Shell kann relativ simples Entfernen von Präfixen und Suffixen. Das Ersetzungsausdruck funktioniert wie bei Dateinamen, es funktionieren keine regulären Ausdrücke. Für die Ersetzung von Präfixen wird der Operator # beziehungsweise ## verwendet. Für Suffixe % oder %%. Die einfache Schreibweise entfernt immer ein möglichst kleines Prä-/Suffix, die doppelte Schreibweise ein möglichst großes. Das folgende Beispiel verdeutlicht das.

$ file='/usr/local/bin/firefox'
$ echo "${file#*/}"
usr/local/bin/firefox
$ echo "${file##*/}"
firefox

Pipes

Viele Programme verwenden Daten die sie über die Standardeingabe /dev/stdin erhalten und machen ihre Ausgabe auf die Standardausgabe /dev/stdout und die Standardfehlerausgabe /dev/stderr. Von diesen hat jedes Programm seine eigenen, die über Pipes miteinander Verbunden oder in Dateien umgeleitet werden können.

$ dmesg | grep -i pci

Das Zeichen | ist eine gewöhnliche Pipe. Die Standardausgabe von dmesg wird in die Standardeingabe von grep umgeleitet.

$ grep -E '(WW|EE)' < /var/log/Xorg.0.log

Mit dem Zeichen < wird die Standardeingabe auf eine Datei verwiesen. Dieses Beispiel zeigt alle Fehler und Warnungen von Xorg. Natürlich wird grep nicht so verwendet, da es sowieso Dateien als Parameter nimmt. Das Kommando:

$ grep -E '(WW|EE)' /var/log/Xorg.0.log

erzeugt genau die gleiche Ausgabe.

$ echo $$ > pid

Mit > wird die Standardausgabe umgeleitet. In diesem Beispiel in die Datei pid.

Nun kommt es vor, dass Programme Fehlerausgaben erzeugen.

$ rm idonotexists 2> /dev/null

Mit 2> leitet man die Standardfehlerausgabe in eine Datei um. In diesem Fall wird die Ausgabe durch das Umleiten in /dev/null verworfen.

$ ifconfig ipw0 | grep associated 2> /dev/null

Wie in diesem Beispiel zu sehen ist, können die verschiedenen Umleitungen kombiniert werden.

$ echo Fehler! 1>&2

Mit >& kann eine Ausgabe in die Andere umgeleitet werden. Mit 2>&1 wird die Fehlerausgabe in die Standardausgabe geleitet und mit 1>&2 die Standardausgabe in die Fehlerausgabe. Das kann dazu verwendet werden um in eigenen Skripten, wie im Beispiel, die Fehlerausgabe zu verwenden oder um Fehler in einer Pipe an andere Programme weiterzugeben.

$ pkg_info -agq > /dev/null 2>&1

Dieses Beispiel leitet sowohl Standardfehler, als auch Standardausgabe nach /dev/null.

$ (pkg_info -agq > /dev/null) 2>&1

Dieses Beispiel ist schon deutlich sinnvoller. Es leitet die Standardausgabe nach /dev/null. Danach wird der Standardfehler in die Standardausgabe umgeleitet. Ohne die Klammerung würde der Standardfehler nicht in die Standardausgabe sondern in die Umleitung der Standardausgabe (/dev/null) umgeleitet werden.

$ (pkg_info -agq > /dev/null) 2>&1 | sed -E 's/^pkg_info: //1' | sed -E 's/ doesn.t exist$//1'

Wie dieses Beispiel zeigt kann das genutzt werden um die Fehlerausgabe separat in einer Pipe weiterzuverarbeiten. Dieser Befehl gibt unter FreeBSD alle von Paketen registrierten aber nicht existierenden Dateien aus.

Die Umleitungsanweisungen können übrigens überall im Befehl stehen. Die folgenden Befehle tun also alle das Gleiche.

$ echo $$ > pid
$ > pid echo $$
$ echo > pid $$

Aus Gründen der Lesbarkeit gehören Umleitungen aber an das Ende der Anweisung.

dynamische Parameter

In einem Skript werden Daten verarbeitet. Dazu müssen Befehle mit dynamisch erzeugten Parametern aufgerufen werden. Bei dynamisch erzeugte Parametern kann es sich um Variablen oder die Ausgabe von Befehlen handeln. Die hier erläuterten Möglichkeiten sind teilweise bereits in vorherigen Beispielen aufgetaucht.

Es gibt Befehle, die ihre Parameter als solche entgegennehmen, Andere hingegen Werten die Standardeingabe /dev/stdin aus und müssen deshalb über eine Pipe mit Daten gefüttert werden. Viele kombinieren beide Verfahren.

Befehle mit Parametern

Befehle die Parameter verwenden können sehr einfach mit Variablen dynamische Parameter zugewiesen werden.

$ cpus=`sysctl -n hw.ncpu`
$ echo There are $cpus CPU cores in this system.

Das ist äquivalent zu folgendem Befehl in dem auf das Zwischenspeichern in einer Variable verzichtet wird.

$ echo There are `sysctl -n hw.ncpu` CPU cores in this system.

Wenn Daten aus einer Pipe kommen können sie auch statt mit Befehlsersetzung mit dem Kommando xargs übergeben werden.

$ find /usr/src -name Makefile | xargs echo Makefile found:

Befehle die aus der Standardeingabe lesen

Viele Befehle wie grep oder sed lesen aus der Standardeingabe, wenn ihnen keine Dateien genannt werden aus denen sie lesen sollen. Das ist üblich bei Befehlen, die Daten Zeilenweise verarbeiten. Diese Befehle werden dann über eine Pipe mit ihren Daten versorgt.

$ dmesg | grep -i usb

Daten in Variablen müssen mit Hilfe von echo injiziert werden.

$ cpus=`sysctl -n hw.ncpu`
$ echo $cpus | sed -E 's/^(.*)$/There exist \1 cpu cores on this system/1'

Eingabe

Die meisten Shell-Skripte funktionieren nur mit übergebenen Parametern, aber auch interaktive Benutzereingaben sind möglich.

read

Mit dem read-Kommando wird eine Zeile eingelesen. Die einzelnen Worte werden in eine Liste von Variablen gespeichert, überschüssige Werte kommen in die letzte Variable.

#!/bin/sh
 
read -p 'Please enter: title name surname> ' title name surname
echo "Title:		$title"
echo "Name:		$name"
echo "Surname:	$surname"

Das ganze sieht dann in der Ausführung so aus:

Please enter: title name surname> Herr Dominic Fandrey
Title:          Herr
Name:           Dominic
Surname:        Fandrey

Die Möglichkeiten sind jedoch begrenzt:

Please enter: title name surname> Herr Dominic Christian Fandrey
Title:          Herr
Name:           Dominic
Surname:        Christian Fandrey

Hier landet der zweite Vorname in der Variablen für den Nachnamen. Da hilft nur alle Werte doch einzeln abzufragen.

Beim Einlesen einer Zeile werden mehrere aufeinander folgende Leerzeichen geschluckt. Will man eine unveränderte Zeile oder sogar mehrere auf einmal einlesen, bietet sich das Kommando head an, das ohne die Angabe einer Datei von /dev/stdin liest.

#!/bin/sh
 
echo "Please enter your delivery address (4 lines):"
delivery=$(head -l4)
 
echo "
You entered the following delivery address:
$delivery"

Beim Ausführen sieht es dann so aus:

Please enter your delivery address (4 lines):
Dominic Fandrey
Mustergasse 13
23666 Ödnis
Germany
 
You entered the following delivery address:
Dominic Fandrey
Mustergasse 13
23666 Ödnis
Germany

Leider endet mit dem Kommando head -c1 nicht die Ausgabe nach dem ersten Tastendruck. Jedoch wird in einer Variable tatsächlich nur das erste Zeichen gespeichert.

Bedingte Anweisungen

Ob und wie oft eine Anweisung ausgeführt wird steht nicht immer schon beim Programmieren fest. Dafür gibt es bedingte Anweisungen wie if und Schleifen, die es erlauben Code nur unter bestimmten Bedingungen auszuführen.

Befehlsketten

Anders als bei Pipes werden Kommandos bei Befehlsketten nicht gleichzeitig (parallel) sondern nacheinander (seriell) ausgeführt. Der Interpreter wartet also den Rückgabewert des Befehls aus und macht dann abhängig von diesem und dem Verkettungsoperator weiter.

Es gibt drei verschiedene Verkettungsoperatoren:

  • ; – führt den nächsten Befehl unabhängig vom Rückgabewert des vorherigen Befehls aus.
  • && – führt den nächsten Befehl aus, wenn der Rückgabewert des vorherigen Befehls 0 (true) ist.
  • || – führt den nächsten Befehl aus, wenn der Rückgabewert des vorherigen Befehls ungleich 0 (false) ist.

Hier ist ein kleines Beispiel, das immer true ausgeben wird:

$ : && echo true || echo false

Wer sich an das Kapitel über Variablen mit Standardwerten erinnert, weiß dass : ein Befehl ist, der nichts tut. Der Befehl gibt immer 0 (true) zurück. /bin/sh bietet noch die Möglichkeit mit dem Vorausstellen eines ! den Rückgabewert zu invertieren. Die 0 wird dadurch zu 1 und alle anderen Rückgabewerte zu 0.

Der folgende Befehl wird also immer false ausgeben:

$ ! : && echo true || echo false

Hier mal ein etwas sinnvolleres Beispiel, das prüft ob eine Datei existiert und eine entsprechende Ausgabe erzeugt.

$ test -e FILE && echo "Yes, the file exists." || echo "No, the file does not exist"

Da solche Zeilen oft lang und unleserlich werden sollten sie gegebenenfalls mit einem \ am Zeilenende über mehrere Zeilen verteilt werden:

$ test -e FILE \
> 	&& echo "Yes, the file exists." \
> 	|| echo "No, the file does not exist."

Je nachdem ob FILE existiert erscheint dann die entsprechende Ausgabe.

Wie bei den Pipes kann auch hier mit Klammern geschachtelt werden:

$ test -e FILE \
> 	&& echo "Yes, the file exists." \
> 	|| ( echo "No, the file does not exist."; touch FILE)

Im Fehlerfall werden in diesem Beispiel also gleich 2 Befehle ausgeführt.

if, then, else

Obwohl Befehlsketten in Verbindung mit Klammerung die gleiche Mächtigkeit wie if-Anweisungen haben, sind sie doch schnell ziemlich unübersichtlich. Deshalb werden in Skripten if-Anweisungen deutlich häufiger verwendet.

Die häufigste Verwendung von if ist in Verbindung mit [:

if [ "$1" = "$2" ]; then
	echo "The parameters 1 and 2 are identical."
else
	echo "The parameters 1 and 2 differ."	
fi

Die else-Anweisung ist natürlich optional. Bei [ handelt es sich um ein Synonym für das Kommando test. Die Syntax kann also der Manual-Page test(1) entnommen werden.

Das folgende Beispiel ist also äquivalent zum letzten Beispiel des vorherigen Abschnitts:

if [ -e "$1" ]; then
	echo "Yes, the file exists."
else
	echo "No, the file does not exist."
	touch "$1"
fi

Der Dateiname FILE wurde hier durch $1 ersetzt. Er wird also als erster Parameter zum Skript erwartet.

Auch if prüft nur den Rückgabewert eines Befehls. Statt [ oder test kann jeder beliebige Befehl verwendet werden, auch eigene Funktionen.

case

Mit case können Variablen mit sogenannten Shell-Patterns gefiltert werden. Shell-Pattern heißt, es gelten die gleichen Regeln wie bei Dateinamen. Shell-Patterns können im case-Statement mit | verodert werden.

case "$1" in
	'')
		echo "No parameter given."
	;;
	[0-9])
		echo "'$1' is a digit."
	;;
	--* | -?)
		echo "'$1' is an unknown option."
		return 1
	;;
	*)
		echo "'$1' is unknown."
	;;
esac

Schleifen

Oft müssen die gleichen Befehle wiederholt auf verschiedenen Datensätzen angewendet werden. Dafür werden Schleifen verwendet.

for-Schleifen

Die for-Schleife bei Shell-Skripten entspricht den aus anderen Sprachen bekannten foreach-Schleifen mit denen man durch Listen iterieren kann. for betrachtet die übergebenen Parameter als Elemente einer Liste. Normalerweise funktionieren Tabulatoren oder Leerzeichen als Trennzeichen. Das kann mit der Variable IFS (Input Field Separator) verändert werden. Es kann häufiger Vorkommen, dass man Informationen Zeilenweise verarbeiten will. Das ist zum Beispiel nützlich um auch mit Leerzeichen enthaltenden Dateinamen klarzukommen.

#!/bin/sh
 
# Use line breaks as field separators.
IFS='
'
 
lastName=
lastChksum=
printed=
for file in $(sha256 "$@" | sort -k5); do
	name="${file#*(}"
	name="${name%)*}"
 
	chksum="${file##*= }"
 
	if [ "$chksum" = "$lastChksum" ]; then
		if [ -z "$printed" ]; then
			echo "The following files have the same checksum ($chksum):"
			echo "$lastName"
			printed=1
		fi
		echo "$name"
	else
		printed=
	fi
done

Dieses Skript listet Duplikate aus einer Liste von Dateien.

Der Ausdruck $(sha256 „$@“ | sort -k5) gibt eine Liste von Dateien mit Prüfsummen zurück. Der sort -k5 Befehl sorgt dafür, dass die Liste nach den Prüfsummen sortiert wird. Aus diesem Grund folgen Duplikate immer aufeinander. Statt den Ausdruck in der Schleife in geschweifte Klammern zu stellen, können auch die Schlüsselwörter do und done verwendet werden.

Eine for-Schleife ohne Liste geht alle dem Skript übergebenen Parameter durch:

#!/bin/sh
 
for parameter do
	echo "Parameter '$parameter' was given."
done

Diese Syntax ist jedoch nicht portabel. Deshalb und für die Lesbarkeit empfiehlt es sich explizit die Liste der Parameter zu übergeben:

#!/bin/sh
 
for parameter in "$@"; do
	echo "Parameter '$parameter' was given."
done

while-Schleifen

Eine while-Schleife läuft bis der folgende Programmaufruf einen Fehler zurückgibt. Mit dem Zeichen ! kann die Logik umgekehrt werden.

#!/bin/sh
 
while ! ifconfig fxp0 | grep -q UP; do
	sleep 5
done
 
ifconfig fxp0 down
$0 &

Dieses Beispielskript macht eine recht sinnlose Tätigkeit. Es überwacht das Netzwerkinterface fxp0 und deaktiviert es, falls es aktiv ist. Wer tatsächlich etwas dergleichen erreichen will, sollte das natürlich lieber über Zugriffsrechte, devd oder einen Paketfilter regeln.

Schleifen mit Zähler

Eine Schleife mit Zähler, die die Funktion wie eine for-Schleife in Programmiersprachen wie C erfüllt, kann mit einer while-Schleife und Shell-Arithmetik realisiert werden.

Das folgende Skript gibt den ersten übergebenen Parameter rückwärts aus.

#!/bin/sh
 
out=""
i=$((${#1} - 1))
while [ $i -ge 0 ]; do
	tail=$((${#1} - $i - 1))
	out="$out$(echo $1 | sed -E -e "s/^.{$i}(.).{$tail}\$/\1/1")"
	i=$(($i - 1))
done
echo "$out"

Die Schleifenlogik kann auf folgende Zeilen reduziert werden:

i=$((${#1} - 1))
while [ $i -ge 0 ]; do
	i=$(($i - 1))
done

Die Zuweisung von $i und auch $i selbst muss nie in Anführungszeichen gesetzt werden, weil wir wissen, dass wir auf jeden Fall mit gültigen Zahlen arbeiten. Im Kapitel Variablen wurde erklärt, dass ${#1} das gleiche ist wie die Länge von $1. Selbst wenn also kein Parameter übergeben wird, ist $i immer noch eine gültige Zahl -1.

Diese Schleife zählt rückwärts, Schleifen die vorwärts zählen sind natürlich auch möglich:

i=0
while [ $i -lt 10 ]; do
	i=$(($i + 1))
done

Diese Schleife zählt von 0 bis 9.

continue und break

Mit den Befehlen continue und break kann der Schleifendurchlauf beeinflusst werden. Der Befehl continue bricht den aktuellen Durchlauf ab und beginnt mit dem nächsten Durchlauf. Der Befehl break bricht die Schleife komplett ab. Optional nehmen die Befehle die Schleifentiefe auf der sie Operieren sollen als Parameter. Wenn also zum Beispiel zwei ineinander verschachtelte Schleifen vorhanden sind und sowohl die innere, wie auch die äußere Schleife beendet werden soll, lautet der Befehl break 2.

Funktionen

Umfangreiche Shell-Skripte können in Funktionen unterteilt werden. In der Verwendung sind Funktionen fast wie eigenständige Befehle, mit eigenen Parametern.

Funktionen deklarieren

Eine Funktionsdeklaration beginnt mit dem Namen der Funktion, gefolgt von einem Klammerpaar. Die eigentlichen Befehle folgen innerhalb geschweifter Klammern.

function_name() {
	...
}

lokale Variablen

Variablen in Shell-Skripten sind für gewöhnlich global. Mit dem Befehl local können sie jedoch lokal deklariert werden. Die Variable wird innerhalb der Funktion erzeugt und nachdem die Funktion terminiert auch wieder vernichtet. Eventuell bereits vorhandene Variablen, werden wenn sie als lokal deklariert werden, kopiert. Innerhalb der Funktion wird dann nur noch auf der Kopie gearbeitet. Änderungen sind Nach außen nicht wirksam.

#!/bin/sh
 
output='live'
 
live() {
	local output
	output='die'
}
 
ordie() {
	output=' or die'
}
 
live
printf "$output"
ordie
echo "$output"

Das Kommando local kann mehrere Variablen verarbeiten.

Parameter

Funktionen haben ihre eigenen Parameter. Nur wenn sie ohne Parameter aufgerufen werden, erben sie die Parameter des Skripts oder der aufrufenden Funktion.

Rückgabewerte

Funktionen können mit den Befehlen return und exit beendet werden. Beide Befehle erwarten einen Parameter, der den numerischen Rückgabewert bestimmt. Dieser Wert kann im Bereich von 0 bis 255 liegen. Es ist eine Konvention für true oder Alles in Ordnung den Wert 0 zu verwenden. Viele Befehle wie if oder while verlassen sich auf die Einhaltung dieser Konvention. Alle anderen Werte werden als Fehlercode und somit als false aufgefasst.

Skripte die Dateien verarbeiten

Beim erzeugen von Skripten gibt es einige Einschränkungen und Dinge die zu beachten sind. Zuerst ist die Zahl der Parameter die ein Skript annehmen kann begrenzt, damit auch die Zahl der Dateiname die dem Skript übergeben werden können. Das ist dann kritisch wenn ein Skript sehr viele Dateien verarbeiten soll, zum Beispiel um viele Dateien nach einem bestimmten Schema umzubenennen.

Das zweite Problem ist, dass Dateinamen Leerzeichen enthalten können.

Der Input Field Seperator

Die einzige uneingeschränkt funktionierende Methode mit Leerzeichen in Dateinamen umzugehen ist die Änderung des IFS (Input Field Seperator). Der IFS ist eine Variable, die eine Liste aller Zeichen enthält, die Parameter trennen. Das sind normalerweise Leerzeichen, Tabulatoren und Zeilenumbrüche. Beim Verarbeiten von Dateien dürfen nur noch Zeilenumbrüche zur Parametertrennung verwendet werden.

#!/bin/sh
 
IFS='
'

Der einzige Nachteil ist, dass das Anhängen an eine Liste (zum Beispiel um sie später mit for zu verarbeiten) etwas hässlicher wird.

list="$list
$newEntry"

Das ist besonders bei eingerücktem Code hässlich.

while ...; do
	if [ ... ]; then
		list="$list
$newEntry"
	fi
done

Das lässt sich jedoch nicht vermeiden, da keine Leerzeichen hinzugefügt werden dürfen, da sie nun ein Teil der Daten wären und nicht mehr beim Verarbeiten verworfen werden.

Dateinamen aus der Standardeingabe

Eine gute Methode die Beschränkung der Parameterzahl zu umgehen ist die Dateinamen aus der Standardeingabe zu lesen. So kann das Skript dann später mit dem find-Kommando über eine Pipe mit den Dateinamen versorgt werden. Dazu wird die Standardausgabe Zeilenweise eingelesen.

#!/bin/sh
 
IFS='
'
while read file; do
	...
done

Im while-Block steht nun jeweils in der Variable file der aktuelle Dateiname zur Verfügung. Das kann natürlich nicht bloß für Dateinamen verwendet werden, sondern für alle zeilenweise arbeitenden Skripte.

Im folgenden ein praktisches Beispiel aus der Skriptsammlung des Autors:

#!/bin/sh
 
IFS='
'
 
command="$(basename "$0" | sed -E 's/^sed//1')"
 
while read file; do
	wait
	$command -v "$file" "$(echo "$file" | sed "$@")" &
done
 
wait

Die Zeile

command="$(basename "$0" | sed -E 's/^sed//1')"

erzeugt ein Kommando aus dem Dateinamen des Skripts. Beim Autor heißt das Skript sedcp und es existiert ein Hardlink mit Namen sedmv. Das resultierende Kommando ist also jeweils cp und mv. Das folgende Beispiel verdeutlicht den Verwendungszweck.

$ find downloads/ -type f | sedmv -E 's/$/.txt/1'
downloads/cauliflower -> downloads/cauliflower.txt
downloads/pirates -> downloads/pirates.txt
downloads/marks -> downloads/marks.txt
downloads/iCTF -> downloads/iCTF.txt

Das Skript setzt etwas Wissen aus dem Kaptiel Prozesse forken voraus. Trotzdem folgt jetzt schon mal eine kurze Erklärung. Damit das Skript schon mal den nächsten Dateinamen einlesen kann, wird das eigentlich Kommando geforkt, das heißt es wird parallel zum weiterlaufenden Skript ausgeführt.

Durch das wait am Schluss wird sichergestellt, dass das Skript erst dann terminiert, wenn alle geforkten Prozesse terminiert sind. Das stellt eine sauber Ausgabe und die sequentielle Verwendbarkeit sicher. Das bedeutet so viel wie, wenn das Skript terminiert, ist es auch tatsächlich fertig.

Das wait innerhalb der while-Schleife kann weggelassen werden. Das bürgt aber Risiken, denn dann kann es passieren, vor allem wenn lange Dateien kopiert werden, dass das Skript sehr viele Prozesse auf einmal forkt, die alle um die Ressource Dateisystem konkurrieren. Außerdem kann es passieren, dass die maximale Anzahl von Prozessen erreicht wird, oder noch schlimmer, der Speicher ausgeht. In letzterem Fall werden Prozesse vom System abgeschossen. Da das System dafür die größten Prozesse nimmt erwischt es also wichtige Dinge wie den X-Server oder eine Datenbank. Die eigentlichen Übeltäter, bleiben verschont. Deshalb sorgt das wait in der Schleife dafür, dass erst der nächste Prozess geforkt wird, wenn der vorherige terminiert.

Skripte zusammenführen

Mit dem Befehl . können andere Skripte innerhalb der aktuellen Umgebung ausgeführt werden. Das ist nützlich um Funktionen und Variablen aus diesen Skripten zu erhalten. Ein gutes Beispiel sind dafür die rc-Skripte in NetBSD (die NetBSD rc-Implementierung wird auch von FreeBSD verwendet). Jedes Skript im Verzeichnis /etc/rc.d importiert mit dem Befehl das Skript /etc/rc.subr. So stehen dessen Funktionen zur Verfügung, die es erlauben sehr kurze und mächtige Startskripte für Dienste zu schreiben.

. /etc/rc.subr

Prozesse forken

UNIX und seine Derivate sind seit jeher Multitasking-Systeme. Das heißt es können mehrere Prozesse parallel laufen. Beim Forken wird ein Prozess komplett dupliziert und unterscheidet sich erst einmal lediglich durch die PID, das ist die Process ID. Außerdem hat der neue Prozess keine Standardeingabe.

In einem Shell-Skript kann ein Prozess mit Hilfe des &-Zeichens geforkt werden. Dazu wird ein Befehl mit & abgeschlossen. Die interpretierende Shell wird kopiert und führt den Befehl aus. Sie terminiert nachdem der Befehl ausgeführt ist. Der Originalprozess kann die PID aus der Variable $! auslesen.

Der Effekt lässt sich gut an einem kleinen Beispiel auf der Shell nachvollziehen:

$ sleep 10; printf "time out" &

Die Befehlskette wird durch das '&' in den Hintergrund geforkt und die Shell nimmt sofort wieder weitere Eingaben an. Nach 10 Sekunden wird die Nachricht „time out“ ausgegeben, wo immer der Cursor sich gerade befindet.

auf geforkte Prozesse warten

Um auf Prozesse zu warten steht der Befehl wait zur Verfügung. Ohne Parameter wartet der Befehl auf alle geforkten Prozesse. Alls Parameter nimmt der Befehl die PIDs der Prozesse auf die er warten soll.

Folgendes Skript ist wieder ein Beispiel aus der Sammlung des Autors. Der Name des Skripts ist pingC. Das Skript pingt alle Adressen eines Klasse C Netzes an.

#!/bin/sh
 
ip=0
if ! echo "$1" | grep -E '^(([0-9]{1,3})\.){2}[0-9]{1,3}$' > /dev/null; then
	echo "Not a valid class C net. ###.###.### format expected."
	return 1
fi
 
do_ping() {
	if ping -c 1 -t 1 "$1.$2" > /dev/null 2>&1; then
		name=`route get "$1.$2" | awk '/route to:/ {print($3);}'`
		if [ "$name" != "$1.$2" ]; then
			echo "$1.$2	$name"
		else
			echo "$1.$2"
		fi
	fi
}
 
(
	while [ $ip -le 255 ]; do
		ip=$(($ip + 1))
		do_ping "$1" $ip &
	done
 
	wait
) | sort -gk 4 -t \.

Der if-Ausdruck am Anfang prüft ob der Parameter im korrekten Format übergeben wurde. Die Funktion do_ping() führt den eigentlichen Ping und die Ausgabe durch. Der hier wirklich interessante Teil ist aber der untere geklammerte Block.

Im unteren geklammerten Block wird die Funktion do_ping() 256 mal geforkt. Dann wird darauf gewartet, dass alle Kommandos terminieren. Die Klammerung ist notwendig um die Ausgabe aller Befehle zusammenzufassen, damit der Befehl sort sie sortieren kann. Durch den Fork benötigt das Skript beim Autor in einem kleinen Test ca. 2 statt 252 Sekunden.

Kommunikation über Signale

Die simpelste Art der Prozesskommunikation ist über Signale. Signale werden mit dem Kommando kill verschickt. Mit dem Kommando trap können Signale abgefangen werden. Beispielsweise können so noch letzte Aufräumaktionen gestartet werden.

trap "rm $pidfile" EXIT

EXIT ist ein Pseudosignal, dass auftritt wenn das Skript terminiert. Eine Liste der verfügbaren Signale mit Erklärung gibt es in der Manual-Page signal(3). Erwähnenswert sind die Signale USR1 und USR2, die für eigene Verwendung vorgesehen sind.

Im folgenden eine Beispielzeile aus einem Skript des Autors:

trap "echo 'wi: signal hup trapped'; (wi_start $* &) ; exit 0" hup

Hier wird SIGHUP abgefangen und zum Anlass genommen einen Daemon neu zu starten.

nützliche Befehle

Dies ist eine grobe Übersicht zum Nachschlagen. Nährere Beschreibungen sind in den Manual-Pages zu finden.

awk

Awk ist eigentlich eine komplette (kleine) Skriptsprache, die besonders dafür geeignet ist Daten Zeilen- und Spaltenweise zu verarbeiten. Statt einem kompletten Skript reicht aber meist schon ein Einzeiler um die gewünschte ausgabe zu erzeugen.

cat

Cat konkateniert Dateien. Natürlich kann es auch einzelne Dateien ausgeben.

cut

Mit cut können bestimmte Spalten aus einer Ausgabe geschnitten werden. Optional kann auch das Trennzeichen angegeben werden.

expr

$ expr 5 - 9 \* 7
-58

Das Kommando expr kann einfache Integeroperationen ausführen. Es versteht Zeichen wie “+-*/()„. Zeichen die dabei von der Shell interpretiert werden müssen natürlich ein \ vorangestellt werden. Dies ist eine häufige Fehlerquelle.

grep

Das Kommando grep erlaubt es Daten zeilenweise nach regulären Ausdrücken zu filtern.

head

Mit dem Kommando head kann der Anfang einer Datei betrachtet werden. Optional können auch feste Zeilen- oder Bytezahlen vorgegeben werden.

sed

Mit dem sed-Kommando können Ersetzungen mit Hilfe regulärer Ausdrücke durchgeführt werden.

sort

Das Kommando sort erlaubt es Ausgaben zu sortieren. Optional kann auch eine Spalte nach der sortiert wird angegeben werden. Auch ist es möglich doppelt vorkommende Zeilen zu entfernen.

tail

Das Kommando tail ist das Gegenstück zu head. Es gibt das Ende einer Datei aus.

test

Das Kommando test kann verwendet werden um logische Ausdrücke auszuwerten. Bekannter ist die Sonderform [ in der es oft mit dem Kommando if verwendet wird.

touch

Das Kommando touch stellt sicher, dass eine Datei existiert und aktualisiert das Datum der letzten Änderung.

uniq

Mit diesem Kommando können doppelte Zeilen aus einer bereits vorsortierten Ausgabe gefiltert werden.

Verweise

  • Die Manpage sh(1).
  • Die Manpage test(1).
  • Die Manpage xargs(1).