Assembly Base con MASM
Capitolo 24: Operatori, direttive e macro
Gli assembler più evoluti come MASM e NASM, mettono a disposizione
una serie di potenti strumenti che spesso si rivelano di grande aiuto per il
programmatore; questi strumenti possono essere suddivisi in tre grandi categorie
chiamate: operatori, direttive e macro.
Gli operatori ci permettono di creare complesse espressioni matematiche da
inserire all'interno di un programma Assembly; è possibile costruire
espressioni valutabili in fase di assemblaggio o in fase di esecuzione di un
programma.
Le direttive hanno principalmente lo scopo di permettere al programmatore di
impartire ordini all'assembler modificandone la modalità operativa; in questo capitolo
esamineremo, in particolare, le direttive condizionali che ci permettono di creare
costrutti sintattici simili a quelli dei linguaggi di alto livello.
Attraverso le macro è possibile rappresentare con un nome simbolico, intere
porzioni di un programma Assembly; in questo capitolo vengono anche illustrate
le macro predefinite messe a disposizione da MASM.
24.1 Gli operatori per la fase di assemblaggio
Iniziamo con una serie di operatori chiamati assembler time operators (operatori
per la fase di assemblaggio); questa definizione è legata al fatto che tali operatori,
compaiono in espressioni che devono poter essere valutate dall'assembler in fase di
assemblaggio del programma.
In sostanza, l'assembler deve avere la possibilità di analizzare e risolvere queste
espressioni, ricavandone alla fine un valore numerico (risultato dell'espressione) che
verrà inserito direttamente nel codice macchina; proprio per questo motivo, gli
assembler time operators possono comparire solo in espressioni comprendenti
operandi di tipo Imm (espressioni costanti).
Non è possibile quindi utilizzare operandi di tipo Reg o Mem; è chiaro,
infatti, che l'assembler non può conoscere in anticipo il contenuto che verrà assunto
da un registro o da una variabile in fase di esecuzione del programma.
Nei precedenti capitoli abbiamo visto che è vivamente sconsigliabile l'utilizzo di
operandi immediati sotto forma di numeri espliciti; la cosa più intelligente da
fare consiste allora nel servirsi di nomi simbolici.
Il modo più semplice per creare un operando immediato, gestibile attraverso un
nome simbolico, consiste nell'utilizzare la direttiva di assegnamento "uguale":
=
La sintassi generica per questa direttiva è la seguente:
nomesimbolico = espressione
In questo caso con il termine espressione si indica un valore immediato o una
qualunque espressione matematica formata da operatori dell'Assembly e da operandi
immediati; nel caso più semplice possiamo scrivere, ad esempio:
LUNGHEZZA = 3500
In questo caso LUNGHEZZA è il nome simbolico dell'operando immediato, mentre
3500 è il valore immediato che la direttiva = assegna all'operando
stesso.
Il nome simbolico LUNGHEZZA può essere utilizzato in qualunque punto del
programma; ogni volta che l'assembler incontra questo nome, provvede a sostituirlo
con il valore 3500.
Al posto di = si può anche utilizzare la direttiva EQU; questa direttiva
viene descritta più avanti. La differenza sostanziale che esiste tra = e
EQU sta nel fatto che = permette solo di assegnare valori numerici ad un
nome simbolico; la direttiva EQU, invece, è molto più potente in quanto permette
di rappresentare con un nome simbolico, sia valori numerici, sia stringhe alfanumeriche,
dalle più semplici alle più complesse!
Un nome simbolico al quale è stato assegnato un valore con = può essere
ridichiarato più volte (sempre con =) e in qualunque altro punto del
programma; nel caso di EQU, invece, è possibile ridichiarare solo nomi simbolici
che rappresentano stringhe.
Nel seguito del capitolo e nei capitoli successivi, per le dichiarazioni di operandi di
tipo numerico verrà utilizzata sempre la direttiva =; in ogni caso, ciascun
programmatore è libero di scegliere la strada che più si addice ai propri gusti.
Tutti gli operatori dell'Assembly lavorano su operandi immediati di tipo intero;
questo significa che non sono permesse dichiarazioni di operandi immediati del tipo:
PI_GRECO = 3.14
Le ampiezze in bit degli operandi immediati non possono superare le capacità della
CPU che si sta utilizzando; non è possibile quindi dichiarare operandi immediati
a 32 bit con una CPU a 16 bit.
24.1.1 Operatori aritmetici
Analizziamo in dettaglio i vari gruppi di operatori partendo da quelli aritmetici;
nelle tabelle che seguono, i termini expr1, expr2, expr3, etc,
indicano generiche espressioni costanti che l'assembler deve poter risolvere.
Vediamo alcuni esempi pratici che illustrano il funzionamento di questi operatori;
supponiamo di avere le seguenti dichiarazioni:
Quando l'assembler incontra queste dichiarazioni cerca di analizzarle e di risolverle
in modo da assegnare al primo membro di ciascuna di esse, un ben preciso valore
numerico; nel seguito della fase di assemblaggio, ogni volta che l'assembler incontra
uno di questi nomi simbolici, lo sostituisce con il corrispondente valore numerico.
Innanzi tutto osserviamo che:
- al nome simbolico OPERANDO1 viene assegnato il valore numerico positivo
+12000
- al nome simbolico OPERANDO2 viene assegnato il valore numerico positivo
+2
- al nome simbolico OPERANDO3 viene assegnato il valore numerico negativo
-2
Utilizzando ora gli operandi immediati OPERANDO1, OPERANDO2 e
OPERANDO3 (che devono essere già stati dichiarati), possiamo costruire espressioni
più complesse come: EXPR1, EXPR2, EXPR3, EXPR4, EXPR5
e EXPR6 mostrate nella tabella precedente.
Per assegnare un valore numerico ai nomi simbolici che rappresentano queste espressioni,
l'assembler deve effettuare una serie di calcoli. A tale proposito, notiamo che è permesso
l'uso delle parentesi tonde attraverso le quali possiamo stabilire l'ordine di valutazione
delle espressioni; le parentesi tonde permettono anche di alterare l'ordine di precedenza
degli operatori (che verrà illustrato più avanti).
Si può facilmente constatare che per EXPR1 l'assembler calcola:
EXPR1 = (12000 + 2) - (-2) = 12002 + 2 = +12004
Per EXPR2 l'assembler calcola:
EXPR2 = (2 - 12000) - (-2) = -11998 + 2 = -11996
Per EXPR3 l'assembler calcola:
EXPR3 = -2 - 12000 = -12002
Per EXPR4 l'assembler calcola:
EXPR4 = (12000 * (-2)) / 2 = -24000 / 2 = -12000
Per EXPR5 l'assembler calcola:
EXPR5 = ((12000 - 2) * (-2)) / 2 = (11998 * (-2)) / 2 = -23996 / 2 = -11998
Per EXPR6 l'assembler calcola:
EXPR6 = -(12000 * (-2)) = -(-24000) = +24000
Tenendo conto del fatto che stiamo operando su numeri interi, appare chiaro che
l'operatore / si riferisce alla divisione intera, la quale provoca il
troncamento di tutte le cifre che nel risultato (quoziente) figurano dopo la
virgola; nel caso, ad esempio, di:
2033 / 2
si ottiene 1016.5 che, dopo il troncamento della parte decimale, diventa
1016!
Analogamente, l'operatori MOD (modulo) restituisce il resto della divisione
intera tra due operandi.
Le espressioni del tipo appena illustrato, possono essere inserite anche all'interno
delle istruzioni Assembly; naturalmente, anche in questo caso l'assembler deve
essere in grado di convertire queste espressioni in valori immediati.
Nel precedente capitolo, ad esempio, abbiamo visto come si deve procedere per calcolare
l'offset dell'ultimo elemento di un vettore; a tale proposito, supponiamo di aver
definito un vettore di nome vectDword formato da MAX_ELEM elementi di tipo
DWORD, con:
MAX_ELEM = 30
A questo punto, se vogliamo caricare in BX l'offset dell'ultimo elemento di
vectDword possiamo scrivere:
mov bx, offset vectDword + ((MAX_ELEM - 1) * 4)
Notiamo, infatti, che (MAX_ELEM - 1) è l'indice dell'ultimo elemento di
vectDword e 4 è la dimensione in byte di ciascun elemento.
Ricordiamo che OFFSET è un operatore dell'Assembly che, applicato al nome
di una variabile, ne restituisce il relativo offset (che è un valore immediato); se, ad
esempio, vectDword si trova all'offset 00BAh del blocco dati, la precedente
istruzione assegna a BX il valore:
00BAh + ((30 - 1) * 4) = 00BAh + (29 * 4) = 00BAh + 116 = 00BAh + 0074h = 012Eh
L'assembler genera quindi il codice macchina:
BBh 012Eh(r)
Quando la CPU incontra tale codice macchina, carica in BX il valore
immediato 012Eh (eventualmente rilocato dal linker).
Un altro esempio può essere rappresentato da:
mov ax, ((OPERANDO1 - OPERANDO2) * OPERANDO3) / OPERANDO2
In base ai valori assegnati in precedenza a questi tre operandi, possiamo dire che
l'assembler produrrà il codice macchina dell'istruzione che carica in AX il numero
negativo -11998.
Possiamo utilizzare espressioni di qualunque complessità senza il rischio di provocare
ripercussioni negative sul programma (in termini di prestazioni o di dimensioni del
codice); ricordiamoci, infatti, che tutti i calcoli vengono svolti dall'assembler,
che in fase di assemblaggio del programma provvede a risolvere queste espressioni
sostituendole con il corrispondente risultato (valore immediato).
Nel codice macchina del programma, quindi, non rimane alcuna traccia di tutti questi
calcoli (ad eccezione del risultato finale); come al solito, se vogliamo verificare il
lavoro svolto dall'assembler, ci basta richiedere la generazione del listing file.
Nel dichiarare espressioni come quelle presentate nei precedenti esempi, il
programmatore deve prestare molta attenzione alla corretta gestione del segno e
dell'ampiezza in bit dei vari operandi di tipo Imm; vediamo subito un esempio
pratico.
Analizzando queste istruzioni appare evidente l'intenzione del programmatore di
voler operare sui numeri interi con segno a 16 bit in complemento a 2;
in presenza di una CPU a 32 bit, l'assembler assegna a PRODOTTO
il numero intero negativo -62000, ma in fase di esecuzione del programma, la
procedura writeSdec16 visualizza il numero intero positivo +3536!
L'errore commesso dal programmatore è abbastanza chiaro; il valore -62000
richiede almeno 32 bit per essere rappresentato in modo corretto; in
esadecimale, la codifica di -62000 a 32 bit in complemento a 2
è FFFF0DD0h. Per caricare tale valore in AX l'assembler tronca i
16 bit più significativi ottenendo così AX=0DD0; ma per i numeri
interi con segno a 16 bit in complemento a 2, 0DD0h codifica
proprio il numero positivo +3536!
24.1.2 Operatori logici
La tabella seguente mostra gli operatori logici messi a disposizione da MASM;
naturalmente, questi operatori non devono essere confusi con le omonime istruzioni
Assembly!
Così come accade per le istruzioni omonime, anche gli operatori logici
dell'Assembly agiscono sui singoli bit dei loro operandi immediati; questo
significa che se abbiamo, ad esempio, le seguenti dichiarazioni:
allora:
EXPR1 = NOT VAL1
assegna a EXPR1 il numero binario 01010101b.
EXPR2 = VAL1 OR VAL2
assegna a EXPR2 il numero binario 11111111b.
EXPR3 = VAL1 XOR VAL3
assegna a EXPR3 il numero binario 10100101b.
EXPR4 = VAL2 AND VAL3
assegna a EXPR4 il numero binario 00001010b.
mov ax, VAL1 AND (NOT VAL3)
fa generare all'assembler il codice macchina dell'istruzione che carica in AX
il numero binario 10100000b.
24.1.3 Operatori di scorrimento dei bit
La tabella seguente mostra gli operatori di scorrimento dei bit messi a disposizione
da MASM; anche in questo caso non bisogna confondere questi operatori con le
istruzioni omonime dell'Assembly.
Questi due operatori trattano il loro operando di sinistra come un numero senza segno
(scorrimento logico dei bit); non esistono quindi operatori per lo scorrimento aritmetico
dei bit, che tengono cioè conto del bit di segno dell'operando.
Nello scorrimento verso destra, i bit dell'operando che traboccano da destra vengono
persi; nello scorrimento verso sinistra, l'assembler finché è possibile cerca di
estendere l'ampiezza in bit del risultato.
Se abbiamo, ad esempio, la seguente dichiarazione:
VAL1 = 10101010b
allora l'espressione:
EXPR1 = VAL1 SHL 1
assegna a EXPR1 il numero binario a 16 bit 0000000101010100b; come
si può notare, l'assembler tratta EXPR1 come una costante a 16 bit tale
da poter contenere interamente il risultato prodotto da SHL. Appare chiaro che
tutto ciò ha un limite rappresentato dall'ampiezza massima in bit gestibile dalla
CPU che stiamo utilizzando; se abbiamo, ad esempio, una CPU a 16
bit e scriviamo:
verrà caricato in AX il valore zero!
Infatti, EXPR1 non può avere un'ampiezza superiore a 16 bit e quindi
non è in grado di contenere il risultato completo a 17 bit dello scorrimento
verso sinistra; il bit più significativo del risultato trabocca da sinistra e viene
perso, per cui a EXPR1 verrà assegnato il numero binario a 16 bit
0000000000000000b!
Un aspetto curioso degli operatori SHL e SHR è dato dal fatto che il
contatore che indica il numero di scorrimenti di bit da effettuare, può essere anche
un numero negativo; in questo caso si produce l'effetto di far scorrere i bit nel
verso opposto a quello indicato dall'operatore!
Se scriviamo, ad esempio:
otteniamo l'assegnamento a EXPR1 del numero binario 01010101b; come si
può notare, il contatore negativo ha fatto scorrere i bit di VAL1 verso destra
anziché verso sinistra!
24.1.4 Operatori relazionali
La tabella seguente mostra gli operatori relazionali messi a disposizione da
MASM; attraverso questi operatori è possibile confrontare tra loro operandi
immediati o espressioni complesse contenenti operandi immediati.
Combinando tra loro due o più espressioni costanti attraverso gli operatori
relazionali, si ottiene una cosiddetta espressione booleana; come già
sappiamo, il risultato di una espressione booleana può essere vero o falso
(TRUE o FALSE).
Convenzionalmente, in tutti i linguaggi di programmazione (compreso l'Assembly),
un risultato FALSE viene associato al valore zero; un risultato TRUE,
invece, viene associato ad un valore diverso da zero (valore non nullo) stabilito
dalle convenzioni standard del linguaggio che si sta usando.
Il programmatore deve solo ricordarsi di questa regola e non ha la necessità di
conoscere il valore numerico esatto associato a TRUE (a titolo di curiosità,
MASM assegna a TRUE il valore simbolico -1); si può dire anzi
che è vivamente sconsigliabile la scrittura di programmi che fanno esplicito
riferimento al valore numerico assegnato a TRUE!
Supponiamo di avere:
In questo caso, tenendo conto del fatto che VAL1 è chiaramente minore
(LT) di VAL2, si ottiene l'assegnamento di FALSE (zero) a
EXPR1 e EXPR3, mentre a EXPR2 verrà assegnato TRUE
(non zero).
Gli operatori relazionali esprimono tutta la loro potenza quando vengono usati in
combinazione con le direttive condizionali IF, ESLSEIF, etc; tali
direttive vengono illustrate più avanti.
In questo paragrafo sono stati illustrati solo alcuni dei numerosissimi operatori
messi a disposizione da MASM; nei precedenti capitoli abbiamo già conosciuto
diversi operatori dell'Assembly come SEG, OFFSET,
BYTE PTR, WORD PTR, etc. Altri operatori importanti verranno illustrati
nei prossimi capitoli; per ulteriori dettagli si consiglia di consultare i manuali di
MASM reperibili anche attraverso Internet.
24.1.5 Ordine di precedenza degli operatori
In conclusione di questo paragrafo analizziamo la tabella relativa all'ordine di
precedenza dei vari operatori dell'Assembly; gli operatori appartenenti alla
stessa riga hanno uguale precedenza, mentre le varie righe indicano i diversi ordini
di precedenza, dal più alto (riga 1) al più basso (riga 13).
I diversi ordini di precedenza dei vari operatori, permettono all'assembler di
valutare in modo corretto istruzioni del tipo (esempio del paragrafo 24.1.1):
mov bx, offset vectDword + ((MAX_ELEM - 1) * 4)
Siccome il calcolo dell'offset ha precedenza maggiore dell'operatore +
binario, l'assembler determina innanzi tutto l'offset di vectDword, poi
calcola:
(MAX_ELEM - 1) * 4 = (30 - 1) * 4 = 29 * 4 = 116
e infine somma il valore 116 all'offset di vectDword; se + avesse
avuto precedenza maggiore del calcolo dell'offset avremmo ottenuto un'istruzione senza senso
indicante all'assembler di sommare 116 a vectDword e calcolare poi l'offset
del risultato della somma!
L'ordine di precedenza dei vari operatori è una caratteristica importantissima di tutti
i linguaggi di programmazione, i quali forniscono sempre un'apposita tabella relativa a
questo aspetto; in ogni caso, un programmatore avveduto e responsabile non dovrebbe
fidarsi ciecamente di tali tabelle!
Un compilatore (o un assemblatore) che non gestisce correttamente gli ordini di precedenza
dei vari operatori, può generare programmi contenenti dei bug difficilissimi da scovare;
in caso di dubbio, si consiglia vivamente di ricorrere alle parentesi tonde che permettono
di specificare in modo esplicito l'ordine di valutazione di un'espressione e, soprattutto,
rendono molto più chiaro il codice.
24.2 Gli operatori per la fase di esecuzione
Le versioni più recenti di MASM forniscono una serie di potenti operatori
utilizzabili in fase di esecuzione di un programma; per questo motivo si parla
anche di run time operators (operatori per la fase di esecuzione).
Tutti questi operatori risultano molto familiari ai programmatori C/C++ in quanto
utilizzano la classica sintassi degli operatori relazionali e logici di tali linguaggi;
la tabella seguente mostra i run time operators più importanti (i simboli op1 e
op2 indicano generici operandi di tipo Reg, Mem o anche Imm).
Anche in questo caso, quindi, abbiamo a che fare con una serie di operatori
relazionali, booleani e logici; la differenza sostanziale rispetto agli assembler
time operators è data dal fatto che con i run time operators possiamo utilizzare,
non solo operandi di tipo Imm, ma anche operandi di tipo Reg e
Mem!
I run time operators possono essere utilizzati esclusivamente in combinazione con una
serie di apposite direttive per il controllo del flusso; queste direttive vengono
illustrate più avanti.
L'uso dei run time operators di tipo relazionale è abbastanza intuitivo; ad esempio,
l'espressione:
(ax == bx)
restituisce TRUE se e solo se il contenuto di AX coincide con il contenuto
di BX, mentre in caso contrario si ottiene FALSE.
Analogamente, l'espressione:
(ax < bx)
restituisce TRUE se e solo se il contenuto di AX è minore del contenuto
di BX; in caso contrario si ottiene FALSE.
Ricordiamoci della importante differenza che esiste tra gli operatori logici
bit a bit e gli operatori booleani; a tale proposito, si veda quanto spiegato
al paragrafo 19.15 del Capitolo 19.
Per maggiore chiarezza, vediamo un semplice esempio pratico; consideriamo le
seguenti istruzioni:
Si può facilmente constatare che:
al & bl = 00000000b ; AND logico bit a bit)
mentre:
al && bl = TRUE ; AND booleano
Una espressione come:
!cx
è di tipo booleano e non logico e quindi restituisce TRUE se e solo se il
contenuto di CX vale zero; se, invece, il contenuto di CX è diverso
da zero, si ottiene FALSE.
Un'ultima considerazione da fare riguarda gli assembler time operators + binario,
- binario e * binario; come abbiamo già visto nei precedenti capitoli,
questi tre operatori possono essere utilizzati anche a run time per gestire gli
indirizzamenti!
Con tutte le CPU della famiglia Intel e compatibili si può scrivere,
ad esempio:
mov ax, [di+2]
Questa istruzione trasferisce in AX un valore a 16 bit che si trova
all'offset DI+2 del segmento di programma referenziato da DS.
Analogamente, l'istruzione:
mov ax, es:[di-2]
trasferisce in AX un valore a 16 bit che si trova all'offset DI-2
del segmento di programma referenziato da ES.
Nel Capitolo 11 abbiamo anche visto che con le CPU 80386 e superiori è inoltre
possibile utilizzare l'operatore * per gestire il nuovo formato di indirizzamento
S.I.B. (Scale Index Base); usando, ad esempio, EBP come registro base,
EDI come registro indice, 4 come fattore di scala e la costante simbolica
DISP32 come spiazzamento, possiamo scrivere l'istruzione:
mov eax, [ebp + (edi * 4) + DISP32]
Tutti questi calcoli verranno eseguiti dalla CPU in fase di esecuzione del
programma; si noti che le parentesi tonde sono superflue in quanto * ha
precedenza maggiore di + binario!
24.3 Le direttive
Nei precedenti capitoli abbiamo già incontrato numerose direttive dell'Assembly;
possiamo citare, ad esempio, ASSUME, SEGMENT, ALIGN, END,
INCLUDE, .8086, .386, DB, DW, etc. Altre direttive
verranno illustrate al momento opportuno nei capitoli successivi; in questo capitolo,
invece, verranno analizzate principalmente le direttive condizionali che si
utilizzano in combinazione con gli operatori a run time e ad assembler
time.
24.3.1 Le direttive condizionali per la fase di assemblaggio
Il primo gruppo di assembler time directives che andiamo ad esaminare comprende le
direttive IF, ELSEIF, ELSE e ENDIF; queste direttive
ci permettono di effettuare, in fase di assemblaggio, una scelta tra più opzioni
possibili.
Vediamo un esempio pratico che illustra l'utilizzo abbastanza intuitivo di queste
direttive; supponiamo di avere le seguenti dichiarazioni:
A questo punto, nel blocco codice del nostro programma possiamo scrivere:
Questa sequenza dice all'assembler che:
- se la prima espressione è TRUE, bisogna assemblare solamente la prima
istruzione (caricare 10 in AX)
- se, invece, la prima espressione è FALSE e la seconda è TRUE,
bisogna assemblare solamente la seconda istruzione (caricare 20 in AX)
- altrimenti (entrambe le precedenti espressioni sono FALSE), bisogna
assemblare solamente la terza istruzione (caricare 30 in AX)
In fase di assemblaggio del programma, l'assembler rileva che VAL1 è minore di
VAL2, per cui la prima espressione booleana è TRUE; la conseguenza pratica
è data dal fatto che verrà assemblata esclusivamente l'istruzione:
mov ax, 10
Per verificarlo possiamo consultare il listing file prodotto dall'assembler.
In sostanza, attraverso queste direttive possiamo indicare all'assembler quali porzioni
del nostro programma devono essere assemblate e quali no!
In effetti, le direttive appena illustrate vengono utilizzate principalmente per tale
scopo; si parla allora di assemblaggio condizionale.
Come si può notare, il tipo di blocco condizionale appena illustrato deve iniziare con
un IF e deve terminare con un ENDIF; questa caratteristica rende
possibile la creazione di blocchi condizionali innestati. Possiamo scrivere, ad
esempio:
All'interno di un blocco IF ENDIF le direttive ELSEIF e ELSE sono
facoltative; se necessario si può inserire un numero arbitrario di ELSEIF, mentre
la direttiva ELSE deve essere unica in quanto rappresenta la scelta predefinita
nel caso in cui tutte le precedenti espressioni siano risultate FALSE!
L'assembler valuta solo le istruzioni associate alla prima espressione che vale
TRUE, dopo di che salta alla direttiva ENDIF (cioè, esce dal blocco
condizionale); anche se più espressioni valgono TRUE, viene presa in
considerazione solo la prima di esse che viene incontrata.
In assenza di espressioni che valgono TRUE, vengono valutate le istruzioni
associate al caso ELSE; se ELSE non è presente, l'assembler salta
direttamente alla direttiva ENDIF.
Tradizionalmente (anche per motivi di chiarezza), le direttive vengono scritte sempre in
maiuscolo; in ogni caso è possibile anche l'uso delle lettere minuscole.
Come è stato già spiegato, possiamo creare espressioni di qualunque complessità; possiamo
scrivere, ad esempio:
IF (expr1 GT (expr2 AND (NOT expr3)))
Se l'espressione è verificata (TRUE), l'assembler effettua l'assemblaggio di
tutte le istruzioni che seguono la direttiva IF; in caso contrario vengono
esaminate le espressioni associate ad eventuali direttive ELSEIF e ELSE.
Naturalmente è anche possibile scrivere:
IF expr1
Questo costrutto significa: se expr1 è TRUE, cioè se il risultato
prodotto dalla valutazione di expr1 è diverso da zero.
Attenzione, invece, al fatto che il costrutto:
IF (NOT expr1)
non significa: se expr1 è FALSE; infatti, l'operatore NOT
inverte i bit di expr1 (NOT logico bit a bit) per cui questo costrutto
significa: se ~expr1 è TRUE!
Per sapere se expr1 è uguale a zero, la forma corretta da utilizzare è:
IF (expr1 EQ 0)
Passiamo ora ad un altro importante gruppo di assembler time directives che ci
permette di prendere delle decisioni in base al fatto che un determinato operando sia
stato definito o meno; un tipico blocco condizionale di questo genere comprende le
direttive: IFDEF, ELSEIFDEF, ELSE, ENDIF.
Possiamo scrivere, ad esempio:
Il costrutto:
IFDEF OPERANDO1
significa: se OPERANDO1 esiste, cioè se OPERANDO1 è stato definito
da qualche parte del programma; in caso affermativo viene assemblato il primo blocco di
istruzioni.
Se, invece, esiste OPERANDO2, viene assemblato il secondo blocco di istruzioni;
altrimenti (se non esiste nessuno dei due operandi), viene presa la decisione predefinita
che consiste nell'assemblaggio del terzo blocco di istruzioni.
In queste valutazioni non ha alcuna importanza il valore assegnato ad un operando;
infatti, anche se scriviamo:
OPERANDO1 = 0
il test:
IFDEF OPERANDO1
restituisce ugualmente TRUE in quanto stiamo testando se OPERANDO1 esiste e
non se è diverso da zero!
Vediamo un esempio pratico che dimostra la grande utilità di queste direttive. Supponiamo
di voler scrivere un programma che, se eseguito su una CPU 80386 o superiore
utilizza le istruzioni a 32 bit, mentre in caso contrario utilizza le istruzioni a
16 bit; possiamo scrivere allora porzioni di codice del tipo:
Se vogliamo assemblare il programma con una CPU 80386 o superiore, ci basta
dichiarare il nome simbolico CPU386 che provocherà l'assemblaggio
dell'istruzione CWDE; infatti, in questo caso il costrutto:
IFDEF CPU386
restituisce TRUE in quanto il nome CPU386 esiste. Se, invece, vogliamo
assemblare il programma con una CPU a 16 bit, ci basta eliminare (o
commentare) la dichiarazione del nome simbolico CPU386; in questo caso verrà
assemblata l'istruzione CWD!
In alternativa, è anche possibile scrivere:
In questo modo, assegnando al nome simbolico CPU386 un valore diverso da zero,
otteniamo l'assemblaggio della sola prima istruzione; assegnando, invece, a
CPU386 il valore zero, otteniamo l'assemblaggio della sola seconda istruzione.
Sono disponibili anche le direttive IFNDEF, ELSEIFNDEF le quali seguono
una logica inversa rispetto a IFDEF, ELSEIFDEF; ad esempio, il costrutto:
IFNDEF OPERANDO1
significa: se OPERANDO1 non è stato definito (not defined).
Possiamo dire quindi che se OPERANDO1 non esiste, il costrutto precedente
restituisce TRUE; se, invece, OPERANDO1 esiste, si ottiene FALSE.
Più avanti verranno illustrate una serie di altre importanti assembler time directives
che vengono prevalentemente usate per creare le macro.
24.3.2 Le direttive condizionali per la fase di esecuzione
Come è stato accennato in precedenza, le versioni più recenti di MASM
(6.x o superiore) forniscono una serie di direttive e operatori utilizzabili
anche a run time; attraverso questi strumenti è possibile scrivere programmi
Assembly con una sintassi molto simile a quella dei linguaggi di alto livello
come il C/C++ o il Pascal.
Tutte le run time directives devono essere precedute da un punto (.) che le
distingue dalle assembler time directives; partiamo allora dalle direttive condizionali
che sono: .IF, .ELSEIF, .ELSE e .ENDIF.
I run time operators possono essere utilizzati esclusivamente in combinazione con le
run time directives; possiamo scrivere, ad esempio:
Un programmatore Assembly con un minimo di esperienza non si fa certo
impressionare da questa "meraviglia" e intuisce subito il trucco; per svelare il
mistero ci basta consultare il listing file del nostro programma!
Nel caso, ad esempio, del Borland Turbo Assembler, scopriamo che il codice
precedente viene tradotto (com'era prevedibile) nel seguente modo:
Come si può notare, le run time directives non hanno niente di magico; queste direttive,
infatti, vengono interpretate dall'assembler che le traduce poi nella corrispondente
sequenza di istruzioni Assembly. A rigore quindi non si potrebbe neanche
parlare di direttive a run time; infatti, è sempre l'assembler che traduce tutto in
codice macchina (ad assembler time)!
Le run time directives presentano una importante limitazione legata al fatto che
l'espressione booleana associata ad esse deve essere costituita da un confronto tra
due operandi semplici; non è possibile quindi utilizzare espressioni complesse del
tipo:
.IF (ax < (bx & !cx))
Nel caso generale, una direttiva condizionale viene tradotta in un confronto
(CMP o TEST) tra l'operando di sinistra e quello di destra; ne
consegue che tali due operandi debbono soddisfare i requisiti richiesti dalle
stesse istruzioni CMP e TEST!
In sostanza, anche in questo caso possiamo parlare di operando sorgente e operando
destinazione; proprio come accade allora per CMP e TEST, i due operandi
SRC e DEST devono avere la stessa ampiezza in bit e possono combinarsi
tra loro solamente nel modo seguente:
Sono proibiti, invece, i confronti del tipo:
Oltre alle direttive condizionali sono disponibili anche una serie di direttive che
permettono di implementare le iterazioni; anche in questo caso si utilizza la classica
sintassi delle analoghe direttive offerte dai linguaggi di alto livello.
Il ciclo WHILE DO viene implementato con le direttive .WHILE, .ENDW;
il ciclo REPEAT UNTIL viene implementato con le direttive .REPEAT,
.UNTIL e .UNTILCXZ.
Supponiamo di voler inizializzare con il valore 3BF2h tutti i 10 elementi
di un vettore di WORD chiamato Vector1; utilizzando allora un ciclo
WHILE DO, possiamo scrivere:
Come accade con i linguaggi di alto livello, la direttiva .WHILE valuta
l'espressione tra parentesi tonde e se ottiene TRUE esegue le istruzioni
successive; dopo l'ultima istruzione, la direttiva .ENDW salta nuovamente a
.WHILE per cui il test viene nuovamente ripetuto. Le iterazioni proseguono
purché l'espressione tra parentesi tonde continui ad essere TRUE; quando
l'espressione diventa FALSE l'iterazione termina e l'esecuzione riprende
dall'istruzione successiva alla direttiva .ENDW.
Appare evidente che se l'espressione booleana risulta FALSE sin dall'inizio,
il ciclo .WHILE non viene mai eseguito; inoltre, se l'espressione booleana
non diventa mai FALSE, si innesca un loop infinito!
Nel caso del Borland Turbo Assembler il codice precedente viene tradotto
nel seguente modo:
Se abbiamo bisogno di un ciclo che venga sicuramente eseguito almeno una volta, possiamo
utilizzare le direttive .REPEAT e .UNTIL; in questo caso, infatti, la
condizione di uscita dal ciclo viene testata alla fine del ciclo stesso.
La struttura generale del ciclo è la seguente:
Come si può notare, la sequenza istruzioni viene eseguita almeno una volta;
alla fine viene valutata l'espressione associata alla direttiva .UNTIL.
Se espressione risulta FALSE viene ripetuto il ciclo; le iterazioni
continuano finché espressione non diventa TRUE.
Al posto di .UNTIL si può utilizzare anche la direttiva .UNTILCXZ;
come si può facilmente intuire, questa direttiva termina le iterazioni quando
espressione diventa TRUE e/o quando CX diventa zero.
Per creare dei loop più complessi sono disponibili anche le direttive .BREAK e
.CONTINUE; queste due direttive sono analoghe a quelle fornite dai linguaggi
di alto livello.
All'interno di un loop, la direttiva .BREAK provoca l'immediata uscita dal
loop stesso; si può scrivere, ad esempio:
.BREAK .IF (ax == cx)
Sempre all'interno di un loop, la direttiva .CONTINUE determina un salto alla
direttiva che controlla il loop stesso (.WHILE, .UNTIL o UNTILCXZ),
provocando quindi una nuova valutazione della condizione di uscita; si può scrivere,
ad esempio:
.CONTINUE .IF (ax < cx)
In definitiva, il comportamento delle run time directives è assolutamente analogo a quello
delle istruzioni per il controllo di flusso dei linguaggi di alto livello; pertanto, se si
vogliono avere maggiori dettagli sull'uso di tutte queste direttive si consiglia di
consultare un qualsiasi manuale di programmazione sul linguaggio C/C++, Pascal,
etc.
Non ci vuole molto a capire che le run time directives fanno storcere il naso a parecchi
programmatori Assembly; in effetti, non si capisce bene che senso abbia programmare
in questo modo!
L'aspetto più discutibile poi è dato dal fatto che queste direttive vengono tradotte in
istruzioni Assembly in base ai gusti di chi ha progettato l'assembler; e non è
detto che tali gusti coincidano con quelli dei programmatori!
Con le run time directives stiamo chiedendo all'assembler di scrivere il codice al nostro
posto; tutto ciò è chiaramente in contrasto con la filosofia di quei programmatori che
utilizzano l'Assembly proprio perché vogliono avere il controllo totale sul
programma che stanno scrivendo.
Le run time directives presentano la caratteristica di snaturare l'Assembly
avvicinandolo ai inguaggi di alto livello; ma, a quel punto, tanto vale passare
direttamente ad un vero linguaggio di alto livello che mette a disposizione tutte queste
comodità. Una valida alternativa è rappresentata dal linguaggio C che può essere
considerato un vero e proprio Assembly di alto livello.
24.3.3 La direttiva LABEL
La direttiva LABEL permette di creare etichette personalizzate; la sintassi
generale per questa direttiva è la seguente:
nome_etichetta LABEL tipo
Il campo nome_etichetta rappresenta il nome simbolico che vogliamo assegnare
alla etichetta; il campo tipo rappresenta le caratteristiche dell'etichetta
che vogliamo creare.
Per definire le caratteristiche di una etichetta possiamo servirci, in particolare,
dei size operators e dei distance operators; vediamo subito alcuni
esempi.
Se vogliamo creare una etichetta che delimita l'inizio di una procedura raggiungibile
con una FAR CALL possiamo scrivere:
nome_procedura LABEL FAR
In questo modo, una istruzione del tipo:
call nome_procedura
verrà tradotta dall'assembler nel codice macchina di una FAR CALL; nel capitolo
successivo verrà illustrato un metodo molto più elegante per creare procedure NEAR
o FAR.
Consideriamo ora la seguente porzione di un blocco dati:
Il fatto che wordData sia stata definita come LABEL di tipo WORD,
ci permette di utilizzare tale nome per accedere ai dati successivi a blocchi di
16 bit; infatti, possiamo dire che:
- wordData[0], cioè [wordData + 0], rappresenta 3C1Fh
- wordData[2], cioè [wordData + 2], rappresenta 1AC8h
- wordData[4], cioè [wordData + 4], rappresenta ABFFh
- wordData[6], cioè [wordData + 6], rappresenta 3DF1h
In sostanza, attraverso wordData possiamo accedere alle informazioni successive
come se si trattasse di un vettore di WORD.
24.4 Le macro
Molti linguaggi di programmazione, compreso l'Assembly, supportano le macro;
si tratta di un potentissimo strumento che permette di rappresentare con un nome simbolico,
intere porzioni di codice, se non addirittura interi programmi!
Si può tracciare un parallelo tra la struttura di una macro e una dichiarazione del
tipo:
NOME_SIMBOLICO = 3500
In tale dichiarazione possiamo notare la presenza di un nome simbolico, una direttiva
(=) e un valore numerico (3500) da assegnare al nome simbolico stesso.
Nel caso della macro, la differenza sostanziale sta nel fatto che al posto del valore
numerico inizializzante ci può essere praticamente qualsiasi cosa; una macro, infatti,
può rappresentare un semplice valore numerico o anche una sequenza di caratteri
alfanumerici che nel loro insieme formano intere porzioni di codice.
In fase di assemblaggio di un programma, ogni volta che l'assembler incontra il nome di
una macro, lo sostituisce con tutto ciò che la macro stessa rappresenta; tale operazione
di sostituzione prende il nome di espansione della macro.
Una macro è quindi una semplice dichiarazione a disposizione dell'assembler; come
accade per tutte le dichiarazioni, la macro non ha un indirizzo di memoria. Ciò
significa che la dichiarazione di una macro può essere collocata dappertutto, sia
all'interno, sia all'esterno dei segmenti di programma.
Il caso più semplice in assoluto è rappresentato da un operando immediato dichiarato
attraverso la direttiva di assegnamento =; possiamo creare quindi una
semplicissima macro scrivendo, ad esempio:
PESO_KG = 300
In fase di assemblaggio del programma, ogni volta che l'assembler incontra il nome
PESO_KG lo sostituisce con il valore numerico 300; possiamo dire quindi
che la macro PESO_KG viene espansa nel numero esplicito 300.
In precedenza è stata più volte sottolineata l'importanza dell'uso dei nomi simbolici
al posto dei numeri espliciti; in questo modo si rende il programma più comprensibile
e si riduce notevolmente il rischio di commettere errori. Se si sbaglia nello scrivere
un numero esplicito, l'assembler non è in grado di intervenire; se, invece, si sbaglia
nello scrivere un nome simbolico, si ottiene subito un messaggio di errore da parte
dell'assembler stesso!
24.4.1 La direttiva EQU
Con l'operatore di assegnamento = possiamo dichiarare nomi simbolici che
rappresentano esclusivamente valori numerici; se ci serve qualcosa di più complesso,
dobbiamo ricorrere alla direttiva EQU (equivalent).
Questa direttiva permette di dichiarare nomi simbolici che possono rappresentare,
sia valori numerici immediati, sia stringhe alfanumeriche; l'unica limitazione è data
dal fatto che una dichiarazione creata con la direttiva EQU deve svilupparsi
su un'unica linea del programma (non è permesso cioè andare a capo).
Con la direttiva EQU la dichiarazione precedente può essere riscritta come:
PESO_KG EQU 300
A differenza di quanto accade con la direttiva =, se abbiamo dichiarato
PESO_KG con la direttiva EQU non è possibile ridichiarare questo nome
simbolico in nessun'altra parte del programma; non è possibile cioè assegnare a
PESO_KG un altro valore o un altro significato.
Questa limitazione riguarda solo i nomi simbolici dichiarati con EQU che
rappresentano valori numerici; la ridichiarazione, invece, è permessa per i nomi
simbolici dichiarati con EQU che rappresentano stringhe alfanumeriche.
Una macro dichiarata con EQU è formata da un nome simbolico, dalla direttiva
EQU e dal cosiddetto corpo della macro; in fase di assemblaggio di un
programma, ogni volta che l'assembler incontra il nome di una macro, lo sostituisce
con il relativo corpo.
Il corpo di una macro può rappresentare qualsiasi cosa che abbia un senso logico nel
contesto del programma che si sta scrivendo; in sostanza, dopo la fase di espansione
delle macro si deve ottenere codice Assembly sensato. In caso contrario
l'assembler genera gli opportuni messaggi di errore!
Supponiamo di voler rappresentare una stringa C mediante una macro; possiamo
scrivere:
TEXT_MACRO EQU Stringa da stampare, 0
A questo punto, nel blocco dati del nostro programma possiamo scrivere:
Stringa1 db TEXT_MACRO
In fase di assemblaggio del programma, l'assembler esamina il blocco dati e incontrando
il nome simbolico TEXT_MACRO, lo sostituisce con il suo corpo ottenendo:
Stringa1 db Stringa da stampare, 0
Chiaramente questo codice è privo di senso per cui l'assembler genera un messaggio di
errore; il modo corretto di scrivere la macro precedente è:
TEXT_MACRO EQU 'Stringa da stampare', 0
Una volta capito il meccanismo possiamo anche scrivere, ad esempio:
MACRO_DB EQU db 'Stringa da stampare', 0
o direttamente:
MACRO_STRING_DB EQU Stringa1 db 'Stringa da stampare', 0
Utilizzando quest'ultima macro, nel blocco dati del nostro programma possiamo scrivere
semplicemente:
MACRO_STRING_DB
Quando l'assembler incontra questo nome simbolico lo espande in:
Stringa1 db 'Stringa da stampare', 0
Se, subito dopo la dichiarazione della macro MACRO_STRING_DB, scriviamo:
MACRO_2 EQU MACRO_STRING_DB
l'assembler si accorge che il nome simbolico MACRO_STRING_DB appartiene ad una
macro già dichiarata in precedenza e quindi assegna il corpo di MACRO_STRING_DB
a MACRO_2; in sostanza, dopo queste dichiarazioni, anche il corpo di
MACRO_2 vale:
Stringa1 db 'Stringa da stampare', 0
La direttiva EQU si rivela utilissima in molte circostanze; in particolare,
la possiamo utilizzare per implementare nuove istruzioni che non sono supportate
dall'assembler in nostro possesso.
Supponiamo, ad esempio, di avere un vecchio assembler a 32 bit che non supporta
l'istruzione CPUID; come già sappiamo, il codice macchina di questa istruzione è
formato dai due opcodes 0Fh e A2h. Con l'ausilio di EQU ci basta
scrivere:
CPUID EQU db 00001111b, 10100010b
oppure:
CPUID EQU db 0Fh, 0A2h
In questo modo, anche il nostro vecchio assembler sarà in grado di supportare l'istruzione
CPUID!
24.4.2 La direttiva MACRO
Se nemmeno la direttiva EQU soddisfa le nostre esigenze, allora non ci
resta che ricorrere alle multiline macro (macro multilinea); con le macro
multilinea si può fare veramente di tutto ed è difficile che un programmatore possa
pretendere di più.
La struttura generale di una macro multilinea comprende:
- un nome simbolico
- la direttiva MACRO
- una lista facoltativa di parametri
- il corpo della macro
- la direttiva ENDM (end macro)
La differenza sostanziale rispetto a EQU sta nel fatto che le macro
dichiarate con la direttiva MACRO possono svilupparsi eventualmente su più
linee; inoltre, come è stato appena detto, una macro multilinea può essere dotata
anche di una lista opzionale di parametri.
Vediamo alcuni esempi pratici che illustrano le potenzialità di questo strumento.
L'esempio mostrato in precedenza, relativo alla creazione di stringhe con
EQU, appare piuttosto banale; vediamo allora cosa può fare la direttiva
MACRO. A tale proposito, scriviamo la seguente dichiarazione:
Non ci vuole molto a capire che la macro MAKE_STRING ci permette di definire,
non una sola stringa predefinita, ma qualunque numero di stringhe, ciascuna con il
proprio nome e con il proprio corpo!
All'interno di un segmento di programma possiamo ora scrivere:
Quando l'assembler incontra le precedenti linee, le espande in:
Proviamo ora a definire una stringa C con MAKE_STRING; possiamo scrivere,
ad esempio:
MAKE_STRING StringaC1, 'corpo stringa C1', 0
Il problema evidente riguarda lo zero finale dopo la virgola; l'assembler lo scambia
per un inesistente terzo argomento e o ignora!
Per ovviare a questo inconveniente, MASM permette di racchiudere un qualsiasi
argomento tra parentesi angolari <>; la precedente definizione diventa quindi:
MAKE_STRING StringaC1, <'corpo stringa C1', 0>
Nei precedenti capitoli abbiamo visto che con le CPU della famiglia 80x86
non è permesso il trasferimento dati da memoria a memoria; possiamo sopperire a questa
carenza attraverso la seguente macro multilinea:
Come si può notare, questa macro è formata da un nome MEM2MEM che simboleggia
una istruzione MOV tra due generici operandi di tipo Mem; abbiamo poi la
direttiva MACRO seguita da una lista formata dai due parametri, Destinazione
e Sorgente, separati da virgole.
Il corpo della macro è formato da una istruzione PUSH che inserisce nello stack
il contenuto del Sorgente e da una istruzione POP che estrae tale contenuto
dallo stack e lo salva in Destinazione realizzando così il trasferimento dati
richiesto; infine, al termine del corpo della macro è presente la direttiva ENDM.
A questo punto, all'interno del blocco codice del nostro programma possiamo scrivere
istruzioni del tipo:
MEM2MEM Var1, Var2
(dove Var1 e Var2 sono, in questo caso, due variabili a 16 bit).
In fase di assemblaggio del programma, ogni volta che l'assembler incontra la precedente
istruzione, effettua l'espansione della macro MEM2MEM; tale espansione consiste
anche nel sostituire al parametro DEST l'argomento Var1 e al parametro
SRC l'argomento Var2. Alla fine, come risulta anche dal listing file, si
ottiene:
In questo modo abbiamo l'impressione di copiare Var2 in Var1 con un'unica
istruzione; si può anche notare che la macro MEM2MEM, senza alcuna modifica, gestisce
anche operandi a 32 bit.
Come al solito, è fondamentale il fatto che l'espansione della macro porti alla generazione
di codice che abbia un senso logico; se, ad esempio, Var2 è un operando di tipo
Mem8, allora l'istruzione:
MEM2MEM Var1, Var2
provoca un messaggio di errore dell'assembler in quanto stiamo tentando di inserire nello
stack un operando a 8 bit!
Bisogna anche prestare molta attenzione al fatto che l'uso disinvolto delle macro può
portare a degli errori piuttosto subdoli e quindi difficili da individuare; nel caso, ad
esempio, della macro MEM2MEM, nessuno ci impedisce di scrivere:
MEM2MEM ax, bx
In questo caso l'assembler utilizza BX come sorgente e AX come destinazione,
generando codice Assembly del tutto lecito; se, però, scriviamo:
MEM2MEM ax, ebx
otteniamo una istruzione PUSH che inserisce nello stack i 32 bit di
EBX e una istruzione POP che estrae dallo stack 16 bit e li copia in
AX. L'assembler non rileva, ovviamente, alcun errore in quanto il codice appena
espanso è sintatticamente legale; alla fine ci ritroviamo con 16 bit di dati in
eccesso nello stack (stack disallineato)!
Come è stato detto in precedenza, il corpo di una macro multilinea può contenere
praticamente qualsiasi cosa che abbia un senso logico; vediamo un altro esempio di una
macro chiamata INIT_VECTOR che inizializza con il valore InitVal i
NumElem elementi, da ElemSize byte ciascuno, di un generico vettore che si
trova all'offset VectOffset.
Come si può notare, si tratta del classico loop gestito da CX per inizializzare
gli elementi di un vettore. Supponiamo ora di voler inizializzare con il valore
3500 i 10 elementi da 2 byte ciascuno di un vettore Vector1;
possiamo scrivere allora:
L'espansione della macro porterà alla generazione del seguente codice:
Appare chiaro che il programmatore è tenuto a chiamare la macro passandole il corretto
numero di argomenti; tali argomenti, inoltre, debbono avere determinate caratteristiche
in modo che l'espansione della macro porti alla generazione di codice legale.
La struttura di INIT_VECTOR ci permette di analizzare un problema che si verifica
quando nel corpo di una macro è presente un'etichetta (nel nostro caso init_loop);
se, ad esempio, nel nostro programma sono presenti due chiamate alla macro
INIT_VECTOR, verranno effettuate due espansioni che determineranno la presenza di
due etichette (init_loop) aventi lo stesso nome!
Naturalmente, questa è una situazione illegale in quanto è proibito ridefinire un nome
già definito in precedenza; una possibile soluzione a questo problema è rappresentata
dall'uso della direttiva LOCAL. La direttiva LOCAL, se presente, deve essere
la prima istruzione del corpo della macro; questa direttiva elenca una serie di nomi
simbolici che verranno espansi dall'assembler sempre con nomi diversi in modo da evitare
le ridefinizioni. Nel caso, quindi, della macro INIT_VECTOR, possiamo scrivere:
Se questa macro viene espansa, ad esempio, 10 volte nel blocco codice del nostro
programma, otterremo 10 nomi differenti per l'etichetta init_loop; questi
nomi vengono scelti direttamente dall'assembler!
Tornando al caso generale, bisogna dire che il corpo di una macro multilinea può
contenere chiamate ad altre macro le quali, naturalmente, devono essere già state
dichiarate in precedenza; è anche permesso l'utilizzo di tutte le assembler time
directives e degli assembler time operators. In questo modo è possibile indicare
all'assembler quali parti di una macro devono essere espanse e quali no; è consigliabile
comunque non abusare troppo di queste caratteristiche per evitare di scrivere programmi
troppo contorti.
Possiamo dichiarare persino macro ricorsive; una macro è ricorsiva quando presenta
nel proprio corpo una chiamata a se stessa. Le macro ricorsive verranno illustrate in un
apposito capitolo dedicato all'implementazione della ricorsione in Assembly.
24.4.3 Le macro predefinite di MASM
Nel precedente paragrafo sono stati analizzati gli strumenti con i quali si possono
creare macro personalizzate; il MASM dispone anche di altri strumenti con i
quali si possono creare macro aventi una struttura predefinita. Esaminiamo, in
particolare, le macro che si possono creare con le direttive REPT e WHILE.
La direttiva REPT (repeat) ci permette di creare macro il cui corpo viene ripetuto
un certo numero di volte; il numero di ripetizioni da effettuare viene indicato da un valore
immediato che deve essere specificato subito dopo la direttiva REPT. La struttura
generale di questa macro predefinita è la seguente:
Utilizziamo, ad esempio, REPT per sopperire ad una carenza delle CPU 8086 che
non supportano istruzioni del tipo:
shl ax, 4
Invece di inserire 4 istruzioni ciascuna delle quali fa scorrere i bit di AX
di una posizione verso sinistra, possiamo scrivere la seguente macro:
A questo punto, nel blocco codice del nostro programma, possiamo scrivere istruzioni del
tipo:
MULTISHL ax, 4
Subito dopo la fase di assemblaggio, si ottiene la seguente espansione della macro:
La direttiva WHILE ci permette di creare macro il cui corpo viene ripetuto un
numero di volte che dipende dal risultato della valutazione di un'espressione booleana;
l'espressione booleana deve essere specificata subito dopo la direttiva WHILE.
La struttura generale di questa macro predefinita è la seguente:
In pratica, viene valutata espressione e se si ottiene TRUE viene espanso
il corpo della macro; successivamente viene nuovamente valutata espressione e se
si ottiene ancora TRUE si verifica una nuova espansione del corpo della macro.
Il processo continua finché espressione non diventa FALSE; in casi di questo
genere è importante evitare, come al solito, la creazione di un loop infinito.
Molto spesso la direttiva WHILE viene usata in combinazione con la direttiva
EXITM (exit macro) che permette di uscire prematuramente da una qualsiasi macro
multilinea; nel corpo di numerose macro è facile incontrare codice del tipo:
Bisogna ribadire che i vari assembler forniscono numerose altre direttive che spesso
creano problemi di compatibilità; maggiori dettagli possono essere reperiti sui manuali
di MASM.
24.5 Analogie tra macro e sottoprogrammi
Un aspetto molto importante da chiarire riguarda il fatto che qualcuno, osservando la
struttura e le caratteristiche di una macro multilinea, potrebbe pensare di trovarsi
davanti ad un vero e proprio sottoprogramma; in effetti, l'analogia esiste perché, come
vedremo nel prossimo capitolo, un sottoprogramma è formato da un nome simbolico, da una
direttiva e da un corpo.
Questa analogia però è solamente formale in quanto, in realtà, tra le macro e i
sottoprogrammi esiste una notevole differenza sostanziale; ovviamente, si tratta della
stessa differenza che esiste tra una dichiarazione e una definizione!
Una macro rappresenta una semplice dichiarazione attraverso la quale diamo all'assembler
una serie di informazioni sulla struttura della macro stessa; nella fase di assemblaggio
l'assembler utilizza queste informazioni per effettuare l'espansione della macro.
Una procedura, invece, necessita di una definizione e ciò comporta, come sappiamo,
l'allocazione della memoria necessaria per contenere il corpo della procedura stessa;
di conseguenza, una procedura ha anche un suo preciso indirizzo di memoria, che coincide
con l'indirizzo della sua prima istruzione.
Un'altra questione molto importante riguarda il fatto che su alcuni libri si afferma
che le macro multilinea, in determinate circostanze, possono rappresentare una valida
alternativa ai sottoprogrammi; questa affermazione è corretta purché si tenga sempre
presente la differenza sostanziale che esiste tra macro e sottoprogrammi, cioè tra
dichiarazioni e definizioni.
Come vedremo nel prossimo capitolo, un sottoprogramma viene caricato in memoria assieme
al programma principale; il programma principale può chiamare un sottoprogramma quando
vuole e dove vuole. Ciascuna di queste chiamate corrisponde ad un salto (trasferimento
del controllo) all'indirizzo di memoria da cui inizia il corpo del sottoprogramma; al
termine del sottoprogramma, il controllo torna al programma principale (o, in generale,
a chi ha effettuato la chiamata).
Questa tecnica di programmazione presenta il vantaggio di farci risparmiare parecchi
byte di codice; infatti, se il programma principale deve chiamare 100 volte un
determinato sottoprogramma, non vengono effettuati 100 salti a 100 copie
diverse del sottoprogramma, ma 100 salti sempre allo stesso sottoprogramma.
Lo svantaggio evidente di questa tecnica di programmazione è rappresentato, invece, dal
sensibile calo delle prestazioni; è chiaro, infatti, che i continui salti dal programma
principale ai sottoprogrammi e viceversa, determinano una certa diminuzione della velocità
di esecuzione.
Se, al posto di un sottoprogramma si utilizza una macro, si ottengono vantaggi e svantaggi
di segno opposto; se il programma principale deve chiamare 100 volte una stessa
macro, si ottengono 100 espansioni del corpo della macro, che fanno aumentare
notevolmente le dimensioni del codice. Il vantaggio delle macro sta nel fatto che il loro
uso elimina parecchi salti favorendo quindi uno stile di programmazione di tipo sequenziale;
tanto minore è il numero di salti, tanto maggiore sarà la velocità del programma.
In definitiva, se il nostro programma ha bisogno, ad esempio, di utilizzare più volte uno
stesso algoritmo, possiamo inserire tale algoritmo in un sottoprogramma o in una macro;
per decidere quale sia la scelta migliore basta fare un semplice ragionamento. Se
l'algoritmo è molto grosso e viene chiamato parecchie volte dal programma principale,
conviene inserirlo in un sottoprogramma; in questo modo si ottiene un programma molto
compatto e sufficientemente veloce. Se l'algoritmo è relativamente piccolo e viene chiamato
poche volte dal programma principale, conviene inserirlo in una macro multilinea; in questo
modo si ottiene un programma sufficientemente compatto e molto veloce.
Le potenzialità delle macro multilinea vanno ben oltre gli aspetti appena illustrati; per
descrivere in modo esauriente questo argomento non basterebbe un intero libro. Per
maggiori dettagli si può fare riferimento al manuale di MASM; si tenga presente,
comunque, che certe caratteristiche delle macro possono variare sintatticamente da
assembler ad assembler, con conseguenti problemi di compatibilità.