1. Indice

2. Protezione

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:

  1. Routine1: avvia il riavvolgimento del nastro e cede il controllo ad un altro job
  2. Routine2: (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:

  1. Costringere l’utente 1 a scrivere il programma tramite routine invece di dialogare direttamente con il controllore
  2. Costringere l’utente 2 a non disattivare le interruzioni

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:

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:

  1. All’accensione, tramite il bootstrap, si inizializza il processore a livello sistema
  2. Quando viene inizializzato un job si passa a livello utente
  3. Quando viene generata un’iterruzione esterna, si torna al livello sistema
  4. Gestita l’interruzione esterna, si torna al job nel livello 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.

2.1. Disponibilità della memoria

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:

Rendendo 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

2.2. Passaggi tra contesti

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:

Ogni gate della IDT occupa 16Byte e contiene le segueni informazioni:

La 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:

  1. Innanzitutto il processore si procura il tipo dell’interruzione
    • In caso di eccezione il tipo è implicito;
    • In caso di interruzione esterna, riceve il tipo dall’APIC;
    • In caso di interruzione software è l’argomento specificato nell’istruzione INT $tipo.
  2. Verifica se il bit P associato al tipo è zero, generando un’eccezione di gate non presente 11 in caso positivo, negli altri casi procede.

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

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

  5. Negli altri casi, il processore salva in un registro di appoggio (chiamiamolo SRSP) il contenuto corrente di RSP

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

  7. Salva in pila 5 long word. In ordine:
    • [0] SS: 1 long word non significativa (rimasuglio della segmentazione, …)
    • [1] SRSP: pila salvata al passo 5. Nel caso di cambio pila è quella utente, altrimenti punta alla pila sistema stessa
    • [2] RFLAGS: registro dei flag
    • [3] CS: vecchio valore del CS da ripristinare successivamente
    • [4] RIP: indirizzo della prima istruzione da eseguire all’uscita del gate. Nel caso di interruzioni software INT $tipo questo contiene l’istruzione immediatamente successiva
  8. Il processore poi azzera:
    • TF in ogni caso;
    • IF solo se il gate è di tipo Interrupt.
  9. Salta infine all’indirizzo della routine puntata dal gate.

Le interruzioni di protezione sono progetatte per poter solamente mantenere o alzare il livello di privilegio.

Il cambio di pila è necessario, è ha due motivazioni:

Quando si chiama la IRETQ per tornare indietro, si effettua un’accesso alla pila di sistema:

  1. Confronta il valore corrente di CS con quello salvato in pila, generando un eccezione di protezione 13 qualora quello salvato fosse più alto;
  2. Ripristina i valori di 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.