Vai alla Home Page About me Courseware Federica Living Library Federica Federica Podstudio Virtual Campus 3D Le Miniguide all'orientamento Gli eBook di Federica La Corte in Rete
 
I corsi di Scienze Matematiche Fisiche e Naturali
 
Il Corso Le lezioni del Corso La Cattedra
 
Materiali di approfondimento Risorse Web Il Podcast di questa lezione

Clemente Galdi » 10.Programmazione in linguaggio C: Input/Output di basso livello


Il linguaggio C

Il linguaggio C è un linguaggio da sempre strettamente legato ai sistemi Unix. Da un lato perché il linguaggio C è nato su sistemi Unix. Dall’altro perché i sistemi Unix (ed I moderni sistemi operativi) sono scritti in larghissima parte in C.
Alcune caratteristiche:

  • Linguaggio ad alto livello
  • Consente semplicemente le operazioni su sequenze di bit, il che lo rende (tra i linguaggi ad alto livello), il più versatile per lo sviluppo di applicazioni che interagiscono con l’hardware.
  • Veloce. Non a caso è il linguaggio utilizzato per sviluppare molti sistemi operativi.

A differenza dei linguaggi di scripting visto finora, il linguaggio C è un linguaggio compilato.
Storicamente il nome del compilatore C su sistemi Unix-like e cc (C Compiler).
Nei sistemi Linux il compilatore di default è il gcc (GNU C Compiler). Questo compilatore è oramai disponibile sulla quasi totalità dei sistemi Unix.
Come nel caso di script shell ed awk, i sorgenti devono essere creati utilizzando un editor di testo (vi/emacs…)
Non utilizzare word processor!
Questo corso NON si prefigge l’insegnamento del linguaggio C. Utilizzeremo questo linguaggio di programmazione per interagire con alcune componenti del sistema operativo.

Il linguaggio C

Un codice sorgente C consiste, tipicamente, di:
Direttive al pre-processore. Una direttiva di questo tipo è identificata dal carattere “#” ad inizio riga.
Tra queste, fondamentali sono le direttive di include:

  • #include <nomefile.h>: Indica al pre-processore di sostituire la riga con il contenuto del file nomefile.h, da ricercare nelle directory di sistema
  • #include “nomefile.h”: Indica al pre-processore di sostituire la riga con il contenuto del file nomefile.h, da ricercare nelle directory corrente (I.e., gli header definiti dall’utente).
  • Utilizzate per “includere” gli header di sistema (e non) che contengono le firme delle system call (definite dal sistema). Le firme vengono utilizzate dal compilatore per verificare la correttezza sintattica del codice.
  • Ad es. Per visualizzare una stringa a video, è possibile utilizzare la funzione “printf”, la cui firma è definita nell’header <stdio.h>.

Una sequenza di funzioni. È sempre necessario definire la firma delle funzioni prima della loro invocazione.
Tra le funzioni, vi è una funzione denominata main da cui inizierà l’esecuzione. Le “firme” standard della funzione main sono:

  • int main(int argc, char **arg): Consente di ottenere i parametri passati all’applicazione dalla linea di comando.
  • int main(void): In questo caso l’applicazione non riceve parametri su riga di comando.

Il linguaggio C (segue)

In applicazioni complesse è opportuno distribuire le funzionalità in più file.
Semplifica lo sviluppo, il debugging ed il riuso del codice.
In ogni applicazione esiste sempre un’unica funzione main

  • Unica per l’applicazione
  • NON “unica per file”.

La verifica della correttezza delle singole funzioni avviene durante la fase di compilazione.
La verifica della consistenza tra le diverse funzioni e l’accorpamento delle diverse funzionalità avviene durante la linking:

  • Ogni sorgente deve include l’header o gli header che contengono le firme di tutte le funzioni utilizzate all’interno di quel codice.
  • NON utilizzare all’interno del codice direttive del tipo:
    • #include “miofile.c”

Il linguaggio C (segue)

Per gcc, il suffisso del nome del file identifica la/le operazione/i che il compilatore esegue:

  • <file>.c: Codice C.
    • Direttive per il pre-processore: Le righe che iniziano per “#” costituiscono esclusivamente direttive per il pre-processore
    • Il codice sorgente.
  • <file>.C (maiuscola): Codice sorgente C++.
  • <file>.h: Header file.
    • Dichiarazioni di costanti, variabili, strutture dati, funzioni.
    • Gli header file NON contengono codice sorgente.

Il processo di compilazione

Per compilazione si intende, impropriamente, la traduzione di un codice sorgente in un eseguibile. In realtà la compilazione è solo una delle fasi di traduzione dal codice sorgente all’eseguibile. Per il linguaggio C, il processo di compilazione, si suddivide nelle seguenti fasi:

  • Fase 1: Pre-processing. La prima fase dell’analisi del codice sorgente è eseguita dal pre-processore il cui compito è l’esecuzione delle direttive a lui indirizzate e la sostituzione del loro risultato all’interno di un nuovo codice sorgente. Il pre-processore, quindi, partendo dal codice sorgente, genera una nuova versione di codice sorgente, che include il risultato delle direttive, che verrà analizzato dal compilatore.
    • Le direttive del pre-processore sono contenute nelle righe che iniziano per “#”. Per le direttive non esiste un “terminatore”, ovvero la direttiva termina a fine riga, I.e., il terminatore è il newline.
    • Possibili direttive sono l’inclusione di un header, la definizione di una costante o di una macro, le direttive condizionali.
  • Fase 2: La compilazione. Con questo termine si indica la traduzione del codice sorgente in un file binario rilocabile, I.e., tutti gli indirizzi indicati nell’eseguibile sono relativi. In questa fase viene controllata, ad esempio, la correttezza sintattica del codice e la compatibilità tra I tipi di dato utilizzati.
    • Se il codice consiste di più file, è possibile richiedere le sole fasi di pre-processing e compilazione.
  • Fase 3: Linking. I diversi moduli oggetto vengono collegati assieme in un unico file, l’eseguibile, Opzionalmente, possono essere incluse in questo file anche alcune librerie di sistema.

La compilazione di applicazioni

Per eseguire l’intero processo di compilazione del programma test1.c è sufficiente eseguire:
gcc test1.c
Il nome di default del file eseguibile generato da gcc è a.out
È possibile indicare un nome di file eseguibile diverso utilizzando l’opzione -o
Se l’applicazione è distribuita in più file sorgente, è sufficiente eseguire il seguente comando:

  • gcc -o nomefile file1.c file2.c…
  • In questo caso, il nome del programma eseguibile è nomefile
  • Si noti che con questa strategia, se l’applicazione è molto complessa, la modifica anche di un solo file sorgente richiede la ricompilazione di tutti i sorgenti

La compilazione di applicazioni (segue)

È possibile richiedere a gcc, con l’opzione -c, la sola fase di “compilazione” del codice, in cui il compilatore verifica la sola correttezza sintattica del sorgente, senza verificare la presenza di una implementazione di tutte le funzioni utilizzate e, successivamente, richiedere il linking dei diversi file object. È possibile quindi suddividere l’unica invocazione di gcc indicata sopra, come segue:

  • gcc -c file1.c -o file1.o
  • gcc -c file2.c -o file2.o
  • gcc -o nomefile file1.o file2.o…

In questo modo, la modifica di “filei.c” richiede la ricompilazione del solo filei.c e l’esecuzione dell’operazione di linking.

Il debugger gdb

Un debugger è una applicazione che consente l’esecuzione controllata del codic eseguibile.
L’applicazione gdb è il debugger di default su sistemi Linux.
Per poter utilizzare gdb in modo “user-friendly” è necessario:

  • Compilare il codice sorgente utilizzando l’opzione -g2.
    • Crea collegamenti tra il codice eseguibile e le corrispondenti linee di codice nel sorgente;
  • Avere codice sorgente ed eseguibile nella stessa directory.
    • Consente a gdb di visualizzare l’istruzione che sta per eseguire.

Il debugger gdb (segue)

Alcuni comandi base:

  • Un breakpoint forza una interruzione del codice.
  • gdb consente di impostare breakpoint in qualunque punto del codice sorgente utilizzando il comando “break”.
  • break nomefunzione: interrompe il programma ogni volta che viene invocata la funzione nomefunzione, e.g., break main.
  • break numeroriga: interrompe il programma ogni volta che viene eseguita l’istruzione alla riga indicata, e.g., break 100.
  • Il comando cont fa ripartire l’esecuzione dopo un breakpoint.
  • Il comando step esegue la prossima riga di codice. Se l’istruzione da eseguire è una funzione, gdb esegue la prima istruzione della funzione e blocca di nuovo l’esecuzione.
  • Il comando next esegue la prossima riga di codice. Se la riga contiene l’invocazione di una funzione, l’intera funzione viene eseguita senza bloccare l’esecuzione.
  • Il comando print consente di visualizzare il contenuto delle variabili.

Gdb fornisce, inoltre, la possibilità di eseguire debugging di applicazioni multi-processo e multi-thread.

Le variabili in C

Alcuni esempi di definizione di variabili: Mostra codice
Commenti e definizione di costanti: Mostra codice

Tutte le variabili devono essere dichiarate prima del loro uso.
Ogni variabile è visibile solo all'interno del blocco in cui è dichiarata.
Il C fornisce i seguenti tipi di dato base: char (carattere), int, short, long (interi), float, double (numeri con virgola).
È possibile costruire tipi di dato complessi tramite il costrutto struct. Una struttura è un agglomerato di elementi I cui tipi possono essere tipi di dato base o strutture.
È possibile definire array di elementi sia di tipo base che array di strutture.
Attenzione: Il primo elemento dell'array in C è l'elemento zero. Quindi gli elementi di un array X di 10 elementi sono X[0],...,X[9].
A differenza di awk, tutti gli elementi dell'array sono sempre dello stesso tipo. Inoltre la dimensione dell'array deve essere nota all'atto della dichiarazione (allocazione statica), o comunque, prima del suo utilizzo (allocazione dinamica).

NON esiste in C il tipo "stringa". Una stringa è un array di caratteri terminato dal carattere nullo ('\0').

I puntatori in C

Una delle caratteristiche del C è la possibilità di dichiarare puntatori a variabili.
Un puntatore non è altro che una variabile che contiene l’indirizzo di memoria di a cui è memorizzata un’altra variabile.
La dichiarazione di un puntatore avviene utilizzando la sintassi: tipo *nome; dove il tipo può essere semplice o composto.
Per ottenere l’indirizzo di una variabile è possibile utilizzare la scrittura: &nomevar.
L’assegnazione *nomevar=val, assegna alla variabile puntata da nomevar il valore val.
Attenzione: la dichiarazione di un puntatore NON alloca lo spazio per contenere il valore della variabile. Un puntatore, quindi, deve essere utilizzato:
Per referenziare una variabile allocata staticamente
Per referenziare una variabile allocata dinamicamente, solo successivamente alla sua allocazione (attraverso le system call malloc e calloc).

Un esempio di utilizzo dei puntatori: Mostra codice.
Un secondo esempio di utilizzo dei puntatori su variabili dinamiche: Mostra codice.

Aritmetica dei puntatori

Per definizione, il nome di un array, in C, corrisponde all’indirizzo della prima locazione di memoria utilizzata dall’array.
Il C definisce anche una aritmetica dei puntatori. Se, ad esempio, p è un puntatore ad una variabile di che occupa x byte, allora p++ corrisponde al valore di p incrementato di x.
L’aritmetica dei puntatori consente un accesso semplice anche agli elementi di un array. Così, se p è il nome di un array, p+k è il puntatore al k-mo elemento dell’array. Di nuovo, si noti che NON è necessario sapere quanto spazio occupa un singolo elemento dell’array per calcolare l’indirizzo del k-mo elemento dell’array.
Il codice: Mostra codice, riporta un esempio delle possibilità offerte dall'aritmetica dei puntatori
Il programma dichiara due array. Il primo è un array di strutture dichiarato staticamente. Il secondo è un array di interi, dichiarato dinamicamente.
È possibile osservare che indipendentemente dal tipo di dato o dal tipo di dichiarazione, è possibile accedere agli elementi dell'array sia utilizzando la tecnica "classica", indicando l'indice dell'elemento tra parentesi quadre, che utilizzando il nome dell'array come puntatore al primo elemento dell'array ed utilizzando l'aritmetica dei puntatore per spostarsi al suo interno.

I blocchi di codice ed il costrutto if

Il terminatore o delimitatore di istruzioni è il “;”. È possibile utilizzare il terminatore “,” ma con un significato specifico.
A differenza di awk, il delimitatore in C è obbligatorio al termine di ogni istruzione.
Le parentesi { } delimitano un blocco di istruzioni. Non è necessario utilizzare il ; dopo la fine di un blocco, I.e., dopo una “}”.
È necessario delimitare i blocchi contenenti almeno due istruzioni
È opzionale delimitare i blocchi contenenti un’unica istruzione (es. “if”)
Nel seguente esempio: Mostra codice, la sintassi del costrutto if-then-else.
La condizione può essere una qualsiasi espressione. In C, come awk, un valore pari a zero o NULL viene interpretato come falso; un valore non-nullo viene interpretato come vero.
La lista-then viene eseguita se la condizione è vera. Altrimenti, sei blocco else è presente, viene eseguita la lista-else.

I costrutti di iterazione: for, while e do-while

Il costrutto while consente di iterare l’esecuzione di una lista di istruzioni in funzione di una condizione. Le istruzioni vengono eseguite se e solo se la condizione di controllo è vera.
A differenza del costrutto while, il do-while verifica la condizione dopo aver eseguito il blocco di istruzioni. In questo caso, quindi, la lista di istruzioni viene eseguita sempre almeno una volta.
Attenzione: NON utilizzare il delimitatore “;” tra prima della “{“. In questo caso, il compilatore considererebbe il costrutto while terminato prima dell’inizio del blocco di istruzioni, generando, con alta probabilità, un ciclo infinito.
Il costrutto for è un secondo costrutto di iterazione, la cui sintassi è indicata nel seguente esempio: Mostra codice. Utilizza tre espressioni. La prima di inizializzazione, la seconda per il controllo dell'interazione e la terza per l'aggiornamento dell'espressione di controllo.
A differenza di awk, le espressioni expr1 ed expr3 possono essere arbitrarie ed utilizzare un numero arbitrario di variabili.

Un esempio di utilizzo di for e while: Mostra codice

Gli operatori in C

Esempi di operatori e loro composizione: Mostra codice
Il linguaggio C fornisce i seguenti tipi di operatori:

  • Operatori relazionali. È possibile utilizzare questi operatori sui soli tipi di dato base, NON per le stringhe. Maggiore (>), Maggiore o uguale (>=), minore (<), minore o uguale (<=), uguale (==), diverso (!=)
    • Attenzione: L'operatore di relazione "==" è DIVERSO dall'operatore di assegnazione "=". Errore comune è utilizzare il secondo in luogo del primo.
  • Operatori logici, per la composizione di relazioni booleane: and (&&), or (||) e not (!)
  • Operatori di pre e post incremento (++) e decremento (--)
  • Operatori orientati ai bit per la manipolazione di stringhe di bit. And (&), or (|), xor (^), shift a sinistra (<<) e a destra (>>), complemento a uno (~).

Le funzioni

Quando una sequenza di istruzioni deve essere utilizzata in più punti del codice è possibile raggrupparla in un blocco di codice e definire una funzione.
È opportuno dichiarare sempre le funzioni utilizzate all’interno di ogni file. Semplifica la correzione degli errori legati ai tipi. Possibilmente tramite l’header.
La firma di una funzione include:

  • Tipo del valore tornato dalla funzione
  • Nome della funzione
  • Elenco dei tipi dei parametri. Si noti che, all’atto della dichiarazione, NON è richiesto indicare il nome del parametro formale.

Il passaggio dei parametri può avvenire:

  • Per valore: eventuali modifiche del valore del parametro all’interno della funzione NON hanno effetto al suo esterno. Ad es., int fun(int a);
  • Per riferimento: Eventuali modifiche del valore del parametro all’interno della funzione hanno effetto sul valore della variabile nella funzione chiamante. Ad es. int fun(int *a);

Un esempio di definizione di funzione: Mostra codice

I puntatori a funzione

In C è possibile definire anche puntatori a funzioni, che possono essere assegnati, inseriti in array etc., proprio come se fossero puntatori a variabili. I puntatori a funzioni rendono possibile scrivere una modulo, indipendentemente dal comportamento che alcune sue componenti avranno o dal tipo di dato che processeranno. Un semplice esempio: Mostra codice; Mostra codice.
La funzione ps prende in input due puntatori a void e stampa il primo ed il secondo parametro dopo averli convertiti in interi.
La funzione sp prende in input due puntatori a void e stampa il secondo ed il primo parametro dopo averli convertiti il primo in intero ed I secondo in float.
La funzione stampa prende in input tre parametri.
Il primo, ordine, è un puntatore ad "una funzione di tipo void che riceve come parametri due puntatori di tipo void".
Gli altri due parametri, x ed y, sono due puntatori di tipo void.
All'interno di stampa, l'invocazione della funzione, avviene semplicemente, utilizzando il nome del parametro formale ordine.
All'interno del main, l'invocazione della funzione stampa avviene passando come primo parametro il nome della funzione da utilizzare.
Si noti che la firma delle funzioni "parametro" (ps e sp nell'esempio) devono coincidere con la firma del parametro (il primo parametro della funzione stampa). D'altro canto, è sempre possibile combinare l'uso di puntatori a variabili void, per soddisfare il matching dei tipi, ed il casting esplicito, per convertire I tipi di dato all'interno delle funzioni.

L’I/O non bufferizzato

Finora abbiamo considerato le funzioni di input o di output come “equivalenti” tra loro. In realtà, dal punto di vista del sistema operativo, esistono classi diverse di funzioni di I/O.
Alcune funzioni di I/O standard utilizzano, per questioni di performance, il buffering dei dati, I.e., utilizzano un’area di memoria per memorizzare temporaneamente le informazioni prima di inviarle effettivamente al device. In questo modo si riduce il numero di system call “effettive”.
Esistono tre tipi di buffering:

  • Completo: L’I/O effettivo avviene solo al riempimento del buffer. Questo tipo di buffering è utilizzato, ad es., per l’accesso ai dischi e NON è mai utilizzato per I device interattivi.
  • Buffering a linea: L’I/O viene eseguito non appena viene inserito nel buffer un carattere newline ‘\n’ (o al riempimento del buffer o al termine del processo). Utilizzato, ad esempio, nella printf.
  • Senza buffering: L’I/O avviene immediatamente. Utilizzato, ad esempio, per lo standard error, in cui è necessario fornire immediatamente all’utente informazioni circa un errore che si è verificato, indipendentemente dall’impatto che l’I/O avrà sulle performance.

Le funzioni di I/O tipo scanf, printf, fprintf sono funzioni di I/O, di norma, bufferizzate.
In questo corso focalizzeremo l’attenzione sull’I/O non bufferizzato.

L’I/O non bufferizzato (segue)

La maggior parte delle operazioni sui file ordinari in ambiente UNIX possono essere eseguite utilizzando solo le cinque chiamate di sistema open, read, write, lseek, close.
Prima di eseguire una operazione su un file e’ necessario “aprire” il file. Ad ogni file aperto, il kernel associa un identificativo detto file descriptor.

  • Il file descriptor è un intero non-negativo;
  • I file descriptor vengono assegnati per processo. In altri termini, i file associati ai file descriptor di processi diversi sono indipendenti. Ad esempio, tutti i processi nel sistema utilizzano il file descriptor uno, assegnato, per default, allo standard output. Tuttavia, ogni processo avrà il proprio device di output.
  • Quando un file viene aperto, il kernel ritorna il minimo file descriptor inutilizzato dal processo

La shell nei sistemi Unix assegna, per convenzione I seguenti file descriptor:

  • 0: standard input
  • 1: standard output
  • 2: standard error.

In luogo degli interi, e per garantire la portabilità del codice, è possibile utilizzare le costanti simboliche STDIN_FILENO (0), STDOU_FILENO (1) e STDERR_FILENO (2) definite nell’header di sistema unistd.h

La funzione open

#include
#include
#include
int open(const char *pathname, int oflag);
int open(const char *pathname, int oflag, mode_t mode);

La system call open consente sia di aprire un file già esistente che di creare un file nel caso in cui questo non esista;
Può essere invocata con due o tre parametri. Restituisce il file descriptor o -1 in caso di errore;
Il file descriptor ritornato è il minimo file descriptor non utilizzato dal processo.
Attenzione: il kernel riutilizza I file descriptor!
Il parametro pathname contiene il nome del file da aprire, identificato attraverso un pathname (assoluto o relativo);
Il parametro oflag permette di specificare le opzioni di apertura (che vedremo nella prossima slide), mediante costanti definite in , combinate attraverso il simbolo “|” (OR);
L’ultimo parametro, mode, è opzionale e viene utilizzato solo nel caso di creazione del file. Consente di definire, all’atto della creazione, la politica d’accesso al file.

La funzione open: I flag di apertura

Di seguito riportiamo possibili valori di oflag. Si tenga conto che su diversi sistemi Unix possono esistere flag di apertura specifici.

Il parametro oflag deve necessariamente contenere esattamente uno dei seguenti:

  • O_RDONLY: Apre il file in sola lettura.
  • O_WRONLY: Apre il file in sola scrittura.
  • O_RDWR: Apre il file in lettura e scrittura.

La funzione open: I flag di apertura (segue)

I successivi flag sono opzionali:

  • O_APPEND: Apre il file in “append”. Tutte le operazioni di scrittura avverranno a fine file.
  • O_CREAT: Crea il file se esso non esiste. Richiede che open sia utilizzata con l’argomento mode per la specifica del protezioni sul file.
  • O_EXCL: Genera un errore se è stata specificata anche l’opzione O_CREAT e se il file già esiste.
  • O_TRUNC: Se il file esiste e se viene richiesta l’apertura in scrittura o lettura-scrittura, tronca il file a zero byte, I.e., la open cancella il contenuto del file.
  • O_NOCTTY: Se pathname si riferisce ad un terminale, non lo alloca come terminale di controllo per il processo.
  • O_NONBLOCK: Se pathname si riferisce ad una FIFO o ad un file di dispositivo, definisce la modalità non-blocking per l’apertura e l’I/O sul file.
  • O_SYNC: Specifica che ogni chiamata write deve attendere per il completamento dell’I/O sul dispositivo fisico.

È possibile comporre diversi flag utilizzando l’operatore OR. Ad esempio: O_RDWR | O_APPEND | O_CREAT | O_TRUNC

La funzione open: il parametro mode

Quando il file non esiste, e se viene specificato il parametro O_CREAT, il parametro mode consente di definire la politica di accesso (le protezioni) al file che si sta creando.
Come abbiamo visto nella lezione 1, le protezioni per il file sono definite con tre triple di bit r (read), r (write) , x (execute), rispettivamente per il proprietario, il gruppo e gli “altri”.
Il parametro mode consente di specificare ogni singolo bit della tripla attraverso l’OR delle seguenti costanti. Per comodità, sono presenti tre costanti, una per ogni tripla, che consente di abilitare, contemporaneamente, I tre bit della tripla.

La funzione open: il parametro mode (segue)

  • S_IRWXU: Permesso r, w, x per il proprietario
  • S_IRUSR: Permesso r per il proprietario
  • S_IWUSR: Permesso w per il proprietario
  • S_IXUSR: Permesso x per il proprietario
  • S_IRWXG: Permesso r, w, x per il gruppo
  • S_IRGRP: Permesso r per il gruppo
  • S_IWGRP: Permesso w per il gruppo
  • S_IXGRP: Permesso x per il gruppo
  • S_IRWXO: Permesso r, w, x per gli altri
  • S_IROTH: Permesso r per gli altri
  • S_IWOTH: Permesso w per gli altri
  • S_IXOTH: Permesso x per gli altri

Esempio: Per consentire l’accesso in lettura, scrittura ed esecuzione al proprietario ed al gruppo ed il solo permesso in lettura agli altri, è possibile utilizzare il seguente valore per il parametro mode:
S_IRWXU | S_IRWXG | S_IROTH

La funzioni creat e close

#include
#include
#include
int creat (const char *pathname, mode_t mode);

In taluni casi il programma deve creare nuovi file, I.e., aprire file che sicuramente non esistono. Esempio classico è l’apertura di un file temporaneo che, nella maggior parte dei casi, è proprio del processo e viene cancellato al termine di una operazione.
La system call creat (non “create”) crea un nuovo file aprendolo in sola scrittura.
È equivalente a: open(pathname, O_WRONLY | O_CREAT | O_TRUNC, mode);
Restituisce il file descriptor del file o -1 in caso di errore

#include
int close(int filedes);

Chiude il file identificato da filedes e precedentemente aperto con open o creat.
Ritorna 0 in caso di successo o -1 in caso di errore.

Offset di file

Ad ogni file aperto è associato un intero, detto offset, che rappresenta la posizione (espressa in numero di byte dall’inizio del file) in cui verrà effettuata la prossima operazione di I/O
L’offset è inizializzato, per default, a zero dalla open e dalla creat.
Anche l’opzione O_APPEND inizializza l’offset a zero per le sole operazioni di lettura. La prima scrittura, però, sposta l’offset a fine file.
Le operazioni di lettura e scrittura incrementano il valore dell’offset di un numero di byte pari al numero di byte letti o scritti. È possibile modificare l’offset del file utilizzando la seguente system call:

off_t lseek (int filedes, off_t offset, int whence);

Lseek ritorna il nuovo valore dell’offset o -1 in caso di errore:
Il parametro whence indica come utilizzare il parametro offset:

  • SEEK_SET: L’offset corrente è posto a offset byte dall’inizio del file.
  • SEEK_CUR: L’offset corrente è incrementato di offsetbyte.
    • Il valore del parametro offset può essere sia positivo che negativo
  • SEEK_END: L’offset è posto a offsetbyte dalla fine del file.
    • Il valore del parametro offset può essere sia positivo che negativo.

Ad esempio, per conoscere l’offset corrente, è sufficiente eseguire: lseek(filedes, (off_t)0, SEEK_CUR);

Lettura da file

La lettura da file può essere effettuata utilizzando la system call read, definita come segue:
#include
ssize_t read (int filedes, void *buff, size_t nbytes);

I parametri della system call sono:

  • Il file descriptor del file su cui eseguire l’I/O;
  • Un puntatore all’area di memoria in cui memorizzare le informazioni lette;
  • Il numero di byte da leggere.

Si noti che il secondo parametro è un void *, I.e., è un puntatore “generico”. Questo consente di leggere attraverso la read qualsiasi tipo di dato. È possibile leggere, quindi, una qualsiasi sequenza di byte (contigui) nel file ed assegnarla ad un’area di memoria a partire dall’indirizzo indicato da buff.
Attenzione: possono verificarsi problemi di compatibilità tra i tipi su architetture non compatibili.
Ad esempio, si supponga che le architetture A e B rappresentino lo stesso tipo di dato utilizzando un diverso numero di byte od una diversa rappresentazione in memoria, e.g. little-endian e big-endian. In questo caso, un file scritto su una architettura A potrebbe essere interpretato erroneamente su una architettura B.
Non esistono problemi di compatibilità quando in un file vengono scritti solo variabili di tipo carattere (o array di caratteri).

Lettura da file (segue)

L’operazione di lettura avviene sempre partendo dall’offset corrente del file. La system call read ritorna:

  • il numero di byte effettivamente letti;
  • 0 se la lettura avviene alla fine del file;
  • -1 in caso di errore.

Il numero di byte effettivamente letti può essere inferiore al parametro nbytes quando:

  • Il numero di byte ancora presenti nel file è inferiore ad nbytes.
  • La lettura avviene da un terminale.
  • La lettura avviene da un buffer di rete.
  • La lettura avviene da una pipe o una FIFO.
  • L’operazione è interrotta da un segnale.

Lettura da file (segue)

Dopo l’operazione l’offset viene incrementato di nbytes.
Attenzione: Il linguaggio C NON esegue alcun controllo sulle dimensioni delle variabili. Ad esempio, se il buffer utilizzato per la read è un intero (4 byte su architettura Intel x86), viene richiesta la lettura di 200 byte e questi sono tutti disponibili (i.e., il valore di ritorno della read e’ 200), la system call, “semplicemente” utilizza i 4 byte della variabile intera e tenta di sovrascrivere i successivi 196. Se questi non sono allocati all’utente, allora il sistema operativo bloccherà il tentativo di accesso alla memoria, altrimenti, i dati (dell’utente) saranno modificati. Quest’ultimo caso porta a comportamenti anomali del programma, difficilmente identificabili.

Scrittura in un file

La scrittura in un file può essere effettuata utilizzando la system call write, definita come segue:

#include
ssize_t write (int filedes, void *buff, size_t nbytes);

I parametri della system call sono:

  • Il file descriptor del file su cui eseguire l’I/O
  • Un puntatore all’area di memoria contenente le informazioni da scrivere nel file
  • Il numero di byte da scrivere.

Come nel caso della read, il parametro buff e’ ti tipo void *. È possibile scrivere quindi su file qualunque tipo di dato.
Valgono, per la write, le stesse considerazioni sulla compatibilità di file scritti su architettura non compatibili.
Anche la system call write può scrivere un numero di byte inferiore a nbytes. È sempre necessario verificare il valore di ritorno delle system call per risolvere eventuali problemi sorti durante la loro esecuzione.

File sharing

I sistemi operativi consentono la condivisione tra più processi dei file aperti
Il kernel usa tre strutture dati indipendenti per gestire i file:

  1. Ogni processo mantiene la lista dei propri file descriptor
  2. Il kernel memorizza la lista di tutti i file aperti (file table), contente, tra l’altro, l’offset corrente per il “file”.
  3. Ad ogni file aperto è associato un v-node/i-node che contiene le informazioni sul file specifico, e.g., proprietario, dimensione, puntatori ai dati su disco, etc.

File sharing (segue)

Se due processi indipendenti aprono lo stesso file, condividono solo il v-node/i-node, come mostrato in figura.
Le informazioni sullo “stato” sono contenute nella file table. Le sytem call read, write e lseek modificano, quindi, solo l’offset per il processo che le invoca.
Se il processo 1 apre il file CON O_APPEND: prima di ogni write, eseguita dal processo 1, il sistema operativo rilegge la dimensione del file dal v-node e scrive il suo valore nell’offset corrente; se anche un altro processo modifica la dimensione del file, l’utilizzo di questa opzione garantisce la scrittura delle informazioni alla fine “effettiva” del file.
Se il processo 1 apre il file SENZA O_APPEND: sembrerebbe possibile simulare il processo appena descritto eseguendo un posizionamento a fine file seguito “immediatamente” da una write: questa sequenza può NON essere equivalente ad una write eseguita con O_APPEND. Difatti, lo scheduler potrebbe interrompere il processo 1 subito dopo la seek ed eseguire il processo 2 che potrebbe modificare la dimensione del file. Quando lo scheduler riprende l’esecuzione del processo 1, viene eseguita la seek, utilizzando un valore dell’offset non consistente con lo l’effettivo stato del file.

Strutture dati utilizzate per l’interazione con i file.

Strutture dati utilizzate per l'interazione con i file.


File sharing: un esempio

Il programma di seguito modifica il file testfile: Mostra codice.

Il programma: apre il file in append e, all'interno di un ciclo, legge un carattere x, compone una stringa come x seguita da "aaaaaaaa\n" e scrive la stringa nel file. Visto che il file è aperto in append, se anche spostiamo l'offset "ad inizio file", la stringa viene sempre scritta a fine file.
Si noti che ogni write incrementa l'offset del file di 10 byte.
Il programma termina quando l'utente digita il carattere f.
L'esempio di seguito: Mostra codice, riporta l'output del programma quando l'input sono I carattere 1,2,3,4,5,f.

File sharing: un esempio (segue)

Una seconda variante dello stesso programma: Mostra codice
Il programma: apre il file NON utilizzando il flag O_APPEND. Posizione, quindi, l'offset a fine file e, all'interno di un ciclo, legge un carattere x, compone una stringa come x seguita da "bbbbbbbb" e scrive la stringa nel file. Visto che all'interno del ciclo l'offset del file non viene modificato da una seek, il programma, scrive la stringa sempre "a fine file".
Il programma termina quando l'utente digita il carattere f.
Di seguito l'output del programma quando l'input è costituito dai caratteri 1,2,3,4,5,f. : Mostra codice

File sharing: un esempio (segue)

I due programmi appena visti, eseguiti in isolamento sono corretti. Nel momento in cui vengono eseguiti in concorrenza e sullo stesso file, esibiscono un comportamento inatteso.
Mostra codice: il file testfile dopo la seguente sequenza di esecuzione riportata di seguito. Tra parentesi indichiamo l'intervallo dei caratteri in input:
Esegui A; Esegui B; A scrive 3 stringhe (1-3); B scrive 5 stringhe (a-e); A scrive 5 stringhe (4-8).
Quando A e B partono, ogni processo punta al proprio elemento nella file table, ma entrambi gli elementi puntano allo stesso v-node (stesso file). Assumendo il file inizialmente vuoto, entrambi I processi inizializzano il proprio offset al byte 0, i.e. Off_A=0, Off_B=0. Si noti che la presenza del getchar, forza i processi ad attendere l'input dall'utente.
Quindi A scrive le prime 3 righe del file, a seguito dell'input dei caratteri 1-3. La scrittura modifica l'offset del solo processo A, I.e., Off_A=30, Off_B=0.
Quando B scrive 5 righe a seguito dell'input dei caratteri a-e, il sistema operativo utilizza l'elemento della file table indicato da B, in cui l'offset è ancora 0. Le 5 righe scritte da B, quindi, sovrascrivono le righe scritte da A. A questo punto, Off_A=30, Off_B=50.
Ora A scrive altre 5 righe. Avendo, però, il processo A aperto il file con O_APPEND, prima di ogni write, il sistema operativo aggiorna l'offset di A ponendolo a "fine file". La prima scrittura, quindi, aggiorna il valore dell'offset Off_A=50 prima dell'esecuzione dell'operazione di I/O.

File sharing: un esempio (segue)

Vediamo ora il comportamento degli stessi programmi quando invertiamo l’ordine delle scritture. La nuova sequenza di esecuzione e’ la seguente:
Esegui A; Esegui B; B scrive 3 stringhe (a-c); A scrive 5 stringhe (1-5); B scrive 3 stringhe (a-c).
Come nel caso precedente, assumendo il file inizialmente vuoto, entrambi I processi inizializzano il proprio offset al byte 0, i.e. Off_A=0, Off_B=0.
Quindi B scrive le prime 3 righe del file, a seguito dell’input dei caratteri a-c. La scrittura modifica l’offset del solo processo B, I.e., Off_A=0, Off_B=30.
Quando A scrive 5 righe a seguito dell’input dei caratteri 1-5, avendo aperto il file con il parametro O_APPEND, il sistema operativo aggiorna il valore di Off_A a 30 prima di iniziare la scrittura. Al termine delle scritture, I valori degli offset sono Off_A=80, Off_B=30.
Infine, la scrittura delle tre righe da parte del processo B avviene a partire dal suo offset corrente, 30. Quindi l’effetto delle tre scritture di e’ la sovrascrittura delle prime tre dighe scritte da A.
Il file testfile dopo la sequenza di operazioni indicata: Mostra codice.

Duplicazione di file descriptor

Un file descriptor può essere “duplicato” utilizzando una delle seguenti system call:
int dup (int filedes);
int dup2(int filedes, int filedes2);

La duplicazione consiste nel far puntare due file descriptor (dello stesso processo) allo stesso elemento della file table (lo stesso file).
La system call dup ritorna un file descriptor che punta allo stesso file indirizzato da filedes. Il valore ritornato da dup è il minimo file descriptor non utilizzato.
La system call dup2 prende in input anche un secondo parametro, filedes2 che contiene il file descriptor da usare nella duplicazione. Se filedes2 è aperto, dup2 chiude il file ad esso associato prima di duplicare il descrittore filedes.
Nell’esempio, il file puntato da filedes2 viene chiuso, dopodichè l’entry filedes2 della process table viene fatto puntare all’entry della file table a cui punta filedes. In alcuni casi dup2 potrebbe essere simulata attraverso una sequenza di open, close e dup. Si tenga conto, però, che a differenza delle simulazioni, la system call dup2 viene eseguita atomicamente. L’utilità di dup e dup2 consiste nel fatto che è possibile implementare semplicemente la redirezione all’interno di una applicazione.

Lo stato delle strutture dati del kernel prima della dup2.

Lo stato delle strutture dati del kernel prima della dup2.

Le strutture dati del kernel dopo la dup2(filedes, fildes2);

Le strutture dati del kernel dopo la dup2(filedes, fildes2);


Duplicazione di file descriptor (segue)

Frammento di codice di una variante di uno dei programmi appena illustrati: Mostra codice.
A differenza della versione originale, questo programma invia su standard output un messaggio "Comando:" e scrive su testfile stringhe contenenti il primo carattere digitato dall'utente concatenato alla stringa "aaaaaaaaa\n".
La presenza, però, di dup2 implica:

  • La chiusura del "file" standard output
  • La redirezione dello standard output sul file indicato da fd.

L'effetto di dup2 sarà, quindi, che ogni operazione di I/O su standard output corrisponderà, in realtà, ad una scrittura su file "testfile".
Difatti, l'esecuzione del frammento di codice con input gli interi 1-5 ed il carattere f, provoca la scrittura nel file "testfile" delle righe riportate nel seguente esempio: Mostra codice.

I materiali di supporto della lezione

Brian W. Kernighan, Dennis KM. Ritchie, Linguaggio C

W.R. Stevens, S.A. Rago, Advanced Programming in the Unix Environment

Capitolo 3 (3.1/3.10)

gnu.org

  • Contenuti protetti da Creative Commons
  • Feed RSS
  • Condividi su FriendFeed
  • Condividi su Facebook
  • Segnala su Twitter
  • Condividi su LinkedIn
Progetto "Campus Virtuale" dell'Università degli Studi di Napoli Federico II, realizzato con il cofinanziamento dell'Unione europea. Asse V - Società dell'informazione - Obiettivo Operativo 5.1 e-Government ed e-Inclusion