4. Streams & File Descriptors

Interazione con i file

In UNIX ci sono due modi per interagire con i file: streams e file descriptors.

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.

Streams.png|500

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:

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:

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).

Canali di output.png|300

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:

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.

File descriptors.png|550

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:

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:

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.

Attenzione

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).