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:
- in presenza e in assenza di un file di nome
"fileprova";
- ridirigendo lo standard error quando "fileprova" non c'è
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:
- quando è pieno;
- per file associati a un terminale (non un disco), quando si ha
una riga intera (cioè quando si scrive un "\n");
- quando il file viene chiuso (con fclose);
- quando il processo (programma) termina di sua iniziativa,
cioè arriva al termine del suo "main", oppure chiama la funzione
"exit";
- quando viene chiamata la funzione:
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.