Assembly Base con MASM
Capitolo 15: Libreria di procedure per l'I/O
Tutti i linguaggi di alto livello mettono a disposizione dei programmatori
sofisticati ambienti di sviluppo che comprendono, in particolare, una numerosa
collezione di cosiddetti sottoprogrammi, "pronti per l'uso"; con il
termine sottoprogramma si indica un piccolo blocco di istruzioni che, nel loro
insieme, formano una sorta di "mini-programma".
Ciascun sottoprogramma è destinato a svolgere un compito ben preciso; possiamo
avere, ad esempio, sottoprogrammi che si occupano della gestione della memoria,
della gestione dei file su disco, della lettura dell'input dalla tastiera, della
scrittura dell'output sullo schermo e così via.
I sottoprogrammi possono essere collegati, direttamente o indirettamente, ad
un generico programma che possiamo definire programma principale (o main
program); ogni volta che il programma principale deve svolgere un determinato
compito, non deve fare altro che chiamare l'opportuno sottoprogramma.
Si ha il collegamento diretto, quando tutti i sottoprogrammi di cui abbiamo
bisogno vengono inseriti nello stesso modulo contenente anche il programma
principale; esiste però un sistema molto più pratico, rappresentato dal
collegamento indiretto. Prima di tutto, in base al compito svolto, i vari
sottoprogrammi vengono suddivisi in gruppi e ciascun gruppo viene inserito in
un file distinto; ciascuno dei file così ottenuti prende il nome di libreria
di sottoprogrammi. Ogni volta che scriviamo un programma che ha bisogno di
una serie di sottoprogrammi, non dobbiamo fare altro che linkare le opportune
librerie al modulo contenente il programma principale.
Nei linguaggi di alto livello, per indicare i diversi tipi di sottoprogrammi,
vengono utilizzati termini come, procedura, funzione,
subroutine; in Assembly, i sottoprogrammi vengono chiamati
procedure. Nel seguito del capitolo e in tutta la sezione Assembly
Base, verrà sempre utilizzato il termine procedura per indicare
un generico sottoprogramma; le procedure Assembly verranno trattate in
dettaglio in un apposito capitolo.
Come è stato spiegato in precedenza, gli utenti dei linguaggi di alto livello
hanno a disposizione numerose librerie di procedure pronte per l'uso, che
possono essere utilizzate "a scatola chiusa", senza preoccuparsi di come esse
svolgano il proprio lavoro; del resto, uno degli scopi fondamentali dei linguaggi
di alto livello, è proprio quello di permettere a chiunque di scrivere programmi
senza la necessità di conoscere il funzionamento interno del computer.
Per raggiungere questo obiettivo, i linguaggi di alto livello vengono strutturati
in modo da rendere la programmazione indipendente dall'hardware del computer; il
compilatore o l'interprete, si occupano di tutti i dettagli relativi alla traduzione
del codice sorgente in codice macchina comprensibile dalla piattaforma hardware
che si sta utilizzando.
I linguaggi di alto livello rappresentano quindi la soluzione ideale per quei
programmatori che non vogliono perdere tempo ad armeggiare, direttamente, con
CPU, FPU, Bus PCI, Bus AGP, interfacce IDE,
EIDE, SCSI, etc; le comodità offerte dai linguaggi di alto livello,
si pagano però a caro prezzo in termini di dimensioni e prestazioni del codice
prodotto. È chiaro, infatti, che più la struttura di un linguaggio si allontana
dall'hardware del computer, più diventa complesso e contorto il lavoro di
traduzione del codice sorgente in codice macchina; tutto ciò porta ad ottenere
programmi eseguibili di dimensioni spropositate e con prestazioni spesso
insoddisfacenti.
I linguaggi di alto livello mostrano tutti i loro limiti nel momento in cui
si presenta la necessità di scrivere programmi potenti ed efficienti, capaci
di sfruttare al massimo l'hardware che il computer ci mette a disposizione; i
programmatori alle prese con i vari Java, VisualBasic, Visual
C++, etc, si trovano davanti ad una vera e propria barriera protettiva, che
impedisce loro di accedere in modo efficiente all'hardware sottostante. Se si
vuole scavalcare questa barriera, esiste una sola strada da seguire e consiste
nell'acquisire una conoscenza approfondita dell'architettura del computer; una
volta che questa conoscenza è stata acquisita, si è in grado di dialogare
direttamente con il computer, utilizzando il suo stesso linguaggio. Come già
sappiamo, il computer parla il linguaggio binario, rappresentato da sequenze di
0 e 1 che, nel loro insieme, formano il codice macchina; per evitare
ai programmatori di dover lavorare direttamente con il codice macchina, è stato
creato il linguaggio Assembly.
A differenza di quanto accade con i linguaggi di alto livello, l'Assembly
non mette a disposizione alcuna procedura predefinita da utilizzare a scatola
chiusa per gestire la memoria, i file su disco, l'input dalla tastiera, l'output
sullo schermo, etc; tutto il lavoro necessario per realizzare queste procedure,
viene lasciato al programmatore. Tutto ciò che l'Assembly mette a
disposizione è rappresentato dal set di istruzioni della CPU; mettendo
assieme queste semplici istruzioni, è possibile fare qualunque cosa purché,
ovviamente, si abbiano le idee chiare.
I programmatori Assembly sparsi in tutto il mondo hanno provveduto a
realizzare e a mettere in circolazione un numero enorme di librerie di procedure
Assembly, destinate a coprire praticamente tutti gli aspetti delle
conoscenze umane; è chiaro però che l'obiettivo fondamentale di chi programma
in Assembly, non è quello di utilizzare queste procedure a scatola chiusa,
ma è quello di capire come scrivere di persona le procedure di cui si ha bisogno.
Nel capitolo precedente è stato messo in evidenza il problema che si trova
davanti chi vuole imparare a programmare in Assembly; se, ad esempio, si
vuole stampare una stringa sullo schermo, bisogna scrivere una apposita procedura
Assembly, ma per scrivere questa procedura, bisogna già conoscere
l'Assembly. Per uscire da questa situazione, l'unica cosa da fare consiste
nel servirsi, inizialmente, di apposite librerie di procedure già pronte per
l'uso; man mano che si impara a programmare in Assembly, si acquisiscono
le conoscenze necessarie per scrivere in proprio queste procedure.
15.1 Le librerie EXELIB e COMLIB
In questo capitolo, viene illustrata una libreria che ci permette di gestire in
modo molto semplice, le operazioni per l'input dalla tastiera e per l'output
sullo schermo; utilizzando le procedure presenti in questa libreria, possiamo
verificare in pratica tutti i concetti esposti nei precedenti capitoli e possiamo
poi procedere in modo più rapido, nell'apprendimento del linguaggio Assembly.
Decomprimendo nella cartella ASMBASE il file EXELIB.ZIP, si ottengono
i seguenti tre file:
- EXELIB.OBJ, che contiene la libreria di procedure da linkare ai
programmi in formato DOS EXE
- EXELIB.INC, che contiene le dichiarazioni delle varie procedure
presenti in EXELIB.OBJ
- EXELIB.TXT, che contiene la descrizione delle varie procedure
presenti in EXELIB.OBJ
Analogamente, decomprimendo nella cartella ASMBASE il file COMLIB.ZIP,
si ottengono i seguenti tre file:
- COMLIB.OBJ, che contiene la libreria di procedure da linkare ai
programmi in formato DOS COM
- COMLIB.INC che contiene le dichiarazioni delle varie procedure
presenti in COMLIB.OBJ
- COMLIB.TXT, che contiene la descrizione delle varie procedure
presenti in COMLIB.OBJ
Sia EXELIB.OBJ che COMLIB.OBJ, contengono le stesse procedure;
l'unica differenza consiste nel fatto che, come è stato già spiegato, la
libreria EXELIB.OBJ è destinata ad essere linkata ai programmi in formato
DOS EXE, mentre la libreria COMLIB.OBJ è destinata ad essere linkata
ai programmi in formato DOS COM.
15.1.1 Parametri in ingresso e valore in uscita di una procedura
Una procedura, per poter svolgere il proprio lavoro, può richiedere una serie di
informazioni, chiamate simbolicamente entry parameters o parametri in
ingresso; le informazioni effettive che il programmatore passa alla procedura,
come parametri in ingresso, prendono il nome di entry arguments o argomenti
in ingresso. Su alcuni libri, i parametri vengono anche chiamati parametri
formali, mentre gli argomenti vengono anche chiamati parametri attuali;
nella sezione Assembly Base, verrà sempre utilizzato il termine parametri
per indicare la lista simbolica di informazioni richieste da una procedura e il termine
argomenti per indicare la lista effettiva di informazioni che il programmatore
passa alla procedura stessa.
Alcune procedure, dopo aver svolto il proprio lavoro, restituiscono a chi le ha
chiamate, una informazione che viene definita exit value o valore in
uscita; spesso, il valore in uscita contiene il risultato delle elaborazioni
a cui sono stati sottoposti gli argomenti ricevuti in ingresso dalla procedura.
Tutte le procedure contenute in EXELIB.OBJ e COMLIB.OBJ, utilizzano
i registri della CPU, sia per ricevere gli eventuali argomenti in ingresso,
sia per restituire un eventuale valore in uscita; come vedremo nei capitoli
successivi, questo metodo, pur non essendo il più raffinato, è sicuramente il
più veloce.
15.1.2 Descrizione delle procedure presenti nella libreria
Esaminiamo ora in dettaglio, l'elenco delle procedure disponibili nella libreria e
il compito svolto da ciascuna di esse; nella descrizione che segue, per ogni
procedura viene utilizzato il termine Entry per indicare la lista simbolica
degli eventuali parametri in ingresso e il termine Exit per indicare
l'eventuale valore in uscita.
15.1.3 Ulteriori dettagli sulle librerie EXELIB e COMLIB
Le procedure presenti in EXELIB e COMLIB, sono molto facili da
utilizzare; in ogni caso, vediamo alcuni chiarimenti relativi al loro funzionamento.
Le due procedure set80x25 e set80x50, hanno lo scopo di selezionare
la modalità testo desiderata per lo schermo; set80x25 attiva la modalità
video testo a 25 righe e 80 colonne, mentre set80x50 attiva
la modalità video testo a 50 righe e 80 colonne.
Non appena si accende il computer, lo schermo viene inizializzato dal BIOS
in modalità testo standard; questa modalità consiste nel gestire lo schermo
attraverso una matrice formata da 25 righe (numerate da 0 a 24)
e 80 colonne (numerate da 0 a 79), per un totale di
80x25=2000 celle. Verso la fine degli anni 80, la IBM ha
introdotto la modalità video grafica VGA standard, che consiste nel gestire
lo schermo attraverso una matrice di 640x480 punti (pixel), ciascuno dei quali
può assumere uno tra 16 colori differenti; trattandosi di uno standard,
questa modalità video viene sicuramente supportata da tutte le schede video
VGA e superiori. La modalità grafica VGA, rende disponibile una nuova
modalità testo formata da 50 righe e 80 colonne; in questo modo, è
possibile visualizzare il doppio delle informazioni rispetto alla modalità testo a
25 righe. Le due procedure set80x25 e set80x50, permettono
appunto di selezionare la modalità testo preferita; pur non essendo obbligatorio,
si consiglia di utilizzare una di queste due procedure all'inizio di ogni programma
che fa uso della libreria EXELIB o COMLIB.
La procedura writeString, permette di stampare una stringa sullo schermo,
a partire dalla posizione indicata dai due registri DH (riga) e DL
(colonna); seguendo la convenzione del linguaggio C (già illustrata in un
precedente capitolo), la stringa da stampare deve terminare con un byte di valore
zero (NUL). Per fare un esempio, la riga:
stringa_c db 'Linguaggio Assembly', 0
definisce una stringa formata da 20 byte, con il ventesimo byte che vale
zero (NUL); è importante che non si faccia confusione tra il simbolo
NUL (che ha codice
ASCII 0) e il simbolo
'0' (che ha codice
ASCII 48).
Nel caso della libreria EXELIB, la procedura writeString riceve nei
registri ES:DI, l'indirizzo logico Seg:Offset della stringa da stampare;
utilizzando la stringa dell'esempio precedente, prima di chiamare writeString
possiamo scrivere:
In questo modo, la coppia ES:DI punterà all'indirizzo logico Seg:Offset
di stringa_c; come vedremo più avanti, nel caso della libreria COMLIB
viene richiesto solo il caricamento in DI della componente Offset
dell'indirizzo della stringa da stampare.
Tutte le procedure del tipo writeUdec, writeSdec, writeHex e
writeBin, permettono di stampare sullo schermo numeri interi nelle principali
ampiezze (8, 16 e 32 bit) e nelle principali basi (10,
16 e 2); anche in questo caso il programmatore, utilizzando i registri
DH e DL, può stabilire in quale zona dello schermo verranno stampati
i numeri.
Tutte le procedure del tipo readUdec, readSdec, readHex e
readBin, permettono di inserire numeri interi dalla tastiera, utilizzando le
tre ampiezze e le tre basi citate prima; l'input avviene nella sestultima riga dello
schermo e si consiglia pertanto di non sovrascrivere questa riga con l'output
prodotto dalle procedure writeString, writeHex, etc.
Le procedure del tipo readUdec e readSdec, sono volutamente prive del
controllo sui limiti del numero che viene inserito; in questo modo, è possibile
verificare quello che succede quando si inseriscono numeri fuori limite. La
procedura readUdec8, ad esempio, dovrebbe ricevere in input un numero intero
senza segno a 8 bit, compreso quindi tra 0 e 255, ma in realtà,
accetta un numero intero senza segno a tre cifre, compreso quindi tra 0 e
999. Analogamente, la procedura readSdec16, dovrebbe ricevere in input
un numero intero con segno a 16 bit, compreso quindi tra -32768 e
+32767, ma in realtà, accetta un numero intero senza segno a cinque cifre,
compreso quindi tra -99999 e +99999.
Per vedere allora quello che accade quando non rispettiamo i limiti, consideriamo
il caso della procedura readUdec8; il numero inserito dal programmatore,
viene restituito nel registro AL che conterrà quindi un valore binario
compreso tra 00000000b e 11111111b. Se proviamo ad inserire il numero
256 e a premere [Invio], la procedura restituisce in AL il valore
0; infatti, 256 (100000000b) è un numero a 9 bit che per
essere inserito negli 8 bit di AL, viene troncato a 00000000b con
perdita del bit più significativo.
Consideriamo ora il caso della procedura readSdec16; se proviamo ad inserire
il numero +32768 e a premere [Invio], verrà restituito in AX il
numero binario 1000000000000000b, che rappresenta il valore 32768 per
i numeri interi senza segno a 16 bit, e il valore -32768 per i numeri
interi con segno a 16 bit in complemento a due. Ricordiamo, infatti, che in
complemento a due:
-32768 = 65536 - 32768 = 32768
Passando, infatti, il valore 1000000000000000b alla procedura writeUdec16,
verrà stampato il numero 32768; passando, invece, il valore 1000000000000000b
alla procedura writeSdec16, verrà stampato proprio il numero -32768.
La procedura showCPU è una delle più importanti della libreria, in quanto
permette di visualizzare, in tempo reale, lo stato della CPU, rappresentato dal
contenuto di ogni suo registro; questa procedura, stampa le sue informazioni nelle
ultime cinque righe dello schermo e quindi, anche in questo caso, bisogna evitare di
sovrascrivere quest'area di output.
15.2 Collegamento di una libreria ad un programma Assembly
Vediamo ora come si deve procedere per linkare ai nostri programmi una libreria di
procedure come EXELIB o COMLIB; tutti i dettagli su questo argomento,
verranno illustrati in modo approfondito in un apposito capitolo.
Supponiamo di scrivere un programma formato dai due file, MAIN.ASM (che
contiene il programma principale) e LIB1.ASM (che contiene una libreria di
procedure da utilizzare in MAIN.ASM); l'aspetto più importante da chiarire,
riguarda il metodo che viene utilizzato per far comunicare tra loro MAIN.ASM
e LIB1.ASM.
Come è stato spiegato in un precedente capitolo, in Assembly i nomi delle
etichette, delle variabili, delle procedure, etc, hanno lo scopo di individuare la
posizione (offset) di queste informazioni, calcolata rispetto al segmento di
programma nel quale sono state definite le informazioni stesse; se, ad esempio, la
procedura writeString inizia a partire dall'offset 0B00h all'interno
di un segmento di programma chiamato CODESEGM, allora, dal punto di vista
della CPU, il nome writeString viene associato all'indirizzo logico
CODESEGM:0B00h.
Se in MAIN.ASM è presente un'istruzione che chiama la procedura
writeString definita in LIB1.ASM, allora è necessario indicare
all'assembler qual è l'indirizzo logico Seg:Offset della procedura stessa;
infatti, chiamare una procedura significa saltare all'indirizzo Seg:Offset
da cui inizia in memoria la procedura stessa.
15.2.1 Le direttive PUBLIC e EXTRN
In sostanza, in fase di assemblaggio di un programma, ogni volta che l'assembler
incontra una chiamata ad una procedura, deve essere in grado di convertire il nome di
quella procedura, in una coppia Seg:Offset che in fase di esecuzione verrà
inserita in CS:IP; per fornire all'assembler tutte le informazioni di cui ha
bisogno, vengono utilizzate le due direttive PUBLIC e EXTRN.
Applicando ad un nome la direttiva PUBLIC, si rende pubblico quel nome, cioè,
lo si rende visibile anche all'esterno del file nel quale è stato definito; se, ad
esempio, in LIB1.ASM definiamo la procedura writeString, preceduta
dalla dichiarazione:
PUBLIC writeString
allora la procedura writeString potrà essere chiamata, sia dall'interno del
modulo LIB1.ASM, sia da un modulo esterno (come MAIN.ASM).
Tutti i nomi di procedure, etichette, variabili, etc, definiti in LIB1.ASM
senza la direttiva PUBLIC, sono invisibili dall'esterno; in questo caso, si
dice che questi nomi sono privati, nel senso che sono visibili (e quindi
utilizzabili) solo all'interno di LIB1.ASM.
Se nel modulo MAIN.ASM vogliamo chiamare la procedura writeString,
definita in LIB1.ASM, allora dobbiamo dire all'assembler che ci stiamo
riferendo al nome di una procedura definita in un modulo esterno; per dare questa
informazione all'assembler, dobbiamo servirci della direttiva EXTRN. Nel
modulo MAIN.ASM dobbiamo quindi scrivere la dichiarazione:
EXTRN writeString
Con le nuove versioni di MASM, per questa direttiva è permessa anche
la sintassi EXTERN.
In relazione alla chiamata di una procedura che si trova in memoria all'indirizzo
logico Seg:Offset, si possono presentare i seguenti due casi:
- La procedura si trova nello stesso segmento di codice (ad esempio,
CODESEGM) dal quale avviene la chiamata; in questo caso, la
CPU deve modificare solamente IP, caricando in questo
registro la componente Offset dell'indirizzo della procedura.
Il registro CS non viene modificato in quanto, al momento della
chiamata, contiene già la stessa componente Seg (CODESEGM)
dell'indirizzo della procedura; a questo punto, la CPU effettua
un salto a CS:IP, chiamato NEAR call (chiamata vicina).
- La procedura si trova in un segmento di codice (ad esempio,
CODESEGM2) differente rispetto a quello (ad esempio, CODESEGM)
dal quale avviene la chiamata; in questo caso, la CPU deve modificare,
sia CS, sia IP. Nella coppia CS:IP viene caricato,
ovviamente, l'indirizzo logico completo Seg:Offset della procedura;
a questo punto, la CPU effettua un salto a CS:IP, chiamato
FAR call (chiamata lontana).
Risulta evidente che gli indirizzi NEAR, essendo costituiti unicamente da
un offset a 16 bit, vengono gestiti dalla CPU più velocemente
rispetto agli indirizzi FAR (che comprendono, sia i 16 bit per la
componente Offset, sia i 16 bit per la componente Seg);
tutti questi concetti, sono stati illustrati in modo molto semplificato in quanto
verranno spiegati in dettaglio in altri capitoli.
Per comodità, tutte le necessarie dichiarazioni EXTRN per le procedure
esterne da utilizzare in un programma, possono essere inserite in appositi file,
chiamati include file; convenzionalmente, per ogni include file relativo
ad una libreria, viene utilizzato lo stesso nome della libreria e l'estensione
INC. Nel caso, ad esempio, della libreria LIB1.ASM, possiamo creare
un apposito include file chiamato LIB1.INC; se il programma MAIN.ASM
vuole servirsi delle procedure presenti in LIB1.ASM, deve avere al suo
interno una direttiva del tipo:
INCLUDE LIB1.INC
Grazie a questa semplice direttiva, tutte le dichiarazioni EXTRN presenti
in LIB1.INC, vengono incorporate nel modulo MAIN.ASM; più avanti
vedremo ulteriori dettagli su questo argomento.
15.2.2 Assembling & linking di un programma diviso in due o più moduli
Una volta chiarito il metodo che permette a MAIN.ASM e LIB1.ASM di
comunicare tra loro, possiamo passare alle fasi di assembling e linking, che ci
permettono di ottenere il programma finale in versione eseguibile; prima di tutto,
bisogna procedere all'assemblaggio separato dei due file (è sottinteso che il
programmatore si trovi già nella directory C:\MASM\ASMBASE).
Con MASM, dobbiamo impartire i comandi:
..\bin\ml /c main.asm
e:
..\bin\ml /c lib1.asm
Se non si sono verificati errori, abbiamo ottenuto i due file in formato oggetto
MAIN.OBJ e LIB1.OBJ e possiamo quindi passare alla fase di linking;
con il MASM, dobbiamo impartire il comando:
..\bin\link main.obj + lib1.obj
Il comportamento predefinito del linker, è quello di assegnare all'eseguibile
il nome del file più a sinistra tra quelli che il programmatore ha specificato
dalla linea di comando; nel nostro caso, si ottiene un eseguibile chiamato
MAIN.EXE.
Se avessimo scritto, invece:
..\bin\link lib1.obj + main.obj
il linker avrebbe assegnato all'eseguibile il nome LIB1.EXE; più avanti
vedremo ulteriori dettagli sulle fasi di assembling e di linking di un programma
suddiviso in due o più moduli.
15.3 L'istruzione CALL
In Assembly, il metodo predefinito per la chiamata di una procedura,
consiste nell'uso dell'istruzione CALL, che verrà illustrata in
dettaglio in un apposito capitolo; per il momento, ci limitiamo ad una
semplice descrizione di questa istruzione, in modo da poterla utilizzare
negli esempi presentati più avanti.
Nella sua forma più semplice, la chiamata di una procedura viene definita come
direct call within segment (chiamata diretta all'interno del segmento);
come è stato spiegato in precedenza, questa situazione si verifica quando la
procedura si trova nello stesso segmento dal quale avviene la chiamata. In un
caso del genere, la CPU ha bisogno di conoscere solo la componente
Offset dell'indirizzo della procedura, in modo da porre IP=Offset;
il registro CS non deve essere modificato, in quanto contiene già una
componente Seg che è la stessa dell'indirizzo della procedura.
Il codice macchina di questa forma dell'istruzione CALL, è composto
dall'Opcode 11101000b (E8h), seguito da un valore a 16 bit;
la CPU somma questo valore all'offset dell'istruzione successiva alla
CALL e ottiene la componente Offset dell'indirizzo della procedura
da chiamare.
Per chiarire questo concetto, vediamo un esempio pratico; supponiamo di avere
le seguenti due istruzioni:
e supponiamo che la prima istruzione si trovi all'offset 00BBh di un segmento
di programma chiamato CODESEGM. Il codice macchina dell'istruzione CALL
è formato dall'Opcode E8h (1 byte), seguito da un valore a 16
bit (2 byte), per un totale di 3 byte; di conseguenza, l'istruzione
successiva alla CALL si trova all'offset:
00BBh + 0003h = 00BEh
Supponiamo ora che la procedura writeString parta dall'offset 0AC2h
nello stesso blocco CODESEGM; di conseguenza, l'assembler tradurrà le due
istruzioni precedenti, nel codice macchina:
Quando la CPU arriva all'offset 00BBh del blocco CODESEGM,
incontra l'istruzione contenente la NEAR call e calcola:
0A04h + 00BEh = 0AC2h
che è proprio l'offset da cui inizia writeString; la CPU quindi carica
0AC2h in IP e salta a CS:IP, cioè a CODESEGM:0AC2h
(NEAR call).
Cosa succede quando writeString termina il suo lavoro?
È chiaro che se non si prendono delle precauzioni, la CPU non è in grado
di sapere da quale punto riprenderà l'esecuzione del programma; osservando il
codice macchina illustrato nell'esempio precedente, risulta evidente che, una volta
che writeString ha terminato il suo lavoro, l'esecuzione del programma
dovrà riprendere dall'istruzione successiva alla CALL e quindi dall'offset
00BEh. In base a queste considerazioni, la CPU, quando incontra
l'istruzione per la NEAR call di writeString, esegue le seguenti
operazioni:
- Sottrae 2 a SP per fare spazio a una nuova WORD nello
stack.
- Salva all'indirizzo SS:SP l'offset (00BEh) dell'istruzione
successiva alla CALL.
- Carica in IP l'offset dell'indirizzo di writeString e salta
a CS:IP.
Come si vedrà in un apposito capitolo, una procedura termina con l'istruzione
RET (return from procedure); quando la CPU incontra una istruzione
RET (che nel nostro caso è un NEAR return da writeString),
esegue in sequenza le seguenti operazioni:
- Estrae un valore a 16 bit dall'indirizzo SS:SP dello stack
e lo carica in IP.
- Incrementa SP di 2, in modo da recuperare lo spazio appena
liberato nello stack.
- Salta all'indirizzo CS:IP.
Se il programmatore non ha commesso errori nella gestione dello stack (all'interno
di writeString), il valore a 16 bit che la CPU estrae
dall'indirizzo SS:SP rappresenta proprio l'offset 00BEh salvato in
precedenza dalla CPU stessa; di conseguenza, l'indirizzo della prossima
istruzione da eseguire, contenuto in CS:IP, sarà proprio quello
dell'istruzione successiva alla CALL.
Analizziamo ora un'altra forma della chiamata di una procedura, che viene definita
come direct call intersegment (chiamata diretta intersegmento); come è stato
spiegato in precedenza, questa situazione si verifica quando la procedura si trova in
un segmento diverso da quello nel quale avviene la chiamata. In un caso del genere,
la CPU ha bisogno di conoscere l'indirizzo completo Seg:Offset della
procedura, in modo da porre CS=Seg e IP=Offset; a causa del salto da
un segmento ad un altro, è necessario quindi modificare anche CS.
Il codice macchina di questa forma dell'istruzione CALL è costituito
dall'Opcode 10011010b (9Ah), seguito da un valore a 32 bit;
questo valore a 32 bit rappresenta l'indirizzo completo Seg:Offset
della procedura da chiamare. Per chiarire questo concetto, vediamo un esempio pratico;
supponiamo di avere le seguenti due istruzioni:
e supponiamo che la prima istruzione si trovi all'offset 00BBh di un segmento
di programma chiamato CODESEGM. Il codice macchina dell'istruzione CALL
è formato dall'Opcode 9Ah (1 byte), seguito da un valore a 32
bit (4 byte), per un totale di 5 byte; di conseguenza, l'istruzione
successiva alla CALL si trova all'offset:
00BBh + 0005h = 00C0h
Supponiamo ora che la procedura writeString parta dall'offset 0AC2h
di un blocco codice chiamato CODESEGM2; di conseguenza, l'assembler tradurrà
le due istruzioni precedenti nel codice macchina:
Notiamo che se l'assembler non è in grado di determinare in anticipo l'indirizzo
di una procedura (definita, ad esempio, in un modulo esterno), delega tale compito
al linker.
Quando la CPU arriva all'offset 00BBh del blocco CODESEGM,
incontra l'istruzione contenente la FAR call, ed esegue le seguenti operazioni:
- Sottrae 4 a SP per fare spazio a una nuova DWORD nello
stack.
- Salva all'indirizzo SS:SP, l'indirizzo completo CODESEGM:00C0h
dell'istruzione successiva alla CALL.
- Carica in CS:IP l'indirizzo completo (CODESEGM2:0AC2h) di
writeString e salta a CS:IP.
Al termine di writeString, è presente la solita istruzione RET, che
in questo caso è un FAR return da writeString; quando la CPU
incontra l'istruzione RET, esegue in sequenza le seguenti operazioni:
- Estrae un valore a 32 bit dall'indirizzo SS:SP dello stack
e lo carica in CS:IP.
- Incrementa SP di 4, in modo da recuperare lo spazio appena
liberato nello stack.
- Salta all'indirizzo CS:IP.
Se il programmatore non ha commesso errori nella gestione dello stack (all'interno di
writeString), il valore a 32 bit che la CPU estrae dall'indirizzo
SS:SP, rappresenta proprio l'indirizzo CODESEGM:00C0h salvato in precedenza
dalla CPU stessa; di conseguenza, l'indirizzo della prossima istruzione da
eseguire, contenuto in CS:IP, sarà proprio quello dell'istruzione successiva
alla CALL.
È importantissimo ribadire ancora una volta che in Assembly, il programmatore
ha il controllo diretto su molti delicati aspetti legati al programma in esecuzione;
in particolare, il programmatore ha il dovere di gestire correttamente lo stack,
altrimenti la CPU non è in grado di eseguire con successo le fasi descritte
negli esempi precedenti. Se, ad esempio, all'interno di writeString il
programmatore salva con PUSH il contenuto di un registro e poi dimentica di
ripristinare lo stack con POP, ciò che accade è che quando la CPU
incontra l'istruzione RET, invece di estrarre dallo stack l'indirizzo della
prossima istruzione da eseguire, estrae quella che, in gergo, viene definita
"spazzatura"; la CPU carica questa spazzatura in CS:IP e compie un
"salto nel buio"!
Generalmente, questa situazione manda in crash il programma in esecuzione; per il
momento comunque, non bisogna preoccuparsi di questi aspetti, perché verranno
spiegati in dettaglio in altri capitoli.
15.4 Programma di prova EXETEST2.ASM per la libreria EXELIB
Dopo aver esposto tutti i necessari concetti teorici, possiamo passare ad un
esempio pratico, rappresentato da un programma che ci permetterà di testare
le procedure contenute nella libreria EXELIB.OBJ; a tale proposito,
ci serviremo del programma EXETEST2.ASM, illustrato dalla Figura 15.1.
Analizzando il listato di EXETEST2.ASM, notiamo che la prima riga
contiene la direttiva:
INCLUDE EXELIB.INC
che permette di includere, comodamente, tutte le necessarie direttive
EXTRN, relative alle procedure definite nel modulo EXELIB.OBJ.
Tutte le procedure dichiarate nel file EXELIB.INC sono di tipo
FAR; infatti, queste procedure vengono definite nel modulo
EXELIB.OBJ, all'interno di un apposito segmento di codice, che è
diverso dal segmento CODESEGM presente nel modulo
EXETEST2.ASM.
Siccome non conosciamo il nome del segmento di codice, interno a
EXELIB.OBJ, nel quale sono state definite le varie procedure esterne,
è importante che le relative direttive EXTRN vengano poste in un'area
del modulo EXETEST2.ASM che deve trovarsi al di fuori di qualsiasi
segmento di programma; come si nota in Figura 15.1, il punto più adatto per
l'inserimento di queste direttive (poste nel file EXELIB.INC), è
rappresentato dalla parte iniziale del modulo che contiene il programma
principale.
Nel blocco DATASEGM di Figura 15.1, vengono definite due stringhe C,
chiamate strTitle e strExit; l'unico aspetto degno di nota, è
rappresentato dal metodo che viene utilizzato per definire stringhe molto
lunghe. Anziché definire una stringa su una sola gigantesca linea, si può
fare ricorso al metodo che è stato seguito in Figura 15.1 per strTitle;
come sappiamo, l'assembler provvede a disporre in memoria, in modo consecutivo
e contiguo, tutti i byte che formano strTitle.
Nel blocco CODESEGM di Figura 15.1, vengono chiamate quasi tutte le procedure
definite in EXELIB.OBJ; in questo modo, è possibile analizzare il loro
funzionamento.
Come possiamo notare, vengono inizialmente chiamate, set80x25 (che seleziona
la modalità video testo), hideCursor (che nasconde il cursore lampeggiante)
e clearScreen (che cancella lo schermo); subito dopo, viene chiamata la
procedura clearRegisters, che provvede ad azzerare i registri EAX,
EBX, ECX, EDX, ESI e EDI.
Tutte le procedure definite in EXELIB.OBJ, ad eccezione di showCPU,
preservano i registri (a 8, 16 e 32 bit) che utilizzano; la
procedura clearRegisters non preserva, ovviamente, il contenuto dei
6 registri da azzerare. Questo aspetto diventa molto importante nel
momento in cui non vogliamo che la chiamata di una procedura alteri il
contenuto di determinati registri che stiamo utilizzando nel programma principale;
eventualmente, è opportuno che vengano prese le necessarie precauzioni. Supponiamo,
ad esempio, di voler chiamare una procedura Proc1, che altera il registro
AX senza preservarne il vecchio contenuto; se, nel programma principale, non
vogliamo perdere il vecchio contenuto di AX, dobbiamo scrivere istruzioni
del tipo:
Subito dopo clearRegisters, viene chiamata la procedura showCPU che
visualizza, in tempo reale, il contenuto di tutti i registri della CPU;
grazie a showCPU, possiamo analizzare, in particolare, il contenuto iniziale
di CS, DS, ES, SS, SP e IP, in modo da
conoscere tutti i dettagli relativi all'area della memoria nella quale è stato
caricato il nostro programma per la fase di esecuzione.
Proseguendo nell'analisi del listato di Figura 15.1, fissiamo la nostra attenzione
sulla chiamata della procedura writeString per la visualizzazione della
stringa strTitle; nel caso della libreria EXELIB.OBJ, la procedura
writeString si trova in un modulo esterno e non ha la più pallida idea
di quali siano le caratteristiche dei segmenti di codice, dati e stack, definiti
nel modulo EXETEST2.ASM. Proprio per questo motivo, writeString
ha bisogno di conoscere l'indirizzo completo Seg:Offset della stringa da
visualizzare; questa informazione, come viene mostrato dalla Figura 15.2, deve essere
passata attraverso la coppia ES:DI.
Osserviamo il metodo che viene seguito per caricare, in un colpo solo, il valore
00h in DH e il valore 0Eh in DL; in base alle proprietà
dei numeri esadecimali, espressi nel sistema posizionale, le due cifre più
significative (00h) di 000Eh, si posizionano nei 16 bit più
significativi (DH) di DX, mentre le due cifre meno significative
(0Eh) di 000Eh, si posizionano nei 16 bit meno significativi
(DL) di DX.
La Figura 15.2 ci permette anche di illustrare in pratica, la differenza concettuale
che esiste tra i parametri in ingresso e gli argomenti in ingresso;
writeString richiede tre parametri in ingresso e cioè, un BYTE
che rappresenta l'indice di riga, un BYTE che rappresenta l'indice di colonna
e una DWORD che rappresenta un indirizzo FAR. Gli argomenti che
il programmatore passa a writeString in ingresso sono, rispettivamente,
00h, 0Eh e la coppia seg(strTitle), offset(strTitle).
Come è stato già detto, tutte le procedure del tipo readUdec,
readSdec, readHex e readBin, utilizzano la sestultima riga per
mostrare i numeri che l'utente inserisce dalla tastiera; inoltre, la riga precedente
a quella di input, mostra una stringa che fornisce informazioni sul tipo e sui limiti
inferiore e superiore dei numeri da inserire.
15.4.1 Assembling & Linking del programma EXETEST2
Dopo aver chiarito questi aspetti, possiamo passare alle fasi di assembling e di
linking del nostro programma.
Come è stato detto in precedenza, la fase di assembling coinvolge tutti i moduli
che contengono il codice sorgente di un programma distribuito su due o più file;
nel nostro caso, il modulo EXELIB.OBJ è già in formato object code,
per cui, dobbiamo procedere con l'assemblaggio del solo modulo EXETEST2.ASM.
Con il MASM, il comando da impartire dalla directory ASMBASE è:
..\bin\ml /c /Fl exetest2.asm
Se vogliamo un assemblaggio case-sensitive, dobbiamo servirci dell'opzione
/Cp e impartire quindi il comando:
..\bin\ml /c /Cp /Fl exetest2.asm
Premendo ora il tasto [Invio], vengono generati i due file EXETEST2.OBJ
e EXETEST2.LST; apriamo subito con un editor il file EXETEST2.LST per
poter esaminare alcuni aspetti interessanti.
Nel listing file notiamo subito che tutte le direttive EXTRN presenti
in EXELIB.INC, sono state incorporate in EXETEST2.LST; inoltre, le
stesse direttive EXTRN sono state sistemate al di fuori di qualsiasi segmento
di programma presente in EXETEST2.LST. Ciò permette all'assembler di generare
l'opportuno codice macchina relativo alla chiamata delle varie procedure; infatti,
osserviamo che, ad esempio, in relazione alla chiamata di set80x25, viene
generato il codice macchina:
9Ah 00000000 ;FAR call
9Ah è proprio l'Opcode della "chiamata diretta intersegmento; come si
può notare, l'assembler sta delegando al linker il compito di calcolare l'indirizzo
logico Seg:Offset della procedura esterna set80x25 di tipo Far.
Nella symbol table, la procedura set80x25 viene classificata
dall'assembler come:
set80x25 Far ----:---- Extern
Passiamo ora alla fase di linking del nostro programma; con il MASM, il
comando da impartire è:
..\bin\link /map exetest2.obj + exelib.obj
Premendo il tasto [Invio], parte la fase di linking che, in assenza di
errori, porta alla generazione del file in formato eseguibile EXETEST2.EXE
e del map file EXETEST2.MAP; analizziamo il lavoro svolto dal linker per
il collegamento dei due moduli EXETEST2.OBJ e EXELIB.OBJ.
Prima di tutto, bisogna premettere che il modulo EXELIB.OBJ contiene
un segmento dati del tipo:
LIBIODATA SEGMENT PARA PRIVATE USE16 'DATA'
e un segmento di codice del tipo:
LIBIOCODE SEGMENT PARA PUBLIC USE16 'CODE'
- Il linker inizia ad esaminare il modulo EXETEST2.OBJ, dove trova
il primo segmento DATASEGM.
- Il linker cerca un eventuale altro segmento DATASEGM nel modulo
EXELIB.OBJ e non trovandolo, crea il primo blocco di programma,
rappresentato dallo stesso DATASEGM con allineamento PARA.
- Il linker cerca altri segmenti di classe 'DATA' e trova il
segmento LIBIODATA nel modulo EXELIB.OBJ.
- Non esistendo altri segmenti LIBIODATA, il linker crea il secondo
blocco di programma, rappresentato dallo stesso LIBIODATA con allineamento
PARA.
- Non esistendo altri segmenti di classe 'DATA', il linker torna
al modulo EXETEST2.OBJ, dove trova il segmento CODESEGM.
- Il linker cerca un eventuale altro segmento CODESEGM nel modulo
EXELIB.OBJ e non trovandolo, crea il terzo blocco di programma,
rappresentato dallo stesso CODESEGM con allineamento PARA.
- Il linker cerca altri segmenti di classe 'CODE' e trova il
segmento LIBIOCODE nel modulo EXELIB.OBJ.
- Non esistendo altri segmenti LIBIOCODE, il linker crea il quarto
blocco di programma, rappresentato dallo stesso LIBIOCODE con
allineamento PARA.
- Non esistendo altri segmenti di classe 'CODE', il linker torna
al modulo EXETEST2.OBJ, dove trova il segmento STACKSEGM.
- Il linker cerca un eventuale altro segmento STACKSEGM nel modulo
EXELIB.OBJ e non trovandolo, crea il quinto blocco di programma,
rappresentato dallo stesso STACKSEGM con allineamento PARA.
- Il linker non trova altri segmenti, per cui la fase di linking è
terminata.
Le considerazioni appena esposte, vengono confermate dal map file di
Figura 15.3.
Il linker ha calcolato per l'entry point, l'indirizzo logico
0042h:0000h; osserviamo, infatti, che il blocco CODESEGM parte
dall'indirizzo fisico 00420h (allineato al paragrafo), a cui corrisponde
l'indirizzo logico normalizzato 0042h:0000h. Nel listato di Figura 15.1,
notiamo che l'etichetta start si trova all'offset 0000h di
CODESEGM; quest'offset non viene rilocato dal linker, per cui l'indirizzo
logico dell'entry point è proprio 0042h:0000h.
In Figura 15.3 possiamo anche notare che, grazie all'attributo Class, il
linker ha potuto stabilire il criterio di ordinamento per i vari blocchi di
memoria; infatti, il nostro programma contiene, prima i blocchi di classe
'DATA', poi i blocchi di classe 'CODE' e infine il blocco di
classe 'STACK'.
Cosa succede se effettuiamo il linking con il comando:
..\bin\link /map exelib.obj + exetest2.obj
In tal caso, il linker inizia ad esaminare per primo il modulo EXELIB.OBJ;
il primo segmento che viene incontrato è LIBIODATA. Alla fine, si ottiene
un eseguibile che viene chiamato EXELIB.EXE e un map file che viene
chiamato EXELIB.MAP; la struttura interna di EXELIB.EXE viene
illustrata proprio dal map file di Figura 15.4.
Come si può notare, questa volta il blocco CODESEGM viene fatto partire
dall'indirizzo fisico 01000h, a cui corrisponde l'indirizzo logico
normalizzato 0100h:0000h; di conseguenza, l'entry point calcolato
dal linker si viene a trovare all'indirizzo logico 0100h:0000h.
15.4.2 Esecuzione del programma EXETEST2.EXE
Passiamo ora alla fase di esecuzione del file EXETEST2.EXE; posizionandoci
nella cartella ASMBASE, dal prompt del DOS impartiamo il
comando:
exetest2
e premiamo il tasto [Invio].
Appena inizia l'esecuzione di EXETEST2.EXE, vedremo comparire in fondo
allo schermo una serie di informazioni come quelle mostrate in Figura 15.5; queste
informazioni si riferiscono alla richiesta di input che arriva dalla procedura
readUdec8 e allo stato della CPU mostrato dalla procedura
showCPU.
La procedura showCPU, chiamata proprio all'inizio del nostro programma,
mostra il contenuto iniziale dei registri della CPU; analizziamo, in
particolare, l'importante contenuto iniziale dei registri CS, DS,
ES, SS, SP e IP (si tenga presente che le
informazioni contenute nei registri di segmento, possono variare da computer a
computer).
Il registro ES non ha subito ancora alcuna modifica, per cui contiene
di sicuro la componente Seg dell'indirizzo logico iniziale del program
segment assegnato a EXETEST2.EXE; possiamo dire allora che il nostro
programma, parte in memoria dall'indirizzo logico 1859h:0000h, a cui
corrisponde l'indirizzo fisico 18590h.
I primi 256 byte (0100h byte) del program segment sono
occupati dal PSP; in base al map file di Figura 15.3, il primo blocco
del nostro programma è DATASEGM, che quindi parte dall'indirizzo
fisico:
18590h + 0100h = 18690h
All'indirizzo fisico 18690h corrisponde l'indirizzo logico normalizzato
1869h:0000h; la componente 1869h di questo indirizzo, viene
assegnata a DATASEGM. Prima della chiamata di showCPU, il valore
DATASEGM è stato assegnato a DS (vedere il listato di Figura 15.1);
infatti, in Figura 15.5 vediamo che DS=1869h.
In base al map file di Figura 15.3, il blocco CODESEGM si trova a
00420h byte di distanza dall'inizio di DATASEGM; possiamo dire
allora che CODESEGM parte in memoria dall'indirizzo fisico:
18690h + 00420h = 18AB0h
All'indirizzo fisico 18AB0h corrisponde l'indirizzo logico normalizzato
18ABh:0000h, che coincide anche con l'indirizzo logico dell'entry
point; la componente 18ABh di questo indirizzo viene assegnata a
CODESEGM. Non appena inizia l'esecuzione del nostro programma, il valore
CODESEGM viene utilizzato quindi per inizializzare CS; infatti, in
Figura 15.5 vediamo che CS=18ABh.
Il valore (0019h) di IP visibile in Figura 15.5, si riferisce al
contenuto dell'instruction pointer al momento della prima chiamata di
showCPU; infatti, analizzando il file EXETEST2.LST, si scopre
che la prima chiamata di showCPU si trova proprio all'indirizzo logico
CODESEGM:0019h.
In base al map file di Figura 15.3, il blocco STACKSEGM si trova a
01140h byte di distanza dall'inizio di DATASEGM; possiamo dire
allora che STACKSEGM parte in memoria dall'indirizzo fisico:
18690h + 01140h = 197D0h
All'indirizzo fisico 197D0h, corrisponde l'indirizzo logico normalizzato
197Dh:0000h; la componente 197Dh di questo indirizzo, viene assegnata
a STACKSEGM. Non appena inizia l'esecuzione del nostro programma, il valore
STACKSEGM viene utilizzato quindi per inizializzare SS; infatti, in
Figura 15.5 vediamo che SS=197Dh.
Il registro SP viene inizializzato con il valore 0400h, così come
avevamo richiesto nel listato di Figura 15.1; è importante notare che in modalità
reale a 32 bit, i 16 bit più significativi di ESP valgono
0000h.
Il fatto che il contenuto di SP resti costante nel corso dell'esecuzione
del programma, indica che le varie procedure chiamate gestiscono correttamente
lo stack (nel senso che, all'interno delle procedure, le istruzioni PUSH
sono bilanciate perfettamente dalle istruzioni POP). Se, ad esempio,
ci accorgiamo che SP=0400h prima della chiamata di una procedura e
SP=03FCH dopo la chiamata, allora c'è sicuramente un errore di gestione
dello stack all'interno della procedura stessa; ricordiamoci che in Assembly,
lo strumento migliore per scovare gli errori è il nostro cervello!
Per veder variare il contenuto di SP, possiamo scrivere istruzioni del
tipo:
In questo caso, showCPU mostra per SP il valore 03FEh;
infatti, a causa dell'inserimento di una WORD nello stack, si ha:
SP = 0400h - 0002h = 03FEh
Subito dopo la chiamata di showCPU, l'istruzione POP riequilibra
lo stack estraendo una WORD dall'indirizzo logico SS:SP che, in
quel momento, vale 197Dh:03FEh.
In relazione ai vari flags, in Figura 15.5 notiamo, in particolare, che PF=1
e ZF=1; ciò è dovuto al fatto che prima di showCPU, viene
chiamata la procedura clearRegisters, che azzera alcuni registri. In
seguito all'azzeramento di un qualsiasi registro, si ottiene, ovviamente,
ZF=1; ricordiamo, infatti, che ZF viene posto a livello logico
1 ogni volta che una operazione produce un risultato pari a zero. I
primi 8 bit del registro appena azzerato valgono 00h e quindi
contengono un numero pari (zero) di bit a livello logico 1 (lo zero
viene trattato come numero pari); di conseguenza, il parity flag PF viene
posto a livello logico 1.
Nel corso dell'esecuzione del programma, vengono chiamate le procedure del tipo
readUdec, readSdec, readHex e readBin, che richiedono
all'utente l'inserimento di numeri interi nelle tre ampiezze 8, 16
e 32 bit e nelle tre basi 10, 16 e 2; ogni numero
inserito, viene poi visualizzato sullo schermo dalle procedure del tipo
writeUdec, writeSdec, writeHex e writeBin.
Per ogni numero inserito, viene chiamata showCPU che mostra la nuova
situazione dei registri della CPU; in particolare, si può notare che
in AL/AX/EAX sono presenti i numeri appena inseriti, mentre in DH
e DL sono presenti le coordinate di schermo che l'utente ha specificato
per l'output. Come è stato detto in precedenza, il contenuto del registro
IP si riferisce ai vari punti del programma, nei quali viene chiamata
la procedura showCPU.
15.5 Programma di prova COMTEST2.ASM per la libreria COMLIB
Le considerazioni appena svolte, sono relative al collegamento di una libreria
di procedure ad un programma Assembly destinato ad essere convertito in
un eseguibile in formato EXE; nel seguito del capitolo esamineremo,
invece, il caso del collegamento di una libreria di procedure ad un programma
Assembly destinato ad essere convertito in un eseguibile in formato
COM.
Come già sappiamo, un programma in formato COM è costituito da un unico
segmento destinato a contenere, codice, dati e stack; questo significa che non
possiamo utilizzare la libreria EXELIB, la quale contiene al suo interno
due segmenti di programma distinti (uno per il codice e uno per i dati privati).
Proprio per questo motivo, si rende necessaria una versione modificata della
libreria EXELIB, destinata esplicitamente ai programmi in formato COM;
si tratta della libreria COMLIB che, come è stato già detto, contiene
le stesse procedure di EXELIB.
Per testare la libreria COMLIB, ci serviamo del file COMTEST2.ASM,
illustrato in Figura 15.6; si tratta della versione in formato COM, del file
EXETEST2.ASM di Figura 15.1.
Notiamo subito che all'interno di COMTEST2.ASM è presente un unico
segmento di programma del tipo:
COMSEGM SEGMENT PARA PUBLIC USE16 'CODE'
Il nome e gli attributi di questo segmento sono (e devono essere) identici a
quelli dell'unico segmento di programma definito nel modulo COMTEST.OBJ.
Il listato del programma di Figura 15.6 è assolutamente identico al listato del
programma di Figura 15.1; l'unica importante eccezione è rappresentata dalla
lista di argomenti che vengono passati alla procedura writeString.
Abbiamo visto che, nel caso della libreria EXELIB.OBJ, la procedura
writeString richiede l'indirizzo completo Seg:Offset (indirizzo
FAR) della stringa da stampare; ciò accade in quanto writeString
non ha altro modo per conoscere il segmento di programma nel quale è stata
definita la stringa stessa.
Nel caso, invece, della libreria COMLIB.OBJ, la procedura writeString
richiede la sola componente Offset (indirizzo NEAR) dell'indirizzo
della stringa da stampare; ciò accade in quanto writeString dà per scontato
che tutti i dati del programma siano stati definiti all'interno dell'unico blocco
COMSEGM e quindi deduce che l'indirizzo logico completo della stringa da
stampare, è sicuramente COMSEGM:Offset.
Una diretta conseguenza della presenza di un unico segmento di programma, è
data dal fatto che, all'interno del file COMLIB.INC, troviamo dichiarazioni
del tipo:
EXTRN set80x25: NEAR
In presenza del solo segmento COMSEGM, le varie procedure della libreria
vengono chiamate con una NEAR call; la CPU ha bisogno solamente di
caricare in IP la componente Offset dell'indirizzo della procedura
da chiamare, in quanto CS contiene già il valore COMSEGM.
15.5.1 Assembling & Linking del programma COMTEST2.ASM
La fase di assemblaggio del programma COMTEST2.ASM è del tutto simile
al caso di EXETEST2.ASM; con il MASM, il comando da impartire è:
..\bin\ml /c /Fl comtest2.asm
Premendo ora il tasto [Invio], vengono generati i due file COMTEST2.OBJ
e COMTEST2.LST; aprendo con un editor il file COMTEST2.LST, possiamo
constatare che, ad esempio, nel caso della chiamata alla procedura set80x25,
l'assembler ha generato il codice macchina:
E8h 0000h ; NEAR call
Inoltre, nella symbol table, la procedura set80x25 viene classificata
dall'assembler come:
set80x25 Near ----:---- Extern
Come al solito, l'assembler sta delegando al linker il compito di calcolare
l'indirizzo logico Seg:Offset della procedura esterna set80x25
di tipo Near.
In una situazione come quella di Figura 15.6, si può anche inserire la direttiva
INCLUDE all'interno del blocco COMSEGM; in tal caso, esaminando il
file COMTEST2.LST, si nota che nella symbol table l'assembler ha
inserito informazioni del tipo:
set80x25 Near COMSEGM:---- Extern
Passiamo ora alla fase di linking del nostro programma; con il MASM, il
comando da impartire è:
c:\masm\bin\link /tiny /map comtest2.obj + comlib.obj
Premendo il tasto [Invio] parte la fase di linking che, in assenza di
errori, porta alla generazione del file in formato eseguibile COMTEST2.COM
e del map file COMTEST2.MAP; analizziamo il lavoro svolto dal linker per
il collegamento dei due moduli COMTEST2.OBJ e COMLIB.OBJ.
- Il linker inizia ad esaminare il modulo COMTEST2.OBJ, dove trova
il primo segmento COMSEGM.
- Il linker cerca un eventuale altro segmento COMSEGM nel modulo
COMLIB.OBJ e dopo averlo trovato, lo unisce all'altro COMSEGM,
ottenendo così il blocco unico del nostro programma; questo blocco è
rappresentato dalla unione dei due COMSEGM, con allineamento PARA.
- Non esistendo, ovviamente, altri segmenti, la fase di linking è
terminata.
Le considerazioni appena esposte, vengono confermate dal map file di
Figura 15.7.
Ovviamente, l'entry point del nostro programma in formato COM si
trova all'indirizzo logico 0000h:0100h.
Come si può facilmente immaginare, nel caso dei programmi in formato COM,
diventa importante l'ordine con il quale si passano i vari moduli al linker;
infatti, se proviamo ad eseguire il comando:
c:\masm\bin\link /tiny /map comlib.obj + comtest2.obj
otteniamo il messaggio di avvertimento:
LINK: warning L4055: start address not equal to 0x100 for /TINY
Il linker ci sta dicendo che è stato generato un eseguibile in formato
COM il cui entry point si trova ad un offset diverso da
0100h!
Il perché di questo messaggio è abbastanza chiaro; infatti, il linker inizia
ad esaminare per primo il modulo COMLIB.OBJ, dove trova il segmento
COMSEGM. A questo segmento, viene aggiunto l'altro COMSEGM
presente in COMTEST2.OBJ; in questo modo, l'entry point del
programma, non solo si viene a trovare ad un offset maggiore di 0100h,
ma viene preceduto dal codice e dai dati definiti nel modulo COMLIB.OBJ.
15.5.2 Esecuzione del programma COMTEST2.COM
Passiamo ora alla fase di esecuzione del file COMTEST2.COM; posizionandoci
nella cartella ASMBASE, dal prompt del DOS impartiamo il
comando:
comtest2
e premiamo il tasto [Invio].
Appena inizia l'esecuzione di COMTEST2.COM, vedremo comparire in fondo allo
schermo una serie di informazioni come quelle mostrate in Figura 15.8; queste
informazioni si riferiscono alla richiesta di input che arriva dalla procedura
readUdec8 e allo stato della CPU mostrato dalla procedura
showCPU.
La procedura showCPU, chiamata proprio all'inizio del nostro programma,
mostra il contenuto iniziale dei registri della CPU; analizziamo, in
particolare, l'importante contenuto iniziale dei registri CS, DS,
ES, SS, SP e IP (come è stato già detto, le
informazioni contenute nei registri di segmento possono variare da computer a
computer).
Come sappiamo, in un programma in formato COM il SO inizializza i
registri di segmento CS, DS, ES e SS, in modo che
puntino tutti all'unico blocco presente; infatti, nel caso di Figura 15.8
vediamo che a questi quattro registri di segmento è stato assegnato lo stesso
valore iniziale 1859h.
In relazione al registro IP, il valore 010Ch si riferisce, come al
solito, al contenuto dell'instruction pointer al momento della prima
chiamata di showCPU; infatti, analizzando il file COMTEST2.LST, si
scopre che la prima chiamata di showCPU si trova proprio all'indirizzo
logico COMSEGM:010Ch.
La Figura 15.8 conferma anche il fatto che, in un programma in formato COM,
il SO inizializza il registro SP con il valore FFFEh; nel
caso quindi del programma COMTEST2.COM, l'indirizzo logico iniziale
della cima dello stack (TOS) è 1859h:FFFEh.
15.6 I batch file
In un precedente capitolo è stato spiegato che il DOS fornisce un
particolare tipo di file eseguibile, chiamato batch file; si tratta
di un normalissimo file di testo, contenente al suo interno una sequenza di
comandi DOS eseguibili.
I batch file si rivelano molto utili nel momento in cui abbiamo bisogno
di automatizzare alcune operazioni particolarmente fastidiose; pensiamo, ad
esempio, alla necessità di riassemblare un intero programma formato da numerosi
moduli.
Un batch file può essere reso molto più flessibile, grazie alla
possibilità di utilizzare alcune particolari "pseudo-istruzioni", chiamate
comandi batch; la Figura 15.9 illustra i comandi batch principali
e l'effetto prodotto da ciascuno di essi (per maggiori dettagli, si consiglia
di consultare i manuali del DOS).
Supponiamo, ad esempio, di voler automatizzare le fasi di assembling e linking
del programma EXETEST2.ASM; a tale proposito, possiamo creare un apposito
batch file chiamato EXETEST2.BAT. Il nome assegnato ad un
batch file deve avere obbligatoriamente l'estensione BAT; la Figura
15.10 illustra la struttura interna (volutamente contorta) di EXETEST2.BAT.
Come si può notare, le etichette devono iniziare con il simbolo ':'
(due punti); inoltre, è importante osservare che l'istruzione da eseguire
nel caso in cui sia verificata la condizione specificata da IF, deve
trovarsi sulla stessa riga dello stesso IF.
Grazie al batch file di Figura 15.10, ogni volta che vogliamo ricreare
il programma EXETEST2.EXE (ad esempio, dopo una modifica del codice
sorgente), non dobbiamo fare altro che impartire il comando:
exetest2.bat
È importante ricordare che, se si impartisce il comando exetest2 senza
specificare l'estensione, il DOS esegue il primo che incontra tra i file,
EXETEST2.COM, EXETEST2.EXE e EXETEST2.BAT; proprio per
questo motivo, nel nostro esempio dobbiamo necessariamente impartire il comando
completo exetest2.bat, altrimenti, il DOS esegue exetest2.exe
(se esiste).
Naturalmente, non siamo obbligati a scrivere un batch file complesso,
come quello di Figura 15.10; possiamo anche limitarci a scrivere i soli comandi
visibili in Figura 15.11.
Se ora vogliamo creare un batch file per un altro programma (ad esempio,
EXETEST3.ASM), siamo costretti a riscrivere completamente l'elenco di
istruzioni visibili in Figura 15.10; per evitare questo fastidio, possiamo
servirci del comando SET che ci permette di ridurre al minimo il lavoro
da svolgere.
Il comando SET crea una variabile simbolica (ad esempio, NomeVar)
a cui può essere assegnato un valore; successivamente, tale variabile può essere
referenziata nel batch file con la sintassi %NomeVar%.
Per avere allora una versione "riciclabile" del batch file di Figura 15.10,
possiamo procedere come si vede in Figura 15.12.
Per adattare ora EXETEST2.BAT ad un nuovo programma (ad esempio,
EXETEST3.ASM), non dobbiamo fare altro che modificare solamente la riga
contenente il comando SET e poi salvare il file assegnandogli il nome
EXETEST3.BAT.
Possiamo rendere ancora più generale l'esempio di Figura 15.12 sfruttando il
fatto che i batch file sono in grado di gestire una serie di argomenti
specificati dall'utente; all'interno di un batch file, tali argomenti
sono referenziabili, nell'ordine, dai simboli:
%0, %1, %2, %3, %4, %5, %6, %7, %8, %9
Il simbolo %0 rappresenta il nome dello stesso batch file, per cui
il primo argomento passato dall'utente è %1.
Tutto ciò ci permette di creare, ad esempio, un generico batch file
attraverso il quale possiamo linkare qualsiasi programma alla libreria
EXELIB.OBJ; la Figura 15.13 illustra un esempio pratico, chiamato
EXELIB.BAT, che rappresenta la generalizzazione del batch file
di Figura 15.12.
Se ora vogliamo creare, ad esempio, il programma EXETEST3.EXE, non
dobbiamo fare altro che impartire il comando:
exelib.bat exetest3
In questo caso, exetest3 è l'argomento %1 che stiamo passando a
EXELIB.BAT; è fondamentale che l'argomento exetest3 venga
passato senza l'estensione asm.
15.7 Esercitazioni consigliate
Si consiglia vivamente di sfruttare al massimo le librerie EXELIB.OBJ
e COMLIB.OBJ, per effettuare il maggior numero possibile di esperimenti;
solo in questo modo si può acquisire la necessaria padronanza di tutti i
concetti esposti nei precedenti capitoli.
Con le pochissime istruzioni (MOV, ADD, PUSH, POP)
e con i pochissimi operatori (SEG, BYTE, WORD, etc) che
già conosciamo, possiamo scrivere un gran numero di programmi; nel seguito,
vengono illustrati alcuni semplici esempi che si servono della libreria
EXELIB (FAR call per le procedure).
15.7.1 Visualizzazione dell'indirizzo logico di una informazione
Dopo aver definito i dati statici di un programma, possiamo provare a stampare
sullo schermo gli indirizzi logici Seg:Offset assegnati in fase di
esecuzione ai dati stessi; supponendo, ad esempio, di aver definito un dato
chiamato Variabile1, possiamo scrivere:
Al posto di Variabile1, possiamo utilizzare anche il nome di una
etichetta o di una procedura, definite in un blocco di codice; ad esempio, si
può provare a visualizzare l'indirizzo Seg:Offset di una qualsiasi
procedura definita nella libreria EXELIB.OBJ o COMLIB.OBJ.
Ricordiamoci che in un eseguibile in formato COM, è proibito scrivere
istruzioni del tipo:
mov ax, seg writeString
Al posto di questa istruzione possiamo scrivere, ad esempio:
mov ax, cs
Infatti, in un programma in formato COM è presente un unico segmento,
referenziato sicuramente da CS; anche gli altri tre registri DS,
ES e SS, se non hanno subito modifiche, referenziano l'unico
segmento di programma presente.
15.7.2 Gestione di un dato attraverso un puntatore NEAR o FAR
Dopo aver definito i dati statici di un programma, possiamo provare a gestirli
attraverso un puntatore NEAR o FAR; supponiamo, ad esempio, di
aver definito in un blocco DATASEGM, il dato:
Variabile1 dw -14532
Dopo aver caricato DATASEGM in DS, possiamo scrivere:
È importante ribadire che prima di utilizzare DI come puntatore
NEAR, il registro di segmento DS deve essere già stato
inizializzato con DATASEGM; in caso contrario, il simbolo [DI]
fornisce un valore privo di senso, dovuto al fatto che DS può avere
un contenuto casuale.
15.7.3 Accesso allo stack con SS:BP
Come sappiamo, in modalità reale è proibito dereferenziare il registro
SP attraverso istruzioni del tipo:
mov ax, [sp] ; ax = ss:[sp]
Al posto di SP dobbiamo allora utilizzare BP; se vogliamo
esplorare lo stack con BP, possiamo scrivere, ad esempio:
Ricordiamo che ogni istruzione PUSH,con operando di tipo WORD,
decrementa SP di 2, per cui le varie PUSH inseriscono
i loro operandi ad indirizzi sempre più bassi dello stack (riempimento
dello stack); come si nota nella parte finale del precedente listato, se
vogliamo ripristinare esattamente il vecchio contenuto di AX e
BX, dobbiamo estrarre gli operandi dallo stack in senso inverso
rispetto agli inserimenti (infatti, lo stack è una struttura di tipo
LIFO).
Naturalmente, nessuno ci impedisce di accedere allo stack con, ad esempio,
ES:SI; in questo caso, dobbiamo caricare SP in SI e
SS in ES. L'utilizzo di ES:SI richiede il segment
override, per cui, dobbiamo scrivere istruzioni del tipo:
mov ax, es:[si+2]
15.7.4 Visualizzazione dei codici ASCII contenuti in una stringa
Supponiamo di aver definito in un blocco DATASEGM, la stringa:
Messaggio db 'Assembly Programming'
Se vogliamo visualizzare i codici
ASCII
dei vari simboli che formano la stringa, possiamo scrivere:
Questo esempio funziona solo se DATASEGM è stato già caricato in
DS.
Volendo utilizzare i registri puntatori, possiamo scrivere:
Questo esempio funziona solo se DATASEGM è stato già caricato in
DS.
Volendo utilizzare BP per indirizzare Messaggio, dobbiamo scrivere,
esplicitamente, DS:[BP+0], DS:[BP+1], etc; in caso contrario, la
CPU associa automaticamente BP a SS.
15.7.5 Analisi del risultato di una operazione attraverso i flags
Sfruttando la procedura showCPU, possiamo verificare in pratica tutti
i concetti sulla matematica del computer, esposti nei precedenti capitoli; per
il momento, dobbiamo limitarci a degli esempi che utilizzano la sola istruzione
ADD.
Se vogliamo provocare un Carry dal nibble basso al nibble alto di
AL, possiamo scrivere:
Sommando 1 al valore binario 00001111b si ottiene
00010000b con evidente riporto dal nibble basso al nibble alto
di AL; di conseguenza, il flag AF (Auxiliary Carry
Flag) si porta a livello logico 1.
Se vogliamo provocare un Carry in una somma con AX, possiamo
scrivere:
Sommando 1 al valore 65535, otteniamo un valore con ampiezza
maggiore di 16 bit, che provoca quindi un riporto; di conseguenza, il flag
CF (Carry Flag) si porta a livello logico 1.
Se vogliamo provocare un Overflow in una somma con AX, possiamo
scrivere:
Sommando 1 al valore +32767, otteniamo 32768 che, per i
numeri interi senza segno rappresenta 32768, mentre per i numeri interi
con segno rappresenta -32768; di conseguenza, il flag CF (Carry
Flag) si porta a livello logico 0, mentre i flag OF (Overflow
Flag) e SF (Sign Flag) si portano entrambi a livello logico
1.
15.7.6 Visualizzazione del campo CommandLineParameters del PSP di un programma
Utilizzando le informazioni presenti nella Figura 13.11 del Capitolo 13, possiamo
provare a visualizzare la stringa dei parametri che un programma accetta dalla
linea di comando; a tale proposito, dobbiamo tenere presente che il campo
CommandLineParmLength occupa 1 byte e si trova all'indirizzo logico
PSP:0080h, mentre il campo CommandLineParameters occupa sino a
127 byte e si trova all'indirizzo logico PSP:0081h.
Non appena un programma (COM o EXE) viene caricato in memoria,
sappiamo che ES=PSP; di conseguenza, prima di chiamare writeString,
dobbiamo solo caricare in DI l'offset 0081h. Bisogna anche
ricordare che la procedura writeString richiede una stringa C
terminata da un byte di valore 0; questo byte lo dobbiamo mettere alla
fine della stringa CommandLineParameters e cioè, nella posizione che si
ricava da ES:[0080h].
In base a queste considerazioni, possiamo scrivere:
Supponiamo che il nome di questo programma sia progtest.exe e proviamo
ad eseguire il comando:
progtest mela pera ciliegia
La stringa dei parametri è lunga 19 byte, compresi gli spazi; otteniamo
quindi all'indirizzo ES:[0081h]:
CommandLineParameters db ' mela pera ciliegia', 0Dh
e all'indirizzo ES:[0080h]:
CommandLineParmLength db 19
Caricando 0081h in DI, facciamo puntare ES:DI alla stringa
dei parametri; a questo punto, dobbiamo mettere un byte di valore zero in posizione
19 della stessa stringa. A tale proposito, carichiamo il byte di valore
19 in BL con l'istruzione:
mov bl, es:[0080h]
Carichiamo ora il valore zero in BH in modo da avere BX=19; a questo
punto, possiamo inserire in CommandLineParameters il byte di valore zero
con l'istruzione:
mov byte es:[bx+di], 0
Osserviamo che:
BX + DI = 19 + 0081h = 0013h + 0081h = 0094h
A questo punto, la stringa è pronta per essere visualizzata da writeString.
A partire dal prossimo capitolo, verranno illustrate in dettaglio tutte le
istruzioni delle CPU 80x86, destinate alla modalità reale; in questo
modo, sarà possibile scrivere programmi di esempio molto più complessi
rispetto a quelli presentati in questo capitolo.