Come già accennato, in questa sede non si vogliono considerare tutti gli aspetti del linguaggio assembly per GNU/Linux, ma esaminare le tecniche di base e le istruzioni fondamentali per poter gestire cicli, vettori, I/O, procedure e stack; questo verrà fatto usando prevalentemente esempi di programmi funzionanti che saranno opportunamente commentati.
Negli esempi vengono usate le istruzioni più comuni del linguaggio, un elenco delle quali è disponibile nell'appendice A. |
Elenchiamo alcune caratteristiche fondamentali della sintassi e delle regole d'uso delle istruzioni dell'assembly con sintassi AT&T:
per i nomi delle etichette dati o istruzioni, delle macro e delle procedure si possono usare lettere e cifre e qualche carattere speciale come «_», senza limiti di lunghezza; si ricordi però che non possono iniziare con una cifra e che il linguaggio è case sensitive e quindi c'è differenza tra maiuscole e minuscole;
i commenti si possono inserire in tre modi diversi:
carattere «#»;
caratteri «//» (solo da inizio riga);
caratteri «/*» (inizio commento) e «*/» (fine commento); in questo modo si possono avere anche commenti su più righe.
i valori numerici si possono rappresentare in esadecimale, prefissandoli con «0x», in binario, con il prefisso «0b» e in decimale, scrivendo i valori senza alcun prefisso;
i valori costanti devono essere preceduti dal simbolo «$»;
i nomi dei registri devono essere preceduti dal simbolo «%»;
le istruzioni che coinvolgono registri e locazioni di memoria prevedono un suffisso che può essere:
b se l'istruzione è riferita a byte;
w se l'istruzione è riferita a word (due byte);
l se l'istruzione è riferita a doppia word (l deriva da «long»);
in verità l'assemblatore GAS permette l'omissione del suffisso deducendolo in base alla natura degli operandi; il sio uso è comunque consigliabile per aumentare la leggibilità dei sorgenti;
il simbolo «$» può precedere anche un nome di etichetta (ad esempio dato1); in tal caso serve a riferirsi al suo indirizzo in memoria ($dato1); in pratica c'è la stessa differenza che si ha in linguaggio c fra l'uso della varialbile dato1 e l'applicazione ad essa dell'operatore «&» (&dato1);
la maggior parte delle istruzioni dell'assembly AT&T prevede due operandi secondo questo modello:
|
è di fondamentale importanza osservare che nelle istruzioni gli operandi non possono essere entrambi riferimenti alla memoria (le etichette dati di cui si parla nel paragrafo 3.3).
Un programma assembly è suddiviso in parti, chiamate segmenti, in modo da rispecchiare l'architettura dei processori Intel x86 (riassunta nel paragrafo 1.1).
I segmenti fondamentali sono quattro:
code segment, contenente le istruzioni del programma;
data segment, contenente i dati;
stack segment, contenente lo stack (vedere il paragrafo 3.14);
extra segment; contenente eventuali dati aggiuntivi (usato molto raramente).
Come in tutti i linguaggi assembly anche in quello con sintassi AT&T esistono tre tipi diversi di istruzioni in un sorgente:
direttive: non si tratta di istruzioni del linguaggio assembly ma di istruzioni rivolte all'assemblatore per fornirgli informazioni utili alla traduzione del programma (ad esempio .data e .text usate per indicare l'inizio del segmento dati e del segmento codice rispettivamente;
dichiarative: sono le istruzioni che servono a dichiarare le etichette;
esecutive: sono le vere e proprie istruzioni corrispondenti alle azioni che l'esecutore dovrà svolgere.
Le istruzioni direttive e dichiarative (che nel nostro caso sono ancora delle direttive usate per dichiarare le etichette dati) non vengono tradotte in linguaggio macchina, cosa che invece avviene per le istruzioni esecutive.
Fra queste ultime e le istruzioni macchina, a parte qualche eccezione, c'è una relazione «uno a uno».
Fra le etichette definite con le istruzioni dichiarative si possono distinguere:
etichette dati: inserite nel segmento dati, usate per definire dati in memoria; possono essere considerate equivalenti alle variabili presenti nei linguaggi di programmazione ad alto livello (c, pascal ecc.);
etichette istruzioni: inserite nel segmento istruzioni, per poter effettuare i salti all'interno del programma (vedere il paragrafo 3.13).
Vediamo un primo esempio di programma in assembly allo scopo di illustrare la struttura generale di un sorgente; la numerazione delle righe non è presente in assembly ed è aggiunta qui al solo scopo di facilitare la descrizione del listato:(1)
|
Le prime sei righe sono di commento e quindi non essenziali; è comunque consigliabile inserire sempre all'inizio righe come queste per fornire informazioni utili sul programma.
Alla riga 7 abbiamo la direttiva che denota l'inizio del segmento dati, che in questo esempio è vuoto; quindi subito dopo inizia il segmento codice in cui la prima istruzione è una direttiva (riga 9) che dichiara globale l'etichetta _start.
A seguire, alla riga 10 è inserita tale etichetta con lo scopo di denotare l'inizio della parte esecutiva del programma; il suo uso, preceduto dalla relativa dichiarazione, è obbligatorio in tutti i programmi a meno che non si usi gcc per la traduzione e la produzione del programma eseguibile, nel qual caso deve essere usata (e dichiarata con .globl) l'etichetta main.
Questo programma non svolge alcuna elaborazione significativa e infatti contiene solo l'istruzione nop che, appunto, non fa niente.
Le ultime due istruzioni invece sono importanti e servono ad attivare l'interruzione software identificata con il valore 8016 con la quale, in generale, si richiamano routine o servizi del sistema operativo (nel nostro caso Linux).
Il servizio richiamato è identificato dal valore che viene inserito nel registro eax prima di attivare l'interruzione; il valore 1 corrisponde alla routine di uscita dal programma e ritorno al sistema operativo.
Siccome ogni programma deve in qualche modo terminare e restituire il controllo al sistema operativo, queste due istruzioni saranno presenti in qualsiasi sorgente assembly.
L'etichetta istruzioni fine presente a riga 12 è inserita solo allo scopo di evidenziare ancora meglio queste operazioni finali e non è usata per effettuare alcun tipo di salto.
Il primo programma che esaminiamo svolge alcune somme e sottrazioni usando alcuni valori immediati (costanti) e i registri accumulatori opportunamente caricati con dei valori numerici.(2)
|
Le prime istruzioni, da riga 15 a riga 18, inseriscono dei valori nei registri accumulatori; si noti l'accordo fra il suffisso dell'istruzione mov e l'ampiezza del registro usato.
Alla riga 19 vediamo una sottrazione tra il registro bx e se stesso; è un modo per azzerare il contenuto del registro.
Stesso effetto si ottiene usando l'operatore di «or esclusivo» come mostrato a riga 25.
I valori costanti possono essere indicati anche in formato esadecimale (righe 20 e 24).
Alle righe 22 e 27 vediamo due operazioni di somma; a tale proposito si deve ricordare che in queste operazioni, come in tutte quelle simili con due operandi, il risultato viene immagazzinato nel secondo operando, il cui valore precedente viene quindi perso.
Il debugger ddd è in pratica un'interfaccia grafica che facilita l'uso del debugger standard di GNU/Linux, cioè gdb.
Per usarlo occorre naturalmente avere installato l'omonimo pacchetto disponibile per tutte le distribuzioni di Linux.
Vediamo le basi del suo utilizzo con alcune immagini tratte dall'esecuzione del programma illustrato nel paragrafo precedente.
Per attivare l'esecuzione con ddd occorre eseguire il comando:
$
ddd nome_eseguibile
Il programma si attiva mostrando la finestra visibile nella figura 3.4.
Bisogna aprire subito anche la finestra che mostra i valori dei registri attivando la voce «Registers» del menu «Status» e poi inserire un breakpoint all'inizio del programma (sulla seconda riga eseguibile, perché sulla prima talvolta non funziona).
Per fare questo occorre prima posizionarsi sulla riga desiderata e poi premere il pulsante «Break», riconoscibile grazie all'icona con il cartello di stop, presente nella barra degli strumenti.
Ci si trova così nella situazione mostrata nella figura 3.5.
A questo punto si può cliccare sul pulsante «Run» nella finestrina «DDD» per provocare l'esecuzione fino alla riga con il breakpoint e successivamente sul pulsante «Step» per avanzare una istruzione alla volta; la freccia verde che appare sulla sinistra del listato indica la prossima istruzione che verrà eseguita all pressione del pulsante «Step».
Nella finestra dei valori dei registri si può seguire il variare del loro contenuto, espresso in esadecimale e in decimale, man mano che le istruzioni vengono eseguite.
Nella figura 3.6 si può vedere la situazione nei registri dopo le prime quattro istruzioni di caricamento (righe dalla 15 alla 18).
In particolare è interessante considerare il valore che appare nella parte bassa del registro ecx: 255 (0xff in esadecimale) e non -1.
Sappiamo però, per le regole di rappresentazione dei valori interi in complemento a due (in questo caso su 8 bit in quanto abbiamo usato il registro cl), che il valore -1 si rappresenta proprio come 0xff.
Possiamo quindi notare come uno stesso insieme di bit memorizzato in un registro possa rappresentare sia un valore positivo che uno negativo (255 o -1 nel nostro esempio); in base al contesto del programma e all'uso che di quel valore fa il programmatore, viene «deciso» quale delle due alternative è quella da prendere in considerazione. |
La successiva figura 3.7 mostra i valori nei registri dopo le istruzioni delle righe,19, 20 e 21.
Possiamo vedere come l'aver posto una unità in bh fa assumere al registro bx il valore 256; se ragioniamo in decimale la cosa appare alquanto strana, molto meno se consideriamo la corrispondente rappresentazione esadecimale 0x100 in cui appare più evidente che gli 8 bit più bassi (corrispondenti a bl) sono tutti a zero mentre gli altri 8 bit contengono il valore 1.
Altrettanto interessante appare il valore presente in cx che è negativo nella colonna dei valori decimali; come già osservato poco sopra dipende dal fatto che i bit corrispondenti all'esadecimale 0xd09dc300 rappresentano sia il valore 3,500,000,000 che -794,967,296.
Proseguendo nell'esecuzione si arriva alla somma di riga 22; nella figura 3.8 ci concentriamo sul registro dx che dovrebbe contenere il risultato dell'operazione e invece contiene tutt'altro valore.
Anche in questo caso ci aiuta ricordare le regole di rappresentazione dei valori interi: abbiamo sommato 3,5 miliardi a 1 miliardo, ma il risultato supera il massimo valore rappresentabile con 16 bit (4,294,967,295); quello cha appare in dx è proprio il valore che è «traboccato» oltre il massimo possibile.
Conferma del traboccamento si ha anche esaminando il registro eflags che mostra l'attivazione del flag di carry o trabocco (CF) insieme a quello di parità (PF).
Ricordiamo che il settaggio dei bit del registro dei flag avviene solo dopo istruzioni aritmetico logiche che coinvolgono la ALU; quindi non si ha alcuna alterazione di tali bit dopo le istruzioni mov. Si può verificare questo provando ad azzerare un registro muovendoci il valore zero; in tal caso non si accende il bit di zero (ZF) che invece si attiva effettuando la sub o la xor di un registro su se stesso. |
Concludiamo con la figura 3.9 in cui vediamo l'attivazione del bit di segno (SF) dopo la somma di una unità al registro al che conteneva 254 (riga 27 del programma); anche questo comportamento pare anomalo ma è giustificato dal fatto che 255 può essere anche interpretato come -1.
Nella stessa figura vediamo anche come il valore -1 su 16 bit (nel registro cx) corrisponda a 0xffffffff e cioè «anche» al valore 65,535.
Il successivo esempio, in cui si usano altre istruzioni molto banali come xchg, inc, dec, ha un certo interesse in quanto mostra delle situazioni in cui si ottiene l'impostazione del bit di overflow.(3)
|
I dati vengono posti nei registri al e bl che poi vengono scambiati alla riga 18; viene poi fatta la somma che fornisce un risultato «normale» senza causare l'impostazione di alcun bit di stato in particolare.
Quando, alla riga 20, viene incrementato di una unità il registro al, si ottiene il risultato atteso (128) ma con la contemporanea accensione (tra gli altri) del bit di segno e del bit di overflow; ciò è del tutto normale se si ricorda che con otto bit il massimo intero rappresentabile è 127 e che la rappresentazione è «modulare» (gli interi sono immaginati disposti su una circonferenza e dopo il più alto positivo si trovano i valori negativi).
Con l'istruzione successiva, che riporta il valore del registro a 127, viene impostato il bit di overflow ma non quello di segno; si ha infatti un prestito dal bit in ottava posizione in una differenza tra valori considerabili con segno mentre il risultato ritorna ad essere positivo.
Stavolta non vengono mostrate le immagini che illustrano questi passaggi fatti con ddd; il lettore può facilmente provvedere autonomamente operando come mostrato nei casi precedenti.
Nel successivo esempio prendiamo in esame alcune operazioni logiche.(4)
|
Alla riga 12 si effettua la negazione (complemento a due) del valore contenuto in bl (127); il risultato che si ottiene è 129.
Di questo valore, alla riga successiva, viene fatta la not, cioè il complemento a uno, che fornisce come risultato 126.
Anche in questo caso tralasciamo i dettagli dell'esecuzione con ddd.
Per accertarci della correttezza dei risultati consideriamo la rappresentazione in binario del valore 127 che è 011111112: facendo il complemento a due si ottiene 100000012, che corrisponde a 129; poi si passa al complemento a uno di quest'ultimo ottenendo 011111102, cioè 126.
Il programma prosegue proponendo, alle righe 15 e 17, le operazioni logiche di and e or fra i valori 35 e 15, con quest'ultimo memorizzato in al; i risultati sono 3 e 47 rispettivamente.
Lasciamo al lettore la verifica di tali risultati partendo dalle rappresentazioni binarie di 15 (000011112) e 35 (001000112) tramite le facili operazioni di and e or bit a bit.
Le operazioni di moltiplicazione e divisione (da intendere come divisione tra interi che fornisce un quoziente e un resto) prevedono un solo operando.
Tale operando rappresenta il moltiplicatore nella moltiplicazione e il divisore nella divisione; il moltiplicando e il dividendo devono essere precaricati in appositi registri prima di svolgere l'operazione.
Si tenga anche presente che l'operando delle moltiplicazioni e divisioni non può essere un valore immediato. |
Più in dettaglio il funzionamento della istruzione mul è il seguente:
mulb: operando a 8 bit, l'altro valore deve essere in al e il risultato viene posto in ax;
mulw: operando a 16 bit, l'altro valore deve essere in ax e il risultato viene posto in dx:ax;
mull: operando a 32 bit, l'altro valore deve essere in eax e il risultato viene posto in edx:eax.
A seguito di una moltiplicazione viene impostato il flag cf per segnalare se è usato il registro contenente la parte alta del risultato (dx o edx).
Per quanto riguarda l'istruzione div abbiamo invece:
divb: operando (divisore) a 8 bit, il dividendo a 16 bit deve essere in ax; il quoziente viene posto in al e il resto in ah;
divw: operando (divisore) a 16 bit, il dividendo a 32 bit deve essere in dx:ax; il quoziente viene posto in ax e il resto in dx;
divl: operando (divisore) a 32 bit, il dividendo a 64 bit deve essere in edx:eax; il quoziente viene posto in eax e il resto in edx;
A seguito di una divisione si può verificare una condizione di overflow che, al momento dell'esecuzione, causa una segnalazione di errore del tipo: «Floating point exception» («Eccezione in virgola mobile»); questo avviene se il quoziente è troppo grande per essere contenuto nel registro previsto come nella seguente porzione di codice:
|
Nel listato seguente vengono effettuate alcune operazioni di moltiplicazione e divisione. (5)
|
Nel listato sono presenti dei commenti che spiegano le operazioni svolte.
La prima moltiplicazione (righe da 14 a 16) non necessita di grosse osservazioni in quanto agisce su operandi residenti su singoli registri e fornisce il risultato atteso nel registro ax sufficiente ad accogliere il valore 50,000.
Più interessante è il risultato della prima divisione (righe da 20 a 22) che appare (come al solito) incomprensibile se visto in decimale, più chiaro in esadecimale: vediamo infatti nella figura 3.14 come in al sia presente il quoziente 0xfa (250) e in ah il resto 3.
Nella successiva divisione fatta con divw (righe da 26 a 29) il dividendo viene posto in parte nel registro dx e in parte nel registro ax in quanto è troppo grande per essere contenuto solo in quest'ultimo; dopo la divisione troviamo il quoziente in ax e il resto in dx come previsto (vedere figura 3.15).
Proseguendo vediamo la moltiplicazione del valore 1,000,000,000 caricato in ebx con eax che contiene 15 (righe da 33 a 35): il risultato non può essere contenuto nei 32 bit del registro eax (il massimo valore possibile con 32 bit senza segno è circa quattro miliardi), quindi i suoi bit più significativi sono memorizzati in edx e viene settato il flag cf.
Osservando i valori contenuti in edx e eax dopo l'operazione (vedere figura 3.16) sembra che il risultato sia privo di senso, ma se consideriamo il valore espresso nella forma edx:eax e traduciamo le cifre esadecimali contenute nei due registri, e cioè 37e11d60016, in binario otteniamo 11011111100001000111010110000000002 corrispondente al valore decimale 15,000,000,000, che è il risultato corretto.
Ne abbiamo conferma anche dopo l'esecuzione della divisione con ecx, caricato nel frattempo con il valore 10 (righe 39 e 40), che fornisce come risultato 1,500,000,000 (vedere figura 3.17).
Iniziamo con questo paragrafo a considerare l'uso dei dati in memoria, basato sulla definizione delle etichette dati all'interno del segmento .data.
Le etichette dati possono essere assimilate, per il loro ruolo, alle variabili che si usano nei linguaggi di programmazione ad alto livello: sono caratterizzate da un nome, da un tipo e da un contenuto.
Nell'esempio seguente vediamo la definizione di due etichette dati con nome dato1 e dato2, entrambe di tipo .long (cioè intere a 32 bit) e contenenti rispettivamente i valori iniziali 100,000 e 200,000.
|
Nell'appendice A.0 è disponibile l'elenco delle direttive utili alla definizione dei vari tipi di dati possibili (interi più corti, reali, stringhe ecc.). |
Nel caso si vogliano definire delle etichette dati con valore iniziale zero (come in alcuni dei prossimi esempi) si può anche usare il segmento «speciale», .bss dedicato proprio ai dati non inizializzati.
L'etichetta dati può avere un nome a piacere purché non coincidente con una parola riservata del linguaggio e possibilmente «significativo»; al momento della definizione l'etichetta deve essere seguita da «:». |
Per usare il valore contenuto in una etichetta dati si deve racchiuderne il nome fra parantesi tonde; se invece si vuole fare riferimento all'indirizzo di memoria dell'etichetta si deve anteporre il simbolo «$» al nome.
Nell'esempio seguente viene mostrata la sintassi da usare in questi due casi con accanto l'equivalente operazione in linguaggio c.
|
Si ricordi che, nel caso di istruzioni assembly con due operandi, questi non possono essere entrambi etchette dati. |
Questa limitazione dipende dall'esigenza di non avere istruzioni esecutive che debbano fare più di due accessi complessivi alla memoria.
Consideriamo le seguenti due istruzioni assembly in cui supponiamo che le due etichette siano definite come .byte:
|
La prima è corretta in quanto l'esecutore deve compiere solo due accessi alla memoria: uno per leggere il contenuto di var1 e uno per scriverci il risultato dopo aver sommato il valore precedente con quanto presente nel registro bl.
La seconda invece è errata perché imporrebbe tre accessi alla memoria: due per leggere i valori contenuti nelle celle corrispondenti alle due etichette e uno per scrivere il risultato in var2.
L'errore viene segnalato dall'assemblatore in sede di traduzione con un messaggio come questo: «Error: too many memory references for 'add'».
Vediamo adesso come usare ddd per leggere il contenuto dei dati in memoria.
A questo scopo riprendiamo il programma già esaminato nel paragrafo 3.8 a cui apportiamo una piccola modifica: il valore 127 non viene caricato nel registro bl ma nella posizione di memoria identificata dall'etichetta dati var1; naturalmente anche le successive operazioni vengono effettuate su tale etichetta e non più sul registro (6)
|
Le operazioni da svolgere sono le seguenti:
eseguire:
$
ddd nome_eseguibile
posizionare il breakpoint sulla seconda riga eseguibile;
lanciare il «Run» dalla finestrina «DDD» come al solito;
selezionare la voce «Displays» dal menu «Data», ottenendo quanto mostrato nella figura 3.22;
cliccare sul pulsantino «Display» della finestra appena aperta in modo da ottenere una ulteriore finestra di dialogo in cui si deve scrivere il nome dell'etichetta dati da ispezionare, confermando poi con [Invio] o con il pulsante «Display» subito sotto (vedere la figura 3.23);
si apre in questo modo la sezione di visualizzazione dei dati contenente la variabile scelta come mostrato nella figura 3.24; naturalmente, in caso di bisogno, si possono aggiungere altre etichette da ispezionare seguendo lo stesso procedimento;
Lo stesso risultato si ottiene, in modo più rapido e agevole, facendo un [doppio click] sul nome della variabile che si vuole tenere sotto controllo nel listato mostrato da ddd. |
A questo punto continuando l'esecuzione con il pulsante «Step» si può vedere come cambia il valore della variabile prescelta in base alle operazioni svolte su di essa.
Non entriamo nei dettagli dei calcoli svolti dal programma in quanto li abbiamo già esaminati nel paragrafo 3.8.
Vediamo invece ulteriori potenzialità del programma ddd: attiviamo la finestra «Data Machine Code» in modo da avere visione degli indirizzi esadecimali corrispondenti alle etichette dati (figura 3.25).
Se si vuole ispezionare il contenuto della memoria a partire, ad esempio, dall'indirizzo assegnato all'etichetta var1, che risulta essere 804909016 occorre operare come segue:
attivare il breakpoint e lanciare il programma come di consueto con il pulsante «Run»;
selezionare la voce «Memory» dal menu «Data», ottenendo quanto mostrato nella figura 3.26;
inserire la quantità di posizioni di memoria da visualizzare, la modalità (ad esempio in decimale) e l'indirizzo di partenza (in esadecimale con il prefisso «0x»), quindi premere il bottone «Display»;
in questo modo si apre la finestra dei dati (per chiuderla si agisce sul menu «View») in cui viene visualizzato il contenuto delle celle di memoria selezionate, come mostrato nella figura 3.27;
Naturalmente si possono aprire in modo analogo altre sezioni di visualizzazione di altre zone di memoria, o della stessa in modalità diverse; inoltre si ricordi che il contenuto delle etichette si può visionare anche con il metodo illustrato precedentemente.
Continuando poi l'esecuzione con il pulsante «Step» si può vedere come cambiano i valori delle posizioni di memoria prescelte; ad esempio nella figura 3.28 vediamo la situazione dopo l'esecuzione dell'istruzione negb sia in decimale che in esadecimale.
In questo paragrafo esaminiamo le operazioni di somma con riporto e sottrazione con prestito, usando dati definiti in memoria.(7)
|
Anche stavolta il segmento dati non è vuoto ma contiene alle righe 8 e 9 le definizioni delle etichette di memoria con i valori su cui operare,
Nelle righe da 14 a 16 si effettua la somma tra i due dati; è necessaria l'operazione adcb dopo la somma perché il risultato (260) esce dall'intervallo dei valori senza segno rappresentabili con otto bit e quindi si deve sommare il riporto nei bit del registro immediatamente a sinistra e cioè in ah.
La figura 3.22 mostra il risultato, in apparenza errato, e la situazione dei bit di stato dopo la somma presente a riga 15.
Nella figura 3.31 invece il risultato appare corretto dopo la somma con riporto fatta a riga 16.
A riga 17 vediamo l'azzeramento di un registro grazie ad una operazione di xor su se stesso; altre possibilità per ottenere lo stesso risultato, anche se in maniera meno «elegante», possono essere: subw %ax, %ax oppure movw $0, %ax.
Con le righe da 18 a 20 viene sottratto dato1 da dato2; qui è necessario tenere conto del prestito sui bit immediatamente a sinistra, e cioè in ah, con l'operazione sbbb.
Anche in questo caso vediamo, nella figura 3.32, la situazione dopo la sottrazione con il risultato apparentemente errato e poi, nella figura 3.33, il risultato corretto, con il bit di segno attivato.
In realtà il valore che appare nel registro eax pare ben diverso da quello che ci saremmo aspettati (-140) ma, ricordando le modalità di rappresentazione dei valori interi nell'elaboratore, abbiamo:
ff7416 = 11111111011101002 = complemento a due di 00000000100011002 che vale 140 e quindi il valore rappresentato è -140.
Per convincerci ancora di più della correttezza del risultato osserviamo, nella figura 3.34, l'effetto dell'operazione di negazione (o di complemento a due) fatta sul registro ax alla riga 21.
I salti servono ad alterare la normale sequenza di esecuzione delle istruzioni all'interno di un programma; il salto avviene sempre con «destinazione» un'etichetta istruzioni che deve essere definita (una volta sola) nel programma in qualsiasi posizione, indifferentemente prima o dopo l'istruzione di salto.
L'etichetta istruzioni può avere un nome a piacere purché non coincidente con una parola riservata del linguaggio e possibilmente «significativo»; al momento della definizione l'etichetta deve essere seguita da «:». |
Come mostrato più avanti, nei programmi assembly i salti si usano per poter costruire le strutture di programmazione di selezione iterazione per le quali il linguaggio non mette a disposizione istruzioni apposite.
Il salto incondizionato, realizzabile con l'istruzione jmp, viene effettuato in ogni caso, a prescindere da qualsiasi condizione si sia venuta a creare a causa delle operazioni precedenti; i salti condizionati, dei quali esistono varie versioni elencate nell'appendice A, sono eseguiti solo se si è verificata la condizione richiesta dal tipo si salto.
Nell'esempio seguente viene mostrato un programma che non compie alcuna elaborazione significativa ma che contiene un'istruzione di salto incondizionato, al fine di mostrarne il comportamento con l'uso del debugger.(8)
|
Come detto il programma non serve ad alcun scopo elaborativo; dopo un paio di operazioni sul valore var1 c'è un salto incondizionato all'etichetta fine: e quindi le due istruzioni nop alle righe 16 e 17 non vengono eseguite (non che cambi molto, visto che sono istruzioni «vuote»).
Vale però la pena osservare l'esecuzione con il debugger ddd inserendo il breakpoint come mostrato in precedenza e attivando, oltre alla finestra dei registri anche quella del «Data Machine Code» dal menu «View».
In questa finestra è disponibile il listato in una forma più vicina a quella del linguaggio macchina, dove tutti i valori sono espressi in esadecimale e i riferimenti alla memoria sono indicati tramite gli indirizzi e non con le etichette. |
Nella figura 3.36, vediamo, proprio nella finestra del codice macchina, gli indirizzi in cui sono memorizzate le istruzioni del nostro programma; dal fatto che la «distanza» in memoria fra di esse è variabile riceviamo conferma che le istruzioni non sono tutte della stessa lunghezza.
Questo è un fatto normale per i processori di tipo CISC (Complex Instruction Set Computing) come gli Intel e compatibili; nel nostro esempio notiamo che l'istruzione nop è lunga un byte, la jmp due byte, la movl cinque byte, la negb e la notb sei byte.
Nelle successive due figure 3.37 e 3.38 si può notare il valore del registro contatore di programma eip subito prima e subito dopo l'esecuzione dell'istruzione di salto incondizionato; da come cambiano i valori di tale registro si può capire che il modo con cui la macchina esegue un'istruzione di salto è molto banale: viene «semplicemente» inserito nel contatore di programma l'indirizzo dell'istruzione a cui saltare invece che quello della prossima istruzione nella sequenza del programma (nel nostro esempio sarebbe la nop a riga 16).
Il salto incondizionato può a tutti gli effetti essere assimilato alla «famigerata» istruzione goto presente in molti linguaggi di programmazione ad alto livello, specialmente fra quelli di vecchia concezione.
L'uso di tale istruzione, anche nei linguaggi che la prevedono, è solitamente sconsigliato a vantaggio delle tecniche di programmazione strutturata.
L'approfondimento di questi concetti esula dagli scopi di queste dispense; ricordiamo solo che secondo i dettami della programmazione strutturata un algoritmo (e quindi il programma che da esso deriva) deve contenere solo tre tipi di strutture di controllo:
sequenza: sempre presente in quanto qualsiasi algoritmo è per sua natura «una sequenza di operazioni da svolgere su dei dati di partenza per ottenere dei risultati»;
selezione (a una via o a due vie): permette di eseguire istruzioni diverse al verificarsi o meno di una certa condizione; nei linguaggi ad alto livello si realizza di solito con il costrutto if;
iterazione con controllo in testa o con controllo in coda: permette di realizzare i cicli grazie ai quali si ripete l'esecuzione di un certo insieme di istruzioni; nei linguaggi ad alto livello ci sono vari tipi di costrutti utili a questo scopo, come la for, il while, il do ... while e altri ancora a seconda del linguaggio considerato.
Per quanto riguarda la programmazione in assembly non ci sarebbe alcun impedimento tecnico riguardo l'uso del salto incondizionato come se fosse un goto; dal punto di vista concettuale è però sicuramente opportuno, e anche molto istruttivo, cercare di rispettare i dettami della programmazione strutturata anche in questo ambito.
La strategia migliore è quindi quella di stendere sempre in anticipo un algoritmo strutturato relativo alla soluzione del problema in esame e poi convertirlo in un sorgente assembly.
Questo procedimento ha senso a patto che il problema non sia davvero banale o risolvibile con una semplice sequenza di istruzioni (come nel caso degli esempi del paragrafo precedente) e implica l'uso di tecniche come i diagrammi di flusso o la pseudo-codifica.
In queste dispense la fase preliminare verrà quasi del tutto trascurata anche perché la capacità di risolvere problemi per via algoritmica è da considerare un prerequisito per avvicinarsi alla programmazione a basso livello.
Purtroppo scrivere programmi strutturati in assembly non è semplice perché il linguaggio non mette a disposizione alcuna istruzione assimilabile ad una selezione o ad una iterazione (con l'eccezione dell'istruzione loop, che vedremo tra breve). Si devono quindi realizzare tali costrutti «combinando» opportunamente l'uso di salti condizionati e incondizionati. |
Consideriamo la struttura di selezione nelle sue due varianti: «a una via» e «a due vie».
Esse si possono rappresentare nei diagrammi di flusso come mostrato nella figura 3.39.
|
Per la loro realizzazione in linguaggio assembly occorre servirsi dei salti condizionati e incondizionati; nei prossimi listati, in cui si fa uso di istruzioni espresse in modo sommario, usando la lingua italiana, viene mostrata la logica da seguire.
Per la selezione a una via:
|
Per la selezione a due vie:
|
Come esempio di uso di selezione a una via consideriamo la divisione tra i due numeri num1 e num2 da effettuare solo se il secondo è diverso da zero.
Siccome non abbiamo ancora le nozioni indispensabili per gestire le fasi di input e output dei dati in assembly, il programma non presenta alcun risultato a video e quindi, per sincerarsi del suo corretto funzionamento, occorre eseguirlo con il debugger; questo ovviamente vale anche per i prossimi esempi fino al momento in cui illustreremo le modalità di visualizzazione dei dati. |
Non essendo (al momento) possibile fornire dati al programma durante l'esecuzione e riceverne i risultati, i due valori di input vengono inseriti fissi nel sorgente e il risultato viene depositato nel registro cl.(9)
|
Il programma è molto banale: alla riga 14 effettua il confronto tra il secondo numero e il registro bl dove in precedenza ha caricato zero; alla riga 15 si ha un salto condizionato all'etichetta fine: se i valori risultano uguali.
In caso contrario alle righe da 16 a 19 si effettua la divisione con divisore num2, ponendo il dividendo num1 in ax e il risultato ottenuto in cl.
Come accennato in precedenza, l'istruzione cmp viene «tradotta» in una differenza tra i due valori da confrontare; per questo motivo il sistema decide se effettuare il salto condizionato je testando il flag di zero (che deve risultare a valore uno) del registro di stato: infatti se tale flag vale 1 significa che la differenza tra i valori, fatta per realizzarne il confronto, ha fornito risultato zero e quindi essi sono uguali.
Per mostrare l'uso della selezione a due vie consideriamo la differenza tra due numeri in valore assoluto (fatta cioè in modo che il risultato sia sempre positivo); anche in questo caso i dati di input sono fissi a programma mentre il risultato viene posto in memoria usando l'etichetta ris.(10)
|
In questo caso non illustriamo le istruzioni del programma, reputando sufficienti, data la sua estrema semplicità, i commenti inseriti direttamente nel listato.
Anche per l'iterazione esistono due varianti: «con controllo in testa» e «con controllo in coda»; le possiamo rappresentare nei diagrammi di flusso come mostrato nella figura 3.44.
|
Vediamo la logica da seguire per realizzare i due tipi di ciclo usando le istruzioni di salto.
Per il ciclo con controllo in testa:
|
Per il ciclo con controllo in coda:
|
Come esempi di uso dei cicli mostriamo due programmi: il primo per il calcolo del prodotto tra due numeri maggiori o uguali a zero, svolto tramite una successione di somme; il secondo per il calcolo della differenza tra due numeri maggiori di zero, ottenuta sottraendo ciclicamente una unità finché il più piccolo si azzera.
In entrambi i casi la spiegazione delle istruzioni svolte viene fatta attraverso i commenti contenuti nei listati e non è quindi necessaria la loro numerazione.
Nel primo programma i due numeri da moltiplicare sono num1 e num2 assegnati nel programma, il risultato viene depositato nel registro bx.(11)
|
Nel secondo programma i valori da sottrarre sono val1 e val2 e, sebbene siano fissi nel programma, si suppone di non conoscere a priori quale sia il minore; il risultato viene depositato in ris.(12)
|
L'istruzione loop permette di creare «iterazioni calcolate» in cui viene usato un contatore gestito automaticamente dal sistema; si ottiene qualcosa di simile all'uso del costrutto for presente in tutti i linguaggi di programmazione ad alto livello.
Il criterio di funzionamento è il seguente:
|
In pratica l'istruzione loop svolge automaticamente le seguenti due operazioni:
decrementa di uno %cx (dec %cx);
salta se non zero all'etichetta di inizio ciclo (jnz iniziociclo).
Come esempio vediamo il programma per il calcolo del quadrato di un numero positivo n ottenuto come somma dei primi n numeri dispari.
Il valore n è, come al solito fisso nel programma mentre il risultato viene posto in q; anche in questo caso i commenti sono inseriti direttamente nel listato.(13)
|
Anche per l'istruzione loop è prevista la presenza dei suffissi (ad eccezione del suffisso b); abbiamo quindi:
loopw, che utilizza il registro %cx;
loopl, che utilizza il registro %ecx.
Come abbiamo notato in precedenza, anche se, in assenza del suffisso, l'assemblatore è in grado di utilizzare la versione appropriata dell'istruzione in base al contesto di utilizzo, è sempre bene farne uso per una maggiore leggibilità dei sorgenti .
Lo stack o pila è un'area di memoria, a disposizione di ogni programma assembly, che viene gestita in modalità LIFO (Last In First Out).
Questo significa che gli inserimenti e le estrazioni di elementi nella pila avvengono alla stessa estremità detta top o cima e quindi i primi elementi a uscire sono gli ultimi entrati (proprio come in una pila di piatti).
Lo stack ha un'importanza fondamentale soprattutto quando si usano le procedure in assembly, come vedremo nel paragrafo 3.19 ad esse dedicato; può essere utile però anche in tutti quei casi in cui serva memorizzare temporaneamente dei dati: ad esempio per «liberare» dei registri da riutilizzare in altro modo.
Ci sono tre registri che riguardano questa area di memoria:
ss (stack segment): contiene l'indirizzo iniziale del segmento stack (o il relativo selettore se parliamo di processori IA-32) e non viene manipolato direttamente nei programmi assembly;
sp (stack pointer): contiene l'indirizzo della cima dello stack facendo riferimento al registro ss, è cioè l'offset rispetto all'inizio del segmento;
bp (base pointer): è un altro registro di offset per il segmento stack e viene usato per gestire i dati nella pila senza alterare il valore del registro sp (vedremo che questo sarà importante soprattutto nell'uso delle procedure); il registro bp in realtà può essere usato come offset di qualsiasi altro segmento (il segmento stack è la scelta predefinita) ma in tal caso occorre specificarlo scrivendo l'indirizzo nella forma completa regseg:bp o selettore:bp.
Una osservazione importante deve essere fatta riguardo all'organizzazione degli indirizzi nella pila: i dati sono inseriti a partire dall'indirizzo finale del segmento e il riempimento avviene scendendo ad indirizzi più bassi; il registro sp, quindi, ha inizialmente il valore più alto possibile e decresce e aumenta ad ogni inserimento e estrazione di dati.
Nella figura 3.51 viene mostrata l'organizzazione della memoria virtuale di 4 GB assegnata a ogni processo.
|
Ricordiamo però che, come detto nel paragrafo 1.2 e come emerge anche dallo schema proposto, solo i primi 3 GB sono davvero a disposizione del processo.
Le frecce significano che l'area di memoria che ospita il segmento bss cresce verso l'alto, mentre lo stack cresce verso il basso.
Le istruzioni per la gestione dello stack sono:
pop: per prelevare un dato dallo stack e porlo nel registro usato come operando;
push: per inserire un dato nello stack prelevandolo dal registro usato come operando.
Entrambe le istruzioni sono disponibili solo per dati di 16 o 32 bit (suffissi w e l).
Vediamo un piccolo esempio in cui evidenziamo (usando il debugger) le variazioni che subisce il registro sp ad ogni inserimento o estrazione di dati; il programma non ha alcuno scopo concreto e contiene solo alcune pop e push. (14)
|
Il programma esegue solo due inserimenti nello stack e poi preleva i dati inseriti; è ovvio che se l'ultimo inserito è una dato long, tale deve essere anche il primo estratto; in caso contrario non si riceve alcuna segnalazione di errore ma i dati estratti saranno errati.
Nella figura 3.53 vediamo la situazione dei registri prima del primo inserimento nella pila.
In particolare notiamo il valore del registro esp che è di poco superiore a tre miliardi; questo ci conferma che lo stack è posizionato verso la fine dello spazio virtuale di 3 GB disponibile per il programma.
Nella figura 3.54 ci spostiamo alla situazione dopo i due inserimenti.
Il valore del puntatore alla pila è sceso di sei e ciò rispecchia il fatto che abbiamo inserito un dato lungo due byte ed uno lungo quattro.
Infine nella figura 3.55 vediamo la situazione dopo la prima estrazione con il puntatore alla pila che è risalito di quattro unità (abbiamo estratto un dato lungo quattro byte).
Vediamo adesso un esempio di uso concreto dello stack per la gestione di due cicli annidati composti da un numero di iterazioni minore di dieci (i valori delle iterazioni sono fissi nel sorgente); il programma stampa a video i valori degli indici dei due cicli saltando a riga nuova per ogni iterazione del ciclo più esterno.(15)
|
Il programma usa lo stesso indice esi per gestire entrambe le iterazioni; le istruzioni più «interessanti» sono:
alla riga 43, dove salva nella pila il valore dell'indice del ciclo esterno prima di reimpostarlo per il ciclo interno;
alla riga 57, dove recupera dalla pila il valore dell'indice per proseguire le iterazioni del ciclo esterno.
Nella figura 3.57 vediamo gli effetti dell'esecuzione del programma.
|
Quando si scrivono programmi in assembly in GNU/Linux, è possibile utilizzare in modo abbastanza comodo le funzioni del linguaggio c al loro interno.
Questa possibilità è molto allettante perché ci permette di usare le funzioni scanf e printf per l'input e l'output dei dati evitando tutti i problemi di conversione dei valori, da stringhe a numerici e viceversa, che si dovrebbero affrontare usando le routine di I/O native dell'assembly (a tale proposito si può consultare l'appendice C).
Il richiamo delle funzioni citate dai sorgenti assembly è abbastanza semplice e prevede le seguenti operazioni:
inserimento nello stack dei parametri della funzione, uno alla volta in ordine inverso rispetto a come appaiono nella sintassi della stessa in linguaggio c;
richiamo della funzione con l'istruzione: call nome_funzione;
ripristino del valore del registro esp al valore precedente agli inserimenti nello stack.
Come esempio riportiamo una porzione di listato (non è un programma completo) con una chiamata a scanf e una a printf in linguaggio c, commentate, seguite poi dalle sequenze di istruzioni assembly da eseguire per ottenere le stesse chiamate.
|
Il prossimo listato è invece un programma completo, simile a quello visto in precedenza, in cui si chiedono due valori da tastiera, si esegue un calcolo (stavolta una somma) e si visualizza il risultato; grazie all'uso delle funzioni c, non sono più necessarie le conversioni e il programma è molto più semplice, oltre che più breve.(16)
Si presti la massima attenzione al fatto che l'esecuzione delle funzioni printf e scanf avviene con l'uso, da parte del processore, dei registri accumulatori; essi sono quindi «sporcati» da tali esecuzioni e, nel caso contengano valori utili all'elaborazione, devono essere salvati in opportune etichette di appoggio. |
|
La fase di assemblaggio del programma non prevede cambiamenti; nella fase di linking invece devono essere aggiunte le seguenti opzioni:
-lc: significa che si devono collegare le librerie del c;
-dynamic-linker /lib/ld-linux.so.2: serve ad usare il linker dinamico indicato per caricare dinamicamente le librerie del c.
Nella figura 3.60 vediamo i comandi per la traduzione e il linking e gli effetti dell'esecuzione del programma.
Per ottenere più facilmente lo stesso risultato si può utilizzare lo script gcc per la traduzione del sorgente; in questo caso basta eseguire l'unico comando:
$
gcc -g -o nome_eseguibile nome_sorgente.s
a patto di avere sostituito nel sorgente le righe:
|
con:
|
Questa esigenza è dovuta al fatto che il gcc si aspetta di trovare l'etichetta main nel file oggetto di cui effettuare il link.
Ricordiamo che l'opzione -g nel comando, serve solo si ha intenzione di eseguire il programma con il debugger.
In questo paragrafo, servendoci di un semplice programma e del debugger, ci soffermiamo su alcune considerazioni riguardanti la rappresentazione dei valori numerici, soprattutto reali, all'interno del sistema di elaborazione, completando quanto mostrato nel paragrafo 3.5 e confermando le nozioni teoriche che il lettore dovrebbe possedere su questo argomento (e che sono comunque fruibili nelle dispense «Rappresentazione dei dati nell'elaboratore» segnalate all'inizio di questo testo).(17)
|
Il programma nelle prime righe, dalla 17, alla 20 esegue degli spostamenti che servono solo per visionare i dati con il debugger.
Successivamente, dalla riga 22 alla 25, stampa il valore intero e, dalla riga 26 alla 30 il valore reale in doppia precisione.
Soffermiamoci in particolare sulle righe 26 e 27 con le quali si pongono nello stack prima i 32 bit «alti» del valore var3 e poi i 32 bit «bassi»; tale valore è infatti composto da 64 bit e quindi una sola istruzione pushl non sarebbe sufficiente.
Nella figura 3.64 vediamo il risultato dell'esecuzione del programma.
|
Forse è però più interessante seguirne almeno i primi passaggi con il debugger.
Nella figura 3.65 vediamo la situazione dopo le istruzioni di spostamento.
Il primo spostamento pone in eax l'indirizzo dell'etichetta val1 ed in effetti possiamo constatarlo visionando il contenuto del registro e il valore dell'indirizzo nella finestra del codice macchina.
Il secondo spostamento porta in ebx il contenuto dell'etichetta val1 che è 7fffffffb16 tradotto in esadecimale dal binario in complemento a due corrispondente al valore 2,147,483,643.
Il terzo spostamento porta in ecx il contenuto dell'etichetta val2 che è 3d80000016 tradotto in esadecimale dal binario in standard IEEE-754 singola precisione corrispondente al valore 0.0625.
Il quarto spostamento, che dovrebbe avere portato in edx il contenuto dell'etichetta val3 pare non sia riuscito; il motivo è che il valore è lungo 64 bit e il registro solo 32.
Se però andiamo a visionare direttamente gli indirizzi di memoria delle varie etichette, possiamo constatare che i valori sono tutti corretti.
Nella figura 3.66 vediamo appunto la presenza dei giusti valori assegnati alle tre etichette in esadecimale e memorizzati «al contrario» secondo il metodo little-endian di gestione della memoria.
In particolare il valore di val3 è c01c80000000000016 cioè la traduzione in esadecimale della rappresentazione binaria in standard IEEE-754 doppia precisione di -7.125.
Quando ci si riferisce a dei dati in memoria occorre specificare l'indirizzo dei dati stessi, indicandone solo l'offset (il registro di segmento o il selettore corrispondono infatti quasi sempre a ds).
Ci sono varie maniere per fare questa operazione corrispondenti a vari modi di indirizzamento:
diretto o assoluto: è il modo più comune e anche quello più utilizzato negli esempi finora esaminati; si indica direttamente in un operando il nome della cella di memoria interessata all'operazione;
indiretto: in tal caso l'indirizzo viene indicato in un registro ed esso deve essere racchiuso tra parantesi tonde; ad esempio: movl $1, (%ebx);
indicizzato o indexato: in questo caso l'offset si ottiene sommando ad un indirizzo base (etichetta o registro base) il valore di un registro indice; ad esempio: movl %eax, base(%esi);
base/scostamento: l'offset viene ottenuto sommando a un indirizzo base una costante; ad esempio: pushl (val1)+4 oppure 8(%ebp);
base/indice/scostamento: è una generalizzazione dei precedenti e lo illustriamo più avanti con un esempio;
immediato: lo inseriamo in questo elenco ma non è un vero modo di indirizzamento in quanto indica l'uso di un operando che contiene un valore, detto appunto immediato; esempio movl $4, %eax.
Occorre subito chiarire che con il processore 8086 ci sono delle limitazioni nell'uso dei registri per l'indirizzamento indiretto: possono essere solo bx, si, di relativamente al segmento ds e bp per il segmento ss.
Anche per l'indirizzamento con gli indici ci sono regole abbastanza rigide: i registri base possono essere solo bx e bp mentre gli indici sono solo si e di.
Tutte queste limitazioni non esistono invece nei processori IA-32, con i quali si possono usare indistintamente tutti i registri estesi a 32 bit.
Torniamo ora brevemente sull'indirizzamento base/indice/scostamento o base + indice * scala + scostamento (base + index * scale + disp):
|
Significa che vogliamo spostare in cx il contenuto della cella di memoria il cui indirizzo è ottenuto sommando quello di var (base), al contenuto di ebx (scostamento) e al prodotto fra 4 (scala) e il contenuto di eax (indice); nei nostri esempi una modalità così complessa di indirizzamento non è necessaria.
Come noto un vettore o array è una struttura dati costituita da un insieme di elementi omogenei che occupano locazioni di memoria consecutive.
La dichiarazione di un vettore è molto semplice; sotto sono mostrati due esempi:
|
Nel primo caso si definisce un vettore in cui ogni elemento è un byte (ma è ovviamente possibile usare .word, .long ecc.) e le cui celle contengono i valori elencati a fianco; nel secondo caso abbiamo un vettore di 50 elementi grandi un byte, tutti contenenti il valore zero.
Notiamo che le stringhe possono essere proficuamente gestite come vettori di caratteri (come avviene in alcuni linguaggi, fra i quali il c); vediamo un paio di definizioni «alternative» di stringhe:
|
Sicuramente nel primo caso è molto più comodo usare la direttiva .string; quando invece si deve definire una stringa contenente ripetizioni di uno stesso carattere, come nel secondo caso, è più conveniente usare .fill.
Come esempio di uso di un vettore vediamo un programma che individua e stampa a video (usando la chiamata printf) il valore massimo contenuto in un vettore; tale vettore è predefinito all'interno del programma.
Il sorgente contiene già tutti i commenti che dovrebbero permettere la comprensione della sua logica elaborativa.(18)
|
Le procedure sono i sottoprogrammi del linguaggio assembly.
Nella sintassi AT&T si dichiarano semplicemente con un nome di etichetta e si chiudono con l'istruzione ret; il richiamo avviene con l'istruzione call seguita dal nome dell'etichetta.
La gestione dell'esecuzione di una procedura avviene, da parte del sistema, mediante queste operazioni:
viene salvato l'indirizzo dell'istruzione successiva alla call che in quel momento è contenuto in eip; tale indirizzo è detto indirizzo di rientro;
viene effettuato un salto all'indirizzo corrispondente al nome della procedura;
quando essa termina (istruzione ret) viene recuperato dallo stack l'indirizzo di rientro e memorizzato in eip, in modo che l'esecuzione riprenda il suo flusso originario nel programma chiamante.
Il meccanismo permette di gestire chiamate nidificate e anche la ricorsione (chiamata a se stessa da parte di una procedura), sfruttando la modalità di accesso allo stack in modo da «impilare» i relativi indirizzi di rientro.
Lo schema della figura 3.71 rappresenta quanto appena detto.
Ribadiamo che tutte queste operazioni vengono svolte automaticamente dal sistema senza che il programmatore debba preoccuparsene.
Se però di devono passare dei parametri ad una procedura, l'automatismo non è più sufficiente e occorre gestire tale passaggio usando opportunamente lo stack, senza interferire con l'uso che ne fa il sistema per la chiamata alla procedura e il successivo rientro al chiamante.
La sequenza delle operazione da compiere è:
depositare nello stack, prima della chiamata alla procedura, i valori dei parametri da passarle, eventualmente anche quelli di ritorno, che essa provvederà a valorizzare e aggiornare nello stack;
all'interno del sottoprogramma provvedere poi ad estrarre, usare e eventualmente reinserire nello stack, tali valori usando il registro ebp e non l'istruzione pop in quanto quest'ultima estrarrebbe i valori a partire dall'ultimo inserito che non è uno dei «nostri» parametri, ma l'indirizzo di rientro inserito automaticamente dal sistema;
nel programma chiamante, al rientro dalla procedura, effettuare le opportune estrazioni dallo stack per «ripulirlo» e/o per usare eventuali parametri di ritorno dal sottoprogramma.
Altre operazioni che potrebbero rivelarsi necessarie sono:
il salvataggio nello stack del registro ebp, da fare all'inizio della procedura, in modo che il suo uso non interferisca con quello fatto da altre procedure nel caso di chiamate nidificate; il vecchio valore del registro deve essere poi ripristinato, estraendolo dallo stack, prima dell'istruzione ret;
il salvataggio nello stack, fatto all'inizio della procedura, dei registri usati dal chiamante, nell'eventualità che essi siano usati anche all'interno del sottoprogramma, e il relativo loro recupero prima della conclusione dello stesso; in questo caso si possono proficuamente usare le istruzioni pusha e popa.
Possiamo riassumere tutto il procedimento in linguaggio informale nel modo seguente:
|
Come primo esempio di uso di una procedura consideriamo un programma molto semplice che è suddiviso in un main che accetta due valori in input (usando la chiamata scanf), richiama una procedura di calcolo e stampa a video il risultato (con la chiamata printf); il calcolo consiste nella somma tra i due valori ricevuti come parametri dal sottoprogramma insieme al risultato (inizialmente pari a zero), che sarà il valore di ritorno.(19)
|
Come ulteriore esempio vediamo invece un programma un po' più impegnativo, soprattutto per la gestione dello stack che impone: il calcolo del fattoriale di un numero naturale con uso di funzione ricorsiva.
Anche in questo caso c'è un programma principale che si cura dell'input e della visualizzazione del risultato e che richiama la procedura di calcolo; come nel precedente esempio, non è inserita la numerazione per la successiva illustrazione delle istruzioni, in quanto ci sono abbondanti commenti che dovrebbero essere sufficienti per la comprensione del programma.(20)
|
1) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/modello.s>.
2) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/01som.s>.
3) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/02ope.s>.
4) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/ope_logiche.s>.
5) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/03ope.s>.
6) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/ope_logiche_mem.s>.
7) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/04ope.s>.
8) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/salto_inc.s>.
9) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/selezione1.s>.
10) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/selezione2.s>.
11) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/iterazione1.s>.
12) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/iterazione2.s>.
13) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/iterazione3.s>.
14) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/stack1.s>.
15) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/stack2.s>.
16) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/io-funzc.s>.
17) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/valori_num.s>.
18) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/vettore.s>.
19) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/proc1.s>.
20) una copia di questo file, dovrebbe essere disponibile anche qui: <allegati/programmi-assembly/proc2.s>.