Assembly Windows 32bit con MASM
Capitolo 3: Struttura di un programma Assembly per Windows 32bit
Sin dalle prime versioni il SO Windows è stato scritto in C standard (ANSI
C); le parti più critiche del SO, legate alla piattaforma hardware di destinazione,
sono state scritte invece in Assembly. Di conseguenza, un programma per Windows
assume una struttura interna legata alle convenzioni che sono state esposte nel Capitolo 29
e nel Capitolo 30 della sezione Assembly Base; nel caso di Win32, tutte queste
convenzioni vengono applicate in un ambiente operativo basato sulla modalità protetta a
32 bit delle CPU 80386 e superiori.
Nel precedente Capitolo abbiamo visto che al momento dell'esecuzione, un'applicazione Win32
viene caricata in memoria a partire dall'indirizzo lineare a 32 bit 4194304d
(4 Mib); una volta caricata in memoria, l'applicazione ha a sua disposizione un vasto spazio
di indirizzamento virtuale che arriva sino all'indirizzo lineare a 32 bit 2147483648d
(2 Gib). All'interno di questo spazio sono presenti il blocco codice, il blocco dati e il
blocco stack dell'applicazione; in sostanza, questi tre blocchi occupano tre aree distinte di uno
stesso segmento di memoria a 32 bit. Ricordiamo che, contrariamente a quanto molti pensano,
anche nella modalità protetta delle CPU 80386 e superiori esiste la segmentazione della
memoria tipica della modalità reale a 16 bit; la differenza sta nel fatto che mentre in
modalità reale a 16 bit i segmenti sono da 64 Kib ciascuno, in modalità protetta
a 32 bit ciascun segmento può arrivare sino a 4 Gib.
La struttura di un programma Assembly per Win32 è molto simile alla struttura di un
programma Assembly per DOS con modello di memoria SMALL; in base a quanto è
stato detto nella sezione Assembly Base, un programma che gira in modalità reale con
modello di memoria SMALL è costituito da un blocco codice referenziato da CS, un
blocco dati referenziato da DS e un blocco stack referenziato da SS, con ciascun
blocco che può raggiungere al massimo la dimensione di 64 Kib (attributo USE16).
Esistendo un solo blocco codice, un solo blocco dati e un solo blocco stack, nella fase di
esecuzione del programma il contenuto dei tre registri di segmento CS, DS e SS
non cambia mai; tutto ciò porta ad una gestione velocissima degli indirizzamenti in quanto per
accedere al codice, ai dati e allo stack la CPU utilizza indirizzi formati dal solo offset
(indirizzi NEAR). Se vogliamo accedere ad esempio ad una variabile del blocco dati, ci basta
specificare solo il suo offset a 16 bit; in assenza di altre informazioni la CPU associa
quest'offset a DS che è il registro di segmento predefinito per i dati. Se la somma delle
dimensioni del blocco codice e del blocco stack non supera i 64 Kib, i compilatori dei
linguaggi di alto livello pongono SS=DS raccogliendo quindi dati e stack in un unico
blocco chiamato convenzionalmente DGROUP; in questo modo si ottiene una gestione piu
efficiente e veloce dei dati e dello stack del programma.
La stessa situazione si verifica nel caso di un'applicazione per Win32 che è formata da
un solo blocco codice referenziato dal selettore CS, un solo blocco dati referenziato dal
selettore DS e un solo blocco stack referenziato dal selettore SS; anche in questo
caso quindi per accedere al codice, ai dati e allo stack si utilizzano indirizzi formati dal solo
offset. Questa volta però stiamo lavorando in modalità protetta a 32 bit e questo
significa che gli offset sono numeri a 32 bit che teoricamente possono spaziare da
00000000h a FFFFFFFFh; nel capitolo successivo vedremo che anche nel caso di
un'applicazione per Win32 si ha SS=DS, per cui questi due selettori referenziano
la stessa area di memoria.
In base alle considerazioni appena esposte, possiamo dire che un programma Assembly per
Win32 dovrà essere dotato di appositi segmenti di programma destinati a contenere il
codice, i dati e lo stack; come è stato spiegato nel Capitolo 29 della sezione Assembly
Base, i nomi e gli attributi di questi segmenti di programma devono rispettare rigorosamente
una serie di convenzioni imposte dalla Microsoft, chiamate anche convenzioni MASM.
Naturalmente queste convenzioni valgono sia per l'Assembly che per qualsiasi altro
linguaggio di alto livello; nel caso dei linguaggi di alto livello questi aspetti vengono gestiti
dal compilatore, mentre nel caso dell'Assembly come al solito l'onore e l'onere di gestire
ogni minimo dettaglio ricade sul programmatore.
Passiamo ora all'analisi dettagliata delle caratteristiche dei vari blocchi che formano la
struttura di un programma Assembly per Win32.
3.1 Le direttive per l'assembler
Come al solito, un programma Assembly inizia con una serie di direttive destinate
all'assembler; le direttive possono essere inserite dappertutto, ma quelle che vengono poste
all'inizio sono le più importanti in quanto definiscono le caratteristiche generali del nostro
programma. La Figura 3.1 mostra le direttive fondamentali che sono sempre presenti in un programma
Assembly destinato a girare sotto Win32.
Analizziamo in dettaglio le varie direttive presenti in questa sezione; come è stato già detto,
si tratta delle direttive minime necessarie per generare un programma Assembly per
Win32.
La direttiva:
.386
indica all'assembler che vogliamo utilizzare il set di istruzioni a 32 bit delle CPU
80386 e superiori; come sappiamo Win32 lavora in modalità protetta a 32 bit,
per cui richiede come minimo una CPU di classe 80386. Come è stato spiegato nella sezione
Assembly Base, è perfettamente inutile ricorrere a direttive del tipo .486,
.586, etc, a meno che non si vogliano utilizzare istruzioni specifiche di queste CPU, come
ad esempio CPUID che richiede almeno la direttiva .586 (naturalmente in questo caso
bisogna possedere un PC con CPU di classe Pentium o superiore); lo stesso discorso vale per
le direttive del tipo .386p, .486p, etc, che sono necessarie solo se vogliamo
utilizzare le istruzioni per la modalità protetta.
La direttiva:
.MODEL FLAT, STDCALL
dice all'assembler quale modello di memoria viene utilizzato dal nostro programma e quale
convenzione viene adottata per il passaggio dei parametri alle procedure e per la pulizia dello
stack al termine delle procedure stesse; come si vede in Figura 3.1, il modello di memoria
utilizzato dalle applicazioni Win32 è FLAT. In base a quanto è stato detto in
precedenza, il modello FLAT può essere paragonato ad una sorta di modello SMALL
per la modalità protetta a 32 bit; in sostanza, il nostro programma è costituito da tre
blocchi referenziati da CS, DS e SS. Il selettore CS punta al
descrittore del blocco codice, il selettore DS punta al descrittore del blocco dati e il
selettore SS punta al descrittore del blocco stack; questi tre blocchi vengono inseriti
in un gigantesco spazio di indirizzamento virtuale a 32 bit. L'aspetto più importante da
considerare è che all'interno di ciascun blocco, gli indirizzamenti si svolgono in modo lineare
grazie proprio agli offset a 32 bit; per chi proviene dal mondo della modalità reale
8086 il modello FLAT rappresenta la fine di un incubo. In modalità reale, se il
nostro programma ha bisogno al massimo di 64 Kib di codice, 64 Kib di dati e 64
Kib di stack, possiamo ritenerci fortunati; in questo caso infatti questi tre blocchi vengono
referenziati da CS, DS e SS e il contenuto di questi tre registri di segmento
rimane invariato per tutta la fase di esecuzione del programma. Tutti gli indirizzamenti si
svolgono in modo lineare a 16 bit, nel senso che la CPU lavora con indirizzi formati dal
solo offset a 16 bit (indirizzi NEAR); ogni volta che la CPU si imbatte in un offset
a 16 bit, è in grado di associarlo al corrispondente registro di segmento grazie al fatto
che il nostro programma ha un solo segmento di codice, un solo segmento di dati e un solo segmento
di stack. Se invece il nostro programma ha bisogno di più di 64 Kib di codice e/o più di
64 Kib di dati, allora si entra nell'inferno della segmentazione a 64 Kib della
memoria; tutti gli indirizzamenti in questo caso sono formati da una coppia seg:offset
(indirizzi FAR), in quanto la CPU ha bisogno di sapere non solo a quale offset di memoria
vogliamo accedere, ma anche a quale segmento di programma appartiene quell'offset. Lo stesso
problema si presenta nel momento in cui vogliamo allocare dinamicamente un blocco di memoria più
grande di 64 Kib; anche in questo caso siamo costretti a richiedere al SO due o più
blocchi di memoria.
Per superare questo problema bisogna ricorrere alla modalità protetta delle CPU 80386 e
superiori; in questo modo possiamo sfruttare gli indirizzamenti lineari a 32 bit che ci
permettono di muoverci virtualmente all'interno di segmenti di programma da 4 Gib ciascuno.
Il problema che si presenta è dato dal fatto che in ambiente DOS, se vogliamo scrivere
programmi che girano in modalità protetta a 32 bit, siamo costretti a scrivere anche le
procedure per l'accesso alle varie periferiche (dischi, tastiera, mouse, stampante, etc); non
bisogna dimenticare infatti che i vari servizi offerti dal DOS, dal BIOS, dai
Device Drivers, etc, sono concepiti espressamente per la modalità reale.
Il discorso cambia radicalmente nel momento in cui scriviamo applicazioni per Win32; in
questo caso, non solo possiamo sfruttare gli indirizzamenti lineari a 32 bit, ma abbiamo
anche a disposizione un vero SO a 32 bit che ci fornisce una serie enorme di
procedure anch'esse a 32 bit, attraverso le quali possiamo accedere a tutto ciò che è
collegato al nostro computer. Il modello di memoria FLAT significa tutto questo;
prepariamoci quindi a mettere da parte i vari segment overrides, le direttive ASSUME, i
puntatori NEAR, i puntatori FAR, etc. Nel modello di memoria FLAT di
Win32 un indirizzo è formato da un solo offset a 32 bit; se ad esempio vogliamo
trasferire in AX un dato a 16 bit puntato da EBX, dobbiamo semplicemente
scrivere:
mov ax, [ebx]
Tutti gli altri dettagli relativi al contenuto dei vari registri di segmento (selettori) vengono
gestiti direttamente dal SO; se si prova a modificare il contenuto di questi registri, si
provoca l'intervento del SO che chiude forzatamente il nostro programma e mostra un
messaggio di errore (errore di pagina non valida).
Per quanto riguarda il passaggio degli argomenti alle procedure, Win32 segue la convenzione
STDCALL; come abbiamo visto nella sezione Assembly Base, le due convenzioni più
importanti sono la convenzione C e la convenzione Pascal. Secondo la convenzione
C, gli argomenti da passare ad una procedura vengono inseriti nello stack a partire
dall'ultimo; inoltre, al termine della procedura il compito di ripulire lo stack spetta al caller.
Secondo la convenzione Pascal invece, gli argomenti da passare ad una procedura vengono
inseriti nello stack a partire dal primo; inoltre, al termine della procedura il compito di
ripulire lo stack spetta alla procedura stessa. La convenzione C è preferibile per quanto
riguarda il passaggio degli argomenti, in quanto ci permette di implementare procedure che
richiedono un numero variabile di argomenti; la convenzione Pascal è più veloce nella
pulizia dello stack. La convenzione STDCALL seguita da Win32 è un misto tra queste
due; in sostanza, quando si chiama una procedura in Win32, si passano gli argomenti
secondo la convenzione C e si ripulisce lo stack secondo la convenzione Pascal.
L'unica eccezione è rappresentata dal caso in cui si voglia effettuare da Windows la
chiamata di procedure appartenenti alle librerie standard del C; molte di queste
procedure sono infatti disponibili anche sotto Windows. La procedura sprintf
ad esempio è presente in Windows con il nome wsprintf; solo in questi casi si
deve seguire la convenzione C sia per il passaggio degli argomenti che per la pulizia
dello stack.
La direttiva:
OPTION CASEMAP: NONE
dice all'assembler che nella fase di individuazione dei nomi (di variabili, di procedure, di
etichette, etc) da inserire nella Symbol Table è necessario distinguere tra lettere
maiuscole e lettere minuscole; questo ci permette ad esempio di definire due variabili chiamate
var1 e VAR1 che verranno considerate distinte dall'assembler (si tratta comunque
di un pessimo stile di programmazione). Ricordiamoci che Windows è stato scritto
prevalentemente in C, e cioè con un linguaggio che distingue tra lettere maiuscole e
lettere minuscole (case-sensitive); in sostanza questo significa che in C il nome
var1 è diverso dal nome VAR1. Il Pascal invece è un linguaggio
case-insensitive che non distingue quindi tra lettere maiuscole e lettere minuscole; questo
significa che se proviamo a definire in Pascal le due variabili precedenti, otteniamo un
messaggio di errore del compilatore. Questa direttiva può essere sostituita anche dall'opzione
/Cp da passare direttamente al MASM.
La direttiva:
include windows.inc
permette al nostro programma di accedere al contenuto dell'include file windows.inc; come
già sappiamo questo è l'include file principale dell'SDK di Win32 e contiene
un'autentica marea di dichiarazioni di costanti, di nuovi tipi di dati, di strutture, etc, che sono
essenziali per lo sviluppo di applicazioni Win32. è molto importante che il programmatore
dia uno sguardo approfondito a questo file per farsi un'idea precisa del suo contenuto; in ogni
caso, nei capitoli successivi avremo modo di prendere confidenza con le informazioni contenute in
windows.inc. Come è stato detto nel Capitolo 1 di questa sezione, man mano che escono le
nuove versioni di Windows, il contenuto di windows.inc; viene aggiornato dalla Microsoft con
l'aggiunta di nuove costanti simboliche, nuove strutture, etc, introdotte dalle versioni più
recenti di Windows.
Subito dopo la direttiva che include windows.inc troviamo due analoghe direttive che
includono gli altri due files user32.inc e kernel32.inc; nell'SDK di
Win32 ad ogni libreria di procedure è associato un include file che contiene
l'interfaccia della libreria stessa. Una libreria contiene il codice delle varie procedure,
e cioè le definizioni delle procedure; il corrispondente include file contiene i prototipi
di queste procedure, e cioè le dichiarazioni delle procedure, più eventuali dichiarazioni
di costanti, strutture, etc, relative a quella particolare libreria. I due include files
user32.inc e kernel32.inc contengono le dichiarazioni delle procedure definite
nelle corrispondenti librerie user32.lib e kernel32.lib; come si vede in Figura 3.1,
queste due librerie vengono collegate al nostro programma attraverso le direttive
INCLUDELIB
La libreria user32.lib contiene una serie di procedure attraverso le quali possiamo
accedere ai servizi dell'interfaccia utente di Windows; la libreria kernel32.inc
contiene una serie di procedure attraverso le quali possiamo accedere ai servizi a basso livello
che ci vengono messi a disposizione dal kernel di Win32.
3.2 Il segmento dati inizializzati
Cominciamo ora ad analizzare i vari segmenti di programma presenti in una applicazione
Win32; la Figura 3.2 mostra le caratteristiche del segmento dati inizializzati di un programma
Assembly per Win32.
In questo blocco dobbiamo inserire tutti i dati inizializzati del nostro programma, cioè tutti
quei dati che vengono inizializzati al momento della loro definizione; scrivendo ad esempio:
varWord1 dw 12800
stiamo definendo una variabile chiamata varWord1 che occupa in memoria 16 bit e
viene inizializzata con il valore 12800d.
Come è stato detto all'inizio di questo capitolo, quando scriviamo un programma Assembly
per Win32 abbiamo l'obbligo di seguire le convenzioni MASM relative ai nomi e agli
attributi dei segmenti di programma; come si vede in Figura 3.2, il segmento dati inizializzati deve
chiamarsi obbligatoriamente _DATA e deve avere un attributo di classe 'DATA'.
L'attributo di allineamento è DWORD, e questo significa che il segmento _DATA deve
partire da un'indirizzo di memoria multiplo di 4 byte; come sappiamo, questo è
l'allineamento ottimale per il Data Bus a 32 bit delle CPU 80386 e
80486. Per consentire alla CPU di accedere ai dati alla massima velocità possibile,
dobbiamo fare in modo che i dati stessi si trovino correttamente allineati all'interno del
segmento _DATA; i dati di tipo BYTE possono trovarsi a qualunque indirizzo di
memoria, i dati di tipo WORD devono trovarsi possibilmente ad indirizzi pari, mentre i
dati di tipo DWORD, QWORD etc, devono trovarsi possibilmente ad indirizzi multipli
di 4 byte.
Per quanto riguarda gli altri attributi, osserviamo in particolare che l'attributo SIZE
viene impostato a USE32; come già sappiamo questo significa che il segmento _DATA
viene gestito attraverso gli offset a 32 bit.
Grazie alla presenza della direttiva:
.MODEL
usata per definire il modello di memoria del nostro programma, possiamo anche servirci delle direttive semplificate per la
creazione dei segmenti di programma; nel caso del segmento dati inizializzati, possiamo sostituire
tutto ciò che si vede in Figura 3.2 con la direttiva semplificata:
.DATA
3.3 Il segmento dati non inizializzati
Il segmento dati inizializzati precedentemente descritto, può anche contenere la definizione di
dati privi di valore iniziale; un esempio pratico può essere rappresentato dalla definizione:
varDword1 dd ?
Inserendo i dati non inizializzati nel segmento dati inizializzati, otteniamo naturalmente un
programma perfettamente funzionante; se però vogliamo aiutare il SO ad ottimizzare al
massimo l'utilizzo della memoria, allora è preferibile inserire la definizione dei dati non
inizializzati in un apposito segmento di programma. La Figura 3.3 mostra proprio le caratteristiche
di questo blocco dati chiamato segmento dati non inizializzati.
Questo blocco deve chiamarsi obbligatoriamente _BSS come previsto dalle convenzioni
MASM; gli attributi sono identici a quelli del segmento dati inizializzati, con la sola
eccezione dell'attributo di classe che deve essere 'BSS'. è importante che in questo
blocco non vengano inseriti dati inizializzati; in tal caso il programma funziona ugualmente bene,
ma costringe il SO ad utilizzare più memoria di quella necessaria.
Se preferiamo servirci delle direttive semplificate per i segmenti, possiamo sostituire tutto
ciò che si vede in Figura 3.3 con la direttiva:
.DATA?
3.4 Il segmento dati costanti
Come abbiamo visto nel Capitolo 29 della sezione Assembly Base, i compilatori e gli
interpreti per poter organizzare i programmi nel modo più efficiente possibile, definiscono
anche un blocco riservato ai dati costanti; in questo blocco vengono sistemati tutti quei
dati di tipo numerico o di tipo stringa che non sono associati necessariamente ad un nome
di variabile. Un esempio pratico è rappresentato dalla seguente istruzione C:
printf("Premere un tasto per continuare");
La stringa "Premere un tasto per continuare" viene utilizzata come argomento da passare
alla funzione printf, ma non è associata a nessuna variabile; questa stringa rappresenta
un classico esempio di dato costante che i compilatori C inseriscono proprio nel blocco
per i dati costanti. La Figura 3.4 illustra le caratteristiche di questo particolare blocco dati.
In presenza della direttiva .MODEL, questo blocco può essere creato con l'ausilio
della direttiva semplificata:
.CONST
3.5 Il segmento di stack
Ai tempi di Win16 il programmatore era tenuto a specificare la dimensione in byte da
assegnare al segmento di stack del programma; questa informazione veniva passata al linker
attraverso un apposito file chiamato Definition File. La Figura 3.5 mostra un esempio pratico
di un classico definition file per un'applicazione Win16 chiamata Win16App.
Il definition file è un file in formato
ASCII
che reca l'estensione DEF;
convenzionalmente, il nome del definition file è lo stesso nome usato per il modulo principale
dell'applicazione che stiamo scrivendo. Nel caso di Figura 3.5, l'applicazione Win16 si
chiama Win16App, per cui il definition file verrà salvato sul disco con il nome
Win16App.def; esaminiamo ora i vari campi presenti all'interno di questo file.
Il campo NAME specifica il nome che verrà assegnato all'eseguibile generato dal linker;
nel nostro caso, l'eseguibile verrà chiamato Win16App.exe.
Il campo DESCRIPTION specifica una stringa contenente una breve descrizione del nostro
programma; questa stringa viene inserita nell'header dell'eseguibile, per cui la possiamo
utilizzare per "firmare" il nostro programma.
Il campo EXETYPE specifica il tipo di eseguibile che verrà generato dal linker; nel nostro
caso il tipo WINDOWS indica che Win16App.exe sarà in formato eseguibile per
Windows.
Il campo STUB indica un programma che verrà chiamato automaticamente nel caso in cui si
tenti di eseguire Win16App.exe dal prompt del DOS; in questo caso, il programma
predefinito winstub.exe interviene e mostra il messaggio:
This program requires Microsoft Windows
Il campo CODE elenca una serie di attributi assegnati al segmento di codice del nostro
programma; l'attributo PRELOAD indica che il blocco codice verrà automaticamente caricato
in memoria al momento dell'avvio del nostro programma, l'attributo MOVEABLE indica che
all'occorrenza Windows potrà spostare blocchi di codice in un'altra zona della RAM
in modo da ottimizzare l'uso della memoria, l'attributo DISCARDABLE indica che
all'occorrenza Windows potrà scaricare blocchi di codice sull'Hard Disk per fronteggiare
situazioni di scarsità di memoria.
Il campo DATA elenca una serie di attributi assegnati al segmento dati del nostro
programma; gli attributi PRELOAD e MOVEABLE hanno il solito significato, mentre
l'attributo MULTIPLE indica che se vengono eseguite due o più istanze dello stesso
programma, ogni istanza avrà la sua copia privata del segmento dati.
Il campo HEAPSIZE indica la dimensione iniziale in byte che verrà assegnata al Local
Heap della nostra applicazione; nel caso di Figura 3.5, viene assegnata una dimensione iniziale
di 1024 byte che se necessario verrà modificata da Windows.
Il campo STACKSIZE indica la dimensione in byte da assegnare allo stack del nostro
programma; in Win16 la dimensione minima raccomandata è di 8192 byte (8
Kib), mentre in Win32 è di circa 10 Kib.
Già con l'arrivo di Windows 95 alcuni campi del definition file hanno cominciato a perdere
importanza; in particolare, un'applicazione "pura" per Windows 95 non ha nessun Local
Heap, per cui il campo HEAPSIZE anche se è presente viene ignorato dal linker.
I campi CODE e DATA diventano importanti solo se si installa Windows in un
computer con scarse risorse di memoria RAM; inoltre l'attributo MULTIPLE del campo
DATA è superfluo in quanto in Win32 ogni applicazione ha il suo spazio di
indirizzamento privato. In generale quando si sviluppano applicazioni per Win32 è
anche possibile omettere il definition file; in questo caso i vari linker utilizzano una
serie di impostazioni predefinite che spesso sono molto più efficienti rispetto a quelle
specificate dal programmatore. In particolare il linker fornisce al SO tutte le indicazioni
per la corretta inizializzazione dei registri SS e ESP utilizzati per la gestione
dello stack; grazie all'abbondante disponibilità di spazio virtuale, in assenza del definition
file un'applicazione Win32 riceve generalmente 1 Mib di stack. Per tutti questi
motivi, la tendenza che si segue in Win32 è quella di evitare del tutto l'uso del
definition file da associare alle applicazioni.
3.6 Il segmento di codice
Nella sezione Assembly Base abbiamo visto che un programma Assembly destinato
a girare in ambiente DOS ha un blocco codice principale che inizia con un'etichetta che
rappresenta l'entry point del programma, e termina con una serie di istruzioni che
restituiscono il controllo al DOS. Qualcuno potrebbe restare sorpreso nell'apprendere che
in Win32 succede esattamente la stessa cosa; la Figura 3.6 mostra proprio l'estrema
semplicità dello scheletro del segmento di codice di un'applicazione Assembly per
Win32.
Il primo aspetto da osservare è che il segmento di codice deve chiamarsi obbligatoriamente
_TEXT; l'attributo di classe inoltre deve essere obbligatoriamente 'CODE'. Appena
il programma viene caricato in memoria, l'instruction pointer EIP viene inizializzato
proprio con l'offset a 32 bit dell'etichetta che abbiamo indicato come entry point;
il nome di questa etichetta deve essere naturalmente lo stesso indicato dalla direttiva END
che chiude il modulo Assembly. Nel caso di Figura 3.6 viene utilizzato il nome start,
ma siamo liberi di utilizzare qualsiasi altro nome come startWin32, main, etc; i
linguaggi di alto livello invece impongono nomi obbligatori per l'entry point del
programma.
Per indicare a Win32 che il nostro programma è giunto al termine, dobbiamo chiamare la
procedura ExitProcess; questa procedura viene definita nella libreria kernel32.lib
e rappresenta quindi uno dei tanti servizi a basso livello che ci vengono messi a disposizione dal
kernel di Win32. Se consultiamo il Win32 Programmer's Reference, possiamo
notare che questa procedura viene dichiarata come:
void ExitProcess(UINT uExitCode);
Come è stato spiegato in un precedente capitolo, il Win32 Programmer's Reference contiene
tutta la documentazione relativa all'API di Win32; questa documentazione comprende
la descrizione completa delle procedure, delle strutture, delle costanti predefinite, delle
varie TYPEDEF, etc, che fanno parte dell'API di Win32. In virtù del fatto
che Windows è scritto in C, anche i manuali di riferimento sull'API di
Windows utilizzano la sintassi del linguaggio C; chi conosce bene questo linguaggio
si troverà quindi a proprio agio anche nella programmazione Assembly in ambiente
Windows.
Analizzando la precedente dichiarazione di ExitProcess (prototipo di funzione), possiamo
notare che questa procedura richiede un unico parametro uExitCode di tipo UINT, e
quando termina non restituisce nessun valore (void); il tipo UINT rappresenta in
Windows il tipo di dato intero senza segno a 32 bit. Questo tipo di dato viene
creato nell'include file windows.inc con la dichiarazione:
UINT TYPEDEF DWORD
che in vecchio stile Assembly è perfettamente equivalente alla dichiarazione:
UINT EQU DD
ogni volta che l'assembler incontra il nome simbolico UINT, lo sostituisce con DD
(Define Double Word). Il nome uExitCode ha il solo scopo di ricordarci che questo parametro
contiene un valore numerico a 32 bit che il nostro programma restituisce a Windows
prima di terminare (exit code); secondo la convenzione Unix, un valore di ritorno
uguale a zero indica la terminazione corretta del nostro programma. Si tenga presente comunque
che questo exit code viene totalmente ignorato da Windows.
La Figura 3.6 ci permette di vedere come avviene la chiamata di ExitProcess in vecchio stile
Assembly e come viene applicata in pratica la convenzione STDCALL; prima di tutto
dobbiamo inserire nello stack i parametri da passare alla procedura. Secondo la convenzione
C questi parametri vengono inseriti nello stack a partire dall'ultimo; nel nostro caso
esiste un solo parametro a 32 bit che viene inserito nello stack con l'istruzione:
push dword ptr 0
Il type override dword ptr serve solo per rendere più chiara l'istruzione; ricordiamoci
che in modalità protetta a 32 bit lo stack viene gestito attraverso SS:ESP.
L'istruzione PUSH accetta quindi operandi a 16 bit e a 32 bit; se questi
operandi sono di tipo mem o reg, allora PUSH è in grado di determinare la
loro dimensione in bit. Se l'operando è di tipo imm, allora PUSH in assenza del
type override converte sempre il valore immediato in un numero a 32 bit.
Una volta che abbiamo inserito i parametri nello stack, possiamo chiamare la procedura
ExitProcess con l'istruzione:
call ExitProcess
prima di terminare, ExitProcess nel rispetto della convenzione Pascal ripulisce lo
stack con l'istruzione:
ret (4) ; (1 DWORD = 4 BYTE).
Se Win32 avesse seguito la convenzione C anche per la pulizia dello stack, questo
compito sarebbe spettato a noi; in questo caso, subito dopo la chiamata di ExitProcess
avremmo dovuto inserire l'istruzione:
add esp, 4
o anche ad esempio:
pop eax
Grazie alla presenza della direttiva:
.MODEL
possiamo evitare tutto questo lavoro sfruttando le istruzioni avanzate di MASM; quindi possiamo scrivere:
invoke ExitProcess, 0
Quando l'assembler incontra queste istruzioni, utilizza le informazioni specificate dalla direttiva:
.MODEL
per sapere come si deve comportare; tutto il lavoro svolto dall'assembler può essere analizzato attraverso il Listing File.
Un'aspetto importantissimo da analizzare è legato al fatto che anche all'interno del segmento
di codice del nostro programma possiamo definire delle variabili; come al solito, è necessario
fare in modo che la CPU non tenti di eseguire queste variabili scambiandole per codici macchina
di qualche istruzione. Una soluzione a questo problema può essere quella mostrata in Figura 3.7.
In pratica, subito dopo l'entry point è presente un'istruzione JMP che esegue
un salto incondizionato all'etichetta start_code; in questo modo la CPU salta le
definizioni dei vari dati presenti all'interno del blocco codice. Il problema che si presenta
è dato dal fatto che ci troviamo in modalità protetta; se vogliamo modificare il contenuto
di una variabile definita nel segmento di codice, dobbiamo avere il permesso di scrittura su
questo segmento. Nell'ambiente operativo a 16 bit predisposto dal DOS, qualunque
segmento di programma (codice, dati o stack), è accessibile sia in lettura che in scrittura;
questo significa che possiamo tranquillamente leggere o modificare il contenuto di eventuali
variabili definite anche nel segmento di codice. In modalità protetta invece tutto dipende
dai vari attributi assegnati ai descrittori dei vari segmenti di programma; nel caso di
Win32 questi attributi vengono decisi naturalmente dal SO. In una applicazione
Win32, i segmenti di stack, di dati inizializzati e di dati non inizializzati sono
ovviamente accessibili sia in lettura che in scrittura; il segmento di codice invece (che è
ovviamente un segmento eseguibile), è accessibile solo in lettura. Questo significa che
possiamo tranquillamente leggere i dati che abbiamo definito in Figura 3.7, ma non possiamo
modificare il loro contenuto; se proviamo ad accedere in scrittura a questi dati, il SO
chiude forzatamente il nostro programma e mostra un messaggio di errore.
Un'ultima cosa da dire è legata al fatto che come al solito, grazie alla presenza nel nostro
programma della direttiva:
.MODEL
possiamo servirci delle direttive
semplificate per i segmenti di programma; nel caso di Figura 3.6 o di Figura 3.7, l'inizio del
segmento di codice può essere indicato attraverso la direttiva semplificata:
.CODE
3.7 Il gruppo DGROUP
In fase di assemblaggio di un programma per Win32, l'assembler in presenza della
direttiva .MODEL, raggruppa tutti i blocchi di dati in un gruppo chiamato
convenzionalmente DGROUP; come abbiamo visto nella sezione Assembly Base e
come viene mostrato anche nel capitolo successivo, l'assembler inserisce automaticamente
nel programma la direttiva:
DGROUP GROUP _DATA, _CONST, _STACK, _BSS
Lo scopo di questa direttiva è quello di permettere una gestione più semplice ed efficiente
dei dati di un programma; tutti i dettagli relativi alla inizializzazione dei registri di
segmento (compreso DS) spettano al SO. Il programmatore quindi deve evitare
nella maniera più assoluta di modificare il contenuto di questi registri (come ad esempio,
tentare di caricare DGROUP in DS); d'altra parte abbiamo anche visto che in
modalità protetta i registri di segmento svolgono un ruolo completamente diverso da quello
svolto in modalità reale.
Nella sezione Assembly Base abbiamo visto che in presenza della direttiva .MODEL,
il MASM crea automaticamente il gruppo DGROUP sia in presenza delle direttive
semplificate per i segmenti, sia in presenza delle direttive classiche per i segmenti stessi.
3.8 Template di un programma Assembly per Win32
Raccogliendo tutte le considerazioni esposte in questo capitolo, possiamo definire lo scheletro
di una applicazione Assembly per Win32; la Figura 3.8 mostra un esempio con le chiamate
delle procedure in puro stile Assembly compatibile con tutte le versioni di MASM.
A lungo andare, l'uso delle istruzioni PUSH per il passaggio degli argomenti alle
procedure può diventare abbastanza fastidioso; possiamo servirci allora delle direttive
avanzate di MASM. Se stiamo utilizzando MASM, la chiamata di
ExitProcess diventa:
invoke ExitProcess, 0
(ricordiamo che per poter usare queste direttive avanzate, è necessaria come al solito
la presenza della direttiva:
.MODEL
La direttiva avanzata INVOKE lavorando in combinazione con i prototipi delle procedure,
è in grado di rilevare eventuali errori che possiamo commettere nel passaggio degli
argomenti alle procedure stesse.
3.9 Programmazione in modalità protetta a 32 bit
Prima di iniziare a scrivere programmi Assembly per Win32, bisogna ricordare
che in questo caso ci troviamo in un ambiente operativo basato sulla modalità protetta a
32 bit delle CPU 80386 e superiori; è necessario quindi riassumere brevemente
le principali differenze che esistono tra la modalità protetta a 32 bit e la
modalità reale a 16 bit. Gli assembler come MASM sono in grado
di rilevare automaticamente la presenza di un ambiente operativo a 16 o a 32
bit; in presenza di un ambiente operativo a 32 bit il comportamento predefinito
dell'assembler si basa principalmente sulle seguenti regole:
Tutti gli indirizzamenti sono di tipo NEAR e sono costituiti quindi dalla sola
componente offset che questa volta però è un numero a 32 bit; con un
offset a 32 bit possiamo spostarci teoricamente da 00000000h a
FFFFFFFFh. Quest'offset è riferito al base address, cioè all'indirizzo
lineare a 32 bit da cui parte il segmento di programma in cui ci troviamo; nel
caso dei SO come Win32, il base address di ogni segmento di
programma viene ovviamente definito dallo stesso SO.
Tutti i registri generali EAX, EBX, ECX, EDX, e tutti i registri
speciali ESI, EDI, ESP, EBP, possono essere utilizzati come
registri puntatori; ricordiamo che negli indirizzamenti che comprendono registro base,
registro indice e spiazzamento, il registro ESP può ricoprire solo
il ruolo di registro base. In modalità protetta a 32 bit possiamo quindi
scrivere istruzioni del tipo:
mov dx, [eax + edi + 120500]
è importante anche ricordare che in assenza di segment override, tutti gli indirizzamenti
che hanno ESP o EBP come registro base rappresentano degli offset a
32 bit calcolati rispetto al base address referenziato da SS; in tutti
gli altri casi gli indirizzamenti rappresentano degli offset a 32 bit calcolati
rispetto al base address referenziato da DS.
Le istruzioni per la manipolazione delle stringhe, utilizzano automaticamente ESI come
puntatore sorgente e EDI come puntatore destinazione; in presenza inoltre dei prefissi
REP, REPZ, REPNZ, etc, viene utilizzato ECX come contatore.
Analogamente, il controllo dei loop da parte delle istruzioni LOOP, LOOPZ,
LOOPNZ, etc, si basa sul contenuto del registro ECX.
Per ottimizzare al massimo i tempi di accesso allo stack da parte della CPU, è importante
tenere ESP e EBP sempre allineati alla DWORD; a tale proposito è
necessario utilizzare le istruzioni PUSH e POP sempre con operandi a 32
bit. Se si deve inserire nello stack un valore formato da meno di 32 bit, è necessario
estendere il valore stesso a 32 bit; se ad esempio vogliamo inserire nello stack il
contenuto a 8 bit del registro AL, dobbiamo passare all'istruzione PUSH
l'operando a 32 bit EAX azzerando i suoi 24 bit più significativi.
Se si passa a PUSH un operando immediato, la dimensione in bit di questo operando
viene automaticamente estesa a 32 bit; le regole per l'estensione del bit di segno sono
state esposte nella sezione Assembly Base.
Lo stack frame delle procedure viene gestito attraverso ESP e EBP; i
parametri di una procedura si trovano a spiazzamenti positivi rispetto a EBP, mentre
le variabili locali si trovano a spiazzamenti negativi rispetto a EBP. Per fare
posto alle variabili locali bisogna sottrarre l'opportuno numero di byte a ESP; le
procedure dotate di stack frame devono rigorosamente preservare il contenuto originale
di ESP e EBP. In modalità protetta a 32 bit, la chiamata di una procedura
comporta da parte della CPU l'inserimento nello stack dell'indirizzo di ritorno formato dalla
sola componente offset a 32 bit; all'interno della procedura, la gestione dello
stack frame comporta da parte del programmatore il salvataggio nello stack del contenuto
originale del registro EBP. Tenendo conto di queste considerazioni, nel caso delle
convenzioni C per il passaggio degli argomenti possiamo dire che il primo parametro di
una procedura si viene a trovare nello stack a EBP+8; al termine della procedura, il
caller deve ripulire lo stack sommando l'opportuno numero di byte a ESP. Nel caso
invece delle convenzioni Pascal per il passaggio degli argomenti possiamo dire che
l'ultimo parametro di una procedura si viene a trovare nello stack a EBP+8; la procedura
stessa prima di terminare deve ripulire lo stack passando un opportuno valore immediato
all'istruzione RET. In Win32 come sappiamo vengono utilizzate le convenzioni miste
STDCALL; in questo caso una procedura trova il suo primo parametro a EBP+8, ed
ha anche la responsabilità di ripulire lo stack.
Se si utilizzano le caratteristiche avanzate di MASM, tutti i dettagli
appena esposti vengono automaticamente gestiti dall'assembler che provvede anche a preservare
il contenuto originale di ESP e di EBP; naturalmente, l'uso delle caratteristiche
avanzate di MASM non esime il programmatore dall'avere una conoscenza
approfondita di tutti gli aspetti relativi alla gestione dello stack frame di una
procedura.
Sempre in relazione alle procedure bisogna anche ricordare che negli ambienti operativi a
32 bit le convenzioni seguite dai linguaggi di alto livello prevedono che i valori
di ritorno a 32 bit vengano restituiti in EAX e non in DX:AX; lo stesso
discorso vale quindi anche per gli indirizzi NEAR formati dalla sola componente
offset a 32 bit.
In modalità protetta bisogna evitare nella maniera più assoluta di chiamare porzioni di
codice scritte espressamente per la modalità reale; questo discorso vale in particolare per
le ISR che gestiscono i vettori di interruzione presenti nei primi 1024 byte
della RAM. Queste ISR sono rivolte alla modalità reale, e la loro chiamata
quindi manda in crash un programma che gira in modalità protetta; si deve anche tenere
presente che una applicazione Win32 si interfaccia con il SO in modo
completamente differente rispetto a quanto accade con il DOS. Una applicazione che
gira sotto DOS si interfaccia al SO attraverso una serie di ISR
richiamabili con l'istruzione INT; come viene spiegato nei capitoli successivi, una
applicazione Win32 invece si interfaccia con il SO attraverso la chiamata
diretta di una serie di procedure fornite dallo stesso SO.
3.10 Esempio pratico
Vediamo un esempio pratico relativo ad una procedura copiaStringa che copia una
stringa C sorgente in una stringa C destinazione restituendo alla fine la
lunghezza della stringa copiata; questa procedura utilizza le convenzioni STDCALL
compatibili con Win32. Se non abbiamo a disposizione le caratteristiche avanzate di
MASM, siamo costretti a gestire personalmente tutti i dettagli relativi
al prolog code e all'epilog code della procedura; in ambiente Win32
dobbiamo quindi attenerci al modello di memoria FLAT e alle convenzioni STDCALL.
Come già sappiamo, il modello FLAT consiste in sostanza nell'uso degli indirizzamenti
di tipo NEAR a 32 bit; le convenzioni STDCALL consistono invece nel
passare gli argomenti in stile C e nel ripulire lo stack in stile Pascal.
In base a queste considerazioni, la procedura copiaStringa scritta in Assembly
classico assume il seguente aspetto:
Prima di tutto osserviamo che questa procedura riceve due argomenti di tipo puntatore a
stringa, e cioè due indirizzi NEAR a 32 bit; siccome gli argomenti vengono
inseriti nello stack a partire dall'ultimo, incontreremo il parametro strTo a
EBP+8 e il parametro strFrom a EBP+12, cioè 4 byte più
avanti. La variabile locale strCount a 32 bit viene utilizzata come
contatore e si trova a EBP-4; naturalmente sarebbe meglio utilizzare un registro,
ma la procedura copiaStringa ha solamente uno scopo didattico e quindi è volutamente
non ottimizzata.
Con le prime tre istruzioni copiaStringa preserva il contenuto di EBP, copia
ESP in EBP e sottrae 4 byte a ESP per fare posto a strCount
nello stack; come si può notare, si tratta dello stesso procedimento già illustrato nella
sezione Assembly Base, adattato in questo caso all'ambiente operativo a 32 bit.
La variabile locale strCount viene inizializzata con -1 per tener conto dello
zero finale della stringa C che non deve essere conteggiato; il registro ESI
contiene l'indirizzo della stringa sorgente, mentre il registro EDI contiene l'indirizzo
della stringa destinazione. All'interno del loop possiamo notare che tutto si svolge nel
modo che già conosciamo; l'unica differenza è rappresentata dall'uso dei puntatori a
32 bit.
Al termine del loop, il contenuto di strCount viene copiato in EAX e rappresenta
il valore di ritorno destinato al caller; successivamente incontriamo le istruzioni che
ripristinano ESP e EBP. Prima di terminare, la procedura copiaStringa
nel rispetto delle convenzioni STDCALL deve ripulire lo stack; siccome la procedura
ha ricevuto due argomenti da 4 byte ciascuno, la pulizia dello stack consiste nel
passare il valore immediato 8 all'istruzione RET.
La fase di chiamata di copiaStringa deve ugualmente adattarsi al modello di memoria
FLAT e alle convenzioni STDCALL; in Win32, in presenza dell'unico
segmento di codice _TEXT, la chiamata di una procedura può essere diretta
intrasegmento (salto ad una etichetta NEAR), o indiretta intrasegmento
(salto ad un indirizzo NEAR a 32 bit).
Supponendo ora di aver definito nel blocco dati del programma le due stringhe strSource
e strDest (con strDest che deve essere in grado di contenere strSource),
la chiamata di copiaStringa si svolge in questo modo:
Come si può notare, gli argomenti vengono inseriti nello stack a partire dall'ultimo; il valore
immediato restituito dall'operatore OFFSET è naturalmente l'indirizzo 32 bit del
relativo operando. Appena copiaStringa restituisce il controllo al caller, il valore di
ritorno della procedura è disponibile in EAX; possiamo anche notare che la pulizia
dello stack viene delegata alla procedura stessa.
Vediamo ora quello che succede in presenza delle caratteristiche avanzate di MASM;
prima di tutto, all'inizio del programma dobbiamo inserire le seguenti direttive:
Come è stato detto in precedenza, la direttiva .386 rappresenta in termini di CPU il
requisito minimo per programmare in Win32; in presenza di questa direttiva e del
parametro FLAT l'assembler capisce che deve lavorare in un ambiente operativo a
32 bit.
Il passo successivo consiste nella dichiarazione del prototipo di copiaStringa; questa
dichiarazione assume il seguente aspetto:
copiaStringa PROTO :DWORD, :DWORD
All'interno del blocco _TEXT, la definizione di copiaStringa è la seguente:
Come si può notare, tutta la gestione dello stack frame, compresa la pulizia dello
stack, viene delegata all'assembler; le modalità che regolano questa gestione vengono
stabilite dai parametri che abbiamo specificato nella direttiva .MODEL.
A questo punto, possiamo procedere con la chiamata di copiaStringa; grazie alla sintassi
avanzata di MASM questa chiamata diventa:
invoke copiaStringa, strDest, strSrc
Quando l'assembler incontra questa chiamata, segue un comportamento determinato dai parametri
passati alla direttiva .MODEL; in questo modo l'assembler è in grado di sapere in
particolare come vengono inseriti gli argomenti nello stack e a chi spetta la pulizia finale
dello stesso stack. Il parametro STDCALL fa in modo che l'assembler inserisca nello
stack prima l'argomento strSource e poi l'argomento strDest; a questo punto,
grazie al parametro FLAT l'assembler capisce che la chiamata a copiaStringa è
di tipo diretto intrasegmento (in sostanza, copiaStringa è un'etichetta NEAR
definita nel blocco _TEXT e rappresentata quindi da un offset a 32 bit). Sempre
in base al parametro STDCALL, l'assembler genera il codice macchina necessario per
la pulizia dello stack in stile Pascal; come al solito, quando il controllo viene
restituito al caller, il registro EAX contiene il valore di ritorno della procedura
copiaStringa.