Assembly Base con NASM
Capitolo 13: Assembling & Linking
Lo scopo di questo capitolo è quello di illustrare il procedimento che bisogna
seguire per convertire in formato eseguibile un programma scritto in linguaggio
Assembly; a tale proposito, ci serviremo del modello presentato nel
precedente capitolo.
13.1 Programma Assembly di esempio
La Figura 13.1 illustra un programma di esempio chiamato EXETEST.ASM, a
partire dal quale otterremo l'eseguibile finale; questo programma fa riferimento
ai vari dati, elementari e complessi, illustrati nel precedente capitolo.
Il contenuto del programma di Figura 13.1 è estremamente semplice e non
necessita di ulteriori spiegazioni; inoltre, attraverso i vari commenti, si
possono ricavare tutte le informazioni necessarie per capire lo scopo delle
varie dichiarazioni, definizioni, linee di codice, etc.
Osserviamo che, per il segmento di codice CODESEGM, è stato scelto
un allineamento di tipo DWORD (ALIGN=4); più avanti vedremo che
questa scelta ci permetterà di analizzare alcuni aspetti molto importanti.
13.2 Installazione dell'assembler
L'installazione dell'assembler NASM sul computer, non presenta alcuna
difficoltà; a tale proposito, tutte le necessarie informazioni sono disponibili
Nella sezione Compilatori assembly, c++ e altri dell’
Area Downloads di questo sito.
Per comodità, in tutti gli esempi presentati nella sezione Assembly
Base, si suppone che il NASM sia stato installato nella cartella:
C:\NASM
13.3 Scrittura del codice sorgente
Come già sappiamo, per la scrittura del codice sorgente dobbiamo munirci di un
apposito editor capace di gestire file rigorosamente in formato testo
semplice ASCII; qualsiasi
altro formato di file, non viene accettato da NASM.
La scelta dell'editor è una questione di gusti personali; in generale, è
consigliabile l'uso di un editor rivolto esplicitamente allo sviluppo dei
programmi. Infatti, questi particolari editor sono dotati di caratteristiche
che si rivelano di grande aiuto per il programmatore; tra le caratteristiche
più utili si possono citare, il supporto del copia/incolla, la gestione
delle tabulazioni (per l'indentazione del codice), la possibilità di interagire
facilmente con il prompt del DOS, etc.
Si possono utilizzare, ad esempio, gli editor forniti in dotazione ai
compilatori e agli interpreti C, Pascal, BASIC, etc;
bisogna però prestare particolare attenzione al fatto che alcuni di questi
editor (come quelli del Visual Basic o del Quick Basic), salvano
il codice sorgente in un formato binario che risulta illeggibile con altri editor
di testo.
Un'altra possibilità consiste nello scaricare da Internet uno dei numerosi
editor per programmatori; molti di questi editor sono scaricabili e utilizzabili
con licenza freeware.
In caso di necessità, si rivela validissimo anche l'editor EDIT.COM (o
EDIT.EXE) fornito con il DOS; tale editor è presente anche in
FreeDOS.
In ambiente Linux sono disponibili diversi ottimi editor, come
GEdit, KWrite, Kate, KDevelop, etc; tali editor
supportano la cosiddetta "sintassi a colori" che permette di evidenziare con
colori differenti le varie componenti del codice sorgente.
Una volta che abbiamo l'editor a disposizione, possiamo procedere con la
scrittura del codice sorgente; a tale proposito, è sufficiente eseguire un
copia/incolla dalla Figura 13.1 all'editor stesso (tale metodo
potrebbe non funzionare con alcuni editor).
Il codice sorgente così ottenuto, deve essere salvato con il nome
EXETEST.ASM; per convenzione, un file con estensione ASM
rappresenta un modulo contenente codice sorgente Assembly. Ogni
linguaggio di programmazione, utilizza alcune estensioni predefinite per i
file che contengono il codice sorgente; nel linguaggio C si utilizza
l'estensione C, nel C++ si utilizza l'estensione CPP,
nel Pascal si utilizza l'estensione PAS e così via.
L'estensione predefinita per i file che contengono codice sorgente
Assembly è appunto ASM; queste regole non sono obbligatorie, ma
è sempre meglio rispettarle per poter individuare facilmente i vari tipi di file.
Per comodità, è opportuno crearsi una cosiddetta cartella di lavoro,
nella quale disporre il codice sorgente dei propri programmi Assembly;
in tutti gli esempi presentati nella sezione Assembly Base, si suppone
che la cartella di lavoro sia:
C:\NASM\ASMBASE
13.4 La fase di assemblaggio (assembling)
La fase di assemblaggio viene svolta, ovviamente, da uno strumento chiamato
assembler (assemblatore); nel caso del NASM, l'assembler si
chiama NASM.EXE.
L'assembler NASM funziona dal prompt del DOS o command
line (linea di comando); a seconda dei gusti, si tratta quindi di
lanciare FreeDOS da VirtualBox o il Prompt di MS-DOS
dal menù Start di Windows o DOSEmu da un terminale di
Linux (da ora in poi si parlerà genericamente di "linea di comando"
o "command line").
Il compito fondamentale svolto dall'assembler consiste nel convertire il
codice sorgente di un modulo Assembly, in un formato binario che
prende il nome di object code (codice oggetto); come vedremo tra
breve, il codice oggetto contiene, oltre al codice macchina, anche una
numerosa serie di informazioni necessarie al linker per poter
svolgere la fase successiva.
Per prendere confidenza con l'assembler NASM, dalla command line
possiamo impartire il comando:
c:\nasm\nasm -h
In questo modo, si ottiene la lista delle opzioni che si possono passare
all'assembler; attraverso l'uso di queste opzioni, è possibile pilotare
il comportamento dell'assembler in base alle nostre specifiche esigenze.
Procediamo ora con la fase di assemblaggio del modulo EXETEST.ASM che,
in precedenza, abbiamo salvato nella cartella ASMBASE; prima di tutto
dobbiamo posizionarci nella stessa cartella ASMBASE impartendo dalla
command line il comando:
cd c:\nasm\asmbase
Una volta che ci siamo posizionati nella cartella di lavoro, possiamo
finalmente impartire i comandi per l'assemblaggio del nostro programma; nel
caso del NASM, il comando da impartire è:
c:\nasm\nasm -f obj exetest.asm -l exetest.lst
Premendo ora il tasto [Invio], parte la fase di assemblaggio del modulo
EXETEST.ASM; in questa fase, l'assembler verifica anche la presenza di
eventuali errori nel codice sorgente. Per ogni eventuale errore che viene
individuato, l'assembler genera una serie di messaggi che indicano, il tipo di
errore e in quale linea del codice sorgente si è verificato il problema; in
questo caso, bisogna tornare nell'editor per apportare le necessarie correzioni
al codice sorgente.
L'assembler individua principalmente due tipi di errore e cioè, errori
sintattici ed errori semantici; gli errori sintattici sono in
pratica gli errori di battitura che vengono commessi dal programmatore. Ad
esempio, per sbaglio si può scrivere MOZ invece di MOV; in questo
caso, l'assembler genera un messaggio di errore del tipo:
error: parser: instruction expected
Gli errori semantici, invece, si riferiscono a parti del codice sorgente prive
di significato logico; ad esempio, una istruzione di trasferimento dati da
BL ad AX (da Reg8 a Reg16), può essere valida dal
punto di vista sintattico, ma semanticamente è priva di senso.
Se il nostro codice sorgente non presenta errori, la fase di assemblaggio si
conclude con la generazione del file contenente il codice oggetto; nel caso del
nostro esempio, in assenza di diverse indicazioni da parte del programmatore,
questo file verrà chiamato EXETEST.OBJ, dove l'estensione OBJ
significa appunto object file.
Se si prova a caricare questo file binario in un editor di testo, verrà
visualizzata una sequenza incomprensibile di simboli appartenenti al set di
codici ASCII; gli editor
di testo, infatti, cercheranno di convertire in stringhe di testo quelli che,
in realtà, sono i codici macchina delle istruzioni del nostro programma.
13.4.1 Il Listing File
Impartendo ora dal prompt il comando:
dir
verrà mostrata la lista dei file presenti nella cartella ASMBASE; in
questo modo si scopre che, oltre a EXETEST.ASM e EXETEST.OBJ,
è presente anche un terzo file chiamato EXETEST.LST. L'assembler ha
generato questo file, perché noi glielo abbiamo chiesto attraverso una
apposita opzione di assemblaggio; nel caso di NASM, si tratta
dell'opzione -l seguita dal nome che vogliamo assegnare al file (la
convenzione più seguita consiste nel dare a questo file lo stesso nome del
file ASM e l'estensione LST).
L'opzione -l significa generate listing e indica all'assembler
di generare un particolare file in formato testo
ASCII, chiamato listing
file; questo file ci offre l'eccezionale opportunità di analizzare in
dettaglio tutto il lavoro svolto da NASM nella fase di assemblaggio di
un modulo Assembly. La Figura 13.2 mostra proprio il contenuto del
file EXETEST.LST generato da NASM, che possiamo visualizzare
con un normale editor di testo.
Analizziamo in dettaglio l'importantissimo contenuto di questo file, che ci
permette di verificare in pratica, le considerazioni teoriche svolte nei
precedenti capitoli.
Notiamo subito che il listing file contiene (nella parte destra) anche il
codice sorgente del nostro programma; si tratta di una grande comodità che
ci permette di confrontare facilmente ogni linea del nostro programma, con
il corrispondente codice ricavato dall'assembler.
Nella parte sinistra del listing file, troviamo una serie di informazioni
molto utili; queste informazioni sono disposte su varie colonne. La colonna
più a sinistra contiene la numerazione progressiva delle varie linee del
codice sorgente; questa numerazione, viene generata a beneficio del
programmatore che così può avere dei punti di riferimento all'interno del
programma che deve analizzare.
La seconda colonna contiene gli offset delle varie informazioni presenti
nei segmenti di programma e nelle eventuali dichiarazioni; più avanti
analizzeremo il procedimento seguito dall'assembler per calcolare questi
offset.
La terza colonna del listing file è sicuramente la più importante, in quanto
contiene il codice macchina generato dall'assembler; analizziamo più da vicino
le informazioni contenute in queste colonne.
Come si vede in Figura 13.2, le varie direttive e dichiarazioni, non hanno
alcun codice macchina e quindi non provocano nessuna allocazione di memoria;
come già sappiamo, infatti, si tratta di semplici disposizioni che il
programmatore impartisce all'assembler. La riga 8, ad esempio, contiene
la direttiva CPU 386 che ha il solo scopo di informare l'assembler sul
fatto che vogliamo utilizzare il set di istruzioni a 32 bit della
CPU 80386; questa direttiva (come qualsiasi altra direttiva) non ha
quindi alcun offset o codice macchina.
La riga 12 contiene la dichiarazione della costante simbolica
STACK_SIZE; l'assembler si limita a rilevare che questo nome simbolico
è associato al valore costante 0400h. Ogni volta che l'assembler incontra
questo nome simbolico in una istruzione o in una espressione costante, lo
sostituisce con il valore 0400h che verrà quindi incorporato direttamente
nel codice macchina.
Tra la riga 14 e la riga 46, notiamo la presenza delle varie
dichiarazioni di STRUC; anche in questo caso si vede che l'assembler non
genera alcun codice macchina e si limita solo a prendere nota delle caratteristiche
di ogni singola STRUC.
In particolare, notiamo che per ogni STRUC l'assembler calcola gli offset
dei vari membri; è importante ricordare che questi offset, sono calcolati rispetto
all'inizio della struttura di appartenenza. In relazione, ad esempio, alla struttura
Automobile, l'assembler rileva che:
- il membro CodModello è formato da 1 word, cioè 2h byte non
inizializzati (res 00000002) e si trova all'offset 00000000h
rispetto all'inizio di Automobile
- il membro Cilindrata è formato da 1 word, cioè 2h byte non
inizializzati (res 00000002) e si trova all'offset 00000002h
rispetto all'inizio di Automobile
- il membro Cavalli è formato da 1 word, cioè 2h byte non
inizializzati (res 00000002) e si trova all'offset 00000004h
rispetto all'inizio di Automobile
- il membro Revisioni è formato da 1 gruppo di 10 dword,
cioè 28h byte non inizializzati (res 00000028) e si trova
all'offset 00000006h rispetto all'inizio di Automobile
Complessivamente, le istanze di tipo Automobile, richiedono una quantità
di memoria pari a:
2 + 2 + 2 + (10 * 4) = 6 + 40 = 46 = 2Eh byte
È importante notare che tutti i codici numerici generati dall'assembler (compresi
gli offset), sono espressi implicitamente in esadecimale; ciò dimostra l'importanza
per il programmatore Assembly, della conoscenza di questo particolare
sistema di codifica.
13.4.2 Assemblaggio di DATASEGM
A questo punto, l'assembler arriva alla linea 50 del listing file,
dove incontra l'inizio del segmento di dati DATASEGM; per ciascun
dato presente in questo segmento, l'assembler procede al calcolo delle
tre informazioni fondamentali che sono, come sappiamo, l'offset, la
dimensione in byte e il contenuto iniziale.
Il primo aspetto importante da osservare, riguarda il fatto che l'assembler
non è ovviamente in grado di determinare in anticipo l'indirizzo fisico di
memoria da cui partirà un determinato segmento di programma; tutto ciò
che riguarda il corretto allineamento in memoria dei segmenti di programma,
è di competenza del linker. Proprio per questo motivo, l'assembler assegna
ad ogni segmento di programma, un indirizzo fisico iniziale simbolico, che
è sempre allineato al paragrafo; questo indirizzo fisico è quindi del tipo
XXXX0h e può essere associato all'indirizzo logico normalizzato
XXXXh:0000h. In fase di assemblaggio, il valore XXXXh assunto
dalla componente Seg di questo indirizzo, è del tutto irrilevante;
il NASM, ad esempio, utilizza il valore simbolico 0000h. Ciò
che conta, è che l'assembler fa partire sempre da 0000h gli offset
relativi alle informazioni contenute in un qualunque segmento di programma;
in questa fase, infatti, lo scopo dell'assembler è quello di determinare lo
spiazzamento che assume ogni informazione, all'interno del segmento di
appartenenza.
Ogni volta che l'assembler incontra l'inizio di un nuovo segmento di
programma, inizializza $ in modo che la sua componente Offset
valga 0000h; a questo punto inizia il calcolo degli offset relativi
alle informazioni presenti nel segmento stesso. Nel caso di DATASEGM,
il location counter viene inizializzato con DATASEGM:0000h (cioè
XXXXh:0000h); a questo punto, l'assembler, come si vede in Figura 13.2,
rileva quanto segue:
- La componente Offset di $ viene inizializzata a 0000h
e si ottiene:
Offset = 0000h
Quest'offset viene assegnato a varByte che occupa 1 byte
(0001h byte).
La componente Offset di $ viene incrementata di 0001h
e si ottiene:
Offset = 0000h + 0001h = 0001h
Quest'offset viene assegnato a varWord che occupa 2 byte
(0002h byte).
La componente Offset di $ viene incrementata di 0002h
e si ottiene:
Offset = 0001h + 0002h = 0003h
Quest'offset viene assegnato a varDword che occupa 4 byte
(0004h byte).
La componente Offset di $ viene incrementata di 0004h
e si ottiene:
Offset = 0003h + 0004h = 0007h
Quest'offset viene assegnato a varQword che occupa 8 byte
(0008h byte).
La componente Offset di $ viene incrementata di 0008h
e si ottiene:
Offset = 0007h + 0008h = 000Fh
Quest'offset viene assegnato a varTbyte che occupa 10 byte
(000Ah byte).
La componente Offset di $ viene incrementata di 000Ah
e si ottiene:
Offset = 000Fh + 000Ah = 0019h
Quest'offset viene assegnato a varFloat32 che occupa 4 byte
(0004h byte).
La componente Offset di $ viene incrementata di 0004h
e si ottiene:
Offset = 0019h + 0004h = 001Dh
Quest'offset viene assegnato a varFloat64 che occupa 8 byte
(0008h byte).
La componente Offset di $ viene incrementata di 0008h
e si ottiene:
Offset = 001Dh + 0008h = 0025h
Quest'offset viene assegnato a DataViaggio che occupa 4 byte
(0004h byte).
La componente Offset di $ viene incrementata di 0004h
e si ottiene:
Offset = 0025h + 0004h = 0029h
Quest'offset viene assegnato a varRect che occupa 8 byte
(0008h byte).
La componente Offset di $ viene incrementata di 0008h
e si ottiene:
Offset = 0029h + 0008h = 0031h
Quest'offset viene assegnato a vettRect che occupa 10 * 8 = 80
byte (0050h byte).
La componente Offset di $ viene incrementata di 0050h
e si ottiene:
Offset = 0031h + 0050h = 0081h
Quest'offset viene assegnato a FiatPanda che occupa 46 byte
(002Eh byte).
La componente Offset di $ viene incrementata di 002Eh
e si ottiene:
Offset = 0081h + 002Eh = 00AFh
Quest'offset viene assegnato a vettAuto che occupa 20 * 46 = 920
byte (0398h byte).
Se si sta utilizzando la versione a 32 bit di NASM, la componente
Offset di $ può arrivare sino a FFFFFFFFh, superando quindi
il valore massimo 0000FFFFh previsto per i segmenti di programma con
attributo USE16; è compito del programmatore evitare che ciò possa accadere.
Analizzando i vari offset assegnati ai dati definiti in DATASEGM, si può
notare che l'assembler compatta al massimo le varie informazioni presenti nei
segmenti di programma, in modo da non sprecare neanche un byte; in base allora al
fatto che vettAuto parte dall'offset 00AFh e occupa 0398h
byte, possiamo dire che la dimensione totale di DATASEGM è pari a:
00AFh + 0398h = 0447h byte = 1095 byte
Nella terza colonna di Figura 13.2, relativamente al blocco DATASEGM,
l'assembler mostra gli eventuali valori iniziali assegnati a ciascun dato;
tutte queste informazioni ricavate dall'assembler, vengono inserite nel codice
oggetto e messe a disposizione del linker.
Esclusivamente in fase di assemblaggio di un programma, il location counter
$ è accessibile in lettura al programmatore; nei capitoli successivi
vedremo degli esempi pratici sull'utilizzo di questo strumento.
13.4.3 Assemblaggio di CODESEGM
Terminato l'assemblaggio di DATASEGM, l'assembler cerca altri segmenti
DATASEGM eventualmente presenti nel modulo EXETEST.ASM; se la
ricerca fornisce esito positivo, l'assembler provvede ad assemblare e ad unire
tra loro, tutti i DATASEGM aventi gli stessi identici attributi. Nel
nostro caso, non essendo presente alcun altro DATASEGM, l'assembler
passa al segmento successivo e cioè, a CODESEGM; il location counter
viene nuovamente inizializzato con la coppia CODESEGM:0000h. A questo
punto, parte l'assemblaggio del blocco CODESEGM; come al solito, se
vogliamo verificare il lavoro svolto dall'assembler, possiamo analizzare il
listing file di Figura 13.2.
Alla riga 285 viene incontrata l'etichetta ..start; in questo
punto, la componente Offset di $ vale 0000h; questo
è proprio l'offset che viene assegnato a ..start. Anche in questo
caso, è necessario ricordare che le etichette sono dei semplici "marcatori"
che hanno lo scopo di individuare un offset all'interno di un segmento di
programma; infatti, in Figura 13.2 notiamo che l'assembler, in corrispondenza
di ..start, non genera alcun codice macchina. Tutto ciò significa che
le etichette non occupano memoria (osserviamo che, in questo caso particolare,
l'etichetta ..start indica l'entry point del nostro programma);
a dimostrazione del fatto che ..start non occupa memoria, possiamo
notare che subito dopo è presente l'istruzione:
mov ax, DATASEGM
alla quale l'assembler assegna l'offset 0000h, che è lo stesso di
..start; questa istruzione è la prima in assoluto che verrà elaborata
dalla CPU non appena inizierà la fase di esecuzione del nostro programma.
Si tratta chiaramente di un trasferimento dati da Imm16 a Reg16;
il codice macchina di questa istruzione, è formato dall'Opcode 1011_w_Reg,
seguito da un Imm16. Nel nostro caso, w=1 (operandi a 16
bit) e Reg=AX=000b; si ottiene quindi:
Opcode = 10111000b = B8h
Il valore immediato è DATASEGM, che simbolicamente vale 0000h;
di conseguenza, l'assembler genera il codice macchina:
10111000b 0000000000000000b = B8h 0000h
In Figura 13.2 notiamo che NASM indica l'operando Imm16 con
[0000]; le parentesi quadre indicano che il valore 0000h è
destinato ad essere modificato dal linker e/o dal SO. Tutte le
componenti Seg e Offset destinate ad essere modificate dal
linker e/o dal SO, vengono definite relocatables (rilocabili);
più avanti vedremo come avviene la fase di rilocazione.
L'istruzione appena esaminata, ha un codice macchina da 3 byte; di
conseguenza, l'assembler incrementa di 0003h la componente
Offset del location counter e ottiene:
Offset = 0000h + 0003h = 0003h
Quest'offset viene assegnato all'istruzione successiva, che è:
mov ds, ax
Si tratta chiaramente di un trasferimento dati da Reg16 a
SegReg; il codice macchina di questa istruzione, è formato
dall'Opcode 10001110b=8Eh e dal campo mod_0_SegReg_r/m. Nel
nostro caso, mod=11b (entrambi gli operandi sono di tipo registro),
r/m=AX=000b e SegReg=DS=11b; si ottiene quindi:
mod_0_SegReg_r/m = 11011000b = D8h
L'assembler genera allora il codice macchina:
10001110b 11011000b = 8Eh D8h
Questo codice macchina occupa 2 byte, per cui, l'assembler incrementa
di 0002h la componente Offset del location counter e ottiene:
Offset = 0003h + 0002h = 0005h
Quest'offset viene assegnato all'istruzione successiva.
Nella parte seguente di CODESEGM, sono presenti una serie di
trasferimenti di dati e addizioni, che coinvolgono, registri, valori
immediati e variabili statiche definite in DATASEGM; lo scopo di
queste istruzioni è quello di mostrare che, simboli apparentemente complessi
come, ad esempio:
[vettAuto+0+Revisioni+8]
non sono altro che banalissimi Disp16.
Consideriamo, ad esempio, l'istruzione (riga 296 di Figura 13.2):
mov word [varRect+p2+x], 6850
Si tratta di un trasferimento dati da Imm16 a Mem16; il codice
macchina di questa istruzione, è formato dall'Opcode 1100011w, seguito
dal campo mod_000_r/m (il sottocampo reg è superfluo e vale
000b), da Disp16 e da Imm16. Nel nostro caso, w=1
(operandi a 16 bit), per cui:
Opcode = 11000111b = C7h
La destinazione è di tipo Disp16, per cui mem=00b e
r/m=110b; otteniamo quindi:
mod_000_r/m = 00000110b = 06h
Nel segmento DATASEGM di Figura 13.2 si nota che varRect si
trova all'offset 0029h; considerando il fatto che ogni Point2d
occupa 4 byte, possiamo dire che varRect+p2+x si trova
all'offset:
Disp16 = 0029h + 0004h + 0000h = 002Dh = 0000000000101101b
Il valore immediato è:
Imm16 = 6850 = 1AC2h = 0001101011000010b
In definitiva, l'assembler genera il codice macchina:
11000111b 00000110b 0000000000101101b 0001101011000010b = C7h 06h 002Dh 1AC2h
Tutti i valori esadecimali formati da 2 o più byte, vengono mostrati da
NASM con i byte disposti al contrario; ad esempio, il valore a 16
bit 33CCh viene disposto come CC33h, mentre il valore a 32
bit AABBCCDDh viene disposto come DDCCBBAAh. Il precedente codice
macchina viene quindi rappresentato da NASM come:
C7h 06h 2D00h C21Ah
È importante acquisire una certa pratica anche con questo modo di rappresentare
i numeri in quanto molti editor esadecimali utilizzano questa convenzione; nel
seguito del capitolo e nei capitoli successivi, i valori esadecimali verranno
sempre rappresentati nel modo classico (peso dei byte che cresce da destra verso
sinistra).
Nell'istruzione che abbiamo appena esaminato, l'identificatore varRect+p2+x
è privo della componente Seg; come già sappiamo, ciò è possibile in quanto,
in assenza di diverse indicazioni da parte del programmatore, un Disp16
viene sempre associato dalla CPU alla componente Seg contenuta nel
registro DS. Naturalmente, è fondamentale che lo stesso DS sia già
stato inizializzato dal programmatore con DATASEGM!
Possiamo dire allora che in questo caso, varRect+p2+x viene gestito con un
indirizzo di tipo NEAR, formato dalla sola componente Offset (cioè
dal Disp16=002Dh); in fase di elaborazione dell'istruzione, la CPU
provvede ad associare automaticamente il Disp16 a DS.
Se avessimo scritto, invece:
mov word [es:varRect+p2+x], 6850
allora l'assembler, all'inizio del precedente codice macchina, avrebbe inserito
il prefisso 26h relativo al registro ES (segment override); ciò
avviene perché, come sappiamo, ES non è il SegReg predefinito per
i segmenti di dati. In un caso del genere, la variabile varRect+p2+x
viene gestita attraverso un indirizzo di tipo FAR, formato da una coppia
completa Seg:Offset (cioè, ES:002Dh); ogni indirizzo FAR
aumenta di 1 byte le dimensioni del codice macchina e, inoltre, essendo
formato da 32 bit, comporta un tempo di elaborazione leggermente più
lungo rispetto al caso degli indirizzi NEAR.
Vediamo un altro esempio (linea 429 di Figura 13.2) relativo all'istruzione:
add al, byte [vettAuto+46+Revisioni+8+3]
Si tratta di una addizione tra Reg8 (destinazione) e Mem8 (sorgente);
il nome Revisioni indica un dato a 32 bit, ma grazie all'operatore
BYTE, possiamo dire all'assembler che vogliamo accedere ad una locazione di
memoria da 8 bit.
Il codice macchina della precedente istruzione, è formato dall'Opcode
000000dw, seguito dal campo mod_reg_r/m e da un Disp16; nel
nostro caso, d=1 (destinazione registro) e w=0 (operandi a 8
bit), per cui:
Opcode = 00000010b = 02h
Il registro destinazione è reg=AL=000b; la sorgente è di tipo Disp16,
per cui mem=00b e r/m=110b. Otteniamo quindi:
mod_reg_r/m = 00000110b = 06h
Nel segmento DATASEGM di Figura 13.2 si nota che vettAuto si trova
all'offset 00AFh, mentre il membro Revisioni si trova all'offset
0006h (rispetto all'inizio di ogni struttura Automobile); tenendo
conto del fatto che 46=002Eh, possiamo dire allora che
vettAuto+46+Revisioni+8+3 si trova all'offset:
Disp16 = 00AFh + 002Eh + 0006h + 0008h + 0003h = 00EEh = 0000000011101110b
In definitiva, l'assembler genera il codice macchina:
00000010b 00000110b 0000000011101110b = 02h 06h 00EEh
Vediamo infine un esempio (linea 397 di Figura 13.2) relativo all'istruzione:
mov eax, [varDword]
Questa volta abbiamo a che fare con un trasferimento dati a 32 bit da
Mem32 a Reg32; grazie alla presenza dell'accumulatore, viene
utilizzato un codice macchina compatto formato dal solo Opcode 1010000w
seguito dal Disp16. Nel modo 32 bit, il bit w=1 indica che
gli operandi sono a 32 bit (full size); otteniamo allora:
Opcode = 10100001b = A1h
In Figura 13.2 si nota che varDword si trova all'offset 0003h
del segmento DATASEGM; otteniamo quindi:
Disp16 = 0003h = 0000000000000011b
L'assembler genera anche il prefisso 66h (01100110b) che
rappresenta l'operand size prefix; alla fine, otteniamo il codice macchina:
01100110b 10100001b 0000000000000011b = 66h A1h 0003h
Come si può notare in Figura 13.2, complessivamente, l'intero contenuto di
CODESEGM occupa 0145h+2h=147h byte di memoria, cioè 327
byte.
Un'ultima considerazione riguarda il fatto che, come si vede in Figura 13.2,
l'assembler racchiude tra [ ] ogni Disp16 presente nelle istruzioni
del segmento di codice; anche in questo caso, queste [ ] stanno per
relocatable e indicano il fatto che, tutti questi Disp16, sono
soggetti ad una eventuale rilocazione da parte del linker. Più avanti verrà
chiarito questo importantissimo aspetto.
13.4.4 Assemblaggio di STACKSEGM
Terminato l'assemblaggio di CODESEGM, l'assembler cerca altri segmenti
CODESEGM eventualmente presenti nel modulo EXETEST.ASM; se la
ricerca fornisce esito positivo, l'assembler provvede ad assemblare e ad unire
tra loro tutti i CODESEGM aventi gli stessi identici attributi. Nel
nostro caso, non essendo presente alcun altro CODESEGM, l'assembler
passa al segmento successivo e cioè, a STACKSEGM; il location counter
viene nuovamente inizializzato con la coppia STACKSEGM:0000h. A questo
punto, parte l'assemblaggio del blocco STACKSEGM; trattandosi di un
segmento contenente esclusivamente dati, la situazione è perfettamente
analoga al caso di DATASEGM.
Come possiamo notare in Figura 13.2, l'assembler assegna l'offset 0000h
all'unico dato presente, che è un vettore formato da 0400h byte non
inizializzati (res 00000400); di conseguenza, la dimensione complessiva
del segmento STACKSEGM è pari proprio a 0400h byte, cioè
1024 byte.
Terminato l'assemblaggio di STACKSEGM, l'assembler cerca altri segmenti
STACKSEGM eventualmente presenti nel modulo EXETEST.ASM; se la
ricerca fornisce esito positivo, l'assembler provvede ad assemblare e ad unire
tra loro, tutti gli STACKSEGM aventi gli stessi identici attributi. Nel
nostro caso, non essendo presente alcun altro STACKSEGM, l'assembler
passa al segmento successivo; non essendo presente nessun segmento successivo,
la fase di assemblaggio può considerarsi terminata.
13.4.5 La Symbol Table
Il lavoro dell'assembler si conclude con la creazione della cosiddetta Symbol
Table (tavola dei simboli); la Symbol Table ha una importanza enorme
in quanto rappresenta un riassunto di tutte le informazioni fondamentali che
l'assembler ha ricavato dal codice sorgente del nostro programma.
Tutte queste informazioni vengono poi passate al linker e diventano determinanti
nella fase di generazione del programma eseguibile; analizziamo quindi in dettaglio
il lavoro svolto proprio dal linker.
13.5 La fase di linkaggio (linking)
Come molti avranno intuito, il file EXETEST.OBJ, pur contenendo il codice
macchina del nostro programma, non è ancora in formato eseguibile; il compito di
convertire il codice oggetto in codice eseguibile, spetta ad un altro strumento
chiamato linker (collegatore), che è proprio il destinatario delle
informazioni generate dall'assembler.
Il NASM fornisce unicamente l'assembler, per cui è necessario munirsi di
un linker; a tale proposito, possiamo utilizzare un qualunque linker destinato
all'ambiente DOS.
Tra le varie scelte possibili si può citare il linker TLINK.EXE fornito
con il Borland TASM o il linker LINK.EXE fornito con il
Microsoft Quick Basic; nel caso del Microsoft MASM, bisogna
munirsi del linker LINK.EXE fornito con le versioni sino alla
6.11 dell'assembler in quanto le versioni superiori sono destinate
all'ambiente Windows a 32 bit.
Nella sezione Compilatori assembly, c++ e altri dell’
Area Downloads di questo sito
è possibile scaricare il MASM 6.00B che contiene una
versione minimale di MASM; il linker presente in tale archivio può
essere usato in combinazione con NASM.
Vanno benissimo anche i linker forniti dai compilatori C/C++,
Pascal, Fortran, etc, destinati all'ambiente DOS; in
alternativa, su Internet si possono reperire anche alcuni linker
freeware (un ottimo esempio è VAL).
Nel seguito del capitolo e nei capitoli successivi, si farà riferimento, per
semplicità, al linker LINK.EXE di MASM; a tale proposito, si
assume che LINK.EXE sia stato installato nella cartella:
C:\MASM\BIN
Teoricamente, il contenuto di un file oggetto destinato all'ambiente
DOS dovrebbe rispecchiare una struttura standard, indicata con la
sigla OMF (Relocatable Object Module Format); in realtà, i
vari assembler producono dei file oggetto che, pur seguendo questo standard,
si differenziano tra loro per alcuni dettagli (spesso non rilevanti).
La Microsoft ha definito un nuovo standard per gli object file,
indicato dalla sigla COFF (Common Object File Format); il
formato COFF viene utilizzato da tutti gli strumenti di sviluppo
della Microsoft (MASM32, Visual C++, etc) per la
realizzazione di applicazioni Windows. Una caratteristica molto
potente di NASM è data dal fatto che questo assembler supporta
pienamente i formati OMF per DOS, COFF per
Windows, ELF e AOUT per Linux, AOUTB
per BSD, etc; chi volesse studiare in dettaglio gli standard
OMF e COFF, può scaricare la documentazione ufficiale nella
sezione Documentazione tecnica di supporto al corso assembly dell’
Area Downloads di questo sito.
Come è stato spiegato nei precedenti capitoli, nel caso più generale un
programma Assembly è formato da due o più moduli di codice sorgente;
per ciascuno di questi moduli, l'assembler crea il relativo object file.
Il compito del linker è innanzi tutto quello di collegare tra loro i vari
moduli; da ciò deriva appunto il nome "linker" (collegatore). In questo
modo il linker ottiene un unico modulo, all'interno del quale i vari
segmenti di programma vengono combinati tra loro secondo le disposizioni
impartite dal programmatore; i segmenti risultanti dalle varie combinazioni,
vengono disposti secondo i criteri di ordinamento e allineamento in
memoria stabiliti ugualmente dal programmatore.
Come si può facilmente intuire, in seguito alle varie combinazioni e ai
successivi allineamenti, può rendersi necessaria la modifica delle
componenti Seg:Offset relative alle informazioni presenti nei
segmenti di programma; questo lavoro di modifica prende il nome di
rilocazione e rappresenta sicuramente il compito più importante e
delicato svolto dal linker.
Una volta che il linker ha portato a termine tutto questo lavoro, viene
prodotto un file in formato eseguibile; come vedremo tra breve, nel caso
più generale un file eseguibile è costituito da due parti principali
chiamate, header (intestazione) e modulo caricabile (codice,
dati e stack).
Dopo queste importanti precisazioni, possiamo procedere con la generazione
del formato eseguibile del nostro programma; a tale proposito, prima di
tutto dobbiamo posizionarci nella cartella di lavoro ASMBASE. A
questo punto, se stiamo utilizzando i linker della Microsoft,
dobbiamo impartire il seguente comando:
c:\masm\bin\link /map exetest.obj
Premendo il tasto [Invio], parte la fase di linking del file
EXETEST.OBJ; anche in questa fase, è possibile che vengano
generati dei messaggi di errore (errori del linker), che verranno
analizzati nel seguito del capitolo e nei capitoli successivi.
Nel caso dei linker Microsoft, viene chiesto al programmatore di
inserire i nomi da assegnare ai vari file che verranno prodotti; premendo
[Invio] per ogni richiesta, si accettano i nomi predefiniti
proposti dal linker stesso.
Nel dare inizio alla fase di linking, il linker non è ovviamente in grado
di conoscere in anticipo l'indirizzo fisico di memoria da cui verrà fatto
partire il nostro programma; questa informazione, infatti, verrà decisa dal
SO al momento di caricare ed eseguire il programma stesso. Proprio per
questo motivo, il linker utilizza simbolicamente l'indirizzo fisico iniziale
00000h; a questo indirizzo fisico, corrisponde l'indirizzo logico
normalizzato 0000h:0000h. Possiamo dire quindi che, al primo segmento
del nostro programma, viene assegnato l'indirizzo logico iniziale
0000h:0000h; osserviamo che questo indirizzo, è compatibile con
qualsiasi attributo di allineamento.
Il linker esamina i vari segmenti di programma, nello stesso ordine stabilito
dal programmatore; nel caso del nostro esempio (ordinamento predefinito di
tipo sequenziale), il linker esamina, prima DATASEGM, poi CODESEGM
e infine STACKSEGM.
13.5.1 Linking di DATASEGM
Il primo segmento esaminato dal linker è DATASEGM; a questo segmento
viene assegnato l'indirizzo fisico iniziale 00000h (compatibile con
l'attributo PARA), a cui corrisponde l'indirizzo logico normalizzato
0000h:0000h. Di conseguenza, il linker ricava:
DATASEGM = 0000h
Gli offset all'interno di DATASEGM, partono dal valore iniziale
0000h, proprio come era stato determinato dall'assembler; di
conseguenza, tutti i calcoli effettuati dall'assembler vengono confermati
(non è necessaria alcuna rilocazione degli offset dei dati).
Il linker va alla ricerca di altri DATASEGM presenti in altri moduli;
nel nostro caso, il programma è formato da un unico modulo, per cui il
linker genera la struttura finale di DATASEGM. In base alle
informazioni ricavate dall'assembler in Figura 13.2, questa struttura è
rappresentata da un blocco da 0447h byte (1095 byte); i vari
byte, occupano tutti gli indirizzi fisici compresi tra 00000h e:
00000h + (0447h - 1h) = 00446h
In termini di indirizzi logici, il blocco DATASEGM viene inserito,
teoricamente, nel segmento di memoria n. 0000h e occupa tutti gli
offset di questo segmento, compresi tra 0000h e:
0000h + (0447h - 1h) = 0446h
In sostanza, DATASEGM è compreso tra gli indirizzi logici
0000h:0000h e 0000h:0446h; all'interno di questo blocco, il
linker inserisce eventuali valori iniziali che abbiamo assegnato ai dati
del programma (terza colonna di Figura 13.2 nel blocco DATASEGM). La
Figura 13.3, illustra una parte della struttura finale di DATASEGM.
Naturalmente, quando DATASEGM verrà caricato in memoria, i vari
dati risulteranno disposti in modo consecutivo e contiguo, secondo lo
schema di Figura 13.4:
In questo schema, gli indirizzi di memoria crescono da sinistra verso destra;
di conseguenza, i dati di tipo WORD, DWORD etc, appaiono
disposti al contrario rispetto alla loro rappresentazione con il sistema
posizionale. Ad esempio, nel caso del dato ABCDh, vediamo che il
BYTE meno significativo CDh, si trova alla sinistra del
BYTE più significativo ABh; è importante abituarsi anche a
questo tipo di rappresentazione, in quanto, molti debugger e editor
esadecimali, visualizzano la memoria con gli indirizzi crescenti da
sinistra verso destra.
La CPU non incontra alcuna difficoltà nel gestire la situazione,
apparentemente incomprensibile, di Figura 13.4; infatti, ogni istruzione che
accede a questi dati, codifica l'indirizzo Seg:Offset e l'ampiezza
in bit dei dati stessi.
13.5.2 Linking di CODESEGM
Terminato il linking di DATASEGM, il linker va alla ricerca di altri
segmenti di classe 'DATA'; nel nostro caso, non esistono altri segmenti
di classe 'DATA', per cui il linker passa al segmento CODESEGM.
Per CODESEGM abbiamo richiesto un allineamento di tipo DWORD;
di conseguenza, il linker calcola un indirizzo fisico iniziale successivo al
blocco DATASEGM e multiplo intero di 4. Abbiamo visto che il
blocco DATASEGM termina all'indirizzo fisico 00446h; l'indirizzo
fisico successivo a 00446h e multiplo intero di 4 è
00448h. Il linker allora, inserisce un byte di valore 00h
(buco di memoria) all'indirizzo fisico 00447h in modo da far partire
CODESEGM da 00448h; a questo indirizzo fisico, corrisponde
l'indirizzo logico normalizzato 0044h:0008h. Di conseguenza, il
linker ricava:
CODESEGM = 0044h
Gli offset all'interno di CODESEGM, partono dal valore iniziale
0008h; questo significa che gli offset calcolati dall'assembler,
per tutte le istruzioni di Figura 13.2, vengono traslati in avanti di
8 byte dal linker. Ad esempio, l'istruzione alla linea 288
di Figura 13.2:
mov ds, ax
passa dall'offset 0003h all'offset:
0003h + 0008h = 000Bh
Se al posto di CODESEGM ci fosse stato DATASEGM, sarebbe
successa la stessa cosa; in sostanza, se DATASEGM fosse partito
dall'indirizzo logico normalizzato 0044h:0008h, allora:
- il dato varByte sarebbe stato spostato all'offset:
0000h + 0008h = 0008h
il dato varWord sarebbe stato spostato all'offset:
0001h + 0008h = 0009h
il dato varDword sarebbe stato spostato all'offset:
0003h + 0008h = 000Bh
il dato varQword sarebbe stato spostato all'offset:
0007h + 0008h = 000Fh
e così via.
In una situazione del genere, il linker avrebbe dovuto modificare tutti
i Disp16 presenti nelle istruzioni del programma; nel caso, ad
esempio, dell'istruzione (riga 372 di Figura 13.2):
mov al, [varByte]
il codice macchina:
A0h 0000h
sarebbe stato trasformato dal linker in:
A0h 0008h
Le considerazioni appena esposte illustrano il concetto importantissimo di
rilocazione degli offset.
Tornando al linking di CODESEGM, il linker va alla ricerca di altri
CODESEGM presenti in altri moduli; nel nostro caso, il programma è
formato da un unico modulo, per cui il linker genera la struttura finale di
CODESEGM. In base alle informazioni ricavate dall'assembler in Figura
13.2, questa struttura è rappresentata da un blocco da 0147h byte
(327 byte); i vari byte, occupano tutti gli indirizzi fisici compresi
tra 00448h e:
00448h + (0147h - 1h) = 0058Eh
In termini di indirizzi logici, il blocco CODESEGM viene inserito nel
segmento di memoria n. 0044h e occupa tutti gli offset di questo
segmento, compresi tra 0008h e:
0008h + (0147h - 1h) = 014Eh
In sostanza, CODESEGM è compreso tra gli indirizzi logici
0044h:0008h e 0044h:014Eh; all'interno di questo blocco, il
linker inserisce tutti i codici macchina della seconda colonna di Figura
13.2, relativi al blocco CODESEGM. La Figura 13.5, illustra una parte
della struttura finale di CODESEGM.
Naturalmente, quando CODESEGM verrà caricato in memoria, i vari
codici risulteranno disposti in modo consecutivo e contiguo, secondo lo
schema di Figura 13.6.
Come al solito, se la memoria viene disegnata con gli indirizzi crescenti da
sinistra verso destra, i dati di tipo WORD, DWORD etc, appaiono
disposti al contrario rispetto alla loro rappresentazione con il sistema
posizionale.
Anche in questo caso, la CPU non ha alcuna difficoltà a gestire tutti
questi codici binari; la CPU, infatti, dopo aver letto e decodificato
il primo byte di una istruzione, è in grado di sapere se il codice macchina
è terminato o se bisogna procedere con la lettura e la decodifica di
ulteriori byte. Non appena l'istruzione è stata decodificata, il registro
IP (Instruction Pointer) viene incrementato di un numero di byte pari
a quello che rappresenta la dimensione dell'istruzione stessa; in questo
modo, IP si trova posizionato sempre sulla prossima istruzione da
eseguire.
In base al fatto che CODESEGM parte dall'indirizzo logico
0044h:0008h, il linker è ora in grado di determinare l'indirizzo logico
Seg:Offset dell'entry point del programma; questo indirizzo
verrà poi assegnato dal DOS alla coppia CS:IP. Nel nostro caso,
si tratta ovviamente dell'indirizzo logico assegnato all'etichetta
..start; per questa etichetta, l'assembler aveva calcolato (in Figura
13.2) una componente Offset pari a 0000h. Il linker ha
incrementato di 8 byte quest'offset, per cui, l'indirizzo logico di
..start è proprio 0044h:0008h; di conseguenza, il linker ottiene:
Entry Point = 0044h:0008h
Più avanti verrà illustrato il metodo che permette al linker di passare
l'entry point al DOS.
13.5.3 Linking di STACKSEGM
Terminato il linking di CODESEGM, il linker va alla ricerca di
altri segmenti di classe 'CODE'; nel nostro caso, non esistono
altri segmenti di classe 'CODE', per cui il linker passa al
segmento STACKSEGM.
Per STACKSEGM abbiamo richiesto un allineamento di tipo PARA;
di conseguenza, il linker calcola un indirizzo fisico iniziale successivo
al blocco CODESEGM e multiplo intero di 16. Abbiamo visto che
il blocco CODESEGM termina all'indirizzo fisico 0058Eh;
l'indirizzo fisico successivo a 0058Eh e multiplo intero di
16 è 00590h. Il linker allora, inserisce un byte di valore
00h (buco di memoria) all'indirizzo fisico 0058Fh in modo da
far partire STACKSEGM da 00590h; a questo indirizzo fisico,
corrisponde l'indirizzo logico normalizzato 0059h:0000h. Di
conseguenza, il linker ricava:
STACKSEGM = 0059h
Gli offset all'interno di STACKSEGM, partono dal valore iniziale
0000h, proprio come era stato determinato dall'assembler; di
conseguenza, tutti i calcoli effettuati dall'assembler vengono confermati
(non è necessaria alcuna rilocazione degli offset dei dati).
Il linker va alla ricerca di altri STACKSEGM presenti in altri
moduli; nel nostro caso, il programma è formato da un unico modulo, per
cui il linker genera la struttura finale di STACKSEGM. In base alle
informazioni ricavate dall'assembler in Figura 13.2, questa struttura è
rappresentata da un blocco da 0400h byte (1024 byte); i
vari byte, occupano tutti gli indirizzi fisici compresi tra 00590h e:
00590h + (0400h - 1h) = 0098Fh
In termini di indirizzi logici, il blocco STACKSEGM viene inserito
nel segmento di memoria n. 0059h e occupa tutti gli offset di
questo segmento, compresi tra 0000h e:
0000h + (0400h - 1h) = 03FFh
In sostanza, STACKSEGM è compreso tra gli indirizzi logici
0059h:0000h e 0059h:03FFh; all'interno di questo blocco,
il linker inserisce tutti i valori della terza colonna, riga 447
di Figura 13.2. Si tratta in pratica di 1024 byte non inizializzati;
quest'area da 1024 byte è destinata ai dati temporanei del programma.
A questo punto, il linker nota la presenza dell'attributo di combinazione
STACK per il segmento STACKSEGM; di conseguenza, il linker
procede al calcolo dell'indirizzo logico iniziale Seg:Offset che
il DOS assegnerà poi alla coppia SS:SP. Abbiamo visto in
precedenza, che il blocco STACKSEGM parte dall'indirizzo logico
0059h:0000h ed occupa 0400h byte; in base a queste informazioni,
il linker ricava facilmente l'indirizzo logico 0059h:0400h da passare
al DOS.
Se non utilizziamo l'attributo STACK per STACKSEGM, il compito
di inizializzare SS:SP (in fase di esecuzione) spetta a noi; in tal
caso, dobbiamo scrivere subito dopo l'entry point:
Terminato il linking di STACKSEGM, il linker va alla ricerca di
altri segmenti di classe 'STACK'; nel nostro caso, non esistono
altri segmenti di classe 'STACK', per cui, la fase di linking dei
vari segmenti di programma può considerarsi terminata.
13.5.4 Il Map File
Una volta che la fase di linking è terminata, digitando dalla command line il
comando:
dir
e premendo [Invio], viene visualizzato, come al solito, l'elenco di
tutti i file presenti nella cartella ASMBASE; in questo modo, si scopre
che, oltre ai 3 file generati dall'assembler, ci sono anche due nuovi
file generati dal linker, con i nomi EXETEST.EXE e EXETEST.MAP.
Naturalmente, EXETEST.EXE rappresenta la versione eseguibile del
nostro programma; l'estensione EXE sta, infatti, per executable.
Il file EXETEST.MAP, invece, rappresenta il cosiddetto map file;
il suo scopo è quello di mostrare un riassunto semplificato di tutto il lavoro
che il linker ha compiuto sul file oggetto EXETEST.OBJ.
Per generare il map file con i linker della Microsoft bisogna
utilizzare l'opzione /map.
Il map file ci permette di verificare la correttezza di tutti i
calcoli che abbiamo effettuato in precedenza; trattandosi di un file di testo,
lo possiamo visualizzare con un normale editor
ASCII.
La Figura 13.7 illustra il map file generato dal linker del
MASM.
(eventuali segmenti di ampiezza nulla elencati nel map file, sono
legati a convenzioni interne di NASM e possono essere ignorati).
L'esame del file EXETEST.MAP conferma l'esattezza di tutti i
calcoli relativi agli indirizzi fisici e logici, che abbiamo effettuato
in precedenza; osserviamo, inoltre, che il linker ha rispettato tutte le
direttive che avevamo impartito all'assembler in relazione all'ordine
con il quale disporre in memoria i vari segmenti di programma.
In Figura 13.7 si nota che anche il linker, come l'assembler, mostra tutte
le informazioni numeriche, in formato esadecimale; a maggior ragione,
quindi, è importantissimo che i programmatori Assembly abbiano
grande dimestichezza con la rappresentazione dei numeri in base 16.
13.6 Struttura di un file EXE
Tra i vari formati eseguibili disponibili per il DOS, il formato
EXE è in assoluto il più potente e flessibile; la caratteristica più
evidente del formato EXE, è rappresentata dalla possibilità di
inserire al suo interno un numero arbitrario di segmenti di programma. In
caso di necessità quindi, si possono scrivere programmi EXE formati
da numerosi segmenti di dati e di codice; il numero dei vari segmenti
presenti in un programma, può crescere sino ad occupare tutta la memoria
convenzionale disponibile.
Naturalmente, per poter garantire tutta questa flessibilità, il DOS
ha bisogno di conoscere una serie di importanti informazioni, relative alla
struttura interna del programma EXE; in particolare, a causa della
presenza di due o più segmenti di programma, il DOS deve sapere quale
di questi segmenti contiene l'entry point (per poter inizializzare la
coppia CS:IP) e quale di questi segmenti verrà usato come stack (per
poter inizializzare la coppia SS:SP).
Come abbiamo appena visto, gli indirizzi logici iniziali da assegnare alle
coppie CS:IP e SS:SP, sono già stati calcolati dal linker; non
bisogna dimenticare però che il linker, assegna a qualsiasi programma un
indirizzo fisico iniziale simbolico, pari a 00000h. Come si vede nella
Figura 9.7 del Capitolo 9, i primi KiB della RAM sono riservati a
svariate informazioni di sistema (vettori di interruzione, area dati del
BIOS, kernel del DOS, etc); è assolutamente impossibile quindi,
che un programma possa essere caricato in memoria a partire dall'indirizzo
fisico 00000h!
Questo significa che anche il DOS dovrà procedere alla rilocazione
degli indirizzi logici calcolati dal linker; questa volta, la rilocazione
riguarda solo le componenti Seg assegnate a questi indirizzi. Infatti,
le componenti Offset delle informazioni presenti nei segmenti di
programma, non sono altro che degli spiazzamenti calcolati rispetto
all'inizio dei segmenti stessi; questi spiazzamenti sono già stati calcolati
(ed eventualmente rilocati) in modo definitivo dal linker e non devono essere
più modificati.
Consideriamo, ad esempio, una informazione che si trova all'indirizzo logico
03B2h:0008h; se ora rilochiamo la componente Seg sommandogli
il valore 210Ah, otteniamo il nuovo indirizzo 24BCh:0008h.
Come si può notare, la componente Offset rimane invariata; in sostanza,
l'informazione dell'esempio è passata dall'offset 0008h del segmento
di memoria n. 03B2h, all'offset 0008h del segmento di memoria
n. 24BCh.
Come conseguenza della rilocazione dei segmenti, il DOS ha anche
bisogno di poter modificare il codice macchina di eventuali istruzioni del
tipo:
mov ax, DATASEGM
È chiaro, infatti, che, se DATASEGM viene rilocato dal DOS, è
anche necessario modificare il codice macchina della precedente istruzione;
per poter svolgere questo lavoro, il DOS ha bisogno di conoscere il
cosiddetto relocation record di ciascuna istruzione che fa riferimento
ad un segmento rilocabile. Il relocation record è l'indirizzo
Seg:Offset di un Imm16 che, in una istruzione, rappresenta una
componente Seg rilocabile; come esempio pratico, consideriamo proprio
la precedente istruzione, alla quale corrisponde, in Figura 13.2, il codice
macchina:
B8h 0000h
L'assembler ha assegnato a questo codice macchina, l'offset 0000h
calcolato rispetto all'inizio di CODESEGM; il linker ha assegnato
a DATASEGM il valore 0000h, a CODESEGM il valore
0044h e ha rilocato (definitivamente) l'offset della precedente
istruzione, portandolo a 0008h.
In seguito alle modifiche apportate dal linker, il codice macchina di questa
istruzione rimane invariato, mentre il suo indirizzo logico iniziale diventa
0044h:0008h; il valore che il DOS deve rilocare nel codice
macchina, è 0000h, che si trova chiaramente all'indirizzo logico
0044h:0009h (1 byte dopo il codice B8h). Di conseguenza,
il relocation record relativo a questa istruzione è 0044h:0009h.
Nel momento in cui EXETEST.EXE viene caricato in memoria, il DOS
procede alla rilocazione dei segmenti DATASEGM, CODESEGM e
STACKSEGM (ovviamente, vengono rilocate anche le componenti Seg
dei vari relocation records); subito dopo, il DOS accede alle
istruzioni puntate dai vari relocation records e modifica ogni
riferimento agli stessi DATASEGM, CODESEGM e STACKSEGM.
Tutte le informazioni di cui il DOS ha bisogno per poter gestire un
file EXE, vengono generate dal linker; a tale proposito, come è stato
già anticipato, il linker suddivide un file EXE in due parti chiamate,
header (intestazione), e loadable module (modulo caricabile,
contenente il programma vero e proprio). All'interno dell'header,
vengono infilate tutte le informazioni richieste dal DOS; la Figura
13.8 contiene la descrizione dell'header di un file EXE (per
maggiori dettagli, si consiglia di consultare un manuale per programmatori
DOS).
Nel prossimo capitolo, analizzeremo alcuni metodi di disassemblaggio di un
programma eseguibile; in questo modo, sarà possibile esaminare in pratica
l'header di un file EXE. La Figura 13.9, visualizza una serie di
informazioni su EXETEST.EXE, facilmente deducibili anche senza
consultare il contenuto dell'header.
13.7 Esecuzione di un file EXE
Una volta che abbiamo a disposizione il nostro programma EXETEST.EXE,
possiamo procedere con la sua esecuzione; a tale proposito, dobbiamo posizionarci
nella cartella ASMBASE e impartire il comando:
exetest
Premendo ora il tasto [Invio], cediamo il controllo all'interprete dei
comandi COMMAND.COM; l'interprete dei comandi, si accorge subito che
exetest non è un comando DOS e si mette allora alla ricerca, nella
cartella ASMBASE, di un eseguibile di nome EXETEST.
In base alle convenzioni seguite dal DOS, COMMAND.COM cerca un file
chiamato EXETEST.COM; se questo file non viene trovato, COMMAND.COM
cerca un file chiamato EXETEST.EXE. Se questo file non viene trovato,
COMMAND.COM cerca un file chiamato EXETEST.BAT; i file con
estensione BAT vengono chiamati batch file e sono dei semplici file
di testo contenenti una sequenza di comandi DOS. Se il file
EXETEST.BAT non viene trovato, COMMAND.COM genera un messaggio di
errore del tipo:
Comando o nome file non valido
Nel nostro caso, nella cartella ASMBASE, l'interprete dei comandi trova
il file EXETEST.EXE; supponendo che si tratti di un file eseguibile,
COMMAND.COM cede il controllo ad un apposito programma del SO,
chiamato loader (caricatore). Come si intuisce dal nome, il compito del
loader è quello di caricare in memoria i programmi eseguibili; a tale
proposito, il loader consulta l'eventuale header del file da
eseguire. Se viene trovata la stringa iniziale 'MZ', il file viene
trattato come EXE; in caso contrario, il loader cerca di trattare
EXETEST come un eseguibile di tipo COM. È chiaro che, se proviamo
ad eseguire un "finto" file EXE o COM, provochiamo una situazione
di errore che può anche determinare un crash (Windows visualizza una
finestra di errore).
Nel caso di EXETEST.EXE, il loader trova la stringa 'MZ' e
quindi, avvia le procedure standard per il caricamento in memoria di un programma
EXE; per ogni programma, EXE o COM, da caricare in memoria,
vengono predisposti due blocchi di memoria chiamati environment segment e
program segment. Come è stato spiegato in un precedente capitolo, tutti i
blocchi di memoria forniti dal DOS, sono sempre allineati al paragrafo;
inoltre, la dimensione in byte di un qualunque blocco di memoria DOS, è
sempre un multiplo intero di 16.
13.7.1 L'environment segment
L'environment segment o segmento d'ambiente di un programma, è un
blocco di memoria DOS destinato a contenere una sequenza di stringhe,
chiamate variabili d'ambiente; ogni stringa assume la forma:
'NOME=contenuto', 0
Come si può notare, queste stringhe seguono le convenzioni del linguaggio
C; una stringa C è un vettore di codici
ASCII
terminato da un byte di valore 0 (il codice
ASCII
0 viene anche chiamato NULL).
Subito dopo l'ultima stringa della sequenza, è presente un byte di valore 0,
che rappresenta quindi una stringa C vuota.
Nelle versioni più recenti del DOS, subito dopo questo byte, è presente una
WORD contenente il numero di eventuali stringhe extra; nel caso generale, è
presente una sola stringa extra che contiene il nome dell'eseguibile, completo di
percorso (path).
Le variabili d'ambiente di un programma, vengono passate da chi ha richiesto
l'esecuzione del programma stesso; nel caso più frequente, l'esecuzione di un
programma viene richiesta da COMMAND.COM. Anche nell'esempio presentato in
questo capitolo, è proprio COMMAND.COM che ha richiesto l'esecuzione di
EXETEST.EXE; si dice allora che COMMAND.COM è padre di
EXETEST.EXE, oppure che EXETEST.EXE è figlio di
COMMAND.COM. A sua volta, EXETEST.EXE potrebbe richiedere
l'esecuzione di un altro programma chiamato, ad esempio, TEST2.EXE; in
questo caso, EXETEST.EXE, come padre di TEST2.EXE, deve
provvedere a passare le variabili d'ambiente allo stesso TEST2.EXE.
La Figura 13.10, mostra un esempio in versione Assembly, della struttura
del segmento d'ambiente, che COMMAND.COM crea per il programma figlio
EXETEST.EXE.
La variabile COMSPEC viene inizializzata dal SO e viene usata
dai programmi, per invocare l'interprete dei comandi COMMAND.COM; questo
è proprio ciò che accadeva, quando si lanciava la DOS Box dall'interno
del vecchio Windows98.
Le due variabili PATH e PROMPT di Figura 13.10 vengono, invece,
configurate dall'utente attraverso il file AUTOEXEC.BAT; a tale proposito,
all'interno del file C:\AUTOEXEC.BAT, si può utilizzare il comando
SET per scrivere, ad esempio:
SET PATH=C:\DOS;C:\WINDOWS;C:\NASM
Per approfondire questi concetti, si consiglia di consultare un manuale per
programmatori DOS.
Chi conosce il linguaggio C, sarà interessato a sapere che l'unica stringa
extra presente nel segmento d'ambiente, rappresenta il famoso argomento
argv[0] passato (dal compilatore C) alla funzione:
int main(int argc, char *argv[])
Un segmento d'ambiente, può arrivare a contenere sino a 32 KiB di
informazioni; bisogna prestare quindi particolare attenzione al fatto che,
numerose richieste di esecuzione a catena, da un programma ad un altro, possono
occupare una notevole quantità di memoria convenzionale.
13.7.2 Il program segment
Il program segment o segmento di programma (da non confondere
con i segmenti che formano un programma), è un blocco di memoria DOS
destinato a contenere il PSP o Program Segment Prefix (prefisso
del segmento di programma) e il programma eseguibile vero e proprio; la Figura
13.11 illustra le caratteristiche del PSP (per maggiori dettagli, si
consiglia di consultare un manuale per programmatori DOS).
13.7.3 Caricamento in memoria di EXETEST.EXE
Supponiamo ora che il DOS abbia creato l'environment segment
e il program segment per EXETEST.EXE; supponiamo anche che il
program segment parta dall'indirizzo fisico (sempre allineato al
paragrafo) 024B0h. A questo indirizzo fisico, corrisponde l'indirizzo
logico normalizzato 024Bh:0000h; possiamo dire quindi che il program
segment destinato a contenere il modulo caricabile di EXETEST.EXE,
inizia dall'offset 0000h del segmento di memoria n. 024Bh.
Nei primi 256 byte (0100h byte) del program segment,
viene sistemato il PSP; possiamo dire quindi che il PSP, occupa
tutti gli indirizzi fisici del program segment, compresi tra
024B0h e:
024B0h + (0100h - 1h) = 025AFh
In termini di indirizzi logici, il PSP viene inserito nel segmento
di memoria n. 024Bh e occupa tutti gli offset di questo segmento,
compresi tra 0000h e:
0000h + (0100h - 1h) = 00FFh
In sostanza, il PSP è compreso tra gli indirizzi logici
024Bh:0000h e 024Bh:00FFh.
L'inizio del PSP coincide con l'inizio del program segment; per
convenzione, si utilizza allora la sigla PSP, per indicare la componente
Seg dell'indirizzo logico iniziale (024Bh:0000h) dello stesso
program segment. Possiamo dire quindi che:
PSP = 024Bh
Immediatamente dopo il PSP, viene sistemato il modulo caricabile
prelevato dal file EXETEST.EXE; siccome il PSP termina
all'indirizzo fisico 025AFh, possiamo dire che il modulo caricabile
parte dall'indirizzo fisico 025B0h, allineato al paragrafo. A
questo indirizzo fisico, corrisponde l'indirizzo logico normalizzato
025Bh:0000h; la componente Seg di questo indirizzo logico,
è proprio quella che viene utilizzata dal DOS, per rilocare i
segmenti di EXETEST.EXE. Indicando simbolicamente questa componente
Seg con il nome StartSeg, possiamo scrivere:
StartSeg = 025Bh
Come si può notare, StartSeg si trova 16 paragrafi più avanti
di PSP; a questo punto, con l'aiuto dell'header di Figura 13.9,
il DOS procede con la rilocazione dei segmenti di programma di
EXETEST.EXE.
Il linker aveva assegnato a DATASEGM l'indirizzo fisico iniziale
00000h; il DOS somma questo indirizzo a 025B0h e
ottiene:
00000h + 025B0h = 025B0h
A questo indirizzo fisico, corrisponde l'indirizzo logico normalizzato
025Bh:0000h; di conseguenza:
DATASEGM = 025Bh = StartSeg
Come si può notare, gli offset interni a DATASEGM non subiscono
alcuna rilocazione e continuano a partire da 0000h (confermando
i calcoli del linker); osserviamo anche che l'indirizzo fisico iniziale
025B0h di DATASEGM, è compatibile con l'attributo di
allineamento PARA.
Il linker aveva assegnato a CODESEGM l'indirizzo fisico iniziale
00448h; il DOS somma questo indirizzo a 025B0h e
ottiene:
00448h + 025B0h = 029F8h
A questo indirizzo fisico, corrisponde l'indirizzo logico normalizzato
029Fh:0008h; di conseguenza:
CODESEGM = 029Fh = InitialCS + StartSeg
Come si può notare, gli offset interni a CODESEGM non subiscono
alcuna rilocazione e continuano a partire da 0008h (confermando
i calcoli del linker); osserviamo anche che l'indirizzo fisico iniziale
029F8h di CODESEGM, è compatibile con l'attributo di
allineamento DWORD.
Il linker aveva assegnato a STACKSEGM l'indirizzo fisico iniziale
00590h; il DOS somma questo indirizzo a 025B0h e
ottiene:
00590h + 025B0h = 02B40h
A questo indirizzo fisico, corrisponde l'indirizzo logico normalizzato
02B4h:0000h; di conseguenza:
STACKSEGM = 02B4h = InitialSS + StartSeg
Come si può notare, gli offset interni a STACKSEGM non subiscono
alcuna rilocazione e continuano a partire da 0000h (confermando
i calcoli del linker); osserviamo anche che l'indirizzo fisico iniziale
02B40h di STACKSEGM, è compatibile con l'attributo di
allineamento PARA.
Nella relocation table (Figura 13.9), il DOS trova un solo
relocation record rappresentato dalla coppia 0044h:0009h;
la componente Seg di questa coppia, viene ovviamente rilocata,
e si ottiene:
(0044h + StartSeg):0009h = 029Fh:0009h
A questo indirizzo di CODESEGM, è presente il valore da rilocare
0000h (DATASEGM), relativo al codice macchina:
B8h 0000h
dell'istruzione:
mov ax, DATASEGM
Il valore 0000h viene rilocato (sommato a StartSeg) e il
precedente codice macchina diventa:
B8h 025Bh
Il DOS passa ora alla inizializzazione dei registri della CPU;
per la coppia CS:IP, il DOS pone:
CS:IP = CODESEGM:InitialIP = 029Fh:0008h
Per la coppia SS:SP, il DOS pone:
SS:SP = STACKSEGM:InitialSP = 02B4h:0400h
Per i due registri di segmento DS e ES, il DOS
pone per convenzione:
DS = ES = PSP = 024Bh
A questo punto, il programma è pronto per l'esecuzione; in seguito a tutto
questo lavoro svolto dal DOS, il programma EXETEST.EXE assume
in memoria la disposizione illustrata in Figura 13.12.
In un programma EXE, possono essere presenti, eventualmente, diversi
segmenti di dati; proprio per questo motivo, il SO non può sapere
quale di questi segmenti di dati deve essere utilizzato per inizializzare
DS. Questo compito quindi, viene lasciato al programmatore.
Convenzionalmente, il DOS inizializza DS e ES, con la
componente Seg (che abbiamo chiamato PSP) dell'indirizzo
iniziale del program segment; questo significa che se vogliamo
accedere al contenuto del PSP, possiamo servirci di DS o
ES e delle informazioni visibili in Figura 13.11.
In genere, subito dopo l'entry point di un programma, DS viene
inizializzato dal programmatore con un segmento di dati, per cui ci rimane
a disposizione ES; in questo caso, il PSP è accessibile a
partire dall'indirizzo logico ES:0000h.
Il campo CommandLineParmLength, ad esempio, si trova all'indirizzo
logico ES:0080h; analogamente, il campo CommandLineParameters,
si trova all'indirizzo logico ES:0081h.
L'environment segment del programma in esecuzione, si trova in
memoria a partire dall'indirizzo logico EnvironmentSegment:0000h;
a sua volta, il campo EnvironmentSegment si trova all'indirizzo
logico ES:002Ch.
Molto spesso, si rende necessario anche il ricorso a ES per la gestione
dei dati di un programma; proprio per questo motivo, l'elaborazione delle
informazioni presenti nel PSP deve essere effettuata prima che il
contenuto iniziale di DS e ES venga sovrascritto.
13.7.4 Esecuzione di EXETEST.EXE
Una volta che il DOS ha inizializzato i necessari registri della CPU,
può partire la fase di esecuzione di EXETEST.EXE; il controllo passa quindi
alla stessa CPU che trova la coppia CS:IP inizializzata con
l'entry point 029Fh:0008h. A questo indirizzo interno a
CODESEGM, è presente il codice macchina:
B8h 025Bh
La CPU quindi, decodifica ed esegue questa istruzione; nel frattempo,
IP viene incrementato in modo che la coppia CS:IP punti alla
prossima istruzione da eseguire.
La precedente istruzione, ha un codice macchina da 3 byte; di conseguenza,
la CPU pone:
IP = IP + 0003h = 0008h + 0003h = 000Bh
All'indirizzo 029Fh:000Bh, interno a CODESEGM, è presente il
codice macchina:
8Eh D8h
La CPU quindi, decodifica ed esegue questa istruzione; nel frattempo,
IP viene incrementato in modo che la coppia CS:IP punti alla
prossima istruzione da eseguire.
La precedente istruzione, ha un codice macchina da 2 byte; di conseguenza,
la CPU pone:
IP = IP + 0002h = 000Bh + 0002h = 000Dh
e così via.
L'esecuzione prosegue in questo modo, finché non si arriva al blocco di
istruzioni che terminano il programma e restituiscono il controllo al DOS.
13.8 Le direttive ALIGN e ALIGNB
Il programma di esempio EXETEST.EXE presentato in questo capitolo,
ha una finalità puramente didattica e non pone quindi alcuna attenzione
al problema del corretto allineamento delle informazioni in memoria; è
chiaro però che nei programmi reali, questo aspetto non può essere trascurato.
Come è stato ampiamente detto nei precedenti capitoli, il programmatore
Assembly ha la possibilità di gestire in modo diretto, tutti i
dettagli relativi al tipo di allineamento da assegnare, sia ai segmenti di
programma, sia alle informazioni contenute nei segmenti stessi; codice e dati
correttamente allineati in memoria, vengono acceduti alla massima velocità
possibile dalla CPU.
Nel capitolo precedente, abbiamo visto che grazie all'attributo
Allineamento, possiamo richiedere al linker che un determinato
segmento di programma venga disposto in memoria a partire da un indirizzo
fisico avente particolari requisiti di allineamento; questo attributo
agisce quindi esclusivamente sull'indirizzo fisico iniziale dei segmenti
di programma.
Se vogliamo imporre anche l'allineamento delle informazioni contenute
all'interno di un segmento di programma, dobbiamo servirci delle direttive:
ALIGN n
e
ALIGNB n
La direttiva ALIGN richiede obbligatoriamente un primo argomento
che deve essere un numero intero positivo n, potenza intera di
2 (quindi, 2, 4, 8, 16, etc); in questo
modo diciamo all'assembler che la successiva informazione deve partire da
un offset multiplo intero di n.
Il secondo argomento è facoltativo e rappresenta il valore da 1
byte che l'assembler deve utilizzare per riempire i necessari buchi di
memoria, sino ad ottenere l'allineamento richiesto; in assenza del secondo
argomento, ALIGN utilizza il valore predefinito 90h che
rappresenta il codice macchina dell'istruzione NOP.
Il mnemonico NOP significa no operation (nessuna operazione
da eseguire); in presenza del codice macchina 90h, la CPU si
limita ad incrementare di 1 il registro IP passando così
all'elaborazione dell'istruzione successiva.
In alternativa, possiamo specificare il secondo argomento di ALIGN
attraverso le direttive DB o RESB (le quali, ovviamente,
devono indicare una sola locazione da 1 byte); possiamo scrivere,
ad esempio:
ALIGN 4, db 0
In questo modo, l'assembler allinea il dato successivo ad un offset multiplo
intero di 4 (allineamento alla DWORD) e riempie i necessari
buchi di memoria con un numero opportuno di byte di valore 00h.
Se scriviamo, invece:
ALIGN 2, resb 1
stiamo dicendo all'assembler di allineare il dato successivo ad un offset
multiplo intero di 2 (allineamento alla WORD); i necessari
buchi di memoria vengono riempiti dall'assembler con un numero opportuno
di byte non inizializzati.
La direttiva ALIGNB è del tutto simile ad ALIGN; l'unica
differenza sta nel fatto che, in assenza di diverse indicazioni da parte
del programmatore, ALIGNB utilizza come secondo argomento predefinito
la direttiva RESB 1.
Per motivi abbastanza ovvi, non avrebbe senso specificare un valore di
n che supera quello dell'attributo Allineamento; nel caso, ad
esempio, di un segmento con attributo PARA (16 byte), il
valore di n non può superare 16.
Naturalmente, nel caso di CPU con architettura a 8 bit, tutto
ciò che riguarda l'allineamento dei segmenti di programma e del loro
contenuto, non ha alcun significato; il discorso cambia, invece, nel momento
in cui abbiamo a che fare con CPU a 16 bit, 32 bit,
64 bit, etc.
Appare ovvio il fatto che le direttive ALIGN e ALIGNB,
raggiungono la massima efficacia quando lavorano in combinazione con
l'attributo Allineamento; allineando, ad esempio, un segmento dati
ad un indirizzo fisico pari, possiamo facilmente allineare ad indirizzi pari
anche i dati contenuti nel segmento stesso.
Per capire meglio questi importanti concetti, analizziamo in dettaglio i
metodi da utilizzare per allineare in memoria, il codice, i dati statici e
i dati temporanei del programma EXETEST.EXE presentato in questo
capitolo.
13.8.1 Allineamento in memoria dei dati statici
Osservando il file EXETEST.LST di Figura 13.2, ci accorgiamo che nel
segmento DATASEGM, l'allineamento dei dati appare piuttosto
insoddisfacente; si nota, infatti, che il dato varByte si trova ad
un offset pari (0000h), mentre gli 11 dati successivi, si
trovano tutti ad offset dispari.
Questa situazione, è dovuta al fatto che varByte, che parte appunto
da un offset pari, ha una ampiezza dispari (1) in byte, mentre gli
11 dati successivi, hanno tutti una ampiezza pari in byte; di
conseguenza, varByte costringe gli 11 dati successivi, a
posizionarsi tutti ad un offset dispari.
Se stiamo lavorando con CPU a 16 bit o superiori, si tratta
di una situazione veramente pessima; infatti, questa situazione provoca
un enorme aumento del numero di accessi in memoria, necessari alla
CPU per leggere o scrivere i dati presenti in DATASEGM.
Per evitare questo problema, possiamo servirci di una semplice regola
generale, valida per una qualsiasi CPU avente un Data Bus
con ampiezza pari a n byte; questa regola consiste nell'inserire
una direttiva ALIGN n, prima di qualsiasi dato il cui allineamento
non è un multiplo intero di n e dopo qualsiasi dato la cui ampiezza
in byte non è un multiplo intero di n.
Per dimostrare questa regola, consideriamo una CPU con architettura
a 16 bit, cioè con Data Bus avente ampiezza pari a 2
byte; osservando il listing file di Figura 13.2, possiamo notare che,
ad eccezione di varByte che ha allineamento pari e dimensione dispari,
tutti gli altri 11 dati hanno allineamento dispari e ampiezza pari.
Proviamo allora ad apportare una semplicissima modifica che consiste
nell'inserire un buco di memoria da 1 byte tra varByte e
varWord; la porzione di DATASEGM interessata da questa
modifica, viene mostrata in Figura 13.13.
Osserviamo subito che varByte continua a partire dall'offset
0000h; subito dopo varByte, troviamo un buco di memoria da
1 byte che si trova all'offset 0001h. Questo buco di memoria,
fa scalare in avanti di 1 byte gli offset di tutti gli altri
11 dati; inoltre, questi 11 dati hanno tutti una ampiezza
pari in byte.
Riassemblando ora il programma di Figura 13.1 e consultando il nuovo
listing file, possiamo constatare che con questa semplice modifica,
tutti i dati presenti in DATASEGM si vengono a trovare allineati ad
indirizzi pari!
Possiamo ottenere lo stesso risultato, attraverso la direttiva ALIGN 2;
in questo modo si perviene alla situazione illustrata in Figura 13.14 (si
noti che per il riempimento dei buchi di memoria è stato utilizzato un byte
di valore 00h).
Quando l'assembler esamina DATASEGM, assegna a varByte
l'offset 0000h; subito dopo, l'assembler incontra la direttiva
ALIGN, attraverso la quale stiamo richiedendo un allineamento ad un
offset pari per varWord. L'offset pari successivo a 0000h è
ovviamente 0002h; di conseguenza, l'assembler inserisce un buco di
memoria da 1 byte (inizializzato a 00h) subito dopo
varByte e fa così scalare in avanti di 1 byte gli offset di
tutti i successivi 11 dati. Questi 11 dati hanno tutti una
ampiezza pari in byte, per cui si troveranno tutti allineati ad un offset
pari; alla fine si ottiene quindi l'identica situazione mostrata in Figura
13.13.
Veniamo ora al caso molto più frequente di una CPU con architettura
a 32 bit, cioè con Data Bus avente ampiezza pari a 4
byte; osservando il listing file di Figura 13.2, possiamo notare che
questa volta, non basta inserire una direttiva ALIGN 4 tra
varByte e varWord. Questa direttiva, fa scalare in avanti di
3 byte l'offset di varWord, che si viene così a trovare
all'offset 0004h; a causa però dell'ampiezza pari a 2 byte
della stessa varWord, la successiva variabile varDword si
viene a trovare all'offset 0006h che non è un multiplo intero di
4.
È necessaria quindi una nuova direttiva ALIGN 4 tra varWord
e varDword; in questo modo, varDword, varQword e
varTbyte si vengono a trovare ad offset multipli interi di 4.
La variabile varTbyte ha una ampiezza pari a 10 byte,
per cui provoca il disallineamento di varFloat32; è necessaria
quindi una nuova direttiva ALIGN 4 tra varTbyte e
varFloat32, in modo che, varFloat32, varFloat64,
dataViaggio, varRect, vettRect e FiatPanda
si vengano a trovare ad offset multipli interi di 4.
La variabile FiatPanda ha una ampiezza pari a 46 byte, per
cui provoca il disallineamento di vettAuto; è necessaria quindi
un'ultima direttiva ALIGN 4 tra FiatPanda e vettAuto.
Si perviene in questo modo alla situazione finale mostrata in Figura 13.15.
Se vogliamo svolgere un lavoro più meticoloso, possiamo ricordare che, con
le CPU a 32 bit, i dati di tipo WORD non hanno problemi
di allineamento, purché si trovino ad indirizzi pari; sfruttando questo
aspetto, possiamo inserire una direttiva ALIGN 2 tra varByte e
varWord. Questa modifica ci permette di eliminare la direttiva
ALIGN 4 tra varWord e varDword; in questo modo, inoltre,
riduciamo anche le dimensioni complessive di DATASEGM, in quanto le
direttive ALIGN, comportano un certo spreco di memoria.
La Figura 13.15 ci permette anche di osservare che dati come FiatPanda,
a causa della loro ampiezza in byte, creano problemi di allineamento ai dati
successivi; possiamo dire allora che lavorando con una CPU avente
Data Bus a n byte, sarebbe meglio definire dati statici con
ampiezza multipla intera di n byte.
Per risolvere questo problema, possiamo inserire, ad esempio, opportuni buchi
di memoria all'interno delle dichiarazioni dei dati; possiamo anche sfruttare
il fatto che l'assembler ci permette di inserire le direttive ALIGN e
ALIGNB all'interno delle dichiarazioni di STRUC.
Supponiamo, ad esempio, di voler ottimizzare la struttura Automobile
per le CPU a 32 bit; possiamo apportare allora le modifiche
mostrate in Figura 13.16.
La direttiva ALIGNB utilizza RESB 1 come secondo argomento
predefinito; l'uso di RESB è necessario in quanto all'interno di
un blocco STRUC non possiamo inizializzare i dati.
Ricordiamo ancora una volta che tutte le WORD con allineamento pari
comportano un unico accesso in memoria da parte delle CPU con
architettura a 32 bit; dalla Figura 13.16 si ricava allora quanto
segue:
- CodModello si trova all'offset 0000h di Automobile
- Cilindrata si trova all'offset 0002h di Automobile
- Cavalli si trova all'offset 0004h di Automobile
- Revisioni si trova all'offset 0008h di Automobile
Allineando allora un dato di tipo Automobile ad un indirizzo fisico
multiplo intero di 4, ottimizziamo al massimo l'accesso al dato stesso
da parte di una CPU a 32 bit; osserviamo inoltre che, grazie
alla direttiva ALIGN, la struttura di Figura 13.16 assume una ampiezza
in byte pari a 48 (multipla intera di 4), che tiene allineato
alla DWORD anche il dato successivo.
13.8.2 Allineamento in memoria dei dati temporanei
Come è stato già detto nel capitolo 10, un programma in esecuzione può fare un
uso veramente massiccio dello stack; inoltre, lo stack di un programma viene
pesantemente utilizzato anche dalle ISR chiamate dalla CPU in
risposta alle richieste di interruzione software e hardware. Il programmatore
è tenuto quindi a curare con grande attenzione il corretto allineamento delle
informazioni presenti nello stack; vediamo allora come ci si deve comportare
nel caso delle CPU con architettura a 16 e a 32 bit.
Nei precedenti capitoli è stato detto che le CPU con architettura a
16 bit, per la gestione dello stack forniscono le due istruzioni
PUSH e POP, che accettano esclusivamente operandi di tipo
WORD; in questo caso, il corretto allineamento dei dati temporanei, può
essere ottenuto in modo estremamente semplice.
In base alle considerazioni svolte in precedenza (in relazione al blocco
DATASEGM), dobbiamo innanzi tutto far partire il segmento di stack da
un indirizzo fisico multiplo intero di 2; è necessario quindi evitare
l'uso dell'attributo di allineamento BYTE. A questo punto, dobbiamo
assegnare a SP un offset iniziale multiplo intero di 2; con
questi semplici accorgimenti, tutte le WORD inserite nello stack si
verranno a trovare ad indirizzi fisici pari e verranno accedute alla massima
velocità possibile dalla CPU.
Nel caso delle CPU con architettura a 32 bit, la situazione è più
delicata; come già sappiamo, queste particolari CPU permettono di
utilizzare le istruzioni PUSH e POP con operandi, sia di tipo
WORD, sia di tipo DWORD. La cosa migliore da fare, consiste allora
nell'utilizzare PUSH e POP, esclusivamente con operandi di tipo
DWORD; questa tecnica viene largamente impiegata nei SO a
32 bit come Windows e Linux.
Ripetendo quindi il ragionamento precedente, dobbiamo innanzi tutto allineare
il segmento di stack ad un indirizzo fisico multiplo intero di 4
(attributo DWORD o PARA); successivamente, dobbiamo assegnare a
SP un offset iniziale multiplo intero di 4. In questo modo,
tutte le DWORD presenti nello stack, si vengono a trovare ad indirizzi
fisici allineati alla DWORD.
Se vogliamo usare PUSH e POP con operandi di tipo WORD e
DWORD, la situazione diventa più complicata; in questo caso, infatti,
dobbiamo fare in modo che gli operandi di tipo WORD si trovino sempre
ad indirizzi multipli interi di 2 e che gli operandi di tipo DWORD
si trovino sempre ad indirizzi multipli interi di 4. Una tale situazione
è però troppo difficile da gestire; in casi del genere, si può anche tollerare
un piccolo aumento di accessi in memoria, dovuto a qualche dato di tipo
DWORD allineato ad un indirizzo che, pur essendo pari, non è un multiplo
intero di 4.
13.8.3 Allineamento in memoria delle istruzioni
Come sappiamo, nelle CPU di classe CISC, vengono utilizzate delle
istruzioni codificate con un numero variabile (e spesso anche dispari) di byte;
ciò significa che in un segmento di codice, moltissime istruzioni si vengono a
trovare fuori allineamento. È chiaro però che sarebbe assurdo pensare di
allineare ogni singola istruzione di un blocco codice; in questo modo, infatti,
si verificherebbe un enorme aumento delle dimensioni del programma.
Proprio per questo motivo, la strada che si segue consiste nell'allineare
solamente quelle istruzioni che si trovano in posizioni particolarmente critiche;
nel caso più frequente, si tratta di quelle istruzioni che vengono raggiunte
dalla CPU attraverso un salto (con conseguente svuotamento della
prefetch queue). Questi aspetti sono stati già anticipati nel capitolo
10 (sezione 10.6); nei capitoli successivi, verranno analizzati diversi
accorgimenti che permettono di ottenere un certo miglioramento nella velocità di
esecuzione del codice.
Quando inseriamo una direttiva ALIGN o ALIGNB in un segmento
dati, il valore assegnato all'eventuale secondo argomento non ha alcuna
importanza; in generale, la cosa migliore da fare consiste nell'usare DB
0 nei segmenti di dati inizializzati e RESB 1 nei segmenti di dati
non inizializzati.
Il discorso cambia radicalmente nel caso di direttive ALIGN o
ALIGNB inserite nei segmenti di codice; in una situazione del genere,
non possiamo usare queste direttive con un secondo argomento qualunque in
quanto rischiamo di trarre in inganno la CPU!
Supponiamo, ad esempio, di utilizzare ALIGN con secondo argomento DB
0 in modo che l'assembler inserisca buchi di memoria di valore 00h;
in tal caso, la CPU viene tratta in inganno in quanto scambia il valore
00h per il codice macchina di una addizione (il codice 00h indica,
infatti, l'Opcode di una addizione tra sorgente Reg8/Mem8 e
destinazione Mem8/Reg8).
Per evitare questo problema, nei segmenti di codice bisogna rigorosamente
inserire buchi di memoria rappresentati da dei NOP, equivalenti a byte
di valore 90h; a tale proposito, è sufficiente utilizzare ALIGN
senza specificare il secondo argomento (il cui valore predefinito è, appunto,
90h)!
Nel caso in cui sia necessario inserire buchi di memoria da più di 1
byte ciascuno, possiamo servirci anche di istruzioni del tipo:
mov ax, ax
Come si può facilmente constatare, questa istruzione lascia invariato il
contenuto di AX; il suo unico scopo è quello di occupare memoria
(in questo caso, il codice macchina 89h C0h, occupa 2 byte).
13.9 Calcolo delle componenti Seg e Offset con NASM
Sulla base di ciò che è stato esposto in questo capitolo in relazione alla
rilocazione degli indirizzi da parte del linker e del SO, si intuisce
subito il fatto che sarebbe assolutamente folle l'idea di scrivere programmi
nei quali si tenti di determinare empiricamente le componenti Seg e
Offset dell'indirizzo logico di una qualsiasi informazione; è chiaro,
infatti, che un qualunque indirizzo logico Seg:Offset che nel codice
sorgente assume un determinato aspetto, è destinato a subire profonde modifiche
ad opera del linker e del SO.
È necessario quindi ribadire che bisogna evitare nella maniera più assoluta di
scrivere programmi che tentino di calcolare indirizzi in modo empirico; per
permettere al programmatore di svolgere questi calcoli in assoluta sicurezza,
NASM mette a disposizione una sintassi estremamente semplice.
Nel Capitolo 11 abbiamo visto che in NASM il nome simbolico di una
qualsiasi informazione (dato o etichetta), rappresenta la componente
Offset dell'indirizzo logico dell'informazione stessa, relativa al
segmento di appartenenza; consideriamo, ad esempio, la seguente istruzione
riferita al programma di Figura 13.1:
mov bx, varDword
Questa istruzione, trasferisce in BX la componente Offset
dell'indirizzo logico del dato varDword; per capire bene questa
istruzione, è necessario ricordare che quando un dato viene caricato in
memoria, il suo indirizzo logico Seg:Offset viene assegnato in modo
definitivo (statico) e rimane fisso per tutta la fase di esecuzione. Tutto
ciò significa che le componenti Seg e Offset dell'indirizzo
logico di una informazione, non sono altro che due semplici valori immediati
del tipo Imm16; ne consegue che la precedente istruzione,
rappresenta un trasferimento dati da Imm16 a Reg16.
Come già sappiamo, il codice macchina di questa istruzione è formato
dall'Opcode 1011_w_Reg, seguito dall'Imm16; nel nostro caso,
w=1 (operandi a 16 bit) e Reg=BX=011b. Otteniamo allora:
Opcode = 10111011b = BBh
Dal listing file di Figura 13.2, si rileva che l'offset di
varDword è:
Disp16 = 0003h = 0000000000000011b
In definitiva, otteniamo il codice macchina:
10111011b 0000000000000011b = BBh 0003h
L'assembler, "marca" 0003h come un offset rilocabile; in questo modo,
il linker potrà apportare tutte le necessarie modifiche al precedente codice
macchina. In base all'esempio su EXETEST.EXE presentato in questo
capitolo, abbiamo visto che l'indirizzo logico che l'assembler assegna a
varDword è 0000h:0003h; questo indirizzo logico viene
trasformato dal linker in 0000h:0003h e dal SO in
025Bh:0003h. In fase di esecuzione del programma, la componente
Offset assegnata a varDword diventa definitiva (0003h);
il programmatore quindi non deve fare altro che gestire questa informazione
attraverso un nome simbolico, lasciando che tutto il complesso lavoro di
rilocazione venga gestito dall'assembler, dal linker e dal SO.
Per determinare la componente Seg dell'indirizzo logico di una qualsiasi
informazione (dato o etichetta), NASM mette a disposizione l'operatore
SEG; sempre in riferimento al programma di Figura 13.1, consideriamo
la seguente istruzione:
mov dx, seg ..start
Questa istruzione, trasferisce in DX la componente Seg
dell'indirizzo logico dell'etichetta ..start; anche in questo caso,
abbiamo a che fare con un trasferimento dati da Imm16 a Reg16.
L'Opcode è sempre 1011_w_Reg; nel nostro caso, w=1
(operandi a 16 bit) e Reg=DX=010b. Otteniamo allora:
Opcode = 10111010b = BAh
Dal listing file di Figura 13.2, si rileva che ..start si trova
nel blocco CODESEGM, a cui l'assembler assegna il valore simbolico
0000h (assolutamente privo di significato); in definitiva, otteniamo
il codice macchina:
10111010b 0000000000000000b = BAh 0000h
L'assembler, "marca" 0000h come un segmento rilocabile; in questo
modo, il linker potrà apportare tutte le necessarie modifiche al precedente
codice macchina. In base all'esempio su EXETEST.EXE presentato in
questo capitolo, abbiamo visto che l'indirizzo logico che l'assembler assegna
a ..start è 0000h:0000h; questo indirizzo logico viene
trasformato dal linker in 0044h:0008h e dal SO in
029Fh:0008h. In fase di esecuzione del programma, la componente
Seg assegnata a ..start diventa definitiva (029Fh); il
programmatore quindi non deve fare altro che gestire questa informazione
attraverso l'operatore SEG, lasciando che tutto il complesso lavoro
di rilocazione venga gestito dall'assembler, dal linker e dal SO.
13.10 Programmi eseguibili in formato COM
Come già sappiamo, il SO CP/M può essere definito l'antenato del DOS;
ai tempi del CP/M, i computer disponevano di quantità piuttosto limitate di
memoria, in genere 32 KiB, 64 KiB o, al massimo, 128 KiB come
nel caso del celebre Commodore 128 (che utilizzava proprio questo SO).
In una situazione del genere, si rendeva necessario limitare al massimo le esigenze
di memoria dei programmi; proprio per questo motivo, il CP/M prevedeva un
formato eseguibile che permetteva di realizzare programmi piuttosto piccoli e con
ridottissime esigenze di memoria, a scapito ovviamente della flessibilità del
programma stesso.
Con l'arrivo del DOS, si rese necessaria la conversione verso questo nuovo
SO, della grande quantità di software scritto per il CP/M; per
facilitare questa conversione, il DOS mise a disposizione un apposito
formato per i programmi eseguibili, chiamato COM (COre iMage). Il formato
COM è disponibile ancora oggi in ambiente DOS e pur essendo stato
soppiantato quasi del tutto dal formato EXE, viene ancora utilizzato per
scrivere piccoli programmi, molto compatti ed efficienti.
13.10.1 Struttura di un eseguibile in formato COM
In pratica, un programma COM viene ottenuto riducendo all'essenziale la
struttura di un programma EXE; per raggiungere questo obiettivo, si elimina
innanzi tutto la possibilità di definire più di un segmento di programma. In un
programma COM quindi, è presente un unico segmento, all'interno del quale
trovano posto, il codice, i dati e lo stack; in presenza di una struttura così
semplice, molte delle informazioni che il linker inserisce nell'header
diventano superflue.
Spingendo al massimo la semplificazione, si arriva ad eliminare del tutto la
necessità, da parte del linker, di generare l'header; di conseguenza, i
file COM generati dal linker, contengono al loro interno solo ed
esclusivamente il modulo caricabile (da cui il nome "core image").
In assenza dell'header, tutte le fasi di caricamento in memoria e
inizializzazione di un programma COM, vengono gestite dal SO
attraverso un procedimento standard; questo procedimento quindi, è identico per
qualsiasi file COM.
Nello scrivere un programma Assembly in formato COM, il
programmatore ha l'obbligo di lasciare liberi i primi 256 byte
(0100h byte) dell'unico segmento presente; come si può facilmente intuire,
in questi primi 256 byte il SO inserisce il PSP. Ne
consegue che l'entry point di un programma COM deve trovarsi
obbligatoriamente all'offset 0100h dell'unico segmento presente!
13.10.2 La direttiva ORG
L'area da 256 byte riservata al PSP in un eseguibile in
formato COM, deve essere rigorosamente non inizializzata; possiamo
facilmente ottenere questo risultato attraverso la direttiva:
resb 0100h
Come vedremo più avanti, NASM permette di generare anche dei file grezzi,
chiamati file binari, contenenti esclusivamente il codice macchina di un
programma; in base a quanto è stato esposto in precedenza, il formato COM
è totalmente compatibile con il formato binario grezzo supportato da NASM
(infatti, un file COM contiene solamente il codice macchina da eseguire).
Esclusivamente per il formato binario grezzo, NASM fornisce la direttiva
ORG n (che sta per origin); questa direttiva dice all'assembler
che la componente Offset del location counter $ deve essere spostata
in avanti di n byte.
In modalità reale, quindi, n deve essere un Imm16; nei capitoli
successivi vedremo che n può essere persino una espressione complessa.
Supponiamo che, ad esempio, l'assembler incontri la seguente direttiva all'offset
0200h di un blocco CODESEGM:
org 0150h
In tal caso, l'assembler incrementa di 0150h byte la componente
Offset del location counter (che in quel punto vale 0200h)
portandola quindi a:
0200h + 0150h = 0350h
Tutti i 0150h byte compresi tra CODESEGM:0200h e
CODESEGM:034Fh vengono saltati e rimangono privi di inizializzazione;
l'assemblaggio di CODESEGM riprende quindi dall'indirizzo logico
CODESEGM:0350h.
Si tenga presente che nel caso di MASM, l'omonima direttiva ORG
agisce in modo totalmente differente; a tale proposito, si può consultare il
tutorial Assembly Base Versione MASM!
13.10.3 Modello di programma Assembly in formato COM
Prima di illustrare la struttura generale di un programma Assembly in
formato COM, rimane ancora da chiarire un aspetto importante, relativo
al punto in cui inserire le definizioni dei dati statici; in presenza,
infatti, di un unico segmento, c'è il rischio che la CPU possa finire
per sbaglio nel bel mezzo di questi dati, scambiandoli per codici macchina da
eseguire!
Per risolvere questo problema è necessario ribadire che, a differenza di quanto
accade con i programmi in formato EXE, in un programma in formato
COM è proibito inserire qualsiasi dato inizializzato prima dell'entry
point (cioè prima dell'offset 0100h); vediamo allora quali altre
strade si possono seguire, per poter individuare un'area adatta a contenere la
definizione dei dati statici di un programma in formato COM.
Una prima soluzione, consiste nel definire tutti i dati statici, subito dopo
l'entry point; in questo caso, prima delle varie definizioni, bisogna
inserire una istruzione di salto, che permetta alla CPU di scavalcare i
dati e di raggiungere la prima istruzione da eseguire. Le istruzioni di salto,
verranno illustrate in un capitolo successivo.
In questo capitolo, viene utilizzato un altro metodo, molto semplice, che
consiste nell'inserire le definizioni dei dati statici, subito dopo le istruzioni
di terminazione del programma; quest'area, non potrà mai essere raggiunta (in modo
involontario) dalla CPU.
Sulla base delle considerazioni appena esposte, siamo finalmente in grado di
definire la struttura generale di un programma Assembly, destinato ad essere
convertito in un eseguibile in formato COM; la Figura 13.17, illustra un
modello che verrà utilizzato in tutta la sezione Assembly Base, per scrivere
esempi di programma Assembly in formato COM.
Il modello presentato in Figura 13.17, utilizza un segmento unico chiamato
COMSEGM; per la classe del segmento, è stata utilizzata la stringa
'CODE', ma naturalmente, è possibile utilizzare anche altre stringhe come
'UNICO', 'COMCLASS', etc.
Ovviamente, in presenza di un unico segmento, un programma in formato COM
non può superare la dimensione massima prevista per i segmenti di memoria e cioè,
64 KiB; questa dimensione massima, comprende anche i 256 byte
riservati al PSP. In realtà, sarebbe anche possibile superare questa
barriera; in tal caso però, si andrebbe incontro a complicazioni tali da rendere
preferibile la scelta del formato EXE.
Anche nel caso dei programmi in formato COM, è possibile spezzare l'unico
segmento presente, in due o più parti che possono trovarsi distribuite, nello stesso
modulo o anche in moduli differenti; tutte queste parti però, devono avere lo stesso
nome e gli stessi identici attributi, in modo che il linker ottenga alla fine un
unico segmento di programma (ovviamente, l'attributo Combinazione non deve
essere PRIVATE). Se non si seguono queste regole, si ottiene un errore del
linker; la suddivisione dei programmi Assembly in due o più moduli, verrà
analizzata in un capitolo successivo.
Ricordando quanto è stato esposto in relazione agli eseguibili in formato EXE,
subito dopo l'entry point dovrebbero essere presenti le classiche istruzioni
per l'inizializzazione di DS e ES; se proviamo però a scrivere, ad
esempio:
mov ax, COMSEGM
otteniamo un errore del linker, rappresentato da un messaggio del tipo:
Cannot generate COM file: segment-relocatable items present in module nomefile.com
Con questo messaggio, il linker ci sta dicendo che in un programma in formato
COM, è proibita la presenza di istruzioni che fanno riferimento ad una
qualsiasi componente Seg rilocabile.
Il perché di questo messaggio è abbastanza ovvio; in assenza dell'header,
non esiste nemmeno la relocation table. Di conseguenza, il SO non è
in grado di modificare il codice macchina di una istruzione che fa riferimento ad
una componente Seg rilocabile; è proibito quindi scrivere anche istruzioni
del tipo:
mov dx, seg ..start
È perfettamente lecito, invece, scrivere istruzioni del tipo:
mov bx, ..start ; carica in BX l'offset di ..start
oppure:
mov ax, ds
Come vedremo tra breve, in un programma in formato COM tutte le
inizializzazioni dei registri di segmento CS, DS, ES e
SS, spettano rigorosamente al SO; le considerazioni appena
esposte, ci danno un'idea delle notevoli limitazioni presenti in questo tipo di
programma eseguibile.
Un altro aspetto abbastanza evidente nel segmento COMSEGM di Figura 13.17,
riguarda l'apparente mancanza di un'area riservata allo stack; anche a questo
proposito, vedremo che l'inizializzazione standard dello stack di un programma
COM, spetta al SO.
13.10.4 Esempio di programma Assembly in formato COM
Come esempio pratico, trasformiamo in formato COM il programma di
Figura 13.1; il risultato di questa trasformazione, ci porta ad ottenere il
file COMTEST.ASM illustrato in Figura 13.18.
Notiamo subito la presenza di alcune differenze importanti rispetto al codice
sorgente di Figura 13.1; in Figura 13.18, è stata utilizzata la direttiva
ALIGNB all'interno della struttura Automobile e la direttiva
ALIGN per l'allineamento alla DWORD dei dati statici del programma.
Notiamo anche la presenza di una direttiva ALIGN 4, che precede l'inizio
delle definizioni dei dati statici; questa direttiva è necessaria in quanto,
molto probabilmente, l'ultima istruzione del blocco codice principale termina ad
un offset che provoca il disallineamento dell'informazione successiva. Per i
programmi in formato COM, si consiglia di ricorrere sempre a questo
accorgimento.
13.10.5 Assembling & Linking di un programma in formato COM
L'assemblaggio di COMTEST.ASM avviene nel solito modo; con il NASM,
dalla cartella ASMBASE, il comando da impartire è:
c:\nasm\nasm -f obj comtest.asm -l comtest.lst
Il comando appena impartito, genera un object file chiamato
COMTEST.OBJ e anche un listing file chiamato COMTEST.LST;
grazie al listing file, possiamo constatare che il blocco
COMSEGM, occupa complessivamente 0694h+(4*10)=06BCh byte
(1724 byte) e comprende anche i 256 byte del PSP.
Sempre attraverso il listing file, si può notare che l'entry point,
rappresentato dall'etichetta ..start, si trova all'offset 0100h;
inoltre, tutti i dati definiti in COMSEGM si trovano allineati alla
DWORD (grazie anche al fatto che questa volta, ogni struttura
Automobile occupa 48 byte).
Passiamo ora alla fase di linking; il comando da impartire con i linker
Microsoft è:
c:\masm\bin\link /map /tiny comtest.obj
In assenza dell'opzione /tiny per i linker Microsoft, il
linker genera un normale eseguibile in formato EXE, chiamato
COMTEST.EXE; in questo caso, viene generato anche un messaggio di
avvertimento (warning) che ci informa dell'assenza dello stack.
In presenza, invece, dell'opzione /tiny per i linker Microsoft,
il linker genera un file eseguibile chiamato COMTEST.COM; in questo
caso, non viene generato alcun warning relativo all'assenza dello stack.
Oltre all'eseguibile in formato COM, viene generato anche un map
file chiamato COMTEST.MAP, che ci fornisce un riassunto semplificato
del lavoro svolto dal linker.
13.10.6 Caricamento in memoria ed esecuzione di un programma in formato COM
Vediamo ora quello che succede, quando dal prompt del DOS, si impartisce
il comando:
comtest
Dopo aver seguito tutta la procedura già descritta a proposito dell'esempio
EXETEST.EXE, il loader del SO trova un file chiamato
COMTEST.COM; non trovando nessuna stringa 'MZ' all'inizio di
questo file, il loader avvia le procedure standard per il caricamento
in memoria di un eseguibile in formato COM.
Come al solito, il DOS alloca due blocchi di memoria, uno per
l'environment segment e l'altro per il program segment; la
domanda che ci poniamo è: in base a quali criteri il DOS determina
la dimensione del program segment?
La tecnica utilizzata dal DOS è molto semplice; al program
segment di un qualunque programma in formato COM, viene riservato
un intero segmento di memoria che, come sappiamo, occupa 64 KiB ed è
sempre allineato al paragrafo.
Supponiamo allora che il DOS abbia riservato a COMTEST.COM, il
segmento di memoria che parte dall'indirizzo fisico 01BC0h; a questo
indirizzo fisico, corrisponde l'indirizzo logico normalizzato 01BCh:0000h.
A questo punto, il DOS legge dal disco il file COMTEST.COM (che
contiene il solo modulo caricabile) e lo copia tale e quale nel program
segment; nei primi 256 byte (0100h) che precedono l'entry
point, viene sistemato il PSP. Possiamo dire quindi che il PSP
occupa tutti gli indirizzi fisici del program segment, compresi tra
01BC0h e:
01BC0h + (0100h - 1h) = 01CBFh
In termini di indirizzi logici, il PSP viene inserito nel segmento di
memoria n. 01BCh e occupa tutti gli offset di questo segmento, compresi
tra 0000h e:
0000h + (0100h - 1h) = 00FFh
In sostanza, il PSP è compreso tra gli indirizzi logici 01BCh:0000h
e 01BCh:00FFh.
L'inizio del PSP coincide con l'inizio del program segment; anche
nei programmi COM quindi, per convenzione, si utilizza la sigla PSP
per indicare la componente Seg dell'indirizzo logico iniziale
(01BCh:0000h) dello stesso program segment. Possiamo scrivere quindi:
PSP = 01BCh
Il DOS passa ora alla inizializzazione standard dei registri della
CPU; per i registri di segmento CS, DS, ES e SS,
il DOS pone ovviamente:
CS = DS = ES = SS = PSP = 01BCh
In sostanza, il blocco codice, il blocco dati e il blocco stack, partono tutti
dallo stesso indirizzo fisico 01BC0h.
Il PSP parte dall'indirizzo logico 01BCh:0000h, per cui l'entry
point del programma, si viene a trovare all'indirizzo logico 01BCh:0100h;
il DOS pone quindi:
CS:IP = 01BCh:0100h
Il registro SP viene inizializzato con l'offset FFFEh (che è il più
grande offset di indice pari, all'interno del segmento di memoria n. 01BCh
da 64 KiB); per la coppia SS:SP, il DOS pone quindi:
SS:SP = 01BCh:FFFEh
Successivamente, il DOS inserisce una WORD di valore 0000h
all'indirizzo logico SS:SP (cioè 01BCh:FFFEh); il perché di questa
WORD verrà chiarito in un prossimo capitolo.
A questo punto, il programma è pronto per l'esecuzione; in seguito a tutto questo
lavoro svolto dal DOS, il programma COMTEST.COM assume in memoria la
disposizione illustrata in Figura 13.19.
Come possiamo notare, il programma COMTEST.COM ha a sua disposizione
una notevole quantità di memoria per lo stack; in base al fatto che il PSP,
il codice e i dati statici di questo programma occupano 06BCh byte,
possiamo dire che lo spazio riservato allo stack è pari a:
FFFEh - 06BCh = 65534 - 1724 = 63810 byte
È necessario ricordare che, man mano che lo stack si riempie, il contenuto del
registro SP viene via via decrementato; se lo spazio riservato allo stack è
troppo piccolo, c'è il rischio che SP invada l'area riservata al codice e
ai dati statici, i quali verrebbero così sovrascritti dai dati temporanei. La
Figura 13.19, permette anche di capire come ci si deve regolare, nel momento in
cui si vuole creare un segmento misto dati + stack; in sostanza, è
fondamentale che l'area riservata allo stack venga disposta sempre nella parte
finale del segmento misto.
Il PSP, il codice, i dati statici e lo stack di COMTEST.COM, occupano
l'intero segmento di memoria n. 01BCh; un segmento di memoria è formato da
65536 byte (10000h byte). Possiamo dire quindi che l'indirizzo fisico
iniziale del segmento di memoria successivo al nostro programma, sarà:
01BC0h + 10000h = 11BC0h
Una volta che il DOS ha inizializzato i necessari registri della CPU,
può partire la fase di esecuzione di COMTEST.COM; il controllo passa quindi
alla stessa CPU che trova la coppia CS:IP inizializzata con l'entry
point 01BCh:0100h. Da questo punto in poi, la fase di esecuzione si svolge nello
stesso modo già descritto per EXETEST.EXE.
13.10.7 Generazione diretta di eseguibili in formato COM
Da quanto è stato esposto, risulta evidente che un file COM contiene
esclusivamente la traduzione grezza, in codice macchina, del codice sorgente
scritto dal programmatore; ciò significa che nel caso estremamente semplice di
un piccolo programma Assembly costituito da un unico file, noi stessi
possiamo leggere il relativo codice macchina (fornito dal listing file)
e copiarlo direttamente in un altro file il quale assumerà, in tutto e per
tutto, le caratteristiche di un eseguibile in formato COM (nel prossimo
capitolo vedremo un esempio pratico)!
Lo stesso NASM fornisce un'opzione che permette di ottenere un file
binario grezzo contenente la traduzione diretta in codice macchina di un
programma Assembly distribuito su un unico file; per quanto è stato
appena esposto, il file binario così ottenuto è perfettamente compatibile con
la struttura di un eseguibile in formato COM.
La Figura 13.20 illustra la sintassi fornita da NASM per la creazione
di file binari grezzi; è necessario ribadire che questa possibilità viene offerta
solo per piccoli programmi Assembly distribuiti su un unico file.
La suddivisione del programma in tre blocchi (.text, .data e
.bss) è assolutamente fittizia; infatti, NASM provvede a
creare un unico segmento di programma contenente, nell'ordine, il codice, i
dati statici inizializzati, i dati statici non inizializzati e lo stack.
L'attributo ALIGN è opzionale; in ogni caso, il suo impiego è
consigliabile soprattutto per far partire i dati da indirizzi adeguati
all'architettura della CPU che si sta utilizzando.
Nel caso in cui si voglia generare un eseguibile in formato COM,
bisogna ovviamente passare n=0100h alla direttiva ORG.
Supponendo di avere a disposizione il codice sorgente nomefile.asm
di un programma Assembly dotato della struttura illustrata in
Figura 13.20, possiamo ottenere l'eseguibile nomefile.com attraverso
il seguente comando NASM:
c:\nasm\nasm -f bin nomefile.asm -o nomefile.com
Si noti come sia necessario specificare il formato bin attraverso
l'apposita opzione -f; l'opzione -o permette di specificare
il nome del file eseguibile (file di output).
Nel prossimo capitolo, verranno illustrati diversi strumenti che ci
permetteranno di verificare in pratica tutto ciò che è stato appena esposto
in relazione alla struttura interna di un file eseguibile e al comportamento
in memoria di un programma in esecuzione.