Zoom Icon

Lezione 1 Assembly

From UIC Archive

Lezione 1: Assembly quick tutorial

Contents


Lezione 1 Assembly
Author: Spider
Email: spider_xx87 (AT) hotmail (DOT) com
Website: http://bigspider.has.it/
Date: 01/09/2008 (dd/mm/yyyy)
Level: Working brain required
Language: Italian Flag Italian.gif
Comments:



Introduzione

Scrivere l'introduzione è sempre la parte più difficile, perché va a finire sempre che si annoia il lettore con cose che già si aspetta.


In questo tutorial parleremo di linguaggio assembly (ma va!). Per un reverser, avere un'ottima dimestichezza con questo linguaggio è essenziale, perché, non avendo a disposizione i file sorgenti dei programmi che si vogliono studiare/modificare/crackare, il listato del disassembler (o del debugger) è l'unica cosa di cui si dispone.

Per chi volesse imparare a programmare in linguaggio assembly, troverà qui abbastanza informazioni per farsi un'idea di come funzionano le cose, ma certo non così tante da poter iniziare a scrivere codice. Per approfondimenti, si rimanda ai riferimenti a fine tutorial.


Il tutorial è diviso in tre parti: nella prima vedremo in sintesi l'architettura dei processori; nella seconda, più corposa e utilizzabile come un semplice manuale di riferimento, analizzeremo tutte le istruzioni principali; infine vedremo un po' di codice e cercheremo di capire cosa fa (che, in fondo, è l'attività principale di un reverser).


Prerequisiti

Sarebbe bello se chiunque potesse capire questo tutorial, ma non si può fare a meno di assumere qualche prerequisito.

Non è richiesta, da parte del lettore, alcuna conoscenza del linguaggio assembly (di nessun processore). Tuttavia, assumerò qualche esperienza di programmazione in linguaggi ad alto livello (e sarebbe quantomeno singolare, seppure non per forza insensato, voler imparare l'assembly come primo linguaggio).

È necessaria una buona familiarità con la numerazione binaria ed esadecimale e i cambiamenti da una base all'altra; a chi non avesse dimestichezza con questi concetti suggerisco di tornare qui dopo aver letto qualche tutorial a riguardo.


Bando alle ciance, iniziamo adesso ad analizzare l'architettura AMD64 (d'ora in poi semplicemente x64). Iniziamo con una prima parte un po' più teorica in cui non vedremo neanche una riga di codice; prometto che cercherò di essere breve, così magari non saltate questa parte... Nella seconda parte vedremo tutte le istruzioni di base, perciò tranquilli: il codice arriverà. Infine vedremo degli esempi di codice assembly prodotto da un compilatore, poiché imparare a comprendere il codice è l'abilità più importante di un reverser.



Essay

x86 e x64

L'architettura x64 è sostanzialmente un'estensione della precedente IA32 dell'Intel, ossia l'architettura presente in tutti i PC dall'80386 in poi (tuttora ben lontana dall'essere scomparsa). Nell'IA32 la dimensione standard degli operandi era di 32 bit (come in passato era stata di 16 bit); nell'architettura x64 i registri di uso generale (nonché gli indirizzi di memoria) sono lunghi 64 bit.


All'introduzione dei processori a 64 bit, l'AMD64 non era l'unica concorrente in gioco, dal momento che l'Intel proponeva una sua IA64 completamente diversa, che però rinunciava alla backward compatibility (cioè la compatibilità con i processori precedenti). È noto quanto, nell'industria informatica, la sacralità di questo principio non si possa mettere in discussione; l'AMD64 ha preso piede e, ormai, anche l'Intel ha reso sostanzialmente compatibili i propri processori (l'implementazione Intel dell'AMD ha tanti nomi: EM64T, Intel IA32e, Intel 64).

D'ora in poi userò i nomi x64 ed x86 indicare, rispettivamente, l'AMD64, l'IA32. Il termine x86-16 sarà usato per indicare l'architettura a 16 bit dei processori intel precedenti all'IA32 (cioè dall'Intel 8086 all'Intel 80286).

Il bit e i suoi multipli

Conoscono anche i bambini l'equivalenza 1 byte = 8 bit. I multipli del byte più comunemente usati sono i seguenti:


word = 2 byte = 16 bit
doubleword (dword) = 2 word = 4 byte = 32 bit
quadword (qdord) = 2 dword = 4 word = 8 byte = 64 bit


Più raramente si sente parlare di oword (octaword), cioè 2 quadword, chiamata anche double quadword; qualche volta di fword (6 bytes) o addirittura di tbyte (ten bytes, 10 bytes).


Interi senza segno e con segno

Gli interi possono essere di due tipi: senza segno (unsigned integers) o con segno (signed integers). Le dimensioni degli interi possono essere di 8, 16, 32, 64 bit; più raramente, 128 bit (nelle moltiplicazioni e divisioni in x64).
Un intero senza segno a n bit può rappresentare tutti i numeri compresi fra 0 e 2n - 1. Perciò un intero unsigned a 8 bit vale al massimo 255, a 16 bit vale al massimo 65535, a 32 bit oltre 4 miliardi, a 64 bit più di 18 miliardi di miliardi. La rappresentazione binaria è quella usuale, per cui il numero più grande che si può rappresentare è quello formato da tutti i bit 1.

Un intero con segno ad n bit, invece, può rappresentare tutti i numeri compresi fra -2n-1 e 2n-1 - 1. Perciò a 8 bit si va da -128 a +127, a 16 bit da -32768 a +32767, a 32 bit da un numero più piccolo di -2 miliardi a oltre 2 miliardi, a 64 bit da un numero inferiore a -9 miliardi di miliardi a oltre 9 miliardi di miliardi.
Gli interi con segno utilizzano una rappresentazione detta complemento a 2. La rappresentazione dei numeri positivi (e dello zero) rimane la stessa; tuttavia, poiché i numeri positivi sono limitati a metà dei possibili valori a n bit, tutti (e soli) i numeri non negativi hanno il bit più significativo (quello "più a sinistra") uguale a 0; i numeri con il bit più significativo 1 sono, appunto, i numeri negativi. Il bit più significativo è chiamato, per questo motivo, bit del segno. Negli interi a 8, 16, 32, 64 bit, il bit del segno ha, rispettivamente, indice 7, 15, 31, 63.
La rappresentazione binaria di un numero negativo tramite complemento a 2 è molto semplice da ottenere: per rappresentare in binario il numero negativo -x, basta scrivere x, invertire tutti i suoi bit, infine aggiungere 1. Ad esempio, lavorando con 8 bit, supponiamo di voler scrivere -6. La rappresentazione binaria di 6 è 00000110 (scrivo anche i bit iniziali nulli per sottolineare che stiamo usando 8 bit). Invertendo tutti i bit si ottiene 11111001; aggiungendo 1 si ottiene 11111010. Questa è la rappresentazione binaria a 8 bit di -6. Notiamo una proprietà importante: sommando le rappresentazioni di 6 e di -6 si ottiene 100000000, che in decimale equivale a 256; questo numero ha 9 bit, perciò se tronchiamo agli 8 bit meno significativi otteniamo 0 (poiché la dimensione delle operazioni del processore ha sempre un numero fissato di bit, il troncamento è implicito). Il fatto che la somma di due numeri opposti faccia 0 è fondamentale, e proprio in questo consiste l'utilità del complemento a 2.
Conviene acquisire una buona dimestichezza con questi concetti.

Zero-extension e sign-extension

Le operazioni aritmetiche tra numeri rappresentati con lo stesso numero di bit si effettuano alla maniera usuale; capita però spesso di dover effettuare operazioni tra operandi di dimensione diversa. In questo caso, l'operando di dimensione più piccola deve essere convertito (implicitamente o esplicitamente) alla dimensione giusta.

Nel caso di operazioni tra interi unsigned, ciò è banale: basta anteporre al numero di dimensione minore un tanti bit 0 quanti ne servono per arrivare alla dimensione giusta: la rappresentazione di 6 a 8 bit è 00000110, a 16 bit è 0000000000000110. Tale procedimento si chiama zero-extension.

Nel caso di interi signed, tuttavia, ciò non funziona. Più precisamente, funziona solo per i numeri positivi. Ma la rappresentazione di -6 è 11111010, con la zero-extension si ottiene 0000000011111010, cioè 250 decimale: non esattamente la stessa cosa. Il motivo sta nel significato speciale del bit del segno e nel funzionamento del complemento a 2. La rappresentazione di -6 a 16 bit è 1111111111111010, estendendo cioè quella a 8 bit con cifre 1 anziché con 0. In generale, l'estensione di interi con segno si effettua riempiendo tutti i bit aggiuntivi con valori uguali al bit del segno. Tale meccanismo si chiama sign-extension.


I registri

All'interno della CPU vi sono un gran numero di registri per le funzioni più disparate. Un registro è soltanto una cella di memoria, la più veloce contenuta nel processore. Essi sono utilizzati per memorizzare lo stato del processore, modificato continuamente come effetto dell'esecuzione delle istruzioni.


Vediamo prima come è composto il set di registri dell'architettura x86, per poi vedere come esso è stato esteso in x64.

Registri x86

Un primo gruppo di registri è costituito dai General Purpose Registers (GPRs), cioè i registri di uso generale, chiamati così perché, idealmente, possono essere usati per qualsiasi scopo.


Il formato generale di un registro di uso generale (prendiamo come esempio EAX) è il seguente:

EAX
AX
AHAL

Le righe andrebbero immaginate come sovrapposte: l'intero registro è EAX, i 16 bit meno significativi ("a destra" nella rappresentazione numerica) formano AX, gli 8 bit meno significativi formano AL (la lettera L sta per Low). Gli 8 bit più significativi di AX formano invece il registro AH (dove H sta per High).


Vi sono 8 registri a 32 bit di uso generale: EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP. La E dei nomi è stata aggiunta con l'introduzione dell'architettura IA32 come estensione della precedente a 16 bit.


Spesso si usa indicare i registri tutti in maiuscolo o tutti in minuscolo, mentre la notazione eAX con la prima lettera minuscola indica indifferentemente AX o EAX. Analogamente si usano le notazioni eBX, eCX, e così via.


EBX, ECX ed EDX hanno lo stesso formato di EAX visto poco sopra, perciò i sottoregistri di EBX sono BX, BH, BL, quelli di ECX sono CX, CH, CL, quelli di EDX, infine, sono DX, DH, DL.


Normalmente ciascuno di questi registri può essere utilizzato per qualsiasi scopo, anche se vi sono un certo numero di eccezioni: le analizzeremo a tempo debito. Il registro EAX è chiamato anche accumulatore, più per motivi storici che per altro, sebbene vi siano un certo numero di situazioni in cui il suo uso è necessario e altre in cui è preferibile a quello di altri registri. In misura minore, ciò è vero anche per EBX, ECX, EDX.


Per gli altri registri General Purpose a 32 bit (ESI, EDI, EBP, ESP) non è possibile accedere a sottoregistri di 8 bit, mentre i sottoregistri di 16 bit sono, nell'ordine, SI, DI, BP, SP.


ESI ed EDI (i cui acronimi stanno per Source Index e Destination Index) possono essere usati per qualsiasi scopo come i precedenti, ma hanno un supporto speciale per l'uso come indici per le operazioni su stringhe (scansione, copia, confronto). I rispettivi sottoregistri a 16 bit sono SI e DI, mentre non vi sono sottoregistri a 8 bit (come già puntualizzato).


EBP ed ESP (Base Pointer e Stack Pointer) sono utilizzati nella gestione dello stack (approfondiremo più avanti). ESP indica la posizione corrente nello stack, mentre EBP punta solitamente alla base dello stack, ossia, per intenderci, alla zona di memoria dove si trovano i dati locali di una funzione (le normali variabili di procedura, non visibili all'esterno). In realtà nulla vieta di usare EBP per altri scopi, ma ciò non è molto frequente. Le controparti a 16 bit sono BP ed SP.


In definitiva, l'insieme dei General Purpose Registers dell'architettura x86 prevede:


  • 8 registri di un byte (AH, AL, BH, BL, CH, CL, DH, DL);
  • 8 registri di una word (AX, BX, CX, DX, SI, DI, BP, SP);
  • 8 registri di 1 dword (EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP).

Un registro a parte è EIP (con il sottoregistro IP), l'Instruction Pointer: esso punta sempre alla prossima istruzione da eseguire. EIP non si può leggere o scrivere come gli altri registri, ma può essere letto indirettamente o modificato con le istruzioni di controllo del flusso che vedremo in seguito.


Un registro molto importante è il registro EFLAGS. Questo registro è un campo di bit, formato da un gran numero di flags, ossiavalori binari (0 oppure 1) con scopi disparati. Ecco uno schema:

|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13 12|11|10|09|08|07|06|05|04|03|02|01|00|

                               |  |  |  |  |  |     |    |    |  |  |  |  |  |     |     |     | 
 ID FLAG(ID) ------------------+  |  |  |  |  |     |    |    |  |  |  |  |  |     |     |     | 
 Virtual Interrupt Pending -------+  |  |  |  |     |    |    |  |  |  |  |  |     |     |     | 
 Virtual Interrupt Flag -------------+  |  |  |     |    |    |  |  |  |  |  |     |     |     | 
 Alignment Check -----------------------+  |  |     |    |    |  |  |  |  |  |     |     |     | 
 Virtual-8086 Mode ------------------------+  |     |    |    |  |  |  |  |  |     |     |     | 
 Resume Flag ---------------------------------+     |    |    |  |  |  |  |  |     |     |     | 
 Nested Task ---------------------------------------+    |    |  |  |  |  |  |     |     |     | 
 I/O Privilege Level ------------------------------------+    |  |  |  |  |  |     |     |     | 
 Overflow Flag -----------------------------------------------+  |  |  |  |  |     |     |     | 
 Direction Flag -------------------------------------------------+  |  |  |  |     |     |     | 
 Interrupt Enable Flag ---------------------------------------------+  |  |  |     |     |     | 
 Trap Flag ------------------------------------------------------------+  |  |     |     |     | 
                                                                          |  |     |     |     | 
 Sign Flag ---------------------------------------------------------------+  |     |     |     | 
 Zero Flag ------------------------------------------------------------------+     |     |     | 
 Auxiliary Carry Flag -------------------------------------------------------------+     |     | 
 Parity Flag ----------------------------------------------------------------------------+     | 
 Carry Flag -----------------------------------------------------------------------------------+ 

A parte un certo numero di flags utili, di fatto, solo per la programmazione di sistema, ve ne sono 6 (gli status flags) che vengono impostati ad 1 oppure 0 a seconda del risultato di molte istruzioni. È fondamentale conoscerli.


Carry Flag (CF, bit 0) - Flag di riporto. Viene messo ad 1 quando c'è un riporto (nel caso di una somma) o un prestito (nel caso di una sottrazione) dal bit più significativo di una operazione. Ad esempio, EAX contiene il valore esadecimale 0x9000000 e si esegue l'istruzione ADD EAX, EAX, il risultato (0x120000000) viene troncato ai 32 bit meno significativi (0x20000000), e la presenza del riporto viene segnalata dal processore impostando il Carry Flag ad 1. Nelle operazioni di shift o di rotazione, invece, il significato è diverso, ma lo vedremo più avanti.


Parity Flag (PF, bit 2) - Flag di parità. Viene settato quando il byte meno significativo del risultato di molte operazioni contiene un numero pari di bit 1. Viene generalmente usato nei sistemi di trasmissione dati come sistema di controllo; per chi è alle prime armi può essere sufficiente conoscerne l'esistenza. L'uso effettivo è piuttosto raro.


Auxiliary Carry Flag (AF, bit 4) - Viene impostato ad 1 quando c'è un riporto o un prestito dal terzo bit di una operazione di tipo BCD (Binary Coded Decimal), azzerato in caso contrario. L'uso è raro, questo flag può essere certamente ignorato dai novizi.


Zero Flag (ZF, bit 6) - Viene impostato ad 1 se il risultato di una operazione è zero, altrimenti viene azzerato.


Sign Flag (SF, bit 7) - Flag del segno. Viene messo ad 1 se, dopo un'operazione aritmetica, il bit più significativo (il bit del segno) è 1. In caso contrario viene azzerato.


Overflow Flag (OF, bit 11) - Viene messo ad 1 quando il risultato di un'operazione è troppo grande (overflow) o troppo piccolo (underflow) per essere contenuto nel registro di destinazione; è azzerato altrimenti. Più precisamente, esso viene impostato ad 1 se il bit del segno (il più significativo) del risultato è diverso da quello di entrambi gli operandi. Nel caso di somme o sottrazioni è facile convincersi che questa condizione equivale ad un overflow/underflow.


L'importanza degli status flags risulterà chiara quando analizzeremo le istruzioni di controllo del flusso (ma sono utili anche per altre istruzioni).


Un altro flag degno di nota (classificabile come control flag, dal momento che altera il comportamento del processore) è il Direction Flag (DF, bit 10). Esso consente di decidere il verso in cui vengono effettuate le operazioni su stringa, cioè se i registri eSI ed eDI vengono incrementati oppure decrementati ad ogni ripetizione. Le operazioni su stringhe non saranno trattate.


Registri speciali per la gestione della memoria sono i Segment Registers, registri a 16 bit contenenti un selettore di segmento. I segmenti permettono di gestire i modelli di memoria segmentati (appunto...), che sono stati tipicamente usati nei sistemi multiprocesso per isolare ogni processo dagli altri in maniera trasparente. Essi sono CS (Code Segment), DS (Data Segment), ES (Extra Segment), FS, GS, SS (Stack Segment). Il Code Segment è il segmento contenente il codice, mentre gli altri sono segmenti per i dati; DS è quello predefinito per la maggior parte delle istruzioni (gli altri, per essere usati, devono di solito essere indicati esplicitamente all'assemblatore, che li codificherà opportunamente nel codice macchina). SS è il segmento che contiene lo stack. FS e GS sono segmenti dati ulteriori i cui nomi non hanno un significato particolare e vanno solo a continuare la sequenza alfabetica; sono stati introdotti nei processori Intel 80386.


Altri registri adibiti per usi avanzati sono i Debug Registers (da DR0, DR1, DR2, DR3, DR6 e DR7), utilizzati principalmente dai debugger, e i Control Registers (da CR0 a CR4), utilizzati solo a livello di sistema operativo e mai dagli applicativi normali.

Registri x64

L'architettura AMD64 è un'estensione dell'IA32 pensata per mantenere la retrocompatibilità. I processori x64 possono eseguire applicativi a 32 bit in sistemi operativi a 64 bit, ma possono addirittura comportarsi come processori a 32 bit (ed eseguire quindi un sistema operativo a 32 bit), sebbene ciò potrebbe non essere il modo migliore di adoperare il proprio denaro. Tuttavia, per sfruttare realmente le potenzialità dell'architettura x64 è necessario compilare i programmi appositamente per questa architettura e possedere un sistema operativo a 64 bit, in modo che il processore possa camminare a 64 bit.

Nella modalità a 64 bit, i processori x64 mettono a disposizione una serie di estensioni.

Tutti i General Purpose Registers, l'Instruction Pointer, il registro dei flags sono ampi 64 bit anziché 32.

Vengono aggiunti 8 GPRs, per un totale di 16. La carenza di registri è stata uno dei motivi principali per ripensare l'architettura nel passaggio da x86 a x64.

I registri diventano RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP. Gli 8 registri estesi sono semplicemente numerati da R8 a R15. Per ogni registro si può accedere anche ai sottoregistri di 32-, 16-, 8-bit contenuti nella parte meno significativa. Da RAX a RSP i nomi sono quelli già visti (es.: EAX, AX, AL), ma diventano indirizzabili anche gli 8 bit meno significativi di RSI, RDI, RBP, RSP, con i nomi SIL, DIL, BPL, SPL rispettivamente. I sottoregistri a 32, 16, 8 bit dei registri da R8 a R15 si denominano aggiungendo i suffissi D, W, B rispettivamente, che stanno naturalmente per Dword, Word e Byte. Ad esempio il sottoregistro a 16 bit di R8 si chiama R8W.

Si può ancora accedere ai registri AH, BH, CH e DH, ma non è possibile mischiare in una singola istruzione questi registri con quelli estesi (cioè quelli non esistenti nell'architettura x86).

Schematizzando, i GPRs diventano:

  • 16 registri a 64 bit: RAX, RBX, RCX, RDX, RBP, RSI, RDI, RSP, R8, R9, R10, R11, R12, R13, R14, R15;
  • 16 registri a 32 bit: EAX, EBX, ECX, EDX, EBP, ESI, EDI, ESP, R8D, R9D, R10D, R11D, R12D, R13D, R14D, R15D;
  • 16 registri a 16 bit: AX, BX, CX, DX, BP, SI, DI, SP, R8W, R9W, R10W, R11W, R12W, R13W, R14W, R15W;
  • 16 registri formati dagli 8 bit meno significativi dei registri: AL, BL, CL, DL, BPL, SIL, DIL, SPL, R8B, R9B, R10B, R11B, R12B, R13B, R14B, R15B;
  • 4 registri di 8 bit nei bit più significativi di AX, BX, CX e DX, ossia: AH, BH, CH, DH.

Anche il registro EIP è esteso in RIP, ed il registro EFLAGS è esteso in RFLAGS.


A differenza dell'architettura x86, x64 prevede la possibilità di utilizzare RIP per l'indirizzamento, cioè di leggere o scrivere su locazioni di memoria che distano da RIP fino ad un massimo di 2 GB. Vedremo più avanti i dettagli sull'indirizzamento relativo a RIP.


La notazione rAX si usa spesso per denotare indifferentemente RAX, EAX oppure AX; notazioni affini esistono ovviamente per tutti gli altri registri.


I registri di segmento non sono quasi più usati in modalità a 64 bit, dal momento che la gestione della memoria tramite segmentazione diventa superflua se la dimensione dei puntatori è di 64 bit (che significa uno spazio di indirizzamento di 264 bytes, cioè veramente tanti). Vengono considerati solo i registri CS, FS e GS, ma in maniera molto meno essenziale che in x86. Gli altri Segment Registers sono semplicemente ignorati.

Operazioni a 32 bit in x64

In x64, la dimensione predefinita degli operandi è, per la maggior parte delle istruzioni, 32 bit. Le codifiche in linguaggio macchina delle versioni a 64 bit delle istruzioni richiedono infatti l'aggiunta di uno speciale prefisso alla codifica dell'istruzione; questo prefisso abilita l'accesso al set esteso di registri.

A differenza delle operazioni sui sottoregistri a 8 e 16 bit, che agiscono solo sul sottoregistro lasciando invariati gli altri 56 o 48 bit, le modifiche ai registri a 32 bit azzerano i 32 bit più significativi del registro di appartenenza (in gergo tecnico, vengono zero-extended). Ad esempio, supponendo che lo stato iniziale sia il seguente:


    RAX = 0002_0001_8000_2201 
    RBX = 0002_0002_0123_3301


Supponendo di eseguire le varianti a 64, 32, 16 e 8 bit di ADD (l'istruzione che somma il secondo operando al primo, salvando il risultato nel primo), si hanno le seguenti possibilità:

    ADD RBX, RAX   Risultato: RBX = 0004_0003_8123_5502 
    ADD EBX, EAX   Risultato: RBX = 0000_0000_8123_5502 
    ADD BX, AX     Risultato: RBX = 0002_0002_0123_5502 
    ADD BL, AL     Risultato: RBX = 0002_0002_0123_3302 

La seconda riga è evidenziata per sottolineare il diverso comportamento della versione a 32 bit rispetto alle altre.

Memoria

Dal punto di vista dell'applicativo, la memoria può essere vista semplicemente come una serie di bytes contigui, ciascuno con un proprio indirizzo, che è possibile leggere o scrivere. In realtà, un indirizzo contenuto in un puntatore è un indirizzo virtuale (virtual address) che deve passare attraverso delle fasi di traduzione per ottenere l'indirizzo fisico (physical address), cioè l'indirizzo reale in memoria. La traduzione avviene in modo trasparente per le applicazioni.


Segmentazione

Nei modelli di memoria che sfruttano la segmentazione, ogni accesso in memoria specifica (esplicitamente o implicitamente) anche un selettore di segmento (contenuto in un Segment Register), utilizzato dall'hardware per conoscere dove il segmento si trova in memoria (base), quant'è grande (limit) e il tipo di accesso consentito. Qualora l'accesso sia al di fuori della dimensione del segmento o sia di un tipo non consentito (es.: scrittura di un segmento in sola lettura), l'hardware genera un'eccezione per segnalare l'anomalia (che, se non gestita opportunamente, provoca il crash, cioè la chiusura forzata da parte del sistema operativo).

La segmentazione era molto in voga nei vecchi sistemi, ma è quasi in disuso nei sistemi x86 moderni, che utilizzano un modello di memoria flat, con segmenti aventi base 0 e limit 4 GB, che equivale sostanzialmente a disabilitare la segmentazione; per questo motivo è stata quasi del tutto rimossa dall'architettura x64.


Paging

Il paging (paginazione), invece, fornisce un sistema di protezione più granulare, consentendo di specificare i permessi per ogni pagina, cioè un blocco di memoria ampio, di solito, 4 kb (4096 bytes). Permette inoltre di rimappare in qualsiasi modo la memoria fisica: indirizzi che l'applicazione crede consecutivi (nell'unico spazio che conosce, quello virtuale) possono essere in qualsiasi posizione nella RAM o, addirittura, possono essere stati rimossi e memorizzati nell'hard disk per far spazio a qualcos'altro, e saranno riportati in RAM quando l'applicazione vorrà accedervi nuovamente (è grazie al paging i sistemi operativi moderni possono "emulare" più RAM di quanta ce ne sia realmente, seppur con un degrado delle prestazioni). Tutto ciò avviene in maniera trasparente all'applicazione.


Ordine dei bytes

Nel caso di dati di dimensione di un byte (o per dati compositi come le stringhe ASCII, che sono semplicemente array di bytes) vi è un solo ordine possibile in memoria: ogni byte ha la sua posizione univoca.

Tuttavia, nel caso di dati di dimensione word (2 bytes), dword (4 bytes) o qword (8 bytes) vi sono due convenzioni principali: big-endian e little-endian.


Nella convenzione big-endian, i bytes sono scritti dal più significativo al meno significativo, in modo che la loro semplice concatenazione formi il dato completo. Ad esempio, se all'indirizzo di memoria 0x402000 si trova la dword 0x11223344, in memoria si avrebbero, nell'ordine, i bytes 0x11 (all'indirizzo 0x402000), 0x22 (all'indirizzo 0x402001), 0x33 (all'indirizzo 0x402002), 0x44 (all'indirizzo 0x402003). La notazione big-endian è lo standard per le comunicazioni in rete, ma è usata anche in alcuni processori.


Nella convenzione little-endian, invece i bytes di un dato multiplo del byte sono scritti dal meno significativo al più significativo, cioè in ordine inverso in memoria. La dword 0x11223344 all'indirizzo 0x402000 sarebbe memorizzata così: 0x44 (all'indirizzo 0x402000), 0x33 (all'indirizzo 0x402001), 0x22 (all'indirizzo 0x402002), 0x11 (all'indirizzo 0x402003).

Tutti i processori x86 e x64 utilizzano la convenzione little-endian, perciò bisogna abituarsi ai bytes invertiti.

Un indubbio vantaggio della notazione little-endian è questo: uno stesso dato è rappresentato contemporaneamente e correttamente sotto diverse dimensioni. Ad esempio, la dword 0x00000055 all'indirizzo 0x402000 diventa: 55 00 00 00. Leggendo la word al medesimo indirizzo si legge: 55 00, cioè 0x0055 in little-endian; leggendo un byte si legge 0x55. Ciò può risultare molto comodo per chi programma in assembly.


Qualche processore ha adottato anche notazioni più complicate (mixed-endian). Si sa, non c'è limite alla follia umana.


Giusto per curiosità: i nomi little-endian e big-endian sono tratti dai Viaggi di Gulliver di Jonathan Swift, dove si riferivano all'estremità piccola (little end) e quella grande (big end) dell'uovo, oggetto di contesa tra i due popoli in guerra di Lilliput e Blefuscu che rompevano le uova da estremità diverse (ok, la smetto di rompervi le... uova con queste notizie!).


Stack

Lo stack è un'area di memoria a disposizione di ogni processo per salvare informazioni temporanee.

Uno stack è una struttura dati di tipo LIFO (Last In First Out), cioè una struttura in cui le informazioni sono rimosse in ordine inverso rispetto a quello di inserimento. Il puntatore all'ultimo elemento inserito nello stack è contenuto nel registro rSP (cioè in RSP se siamo in x64, ESP in x86, SP nella vecchia x86-16). Nei modelli di memoria segmentati in x86, è utile sapere che lo stack è contenuto nel segmento SS e che i tutti riferimenti che contengono rBP o rSP vengono fatti di default attraverso il segmento SS.


Lo stack può essere usato per conservare dei dati in un registro per poter essere utilizzati in seguito, in modo da liberare il registro per altri scopi. Per via della struttura LIFO, i dati vengono ripristinati in ordine inverso rispetto a come sono stati inseriti. Perciò se ho salvato, nell'ordine, RAX, RSI ed R14, poi ripristinerò prima R14, poi RSI ed infine RAX. Vedremo più avanti le istruzioni PUSH e POP che si usano per manipolare lo stack.

Un'altra funzione dello stack è quella di gestire il controllo di flusso nelle chiamate di procedura. Quando viene chiamata una procedura, infatti, è necessario salvare l'indirizzo dell'istruzione da cui deve ripartire l'esecuzione al termine della procedura. Questo indirizzo di ritorno (return address) viene implicitamente salvato nello stack dall'istruzione CALL per le chiamate a procedura, e viene rimosso dall'istruzione RET che è sempre l'ultima di una procedura.

Anche i parametri passati ad una procedura vengono a volte immessi nello stack dal chiamante (altre volte vengono, invece, passati tramite i registri... basta mettersi d'accordo!). Infine, è nello stack che viene riservato lo spazio per le variabili locali di una procedura (quelle che esistono solo durante l'esecuzione della procedura stessa e cessano di esistere al suo completamento).

Torneremo più avanti a parlare più in dettaglio di stack, delle istruzioni PUSH e POP, delle istruzioni di controllo del flusso e delle convenzioni di chiamata.

Il linguaggio assembly

Iniziamo finalmente a parlare del linguaggio assembly.


La sintassi che verrà utilizzata in questo tutorial è quella dell'assembler MASM; non è tuttavia difficile adattare la sintassi a quella di altri assemblatori, nel qual caso si rimanda ai rispettivi manuali per conoscere le differenze.


Sintassi di base

La sintassi delle istruzioni assembly è in generale molto semplice. Ogni istruzione ha un nome che viene detto mnemonico, perché sostanzialmente serve solo a non ricordare la codifica binaria in linguaggio macchina. Il codice numerico che rappresenta l'istruzione è detto opcode (contrazione di operative code).

Ogni istruzione può avere 0, 1 oppure 2 operandi; in rari casi 3. L'istruzione completa, in una riga a sé stante nel file sorgente, è formata dallo mnemonico e dalla lista degli operandi. Tra lo mnemonico e la lista degli operandi, naturalmente, ci vuole almeno uno spazio; gli operandi sono invece separati da virgole. Ad esempio l'istruzione ADD ha due parametri, che (fra le altre cose) possono essere due registri. Un'istruzione valida è:

    add rax, rbx

Questa istruzione somma il registro rbx al registro rax, salvando il risultato in rax. Possiamo già sottolineare una costante di tutte le istruzioni con due operandi: se ha senso parlare di sorgente e destinazione (cioè l'operando in cui il processore salva il risultato), la sintassi MASM prevede che la destinazione sia sempre il primo operando (esistono assemblatori che adottano la sintassi AT&T con gli operandi invertiti; sono più usati in ambiente Linux). Quindi molte istruzioni saranno del tipo:

mnemonic dst, src

Il numero ed il tipo degli operandi sono specificati dettagliatamente nei manuali (vedi bibliografia), ma per tutte le istruzioni di base li vedremo più avanti.


Tipi di operandi e sinossi

Le istruzioni possono richiedere diversi tipi di operandi. I principali sono i seguenti: un registro, un valore in memoria, un immediato (cioè una costante numerica) o un indirizzo relativo (cioè che va sommato all'Instruction Pointer).


Gli operandi possono essere di varie dimensioni, per cui in seguito, quando descriveremo la sinossi delle istruzioni specificheremo sempre quali sono quelle consentite.


Iniziamo con alcune osservazioni di carattere generale sull'assembly e sul formato delle istruzioni; questa sezione prosegue quindi con una lunga introduzione alle istruzioni assembly: analizzeremo, con un certo grado di dettaglio, una quarantina di istruzioni assembly: un sottoinsieme relativamente piccolo rispetto alle centinaia di istruzioni esistenti, ma che copre quasi quasi tutte le istruzioni che vi capiterà di incontrare nella pratica.

Non preoccupatevi di memorizzare tutto subito, non è richiesto; ciò è possibile (e in realtà abbastanza facile) solo con la pratica. Ma non abbiate fretta di passare alla sezione successiva.


Formato degli operandi in memoria

Gli operandi in memoria sono indicati, in MASM ma anche nel listato di molti disassemblatori, racchiusi tra parentesi quadre. Ad esempio, l'istruzione MOV RAX, [RBX] copia in RAX la qword contenuta all'indirizzo di memoria puntato da RBX. Osserviamo che il secondo operando non contiene nessuna informazione sulla dimensione; tuttavia, nell'istruzione MOV i due operandi hanno sempre la stessa dimensione, quindi la dimensione deve essere la stessa di RAX, cioè 64 bit. In altri casi, invece, ci sarebbe ambiguità; ad esempio l'istruzione INC [RBX], che incrementa il valore puntato da EBX, esiste nella versione a 8, 16, 32, 64 bit. In questo caso, sia in x86 che in x64 la dimensione predefinita è 32 bit. Per indicare una dimensione diversa da quella predefinita, si antepone all'operando un'espressione come "byte ptr", "word ptr", "dword ptr" o "qword ptr". Ad esempio INC byte ptr [ebx] incrementa il byte puntato da ebx, INC qword ptr [ebx] incrementa una qword, e così via.

Il formato di indirizzamento generale di un operando in memoria (cioè quello che può essere contenuto tra parentesi quadre) in x86 è il seguente:

base + index*scale + displacement

Dove base ed index sono due registri a 32 bit (ma index non può essere ESP), lo scale factor può essere 1, 2, 4 o 8 (nel caso sia 1, ovviamente, non viene indicato), il displacement è un intero con il segno a 8, 16 oppure 32 bit. Ad esempio:

    mov rdx, qword ptr [ebx + ecx*8 + 24]

In x64 si può usare lo stesso tipo di indirizzamento con base, index, scale e displacement; base e index sono di solito general purpose registers qualsiasi a 64 bit, ma si possono usare anche registri a 32 bit (ma non uno a 32 e uno a 64 bit, ovviamente), nel qual caso si può indirizzare solo una parte dello spazio degli indirizzi (4 GB). Il displacement rimane comunque un intero con segno di 32 bit al massimo, per cui non è più possibile una scrittura del tipo mov eax, [address] dove address è un indirizzo assoluto in memoria.

x64 introduce una forma di indirizzamento non presente in x86: l'indirizzamento relativo a RIP (detto in italiano non è un granché, gli anglofoni dicono RIP relative addressing). In questo caso, si ha a disposizione solo un displacement di 32 bit che il processore aggiunge al RIP dell'istruzione successiva. In questo modo si possono indirizzare dati che distano fino a 2 GB da RIP, che di solito è sufficiente per le normali applicazioni (l'immagine di un eseguibile non supera praticamente mai queste dimensioni!). Un vantaggio di questa forma di indirizzamento è quello di rendere il codice indipendente dalla sua effettiva posizione in memoria, mentre con gli indirizzi assoluti un'eventuale rilocazione (cioè il caricamento in un indirizzo diverso da quello previsto) richiede al loader di correggere tutti gli indirizzi assoluti (anche se, in realtà, quest'operazione non è mai stata particolarmente costosa).


Allineamento dei dati

I dati in memoria dovrebbero sempre essere allineati. Un dato è correttamente allineato quando il suo indirizzo in memoria è multiplo della sua dimensione (mi riferisco qui unicamente ai dati la cui dimensione è potenza di 2). Perciò per un byte non ci sono mai problemi, ma una word dovrebbe essere in un indirizzo divisibile per 2 (cioè con l'ultimo bit 0), una dword in un indirizzo divisibile per 4 (gli ultimi due bit sono 0), una qword ad un indirizzo divisibile per 8 (ultimi tre bit 0), e così via.


Il motivo per cui è conveniente allineare i dati è il guadagno in efficienza: così il processore vi accede più rapidamente. L'accesso a una dword non allineata, ad esempio, obbliga il processore a fare due richieste alla memoria anziché una.

In Windows a 64 bit, tuttavia, l'allineamento dello stack è obbligatorio nelle chiamate API, perciò diventa più di un mero problema di prestazioni.


L'istruzione MOV

L'istruzione MOV è una delle principali e più comuni istruzioni assembly. Il formato generale è il seguente:

    MOV dst, src

e quello che l'istruzione fa è copiare in dst i dati che sono contenuti in src. I due operandi sono necessariamente della stessa dimensione.


Nella forma più semplice, dst e src sono registri oppure un registro e un operando in memoria (ma non due operandi in memoria):

    mov rcx, r12   ;copia r12 in rcx
    mov rcx, qword ptr[esp] ;copia in rcx la qword in cima allo stack
    mov byte ptr[rsi], r12b ;copia il byte meno significativo di r12
                            ;all'indirizzo puntato da rsi

Se dst è un registro a 8, 16, 32 o 64 bit, src può anche essere un immediato della stessa dimensione. Se dst è un operando in memoria di 8, 16, 32 bit, src può essere un immediato della stessa dimensione, mentre se dst è un operando in memoria a 64 bit, src può ancora essere un immediato con segno a 32 bit che viene esteso col segno (sign-extended) a 64 bit e copiato in dst.

    mov al, 12h    ;scrive un numero a 8 bit in al
    mov si, 1234h  ;scrive un numero a 16 bit in si
    mov ecx, 11223344h ;scrive un numero a 32 bit in ecx
                       ;(e azzera i 32 bit più significativi di rcx!)
    mov r14, 1122334455667788h ;scrive in r14 un numero a 64 bit
    mov byte ptr[ebp], 64h ;inizializza un byte in memoria
    mov word ptr[ebp], 12345 ;inizializza una word in memoria
    mov dword ptr[ebp], 401000h ;inizializza una dword in memoria
    mov qword ptr[ebp], -5 ;inizializza una qword in memoria
                           ;con un intero con segno a 32 bit
    mov qword ptr[ebp], 100000000h ;SBAGLIATO! Il secondo operando
                                   ;non può essere interpretato come
                                   ;un intero con segno a 32 bit!

Se dst è AL, AX, EAX o RAX, si può anche leggere un valore in memoria ad un dato indirizzo a 64 bit (ovviamente, in x86 si può fare solo con un indirizzo a 32 bit). MOV è l'unica istruzione che può avere come operando un valore in memoria con un indirizzo a 64 bit.

    mov al, byte ptr[1122334455667788h]   ;legge un byte in memoria
    mov rax, qword ptr[1122334455667788h] ;legge una qword in memoria
    mov rbx, qword ptr[1122334455667788h] ;SBAGLIATO!

Naturalmente, nella pratica succede molto raramente di scrivere l'indirizzo in questo modo, visto che si può usare il RIP-relative addressing. In x86, invece, ciò è abbastanza frequente per l'accesso ai dati globali.

Viceversa, se src è AL, AX, EAX o RAX, dst può essere un valore in memoria di cui viene dato l'indirizzo (es.: mov byte ptr[1122334455667788h], al).


Esistono anche varianti dell'istruzione MOV per leggere o scrivere i Segment Registers, i Debug Registers o i Control Registers.


L'istruzione MOV non modifica il registro RFLAGS.

Le istruzioni PUSH e POP

PUSH e POP sono le due principali istruzioni per manipolare lo stack. Entrambe prendono un parametro; PUSH serve ad inserire qualcosa nello stack, POP a prelevarlo.


L'istruzione PUSH decrementa il registro rSP di un valore pari a 8 in x64, a 4 in x86; quindi salva il valore dell'operando all'indirizzo puntato da rSP. L'operando di PUSH può essere un registro, un valore in memoria o un immediato. In x64, l'immediato non può essere di 64 bit, ma può essere un intero con segno a 32 bit che viene sign-extended e messo nello stack come valore a 64 bit.


    push rdx ;salva RDX nello stack
    push qword ptr[rax] ;salva nello stack il valore puntato da eax
    push 12 ;salva nello stack il valore 12 (l'immediato è a 8 bit, ma
            ;il valore inserito nello stack ha comunque 64 bit
    push 12345678h ;salva nello stack la qword 0x0000000012345678

L'istruzione PUSH con operando a 32 bit è la regola in x86, ma non esiste in x64. Esiste invece in entrambe le architetture l'istruzione PUSH con operando a 16 bit, ma solitamente ha poco senso perché lascia lo stack non allineato (ma, ad esempio, nulla vieta di usare 4 PUSH a 16 bit e prelevare successivamente un'unica qword).


L'operando dell'istruzione POP è invece un registro o una locazione in memoria di 16, 32 o 64 bit (la versione a 32 bit non esiste in x64; in x86, invece, non esiste ovviamente la versione a 64 bit).

POP copia il valore nello stack nell'operando di destinazione, quindi incrementa rSP (di 8, 4 o 2 per POP a 64, 32, 16 bit).


    pop rax ;salva in rax il valore in cima allo stack
    pop qword ptr[rdi] ;salva in [rdi] il valore in cima allo stack

I novizi sono spesso disorientati dal fatto che PUSH (= metti qualcosa nello stack) decrementa rSP, mentre POP (= togli qualcosa dallo stack) lo incrementa. Lo stack si espande all'indietro, perciò è bene farci l'abitudine.


PUSH e POP sono spesso usati accoppiati, per salvare uno o più valori nello stack e ripristinarli in seguito:


    push rbx ;salva rbx
    push rsi ;salva rsi
    push rdi ;salva rdi
    ;...
    ;[codice che usa rbx, rsi ed rdi]
    ;...
    pop rdi ;salva rdi
    pop rsi ;salva rsi
    pop rbx ;salva rbx


Notare che l'ordine dei POP è inverso rispetto a quello dei PUSH.


L'istruzione PUSH serve anche per passare i parametri per una chiamata a funzione, quando essi non siano passati tramite i registri (vedremo in seguito le convenzioni di chiamata).


Per eliminare uno o più elementi dallo stack senza leggerli, si può usare semplicemente l'istruzione ADD (aggiungere 8*n bytes ad RSP in x64 equivale a togliere n elementi).

LEA

L'istruzione LEA (Load Effective Address) è collegata, in un certo senso, all'istruzione MOV. LEA richiede due parametri, il primo è un registro a 16, 32 o 64 bit. Il secondo è un valore in memoria.


In realtà, l'operando in memoria è solo fittizio, dal momento che, in realtà, non avviene nessun accesso alla RAM del processore. LEA copia nel registro di destinazione solo l'indirizzo dell'operando in memoria.


Ad esempio:

    lea rax, [rbx + rcx] ;mette in rax la somma di rbx ed rcx
    lea rbx, [label] ;mette in rbx l'indirizzo di label (in x64, ciò
                     ;verrà codificato tramite RIP-relative addressing)
                     ;questa istruzione occupa 5 bytes, mentre
                     ;l'equivalente con MOV ne occupa 9.
    lea edx, [edx + edx*8] ;moltiplica edx per 9

L'ultimo esempio mostra l'uso di LEA per effettuare semplici calcoli che, in alternativa, richiederebbero più di un'istruzione. Inoltre LEA ha la proprietà, talvolta desiderabile, di non modificare nessuno dei flags.


In realtà, la dimensione del primo operando e la dimensione dei registri usati nell'indirizzamento del secondo operando può essere diversa. Se il primo operando è più piccolo, il risultato viene troncato; se è più grande, viene zero-extended:

    lea ax, [rbx + rdi] ;mette in ax i 16 bit inferiori di rbx+rdi
    lea rbx, [eax + ecx*2 + 15] ;nulla da spiegare, qui :)

XCHG

L'istruzione XCHG (eXCHanGe) richiede due operandi, che possono essere o due registri, oppure un registro e un operando in memoria. I due operandi possono essere grandi 8, 16, 32 o 64 bit, ma devono avere la stessa dimensione. L'istruzione XCHG scambia il valore dei due operandi.

    xchg rax, rdx ;scambia rax ed rdx
    xchg cl, byte ptr[rax] ;scambia cl con il byte puntato da rax
    xchg ecx, 12 ;SBAGLIATO! Nessun operando può essere un immediato!

NOP

L'istruzione NOP (No OPeration) è la più semplice che esista... In effetti, il suo compito è quello di non fare assolutamente nulla (a parte, ovviamente, incrementare rIP per passare all'istruzione successiva!). L'opcode dell'istruzione NOP è 0x90, un solo byte; ciò la rende particolarmente utile per fini di reverse engineering o cracking. Per neutralizzare un'istruzione (ad esempio un'istruzione di salto condizionale) si può sostituire con una serie di NOP lunga esattamente quanto l'istruzione (cancellare l'istruzione, infatti, comprometterebbe l'allineamento del file).


Istruzioni matematiche

ADD e SUB

Le istruzioni ADD e SUB permettono di eseguire addizioni e somme. Il formato generale è:

    ADD dst, src ;aggiunge src a dst, salvando il risultato in dst
    SUB dst, src ;sottrae src a dst, salvando il risultato in dst


dove dst può essere un registro o un valore in memoria, src un registro, un valore in memoria o una costante immediata con segno grande fino a 32 bit (il limite vale anche se dst è grande 64 bit). src e dst, tuttavia, non possono essere entrambi operandi in memoria, e devono avere la stessa dimensione (eccetto il caso in cui src è un immediato, nel qual caso, se è più piccolo, viene semplicemente esteso col segno per essere sommato/sottratto a dst). Esempi:

    add rax, rbx ;aggiunge rbx a rax
    add ecx, eax ;aggiunge eax ad ecx (e azzera i 32 bit alti di rcx!)
    sub sp, word ptr [r12] ;sottrae la word in [r12w] a sp
    sub al, ah   ;sottrae ah da al
    add dx, 1234h ;aggiunge a dx un valore a 16 bit
    sub rbp, -5  ;sottrae -5 ad rbp
    add qword ptr [rax], -100000 ;aggiunge -100000 alla qword in [rax]
    add rsi, -3000000000 ;SBAGLIATO! L'immediato non sta in 32 bit


Se il secondo operando è un immediato, entrambe le istruzioni hanno codifiche più brevi se la destinazione è AL, AX, EAX o RAX.


Sia l'istruzione ADD che l'istruzione SUB modificano tutti e 6 gli status flags in maniera concorde al risultato dell'operazione.

Talvolta l'istruzione SUB viene utilizzata per azzerare un registro sottraendolo a se stesso, ma per questo scopo è consigliata (e più diffusa) l'istruzione logica XOR.


NEG

L'istruzione NEG (NEGate) ha un solo operando, un registro o valore in memoria a 8, 16, 32 o 64 bit; NEG effettua il complemento a 2 del suo operando, ossia ne calcola l'opposto. Ovviamente ha senso solo per gli interi con segno.

    neg rax    ;calcola l'opposto di rax
    neg qword ptr[rsp] ;calcola l'opposto della qword puntata da rsp
    neg r13b   ;calcola l'opposto del byte meno significativo di r13

NEG imposta il Carry Flag a 0 se il valore dell'operando è 0, altrimenti lo imposta a 1. Gli altri status flag (OF, SF, ZF, AF e PF) sono impostati concordemente al risultato.


ADC e SBB

Le istruzioni ADC e SBB (rispettivamente ADd with Carry e SuBtract with Borrow) hanno la stessa sintassi di ADD e SUB, ma anche la stessa funzione. L'unica differenza è che ADC aggiunge ulteriormente 1 al risultato se il Carry Flag è 1; analogamente, SBB sottrae 1 se il CF è 1. Si comportano invece esattamente come ADD e SUB se il CF è 0.


Le istruzioni ADC e SBB servono per tenere conto del riporto (carry) o del prestito (borrow) nel caso di addizioni o sottrazioni in più parti. Ad esempio, se si vuole sommare RAX:RBX (cioè la concatenazione di RAX e RBX, un numero a 128 bit) con RCX:RDX, si può usare il seguente codice:

    add rbx, rdx ;somma i 32 bit inferiori
    adc rax, rcx ;somma i 32 bit superiori, più l'eventuale riporto

Per la sottrazione, invece:

    sub rbx, rdx ;sottrae i 32 bit inferiori
    sbb rax, rcx ;sottrae i 32 bit superiori, meno 1 se c'era prestito


INC e DEC

INC e DEC (INCrement e DECrement) hanno un solo operando, che può essere un registro o un operando in memoria. L'effetto di queste istruzioni è quello di aggiungere (INC) o sottrarre (DEC) 1 all'operando, come le istruzioni ADD e SUB in cui il secondo operando è 1. Hanno però una codifica più breve.


L'unica differenza con ADD e SUB è che INC e DEC preservano il Carry Flag; tutti gli altri flag sono modificati allo stesso modo.


MUL e IMUL

Le istruzioni MUL e IMUL (MULtiply) servono per effettuare le moltiplicazioni, rispettivamente di interi senza segno o con segno.


L'istruzione MUL ha un solo operando, un registro o valore in memoria a 8, 16, 32 o 64 bit. A seconda della dimensione dell'operando, MUL effettua il prodotto tra il suo operando e il valore di AL, AX, EAX o RAX, rispettivamente; il risultato viene salvato in AX, DX:AX, EDX:EAX, RDX:RAX, dove la notazione con i due punti indica la concatenazione. I registri di destinazione hanno una dimensione doppia perché, in generale, il prodotto di due numeri a n bit ha bisogno di 2n bit per essere contenuto interamente. Esempi:

    mul bh ;calcola al*bh, salva il risultato in ax
    mul r15w ;calcola ax*r15w, risultato in dx:ax
    mul dword ptr[r12] ;calcola eax*[r12], risultato in edx:eax
    mul rax ;calcola il quadrato in rax, risultato in rdx:rax
    mul 12 ;SBAGLIATO! L'operando non può essere un immediato.

L'istruzione IMUL è più flessibile, e può presentarsi con uno, due o tre operandi.


Nella forma a un operando (registro o valore in memoria a 8, 16, 32 o 64 bit), IMUL moltiplica loperando per il valore di AL, AX, EAX o RAX (a seconda della dimensione dell'operando) e salva il risultato in AX, DX:AX, EDX:EAX o RDX:RAX; in altre parole, funziona come MUL, solo che la moltiplicazione è fatta considerando gli operandi come interi con segno. Esempio:

    imul rbx ;moltiplica rax per rbx, risultato in rdx:rax

Nella forma a due operandi, il primo è la destinazione, un registro a 16, 32 o 64 bit, e il secondo un registro o valore in memoria di uguale dimensione, oppure un valore immediato (se l'immediato è più piccolo degli operandi, viene sign-extended; se gli operandi sono di 64 bit, l'immediato è comunque al massimo 32 bit). In questo caso la destinazione viene moltiplicata per il secondo operando e il risultato è salvato nella destinazione.

    imul cx, r15w ;moltiplica cx per r15w
    imul rdx, qword ptr[rsp] ;moltiplica rdx per [rsp]
    imul bp, 10 ;moltiplica bp per 10
    imul rax, 123456789h ;SBAGLIATO! Immediato troppo grande per 32 bit

Nella forma a tre operandi, il primo è la destinazione, un registro a 16, 32 o 64 bit; il secondo è un registro o valore in memoria di uguale dimensione; il terzo è un immediato (di nuovo, se l'immediato è 8 bit e gli operandi sono più grandi, l'immediato viene sign-extended; non sono consentiti immediati di 64 bit). L'istruzione allora moltiplica secondo e terzo operando e salva il risultato nel primo.

    imul rbx, rax, -99 ;calcola rax*(-99) e salva il risultato in rbx

Osserviamo che nelle forme a due e tre operandi, la destinazione non è più grande dei prodotti, quindi, se il risultato fosse troppo grosso per entrare nella destinazione, si otterrebbe solo la metà meno significativa.


Nel caso di MUL o di IMUL con un operando, il Carry Flag e l'Overflow Flag sono messi ad 1 se la metà alta del risultato è diversa da 0, e sono azzerati altrimenti. Per le forme a due e tre operandi di IMUL, il CF e l'OF sono messi a 1 in caso di overflow, cioè se il risultato è più grande del registro di destinazione (in effetti non è molto diverso dal caso ad 1 operando).

Gli altri status flags (SF, ZF, AF e PF) sono indefiniti dopo MUL o IMUL, perciò non si deve fare affidamento sul loro valore.


DIV e IDIV; CBW, CWD, CDQ, CQO

Le istruzioni DIV e IDIV effettuano le divisioni, rispettivamente senza e con segno. Entrambe le istruzioni accettano un solo operando, un registro o valore in memoria a 8, 16, 32 o 64 bit che rappresenta il divisore. Il dividendo è grande il doppio del divisore, e può essere AX, DX:AX, EDX:EAX o RDX:RAX rispettivamente.

Se il divisore ha 8 bit, il risultato va in AL e il resto in AH; se ha 16 bit, il risultato in AX e il resto in DX; se ha 32 bit, il risultato in EAX e il resto in EDX; se ha 64 bit, infine, il risultato va in RAX ed il resto in RDX.


Le due istruzioni generano un'eccezione in due casi: se il divisore è 0 (poiché non ha senso dividere per 0) e se il risultato è troppo grande per entrare nel registro di destinazione (overflow). Tutti gli status flags sono indefiniti dopo DIV o IDIV.


Se si vuole dividere RAX per un altro registro a 64 bit (senza segno), è bene accertarsi che il registro RDX sia a 0, altrimenti si richia l'overflow (oltre a risultati imprevisti). Se invece si effettua una divisione col segno, bisogna estendere il segno di RAX in tutti i bit di RDX, in modo da costruire il dividendo a 128 bit. Ciò viene fatto con l'istruzione CQO (Convert Quadword to Octaword), che di solito viene eseguita appena prima di IDIV. I corrispettivi a 32, 16 e 8 bit sono, rispettivamente, CDQ (Convert Doubleword to Quadword: estende EAX in EDX:EAX), CWD (Convert Word to Doubleword: estende AX in DX<:AX) e CBW (Convert Byte to Word: estende AL in AX). Nessuna di queste istruzioni modifica i flags.


Non esiste nessuna forma di divisione per un immediato, per cui, se serve un divisore fissato, è necessario caricarlo su un registro per poi usarlo come divisore.


Esempi (si assume che il divisore sia diverso da 0):

    xor rdx, rdx ;azzera rdx
    mov ebx, 10  ;mette 10 in rbx
    div rbx      ;divide (senza segno) eax per 10
                 ;il risultato sarà in rax, il resto in rdx
    cdq                 ;estende il segno di EAX ad EDX:EAX
    idiv dword ptr[r13] ;divide EDX:EAX per la dword puntata da r13

Istruzioni logiche

Le istruzioni logiche sono quelle istruzioni che operano bit a bit; le più comuni sono AND, OR, XOR, NOT, gli shift e lo rotazioni.

AND, OR, XOR

AND, OR e XOR (eXclusive OR) sono operazioni binarie (cioè con due operandi) che hanno la stessa sintassi di ADD e SUB. Queste operazioni agiscono effettuando le rispettive operazioni tra i bit corrispondenti dei loro operandi.


AND imposta ad 1 nella destinazione tutti i e soli quei bit tali che i bit corrispondenti di entrambi gli operandi sono 1.

OR imposta ad 1 nella destinazione tutti i e soli quei bit tali che almeno uno dei bit corrispondenti degli altri due operandi è 1.

XOR imposta ad 1 nella destinazione tutti e soli quei bit tali che i bit corrispondenti degli altri due operandi sono diversi.


Supponendo che i valori iniziali di due registri a 8 bit, in binario, siano 10110010 e 11100111; allora:


  10110010 AND  
  11100111 =  
  ------------  
  10100010
  10110010 OR  
  11100111 =  
  ------------  
  11110111
  10110010 XOR  
  11100111 =  
  ------------  
  01010101

Queste istruzioni sono spesso usate per manipolare i singoli bit: un AND azzera tutti i bit che nel secondo operando sono 0 e lascia invariati gli altri; un OR imposta ad 1 tutti i bit che sono 1 nel secondo operando e lascia invariati gli altri; uno XOR inverte tutti i bit che sono 1 nel secondo operando e lascia invariati gli altri.


L'istruzione XOR, inoltre, è comunemente usata per azzerare un registro, effettuando uno XOR tra il registro e se stesso. Tuttavia lo XOR ha l'effetto collaterale di modificare i flags; nel caso, piuttosto raro, in cui sia necessario preservare i flag, si può ricorrere ad un semplice MOV, che però ha il difetto di avere una codifica più lunga.


Una proprietà utile dell'operazione XOR è la seguente: due operazioni XOR per lo stesso valore si annullano (cioè è sempre vero che a XOR b XOR b = a); ciò la rende adatta a semplici sistemi di criptazione.


Tutte queste istruzioni impostano i flag SF, ZF e PF in base al risultato, azzerano sempre OF e CF (per ovvi motivi non possono generare riporti!), mentre il valore del flag AF è indefinito.


Esempi:

    and rax, rbx
    and al, 11111011b ;azzera il terzultimo bit di al
    or rax, 16 ;16 in binario è 10000, quindi setta il quintultimo bit
    xor bx, -1 ;-1 ha tutti i bit 1, quindi inverte tutti i bit di bx
    or ecx, dword ptr[rbp]
    xor al, r8b
    xor edx, edx ;azzera edx (in x64 azzera comunque tutto rdx)
    and r15, 102030405h ;SBAGLIATO! L'immediato è troppo grande!

NOT

L'istruzione NOT, a differenza delle altre operazioni logiche, ha un solo operando, un registro o valore in memoria di 8, 16, 32 o 64 bit. Questa istruzione effettua il complemento a 1 del suo operando, ossia ne inverte tutti i bit. La modifica al registro di destinazione equivale quindi ad uno XOR con una valore con tutti i bit 1; tuttavia, l'istruzione NOT non modifica nessun flag.

    not rdx
    not dword ptr[rax*4 + rsi]
    not 15 ;SBAGLIATO! Ovviamente l'operando non può essere immediato!

SHL, SHR, SAL, SAR

Le istruzioni SHL ed SHR (SHift Left e SHift Right) effettuano l'operazione di shift dei un valore.

Lo shift consiste nello scorrimento dei bit del numero specificato di posizioni. Ad esempio, se un registro contiene il valore binario 01101101, lo shift a sinistra di una posizione è 11011010; lo shift a sinistra di due posizioni è 10110100 (i primi due bit sono "spinti fuori", mentre i due bit in fondo sono riempiti con 0. Lo shift a destra di 1 bit è 00110110; lo shift a destra di 2 bit è 00011011 (stavolta vengono spinti fuori i bit meno significativi, e gli zeri sono aggiunti da sinistra).


SHL ed SHR hanno 2 operandi; il primo è un registro o valore in memoria (8, 16, 32 o 64 bit), ed è la destinazione; il secondo operando può essere il registro CL, oppure un immediato senza segno a 8 bit (cioè tra 0 e 255), e rappresenta il numero di posizioni di cui si vuole shiftare la destinazione. Del secondo operando vengono presi solo i 5 bit inferiori (o 6 se la destinazione ha 64 bit), in modo che il contatore del numero di posizioni è compreso fra 0 e 31 (fra 0 e 63 se la destinazione ha 64 bit).


SHL e SHR con un contatore diverso da 0 modificano i flags (i dettagli possono essere visti nel manuale); in particolare, il CF viene posto uguale all'ultimo bit che è stato spinto fuori.


Esempi:

    shl rax, 1
    shr ebx, cl
    shl word ptr[r12], 13
    shr cl, al ;SBAGLIATO! L'unico registro valido secondo operando è cl


Dal punto di vista aritmetico, uno shift a sinistra di n bit equivale a moltiplicare un numero senza segno per 2n; uno shift a destra di n bit equivale ad una divisione (con troncamento) di un numero senza segno per 2n. L'uso degli shift per evitare le moltiplicazioni e (soprattutto) le divisioni è notevolmente più efficiente. Ovviamente gli shift si possono usare per altri scopi di manipolazione dei bit


Nel caso si vogliano effettuare moltiplicazioni è divisioni per potenze di 2 di numeri con segno, si usano le istruzioni SAL (Shift Arithmetical Left) e SAR (Shift Aritmetical Right). La sintassi degli operandi è la stessa, e anche le funzioni sono simili. Di fatto, SAL è un alias di SHL e non differisce in nulla (l'opcode è una sola, non sono due istruzioni diverse). SAR, invece, si comporta come SHR, con la differenza che i bit che "entrano" da sinistra vengono riempiti con il valore del bit del segno dell'operando originario. Perciò mentre lo shift a destra di un bit di 10011010 è 01001101, lo shift aritmetico è 11001101. Notiamo che 10011010 binario è uguale a -102 decimale, mentre 11001101 è uguale a -51 (la metà!).


In realtà il risultato di SAR con contatore n è uguale a quello di IDIV con divisore 2n solo per i numeri positivi o quando la divisione è esatta; infatti mentre SAR tronca sempre per difetto, IDIV tronca verso lo 0 (quindi per eccesso nel caso di numeri negativi). Ad esempio se si usa IDIV per dividere -9 per 4, il risultato è -2 e il resto -1 (notiamo che ciò NON è coerente con la definizione matematica della divisione con resto, in cui il resto è sempre positivo). Usando invece SAR per shiftare -9 di 2 bit, il risultato è -3 (e il resto, che in questo caso non viene calcolato esplicitamente, è 3).

    sar rax, 2 ;shift aritmetico di 2, equivalente a dividere per 4

ROL e ROR

Le rotazioni sono molto simili agli shift; la sintassi di ROL (ROtate Left) e di ROR (ROtate Right) è la stessa di SHL e SHR. La differenza è che i bit "spinti fuori" da una parte rientrano dall'altra. Con lo stesso esempio di prima, la rotazione a sinistra di un bit di 01101101 è 11011010. La rotazione a destra è 10110110. Anche in questo caso il CF è impostato come l'ultimo bit spinto fuori.

Istruzioni di controllo

CMP e TEST

L'istruzione CMP (CoMPare) ha esattamente la stessa sintassi dell'istruzione SUB. In effetti effettua anche la stessa operazione, tranne un dettaglio: non modifica il registro di destinazione. L'unico effetto è quello di aggiornare gli status flags in base al risultato della sottrazione fra il primo ed il secondo operando. Tipicamente è utilizzata per confrontare due numeri prima di un'istruzione di salto condizionale (vedi oltre).

    cmp rax, rbx ;confronta rax e rbx


L'istruzione TEST, analogamente, ha la stessa sintassi e la stessa funzione dell'istruzione AND; anche in questo caso, senza modificare il registro di destinazione, ma modificando solo i flags. Si può usare, ad esempio per vedere se un certo bit di un valore è 1 o 0:

    test al, 8   ;setta lo ZF se il quartultimo bit è 0

Un uso classico è quello di verificare se un registro vale 0 oppure no:

    test rcx, rcx ;setta lo ZF se rcx vale 0

CMP e TEST sono utili soprattutto con le istruzioni di salto condizionale.

Istruzioni di conversione dei dati

MOVZX, MOVSX, MOVSXD

Le istruzioni MOVZX (MOVe with Zero eXtend), MOVSX (MOVe with Sign eXtend), MOVSD (MOVe with Sign eXtend Doubleword) servono ad effettuare delle conversioni di datti più piccoli in dati più grandi. Tutte richiedono due parametri, il primo (la destinazione) è un registro, il secondo (la sorgente) un registro o un valore in memoria.

Il primo parametro di MOVZX e MOVSX può essere 16, 32 o 64 bit, mentre il secondo può essere di 8 o 16 bit (ma non possono essere entrambi di 16 bit). MOVZX zero-estende la sorgente e salva il risultato nella destinazione; MOVSX usa invece una sign-extension. Esempi:

    movzx ax, byte ptr [rdi]
    movsx eax, byte ptr [rdi]
    movzx rax, byte ptr [rdi]
    movsx eax, word ptr [rdi]
    movzx rax, word ptr [rdi]
    movsx rax, dword ptr [rdi] ;SBAGLIATO! Il secondo operando non può
                               ;essere a 32 bit

L'istruzione MOVSXD esiste solo in x64, e serve ad estendere (con il segno) un intero da 32 a 64 bit. Il primo operando è perciò un registro a 64 bit, il secondo un registro o un valore in memoria a 32 bit. Esempio:

    movsxd rax, ecx  ;estende ecx in rax con il segno


Non esiste nessuna istruzione pensata per effettuare una estensione senza segno. I più attenti avranno già capito perché: basta una semplice MOV con destinazione a 32 bit. Infatti, in x64, le operazioni sui registri a 32 bit sono zero-estese automaticamente, per cui può avere senso un'istruzione (a prima vista scema) come questa:

    mov ecx, ecx     ;copia ecx in ecx (di fatto: zero-estende in rcx!)

Istruzioni di controllo del flusso

Le istruzioni di controllo del flusso sono quelle istruzioni che permettono di modificare l'Instruction Pointer, effettuando una chiamata a procedura (CALL/RET), un salto incodizionato (JMP) o un salto condizionato (Jcc).


Le istruzioni di controllo del flusso (esclusa RET) accettano un parametro che nei casi più frequenti è della forma di un indirizzo relativo rispetto a RIP. Tale displacement, può essere grande fino a 32 bit (con segno), per cui questa forma di indirizzamento permette di specificare destinazioni distanti fino a 2 GB dall'istruzione corrente.


Gli assemblatori, tuttavia, si prendono il carico di calcolare il corretto displacement, permettendo di specificare la destinazione attraverso un nome simbolico detto label (etichetta).


Degli esempi saranno visti nelle prossime sezioni.


JMP

L'istruzione JMP (JuMP) esegue un salto incondizionato alla destinazione specificata dall'operando. Nella sua forma più comune, l'operando è un displacement a 8 bit (near jump) o 32 bit (short jump). Near e short jump permettono di saltare solo all'interno dello stesso segmento; i far jump (che a noi non interessano), invece, permettono di saltare ad una destinazione di un altro segmento.

In MASM e negli altri assembler, ovviamente, non si scrive direttamente il displacement, ma si indica piuttosto il nome di un'etichetta, lasciando il compito della codifica all'assembler. Ad esempio, un ciclo infinito potrebbe avere questa struttura:

inizio:    ;questa è una label
    ...
    ;[Codice che fa qualcosa]
    ...
    jmp inizio ;salta a inizio


In alternativa, l'operando di un JMP può essere un registro o un valore in memoria, che rappresenta la destinazione del salto (cioè il nuovo valore di rIP):

    lea rcx, etichetta ;carico in rcx un puntatore
    jmp rcx ;salta all'indirizzo contenuto in rcx

Oppure:

    jmp qword ptr[rax] ;salta all'indirizzo contenuto in [rax]


Jcc

La famiglia di istruzioni Jcc (Jump if condition) permette di eseguire dei salti condizionati, cioè dei salti che vengono effettuati se una condizione è vera, ignorati altrimenti. La condizione è la verifica dello stato di uno o più status flag. Come JMP, anche i Jcc hanno un solo operando, che può essere solo un displacement relativo (perciò possono essere solo short e near).


Vediamo la lista delle istruzioni di salto condizionato; in totale vi sono 16 diverse istruzioni, ma gli mnemonici effettivi sono di più, per via di numerosi sinonimi:


Mnemonico Condizione Spiegazione
JO OF = 1 Jump if Overflow
JNO OF = 0 Jump if Not Overflow
JB
JC
JNAE
CF = 1 Jump if Below
Jump if Carry
Jump if Not Above or Equal
JNB
JNC
JAE
CF = 0 Jump if Not Below
Jumpt if Not Carry
Jump if Above or Equal
JZ
JE
ZF = 1 Jump if Zero
Jump if Equal
JNZ
JNE
ZF = 0 Jump if Not Zero
Jump if Not Equal
JNA
JBE
CF = 1 o ZF = 1 Jump if Not Above
Jump if Below or Equal
JNBE
JA
CF = 0 e ZF = 0 Jump if Not Below or Equal
Jump if Above
JS SF = 1 Jump if Sign
JNS SF = 0 Jump if Not Sign
JP
JPE
PF = 2 Jump if Parity
Jump if Parity Even
JNP
JPO
PF = 0 Jump if Not Parity
Jump if Parity Odd
JL
JNGE
SF != OF Jump if Less
Jump if Not Greater or Equal
JGE
JNL
SF = OF Jump if Greater Or Equal
Jump if Not Less
JNG
JLE
ZF = 1 o SF != OF Jump if Not Greater
Jump if Less or Equal
JNLE
JG
ZF = 0 e SF = OF Jump if Not Less or Equal
Jump if Greater

Sono stati inventati molti sinonimi per consentire di scegliere quello più opportuno in base al significato del codice, in modo da migliorare la leggibilità dei sorgenti. Naturalmente i disassemblatori non fanno sempre la scelta più coerente tra gli alias disponibili, dato che non possono capire il senso del codice.


I nomi di molte di queste istruzioni hanno senso solo se si trovano dopo un istruzione di confronto (CMP o, ovviamente, SUB), mentre altri specificano solo quale flag controllano (ad esempio JC, JO e JP).


Precisiamo che i termini above/greater e below/less non sono assolutamente sinonimi: infatti above e below hanno senso dopo un confronto tra interi senza segno, mentre greater e less hanno senso dopo un confronto tra interi con segno.


Vediamo alcuni esempi:

    cmp rax, rbx ;confronta rax e rbx
    je etichetta ;salta se sono uguali
    shr cl, 1   ;shift a destra di 1 bit
    jc dispari  ;salta se il carry flag è 1, cioè se il bit meno
                ;significativo di cl era 1 (ovvero, cl era dispari)
    xor ebx, r12d
    jp parity   ;salta se, dopo lo xor, bl ha un numero pari di bit 1
    add bx, dx
    jz zero    ;salta se il risultato della somma è 0
               ;(cioè se i due numeri erano opposti)
    cmp rcx, rdx
    jna maggiore  ;salta se rcx non è maggiore di rdx
                  ;se li consideriamo senza segno
    jng maggiore  ;salta se rcx non è maggiore di rdx
                  ;se li consideriamo CON segno
    or al, cl
    js segno   ;salta se il bit 7 di al (il bit del segno) è uguale a 1
               ;(ciò equivale al fatto che, prima dell'or, il bit del
               ;segno di almeno una delle due istruzioni fosse 1, cioè,
               ;ancora, che almeno una delle due fosse negativa)


Con le istruzioni di salto condizionale si possono creare tutti i costrutti ad alto livello.

Un codice del tipo

    IF (a == b) THEN
        qualcosa;
    ELSE
        qualcos'altro;


Diventa qualcosa come (supponendo che rax ed rbx siano a e b):

    cmp rax, rbx
    jne qualcosaltro
qualcosa:
    ;...
    ;codice del ramo IF
    ;...
    jmp dopo
qualcosaltro:
    ;...
    ;codice del ramo ELSE
    ;...
dopo:


Un ciclo per ripetere del codice 100 volte si scrive solitamente così:

    mov ecx, 100 ;ecx fa da contatore (inutile usare rcx). L'uso
                 ;di questo registro come contatore non è obbligatorio
                 ;ma è uno standard de facto.
ciclo:
    ;...
    ;codice del ciclo
    ;...
    dec ecx   ;decrementa il contatore
    jnz ciclo ;salta se non è ancora a 0

Naturalmente si può tradurre in linguaggio assembly qualsiasi costrutto ad alto livello.

CALL e RET

Le istruzioni CALL e RET (RETurn) servono ad implementare le chiamate a procedura.


Anche per l'istruzione CALL esistono le versioni near e quelle far; analizzeremo solo il primo caso.

L'istruzione CALL mette nello stack il rIP (RIP in x64, EIP in x86) dell'istruzione successiva (tale indirizzo prende il nome di return address, indirizzo di ritorno). Quindi si comporta analogamente all'istruzione JMP, saltando all'indirizzo specificato dal suo unico operando. Anche in questo caso, l'operando può essere un displacement di 32 bit (cioè, per chi programma, una label), un registro oppure un valore in memoria.


L'istruzione RET permette di proseguire l'esecuzione dall'istruzione successiva a CALL. RET preleva il return address dallo stack e lo imposta come nuovo rIP.

RET può anche avere un parametro, un immediato senza segno a 16 bit, che viene aggiunto a rSP dopo aver tolto dallo stack il return address. Infatti, a seconda della convenzione di chiamata, il chiamante mette nello stack alcuni o tutti i parametri richiesti dalla funzione chiamata; l'istruzione RET si occupa quindi di incrementare rSP per rimuoverli.

Nella prossima sezione daremo un'occhiata alle due convenzioni di chiamata.


Se la funzione chiamata restituisce una valore, lo standard è di restituirlo nel registro rAX.

Il vostro primo listato

Passiamo adesso ad analizzare un semplice programmino per capire cosa fa. Scaricate qui l'allegato. Lo stesso sorgente in C è stato compilato sia per x86, sia per x64. Vediamo prima la versione a 32 bit, quindi analizzeremo il codice a 64 bit.


Il disassemblato è stato prodotto da IDA Pro 5.1, ma va bene qualunque versione (purché non troppo antica). L'uso delle funzionalità del disassemblatore va oltre lo scopo di questo tutorial.


Versione x86

Il codice della procedura principale di prima_lezione_86.exe, è il seguente:

.text:00401020 _wmain   proc near   ; CODE XREF: ___tmainCRTStartup+10A#p
.text:00401020
.text:00401020 var_14          = dword ptr -14h
.text:00401020 var_10          = dword ptr -10h
.text:00401020 var_C           = dword ptr -0Ch
.text:00401020 var_8           = dword ptr -8
.text:00401020 var_4           = dword ptr -4
.text:00401020
.text:00401020    push    ebp
.text:00401021    mov     ebp, esp
.text:00401023    sub     esp, 14h
.text:00401026    mov     [ebp+var_4], 0Ah
.text:0040102D    mov     [ebp+var_8], 19h
.text:00401034    mov     [ebp+var_10], 1Eh
.text:0040103B    mov     [ebp+var_C], 0Ch
.text:00401042    mov     [ebp+var_14], 23h
.text:00401049    mov     eax, [ebp+var_4]
.text:0040104C    sub     eax, [ebp+var_10]
.text:0040104F    mov     [ebp+var_4], eax
.text:00401052    mov     ecx, [ebp+var_4]
.text:00401055    shl     ecx, 1
.text:00401057    mov     [ebp+var_10], ecx
.text:0040105A    mov     eax, [ebp+var_C]
.text:0040105D    cdq
.text:0040105E    sub     eax, edx
.text:00401060    sar     eax, 1
.text:00401062    mov     edx, [ebp+var_C]
.text:00401065    and     edx, 80000001h
.text:0040106B    jns     short loc_401072
.text:0040106D    dec     edx
.text:0040106E    or      edx, 0FFFFFFFEh
.text:00401071    inc     edx
.text:00401072
.text:00401072 loc_401072:                     ; CODE XREF: _wmain+4B#j
.text:00401072     add     eax, edx
.text:00401074     mov     [ebp+var_C], eax
.text:00401077     mov     eax, [ebp+var_14]
.text:0040107A     push    eax
.text:0040107B     mov     ecx, [ebp+var_C]
.text:0040107E     push    ecx
.text:0040107F     mov     edx, [ebp+var_10]
.text:00401082     push    edx
.text:00401083     mov     eax, [ebp+var_8]
.text:00401086     push    eax
.text:00401087     mov     ecx, [ebp+var_4]
.text:0040108A     push    ecx
.text:0040108B     call    sub_401000
.text:00401090     add     esp, 14h
.text:00401093     push    eax
.text:00401094     push    offset Format   ; "%d"
.text:00401099     call    ds:printf
.text:0040109F     add     esp, 8
.text:004010A2     xor     eax, eax
.text:004010A4     mov     esp, ebp
.text:004010A6     pop     ebp
.text:004010A7     retn
.text:004010A7 _wmain          endp

Prima di leggere la spiegazione del codice, provate un po' a capire da soli cosa succede; ma non preoccupatevi se non ci riuscite.


Cominciamo dalle prime righe:


.text:00401020    push    ebp
.text:00401021    mov     ebp, esp
.text:00401023    sub     esp, 14h

Le prime due righe creano il cosiddetto stack frame. Infatti, il valore di esp potrebbe cambiare nel corso dell'esecuzione, ma è proprio nello stack che verranno messi i dati locali. Per avere un riferimento stabile, il valore iniziale di esp viene copiato in ebp, che così punta alla base dello stack. Tutti i futuri riferimenti alle variabili nello stack, infatti, avverranno tramite ebp.


La terza istruzione sottrae 14h (la lettera h indica hexadecimal, cioè un numero esadecimale) ossia 20 al registro esp. In pratica, crea lo spazio per 5 variabili locali di dimensione dword, come si chiarisce poco dopo:

.text:00401026    mov     [ebp+var_4], 0Ah
.text:0040102D    mov     [ebp+var_8], 19h
.text:00401034    mov     [ebp+var_10], 1Eh
.text:0040103B    mov     [ebp+var_C], 0Ch
.text:00401042    mov     [ebp+var_14], 23h

IDA ha gentilmente rinominato le variabili con dei nomi simbolici (non molto significativi, ovviamente, ma sono un riferimento migliore dei semplici numeri). Questo codice inizializza le variabili locali var_4, var_8, var_C, var_10, var_14 con i valori 10 (0Ah), 25 (19h), 12 (0Ch), 30 (1Eh), 35 (23h).

.text:00401049    mov     eax, [ebp+var_4]
.text:0040104C    sub     eax, [ebp+var_10]
.text:0040104F    mov     [ebp+var_4], eax

Questo codice mette in eax il valore di var_4, quindi gli sottrae il valore di var_10, infine salva il risultato nuovamente in var_4.


.text:00401052    mov     ecx, [ebp+var_4]
.text:00401055    shl     ecx, 1
.text:00401057    mov     [ebp+var_10], ecx

Qui riprende il valore di var_4 copiandolo in ecx, lo shifta a sinistra di un bit (moltiplicandolo quindi per 2), e lo nella variabile var_10.

.text:0040105A    mov     eax, [ebp+var_C]
.text:0040105D    cdq
.text:0040105E    sub     eax, edx
.text:00401060    sar     eax, 1

Qui mette in eax il valore di var_C (che, ricordiamo, contiene 12). L'istruzione cdq estende il segno di eax in edx; ma eax è positivo, perciò l'unico effetto è di azzerare edx. L'istruzione sub sottrae edx ad eax, e visto che edx è nullo, è un'istruzione inutile. Capita spesso di trovare codice inutile, i compilatori non sempre riescono a inferire ciò che per un uomo, magari, è un'ovvietà (in realtà, qui il compilatore non si è reso conto solo della positività di var_C, e ha prodotto un codice generico che funzionerebbe anche se var_C fosse negativo). L'ultima riga, semplicemente, shifta a destra di un bit (tra shift logico e shift aritmetico, in questo caso, non fa differenza, essendo eax positivo). In altre parole, in eax viene messo il valore di var_C diviso per 2 (senza resto), quello che in linguaggio C sarebbe var_C / 2.

.text:00401062    mov     edx, [ebp+var_C]
.text:00401065    and     edx, 80000001h
.text:0040106B    jns     short loc_401072

Qui copia di nuovo var_C in edx. L'and azzera tutti i bit, tranne il bit del segno e il bit meno significativo. In questo caso, sappiamo che edx è positivo, per cui il bit del segno è 0 (salvare solo il bit meno significativo equivale a prendere il resto della divisione per 2). Di conseguenza, il Sign Flag viene impostato a 0. Allora l'istruzione jns salterà sempre all'indirizzo 00401072, perciò queste tre istruzioni non saranno mai eseguite:

.text:0040106D    dec     edx
.text:0040106E    or      edx, 0FFFFFFFEh
.text:00401071    inc     edx

Giusto per curiosità, vediamo cosa significano. Ricordiamoci che questo codice viene eseguito solo se il numero iniziale era negativo (cosa impossibile, in questo caso). L'istruzione or imposta ad 1 tutti i bit eccetto l'ultimo. dec sicuramente inverte l'ultimo bit (magari anche gli altri, ma non importa). Si presentano due casi: se edx è pari, allora dopo dec sarà dispari (cioè ha l'ultimo bit 1), quindi, dopo l'or, tutti i bit sono ad 1: ciò vuol dire che edx contiene -1, perciò dopo il successivo incremento conterrà 0. Se invece edx è dispari, con dec diventerà pari, dopo l'or edx conterrà 0xFFFFFFFE, cioè -2, che incrementato diventa -1. Riassumendo: se var_C è positivo (o nullo) e pari, non si arriva qui e edx conterrà 0; se var_C è positivo e dispari, non si arriva qui, ma edx conterrà 1; se var_C fosse negativo e pari, edx conterrà ancora 0; se var_C fosse negativo e dispari, edx, alla fine, conterrà -1. Cos'è tutto ciò? Semplice! È l'operatore % (modulo) del C. Alla fine edx conterrà sempre il risultato di var_C % 2.

.text:00401072 loc_401072:                     ; CODE XREF: _wmain+4B#j
.text:00401072     add     eax, edx
.text:00401074     mov     [ebp+var_C], eax

Somma edx ad eax, quindi salva tutto in var_C. Abbiamo capito prima che eax contiene var_C / 2, mentre edx contiene var_C % 2. Tutto il codice da 0040105A a 00401074, dunque, significa questo:

    var_C = (var_C / 2) + (var_C % 2)


Andiamo avanti.

.text:00401077     mov     eax, [ebp+var_14]
.text:0040107A     push    eax
.text:0040107B     mov     ecx, [ebp+var_C]
.text:0040107E     push    ecx
.text:0040107F     mov     edx, [ebp+var_10]
.text:00401082     push    edx
.text:00401083     mov     eax, [ebp+var_8]
.text:00401086     push    eax
.text:00401087     mov     ecx, [ebp+var_4]
.text:0040108A     push    ecx
.text:0040108B     call    sub_401000
.text:00401090     add     esp, 14h

Questo codice mette nello stack tutte e 5 le variabili, quindi chiama la funzione all'indirizzo 401000.

La convenzione di chiamata più diffusa su Win32 è la convenzione stdcall, che prevede che tutti i parametri siano passati nello stack in ordine inverso (dall'ultimo al primo). Perciò var_4 è il primo parametro, var_8 il secondo, var_10 il terzo, var_C il quarto, var_14 il quinto e ultimo. Vedremo tra poco il codice della funzione sub_401000.

In effetti, la convenzione di chiamata stdcall prevede che sia la funzione chiamata a ripulire lo stack (tramite l'istruzione ret). In questo caso, invece, lo stack viene messo a posto dal chiamante con add esp, 14h, che aggiunge 20 allo stack (20 bytes è proprio lo spazio occupato da 5 parametri dword).

.text:00401093     push    eax
.text:00401094     push    offset Format   ; "%d"
.text:00401099     call    ds:printf
.text:0040109F     add     esp, 8

Qui vediamo una chiamata alla funzione della libreria C printf, che stampa sullo schermo qualcosa in base alla stringa di formato. Il primo parametro è, appunto, la stringa di formato ("%d", che significa "stampa un intero"); i successivi (uno solo in questo caso) sono i valori da passare alla funzione. In questo caso viene passato eax; l'ultima modifica ad eax che abbiamo visto è alla riga 00401083, ma nel frattempo c'è stata una chiamata di funzione... e proprio in eax le funzioni mettono il loro valore di ritorno, perciò ci aspettiamo che eax sia stato modificato dentro la funzione.

.text:004010A2     xor     eax, eax
.text:004010A4     mov     esp, ebp
.text:004010A6     pop     ebp
.text:004010A7     retn

Quest'ultimo codice della procedura azzera eax (è il valore di ritorno della funzione corrente), quindi rilascia lo stack frame (in pratica fa l'inverso delle righe 401020-401021).

Vediamo l'unica cosa che abbiamo tralasciato, cioè la funzione sub_401000:

.text:00401000 sub_401000   proc near            ; CODE XREF: _wmain+6B#p
.text:00401000
.text:00401000 arg_0           = dword ptr  8
.text:00401000 arg_4           = dword ptr  0Ch
.text:00401000 arg_8           = dword ptr  10h
.text:00401000 arg_C           = dword ptr  14h
.text:00401000 arg_10          = dword ptr  18h
.text:00401000
.text:00401000     push    ebp
.text:00401001     mov     ebp, esp
.text:00401003     mov     eax, [ebp+arg_0]
.text:00401006     add     eax, [ebp+arg_4]
.text:00401009     add     eax, [ebp+arg_8]
.text:0040100C     add     eax, [ebp+arg_C]
.text:0040100F     add     eax, [ebp+arg_10]
.text:00401012     pop     ebp
.text:00401013     retn
.text:00401013 sub_401000      endp

A parte il solito codice di creazione e rilascio dello stack frame, vediamo che viene messo in eax il valore del primo argomento, quindi gli vengono sommati tutti gli altri 4. Questa sofisticata funzione, insomma, non fa altro che sommare i suoi 5 argomenti :)

Versione x64

Vediamo adesso il codice della versione a 64 bit. Non commenterò più riga per riga, ma mi soffermerò solo sulle differenze rispetto all'altra versione.

.text:0000000140001030 sub_140001030   proc near ; CODE XREF: sub_140001178+115#p 
.text:0000000140001030                 ; DATA XREF: .pdata:ExceptionDir#o 
.text:0000000140001030 
.text:0000000140001030 var_38          = dword ptr -38h 
.text:0000000140001030 var_28          = dword ptr -28h 
.text:0000000140001030 var_24          = dword ptr -24h 
.text:0000000140001030 var_20          = dword ptr -20h 
.text:0000000140001030 var_1C          = dword ptr -1Ch 
.text:0000000140001030 var_18          = dword ptr -18h 
.text:0000000140001030 arg_0           = dword ptr  8 
.text:0000000140001030 arg_8           = qword ptr  10h 
.text:0000000140001030 
.text:0000000140001030     mov     [rsp+arg_8], rdx 
.text:0000000140001035     mov     [rsp+arg_0], ecx 
.text:0000000140001039     sub     rsp, 58h 
.text:000000014000103D     mov     [rsp+58h+var_28], 0Ah 
.text:0000000140001045     mov     [rsp+58h+var_24], 19h 
.text:000000014000104D     mov     [rsp+58h+var_1C], 1Eh 
.text:0000000140001055     mov     [rsp+58h+var_20], 0Ch 
.text:000000014000105D     mov     [rsp+58h+var_18], 23h 
.text:0000000140001065     mov     ecx, [rsp+58h+var_1C] 
.text:0000000140001069     mov     eax, [rsp+58h+var_28] 
.text:000000014000106D     sub     eax, ecx 
.text:000000014000106F     mov     [rsp+58h+var_28], eax 
.text:0000000140001073     mov     eax, [rsp+58h+var_28] 
.text:0000000140001077     shl     eax, 1 
.text:0000000140001079     mov     [rsp+58h+var_1C], eax 
.text:000000014000107D     mov     eax, [rsp+58h+var_20] 
.text:0000000140001081     cdq 
.text:0000000140001082     sub     eax, edx 
.text:0000000140001084     sar     eax, 1 
.text:0000000140001086     mov     r8d, eax 
.text:0000000140001089     mov     eax, [rsp+58h+var_20] 
.text:000000014000108D     cdq 
.text:000000014000108E     and     eax, 1 
.text:0000000140001091     xor     eax, edx 
.text:0000000140001093     sub     eax, edx 
.text:0000000140001095     mov     ecx, eax 
.text:0000000140001097     mov     eax, r8d 
.text:000000014000109A     add     eax, ecx 
.text:000000014000109C     mov     [rsp+58h+var_20], eax 
.text:00000001400010A0     mov     eax, [rsp+58h+var_18] 
.text:00000001400010A4     mov     [rsp+58h+var_38], eax 
.text:00000001400010A8     mov     r9d, [rsp+58h+var_20] 
.text:00000001400010AD     mov     r8d, [rsp+58h+var_1C] 
.text:00000001400010B2     mov     edx, [rsp+58h+var_24] 
.text:00000001400010B6     mov     ecx, [rsp+58h+var_28] 
.text:00000001400010BA     call    sub_140001000 
.text:00000001400010BF     mov     edx, eax 
.text:00000001400010C1     lea     rcx, aD         ; "%d" 
.text:00000001400010C8     call    cs:printf 
.text:00000001400010CE     xor     eax, eax 
.text:00000001400010D0     add     rsp, 58h 
.text:00000001400010D4     retn 
.text:00000001400010D4 sub_140001030   endp

Questo, invece, è il codice della funzione della somma (che stavolta si chiama sub_140001030):

.text:0000000140001000 sub_140001000   proc near               ; CODE XREF: sub_140001030+8A#p
.text:0000000140001000
.text:0000000140001000 arg_0           = dword ptr  8
.text:0000000140001000 arg_8           = dword ptr  10h
.text:0000000140001000 arg_10          = dword ptr  18h
.text:0000000140001000 arg_18          = dword ptr  20h
.text:0000000140001000 arg_20          = dword ptr  28h
.text:0000000140001000
.text:0000000140001000     mov     [rsp+arg_18], r9d
.text:0000000140001005     mov     [rsp+arg_10], r8d
.text:000000014000100A     mov     [rsp+arg_8], edx
.text:000000014000100E     mov     [rsp+arg_0], ecx
.text:0000000140001012     mov     ecx, [rsp+arg_8]
.text:0000000140001016     mov     eax, [rsp+arg_0]
.text:000000014000101A     add     eax, ecx
.text:000000014000101C     add     eax, [rsp+arg_10]
.text:0000000140001020     add     eax, [rsp+arg_18]
.text:0000000140001024     add     eax, [rsp+arg_20]
.text:0000000140001028     retn
.text:0000000140001028 sub_140001000   endp

Nel corpo della funzione principale non ci sono grandi differenze (a parte quelle ovvie dovute all'architettura). L'unica degna di nota si trova in queste righe:

.text:0000000140001089     mov     eax, [rsp+58h+var_20] 
.text:000000014000108D     cdq 
.text:000000014000108E     and     eax, 1 
.text:0000000140001091     xor     eax, edx 
.text:0000000140001093     sub     eax, edx 
.text:0000000140001095     mov     ecx, eax 

Esse corrispondono alle righe da 00401062 a 00401074 del vecchio listato. Stavolta il compilatore ha usato un modo più intelligente per calcolare var_20 % 2. Consideriamo 2 casi anche qui: se var_20 è positivo, allora cdq azzera edx, lo xor e l'istruzione sub non modificano eax, quindi l'unica istruzione utile è and eax, 1: essa prende il bit meno significativo, quindi calcola var_20 % 2. Se, invece, var_20 è negativo, allora dopo cdq il valore di edx sarà -1; and estrae il bit meno significativo. Se è 0 (numero negativo pari), allora lo xor (invertendo tutti i bit) risulta in -1, e l'istruzione sub, sottraendo due valori uguali, ha come risultato 0. Se, infine il bit meno significativo è 1 (numero negativo dispari), allora lo xor mette in eax il valore -2 (cioè, in binario, 11111111111111111111111111111110... vi ricordate il complemento a 2, no?). sub sottrae -1, quindi incrementa di 1, ed il risultato finale sarà -1. Quindi anche stavolta il codice equivale al calcolo di var_20 % 2. Il compilatore, però è stato un po' più furbo di prima ed ha evitato il salto condizionale (una buona regola di ottimizzazione è questa: preferisci codice senza salti condizionali).


Se avete difficoltà a seguire questi ragionamenti, è solo perché non avete dimestichezza con le istruzioni assembly; il reverse engineering vi costringe a sviscerare il codice per carpirne il più recondito significato (scusate, non ho resistito... a parte i paroloni stupidi, il concetto è vero).


I più attenti (se stanno leggendo ancora) si saranno accorti di un'altra differenza significativa rispetto alla versione x86: cambiano le convenzioni di chiamata e, di conseguenza, il codice di creazione e rilascio dello stack frame. In Win64, vista la maggiore disponibilità di registri, si adotta la convenzione di chiamata fastcall. Essa prevede che i primi 4 parametri siano passati attraverso i registri anziché attraverso lo stack. In particolare, i primi quattro parametri vanno, nell'ordine, in rcx, rdx, r8, r9. Se ce ne sono altri, vanno sullo stack. In realtà, lo spazio sullo stack per i parametri passati attraverso i registri viene comunque riservato, solo che rimane a disposizione della funzione chiamata, che può salvarvi i parametri (ma, se non gli serve, può anche non farlo). La funzione sub_140001000, ad esempio, salva tutti e 4 i parametri nell'apposito spazio riservato nelle righe 140001000-14000100E (anche se poteva farne a meno, visto che deve solo sommarli tutti in eax... ma questo codice non è ottimizzato). Dal momento che preparare lo spazio sullo stack prima di ogni call sarebbe troppo costoso in termini di prestazioni (e la convenzione si chiama fastcall e non slowcall), viene allocato una volta per tutte, durante la creazione dello stack frame nelle prime righe, abbastanza spazio per le chiamate successive; quindi, per ogni chiamata, viene riutilizzato sempre lo stesso spazio sullo stack. Per questo motivo, i parametri non vengono messi nello stack con l'istruzione push (che riserva nuovo spazio), ma vengono piazzati nello spazio già allocato tramite una semplice istruzione mov (un esempio si ha alla riga 1400010A4, dove viene messo nello stack il quinto parametro della successiva chiamata).

Una conseguenza dell'assenza di push e pop è che il registro rsp ha sempre lo stesso valore all'interno della funzione; così diventa superfluo il mantenimento di un altro registro per puntare alla base dello stack (come si fa invece con la convenzione stdcall, dove il registro ebp è condannato ad essere un puntatore costante per tutta la vita della funzione).


Riferimenti

Per l'architettura x64, si può fare riferimento ai manuali di AMD e Intel: Documentazione su AMD64
Documentazione Intel
Per informazioni approfondite sulle convenzioni di chiamata su Win64, si può leggere questa documentazione dall'MSDN.
Per informazioni sulla programmazione in assembly su Win32, fate riferimento alle sezioni Assembly e Programming e al sito di Iczelion (Mirror), un po' datato ma ancora molto valido. L'ultima versione di MASM32 si può scaricare su http://www.masm32.com/
Per un'introduzione alla programmazione in assembly su x64/Win64, questo tutorial fa per voi.


Note Finali

Si chiude qui questa lezione sull'assembly. Spero di essere riuscito a non rendere sterile e noioso un argomento che, per sua natura, è stimolante e molto creativo.

Ringrazio anzitutto Pnluck che mi ha commissionato questa lezione: grazie :)

Saluto Quequero che, dopo tutti questi anni continua a mandare avanti la baracca (ma vi assicuro che non è diventato vecchio, eh!... è solo che ha iniziato precocemente).
Saluto tutti coloro che continuano a far parte di questa community, unica in Italia nel suo genere; ma anche coloro che ormai latitano perché assorbiti dalla real life.

Grazie a tutti.


Disclaimer

I documenti qui pubblicati sono da considerarsi pubblici e liberamente distribuibili, a patto che se ne citi la fonte di provenienza. Tutti i documenti presenti su queste pagine sono stati scritti esclusivamente a scopo di ricerca, nessuna di queste analisi è stata fatta per fini commerciali, o dietro alcun tipo di compenso. I documenti pubblicati presentano delle analisi puramente teoriche della struttura di un programma, in nessun caso il software è stato realmente disassemblato o modificato; ogni corrispondenza presente tra i documenti pubblicati e le istruzioni del software oggetto dell'analisi, è da ritenersi puramente casuale. Tutti i documenti vengono inviati in forma anonima ed automaticamente pubblicati, i diritti di tali opere appartengono esclusivamente al firmatario del documento (se presente), in nessun caso il gestore di questo sito, o del server su cui risiede, può essere ritenuto responsabile dei contenuti qui presenti, oltretutto il gestore del sito non è in grado di risalire all'identità del mittente dei documenti. Tutti i documenti ed i file di questo sito non presentano alcun tipo di garanzia, pertanto ne è sconsigliata a tutti la lettura o l'esecuzione, lo staff non si assume alcuna responsabilità per quanto riguarda l'uso improprio di tali documenti e/o file, è doveroso aggiungere che ogni riferimento a fatti cose o persone è da considerarsi PURAMENTE casuale. Tutti coloro che potrebbero ritenersi moralmente offesi dai contenuti di queste pagine, sono tenuti ad uscire immediatamente da questo sito.

Vogliamo inoltre ricordare che il Reverse Engineering è uno strumento tecnologico di grande potenza ed importanza, senza di esso non sarebbe possibile creare antivirus, scoprire funzioni malevole e non dichiarate all'interno di un programma di pubblico utilizzo. Non sarebbe possibile scoprire, in assenza di un sistema sicuro per il controllo dell'integrità, se il "tal" programma è realmente quello che l'utente ha scelto di installare ed eseguire, né sarebbe possibile continuare lo sviluppo di quei programmi (o l'utilizzo di quelle periferiche) ritenuti obsoleti e non più supportati dalle fonti ufficiali.