Concetti Fondamentali sul sistema operativo Linux

In questa lezione introduciamo un insieme di concetti relativi alla programmazione di sistema in Linux.

Questa lezione è principalmente rivolta a tutti coloro che hanno lavorato principalmente con altri sistemi operativi oppure che hanno soltanto un'esperienza limitata con Linux o altre implementazioni UNIX.

Kernel

Il termine sistema operativo è comunemente usato con due significati diversi:

  • Per indicare l'intero pacchetto costituito dal software centrale che gestisce le risorse di un computer e da tutti gli strumenti software standard di accompagnamento, come interpreti a riga di comando, interfacce grafiche, utility di file e editor.
  • In modo più ristretto, per riferirsi al software centrale che gestisce e assegna le risorse del computer (cioè CPU, RAM e dispositivi).

Il termine kernel è spesso usato come sinonimo del secondo significato, ed è con questo significato di "sistema operativo" che ci occupiamo in queste lezioni sulla programmazione di sistema.

Sebbene sia possibile eseguire programmi su un computer senza un kernel, la presenza di un kernel semplifica notevolmente la scrittura e l'uso di altri programmi, aumentando la potenza e la flessibilità a disposizione dei programmatori.

Il kernel lo fa fornendo uno strato software per gestire le risorse limitate di un computer. L'eseguibile del kernel Linux si trova tipicamente nel percorso /boot/vmlinuz, o qualcosa di simile. La denominazione di questo file è di origine storica. Nelle prime implementazioni UNIX, il kernel si chiamava unix. Le successive implementazioni UNIX, che introdussero la memoria virtuale, rinominarono il kernel in vmunix. Su Linux, il nome del file rispecchia il nome del sistema, con la z che sostituisce la x finale per indicare che il kernel è un eseguibile compresso (da zip o zipped per indicare il fatto che è compresso).

Compiti svolti dal kernel

Tra le altre cose, il kernel esegue i seguenti compiti:

  • Pianificazione dei processi: un computer dispone di una o più unità di elaborazione centrale (CPU), che eseguono le istruzioni dei programmi. Come gli altri sistemi UNIX, Linux è un sistema operativo a multitasking preemptive. Multitasking significa che più processi (cioè programmi in esecuzione) possono risiedere simultaneamente in memoria e ciascuno può ricevere tempo di CPU. Preemptive indica che le regole che determinano quali processi ottengono la CPU e per quanto tempo sono decise dal pianificatore di processo del kernel (e non dai processi stessi).
  • Gestione della memoria: sebbene le memorie dei computer siano enormi rispetto a qualche decennio fa, anche le dimensioni del software sono cresciute di pari passo, così la memoria fisica (RAM) resta una risorsa limitata che il kernel deve condividere tra i processi in modo equo ed efficiente. Come la maggior parte dei moderni sistemi operativi, Linux utilizza la gestione della memoria virtuale, una tecnica che offre due vantaggi principali:

    • I processi sono isolati l'uno dall'altro e dal kernel, quindi un processo non può leggere o modificare la memoria di un altro processo o del kernel.
    • Solo una parte di un processo deve essere mantenuta in memoria, riducendo così i requisiti di RAM di ciascun processo e permettendo di tenere più processi in RAM contemporaneamente. Questo porta a un migliore utilizzo della CPU, poiché aumenta la probabilità che, in ogni istante, vi sia almeno un processo pronto per essere eseguito.
  • Fornitura di un file system: il kernel mette a disposizione un file system su disco, consentendo la creazione, il recupero, l'aggiornamento, la cancellazione e così via dei file.

  • Creazione e terminazione dei processi: il kernel può caricare un nuovo programma in memoria, fornendogli le risorse (CPU, memoria, accesso ai file) necessarie per l'esecuzione. Una tale istanza di programma in esecuzione è chiamata processo. Quando un processo termina, il kernel provvede a liberare le risorse occupate, rendendole disponibili per altri programmi.
  • Accesso ai dispositivi: i dispositivi (mouse, monitor, tastiere, unità disco e nastro, ecc.) collegati a un computer permettono lo scambio di informazioni tra il computer e il mondo esterno, consentendo input, output o entrambi. Il kernel fornisce alle applicazioni un'interfaccia che standardizza e semplifica l'accesso ai dispositivi, arbitrando al contempo l'uso da parte di più processi di ciascun dispositivo.
  • Rete: il kernel trasmette e riceve messaggi di rete (pacchetti) per conto dei processi utente. Questo compito include l'instradamento dei pacchetti verso il sistema di destinazione.
  • Fornitura di un'API di chiamate di sistema: i processi possono richiedere al kernel di eseguire varie operazioni mediante punti di ingresso noti come system call. L'API delle system call di Linux è il tema principale di queste lezioni. Nelle prossime lezioni vedremo i passaggi che avvengono quando un processo effettua una system call.

Oltre alle funzionalità sopra elencate, i sistemi operativi multiutente come Linux offrono generalmente l'astrazione di un computer virtuale privato: ogni utente può accedere al sistema e operare in modo quasi indipendente dagli altri.

Per esempio, ogni utente ha il proprio spazio di archiviazione su disco (directory home). Inoltre, gli utenti possono eseguire programmi, ognuno dei quali ottiene una quota di CPU e opera nel proprio spazio di indirizzi virtuale; questi programmi possono accedere indipendentemente ai dispositivi e trasferire informazioni sulla rete. Il kernel risolve i conflitti potenziali nell'accesso alle risorse hardware, così gli utenti e i processi non sono generalmente consapevoli di tali conflitti.

Modalità kernel e modalità utente

Le moderne architetture di processore consentono tipicamente alla CPU di operare in almeno due modalità diverse: modalità utente e modalità kernel (a volte chiamata anche modalità supervisor).

Istruzioni hardware permettono il passaggio da una modalità all'altra. Analogamente, le aree di memoria virtuale possono essere contrassegnate come appartenenti allo spazio utente o allo spazio kernel. Quando la CPU è in modalità utente, può accedere solo alla memoria marcata come spazio utente; tentativi di accesso alla memoria kernel provocano un'eccezione hardware. In modalità kernel, la CPU può accedere sia allo spazio utente sia a quello kernel. Alcune operazioni possono essere eseguite solo quando il processore è in modalità kernel.

Esempi includono l'esecuzione dell'istruzione halt per fermare il sistema, l'accesso all'hardware di gestione della memoria e l'avvio di operazioni I/O sui dispositivi. Sfruttando questo design hardware per collocare il sistema operativo nello spazio kernel, gli implementatori possono garantire che i processi utente non possano accedere alle istruzioni e alle strutture dati del kernel, né eseguire operazioni che potrebbero compromettere il funzionamento del sistema.

Visione del sistema da parte del processo vs. dal kernel

Nella programmazione quotidiana siamo abituati a pensare in termini orientati al processo.

Tuttavia, quando si affrontano gli argomenti trattati più avanti in queste lezioni, può essere utile cambiare prospettiva e considerare le cose dal punto di vista del kernel.

Per rendere il contrasto chiaro, esaminiamo prima la visione da parte di un processo e poi quella da parte del kernel.

Un sistema in esecuzione tipicamente contiene numerosi processi. Per un processo, molte cose avvengono in modo asincrono. Un processo in esecuzione non sa quando scadrà il suo tempo di CPU, quali altri processi saranno poi eseguiti (e in quale ordine), o quando sarà nuovamente eseguito.

La consegna di segnali e gli eventi di comunicazione inter-processo sono mediati dal kernel e possono verificarsi in qualsiasi momento per un processo. Molte cose avvengono in modo trasparente per un processo: non conosce la sua posizione nella RAM né, in generale, se una certa parte del suo spazio di memoria è attualmente residente in memoria o è stata spostata nell'area di swap (una zona di disco riservata a integrare la RAM nel momento in cui questa sta per esaurirsi).

Analogamente, un processo non sa dove sul disco siano memorizzati i file a cui accede; li riferisce semplicemente per nome.

Un processo opera in isolamento; non può comunicare direttamente con un altro processo. Non può creare un nuovo processo né terminare la propria esecuzione. Infine, non può comunicare direttamente con i dispositivi di input e output collegati al computer.

Al contrario, un sistema in esecuzione ha un unico kernel che conosce e controlla tutto. Il kernel facilita l'esecuzione di tutti i processi sul sistema. Decide quale processo otterrà per prossimo l'accesso alla CPU, quando ciò avverrà e per quanto tempo. Mantiene strutture dati contenenti informazioni su tutti i processi in esecuzione e le aggiorna quando i processi vengono creati, cambiano stato o terminano. Gestisce le strutture di basso livello che consentono di tradurre i nomi dei file usati dai programmi in posizioni fisiche sul disco.

Inoltre, mantiene le strutture che mappano la memoria virtuale di ciascun processo nella memoria fisica del computer e nelle aree di swap su disco. Tutta la comunicazione tra processi avviene tramite i meccanismi forniti dal kernel. In risposta alle richieste dei processi, il kernel crea nuovi processi e termina quelli esistenti. Infine, il kernel (in particolare i driver di dispositivo) gestisce tutta la comunicazione diretta con i dispositivi di input e output, trasferendo le informazioni verso e dalle applicazioni utente secondo necessità.

Più avanti in queste lezioni diremo cose come "un processo può creare un altro processo", "un processo può creare una pipe", "un processo può scrivere dati su un file" o "un processo può terminare chiamando exit()".

Ricordate, però, che il kernel media tutte queste azioni; tali affermazioni sono semplici scorciatoie per "un processo può richiedere al kernel di creare un altro processo", e così via.

Per ulteriori informazioni, potete consultare le nostre lezioni sui sistemi operativi

La Shell

Una shell è un programma a scopo speciale progettato per leggere i comandi digitati da un utente ed eseguire i programmi appropriati in risposta a tali comandi.

Questo programma è talvolta chiamato interprete di comandi. Il termine login shell indica il processo creato per avviare una shell quando l'utente effettua il primo login.

Mentre su alcuni sistemi operativi l'interprete di comandi è parte integrante del kernel, sui sistemi UNIX la shell è un processo utente. Alcuni sistemi operativi, come Windows o macOs, hanno una shell grafica che fornisce la metafora del desktop o scrivania per permettere l'esecuzione di comandi da parte dell'utente.

Su Linux esistono sia shell grafiche che testuali. Originariamente, però, le prime shell erano solo di tipo testuale. Sono quelle su cui ci concentreremo in questa e nelle prossime lezioni.

Esistono molte shell diverse e utenti diversi (o anche lo stesso utente) sullo stesso computer possono utilizzare simultaneamente shell differenti.

Nel tempo sono emerse diverse shell importanti:

  • Bourne shell (sh): è la più antica delle shell ampiamente usate, scritta da Steve Bourne. Era la shell standard per la Seventh Edition UNIX. La Bourne shell contiene molte delle funzionalità familiari a tutte le shell: redirezione I/O, pipeline, generazione di nomi file (globbing), variabili, manipolazione delle variabili d'ambiente, sostituzione di comandi, esecuzione di comandi in background e funzioni. Tutte le successive implementazioni UNIX includono la Bourne shell oltre a eventuali altre shell fornite.
  • C shell (csh): scritta da Bill Joy all'Università della California, Berkeley. Il nome deriva dalla somiglianza di molte costruzioni di controllo del flusso di questa shell a quelle del linguaggio di programmazione C. La C shell offriva diverse utilità interattive non presenti nella Bourne shell, tra cui cronologia dei comandi, editing della riga di comando, controllo dei job e alias. La C shell non era retro‑compatibile con la Bourne shell. Sebbene la shell interattiva standard su BSD fosse la C shell, gli script di shell (descritti più avanti) venivano solitamente scritti per la Bourne shell, così da essere portabili su tutte le implementazioni UNIX.
  • Korn shell (ksh): scritta come successore della Bourne shell da David Korn presso gli AT&T Bell Laboratories. Mantenendo la retro‑compatibilità con la Bourne shell, incorporava anche funzionalità interattive simili a quelle offerte dalla C shell.
  • Bourne again shell (bash): è la reimplementazione GNU della Bourne shell. Fornisce funzionalità interattive simili a quelle disponibili nella C e nella Korn shell. Gli autori principali di bash sono Brian Fox e Chet Ramey. Bash è probabilmente la shell più usata su Linux. (Su Linux, la Bourne shell, sh, è in realtà fornita da bash che emula sh il più fedelmente possibile.)

    Lo standard POSIX.2‑1992 definiva una shell basata sulla versione allora corrente della Korn shell. Oggi sia la Korn shell sia bash sono conformi a POSIX, ma offrono numerose estensioni allo standard, molte delle quali differiscono tra le due shell. Le shell non sono state progettate solo per l'uso interattivo, ma anche per l'interpretazione di script di shell, file di testo contenenti comandi di shell. A tal fine, ciascuna shell dispone delle strutture tipiche dei linguaggi di programmazione: variabili, istruzioni di ciclo e condizionali, comandi I/O e funzioni.

Tutte le shell svolgono compiti simili, sebbene con variazioni nella sintassi. Quando non si fa riferimento a una shell specifica, si parla generalmente di "la shell", intendendo che tutte le shell operano secondo le modalità descritte. La maggior parte degli esempi di questo libro che richiedono una shell utilizza bash; tuttavia, salvo diversa indicazione, il lettore può presumere che gli esempi funzionino allo stesso modo anche nelle altre shell di tipo Bourne.

Utenti e Gruppi

Ogni utente del sistema ha un nome di login unico (username) e un corrispondente ID numerico (UID).

Per ciascun utente, queste informazioni sono definite da una riga nel file delle password di sistema, /etc/passwd, che include anche i seguenti dati aggiuntivi:

  • ID gruppo: l'ID numerico del primo dei gruppi di cui l'utente è membro.
  • Directory home: la directory iniziale in cui l'utente viene collocato dopo il login.
  • Shell di login: il nome del programma da eseguire per interpretare i comandi dell'utente.

Il record della password può contenere anche la password dell'utente, in forma cifrata.

Tuttavia, per motivi di sicurezza, la password è spesso memorizzata in un file separato, /etc/shadow, leggibile solo da utenti privilegiati.

Gruppi

Per scopi amministrativi – in particolare per controllare l'accesso a file e altre risorse di sistema – è utile organizzare gli utenti in gruppi.

Ad esempio, le persone di un team che lavorano su un unico progetto e condividono un insieme comune di file possono essere tutte membri dello stesso gruppo.

Nelle prime implementazioni di UNIX, un utente poteva appartenere a un solo gruppo.

BSD ha permesso a un utente di appartenere simultaneamente a più gruppi, un'idea poi adottata da altre implementazioni UNIX e dallo standard POSIX.1‑1990.

Ogni gruppo è identificato da una singola riga nel file dei gruppi di sistema, /etc/group, che contiene le seguenti informazioni:

  • Nome gruppo: il nome (unico) del gruppo.
  • ID gruppo (GID): l'ID numerico associato a questo gruppo.
  • Elenco utenti: una lista di nomi di login separati da virgole degli utenti che sono membri di questo gruppo (e che non sono già identificati come membri del gruppo tramite il campo GID del loro record nel file delle password).

Superutente o utente root

Un utente, noto come superutente, possiede privilegi speciali all'interno del sistema.

L'account del superutete ha UID 0 e normalmente il nome di login root.

Sui tipici sistemi UNIX, il superutente bypassa tutti i controlli di permesso del sistema. Per esempio, il superutente può accedere a qualsiasi file, indipendentemente dai permessi impostati, e può inviare segnali a qualsiasi processo utente. L'amministratore di sistema utilizza l'account superutente per eseguire varie operazioni amministrative sul sistema.

La gerarchia a directory singola

Il kernel mantiene una singola struttura gerarchica di directory per organizzare tutti i file del sistema.

Ciò è diverso da sistemi operativi come Microsoft Windows, dove ogni dispositivo disco ha la propria gerarchia di directory.

Alla base di questa gerarchia c'è la directory radice, chiamata / (slash).

Tutti i file e le directory sono figli o discendenti più lontani della directory radice.

Un esempio di questa gerarchia è mostrato di seguito:

/
├── bin
├── dev
├── home
│   ├── utente
│   |   └── .bashrc
|   └── utente2
|       └── file_di_prova.txt
├── usr
│   ├── include
│   │   └── sys
│   │       └── types.h
│   └── lib
└── var

Tipi di file

All'interno del file system, ogni file è contrassegnato con un tipo, che indica di che tipo di file si tratta.

Uno di questi tipi è il file di dati ordinario, solitamente chiamato file regolare o file semplice per distinguerlo dagli altri tipi.

Gli altri tipi includono dispositivi, pipe, socket, directory e collegamenti simbolici.

Il termine file è comunemente usato per indicare un file di qualsiasi tipo, non solo un file regolare.

In UNIX vale, infatti, l'adagio che tutto è un file: i dispositivi, le directory e persino i processi (attraverso il file system /proc) sono rappresentati come file.

Directory e collegamenti

Una directory è un file speciale il cui contenuto è una tabella di nomi di file associati a riferimenti ai file corrispondenti.

Questa associazione "nome‑file‑più‑riferimento" è chiamata collegamento o link;

i file possono avere più collegamenti, e quindi più nomi, nella stessa directory o in directory diverse. Le directory possono contenere collegamenti sia a file sia ad altre directory.

I collegamenti tra directory costituiscono la gerarchia mostrata nell'esempio di sopra.

Ogni directory contiene almeno due voci:

  • . (punto), che è un collegamento alla directory stessa
  • .. (punto‑punto), che è un collegamento alla sua directory padre, cioè la directory immediatamente superiore nella gerarchia.

Ogni directory, eccetto la radice, ha un padre. Per la directory radice, la voce .. è un collegamento alla radice stessa (quindi /.. equivale a /).

Come un collegamento normale, un collegamento simbolico fornisce un nome alternativo per un file.

Ma mentre un collegamento normale è una voce "nome‑file‑più‑puntatore" nella lista di una directory, un collegamento simbolico è un file appositamente marcato che contiene il nome di un altro file. (In altre parole, il collegamento simbolico ha una voce "nome‑file‑più‑puntatore" nella directory, e il file puntato contiene una stringa che indica il nome di un altro file.)

Questo file è spesso chiamato destinazione del collegamento simbolico, e si dice comunemente che il collegamento "punta" o "fa riferimento" al file di destinazione.

Quando un pathname è specificato in una chiamata di sistema, nella maggior parte dei casi il kernel dereferenzia automaticamente (o allo stesso modo, segue) ogni collegamento simbolico presente nel pathname, sostituendolo con il nome del file a cui punta. Questo processo può avvenire ricorsivamente se la destinazione di un collegamento simbolico è a sua volta un collegamento simbolico. (Il kernel impone limiti al numero di dereferenze per gestire la possibilità di catene circolari di collegamenti simbolici.)

Se un collegamento simbolico fa riferimento a un file che non esiste, si parla di collegamento pendente o dangling link.

Spesso i termini hard link e soft link sono usati come sinonimi di collegamento normale e collegamento simbolico. Le ragioni per avere due tipi diversi di collegamenti le vedremo nelle prossime lezioni.

Nomi di file

Sui file system Linux più comuni, i nomi di file possono essere lunghi fino a 255 caratteri. I nomi possono contenere qualsiasi carattere eccetto lo slash (/) e il carattere nullo (\0). Tuttavia, è consigliabile usare solo lettere, cifre, il punto (.), il trattino basso (_) e il trattino (-). Questo insieme di 65 caratteri [-._a-zA-Z0-9] è definito nello SUSv3 come insieme portabile di caratteri per i nomi di file.

È opportuno evitare l'uso di caratteri non appartenenti a questo insieme, perché potrebbero avere significati speciali nella shell, nelle espressioni regolari o in altri contesti. Se un nome di file contiene caratteri con significati speciali, questi devono essere escaped (tipicamente preceduti da un backslash \) per indicare che non devono essere interpretati con il loro significato speciale. Nei contesti in cui non è disponibile un meccanismo di escape, il nome di file non è utilizzabile.

È inoltre sconsigliato dare a un file un nome che inizi con un trattino (-), poiché potrebbe essere confuso con un'opzione di un comando della shell.

Pathname

Un pathname è una stringa composta da uno slash iniziale opzionale (/) seguito da una serie di nomi di file separati da slash. Tutti, tranne l'ultimo, identificano una directory (o un collegamento simbolico che risolve a una directory). L'ultimo componente può identificare qualsiasi tipo di file, inclusa una directory. La serie di componenti che precede l'ultimo slash è talvolta chiamata parte di directory del pathname, mentre il nome che segue l'ultimo slash è chiamato parte di file o base del pathname.

Il pathname viene letto da sinistra a destra; ogni nome di file risiede nella directory specificata dalla parte precedente del pathname. La stringa .. può essere usata in qualsiasi punto del pathname per riferirsi al genitore della posizione specificata fino a quel punto.

Un pathname descrive la posizione di un file nella gerarchia a directory singola ed è di due tipi:

  • Pathname assoluto: inizia con uno slash (/) e specifica la posizione di un file rispetto alla directory radice. Esempi di pathname assoluti nell'esempio di sopra sono /home/utente/.bashrc, /usr/include e / (pathname della directory radice).
  • Pathname relativo: specifica la posizione di un file rispetto alla directory di lavoro corrente di un processo (vedi sotto) e si distingue da quello assoluto per l'assenza di uno slash iniziale. Nell'esempio di sopra, dalla directory usr il file types.h può essere referenziato con il pathname relativo include/sys/types.h, mentre dalla directory utente2 il file .bashrc è accessibile con il pathname relativo ../utente/.bashrc.

Directory di lavoro corrente

Ogni processo ha una directory di lavoro corrente (a volte chiamata semplicemente working directory o current directory).

Questa è la "posizione attuale" del processo nella gerarchia a directory singola, ed è da questa directory che vengono interpretati i pathname relativi per il processo.

Un processo eredita la sua directory di lavoro corrente dal processo padre. Una shell di login imposta la directory di lavoro corrente iniziale al percorso indicato nel campo home directory del record dell'utente nel file delle password. La directory corrente della shell può essere cambiata con il comando cd.

Proprietà e permessi dei file

Ogni file ha associati un user ID (UID) e un group ID (GID) che definiscono rispettivamente il proprietario del file e il gruppo a cui appartiene. La proprietà è usata per determinare i diritti di accesso disponibili agli utenti.

Per l'accesso a un file, il sistema suddivide gli utenti in tre categorie:

  • il proprietario del file (a volte chiamato utente del file),
  • gli utenti che appartengono al gruppo indicato dal GID del file (gruppo),
  • e tutti gli altri (other).

Per ciascuna di queste categorie possono essere impostati tre bit di permesso (per un totale di nove bit):

  • read (r): consente di leggere il contenuto del file;
  • write (w): consente di modificare il contenuto;
  • execute (x): consente di eseguire il file, che può essere un programma o uno script da interpretare (di solito, ma non sempre, da una shell).

Questi permessi possono essere impostati anche sulle directory, sebbene il loro significato sia leggermente diverso:

  • read (r): permette di elencare i contenuti della directory (i nomi dei file);
  • write (w): permette di modificare i contenuti della directory (aggiungere, rimuovere o rinominare file);
  • execute (x) (a volte chiamato search): permette di accedere ai file all'interno della directory, soggetto ai permessi dei file stessi.

Il modello I/O universale di UNIX

Una delle caratteristiche distintive del modello I/O sui sistemi UNIX è il concetto di universalità dell'I/O. Questo significa che le stesse chiamate di sistema (open(), read(), write(), close() e così via) sono usate per effettuare I/O su tutti i tipi di file, inclusi i dispositivi. (Il kernel traduce le richieste I/O dell'applicazione in operazioni appropriate del filesystem o del driver di dispositivo che eseguono I/O sul file o dispositivo di destinazione.) Così, un programma che utilizza queste chiamate di sistema funzionerà su qualsiasi tipo di file.

Il kernel fornisce essenzialmente un unico tipo di file: un flusso sequenziale di byte, che, nel caso di file su disco, dischi e nastri, può essere accessibile in modo casuale usando la chiamata di sistema lseek().

Molte applicazioni e librerie interpretano il carattere di nuova riga (codice ASCII 10 decimale, talvolta anche noto come linefeed) come segno di fine di una riga di testo e inizio della successiva. I sistemi UNIX non hanno un carattere di fine‑file; la fine di un file viene rilevata da una read che non restituisce dati.

File descriptor (Descrittore di File)

Le chiamate di I/O si riferiscono ai file aperti usando un file descriptor, un intero non negativo (di solito piccolo). Un file descriptor si ottiene tipicamente con una chiamata a open(), che prende come argomento un pathname che specifica il file su cui eseguire l'I/O.

Normalmente, un processo eredita tre file descriptor aperti quando viene avviato dalla shell: il descrittore 0 è lo standard input, il file da cui il processo prende il suo input; il descrittore 1 è lo standard output, il file su cui il processo scrive il suo output; e il descrittore 2 è lo standard error, il file su cui il processo scrive i messaggi di errore e le notifiche di condizioni eccezionali o anormali. In una shell o programma interattivo, questi tre descrittori sono normalmente collegati al terminale. Nella libreria stdio, questi descrittori corrispondono ai flussi di file stdin, stdout e stderr.

La libreria stdio

Per effettuare I/O su file, i programmi C tipicamente impiegano le funzioni I/O contenute nella libreria standard C.

Questo insieme di funzioni, chiamato libreria stdio, include fopen(), fclose(), scanf(), printf(), fgets(), fputs() e così via.

Le funzioni stdio sono costruite sopra le chiamate di sistema I/O (open(), close(), read(), write() e così via).

Per ulteriori informazioni, potete consultare le nostre lezioni sulle funzioni di input e output in C, disponibili nella sezione Linguaggio C del nostro sito.

Il concetto di programma

I programmi esistono normalmente in due forme:

  • La prima è il codice sorgente, testo leggibile dall'uomo costituito da una serie di istruzioni scritte in un linguaggio di programmazione come il C.

Per essere eseguito, il codice sorgente deve essere convertito nella seconda forma:

  • istruzioni binarie in linguaggio macchina che il computer può comprendere. (Questo è diverso da uno script, che è un file di testo contenente comandi da elaborare direttamente da un programma come una shell o un altro interprete di comandi.)

I due significati del termine programma sono solitamente considerati sinonimi, poiché la fase di compilazione e collegamento trasforma il codice sorgente in codice macchina binario semanticamente equivalente.

Filtri

Un filtro è il nome spesso attribuito a un programma che legge il suo input dallo stdin, esegue una qualche trasformazione su tale input e scrive i dati trasformati su stdout.

Esempi di filtri includono cat, grep, tr, sort, wc, sed e awk.

Argomenti da riga di comando

In C, i programmi possono accedere agli argomenti della riga di comando, ovvero le parole fornite sulla riga di comando al momento dell'esecuzione del programma.

Per accedere a questi argomenti, la funzione main() del programma viene dichiarata nel seguente modo:

int main(int argc, char *argv[])

La variabile argc contiene il numero totale di argomenti della riga di comando, mentre gli argomenti individuali sono disponibili come stringhe puntate dagli elementi dell'array argv. La prima di queste stringhe, argv[0], identifica il nome stesso del programma.

Processi

In modo semplice, un processo è un'istanza di un programma in esecuzione. Quando un programma viene eseguito, il kernel carica il codice del programma nella memoria virtuale, alloca spazio per le variabili del programma e prepara le strutture dati interne necessarie a registrare varie informazioni sul processo (come ID del processo, stato di terminazione, ID utente e ID di gruppo).

Dal punto di vista del kernel, i processi sono le entità tra cui il kernel deve condividere le varie risorse del computer. Per le risorse limitate, come la memoria, il kernel alloca inizialmente una certa quantità di risorsa al processo e ne adatta l'allocazione durante il ciclo di vita del processo, in risposta alle sue richieste e alla domanda complessiva del sistema. Quando il processo termina, tutte queste risorse vengono rilasciate e rese disponibili per altri processi. Altre risorse, come CPU e banda di rete, sono rinnovabili, ma devono comunque essere condivise in modo equo tra tutti i processi.

Layout della memoria di un processo

Un processo è logicamente suddiviso nelle seguenti parti, dette segmenti:

  • Text: le istruzioni del programma.
  • Data: le variabili statiche usate dal programma.
  • Heap: un'area da cui i programmi possono allocare dinamicamente memoria aggiuntiva.
  • Stack: una porzione di memoria che cresce e si riduce quando le funzioni vengono chiamate e ritornano, usata per allocare lo spazio per variabili locali e informazioni di collegamento delle chiamate di funzione.

Creazione dei processi ed esecuzione dei programmi

Un processo può creare un nuovo processo usando la system call fork(). Il processo che invoca fork() è chiamato processo padre, mentre il nuovo processo è chiamato processo figlio. Il kernel crea il processo figlio duplicando il processo padre. Il figlio eredita copie dei segmenti data, stack e heap del padre, che poi può modificare in modo indipendente rispetto alle copie del padre. (Il segmento text del programma, collocato in memoria in sola lettura, è condiviso tra i due processi.)

Il processo figlio può poi eseguire un insieme diverso di funzioni nello stesso codice del padre oppure, più frequentemente, usare la system call execve() per caricare ed eseguire un programma completamente nuovo. Una chiamata a execve() distrugge i segmenti text, data, stack e heap esistenti, sostituendoli con nuovi segmenti basati sul codice del nuovo programma.

Sopra execve() sono costruite varie funzioni correlate della libreria C, ciascuna con un'interfaccia leggermente diversa ma con la stessa funzionalità di base. Tutte queste funzioni hanno nomi che iniziano con la stringa exec; quando le differenze non sono rilevanti, useremo la notazione exec() per riferirci in modo generale a tali funzioni. Tieni però presente che non esiste una funzione reale chiamata exec().

Spesso useremo il verbo eseguire con exec per descrivere l'operazione effettuata da execve() e dalle funzioni di libreria costruite sopra di essa.

ID processo e ID del processo padre (PID e PPID)

Ogni processo ha un identificatore intero univoco chiamato process identifier (PID).

Ogni processo ha anche un attributo parent process identifier (PPID), che identifica il processo che ha richiesto al kernel di crearne uno nuovo.

Terminazione del processo e stato di terminazione

Un processo può terminare in uno dei seguenti due modi:

  1. richiedendo la propria terminazione tramite la system call _exit() (o la funzione di libreria correlata exit()),
  2. oppure venendo ucciso dalla consegna di un segnale.

In entrambi i casi, il processo produce uno stato di terminazione, un piccolo valore intero non negativo che può essere esaminato dal processo padre tramite la system call wait().

Nel caso di una chiamata a _exit(), il processo specifica esplicitamente il proprio stato di terminazione. Se un processo viene ucciso da un segnale, lo stato di terminazione viene impostato in base al tipo di segnale che ne ha causato la morte. (Talvolta ci riferiremo all'argomento passato a _exit() come exit status del processo, distinto dallo stato di terminazione, che può essere il valore passato a _exit() oppure un'indicazione del segnale che ha ucciso il processo.)

Per convenzione, uno stato di terminazione 0 indica che il processo ha avuto successo, mentre uno stato non nullo indica che si è verificato un errore. La maggior parte delle shell rende disponibile lo stato di terminazione dell'ultimo programma eseguito tramite una variabile chiamata $?.

Identificatori utente e gruppo del processo (credenziali)

Ogni processo ha vari ID utente (UID) e ID gruppo (GID) associati. Tra questi:

  • Real user ID e real group ID: identificano l'utente e il gruppo a cui appartiene il processo. Un nuovo processo eredita questi ID dal padre. Una login shell ottiene il real user ID e il real group ID dai campi corrispondenti nel file delle password di sistema.
  • Effective user ID ed effective group ID: questi due ID (insieme agli ID di gruppo supplementari descritti subito sotto) vengono usati per determinare i permessi che il processo ha quando accede a risorse protette, come file e oggetti di comunicazione tra processi. In genere, gli effective ID del processo hanno lo stesso valore dei corrispondenti real ID. La modifica degli effective ID è un meccanismo che consente a un processo di assumere i privilegi di un altro utente o gruppo.
  • Supplementary group IDs: identificano gruppi aggiuntivi a cui il processo appartiene. Un nuovo processo eredita gli ID di gruppo supplementari dal padre. Una login shell ottiene gli ID di gruppo supplementari dal file dei gruppi di sistema.

Processi privilegiati

Tradizionalmente, sui sistemi UNIX, un processo privilegiato è un processo con effective user ID uguale a 0 (superuser). Un processo di questo tipo aggira le restrizioni di permesso normalmente applicate dal kernel. Al contrario, il termine non privilegiato (o unprivileged/nonprivileged) si applica ai processi eseguiti da altri utenti. Questi processi hanno effective user ID diverso da zero e devono rispettare le regole di permesso imposte dal kernel.

Un processo può essere privilegiato perché è stato creato da un altro processo privilegiato, ad esempio da una login shell avviata da root (superuser). Un altro modo per diventare privilegiato è il meccanismo set-user-ID, che consente a un processo di assumere un effective user ID uguale allo user ID del file programma che sta eseguendo.

Capacità

Dal kernel 2.2, Linux suddivide i privilegi tradizionalmente concessi al superuser in un insieme di unità distinte chiamate capability. Ogni operazione privilegiata è associata a una specifica capability, e un processo può eseguire un'operazione solo se possiede la capability corrispondente. Un processo superuser tradizionale (effective user ID pari a 0) corrisponde a un processo con tutte le capability abilitate.

Concedere a un processo solo un sottoinsieme di capability gli consente di eseguire alcune operazioni normalmente permesse al superuser, impedendogli però di eseguirne altre.

Le capability sono descritte nelle prossime lezioni. Nel resto delle lezioni, quando indicheremo che una certa operazione può essere eseguita solo da un processo privilegiato, in genere specificheremo tra parentesi la capability necessaria. I nomi delle capability iniziano con il prefisso CAP_, ad esempio CAP_KILL.

Il processo init

Durante l'avvio del sistema, il kernel crea un processo speciale chiamato init, il "genitore di tutti i processi", derivato dal file programma /sbin/init.

Tutti i processi del sistema vengono creati (con fork()) da init oppure da uno dei suoi discendenti. Il processo init ha sempre process ID 1 ed esegue con privilegi di superuser.

Il processo init non può essere ucciso (nemmeno dal superuser) e termina solo allo spegnimento del sistema. Il compito principale di init è creare e monitorare un insieme di processi necessari al funzionamento del sistema.

Processi demone

Un demone è un processo con uno scopo specifico, creato e gestito dal sistema come gli altri processi, ma distinto dalle seguenti caratteristiche:

  • È di lunga durata. Un processo demone viene spesso avviato al boot del sistema e rimane in esecuzione fino allo spegnimento.
  • Esegue in background e non ha un terminale di controllo dal quale leggere input o sul quale scrivere output.

Esempi di processi demone includono syslogd, che registra messaggi nel log di sistema, e httpd, che serve pagine web tramite il protocollo HTTP.

Lista d'ambiente

Ogni processo possiede una lista d'ambiente, cioè un insieme di variabili d'ambiente mantenute nella memoria user-space del processo. Ogni elemento di questa lista è composto da un nome e da un valore associato. Quando un nuovo processo viene creato tramite fork(), eredita una copia dell'ambiente del padre. L'ambiente fornisce quindi un meccanismo con cui un processo padre può comunicare informazioni a un processo figlio. Quando un processo sostituisce il programma in esecuzione tramite exec(), il nuovo programma o eredita l'ambiente del vecchio programma oppure riceve un nuovo ambiente specificato come parte della chiamata exec().

Le variabili d'ambiente vengono create con il comando export nella maggior parte delle shell (oppure con setenv nella C shell), come nel seguente esempio:

$ export MYVAR='Hello world'

I programmi C possono accedere all'ambiente usando una variabile esterna (char **environ), e varie funzioni di libreria consentono a un processo di leggere e modificare i valori del proprio ambiente.

Le variabili d'ambiente sono usate per molti scopi. Per esempio, la shell definisce e usa varie variabili accessibili da script e programmi eseguiti dalla shell. Tra queste ci sono la variabile HOME, che specifica il percorso della directory di login dell'utente, e la variabile PATH, che specifica un elenco di directory in cui la shell deve cercare i programmi corrispondenti ai comandi inseriti dall'utente.

Limiti delle risorse

Ogni processo consuma risorse, come file aperti, memoria e tempo CPU. Tramite la system call setrlimit(), un processo può impostare limiti superiori al consumo di varie risorse. Ogni limite di risorsa ha due valori associati: un soft limit, che limita la quantità di risorsa consumabile dal processo, e un hard limit, che rappresenta il valore massimo a cui il soft limit può essere aumentato. Un processo non privilegiato può modificare il proprio soft limit di una certa risorsa a qualsiasi valore compreso tra zero e il relativo hard limit, ma può solo abbassare il proprio hard limit.

Quando un nuovo processo viene creato con fork(), eredita copie delle impostazioni dei limiti di risorsa del processo padre.

I limiti di risorsa della shell possono essere regolati con il comando ulimit (limit nella C shell). Queste impostazioni di limite sono ereditate dai processi figli che la shell crea per eseguire i comandi.

mmap e Memory Mapping

La chiamata di sistema mmap() crea una nuova mappatura di memoria nello spazio di indirizzi virtuali del processo chiamante.

Le mappature rientrano in due categorie:

  • Mappatura di file (File Mapping): associa una regione di un file alla memoria virtuale del processo chiamante. Una volta mappata, il contenuto del file è accessibile tramite operazioni sui byte nella corrispondente area di memoria. Le pagine della mappatura vengono caricate automaticamente dal file secondo necessità.

  • Mappatura anonima (Anonymous Mapping): non ha un file di riferimento. In questo caso le pagine della mappatura vengono inizializzate a 0.

La memoria di una mappatura di un processo può essere condivisa con le mappature di altri processi. Questo può avvenire perché due processi mappano la stessa regione di un file oppure perché un processo figlio creato con fork() eredita una mappatura dal genitore.

Quando due o più processi condividono le stesse pagine, ciascun processo può vedere le modifiche apportate dagli altri ai contenuti delle pagine, a seconda che la mappatura sia stata creata come privata o condivisa. Con una mappatura privata, le modifiche al contenuto non sono visibili agli altri processi e non vengono riportate sul file sottostante.

Con una mappatura condivisa, le modifiche sono visibili agli altri processi che condividono la stessa mappatura e vengono propagate al file sottostante. Le mappature di memoria hanno molteplici scopi, tra cui l'inizializzazione del segmento di testo di un processo a partire dal segmento corrispondente di un file eseguibile, l'allocazione di nuova memoria (riempita di zeri), l'I/O su file (memory‑mapped I/O) e la comunicazione interprocesso tramite una mappatura condivisa.

Librerie

Una libreria oggetto o object library è un file che contiene il codice oggetto compilato per un insieme (di solito logicamente correlato) di funzioni richiamabili dai programmi applicativi. Raggruppare il codice di più funzioni in una singola libreria semplifica la creazione e la manutenzione dei programmi. I sistemi UNIX moderni forniscono due tipi di librerie: librerie statiche e librerie condivise.

Librerie statiche (Static Libraries)

Le librerie statiche (talvolta chiamate anche archivi) erano l'unico tipo di libreria presente nei primi sistemi UNIX. Una libreria statica è essenzialmente un contenitore strutturato di moduli oggetto compilati. Per utilizzare le funzioni di una libreria statica, si specifica la libreria nel comando di collegamento (link) usato per costruire il programma. Dopo aver risolto i riferimenti alle funzioni del programma principale verso i moduli presenti nella libreria, il linker estrae le copie dei moduli oggetto necessari e le copia nel file eseguibile risultante. Si dice che il programma è collegato staticamente.

Il fatto che ogni programma collegato staticamente includa la propria copia dei moduli oggetto richiesti dalla libreria comporta diversi svantaggi. Prima di tutto, la duplicazione del codice oggetto in più file eseguibili spreca spazio su disco. Uno spreco analogo di memoria si verifica quando più programmi collegati staticamente e che usano la stessa funzione della libreria vengono eseguiti contemporaneamente: ciascuno deve mantenere la propria copia della funzione in memoria. Inoltre, se una funzione della libreria deve essere modificata, è necessario ricompilare quella funzione, aggiungerla alla libreria statica e ricollegare tutte le applicazioni che la utilizzano contro la libreria aggiornata.

Librerie condivise (Shared Libraries)

Le librerie condivise sono state progettate per risolvere i problemi delle librerie statiche.

Se un programma è collegato contro una libreria condivisa, invece di copiare i moduli oggetto dalla libreria nell'eseguibile, il linker scrive semplicemente un record nell'eseguibile che indica che, a runtime, il programma dovrà utilizzare quella libreria condivisa. Quando l'eseguibile viene caricato in memoria, un programma chiamato dynamic linker (linker dinamico) si occupa di trovare e caricare in memoria le librerie condivise richieste, effettuando il collegamento a runtime per risolvere le chiamate di funzione presenti nell'eseguibile con le relative definizioni nelle librerie condivise. A runtime è necessaria una sola copia del codice della libreria condivisa in memoria; tutti i programmi in esecuzione possono utilizzare quella stessa copia.

Il fatto che una libreria condivisa contenga l'unica versione compilata di una funzione consente di risparmiare spazio su disco. Inoltre, semplifica notevolmente l'aggiornamento delle funzioni: ricostruendo semplicemente la libreria condivisa con la nuova definizione, i programmi già esistenti utilizzeranno automaticamente la nuova versione al prossimo avvio.

Meccanismi di Comunicazione Interprocesso (IPC)

Un sistema Linux in esecuzione è composto da numerosi processi, molti dei quali operano in modo indipendente gli uni dagli altri. Alcuni processi, tuttavia, collaborano per raggiungere i loro scopi e hanno bisogno di metodi per comunicare tra loro e sincronizzare le loro azioni.

Un modo per far comunicare i processi è leggere e scrivere informazioni su file su disco. Tuttavia, per molte applicazioni questo è troppo lento e poco flessibile. Perciò Linux, come tutte le moderne implementazioni UNIX, fornisce un ricco insieme di meccanismi per la comunicazione interprocesso (IPC), tra cui:

  • segnali, usati per indicare che si è verificato un evento;
  • pipe (conosciute dagli utenti della shell come l'operatore |) e FIFO, che possono essere usate per trasferire dati tra processi;
  • socket, che possono essere usati per trasferire dati da un processo a un altro, sia sullo stesso host che su host diversi collegati tramite rete;
  • blocco di file o file locking, che consente a un processo di bloccare regioni di un file per impedire ad altri processi di leggere o aggiornare il contenuto;
  • code di messaggi, usate per scambiare messaggi (pacchetti di dati) tra processi;
  • semafori, usati per sincronizzare le azioni dei processi;
  • memoria condivisa o shared memory, che permette a due o più processi di condividere una porzione di memoria. Quando un processo modifica il contenuto della memoria condivisa, tutti gli altri processi possono vedere immediatamente le modifiche.

La grande varietà di meccanismi IPC nei sistemi UNIX, a volte con funzionalità sovrapposte, è dovuta in parte alla loro evoluzione in diverse varianti di UNIX e alle esigenze di vari standard. Per esempio, FIFO e socket di dominio UNIX svolgono essenzialmente la stessa funzione: consentire a processi non correlati sullo stesso sistema di scambiare dati. Entrambi esistono nei moderni sistemi UNIX perché i FIFO provengono da System V, mentre i socket derivano da BSD.

Segnali

Sebbene li abbiamo elencati come metodo di IPC nella sezione precedente, i segnali sono più comunemente usati in una vasta gamma di altri contesti e meritano quindi una discussione più approfondita.

I segnali sono spesso descritti come "interruzioni software". L’arrivo di un segnale informa un processo che si è verificato un evento o una condizione eccezionale. Esistono vari tipi di segnali, ognuno dei quali identifica un diverso evento o condizione. Ogni tipo di segnale è identificato da un intero diverso, definito con nomi simbolici della forma SIGxxxx.

I segnali vengono inviati a un processo dal kernel, da un altro processo (con i permessi appropriati) o dallo stesso processo. Per esempio, il kernel può inviare un segnale a un processo quando si verifica una delle seguenti situazioni:

  • l'utente ha digitato il carattere di interruzione (di solito Ctrl+C) sulla tastiera;
  • uno dei processi figli è terminato;
  • un timer (allarme) impostato dal processo è scaduto; oppure
  • il processo ha tentato di accedere a un indirizzo di memoria non valido.

All'interno della shell, il comando kill può essere usato per inviare un segnale a un processo. La chiamata di sistema kill() fornisce la stessa funzionalità all'interno dei programmi. Quando un processo riceve un segnale, compie una delle seguenti azioni, a seconda del segnale:

  • lo ignora;
  • viene terminato dal segnale; oppure
  • viene sospeso fino a quando non viene ripreso dalla ricezione di un segnale a scopo speciale.

Per la maggior parte dei tipi di segnale, invece di accettare l'azione predefinita, un programma può scegliere di ignorare il segnale (utile se l'azione predefinita non è l'ignorarlo) o di stabilire un handler per il segnale. Un handler è una funzione definita dal programmatore che viene invocata automaticamente quando il segnale viene consegnato al processo. Questa funzione esegue un'azione appropriata alla condizione che ha generato il segnale.

Nel intervallo compreso tra il momento in cui il segnale è generato e quello in cui viene consegnato, il segnale è considerato pendente per un processo. Normalmente, un segnale pendente viene consegnato non appena il processo ricevente viene nuovamente schedulato, o immediatamente se il processo è già in esecuzione. Tuttavia, è anche possibile bloccare un segnale aggiungendolo alla signal mask del processo. Se un segnale viene generato mentre è bloccato, rimane pendente fino a quando non viene sbloccato (cioè rimosso dalla signal mask).

Thread

Nelle implementazioni UNIX moderne, ogni processo può avere più thread di esecuzione. Un modo per immaginare i thread è considerarli come un insieme di processi che condividono la stessa memoria virtuale, oltre a una serie di altri attributi. Ogni thread esegue lo stesso codice del programma e condivide la stessa area dati e l'heap. Tuttavia, ogni thread possiede il proprio stack, contenente le variabili locali e le informazioni di collegamento delle chiamate di funzione.

I thread possono comunicare tra loro tramite le variabili globali che condividono. L'API per i thread fornisce variabili di condizione e mutex, primitive che consentono ai thread di un processo di comunicare e sincronizzare le proprie azioni, in particolare l'uso delle variabili condivise. I thread possono inoltre comunicare usando i meccanismi di IPC e sincronizzazione descritti nella Sezione apposita.

I principali vantaggi dell'uso dei thread sono la facilità di condividere dati (tramite variabili globali) tra thread cooperanti e il fatto che alcuni algoritmi si adattano più naturalmente a un'implementazione multithread rispetto a una multiprocesso. Inoltre, un'applicazione multithread può sfruttare in modo trasparente le potenzialità di elaborazione parallela offerte dall'hardware multiprocessore.

Gruppi di Processi e controllo dei Job nella Shell

Ogni programma eseguito dalla shell viene avviato in un nuovo processo. Per esempio, la shell crea tre processi per eseguire la seguente pipeline di comandi (che mostra l'elenco dei file nella directory corrente ordinati per dimensione):

$ ls -l | sort -k5n | less

Tutte le shell principali, eccetto la Bourne shell, offrono una funzionalità interattiva chiamata job control, che consente all'utente di eseguire e manipolare simultaneamente più comandi o pipeline. Nelle shell con job control, tutti i processi di una pipeline vengono collocati in un nuovo gruppo di processi o job. (Nel caso semplice di una riga di comando contenente un solo comando, viene creato un nuovo gruppo di processi contenente solo quel singolo processo.) Ogni processo in un gruppo di processi condivide lo stesso identificatore intero di gruppo, che corrisponde al PID di uno dei processi del gruppo, definito leader del gruppo di processi.

Il kernel permette di eseguire varie operazioni, in particolare l'invio di segnali, su tutti i membri di un gruppo di processi. Le shell con job control sfruttano questa caratteristica per consentire all'utente di sospendere o riprendere tutti i processi di una pipeline, come descritto nella sezione successiva.

Sessioni, Terminali di Controllo e Processi di Controllo

Una sessione è un insieme di gruppi di processi (job). Tutti i processi di una sessione condividono lo stesso identificatore di sessione. Il leader di sessione è il processo che ha creato la sessione; il suo PID diventa l'ID della sessione.

Le sessioni sono usate principalmente dalle shell con controllo dei job. Tutti i gruppi di processi creati da una shell di questo tipo appartengono alla stessa sessione della shell stessa, che funge da leader di sessione.

Di solito una sessione ha un terminale di controllo associato. Il terminale di controllo viene stabilito quando il processo leader di sessione apre per la prima volta un dispositivo terminale. Per una sessione creata da una shell interattiva, si tratta del terminale su cui l'utente ha effettuato il login. Un terminale può essere il terminale di controllo di al più una sessione.

Come conseguenza dell'apertura del terminale di controllo, il leader di sessione diventa il processo di controllo per quel terminale. Il processo di controllo riceve un segnale SIGHUP se si verifica una disconnessione del terminale (ad esempio, se la finestra del terminale viene chiusa).

In ogni momento, un gruppo di processi all'interno di una sessione è designato come gruppo di processo in primo piano (job in foreground); questo gruppo può leggere l'input dal terminale e inviare l'output a esso. Se l'utente digita il carattere di interruzione (di solito CTRL+C) o il carattere di sospensione (di solito CTRL+Z) sul terminale di controllo, il driver del terminale invia un segnale che termina o sospende (cioè ferma) il gruppo di processo in primo piano. Una sessione può contenere un numero arbitrario di gruppi di processo in background (job in background), creati terminando un comando con il carattere &.

Le shell con controllo dei job forniscono comandi per elencare tutti i job, inviare segnali ai job e spostare i job tra primo piano e background.

Pseudoterminali

Uno pseudoterminale è una coppia di dispositivi virtuali collegati, noti come master e slave. Questa coppia fornisce un canale IPC che permette il trasferimento di dati in entrambe le direzioni tra i due dispositivi.

Il punto fondamentale di uno pseudoterminale è che il dispositivo slave offre un'interfaccia che si comporta come un terminale. Questo rende possibile collegare un programma orientato al terminale al dispositivo slave e, contemporaneamente, utilizzare un altro programma collegato al dispositivo master per guidare il programma terminale. L'output scritto dal programma driver subisce la consueta elaborazione di input eseguita dal driver del terminale (ad esempio, nella modalità predefinita, un ritorno a capo viene mappato a un carattere di ritorno a capo) e viene poi passato come input al programma orientato al terminale collegato allo slave. Qualsiasi cosa il programma terminale scriva sullo slave viene trasmessa (dopo aver effettuato tutta l'elaborazione di output tipica del terminale) come input al programma driver. In altre parole, il programma driver svolge la funzione normalmente eseguita dall'utente su un terminale convenzionale.

I pseudoterminali sono impiegati in molteplici applicazioni, in particolare nell'implementazione di finestre terminale fornite da un login X Window System e in applicazioni che offrono servizi di login di rete, come telnet e ssh.

Data e Ora

Due tipologie di tempo interessano un processo:

  • Tempo reale: viene misurato rispetto a un punto di riferimento standard (tempo di calendario) oppure rispetto a un punto fisso, tipicamente l'inizio, nella vita di un processo (tempo trascorso o "wall‑clock"). Nei sistemi UNIX, il tempo di calendario è conteggiato in secondi a partire dalla mezzanotte del 1° gennaio 1970, Coordinated Universal Time (abbreviato UTC), e si basa sul meridiano di Greenwich, Inghilterra, per la definizione dei fusi orari. Questa data, vicina alla nascita del sistema UNIX, è nota come Epoch.
  • Tempo di processo, detto anche tempo CPU, è la quantità totale di tempo CPU che un processo ha consumato dall'avvio. Il tempo CPU si suddivide ulteriormente in:

    • Tempo CPU di sistema: il tempo speso nell'esecuzione di codice in modalità kernel (ad esempio, chiamate di sistema e altri servizi del kernel per conto del processo).
    • Tempo CPU utente: il tempo speso nell'esecuzione di codice in modalità utente (cioè il normale codice del programma).

Il comando time mostra il tempo reale, il tempo CPU di sistema e il tempo CPU utente impiegati per eseguire i processi in una pipeline.

Architettura Client/Server

In vari punti di queste lezioni, discutiamo la progettazione e l'implementazione di applicazioni client‑server.

Un'applicazione client‑server è suddivisa in due processi componenti:

  • un client, che richiede al server l'esecuzione di un servizio inviandogli un messaggio di richiesta;
  • un server, che esamina la richiesta del client, esegue le azioni appropriate e poi invia un messaggio di risposta al client.

Talvolta, client e server possono intrattenere un dialogo esteso di richieste e risposte.

Tipicamente, l'applicazione client interagisce con l'utente, mentre l'applicazione server fornisce l'accesso a una risorsa condivisa. È comune avere più istanze di processi client che comunicano con una o poche istanze del processo server.

Client e server possono risiedere sullo stesso computer host o su host distinti collegati tramite una rete. Per comunicare tra loro, client e server utilizzano i meccanismi IPC descritti nella Sezione 2.10.

I server possono implementare una varietà di servizi, ad esempio:

  • fornire l'accesso a un database o a un'altra risorsa informativa condivisa;
  • fornire l'accesso a un file remoto attraverso la rete;
  • incapsulare una logica di business;
  • fornire l'accesso a una risorsa hardware condivisa (ad es., una stampante);
  • servire pagine web.

Incapsulare un servizio all'interno di un unico server è utile per diversi motivi, tra cui:

  • Efficienza: può risultare più economico fornire un'unica istanza di una risorsa (ad es., una stampante) gestita da un server, anziché fornire la stessa risorsa localmente su ogni computer.
  • Controllo, coordinamento e sicurezza: mantenendo una risorsa (soprattutto informativa) in un unico luogo, il server può coordinare l'accesso (ad es., impedendo a due client di aggiornare simultaneamente la stessa informazione) o proteggerla, rendendola disponibile solo a client selezionati.
  • Operatività in ambienti eterogenei: in una rete, i vari client e il server possono girare su piattaforme hardware e sistemi operativi diversi.

Applicazioni in Tempo Reale (Realtime)

Le applicazioni in tempo reale sono quelle che devono rispondere in modo tempestivo a un input. Spesso tale input proviene da un sensore esterno o da un dispositivo di input specializzato, e l'output consiste nel controllare qualche hardware esterno. Esempi di applicazioni con requisiti di risposta in tempo reale includono linee di assemblaggio automatizzate, sportelli automatici bancari (ATM) e sistemi di navigazione aeronautica.

Sebbene molte applicazioni in tempo reale richiedano risposte rapide all'input, il fattore determinante è che la risposta è garantita entro un certo intervallo di tempo (deadline) dopo l'evento scatenante.

Fornire questa reattività, soprattutto quando sono richiesti tempi di risposta brevi, richiede il supporto del sistema operativo sottostante. La maggior parte dei sistemi operativi non offre nativamente tale supporto perché i requisiti di reattività in tempo reale possono confliggere con quelli dei sistemi operativi multi‑utente a condivisione temporale. Le implementazioni tradizionali di UNIX non sono sistemi operativi in tempo reale, sebbene siano state create varianti in tempo reale. Anche varianti in tempo reale di Linux sono state sviluppate, e i kernel Linux più recenti stanno evolvendo verso un supporto nativo completo per le applicazioni in tempo reale.

POSIX.1b ha definito una serie di estensioni a POSIX.1 per il supporto delle applicazioni in tempo reale. Queste includono I/O asincrono, memoria condivisa, file mappati in memoria, blocco della memoria, orologi e timer in tempo reale, politiche di scheduling alternative, segnali in tempo reale, code di messaggi e semafori. Anche se non si qualificano strettamente come tempo reale, la maggior parte delle implementazioni UNIX ora supporta alcune o tutte queste estensioni. (Nel corso di queste lezioni descriveremo le funzionalità di POSIX.1b supportate da Linux.)

In queste lezioni, usiamo il termine real time per riferirci al concetto di tempo di calendario o tempo trascorso, e il termine realtime per indicare un sistema operativo o un'applicazione che fornisce il tipo di reattività descritto in questa sezione.

Il File System /proc

Come molte altre implementazioni UNIX, Linux offre un file system /proc, costituito da un insieme di directory e file montati sotto la directory /proc.

Il file system /proc è un file system virtuale che espone le strutture dati del kernel sotto forma di file e directory, come se fossero parte di un normale file system. Questo offre un meccanismo semplice per visualizzare e modificare vari attributi di sistema. Inoltre, un insieme di directory con nomi del tipo /proc/PID, dove PID è l'identificatore di un processo, consente di consultare le informazioni su ciascun processo in esecuzione.

Il contenuto dei file in /proc è generalmente testo leggibile dall'uomo e può essere analizzato da script shell. Un programma può semplicemente aprire e leggere, o scrivere, il file desiderato. Nella maggior parte dei casi, è necessario avere privilegi per modificare i contenuti dei file nella directory /proc.

Man mano che descriviamo le varie parti dell'interfaccia di programmazione di Linux, illustreremo anche i file /proc pertinenti. Le prossime lezioni forniranno ulteriori informazioni generali su questo file system. Il file system /proc non è definito da alcuno standard; i dettagli che presentiamo sono specifici di Linux.

In Sintesi

In questa lezione abbiamo esaminato una serie di concetti fondamentali legati alla programmazione di sistema su Linux.

Una comprensione di questi concetti dovrebbe fornire ai lettori con poca esperienza su Linux o UNIX le basi necessarie per iniziare a studiare la programmazione di sistema.