Aller au contenu

Administration Unix - CM séance 10

9. Approfondissements du langage bash (suite)

9.6. La grammaire du shell

La ligne de commande peut être un assemblage de différents éléments : (légende : [] signifie optionnel, ... signifie 1 ou plusieurs)

  • Commande : par priorité décroissante

    • une fonction bash
    • une commande interne de bash
    • un fichier exécutable (binaire ou script)
  • Commande simple :

    [variable=valeur]... commande [argument]... [redirection]...
    
  • Pipeline de commandes :

    [!] commande_simple [ | commande_simple]...
    
  • Liste de commandes :

    pipeline [SEP pipeline]... [; ou &]        # séparateur SEP parmi ; & && ||
    
  • Commande composée : (list_co désigne une liste de commande)

    (list_co)                 # liste_co exécutée dans un sous-shell
    { list_co ;}              # liste_co exécutée dans le même shell
    ((expression))
    [[ expression ]]
    if list_co ; then list_co ; [elif list_co ; then list_co ;]... [else list_co ;] fi
    case nom in [motif) list_co ;;]... esac
    select nom [in mots] ; do list_co ; done
    for nom [in mots] ; do list_co ; done
    for ((expr1;expr2;expr3)) ; do list_co ; done
    while list_co ; do list_co ; done
    until list_co ; do list_co ; done
    

Le code de terminaison $? d'une commande composée est celui de la dernière commande simple exécutée.

Une commande composée peut être redirigée ; la rediction s'applique alors à tous les éléments qui la composent.

Exemples :

{ echo "Répertoire courant :" ; pwd ; echo "date :" ; date ;} >| trace.txt
while read ligne ; do echo "J'ai lu la ligne : $ligne" ; done < trace.txt

À noter : la définition d'une fonction étant

nom_fonction() commande_composée [redirection]...

elle autorise les déclarations suivantes :

toto() { echo "Bonjour" ;} >> trace.txt         # avec redirection
titi() (cd .. ; pwd)                            # sans {}

9.7. Lecture et découpage avec read

Revenons sur la commande interne read : avec un seul argument,

read var1

lit une ligne sur l'entrée standard, puis la mémorise dans var1.

Exemple :

$ read nom
Maître Yoda             # entré par l'utilisateur
$ echo "$nom"
Maître Yoda

Remarque : on peut aussi fournir l'entrée standard via un here-string avec <<< :

$ read nom <<< "Maître Yoda"

Avec plusieurs arguments,

read var1 var2 ... varn

lit une ligne sur l'entrée standard, puis découpe la ligne en mots, selon IFS et le nombre d'arguments :

  • mot1var1
  • mot2var2
  • ...
  • reste de la ligne ⟶ varn

Exemple :

$ read a b c <<< "À vos intuitions, vous fier, il faut."
$ echo "$a | $b | $c"
À | vos | intuitions, vous fier, il faut.

On peut préciser le caractère de découpe avec la variable d'environnement IFS :

$ IFS="," read a b c <<< "À vos intuitions, vous fier, il faut."
$ echo "$a | $b | $c"
À vos intuitions |  vous fier |  il faut.

On peut également découper tous les mots et stocker dans un tableau :

$ read -a notes <<< "do ré mi fa sol"
$ declare -p notes
declare -a notes='([0]="do" [1]="ré" [2]="mi" [3]="fa" [4]="sol")'

$ IFS=":" read -a pif <<< "$PATH"           # découpe le PATH
$ declare -p pif
declare -a pif='([0]="/home/thiel/bin" [1]="/bin" [2]="/usr/bin" ...)'

Il a d'autres options intéressantes (help read) :

read -p message        # affiche le message sans retour chariot avant de lire
read -t timeout        # s'arrête si rien n'est rentré au bout du timeout
read -r                # protège les \ lus en les doublant
...

On emploie souvent read pour lire un texte ligne à ligne :

while read ligne ; do
    echo "traitement de la ligne : $ligne"
done < texte_en_entree.txt

Autre exemple, dans lequel on lit ligne à ligne le fichier /etc/passwd en les découpant en mots sur ":"

while IFS=: read login mdp uid reste ; do
    echo "Le user $login a le numéro $uid"
done < /etc/passwd

Il y a un piège fréquent lorsqu'on utilise des tubes et un read :

commande1 | commande2 | ... | while read ligne ; do ... ; done
commande1 | commande2 | ... | read resultat

Piège

Dans un pipeline, chaque commande est exécutée dans un processus fils ⟶ le read aussi, donc les variables affectées par read ne seront pas connues du père.

Exemples :

$ type bash | cut -d' ' -f 3 | read chemin                  # Incorrect
echo "chemin : $chemin"
chemin :
$ chemin=$(type bash | cut -d' ' -f 3)                      # Solution
$ echo "chemin : $chemin"
chemin : /bin/bash

$ echo 1 2 3 | read p q r                                   # Incorrect
$ echo "$p ; $q ; $r" 
 ;  ;
$ read p q r <<< "$(echo 1 2 3)"                            # Solution
$ echo "$p ; $q ; $r"
1 ; 2 ; 3

$ i=0
$ ls | while read fichier ; do ((i++)) ; done               # Incorrect
$ echo "$i" 
0
$ while read fichier ; do ((i++)) ; done <<< "$(ls)"        # Solution
$ echo "$i" 
42

La solution générale s'appelle le retournement de tube : au lieu d'écrire

commande1 | commande2

où les 2 commandes sont exécutées dans des fils, on écrit

commande2 <<< "$(commande1)"        # les "" sont importantes

commande2 est exécutée dans le shell principal.

9.8. Redirections et descripteurs

On a vu la notion de descripteur de fichier (fd) :

  • 0 : entrée standard
  • 1 : sortie standard
  • 2 : sortie d'erreurs

On peut utiliser d'autres fd (<= 9, voir plus loin).

$ { echo "foo" >&3 ; echo "bar" ;} 3>| trace.txt | tr a-z A-Z
BAR
$ cat trace.txt
foo
Explications :
3>| trace.txt   écrase le fichier trace.txt et l'associe au fd 3
>&3 ou 1>&3   redirige la sortie standard dans le fd 3

ceci permet au premier echo de "contourner" le tube.

Application : lecture en parallèle de fichiers

$ while read ligne1 <&3 && read ligne2 <&4
  do
      echo "Lu '$ligne1' et '$ligne2'"
  done 3< fichier1 4< fichier2
Explications :
3< fichier1   ouvre le fichier1 en lecture et l'associe au fd 3
read ligne1 <&3   ou encore 0<&3 : redirige l'entrée standard sur le fd 3 ; donc read va lire une ligne de fichier1.
Le while read && read s'arrête dès que la fin d'un des deux fichiers est atteinte.

On peut rediriger le shell de façon permanente avec la commande exec :

$ exec 4>| spam.txt
$ echo "eggs" >&4
$ cat spam.txt
eggs
$ exec 4>&-         # ferme le fichier, supprime le fd
$ echo "bacon" >&4
bash: 4: Mauvais descripteur de fichier

On peut demander à bash d'attribuer automatiquement un fd (qui sera alors >= 10) ; notez la syntaxe {nom} à gauche et $nom à droite :

$ exec {myfd}>| gloubi.txt      # crée le fd myfd et lui associe le fichier
$ echo $myfd
10
$ ls -l /proc/$$/fd             # Affiche la liste des fd du shell
total 0
lrwx------ 1 ...   0 -> /dev/pts/37
lrwx------ 1 ...   1 -> /dev/pts/37
lrwx------ 1 ...   2 -> /dev/pts/37
l-wx------ 1 ...  10 -> /tmp/gloubi.txt     # ouvert en écriture
lrwx------ 1 ... 255 -> /dev/pts/37         # contrôle du tty par bash
$ echo "boulga" >&$myfd         # écrit dans le fd myfd
$ cat gloubi.txt 
boulga
$ exec {myfd}>&-                # ferme le fichier, supprime le fd
$ echo "Casimir" >&$myfd
bash: $myfd: Mauvais descripteur de fichier

9.9. Diverses choses utiles

9.9.1. Le PID $$

Le PID du shell principal est obtenu avec $$ (alors que $BASHPID est le PID du shell courant, qui peut être un fils, et changer à chaque appel).

Usage fréquent : pour créer des fichiers temporaires dans un script :

tmp1="temp-$$.txt"
commande >| "$tmp1"
rm -f "$tmp1"

Chaque fois qu'on lance le script, il a son propre $$.

9.9.2. Substitution $'\sequence'

Est substitué par le caractère décrit par \sequence :

  • $'\n'   retour chariot
  • $'\t'   tabulation
  • $'\xyz'   caractère dont le code octal est xyz
    par exemple $'\41'!, $'\61'1, $'\101'A

⚠ $'...' doit être situé en dehors des "" pour expanser.

9.9.3. Hasard avec $RANDOM

À chaque substitution, produit un nombre au hasard entre 0 et 32767 (c'est un peu short !).

Utile par exemple pour générer des chaînes aléatoires :

generer_chaine() # tableau_caractères longueur
{
    local -n tabcar=$1                      # par référence
    local longueur=$2 
    local tablen=${#tabcar[*]} i res=

    for ((i=0; i<longueur; i++)); do
        res+=${tabcar[RANDOM % tablen]}     # mode arithmétique dans les []
    done
    echo "$res"
}
$ char64=({a..z} {A..Z} {0..9} - +)
$ generer_chaine char64 40
PWJUMUrnAS-KoE9WciP9HHg+A9daAx1+aScOMMOR
$ generer_chaine char64 60
npjA+Gt+BF7aNKcZVVswRggKKEmoIV7RUyjuLF5Skwejure5obwS5IHH2JPl

9.9.4. Commande printf

Permet d'afficher avec les mêmes arguments que printf en C :

$ printf "%05d\n" 12            # affichage décimal précédé de zéros
00012
$ printf "%x\n" 63              # en base 16
3f
$ printf "%o\n" 63              # en octal
77

Attention à la locale pour les réels :

$ printf "%.2f\n" "3.14159"
bash: printf: 3.14159: nombre non valable
0,00
$ printf "%.2f\n" "3,14159"                 # en français il faut une ","
3,14
$ LANG=C printf "%.2f\n" "3.14159"          # locale "C" = en anglais : "."
3.14

9.9.5. Flags true et false

Comme true et false sont des commandes, on peut écrire :

flag1=true
flag2=false

if $flag1 ; then ... ; fi               # ça fera : if true ; then ..
while ! $flag2 ; do ... ; done

C'est plus court que d'écrire

if test "$flag1" = true ; then ... ; fi
while ! test "$flag2" = "true" ; do ... ; done

9.9.6. Commande trap

La commande trap permet de capter un signal Unix :

trap CMD liste_signaux

Chaque fois qu'un signal de la liste est reçu, la commande CMD est exécutée.

Exemple :

$ trap 'echo "signal reçu"' TERM QUIT
$ kill -QUIT $$
signal reçu

Ceci permet par exemple de rétablir un fichier modifié par un script s'il est interrompu.

Cas particuliers :

trap CMD 0              # la commande CMD est exécutée à la sortie du shell ;
                        # très utile pour supprimer les fichiers temporaires

trap CMD DEBUG          # commande CMD executée après chaque commande simple

Exemple :

$ trap 'echo "[$BASHPID] $BASH_COMMAND"' DEBUG
$ echo salut
[15666] echo salut
salut

Valeurs spéciales pour CMD :

  • ""   supprime le trap
  • -   rétablit le comportement par défaut

9.10. Quelques pièges à éviter

a) Le $ en trop :

$var1=truc

Cela affecte la variable dont le nom est dans var1, mais pas var1 :

$ var1=toto
$ $var1=truc
$ echo "$var1"
toto
$ echo "$toto"
truc

De plus, si var1 n'existe pas, cela produit une erreur :

$ unset var1
$ $var1=truc
=truc : commande introuvable

b) Le = non accolé :

var1 = truc

Pour bash ce n'est pas une affectation, mais une commande :

$ var1 = truc
var1 : commande introuvable

c) Oubli de la substitution de commandes $() :

var1=fonction $*

Ceci affecte var1 comme variable d'environnement puis exécute $1 comme une commande. Très dangereux !

$ set echo je t\'ai eu
$ hello(){ echo "bonjour" ;}
$ foo=hello $*
je t'ai eu

d) echo avec des !!

bash réalise l'expansion des ! lorsqu'il est suivi d'un symbole, avec l'historique des commandes (!! ⟶ dernière commande, etc).

$ echo "coucou !"
coucou !
$ echo "bye bye !!"
echo "bye bye echo "coucou !""
bye bye echo coucou !

Pour afficher !! il faut l'isoler avec des '' :

$ echo "bye bye "'!!'
bye bye !!

e) Indirections multiples en mode arithmétique

$ a=b ; b=c ; c=d ; d=42
$ echo $((a))
42

En mode arithmétique, lorsqu'une variable contient une chaîne, bash fait une indirection, et continue jusqu'à ce qu'il trouve une valeur entière.

Dans le cas où la variable n'est pas définie, bash l'expanse en 0 :

$ unset bidon
$ echo $((bidon))
0

f) Oubli de protection contre la découpe avec des ""

Les arguments et variables contiennent souvent des chemins, dans lesquels il peut y avoir des blancs. Il est donc crucial de les protéger systématiquement contre la découpe avec des "".

g) Boucler sur ls

Lorsqu'on veut parcourir les fichiers d'un répertoire dir, on est parfois tenté de boucler sur ls :

for f in $(ls "$dir") ; do      # MAUVAIS
    echo "$f"
done

C'est une mauvaise pratique car ls ne préserve pas les noms qui contiennent des blancs.

La bonne approche est de boucler sur un motif, en plaçant les wildcards en dehors des "" pour qu'ils soient expansés :

for f in "$dir"/* ; do
    echo "$f"
done