APPUNTI DI SISTEMI OPERATIVI 1 PER LE ESERCITAZIONI IN LABORATORIO

PARTE 1: INTRODUZIONE ALLE CHIAMATE DI SISTEMA

**************************************

ALCUNE SYSTEM CALLS PER GESTIONE FILES

**************************************

Forniscono I/O a basso livello. Le usiamo come primi esempi perché si tratta di operazioni (aprire un file, leggere o scrivere su file) il cui significato è noto anche a chi non conosce ancora molto dei sistemi operativi.
É bene sapere che esistono, ma tipicamente per operare su files è meglio usare le funzioni di libreria, che sono implementate con le chiamate di sistema ma offrono diversi vantaggi (si vedranno più avanti alcune differenze).
Le chiamate di sistema operano sui files tramite "descrittori di file": numeri interi usati come indice nella tabella dei file aperti. I descrittori 0, 1, 2 corrispondono ai file che costituiscono i cosiddetti "stream" per lo standard input, standard output e standard error di un programma.
Lo standard input, per default associato alla tastiera (quando la finestra selezionata è quella da cui è stato fatto partire il programma), è il file da cui leggono funzioni come getchar e scanf, mentre lo standard output, per default associato alla finestra in cui il programma è stato lanciato, è quello su cui scrivono funzioni come putchar e printf.
Lo stream per lo "standard error" esiste per dare la possibilità di separare l'output "normale" di un programma e i messaggi di errore.

Le seguenti sono descrizioni delle chiamate di sistema molto semplificate rispetto a quelle che si trovano nel "man":
OPEN:
	int open(const char *pathname, int flags, /*  mode_t  mode  */ ...);
Apre il file avente pathname "pathname" con modalità di apertura "flags"; restituisce un descrittore di file.
Con il bit opportuno settato in "flags" crea il file, se non esiste, con i diritti di accesso passati in "mode".

CREAT:
	int creat(const char *pathname, mode_t mode);
Crea il file avente pathname "pathname" e diritti di accesso "mode".

READ:
	ssize_t read(int fd, void *buf, size_t count);
Cerca di leggere "count" bytes dal file associato a "fd" (un descrittore restituito da una open precedente) copiandoli in memoria a partire dall'indirizzo "buf"; restituisce il numero di bytes effettivamente letti

WRITE:
	ssize_t write(int fd, const void *buf, size_t count);
Scrive fino a "count" bytes nel file associato a "fd" copiandoli dalla memoria a partire dall'indirizzo "buf"; restituisce il numero di bytes effettivamente scritti.

CLOSE:
	int close(int fd);
Chiude il file associato a "fd".

LSEEK:
	off_t lseek(int fildes, off_t offset, int whence);
sposta il puntatore di lettura/scrittura di "fildes" del numero di bytes "offset" a partire dalla posizione specificata da "whence" (dall'inizio, dalla posizione corrente o dalla fine). Se l'ultima operazione è stata read o write, la posizione corrente del puntatore di lettura/scrittura è il punto (identificato in numero di byte) dove è terminata l'ultima operazione (di lettura o scrittura) sul file aperto, o meglio l'ultima effettuata attraverso l'apertura identificata da "fildes" (su un file vi possono infatti essere più aperture contemporaneamente, ognuna ha un puntatore diverso). 

DUP e DUP2:
	int dup(int oldfd);
	int dup2(int oldfd, int newfd);
copiano il descrittore di file "oldfd" nel primo elemento libero della tabella dei file aperti (nel caso di "dup", che restituisce il descrittore in cui ha copiato) o in "newfd" (nel caso di "dup2")
Sono utilizzate per la ridirezione dei file standard, ad esempio dall'interprete dei comandi per interpretare correttamente comandi come "ls > pippo", in cui si vuole che l'output del comando venga diretto sul file "pippo".

ESERCIZIO 1.1: visualizzare le pagine del "man" relative alle chiamate di sistema open e read.
(a seconda dell'installazione, può essere necessario chiedere esplicitamente con "man -s 2 read" di usare la sezione 2, dedicata alle chiamate di sistema; "man man" illustra il comando man e le sue sezioni)

**********************************

ESEMPI

**********************************

Il seguente programma è un esempio di uso di system calls su files e di gestione di errori di sistema:
:::::::::::::: 
readerr.c
::::::::::::::

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
int f,i,n,ne;
char buf[20];

f=open("fileprova",O_RDONLY);
if (f==-1) {
perror("Errore apertura file");
return(1); /* qualcosa non e' andato bene */
}
else
{
printf("Numero bytes da leggere (max 20): ");
scanf("%d",&n);
if (n>20) n=20;
ne=read(f,buf,n);
printf("letti %d caratteri: ",ne);
for (i=0;i<ne;i++) putchar(buf[i]);
printf("\n");
return(0);
}
}

::::::::::::::

Tutte le system calls (che restituiscono un intero) in caso di errore restituiscono il valore -1 e valorizzano una variabile intera "errno" con un valore diverso per ogni tipo di errore.
Il programma nell'esempio utilizza la funzione  "perror" della libreria standard per ottenere un sintetico messaggio di errore che combina: un messaggio appropriato per ogni tipo di errore (associato al valore della variable "errno") - in questo caso, e uno che passiamo alla "perror".
La variabile "errno" non deve essere dichiarata, ma se la si vuole usare è necessario includere il file <errno.h> che non approfondiamo.
Si noti che i messaggi di errore sono stampati sullo stream standard error con fprintf(stderr, ...) invece che sullo standard output con printf. É bene seguire sempre questa convenzione che dà la possibilità di ridirigere separatamente l'output "normale" di un programma (con "> nomefile") e i messaggi di errore (con "2>  nomefile" nella bash e in alcune altre shell, ">& nomefile" in altre ancora).
Il file errno.h, da includere con la notazione <errno.h>  (che come noto indica che il file da includere si trova in una opportuna directory, tipicamente  /usr/include/, dove vengono messi tutti i file .h delle librerie standard), definisce nomi di costanti per i diversi possibili codici di errore; a questi nomi simbolici si fa riferimento nel man della chiamata di sistema, nella sezione "ERRORS". Ad esempio per open "ENOENT" (che presumibilmente sta per "error: no entry") indica che il file da aprire non esiste (e non è stato chiesto di crearlo con opportuna opzione di open).

ESERCIZIO 1.2: trovare questa informazione nel "man".

I valori possibili di errno e i nomi di costanti possono essere utilizzati per programmare opportunamente il comportamento in caso di errore, specie nel caso si voglia fare qualcosa di diverso dalla segnalazione di un messaggio di errore, o lo si voglia personalizzare:
  if (f==-1) if (errno == ENOENT) ....

ESERCIZIO 1.3: eseguire il programma:  
Si noti che anche per la "read" sarebbe necessario verificare se ci sono errori.
Per non appesantire troppo la scrittura dei programmi che utilizzano le chiamate di sistema, ma d'altra parte non tralasciare la verifica degli errori, si può effettuare quest'ultima in modo uniforme per tutte le chiamate di sistema definendo una funzione per effettuare le chiamate di sistema.
I seguenti files call1.h e call1.c costituiscono un microscopico esempio di definizione di una libreria, in cui definiamo delle funzioni (una sola in questo caso) che possono essere riutilizzate in tanti altri programmi. In questo caso costerebbe poco copiare e incollare la definizione della funzione "call" in tutti i file che la usano e ricompilarla tutte le volte, ma usare file separati facilita la modifica di una libreria.
Si ricorda che per convenzione si usa un file .h "header" (intestazione) che contiene solo la dichiarazione della funzione call (il suo prototipo) e un file .c che ne contiene la definizione.
Il file .c che usa la funzione include il file "call1.h" in modo che il compilatore possa "vedere" la dichiarazione della funzione che viene chiamata in esso, e nella generazione del file eseguibile deve essere effettuato il collegamento con il codice della funzione call.
In questo caso la definizione della funzione è semplice in quanto essa prende semplicemente il risultato della chiamata di sistema, se si è verificato un errore (il risultato è -1) chiama  la "perror" passandogli il messaggio aggiuntivo da stampare passato alla call stessa, e termina l'esecuzione del programma; cioè qualunque errore che occorre in una chiamata di sistema viene considerato "fatale" per il proseguimento del programma, con la chiamata di exit che forza la terminazione del programma. Per convenzione, un valore diverso da 0 (in questo caso, 1) passato ad exit indica la terminazione a causa di un errore. Si vedrà in seguito come si può utilizzare questa informazione.

::::::::::::::
call.h
::::::::::::::
#include <stdlib.h>

int call(int res, char * msg);
::::::::::::::
call.c
::::::::::::::
#include <stdio.h>
#include "call.h"

int call(int res, char * msg)
{
if (res == -1)
{
perror(msg);
exit(1);
}
else return res;
}

::::::::::::::
readerr1.c
::::::::::::::
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include "call.h"

int main(int argc, char *argv[])
{
int f,i,n,ne;
char buf[20];

call(f=open("fileprova",O_RDONLY),"Errore apertura file");
printf("Numero bytes da leggere (max 20): ");
scanf("%d",&n);
call(ne=read(f,buf,n),"Errore Read");
printf("letti %d caratteri: ",ne);
for (i=0;i<ne;i++) putchar(buf[i]);
printf("\n");
return 0;
}

::::::::::::::

Una realizzazione più flessibile potrebbe limitarsi a "nascondere" dentro call solo l'eventuale stampa del messaggio di errore, e lasciare al programma chiamante il compito di decidere se proseguire o no.
In questo caso va utilizzato il valore restitiuito dalla funzione chiamando ad esempio:
call(f=open("fileprova",O_RDONLY),"Errore apertura file");
if (f == -1) ...

**********************************

RIDIREZIONE 

**********************************

Esempio di ridirezione dello standard output:
::::::::::::::
redir.c
::::::::::::::

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
int n=creat("pippo",0600); /* ahi, non controlliamo eventuali errori... */
close(1); /* chiude lo standard output */
dup(n); /* ora il descrittore 1 e' associato a pippo */
close(n);
printf("Hello \n"); /* oppure write(1,"Hello \n",7); */
return 0;
}
::::::::::::::
Al posto di
close(1);
dup(n);
si può usare:
dup2(n,1)
In entrambi i casi la stampa del messaggio, che è effettuata con printf, cioè sullo standard output, ha luogo sul file "pippo".

**********************************

DIFFERENZA TRA L'USO DI SYSTEM CALLS E FUNZIONI DI LIBRERIA PER L'I/O

**********************************

Le funzioni della libreria di I/O standard del linguaggio C sono realizzate mediante chiamate di sistema ed effettuano una "bufferizzazione" dei dati a livello del programma utente; cioè, ad esempio, per la scrittura, usano un buffer (letteralmente "tampone", nell'informatica: una zona di memoria utilizzata per parcheggiare temporaneamente dei dati) in cui vengono accumulati i byte che il processo scrive con printf, putchar e simili; solo in certi momenti il buffer viene svuotato sul file effettuando una chiamata di sistema write:
		int fflush(FILE *stream);
In lettura (es. chiamata di getchar o scanf), viene letto un blocco di dati nel buffer, se questo è vuoto; altrimenti si leggono i dati dal buffer precedentemente riempito.

Per saperne di più consultare ad es. il classico testo sul C di Kernighan & Ritchie, cap.8, e il manuale on line.

Il tipo di bufferizzazione di default dipende dal tipo di dispositivo a cui il file è associato; per file su disco il default è a blocchi, ad es. di 1024 bytes, o 2048, etc; per i terminali, a righe.
Se si vuole cambiarla per un file si può usare la funzione setvbuf (vedere man setvbuf) una volta per tutte subito dopo aver aperto il file. Se si vuole alterare il meccanismo solo in opportuni momenti, usare fflush.

In generale è conveniente usare le funzioni di libreria e non le system calls, perché offrono molte funzionalità in più (es. printf formatta l'output in vari modi, per esempio permette di scrivere valori numerici in formato decimale) e perché si fanno meno chiamate al sistema operativo, che comportano un certo overhead (lavoro di gestione aggiuntivo) dovuto al passaggio alla modalità kernel. Ad esempio è pesante chiamare molte volte read o write per un carattere alla volta, mentre se ad es. si effettuano ripetute chiamate di putchar, solo ogni tanto (quando c'è da svuotare il buffer, quindi una volta ogni 1024, o 2048, etc) queste chiamate daranno luogo a una chiamata di sistema.

Lo si può notare con i due programmi seguenti.
::::::::::::::
loopsc.c
::::::::::::::
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    int n,i;

    if (argc==2) n=atoi(argv[1]); else return 1;

    for(i=0;i<n;i++)
        write(1,"Q",1);

    return 0;
}

::::::::::::::
looplf.c
::::::::::::::

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
int n,i;

if (argc==2) n=atoi(argv[1]); else return 1;

for(i=0;i<n;i++)
putchar('q');
}


Entrambi scrivono ripetutamente un carattere, il primo con la chiamata di sistema, il secondo con la funzione di libreria, per un numero di volte passato come argomento.

ESERCIZIO 1.4. Dopo aver generato gli eseguibili "loopsc" e "looplf", chiamare ad esempio:

    time ./looplf 1000 > /dev/null

    time ./loopsc 1000 > /dev/null

proseguendo con valori più grandi dell'argomento (10000, 100000, 1000000,...) si noterà una differenza sempre più significativa tra i tempi di esecuzione delle due versioni. Il comando "time" esegue il comando sul resto della riga, fornendo il tempo "real" trascorso in totale, il tempo "user" di esecuzione in user mode, il tempo "sys" di esecuzione in kernel mode. Per evitare di generare file enormi, e per non coinvolgere il tempo di esecuzione di vere operazioni su dispositivo (anche su quelle c'è differenza), l'output viene ridiretto su /dev/null, un "finto" dispositivo a cui non corrispondono operazioni, utile in qualche caso (es. una applicazione che genera un "log" di ciò che fa, che occupa troppo spazio rispetto a quello disponibile).

In casi molto particolari ci sono anche delle differenze di risultato, come nei seguenti due programmi, che scrivono alcuni bytes e poi effettuano un ciclo infinito:
::::::::::::::
hellosc.c
::::::::::::::

#include <unistd.h>

int main(int argc, char *argv[])
{
write(1,"Hello \n",7);
for (;;);
}
::::::::::::::
hellolf.c
::::::::::::::

#include<stdio.h>

int main(int argc, char *argv[])
{
printf("Hello \n");
for (;;);
}
Essi sembrano equivalenti: entrambi scrivono e poi devono essere interrotti.
Se però si ridirige l'output dei due programmi su un file, oppure non si stampa il \n, sono ancora equivalenti? Si noti che quando il processo termina perché viene interrotto, il buffer non viene svuotato.

ESERCIZIO 1.5: usare fflush o setvbuf per rendere il loro comportamento analogo anche in questi casi.

In seguito si vedranno esempi più significativi di casi in cui la bufferizzazione causa risultati diversi.