Assembly Base con MASM
Capitolo 25: I sottoprogrammi
Moltissimi linguaggi di programmazione, compreso l'Assembly, offrono il pieno
supporto per i cosiddetti sottoprogrammi; nei capitoli precedenti abbiamo
visto che il sottoprogramma assume l'aspetto di un vero e proprio "mini-programma"
che può essere reso distinto ed indipendente dal programma principale (di cui è parte
integrante).
In questo senso il sottoprogramma può essere visto come una sorta di modulo
che esegue un compito specifico; ogni volta che il nostro programma deve eseguire quel
particolare compito, non deve fare altro che "chiamare" il relativo sottoprogramma.
L'uso dei sottoprogrammi presenta diversi aspetti di notevole interesse; analizziamo
quelli più importanti.
Lo stile di programmazione che consiste nel suddividere un programma in tanti moduli,
cioè in tanti sottoprogrammi, prende il nome di programmazione modulare; in
un caso del genere l'esecuzione di un programma consiste in una serie di chiamate ai
vari sottoprogrammi che lo compongono.
- Ricerca semplificata degli errori
In un programma modulare la ricerca degli eventuali errori viene notevolmente
semplificata; infatti, un programma modulare che si comporta in modo non corretto,
permette al programmatore di individuare facilmente il modulo responsabile
dell'anomalia. Nel caso di un programma tradizionale, il programmatore è costretto,
invece, a riesaminare tutto il codice sorgente.
Nella programmazione modulare, capita spesso di scrivere moduli particolarmente
interessanti ed efficienti che possono tornare utili anche in altri programmi; in un
caso del genere è facilissimo "estrarre" da un programma il modulo che ci interessa,
per inserirlo poi in un altro programma.
Una diretta conseguenza di questo aspetto è rappresentata dalla possibilità di
raccogliere, in un unico file, una serie di moduli aventi caratteristiche comuni;
questi particolari file prendono il nome di librerie di sottoprogrammi.
Pensiamo, ad esempio, ad una libreria di moduli per la gestione dei file su disco,
una libreria di moduli per la gestione della scheda video, etc; queste librerie, una
volta realizzate, possono essere facilmente "linkate" ai programmi che ne hanno
bisogno.
Un programma modulare può essere facilmente modificato o aggiornato senza la necessità
di riscriverlo da zero; infatti, tutto il lavoro di modifica o di aggiornamento si
concentra sui singoli moduli e non sull'intero programma. Esiste quindi anche la
possibilità di modificare o aggiornare un solo modulo che non soddisfa le nostre
esigenze.
Nel caso dei programmi tradizionali, le modifiche sono estremamente difficili se non
impossibili in quanto c'è l'elevato rischio di compromettere in modo irrimediabile
l'intera struttura del programma. A tale proposito, è emblematico il caso di molti
vecchi e famosi programmi scritti secondo il classico stile (si fa per dire) sequenziale
del BASIC; al momento di aggiornare tali programmi per adattarli al nuovo
hardware disponibile, ci si è resi conto che sarebbe stato molto più conveniente (in
termini di tempo e di soldi) riscriverli da zero.
25.1 Analogie tra sottoprogrammi e funzioni matematiche
Nel linguaggio C i sottoprogrammi vengono chiamati funzioni; il
Pascal mette a disposizione due tipi di sottoprogrammi attraverso le direttive
function e procedure. Anche in FORTRAN vengono supportati due
tipi di sottoprogrammi attraverso le direttive FUNCTION e SUBROUTINE;
le subroutines sono note anche ai programmatori BASIC.
In Assembly i sottoprogrammi vengono chiamati procedure; i linguaggi di
alto livello sfruttano proprio le procedure dell'Assembly per implementare i
sottoprogrammi. Da questo momento in poi utilizzeremo sempre il termine procedura
per indicare un generico sottoprogramma Assembly.
L'utilizzo del termine funzione per indicare un sottoprogramma non è certo
casuale in quanto si possono notare delle evidenti analogie tra la struttura di un
sottoprogramma e la struttura di una funzione matematica; così come accade, in
generale, per un sottoprogramma, anche una funzione matematica è costituita da un
nome, una lista di parametri, un corpo e un valore di
ritorno.
Il nome ci permette di identificare la funzione in modo univoco; la lista di
parametri rappresenta l'insieme delle informazioni di cui la funzione ha bisogno
per svolgere il proprio lavoro. Il corpo della funzione contiene l'algoritmo che
elabora i parametri in ingresso; il valore di ritorno rappresenta il risultato
delle elaborazioni compiute dalla funzione sui parametri in ingresso.
Supponiamo, ad esempio, di voler scrivere una funzione matematica che calcola l'area di
un triangolo; una funzione di questo genere ha bisogno quindi di ricevere in input la
base e l'altezza del triangolo. Il corpo della funzione elabora questi
parametri e restituisce in output un valore di ritorno che rappresenta l'area del
triangolo; indicando con x la base del triangolo, con y l'altezza, con
z il valore di ritorno e con AreaTriangolo il nome della funzione, possiamo
scrivere in modo simbolico:
z = AreaTriangolo(x, y)
Questa simbologia indica il fatto che z è funzione di x e di y; in
sostanza, il valore di z dipende dai valori di x e di y e per questo
motivo si dice che x e y sono variabili indipendenti, mentre z
è una variabile dipendente.
La precedente rappresentazione simbolica di AreaTriangolo fornisce una descrizione
"esterna" delle caratteristiche generali della funzione; nei linguaggi di programmazione
questa descrizione è una dichiarazione che prende il nome di prototipo di funzione.
Naturalmente, oltre alla dichiarazione della funzione, abbiamo bisogno anche della
definizione della funzione, cioè della creazione materiale del corpo della funzione; nel
caso di AreaTriangolo, il corpo della funzione è rappresentato, ovviamente, dal
noto algoritmo per il calcolo dell'area di un triangolo (base per altezza diviso due):
z = (x * y) / 2
Una volta che questo algoritmo è stato scritto, collaudato e verificato, possiamo anche
scordarcelo; l'importante è conoscere il prototipo della funzione che ci indica il
procedimento da seguire per "chiamare" la funzione stessa e per ottenere il valore di
ritorno. Questo procedimento rappresenta la cosiddetta interfaccia della funzione;
un programma che ha la necessità di chiamare una determinata funzione, ne deve conoscere
l'interfaccia.
Tornando alla nostra funzione AreaTriangolo, supponiamo di voler calcolare
l'area di un triangolo avente base varBase e altezza varAltezza; se
vogliamo salvare il risultato in una variabile chiamata varArea, possiamo
scrivere:
varArea = AreaTriangolo(varBase, varAltezza)
Questa fase rappresenta la chiamata della funzione; in questo modo
AreaTriangolo riceve in input varBase e varAltezza e produce in
output un risultato che noi memorizziamo in varArea.
I dati x e y indicati nel prototipo della funzione o nella definizione
della funzione prendono il nome di parametri, mentre i dati varBase e
varAltezza passati nella chiamata della funzione prendono il nome di
argomenti; su alcuni libri i parametri vengono chiamati parametri formali
o argomenti formali, mentre gli argomenti vengono chiamati parametri attuali
o argomenti reali.
Non è detto che una funzione debba per forza restituire un valore di ritorno; esistono
funzioni che devono solo eseguire un determinato compito come, ad esempio, ricevere in
input una stringa e stamparla sullo schermo.
Proprio per questo motivo alcuni linguaggi di programmazione supportano due tipi di
sottoprogrammi; abbiamo visto, ad esempio, che il FORTRAN distingue tra
FUNCTION e SUBROUTINE. La FUNCTION si comporta proprio come una
funzione matematica ed ha sempre un valore di ritorno; la SUBROUTINE, invece,
è un semplice sottoprogramma che esegue un determinato compito senza produrre alcun
valore in output.
25.2 La struttura generale di una procedura Assembly
Dopo aver illustrato gli aspetti teorici, passiamo agli aspetti pratici e analizziamo
il procedimento che bisogna seguire per creare una procedura con il linguaggio
Assembly; tutte le analogie appena descritte con le funzioni matematiche, si
rivelano di grande aiuto nella scrittura di procedure eleganti ed efficienti.
Nei precedenti capitoli abbiamo già creato diverse procedure attraverso l'uso di
metodi abbastanza rudimentali; l'inefficienza di tali metodi sta nel fatto che
l'assembler è costretto a chiederci tutti i dettagli relativi, principalmente, al
tipo di chiamata (NEAR o FAR) della procedura e al conseguente tipo di
ritorno (NEAR o FAR) dalla procedura.
Supponiamo, ad esempio, di creare una procedura che, in un segmento di codice,
inizia a partire dall'etichetta startProc1; se vogliamo informare l'assembler
che la procedura è di tipo FAR, dobbiamo servirci degli operatori di distanza
effettuando tutte le chiamate attraverso istruzioni del tipo:
call far ptr startProc1
In una situazione del genere, all'interno della procedura non possiamo utilizzare
l'istruzione RET che verrebbe interpretata dall'assembler come un NEAR
RETURN; per evitare questo problema, al posto di RET dobbiamo utilizzare
il codice macchina CBh che indica esplicitamente un FAR RETURN!
Un simile modo di procedere determina per il programmatore un elevato rischio di
commettere errori; la situazione migliora di poco facendo partire la procedura
startProc1 dall'etichetta personalizzata:
startProc1 LABEL FAR
In un caso del genere, l'unico vantaggio che otteniamo è dato dal fatto che in presenza
di una qualsiasi istruzione:
call startProc1
l'assembler genera automaticamente il codice macchina di una FAR CALL; permane,
però, il solito problema dell'istruzione RET che viene ugualmente interpretata
dall'assembler come un NEAR RETURN!
Tutti questi problemi vengono efficacemente risolti ricorrendo ad una potente direttiva
Assembly chiamata PROC; attraverso tale direttiva, la definizione di una
procedura assume il seguente aspetto:
Come si può notare, ci troviamo in presenza di una definizione estremamente chiara ed
elegante, racchiusa tra le direttive PROC (procedure) e ENDP (end of
procedure); in particolare, risultano specificate in modo evidente tutte le informazioni
come, il nome simbolico, l'etichetta di inizio, il tipo di chiamata, il corpo, il tipo
di ritorno, l'etichetta di fine procedura!
Il parametro distance può assumere il valore NEAR o FAR ed è
facoltativo; come però si può facilmente intuire, è vivamente consigliabile specificare
sempre tale parametro in modo da dare all'assembler tutte le necessarie informazioni sul
tipo di trasferimento del controllo in fase di call e return. In presenza
del parametro NEAR, l'assembler associa una qualsiasi chiamata di ProcName
ad una NEAR CALL e la corrispondente RET ad un NEAR RETURN;
analogamente, in presenza del parametro FAR, l'assembler associa una qualsiasi
chiamata di ProcName ad una FAR CALL e la corrispondente RET ad un
FAR RETURN.
Vediamo subito un esempio pratico; a tale proposito, consideriamo la seguente definizione
presente in un segmento di codice:
Si tratta di una procedura di nome AreaRettangolo che calcola l'area di un
rettangolo avente una determinata base e altezza; la stessa procedura richiede che
la base si trovi in AX e l'altezza in BX (parametri in ingresso).
Come sappiamo, l'istruzione MUL con operando BX moltiplica AX
per lo stesso BX e restituisce il risultato in DX:AX; il contenuto
di DX:AX rappresenta quindi il valore di ritorno della procedura.
La chiamata di AreaRettangolo avviene in modo semplicissimo; supponendo, ad
esempio, di aver definito le due variabili a 16 bit varBase e
varAltezza, possiamo scrivere:
In presenza dell'istruzione:
call AreaRettangolo
l'assembler genera sicuramente il codice macchina di una FAR CALL; inoltre,
nel corpo di AreaRettangolo, l'istruzione finale RET viene associata
dall'assembler ad una procedura FAR e conseguentemente tradotta nel codice
macchina di un FAR RETURN!
L'esempio appena presentato ci permette di chiarire ulteriormente la differenza
concettuale che esiste tra parametri e argomenti della procedura;
nel caso di AreaRettangolo possiamo osservare che:
- i parametri, cioè i dati richiesti in input dalla procedura, sono
rappresentati da due generici valori interi positivi di tipo WORD
(chiamati simbolicamente base e altezza)
- gli argomenti, cioè i dati che effettivamente passiamo alla procedura
durante la sua chiamata, sono rappresentati da varBase e
varAltezza (o da qualsiasi altra coppia di WORD)
Particolare importanza viene assunta dal metodo che si utilizza per passare
materialmente gli argomenti ad una procedura e per la restituzione del valore di
ritorno; nell'esempio relativo ad AreaRettangolo abbiamo deciso di utilizzare
i registri generali AX e BX per il passaggio dei dati in input e la
coppia DX:AX per il valore di ritorno.
Nei linguaggi di alto livello, questi aspetti sono regolati da precise convenzioni;
in Assembly, invece, il programmatore è libero di seguire il metodo che più
si adatta ai propri gusti.
Un'ultima considerazione riguardo all'area di un programma Assembly più
adatta a contenere le varie procedure; tale questione è stata già affrontata in
dettaglio nel paragrafo 20.7.4 del Capitolo 20.
25.3 Procedure e trasferimento del controllo
In relazione alle procedure, l'argomento più importante da affrontare riguarda
sicuramente il meccanismo attraverso il quale avviene il trasferimento del
controllo, "verso una procedura" e "da una procedura"; tutti questi aspetti sono
stati illustrati in modo dettagliato nel Capitolo 20 (Istruzioni per il
trasferimento del controllo). Nel seguito vengono ricapitolati i concetti
più importanti.
25.3.1 Indirizzo in memoria di una procedura
Come accade per le variabili statiche di un programma, anche le procedure vengono
create attraverso una definizione; quando l'assembler incontra la definizione di
una procedura, crea (all'interno del segmento di programma che sta assemblando) lo
spazio necessario per contenere il corpo della procedura stessa.
Ne consegue che, al pari delle variabili statiche, anche il corpo di una procedura
occupa una porzione di memoria a partire da un determinato indirizzo; in fase di
esecuzione del programma, tale indirizzo verrà utilizzato per la chiamata della
procedura ad esso associata!
Supponiamo, ad esempio, di aver definito una procedura ProcTest il cui corpo
inizia dall'offset 00B2h del segmento di codice CODESEGM; possiamo
affermare allora che ProcTest si trova all'indirizzo logico
CODESEGM:00B2h e cioè, all'offset 00B2h di CODESEGM.
Questo è anche l'indirizzo logico relativo al punto in cui viene definito il nome
ProcTest; tenendo conto del fatto che il nome di una procedura è una semplice
etichetta, che delimita l'inizio della procedura stessa senza occupare memoria,
possiamo anche affermare che la coppia CODESEGM:00B2h rappresenta l'indirizzo
logico della prima istruzione eseguibile di ProcTest.
In definitiva, una istruzione del tipo:
mov bx, offset ProcTest
caricherà in BX il valore 00B2h; analogamente, una istruzione del tipo:
mov dx, seg ProcTest
caricherà in DX il paragrafo che il SO assegna a CODESEGM al
momento di disporre il programma in memoria.
La chiamata di una procedura viene effettuata attraverso l'istruzione CALL (call
procedure); la parte del programma (istruzione) che chiama una procedura viene definita
caller (chiamante). La procedura che viene chiamata viene definita callee
(chiamato); il caller può essere, sia il programma principale, sia un'altra procedura
(compresa la stessa procedura da chiamare).
In presenza di un'istruzione CALL la CPU salva nello stack il cosiddetto
return address (indirizzo di ritorno), poi carica in CS:IP l'indirizzo
logico della procedura da chiamare e infine trasferisce il controllo (salta) a
CS:IP (prima istruzione della procedura); come sappiamo, l'indirizzo di ritorno
è quello dell'istruzione immediatamente successiva alla CALL.
Al termine della procedura è necessario restituire il controllo al caller; tale processo
viene attivato da RET che è l'ultima istruzione di una procedura. In presenza di
una istruzione RET la CPU estrae dallo stack l'indirizzo di ritorno, lo
carica in CS:IP e trasferisce il controllo (salta) a CS:IP; in assenza di
errori nella gestione dello stack, in CS:IP viene caricato l'indirizzo
dell'istruzione immediatamente successiva alla CALL.
Una procedura può essere chiamata, sia in modo diretto, attraverso il proprio nome
simbolico, sia in modo indiretto, attraverso il proprio indirizzo; nel primo caso si
parla di direct call (chiamata diretta), mentre nel secondo caso si parla di
indirect call (chiamata indiretta).
Una procedura da chiamare può trovarsi, sia nello stesso segmento di programma del
caller, sia in un segmento di programma diverso da quello del caller; nel primo caso
si parla di intrasegment call (chiamata intrasegmento), mentre nel secondo caso
si parla di intersegment call (chiamata intersegmento).
Complessivamente, quindi, si possono presentare quattro casi differenti; tali quattro
casi vengono illustrati in dettaglio nei paragrafi seguenti.
25.3.2 Chiamata diretta intrasegmento
Facciamo riferimento ad una procedura ProcTest, di tipo NEAR, definita
all'offset 00B2h di un segmento di codice CODESEGM; supponiamo ora che
all'interno dello stesso segmento CODESEGM siano presenti le seguenti
istruzioni:
Alla destra di ogni istruzione è presente un commento contenente l'offset e il codice
macchina dell'istruzione stessa; come possiamo notare, all'offset 0005h del
blocco CODESEGM è presente un'istruzione di chiamata diretta intrasegmento
alla procedura ProcTest.
Sia il caller, sia il callee, si trovano nello stesso segmento di programma; in un
caso del genere la chiamata della procedura viene definita di tipo NEAR (near
= vicino). Una chiamata NEAR consiste quindi in un trasferimento del controllo
ad un indirizzo che si trova all'interno dello stesso segmento di programma del caller;
la CPU ha bisogno di conoscere solo l'offset dell'indirizzo a cui saltare in
quanto il contenuto di CS rimane invariato.
L'operatore NEAR PTR è del tutto superfluo quando la definizione di una
procedura precede la chiamata della procedura stessa; infatti, in tal caso l'assembler
sa già che la procedura da chiamare è di tipo NEAR.
L'operatore NEAR PTR diventa, invece, necessario quando la chiamata di una
procedura precede la definizione della procedura stessa; in un caso del genere, se
non vogliamo ricorrere all'operatore NEAR PTR, sappiamo che è possibile
chiedere all'assembler di effettuare l'assemblaggio in due passaggi in modo da
determinare preventivamente il tipo, NEAR o FAR, di una qualsiasi
procedura (di default, il MASM assembla sempre in almeno due passaggi).
Il codice macchina della chiamata diretta NEAR è rappresentato dall'opcode
E8h; tale opcode è seguito da un valore a 16 bit (nel nostro caso,
00AAh). Come già sappiamo, si tratta di un Rel16 (calcolato
dall'assembler) che la CPU somma all'offset dell'istruzione successiva alla
CALL per ottenere l'offset a cui saltare; nel nostro caso si ha:
00AAh + 0008h = 00B2h
che è proprio l'offset di ProcTest!
Prima di tutto la CPU salva nello stack l'offset 0008h dell'istruzione
successiva alla CALL; subito dopo la stessa CPU carica l'offset
00B2h in IP e salta a CS:IP. Tenendo conto del fatto che in
quel preciso momento si ha CS=CODESEGM, possiamo dire che il salto viene
effettuato verso l'indirizzo CODESEGM:00B2h; la Figura 25.1 mostra quello che
succede esattamente nello stack.
La Figura 25.1a illustra lo stato del segmento di stack prima della chiamata di
ProcTest, mentre la Figura 25.1b illustra lo stato del segmento di stack dopo
l'esecuzione dell'istruzione CALL; come al solito, la memoria viene rappresentata
con gli indirizzi crescenti da destra verso sinistra.
Nella Figura 25.1a vediamo che prima dell'esecuzione dell'istruzione CALL, lo stack
pointer SP contiene l'offset 0200h relativo al segmento di stack del nostro
programma; tale offset rappresenta l'attuale cima dello stack (TOS) e punta al
dato a 16 bit 3BF2h memorizzato in precedenza da qualche istruzione del
nostro programma.
Al momento di eseguire l'istruzione CALL, la CPU sottrae 2 byte ad
SP per far posto ad una nuova WORD da memorizzare nello stack e ottiene
(Figura 25.1b):
SP = 0200h - 2h = 019Eh
All'indirizzo SS:SP (cioè, in SS:019Eh) viene salvato l'offset 0008h
dell'indirizzo di ritorno; subito dopo la CPU carica in IP l'offset
00B2h di ProcTest e salta al nuovo CS:IP. Appena entriamo in
ProcTest, la coppia SS:SP vale quindi SS:019Eh e punta al dato
0008h.
Al termine di ProcTest è presente l'istruzione RET che attiva il procedimento
per la restituzione del controllo al caller; in conseguenza del fatto che la procedura
ProcTest è di tipo NEAR, l'assembler assegna a RET il codice macchina
C3h del NEAR RETURN.
Il ritorno NEAR consiste nell'estrazione dallo stack di un valore a 16
bit da caricare in IP, mentre CS rimane inalterato; nel nostro caso, se non
ci sono stati errori nella gestione dello stack, il registro SP in quel preciso
momento vale 019Eh, per cui la CPU estrae da SS:019Eh proprio il
valore 0008h!
La stessa CPU ripristina lo stack pointer sommando 2 a SP e
ottenendo in tal modo (Figura 25.1b):
SP = 019Eh + 2h = 0200h
Il valore 0008h, precedentemente estratto dallo stack, viene caricato in IP
e l'esecuzione del programma salta a CS:IP (CS:0008h); chiaramente,
CS:0008h è l'indirizzo dell'istruzione successiva alla CALL!
Come si può notare, quando il controllo torna al caller la coppia SS:SP vale
SS:0200h e punta al dato 3BF2h; questa è l'identica situazione che
caratterizzava il segmento di stack prima dell'esecuzione dell'istruzione CALL.
Naturalmente, tutto ciò si verifica solo se la procedura ProcTest non ha
commesso errori nella gestione dello stack; in sostanza, questo significa che
all'interno di ProcTest tutti i byte eventualmente inseriti nello stack devono
essere perfettamente bilanciati da altrettanti byte estratti dallo stack!
Vediamo un esempio pratico che, attraverso la procedura writeHex16 della
libreria EXELIB, ci permette di visualizzare sullo schermo l'indirizzo di
ritorno della procedura ProcTest; naturalmente, questo indirizzo può variare
da computer a computer. Il codice di ProcTest è il seguente:
Supponiamo, come al solito, che prima della chiamata di ProcTest si abbia
SP=0200h e che all'indirizzo SS:SP (cioè, SS:0200h) sia
presente il dato 3BF2h precedentemente inserito nello stack dal programma
principale (ad esempio, con una istruzione PUSH AX); inoltre, facciamo
riferimento ad una chiamata a ProcTest con indirizzo di ritorno 0008h.
In seguito alla chiamata di ProcTest, il registro SP viene decrementato
di 2 e diventa quindi:
SP = 0200h - 2h = 019Eh
All'indirizzo SS:019Eh viene memorizzato il valore 0008h (return
address); possiamo dire quindi che appena entriamo in ProcTest, la coppia
SS:SP punta all'indirizzo di ritorno.
Se SS:SP punta all'indirizzo di ritorno, allora SS:[SP] è l'indirizzo
di ritorno!
Per memorizzare, ad esempio, in AX tale indirizzo di ritorno, potremmo scrivere
allora:
mov ax, [sp]
Sappiamo, però, che è proibito dereferenziare SP con questo tipo di istruzioni;
il deriferimento di SP è una prerogativa riservata solo alla CPU. Al
posto di SP possiamo usare allora il base pointer BP; ricordiamoci che,
in assenza di segment override, i registri SP e BP vengono associati
automaticamente a SS.
Per i motivi che vedremo più avanti, i compilatori C e Pascal esigono
che le procedure chiamate preservino il contenuto originario di BP; seguendo
queste convenzioni (anche se in Assembly non siamo obbligati a farlo), prima
di usare BP salviamo nello stack il suo contenuto originale (che chiamiamo
simbolicamente oldBP).
In seguito a questo ulteriore inserimento nello stack, il registro SP viene
nuovamente decrementato di 2 e diventa quindi:
SP = 019Eh - 2h = 019Ch
All'indirizzo SS:019Ch viene memorizzato il valore oldBP (cioè, il
contenuto originale di BP); a questo punto, lo stack si viene a trovare nella
situazione illustrata dalla Figura 25.2.
In base a quanto si vede in Figura 25.2, possiamo dire che l'istruzione:
mov bp, sp
copia in BP il valore 019Ch; di conseguenza, l'indirizzo di ritorno
(0008h) si viene a trovare all'indirizzo SS:BP+2!
Il valore a 16 bit (0008h) puntato da SS:BP+2 viene caricato in
AX e visualizzato sullo schermo da writeHex16; per verificare l'esattezza
del valore stampato sullo schermo possiamo consultare il listing file del nostro
programma (a tale proposito, ricordiamoci di assegnare a tutti i segmenti di programma
un allineamento di tipo PARA in modo da evitare che il linker possa rilocare
gli offset).
Come conseguenza dell'istruzione PUSH che ha salvato nello stack il valore
originale di BP, prima dell'istruzione RET dobbiamo estrarre due byte
dallo stesso stack scrivendo:
pop bp
In questo modo, lo stack viene bilanciato e il registro BP viene ripristinato
con il suo vecchio contenuto!
Osserviamo che se dimentichiamo quest'ultima istruzione, la CPU incontra RET
ed estrae dallo stack il vecchio contenuto di BP (old BP); tale valore
viene caricato in IP con un conseguente salto a CS:IP. Naturalmente, questo
non è l'indirizzo di ritorno corretto e il nostro programma (generalmente) va in crash.
25.3.3 Chiamata indiretta intrasegmento
Per la chiamata indiretta intrasegmento si possono fare considerazioni assolutamente
analoghe al caso precedente; l'unica differenza è data dal fatto che l'operando di
CALL questa volta non è il nome simbolico di una procedura NEAR, ma è
un valore a 16 bit che rappresenta l'offset della stessa procedura NEAR
da chiamare.
Utilizzando sempre l'esempio della procedura ProcTest definita all'offset
00B2h del blocco CODESEGM, possiamo scrivere allora istruzioni del
tipo:
Come si può notare, in questo caso è stato utilizzato il registro DX per
contenere l'offset di ProcTest; si può anche utilizzare qualsiasi altro registro
generale a 16 bit.
L'istruzione CALL questa volta ha il codice macchina FFh che indica una
chiamata indiretta (la distinzione tra chiamata intrasegmento e intersegmento è codificata
nel secondo opcode); per enfatizzare il fatto che l'operando di CALL è un valore a
16 bit e non un nome simbolico, si può utilizzare l'operatore WORD PTR.
Quando la CPU incontra l'istruzione CALL di questo esempio, legge il
contenuto di DX, lo carica direttamente in IP, salva nello stack
l'indirizzo di ritorno (000Ah) e salta a CS:IP; al termine di
ProcTest, l'istruzione RET attiverà un ritorno di tipo NEAR.
In base a tutte queste considerazioni, possiamo dire che in relazione alla chiamata
indiretta intrasegmento di ProcTest, la gestione dello stack da parte della
CPU è assolutamente identica al caso della chiamata diretta intrasegmento.
In alternativa ai registri generali, per contenere l'offset di ProcTest si può
anche utilizzare una qualsiasi variabile a 16 bit; questo caso è stato illustrato
in dettaglio nel Capitolo 20.
A titolo di curiosità, vediamo come ci si deve comportare quando si vuole utilizzare una
variabile definita in un segmento dati diverso da quello referenziato da DS; a
tale proposito, supponiamo che all'offset 0000h di un blocco dati chiamato
DATASEGM2 sia presente la seguente definizione:
procAddr dw 0
Se vogliamo chiamare indirettamente ProcTest attraverso procAddr possiamo
scrivere, ad esempio:
Come già sappiamo, rinunciando ad usare la direttiva ASSUME per associare
DATASEGM2 a ES, dobbiamo provvedere noi stessi ad inserire i necessari
segment override; nel codice macchina notiamo appunto la presenza del valore 26h
che è proprio il segment override per il registro ES.
Se avessimo utilizzato la direttiva ASSUME per associare DATASEGM2 a
ES, questi segment override sarebbero stati inseriti direttamente dall'assembler;
come sappiamo, quando la CPU incontra il codice macchina 26h, sa che il
successivo offset deve essere calcolato rispetto a ES e non rispetto a DS.
Si presti molta attenzione al significato di una istruzione come:
call word ptr es:procAddr
Questa istruzione indica chiaramente una chiamata indiretta intrasegmento; l'offset
della procedura da chiamare si trova in una locazione di memoria da 16 bit il
cui indirizzo logico è ES:procAddr!
25.3.4 Chiamata diretta intersegmento
La chiamata diretta intersegmento si verifica quando chiamiamo direttamente (per nome)
una procedura definita in un segmento di programma differente da quello in cui si trova
il caller; supponiamo quindi che il caller si trovi nel blocco CODESEGM, mentre
la solita procedura ProcTest (callee) sia definita all'offset 00B2h del
blocco CODESEGM2. Consideriamo allora le seguenti istruzioni presenti nel blocco
CODESEGM:
All'offset 0005h del blocco CODESEGM è presente un'istruzione di chiamata
diretta intersegmento alla procedura ProcTest; infatti, la procedura viene chiamata
per nome e inoltre, il caller si trova in CODESEGM mentre il callee si trova in
CODESEGM2.
In un caso del genere la chiamata della procedura viene definita di tipo FAR
(far = lontano); in generale, una chiamata FAR consiste in un trasferimento del
controllo ad un indirizzo logico formato da una coppia Seg:Offset. Questa
situazione richiede la modifica del contenuto dei due registri CS e IP,
per cui la CPU ha bisogno di conoscere, sia la componente Offset, sia la
componente Seg dell'indirizzo logico a cui saltare; nel caso del nostro esempio,
il controllo passa da CODESEGM:0005h a CODESEGM2:00B2h.
Per enfatizzare il fatto che si tratta di una chiamata FAR, si può utilizzare
l'operatore FAR PTR in modo da informare l'assembler che l'indirizzo seguente
è formato da una coppia Seg:Offset; questo operatore diventa necessario quando
la chiamata di una procedura precede la definizione della procedura stessa.
Il codice macchina della chiamata diretta FAR è 9Ah; questo opcode è
seguito da un valore a 32 bit (nel nostro caso, 000000B2h) che deve
essere interpretato come coppia Seg:Offset. La componente Offset
occupa i 16 bit meno significativi, mentre la componente Seg occupa
i 16 bit più significativi; naturalmente, per la componente Seg
l'assembler usa un valore simbolico 0000h, in quanto il vero valore sarà
noto solo al momento di caricare il programma in memoria.
In virtù del fatto che la FAR call modifica anche CS, prima di effettuare
una tale chiamata la CPU salva nello stack la coppia CODESEGM:000Ah
relativa all'istruzione successiva alla CALL (indirizzo di ritorno); subito dopo
la CPU carica la coppia CODESEGM2:00B2h in CS:IP e salta a
CS:IP.
La Figura 25.3 illustra la situazione che si viene a creare nello stack.
La Figura 25.3a illustra lo stato del segmento di stack prima della chiamata di
ProcTest; la Figura 25.3b illustra lo stato del segmento di stack dopo
l'esecuzione dell'istruzione CALL.
Nella Figura 25.3a vediamo che prima dell'esecuzione dell'istruzione CALL, lo stack
pointer SP contiene l'offset 0200h relativo al segmento di stack del nostro
programma; quest'offset rappresenta l'attuale cima dello stack (TOS) e punta ad un
dato a 16 bit(3BF2h) memorizzato in precedenza dal nostro programma.
Al momento di eseguire l'istruzione CALL, la CPU sottrae 2 byte ad
SP per far posto ad una nuova WORD da memorizzare nello stack e ottiene:
SP = 0200h - 2h = 019Eh
All'indirizzo SS:SP (cioè, in SS:019Eh) viene salvato il paragrafo assegnato
a CODESEGM dal SO (nel nostro esempio supponiamo di avere CODESEGM=0CF1h).
In seguito la CPU sottrae altri 2 byte a SP per far posto ad una nuova
WORD da memorizzare nello stack e ottiene:
SP = 019Eh - 2h = 019Ch
All'indirizzo SS:SP (cioè, in SS:019Ch) viene salvato l'offset 000Ah
dell'indirizzo di ritorno.
Come si può notare, la CPU dispone sempre le coppie Seg:Offset in modo che
la componente Offset preceda la componente Seg; come già sappiamo, questa
convenzione è estremamente importante e deve essere rigorosamente seguita anche dal
programmatore.
Subito dopo aver salvato nello stack l'indirizzo di ritorno, la CPU carica in
CS:IP la coppia CODESEGM2:00B2h relativa a ProcTest e salta al nuovo
CS:IP; appena entriamo in ProcTest, la coppia SS:SP vale quindi
SS:019Ch e punta al dato 000Ah, mentre la coppia SS:SP+2 vale
SS:019Eh e punta al dato 0CF1h.
Al termine di ProcTest è presente l'istruzione RET che attiva il procedimento
per la restituzione del controllo al caller; in conseguenza del fatto che la procedura
ProcTest è di tipo FAR, l'assembler assegna a RET il codice macchina
CBh del ritorno di tipo FAR.
Il ritorno FAR consiste nell'estrazione dallo stack di due valori a 16 bit
da caricare in CS:IP; la prima WORD estratta viene caricata in IP,
mentre la seconda WORD estratta viene caricata in CS. Come al solito, se
non ci sono stati errori nella gestione dello stack, la CPU estrae la coppia
0CF1h:000Ah e ripristina lo stack pointer sommando 4 byte a SP
(ottenendo SP=0200h); la coppia 0CF1h:000Ah viene caricata in CS:IP
e l'esecuzione del programma salta a CS:IP.
Come si può notare, quando il controllo torna al caller, la coppia SS:SP vale
SS:0200h e punta al dato 3BF2h; questa è l'identica situazione che
caratterizzava il segmento di stack prima dell'esecuzione dell'istruzione CALL.
Anche in questo caso quindi, bisogna ribadire il fatto che se la procedura ProcTest
utilizza lo stack, deve farlo in modo corretto, equilibrando perfettamente gli inserimenti
con le estrazioni.
Ripetiamo ora l'esempio pratico visto per la chiamata diretta intrasegmento; scriviamo
quindi una procedura ProcTest che, attraverso la writeHex16 ci permette
di mostrare sullo schermo l'indirizzo di ritorno completo.
Questo listato è del tutto simile a quello dell'esempio visto in precedenza; assumiamo
quindi che prima della chiamata a ProcTest si abbia SP=0200h, con
SS:SP che punta al dato 3BF2h precedentemente inserito nello stack dal
programma principale. Supponiamo, inoltre, che l'indirizzo di ritorno sia
0CF1h:000Ah; sulla base di queste premesse, possiamo dire che dopo la chiamata
a ProcTest e dopo l'istruzione PUSH BP, si viene a creare nello stack la
situazione illustrata in Figura 25.4.
Appare evidente quindi che la componente Offset dell'indirizzo di ritorno si
trova in posizione SS:BP+2; analogamente, la componente Seg dell'indirizzo
di ritorno si trova in posizione SS:BP+4.
25.3.5 Chiamata indiretta intersegmento
La chiamata indiretta intersegmento è assolutamente analoga alla chiamata diretta
intersegmento; l'unica differenza formale è rappresentata dal fatto che l'operando di
CALL non è il nome di una procedura FAR, ma è l'indirizzo completo
Seg:Offset di una procedura FAR.
Supponiamo di voler effettuare una chiamata indiretta intersegmento alla procedura
ProcTest del precedente esempio; a tale proposito, definiamo nel blocco
DATASEGM una variabile a 32 bit chiamata procAddr. Tale variabile
dovrà contenere l'indirizzo completo Seg:Offset di ProcTest; nel rispetto
della convenzione più volte citata in precedenza, la componente Offset deve
occupare i 16 bit meno significativi di procAddr, mentre la componente
Seg deve occupare i 16 bit più significativi di procAddr.
La FAR call indiretta intersegmento a ProcTest può essere allora riscritta
in questo modo:
Le componenti Seg e Offset di ProcTest sono due valori a 16
bit; per copiare questi valori nella variabile procAddr a 32 bit, bisogna
spezzare procAddr in due metà attraverso l'operatore WORD PTR.
La scritta:
word ptr procAddr[0]
rappresenta il contenuto dei due BYTE di procAddr che si trovano nelle
posizioni 0 e 1 (prima WORD).
La scritta:
word ptr procAddr[2]
rappresenta, invece, il contenuto dei due BYTE di procAddr che si trovano
nelle posizioni 2 e 3 (seconda WORD).
Per enfatizzare il fatto che l'operando di CALL è una variabile a 32 bit,
possiamo utilizzare l'operatore DWORD PTR.
In relazione alla situazione che si viene a creare nello stack, possiamo dire che la
chiamata indiretta intersegmento è assolutamente identica alla chiamata diretta
intersegmento.
È importante ricordare che in modalità reale, per la chiamata indiretta intersegmento
non è possibile utilizzare un registro a 32 bit scrivendo, ad esempio:
I registri a 32 bit possono svolgere il ruolo di operandi di CALL solo in
modalità protetta a 32 bit; in tale modalità operativa delle CPU 80386 e
superiori, gli offset sono appunto valori a 32 bit relativi a segmenti di memoria
da 4 Gb!
25.4 Considerazioni generali sulle procedure
All'inizio di questo capitolo sono stati illustrati una serie di benefici legati
all'utilizzo delle procedure nei programmi; abbiamo visto in sostanza che scomponendo
un programma in tanti sottoprogrammi, si perviene ad uno stile di programmazione
modulare che presenta diversi risvolti positivi.
Analizzando, però, le cose appena dette in relazione ai metodi di chiamata di una
procedura, possiamo mettere in evidenza anche un aspetto negativo; come si può
facilmente intuire, questo aspetto negativo è legato al fatto che ricorrere alle
procedure significa inserire nei programmi una serie di istruzioni di salto necessarie
per trasferire il controllo dal caller al callee e viceversa.
Ciascun salto comporta una certa perdita di tempo dovuta alle ragioni esposte in questo
capitolo e nei capitoli precedenti; se il ricorso alle procedure è massiccio, tutte
queste perdite di tempo si sommano tra loro e possono avere gravi conseguenze sulle
prestazioni dei programmi!
Proprio per questo motivo è importante (soprattutto in Assembly) non eccedere
con il ricorso alle procedure; un uso ridotto delle procedure comporta una maggiore
sequenzialità delle istruzioni di un programma con conseguente aumento della velocità
di esecuzione.
Un altro aspetto abbastanza evidente è legato al fatto che una FAR call è
sicuramente più lenta di una NEAR call; osserviamo, infatti, che la FAR
call comporta una maggiore perdita di tempo dovuta al maggior numero di informazioni
da gestire nello stack. La NEAR call comporta l'inserimento nello stack di un
solo offset a 16 bit e il conseguente NEAR return comporta l'estrazione
dallo stack dello stesso offset a 16 bit; la FAR call, invece, comporta
l'inserimento nello stack di una coppia Seg:Offset a 16+16 bit e il
conseguente FAR return comporta l'estrazione dallo stack della stessa coppia
Seg:Offset a 16+16 bit.
I puristi dell'Assembly arrivano addirittura ad evitare del tutto le procedure,
sostituendole eventualmente con le macro; nel precedente capitolo, infatti, abbiamo
visto che le macro vengono espanse nel punto esatto nel quale vengono chiamate,
garantendo in questo modo un'ottima sequenzialità delle istruzioni. In pratica, è come
se il codice completo di una procedura venisse copiato nel punto in cui è presente la
chiamata alla procedura stessa; in questo modo si migliorano le prestazioni del programma
in quanto si evita il salto dal caller al callee e viceversa. Naturalmente, sappiamo che
anche un uso eccessivo delle macro porta a delle conseguenze negative; ogni chiamata di
una macro si traduce, infatti, nell'espansione del corpo della macro stessa con conseguente
aumento delle dimensioni del codice.
Da tutte queste considerazioni si deduce che se vogliamo scrivere programmi compatti e
veloci, dobbiamo trovare un buon equilibrio tra modularità e sequenzialità.
25.5 Passaggio di argomenti alle procedure
Programmare in Assembly significa avere il privilegio di poter accedere in modo
diretto a tutto l'hardware installato nel nostro computer; in questo modo è possibile
sfruttare al massimo le tecniche di programmazione più potenti ed efficienti a patto,
naturalmente, di avere una profonda conoscenza dell'architettura del computer.
L'Assembly, in particolare, ci permette di accedere direttamente ai registri della
CPU e questo è sicuramente un grande vantaggio per il programmatore; come già
sappiamo, infatti, i registri della CPU sono delle vere e proprie mini-memorie
RAM statiche, che grazie all'utilizzo dei flip-flop ad altissime prestazioni,
risultano molto più veloci dell'ordinaria memoria RAM dinamica del computer.
Una diretta conseguenza di questo aspetto è data dal fatto che, nei limiti del possibile,
il programmatore dovrebbe utilizzare sempre operandi di tipo Reg per le istruzioni
della CPU; in questo modo si ottiene un notevole aumento delle prestazioni dei
programmi rispetto al caso in cui si utilizzino operandi di tipo Mem o Imm.
Si potrebbe pensare allora di utilizzare i registri della CPU per implementare un
metodo velocissimo per il passaggio di eventuali argomenti alle procedure; questo è proprio
il metodo impiegato da tutte le procedure delle librerie EXELIB e COMLIB.
Naturalmente, è anche possibile utilizzare i registri della CPU per contenere un
eventuale valore restituito da una procedura; a tale proposito, vediamo un semplice
esempio pratico.
Supponiamo di voler scrivere una procedura chiamata Somma che riceve in input due
numeri interi a 16 bit e produce in output la loro somma; utilizziamo, ad esempio,
CX e DX per contenere i due addendi e AX per contenere la somma.
Siccome si tratta solo di un esempio, trascuriamo il controllo degli errori legati ad
eventuali riporti o overflow; otteniamo allora il seguente codice:
Naturalmente, questa procedura presuppone che il programmatore sappia che i due addendi da
sommare devono essere passati attraverso CX e DX e che la somma finale viene
restituita in AX; come è stato spiegato all'inizio del capitolo, queste informazioni
rappresentano la cosiddetta interfaccia della procedura Somma.
A questo punto, nel blocco codice del nostro programma possiamo scrivere:
La procedura Somma termina restituendo il risultato finale (valore di ritorno) nel
registro AX.
Questo semplicissimo esempio ci permette di evidenziare alcuni aspetti importanti legati
all'utilizzo dei registri per il passaggio degli argomenti alle procedure; la prima cosa
da dire è relativa al fatto che questa tecnica, per poter essere applicata in modo chiaro,
richiede una serie di precise regole. In sostanza, il programmatore deve stabilire quali
registri debbano essere usati per il passaggio degli argomenti e quali registri debbano
essere usati per i valori di ritorno; lo scopo di queste regole è quello di definire
un'interfaccia coerente per la chiamata delle procedure.
Nel caso, ad esempio, delle procedure definite nella libreria EXELIB, abbiamo
visto che vengono utilizzati solamente i tre registri AX/EAX, DI e
DX, ciascuno dei quali svolge un ruolo ben preciso; in particolare, il registro
DI viene sempre utilizzato per passare l'offset di una stringa, mentre il registro
DX passa alla procedura sempre le coordinate (riga, colonna) dello schermo a
partire dalle quali viene stampato l'output. Il registro accumulatore viene, invece,
sempre utilizzato per passare numeri interi a 8, 16 e 32 bit; tutte
le procedure che restituiscono valori interi a 8, 16 e 32 bit
utilizzano ugualmente l'accumulatore.
Queste regole aiutano il programmatore ad utilizzare in modo semplice le procedure di una
libreria; in assenza di regole coerenti, invece, ogni volta che vogliamo chiamare una
procedura appartenente ad una libreria, dobbiamo consultare la relativa documentazione per
sapere quali siano i registri da utilizzare.
Abbiamo visto quindi che l'uso dei registri della CPU per l'interfacciamento con
le procedure, garantisce una elevata velocità di esecuzione; esistono però anche delle
"controindicazioni" abbastanza importanti. Osserviamo innanzi tutto che i registri della
CPU, essendo disponibili in numero molto limitato, devono essere trattati dal
programmatore come risorse estremamente preziose; impegnare i registri per passare gli
argomenti alle procedure significa sottrarli alle altre istruzioni che ne hanno bisogno.
Molto spesso questo problema può arrivare a pregiudicare in modo grave l'efficienza del
programma che stiamo scrivendo; in particolare, questo caso si verifica in presenza di
procedure che richiedono un numero elevato di argomenti e che quindi possono arrivare ad
impegnare quasi tutti i registri della CPU!
In definitiva, l'utilizzo dei registri per l'interfacciamento con le procedure ha il
vantaggio di garantire prestazioni molto elevate; questo vantaggio, però, viene annullato
dal problema appena esposto. Proprio per questo motivo, i moderni linguaggi di
programmazione passano gli argomenti alle procedure attraverso un metodo completamente
diverso che consiste nell'uso dello stack; prima di illustrare tale metodo, però,
dobbiamo affrontare una questione molto importante, legata alle due diverse modalità di
passaggio degli argomenti ad una procedura.
25.5.1 Passaggio degli argomenti per valore e per indirizzo
Gli argomenti possono essere passati ad una procedura attraverso due modalità chiamate:
passaggio per valore e passaggio per indirizzo; analizziamole in dettaglio.
Vediamo un esempio pratico; supponiamo che nel blocco dati del nostro programma sia presente
la seguente definizione di una variabile:
Var1 dw 1500
Supponiamo ora di avere una procedura chiamata Raddoppia che attraverso AX
richiede un valore intero a 16 bit e sempre attraverso AX restituisce tale
valore raddoppiato; se vogliamo passare il contenuto di Var1 a Raddoppia
possiamo scrivere:
All'interno di Raddoppia, il contenuto (1500) di AX viene
raddoppiato con un'istruzione del tipo:
add ax, ax
in modo che la procedura termini restituendo AX=3000.
Osserviamo che la modifica del contenuto di AX non ha alcuna ripercussione sul
contenuto di Var1 che continua a valere 1500; è chiaro, infatti, che il
contenuto di AX non ha niente a che vedere con il contenuto della locazione di
memoria identificata da Var1. In sostanza, attraverso AX la procedura
Raddoppia riceve una copia del valore (1500) di Var1 e si dice
quindi che l'argomento Var1 è stato passato per valore a Raddoppia.
Vediamo un esempio pratico che si riferisce sempre alla variabile Var1 definita
in precedenza; supponiamo che questa volta la procedura Raddoppia, attraverso
BX, richieda l'indirizzo di una variabile contenente un valore intero a 16
bit. Questo valore viene raddoppiato e restituito, come al solito, in AX; la
chiamata a Raddoppia diventa:
La struttura della procedura Raddoppia è la seguente:
L'istruzione:
shl word ptr [bx], 1
fa scorrere di un posto verso sinistra i 16 bit del valore (1500) puntato da
DS:BX; ciò equivale a moltiplicare per due il valore 1500, trasformandolo in
3000. Il valore così ottenuto viene copiato in AX e la procedura termina con
"return value" AX=3000; non ci vuole molto a capire che alla fine si ottiene anche
Var1=3000!
Osserviamo, infatti, che Raddoppia conosce l'indirizzo di Var1 e può quindi
accedere all'indirizzo stesso modificandone il contenuto; questo accade proprio attraverso
l'istruzione SHL.
Può capitare che tutto ciò accada in modo consapevole da parte del programmatore; può anche
capitare, però, che il corpo della procedura Raddoppia sia frutto di una svista da
parte del programmatore che magari intendeva scrivere:
Questa seconda versione di Raddoppia, copia in AX il contenuto (1500)
di [BX]; successivamente viene raddoppiato il contenuto di AX senza che
questa modifica si ripercuota sul contenuto di Var1. Quando Raddoppia
restituisce il controllo al caller si ottiene: AX=3000 e Var1=1500; nelle
due versioni appena presentate la procedura Raddoppia riceve attraverso BX
una copia dell'indirizzo di Var1 e si dice quindi che l'argomento Var1 è
stato passato per indirizzo a Raddoppia.
Il meccanismo del passaggio degli argomenti per indirizzo è molto importante perché ci
permette di affrontare il caso delle procedure che richiedono argomenti di tipo vettore
(comprese le stringhe che, come sappiamo, sono vettori di codici
ASCII); è chiaro che il passaggio di
un vettore per valore non sembra una cosa molto sensata, soprattutto quando abbiamo a
che fare con vettori formati da centinaia di elementi!
In un caso del genere, si può risolvere la situazione passando appunto il vettore per
indirizzo; in sostanza, la procedura riceve l'indirizzo iniziale del vettore e, se
necessario, anche il numero di elementi del vettore stesso. Vediamo un esempio relativo
ad una procedura chiamata ToUpperCase che converte una stringa
ASCII in maiuscolo; prima di
tutto definiamo la stringa da convertire:
Alla costante strLenght viene assegnato un valore che si ottiene sottraendo
l'offset di strLenght dall'offset di strTest; il risultato è 21,
che è proprio la lunghezza in byte della stringa.
A questo punto possiamo scrivere la procedura ToUpperCase che riceve in BX
l'offset di una stringa e in CX la lunghezza in byte della stringa stessa.
La procedura esamina, uno alla volta, tutti gli elementi della stringa; se un elemento
vale meno di 'a' (97) o più di 'z' (122), allora non
rappresenta una lettera minuscola e si passa quindi all'elemento successivo. In caso
contrario, si sottrae 32 al valore dell'elemento convertendolo nel codice
ASCII di una lettera maiuscola;
ricordiamo, infatti, che nel codice
ASCII la distanza costante tra
una lettera minuscola e il suo equivalente maiuscolo è pari a 32. Nel caso, ad
esempio, della lettera a risulta:
'a' - 'A' = 32
e quindi:
'A' = 'a' - 32
La chiamata alla procedura ToUpperCase è molto semplice:
Se vogliamo visualizzare strTest con la procedura writeString, dobbiamo
ricordarci di aggiungere lo zero finale nella definizione della stringa; la procedura
writeString si aspetta, infatti, una stringa C (zero terminated string).
Tornando al caso generale del passaggio degli argomenti per indirizzo, il concetto
fondamentale da sottolineare è che una procedura che conosce l'indirizzo in memoria di
un argomento, può anche modificare involontariamente il contenuto di quell'indirizzo;
il passaggio degli argomenti per indirizzo rappresenta quindi una situazione molto
delicata che richiede una certa attenzione da parte del programmatore.
25.5.2 Convenzione C per il passaggio di argomenti ai sottoprogrammi
Molti linguaggi di programmazione di alto livello, passano gli argomenti ai sottoprogrammi
attraverso un metodo che si basa sull'uso dello stack; in pratica, gli argomenti da
passare al sottoprogramma vengono inseriti, uno ad uno, nello stack e successivamente
avviene il trasferimento del controllo dal caller al callee. Il sottoprogramma chiamato
può leggere i vari argomenti dallo stack secondo il meccanismo già illustrato attraverso
gli esempi associati alle Figure 25.1, 25.2, 25.3 e 25.4; è chiaro, però, che
l'applicazione di un simile meccanismo richiede l'adozione di apposite convenzioni
destinate a regolamentare in modo rigoroso l'accesso agli argomenti presenti nello stack!
Storicamente, le due convenzioni più importanti sono quelle relative al linguaggio
C e al linguaggio Pascal; tali due convenzioni vengono prese come
riferimento anche da altri linguaggi di programmazione.
Cominciamo allora con la convenzione del linguaggio C ricordando che in questo caso i
sottoprogrammi vengono chiamati funzioni.
Per illustrare questa convenzione, facciamo riferimento al seguente programma C:
Il programma inizia con la definizione di due variabili di tipo short int chiamate,
var1 e var2; nei compilatori C il tipo short int rappresenta
un intero con segno a 16 bit che può assumere quindi tutti i valori compresi tra
-32768 e +32767.
Tutte le variabili definite al di fuori di qualsiasi funzione, vengono inserite dal
compilatore C nel segmento dati del programma e vengono chiamate variabili
globali; queste variabili quindi sono visibili e accessibili da qualunque funzione
del programma (la cui definizione è successiva a quella delle variabili stesse).
Successivamente incontriamo il prototipo di una funzione C chiamata func1;
come già sappiamo, il prototipo descrive le caratteristiche esterne di una funzione e cioè,
l'interfaccia attraverso la quale è possibile chiamare la funzione. Nel caso del nostro
esempio, il prototipo di func1 afferma che questa funzione richiede due argomenti di
tipo short int e non restituisce alcun valore di ritorno; in C il tipo
void, riferito ai parametri o al valore di ritorno di una funzione, significa "nulla".
A questo punto arriviamo alla funzione main che rappresenta l'entry point di tutti
i programmi scritti in C standard (ANSI C); all'interno del main è
presente la chiamata della funzione func1 e l'istruzione return che termina
il programma con exit code 0 (in ambiente UNIX un exit code 0 indica
la corretta terminazione di un programma).
Subito dopo il main iniziano le definizioni delle varie funzioni del programma; ai
fini del nostro esempio ci interessa sapere, non come è definita func1, ma come
vengono passati gli argomenti var1 e var2 a tale funzione. Partiamo dal caso
in cui la funzione func1 sia di tipo NEAR e la sua definizione si trovi nello
stesso segmento di codice del main (caller); in questo caso il compilatore C,
incontrando la chiamata di func1, produce il codice macchina corrispondente alle
seguenti istruzioni Assembly:
Come si può notare, prima di tutto vengono inseriti nello stack i due argomenti var1
e var2 a partire dall'ultimo; successivamente avviene la chiamata di func1.
Trattandosi di una chiamata di tipo NEAR, la CPU inserisce nello stack il solo
offset dell'indirizzo di ritorno, carica in IP l'offset di func1 e salta a
CS:IP; nel nostro esempio supponiamo che l'indirizzo di ritorno sia 00A4h.
Quest'offset è relativo chiaramente all'istruzione:
add sp, 4
Il significato (importantissimo) di questa istruzione viene chiarito in seguito.
La Figura 25.5 ci permette di analizzare gli effetti prodotti sullo stack dalla chiamata di
func1.
Come al solito, supponiamo che inizialmente si abbia SP=0200h, con la coppia
SS:SP (cioè, SS:0200h) che punta al dato (3BF2h) inserito in
precedenza dal nostro programma (Figura 25.5a); la fase di chiamata di func1
determina l'inserimento nello stack di una serie di informazioni. Prima di tutto viene
inserito il contenuto di var2; osserviamo che il passaggio dell'argomento
var2 avviene chiaramente per valore. Infatti, quando la CPU incontra
l'istruzione:
push var2
esegue le seguenti operazioni (si suppone che var1 e var2 siano definite
agli offset 000Ah e 000Ch del blocco dati):
- sottrae 2 byte a SP per fare posto nello stack ad una nuova
WORD e ottiene SP=019Eh
- legge il valore 3200d=0C80h contenuto all'indirizzo di memoria
DS:000Ch di var2
- salva tale valore all'indirizzo SS:SP e cioè, SS:019Eh
Il valore 0C80h appena inserito nel segmento di stack all'indirizzo SS:019Eh
non ha niente a che vedere con il valore 0C80h di var2 presente nel segmento
dati all'indirizzo DS:000Ch; la funzione func1 ha quindi a disposizione una
copia del valore di var2 e non l'originale.
Il passo successivo consiste nell'inserimento nello stack del valore (1500) di
var1; la CPU esegue i seguenti passi:
- sottrae 2 byte a SP per fare posto nello stack ad una nuova
WORD e ottiene SP=019Ch
- legge il valore 1500d=05DCh contenuto all'indirizzo di memoria
DS:000Ah di var1
- salva tale valore all'indirizzo SS:SP e cioè, SS:019Ch
Il valore 05DCh appena inserito nel segmento di stack all'indirizzo SS:019Ch
non ha niente a che vedere con il valore 05DCh di var1 presente nel segmento
dati all'indirizzo DS:000Ah; la funzione func1 ha quindi a disposizione una
copia del valore di var1 e non l'originale.
A questo punto incontriamo la chiamata di func1 che essendo di tipo NEAR
determina l'inserimento nello stack dell'offset 00A4h dell'indirizzo di ritorno;
la CPU esegue i seguenti passi:
- sottrae 2 byte a SP per fare posto nello stack ad una nuova
WORD e ottiene SP=019Ah
- legge l'offset 00A4h dell'indirizzo di ritorno
- salva tale offset all'indirizzo SS:SP e cioè, SS:019Ah
- carica in IP l'offset di func1
- trasferisce il controllo (salta) all'indirizzo CS:IP
Terminate queste fasi, ci ritroviamo all'interno di func1; osservando la Figura
25.5b possiamo già intuire il modo con il quale avviene l'accesso agli argomenti passati
alla funzione.
Per accedere a questi argomenti viene utilizzato, come al solito, il Base Pointer BP;
le prime due istruzioni che il compilatore C inserisce in func1 sono le
seguenti:
Come già sappiamo, la prima istruzione salva nello stack il vecchio contenuto di BP
(che indichiamo con old BP); la seconda istruzione copia il contenuto di SP
in BP.
Per il salvataggio nello stack del contenuto originale di BP la CPU esegue i
seguenti passi:
- sottrae 2 byte a SP per fare posto nello stack ad una nuova
WORD e ottiene SP=0198h
- legge il contenuto originale (old BP) di BP
- salva tale valore all'indirizzo SS:SP e cioè, SS:0198h
In base a questa situazione, l'istruzione:
mov bp, sp
copia in BP il valore 0198h contenuto in SP; a questo punto,
analizzando la Figura 25.5b possiamo dire che:
- l'argomento 05DCh (copia del valore di var1) si trova nello
stack all'indirizzo SS:BP+4; infatti:
BP+4 = 0198h + 4h = 019Ch e quindi: word ptr [BP+4] = 05DCh
- l'argomento 0C80h (copia del valore di var2) si trova nello
stack all'indirizzo SS:BP+6; infatti:
BP+6 = 0198h + 6h = 019Eh e quindi: word ptr [BP+6] = 0C80h
La tecnica appena illustrata permette quindi alle funzioni del linguaggio C di
accedere facilmente ai vari argomenti ricevuti attraverso lo stack.
Al termine di func1, il compilatore C inserisce le seguenti due istruzioni:
La prima istruzione ripristina il contenuto originale di BP; la CPU esegue i
seguenti passi:
- legge il valore old BP dall'indirizzo SS:SP e cioè, SS:0198h
- somma 2 byte a SP per recuperare lo spazio appena liberatosi nello
stack e ottiene SP=019Ah
- salva in BP il valore old BP
La seconda istruzione (RET) rappresenta un NEAR return e determina l'estrazione
dallo stack dell'offset dell'indirizzo di ritorno; la CPU esegue i seguenti passi:
- legge il valore 00A4h dall'indirizzo SS:SP e cioè, SS:019Ah
- somma 2 byte a SP per recuperare lo spazio appena liberatosi nello
stack e ottiene SP=019Ch
- salva in IP il valore 00A4h
- trasferisce il controllo (salta) a CS:IP
Attraverso questo salto, il controllo viene restituito al caller; all'indirizzo
CS:IP (CS:00A4h) viene incontrata l'istruzione:
add sp, 4
Come si può facilmente intuire, questa istruzione ha il compito di ripulire lo stack
dagli argomenti che avevamo passato a func1; siccome avevamo inserito due
WORD, pari a 4 byte, sommando 4 a SP=019Ch si ottiene
SP=0200h. A questo punto la coppia SS:SP vale SS:0200h e punta al
dato 3BF2h; in questo modo abbiamo perfettamente ripristinato la situazione
mostrata in Figura 25.5a, relativa allo stato dello stack precedente alla chiamata di
func1!
Una volta capito il meccanismo della NEAR call a func1, possiamo affrontare
facilmente il caso della FAR call; come già sappiamo, questo caso si presenta quando
la definizione di func1 si trova in un segmento di programma diverso da quello del
caller, oppure, più in generale, quando func1 è stata dichiarata di tipo FAR.
Nel caso di una FAR call, il compilatore C genera il codice macchina
corrispondente alle seguenti istruzioni Assembly:
Rispetto alla NEAR call l'unica differenza sta nel fatto che prima di eseguire
l'istruzione CALL la CPU salva nello stack, non solo l'offset
dell'indirizzo di ritorno, ma anche il contenuto corrente di CS; abbiamo visto,
infatti, che per eseguire un salto FAR la CPU modifica, non solo IP,
ma anche CS.
In sostanza, per illustrare la situazione che si viene a creare dopo l'esecuzione della
CALL ci basta sostituire la precedente Figura 25.5b con la seguente Figura 25.6:
In questo esempio supponiamo che il segmento di programma in cui si trova il caller sia
CODESEGM=0CF0h; prima di chiamare func1 la CPU salva quindi nello
stack, prima il contenuto corrente (0CF0h) di CS e poi l'offset 00A4h
dell'indirizzo di ritorno.
Il valore 0CF0h viene salvato all'offset SP=019Ah, mentre il valore
00A4h viene salvato all'offset SP=0198h. In base a questa situazione,
all'interno di func1 l'istruzione:
push bp
salva il valore corrente old BP di BP all'offset SP=0196h; ne
consegue che la successiva istruzione:
mov bp, sp
copia in BP il valore 0196h contenuto in quel momento in SP.
A questo punto, osservando la Figura 25.6 possiamo affermare che:
- l'argomento 05DCh (copia del valore di var1) si trova nello stack
all'indirizzo SS:BP+6; infatti:
BP+6 = 0196h + 6h = 019Ch e quindi: word ptr [BP+6] = 05DCh
- l'argomento 0C80h (copia del valore di var2) si trova nello stack
all'indirizzo SS:BP+8; infatti:
BP+8 = 0196h + 8h = 019Eh e quindi: word ptr [BP+8] = 0C80h
Riassumendo, nel caso di NEAR call la lista degli argomenti inizia da
BP+4; nel caso, invece, di FAR call la lista degli argomenti inizia da
BP+6.
In tutti gli esempi appena illustrati, i vari argomenti sono stati passati per valore a
func1; il caso relativo ad eventuali argomenti da passare per indirizzo è
altrettanto semplice. Per illustrare questo caso, supponiamo che func1 richieda
il primo argomento per valore e il secondo per indirizzo; il prototipo C di
func1 diventa allora:
void func1(short int, *short int);
Questo prototipo afferma che func1 richiede due argomenti; il primo è di tipo
short int, mentre il secondo è di tipo indirizzo di uno short int. Se
vogliamo chiamare func1 passandole come argomenti il valore di var1 e
l'indirizzo di var2, possiamo scrivere:
func1(var1, &var2);
Nel caso, ad esempio, di NEAR call, il compilatore C incontrando questa
chiamata genera il codice macchina corrispondente alle seguenti istruzioni Assembly:
L'istruzione:
push offset var2
inserisce nello stack una copia dell'indirizzo (000Ch) di var2; la funzione
func1 conoscendo questo indirizzo è in grado di modificare direttamente il
contenuto originale di var2. Ricordiamo che l'istruzione PUSH può utilizzare
un operando di tipo Imm solo con le CPU 80286 o superiori; nel caso di CPU
8086 bisogna prima copiare l'operando Imm in un registro e poi usare lo stesso
registro come operando di PUSH (al posto del registro si può anche usare un operando
di tipo Mem).
La convenzione C per il passaggio degli argomenti alle funzioni prende origine dal
fatto che il linguaggio C prevede anche il caso di funzioni che accettano un numero
variabile di argomenti; un caso emblematico è rappresentato dalla funzione printf
che fa parte della libreria standard stdio del C. Consultando l'header
file stdio.h (fornito insieme a qualsiasi compilatore C) si nota la presenza
di un prototipo del tipo:
int printf(char *format, ...);
Questo prototipo afferma che printf richiede come primo argomento l'indirizzo di una
stringa (vettore di char) chiamata stringa di formato; i tre puntini seguenti
indicano che questa funzione accetta una lista variabile di ulteriori argomenti. Scrivendo,
ad esempio:
printf("V[%d] = %d\n", i, vector[i]);
stiamo chiamando printf con tre argomenti che sono: la stringa di formato
"V[%d] = %d\n", il valore di una variabile i e il valore di una variabile
vector[i] (elemento di indice i di un vettore vector). Se i
vale 4 e vector[i] vale 10, verrà prodotto l'output:
V[4] = 10
seguito da un new line = '\n' (avanzamento linea).
I progettisti del linguaggio C hanno constatato che il modo migliore per gestire
questa situazione consisteva nel passare gli argomenti a partire dall'ultimo della lista
(da destra verso sinistra); osserviamo, infatti, che in questo modo qualunque sia il
numero di argomenti inseriti nello stack, il primo di essi è sempre quello successivo
all'indirizzo di ritorno, il secondo segue il primo, il terzo segue il secondo e così via.
Inserendo, invece, gli argomenti a partire dal primo della lista (da sinistra verso destra),
non siamo in grado di stabilire con certezza quale sia la posizione nello stack del primo
di essi; di conseguenza, non siamo in grado nemmeno di stabilire la posizione nello stack
del secondo argomento, del terzo, etc.
Supponiamo, ad esempio, che la precedente chiamata di printf sia di tipo NEAR,
con i e vector[i] di tipo short int; all'interno di printf
otteniamo allora la seguente situazione:
- in posizione BP+4 è presente l'offset della stringa di formato;
- in posizione BP+6 è presente il valore 4 (copia del valore di
i);
- in posizione BP+8 è presente il valore 10 (copia del valore di
vector[i])
Tutti i concetti esposti in questo paragrafo, verranno approfonditi in un apposito capitolo
dedicato all'interfacciamento tra C e Assembly.
25.5.3 Convenzione Pascal per il passaggio di argomenti ai sottoprogrammi
Nel caso del linguaggio Pascal, i sottoprogrammi vengono supportati attraverso le
due direttive function e procedure; la function implementa il concetto
di estensione degli operatori matematici, mentre la procedure implementa il concetto
di estensione delle istruzioni semplici.
Analizziamo il caso di un generico sottoprogramma Pascal visto che le considerazioni
che verranno svolte valgono, sia per le function, sia per le procedure.
Per illustrare questa convenzione, facciamo riferimento al seguente programma Pascal:
Il programma inizia con la definizione di due variabili di tipo Integer, chiamate
var1 e var2; nelle architetture a 16 bit il tipo Integer del
Pascal è perfettamente equivalente al tipo short int del C.
Tutte le variabili definite all'inizio di un programma Pascal corrispondono alle
variabili globali del C e quindi vengono inserite dal compilatore Pascal
nel segmento dati del programma; queste variabili quindi sono visibili e accessibili da
qualunque funzione o procedura del programma.
Subito dopo le variabili globali, incontriamo la definizione di una procedura chiamata
Proc1; come si può notare, questa procedura richiede due argomenti di tipo
Integer.
A questo punto arriviamo all'istruzione BEGIN che delimita l'entry point di un
programma Pascal; all'interno del blocco codice principale del programma è
presente l'inizializzazione di var1 e var2 e la chiamata della procedura
Proc1.
Anche in questo caso, il nostro obiettivo è capire come vengono passati gli argomenti
var1 e var2 a questa procedura; non ci interessa quindi sapere cosa contiene
il corpo di Proc1. Cominciamo dal caso in cui la procedura Proc1 sia di tipo
NEAR e la sua definizione si trovi nello stesso segmento di codice del caller; in
questo caso il compilatore incontrando la chiamata di Proc1 produce il codice
macchina corrispondente alle seguenti istruzioni Assembly:
Rispetto all'esempio della NEAR call della funzione C func1 che
abbiamo analizzato in precedenza, si notano due importanti differenze; osserviamo,
infatti, che gli argomenti vengono passati a partire dal primo della lista (da sinistra
verso destra) e inoltre, non è più presente l'istruzione:
add sp, 4
Per analizzare questa situazione, ci serviamo come al solito di una rappresentazione
visiva dello stato del segmento di stack del programma; prima della chiamata di
Proc1 la situazione dello stack è identica a quella illustrata in Figura 25.5a.
Subito dopo la chiamata di Proc1 e il salvataggio del contenuto originale di
BP otteniamo la situazione descritta nella seguente Figura 25.7.
In pratica, partendo da SP=0200h, la copia del valore 05DCh di var1
viene inserita nello stack in posizione SP=019Eh, mentre la copia del valore
0C80h di var2 viene inserita nello stack in posizione SP=019Ch; la
chiamata NEAR di Proc1 provoca l'inserimento nello stack dell'indirizzo di
ritorno 00A4h in SP=019Ah.
All'interno di Proc1 l'accesso ai vari parametri avviene come al solito attraverso
BP; il compilatore Pascal inserisce quindi prima di tutto l'istruzione:
push bp
In seguito all'esecuzione di questa istruzione, il contenuto originale (old BP) di
BP viene inserito nello stack in SP=0198h; in base a questa situazione, la
successiva istruzione:
mov bp, sp
copia in BP il valore 0198h di SP.
A questo punto, osservando la Figura 25.7 possiamo affermare che:
- l'argomento 05DCh (copia del valore di var1) si trova nello stack
all'indirizzo SS:BP+6; infatti:
BP+6 = 0198h + 6h = 019Eh e quindi: word ptr [BP+6] = 05DCh
- l'argomento 0C80h (copia del valore di var2) si trova nello stack
all'indirizzo SS:BP+4; infatti:
BP+4 = 0198h + 4h = 019Ch e quindi: word ptr [BP+4] = 0C80h
In pratica, l'ultimo argomento inserito nello stack si trova subito dopo l'indirizzo di
ritorno, poi incontriamo in successione il penultimo argomento, il terzultimo, etc;
rispetto agli esempi presentati per il C notiamo che questa volta gli argomenti
inseriti nello stack si trovano disposti in ordine inverso. Il Pascal non permette
la definizione di sottoprogrammi con lista variabile di argomenti, per cui la posizione
nello stack degli argomenti stessi è facilmente individuabile.
Nella parte finale della procedura Proc1, il compilatore Pascal inserisce le due
istruzioni:
La prima di queste istruzioni, come al solito, ripristina il contenuto originale di
BP ponendo anche SP=019Ah; la seconda istruzione è un NEAR return
con operando immediato di valore 4. Come è stato spiegato nel Capitolo 20, la
CPU incontrando questa istruzione esegue i seguenti passi:
- legge il valore 00A4h dall'indirizzo SS:SP e cioè SS:019Ah
- somma 2 byte a SP per recuperare lo spazio appena liberatosi nello
stack e ottiene SP=019Ch
- somma il valore immediato 4 a SP ottenendo SP=0200h
- salva in IP il valore 00A4h
- trasferisce il controllo (salta) a CS:IP
Attraverso questo salto, il controllo viene restituito al caller; quando il caller
riottiene il controllo, trova il segmento di stack perfettamente ripristinato (Figura
25.5a).
Come si può facilmente intuire, l'operando immediato 4 di RET ha il compito
di ripulire lo stack dagli argomenti che avevamo passato a Proc1; siccome avevamo
inserito due WORD, pari a 4 byte, sommando 4 a SP=019Ch si
ottiene SP=0200h.
Possiamo dire quindi che in relazione al passaggio degli argomenti e alla pulizia dello
stack, le convenzioni Pascal sono opposte a quelle del linguaggio C.
A questo punto, il caso della FAR call di Proc1 appare abbastanza semplice
in quanto il meccanismo dovrebbe essere ormai chiaro; la situazione all'interno di
Proc1 viene descritta dalla Figura 25.8:
Osservando la Figura 25.8 possiamo affermare che:
- l'argomento 05DCh (copia del valore di var1) si trova nello stack
all'indirizzo SS:BP+8; infatti:
BP+8 = 0196h + 8h = 019Eh e quindi: word ptr [BP+8] = 05DCh
- l'argomento 0C80h (copia del valore di var2) si trova nello stack
all'indirizzo SS:BP+6; infatti:
BP+6 = 0196h + 6h = 019Ch e quindi: word ptr [BP+6] = 0C80h
Al termine di Proc1 viene ripristinato il contenuto originale di BP e viene
effettuato un FAR return con operando immediato di valore 4. La tecnica
utilizzata dal Pascal per la pulizia dello stack è più veloce dell'analoga tecnica
utilizzata dal C.
Anche il Pascal permette il passaggio degli argomenti per indirizzo; a tale
proposito è necessario utilizzare la parola riservata VAR. Supponiamo che la
procedura Proc1 dell'esempio precedente richieda il primo argomento per valore e
il secondo per indirizzo; in questo caso, la definizione di Proc1 assume il
seguente aspetto:
Nel linguaggio C il programmatore ha il compito di passare esplicitamente un
argomento per indirizzo; nel caso del Pascal, invece, tutto il lavoro necessario
viene svolto dal compilatore. La chiamata di Proc1 quindi rimane immutata:
Proc1(var1, var2);
In presenza di questa chiamata, il compilatore Pascal, tenendo conto della
definizione di Proc1, produce il codice macchina corrispondente alle seguenti
istruzioni Assembly (nel caso di NEAR call):
La procedura Proc1 riceve quindi una copia del valore di var1 e una copia
dell'indirizzo di var2; accedendo a questo indirizzo, Proc1 ha la
possibilità di modificare il contenuto originale della variabile var2.
Tutti i concetti esposti in questo paragrafo, verranno approfonditi in un apposito capitolo
dedicato all'interfacciamento tra Pascal e Assembly.
25.6 Convenzioni per le variabili locali dei sottoprogrammi
Come abbiamo avuto modo di constatare nei precedenti capitoli, ogni programma scritto in
un qualsiasi linguaggio, ha bisogno di una serie di variabili destinate a contenere dei
valori; questi valori possono rappresentare, ad esempio, indirizzi di memoria, risultati
intermedi di calcoli che il programma sta svolgendo, etc.
Le variabili di un programma hanno due caratteristiche importantissime che vengono chiamate
durata e visibilità.
Può capitare che un programma abbia bisogno di variabili che esistano in qualunque momento
della fase di esecuzione e che siano visibili da qualunque punto del programma stesso;
queste particolari variabili vengono denominate variabili globali. Come già sappiamo,
le variabili globali, per poter avere queste caratteristiche, devono essere definite
all'interno dei segmenti di programma; preferibilmente è opportuno inserire queste
definizioni nei segmenti di dati.
Le variabili definite all'interno di un segmento di programma vengono anche chiamate
variabili statiche in quanto a ciascuna di esse viene assegnato un indirizzo di
memoria che rimane fisso (statico) per tutta la durata del programma; appare evidente
quindi che la durata di una variabile globale coincide con la durata dell'intero
programma.
Una variabile globale ha anche una visibilità globale in quanto può essere vista e quindi
acceduta da qualunque punto del programma; in un prossimo capitolo vedremo, inoltre, che
in un programma formato da più file, una variabile globale definita in un file può essere
resa visibile e quindi accessibile agli altri file.
Anche un sottoprogramma può avere bisogno di apposite variabili necessarie per poter
memorizzare temporaneamente determinate informazioni; in questo caso, l'utilizzo delle
variabili globali non rappresenta una soluzione molto conveniente in quanto produce un
grave spreco di memoria. Osserviamo, infatti, che queste variabili vengono utilizzate solo
nel momento in cui avviene la chiamata del sottoprogramma; non appena il sottoprogramma
termina, le variabili ad esso associate non servono più e continuano ad occupare
inutilmente la memoria.
L'ideale sarebbe "creare" queste variabili al momento della chiamata di un sottoprogramma
e "distruggerle" non appena il sottoprogramma stesso restituisce il controllo al caller;
questa è proprio la strada seguita dai compilatori dei linguaggi di alto livello.
Naturalmente, il luogo più adatto per creare e distruggere variabili non può che essere
lo stack; il procedimento che si segue è assolutamente analogo a quello che ci permette
di passare gli argomenti ai sottoprogrammi. Abbiamo visto, infatti, che in fase di chiamata
di un sottoprogramma, gli argomenti richiesti vengono inseriti nello stack; quando il
sottoprogramma termina, lo stack viene ripulito in modo da recuperare la memoria non più
necessaria.
Nella terminologia dei linguaggi di alto livello, le variabili create all'interno di un
sottoprogramma vengono definite variabili locali; in contrapposizione alle variabili
statiche create nei segmenti di programma, le variabili locali create nello stack possono
essere definite variabili dinamiche.
Nel linguaggio C le variabili locali vengono anche chiamate variabili
automatiche; questa definizione è legata chiaramente al fatto che queste variabili
vengono automaticamente create e distrutte dai sottoprogrammi.
In base alle considerazioni appena esposte, possiamo affermare che anche gli argomenti
passati ad un sottoprogramma possono essere considerati, a tutti gli effetti, come
variabili locali.
Vediamo un esempio pratico che illustra il procedimento che permette ai compilatori dei
linguaggi di alto livello di creare variabili locali; supponiamo di voler scrivere una
procedura che inizializza con il valore zero tutti gli n elementi di un qualsiasi
vettore di WORD. Supponiamo poi di avere un vettore di 10 elementi, chiamato
vector1 e definito all'offset 00BCh di un blocco dati di nome DATASEGM;
la procedura richiede due argomenti che rappresentano l'offset iniziale del vettore e il
numero di elementi. All'interno della procedura è presente un loop che inizializza con zero
tutti gli elementi del vettore; in questi casi è preferibile utilizzare ovviamente CX
come contatore. Nel nostro esempio, invece, a scopo didattico utilizziamo una variabile
locale a 16 bit creata nello stack; per il passaggio degli argomenti e per la
pulizia dello stack seguiamo, inoltre, la convenzione C. La struttura della
procedura di tipo NEAR chiamata ZeroVector è la seguente:
Il parametro voffset rappresenta l'indirizzo di uno short int che nel nostro
caso è l'indirizzo del primo elemento di vector1; il parametro n rappresenta
il numero di elementi del vettore. All'interno di ZeroVector l'accesso agli argomenti
avviene nel solito modo; osserviamo che trattandosi di una procedura NEAR,
l'argomento voffset si trova in [BP+4], mentre l'argomento n si
trova in [BP+6].
La novità è rappresentata dall'istruzione:
sub sp, 2
Sottraendo 2 a SP stiamo richiedendo allo stack 2 byte di spazio da
destinare ad una variabile locale che utilizzeremo come contatore; questa è un'altra
rarissima circostanza nella quale il programmatore Assembly è chiamato a gestire
direttamente lo stack pointer SP.
Come si può notare, il contatore (che, come vedremo, si trova in SS:BP-2), viene
inizializzato attraverso AX con il valore n. All'interno del loop, il
contatore viene continuamente decrementato finché non diventa zero; questa è la condizione
di uscita dal loop. Al termine del loop è presente l'istruzione:
mov sp, bp
Questa istruzione ripulisce lo stack dalle variabili locali in quanto riposiziona
SP all'offset che aveva prima della creazione del contatore; potevamo anche
scrivere:
add sp, 2
La prima versione, però, è da preferire in quanto è indipendente dal numero di
byte che avevamo sottratto a SP per creare le variabili locali.
Infine, incontriamo la solita istruzione di ripristino di BP e il NEAR
return che restituisce il controllo al caller.
La chiamata C di ZeroVector è la seguente:
La Figura 25.9 chiarisce meglio la situazione che si viene a creare all'interno dello stack
in seguito alla chiamata di ZeroVector e alla creazione della variabile locale.
Come al solito, supponiamo di partire da SP=0200h che punta al dato 3BF2h
già presente nello stack; a questo punto inizia il passaggio degli argomenti alla
procedura.
L'argomento n=10=000Ah (numero di elementi del vettore) viene inserito in
SP=019Eh; l'argomento offset(vector1)=00BCh viene inserito in
SP=019Ch.
La chiamata NEAR di ZeroVector determina l'inserimento nello stack del
solo offset dell'indirizzo di ritorno; nel nostro esempio quest'offset vale 001Eh
e viene inserito in SP=019Ah.
All'interno di ZeroVector viene prima di tutto salvato il contenuto originario
old BP di BP nello stack; il valore old BP viene inserito in
SP=0198h. In base a questa situazione, l'istruzione:
mov bp, sp
copia in BP il valore 0198h di SP.
A questo punto avviene la creazione nello stack di una variabile locale a 16 bit
indicata in Figura 25.9 con il nome count; a tale proposito sottraiamo 2 byte
a SP ottenendo SP=0196h, mentre BP vale sempre 0198h. Tenendo
conto dei valori di SP e BP possiamo dire quindi che:
- l'argomento voffset=00BCh si trova in SS:BP+4
- l'argomento n=000Ah si trova in SS:BP+6
- la variabile locale count si trova in SS:BP-2
In sostanza, gli argomenti della procedura si trovano a spiazzamenti positivi
da BP, mentre le variabili locali si trovano a spiazzamenti negativi da
BP!
Nella parte finale della procedura incontriamo le istruzioni di ripristino; la prima di
queste istruzioni è:
mov sp, bp
Questa istruzione copia in SP il contenuto 0198h di BP; in questo modo
lo stack viene ripulito dalle variabili locali create dalla procedura. L'istruzione seguente
ripristina BP e pone SP=019Ah. Il NEAR return estrae 001Eh dallo
stack, lo carica in IP, pone SP=019Ch e salta a CS:IP; a CS:IP
(cioè, a CS:001Eh) viene incontrata l'istruzione:
add sp, 4
che ripulisce lo stack dagli argomenti passati a ZeroVector; a questo punto otteniamo
SP=0200h con il segmento di stack completamente ripristinato.
Nei linguaggi di alto livello, l'inserimento degli argomenti nello stack e la sequenza di
istruzioni:
rappresentano il cosiddetto prolog code (codice di prologo) o procedure entry;
invece, la sequenza di istruzioni:
e l'istruzione per ripulire lo stack dagli argomenti, rappresentano il cosiddetto epilog
code (codice di epilogo) o procedure exit.
Supponiamo, ad esempio, di voler preservare il contenuto originale di tutti i registri
utilizzati da ZeroVector; in questo caso dobbiamo scrivere:
Come al solito è importantissimo ricordare che tutte le estrazioni dallo stack devono
avvenire in senso inverso rispetto agli inserimenti; infatti, lo stack è una struttura
di tipo LIFO (Last In First Out)!
Le convenzioni del Pascal per la creazione delle variabili locali sono identiche
alle convenzioni del C; anche nel caso del Pascal quindi, all'interno di
un sottoprogramma gli argomenti si trovano a spiazzamenti positivi da BP, mentre
le variabili locali si trovano a spiazzamenti negativi da BP.
In definitiva, nei linguaggi di alto livello tutta la gestione degli argomenti e delle
variabili locali ruota attorno al base pointer BP; questi linguaggi richiedono
tassativamente che all'interno dei sottoprogrammi che fanno uso di argomenti e di variabili
locali, il contenuto originale di BP debba essere sempre preservato. In caso
contrario, si può andare incontro a seri problemi; in particolare, ciò è vero nel caso
di chiamate "innestate" (procedure che, dal loro interno, chiamano altre procedure)!
Tutti questi concetti vengono approfonditi in modo dettagliato nei capitoli dedicati
all'interfaccia tra l'Assembly e i linguaggi di alto livello.
25.7 Gestione degli argomenti e delle variabili locali con EQU
All'interno di una procedura, la gestione diretta degli argomenti e delle variabili
locali attraverso BP risulta particolarmente scomoda; il programmatore deve
ricordarsi di calcolare il corretto spiazzamento (rispetto a BP) ogni volta
che vuole accedere ad un determinato argomento o ad una determinata variabile locale.
Questa situazione determina un serio incremento delle possibilità di commettere errori;
per ovviare a questo inconveniente è possibile utilizzare efficacemente la direttiva
EQU. Come sappiamo, questa direttiva è in grado di gestire, attraverso un
nome simbolico, anche stringhe alfanumeriche; l'importante è che queste stringhe, una
volta espanse, producano codice Assembly sintatticamente e semanticamente valido!
Utilizzando allora la direttiva EQU, la procedura ZeroVector può
essere riscritta in questo modo:
Come si può notare, una volta dichiarate le varie macro, la gestione degli argomenti e
delle variabili locali diventa semplicissima; inoltre, una procedura scritta in questo
modo appare molto più elegante e comprensibile.
Quando si dichiarano nomi simbolici per le macro, bisogna stare attenti a non utilizzare
nomi già usati in precedenza per definire variabili, etichette, procedure, etc; è possibile,
invece, ridichiarare nomi già associati con EQU ad altre stringhe alfanumeriche.
La visibilità dei nomi delle macro è limitata al solo file di appartenenza.
Un'ultima considerazione riguarda il fatto che nella fase di progettazione di una procedura
che utilizza argomenti e variabili locali è importantissimo tracciare sempre su un foglio
di carta uno schema dello stack; un disegno come quello di Figura 25.9 rappresenta un
notevole aiuto che riduce enormemente il rischio di commettere errori.
Come offset iniziale si può utilizzare un qualunque valore simbolico come, ad esempio,
SP=0400h; si ricorda, inoltre, che è conveniente tracciare lo schema dello stack
con indirizzi crescenti da destra verso sinistra, dall'alto verso il basso o dal basso
verso l'alto, ma mai da sinistra verso destra.
25.8 L'indirizzo di una variabile locale
In relazione alle variabili locali è necessario affrontare un aspetto delicatissimo di
cui il programmatore Assembly deve sempre tenere conto; la non conoscenza di
questo aspetto può portare alla scrittura di programmi contenenti pericolosissimi bug!
Abbiamo visto che le variabili statiche sono così chiamate in quanto, essendo definite
all'interno di un segmento di programma, sono dotate di un ben preciso indirizzo che
rimane fisso (statico) per tutta la fase di esecuzione del programma; in fase di
assemblaggio del programma, l'assembler ogni volta che incontra la definizione di una
nuova variabile, assegna ad essa un offset che rappresenta la distanza in byte che
separa la definizione della variabile dall'inizio del segmento di appartenenza.
Supponiamo di avere una variabile a 16 bit, chiamata Var16, definita
all'offset 0010h di un segmento dati DATASEGM; possiamo dire quindi che
la definizione di Var16 si trova a 0010h byte di distanza dall'inizio del
blocco DATASEGM
In un caso del genere è perfettamente lecito scrivere istruzioni del tipo:
mov bx, offset Var16
Quando l'assembler incontra questa istruzione, genera un codice macchina che, in fase
di esecuzione del programma, dice alla CPU di caricare in BX il valore
immediato 0010h; tutto ciò è possibile in quanto l'offset di Var16
all'interno di DATASEGM è un valore immediato che l'assembler è in grado di
determinare in modo univoco.
Le considerazioni appena esposte si applicano in generale al caso in cui Var16
venga definita staticamente all'interno di qualsiasi segmento di programma (codice,
dati o stack); se, ad esempio, definiamo staticamente Var16 all'interno del
segmento di stack STACKSEGM, l'assembler incontrando l'istruzione precedente
calcola l'offset di Var16 rispetto a STACKSEGM.
La situazione cambia radicalmente nel caso delle variabili locali (compresi gli argomenti
ricevuti da una procedura); queste variabili non possono avere un indirizzo fisso (statico),
in quanto esse vengono create e distrutte dinamicamente nello stack!
Consideriamo, ad esempio, gli argomenti voffset, n e la variabile locale
count della procedura ZeroVector; ogni volta che ZeroVector viene
chiamata, l'unica certezza è che voffset si trova nello stack a BP+4,
n si trova a BP+6, mentre count si trova a BP-2. L'aspetto
fondamentale da sottolineare è che in fase di assemblaggio del programma l'assembler non
può sapere quanto varrà BP al momento della chiamata di ZeroVector;
osserviamo, infatti, che il contenuto di BP dipende dal contenuto di SP e
questi valori sono noti solo in fase di esecuzione del programma (run time).
Se il programma principale chiama 10 volte ZeroVector, può capitare che
per ciascuna di queste chiamate SP assuma un valore differente; questo valore
dipende, infatti, dalla situazione dello stack al momento della chiamata della
procedura.
Dalle considerazioni appena esposte si deduce che per calcolare, ad esempio, l'offset
della variabile locale count di ZeroVector, non avrebbe alcun senso scrivere:
mov bx, offset count
Purtroppo, però, l'assembler incontrando questa istruzione non visualizza alcun messaggio
di errore o di avvertimento; di conseguenza è importantissimo che il programmatore ricordi
sempre di non utilizzare nella maniera più assoluta l'operatore OFFSET per
determinare l'indirizzo degli argomenti o delle variabili locali di una procedura!
Per ovviare a questo problema esistono diverse soluzioni; volendo caricare, ad esempio, in
BX l'offset di count, potremmo scrivere:
mov bx, bp-2
Questa istruzione, però, è illegale perché l'assembler (come al solito) non è in grado di
sapere quanto vale BP e non è quindi in grado di valutare l'espressione BP-2.
Questo lavoro deve essere delegato alla CPU che in fase di esecuzione può svolgere
i calcoli necessari; in fase di assemblaggio dobbiamo scrivere quindi:
A questo punto possiamo scrivere anche istruzioni del tipo:
mov ax, ss:[bx]
(notare il segment override senza il quale BX verrebbe associato a DS).
Questa istruzione accede all'indirizzo dello stack in cui si trova count, legge il
contenuto di tale indirizzo e lo carica in AX.
Ricordando quanto è stato detto nel Capitolo 16 a proposito dell'istruzione LEA,
possiamo anche scrivere:
lea bx, count
che equivale ovviamente a scrivere:
lea bx, [bp-2]
Come già sappiamo, non bisogna farsi trarre in inganno dal simbolo [BP-2]; tale
simbolo rappresenta, infatti, l'indirizzo (effective address) BP-2 e non
il contenuto della locazione di memoria puntata da BP-2.
Riferendoci, ad esempio, alla Figura 25.9, possiamo dire che l'operando [BP-2]
di LEA rappresenta l'indirizzo 0196h e non il suo contenuto count.
Indubbiamente, l'uso dell'istruzione LEA è il metodo da preferire; osserviamo,
infatti, che con LEA possiamo utilizzare direttamente i nomi simbolici come
count, voffset, etc.
È chiaro che tutte le istruzioni appena illustrate hanno senso solo all'interno di
ZeroVector; infatti, quando ZeroVector termina, le sue variabili locali
(compresi gli argomenti ricevuti) vengono distrutte e quindi non esistono più fino alla
successiva chiamata. Da tutto ciò si deduce, inoltre, che è un gravissimo errore
restituire al caller l'indirizzo di una variabile locale; questo concetto dovrebbe
essere ben noto ai programmatori C!
25.9 Convenzioni per i valori di ritorno dei sottoprogrammi
Molto spesso si presenta la necessità di scrivere sottoprogrammi che ricevono una lista
di argomenti, li elaborano attraverso un apposito algoritmo e producono infine un
risultato da restituire al caller; questo valore restituito al caller viene definito
return value (valore di ritorno).
I linguaggi di alto livello utilizzano apposite convenzioni che stabiliscono in quale modo
si deve "spedire" il valore di ritorno al caller; fortunatamente, in relazione al return
value, i diversi linguaggi di alto livello seguono convenzioni molto simili tra loro. In
particolare:
- i valori interi vengono sempre restituiti attraverso l'accumulatore
AX/EAX in congiunzione, se necessario, con DX/EDX
- i valori in virgola mobile (numeri reali), vengono restituiti attraverso
ST0 che rappresenta la cima dello stack (TOS) del coprocessore
matematico (FPU)
La tabella seguente illustra in quale registro (locazione) vengono restituiti i vari tipi
di dati del linguaggio C e del Pascal; questa tabella è valida quindi anche
per i linguaggi "derivati", come il C++, l'Object Pascal, Delphi, etc.
In pratica, i valori interi a 8 bit vengono restituiti nel BYTE meno
significativo di AX (half register AL); in questo caso il contenuto di
AH non è significativo e dovrebbe valere zero (ma è meglio non fidarsi).
I valori interi a 16 bit vengono restituiti in AX. I valori interi a
32 bit vengono restituiti nella coppia DX:AX; nel rispetto della convenzione
little endian, il registro DX contiene la WORD più significativa,
mentre AX contiene la WORD meno significativa.
Le informazioni illustrate in Figura 25.10 valgono solo per la modalità reale a 16
bit del DOS; in tale modalità, la dimensione standard dei tipi interi del
C è pari a 8 bit per i char, 16 bit per gli short,
16 bit per gli int e 32 bit per i long. Nella modalità
protetta a 32 bit dei SO come Windows, Unix/Linux, MacOSX,
etc, la dimensione standard dei tipi interi è pari a 8 bit per i char,
16 bit per gli short, 32 bit per gli int e 32 bit per
i long; in tale modalità, i valori interi a 32 bit vengono restituiti in
EAX, mentre i valori interi a 64 bit vengono restituiti in EDX:EAX.
Nel caso dei valori di ritorno di tipo indirizzo (address), gli indirizzi NEAR
della modalità reale sono formati da un solo offset a 16 bit e vengono restituiti in
AX. Gli indirizzi FAR della modalità reale sono formati da una coppia
Seg:Offset a 16+16 bit e vengono restituiti in DX:AX; il registro
DX contiene la componente Seg, mentre AX contiene la componente
Offset. Nella modalità protetta a 32 bit, per indirizzo NEAR si
intende un offset a 32 bit; questo tipo di indirizzo viene restituito in EAX.
Tutti i valori reali in floating point vengono restituiti nel registro ST(0) che
rappresenta la cima dello stack (TOS) della FPU; questo aspetto viene
illustrato nella sezione Assembly Avanzato.
25.10 Programmi Assembly vecchio stile
Se si analizza il listato di qualche vecchio programma Assembly, scritto
con MASM o TASM, si scopre che il blocco delle istruzioni principali
si trova usualmente inserito all'interno di una procedura; nel caso di un
eseguibile in formato EXE, si può presentare, ad esempio, una situazione
di questo genere:
Alla fine del file che contiene il programma Assembly troviamo, inoltre, la
direttiva:
END main
Questa direttiva indica che l'identificatore main (che in questo caso è il
nome di una procedura) rappresenta l'entry point del programma (vedere il paragrafo del Capitolo
25.2 della sezione Assembly Base con MASM).
Come si può notare, alla fine della procedura main non sono presenti le solite
istruzioni che, attraverso il servizio 4Ch dell'INT 21h, terminano il
programma restituendo il controllo al DOS; vediamo allora di capire come si
svolgeva questa fase nei vecchi programmi Assembly.
Come è stato spiegato nel Capitolo 13, al momento di caricare in memoria un eseguibile
in formato EXE, il DOS crea due blocchi di memoria; nel primo blocco viene
sistemato l'Environment Segment del programma, mentre nel secondo blocco vengono
sistemati i 256 byte del Program Segment Prefix (PSP) seguiti dal
programma vero e proprio. Subito dopo il DOS procede con l'inizializzazione dei
vari registri della CPU; nei due registri DS e ES viene caricato il
paragrafo di memoria da cui parte il PSP, per cui si ha DS=PSP e
ES=PSP. Nel registro CS viene caricato il paragrafo di memoria da cui parte
il segmento di programma contenente l'entry point, mentre IP viene fatto puntare
allo stesso entry point; infine, se è presente un segmento di programma con attributo di
combinazione STACK, il DOS carica in SS il paragrafo di memoria da
cui parte tale segmento e posiziona SP alla fine del segmento stesso.
Terminata l'inizializzazione dei vari registri può partire l'esecuzione del programma;
la prima istruzione che viene eseguita dalla CPU è ovviamente quella che si trova
all'indirizzo logico CS:IP. In base alle inizializzazioni precedenti, possiamo
dire che a questo indirizzo si trova la prima istruzione della procedura main;
questo è il punto esatto (entry point) in cui il programmatore riceve il controllo.
Le prime importantissime istruzioni della procedura main provvedono ad inserire
nello stack due valori a 16 bit; il primo valore è il contenuto corrente di
DS e, in base a quanto è stato detto in precedenza, si tratta del paragrafo di
memoria da cui inizia il PSP (al posto di DS si può usare anche ES).
La seconda WORD inserita nello stack è AX=0000h; è fondamentale che queste
istruzioni siano le prime in assoluto all'interno della procedura main.
Al termine della procedura main la CPU incontra un FAR return
(MASM e TASM associano automaticamente una procedura FAR ad un
FAR return); di conseguenza si verifica l'estrazione dallo stack di due valori
a 16 bit che verranno inseriti in CS:IP. Se non ci sono stati errori
nella gestione dello stack, questi due valori sono proprio quelli salvati all'inizio
di main e cioè, PSP e 0000h; la CPU pone quindi
CS:IP=PSP:0000h e salta a CS:IP.
Se osserviamo la Figura 13.11 del Capitolo 13 (struttura del PSP), possiamo notare
che all'offset 0000h del PSP è presente una WORD chiamata
TerminateLocation; tale WORD contiene il codice macchina 20CDh
dell'istruzione:
INT 20h
Per dimostrarlo possiamo servirci della procedura writeHex16 della libreria
EXELIB; a tale proposito, all'interno della procedura main e subito dopo
l'inizializzazione di DS, possiamo scrivere:
La prima istruzione carica in AX la WORD contenuta all'indirizzo
ES:0000h=PSP:0000h; la terza istruzione visualizza in esadecimale il contenuto
di AX.
In questo modo possiamo constatare che sullo schermo viene mostrata proprio la WORD
20CDh; questa WORD è chiaramente disposta in notazione little-endian,
per cui CDh, che è il codice macchina di INT, è seguito dal valore immediato
20h.
Il codice macchina 20CDh rappresenta una istruzione di terminazione dei programmi
DOS; come è stato spiegato nel Capitolo 13, si tratta di una vecchia tecnica che
si utilizzava nel Sistema Operativo CP/M (l'antenato del DOS). Questa
tecnica è ancora utilizzabile per motivi di compatibilità con i vecchi programmi
DOS; in ogni caso, si consiglia vivamente di utilizzare sempre il servizio
4Ch dell'INT 21h che è in grado di effettuare una terminazione molto più
avanzata dei programmi.
È chiaro che un programmatore Assembly sufficientemente smaliziato, gestirebbe
tutta questa situazione anche in altri modi; l'aspetto fondamentale da ricordare è che
alla fine del nostro programma dobbiamo costringere la CPU a saltare a
CS:IP=PSP:0000h.
Un primo metodo alternativo che possiamo seguire consiste nell'eliminare la procedura
main sfruttando solamente il FAR return; osserviamo, infatti, che lo scopo
della procedura main di tipo FAR è solo quello di costringere gli assembler
come MASM e TASM a generare il codice macchina di un FAR return.
Possiamo ottenere lo stesso risultato inserendo direttamente il codice macchina
CBh) del ritorno FAR; servendoci, ad esempio, dell'etichetta main
come entry point ordinario, possiamo scrivere:
Quando la CPU incontra il codice macchina CBh (o RETF), estrae due
WORD dallo stack, le carica in CS:IP e salta a CS:IP; in assenza di
errori nella gestione dello stack, queste due WORD sono proprio PSP e
0000h.
Un altro metodo alternativo consiste nel definire una variabile a 32 bit del tipo:
TermAddress dd 0
In questa DWORD possiamo caricare la coppia PSP:0000h che ci serve per
terminare il nostro programma. Come al solito, bisogna ricordare che la componente
Offset deve occupare i 16 bit meno significativi della DWORD; nel
momento in cui vogliamo terminare il programma, non dobbiamo fare altro che saltare con
JMP all'indirizzo contenuto in TermAddress (salto indiretto intersegmento).
Il codice che si ottiene è il seguente:
Osserviamo che in TermAddress[2] viene caricato ES in quanto
DS è stato modificato (DS=DATASEGM); l'inizializzazione di DS
presuppone che TermAddress sia stata definita nel blocco DATASEGM.
Passiamo ora al caso dei programmi eseguibili in formato COM (COre iMage); in
questo caso si può presentare, ad esempio, una situazione di questo genere (sempre
in versione MASM TASM):
Come al solito, per capire il perché di questa struttura, dobbiamo analizzare il
procedimento seguito dal DOS per caricare in memoria un programma in formato
COM; anche in questo caso (vedere il Capitolo 13), al momento di caricare il
programma in memoria il DOS rende disponibili due blocchi di memoria. Il
primo blocco è riservato all'Environment Segment del programma; il secondo
blocco è formato da 65536 byte ed è destinato a contenere i 256 byte
del PSP seguiti dal programma vero e proprio.
Successivamente il DOS provvede ad inizializzare i vari registri della
CPU; nel caso del nostro esempio il DOS pone CS=DS=ES=SS=COMSEGM
(con COMSEGM=PSP). Il registro IP viene fatto puntare all'entry point
main (IP=0100h); il registro SP viene posizionato all'ultimo
offset pari (65534) del blocco di memoria da 65536 byte.
L'ultimo passo compiuto dal DOS consiste nell'inserimento nello stack del valore
a 16 bit 0000h; questo passo viene svolto esclusivamente per i programmi
eseguibili in formato COM.
Terminata l'inizializzazione dei vari registri, può partire l'esecuzione del programma;
la prima istruzione che viene eseguita dalla CPU è ovviamente quella che si trova
all'indirizzo logico CS:IP. In base alle inizializzazioni precedenti, possiamo
dire che a questo indirizzo si trova la prima istruzione della procedura main;
questo è il punto esatto (entry point) in cui il programmatore riceve il controllo.
Al termine della procedura main (di tipo NEAR) la CPU incontra un
NEAR return; di conseguenza si verifica l'estrazione dallo stack di un valore a
16 bit che verrà caricato in IP. Se non ci sono stati errori nella gestione
dello stack, questo valore è proprio quello salvato inizialmente dal DOS e cioè,
0000h; la CPU pone quindi IP=0000h e salta a CS:IP.
Trattandosi di un programma eseguibile in formato COM, il registro CS non
necessita di alcuna modifica in quanto contiene già il paragrafo di memoria da cui inizia
il PSP (cioè, CS=PSP); in base alle considerazioni appena svolte, possiamo
notare che anche in questo caso al termine di main si ottiene CS:IP=PSP:0000h.
La CPU esegue quindi l'istruzione 20CDh con conseguente terminazione del
programma e restituzione del controllo al DOS. Anche nel caso dei programmi COM
si possono utilizzare i metodi alternativi descritti in precedenza, ricordando che questa
volta dobbiamo servirci di un NEAR return o di un salto indiretto intrasegmento.
25.11 Esercitazioni consigliate
Utilizzando i concetti acquisiti in questo capitolo e le istruzioni che già conosciamo
(in particolare, i loop), possiamo creare delle procedure estremamente compatte, potenti
ed efficienti; come esercitazione pratica si consiglia di provare a reimplementare, con
l'ausilio delle procedure, i vari algoritmi sequenziali presentati nei precedenti capitoli.
A titolo di esempio, scriviamo delle procedure che ci permettono di effettuare somme tra
numeri interi con segno espressi in formato packed BCD standard; si tratta quindi
di reimplementare i due algoritmi sequenziali mostrati nei paragrafi 18.6 e
18.7 del Capitolo 18.
Bisogna ricordare che un numero packed BCD standard occupa 10 byte, con
il segno che viene codificato nel BYTE più significativo; il valore 00h
codifica il segno positivo, mentre il valore 80h codifica il segno
negativo.
Con questo tipo di codifica possiamo quindi rappresentare numeri interi decimali con
segno compresi tra il limite inferiore -999999999999999999 e il limite superiore
+999999999999999999; lo zero può essere codificato indifferentemente come
-000000000000000000 o +000000000000000000.
La Figura 25.11 illustra il listato completo del programma PBCDSUM.ASM.
Innanzi tutto vengono definiti due numeri (pbcdNum1 e pbcdNum2) che
rappresentano i due addendi da sommare; il terzo numero (pbcdSum) è destinato
a contenere il risultato della somma.
Per il passaggio degli argomenti alle procedure del programma viene utilizzata la
convenzione del linguaggio C; di conseguenza, gli argomenti vengono inseriti
nello stack a partire dall'ultimo della lista e la pulizia dello stack viene
effettuata dal caller.
Per visualizzare sullo schermo i numeri in formato packed BCD standard viene
utilizzata la procedura printPBCD; il suo prototipo in versione C è:
void printPBCD(UCHAR *num, USHORT coord);
Il parametro num è l'offset di un numero packed BCD standard, mentre il
parametro coord è un intero senza segno contenente le coordinate di output; ciò
significa che, come primo argomento, dobbiamo passare a printPBCD la componente
Offset dell'indirizzo del primo BYTE di un numero packed BCD
standard.
Come al solito, il numero viene visualizzato da writeHex8 a partire dal
BYTE più significativo; inoltre, il BYTE più significativo, contenente
la codifica del segno, viene convertito nell'opportuno simbolo '+' o
'-'.
Per effettuare la somma tra i due addendi viene chiamata la procedura sommaPBCD;
il suo prototipo in versione C è:
void sommaPBCD(UCHAR *num1, UCHAR *num2, UCHAR *res);
I tre parametri richiesti da sommaPBCD sono del tutto analoghi al parametro
num richiesto da printPBCD; essi rappresentano gli indirizzi NEAR
del primo addendo, del secondo addendo e del risultato.
La procedura sommaPBCD valuta prima di tutto il segno dei due addendi; se i
due addendi hanno lo stesso segno, viene chiamata la procedura addPBCD che
effettua una somma ordinaria tra gli addendi stessi. Ci troviamo quindi nei due
possibili casi:
(+) + (+) e (-) + (-)
Se i due addendi hanno segno opposto, viene innanzi tutto determinato il maggiore
tra i due (in valore assoluto); a questo punto viene chiamata la procedura subPBCD
che effettua una differenza ordinaria tra gli addendi stessi. Ci troviamo quindi nei due
possibili casi:
(+) + (-) e (-) + (+)
La procedura addPBCD effettua una somma ordinaria tra due numeri in formato
packed BCD standard; il suo prototipo in versione C è:
void addPBCD(UCHAR *add1, UCHAR *add2, UCHAR *add_res);
Il significato dei tre parametri è analogo al caso di sommaPBCD (indirizzi
NEAR del primo addendo, del secondo addendo e del risultato).
I due numeri add1 e add2 vengono sommati in valore assoluto e alla
fine viene anche aggiustato il segno del risultato; ovviamente, il segno del
risultato coincide con il segno di uno qualunque degli addendi.
Osserviamo che, sommando due numeri aventi lo stesso segno, si può anche verificare
un overflow; ciò accade quando la somma tra le cifre più significative produce un
riporto finale (CF=1). In tal caso, addPBCD mette il codice
ASCII della lettera
'O' nel BYTE di segno del risultato; l'eventuale overflow viene
determinato attraverso l'istruzione JNC (a tale proposito, si ricordi che
l'istruzione MOV non produce effetti sul registro FLAGS e non influisce
quindi sulla successiva istruzione JNC).
Per effettuare la somma viene utilizzato un loop che contiene, in forma compatta,
l'algoritmo sequenziale presentato nella sezione 18.6 del Capitolo 18; è
interessante notare che tutte le somme parziali vengono effettuate con ADC.
Per evitare di effettuare la prima somma con ADD, possiamo utilizzare
l'istruzione CLC che pone CF=0; di conseguenza, la prima esecuzione di
ADC (che somma i BYTE meno significativi) produce gli stessi effetti
di ADD.
La procedura subPBCD effettua una differenza ordinaria tra due numeri in
formato packed BCD standard; il suo prototipo in versione C è:
void subPBCD(UCHAR *sub1, UCHAR *sub2, UCHAR *sub_res);
Il significato dei tre parametri è analogo al caso di sommaPBCD (indirizzi
NEAR del minuendo, del sottraendo e del risultato).
I due numeri sub1 e sub2 vengono sottratti in valore assoluto e alla
fine viene anche aggiustato il segno del risultato; ovviamente, il segno del
risultato coincide con il segno del minuendo. Si tenga presente che subPBCD
riceve da sommaPBCD il primo numero (minuendo) maggiore in valore assoluto
del secondo numero (sottraendo); appare anche evidente che sommando un numero
positivo con un numero negativo (o viceversa) non si può mai verificare un overflow.
Per effettuare la differenza viene utilizzato un loop che contiene, in forma compatta,
l'algoritmo sequenziale presentato nella sezione 18.7 del Capitolo 18; è
interessante notare che tutte le differenze parziali vengono effettuate con SBB.
Per evitare di effettuare la prima differenza con SUB, possiamo utilizzare
l'istruzione CLC che pone CF=0; di conseguenza, la prima esecuzione di
SBB (che sottrae i BYTE meno significativi) produce gli stessi effetti
di SUB.
Il programma di Figura 25.11 lavora correttamente solo su numeri codificati in formato
packed BCD standard; è necessario quindi rispettare tutte le regole legate
alla codifica del segno e delle varie cifre. Appare anche evidente che il programma
di Figura 25.11 può essere facilmente modificato in modo da poter operare su numeri
packed BCD di ampiezza arbitraria!
Un'ultima importante considerazione riguarda il fatto che tutti i dati del programma
(compresi i dati da passare per indirizzo alle procedure) vengono gestiti attraverso
indirizzi NEAR; ciò è una diretta conseguenza del fatto che è presente un
unico segmento di programma referenziato da DS. Possiamo limitarci quindi
a specificare solo la componente Offset di un qualsiasi dato in quanto la
sua componente Seg è implicitamente rappresentata da DS; in presenza
di due o più segmenti di dati avremmo dovuto utilizzare puntatori FAR in
modo da specificare l'indirizzo completo Seg:Offset del dato a cui accedere!