Assembly Base con NASM

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 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: 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: 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: 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: 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: 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: 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: 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; 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: 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: 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: 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).