Assembly Base con NASM
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).
Le versioni più recenti degli assembler come MASM, offrono 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 è 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.
Le caratteristiche avanzate possono essere sfruttate anche per rendere più semplice lo
sviluppo di programmi interamente scritti in Assembly; in generale, però, bisogna
osservare che queste "innovazioni" non suscitano particolare entusiasmo tra i puristi del
linguaggio Assembly.
Proprio per questo motivo, gli sviluppatori del progetto NASM hanno deciso di non
fornire alcun supporto per le caratteristiche avanzate disponibili con MASM; tutto
il lavoro necessario per l'interfacciamento con i linguaggi di alto livello, viene lasciato
nelle mani del programmatore!
Del resto, come abbiamo visto nei precedenti capitoli, gli strumenti avanzati forniti dagli
assembler "commerciali" sono implementati attraverso le macro; lo scopo di questo capitolo
è proprio quello di dimostrare come sia possibile "potenziare" NASM mediante la
creazione di apposite macro.
29.1 I modelli di memoria
I compilatori dei linguaggi di alto livello, ricorrono spesso ai cosiddetti modelli
di memoria; nel Capitolo 27 abbiamo visto che i principali modelli di memoria sono
individuati, per convenzione, dalle denominazioni TINY, SMALL, MEDIUM,
COMPACT e LARGE.
In base al modello di memoria selezionato, i compilatori assumono un comportamento
predefinito per l'indirizzamento (NEAR o FAR) dei dati e delle procedure;
la Figura 29.1 illustra tutti i dettagli su questo aspetto.
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.1 Indirizzamento predefinito nel modello 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.2 Indirizzamento predefinito nel modello 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.3 Indirizzamento predefinito nel modello 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.4 Indirizzamento predefinito nel modello 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.5 Indirizzamento predefinito nel modello 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.6 Indirizzamento predefinito nel modello 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.7 Indirizzamento predefinito nel modello FLAT
Il modello FLAT viene supportato da NASM 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.8 Memory model override
Molti linguaggi di programmazione evoluti, permettono al programmatore di aggirare le
convenzioni di Figura 29.1; ad esempio, in un modulo C con modello SMALL, è
possibile creare procedure del tipo:
void far func1(short int, char far *);
Questo prototipo afferma che func1 è una funzione di tipo FAR che richiede
un parametro di tipo short int (intero con segno a 16 bit) e un altro di
tipo "indirizzo FAR di un char" (cioè, indirizzo Seg:Offset di un
intero con segno a 8 bit).
All'interno dei moduli C tutti questi aspetti vengono gestiti dal compilatore;
all'interno dei moduli Assembly, invece, è compito del programmatore gestire il
corretto indirizzamento dei dati e delle procedure.
Se vogliamo allora accedere a func1 da un modulo Assembly, dobbiamo
attenerci rigorosamente al prototipo di tale procedura; supponendo, ad esempio, di
aver definito i due dati varInt (intero con segno a 16 bit) e
varChar (intero con segno a 8 bit), la chiamata Assembly di
func1, in stile C, assume il seguente aspetto:
Il passaggio dell'argomento "puntatore FAR a varChar" comporta
l'inserimento nello stack dell'indirizzo completo Seg:Offset dello stesso
varChar; si osservi che la componente Seg, come al solito, viene inserita
nello stack prima della componente Offset.
È superfluo sottolineare che se non si rispettano tutte queste regole, si ottiene un
programma che, in fase di esecuzione, va sicuramente in crash!
29.1.9 Convenzioni per il prolog code e l'epilog code
I vari linguaggi di programmazione, utilizzano una serie di convenzioni relative al
passaggio degli argomenti alle procedure, alla pulizia dello stack e al modo di
restituire il valore di ritorno; molti di questi aspetti sono stati illustrati nei
capitoli precedenti e vengono riassunti dalla Figura 29.2.
Come si può notare, la chiamata predefinita di una procedura BASIC, FORTRAN
o Pascal, comporta l'inserimento degli argomenti nello stack a partire da quello
più a sinistra; il compito di ripulire lo stack dagli argomenti ricade sulla procedura
stessa (callee).
La chiamata predefinita di una procedura C, C++ o Prolog, comporta
l'inserimento degli argomenti nello stack a partire da quello più a destra; il compito di
ripulire lo stack dagli argomenti ricade sul caller.
STDCALL indica, non un linguaggio, bensì una convenzione largamente utilizzata in
ambiente Windows a 32 bit; si tratta di 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
(callee); 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.
Per quanto riguarda, infine, le convenzioni per la restituzione dei valori di ritorno
da parte delle procedure, vale tutto ciò che è stato illustrato nella Figura 25.10 del
Capitolo 25.
29.1.10 Prolog code e epilog code override
Come accade per i modelli di memoria, molti linguaggi di programmazione permettono di
aggirare anche le convenzioni di Figura 29.2 sul prolog code e l'epilog code;
ad esempio, in un modulo C è possibile creare funzioni del tipo:
short int far pascal func2(short int, short int);
Questo prototipo afferma che func2 è una procedura di tipo FAR che
richiede due parametri di tipo short int e restituisce uno short int;
inoltre, tale procedura segue le convenzioni Pascal per il prolog code
e l'epilog code.
Se vogliamo chiamare allora func2 da un modulo Assembly, con i due
argomenti varInt1 e varInt2, dobbiamo scrivere:
Come si può notare, gli argomenti vengono inseriti nello stack a partire dal primo;
inoltre, il compito di ripulire lo stack dagli argomenti viene lasciato alla procedura
chiamata (callee).
Si noti la chiamata di FUNC2 con la procedura scritta in lettere maiuscole;
ciò accade in quanto il Pascal è case-insensitive e non distingue quindi
tra maiuscole e minuscole (maggiori dettagli nel Capitolo 31).
29.1.11 Le opzioni NEARSTACK e FARSTACK
Come è stato spiegato in precedenza, 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; i compilatori stessi permettono al programmatore
di intervenire su questo aspetto attraverso apposite opzioni di configurazione denominate
NEARSTACK e FARSTACK.
Quando si abilita l'opzione NEARSTACK, il blocco stack viene inserito in
DGROUP (SS=DS); abilitando, invece, l'opzione FARSTACK, il blocco
stack viene separato da DGROUP (SS diverso da DS).
Ovviamente, un programmatore che intende interfacciare un modulo Assembly a
dei moduli scritti con i linguaggi di alto livello, deve tenere conto anche di tale
aspetto; si tenga presente, comunque, che tutti i dettagli relativi alla creazione e
gestione dello stack spettano al compilatore.
Più avanti verranno illustrati degli esempi pratici.
29.2 Sintassi avanzata per le procedure
Analizziamo ora un esempio pratico che ci permette di illustrare alcune macro per
la creazione delle procedure attraverso una sintassi più evoluta; 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.
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.
In ogni caso, appare evidente il fatto che in base a quanto è stato esposto in precedenza,
ogni linguaggio di programmazione utilizza apposite convenzioni per la gestione del
prolog code e dell'epilog code; ciò suggerisce l'idea di creare apposite
macro capaci di "applicare" automaticamente tali convenzioni!
Analizziamo allora gli strumenti che NASM ci mette a disposizione per risolvere il
nostro problema; in particolare, fissiamo la nostra attenzione su ciò che è stato esposto
nel Capitolo 24.
29.2.1 Macro per il prolog code e l'epilog code di una procedura
Osserviamo subito che una procedura C, CPP, Prolog, riceve gli
argomenti a partire da quello più a destra (ultimo argomento); viceversa, una
procedura Pascal, BASIC, FORTRAN, riceve gli argomenti a partire
da quello più a sinistra (primo argomento).
Convenzionalmente, l'accesso a tali argomenti avviene tramite BP; a tale
proposito, il contenuto originale di BP viene salvato nello stack e il contenuto
di SP viene copiato nello stesso BP.
Una volta compiuti questi passi, gli eventuali argomenti risultano accessibili a
partire da BP+4 per le procedure NEAR e BP+6 per le procedure
FAR; analogamente, le eventuali variabili locali risultano accessibili a
spiazzamenti negativi rispetto a BP.
Appare evidente che i procedimenti appena descritti sono basati sul rigoroso rispetto di
precise convenzioni; di conseguenza, possiamo facilmente implementare tali convenzioni
attraverso apposite macro!
Partiamo allora con la creazione di una serie di costanti globali il cui scopo è quello
di codificare le convenzioni illustrate nelle Figure 29.1 e 29.2; possiamo scrivere, ad
esempio:
Il programmatore deve utilizzare queste costanti per indicare il tipo NEAR o
FAR della procedura (parametro proc_type) e le convenzioni di linguaggio
(parametro language); come si può notare, le convenzioni di linguaggio possono
essere ricondotte a due grandi categorie a cui si aggiunge il caso particolare della
convenzione STDCALL).
A questo punto creiamo una prima macro (procedure) il cui compito è quello di
aprire la definizione di una procedura; all'interno di tale macro viene anche creato
un nuovo context stack destinato a contenere nomi simbolici da condividere
con altre macro.
La macro procedure assume il seguente aspetto:
La macro procedure è dotata di 3 parametri che rappresentano: le
convenzioni di linguaggio, il tipo di procedura (NEAR o FAR) e il
nome della procedura; naturalmente, il programmatore deve specificare i primi due
parametri attraverso le costanti simboliche create in precedenza.
Si può notare che viene creato un identificatore condiviso %$PROC_LANG che
memorizza il primo parametro (%1); invece, l'identificatore condiviso
%$STACK_BASE memorizza il secondo parametro (%2) che rappresenta lo
spiazzamento iniziale degli argomenti rispetto a BP (si osservi, infatti,
che NEARPROC=4 e FARPROC=6).
La terza costante condivisa %$ARGS_SIZE viene inizializzata a 0 e
rappresenta la dimensione totale in byte di tutti gli argomenti richiesti dalla
procedura; questa costante deve essere posizionata (e inizializzata a 0)
nella macro procedure per gestire il caso delle procedure che non richiedono
alcun argomento!
Nella parte finale di procedure notiamo la presenza di due istruzioni che
già conosciamo; si tratta, infatti, delle istruzioni per l'inizializzazione del
registro BP.
Per aprire, ad esempio, la definizione di una procedura FAR Pascal di nome
provaProc1, possiamo scrivere:
procedure LANGPAS, FARPROC, provaProc1
Questa macro viene espansa in:
Per la creazione dei nomi simbolici degli eventuali argomenti, scriviamo una nuova
macro di nome arguments; tale macro assume il seguente aspetto:
Come si può notare, ciascun argomento è formato da una coppia che comprende il nome
simbolico e la dimensione in byte; possiamo affermare quindi che il numero totale
di argomenti di cui la procedura ha bisogno è pari a ARGS_NUMB=%0/2.
All'interno della macro viene eseguito un loop con %REP per un numero di
volte pari proprio a ARGS_NUMB; attraverso la direttiva %ROTATE
vegnono definiti i nomi simbolici di ciascun parametro e la relativa posizione
nello stack.
Si osservi che per tali definizioni viene utilizzata la direttiva %XDEFINE;
se si impiegasse %DEFINE, al nome simbolico ARGS_BASE verrebbe assegnato
sempre l'ultimo valore derivante dall'espansione di tutte le macro!
Un'altra caratteristica interessante della macro arguments è rappresentata
dalla presenza delle direttive condizionali il cui scopo è quello di gestire le due
principali convenzioni (C e Pascal) per la disposizione degli argomenti
nello stack; come sappiamo, nella convenzione C la disposizione segue un ordine
inverso rispetto al caso della convenzione Pascal.
Dopo il loop, viene creata una costante condivisa %$ARGS_SIZE che contiene
la dimensione totale in byte di tutti gli argomenti ricevuti dalla procedura; tale
costante sarà poi utilizzata per ripulire gli argomenti dallo stack (ricordiamo che
per le procedure che non richiedono argomenti, si ha %$ARGS_SIZE=0).
Vediamo ora quello che succede se, nell'area di un programma destinata alla definizione
delle procedure, scriviamo:
La chiamata di queste macro viene espansa in:
Come si può notare, abbiamo creato tutto il codice standard iniziale di una procedura
C di tipo FAR, dotata di due argomenti di tipo WORD; nel corpo
della procedura possiamo così accedere ai vari argomenti attraverso i nomi simbolici
num1 e num2!
Per la creazione dei nomi simbolici delle eventuali variabili locali, scriviamo una
nuova macro di nome localvars; tale macro assume il seguente aspetto:
Si tratta di una macro del tutto simile ad arguments, che riceve gli
argomenti in coppie (nome simbolico, dimensione in byte); ovviamente, questa volta
i vari spiazzamenti vengono sottratti da BP!
Al termine del loop, la costante simbolica LOCV_BASE contiene la dimensione
totale in byte di tutte le variabili locali create nello stack; tale costante viene
poi sottratta da SP per creare fisicamente, nello stack, lo spazio necessario.
Vediamo ora quello che succede se, nell'area di un programma destinata alla definizione
delle procedure, scriviamo:
La chiamata di queste macro viene espansa in:
Come si può notare, abbiamo creato tutto il codice standard iniziale di una procedura
Pascal di tipo NEAR dotata di due argomenti (uno WORD e uno
DWORD) e di due variabili locali di tipo WORD; nel corpo della procedura
possiamo così accedere ai vari argomenti attraverso i nomi simbolici Integer1 e
Longint1, e alle varie variabili locali attraverso i nomi simbolici
LocalInt1 e LocalInt2!
Una procedura che segue le convenzioni C/Pascal termina con una istruzione che
copia il contenuto di BP in SP per la rimozione dallo stack di eventuali
variabili locali; questa istruzione può essere usata senza problemi anche in assenza di
tali variabili.
Subito dopo è presente una istruzione PUSH BP (che ripristina il contenuto
originale di BP) seguita da una istruzione per la restituzione del controllo al
caller; tale istruzione è RET (o RETN) per le procedure NEAR e
RETF per le procedure FAR.
La rimozione degli argomenti dallo stack spetta al caller nel caso della convenzione
C e al callee nel caso delle convenzioni Pascal e STDCALL;
possiamo creare allora la seguente macro per la chiusura della definizione di una
procedura:
Come si può notare, viene impiegata la costante condivisa %$STACK_BASE per
stabilire il tipo di ritorno NEAR o FAR; invece, la costante condivisa
%$PROC_LANG viene impiegata per stabilire a chi spetta la rimozione degli
argomenti dallo stack. Se tale compito spetta al callee, allora il valore da sommare
a SP è contenuto nella costante condivisa %$ARGS_SIZE (la quale viene
impiegata come operando di RETN/RETF); se, invece, tale compito spetta al
caller, allora l'aggiornamento di SP deve essere effettuato manualmente!
Si noti anche l'altro importante compito svolto da endprocedure, che consiste
nel rimuovere il context stack precedentemente creato da procedure.
Nel caso, ad esempio, della precedente procedura provaProc3, la macro
endprocedure viene espansa in:
Da tutte le considerazioni appena esposte, risulta evidente che per la definizione di
una procedura, l'ordine di chiamata delle macro deve essere, rigorosamente,
procedure, arguments, localvars, endprocedure; ovviamente,
procedure e endprocedure sono obbligatorie, mentre arguments e
localvars sono opzionali!
29.2.2 Macro per la chiamata di una procedura
Il potentissimo preprocessore di NASM può essere utilizzato anche per rendere
più evoluta la chiamata delle procedure; in pratica, possiamo scrivere una macro che
ci permette di chiamare le procedure secondo una sintassi molto simile a quella dei
linguaggi di alto livello.
Bisogna ricordare che una procedura C riceve gli argomenti a partire da quello
più a destra, mentre una procedura Pascal riceve gli argomenti a partire da
quello più a sinistra; possiamo scrivere allora la seguente macro:
Come si può notare, il meccanismo è sempre lo stesso utilizzato per le macro
arguments e localvars; osserviamo che nel caso delle procedure C,
%ROTATE riceve il valore -1 in modo che i vari argomenti, ruotando verso
destra, vengano inseriti nello stack a partire dall'ultimo!
Nel caso, ad esempio, della precedente procedura provaProc3, supponendo di aver
definito una variabile varWord1 di tipo WORD e una variabile
vardDword1 di tipo DWORD, possiamo effettuare chiamate del tipo:
callproc LANGPAS, NEARPROC, provaProc3, word [varWord1], dword [varDword1]
Questa macro viene espansa in:
29.2.3 Libreria di macro per le procedure C e Pascal
A questo punto, possiamo facilmente crearci una libreria di macro destinata alla
gestione semplificata delle procedure C e Pascal; come abbiamo visto
all'inizio del capitolo, molti altri linguaggi utilizzano queste stesse convenzioni.
Tradizionalmente, le librerie di macro vengono disposte in appositi file che
recano l'estensione .MAC; nel nostro caso, otteniamo la libreria
LIBPROC.MAC visibile in Figura 29.6.
Se un programma Assembly ha bisogno di questa libreria, non deve fare altro
che specificare la direttiva:
%include "libproc.mac"
Un aspetto importante della libreria di Figura 29.6, è dato dal fatto che i nomi
simbolici utilizzati per i parametri e le variabili locali, non possono essere
ridefiniti; in sostanza, se una procedura utilizza un parametro chiamato arg1,
allora tale nome non può essere utilizzato in un'altra procedura!
Se tutto ciò ci crea dei problemi, possiamo facilmente risolvere la situazione
approfittando del fatto che tra le macro procedure e endprocedure,
risulta attivo un context stack; di conseguenza, non dobbiamo fare altro
che anteporre il simbolo %$ a tutti i nomi simbolici che vogliamo ridefinire!
Possiamo scrivere, ad esempio:
In questo modo, in un'altra procedura possiamo riutilizzare i nomi %$Integer1,
%$LocalInt1, etc.
29.2.4 Riscrittura di TestProc con una sintassi più avanzata
Torniamo ora alla procedura TestProc di Figura 29.4 e riscriviamola con l'ausilio
della libreria LIBPROC.MAC di Figura 29.6; tenendo conto del prototipo C
di tale procedura
int far TestProc(long param1, int param2, int param3);
otteniamo il risultato visibile in Figura 29.7.
Come si può notare, le quattro macro che abbiamo creato in precedenza, permettono di
automatizzare tutta la fase relativa al prolog code e all'epilog code,
riducendo praticamente a zero la possibilità di commettere errori; il programmatore
può così concentrarsi sul solo corpo della procedura.
A questo punto, supponendo di aver creato le variabili varDword (di tipo
DWORD), varWord1 e varWord2 (di tipo WORD), possiamo
effettuare chiamate del tipo:
callproc LANGC, PROCFAR, TestProc, dword [varDword], word [varWord1], word [varWord2]
Al termine della chiamata, il valore di ritorno è disponibile nel registro AX.
29.3 Indirizzamento delle variabili locali di una procedura
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; abbiamo visto, infatti, che non avrebbe
senso trattare una variabile locale (dinamica) definita nello stack, come se fosse
una variabile statica definita in un segmento di programma.
Se, ad esempio, varWord1 è una variabile statica, definita quindi in un
segmento di programma, allora una istruzione del tipo:
mov bx, varWord1
trasferisce in BX l'offset di varWord1, mentre una istruzione del tipo:
mov bx, [varWord1]
trasferisce in BX il contenuto a 16 bit della locazione di memoria che
si trova all'indirizzo DS:varWord1.
Tenendo conto del significato di locVar2 (variabile locale a 16 bit
della procedura TestProc di Figura 29.7), una istruzione del tipo:
mov bx, locVar2
viene espansa in:
mov bx, [bp-6]
Questa istruzione carica in BX il contenuto di locVar2 e cioè, la
WORD memorizzata all'indirizzo SS:(BP-6)!
In pratica, il calcolo "tradizionale" dell'offset funziona solo in relazione ad
identificatori definiti 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.
Il metodo vivamente raccomandato per risalire all'offset di una variabile locale,
consiste (come già sappiamo) nel ricorso all'istruzione LEA (Load Effective
Address); nel caso, ad esempio, di locVar2, possiamo scrivere:
lea bx, locVar2
L'assembler espande la macro locVar2 e ottiene l'istruzione:
lea bx, [bp-6]
Questa istruzione calcola BP-6 (senza alterare BP) e carica il risultato
in BX; ovviamente, tale risultato è proprio l'offset di locVar2
all'interno del blocco stack (referenziato da SS).
Come è stato spiegato nel Capitolo 16, bisogna stare molto attenti al fatto che la
sintassi dell'istruzione LEA potrebbe confondere le idee al programmatore;
l'istruzione precedente carica in BX l'offset rappresentato da BP-6 e
non il contenuto a 16 bit della locazione di memoria che si trova all'indirizzo
logico SS:(BP-6).
Il problema appena esposto si presenta anche quando, dall'interno di una procedura
A, dobbiamo chiamare una procedura B la quale richiede come argomento
l'offset di un parametro o di una variabile locale della stessa procedura A;
anche in un caso del genere, conviene servirsi dell'istruzione LEA.
Vediamo un esempio pratico volutamente contorto. Supponiamo di avere una procedura
ProcA la quale passa ad una procedura ProcB gli offset di tre sue
variabili locali di tipo DWORD; la procedura ProcB restituisce in
EAX la somma dei contenuti delle tre variabili locali ricevute per indirizzo.
La struttura di ProcA è la seguente:
Quindi, ProcA crea tre variabili locali loc1A, loc2A, loc3A
e le inizializza con tre DWORD; poi carica i rispettivi offset nei tre registri
AX, BX, CX e li passa a ProcB.
Il compito di ProcB è quello di sommare le tre DWORD ricevute per
indirizzo, restituendo poi il risultato a ProcA attraverso EAX; la
struttura di ProcB è la seguente:
Come è stato spiegato in precedenza, la struttura di queste procedure di esempio
è volutamente contorta; osserviamo, infatti, che il numero di istruzioni usate è
notevolmente superiore al necessario.
ProcB carica in BX ciascuno degli offset ricevuti come argomento e
può così accedere per indirizzo alle variabili locali di ProcA; tali
variabili esistono anche quando il controllo passa a ProcB grazie al
fatto che ci troviamo in presenza di una chiamata innestata (in sostanza, le
variabili locali di ProcA saranno distrutte solo dopo che ProcB
avrà restituito il controllo)!
Ricordando che gli offset ricevuti da ProcB sono calcolati rispetto a
SS, dobbiamo ricordarci di ricorrere, se necessario, al segment override;
questo è proprio il caso del nostro esempio in quanto il puntatore BX non
viene associato automaticamente a SS.
29.4 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 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.4.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:
SEGMENT _DATA ALIGN=2 PUBLIC USE16 CLASS=DATA
Siccome il blocco _DATA è dotato di nome e attributi standard, per poterlo definire
possiamo servirci di una apposita macro del tipo:
%define STD_DATA SEGMENT _DATA ALIGN=2 PUBLIC USE16 CLASS=DATA
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_DATA è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
29.4.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 resw 1
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:
SEGMENT _BSS ALIGN=2 PUBLIC USE16 CLASS=BSS
Siccome il blocco _BSS è dotato di nome e attributi standard, per poterlo definire
possiamo servirci di una apposita macro del tipo:
%define STD_BSS SEGMENT _BSS ALIGN=2 PUBLIC USE16 CLASS=BSS
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_BSS è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
29.4.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:
SEGMENT _CONST ALIGN=2 PUBLIC USE16 CLASS=CONST
Siccome il blocco _CONST è dotato di nome e attributi standard, per poterlo
definire possiamo servirci di una apposita macro del tipo:
%define STD_CONST SEGMENT _CONST ALIGN=2 PUBLIC USE16 CLASS=CONST
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_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.4.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.4.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:
SEGMENT _STACK ALIGN=16 STACK USE16 CLASS=STACK
Siccome il blocco _STACK è dotato di nome e attributi standard, per poterlo definire
possiamo servirci di una apposita macro del tipo:
%define STD_STACK SEGMENT _STACK ALIGN=16 STACK USE16 CLASS=STACK
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_STACK è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
29.4.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:
GROUP DGROUP _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).
29.4.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:
SEGMENT FAR_DATA ALIGN=16 PRIVATE USE16 CLASS=FAR_DATA
Eventualmente, il programmatore può assegnare anche un nome differente al segmento
di programma (è proibito utilizzare nomi riservati come _DATA o _BSS);
siccome il blocco FAR_DATA è dotato di attributi standard, per poterlo
definire possiamo servirci di una apposita macro del tipo:
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_FAR_DATA è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
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.4.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:
SEGMENT FAR_BSS ALIGN=16 PRIVATE USE16 CLASS=FAR_BSS
Eventualmente, il programmatore può assegnare anche un nome differente al segmento
di programma (è proibito utilizzare nomi riservati come _DATA o _BSS);
siccome il blocco FAR_BSS è dotato di attributi standard, per poterlo
definire possiamo servirci di una apposita macro del tipo:
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_FAR_BSS è rappresentata dall'inizio del blocco successivo o dalla fine del
modulo.
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.4.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:
SEGMENT _TEXT ALIGN=2 PUBLIC USE16 CLASS=CODE
Siccome il blocco _TEXT è dotato di nome e attributi standard, per poterlo definire
possiamo servirci di una apposita macro del tipo:
%define STD_CODE SEGMENT _TEXT ALIGN=2 PUBLIC USE16 CLASS=CODE
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_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.4.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:
SEGMENT name_TEXT ALIGN=2 PUBLIC USE16 CLASS=CODE
Siccome il blocco name_TEXT è dotato di nome e attributi standard, per poterlo definire
possiamo servirci di una apposita macro del tipo:
%define STD_name_CODE(name) SEGMENT name %+ _TEXT ALIGN=2 PUBLIC USE16 CLASS=CODE
Combinando questa macro con quella precedente, otteniamo la seguente macro destinata a
creare qualsiasi blocco _TEXT o name_TEXT:
Quando l'assembler incontra questa macro, la espande e ottiene un segmento di programma
avente il nome e gli attributi descritti in precedenza; la fine di un blocco aperto dalla
macro STD_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; per convenzione, i compilatori utilizzano il nome
del modulo in cui si trova il blocco codice, seguito dalla stringa _TEXT. Ad
esempio, all'interno di un modulo C chiamato LIB1.C, il compilatore può
creare un segmento di codice denominato LIB1_TEXT.
29.4.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 esposte in precedenza.
Può anche capitare di imbattersi in qualche compilatore che, per i segmenti di programma,
utilizza nomi leggermente differenti rispetto a quelli standard (ad esempio, CONST
invece di _CONST); nei capitoli successivi vedremo come comportarci in casi del
genere.
Tutte le macro per la segmentazione standard appena illustrate, possono essere inserite
in un apposito file chiamato, ad esempio, STDSEGM.MAC; la struttura di tale file
è visibile nella Figura 29.8.
29.5 Esempi pratici
Per analizzare in pratica i concetti appena esposti, proviamo a riscrivere gli esempi
presentati nel Capitolo 27; più avanti verranno illustrati i dettagli relativi ai
programmi Assembly distribuiti su più moduli.
Lo scopo degli esempi che seguono è quello di simulare con l'Assembly, la
struttura interna che i compilatori assegnano ai programmi scritti con i linguaggi di
alto livello; attraverso il map file, il programmatore può analizzare il tipo
e la disposizione dei segmenti di programma creati dall'assembler.
29.5.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:
GROUP DGROUP _TEXT _DATA _CONST _BSS
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.9 utilizziamo i concetti appena esposti per riscrivere il programma
illustrato nella Figura 27.4 del Capitolo 27.
La disposizione dei segmenti illustrata in Figura 29.9 è estremamente importante;
se non si rispetta quest'ordine, non risulta possibile la generazione di un eseguibile
in formato COM!
Il blocco _TEXT deve essere il primo in assoluto in quanto contiene l'entry
point che, come sappiamo, deve trovarsi rigorosamente all'offset 0100h;
inoltre, i 0100h byte che precedono l'entry point devono essere
rigorosamente non inizializzati!
In un eseguibile in formato COM deve essere presente un unico segmento di
programma; proprio per questo motivo, la direttiva GROUP raggruppa tutti i
quattro segmenti di programma presenti.
I blocchi di dati inizializzati (_DATA e _CONST) devono precedere i
blocchi di dati non inizializzati (_BSS e _STACK); bisogna ribadire che
in un programma in formato COM il compito di creare il blocco stack spetta al
SO. Disponendo i blocchi di dati non inizializzati proprio alla fine del
programma, facciamo in modo che la memoria ad essi necessaria venga assegnata dal
SO nel momento in cui inizia la fase di esecuzione; a tale proposito, si veda
il significato del campo MinimumAllocation nella Figura 13.8 del Capitolo 13.
Rispettando le convenzioni per il modello di memoria TINY, nell'esempio di
Figura 29.9 utilizziamo solo dati e procedure di tipo NEAR; alcuni linker si
rifiutano di generare eseguibili in formato COM contenenti qualsiasi tipo di
salto FAR (comprese le FAR call)!
29.5.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.
Il compilatore provvede poi ad inserire tutti i blocchi di dati NEAR in un
gruppo DGROUP; se abilitiamo l'opzione NEARSTACK, il compilatore
inserisce anche il blocco _STACK in DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS _STACK
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = SS = DGROUP, ES = PSP
Se abilitiamo l'opzione FARSTACK, allora il compilatore esclude il blocco
_STACK da DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = DGROUP, ES = PSP, SS = _STACK
In Figura 29.10 utilizziamo i concetti appena esposti per riscrivere il programma
illustrato nella Figura 27.6 del Capitolo 27.
Nell'esempio di Figura 29.10 stiamo simulando l'opzione NEARSTACK; di
conseguenza, il blocco _STACK viene inserito in DGROUP. Subito dopo
l'entry point dobbiamo allora provvedere ad inizializzare correttamente i
registri DS, SS e SP; ovviamente, dobbiamo porre:
DS = SS = DGROUP
Il registro SP deve puntare alla fine del blocco DGROUP; ricordando
che un indirizzo logico Seg:Offset equivale all'indirizzo fisico
(Seg*16)+Offset e assumendo, per comodità, che tutti i blocchi siano
allineati al paragrafo (componente Offset pari a 0000h), possiamo
affermare che la distanza in byte tra l'inizio del blocco _STACK e l'inizio
del blocco _DATA è pari a:
(_STACK * 16) - (_DATA * 16)
A questo valore dobbiamo aggiungere poi STACK_SIZE ottenendo così la
dimensione totale in byte del blocco DGROUP; possiamo affermare allora che:
SP = ((_STACK * 16) - (_DATA * 16)) + STACK_SIZE
Nel caso in cui venga scelta l'opzione FARSTACK, tutti questi calcoli non
sono più necessari; infatti, in presenza di un segmento di programma con attributo
di combinazione _STACK, l'inizializzazione della coppia SS:SP spetta
al linker e al SO!
Rispettando le convenzioni per il modello di memoria SMALL, tutti i dati e
le procedure devono essere di tipo NEAR; in ogni caso, il programmatore è
libero di lavorare con dati e/o procedure di tipo FAR.
In Figura 29.10, ad esempio, la procedura printStr2 richiede un indirizzo
FAR in modo da poter visualizzare stringhe definite in vari segmenti di
programma; di conseguenza, all'interno di tale procedura, è importantissimo
preservare il contenuto originale di DS!
29.5.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: _TEXT, name_TEXT, _DATA,
_CONST, _BSS e _STACK.
Il compilatore provvede poi ad inserire tutti i blocchi di dati NEAR in un
gruppo DGROUP; se abilitiamo l'opzione NEARSTACK, il compilatore
inserisce anche il blocco _STACK in DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS _STACK
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = SS = DGROUP, ES = PSP
Se abilitiamo l'opzione FARSTACK, allora il compilatore esclude il blocco
_STACK da DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = DGROUP, ES = PSP, SS = _STACK
In Figura 29.11 utilizziamo i concetti appena esposti per riscrivere il programma
illustrato nella Figura 27.8 del Capitolo 27.
Nell'esempio di Figura 29.11 stiamo simulando l'opzione FARSTACK; di conseguenza,
il blocco _STACK viene escluso da DGROUP e la coppia SS:SP
viene inizializzata dal SO.
Questa volta, la modalità di indirizzamento predefinita è NEAR per i dati che si
trovano in DGROUP e FAR per le procedure; il programmatore è tenuto a
gestire in modo esplicito eventuali dati FAR e procedure NEAR.
In Figura 29.11 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 C2h Imm16 del NEAR
return; per tutte le altre procedure, le chiamate predefinite sono di tipo
FAR con conseguente FAR return (codice macchina CAh Imm16).
29.5.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.
Il compilatore provvede poi ad inserire tutti i blocchi di dati NEAR in un
gruppo DGROUP; se abilitiamo l'opzione NEARSTACK, il compilatore
inserisce anche il blocco _STACK in DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS _STACK
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = SS = DGROUP, ES = PSP
Se abilitiamo l'opzione FARSTACK, allora il compilatore esclude il blocco
_STACK da DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = DGROUP, ES = PSP, SS = _STACK
In Figura 29.12 utilizziamo i concetti appena esposti per riscrivere il programma
illustrato nella Figura 27.10 del Capitolo 27.
Nell'esempio di Figura 29.12 stiamo simulando l'opzione FARSTACK; di conseguenza,
il blocco _STACK viene escluso da DGROUP e la coppia SS:SP
viene inizializzata dal SO.
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; il programmatore è tenuto a gestire in modo
esplicito eventuali procedure FAR.
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.5.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: _TEXT, name_TEXT,
_DATA, _CONST, _BSS, _STACK, FAR_DATA e FAR_BSS.
Il compilatore provvede poi ad inserire tutti i blocchi di dati NEAR in un
gruppo DGROUP; se abilitiamo l'opzione NEARSTACK, il compilatore
inserisce anche il blocco _STACK in DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS _STACK
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = SS = DGROUP, ES = PSP
Se abilitiamo l'opzione FARSTACK, allora il compilatore esclude il blocco
_STACK da DGROUP attraverso la direttiva:
GROUP DGROUP _DATA _CONST _BSS
Subito dopo l'entry point il compilatore inserisce tutte le istruzioni
necessarie affinché, in fase di esecuzione, risulti:
CS = _TEXT, DS = DGROUP, ES = PSP, SS = _STACK
In Figura 29.13 utilizziamo i concetti appena esposti per riscrivere il programma
illustrato nella Figura 27.12 del Capitolo 27.
Nell'esempio di Figura 29.13 stiamo simulando l'opzione FARSTACK; di conseguenza,
il blocco _STACK viene escluso da DGROUP e la coppia SS:SP
viene inizializzata dal SO.
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; il programmatore è tenuto a gestire in modo
esplicito eventuali procedure NEAR.
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.5.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:
SEGMENT _DATA ALIGN=4 PUBLIC USE32 CLASS=DATA
Questo segmento di programma può essere ottenuto attraverso la seguente macro:
%define STD_DATA32 SEGMENT _DATA ALIGN=4 PUBLIC USE32 CLASS=DATA
Per i dati statici non inizializzati viene riservato un segmento di programma avente le
seguenti caratteristiche:
SEGMENT _BSS ALIGN=4 PUBLIC USE32 CLASS=BSS
Questo segmento di programma può essere ottenuto attraverso la seguente macro:
%define STD_BSS32 SEGMENT _BSS ALIGN=4 PUBLIC USE32 CLASS=BSS
Per i dati statici costanti viene riservato un segmento di programma avente le
seguenti caratteristiche:
SEGMENT _CONST ALIGN=4 PUBLIC USE32 CLASS=CONST
Questo segmento di programma può essere ottenuto attraverso la seguente macro:
%define STD_CONST32 SEGMENT _CONST ALIGN=4 PUBLIC USE32 CLASS=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:
SEGMENT _STACK ALIGN=4 STACK USE32 CLASS=STACK
Questo segmento di programma può essere ottenuto attraverso la seguente macro:
%define STD_STACK32 SEGMENT _STACK ALIGN=4 STACK USE32 CLASS=STACK
Per il codice viene riservato un segmento di programma avente le seguenti
caratteristiche:
SEGMENT _TEXT ALIGN=4 PUBLIC USE32 CLASS=CODE
Questo segmento di programma può essere ottenuto attraverso la seguente macro:
%define STD_CODE32 SEGMENT _TEXT ALIGN=4 PUBLIC USE32 CLASS=CODE
L'assembler provvede a raggruppare tutte queste informazioni in un apposito blocco
DGROUP avente le seguenti caratteristiche:
GROUP DGROUP _TEXT _DATA _CONST _BSS _STACK
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.5.7 Programmi Assembly distribuiti su due o più file
In relazione ai programmi Assembly distribuiti su due o più file, valgono tutte
le considerazioni già esposte nel Capitolo 28; è possibile quindi impiegare le direttive
GLOBAL e EXTERN secondo le regole che già conosciamo.
29.6 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.
29.7 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.