Matteo Vaccari

Matteo Vaccari > Sistemi Operativi

Esercizi sulla programmazione Unix in C

Lo scopo di questi esercizi è di impratichire lo sviluppatore con le chiamate di sistema di Unix. Una buona conoscenza di queste chiamate di sistema permette di realizzare applicazioni più semplici ed efficaci. Non riporto le istruzioni per l’uso delle varie chiamate di sistema; mi aspetto che il lettore si studi le pagine del manuale di Linux relative a tutte le syscall usate negli esercizi.

Per gli studenti del corso di Sistemi Operativi II, a.a. 2008/09, dovete essere in grado di risolvere tutti gli esercizi che non siano marcati con la (!), che significa “difficile.”

Come organizzare un semplice programma C

Per prima cosa: ogni programma va realizzato per stadi successivi. Ad ogni stadio dobbiamo avere il programma funzionante, stabile, pulito e in ordine. Conserviamo le versioni precedenti dei nostri programmi, ad esempio archiviando tutta la directory del progetto in un file .zip o .tgz ogni volta che raggiungiamo un punto fermo, e comunque almeno una volta ogni ora.

Ogni programma C dovrebbe essere accompagnato da un Makefile; soprattutto se il programma è organizzato in più di un modulo. Un esempio di Makefile per un programma composto da un solo modulo è il seguente:

CFLAGS=-Wall -std=c99 myshell: myshell.o gcc $(CFLAGS) myshell.o -o myshell

Quando il nostro programma contenga più di un modulo, è buona norma realizzare per ogni file .c un file .h che contenga tutte le dichiarazioni necessarie per usare le funzioni e le variabili dichiarate nel .c. Un esempio di Makefile per un programma composto di più moduli:

  CFLAGS=-Wall -std=c99

  OBJECTS=foo.o bar.o
  OBJECTS_PRODUCTION= $(OBJECTS) main.o 
  OBJECTS_TEST=$(OBJECTS) test_foo.o test_bar.o main_test.o

  myshell: $(OBJECTS_PRODUCTION)
  	gcc $(CFLAGS) $(OBJECTS_PRODUCTION) -o myshell

  test:  $(OBJECTS_TEST)
  	gcc $(CFLAGS) $(OBJECTS_TEST) -lcgreen -o test
  	./test

  clean:
  	rm -rf *.o *~ *.zip myshell test

Questo makefile prevede la compilazione di due eseguibili; uno è l’eseguibile di produzione, ovvero il programma che effettivamente vogliamo realizzare; l’altro è l’eseguibile di test, che contiene i test unitari. L’uso di test unitari permette di sviluppare in maniera molto più efficace, tanto che sono diventati la norma, ovvero lo stato dell’arte nella pratica della programmazione.

E’ importante scrivere un makefile, per non essere costretti a digitare ogni volta come scimmiette i comandi per compilare. Il lavoro meccanico deve essere lasciato alla macchina; il nostro tempo va impiegato per compiti che richiedono intelletto.

[the process of computer programming] “should be very fascinating. There need be no real danger of it ever becoming a drudge, for any processes that are quite mechanical can be be turned over to the machine itself.” — Alan Turing, citato in The Engines of Logic, p. 192.

L’ordine è essenziale

Non consegnate codice male indentato. E’ una cosa che denota sciatteria e assenza di cura per i particolari. Non c’è bisogno di dire che la cura dei dettagli è una qualità importante per un programmatore.

// che schifo!  Tutto indentato a casaccio
int main() {
close(1);    
  // se non metto spazi dopo ogni segno di interpunzione non
  // si riesce a leggere bene.
  int fd=open("/tmp/output",O_WRONLY|O_TRUNC|O_CREAT,0777); 
  if (fd < 0) {
    perror("open fallita"); 
  exit(1);
  }

execlp("ls", "ls", "/etc", NULL);
 perror("exec fallita");
   exit(1);
}  

Non è meglio questo?

// uno sviluppatore che sa il fatto suo si vede anche dall'ordine.
int main() {
  close(1);    
  // uno spazio dopo ogni virgola (non prima), spazi prima e dopo gli
  // operatori come "|" o "="
  int fd = open("/tmp/output", O_WRONLY | O_TRUNC | O_CREAT, 0777); 
  if (fd < 0) {
    perror("open fallita"); 
    exit(1);
  }
  execlp("ls", "ls", "/etc", NULL);
  perror("exec fallita");
  exit(1);
}  

Esercizio -1. Trova l’errore

Esamina questo programma: vedi l’errore? Se non lo vedi, non hai le minime conoscenze di programmazione C per proseguire. Prenditi il Kernighan & Ritchie e fai i tuoi compiti. Lo scopo di questi esercizi è di imparare le chiamate di sistema Unix, non imparare la programmazione C tout court.

int main() {
  char *buf;
  while (1) {
    int nread = read(0, buf, BUF_SIZE);
    if (nread < 0) {
      perror("read"); exit(EXIT_FAILURE);
    }
    write(1, buf, nread);
  }
  return 0;
}    

E in questo? Lo vedi l’errore?

int main() {
  struct stat *buf;
  while (1) {
    stat("/etc/passwd", buf);
    printf("file size is %lld\n", buf->st_size);
  }
  return 0;
}    

Altro errore:

  char *pid = (char*) getpid();
  printf("%s", pid);

Esercizio 0. Redirezione

Scrivere un programma C equivalente al seguente comando di shell:

$ ls /etc > /tmp/output

Come è noto, la ragione principale per cui in Unix l’esecuzione di un programma comporta due chiamate separate fork(2) ed exec(2) è che fra la prima e la seconda, il processo figlio può manipolare i propri file descriptor per implementare le redirezioni. In questo semplice esercizio la fork(2) non è necessaria. Si risolve facilmente:

int main() {
  // Chiudo standard output
  close(1);    

  // Apro il file su cui devo ridirigere standard output.  
  // O_TRUNC significa "se il file esiste, tronca la lunghezza a 0"
  // O_CREAT significa "se invece il file _non_ esiste, crealo"
  // Poiché abbiamo usato l'opzione O_CREAT dobbiamo passare anche
  // un terzo argomento che rappresenta i permessi che vogliamo
  // dare al file nel caso questo venga creato.
  // Mi aspetto che il file descriptor restituito sia 1, perché
  // dovrebbe essere il più piccolo fd non in uso.
  int fd = open("/tmp/output", O_WRONLY | O_TRUNC | O_CREAT, 0777); 
  
  // Dopo _ogni_ chiamata di sistema bisogna verificare se ha avuto 
  // successo.  In questo caso se fallisce, terminiamo il programma
  // con un messaggio di errore informativo.
  if (fd < 0) {
    perror("open fallita"); 
    exit(1);
  }

  execlp("ls", "ls", "/etc", NULL);
  
  // se siamo qui, la execlp(2) ha fallito!
  perror("exec fallita");
  exit(1);
}

Ogni programma va testato: il test di accettazione per questo programma è il seguente:

$ ./a.out                           # eseguo il mio programma
$ ls /etc > /tmp/output-confronto   # eseguo un comando shell equivalente
$ diff /tmp/output /tmp/output-confronto
                                    # se diff non dice niente, vuol dire 
                                    # che i due file sono uguali.

Mi raccomando lettore: non essere asino! Esegui il test di accettazione. Non scherzo.

Scrivere programmi analoghi che implementano i seguenti comandi di shell:

$ ls /etc >> /tmp/output

Mi aspetto che se lo invoco più volte, il file /tmp/output contenga il risultato delle successive invocazioni. Il test di accettazione:

$ ./a.out
$ ./a.out
$ ls /etc > /tmp/output-confronto   
$ ls /etc >> /tmp/output-confronto   
$ diff /tmp/output /tmp/output-confronto
    # se diff non dice niente, vuol dire che i due file sono uguali.

Altro esercizio:

$ tr o 0 < /etc/passwd > /tmp/output

Mi aspetto di trovare in /tmp/output una copia di /etc/passwd con tutte le “o” cambiate in “0”

$ ls /tmp foobar > /tmp/output 2> /tmp/error

Mi aspetto di trovare in /tmp/output la lista dei file contenuti in /tmp, e in /tmp/error il messaggio “foobar: No such file or directory”

$ ls /tmp foobar > /tmp/output 2>&1

Mi aspetto di trovare in /tmp/output sia la lista dei file di /tmp, che il messaggio di errore.

Esercizio 1. Processi che comunicano tramite pipe(2)

Scrivere un programma C equivalente al seguente comando di shell:

$ ls /etc | more

Ovviamente, perché i due processi possano condividere la pipe, la chiamata a pipe(2) deve essere fatta prima della chiamata a fork(2).

Altri esercizi: implementare programmi C equivalenti ai comandi:

$ ls /etc | grep foo > /tmp/foo
$ cat < /etc/passwd | grep oo >> /tmp/foo 2> /tmp/bar
$ cat /etc/* | sort | uniq -c

Per ciascuno, verificare che il comportamento del nostro programma C sia uguale a quello del comando di shell.

Esercizio 2. Copia di file

Scrivere un file che copia un file in un altro. Primo livello: funziona correttamente solo per file di dimensione minore di 8KB.

Pseudocodice:

apre il primo file (argv[1])
crea il secondo file (argv[2])
legge i primi 8Kb con read(2)
copia i byte con write(2)
chiude entrambi i file
exit(0)

Test:

./copia /etc/passwd /tmp/copia-di-passwd
diff /etc/passwd /tmp/copia-di-passwd
(se "diff" dice qualcosa vuol dire che i file non sono uguali)

Copia di file, secondo livello

Usare un ciclo per copiare a colpi di 8KB file di dimensioni arbitrarie.

Test:

  n=0; while [ $n -lt 1000000 ]; do echo $n >> /tmp/bigfile; n=$(($n + 1)); done
  ./copia /tmp/bigfile /tmp/copia-di-bigfile
  diff /tmp/bigfile /tmp/copia-di-bigfile

Domande:

Esercizio 3: File mappati in memoria

Usare mmap(2) per mappare in memoria un file il cui nome viene passato come argomento. Poi, iterare su tutti i caratteri del file e produrre scritte come nel seguente esempio:

$ echo ciao > tmp.file
$ ./a.out tmp.file
Il carattere alla posizione 0 è 'c'
Il carattere alla posizione 1 è 'i'
Il carattere alla posizione 2 è 'a'
Il carattere alla posizione 3 è 'o'
Il carattere alla posizione 4 è '
'

Pseudocodice:

  apre il file il cui nome è in argv[1] con open(2)
  ottiene la lunghezza del file con fstat(2)
  mappa il file in memoria con mmap(2)
  per ogni carattere 
    esegui la stampata

Esercizio 4: copiare file con mmap

Scrivere un file che copia il file il cui nome è passato come primo argomento in un file il cui nome è passato come secondo argomento. Se il secondo file non esiste, deve essere creato. Se il secondo file esiste, deve essere troncato.

Pseudocodice:

main(argc, argv)
      /* il nome del file di input è passato come argomento: si trova in argv[1] */
      /* il nome del file di output è passato come argomento: si trova in argv[2] */
      apri il file di input con open(2)
      crea il file di output con open(2)
      trova la lunghezza del file di input con fstat(2)
      estendi la lunghezza del file di output con ftruncate(2) per farla uguale al file di input
      mappa entrambi i file in memoria con mmap(2)
      copia l'input sull'output con memcpy(3)
      chiudi i due file

Esercizio 5: i metadati dei file

Primo livello: scrivere un programma che accetta come argomento un nome di file e descrive i metadati del file, come da questo esempio:

$ ./lista /etc/passwd
Type Mode  N.links Uid Gid Size Name
file 0644  1       0   1   1932 /etc/passwd
$
$ ./lista blablabla
blablabla: No such file or directory

Discussione. Per risolvere questo esercizio dobbiamo essere in grado di:

Metadati dei file: secondo livello

Se il nome di file rappresenta una directory, listare il contenuto di essa

$ ./lista /etc
Type Mode  N.links Uid Gid Size Name
file 0644  1       0   1   1932 csh.login
dir  0644  1       0   1   1932 cups
file 0644  1       0   0    388 passwd
file 0644  1       0   112 1234 profile

Metadati dei file: terzo livello (!)

Se viene passato l’argomento “-R”, listare ricorsivamente le sottodirectory.

$ ./lista -R /etc
directory /etc:
Type Mode  N.links Uid Gid Size Name
file 0644  1       0   1   1932 csh.login
dir  0644  1       0   1   1932 cups
file 0644  1       0   0    388 passwd
file 0644  1       0   112 1234 profile

directory cups:
Type Mode  N.links Uid Gid Size Name
file 0644  1       0   112 1234 printers.conf
$

Esercizio 6. Un clone di grep(1)

Sviluppare il comando sgrep (simple grep) che si comporta come grep. Caso base:

$ sgrep matteo /etc/passwd
matteo:x:1000:100:,,,:/home/matteo:/bin/bash
$

Discussione. La maniera più semplice di sviluppare questo comando è

  apri il file in argv[1] con fopen(3)
  leggi il file riga per riga con fgets(3)
  confronta ciascuna riga con strstr(3)

Clone di grep(1), secondo livello

Supportare più di un file sulla riga di comando. Esempio:

  
$ grep root /etc/passwd /etc/motd /usr/share/dict/words 
/etc/passwd:root:x:0:0::/root:/bin/bash
/etc/passwd:operator:x:11:0:operator:/root:/bin/bash
/usr/share/dict/words:bitterroot
/usr/share/dict/words:root
/usr/share/dict/words:rooted
/usr/share/dict/words:rooter
/usr/share/dict/words:rooting
/usr/share/dict/words:roots
/usr/share/dict/words:taproot
/usr/share/dict/words:taproots
/usr/share/dict/words:uproot
/usr/share/dict/words:uprooted
/usr/share/dict/words:uprooting
/usr/share/dict/words:uproots
$

Notare che ogni riga che corrisponde al pattern contiene come prefisso il nome del file seguito da ‘:’.

Clone di grep(1), terzo livello (!)

Riscrivere l’implementazione senza usare stdio. Per ogni file passato come argomento:

apri il file con open(2)
mappa il file in memoria con mmap(2)
leggi le righe con una funzione get_next_line() sviluppata da noi
confronta la riga con il pattern usando strstr(3)

Confrontare la performance delle due implementazioni su file di grosse dimensioni. C’è differenza?

Esercizio 7: il processo invincibile

Vogliamo scrivere un programma che, come un eco, stampa sul suo standard output quello che riceve da standard input. Come ulteriore complicazione, vogliamo che questo processo ignori il segnale SIGINT, inviato generalmente con control-C sul terminale.

Esempio:

$ ./echo 
ciao
rispondo: ciao
^C
non muoio!
bla
rispondo: bla
^\
Quit
$

Il processo invincibile, primo livello: siamo ancora vulnerabili

Pseudo codice:

  while true
    leggi un massimo di 100 caratteri da stdin con read(2)
    scrivi "rispondo: " su stdout con write(2), 
    scrivi con write(2) la riga letta prima 

Il processo invincibile, secondo livello: installiamo la gestione di SIGINT

Il programma resta identico al primo livello, ma in aggiunta prima di iniziare l’attività installa uno handler per SIGINT usando signal(2) oppure sigaction(2). Nello handler stampa “non muoio!”.

Esercizio 8. Echo server con FIFO

Realizziamo un semplice sistema client-server che si scambia messaggi tramite oggetti FIFO.

Primo livello: il client scrive su un oggetto FIFO ben noto un messaggio. Il server dimostra di averlo ricevuto stampandolo sul suo standard output.

E’ facile scrivere una versione bash di questo sistema client-server:

# echo_client.sh
fifo_server=/tmp/echo_server.fifo
mkfifo $fifo_server
while true; do
    read -p "> " message
    echo "$message"
done

# echo_server.sh
server_fifo=/tmp/echo_server.fifo
mkfifo $fifo_server
while true; do
  cat $fifo_server
done

Dobbiamo realizzare una versione C di questi due programmi bash. Il bello è che possiamo scrivere prima il client in C, e testarlo con il server in bash, o viceversa. Usano lo stesso protocollo.

Esempio:

  $ ./echo_client      $ ./echo_server
  > ciao               ciao
  > pippo              pippo

Si noti che posso connettere anche più di un client contemporaneamente allo stesso server.

Pseudocodice:

  client:
    crea l'oggetto fifo /tmp/echo_server.fifo con mkfifo(3)
    (se il fifo già esiste ignora l'errore e prosegue)
    apre il fifo con open(2)
    (se la open(2) fallisce termina con un messaggio di errore)
    while true
      legge una riga da stdin con read(2)
      stampa la riga sulla fifo con write(2)

  server:
    crea l'oggetto fifo /tmp/echo_server.fifo con mkfifo(3)
    apre il fifo con open(2)
    while true
      leggi una riga dal fifo
      stampa la riga su stdout

Echo server, Secondo livello

Vogliamo che il server risponda al client, in modo che il client stampi la risposta ottenuta dal server. Non possiamo usare la fifo del server per questo, perché sarebbe condivisa fra tutti i client. Dobbiamo usare un FIFO specifico per rispondere a ciascun client. Costruiamo il nome del fifo dal process id del client; però il client ci deve comunicare in ogni messaggio il suo pid. Decidiamo che il messaggio prende la forma “(pid) (resto del messaggio)”. La versione bash resta come esempio. (Si noti che $$ è la variabile built-in di bash che contiene il process id della bash stessa.)

# echo_client.sh
fifo_server=/tmp/echo_server.fifo
fifo_client=/tmp/echo_client.$$
mkfifo $fifo_server
mkfifo $fifo_client
while true; do
    read -p "> " message
    echo "$$ $message" > $fifo_server
    cat $fifo_client
done

# echo_server.sh
fifo_server=/tmp/echo_server.fifo
mkfifo $fifo_server
while true; do
    message=$(cat $fifo_server)
    # dimostra che hai ricevuto qualche cosa
    echo "$message"
    # estrai il pid dal messaggio
    client_pid=$(echo $message | cut -f 1 -d ' ')
    # costruisci il nome del fifo su cui dobbiamo rispondere
    fifo_client=/tmp/echo_client.$client_pid
    echo "$message" > $fifo_client
done

Lo pseudocodice della versione C:

  client:
    crea l'oggetto fifo /tmp/echo_server.fifo 
    ottiene il proprio pid con getpid(2)
    crea l'oggetto fifo /tmp/echo_client.(pid del client)
    while true
      leggi una riga da stdin
      stampa pid + blank + la riga sul fifo del server
      leggi una riga dal fifo del client
    
  server:
    while true
      leggi una riga dal fifo del server
      stampa la risposta sul stdout
      estrai dal messaggio il pid del client
      stampa la risposta sul fifo del client

Esercizio 9: una semplice shell

Lo scopo di questo esercizio è di impratichirsi con le chiamate di sistema wait(2) e varianti, fork(2), exec(2) e varianti.

Primo livello: eseguiamo solo comandi composti da una sola “parola”.

Esempio:

$ ./myshell
myshell> ls<RETURN>
(lista dei file della directory corrente)
myshell> 

Pseudocodice:

while true
  print "myshell> "
  read command
  fork
  if child then
    execute command
  else
    wait for child
  end
end

Semplice shell, secondo livello: eseguiamo anche comandi composti da piu’ di una parola.

Esempio:

  $ ./myshell
  myshell> ls foo bar baz
  baz: file does not exist
  foo bar
  myshell>

Fra le varianti di exec(2) che possiamo usare, la piu’ conveniente e’
probabilmente la execvp(2), perche’ accetta un array di argomenti di
lunghezza arbitraria. Ai fini dell’esercizio possiamo usare un array
di argomenti di una lunghezza massima di (diciamo) dieci elementi.

E’ importante testare l’algoritmo che separa gli argomenti in un
array di stringhe separatamente rispetto al resto del programma. Ad
esempio usando un “main” apposta che esegue una c.d. “test harness”
per testare questo algoritmo.

Quello che dobbiamo fare è realizzare una funzione che parsa il comando, ovvero converte la riga di comando digitata dall’utente in una struttura dati, che sia conveniente per quello che dovremo fare dopo. Ad esempio, sapendo che la execv(2) e la execvp(2) richiedono un array di argomenti terminato da un puntatore nullo, conviene fare in modo che la nostra routine di parsing produca un array fatto proprio così.

Alla fine dobbiamo definire una struttura dati che rappresenti il comando inserito dall’utente. La funzione che parsa la riga popola la struttura dati. Questa cosa è spiegata bene da Martin Fowler in Semantic Model.

Semplice shell, terzo livello: implementiamo alcuni comandi built-in.

Il comando “cd” non puo’ essere sensatamente implementato come un comando eseguito tramite fork+exec. Perché? Pensaci.

Alcuni comandi, come “cd(1)”, “exit(1)”, “exec(1)” non sono eseguiti tramite fork+exec, ma sono implementati direttamente dalla shell. Implementare almeno “cd(1)”, "pwd(1) e “exit(1)”. Esempio:

$ ./myshell
myshell> ls
(lista dei file della directory corrente)
myshell> pwd
/home/matteo
myshell> cd /tmp
myshell> pwd
/tmp
myshell> ls -l
(lista dei file della directory /tmp)
myshell> exit
$ 

Semplice shell, quarto livello: implementiamo i comandi in background

Esempio:

$ ./myshell
myshell> sleep 5 &
processo con pid 1345 in background
myshell> sleep 5 &
processo con pid 1346 in background
myshell> sleep 5 &
processo con pid 1347 in background
myshell> 
myshell> 
myshell> 
processo con pid 1345: terminato con status 0
processo con pid 1346: terminato con status 0
processo con pid 1347: terminato con status 0
myshell>

Come si vede dall’esempio, non è sufficiente, per implementare i comandi in background, evitare di fare la wait(2) per restituire subito il prompt; è necessario anche verificare, prima di stampare il prompt, se uno qualsiasi dei processi figli che abbiamo generato è terminato. Se non lo facessimo, tutti i processi in background terminati resterebbero “zombie”. Quindi, prima di stampare il prompt, invochiamo una funzione apposita “wait_for_terminated_children()”.

La syscall da chiamare non è la wait(2), perché quest’ultima blocca sempre, e noi non vogliamo restare bloccati. Vogliamo solo sapere se uno qualsiasi dei nostri figli è terminato, nel qual caso diamo notifica all’utente. La syscall giusta in questo caso è la waitpid(2)

wait_for_terminated_children()
  chiama waitpid in maniera non bloccante
  finché waitpid dice che un processo figlio è terminato, 
    stampa una riga di notifica 

Semplice shell, quinto livello. Implementare pipe e redirezioni (!)

Iniziare con “>”, poi implementare gli altri: “<”, “>>”, “2>”, “2>&1”, “<<”, “|”. Gli ultimi due sono più difficili. Limitarsi al caso di una pipe fra due soli comandi.