Capitolo 3.   Il linguaggio assembly con sintassi AT&T

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.

3.1   Caratteristiche generali

Elenchiamo alcune caratteristiche fondamentali della sintassi e delle regole d'uso delle istruzioni dell'assembly con sintassi AT&T:

3.2   I segmenti di un programma

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:

3.3   Tipi di istruzioni

Come in tutti i linguaggi assembly anche in quello con sintassi AT&T esistono tre tipi diversi di istruzioni in un sorgente:

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:

3.4   Struttura del sorgente

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)

      1 /*
      2 Programma:     modello.s
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 Descrizione:   Modello di sorgente per assembly AT&T
      6 */
      7 .data
      8 .text
      9 .globl _start
     10 _start:
     11    nop
     12 fine:
     13    movl $1, %eax
     14    int  $0x80

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.

3.5   Primo esempio di programmazione

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)

      1 /*
      2 * Descr.: esempio di somma e sottrazione con uso dei registri
      3 * Autore: FF
      4 * Data  : gg/mm/aaaa
      5 * Note  : Assemblare con: as -o nome.o nome.s (eventuale
      6 *         opzione --gstabs per poter usare gdb o ddd)
      7 *         Linkare con ld -o nome nome.o
      8 *         Eseguire con ./nome o (per il debug) con gdb nome o ddd nome
      9 */
     10 .data
     11 .text
     12 .globl _start
     13 _start:
     14    nop
     15    movb $32, %al
     16    movw $2048, %bx
     17    movb $-1, %cl
     18    movl $1000000000, %edx
     19    subw %bx, %bx
     20    movb $0x1, %bh
     21    movl $3500000000, %ecx
     22    addl %ecx, %edx
     23    movb $1, %dh
     24    movl $-0x1, %ecx
     25    xorl %eax, %eax
     26    movb $254, %al
     27    addb $1, %al
     28 fine:
     29    movl $1, %eax
     30    int $0x80

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.

3.6   Il debugger ddd

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.

Figura 3.4.

figure/asm-ddd-es01-1

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.

Figura 3.5.

figure/asm-ddd-es01-2

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).

Figura 3.6.

figure/asm-ddd-es01-3

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.

Figura 3.7.

figure/asm-ddd-es01-4

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.

Figura 3.8.

figure/asm-ddd-es01-5

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.

Figura 3.9.

figure/asm-ddd-es01-6

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.

3.7   La situazione di overflow

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)

      1 /*
      2 * Descr.: esempio con operazioni varie (inc, dec, xchg ...)
      3 * Autore: FF
      4 * Data  : gg/mm/aaaa
      5 * Note  : Assemblare con: as -o nome.o nome.s (eventuale
      6 *         opzione --gstabs per poter usare gdb o ddd)
      7 *         Linkare con ld -o nome nome.o
      8 *         Eseguire con ./nome o (per il debug) con gdb nome o ddd nome
      9 */
     10 .data
     11 .text
     12 .globl _start
     13 _start:
     14    nop
     15    movb $64, %al
     16    movb $63, %bl
     17    xchgb %al, %bl
     18    addb %bl, %al
     19    incb %al
     20    decb %al
     21 fine:
     22    movl $1, %eax
     23    int $0x80

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.

3.8   Operazioni logiche

Nel successivo esempio prendiamo in esame alcune operazioni logiche.(4)

      1 /*
      2 Descrizione:   Esempio di operazioni logiche
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 */
      6 .data
      7 .text
      8 .globl _start
      9 _start:
     10    nop
     11    movb $127, %bl
     12    negb %bl
     13    notb %bl
     14    movb $15, %al  
     15    andb $35, %al
     16    movb $15, %al
     17    orb  $35, %al       
     18 fine:
     19    movl $1, %eax
     20    int  $0x80

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.

3.9   Operazioni di moltiplicazione e divisione

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:

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:

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:

   movw $1500, %ax     
   movb $3, %bl
   divb %bl

Nel listato seguente vengono effettuate alcune operazioni di moltiplicazione e divisione. (5)

      1 /*
      2 * Descrizione:   Esempio di moltiplicazioni e divisioni
      3 * Autore:        FF
      4 * Data:          gg/mm/aaaa
      5 */
      6 .data
      7 .text
      8 .globl _start
      9 _start:
     10    nop
     11 /* 10,000 * 5 con mulw 
     12    moltiplicando in ax, risultato in dx:ax
     13 */
     14    movw $10000, %ax
     15    movw $5, %bx
     16    mulw %bx   
     17 /* 1,003 / 4 con divb
     18    dividendo in ax, quoziente in al e resto in ax 
     19 */  
     20    movw $1003, %ax
     21    movb $4, %bl
     22    divb %bl
     23 /* 1,000,000 / 15 con divw  (1,000,000 = F4240 hex) 
     24    dividendo in dx:ax, quoziente in ax e resto in dx 
     25 */
     26    movw $0xf, %dx
     27    movw $0x4240, %ax
     28    movw $30, %bx
     29    divw %bx
     30 /* 1,000,000,000 * 15 con mull 
     31    moltiplicando in eax, risultato in edx:eax 
     32 */
     33    movl $15, %eax
     34    movl $1000000000, %ebx
     35    mull %ebx
     36 /* ris. precedente / 10 con divl
     37    dividendo in edx:eax, quoziente in eax e resto in edx 
     38 */
     39    movl $10, %ecx
     40    divl %ecx    
     41 fine:
     42    movl $1, %eax
     43    int  $0x80

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.

Figura 3.14.

figure/asm-ddd-es02-1

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).

Figura 3.15.

figure/asm-ddd-es02-2

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.

Figura 3.16.

figure/asm-ddd-es02-3

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).

Figura 3.17.

figure/asm-ddd-es02-4

3.10   Uso della memoria

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.

.data
dato1:   .long 100000
dato2:   .long 200000 

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.

.data
var1:   .byte 0
var2:   .long 0
.text
.globl _start
_start:
   movb $15, (var1)   # a=32;
   movl $var1, (var2) # c=&a; // con c definito come puntatore

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:

   addb %bl, (var1)
   addb (var1), (var2)

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'».

3.11   Lettura del contenuto della memoria con ddd

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)

      1 /*
      2 Descrizione:   Esempio di operazioni logiche con uso memoria
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 */
      6 .data
      7 var1:     .byte 127
      8 .text
      9 .globl _start
     10 _start:
     11    nop
     12    negb (var1)
     13    notb (var1)
     14    movb $15, %al  
     15    andb $35, %al
     16    movb $15, %al
     17    orb  $35, %al       
     18 fine:
     19    movl $1, %eax
     20    int  $0x80

Le operazioni da svolgere sono le seguenti:

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).

Figura 3.25.

figure/asm-ddd-es03-4

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:

3.12   Somma con riporto e differenza con prestito

In questo paragrafo esaminiamo le operazioni di somma con riporto e sottrazione con prestito, usando dati definiti in memoria.(7)

      1 /*
      2 Descrizione:   Esempio contenente somma con riporto 
      3                e differenza con prestito
      4 Autore:        FF
      5 Data:          gg/mm/aaaa
      6 */
      7 .data
      8 dato1:  .byte 200
      9 dato2:  .byte 60
     10 .text
     11 .globl _start
     12 _start:
     13    nop
     14    movb (dato1), %al     
     15    addb (dato2), %al
     16    adcb $0, %ah
     17    xorw %ax, %ax
     18    movb (dato2), %al
     19    subb (dato1), %al
     20    sbbb $0, %ah
     21    negw %ax
     22 fine:
     23    movl $1, %eax
     24    int  $0x80

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.

Figura 3.30.

figure/asm-ddd-es04-1

Nella figura 3.31 invece il risultato appare corretto dopo la somma con riporto fatta a riga 16.

Figura 3.31.

figure/asm-ddd-es04-2

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.

Figura 3.32.

figure/asm-ddd-es04-3

Figura 3.33.

figure/asm-ddd-es04-4

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.

Figura 3.34.

figure/asm-ddd-es04-5

3.13   Salti incondizionati e condizionati

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)

      1 /*
      2 Programma:     salto_inc.s
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 Descrizione:   Esempio con salto incondizionato
      6 */
      7 .data
      8 var1:   .byte 127
      9 .text
     10 .globl _start
     11 _start:
     12    nop
     13    negb (var1)
     14    notb (var1)          
     15    jmp fine
     16    nop
     17    nop
     18 fine:
     19    movl $1, %eax
     20    int  $0x80

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.

Figura 3.36.

figure/asm-ddd-es05-1

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).

Figura 3.37.

figure/asm-ddd-es05-2

Figura 3.38.

figure/asm-ddd-es05-3

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:

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.

3.13.1   Realizzazione di strutture di selezione con i salti

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.

Figura 3.39.

figure/asm-diagramma01

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:

   istruzioni prima della selezione
   
   confronto
   salto condizionato a finese
      
      istruzioni del corpo della selezione

finese:

   istruzioni dopo la selezione

Per la selezione a due vie:

   istruzioni prima della selezione
   
   confronto
   salto condizionato a altrimenti
      
      istruzioni di un ramo della selezione
      salto incondizionato a finese
      
altrimenti:
      
      istruzioni dell'altro ramo della selezione

finese:

   istruzioni dopo la selezione

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)

      1 /*
      2 Programma:     selezione1.s
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 Descrizione:   Esempio con selezione a una via
      6 */
      7 .data
      8 num1:   .byte  125
      9 num2:   .byte  5
     10 .text
     11 .globl _start
     12 _start:
     13    movb $0, %bl
     14    cmpb (num2), %bl
     15    je finese
     16    xorw %ax, %ax
     17    movb (num1), %al
     18    divb (num2)
     19    movb %al, %cl
     20 finese:   
     21 fine:
     22    movl $1, %eax
     23    int  $0x80

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)

/*
Programma:     selezione2.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Esempio con selezione a due vie
*/
.data
num1:   .byte  247
num2:   .byte  210
ris:    .byte  0
.text
.globl _start
_start:
   movb (num2), %al
//iniziose
   cmpb (num1), %al
   jl altrimenti     # se %al (cioe' num2) piu' piccolo salta
   movb (num1), %cl  
   subb %cl, %al     # sottrae %cl (cioe' num1) da %al (cioe' num2)
   jmp  finese
altrimenti:
   movb (num1), %cl 
   subb (num2), %cl  # sottrae num2 da %cl (cioe' num1)
finese:
   movb %cl, (ris)   
fine:
   movl $1, %eax
   int  $0x80

In questo caso non illustriamo le istruzioni del programma, reputando sufficienti, data la sua estrema semplicità, i commenti inseriti direttamente nel listato.

3.13.2   Realizzazione di strutture di iterazione con i salti

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.

Figura 3.44.

figure/asm-diagramma02

Vediamo la logica da seguire per realizzare i due tipi di ciclo usando le istruzioni di salto.

Per il ciclo con controllo in testa:

   istruzioni prima del ciclo
   
iniziociclo:   
   confronto
   salto condizionato a fineciclo:
      
      istruzioni del corpo del ciclo
      salto incondizionato a iniziociclo

fineciclo:

   istruzioni dopo il ciclo

Per il ciclo con controllo in coda:

   istruzioni prima del ciclo
   
iniziociclo:   
   
      istruzioni del corpo del ciclo
   
   confronto
   salto condizionato a iniziociclo

   istruzioni dopo il ciclo

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)

/*
Programma:     iterazione1.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Esempio con iterazione con controllo in testa: 
               prodotto come successione di somme
*/
.data
num1:   .byte  50
num2:   .byte  6
.text
.globl _start
_start:
   xorb %cl, %cl       # azzero %cl (contatore)
   xorw %bx, %bx       # azzero %bx (accumulatore)
iniziociclo:
   cmpb (num2), %cl
   je  fineciclo       # se %cl = num2 il ciclo è finito
      addb (num1), %bl # accumulo il valore di num1 in %bl
      adcb $0, %bh     # considero eventuale riporto
      incb %cl         # incremento del contatore   
      jmp iniziociclo
fineciclo:      
fine:
   movl $1, %eax
   int  $0x80

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)

/*
Programma:     iterazione2.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Esempio con iterazione con controllo in coda: 
               differenza tra numeri positivi sottraendo ciclicamente 1
*/
.data
val1:   .byte  5
val2:   .byte  26
ris:    .byte  0
.text
.globl _start
_start:
// con una selezione a due vie pone in %al il valore maggiore e in %bl il minore
   movb (val2), %dl    # val2 va in %dl, non si fanno confronti tra etichette
   cmpb (val1), %dl
   jl altrimenti
      movb %dl, %al
      movb (val1), %bl
      jmp finese
altrimenti:
      movb (val1), %al
      movb %dl, %bl
finese:
   xorb %cl, %cl      
iniziociclo:
      decb %al       # tolgo 1
      decb %bl
   cmpb %bl, %cl
   jne iniziociclo   # se %bl non zero il ciclo continua
   movb %al, (ris)   # valore residuo di %al = differenza
fine:
   movl $1, %eax
   int  $0x80

3.13.3   Iterazioni con l'istruzione loop

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:

   istruzioni prima del ciclo
   
   impostazione del numero di iterazioni in %cx 
       
iniziociclo:   
         
      istruzioni del corpo del ciclo
      loop iniziociclo

   istruzioni dopo il ciclo

In pratica l'istruzione loop svolge automaticamente le seguenti due operazioni:

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)

/*
Programma:     iterazione3.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Esempio con iterazione calcolata (loop): 
               quadrato di n come somma dei primi n numeri dispari
*/
.data
n:   .word  5
q:   .word  0
.text
.globl _start
_start:
   movw (n), %cx    
   movw $1, %ax     # imposta la variabile per scorrere i numeri dispari
   xorw %bx, %bx    # azzera registro dove accumulare i valori
iniziociclo:
      addw %ax, %bx
      addw $2, %ax  # prossimo valore dispari
      loop iniziociclo
   movw %bx, (q)      
fine:
   movl $1, %eax
   int  $0x80

Anche per l'istruzione loop è prevista la presenza dei suffissi (ad eccezione del suffisso b); abbiamo quindi:

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 .

3.14   Gestione dello stack

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:

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.

Figura 3.51.

figure/asm-schema-mem

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:

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)

/*
Programma:     stack1.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Esempi di pop e push   
*/
.data
.text
.globl _start
_start:
   movw $2, %ax
   movl $3, %ebx
   pushw %ax
   pushl %ebx
   popl  %ecx
   popw  %dx
fine:
   movl $1, %eax
   int  $0x80

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.

Figura 3.53.

figure/asm-ddd-es06-1

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.

Figura 3.54.

figure/asm-ddd-es06-2

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).

Figura 3.55.

figure/asm-ddd-es06-3

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)

      1 /*
      2 Programma:     stack2.s
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 Descrizione:   Gestione di cicli annidati con lo stack   
      6 */
      7 .bss 
      8 cifra:     .string ""         # per stampa delle cifre
      9 .data
     10 iteraz1:   .long   7          # iterazioni ciclo esterno
     11 iteraz2:   .long   5          # iterazioni ciclo interno
     12 spazio:    .string " "        # per spaziare le cifre
     13 acapo:     .string "\n"       # a capo     
     14 .text
     15 .globl _start
     16 .macro stampa_spazio
     17    movl $4, %eax
     18    movl $1, %ebx
     19    movl $spazio, %ecx   
     20    movl $1, %edx
     21    int  $0x80
     22 .endm
     23 .macro stampa_cifra
     24    movl $4, %eax
     25    movl $1, %ebx
     26    movl $cifra, %ecx   
     27    movl $1, %edx
     28    int  $0x80
     29 .endm   
     30 _start:
     31 // imposta ciclo esterno
     32    movl (iteraz1), %esi
     33 ciclo_esterno:
     34    movl %esi, %eax
     35    addl $0x30, %eax               # somma 48 per avere ascii del valore
     36    movl %eax, (cifra)
     37 // stampa indice esterno 
     38    stampa_cifra
     39 // stampa spazio due volte
     40    stampa_spazio
     41    stampa_spazio
     42 // imposta ciclo interno
     43    pushl %esi
     44    movl (iteraz2), %esi
     45  ciclo_interno:
     46       movl %esi, %ebx
     47       addl $0x30, %ebx           # somma 48 per avere ascii del valore 
     48       movl %ebx, (cifra)
     49  // stampa indice interno
     50       stampa_cifra     
     51  // stampa spazio
     52       stampa_spazio
     53  // controllo ciclo interno     
     54       decl %esi
     55       jnz ciclo_interno
     56 // fine ciclo interno: recupera indice esterno e stampa a capo
     57    popl %esi      
     58    movl $4, %eax
     59    movl $1, %ebx
     60    movl $acapo, %ecx   
     61    movl $1, %edx
     62    int  $0x80
     63 // controllo ciclo esterno
     64    decl %esi
     65    jnz ciclo_esterno
     66 fine:
     67    movl $1, %eax
     68    int  $0x80

Il programma usa lo stesso indice esi per gestire entrambe le iterazioni; le istruzioni più «interessanti» sono:

Nella figura 3.57 vediamo gli effetti dell'esecuzione del programma.

Figura 3.57.

figure/asm-esec-stack02

3.15   Uso di funzioni di linguaggio c nei programmi assembly

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:

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.

// scanf("%d",&val1); // input di una variabile intera di nome val1

pushl $val1      # val1 etichetta di tipo .long - $val1 e' il suo indirizzo 
pushl $formato   # formato etichetta .string contenente "%d"
call scanf
addl $8, %esp    # due pushl hanno spostato %esp di 8 byte indietro

// printf("Valore del %d elem. = %f\n",el,ris); # stampa stringa con 2 val.   

pushl (ris)      # ris etichetta di tipo .long
pushl (el)       # el etichetta di tipo .long
pushl $stri      # stri etichetta .string contente "Valore del %d elem. = %f\n"
call printf
addl $12, %esp   # tre pushl hanno spostato %esp di 12 byte indietro         

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.

/*
Programma:     io-funzc.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Input e output con funzioni del c
*/
.bss
val1:      .long   0                          # primo valore di input
val2:      .long   0                          # secondo valore di input
ris:       .long   0                          # risultato
.data
invito:    .string "Inserire un valore: "     # stringa terminata con \0
formato:   .string "%d"                       # formato input
risult:    .string "Somma = %d\n"             # stringa per risultato
.text
.globl _start
_start:
// input primo valore
   pushl $invito
   call printf
   addl $4, %esp
   pushl $val1
   pushl $formato
   call scanf
   addl $8, %esp
// input secondo valore   
   pushl $invito
   call printf
   addl $4, %esp
   pushl $val2
   pushl $formato
   call scanf
   addl $8, %esp
// calcolo di val1 + val2
   movl (val2), %eax
   addl (val1), %eax
   movl %eax, (ris)      
// stampa risultato
   pushl (ris)
   pushl $risult
   call printf
   addl $8, %esp   
fine:
   movl $1, %eax
   int  $0x80

La fase di assemblaggio del programma non prevede cambiamenti; nella fase di linking invece devono essere aggiunte le seguenti opzioni:

Nella figura 3.60 vediamo i comandi per la traduzione e il linking e gli effetti dell'esecuzione del programma.

Figura 3.60.

figure/asm-esec-funzc

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:

.globl _start
_start:

con:

.globl main
main:

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.

3.16   Rappresentazione dei valori reali

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)

      1 /*
      2 Programma:     valori_num.s
      3 Autore:        FF
      4 Data:          gg/mm/aaaa
      5 Descrizione:   Prove sulla rappr. di valori numerici reali
      6 */
      7 .data
      8 val1:      .long     2147483643   # max intero - 4  
      9 val2:      .float    0.0625       # reale singola prec. 
     10 val3:      .double   -7.125       # reale doppia prec.
     11 st_int:    .string   "Valore = %d\n"  # per stampa intero
     12 st_real:   .string   "Valore = %f\n"  # per stampa reale
     13 .text
     14 .globl main
     15 main:
     16    nop
     17    movl $val1, %eax
     18    movl (val1), %ebx
     19    movl (val2), %ecx
     20    movl (val3), %edx
     21 // stampe a video   
     22    pushl (val1)
     23    pushl $st_int
     24    call printf
     25    addl $8, %esp
     26    pushl (val3)+4
     27    pushl (val3)
     28    pushl $st_real
     29    call printf
     30    addl $12, %esp   
     31 fine:
     32    movl $1, %eax
     33    int  $0x80

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.

Figura 3.64.

figure/asm-esec-valori

Forse è però più interessante seguirne almeno i primi passaggi con il debugger.

Nella figura 3.65 vediamo la situazione dopo le istruzioni di spostamento.

Figura 3.65.

figure/asm-ddd-valori-1

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.

Figura 3.66.

figure/asm-ddd-valori-2

3.17   Modi di indirizzamento

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:

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):

movw var(%ebx,%eax,4), %cx

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.

3.18   Vettori

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:

vet1:   .byte 1,2,5,3,9,6,7
vet2:   .fill,50,1,0

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:

stringa1: .byte 'C','i','a','o',' ','a',' ','t','u','t','t','i'
stringa2: .fill,50,1,'*'

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)

/*
Programma:     vettore.s
Autore:        FF
Data:          gg/mm/aaaa
Descrizione:   Ricerca max in un vettore predefinito 
*/
.data
vet:       .byte 12,34,6,4,87,3
msgout:    .string "Max = %d\n"  
.text
.globl main
main:
   movl $5, %ebx        # ultimo indice vettore
   xorl %eax, %eax      # pulisco %eax (max)
   movl $0, %esi        
   movb vet(%esi), %al  # inizialmente metto in max il primo el del vettore 
   movl $1, %esi        # imposto indice per il ciclo
ciclo:
   cmpb vet(%esi), %al  # confronto el del vettore con max
   jg avanti            # se max è maggiore non faccio niente
   movb vet(%esi), %al  # se max minore aggiorno max
avanti:      
   incl %esi            # incremento indice   
   cmpl %esi, %ebx       # controllo di fine ciclo
   jne ciclo
// stampa max trovato
   pushl %eax           # stampo con funzione printf
   pushl $msgout
   call printf
   addl $8, %esp
fine:
   movl $1, %eax
   int  $0x80

3.19   Procedure

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:

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.

Figura 3.71.

figure/asm-schema-proc

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 è:

Altre operazioni che potrebbero rivelarsi necessarie sono:

Possiamo riassumere tutto il procedimento in linguaggio informale nel modo seguente:

nel chiamante:

- salvataggio nello stack dei parametri da passare alla procedura
- call della procedura
- (inserimento nello stack dell'indirizzo di rientro) fatto
  automaticamente dal sistema

nella procedura:

- eventuale salvataggio nello stack del registro ebp
- valorizzazione di ebp = esp
- eventuale salvataggio nello stack dei registri usati dal
  chiamante o di tutti i registri (pusha)
- uso di ebp con indirizzamento base/scostamento per leggere i param. dallo stack
- eventuale aggiornamento nello stack dei parametri di ritorno
- eventuale ripristino dei registri precedentemente salvati o di tutti i
  registri (popa)  
- eventuale ripristino del valore precedente di ebp prelevandolo dallo stack
- ret
- (estrazione dell'indirizzo di rientro e valorizzazione di eip) fatta
  automaticamente dal sistema

nel chiamante:

- estrazione dallo stack dei parametri

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)

/*
Descrizione:   Chiamata a una procedura con parametri 
Autore:        FF
Data:          gg/mm/aaaa
*/
.bss       
val1:       .long 0
val2:       .long 0 
ris:        .long 0 
.data
msgin:      .string "Inserire un valore: "
formatin:   .string "%d"
msgout:     .string "Somma = %d\n" 
.text
.globl main
main:
// fase di input
   pushl $msgin
   call printf
   addl $4, %esp
   pushl $val1
   pushl $formatin
   call scanf
   addl $8, %esp
   pushl $msgin
   call printf
   addl $4, %esp
   pushl $val2
   pushl $formatin
   call scanf
   addl $8, %esp
// chiamata a procedura
   pushl (ris)      # in stack il risultato (valore di ritorno)
   pushl (val2)     # in stack primo parametro
   pushl (val1)     # in stack secondo parametro
   call somma
   popl  %eax       # estraggo secondo parametro (non serve piu')
   popl  %eax       # estraggo primo parametro (non serve piu')
   popl  (ris)      # estraggo risultato
// stampa risultato
   pushl (ris)          
   pushl $msgout
   call printf
   addl $8, %esp
fine:
   movl $1, %eax
   int  $0x80
// procedura   
somma:             
   movl %esp, %ebp      # %ebp = cima della pila
   movl 4(%ebp), %ebx   # salto i primi 4 byte dove e' ind. di rientro
                        # e leggo il primo parametro 
   movl 8(%ebp), %eax   # leggo secondo parametro
   addl %ebx, %eax
   movl %eax, 12(%ebp)  # scrivo il risultato sul terzo parametro
   ret

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)

/*
Descrizione:   Fattoriale con procedura ricorsiva 
Autore:        FF
Data:          gg/mm/aaaa
*/
.bss       
num:       .long 0
ris:       .long 0 
.data
msgin:      .string "Inserire il valore: "
formatin:   .string "%d"
msgout:     .string "Fattoriale = %d\n" 
.text
.globl main
main:
// fase di input
   pushl $msgin
   call printf
   addl $4, %esp
   pushl $num
   pushl $formatin
   call scanf
   addl $8, %esp
// chiamata a procedura
   pushl (ris)      # in stack il risultato (valore di ritorno)
   pushl (num)      # in stack il parametro
   call fatt        # eseguo fatt(num)
   popl  %eax       # estraggo parametro (non serve piu')
   popl  (ris)      # estraggo risultato
// stampa risultato
   pushl (ris)          
   pushl $msgout
   call printf
   addl $8, %esp
fine:
   movl $1, %eax
   int  $0x80
// procedura fatt(x)  
fatt:
   pushl %ebp           # salvo %ebp             
   movl %esp, %ebp      # %ebp = cima della pila
   movl 8(%ebp), %ebx   # salto i primi 8 byte dove ci sono %ebp e
                        # ind. di rientro e leggo il parametro x 
   cmpl $1, %ebx        # se x = 1 esco dalla ricorsione (fatt = 1)
   je noricor
   pushl %ebx           # metto da parte questo parametro x
   decl %ebx            # x = x-1
   pushl (ris)          # in stack il risultato (valore di ritorno)
   pushl %ebx           # in stack il parametro (x-1)
   call fatt            # eseguo fatt(x)
   popl  %eax           # estraggo parametro (non serve piu')
   popl  %eax           # estraggo risultato ( fatt(x-1) )
   popl  %ebx           # estraggo %ebx precedente (x)
   mull  %ebx           # calcolo %eax = x*fatt(x-1)
   movl  %eax, 12(%ebp) # scrivo risultato
   jmp fine_proc
noricor:   
   movl %ebx, 12(%ebp)  # scrivo il risultato senza ricorsione %ebx = 1
fine_proc:
   popl %ebp            # ripristino %ebp
   ret

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>.