Introduzione all'Input e Output in Linux

Concetti Chiave
  • Linux tratta ogni risorsa (file, dispositivo, pipe, socket) come un file unificato.
  • Le operazioni di I/O si basano su chiamate di sistema universali: open(), read(), write() e close().
  • I file descriptor sono numeri interi che identificano file aperti e permettono di gestire operazioni di input e output.
  • Esistono file descriptor standard predefiniti: STDIN_FILENO (0) per input, STDOUT_FILENO (1) per output e STDERR_FILENO (2) per errori.
  • La shell setta e reindirizza questi file descriptor prima dell'esecuzione dei comandi, facilitando la gestione degli I/O.
  • L'approccio tutto è un file consente ai programmi di operare sugli stessi tipi di dati indipendentemente dalla loro natura fisica o logica.

Descrittori di File in Linux

Tutte le chiamate di sistema che effettuano operazioni di input/output (I/O) in Linux sono implementate attraverso l'uso di file.

Spesso si usa lo slogan che in Linux (e Unix in generale) "tutto è un file". Questo significa che anche dispositivi hardware come tastiere, mouse, stampanti e dischi sono rappresentati come file nel sistema operativo.

Per accedere ad un file bisogna fare riferimento ad esso attraverso un file descriptor (un descrittore di file). Si tratta di un numero intero non negativo che viene adoperato dal sistema operativo per etichettare un file aperto. I file descriptor sono utilizzati per identificare i file aperti e per eseguire operazioni su di essi, come leggere, scrivere o chiudere il file.

Un file descriptor viene adoperato per far riferimento a tutti i tipi di file tra cui:

  • File regolari: file di testo, immagini, video, ecc.
  • Pipe: canali di comunicazione tra processi.
  • Socket: canali di comunicazione tra processi su macchine diverse.
  • Dispositivi: come tastiere, mouse, stampanti, dischi, ecc.

Inoltre, ogni processo ha il proprio insieme di file descriptor. Quindi può accadere che uno stesso file abbia file descriptor diversi in processi diversi.

Definizione

File Descriptor

Un file descriptor è un numero intero non negativo che rappresenta un file su cui un processo può eseguire operazioni di input/output.

Ogni processo ha il proprio insieme di file descriptor. Due processi possono accedere allo stesso file, ma avranno file descriptor diversi per quel file.

File Descriptor Standard

Per convenzione, qualsiasi programma Linux (e Unix) si aspetta di poter adoperare tre file descriptor predefiniti:

File Descriptor Scopo Nome POSIX Stream associato
0 Input STDIN_FILENO stdin
1 Output STDOUT_FILENO stdout
2 Errori STDERR_FILENO stderr
Tabella 1: Descrittori di File Standard

Questi file descriptor vengono aperti per conto del programma dalla shell (la riga di comando) prima ancora che il programma venga eseguito. Per essere più precisi, in realtà il programma eredita delle copie di questi file descriptor dalla shell che lo ha avviato.

La shell lavora tipicamente sempre con questi tre file descriptor e, quando si tratta di una shell interattiva, essi sono associati al terminale da cui l'utente sta interagendo.

Quando da riga di comando specifichiamo una redirezione (ad esempio >, <, >>), la shell modifica i file descriptor standard per reindirizzare l'input o l'output verso un file specifico prima di eseguire il programma.

Ad esempio, quando eseguiamo un comando come:

ls > file.txt

la shell reindirizza l'output standard (stdout, file descriptor 1) verso il file file.txt. In questo modo, invece di stampare l'output sul terminale, il comando ls scrive direttamente nel file specificato.

Per riferirci a questi file descriptor standard, possiamo adoperare i numeri 0, 1 e 2 ma questo è sconsigliato. Meglio adoperare le costanti POSIX STDIN_FILENO, STDOUT_FILENO e STDERR_FILENO che sono definite in <unistd.h>.

La motivazione sta nel fatto che sebbene i file descriptor standard siano sempre associati a stdin, stdout e stderr, in alcuni casi potrebbero essere reindirizzati a file o dispositivi diversi. Utilizzando le costanti POSIX, il codice diventa più leggibile e portabile.

Chiamate di Sistema per I/O

In Linux, le operazioni di input/output (I/O) su file sono gestite attraverso un insieme di chiamate di sistema che permettono ai processi di interagire con i file. Le principali chiamate di sistema per I/O includono:

  • open(): apre un file e restituisce un file descriptor.

    Questa chiamata di sistema ha la forma:

    fd = open(pathname, flags, mode);
    

    Questa system call apre il file identificato da pathname e restituisce un file descriptor fd che si può adoperare nelle operazioni successive.

    Nel caso in cui il file non esista, open potrebbe crearne uno nuovo a seconda di come sono impostati i flags.

    flags è una maschera di bit che specifica le modalità di apertura del file, come ad esempio se aprirlo in lettura, scrittura o entrambe.

    Il parametro mode è usato solo se il file viene creato e specifica i permessi del file (ad esempio, chi può leggere, scrivere o eseguire il file). Nel caso in cui il file non venga creato, questo parametro viene ignorato.

  • read(): legge dati da un file.

    La chiamata di sistema read ha la forma:

    numero = read(fd, buffer, count);
    

    Questa system call legge fino a count byte dal file identificato da fd e li memorizza nel buffer buf. Restituisce il numero di byte effettivamente letti o -1 in caso di errore.

    Se non ci sono più dati da leggere, read restituisce 0 per indicare la fine del file (EOF: End Of File).

  • write(): scrive dati su un file.

    La chiamata di sistema write ha la forma:

    numero = write(fd, buffer, count);
    

    Questa system call scrive fino a count byte dal buffer buf nel file identificato da fd. Restituisce il numero di byte effettivamente scritti o -1 in caso di errore.

    Inoltre, e questo è importante, write può restituire un numero di byte inferiore a count se non c'è abbastanza spazio disponibile nel file o se il file è stato chiuso da un altro processo.

  • close(): chiude un file.

    La chiamata di sistema close ha la forma:

    status = close(fd);
    

    Questa system call chiude il file identificato da fd. Dopo la chiusura, il file descriptor non è più valido e non può essere utilizzato per ulteriori operazioni di I/O.

    Se la chiusura ha successo, close restituisce 0, altrimenti restituisce -1 in caso di errore.

Universalità dell'I/O in Linux

Una delle caratteristiche distintive del modello di I/O in Linux (e Unix in generale) è la sua universalità. In Linux, tutto è un file, il che significa che le stesse chiamate di sistema, come open(), read(), write() e close(), possiamo operare su qualsiasi tipo di file, che si tratti di un file regolare, di un dispositivo, di una pipe o di un socket.

La conseguenza è che, se scriviamo un programma che adopera soltanto queste chiamate di sistema, esso sarà in grado di leggere e scrivere su qualsiasi tipo di file senza dover conoscere in anticipo il tipo specifico del file.

L'universalità dell'I/O in Linux e Unix è ottenuta assicurandosi che ciascun filesystem o driver di dispositivo implementi le stesse interfacce di I/O. Poiché i dettagli di implementazione sono nascosti dietro le chiamate di sistema e sono gestiti dal kernel, il programma non ha bisogno di preoccuparsi di come i dati sono effettivamente memorizzati o trasmessi.

Soltanto quando non possiamo prescindere da specifiche funzionalità di un dispositivo o di un file, dobbiamo adoperare la chiamata di sistema ioctl() (input/output control) per inviare comandi specifici al dispositivo o al file. Vedremo ioctl() nelle prossime lezioni.