Guida Il Linguaggio Macchina di x86 e x86-64 (Parte 2)

DispatchCode

Moderatore
24 Maggio 2016
500
14
377
249

Il Linguaggio Macchina di x86 e x86-64 (Parte 2)​




1    Preambolo

Per aiutarci oltre al Vol.2 per gli sviluppatori di Intel, dobbiamo avere a portata di mano alcuni strumenti. Io ho scelto di usare un editor esadecimale, HxD, un mio software non ancora completo, di cui ho scritto anche qui, MCA, che consente di sapere quanto un'istruzione è lunga e avere i vari campi che la compongono e infine NASM per assemblare; ho scelto NASM poichè utilizzeremo solo dei file binari (raw bytes), senza header. Non ci importa eseguirli, ma solo avere il codice macchina di un'istruzione Assembly (no, non farò come con 8086...).

Links:
- Software Developer Manual Vol2 (fate riferimento in particolare all'appendice C)
- MCA (usate ciò che trovate su master)
- HxD (o ciò che preferite)
- NASM

Ho scritto qualche riga per utilizzare MCA, che potete trovare sotto spoiler:
C:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

#include "mca.h"

size_t get_file_size(FILE *hfile)
{
    fseek(hfile, 0, SEEK_END);
    size_t file_size = (size_t) ftell(hfile);
    fseek(hfile, 0, 0);
    return file_size;
}

void instruction_info(struct instruction instr)
{
    printf("RAW bytes (hex): ");
    for(int i=0; i<instr.length; i++)
        printf("%02X ", instr.instr[i]);

    printf("\nInstr. length: %d\n", instr.length);

    printf("Print instruction fields:\n");
    printf("\tLocated Prefixes %d:\n\t\t", instr.prefix_cnt);

    for (int i = 0; i < instr.prefix_cnt; i++)
        printf("0x%X ", instr.prefixes[i]);

    if(instr.set_field & REX)
        printf("\n\n\tREX 0x%X:", instr.rex.value);

    if(instr.set_prefix & VEX) {
        printf("\n\tVEX prefix value:\n\t\t");
        for(int i=0; i<instr.vex_cnt; i++)
            printf("0x%X ", instr.vex[i]);

        #ifdef _ENABLE_VEX_INFO
            if(instr._vex.type == 0xC5) {
                printf("\n\tField 0x%X:\n\t\t", instr.vex[1]);
                printf("r: %X\n\t\t", instr._vex.vexc5b.vex_r);
                printf("v: %X\n\t\t", instr._vex.vexc5b.vex_v);
                printf("L: %X\n\t\t", instr._vex.vexc5b.vex_l);
                printf("pp: %X\n\t\t", instr._vex.vexc5b.vex_pp);
            }
            else {
                printf("\n\tField 0x%X:\n\t\t",instr.vex[1]);
                printf("r: %X\n\t\t", instr._vex.vexc4b.vex_r);
                printf("x: %X\n\t\t", instr._vex.vexc4b.vex_x);
                printf("b: %X\n\t\t", instr._vex.vexc4b.vex_b);
                printf("m: %X\n\n\t", instr._vex.vexc4b.vex_m);
                printf("Field 0x%X:\n\t\t",instr.vex[2]);
                printf("W: %X\n\t\t", instr._vex.vexc4b.vex_w);
                printf("v: %X\n\t\t", instr._vex.vexc4b.vex_v);
                printf("L: %X\n\t\t", instr._vex.vexc4b.vex_l);
                printf("pp: %X\n\n\t", instr._vex.vexc4b.vex_pp);
            }
        #endif
    }

    printf("\n\tOP: 0x%X\n", instr.op);

    if(instr.set_field & MODRM)
        printf("\tmod_reg_rm: 0x%X\n", instr.modrm.value);

    if(instr.set_field & SIB)
        printf("\tSIB byte: 0x%X\n", instr.sib.value);

    if(instr.set_field & DISP)
        printf("\tdisp (%d): 0x%llX\n", instr.disp_len, instr.disp);

    if(instr.set_field & IMM)
        printf("\tIimm: 0x%llX\n", instr.imm);

    printf("------------------------------------------------\n");
}

int main(int argc, char *argv[])
{
    if(argc < 3)
    {
        printf("Usage: instr_info <filename.bin> <arch>\n");
        printf("-arch\n\t1: x86\n\t2: x64");
        return -1;
    }

    FILE *hfile = fopen(argv[1],"rb");
    if(hfile == NULL) {
        printf("Binary file not found!");
        return -2;
    }

    size_t file_size = get_file_size(hfile);
    uint8_t *data_buffer = calloc(file_size, sizeof(char));
    fread(data_buffer, sizeof(char), file_size, hfile);
    fclose(hfile);

    int arch = atoi(argv[2]);
    assert(arch == 1 || arch == 2 && "Unknown architecture! Aborted.");

    int bytes_read = 0;
    while(bytes_read < file_size-16)
    {
        struct instruction instr;
        memset(&instr, 0, sizeof(instr));
        mca_decode(&instr, arch, data_buffer, 0);

        instruction_info(instr);

        bytes_read += 16;
        data_buffer += 16;
    }
    return 0;
}

Il "programma" avrà come input il file generato dall'assembler e l'architettura target, che sarà 1 per x86 e 2 per x64. Questo è un esempio di ouput:
Codice:
RAW bytes (hex): 00 D8
Instr. length: 2
Print instruction fields:
        Located Prefixes 0:

        OP: 0x0
        mod_reg_rm: 0xD8
------------------------------------------------
RAW bytes (hex): 36 89 0B
Instr. length: 3
Print instruction fields:
        Located Prefixes 1:
                0x36
        OP: 0x89
        mod_reg_rm: 0xB
------------------------------------------------

Il sorgente che ho assemblato è:
Codice:
[BITS 32]

; Macros
pad     equ   16


op00:   add     al, bl
times   pad - ($ - $op00) db 0xCC

op01:   mov   ss:[ebx], ecx
times   pad - ($ - $op01) db 0xCC


; Variables
; ---------------------------------
var:
    times pad - ($ - $var) db 0x90

Per qualsiasi cosa fate riferimento al manuale, ma in sintesi se aprite con un editor esadecimale il file bin generato, vedrete che ogni istruzione ha una "larghezza fissa" di 16 bytes. L'ho fatto apposta, per rendere più semplice la lettura e capire a colpo d'occhio dove finisce un'istruzione.

hxd_example.png

La sintassi per assemblare con NASM è:
Codice:
nasm -f bin <nomefile.asm> -o <nomefile.bin>

Verranno comunque riportati gli output, quindi nessuno degli strumenti sopra riportati sarà fondamentale per seguire l'articolo.
L'output di MCA verrà introdotto un pò più avanti, dopo aver prima affrontato alcuni dei campi che vanno a comporre l'istruzione.


1.1    I registri


Un'importante differenza rispetto a 8086 è la dimensione dei registri. Dalla CPU i386 i registri sono ora ampi 32-bit; i registri di segmento sono aumentati, e la loro dimension rimane di 16-bit. L'immagine sottostante ne riepiloga i nomi:

reg_x86_gpr.png

Il prefisso E sta a significare "Extended".

Come si può immaginare, x64 introduce a sua volta altri registri: ora EIP prende il nome di RIP, in maniera analoga tutti gli altri registri avranno come prefisso R. Sono stati introditti numerosi nuovi registri da r8 a r15, tutti di 64-bit. Si può accedere in maniera analoga a come fatto per 8086 e per x86 alla parte più bassa: ad esempio, i 32bit più bassi di r8 si trovano in r8d, mentre i 16bit bassi e gli 8-bit bassi si trovano rispettivamente in r8w e r8b.

reg_x64_gpr.png


Come vedremo l'introduzione di questi registri è causa di cambiamenti anche importanti nella codifica dell'istruzione; il campo REX è infatti sempre presente quando si effettua un'operazione che coinvolge questi registri.

NOTA: mi sono perso il registro EFLAGS/RFLAGS, che è ampio rispettivamente 64-bit e 32-bit.

Ci sono altri registri che non ho citato, in quanto non rientrano in quelli chiamati GRP (General Pourpose Registers), e sono ad esempio i registri di controllo (CR), di debug e altri come MCR.


2    Campi di un'istruzione

Anche in x86 e in x64 l'istruzione è composta da diversi campi, quasi tutti non obbligatori, ad eccezione dell'opcode. L'immagine che segue riepiloga le varie parti e identifica un'istruzione in 64-bit mode (in x86 non è presente il prefisso REX):

istruzione.png

Le logiche sono sempre le stesse di 8086. L'opcode può essere da 1 a 3-byte (in due codifiche diverse, come vedremo). Ogni opcode identifica un'istruzione... anche se non è sempre così, come avremo modo di vedere in seguito.


2.1    Prefissi

I prefissi disponibili che si possono incontrare sono presenti nell'immagine sottostante:

legacy_prefix.PNG

Sulle REP/REPNE ho riportato "utilizzabili con le stringhe" per indicare un utilizzo; ma in generale servono per iterare un'istruzione N volte (quante presente in ECX, che è un "operando implicito") e per copiare dati da un buffer ad un altro (puntati da ESI/EDI solitamente).

I prefissi Grp2 sono tutti segment override. Questi prefissi vengono aggiunti solo nel caso in cui l'associazione di default tra il registo di segmento e il registro generale deve essere bypassata; in tutti gli altri casi non saranno presenti.
Un esempio senza Override:

Codice:
mov   [ebx], ecx

viene tradotto in 89 0B.
Aggiungendo il prefisso SS in questo modo mov [ebx], ecx si ottiene invece 36 89 0B.


2.2    REX Prefix

Questo prefisso è davvero interessante, poichè cambia il significato di alcuni opcode. E' presente solo in x64 e come valore è compreso tra 0x40 e 0x4F. L'aspetto interessante è che in x86 questo range è occupato dalle istruzioni INC/DEC (incremento e decremento di un'unità). Il prefisso è stato introdotto per poter gestire i nuovi registri che iniziano per R (RAX, RBX, RCX,...) così come la codifica dei registri delle altre estensioni (SSE). Quindi, ogni qual volta viene utilizzato uno di questi registri, è presente un byte in più nell'istruzione.

Per chiarire, riporto due istruzioni, la prima x86 e la seconda x64, che fa uso dei nuovi registri:
Codice:
mov   eax, ebx      //  89 D8
mov   rax, rbx      //  48 89 D8

Il primo nibble (4-bit) più significativo è quindi sempre 0x4, mentre il secondo ha un significato codificato: ogni bit è indicato come W, R, X e B.

W
Operand Size: 0 se la dimensione viene ricavata dal valore del bit CS.D (CS è il Code Segment), che può essere 16 o 32bit; 1 in caso di 64bit​
R
estensione del campo reg di ModRm​
x
estensione del campo index in SIB​
B
estensione di r/m del byte ModRm o base del SIB​

Per non appesantire con dettagli specifici lascio a voi eventuali approfondimenti; nel corso dell'articolo vedremo comunque qualcuna delle codifiche.


2.3    Opcode

L'opcode in x86 e x64 può avere da un minimo di 1-byte a un massimo di 3-byte. Un esempio di opcode a 1-byte è quello che abbiamo visto in precedenza:
Codice:
mov   eax, ebx      //  89 D8

Guardiamo ora un'altra istruzione:

Codice:
cmove  eax, ebx

L'istruzione è "Conditional Move": la condizione è in questo caso l'ultima lettera "e" (equals). Il formato dell'istruzione è infatti CMOVcc dove al posto di cc si andrà a utilizzare la condizione desiderata.
La codifica di questa istruzione è questa:

Codice:
0F 44 C3

Riporto il significato dei vari campi (mod_reg_rm verrà visto in seguito, il nome esatto in realtà da codifica Intel è ModRm):

Codice:
RAW bytes (hex): 0F 44 C3
Instr. length: 3
Print instruction fields:
        Located Prefixes 1:
                0xF
        OP: 0x44
        mod_reg_rm: 0xC3

Se guardate l'immagine sottostante, capirete il significato del primo byte:

one_byte_instr_table.png

In particolare guardate il valore di 0x0F, si tratta di "2-byte escape". Il modo in cui viene codificato il secondo byte in x86 e x64 è proprio per mezzo del byte di escape. Quello che segue nell'istruzione sopra è l'effettivo OP che va cercato nella tabella relativa alla codifica delle istruzioni con 2-bytes.

Con un meccanismo analogo vengono codificate le istruzioni con 3-bytes, anche se è la codifica è un pò più complicata: ci sono due tabelle distinte e ci sono due escape differenti.

two_byte_escape.png

In particolare prestate attenzione alle celle 0x38 e 0x3A: sono byte di escape che seguono l'escape visto in precedenza. Vediamo un paio di esempi:

Codice:
movbe eax, [var]

Questa istruzione assegna il valore puntato dalla variabile (alla locazione di memoria "var") al registro EAX. La codifica è:

Codice:
0F 38 F0 05 50 00 00 00

Il significato dei vari bytes è riportato di seguito:

Codice:
RAW bytes (hex): 0F 38 F0 05 50 00 00 00
Instr. length: 8
Print instruction fields:
        Located Prefixes 2:
                0xF 0x38
        OP: 0xF0
        mod_reg_rm: 0x5
        disp (4): 0x50

Ora un caso più complesso per vedere la codifica di 0x0f 0x3A:

Codice:
sha1rnds4 xmm0, xmm2, 0x22

Viene codificata come:
Codice:
RAW bytes (hex): 0F 3A CC C2 22
Instr. length: 5
Print instruction fields:
        Located Prefixes 2:
                0xF 0x3A
        OP: 0xCC
        mod_reg_rm: 0xC2
        Iimm: 0x22

Le istruzioni con opcodes di 3-bytes sono sicuramente più rare rispetto alle altre (in particolare quelle da 1-byte).
Nel precedente esempio i reigstri coinvolti fanno parte del set SSE.


2.4    VEX prefix


Vale la pena menzionare qui anche un altro nuovo prefisso che prende il nome di VEX. Questa codifica viene utilizzata in svariati casi tra i quali la presenza di 3 operandi e l'uso dei registri YMM (256-bit) e XMM. L'introduzione di questo prefisso la si deve al set di istruzioni AVX.

L'immagine sottostante riporta la codifica di questo prefisso:

VEX_format.png

Iniziamo da un'istruzione che non fa uso di VEX:

Codice:
aesdeclast  xmm0, xmm1

Il codice macchina sarà il seguente:

Codice:
RAW bytes (hex): 66 0F 38 DF C1
Instr. length: 5
Print instruction fields:
        Located Prefixes 3:
                0x66 0xF 0x38
        OP: 0xDF
        mod_reg_rm: 0xC1

Si tratta di un'istruzione che fa uso di 3-bytes per codificare l'opcode, e in aggiunta richiede il prefisso 0x66. Se la codifichiamo utilizzando VEX otteniamo:

Codice:
vaesdeclast  xmm0, xmm1

La codifica è:

Codice:
RAW bytes (hex): C4 E2 79 DF C1
Instr. length: 5
Print instruction fields:
        Located Prefixes 0:

        VEX prefix value:
                0xC4 0xE2 0x79
        Field 0xE2:
                r: 1
                x: 1
                b: 1
                m: 2

        Field 0x79:
                W: 0
                v: F
                L: 0
                pp: 1


        OP: 0xDF
        mod_reg_rm: 0xC1

Si tratta di un'istruzione codificata con 0xC4, che secondo le specifiche di intel, indica un prefisso VEX di 3byte (0xC5 è a 2-byte; esiste anche il prefisso XOP di AMD, che è a 3byte).
Una delle comodità di VEX è che nei bytes successivi al prefisso (il primo) codifica altre info: permette ad esempio di non avere un REX prefix e includerlo direttamente qui, oppure com e in questo caso, di avere un prefisso obbligatorio codificato implicitamente nell'istruzione: se notate non è più presente 0x66. Questo in quanto il campo "pp" specifica la presenza di un prefisso obbligatorio (o nessuno), come potete vedere dall'immagine poco sopra.

Il prefisso 'v' davanti all'istruzione indica sostanzialmente di utilizzare la versione AVX di quell'istruzione; un altro esempio può essere:

Codice:
movups   xmm0,  [var]

codificata come:

Codice:
RAW bytes (hex): 0F 10 05 80 00 00 00
Instr. length: 7
Print instruction fields:
        Located Prefixes 1:
                0xF
        OP: 0x10
        mod_reg_rm: 0x5
        disp (4): 0x80

utilizzando il set SSE.
E codificata come riportata di seguito, se viene utilizzato AVX:

Codice:
RAW bytes (hex): C5 F8 10 05 80 00 00 00
Instr. length: 8
Print instruction fields:
        Located Prefixes 0:

        VEX prefix value:
                0xC5 0xF8
        Field 0xF8:
                r: 1
                v: F
                L: 0
                pp: 0

        OP: 0x10
        mod_reg_rm: 0x5
        disp (4): 0x80


2.5    ModRm


Questo byte è particolarmente importante e il suo funzionamento rimane pressochè simile a 8086. Lo scopo è codificare le modalità di indirizzamento. Nei due screenshot sottostanti potete vedere le numerose modalità a disposizione:

addr_forms_1.png


addr_forms_2.png

Come in 8086, il significato dei bit di questo campo è il medesimo:

modregrm.png

I 2 bit di mod specificano se l'istruzione avviene tra due registri (0x11) oppure se coinvolge la memoria; in caso coinvolta la memoria vengono usate le codifiche riportate nelle immagini sopra.


2.6    SIB byte


Questo byte è nuovo, non era presente in 8086; di seguito riporto il formato:

sib.png

Più nel dettaglio, i vari campi assumono questo significato:

dettaglio_sib.png

Quando lo si utilizza? Bhe, un utilizzo molto comune e che si vede spesso nel codice compilato, è l'accesso a un array:

Codice:
mov   ebx, [var + EDX * 4]

Dove 'var' è una locazione di memoria (può essere la base di un array, la locazione 0) mentre EDX è l'indice che usiamo; il 4 è la dimensione di ogni elemento dell'array. Qui abbiamo già tutte le componenti del SIB, e infatti...

Codice:
RAW bytes (hex): 8B 1C 95 90 00 00 00
Instr. length: 7
Print instruction fields:
        Located Prefixes 0:

        OP: 0x8B
        mod_reg_rm: 0x1C
        SIB byte: 0x95
        disp (4): 0x90

Viene inserito il SIB byte. Il valore è 0x95: 1001'0101, scomponendolo nelle varie componenti:

Codice:
scaled = 10
index  = 010
base   = 101

il che significa di utilizzare come 'scale' il valore 4 come registro indice EDX e come base 101, quindi il displacement (i bit mod di ModRm sono uguali a 0), che è la locazione 'var' nell'assembly scritto sopra.

Ora riporto la terza tabella che ha a che fare con l'indirizzamento:

addr_forms_3.png


2.7    Displacement


Come in 8086 il campo displacement, lo spiazzamento, indica un indirizzo di memoria; l'abbiamo già di fatto trattato negli esempi sopra. Ogni qual volta è presente un'operazione che ha a che fare con la memoria, è presente lo spiazzamento.


2.8    Immediate


Si tratta di un valore 'immediato', presente in tutti quei casi dove un numero viene direttamente assegnato a un registro (o alla memoria).
Esempio utilizzando x64:

Codice:
mov    rax, 0x1122334455667788

L'istruzione viene codificata in questo modo:

Codice:
RAW bytes (hex): 48 B8 88 77 66 55 44 33 22 11
Instr. length: 10
Print instruction fields:
        Located Prefixes 1:
                0x48

        REX 0x48:
        OP: 0xB8
        Iimm: 0x1122334455667788

Come si nota il valore immediato è codificato nell'istruzione stessa (in little endian, il byte meno importante è il primo).


3    RIP Relative Addressing


In x64 esiste un'altra modalità di indirizzamento, che si chiama appunto "RIP relative addressing". Come si evince dal nome fa riferimento al registro RIP e viene codificata quando è presente un campo ModRm con r/m = 101b.

Ecco un esempio (codificato in NASM):
Codice:
mov   rbx, [rel next]
nop
nop
next:

viene generato:

Codice:
RAW bytes (hex): 48 8B 1D 02 00 00 00
Instr. length: 7
Print instruction fields:
        Located Prefixes 1:
                0x48

        REX 0x48:
        OP: 0x8B
        mod_reg_rm: 0x1D
        disp (4): 0x2

Dove la componente displacement è RIP+2 (i 2 NOP, 1 byte ciascuno, li ho inseriti apposta), dove RIP punta alla prossima istruzione (il primo NOP).


4    Conclusione


Ho trattato solo parzialmente x86 e x64 ed ho solo accennato ai set SSE e AVX, ma ci sarebbe molto altro da aggiungere e non solo su questi due set di istruzioni, ma anche su altri aspetti che riguardano la codifica (il prefisso EVEX, per esempio). Non ho accennato ad AVX-512, che introduce anche nuovi registri (ZMM) estendendo i precedenti.

Ho letteralmente scritto l'articolo con pause di mesi tra una parte e la successiva (la primissima stesura risale a qualche anno fa, l'ho ricominciato 3-4 volte da allora, e poi ho mantenuto quest'ultima versione di un paio di anni fa, modificandola un pò), se trovate qualcosa di errato fatemelo notare. ;)


5    Risorse


- Documentazione Intel, in particolare il manuale 2
- OSDev - x86_64 Instruction Encoding