Assembly Base con NASM
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 NASM.
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:
%assign
La sintassi generica per questa direttiva è la seguente:
%assign 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:
%assign LUNGHEZZA 3500
In questo caso LUNGHEZZA è il nome simbolico dell'operando immediato, mentre
3500 è il valore immediato che la direttiva %assign 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.
Un nome simbolico al quale è stato assegnato un valore con %assign può essere
ridichiarato più volte (sempre con %assign) e in qualunque altro punto del
programma; in riferimento al precedente esempio, in un altro punto del programma
possiamo scrivere:
%assign LUNGHEZZA 8000
Da questo punto in poi, LUNGHEZZA vale 8000.
Tutti gli operatori dell'Assembly lavorano su operandi immediati di tipo intero;
questo significa che non sono permesse dichiarazioni di operandi immediati del tipo:
%assign 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.
La direttiva %ASSIGN crea nomi simbolici case-sensitive; se vogliamo
creare nomi simbolici case-insensitive, dobbiamo utilizzare la corrispondente
direttiva %IASSIGN.
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
gli operatori / e // si riferiscono 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, gli operatori % e %% (modulo) restituiscono 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:
%assign MAX_ELEM 30
A questo punto, se vogliamo caricare in BX l'offset dell'ultimo elemento di
vectDword possiamo scrivere:
mov bx, 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 in NASM il nome simbolico di una variabile rappresenta 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]
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 NASM;
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:
%assign EXPR1 ~VAL1
assegna a EXPR1 il numero binario 01010101b.
%assign EXPR2 VAL1 | VAL2
assegna a EXPR2 il numero binario 11111111b.
%assign EXPR3 VAL1 ^ VAL3
assegna a EXPR3 il numero binario 10100101b.
%assign EXPR4 VAL2 & VAL3
assegna a EXPR4 il numero binario 00001010b.
mov ax, VAL1 & (~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 NASM; 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:
%assign VAL1 10101010b
allora l'espressione:
%assign EXPR1 VAL1 << 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!
24.1.4 Ordine di precedenza degli operatori logico aritmetici
In conclusione di questo paragrafo analizziamo la tabella relativa all'ordine di
precedenza dei vari operatori logico aritmetici 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 7).
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, 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 relazionali e booleani
Le versioni più recenti di NASM forniscono una serie di potenti operatori
relazionali e booleani che risultano molto familiari ai programmatori C/C++ in
quanto utilizzano la sintassi classica di tali linguaggi; la tabella seguente mostra
gli operatori più importanti.
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à,
NASM 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
(<) di VAL2, si ottiene l'assegnamento di FALSE (zero) a
EXPR1 e EXPR3, mentre a EXPR2 verrà assegnato TRUE
(non zero).
Gli operatori relazionali e booleani esprimono tutta la loro potenza quando vengono
usati in combinazione con una serie di apposite direttive per il controllo del flusso;
tali direttive vengono illustrate più avanti.
L'uso degli operatori di tipo relazionale è abbastanza intuitivo; ad esempio,
l'espressione:
(expr1 == expr2)
restituisce TRUE se e solo se il contenuto di expr1 coincide con il
contenuto di expr2, mentre in caso contrario si ottiene FALSE.
Analogamente, l'espressione:
(expr1 < expr2)
restituisce TRUE se e solo se il contenuto di expr1 è minore del
contenuto di expr2; 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 i seguenti
assegnamenti:
Si può facilmente constatare che:
EXPR1 & EXPR2 = 00000000b ; AND logico bit a bit
mentre:
EXPR1 && EXPR2 = TRUE ; AND booleano
Un'ultima considerazione da fare riguarda gli operatori + 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 è 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, SEGMENT, ALIGN, %INCLUDE, CPU,
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 logici,
aritmetici, relazionali e booleani.
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, %ELIF, %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 %ELIF e %ELSE sono
facoltative; se necessario si può inserire un numero arbitrario di %ELIF, 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 > (expr2 && (expr3 == 0)))
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 %ELIF 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 (~expr1)
non significa: se expr1 è FALSE; infatti, l'operatore ~
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 == 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, %ELIFDEF, %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:
%assign 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, %ELIFNDEF le quali seguono
una logica inversa rispetto a %IFDEF, %ELIFDEF; 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.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:
%assign NOME_SIMBOLICO 3500
In tale dichiarazione possiamo notare la presenza di un nome simbolico, una direttiva
(%assign) 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 %assign; possiamo creare quindi una
semplicissima macro scrivendo, ad esempio:
%assign 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 %assign 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 %assign, 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.
In sostanza, la direttiva EQU ci permette di dichiarare solamente costanti
simboliche; il valore rappresentato da tali costanti non può cambiare durante
tutta la fase di esecuzione del programma.
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.
Molto spesso, la direttiva EQU viene utilizzata per calcolare, in fase di
assemblaggio, valori costanti da utilizzare durante l'esecuzione del programma;
vediamo un esempio pratico relativo al calcolo della lunghezza di una stringa:
Osserviamo subito che la stringa varStringa1 si trova all'offset 0006h
del segmento DATASEGM ed è formata da 23 caratteri (compreso lo zero
finale); di conseguenza, subito dopo tale stringa, il location counter $ vale:
DATASEGM:(0006h + 23) = DATASEGM:(0006h + 0017h) = DATASEGM:001Dh
Quando incontra la direttiva EQU, l'assembler calcola quindi:
DATASEGM:001Dh - DATASEGM:0006h = DATASEGM:(001Dh - 0006h) = DATASEGM:0017h
Alla costante simbolica LEN_STR1 viene dunque assegnato il valore 0017h
che rappresenta la distanza in byte tra gli offset 001Dh e 0006h di
DATASEGM; ma in base 10, il valore 0017h diventa 23 che
è proprio la lunghezza in byte di varStringa1!
24.4.2 Le direttive %DEFINE e %XDEFINE
Se abbiamo bisogno di macro più complesse di quelle dichiarabili con EQU,
possiamo ricorrere alla direttiva %DEFINE; il corpo di una macro creata
con %DEFINE può rappresentare qualsiasi cosa che abbia un senso logico
nel contesto del programma che si sta scrivendo!
Ovviamente, dopo la fase di espansione della macro si deve ottenere codice
Assembly sensato; in caso contrario l'assembler genera gli opportuni
messaggi di errore!
Analogamente a quanto accade con EQU, anche %DEFINE permette di
creare macro che si sviluppano su una sola linea; le macro create con
%DEFINE possono essere, però, ridichiarate in altri punti del programma!
Supponiamo di voler rappresentare una stringa C mediante una macro;
possiamo scrivere:
%define TEXT_MACRO 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 è:
%define TEXT_MACRO "Stringa da stampare", 0
Una volta capito il meccanismo possiamo anche scrivere, ad esempio:
%define MACRO_DB db "Stringa da stampare", 0
o direttamente:
%define MACRO_STRING_DB 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:
%define MACRO_2 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
Con %DEFINE possiamo creare persino macro dotate di parametri; a tale
proposito, consideriamo le seguenti dichiarazioni:
A questo punto, nel blocco codice del nostro programma possiamo scrivere istruzioni
del tipo:
mov ax, EXPR1(VAL1, VAL2, VAL3)
Nella dichiarazione della macro EXPR1, è importantissimo notare che ciascun
parametro deve essere racchiuso tra parentesi tonde; in questo modo, l'assembler
può gestire correttamente anche le istruzioni del tipo:
mov bx, EXPR1(VAL1 + 2, VAL2 / 2, VAL3 * 2 / 5)
In una tale istruzione, al parametro a viene assegnato l'argomento
VAL1+2, al parametro b viene assegnato l'argomento VAL2/2,
mentre al parametro c viene assegnato l'argomento VAL3*2/5!
La direttiva %DEFINE si rivela utilissima in molte altre 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 %DEFINE ci
basta scrivere:
%define CPUID db 00001111b, 10100010b
oppure:
%define CPUID db 0Fh, 0A2h
In questo modo, anche il nostro vecchio assembler sarà in grado di supportare l'istruzione
CPUID!
Osserviamo ora le seguenti macro:
Dopo l'espansione delle macro otteniamo, com'era prevedibile, Val1=1; l'aspetto
imprevisto è che si ottiene anche Test1=1!
Ciò accade in quanto abbiamo dichiarato Test1 come una macro che contiene il
valore memorizzato in Val1; di conseguenza, NASM segue un comportamento
logico e fa in modo che una qualunque modifica di Val1 si ripercuota anche
sul contenuto di Test1!
Se vogliamo che ciò non accada (cioè, se vogliamo che a Test1 venga assegnato il
contenuto corrente di Val1), possiamo servirci della direttiva %XDEFINE;
dobbiamo scrivere, allora:
In questo modo, dopo l'espansione delle macro otteniamo Val1=1 e Test1=0!
All'interno di una macro creata con %DEFINE, è possibile utilizzare l'operatore
%+ il cui scopo è quello di concatenare due o più nomi simbolici; consideriamo
il seguente esempio:
%define DATA_SEGM(name) SEGMENT DATA %+ name ALIGN=16 PUBLIC USE16 CLASS=DATA
Attraverso questa macro, possiamo creare segmenti di dati con nomi personalizzati; ad
esempio, la chiamata:
DATA_SEGM(SEGM1A)
sarà espansa in:
SEGMENT DATASEGM1A ALIGN=16 PUBLIC USE16 CLASS=DATA
Le direttive %DEFINE e %XDEFINE creano nomi simbolici case-sensitive;
se vogliamo creare nomi simbolici case-insensitive, dobbiamo utilizzare le
corrispondenti direttive %IDEFINE e %XIDEFINE.
24.4.3 La direttiva %MACRO
Se nemmeno la direttiva %DEFINE 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:
- la direttiva %MACRO
- un nome simbolico
- il numero di parametri richiesti
- il corpo della macro
- la direttiva %ENDMACRO
La differenza sostanziale rispetto a %DEFINE 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
%DEFINE, appare piuttosto banale; vediamo allora cosa può fare la direttiva
%MACRO. A tale proposito, scriviamo la seguente dichiarazione:
La macro MAKE_STRING richiede quindi 2 parametri; nel corpo di
MAKE_STRING, il simbolo %1 rappresenta il primo parametro, mentre il
simbolo %2 rappresenta il secondo parametro.
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 genera un messaggio di errore!
Per ovviare a questo inconveniente, NASM permette di racchiudere un qualsiasi
argomento tra parentesi graffe {}; 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 dal numero (2) di parametri richiesti
(DEST, SRC).
Il corpo della macro è formato da una istruzione PUSH che inserisce nello stack
il contenuto del secondo parametro (SRC) e da una istruzione POP che
estrae tale contenuto dallo stack e lo salva nel primo parametro DEST realizzando
così il trasferimento dati richiesto; infine, al termine del corpo della macro è presente
la direttiva %endmacro.
A questo punto, all'interno del blocco codice del nostro programma possiamo scrivere
istruzioni del tipo:
MEM2MEM word [Var1], word [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 byte [Var1], byte [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)!
Possiamo evitare questo tipo di problemi modificando la macro in questo modo:
La chiamata alla macro assume allora un aspetto del tipo:
MEM2MEM [Var1], [Var2], word
Come si può notare, in questo caso il programmatore deve passare alla macro anche la
dimensione dei due operandi; in questo modo, nella peggiore delle ipotesi otteniamo
almeno un messaggio di avvertimento (warning) da parte dell'assembler!
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 identico nome!
Naturalmente, questa è una situazione illegale in quanto è proibito ridefinire un nome
già definito in precedenza; la soluzione a questo problema è rappresentata dall'uso
delle etichette locali. Una etichetta locale, deve essere preceduta dal simbolo
%%; tale etichetta, verrà espansa 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.
Il nome simbolico associato alla direttiva %MACRO è case-sensitive; se
vogliamo assegnare ad una macro un nome simbolico case-insensitive, dobbiamo
utilizzare la corrispondente direttiva %IMACRO.
24.4.4 Macro con numero variabile di parametri
Il potentissimo preprocessore di NASM permette anche la creazione di macro
dotate di un numero variabile di parametri; a tale proposito, per indicare il numero
dei parametri dobbiamo utilizzare il simbolo n-*, dove n indica il
numero minimo di parametri obbligatori (eventualmente, 0).
Ad esempio, il simbolo 1-* indica una macro dotata di un numero variabile
di parametri, di cui il primo è obbligatorio; in sostanza, il precedente simbolo
deve essere letto come "uno o più parametri".
All'interno di una macro dotata di un numero variabile di parametri, è disponibile il
simbolo %0 che indica il numero effettivo di argomenti passato dal programmatore;
tale simbolo viene generalmente usato come contatore della direttiva %REP
illustrata nel seguito.
24.4.5 La direttiva %ROTATE
La direttiva %ROTATE è formalmente simile alle istruzioni ROL e
ROR; la differenza sostanziale sta nel fatto che ROL e ROR
ruotano i bit di un operando Reg o Mem, mentre %ROTATE ruota
gli argomenti ricevuti da una macro dotata di numero variabile di parametri!
%ROTATE richiede un numero intero n con segno che rappresenta il
numero di rotazioni da effettuare; se n è positivo, le rotazioni avvengono
verso sinistra, mentre se n è negativo, le rotazioni avvengono verso destra.
Consideriamo una macro che ha ricevuto 4 argomenti; come sappiamo, all'interno
della macro tali argomenti sono rappresentati dai simboli %1, %2,
%3, %4.
A questo punto, la macro:
%rotate 1
provoca la rotazione di un posto "verso sinistra" dei 4 argomenti; quindi:
- %1 "esce" da sinistra e "rientra" da destra diventando %4
- %2 scorre di un posto verso sinistra e diventa %1
- %3 scorre di un posto verso sinistra e diventa %2
- %4 (originale) scorre di un posto verso sinistra e diventa %3
Viceversa, la macro:
%rotate -1
provoca la rotazione di un posto "verso destra" dei 4 argomenti; quindi:
- %4 "esce" da destra e "rientra" da sinistra diventando %1
- %3 scorre di un posto verso destra e diventa %4
- %2 scorre di un posto verso destra e diventa %3
- %1 (originale) scorre di un posto verso destra e diventa %2
24.4.6 La direttiva %REP
La direttiva %REP (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
%REP. La struttura generale di questa macro è la seguente:
Utilizziamo, ad esempio, %REP per sopperire ad una carenza delle CPU
8086 le quali 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:
Se vogliamo uscire prematuramente da un blocco %REP %ENDREP, possiamo servirci
della direttiva %EXITREP; a tale proposito, si utilizza spesso il seguente
metodo:
Utilizziamo ora %REP in combinazione con %ROTATE per scrivere una macro
dotata di un numero variabile di parametri; lo scopo di questa macro è quello di
inserire nello stack uno o più registri a 16 bit.
La nostra macro, chiamata MULTIPUSH, assume il seguente aspetto:
Se ora effettuiamo la chiamata:
MULTIPUSH ax, bx, cx, dx
otteniamo la seguente espansione:
Infatti, la direttiva %REP %0 ripete un loop per 4 volte (%0=4);
ad ogni loop, i vari argomenti vengono ruotati di un posto verso sinistra in modo che
ciascuno di essi compaia, in successione, nella posizione %1!
Se vogliamo creare una macro MULTIPOP che effettui l'operazione inversa,
possiamo scrivere:
Utilizzando un valore negativo per %ROTATE, possiamo effettuare la chiamata
senza la necessità di passare gli argomenti in ordine inverso; la chiamata assume
quindi il seguente aspetto:
MULTIPOP ax, bx, cx, dx
Dopo l'espansione della macro si ottiene:
Infatti, questa volta gli argomenti vengono ruotati di un posto verso destra ad
ogni loop!
24.4.7 Il "context stack"
I vari nomi simbolici eventualmente dichiarati all'interno di una macro, non risultano
visibili all'interno di altre macro; ad esempio, se all'interno di una macro M1
scriviamo:
%assign ARG_SIZE 4
allora non è possibile accedere al contenuto di ARG_SIZE dall'interno di una
macro M2.
Può capitare, però, che il programmatore voglia condividere alcuni nomi simbolici tra
due o più macro; ancora una volta ci viene incontro il potentissimo preprocessore di
NASM che ci mette a disposizione il "context stack"!
Si tratta di un vero e proprio stack destinato a contenere gruppi di nomi simbolici
dichiarati all'interno delle macro; tali nomi simbolici risultano visibili in altre
macro finché non vengono rimossi dallo stack!
Per la gestione del context stack sono disponibili le due direttive
%PUSH e %POP; il loro comportamento è del tutto simile a quello delle
omonime istruzioni della CPU.
All'interno di una macro, la direttiva %PUSH nome_simbolico crea un nuovo
"contesto" e lo inserisce sulla cima del context stack; tutti i nomi simbolici
da inserire in tale contesto devono iniziare con il simbolo %$.
Tali nomi simbolici risultano visibili in tutte le macro successive, finché non vengono
rimossi dal context stack; a tale proposito, è necessario utilizzare la direttiva
%POP.
Consideriamo il seguente esempio pratico:
Finché il contesto context1 rimane in cima al context stack, il nome
simbolico %$ARGS_SIZE creato nella macro BEGIN_CONTEXT, risulta visibile
anche nella macro END_CONTEXT; invece, il nome simbolico ARGS_NUMB non
è visibile in END_CONTEXT in quanto non inizia con il simbolo %$!
Naturalmente, altre macro interposte tra BEGIN_CONTEXT e END_CONTEXT
possono aprire altri contesti, i quali andranno a sovrapporsi a context1; è
compito del programmatore tenere traccia del contesto che, in un determinato momento,
si trova in cima al context stack!
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 NASM.