|
Appunti informatica |
|
Visite: 1579 | Gradito: | [ Grande appunti ] |
Leggi anche appunti:Problemi di mutua esclusione nel modello a memoria comuneProblemi di mutua esclusione nel modello a memoria comune VI) Scrivere una Operatori aritmeticiOperatori aritmetici Gli operatori aritmetici del C sono i simboli di addizione C e clipperC e Clipper Clipper è un linguaggio compilato, sintatticamente compatibile in |
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 . 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
_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 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)
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 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 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)
Il compilatore C si occupa di generare il sorgente assembler inserendovi, senza modificarle , anche le righe precedute dalla direttiva asm , 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 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 : 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)
e la si confronti con il corrispondente codice assember generato dal compilatore
_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: chi sia curioso di consocerne il motivo, può vedere a pag.
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 : 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 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 , 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 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
Qualche cenno meritano infine le funzioni dichiarate interrupt , 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 , e attenzione a quanto detto a pagina e seguenti (forse è bene dare un'occhiata anche al capitolo sui TSR, pag.
Torniamo alla funzione prova() di pag. : 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 ed è quindi prudente, ove possibile, caricarlo immediatamente prima dell'istruzione che ne utilizza il contenuto (vedere anche pag. La funzione prova(), a scanso di problemi, può essere riscritta come segue:
char prova(char var1,int var2)
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'
Qualche considerazione a parte meritano SI e DI . 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)
REPEAT:
asm
DO_LOOP:
asm
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 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]
lodsb
cmp al,0FFh
jne short @1@242
pop bp
ret
stosb
loop short @1@98
pop di
pop si
pop bp
ret
_copia endp_copia endp
E' facile immaginare i problemi che si verificano quando il byte letto da src è un ASCII : il valore di BP all'uscita dalla funzione è, in realtà, quello che il registro DI presenta in entrata , 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)
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 , è 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
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
printf(stringa);
.
Considerazioni analoghe valgono per il registro ES , 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 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 ); 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
Le variabili a 32 bit (tipico esempio: i long integer e i puntatori far) devono essere considerate una coppia di variabili a 16 bit . 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 (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') 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
.
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
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 ) con una sola istruzione
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 pag.
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)
La funzione boot() , quando eseguita, provoca un bootstrap . 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 (pag. ). 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 (di cui si parla e straparla a pag. e seguenti): 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 . 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
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
Supponiamo, ad esempio, di voler definire un puntatore a funzione interrupt e un intero senza segno:
void Jolly(void)
#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
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
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)
void interrupt new_handler(void)
.
Il gestore di interrupt new_handler() utilizza il vettore del gestore originale per invocare quest'ultimo ; 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)
void interrupt new_handler(void)
.
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)
void unsigednVar(void)
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)
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 , 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)
#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)
#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)
La tabella che segue è riportata per comodità.
Valori della macro __TURBOC__
VALORE MACRO |
VERSIONE |
REVISIONE |
CompilatorE |
0x0001 |
|
|
TURBO C 1.00 |
0x0200 |
|
|
TURBO C 2.00 |
0x0295 |
|
|
TURBO C++ 1.00 |
0x0296 |
|
|
TURBO C++ 1.01 |
0x0297 |
|
|
BORLAND C++ 2.00 |
0x0410 |
|
|
BORLAND C++ 3.1 |
Infine, riprendiamo uno dei precedenti esempi applicandovi la tecnica di compilazione condizionale
void Jolly(void)
#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 le pagg. e
Tant'è che ogni microprocessore ha il proprio linguaggio assembler. I personal computer generalmente indicati come 'IBM o compatibili' sono basati sulla famiglia di processori Intel 80x86: 8086, 80286, 80386, 80386SX, 80486 ed altri ancora. Esiste un vasto insieme di istruzioni comuni a tutti questi microprocessori: di qui la possibilità concreta di scrivere programmi in grado di girare su macchine di tipo differente. Il programmatore che intenda sfruttare le prestazioni più avanzate offerte da ciascuno di essi (in particolare dal tipo 80286 in poi) deve però rinunciare alla compatibilità del proprio programma con i processori che non dispongono di tali caratteristiche.
Ad esempio le più recenti versioni dei compilatori Microsoft e Borland. Il contenuto del presente capitolo fa riferimento specifico alle possibilità offerte dal secondo.
Per la verità, le istruzioni inline assembly vengono modificate laddove contengano riferimenti a simboli C (ad esempio nomi di variabili, come vedremo). Nella maggior parte dei casi ciò rappresenta un vantaggio, perché consente di referenziare le variabili definite direttamente in C, come nell'istruzione
asm mov dl,p_escl;
ma in alcuni casi è fonte di grattacapi non da poco. Esempietto chiarificatore, tra i diversi possibili: se una istruzione assembly contiene l'operatore DUP, che serve a inizializzare più byte ad un dato valore, e prima di tale istruzione è incluso il file IO.H, contenente il prototipo della funzione C dup(), che duplica lo handle di un file, il compilatore la scambia (orrore!) per una chiamata a detta funzione C, confondendo le idee all'assemblatore. La soluzione è una sola: includere il file IO.H dopo tutte le righe assembly che fanno uso di DUP. Nel vostro programma non è possibile? Peggio per voi: non vi rimane che copiare il file IO.H e modificare la copia eliminando la dichiarazione del prototipo di dup(); è ovvio che nel sorgente C deve essere inclusa la copia così modificata.
In alternativa, la direttiva asm può introdurre un blocco di istruzioni racchiuso, come di consueto, tra parentesi graffe:
asm
Il termine va inteso in senso lato: in questo caso con 'istruzione' si indicano anche le chiamate a funzione. Le funzioni, a loro volta, possono essere considerate sequenze di istruzioni delle quali la routine chiamante conosce esclusivamente le modalità di interfacciamento al programma (in altre parole la struttura dell'input e dell'output).
Essi sono cioè in grado, su richiesta, di compilare il codice sorgente in modo che il codice oggetto prodotto sia il più compatto possibile, oppure il più veloce possibile, e così via.
Compilato con l'opzione ‑S sulla riga di comando. Il codice riportato è solo una parte di quello prodotto dal compilatore: sono state eliminate tutte le parti relative alla segmentazione.
Non bisogna dimenticare che il C garantisce che i parametri siano passati alle funzioni per valore: in altre parole alla funzione è passata una copia di ogni parametro, ottenuta copiando il valore di questo nello stack, dal quale la funzione lo preleva. Ciò implica che una funzione non può modificare il valore delle variabili che le sono date quali parametri attuali. Vedere pag. e seguenti.
Se la chiamata è near, cioè se il codice della funzione è compreso nello stesso segmento in cui appare la CALL, viene salvato sullo stack solamente un offset (il valore che IP deve assumere perché sia eseguita la prima istruzione che segue la CALL nella routine chiamante) e quindi SP è decrementato di 2. Se, al contrario, la chiamata è di tipo far, vengono salvati sullo stack un segmento ed un offset, e pertanto SP è decrementato di 4.
Per essere più precisi: rispetto al valore che SP ha al momento dell'ingresso nella funzione, cioè dopo la CALL. Per questo entra in gioco il registro BP
In termini di word. Esempi: un int richiede una word; un long ne richiede due; tre char ne richiedono tre; un array di nove char ne richiede cinque; due array di nove char ne richiedono cinque ciascuno. E' ovvio che in assenza di variabili locali non vi sono istruzioni SUB SP, o DEC SP in testa, né la MOV SP,BP in coda alla funzione.
Tanto per fare un esempio: il compilatore Borland TURBO C 2.0 non mantiene la standard stack frame, e quindi genera le istruzioni per la gestione dello stack solo nelle funzioni in cui sono indispensabili. Il compilatore Borland C++, dalla versione 1.0 in poi, genera per default, in qualsiasi funzione, le istruzioni necessarie alla standard stack frame, se sulla command line non è specificata l'opzione ‑k‑. Si tornerà sull'argomento a pag. , con riferimento alla gestione dei dati nel Code Segment.
La possibilità di dichiarare interrupt una funzione è offerta da molti compilatori C in commercio, soprattutto nelle loro versioni recenti.
Per la verità, le funzioni interrupt ritornano mediante l'istruzione IRET. La RET può essere utilizzata solo nella forma RET 2, per eliminare la copia dei flag spinta sullo stack dalla chiamata ad interrupt.
D'altra parte la 'A' di AX sta per 'Accumulatore'. Esso è anche usato in modo implicito da alcune istruzioni, quali LODSB LODSW STOSB STOSW
Ad esempio, BX (Base) può essere usato nel calcolo degli offset per referenziare i membri delle strutture; CX (Counter) agisce come contatore con l'istruzione LOOP e con quelle precedute da REP DX (Data) è destinato a generiche funzionalità di archivio dati.
Source Index e Destination Index. Utilizzati, tra l'altro, come indici ad incremento o decremento automatico dalle istruzioni MOVSB MOVSW, etc..
Coloro che sono privi di immaginazione sappiano che si tratta di problemi legati alla gestione dello stack, del tutto analoghi a quelli discussi con riferimento alla funzione prova(). Nella copia() non compaiono le istruzioni DEC SP e MOV SP,BP in quanto essa non fa uso di variabili locali.
Si ricordi che lo stack è sempre movimentato con un algoritmo del tipo LIFO (Last In First Out): l'ultima word entrata con una PUSH è la prima ad esserne estratta con una POP
A dire il vero, i byte potrebbero essere quattro se il codice venisse compilato in modalità 80386 a 32 bit, ma ciò sarebbe, in questo caso, ininfluente.
L'istruzione CBW (Convert Byte to Word) funziona solo con AX: la parte alta degli altri registri deve essere azzerata esplicitamente (ad es.: XOR BH,BH o MOV BH,0
Eccetto il caso in cui il codice sia in grado di sfruttare le caratteristiche dell'assembler 80386: in tal caso sono disponibili i registri estesi (EAX EBX, etc.) a 32 bit.
Per la precisione, se ne può usare uno solo:
asm mov ax,ds:[si];
asm mov var32bits,ax;
asm mov ax,ds:[si+2];
asm mov var32bits+2,ax;
Il compilatore ha generato una sequenza valida per ogni bit dei flag (non esiste una specifica istruzione assembler per ciascuno di essi).
E' un cold bootstrap se prima di invocare la boot() il programma non scrive il numero 1234h all'indirizzo RAM
Infatti esso punta al code segment del gestore di interrupt; in altre parole esso è la parte segmento del vettore, cioè dell'indirizzo, della routine.
Meglio non fidarsi dell'opportunità, offerta da alcuni compilatori, di definire nel sorgente variabili indirizzate relativamente a CS: a differenza del metodo descritto in queste pagine, l'opzione citata non consente di controllare dove, all'interno del code segment, tali variabili saranno allocate.
In realtà le funzioni potrebbero essere più di una, e ciascuna potrebbe contenere uno o più dati. Il programmatore è naturalmente libero di scegliere l'approccio che gli è più congeniale.
Come si è anticipato, quello descritto è un trucco per memorizzare dati in locazioni relative a CS e non a DS, contrariamente al default del compilatore.
Appunti su: considerazioni su linguaggio assembler relazione, |
|