<- PW: Gnome - Archivio Generale - Copertina - PW: Gosh ->

PlutoWare


Bash

di Umberto Salsi


L'articolo

Una introduzione al programma di shell e linguaggio di programmazione Bash, versione 2. Ci concentreremo sugli aspetti che fanno di Bash non solo uno shell di sistema, ma anche un potente linguaggio di programmazione di uso generale. A completamento della esposizione, vedremo due utili programmi per la gestione dei pacchetti RPM e per il download della posta elettronica via protocollo POP-3, naturalmente realizzati completamente in Bash.



Indice

Introduzione
Le interfacce di programmazione nell'ambiente Unix
Le origini prima di tutto
Dai terminali seriali alle workstation grafiche
Meccanismo per l'invocazione del programma
Variabili
Comandi, pipeline e liste
Istruzioni per il controllo del flusso
Stringhe
Processi: creazione e gestione
Redirezione
Pipe
0=vero, altrimenti falso
Gestione dei file
Funzioni
Esempio 1 - Gestore di pacchetti RPM
Esempio 2 - Client di posta POP-3
Conclusioni
Bibliografia
L'autore


Introduzione

Bash è il programma shell più comunemente utilizzato nel sistema operativo GNU/Linux. Esso discende dal classico sh, ma ne integra le funzionalità e lo estende nella direzione di un vero e proprio linguaggio di programmazione. Con la versione 2, Bash mette a disposizione un set completo di istruzioni per il controllo del flusso di esecuzione, la ricorsione, gli array, le variabili di riferimento, il supporto alla manipolazione di stringhe, alla gestione dei segnali, il controllo dei processi e dei jobs, le tecniche di comunicazione tra processi, i socket Internet. Nato come linguaggio di scripting, cioè come una sorta di collante rispetto a programmi esterni scritti tipicamente nel più efficiente linguaggio C, Bash oggi mette a disposizione uno spettro di funzionalità sufficientemente ampio da poter realizzare anche programmi di una certa complessità.

Questo articolo è rivolto a coloro che hanno già dimestichezza con i concetti della programmazione e che desiderano conoscere più da vicino questo potente shell di sistema. Alcune funzionalità presentate sono disponibili solo nella versione 2. Ad esempio, la distribuzione Red Hat 7 fornisce questa shell per default, mentre nella Red Hat 6.2 e precedenti fornivano sia il vecchio bash (default) che bash2.

Le interfacce di programmazione nell'ambiente Unix

Il sistema operativo Unix, e tutti gli altri sistemi operativi che ad esso si ispirano, mettono a disposizione del programmatore una serie di strumenti per interfacciare i suoi programmi al resto del sistema: parametri, variabili d'ambiente, file, segnali, processi. Qualsiasi linguaggio di programmazione o sistema di sviluppo che debba vivere dentro a questo sistema deve in qualche modo implementare questi strumenti. Uno dei fili conduttori della mia esposizione sarà proprio quello di passare in rassegna le soluzioni adottate da Bash per integrarsi nell'ambiente Unix.

Le origini prima di tutto

Tra i linguaggi di programmazione, Bash e gli shell in generale occupano un posto un po' particolare: diversamente da altri linguaggi di programmazione nati magari a tavolino, per il Bash sembra non esistere una definizione precisa e formale della sintassi. Ci sono due motivi per questo: il primo è che il Bash, come molti altri linguaggi di scripting, fa largo uso della sostituzione di stringhe durante il processo di interpretazione, per cui la sintassi del linguaggio assume una certa dipendenza temporale difficile da riprodurre con grafici sintattici o metalinguaggi. Il secondo motivo è di tipo storico: determinate scelte implementative più o meno felici sono legate alla natura del Bash come shell di sistema, e non come linguaggio di programmazione. La motivazione storica sarà il filo conduttore principale nella prima parte dell'articolo, dove vedremo i principi fondamentali di Bash come shell di sistema. Successivamente affronteremo gli aspetti più prettamente legati alla programmazione, e allora proverò ad introdurre un abbozzo di sintassi formale.

Dai terminali seriali alle workstation grafiche

Ai tempi dei sistemi ad elaborazione batch, l'utente si presentava nella sala terminale con in mano un pacco di qualche centinaio di schede perforate: una "mazzetta" iniziale conteneva il programma e le istruzioni di esecuzione, e il resto del malloppo conteneva i dati da elaborare. L'utente consegnava il tutto ad un tecnico in camice bianco, il quale inseriva il pacco nel lettore di schede. Non appena la CPU del calcolatore era disponibile, partiva la lettura delle schede, cui seguiva l'elaborazione e la stampa dei risultati su carta in modulo continuo. L'utente poteva passare alcune ore dopo a ritirare l'elaborato.

L'avvento dei terminali seriali e dei sistemi multiutente come Unix, ha permesso di ridisegnare questa procedura rigida introducendo il paradigma della linea di comando. In effetti l'interfaccia terminale a linea di comando riproduce il concetto di scheda perforata: la telescrivente o il videoterminale del computer sono organizzati in righe (corrispondenti alle schede) costituite tipicamente da 80 colonne (esattamente come le schede). L'operatore compone la riga di comando come se si trovasse davanti ad un perforatore di schede, ma con la differenza che, una volta premuto un apposito bottone, la riga di comando va immediatamente al calcolatore, e sullo schermo del videoterminale compare subito l'output risultante.

Pur nella sua semplicità, l'interfaccia a riga di comando richiede comunque una qualche formalizzazione, e richiede anche un programma che la implementi: questo programma viene chiamato shell (guscio). Nel caso del sistema operativo Unix è risultato naturale strutturare la riga di comando indicando prima il nome di un programma e poi, a seguire, gli eventuali suoi parametri. Il nome del programma e i parametri, per essere distinguibili, vanno separati l'uno dall'altro con almeno un carattere di spazio o di tabulazione (HT):

    programma  parametro1  parametro2  parametro3

Questa semplice sintassi è facile da imparare, è veloce da battere sulla tastiera, ed in generale si è dimostrata molto pratica. Sorgono tuttavia spontanee alcune domande: come è possibile includere caratteri di spazio o tabulazione all'interno dei parametri? I programmi di shell, incluso Bash, risolvono il problema introducendo il carattere di doppio apice " per delimitare i parametri dove si vogliono inserire degli spazi.
Sfortunatamente, con questa scelta il carattere di doppio apice diventa riservato: com'è possibile inserirlo dentro a un parametro? Per risolvere anche questo problema, i programmi di shell adottano la classica soluzione della sequenza di escape: un carattere di doppio apice che sia preceduto dal carattere di backslash \ conserva il suo significato letterale, e non viene interpretato come delimitatore.
Sfortunatamente, il carattere di backslash diventa a sua volta un carattere riservato: come fare ad introdurre un backslash dentro a un parametro? La risposta è che bisogna far precedere il backslash da un secondo backslash. A questo punto il problema si direbbe definitivamente risolto.

Meccanismo per l'invocazione del programma

Una volta che l'operatore ha composto correttamente il nome del programma e i suoi eventuali parametri, dovrà premere l'apposito tasto "Invio" (o "Return", o "Enter") per inviare la riga allo shell e dare così inizio alla sua esecuzione.

Lo shell separa tra loro il nome del programma e i parametri, individua il programma in questione nel file system, e lo avvia passandogli i parametri inseriti. Individuare il programma in questione non è un problema del tutto banale. Innanzitutto esistono almeno due modi per scrivere il nome di un programma: si può indicare direttamente il pathfile assoluto o relativo, oppure si può indicare solo il suo nome.
Nel caso di un pathfile assoluto o relativo, lo shell può individuare immediatamente il programma richiesto.
Quando invece il comando dato per lanciare il programma non contiene alcun pathfile (cioè non contiene almeno uno slash /) allora lo shell ricerca il file del programma esaminando in sequenza tutte le directory elencate nella variabile d'ambiente PATH. Dentro a PATH ci dovrebbe essere infatti una lista di directory separate da un : (due punti). Per ovvi motivi di efficienza, una volta che Bash ha individuato la dir. dove risiede un certo comando, la ricorda in una sua cache interna, in modo da velocizzare successive ricerche.

Ogni bravo utente di sistema Unix costruisce nel tempo un certo numero di propri comandi di utilità, magari sviluppati proprio con Bash, oppure in C, o altro ancora. Dove mettere questi comandi in modo che siano direttamente raggiungibili? La soluzione migliore è creare una directory bin nella propria home directory, e quindi inserire questa linea nel file .bash_profile:

export PATH="$HOME/bin:$PATH"

In questo modo Bash cercherà i comandi anche nella directory degli eseguibili personali.

Esiste in realtà un'altra categoria di programmi, che sono i comandi interni dello shell: comandi come echo e kill non sono realmente dei programmi esterni, ma vengono eseguiti internamente da Bash per motivi di efficienza (il primo) e di sicurezza (il secondo). Come è facile immaginare, è sempre preferibile usare i comandi interni allo shell che avviare programmi esterni, qualora Bash offra una conveniente alternativa.

Variabili

Bash manipola essenzialmente stringhe, e dunque le variabili conterranno stringhe (c'è solo una eccezione per i numeri interi). I nomi di variabile possono essere costituiti da lettere, cifre e underscore, ma il primo carattere deve essere una lettera o underscore. Bash è sensibile alla differenza tra lettere maiuscole e lettere minuscole. Esempi di assegnamenti:

HOST_NAME="localhost.localdomain"
PORT=110
messaggio="Ciao, il mio nome e' Giovanni, ma tutti mi chiamano \"Vanni\"."

Notare che a sinistra e a destra del carattere di = non ci sono spazi, altrimenti lo shell tenterebbe di interpretare il nome della variabile come programma e il carattere di = come primo parametro... in effetti, i nomi dei programmi non possono contenere il carattere di =.

Siccome i nomi di variabile si confondono come normali stringhe, ecco che sorge la necessità di designare in qualche modo che una certa stringa è il nome di una variabile: a questo scopo serve il carattere $, per cui ogni volta che vorremo il valore delle variabili definite prima dovremo anteporre questo carattere:

echo $HOST_NAME $PORT $messaggio

Ne segue che anche $ diventa un carattere riservato, e quando vorremo inserire questo carattere col suo significato letterale dovremo farlo precedere dal solito backslash.

L'accesso alle variabili d'ambiente in Bash è estremamente semplice: il loro valore si ottiene esattamente come se fossero variabili normali. Invece, per impostare una variabile d'ambiente in modo che venga ereditata dai sottoprocessi, è necessario esportarla:

export DATI=/tmp/dati.txt

Il comando env fornisce l'elenco delle variabili di ambiente correntemente definite.

Comandi, pipeline e liste

La sintassi del Bash può essere a volte piuttosto sibillina per il neofita. Nel tentativo di chiarire la differenza tra comandi, pipeline e liste di comandi, tenterò una descrizione formale di questi oggetti, nella speranza che possa contribuire a chiarire le cose. E' una operazione mai tentata prima, forse è impossibile, ma io ci provo lo stesso!

Un comando si compone di un nome di comando, seguito dagli eventuali parametri, ed eventualmente terminato da un operatore di controllo:

    comando ::= nomecomando { parametro } [ ctrl ]

Solo il nome del comando è obbligatorio, mentre gli altri elementi sono opzionali. I nomi di comando possono essere comandi interni al Bash (come echo), funzioni, o programmi esterni (come tar).

Una pipeline è una sequenza di comandi separati dalla barra verticale. Ogni comando viene eseguito in un nuovo processo, e l'output di ogni comando diventa l'input del successivo. L'output finale va su stdout, a meno che non si usino opportuni operatori di controllo:

    pipeline ::= [!] comando { | comando }

Il codice di stato risultante è quello ritornato dall'ultimo comando. Il punto esclamativo iniziale permette di negare il codice di stato, invertendo la condizione di successo / fallimento.

Una lista di comandi è una successione di pipeline unita da dall'operatore ;, dall'operatore & (esecuzione in background) oppure dagli operatori logici && (AND) e || (OR):

    operatore ::= { ";" | "&" | "&&" | "||" }
    lista ::= pipeline { operatore pipeline } [ ";" | "&" ]

Un gruppo è una sequenza di comandi, anche su più linee, racchiusi tra parentesi graffe. Tutti i comandi inclusi condividono l'eventuale redirezione dell'I/O:

    gruppo ::= "{" lista ";" "}" [ctrl]

Una sottoshell viene generata inserendo una lista di comandi racchiusi tra parentesi tonde, anche disposti su più righe:

    sottoshell ::= "(" lista ")" [ctrl]

Le istruzioni semplici comprendono le istruzioni per il controllo di flusso e le chiamate di funzione, che vedremo nei prossimi paragrafi.

In definitiva, una istruzione Bash può assumere una delle seguenti forme:

    istruzione ::= lista | gruppo | sottoshell

Terminata questa faticaccia, il prossimo paragrafo sulle istruzioni di controllo dovrebbe essere finalmente più chiaro.

Istruzioni per il controllo del flusso

Bash dispone di un set completo di istruzioni per il controllo del flusso, ormai tradizionali in tutti i linguaggi di programmazione. Quella più articolata è l'istruzione condizionale:

if lista
then
    istruzioni
elif lista
then
    istruzioni
else
    istruzioni
fi

Naturalmente, non è necessario specificare tutti i rami alternativi, ma qui ho voluto illustrare la struttura generale dell'istruzione. Notare che ad ogni comando if deve corrispondere il comando di chiusura fi. Io trovo molto pratico sfruttare il punto-e-virgola per rendere più compatto il sorgente, e porto il comando then nella riga di sopra:

if lista; then
    istruzioni
elif lista; then
    istruzioni
else
    istruzioni
fi

Le espressioni logiche si basano sul codice di stato ritornato dalle liste di comandi: se il comando ha successo (cioè ritorna il codice di stato zero) la condizione è vera, altrimenti è falsa. Ricordiamo che la lista può contenere l'operatore di negazione. Ad esempio, un modo corretto per cancellare un file potrebbe essere questo:

if ! rm $FILE_NAME; then
    echo "Cancellazione del file $FILE_NAME fallita."
    exit 1
fi

Il tradizionale comando test (cfr. man test) può essere sostituito con il corrispondente comando interno di Bash "[" (parentesi quadra aperta), con notevole miglioramento della efficienza. Col comando di test sono possibili una serie di espressioni logiche per il confronto di stringhe e la verifica della accessibilità dei files. Un bel help test | less fornisce il quadro completo delle possibilità offerte da questo comando. Trattandosi di un comando, sebbene dal nome un po' particolare, lo si può usare sia all'interno della istruzioni if, sia da solo. Per eseguire il remove di un file previa verifica della sua esistenza possiamo scrivere:

if [ -f "$FILE_NAME" ]; then
    rm $FILE_NAME
fi

ma possiamo anche scrivere in forma compatta:

[ -f "$FILE_NAME" ] && rm $FILE_NAME

Diversamente da molti linguaggi di programmazione, gli operatori logici && e || hanno uguale precedenza. Tuttavia, Bash esegue il calcolo breve delle espressioni logiche, cioè non esegue le parti di istruzione logica che non servono ai fini della determinazione del valore di verità, similmente a quanto fanno altri linguaggi come il C. Ecco perché nell'istruzione dell'esempio precedente il remove viene eseguito solo se il test di esistenza del file ha avuto esito positivo.

Per completare il quadro, vediamo come eseguire il remove di un file solo se esiste, e gestire correttamente l'eventuale condizione di errore. La prima versione:

if [ -f "$FILE_NAME" ]; then
    if ! rm $FILE_NAME; then
    echo "Cancellazione del file $FILE_NAME fallita."
    exit 1
    fi
fi

Oppure, in forma compatta:

if [ -f "$FILE_NAME" ] && ! rm $FILE_NAME; then
    echo "Cancellazione del file $FILE_NAME fallita."
fi

Fatte queste premesse, passiamo in rassegna le altre istruzioni di controllo per i cicli. A questo scopo mi servirò di un semplice esempio: voglio stampare a schermo i numeri da 1 a 10. Ecco le diverse versioni:

# Istruzione "while" con test "[":
n=1
while [ $n -le 10 ]; do
    echo $n
    n=$(( n + 1 ))
done


# Istruzione "while" con test "((" (solo Bash 2):
n=1
while (( n <= 10 )); do
    echo $n
    (( n++ ))
done


# Istruzione "until" con test "[":
n=1
until [ $n -gt 10 ]; do
    echo $n
    n=$(( n + 1 ))
done


# Istruzione "for":
for n in 1 2 3 4 5 6 7 8 9 10; do
    echo $n
done


# Istruzione "for" resa sintetica col comando "seq":
for n in $( seq 1 10 ); do
    echo $n
done


# Istruzione "for" in stile linguaggio C (solo Bash 2):
for (( n=1; n<=10; n++ )); do
    echo $n
done

Il controllo dei cicli si completa anche dei classici comandi continue e break per saltare alla prossima iterazione e per uscire dal ciclo.

Le istruzioni select e case sono di uso meno frequente: ne vedremo degli esempi nel seguito.

Stringhe

Una stringa letterale deve essere racchiusa tra doppi apici e può essere arbitrariamente lunga. All'interno delle stringhe il carattere $ permette di includere anche delle variabili:

    FILE_NAME="/tmp/dati.txt"
    N=$( wc $FILE_NAME | (read n x; echo $n ) )
    echo "Il file $FILE_NAME contiene $N righe."

In queste poche righe sono concentrate una serie di caratteristiche molto interessanti che ora esaminiamo in dettaglio:

Il costrutto $( lista ) esegue la lista di istruzioni indicata in un sottoprocesso e ne ritorna l'output. Ma attenzione: tutto l'output viene riportato su di una sola riga! Se si desidera una riga specifica bisogna lavorare con i vari head, tail, grep, sed e compagnia. Per determinare il numero di righe nel file $FILE_NAME ho usato il classico comando wc, che ritorna nell'ordine il numero di righe, il numero di parole, il numero di byte e il nome del file. Per isolare solo il primo dato, cioè il numero di righe, sono ricorso ad un trucco che sfrutta le proprietà del comando interno read: il primo dato viene assegnato alla variabile n, mentre il resto viene assegnato alla variabile x. Osserviamo che, siccome la read viene eseguita in un sottoprocesso dello shell, le variabili così assegnate non sono visibili all'esterno di questo sottoprocesso. L'unico modo per recuperare il valore di $n è di riportarlo in stdout con un echo. Dal punto di vista della efficienza, l'intera istruzione genera tre processi: il wc, la sotto-shell dove esegue read ed echo, e la sotto-shell per il costrutto $( lista ). Non è certo una soluzione adatta per le alte prestazioni e, quando serve, bisogna mettere in atto qualche trucco più sofisticato.

Bash prevede una serie di funzionalità per la manipolazione delle stringhe che sono particolarmente utili nelle applicazioni tipiche nelle quali questo linguaggio viene utilizzato. Ad esempio, per estrarre l'estensione di un file:

    echo -n "Il file $FILE_NAME e' di tipo "
    estensione=${FILE_NAME##*.}
    case $estensione in
    txt,text) echo "testo" ;;
    html)     echo "HTML" ;;
    gif,jpg,jpeg)  echo "immagine" ;;
    *) echo "non previsto!"; exit 1 ;;
    esac

Qui ho usato l'operatore di stringa ${nomevar##pattern} applicato alla stringa $FILE_NAME per ricavare l'estensione del file. La documentazione di Bash riporta anche altri operatori similari. Ad esempio ${FILE_NAME%.*} darebbe invece il nome base del file, privo della estensione. Ovviamente questi operatori non sono limitati a manipolare solo nomi di file, ma si possono sfruttare anche per eseguire il parsing di stringhe generiche.

Purtroppo Bash non fornisce uno strumento generale come gli operatori di stringhe basati sulle espressioni regolari, disponibili ad esempio in PERL. In questi casi il Bash si appoggia ai comandi esterni, come sed.

Con il Bash 2 è stato introdotto un operatore per estrarre sottostringhe, una volta noto l'offset del carattere iniziale e il numero di caratteri da estrarre. Nel prossimo esempio uso questa funzionalità per assicurare che la stringa $msg non superi la lunghezza di 40 caratteri; se è più lunga, viene troncata e il troncamento viene marcato con ellissi:

msg="Questo messaggio e' particolarmente lungo e bisogna accorciarlo a 40."
if [ ${#msg} -gt 40 ]; then
	msg=${msg:0:37}"..."
fi
echo "$msg"

che visualizzerà sullo schermo la stringa $msg accorciata così:

Questo messaggio e' particolarmente l...

Processi: creazione e gestione

Esistono diversi strumenti per la gestione dei processi in Bash: qui ne vedremo solo alcuni.

Spesso è comodo avviare dei processi in background e lasciare che svolgano il loro lavoro mentre noi siamo liberi di continuare la nostra sessione di shell. A questo scopo basta aggiungere il carattere di ampersand alla fine della riga di comando:

programma parametro1 parametro2 &

La conseguenza, ancora una volta, è che l'ampersand & diventa un carattere riservato: per inserirlo come letterale di stringa occorre farlo precedere dal backslash, oppure bisogna racchiuderlo tra doppi apici.

Bash esegue il forking, e ritorna nella variabile $! il PID del sottoprocesso avviato. Bash fornisce anche le istruzioni trap per la gestione dei segnali (come SIGCHLD) e wait per controllare la terminazione dei sottoprocessi, oltre naturalmente a kill per inviare segnali a un processo.

Ricordiamo che tutti i sottoprocessi avviati da shell hanno lo shell stesso come processo padre, e da esso ereditano le variabili d'ambiente e tutti i file aperti. Per convenzione, i file numero 0, 1 e 2 vengono interpretati dai processi come standard input, standard output e standard error, e sono associati rispettivamente il primo alla tastiera e gli altri due allo schermo del terminale. Ne segue che se i programmi avviati in background generano un output o richiedono un input, questo potrebbe interferire fastidiosamente con le altre operazioni che stiamo eseguendo in modo interattivo, e quindi di solito i processi lanciati in background vengono avviati previa una adeguata redirezione del loro output.

Redirezione

Il modo più comune per gestire i file nello shell è attraverso gli operatori di redirezione. Mentre i comuni linguaggi di programmazione richiedono esplicite chiamate per l'apertura, la scrittura/lettura e la chiusura dei file, in Bash si può avviare un comando indicando semplicemente i file di interesse:

programma parametri  <fileinput  >fileoutput  &2>fileerror

Gli operatori <file, >file 2>file istruiscono lo shell ad eseguire il programma indicato con impostati questi file come stdin, stdout e stderr. Naturalmente, non è necessario indicare la redirezione su tutti i tre file. E' possibile combinare la redirezione con l'esecuzione in background, così che in pratica possiamo indicare al programma dove si trovano i dati da elaborare, dove riversare i risultati e dove inviare gli eventuali errori. Una volta terminato il programma, potremo andare ad esaminare con comodo questi file.

Sfortunatamente, ancora una volta abbiamo attribuito un significato speciale ai caratteri > e <, per cui torna utile il backslash quando necessario.

Spesso è comodo riversare stdout e stderr in un unico file: in questo caso l'operatore di redirezione da usare è &>.

Pipe

Le pipe sono uno degli strumenti più potenti che possono essere usati nello shell, e permettono di avviare diversi programmi in modo che l'output del precedente si innesti come input del successivo. Una delle applicazioni più frequenti è con il grep, ad esempio per trovare tutti i file che sono directory:

ls -l | grep ^d

Il carattere di barra verticale | comanda allo shell di creare una pipe senza nome nella quale fluiscono i dati che vanno dal processo ls verso il processo grep; una volta creata la pipe, lo shell avvia i due processi impostando lo stdout di ls verso l'input della pipe, e lo stdin di grep verso l'output della pipe. L'effetto netto è che l'output del processo ls viene filtrato dal processo grep prima di essere riversato sullo schermo. In questo caso l'espressione regolare del grep seleziona le righe che iniziano con la lettera d, che sono proprio le directory che volevamo.

Vi sarete accorti che il carattere di barra verticale assume così un significato speciale, e diventa un carattere riservato: per indicare il carattere letterale dovremo farlo precedere dal solito backslash. Come esempio, cercare di prevedere il diverso comportamento dei comandi seguenti:

echo ciao  | grep c  > f
echo ciao \| grep c  > f
echo ciao  | grep c \> f
echo ciao \| grep c \> f

0=vero, altrimenti falso

Tutti i processi, una volta terminati, ritornano al padre che li ha avviati lo stato di uscita: si tratta di un numero che codifica la condizione di errore riscontrata. Come scelta naturale, il codice 0 (zero) corrisponde alla assenza di errore e quindi alla terminazione con successo del programma, mentre altri valori indicano che qualcosa è andato storto. Fin qui, tutto normale.

Uno dei ruoli principali di uno shell è quello di collante tra vari programmi. Di conseguenza, lo stato di uscita di un processo rientra in una qualche condizione logica per stabilire come comportarsi poi. In Bash lo stato di uscita dei processi è proprio il valore logico su cui si lavora, per cui 0 codifica il successo, mentre tutti gli altri valori codificano il fallimento del processo. Questa convenzione è opposta a quella adottata in C e nella maggior parte degli altri linguaggi.

Gestione dei file

Per aprire un nuovo file sul descrittore numero 3 rispettivamente in scrittura / lettura / lettura e scrittura si può usare il comando exec corredato dagli opportuni operatori di redirezione:

exec 3> /tmp/dati
exec 3< /tmp/dati
exec 3<> /tmp/dati

A questo punto il descrittore numero 3 si può usare per le operazioni di redirezione successive, ricordando che l'operatore di redirezione > sovrascrive il file, mentre >> accoda al file esistente. Per esempio:

echo "Elaborazione dati" >> &3
read altezza larghezza profondita <&3

Una volta finito, si può chiudere il file:

exec 3<&-

Talvolta si desidera che tutto l'output del programma, incluso gli eventuali errori, venga rediretto su un file di log. Ci sono diversi modi per ottenere questo risultato: specificare gli operatori di redirezione per ogni comando; inserire tutti i comandi in un gruppo di comandi delimitato da parentesi graffe e specificare gli operatori di redirezione una volta per tutte. Una terza possibilità è quella di ridefinire i descrittori di file 1 e 2 in modo definitivo, come nel seguente esempio:

exec  1>>/var/log/miolog  2>&1
echo "Backup del $( date ):"
tar cvf - /home | gzip > /mnt/bkup/last-bk
...

Funzioni

Le funzioni sono un potente strumento per la strutturazione dei programmi. In Bash le funzioni devono essere definite prima di essere invocate, si invocano come un qualsiasi comando, accettano parametri, supportano la ricorsione, ritornano un codice di stato, possono comunicare con l'esterno attraverso i file (stdin, stdout, stderr, ecc.) e attraverso le variabili globali. Vedremo alcuni esempi di applicazione delle funzioni nei prossimi due paragrafi.

Esempio 1 - Gestore di pacchetti RPM

Il primo esempio di programma è un gestore di pacchetti RPM, il diffuso sistema per la distribuzione di software per varie distribuzioni di Linux, a partire dalla Red Hat, alla SuSE, Mandrake, ed altre. Gli strumenti visuali tipicamente disponibili sono piuttosto lenti sulle macchine non recenti, ed inoltre non sono utilizzabili da linea di comando. Questo programma realizza invece una semplice interfaccia a menu verso le funzionalità più comuni di rpm. Per selezionare una opzione del menu basta premere il tasto corrispondente, non è necessario premere anche il tasto Invio (o Return): questo risultato è possibile grazie alla nuova opzione -n del comando read introdotto con Bash 2. Prima, con Bash versione 1, si era costretti a ricorrere a trucchi sporchi per ottenere lo stesso risultato.


#!/bin/sh

while : ; do
    echo -e "\nmyrpm - Interfaccia a menu per rpm"
    echo "   b) mostra info su di un package installato"
    echo "   c) mostra tutti i packages installati"
    echo "   d) mostra il package cui appartiene un certo file"
    echo "   e) installa un package"
    echo "   j) aggiorna un package"
    echo "   f) disinstalla un package"
    echo "   q) quit"
    echo -ne "Quale? [ ]\b\b"
    read -n 1 q
    echo
    case $q in
    b) echo -n "Nome Package: "
        read pk
        {
            echo -e "\nINFO ON $pk:"
            rpm -qi $pk
            echo -e "\nFILES ON $pk:"
            rpm -qls $pk
            echo -e "\nDEPENDENCIES OF $pk:"
            rpm -q --requires $pk
        } | less
        ;;
    c) {
        echo -e "\nTUTTI I PACKAGES INSTALLATI:"
        rpm -qa | sort -df
        } | less
        ;;
    d) echo -n "File: "
        read fn
        echo "Il file $fn appartiene a:"
        rpm -qf $fn | less
        ;;
    e) echo -n "File *.rpm da installare: "
        read pk
        rpm --install $pk 2>&1 | less
        ;;
    f) echo -n "Package da disinstallare: "
        read pk
        rpm --erase $pk 2>&1 | less
        ;;
    j) echo -n "File *.rpm di aggiornamento: "
        read pk
        rpm -U $pk | less 2>&1
        ;;
    q) exit 0 ;;
    *) echo "OPZIONE ERRATA, RIPROVA"
        sleep 4
        ;;
    esac
done

Listato 1. Programma per gestire pacchetti RPM (myrpm).

Anche tu non ricordi mai tutte le opzioni di un certo comando, e ti tocca consultare spesso la man page per trovare quella giusta? Bene, adesso sai come renderti la vita più semplice costruendo il tuo programma wrapper con interfaccia a menu!

Esempio 2 - Client di posta POP-3

Bash 2 introduce i socket Internet, e permette di realizzare semplici programmi client. Qui svilupperemo un piccolo programma capace di scaricare la posta elettronica e di visualizzarla sullo schermo. Il dialogo con il server avviene usando il protocollo POP-3, documentato nell'RFC 1939. L'articolo Protocolli 1 pubblicato sul PLUTO Journal n. 35 del gennaio 2002 (www.pluto.linux.it/journal/pj0201/protocolli1.html) fornisce una introduzione a questo protocollo.

All'inizio del programma ho raccolto le variabili che caratterizzano il collegamento: il nome dell'host remoto, la porta TCP, il nome e la password di login. Ho fatto le prove con il server POP-3 del mio stesso computer, ecco perché il nome dell'host indicato è localhost.

Siccome il dialogo tra il nostro client e il server avviene per linee, ho trovato conveniente approntare tre utili funzioni:

Una volta approntate queste tre funzioncine di base, il resto del programma non deve fare altro che inviare in sequenza i comandi POP-3 necessari, controllare l'esito dell'operazione, e quindi interpretare adeguatamente i risultati ottenuti.


#!/bin/bash -f
#
# Scarica posta del server POP-3 e la mostra su stdout.
# La posta viene lasciata sul server.
# Esce con codice 0 se ha successo, 1 altrimenti.
#
# ATTENZIONE! richiede Bash v. 2

host="localhost"
port=110
user="salsi"
pass="xyzt"


function putline()
{
    echo "C: $@"
    echo -n "$@"$'\r\n' >&3
}


function getline()
{
    IFS_BAK="$IFS"
    IFS=""
    read -r l <&3
    result=$?
    IFS="$IFS_BAK"
    [ $result == 0 ] || return 1
    cr=$'\r'
    l=${l%$cr}
    echo "S: $l"
}


function check_ok()
{
    [ ${l:0:3} == "+OK" ]
}


# Apre socket TCP sul descrittore 3:
3<>/dev/tcp/$host/$port || exit 1

# Scarica il msg di benvenuto:
getline && check_ok || exit 1

# Esegui il login:
putline "USER $user"
getline && check_ok || exit 1
putline "PASS $pass"
getline && check_ok || exit 1

# Determina il numero $n di email giacenti:
putline "STAT"
getline && check_ok || exit 1
n=$( echo "$l" | (read x n x; echo $n) )


# Scarica tutte le $n email:
for (( i=1; i<=n; i++ )); do
    putline "RETR $i"
    getline && check_ok || exit 1
    while getline ; do
        [ "$l" == "." ] && break
    done
done

# Chiudi il collegamento:
putline "QUIT"
getline && check_ok || exit 1

exit

Listato 2. Programma client per protocollo POP-3 (mypop).

Alcune note su questo programmino:

Grazie alle recenti estensioni di Bash 2, l'intero programma è realizzato sfruttando esclusivamente funzionalità interne di Bash: dunque non vengono invocati processi esterni, e la velocità di esecuzione risulta decisamente buona.

Noterai che il programma visualizza la posta ma non la cancella dal server (cioè non usa il comando DELE del protocollo POP-3): è facile introdurre anche questa funzionalità aggiungendo queste righe come ultime istruzioni del ciclo for:

putline "DELE $i"
getline && check_ok || exit 1

Un'altra utile funzionalità sarebbe quella di salvare i messaggi ognuno in un file di testo: 1.txt per il primo email, 2.txt per il secondo, e così via, in modo che siano facilmente consultabili. E' molto facile realizzare questa funzionalità sfruttando gli operatori di redirezione. Suggerimento:

for (( i=1; i<=n; i++ )); do
    ...
    while getline ; do
    ...
    done | sed "s/^S: //" > $i.txt
done

Un'altra estensione facile da realizzare è l'implementazione del comando APOP per l'invio crittato della password: l'articolo sui protocolli già citato fornisce le indicazioni necessarie. La parte più difficile di questa estensione è riuscire ad estrarre il timestamp dal messaggio di benvenuto del server: a questo scopo si può lavorare sia con gli operatori di stringa di Bash, sia con il filtro sed. Il messaggio di benvenuto del server è la prima linea $l ritornata alla prima invocazione di getline(). Ecco un esempio:

s=${l#*<}
s=${s%>*}
timestamp="<$s>"
crittata=$( echo -n $timestamp$pass | md5sum | (read s x; echo $s) )
putline "APOP $user $crittata"
getline && check_ok || exit 1

Conclusioni

Altri esempi con applicazioni ai CGI del WEB si trovano nell'articolo Apache 2 pubblicato su PLUTO Journal n. 36 di aprile 2002 (www.pluto.linux.it/pj0204/apache2.html). Ho omesso di descrivere altre importanti funzionalità di Bash, come il file globbing, gli array, le variabili predefinite, le innumerevoli opzioni di configurazione, ecc., e inoltre gli argomenti qui esposti sono stati appena sfiorati, giusto per dare l'idea. Una buona lettura per gli approfondimenti è la guida di riferimento di Bash, scritta ad opera dei suoi autori e citata in bibliografia. La man page di Bash rimane comunque la fonte ultima e definitiva.

Bash non pretende di sostituirsi ai veri linguaggi di programmazione, tuttavia può rivelarsi utile in molte occasioni. La sua sintassi è piuttosto arcana, richiede esperienza e induce facilmente a commettere errori subdoli, per cui non è certo un linguaggio didatticamente valido. Si tratta invece di uno strumento di uso pratico, e come tale si permette qualche espediente "sporco" pur di raggiungere lo scopo. Ricordo inoltre che gran parte delle possibilità disponibili nella programmazione con un linguaggio di shell stanno nei programmi a corredo del sistema operativo, a cominciare dai mitici sed, grep, find, ecc. e in generale quelli della collezione "shell utilities" di GNU.

Bibliografia



L'autore

Umberto Salsi <umberto-salsi@libero.it> ha scritto il suo primo programma nel 1981: un potente ciclo FOR stampava su schermo i numeri da 1 a 10000. Folgorato da questo successo, da allora non ha più smesso di seviziare computer nel software e nell'hardware, e di queste pratiche ne ha fatto il suo lavoro e il suo hobby. Nel 1992 scopre Internet e il mondo delle reti telematiche. Nel 1996 incontra GNU/Linux, ed è un'altra infatuazione.



<- PW: Gnome - Archivio Generale - Copertina - PW: Gosh ->