Il processore lavora su un flusso di controllo, determinato esclusivamente e univocamente da software.
Il programmatore però potrebbe utilizzare un modello di programmazione diverso, in grado di associare i programmi a degli eventi. Questi eventi sono un numero finito e ben stabilito, ed il programmatore vi può associare una propria routine.
Vorremmo quindi che il processore, anche mentre sta eseguendo un programma p
, stia sempre in ascolto per gli eventi con routine. Nel momento in cui l’evento viene generato, il processore dovrebbe mettere in pausa l’esecuzione di p
per gestire la routine dell’evento.
Una volta terminata la routine, vorremmo che l’esecuzione di p
riprenda da dove si è interrotta.
Uno dei primi a studiare questo aspetto è stato Dijsktra, con il seguente problema:
Ipotizziamo di avere una tabella contenente due indici:
Vorremmo che venissero stampati le coppie valore $x$-$f(x)$.
$x$ | $f(x)$ |
---|---|
0000 | …. |
0001 | …. |
0002 | …. |
0003 | …. |
$\vdots$ | … |
La stampa degli elementi è gestita tramite una stampante con due registri:
TBR
: contiene il valore di $f(x)$STS
: handshake, contiene un flag che indica che la stampa di $f(x)$ è avvenuta con successo e si può inserire un nuovo valore in TBR
.Un approccio al problema potrebbe essere questo:
checkFlag()
Questo approccio però perde del tempo durante l’attesa di checkFlag()
.
In particolare potremmo utilizzare questo tempo per precalcolare le $f(x_i)$ successive.
Un’opzione che potremmo utilizzare è quello di controllare checkFlag()
più volte all’interno del programma:
Troppo Raro
checkFlag();
for(int i = 0; i < 1000000; ++i)
a += i;
checkFlag();
Troppo Spesso
for(int i = 0; i < 1000000; ++i){
checkFlag()
a += i;
}
Implementiamo quindi una modifica hardware che ci permetta poi di implementare le routine degli eventi in software.
Quello che possiamo fare per supportare gli eventi di questa interfaccia è collegare fisicamente il bit della stampante alla CPU e aggiungere una $\mu$-istruzione che controlla il bit al termine di ogni istruzione.
Essendo solo un evento, il programmatore ha la possibilità di scrivere un programma che personalizzi la routine, utilizzando un indirizzo di memoria dedicato.
Il comportamento è alquanto basilare:
Nel caso in cui la $\mu$-istruzione verifichi
FLAG == 1
il processore salta alla routine e la esegue per poi resettare il bit inSTS
.
Nel caso della stampa però dobbiamo fare delle precisazioni:
READY
e basta, si entrerebbe in loop.
Questo accadrebbe perché TBR
non verrebbe mai di fatto aggiornato tra una stampa e l’altra.READY
come settato ricevendo di fatto infinite richieste, quando in realtà vogliamo solamente una richiesta singola.Per rimediare a questi problemi è sufficente inserire un generatore di impulsi e un FF-SR
che verrà resettato quando la richiesta sarà stata già presa in attenzione.
Per quanto riguarda la gestione delle interruzioni durante routine, nel processore intelx86 esiste un flag aggiuntivo in RFLAG
, chiamato IF
(Interact Flag). Se il bit è resettato, il processore non accetta nuove richieste finché non ha terminato quella attuale. Per poterlo manipolare esistono due istruzioni:
STI
(SeT Interact flag)CLI
(CLear Interact flag).Quando il processore accetta un’interruzione salva nella pila diverse informazioni, tra le quali:
RIP
per capire dove tornare al termine della routineRFLAG
per poter poi ripristinare i flag come se la routine non fosse mai avvenuta.Nelle routine ci sono alcune regole da seguire:
IF
tramite le istruzioni STI
e CLI
IRETq
piuttosto che RET
. IRETq
infatti si ripristina lo stato del processore a prima che la routine iniziasse, in particolare ripristina RFLAG
e gli altri registri prima di ripristinare RIP
.Nella macchine moderne ci sono tantissimi eventi supportati, noi ne vedremo solo alcuni.
In particolare, nella macchina QEMU
che studiamo abbiamo tre interfacce che generano eventi:
RBR
0
quando si azzera per richieste temporizzatePer gestire tutte le richieste viene introdotto una nuova componente, detto Controllore delle Interruzioni l’APIC
(Advanced Programmable Interrupt Controller).
Il controllore è collegato alle periferiche tramite tre fili, ognuna collegata ad un piedino noto dalle specifiche. Nel nostro calcolatore i dispositivi rilevanti sono connessi ai seguenti piedini
1
2
14
Quando uno di questi segnali viene settato l’APIC
invia alla CPU un segnale tramite un suo registro interno chiamato INTR
(INTervall Request), inizializzando un handshake.
Per permettere di capire chi è la sorgente del segnale, il programmatore associa ad ogni piedino del controllore APIC
un tipo, ovvero una codifica su 8bit
.
Nel momento in cui la CPU accetta la richiesta, si prepara inviando il segnale di handshake tramite il filo INTA
: INTervall Acknowledge.
Successivamente l’APIC
carica il tipo sul bus così che la CPU lo possa leggere.
Vedremo successivamente che l’APIC
conserva tre informazioni per ogni piedino:
Registro | Funzione | Valore di Default |
---|---|---|
VECT |
Tipo associato alle interruzioni su quel piedino | - |
TRGM |
(Trigger Mode) indica se generare segnali su livello o su fronte | true (livello) |
MIRQ |
Maschera che indica se l’APIC deve stare in ascolto su quel piedino o meno |
0 (mascherata) |
Affinché tutto questo funzioni, la CPU ha un suo registro interno chiamato IDTR
(Interact Descriptor Table Register) che ha salvato l’indirizzo della IDT
salvata in memoria.
La IDT
è una tabella che ha per ogni riga delle informazioni che vedremo meglio nel dettaglio nella sezione dedicata alla Protezione.
Alcune informazioni sono:
IF
o meno durante la routine.La IDT
deve essere allocata dal programmatore, e ogni sua riga viene identificata dal tipo che il programmatore ha precedentemente dato ai piedini dell’APIC
.
Da software questo si fa con i seguenti comandi:
#include<libce.h>
namespace kbd {
const ioaddr iCMR = 0x64;
const ioaddr iTBR = 0x60;
void enable_intr() {
outputb(0x60, iCMR);
outputb(0x61, iTBR);
}
void disable_intr() {
outputb(0x60, iCMR);
outputb(0x60, iTBR);
}
}
const natb KBD_VECT = 0x40; // Valore di Esempio
apic::set_VECT(1, KBD_VECT); // Scelgo il valore 0x40 per il piedino 1 (associato alla tastiera)
gate_init(KBD_VECT, tastiera); // Associo la funzione tastiera() al tipo 0x40
apic::set_MIRQ(1, false); // Azzera la maschera affinché non vengano accettate nuove interruzioni durante quelle in esecuzione
kbd::enable_intr(); // Permetto alla tastiera di generare segnali di interruzione
Il comando gate_init()
ha un terzo parametro settato di default su false
, che imposta IF = 0
durante le routine, affinché non possano essere interrotte. Se volessimo invece che una routine possa essere interrotta, dobbiamo impostarlo esplicitamente a true
.
Per capire se c’è una nuova richiesta o meno, si utilizza un metodo software chiamato controllo su Livello.
Questo tipo di controllo fa in modo che l’APIC
, dopo aver inviato una richiesta, guardi nuovamente i piedini solo dopo aver ricevuto il segnale di gestione completata dell'interruzione.
Per inviare questo segnale il software utilizza il registro EOI
(End Of Interrupt) del controllore APIC
.
Per evitare comportamenti indesiderati da parte dell’APIC
è importante che questo bit
venga settato quando tutta la routine è terminata e non c’è altro da fare.
Se così non fosse, il controllore potrebbe reinviare segnali di interruzioni su eventi già gestiti.
Per gestire le richieste di interruzione, l’APIC
, oltre a EOI
, possiede altri due registri a 256bit
:
ISR
(In Service Register): conserva informazioni relative ai tipi accettati dalla CPU e non ancora terminati.IRR
(Interrupt Request Register): conserva informazioni relative alle richieste dei tipi non ancora accettati.Nel debugger della macchina QEMU
esiste il comando apic
che ci permette di visualizzare il contenuto di questi due registri.
Vediamo quindi nel dettaglio come vengono utilizzati questi registri nel prossimo capitolo.
Se manteniamo il IF = 1
, quando avvengono due richieste di interruzione dobbiamo porci la domanda se abbia senso interrompere la prima interruzione o attendere prima che qeusta venga completata.
La scelta ricade esclusivamente sulla priorità che il programmatore dà alle varie interruzioni, e può cambiare da caso a caso: attendere per la visualizzazione di un carattere a schermo può non provocare problemi, mentre se abbiamo una richiesta attraverso il timer, non gestirla può provocare perdita di richieste, e quindi ha più senso interrompere anche le eventuali interruzioni.
Interromprere le interruzioni è ovviamente un’operazione lecita, dopotutto le interruzioni funzionano a pila, e non vanno a toccarsi tra di loro se ben gestite.
Il programmatore ha quindi il compito di assegnare una precedenza alle varie richieste, e lo fa tramite il tipo.
Quando assegna un tipo il programmatore ha 8bit
, dove i 4 più significativi indicano la classe di precedenza.
Se arriva una nuova richiesta che ha classe strettamente maggiore l’APIC
invierà una nuova richiesta, negli altri casi attenderà EOI
, per poi inviare la successiva richiesta con classe più alta in IRR
.
Nel caso in cui ci fossero più richieste con la stessa classe di priorità, andrà a discriminare sui 4bit
meno significativi, inviando quella con il valore più alto.
In ogni caso l'ultima parola sull'eseguire in maniera effettiva o meno l'interruzione spetta alla CPU.
Solamente quando la CPU invierà il segnale di INTA
l’APIC
sceglierà la richiesta di classe più elevata in IRR
e la sposterà in ISR
.
È inoltre possibile configurare l’APIC
affinché legga le richieste sui fronti di salita o sui fronti di discesa.
Con la tastiera abbiamo visto le richieste su livello, vediamo ora come impostare quelle sul fronte di salita tramite il contatore:
#include <libce.h>
namespace timer {
static const ioaddr iCWR = 0x43;
static const ioaddr iCTR0_LOW = 0x40;
static const ioaddr iCTR0_HIG = 0x40;
void start(natw N) {
outputb(0b00110100, iCWR); // contatore 0 in modo 2
outputb(N, iCTR0_LOW);
outputb(N >> 8, iCTR0_HIG);
}
}
volatile natq counter = 0;
extern "C" void c_timer() {
counter++;
apic::send_EOI();
}
extern "C" void a_timer();
const natb TIM_VECT = 0x50; // tipo
void main() {
apic::set_VECT(2, TIM_VECT); // piedino 2 è il timer
gate_init(TIM_VECT, a_timer); // riempiamo la riga di IDT con la routine
timer::start0(59660); // periodo di 50ms (50/1000 * 1193192.66...)
apic::set_TRGM(2, false);
/*
* Trigger Mode: genera segnale sul piedino 2 quando avviene
* false=riconoscimento su fronte,
* true=riconoscimento su livello
*/
apic::set_MIRQ(2, false); // Inizia a guardare il piedino 2
for (volatile int i = 0; i < 100000000; i++)
; // Attendiamo a vuoto un po'
printf("counter = %lu\n", counter);
pause();
}
#include <libce.h>
; .....
.global a_timer
.extern c_timer
a_timer:
salva_registri
call c_timer
carica_registri
iretq
IL timer genera un’onda quadra. Abbiamo inserito un periodo di 50ms
perciò il segnale sarà ad 1
per 25ms
e a 0
per 25ms
.
Se avessimo impostato apic::setTRGM(2, true)
, avremmo avuto molte più richieste.
Infatti, il segnale è settato per 25ms
, che è un tempo molto maggiore del tempo necessario a eseguire la routine, e ogni volta che terminerà la routine la farà ripartire con lo stesso segnale.
Il compilatore non ha idea di come distiguere le funzioni routine dalle altre. Perciò il comportamento di default segue le classiche politiche di compilazione:
RET
invece che di IRETq
,Per ovviare a ciò quello che facciamo è inserire manualmente degli snippet in assembler per poter terminare la chiamata alla funzione con la IRETq
:
extern "C" void a_tastiera();
extern "C" void c_tastiera(){
/* Implementazione */
apic::setEOI(); //! IMPORTANTE !//
}
.global a_tastiera
.extern c_tastiera
a_tastiera:
CALL c_tastiera
IRETq
Un altro errore comune generato dalle interruzioni è quello di sovrascrivere i registri utilizzati dal flusso principale.
Questo succede per via di una convenzione, che stabilisce cha alcuni registri sono ad utilizzo libero, e non è quindi richiesto alle funzioni di ripristinarli al termine della loro esecuzione.
Tra questi registri abbiamo ad esempio il registro %rcx
.
Nel caso in cui sia il nostro flusso principale che la routine utilizzino %rcx
la probabilità che il programma abbia dei comportamenti inaspettati è molto elevata.
Per ovviare anche a questo la soluzione è quella di salvare il contenuto di tutti i registri nella pila.
In assembly
su 64bit
non esistono equivalenti della PUSHAD
/POPAD
, ma possiamo utilizzare una macro messa a disposizione dalla libreria, trasformando il nostro codice così:
#include <libce.h>
.global a_tastiera
.extern c_tastiera
a_tastiera:
salva_registri
call c_tastiera
carica_registri
iretq
Un altro tipo di problemi che compaiono spesso sono quelli generati quando le routine modificano variabili globali che nel flusso principale non dovrebbero variare. Il compilatore, quando ottimizza, eviterà infatti di effettuare controlli multipli su una variabile che sa non cambiare mai, ignorando che queste modifiche potrebbero avvenire per via della routine.
Per “forzare” il compilatore a controllare queste variabili, un metodo è quello di utilizzare la keyword volatile
, che indica al compilatore che la variabile ha un comportamento inaspettato, e che quindi non può dare per scontato le sue variazioni.
volatile bool fine = false;
Il problema di quando più flussi operano sulle stesse variabili è stato chiamato “Vaso di Pandora” da Dijsktra. La gestione di questi casi può diventare infatti estremamente complessa.