Assembly Base con NASM
Capitolo 12: Struttura di un programma Assembly
In questo capitolo vengono illustrati in dettaglio tutti gli strumenti che il
linguaggio Assembly ci mette a disposizione per permetterci di definire
la struttura generale di un programma; in particolare, vedremo come si deve
procedere per creare i vari segmenti di programma e studieremo le caratteristiche
delle informazioni (codice, dati e stack) destinate a riempire i segmenti stessi.
Nei precedenti capitoli è stato detto che un programma destinato ad essere
eseguito nella modalità reale 8086, deve essere suddiviso in uno o più
blocchi chiamati program segments (segmenti di programma); all'interno di
questi segmenti vengono distribuite le informazioni che nel loro insieme formano
appunto un programma.
Nel caso più semplice e intuitivo, ciascun segmento di programma viene riservato
ad un solo tipo di informazioni; possiamo avere quindi, segmenti riservati al
solo codice, segmenti riservati ai soli dati statici e segmenti riservati ai soli
dati temporanei (stack).
In modalità protetta, il programmatore può assegnare a ciascun segmento di
programma particolari attributi che definiscono la modalità di accesso al segmento
stesso; si possono avere in questo modo, segmenti con attributo readable
(leggibile), writable (scrivibile) e executable (eseguibile). Un
segmento di codice, ad esempio, può essere "etichettato" come executable
e readable, per cui, eventuali dati statici definiti al suo interno non
possono essere modificati; analogamente, un segmento riservato ai dati statici
può essere etichettato come readable e writable, in modo da renderlo
accessibile in lettura e in scrittura.
In modalità reale 8086, invece, un qualunque segmento di programma è allo
stesso tempo, leggibile, scrivibile ed eseguibile; ciò implica che un qualunque
segmento di un programma DOS può contenere al suo interno un misto di
codice, dati e stack. Nei prossimi capitoli vedremo che, in particolari circostanze,
può essere conveniente scrivere un programma Assembly formato da un unico
segmento destinato a contenere qualsiasi tipo di informazione; nel caso generale
però, si può ottenere un enorme aumento della flessibilità e della chiarezza di un
programma, suddividendolo in almeno tre segmenti che ci permettono di separare in
modo ordinato, il codice, i dati e lo stack.
La filosofia dell'Assembly consiste nel lasciare al programmatore la
massima libertà di scelta; l'importante è che il programmatore stesso sappia
esattamente ciò che sta facendo.
12.1 Struttura generale di un segmento di programma
La Figura 12.1 illustra la struttura generale che assume un segmento di un
programma Assembly.
Come possiamo notare, un segmento di programma viene "aperto" dalla direttiva
SEGMENT seguita da un nome simbolico (in questo caso, NomeSegmento)
e da altri parametri; in base alla sintassi NASM, la fine di un segmento
di programma è rappresentata dall'inizio del segmento successivo o dalla fine del
file.
In Assembly, le direttive sono parole riservate attraverso le quali il
programmatore impartisce dei veri e propri ordini all'assembler; in questo modo
è possibile "pilotare" il lavoro che l'assembler stesso deve svolgere.
Un nome simbolico come NomeSegmento, viene chiamato identificatore
in quanto serve per identificare simbolicamente una qualche entità del programma;
nel caso generale, un identificatore è sempre associato ad una direttiva che
specifica il tipo di entità da identificare. Nel caso di Figura 12.1, ad esempio,
la direttiva SEGMENT permette all'assembler di classificare
NomeSegmento come l'identificatore di un segmento di programma; in assenza
di questa direttiva, l'assembler produce un messaggio di errore per informarci che
non è in grado di capire a cosa si riferisca il nome simbolico NomeSegmento.
Se si eccettuano alcuni casi particolari, in generale il nome di un identificatore
viene scelto a piacere dal programmatore; le regole da rispettare sono le stesse
imposte da tutti i linguaggi di programmazione. Un identificatore è formato da una
sequenza di codici
ASCII
che deve iniziare obbligatoriamente con una lettera dell'alfabeto; al suo interno
l'identificatore può contenere un misto di lettere dell'alfabeto e cifre numeriche.
È proibito, invece, l'uso di spazi, segni di punteggiatura, simboli matematici e
altri simboli speciali del codice
ASCII;
l'unica eccezione riguarda il simbolo underscore ('_') che è associato
al codice
ASCII 95.
Le regole appena enunciate sono del tutto generali e risultano compatibili quindi
con qualsiasi assembler; esistono poi altre regole particolari che variano però da
assembler ad assembler.
In assenza di diverse indicazioni da parte del programmatore, il NASM è
case-sensitive e questo significa che, in relazione ai nomi degli
identificatori, l'assembler distingue tra lettere maiuscole e lettere minuscole;
per NASM quindi, il nome simbolico Variabile1 è differente da
VARIABILE1 o da variabile1.
In relazione, invece, ai mnemonici delle varie istruzioni Assembly, non c'è
alcuna differenza tra PUSH e push o tra POP e pop;
qualunque mnemonico quindi può essere scritto, indifferentemente, in maiuscolo o
in minuscolo.
Questo aspetto diventa molto importante nel momento in cui si deve interfacciare
l'Assembly con i linguaggi di alto livello; in questo caso, infatti, bisogna
tenere presente che certi linguaggi, come il Pascal e il BASIC, sono
case-insensitive, mentre altri linguaggi, come il C, sono
case-sensitive. Come vedremo in un apposito capitolo, per potersi adattare
alle convenzioni seguite da questi linguaggi, NASM permette al programmatore
di abilitare o meno la distinzione tra lettere maiuscole e lettere minuscole negli
identificatori; si tenga anche presente che in molti linguaggi di alto livello, il
carattere underscore usato negli identificatori, può assumere un significato speciale.
Tornando alla Figura 12.1, è necessario sottolineare che per motivi di stile e di
chiarezza, è meglio scegliere per l'identificatore di un segmento di programma,
un nome che abbia attinenza con l'uso che si vuole fare del segmento stesso; nel
caso, ad esempio, di un segmento di codice, possiamo scegliere dei nomi come
SEGM_CODICE, SEGCODE, CODESEGM, etc, mentre nel caso di un
segmento di dati possiamo scegliere dei nomi come SEGM_DATI, SEGDATA,
DATASEGM, etc. Naturalmente, è proibito usare per gli identificatori nomi
di parole riservate come SEGMENT, ADD, PUSH, POP, etc;
si tenga presente che esistono anche nomi predefiniti come CODE, DATA,
STACK, CODESEG, DATASEG, STACKSEG, etc, che vengono
impiegati quando si interfaccia l'Assembly con i linguaggi di alto livello.
Per il programmatore, l'identificatore di un segmento di programma ha un significato
importantissimo. Ricordiamo a tale proposito che non appena un programma viene
caricato in memoria, ad ogni segmento di programma viene assegnato un determinato
indirizzo fisico iniziale; a questo indirizzo fisico iniziale corrisponde
univocamente un indirizzo logico normalizzato Seg:Offset. Ebbene:
Supponiamo, ad esempio, di avere un segmento di programma chiamato CODESEGM,
che in fase di caricamento in memoria, viene fatto partire dall'indirizzo fisico
0BFA2h; da questo indirizzo fisico ricaviamo univocamente l'indirizzo logico
normalizzato 0BFAh:0002h. Possiamo dire allora che durante la fase di
esecuzione del programma si ha:
CODESEGM = 0BFAh
Una volta che un programma è stato caricato in memoria, tutti gli indirizzi
Seg:Offset assegnati alle varie informazioni statiche (dati statici e
istruzioni), rimangono fissi per tutta la fase di esecuzione; questo discorso vale
quindi anche per la componente Seg assegnata ad ogni segmento di programma.
Da queste considerazioni si deduce allora che l'identificatore di un segmento di
programma contiene un valore immediato (costante) di tipo intero senza segno a
16 bit; questo identificatore può essere impiegato come operando sorgente di
tipo Imm16 nelle istruzioni che coinvolgono operandi a 16 bit.
Riprendendo il precedente esempio, possiamo scrivere quindi istruzioni del tipo:
MOV AX, CODESEGM
(trasferimento dati da Imm16 a Reg16).
Per definire in modo dettagliato tutte le caratteristiche di un segmento di
programma, dobbiamo servirci di una serie di cosiddetti attributi che,
come si vede in Figura 12.1, formano una lista che deve essere disposta subito
dopo la direttiva SEGMENT; l'ordine con il quale vengono disposti i vari
attributi non ha alcuna importanza. Esaminando gli attributi e il contenuto dei
vari segmenti di programma, l'assembler ricava una numerosa serie di informazioni
che verranno poi passate al linker; il compito del linker è quello di
utilizzare queste informazioni, per rendere materialmente effettive tutte le
richieste che abbiamo impartito all'assembler.
Gli attributi da assegnare ad un segmento di programma sono tutti facoltativi;
l'assembler provvede ad assegnare un valore predefinito a tutti gli attributi
non specificati dal programmatore. Analizziamo ora in modo approfondito il
significato dei vari attributi di un segmento di programma.
12.1.1 L'attributo "Allineamento"
Attraverso l'attributo Allineamento, il programmatore può richiedere al
linker che un determinato segmento di programma venga disposto in memoria a partire
da un indirizzo fisico avente particolari requisiti di allineamento; questo aspetto
è molto importante in quanto, come sappiamo, codice e dati correttamente allineati
in memoria, vengono acceduti alla massima velocità possibile dalla CPU.
Questo attributo opera solo sull'indirizzo fisico iniziale di un segmento di
programma e non sulle informazioni contenute al suo interno; è chiaro però che, ad
esempio, un segmento dati allineato ad un indirizzo pari, facilita il lavoro del
programmatore che ha la necessità di allineare ad indirizzi pari anche i dati
presenti nel segmento stesso.
In base alla sintassi NASM, l'attributo Allineamento viene espresso
attraverso la direttiva:
ALIGN = n
dove n è un numero intero positivo che può assumere i valori mostrati in
Figura 12.2; nella stessa figura vengono mostrati gli effetti prodotti da n
sull'indirizzo fisico iniziale di un segmento di programma.
Per chiarire questi importanti concetti, vediamo subito un esempio pratico;
supponiamo a tale proposito di richiedere al DOS l'esecuzione di un
programma formato nell'ordine, da un blocco codice chiamato CODESEGM, da
un blocco dati chiamato DATASEGM e da un blocco stack chiamato
STACKSEGM. Come già sappiamo, il DOS provvede innanzi tutto a
trovare un blocco di memoria le cui dimensioni devono essere sufficienti a
contenere il programma da eseguire; una volta che il DOS ha caricato il
programma in questo blocco di memoria, grazie al lavoro svolto dal linker i vari
segmenti si vengono a trovare disposti in modo da rispettare i requisiti di
allineamento che noi abbiamo richiesto.
Nel nostro esempio supponiamo che il primo segmento del programma sia
CODESEGM e supponiamo, inoltre, che tale blocco, dopo il caricamento in
memoria, termini esattamente all'indirizzo fisico 0ADA4h; a questo punto
ci interessa sapere come viene disposto il successivo blocco DATASEGM.
La Figura 12.3 illustra proprio il tipo di indirizzo fisico iniziale che viene
assegnato a DATASEGM in funzione dell'attributo di allineamento che
abbiamo scelto per questo segmento di programma; osserviamo che il blocco
CODESEGM è stato colorato in verde, mentre il blocco DATASEGM
è stato colorato in celeste.
Sul lato sinistro della Figura 12.3 vengono mostrati gli indirizzi fisici a
20 bit associati alle varie celle di memoria; sul lato destro vengono
mostrati i corrispondenti indirizzi logici normalizzati. È importante ribadire
che in modalità reale, ad ogni indirizzo fisico a 20 bit possono
corrispondere uno o più indirizzi logici generici; ad esempio, dall'indirizzo
fisico 0ADA5h possiamo ricavare gli indirizzi logici 0ADAh:0005h,
0AD9h:0015h, 0AD8h:0025h, etc.
Normalizzando, invece, gli indirizzi logici, si ottiene una corrispondenza
biunivoca con gli indirizzi fisici; in questo caso, infatti, all'indirizzo
fisico 0ADA5h corrisponde univocamente l'indirizzo logico normalizzato
0ADAh:0005h e viceversa.
In Figura 12.3a vediamo quello che succede nel caso in cui per DATASEGM
venga scelto un allineamento di tipo BYTE; questa situazione si verifica
quando il programmatore apre il blocco DATASEGM con una linea del tipo:
SEGMENT DATASEGM ALIGN=1
In questo caso stiamo richiedendo che DATASEGM venga allineato al
prossimo indirizzo fisico libero, successivo al blocco CODESEGM;
come si può notare in Figura 12.3a, il prossimo indirizzo fisico libero
successivo a 0ADA4h è ovviamente 0ADA5h. Il blocco DATASEGM
viene quindi disposto in memoria a partire dall'indirizzo fisico 0ADA5h;
l'indirizzo logico normalizzato associato a 0ADA5h è 0ADAh:0005h e
ciò significa che:
DATASEGM = 0ADAh
Possiamo dire quindi che DATASEGM viene indirizzato attraverso coppie
Seg:Offset la cui componente Seg vale 0ADAh; come già
sappiamo, questa componente Seg è associata all'indirizzo fisico multiplo
di 16 (0ADA0h) che più si avvicina per difetto a 0ADA5h.
Possiamo anche dire che il segmento di programma DATASEGM viene inserito
nel segmento di memoria n. 0ADAh a partire dal byte n. 0005h; di
conseguenza, gli offset all'interno di DATASEGM partono dal valore minimo
0005h.
L'indirizzo fisico 0ADA5h da cui inizia DATASEGM è chiaramente un
indirizzo dispari; come si può facilmente intuire, questa situazione si rivela
adeguata per una CPU con architettura a 8 bit, mentre può creare
problemi di allineamento dei dati nel caso di CPU con architettura a
16 bit o superiore.
Osserviamo inoltre che con l'allineamento di tipo BYTE per i segmenti,
si ottiene la massima compattezza possibile per un programma; infatti, i vari
segmenti vengono disposti in modo consecutivo e contiguo, senza buchi di memoria
tra un segmento e l'altro.
In Figura 12.3b vediamo quello che succede nel caso in cui per DATASEGM
venga scelto un allineamento di tipo WORD; questa situazione si verifica
quando il programmatore apre il blocco DATASEGM con una linea del tipo:
SEGMENT DATASEGM ALIGN=2
In questo caso stiamo richiedendo che DATASEGM venga allineato al prossimo
indirizzo fisico libero, successivo al blocco CODESEGM e multiplo intero di
2 byte; come si può notare in Figura 12.3b, il prossimo indirizzo fisico
libero successivo a 0ADA4h e multiplo intero di 2 è ovviamente
0ADA6h. Il blocco DATASEGM viene quindi disposto in memoria a partire
dall'indirizzo fisico 0ADA6h; l'indirizzo logico normalizzato associato a
0ADA6h è 0ADAh:0006h e ciò significa che:
DATASEGM = 0ADAh
Possiamo dire quindi che DATASEGM viene indirizzato attraverso coppie
Seg:Offset la cui componente Seg vale 0ADAh; questa
componente Seg è associata all'indirizzo fisico multiplo di 16
(0ADA0h) che più si avvicina per difetto a 0ADA6h. Possiamo anche
dire che il segmento di programma DATASEGM viene inserito nel segmento di
memoria n. 0ADAh a partire dal byte n. 0006h; di conseguenza, gli
offset all'interno di DATASEGM partono dal valore minimo 0006h.
L'indirizzo fisico 0ADA6h da cui inizia DATASEGM è chiaramente
un indirizzo pari; come si può facilmente intuire, questa situazione si rivela
adeguata per tutte le CPU con architettura a 16 bit o inferiore,
mentre può creare problemi di allineamento dei dati nel caso di CPU con
architettura a 32 bit o superiore.
Osserviamo inoltre che per poter soddisfare i requisiti di allineamento che
abbiamo richiesto, viene lasciato un buco di memoria tra CODESEGM e
DATASEGM; questo buco è pari a 1 byte e rimane inutilizzato.
In Figura 12.3c vediamo quello che succede nel caso in cui per DATASEGM
venga scelto un allineamento di tipo DWORD; questa situazione si verifica
quando il programmatore apre il blocco DATASEGM con una linea del tipo:
SEGMENT DATASEGM ALIGN=4
In questo caso stiamo richiedendo che DATASEGM venga allineato al prossimo
indirizzo fisico libero, successivo al blocco CODESEGM e multiplo intero di
4 byte; come si può notare in Figura 12.3c, il prossimo indirizzo fisico
libero successivo a 0ADA4h e multiplo intero di 4 è ovviamente
0ADA8h (infatti, 0ADA6h pur essendo pari non è divisibile per
4). Il blocco DATASEGM viene quindi disposto in memoria a partire
dall'indirizzo fisico 0ADA8h; l'indirizzo logico normalizzato associato a
0ADA8h è 0ADAh:0008h e ciò significa che:
DATASEGM = 0ADAh
Possiamo dire quindi che DATASEGM viene indirizzato attraverso coppie
Seg:Offset la cui componente Seg vale 0ADAh; questa
componente Seg è associata all'indirizzo fisico multiplo di 16
(0ADA0h) che più si avvicina per difetto a 0ADA8h. Possiamo anche
dire che il segmento di programma DATASEGM viene inserito nel segmento
di memoria n. 0ADAh a partire dal byte n. 0008h; di conseguenza,
gli offset all'interno di DATASEGM partono dal valore minimo 0008h.
L'indirizzo fisico 0ADA8h da cui inizia DATASEGM è chiaramente un
indirizzo pari multiplo intero di 4; come si può facilmente intuire, questa
situazione si rivela adeguata per tutte le CPU con architettura a 32
bit o inferiore.
Osserviamo inoltre che per poter soddisfare i requisiti di allineamento che abbiamo
richiesto, viene lasciato un buco di memoria tra CODESEGM e DATASEGM;
questo buco è pari a 3 byte e rimane inutilizzato.
In Figura 12.3d vediamo quello che succede nel caso in cui per DATASEGM
venga scelto un allineamento di tipo PARA; questa situazione si verifica
quando il programmatore apre il blocco DATASEGM con una linea del tipo:
SEGMENT DATASEGM ALIGN=16
In questo caso stiamo richiedendo che DATASEGM venga allineato al prossimo
indirizzo fisico libero, successivo a CODESEGM e multiplo intero di
16 byte; come si può notare in Figura 12.3d, il prossimo indirizzo fisico
libero successivo a 0ADA4h e multiplo intero di 16 è ovviamente
0ADB0h (ricordiamo, infatti, che tutti gli indirizzi fisici multipli di
16, espressi in esadecimale, hanno sempre la cifra meno significativa che
vale 0). Il blocco DATASEGM viene quindi disposto in memoria a partire
dall'indirizzo fisico 0ADB0h; l'indirizzo logico normalizzato associato a
0ADB0h è 0ADBh:0000h e ciò significa che:
DATASEGM = 0ADBh
Possiamo dire quindi che DATASEGM viene indirizzato attraverso coppie
Seg:Offset la cui componente Seg vale 0ADBh; questa
componente Seg è associata all'indirizzo fisico multiplo di 16
(0ADB0h) che più si avvicina per difetto a 0ADB0h. L'allineamento
di tipo PARA presenta quindi l'importante caratteristica di far coincidere
l'inizio di un segmento di programma con l'inizio di un segmento di memoria; nel
caso del nostro esempio, l'inizio (0ADB0h) del segmento di programma
DATASEGM coincide con l'inizio del segmento di memoria n. 0ADBh e
quindi, gli offset all'interno dello stesso DATASEGM partono dal valore minimo
possibile 0000h.
L'indirizzo fisico 0ADB0h da cui inizia DATASEGM è chiaramente
un indirizzo pari multiplo intero di 16 (e quindi anche multiplo intero
di 4 e di 2); come si può facilmente intuire, questa situazione
si rivela adeguata per tutte le CPU con architettura a 128 bit
o inferiore.
Osserviamo inoltre che per poter soddisfare i requisiti di allineamento che
abbiamo richiesto, viene lasciato un buco di memoria tra CODESEGM e
DATASEGM; questo buco è pari a ben 11 byte e rimane inutilizzato.
Un programmatore Assembly degno di questo nome, deve necessariamente
conoscere e padroneggiare i concetti appena esposti; come vedremo però nel
seguito del capitolo, anche i principianti possono stare tranquilli, in quanto
tutti questi aspetti, vengono gestiti automaticamente dall'assembler e dal linker.
In modalità reale, gli attributi di allineamento più utilizzati sono: BYTE,
WORD, DWORD e PARA, mentre gli attributi PAGE e
MEMPAGE vengono largamente usati in modalità protetta; si tenga presente che
non tutti i linker offrono il supporto per un allineamento di tipo MEMPAGE.
Dalle considerazioni appena esposte risulta chiaramente che in modalità reale,
l'attributo di allineamento più adatto per tutte le circostanze è sicuramente
PARA, in quanto 16 è un multiplo intero anche di 4 e di
2 (oltre che ovviamente di 1); l'unico piccolo difetto dell'attributo
PARA è legato alla eventualità di un leggero spreco di memoria come risulta
evidente anche nell'esempio di Figura 12.3d.
Se il programmatore definisce un segmento di programma privo dell'attributo di
allineamento, NASM utilizza il valore predefinito BYTE (cioè, n=1);
per motivi di stile e di chiarezza, si consiglia vivamente di specificare sempre tutti
gli attributi dei segmenti di programma. In questo modo facilitiamo il compito di chi
deve eventualmente occuparsi della manutenzione e dell'aggiornamento del codice sorgente
che noi abbiamo scritto; indicando esplicitamente tutti gli attributi di un segmento di
programma, evitiamo anche il rischio di scrivere programmi che dipendono dal comportamento
predefinito dei diversi assembler.
12.1.2 L'attributo "Combinazione"
Nel caso più semplice, un programma Assembly è interamente contenuto
all'interno di un unico file; nel seguito del capitolo e nei capitoli successivi,
verrà utilizzato il termine modulo per indicare ciascuno dei file che
contengono il codice sorgente di un programma Assembly.
All'interno di un singolo modulo, uno stesso segmento di programma può essere aperto
e chiuso più volte; tutto ciò significa che, ad esempio, un segmento aperto dalla linea:
SEGMENT DATASEGM ALIGN=2
dopo essere stato chiuso, può essere nuovamente riaperto all'interno dello stesso
modulo. Le varie parti che formano DATASEGM devono condividere tutte lo stesso
nome e gli stessi identici attributi; a tale proposito, NASM prende in
considerazione solamente gli attributi del primo blocco DATASEGM che incontra.
In sostanza, le parti di DATASEGM successive alla prima possono essere
aperte dalla semplice linea:
SEGMENT DATASEGM
In fase di assemblaggio del programma, l'assembler provvede a disporre le varie
parti una di seguito all'altra in modo da formare un unico segmento di programma.
La dimensione in byte del segmento risultante è pari alla somma delle dimensioni
delle varie parti che formano il segmento stesso; naturalmente, questa somma non
deve essere superiore a 65536 byte.
La situazione cambia radicalmente nel caso più generale di un programma
Assembly formato da due o più moduli; anche in questo caso, il programmatore
ha la possibilità di "spezzare" un segmento di programma in due o più parti che
possono trovarsi, sia all'interno di uno stesso modulo, sia in moduli differenti.
In una situazione di questo genere, si presenta il problema di come debbano essere
combinate tra loro le varie parti che formano un segmento di programma e che si
trovano distribuite in moduli differenti; per poter gestire questa situazione con la
massima flessibilità possibile, l'Assembly ci mette a disposizione l'attributo
Combinazione. Attraverso l'attributo Combinazione, il programmatore può
stabilire se e come il linker debba combinare tra loro i vari segmenti che formano un
programma distribuito su due o più moduli; la Figura 12.4 illustra i diversi valori
che può assumere questo attributo e gli effetti prodotti da tali valori sui segmenti
presenti nel programma.
È necessario ribadire che l'attributo Combinazione opera solo sui
segmenti di programma distribuiti su due o più moduli; questo attributo non
ha quindi alcun effetto su un segmento di programma suddiviso in due o più
parti disposte tutte all'interno dello stesso modulo.
Per analizzare i vari casi che si possono presentare, supponiamo di avere un
programma Assembly distribuito in due moduli chiamati MAIN.ASM
e LIB1.ASM; possiamo disporre, ad esempio, in MAIN.ASM il
programma principale e in LIB1.ASM una libreria di sottoprogrammi
chiamabili dallo stesso programma principale.
Supponiamo ora che in entrambi i moduli, sia presente un segmento di programma
aperto da una linea del tipo:
SEGMENT DATIPRIVATI ALIGN=2 PRIVATE
In questo caso, i due segmenti DATIPRIVATI, nonostante abbiano lo
stesso nome e gli stessi attributi, verranno tenuti separati dal linker; ciò
è dovuto alla presenza dell'attributo PRIVATE. Il segmento
DATIPRIVATI presente in LIB1.ASM risulta invisibile nel modulo
MAIN.ASM e viceversa; questo discorso vale anche per i due identificatori
DATIPRIVATI che, essendo definiti in due moduli differenti, non entrano
in conflitto tra loro.
Se i due segmenti DATIPRIVATI si trovano definiti all'interno dello
stesso modulo, vengono uniti tra loro dall'assembler nonostante la presenza
dell'attributo PRIVATE; in questo modo al linker verrà passato un unico
segmento di programma DATIPRIVATI.
Supponiamo ora che in entrambi i moduli, sia presente un segmento di programma
aperto da una linea del tipo:
SEGMENT DATIPUBBLICI ALIGN=2 PUBLIC
In questo caso, i due segmenti DATIPUBBLICI vengono uniti tra loro dal
linker in modo da formare un unico segmento di programma; ciò è dovuto alla
presenza dell'attributo PUBLIC.
I due segmenti di programma possono anche differire tra loro per l'attributo
Allineamento; possiamo assegnare, ad esempio, al primo segmento un
allineamento WORD e al secondo segmento un allineamento PARA. Il
linker provvede ad unire i due segmenti rispettando anche gli attributi di
allineamento che abbiamo richiesto per ciascun segmento.
L'attributo STACK è del tutto simile a PUBLIC; la differenza
fondamentale sta nel fatto che in presenza di un segmento di programma con
attributo STACK, il linker provvede anche ad inizializzare la coppia
SS:SP.
Supponiamo che nei due moduli del nostro esempio siano presenti due segmenti
aperti con una linea del tipo:
SEGMENT STACKSEGM ALIGN=16 STACK
Supponiamo inoltre che il primo segmento abbia una dimensione di 32
byte e che il secondo segmento abbia una dimensione di 48 byte; questi
due segmenti vengono uniti dal linker che ottiene così un unico segmento
STACKSEGM grande 32+48=80 byte (cioè 0050h byte). Grazie,
infatti, all'allineamento PARA e alle dimensioni (32 e 64)
multiple di 16, i due segmenti vengono disposti dal linker in modo
consecutivo e contiguo; in sostanza, tra un segmento e l'altro non è presente
alcun buco di memoria.
A questo punto il linker inizializza SS:SP ponendo SP=0050h
(massima capacità in byte del segmento risultante); per SS il linker
utilizza un valore simbolico che verrà poi "aggiustato" dal DOS.
È chiaro, infatti, che solo al momento di caricare il programma in memoria
sarà possibile conoscere la vera componente Seg assegnata a
STACKSEGM; appare anche intuitivo il fatto che i segmenti con attributo
STACK si rivelano particolarmente utili per la creazione dello stack
segment di un programma.
Supponiamo ora che in entrambi i moduli MAIN.ASM e LIB1.ASM, sia
presente un segmento di programma aperto da una linea del tipo:
SEGMENT DATICOMUNI ALIGN=4 COMMON
In questo caso, i due segmenti DATICOMUNI vengono sovrapposti tra loro
dal linker in modo che condividano entrambi lo stesso indirizzo fisico iniziale
(che in questo caso è allineato alla DWORD); il segmento risultante ha
quindi una dimensione in byte pari a quella del segmento più grande coinvolto
nella sovrapposizione.
Questa situazione implica che le informazioni contenute nel primo segmento
DATICOMUNI, verranno sovrascritte (tutte o in parte) dalle informazioni
contenute nel secondo segmento DATICOMUNI che viene sovrapposto al primo;
bisogna prestare quindi particolare attenzione quando si definiscono dati
inizializzati in questo tipo di segmenti.
Supponiamo, ad esempio, che il primo segmento contenga 35 byte di dati e
che il secondo segmento contenga 20 byte di dati; subito dopo la
sovrapposizione, si ottiene un segmento risultante con i primi 20 byte
del primo segmento che vengono sovrascritti dai 20 byte del secondo
segmento. Osserviamo inoltre che la dimensione del segmento risultante è pari a
quella del primo segmento e cioè, 35 byte.
I segmenti di tipo COMMON vengono utilizzati, ad esempio, dal BASIC
per la condivisione dei dati tra due o più moduli; di conseguenza, come vedremo
in un apposito capitolo, i segmenti COMMON sono necessari anche per la
condivisione dei dati tra moduli BASIC e moduli Assembly.
Analizziamo infine i segmenti con attributo ABSOLUTE=XXXXh; come è stato
già spiegato, attraverso questo potente attributo, il programmatore ha la
possibilità di posizionare un segmento di programma direttamente all'indirizzo
logico iniziale XXXXh:0000h, cioè all'indirizzo fisico assoluto XXXX0h
allineato al paragrafo.
Ricordando, ad esempio, che la ROM BIOS del computer viene mappata in
RAM in una finestra da 64 KiB che parte dall'indirizzo logico
F000h:0000h, possiamo accedere a questa finestra attraverso un apposito
segmento di programma aperto da una linea del tipo:
SEGMENT BIOSDATA ABSOLUTE=0F000h
(il perché dello zero alla sinistra di F000h verrà spiegato più avanti).
Per i segmenti di tipo ABSOLUTE=XXXXh, gli altri attributi in genere
vengono omessi; in particolare, è proibito l'uso dell'attributo di allineamento.
Non è permesso definire dati inizializzati in un segmento di programma di tipo
ABSOLUTE=XXXXh; in ogni caso, eventuali inizializzazioni verranno ignorate.
Se il programmatore definisce un segmento di programma privo dell'attributo
Combinazione, il NASM si serve dell'attributo predefinito PUBLIC.
12.1.3 L'attributo "Dimensione"
L'attributo Dimensione si riferisce all'ampiezza in bit delle componenti
Offset utilizzate per indirizzare un segmento di programma; la Figura 12.5
illustra i due valori disponibili per questo attributo.
Come già sappiamo, in modalità reale gli offset devono essere compresi tra
0000h e FFFFh, per cui dobbiamo utilizzare l'attributo USE16;
possiamo definire, ad esempio, un segmento del tipo:
SEGMENT DATASEGM ALIGN=2 PUBLIC USE16
I segmenti di questo tipo devono avere, ovviamente, una ampiezza massima di
64 KiB in quanto, con gli offset a 16 bit non possiamo accedere ad
informazioni che si trovano oltre questo limite.
L'attributo USE32 viene utilizzato negli ambienti operativi che supportano
la modalità protetta a 32 bit; in tali ambienti si lavora con offset a
32 bit che permettono di indirizzare, teoricamente, segmenti da
232=4 GiB!
Anziché assegnare l'attributo USE16 ad ogni segmento di programma,
possiamo ottenere lo stesso risultato posizionando all'inizio di un modulo
Assembly la direttiva:
BITS 16
Analogamente, anziché assegnare l'attributo USE32 ad ogni segmento di
programma, possiamo ottenere lo stesso risultato posizionando all'inizio di un
modulo Assembly la direttiva:
BITS 32
Se il programmatore definisce un segmento di programma privo dell'attributo
Dimensione, il NASM si serve dell'attributo predefinito USE16.
12.1.4 L'attributo "Classe"
Attraverso l'attributo Classe, il programmatore ha la possibilità di imporre
al linker l'ordine con il quale disporre i vari segmenti che formano un programma;
in base alla sintassi NASM, l'attributo Classe viene espresso
attraverso la direttiva:
CLASS = NomeClasse
dove NomeClasse è un nome simbolico che identifica la classe del segmento
di programma.
Nel mondo dell'Assembly vengono largamente utilizzate le classi consigliate
dal MASM come, ad esempio, 'DATA', 'CODE', 'STACK', etc;
queste classi hanno il pregio di indicare chiaramente il tipo di informazioni
contenute in un segmento di programma.
Le classi MASM diventano obbligatorie quando si interfaccia l'Assembly
con i linguaggi di alto livello; se, invece, dobbiamo scrivere un programma in puro
Assembly, nessuno ci impedisce di utilizzare classi del tipo 'MELA',
'CILIEGIA', 'LIMONE', etc.
Per capire il principio di funzionamento dell'attributo Classe, bisogna dire
innanzi tutto che il linker esamina i vari segmenti di programma nello stesso ordine
con il quale sono stati disposti nel codice sorgente dal programmatore; supponiamo
allora che il nostro programma sia distribuito nei due moduli MAIN.ASM e
LIB1.ASM. Nel modulo MAIN.ASM sono presenti i seguenti segmenti di
programma:
SEGMENT LOCALDATA ALIGN=16 PRIVATE USE16 CLASS=DATA
SEGMENT DATASEGM ALIGN=16 PUBLIC USE16 CLASS=DATA
SEGMENT CODESEGM ALIGN=16 PUBLIC USE16 CLASS=CODE
SEGMENT STACKSEGM ALIGN=16 STACK USE16 CLASS=STACK
Nel modulo LIB1.ASM sono presenti i seguenti segmenti di programma:
SEGMENT CODESEGM ALIGN=16 PUBLIC USE16 CLASS=CODE
SEGMENT LOCALDATA ALIGN=16 PRIVATE USE16 CLASS=DATA
SEGMENT DATASEGM ALIGN=16 PUBLIC USE16 CLASS=DATA
SEGMENT STACKSEGM ALIGN=16 STACK USE16 CLASS=STACK
Il linker parte dal modulo MAIN.ASM e incontra per primo il segmento
LOCALDATA di classe 'DATA'; questo segmento è PRIVATE,
per cui non verrà combinato con altri eventuali segmenti LOCALDATA
presenti nel modulo LIB1.ASM.
Siccome LOCALDATA è di classe 'DATA', il linker va a cercare
tutti gli altri segmenti di classe 'DATA'; nel modulo LIB1.ASM
il linker aveva già incontrato un secondo segmento LOCALDATA, che
essendo PRIVATE non viene combinato con il precedente LOCALDATA.
Nel modulo MAIN.ASM viene incontrato DATASEGM che viene unito,
invece, con il DATASEGM del modulo LIB1.ASM.
Non essendoci altri segmenti di classe 'DATA', il linker passa al
segmento CODESEGM di classe 'CODE' presente nel modulo
MAIN.ASM; questo segmento viene unito con il segmento CODESEGM
presente in LIB1.ASM.
Non essendoci altri segmenti di classe 'CODE', il linker passa al
segmento STACKSEGM di classe 'STACK' presente nel modulo
MAIN.ASM; questo segmento viene unito con il segmento STACKSEGM
presente in LIB1.ASM.
Alla fine il linker genera un programma eseguibile la cui struttura interna è
la seguente:
SEGMENT LOCALDATA ALIGN=16 PRIVATE USE16 CLASS=DATA
SEGMENT LOCALDATA ALIGN=16 PRIVATE USE16 CLASS=DATA
SEGMENT DATASEGM ALIGN=16 PUBLIC USE16 CLASS=DATA
SEGMENT CODESEGM ALIGN=16 PUBLIC USE16 CLASS=CODE
SEGMENT STACKSEGM ALIGN=16 STACK USE16 CLASS=STACK
Come possiamo notare, il linker ha ordinato i vari segmenti disponendo,
prima quelli di classe 'DATA', poi quelli di classe 'CODE'
e infine quelli di classe 'STACK'.
L'ordinamento dei segmenti di programma può essere gestito anche attraverso
un metodo diretto che permette al programmatore di indicare al linker come
disporre i vari blocchi; tale metodo è basato sull'uso dei cosiddetti dummy
segments (letteralmente, segmenti fantoccio). Si tratta di segmenti
di programma vuoti che hanno il solo scopo di imporre al linker la sequenza di
ordinamento dei segmenti stessi; riprendendo il precedente esempio relativo ai
due moduli MAIN.ASM e LIB1.ASM, proprio all'inizio del modulo
MAIN.ASM possiamo disporre i dummy segments mostrati in Figura 12.6.
Siccome il linker incontra per primi questi tre dummy segments, alla fine
genera un programma eseguibile la cui struttura interna è la seguente:
SEGMENT CODESEGM ALIGN=16 PUBLIC USE16 CLASS=CODE
SEGMENT DATASEGM ALIGN=16 PUBLIC USE16 CLASS=DATA
SEGMENT LOCALDATA ALIGN=16 PRIVATE USE16 CLASS=DATA
SEGMENT LOCALDATA ALIGN=16 PRIVATE USE16 CLASS=DATA
SEGMENT STACKSEGM ALIGN=16 STACK USE16 CLASS=STACK
Come si può notare, questa volta i segmenti di programma vengono ordinati
ponendo per primi quelli di classe 'CODE', seguiti poi da quelli di
classe 'DATA' e infine da quelli di classe 'STACK'; si tenga
presente che in determinate circostanze, l'ordinamento dei segmenti di un
programma Assembly assume una importanza enorme.
12.1.5 Considerazioni finali sui segmenti di programma
Per chiudere questa prima parte del capitolo, si possono riassumere alcuni
concetti importanti sui segmenti di programma.
In modalità reale 8086, un segmento di programma non può contenere più di
65536 byte di informazioni (64 KiB); se abbiamo bisogno, ad esempio,
di più di 64 KiB di dati, dobbiamo ripartirli in due o più segmenti di dati.
Lo stesso discorso vale per i segmenti di codice e per i segmenti di stack; in
relazione allo stack bisogna dire che generalmente un programma ha bisogno al massimo
di qualche migliaio di byte per i dati temporanei, per cui un solo segmento di stack
è più che sufficiente (la gestione di un programma con due o più segmenti di stack è
piuttosto impegnativa e richiede una notevole padronanza del linguaggio Assembly).
Le considerazioni appena esposte si applicano anche alla combinazione dei segmenti
di programma attraverso gli attributi illustrati in Figura 12.4; tutti i segmenti
che scaturiscono da queste combinazioni, devono avere una dimensione complessiva che
non può superare i 64 KiB.
Non ci si deve preoccupare se i concetti sin qui esposti appaiono ancora poco chiari;
tutto ciò che riguarda i segmenti di programma e il loro contenuto, verrà compreso
meglio attraverso svariati esempi pratici che verranno presentati nel seguito del
capitolo e nei capitoli successivi.
Dopo aver esaminato in dettaglio le caratteristiche generali dei segmenti di
programma, possiamo occuparci ora di tutto ciò che riguarda il contenuto dei segmenti
stessi; vediamo quindi come bisogna procedere per inserire codice, dati e stack nei
segmenti che formano un programma Assembly.
12.2 Definizione dei dati statici elementari dell'Assembly
L'Assembly, più che un linguaggio di programmazione, è un insieme di
strumenti attraverso i quali si può fare praticamente di tutto; per essere
precisi, bisogna dire che l'Assembly è uno strato software intermedio,
attraverso il quale il programmatore può accedere in modo molto semplice ai
potenti e complessi strumenti forniti dalla CPU. Lavorare in
Assembly significa quindi dialogare direttamente con il mondo binario
della CPU; tutto ciò si ripercuote anche sul procedimento che bisogna
seguire per definire i dati statici di un programma Assembly.
Abbiamo già visto che i dati statici sono così definiti, in quanto esistono
staticamente per tutta la fase di esecuzione di un programma; in sostanza, a
ciascun dato statico di un programma, viene assegnata una locazione fissa di
memoria che permane per tutta la fase di esecuzione del programma stesso. Nei
linguaggi di alto livello, i dati statici sono quelli che vengono definiti al di
fuori di qualsiasi sottoprogramma (procedura o funzione); come vedremo in un
capitolo successivo, i dati definiti all'interno di un sottoprogramma vengono in
genere sistemati nello stack in modo da poterli creare quando il sottoprogramma
viene chiamato e distruggere quando il sottoprogramma stesso termina.
Quando si lavora con i linguaggi di alto livello, si ha l'impressione che i
relativi compilatori ed interpreti siano in grado di distinguere tra diversi tipi
di dati; la Figura 12.7, ad esempio, illustra una serie di definizioni di dati
statici in un programma scritto in linguaggio C.
Come si può notare in Figura 12.7, sembrerebbe che i compilatori C siano
in grado di distinguere tra numeri interi con o senza segno, numeri con la virgola
(reali), stringhe alfanumeriche, etc; in realtà, i compilatori e gli interpreti
C, Pascal, FORTRAN, BASIC, etc, non fanno altro che
simulare via software la distinzione che effettivamente esiste tra i diversi tipi di
dati.
Nel momento in cui il codice sorgente viene tradotto in codice macchina, questa
distinzione scompare; come già sappiamo, infatti, la CPU è in grado di
maneggiare esclusivamente numeri binari e non ha la più pallida idea di cosa sia un
numero reale, una stringa, una lettera dell'alfabeto, etc.
In relazione alla fase di definizione di un qualsiasi dato statico, la CPU
ha bisogno di conoscere tre informazioni fondamentali che sono:
- l'offset
- l'ampiezza in bit
- il contenuto iniziale
Supponiamo, ad esempio, di avere un segmento dati chiamato DATASEGM;
all'offset 002Dh di questo segmento è presente un dato statico da
16 bit, che contiene il valore iniziale 3AC8h. Le tre
informazioni fondamentali associate a questo dato sono quindi, l'offset
002Dh, l'ampiezza in bit 16 e il contenuto iniziale
3AC8h.
Il programmatore Assembly deve attenersi a queste esigenze della
CPU; ciò significa che per ogni dato statico che definiamo in un
programma, dobbiamo specificare in modo chiaro l'offset, l'ampiezza in bit
e il valore iniziale. A tale proposito, dobbiamo servirci di una apposita
sintassi che assume la seguente forma:
NomeSimbolico DIRETTIVA valore_iniziale
Il NomeSimbolico viene scelto a piacere dal programmatore e deve
presentare le caratteristiche già discusse in relazione ai nomi dei segmenti
di programma; questo nome è facoltativo e il suo scopo è quello di permettere
al programmatore di individuare con estrema facilità l'offset del dato a cui
il nome stesso fa riferimento. Come già sappiamo, infatti, l'assembler tratta
NomeSimbolico come un Disp16 attraverso il quale si può accedere
al contenuto della corrispondente locazione di memoria; ogni volta che l'assembler
incontra NomeSimbolico in una istruzione, calcola automaticamente il
relativo Disp16 da inserire nel codice macchina dell'istruzione stessa.
Il NomeSimbolico è seguito da una DIRETTIVA che specifica
l'ampiezza in bit del dato che stiamo creando; le direttive disponibili con le
CPU 80x86 vengono illustrate dalla Figura 12.8.
Attraverso le direttive di Figura 12.8, poste all'interno di un qualsiasi segmento
di programma, possiamo chiedere all'assembler di creare locazioni di memoria di
ampiezza specificata, riservate alle variabili statiche del nostro programma;
tali locazioni di memoria sono destinate a contenere generici numeri binari compresi
tra i limiti min. e max. specificati dalla stessa Figura 12.8.
Le direttive di Figura 12.8 sono in genere seguite da un valore immediato che
prende il nome di valore inizializzante; consideriamo, ad esempio, la
seguente linea posta all'interno di un segmento di programma chiamato DATASEGM:
Variabile1 DW 2F8Dh
Quando l'assembler incontra questa linea all'interno di DATASEGM, capisce
che in quel preciso punto, cioè in quel preciso offset interno a DATASEGM,
deve riservare uno spazio pari a 16 bit da riempire con il valore iniziale
2F8Dh. Nel momento in cui il programma viene caricato in memoria, in
corrispondenza dell'offset interno a DATASEGM, individuato dal nome
Variabile1, sarà presente una locazione da 16 bit contenente il
valore iniziale 2F8Dh; questa locazione potrà quindi essere utilizzata
nelle istruzioni, come operando di tipo Mem.
Ricordando le cose dette nel precedente capitolo, possiamo anche scrivere:
PesoNetto DW (2 + 181) - 4 + (6 * (10 / 5))
L'importante è che l'espressione inizializzante sia formata esclusivamente da
valori immediati; questa espressione, infatti, deve essere risolta dall'assembler
in fase di assemblaggio del programma.
Se non vogliamo specificare un valore iniziale, possiamo ricorrere alle apposite
direttive illustrate in Figura 12.9.
La seguente istruzione, ad esempio, riserva una locazione da 4 byte
(1 dword) che parte dall'offset VarDword:
VarDword RESD 1
La seguente istruzione, invece, riserva 20 locazioni da 2 byte
ciascuna (cioè, 20 locazioni da 1 word ciascuna) a partire
dall'offset VectWord:
VectWord RESW 20
In questi due esempi, il contenuto iniziale delle locazioni di memoria che
abbiamo creato è casuale o, come si dice in gergo, "sporco"; in sostanza,
quando l'assembler incontra le definizioni appena illustrate, non effettua alcuna
inizializzazione delle corrispondenti locazioni di memoria.
12.2.1 Formati IEEE per i numeri in floating point
Analogamente a quanto accade con gli assembler più evoluti, anche NASM
permette di utilizzare un numero reale come valore inizializzante; in questo
caso, possono essere impiegate solo le tre direttive DD, DQ e
DT (o RESD, RESQ e REST per eventuali numeri reali non
inizializzati). È possibile scrivere, ad esempio:
PiGreco DD 3.14
Quando l'assembler incontra una definizione del genere, converte il numero reale
3.14 in una apposita codifica binaria a 32 bit; a titolo di
curiosità, il numero reale 3.14 viene codificato a 32 bit come
01000000010010001111010111000011b.
Il sistema di codifica binaria dei cosiddetti floating point numbers (numeri
reali in virgola mobile), è stato stabilito dall'IEEE (Institute of
Electrical and Electronics Engineers); la Figura 12.10 illustra i tre formati
principali che tutti i linguaggi di programmazione forniscono per questi numeri e
le relative caratteristiche (ampiezza in bit, limite inferiore, limite superiore,
numero di cifre significative dopo la virgola).
La Figura 12.10 mostra i limiti inferiore e superiore dei numeri reali positivi; per
ottenere i corrispondenti limiti inferiore e superiore dei numeri reali negativi, basta
mettere il segno meno davanti ai valori min. e max.
Per poter lavorare seriamente con i numeri reali, è necessario disporre di una
apposita FPU o Fast Processing Unit (unità di elaborazione rapida); la
FPU è in grado, infatti, di operare via hardware direttamente sui numeri reali,
eseguendo su di essi, ad altissima velocità, complessi calcoli matematici che
coinvolgono, funzioni logaritmiche, trigonometriche, esponenziali, etc.
La CPU, invece, opera esclusivamente su valori binari che codificano numeri
interi con o senza segno; su questi numeri la CPU può solo eseguire operazioni
elementari come addizioni, sottrazioni, negazioni, scorrimenti dei bit, etc. Tutto
ciò significa che se definiamo il dato PiGreco mostrato in precedenza, la
CPU lo tratterà come un normalissimo numero binario a 32 bit; se
proprio vogliamo operare sui numeri reali con una semplice CPU, dobbiamo
procedere via software scrivendo complesse procedure per la simulazione dei numeri
in floating point.
Tutte le CPU 80486 DX e superiori, sono dotate di FPU
incorporata; con le vecchie CPU, invece, era necessario acquistare a parte
una apposita FPU che veniva identificata dalle sigle 8087, 80187,
80287, etc.
Tutti gli aspetti relativi alla FPU e ai numeri in virgola
mobile, verranno analizzati nella sezione Assembly Avanzato.
12.2.2 Base predefinita per i valori immediati
In assenza di diverse indicazioni da parte del programmatore, tutti i numeri
espliciti (valori immediati, spiazzamenti, etc) presenti in un programma
Assembly, si intendono espressi in base 10; ciò significa che nella
seguente definizione:
Pressione DW 3800
alla variabile Pressione viene assegnato il valore iniziale 3800
espresso in base 10; se vogliamo indicare in modo esplicito la base
numerica di un valore immediato, dobbiamo servirci dei suffissi mostrati in
Figura 12.11.
I suffissi possono essere espressi con una lettera maiuscola o minuscola; per la
base ottale è preferibile utilizzare il suffisso Q in quanto la lettera
O può essere confusa con uno zero.
Con questi suffissi, la precedente definizione può essere riscritta come:
Pressione DW 0000111011011000b
oppure:
Pressione DW 7330q
oppure:
Pressione DW 3800d
oppure:
Pressione DW 0ED8h
Nel caso dei numeri espliciti espressi in base 16, si può presentare
un piccolo problema; consideriamo a tale proposito la seguente definizione:
VarHex DW F28Ch
Quando l'assembler incontra questa definizione, genera un messaggio di
errore; il perché di questo messaggio di errore è abbastanza evidente. Il
valore immediato F28Ch inizia con una lettera dell'alfabeto, per cui
l'assembler confonde questo valore con un identificatore; se l'identificatore
F28Ch non esiste, l'assembler genera appunto un messaggio di errore.
Per evitare questo problema, dobbiamo mettere uno zero prima della cifra
più significativa di F28Ch; la precedente definizione diventa quindi:
VarHex DW 0F28Ch
NASM permette anche di specificare valori esadecimali attraverso la
stessa sintassi del linguaggio C; possiamo scrivere, ad esempio:
VarWord DW 0x3FB1
Con questa sintassi, non si presenta il problema dello zero iniziale; la
variabile VarHex può essere quindi definita come:
VarHex DW 0xF28C
12.3 Definizione dei dati statici complessi dell'Assembly
Dopo aver parlato dei formati elementari dei dati, che l'Assembly ci mette
a disposizione, passiamo ad esaminare le direttive che ci permettono di definire
i cosiddetti "aggregati" di dati; gli aggregati sono particolari strutture
dati che possono essere trattate come un singolo dato complesso.
A differenza di quanto accade con MASM, l'assembler NASM non
fornisce alcuna direttiva capace di definire dati statici complessi; tuttavia,
questa carenza viene adeguatamente sopperita da uno dei principali punti di
forza di NASM è cioè, dal suo potentissimo preprocessore.
Il preprocessore è quella parte di un compilatore (o assemblatore) che si occupa
della prima fase di compilazione (o assemblaggio); durante questa prima fase, il
preprocessore cerca di analizzare e determinare il significato di ogni simbolo
presente nel codice sorgente.
Molte delle direttive fornite da NASM, in realtà, sono implementate sotto
forma di macro; come vedremo nel seguito, una macro è una porzione di codice
rappresentata da un nome simbolico. Il preprocessore ha l'importante compito di
effettuare la cosiddetta espansione delle macro; in sostanza, ogni volta
che il preprocessore incontra il nome simbolico di una macro, lo sostituisce con
tutto il codice rappresentato dalla macro stessa.
Nel seguito del capitolo, sarà spesso utilizzato il termine direttiva anche
per indicare gli strumenti che NASM implementa sotto forma di macro;
iniziamo allora con la direttiva STRUC attraverso la quale si possono creare
strutture dati propriamente dette.
12.3.1 Dati strutturati di tipo STRUC
La direttiva STRUC permette di creare strutture dati equivalenti alle
struct del C/C++ e ai record del Pascal. Una struttura
racchiude un insieme di dati di qualsiasi formato; la Figura 12.12 illustra la
sintassi che NASM utilizza per questa direttiva.
Come si può notare, una struttura dati viene aperta dalla direttiva STRUC
seguita da un nome simbolico; la struttura stessa viene poi chiusa dalla direttiva
ENDSTRUC.
Osservando la Figura 12.12 si può constatare che una STRUC (come si vedrà
meglio in seguito) è molto simile ad un segmento dati; al suo interno, infatti,
possiamo inserire dati di qualsiasi formato (comprese le strutture stesse).
Attraverso le dichiarazioni, stiamo creando in pratica nuovi formati di dati; i
nomi attribuiti a questi nuovi formati di dati, possono essere usati come vere
e proprie direttive. Nel caso di Figura 12.12, possiamo utilizzare il nome
Struc1 come se fosse una delle direttive di Figura 12.8; in base alla
terminologia usata nei linguaggi di programmazione, un dato di tipo Struc1
viene definito istanza di Struc1.
Per creare una istanza di una STRUC, l'assembler NASM fornisce la
direttiva ISTRUC; all'interno di un segmento di programma, possiamo
definire un dato di tipo Struc1 attraverso la sintassi mostrata in
Figura 12.13.
In questo modo stiamo definendo ed inizializzando una struttura varStruc1
di tipo Struc1; come si può notare, la lista degli inizializzatori è
racchiusa tra le parole chiave ISTRUC e IEND.
È importante sottolineare ancora la differenza fondamentale che esiste tra una
dichiarazione e una definizione; con la dichiarazione di
Figura 12.12 stiamo semplicemente illustrando all'assembler le caratteristiche
generali di un dato strutturato come Struc1. Attraverso, invece, la
definizione di varStruc1 (Figura 12.13), stiamo chiedendo
all'assembler di riservare fisicamente una locazione di memoria di dimensioni
sufficienti a contenere la stessa struttura varStruc1; quando l'assembler
incontra questa definizione, crea in quel preciso punto del segmento di programma,
una locazione di memoria da:
2 + 4 + 2 + 1 + 1 = 10 byte
Supponendo che varStruc1 si trovi all'offset 0028h di un segmento
di programma, allora questa struttura verrà disposta in memoria secondo lo schema
di Figura 12.14.
Come accade per il linguaggio C, anche in Assembly gli elementi che
fanno parte di una struttura vengono chiamati membri (nel Pascal si
utilizza il termine campi); nel nostro caso, la struttura varStruc1
contiene i membri elencati in Figura 12.13 e in Figura 12.14.
Osservando allora la Figura 12.14, possiamo affermare che:
- varSt1 si trova all'offset 0000h di varStruc1
- varSt2 si trova all'offset 0002h di varStruc1
- varSt3 si trova all'offset 0006h di varStruc1
- varSt4 si trova all'offset 0008h di varStruc1
- varSt5 si trova all'offset 0009h di varStruc1
Tenendo conto poi del fatto che varStruc1 si trova all'offset 0028h
di un segmento di programma (chiamato, ad esempio, DATASEGM), possiamo
affermare che:
- varSt1 si trova all'offset 0028h + 0000h = 0028h di DATASEGM
- varSt2 si trova all'offset 0028h + 0002h = 002Ah di DATASEGM
- varSt3 si trova all'offset 0028h + 0006h = 002Eh di DATASEGM
- varSt4 si trova all'offset 0028h + 0008h = 0030h di DATASEGM
- varSt5 si trova all'offset 0028h + 0009h = 0031h di DATASEGM
Coerentemente con le considerazioni appena svolte, la sintassi:
NomeStruttura + NomeMembro
rappresenta l'offset di NomeMembro calcolato rispetto all'indirizzo
iniziale del segmento di programma in cui è stata definita la struttura
NomeStruttura.
Analogamente, la sintassi:
[NomeStruttura + NomeMembro]
rappresenta il contenuto di NomeMembro.
In riferimento all'esempio di Figura 12.14, l'istruzione:
mov bx, varStruc1+varSt3
trasferisce in BX l'offset 002Eh di varSt3.
Analogamente, l'istruzione:
mov ax, [varStruc1+varSt3]
trasferisce in AX il contenuto 884Ch di varSt3.
Volendo trasferire in DX la WORD più significativa di
varSt2, possiamo servirci dei size operators scrivendo:
mov dx, word [varStruc1+varSt2+2]
Osservando, infatti, la Figura 12.14, si vede subito che l'operando sorgente
di questa istruzione fa riferimento alla WORD che si trova all'offset:
0028h + 0002h + 0002h = 002Ch
del segmento di programma che contiene varStruc1; dopo il trasferimento
dati si ottiene quindi DX=3BF1h (WORD più significativa di
varSt2).
Naturalmente, possiamo accedere a varStruc1 anche attraverso i registri
puntatori; ponendo allora BX=0028h, SI=0002h e ricordando
l'associazione predefinita tra BX e DS, l'istruzione dell'ultimo
esempio può essere riscritta come:
mov dx, [bx+si+2]
Il potente preprocessore di NASM rende possibile la dichiarazione di
strutture che contengono tra i loro membri altre strutture; in un caso del
genere si parla di strutture innestate.
In Figura 12.15 vediamo un esempio che mostra una struttura la quale ha tra
i suoi membri un'altra struttura.
In questo esempio, dichiariamo innanzi tutto una struttura Point2d che
è formata da due membri, x e y, entrambi di tipo WORD;
questi due membri rappresentano le coordinate (ascissa e ordinata) di un punto
del piano.
Successivamente dichiariamo una struttura Rect che è formata da due
membri, p1 e p2, entrambi di tipo Point2d; i due punti
p1 e p2 rappresentano i vertici, in alto a sinistra e in basso
a destra, di un rettangolo.
Se ora vogliamo creare e inizializzare un'istanza di Rect, possiamo
procedere come mostrato in Figura 12.16.
Supponendo che r1 si trovi all'offset 00F6h di un segmento di
programma chiamato DATASEGM, allora questa struttura verrà disposta
in memoria secondo lo schema di Figura 12.17.
In analogia a quanto già visto per la struttura Struc1 e tenendo conto
della Figura 12.17, possiamo affermare che:
[r1+p1+x] rappresenta il contenuto 3AB2h della locazione di
memoria da 16 bit che si trova all'offset:
r1 + p1 + x = 00F6h + 0000h + 0000h = 00F6h
[r1+p1+y] rappresenta il contenuto 1C8Fh della locazione di
memoria da 16 bit che si trova all'offset:
r1 + p1 + y = 00F6h + 0000h + 0002h = 00F8h
[r1+p2+x] rappresenta il contenuto 284Dh della locazione di
memoria da 16 bit che si trova all'offset:
r1 + p2 + x = 00F6h + 0004h + 0000h = 00FAh
[r1+p2+y] rappresenta il contenuto 6FC5h della locazione di
memoria da 16 bit che si trova all'offset:
r1 + p2 + y = 00F6h + 0004h + 0002h = 00FCh
In base a queste considerazioni, l'istruzione:
mov di, r1+p2+x
trasferisce in DI l'offset 00FAh del membro x di p2;
di conseguenza, l'istruzione:
mov cx, [r1+p2+x]
trasferisce in CX il contenuto 284Dh del membro x di p2.
Una struttura innestata può avere tra i suoi membri ulteriori strutture innestate;
gli innesti possono andare avanti sino all'esaurimento della memoria disponibile.
In NASM, i nomi simbolici utilizzati per identificare i membri di una
struttura, sono visibili anche all'esterno della struttura stessa; ciò significa
che tali nomi non possono essere ridefiniti in altri punti del modulo che contiene
la definizione della struttura.
Se vogliamo rendere locali (cioè, invisibili all'esterno) i nomi dei membri di
una struttura, dobbiamo servirci di una apposita sintassi che consiste
nell'anteporre un punto ('.') a ciascuno dei nomi stessi; ad esempio, la
struttura Struc1 di Figura 12.12 può essere dichiarata come illustrato in
Figura 12.18.
A questo punto, i membri di Struc1 (e di qualsiasi sua istanza) devono
essere specificati attraverso la sintassi Struc1.varSt1,
Struc1.varSt2, etc; ad esempio, la definizione dell'istanza
varStruc1 di Struc1 assume l'aspetto mostrato in Figura 12.19.
Naturalmente, questa stessa sintassi deve essere utilizzata anche nelle
istruzioni; possiamo scrivere, ad esempio:
mov ax, [varStruc1+Struc1.varSt3]
In Figura 12.20 viene illustrata la sintassi da utilizzare per rendere locali
i nomi x, y, p1 e p2 dei membri delle strutture
Point2d e Rect di Figura 12.15.
La definizione dell'istanza r1 di Rect assume l'aspetto
mostrato in Figura 12.21.
A questo punto possiamo scrivere istruzioni del tipo:
mov cx, [r1+Rect.p2+Point2d.x]
Ovviamente, tutti i nomi locali ad una struttura, possono essere ridichiarati,
ad esempio, all'interno di altre strutture.
12.3.2 Dati strutturati di tipo UNION
Una UNION è una STRUC all'interno della quale tutti i membri
si trovano all'offset 0000h; in sostanza, tutti i membri di una
UNION condividono lo stesso indirizzo iniziale e risultano quindi
sovrapposti tra loro.
Questo tipo di aggregato si rivela molto utile quando si ha bisogno di una
variabile che, in fase di esecuzione di un programma, assume formati diversi
(BYTE, WORD, etc); le UNION dell'Assembly
equivalgono alle union del C/C++ e ai record varianti
del Pascal.
NASM non fornisce alcuna direttiva che permetta la creazione di dati
di tipo UNION; questa carenza viene, però, facilmente superata grazie
alle direttive ABSOLUTE e STRUC.
Abbiamo già visto che ABSOLUTE può essere utilizzata come attributo
che obbliga un segmento di programma a partire da un determinato indirizzo
logico XXXXh:0000h e cioè, da un indirizzo fisico assoluto XXXX0h
multiplo intero di 16; a tale proposito, bisogna assegnare al segmento
di programma l'attributo ABSOLUTE=XXXXh.
La direttiva ABSOLUTE è estremamente versatile, tanto che accetta come
argomento persino un identificatore; consideriamo, ad esempio, le seguenti istruzioni:
In questo caso, la direttiva ABSOLUTE dice all'assembler che
varDword1 deve essere posizionata allo stesso offset di varWord1;
la conseguenza pratica è che i 16 bit meno significativi (28B7h)
di varDword1 sovrascriveranno i 16 bit (3C2Ah) di
varWord1!
Sulla base delle considerazioni appena svolte, possiamo facilmente dichiarare
una UNION attraverso la sintassi illustrata in Figura 12.22.
Osserviamo che Union1 dichiara una UNION formata da un membro
di tipo BYTE, uno di tipo WORD e uno di tipo DWORD; se
ora definiamo in un segmento di programma una istanza VarUnion1 di
Union1, dobbiamo dire all'assembler di riservare a questa istanza uno
spazio la cui ampiezza in bit deve essere pari a quella del membro più grande
di Union1. Come possiamo notare in Figura 12.22, il membro più grande
di Union1 è varUn3 che ha una ampiezza di 32 bit; tale
spazio da 32 bit è sufficiente a contenere uno qualunque dei tre membri
di Figura 12.22.
Appare evidente che la creazione di una istanza di una UNION
comporta l'inizializzazione di uno solo dei suoi membri; tale membro
rappresenta il valore corrente della UNION.
A causa del metodo che stiamo utilizzando per dichiarare le UNION,
nella definizione delle istanze è necessario inizializzare sempre il
membro dotato di maggiore ampiezza in bit; la Figura 12.23 illustra un
esempio pratico che consiste nel definire una istanza varUnion1 di
Union1.
In questo caso stiamo riservando a varUnion1 uno spazio da 32 bit,
sufficiente a contenere qualsiasi membro della struttura; all'interno del blocco
codice del programma, possiamo ora decidere quale membro utilizzare come valore
corrente di varUnion1. Se la nostra scelta cade, ad esempio, su
varUn2, possiamo inizializzare tale membro scrivendo istruzioni del tipo:
mov word [varUnion1+varUn2], 2BFFh
Abbiamo quindi inizializzato il membro varUn2 di tipo WORD;
supponendo che varUnion1 si trovi all'offset 00D4h di un
segmento di programma, allora questa UNION assumerà in memoria lo
schema illustrato in Figura 12.24.
Osserviamo subito che i tre membri di varUnion1 condividono tutti lo stesso
offset iniziale 00D4h; siccome abbiamo inizializzato il membro varUn2
a 16 bit, solamente i primi 16 bit di varUnion1 sono
significativi. In sostanza, subito dopo l'inizializzazione, solamente il membro
varUnion1+varUn2 contiene un dato valido; durante la fase di esecuzione del
programma, possiamo alterare in qualunque momento questa situazione scrivendo, ad
esempio:
mov byte [varUnion1+varUn1], 2Fh
Subito dopo l'esecuzione di questa istruzione, la locazione di Figura 12.24 assume
l'aspetto mostrato in Figura 12.25.
Da questo momento, solamente varUnion1+varUn1 contiene un dato valido;
questa situazione permane sino alla prossima modifica di varUnion1.
Possiamo dire quindi che, durante la fase di esecuzione del programma,
varUnion1 può essere utilizzata per contenere a scelta, un dato a
8 bit, oppure un dato a 16 bit, oppure un dato a 32 bit;
chiaramente, è compito del programmatore tenere traccia del membro che è valido
in un determinato momento.
Una UNION può contenere tra i suoi membri anche una o più STRUC;
a sua volta, ogni STRUC innestata può contenere altre strutture innestate
o anche altre unioni innestate. La gestione di questi innesti è tanto più complessa
quanto più sono "contorti" gli innesti stessi; in ogni caso, raramente si ha bisogno
di strutture dati così complicate.
La Figura 12.26 illustra un esempio di UNION che contiene tra i suoi membri
anche una STRUC.
La UNION chiamata Frutta può contenere un BYTE di nome
Mela, oppure una WORD di nome Pera, oppure una DWORD
di nome Ciliegia, oppure una struttura Agrumi di nome Altro;
la struttura Agrumi a sua volta è formata da due DWORD e occupa
quindi 64 bit.
In seguito a questa dichiarazione, nel segmento dati del nostro programma
possiamo utilizzare il nome Frutta per definire le istanze di questa
UNION; ricordando quanto è stato detto in precedenza, ogni istanza di
Frutta deve sempre inizializzare il membro dotato di maggiore ampiezza
in bit (in questo caso, Altro), come mostrato in Figura 12.27.
Quando l'assembler incontra questa definizione, riserva a varFrutta1
uno spazio sufficiente a contenere il membro più grande dell'unione
Frutta; dalla Figura 12.26 si rileva che il membro più grande è
Altro che occupa 64 bit.
Nel segmento di codice del nostro programma possiamo ora procedere con
l'inizializzazione di uno dei membri di varFrutta1; supponendo di voler
inizializzare per primo il membro Pera a 16 bit, possiamo
scrivere, ad esempio:
mov word [varFrutta1+Pera], 8B21h
Nell'ipotesi che varFrutta1 si trovi all'offset 00C8h di un
segmento di programma, allora questa UNION assumerà in memoria lo
schema illustrato in Figura 12.28.
Da questo momento, solamente varFrutta1+Pera contiene un dato valido;
questa situazione permane sino alla prossima modifica di varFrutta1. Ad
un certo punto della fase di esecuzione, possiamo decidere di alterare la
situazione di Figura 12.28, inizializzando varFrutta1+Altro; possiamo
scrivere, ad esempio:
mov dword [varFrutta1+Altro+Arancio], 3FAB819Ch
e:
mov dword [varFrutta1+Altro+Limone], 6DF934E1h
Dopo l'esecuzione di queste istruzioni, la locazione di memoria di Figura 12.28
assume l'aspetto mostrato in Figura 12.29.
Da questo momento, solamente varFrutta1+Altro contiene un dato valido;
questa situazione permane sino alla prossima modifica di varFrutta1.
Tutte le considerazioni appena illustrate per le UNION, sono valide
anche per le STRUC; dagli esempi che sono stati presentati si può anche
constatare che seguendo la logica, la gestione di questi aggregati complessi
non presenta grosse difficoltà. Nei casi più contorti, possiamo semplificarci
notevolmente la vita tracciando su un foglio di carta uno schema della
locazione di memoria che contiene l'aggregato che vogliamo gestire; gli
schemi come quelli di Figura 12.25 o di Figura 12.29, ci permettono anche di
capire meglio il modo di lavorare della CPU.
12.3.3 Dati strutturati di tipo RECORD
Attraverso la direttiva RECORD, fornita dagli assembler come
MASM, possiamo dichiarare una singola locazione di memoria contenente
una sequenza di dati, ciascuno dei quali può avere una qualsiasi ampiezza in
bit; questa direttiva si rivela molto utile nel momento in cui abbiamo la
necessità di compattare nel più piccolo spazio possibile, una numerosa serie
di informazioni.
Un esempio pratico è rappresentato dal registro FLAGS della CPU,
dove ogni singolo bit ha un preciso significato; il registro FLAGS può
essere visto quindi come un classico esempio di RECORD.
La direttiva RECORD veniva largamente utilizzata nei vecchi assembler
e presenta (come vedremo in seguito) notevoli limitazioni che la rendono ormai
obsoleta; in particolare, si deve tenere presente che un RECORD non è
altro che un normalissimo numero binario la cui ampiezza in bit non può superare
quella dell'architettura della CPU.
I moderni assembler possono fare a meno della direttiva RECORD in quanto
gestiscono facilmente questi dati strutturati attraverso l'uso delle istruzioni
logiche e degli operatori logici; questa è proprio la strada che si segue con
NASM per sopperire all'assenza di tale direttiva.
Per analizzare un esempio pratico, supponiamo di voler definire un record
varData1, destinato a contenere in forma compatta una data del calendario
compresa tra 01/01/0000 e 31/12/2047; a tale proposito,
suddividiamo il record in tre membri destinati a contenere, il giorno, il mese e
l'anno.
Osserviamo che:
- il membro Giorno richiede almeno 5 bit in modo da poter contenere
un valore compreso tra 0 e 31
- il membro Mese richiede almeno 4 bit in modo da poter contenere un
valore compreso tra 0 e 15
- il membro Anno richiede almeno 11 bit in modo da poter contenere un
valore compreso tra 0 e 2047
Con MASM possiamo gestire il tutto attraverso la seguente dichiarazione:
RecData RECORD Giorno: 5, Mese: 4, Anno: 11
A questo punto, nel blocco dati del nostro programma possiamo creare una
istanza di RecData attraverso la seguente definizione riferita alla data
del 24/10/2003 (in binario 11000b/1010b/11111010011b):
varData1 RecData < 24, 10, 2003 >
Come vedremo in un capitolo successivo, in NASM il simbolo <<
rappresenta l'operatore shift logico verso sinistra, mentre il simbolo |
rappresenta l'operatore OR logico "bitwise" (bit a bit); osserviamo ora
che Anno occupa 11 bit, per cui Mese deve essere "shiftato" a
sinistra di 11 bit. A sua volta, Mese occupa 4 bit, per cui
Giorno deve essere "shiftato" a sinistra di 11+4=15 bit; in base a
queste considerazioni, possiamo facilmente creare il record varData1 con
la seguente definizione NASM:
varData1 dd (24 << 15) | (10 << 11) | 2003
che equivale a scrivere, direttamente:
varData1 dd 11000101011111010011b
Quando l'assembler incontra questa definizione, riserva uno spazio pari a
32 bit destinato a varData1; come è stato già anticipato, nel caso
delle CPU 80286 e inferiori l'ampiezza di un RECORD può essere di
8 o 16 bit, mentre con le CPU 80386 e superiori si può avere
anche una ampiezza di 32 bit.
Nell'ipotesi che varData1 si trovi in memoria all'offset 008Dh di
un segmento di programma, si ottiene la situazione illustrata in Figura 12.30.
Osserviamo che Anno occupa gli 11 bit meno significativi di
varData1; alla sinistra di Giorno troviamo, inoltre, 12
zeri aggiunti dall'assembler per riempire totalmente i 32 bit di questo
dato.
Con i vecchi assembler, per l'accesso ad un record come varData1 si poteva
utilizzare una sintassi del tipo varData1.Giorno, varData1.Mese,
etc; le versioni più recenti di MASM non permettono però questa possibilità.
Del resto, osservando la Figura 12.30 si può facilmente constatare che non è
possibile maneggiare i membri di un RECORD come si fa con le strutture;
ciò è dovuto al fatto che la CPU non permette l'accesso a dati che non
abbiano una ampiezza in bit multipla intera di 8 e che non siano almeno
allineati al BYTE (come accade con i membri di varData1).
In definitiva, la direttiva RECORD si rivela utile solo per la definizione
dei dati ma non per la loro gestione; come è stato già anticipato, nei capitoli
successivi verranno illustrati gli operatori logici e, in particolare, le
istruzioni logiche attraverso le quali è possibile accedere ai singoli bit di una
locazione di memoria!
12.3.4 Le direttive TIMES e %REP %ENDREP
Attraverso la direttiva TIMES è possibile "replicare" un oggetto che può
essere un'istruzione Assembly o un dato semplice; usualmente, la direttiva
TIMES viene inserita in un segmento di programma in quanto comporta la
definizione di un aggregato di oggetti, con conseguente allocazione della memoria.
La sintassi generale per TIMES è la seguente:
NomeSimbolico TIMES Repliche Oggetto
Il NomeSimbolico è facoltativo; il valore immediato Repliche indica
quante volte Oggetto deve essere replicato.
Gli oggetti replicati con TIMES vengono disposti in memoria in modo
consecutivo e contiguo; indicando con Dimensione(Oggetto) la dimensione
in byte di Oggetto, possiamo affermare allora che la memoria complessiva
da allocare è pari al prodotto:
Repliche * Dimensione(Oggetto)
Consideriamo il seguente esempio:
VettWord1 times 4 dw 03FDh
Quando l'assembler incontra questa definizione, riserva uno spazio pari a 4
word, per un totale di 4*2=8 byte; in questo spazio vengono sistemate
4 WORD, ciascuna delle quali viene inizializzata con il valore
03FDh.
Nell'ipotesi che VettWord1 si trovi all'offset 00F2h di un segmento
di programma, si ottiene per questa variabile la disposizione in memoria mostrata
in Figura 12.31.
Dalla Figura 12.31 si rileva chiaramente che:
- La WORD di indice 0 è individuata da [VettWord1+0]
- La WORD di indice 1 è individuata da [VettWord1+2]
- La WORD di indice 2 è individuata da [VettWord1+4]
- La WORD di indice 3 è individuata da [VettWord1+6]
Caricando in BX l'offset 00F2h e ricordando l'associazione
predefinita tra BX e DS, possiamo anche dire che:
- La WORD di indice 0 è individuata da [BX+0]
- La WORD di indice 1 è individuata da [BX+2]
- La WORD di indice 2 è individuata da [BX+4]
- La WORD di indice 3 è individuata da [BX+6]
Una sequenza consecutiva e contigua di oggetti, tutti della stessa natura,
viene chiamata vettore; ogni oggetto appartenente ad un vettore
prende il nome di elemento del vettore.
Osserviamo che formalmente non esiste alcuna differenza tra l'istruzione:
VettWord2 times 100 resw 1
e l'istruzione:
VettWord2 resw 100
Infatti, nell'eseguibile finale avremo in entrambi i casi 100 locazioni
consecutive e contigue da 16 bit ciascuna; la differenza sostanziale
riguarda solo il fatto che la seconda istruzione viene assemblata da NASM
circa 100 volte più velocemente della prima!
TIMES può essere applicata anche alle istruzioni; ad esempio, se vogliamo
sommare per 10 volte il contenuto di BX al contenuto di AX,
possiamo scrivere:
times 10 add ax, bx
Non è permesso applicare TIMES ad oggetti ottenuti attraverso una macro;
ad esempio, non è possibile usare TIMES per replicare una STRUC.
Questo problema può essere facilmente risolto grazie alle direttive %REP
%ENDREP; queste due direttive replicano tutto ciò che viene inserito al loro
interno, compresa la stessa TIMES o ulteriori %REP %ENDREP.
Supponiamo allora di voler creare un vettore di 10 elementi, dove ogni
elemento è un Rect di Figura 12.15; nel blocco dati del nostro programma
possiamo allora inserire la seguente definizione:
Davanti a questa definizione, l'assembler non si spaventa per niente e crea uno
spazio sufficiente per contenere 10 strutture Rect; ogni Rect
occupa 8 byte, per cui la memoria totale allocata dall'assembler è pari a
10*8=80 byte.
Supponendo che VettRect1 si trovi all'offset 00F6h di un segmento di
programma, allora questo vettore di oggetti Rect verrà disposto in memoria
secondo lo schema di Figura 12.32; in questa figura, per ovvie ragioni di spazio,
vengono mostrati solamente i primi due Rect del vettore.
Analizzando la Figura 12.32 rileviamo che il Rect di indice 0 è
individuato da [VettRect1+0]; di conseguenza:
[VettRect1+0+p1] rappresenta il Point2d che si trova all'offset:
VettRect1 + 0 + p1 = 00F6h + 0000h + 0000h = 00F6h
[VettRect1+0+p1+x] rappresenta la WORD che si trova all'offset:
VettRect1 + 0 + p1 + x = 00F6h + 0000h + 0000h + 0000h = 00F6h
[VettRect1+0+p1+y] rappresenta la WORD che si trova all'offset:
VettRect1 + 0 + p1 + y = 00F6h + 0000h + 0000h + 0002h = 00F8h
Analogamente:
[VettRect1+0+p2] rappresenta il Point2d che si trova all'offset:
VettRect1 + 0 + p2 = 00F6h + 0000h + 0004h = 00FAh
[VettRect1+0+p2+x] rappresenta la WORD che si trova all'offset:
VettRect1 + 0 + p2 + x = 00F6h + 0000h + 0004h + 0000h = 00FAh
[VettRect1+0+p2+y] rappresenta la WORD che si trova all'offset:
VettRect1 + 0 + p2 + y = 00F6h + 0000h + 0004h + 0002h = 00FCh
Sempre dalla Figura 12.32 rileviamo che ogni Rect occupa 8 byte, per
cui il Rect di indice 1 (cioè, il secondo elemento del vettore) è
individuato da [VettRect1+8]; di conseguenza:
[VettRect1+8+p1] rappresenta il Point2d che si trova all'offset:
VettRect1 + 8 + p1 = 00F6h + 0008h + 0000h = 00FEh
[VettRect1+8+p1+x] rappresenta la WORD che si trova all'offset:
VettRect1 + 8 + p1 + x = 00F6h + 0008h + 0000h + 0000h = 00FEh
[VettRect1+8+p1+y] rappresenta la WORD che si trova all'offset:
VettRect1 + 8 + p1 + y = 00F6h + 0008h + 0000h + 0002h = 0100h
Analogamente:
[VettRect1+8+p2] rappresenta il Point2d che si trova all'offset:
VettRect1 + 8 + p2 = 00F6h + 0008h + 0004h = 0102h
[VettRect1+8+p2+x] rappresenta la WORD che si trova all'offset:
VettRect1 + 8 + p2 + x = 00F6h + 0008h + 0004h + 0000h = 0102h
[VettRect1+8+p2+y] rappresenta la WORD che si trova all'offset:
VettRect1 + 8 + p2 + y = 00F6h + 0008h + 0004h + 0002h = 0104h
Per accedere agli altri Rect si utilizza lo stesso meccanismo; infatti, il
Rect di indice 2 è individuato da [VettRect1+16], il Rect
di indice 3 è individuato da [VettRect1+24] e così via.
Volendo utilizzare i registri puntatori, possiamo porre BX=00F6h; a questo
punto osserviamo che:
- [VettRect1+(Indice*8)+p1+x] equivale a [BX+(Indice*8)+0+0]
- [VettRect1+(Indice*8)+p1+y] equivale a [BX+(Indice*8)+0+2]
- [VettRect1+(Indice*8)+p2+x] equivale a [BX+(Indice*8)+4+0]
- [VettRect1+(Indice*8)+p2+y] equivale a [BX+(Indice*8)+4+2]
Volendo trasferire, ad esempio, in AX il contenuto del membro p2+y
del Rect di indice 3, dobbiamo scrivere l'istruzione:
mov ax, [bx+(3*8)+4+2]
L'espressione (3*8)+4+2 non comporta alcuna perdita di tempo in quanto
viene risolta dall'assembler in fase di assemblaggio del programma; alla fine
viene generato un codice macchina contenente un semplice effective address
del tipo [BX+Disp16].
Come è stato detto in precedenza, l'oggetto replicato da %REP %ENDREP può
essere persino una direttiva TIMES seguita da un ulteriore oggetto da
replicare; possiamo scrivere, ad esempio:
In questo modo stiamo chiedendo all'assembler di replicare 5 oggetti,
ciascuno dei quali è un vettore di 8 WORD che valgono tutte
72F8h; complessivamente abbiamo bisogno quindi di 5*8=40 word,
per un totale di 40*2=80 byte.
Un "vettore di vettori" prende il nome di matrice rettangolare o
vettore bidimensionale; ogni oggetto della matrice prende il nome di
elemento della matrice.
La matrice MatrWord1 può essere schematizzata simbolicamente come in
Figura 12.33.
Naturalmente, MatrWord1 viene disposta in memoria, non come in Figura 12.33,
ma come una sequenza di 5*8=40 WORD consecutive e contigue; il
programmatore Assembly è libero di gestire come meglio crede questa
disposizione. Diversi linguaggi di alto livello dispongono in memoria le matrici,
come una sequenza consecutiva e contigua di righe; in questo caso si dice che la
matrice è row ordered (ordinata per righe). Altri linguaggi, invece,
dispongono in memoria le matrici, come una sequenza consecutiva e contigua di
colonne; in questo caso si dice che la matrice è column ordered (ordinata
per colonne).
Tornando a MatrWord1, possiamo osservare che i vari elementi di questa
matrice sono individuati dalla sequenza:
[MatrWord1+0], [MatrWord1+2], [MatrWord1+4], ...
Se abbiamo tracciato su un foglio di carta uno schema come quello di Figura 12.33,
si presenta il problema di come risalire all'indirizzo di memoria dell'elemento
che si trova all'incrocio tra un certo indice di riga e un certo indice di colonna;
in sostanza, dato l'elemento di Figura 12.33 che si trova all'incrocio tra la riga
i e la colonna j, vogliamo determinare lo spiazzamento del
corrispondente elemento che si trova in memoria.
Indicando con Dimensione(elemento) la dimensione in byte di ciascun elemento
della matrice, la formula da utilizzare è la seguente:
Spiazzamento = ((i * numero_colonne) + j) * Dimensione(elemento)
Naturalmente, lo Spiazzamento così calcolato deve essere sommato all'offset
da cui inizia MatrWord1 in memoria; ad esempio, l'elemento che si trova
all'incrocio tra la riga i=3 e la colonna j=4, corrisponde a:
[MatrWord1+(((3*8)+4)*2)] = [MatrWord1+56]
La direttiva TIMES può essere utilizzata anche come membro di una
STRUC o di una UNION; la Figura 12.34 mostra un esempio pratico dove
il membro Revisioni è un RECORD RecData che abbiamo visto in
precedenza.
Al posto di:
Revisioni times 10 resd 1
possiamo anche scrivere:
Revisioni resd 10
Questa struttura è formata da 3 word e da un vettore di 10
RecData; ogni RecData occupa 32 bit (4 byte), per cui
la dimensione totale di Automobile è pari a:
2 + 2 + 2 + (10 * 4) = 6 + 40 = 46 byte
In un segmento di programma possiamo ora creare delle istanze di
Automobile; la seguente definizione:
crea un vettore di 20 strutture Automobile; questo vettore
richiede quindi 46*20=920 byte di memoria.
Al posto di:
at Revisioni, times 10 resd 1
possiamo anche scrivere:
at Revisioni, resd 10
I vari elementi del vettore FerrariF3000 sono:
In relazione all'elemento [FerrariF3000+46] e tenendo presente
che ogni RecData occupa 4 byte, possiamo dire che i vari
elementi del vettore Revisioni sono:
Volendo inserire in [FerrariF3000+46+Revisioni+0] la data
12/03/2008 (in binario 01100b/0011b/11111011000b), possiamo
scrivere l'istruzione:
mov dword [FerrariF3000+46+Revisioni+0], (12 << 15) | (3 << 11) | 2008
oppure, direttamente:
mov dword [FerrariF3000+46+Revisioni+0], 01100001111111011000b
Utilizzando numerose direttive %REP %ENDREP e TIMES innestate, si
possono ottenere vettori tridimensionali, quadridimensionali, etc; tutto ciò si
applica non solo a oggetti semplici, ma anche a oggetti di tipo struttura, unione,
etc. In questo modo si ottengono aggregati di dati particolarmente complessi; la
dimensione complessiva di ciascuno di questi aggregati non deve superare 65536
byte.
12.3.5 Creazione diretta di vettori monodimensionali e multidimensionali
Tutte le definizioni effettuate con la direttiva TIMES possono essere
ottenute anche in modo diretto; ciò è possibile in quanto, in fase di definizione
di un dato, l'assembler ci permette di specificare una lista formata da uno o più
inizializzatori separati da virgole.
Ad esempio, la definizione:
VettWord1 times 8 dw 03BCh
equivale a:
VettWord1 dw 03BCh, 03BCh, 03BCh, 03BCh, 03BCh, 03BCh, 03BCh, 03BCh
Anche in questo caso quindi, l'assembler crea una locazione di memoria da 8
word, nella quale vengono disposte in modo consecutivo e contiguo, 8 WORD
inizializzate tutte con il valore fisso 03BCh.
Il vantaggio del metodo diretto sta nel fatto che possiamo specificare una lista
di inizializzatori, tutti diversi tra loro; possiamo scrivere, ad esempio:
VettWord1 dw 2800, 3500, 1890, 3961, 8767, 9945, 1998, 6780
Se vogliamo simulare una matrice da 4*10 BYTE, possiamo ricorrere
alle direttive %REP %ENDREP e TIMES scrivendo:
Volendo utilizzare il metodo diretto, possiamo assegnare un valore diverso
ad ogni elemento della matrice; in questo modo si ottiene la situazione mostrata
in Figura 12.35 (ricordiamo che i nomi simbolici da assegnare ai dati sono
facoltativi).
Questa matrice è chiaramente ordinata per righe; se vogliamo ordinare la
matrice per colonne, dobbiamo definirla come in Figura 12.36 (matrice da
10*4 BYTE).
Chiaramente, in entrambi i casi l'assembler crea una locazione di memoria
da 40 byte; all'interno di questo spazio vengono sistemati in modo
consecutivo e contiguo, 40 elementi di tipo BYTE.
In riferimento alla struttura Rect dichiarata in Figura 12.15, abbiamo
visto che è possibile scrivere:
Di conseguenza, possiamo anche scrivere:
e così via per gli altri 7 Rect.
Volendo creare un vettore bidimensionale di 4*3 oggetti di tipo
Rect, possiamo scrivere:
Di conseguenza, possiamo anche utilizzare il metodo diretto inserendo un nome
simbolico MatrRect1 seguito da 4*3=12 istanze di Rect.
Tutte le considerazioni appena svolte si applicano anche all'oggetto associato
ad una direttiva TIMES; ad esempio, la seguente definizione:
può essere scritta anche come:
MatrWord1 times 4 dw 0, 0, 0, 0, 0, 0, 0, 0, 0 ,0
Questa definizione replica 4 volte un vettore formato da 10
oggetti di tipo WORD.
12.3.6 Caratteri e stringhe alfanumeriche
Come già sappiamo, un generico simbolo appartenente al set
ASCII, viene
codificato attraverso un numero binario a 8 bit; la lettera A,
ad esempio, viene codificata come 01000001b (65). Il metodo più
banale per definire un dato contenente il codice
ASCII
della lettera A, consiste allora nello scrivere:
AsciiSymbol db 65
L'Assembly però ci permette di utilizzare una sintassi molto più chiara
ed elegante; possiamo scrivere, infatti:
AsciiSymbol db 'A'
Questa istruzione è assolutamente equivalente a quella precedente; l'assembler,
infatti, provvede a convertire 'A' nel valore 65 a 8 bit.
Al posto degli apici singoli si possono utilizzare anche i doppi apici
("A").
In base a queste considerazioni, possiamo facilmente definire intere
stringhe; si definisce stringa ASCII, un vettore di codici
ASCII.
Possiamo scrivere allora:
asmString db 'A', 's', 's', 'e', 'm', 'b', 'l', 'y'
Questa istruzione è perfettamente equivalente a:
asmString db 65, 115, 115, 101, 109, 98, 108, 121
Anche in questo caso, l'Assembly ci permette di utilizzare una
sintassi molto più chiara ed elegante; possiamo scrivere, infatti:
asmString db 'Assembly'
Quando l'assembler incontra questa definizione, crea un vettore di
8 byte e lo riempie con gli 8 codici
ASCII
dei simboli specificati da asmString.
Se all'interno della stringa è presente un apostrofo, possiamo servirci dei
doppi apici scrivendo:
asmString db "L'Assembly"
Alternativamente, osservando che l'apostrofo corrisponde al codice
ASCII
39, possiamo scrivere:
asmString db 'L', 39, 'Assembly'
Osservando che ogni codice
ASCII
occupa 8 bit, è anche possibile scrivere:
wordString dw 'ab'
oppure:
dwordString dd 'abcd'
e così via.
Grazie alle stringhe, possiamo dichiarare strutture dati come quella
mostrata in Figura 12.37.
Come è stato spiegato in un precedente capitolo, i codici
ASCII
sono numeri a 8 bit che permettono di rappresentare solo
28=256 simboli, appartenenti principalmente alla cultura
occidentale; con la diffusione planetaria dei computer e con la loro
interconnessione attraverso Internet, chiaramente 256 simboli
non sono più sufficienti.
Per questo motivo, è stato definito un nuovo set di codici, chiamato
UNICODE; in questo caso vengono utilizzati codici a 16 bit
che permettono quindi di rappresentare 216=65536 simboli
differenti comprendenti, tra l'altro, i simboli dell'alfabeto cirillico,
dell'alfabeto cinese, giapponese, arabo, indiano, etc. Per motivi di
compatibilità, i primi 128 codici UNICODE coincidono con i
primi 128 codici
ASCII.
Le stringhe UNICODE quindi, a differenza delle stringhe ASCII,
sono vettori di WORD; se si sta usando un editor che supporta i caratteri
UNICODE, la stringa:
unicodeStr dw 0910h, 0920h, 0930h, 0940h, 0950h, 0960h
verrebbe visualizzata in caratteri Devanagari come:
ऐ ठ र ी ॐ ॠ
All'indirizzo
Unicode Book
si può scaricare gratuitamente una applicazione Windows che permette
di visualizzare l'elenco più recente di tutti i simboli codificati in formato
UNICODE.
In ambiente Linux sono presenti ottime applicazioni come
gucharmap e KCharSelect.
12.4 Creazione di un Data Segment
Tutto ciò che è stato esposto nelle precedenti sezioni, può essere utilizzato
per creare segmenti di programma e per definire al loro interno i dati statici
di cui abbiamo bisogno; nel caso più semplice, possiamo creare segmenti
destinati esclusivamente ai dati statici. In questo modo otteniamo dei
cosiddetti Data Segments (segmenti di dati); la Figura 12.38 illustra
un esempio pratico che fa riferimento anche ai vari aggregati di dati dichiarati
in questo capitolo.
In questo esempio abbiamo creato un segmento dati individuato dal nome simbolico
DATASEGM; il segmento viene indirizzato con offset a 16 bit
(USE16), ed è dotato di allineamento PARA, combinazione
PUBLIC e classe 'DATA'.
Osserviamo che per inizializzare i dati di tipo floating point, possiamo anche
ricorrere a numeri espressi in notazione esponenziale; questo significa che:
-5.12345E-200 = -5.12345 * (10-200)
Osserviamo, inoltre, che l'esempio di Figura 12.38 assume che la base numerica
predefinita sia 10; di conseguenza, tutti i numeri espliciti privi di
suffisso, si intendono espressi in base 10.
Una volta che abbiamo definito i dati statici del nostro programma, possiamo
impiegarli nelle varie istruzioni, riferendoci ad essi tramite il loro nome
simbolico, oppure tramite il loro indirizzo; nel primo caso si parla di
accesso ai dati per nome, mentre nel secondo caso si parla di accesso
ai dati per indirizzo.
In base a quanto abbiamo visto nei precedenti capitoli possiamo dire che, in
ogni caso, l'accesso ad un dato statico di un programma avviene sempre tramite
il suo indirizzo logico Seg:Offset; per rendercene conto, consideriamo
la seguente istruzione che si riferisce all'accesso per nome ad un dato definito
in Figura 12.38:
mov cx, [es:varRect+p1+y]
Come possiamo notare, l'operando sorgente è una locazione di memoria che si
trova all'indirizzo logico Seg:Offset; in questo caso, la componente
Seg è contenuta in ES, mentre la componente Offset è un
semplice Disp16 rappresentato dalla WORD chiamata simbolicamente
varRect+p1+y.
Il vantaggio che deriva dall'uso dei nomi simbolici, sta nel fatto che in
questo modo stiamo delegando all'assembler il compito di calcolare il corretto
offset del dato al quale vogliamo accedere; in presenza, infatti, del nome
varRect+p1+y, l'assembler analizza il DATASEGM di Figura 12.38
per ricavare il corrispondente offset.
Appare evidente il fatto che il programmatore è tenuto rigorosamente ad
inizializzare i registri di segmento che vuole impiegare per referenziare i
segmenti di dati dei propri programmi; in caso contrario, l'istruzione del
precedente esempio trasferisce in CX un valore privo di senso!
Volendo accedere per indirizzo a varRect+p1+y, possiamo scrivere
una istruzione del tipo:
mov cx, [ds:si]
Questa volta la CPU è in grado di associare automaticamente SI
a DS, per cui possiamo anche scrivere:
mov cx, [si]
Anche in questo caso notiamo che l'operando sorgente indica una locazione
di memoria a 16 bit che si trova all'indirizzo logico Seg:Offset
(la presenza di CX indica all'assembler che gli operandi sono a
16 bit); la componente Seg è contenuta in DS, mentre la
componente Offset è contenuta in SI.
Questa volta, il programmatore è tenuto rigorosamente ad inizializzare, non
solo DS, ma anche SI; infatti, affinché la precedente istruzione
fornisca il risultato desiderato, il registro DS deve contenere la
componente Seg assegnata a DATASEGM, mentre SI deve
contenere la componente Offset assegnata a varRect+p1+y.
In base alle considerazioni appena esposte, è necessario ribadire che il
programmatore ha l'importante compito di inizializzare i registri di segmento
che intende utilizzare per referenziare i segmenti di dati dei propri programmi;
ricordiamo inoltre che il registro di segmento naturale per i dati è DS.
In caso di necessità possiamo anche servirci di ES, FS e GS;
inoltre, se vogliamo accedere a dati statici definiti nel segmento di stack o nei
segmenti di codice, dobbiamo ricordarci di specificare il segment override per
SS o CS.
Nel prossimo capitolo analizzeremo in dettaglio tutti gli aspetti relativi al
calcolo degli offset dei dati statici di un programma e al loro corretto
allineamento in memoria.
12.5 Creazione di uno Stack Segment
Anche in relazione alle variabili temporanee di un programma, la cosa migliore
da fare consiste nel creare un apposito segmento da destinare esclusivamente a
questo tipo di informazioni; in questo caso si ottiene un cosiddetto Stack
Segment (segmento di stack).
Come già sappiamo, nel momento in cui inizia la fase di esecuzione di un
programma, la CPU si aspetta che la coppia SS:SP sia già stata
predisposta in modo da referenziare lo stack; in particolare, SS deve
contenere la componente Seg dell'indirizzo logico normalizzato da cui
inizia lo stack, mentre SP deve indicare inizialmente la massima ampiezza
in byte dello stesso stack.
A seconda dei casi, il compito di inizializzare la coppia SS:SP può anche
ricadere sul programmatore; se vogliamo evitare questa eventualità, possiamo
servirci dell'attributo di combinazione STACK illustrato nella sezione
12.1.
Abbiamo visto, infatti, che il linker utilizza automaticamente un eventuale
segmento di programma con attributo di combinazione STACK, per
inizializzare la coppia SS:SP; supponendo, ad esempio, di avere bisogno
di uno stack da 1024 byte (0400h byte), possiamo definire lo stack
segment mostrato in Figura 12.39.
In questo esempio abbiamo creato un segmento di stack individuato dal nome
simbolico STACKSEGM; il segmento viene indirizzato con offset a 16
bit (USE16) ed è dotato di allineamento PARA, combinazione
STACK e classe 'STACK'.
Incontrando questo segmento di programma, il linker pone automaticamente:
SS = STACKSEGM
e:
SP = 0400h
Osserviamo che all'interno dello stack segment è presente un vettore da
1024 byte; il nome simbolico per questo vettore non è necessario
in quanto la CPU indirizza lo stack attraverso la coppia
SS:SP. Non è necessaria nemmeno l'inizializzazione del vettore
di stack; quest'area, infatti, viene continuamente modificata dalla
CPU durante la fase di esecuzione del programma.
Se lo stack viene creato all'interno di un segmento di programma privo
dell'attributo STACK, allora il compito di inizializzare la coppia
SS:SP ricade ovviamente sul programmatore; questo caso viene
analizzato più avanti.
In rarissime circostanze che verranno illustrate nei capitoli successivi,
il programmatore ha la responsabilità di modificare in fase di esecuzione,
il contenuto del registro SP; nel caso generale, invece, il contenuto
della coppia SS:SP viene gestito dalla CPU e, per ovvie ragioni,
non deve essere assolutamente modificato dal programmatore. Se abbiamo la
necessità di "esplorare" lo stack, possiamo servirci dell'apposito registro
puntatore BP; ricordiamo anche che la CPU associa automaticamente
SP e BP al registro di segmento SS.
Nei precedenti capitoli abbiamo visto che se vogliamo spingere al massimo le
prestazioni di un programma, dobbiamo affrontare con molta attenzione il
problema del corretto allineamento in memoria delle informazioni che fanno
parte del programma stesso; questo aspetto assume una importanza ancora
maggiore nel caso dello stack segment. Un programma in esecuzione, infatti,
può avere l'esigenza di sfruttare lo stack in modo veramente intensivo; nei
capitoli successivi vedremo inoltre che anche il SO usa lo stack dei
programmi per poter svolgere numerosi compiti.
Tutto ciò che riguarda il corretto allineamento in memoria del segmento di
stack e del registro SP, verrà trattato in dettaglio nel prossimo capitolo.
12.6 Creazione di un Code Segment
Dopo aver definito il Data Segment e lo Stack Segment di un
programma, non ci resta che definire appositi segmenti destinati a contenere
le istruzioni per l'elaborazione dei dati statici e dinamici; nel caso più
semplice, possiamo creare un segmento riservato unicamente alle istruzioni,
ottenendo in questo modo un cosiddetto Code Segment.
All'interno del segmento di codice vengono inserite, in particolare, un
gruppo di istruzioni che nel loro insieme formano il main program
(programma principale o codice principale); dal codice principale è possibile
saltare ad altre istruzioni che spesso si trovano in sottoprogrammi disposti
in uno o più moduli esterni.
Analizziamo ora gli aspetti fondamentali che caratterizzano un segmento di
programma destinato a contenere, in particolare, il codice principale.
12.6.1 L'entry point
Un qualunque programma, scritto in qualunque linguaggio, deve fornire alla
CPU una informazione fondamentale che prende il nome di entry
point (punto di ingresso); l'entry point rappresenta in pratica
l'indirizzo Seg:Offset della prima istruzione che verrà eseguita dalla
CPU. Nei linguaggi di alto livello, l'entry point viene gestito
direttamente dal compilatore o dall'interprete; in Assembly, invece,
l'entry point deve essere specificato dal programmatore.
Come si può facilmente intuire, il linker utilizza proprio l'entry point per
inizializzare la coppia CS:IP; in sostanza, non appena inizia la fase
di esecuzione di un programma, la coppia CS:IP punta alla prima
istruzione che verrà eseguita dalla CPU. Il compito di inizializzare
la coppia CS:IP spetta quindi rigorosamente al linker; in fase di
esecuzione, il contenuto di CS:IP viene gestito dalla CPU e
non deve essere assolutamente modificato dal programmatore.
Il metodo più semplice per indicare l'entry point di un programma, consiste
nel definire una cosiddetta label (etichetta); una label è formata da
un nome simbolico, seguito da un ':' (due punti). All'interno di un
qualunque segmento di programma possiamo scrivere, ad esempio:
esempio_di_etichetta:
Una label ci permette di individuare facilmente un indirizzo posto
all'interno di un segmento di programma; possiamo paragonare la label ad una
sorta di segnalibro che ci aiuta a ritrovare rapidamente una determinata
pagina che stavamo leggendo. Da queste considerazioni si deduce che una label
ha il semplice scopo di indicare una posizione all'interno di un segmento di
programma; il programmatore può quindi definire tutte le label di cui ha
bisogno, senza il rischio di sprecare memoria.
Per indicare all'assembler che una determinata label rappresenta l'entry point
del nostro programma, dobbiamo utilizzare una apposita sintassi; nel caso di
NASM l'entry point è indicato univocamente dall'etichetta riservata
'..start'.
In sostanza, quando NASM incontra l'etichetta:
..start:
capisce che quello è l'entry point del programma; un programma Assembly
destinato all'ambiente DOS, deve specificare obbligatoriamente un unico
entry point.
12.6.2 NASM doesn't ASSUME!
Gli sviluppatori di NASM, volendo perseguire l'obiettivo della chiarezza e
della semplicità, hanno deciso volutamente di non supportare l'ambigua direttiva
ASSUME (presente, invece, in MASM); questa precisa scelta viene
sottolineata nel manuale di NASM dalla eloquente frase "NASM doesn't
ASSUME" (NASM non dà per scontato)!
In effetti, nella sezione Assembly Base - Versione MASM, viene spiegato che
la direttiva ASSUME è molto comoda, ma deve essere utilizzata con molta
attenzione; se il programmatore non ha le idee chiare su ciò che deve fare, può
andare incontro ad errori particolarmente difficili da scovare!
La conseguenza pratica è che in NASM dobbiamo sempre indicare in modo
esplicito l'eventuale presenza del segment override; tutto ciò può risultare
fastidioso, ma permette al programmatore di avere il pieno controllo della
situazione.
Ovviamente, il segment override non è richiesto quando si vogliono sfruttare le
associazioni predefinite stabilite dalla CPU; questo argomento è stato
già trattato nei precedenti capitoli e in seguito verrà approfondito a dovere.
Per maggiori dettagli sulla direttiva ASSUME, si consiglia di consultare
il Capitolo 12 del tutorial Assembly Base - Versione MASM.
12.6.3 Inizializzazione dei registri di segmento per i dati
Prima di accedere ai dati presenti in un qualunque segmento di programma, il
programmatore ha l'importante compito di stabilire quale SegReg utilizzare
per gestire il segmento di programma stesso; come sappiamo, nel SegReg
dobbiamo caricare la componente Seg dell'indirizzo logico
Seg:Offset da cui inizia il segmento di programma.
All'inizio di questo capitolo è stato spiegato che il nome simbolico assegnato
ad un segmento di programma ha un significato molto importante; infatti, esso
rappresenta la componente Seg dell'indirizzo logico Seg:Offset
da cui inizia il segmento di programma stesso.
A questo punto, abbiamo tutti gli elementi necessari per effettuare questa
importante inizializzazione; supponendo, ad esempio, di voler gestire con
DS il blocco DATASEGM di Figura 12.38, possiamo scrivere le
seguenti istruzioni:
È proibito trasferire direttamente un valore immediato come DATASEGM
in un SegReg; siamo costretti quindi ad effettuare un passaggio
intermedio attraverso il registro AX. Nei limiti del possibile, in
casi di questo genere conviene sempre utilizzare l'accumulatore; in questo
modo otteniamo un codice macchina più compatto e più veloce.
Naturalmente, lo stesso tipo di inizializzazione deve essere effettuato,
se necessario, anche per ES; ricordiamo poi che con le CPU
80386 e superiori, possiamo gestire i data segment anche attraverso
FS e GS.
12.6.4 Inizializzazione della coppia SS:SP
Se abbiamo inserito lo stack in un segmento di programma privo dell'attributo
STACK, allora il compito di inizializzare la coppia SS:SP spetta
a noi; anche in questo caso, il metodo da seguire è abbastanza semplice.
Supponiamo, ad esempio, che il segmento di Figura 12.39 sia dotato di
attributo PUBLIC e non STACK; per usare questo segmento da
1024 byte come stack segment, possiamo scrivere:
Notiamo subito la presenza delle due istruzioni CLI e STI
che ancora non conosciamo; queste due istruzioni agiscono sul flag IF
(Interrupt Enable Flag) del registro FLAGS. Come già sappiamo,
se IF=1, le interruzioni mascherabili possono arrivare liberamente
alla CPU; se, invece, IF=0, le interruzioni mascherabili
vengono bloccate prima che arrivino alla CPU. Le due istruzioni
CLI e STI producono i seguenti effetti:
- l'istruzione CLI (Clear IF) pone IF=0
- l'istruzione STI (Set IF) pone IF=1
Quando dobbiamo inizializzare la coppia SS:SP, è importante che
questo lavoro venga svolto con le interruzioni disabilitate; infatti,
quando la CPU riceve una richiesta di interruzione hardware,
ferma temporaneamente il programma in esecuzione, salva alcune informazioni
nello stack e chiama l'opportuna ISR (Interrupt Service Routine).
È chiaro però che se una interruzione arriva quando lo stack non è stato
ancora creato, andiamo incontro ad un sicuro disastro!
Ricordiamoci anche del fatto che, per garantire il corretto funzionamento
del computer, il flag IF deve restare a zero per il più breve tempo
possibile.
12.6.5 Terminazione di un programma DOS
Quando un programma termina, deve restituire il controllo al SO che
nel nostro caso è il DOS; con le versioni più recenti del DOS,
questo lavoro viene svolto attraverso il servizio n. 4Ch fornito dal
vettore di interruzione n. 21h (interrupt dei servizi DOS).
Il codice 4Ch deve essere inserito nel registro AH, mentre
il registro AL deve contenere un codice chiamato exit code
(codice di uscita); tradizionalmente, nei programmi DOS, un codice
0 indica una terminazione senza errori.
L'istruzione che si utilizza per richiedere una interruzione software, ha
il mnemonico INT; questo mnemonico è seguito da un codice che
rappresenta, come sappiamo, il vettore di interruzione da chiamare. In
definitiva, la terminazione del nostro programma è ottenuta con le seguenti
istruzioni:
Un programma deve avere un unico entry point, ma può avere uno o più
exit point; in genere, uno solo di questi exit point indica una
terminazione senza errori del programma, mentre gli altri vengono utilizzati
con un exit code diverso da zero, per indicare una terminazione anomala.
12.6.6 Struttura generale di un Code Segment
Una volta che abbiamo definito tutti i dettagli relativi ai concetti appena
illustrati, possiamo passare alla fase di creazione di un code segment; la
Figura 12.40 illustra un code segment destinato a contenere, l'entry point,
l'exit point principale e quindi anche il codice principale del programma.
In questo esempio abbiamo creato un segmento di codice individuato dal nome
simbolico CODESEGM; il segmento viene indirizzato con offset a
16 bit (USE16), ed è dotato di allineamento PARA,
combinazione PUBLIC e classe 'CODE'.
L'entry point del programma viene indicato dall'etichetta ..start;
a differenza di quanto accade con MASM, questo nome è obbligatorio e
non può essere sostituito con un altro.
Nel caso di Figura 12.40, il linker inizializza la coppia CS:IP
ponendo:
CS = CODESEGM
e:
IP = Offset(..start)
Subito dopo l'entry point, vengono effettuate le inizializzazioni più
importanti; in particolare, osserviamo che viene inizializzato il registro
DS con il valore DATASEGM.
In questa stessa zona deve essere effettuata, se necessario, anche
l'inizializzazione della coppia SS:SP; vista la delicatezza di questa
operazione, si consiglia vivamente di inizializzare SS:SP
immediatamente dopo l'entry point.
Terminate le varie inizializzazioni, incontriamo un'area riservata alle
istruzioni che formano il codice principale; subito dopo troviamo le istruzioni
per la terminazione del programma.
12.7 Schema generale di un programma Assembly
Raccogliendo tutto ciò che è stato esposto in questo capitolo, siamo finalmente
in grado di definire la struttura generale di un semplice programma
Assembly; prima di passare ad illustrare questa struttura, analizziamo
alcuni strumenti dell'Assembly, che si rivelano molto utili.
12.7.1 Costanti simboliche
Nei precedenti capitoli è stato ampiamente spiegato che in un programma scritto
con un qualsiasi linguaggio, bisogna evitare di maneggiare direttamente numeri
espliciti; il programmatore, infatti, può facilmente andare incontro ad una
svista che porta a scrivere un numero sbagliato.
Per evitare questi rischi, tutti i linguaggi di programmazione permettono di
gestire i numeri espliciti attraverso nomi simbolici; nel caso di NASM,
ci viene messa a disposizione una direttiva chiamata %assign (assegna).
Attraverso questa direttiva possiamo scrivere:
%assign TEMPERATURA +25
Il nome simbolico TEMPERATURA può essere "infilato" dappertutto; lo
possiamo usare, ad esempio, per inizializzare un dato, come:
varTemp1 dw TEMPERATURA
Lo possiamo usare in combinazione con TIMES, come:
vettTemp times TEMPERATURA resw 1
(vettore di 25 elementi di tipo WORD).
Lo possiamo usare in combinazione con %REP, come:
Lo possiamo persino usare per indicare uno spiazzamento all'interno di un
effective address, come:
mov cx, [bx+si+TEMPERATURA]
Ogni volta che l'assembler incontra il nome simbolico TEMPERATURA,
lo sostituisce con il numero esplicito +25; eventualmente,
l'assembler provvede anche ad adattare +25 all'ampiezza in bit
degli operandi.
Una volta che abbiamo definito la costante TEMPERATURA, possiamo
anche scrivere:
%assign CALDO (TEMPERATURA * 6) + 4 - (100 / 2)
Più in generale, possiamo servirci di tutti gli operatori logico aritmetici
che l'Assembly ci mette a disposizione; l'insieme completo degli
operatori logico aritmetici, verrà illustrato in un capitolo successivo.
Le costanti simboliche possono essere inizializzate solo con valori
immediati di tipo intero, con o senza segno; è proibito, invece, scrivere:
%assign PIGRECO 3.14
Di conseguenza, la direttiva:
%assign TEMP2 TEMPERATURA / 2
assegna a TEMP2 il valore +12; infatti, la divisione
+25/2 produce il quoziente +12.5 che viene troncato a
+12.
Tutte le direttive (come %assign) non devono essere confuse con le
istruzioni e non comportano quindi alcuna allocazione di memoria; le
costanti simboliche quindi, possono essere create dappertutto, sia
all'interno, sia all'esterno dei segmenti di programma.
12.7.2 Commenti nel linguaggio Assembly
In tutti i linguaggi di programmazione, i commenti assumono una grande importanza;
attraverso i commenti, il programmatore può facilmente individuare lo scopo di un
dato o di una istruzione. In un linguaggio "criptico" come l'Assembly, i
commenti diventano a dir poco indispensabili; un programmatore Assembly
con un minimo di senso di responsabilità, dovrebbe quindi fare un uso veramente
massiccio dei commenti nei propri programmi.
Abbiamo già visto che in Assembly, il "punto e virgola" delimita l'inizio
di un commento che si sviluppa su una sola linea, come:
rectBase dw 3F22h ; base del rettangolo
Questo commento quindi termina non appena si va a capo; se, invece, vogliamo
scrivere un commento su più linee, dobbiamo servirci di un numero opportuno di
';'.
12.7.3 La direttiva CPU
Attraverso la direttiva CPU possiamo indicare all'assembler che
vogliamo lavorare con il set di istruzioni di una determinata CPU; nel
caso di NASM, questa direttiva accetta le opzioni elencate in Figura 12.41.
È necessario ribadire che la direttiva CPU serve solo per indicare
all'assembler quale set di istruzioni vogliamo utilizzare; tale direttiva,
quindi, non ha alcun effetto sulla qualità dei programmi (un pessimo programma
Assembly che fa uso della direttiva CPU 386, non migliora di
certo con la direttiva CPU 686)!
Un programma Assembly che fa uso, ad esempio, della direttiva CPU
586 (e che utilizza esplicitamente istruzioni della 80586), non
può girare su computer dotati di CPU 80486 o inferiore; viceversa, un
programma Assembly che fa uso, ad esempio, della direttiva CPU
286, può girare su computer dotati di CPU 80286 o superiore.
12.7.4 Modello di un programma Assembly
La Figura 12.42 illustra il modello generale di un programma Assembly,
che riassume tutti i concetti esposti in questo capitolo.
Il modello di Figura 12.42 verrà largamente utilizzato nei prossimi capitoli
per illustrare diversi programmi Assembly di esempio; nei capitoli
finali della sezione Assembly Base, analizzeremo programmi dotati
di una struttura molto più complessa.
Come possiamo notare, abbiamo a disposizione tre segmenti di programma che
ci permettono di suddividere in modo ordinato, il codice, i dati e lo stack;
ciascun segmento, come sappiamo, può crescere sino a raggiungere la dimensione
massima di 65536 byte (64 KiB). Con il modello di Figura 12.42,
possiamo scrivere quindi programmi che richiedono al massimo una allocazione
di memoria statica pari a:
65536 * 3 = 196608 byte = 192 KiB
Per scrivere semplici programmi in Assembly, una simile disponibilità
di memoria statica è persino esagerata; in casi di questo genere, si potrebbero
infilare tutte le informazioni (codice, dati e stack) all'interno di un unico
segmento di programma di dimensioni non superiori a 64 KiB!
Come direttiva CPU è stata utilizzata 386, che ci permette di
sfruttare il set di istruzioni a 32 bit per scrivere programmi
compatibili con tutte le CPU 80386 e superiori; utilizzando, invece,
direttive come 686, rendiamo il programma incompatibile con tutti i
computer equipaggiati con CPU di classe inferiore.
Proseguendo nell'analisi del modello di Figura 12.42, possiamo notare la
presenza della costante simbolica STACK_SIZE; l'uso di questo nome
all'interno dello stack segment, rende il programma molto più chiaro ed elegante.
In assenza di diverse indicazioni da parte del programmatore, i vari segmenti
che formano il programma di Figura 12.42, verranno disposti in memoria nello
stesso ordine da noi specificato; se vogliamo alterare questa situazione,
possiamo servirci, ad esempio, della tecnica dei dummy segments descritta
nella sezione 12.1.
In Figura 12.42 notiamo la presenza dell'etichetta ..start la quale
rappresenta univocamente l'entry poin del programma; come già sappiamo,
..start è un nome riservato proprio a questo particolare uso e non può
essere sostituito con altri nomi. Nel caso di un programma Assembly
formato da due o più moduli, solo uno di essi deve specificare l'entry point.
Nel prossimo capitolo, analizzeremo in dettaglio tutto il procedimento svolto
dall'assembler e dal linker, per rendere eseguibile un programma scritto in
codice sorgente Assembly.