Introduzione ai sistemi operativi con UNIX
Prec 5. I processi Succ

5.2 execl, execve

Un altro esempio di uso della fork(2): come forse sapete molti programmi di UNIX interattivi consentono di eseguire un comando di shell senza uscire dal programma. Tutti usano la stessa sintassi: !comando dove comando è un qualsiasi comando di shell. Se comando manca, viene semplicemente aperta una shell e uscendo dalla shell si ritorna al comando che l'ha invocata. Tra i comandi più comuni che forniscono questa funzionalità vi sono less(1), ed(1), vi(1), mail(1). La shell di default in FreeBSD è sh(1), che è anche quella più leggera ed è quella che useremo anche noi. L'eseguibile si trova in /bin/sh.

Supponiamo di avere un generico programma che ha un ciclo che processa un comando alla volta, ogni comando essendo su una linea di input separata, e di voler implementare questa funzionalità di escaping alla shell.

Per leggere una linea alla volta, invece di usare la system call read(2), risulta più semplice da usare la funzione della libreria di I/O del C fgets(3):

char *
     fgets(char *str, int size, FILE *stream);

La fgets(3) non legge mai più di size-1 caratteri dallo stream. Essa si ferma anche quando incontra un carattere di nuova linea (newline), oppure se sopraggiunge la fine del file o in caso di errore. Legge al più size-1 caratteri invece di size caratteri perché si riserva una posizione per inserire un carattere NULL (\0) che in C per convenzione viene usato come terminatore delle stringhe. Perciò il buffer str deve essere almeno di dimensione size e non di dimensione size-1. Un eventuale newline letto viene incluso nella stringa str. Il valore di ritorno di fgets(3) se viene incontrato l'EOF (end-of-file) prima che venga letto un carattere, sarà NULL. In tal caso il buffer str non viene cambiato. Se invece si verifica un errore, la fgets(3) ritorna lo stesso NULL, ma il contenuto del buffer rimane indeterminato. Per distinguere i due casi occorrerà usare le funzioni feof(3) e ferror(3). Altrimenti la fgets(3) ritorna il valore del suo stesso primo parametro, ossia il puntatore ad str, che essendo un puntatore valido non può valere NULL.

Tornando al nostro programma, nel caso in cui la linea inizia con un punto esclamativo, l'indirizzo della stringa comando sarà &cmd[1] oppure (scrittura equivalente) cmd+1. L'azione consiste semplicemente nel fare l'eco del comando (ma potete sostituirla con quello che volete). Lo scheletro del nostro programma sarà il seguente (per una spiegazione riguardante gli stream vedere stdio(3)):

/* system.c */

#include <stdio.h>

/* max command line length (including newline and null) */
#define MAXLEN 300

int main()
{ char cmd[MAXLEN];

  /* command processing loop */
  while (fgets(cmd, MAXLEN, stdin))
  { cmd[strlen(cmd)-1]='\0';
    if (cmd[0]=='!')
    { /* escape to a shell */
      puts(&cmd[1]);
    }
    else
    { /* process command cmd */  
      puts(cmd);
    }
  }

  exit(ferror(stdin)!=0);
}

Notate che il newline letto viene incluso nella stringa cmd. Questo non è un problema in FreeBSD, ma è meglio eliminarlo.

Per implementare lo shell-escape usiamo fork(2) per clonare il processo. fork(2) è l'unico modo in UNIX per creare un nuovo processo. Abbiamo però adesso una difficoltà: noi non vogliamo che il nuovo processo esegua lo stesso codice di system.c, bensì vogliamo che esegua il codice della shell sh(1). Ci viene in aiuto un'altra funzione della libreria del C, la execl(3):

int
     execl(const char *path, const char *arg, ...);

La execl(3) ha un numero variabile di argomenti. Il primo argomento è sempre il nome del file del comando che deve essere eseguito; deve essere comprensivo del percorso perchè la execl(3) non è in grado di cercare nelle directory definite dalla variabile di ambiente PATH (è la shell che provvede questa funzionalità per comodità dell'utente, non la execl(3), tuttavia un'altra variante della execl(3), la execlp(3) effettua la ricerca nei percorsi di PATH).

Il secondo argomento è ancora una stringa e rappresenta il nome del file di programma che viene eseguito. Si conviene di assegnare a questo parametro l'ultima componente del primo parametro, quindi ad esempio "sh" se il primo parametro è "/bin/sh". In effetti il secondo parametro rappresenterà l'argv[0] che verrà passato al main() del programma eseguito. I parametri successivi sono sempre stringhe NULL-terminate e rappresentano i singoli parametri che saranno disponibili al comando in argv[1], argv[2], ecc. L'ultimo parametro deve essere sempre NULL (interpretato come un puntatore all'indirizzo di memoria 0) per segnalare la fine della lista degli argomenti.

Tutte le funzioni di tipo exec(3) sono dei front-end sulla system call execve(2):

int
     execve(const char *path, char *const argv[], char *const envp[]);

La execve(2) esegue il file specificato, trasformando il processo che la chiama in un nuovo processo. L'immagine del nuovo processo viene costruita a partire dal file oggetto eseguibile specificato e sovrapposta a quella del processo che invoca la execve(2). Viene sovrapposto sia lo spazio codice che lo spazio dati. Il nuovo processo così ottenuto viene eseguito e poi esce e non vi è alcun ritorno al processo originale che è stato sovrapposto. Ne segue allora che la la execl(3) non ritorna mai, tranne quando si è verificato un errore e non è stato possibile effettuare l'overlay del processo invocante. In tal caso la execl(3) ritorna il valore -1 e setta errno.

Un file oggetto inizia sempre con un header particolare che consente di riconoscerlo come tale (è il cosiddetto numero magico o magic number, vedere anche file(1)). Seguono poi pagine di dati che rappresentano il codice del programma e i valori iniziali dei suoi dati. FreeBSD utilizza per gli eseguibili il moderno standard ELF (Executable and Linking Format), descritto in elf(5):

$ file /bin/sh 
/bin/sh: ELF 32-bit LSB executable, Intel 80386, version 1 (FreeBSD), for FreeBSD 4.8, statically linked, stripped

Il kernel tramite la execve(2) permette di caricare non solo file binari eseguibili, ma anche file che devono essere interpretati, che sono tipicamente file in formato testo, come ad esempio gli script di shell sh(1) o di linguaggi interpretativi come perl(1), awk(1), bc(1), python(1), php e tanti altri. Quest'ultimi, per poter essere caricati con la execve(2), devono avere l'attributo di eseguibilità (il permesso x di cui abbiamo già parlato che potete attribuire con chmod(1) interattivamente e tramite la syscall chmod(2) da programma C) e devono iniziare con una linea del tipo:

#! interpreter [arg]
dove interpreter rappresenta il percorso completo dell'interprete (che deve essere un eseguibile binario in un formato supportato dal kernel, ad es. ELF). L'argomento opzionale arg verrà trasmesso come primo argomento (argv[1]) all'interpreter, se presente. Il prossimo argomento (che sarà argv[1] se arg non è presente) sarà il primo argomento della execve(2), cioè il file da interpretare. Seguono a ruota tutti gli argomenti del vettore di puntatori a stringhe argv (che deve essere NULL terminato) passato come secondo argomento alla execve(2). Uniformità vuole che argv debba avere almeno un elemento, argv[0] che deve essere il nome del programma eseguito (l'ultimo componente di path).

Il nuovo processo mantiene gli stessi descrittori di file che erano aperti dal processo che ha invocato la execve(2), mantiene lo stesso pid e lo stesso ppid, la stessa umask, la stessa directory corrente e directory root (vedere chroot(2)), lo stesso terminale di controllo e altre informazioni di stato che non abbiamo ancora trattato (vedere execve(2) per i dettagli). Inoltre mantiene le stesse informazioni sui permessi (stessa lista dei gruppi di accesso, stesso ruid, stesso rgid), ad eccezion del caso in cui il file path ha settato il suid: in tal caso l'euid cambia e diventa pari all'uid del possessore del file path; analogamente se path ha settato il bit sgid, l'egid diventa pari al gid del file path.

Vi sono due casi in cui i bit suid e sgid su path vengono ignorati, ossia non hanno alcun effetto: se il filesystem su cui risiede path viene montato con l'opzione nosuid di mount(8) oppure se path non si riferisce ad un eseguibile binario bensì ad un file da interpretare.

La nostra nuova versione di system.c, con la gestione delle condizioni di errore che si possono verificare con la fork(2) e la execl(3) è la seguente:

/* system.c */

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

/* max command line length (including newline and null) */
#define MAXLEN 300

int main()
{ char cmd[MAXLEN];
  pid_t pid;

  /* command processing loop */
  while (fgets(cmd, MAXLEN, stdin))
  { cmd[strlen(cmd)-1]='\0';
    if (cmd[0]=='!')
    { /* escape to a shell */

      if ((pid=fork())==0)
      { /* we are the child */

        (strlen(cmd+1) ?
              execl("/bin/sh", "sh", "-c", cmd+1, NULL)
            : execl("/bin/sh", "sh", NULL));
        perror("execl");
        _exit(EXIT_FAILURE);
      }

      /* we are the parent */
      if (pid==-1)
        perror("fork");
    }
    else
    { /* process command cmd */
      puts(cmd);
    }
  }

  exit(ferror(stdin)!=0);
}

Si noti che è inutile testare il valore ritornato da execl(3) per vedere se è -1: se la execl(3) ritorna questo valore sarà sicuramente -1, ossia si è verificato un errore, quindi si procede direttamente a stampare un messaggio di errore. Notare inoltre che il processo figlio chiama la syscall _exit(2) invece della funzione di libreria exit(3). Questa è una buona pratica perché la exit(3) effettua in più delle operazioni che si vogliono fare solo alla fine del processo padre (ad esempio il flush dei buffer vogliamo eseguirlo solo una volta alla fine).

Piuttosto c'è un altro problema ben più grave: system è un processo interattivo che richiede l'input dal terminale; finchè system lancia un processo figlio che si limita a stampare qualcosa, come quando si dà il comando !ls, tutto sembra funzionare correttamente, ma non appena si lancia un comando pure interattivo, ossia che richieda dell'input da parte dell'utente, le cose non vanno più bene: provate ad esempio con !man cat, !read var o con un semplice !. Si tratta di una questione di sincronizzazione dei processi.

Per risolvere il problema occorre che il processo padre, che sicuramente non può continuare in parallelo al processo figlio, necessitando dell'input dell'utente, attenda che il processo figlio termini l'esecuzione prima di continuare. Quindi il padre, invocando la wait(2) si pone volontariamente nello stato di sleeping S, aspettando poi di essere risvegliato dal kernel che lo pone nello stato runnable R, non appena il processo figlio è terminato e ne è stato raccolto lo stato di terminazione:

pid_t
     wait(int *status);

Infatti quando una wait(2) che ha avuto successo ritorna, la variabile intera puntata da status (variabile passata per riferimento) conterrà le informazioni di terminazione riguardanti il processo figlio che è terminato. Negli otto bit più bassi di status il sistema memorizza il codice di terminazione del processo figlio, indicando con zero che una terminazione normale e con codici diversi da zero vari tipi di problemi. Nel caso in cui il processo figlio termini normalmente tramite una chiamata ad _exit(2) oppure exit(3), i successivi 8 bit contengono esattamente l'argomento passato a queste funzioni dal processo figlio. Ora per semplicità ignoriamo il valore di status, nel senso che non ce ne facciamo niente:

/* system.c */

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

/* max command line length (including newline and null) */
#define MAXLEN 300

int main()
{ char cmd[MAXLEN];
  pid_t pid;
  int status;

  /* command processing loop */
  while (fgets(cmd, MAXLEN, stdin))
  { cmd[strlen(cmd)-1]='\0';
    if (cmd[0]=='!')
    { /* escape to a shell */

      if ((pid=fork())==0)
      { /* we are the child */

        (strlen(cmd+1) ?
              execl("/bin/sh", "sh", "-c", cmd+1, NULL)
            : execl("/bin/sh", "sh", NULL));
        perror("execl");
        _exit(EXIT_FAILURE);
      }

      /* we are the parent */
      if (pid==-1
             || wait(&status)==-1) /* wait for the child to finish */
        perror(NULL);
    }
    else
    { /* process command cmd */
      puts(cmd);
    }
  }

  exit(ferror(stdin)!=0);
}

Quando una wait(2) che ha avuto successo ritorna (il processo figlio è terminato o è nello stato di stop T), restituisce il pid del processo figlio di cui il padre ha atteso la terminazione. Se la wait(2) fallisce, ad esempio perchè il puntatore status non è valido o non ci sono processi figli di cui non si è già attesa la terminazione (quindi il processo padre in questo momento non ha discendenti, ma erroneamente ha invocato lo stesso la wait(2)), ritorna subito (nel senso che non è bloccante) il valore -1 e setta errno per indicare il tipo di errore.

Se non interessano le informazioni di stato sulla terminazione si può passare un puntatore nullo alla wait(2) in questo modo:

wait((int*)0)
o se preferite potete usare la costante NULL definita come 0 in <stdio.h>:
wait(NULL);

Coloro che hanno usato il vecchio sistema operativo DOS, un figlio minore di UNIX diffuso sui primissimi PC, ricorderanno che anche in questo sistema era possibile eseguire un programma all'interno di un altro, per esempio era possibile l'escaping alla shell, che anche qui era un eseguibile come tutti gli altri (il suo nome storico è COMMAND.COM). La cosa si realizzava tramite una system call messa a disposizione del DOS: questa si invocava tramite l'interruzione numero 21h (h indica che il numero è esadecimale), mettendo nel registro %ah il numero di funzione 4Bh e nel registro %al il numero di sottofunzione 0 e veniva denominata funzione di exec.

Tuttavia essendo il DOS un sistema monoprogrammato, il processo padre viene automaticamente bloccato dal sistema operativo DOS quando crea il processo figlio e non era perciò necessario chiamare l'equivalente della wait(2) di UNIX, né veniva fornita dal DOS una tale syscall.

In UNIX, che è un sistema multiprogrammato, invece i processi padre e figlio eseguono contemporaneamente a meno che il padre non decida di bloccarsi eseguendo una wait(2). Perciò in UNIX non dimenticate di far eseguire al processo padre la wait(2) se non volete che i due processi eseguano contemporaneamente!


Prec Indice Succ
fork Livello superiore La comunicazione tra i processi