Assembly Base con MASM
Capitolo 20: Istruzioni per il trasferimento del controllo
Le CPU che equipaggiavano i primi calcolatori elettronici, erano dotate
di un set di istruzioni molto limitato, che permetteva l'elaborazione dei
programmi, esclusivamente in modo sequenziale; tale tipo di elaborazione
consiste nel fatto che le varie istruzioni che compongono un programma, vengono
eseguite ordinatamente, una di seguito all'altra, senza la possibilità di
saltare da un punto all'altro del codice.
L'elaborazione sequenziale di un programma si svolge in modo estremamente semplice
e presenta quindi il vantaggio di garantire la massima velocità di esecuzione
possibile delle istruzioni; ciò è vero soprattutto per le CPU dotate di
prefetch queue (coda di pre-carica). Infatti, in un precedente capitolo
abbiamo visto che (semplificando al massimo), mentre la CPU sta
elaborando una determinata istruzione (che possiamo chiamare I1), la
logica di controllo (CL) provvede a pre-caricare l'istruzione successiva
(che possiamo chiamare I2); quando la CPU ha finito di elaborare
l'istruzione I1, trova già pronta l'istruzione I2, mentre la
CL, a sua volta, provvede a pre-caricare l'istruzione successiva (che
possiamo chiamare I3).
Lo svantaggio evidente della elaborazione sequenziale di un programma è
rappresentato, invece, dalla totale assenza di flessibilità; dovendo scrivere,
ad esempio, un programma sequenziale che deve ripetere per dieci volte uno stesso
calcolo, siamo costretti a riscrivere per dieci volte consecutive, una stessa
sequenza di istruzioni. Questo stile di programmazione (l'unico possibile con le
vecchie CPU), viene appunto definito sequenziale; gli esempi
presentati nei precedenti capitoli, per le istruzioni aritmetiche e logiche,
seguono proprio lo stile di programmazione sequenziale.
Anziché riscrivere per dieci volte consecutive una stessa sequenza di istruzioni,
appare più logico scriverla una sola volta, con la possibilità di rieseguire tale
sequenza per il numero di volte necessario; questo differente stile di
programmazione viene definito strutturato, in quanto permette di assegnare
ad un programma, una struttura ben precisa attraverso la quale è possibile
alterare il classico ordine sequenziale con cui vengono elaborate le varie
istruzioni.
Tutte le CPU della famiglia 80x86, rendono disponibili una serie
di istruzioni che permettono di programmare in stile strutturato attraverso il
cosiddetto trasferimento del controllo; questa definizione è legata al
fatto che sfruttando tali istruzioni, da un qualsiasi punto di un programma è
possibile trasferire il controllo (cioè, saltare) ad un qualsiasi altro indirizzo
di memoria!
Per capire l'importanza di questo aspetto, basti pensare al fatto che grazie alle
istruzioni per il trasferimento del controllo, possiamo facilmente implementare
costrutti fondamentali come i sottoprogrammi (procedure), le
iterazioni (loop) e i salti (jumps) incondizionati o
condizionati dal risultato di una operazione appena eseguita; questo discorso vale,
non solo per l'Assembly, ma anche per i linguaggi di alto livello.
Consideriamo, ad esempio, la porzione di codice in linguaggio C illustrata
in Figura 20.1:
Questo codice "intelligente" è in grado di compiere tre scelte distinte in
base al risultato di una comparazione aritmetica (CMP) tra i due dati
numerici var1 e var2; in particolare:
- Se var1 è maggiore di var2, viene chiamato il sottoprogramma
func1() (nel linguaggio C, i sottoprogrammi vengono definiti
funzioni); al termine di func1(), il controllo passa direttamente
all'istruzione printf.
- Se, invece, var1 è minore di var2, viene chiamata la funzione
func2(); al termine di func2(), il controllo passa direttamente
all'istruzione printf.
- Altrimenti (var1 uguale a var2), viene chiamata la funzione
func3(); al termine di func3(), il controllo passa direttamente
all'istruzione printf.
Per implementare il "blocco condizionale" if - else if - else di Figura 20.1,
il compilatore C sfrutta proprio (oltre a CMP) le istruzioni per il
trasferimento del controllo fornite dalla CPU; lo stesso discorso vale per
le iterazioni (loop), come quella mostrata in Figura 20.2.
Il loop di Figura 20.2 viene controllato dalla variabile contatore i e
consiste nell'eseguire per 10 volte consecutive, le due istruzioni
comprese tra parentesi graffe; infatti, osserviamo che i parte dal
valore 0 (inizializzazione) e viene incrementata di 1 ad ogni
ciclo (incremento del contatore), sino a raggiungere il valore 10
(condizione di uscita dal loop).
Un programma che fa un uso massiccio di sottoprogrammi, blocchi condizionali
e loop, può assumere una struttura piuttosto contorta, che porta ad una
inevitabile diminuzione delle prestazioni in termini di velocità di esecuzione;
questo problema diventa particolarmente evidente nel caso CPU dotate di
prefetch queue. Abbiamo appena visto, infatti, che mentre la CPU sta
elaborando una determinata istruzione I1, la CL provvede a
pre-caricare l'istruzione successiva I2; se, però, I1 è una
istruzione che prevede un salto, tutto il lavoro svolto dalla CL per
pre-caricare l'istruzione I2, si rivela totalmente inutile!
In un caso del genere, la CL provvede a svuotare la prefetch queue e a
ricaricarla con l'istruzione che si trova all'indirizzo di destinazione del salto;
come si può facilmente immaginare, tutta la fase di svuotamento e ricarica della
prefetch queue, comporta un serio rallentamento nella fase di esecuzione di un
programma. Si tratta di un problema talmente grave, da aver indotto i progettisti
delle CPU ad escogitare svariate soluzioni come, la cache memory,
la doppia pipeline, etc; questi aspetti sono stati già esaminati nel
Capitolo 10.
In sostanza, se vogliamo spingere al massimo la velocità dei nostri programmi,
dobbiamo puntare il più possibile sullo stile di programmazione sequenziale; se,
invece, vogliamo scrivere programmi compatti e flessibili, destinati ad eseguire
compiti molto complessi, dobbiamo ricorrere necessariamente ad uno stile di
programmazione strutturato. È chiaro che se si riesce a trovare un giusto
equilibrio tra queste due esigenze, è possibile scrivere programmi che, pur
essendo di tipo strutturato, vengono eseguiti molto velocemente dalla CPU;
per raggiungere un tale obbiettivo, è necessaria una conoscenza approfondita
delle varie istruzioni per il trasferimento del controllo.
20.1 Nozioni generali sulle istruzioni di salto
Appare intuitivo il fatto che per poter trasferire il controllo (saltare) da
una "origine" ad una "destinazione", la CPU deve caricare nella coppia
CS:IP, l'indirizzo logico Seg:Offset della stessa "destinazione";
in questo modo, la prossima istruzione che verrà eseguita sarà proprio quella
che si trova all'indirizzo di destinazione del salto.
Nel caso generale, il trasferimento del controllo può svolgersi secondo quattro
modalità distinte che vengono illustrate in dettaglio nel seguito; come al
solito, tutte le considerazioni esposte in questo capitolo e nei capitoli
successivi, sono riferite, esclusivamente, alla modalità di indirizzamento
reale (e a segmenti di programma con attributo USE16).
20.1.1 Salto diretto intrasegmento
Si ha il salto intrasegmento quando l'indirizzo di origine e l'indirizzo
di destinazione del salto, si trovano all'interno dello stesso segmento di
programma; questa situazione si verifica, ad esempio, quando vogliamo saltare
dall'offset 03BFh all'offset 083Dh di uno stesso segmento di
programma chiamato CODESEGM.
Per poter effettuare un salto intrasegmento, la CPU ha la necessità di
modificare solamente il contenuto di IP, lasciando invariato il contenuto
di CS; infatti, l'indirizzo logico di origine e l'indirizzo logico di
destinazione, hanno la stessa componente Seg.
In sostanza, l'indirizzo di destinazione di un salto intrasegmento può essere
specificato attraverso la sola componente Offset (la componente
Seg si trova, implicitamente, in CS), ed è quindi un indirizzo di
tipo NEAR (vicino); proprio per questo motivo, il salto intrasegmento
viene anche definito NEAR jump (salto vicino).
Il salto intrasegmento viene definito diretto, quando specifichiamo
direttamente l'indirizzo a cui la CPU deve saltare; il metodo più ovvio
per indicare direttamente l'indirizzo di destinazione del salto, consiste
nel servirsi di una etichetta (label).
Nel codice macchina di un salto diretto intrasegmento, l'assembler inserisce
un valore relativo che rappresenta la distanza in byte tra l'offset di
destinazione e l'offset successivo a quello dell'istruzione che origina il
salto; nel seguito del capitolo e nei capitoli successivi, tale valore
relativo verrà indicato simbolicamente con Rel.
Il valore Rel viene codificato come numero intero con segno a 16
bit in complemento a 2; per sottolineare questo aspetto, si utilizza
il simbolo Rel16.
Come esempio pratico, supponiamo di voler saltare ad una etichetta
dest_label che si trova all'offset 0CF2h di un segmento di codice
chiamato CODESEGM; l'istruzione che origina il salto si trova all'offset
03B8h di CODESEGM e ha un codice macchina da 2 byte, per cui
l'offset successivo è 03BAh.
Per ricavare la distanza in byte che esiste tra dest_label e l'offset
03BAh, l'assembler calcola:
0CF2h - 03BAh = 0938h
Nel codice macchina dell'istruzione di salto, l'assembler inserisce quindi un
Rel16 pari a 0938h.
Osserviamo subito che, nel momento in cui la CPU incontra l'istruzione
di salto, abbiamo CS=CODESEGM; dopo aver decodificato il codice macchina
di questa istruzione, la CPU calcola:
0938h + 03BAh = 0CF2h
Successivamente, la CPU carica in IP il valore 0CF2h e
salta a CS:IP (cioè, a CODESEGM:0CF2h), che è proprio l'indirizzo
logico dell'etichetta dest_label; come si può notare, il contenuto
(CODESEGM) di CS rimane invariato in quanto stiamo saltando da
CODESEGM:03B8h a CODESEGM:0CF2h.
Supponiamo, invece, che l'etichetta dest_label si trovi all'offset
01CDh di un segmento di codice chiamato CODESEGM; l'istruzione
che origina il salto si trova all'offset 0DA0h di CODESEGM e ha
un codice macchina da 2 byte, per cui l'offset successivo è 0DA2h.
Questa volta, notiamo che il salto si svolge all'indietro in quanto
dest_label precede l'istruzione che origina il salto!
Per ricavare la distanza in byte che esiste tra dest_label e l'offset
0DA2h, l'assembler calcola:
01CDh - 0DA2h = 461 - 3490 = -3029 = 216 - 3029 = 62507 = F42Bh
Nel codice macchina dell'istruzione di salto, l'assembler inserisce quindi un
Rel16 pari a F42Bh.
Osserviamo subito che, nel momento in cui la CPU incontra l'istruzione
di salto, abbiamo CS=CODESEGM; dopo aver decodificato il codice macchina
di questa istruzione, la CPU calcola:
F42Bh + 0DA2h = 01CDh
Successivamente, la CPU carica in IP il valore 01CDh e
salta a CS:IP (cioè, a CODESEGM:01CDh), che è proprio l'indirizzo
logico dell'etichetta dest_label; come si può notare, il contenuto
(CODESEGM) di CS rimane invariato in quanto stiamo saltando da
CODESEGM:0DA0h a CODESEGM:01CDh.
Può capitare che in un NEAR jump, la distanza tra l'offset di destinazione
e l'offset successivo a quello dell'istruzione che origina il salto, sia compresa
tra -128 e +127 byte; in un caso del genere, il NEAR jump
viene anche definito SHORT jump (salto corto).
Quando si verifica questa condizione, il valore Rel relativo allo
SHORT jump può essere rappresentato con soli 8 bit; per indicare
un Rel a 8 bit viene utilizzato il simbolo Rel8. Come si può
facilmente intuire, gli SHORT jumps (quando sono possibili) permettono di
ottenere un codice macchina più compatto; infatti, un Rel16 occupa 2
byte, mentre un Rel8 occupa 1 solo byte.
20.1.2 Salto indiretto intrasegmento
Il salto intrasegmento viene definito indiretto quando specifichiamo
l'offset di destinazione, indirettamente, attraverso un operando di tipo
Reg16 o Mem16; questo è l'aspetto fondamentale che differenzia
il salto diretto intrasegmento dal salto indiretto intrasegmento.
Un eventuale operando di tipo Reg16, non deve essere necessariamente un
registro puntatore; infatti, lo scopo del Reg16 è solamente quello di
contenere una componente Offset a 16 bit.
Per capire il meccanismo su cui si basa il salto indiretto intrasegmento,
supponiamo di voler saltare ad una etichetta dest_label specificando il
suo indirizzo (ad esempio, 0FA8h), indirettamente, attraverso il registro
generale AX; in un caso del genere, dobbiamo scrivere innanzi tutto:
mov ax, offset dest_label
Questa istruzione carica in AX il valore 0FA8h; a questo punto,
possiamo chiedere alla CPU di "saltare ad AX"!
Quando la CPU incontra una tale istruzione, legge il contenuto
0FA8h di AX, lo carica direttamente in IP e salta
all'indirizzo logico CS:IP; come al solito, il contenuto di CS
rimane invariato in quanto il salto è intrasegmento.
Come è stato già detto, al posto di un Reg16 possiamo anche utilizzare
un Mem16; in riferimento al precedente esempio, dopo aver definito
una WORD chiamata dest_addr, possiamo scrivere l'istruzione:
mov dest_addr, offset dest_label
Questa istruzione carica in dest_addr il valore 0FA8h; a questo
punto, possiamo chiedere alla CPU di "saltare a dest_addr"!
Anche in questo caso, la CPU legge il contenuto 0FA8h di
dest_addr, lo carica direttamente in IP e salta all'indirizzo
logico CS:IP.
Il salto indiretto intrasegmento viene sempre trattato come un NEAR jump
generico, anche quando l'offset di destinazione si trova ad una distanza compresa
tra -128 e +127 byte dall'offset successivo a quello dell'istruzione
che origina il salto; non avrebbe quindi alcun senso parlare di SHORT jump
indiretto intrasegmento, in quanto tale concetto è legato, esclusivamente, al
caso di un salto diretto intrasegmento esprimibile con un Rel8!
20.1.3 Salto diretto intersegmento
Si ha il salto intersegmento quando l'indirizzo di origine del salto si
trova in un segmento di programma differente da quello che contiene l'indirizzo
di destinazione; questa situazione si verifica, ad esempio, quando vogliamo
saltare dall'offset 03BFh di un segmento di programma chiamato
CODESEGM, all'offset 083Dh di un segmento di programma chiamato
LIBCODE.
Per poter effettuare un salto intersegmento, la CPU ha la necessità di
modificare, sia il contenuto di CS, sia il contenuto di IP;
infatti, l'indirizzo logico di origine e l'indirizzo logico di destinazione,
hanno componenti Seg differenti.
In sostanza, l'indirizzo di destinazione di un salto intersegmento deve essere
necessariamente specificato attraverso una coppia completa Seg:Offset,
ed è quindi un indirizzo di tipo FAR (lontano); proprio per questo
motivo, il salto intersegmento viene anche definito FAR jump (salto
lontano).
Il salto intersegmento viene definito diretto, quando specifichiamo
direttamente l'indirizzo a cui la CPU deve saltare; come al solito, il
metodo più ovvio per indicare direttamente l'indirizzo di destinazione del salto,
consiste nel servirsi di una etichetta (label).
Nel codice macchina di un salto diretto intersegmento, l'assembler inserisce
sempre un valore a 32 bit che rappresenta l'indirizzo logico completo
Seg:Offset della destinazione del salto; nel rispetto della convenzione
enunciata nel Capitolo 16.8 (disposizione in memoria degli indirizzi FAR),
la componente Offset precede sempre la componente Seg. Nei manuali
tecnici delle CPU (e anche nel seguito del capitolo), un generico indirizzo
FAR, destinazione di un salto diretto intersegmento, viene indicato con il
simbolo Ptr16:Ptr16; il simbolo Ptr sta per pointer
(puntatore).
Come esempio pratico, consideriamo una istruzione di salto diretto intersegmento,
che si trova all'offset 03BFh di un segmento di programma chiamato
CODESEGM; il salto da effettuare consiste in un FAR jump ad una
etichetta, dest_label, che si trova all'offset 083Dh di un segmento
di programma chiamato LIBCODE.
Il valore Ptr16:Ptr16 ricavato dall'assembler, vale quindi
LIBCODE:083Dh; a questo punto, possiamo chiedere alla CPU di
"saltare a dest_label"!
Osserviamo subito che, nel momento in cui la CPU incontra l'istruzione
di salto, abbiamo CS=CODESEGM; dopo aver decodificato il codice macchina
di questa istruzione, la CPU pone CS=LIBCODE, IP=083Dh e
salta all'indirizzo CS:IP (cioè, a LIBCODE:083Dh). Come si può
notare, questa volta la CPU è costretta a modificare anche CS;
infatti, stiamo saltando da CODESEGM:03BFh a LIBCODE:083Dh.
20.1.4 Salto indiretto intersegmento
Il salto intersegmento viene definito indiretto, quando specifichiamo
l'indirizzo completo Seg:Offset di destinazione, indirettamente, attraverso
un operando di tipo Mem16:Mem16; questo è l'unico aspetto che differenzia
il salto diretto intersegmento dal salto indiretto intersegmento.
La locazione di memoria Mem16:Mem16 a 32 bit, deve contenere
quindi un indirizzo logico completo Seg:Offset; nel rispetto della
convenzione enunciata nel Capitolo 16.8 (disposizione in memoria degli indirizzi
FAR), la componente Offset deve trovarsi, rigorosamente, nella
WORD meno significativa dell'operando a 32 bit.
È proibito l'uso di una coppia Reg16:Reg16!
Riprendiamo il precedente esempio che si riferiva ad un salto diretto
intersegmento da CODESEGM:03BFh ad una etichetta dest_label che
si trova all'indirizzo LIBCODE:083Dh; vediamo ora come dobbiamo procedere
se vogliamo effettuare un salto indiretto intersegmento.
Prima di tutto, definiamo una DWORD chiamata dest_addr;
successivamente, scriviamo le seguenti istruzioni:
A questo punto possiamo chiedere alla CPU di "saltare a dest_addr"!
Osserviamo subito che, nel momento in cui la CPU incontra l'istruzione
di salto, abbiamo CS=CODESEGM. Dopo aver decodificato il codice macchina
di questa istruzione, la CPU legge il contenuto (083Dh) della
WORD bassa di dest_addr e lo copia in IP; successivamente,
la CPU legge il contenuto (LIBCODE) della WORD alta di
dest_addr e lo copia in CS. Infine, la CPU salta a
CS:IP (cioè, a LIBCODE:083Dh).
Si noti che se non rispettiamo la convenzione sulla disposizione in memoria
degli indirizzi FAR, la componente 083Dh viene copiata in CS,
mentre la componente LIBCODE viene copiata in IP; in un caso del
genere, la CPU salta ad un indirizzo senza senso e il programma va in crash!
20.2 Gli operatori di distanza
Nei limiti del possibile, l'assembler cerca di interpretare correttamente
tutte le istruzioni di salto da convertire in codice macchina; in teoria,
l'assembler è quindi in grado di distinguere in modo autonomo, tra SHORT
jump, NEAR jump e FAR jump.
In presenza, ad esempio, di un salto diretto intrasegmento con distanza
compresa tra -128 e +127 byte, l'assembler genera il codice
macchina di uno SHORT jump; se, invece, la distanza è maggiore,
l'assembler genera il codice macchina di un NEAR jump.
In ogni caso, le considerazioni esposte in precedenza sui vari tipi di salto,
possono determinare situazioni equivoche, tali da trarre in inganno
l'assembler; ciò è vero, in particolare, per il salto di tipo diretto.
Nel caso del salto di tipo indiretto, l'assembler non incontra alcuna
difficoltà in quanto stiamo specificando, in modo esplicito, l'ampiezza in
bit dell'indirizzo di destinazione del salto; infatti, un operando di tipo
Reg16 o Mem16 indica esplicitamente un salto NEAR
indiretto, mentre un operando di tipo Mem16:Mem16 indica esplicitamente
un salto FAR indiretto. In caso di necessità, possiamo anche servirci
degli operatori WORD PTR e DWORD PTR; come sappiamo, l'operatore
WORD PTR indica che il suo operando ha una ampiezza di 16 bit,
mentre l'operatore DWORD PTR indica che il suo operando ha una ampiezza
di 32 bit.
Nel caso del salto di tipo diretto, la situazione è più delicata; infatti,
in mancanza di diverse indicazioni da parte del programmatore, gli assembler
come MASM assumono implicitamente che i salti di tipo diretto siano
intrasegmento. Se, invece, vogliamo effettuare un salto diretto intersegmento,
dobbiamo indicarlo in modo esplicito all'assembler; in caso contrario, viene
generato un messaggio di errore!
Per indicare in modo esplicito il tipo di salto diretto che vogliamo effettuare,
vengono resi disponibili i cosiddetti distance operators (operatori di
distanza), illustrati in Figura 20.3; è importante ribadire che questi operatori
devono essere impiegati, esclusivamente, con i salti di tipo diretto.
Nel caso di un salto diretto intrasegmento, dest è una label
definita all'interno dello stesso segmento di programma che contiene
l'istruzione di salto; come è stato detto in precedenza, MASM
gestisce correttamente questa situazione ed è anche in grado di generare
il codice macchina più opportuno (SHORT jump o NEAR jump).
Se conosciamo con certezza la distanza del salto diretto intrasegmento,
possiamo anche servirci in modo esplicito degli operatori SHORT o
NEAR PTR.
Nel caso di un salto diretto intersegmento, dest è una label
definita in un segmento di programma differente da quello che contiene
l'istruzione di salto; come è stato detto in precedenza, in una situazione
di questo genere, gli assembler come MASM richiedono che il
programmatore indichi esplicitamente la presenza di un FAR jump.
Ovviamente, per indicare all'assembler che vogliamo effettuare un salto
diretto intersegmento, dobbiamo servirci dell'operatore FAR PTR.
Nel seguito del capitolo, verranno presentati numerosi esempi che chiariranno
tutti questi aspetti.
20.3 L'istruzione JMP
Con il mnemonico JMP si indica l'istruzione Unconditional Jump
(salto incondizionato); lo scopo di questa istruzione è quello di costringere la
CPU a saltare, senza condizioni, ad un determinato indirizzo di memoria
Seg:Offset specificato dal programmatore attraverso un apposito operando
esplicito (DEST).
In modalità reale, le uniche forme lecite per l'istruzione JMP sono le
seguenti:
Come si può notare, sono permessi tutti i tipi di salto descritti nella sezione
20.1; i codici macchina relativi ai vari casi, sono i seguenti:
Il programma JMP.ASM di Figura 20.4, illustra l'uso pratico dell'istruzione
JMP.
Nei commenti che accompagnano il listato di Figura 20.4, notiamo la presenza
degli offset e dei codici macchina delle varie istruzioni; il simbolo (r)
sta per relocatable (componente Seg o Offset rilocabile),
mentre il simbolo (e) sta per extern (identificatore definito in
un modulo esterno).
Avendo a disposizione queste informazioni, possiamo seguire, istante per istante,
le varie posizioni che vengono assunte dall'instruction pointer durante
la fase di elaborazione del programma; tutti gli aspetti relativi al
funzionamento dell'istruzione JMP valgono, in generale, anche per le altre
istruzioni di salto, per cui il listato di Figura 20.4 verrà analizzato in estremo
dettaglio.
Innanzi tutto, il programma stampa sullo schermo i valori assegnati dal SO
a CODESEGM e LIBCODE; in questo modo, abbiamo la possibilità di
conoscere il segmento di destinazione di ciascun salto. Come sappiamo, il valore
che il SO assegna all'identificatore di un segmento di programma,
rappresenta il segmento di memoria all'interno del quale è stato inserito lo
stesso segmento di programma; se, ad esempio, il SO inserisce
CODESEGM all'interno del segmento di memoria n. 16C8h, allora
avremo CODESEGM=16C8h.
Partiamo dall'offset 003Dh di CODESEGM, dove notiamo la presenza di
un JMP diretto intrasegmento verso l'offset 0069h dell'etichetta
dest_label1; mentre la CPU elabora questa istruzione, IP è
stato aggiornato all'offset 003Fh, per cui la distanza del salto è pari
a:
0069h - 003Fh = 002Ah = +42
Si tratta quindi di uno SHORT jump il cui Rel8 vale +42;
tale valore può essere memorizzato in soli 8 bit (2Ah) e, infatti,
l'assembler genera il codice macchina:
EBh 2Ah
Se abbiamo la certezza che il salto è SHORT, possiamo anche scrivere,
esplicitamente:
jmp short dest_label1
In presenza del precedente codice macchina, la CPU calcola:
2Ah + 3Fh = 69h
Il valore 69h viene caricato in IP e viene effettuato un salto
a CS:IP (cioè, CODESEGM:0069h); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta dest_label1!
Per dimostrare che il salto si svolge all'interno di CODESEGM, le
istruzioni successive a dest_label1 visualizzano, oltre alla stringa
strMsg1, gli indirizzi logici Seg:Offset dell'origine
(orig_label1) e della destinazione del salto (dest_label1); in
questo modo, possiamo constatare che per entrambi gli indirizzi, la
componente Seg vale sempre CODESEGM.
Dall'indirizzo CODESEGM:00A0h viene effettuato un salto diretto
intrasegmento verso l'offset 003Fh dell'etichetta return_label1;
mentre la CPU elabora questa istruzione, IP è stato aggiornato
all'offset 00A2h, per cui la distanza del salto è pari a:
003Fh - 00A2h = 63 - 162 = -99 = 28 - 99 = 157 = 9Dh
Si tratta quindi di uno SHORT jump il cui Rel8 vale -99;
tale valore può essere memorizzato in soli 8 bit (9Dh) e, infatti,
l'assembler genera il codice macchina:
EBh 9Dh
In presenza del precedente codice macchina, la CPU calcola:
9Dh + A2h = 3Fh
Il valore 3Fh viene caricato in IP e viene effettuato un salto
a CS:IP (cioè, CODESEGM:003Fh); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta return_label1!
L'istruzione successiva a return_label1 consiste in un JMP
diretto intrasegmento dall'offset 003Fh di CODESEGM, all'offset
0200h dell'etichetta dest_label2; mentre la CPU elabora
questa istruzione, IP è stato aggiornato all'offset 0042h, per
cui la distanza del salto è pari a:
0200h - 0042h = 01BEh = +446
Si tratta quindi di un NEAR jump il cui Rel16 vale +446;
tale valore richiede almeno 16 bit (01BEh) e, infatti, l'assembler
genera il codice macchina:
E9h 01BEh
Per fare in modo che la distanza del salto diretto intrasegmento superi i limiti
di -128 e +127 byte, è stata utilizzata la direttiva ORG che
sposta il location counter a CODESEGM:0200h; l'istruzione successiva a
ORG parte quindi dall'offset 0200h (512) di CODESEGM.
Se abbiamo la certezza che il salto è NEAR, possiamo anche scrivere,
esplicitamente:
jmp near dest_label2
In presenza del precedente codice macchina, la CPU calcola:
01BEh + 0042h = 0200h
Il valore 0200h viene caricato in IP e viene effettuato un salto
a CS:IP (cioè, CODESEGM:0200h); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta dest_label2!
Per dimostrare che il salto si svolge all'interno di CODESEGM, le
istruzioni successive a dest_label2 visualizzano, oltre alla stringa
strMsg2, gli indirizzi logici Seg:Offset dell'origine
(orig_label2) e della destinazione del salto (dest_label2); in
questo modo, possiamo constatare che per entrambi gli indirizzi, la
componente Seg vale sempre CODESEGM.
Dall'indirizzo CODESEGM:0237h viene effettuato un salto diretto
intrasegmento verso l'offset 0042h dell'etichetta return_label2;
mentre la CPU elabora questa istruzione, IP è stato aggiornato
all'offset 023Ah, per cui la distanza del salto è pari a:
0042h - 023Ah = 66 - 570 = -504 = 216 - 504 = 65032 = FE08h
Si tratta quindi di un NEAR jump il cui Rel16 vale -504;
tale valore richiede almeno 16 bit (FE08h) e, infatti, l'assembler
genera il codice macchina:
E9h FE08h
In presenza del precedente codice macchina, la CPU calcola:
FE08h + 023Ah = 0042h
Il valore 0042h viene caricato in IP e viene effettuato un salto
a CS:IP (cioè, CODESEGM:0042h); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta return_label2!
L'istruzione successiva a return_label2 consiste in un JMP
indiretto intrasegmento dall'offset 0045h di CODESEGM,
all'offset 023Ah dell'etichetta dest_label3; per rappresentare,
indirettamente, l'offset 023Ah di dest_label3, viene utilizzato
il registro CX.
Per il campo mod_100_r/m abbiamo mod=11b, r/m=001b
(registro CX) e quindi:
mod_100_r/m = 11100001b = E1h
Il codice macchina generato dall'assembler è, infatti:
FFh E1h
(la presenza dell'operando CX rende esplicito il fatto che il salto
è indiretto intrasegmento).
In presenza di questo codice macchina, la CPU legge il valore
023Ah da CX, lo carica direttamente in IP e salta a
CS:IP (cioè, CODESEGM:023Ah); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta dest_label3!
Per dimostrare che il salto si svolge all'interno di CODESEGM, le
istruzioni successive a dest_label3 visualizzano, oltre alla stringa
strMsg3, gli indirizzi logici Seg:Offset dell'origine
(orig_label3) e della destinazione del salto (dest_label3); in
questo modo, possiamo constatare che per entrambi gli indirizzi, la
componente Seg vale sempre CODESEGM.
Dall'indirizzo CODESEGM:0271h viene effettuato un salto diretto
intrasegmento verso l'offset 0047h dell'etichetta return_label3;
mentre la CPU elabora questa istruzione, IP è stato aggiornato
all'offset 0274h, per cui la distanza del salto è pari a:
0047h - 0274h = 71 - 628 = -557 = 216 - 557 = 64979 = FDD3h
Si tratta quindi di un NEAR jump il cui Rel16 vale -557;
tale valore richiede almeno 16 bit (FDD3h) e, infatti, l'assembler
genera il codice macchina:
E9h FDD3h
In presenza del precedente codice macchina, la CPU calcola:
FDD3h + 0274h = 0047h
Il valore 0047h viene caricato in IP e viene effettuato un salto
a CS:IP (cioè, CODESEGM:0047h); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta return_label3!
L'istruzione successiva a return_label3 consiste in un JMP
indiretto intrasegmento dall'offset 004Dh di CODESEGM,
all'offset 0274h dell'etichetta dest_label4; per rappresentare,
indirettamente, l'offset 0274h di dest_label4, viene utilizzato
il dato label_addr16 di tipo WORD.
Per il campo mod_100_r/m abbiamo mod=00b, r/m=110b
(operando di tipo Disp) e quindi:
mod_100_r/m = 00100110b = 26h
L'offset di label_addr16 è 0000h, per cui l'assembler genera
il codice macchina:
FFh 26h 0000h
Se vogliamo enfatizzare il fatto che label_addr16 è un operando a
16 bit contenente la destinazione di un salto NEAR, possiamo
anche scrivere:
jmp word ptr label_addr16
In presenza del precedente codice macchina, la CPU accede all'indirizzo
DS:0000h, legge il valore 0274h, lo carica direttamente in
IP e salta a CS:IP (cioè, CODESEGM:0274h); come si può
notare, si tratta proprio dell'indirizzo logico dell'etichetta
dest_label4!
Per dimostrare che il salto si svolge all'interno di CODESEGM, le
istruzioni successive a dest_label4 visualizzano, oltre alla stringa
strMsg4, gli indirizzi logici Seg:Offset dell'origine
(orig_label4) e della destinazione del salto (dest_label4); in
questo modo, possiamo constatare che per entrambi gli indirizzi, la
componente Seg vale sempre CODESEGM.
Dall'indirizzo CODESEGM:02ABh viene effettuato un salto diretto
intrasegmento verso l'offset 0051h dell'etichetta return_label4;
mentre la CPU elabora questa istruzione, IP è stato aggiornato
all'offset 02AEh, per cui la distanza del salto è pari a:
0051h - 02AEh = 81 - 686 = -605 = 216 - 605 = 64931 = FDA3h
Si tratta quindi di un NEAR jump il cui Rel16 vale -605;
tale valore richiede almeno 16 bit (FDA3h) e, infatti, l'assembler
genera il codice macchina:
E9h FDA3h
In presenza del precedente codice macchina, la CPU calcola:
FDA3h + 02AEh = 0051h
Il valore 0051h viene caricato in IP e viene effettuato un salto
a CS:IP (cioè, CODESEGM:0051h); come si può notare, si tratta
proprio dell'indirizzo logico dell'etichetta return_label4!
L'istruzione successiva a return_label4 consiste in un JMP
diretto intersegmento dall'indirizzo CODESEGM:0051h all'indirizzo
LIBCODE:0000h dell'etichetta dest_label5; la coppia
Ptr16:Ptr16 ricavata dall'assembler è appunto LIBCODE:0000h e
quindi, per questo FAR jump diretto, l'assembler genera il codice
macchina:
EAh ----h:0000h
Ricordiamo che l'assembler non è in grado di conoscere in anticipo il
valore che il SO assegnerà a LIBCODE, per cui viene utilizzata
una componente Seg simbolica pari a ----h.
Osserviamo che, in questo caso (e in tutti i casi di salto diretto
intersegmento), è necessario scrivere in modo esplicito:
jmp far ptr dest_label5
In presenza del precedente codice macchina, la CPU carica il valore
0000h in IP, il valore LIBCODE in CS e salta a
CS:IP (cioè, LIBCODE:0000h); come si può notare, si tratta proprio
dell'indirizzo logico dell'etichetta dest_label5!
Per dimostrare che il salto si svolge da CODESEGM a LIBCODE, le
istruzioni successive a dest_label5 visualizzano, oltre alla stringa
strMsg5, gli indirizzi logici Seg:Offset dell'origine
(orig_label5) e della destinazione del salto (dest_label5); in
questo modo, possiamo constatare che per l'indirizzo di origine la componente
Seg vale CODESEGM, mentre per l'indirizzo di destinazione la
componente Seg vale LIBCODE.
Dall'indirizzo LIBCODE:0037h viene effettuato un salto diretto
intersegmento verso l'indirizzo CODESEGM:0056h dell'etichetta
return_label5; la coppia Ptr16:Ptr16 ricavata dall'assembler è
appunto CODESEGM:0056h, e quindi, per questo FAR jump diretto,
l'assembler genera il codice macchina:
EAh ----h:0056h
In presenza di questo codice macchina, la CPU carica il valore
0056h in IP, il valore CODESEGM in CS e salta a
CS:IP (cioè, CODESEGM:0056h); come si può notare, si tratta proprio
dell'indirizzo logico dell'etichetta return_label5!
L'istruzione successiva a return_label5 consiste in un JMP
indiretto intersegmento dall'indirizzo CODESEGM:0062h all'indirizzo
LIBCODE:003Ch dell'etichetta dest_label6; per rappresentare,
indirettamente, l'indirizzo LIBCODE:003Ch di dest_label6,
viene utilizzato il dato label_addr32 di tipo DWORD.
Per il campo mod_101_r/m abbiamo mod=00b, r/m=110b
(operando di tipo Disp), e quindi:
mod_101_r/m = 00101110b = 2Eh
L'offset di label_addr32 è 0002h, per cui l'assembler genera il
codice macchina:
FFh 2Eh 0002h
Si noti che l'assembler non genera il prefisso 66h, che indica la
presenza di operandi a 32 bit; infatti, è stato già sottolineato che
in un segmento di programma con attributo USE16, una locazione di
memoria a 32 bit, contenente la destinazione di un FAR jump
indiretto, viene trattata come coppia Mem16:Mem16 (lo stesso discorso
vale per il codice macchina di un FAR jump diretto)!
Se vogliamo enfatizzare il fatto che label_addr32 è un operando a
32 bit, possiamo anche scrivere:
jmp dword ptr label_addr32
In presenza del precedente codice macchina, la CPU accede all'indirizzo
DS:0002h, legge la coppia LIBCODE:003Ch, la carica direttamente
in CS:IP e salta a CS:IP (cioè, LIBCODE:003Ch); come si
può notare, si tratta proprio dell'indirizzo logico dell'etichetta
dest_label6!
Per dimostrare che il salto si svolge da CODESEGM a LIBCODE, le
istruzioni successive a dest_label6 visualizzano, oltre alla stringa
strMsg6, gli indirizzi logici Seg:Offset dell'origine
(orig_label6) e della destinazione del salto (dest_label6); in
questo modo, possiamo constatare che per l'indirizzo di origine la componente
Seg vale CODESEGM, mentre per l'indirizzo di destinazione la
componente Seg vale LIBCODE.
Dall'indirizzo LIBCODE:0073h viene effettuato un salto diretto
intersegmento verso l'indirizzo CODESEGM:0066h dell'etichetta
return_label6; la coppia Ptr16:Ptr16 ricavata dall'assembler è
appunto CODESEGM:0066h e quindi, per questo FAR jump diretto,
l'assembler genera il codice macchina:
EAh ----h:0066h
In presenza di questo codice macchina, la CPU carica il valore
0066h in IP, il valore CODESEGM in CS e salta a
CS:IP (cioè, CODESEGM:0066h); come si può notare, si tratta proprio
dell'indirizzo logico dell'etichetta return_label6!
L'istruzione successiva a return_label6 consiste in un normale NEAR
jump all'etichetta exit_label, dopo la quale sono presenti le solite
istruzioni per la terminazione del programma; in questo modo, evitiamo che
vengano rieseguite le istruzioni successive a dest_label1, con
conseguente salto a return_label1.
Come si può facilmente constatare, se dimentichiamo di eseguire il salto finale
a exit_label, il programma continua a girare all'infinito (loop
infinito) e deve essere terminato forzatamente; in un caso del genere, se si
sta lavorando in ambiente DOS "puro", è necessario riavviare il computer!
20.3.1 Casi particolari
In riferimento al listato di Figura 20.4, cosa succede se non è presente la direttiva
ASSUME che associa DATASEGM a DS?
Ovviamente, in un caso del genere dobbiamo ricorrere al segment override; questa
necessità si presenta nei salti indiretti, che si servono di operandi definiti
in DATASEGM.
Ad esempio, l'istruzione:
jmp label_addr16
deve essere riscritta, esplicitamente, come:
jmp ds:label_addr16
Questa istruzione indica chiaramente un salto indiretto intrasegmento ad un
indirizzo Seg:Offset; la componente Seg è contenuta, implicitamente,
in CS, mentre la componente Offset è contenuta, esplicitamente, in
una locazione da 16 bit puntata da DS:label_addr16.
Sempre in riferimento al listato di Figura 20.4, cosa succede se, ad esempio, un
operando di tipo Mem16:Mem16 (come label_addr32) è contenuto nella
seconda DWORD del dato:
var64 dq 0
In un caso del genere, dobbiamo servirci dell'operatore DWORD PTR;
possiamo scrivere quindi:
jmp dword ptr var64[4]
Questa istruzione indica chiaramente un salto indiretto intersegmento ad un
indirizzo Seg:Offset contenuto nella locazione a 32 bit puntata
da DS:var64[4].
Se, inoltre, non è presente la direttiva ASSUME che associa DATASEGM
a DS, dobbiamo scrivere:
jmp dword ptr ds:var64[4]
Il significato di questa istruzione è identico a quello dell'istruzione precedente.
Dalle considerazioni appena esposte, si deduce facilmente un metodo per
creare un vettore di indirizzi, ciascuno dei quali è la destinazione di un
salto; a tale proposito, consideriamo il seguente dato:
vett_addr16 dw 10 dup (0)
Dopo aver caricato in ciascuna WORD di vett_addr16, gli offset
di destinazione, possiamo effettuare dei salti indiretti intrasegmento,
attraverso istruzioni del tipo:
jmp word ptr vett_addr16[0]
jmp word ptr vett_addr16[2]
jmp word ptr vett_addr16[4]
e così via.
Come si può notare, si tratta di applicare i soliti semplici concetti già
esposti nei precedenti capitoli; l'importante è avere le idee chiare su ciò
che si vuole fare.
20.3.2 Il salto incondizionato nei linguaggi di alto livello
Come molti avranno intuito, l'istruzione JMP viene utilizzata dai linguaggi
di alto livello per implementare la "famigerata" istruzione GOTO; tale
istruzione viene letteralmente "criminalizzata" da quasi tutti i libri sulla
programmazione (spesso copiati l'uno dall'altro).
In effetti, in base ai concetti appena esposti, si capisce subito che è meglio non
abusare dell'istruzione JMP; in caso contrario, si perviene facilmente alla
scrittura di programmi particolarmente contorti (come l'esempio di Figura 20.4). Nei
casi estremi, l'utilizzo eccessivo dei salti incondizionati, porta alla scrittura
di programmi incomprensibili e quindi impossibili da sottoporre ad eventuali
modifiche (o a cicli periodici di aggiornamento); questo problema ha assunto una
certa gravità con l'avvento dei linguaggi come il BASIC che, incoraggiando
un uso indiscriminato dell'istruzione GOTO, hanno favorito uno "stile" di
programmazione universalmente conosciuto come "spaghetti code"!
È anche importante ribadire che ciascuna istruzione di salto, provoca un
sensibile rallentamento del programma in esecuzione; vediamo un esempio
pratico che si riferisce sempre al listato di Figura 20.4.
Supponiamo che la CPU stia elaborando l'istruzione:
jmp far ptr dest_label5
che si trova all'indirizzo CODESEGM:0051h; nel frattempo, la CL
provvede a pre-caricare nella prefetch queue, il codice macchina B8h
003Ch dell'istruzione successiva:
mov ax, offset dest_label6
che si trova all'indirizzo CODESEGM:0056h (dando per scontato che si
tratti della prossima istruzione da eseguire).
Purtroppo, però, l'istruzione presente all'indirizzo CODESEGM:0051h,
prevede un salto all'indirizzo LIBCODE:0000h, per cui tutto il lavoro
di pre-carica svolto dalla CL, si rivela inutile; in un caso del genere,
la CL svuota la prefetch queue e la ricarica con il codice macchina
BFh 012Ch dell'istruzione che si trova all'indirizzo LIBCODE:0000h.
A maggior ragione, è necessario quindi limitare al massimo l'utilizzo delle
istruzioni di salto nei programmi scritti in stile strutturato; appare anche
evidente che la situazione appena descritta, non può mai verificarsi in un
programma scritto in stile sequenziale!
In ogni caso, la criminalizzazione dell'istruzione JMP appare del tutto
ingiustificata; infatti, nel seguito del capitolo vedremo che in determinate
situazioni l'istruzione JMP, se utilizzata in modo razionale, si rivela
molto utile se non indispensabile.
Consideriamo subito un esempio pratico, relativo ad un programma eseguibile
in formato COM; come sappiamo, in questo tipo di programmi è proibito
inserire dati inizializzati prima dell'entry point (che deve trovarsi,
rigorosamente, all'offset 0100h dell'unico segmento presente). Si
presenta allora il problema di come evitare che la CPU finisca per
sbaglio nel blocco contenente le definizioni dei vari dati del programma; un
modo per risolvere tale problema, consiste nel ricorrere all'espediente mostrato
in Figura 20.5.
Come si può notare, subito dopo l'entry point è presente una istruzione
JMP che provoca un salto incondizionato verso l'etichetta start_code
(dopo la quale inizia il blocco codice del programma); in questo modo, viene
scavalcata la zona compresa tra le etichette start: e start_code,
contenente il blocco dati del programma!
20.3.3 Effetti provocati da JMP sugli operandi e sui flags
L'esecuzione dell'istruzione JMP non provoca alcuna modifica sull'unico
operando presente (DEST).
L'esecuzione dell'istruzione JMP, in modalità reale, non modifica alcun
campo del Flags Register.
20.4 Le istruzioni Jcond
In contrapposizione a JMP che costringe la CPU a saltare in modo
incondizionato, viene resa disponibile una numerosa famiglia di istruzioni la
cui esecuzione provoca un salto solo se si verificano determinate condizioni;
tale famiglia viene indicata simbolicamente con il mnemonico Jcond, che
significa Jump if Condition Is Met (salta se la condizione cond
è verificata). Il termine cond indica, appunto, la condizione che deve
verificarsi affinché possa essere eseguito il salto; se la condizione cond
non si verifica, l'esecuzione prosegue sequenzialmente con l'istruzione successiva
alla stessa Jcond.
In modalità reale, le uniche forme lecite per l'istruzione Jcond sono le
seguenti:
Come si può notare, è permesso solamente il salto diretto intrasegmento; in
particolare, con le CPU 8086 e 80286, è permesso solo lo
SHORT jump, mentre con le CPU 80386 e superiori, è permesso anche
il NEAR jump.
La condizione che, verificandosi, determina l'esecuzione del salto, è legata
al risultato prodotto da una operazione logico aritmetica appena eseguita;
come sappiamo, immediatamente dopo l'esecuzione di una istruzione logico
aritmetica, la CPU modifica una serie di flags, in modo da fornire
un resoconto dettagliato sul risultato appena ottenuto.
Al momento di eseguire una istruzione Jcond, la CPU consulta
proprio gli opportuni campi del Flags Register per sapere se la
condizione di salto è verificata o meno; se la condizione è verificata, il
salto viene eseguito, mentre in caso contrario, l'esecuzione prosegue
sequenzialmente con l'istruzione successiva alla stessa Jcond.
Le istruzioni Jcond possono essere suddivise in due grandi categorie
in quanto, la condizione che determina l'esecuzione del salto può fare
riferimento, esplicitamente o implicitamente, al valore assunto da
determinati flags; analizziamo in dettaglio queste due categorie.
20.4.1 Istruzioni Jcond riferite esplicitamente ai flags
In questa particolare categoria di istruzioni Jcond, la condizione
cond che provoca il salto è legata esplicitamente al valore (0
o 1) assunto da un determinato flag come conseguenza del risultato
prodotto da una operazione logico aritmetica appena eseguita; la Figura 20.6
illustra l'insieme completo di queste istruzioni (nel seguito del capitolo,
per rappresentare la condizione che provoca il salto, vengono utilizzati i
soliti operatori relazionali del linguaggio C).
Il significato di questi mnemonici è abbastanza chiaro; JO significa
jump if overflow (salta se si è verificato un overflow), JC
significa jump if carry/borrow (salta se si è verificato un riporto
o un prestito), etc.
Bisogna ricordare che, nella rappresentazione dei numeri interi relativi in
complemento a 2, lo zero figura come numero positivo; di conseguenza,
la condizione JNS (SF=0) è verificata quando il risultato è
positivo o nullo.
Come si può notare, alcune condizioni di salto sono associate a due mnemonici
equivalenti; ciò è dovuto al fatto che una determinata condizione, può essere
espressa in diversi modi.
Ad esempio, nella comparazione aritmetica (CMP) tra due operandi
A e B, se il risultato è nullo (ZF=1), allora la
condizione:
JZ = jump if zero (salta se ZF = 1)
può essere espressa anche come:
JE = jump if equal (salta se A = B)
Se CMP produce, invece, un risultato diverso da zero (ZF=0),
allora la condizione:
JNZ = jump if not zero (salta se ZF = 0)
può essere espressa anche come:
JNE = jump if not equal (salta se A ≠ B)
Se gli 8 bit meno significativi del risultato di una operazione,
contengono un numero pari di bit a livello logico 1 (PF=1),
allora la condizione:
JP = jump if parity (salta se PF = 1)
può essere espressa anche come:
JPE = jump if parity is even (salta se la parità è pari)
Se gli 8 bit meno significativi del risultato di una operazione,
contengono un numero dispari di bit a livello logico 1 (PF=0),
allora la condizione:
JNP = jump if not parity (salta se PF = 0)
può essere espressa anche come:
JPO = jump if parity is odd (salta se la parità è dispari)
Naturalmente, il programmatore è libero di utilizzare la forma che più si
adatta al contesto del programma che sta scrivendo.
20.4.2 Istruzioni Jcond riferite implicitamente ai flags
L'altra categoria di istruzioni Jcond fa esplicito riferimento alla
relazione d'ordine che esiste tra due operandi, DEST e SRC; in
sostanza, attraverso questa categoria di istruzioni Jcond, possiamo
effettuare dei salti condizionati dal risultato di una comparazione tra
DEST e SRC.
Appare chiaro, però, che il riferimento ai vari campi del Flags Register
è implicito; infatti, in un precedente capitolo abbiamo visto che la CPU,
per conoscere la relazione d'ordine che esiste tra due operandi, si serve dei
flags CF, ZF e OF.
Abbiamo anche visto che nella comparazione tra due operandi, è importantissimo
distinguere tra numeri interi con o senza segno; per rendercene conto,
consideriamo i due codici numerici esadecimali 3FB8h e BC2Dh.
Nell'insieme dei numeri interi senza segno, 3FB8h rappresenta
16312, mentre BC2Dh rappresenta 48173; in questo caso,
appare evidente che 16312 è strettamente minore di 48173 (cioè,
3FB8h è strettamente minore di BC2Dh).
Nell'insieme dei numeri interi con segno in complemento a 2,
3FB8h rappresenta +16312, mentre BC2Dh rappresenta
-17363; in questo caso, appare evidente che +16312 è
strettamente maggiore di -17363 (cioè, 3FB8h è strettamente
maggiore di BC2Dh)!
Proprio per i motivi appena illustrati, le istruzioni Jcond riferite
implicitamente ai flags, si suddividono in due ulteriori gruppi; il primo
gruppo fa esplicito riferimento ai numeri interi senza segno, mentre il
secondo gruppo fa esplicito riferimento ai numeri interi con segno.
Ovviamente, è compito del programmatore stabilire quale gruppo di istruzioni
Jcond debba essere utilizzato; tutto dipende cioè, dalla nostra
intenzione di voler operare sui numeri interi con o senza segno.
La Figura 20.7 illustra le istruzioni Jcond riferite, esplicitamente,
ai numeri interi senza segno.
Per capire il significato di questi mnemonici, è necessario tenere presente
che la Intel, per i numeri interi senza segno, ha deciso di utilizzare
il termine above (al di sopra) per indicare "maggiore di" e il
termine below (al di sotto) per indicare "minore di".
Questi due termini possono essere combinati con equal (che sta per
"uguale") e con not (che indica una negazione); in questo
modo, possiamo costruire espressioni del tipo "below or equal"
(minore o uguale), "not below" (non minore), "not above or
equal" (né maggiore, né uguale), etc.
Ciascuna istruzione di Figura 20.7 ha un doppio nome in virtù del fatto che una
determinata relazione d'ordine può essere espressa in modi differenti;
analizziamo, infatti, i quattro casi che si possono presentare.
Dire:
JB = jump if below (salta se DEST è minore di SRC)
equivale a dire:
JNAE = jump if not above or equal (salta se DEST non è, né maggiore, né uguale a SRC)
Dire:
JBE = jump if below or equal (salta se DEST è minore o uguale a SRC)
equivale a dire:
JNA = jump if not above (salta se DEST non è maggiore di SRC)
Dire:
JNB = jump if not below (salta se DEST non è minore di SRC)
equivale a dire:
JAE = jump if above or equal (salta se DEST è maggiore o uguale a SRC)
Dire:
JNBE = jump if not below or equal (salta se DEST non è, né minore, né uguale a SRC)
equivale a dire:
JA = jump if above (salta se DEST è maggiore di SRC)
La Figura 20.8 illustra le istruzioni Jcond riferite, esplicitamente,
ai numeri interi con segno.
Per capire il significato di questi mnemonici, è necessario tenere presente
che la Intel, per i numeri interi con segno, ha deciso di utilizzare
il termine greater (più grande) per indicare "maggiore di" e
il termine less (meno) per indicare "minore di".
Questi due termini possono essere combinati con equal (che sta per
"uguale") e con not (che indica una negazione); in questo
modo, possiamo costruire espressioni del tipo "less or equal" (minore
o uguale), "not less" (non minore), "not greater or equal" (né
maggiore, né uguale), etc.
Ciascuna istruzione di Figura 20.8 ha un doppio nome in virtù del fatto che una
determinata relazione d'ordine può essere espressa in modi differenti;
analizziamo, infatti, i quattro casi che si possono presentare.
Dire:
JL = jump if less (salta se DEST è minore di SRC)
equivale a dire:
JNGE = jump if not greater or equal (salta se DEST non è, né maggiore, né uguale a SRC)
Dire:
JLE = jump if less or equal (salta se DEST è minore o uguale a SRC)
equivale a dire:
JNG = jump if not greater (salta se DEST non è maggiore di SRC)
Dire:
JNL = jump if not less (salta se DEST non è minore di SRC)
equivale a dire:
JGE = jump if greater or equal (salta se DEST è maggiore o uguale a SRC)
Dire:
JNLE = jump if not less or equal (salta se DEST non è, né minore, né uguale a SRC)
equivale a dire:
JG = jump if greater (salta se DEST è maggiore di SRC)
Come al solito, il programmatore è libero di utilizzare la forma che più si
adatta al contesto del programma che sta scrivendo.
20.4.3 Esempi pratici per le istruzioni Jcond
Come si può facilmente immaginare, attraverso l'uso combinato delle istruzioni
JMP e Jcond, possiamo implementare numerosi costrutti sintattici,
tipici dei linguaggi di alto livello; il programma JCOND.ASM illustrato
in Figura 20.9, mostra diversi esempi relativi a blocchi condizionali IF -
ELSE e IF - ELSE IF - ELSE, cicli FOR, cicli
WHILE - DO, cicli DO - WHILE, etc.
20.4.4 Reverse logic (logica inversa)
Abbiamo visto che con le istruzioni Jcond è permesso solamente il
salto diretto intrasegmento; può anche capitare, però, di dover utilizzare
Jcond per compiere dei salti "fuori portata". Una tale situazione
si presenta, ad esempio, quando vogliamo effettuare un Jcond NEAR
o un Jcond FAR con una CPU 8086 o 80286; oppure,
quando vogliamo effettuare un Jcond FAR con una CPU 80386 o
superiore.
Questo problema si risolve in modo molto semplice, attraverso una tecnica
chiamata reverse logic (logica inversa); in pratica, anziché
scrivere:
Jcond salto_troppo_lontano
possiamo scrivere:
In sostanza, la condizione cond viene sostituita dalla sua negazione
notcond per effettuare un normale salto "vicino"; il salto "lontano"
(associato a cond) viene, invece, effettuato da JMP (che può
raggiungere qualsiasi indirizzo di memoria)!
Supponiamo, ad esempio, di voler utilizzare JZ per effettuare un
FAR jump ad una etichetta far_label; applicando la logica
inversa, dobbiamo sostituire JZ con JNZ e scrivere:
Quindi, se ZF=0, l'istruzione JNZ fa saltare il programma a
near_label; se, invece, ZF=1, l'istruzione JMP fa
saltare il programma a far_label!
20.4.5 Effetti provocati dalle istruzioni Jcond, sugli operandi e sui flags
L'esecuzione di una qualsiasi istruzione Jcond non provoca alcuna
modifica sull'unico operando presente (DEST).
L'esecuzione di una qualsiasi istruzione Jcond, in modalità reale,
non modifica alcun campo del Flags Register.
Ciò permette di utilizzare diverse istruzioni Jcond in sequenza; possiamo
scrivere, ad esempio:
20.5 Le istruzioni JCXZ
e JECXZ
La famiglia Jcond comprende anche due istruzioni particolari, indicate
dai mnemonici JCXZ e JECXZ; come si intuisce dai mnemonici, tali
istruzioni si distinguono dalle altre Jcond, in quanto fanno esplicito
riferimento al contenuto del registro contatore CX/ECX!
Con il mnemonico JCXZ si indica l'istruzione Jump Short if CX is
Zero (salto corto se il contenuto di CX è zero); con il mnemonico
JECXZ si indica l'istruzione Jump Short if ECX is Zero (salto corto
se il contenuto di ECX è zero).
In modalità reale, le uniche forme lecite per le istruzioni JCXZ e
JECXZ sono le seguenti:
A differenza di quanto accade per le altre Jcond, con le istruzioni
JCXZ e JECXZ si possono effettuare solamente salti diretti
SHORT intrasegmento; ovviamente, l'istruzione JECXZ è
disponibile solo con le CPU 80386 e superiori.
Per capire la funzione svolta da JCXZ e JECXZ, è necessario
tenere presente che molte istruzioni della CPU utilizzano CX
o ECX come registro contatore predefinito; non a caso, tale registro
viene chiamato proprio counter register.
Usualmente, CX/ECX viene impiegato per gestire il controllo di un
loop; nel caso più frequente, il registro viene inizializzato con il numero
n di iterazioni da effettuare e viene poi decrementato di 1
ad ogni ciclo, finché non raggiunge il valore zero (condizione di uscita dal
loop).
Un tale procedimento, può portare ad una situazione potenzialmente pericolosa,
rappresentata dalla eventualità che un loop inizi con il registro contatore
che vale zero; questa situazione non è infrequente in quanto, spesso, il
registro contatore viene inizializzato attraverso una o più istruzioni
aritmetiche. Possiamo avere, ad esempio:
Nell'eventualità che le precedenti istruzioni carichino il valore zero in
CX, vediamo quello che succede quando, all'interno del loop, viene
incontrata l'istruzione:
dec cx
Il risultato prodotto da questa istruzione è, ovviamente:
CX = CX - 1 = 0 - 1 = FFFFh = 65535
Di conseguenza, la successiva istruzione, usualmente del tipo:
jnz start_loop
anziché terminare il loop, lo fa ripetere altre 65535 volte, per un
totale di 65536 iterazioni!
Per evitare una situazione del genere, possiamo utilizzare proprio
l'istruzione JCXZ (o JECXZ) scrivendo:
Come si può notare, se CX=0 il loop viene aggirato con uno SHORT
jump compiuto da JCXZ; trattandosi, appunto, di uno SHORT
jump, l'etichetta end_loop deve trovarsi ad una distanza compresa
tra -128 e +127 byte dall'etichetta start_loop!
A rigore quindi, il caso CX=0 (o ECX=0) all'inizio di un loop
(controllato dallo stesso CX/ECX), deve essere considerato come una
situazione di errore; molti programmatori Assembly, però, sfruttano
proprio questa situazione quando hanno la necessità di ripetere un loop
65536 volte con una CPU 8086 (o 80286), dotata di
registri a soli 16 bit!
Avendo a disposizione una CPU 80386 o superiore, è anche possibile
utilizzare l'istruzione JECXZ che forza la CPU a lavorare su
ECX (anche in modalità reale); in tal caso, possiamo gestire dei loop
che effettuano sino a 232 iterazioni!
20.5.1 Effetti provocati dalle istruzioni JCXZ e JECXZ, sugli operandi e sui flags
L'esecuzione di una istruzione JCXZ/JECXZ non provoca alcuna modifica
sul registro CX/ECX.
L'esecuzione di una istruzione JCXZ o JECXZ, in modalità reale,
non modifica alcun campo del Flags Register.
20.6 Le istruzioni LOOP,
LOOPZ/LOOPE,
LOOPNZ/LOOPNE
Nel mondo della programmazione, le iterazioni si sono rivelate uno strumento
talmente utile e potente, da aver indotto i progettisti delle CPU a
creare apposite istruzioni, con lo scopo principale di automatizzare la fase
di controllo del loop che, normalmente, ricade sul programmatore; in
particolare, la famiglia delle CPU 80x86 rende disponibili le
istruzioni LOOP, LOOPZ/LOOPE e LOOPNZ/LOOPNE.
Tutte queste istruzioni utilizzano CX/ECX come registro contatore
predefinito per il controllo del loop; a tale proposito, tutto dipende
dall'attributo Dimensione del segmento di programma contenente il
loop. Se l'attributo è USE16, il registro contatore predefinito è
CX, mentre se l'attributo è USE32, il registro contatore
predefinito è ECX; nel seguito viene fatto riferimento,
esclusivamente, a segmenti di programma con attributo USE16, per cui
è sottinteso che il registro contatore predefinito sia CX!
20.6.1 L'istruzione LOOP
Con il mnemonico LOOP si indica l'istruzione Loop According to
CX/ECX Counter (controllo di un loop attraverso il registro contatore
CX); questa istruzione presuppone che CX sia stato
inizializzato dal programmatore con un valore che rappresenta il numero di
iterazioni da effettuare.
Ogni volta che la CPU incontra l'istruzione LOOP, decrementa
di 1 il contenuto di CX, senza alterare alcun campo del
Flags Register; successivamente, la CPU effettua una scelta
basata sul risultato prodotto dal decremento di CX:
- se CX è uguale a zero (condizione di uscita dal loop),
l'esecuzione prosegue sequenzialmente con l'istruzione successiva a
LOOP
- se CX è diverso da zero, viene effettuato un salto ad una
etichetta specificata dal programmatore (usualmente, si tratta
dell'etichetta che delimita l'inizio di un loop)
In modalità reale, l'unica forma lecita per l'istruzione LOOP è
la seguente:
LOOP Rel8
Come si può notare, LOOP permette di effettuare, esclusivamente,
salti diretti SHORT intrasegmento; di conseguenza, l'offset di
destinazione deve trovarsi ad una distanza compresa tra -128 e
+127 byte dall'offset successivo a quello della stessa istruzione
LOOP.
Nel programma di Figura 20.9, ad esempio, il ciclo FOR che inizializza il
vettore vectWord, può essere riscritto in questo modo:
20.6.2 L'istruzione LOOPZ/LOOPE
Con il mnemonico LOOPZ (o LOOPE) si indica l'istruzione
Loop According to CX/ECX Counter and ZF (controllo di un loop
attraverso il registro contatore CX e lo Zero Flag); questa
istruzione presuppone che CX sia stato inizializzato dal programmatore
con un valore che rappresenta il numero di iterazioni da effettuare.
Ogni volta che la CPU incontra l'istruzione LOOPZ/LOOPE,
decrementa di 1 il contenuto di CX, senza alterare alcun campo
del Flags Register; successivamente, la CPU effettua una scelta
basata sul risultato prodotto dal decremento di CX e sul contenuto di
ZF:
- se (CX == 0) OR (ZF == 0) (condizione di uscita dal loop),
l'esecuzione prosegue sequenzialmente con l'istruzione successiva
a LOOP
- se (CX != 0) AND (ZF == 1), viene effettuato un salto ad una
etichetta specificata dal programmatore (usualmente, si tratta
dell'etichetta che delimita l'inizio di un loop)
Siccome LOOPZ/LOOPE non modifica alcun campo del Flags Register,
è evidente che la modifica di ZF deve essere provocata da una istruzione
posta all'interno del loop; normalmente, tale istruzione è quella che precede
LOOPZ/LOOPE.
I due mnemonici, LOOPZ e LOOPE, sono del tutto equivalenti;
sappiamo, infatti, che la condizione "jump if zero" può essere espressa
anche come "jump if equal".
In modalità reale, l'unica forma lecita per l'istruzione LOOPZ/LOOPE è
la seguente:
Come si può notare, LOOPZ/LOOPE permette di effettuare, esclusivamente,
salti diretti SHORT intrasegmento; di conseguenza, valgono le
limitazioni già esposte per LOOP.
Come esempio pratico, supponiamo di utilizzare AX per contenere un
numero intero con segno a 16 bit in complemento a 2; tale numero,
inizialmente è positivo (bit di segno uguale a 0) e viene decrementato
di 1 ad ogni loop. Il loop termina quando il numero diventa negativo
(bit di segno uguale a 1); in tal caso, l'istruzione:
test ah, 80h ; ah and 10000000b
fornisce un risultato diverso da zero (ZF=0), senza che venga alterato il
contenuto di AH.
Possiamo scrivere allora il seguente codice:
20.6.3 L'istruzione LOOPNZ/LOOPNE
Con il mnemonico LOOPNZ (o LOOPNE) si indica l'istruzione Loop
According to CX/ECX Counter and ZF (controllo di un loop attraverso il registro
contatore CX e lo Zero Flag); questa istruzione presuppone che
CX sia stato inizializzato dal programmatore con un valore che rappresenta
il numero di iterazioni da effettuare.
Ogni volta che la CPU incontra l'istruzione LOOPNZ/LOOPNE,
decrementa di 1 il contenuto di CX, senza alterare alcun campo
del Flags Register; successivamente, la CPU effettua una scelta
basata sul risultato prodotto dal decremento di CX e sul contenuto di
ZF:
- se (CX == 0) OR (ZF == 1) (condizione di uscita dal loop),
l'esecuzione prosegue sequenzialmente con l'istruzione successiva a
LOOP
- se (CX != 0) AND (ZF == 0), viene effettuato un salto ad una
etichetta specificata dal programmatore (usualmente, si tratta
dell'etichetta che delimita l'inizio di un loop)
Siccome LOOPNZ/LOOPNE non modifica alcun campo del Flags Register,
è evidente che la modifica di ZF deve essere provocata da una istruzione
posta all'interno del loop; normalmente, tale istruzione è quella che precede
LOOPNZ/LOOPNE.
I due mnemonici, LOOPNZ e LOOPNE, sono del tutto equivalenti;
sappiamo, infatti, che la condizione "jump if not zero" può essere
espressa anche come "jump if not equal".
In modalità reale, l'unica forma lecita per l'istruzione LOOPNZ/LOOPNE è
la seguente:
Come si può notare, LOOPNZ/LOOPNE permette di effettuare, esclusivamente,
salti diretti SHORT intrasegmento; di conseguenza, valgono le limitazioni
già esposte per LOOP.
Nel programma di Figura 20.9, ad esempio, il ciclo DO - WHILE che copia
stringaC in strBuffer, può essere riscritto in questo modo:
In un segmento di programma con attributo USE16, possiamo definire una
stringa avente lunghezza massima pari a 65535 elementi (byte), i cui
offset sono compresi tra 0000h e FFFFh; proprio per questo motivo,
il registro contatore CX viene inizializzato con il massimo numero
possibile di iterazioni (FFFFh)!
20.6.4 Considerazioni generali sulle istruzioni LOOP, LOOPZ/LOOPE,
LOOPNZ/LOOPNE
In base alle considerazioni appena esposte, risulta che le istruzioni LOOP,
LOOPZ/LOOPE e LOOPNZ/LOOPNE, presentano diverse limitazioni; la più
evidente è rappresentata dalla possibilità di effettuare solamente salti diretti
SHORT intrasegmento.
In effetti, lo SHORT jump sembrerebbe troppo limitato per la gestione
di loop molto complessi; bisogna tenere presente, però, che nella stragrande
maggioranza dei casi, si ha bisogno di loop molto piccoli e veloci, mentre i
loop molto grandi e contorti, sono indice di un pessimo stile di programmazione!
In ogni caso, può anche presentarsi la necessità di scrivere dei loop che
richiedono un NEAR jump; per risolvere un tale problema, conviene
decisamente rinunciare a LOOP, LOOPZ/LOOPE e
LOOPNZ/LOOPNE, utilizzando al loro posto le istruzioni Jcond in
combinazione con JMP.
Analizzando gli esempi presentati in precedenza, risulta che una iterazione
gestita con LOOP equivale ad un ciclo FOR, mentre una iterazione
gestita con LOOPZ/LOOPE o LOOPNZ/LOOPNE equivale ad un ciclo
DO - WHILE; possiamo dire allora che nel caso di cicli FOR
piccoli e semplici, risulta molto comodo l'utilizzo dell'istruzione LOOP,
mentre in tutti gli altri casi (cicli FOR, DO - WHILE o WHILE
- DO, più o meno complessi), conviene optare per le istruzioni Jcond
e JMP, che appaiono anche più chiare ed espressive (come si vede negli
esempi di Figura 20.9).
È anche importante ricordare che in una iterazione gestita con LOOP,
LOOPZ/LOOPE o LOOPNZ/LOOPNE, bisogna evitare qualsiasi modifica
al contenuto del registro contatore CX; se si presenta l'assoluta
necessità di modificare CX/ECX, bisogna preservarne il vecchio
contenuto con l'ausilio delle istruzioni PUSH e POP!
20.6.5 Effetti provocati dalle istruzioni LOOP, LOOPZ/LOOPE e
LOOPNZ/LOOPNE, sugli operandi e sui flags
L'esecuzione di una istruzione LOOP, LOOPZ/LOOPE,
LOOPNZ/LOOPNE, modifica il contenuto del registro contatore CX/ECX;
il contenuto dell'operando DEST non subisce, invece, alcuna modifica.
L'esecuzione di una istruzione LOOP, LOOPZ/LOOPE, LOOPNZ/LOOPNE,
in modalità reale, non modifica alcun campo del Flags Register.
20.7 Le istruzioni CALL
e RET
Analizzando l'esempio presentato in Figura 20.4, ci rendiamo conto che grazie
alle istruzioni per il trasferimento del controllo, possiamo costringere la
CPU ad effettuare dei salti verso qualunque indirizzo di memoria; a
tale proposito, non dobbiamo fare altro che indicare alla stessa CPU
l'indirizzo logico Seg:Offset da caricare nella coppia CS:IP,
in modo da poter raggiungere la destinazione del salto.
È chiaro che in una situazione di questo genere, il programmatore deve fare
in modo che la CPU sia sempre in grado di trovare la "strada per il
ritorno"; in sostanza, i vari salti presenti in un programma, devono essere
tali da permettere alla CPU di seguire un percorso che, in modo più o
meno diretto, deve condurre sempre alle istruzioni di terminazione del
programma stesso.
Il concetto di "strada per il ritorno" è molto importante, in quanto
suggerisce l'idea di far assumere ad un programma una struttura piuttosto
particolare; in pratica, possiamo pensare di suddividere le istruzioni di
un programma in un blocco principale chiamato programma principale
e in tanti altri sottoblocchi, distinti ed indipendenti dallo stesso
programma principale.
Ciascuno dei sottoblocchi è destinato a svolgere un compito specifico come,
ad esempio, la conversione in maiuscolo di una stringa C, la copia di
una stringa C, etc; i vari sottoblocchi così ottenuti, possono essere
paragonati a tanti strumenti, a disposizione del programma principale.
Ogni volta che il programma principale ha bisogno di svolgere un determinato
compito come, ad esempio, convertire in maiuscolo una stringa C, non
deve fare altro che "chiamare" (attraverso un salto) l'opportuno sottoblocco;
lo stesso sottoblocco, dopo aver svolto il proprio lavoro, deve essere in
grado di restituire il controllo (attraverso un altro salto) al cosiddetto
"chiamante" (cioè, alla porzione di programma principale che ha effettuato
la chiamata).
Le considerazioni appena esposte stanno alla base del concetto fondamentale
di sottoprogramma; i sottoprogrammi rappresentano un potentissimo
strumento che ha letteralmente rivoluzionato l'arte della programmazione,
permettendo di raggiungere obiettivi come la modularità, la
riusabilità e la manutenibilità del codice!
20.7.1 I sottoprogrammi
Con il termine sottoprogramma, si indica un blocco di istruzioni
distinte ed indipendenti dal programma principale; un sottoprogramma è
destinato a svolgere un compito specifico e le istruzioni che lo compongono
rappresentano, nel loro insieme, un vero e proprio mini-programma.
Un sottoprogramma può essere chiamato da qualunque punto del programma
principale ed in qualsiasi momento della fase di esecuzione; più in generale,
un sottoprogramma può essere chiamato anche da un altro sottoprogramma
(inoltre, come vedremo nei capitoli successivi, un sottoprogramma può anche
chiamare se stesso, direttamente o indirettamente).
Chiamare un sottoprogramma significa effettuare un salto all'indirizzo della
prima istruzione del sottoprogramma stesso; una volta che il sottoprogramma ha
terminato il proprio lavoro, deve essere in grado di restituire il controllo a
chi lo ha chiamato. L'indirizzo a cui salta un sottoprogramma che ha appena
terminato il proprio lavoro, prende il nome di return address (indirizzo
di ritorno); come è stato già anticipato in un precedente capitolo, l'indirizzo
di ritorno più logico è chiaramente quello dell'istruzione immediatamente
successiva a quella che ha effettuato la chiamata.
Utilizzando le istruzioni che abbiamo studiato in questo capitolo, possiamo
facilmente implementare le due fasi fondamentali che caratterizzano la chiamata
di un sottoprogramma; la prima fase consiste in un salto che il chiamante
effettua verso il sottoprogramma (chiamata), mentre la seconda fase consiste in
un salto dal sottoprogramma verso l'indirizzo di ritorno (restituzione del
controllo).
Supponiamo, ad esempio, di aver creato un sottoprogramma il cui indirizzo
iniziale è delimitato dall'etichetta start_subroutine1; tale
sottoprogramma effettua la conversione in maiuscolo di una stringa C che
deve essere puntata da DS:BX. Lo stesso sottoprogramma, richiede che
l'indirizzo di ritorno si trovi nel registro DX; in base a queste premesse,
se vogliamo richiedere la conversione in maiuscolo di una stringa C
chiamata stringa_da_convertire, possiamo scrivere le seguenti istruzioni:
Come è facile intuire, il sottoprogramma dopo aver terminato il proprio lavoro,
non deve fare altro che eseguire un semplicissimo:
jmp dx
Nell'esempio appena presentato, abbiamo supposto che il sottoprogramma sia
raggiungibile con un NEAR jump; inoltre, il registro DS
referenzia il segmento di programma in cui è stata definita la stringa
stringa_da_convertire.
Questo procedimento appare concettualmente molto semplice, ma può risultare
piuttosto fastidioso da applicare quando si ha a che fare con decine e decine
di chiamate; per ogni chiamata, il programmatore deve ricordarsi di caricare
in DX l'indirizzo di ritorno, con l'evidente rischio di commettere
qualche errore.
Per evitare tutti questi potenziali problemi, le CPU della famiglia
80x86 mettono a disposizione due potentissime istruzioni rappresentate
dai mnemonici CALL e RET; come si può facilmente intuire,
l'istruzione CALL effettua la chiamata di un sottoprogramma, mentre
l'istruzione RET restituisce il controllo al chiamante.
20.7.2 L'istruzione CALL
Con il mnemonico CALL si indica l'istruzione CALL Procedure
(chiamata di una procedura); come già sappiamo, in Assembly si utilizza
il termine procedura per indicare un generico sottoprogramma. Nei
linguaggi di alto livello, vengono utilizzati termini come, procedure,
function, subroutine, etc; nel seguito del capitolo e in tutta
la sezione Assembly Base, verrà sempre utilizzato il termine generico
procedura.
L'istruzione CALL è del tutto simile all'istruzione JMP; il suo
scopo quindi è quello di costringere la CPU a saltare, senza condizioni,
ad un determinato indirizzo di memoria Seg:Offset specificato dal
programmatore attraverso un apposito operando esplicito (DEST). La
differenza fondamentale tra JMP e CALL sta nel fatto che, prima di
effettuare il salto specificato da CALL, la CPU inserisce sulla
cima dello stack l'indirizzo di ritorno; come già sappiamo, l'indirizzo di
ritorno è quello dell'istruzione immediatamente successiva alla stessa CALL.
Ovviamente, prima di inserire l'indirizzo di ritorno nello stack, la CPU
decrementa opportunamente il registro SP!
In modalità reale, le uniche forme lecite per l'istruzione CALL sono le
seguenti:
Come si può notare, sono permessi tutti i tipi di salto descritti nella sezione
20.1, ad eccezione della SHORT call (chiamata corta) che viene
sempre ricondotta ad una NEAR call (chiamata vicina); i codici macchina
relativi ai vari casi, sono i seguenti:
Analizziamo subito i vari casi che si possono presentare; ovviamente, restano
valide tutte le considerazioni esposte nella sezione 20.1 in relazione
al procedimento seguito dalla CPU per effettuare i diversi tipi di
salto.
Abbiamo una procedura start_proc1 che si trova all'offset 0FC8h
di un segmento di programma chiamato CODESEGM; se vogliamo chiamare tale
procedura dall'interno dello stesso CODESEGM, possiamo effettuare una
NEAR call diretta intrasegmento in questo modo:
Come possiamo notare, l'assembler ha calcolato:
0FC8h - 0040h = 0F88h
Quando la CPU incontra l'istruzione CALL, prima di tutto sottrae
2 byte a SP e salva nello stack l'offset 0040h dell'istruzione
successiva; non è necessario salvare anche la componente Seg dell'indirizzo
di ritorno, in quanto il contenuto (CODESEGM) di CS resterà invariato
(salto intrasegmento). Successivamente, la CPU calcola:
0F88h + 0040h = 0FC8h
Il valore 0FC8h viene caricato in IP e viene effettuato un
salto diretto intrasegmento a CS:IP (cioè, a CODESEGM:0FC8h); come
si può notare, si tratta proprio dell'indirizzo iniziale di start_proc1.
Se abbiamo la certezza che il salto è diretto intrasegmento, possiamo anche
scrivere in modo esplicito:
call near ptr start_proc1
Vogliamo chiamare start_proc1 attraverso una NEAR call indiretta
intrasegmento; utilizzando, ad esempio, il registro CX, possiamo
scrivere:
Quando la CPU incontra l'istruzione CALL, prima di tutto sottrae
2 byte a SP e salva nello stack l'offset 0047h dell'istruzione
successiva; non è necessario salvare anche la componente Seg dell'indirizzo
di ritorno, in quanto il contenuto (CODESEGM) di CS resterà invariato
(salto intrasegmento). Successivamente, la CPU carica direttamente in
IP il valore 0FC8h contenuto in CX ed effettua un salto
indiretto intrasegmento a CS:IP (cioè, a CODESEGM:0FC8h); come si può
notare, si tratta proprio dell'indirizzo iniziale di start_proc1.
Al posto di CX possiamo utilizzare un dato di tipo WORD chiamato,
ad esempio, near_addr e definito all'offset 000Ah di un segmento
DATASEGM referenziato da DS; possiamo scrivere allora:
Quando la CPU incontra l'istruzione CALL, prima di tutto sottrae
2 byte a SP e salva nello stack l'offset 0053h dell'istruzione
successiva; non è necessario salvare anche la componente Seg dell'indirizzo
di ritorno, in quanto il contenuto (CODESEGM) di CS resterà invariato
(salto intrasegmento). Successivamente, la CPU accede all'indirizzo
DS:near_addr (cioè, a DS:000Ah), legge il valore 0FC8h, lo
carica direttamente in IP ed effettua un salto indiretto intrasegmento a
CS:IP (cioè, a CODESEGM:0FC8h); come si può notare, si tratta proprio
dell'indirizzo iniziale di start_proc1.
Per enfatizzare il fatto che near_addr contiene un indirizzo di tipo
NEAR, possiamo anche scrivere in modo esplicito:
call word ptr near_addr
Abbiamo una procedura start_proc2 che si trova all'offset 00B8h
di un segmento di programma chiamato LIBCODE; se vogliamo chiamare tale
procedura da un altro segmento CODESEGM, possiamo effettuare una FAR
call diretta intersegmento in questo modo:
Quando la CPU incontra l'istruzione CALL, prima di tutto sottrae
4 byte a SP e salva nello stack l'indirizzo completo
CODESEGM:005Ah dell'istruzione successiva; è necessario salvare anche
la componente Seg in quanto il contenuto (CODESEGM) di CS
verrà modificato (salto intersegmento).
Nel rispetto della convenzione relativa alla disposizione in memoria degli
indirizzi FAR, la CPU salva nello stack, prima la componente
Seg (cioè, CODESEGM) e poi la componente Offset (cioè,
005Ah) dell'indirizzo di ritorno; in questo modo, la componente
Offset si trova a precedere nello stack la componente Seg.
Successivamente, la CPU carica la coppia LIBCODE:00B8h direttamente
in CS:IP ed effettua un salto diretto intersegmento a CS:IP (cioè,
a LIBCODE:00B8h); come si può notare, si tratta proprio dell'indirizzo
iniziale di start_proc2.
Vogliamo chiamare start_proc2 attraverso una FAR call indiretta
intersegmento utilizzando, ad esempio, un dato di tipo DWORD chiamato
far_addr e definito all'offset 000Ch di un segmento DATASEGM2
referenziato da ES; possiamo scrivere allora:
Se non vogliamo inserire ogni volta il registro ES davanti a tutti gli
identificatori definiti in DATASEGM2, possiamo servirci di una direttiva
ASSUME che associa DATASEGM2 a ES.
Bisogna ribadire ancora una volta che il programmatore deve attenersi alla
convenzione relativa alla disposizione in memoria degli indirizzi FAR;
notiamo quindi che la componente Offset di start_proc2 viene
disposta nella WORD bassa di far_addr, mentre la componente
Seg di start_proc2 viene disposta nella WORD alta di
far_addr!
Quando la CPU incontra l'istruzione CALL, prima di tutto sottrae
4 byte a SP e salva nello stack l'indirizzo completo
CODESEGM:006Fh dell'istruzione successiva; è necessario salvare anche
la componente Seg in quanto il contenuto (CODESEGM) di CS
verrà modificato (salto intersegmento).
Successivamente, la CPU accede all'indirizzo ES:000Ch, legge la
coppia LIBCODE:00B8h, la carica direttamente in CS:IP ed effettua
un salto indiretto intersegmento a CS:IP (cioè, a LIBCODE:00B8h);
come si può notare, si tratta proprio dell'indirizzo iniziale di start_proc2.
Per enfatizzare il fatto che far_addr è un dato di tipo DWORD,
possiamo anche scrivere in modo esplicito:
call dword ptr es:far_addr
20.7.3 L'istruzione RET
Con il mnemonico RET si indica l'istruzione Return from Procedure
(ritorno da una procedura); questa istruzione è chiaramente complementare a
CALL e permette ad una procedura di restituire il controllo al chiamante.
L'istruzione RET è del tutto simile all'istruzione JMP; il suo
scopo quindi è quello di costringere la CPU a saltare, senza condizioni,
ad un determinato indirizzo di memoria Seg:Offset. La differenza
fondamentale tra JMP e RET sta nel fatto che incontrando una
istruzione RET, la CPU estrae dalla cima dello stack l'indirizzo
Seg:Offset da utilizzare come destinazione del salto; la logica vuole che
si tratti di un indirizzo inserito nello stack da una precedente istruzione
CALL. Ovviamente, dopo aver estratto l'indirizzo di ritorno dallo stack, la
CPU incrementa opportunamente il registro SP!
In modalità reale, le uniche forme lecite per l'istruzione RET sono le
seguenti:
La forma semplice (RET) comporta l'estrazione dallo stack del solo
indirizzo di ritorno; la forma speciale (RET Imm16) comporta la
somma:
SP = SP + Imm16
a cui fa seguito l'estrazione dallo stack dell'indirizzo di ritorno. La
forma speciale dell'istruzione RET viene utilizzata dai linguaggi di
alto livello, per eliminare eventuali variabili locali create nello stack
in fase di chiamata di una procedura; nei capitoli successivi, questo argomento
verrà analizzato in estremo dettaglio.
I codici macchina relativi ai vari casi, sono i seguenti:
Analizziamo subito i vari casi che si possono presentare; a tale proposito,
vediamo come si svolge il ritorno dalle procedure chiamate nei cinque esempi
presentati per l'istruzione CALL.
Nei primi tre esempi, la procedura start_proc1 è stata chiamata
attraverso una NEAR call intrasegmento; di conseguenza, la stessa
procedura deve terminare con un NEAR return. A tale proposito, viene
utilizzato il codice macchina C3h.
In presenza del codice macchina C3h, la CPU estrae una
WORD dallo stack, la carica in IP e, dopo aver incrementato
SP di 2 byte, salta a CS:IP; se il programmatore non ha
commesso errori nella gestione dello stack, la WORD estratta dalla
CPU coincide con l'indirizzo di ritorno inserito da una precedente
NEAR call!
In base alle conoscenze che abbiamo acquisito, possiamo facilmente simulare un
NEAR return in questo modo:
Nel quarto e quinto esempio, la procedura start_proc2 è stata chiamata
attraverso una FAR call intersegmento; di conseguenza, la stessa
procedura deve terminare con un FAR return. A tale proposito, viene
utilizzato il codice macchina CBh.
In presenza del codice macchina CBh, la CPU estrae una DWORD
dallo stack, la carica in CS:IP e, dopo aver incrementato SP di
4 byte, salta a CS:IP; se il programmatore non ha commesso errori
nella gestione dello stack, la DWORD estratta dalla CPU coincide
con l'indirizzo di ritorno inserito da una precedente FAR call!
Sfruttando il dato far_addr definito nel quinto esempio, possiamo
facilmente simulare un FAR return in questo modo:
Ancora una volta, bisogna osservare che questo esempio funziona in quanto
vengono rigorosamente seguite le convenzioni utilizzate dalla CPU per
la disposizione in memoria degli indirizzi FAR!
20.7.4 Esempio pratico per le istruzioni CALL e RET
Prima di illustrare un esempio pratico per le istruzioni CALL e
RET, è necessario analizzare un aspetto importante; tale aspetto
riguarda il fatto che la CPU deve essere messa nelle condizioni
di poter chiaramente distinguere tra il codice appartenente al programma
principale e il codice appartenente ad una procedura. Ci chiediamo
allora: qual'è l'area più adatta di un programma, dove poter inserire le
varie procedure?
Nel caso dei programmi eseguibili di tipo COM, sicuramente l'area
più indicata è quella che si trova oltre le istruzioni di terminazione;
come già sappiamo, è impossibile che la CPU possa finire per sbaglio
in tale area.
Nel caso dei programmi eseguibili di tipo EXE, possiamo adottare
varie soluzioni; anche in questo caso, l'area più indicata è quella che si
trova oltre le istruzioni di terminazione. In alternativa, possiamo disporre
le procedure anche prima dell'entry point; come sappiamo, tale
alternativa non è permessa nei programmi di tipo COM.
Una ulteriore soluzione consiste nel servirsi di un apposito segmento di
codice, diverso da quello che contiene il programma principale; è chiaro
che tutte le procedure definite in tale segmento, devono essere chiamate,
necessariamente, attraverso FAR call.
Generalizzando, possiamo pensare di disporre le varie procedure in appositi
segmenti che si trovano in uno o più moduli Assembly esterni; in tal
caso, otteniamo delle cosiddette librerie di procedure. Il vantaggio
offerto da questa soluzione, consiste nella possibilità di linkare le varie
librerie a tutti i programmi che ne hanno bisogno; questo argomento assume una
importanza enorme e verrà quindi trattato in estremo dettaglio nei capitoli
successivi.
Come esempio pratico, scriviamo un programma che illustra tutti i possibili
metodi di chiamata delle procedure; inoltre, visto che abbiamo acquisito
sufficienti conoscenze, possiamo sfruttare questo esempio per cominciare a
lavorare anche con indirizzi di tipo FAR per i dati.
Il programma di Figura 20.10 definisce cinque stringhe che dovranno essere
convertite in maiuscolo; le prime tre stringhe si trovano in un segmento dati
chiamato DATASEGM, mentre altre due stringhe si trovano in un secondo
segmento dati chiamato DATASEGM2. Il segmento DATASEGM viene
gestito attraverso DS; il segmento DATASEGM2 viene gestito
attraverso FS, in modo da non dover modificare ES (utilizzato
dalla procedura writeString).
La procedura start_maiuscole1 si trova nel segmento di codice principale
CODESEGM ed opera su stringhe che devono essere puntate dalla coppia
DS:BX; la procedura start_maiuscole2 si trova in un segmento di
codice chiamato LIBCODE ed opera su stringhe che devono essere puntate
dalla coppia FS:BX.
Analizzando il programma di Figura 20.10 possiamo notare che tutte le chiamate
a start_maiuscole1, che partono dall'interno di CODESEGM, sono
intrasegmento e richiedono quindi delle NEAR call; nell'esempio si
presuppone che start_maiuscole1 venga sempre chiamata con una NEAR
call e quindi la procedura termina con una istruzione RET che
viene sempre interpretata dall'assembler come NEAR return (codice
macchina C3h).
Tutte le chiamate a start_maiuscole2, che partono dall'interno di
CODESEGM, sono intersegmento e richiedono quindi delle FAR call;
nell'esempio si presuppone che start_maiuscole2 venga sempre chiamata
con una FAR call e quindi la procedura termina con un codice macchina
CBh che rappresenta un FAR return.
Il segmento dati DATASEGM viene referenziato da DS; di conseguenza,
tutti i dati definiti in DATASEGM possono essere gestiti attraverso
puntatori NEAR. Il segmento dati DATASEGM2 viene referenziato da
FS; di conseguenza, tutti i dati definiti in DATASEGM2 devono
essere gestiti, necessariamente, attraverso puntatori FAR.
Se inseriamo una direttiva ASSUME che associa DATASEGM2 a FS,
possiamo evitare di specificare ogni volta il registro FS davanti agli
identificatori creati nello stesso segmento DATASEGM2; in questo modo
possiamo scrivere, ad esempio:
call far_addr
La procedura start_maiuscole2 opera su stringhe che devono essere puntate
da FS:BX; in questo caso, siccome stiamo utilizzando i registri puntatori,
la direttiva ASSUME citata in precedenza non serve. Ogni volta che
dereferenziamo il registro puntatore BX, dobbiamo quindi specificare sempre
il segment override FS; se proviamo a scrivere:
mov al, [bx]
la CPU carica in AL un BYTE che viene letto dall'indirizzo
DS:BX e non da FS:BX!
Per garantire il corretto funzionamento di writeString, dobbiamo ricordare
che questa procedura visualizza una stringa che deve essere puntata da ES:DI;
sappiamo, inoltre, che il segmento DATASEGM viene gestito con DS, mentre
il segmento DATASEGM2 viene gestito con FS. Affinché writeString
visualizzi correttamente una delle stringhe definite in DATASEGM, dobbiamo
porre quindi ES=DS; analogamente, affinché writeString visualizzi
correttamente una delle stringhe definite in DATASEGM2, dobbiamo porre
ES=FS.
Osserviamo infine che le due procedure, start_maiuscole1 e
start_maiuscole2, prima di effettuare qualunque operazione, provvedono a
visualizzare il return address; ovviamente, appena si entra in una qualsiasi
procedura, il return address è disponibile a partire dall'indirizzo SS:SP
dello stack. Naturalmente, nel caso di start_maiuscole1 l'indirizzo di
ritorno è di tipo NEAR, mentre nel caso di start_maiuscole2
l'indirizzo di ritorno è di tipo FAR; per verificare la correttezza di
tali indirizzi, si può confrontare il contenuto del listing file CALLRET.LST,
con l'output prodotto dal programma CALLRET.EXE.
20.7.5 Effetti provocati dalle istruzioni CALL e RET sugli operandi e sui
flags
L'esecuzione dell'istruzione CALL non provoca alcuna modifica sull'unico
operando presente; lo stesso discorso vale per l'istruzione RET con
operando Imm16.
L'esecuzione delle istruzioni CALL e RET, in modalità reale, non
modifica alcun campo del Flags Register.
20.8 Le istruzioni INT,
INTO e
IRET
In un precedente capitolo è stato spiegato che in seguito ad una importantissima
convenzione adottata dai produttori di PC, i primi 1024 byte della
RAM devono essere rigorosamente riservati ai cosiddetti interrupt
vectors (vettori di interruzione); questi 1024 byte vengono suddivisi
in 256 locazioni (100h locazioni) da 4 byte ciascuna.
Ogni locazione da 4 byte può ospitare un indirizzo FAR, formato
quindi da una coppia Seg:Offset (con la componente Offset che
precede sempre la componente Seg); tale indirizzo, se è presente, prende
appunto il nome di vettore di interruzione. Un vettore di interruzione
rappresenta l'indirizzo di una procedura presente nella memoria RAM; tale
procedura prende il nome di ISR o Interrupt Service Routine
(procedura di servizio per una interruzione).
Il fatto che un vettore di interruzione sia un indirizzo Seg:Offset da
2+2 byte, implica il chiaro riferimento alla modalità reale 8086;
provando ad eseguire uno di questi vettori di interruzione in modalità protetta,
si provoca un sicuro crash. Con un indirizzo Seg:Offset da 2+2 byte,
possiamo accedere solo al primo MiB della RAM; ciò implica che le ISR
associate a tali indirizzi, debbano trovarsi anch'esse nello stesso primo MiB!
Numerosi vettori di interruzione (con le ISR associate) vengono installati
dal BIOS durante la fase di avvio del PC e dal DOS durante
la fase di avvio del SO; altri vettori di interruzione vengono installati
dai cosiddetti device drivers (piloti di dispositivo) che permettono ai
programmi di accedere alle periferiche del PC.
Naturalmente, è stata prevista anche la presenza di un certo numero di vettori di
interruzione, liberamente disponibili per le eventuali esigenze dei programmatori;
per l'elenco completo dei 256 vettori di interruzione, si può consultare
l'apposita Lista dei vettori di
interruzione in modalità reale.
Tutto ciò che riguarda i vettori di interruzione, verrà trattato in dettaglio
nella sezione Assembly Avanzato.
Nella modalità reale, i vettori di interruzione assumono una importanza enorme in
quanto rappresentano il metodo che permette ai programmi di interfacciarsi con i
servizi offerti dal BIOS, dal DOS o dai device drivers; i
programmi che intendono usufruire di tali servizi, non devono fare altro che
chiamare l'opportuno vettore di interruzione.
La Figura 20.11 illustra un semplice programma in formato COM che visualizza,
attraverso un loop, tutti i 256 vettori di interruzione presenti in
un PC; naturalmente, l'output di questo programma può differire da PC
a PC.
Quando una periferica del PC vuole comunicare con la CPU, invia
un apposito segnale che prende il nome di IRQ o interrupt request
(richiesta di interruzione); il procedimento che permette alla CPU di
elaborare questo tipo di richieste, è stato già esposto in un precedente
capitolo e verrà analizzato in dettaglio nella sezione Assembly Avanzato.
L'elaborazione, da parte della CPU, di una richiesta di interruzione
proveniente da una periferica, prende il nome di hardware interrupt
(interruzione hardware); come già sappiamo, tali interruzioni avvengono in
modo asincrono in quanto possono presentarsi in qualsiasi momento
della fase di esecuzione di un programma.
Esiste però anche la possibilità di richiedere interruzioni che avvengono in
modo sincrono con il programma in esecuzione; ciò accade quando è il
programma stesso a richiedere, ad esempio, un determinato servizio del
DOS o del BIOS, attraverso un apposito vettore di interruzione.
In un caso del genere, si parla di software interrupt (interruzione
software); in pratica, le interruzioni software vengono richieste in modo
consapevole da un programma in esecuzione, mentre le interruzioni hardware
possono presentarsi in qualsiasi momento e senza alcun preavviso per il
programma stesso.
L'elaborazione, da parte della CPU, di una qualsiasi richiesta di
interruzione, sia hardware, sia software, comporta in ogni caso la chiamata
di una apposita ISR; come sappiamo, il compito della ISR è
quello di soddisfare le richieste di chi ha effettuato la chiamata.
Dalle considerazioni appena esposte si deduce quindi che l'elaborazione di
una richiesta di interruzione presenta aspetti perfettamente analoghi al caso
della gestione di una procedura attraverso CALL e RET; siccome
però le ISR sono procedure speciali, le CPU della famiglia
80x86 mettono a disposizione apposite istruzioni rappresentate dai
mnemonici INT, INTO e IRET.
20.8.1 L'istruzione INT
Con il mnemonico INT si indica l'istruzione Call to Interrupt
Procedure (chiamata di una ISR); l'istruzione INT è del tutto
simile all'istruzione CALL in quanto il suo scopo è quello di chiamare
una procedura. La differenza fondamentale sta nel fatto che prima di effettuare
la chiamata specificata da INT, la CPU inserisce sulla cima dello
stack il contenuto del registro FLAGS seguito dall'indirizzo di ritorno;
prima di effettuare tali inserimenti, la CPU stessa decrementa opportunamente
il registro SP!
In modalità reale, l'unica forma lecita per l'istruzione INT è la seguente:
INT Imm8
Il valore immediato Imm8 deve essere compreso tra 00h e FFh
e indica naturalmente il vettore di interruzione da chiamare; come vedremo più
avanti, il caso Imm8=3 viene trattato in modo speciale dalla CPU.
Il codice macchina generale per l'istruzione INT è formato dall'Opcode
CDh seguito da un Imm8; quando la CPU incontra tale codice
macchina, effettua le seguenti operazioni:
- decrementa di 2 byte il registro SP
- salva nello stack il contenuto del registro FLAGS
- decrementa di 4 byte il registro SP
- salva nello stack l'indirizzo di ritorno completo Seg:Offset
- carica in CS:IP il vettore di interruzione n. Imm8 e
salta a CS:IP
Osserviamo subito che la CPU salva nello stack l'indirizzo di ritorno
completo Seg:Offset; tutto ciò è una logica conseguenza del fatto che
la chiamata di una ISR è sempre di tipo FAR!
Indicando con far_addr un dato a 32 bit referenziato da DS
e supponendo di avere ES=0000h, possiamo facilmente simulare una INT
Imm8 in questo modo:
(Siccome ogni vettore occupa 4 byte, il vettore n. Imm8 si trova
all'offset Imm8*4 del segmento di memoria n. 0000h).
L'istruzione INT permette ai normali programmi di generare una interruzione
software; in questo modo, è possibile richiedere numerosi servizi forniti,
principalmente, dal DOS e dal BIOS.
Nei precedenti capitoli abbiamo già conosciuto alcuni servizi offerti dal DOS;
la maggior parte dei servizi DOS sono ottenibili attraverso il vettore di
interruzione n. 21h (chiamato, appunto, INT dei servizi DOS).
Analizzando, ad esempio, i listati dei vari programmi di esempio presentati anche
in questo capitolo, possiamo notare la presenza del servizio 4Ch della
INT n. 21h; attraverso tale servizio, è possibile terminare un
programma e restituire il controllo al DOS.
In un precedente capitolo è stato utilizzato il servizio n. 09h, fornito
ugualmente dalla INT n. 21h; le caratteristiche di tale servizio
vengono riassunte dalla Figura 20.12.
In un segmento di programma referenziato da DS possiamo definire, ad
esempio, la stringa:
strTest db 'Stringa da visualizzare', '$'
A questo punto possiamo scrivere:
Osserviamo che, spesso, le ISR richiedono una lista di argomenti che il
programmatore deve passare attraverso i registri generali; inoltre, uno stesso
vettore può fornire numerosissimi servizi. Per un elenco completo di tutti i
servizi offerti dai 256 vettori di interruzione, si può consultare la
famosissima Ralf Brown's Interrupt List; a tale proposito, è presente
un link nella pagina dei
Siti con argomenti correlati
di questo sito.
Nel caso particolare di una istruzione INT che specifica un operando
Imm8=3, gli assembler generano un codice macchina formato dal solo Opcode
CCh; come già sappiamo, la INT n. 03h è stata espressamente
concepita per permettere ai debuggers di gestire facilmente i cosiddetti
breakpoint. In pratica, il programmatore che ha la necessità di testare un
proprio programma, può chiedere al debugger di eseguire il programma stesso sino
ad una determinata istruzione; per svolgere un tale lavoro, il debugger sostituisce
con il codice CCh, il primo byte dell'istruzione prescelta.
Consideriamo, ad esempio, l'istruzione:
mov di, offset strTitle
del programma di Figura 20.11; il codice macchina di tale istruzione è:
BFh 0170h
Se chiediamo l'inserimento di un breakpoint in quel preciso punto, il debugger
salva il primo byte (BFh) del codice macchina e lo sostituisce con
CCh, ottenendo quindi:
CCh 0170h
A questo punto, possiamo chiedere al debugger di avviare l'esecuzione del
programma INTLIST.COM; quando la CPU incontra il codice macchina
CCh, chiama automaticamente il vettore di interruzione n. 03h.
Il debugger ha precedentemente installato una propria ISR per la
gestione di tale INT; lo scopo di questa ISR è proprio quello di
interrompere il programma in esecuzione, mostrando lo stato che in quel preciso
istante viene assunto da tutti i registri della CPU.
Quando la fase di debugging è terminata, il debugger ripristina il precedente
codice macchina rimettendo BFh nel punto in cui era stato inserito il
valore CCh!
Osserviamo che diverse istruzioni Assembly (come PUSHF), possono
avere un codice macchina formato da un solo byte; grazie al fatto che anche la
INT 03h ha un codice macchina formato da un solo byte (mentre le altre
INT utilizzano due byte), si evita il rischio di sovrascrivere
l'istruzione successiva al breakpoint!
20.8.2 L'istruzione INTO
Con il mnemonico INTO si indica l'istruzione Call to Interrupt
Procedure on Overflow (chiamata di una ISR in caso di overflow);
lo scopo di questa istruzione è quello di far generare alla CPU una
INT 04h nel caso in cui una operazione logico aritmetica appena eseguita
abbia provocato un overflow.
In modalità reale, l'unica forma lecita per l'istruzione INTO è la seguente:
INTO
Il codice macchina associato a questa istruzione è formato dal solo Opcode
CEh; quando la CPU incontra questo codice macchina, verifica lo stato
dell'overflow flag per poter decidere il comportamento da seguire:
- se OF=0, l'esecuzione del programma prosegue con l'istruzione
successiva a INTO
- se OF=1, viene chiamato automaticamente il vettore di interruzione
n. 04h
In sostanza, si tratta di una situazione perfettamente analoga a quella relativa
alla INT 03h; il programmatore può installare una propria ISR per
poter adattare alle proprie esigenze la gestione dei casi di overflow.
Si tenga presente che generalmente, il DOS non installa alcuna ISR
predefinita per la gestione delle INT come le 03h e 04h;
spesso, tali vettori di interruzione puntano ad una locazione di memoria
contenente una semplice istruzione IRET (ritorno da una ISR).
20.8.3 L'istruzione IRET
Con il mnemonico IRET si indica l'istruzione Interrupt Return
(ritorno da una ISR); l'istruzione IRET è del tutto simile
all'istruzione RET in quanto il suo scopo è quello di permettere ad
una procedura di restituire il controllo al chiamante. La differenza
fondamentale sta nel fatto che in presenza di una istruzione IRET,
la CPU estrae dalla cima dello stack l'indirizzo di ritorno e una
ulteriore WORD che viene caricata nel registro FLAGS; dopo
aver effettuato tali estrazioni, la CPU stessa incrementa opportunamente
il registro SP!
In modalità reale, l'unica forma lecita per l'istruzione IRET è la seguente:
IRET
Il codice macchina per l'istruzione IRET è formato dal solo Opcode
CFh; quando la CPU incontra tale codice macchina, effettua le seguenti
operazioni:
- estrae dallo stack l'indirizzo di ritorno completo Seg:Offset
- incrementa di 4 byte il registro SP
- estrae dallo stack una WORD e la carica nel registro FLAGS
- incrementa di 2 byte il registro SP
- carica l'indirizzo di ritorno in CS:IP e salta a CS:IP
Una ISR è tenuta rigorosamente a restituire il controllo attraverso una
istruzione IRET; infatti, una istruzione RET di tipo FAR,
non ripristina il registro FLAGS, provocando così un sicuro crash (a causa
della WORD rimasta nello stack)!
Indicando con far_addr un dato a 32 bit referenziato da DS,
possiamo facilmente simulare una IRET in questo modo:
20.8.4 Esempio pratico sulla gestione delle ISR
In base alle considerazioni esposte in precedenza, appare evidente che il
programmatore deve evitare nella maniera più assoluta, la modifica casuale
degli indirizzi contenuti nei vettori di interruzione; è anche chiaro il
fatto che l'installazione di ISR personalizzate, per la gestione di
vettori di interruzione riservati al DOS, al BIOS o ai
Device Drivers, è un compito che compete ai programmatori
particolarmente esperti (principalmente, programmatori di sistemi operativi)!
Se si ha la necessità di installare ISR personalizzate, per scopi
"meno importanti", ci si può servire dei vettori di interruzione marcati
come "reserved for user interrupt"; è anche possibile installare
proprie ISR, destinate ad intercettare particolari INT generate
dalla CPU come la 00h (Divide Error), la 01h
(Single Step), la 03h (Breakpoint), la 04h
(INTO), etc.
Prima di analizzare alcuni esempi pratici, è necessario definire il
procedimento che deve essere rigorosamente seguito per la corretta gestione
di una ISR; tale procedimento può essere suddiviso nelle seguenti fasi,
riferite ad una generica INT n:
- avvio del programma
- salvataggio dell'indirizzo originale della INT n
- installazione della nuova ISR per la INT n
- esecuzione del programma
- ripristino dell'indirizzo originale della INT n
- terminazione del programma
Inoltre, è importante che la ISR preservi il contenuto di tutti i
registri che deve utilizzare; questo aspetto diventa fondamentale nel caso
delle ISR destinate a gestire interruzioni hardware.
Naturalmente, nel preservare il contenuto di tutti i registri utilizzati,
il programmatore deve anche garantire una corretta gestione dello stack
all'interno della ISR; in caso contrario, l'istruzione IRET
estrae dallo stack valori privi di senso ed effettua un salto ad un indirizzo
sbagliato, con conseguente crash del programma!
Analizziamo un programma di esempio che mostra come intercettare la INT
01h (Single Step); come sappiamo, se poniamo TF=1 (Trap
Flag), la CPU genera automaticamente una INT 01h per ogni
istruzione che esegue. I debugger intercettano la INT 01h e rendono
possibile così l'esecuzione a "passo singolo" di un programma; la Figura
20.13
mostra un programma in formato COM, che intercetta la INT 01h e
si "autodebugga" da solo.
Osserviamo che il flag TF si trova in posizione 8 nel registro
FLAGS; di conseguenza:
OR FLAGS, 0000000100000000b pone TF=1 e lascia inalterati tutti
gli altri flags;
AND FLAGS, 1111111011111111b pone TF=0 e lascia inalterati tutti
gli altri flags.
Dal punto in cui viene posto TF=1, al punto in cui viene posto TF=0,
sono presenti 9 istruzioni; infatti, la stringa strDebug viene
visualizzata (da new_int01h) 9 volte!
È importantissimo notare che la INT 01h non viene chiamata per le
istruzioni interne a new_int01h; ciò è una diretta conseguenza del
comportamento seguito dalla CPU nella elaborazione delle interruzioni.
Al momento di chiamare una ISR, la CPU disabilita tutte le
INT mascherabili; tali INT vengono riabilitate al termine della
ISR stessa!
Se la CPU non adottasse questo accorgimento, una ISR come
new_int01h finirebbe per chiamare se stessa; in tal caso, si innescherebbe
un loop infinito con conseguente blocco del programma!
È importante ribadire che le ISR devono preservare tutti i registri che
utilizzano; in caso contrario, si può andare incontro ad un sicuro crash dovuto al
fatto che la ISR "sporca" il contenuto di registri in uso al chiamante!
Nel nostro caso, si può notare che new_int01h modifica il contenuto di
DI; il programma principale, a sua volta, si aspetta che al termine di
new_int01h, il registro DI contenga ancora il valore 0004h!
All'interno di new_int01h, vengono chiamate procedure come,
writeString, writeHex16, waitChar, le quali provvedono
anch'esse a preservare il contenuto di tutti i registri che utilizzano; la
procedura waitChar restituisce AX modificato, per cui questo
registro deve essere preservato dalla new_int01h.
Come esercitazione pratica, si consiglia di scrivere un programma analogo a
INT01h.ASM, destinato però ad intercettare la INT 04h
(Overflow); a tale proposito, dopo ogni operazione logico aritmetica
che vogliamo "monitorare", dobbiamo inserire una istruzione INTO.
Si noti, inoltre, che il vettore relativo alla INT 04h, si trova
all'offset 4*4=16=10h del segmento di memoria 0000h.
20.8.5 Effetti provocati dalle istruzioni INT, INTO e IRET, sugli
operandi e sui flags
L'esecuzione di una istruzione INT con operando Imm8, non provoca,
ovviamente, alcuna modifica sullo stesso operando.
L'esecuzione delle istruzioni INT e INTO pone OF=0 e
TF=0; tutti gli altri campi del registro dei flags rimangono inalterati.
L'esecuzione dell'istruzione IRET altera, ovviamente, tutti i campi del
registro dei flags.
Al momento di elaborare una richiesta di interruzione, la CPU
disabilita automaticamente tutte le INT mascherabili; tali INT
vengono riabilitate al termine della elaborazione dell'interruzione stessa.
20.9 Allineamento degli indirizzi di destinazione dei salti
In un precedente capitolo è stato detto che sarebbe assurdo pensare di allineare
correttamente in memoria tutte le istruzioni di un programma; se vogliamo
affrontare questo problema, la strada migliore da seguire consiste nel garantire
l'allineamento solo per quelle istruzioni che si trovano in posizioni delicate.
Tra queste particolari istruzioni, troviamo sicuramente quelle che vengono
raggiunte dalla CPU attraverso un salto; allineando tali istruzioni in
modo opportuno, si permette alla CPU di ricaricare la prefetch queue
nel modo più rapido possibile!
Nel capitolo 10 è stato spiegato che i diversi modelli di CPU della
famiglia 80x86, caricano i blocchi di codice nella prefetch queue,
in base a precisi metodi di allineamento (allineamento di prefetch); per
comodità, viene riportata in Figura 20.14 la tabella relativa all'allineamento di
prefetch nei principali modelli di CPU.
In pratica, una CPU 8086, 80186, 80286 o 80386 SX,
carica le istruzioni nella prefetch queue, a blocchi da 16 bit
ciascuno; per le CPU 80386 DX i blocchi sono da 32 bit (4
byte), mentre per le CPU 80486 DX i blocchi sono da 16 byte.
Consideriamo allora il caso di una CPU 80386 DX; prima di tutto, è
importante che i segmenti di codice vengano allineati almeno alla DWORD
(indirizzi fisici multipli interi di 4 byte). A questo punto, possiamo
facilmente allineare alla DWORD tutte le istruzioni raggiungibili
attraverso un salto; tali istruzioni, sono principalmente quelle che delimitano
l'inizio di un loop o di una procedura.
Supponendo, ad esempio, di avere un loop che inizia da una etichetta di nome
start_loop, possiamo scrivere:
Analogamente, nel caso delle CPU 80486 DX, bisogna innanzi tutto
allineare i segmenti di codice al paragrafo (indirizzi fisici multipli interi
di 16 byte); a questo punto, davanti a tutte le istruzioni che vogliamo
allineare, dobbiamo inserire una direttiva del tipo:
ALIGN 16 ; allinea al paragrafo (con NOP)
Nel caso delle CPU 80486 e inferiori, questi accorgimenti possono
portare ad un aumento veramente notevole della velocità di esecuzione; nel
caso, invece, delle CPU di classe Pentium, la presenza di
dispositivi come il branch prediction, la doppia pipeline, etc,
permette di rendere meno importante il problema dell'allineamento delle
istruzioni.