Assembly | NEXT chapters | |||||||||||||||||||||||||
La Central Processing Unit (CPU) è considerata il cervello del computer, visto che è l'unità in cui vengono eseguiti tutti i calcoli. La CPU è ospitata in un singolo chip, chiamato processore, oppure chip, oppure die. La CPU è suddivisa in più unità funzionali, una delle quali è la Arithmetic Logic Unit (ALU), in cui vengono eseguiti realmente i calcoli aritmetici e logici. Per supportare la ALU, nel chip (inside the chip o “on the die”) vengono installati i registri del processore e la memoria cache. Un computer è composto da una Central Processing Unit (CPU), da una Primary Storage o Random Access Memory (RAM), da un Secondary Storage, dalle periferiche di Input/Output (schermo, tastiera, mouse), da una fascia di interconnessione fra tutte le periferiche, chiamata Bus. I dati e i programmi sono memorizzati nel secondary storage (i dischi fissi). Quando si esegue un programma, il codice sorgente del programma viene trasferito (attraverso il bus) dal disco fisso (secondary storage) alla RAM, memoria di sistema (primary storage o main memory). La CPU può leggere dati e codici solo dalla memoria di sistema (primary storage o RAM). Abbiamo visto che esistono diversi livelli di memoria: dai registri della CPU, alle memorie cache, installate nella CPU, alla RAM o Main Memory, esterna alla CPU, alle memorie di massa (Secondary Storage, quali i dischi fissi). Questo accade perchè esistono differenti livelli di velocità delle memorie: in generale, le memorie più veloci sono anche quelle più costose, mentre le memorie meno costose sono quelle più lente. I registri della CPU sono piccoli, veloci, ma molto costosi. I cosiddetti "Secondary storage devices", le periferiche quali i dischi fissi, sono molto grandi, molto più lenti e molto meno costosi. L'obiettivo finale, nel calibrare l'uso di tutte queste memorie, è di trovare il giusto equilibrio tra costi e performance. Vediamo le differenze di prestazione tra le diverse memorie, partendo dalle più veloci e costose: i registri della CPU hanno tempi di accesso dell'ordine di un nanosecondo, mentre le cache memory, sempre installate nella CPU, hanno tempi di accesso dell'ordine di 5 fino a 60 nanosecondi, mentre la memoria RAM (main memory) ha tempi di accesso dell'ordine di 100 fino a 150 nanosecondi, mentre le periferiche esterne alla scheda madre, le secondary storage devices (quali i dischi fissi), hanno tempi di accesso dell'ordine di 3 fino a 15 millisecondi. La memoria cache è l'anticamera della memoria di sistema (primary storage o RAM) ed è installata direttamente nel chip della CPU (mentre la RAM è esterna alla CPU ed è collegata alla CPU attraverso il BUS di sistema). Normalmente, per leggere un dato dalla RAM, la CPU invia al controller della memoria RAM, attraverso il BUS di sistema, l'indirizzo della cella di memoria da cui prelevare il dato. Il controller andrà a prelevare il dato dalla locazione di memoria specificata e lo invierà alla CPU, sempre utilizzando il BUS di sistema. E questo accadrà ogni volta in cui la CPU avrà bisogno di quel dato. Naturalmente, se il valore di quella locazione di memoria venisse copiato nella cache della CPU, la CPU potrebbe eseguire tutte le operazioni successive direttamente sul dato copiato in cache, evitando di andarlo a prelevare ogni volta dalla RAM, riducendo i tempi di prelievo. Prelevare un dato dalla cache è molto più veloce che prelevarlo dalla memoria di sistema o RAM. Ed ecco perchè, ogni volta in cui la CPU preleva un dato dalla RAM, quel dato viene copiato nella cache della CPU. Tutti gli accessi successivi a quel dato verranno, quindi, realizzati sulla copia della cache, cioè all'interno dello stesso chip della CPU. Un "cache hit" indica che un dato è stato trovato nella cache, mentre un "cache miss" indica che un dato non è stato trovato nella cache.
I chip attuali includono una cache L1 per ciascun core (processore) e una cache L2 condivisa. Molte CPU, oggi, includono una ulteriore cache, chiamata L3. Come si può notare dal diagramma, tutti gli accessi alla memoria RAM attraversano tutti i livelli di cache. Questo significa che di uno stesso dato potrebbero esistere più copie (nei registri della CPU, nella L1 cache, nella L2 cache, nella RAM (main memory). Tutto questo viene gestito dalla CPU e non c'è alcuna possibilità che un programmatore possa entrarci. I registri della CPU sono un'ulteriore unità di memoria, installata direttamente nel chip della CPU, quindi separata dalla RAM, che viene utilizzata dalla CPU stessa per eseguire i suoi calcoli. La funzione dei registri è di servire la CPU senza constringere la CPU stessa ad inviare i dati alla RAM, invio che comporterebbe un dispendio di tempo. I registri a disposizione della CPU sono 16, ciascuno dei quali è composto da 64 bit, e vengono definiti General Purpose Registers (GPR):
Quando si usano dati di lunghezza inferiore ai 64 bit (per esempio, 32-bit, 16-bit, 8-bit), le porzioni più basse di ciascun registro possono essere utilizzate con un nome differente. Per esempio, per il registro RAX, avremo i registri:
Il numero totale dei registri utilizzabili, quindi, diventa:
Il registro RSP contiene la posizione del top attuale dello stack e non può, quindi, essere usato in alcun modo. Il registro RBP viene usato come puntatore per le chiamate di funzione e non può, quindi, essere usato in alcun modo. La possibilità di accedere ad una porzione di un registro, comporta che, nel caso il registro RAX (64 bit) contenesse il valore 50.000.000.000 (cinquanta miliardi), che espresso in esadecimale corrisponde a:
e, in un'operazione successiva, andassimo a modificare il valore del registro AX (i primi 16 bit del registro RAX), inserendovi il valore 50.000 (cinquantamila), che espresso in valori esadecimali corrisponde a:
ci ritroveremmo con il registro RAX contenente il nuovo valore:
Avendo modificato solo i primi 16 bit del registro RAX, i restanti 48 bit risultano inalterati. Per le operazioni eseguite sui registri a 32 bit, i restanti 32 bit (non utilizzati) vengono portati a zero. Oltre ai General Purpose Registers (GPR), esistono una serie di registri speciali che svolgono compiti specifici:
La memoria può essere vista come una serie di byte, posti uno dopo l'altro. La memoria, quindi, è "byte addressable" (indirizzabile per byte) e questo significa che ogni indirizzo di memoria contiene un byte di informazione. Quindi, per memorizzare una double-word (una unità composta da quattro byte o 32 bit), sono necessari quattro byte, quindi quattro indirizzi di memoria. Per complicare un pochino le cose, inoltre, l'architettura hardware potrebbe essere "little-endian", che significa che il byte meno significativo (Least Significant Byte o LSB), in una unità superiore al byte, viene memorizzato all'indirizzo di memoria più basso, mentre il byte più significativo (Most Significant Byte o MSB) viene memorizzato all'indirizzo di memoria più alto. Vediamo un esempio. Il numero 5.000.000 (cinquemilioni) può essere contenuto in una double-word (una unità composta da quattro byte o 32 bit). Con un solo byte, infatti, possiamo memorizzare 256 valori (due elevato alla otto), mentre con due byte ne possiamo memorizzare 65.536 (due elevato alla 16), mentre con quattro byte ne possiamo memorizzare 4.294.967.296 (due elevato alla 32). Non esistono unità composte da un numero dispari di byte (come 3 byte). Quindi, il numero 5.000.000 (cinquemilioni), 004C4B40 in rappresentazione esadecimale, o base 16, verrebbe memorizzato così, in un'architettura "little-endian":
La locuzione "Data representation" indica la modalità di rappresentazione delle informazioni in un computer: numeri interi, numeri a virgola mobile (floating-point), caratteri. Per la semplificazione dell’hardware, il computer immagazzina tutte le informazioni in formato numerico binario (base 2). I numeri a base 2 sono composti di due possibili valori (0 e 1). Ogni cifra del numero ha una potenza del 2 il cui esponente è dato dalla sua posizione nel numero (una singola cifra binaria è chiamata bit). Per esempio:
Ricorda che due elevato alla potenza zero è sempre uguale a 1. Abbiamo convertito un numero binario nel suo equivalente decimale. Per fare l'operazione opposta, si prende il numero decimale da convertire e lo si divide per due fino a ridurlo a zero e conservando le cifre dei resti:
Quindi, il numero decimale 25, in RAM o in un registro della CPU, viene memorizzato come 00011001 (un byte). Nonostante ciò, tutte le rappresentazioni di dati che troverete sono sempre espresse in notazione esadecimale (hex):
I numeri esadecimali usano la base 16. Gli esadecimali hanno 16 possibili cifre:
La cifra A è equivalente a 10 in decimale, B a 11, e cosi’ via. Per convertire un decimale in un esadecimale, si usa la stessa procedura usata per i numeri binari, ma si divide per 16. La ragione per cui gli esadecimali sono utili è che esiste un modo molto semplice di conversione tra gli esadecimali e i binari. Prendiamo il nostro esempio. Notate come sia sufficiente convertire ogni cifra esadecimale in un numero binario a 4 bit:
La conversione da binario a esadecimale è altrettanto semplice. Occorre applicare la procedura inversa: spezzare il numero binario in gruppi di 4 bit e convertire ciascun gruppo nella sua rappresentazione esadecimale. Occorre spezzare il numero partendo da destra, non da sinistra. Cio’ assicura che il processo di conversione utilizzi i gruppi di 4 bit corretti:
Un numero a 4 bit è chiamato nibble. Ogni cifra esadecimale corrisponde così ad un nibble. Due nibble fanno un byte.
Data Representation: Integer (numeri interi) Con un byte (8-bit) è possibile rappresentare 256 valori differenti (28). Quindi, se vogliamo rappresentare i numeri interi positivi, con 8 bit possiamo contare da zero a 255!
Naturalmente, se vogliamo rappresentare anche i numeri negativi, l'intervallo numerico rappresentabile da un solo byte diventa: da -128 a +127. Come si rappresenta un numero negativo? Con l'algoritmo del two's complement representation: per calcolare il two's complement di un numero, è sufficiente calcolare il numero complementare, invertendo tutti i bit, per poi addizionare 1 al risultato:
Questo metodo di rappresentazione ha notevoli vantaggi nell'effettuare somme e differenze:
Attenzione: una stessa sequenza di bit può essere interpretata diversamente, a seconda che si tratti di una variabile senza segno o una con segno. Per esempio, la sequenza:
rappresenta il numero decimale 15. Il suo negativo diventa, quindi:
che, però, in un contesto senza segno rappresenta il numero decimale 241. Questo è il motivo per cui è sempre necessario definire esattamente il tipo di dato (data type: signed, unsigned) con cui stiamo trattando e la sua dimensione (byte (8-bit), word (16-bit), double-word (32-bit), quadword (64-bit)).
Un file sorgente di assembly (in questi esempi esporremo le specifiche dell'assembler yasm) è costiuito da più sezioni:
Il punto e virgola viene usato per i commenti.
I numeri possono essere espressi in notazione decimale, che è la base di default (per cui, per esprimere un valore in decimale non serve alcun prefisso o suffisso), in notazione esadecimale o in notazione ottale:
Le costanti vengono definite con l'operatore
Il valore di una costante non può essere modificato nel corso dell'esecuzione del programma. Il linguaggio Assembly è una forma human readable (leggibile dall'uomo) del linguaggio macchina, una sequenza di bit e di byte sui quali il processore opera. È molto più semplice per un programmatore usare un linguaggio mnemonico (facile da ricordare), piuttosto che una serie di numeri binari, ottali, esadecimali, così il programmatore preferisce scrivere codice in linguaggio assembly, per poi usare uno o più programmi che lo convertano in quella sequenza di bit e byte da dare in pasto al processore. Un assembler è un programma che legge il programma in linguaggio assembly, lo esamina e ne produce il corrispondente linguaggio macchina. Diversamente dai linguaggi più strutturati, quali C++, che è un linguaggio ben definito, di linguaggi assembly ne esistono molti, almeno uno per ogni architettura hardware: ARM, MIPS, x86, per ciascuna delle quali esisterà un linguaggio macchina ed un linguaggio assembly. In alcuni casi, esistono più linguaggi assembly per la stessa architettura hardware: i processori della famiglia x86 hanno, per esempio, due formati: uno noto come gas syntax ( gas è il nome dell'eseguibile del GNU Assembler) e Intel syntax (dal nome del produttore). I due formati sono differenti, ma equivalenti: è possibile scrivere un programma usando una delle due sintassi. Generalmente, il produttore del processore documenta il processore ed il suo codice macchina, creando anche un linguaggio assembly, il solo che dovrebbe essere usato con quel processore. Ma, a volte, il linguaggio assembly definito dal produttore non coincide perfettamente al linguaggio assembly scritto dai programmatori degli assembler. Ci sono due tipi di processori:
Per un programmatore di assembler, la differenza tra i due tipi è che un processore CISC può avere molte istruzioni macchina da imparare, ma potrebbe averne una per una particolare operazione, mentre per un processore RISC una singola operazione potrebbe richiedere molte più istruzioni da scrivere. I compilatori di altri linguaggi di programmazione producono, a volte, il codice assembly, per poi compilarlo in codice macchina, utilizzando un assembler. Questo è ciò che fa gcc, con il suo gas assembler. Il codice macchina prodotto è spesso memorizzato in object file, che poi verranno linkati (linked) direttamente nel file eseguibile, dal programma linker. Una "toolchain" completa spesso consiste di un compilatore, di un assembler e di un linker. È possibile utilizzare quell'assembler e quel linker direttamente, per scrivere un programma in assembly language. Nel mondo GNU, coloro che sono interessati alla programmazione assembly non necessitano di gcc o di altri compilatori. I piccoli microcontroller vengono spesso programmati in assembly language, a volte in combinazione con uno o più linguaggi di programmazione di più alto livello, quali C o C++, di cui usano un particolare e ristretto range dell'instruction set. Questi apparecchi hanno spesso un numero di registri e una quantità di memoria molto limitati. Molti di questi microprocessori vengono usati nei cosiddetti embedded system (quali TV, forni a microonde, unità di controllo nelle moderne automobili), apparecchi contenenti un microprocessore dedicato ad una specifica funzione (quindi, non general purpose computer). Questi apparecchi non hanno bisogno di tastiere e schermo: così il programmatore scrive il programma in un general purpose computer, esegue un cross-assembler (un assembler che produce codice macchina per un processore diverso da quello su viene eseguito) e/o un cross-compiler con un cross linker, così da produrre il codice macchina. Yasm è un Modular Assembler scritto sotto la licenza “new” (2 o 3 clause) BSD. Attualmente supporta il set di istruzioni x86 e AMD64, accetta le sintassi degli assembler NASM e GAS, restituisce oggeti nei formati binario, ELF32, ELF64, COFF, Win32, Win64. Un assembler è un programma che legge un file scritto in linguaggio assembly (istruzioni scritte in un linguaggio umano), convertendolo in un file binario scritto in linguaggio macchina, chiamato "object file". In questa fase, i commenti vengono eliminati e i nome delle variabili e le etichette vengono sostituiti dai relativi indirizzi di memoria (come richiesto dalla CPU in fase di esecuzione). Il comando
assembla il file
che genererà i file:
file object e list file. L'opzione:
dice all'assembler di includere le informazioni di debug nell'object file. Le informazioni di debug potranno poi essere utilizzate per risalire al file sorgente, a partire dal codice eseguibile. Esiste una corrispondenza one-to-one tra istruzioni assembly e linguaggio macchina binario. Questo significa che il file eseguibile scritto in linguaggio macchina può immediatamente essere riconvertito nel file sorgente scritto in linguaggio assembly. Ma nel file eseguibile non vengono riportati i commenti, le etichette (label), i nomi delle variabili, rendendo il file ricostruito di difficile lettura e comprensione. Il formato di debug DWARF 2 è uno standard complesso e ben documentato per la raccolta di informazioni di debug. Il formato ELF64 è un formato appropriato per l'object file per sistemi Linux a 64-bit. L'assembler genere il codice macchina per ogni riga del linguaggio assembly presente nel file sorgente. Questo modo di procedere non funziona, però, in presenza di un'istruzione jump, che può modificare il flusso delle istruzioni (IF statement, unconditional jump):
Questa è nota come forward reference. Visto che l'assembler legge una riga dopo l'altra del file assembly, in questo caso non può aver ancora letto la definizione di skipRest. Per risolvere questo problema, l'assembler legge il file sorgente due volte (two-pass assembler). I compiti svolti nella prima lettura cambiano a seconda dell'assembler in uso, ma le operazioni di base sono comuni a tutti gli assembler:
In seconda lettura, l'assembler genera il codice macchina (operazione per la quale ha bisogno di consultare la tabella dei simboli), crea il list file, se richiesto, crea l'object file. The Yasm Modular Assembler: il List File L'assembler è anche in grado di produrre, se richiesto, un cosiddetto "list file". Il list file mostra: il numero di riga, l'indirizzo di memoria relativo, l'istruzione in linguaggio macchina (inclusi i riferimenti alle variabili), la riga originale del codice, tutte informazioni utili in fase di debug. Vediamone un esempio:
Nella prima riga, 36 è il numero di riga, 0x00000009 è l'indirizzo di memoria relativo nell'area di memoria in cui la variabile verrà scritta, 0x40660301 è il valore, in notazione esadecimale, della variabile, così come scritta in memoria, il resto è la dichiarazione scritta nel codice sorgente in assembly language. Da notare che poichè la variable dVar1 è una variable double-word, che occupa quattro byte, l'indirizzo di memoria della variable successiva sarà al quinto byte successivo: 0x0000000D, visto che dVar1 occupa gli indirizzi 0x00000009, 0x0000000A, 0x0000000B, 0x0000000C. Per quanto riguarda il valore della variabile, 0x40660301, in hex, occorre ricordare che in un'architettura little-endian il byte meno significativo viene salvato all'indirizzo di memoria più basso. Quindi, se il valore della variabile è 17.000.000 (0x01036640 in notazione esadecimale), in memoria verrà salvato nel modo seguente:
Vediamo un esempio della sezione TEXT:
Anche qui abbiamo, sulla sinistra, i numeri di riga, poi l'indirizzo relativo di memoria in cui l'istruzione viene memorizzata (0x0000005A, 00000061, 00000068), poi l'istruzione in linguaggio macchina che la CPU è in grado di leggere e comprendere. L'etichetta:
non ha una corrispondente istruzione in linguaggio macchina, poichè un'etichetta viene utilizzata come riferimento ad uno specifico indirizzo di memoria e non è un'istruzione eseguibile. Il linker, o linkage editor, crea il file eseguibile, a partire da un singolo object file o da più object file. Il linker GNU è
In questo caso abbiamo specificato il nome del file eseguibile (example), grazie all'opzione
L'opzione
Il codice in linguaggio macchina dei due file verrà copiato dai due file object nel singolo file eseguibile. Ma ... il linker dovrà anche occuparsi di sistemare i cosiddetti "relocatable addresses" (indirizzi da rilocare). Di cosa si tratta? Immaginiamo che nel file
Il list file del file object in cui c'è la chiamata alla funzione ( Il loader è quella parte del sistema operativo che preleverà il programma dal disco fisso (secondary storage), lo caricherà in memoria (primary storage), creerà un nuovo processo, marcherà il programma come "pronto per essere eseguito". Sarà lo scheduler del sistema operativo che poi deciderà quando eseguirlo. Quando scriviamo il nome del programma che abbiamo appena creato sulla riga di comando:
il loader viene implicitamente invocato. Poichè quel programma non prevede alcun output, nella nostra console non apparirà nulla. È a questo punto che può tornare utile il debugger. Il debugger permette all'utente di verificare l'esecuzione di un programma. Il file eseguibile deve essere creato, come abbiamo fatto negli esempi precedenti, utilizzando l'opzione
Il debugger utilizzato in questi esempi è GNU DDD debugger (Data Display Debugger), che mette a disposizione un'interfaccia utente a riga di comando:
Attualmente gdb riconosce C, C++, D, Objective-C, Fortran, Java, OpenCL C, Pascal, assembly, Modula-2, Go e Ada. Per lanciare il debug del file di esempio, eseguire:
Al prompt, è possibile utilizzare una lunga serie di comandi. I comandi sono divisi in classi. Per conoscere le classi di comandi, al prompt eseguire:
Per avere un elenco dei comandi di una determinata classe, eseguire, al prompt, il comando help seguito dal nome della classe:
oppure eseguire:
per ottenere la lista di tutte le classi e di tutti i comandi di ciascuna classe. Per ottenere la descrizione di un comando, eseguire help seguito dal nome del comando:
Per uscire dal debugger, eseguire, al prompt:
oppure:
Per eseguire il programma (example), eseguire il comando:
ma, in questo caso, il programma verrà eseguito interamente e il risultato verrà, alla fine, resettato e perso. Per avere il controllo dell'esecuzione del programma, è necessario impostare almeno un breakpoint. Per indicare la riga alla quale impostare il breakpoint, specificare il numero di riga, se appartenente all'attuale file in esecuzione:
oppure, il nome del file, esterno al file corrente, seguito dal nome della funzione, dal nome dell'etichetta oppure dal numero di riga:
Se la riga specificata non contiene codice eseguibile, il breakpoint verrà impostato sulla prima riga successiva, in cui c'è del codice eseguibile. Se avete creato il List File, fate riferimento ad esso per conoscere il numero di riga dell'istruzione a cui siete interessati. Naturalmente, è possibile impostare più breakpoint. Per eseguire il programma fino al primo breakpoint, eseguire:
Per eseguire il programma fino al successivo breakpoint, eseguire uno dei due comandi:
oppure, per eseguire una riga alla volta, eseguire uno dei due comandi:
Per una istruzione singola, che non invochi una funzione, questi due comandi sono equivalenti. In presenza di una funzione, invece,
Normalmente, un dato, per poter essere elaborato, deve essere spostato dalla memoria RAM ad un registro della CPU. Una volta eseguito il calcolo richiesto, il risultato dell'operazione deve essere copiato dal registro della CPU in una variabile.
L'operando sorgente (source) viene copiato nell'operando di destinazione. Gli opendi sorgente e destinazione, ovviamente, devono avere la stessa dimensione (byte, word, etc.). L'operando di destinazione non può essere un immediate. Un dato immediate è un'espressione numerica che viene codificata all'interno di un'istruzione (come il numero 42 del precedente esempio): non risiede in memoria e tantomeno in un registro della CPU, ma viene assemblata direttamente nel file object contenente le istruzioni macchina:
Entrambi gli operandi non possono essere dati di memoria. Per eseguire un'operazione da memoria a memoria sono necessarie due istruzioni. Quando il registro di destinazione e l'operando sorgente sono entrambi di 32 bit (double-word), i bit più significativi del registro quadword (64 bit) vengono impostati a zero. In questo esempio:
la porzione più significativa (upper-order portion) del registro rcx viene impostato a zero, sovrascrivendo gli uno della precedente istruzione. Una notazione interessante: l'istruzione:
copia, dalla memoria RAM, il valore della variabile bvar nel registro cl (entrambi di otto bit). Il solo modo di accedere alla memoria RAM è usare le parentesi quadre. Senza le parentesi quadre, otterremmo l'indirizzo della variabile. L'istruzione:
copia, nel registro a 64 bit rcx, l'indirizzo della variabile bvar. L'indirizzo di una variabile viene anche restituito dalla funzione lea (load effective address):
che copia l'indirizzo della variabile bvar o dvar nel registro a 64 bit rcx o rsi. La forma generale di un'addizione tra due numeri interi è la seguente:
La destinazione e la sorgente devono avere la stessa dimensione (byte, word, etc.). Il comando ADD somma gli operandi sorgente e destinazione, sovrascrivendo, con il risultato, il valore contenuto nell'operando di destinazione. Il valore contenuto in source (sorgente) non subisce modifiche. L'operando di destinazione non può essere un immediate. Entrambi gli operandi non possono essere dati di memoria. Per eseguire un'operazione da memoria a memoria sono necessarie due istruzioni. La definizione del tipo di dato (byte, word, dword, qword) può anche essere evitata, se l'altro operando (al) definisce chiaramente le dimensioni del dato. Occorre ricordare che l'istruzione ADD opera nello stesso modo, sia che si tratti di un dato con segno, sia che si tratti di un dato senza segno. È responsabilità del programmatore assicurarsi che i tipi di dato e le dimensioni siano appropriate all'operazione da eseguire! Instruction Set: INC (INCREMENT 1) La sintassi dell'istruzione INC, che somma 1 all'operando specificato, è la seguente:
Quando l'operando è in memoria RAM, è necessario specificare il tipo di dato (byte, word, dword, qword):
Instruction Set: ADC (ADD WITH CARRY) Cosa accade se incrementate di 1 una variable byte impostata a 255?
1+1, in un sistema binario, fa 10, o meglio: 0 con riporto (carry) di 1. Nel nostro esempio, 255 + 1 è uguale a 256. Ma per rappresentare 256, non bastano gli otto bit che compongono un byte! Quindi, se la nostra variabile è di un byte, l'addizione 255 + 1 comporta la perdita del riporto (che sarebbe il nono bit), mentre i restanti 8 bit restano impostati a zero. Quindi, nel rispetto del limite di un byte, 255 + 1 è uguale a zero! Il codice seguente:
assegnerà al registro AL ed alla variabile bResult il valore zero, mentre il codice:
assegnerà al registro AX (16 bit) ed alla variabile wResult (16 bit) il valore 256 (0x100 in notazione esadecimale). In questo caso, quindi, per avere un risultato corretto, è sufficiente assegnare un tipo di variabile adeguato (word). Ma cosa accade nei casi di numeri talmente grandi da eccedere le dimensioni dei registri? Un esempio:
Qui abbiamo tre variabili (dquad1, dquad2, dqSum) a 128 bit, mentre i registri sono di 64 bit. Quindi, per contenere ciascuna di queste varabili, abbiamo bisogno di due registri, quindi di due istruzioni MOV:
Nel registro RAX abbiamo inserito la Quadword meno significativa (Least Significant Quadword o LSQ) della variabile
mentre nel registro RDX abbiamo inserito la Quadword più significativa (Most Significant Quadword o MSQ) della variabile
A questo punto, per sommare le due variabili DDQ (128 bit), dovremo sommare la Quadword meno significativa (Least Significant Quadword o LSQ) della variabile
poi, la Quadword più significativa (Most Significant Quadword o MSQ) della variabile
A questo punto, i due registri a 64 bit dovrebbero, se affiancati, dare il risultato dela somma dei due numeri a 128 bit. Ma così non può essere, visto che nel primo ADD abbiamo scartato proprio il riporto (carry) di 1, risultante dall'addizione. L'istruzione ADD, infatti, non tiene conto di un eventuale riporto dell'ADD precedente. Per avere il risultato corretto, dobbiamo sostituire il secondo ADD con l'istruzione ADC (ADD con carry), che esegue la seguente operazione:
L'istruzione ADC (add with carry) deve seguire immediatamente l'ADD iniziale, così da essere certi che il registro RFLAG non venga modificato da operazioni terze, che non abbiano alcuna relazione con l'operazione corrente (in questo caso, verrebbe alterato il carry bit). La forma generale di una sottrazione tra due numeri interi è la seguente:
La destinazione e la sorgente devono avere la stessa dimensione (byte, word, etc.). Il comando SUB sottrae il valore di SRC (source) al valore contenuto nell'operando DEST (destination), sovrascrivendo, con il risultato, il valore contenuto nell'operando di destinazione. Il valore contenuto in source (sorgente) non subisce modifiche. L'operando di destinazione non può essere un immediate. Entrambi gli operandi non possono essere dati di memoria. Per eseguire un'operazione da memoria a memoria sono necessarie due istruzioni. La definizione del tipo di dato (byte, word, dword, qword) può anche essere evitata, se l'altro operando (al) definisce chiaramente le dimensioni del dato. Instruction Set: DEC (DECREMENT 1) Come per ADD esiste l'operazione INC, per SUB esiste l'istruzione DEC, che sottae 1 dall'operando specificato:
Anche con DEC, se l'operando è un operando di memoria, è necessario rendere esplicito il tipo di dato, affinchè se ne conosca l'esatta dimensione (byte, word, dword, qword).
Moltiplicazione. Una moltiplicazione normalmente produce un risultato di dimensioni doppie, rispetto alle dimensioni dei due operandi: moltiplicando due valori di dimensione n-bit, otterremo un risultato a 2n-bit. Moltiplicando due numeri a 8 bit, otterremo un risultato a 16 bit. Moltiplicando due numeri a 16 bit, otterremo un risultato a 32 bit. Moltiplicando due numeri a 32 bit, otterremo un risultato a 64 bit. Moltiplicando due numeri a 64 bit, otterremo un risultato a 128 bit. L'istruzione per eseguire una moltiplicazione cambia a seconda che si tratti di numeri senza segno (unsigned), nel cui caso si utilizzerà l'istruzione:
oppure numeri con segno (signed), nel cui caso si utilizzerà l'istruzione:
Per le moltiplicazioni con operando singolo:
il registro A (
L'utilizzo di due differenti registri è normale quando moltiplichiamo per RAX (64-bit) volte una quadword (64-bit), moltiplicazione che produce un risultato a 128 bit (double quadword), mentre un registro ha dimensione massima (almeno oggi) di 64 bit. In questo caso, l'upper-order result (la parte più alta, o significativa, del risultato) viene salvata nel registro RDX, mentre il lower-order result (la parte più bassa, o meno significativa, del risultato) viene salvato nel registro RAX. La distribuzione del risultato si scrive, per convenzione, così:
Tuttavia, l'utilizzo di due registri per il risultato di una moltiplicazione, è adottato anche per i valori di minor grandezza. Questo è un retaggio delle prime versioni dei sistemi hardware. L'utilizzo di due registri per il risultato di una moltiplicazione garantisce la piena compatibilità con i sistemi precedenti, pur potendo generare un po' di confusione. Per esempio, moltiplicando per AX volte (16-bit) un operando word (anch'esso a 16-bit) avremo un risultato in double-word (32-bit). Questo risultato non verrà salvato nel registro EAX (come sarebbe naturale), bensì distribuito in due registri: DX per l'upper-order result (16-bit) e AX per il lower-order result (16-bit):
Quindi:
|
| |||||||||||||||||||||||||
Assembly | The .bit guides: original contents |