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. 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: 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): 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: 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: 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: 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:
BP+4 = 0198h + 4h = 019Ch e quindi: word ptr [BP+4] = 05DCh
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: 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: 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:
BP+6 = 0196h + 6h = 019Ch e quindi: word ptr [BP+6] = 05DCh
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: 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:
BP+6 = 0198h + 6h = 019Eh e quindi: word ptr [BP+6] = 05DCh
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: 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:
BP+8 = 0196h + 8h = 019Eh e quindi: word ptr [BP+8] = 05DCh
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: 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: 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!