Assembly Base con MASM
Capitolo 10: Architettura interna della CPU
Il compito fondamentale della CPU è quello di eseguire un programma; risulta
evidente quindi che un programma da eseguire debba essere strutturato in modo da
adattarsi alle caratteristiche interne della CPU stessa. La prima cosa da fare
consiste quindi nell'analizzare in dettaglio la struttura che caratterizza un programma
destinato alla piattaforma hardware 80x86; in particolare, nella sezione
Assembly Base ci occuperemo della struttura di un programma destinato ad essere
eseguito in modalità reale 8086.
10.1 Struttura generale di un programma
Nei precedenti capitoli è stato ampiamente detto che un programma è formato da un insieme
di dati e istruzioni; per motivi di stile e soprattutto di efficienza, è consigliabile
quindi suddividere un programma in due blocchi fondamentali chiamati blocco codice e
blocco dati. Come già sappiamo, il blocco dati contiene i vari dati temporanei e
permanenti di un programma; il blocco codice contiene, invece, le istruzioni destinate ad
elaborare i dati stessi.
L'aspetto interessante da analizzare riguarda proprio la distinzione che viene fatta tra
dati temporanei e dati permanenti; si tratta di una distinzione talmente importante da
suggerire, come vedremo tra breve, una ulteriore suddivisione del blocco dati in due parti
destinate a contenere, appunto, una i dati temporanei e l'altra i dati permanenti.
I dati permanenti sono così chiamati in quanto permangono (cioè esistono) in memoria per
tutta la fase di esecuzione di un programma; tra i dati permanenti più importanti si
possono citare, ad esempio, i dati in input e i dati destinati a contenere l'output del
programma.
I dati temporanei sono così chiamati in quanto la loro esistenza in memoria è necessaria
solo per un periodo limitato di tempo; una situazione del genere si verifica, ad esempio,
quando abbiamo bisogno temporaneamente di una certa quantità di memoria per salvare un
risultato parziale di una operazione matematica.
In generale, a ciascun dato temporaneo o permanente, viene assegnata una apposita locazione
di memoria; la fase di assegnamento della memoria prende il nome di allocazione. La
distinzione tra dati temporanei e dati permanenti scaturisce proprio dalla diversa gestione
della memoria destinata a questi due tipi di dati; infatti, come si può facilmente intuire,
la memoria destinata ad un dato permanente deve esistere per tutta la fase di esecuzione di
un programma, mentre la memoria destinata ad un dato temporaneo è necessaria solo per un
intervallo di tempo limitato.
10.1.1 Il Data Segment di un programma
Dalle considerazioni appena esposte, si deduce subito che per i dati permanenti di un
programma conviene predisporre un apposito blocco di memoria che esiste per tutta la fase
di esecuzione e le cui dimensioni sono sufficienti per contenere l'insieme dei dati stessi;
questo blocco viene internamente suddiviso in locazioni ciascuna delle quali è destinata
a contenere uno dei dati del programma.
L'indirizzo di ciascuna locazione rimane statico e cioè, fisso, per tutta la fase di
esecuzione e per questo motivo si dice anche che la memoria per un dato permanente
viene allocata staticamente; sempre per lo stesso motivo, i dati permanenti vengono
anche chiamati dati statici.
Conoscendo l'indirizzo di un dato statico, possiamo accedere alla corrispondente locazione
di memoria modificandone il contenuto; un dato statico il cui contenuto può essere modificato
(variato), viene anche definito variabile statica.
Il blocco di memoria destinato a contenere i dati statici forma un segmento di programma che
prende il nome di Data Segment (segmento dati).
10.1.2 Lo Stack Segment di un programma
Teoricamente, per i dati temporanei di un programma si potrebbe procedere come per i dati
statici; in questo modo però si andrebbe incontro ad un gigantesco spreco di memoria.
Osserviamo, infatti, che è praticamente impossibile che i dati temporanei di un programma
siano necessari tutti nello stesso istante di tempo; ciò significa che le locazioni di
memoria allocate staticamente per i dati temporanei resterebbero inutilizzate per buona
parte della fase di esecuzione.
Nel corso della sua "esistenza" un programma può arrivare a gestire una quantità di dati
temporanei tale da richiedere, ad esempio, decine di migliaia di byte di memoria; istante
per istante, però, questo stesso programma può avere bisogno di una quantità di dati
temporanei che occupano mediamente appena qualche centinaio di byte di memoria!
Questa situazione richiede un attento studio volto ad individuare il metodo più semplice
ed efficiente per la gestione dei dati temporanei; questo metodo ci deve permettere in
sostanza di ridurre al minimo indispensabile le dimensioni del blocco di memoria destinato
a questo particolare tipo di dati.
Per individuare la strada da seguire, possiamo fare riferimento alle considerazioni esposte
in precedenza, in base alle quali una locazione di memoria destinata ad un dato temporaneo,
viene sfruttata solo per un periodo limitato di tempo; possiamo pensare allora di
"creare" questa locazione quando ne abbiamo bisogno, "distruggendola" poi quando non ci
serve più. Questo tipo di gestione "al volo" (cioè in fase di esecuzione) della memoria
viene definito dinamico; la fase di creazione al volo di una locazione di memoria
viene definita allocazione dinamica, mentre la fase di distruzione al volo della
stessa locazione viene definita deallocazione dinamica.
L'unico svantaggio di questo sistema è legato alla relativa lentezza delle numerosissime
operazioni di allocazione e deallocazione; questo unico svantaggio scompare però davanti
al fatto che con questa tecnica possiamo arrivare ad ottenere un risparmio di memoria
veramente colossale (nel caso dell'esempio precedente, poche centinaia di byte anziché
decine di migliaia di byte).
Esistono diverse strutture dati che permettono di implementare un sistema di gestione
dei dati temporanei; la più semplice e la più efficiente tra queste strutture è però
la cosiddetta pila (in inglese, stack). Come si intuisce dal nome, la
pila è un contenitore all'interno del quale possiamo disporre (impilare) uno sull'altro
una serie di oggetti, tutti della stessa natura; possiamo avere così una pila di sedie,
una pila di scatole, una pila di numeri interi a 16 bit e così via.
La gestione delle strutture a pila assume una importanza enorme nel mondo del computer,
per cui questo argomento deve essere necessariamente analizzato in modo approfondito; a
tale proposito ci serviamo della Figura 10.1 che illustra il funzionamento di una pila di
palline.
Osserviamo subito che il contenitore della pila è dotato di una serie di ripiani o
locazioni; ciascuna locazione viene univocamente individuata da un apposito indice.
In Figura 10.1 abbiamo quindi una pila da 8 locazioni numerate da 0 a
7; ciascuna locazione può contenere una e una sola pallina. Appare anche
evidente che la pila, per sua stessa natura, è dotata di una sola imboccatura
attraverso la quale possiamo inserire o estrarre le palline; è chiaro allora che
le varie palline inserite andranno a disporsi, non in modo casuale, ma in modo
ordinato e cioè una sopra l'altra. Analogamente, durante la fase di estrazione
le palline escono, non in modo casuale, ma in modo ordinato e cioè, a partire da
quella che si trova in cima; la pallina che si trova in cima alla pila è ovviamente
quella che era stata inserita per ultima.
In generale, in una struttura a pila il primo oggetto che viene estratto coincide con
l'ultimo oggetto che era stato inserito; per questo motivo si dice che la pila è
una struttura di tipo LIFO o Last In, First Out (l'ultimo entrato è
anche il primo che verrà estratto).
Le due operazioni fondamentali che possiamo compiere con una pila sono l'inserimento
di un oggetto e l'estrazione di un oggetto; l'operazione di inserimento viene
chiamata PUSH, mentre l'operazione di estrazione viene chiamata POP.
L'istruzione (o operatore) PUSH ha bisogno di una locazione (o operando)
chiamata sorgente; l'operando sorgente dice a PUSH dove prelevare la pallina
da inserire in cima alla pila.
L'istruzione (o operatore) POP ha bisogno di una locazione (o operando)
chiamata destinazione; l'operando destinazione dice a POP dove mettere la pallina
appena estratta dalla pila.
Per gestire correttamente tutta questa situazione abbiamo bisogno di una serie di
importanti informazioni; in particolare, dobbiamo conoscere la dimensione della pila
e la posizione (indice) della cima della pila stessa. La cima della pila (Top Of
Stack) viene indicata con la sigla TOS; in Figura 10.1a vediamo che inizialmente
la pila è vuota e questa situazione è rappresentata chiaramente dalla condizione
TOS=0. L'indice 0 rappresenta quindi il fondo della pila; l'indice
7 è, invece, quello della locazione che si trova all'imbocco della pila stessa.
Osserviamo ora in Figura 10.1b quello che succede in seguito all'istruzione:
PUSH A
Questa istruzione comporta due fasi distinte:
- La pallina contenuta nella locazione sorgente A viene inserita nella
pila all'indice TOS=0
- Il TOS viene incrementato di 1 e si ottiene TOS=1
La Figura 10.1c mostra l'inserimento nella pila di una seconda pallina prelevata dalla
sorgente B; questa pallina si posiziona all'indice 1 immediatamente
sopra la prima pallina, mentre TOS si posiziona sull'indice 2.
La Figura 10.1d mostra l'inserimento nella pila di due ulteriori palline prelevate dalle
sorgenti A1 e A2; questa fase richiede naturalmente due istruzioni
PUSH che portano TOS prima a 3 e poi a 4.
In Figura 10.1e vediamo quello che succede in seguito all'istruzione:
POP A
Questa istruzione comporta due fasi distinte:
- Il TOS viene decrementato di 1 e si ottiene TOS=3
- La pallina contenuta all'indice TOS=3 viene prelevata e sistemata nella
locazione destinazione A
La Figura 10.1f mostra infine l'estrazione dalla pila di due ulteriori palline da trasferire
alle locazioni destinazione C e D; questa fase richiede naturalmente due
istruzioni POP che portano TOS prima a 2 e poi a 1.
Se si vuole una gestione più completa della pila bisogna prevedere anche il controllo
degli errori; osserviamo a tale proposito che l'istruzione PUSH può essere eseguita
solo se TOS è minore di 8, mentre l'istruzione POP può essere
eseguita solo se TOS è maggiore di 0.
La Figura 10.1 ci offre la possibilità di dedurre una regola fondamentale che si applica alle
strutture a pila: in una struttura a pila, tutte le operazioni di estrazione devono
essere effettuate in ordine esattamente inverso alle operazioni di inserimento.
Nel caso di Figura 10.1 questa regola può sembrare del tutto ovvia; in Figura 10.1d, ad
esempio, si vede subito che non è assolutamente possibile estrarre la pallina di indice
2 che si trova sotto la pallina di indice 3. Prima di estrarre quindi la pallina
di indice 2, dobbiamo estrarre la pallina di indice 3; nel caso dello stack di
un programma vedremo però che la situazione non è così ovvia come sembra.
Un'altra regola fondamentale è la seguente: in una struttura a pila, il numero
complessivo delle operazioni di inserimento deve essere perfettamente bilanciato dal numero
complessivo delle operazioni di estrazione.
Se questa regola non viene rispettata ci si ritrova con uno stack che viene definito
sbilanciato; nel caso di un programma, lo stack sbilanciato porta come vedremo in
seguito, ad un sicuro crash.
Un altro aspetto evidente che emerge dalla Figura 10.1 riguarda il fatto che se una pallina
viene prelevata dalla locazione A e viene inserita nello stack all'indice 2,
non è obbligatorio che in fase di estrazione questa stessa pallina venga rimessa per forza
nella locazione A; nessuno ci impedisce di estrarre questa pallina dallo stack e
di metterla in una locazione B.
La struttura a pila mostrata in Figura 10.1 si presta in modo perfetto per la gestione dei
dati temporanei di un programma; questa struttura ci permette, infatti, di ridurre al
minimo indispensabile la quantità di memoria necessaria per questo tipo di dati.
Se, ad esempio, un programma ha bisogno istante per istante di una media di 200 byte
di spazio in memoria per le variabili temporanee, non dobbiamo fare altro che allocare
staticamente un blocco di memoria non inferiore a 200 byte; all'interno di questo
blocco vengono continuamente create e distrutte dinamicamente le locazioni per i dati
temporanei.
Indubbiamente, il compito più delicato per il programmatore consiste nel corretto calcolo
della quantità di memoria da allocare staticamente per il blocco stack; un blocco troppo
grande comporta uno spreco di memoria, mentre un blocco troppo piccolo porta ad un cosiddetto
stack overflow con conseguente crash del programma. È chiaro che in caso di dubbio
conviene sempre approssimare per eccesso le dimensioni del blocco stack.
Vediamo ora come si applicano in pratica i concetti illustrati in Figura 10.1, per implementare
lo stack di un programma. Come è stato appena detto, la prima cosa da fare consiste nel
determinare la dimensione più adatta per questo blocco di memoria; questa informazione
rappresenta la quantità di memoria da allocare staticamente per lo stack.
Il blocco stack di un programma viene gestito come al solito attraverso indirizzi logici del
tipo Seg:Offset; appare evidente il fatto che in questo caso, il TOS non è
altro che la componente Offset della locazione che si trova in cima allo stack.
Per motivi di efficienza, la gestione dello stack di un programma è strettamente legata
alla architettura della CPU che si sta utilizzando; in base ai concetti esposti nei
precedenti capitoli, è fondamentale che vengano garantiti tutti i requisiti di allineamento
in memoria, sia per l'indirizzo di partenza del blocco stack, sia per il TOS.
Una CPU con architettura a 8 bit è predisposta per gestire uno stack di
BYTE; in questo caso, come sappiamo, non esiste alcun problema di allineamento,
né per l'indirizzo iniziale dello stack, né per il TOS.
Una CPU con architettura a 16 bit è predisposta per gestire uno stack di
WORD; in questo caso, come sappiamo, conviene allineare ad indirizzi pari, sia
l'inizio del blocco stack, sia il TOS.
Una CPU con architettura a 32 bit è predisposta per gestire uno stack di
DWORD; in questo caso, come sappiamo, conviene allineare ad indirizzi multipli interi
di 4, sia l'inizio del blocco stack, sia il TOS.
Per chiarire questi importanti concetti analizziamo in Figura 10.2 le caratteristiche del blocco
stack di un programma; in particolare, questa figura si riferisce ad uno stack di WORD,
tipico dei programmi che girano in modalità reale 8086.
Sulla sinistra del vettore della RAM notiamo la presenza dei vari indirizzi
fisici a 20 bit; sulla destra dello stesso vettore notiamo la presenza dei
corrispondenti indirizzi logici normalizzati.
Osserviamo subito che il blocco stack di Figura 10.2 (colorato in verde) ha una
capienza di 7 dati di tipo WORD (per un totale di 14 byte) e inizia in
memoria dall'indirizzo fisico 0BF20h che è un indirizzo pari; assegnando anche
al TOS un valore iniziale pari, possiamo facilmente constatare che tutte le
WORD presenti nello stack si vengono a trovare a loro volta allineate ad indirizzi
pari e possono essere quindi lette o scritte con un unico accesso in memoria da parte delle
CPU con architettura a 16 bit o superiore.
La Figura 10.2 evidenzia anche un aspetto importante che riguarda proprio il valore
iniziale assegnato al TOS; siccome stiamo creando uno stack da 7
WORD, cioè da 14 BYTE, per indicare il fatto che inizialmente
lo stack è vuoto poniamo TOS=14 (000Eh). Il valore 14 indica
quindi che lo stack in quel preciso istante è in grado di accogliere 14
byte di dati temporanei; rispetto al caso di Figura 10.1, si tratta di una differenza
puramente formale in quanto la sostanza è sempre la stessa. Possiamo dire quindi
che in Figura 10.2 il fondo dello stack si trova all'indirizzo fisico più alto
0BF2Dh; l'imboccatura dello stack si trova, invece, all'indirizzo fisico più
basso 0BF20h.
Analizziamo ora il funzionamento dello stack di Figura 10.2 in relazione alle istruzioni
PUSH e POP applicate ad operandi di tipo WORD; osserviamo innanzi
tutto che dall'indirizzo fisico 0BF20h si ricava l'indirizzo logico normalizzato
0BF2h:0000h. La CPU quindi indirizza lo stack attraverso coppie logiche
Seg:Offset aventi la componente Seg pari a 0BF2h; l'inizio dello stack
coincide in questo caso con l'inizio del segmento di memoria n. 0BF2h, per cui i
14 byte dello stack si trovano a spiazzamenti compresi tra 0 e 13 (cioè
tra 0000h e 000Dh).
Consideriamo ora una locazione di memoria A da 16 bit e vediamo quello
che succede in seguito all'istruzione:
PUSH A
Questa istruzione comporta due fasi distinte:
- Il TOS viene decrementato di 2 e si ottiene TOS=12
(cioè TOS=000Ch)
- La WORD presente nella locazione A viene letta e inserita
all'indirizzo logico 0BF2h:000Ch, corrispondente all'indirizzo
fisico 0BF2Ch
Osserviamo che la WORD appena inserita nello stack è formata da due BYTE che
vanno a disporsi agli offset 000Ch e 000Dh; nel rispetto della convenzione
little endian, il BYTE meno significativo occupa l'offset 000Ch, mentre
il BYTE più significativo occupa l'offset 000Dh. È anche importante osservare
che la WORD appena inserita si trova all'indirizzo fisico 0BF2Ch che è pari;
ciò dimostra che tutti i dati di tipo WORD presenti nello stack vengono allineati
in modo ottimale.
Consideriamo ora una locazione di memoria B da 16 bit e vediamo quello
che succede in seguito all'istruzione:
POP B
Questa istruzione comporta due fasi distinte:
- La WORD presente nello stack all'indirizzo logico 0BF2h:000Ch,
viene letta e inserita nella locazione di memoria B
- Il TOS viene incrementato di 2 e si ottiene TOS=14
(cioè TOS=000Eh)
Osserviamo che l'istruzione POP, dopo aver estratto la WORD che si trova
all'indirizzo fisico 0BF2Ch, non ha bisogno di cancellare il contenuto di questa
locazione; infatti, una successiva istruzione PUSH sovrascrive direttamente con
una nuova WORD il vecchio contenuto di quest'area dello stack.
Il funzionamento appena illustrato delle istruzioni PUSH e POP ci permette
di dedurre una serie di importanti aspetti relativi allo stack di un programma; l'aspetto
più importante è legato al fatto che, come si può facilmente intuire, il programmatore
ha il delicato compito di provvedere alla corretta gestione dello stack. Infatti, a
differenza di quanto accade con lo stack di Figura 10.1, nel caso dello stack di un programma
nessuno ci impedisce di utilizzare in modo indiscriminato le istruzioni PUSH e
POP; in questo modo è facile andare incontro a situazioni anomale.
Consideriamo nuovamente lo stack di Figura 10.2 e supponiamo di partire dalla condizione
TOS=14 (stack vuoto); supponiamo poi che il programma in esecuzione ad un certo punto
preveda 4 istruzioni PUSH consecutive. Queste 4 istruzioni inseriscono
4 WORD nello stack portando il TOS al valore 6; se in seguito lo
stesso programma prevede 4 istruzioni POP consecutive, vengono estratte dallo
stack 4 WORD con il TOS che si riporta a 14 (000Eh). A
questo punto nessuno ci impedisce di ricorrere ad una quinta istruzione POP che estrae
da 0BF2h:000Eh una WORD che si trova chiaramente al di fuori dello stack;
inoltre, la stessa istruzione POP porta il TOS all'offset 16!
La situazione è ancora più grave nel caso in cui venga eseguita una istruzione
PUSH con lo stack completamente pieno (TOS=0); in questo caso, infatti,
l'istruzione PUSH cerca di sottrarre 2 al TOS che però vale
0. Siccome il TOS è un valore a 16 bit, si ottiene in
complemento a 2:
0 - 2 = 0 + (65536 - 2) = 0 + 65534 = 65534 = FFFEh
(Si può ricorrere anche all'esempio del contachilometri a 16 bit, andando in
retromarcia di 2 bit a partire da 0000000000000000b).
Si tratta del classico wrap around che porta PUSH ad inserire una WORD
all'indirizzo logico 0BF2h:FFFEh totalmente al di fuori dello stack; trattandosi
però in questo caso di una operazione di scrittura, viene sovrascritta un'area della memoria
che potrebbe anche essere riservata al SO, con conseguente crash del programma!
In definitiva, il programmatore è tenuto a garantire nei propri programmi una gestione
rigorosa dello stack, con un perfetto bilanciamento tra inserimenti ed estrazioni e con
il rispetto dei limiti inferiore e superiore del TOS; come vedremo nei capitoli
successivi, con un minimo di stile e di attenzione il programmatore può svolgere questo
compito in modo relativamente semplice.
Cosa succede se nello stack di Figura 10.2 vogliamo inserire un dato di tipo BYTE?
Le CPU con architettura a 16 bit o superiore non permettono l'utilizzo
delle istruzioni PUSH e POP con operandi di tipo BYTE; in questo
modo si evita che il TOS si disallinei posizionandosi ad indirizzi dispari.
Se vogliamo inserire nello stack un dato di tipo BYTE, siamo costretti quindi a
trasformarlo prima in una WORD; in sostanza, utilizziamo come operando sorgente
di PUSH una locazione da 16 bit che contiene nei suoi 8 bit meno
significativi il nostro dato di tipo BYTE. Al momento di estrarre questo dato
dallo stack, utilizziamo l'istruzione POP con un operando destinazione ugualmente
di tipo WORD; il nostro dato di tipo BYTE verrà così posizionato negli
8 bit meno significativi dell'operando destinazione.
La situazione è ancora più semplice nel caso in cui nello stack di Figura 10.2 si voglia
memorizzare un dato di tipo DWORD; in questo caso, infatti, ci basta suddividere
la DWORD in due WORD utilizzando poi due istruzioni PUSH consecutive.
È fondamentale che il programmatore rispetti la convenzione little endian inserendo
per prima la WORD più significativa; in questo modo la WORD meno significativa
si trova a precedere nello stack la WORD più significativa. Se, ad esempio, vogliamo
inserire nello stack la DWORD 3CF2B8A4h, con il TOS che in quel momento
vale 000Ah, con la prima PUSH la WORD alta 3CF2h viene inserita
all'indirizzo 0BF2h:0008h, mentre con la seconda PUSH la WORD bassa
B8A4h viene inserita all'indirizzo 0BF2h:0006h; con le CPU a
32 bit e superiori, è anche possibile utilizzare PUSH e POP con
operandi a 32 bit, senza uscire dalla modalità reale (in tal caso, il precedente
inserimento può essere effettuato con una sola PUSH, con la CPU che
provvede automaticamente a disporre la DWORD secondo la convenzione little
endian).
All'interno dello stack di un programma i dati temporanei vengono continuamente creati
e distrutti; la situazione interna dello stack è in continua evoluzione, per cui è molto
probabile che uno stesso dato temporaneo creato e distrutto numerose volte nello stack,
venga posizionato di volta in volta sempre ad offset differenti. Tutto dipende, infatti,
dal valore che assume il TOS al momento della creazione di questo dato; la conseguenza
pratica di tutto ciò è data dal fatto che l'indirizzo di un dato temporaneo che si trova
nello stack non è statico ma è dinamico. Per questo motivo, i dati temporanei di
un programma vengono anche chiamati dati dinamici; è evidente che un dato dinamico
può essere utilizzato solo durante la sua esistenza e cioè, nell'intervallo di tempo che
intercorre tra la creazione e la distruzione del dato stesso.
Conoscendo l'indirizzo di un dato dinamico (esistente), possiamo accedere alla corrispondente
locazione dello stack modificandone il contenuto; un dato dinamico il cui contenuto può
essere modificato (variato), viene anche definito variabile dinamica.
Il blocco di memoria destinato a contenere i dati dinamici forma un segmento di programma che
prende il nome di Stack Segment (segmento stack).
Come è stato già detto in precedenza, lo stack di un programma assume una importanza
enorme; questo particolare blocco di memoria, infatti, viene utilizzato non solo dal
programma a cui appartiene, ma anche dal SO e dalla CPU!
Ogni volta che un programma chiama un suo sottoprogramma, la CPU salva nello stack il
cosiddetto return address (indirizzo di ritorno); si tratta dell'indirizzo di memoria
da cui riprenderà l'esecuzione al termine del sottoprogramma stesso.
Ogni volta che la CPU riceve una richiesta di interruzione hardware, prima di chiamare
la relativa ISR sospende il programma in esecuzione salvando nello stack tutte le
necessarie informazioni; al termine della ISR il programma che era stato sospeso viene
riavviato grazie alle informazioni estratte dallo stack.
Molti linguaggi di alto livello utilizzano lo stack per creare le cosiddette variabili
locali; si tratta delle variabili create temporaneamente dai sottoprogrammi che ne
hanno bisogno. Nel momento in cui un sottoprogramma termina, le sue variabili locali non
sono più necessarie e devono essere quindi distrutte; come abbiamo visto in precedenza, lo
stack si presta in modo perfetto per questo particolare scopo.
Nei capitoli successivi vedremo che la struttura a stack si presta in modo ottimale anche per
la gestione delle chiamate innestate tra sottoprogrammi; si hanno le chiamate innestate
quando un sottoprogramma A chiama un sottoprogramma B che a sua volta chiama un
sottoprogramma C e così via.
Un caso particolarmente importante di chiamata innestata si ha quando un sottoprogramma
A chiama se stesso direttamente o indirettamente; si parla allora di chiamata
ricorsiva. Anche in questo caso vedremo che lo stack permette di gestire in modo semplice
ed efficiente la ricorsione.
10.1.3 Segmento misto Data + Stack
Anziché dotare un programma di Data Segment e Stack Segment separati, è
possibile servirsi di un unico segmento di programma destinato a contenere, sia i dati
statici, sia lo stack; in questo caso, il procedimento da seguire può essere facilmente
dedotto dalla Figura 10.2. Prima di tutto dobbiamo allocare staticamente un blocco di memoria
sufficiente a contenere, sia i dati statici, sia lo stack; a questo punto dobbiamo
suddividere questo blocco in due parti destinate una ai dati statici e l'altra ai dati
dinamici.
Osservando la Figura 10.2 si nota che lo stack inizia a riempirsi a partire dagli offset più
alti; man mano che lo stack si riempie, il TOS si decrementa portandosi verso gli
offset più bassi. È chiaro quindi che il nostro segmento misto contenente Data
e Stack deve essere organizzato disponendo i dati statici agli offset più bassi;
gli offset più alti vengono riservati, invece, allo stack.
Supponiamo in Figura 10.2 di riservare i primi 6 offset ai dati statici; in questo
caso il blocco Data risulta compreso tra l'offset 0000h e l'offset
0005h. Tutti gli offset compresi tra 0006h e 000Dh sono riservati,
invece, allo stack; la condizione di stack vuoto è rappresentata da TOS=000Eh,
mentre la condizione di stack pieno è rappresentata da TOS=0006h. Se il
TOS scende sotto l'offset 0006h invade l'area riservata ai dati statici;
in questo caso, eventuali istruzioni PUSH sovrascrivono gli stessi dati statici.
Il segmento misto Data + Stack viene largamente utilizzato da molti linguaggi di
alto livello; questo argomento verrà trattato in un apposito capitolo.
10.1.4 Il Code Segment di un programma
Per la memoria da riservare al blocco codice di un programma si segue lo stesso metodo
già illustrato per il Data Segment; osserviamo, infatti, che l'insieme dei codici
macchina delle varie istruzioni occupa in memoria un ben preciso numero di byte. La cosa
migliore da fare consiste quindi nell'allocare staticamente un blocco di memoria le cui
dimensioni sono sufficienti a contenere i codici macchina di tutte le istruzioni del
programma; il blocco di memoria destinato a contenere le istruzioni forma un segmento di
programma che prende il nome di Code Segment (segmento codice).
10.1.5 Struttura a segmenti di un programma
A questo punto siamo finalmente in grado di affermare che un programma destinato a girare
in modalità reale 8086, nel caso più semplice e intuitivo risulta formato da tre
segmenti di programma chiamati Code Segment, Data Segment e Stack
Segment; in linea di principio, la dimensione in byte di un programma è data dalla
somma delle dimensioni in byte di questi tre segmenti. Al momento di caricare un programma
in memoria per la fase di esecuzione, il DOS predispone un blocco di memoria le cui
dimensioni sono sufficienti a contenere il programma stesso; la Figura 10.3 illustra la
situazione che si viene a creare quando il nostro programma viene caricato nella RAM.
Teoricamente i tre segmenti di programma dovrebbero essere consecutivi e contigui, cioè
attaccati l'uno all'altro; bisogna ricordare però che al programmatore viene data la
possibilità di selezionare il tipo di allineamento in memoria per ciascun segmento. Di
conseguenza, come vedremo in dettaglio nei capitoli successivi, tra un segmento e l'altro
possono rimanere degli spazi vuoti inutilizzati; questi spazi vuoti vengono chiamati in
gergo memory holes (buchi di memoria).
La CPU istante per istante deve avere il controllo totale sul programma in esecuzione
e questo significa che deve sapere in quale area della memoria si trovano, rispettivamente, il
blocco dati, il blocco codice e il blocco stack, qual è l'indirizzo della prossima istruzione
da eseguire, qual è l'indirizzo dei dati eventualmente chiamati in causa da questa istruzione,
qual è l'attuale livello di riempimento dello stack (indirizzo del TOS) e così via;
come già sappiamo, tutte queste informazioni vengono gestite dalla CPU attraverso i
propri registri interni.
In un precedente capitolo è stato anche detto che tutti i registri della CPU destinati
a contenere una componente Seg vengono chiamati Segment Registers (registri di
segmento); tutti i registri della CPU destinati a contenere una componente Offset
vengono chiamati Pointer Registers (registri puntatori).
Osservando la Figura 10.3 possiamo subito dire che una CPU della famiglia 80x86
deve essere dotata come minimo di tre registri di segmento destinati a contenere le
componenti Seg del Code Segment, del Data Segment e dello Stack
Segment; in Figura 10.3 queste tre componenti sono state chiamate Seg Code, Seg
Data e Seg Stack.
In relazione al Code Segment osserviamo subito che le istruzioni vengono eseguite
dalla CPU una alla volta; questo significa che per gestire la componente Offset
dell'indirizzo della prossima istruzione da eseguire è necessario un unico registro puntatore
appositamente destinato a questo scopo.
Nel caso, invece, dello Stack Segment e soprattutto del Data Segment, sarebbe
opportuno avere a disposizione il maggior numero possibile di registri puntatori; è chiaro,
infatti, che è molto meglio poter indirizzare n dati con n registri puntatori
differenti, piuttosto che n dati con un solo registro puntatore da far "puntare" di
volta in volta su uno dei dati stessi.
È anche importante che la CPU ci metta a disposizione un adeguato numero di registri
di uso generale che possiamo utilizzare per contenere, ad esempio, gli operandi di una operazione
logico aritmetica; come già sappiamo, infatti, i registri della CPU sono accessibili
molto più velocemente rispetto alle locazioni di memoria.
Sulla base di queste considerazioni, analizziamo ora l'architettura interna di una generica
CPU appartenente alla famiglia 80x86.
10.2 Architettura generale della CPU
La struttura interna di una CPU varia enormemente da modello a modello; in generale
però alcune parti sono sempre presenti e questo permette di rappresentare una generica
CPU secondo lo schema a blocchi di Figura 10.4.
Il primo aspetto da sottolineare riguarda il fatto che lo scambio di dati tra i vari blocchi
della CPU avviene attraverso un apposito bus chiamato Internal Bus (bus interno),
dotato di un numero di linee che nel caso generale rispecchia l'architettura della stessa
CPU (8 bit, 16 bit, 32 bit, etc); in alcuni rari casi può capitare
che il bus interno abbia un numero di linee maggiore di quello del Data Bus del computer
(questo accadeva, ad esempio, nel caso della CPU 8088 che aveva un bus interno a 16
bit e un Data Bus a 8 bit).
Tornando alla Figura 10.4, possiamo suddividere questo schema in due parti fondamentali: la metà
a sinistra rappresenta la Execution Unit o EU (unità di esecuzione), mentre la
metà a destra rappresenta la Bus Interface Unit o BIU (unità di
interfacciamento con i bus di sistema).
10.2.1 Execution Unit
La EU, come si intuisce dal nome, ha il compito di eseguire materialmente le istruzioni
del programma in esecuzione ed è incentrata sul dispositivo che in Figura 10.4 è stato
chiamato ALU o Arithmetic Logic Unit (unità logico aritmetica); la ALU
contiene i circuiti logici che abbiamo visto nei capitoli precedenti e che sono necessari per
eseguire le operazioni logico aritmetiche sui dati del programma. Tecnologie sempre più
avanzate permettono di inserire in spazi ristretti un numero sempre maggiore di porte
logiche e ciò rende possibile la realizzazione di ALU capaci di eseguire un insieme di
operazioni sempre più vasto e complesso; nel caso generale comunque, qualsiasi ALU
mette a disposizione una serie di operazioni basilari come, ad esempio: addizione, sottrazione,
moltiplicazione, divisione, shift a destra e a sinistra, complementazione dei bit, operatori
logici AND, OR, EX-OR, operatori PUSH e POP per la gestione
dello stack e così via.
Naturalmente, la ALU deve essere in grado di operare su dati la cui dimensione in bit
rispecchia l'architettura della CPU; possiamo dire quindi che su una CPU con
architettura a 16 bit, la ALU deve essere in grado di operare su dati a
16 bit, così come su una CPU con architettura a 32 bit, la ALU
deve essere in grado di operare su dati a 32 bit.
La ALU non è dotata di memoria propria per cui deve servirsi di apposite memorie
esterne che in Figura 10.4 sono rappresentate dai blocchi denominati: Registri temporanei,
Registri generali e Flags (registro dei flags).
I registri temporanei non sono accessibili al programmatore ed hanno il compito di
memorizzare temporaneamente i dati (operandi) sui quali la ALU deve eseguire
una determinata operazione logico aritmetica; ogni volta che arrivano nuovi dati da
elaborare, il vecchio contenuto dei registri temporanei viene sovrascritto.
I registri generali sono, invece, direttamente accessibili al programmatore e rappresentano
una vera e propria memoria RAM interna velocissima, che viene utilizzata per svolgere
svariate funzioni; questi registri, ad esempio, contengono dati in arrivo dalle periferiche
(cioè dalla RAM o dai dispositivi di I/O), dati destinati alle periferiche,
risultati di operazioni appena eseguite dalla ALU e così via.
Come si può notare in Figura 10.4, la ALU è collegata ai registri generali, sia in
ingresso che in uscita e può quindi dialogare direttamente con essi; il programmatore
Assembly può utilizzare in modo massiccio i registri generali per permettere alla
ALU di svolgere svariate operazioni alla massima velocità possibile. Bisogna
ricordare, infatti, che i registri della CPU hanno tempi di accesso nettamente inferiori
rispetto alle locazioni della RAM; tutte le operazioni che coinvolgono dati presenti
nei registri, vengono svolte dalla ALU molto più velocemente rispetto al caso in
cui i dati si trovino, invece, in RAM.
Proprio per questo motivo, persino alcuni linguaggi di programmazione di alto livello danno
la possibilità al programmatore di richiedere l'inserimento di determinati dati del
programma, direttamente nei registri della CPU; il linguaggio C, ad esempio,
mette a disposizione la parola chiave register che applicata ad un dato (intero),
indica al compilatore C di inserire, se è possibile, quel dato in un registro libero
della CPU. Il compilatore C è libero di ignorare eventualmente questa richiesta;
il vantaggio dell'Assembly sta proprio nel fatto che in questo caso il programmatore
ha l'accesso diretto ai registri generali con la possibilità quindi di sfruttarli al
massimo.
Un altro importante componente della EU è il Registro dei Flags, che istante
per istante contiene una serie di informazioni che permettono, sia al programmatore, sia
alla CPU, di avere un quadro completo e dettagliato sul risultato prodotto da una
operazione appena eseguita dalla ALU; in base a queste informazioni, il programmatore
può prendere le decisioni più opportune per determinare il cosiddetto flusso del
programma, cioè l'ordine di esecuzione delle varie istruzioni.
Abbiamo già fatto conoscenza nei precedenti capitoli con il Carry Flag, il Sign
Flag e l'Overflow Flag; più avanti verranno illustrati altri flags, compresi
quelli che hanno lo scopo di configurare la modalità operativa della CPU. L'insieme
dei valori assunti da questi flags definisce la cosiddetta Program Status Word o
PSW (parola di stato del computer).
10.2.2 Bus Interface Unit
Passiamo ora alla metà destra dello schema di Figura 10.4, che rappresenta la BIU ed ha
il compito importantissimo di far comunicare la CPU con il mondo esterno attraverso i
bus di sistema; come si può notare, anche la BIU comprende numerosi registri di
memoria inclusi nei blocchi denominati Registri di segmento e Registri
puntatori. Come abbiamo già visto, attraverso questi registri la CPU controlla
in modo completo l'indirizzamento del programma in esecuzione; si tratta, infatti, dei registri
destinati a contenere gli indirizzi relativi ai vari segmenti che formano un programma.
Tra i vari registri puntatori è necessario segnalare, in particolare, il cosiddetto
PC o Program Counter (contatore di programma), che contiene l'indirizzo
della prossima istruzione da eseguire; nel momento in cui la CPU sta eseguendo
una istruzione, il PC viene aggiornato in modo che punti alla prossima
istruzione da eseguire. Nelle CPU della famiglia 80x86 il PC
viene chiamato IP o Instruction Pointer (puntatore alla prossima
istruzione da eseguire); questi argomenti vengono trattati in dettaglio più avanti.
Il blocco che in Figura 10.4 viene chiamato Control Logic o CL (logica di
controllo) rappresenta come già sappiamo il cervello di tutto il sistema in quanto ha il
compito di gestire tutto il funzionamento della CPU; la CL stabilisce istante
per istante, quale dei dispositivi mostrati in Figura 10.4 deve ricevere il controllo, che
compito deve eseguire questo dispositivo, quali dispositivi devono essere, invece, inibiti,
etc.
In Figura 10.4, le linee di colore rosso rappresentano simbolicamente i collegamenti che
permettono alla CL di pilotare tutti i dispositivi della CPU; attraverso poi
il Control Bus, la CL può anche interagire con i dispositivi esterni alla
CPU (RAM, dispositivi di I/O, etc).
Per avere un'idea dell'importanza della CL, analizziamo in modo semplificato quello
che accade quando deve essere eseguita una istruzione del tipo:
NOT Variabile1
Il nome Variabile1 rappresenta simbolicamente una locazione di memoria che contiene,
ad esempio, un numero intero a 16 bit; la precedente istruzione deve quindi invertire
tutti i 16 bit contenuti nella locazione Variabile1. Come vedremo nei prossimi
capitoli, dal punto di vista della CPU il simbolo Variabile1 rappresenta
l'indirizzo di memoria della locazione contenente il dato a cui vogliamo accedere; tenendo
conto di questo aspetto, l'esecuzione della precedente istruzione comporta le seguenti
fasi:
- La CL legge l'indirizzo Seg:Offset relativo alla prossima istruzione
da eseguire e lo passa al dispositivo che in Figura 10.4 viene chiamato Registro
indirizzi di memoria o Memory Address Register (MAR).
- Il MAR ha il compito (in modalità reale) di convertire la coppia
Seg:Offset in un indirizzo fisico a 20 bit da caricare
sull'Address Bus.
- La CL attiva l'Address Bus e mette in comunicazione la CPU
con la locazione del Code Segment che contiene il codice macchina della
prossima istruzione da eseguire.
- La CL attiva il Data Bus in lettura in modo che il codice macchina
venga letto dalla memoria e caricato nel dispositivo che in Figura 10.4 viene
chiamato Registro Istruzioni.
- Il codice macchina viene decodificato dal dispositivo che in Figura 10.4 viene
chiamato Decodifica Istruzioni e viene accertata in questo modo la richiesta
di eseguire una istruzione NOT sui 16 bit del dato Variabile1.
- La CL legge l'indirizzo Seg:Offset relativo a Variabile1 e lo
passa al MAR.
- Il MAR converte la coppia Seg:Offset in un indirizzo fisico a
20 bit da caricare sull'Address Bus.
- La CL attiva l'Address Bus e mette in comunicazione la CPU
con la locazione del Data Segment che contiene Variabile1.
- La CL attiva il Data Bus in lettura permettendo il trasferimento
del contenuto di Variabile1 in uno dei registri temporanei della ALU.
- La CL attiva sulla ALU la rete logica che esegue l'operazione NOT
sul contenuto del registro temporaneo selezionato.
- La CL attiva il Data Bus in scrittura permettendo il trasferimento
del contenuto del registro temporaneo nella locazione Variabile1.
Dopo aver esaminato la struttura generale di una ipotetica CPU, vediamo come questi
concetti vengono applicati ai microprocessori realmente esistenti della famiglia 80x86;
naturalmente, ci interessa analizzare in dettaglio le caratteristiche dei registri e cioè,
di quelle parti della CPU che sono direttamente accessibili al programmatore.
10.3 Registri interni delle CPU a 16 bit
Nella famiglia 80x86 la CPU di riferimento per le architetture a 16
bit è sicuramente l'8086; la Figura 10.5 illustra il set completo di registri interni
(tutti a 16 bit) di questa CPU.
Osserviamo subito che l'8086 dispone di 4 registri di segmento che
sono CS, DS, ES e SS; come si deduce facilmente dai
nomi:
- CS è destinato a contenere la componente Seg del Code Segment
- DS è destinato a contenere la componente Seg del Data Segment
- SS è destinato a contenere la componente Seg dello Stack Segment
Il registro ES permette di gestire la componente Seg di un secondo Data
Segment; possiamo dire quindi che con l'8086, il programmatore ha eventualmente
la possibilità di gestire contemporaneamente due Data Segment distinti. La presenza
di ES è necessaria in quanto l'8086 dispone di istruzioni che, come vedremo
in un apposito capitolo, permettono di trasferire dati tra due blocchi di memoria; il
comportamento predefinito di queste istruzioni prevede che il blocco sorgente venga
gestito con DS e il blocco destinazione con ES.
I registri che in Figura 10.5 vengono definiti speciali, sono tutti registri puntatori;
il loro scopo quindi è quello di gestire componenti Offset relative ai vari
segmenti di programma.
Il registro IP fa coppia con CS, in quanto è destinato a contenere
la componente Offset dell'indirizzo della prossima istruzione da eseguire;
questo registro non è accessibile in modo diretto al programmatore.
Il compito di inizializzare la coppia CS:IP spetta rigorosamente al
linker; a tale proposito, il programmatore è tenuto a specificare
obbligatoriamente in ogni programma, l'indirizzo della prima istruzione da eseguire.
Questo indirizzo prende il nome di entry point (punto d'ingresso) e permette
appunto al linker di determinare la coppia iniziale Seg:Offset che
verrà caricata in CS:IP.
In fase di esecuzione di un programma, la gestione di CS:IP passa alla
CPU che provvede ad aggiornare istante per istante IP (e se
necessario anche CS); osserviamo, infatti, che l'ordine con il quale
verranno eseguite le varie istruzioni del nostro programma è implicito nella
struttura del programma stesso.
Il programmatore ha la possibilità di accedere indirettamente a CS e a
IP, modificandone il contenuto; se si eccettuano casi rarissimi (e molto
complessi), il programmatore deve evitare nella maniera più assoluta qualsiasi
modifica alla coppia CS:IP!
I registri puntatori SP e BP fanno coppia normalmente con SS
e sono quindi destinati a contenere componenti Offset relative allo Stack
Segment del programma; in particolare, istante per istante SP rappresenta
il TOS dello stack. L'utilizzo di SP è riservato quindi principalmente
al SO e alla CPU; in alcuni rari casi, SP deve essere gestito
direttamente anche dal programmatore.
Il registro BP può essere invece utilizzato dal programmatore per muoversi
liberamente all'interno dello stack; lo scopo fondamentale di BP è quello di
permettere al programmatore di accedere rapidamente ai dati temporanei eventualmente
salvati nello stack.
BP viene utilizzato per lo stesso scopo anche da molti compilatori dei
linguaggi di alto livello; in questo caso, BP ha il compito di indirizzare,
ad esempio, le variabili locali e la lista dei parametri dei sottoprogrammi. Queste
informazioni formano il cosiddetto stack frame di un sottoprogramma; in un
apposito capitolo, questi argomenti verranno trattati in modo dettagliato.
L'inizializzazione della coppia SS:SP può essere effettuata in modo automatico
dal linker; in alternativa, si può anche optare per una inizializzazione manuale
a carico del programmatore. Per delegare al linker il compito di inizializzare
in modo automatico la coppia SS:SP, dobbiamo creare un segmento di programma
esplicitamente "etichettato" come Stack Segment; in questo caso il linker
carica in SS la componente Seg dell'indirizzo iniziale del blocco stack
e in SP la componente Offset del TOS iniziale (che, come già
sappiamo, corrisponde alla massima capienza in byte dello stack). In assenza di uno
Stack Segment esplicito, l'inizializzazione di SS:SP deve essere effettuata
rigorosamente dal programmatore; questi argomenti verranno analizzati in dettaglio nei
prossimi capitoli.
I registri puntatori SI e DI fanno coppia normalmente con DS e sono
quindi destinati a contenere componenti Offset relative al Data Segment del
programma; il programmatore può quindi indirizzare i dati di un programma attraverso
coppie del tipo DS:SI e DS:DI. L'inizializzazione di DS spetta
unicamente al programmatore; è chiaro, infatti, che è compito del programmatore indicare
alla CPU l'indirizzo dei dati ai quali si vuole accedere.
I due registri SI e DI vengono anche utilizzati automaticamente dalla
CPU con le istruzioni citate in precedenza per il trasferimento dati tra due blocchi
di memoria; in questo caso la CPU utilizza DS:SI come sorgente e ES:DI
come destinazione. Ciò spiega anche il perché dei nomi assegnati a SI e DI;
infatti, la sigla SI sta per indice sorgente, mentre la sigla DI
sta per indice destinazione.
Una caratteristica particolare di SI e DI è data dal fatto che questi
due registri possono essere utilizzati, non solo come registri puntatori, ma anche come
registri generali; il programmatore è libero quindi di utilizzare SI e DI
per gestire informazioni di vario genere.
Passiamo infine al gruppo dei 4 registri generali chiamati AX, BX,
CX e DX; trattandosi di registri generali, il programmatore li può
utilizzare liberamente per svariati scopi. In particolare, i registri generali vengono
impiegati in modo veramente massiccio per contenere dati che devono essere velocemente
elaborati dalla ALU; i nomi di questi 4 registri sono strettamente legati
al ruolo predefinito che essi svolgono nella CPU 8086.
Il registro AX viene chiamato accumulatore e svolge il ruolo di registro
preferenziale della CPU; in generale, tutte le famiglie di CPU hanno
almeno un registro accumulatore. La peculiarità dell'accumulatore sta nel fatto che esso
viene largamente utilizzato dalla CPU come registro predefinito per numerose
operazioni; nel caso dell'8086, il registro AX viene utilizzato, ad esempio,
come operando predefinito per moltiplicazioni, divisioni, comparazioni, etc. Un altro
impiego predefinito di AX è quello di registro destinazione o sorgente per lo
scambio di dati con le periferiche di I/O; appare evidente il fatto che per il
programmatore, un utilizzo oculato di AX può portare ad ottenere un notevole
miglioramento delle prestazioni di un programma.
Il registro BX ha caratteristiche del tutto simili a SI e DI; infatti,
anche BX può essere usato, sia come registro generale, sia come registro puntatore.
Il suo nome (registro base) deriva proprio dall'impiego come puntatore in combinazione
con SI e DI; come vedremo in un apposito capitolo, attraverso questi registri
è possibile specificare componenti Offset del tipo BX+DI. In una componente
Offset di questo genere il registro BX viene appunto chiamato base,
mentre DI viene chiamato indice; con questo sistema è possibile indirizzare
in modo molto efficiente, vettori, matrici e altre strutture dati. Il registro BX
impiegato come puntatore, normalmente fa coppia con DS.
Il registro CX viene chiamato contatore in quanto la CPU lo utilizza
in diverse istruzioni come registro predefinito per effettuare dei conteggi; naturalmente,
come accade per tutti i registri generali, il programmatore è libero di utilizzare
CX anche per altri scopi.
Il registro DX, infine, viene chiamato registro dati in quanto il suo ruolo
predefinito è quello di contenere dati da impiegare come operandi in varie operazioni
logico aritmetiche; questo registro viene anche impiegato per contenere l'indirizzo di
una porta hardware che deve comunicare con la CPU.
Osserviamo in Figura 10.5 che i 4 registri generali AX, BX, CX
e DX sono tutti a 16 bit; al programmatore viene data anche la possibilità
di suddividere ciascuno di essi in due Half Registers (mezzi registri) da 8
bit. A tale proposito, basta sostituire la lettera X con la lettera H o
L; la H sta per High (alto), mentre la L sta per Low
(basso). Nel caso, ad esempio, di AX, la sigla AH sta per Accumulator
High e indica gli 8 bit più significativi di AX; la sigla AL
sta, invece, per Accumulator Low e indica gli 8 bit meno significativi di
AX.
10.3.1 Associazioni predefinite tra registri puntatori e registri di segmento
Le considerazioni appena esposte assumono una importanza enorme nella scrittura
di un programma Assembly; è necessario quindi analizzare in dettaglio
gli aspetti relativi alle associazioni predefinite che la CPU effettua
tra registri di segmento e registri puntatori.
Partiamo quindi dalle convenzioni fondamentali seguite dalla CPU:
- CS è il registro di segmento naturale per il Code Segment di un programma
- DS è il registro di segmento naturale per il Data Segment di un programma
- SS è il registro di segmento naturale per lo Stack Segment di un programma
Sulla base di queste convenzioni, la CPU stabilisce una serie di importanti
associazioni predefinite tra registri puntatori e registri di segmento; in assenza
di diverse indicazioni da parte del programmatore, la CPU adotta il seguente
comportamento predefinito:
- IP viene automaticamente associato a CS
- SP e BP vengono automaticamente associati a SS
- SI, DI e BX vengono automaticamente associati a DS
Come è stato detto in precedenza, la coppia CS:IP rappresenta, istante per istante,
l'indirizzo logico della prossima istruzione da eseguire; il programmatore non ha alcuna
possibilità di alterare l'associazione predefinita tra CS e IP. Del resto,
abbiamo visto che la gestione della coppia CS:IP spetta rigorosamente alla
CPU; se non si è sicuri di ciò che si sta facendo, è necessario evitare nella maniera
più assoluta di accedere indirettamente a questi registri, modificandone il contenuto.
Il programmatore ha, invece, la possibilità di gestire come meglio crede le associazioni
predefinite che coinvolgono SP, BP, SI, DI e BX; in
particolare, accettando queste associazioni predefinite possiamo gestire un indirizzo
logico attraverso la sola componente Offset!
Questa possibilità è legata, ovviamente, al fatto che la CPU è in grado, appunto,
di associare automaticamente un determinato registro puntatore, all'opportuno registro di
segmento (che contiene la componente Seg di un indirizzo logico).
Supponiamo di specificare un indirizzo logico attraverso il solo registro SP; in
base alle convenzioni enunciate in precedenza, la CPU associa automaticamente
SP a SS e ottiene l'indirizzo logico completo SS:SP.
Analogamente, supponiamo di specificare un indirizzo logico attraverso il solo registro
DI; in base alle convenzioni enunciate in precedenza, la CPU associa
automaticamente DI a DS e ottiene l'indirizzo logico completo DS:DI.
Un indirizzo logico formato dalla sola componente Offset, viene chiamato
indirizzo NEAR (indirizzo vicino); gli indirizzi NEAR sono formati
da soli 16 bit, per cui oltre a ridurre le dimensioni del programma, sono
anche molto più veloci da gestire rispetto agli indirizzi logici completi espressi
da coppie Seg:Offset.
Se il programmatore vuole alterare le associazioni predefinite tra registri puntatori e
registri di segmento, deve indicare in modo esplicito alla CPU, a quale registro
di segmento (non naturale) vuole associare un determinato registro puntatore.
Se, ad esempio, vogliamo associare BX a CS, dobbiamo scrivere
esplicitamente CS:BX; analogamente, se vogliamo associare BP a
DS, dobbiamo scrivere esplicitamente DS:BP.
L'alterazione delle associazioni predefinite tra registri di segmento e registri
puntatori, prende il nome di segment override; in inglese, il verbo to
override significa, scavalcare, aggirare le regole.
Un indirizzo logico formato da una coppia completa Seg:Offset, viene
chiamato indirizzo FAR (indirizzo lontano); gli indirizzi FAR occupano
più memoria rispetto agli indirizzi NEAR e vengono gestiti meno velocemente
dalla CPU.
Nei limiti del possibile, è meglio quindi servirsi sempre di indirizzi di tipo
NEAR; come vedremo però nel seguito del capitolo e nei capitoli successivi,
in molte situazioni non esiste alternativa all'utilizzo degli indirizzi FAR.
10.3.2 Indirezione o deriferimento di un puntatore NEAR
Supponiamo di avere un dato a 16 bit che si trova all'offset 002Ch di un
Data Segment avente componente Seg pari a 03FCh; supponiamo inoltre
che questo dato sia un numero intero espresso dal valore decimale 32950.
Se vogliamo gestire questo Data Segment con DS, dobbiamo porre, ovviamente,
DS=03FCh; sfruttando ora le associazioni predefinite che legano DS a
DI, SI e BX, possiamo utilizzare uno di questi registri puntatori,
per gestire il nostro dato attraverso un indirizzo di tipo NEAR.
Caricando, ad esempio, in BX l'offset 002Ch, possiamo dire che il dato a
cui vogliamo accedere, si trova all'indirizzo NEAR espresso da BX; la
CPU associa automaticamente BX a DS e ottiene l'indirizzo logico
completo DS:BX=03FCh:002Ch.
Se ora vogliamo accedere a questo dato tramite il suo puntatore, dobbiamo compiere una
operazione che prende il nome di deriferimento del puntatore (o indirezione del
puntatore); si dice anche che il puntatore viene dereferenziato.
Il linguaggio Assembly fornisce una apposita sintassi che ci permette di
dereferenziare un puntatore; se BX è un puntatore NEAR, allora il
simbolo [BX] rappresenta il contenuto della locazione di memoria puntata
da BX. Nel nostro caso:
Se chiediamo alla CPU di trasferire nel registro accumulatore AX
il contenuto di BX, otteniamo AX=002Ch; in questo caso, infatti,
la CPU legge il valore 002Ch contenuto in BX e lo copia
in AX.
Se, invece, chiediamo alla CPU di trasferire nel registro accumulatore
AX il contenuto di [BX], otteniamo AX=32950; in questo caso,
infatti, la CPU accede all'indirizzo di memoria DS:BX=03FCh:002Ch,
legge il contenuto 32950 e lo copia in AX.
10.3.3 Indirezione o deriferimento di un puntatore FAR
Supponiamo di avere un dato a 16 bit che si trova all'offset 001Ah
di un Code Segment avente componente Seg pari a 0DF8h;
supponiamo inoltre che questo dato sia un numero intero espresso dal valore
decimale 12920.
Quando la CPU sta eseguendo le istruzioni presenti in questo Code
Segment, si ha, ovviamente, CS=0DF8h; infatti, sappiamo che la
CPU utilizza CS:IP per accedere alle istruzioni da eseguire.
In una situazione di questo genere (con DS già impegnato a referenziare
il Data Segment), siamo costretti a gestire il nostro dato attraverso
un indirizzo di tipo FAR; infatti, il registro di segmento CS
non è quello naturale per i dati.
Caricando, ad esempio, in DI l'offset 001Ah, possiamo dire che
il dato a cui vogliamo accedere, si trova all'indirizzo FAR espresso
da CS:DI=0DF8h:001Ah; in assenza del segment override esplicito, la
CPU assocerebbe automaticamente DI a DS.
In base a quanto è stato esposto in precedenza sul deriferimento dei puntatori,
possiamo dire che se CS:DI è un puntatore FAR, allora il simbolo
CS:[DI] rappresenta il contenuto della locazione di memoria puntata da
CS:DI (è permessa anche la sintassi [CS:DI]); nel nostro caso:
Se chiediamo alla CPU di trasferire nel registro accumulatore AX
il contenuto di DI, otteniamo AX=001Ah; in questo caso, infatti,
la CPU legge il valore 001Ah contenuto in DI e lo copia
in AX.
Se chiediamo alla CPU di trasferire nel registro accumulatore AX
il contenuto di [DI], otteniamo in AX un valore diverso da
12920; in questo caso, infatti, la CPU associa automaticamente
DI a DS e copia in AX un valore a 16 bit che si
trova all'indirizzo DS:DI.
Se, invece, chiediamo alla CPU di trasferire nel registro accumulatore
AX il contenuto di CS:[DI], otteniamo AX=12920; in questo
caso, infatti, la CPU accede all'indirizzo di memoria
CS:DI=0DF8h:001Ah, legge il contenuto 12920 e lo copia in
AX.
Come si può facilmente intuire, i concetti appena esposti sui puntatori e sulle associazioni
predefinite tra registri, assumono una importanza colossale non solo per chi programma
in Assembly; un programmatore che non acquisisce una adeguata padronanza di questi
concetti, va incontro ad una elevatissima probabilità di scrivere programmi contenenti
errori piuttosto gravi e subdoli.
Tutti i concetti relativi agli indirizzi di memoria e alla loro indirezione, rappresentano
il metodo più semplice e naturale che permette alla CPU di svolgere il proprio
lavoro; si tratta quindi degli stessi concetti che si ritrovano (in modo più o meno
esplicito) in tutti i linguaggi di programmazione di alto livello. Abbiamo già visto,
ad esempio, che nel linguaggio C, una definizione del tipo:
char near *ptcn;
crea un puntatore NEAR (intero senza segno a 16 bit) chiamato ptcn e
destinato a contenere la componente Offset dell'indirizzo di un dato di tipo
char (intero con segno a 8 bit).
Analogamente, la definizione:
char far *ptcf;
crea un puntatore FAR (intero senza segno a 32 bit) chiamato ptcf
e destinato a contenere la coppia Seg:Offset dell'indirizzo di un dato di tipo
char (intero con segno a 8 bit).
Purtroppo, però, molti libri sulla programmazione con i linguaggi di alto livello,
descrivono i puntatori come qualcosa di complesso e misterioso; proprio per questo
motivo, capita spesso che i puntatori diventino un vero e proprio incubo per i
programmatori. Tanto per citare un esempio emblematico, in un libro sul linguaggio
Pascal, i puntatori sono stati addirittura definiti "entità esoteriche"!
Per chi programma in Assembly, invece, i puntatori rappresentano il pane quotidiano
e queste paure appaiono assolutamente incomprensibili; abbiamo appena visto, infatti, che
si tratta in realtà di concetti abbastanza semplici da capire anche per un programmatore
Assembly alle prime armi.
10.3.4 Flags Register
La CPU 8086 dispone anche di un registro a 16 bit chiamato
Flags Register; come è stato già detto, questo registro istante per
istante contiene una serie di informazioni che permettono, sia al programmatore,
sia alla CPU, di avere un quadro completo e dettagliato sul risultato
prodotto da una operazione appena eseguita dalla ALU.
Il Flags Register (o FLAGS) è suddiviso in diversi campi che, in
genere, hanno una ampiezza pari ad 1 bit; la Figura 10.6 illustra la disposizione
di questi campi.
I bit colorati in rosso sono inutilizzati e in ogni caso, si tratta di campi riservati
alle future CPU; i manuali della 8086 consigliano di evitare la modifica
del contenuto di questi bit.
I bit colorati in bianco rappresentano ciascuno un differente flag (segnalatore);
notiamo, in particolare, la presenza dei flags CF, SF e OF che
già conosciamo. Nel caso generale, ciascuno di questi bit indica se, dopo lo
svolgimento di una operazione logico aritmetica, una determinata condizione si è
verificata o meno; se il bit vale 1, la condizione si è verificata, mentre
se il bit vale 0, la condizione non si è verificata.
Il flag CF o Carry Flag (segnalatore di riporto/prestito) vale 1 quando
si verifica un riporto dopo un'addizione o quando si verifica un prestito dopo una
sottrazione; in caso contrario, CF vale zero. Nei prossimi capitoli vedremo altri
significati assunti, sia dal flag CF, sia dagli altri flags.
Il flag PF o Parity Flag (segnalatore di parità) viene largamente utilizzato
per il controllo degli errori durante la trasmissione di dati tra due computer attraverso
dispositivi come il modem; quando la CPU esegue determinate istruzioni logico
aritmetiche, controlla gli 8 bit meno significativi del risultato prodotto dalle
istruzioni stesse e attraverso PF indica se questi 8 bit contengono un numero
pari o dispari di 1. Se il numero di 1 è pari, la CPU pone PF=1;
se il numero di 1 è dispari, la CPU pone PF=0.
La ragione per cui PF si riferisce solo agli 8 bit meno significativi di un
numero è legata al fatto che diversi protocolli di comunicazione tra computer prevedono
la suddivisione dei dati trasmessi, in gruppi (pacchetti) di 8 bit; questi protocolli
utilizzano per i dati la vecchia codifica ASCII a 7 bit che permette di
rappresentare 27=128 simboli differenti. I 7 bit meno
significativi di ogni pacchetto contengono un codice ASCII, mentre il bit più
significativo contiene il cosiddetto bit di parità; un bit di parità di valore
1 prende il nome di parità pari e indica che il pacchetto è valido solo
se i suoi 8 bit hanno un numero pari di 1. Un bit di parità di valore
0 prende il nome di parità dispari e indica che il pacchetto è valido
solo se i suoi 8 bit hanno un numero dispari di 1; questo sistema si basa
sul fatto che se la probabilità di inviare un bit sbagliato è bassa, la probabilità
di inviare due bit sbagliati diventa enormemente più bassa.
Il flag AF o Auxiliary Flag (segnalatore ausiliario) vale 1 quando si
verifica un riporto dal nibble basso al nibble alto di un numero a 8 bit o quando
si verifica un prestito dal nibble alto al nibble basso di un numero a 8 bit; come
vedremo in un apposito capitolo, AF viene utilizzato nell'aritmetica dei numeri in
formato BCD, cioè in formato Binary Coded Decimal (decimale codificato in
binario).
Il flag ZF o Zero Flag (segnalatore di zero) vale 1 quando il risultato
di una operazione è zero; in caso contrario si ha ZF=0.
Il flag SF o Sign Flag (segnalatore di segno) vale 1 quando il
risultato di una operazione ha il bit più significativo (MSB) che vale 1;
in caso contrario si ottiene SF=0. Come già sappiamo, i numeri interi con segno
espressi in complemento a 2 sono negativi se hanno l'MSB che vale 1,
mentre sono positivi in caso contrario.
Il flag OF o Overflow Flag (segnalatore di trabocco) vale 1 quando il
risultato di una operazione tra numeri con segno è "troppo positivo" o "troppo negativo";
in sostanza, OF=1 segnala un overflow di segno, cioè un risultato il cui bit
di segno è l'opposto di quello che sarebbe dovuto essere.
I flags appena elencati, vengono modificati direttamente dalla CPU; nel Flags
Register dell'8086 sono presenti anche altri tre flags che vengono gestiti
invece dal programmatore e servono a configurare il modo di operare della CPU.
Il flag TF o Trap Flag (segnalatore di trappola) viene utilizzato da appositi
programmi chiamati debuggers per scovare eventuali errori in un programma in
esecuzione; se TF viene posto a 1, la CPU genera una interruzione
hardware per ogni istruzione eseguita e ciò permette ai debuggers (che intercettano
l'interruzione) di analizzare, istruzione per istruzione, il programma in esecuzione
(si parla in questo caso di esecuzione a passo singolo). Se non si deve effettuare
alcun lavoro di "debugging", si consiglia vivamente di tenere TF a 0 in
modo da evitare di rallentare il computer; questo flag viene inizializzato a 0 in
fase di accensione del PC.
Il flag IF o Interrupt Enable Flag (segnalatore di interruzioni abilitate)
serve ad abilitare o a disabilitare la gestione delle interruzioni hardware che le varie
periferiche inviano alla CPU per segnalare di voler intervenire; tutte le interruzioni
hardware che possono essere abilitate o disabilitate tramite IF vengono chiamate
maskable interrupts (interruzioni mascherabili).
Si può avere la necessità di disabilitare temporaneamente queste interruzioni
quando, ad esempio, un programma sta eseguendo un compito piuttosto delicato che
richiede la totale assenza di "interferenze" esterne; ponendo IF=0 le
interruzioni mascherabili vengono disabilitate (mascherate), mentre se IF=1
le interruzioni mascherabili vengono abilitate ed elaborate dalla CPU.
Lo stato di IF non ha alcun effetto sulle interruzioni software (che vengono
inviate esplicitamente dai programmi) e sulle cosiddette NMI o Non Maskable
Interrupts (interruzioni non mascherabili); le NMI vengono inviate
dall'hardware alla CPU nel caso si verifichino gravi problemi nel computer
come, ad esempio, errori in memoria o nei dati che transitano sui bus. Osservando la
Figura 9.3 e la Figura 9.6 del precedente capitolo, si può notare nella piedinatura della
8086 e della 80386, la presenza di un terminale chiamato NMI;
proprio attraverso questo ingresso, le interruzioni non mascherabili vengono inviate
alla CPU.
Per motivi facilmente comprensibili, si consiglia di tenere IF a 0 per il
minor tempo possibile; in fase di accensione del PC, il flag IF viene
inizializzato a 1.
Il flag DF o Direction Flag (segnalatore di direzione) viene utilizzato in
combinazione con le istruzioni della CPU che eseguono trasferimenti di dati e
comparazioni varie tra due blocchi di memoria; abbiamo già visto che in questo caso la
CPU utilizza DS:SI come indirizzo sorgente predefinito e ES:DI
come indirizzo destinazione predefinito. Durante queste operazioni, i puntatori SI
e DI vengono automaticamente aggiornati, cioè incrementati o decrementati; ponendo
DF=0 i puntatori vengono autoincrementati, mentre con DF=1 i puntatori
vengono autodecrementati. In fase di accensione del PC, il flag DF viene
inizializzato a 0.
10.4 Registri interni delle CPU a 32 bit
Nella famiglia 80x86, le CPU di riferimento per le architetture a
32 bit sono la 80386 DX e la 80486 DX; sostanzialmente, la
80486 DX è una evoluzione della 80386 DX con, in più, un
coprocessore matematico incorporato. Nel seguito del capitolo possiamo fare quindi
riferimento alla generica sigla 80386; tutte le considerazioni che verranno
esposte, sono perfettamente valide anche per le CPU di classe Pentium
(80586, 80686, etc) a 64 bit.
Una delle caratteristiche fondamentali della 80386, è la compatibilità
verso il basso; grazie a questa caratteristica, tutti i programmi scritti per la
8086 possono essere eseguiti, senza alcuna modifica, sulla 80386.
Per ottenere questa compatibilità, la 80386 in fase di accensione del
computer, viene inizializzata in modalità reale; in questa modalità viene anche
disabilitata la linea A20 dell'Address Bus, in modo che la 80386
sia in grado di indirizzare in modo diretto solo 220=1048576 byte,
proprio come accade per la 8086.
Naturalmente, tutto ciò non basta per rendere la 80386 pienamente compatibile
con la 8086; è anche necessario che il set di istruzioni della 80386
sia una estensione del set di istruzioni della 8086. Analogamente, il set di
registri interni della 80386 deve essere una estensione del set di registri
interni della 8086. Tutto ciò che concerne il set di istruzioni delle
CPU 80x86 verrà trattato in dettaglio nel prossimo capitolo; in questo
capitolo ci occuperemo, invece, della compatibilità tra i registri interni della
80386 e della 8086.
Per ottenere la necessaria compatibilità, si ricorre ad un espediente molto semplice,
che consiste nel prendere i registri a 16 bit della 8086, "estendendoli"
a 32 bit nella 80386; in questo modo si perviene alla situazione
illustrata in Figura 10.7.
Osserviamo subito che i registri di segmento della 80386 rimangono a 16
bit; i primi 4 registri, CS, SS, DS e ES, sono
assolutamente equivalenti agli omonimi registri della 8086 e in modalità
reale sono destinati a contenere le solite componenti Seg dei vari segmenti
di programma. Notiamo inoltre che la 80386 rende disponibili due nuovi registri
di segmento, FS e GS, destinati alla gestione di due ulteriori Data
Segment; possiamo dire quindi che con la 80386, il programmatore può
gestire in modalità reale ben 4 Data Segment differenti
contemporaneamente. Le sigle FS e GS di questi due nuovi registri, non
hanno alcun significato particolare; si tratta semplicemente della prosecuzione
alfabetica delle sigle DS e ES.
I 5 registri speciali di Figura 10.7 sono, anche in questo caso, tutti registri
puntatori; come si può notare, si tratta delle estensioni a 32 bit dei
5 registri puntatori SI, DI, BP, SP e IP
della 8086. Il programmatore può riferirsi ai registri speciali a 32
bit anteponendo una E (extended) ai nomi dei corrispondenti registri
speciali a 16 bit; osserviamo inoltre che i registri speciali a 16 bit,
occupano i 16 bit meno significativi dei corrispondenti registri speciali a
32 bit.
Anche nel caso della 80386, i registri speciali SI, DI,
ESI e EDI possono svolgere il ruolo, sia di registri puntatori,
sia di registri generali; in particolare, utilizzando ESI e EDI
come registri generali, possiamo gestire con grande efficienza generici valori
a 32 bit.
I 4 registri generali EAX, EBX, ECX e EDX
della 80386, sono le estensioni a 32 bit dei corrispondenti
registri a 16 bit AX, BX, CX e DX della
8086; come si può notare in Figura 10.7, vengono supportati, naturalmente,
anche gli half registers relativi a AX, BX, CX e
DX. Anche in questo caso, BX e EBX possono svolgere il ruolo,
sia di registri generali, sia di registri puntatori; come registri puntatori,
BX e EBX vengono normalmente associati a DS.
Un aspetto particolarmente interessante riguarda il fatto che la 80386 ci
permette di utilizzare la sua architettura a 32 bit senza uscire dalla modalità
reale; in questo modo possiamo sfruttare, ad esempio, i registri generali a 32
bit per gestire via hardware operandi a 32 bit da elaborare ad altissima velocità
con la ALU. Con la 80386 possiamo quindi gestire, via hardware, dati a
32 bit che con la 8086 richiederebbero una gestione via software; infatti,
con la 8086 un dato a 32 bit deve essere necessariamente scomposto almeno
in due parti da 16 bit ciascuna.
Come esempio pratico supponiamo, con la 80386, di voler copiare il contenuto di
BX nei 16 bit più significativi di EAX e il contenuto di CX
nei 16 bit meno significativi di EAX; un metodo molto sbrigativo per
ottenere questo risultato, consiste nell'utilizzare le seguenti tre istruzioni:
Supponendo che TOS=0088h, si vede subito che con le prime due PUSH, i
16 bit di BX vengono inseriti nello stack agli offset 0086h e
0087h, mentre i 16 bit di CX vengono inseriti nello stack agli
offset 0084h e 0085h; a questo punto, l'istruzione POP estrae
32 bit dagli offset 0084h, 0085h, 0086h, 0087h
dello stack e li copia in EAX. Si noti che dopo l'esecuzione di queste
3 istruzioni, lo stack rimane perfettamente bilanciato; abbiamo effettuato,
infatti, due inserimenti da 16 bit ciascuno e una estrazione da 32 bit.
Tornando alla Figura 10.7, esaminiamo il Flags Register che sulla 80386 viene
esteso anch'esso a 32 bit, diventando così l'Extended Flags Register o
EFLAGS; come al solito, per motivi di compatibilità verso il basso, i primi
16 bit di EFLAGS rispecchiano perfettamente la struttura del registro
FLAGS a 16 bit della 8086. I successivi 16 bit di
EFLAGS (e anche i bit inutilizzati in FLAGS) contengono altri flags che
vengono impiegati in modalità protetta e verranno descritti nella apposita sezione
Modalità Protetta di questo sito; lo stesso discorso vale per numerosi altri
registri speciali presenti sulle 80386, 80486, etc, che non possono essere
utilizzati in modalità reale.
10.5 Logica di controllo e temporizzazioni della CPU
Abbiamo visto che la Control Logic è il cervello pensante non solo della CPU,
ma dell'intero computer; istante per istante la CL deve stabilire quali dispositivi
devono essere disabilitati, quali devono essere, invece, abilitati e quale compito devono
svolgere. Risulta evidente che sarebbe impossibile far funzionare il computer se tutte
queste fasi non fossero caratterizzate da una adeguata sincronizzazione e temporizzazione;
come abbiamo visto nei precedenti capitoli, questo compito (che consiste in pratica nello
scandire il ritmo all'interno del computer) spetta ad un apposito segnale periodico
chiamato clock.
In base a quanto è stato esposto nel Capitolo 7, la CPU a partire dal segnale di
clock di riferimento, ricava altri segnali periodici che servono a gestire diverse
sincronizzazioni; in particolare, consideriamo il segnale CLKm che assume una
notevole importanza. Abbiamo visto, infatti, che ogni ciclo del segnale CLKm
rappresenta il cosiddetto ciclo macchina e più cicli macchina formano il ciclo
istruzione, cioè il numero di cicli macchina necessari alla CPU per eseguire una
determinata istruzione; ogni istruzione appartenente ad una CPU viene eseguita in
un preciso numero di cicli macchina e ciò permette alla logica di controllo di
sincronizzare tutte le fasi necessarie per l'elaborazione dell'istruzione stessa.
Le diverse istruzioni che formano un programma, possono differire tra loro in termini di
complessità e questo significa che maggiore è la complessità di una istruzione, maggiore
sarà il numero di cicli macchina necessari per l'elaborazione; questa situazione viene
illustrata dalla Figura 10.8 che si riferisce al caso di una istruzione che viene elaborata
in 5 cicli macchina.
In linea di principio, l'elaborazione di una istruzione è costituita da due fasi distinte;
la fase di Fetch (caricare, prelevare) consiste nella ricerca in memoria e nel
successivo caricamento nel Registro Istruzioni, del codice macchina della prossima
istruzione da eseguire. La fase di Execute (esecuzione) consiste nella decodifica
dell'istruzione e nella sua elaborazione attraverso la EU della CPU.
Analizzando in dettaglio l'esempio di Figura 10.8, si vede che nel ciclo macchina (o fase)
T1 la CL compie tutte le azioni necessarie per richiedere alla memoria il
codice macchina della prossima istruzione da eseguire; nella fase T2 viene eseguita
la lettura che pone nel Registro Istruzioni il codice macchina dell'istruzione
stessa. A questo punto la fase di Fetch è terminata; da queste considerazioni si
può constatare che la fase di Fetch richiede sempre due cicli macchina.
La fase di Execute, invece, dipende dalla complessità dell'istruzione da decodificare
ed eseguire; nel caso illustrato in Figura 10.8, sono necessari tre cicli macchina indicati
con T3, T4, T5. Istruzioni molto complesse possono richiedere un
numero piuttosto elevato di cicli macchina.
10.6 Prefetch Queue, Cache Memory e Pipeline
Ogni istruzione di un programma, è rappresentata in memoria da un codice binario formato
da un certo numero di bit; decodificando questo codice, la CPU acquisisce tutte le
informazioni necessarie per eseguire l'istruzione stessa. Il problema che si presenta è
dato dal fatto che per codificare certe istruzioni sono sufficienti pochi bit, mentre per
altre istruzioni bisogna ricorrere a codici formati da decine di bit; la soluzione più
semplice a questo problema consiste nel rappresentare qualunque istruzione attraverso un
codice formato da un numero fisso di bit. Supponendo, ad esempio, di utilizzare una codifica
a 64 bit, l'esecuzione di un programma da parte della CPU viene notevolmente
semplificata; in questo caso, infatti, la CPU legge in sequenza dalla memoria blocchi
da 64 bit, ciascuno dei quali contiene il codice macchina di una singola istruzione.
Questo codice macchina viene poi decodificato ed eseguito; successivamente la CPU
passa al prossimo blocco da 64 bit e ripete i passi precedenti (decodifica ed
esecuzione).
I computer che utilizzano questa tecnica vengono indicati con la sigla RISC o
Reduced Instruction Set Computer (computer con set semplificato di istruzioni);
si tratta di un sistema che semplifica notevolmente il lavoro della CPU, ma
presenta l'evidente difetto di comportare un notevole spreco di memoria. Osserviamo, infatti,
che per codificare ogni istruzione vengono utilizzati in ogni caso 64 bit; questo
accade quindi anche per quelle istruzioni il cui codice macchina richiederebbe, invece,
pochissimi bit.
In contrapposizione ai computer RISC, sono nati i computer di tipo CISC o
Complex Instruction Set Computer (computer con set complesso di istruzioni); su
questi computer, si utilizza una tecnica più avanzata (e più complicata) attraverso
la quale il codice macchina specifica non solo il tipo di istruzione, ma anche (in modo
implicito) la dimensione in bit del codice stesso. Il vantaggio di questo metodo consiste
nel fatto che si ottiene una codifica estremamente compatta, riducendo al minimo lo spreco
di memoria; naturalmente, il sistema di decodifica, essendo più complesso, porta ad una
minore velocità di elaborazione.
Tutti i PC basati sulle CPU della famiglia 80x86 sono sostanzialmente
di tipo CISC; vediamo allora come avviene l'elaborazione delle istruzioni su questo
tipo di computer.
Per motivi di efficienza, i codici macchina che rappresentano le varie istruzioni di un
programma sono formati da un numero di bit che è sempre un multiplo di 8; possiamo
dire allora che il codice macchina di una qualsiasi istruzione occuperà in memoria uno o
più byte. La CPU carica il primo byte e dopo averlo decodificato è in grado di
sapere se quel byte rappresenta l'istruzione completa o se, invece, è seguito da altri byte;
nel primo caso la CPU passa alla fase di elaborazione dell'istruzione, mentre nel
secondo caso, la CPU provvede a caricare e decodificare gli altri byte che formano
l'istruzione. Ad esempio, come vedremo nei capitoli successivi, l'istruzione
Assembly:
ADD AX, Variabile1
significa: prendi il contenuto della locazione di memoria rappresentata dal nome
Variabile1 e sommalo al contenuto del registro accumulatore AX, mettendo poi
il risultato finale nell'accumulatore stesso.
L'operando di destra (Variabile1) si chiama operando sorgente, mentre quello
di sinistra (AX) si chiama operando destinazione.
Supponendo che la componente Offset dell'indirizzo in memoria di Variabile1
sia 0088h, vedremo in un apposito capitolo che il codice macchina di questa istruzione
(in esadecimale) sarà:
03 06 00 88
In binario:
00000011 00000110 00000000 10001000
La CPU carica il primo byte (03h) e dopo averlo decodificato capisce che
questo codice significa (come vedremo in seguito): esegui la somma tra due operandi a
16 bit, con l'operando destinazione che è un registro.
In base a queste informazioni, la CPU sa che il byte appena elaborato (03h) è
seguito sicuramente da un secondo byte (nel nostro caso 06h) che codifica una serie
di informazioni relative al nome del registro, al fatto che l'operando sorgente è una
locazione di memoria e alla modalità di accesso a questa locazione; nel nostro caso, il
codice 06h indica che il registro è AX e che l'accesso in memoria avviene
direttamente attraverso l'indirizzo (0088h) di Variabile1.
In base a queste altre informazioni, la CPU sa che dovrà caricare due ulteriori
byte (00h e 88h) che rappresentano la componente Offset dell'indirizzo
di Variabile1; a questo punto, la CPU ha tutte le informazioni necessarie per
elaborare l'istruzione. La CL predispone la ALU per l'esecuzione di una
addizione a 16 bit, dopo aver provveduto a far caricare in un registro temporaneo il
contenuto di AX e in un altro registro temporaneo il contenuto di Variabile1
(prelevato dalla memoria all'offset 0088h del Data Segment); la ALU
esegue la somma e il risultato finale viene messo in AX, mentre il registro
FLAGS conterrà tutte le informazioni sulla operazione appena eseguita.
10.6.1 La Prefetch Queue
Dalle considerazioni appena svolte, si capisce subito che nel caso dei computer
CISC, la codifica delle istruzioni con un numero variabile di byte crea chiaramente
un problema legato al fatto che la CPU non può conoscere in anticipo la dimensione
in bit del codice della prossima istruzione da eseguire; infatti, per avere questa
informazione la CPU è costretta a caricare e decodificare il primo byte
dell'istruzione e tutto ciò comporta un notevole aumento degli accessi in memoria con
gravi conseguenze sulle prestazioni.
Le vecchie CPU come, ad esempio, l'8080, aggiravano il problema caricando il
codice macchina delle varie istruzioni, 1 byte alla volta; questo sistema era
però estremamente inefficiente e ci si rese subito conto che se si volevano realizzare
CPU molto più veloci, bisognava trovare altre soluzioni.
Con la comparsa sul mercato delle prime CPU a 16 bit (8086 e 80186),
la Intel ha introdotto una prima soluzione a questo problema, ottenendo un notevole
incremento delle prestazioni; l'idea è molto semplice ma geniale allo stesso tempo e
consiste nello sfruttare i tempi morti dei bus di sistema (cioè dell'insieme formato dal
Data Bus, dal Control Bus e dall'Address Bus). Osserviamo, infatti, che
mentre, ad esempio, la EU è occupata nella fase di esecuzione di una istruzione, può
capitare che la BIU si trovi ad essere inoperosa, in attesa che la EU finisca
il proprio lavoro; sfruttando questi tempi morti, si può impiegare la BIU per
"pre-caricare" (prefetch) dalla memoria una nuova porzione di codice macchina che
viene sistemata in un apposito buffer della CPU. Non appena la EU è di nuovo
libera, la prossima istruzione (o parte di essa) è già pronta per essere elaborata, senza
la necessità di doverla caricare dalla memoria.
Il buffer nel quale si accumulano i vari byte di codice da elaborare ha una capienza adeguata
alle esigenze della CPU; nel caso, ad esempio, delle CPU 8086 e
80186, la sua dimensione è di 6 byte.
La struttura più adatta per realizzare questo particolare buffer è sicuramente la
cosiddetta coda (in inglese queue); la Figura 10.9 illustra le caratteristiche
di una generica "coda di attesa".
La coda è formata da una serie di locazioni destinate a contenere oggetti tutti della
stessa natura; si può citare come esempio una coda di persone che attendono il loro
turno davanti agli sportelli di un Ufficio Postale. La struttura a coda è dotata di
due imboccature che rappresentano l'Ingresso e l'Uscita; ovviamente, i
vari oggetti entrano in ordine dall'ingresso della coda e si presentano nello stesso
ordine all'uscita della coda stessa. Appare anche evidente il fatto che il primo oggetto
che esce dalla coda, coincide con il primo oggetto che era entrato nella coda stessa;
per questo motivo si dice che la coda è una struttura di tipo FIFO o First
In, First Out (il primo oggetto ad entrare è anche il primo ad uscire).
Ogni volta che un oggetto (che sta in testa) esce dalla coda di attesa, libera una
locazione permettendo così a tutti gli oggetti che lo precedevano, di avanzare di una
posizione; in questo modo si libera anche la locazione che sta in fondo alla coda
permettendo così ad un nuovo oggetto di entrare e cioè, di "accodarsi".
Nel caso delle CPU come la 8086, è presente come è stato già detto una
coda di attesa da 6 byte; ogni volta che la CPU estrae dei byte per la
decodifica, libera una serie di locazioni che verranno occupate dai byte precedenti.
I vari byte in coda avanzano quindi verso la testa, liberando a loro volta diverse
posizioni che si trovano in fondo; non appena può, la BIU provvede a caricare dalla
memoria altri byte di codice riempiendo i posti rimasti liberi alla fine della coda.
Appare chiaro che questo modo di procedere determina un notevole aumento delle prestazioni
generali della CPU; questo aumento di prestazioni è legato principalmente al
fatto che i vari accessi in memoria, pesantissimi in termini di tempo, vengono effettuati
nei tempi morti della BIU.
Nel caso delle CPU, la struttura di Figura 10.9 viene chiamata prefetch queue
(coda di pre-carica).
Per motivi di efficienza, la pre-carica del codice macchina dalla memoria, viene effettuata
dalle CPU a gruppi di byte; nel caso, ad esempio, della 8086, la BIU
attende che si liberino due posizioni alla fine della coda (2 byte), dopo di che
provvede alla pre-carica dalla memoria di altri due byte (una word) di codice. Tutto ciò
significa che nella prefetch queue della 8086, le informazioni da elaborare
vengono suddivise in gruppi di 2 byte; di conseguenza, ciascun gruppo risulta
allineato ad un indirizzo pari della stessa prefetch queue.
La dimensione dei blocchi di codice macchina pre-caricati varia da modello a modello di
CPU; questa dimensione prende il nome di allineamento di prefetch in quanto
definisce il tipo di allineamento dei blocchi pre-caricati nella prefetch queue (o
nella cache memory) della CPU. La Figura 10.10 mostra l'allineamento di prefetch
nei vari modelli di CPU.
Nel caso, ad esempio, della 80386 DX, il codice di un programma viene pre-caricato
a blocchi da 32 bit; ogni blocco pre-caricato si trova quindi allineato a indirizzi
multipli di 32 bit nella coda di attesa. Nel caso, invece, della 80486 DX, il
codice di un programma viene pre-caricato a blocchi da 16 byte; ogni blocco
pre-caricato si trova quindi allineato a indirizzi multipli di 16 byte nella coda
di attesa.
Dal punto di vista del programmatore tutto ciò significa che se si vuole "spremere" al
massimo il computer, bisognerà preoccuparsi non solo del corretto allineamento dei dati
nei propri programmi, ma anche del corretto allineamento del codice; questi concetti
verranno approfonditi nei capitoli successivi.
Un altro aspetto importante da considerare è dato dal fatto che la struttura della
prefetch queue presuppone che le istruzioni che formano un programma vengano
eseguite una di seguito all'altra, nello stesso ordine con il quale appaiono disposte in
memoria; in effetti questo è il caso più frequente, ma può presentarsi una situazione
particolare che determina un "intoppo". Può capitare, infatti, che ad un certo punto della
fase di esecuzione di un programma, si arrivi ad una istruzione che prevede un salto ad
un'altra zona della memoria relativamente distante dall'istruzione stessa (ad esempio, una
istruzione che chiama un sottoprogramma); in una situazione del genere il contenuto della
prefetch queue diventa del tutto inutile in quanto si riferisce ad istruzioni che
devono essere "saltate".
In questo caso, l'unica cosa che la CPU può fare consiste nello svuotare (to
flush) completamente la prefetch queue procedendo poi a riempirla con le
nuove istruzioni da eseguire; come si può facilmente intuire, questa situazione determina
un sensibile rallentamento del lavoro che la CPU sta eseguendo. Teoricamente, il
programmatore può eliminare questo problema scrivendo programmi privi di salti; è chiaro
però che in pratica sarebbe assurdo procedere in questo modo, anche perché si andrebbe
incontro ad una grave limitazione delle potenzialità dei programmi stessi.
Senza arrivare a soluzioni così estreme, è possibile ricorrere a determinati accorgimenti
che possono ridurre i disagi per la CPU; ad esempio, si può allineare opportunamente
in memoria l'indirizzo dell'istruzione alla quale si vuole saltare, in modo che la prefetch
queue possa essere ripristinata il più rapidamente possibile.
10.6.2 La Cache Memory
La prefetch queue rappresenta a suo modo una piccolissima memoria ad alta velocità
di accesso (SRAM) che permette alla CPU di ridurre notevolmente i tempi
necessari per il caricamento delle varie istruzioni da eseguire; questo aspetto suggerisce
l'idea di aumentare adeguatamente le dimensioni della prefetch queue in modo che sia
possibile caricare in essa una porzione consistente del programma in esecuzione, che verrebbe
quindi gestito dalla CPU con maggiore efficienza. Naturalmente però bisogna fare i
conti con il costo elevato della SRAM e con la sua scarsa densità di integrazione
rispetto alla DRAM; i progettisti dei computer, tenendo conto di questi aspetti
hanno raggiunto un compromesso rappresentato dalla cache memory.
Come abbiamo già visto, si tratta di una piccola memoria ad elevate prestazioni, in quanto
realizzata con flip-flop ad altissima velocità di risposta; la cache memory
per le sue ridotte dimensioni non può contenere in genere un intero programma, ma solo parte
di esso. Ovviamente, la domanda che ci poniamo è: quale parte del programma viene caricata
nella cache memory?
Per rispondere a questa domanda dobbiamo considerare i due aspetti fondamentali che
caratterizzano un programma in esecuzione:
- Nel caso più frequente (come abbiamo visto prima) le istruzioni di un programma
vengono eseguite nello stesso ordine con il quale appaiono disposte in memoria;
si parla a questo proposito di "caratteristica spaziale".
- Esistono porzioni di un programma (codice o dati) che statisticamente vengono
accedute più frequentemente di altre; si parla a questo proposito di
"caratteristica temporale".
La CPU sfrutta queste considerazioni, caricando nella cache memory le porzioni
di codice e dati aventi le caratteristiche appena descritte.
La cache memory si trova interposta tra la CPU e la memoria centrale; ogni
volta che la CPU deve caricare una nuova istruzione o nuovi dati del programma,
controlla prima di tutto se queste informazioni sono presenti nella cache memory. In
caso affermativo, la lettura avviene alla massima velocità possibile; se questa
eventualità si verifica, si parla in gergo di zero wait states. In caso negativo,
invece, bisognerà necessariamente accedere alla memoria centrale (DRAM), che essendo
più lenta della cache memory, comporterà l'inserimento di uno o più wait
states.
Se la CPU trova le informazioni desiderate nella cache, si dice in gergo che "la cache
è stata colpita" (cache hit), mentre in caso contrario si dice che "la cache è stata
mancata" (cache miss); in caso di cache miss l'unità di prefetch della
CPU provvede a liberare spazio nella cache, eliminando le informazioni statisticamente
meno richieste, riempiendo poi questo spazio con le nuove informazioni lette dalla memoria
centrale.
Mentre, ad esempio, la 8086 dispone di una prefetch queue con capienza di soli
6 byte, le attuali CPU dispongono di cache memory da alcune centinaia di
KiB; rispetto alla prefetch queue, l'enorme vantaggio della cache memory sta nel
fatto che in caso di cache miss, non è necessario svuotare tutta la cache, ma solo una
parte di essa, salvaguardando così tutte le altre informazioni memorizzate. La 80486,
ad esempio, in caso di cache miss legge dalla memoria centrale un blocco da 16
byte (1 paragrafo) contenente le nuove informazioni richieste; se nella cache non c'è
spazio, basterà eliminare solo 16 byte di informazioni non più necessarie.
Tutte le CPU, a partire dalla 80486, dispongono di cache memory interna
che, come già sappiamo, viene chiamata first level cache o L1 cache (cache di
primo livello); la cache interna può essere espansa attraverso una cache esterna detta
second level cache o L2 cache (cache di secondo livello).
Anche con la cache memory si possono verificare degli intoppi determinati non solo
dai salti di un programma, ma anche da altre situazioni che verranno analizzate nei successivi
capitoli.
10.6.3 La Pipeline
Un'altra importante innovazione che ha determinato un enorme incremento delle prestazioni
generali dei microprocessori, è rappresentata dalla cosiddetta pipeline, presente
su tutte le nuove CPU a partire dalla 80486; per capire il funzionamento
della pipeline, si può pensare a quello che accade, ad esempio, nella catena di
montaggio di una fabbrica di automobili. Supponiamo per semplicità che la fabbricazione
di una automobile si possa suddividere nelle 5 fasi seguenti:
- assemblaggio della carrozzeria
- verniciatura
- allestimento degli interni
- installazione del motore
- collaudo finale
Se tutte queste 5 fasi si svolgessero in un unico reparto della fabbrica, si
verificherebbe una notevole perdita di tempo causata dalla necessità di operare su una
sola auto per volta; in questo caso, se per realizzare ogni nuova macchina sono necessarie
10 ore di lavoro, possiamo dire che la fabbrica lavora al ritmo di una macchina
ogni 10 ore.
Questa situazione può essere notevolmente migliorata osservando che le 5 fasi di
lavorazione suggeriscono l'idea di predisporre 5 appositi reparti della fabbrica;
in questo modo è possibile operare istante per istante su 5 auto diverse. Non
appena la prima auto esce dal reparto assemblaggio per entrare nel reparto verniciatura,
il reparto assemblaggio si libera e può quindi ospitare la seconda auto; non appena la
prima auto esce dal reparto verniciatura per entrare nel reparto allestimento, il reparto
verniciatura si libera e può ospitare la seconda auto la quale a sua volta libera il
reparto assemblaggio che può quindi accogliere una terza auto e così via. Prevedendo
quindi tanti reparti quante sono le fasi di fabbricazione, si vede subito che la stessa
fabbrica può lavorare al ritmo di 5 macchine ogni 10 ore e cioè, 5
volte più del caso precedente!
Tutti questi concetti possono essere applicati al microprocessore osservando che (come
abbiamo visto in precedenza), anche l'esecuzione di una istruzione può essere suddivisa
in diverse fasi; nel caso delle vecchie CPU, l'esecuzione di una istruzione
comportava una lunghissima serie di cicli macchina necessari per la richiesta del codice
macchina alla memoria, per il caricamento del codice stesso, per la decodifica, per il
caricamento di eventuali operandi dalla memoria e così via.
L'aggiunta di un dispositivo hardware come la prefetch queue porta un notevole
miglioramento delle prestazioni in quanto permette alla CPU di risparmiare tempo;
lo scopo della coda di pre-carica, infatti, è quello di fare in modo che la CPU
trovi la prossima istruzione da eseguire, già caricata e pronta per la decodifica (salvo
complicazioni legate, ad esempio, alla precedente esecuzione di una istruzione di salto).
Con l'aggiunta di un altro dispositivo hardware come la cache memory, la situazione
migliora ulteriormente in quanto la CPU trova intere porzioni di un programma già
caricate e quindi eseguibili alla massima velocità possibile; grazie alla cache memory,
inoltre, è spesso possibile evitare intoppi come quelli determinati dalle istruzioni di
salto.
Dalle considerazioni appena svolte, nasce l'idea di aggiungere alla CPU un numero
adeguato di dispositivi hardware, ciascuno dei quali ha il compito di occuparsi di una ben
determinata fase della elaborazione di una istruzione; se si organizza allora la CPU
come una catena di montaggio per automobili, è possibile ridurre enormemente i tempi di
elaborazione grazie al fatto che si può operare su più istruzioni contemporaneamente,
purché ciascuna di queste istruzioni si trovi in una diversa fase (cioè in un diverso
reparto della catena di montaggio). L'insieme di questi dispositivi formano una cosiddetta
pipeline; la Figura 10.11 illustra in modo schematico il principio di funzionamento
di una generica pipeline.
Per chiarire lo schema di Figura 10.11, suddividiamo innanzi tutto la fase di elaborazione di
una istruzione nelle 5 fasi seguenti:
- Fase di "Fetch", che consiste nel caricare nel Registro Istruzioni
della CPU il codice macchina della prossima istruzione da eseguire.
- Fase di "Decodifica", che consiste nel raccogliere una serie di informazioni
riguardanti: il compito che l'istruzione richiede di svolgere alla CPU, la
presenza di eventuali operandi, etc.
- Fase di "Addressing" (indirizzamento), che consiste nel predisporre gli
indirizzi per caricare gli eventuali operandi.
- Fase di "Execute" che consiste nell'esecuzione vera e propria dell'istruzione
da parte della CPU.
- Fase di "Write Back" che consiste nel salvataggio del risultato nell'operando
destinazione (registro o locazione di memoria).
Naturalmente, la pipeline viene sincronizzata attraverso un apposito segnale
periodico visibile in Figura 10.11; la stessa Figura 10.11 mostra la situazione relativa
alla esecuzione delle prime 4 istruzioni di un programma.
Nel ciclo T1 l'istruzione n. 1 si trova nella fase di Fetch; nel ciclo
T2 l'istruzione n. 1 passa alla fase di Decodifica permettendo alla
pipeline di procedere con la fase di Fetch dell'istruzione n. 2. Nel
ciclo T3 l'istruzione n. 1 passa all'eventuale fase di Addressing,
l'istruzione n. 2 passa alla fase di Decodifica, mentre l'hardware che si
occupa della fase di Fetch può procedere con l'istruzione n. 3.
Nel caso illustrato dalla Figura 10.11 si parla di pipeline a 5 stadi; come si può
notare, in questo caso la CPU può operare istante per istante su più istruzioni
differenti. Più aumentano gli stadi della pipeline, maggiore sarà il numero di istruzioni
elaborate in contemporanea e minore sarà il tempo richiesto per ogni fase; in più bisogna
anche considerare un ulteriore aumento delle prestazioni dovuto al fatto che spesso alcune
delle fasi descritte prima non sono necessarie (caricamento degli operandi, write back).
Anche con la pipeline possono verificarsi degli imprevisti che determinano un certo
rallentamento delle operazioni. Supponiamo che nella Figura 10.11 l'istruzione n. 1
preveda nella fase di Write Back la scrittura in memoria del risultato finale, mentre
l'istruzione n. 3 preveda nella fase di Addressing il caricamento di un
operando da una locazione di memoria; come si può notare, si viene a creare un problema
in quanto entrambe le operazioni dovrebbero svolgersi nel ciclo T5. La CPU non
può effettuare contemporaneamente due accessi in memoria (uno in lettura e uno in scrittura)
e deve quindi "accontentare" una sola delle due istruzioni; in un caso del genere, la
pipeline non può fare altro che eseguire nel ciclo T5 la fase di Write
Back per l'istruzione n. 1, rimandando la fase di Addressing per
l'istruzione n. 3 al ciclo T6. La fase di Addressing per l'istruzione
n. 3 richiede quindi non 1 ma 2 cicli del segnale di Figura 10.11;
questa situazione rappresenta una cosiddetta fase di "stallo" della pipeline
(pipeline stall).
Le CPU di classe 80586 e superiori, utilizzano due o più pipeline
attraverso le quali possono eseguire in alcuni casi più istruzioni in contemporanea;
questa situazione si verifica, ad esempio, quando la CPU incontra due istruzioni
consecutive, indipendenti l'una dall'altra e tali da non provocare stalli nella
pipeline. Se si verificano queste condizioni, è possibile eseguire le due
istruzioni in parallelo; le CPU capaci di eseguire più istruzioni in parallelo
vengono definite superscalari.
Una potentissima caratteristica delle CPU superscalari è data dalla possibilità
di poter "indovinare" la direzione più probabile, lungo la quale proseguirà l'esecuzione
di un programma; in sostanza, in presenza di una istruzione di salto "condizionato", la
CPU può tentare di indovinare se il salto verrà effettuato o meno. Questa
caratteristica viene definita branch prediction e permette in moltissimi casi di
evitare lo svuotamento della pipeline in presenza di una istruzione di salto.
In pratica, per alimentare ciascuna pipeline vengono utilizzate due code di prefetch; la
prima viene riempita nel solito modo (leggendo le istruzioni dalla memoria, una di seguito
all'altra), mentre la seconda viene, invece, riempita in base alle "previsioni" fatte da un
apposito algoritmo di branch prediction. Se nella prima coda viene incontrata una
istruzione di salto e se l'algoritmo di branch prediction ha azzeccato le previsioni,
allora la seconda coda conterrà le nuove istruzioni che ci servono; in questo modo, si
evita lo svuotamento delle pipeline e l'esecuzione non subisce alcun rallentamento.
I programmatori che volessero approfondire questi concetti, possono consultare la
documentazione tecnica messa a disposizione dai vari produttori di CPU (vedere a
tale proposito la sezione Siti con argomenti correlati).