Il C, pur rientrando tra i linguaggi di alto livello, rende disponibili potenti funzionalità di gestione dello hardware: nelle librerie di tutti (o quasi) i compilatori oggi in commercio sono incluse funzioni progettate appositamente per controllare "da vicino" e pilotare il comportamento del BIOS e delle porte. A questo va aggiunta la notevole efficienza del codice compilato, una delle caratteristiche di maggior pregio dei programmi scritti in C.
Ciononostante, il controllo totale del sistema nel modo più efficiente possibile è ottenibile solo tramite la programmazione in linguaggio assembler, in quanto esso costituisce la traduzione letterale, in termini umani, del linguaggio macchina, cioè dell'insieme di istruzioni in codice binario che il processore installato sul computer è in grado di eseguire[1]. In altre parole, ogni istruzione assembler corrisponde (in prima approssimazione) ad una delle operazioni elementari eseguibili dalla CPU.
Si comprende perciò come il realizzare parte di un programma in C ricorrendo direttamente all'assembler possa accentuarne la potenza e l'efficienza complessive.
In generale, si può affermare che esistono due differenti approcci metodologici alla realizzazione di programmi parte in linguaggio C e parte in linguaggio assembler.
Il primo metodo consiste nello scrivere una o più routine interamente in linguaggio assembler: è necessaria un'ottima padronanza del linguaggio, con particolare riferimento alla gestione dello stack e dei segmenti di codice in relazione ai differenti modelli di memoria. Assemblando il sorgente si ottiene un modulo oggetto (un file .OBJ) che deve essere collegato in fase di linking ai moduli oggetto generati dal compilatore C; le routine così implementate possono essere richiamate nel sorgente C come una funzione qualsiasi. Si tratta di un ottimo sistema per realizzare librerie di funzioni, ma, di solito, alla portata unicamente dei più esperti. Ecco un semplice esempio di sorgente assembler per una funzione che stampa sullo standard output il carattere passatole come parametro, facendolo seguire da un punto esclamativo, e restituisce 1:
_TEXT segment byte public 'CODE'
_TEXT ends
DGROUP group _DATA,_BSS
assume cs:_TEXT,ds:DGROUP
_DATA segment word public 'DATA'
_p_escl label byte
db '!'
_DATA ends
_BSS segment word public 'BSS'
_BSS ends
_TEXT segment byte public 'CODE'
assume cs:_TEXT
_stampa proc near
push bp
mov bp,sp
mov ah,2
mov dl,[bp+4]
int 21h
mov ah,2
mov dl,DGROUP:_p_escl
int 21h
mov ax,1
pop bp
ret
_stampa endp
_TEXT ends
public _p_escl
public _stampa
end
Un programma C può utilizzare la funzione del listato dopo averla dichiarata:
....
int stampa(char c);
....
Va osservato che il nome assembler della funzione è _stampa, mentre in C l'underscore non compare. In effetti, per default, il compilatore modifica i nomi di tutte le funzioni aggiungendovi in testa un underscore, perciò quando si scrive una funzione direttamente in assembler bisogna ricordarsi che il nome deve iniziare con "_". Nelle chiamate alla funzione inserite nel sorgente C, invece, l'underscore deve essere omesso.
Come si vede, in questo caso la maggior parte[2] del listato assembler non è destinato a produrre codice eseguibile, ma ad informare l'assemblatore sulla struttura dei segmenti di programma, sulla presenza di simboli pubblici e sulla gestione dei registri di segmento. Per completezza presentiamo la funzione stampa() realizzata in C:
char p_escl = '!';
int bdos(int dosfn,int dosdx,int dosal);
int stampa(char c)
{
(void)bdos(2,c,0);
(void)bdos(2,p_escl,0);
return(1);
}
La maggiore compattezza del listato C è evidente. Il lettore curioso che compili la versione C di stampa() richiedendo al compilatore di produrre il corrispondente sorgente assembler[3] ottiene un listato strutturato come quello discusso poco sopra. Il file eseguibile generato a partire dalla funzione C è però, verosimilmente, di dimensioni maggiori (a parità di altre condizioni), in quanto il linker collega ad esso anche il modulo oggetto della funzione di libreria bdos().
Per sviluppare a fondo l'argomento sarebbe necessaria una approfondita digressione sul linguaggio assembler, per la quale preferiamo rimandare alla vasta manualistica disponibile: chi desidera ottimizzare, laddove necessario, i propri sorgenti C ricorrendo a un poco di assembler (e senza esserne un vero esperto) non perda le speranze...
Alcuni compilatori C[4] supportano un secondo metodo di programmazione mista, consentendo la compresenza di codice C e assembler nel medesimo sorgente: in altre parole essi permettono al programmatore di assumere a basso livello il controllo del flusso di programma, senza richiedere una conoscenza della sintassi relativa alla segmentazione del codice più approfondita di quella necessaria per la programmazione in C "puro". La funzione stampa() può allora essere realizzata così:
#pragma inline // informa il compilatore: usato inline assembly
char p_escl = '!';
int stampa(char c)
{
asm mov ah,2;
asm mov dl,c;
asm int 21h;
asm mov ah,2;
asm mov dl,p_escl;
asm int 21h;
return(1);
}
Il compilatore C si occupa di generare il sorgente assembler inserendovi, senza modificarle[5], anche le righe precedute dalla direttiva asm [6], invocando poi l'assemblatore (che effettua su di esse i controlli sintattici del caso) per generare il modulo oggetto. Segmentazione e modelli di memoria sono gestiti dal compilatore stesso come di norma, in modo del tutto trasparente al programmatore.
Calma, calma: non è questa la sede adatta a presentare le regole sintattiche relative all'uso dello inline assembly nei sorgenti C: esse sono più o meno esaurientemente trattate nella documentazione di corredo ai compilatori; inoltre il testo è ricco di esempi nei quali è frequente il ricorso alla programmazione mista. In questa sede intendiamo soffermarci, piuttosto, su alcune questioni in apparenza banali, ma sicura fonte di noiosi problemi per il programmatore che non le tenga nella dovuta considerazione.
Non bisogna dimenticare, infatti, che una singola istruzione[7] di un linguaggio di alto livello (quale è il C) può corrispondere a più istruzioni di linguaggio macchina, e dunque di assembler. A ciò si aggiunga che i compilatori, di norma, dispongono di opzioni di controllo delle ottimizzazioni[8]: non è sempre agevole, dunque, prevedere con precisione quali registri della CPU vengono di volta in volta impegnati e a quale scopo, o quale struttura assumono cicli e sequenze di salti condizionati.
Va ancora sottolineato che è buona norma inserire sempre la direttiva riservata
#pragma inline
in testa ai sorgenti in cui si faccia uso dello inline assembly, onde evitare strani messaggi di errore o di warning da parte del compilatore, anche laddove tutto è in regola.
Qualche ulteriore approfondimento potrà servire.
Si consideri la seguente funzione:
void prova(char var1,int var2)
{
char result;
asm mov ah,2;
result = var1*var2;
asm mov dl,result;
asm int 21h;
asm ret;
}
e la si confronti con il corrispondente codice assember generato dal compilatore[9]:
....
_prova proc near
push bp ; gestione stack: salva l'ind. della base
mov bp,sp ; crea lo stack privato della funzione
dec sp ; riserva lo spazio per result (word)
dec sp
mov ah,2 ; carica AH per INT 21h, servizio 2
mov al,byte ptr [bp+4] ; carica AL con var1
cbw ; "promuove" AL (var1) ad integer
imul word ptr [bp+6] ; moltiplica var1 * var2 (integer * integer)
mov byte ptr [bp-1],al ; carica result (un char) con il risultato
mov dl,[bp-1] ; carica DL con result
int 21h ; invoca servizio DOS
ret ; ritorna alla routine chiamante
mov sp,bp ; gestione stack
pop bp
ret ; ritorna alla routine chiamante
_prova endp
....
La funzione prova() dovrebbe caricare i registri AH e DL per invocare il servizio 2 dell'int 21h (scrittura del carattere in DL sullo standard output), ma, invocandola, si ottiene un crash di sistema.
Il motivo va ricercato nell'istruzione inline assembly RET: essa impedisce che siano eseguite le istruzioni generate dal compilatore per gestire correttamente lo stack in uscita dalla funzione; infatti la sequenza
MOV SP,BP
POP BP
rappresenta la controparte delle istruzioni
PUSH BP
MOV BP,SP
DEC SP
DEC SP
che si trovano in testa al codice. Se ne trae che è opportuno, salvo casi particolari, usare l'istruzione C return in uscita dalle funzioni (tra parentesi, pur eliminando la RET il risultato non è ancora quello voluto).
Lo stack è in sostanza utilizzato per il passaggio dei parametri alle funzioni e per l'allocazione delle loro variabili locali. Senza entrare nel dettaglio, esso è gestito mediante alcuni registri dedicati: SS (puntatore al segmento dello stack), SP (puntatore, nell'ambito del segmento individuato da SS, all'indirizzo che ospita l'ultima word salvata sullo stack) e BP (puntatore base, per la gestione dello stack locale delle funzioni).
Una chiamata a funzione richiede che siano copiati (PUSH) sullo stack tutti i parametri[10]: ad ogni istruzione PUSH eseguita, SP viene decrementato di due (lo stack è gestito a word, procedendo a ritroso dalla parte alta del suo segmento) per puntare all'indirizzo al quale memorizzare il parametro (o una word del parametro stesso); soltanto quando tutti i parametri sono stati copiati nello stack viene eseguita la CALL che trasferisce il controllo alla funzione (e modifica ancora una volta SP, salvando sullo stack l'indirizzo[11] al quale deve essere trasferita l'esecuzione in uscita alla funzione stessa). Ne segue che ogni funzione può recuperare i propri parametri ad offset positivi rispetto a SP [12], ed allocare spazio nello stack per le proprie variabili locali ad offset negativi (sempre rispetto a SP): infatti BP viene salvato sullo stack (PUSH BP con ulteriore decremento di SP) e caricato con il valore di SP (MOV BP,SP); BP costituisce, a questo punto, la base di riferimento per detti offset. Se sono definite variabili locali SP è decrementato (DEC o SUB) per riservare loro lo spazio necessario[13] nello stack. In uscita, la funzione deve disallocare la parte di stack assegnata alle variabili locali (MOV SP,BP) ed eliminare BP dallo stack medesimo (POP BP): in tal modo la RET (o RETF) può estrarre, sempre dallo stack, il corretto indirizzo a cui trasferire l'esecuzione. La restituzione di un valore alla funzione chiamante non coinvolge lo stack: essa avviene, di norma, tramite il registro AX, o la coppia DX:AX se si tratta di un valore a 32 bit. Alla routine chiamante spetta il compito di liberare lo stack dai parametri passati alla funzione invocata: lo scopo è raggiunto incrementando opportunamente SP con una istruzione ADD (se i parametri sono pochi, il compilatore tende ad ottimizzare il codice generando invece una o più PUSH di un registro libero, di solito CX).
La gestione della struttura dello stack è invisibile al programmatore C, in quanto il codice necessario al mantenimento della cosiddetta standard stack frame è generato automaticamente dal compilatore, ma un uso poco accorto dello inline assembly può interferire (distruttivamente!) con tali delicate operazioni, come del resto gli esempi poco sopra riportati hanno evidenziato.
Consideriamo ora un caso particolare: le funzioni che non prendono parametri e non definiscono variabili locali non fanno uso dello stack, quindi il compilatore può evitare di generare le istruzioni necessarie alla standard stack frame. Chi utlizzi lo inline assembly deve documentarsi con molta attenzione sulle caratteristiche del compilatore utilizzato, in quanto alcuni prodotti ottimizzano il codice evitando di generare dette istruzioni, mentre altri le generano in ogni caso privilegiando la standardizzazione del comportamento delle funzioni[14].
Qualche cenno meritano infine le funzioni dichiarate interrupt [15], dal momento che il compilatore si occupa di gestire lo stack in un modo particolare: infatti una funzione interrupt, normalmente, è destinata a sostituirsi ad un gestore di interrupt di sistema (o a modificarne il comportamento) e dunque non viene invocata dal programma di cui è parte, ma dal sistema operativo o dallo hardware. Si rendono pertanto necessarie alcune precauzioni, quali preservare lo stato dei registri al momento della chiamata e ripristinare i flag in uscita. A questo pensa, automaticamente, il compilatore, ma deve tenerne conto il programmatore che intenda servirsi dello inline assembly nel codice della funzione (ed è un caso frequente): i registri sono, ovviamente, salvati sullo stack in testa alla funzione, e devono esserne estratti prima di restituire il controllo al processo chiamante. Dunque attenzione, a scanso di disastri, ancora una volta alle IRET o RET selvagge[16], e attenzione a quanto detto circa la gestione degli interrupt (forse è bene dare un'occhiata anche al capitolo sui TSR).
Torniamo alla funzione prova(): si è detto che eliminare l'istruzione RET, causa di problemi nella gestione dello stack, non è sufficiente per rimuovere ogni malfunzionamento: infatti il registro AH, caricato con il numero del servizio richiesto all'int 21h, viene azzerato dall'istruzione CBW, necessaria per "promuovere" var1 da char ad int, secondo le convenzioni C in materia di operazioni algebriche tra dati di tipi diversi. L'esempio non solo evidenzia una delle molteplici situazioni in cui il codice assembler generato dal compilatore nel tradurre le istruzioni C risulta in conflitto con lo inline assembly, ma fornisce lo spunto per alcune precisazioni in materia di registri.
Il registro AX è spesso utilizzato come variabile di transito per risultati intermedi[17] ed è quindi prudente, ove possibile, caricarlo immediatamente prima dell'istruzione che ne utilizza il contenuto (vedere anche gli pseudoregistri). La funzione prova(), a scanso di problemi, può essere riscritta come segue:
char prova(char var1,int var2)
{
char result;
result = var1*var2;
asm mov dl,result;
asm mov ah,2;
asm int 21h;
}
Il registro AX non è il solo a richiedere qualche cautela: anche BX e CX sono spesso utilizzati in particolari situazioni, mentre DX è forse, tra i registri della CPU, il più "tranquillo"[18].
Qualche considerazione a parte meritano SI e DI [19]. Il compilatore garantisce che al ritorno da una funzione C essi conservano il valore che avevano al momento della chiamata: per tale motivo detti registri, se utilizzati nella funzione, vengono salvati sullo stack (PUSH) in testa al codice della funzione ed estratti dallo stesso (POP) in uscita, anche qualora SI e DI siano referenziati esclusivamente nell'ambito di righe inline assembly. Ecco un esempio:
void copia(char *dst,char *src,int count)
{
asm {
mov si,src;
mov di,dst;
mov cx,count;
}
REPEAT:
asm {
lodsb;
cmp al,0FFh;
jne DO_LOOP;
pop bp;
ret;
}
DO_LOOP:
asm {
stosb;
loop REPEAT;
}
}
La funzione presentata copia dall'array src all'array dst un numero di byte pari al valore della variabile intera count; se è incontrato un ASCII 255 (FFh), esso non è copiato e il controllo è restituito alla routine chiamante. Un rapido esame del codice assembler prodotto dal compilatore consente di verificare la fondatezza di quanto affermato:
....
_copia proc near
push bp
mov bp,sp
push si
push di
mov si,[bp+6]
mov di,[bp+4]
mov cx,[bp+8]
@1@98:
lodsb
cmp al,0FFh
jne short @1@242
pop bp
ret
@1@242:
stosb
loop short @1@98
pop di
pop si
pop bp
ret
_copia endp_copia endp
....
E' facile immaginare i problemi[20] che si verificano quando il byte letto da src è un ASCII 255: il valore di BP all'uscita dalla funzione è, in realtà, quello che il registro DI presenta in entrata[21], mentre dovrebbe essere quello dello stesso BP in entrata.
Per ottenere il salvataggio automatico di SI e DI in ingresso alla funzione (ed il loro ripristino in uscita) è sufficiente dichiarare due variabili register:
#pragma warn -use
void function(void)
{
register dummy_SI, dummy_DI;
....
....
}
Il trucchetto è particolarmente utile quando il codice inline assembly può modificare il contenuto di SI e DI in modo implicito, cioè senza memorizzare direttamente in essi alcun valore (ad esempio con una chiamata ad un interrupt che utilzzi internamente detti registri).
Per quanto riguarda il registro di segmento DS [22], è opportuno evitare di modificarlo, se non quando si sappia molto bene ciò che si sta facendo, e dunque si conosca l'impatto che tale modifica ha sul comportamento del codice: il problema può essere eliminato salvando sullo stack DS prima di modificarlo e ripristinandolo prima che esso sia referenziato da altre istruzioni C. Un esempio:
....
char far *source, far *dest;
char stringa;
....
asm {
lds si,source;
les di,dest;
mov cx,05h;
rep movsw;
}
printf(stringa);
....
Nel frammento di codice presentato, source e dest sono puntatori far, mentre stringa è un puntatore near (ipotizzando di compilare per uno dei modelli tiny, small o medium). Il compilatore lo gestisce pertanto come un offset rispetto a DS: se il segmento del puntatore far source non è pari a DS la printf() non stampa stringa, bensì ciò che si trova ad un offset pari a stringa nel segmento di source. Il codice listato di seguito lavora correttamente:
char far *source, far *dest;
char stringa;
....
asm {
push ds;
lds si,source;
les di,dest;
mov cx,05h;
rep movsw;
pop ds;
}
printf(stringa);
....
Considerazioni analoghe valgono per il registro ES [23], con la differenza che non è necessario ripristinarne il valore in uscita alla funzione che lo ha modificato, in quanto il compilatore C, contrariamente a quanto avviene per DS, non lo associa ad alcun utilizzo particolare.
Le variabili C possono essere referenziate da istruzioni inline assembly, che hanno così modo di accedere al loro contenuto: le istruzioni
int Cvar;
....
asm mov ax,Cvar;
caricano in AX il contenuto di Cvar. Analogamente:
char byteVar;
....
asm mov al,byteVar;
caricano in AL il contenuto di byteVar. Tutto fila liscio, in quanto il tipo di dato C (o meglio: le dimensioni in bit dalle variabili C) sono coerenti con le dimensioni dei registri utilizzati; nel caso in cui tale coerenza non vi sia occorre tenere in considerazione alcune cosette. Riprendendo gli esempi precedenti, l'istruzione:
asm mov al,Cvar;
è perfettamente lecita. L'assemblatore conosce la dimensione di AL (ovvio!) e si regola di conseguenza, caricandovi uno solo dei due byte di Cvar. Quale? Quello "basso". Il motivo è evidente: il nome di una variabile può essere inteso, in assembler, come una sorta di puntatore[24] all'indirizzo di memoria al quale si trova il dato contenuto nella variabile stessa. Il nome Cvar, dunque, "punta" al primo byte (quello meno significativo, appunto) di una zona di tanti byte quanti sono quelli richiesti dal tipo di dato definito in C (si tratta di un integer, pertanto sono due[25]); in altre parole, l'istruzione commentata ha l'effetto di caricare in un registro della CPU tanti byte quanti sono necessari per "riempire" il registro stesso, (in questo caso C, dunque un solo byte) a partire dall'indirizzo della variabile C. Sorge il dubbio che, allora, l'istruzione:
asm mov ax,byteVar;
carichi in AX due byte presi all'indirizzo di byteVar, anche se questa è definita char nel codice C. Ebbene, è proprio così. In tali scomode situazioni occorre venire in aiuto all'assemblatore, che non conosce le definizioni C:
asm mov al,byteVar;
asm cbw;
lavora correttamente, caricando il byte di byteVar in AL e azzerando AH [26].
Le variabili a 32 bit (tipico esempio: i long integer e i puntatori far) devono essere considerate una coppia di variabili a16 bit[27]. Supponiamo, per esempio, che la coppia di registri DS:SI punti ad un intero di nostro interesse; ecco come memorizzare detto indirizzo in un puntatore far:
char far *varptr;
....
asm mov varptr,si;
asm mov varptr+2,ds;
La prima istruzione MOV copia SI nei due byte meno significativi di varptr (è la parte offset dell'indirizzo); la seconda copia DS nei due byte alti (la parte segment): non va dimenticato che i processori 80x86 memorizzano le variabili numeriche con la tecnica backwords, in modo tale, cioè, che a indirizzo di memoria inferiore corrisponda, byte per byte, la parte meno significativa della cifra. E se volessimo copiare il dato (non il suo indirizzo) referenziato da DS:SI in una variabile? Nell'ipotesi che DS:SI punti ad un long, la sequenza di istruzioni è la seguente:
long var32bits;
....
asm mov ax,ds:[si];
asm mov dx,ds:[si+2];
asm mov var32bits,ax;
asm mov var32bits+2,dx;
Si possono fare due osservazioni: in primo luogo, non è indispensabile usare due registri[28] (qui sono usati AX e DX) come "tramite", ma non è possibile muovere dati direttamente da un'indirizzo di memoria ad un altro (in questo caso dall'indirizzo puntato da DS:SI all'indirizzo di var32bits); inoltre non è stato necessario tenere conto del metodo backwords perché il dato a 32 bit è già in formato backwords all'indirizzo DS:SI.
Abbiamo così accennato ai puntatori: il discorso merita qualche approfondimento. I puntatori C, indipendentemente dal tipo di dato a cui puntano, si suddividono in due categorie: quelli near, che esprimono un offset relativo ad un registro di segmento (un indirizzo a 16 bit), e quelli far, che esprimono un indirizzo completo di segmento e offset (32 bit). I puntatori near, però, sono il default solo nei modelli di memoria tiny, small e medium (che d'ora in avanti chiameremo "piccoli"): negli altri modelli (compact, large e huge, d'ora in poi "grandi")[29] tutti i puntatori a dati sono far, anche se non dichiarati tali esplicitamente. La gestione dei puntatori C con lo inline assembly dipende dunque in modo imprescindibile dal modello di memoria utilizzato in compilazione. Vediamo subito qualche esempio.
/*
modello "PICCOLO"
*/
....
int *s_pointer;
int *d_pointer;
....
/* s_pointer e d_pointer sono inizializzati, ad es. con malloc() */
....
asm mov si,s_pointer;
asm mov di,d_pointer;
asm push ds;
asm pop es;
asm mov cx,4;
asm rep movsw;
....
Il frammento di codice riportato copia 8 byte da s_pointer a d_pointer: dal momento che si è ipotizzato un modello di memoria "piccolo", questi sono entrambi puntatori near relativi a DS. In pratica, il contenuto di s_pointer è l'offset dell'area di memoria da cui si vogliono copiare i byte, e il contenuto di d_pointer è l'offset dell'area nella quale essi devono essere copiati: è necessario caricare ES con il valore di DS perché la MOVSW lavori correttamente.
/*
modello "GRANDE"
*/
....
int *s_pointer;
int *d_pointer;
....
/* s_pointer e d_pointer sono inizializzati, ad es. con malloc() */
....
asm push ds;
asm lds si,s_pointer;
asm les di,d_pointer;
asm mov cx,4;
asm rep movsw;
asm pop ds;
....
Nei modelli "grandi" s_pointer e d_pointer sono, per default, puntatori far, pertanto è possibile usare le istruzioni LDS e LES per caricare registri di segmento e di offset. Ciò vale anche per puntatori dichiarati far nei modelli "piccoli":
/*
modello "PICCOLO"
*/
....
int far *s_pointer;
int far *d_pointer;
....
/* s_pointer e d_pointer sono inizializzati, ad es. con farmalloc() */
....
asm push ds;
asm lds si,s_pointer;
asm les di,d_pointer;
asm mov cx,4;
asm rep movsw;
asm pop ds;
....
Molti compilatori offrono supporto a strumenti che consentono il controllo a basso livello delle risorse del sistema senza il ricorso diretto al linguaggio assembler.
Gli pseudoregistri sono identificatori che consentono di manipolare direttamente da istruzioni C i registri della CPU (compreso il registro dei flag). Per quel che riguarda il compilatore C Borland, essi sono implementati intrinsecamente: pertanto non è possibile portare il codice che li utilizza ad altri compilatori che non li implementino in maniera analoga. Va precisato che gli pseudoregistri non sono variabili, ma, come accennato, semplicemente identificatori che consentono al compilatore di generare le opportune istruzioni assembler (essi non hanno dunque un indirizzo referenziabile da puntatori): ad esempio le istruzioni C
_AX = 9;
_BX = _AX;
producono le istruzioni assembler
mov ax,9
mov bx,ax
come, del resto, ci si può attendere.
Non sempre, però, un'istruzione C contenente un riferimento ad uno pseudoregistro genera una singola istruzione assembler: vediamo un esempio.
....
if(!_AH)
if(_AL == _BL)
_AL = _CL;
....
Il compilatore, a partire dal frammento di codice riportato, genera il seguente listato assembler:
....
mov al,ah
mov ah,0
or ax,ax
jne short @1@74
cmp al,bl
jne short @1@74
mov al,cl
@1@74:
....
Come si vede, il valore di AL viene modificato per effettuare il primo test, con la conseguenza di invalidare i risultati del confronto successivo. Il ricorso allo inline assembly può evitare tali pasticci (e consentire la scrittura di codice più efficiente):
....
asm {
or ah,ah;
jne NO_CHANGE;
cmp al,bl;
jne NO_CHANGE;
mov al,cl;
}
NO_CHANGE:
....
Quando vengono assegnati valori ai registri di segmento il compilatore deve tenere conto dei limiti alla libertà di azione imposti, in questi casi, dalle regole sintattiche del linguaggio assembler. Vediamo un esempio:
_ES = 0xB800;
è tradotta in
mov ax,0B800h
mov es,ax
e anche
_DS = _ES;
produce due istruzioni assembler:
mov ax,es
mov ds,ax
che hanno, tra l'altro, l'effetto di modificare il contenuto di AX; programmando direttamente in assembler (o con l'inline assembly) si può assegnare ES a DS, senza rischio alcuno di modificare AX, con le due istruzioni seguenti:
push es
pop ds
Analoghe osservazioni sono valide per operazioni coinvolgenti lo pseudoregistro dei flag. L'istruzione C
_FLAGS |= 1;
produce la sequenza di istruzioni assembler riportata di seguito:
pushf
pop ax
or ax,1
push ax
popf
Si noti che programmando direttamente in assembler (o inline assembly), sarebbe stato possibile ottenere il risultato desiderato (CarryFlag = 1) con una sola istruzione[30]:
stc
Vale infine la pena di richiamare l'attenzione sul fatto che l'utilizzo degli pseudoregistri può condurre ad un impiego "nascosto" di registri apparentemente non coinvolti nell'operazione: negli esempi appena visti il registro AX viene usato all'insaputa del programmatore; sull'argomento si veda l'utilizzo dei registri.
La geninterrupt() è una macro basata sulla funzione intrinseca __int__() (la portabilità è perciò praticamente nulla) e definita in DOS.H. Essa invoca l'interrupt il cui numero le è passato come argomento; in pratica ha un effetto equivalente a quello di una istruzione INT inserita nel codice C tramite l'inline assembly. I registri devono essere gestiti tramite l'inline assembly medesimo o mediante gli pseudoregistri.
La __emit__() è una funzione intrinseca del compilatore C Borland: ciò implica l'impossibilità di portare ad altri compilatori che non la implementino i programmi che ne fanno uso. Nella forma più semplice di utilizzo i suoi argomenti sono byte, che vengono inseriti dal compilatore direttamente nel codice oggetto prodotto, senza che sia generato il codice relativo ad una reale chiamata a funzione. Come si comprende facilmente, __emit__() va oltre lo inline assembly, consentendo una vera e propria forma di programmazione in linguaggio macchina. Un esmpio:
#pragma option -k-
void boot(void)
{
__emit__(0xEA,(unsigned)0x00,0xFFFF);
}
La funzione boot(), quando eseguita, provoca un bootstrap[31]. Il codice macchina prodotto è il seguente (byte esadecimali):
EA 00 00 FF FF C3
I primi quattro byte rappresentano l'istruzione JMP FAR seguita dall'indirizzo (standard; FFFF:0000) al quale, nel BIOS, si trova l'istruzione di salto all'effettivo indirizzo della routine BIOS dedicata al bootstrap. Il quinto byte è la RET (peraltro mai eseguita, in questo caso) che chiude la funzione. La #pragma evita la gestione, evidentemente inutile, della standard stack frame. Da sottolineare: l'assemblatore non accetta l'istruzione
JMP FAR 0FFFFh:0h
ed emette un messaggio di errore del tipo "indirizzamento diretto illecito". La __emit__() permette, in questo caso, di ottimizzare il codice evitando il ricorso ad un puntatore contenente l'indirizzo desiderato.
Lo scrivere programmi in una sorta di linguaggio misto "C/Assembler/codice macchina" mette a disposizione possibilità realmente interessanti: vediamone una utile in diverse intricate situazioni.
Di norma, nei modelli di memoria "piccoli" (tiny, small, medium) lo spazio necessario alle variabili globali è allocato ad offset relativi a DS: ciò significa che l'accesso ad ogni variabile globale utilizza DS come punto di riferimento. Ciò avviene in maniera trasparente al programmatore, ma vi sono casi in cui non è possibile fare affidamento su detto registro.
Una tipica situazione è quella dei gestori di interrupt: questi entrano in azione in un contesto non conoscibile a priori, pertanto nulla garantisce (e infatti raramente accade) che DS punti proprio al segmento dati del programma in cui il gestore è definito; CS è l'unico registro che in entrata ad un interrupt assuma sempre il medesimo valore[32]. In casi come quello descritto è pratica prudente ed utile, a scanso di problemi, allocare le variabili esterne alla funzione ad indirizzi relativi a CS.
Un metodo per raggiungere lo scopo, benché un poco macchinoso (come al solito!), consiste nel dichiarare, collocandola opportunamente nel sorgente, una funzione fittizia, il cui codice non rappresenti istruzioni, bensì i dati (generalmente variabili globali) che dovranno essere referenziati mediante il registro CS [33].
Come fare? All'interno della funzione fittizia i dati non possono essere dichiarati come variabili esterne, perché mancherebbe comunque la dichiarazione globale, né come variabili statiche, perché subirebbero pressappoco la medesima sorte dei dati globali, né, tantomeno, come variabili automatiche, perché esisterebbero (nello stack) solamente durante l'esecuzione della funzione fittizia, la quale, proprio in quanto tale, non è mai eseguita (del resto, anche se venisse eseguita, non sarebbe comunque possibile risolvere i problemi legati alla visibilità delle variabili locali). E' necessario ricorrere allo inline assembly, che consente l'inserimento diretto di costanti numeriche nel codice oggetto in fase di compilazione; il nome della funzione fittizia, grazie ad opportune operazioni di cast, può essere utilizzato in qualunque parte del codice come puntatore ai dati in essa generati[34].
Supponiamo, ad esempio, di voler definire un puntatore a funzione interrupt e un intero senza segno:
void Jolly(void)
{
asm db 5 dup (0); // genera 5 bytes inizializzati a 0
}
#define OldIntVec ((void (interrupt *)())(*((long *)Jolly)))
#define UnsIntegr (*(((unsigned int *)Jolly)+2))
I dati da noi definiti occupano, complessivamente, 6 byte: tale deve essere l'ingombro minimo, in termini di codice macchina, della funzione Jolly(). Con lo inline assembly è però sufficiente definire un byte in meno dello spazio richiesto dai dati, in quanto il byte mancante è, in realtà, rappresentato dall'opcode dell'istruzione RET (C3), generata dal compilatore in chiusura della funzione, il quale può essere sovrascritto senza alcun problema: la Jolly() non viene mai eseguita[35].
Le macro definite dalle direttive #define eliminano la necessità di effettuare complessi cast quando si utilizzano, nel codice, i dati contenuti in Jolly(): si può fare riferimento ad essi come a variabili globali dichiarate nel modo tradizionale. Infatti OldIntVec rappresenta, a tutti gli effetti, un puntatore a funzione di tipo interrupt: il nome della Jolly(), che è implicitamente puntatore alla funzione medesima, viene forzato a puntatore a long (in pratica, puntatore a un dato a 32 bit), la cui indirezione (il valore contenuto all'indirizzo CS:Jolly) è a sua volta forzata a puntatore a funzione interrupt. La seconda macro impone al compilatore di considerare Jolly quale puntatore a intero senza segno; sommandovi 2 si ottiene l'indirizzo CS:Jolly+4 (il compilatore somma in realtà quattro a Jolly proprio perché si tratta, in seguito al cast, di puntatore ad intero), la cui indirezione è un unsigned integer, rappresentato da UnsIntegr [36].
Una precisazione, a scanso di problemi: può accadere di dover fare riferimento con istruzioni inline assembly ai dati globali gestiti nella Jolly(). E' evidente che in tale caso le macro descritte non sono utilizzabili, in quanto, espanse dal preprocessore, diverrebbero parti in C di istruzioni assembler, con la conseguenza che l'assemblatore non sarebbe in grado di compilare l'istruzione così costruita. Ad esempio:
#define integer1 (*((int *)Jolly))
#define integer2 (*(((int *)Jolly)+1))
#define new_handler ((void (interrupt *)())(*(((long *)Jolly)+1)))
void Jolly(void)
{
asm dw 0;
asm dw 1;
asm dd 0;
}
void interrupt new_handler(void)
{
....
asm {
pushf;
call dword ptr new_handler;
}
....
}
Il gestore di interrupt new_handler() utilizza il vettore del gestore originale per invocare quest'ultimo[37]; il sorgente assembler risultante contiene le righe seguenti:
....
PUSHF
CALL DWORD PTR ((void (interrupt *)())(*(((long *)Jolly)+1)))
....
Sicuramente la CALL non è assemblabile: la macro new_handler può essere utilizzata solo nell'ambito di istruzioni in linguaggio C. Con lo inline assembly è necessario fare riferimento al nome della funzione fittizia sommandovi l'offset, in byte, del dato che si intende referenziare. Come in precedenza, una macro può semplificare le operazioni:
#define integer1 (*((int *)Jolly))
#define integer2 (*(((int *)Jolly)+1))
#define new_handler ((void (interrupt *)())(*(((long *)Jolly)+1)))
#define ASM_handler Jolly+4
void Jolly(void)
{
asm dw 0;
asm dw 1;
asm dd 0;
}
void interrupt new_handler(void)
{
....
asm {
pushf;
call dword ptr ASM_handler;
}
....
}
L'espansione della macro, questa volta, genera la riga seguente, che può essere validamente compilata dall'assemblatore:
....
CALL DWORD PTR Jolly+4
....
Per evitare un proliferare incontrollato di direttive #define, si può definire una funzione fittizia per ogni variabile da simulare:
void vectorPtr(void)
{
asm db 3 dup(0);
}
void unsigednVar(void)
{
asm db 0;
}
In tal modo le istruzioni assembly potranno contenere riferimenti diretti ai nomi delle funzioni fittizie:
....
asm mov ax,word ptr unsignedVar;
asm pushf;
asm call dword ptr vectorPtr;
....
Sfortunatamente, le differenze esistenti tra versioni successive del compilatore introducono alcune complicazioni: gli esempi riportati sono validi sia per TURBO C 2.0 che per TURBO C++ 1.0 e successivi, ma occorrono alcune precisazioni.
Le versioni C++ 1.0 e successive del compilatore (a differenza di TURBO C 2.0) inseriscono per default i tre opcode corrispondenti alle istruzioni PUSH BP e MOV BP,SP in testa al codice di ogni funzione e, di conseguenza, quello dell'istruzione POP BP prima della RET finale, anche quando la funzione stessa sia dichiarata void e priva di parametri formali, come nel caso della Jolly(): questo significa che l'ingombro minimo di una funzione è 5 byte (55h,8Bh,ECh e 5Dh,C3h). Nel caso esaminato è allora sufficiente definire un solo byte aggiuntivo per riservare tutto lo spazio necessario ai dati utilizzati:
void Jolly(void)
{
asm db 0; // genera 1 byte inizializzato a 0
}
Si noti che almeno un byte deve essere comunque riservato mediante lo inline assembly, anche qualora i 5 byte di ingombro minimo della funzione siano sufficienti a contenere tutti i dati necessari: in caso contrario il compilatore non interpreta come desiderato il codice e gestisce l'offset rispetto a CS della funzione fittizia come offset rispetto a DS, con la conseguenza di vanificare tutto l'artificio, ed il rischio di obliterare selvaggiamente il segmento dati. Inoltre, se si desidera inizializzare i dati direttamente con le istruzioni DB [38], non è possibile sfruttare i byte di codice generati dal compilatore, e può essere necessario inserire dei byte a "tappo" per semplificare la gestione dei cast. Per definire un long integer inizializzato al valore 0x12345678 occorre regolarsi come segue:
void Jolly(void)
{
asm db 0; // tappo
asm dd 0x12345678;
}
#define LongVar (*(((long *)Jolly)+1))
Il byte tappo consente di sfruttare l'aritmetica dei puntatori: in sua assenza, la macro sarebbe risultata necessariamente più complessa:
void Jolly(void)
{
asm dd 0x12345678;
}
#define LongVar (*((long *)(((char *)Jolly)+3)))
Solo i puntatori a dati di tipo char, infatti, ammettono un offset di un numero dispari di byte rispetto all'indirizzo di base (si ricordi che TURBO C++ 1.0 e successivi generano tre opcode in testa alla funzione).
Va rilevato, infine, che il compilatore, quando si utilizza il modello di memoria huge, genera un'istruzione PUSH DS subito dopo la MOV BP,SP e, di conseguenza, una POP DS immediatamente prima della POP BP: di ciò bisogna ovviamente tenere conto.
Da quanto ora evidenziato derivano problemi di portabilità, che possono però essere risolti con poco sforzo. Il compilatore definisce automaticamente alcune macro, una delle quali può essere utilizzata per scrivere programmi che, pur utilizzando la tecnica di gestione dei dati globali sopra descritta, risultino portabili tra le diverse versioni del compilatore stesso e possano quindi essere compilati senza necessità di modifiche al sorgente.
La macro predefinita in questione è __TURBOC__: essa rappresenta una costante esadecimale intera senza segno che corrisponde alla versione di compilatore utilizzata. Ad esempio, il programma seguente visualizza numeri differenti se compilato con differenti versioni del compilatore:
#include <stdio.h>
void main(void)
{
int Version, Revision;
Version = __TURBOC__ >> 8;
Revision = __TURBOC__ & 0xFF;
printf("Versione del Compilatore TURBO C: %02X.%02X\n",Version,Revision);
}
La tabella che segue è riportata per comodità.
VALORI DELLA MACRO __TURBOC__
TURBO C 1.00 | |||
TURBO C 2.00 | |||
TURBO C++ 1.00 | |||
TURBO C++ 1.01 | |||
BORLAND C++ 2.00 | |||
BORLAND C++ 3.1 |
Infine, riprendiamo uno dei precedenti esempi applicandovi la tecnica di compilazione condizionale:
void Jolly(void)
{
#if __TURBOC__ >= 0x0295
asm db 0; // tappo
#endif
asm dd 0x12345678;
}
#if __TURBOC__ >= 0x0295
#define LongVar (*(((long *)Jolly)+1))
#else
#define LongVar (*((long *)Jolly))
#endif
Una seconda via, ancora più semplice, per aggirare l'ostacolo consiste nello specificare, quando si compili con TURBO C++ 1.0 o successivi, l'opzione k sulla command line (o inserire la direttiva #pragma option k) in testa al codice sorgente: essa evita la creazione di una struttura standard di stack per tutte le funzioni (standard stack frame) e pertanto le funzioni void prive di parametri vengono compilate come avviene per default con TURBO C 2.0 (e versioni precedenti).
Va ancora sottolineato che definire una funzione fittizia per ogni variabile (e attivare sempre l'opzione k) consente di aggirare le difficoltà cui si è fatto cenno.
Per ulteriori approfondimenti in tema di funzioni fittizie utilizzate
quali contenitori di dati si vedano il paragrafo dedicato in tema di programmi TSR e il paragrafo sull'uso dei vettori di interrupt come puntatori.
Non ci ho capito niente! Ricominciamo...