Fin’ora i programmmi che abbiamo scritto e quelli degli esempi, hanno sempre avuto il pieno controllo di tutta la macchina QEMU
.
I programmi avevano a disposizione infatti tutto lo spazio di I/O e tutto lo spazio di memoria.
Nella realtà questa situazione è possibile solo qualora eseguissimo un unico programma alla volta, così come veniva fatto con i primi calcolatori, quando un calcolatore occupava l’area di una palestra, ed una Università poteva averne uno o forse due.
I primi calcolatori venivano infatti usati in modalità batch
:
Gli utenti, ricercatori o studenti, preparavano i programmi a casa su fogli di carta scrivendoli in linguaggio macchina
o in FORTRAN
.
Portavano poi i loro fogli al centro di calcolo, dove alcuni impiegati potevano trascriverli su nastro o su schede perforate.
Ogni pacco di schede, contenente il programma di un utente, rappresentava un job.
L’utente consegnava poi il suo job agli operatori del calcolatore e in un secondo momento sarebbe dovuto ritornare a ritirare i risultati, tipicamente sotto forma di un tabulato stampato su carta.
Gli operatori erano gli unici ad avere accesso alla sala del calcolatore, e aspettavano di avere un mazzo (batch
) di job e poi lo caricavano sul lettore di schede.
Questo eseguiva i job uno alla volta. Quando il primo job terminava, veniva caricato automaticamente il successivo. In questi sistemi era quindi importante cercare di massimizzare il numero di job completati ogni ora, così da sfruttare il costosissimo processore nel modo più efficiente possibile.
Supponiamo però ora che un job1 debba caricare una serie di dati da un nastro magnetico a controllo di programma. Il nastro magnetico va però riavvolto, operazione che richiede diversi secondi. Il costosissimo processore viene così sprecato in un banale ciclo di istruzioni che legge ripetutamente i registri del controllore del nastro, per attendere che il nastro raggiunga la posizione desiderata. La “vera” elaborazione del job1, quella che ha davvero bisogno delle piene capacità della CPU, comincerà infatti solo quando tutti i dati saranno stati letti.
Per gli operatori del calcolatore sarebbe molto meglio se il controllore del nastro fosse programmato per inviare una richiesta di interruzione dopo il riavvolgimento.
In questo modo, durante il tempo di riavvolgimento, si potrebbe utilizzare la CPU per cominciare ad eseguire il prossimo job del batch
.
All’arrivo della richiesta di interruzione si potrebbe poi ritornare al job1.
Potremmo quindi pensare di utilizzare due routine:
Routine1
: avvia il riavvolgimento del nastro e cede il controllo ad un altro jobRoutine2
: (da associare alle richieste di interruzione provenienti dal controllore del nastro) restituisce il controllo al job che aveva invocato la Routine1
Questa soluzione, che poi sarà quella che vedremo più avanti, presenta però dei problemi legati alla natura dei job:
Un’opzione potrebbe essere controllare manualmente i job consegnati per verificare che non disattivino le interruzioni né eseguano processi all’I/O direttamente da programma. Tuttavia questa operazione è estremamente difficile e potenzialmente impossibile, in quanto il programmatore può utilizzare metodi più o meno complessi per mascherare le sue azioni.
Sono quindi state apportate delle modifiche sull’hardware per imporre al processore che alcune operazioni sono vietate in determinati contesti. Per risolvere il primo problema creiamo una distinzione del contesto nel quale la CPU sta operando, differenziando tra:
utente
sistema
Andremo quindi a vietare le istruzioni di IN
, OUT
, CLI
, STI
per il contesto utente
, permettendole solamente quando ci si trova nel contesto sistema
.
Dovremo quindi solamente far capire al processore in quale contesto si trova (cosa relativamente semplice poiché si tratta di due contesti).
È infatti sufficente fornire un singolo bit
al processore che se settato indica il contesto sistema, se resettato invece si trova nel contesto utente.
Questo bit si trova nel registro chiamato CS
(Code Selector).
In questo modo il processore, qual’ora trovi una delle operazioni “vietate”, andrà a prima a controllare il contesto nel quale si trova prima di eseguirla eventualmente vietandone l’esecuzione.
L’idea generale di questo nuovo sistema è quindi la seguente:
sistema
utente
sistema
utente
Per far funzionare ciò dobbiamo quindi togliere agli utenti la possibilità di poter sfruttare le interruzioni, permettendo loro però di poter comunque chiamare le routine e utilizzarle.
Uno dei modi per poterlo fare è quello di sfruttare le eccezioni: permetteremo all’utente di utilizzare una determinata eccezione (non modificabile nella memoria), salvando in un registro quale routine si vuole chiamare.
La intel ha adottato un sistema diverso, introducendo un nuovo operando assembler INT $tipo
che fa da gate per chiamare la routine (primitiva di sistema) e passare in modalità sistema
.
$tipo
è un numero tra 0
e 255
, ed ha lo stesso significato del tipo delle eccezioni e delle interruzioni esterne.
Per fare il passaggio inverso da sistema
a utente
l’unica istruzione utilizzabile è la IRETQ
, chiamata alla fine della routine.
In tutto questo contesto va però ridefinita la porzione di memoria concessa all’utente. Se infatti avesse a disposizione tutta la memoria il’utente potrebbe:
IDT
e rimuovere le routine presentiRendendo inutile la separazione dei contesti.
Dobbiamo quindi trovare un modo per vietare certi indirizzi. Parleremo più avanti della paginazione, per ora facciamo invece una semplificazione: Immaginiamo dunque di avere un registro nel processore che contiene l’ultimo indirizzo valido di memoria utilizzabile dagli utenti. Ogni qualvolta che il processore effettuerà un accesso in memoria, prima controllerà il contesto e, nel caso fosse nel contesto utente, controllerà che l’indirizzo desiderato sia maggiore o uguale a quello contenuto nel registro.
Da ora in poi chiameremo M1
la parte di memoria ad indirizzi inferiori al limite (system-only), e M2
la rimanente.
Il registro contenente l’indirizzo di separazione viene inizializzato tramite il programma di bootstrap, lo stesso che carica IDT
e il corpo delle varie orutine e strutture dati
Il livello di privilegio può essere cambiato solo in due modi:
Operazione | Livello di privilegio |
---|---|
gate della IDT |
utente $\to$ sistema |
Istruzione IRETQ |
sistema $\to$ utente |
Ai gate della IDT
si può passare tramite tre operazioni:
INT
Ogni gate della IDT
occupa 16Byte
e contiene le segueni informazioni:
Il puntatore alla Routine
a cui saltare (8 Byte
)
P
(Presenza): indica se la riga contiene bit significativi
I/T
: indica se il gate è di tipo Interrupt (azzera IF
) o Trap (mantiene IF
invariato).
L
(Livello): indica il livello di privilegio al quale portare il processore dopo aver passato il gate. Nel nostro caso sarà sempre settato a sistema
.
DPL
(Descriptor Privilege Level): specifica il livello di privilegio minimo che deve avere il processore prima di passare il gate. Può vietare l’utilizzo di alcuni gate attraverso l’istruzione INT
generando un’eccezione di protezione 13
.
I programmatori di sistema possono settarlo come:
sistema
: nei gate delle interruzioni esterne, così che possano essere attraversati solo da codice protettoutente
: nei gate delle primitive, per permetterne l’utilizzo da parte degli utentiLa IDT
viene inizializzata tramite il programma di bootstrap, in particolare utilizzando l’istruzione LIDTR
che carica l’indirizzo della IDT
nel registro IDTR
che il processore utilizza per accedere ala tabella e allocando IDT
nella memoria M1
.
Per non permettere la modifica di IDT
da parte dell’utente l’istruzione LIDTR
è anch’essa vietata nel contesto utente.
Quando il processore accede all’IDT
accadono questi passaggi:
APIC
;INT $tipo
.Verifica se il bit P
associato al tipo è zero, generando un’eccezione di gate non presente 11
in caso positivo, negli altri casi procede.
Se sta gestendo una interruzione software o int3
, confronta il livello corrente con il campo DPL
del gate.
Se il livello corrente è meno privilegiato di DPL
si genera una eccezione di protezione 13
.
Altrimenti, confronta CS
con L
.
Se L
è inferiore, si genera ancora un’eccezione di protezione 13
.
Questo perché attraverso la IDT
non è possibile abbassare il livello di privilegio ma solamente mantenerlo o aumentarlo.
Negli altri casi, il processore salva in un registro di appoggio (chiamiamolo SRSP
) il contenuto corrente di RSP
Se CS
è diverso da L
esegue un cambio di pila (pila sistema/utente nel nostro caso), caricando un nuovo valore in RSP
(vedremo più avanti dove si trova questo valore)
5 long word
. In ordine:
SS
: 1 long word
non significativa (rimasuglio della segmentazione, …)SRSP
: pila salvata al passo 5. Nel caso di cambio pila è quella utente
, altrimenti punta alla pila sistema
stessaRFLAGS
: registro dei flagCS
: vecchio valore del CS
da ripristinare successivamenteRIP
: indirizzo della prima istruzione da eseguire all’uscita del gate. Nel caso di interruzioni software INT $tipo
questo contiene l’istruzione immediatamente successivaTF
in ogni caso;IF
solo se il gate è di tipo Interrupt.Le interruzioni di protezione sono progetatte per poter solamente mantenere o alzare il livello di privilegio.
Il cambio di pila è necessario, è ha due motivazioni:
long word
senza sovrascrivere altre cose, e non può quindi fidarsi di RSP
che è completamente a servizio dell’utente.CS
.Quando si chiama la IRETQ
per tornare indietro, si effettua un’accesso alla pila di sistema:
CS
con quello salvato in pila, generando un eccezione di protezione 13
qualora quello salvato fosse più alto;RIP
, CS
, RFLAGS
e RSP
leggendo i corrispondenti valori dalla pila.La IRETQ
è progettata per poter solamente abbassare il livello di privilegio.
Nei primi processori intel ogni job aveva un proprio segmento di un registro chiamato TSS
, che indicava la pila a disposizione del job.
Per identificare la pila sistema si accedeva prima ad un’altro registro, TR
(Task Register), che indicava quale segmento era associato a quel job.