1. Introduzione

1.1 Definizione.

Esistono numerose definizioni di Sistema Operativo, ciascuna delle quale mette in risalto compiti particolari che devono essere svolti: questo è comprensibile soprattutto se si pensa all'evoluzione storica legata al contemporaneo sviluppo dell'hardware.

1^ def. Il S.O. è un insieme di programmi che rendono facilmente disponibile all'utente la potenza di calcolo dell'hardware.

Questa è una definizione astratta la cui parola chiave è "facilmente" che introduce tutti i problemi di interfaccia utente e di semplicità d'uso.

2^ def. Il S.O. è un insieme di programmi che controlla l'esecuzione dei programmi utente e l'uso delle risorse ad essi necessarie.

In questa definizione l'accento è posto sull'attività di supervisore, che il S.O. deve esercitare nei confronti di tutti gli altri programmi che vengono eseguiti sulla macchina e sulla gestione delle risorse del computer.

3^ def. Il S.O. è un insieme organizzato di programmi che agisce come interfaccia tra l'hardware della macchina e l'utilizzatore.

In questa definizione si sottolineano i problemi di architettura, cioè di organizzazione interna dei S.O.. Questi sono programmi complessi, sottoposti a continue modifiche ed implementazioni e che quindi devono soddisfare a certi criteri per quanto riguarda la struttura stessa per evitare di aumentare i già alti costi di sviluppo.

Dalle definizioni proposte si possono dedurre alcune funzioni di un S.O.:
- controllo dell'esecuzione dei programmi: impedire interferenze tra i vari utenti, evitare danni dovuti al comportamento scorretto di un singolo utente.
- amministrare le risorse: molto importante soprattutto per macchine multiutente in cui le risorse vengono condivise tra più programmi.
- facilitazioni della programmazione utente.

Possiamo quindi pensare al S.O. come ad un insieme di programmi che si interpone tra l'hardware e l'utilizzatore e che permettono al calcolatore di svolgere le proprie funzioni con la massima efficienza possibile.

1.2 Efficienza

Definire l'efficienza non è agevole: ci sono vari parametri, spesso in contrasto tra loro. Vediamone alcuni:

- il THROUGHPUT: è il lavoro compiuto dal calcolatore nell'unità di tempo.

- il TURNAROUND TIME: è il tempo che intercorre tra l'introduzione di dati in ingresso e l'ottenimento dei risultati in uscita.

Questi due parametri sono in contrasto. Infatti il miglior tempo di turnaround per un singolo programma si ha quando gli si concede l'uso esclusivo della macchina per tutto il tempo di esecuzione.

- OVERHEAD: è il tempo di esecuzione dei programmi del S.O. che sarà maggiore quanto più sofisticato è il S.O..

1.3 Evoluzione storica

Analogamente all'hardware anche i S.O. hanno avuto un'evoluzione. In genere si identificano cinque generazioni diverse:

a) 0 : ANNI '40 ASSENZA DEL S.O.

b) 1 : ANNI '50 GESTIONE A LOTTI

c) 2 : ANNI '60 MULTIPROGRAMMAZIONE

d) 3 : META' 60..META'70 SISTEMI GENERAL PURPOSE (IBM 360)

e) 4 : META' 70..META'80 SISTEMI USER FRIENDLY

f) DATA BASE

g) SISTEMI DISTRIBUITI

a) gli utilizzatori scrivono programmi direttamente in linguaggio macchina li caricano in memoria e avviano l'elaboratore per l'esecuzione. Ogni programma viene caricato singolarmente, viene fatto eseguire fino al suo naturale completamento o fino a quando si ferma per qualche motivo. A questo punto l'operatore può eseguire una dump se necessario oppure prelevare i risultati stampati e preparare la macchina per una nuova elaborazione, con grandi perdite di tempo di C.P.U.

Schematicamente un processo di elaborazione può essere così rappresentato:

WHILE ci sono programmi DO

BEGIN

carica programma

esegui e produci risultati

controlla e riazzera lo stato della macchina

END

b) In questa fase viene automatizzata la terza operazione. I programmi sono fatti per restituire il controllo alla fine della propria elaborazione al S.O. che azzera lo stato della macchina e inizia a leggere il programma successivo. Per fare questo vengono introdotte delle schede di controllo che rappresentano un primo esempio di JOB CONTROL LANGUAGE. Una ulteriore ottimizzazione viene realizzata radunando un insieme di programmi in un singolo gruppo (BATCH) e caricandoli su una periferica più veloce del lettore di schede. Tale caricamento è svolto in genere "fuori linea" per mezzo di un elaboratore più piccolo di supporto. Vengono messi a disposizione dell'utente dei files di sistema per l'input e l'output standard del calcolatore e vengono fornite delle routines per leggere e scrivere su questi files. Il programmatore può quindi utilizzare queste uscite standard indicandole con un nome e non con l'indirizzo fisico. Vengono così superati i tempi morti legati all'input e l'output: la C.P.U. rimane però sottoutilizzata.

c) Una delle innovazioni hardware più importanti è costituita dal CANALE di dati (data channel): questo è un calcolatore più semplice dell'unità centrale, ma dotato di un insieme di istruzioni e di registri, che controlla la comunicazione e la trasmissione dei dati tra la memoria centrale e le unità periferiche. La memoria è divisa tra C.P.U. e canale e contiene i dati e i programmi per entrambi. Diventa così possibile sovrapporre la fase di output di un programma con quella di input del successivo. Ma questo non basta. Con uno sviluppo tecnologico successivo ecco che diventano disponibili gli interrupts, cioè segnali che quando arrivano alla C.P.U. hanno la capacità di interrompere il suo normale funzionamento.

Con i canali e il meccanismo di interrupts diventa possibile la multiprogrammazione, cioè sovrapporre completamente la fase di I/O con quella di elaborazione. Infatti se i dispositivi di I/O sono abbastanza autonomi da poter funzionare senza costante controllo da parte della C.P.U., se sono in grado di segnalare alla C.P.U. la terminazione del proprio lavoro diventa possibile utilizzare il tempo di I/O di un programma per eseguirne un altro eventualmente presente in memoria. (diventa chiaro la definizione di S.O.come gestore di risorse: i vari programmi che coesistono in memoria devono spartirsi l'uso delle periferiche, della memoria stessa e naturalmente della C.P.U.). Gli stessi S.O. sono multiprogrammati: i programmi che lo conpongono competono tra loro e con quelli utente per l'uso della C.P.U.. Esiste un programma che serve per amministrare gli altri programmi. Altra importante caratteristica dei S.O. di questa generazione è l'estensione della DEVICE INDIPENDENCE e la nascita del TIME SHARING.

d) nascita dei S.O. general purpose: aumenta l'overhead del sistema.

e) nascita dei sistemi virtuali; sempre più funzioni sono realizzate a livello hardware e firmware allo scopo di ridurre i costi di sviluppo e di migliorare le prestazioni dei sistemi stessi. Vengono inglobate sempre più funzioni nel S.O. che in precedenza erano caratteristiche dei programmi applicativi.

2. Architettura.

2.1 Introduzione.

Un computer è una macchina che, eseguendo istruzioni, può aiutare una persona a risolvere problemi. L'insieme delle istruzioni utilizzate per tale scopo forma il programma. Ogni computer riconosce ed esegue un set limitato di istruzioni molto semplice che forma il LINGUAGGIO MACCHINA. Ad esempio

addiziona due numeri

controlla se un numero è 0

sposta dei dati da una zona di memoria ad un'altra

Per realizzare un nuovo computer è quindi necessario stabilire il set di istruzioni macchina. Però l'utilizzo da parte delle persone di tale linguaggio per comunicare con il computer è difficile, noioso e causa di errori. Come si possono migliorare le cose? Non è possibile aumentare troppo il set di istruzioni macchina, o addirittura renderle più vicine al linguaggio comune sia per problemi tecnici, sia perchè una tale soluzione renderebbe il costo del computer spaventosamente elevato.

Si può pensare allora di realizzare un nuovo linguaggio, che chiameremo L2 così come il linguaggio macchina sarà L1, con un set di istruzioni più vasto e potente. A ciascuna istruzione di L2 corrisponderà una serie di istruzioni di L1, comprensibili dal computer. Però come avviene la fase di traduzione da L2 a L1?

Si può procedere in due modi: il primo metodo consiste nel sostituire tutte le istruzioni di L2 con le corrispondenti di L1. Il computer eseguirà quindi un programma scritto in L1. Questa tecnica è chiamata COMPILAZIONE. La sostituzione delle istruzioni sarà svolta da un programma scritto in L1.

Una seconda tecnica consiste nello scrivere un programma in L1 che, avuto in input il programma in L2, lo esamina una istruzione alla volta e dopo averla riconosciuta, esegue il set di istruzioni di L1 associato. Questa tecnica è detta INTERPRETAZIONE.

I due metodi sono equivalenti: in entrambi ciascuna istruzione di L2 porterà all'esecuzione di un equivalente set di istruzioni di L1. Nella interpretazione non si avrà la generazione di un programma in L1 mentre nelle compilazione si procederà prima alla conversione del programma di L2 in un equivalente programma di L1.

Al posto di pensare alla compilazione o alla interpretazione è più conveniente immaginare di avere a disposizione un computer che abbia come LINGUAGGIO MACCHINA il set di istruzioni L2: questa MACCHINA VIRTUALE è quindi in grado di riconoscere un programma scritto in L2. Che poi questa macchina esista nella realtà o che venga simulata realizzando un programma in L1 che traduca o interpreti il programma in L2 non è molto importante.

Per poter scrivere agevolmente l'interprete per la traduzione da L2 a L1 non devono essere troppo differenti i set di istruzioni L1 e L2: può succedere allora che anche L2 sia troppo lontano dal linguaggio che l'utente utilizza per la descrizione del proprio problema. Potrebbe così essere necessario un nuovo set di istruzioni, L3, più vicino al liguaggio utilizzato dall'utente, e che identificherà una nuova macchina vituale realizzata ad esempio con un interprete scritto in L2.

In teoria tale processo potrebbe continuare definendo un nuovo set di istruzioni (L4), e realizzando in L3 un interprete per tradurle. Ad esempio il Pascal può essere pensato come una machina virtuale che ha come linguaggio macchina il set di istruzioni del Pascal. E' importante sottolineare che non è necessario sapere come è stata realizzata questa macchina virtuale per poterla utilizzare!

2.2 I livelli sugli elaboratori.

Ogni computer, ai nostri giorni, ha almeno sei livelli:

Il livello L0 è il livello hardware, realizzato attraverso transistor.

Il livello L1 è chiamato il livello microprogramma. E` realizzato un programma, chiamato microprogramma che serve per interpretare le istruzioni del livello L2. E` anche vero che il microprogramma del livello L1 si differenzia molto da macchina a macchina pur mantenendo pero un certo numero di compiti comuni, quali muovere dati da una parte all'altra della macchina o piere semplici tests. Il microprogramma funzionante al livello L1 definisce implicitamente il set di istruzioni L2.

E` importante ricordare che alcuni computer (RISC) sono sprovvisti del livello microprogramma. Questo significa che le istruzioni di L2 sono riconosciute direttamente dall'hardware.

Il livello L2 è il livello istruzioni: troviamo qui il set di istruzioni macchina. Al livello 2 è definito un interprete chiamato sistema operativo (kernel) che interpreta le istruzioni del livello superiore.

Il livello L3, chiamato livello del sistema operativo, è un livello ibrido: infatti alcune istruzioni di questo livello sono interpretate dall'interprete del livello L2, altre direttamente dal microinterprete.

Tra il livello L3 e il livello L4 esiste un vero salto: il livello assembler e i successivi definiscono dei nuovi linguaggi simbolici, mentre i livelli inferiori definiscono dei linguaggi numerici.

Il livello L5 è il livello dei linguaggi problem oriented come il C, il Pascal, il Lisp, il Fortran: i programmi scritti in questi linguaggi sono in genere tradotti dai COMPILATORI in programmi del livello L4 o L3.

Il livello L6 è quello dei programmi applicativi, cioè di programmi costruiti e realizzati per applicazioni particolari.

                L5      Livello dei linguaggi
                        orientati ai problemi

                                 !
                                 !
                                 ! compilatori
                                 !
                                 !

                L4      Livello del linguaggio
                        assembler
                                 !
                                 !
                                 ! assemblatori
                                 !
                                 !
                                 !
                L3      Livello sistema
                        operativo
                                 !
                                 ! parziale interpretazione
                                 ! (operating system)
                                 !

                L2      Livello istruzioni
                                 !
                                 ! interpretazione
                                 ! (microprogramma)
                                 !
                L1      Livello microprogramma
                                 !
                                 ! I microprogrammi sono
                                 ! direttamente eseguiti
                                 ! dall'hardware
                                 !
                L0      Livello circuiti logici
 
 

2.3 Architettura di Unix.

        ------------------------------------------------------------------
        ! cc !  !    ! date ! wc ! shell ! vi ! who ! ps ! ed !   !     !
        !---------------------------------------------------------------!
        !    !                                                    !     !
        !    !                                                    !     !
        !    !                                                    !     !
        !    !----------------------------------------------------!     !
        !....!                   KERNEL                           !a.out!
        !    !    K                                               ! ....!
        !    !    E         !------------------!                  !     !
        !    !    R         ! Hardware         !                  !     !
        !    !    N         !                  !                  !     !                 
        !    !    E         !----------------- !                  !     !
        !    !    L                                               !     !
        !    !                                                    !     !
        !    !                                                    !     !
        -----------------------------------------------------------------
        !    !      !      !     !      !      !       !     !    !     !
        !    ! grep !      !     !      !kill  !       !     !    !     !
        -----------------------------------------------------------------
Questa figura mostra a grandi linee l'architettura del sistema operativo Unix. L'hardware è posto al centro del diagramma ( L0 ), circondato dal kernel che fornisce le primitive per interagire con l'hardware stesso. In questo schema non è rappresentato il livello L1 perchè lo si può immaginare caratteristico dell'elaboratore e quindi è raggruppato nello hardware stesso. Il livello L3 è qui rappresentato dai comandi tipo date, shell, who .... che vengono interpretati sia dal livello kernel, sia dal livello microistruzione. Le "istruzioni" di L3 interpretate dal kernel sono le chiamate di sistema. Tutte le altre istruzioni sono interpretate direttamente dal micropropramma.

Nel System V ci sono circa 64 chiamate di sistema. Hanno opzioni semplici che le rendono semplici da utilizzare e forniscono all'utente una buona potenza. Permettono la gestione dei files, la gestione dei processi e la gestione dell'input/output. Nelle prossime pagine vedremo con esempi come utilizzarne alcune.

2.4 Chiamate di sistema.

                    programmi utente 
                           !------------------------!
                           !                   ____________
                           !                   ! librerie !
                           !                   !__________!
livello utente             !  <------- trap --->    !
---------------------------!------------------------!----------
                           !                        !
                 ----------V------------------------V-------
                 ! interfaccia chiamate di sistema         !
                 -------------------------------------------
                           !                        !
                           !                        !
                  -----------------            ----------------
                  ! gestione del  ! <--------> ! gestione dei !
                  ! file system   !            ! processi     !
                  -----------------            ----------------
                     !         !                    !
                     !    ----------------          !
                     !    ! buffer cache !          !
                     !    ----------------          !
                     !         !                    !
                     !         !                    !
                ----------------------------        !
                ! driver ! driver ! driver !        !
                ! TTY    ! disco  ! ...... !        !
                ----------------------------        !
                ! driver delle periferiche !        !
                ----------------------------        !
                           !                        !
                           !                        !
             --------------------------------------------------
             !                 controllo hardware             !
             --------------------------------------------------

Livello kernel
-----------------------------------------------------------------
Livello hardware

               ----------------------------------------------
               !                      hardware              !
               ----------------------------------------------
Come abbiamo osservato nelle pagine precedenti le chiamate al kernel rivestono un ruolo fondamentale per il funzionamento corretto di un sistema operativo. Nella figura è mostrato uno schema a blocchi. In essa sono indicati tre livelli: il livello utente, il livello kernel e il livello hardware. Come si può osservare le richieste dell'utente avvengono attraverso chiamate al sistema, che non si differenziano dalle normali chiamate di funzioni in C. Sarà cura delle librerie mappare queste funzioni nelle primitive per accedere al kernel. L'insieme delle chiamate può essere diviso in due blocchi: quelle che interagiscono con il sottosistema per la gestione dei files, e quelle che interagiscono con il sottosistema per la gestione dei processi.

Il primo sottosistema svolge una funzione molto importante, l'input/output dei dati, che dipende dal tipo di periferico interessato. In Unix abbiamo due tipi di periferici, a caratteri o a blocchi. I dispositivi a blocchi sono quelli peri i quali i dati sono bufferizzati nelle operazioni di I/O. Come si può osservare nello schema precedente il meccanismo di bufferizzazione interagisce con i drivers dei periferici, che come vedremo in seguito sono la parte del kernel preposta al controllo del periferico stesso. Unix usa il meccanismo del buffer cache per ottimizzare l'accesso ai periferici. Infatti cerca di mantenere in memoria più dati possibili per cercare di diminuire il numero di accessi ai periferici stessi. I dispositivi a carattere sono quei periferici per i quali non esiste un meccanismo di buffering ma la trasmissione avviene carattere per carattere.

Il sottosistema per la gestione dei processi gestisce sia il singolo processo, sia controlla il comportamento del singolo processo nei confronti degli altri. Come si può osservare dallo schema precedente, i due sottosistemi interagiscono tra loro. Questo avviene durante la fase di di caricamento in memoria di un file per l'esecuzione. Infatti prima di essere eseguito un programma deve essere caricato in memoria.

Per quanto riguarda questo sottosistema è importante ricordare sia le funzioni di scheduling sia le funzioni per la gestione della memoria fisica, entrambi fondamentali per il corretto utilizzo di due delle risorse più importanti di un sistema di calcolo, la CPU e la memoria, e di cui parleremo più diffusamente nelle prossime pagine.

2.4.1 Chiamate per l'input/output.

Descriviamo brevemente alcune chiamate di Unix per l'input/output da file.

int open( path, flags, perms )
char* path;                    /* pathname */
int flags;                       /* indicatori */
int perms;                     /* permission */

restituisce il descrittore di file, -1 per errore

I possibili flags sono:

O_RDONLY apertura in sola lettura.

O_WRONLY apertura in sola scrittura.

O_RDWR apertura in lettura e scrittura.

O_NDELAY ha effetto solo in caso di pipe, FIFO, e di file speciali per linee di comunicazione. Non ha effetto su file ordinari o su direttori.

O_CREAT se il file non esiste lo crea, utilizzando le permission (.perms).

O_TRUNC se il file esiste ne tronca la lunghezza a zero. O_EXCL associato con O-CREAT fallisce se il file esiste già.

O_APPEND assicura che tutte le scritture successive avvengano alla fine del file

I flags che abbiamo elencato possono essere combinati tra loro attraverso l'or sui bit. Ad esempio, per aprire un file in lettura/scrittura e se non c'è crearlo si opera così:

if( (fd=open(path, O_RDWR | O_CREAT, 0666)) == -1 ) error("open");

O_TRUNC è molto pericoloso. E` l'unico modo con cui si può distruggere un file esistente.

int write( fd, buf, nbyte )
int fd;
char* buf;
unsigned nbyte;

restituisce il numero di byte scritti o -1 per errore.

La write scrive su un file aperto, con descrittore fd, nbyte puntati da buf. La scrittura parte dalla posizione corrente del puntatore al file che verrà incrementato del numero di byte effettivamente scritti.

Attenzione che la chiamata write non trasferisce i dati da buf al file ma da buf al buffer cache, aggiornando il puntatore al file. La write effettiva sarà effettuata da Unix quando si verificheranno alcune condizioni. Questo serve per migliorare le prestazioni del sistema. Se eventualmente altri processi richiedessero i dati appena scritti, Unix li preleva dal buffer cache con enormi vantaggi di tempo. Ma può essere fonte di problemi. Se durante l'operazione di scrittura su file per qualche mal funzionamento si verificassero degli errori, il processo che ha richiesto l'operazione di write non può essere avvisato. Inoltre i dati dal buffer cache potrebbero andare persi in caso di crash.

Un possibile esempio di scrittura potrebbe essere il seguente:

if( (n=write(fd, buf, nbyte)) != nread) errore("write");
int read( fd, buf, nbyte )
int fd;
char* buf;
unsigned nbyte;

restituisce il numero di byte letti, 0 in caso di EOF, -1 in caso di errore.

La chiamata read trasferisce dal file aperto, con descrittore fd, nbyte in buf. La lettura ha inizio dalla posizione corrente del puntatore al file e dopo la lettura il puntatore viene incrementato del numero di byte effettivamente trasferiti. I dati vengono letti dal buffer cache se sono già presenti, oppure il processo deve attendere che il kernel li trasferisca nei propri buffer. Il kernel addirittura cerca di riconoscere quali sono i processi che necessitano di letture di dati sequenziali e si preoccupa di trasferire tali dati nei propri buffer prima che il processo stesso li richieda.

2.4.2 Chiamate per la gestione dei processi.

Abbiamo già utilizzato parecchie volte nelle pagine precedenti il termine processo. Per ora diciamo che un processo è un programma in esecuzione. Vedremo poi nel prossimo capitolo di spiegare meglio cosa significa questa definizione. In Unix si crea un processo attraverso una chiamata di sistema.

int fork()

restituisce il process id oppure 0 se ha successo, -1 per errore.

La chiamata di sistema fork crea un processo creando una copia dei dati del vecchio processo. Dopo che la funzione fork è stata eseguita dal kernel sia il processo padre che il processo figlio ricevono un valore: per il padre è il process id del figlio, per il figlio è il numero 0. L'unica causa di errore che può essere generato da una fork è l'esauruirsi delle risorse del sistema.

Per capire cosa avviene con la chiamata di sistema fork si invita a provare il seguente programma:

provafork()
{
    int pid;
    printf(" Inizio test \n");
    pid=fork();
    printf("Restituito da fork %d \n", pid );
}

void exit( status )
int status;

non restituisce valori.

La chiamata exit termina il processo che l'ha richiamata, con un codice uguale al byte più basso di status. Tutti i descrittori di file aperti vengono chiusi, tutti i buffer vengono svuotati. Se ci sono dei processi figli ancora attivi, non vengono disturbati ma viene modificato il puntatore al padre con 1, il processo init. E` importante sottolineare che exit è una chiamata di sistema, la sola che non ritorna mai.

int getpid()

restituisce il process id.

3. Processi.

3.1 Introduzione.

Definizione. Un processo è un programma in esecuzione.

Che cosa significa? vediamo un esempio.

Supponiamo che il sig. Pallino debba preparare per pranzo una zuppa di pesce.
Pallino non è un abile cuoco e per preparare la zuppa segue fedelmente una ricetta molto precisa in tutte le sue parti. Inoltre ha a disposizione una cucina fornitissima e tutti gli ingredienti occorrenti, nella giusta dose.

Pallino incomincia il suo lavoro seguendo fedelmente la ricetta, aspettando che i vari ingredienti preparati e cotti separatamente siano pronti per essere uniti nella zuppa. Ma mentre lavora suona il telefono. Pallino deve rispondere.

Lascia il lavoro zuppa, segnando su un foglio la situazione in cui si trova e risponde al telefono. Dopo, grazie agli appunti riprende esattamente dalla situazione appena lasciata.

Il Processo è l'attività svolta da Pallino per preparare la zuppa, attività che necessita oltre che dell'algoritmo ( la ricetta ), di tutte le risorse necessarie ( gli ingredienti e la cucina fornita). Inoltre questa attività può essere interrotta dalla CPU ( Pallino ) per svolgere momentaneamente altre funzioni, ma deve essere necessariamente ripresa per produrre il risultato. Inoltre la CPU potrebbe mentre vengono compiute operazioni che non richiedono il suo intervento, svolgere altri compiti. (Pallino potrebbe leggere un libro mentre attende che la cottura sia terminata). E' necessario avere quindi degli strumenti che possano segnalare la fine di determinate operazioni, che permettano quindi la comunicazione tra i processi.

3.2. Caratteristiche di un processo in UNIX.

Ad ogni processo di UNIX vengono attribuiti

Inoltre UNIX riserva tre segmenti di lavoro costituenti l'immagine del processo: Il segmento testo: contiene il codice da eseguire.

Il segmento dati utente: si riferisce ai dati privati del processo.

Il segmento stack: viene gestito dinamicamente durante l'avanzamento del processo. Il kernel allocherà nuovo spazio per lo stack se non basta.

Al momento della creazione viene associato ad ogni processo una voce nella tabella dei processi ed un'area detta U_Area (User Area) che contiene tutti i dati che servono al Kernel per gestire il processo stesso :

3.3. Gerarchie di processi.

Nei S.O. che si basano sulla nozione di processo deve essere fornito un metodo per creare tutti i processi di cui si ha bisogno. UNIX fonisce la primitiva fork per la creazione.
L'esecuzione della fork provoca la duplicazione dell'immagine del processo padre e la creazione di un nuovo PID. Il processo figlio, attraverso la primitiva exec provvederà poi ad inizializzare il segmento testo e i segmenti dati prelevandoli da un file (programma). Il segmento dati di sistema rimane pressochè intatto.
Questa generazione può proseguire creando una strutura ad albero nella quale ogni elemento ha un solo padre ma può avere più figli.

Tutti i processi, escluso lo swapper hanno in UNIX un padre comune, init.
Il processo init è un processo speciale, che viene avviato subito dopo il caricamento di UNIX: tale processo legge dal file etc/inittab i processi da creare. In genere crea per ogni terminale un processo getty associandolo a ciascun terminale, e ponendolo in attesa di un segnale dal terminale stesso. All'arrivo del segnale viene risvegliato e procede alle operazioni di "connessione" dell'utente con il sistema.

3.4. Stato di un processo.

Durante la sua vita un processo può trovarsi in diverse situazioni, caratteriz zate principalmente da due elementi:

- possesso o meno della CPU

- residenza della sua immagine (memoria centrale o disco)

Per quanto riguarda il primo punto possiamo individuare tre stati:

a) stato di esecuzione: possiede la CPU e avanza

b) stato di attesa : il processo è stato messo in attesa di qualche evento esplicitamente dichiarato dal processo stesso mediante una chiamata di sistema : ad esempio per una read.

c) stato di pronto: il processo è pronto per avanzare, manca solo la CPU.

A questi stati bisogna aggiungere lo stato iniziale, cioè la nascita mediante Fork, e lo stato di terminazione.

E' utile sottolineare che un processo in stato di esecuzione può essere eseguito in due diverse modalità: Kernel e utente.

Durante la modalità utente, gli accessi del processo si limitano al proprio codice e ai propri dati;

Se invece un processo è in modalità Kernel, può accedere oltre che alle proprie arre, anche a quelle riservate al Kernel e ciò gli permette di eguire funzioni privilegiate.

Il passaggio da modalità utente a modalità Kernel avviene in modo implicito. Vi sono delle operazioni che, non potendo essere effettuate direttamente dall'utente per motivi di sicurezza, se richieste provocano il passaggio di modalità. La richiesta avviene attraverso una chiamata di sistema, che appare come una normale chiamata di funzione; la differenza è che il codice non è nell'area testo del processo chiamante bensì nell'area testo del Kernel.

3.5. Cambiamento di stato.

A livello macroscopico, nella situazione vista prima, i possibili cambiamenti di stato sono come indicati nella figura.

La transizione 1 avviene quando il processo scopre di non poter continuare l'esecuzione per mancanza di una risorsa.

Le transizioni 2 e 3 sono causte dallo scheduler (switcher), il quale decide quale processo deve passare in esecuzione, e per quanto tempo.

La transizione 4 si realizza quando si verifica l'evento esterno che il processo stava aspettando.

E' importante sottolianeare che la transizione 2 può avvenire solo se l'esecuzione è in modalità utente.

3.6. Realizzazione dei processi in Unix.

Per la realizzazione e la gestione dei processi Unix utilizza numerose strutture dati che possiamo così suddividere:

a) struttura per rappresentare l'insieme dei processi.

b) struttura per il funzionamento dei singoli processi.

c) struttura per l'indirizzamento delle informazioni relative ai singoli processi.

Punto A : Tutti i processi attivi sono rappresentati nella TABELLA DEI PROCESSI. Tale tabella è unica, risiede sempre in una memoria centrale e contiene informazioni su tutti i processi attivi nel sistema: ad ogni elemento è associato un particolare processo. La dimensione della tabella è fissata da un parametro di configurazione NPROC.

Le informazioni contenute nella tabella sono:

Punto B: Per il funzionamento dei singoli processi Unix riserva la n_area. Il KERNEL vede la sola n_area del processo di volta in volta in esecuzione poichè in essa sono contenute le informazioni relative alla attività svolta e non avrebbe senso analizzare le n_aree dei processi inattivi; inoltre il sistema accede alla n_area solo quando il processo è in modalità KERNEL. Perciò quando un processo entra nello stato di esecuzione il KERNEL individua l'indirizzo della corrispondente n_area.

Le informazioni contenute nella n_area riguardano:

Una parte della n_area è occupata da informazioni relative alla gestione dei files utilizati dal processo quali:

L'utilizzo di queste informazioni sarà dettagliato quando affronteremo i problemi relativi all'Input/Output.

Per quanto riguarda i dati relativi allo STATO DEL PROCESSO troviamo:
 

Punto C: Come abbiamo osservato in precedenza Unix divide lo SPAZIO LOGICO del processo in OGGETTI VIRTUALI (testi, dati,stack) ognuno dei quali occupa un preciso spazio di indirizzamento e può essere trattato autonomamente: l'insieme degli indirizzi di questo spazio virtuale costituisce una REGION.

Per ogni processo esiste una region distinta. Però i processi possono avere dati e codice in comune. E'quindi ovvio che non può esistere una corrispondenza biunivoca tra la region e la MEMORIA FISICA occupata. Ogni indirizzo logico di una region dovrà essere trasformato in un indirizzo fisico; e questo avviene utilizzando tabelle intermedie: le p_region, le region e le pagine.

Scenderemo più nei dettagli quando affronteremo le diverse tecniche per la gestione della memoria.

3.7. Comunicazione tra processi.

Ogni processo deve poter comunicare con gli altri processi per poter scambiare le infomazioni necessarie per avanzare.

Vediamo con un esempio cosa significa comuninicazione tra processi. In un sistema per elaborazione dati con molti utenti viene utilizzata per la stampa una tecnica di spool così funzionante: esiste un directory speciale , chiamato spool, e un processo speciale, stampa. Per stampare un file basta inserire il suo nome in spool. Il processo stampa e controlla periodicamente se in spool ci sono dei files e se li trova li stampa e toglie il loro nome da spool.

Supponiamo che spool abbia un numero illimitato di record per contenere i nomi dei files, e che esistano due variabili, out e in che indicano rispettivamente il prossimo file da stampare e il primo record libero.

Supponiamo che ad un certo istante i record da 0 a 6 siano liberi ( i files sono già stati stampati) mentre da 7 a 9 ci sono files ancora da stampare. Out conterrà 7 mentre In conterrà 10.

Contemporaneamente due processi A e B decidono di stampare. Si potrebbe verificare la seguente situazione:

Il processo A esegue la seguente operazione:

    posto_libero <-- in {posto_libero è locale}

A questo punto arriva una intenzione dal timer e la CPU viene assegnata a B. Anche B esegue

    posto_libero <-- in
    in <-- in+1
    spool[posto_libero] <-- nome

Quando poi la CPU viene riassegnata ad A

    in <-- in+1
    spool[posto_libero] <-- nome

Il nome inserito da B viene cancellato e sostituito da quello di A, in conterrà 12, e la posizione 11 sara non utilizzata. Il processo stampa non si accorgerà di questo , B non avrà uscite mentre la stampa di spool[11] creerà probabilmente problemi. Inoltre tale errore non sarà facilmente scoperto perchè sia A che B sono corretti !

3.8. Competizione tra processi.

Il problema di interazione fra processi visto nell' esempio precedente è chiamato competizione e si verifica tutte le volte che due o più processi richiedono una  risorsa comune di tipo riusabile  e di molteplicità finita. (molteplicità = numero di processi che possono utilizzare contemporaneamente una risorsa).

Poichè l'errore nasce dal fatto che i due processi A e B accedono contemporaneamente ad una stessa variabile allora il problema si risolvere impedendo il doppio accesso contemporaneo alla variabile : questo tipo di problema si chiama mutua esclusione.
In modo astratto si potrebbe formulare il problema così: ogni processo occupa parte del tempo a fare computazioni interne che non portano a condizioni anomale. Però potrebbe accedere ogni tanto a risorse condivise, causando errori. La parte di programma in cui il processo accede alla risorsa condivisa è detta sezione critica. 

Se si potessero sistemare le cose in modo che non ci siano mai due processi nella loro sezione critica nello stesso momento si potrebbero evitare le condizioni anomale. Per una soluzione efficente e corretta non basta! Bisogna ricordare che :
 

3.9. Semafori a basso livello.

Una possibile soluzione software del problema si ottiene utilizzando i "semafori a livello basso". Sono variabili booleane L (luchetto):

L=0 {l=vero} -----> nessun processo è nella sezione critica; porta aperta

L=1 {l=falso} c'è un processo nella sezione critica; porta chiusa.

Un possibile controllo di richiesta è:

    WHILE NOT l DO;
    l <-- false;

Il protocollo di rilascio

    l <-- true;

Questi due protocolli sono spesso indicati con il nome di LOCK(l) UNLOCK(l)

Si osservi che nell' operazione di LOCK il processo continua a ripetere il ciclo WHILE sinchè l=true. L' attesa è quindi attiva, cioè impegna inutilmente il processore.

In questo modo il problema è risolto? Direi di no. Se pensiamo di utilizzare LOCK e UNLOCK nell'esempio iniziale dello spool, spostiamo semplicemente il problema della condivisione dalle variabili IN e OUT al semaforo. Per risolverlo, in un sistema uniprocessor si potrebbero disabilitare le interazioni durante le operazioni di LOCK e UNLOCK. Ma se si lavora con un multiprocessor?

3.10. Istruzione TEST AND SET.

Numerosi processori hanno una istruzione Test and Set che funziona cosi: legge il contenuto di una parola di memoria in un registro e sostituisce in memoria il vecchio contenuto con un valore diverso da zero.

Ma il fatto importante è che tale sostituzione è indivisibile: il bus di memoria viene bloccato sino a quando l'istruzione non è terminata, impedendo l'accesso alla memoria alle altre eventuali CPU.

I nuovi protocolli risulteranno così modificati:

ingresso

    T.A.S. registro,flag
    IF registro <> 0 goto ingresso

uscita

    flag <-- 0

Dove flag è la variabile condivisa, e che indica:

    0 : regione critica aperta

    1 : regione critica occupata

Questa soluzione presenta due gravi inconvenienti :
 

  1. il processo che effettua ingresso(x) effettua un tipo di attesa attiva; cioè impegna inutilmente la CPU
  2. se un processo esegue un ciclo così
                        WHILE TRUE DO
                                ingresso(x)
                                < sezione critica >
                                uscita(x)

 un processo B potrebbe attendere indefinitamente x=0, anche se questo si verifica,poichè il primo processo gli "ruba" sempre il tempo. Inoltre potrebbe anche verificarsi una situazione inaspettata: supponiamo che su un calcolatore ci siano due processi attivi, A e B, A con alta priorità, B con bassa. Supponiamo che lo scheduler assegni sempre il processore al processo con priorità più alta. In un certo istante A è in attesa e B in esecuzione. B entra con successo in una regione critica, A passa nello stato di pronto e viene messo in esecuzione.Richiede la regione attraverso ingresso(x) e la trova occcupata (da B). Allora si mette in attesa del rilascio (attesa attiva).Non sarà più interrotto, B non potrà proseguire e lasciare la regione critica. A sarà in attesa di un certo evento che non potrà succedere: sarà in una SITUAZIONE DI STALLO.

3.11. Attesa attiva.

Per poter risolvere il problema precedente sono necessarie delle primitive di comunicazione che blocchino i processi quando non possono entrare nelle regioni critiche. Vediamo un nuovo problema, conosciuto come problema del produttore-consumatore.

Supponiamo che due processi, A e B condividano un buffer comune di dimensione prefissata.

Il processo A (produttore) inserisce nel buffer delle informazioni, mentre il processo B (consumatore), le estrae. Non si possono fare ipotesi, per quanto riguarda la velocità di A e B.

Si possono verificare due situazioni pericolose:
 

Come si può risolvere il problema?

La situazione proposta in un articolo del 1965 da E.W. Dijkstra, introduce per la prima volta un nuovo tipo di variabile, il semaforo. Insieme al semaforo furono introdotte due primitive per la gestione, P e V.

P funziona così: si controlla il semaforo, se >0 viene decrementato, se = 0 viene posto in stato di attesa il processo che ha richiesto l'operazione P.

V funziona così: viene incrementato il semaforo; se ci sono dei processi che erano in attesa, uno di questi viene risvegliato.

E' importante sottolineare che le operazioni P e V sono indivisibili. Utilizzando le due primitive P e V potremmo risolvere il problema del produttore/consumatore così:

    semaforo=INTEGER
    mutex,empty,full:semaforo
    mutex=1; empty=n; full=0 {n è la dimensione del buffer}

Produttore

    WHILE not finito DO
        perpara_informazione
        P(empty)
        p(mutex)
        inserisci_informazione
        V(mutex)
        V(full)

Consumatore

    WHILE not finito DO
        P(full)
        P(mutex)
        togli_informazione
        V(mutex)
        V(empty)
        elebora/informazione

mutex è un semaforo binario e serve per garantire la mutua esclusione.

Per quanto riguarda UNIX la gestione dei semafori è molto piùcomplessa. Nel System V esistono alcune chiamate (complicate) che permettono operazioni sofisticate sui semafori. Per le persone interessate le chiamate sono: semget, semop, semctl. E' possibile implementare le P e V attraverso tali operazioni.

3.12 Monitors.

Riprendiamo l'esempio precedente: supponiamo che sia invertito l'ordine delle due P effettuate prima di inserire o togliere elementi dal buffer. Supponiamo che il buffer sia pieno. Il produttore si bloccherebbe, con mutex =0.
Il consumatore a sua volta, trovando mutex a zero si bloccherebbe.
Si avrà una situazione di stallo.

Questo esempio ci può far capire quanto sia complicato l'utilizzo diretto dei semafori da parte del programmatore. Per agevolare il programmatore nella stesura di programmi corretti è stata introdotta una primitiva di sincronizzazione di livello più alto, chiamata monitor.

Si può pensare il monitor come un ADT che garantisca in ogni istante al più un processo attivo nel monitor. Il programmatore è agevolato dal fatto che il monitor è un costrutto del linguaggio di programmazione: la mutua esclusione è garantita dal monitor stesso.

Anche se il monitor è una soluzione esteticamente valida non è efficiente: ad esempio potrebbero esserci operazioni in un monitor che non causano problemi, anche se eseguite parallelamente. Inoltre sono un costrutto del linguaggio di programmazione (presente su pochi linguaggi).

3.13. Scambio di messaggi.

Una soluzione diversa dei problemi visti nelle pagine precedenti è lo SCAMBIO DI MESSAGGI. Per realizzarlo sono necessarie due primitive SEND e RECEIVE, che sono chiamate di sistema.

In Unix tali chiamate non sono direttamente implementate, ma lo possono essere sfuttando altre chiamate. La sintassi delle due primitive è la seguente:

SEND (destinazione,messaggio)

RECEIVE (sorgente,messaggio)

Se non ci sono messaggi il ricevitore si deve bloccare fino a quando non ne arriva uno.

3.14. Problemi relativi all`IPC.

Problema dei 5 filosofi. Cinque filosofi sono seduti intorno ad un tavolo rotondo. Ciascun filosofo ha un piatto di spaghetti. Ogni filosofo,per mangiarli,necessita di due forchette. Tra due piatti c` è una forchetta sola. La vita di un filosofo è costituita da periodi di pensiero e periodi di alimentazione.Si può scrivere un programma per ogni filosofo,che garantisca a tutti di non morire di fame?

La soluzione banale potrebbe essere questa:

        WHILE true DO {
                pensa
                prendi forchetta(i)
                prendi forchetta(i+1)
                mangia
                lascia_forchetta(i)
                lascia_forchetta(i+1)]

La procedura prendi_forchetta attende che la forchetta specificata sia disponibile e poi se ne impossessa.

Se tutti i filosofi si impossessano contemporaneamente della forchetta di sinistra entreranno in stallo perchè nessuno di loro rilascia la propria. Si può allora pensare di modificare il programma in modo tale che quando un filosofo si impossessa della prima forchetta e non trova la seconda , rilascia anche la prima per ritentare dopo un pò di tempo. Se si è sfortunati, anche così non si risolve il problema : se tutti contemporaneamente decidono di mangiare, impossessandosi della prima forchetta ,non ne troveranno un` altra. Non è stallo ma i filosofi moriranno di fame.

Un modo per risolvere il problema consiste nell` utilizzo di un semaforo binario. Prima di prendere la forchetta un filosofo esegue una P(sem). Dopo aver mangiato si segue una V(sem). In questo modo si evita sia lo stallo sia la  morte per fame. Ma le performance sono basse. Con 5 forchette ci sarà sempre al massimo 1 filosofo che mangia!
La soluzione migliore è la seguente:

filosofo(i)

        WHILE true DO {
                think()
                take_forks(i){se non si possono prendere le
                eat() {due forchette si blocca}
                put_forks(i)
        }

Ogni filosofo può essere in uno di questi tre stati: pensante, mangiante, affamato. Inoltre associato ad ogni filosofo c`è un semaforo.

Vediamo le altre procedure

take_forks(i)

        P(sem)
        stato[i]:=affamato
        controllo(i)
        V(sem)
        P(s[i])
        put_forks(i)
        P(sem)
        state[i]:=pensante
        controllo(LEFT)
        controllo(RIGHT)
        V(sem)

controllo(i)

        IF ((state[i]=affamato)and(state[LEFT(i)]<>mangiante)  and (state[RIGHT(i)]<>mangiante)) {
                    state[i]:=mangiante
                    V(s[i])
        }

Con questa soluzione corretta si riesce ad ottenere il massimo parallelismo possibile.

Il modello dei filosofi mangiatori è utile per descrivere processi in competizione per l` accesso ad un numero limitato di risorse, come dischi o dispositivi di I/0.

Un altro problema interessante ,conosciuto come il problema dei lettori e degli scrittori, può essere utilizzato come modello di accesso ad una base di dati. In una grossa base di dati ci sono molti processi in competizione; alcuni debbono leggere, altri scrivere. La lettura non presenta particolari problemi, mentre se un processo sta scrivendo, nessun altro processo può accedervi. Una possibile soluzione è la seguente :

LETTORE

        WHILE true DO {
            P(sem);
            lettori:=lettori+1;
            IF (lettori=1) THEN
                P(d_b);
            V(sem); leggi_dati;
            P(sem);
            lettori:=lettori-1;
            IF (lettori=0) THEN
                V(d_b);
            V(sem);
            utilizza_dati_letti;
        }

SCRITTORE

        WHILE true DO {
            elaborazione;
            P(d_b);
            scrivi_dati;
            V(d_b);
        }

In questa soluzione i lettori hanno la precedenza sugli scrittori.

3.14. Stallo.

Nelle pagine precedenti abbiamo già utilizzato il termine stallo. Cerchiamo di definirlo formalmente: un insieme di processi si trova nella situzione di stallo se ogni processo dell'insieme si trova ad aspettare un evento che solo un altro processo dell'insieme può generare. Poichè tutti i processi sono in attesa, nessuno può generare eventi!

In genere l'evento atteso è il rilascio di una risorsa. Ma per il rilascio di una risorsa il processo deve essere nello stato di esecuzione. Affinchè si possa verificare una situazione di stallo debbono essere verificate le seguenti condizioni:
 

Risoluzione.

Le tecniche per affrontare lo stallo sono diverse:
 

Vedremo nei dettagli le strategie proposte.

Ignorare il problema: a prima vista sembra assurdo dire che una soluzione è ignorare il problema, ma nella realtà è così; in Unix ad esempio, ogni processo occupa una voce della tabella dei processi, che ha dimensioni finite.

Un motivo per cui una Fork può fallire è che non c'è spazio nella tabella dei processi.

Allora si lascia trascorre un certo periodo di tempo e si riprova. Supponiamo che lo spazio disponibile sia 80 e che 10 processi in avanzamento creino altri 10 processi ciascuno, nello stesso tempo. Al settimo non c'è più spazio nella tabella. Tutti e dieci riproveranno all'infinito senza avere speranze di avanzare. E' lo stallo.

Anche per quanto riguarda i files, il numero totale di quelli che si possono aprire è' limitato dalla tabella degli i_node, che è' di dimensione finita e che può quindi portare allo stallo! Queste possibili situazioni critiche vengono ignorate.

Identificazione e riparazione: il sistema memorizza le richieste e i rilasci delle risorse. Se si incontra una situazione di richiesta di risorse ciclica, allora si provvede a far abortire uno dei processi del ciclo, sino a quando non si risolve il problema. Un altro sistema più brutale è di ricercare i processi che da troppo tempo sono in attesa per l'utilizzo di una risorsa e di farli abortire. Questi metodi possono essere pericolosi: se ono in corso operazioni di aggiornamento di file il ripristino dei dati precedenti non è sempre possibile.

Prevenzione: si tratta di imporre delle restrizioni ai processi in modo tale che lo stallo risulti impossibile. Poichè le quattro condizioni viste in precedenza sono tutte necessarie, l'eventuale eliminazione di una rende lo stallo impossibile.
 

4. Gestione dei periferici.

4.1. Introduzione.

Con gestione dei periferici si intendono tutte quelle operazioni e quelle procedure che permettono di conoscere e modificare lo stato di tutti i periferici collegati al sistema e che offrono ai processi utente la possibilità di utilizzarli ( attraverso primitive) senza conoscerne il funzionamento. Tutti i programmi di gestione delle device formano il Device Management.

Una funzione molto importante svolta a questo livello è di fornire all'utente un insieme di procedure per l'input/output. Il s.o. ricevuta la richiesta di I/O, ad esempio una chiamata di tipo read(fd,buf,nbyte), deve riconoscere il device interessato alla richiesta, se libero inviargli i comandi, riconoscere le interruzioni e trattare gli errori. Deve cioè possedere una interfaccia, semplice da usare, tra il dispositivo fisico e il resto del sistema e possibilmente tale interfaccia deve essere la stessa per tutti i dispositivi.

Tutte queste operazioni non possono prescindere dall'hardware e da come programmarlo.

4.2. Cenni sui dispositivi hardware di I/O.

A grandi linee tutti i dispositivi hardware possono essere classificati in due categorie: dispositivi a blocco o dispositivi a carattere.

Il dispositivo a blocco memorizza le informazioni in blocchi di lunghezza fissa. Ciascuno dei blocchi può essere letto o scritto indipendentemente dagli altri. Un esempio sono i dischi.

Il dispositivo a caratteri spedisce o accetta caratteri senza tener conto di alcuna struttura di blocco. Non è indirizzabile e non è quindi possibile alcuna operazione di posizionamento. Esempio sono i terminali e la stampante.

4.3 Controllori dei dispositivi.

Ogni dispositivo è composto da una parte meccanica e da una parte elettronica, in genere chiamata controller del dispositivo. Il controller, in genere una scheda, può controllare più di un dispositivo .

lI sistema operativo interagisce con il controller: è compito del controller tramutare i segnali ricevute in operazioni da far svolgere alla parte meccanica. Ogni controller ha alcuni registri che vengono utilizzati per comunicare con la CPU. Il sistema operativo esegue l'input/output scrivendo dei bytes nei registri del controllore.

A questo punto il controllore è in grado di operare da solo, parallelamente all'attività della CPU, segnalando con un interrupt la fine dell'operazione e permettendo così al s.o. di intervenire, leggendo i risultati dell'operazione nei registri del controllore.

4.4. Software di input/output.

Il software per la gestione dello I/O dovrà essere strutturato a livelli, con quello più basso che si occupa dell'hardware. Inoltre dovrà tener conto di alcuni fattori molto importanti:
 

I programmi utente sono in genre sincroni, cioè non possono proseguire sino a quando i dati richiesti non sono disponibili: sarà compito del sistema operativo gestire questa situazione, rendendo sincrona per gli utenti operazioni che sono intrinsecamente asincrone.

- dispositivi dedicati. La stampante ad esempio deve essere assegnata ad un processo sino alla fine dell'operazione richiesta.

Il software del Device Management è strutturato a livelli, con il livello più basso che dipende dal device. Analizziamo nei detagli i vari livelli.

4.5. Gestore dei dispositivi.

Il gestore dei dispositivi è quella parte del sistema operativo che dipende dall'hardware. E' in grado di trasformare una richiesta di I/O in una serie di comandi da inviare nei registri del controller. Se il controller è in grado di ricevere in input una lista di comandi il gestore può inviarla e mettersi in attesa della risposta. In caso contrario deve programmare il controller inviando i comandi appropriati uno alla volta e con la necessaria temporizzazione. Solo alla fine della programmazione può mettersi in attesa della risposta. Non sempre il gestore si pone nello stato di attesa: se la risposta vine fornita in pochi miscrosecondi allora rimane in attesa.

ESEMPIO.Una tipica richiesta ad un gestore di disco potrebbe essere la lettura di un blocco dal disco.

Se il gestore è libero da altre richieste, allora dovrà procedere a trasformare la richiesta di lettura del blocco in una serie di comandi da inviare al controller per posizionare il braccio con le testine sulla giusta traccia e sul settore interessato e procedere quindi al trasferimento dei dti dal disco alla memoria.

Se l'operazione ha esito positivo il gestore verrà risvegliato da un interrupt e segnalerà al modulo software che aveva fatto la richiesta alcune informazioni tipo la parola di stato restituita dal controller.

4.6. Software indipendente dall'hardware.

Il gestore del dispositivo è l'unico che deve conoscere le caratteristiche dell'hardware con cui colloquiare.

Il resto del software deve esssere indipendente. Non sempre si può stabilire con precisione quali sono le funzioni del gestore e quali quelle del resto del software di I/O. Questo perchè parti che potrebbero esere svolte dal software indipendente potrebbero essere delegate per ragioni di efficienza al gestore.

I problemi che devono essere affrontati da questa parte di software sono:

- fornire ai programmi utente un modo uniforme per descrivere i periferici e i file sui quali lavorare, riuscendo però a tradurre il nome correttamente per il gestore.

In Unix questo problema è risolto trattando files e periferici allo stesso modo, cioè assegnando ad ogni device un file speciale all'interno del direttorio /dev. Il software indipendente riconoscerà che si tratta di un file speciale e farà riferimento al gestore opportuno passando l'indirizzo esatto del device.

- protezione delle device: non sempre deve essere concesso agli utenti l'accesso alle device. In unix questo si ottiente proteggendo i files speciali relativi alle device esattamente come se fossero dei file standard.

- blocchi fisici nel caso dei dischi. Non tutti i dischi hanno la stessa dimensione del settore fisico. Sarà compito del software definire una dimensione standard e fare in modo che l'utente non si accorga di questo problema.

Se per riempire tale blocco sono necessarie più operazioni di lettura sarà compito del gestore procedere a tali operazioni. Solo a blocco pieno il software di livello superiore sarà attivato. Lo stesso per la scrittura: se il blocco non è pieno la scrittura non avverrà su disco ma solo in memoria; solo quando il blocco è pieno si procederà allo svuotamento su disco. Il metodo descritto viene chiamato bufferizzazione e permette ai programmi utente di gestire una quantità arbitraria di dati indipendentemente dalla dimensione del blocco fisico.

4.7. Livello utente.

Nelle librerie a livello utente è contenuto una parte del software che deve essere collegato ai programmi utente per l'input/output. Ad esempio in Unix si hanno a disposizione le seguenti chiamate per l'I/O:

                    i=read(fd,buf,nbytes)

                    i=write(fd,buf,nbytes)

dove:

- i indica il numero di bytes letti o scritti effettivamente
- fd è il "file " sul quale deve essere effettuata l'operazione
- buf è l'indirizzo iniziale dell'area di memoria in cui dovranno essere inseriti o prelevati i dati da trasferire
- nbytes è il numero di bytes da trasferire.

In questo caso la procedura contenuta nella libreria non deve fare molto: deve effettuare la chiamata di sistema mettendo i parametri al posto giusto. Nella libreria standard ci sono altre funzioni di I/O come printf() e scanf() che prendono in input una stringa di "formato" con alcune variabili e che dopo averle trasformate in base al formato eseguono rispettivamente una write() e una read(). A queste librerie ogni utente unix può aggiungere facilmente delle sue per input/output particolari, sempre ricordandosi che l'accesso alle device o ai file sarà effettuato attraverso la chiamata di sistema read() o write().

6. Unix file system.

6.1. Introduzione.

Un importante compito del S.O. è quello della gestione dei files creati dall'utente: tale compito viene svolto dal file system. Il file system può essere tudiato da diversi punti di vista: ad esempio l' utente finale può immaginare i propri files organizzati in una ben determinata struttura, i files a loro volta sono inseriti in apposite cartelle ( directory ) che possono appartenere ad altre directory, e così via. L'utente deve avere a disposizione i comandi per la manipolazione degli oggetti appartenenti a tale struttura, come i comandi per la creazione di una directory o di un file, per l'inserimento di un file in una directory.

Tale visione logica deve essere trasformata in una visione fisica costruita con precise strutture dati che rappresentano fisicamente il file system sui vari device. Devono poi esistere degli strumenti che, agendo sulla struttura fisica, realizzino le manipolazioni neccessarie per soddisfare le richieste dell' utente che invece riguardano la struttura logica. Vediamo ora le strutture dati utilizzate da Unix.

6.2. I files.

I files gestiti da Unix sono di tre tipi : file ordinario , directory e file

speciale. Ogni file è caratterizzato da diversi attributi quali data di creazione, lunghezza, ..... ecc, proprietà e protezione. In genere al momento della creazione un file diventa di proprietà dell'utente che ha richiesto l'operazione, identificato attraverso UID e GID.

Esistono dei comandi per il passaggio di proprietà di file fra gli utenti ( chown , chqrp ). La proprietà abilita diversi permessi di accesso a tre classi di utenti: proprietario, membri del gruppo e rimanenti utenti.

La protezione può essere in lettura, scrittura e esecuzione. Il comando chmod permette di modificare le permission.

6.3. File ordinario.

In Unix ogni file è visto come una sequenza di caratteri (bytestream) terminata con il carattere convenzionale EOF: non esiste alcuna imposizione riguardante la strutturazione di un file in un record, blocchi o altro.

E'compito dei programmi che lo utilizzano fornire al file una struttura logica, suddividendolo ad esempio in record, blocchi e campi. L'accesso ai files è quindi sequenziale e posizionale.

Il numero delle primitive a disposizione dei programmatori sono poche ma potenti e facilmente utilizzabili; la realizzazione di librerie per gestioni sofisticate di archivi diventa quindi molto facile.

6.4. Directory.

A livello di s.o. una directory è considerata come un file ordinario, con la differenza che il suo contenuto, di formato fisso, èun elenco di nomi di files e relativi riferimenti ad altre strutture dati del file system.

L'utente può invece pensare alla directory come un indice analitico. I comandi per la creazione di directory e la cancellazione sono: mkdir, rmdir.

4.5. File speciale.

I files speciali permettono una rappresentazione virtuale dei dispositivi fisici connessi al sistema. Esiste un file speciale per ogni terminale collegato ( /dev/tty0p0 .....), uno per ogni unità disco, uno per la stampante ... ecc.

Per l'utente le operazioni di I/O relative ai dispositivi fisici vanno effettuate sui corrispondenti file speciali, utilizzando le normali primitive di I/O.

La creazione di un file speciale avviene attraverso la primitiva mknod alla quale vengono fornite varie informazioni tra le quali i numeri identificatori (major number, minor number) del dispositivo.

4.6. Organizzazione fisica.

Tratteremo solo il file system per la parte relativa alla gestione del disco.

4.6.1. Device.

Ogni periferico collegato a Unix è identificato da una coppia di numeri: major number, che indica il tipo di periferico, e il minor number, che individua, fra quelli dello stesso tipo, un'unità particolare.

I dispositivi fisici possono essere suddivisi in due categorie: a caratteri ( character serial device ) ed a blocchi ( block-structured-device ). I primi dispositivi sono quelli che possono essere letti/scritti solo in modo sequenziale, un carattere per volta, come avviene per i terminali, le stampanti ....., mentre i secondi sono quelli per i quali le operazioni di I/O sono effettuate a blocchi, con un meccanismo di bufferizzazione.

Tuttavia è possibile operare in modo puramente sequenziale anche su device a blocchi, associando allo stesso dispositivo un altro file speciale : il "nuovo dispositivo" è chiamato "raw device".

4.6.2. Volumi.

Ogni disco collegato a unix può essere suddiviso fisicamente, al momento della configurazione, in zone contigue, chiamate volumi o partizioni. Ogni volume contiene il proprio file system: il volume è in pratica visto come un device e come tale può essere collegato/scollegato al sistema. In questo modo si può decidere che l'albero dei files del sistema venga diviso in un certo numero di sottoalberi, ciascuno residente in un device logico e costituente il suo file system.

Quando un device viene scollegato, il relativo sottoalbero diventa inaccessibile. Il volume contenente la "radice" (root) deve sempre essere collegato. Ogni volume è inoltre suddiviso in "blocchi logici" di dimensione fissa e pari ad un multiplo di 512 byte. Ogni volume è suddiviso in 4 aree distinte:

- Area di inizializzazione ( bootstrap ): contiene l'insieme minimo di codice necessario all'attivazione del S.O. al momento dell'accensione. Per mantenere la compatibilità tale area è presente su tutti i volumi del sistema, anche se solo una conterrà effettivamente il codice.

- Area di controllo ( super block ): contiene le informazioni riguardanti le caratteristiche del volume a cui appartiene , (dimensione del volume, lista dei blocchi liberi.

- Area dei descrittori di file ( inode list ): le informazioni relative ad un file sono contenute in una apposita tabella, chiamata inode table. Gli inode sono allineati in un vettore, chiamato inode list, di dimensione fissa e stabilita al momento della creazione del file system. L'indice dell' inode è la posizione , all'interno del vettore.

- Area dei blocchi di dati: sono i blocchi rimanenti del volume utilizzati per i contenuti dei file.

4.6.3. Area dei descrittori e dei blocchi.

Ogni file è rappresentato da un nome simbolico riportato nella directory a cui appartiene con accanto un numero ( i_number ) che indica la posizione del relativo inode all'interno dell'i_list. Per ritrovare i dati di un file il S.O. inizia la ricerca del suo nome fra gli elementi della relativa directory.

Trovato il nome, attraverso l'i_number si accede all'i_node associato dove, accanto ad altre informazioni ci sono gli indirizzi fisici che portano all'individuazione dei blocchi che rappresentano il contenuto del file.

La ricerca prima indicata presuppone di conoscere la posizione della directory interessata. Per questo è neccessario risalire alla directory "padre" di quella interessata. Tale procedimento ha termine con il raggiungimento della root. Di conseguenza l'indice dell'i_node della root di ogni file system deve sempre essere noto : tale i_node è individuato in seguito al comando mount che effettua il montaggio del relativo file system.

Non sempre la ricerca deve iniziare da root: attrverso il working directory è possibile partire da un nodo intermedio. Gli esempi proposti sono una semplificazione di quello che avviene nella realtà.

4.6.4. i_node e in_core node.

Come abbiamo visto la struttura dati più importante del file system è quella dell' i_node. In ogni sistema Unix, una parte degli i_node è contenuta in Ram per migliorare i tempi di ricerca.

Ogni i_node è costituito dai seguenti campi:
 

Numero di link: un unico file fisico può essere identificato da più nomi simbolici, presenti in più directory, che identificano lo stesso i_mode. Il link è il numero di nomi diversi.

Dimensione: il numero di byte contenuti nel file

Storia degli accessi: vengono riportate data e ora relativi all'ultima modifica effettuata sul file, all'ultimo accesso al file e all'ultima modifica dell'i_node. Si deve notare che una modifica del file comporta una modifica del relativo i_node ma non il viceversa; ad esempio si modifica l'i_node quando aumenta il numero dei link.

6.6.5. Tabella di indirizzi dei blocchi di dati.

E` una tabella di 13 elementi che permette l'accesso ai blocchi del file. Solo i primi 10 blocchi indicano direttamente il blocco dati. Se questi non bastano si utilizza 11_esimo, che contiene l'indirizzo di un blocco che contiene gli indirizzi degli altri blocchi di dati del file.

Il 12_esimo indica un blocco che contiene l'indirizzo degli altri blocchi che contengono gli indirizzi dei dati. Con il 13_esimo aumenta ancora di 1 il livello di indirizzamento.

Se la dimensione del blocco è di 512 byte, ogni bolcco contiene 128 indirizzi.

6.6.6. Area super block.

Contiene le informazioni relative alle altre aree del volume,per una corretta gestione del volume stesso.I dati contenuti sono:

- dimensione del volume

- il numero di blocchi liberi

- la lista degli indirizzi dei blocchi liberi (free list)

- l'indice del prossimo elemento da leggere nella lista

- la quantità degli i_node non ancora utilizzati

- la lista degli i_node liberi(free_inode_list)

- l'indice del prossimo elemento della lista i_node_liberi al quale accedere

- semafori (variabili booleane) per proteggere gli accessi alle 2 free_inode_list

- indicazione di eventuale modifica del super block.

6.6.7. Gestione di i_node e di blocchi liberi.

Quando, attraverso il comando mkfs, viene creato un file system da associare ad un volume, viene allocata la i_list, vettore di dimensione fissa (stabilita da un parametro del comando). Gli elementi della i_list sono gli i_node che verranno associati ai vari files. La i_list conterrà degli i_node occupati e degli i_node liberi. Un i_node è libero se contiene "0" nel campo "tipo_file". La lista degli i_node liberi è riportata nella "free list" dell'area super block. Tale aggiornamento non viene sempre effettuato tempestivamente. L'assegnazione di un nuovo i_node può essere suddivisa in due casi:

- free i_node list vuota o

- free i_node list non vuota.

Nel secondo caso si ottiene l'indice dell'i_node libero accedendo al suo indirizzo attraverso il "puntatore" contenuto nell'area super block. Tale i_node viene copiato nella lista degli i_core_node (i_node in ram) e viene aggiornato in base ai dati del nouvo file e ricopiato su disco. Se la lista è vuota, si ricerca un i_node libero direttamente nella i_list. Tale operazione è "costosa" perchè non ci si limita alla ricerca del primo, ma si cerca di assegnare alla free_list più elementi possibili. Per quanto riguarda i blocchi liberi la gestione è simile. I componenti la free_list, sono blocchi contenenti gli indirizzi dei blocchi liberi. L'ultimo indirizzo indica il successivo elemento della free_list: Quando deve essere allocato un nuovo blocco, viene prelevato l'indirizzo da questa lista. Viceversa se un blocco viene rilasciato, allora deve essere inserito in questa lista.