4. Streams & File Descriptors
Interazione con i file
In UNIX ci sono due modi per interagire con i file: streams e file descriptors.
- Streams: forniscono strumenti come la formattazione dei dati, bufferizzazione, ecc…
- File descriptors: interfaccia di basso livello costituita dalle system call messe a disposizione dal kernel.
Streams
Gli streams sono flussi di dati che possono essere letti o scritti da un file. Utilizzando gli streams, un file è descritto da un puntatore a una struttura di tipo FILE (definita in stdio.h). I dati possono essere letti e scritti in vari modi (un carattere alla volta, una linea alla volta, ecc.) ed essere interpretati di conseguenza.
/%F0%9F%92%BE%20Sistemi%20Operativi/Laboratorio/_images/Streams.png)
Aprire e chiudere un file
FILE *fopen(const char* filename, const char* mode);
La funzione fopen() accetta due argomenti: il primo è il nome del file da aprire (filename) e il secondo è la modalità in cui il file deve essere aperto (mode). La funzione restituisce un puntatore di tipo FILE che può essere utilizzato per gestire il file aperto. Se la funzione non riesce ad aprire il file, restituisce NULL. La modalità specificata da mode determina come il file può essere gestito (ad esempio, se può essere letto o scritto). Questa può essere:
- r: read
- w: write or overwrite (create)
- r+: read and write
- w+: read and write. Create or overwrite
- a: write at end (create)
- a+: read and write at end (create)
Successivamente, per chiudere il file, è possibile utilizzare la funzione fclose():
int fclose(FILE *stream);
La funzione fclose() chiude il file rappresentato dal puntatore stream. Restituisce 0 se la chiusura è avvenuta correttamente, altrimenti restituisce un valore diverso da 0 in caso di errore.
Leggere un file
Vediamo ora una serie di funzioni utili per leggere un file.
fgetc()
int fgetc(FILE *stream)
Legge un carattere dallo stream di input specificato e restituisce il carattere letto, convertendo il carattere in un valore intero in ASCII (questo perché il valore EOF che indica un errore o la fine del file è un intero e non può essere rappresentato da un carattere). Se lo stream è alla fine del file quando viene chiamato, la funzione restituisce EOF e imposta l'indicatore di fine del file per lo stream.
fgets()
char *fgets(char *str, int n, FILE *stream)
Restituisce una stringa da stream e la salva in str (array di caratteri). Si ferma quando n-1 caratteri sono stati letti, una nuova linea (\n) è letta o la fine del file viene raggiunta. Inserisce anche il carattere di terminazione e, eventualmente, ‘\n’..
fscanf()
int fscanf(FILE *stream, const char *format, ...)
Legge da stream dei dati, salvando ogni dato nelle variabile fornite (simile a printf) seguendo la stringa format.
feof()
int feof(FILE *stream)
Restituisce 1 se lo stream ha raggiunto la fine del file, zero altrimenti.
Scrivere su un file
fputc()
int fputc(int char, FILE *stream)
Scrive un singolo carattere char su stream.
fputs()
int fputs(const char *str, FILE *stream)
Scrive una stringa str su stream senza includere il carattere null.
fprintf()
int fprintf(FILE *stream, const char *format, ...)
Scrive il contenuto di alcune variabile su stream, seguendo la stringa format. E molte altre....
Flush e rewind
I buffer sono aree di memoria temporanea in RAM. Quando si legge o si scrive su un file utilizzando gli streams (dei buffer) di dati, le operazioni non avvengono immediatamente sul file. Invece, i dati vengono prima letti o scritti sul buffer e solo successivamente trasferiti sul file. Il processo di scrittura dei dati presenti nello stream nel file corrispondente è noto come “flushing” del buffer.
Il flushing del buffer avviene automaticamente in determinate situazioni, come quando il buffer è pieno o quando il file viene chiuso. Tuttavia, è anche possibile forzare il flushing del buffer manualmente utilizzando la funzione fflush().
Altre situazioni in cui avviene il flushing del buffer sono le seguenti:
- Il programma termina con un return dal main o con exit().
- fprintf() inserisce una nuova riga.
- int fflush(FILE *stream) viene invocato.
- void rewind(FILE *stream) viene invocato.
- fclose() viene invocato
rewind consente inoltre di ripristinare la posizione della testina all’inizio del file.
Esempio 1.
Dato il seguente script in C
#include <stdio.h>
int main(int argc, char const *argv[])
{
FILE *ptr; //Declare stream file
ptr = fopen("filename.txt","r+"); //Open
int id;
char str1[10], str2[10];
while (!feof(ptr)) //Check end of file
{
//Read int, word and word
fscanf(ptr,"%d %s %s", &id, str1, str2);
printf("%d %s %s\n",id,str1,str2);
}
printf("End of file\n");
fclose(ptr); //Close file
return 0;
}
e un file chiamato "filename.txt" contenente le seguenti 3 righe di testo:
1 Nome1 Cognome1
2 Nome2 Cognome2
3 Nome3 Cognome3
L'output è il seguente:
1 Nome1 Cognome1
2 Nome2 Cognome2
3 Nome3 Cognome3
End of file
Esempio 2.
Dato il seguente script in C
#include <stdio.h>
#define N 10
int main(int argc, char const *argv[])
{
FILE *ptr;
ptr = fopen("filename.txt","w+");
fprintf(ptr,"Content to write"); //Write content to file
rewind(ptr); // Reset pointer to begin of file
char string[N], character;
fgets(string,N,ptr); // store the next N-1 chars from ptr in string
printf("%d %s",string[N-1], string); //string[N-1] contains '\0', string[N-2] contains 't', etc
//now the file pointer points to 'o'
character = fgetc(ptr); // return next available char or EOF
while(character != EOF) {
printf("%c",character);
character = fgetc(ptr); // return next available char or EOF
}
printf("\n");
fclose(ptr);
return 0;
}
e un file chiamato "filename.txt", l'output è il seguente:
0 Content to write
Ricordiamo che l'ultimo carattere in un array di caratteri è '\0'.
File Descriptors
La file table è una struttura dati mantenuta dal sistema operativo che tiene traccia di tutti i file aperti. Ogni file aperto ha una corrispondente entry nella file table che contiene informazioni sul file, come la sua posizione sul disco e lo stato corrente dell’indicatore di posizione.
Quando si apre un file utilizzando le system call del sistema operativo, viene creata una nuova entry nella file table per quel file e viene restituito un file descriptor, ovvero un intero che punta a quella entry. Un file descriptor può quindi essere utilizzato per effettuare operazioni di input e output sul file tramite le system call, che utilizzeranno l’entry corrispondente nella file table per tenere traccia dello stato del file.
A differenza delle funzioni di alto livello della libreria standard del C, come fopen() e fread(), le system call forniscono un controllo maggiore sulle operazioni di input e output ma hanno un’interfaccia meno amichevole. Ad esempio, quando si leggono o si scrivono dati utilizzando le system call (come read o write), è necessario specificare la dimensione del buffer e gestire manualmente il trasferimento dei dati tra il buffer e il file.
Quando un processo vuole eseguire operazioni di input o output su un file, deve passare il file descriptor corrispondente al kernel del sistema operativo attraverso una system call. Una system call è una funzione fornita dal sistema operativo che consente a un processo di richiedere l’esecuzione di un’operazione a basso livello, come la lettura o la scrittura di dati su un file. Quando il kernel riceve la system call, utilizza il file descriptor fornito dal processo per identificare il file su cui eseguire l’operazione richiesta. Il kernel accederà quindi al file per conto del processo e eseguirà l’operazione di input o output richiesta.
Per accedere al contenuto di un file bisogna creare un canale di comunicazione con il kernel, aprendo il file con la system call open() la quale localizza l’i-node del file e aggiorna la file table del processo.
A ogni processo è associata una tabella dei file aperti di dimensione limitata, dove ogni elemento della tabella rappresenta un file aperto dal processo ed è individuato da un indice intero (il “file descriptor”).
I file descriptor 0, 1 e 2 individuano normalmente standard input, output ed error (aperti automaticamente).
/%F0%9F%92%BE%20Sistemi%20Operativi/Laboratorio/_images/Canali%20di%20output.png)
Sui sistemi Unix un i-node è una struttura dati che archivia e descrive attributi base su file, directory o qualsiasi altro oggetto. Le informazioni includono la dimensione del file e la sua locazione fisica, il proprietario e il gruppo di appartenenza, le informazioni temporali di modifica, ultimo accesso e di cambio di stato, il numero di collegamenti fisici che referenziano l’inode, i permessi d’accesso e un puntatore allo spazio del disco che contiene i file veri e propri. In poche parole, l’i-node contiene informazioni sul file, ma non il suo contenuto o il suo nome. Quando un processo apre un file con la chiamata di sistema open(), il kernel localizza l’i-node del file e aggiorna la tabella dei file del processo. La chiamata open() restituisce un file descriptor, che è un identificatore univoco per il processo e viene mantenuto globalmente nella tabella dei file descriptor del kernel. Il file descriptor mappa a un oggetto di descrizione del file, ovvero funge da collegamento tra il processo e l’oggetto di descrizione del file nel kernel del sistema operativo. In altre parole, il file descriptor è come un’etichetta o un riferimento che il processo utilizza per identificare il file aperto. L’oggetto del file è popolato con, tra le altre strutture, la struttura inode, inode_operations, file_operations, ecc Una volta che il file è stato aperto e il file descriptor è stato restituito al processo, il processo può utilizzare il file descriptor per accedere al contenuto del file tramite chiamate di sistema come read() e write().
Il kernel gestisce l’accesso ai files attraverso due strutture dati: la tabella dei files attivi e la tabella dei files aperti. La prima contiene una copia dell’i-node di ogni file aperto, ovvero i file che sono attualmente aperti e in uso da parte di uno o più processi. Quando un processo apre un file, il kernel crea una copia dell’i-node del file nella tabella dei file attivi per aumentare l’efficienza nell’accesso al file. In questo modo, il kernel può tenere traccia dei file che sono attualmente in uso e gestirne l’accesso in modo efficiente.
La tabella dei files aperti invece contiene un elemento per ogni file aperto e non ancora chiuso. Questo elemento contiene:
- I/O pointer: posizione corrente nel file
- i-node pointer: Puntatore a inode corrispondente
La tabella dei file aperti può avere più elementi corrispondenti allo stesso file, questo perché ogni processo che apre un file crea un nuovo elemento nella tabella dei file aperti. Questo significa che se più processi aprono lo stesso file contemporaneamente, ci saranno più elementi nella tabella dei file aperti che corrispondono a quel file.
/%F0%9F%92%BE%20Sistemi%20Operativi/Laboratorio/_images/File%20descriptors.png)
Aprire e chiudere un file
int open(const char *pathname, int flags, mode_t mode]);
flags: interi (ORed) che definiscono l’apertura del file. I più comuni:
- O_RDONLY, O_WRONLY, O_RDWR (lettura e scrittura): almeno uno è obbligatorio.
- O_CREAT: crea il file se non esiste (con O_EXCL la chiamata fallisce se esiste)
- O_APPEND: apre il file in append-mode (questo è come effettuare un auto lseek con ogni scrittura). Quando si legge un file aperto con O_APPEND, la posizione di lettura non viene influenzata e si può leggere da qualsiasi posizione usando lseek
- O_TRUNC: cancella il contenuto del file (se usato con la modalità scrittura)
mode: usato per specificare i privilegi da assegnare quando viene creato un file, ma è necessario solo quando si apre un file in scrittura (O_WRONLY o O_RDWR) e il file non esiste ancora. I valori del campo mode sono interi ma solitamente vengono specificati utilizzando le costanti simboliche come:
- S_IRUSR: indica il permesso di lettura per il proprietario del file
- S_IWUSR: indica il permesso di scrittura per il proprietario del file
- S_IXUSR: indica il permesso di esecuzione per il proprietario del file
- S_IRWXU: combinazione delle tre costanti simboliche S_IRUSR, S_IWUSR e S_IXUSR
- S_IRWXG: indica il permesso di lettura, scrittura ed esecuzione per il gruppo
- S_IRWXG indica il permesso di lettura, scrittura ed esecuzione per gli altri
- S_IRGRP: indica il permesso di lettura per il gruppo a cui appartiene il file
- S_IROTH: indica il permesso di lettura per gli altri utenti
- ecc...
Queste costanti possono essere combinate utilizzando l'operatore OR bit a bit (ORed) per creare un valore intero che rappresenta i permessi desiderati.
Per poterle utilizzare è necessario includere sys/stat.h
Tabella valori ottali e costanti simboliche
| Valore ottale | Costanti simboliche |
| 0000 | - |
| 0400 | S_IRUSR |
| 0200 | S_IWUSR |
| 0100 | S_IXUSR |
| 0700 | S_IRWXU |
| 0040 | S_IRGRP |
| 0020 | S_IWGRP |
| 0010 | S_IXGRP |
| 0070 | S_IRWXG |
| 0004 | S_IROTH |
| 0002 | S_IWOTH |
| 0001 | S_IXOTH |
| 0007 | S_IRWXO |
Tabella valori di errno per open
| Valore di errno | Descrizione |
| EACCES | il permesso di accesso al file è negato |
| EEXIST | il file esiste già e il flag O_CREAT e O_EXCL sono stati specificati |
| EINTR | la chiamata a funzione è stata interrotta da un segnale |
| EINVAL | i flag specificati non sono validi |
| EISDIR | si sta tentando di aprire una directory con il flag O_WRONLY o O_RDWR |
| EMFILE | il processo ha raggiunto il limite massimo di file descriptor aperti |
| ENAMETOOLONG | il nome del file è troppo lungo |
| ENFILE | il sistema ha raggiunto il limite massimo di file aperti |
| ENOENT | il file o la directory specificati non esistono |
| ENOSPC | non c'è spazio sufficiente sul dispositivo per creare il file |
| ENOTDIR | un componente del percorso non è una directory |
| EROFS | si sta tentando di creare un file su un filesystem in sola lettura |
Per chiudere il file, è possibile utilizzare la funzione close(), passando come parametro il file descriptor corrispondente.
int close(int fd);
Leggere e scrivere un file
read()
ssize_t read (int fd, void *buf, size_t count);
Legge dati da un file e li salva in un buffer buf specificato dall'utente. L'argomento count specifica il numero massimo di byte da leggere dal file e fd identifica il file da cui leggere i dati.
write()
ssize_t write(int fd, const void *buf, size_t count);
Scrive sul file associato al file descriptor fd fino a count bytes di dati dal buffer buf.
lseek()
off_t lseek(int fd, off_t offset, int whence);
Riposiziona l’offset del file. fd è il file descriptor del file il cui offset corrente si desidera modificare, offset è la quantità di byte per cui si desidera spostare l'offset e invece whence specifica la posizione da cui si desidera spostare l'offset e può essere uno dei seguenti valori: SEEK_SET (inizio del file), SEEK_CUR (dalla posizione attuale) e SEEK_END (dalla fine del file).
Esempio 1.
Dato il seguente script in C
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
//Open new file in Read only
int main()
{
int openedFile = open("filename.txt", O_RDONLY); // ES: filename = mnt/c/Users/NomeUtente/Desktop/input.txt
char content[10]; int bytesRead;
do{
bytesRead = read(openedFile, content, 9); //Read 9 byte (1 char = 1 byte -> 9 characters) to content
content[bytesRead]='\0';
printf("%s",content);
} while(bytesRead > 0);
printf("\n");
close(openedFile);
return 0;
}
e un file chiamato "filename.txt" contenente la seguente riga di testo:
Content to read
L'output è il seguente:
Content to read
Esempio 2.
Dato il seguente script in C
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
//Open file (create it with user R and W permissions)
int main()
{
int openFile = open("filename.txt", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR); //Open file in R and W mode (O_RDWR) and creates it if it doesn't exist (O_CREAT). The file is created with read and write permissions for the user (S_IRUSR|S_IWUSR).
char toWrite[] = "Professor";
write(openFile, "hello world\n", strlen("hello world\n")); //Write the string "hello world\n" to file, overwriting the first 12 characters (also counting /n)
lseek(openFile, 6, SEEK_SET); // move I/O pointer to position 6 from beginning
write(openFile, toWrite, strlen(toWrite)); //Write the string "Professor" to file
close(openFile);
return 0;
}
e un file chiamato "filename.txt" contenente la seguente riga di testo:
This content will be overwritten
L'output è il seguente:
hello Professorll be overwritten
Canali standard
I canali standard (in/out/err che hanno indici 0/1/2 rispettivamente) sono rappresentati con strutture “stream” (stdin, stdout, stderr) e macro (STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO).
La funzione fileno restituisce l’indice di uno “stream”, per cui si ha:
- fileno(stdin)=STDIN_FILENO // = 0
- fileno(stdout)=STDOUT_FILENO // = 1
- fileno(stderr)=STDERR_FILENO // = 2
isatty(stdin) == 1 se l’esecuzione è interattiva, altrimenti isatty(stdin) == 0
printf("ciao"); e fprintf(stdout, "ciao"); sono equivalenti!
Esempio
Dato il seguente script in C
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("stdin: stdin ->_flags = %hd, STDIN_FILENO = %d\n",
stdin->_flags, STDIN_FILENO
); // replace %hd with stdin->_flags and replace %d\n with STDIN_FILENO. %hd indicates that the value to print is a short integer, while %d indicates that the value to print is an integer (int)
printf("stdout: stdout->_flags = %hd, STDOUT_FILENO = %d\n",
stdout->_flags, STDOUT_FILENO
);
printf("stderr: stderr->_flags = %hd, STDERR_FILENO = %d\n",
stderr->_flags, STDERR_FILENO
);
return 0;
}
L'output è il seguente:
stdin: stdin ->_flags = 8328, STDIN_FILENO = 0
stdout: stdout->_flags = 10884, STDOUT_FILENO = 1
stderr: stderr->_flags = 8326, STDERR_FILENO = 2
Questo script in C stampa informazioni sui file descriptor standard: stdin, stdout e stderr.
Nella prima printf, viene stampato il valore dei flag (_flags) associati al file descriptor stdin e il valore di STDIN_FILENO. Questi valori indicano lo stato e le caratteristiche del file descriptor stdin. Lo stesso viene fatto per stdout e stderr nelle printf successive. I valori specifici dei flag possono variare tra le implementazioni e i sistemi operativi, quindi i numeri riportati potrebbero essere diversi su altre piattaforme.
Piping con bash
Normalmente un’applicazione eseguita da bash ha accesso ai canali standard stdin, stdout e stderr (tastiera/video).
Se le applicazioni sono usate in un’operazione di piping (es. ls | wc -l) allora l’output dell’applicazione sulla sinistra diventa l’input dell’applicazione sulla destra.
L’esecuzione del comando sopra riportato è parallela! Entrambi i comandi sono eseguiti allo stesso momento.
Esempio 1.
Dato il seguente script in C
#define MAXBUF 10
#include <stdio.h>
#include <string.h>
int main()
{
char buf[MAXBUF];
fgets(buf, sizeof(buf), stdin); // may truncate!
printf("%s\n", buf);
return 0;
}
dopo aver eseguito i seguenti comandi nel terminale
$ gcc src.c -o pip.out
$ echo “hi how are you” | ./pip.out
il comando echo invierà il testo “hi how are you” come input al programma pip.out.
Il programma leggerà l’input e lo stamperà sulla console. Tuttavia, poiché la dimensione del buffer è limitata a 10 caratteri, l’output sarà troncato e vedrai solo i primi 9 caratteri dell’input seguiti da un carattere di nuova riga, ovvero "hi how ar\n".
Esempio 2.
Dato il seguente script in C
#include <stdio.h>
int main()
{
int c, d;
// loop into stdin until EOF (as CTRL+D)
// read from stdin
while ((c = getchar()) != EOF)
{
d = c;
if (c >= 'a' && c <= 'z') d -= 32;
if (c >= 'A' && c <= 'Z') d += 32;
putchar(d); // write to stdout
};
return (0);
}
dopo aver eseguito i seguenti comandi nel terminale
$ gcc src.c -o inv.out
$ ls /tmp | ./inv.out
l'output del comando ls /tmp verrà inviato come input al programma inv.out. Il programma legge da stdin carattere per carattere e lo stamperà su stdout, producendo una lista dei file nella directory /tmp invertendo i caratteri minuscoli con quelli maiuscoli.
Conclusioni
Esistono diversi modi per interagire con i file: flussi e descrittori di file. Ogni approccio ha i suoi pro e i suoi contro. Entrambi ruotano attorno a una diversa rappresentazione di un file: gli stream operano su puntatori di file (cioè buffer), mentre i descrittori di file operano su interi (cioè voci di una struttura dati).