Dieser Artikel erfordert mehr Erfahrung im Umgang mit Linux und ist daher nur für fortgeschrittene Benutzer gedacht.
Dieser Artikel soll ein wenig Hilfe geben, "besser" mit der Shell umzugehen. Insbesondere wird ein Augenmerk darauf gelegt, übersichtlich zu bleiben und dabei möglichst wenige Prozesse zu verwenden. In Zeiten von Gigahertz-Prozessoren wirkt das vielleicht ein wenig anachronistisch, wenn allerdings ein "schlechtes" Konstrukt in einem Skript einige tausend oder gar Millionen Male ausgeführt wird, können nicht unerhebliche Laufzeiten entstehen.
Weiterhin soll hier der eine oder andere nicht offensichtliche Trick zu finden sein.
Die Shell und die Standard-Unix-Hilfsprogramme wie beispielsweise grep, sed und awk sind eine mächtige Kombination, die das Computer-Leben erleichtern kann.
cat wird häufig verwendet, um den Inhalt einer Datei an ein Programm zu übergeben. Das ist aber überflüssig, da man das auch mit einem Kleiner-Als-Zeichen machen kann.
Schlecht:
cat dateiname | grep "suchbegriff"
Schon besser:
grep "suchbegriff" <dateiname
Vielen Befehlen kann man die Datei aber auch direkt übergeben, darunter auch grep:
grep "suchbegriff" dateiname
kill -9
bricht einen Prozess ohne Rücksicht auf Tochterprozesse ab. Das sollte nur dann in Erwägung gezogen werden, wenn ein kill PID
nicht mehr funktioniert.
kill PID
sendet eine "Beenden-Aufforderung (-15
/-TERM
)" an den Prozess mit der Nummer PID
, damit bekommt er die Gelegenheit, offene Dateien zu schließen, die Tochterprozesse sauber zu beenden, Socket-Verbindungen zu beenden, temporäre Dateien zu löschen, ...
pkill NAME
sendet ebenfalls eine "Beenden-Aufforderung (-15
/-TERM
)" an den Prozess, nur dass man hier den Prozessnamen bzw. einen regulären Ausdruck anstatt der PID benutzt. Siehe auch pidof mit dem korrekten Programmnamen.
Erst wenn das nicht funktioniert, weil der Prozess hängt, ist ein kill -9
angebracht.
Das ist ein Spezialfall der sinnlosen Verwendung von Kommandosubstitution:
Häufig ist folgende (falsche) Verwendung zu sehen:
1 | Kommando $(echo $variable) |
Eine Shell interpretiert jede Kommandozeile zweistufig. In der ersten Stufe werden alle Variablen durch ihre Inhalte ersetzt, es wird eine Dateinamenersetzung und eine Kommandoersetzung durchgeführt. In der zweiten Stufe wird die daraus entstandene Kommandozeile ausgeführt.
Damit ist Folgendes der richtige Weg:
Kommando $variable
In der ersten Stufe wird $variable durch dessen Inhalt ersetzt. In der zweiten Stufe wird die daraus entstandene Zeile ausgeführt.
Ein weiterer Spezialfall der sinnlosen Verwendung von Befehlssubstitution ist der Einsatz von ls
.
Falsch:
1 2 3 4 | for i in $(ls *) do cat $i done |
Wie im vorigen Abschnitt beschrieben, durchläuft jedes Shellkommando einen zweistufigen Interpretationsprozess. In der ersten Stufe wird eine Dateinamenersetzung durchgeführt, so dass man gerne das Folgende macht:
1 2 3 4 | for i in * do cat $i done |
Jedes Vorkommen von *
wird durch die Dateien ersetzt, die auf *
passen. Beinhalten die Dateien jedoch Leerzeichen, dann werden auch die Dateinamen einzeln interpretiert und das Leerzeichen wirkt wie ein Trenner, sie werden aufgesplittet. Um dies zu vermeiden sollte man die zu bearbeitende Variable immer in "" setzen oder eine read
-Schleife benutzen.
1 2 3 4 | for i in * do echo "$i" # Variable in " " done |
Es tritt noch ein weiteres Problem auf, bei ls *
werden auch evtl. vorhandene Unterverzeichnisse durchsucht und das Ergebnis wird verfälscht.
Um das Beispiel mit cat
zu vervollständigen: cat
erlaubt auch die Angabe mehrerer Dateien, also ist das folgende die beste Lösung um Dateiinhalte aufzulisten:
cat *
Sehr häufig sind Konstrukte anzutreffen, in denen etwas gesucht und die Anzahl der Zeilen mit Treffern ausgegeben wird.
1 | grep "suchbegriff" dateiname | wc -l |
Das Programm grep selber hat allerdings die Option die Anzahl der Trefferzeilen auszugeben.
Daher ist
grep -c "suchbegriff" dateiname
die bessere Lösung. Ausnahme:
grep -o "suchbegriff" dateiname | wc -l
Der Befehl
1 | grep -oc "suchbegriff" dateiname
|
würde hier zu einem falschen Ergebnis führen. Überhaupt lohnt sich ein Blick in die Kommandozeilenoptionen von grep.
awk wird von vielen überwiegend dazu verwendet, Spalten einer Logdatei (oder einer anderen Ausgabe) auszugeben.
Um die Zeilen zu finden, aus denen awk die Spalten ausgeben soll, wird meistens grep vorgeschaltet, so dass ein solches Konstrukt dabei entsteht:
1 | grep "suchbegriff" dateiname | awk '{ print $2 }' |
Das gibt die zweite Spalte von Zeilen aus, die den Suchbegriff enthalten.
Da awk auch suchen kann, ist es aber sinnvoller
awk '/suchbegriff/ { print $2 }' dateiname
zu verwenden.
Häufig werden aus Dateien mit grep die Zeilen gesucht, in denen man etwas mit sed suchen und ersetzen möchte.
1 | grep "suchbegriff" dateiname | sed 's/anderesuche/ersetzungstext/' |
Da sed auch suchen kann, ist es aber sinnvoller
sed '/suchbegriff/s/anderesuche/ersetzungstext/' dateiname
zu verwenden.
Gerne wird der Aufruf
1 | ps aux | grep ssh # als Beispiel wird das Programm ssh genutzt |
ausgeführt, um festzustellen ob ein Programm läuft bzw. welche PID es hat. Dieser Aufruf ist aus 2 Gründen nicht zu empfehlen: 1. Es werden zwei Programme für die Ergebnisaufstellung genutzt und 2. grep
findet sich selbst ebenfalls in der Prozessliste und zeigt dies auch an. grep
-Kenner wenden darum einen weiteren Trick an und es entstehen folgende Aufrufe
1 2 3 | ps aux | grep -v "grep" |grep ssh # als Beispiel wird das Programm ssh genutzt # oder mit der Kraft der regulären Ausdrücke ps aux | grep [s]sh |
um das grep
wieder auszublenden.
All diese Eingaben sind nicht notwendig, denn unter Ubuntu gibt es das Programm pgrep, welches das viel kürzer kann:
pgrep -fl ssh
bzw. um nur die PID zu ermitteln:
pidof ssh
oder mit ps
-eigenen Mitteln:
ps -C ssh
Ein Skript, in dem $( ... )
vorkommt, oder die veralteten Backticks, kann man sehr häufig optimieren.
1 2 3 | for i in $(cat dateiname) ; do ... done |
Lässt sich optimieren nach
1 2 3 | while read i ; do ... done < dateiname |
Wenn statt cat dateiname
ein komplexerer Befehl benutzt wird, ist es besser statt
1 2 3 | for i in $(komplexerer Befehl) ; do ... done |
1 2 3 | while read i ; do ... done< <(komplexer Befehl) |
zu schreiben.
Es ist überflüssig
1 2 3 4 5 | kommando if [ $? -ne 0 ] ; then echo "fehler" exit 255 fi |
zu schreiben, wenn doch auch
1 2 3 4 | if ! kommando ; then echo "fehler" exit 255 fi |
funktioniert.
In dem Zusammenhang noch ein gerne gemachter "Fehler" mit grep.
1 2 | grep "suchbegriff" datei > /dev/null if [ $? -ne 0 ] ; then |
ist genauso wenig optimal wie
1 | if grep "suchbegriff" datei > /dev/null ; then |
weil es dafür "-q" gibt, welches die Suche beim ersten Treffer abbricht.
if grep -q "suchbegriff" datei ; then
(weitergehende Informationen gibt es unter grep)
.*
könnte man auch umschreiben mit "alles oder nichts". Das ist der reguläre Ausdruck, der immer wahr ist.
.
ist das Jokerzeichen, steht also für irgendein Zeichen.
*
steht für kein Mal oder beliebig oft.
Ein beliebiges Zeichen, kein Mal oder beliebig oft, trifft auf alles zu. Wenn statt des Sterns *
ein Pluszeichen +
verwendet wird, ergibt es Sinn. Das +
steht für wenigstens ein Mal. Wird grep verwendet, muss noch ein Schrägstrich vor das Pluszeichen gesetzt werden: \+
, und der gesamte reguläre Ausdruck muss auch dann gequotet werden, wenn er mit dem Stern nicht gequotet werden müsste. Bei egrep mit erweiterten regulären Ausdrücken ist dieses Escapen des Metazeichens nicht nötig.
Häufig will man sich den MBR-Inhalt mittels hexdump ansehen, dabei werden dann Kombinationen mit dd und einer Pipe gebaut, das ist nicht nötig: hexdump kann man einen Startwert und ein Anzahl der auszuwerteten Bytes mitgeben. Sollte die Platte mit logischen Sektoren ungleich 512 Bytes eingerichtet sein, dann müssen die Werte natürlich auf diese abgestellt werden.
1 | sudo dd if/dev/sda count=1| hexdump -C |
Beispiele:
Andruck des MBR der Festplatte /dev/sda:
sudo hexdump -s0 -n512 -C /dev/sda
Dabei beschreibt s den Start (0 Byte) und n die Anzahl der zu druckenden Bytes (512 = MBR). Mit -C wird der Canonical-Modus eingeschlatet (Hex und mögliche Klarschrift).
Hier wird der GPT-Header (LBA 1) angedruckt:
sudo hexdump -s512 -n512 -C /dev/sda
Man kann eine Datei namens dateiname mittels
> dateiname
leeren.
Vorteil: Der Dateihandle ändert sich nicht. Was bedeutet, dass ein Programm, das die Datei als Logdatei benutzt, dort weiterhin hineinschreiben kann, ohne neu gestartet werden zu müssen (Beispiel Serverdienste und deren Logdateien).
read
und Pipe¶In den meisten Shells wird jedes Kommando einer Pipe in einer separaten Shell (Sub-Shell) ausgeführt, dadurch stehen die bearbeiteten Variablen anschließend im Eltern-Prozess nicht mehr zur Verfügung. Das folgende Beispiel veranschaulicht dies:
1 2 3 4 5 6 7 8 | # printf übergibt 2 Zeilen die mittels read gezählt werden Zaehler=0 printf "%s\n" foo bar | while read -r line do Zaehler=$((Zaehler+1)) echo "Zaehler in der Schleife: $Zaehler" # Ausgabe 2 done echo "Zaehler nach der Schleife: $Zaehler" # Ausgabe 0 |
Das gleiche Problem tritt auch ohne Schleife auf
1 2 3 | Zaehler=0 echo 2 | read -r Zaehler echo "echo übergab 2, aber jetzt beinhaltet der Zaehler: $Zaehler, also 0" |
Um dieses Verhalten zu umgehen, sollte man read mittels Prozess-Substitution aufrufen.
Dabei kommt dem < eine besondere Bedeutung zu: | |
< irgendeineDatei | lesen der Daten aus der Datei irgendeineDatei |
< <(Kommando/Kommandokette) | lesen der Daten aus der Ausgabe eines Kommandos oder einer Kommandokette |
<<<"$Variable" | lesen der Daten aus einer vorher befüllten Variable |
Hier nun die korrekten Beispiele:
read
liest die Zeilen aus einer Datei¶
1 2 3 4 5 6 7 | Zaehler=0 while read -r line do Zaehler=$((Zaehler+1)) echo "Zaehler in der Schleife: $Zaehler" done < irgendeineDatei echo "Zaehler nach der Schleife: $Zaehler" |
read
liest die Zeilen aus einem Kommando bzw. Kommandokette¶
1 2 3 4 5 6 7 | Zaehler=0 while read -r line do Zaehler=$((Zaehler+1)) echo "Zaehler in der Schleife: $Zaehler" done < <(cat Datei1 Datei2) echo "Zaehler nach der Schleife: $Zaehler" |
read
liest die Zeilen aus einer vorher befüllten Variable¶
1 2 3 4 5 6 7 8 | Variable=$(printf "%s\n" foo bar usw ...) Zaehler=0 while read -r line do Zaehler=$((Zaehler+1)) echo "Zaehler in der Schleife: $Zaehler" done <<<"$Variable" echo "Zaehler nach der Schleife: $Zaehler" |
multiple Aufrufe von "<()" können dabei so aussehen:
1 | diff <(echo "foo") <(echo "fool") |
Noch ein Hinweis zum Schluss: read
liest standardmäßig Zeilen ein, reagiert also auf ein \n
(newline). Will man dies beeinflussen, kann man das mit der Option -d delim
bewirken. Mehr dazu unter man bash-builtins read
Wenig bekannt ist, dass SSH auch Standardeingabe und Standardausgabe verwenden kann. Damit ist es möglich via
tar cf - <dateien oder verzeichnisse> | ssh <user>@<zielhost> "cd <zielverzeichnis> && tar xf -"
<dateien oder verzeichnisse>
vom aktuellen Rechner ins <zielverzeichnis>
auf dem <zielhost>
als <user>
zu kopieren. Das "-"-Zeichen bei tar cf
sorgt dafür, dass tar seinen Datenstrom in Richtung Standardausgabe schickt, die wird mittels "|
" umgelenkt auf den ssh-Befehl, in dem wiederum tar xf -
die Daten von der Standardeingabe liest.
Das ist noch nichts spektakuläres. Das kann scp
auch. Allerdings überträgt scp
- auch mit der Option -p
- keine Dateieigentümerschaften und löst symbolische Links auf, anstatt sie 1:1 zu übertragen. Deswegen ist scp
für das exakte Klonen eines Verzeichnisbaums ungeeignet.
Zusammen mit dem Paket buffer (aus universe) kann man den Datentransfer außerdem gerade bei sehr vielen kleinen Dateien beschleunigen.
tar cf - <dateien oder verzeichnisse> | buffer -m 10m | ssh <user>@<zielhost> "cd <zielverzeichnis> && tar xf -"
Das buffer -m 10m
sorgt dafür, dass ein Puffer von 10 Megabytes eingerichtet wird.
Manchmal speichert man einen Befehl in einer Variablen, um ihn später an mehreren Stellen aufzurufen:
1 2 3 4 5 6 7 8 | #!/bin/bash cmd="vim" # ... $cmd foo $cmd bar |
Das hat den Vorteil, dass man den Befehl bei Bedarf nur an einer Stelle ändern muss. Möchte man den Befehl nun um Parameter erweitern, dann geht das im einfachsten Fall gerade noch:
1 | cmd="vim -p" |
Gemäß den Regeln des „word splitting“ werden hieraus die beiden
Argumente „vim
“ und „-p
“.
Was aber, wenn man den Befehl so erweitern möchte, dass er eigentlich Anführungszeichen benötigen würde? Folgendes Beispiel funktioniert nicht:
1 2 | # Falsch! cmd="vim -c \"set number\"" |
Der Knackpunkt ist, dass die inneren Anführungszeichen hier nicht wie vielleicht erwartet ausgewertet werden. Es entsteht folgende Liste an Argumenten:
vim -c "set number"
Auch der Aufruf mit äußeren Anführungszeichen über
1 | "$cmd" foo |
schlägt fehl – in diesem Fall wird versucht, „vim -c "set number"
“
aufzurufen und zwar als ein einziges Argument gesehen. Eine Datei mit
dem Namen „vim -c "set number"
“ gibt es natürlich nicht.
Man müsste den String eigentlich zweimal auswerten, um die inneren
Anführungszeichen zu berücksichtigen (das ginge mit „eval
“), was aber
fehleranfällig ist und aus guten
Gründen vermieden werden muss.
Der bevorzugte Weg, das Problem zu lösen, ist, eine Funktion zu definieren und diese statt des eigentlichen Befehls aufzurufen:
1 2 3 4 5 6 7 8 9 10 | #!/bin/bash cmd() { vim -c "set number" "$@" } # ... cmd foo |
Funktionen sind dafür gedacht, Code zu enthalten – Variablen sollen Daten enthalten. Darüberhinaus ermöglicht dieser Ansatz auch komplexere Befehle, die Pipes oder Umleitungen enthalten:
1 2 3 4 5 6 7 8 9 10 | #!/bin/bash cmd() { du "$1" | sort -n > /tmp/verzeichnisgroessen } # ... cmd foo |
Lesenswertes dazu:
Das Ausführen derselben Operation auf verschiedene Dateien ist generell nützlich, wenn das jeweilige Programm dies von Haus aus nicht beherrscht. Allgemeines Schema:
for i in *.ALT; do PROGRAMM "$i" -o "`basename "$i" .ALT` .NEU"; done
Hier findet vor dem ersten Semikolon die Auswahl der Dateien (*.ALT
) im Arbeitsverzeichnis der Shell statt. Alles nach dem "do
" und bis zum zweiten Semikolon ist die spezifische Anwendung eines Programms (PROGRAMM
) mit dessen jeweiliger Befehlssyntax. "$i
" (oder "$f
", siehe unten) stellen die zu verwendeten Dateien dar. Beispiele:
Entpacken aller .zip-Dateien im aktuellen Verzeichnis:
for f in *.ZIP; do unzip “$f”; done
Konvertieren aller .jpg-Bilder im aktuellen Verzeichnis:
for f in *.jpg; do convert "$f" -background white -chop 100x0+1320+0 -splice 100x0+1320+0 "${f%}_sauber.jpg"; done
Für eine Lösung in Kombination mit find siehe Audiodateien umwandeln.
Nicht selten kommt es vor, dass Tabellen zur bündigen Darstellung keine Tabulatorzeichen verwenden, sondern der Zwischenraum mit unterschiedlich vielen Leerzeichen aufgefüllt wird (Beispiel: die Ausgabe von ls -l
). Um diese beispielsweise mit cut
weiterverarbeiten zu können, benötigt man ein Format, das zwischen den Feldern je genau einen Feldtrenner enthält. Abhilfe schafft hier der folgende Befehl:
tr -s "[:space:]"
Beispiel, mit dem man auswerten kann, an welchen Tagen in einem Verzeichnis Änderungen vorgenommen wurden:
ls -l --time-style=long-iso | tr -s "[:space:]" | cut -f6 -d" " | sort -u
ShellCheck - Befehlszeilenwerkzeug zur Analyse von Shell- und Bash-Skripten
Pitfalls - Fallstricke auf Greg's Wiki
Useless Use of Cat Award (Original existiert nicht mehr, deshalb hier die letzte Version von 2014 bei archive.org)
Diese Revision wurde am 19. September 2016 11:17 von BillMaier erstellt.