Assembly Base con MASM
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" da un nome simbolico
(NomeSegmento), seguito dalla direttiva SEGMENT; il segmento di
programma viene poi "chiuso" dallo stesso nome simbolico, seguito dalla direttiva
ENDS (end of segment).
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.
Il linguaggio Assembly "puro" è case-insensitive e questo significa
che non distingue tra lettere maiuscole e lettere minuscole; per l'Assembly,
i nomi simbolici Variabile1, VARIABILE1, variabile1, etc,
rappresentano tutti lo stesso identificatore. Lo stesso discorso vale per i mnemonici
delle varie istruzioni dell'Assembly; non c'è differenza quindi tra PUSH
e push o tra POP e pop.
La situazione cambia 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,
l'Assembly 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.
La tabella di Figura 12.2 illustra in dettaglio i diversi valori che può assumere
l'attributo Allineamento e gli effetti prodotti da questi valori
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:
DATASEGM SEGMENT BYTE
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:
DATASEGM SEGMENT WORD
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:
DATASEGM SEGMENT DWORD
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:
DATASEGM SEGMENT PARA
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, MASM utilizza il valore predefinito PARA; 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:
DATASEGM SEGMENT WORD
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; 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:
DATIPRIVATI SEGMENT WORD 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:
DATIPUBBLICI SEGMENT WORD 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:
STACKSEGM SEGMENT PARA 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.
Nel caso del MASM si sconsiglia vivamente la creazione di dati statici
inizializzati nei segmenti di tipo STACK distribuiti su due o più moduli;
tutti questi argomenti verranno ripresi in modo approfondito nel seguito del
capitolo e nei capitoli successivi.
Supponiamo ora che in entrambi i moduli MAIN.ASM e LIB1.ASM, sia
presente un segmento di programma aperto da una linea del tipo:
DATICOMUNI SEGMENT DWORD 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 AT 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:
BIOSDATA SEGMENT AT 0F000h
(il perché dello zero alla sinistra di F000h verrà spiegato più avanti).
Per i segmenti di tipo AT XXXXh, gli altri attributi in genere vengono
omessi; in caso contrario, il MASM richiede che AT XXXXh sia
l'ultimo attributo della lista.
Non è permesso definire dati inizializzati in un segmento di programma di tipo
AT XXXXh; in caso contrario, l'assembler genera un semplice messaggio di
avvertimento per informarci che le varie inizializzazioni verranno ignorate.
Se il programmatore definisce un segmento di programma privo dell'attributo
Combinazione, il MASM si serve dell'attributo predefinito
PRIVATE.
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:
DATASEGM SEGMENT WORD 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!
Se il programmatore definisce un segmento di programma privo dell'attributo
Dimensione, il MASM si serve di un attributo predefinito che
dipende dalla eventuale presenza di apposite direttive che specificano il set di
istruzioni che vogliamo utilizzare; in assenza di queste direttive, MASM
assume che il programmatore voglia utilizzare il set di istruzioni della
8086, per cui l'attributo Dimensione predefinito è USE16.
Alternativamente possiamo indicare in modo esplicito le direttive illustrate in
Figura 12.6; questa figura mostra anche l'ampiezza predefinita per gli offset, che
MASM utilizza in funzione del set di istruzioni che abbiamo specificato.
Queste direttive vengono inserite in genere all'inizio di ogni modulo e hanno il
solo scopo di specificare il set di istruzioni che vogliamo utilizzare; è chiaro
quindi che un pessimo programma che utilizza la direttiva .8086, non
migliora di certo con la direttiva .586!
Un programma con direttiva .8086 gira su qualsiasi CPU di classe
8086 o superiore, mentre un programma con direttiva .586 non può
girare su CPU di classe 80486 o inferiore; si tenga anche presente
che esistono numerose altre direttive Processor per le CPU e le
FPU, che verranno illustrate nei capitoli successivi.
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;
questo attributo è formato da una stringa racchiusa tra apici singoli. 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:
LOCALDATA SEGMENT PARA PRIVATE USE16 'DATA'
DATASEGM SEGMENT PARA PUBLIC USE16 'DATA'
CODESEGM SEGMENT PARA PUBLIC USE16 'CODE'
STACKSEGM SEGMENT PARA STACK USE16 'STACK'
Nel modulo LIB1.ASM sono presenti i seguenti segmenti di programma:
CODESEGM SEGMENT PARA PUBLIC USE16 'CODE'
LOCALDATA SEGMENT PARA PRIVATE USE16 'DATA'
DATASEGM SEGMENT PARA PUBLIC USE16 'DATA'
STACKSEGM SEGMENT PARA STACK USE16 '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:
LOCALDATA SEGMENT PARA PRIVATE USE16 'DATA'
LOCALDATA SEGMENT PARA PRIVATE USE16 'DATA'
DATASEGM SEGMENT PARA PUBLIC USE16 'DATA'
CODESEGM SEGMENT PARA PUBLIC USE16 'CODE'
STACKSEGM SEGMENT PARA STACK USE16 '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'.
Il metodo di ordinamento dei segmenti di programma può essere gestito anche
attraverso le direttive illustrate in Figura 12.7.
La direttiva .SEQ (predefinita) indica al linker che i segmenti di
programma devono essere disposti nello stesso ordine stabilito dal programmatore
nel codice sorgente; in presenza dell'attributo Classe, i vari segmenti
vengono ordinati sequenzialmente e raggruppati per classi.
La direttiva .ALPHA indica al linker che i segmenti di programma devono
essere disposti in ordine alfabetico rispetto ai loro nomi; in presenza
dell'attributo Classe, i vari segmenti vengono ordinati alfabeticamente e
raggruppati per classi.
La direttiva .DOSSEG indica al linker che i segmenti di programma devono
essere disposti secondo lo standard DOS e cioè, prima i segmenti di codice,
poi i segmenti di dati statici e infine lo stack; la direttiva .DOSSEG è
obsoleta e non dovrebbe essere più utilizzata.
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.8.
Siccome il linker incontra per primi questi tre dummy segments, alla fine
genera un programma eseguibile la cui struttura interna è la seguente:
CODESEGM SEGMENT PARA PUBLIC USE16 'CODE'
DATASEGM SEGMENT PARA PUBLIC USE16 'DATA'
LOCALDATA SEGMENT PARA PRIVATE USE16 'DATA'
LOCALDATA SEGMENT PARA PRIVATE USE16 'DATA'
STACKSEGM SEGMENT PARA STACK USE16 '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.9, ad esempio, illustra una serie di definizioni di dati
statici in un programma scritto in linguaggio C.
Come si può notare in Figura 12.9, 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.10.
Attraverso le direttive di Figura 12.10, 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.10.
Le direttive di Figura 12.10 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 scrivere:
Variabile1 DW ?
In questo caso, il contenuto iniziale di Variabile1 è casuale o, come si
dice in gergo, "sporco"; in sostanza, quando l'assembler incontra il
simbolo '?' associato alla definizione di Variabile1, non effettua
alcuna inizializzazione della corrispondente locazione di memoria.
Le direttive DF e DP vengono utilizzate in modalità protetta per
creare indirizzi FAR Seg:Offset a 48 bit, con componente
Seg a 16 bit e componente Offset a 32 bit; in modalità
reale, queste due direttive definiscono semplicemente locazioni di memoria da
48 bit.
Osservando la Figura 12.10 possiamo notare che è possibile definire dati aventi
una ampiezza in bit maggiore dell'architettura della CPU; anche con una
80386, ad esempio, possiamo scrivere:
Var64 DQ 3D668AC1FF283AB2h
Nell'ipotesi che Var64 si trovi all'offset 00F2h di un segmento di
programma gestito con DS, si ottiene per questa variabile la disposizione
in memoria mostrata in Figura 12.11.
Naturalmente, in questo caso dobbiamo ricordarci che la 80386 può
maneggiare via hardware numeri binari formati al massimo da 32 bit; di
conseguenza, se vogliamo utilizzare Var64 in una istruzione, dobbiamo
"spezzare" questo dato in tante parti da 8, da 16 o da 32
bit.
Utilizzando, ad esempio, gli address size operators mostrati nel
precedente capitolo, possiamo scrivere istruzioni del tipo:
MOV EAX, DWORD PTR DS:Var64[0]
Osservando la Figura 12.11 possiamo notare che l'operando sorgente di questa
istruzione fa chiaramente riferimento ai primi 32 bit della locazione
di memoria che si trova all'offset:
00F2h + 0000h = 00F2h
In questo caso vengono quindi copiati in EAX i 32 bit meno
significativi di Var64 (EAX=FF283AB2h).
Analogamente, possiamo scrivere l'istruzione:
MOV EBX, DWORD PTR DS:Var64[4]
Osservando la Figura 12.11 possiamo notare che l'operando sorgente di questa
istruzione fa chiaramente riferimento ai primi 32 bit della locazione
di memoria che si trova all'offset:
00F2h + 0004h = 00F6h
In questo caso vengono quindi copiati in EBX i 32 bit più
significativi di Var64 (EBX=3D668AC1h).
12.2.1 Formati IEEE per i numeri in floating point
Gli assembler più evoluti, come MASM, permettono di utilizzare anche un
numero reale come valore inizializzante; in questo caso, possono essere impiegate
solo le tre direttive DD, DQ e DT. È 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.12 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.12 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 Nuove direttive MASM per i dati statici
Le direttive mostrate in Figura 12.10 sono standard e sono riconosciute quindi
da tutti gli assembler destinati al mondo delle CPU 80x86; se abbiamo la
necessità di scrivere programmi compatibili con diversi assembler, o se abbiamo
a disposizione un vecchio assembler, dobbiamo necessariamente servirci delle
direttive standard di Figura 12.10.
Il MASM ha introdotto da tempo una serie di direttive alternative, che
permettono di definire i vari formati di dati, utilizzando una sintassi molto
simile a quella dei linguaggi di alto livello. La Figura 12.13 illustra la
sintassi di queste nuove direttive MASM; per i floating point vengono
mostrati in questa figura solo i limiti min. e max. positivi.
Queste nuove direttive aiutano il programmatore a scrivere programmi più
comprensibili, in quanto rendono evidente l'uso che vogliamo fare di un
determinato dato; possiamo scrivere, ad esempio:
Dislivello SBYTE -75
È chiaro però che, indipendentemente dall'aspetto formale, la sostanza non
cambia; dal punto di vista della CPU, infatti, Dislivello indica
una locazione di memoria da 8 bit che contiene il valore binario
iniziale 10110101b (cioè 181); questo valore è la rappresentazione
a 8 bit in complemento a 2 del numero negativo -75.
Per indicare a chi legge il nostro programma, che vogliamo interpretare
10110101b come -75, utilizziamo la direttiva SBYTE;
se, invece, vogliamo interpretare 10110101b come +181, possiamo
utilizzare la direttiva BYTE. Questa distinzione quindi è puramente
formale; la sostanza è che in entrambi i casi, il contenuto binario di
Dislivello è 10110101b.
Lo stesso discorso vale naturalmente per i numeri reali definiti con le
direttive REAL4, REAL8 e REAL10; questi numeri vengono
visti dalla CPU come generici numeri binari costituiti da una sequenza
rispettivamente di 32, 64 e 80 bit. A dimostrazione del
fatto che l'unico tipo di dato gestibile dalla CPU è quello intero,
possiamo tranquillamente sommare tra loro un DWORD con un REAL4
senza che l'assembler generi alcun messaggio di errore; per la CPU,
infatti, sia il DWORD che il REAL4 sono generici numeri binari
a 32 bit. Tutto ciò non sarebbe possibile con i linguaggi di alto
livello che simulano via software uno stretto controllo sui tipi di dati; il
linguaggio Pascal, ad esempio, impedisce al programmatore di effettuare
una somma tra un Single (reale con segno a 32 bit) e un
Longint (intero con segno a 32 bit).
In sostanza, possiamo dire che tutti i dati definiti con le direttive di
Figura 12.13, possono essere tranquillamente definiti usando le direttive
standard di Figura 12.10; queste direttive, essendo appunto standard, sono
compatibili con tutti gli assembler disponibili sul mercato. Le direttive di
Figura 12.13 influiscono solo sull'aspetto estetico dei programmi; molti
programmatori preferiscono le direttive di Figura 12.10 perché rispecchiano
meglio la filosofia della programmazione Assembly che non lascia molto
spazio alle formalità.
12.2.3 La direttiva TYPEDEF
Gli assembler più recenti mettono a disposizione anche la direttiva
TYPEDEF che permette di assegnare nuovi nomi (alias) alle
direttive di Figura 12.13; in questo modo, è possibile definire i dati statici
di un programma, con una sintassi molto simile (formalmente) a quella utilizzata
con i linguaggi di alto livello. I fanatici del Borland Turbo Pascal, ad
esempio, possono scrivere:
Integer TYPEDEF SWORD
creando in questo modo un alias Integer per la direttiva SWORD;
a questo punto è possibile scrivere definizioni del tipo:
pascalVar Integer +12538
12.2.4 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.14.
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
È anche possibile imporre una diversa base predefinita attraverso la direttiva:
.RADIX n
Il valore n indica la base predefinita che può essere 2, 8,
10 o 16.
Scrivendo, ad esempio, all'inizio di un programma:
.RADIX 16
allora tutti i numeri espliciti privi di suffisso si intendono espressi in base
16; di conseguenza, la precedente definizione:
Pressione DW 3800
verrebbe interpretata da MASM come:
Pressione DW 3800h
Se si impone una base predefinita 16, allora tutti i numeri espliciti
espressi in una base diversa da 16 (compresi i numeri in base 10)
devono specificare obbligatoriamente il suffisso; come si può facilmente
intuire, questa situazione può creare parecchia confusione, per cui si raccomanda
vivamente di lasciare 10 come base predefinita.
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
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.
Cominciamo con le strutture propriamente dette, che possono essere create
attraverso le direttive STRUC e UNION disponibili con MASM;
si tenga presente che altri assembler (e le versioni più vecchie di MASM)
non supportano queste direttive.
12.3.1 La direttiva STRUC
La direttiva STRUC permette di creare strutture dati equivalenti alle
struct del C/C++ e ai record del Pascal; con le
versioni più recenti di MASM, questa direttiva assume anche il nome
STRUCT. Una struttura racchiude un insieme di dati di qualsiasi formato;
la Figura 12.15 illustra la sintassi da utilizzare con questa direttiva.
Come si può notare, una STRUC viene aperta da un nome simbolico seguito
dalla direttiva STRUC; la struttura viene poi chiusa dallo stesso nome
simbolico seguito dalla direttiva ENDS, proprio come accade per i
segmenti di programma. In effetti, osservando la Figura 12.15 si può constatare
che una STRUC è formalmente identica 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.15, possiamo utilizzare il nome Struc1 come
se fosse una delle direttive di Figura 12.10 o di Figura 12.13; di conseguenza,
all'interno di un segmento di programma, possiamo scrivere, ad esempio:
varStruc1 Struc1 < 2AB1h, 3BF1854Dh, 884Ch, 2Fh, 5Ah >
In questo modo stiamo definendo ed inizializzando una struttura varStruc1
di tipo Struc1; come si può notare, la lista degli inizializzatori è
racchiusa da una coppia di parentesi angolari. Ricorrendo alla terminologia dei
linguaggi di programmazione ad oggetti, si può anche dire che varStruc1
è una istanza di Struc1.
È importante sottolineare ancora la differenza fondamentale che esiste tra una
dichiarazione e una definizione; con la dichiarazione di
Figura 12.15 stiamo semplicemente illustrando all'assembler le caratteristiche
generali di un dato strutturato come Struc1. Attraverso, invece, la
definizione di varStruc1, 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.16.
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.15 e in Figura 12.16.
Per accedere ai membri di una struttura, si utilizza la stessa sintassi dei
linguaggi di alto livello; questa sintassi prevede l'uso dell'operatore '.'
(punto) secondo la forma:
NomeStruttura.NomeMembro
NomeStruttura.NomeMembro rappresenta il contenuto della locazione di
memoria che si trova all'offset risultante dalla somma:
Offset(NomeStruttura) + Offset(NomeMembro)
Mentre però Offset(NomeStruttura) viene calcolato rispetto al segmento
di appartenenza della struttura, Offset(NomeMembro) viene, invece,
calcolato rispetto ad Offset(NomeStruttura); in sostanza, è come se
NomeMembro fosse un dato definito all'interno di un segmento chiamato
NomeStruttura.
Osservando, ad esempio, la Figura 12.15 e la Figura 12.16, si vede subito 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
Consideriamo, ad esempio, l'istruzione:
MOV AX, DS:varStruc1.varSt1
Osservando la Figura 12.16, si vede subito che l'operando sorgente di questa
istruzione fa riferimento alla WORD che si trova all'offset:
0028h + 0000h = 0028h
del segmento di programma che contiene varStruc1; dopo il trasferimento
dati si ottiene quindi AX=2AB1h.
Consideriamo l'istruzione:
MOV EBX, DS:varStruc1.varSt2
Osservando la Figura 12.16, si vede subito che l'operando sorgente di questa
istruzione fa riferimento alla DWORD che si trova all'offset:
0028h + 0002h = 002Ah
del segmento di programma che contiene varStruc1; dopo il
trasferimento dati si ottiene quindi EBX=3BF1854Dh.
Consideriamo l'istruzione:
MOV DX, WORD PTR DS:varStruc1.varSt2[2]
Osservando la Figura 12.16, 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). In questo caso, l'operatore WORD PTR
è necessario in quanto varSt2 è stata dichiarata di tipo DWORD.
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]
MASM 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.17 vediamo un esempio che mostra una struttura che 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
scrivere:
r1 Rect < < 3AB2h, 1C8Fh >, < 284Dh, 6FC5h > >
Come si può notare, abbiamo una coppia più esterna di parentesi angolari (per
Rect), che incorpora due coppie interne di parentesi angolari (per i due
Point2d).
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.18.
Anche nel caso delle strutture innestate, l'accesso ai membri avviene con la
stessa sintassi dei linguaggi di alto livello; questa sintassi assume la forma:
NomeStruttura.NomeStrutturaInnestata.NomeMembro
Come al solito, NomeStruttura.NomeStrutturaInnestata.NomeMembro
rappresenta il contenuto della locazione di memoria che si trova all'offset
risultante dalla somma:
Offset(NomeStruttura) + Offset(NomeStrutturaInnestata) + Offset(NomeMembro)
- Offset(NomeStruttura) viene calcolato rispetto al segmento di
appartenenza della struttura
- Offset(NomeStrutturaInnestata) viene calcolato rispetto a
Offset(NomeStruttura)
- Offset(NomeMembro) viene calcolato rispetto a
Offset(NomeStrutturaInnestata)
Osservando che r1.p1 rappresenta una struttura Point2d che
contiene il vertice in alto a sinistra del rettangolo, allora r1.p1.x
e r1.p1.y rappresentano le coordinate di questo stesso vertice;
l'offset di p1 viene calcolato rispetto a r1, mentre gli
offset di x e di y vengono calcolati rispetto a p1.
Analogamente, osservando che r1.p2 rappresenta una struttura
Point2d che contiene il vertice in basso a destra del rettangolo,
allora r1.p2.x e r1.p2.y rappresentano le coordinate di
questo stesso vertice; l'offset di p2 viene calcolato rispetto a
r1, mentre gli offset di x e di y vengono calcolati
rispetto a p2.
Volendo inizializzare la struttura r1 nel blocco codice del programma,
anziché con il metodo visto prima, possiamo scrivere le istruzioni mostrate
in Figura 12.19; queste istruzioni rappresentano chiaramente dei trasferimenti
di dati da Imm16 a Mem16.
Osservando lo schema di Figura 12.18, si può constatare che:
r1.p1.x rappresenta il contenuto 3AB2h della locazione di
memoria da 16 bit che si trova all'offset:
00F6h + 0000h + 0000h = 00F6h
r1.p1.y rappresenta il contenuto 1C8Fh della locazione di
memoria da 16 bit che si trova all'offset:
00F6h + 0000h + 0002h = 00F8h
r1.p2.x. rappresenta il contenuto 284Dh della locazione di
memoria da 16 bit che si trova all'offset:
00F6h + 0004h + 0000h = 00FAh
r1.p2.y rappresenta il contenuto 6FC5h della locazione di
memoria da 16 bit che si trova all'offset:
00F6h + 0004h + 0002h = 00FCh
Una struttura innestata può avere tra i suoi membri ulteriori strutture innestate;
gli innesti possono andare avanti sino all'esaurimento della memoria disponibile.
In MASM, i nomi dei membri di una struttura sono invisibili all'esterno
della struttura stessa e quindi possono essere ridefiniti (ad esempio, come membri
di altre strutture); in sostanza, nel caso del MASM l'identificatore
r1.p1.x viene considerato distinto da p1 o da x.
12.3.2 La direttiva UNION
Passiamo ora alla direttiva UNION (unione) attraverso la quale possiamo
creare aggregati di dati, molto simili alle strutture; le UNION
dell'Assembly equivalgono alle union del C/C++ e ai
record varianti del Pascal.
La differenza fondamentale che esiste tra una STRUC e una UNION
sta nel fatto che tutti i membri di una UNION vengono sovrapposti tra
loro in modo che condividano lo stesso offset iniziale in memoria; questo
tipo di aggregato quindi è molto utile quando si ha bisogno di una variabile
che in fase di esecuzione di un programma, assume formati diversi (BYTE,
WORD, etc).
Come si può facilmente intuire, nel caso delle UNION possiamo
inizializzare solo un membro per volta; la Figura 12.20 mostra un esempio di
dichiarazione di una unione.
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, l'assembler riserva a questa istanza uno spazio la cui ampiezza
in bit è pari a quella del membro più grande di Union1. Come possiamo
notare in Figura 12.20, il membro più grande di Union1 è varUn3
che ha una ampiezza di 32 bit; lo spazio da 32 bit riservato
dall'assembler è sufficiente a contenere uno qualunque dei tre membri di
Figura 12.20.
Per la definizione di varUnion1 possiamo utilizziare la sintassi
generica:
varUnion1 Union1 < >
In questo caso stiamo dicendo all'assembler che per l'inizializzazione di
varUnion1 utilizziamo i valori predefiniti di Figura 12.20; a questo
punto, nel blocco codice del programma possiamo scrivere, ad esempio:
MOV DS: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.21.
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 DS:varUnion1.varUn1, 2Fh
Subito dopo l'esecuzione di questa istruzione, la locazione di Figura 12.21 assume
l'aspetto mostrato in Figura 12.22.
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.23 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, possiamo utilizzare il nome Frutta
per definire le istanze di questa UNION; nel segmento dati del nostro
programma possiamo scrivere, ad esempio:
varFrutta1 Frutta < >
Quando l'assembler incontra questa definizione, riserva a varFrutta1
uno spazio sufficiente a contenere il membro più grande dell'unione Frutta;
dalla Figura 12.23 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 DS: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.24.
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.24, inizializzando varFrutta1.Altro; possiamo
scrivere, ad esempio:
MOV DS:varFrutta1.Altro.Arancio, 3FAB819Ch
e:
MOV DS:varFrutta1.Altro.Limone, 6DF934E1h
Dopo l'esecuzione di queste istruzioni, la locazione di memoria di Figura 12.24
assume l'aspetto mostrato in Figura 12.25.
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.22 o di Figura 12.25, ci permettono anche di
capire meglio il modo di lavorare della CPU.
12.3.3 La direttiva 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 sintassi da utilizzare per la dichiarazione di un RECORD è la seguente:
NomeRecord RECORD NomeMembro1: ampiezza1, NomeMembro2: ampiezza2, ...
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 >
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.
L'assembler, eventualmente, incrementa la dimensione complessiva del RECORD,
in modo da portarla a 8 bit, 16 bit o 32 bit; questo
incremento viene ottenuto inserendo un numero adeguato di zeri alla sinistra del
RECORD stesso. Nel caso di varData1, la dimensione complessiva è:
5 + 4 + 11 = 20 bit
Siccome questa dimensione è maggiore di 16 bit, l'assembler crea una
locazione di memoria da 32 bit aggiungendo 12 zeri alla sinistra
del RECORD; naturalmente, in questo caso dobbiamo disporre almeno di una
CPU 80386.
Nell'ipotesi che varData1 si trovi in memoria all'offset 008Dh di
un segmento di programma, si ottiene la situazione illustrata in Figura 12.26.
Osserviamo che Anno occupa gli 11 bit meno significativi di
varData1, mentre Giorno occupa i 5 bit più significativi;
alla sinistra di Giorno troviamo, inoltre, 12 zeri aggiunti
dall'assembler per riempire totalmente i 32 bit di questo dato.
Bisogna prestare particolare attenzione al fatto che, a differenza di quanto
accade con le STRUC, i vari membri di un RECORD formano un unico
numero binario; nel caso quindi del nostro esempio, Anno rappresenta la
parte meno significativa di varData1, mentre Giorno rappresenta
la parte più significativa. La definizione di varData1 crea quindi una
locazione di memoria contenente il numero binario 11000101011111010011b;
in pratica, è come se il programmatore avesse scritto:
varData1 DD 00000000000011000101011111010011b
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.26 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; 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 La direttiva DUP
Attraverso la direttiva DUP è possibile "replicare" un oggetto che può essere,
un dato semplice, un aggregato di dati, o persino un'altra direttiva DUP
seguita da un ulteriore oggetto da replicare; a differenza di quanto accade con
STRUC, UNION e RECORD, che vengono usate nelle dichiarazioni,
la direttiva DUP deve essere inserita in un segmento di programma in quanto
comporta la definizione di un aggregato di dati, con conseguente allocazione della
memoria.
La sintassi generale per DUP è la seguente:
NomeSimbolico DIRETTIVA Repliche DUP ( Oggetto )
La DIRETTIVA è una di quelle illustrate in Figura 12.10 e in Figura 12.13,
oppure il nome simbolico di una STRUC, UNION, RECORD, etc; il
valore immediato Repliche indica il numero di oggetti da replicare.
Gli oggetti replicati con DUP 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 DW 4 DUP ( 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.27.
Dalla Figura 12.27 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.
Passiamo ora ad un esempio più impegnativo; in riferimento alla struttura Rect
dichiarata in Figura 12.17, consideriamo la seguente definizione:
VettRect1 Rect 10 DUP ( < > )
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.28; in questa figura, per ovvie ragioni di spazio,
vengono mostrati solamente i primi due Rect del vettore.
Analizzando la Figura 12.28 rileviamo che il Rect di indice 0 è
individuato da VettRect1[0]; di conseguenza:
VettRect1[0].p1 rappresenta il Point2d che si trova all'offset:
00F6h + 0000h + 0000h = 00F6h
VettRect1[0].p1.x rappresenta la WORD che si trova all'offset:
00F6h + 0000h + 0000h + 0000h = 00F6h
VettRect1[0].p1.y rappresenta la WORD che si trova all'offset:
00F6h + 0000h + 0000h + 0002h = 00F8h
Analogamente:
VettRect1[0].p2 rappresenta il Point2d che si trova all'offset:
00F6h + 0000h + 0004h = 00FAh
VettRect1[0].p2.x rappresenta la WORD che si trova all'offset:
00F6h + 0000h + 0004h + 0000h = 00FAh
VettRect1[0].p2.y rappresenta la WORD che si trova all'offset:
00F6h + 0000h + 0004h + 0002h = 00FCh
Sempre dalla Figura 12.28 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:
00F6h + 0008h + 0000h = 00FEh
VettRect1[8].p1.x rappresenta la WORD che si trova all'offset:
00F6h + 0008h + 0000h + 0000h = 00FEh
VettRect1[8].p1.y rappresenta la WORD che si trova all'offset:
00F6h + 0008h + 0000h + 0002h = 0100h
Analogamente:
VettRect1[8].p2 rappresenta il Point2d che si trova all'offset:
00F6h + 0008h + 0004h = 0102h
VettRect1[8].p2.x rappresenta la WORD che si trova all'offset:
00F6h + 0008h + 0004h + 0000h = 0102h
VettRect1[8].p2.y rappresenta la WORD che si trova all'offset:
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 DUP può essere
persino un'altra direttiva DUP seguita da un ulteriore oggetto da replicare;
possiamo scrivere, ad esempio:
MatrWord1 DW 5 DUP (8 DUP (72F8h))
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.29.
Naturalmente, MatrWord1 viene disposta in memoria, non come in Figura 12.29,
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.29,
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.29 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 DUP può essere utilizzata anche come membro di una STRUC
o di una UNION; la Figura 12.30 mostra un esempio pratico che fa riferimento
al RECORD RecData definito in precedenza.
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:
FerrariF3000 Automobile 20 DUP ( < > )
crea un vettore di 20 strutture Automobile; questo vettore richiede
quindi 46*20=920 byte di memoria.
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 DS:FerrariF3000[46].Revisioni[0], 01100001111111011000b
Osserviamo che ogni elemento Revisioni[i] è una DWORD; di conseguenza,
l'assembler è in grado di rilevare che questa istruzione rappresenta un trasferimento
dati da Imm32 a Mem32.
Utilizzando numerose direttive DUP 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 DUP 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 DW 8 DUP ( 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
alla direttiva DUP scrivendo:
MatrByte1 DB 4 DUP ( 10 DUP ( 0 ) )
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.31 (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.32 (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.17, abbiamo
visto che è possibile scrivere:
VettRect1 Rect 10 DUP ( < > )
Di conseguenza, possiamo anche scrivere:
VettRect1 Rect < >, < >, < >, < >, < >, < >, < >, < >, < >, < >
Volendo creare un vettore bidimensionale di 4*3 oggetti di tipo
Rect, possiamo scrivere:
MatrRect1 Rect 4 DUP ( 3 DUP ( < > ) )
Di conseguenza, possiamo anche utilizzare il metodo diretto illustrato in
Figura 12.33.
Tutte le considerazioni appena svolte si applicano anche all'oggetto associato
ad una direttiva DUP; consideriamo, ad esempio, la seguente definizione:
MatrRect1 Rect 4 DUP ( < >, < >, < > )
Questa definizione replica 4 volte un vettore formato da 3
oggetti di tipo Rect; alla fine si ottiene la stessa situazione di
Figura 12.33.
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.34.
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.35 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.35 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.35:
mov cx, ds: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 DS, 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.35
per ricavare il corrispondente offset.
Come abbiamo visto nel precedente capitolo, quando utilizziamo in una istruzione
il nome simbolico di un dato (cioè, un offset esplicito), dobbiamo indicare anche
il registro di segmento contenente la componente Seg dell'indirizzo logico
del dato stesso; in questo modo, possiamo far capire all'assembler che il nome
simbolico rappresenta una locazione di memoria e non un generico valore numerico.
Appare anche 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.36.
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 direttiva che viene illustrata
più avanti; un programma Assembly deve specificare obbligatoriamente un
unico entry point.
12.6.2 La direttiva ASSUME
Abbiamo visto che ogni volta che in una istruzione facciamo riferimento al nome
simbolico (offset esplicito) di un dato del nostro programma, siamo tenuti a
specificare anche il registro di segmento contenente la componente Seg
dell'indirizzo logico del dato stesso; infatti, a differenza di quanto accade
con i registri puntatori, l'assembler non è in grado di associare automaticamente
un nome simbolico ad un registro di segmento. Nei programmi molto grossi, questa
situazione può risultare abbastanza fastidiosa; per il programmatore esiste anche
il rischio di incappare in una svista che porta ad indicare un registro di segmento
sbagliato.
Supponiamo, ad esempio, di voler accedere per nome ai dati statici definiti
nel segmento DATASEGM di Figura 12.35; se decidiamo di impiegare
DS per gestire questo segmento, ogni volta che in una istruzione
è presente il nome di uno dei dati di Figura 12.35, dobbiamo indicare anche
lo stesso DS come registro di segmento predefinito. Per delegare
all'assembler questo fastidioso compito, possiamo servirci della direttiva
ASSUME; la sintassi da utilizzare è la seguente:
ASSUME SegReg: NomeSegmento1, SegReg: NomeSegmento2, ...
Nel nostro caso, all'interno del segmento di codice di un programma
possiamo scrivere:
ASSUME DS: DATASEGM
A questo punto possiamo scrivere istruzioni del tipo:
MOV AX, varWord
Quando l'assembler incontra una istruzione di questo genere, grazie alla
precedente direttiva ASSUME è in grado di sapere che il dato
varWord definito nel segmento DATASEGM, deve essere
associato a DS; di conseguenza, nel codice macchina di questa
istruzione, l'assembler non inserisce alcun segment override (infatti,
DS è il registro di segmento naturale per i dati).
Se, invece, vogliamo gestire DATASEGM attraverso ES, possiamo
scrivere:
ASSUME ES: DATASEGM
Anche in questo caso, possiamo scrivere istruzioni del tipo:
MOV AX, varWord
Quando l'assembler incontra una istruzione di questo genere, grazie alla
precedente direttiva ASSUME è in grado di sapere che il dato
varWord definito nel segmento DATASEGM, deve essere
associato a ES; di conseguenza, nel codice macchina di questa
istruzione, l'assembler inserisce il segment override 26h
relativo al registro ES.
Il problema appena descritto, riguarda anche i nomi simbolici definiti
all'interno di un segmento di codice; questi nomi possono appartenere a
dati statici, etichette, sottoprogrammi, etc.
In questo caso, siamo obbligati ogni volta ad indicare all'assembler
che la componente Seg dell'indirizzo logico di un determinato
nome simbolico, è contenuta in CS; per evitare questo fastidio,
possiamo servirci anche in questo caso della direttiva ASSUME.
All'inizio di un segmento di programma chiamato, ad esempio,
CODESEGM, possiamo scrivere:
ASSUME CS: CODESEGM
Nel caso dei segmenti di codice, la direttiva ASSUME svolge
anche un altro importante ruolo; per capire in cosa consiste questo
ruolo, vediamo quello che succede quando la CPU incontra una
istruzione che prevede un salto (jump) ad un determinato
indirizzo Seg:Offset.
Per poter effettuare questo salto, la CPU carica in CS:IP
l'indirizzo Seg:Offset di destinazione; si possono presentare
allora le due possibilità seguenti:
- L'indirizzo Seg:Offset si trova nello stesso code segment
da cui avviene il salto
- L'indirizzo Seg:Offset si trova in un code segment diverso
da quello in cui avviene il salto
Nel caso a, la CPU deve modificare solo IP in quanto
CS rimane inalterato; questo salto viene definito NEAR jump
(salto vicino).
Nel caso b, la CPU deve modificare, sia CS, sia
IP; questo salto viene definito FAR jump (salto lontano).
Questi due tipi di salto corrispondono a due diversi codici macchina;
grazie alle direttive ASSUME che associano CS ai vari
code segment del nostro programma, l'assembler è in grado di sapere
se, per una istruzione di salto, deve generare il codice macchina di
un NEAR jump o di un FAR jump.
Proprio per i motivi appena illustrati, si raccomanda vivamente di
inserire una direttiva:
ASSUME CS: NomeSegmento
all'inizio di ogni segmento di codice chiamato NomeSegmento.
Nel momento in cui decidiamo di far cessare l'associazione tra un
SegReg ed un segmento di programma, possiamo ricorrere ancora
alla direttiva ASSUME; per dissociare, ad esempio, DS da
DATASEGM, possiamo scrivere:
ASSUME DS: NOTHING
Il termine inglese NOTHING significa niente; da questo
momento in poi, l'assembler non associa più DS ai nomi simbolici
dei dati definiti in DATASEGM.
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!
12.6.3 Inizializzazione dei registri di segmento per i dati
Una direttiva come:
ASSUME DS: DATASEGM
dice semplicemente all'assembler che tutti i nomi simbolici dei dati definiti
in DATASEGM, rappresentano delle componenti Offset da associare
ad una componente Seg contenuta in DS; è chiaro però che questa
direttiva da sola non serve a niente. È anche necessario che il programmatore
provveda ad inizializzare DS con la componente Seg assegnata a
DATASEGM; come già sappiamo, questa informazione è rappresentata dallo
stesso nome simbolico DATASEGM.
Prima di accedere a qualunque dato presente in DATASEGM, dobbiamo quindi
ricordarci di inizializzare DS; possiamo usare a tale proposito 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.36 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.37 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;
naturalmente, siamo liberi di utilizzare un qualsiasi altro nome simbolico.
Nel caso di Figura 12.37, 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;
osserviamo, infatti, che viene inizializzato il registro DS e viene
associato lo stesso DS a 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.
Le direttive come ASSUME, servono per impartire determinate disposizioni
all'assembler e non devono essere confuse con le istruzioni; queste direttive
quindi non comportano alcuna allocazione di memoria.
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 MASM,
ci viene messa a disposizione la direttiva = (uguale). Attraverso questa
direttiva possiamo scrivere:
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 DUP, come:
vettTemp dw TEMPERATURA dup ( ? )
(vettore di 25 elementi di tipo WORD).
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:
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:
PIGRECO = 3.14
Di conseguenza, la direttiva:
TEMP2 = TEMPERATURA / 2
assegna a TEMP2 il valore +12; infatti, la divisione
+25/2 produce il quoziente +12.5 che viene troncato a
+12.
Anche la direttiva = non deve essere confusa con una istruzione e non
comporta 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 vogliamo scrivere un commento su più linee, possiamo servirci della direttiva
COMMENT; possiamo scrivere, ad esempio:
Come possiamo notare, un commento associato alla direttiva COMMENT,
deve essere aperto e chiuso da uno stesso simbolo appartenente al set di
codici ASCII; nel nostro
esempio, è stato utilizzato il simbolo '#' (chiamato in gergo,
cancelletto). Naturalmente, all'interno del commento non deve essere
presente il simbolo '#'; l'assembler, infatti, lo interpreterebbe come
la fine del commento stesso.
12.7.3 Modello di un programma Assembly
La Figura 12.38 illustra il modello generale di un programma Assembly,
che riassume tutti i concetti esposti in questo capitolo.
Il modello di Figura 12.38 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.38,
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 processor è 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. Si tenga anche presente
che le direttive come .586 o .686, sono disponibili solo con gli
assembler più recenti.
Proseguendo nell'analisi del modello di Figura 12.38, 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.38, 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.
La Figura 12.38 ci permette anche di conoscere la tecnica che viene utilizzata
per indicare l'entry point del programma; prima di tutto bisogna dire che un
qualunque modulo Assembly, deve essere terminato dalla direttiva
END. Tutto ciò che viene inserito a partire dalla riga successiva alla
direttiva END, viene completamente ignorato dall'assembler; questa
opportunità viene sfruttata da molti programmatori Assembly, per inserire
svariati commenti alla fine del programma, senza la necessità di ricorrere a punti
e virgola o a direttive COMMENT.
Il modulo Assembly che contiene l'entry point, deve essere chiuso dalla
direttiva END seguita dal nome simbolico che abbiamo utilizzato per
identificare il punto di ingresso del programma; nel nostro caso, possiamo notare
che la direttiva END è seguita dal nome start.
Nel caso di un programma Assembly formato da due o più moduli, solo uno di
essi deve specificare l'entry point; tutti gli altri moduli, devono essere chiusi
da una semplice direttiva END.
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.