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: 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: 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 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: Viceversa, la macro:
%rotate -1
provoca la rotazione di un posto "verso destra" dei 4 argomenti; quindi:

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.