Assembly Base con MASM
Capitolo 30: Interfaccia tra C e Assembly
In questo capitolo vengono descritte le convenzioni che permettono di interfacciare il
linguaggio C con il linguaggio Assembly; naturalmente, si dà per scontato
che chi legge abbia una adeguata conoscenza della programmazione in C.
Il linguaggio C è stato creato nel 1972 da Dennis Ritchie presso i
laboratori Bell Telephone della AT&T; l'implementazione del C
venne effettuata su un computer Digital Equipment PDP-11 gestito da un sistema
operativo UNIX.
Sino ad allora erano stati realizzati diversi linguaggi di programmazione, come il
BASIC, il Pascal, il FORTRAN, il COBOL, etc; tutti questi
linguaggi si rivolgevano ad utenti accomunati dalla necessità di programmare un computer
senza avere la minima conoscenza degli aspetti legati all'architettura hardware degli
elaboratori elettronici.
Il BASIC ed il Pascal, ad esempio, si proponevano di insegnare l'arte della
programmazione ai principianti; il FORTRAN permetteva di risolvere problemi
matematici con il computer, mentre il COBOL veniva usato per scrivere programmi
destinati al settore finanziario ed alla gestione aziendale.
L'idea di Dennis Ritchie fu quella di creare un linguaggio di programmazione di
alto livello e di uso generale (general purpose) rivolto, però, ad utenti esperti
in informatica e hardware; a tale proposito, Ritchie prese il linguaggio
Assembly e gli aggiunse i costrutti sintattici tipici dei linguaggi di alto
livello.
In questo modo ottenne un linguaggio che permetteva di programmare un computer senza
gli impedimenti e le limitazioni tipiche dei linguaggi destinati ai non esperti; usando
il C quindi, il programmatore aveva la possibilità di fare tutto ciò che era
permesso dalla logica del computer.
Queste caratteristiche fecero guadagnare al C il titolo di linguaggio di
programmazione per eccellenza e di linguaggio ufficiale del mondo UNIX;
in effetti, nel corso della sua storia il C è stato largamente utilizzato per la
realizzazione di software che richiedeva le più elevate doti di potenza ed efficienza.
In particolare, il C è diventato il linguaggio preferito dai progettisti di
SO; infatti, con il C sono stati realizzati i più famosi e diffusi
SO, come il DOS, Windows, OS/2, UNIX, Linux,
BSD e molti altri.
Un altro settore dominato dal C è quello della progettazione dei compilatori e degli
interpreti; con il C sono stati realizzati numerosi compilatori ed interpreti per
linguaggi come il C++, Visual Basic, Java e persino assemblatori come
NASM, MASM e TASM!
A tutto ciò bisogna anche aggiungere che nel 1983 un apposito comitato dell'ANSI
(American National Standard Institute) ha definito lo standard ufficiale per il linguaggio
C (ANSI C); l'importantissima conseguenza di tutto ciò sta nel fatto che il
codice sorgente di un programma ANSI C può essere ricompilato senza alcuna modifica
su qualsiasi piattaforma hardware dotata di un compilatore ANSI C.
Come è stato spiegato in precedenza, la caratteristica rivoluzionaria del C sta nel
fatto che questo linguaggio è un vero e proprio Assembly di alto livello
grazie al quale si può fare tutto ciò che è permesso dalla logica del computer; per chiarire
questo concetto vediamo un semplice esempio pratico.
Supponiamo che nel blocco dati di un programma C siano state dichiarate le seguenti
variabili:
Un programmatore Assembly vedendo questo blocco dati capisce subito che la prima
WORD del vettore v si trova a +0 byte di distanza dall'inizio di
v, la seconda WORD si trova a +2 byte di distanza, la terza a +4
byte di distanza e così via sino alla sesta WORD che si trova a +10 byte di
distanza dall'offset iniziale assegnato a v; di conseguenza, il BYTE chiamato
c1 si trova a +12 byte di distanza dall'inizio di v, il BYTE
chiamato c2 si trova a +13 byte di distanza, la WORD chiamata i1
si trova a +14 byte di distanza e la WORD chiamata i2 si trova a
+16 byte di distanza.
Tutto ciò significa quindi che in Assembly possiamo scrivere, ad esempio:
Come sappiamo, infatti, in Assembly la scrittura v[12] rappresenta il
contenuto della locazione di memoria che si trova a +12 byte di distanza
dall'offset iniziale assegnato a v (al posto di v[12] si può anche
scrivere [v+12]); di conseguenza, l'istruzione:
mov al, byte ptr v[12]
carica in AL il contenuto a 8 bit della locazione di memoria che si trova a
+12 byte di distanza dall'inizio del vettore v (l'operatore byte ptr
è necessario in quanto v è stato definito come vettore di WORD e non di
BYTE).
Come si può notare, l'Assembly si fida del programmatore e non effettua quindi
alcuna verifica sulla correttezza dell'indice che abbiamo assegnato al vettore v;
in sostanza, l'Assembly presuppone che il programmatore sappia esattamente ciò che
sta facendo.
Questa è la stessa filosofia adottata nel linguaggio C; se vogliamo quindi
visualizzare sullo schermo il contenuto delle variabili illustrate in precedenza, possiamo
scrivere il seguente codice C:
Questo esempio ci offre l'occasione per sottolineare ancora una volta una differenza
importantissima che esiste tra l'Assembly e i linguaggi di alto livello; in
Assembly, se vogliamo accedere ad un elemento di un vettore, siamo tenuti a
specificare lo spiazzamento in byte che possiede l'elemento all'interno del vettore
stesso. Se, ad esempio, vogliamo accedere alla terza WORD del vettore v
definito in precedenza, dobbiamo scrivere v[4]; infatti, la terza WORD
di v si trova a +4 byte di distanza dall'inizio dello stesso vettore
v.
Nei linguaggi di alto livello, invece, tutti questi calcoli spettano al compilatore o
all'interprete; di conseguenza, il programmatore deve indicare tra parentesi quadre
l'indice dell'elemento di un vettore e non lo spiazzamento. Nel linguaggio C,
ad esempio, gli indici dei vettori partono da 0, per cui se vogliamo accedere
alla terza WORD di v dobbiamo scrivere v[2]; quando il compilatore
incontra l'indice 2 lo moltiplica per la dimensione in byte (2) di
ciascun elemento di v e ottiene lo spiazzamento 4.
Le tecniche di indirizzamento illustrate in precedenza per il linguaggio C,
vengono considerate sintatticamente illegali da molti altri linguaggi di programmazione;
ciò accade, in particolare, con quei linguaggi che, essendo rivolti ai principianti,
impongono rigide regole sintattiche.
Supponiamo, ad esempio, che il blocco dati illustrato in precedenza appartenga ad un
programma scritto in Pascal e supponiamo inoltre che il vettore v sia
stato definito come:
var v: array[1..6] of Integer;
Se ora proviamo a scrivere v[7], otteniamo un messaggio di errore del compilatore
Pascal; questo messaggio ci informa che stiamo utilizzando un indice fuori limite
per il vettore v!
In ogni caso, si tenga presente che spesso queste regole sono facilmente aggirabili;
osservando, ad esempio, che i1 si trova a 8 WORD di distanza dall'inizio di
v, possiamo definire una variabile intera i alla quale assegnamo il valore
8 in modo da poter scrivere il seguente codice Pascal:
WriteLn('i1 = ', v[i]);
Un qualsiasi compilatore o interprete non può effettuare in questo caso alcuna verifica
sul contenuto dell'indice del vettore; infatti, l'indice i è una variabile e non
una costante, per cui il suo contenuto sarà noto (alla CPU) solo in fase di
esecuzione del programma.
Un altro esempio pratico è rappresentato dal fatto che se in C definiamo una
variabile x di tipo float (numero reale a 32 bit) e una variabile
i3 di tipo long int (numero intero con segno a 32 bit), possiamo
scrivere allora il seguente assegnamento:
i3 = x;
Dal punto di vista della CPU questa istruzione è perfettamente legale in quanto
consiste nel copiare il contenuto a 32 bit di x (in formato IEEE
standard), nel contenuto a 32 bit di i3; di conseguenza, anche i compilatori
C considerano legale questo assegnamento.
Un compilatore Pascal, invece, incontrando la precedente istruzione produce un
messaggio di errore; questo perché il Pascal impone rigide regole sulla compatibilità
tra diversi tipi di dati e quindi non permette di copiare una variabile reale in una variabile
intera anche se in memoria entrambe le variabili rappresentano locazioni da 32 bit.
Questo capitolo tratta l'interfacciamento tra C e Assembly nell'ambiente
operativo a 16 bit offerto dal DOS; per lo sviluppo dei programmi di esempio
presentati nel seguito è necessario quindi munirsi di un compilatore C a 16
bit come il Microsoft C, il Borland Turbo C, etc.
Nel nostro caso, verrà utilizzato il compilatore Borland C++ 3.1 scaricabile dalla
sezione Compilatori assembly, c++ e altri dell’
Area Downloads di questo sito.
30.1 Tipi di dati del C
Per interfacciare correttamente l'Assembly con un linguaggio di alto livello è
necessario conoscere tutte le informazioni relative ai tipi di dati forniti dal linguaggio
stesso; queste informazioni assumono una importanza vitale nell'interscambio di dati tra
due o più moduli di un programma e, in particolare, nella fase di gestione dello stack
frame di una procedura.
Un compilatore C destinato a lavorare in un ambiente operativo a 16 bit,
fornisce una serie di tipi di dati interi aventi le caratteristiche illustrate in Figura
30.1.
Nel linguaggio C il tipo int rappresenta il tipo di dato intero di riferimento
e, in base a quanto previsto dallo standard ANSI C, la sua ampiezza in bit deve
rispecchiare il numero di bit che caratterizza l'ambiente operativo a cui è destinato il
compilatore.
Proprio per questo motivo, i compilatori destinati agli ambienti operativi a 16 bit
forniscono il tipo di dato int con ampiezza pari a 16 bit; lo standard ANSI
C prevede, inoltre, che il tipo short int abbia una ampiezza in bit non superiore
a quella di un int e che il tipo long int abbia una ampiezza in bit non inferiore
a quella di un int.
Il C fornisce anche il tipo enum che permette di definire enumerazioni di
dati di tipo signed int; tali dati hanno quindi la stessa ampiezza in bit dei
signed int.
I tipi di dati in virgola mobile (floating point) sono indipendenti dall'architettura
del computer in quanto il loro formato standard viene stabilito, come sappiamo, dall'IEEE
(Institute of Electrical and Electronics Engineers); i compilatori C che supportano lo
standard IEEE forniscono i tre tipi di dati in virgola mobile illustrati in Figura 30.2.
Gli estremi Min. e Max. si riferiscono ai numeri reali positivi; per ottenere
gli estremi negativi basta mettere il segno meno davanti agli estremi positivi.
Il campo Precisione si riferisce al numero di cifre esatte dopo la virgola; si tenga
presente che tanto maggiore è la parte intera del numero reale, tanto minore sarà il numero di
cifre esatte dopo la virgola.
Riassumendo, possiamo affermare che tutti questi aspetti dipendono dall'architettura del
computer che si sta utilizzando; per convenzione, i limiti minimo e massimo che assumono i
tipi interi e in virgola mobile, vengono specificati nei due header limits.h e
float.h della libreria standard del C. Il programma C di Figura 30.3
permette di verificare in pratica le considerazioni esposte in precedenza:
Compilando questo programma è possibile verificare le diverse ampiezze in bit assunte dai
tipi interi; in relazione, invece, ai numeri reali è possibile verificare il differente
numero di cifre decimali esatte garantito dai tre tipi float, double e
long double.
30.2 Convenzioni per i compilatori C
Nel Capitolo 29 sono state illustrate le convenzioni seguite dai compilatori nella
gestione della struttura interna dei programmi; analizziamo ora tali convenzioni in
riferimento ai compilatori C.
30.2.1 Segmenti di programma
In relazione alla segmentazione standard utilizzata per organizzare i programmi a basso
livello, i compilatori C seguono completamente le convenzioni illustrate nel
Capitolo 29; si può dire, anzi, che tali convenzioni sono state concepite in modo
particolare proprio per i compilatori C!
Possiamo utilizzare quindi tutti i modelli di memoria disponibili (TINY, SMALL,
MEDIUM, COMPACT, LARGE e HUGE); inoltre, in base al modello di
memoria utilizzato, le informazioni che compongono un programma possono essere distribuite
nei blocchi _DATA, CONST, _BSS, STACK, FAR_DATA,
FAR_BSS e name_TEXT.
I vari blocchi di dati NEAR vengono raggruppati dai compilatori C con la
direttiva:
DGROUP GROUP _DATA, CONST, _BSS, STACK
Il registro DS viene automaticamente inizializzato dal compilatore C con
l'assegnamento:
DS = DGROUP
Il comportamento predefinito dei compilatori C prevede che, in qualsiasi modello
di memoria, il blocco STACK venga sempre inserito in DGROUP; eventualmente,
il programmatore può modificare questa situazione attraverso appositi menu di
configurazione del compilatore.
Nel caso, ad esempio, del compilatore Borland C++ 3.1, dall'interno dell'editor
integrato bisogna selezionare il menu:
Options - Compiler - Code generation
e disattivare l'opzione Assume SS Equals DS.
Come è stato spiegato nel Capitolo 29, quando si interfaccia MASM con i
linguaggi di alto livello è vivamente consigliato l'uso delle direttive semplificate
per i segmenti; in questo modo si delega all'assembler il compito di espandere tali
direttive in conformità al modello di memoria che si sta utilizzando.
Abbiamo anche visto che in presenza delle direttive semplificate per i segmenti,
l'assembler crea automaticamente il gruppo DGROUP; definendo, invece, i segmenti
di programma secondo la sintassi classica, tutti questi aspetti sono a carico del
programmatore (il quale, in tal caso, deve sapere esattamente come comportarsi).
30.2.2 Stack frame
In relazione allo stack frame, bisogna ricordare innanzi tutto che in C
le procedure vengono chiamate funzioni. Nei precedenti capitoli abbiamo visto
che gli eventuali argomenti richiesti da una funzione C vengono inseriti nello
stack a partire dall'ultimo (destra sinistra); in questo modo, all'interno dello stack,
gli argomenti di una funzione si trovano posizionati nello stesso ordine indicato dal
prototipo della funzione stessa.
Il compito di ripulire lo stack dagli argomenti spetta, come sappiamo, al caller.
Per accedere ai parametri di una funzione, i compilatori C a 16 bit si
servono di BP; abbiamo visto che i parametri si trovano a spiazzamenti positivi
da BP, mentre le eventuali variabili locali si trovano a spiazzamenti negativi
da BP.
30.2.3 Valori di ritorno
In relazione, infine, alle locazioni utilizzate per contenere gli eventuali valori di
ritorno delle funzioni, valgono tutte le convenzioni illustrate nella Figura 25.10 del
Capitolo 25.
Sempre in relazione ai valori di ritorno, bisogna ricordare che il C permette di
definire funzioni che possono restituire intere strutture, non solo per indirizzo, ma
anche per valore!
Ovviamente, il caso di una funzione che restituisce una struttura per indirizzo è molto
semplice; infatti, nella Figura 25.10 del Capitolo 25 si vede che un indirizzo NEAR
viene restituito in AX, mentre un indirizzo FAR viene restituito in
DX:AX.
Il caso interessante riguarda, invece, il procedimento utilizzato dai compilatori C
per permettere ad una funzione di restituire una struttura per valore. Una struttura che
occupa complessivamente 1, 2 o 4 byte, viene restituita, rispettivamente,
nei registri AL, AX, DX:AX; una struttura che occupa complessivamente
3 byte, oppure più di 4 byte, viene restituita al caller attraverso un metodo
più complesso che sarà illustrato nel seguito del capitolo attraverso un apposito esempio
pratico.
30.3 Le classi di memoria del C
Nella terminologia dei linguaggi di alto livello, con l'espressione classe di memoria
si indica l'insieme degli aspetti legati alla visibilità e alla durata di un identificatore;
questi aspetti coinvolgono, naturalmente, il metodo attraverso il quale l'identificatore è
stato creato.
Come accade nella maggior parte dei linguaggi, anche in C esistono due categorie
fondamentali di identificatori e cioè, gli identificatori locali e globali;
in generale, gli identificatori locali del C sono quelli definiti all'interno di un
blocco funzionale delimitato da una coppia { }, mentre gli identificatori globali
sono quelli esterni a qualsiasi blocco { }.
A differenza di quanto accade in Pascal, il C non permette di definire
funzioni innestate all'interno di altre funzioni; possiamo dire allora che in C i
nomi che identificano le funzioni sono tutti globali e quindi le funzioni esistono per
tutta la fase di esecuzione e sono visibili in tutto il programma.
In assenza di altre indicazioni da parte del programmatore, una funzione C definita
in un modulo è visibile anche in altri moduli del programma; se in un modulo A
definiamo una funzione func1 con prototipo:
int func1(char, char);
allora in un modulo B possiamo rendere visibile func1 attraverso la
dichiarazione:
extern int func1(char, char);
Per fare in modo che func1 non sia visibile all'esterno del modulo A,
dobbiamo utilizzare la parola chiave static e dichiarare in A il prototipo:
static int func1(char, char);
Le etichette del C possono essere definite solo all'interno di una funzione; la
loro visibilità quindi è limitata alla funzione di appartenenza e il loro identificatore
può essere ridefinito in altri punti del programma.
Le variabili del C possono essere, sia globali, sia locali; in C le variabili
globali vengono anche chiamate statiche, mentre le variabili locali vengono anche
chiamate automatiche.
Una variabile è globale quando viene definita all'esterno di qualsiasi blocco funzionale
{ }; in questo caso il compilatore C inserisce la variabile globale nel
blocco dati del programma. Una variabile globale quindi esiste per tutta la fase di
esecuzione ed è visibile in tutto il programma.
Le variabili globali possono essere inizializzate solo con valori costanti; in assenza
di inizializzazione, lo standard ANSI C prevede che il compilatore assegni ad una
variabile globale il valore iniziale 0.
In assenza di altre indicazioni da parte del programmatore, una variabile globale del
C definita in un modulo è visibile anche in altri moduli del programma; se in un
modulo A definiamo una variabile globale:
float pi_greco = 3.14;
allora in un modulo B possiamo rendere visibile pi_greco attraverso la
dichiarazione:
extern float pi_greco;
Per fare in modo che pi_greco non sia visibile all'esterno del modulo A,
dobbiamo utilizzare la parola chiave static e riscrivere nel modulo A la
definizione:
static float pi_greco = 3.14;
Una variabile del C è locale (o automatica) quando viene definita all'interno di
un qualsiasi blocco { }; la visibilità di una variabile locale è limitata al blocco
{ } di appartenenza e quindi il suo identificatore può essere ridefinito altrove.
Una variabile locale viene creata nello stack quando si entra nel blocco { } di
appartenenza e viene poi distrutta quando si esce dal blocco stesso; per fare in modo che
una variabile locale non venga distrutta, bisogna utilizzare ancora la parola chiave
static. Se la precedente variabile pi_greco viene definita all'interno di
un blocco { }, possiamo scrivere:
static float pi_greco = 3.14;
In questa seconda versione la parola chiave static dice al compilatore C
che pi_greco non deve essere distrutta all'uscita dal blocco { } di
appartenenza; il trucco adottato dal compilatore C consiste nel creare questo
tipo di variabili nel blocco dati del programma e non nello stack. È perfettamente
lecito quindi il caso di una funzione che restituisce al caller l'indirizzo di una sua
variabile locale static; tale variabile, infatti, essendo stata definita nel blocco
dati del programma, continua ad esistere anche al di fuori della funzione che l'ha creata.
Questo stesso discorso vale anche se, all'interno di una funzione, definiamo una variabile
locale del tipo:
char *loc_string = "Stringa locale";
Quando il compilatore incontra questa definizione, crea la stringa "Stringa locale"
nel blocco dati del programma, poi crea nello stack una variabile locale loc_string
di tipo puntatore a char e le assegna l'indirizzo iniziale della stringa; al termine
della funzione, la variabile locale loc_string viene distrutta, mentre la stringa
"Stringa locale" continua ad esistere.
In sostanza, anche in assenza della parola chiave static, le stringhe definite con
il precedente metodo all'interno di un blocco { }, vengono ugualmente sistemate nel
blocco dati del programma; è lecito quindi scrivere una funzione C che restituisce
al caller l'indirizzo (della stringa) contenuto nella precedente variabile puntatore
loc_string.
Le variabili locali del C possono essere inizializzate, sia con valori costanti, sia
con il contenuto di altre variabili; in assenza di inizializzazione il contenuto di una
variabile locale è casuale ("sporco").
30.4 Gestione degli indirizzamenti in C
Nel linguaggio C un puntatore è una variabile contenente un valore intero che
rappresenta un indirizzo di memoria; negli ambienti operativi a 16 bit un indirizzo
di memoria può essere o di tipo NEAR (formato da un Offset a 16 bit),
o di tipo FAR (formato da una coppia Seg:Offset a 16+16 bit).
Come già sappiamo, l'indirizzo di una locazione di memoria da 8 bit, 16 bit,
32 bit, etc, coincide con l'indirizzo del BYTE meno significativo della
locazione stessa; un puntatore contiene quindi l'indirizzo NEAR o FAR del
BYTE meno significativo della locazione di memoria a cui sta puntando.
Analizziamo in pratica questi concetti premettendo che nel seguito, quando si parla
genericamente di compilatore o compilazione, si intende il processo di traduzione del
codice C in codice macchina; supponiamo allora di avere il seguente blocco dati
di un programma C:
Quando un compilatore C incontra questo blocco dati:
- assegna a c1 una locazione di memoria da 8 bit e carica in questa
locazione il valore 12
- assegna a i1 una locazione di memoria da 16 bit e carica in questa
locazione il valore 1350
- assegna a l1 una locazione di memoria da 32 bit e carica in questa
locazione il valore 186900
Supponendo di lavorare con indirizzi di tipo NEAR, in presenza della definizione:
char *pc1 = &c1;
il compilatore assegna a pc1 una locazione di memoria da 16 bit e carica in
questa locazione l'Offset iniziale di c1.
In presenza della definizione:
int *pi1 = &i1;
il compilatore assegna a pi1 una locazione di memoria da 16 bit e carica in
questa locazione l'Offset iniziale di i1.
In presenza della definizione:
long *pl1 = &l1;
il compilatore assegna a pl1 una locazione di memoria da 16 bit e carica in
questa locazione l'Offset iniziale di l1.
Se, invece, stiamo lavorando con indirizzi di tipo FAR, in presenza della definizione:
char far *pc1 = &c1;
il compilatore assegna a pc1 una locazione di memoria da 16+16 bit e carica
in questa locazione la coppia Seg:Offset iniziale di c1.
In presenza della definizione:
int far *pi1 = &i1;
il compilatore assegna a pi1 una locazione di memoria da 16+16 bit e carica
in questa locazione la coppia Seg:Offset iniziale di i1.
In presenza della definizione:
long far *pl1 = &l1;
il compilatore assegna a pl1 una locazione di memoria da 16+16 bit e carica
in questa locazione la coppia Seg:Offset iniziale di l1.
A questo punto, se all'interno di una funzione C scriviamo:
char c2 = *pc1;
il compilatore accede all'indirizzo puntato da pc1, legge un blocco da 8 bit
che parte da quell'indirizzo e lo copia nella locazione a 8 bit in cui si trova
c2.
Se scriviamo:
int i2 = *pi1;
il compilatore accede all'indirizzo puntato da pi1, legge un blocco da 16 bit
che parte da quell'indirizzo e lo copia nella locazione a 16 bit in cui si trova
i2.
Se scriviamo:
long l2 = *pl1;
il compilatore accede all'indirizzo puntato da pl1, legge un blocco da 32 bit
che parte da quell'indirizzo e lo copia nella locazione a 32 bit in cui si trova
l2.
Supponiamo ora di avere una funzione scritta in Assembly che presenta il seguente
prototipo C:
extern void func_asm(char c, int i, long l);
Effettuiamo ora, da un modulo scritto in C, la chiamata di func_asm con gli
argomenti c1, i1 e l1 definiti in precedenza; la chiamata assume il
seguente aspetto:
func_asm(c1, i1, l1);
In questo caso abbiamo a che fare con il passaggio per valore dei tre argomenti c1,
i1 e l1, per cui vengono create nello stack le copie dei valori di questi tre
argomenti; queste tre copie vengono chiamate simbolicamente c, i e l e
rappresentano i tre parametri della funzione func_asm.
All'interno di func_asm il parametro c rappresenta un generico valore intero
12 a 8 bit, il parametro i rappresenta un generico valore intero
1350 a 16 bit, mentre il parametro l rappresenta un generico valore
intero 186900 a 32 bit; in base a queste considerazioni, nella funzione
func_asm possiamo scrivere quindi istruzioni del tipo:
È importante notare che i vari operandi che fanno parte di una istruzione, devono avere
dimensioni in bit compatibili con quelle previste dall'istruzione stessa; non avrebbe
senso quindi utilizzare MOV per caricare c in AX, o utilizzare
ADD per sommare EBX con i.
Grazie al passaggio per valore, qualsiasi modifica apportata ai parametri c, i
e l, non ha alcuna ripercussione sugli argomenti c1, i1 e l1;
infatti, c, i e l si trovano nel blocco stack, mentre c1,
i1 e l1 si trovano nel blocco dati del programma.
Vediamo, invece, come ci si deve comportare quando la funzione func_asm ha il seguente
prototipo:
extern void func_asm(char *pc, int *pi, long *pl);
Utilizzando le variabili definite in precedenza, la chiamata di questa funzione diventa:
func_asm(&c1, &i1, &l1);
oppure:
func_asm(pc1, pi1, pl1);
Nel primo caso stiamo passando direttamente gli indirizzi degli argomenti c1,
i1 e l1, per cui si parla di passaggio diretto per indirizzo; nel
secondo caso stiamo passando indirettamente (tramite cioè i puntatori) gli indirizzi degli
argomenti c1, i1 e l1, per cui si parla di passaggio indiretto per
indirizzo.
In entrambi i casi, la funzione func_asm riceve tre valori interi a 16 bit
chiamati simbolicamente pc, pi e pl, che rappresentano i tre indirizzi
NEAR di c1, i1 e l1; attraverso questi tre indirizzi, la funzione
func_asm può accedere alle relative locazioni di memoria modificandone il contenuto
originale.
Tenendo conto del significato dei parametri pc, pi e pl, all'interno
di func_asm possiamo scrivere istruzioni del tipo:
Questa volta abbiamo a che fare con indirizzi NEAR formati ciascuno da una componente
Offset a 16 bit; questi indirizzi vengono gestiti attraverso i tre registri
puntatori SI, DI e BX. Accedendo alle locazioni di memoria relative a
questi indirizzi, possiamo modificarne il contenuto originale; infatti, come si vede
nell'esempio, tutte le modifiche apportate a [SI], [DI] e [BX] si
ripercuotono su c1, i1 e l1.
Da queste considerazioni si intuisce subito che se il parametro pc (che abbiamo
caricato in SI) è l'indirizzo iniziale di un vettore di char, allora gli
indirizzi dei vari elementi del vettore corrispondono a: SI+0, SI+1,
SI+2, etc; di conseguenza, il contenuto dei vari elementi del vettore è
rappresentato da: [SI+0], [SI+1], [SI+2], etc.
Se il parametro pi (che abbiamo caricato in DI) è l'indirizzo iniziale di un
vettore di int, allora gli indirizzi dei vari elementi del vettore corrispondono a:
DI+0, DI+2, DI+4, etc; di conseguenza, il contenuto dei vari elementi
del vettore è rappresentato da: [DI+0], [DI+2], [DI+4], etc.
Se il parametro pl (che abbiamo caricato in BX) è l'indirizzo iniziale di un
vettore di long, allora gli indirizzi dei vari elementi del vettore corrispondono a:
BX+0, BX+4, BX+8, etc; di conseguenza, il contenuto dei vari elementi
del vettore è rappresentato da: [BX+0], [BX+4], [BX+8], etc.
Nel caso di indirizzamenti di tipo FAR bisogna solo ricordarsi che in questo caso
si devono gestire indirizzi da 16+16 bit; il prototipo della funzione func_asm
diventa:
extern void func_asm(char far *pc, int far *pi, long far *pl);
In presenza della chiamata:
func_asm(&c1, &i1, &l1);
il compilatore C provvede automaticamente a passare le coppie Seg:Offset
relative agli indirizzi dei vari argomenti; per poter effettuare, invece, la chiamata:
func_asm(pc1, pi1, pl1);
dobbiamo servirci delle definizioni FAR illustrate in precedenza per i tre puntatori
pc1, pi1 e pl1.
In entrambi i casi, la funzione func_asm riceve tre valori interi a 16+16 bit
chiamati simbolicamente pc, pi e pl, che rappresentano i tre indirizzi
FAR di c1, i1 e l1; attraverso questi tre indirizzi, la funzione
func_asm può accedere alle relative locazioni di memoria modificandone il contenuto
originale.
Tenendo conto del significato dei parametri pc, pi e pl, all'interno
di func_asm possiamo scrivere istruzioni del tipo:
Queste istruzioni presuppongono l'utilizzo del set di istruzioni a 32 bit in modo
da poter disporre dei registri di segmento FS e GS; in caso contrario
bisogna riuscire a destreggiarsi con DS e ES, ricordandosi poi di ripristinare
il contenuto originale di DS.
Se pc, pi e pl rappresentano gli indirizzi di partenza di tre vettori
rispettivamente di char, di int e di long, valgono le stesse
considerazioni già esposte per il caso degli indirizzi NEAR; l'unica differenza
sta nel fatto che questa volta dobbiamo gestire anche la componente Seg di ogni
elemento dei vettori per cui, ad esempio, ES:[SI+4] rappresenta il contenuto del
quinto char del vettore puntato da pc.
30.5 Protocollo di comunicazione tra C e Assembly
In base alle considerazioni appena svolte e in base alle cose dette nei precedenti capitoli,
possiamo dedurre una serie di regole che permettono a due o più moduli C e
Assembly di comunicare tra loro; l'insieme di queste regole definisce quindi il
protocollo di comunicazione tra C e Assembly.
I moduli C e Assembly destinati a comunicare tra loro, devono specificare lo
stesso modello di memoria; questo aspetto è di vitale importanza in quanto garantisce il
corretto scambio di informazioni tra i vari moduli.
In alternativa è possibile seguire una strada molto complessa e pericolosa, che consiste
nello specificare esplicitamente il tipo di indirizzamento NEAR o FAR
necessario per accedere alle variabili e alle procedure del programma.
Un modulo C che intende utilizzare degli identificatori definiti in un modulo
Assembly, deve dichiarare questi identificatori attraverso il qualificatore
extern; da parte sua il modulo Assembly contenente le definizioni degli
identificatori da utilizzare nel modulo C, deve specificare le necessarie direttive
PUBLIC relative agli identificatori stessi.
Un modulo Assembly che intende utilizzare degli identificatori definiti in un modulo
C, deve dichiarare questi identificatori attraverso la direttiva EXTRN; da
parte sua il modulo C contenente le definizioni degli identificatori da utilizzare
nel modulo Assembly, deve provvedere a rendere esternamente visibili gli identificatori
stessi.
Tutti gli identificatori visibili anche all'esterno del modulo di appartenenza, vengono
chiamati identificatori con linkaggio esterno; gli identificatori visibili, invece,
solo all'interno del modulo di appartenenza, vengono chiamati identificatori con linkaggio
interno.
In un programma C, tutti gli identificatori con linkaggio esterno devono essere
preceduti dal carattere '_' (underscore); il carattere underscore è associato al
codice ASCII 95.
I compilatori C aggiungono automaticamente l'underscore all'inizio di ogni
identificatore con linkaggio esterno presente nei vari moduli C di un programma; per
non generare confusione, nei moduli C è consigliabile quindi evitare l'uso di nomi
preceduti dall'underscore.
Nel caso degli identificatori con linkaggio esterno definiti o dichiarati nei moduli
Assembly, si possono presentare due casi:
- se si sta programmando in stile Assembly classico, il compito di inserire
tutti gli underscore spetta al programmatore
- se si utilizzano le caratteristiche avanzate di MASM, basta passare il
parametro di linguaggio C alla direttiva .MODEL per delegare
all'assembler il compito di inserire tutti gli underscore
Tutte le procedure contenute nei moduli Assembly da linkare al C, devono
preservare rigorosamente il contenuto di tutti i registri di segmento (in particolare,
CS, DS e SS) e dei registri SP e BP; se si utilizzano
le caratteristiche avanzate di MASM, all'interno delle procedure dotate di
stack frame il contenuto dei registri SP e BP viene automaticamente
preservato dall'assembler.
I registri CS e SS vengono gestiti direttamente dal SO e non necessitano
quindi di modifiche da parte del programmatore; in rarissime circostanze si può avere la
necessità di modificare SS e SP per poter gestire uno stack personalizzato.
I registri di segmento ES, FS e GS sono a nostra completa disposizione,
mentre DS viene inizializzato con DGROUP dal compilatore C; se il
programmatore ha la necessità di modificare DS per referenziare altri segmenti di
dati, deve preservarne il contenuto originale (che in genere è DGROUP).
I compilatori C utilizzano anche i registri SI e DI per gestire le
variabili intere con qualificatore register; molti compilatori C permettono
di abilitare o meno questa possibilità. Per non creare problemi al compilatore, si consiglia
quindi di preservare sempre SI e DI nelle proprie procedure Assembly che
utilizzano questi registri; generalmente, tutte le funzioni delle librerie fornite dai
compilatori C preservano SI e DI.
Il C è un linguaggio case-sensitive e quindi i compilatori C distinguono
tra maiuscole e minuscole; questo significa che identificatori come varword1,
VarWord1, VARWORD1, etc, verranno considerati tutti distinti.
In osservanza a queste regole, al momento di assemblare un modulo Assembly da linkare
ad un modulo C è necessario richiedere un assemblaggio case-sensitive; se si
sta utilizzando MASM, il parametro da passare all'assembler è /Cp.
30.6 Esempi pratici
Analizziamo ora una serie di esempi pratici che ci permettono di verificare le considerazioni
esposte in precedenza; è necessario ribadire che tutti gli esempi che seguono si riferiscono
all'ambiente operativo a 16 bit offerto dal DOS.
30.6.1 Chiamate incrociate tra moduli C e moduli Assembly
Iniziamo con un programma formato dai due moduli CMOD1.C e ASMMOD1.ASM;
lo scopo del programma è quello di effettuare delle chiamate incrociate tra procedure
definite nei due moduli.
Il modulo CMOD1.C chiama una procedura definita nel modulo ASMMOD1.ASM per
visualizzare una stringa C definita nello stesso modulo CMOD1.C; il modulo
ASMMOD1.ASM chiama una procedura definita nel modulo CMOD1.C per visualizzare
una stringa C definita nello stesso modulo ASMMOD1.ASM.
In entrambi i casi la stringa C viene visualizzata attraverso la funzione printf
della libreria standard del C; in questo modo possiamo anche vedere come sia possibile
chiamare, da un modulo Assembly, le funzioni della libreria standard del C.
CMOD1.C svolge il ruolo di modulo principale del programma e quindi contiene
anche l'entry point che, come vedremo in seguito, viene gestito direttamente dal
compilatore C; il modulo CMOD1.C assume l'aspetto mostrato in Figura 30.4.
Come si può notare, in questo modulo viene definita una stringa strc con linkaggio
esterno e viene dichiarata una stringa esterna strasm; inoltre, viene definita una
procedura printstr_c con linkaggio esterno e viene dichiarata una procedura esterna
printstr_asm. Appena si entra nella funzione principale main, viene chiamata
la procedura esterna printstr_asm definita nel modulo ASMMOD1.ASM.
Analizziamo ora proprio il modulo ASMMOD1.ASM che, essendo un modulo secondario, non
deve contenere alcun entry point; la prima versione di questo modulo è scritta in
stile Assembly classico e non fa uso quindi delle macro presentate nel precedente
capitolo.
Come sappiamo, in un caso del genere siamo obbligati a specificare tutti i dettagli relativi
ai segmenti standard; inoltre, tutte le procedure devono indicare il loro tipo (NEAR
o FAR).
Naturalmente, la gestione dello stack frame è interamente a carico del programmatore;
anche la gestione delle direttive ASSUME e del gruppo DGROUP ricade sul
programmatore.
Nel caso di un programma con modello di memoria SMALL, il modulo ASMMOD1.ASM
assume l'aspetto mostrato in Figura 30.5.
La prima cosa da osservare riguarda il fatto che il C è case sensitive, per
cui tutti gli identificatori presenti nel modulo ASMMOD1.ASM devono letteralmente
coincidere con i corrispondenti identificatori presenti nel modulo CMOD1.C; notiamo
poi la presenza del carattere underscore all'inizio di ogni identificatore con linkaggio
esterno.
In virtù del fatto che stiamo utilizzando il modello di memoria SMALL, tutte le
procedure interne o esterne sono di tipo NEAR; analogamente, l'accesso a tutte le
variabili del programma avviene tramite indirizzi di tipo NEAR, costituiti quindi
dalla sola componente Offset a 16 bit.
La procedura printstr_asm, chiamata dalla funzione main del modulo
CMOD1.C, utilizza la funzione esterna printf della libreria standard del
C per visualizzare la stringa strc definita nel modulo CMOD1.C; come
al solito, si raccomanda vivamente di inserire tutte le direttive EXTRN al di
fuori di qualsiasi segmento di programma.
Il prototipo C della funzione printf è:
int printf(char *format, ...);
Questa funzione richiede quindi un numero variabile di argomenti, di cui il primo è una
stringa C denominata stringa di formato; lo scopo della stringa
format è quello di formattare l'output prodotto da printf.
Nel blocco _DATA del modulo ASMMOD1.ASM creiamo a tale proposito la stringa
di formato strFmt (terminata da uno 0), che chiede a printf di
visualizzare una stringa (%s) seguita da un new line (10='\n'); la
variabile strFmt ha linkaggio interno per cui non richiede l'underscore.
All'interno di printstr_asm, la chiamata di printf avviene in stile
Assembly classico; di conseguenza, dobbiamo inserire gli argomenti nello stack a
partire dall'ultimo e alla fine dobbiamo provvedere a ripulire lo stesso stack.
Siccome avevamo inserito due offset da 16 bit ciascuno, la pulizia dello stack
consiste nel sommare 4 byte a SP; si tenga anche presente che printf
si aspetta che strc sia una stringa C con zero finale (il quale, ovviamente,
viene aggiunto dal compilatore C).
Successivamente printstr_asm chiama la procedura printstr_c definita nel
modulo CMOD1.C; all'interno del modulo CMOD1.C, la procedura printstr_c
utilizza printf per visualizzare la stringa strasm definita nel modulo
ASMMOD1.ASM.
Anche in questo caso strasm deve essere una stringa C terminata da uno
0; siccome tale stringa viene definita in un modulo Assembly, il compito di
inserire lo zero finale spetta questa volta al programmatore.
30.6.2 Generazione dell'eseguibile
Analizziamo ora le fasi di assembling e di linking dei precedenti programmi di esempio;
tali fasi sono estremamente semplici grazie al fatto che i compilatori C producono,
per ogni modulo C, il relativo object file. Da queste considerazioni si
intuisce subito che per convertire in formato eseguibile il programma precedente, sono
necessari i seguenti semplici passi:
- compilazione di CMOD1.C per ottenere CMOD1.OBJ
- assemblaggio di ASMMOD1.ASM per ottenere ASMMOD1.OBJ
- linkaggio di CMOD1.OBJ e ASMMOD1.OBJ per ottenere CMOD1.EXE
Naturalmente, la fase di linking coinvolge anche le librerie contenenti le funzioni del
C; i dettagli relativi alle varie fasi da svolgere, possono variare da compilatore a
compilatore.
Molto spesso i compilatori sono dotati di potenti ambienti integrati di sviluppo
(IDE), che permettono di automatizzare tutto questo lavoro; nel caso, ad esempio,
del celebre compilatore Borland C++ 3.1 (che tra l'altro contiene anche la versione
3.x del TASM), dall'interno dell'IDE è possibile configurare tutte le
opzioni come, ad esempio, il modello di memoria, il set di istruzioni della CPU,
l'assembler da utilizzare, etc; una volta effettuate le varie configurazioni, si può
aprire un nuovo progetto che nel nostro caso sarà formato dai due moduli CMOD1.C
e ASMMOD1.ASM.
Se non si dispone di un IDE, si può sempre ricorrere alla linea di comando che
spesso rappresenta la soluzione più semplice ed efficace; in questo caso è necessario
inserire manualmente tutte le opzioni di configurazione richieste dal compilatore,
dall'assemblatore e dal linker.
In genere, digitando dal prompt del DOS il nome del compilatore e premendo
[Invio], si ottiene l'elenco completo di tutte le opzioni di compilazione
disponibili; lo stesso discorso vale per l'assembler e per il linker.
Vediamo un caso pratico che si riferisce al compilatore Borland C++ 3.1 a 16
bit; supponiamo a tale proposito che tutti gli strumenti di sviluppo siano stati installati
nella cartella predefinita:
c:\borlandc\bin
In questo caso, tutte le librerie del C si trovano nella cartella:
c:\borlandc\lib
mentre gli include file si trovano nella cartella:
c:\borlandc\include
Prima di tutto bisogna creare nella cartella BIN i file di configurazione per il
compilatore e per il linker; questi file devono essere in formato
ASCII e si devono chiamare
TURBOC.CFG e TLINK.CFG.
All'interno del file TURBOC.CFG dobbiamo inserire i percorsi per le librerie, per
gli include file e (opzionalmente) per l'assembler che vogliamo utilizzare, più eventuali
parametri da passare al compilatore, al linker e all'assembler; l'aspetto generale del
file TURBOC.CFG è il seguente:
Come si può notare, l'opzione -E permette di specificare l'assembler da utilizzare,
mentre ogni opzione -T permette di passare un parametro di configurazione
all'assembler stesso; questa tecnica non permette di specificare, con l'opzione -E,
un assembler diverso da TASM. Se si intende utilizzare, ad esempio, un compilatore
C della Borland in combinazione con il MASM, bisogna separare la fase
di compilazione dalla fase di assemblaggio; più avanti viene illustrato in pratica il
procedimento da seguire.
L'aspetto generale del file TLINK.CFG è il seguente:
-Lc:\borlandc\lib
Si tenga presente che spesso i due file, TURBOC.CFG e TLINK.CFG, vengono
creati automaticamente durante la fase di installazione; in questo caso il programmatore
deve solo aggiungere le eventuali altre opzioni di cui ha bisogno.
Numerose altre opzioni possono essere passate anche attraverso la linea di comando; ad
esempio, l'opzione -3 abilita il set di istruzioni a 32 bit del compilatore,
mentre l'opzione -f287 abilita l'uso della FPU 80287/80387.
A questo punto siamo pronti per la fase di creazione dell'eseguibile CMOD1.EXE; tale
fase può essere interamente gestita dal compilatore a linea di comando il cui nome è
BCC.EXE.
Per svolgere il proprio lavoro, il compilatore si serve proprio dei file di configurazione
descritti in precedenza; è importantissimo passare a BCC.EXE il parametro relativo
al modello di memoria che intendiamo utilizzare. Tale parametro è formato dall'opzione
-m seguita dalla lettera iniziale del modello di memoria desiderato; se abbiamo
deciso, ad esempio, di utilizzare il modello SMALL, dobbiamo eseguire il comando:
c:\borlandc\bin\bcc -ms -3 cmod1.c asmmod1.asm
Se, invece, vogliamo utilizzare il modello LARGE, dobbiamo eseguire il comando:
c:\borlandc\bin\bcc -ml -3 cmod1.c asmmod1.asm
Naturalmente, la struttura del modulo ASMMOD1.ASM deve essere perfettamente
compatibile con il modello di memoria selezionato per il modulo C; a questo punto,
premendo il tasto [Invio], il controllo passa a BCC.EXE che:
- compila CMOD1.C producendo CMOD1.OBJ
- assembla ASMMOD1.ASM producendo ASMMOD1.OBJ
- linka CMOD1.OBJ + ASMMOD1.OBJ + le librerie del C, producendo infine
CMOD1.EXE
Se vogliamo generare anche un dettagliatissimo map file, possiamo passare a
BCC.EXE il parametro -M; in questo caso viene creato anche un file chiamato
CMOD1.MAP.
Se vogliamo generare il listing file di ASMMOD1.ASM, dobbiamo passare a
BCC.EXE il parametro -Tl; in questo caso viene creato anche un file chiamato
ASMMOD1.LST.
Un parametro di straordinaria importanza è sicuramente -S che richiede a
BCC.EXE la generazione del file CMOD1.ASM; tale file è la traduzione in
Assembly di CMOD1.C, effettuata dal compilatore C!
È estremamente istruttivo esaminare il contenuto di CMOD1.ASM in quanto si ha la
possibilità di verificare tutto il lavoro compiuto a basso livello dal compilatore C.
L'esame di questo file permette anche di conoscere tutti i dettagli sul tipo di segmentazione
utilizzato dal compilatore; in presenza del parametro -S, non viene generato alcun
eseguibile CMOD1.EXE.
Nel caso in cui si intenda utilizzare un assembler diverso da TASM.EXE (ad esempio,
ML.EXE), e/o un compilatore diverso da BCC.EXE, si deve
procedere alla separazione delle varie fasi; avendo a disposizione, ad esempio,
ML.EXE e BCC.EXE, dobbiamo procedere in questo modo:
1) generazione del file ASMMOD1.OBJ con il comando:
ml /c /Cp asmmod1.asm
2) generazione dell'eseguibile CMOD1.EXE con il comando (caso del modello
SMALL):
bcc -ms -3 -M cmod1.c asmmod1.obj
In assenza del parametro /coff il MASM genera un object file in
formato OMF (Object Module Format) compatibile con gli object file
generati dagli strumenti di sviluppo della Borland.
30.6.3 Riscrittura di ASMMOD1.ASM con l'uso delle caratteristiche
avanzate di MASM
Dalle considerazioni esposte in precedenza emerge chiaramente il fatto che,
nell'interfacciamento dell'Assembly con i linguaggi di alto livello, il
programmatore è chiamato a svolgere un lavoro veramente impegnativo; infatti, oltre a
dover scrivere il codice Assembly, il programmatore deve anche gestire una
numerosa serie di dettagli legati ai protocolli di comunicazione tra i vari moduli.
In generale, tale modo di procedere è vivamente sconsigliabile in quanto comporta
una elevata probabilità di errore; ciò è vero soprattutto quando si devono gestire
moduli Assembly ben più complessi di quello mostrato in Figura 30.5.
Bisogna anche ricordare che il ricorso alla segmentazione "vecchio stile" in
combinazione con la direttiva GROUP, scarica sul programmatore il compito di
gestire il tristemente famoso bug dell'operatore OFFSET di MASM; come
è stato spiegato nel precedente capitolo, tale bug scompare quando si utilizzano le
direttive avanzate di MASM per la segmentazione standard!
Utilizzando le caratteristiche avanzate di MASM, il modulo ASMMOD1.ASM
assume l'aspetto mostrato in Figura 30.6 (modello SMALL).
In questa nuova versione del modulo ASMMOD1.ASM notiamo subito la scomparsa dei
dettagli relativi alle caratteristiche dei segmenti di programma, del gruppo DGROUP,
delle direttive ASSUME e degli underscore; in presenza della direttiva
.MODEL, che specifica il modello di memoria e il linguaggio di riferimento, tutti
questi aspetti vengono gestiti direttamente dall'assembler!
La presenza della direttiva .MODEL permette di delegare all'assembler anche i
dettagli relativi al tipo NEAR o FAR delle procedure; in relazione agli
identificatori pubblici, le necessarie direttive PUBLIC possono essere inserite
all'inizio del programma, oppure all'interno dei segmenti contenenti le definizioni degli
identificatori stessi.
In relazione, invece, agli identificatori esterni, come è stato sottolineato in precedenza,
si raccomanda vivamente di inserire le direttive EXTRN all'inizio del programma, al
di fuori di qualsiasi segmento; in questo modo lasciamo al linker il compito di individuare
la coppia Seg:Offset relativa ad ogni identificatore.
Se al posto di PROTO si utilizza la direttiva EXTRN per le procedure esterne,
bisogna indicare il tipo PROC al posto di NEAR o FAR; si può scrivere,
ad esempio:
EXTRN printf: PROC, printstr_c: PROC
In ogni caso, si raccomanda vivamente l'utilizzo della direttiva PROTO; in questo
modo possiamo anche specificare il prototipo di funzioni C che richiedono un numero
variabile di argomenti.
Un esempio pratico è rappresentato dalla funzione di libreria printf; il prototipo di
printf in versione MASM è:
printf PROTO :WORD, :VARARG
30.6.4 Ulteriori dettagli sul programma CMOD1.EXE
L'esempio appena presentato, pur essendo molto semplice, ci permette di analizzare alcuni
aspetti molto delicati; in particolare, come è stato detto in precedenza, è fondamentale
che ci sia una perfetta corrispondenza tra definizioni e dichiarazioni relative agli
identificatori con linkaggio esterno presenti nei vari moduli C e Assembly.
Possiamo notare, ad esempio, che nel modulo CMOD1.C la variabile strc è stata
definita come vettore di char (cioè, di BYTE); di conseguenza, la direttiva
EXTRN presente nel modulo ASMMOD1.ASM deve dichiarare strc come
variabile di tipo BYTE, cioè come variabile che permette di accedere a locazioni di
memoria da 8 bit ciascuna.
Se nel modulo CMOD1.C proviamo a modificare la definizione di strc scrivendo:
char *strc = "Stringa definita nel modulo CMOD1.C";
possiamo subito constatare che il programma non funziona più (o funziona in modo anomalo);
ciò accade in quanto, nel modulo ASMMOD1.ASM, la variabile esterna strc viene
gestita come dato di tipo BYTE, mentre nel modulo CMOD1.C la variabile
strc viene definita come puntatore a char e cioè, come indirizzo di una
locazione di memoria da 8 bit!
Nel modello di memoria SMALL un indirizzo è formato dalla sola componente Offset
a 16 bit; di conseguenza, nel modulo ASMMOD1.ASM dobbiamo ridichiarare
strc come:
EXTRN strc: WORD
Non solo, ma tenendo anche conto del fatto che questa volta strc rappresenta
l'offset iniziale di un vettore di char, all'interno della procedura
printstr_asm dobbiamo sostituire l'istruzione:
invoke printf, offset strFmt, offset strc
con l'istruzione:
invoke printf, offset strFmt, strc
Lo stesso discorso vale, naturalmente, per la variabile strasm; se vogliamo che
questa variabile sia un puntatore ad un vettore di char, nel blocco .DATA
del modulo ASMMOD1.ASM possiamo scrivere:
A questo punto, nel modulo CMOD1.C dobbiamo ridichiarare strasm come:
extern char *strasm;
All'interno di printstr_c, la riga:
printf("%s\n", strasm);
non necessita di alcuna modifica in quanto in C, come sappiamo, il nome di un
vettore viene trattato come indirizzo iniziale del vettore stesso; di conseguenza,
anche se strasm è un vettore di char, la funzione printf riceve in
ogni caso l'indirizzo iniziale della stringa.
Un altro aspetto interessante riguarda l'eventualità di dover scavalcare il modello di
memoria SMALL per poter utilizzare procedure di tipo FAR; anche in questo
caso bisogna ricordarsi che le dichiarazioni e le definizioni nei vari moduli devono
coincidere!
Supponiamo, ad esempio, di voler ridefinire printstr_asm come procedura di tipo
FAR; a tale proposito, nel modulo ASMMOD1.ASM l'intestazione di
printstr_asm diventa:
printstr_asm proc far
Di conseguenza, nel modulo CMOD1.C dobbiamo ridichiarare printstr_asm
come:
extern void far printstr_asm(void);
Se non prendiamo questa precauzione, il programma va sicuramente in crash e in certi casi
può anche andare in crash l'intero SO!
30.6.5 Programma CMOD1.EXE con modello LARGE
La struttura del precedente programma può variare a seconda del modello di memoria usato;
in particolare, il programmatore deve prestare particolare attenzione agli indirizzamenti
relativi ai dati del programma.
Nei modelli di memoria che comportano la presenza di un unico segmento di dati, tutti gli
indirizzamenti predefiniti per i dati sono di tipo NEAR; in un caso del genere,
infatti, per tutta la fase di esecuzione del programma abbiamo DS=DGROUP con
DGROUP che raggruppa gli eventuali segmenti di dati _DATA, _BSS,
CONST e STACK.
Nei modelli di memoria che comportano la presenza di un unico segmento di codice, tutti gli
indirizzamenti predefiniti per le procedure sono di tipo NEAR; in un caso del genere,
infatti, per tutta la fase di esecuzione del programma abbiamo CS=_TEXT.
Nei modelli di memoria che comportano la presenza di due o più segmenti di codice, tutti gli
indirizzamenti predefiniti per le procedure sono di tipo FAR; in un caso del genere,
infatti, il caller può trovarsi in un segmento di programma diverso da quello della procedura
chiamata. In generale, quindi, la chiamata di una procedura rende necessaria anche la
modifica di CS; questa situazione è stata già illustrata nel precedente capitolo e
viene gestita automaticamente dall'assembler grazie all'apposito parametro specificato
nella direttiva .MODEL.
Sicuramente, il caso più interessante e delicato riguarda quei modelli di memoria che prevedono
la presenza di due o più segmenti di dati; come sappiamo, il compilatore inizializza DS
ponendo, in ogni caso, DS=DGROUP.
Si può presentare allora il caso della chiamata di una procedura che deve accedere con
DS ad un segmento dati; se tale segmento dati appartiene a DGROUP non ci
sono problemi in quanto non c'è bisogno di modificare DS. I problemi, invece, si
presentano quando la procedura deve accedere con DS ad un blocco FAR_DATA
o FAR_BSS; in tal caso è fondamentale che la procedura restituisca il controllo
al caller solo dopo aver ripristinato DS.
Supponiamo, ad esempio, di riscrivere il modulo ASMMOD1.ASM per il modello di memoria
LARGE e supponiamo anche di avere in tale modulo il seguente blocco dati:
Se la procedura printstr_asm deve accedere per nome a farDword, si presenta il
problema della modifica di DS che referenzia DGROUP; per evitare tale modifica,
all'interno di printstr_asm potremmo utilizzare ES scrivendo:
In questo modo possiamo restituire il controllo alla funzione main del modulo
CMOD1.C con DS che continua a referenziare DGROUP; se, però, vogliamo
accedere ad una grande quantità di dati presenti in .FARDATA e non vogliamo usare
i segment override (che tra l'altro aumentano le dimensioni del codice macchina), possiamo
anche servirci di DS scrivendo:
Anche in questo caso, il registro DS viene restituito inalterato al caller; se non
prendiamo questa precauzione, il crash del programma è assicurato!
In presenza dei modelli di memoria come COMPACT, LARGE e HUGE che
prevedono la presenza di due o più segmenti di dati, bisogna ricordarsi che l'accesso ai
dati stessi potrebbe richiedere in questo caso indirizzi di tipo FAR; vediamo un
esempio pratico che consiste nell'utilizzare il modello LARGE per il programma
presentato in precedenza.
Il modulo CMOD1.C rimane inalterato in quanto tutti i dettagli relativi agli
indirizzamenti vengono gestiti dal compilatore C; in particolare, la funzione
printstr_c diventa automaticamente FAR e, inoltre, la funzione printf,
chiamata da printstr_c, oltre a diventare anch'essa FAR, riceve come argomenti
gli indirizzi Seg:Offset relativi, sia alla stringa di formato, sia alla stringa
strasm da visualizzare.
All'interno del modulo ASMMOD1.ASM, invece, tutti questi aspetti ricadono
sul programmatore il quale può semplificarsi la vita grazie alle caratteristiche avanzate;
la Figura 30.7 illustra le modifiche apportate al modulo ASMMOD1.ASM (il quale contiene
anche il segmento .FARDATA descritto in precedenza).
La presenza del parametro LARGE nella direttiva .MODEL, rende automaticamente
FAR tutte le procedure prive di modificatore del modello di memoria.
Vediamo ora quello che succede all'interno di printstr_asm. La prima chiamata di
printf serve per visualizzare la stringa strc; questa volta, però,
printf per gli argomenti di tipo indirizzo richiede coppie Seg:Offset. Siccome
gli argomenti strFmt e strc sono definiti sicuramente nel blocco DGROUP
referenziato da DS, possiamo scrivere:
invoke printf, offset strFmt, ds, offset strc, ds
È importantissimo ricordare che in base alla convenzione Intel, gli indirizzi
FAR formati da 16+16 bit devono contenere la componente Seg nella
WORD più significativa; siccome il C inserisce i parametri nello stack
da destra verso sinistra, nella chiamata di printf dobbiamo posizionare la
componente Seg alla destra della corrispondente componente Offset. In questo
modo la precedente direttiva INVOKE viene espansa in questo modo:
Come si può notare, per ogni coppia Seg:Offset viene inserita nello stack, prima
la componente Seg e poi la componente Offset; all'interno dello stack quindi,
la componente Offset precede la relativa componente Seg.
La seconda chiamata di printf serve per visualizzare una variabile farDword
di tipo unsigned long, definita nel blocco dati inizializzati .FARDATA;
prima di tutto accediamo a questa variabile per nome e, a tale proposito, utilizziamo
DS per referenziare .FARDATA.
Dopo aver modificato farDword, memorizziamo questa variabile in EBX e
ripristiniamo DS in modo da fargli referenziare di nuovo DGROUP; a questo
punto chiamiamo printf per visualizzare il contenuto di EBX.
Per indicare a printf che EBX contiene un intero senza segno a 32 bit,
dobbiamo utilizzare la stringa di formato strFmt2; questa stringa viene definita in
DGROUP, per cui è fondamentale che printf riceva, attraverso DS, la
giusta componente Seg di strFmt2.
In sostanza, se dopo aver modificato farDword dimentichiamo di ripristinare DS,
la successiva chiamata di printf potrebbe anche mandare in crash il programma; si
tenga presente, inoltre, che al termine di printstr_asm il controllo tornerebbe alla
funzione main con DS che referenzia ancora .FARDATA.
L'esempio di Figura 30.7 dimostra chiaramente per quale motivo, le convenzioni C,
deleghino al caller il compito di ripulire gli argomenti dallo stack; ciò è dovuto al
fatto che, come sappiamo, in C è possibile creare procedure dotate di un numero
variabile di parametri.
In Figura 30.7, ad esempio, notiamo che printf viene chiamata una prima volta con
4 argomenti e una seconda volta con 3 argomenti; ovviamente, solo il caller
conosce la dimensione totale in byte degli argomenti passati alla procedura, per cui è
suo compito gestire manualmente la pulizia dello stack attraverso SP!
La generazione dell'eseguibile con MASM e BCC si ottiene con i comandi:
ml /c /Cp asmmod1c.asm
bcc -ml -3 cmod1.c asmmod1c.obj
(si noti il parametro -ml che richiede a BCC.EXE l'uso del modello di
memoria LARGE).
30.6.6 Funzioni che restituiscono strutture per valore
Passiamo ora ad un secondo esempio che illustra il caso di una funzione C la quale
restituisce una intera struttura per valore; se la struttura occupa 3 byte o più
di 4 byte, viene adottato un procedimento che comporta le fasi seguenti:
- il caller crea un'area di memoria sufficiente a contenere la struttura da
restituire
- il caller chiama la funzione passandole come ultimo argomento l'indirizzo
Seg:Offset dell'area di memoria creata nella fase 1
- la funzione chiamata utilizza tale indirizzo per riempire la struttura
creata dal caller
- la funzione termina restituendo in DX:AX l'indirizzo Seg:Offset
ricevuto dal caller
In sostanza, la funzione chiamata deve riempire una struttura che si trova in memoria
all'indirizzo Seg:Offset; indipendentemente dal modello di memoria utilizzato,
tale indirizzo deve essere sempre di tipo FAR.
Si può notare che la fase 4 appare superflua in quanto il caller conosce già
l'indirizzo Seg:Offset della struttura che esso stesso ha creato; in ogni caso,
si tratta di una convenzione del linguaggio C che il programmatore è tenuto a
seguire.
Se il caller si trova in un modulo C, le fasi 1 e 2 vengono gestite
direttamente dal compilatore; se, invece, il caller si trova in un modulo Assembly,
tutto questo lavoro ricade come al solito sul programmatore.
Per illustrare in pratica questi concetti, vediamo un esempio formato dai due moduli
CMOD2.C e ASMMOD2.ASM; anche in questo esempio sono presenti chiamate
incrociate tra i due moduli.
Il modulo CMOD2.C definisce due strutture dati e chiama il modulo ASMMOD2.ASM
per sommarle tra loro; il modulo ASMMOD2.ASM definisce due strutture dati e chiama
il modulo CMOD2.C per sommarle tra loro.
Analizziamo prima di tutto la struttura del modulo CMOD2.C che svolge, come al
solito, il ruolo di modulo principale del programma; il listato è visibile in Figura 30.8.
Questo modulo dichiara un nuovo tipo di dato Point3d, che rappresenta una struttura
formata da tre membri di tipo int denominati x, y e z; in
totale, ciascun dato di tipo Point3d richiede quindi 6 byte di memoria.
In seguito vengono definite e inizializzate due variabili globali di tipo Point3d,
denominate point1 e point2; come si può notare, queste due variabili hanno
classe di memoria static per cui risultano invisibili all'esterno del modulo
CMOD2.C.
Appena si entra nella funzione principale main, viene creata una nuova variabile
di tipo Point3d chiamata psomma; naturalmente, si tratta di una variabile
locale che verrà quindi creata nello stack. A questo punto main chiama la
funzione esterna sommapoint3d_asm che ha il compito di sommare le due strutture
point1 e point2, restituendo poi il risultato alla funzione main
che provvederà a salvarlo nella struttura psomma; in questo esempio, per somma
di due strutture Point3d si intende la somma dei membri corrispondenti (x
con x, y con y e z con z).
In base a quanto detto in precedenza, quando il compilatore C incontra la chiamata
di sommapoint3d_asm, la espande nelle seguenti istruzioni (modello di memoria
SMALL):
Come si può notare, il C inserisce nello stack i membri delle due strutture seguendo
il solito ordine da destra a sinistra; alla fine il C inserisce "di nascosto" nello
stack anche la coppia Seg:Offset relativa alla struttura psomma che rappresenta
il valore di ritorno di sommapoint3d_asm. Osserviamo che psomma è una
variabile locale, per cui il suo offset all'interno dello stack viene calcolato con
l'istruzione LEA.
Quando sommapoint3d_asm termina, la struttura psomma è stata riempita con la
somma tra point1 e point2 e ciò può essere verificato attraverso la
printf che visualizza proprio i tre membri di psomma; si noti che il caller
effettua la pulizia dello stack sommando ben 16 byte a SP.
Passiamo ora all'analisi del modulo ASMMOD2.ASM; supponendo di utilizzare il modello
di memoria LARGE, il modulo ASMMOD2.ASM assumerà l'aspetto mostrato in
Figura 30.9.
Anche ASMMOD2.ASM dichiara un nuovo tipo di dato Point3d che ha le stesse
caratteristiche di quello dichiarato nel modulo CMOD2.C; è importante ricordare
che in qualsiasi linguaggio di programmazione, le dichiarazioni non occupano memoria in
quanto sono solo dei modelli di dati o di procedure.
In seguito ASMMOD2.ASM definisce e inizializza, nel blocco _DATA, due
variabili point3 e point4 di tipo Point3d; in assenza della direttiva
PUBLIC, queste due variabili risultano invisibili all'esterno del modulo.
All'interno del blocco _BSS viene, invece, definita la variabile non inizializzata
psomma di tipo Point3d; anche in questo caso abbiamo a che fare con una
variabile con linkaggio interno, che non ha niente a che vedere quindi con la variabile
locale psomma definita nel modulo CMOD2.C.
Per capire meglio quello che succede all'interno della procedura sommapoint3d_asm,
è necessario analizzare il significato che viene assunto in Assembly dai nomi dei
membri di una struttura; consideriamo a tale proposito la variabile point3 di tipo
Point3d. Osserviamo subito che:
- il membro x si trova a 0 byte di distanza dall'offset iniziale di
point3
- il membro y si trova a 2 byte di distanza dall'offset iniziale di
point3
- il membro z si trova a 4 byte di distanza dall'offset iniziale di
point3
Quando la CPU incontra una istruzione del tipo:
mov ax, point3.x
trasferisce quindi in AX la WORD che si trova in memoria all'indirizzo che si
ottiene sommando 0 byte all'offset iniziale di point3.
Quando la CPU incontra una istruzione del tipo:
mov ax, point3.y
trasferisce quindi in AX la WORD che si trova in memoria all'indirizzo che si
ottiene sommando 2 byte all'offset iniziale di point3.
Quando la CPU incontra una istruzione del tipo:
mov ax, point3.z
trasferisce quindi in AX la WORD che si trova in memoria all'indirizzo che si
ottiene sommando 4 byte all'offset iniziale di point3.
In sostanza, i membri x, y e z vengono trattati come se fossero
variabili definite all'interno di un blocco dati chiamato point3; da queste
considerazioni segue, inoltre, che l'istruzione:
mov ax, point3.x
equivale a:
mov ax, point3[x]
oppure a:
mov ax, [point3][x]
oppure a:
mov ax, [point3+x]
oppure a:
mov ax, [point3.x]
Naturalmente, al posto di x, y e z si possono utilizzare direttamente
i corrispondenti spiazzamenti 0, 2 e 4; in tal caso è proibito l'uso
dell'operatore . (punto).
Se vogliamo accedere per indirizzo ad una variabile di tipo Point3d, siamo tenuti
a gestire correttamente lo spiazzamento dei vari membri; se BX punta, ad esempio,
all'indirizzo di point3, allora:
mov ax, [bx+0]
carica in AX il membro x di point3.
mov ax, [bx+2]
carica in AX il membro y di point3.
mov ax, [bx+4]
carica in AX il membro z di point3.
Come al solito, sono permesse anche le forme sintattiche esposte in precedenza; possiamo
affermare quindi che, ad esempio:
mov ax, [bx+2]
può essere scritto anche come:
mov ax, [bx][2]
oppure:
mov ax, bx[2]
È proibito, invece, scrivere:
mov ax, [bx.2]
Una volta chiariti questi concetti, torniamo al nostro programma e supponiamo che, nel
modulo CMOD2.C, la funzione main abbia appena chiamato sommapoint3d_asm
passandole, come argomenti, point1 e point2; main passa, inoltre, un
ulteriore argomento "nascosto" che rappresenta l'indirizzo Seg:Offset della struttura
psomma definita nello stesso modulo CMOD2.C. In Assembly è compito del
programmatore tenere conto di tale convenzione; osserviamo, infatti, che la macro
arguments specifica anche il primo argomento nascosto (p_addr) inviato da
sommapoint3d_c!
Il primo compito svolto da sommapoint3d_asm consiste nel chiamare la funzione
sommapoint3d_c, definita in CMOD2.C, per effettuare la somma tra
point3 e point4; tale somma verrà poi memorizzata nella struttura psomma
definita nel modulo ASMMOD2.ASM.
Questa volta è compito del programmatore passare a sommapoint3d_c la coppia
Seg:Offset della "struttura di ritorno" psomma; a tale proposito si può notare
che vengono inserite nello stack anche le componenti Seg (DS=DGROUP) e
Offset di psomma.
La funzione sommapoint3d_c somma direttamente p2 a p1 e copia poi il
contenuto di p1 nella struttura di ritorno psomma (definita in
ASMMOD2.ASM). È necessario ricordare che nel passaggio degli argomenti per valore,
vengono create nello stack delle copie degli argomenti stessi; tali copie non hanno niente
a che vedere con gli originali. Nel nostro caso, il passaggio per valore a
sommapoint3d_c degli argomenti point3 e point4, comporta la creazione
nello stack delle copie p1 e p2 degli argomenti stessi; sommando p2 a
p1, non provochiamo quindi alcuna modifica all'argomento originale point3.
Per dimostrare che sommapoint3d_c ha veramente sommato point3 con point4
restituendo il risultato in psomma, utilizziamo come al solito la printf del
C; come si può notare, la printf si serve della stringa di formato strFmt
per visualizzare i tre membri di psomma.
Il compito successivo svolto da sommapoint3d_asm consiste nel sommare le copie di
point1 e point2 passate per valore da main; prima di tutto carichiamo
in ES:BX la coppia Seg:Offset della struttura di ritorno. L'istruzione
utilizzata è:
les bx, p_addr
(è una buona abitudine preservare sempre il contenuto dei registri di segmento, come
ES, anche se non vengono utilizzati dal compilatore C).
A questo punto effettuiamo le varie somme con istruzioni del tipo:
Un'ultima osservazione riguarda il fatto che sommapoint3d_asm, prima di terminare,
carica in DX:AX l'indirizzo p_addr della struttura di ritorno; ciò è
richiesto dalle convenzioni del linguaggio C.
Come ci dobbiamo comportare se vogliamo creare la struttura psomma all'interno
di sommapoint3d_asm (cioè, nello stack)?
Prima di tutto, subito dopo l'intestazione di sommapoint3d_asm dobbiamo scrivere:
LOCAL psomma :Point3d
La chiamata a sommapoint3d_c diventa:
invoke sommapoint3d_c, addr psomma, ss, point3, point4
Per generare l'eseguibile CMOD2.EXE con BCC dobbiamo prima assemblare con
MASM il modulo ASMMOD2.ASM; a questo punto dobbiamo impartire il comando:
bcc -ml -3 -M cmod2.c asmmod2.obj
30.6.7 Interscambio di variabili di tipo Floating Point
Analizziamo ora un esempio che illustra l'interscambio di numeri in floating point tra
moduli C e moduli Assembly; i numeri in floating point possono essere
gestiti, sia via hardware, sia via software.
Per la gestione via hardware è necessario un coprocessore matematico (FPU) che
ormai si trova incorporato su tutte le CPU 80486 e superiori; tutti i dettagli
sull'uso della FPU vengono esposti in un apposito capitolo della sezione
Assembly Avanzato.
Se non si dispone di una FPU è necessario ricorrere ad apposite procedure
contenenti algoritmi di simulazione che permettono alla normale CPU di gestire
(molto più lentamente) i numeri reali; anche questi aspetti vengono illustrati nella
sezione Assembly Avanzato.
I compilatori C forniscono una libreria matematica standard che sfrutta
l'eventuale presenza di una FPU; in assenza della FPU viene utilizzata
una apposita libreria di emulazione fornita ugualmente dal compilatore.
Per il semplice esempio illustrato più avanti è sufficiente sapere che la FPU
dispone di 8 registri a 80 bit che in MASM vengono denominati:
ST(0), ST(1), ST(2), ST(3), ST(4), ST(5),
ST(6), ST(7); questi 8 registri formano una struttura a stack
di cui ST(0) è la cima (TOS).
Ogni volta che si carica un numero (intero o reale) nella FPU, questo numero
viene convertito in un formato chiamato temporary real a 80 bit e viene
sistemato in ST(0); il vecchio contenuto di ST(0) scala di un posto e si
porta in ST(1), il vecchio contenuto di ST(1) scala di un posto e si porta
in ST(2) e così via, sino al vecchio contenuto di ST(6) che scala di un
posto e si porta in ST(7). Bisogna prestare attenzione al fatto che il vecchio
contenuto di ST(7) viene perso; di conseguenza, il programmatore deve tenere
traccia dei vari inserimenti in modo da evitare la perdita di dati ancora in uso.
Le istruzioni della FPU iniziano tutte con la lettera F che significa
fast (veloce); nel nostro esempio utilizziamo le seguenti istruzioni:
L'istruzione FWAIT (che equivale a WAIT) era necessaria per evitare che le
vecchie CPU tentassero di accedere ad una locazione di memoria nella quale la
FPU doveva ancora salvare il risultato di un calcolo; con le CPU 80486 e
superiori, questa istruzione non è più necessaria e il suo utilizzo viene ignorato.
Le istruzioni FLD, FMUL e FSTP presuppongono che l'operando op
sia un numero reale in formato IEEE.
Il programma di esempio è formato dai due moduli CMOD3.C e ASMMOD3.ASM;
analizziamo prima di tutto il modulo CMOD3.C che ricopre il ruolo di modulo
principale del programma.
Come si può notare, il modulo CMOD3.C definisce due variabili lato1 e
lato2 di tipo double (numero reale a 64 bit); queste due variabili
sono static per cui risultano invisibili all'esterno.
All'interno della funzione principale main viene chiamata la funzione esterna
diagonale_asm che riceve lato1 e lato2 come argomenti;
diagonale_asm utilizza il teorema di Pitagora per calcolare la diagonale del
rettangolo che ha lato1 e lato2 come lati. Il risultato ottenuto viene
restituito come valore di ritorno; in sostanza, viene effettuato il calcolo:
diagonale = SQRT((lato1 * lato1) + (lato2 * lato2))
(SQRT = radice quadrata)
Per capire come lavora diagonale_asm, dobbiamo analizzare il modulo
ASMMOD3.ASM il cui listato è visibile in Figura 30.11.
Si noti l'uso della direttiva REAL8 per definire dati a 64 bit in formato
IEEE standard; ; ovviamente, dal punto di vista dell'assembler, REAL8 è del
tutto equivalente alla direttiva QWORD o DQ. Per definire strFmt
viene utilizzata la direttiva BYTE.
Anche nel modulo ASMMOD3.ASM vengono definite due variabili lato3 e
lato4 di tipo QWORD (cioè, double a 64 bit); in assenza
della direttiva PUBLIC queste due variabili risultano invisibili all'esterno.
La procedura diagonale_asm chiamata da main, chiama a sua volta la funzione
esterna diagonale_c definita nel modulo CMOD3.C; questa funzione riceve
come argomenti lato3 e lato4 e restituisce la diagonale del corrispondente
rettangolo.
Si può notare che diagonale_c utilizza la funzione sqrt appartenente alla
libreria matematica del C; notiamo, infatti, che all'inizio del modulo
CMOD3.C è presente l'inclusione dell'header standard math.h.
Come è stato detto all'inizio del capitolo, i valori in floating point restituiti da una
funzione si devono trovare nel registro ST(0) della FPU; i registri della
FPU sono a 80 bit, per cui possono gestire float a 32 bit,
double a 64 bit e long double a 80 bit. Nel nostro caso, con
l'istruzione FSTP estraiamo un double da ST(0) e lo salviamo nella
variabile locale diag a 64 bit creata nello stack da diagonale_asm.
Successivamente, il contenuto di diag viene visualizzato con printf; si noti
che la stringa di formato strFmt prevede la visualizzazione di un float con almeno
15 cifre dopo la virgola in modo da dare la possibilità di analizzare la precisione
del risultato (il risultato stesso può essere verificato con una calcolatrice scientifica).
Il compito finale svolto da diagonale_asm consiste nel ripetere l'identico calcolo
in relazione ai due parametri d1 e d2 ricevuti da main; osserviamo
che alla fine del calcolo, il risultato finale è a disposizione di main nel
registro ST(0) della FPU.
Come ci si deve comportare quando si ha la necessità di usare direttamente l'istruzione
PUSH per inserire nello stack un dato di tipo QWORD?
L'aspetto fondamentale consiste nel fatto che il programmatore deve rispettare la
convenzione little endian; nel caso, ad esempio, di un dato come lato3, con
il set di istruzioni a 16 bit dobbiamo scrivere:
Se abbiamo a disposizione il set di istruzioni a 32 bit possiamo scrivere:
Per generare l'eseguibile CMOD3.EXE con BCC dobbiamo prima assemblare con
MASM il modulo ASMMOD3.ASM; a questo punto dobbiamo impartire il comando:
bcc -ms -3 -f287 -M cmod3.c asmmod3.obj
Si noti l'opzione -f287 che richiede al compilatore C l'uso esplicito della
FPU.
30.6.8 Allineamento dei dati al BYTE e alla WORD
Molti compilatori C a 16 bit permettono al programmatore di selezionare
l'allineamento dei dati al BYTE o alla WORD; chiaramente, lo scopo di
tale allineamento è quello di ottimizzare l'accesso ai dati da parte delle CPU
con Data Bus formato da 16 o più linee.
Le modalità per la richiesta del tipo di allineamento variano da compilatore a compilatore;
nel caso, ad esempio, del Borland C++ 3.1 è necessario selezionare il menu:
Options - Compiler - Code generation - Word alignment
Alternativamente è possibile passare l'opzione -a dalla linea di comando; per gli
altri compilatori si possono consultare i relativi manuali (o l'help in linea).
Consideriamo, ad esempio, il seguente blocco dati di un programma C:
Se è attivo l'allineamento al BYTE, supponendo che la variabile i1 a
16 bit venga disposta all'offset 0000h del blocco dati, allora la
variabile c1 a 8 bit viene disposta all'offset 0002h, mentre
la variabile i2 a 16 bit viene disposta all'offset 0003h; in
questo caso i2 parte da un offset dispari e le CPU a 16 bit
hanno bisogno di due accessi in memoria per leggere o scrivere nella relativa
locazione di memoria a 16 bit.
Se, invece, è attivo l'allineamento alla WORD, supponendo che la variabile
i1 a 16 bit venga disposta all'offset 0000h del blocco dati,
allora la variabile c1 a 8 bit viene disposta all'offset 0002h,
mentre la variabile i2 a 16 bit viene disposta all'offset 0004h;
in questo caso i2 parte da un offset pari e le CPU a 16 bit hanno
bisogno di un solo accesso in memoria per leggere o scrivere nella relativa locazione
di memoria a 16 bit.
Nei moduli C tutti questi aspetti vengono gestiti direttamente dal compilatore
(abilitando le apposite opzioni); nei moduli Assembly è compito del programmatore
tenere conto dell'attributo di allineamento che è stato selezionato per i dati
EXTRN definiti in un modulo C.
In generale, è vivamente sconsigliabile scrivere programmi Assembly che cercano di
dedurre in modo empirico l'offset di una variabile; utilizzando, invece, il nome simbolico
per le variabili statiche e l'istruzione LEA per le variabili dinamiche, si ottiene
questa informazione in modo assolutamente sicuro ed affidabile.
30.7 Argomenti dalla linea di comando
Un programma scritto in ANSI C deve contenere una funzione chiamata main che
rappresenta il punto in cui il programmatore riceve il controllo; il prototipo standard
della funzione main è:
int main(int argc, char *argv[]);
Il parametro argc è un intero che rappresenta il numero di eventuali argomenti
passati al programma dalla linea di comando; il parametro argv rappresenta un
vettore di puntatori a stringhe, dove ogni stringa è uno degli eventuali argomenti
passati al programma dalla linea di comando.
Per convenzione, argv[0] deve puntare al nome del programma (compreso il percorso
completo); inoltre, argv[argc] deve puntare a NULL.
Possiamo verificare tutti questi aspetti attraverso il seguente esempio pratico:
Per verificare l'output prodotto dal programma ARGVECT.EXE si può eseguire
il comando:
argvect arg1 arg2 arg3 arg4
Il fatto che main riceva una lista di argomenti, ci fa capire chiaramente che
questo non è il vero entry point del programma; il vero entry point,
infatti, si trova in un apposito modulo che in fase di generazione dell'eseguibile
verrà collegato dal linker al nostro programma.
Nel caso, ad esempio, del Borland C++ 3.1, questo modulo viene chiamato
C0.ASM e si trova nella cartella:
c:\borlandc\lib\startup
Il compito di questo modulo Assembly è quello di effettuare una serie di
inizializzazioni che comprendono, ad esempio, la creazione dello stack, la raccolta
degli eventuali argomenti passati dalla linea di comando, etc; una volta terminate
tutte queste fasi, il modulo C0.ASM chiama una funzione esterna che deve
chiamarsi _main.
A questa funzione vengono passati proprio gli argomenti argc e argv
descritti in precedenza; per dimostrarlo, riscriviamo l'esempio ARGVECT.C
in versione Assembly.
Per ottenere l'eseguibile ARGVECT.EXE dobbiamo prima assemblare ARGVECT.ASM
con il comando:
ml /c /Cp argvect.asm
e poi chiamare BCC.EXE con il comando:
bcc -ms -3 -M argvect.obj
Come si può notare, è necessario inserire una direttiva EXTRN relativa alla
funzione __setargv__ che si occupa dell'inizializzazione di argc e
argv; trattandosi di un identificatore FAR, questa direttiva va
inserita al di fuori di qualsiasi segmento di programma.
Questi aspetti sono relativi esclusivamente al compilatore Borland C++ 3.1;
altri compilatori possono anche richiedere un procedimento differente.
Con il modello di memoria SMALL, all'interno di main troviamo argc
a BP+4 e argv a BP+6; il loop arg_loop esegue lo stesso
lavoro mostrato nel precedente esempio ARGVECT.C.
Osserviamo che, per definizione, argv è l'indirizzo iniziale di un vettore di
indirizzi; in pratica, ogni elemento di argv è l'indirizzo di una stringa.
Caricando argv in SI possiamo affermare che:
- [SI+0] è l'indirizzo della prima stringa
- [SI+2] è l'indirizzo della seconda stringa
- [SI+4] è l'indirizzo della terza stringa
e così via.
È importante notare come la chiamata di printf non danneggia il contenuto di
SI; ricordiamo, infatti, che tutte le funzioni del C preservano il
contenuto di SI e DI (in ogni caso, sarebbe meglio non fidarsi).
Per maggiori dettagli sui parametri passati ad un programma dalla linea di comando,
si può consultare la Figura 13.11 del Capitolo 13; può essere anche interessante
rivedere l'esempio presentato nella sezione 15.7.6 del Capitolo 15.
30.8 Assembly inline
I compilatori C più evoluti come il Borland C, il Microsoft C,
il Watcom C, etc, supportano una caratteristica molto potente che prende il nome
di Assembly inline; tale caratteristica consiste nella possibilità di inserire
codice Assembly direttamente nel codice sorgente C!
I compilatori C che supportano l'Assembly inline, sono dotati di appositi
assemblatori incorporati; in fase di compilazione del sorgente C, le istruzioni
scritte in Assembly inline vengono tradotte direttamente in codice macchina senza
effettuare su di esse alcuna ottimizzazione.
La sintassi da utilizzare per la scrittura delle istruzioni Assembly inline varia
da compilatore a compilatore; a titolo di esempio, analizziamo il caso del compilatore
Borland C++ 3.1. Questo compilatore supporta praticamente l'intero set di istruzioni
a 16 bit delle CPU 80286 e l'intero set di istruzioni della FPU 80287.
All'interno di un modulo C, una istruzione Assembly inline deve essere
preceduta dalla parola chiave asm; nel caso di un blocco formato da due o più
istruzioni, si può inserire il blocco stesso tra la coppia { }.
Analizziamo subito un primo esempio che si riferisce al caso già trattato in precedenza,
relativo alla somma tra due strutture dati di tipo Point3d; il listato di questo
programma viene illustrato in Figura 30.13.
Da questo semplice esempio si intuisce che un programmatore Assembly esperto può
fare praticamente di tutto con l'Assembly inline. In Figura 30.14 vediamo un altro
esempio che si riferisce al caso, già analizzato, relativo al calcolo della diagonale di
un rettangolo.
Come si può notare, questa volta il calcolo della diagonale viene effettuato con la
FPU senza la necessità di incorporare la libreria matematica del C; si
può anche osservare che, all'interno di un blocco asm, gli eventuali commenti
devono essere inseriti secondo la sintassi del C.
Non è permesso l'uso di etichette all'interno di un blocco asm; per aggirare
questo problema si può scrivere, ad esempio:
È importante notare il fatto che la funzione init_vector utilizza il registro
SI; di conseguenza, il contenuto originale di SI deve essere preservato.
Naturalmente, non si può pretendere che l'Assembly inline abbia le stesse
potenzialità dell'Assembly vero e proprio; non è possibile, ad esempio, utilizzare
le macro e non sono supportate diverse direttive.
Per maggiori dettagli sull'Assembly inline si consiglia di consultare i manuali
del proprio compilatore C; nel caso del Borland C++ 3.1 si possono reperire
tutte le necessarie informazioni attraverso l'help in linea.