Assembly Base con MASM
Capitolo 29: Interfaccia con i linguaggi di alto livello
Nei precedenti capitoli abbiamo visto che i linguaggi di programmazione di alto livello
si suddividono in interpretati e compilati; parallelamente, gli strumenti
software che traducono in codice macchina i programmi scritti con un linguaggio di alto
livello, si suddividono in interpreti e compilatori.
Se abbiamo la necessità di interfacciare l'Assembly con i linguaggi di alto livello,
dobbiamo conoscere tutti i dettagli relativi alle convenzioni seguite dai compilatori e
dagli interpreti per la generazione del codice macchina; riassumiamo allora gli aspetti
principali che caratterizzano questi particolari strumenti software.
Un interprete svolge il proprio lavoro operando su una sola istruzione per volta; in
sostanza, l'interprete legge una istruzione dal codice sorgente, la traduce in codice
macchina e la fa eseguire alla CPU prima di passare all'istruzione successiva.
Per ogni singola istruzione da eseguire, l'interprete deve passare alla CPU tutte
le informazioni necessarie, compresi eventuali riferimenti a dati o procedure, contenuti
nell'istruzione stessa; ciò implica che l'interprete debba farsi carico di tutta la
complessità (indirizzamenti, trasferimenti del controllo, etc) che caratterizza la
struttura interna di un programma da eseguire.
Questo modo di procedere determina, inesorabilmente, una notevole lentezza nella fase
di esecuzione dei programmi interpretati; è nota, ad esempio, l'esasperante lentezza
di esecuzione dei programmi scritti in BASIC interpretato.
Appare evidente che un programma interpretato, per poter essere eseguito, necessita
della presenza in memoria del relativo interprete; di conseguenza, anche la possibilità
di distribuire un programma interpretato a diversi utenti, richiede che ciascuno degli
utenti stessi disponga dell'apposito interprete.
Un programma interpretato è costituito, generalmente, da un file di testo scritto in
formato ASCII; a meno di
utilizzare un sistema di criptatura, questa situazione determina il rischio che
un malintenzionato possa "carpire" il codice sorgente del programma stesso!
Un compilatore svolge il proprio lavoro di traduzione operando sull'intero codice sorgente
di un programma; ciò significa che il compilatore legge un intero programma scritto con un
linguaggio di alto livello e lo traduce completamente in codice macchina in modo da ottenere
un cosiddetto eseguibile.
L'eseguibile così ottenuto è del tutto "autosufficiente" e può essere quindi inviato alla
fase di esecuzione indipendentemente dalla presenza o meno del compilatore; ciò implica
che il compilatore debba inserire nell'eseguibile tutta la complessità (indirizzamenti,
trasferimenti del controllo, etc) che caratterizza la struttura interna dell'eseguibile
stesso. In sostanza, è come se un programma compilato incorpori al suo interno un
interprete; ogni istruzione di un programma compilato, fornisce quindi alla CPU
tutte le informazioni necessarie per l'esecuzione.
Per poter essere eseguito, un programma compilato viene interamente caricato in memoria;
ciò permette velocità di esecuzione nettamente superiori rispetto ai programmi interpretati.
Un programma compilato è costituito da un file in codice macchina e risulta quindi
illeggibile con un editor ASCII;
ciò implica che solo un hacker esperto può riuscire a risalire al codice sorgente del
programma stesso!
Le considerazioni appena esposte evidenziano notevoli analogie tra i compilatori e gli
assemblatori; anche gli assembler, infatti, partendo dal codice sorgente producono un
programma in codice macchina destinato a diventare un eseguibile del tutto
"autosufficiente". Questo aspetto ci fa intuire che solo i programmi compilati possono
rendere possibile l'interfacciamento con l'Assembly; nel caso dei programmi
interpretati, infatti, questa possibilità è vanificata dall'assenza dell'indispensabile
object code!
Affinché sia possibile interfacciare un programma Assembly con un programma
compilato, devono verificarsi due importanti condizioni:
- il formato interno dei file oggetto prodotti dall'assembler deve coincidere con
il formato interno dei file oggetto prodotti dal compilatore
- i moduli scritti in Assembly devono attenersi a tutte le convenzioni
seguite dal compilatore in relazione alla struttura del programma
La prima condizione viene rispettata attraverso la definizione di un formato standard per
i file oggetto; nel mondo DOS/Windows i due formati standard più usati sono il
formato OMF (Object Module Format) e il formato COFF (Common Object File
Format).
Il formato OMF viene supportato da numerosi assembler come NASM, MASM,
TASM e da molti compilatori della Microsoft e della Borland; il
formato COFF, invece, viene largamente utilizzato in ambiente Windows dagli
strumenti di sviluppo della Microsoft come MASM, Visual C++, etc, ed
è supportato anche da NASM. Per maggiori dettagli su questi due formati per i moduli
oggetto si consiglia di scaricare la documentazione ufficiale disponibile nella sezione
Documentazione tecnica di supporto al corso assembly dell’
Area Downloads di questo sito.
Per rispettare anche la seconda condizione, dobbiamo essere in grado di conoscere tutti
i dettagli relativi alle convenzioni seguite dai compilatori per definire la struttura
a basso livello di un programma eseguibile; attenendoci a queste convenzioni possiamo
creare moduli Assembly interfacciabili con i moduli oggetto prodotti dai compilatori.
Come è stato spiegato nei capitoli dal 24 al 27, queste convenzioni riguardano, in
particolare:
- i modelli di memoria
- i nomi e gli attributi dei segmenti di programma
- svariati aspetti relativi alle procedure (come il prolog code e
l'epilog code)
- la creazione di variabili locali nello stack
- i registri da utilizzare per i valori di ritorno delle procedure
Uno degli scopi più importanti di queste convenzioni è quello di ottenere la massima
semplificazione possibile nella complessa fase di progettazione di un compilatore.
In sostanza, al momento di convertire in codice macchina un programma scritto in
C/C++, Pascal, FORTRAN, BASIC (compilato), etc, i compilatori
si attengono ad una serie di convenzioni che possono essere considerate come veri e propri
standard di linguaggio; in genere, questi standard vengono decisi dai produttori di
CPU (come Intel e AMD) e da chi scrive i SO (come
Microsoft, Apple, Sun, etc).
A partire dalla versione 5.x, il MASM offre il pieno supporto di queste
convenzioni attraverso una serie di caratteristiche avanzate che, dal punto di vista del
programmatore, si presentano come una vera e propria estensione del linguaggio
Assembly; grazie all'uso di una sintassi evoluta, molto simile a quella dei
linguaggi di alto livello, è possibile ottenere notevoli semplificazioni nel lavoro di
interfacciamento tra l'Assembly e gli altri linguaggi.
Il compito fondamentale delle caratteristiche avanzate del MASM è quello di permettere
all'assembler la generazione automatica di tutte quelle porzioni di codice macchina che, in
base a quanto è stato appena detto, si possono considerare standard; in questo modo il
programmatore ha la possibilità di delegare all'assembler la gestione di tutti quegli aspetti
che coinvolgono i modelli di memoria, i segmenti di programma, le convenzioni per le procedure,
etc.
Naturalmente, è anche possibile sfruttare le caratteristiche avanzate per rendere più semplice
lo sviluppo di programmi interamente scritti in Assembly; in questo caso, però, bisogna
osservare che queste "innovazioni" non suscitano particolare entusiasmo tra i puristi del
linguaggio Assembly.
Si tenga presente, comunque, che in questo specifico settore, tra i vari assembler esistono
purtroppo numerose incompatibilità spesso legate a banalissimi dettagli sintattici; la
situazione è aggravata dal fatto che la sintassi di queste estensioni dell'Assembly
varia anche da una versione all'altra dello stesso assembler. Per maggiori dettagli si
consiglia di fare riferimento direttamente ai manuali dell'assembler che si intende utilizzare.
29.1 La direttiva .MODEL
Per poter accedere alle caratteristiche avanzate di MASM è necessario servirsi
della direttiva .MODEL; questa direttiva, che deve essere inserita all'inizio
di ogni modulo Assembly, ci permette di passare all'assembler una serie di importanti
informazioni relative, in particolare, al modello di memoria da utilizzare e alle convenzioni
di linguaggio per le procedure.
29.1.1 Il parametro memory_model
Tra tutte queste informazioni, l'unica obbligatoria è quella relativa al modello di memoria;
i principali modelli di memoria sono stati esaminati nel Capitolo 27. Per specificare un
determinato modello di memoria, la sintassi da utilizzare è:
.MODEL memory_model
Il parametro memory_model è uno tra quelli indicati dalla Figura 29.1; ogni modello
di memoria indica il tipo di indirizzamento predefinito (NEAR o FAR) che
l'assembler deve utilizzare per accedere alle procedure e ai dati del programma.
Se vogliamo scrivere moduli Assembly destinati ad interfacciarsi con altri moduli
C/C++, Pascal, BASIC, etc, dobbiamo ovviamente tenere conto delle
convenzioni illustrate in Figura 29.1; analizziamo allora in dettaglio i vari casi.
29.1.2 Il parametro TINY
Consideriamo la direttiva:
.MODEL TINY
Come già sappiamo, questo modello di memoria è compatibile con la generazione di un
eseguibile in formato COM (COre iMage); nel Capitolo 27 abbiamo visto che questo
modello di memoria prevede la presenza di un unico segmento di programma contenente codice,
dati e stack. In assenza di diverse indicazioni da parte del programmatore, tutti i dati
e le procedure del programma vengono considerati di tipo NEAR.
I compilatori che supportano i modelli di memoria, creano un gruppo DGROUP che
rappresenta l'unico segmento di programma presente; inoltre, il compilatore stesso pone
CS=DS=SS=DGROUP.
29.1.3 Il parametro SMALL
Consideriamo la direttiva:
.MODEL SMALL
Questo modello di memoria prevede la presenza di un unico segmento di codice e di un unico
segmento di dati; in assenza di diverse indicazioni da parte del programmatore, tutti i dati
e le procedure del programma vengono considerati di tipo NEAR.
I compilatori che supportano i modelli di memoria, creano un gruppo DGROUP che
comprende l'unico blocco dati seguito dal blocco stack; inoltre, il compilatore stesso pone
DS=SS=DGROUP e fa puntare CS all'unico blocco codice (che contiene l'entry
point). Se DGROUP supera i 64 KiB, allora il blocco stack viene separato
da DGROUP; in tal caso il compilatore pone DS=DGROUP e fa puntare SS
al blocco stack e CS all'unico blocco codice (che contiene l'entry point).
29.1.4 Il parametro MEDIUM
Consideriamo la direttiva:
.MODEL MEDIUM
Questo modello di memoria prevede la presenza di due o più segmenti di codice e di un unico
segmento di dati; in assenza di diverse indicazioni da parte del programmatore, tutte le
procedure del programma vengono considerate di tipo FAR, mentre tutti i dati vengono
considerati di tipo NEAR.
I compilatori che supportano i modelli di memoria, creano un gruppo DGROUP che
comprende l'unico blocco dati seguito dal blocco stack; inoltre, il compilatore stesso pone
DS=SS=DGROUP e fa puntare CS al blocco codice che contiene l'entry point.
Se DGROUP supera i 64 KiB, allora il blocco stack viene separato da DGROUP;
in tal caso il compilatore pone DS=DGROUP e fa puntare SS al blocco stack e
CS al blocco codice che contiene l'entry point.
29.1.5 Il parametro COMPACT
Consideriamo la direttiva:
.MODEL COMPACT
Questo modello di memoria prevede la presenza di un unico segmento di codice e di due o più
segmenti di dati; in assenza di diverse indicazioni da parte del programmatore, tutte le
procedure del programma vengono considerate di tipo NEAR, mentre tutti i dati vengono
considerati di tipo FAR (ad eccezione dei "dati principali" del programma).
I compilatori che supportano i modelli di memoria, creano un gruppo DGROUP che
comprende uno dei blocchi di dati (blocco dati principale) seguito dal blocco stack; inoltre,
il compilatore stesso pone DS=SS=DGROUP e fa puntare CS all'unico blocco codice
(che contiene l'entry point). Se DGROUP supera i 64 KiB, allora il blocco
stack viene separato da DGROUP; in tal caso il compilatore pone DS=DGROUP e fa
puntare SS al blocco stack e CS all'unico blocco codice (che contiene l'entry
point). Appare evidente che tutti i dati contenuti in DGROUP possono essere acceduti
con indirizzamenti di tipo NEAR; invece, l'accesso a tutti i dati contenuti negli altri
segmenti esterni a DGROUP richiede indirizzamenti di tipo FAR.
29.1.6 Il parametro LARGE
Consideriamo la direttiva:
.MODEL LARGE
Questo modello di memoria prevede la presenza di due o più segmenti di codice e di due o più
segmenti di dati; in assenza di diverse indicazioni da parte del programmatore, tutti i dati
e le procedure del programma vengono considerati di tipo FAR (ad eccezione dei "dati
principali" del programma).
I compilatori che supportano i modelli di memoria, creano un gruppo DGROUP che
comprende uno dei blocchi di dati (blocco dati principale) seguito dal blocco stack; inoltre,
il compilatore stesso pone DS=SS=DGROUP e fa puntare CS al blocco codice che
contiene l'entry point. Se DGROUP supera i 64 KiB, allora il blocco
stack viene separato da DGROUP; in tal caso il compilatore pone DS=DGROUP e fa
puntare SS al blocco stack e CS al blocco codice che contiene l'entry
point. Appare evidente che tutti i dati contenuti in DGROUP possono essere acceduti
con indirizzamenti di tipo NEAR; invece, l'accesso a tutti i dati contenuti negli altri
segmenti esterni a DGROUP richiede indirizzamenti di tipo FAR.
29.1.7 Il parametro HUGE
Si ricorda che il modello HUGE è del tutto simile al modello LARGE; l'unica
differenza è data dal fatto che, nel modello HUGE, i compilatori (che supportano tale
modello) utilizzano gli indirizzi logici normalizzati per permettere la gestione di grossi
dati (come i vettori) che occupano più di 64 KiB in memoria.
29.1.8 Il parametro FLAT
Il modello FLAT viene supportato solo dalle versioni più recenti di MASM e
deve essere utilizzato per lo sviluppo di applicazioni destinate ai SO a 32
bit come Windows 9x o superiore, OS/2, Linux, etc; questi sistemi
operativi sono in grado di sfruttare l'architettura a 32 bit delle CPU 80386
e superiori.
Sfruttare l'architettura a 32 bit di una CPU significa poter indirizzare in
modo lineare (flat) la memoria del computer attraverso l'uso di offset a 32
bit; un offset a 32 bit permette di rappresentare tutti i valori compresi tra
00000000h e FFFFFFFFh, con la possibilità quindi di poter accedere in modo
lineare a ben 4 GiB di RAM!
Nel caso generale, i SO a 32 bit supportano la possibilità di far girare
"contemporaneamente" più programmi (multitasking); per ciascun programma in esecuzione
il SO crea uno spazio di indirizzamento "virtuale". Ad ogni programma in memoria viene
fatto credere di essere l'unico in esecuzione, con tutto l'hardware del computer a sua
completa disposizione; all'interno del suo spazio virtuale (flat segment) un programma
assume una struttura del tutto simile a quella del modello di memoria SMALL.
In sostanza, all'interno del flat segment viene creato un unico blocco codice, un
unico blocco dati e un unico blocco stack; il blocco dati e il blocco stack vengono, in genere,
raggruppati tra loro attraverso la direttiva GROUP. In una situazione di questo genere,
per l'accesso alle procedure e ai dati del programma vengono utilizzati indirizzamenti di tipo
NEAR; è chiaro quindi che nel modello di memoria FLAT il termine NEAR
indica un indirizzo logico formato dalla sola componente Offset la cui ampiezza è di
32 bit.
Il modello di memoria FLAT dei SO a 32 bit rappresenta un vero e proprio
paradiso per tutti quei programmatori che provengono dall'inferno della segmentazione a
64 KiB dei SO a 16 bit; per una descrizione più dettagliata del modello
di memoria FLAT si possono consultare le apposite sezioni di questo sito.
29.1.9 Memory model override
Se il programmatore vuole aggirare le convenzioni di Figura 29.1, deve passare all'assembler
le opportune informazioni; nel caso, ad esempio, di un programma con modello di memoria
SMALL, nessuno ci impedisce di definire procedure di tipo FAR. Tali procedure
devono essere chiamate con una FAR call e devono contenere, di conseguenza, un FAR
return; per obbligare l'assembler a rispettare queste condizioni dobbiamo usare il
Model Modifier (modificatore del modello di memoria) FAR e, se necessario,
anche l'operatore FAR PTR.
Supponiamo, ad esempio, di voler definire una procedura FAR di nome FarProc
in un programma con modello di memoria SMALL; in questo caso la definizione della
procedura deve iniziare con:
FarProc proc far
In questo modo l'assembler capisce che l'istruzione RET posta alla fine della procedura
deve essere convertita nel codice macchina di un FAR return.
Naturalmente, al momento di chiamare la procedura FarProc dobbiamo anche ricordarci di
scrivere:
call far ptr FarProc
In questo modo l'assembler capisce che l'istruzione CALL deve essere convertita nel
codice macchina di una FAR call; al momento di eseguire la chiamata di FarProc,
la CPU inserirà quindi nello stack l'indirizzo di ritorno completo Seg:Offset,
anche se il contenuto di CS rimane invariato.
29.1.10 Il parametro language
Tra i parametri facoltativi che possiamo passare alla direttiva .MODEL c'è, in
particolare, il parametro di linguaggio; attraverso questo parametro indichiamo all'assembler
quale convenzione di linguaggio vogliamo utilizzare per la gestione delle procedure.
Come già sappiamo, queste convenzioni riguardano: il prolog code (codice di ingresso),
l'epilog code (codice di uscita), le variabili locali e la pulizia dello stack;
specificando il parametro di linguaggio possiamo delegare tutti questi aspetti all'assembler.
Per quanto riguarda la gestione del valore di ritorno, il programmatore deve attenersi alle
convenzioni esposte nel Capitolo 25.
Per poter specificare un parametro di linguaggio la sintassi da utilizzare è:
.MODEL memory_model, language
Il parametro language è uno tra quelli illustrati in Figura 29.2; per ogni parametro
di linguaggio la Figura 29.2 indica l'ordine di inserimento, nello stack, degli argomenti di
una procedura e su chi ricade il compito di ripulire lo stack dagli argomenti stessi.
Si tenga presente che i linguaggi C++ e Prolog utilizzano le stesse
convenzioni del C.
In presenza del parametro di linguaggio l'assembler è in grado di generare automaticamente
il prolog code e l'epilog code delle procedure; il tutto avviene in conformità
alle convenzioni del linguaggio selezionato. Il programmatore gestisce tutta questa situazione
attraverso l'uso di una sintassi evoluta, molto simile a quella dei linguaggi di alto livello;
l'assembler elabora questa sintassi evoluta e la converte nell'opportuna sequenza di codici
macchina.
Supponiamo, ad esempio, di voler creare un programma con modello di memoria SMALL
conforme alle convenzioni Pascal per la gestione delle procedure; in questo caso
dobbiamo scrivere:
.MODEL SMALL, PASCAL
Il parametro di linguaggio PASCAL dice all'assembler che in assenza di diverse
indicazioni da parte del programmatore, ogni chiamata "evoluta" ad una procedura deve
essere convertita in una sequenza di codici macchina che rispettano tutte le convenzioni
del Pascal; in particolare, gli eventuali argomenti vengono automaticamente inseriti
nello stack a partire dal primo (sinistra destra), mentre alla fine della procedura viene
aggiunto all'istruzione RET un opportuno valore immediato da sommare a SP
per la pulizia dello stack.
Il parametro STDCALL viene largamente utilizzato in ambiente Windows a
32 bit e rappresenta un misto tra le convenzioni C e Pascal.
Come già sappiamo, in C il passaggio degli argomenti ad una procedura avviene a
partire dall'ultimo (destra sinistra), mentre la pulizia dello stack spetta al caller;
questa convenzione permette di implementare procedure che accettano un numero variabile
di argomenti.
In Pascal, invece, il passaggio degli argomenti ad una procedura avviene a partire
dal primo (sinistra destra), mentre la pulizia dello stack spetta alla procedura stessa;
la pulizia dello stack in stile Pascal è più veloce rispetto al caso del C
(infatti, l'istruzione RET Imm16 permette di evitare la successiva istruzione
ADD usata dal C per sommare un Imm16 a SP).
La convenzione STDCALL rappresenta un compromesso tra C e Pascal in
quanto sfrutta la convenzione C per il passaggio degli argomenti e la convenzione
Pascal per la pulizia dello stack.
29.1.11 Language override
Se il programmatore vuole aggirare le convenzioni illustrate in Figura 29.2, deve passare
le opportune informazioni all'assembler; in sostanza, se stiamo utilizzando, ad esempio, il
parametro di linguaggio PASCAL, possiamo ugualmente definire procedure che seguono
le convenzioni C. Per ottenere questo risultato dobbiamo inserire il Language
Modifier (modificatore di linguaggio) C direttamente nella definizione di quelle
procedure che vogliono sfruttare le convenzioni C; la sintassi da utilizzare viene
illustrata più avanti.
29.1.12 I parametri NEARSTACK e FARSTACK
Come è stato appena spiegato, i compilatori che supportano i modelli di memoria tendono a
raggruppare, se ciò è possibile, il blocco dati principale con il blocco stack, ottenendo
così il gruppo DGROUP; questo è anche il comportamento predefinito
dell'Assembly quando si utilizzano le caratteristiche avanzate (cioè, quando si
utilizza la direttiva .MODEL). Per rendere esplicito tale comportamento, il
programmatore può servirsi del parametro NEARSTACK da passare alla direttiva
.MODEL; possiamo scrivere, ad esempio:
.MODEL SMALL, PASCAL, NEARSTACK
Se, invece, vogliamo costringere l'Assembly a tenere il blocco stack distinto da
DGROUP, possiamo servirci del parametro FARSTACK da passare alla direttiva
.MODEL; possiamo scrivere, ad esempio:
.MODEL LARGE, C, FARSTACK
Più avanti verranno illustrati degli esempi pratici.
29.2 Sintassi avanzata per le procedure
Analizziamo ora un esempio pratico che ci permette di illustrare la sintassi avanzata di
MASM per la gestione delle procedure; a tale proposito, supponiamo di voler creare
un programma con modello di memoria LARGE, conforme alle convenzioni C per
le procedure.
In Assembly "classico", tutti gli aspetti relativi al modello di memoria e alle
convenzioni di linguaggio, sono a carico del programmatore; in particolare, è nostro
compito gestire il tipo di chiamata (NEAR o FAR) di una procedura, nonché
altri aspetti importanti come il prolog code e l'epilog code.
Supponiamo ora che il nostro programma contenga una procedura dotata del seguente prototipo,
espresso secondo la sintassi del C:
int far TestProc(long param1, int param2, int param3);
Questa procedura quindi è dotata dei tre parametri: param1 (intero con segno a
32 bit), param2 (intero con segno a 16 bit) e param3 (intero
con segno a 16 bit); la procedura, inoltre, termina restituendo un intero con segno
a 16 bit.
Supponiamo poi che TestProc abbia bisogno di due variabili locali chiamate
locVar1 (intero con segno a 32 bit) e locVar2 (intero con segno a
16 bit); per gestire la chiamata di questa procedura definiamo le quattro variabili
visibili in Figura 29.3.
In linguaggio C la chiamata di TestProc, con i primi tre argomenti visibili in
Figura 29.3, assume il seguente aspetto:
retVal = TestProc(varDword, varWord1, varWord2);
Come si può notare, il valore di ritorno di TestProc viene salvato in retVal;
naturalmente, in base alle convenzioni che già conosciamo, il compilatore C genera il
codice macchina necessario per trasferire in retVal il valore a 16 bit che
TestProc ha inserito in AX.
La precedente chiamata C viene convertita dal compilatore nel codice macchina che
corrisponde alla seguente sequenza di istruzioni Assembly:
Nel rispetto delle convenzioni C, gli argomenti vengono passati alla procedura a
partire dall'ultimo; la sequenza di inserimento nello stack è rappresentata quindi dai
2 byte di varWord2, seguiti dai 2 byte di varWord1 e dai
4 byte di varDword.
Quando il controllo torna al caller, viene effettuata la pulizia dello stack; siccome
abbiamo inserito 8 byte nello stack, la pulizia consiste nel sommare il valore
immediato 8 a SP.
La procedura TestProc restituisce il suo valore di ritorno in AX; in
Assembly il compito di leggere questo valore e memorizzarlo in retVal spetta,
ovviamente, al programmatore.
Per analizzare in dettaglio quello che accade nello stack, partiamo dalla condizione
SP=0202h e supponiamo che l'indirizzo di ritorno sia Seg:Offset=0CF2h:003Bh;
la chiamata FAR di TestProc comporta i seguenti passi eseguiti dalla
CPU:
- la CPU sottrae due byte a SP e ottiene SP=0200h
- il contenuto 003Dh di varWord2 viene inserito nello stack all'offset
SP=0200h
- la CPU sottrae 2 byte a SP e ottiene SP=01FEh
- il contenuto 00F2h di varWord1 viene inserito nello stack all'offset
SP=01FEh
- la CPU sottrae 2 byte a SP e ottiene SP=01FCh
- la WORD più significativa (000Dh) di varDword viene inserita
nello stack all'offset SP=01FCh
- la CPU sottrae 2 byte a SP e ottiene SP=01FAh
- la WORD meno significativa (3BA2h) di varDword viene inserita
nello stack all'offset SP=01FAh
- la CPU sottrae 2 byte a SP e ottiene SP=01F8h
- la componente Seg=0CF2h dell'indirizzo di ritorno viene inserita nello
stack all'offset SP=01F8h
- la CPU sottrae 2 byte a SP e ottiene SP=01F6h
- la componente Offset=003Bh dell'indirizzo di ritorno viene inserita nello
stack all'offset SP=01F6h
- l'indirizzo Seg:Offset di TestProc viene caricato in CS:IP e
viene effettuato un salto FAR a CS:IP
Il salto FAR ci porta direttamente all'interno di TestProc; la Figura 29.4
mostra l'aspetto che assume questa procedura in stile Assembly classico:
Per giustificare questa situazione ricordiamoci innanzi tutto che appena entrati in
TestProc abbiamo SP=01F6h; le prime tre istruzioni presenti all'interno di
TestProc sono le seguenti:
Come già sappiamo, queste tre istruzioni ci servono per accedere ai parametri e alle
variabili locali di TestProc; quando la CPU incontra queste tre istruzioni
esegue le seguenti operazioni:
- la CPU sottrae due byte a SP e ottiene SP=01F4h
- il contenuto di BP (simbolicamente, old BP) viene inserito nello
stack all'offset SP=01F4h
- il contenuto 01F4h di SP viene copiato in BP per cui si
ottiene BP=01F4h
- la CPU sottrae 6 byte a SP e ottiene SP=01EEh
Quest'ultima operazione serve per ottenere 6 byte di spazio nello stack, da
destinare alle variabili locali; come è stato detto in precedenza, TestProc ha
bisogno di due variabili locali chiamate simbolicamente locVar1 (4 byte)
e locVar2 (2 byte).
Tutte le operazioni che comprendono l'inserimento degli argomenti nello stack e le tre
precedenti istruzioni, rappresentano il prolog code di TestProc; al termine
del prolog code otteniamo quindi BP=01F4h e SP=01EEh. Per maggiore
chiarezza, tutta questa situazione viene illustrata in Figura 29.5.
L'aspetto importante da ricordare è dato dal fatto che con questo sistema, i parametri di
TestProc vengono a trovarsi a spiazzamenti positivi rispetto a BP, mentre
le variabili locali vengono a trovarsi a spiazzamenti negativi rispetto a BP; dalla
Figura 29.5, infatti, ricaviamo le seguenti informazioni:
- il parametro param1, che è la copia di varDword, viene a trovarsi
nello stack all'offset:
BP+6 = 01F4h + 0006h = 01FAh
- il parametro param2, che è la copia di varWord1, viene a trovarsi
nello stack all'offset:
BP+10 = 01F4h + 000Ah = 01FEh
- il parametro param3, che è la copia di varWord2, viene a trovarsi
nello stack all'offset:
BP+12 = 01F4h + 000Ch = 0200h
- la variabile locale locVar1 viene a trovarsi nello stack all'offset:
BP-4 = 01F4h - 0004h = 01F0h
- la variabile locale locVar2 viene a trovarsi nello stack all'offset:
BP-6 = 01F4h - 0006h = 01EEh
È importante osservare che param1 si trova all'offset BP+6 e occupa 4
byte nello stack; di conseguenza, param2 si troverà 4 byte più avanti e
cioè all'offset BP+10. Analogamente, la variabile locale locVar1 occupa
4 byte e quindi si trova all'offset BP-4; la variabile locale locVar2
occupa, invece, 2 byte e si trova quindi all'offset BP-6 (cioè, 2 byte
più indietro).
Si tenga presente che in Figura 29.5, i valori 002B14FFh per locVar1 e
01BCh per locVar2 sono puramente simbolici; in assenza di inizializzazione,
tutte le variabili locali di un programma hanno un contenuto casuale dovuto al fatto che lo
stack viene continuamente scritto e sovrascritto.
Qualcuno potrebbe avere dei dubbi in relazione agli offset delle due variabili locali
locVar1 e locVar2; per chiarire questi dubbi bisogna osservare innanzi tutto
che la porzione di memoria visibile in Figura 29.5 viene disposta, come al solito, con gli
indirizzi crescenti da destra verso sinistra. Ricordiamoci, inoltre, che l'offset di una
locazione di memoria coincide con l'offset del suo BYTE meno significativo; di
conseguenza, la variabile locale locVar1 parte dall'offset BP-4 ed occupa
nello stack i 4 BYTE che si trovano agli offset BP-4, BP-3, BP-2
e BP-1. Lo stesso discorso vale anche per locVar2; questa variabile locale,
infatti, parte dall'offset BP-6 ed occupa nello stack i 2 BYTE che si trovano
agli offset BP-6 e BP-5.
Le considerazioni appena svolte dimostrano ancora una volta quanto sia importante in questi
casi tracciare su un foglio di carta uno schema dello stack come quello visibile in Figura
29.5; se si prova a svolgere mentalmente tutti i calcoli descritti in precedenza, si va
incontro sicuramente a numerosi errori. Un ulteriore aiuto al programmatore deriva poi
dall'uso delle macro alfanumeriche, visibili in Figura 29.5, per la gestione dei parametri
e delle variabili locali di TestProc.
Passiamo ora alla descrizione della fase di terminazione di TestProc con conseguente
restituzione del controllo al caller. Prima di tutto, all'interno di TestProc, dobbiamo
caricare in AX il return value della procedura; nel nostro caso utilizziamo
AX in quanto dobbiamo restituire un intero con segno a 16 bit.
A questo punto comincia l'epilog code di TestProc; il primo compito da svolgere
consiste nel rimuovere dallo stack le variabili locali. Questo compito, che spetta sempre alla
procedura (indipendentemente dalle convenzioni di linguaggio), viene svolto dall'istruzione:
mov sp, bp
In questo modo si ottiene SP=01F4h; si può anche scrivere:
add sp, 6
Questo secondo metodo, però, ci espone a dei potenziali errori in quanto ci obbliga a
ricordare esattamente quanti byte di variabili locali avevamo richiesto allo stack;
naturalmente, il primo metodo funziona solo se BP non ha subito alcuna modifica
"imprevista".
Il secondo compito da svolgere consiste nel ripristinare BP ponendo BP=oldBP;
questo lavoro viene svolto dall'istruzione:
pop bp
Subito dopo l'estrazione di old BP dallo stack, la CPU somma 2 byte a
SP e ottiene SP=01F6h; a questo punto viene incontrato un FAR return
che comporta le seguenti operazioni svolte dalla CPU:
- la CPU estrae dall'offset SP=01F6h la WORD 003Bh e la carica
in IP
- la CPU somma 2 byte a SP e ottiene SP=01F8h
- la CPU estrae dall'offset SP=01F8h la WORD 0CF2h e la carica
in CS
- la CPU somma 2 byte a SP e ottiene SP=01FAh
- la CPU salta a CS:IP=0CF2h:003Bh
Subito dopo il salto FAR ci ritroviamo nell'istruzione associata all'indirizzo di
ritorno; come abbiamo visto in precedenza (chiamata Assembly di TestProc),
questa istruzione è:
add sp, 8
In questo modo stiamo restituendo allo stack gli 8 byte utilizzati per il passaggio
degli argomenti a TestProc; come sappiamo, infatti, in C questo compito spetta
al caller.
Subito dopo questa istruzione otteniamo SP=0202h e in questo modo abbiamo ripristinato
il valore che SP aveva prima della chiamata di TestProc; al termine di tutte
queste fasi il registro AX contiene il valore di ritorno della procedura.
29.2.1 Riscrittura di TestProc con la sintassi avanzata
Le considerazioni appena svolte si riferiscono al caso in cui il "rude" programmatore
Assembly si assuma l'onore e l'onere di gestire in proprio il prolog code
e l'epilog code delle procedure; come si può constatare, questi concetti una volta
appresi risultano abbastanza semplici da applicare.
Vediamo ora come si modifica la situazione nel momento in cui decidiamo di servirci della
sintassi avanzata di MASM; come è stato spiegato in precedenza, per usufruire di
queste innovazioni dobbiamo utilizzare la direttiva .MODEL. Nel nostro caso, il
programma deve quindi iniziare con:
.MODEL LARGE, C
La prima novità che ci offre la sintassi avanzata di MASM è rappresentata dalla
possibilità di dichiarare, anche in Assembly, un prototipo per ogni procedura
del programma che stiamo scrivendo.
Come accade per i linguaggi di alto livello, i prototipi permettono all'assembler di
verificare la correttezza delle chiamate alle procedure generando, se necessario, gli
opportuni messaggi di errore; in sostanza, incontrando una chiamata ad una procedura,
l'assembler verifica se il numero e il tipo degli argomenti che stiamo passando coincide
con le informazioni presenti nel prototipo.
La struttura generale di un prototipo di procedura assume in Assembly il seguente
aspetto:
nome_procedura PROTO [model_modifier] [language_modifier] [argument_list]
Le parentesi quadre indicano che questi parametri sono tutti facoltativi.
Il parametro argument_list (lista argomenti) elenca le caratteristiche degli
argomenti da passare alla procedura (parametri formali); ciascun argomento della
lista deve essere specificato con la seguente sintassi:
nome_simbolico : tipo
Il nome_simbolico è facoltativo e in genere, come accade nel linguaggio C,
viene omesso; il tipo può essere, come al solito, BYTE, WORD,
DWORD, etc. I vari argomenti della lista sono separati tra loro da virgole.
L'ordine con il quale gli argomenti vengono disposti nel prototipo non ha niente a che vedere
con le convenzioni per il passaggio degli argomenti alle procedure; indipendentemente, quindi,
dal parametro di linguaggio utilizzato, gli argomenti di TestProc vanno disposti sempre
nello stesso ordine.
Il modificatore di modello di memoria (NEAR o FAR) deve essere inserito solo se
vogliamo aggirare l'analogo parametro specificato nella direttiva .MODEL; lo stesso
discorso vale per il modificatore di linguaggio (C, PASCAL, etc).
Il luogo ideale per l'inserimento dei prototipi delle procedure è sicuramente la parte
iniziale del modulo Assembly (si può anche ricorrere ad un include file da
includere all'inizio del modulo che ne ha bisogno); naturalmente, il prototipo di una
procedura deve precedere la chiamata della procedura stessa.
Per applicare a TestProc le cose appena dette, con MASM dobbiamo scrivere:
TestProc PROTO FAR C :DWORD, :WORD, :WORD
Osserviamo che i modificatori di linguaggio e di modello di memoria coincidono con quelli
presenti nella direttiva .MODEL e quindi sono superflui; il prototipo di
TestProc può essere riscritto quindi come:
TestProc PROTO :DWORD, :WORD, :WORD
Se vogliamo essere pignoli possiamo anche specificare i nomi simbolici dei parametri
formali di TestProc; in questo caso dobbiamo scrivere:
TestProc PROTO param1 :DWORD, param2 :WORD, param3 :WORD
Una volta dichiarato il prototipo, possiamo passare alla definizione della procedura; nel
caso generale la definizione di una procedura inizia con la seguente intestazione, in
versione MASM:
nome_procedura PROC [model_modifier] [language_modifier] [argument_list]
Il MASM richiede che l'intestazione di una procedura sia identica al prototipo; di
conseguenza, gli eventuali parametri model_modifier e language_modifier
specificati nel prototipo devono essere specificati nello stesso ordine anche
nell'intestazione della procedura.
In generale, possiamo notare che l'intestazione della procedura è del tutto simile al suo
prototipo; questa volta la lista argument_list deve specificare anche i nomi
simbolici che intendiamo utilizzare per i parametri formali della procedura.
I parametri model_modifier e language_modifier devono essere specificati solo
se vogliamo aggirare gli analoghi parametri passati alla direttiva .MODEL.
All'interno della procedura possiamo definire le necessarie variabili locali attraverso la
direttiva LOCAL; la sintassi generale da utilizzare è la seguente:
LOCAL nome_var1 : tipo, nome_var2 : tipo, ...
Sia il prolog code che l'epilog code non vengono più gestiti dal programmatore;
tutto questo lavoro, infatti, viene delegato all'assembler.
Applichiamo ora tutti questi concetti al caso di TestProc; la Figura 29.6 illustra il
nuovo aspetto assunto dalla procedura TestProc con l'uso della sintassi avanzata di
MASM.
La prima cosa da osservare riguarda il fatto che questa volta, come accade nei linguaggi
di alto livello, la definizione di TestProc deve specificare anche la lista dei
parametri formali che la procedura richiede; è chiaro che il numero e il tipo dei parametri
di questa lista deve coincidere esattamente con le analoghe informazioni specificate nel
prototipo di TestProc.
Subito dopo l'intestazione di TestProc incontriamo la direttiva LOCAL; come
si può notare, questa direttiva ci permette di utilizzare una sintassi semplicissima per
la definizione delle variabili locali. Nel Capitolo 24 abbiamo anche visto che la direttiva
LOCAL viene impiegata per dichiarare etichette con visibilità locale all'interno del
corpo di una macro alfanumerica.
In Figura 29.6 notiamo che dopo la direttiva LOCAL inizia subito il corpo della
procedura; inoltre, al termine di TestProc troviamo solamente il caricamento in
AX del valore di ritorno e l'istruzione RET.
Come è stato spiegato in precedenza, il prolog code e l'epilog code devono
essere omessi in quanto vengono gestiti direttamente dall'assembler; dopo aver svolto il
proprio lavoro, l'assembler ottiene lo stesso risultato visibile in Figura 29.4!
Affinché l'assembler possa gestire automaticamente il prolog code e l'epilog
code di una procedura, dobbiamo servirci di una apposita sintassi avanzata per la
chiamata della procedura stessa; a tale proposito, viene resa disponibile una potente
direttiva di MASM denominata INVOKE!
29.3 La direttiva INVOKE
Con le caratteristiche avanzate di MASM, le procedure possono essere chiamate
attraverso l'uso di una sintassi molto simile a quella dei linguaggi di alto livello;
a tale proposito, dobbiamo servirci della apposita direttiva INVOKE.
La sintassi generale da utilizzare con il MASM è:
INVOKE nome_procedura, [argument_list]
La argument_list rappresenta, ovviamente, la lista dei nomi degli argomenti
(parametri attuali) da passare alla procedura; i vari nomi sono separati da virgole.
È importante notare anche la presenza della virgola dopo il nome_procedura.
Applicando questi concetti alla procedura TestProc, nel caso del MASM
otteniamo:
Come si può notare, il programmatore deve preoccuparsi esclusivamente della chiamata della
procedura e del salvataggio dell'eventuale valore di ritorno; tale salvataggio deve essere
effettuato subito dopo la chiamata della procedura per evitare che il contenuto di AX
possa essere inavvertitamente sovrascritto da qualche altra istruzione.
Tutto il lavoro legato al prolog code e all'epilog code viene svolto
dall'assembler che, incontrando questo tipo di chiamata, produce tutto il necessario
codice macchina; nel nostro caso, l'assembler nota la presenza dei parametri LARGE
e C associati alla direttiva .MODEL e genera quindi tutto il codice macchina
necessario per inserire gli argomenti nello stack (a partire dall'ultimo), per la chiamata
FAR di TestProc e per la pulizia dello stack a carico del caller (somma del
valore 8 a SP).
Per renderci conto del lavoro svolto dall'assembler possiamo richiedere, come al solito,
il listing file del modulo che vogliamo assemblare.
Sicuramente, uno dei vantaggi più interessanti delle caratteristiche avanzate di
MASM è rappresentato dal fatto che per adattare un intero modulo Assembly
ad un diverso modello di memoria e/o ad una diversa convenzione di linguaggio, basta
modificare solamente gli opportuni parametri passati alla direttiva .MODEL; nel
nostro caso possiamo scrivere, ad esempio:
.MODEL SMALL, PASCAL
In un caso del genere l'assembler converte automaticamente tutte le procedure da FAR
a NEAR e utilizza le convenzioni del linguaggio Pascal per la gestione delle
procedure stesse; inoltre, gli indirizzi NEAR vengono utilizzati anche per l'accesso
ai dati del programma.
29.4 L'operatore ADDR di MASM
Nel Capitolo 25, dedicato ai sottoprogrammi, è stata messa in evidenza la delicata
situazione che si viene a creare quando all'interno di una procedura dobbiamo gestire
l'offset di una variabile locale; in particolare, abbiamo visto che in un caso del genere non
avrebbe senso utilizzare l'operatore OFFSET. Questo operatore, infatti, lavora solo in
fase di assemblaggio e quindi richiede un operando che deve essere un identificatore definito
staticamente nel programma; come sappiamo, tali identificatori hanno un offset ben preciso
che viene assegnato dall'assembler (o dal linker nel caso di rilocazione) e rimane fisso per
tutta la fase di esecuzione di un programma.
La situazione cambia radicalmente nel caso di una variabile locale; le variabili locali di
una procedura vengono create nello stack nel preciso momento in cui la procedura stessa
viene chiamata dal caller. Questo significa che se chiamiamo 10 volte una stessa
procedura, può capitare che ad ogni chiamata le sue variabili locali vengano create sempre
ad offset differenti rispetto alla chiamata precedente; tutto dipende, infatti, dall'offset
a cui punta SP nello stack al momento della chiamata (un caso emblematico è
rappresentato dalle chiamate ricorsive).
Bisogna anche ricordare che le variabili locali vengono distrutte al termine della procedura
che le ha create; ciò significa che una variabile locale esiste (ed è quindi visibile) solo
all'interno della procedura a cui appartiene.
Se, ad esempio, all'interno di TestProc (vedi Figura 29.4) vogliamo caricare in
AX l'offset di locVar1, non possiamo assolutamente scrivere:
mov ax, offset locVar1
Questa istruzione, infatti, carica in AX un valore privo di senso; per risolvere il
problema possiamo utilizzare l'istruzione LEA scrivendo:
lea ax, locVar1
L'assembler espande la macro locVar1 e ottiene l'istruzione:
lea ax, [bp-4]
Questa istruzione calcola BP-4 (senza alterare il contenuto di BP) e carica
il risultato in AX; ovviamente, tale risultato è proprio l'offset di locVar1
all'interno del blocco stack (referenziato da SS).
Come è stato spiegato nel Capitolo 16, bisogna stare molto attenti al fatto che la sintassi
richiesta dall'istruzione LEA potrebbe confondere le idee al programmatore;
l'istruzione precedente carica in AX l'offset rappresentato da BP-4 e non il
contenuto a 16 bit della locazione di memoria che si trova all'indirizzo logico
SS:(BP-4).
Il problema appena esposto si presenta anche quando nella argument_list di
INVOKE è presente l'offset di qualche variabile locale. Supponiamo, ad esempio,
di dover chiamare una procedura Proc2 dall'interno di TestProc; se dobbiamo
passare a Proc2 il parametro param2 e l'offset di locVar2, dovremmo
scrivere:
invoke Proc2, param2, offset locVar2
Questa istruzione produce un risultato privo di senso che in genere manda in crash il
programma; per risolvere il problema possiamo scrivere, invece:
La direttiva INVOKE di MASM permette di rendere più compatto questo codice
grazie all'operatore ADDR; l'operatore ADDR, infatti, equivale all'operatore
OFFSET per le variabili statiche e all'istruzione LEA per le variabili
locali (dinamiche). Nel caso di MASM possiamo quindi scrivere:
invoke Proc2, param2, addr locVar2
Bisogna ricordare, però, che INVOKE, nello svolgere il proprio lavoro, potrebbe
sovrascrivere i registri citati in precedenza in questo capitolo; è necessario quindi
usare questa direttiva con molta cautela.
29.5 Convenzioni per i segmenti di programma
Nel corso degli anni, l'ambiente DOS/Windows è sempre stato dominato (nel bene e nel
male) dai compilatori Microsoft; questa "posizione dominante" ha permesso alla stessa
Microsoft di imporre una serie di convenzioni relative alla struttura interna dei
programmi. In particolare, sono state definite le caratteristiche complete dei program
segment utilizzati dai compilatori per l'organizzazione a basso livello dei programmi;
l'insieme di tutte queste convenzioni ha dato vita a dei veri e propri standard a cui si sono
adeguati molti altri produttori di compilatori.
Per facilitare l'interfacciamento tra l'Assembly e i linguaggi di alto livello, gli
assembler come MASM offrono il supporto completo della segmentazione standard
utilizzata dai compilatori; le caratteristiche avanzate di MASM coprono anche questo
importante aspetto.
Per ottimizzare al massimo la compattezza e l'efficienza dei programmi, i compilatori si
servono di un particolare sistema di segmentazione che comporta anche la suddivisione del
codice e dei dati in differenti categorie; analizziamo in dettaglio i vari tipi di
program segment disponibili.
29.5.1 Il segmento principale per i dati statici inizializzati
Nei linguaggi di alto livello, i dati statici inizializzati sono le variabili definite
al di fuori di qualsiasi procedura; tali variabili vengono anche inizializzate al momento
stesso della loro definizione.
In C possiamo avere, ad esempio:
int var_word1 = 12500;
In Pascal possiamo avere, ad esempio:
CONST var_word1: Integer = 12500;
In Assembly possiamo avere, ad esempio:
var_word1 dw 12500
Questi dati appartengono alla categoria delle variabili globali del programma; a
ciascuna di esse viene assegnato un indirizzo che rimane fisso (statico) per tutta la fase
di esecuzione. La durata di queste variabili è pari a quella dell'intero programma; la loro
visibilità è limitata al modulo di appartenenza, ma può essere estesa anche ad altri moduli.
Nei limiti del possibile, i compilatori cercano di inserire tutti i dati statici inizializzati
in un apposito program segment avente le seguenti caratteristiche:
Siccome il blocco _DATA è dotato di nome e attributi standard, per poterlo definire
possiamo servirci della apposita direttiva semplificata:
.DATA
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .DATA è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
29.5.2 Il segmento principale per i dati statici non inizializzati
Nei linguaggi di alto livello, i dati statici non inizializzati sono le variabili definite
al di fuori di qualsiasi procedura; tali variabili non vengono inizializzate durante la
definizione, per cui il loro contenuto iniziale dipende dalle convenzioni del linguaggio
utilizzato.
In C possiamo avere, ad esempio:
int var_temp;
In Pascal possiamo avere, ad esempio:
VAR var_temp: Integer;
In Assembly possiamo avere, ad esempio:
var_temp dw ?
Questi dati appartengono alla categoria delle variabili globali del programma; a
ciascuna di esse viene assegnato un indirizzo che rimane fisso (statico) per tutta la fase
di esecuzione. La durata di queste variabili è pari a quella dell'intero programma; la loro
visibilità è limitata al modulo di appartenenza, ma può essere estesa anche ad altri moduli.
Nei limiti del possibile, i compilatori cercano di inserire tutti i dati statici non
inizializzati in un apposito program segment avente le seguenti caratteristiche:
Siccome il blocco _BSS è dotato di nome e attributi standard, per poterlo definire
possiamo servirci della apposita direttiva semplificata:
.DATA?
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .DATA? è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
29.5.3 Il segmento principale per i dati statici costanti
Consideriamo la seguente istruzione relativa al linguaggio C:
printf("Premere un tasto per continuare");
Questa istruzione chiama la funzione di libreria printf per visualizzare sullo
schermo la stringa "Premere un tasto per continuare"; tale stringa viene usata
come argomento da passare a printf, ma non è associata ad alcuna variabile di
tipo stringa.
Il compilatore C tratta questi particolari dati come variabili globali
prive di nome; tali variabili vengono inserite in un apposito program segment
riservato ai cosiddetti dati costanti (in effetti, una stringa come "Premere
un tasto per continuare" è un dato costante in quanto viene definita una volta per
tutte e non può subire modifiche durante la fase di esecuzione del programma).
Nei limiti del possibile, i compilatori cercano di inserire tutti i dati statici costanti
in un apposito program segment avente le seguenti caratteristiche:
Siccome il blocco CONST è dotato di nome e attributi standard, per poterlo
definire possiamo servirci della apposita direttiva semplificata:
.CONST
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .CONST è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
Vista l'importanza dell'argomento, consideriamo un altro esempio rappresentato dalla
seguente definizione C:
char *pt_str = "Inserire un numero";
Quando il compilatore C incontra questa definizione, riserva 19 byte di
memoria (possibilmente nel blocco CONST) destinati a contenere i 18
caratteri della stringa più lo zero finale (stringa C); successivamente, il
compilatore richiede 2 byte di memoria (possibilmente nel blocco _DATA)
e li assegna alla variabile puntatore pt_str. Nei 2 byte assegnati a
pt_str viene caricato l'offset iniziale della stringa; a questo punto il
compilatore ha creato una variabile globale pt_str che è un puntatore
NEAR alla stringa "Inserire un numero".
Si potrebbe obiettare che pt_str non può essere un puntatore NEAR in
quanto punta ad una stringa che si trova in un segmento dati differente; a tale proposito,
si veda più avanti la sezione 29.5.5.
Anche se la precedente definizione viene posta all'interno di una funzione, il compilatore
crea pt_str nello stack (variabile locale), ma posiziona la stringa ugualmente nel
blocco CONST; questo comportamento è legato al fatto che le stringhe occupano troppo
spazio nello stack.
Possiamo dire quindi che, in questo caso, pt_str è una variabile locale, mentre la
stringa è, in ogni caso, un dato statico che non viene distrutto al termine della funzione.
Tutto ciò significa che in C è perfettamente lecito restituire al caller l'indirizzo
iniziale della stringa assegnata a pt_str; il caller, infatti, riceve l'indirizzo di
un dato statico che esiste per tutta la fase di esecuzione del programma. È un errore,
invece, restituire al caller l'indirizzo di pt_str; infatti, pt_str è una
variabile locale che quindi viene distrutta al termine della funzione che l'ha creata.
Per quanto riguarda i dati costanti di tipo numerico, bisogna dire che a seconda della loro
dimensione in byte, i compilatori possono decidere se inserirli nel blocco CONST o
direttamente nel codice macchina; consideriamo, ad esempio, la seguente istruzione C:
printf("Diametro = %f\n", circonferenza / 3.14);
Un compilatore C a 16 bit potrebbe anche decidere di inserire il valore
3.14 nel blocco CONST; un compilatore C a 32 bit potrebbe,
invece, decidere di inserire il valore 3.14 direttamente nel codice macchina (in
formato IEEE per i numeri reali a 32 bit).
29.5.4 Il segmento di stack per i dati dinamici
Come sappiamo, il blocco stack viene utilizzato per la creazione e la distruzione dinamica
dei dati temporanei di un programma (come le variabili locali definite all'interno di una
procedura).
I compilatori che supportano l'uso dello stack creano un apposito program segment
avente le seguenti caratteristiche:
Siccome il blocco STACK è dotato di nome e attributi standard, per poterlo definire
possiamo servirci della apposita direttiva semplificata:
.STACK [size]
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .STACK è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
Il parametro facoltativo size, se è presente, indica la dimensione in byte che
vogliamo assegnare allo stack; è fondamentale che venga specificato sempre un numero pari
di byte in modo da garantire il corretto allineamento di SP. In assenza del
parametro size gli assembler utilizzano una dimensione predefinita che, in genere,
è pari a 1024 byte.
29.5.5 Il gruppo DGROUP
Dopo aver distribuito i dati di un programma nei vari segmenti descritti in precedenza,
i compilatori definiscono un raggruppamento avente le seguenti caratteristiche:
DGROUP GROUP _DATA, CONST, _BSS, STACK
Successivamente, il compilatore stesso pone:
DS = SS = DGROUP
Questo modo di procedere dei compilatori, comporta notevoli vantaggi in termini di
efficienza e compattezza del codice macchina che viene prodotto.
Prima di tutto, osserviamo che referenziando DGROUP con DS, si fa in modo
che tutti i dati contenuti in _DATA, CONST, _BSS e STACK
possano essere gestiti attraverso indirizzi di tipo NEAR; come sappiamo, tali
indirizzi sono formati dalla sola componente Offset e quindi, oltre ad occupare
meno memoria di un indirizzo FAR, sono anche più veloci da gestire.
Un altro aspetto importante è dato dal fatto che DGROUP comprende, nell'ordine,
i dati inizializzati contenuti in _DATA e CONST, seguiti dai dati non
inizializzati contenuti in _BSS e dai dati temporanei contenuti in STACK;
disponendo DGROUP come ultimo segmento del programma, i compilatori possono
ottimizzare notevolmente le esigenze di memoria. Il file eseguibile generato dal
compilatore comprende solamente i blocchi _DATA e CONST (oltre ai
blocchi di codice e ad eventuali blocchi di dati FAR che verranno illustrati
nel seguito); la memoria necessaria per i blocchi _BSS e STACK verrà
richiesta solo durante la fase di esecuzione del programma (a tale proposito, si veda
il significato del campo MinimumAllocation nella Figura 13.8 del Capitolo 13).
I compilatori che supportano i modelli di memoria, permettono al programmatore di
abilitare o disabilitare l'inserimento del blocco stack in DGROUP; ad esempio,
il Borland C/C++ 3.1 dispone dell'opzione Assume SS equal DS (quindi,
abilitando tale opzione, il blocco stack verrà inserito in DGROUP).
Come è stato spiegato in precedenza, anche l'Assembly permette questo tipo
di scelta; a tale proposito, abbiamo visto che è necessario utilizzare i parametri
NEARSTACK e FARSTACK (da passare alla direttiva .MODEL).
29.5.6 I segmenti aggiuntivi per i dati statici inizializzati
Se il blocco _DATA non è sufficiente per contenere tutti i dati statici inizializzati
di un programma, il compilatore crea uno o più blocchi aggiuntivi ciascuno dei quali ha le
seguenti caratteristiche:
Siccome il blocco FAR_DATA è dotato di attributi standard, per poterlo
definire possiamo servirci della apposita direttiva semplificata:
.FARDATA [name]
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .FARDATA è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
Il parametro facoltativo name, se è presente, permette di assegnare un nome al
segmento di programma (è proibito utilizzare nomi riservati come _DATA o _BSS);
in assenza di tale parametro, gli assembler utilizzano il nome predefinito FAR_DATA.
Come si può notare, questo particolare blocco ha l'attributo di combinazione PRIVATE;
ciò significa che se definiamo numerosi blocchi FAR_DATA, ciascuno in un differente
modulo, tutti questi blocchi non verranno fusi tra loro (mentre se si trovano tutti nello
stesso modulo, daranno vita ad un unico blocco FAR_DATA).
In pratica, se in un programma creiamo 10 blocchi FAR_DATA distribuiti in
10 moduli differenti, otteniamo 10 blocchi FAR_DATA distinti; di
conseguenza, anche se non stiamo utilizzando nomi diversi per ciascun blocco, non esiste
la possibilità di confondere tra loro i vari blocchi FAR_DATA.
Naturalmente, tutti i dati inizializzati presenti all'interno dei blocchi FAR_DATA
vengono considerati di tipo FAR; per poter gestire questi dati è necessario quindi
specificare i loro indirizzi completi Seg:Offset.
La situazione appena descritta implica che potrebbe presentarsi la necessità di utilizzare
DS per referenziare un blocco FAR_DATA; in casi del genere bisogna sempre
ricordarsi di ripristinare il contenuto originario di DS. I compilatori si servono
normalmente di DS per gestire DGROUP; ciò significa che dopo aver utilizzato
DS per referenziare un blocco FAR_DATA, dobbiamo ripristinarlo in modo da
ottenere di nuovo DS=DGROUP.
29.5.7 I segmenti aggiuntivi per i dati statici non inizializzati
Se il blocco _BSS non è sufficiente per contenere tutti i dati statici non
inizializzati di un programma, il compilatore crea uno o più blocchi aggiuntivi ciascuno dei
quali ha le seguenti caratteristiche:
Siccome il blocco FAR_BSS è dotato di attributi standard, per poterlo
definire possiamo servirci della apposita direttiva semplificata:
.FARDATA? [name]
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .FARDATA? è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
Il parametro facoltativo name, se è presente, permette di assegnare un nome al
segmento di programma (è proibito utilizzare nomi riservati come _DATA o _BSS);
in assenza di tale parametro, gli assembler utilizzano il nome predefinito FAR_BSS.
Come si può notare, questo particolare blocco ha l'attributo di combinazione PRIVATE;
ciò significa che se definiamo numerosi blocchi FAR_BSS, ciascuno in un differente
modulo, tutti questi blocchi non verranno fusi tra loro (mentre se si trovano tutti nello
stesso modulo, daranno vita ad un unico blocco FAR_BSS).
In pratica, se in un programma creiamo 10 blocchi FAR_BSS distribuiti in
10 moduli differenti, otteniamo 10 blocchi FAR_BSS distinti; di
conseguenza, anche se non stiamo utilizzando nomi diversi per ciascun blocco, non esiste
la possibilità di confondere tra loro i vari blocchi FAR_BSS.
Naturalmente, tutti i dati non inizializzati presenti all'interno dei blocchi FAR_BSS
vengono considerati di tipo FAR; per poter gestire questi dati è necessario quindi
specificare i loro indirizzi completi Seg:Offset.
Tutto ciò implica che potrebbe presentarsi la necessità di utilizzare DS per
referenziare un blocco FAR_BSS; in casi del genere bisogna sempre ricordarsi di
ripristinare il contenuto originario di DS. I compilatori si servono normalmente
di DS per gestire DGROUP; ciò significa che dopo aver utilizzato DS
per referenziare un blocco FAR_BSS, dobbiamo ripristinarlo in modo da ottenere di
nuovo DS=DGROUP.
29.5.8 Il segmento principale per il codice
Per gestire il blocco codice contenente l'entry point e le istruzioni principali,
i compilatori creano un apposito program segment avente le seguenti caratteristiche:
Siccome il blocco _TEXT è dotato di nome e attributi standard, per poterlo definire
possiamo servirci della apposita direttiva semplificata:
.CODE
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .CODE è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
Come si può facilmente intuire, al momento di caricare il programma in memoria per la
fase di esecuzione, il SO pone CS=_TEXT.
29.5.9 I segmenti aggiuntivi per il codice
Se il blocco _TEXT non è sufficiente per contenere tutto il codice del programma,
il compilatore crea uno o più blocchi aggiuntivi ciascuno dei quali ha le seguenti
caratteristiche:
Siccome il blocco name_TEXT è dotato di nome e attributi standard, per poterlo definire
possiamo servirci della apposita direttiva semplificata:
.CODE [name]
Quando l'assembler incontra questa direttiva, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
direttiva .CODE [name] è rappresentata dall'inizio del blocco successivo o dalla
fine del modulo.
Il parametro facoltativo name, se è presente, indica il nome personalizzato che
vogliamo dare al segmento di codice; in assenza di questo parametro viene utilizzato il nome
del modulo in cui si trova il blocco codice, seguito dalla stringa _TEXT. Se, ad
esempio, viene incontrata una direttiva .CODE in un modulo chiamato LIB1.ASM,
si ottiene il nome LIB1_TEXT.
Di conseguenza, se nel modulo LIB1.ASM sono presenti 10 blocchi .CODE
privi del parametro name, verranno creati 10 blocchi chiamati LIB1_TEXT;
questi 10 blocchi hanno tutti lo stesso nome e gli stessi attributi (compreso
PUBLIC), per cui verranno fusi in un unico blocco LIB1_TEXT.
Se vogliamo avere 10 blocchi name_TEXT distinti, dobbiamo inserirli in 10
moduli differenti (uno per modulo), oppure dobbiamo assegnare loro 10 nomi differenti.
29.5.10 Considerazioni generali sulle convenzioni per i segmenti di programma
È necessario tenere presente che non tutti i compilatori seguono le convenzioni appena
esposte sui segmenti di programma; considerando, ad esempio, le numerose varianti del
BASIC (compilato), può anche capitare che i diversi compilatori di tale linguaggio
seguano convenzioni tra loro incompatibili.
Se abbiamo la necessità di sfruttare tutte queste caratteristiche avanzate, dobbiamo quindi
munirci di un adeguato compilatore; fortunatamente, tutti i compilatori più diffusi sul
mercato sono in grado di supportare le convenzioni seguite da MASM.
Un altro aspetto importante è dato dal fatto che solo in presenza della direttiva
.MODEL e delle varie direttive avanzate (per i segmenti di programma, per la chiamata
delle procedure, etc) gli assembler come MASM sono in grado di svolgere
automaticamente tutto il loro lavoro (compresa la generazione del prolog code,
dell'epilog code, del gruppo DGROUP, etc); in caso contrario, tutto questo
lavoro spetta al programmatore.
Infine, è necessario ricordare un aspetto che, se trascurato, può portare alla scrittura
di programmi che in fase di esecuzione vanno subito in crash; tale aspetto riguarda il
fatto che i compilatori, ovviamente, provvedono autonomamente alla inizializzazione di
DS (e, se necessario, anche di ES).
In Assembly, invece, questo importantissimo compito spetta al programmatore; in
generale, la cosa migliore da fare consiste nel seguire le convenzioni dei compilatori che
pongono DS=DGROUP.
29.6 Variabili simboliche create dall'assembler
Per facilitare ulteriormente la gestione dei segmenti standard da parte del programmatore,
gli assembler come MASM creano una serie di variabili simboliche da utilizzare
durante la fase di esecuzione; la Figura 29.7 elenca le variabili supportate da
MASM. Esistono anche numerose altre variabili simboliche che, però, variano da
assembler ad assembler; a tale proposito, si consiglia di consultare i manuali
dell'assembler che si intende utilizzare.
Ad esempio, se in un determinato momento della fase di esecuzione CS sta
referenziando il blocco di codice principale _TEXT, allora in quello stesso
momento abbiamo:
@code = _TEXT
Se, in un momento successivo, l'esecuzione salta ad un blocco di codice chiamato
LIB1_TEXT, allora in quello stesso momento abbiamo:
@code = LIB1_TEXT
Se in un determinato momento della fase di esecuzione stiamo accedendo ad un blocco
FAR_DATA (blocco privato di dati FAR inizializzati), allora in quello stesso
momento abbiamo:
@fardata = FAR_DATA
Se stiamo utilizzando il parametro (predefinito) NEARSTACK, allora appare evidente
che la variabile @stack equivale al nome DGROUP; se, invece, stiamo utilizzando
il parametro FARSTACK, allora la variabile @stack equivale al nome STACK.
Subito dopo l'entry point di un programma che fa uso delle caratteristiche avanzate
di MASM, DGROUP viene generalmente assegnato a DS; a quel punto abbiamo
quindi:
@data = DGROUP
29.7 Esempi pratici
Per analizzare in pratica i concetti appena esposti, utilizziamo le caratteristiche
avanzate di MASM per riscrivere gli esempi presentati nel Capitolo 27; più avanti
verranno illustrati i dettagli relativi ai programmi Assembly distribuiti su più
moduli.
29.7.1 Modello TINY
La struttura classica di un programma con modello di memoria TINY prevede la
presenza di un solo blocco contenente codice, dati e stack; per rendere il programma più
ordinato e leggibile, ci viene data la possibilità di distribuire le varie informazioni
nei segmenti: _TEXT, _DATA, CONST e _BSS.
Naturalmente, questa suddivisione è del tutto fittizia; infatti, il compilatore
provvederà a raggruppare tutte queste informazioni in un apposito blocco DGROUP
avente le seguenti caratteristiche:
DGROUP GROUP _TEXT, _DATA, CONST, _BSS
Successivamente, l'assembler genera automaticamente la seguente direttiva ASSUME:
ASSUME CS: DGROUP, DS: DGROUP, SS: DGROUP
Come sappiamo, in un programma con modello di memoria TINY destinato ad essere
convertito in un eseguibile in formato COM, il compito di creare il blocco stack
spetta al SO; lo stesso SO provvede all'inizializzazione di tutti i registri
di segmento ponendo:
CS = DGROUP, DS = DGROUP, ES = DGROUP, SS = DGROUP
Inoltre, lo stesso SO pone:
IP = 0100h, SP = FFFEh
In Figura 29.8 utilizziamo i concetti appena esposti per riscrivere il programma
illustrato nella Figura 27.4 del Capitolo 27.
Il prototipo di printStr2 è molto importante in quanto specifica un modello di
indirizzamento FAR (per le procedure), differente da quello predefinito
NEAR; grazie a tale prototipo, l'assembler è in grado di generare automaticamente
le FAR call a printStr2 e i FAR return dalla stessa procedura.
All'interno di printStr2 il contenuto originale di DS non viene preservato
in quanto tale precauzione è superflua; osserviamo, infatti, che gli indirizzi delle
stringhe stampate da questa procedura, hanno una componente Seg=DGROUP che,
ovviamente, non altera il contenuto di DS!
In Figura 29.8 si nota subito l'assenza delle direttive ASSUME, del prolog
code e dell'epilog code per le procedure; tutti questi aspetti vengono
gestiti automaticamente dall'assembler.
29.7.2 Modello SMALL
La struttura classica di un programma con modello di memoria SMALL prevede
la presenza di un solo blocco codice, un solo blocco dati e un solo blocco stack;
per rendere il programma più ordinato e leggibile, ci viene data la possibilità
di distribuire le varie informazioni nei segmenti: _TEXT, _DATA,
CONST, _BSS e STACK.
In fase di assemblaggio, l'assembler provvederà a raggruppare le varie categorie di dati
in un apposito blocco DGROUP avente le seguenti caratteristiche:
DGROUP GROUP _DATA, CONST, _BSS, STACK
Notiamo che questa volta (e anche per i modelli di memoria illustrati nel seguito) il
blocco _TEXT viene escluso da DGROUP; se vogliamo escludere anche il blocco
STACK, dobbiamo passare il parametro FARSTACK alla direttiva .MODEL.
In presenza del parametro NEARSTACK (o in assenza del parametro FARSTACK),
l'assembler genera automaticamente la seguente direttiva ASSUME:
ASSUME CS: _TEXT, DS: DGROUP, SS: DGROUP
Il SO effettua quindi le seguenti inizializzazioni:
CS = _TEXT, DS = ES = PSP, SS = STACK = DGROUP
In presenza del parametro FARSTACK, l'assembler genera automaticamente la seguente
direttiva ASSUME:
ASSUME CS: _TEXT, DS: DGROUP, SS: STACK
Il SO effettua quindi le seguenti inizializzazioni:
CS = _TEXT, DS = ES = PSP, SS = STACK
Il programmatore ha quindi l'importante compito di inizializzare DS e, se
necessario, anche ES; chiaramente, la cosa più ovvia da fare consiste nel porre:
DS = DGROUP
In Figura 29.9 vengono utilizzate le direttive avanzate per riscrivere il programma
illustrato nella Figura 27.5 del Capitolo 27.
Notiamo subito che, questa volta, il programmatore ha il compito di creare il blocco
stack; a tale proposito, utilizziamo la direttiva .STACK con un parametro che
rappresenta la dimensione in byte di tale segmento di programma.
Questa tecnica ci impedisce di definire dati statici all'interno del blocco stack; se
vogliamo fare una cosa del genere, dobbiamo tornare al metodo tradizionale per la
definizione dei segmenti di programma. Nel nostro caso, seguendo le convenzioni
MASM dobbiamo definire un blocco avente: nome STACK, allineamento
PARA, combinazione STACK e classe 'STACK'; si tenga presente,
però, che in questo modo si impedisce all'assembler di generare correttamente il
gruppo DGROUP!
In questo esempio, la procedura printStr2 visualizza stringhe definite in vari
segmenti di programma; di conseguenza, è importantissimo preservare il contenuto
originale di DS!
Come al solito, analizzando il listing file si può osservare in dettaglio tutto
il lavoro svolto dall'assembler per l'espansione delle direttive avanzate; notiamo, in
particolare, il valore che l'assembler assegna alla variabile simbolica @stack.
In caso di NEARSTACK l'assembler pone:
@stack = DGROUP
In caso di FARSTACK l'assembler pone:
@stack = STACK
29.7.3 Modello MEDIUM
La struttura classica di un programma con modello di memoria MEDIUM prevede la
presenza di due o più blocchi di codice, un solo blocco dati e un solo blocco stack; per
rendere il programma più ordinato e leggibile, ci viene data la possibilità di distribuire
le varie informazioni nei segmenti: name_TEXT, _DATA, CONST,
_BSS e STACK.
In fase di assemblaggio, l'assembler provvederà a raggruppare le varie categorie di dati
in un apposito blocco DGROUP avente le seguenti caratteristiche:
DGROUP GROUP _DATA, CONST, _BSS, STACK
Se vogliamo escludere il blocco STACK dal gruppo DGROUP, dobbiamo passare il
parametro FARSTACK alla direttiva .MODEL.
In presenza del parametro NEARSTACK (o in assenza del parametro FARSTACK),
l'assembler genera automaticamente la seguente direttiva ASSUME:
ASSUME CS: name_TEXT, DS: DGROUP, SS: DGROUP
Il SO effettua quindi le seguenti inizializzazioni:
CS = name_TEXT, DS = ES = PSP, SS = STACK = DGROUP
In presenza del parametro FARSTACK, l'assembler genera automaticamente la seguente
direttiva ASSUME:
ASSUME CS: name_TEXT, DS: DGROUP, SS: STACK
Il SO effettua quindi le seguenti inizializzazioni:
CS = name_TEXT, DS = ES = PSP, SS = STACK
Il programmatore ha quindi l'importante compito di inizializzare DS e, se
necessario, anche ES; chiaramente, la cosa più ovvia da fare consiste nel porre:
DS = DGROUP
In Figura 29.10 vengono utilizzate le direttive avanzate per riscrivere il programma
illustrato nella Figura 27.8 del Capitolo 27.
Questa volta, la modalità di indirizzamento predefinita è NEAR per i dati che si
trovano in DGROUP e FAR per le procedure; se vogliamo definire procedure
di tipo NEAR, dobbiamo inserire il modificatore di modello NEAR nel
relativo prototipo.
In Figura 29.10 notiamo che printStr1 viene definita, appunto, di tipo NEAR;
analizzando il listing file possiamo notare che l'assembler, per ogni chiamata di
questa procedura, genera il codice macchina di una NEAR call. All'interno di
printStr1 si può anche notare il codice macchina C3h del NEAR return;
per tutte le altre procedure, le chiamate predefinite sono di tipo FAR con
conseguente FAR return (codice macchina CBh).
Sempre attraverso il listing file possiamo constatare la presenza dei tre segmenti
di codice emedium_TEXT, CODESEGM2 e CODESEGM3; nei modelli di memoria
che prevedono la presenza di due o più segmenti di codice, le convenzioni MASM
stabiliscono che il nome del blocco codice principale sia composto dal nome del modulo
che contiene la direttiva .CODE (nel nostro caso, EMEDIUM), seguito dalla
stringa _TEXT.
È anche interessante analizzare il significato che la variabile @code assume nei
vari punti del listato di Figura 29.10; come sappiamo, questa variabile rappresenta il
program segment correntemente referenziato da CS.
Possiamo affermare allora che:
- all'interno di emedium_TEXT si ha: @code = CS = emedium_TEXT
- all'interno di CODESEGM2 si ha: @code = CS = CODESEGM2
- all'interno di CODESEGM3 si ha: @code = CS = CODESEGM3
29.7.4 Modello COMPACT
La struttura classica di un programma con modello di memoria COMPACT prevede
la presenza di un solo blocco codice, due o più blocchi di dati e un solo blocco
stack; per rendere il programma più ordinato e leggibile, ci viene data la
possibilità di distribuire le varie informazioni nei segmenti: _TEXT,
_DATA, CONST, _BSS, STACK, FAR_DATA e
FAR_BSS.
In fase di assemblaggio, l'assembler provvederà a raggruppare le varie categorie di dati
in un apposito blocco DGROUP avente le seguenti caratteristiche:
DGROUP GROUP _DATA, CONST, _BSS, STACK
Se vogliamo escludere il blocco STACK dal gruppo DGROUP, dobbiamo passare il
parametro FARSTACK alla direttiva .MODEL.
In presenza del parametro NEARSTACK (o in assenza del parametro FARSTACK),
l'assembler genera automaticamente la seguente direttiva ASSUME:
ASSUME CS: _TEXT, DS: DGROUP, SS: DGROUP
Il SO effettua quindi le seguenti inizializzazioni:
CS = _TEXT, DS = ES = PSP, SS = STACK = DGROUP
In presenza del parametro FARSTACK, l'assembler genera automaticamente la seguente
direttiva ASSUME:
ASSUME CS: _TEXT, DS: DGROUP, SS: STACK
Il SO effettua quindi le seguenti inizializzazioni:
CS = _TEXT, DS = ES = PSP, SS = STACK
Il programmatore ha quindi l'importante compito di inizializzare DS e, se
necessario, anche ES; chiaramente, la cosa più ovvia da fare consiste nel porre:
DS = DGROUP
In Figura 29.11 vengono utilizzate le direttive avanzate per riscrivere il programma
illustrato nella Figura 27.10 del Capitolo 27.
Questa volta, la modalità di indirizzamento predefinita è NEAR per i dati che si
trovano in DGROUP, FAR per i dati che si trovano nei blocchi FAR_DATA,
FAR_BSS e NEAR per le procedure; se vogliamo definire procedure di tipo
FAR, dobbiamo inserire il modificatore di modello FAR nel relativo prototipo.
Attraverso il listing file possiamo constatare la presenza dei due segmenti di dati
FAR denominati DATASEGM2 e DATASEGM3; se non avessimo usato questi
nomi personalizzati, avremmo ottenuto due blocchi denominati entrambi FAR_DATA
(tenuti distinti a causa dell'attributo di combinazione PRIVATE).
Nel caso della procedura printStr1, i suoi due parametri di tipo WORD possono
essere sostituiti da un unico parametro di tipo DWORD che rappresenta, ovviamente,
un indirizzo FAR (con la componente Offset nella WORD meno
significativa); nell'ipotesi che tale parametro venga chiamato strAddr, il codice
per il caricamento dell'indirizzo della stringa in DS:DX diventa:
lds dx, strAddr
È necessario ribadire che le istruzioni come LDS, LES, LFS, etc,
funzionano in modo corretto solo se l'operando sorgente (come strAddr) contiene
una coppia Seg:Offset che rispetta le convenzioni Intel!
29.7.5 Modelli LARGE e HUGE
La struttura classica di un programma con modello di memoria LARGE prevede la
presenza di due o più blocchi di codice, due o più blocchi di dati e un solo blocco
stack; per rendere il programma più ordinato e leggibile, ci viene data la possibilità
di distribuire le varie informazioni nei segmenti: name_TEXT, _DATA,
CONST, _BSS, STACK, FAR_DATA e FAR_BSS.
In fase di assemblaggio, l'assembler provvederà a raggruppare le varie categorie di dati
in un apposito blocco DGROUP avente le seguenti caratteristiche:
DGROUP GROUP _DATA, CONST, _BSS, STACK
Se vogliamo escludere il blocco STACK dal gruppo DGROUP, dobbiamo passare il
parametro FARSTACK alla direttiva .MODEL.
In presenza del parametro NEARSTACK (o in assenza del parametro FARSTACK),
l'assembler genera automaticamente la seguente direttiva ASSUME:
ASSUME CS: name_TEXT, DS: DGROUP, SS: DGROUP
Il SO effettua quindi le seguenti inizializzazioni:
CS = name_TEXT, DS = ES = PSP, SS = STACK = DGROUP
In presenza del parametro FARSTACK, l'assembler genera automaticamente la seguente
direttiva ASSUME:
ASSUME CS: name_TEXT, DS: DGROUP, SS: STACK
Il SO effettua quindi le seguenti inizializzazioni:
CS = name_TEXT, DS = ES = PSP, SS = STACK
Il programmatore ha quindi l'importante compito di inizializzare DS e, se
necessario, anche ES; chiaramente, la cosa più ovvia da fare consiste nel porre:
DS = DGROUP
In Figura 29.12 vengono utilizzate le direttive avanzate per riscrivere il programma
illustrato nella Figura 27.12 del Capitolo 27.
Questa volta, la modalità di indirizzamento predefinita è NEAR per i dati che si
trovano in DGROUP, FAR per i dati che si trovano nei blocchi FAR_DATA,
FAR_BSS e FAR per le procedure; se vogliamo definire procedure di tipo
NEAR, dobbiamo inserire il modificatore di modello NEAR nel relativo prototipo
(come accade per printStr1).
Attraverso il listing file possiamo constatare la presenza del gruppo DGROUP
e di due ulteriori segmenti di dati FAR denominati DATASEGM2 e
DATASEGM3; se non avessimo usato questi nomi personalizzati, avremmo ottenuto due
blocchi denominati entrambi FAR_DATA (tenuti distinti a causa dell'attributo di
combinazione PRIVATE).
Il segmento di codice principale viene chiamato automaticamente elarge_TEXT (in
quanto stiamo lavorando con un modello di memoria che prevede la presenza di due o più
blocchi di codice); per gli altri due segmenti di codice vengono utilizzati i nomi che
noi abbiamo imposto e cioè: CODESEGM2 e CODESEGM3.
Il modello HUGE, come sappiamo, è del tutto simile al modello LARGE; i
compilatori che supportano il modello HUGE lo utilizzano per permettere al
programmatore di definire singoli dati che superano la dimensione di 64 KiB.
Naturalmente, il compilatore gestisce questa situazione attraverso apposite procedure
capaci di manipolare i dati huge; in Assembly, tali procedure devono
essere scritte dal programmatore.
29.7.6 Modello FLAT
Il modello FLAT è stato espressamente concepito per la scrittura di programmi
destinati a girare sotto un SO a 32 bit; l'utilizzo di tale modello di
memoria permette quindi di sfruttare direttamente gli indirizzamenti caratterizzati da
una componente Offset a 32 bit!
Il modello FLAT è formalmente identico al modello TINY; tutte le
informazioni possono essere quindi distribuite nei segmenti: _TEXT, _DATA,
CONST, _BSS e STACK. La differenza sostanziale sta nel fatto che,
all'interno di questi segmenti, gli spiazzamenti sono sempre a 32 bit!
Analizziamo ora le convenzioni relative alla segmentazione standard nel modello
FLAT; si noti, in particolare, l'attributo USE32 (offset a 32 bit)
e l'attributo di allineamento DWORD (ottimale per le CPU a 32 bit).
Per i dati statici inizializzati viene riservato un segmento di programma avente le
seguenti caratteristiche:
Questo segmento di programma può essere ottenuto attraverso la direttiva semplificata:
.DATA
Per i dati statici non inizializzati viene riservato un segmento di programma avente le
seguenti caratteristiche:
Questo segmento di programma può essere ottenuto attraverso la direttiva semplificata:
.DATA?
Per i dati statici costanti viene riservato un segmento di programma avente le
seguenti caratteristiche:
Questo segmento di programma può essere ottenuto attraverso la direttiva semplificata:
.CONST
In analogia al modello di memoria TINY, anche nel modello FLAT il compito di
creare il blocco stack spetta al SO; nel caso in cui si abbia la necessità di definire
direttamente tale blocco, viene riservato un segmento di programma avente le seguenti
caratteristiche:
Questo segmento di programma può essere ottenuto attraverso la direttiva semplificata:
.STACK [size]
Per il codice viene riservato un segmento di programma avente le seguenti
caratteristiche:
Questo segmento di programma può essere ottenuto attraverso la direttiva semplificata:
.CODE
L'assembler provvede a raggruppare tutte queste informazioni in un apposito blocco
DGROUP avente le seguenti caratteristiche:
DGROUP GROUP _TEXT, _DATA, CONST, _BSS, STACK
Successivamente, l'assembler genera automaticamente la seguente direttiva ASSUME:
ASSUME CS: DGROUP, DS: DGROUP, SS: DGROUP
Il SO provvede all'inizializzazione di tutti i registri di segmento ponendo:
CS = DGROUP, DS = DGROUP, ES = DGROUP, SS = DGROUP
I segmenti di dati FAR_DATA e FAR_BSS e i segmenti di codice name_TEXT
perdono significato; non bisogna dimenticare, infatti, che nel modello FLAT ogni
programma viene inserito in un segmento virtuale da 4 GiB (flat segment)
all'interno del quale vengono sistemati i dati, il codice e lo stack.
Per muoverci all'interno di un flat segment utilizziamo indirizzi NEAR
rappresentati da offset a 32 bit che possono assumere tutti i valori compresi tra
00000000h e FFFFFFFFh; in questo modo è possibile esplorare tutto il segmento
virtuale da 4 GiB.
Per maggiori dettagli sul modello FLAT si può fare riferimento alle apposite
sezioni di questo sito.
29.8 Programmi Assembly distribuiti su due o più file
In relazione all'uso delle caratteristiche avanzate nei programmi Assembly
distribuiti su due o più file, valgono tutte le considerazioni già esposte nel Capitolo
28; è possibile quindi impiegare le direttive PUBLIC e EXTRN secondo le
regole che già conosciamo.
In precedenza è stato anche sottolineato il fatto che, quando si usano le caratteristiche
avanzate, la direttiva EXTRN per le procedure può essere sostituita efficacemente
con la direttiva PROTO (cioè, con il prototipo di procedura); in effetti, questo
metodo è caldamente consigliato.
Ad esempio, un modulo Assembly che ha bisogno di una procedura esterna avente le
seguenti caratteristiche:
printStr1 PROTO FAR :WORD, :WORD
può servirsi proprio di tale prototipo per dare all'assembler tutte le necessarie
informazioni; appare evidente, infatti, che i dettagli forniti da PROTO sono
ben più consistenti rispetto a ciò che si può ricavare da una semplice direttiva:
EXTRN printStr1
29.9 Le istruzioni ENTER
e LEAVE
Analizzando alcuni listati Assembly, ci si può imbattere in due particolari
istruzioni, ENTER e LEAVE, che ancora non conosciamo; si tratta di due
istruzioni concepite espressamente per automatizzare la generazione del prolog
code e dell'epilog code di una procedura!
Nel caso generale di una procedura dotata di parametri e di n byte di variabili
locali, il codice di "ingresso" è costituito dalle seguenti istruzioni:
Come già sappiamo, grazie a queste istruzioni possiamo trovare i parametri della procedura
a spiazzamenti positivi rispetto a BP e le variabili locali a spiazzamenti negativi
rispetto a BP; la fase appena descritta rappresenta la creazione del
cosiddetto stack frame della procedura.
Al termine della stessa procedura, il codice di "uscita" è costituito dalle seguenti
istruzioni:
Lo scopo di queste istruzioni è quello di ripristinare SP e BP prima
di restituire il controllo al caller; la fase appena descritta rappresenta la
distruzione dello stack frame della procedura e comprende anche la
rimozione dei parametri dallo stack (che può competere al caller o alla procedura
stessa).
Nel caso in cui si stia utilizzando una CPU 80186 o superiore, è possibile
sostituire il codice di "ingresso" con l'istruzione ENTER e il codice di
"uscita" con l'istruzione LEAVE; queste due istruzioni, come detto, sono
disponibili solo con le CPU 80186 e superiori ed hanno proprio lo scopo di
rendere automatica la generazione del codice relativo allo stack frame di
una procedura dotata di parametri e variabili locali.
L'istruzione ENTER ha il codice macchina C8h e richiede due operandi;
il primo operando è di tipo Imm16 mentre il secondo è di tipo Imm8.
Il primo operando indica il numero di byte da sottrarre a SP/ESP per l'allocazione
nello stack della memoria da destinare alle variabili locali; il secondo operando è un
numero compreso tra 0 e 31, che indica il "livello di innesto", cioè
l'eventuale presenza di stack frame precedenti da innestare nel nuovo stack
frame creato da ENTER. Attraverso questo meccanismo, i linguaggi come il
Pascal permettono la definizione di procedure all'interno di altre procedure
(procedure locali); per i linguaggi che non supportano la creazione di procedure locali,
il secondo parametro vale 0.
Applicando questi concetti alla procedura TestProc di Figura 29.4 (6 byte
di variabili locali), tutto il codice di "ingresso" viene sostituito dall'istruzione:
ENTER 6, 0
Quando la CPU incontra questa istruzione, salva BP nello stack, copia
SP in BP e sottrae 6 byte a SP; in modalità protetta a
32 bit la CPU lavora sui registri ESP, EBP
L'istruzione LEAVE ha il codice macchina C9h e non richiede alcun operando;
il compito di questa istruzione è quello di ripristinare i registri BP/EBP,
SP/ESP precedentemente modificati da ENTER.
Nel caso della procedura TestProc di Figura 29.4, tutto il codice di "uscita" viene
sostituito dall'istruzione:
LEAVE
Quando la CPU incontra questa istruzione, copia BP in SP e ripristina
BP; in modalità protetta a 32 bit la CPU lavora sui registri
ESP, EBP.
Come sappiamo, se si utilizzano le caratteristiche avanzate di MASM, tutto il
codice necessario per il prolog code e l'epilog code viene generato
automaticamente dall'assembler; in tal caso, lo stesso assembler può anche decidere di
impiegare ENTER e LEAVE.
29.10 Considerazioni finali
Appare evidente che le convenzioni illustrate in questo capitolo, hanno principalmente
lo scopo di semplificare il lavoro di interfacciamento tra l'Assembly e i
linguaggi di alto livello; naturalmente, nessuno ci impedisce di sviluppare programmi
interamente scritti in Assembly, che fanno uso delle convenzioni adottate dai
compilatori.
I puristi dell'Assembly ritengono, però, che non avrebbe molto senso utilizzare
in questo modo un linguaggio di basso livello; in effetti, se si ha bisogno di tutte
queste caratteristiche avanzate, si fa molto prima a passare ad un linguaggio di alto
livello.
Si tenga anche presente che quando si deve scrivere un modulo Assembly
particolarmente complesso, le varie convenzioni illustrate in questo capitolo possono
rivelarsi del tutto inadeguate; in una situazione del genere gli stessi manuali degli
assembler come NASM e MASM, consigliano, in particolare, di ricorrere
alla sintassi classica per i segmenti di programma.
In ogni caso, tutto dipende dai gusti personali; l'importante è capire che per poter
utilizzare tutte queste caratteristiche avanzate è necessaria una conoscenza piuttosto
solida del linguaggio Assembly e delle convenzioni illustrate in questo capitolo.