Dopo l’analisi e i freddi numeri riportati nei precedenti articoli, passiamo a verificare il codice x86 e x64, dando un’occhiata ad alcuni spezzoni di codice e mostrando come si comportano le due architetture quando c’è da effettuare lo stesse elaborazioni, cercando riscontro a quanto detto finora.
La scelta del codice deve, però, soddisfare alcuni criteri. Dovrebbe offrire uno spaccato abbastanza comune, senza perdersi in listati chilometrici che disperdono l’attenzione e l’interesse, ma soprattutto è necessario recuperare spezzoni “uguali”, che elaborino sostanzialmente la stessa cosa, in modo da rendere semplice il confronto fra le due ISA.
Cercare le similitudini fra le istruzioni in modo da recuperare la stessa routine non è cosa semplice, poiché i due disassemblati, per x86 e x64, non le riportano fedelmente, ma hanno “ramificazioni” diverse o sono proprio pezzi di codice diversi. Il disassemblato, infatti, parte dall’entry point, e cerca di scovarli, ma è impossibile che tutto il codice venga coperto, a causa dell’approccio conservativo che s’è scelto (non vengono disassemblate porzioni che potrebbero contenere dati, ma soltanto riferimenti alle istruzioni di salto).
In ogni caso non siamo in presenza della medesima sequenza di blocchi di codice, per cui è necessario partire da qualcosa, trovare un modo per arrivare allo scopo, e in questo le statistiche accumulate hanno dato una mano. Infatti l’idea è stata quella di utilizzare le istruzioni più rare come “cavalli di Troia”, individuando nel mezzo del listato la routine che le contenessero, e verificando poi che esistesse la stessa nell’altro disassemblato (che ovviamente avrebbe contenuto la medesima istruzione).
Una di quelle che ha permesso tutto ciò è stata la PAUSE, che è presente soltanto tre volte nel codice x86 della beta pubblica di Adobe Photoshop CS6 (PS32, come già descritto nei precedenti articoli) e soltanto due volte in quello x64 (PS64). La ridottissima frequenza ha consentito di poter verificare abbastanza velocemente che, preso un blocco di codice x86, ci fosse il corrispondente x64, come si è poi verificato, e fosse utilizzabile per lo scopo prefisso.
Per PS32 abbiamo la seguente routine:
0x00de37c0 (1) 57 PUSH EDI 0x00de37c1 (2) 8bf9 MOV EDI, ECX 0x00de37c3 (5) b801000000 MOV EAX, 0x1 0x00de37c8 (2) 8701 XCHG [ECX], EAX 0x00de37ca (3) 83f801 CMP EAX, 0x1 0x00de37cd (2) 7535 JNZ 0xde3804 0x00de37cf (1) 53 PUSH EBX 0x00de37d0 (6) 8b1d94131602 MOV EBX, [0x2161394] 0x00de37d6 (1) 56 PUSH ESI 0x00de37d7 (2) 8b17 MOV EDX, [EDI] 0x00de37d9 (5) be01000000 MOV ESI, 0x1 0x00de37de (2) 85d2 TEST EDX, EDX 0x00de37e0 (2) 7412 JZ 0xde37f4 0x00de37e2 (2) f390 PAUSE 0x00de37e4 (2) 8bc6 MOV EAX, ESI 0x00de37e6 (1) 46 INC ESI 0x00de37e7 (3) 83f820 CMP EAX, 0x20 0x00de37ea (2) 7e02 JLE 0xde37ee 0x00de37ec (2) ffd3 CALL EBX 0x00de37ee (2) 8b0f MOV ECX, [EDI] 0x00de37f0 (2) 85c9 TEST ECX, ECX 0x00de37f2 (2) 75ee JNZ 0xde37e2 0x00de37f4 (5) ba01000000 MOV EDX, 0x1 0x00de37f9 (2) 8bc7 MOV EAX, EDI 0x00de37fb (2) 8710 XCHG [EAX], EDX 0x00de37fd (3) 83fa01 CMP EDX, 0x1 0x00de3800 (2) 74d5 JZ 0xde37d7 0x00de3802 (1) 5e POP ESI 0x00de3803 (1) 5b POP EBX 0x00de3804 (1) 5f POP EDI 0x00de3805 (1) c3 RET
Mentre per PS64:
0x0000000140b674d0 (2) 4057 PUSH RDI 0x0000000140b674d2 (4) 4883ec20 SUB RSP, 0x20 0x0000000140b674d6 (5) b801000000 MOV EAX, 0x1 0x0000000140b674db (3) 488bf9 MOV RDI, RCX 0x0000000140b674de (2) 8701 XCHG [RCX], EAX 0x0000000140b674e0 (3) 83f801 CMP EAX, 0x1 0x0000000140b674e3 (2) 7543 JNZ 0x140b67528 0x0000000140b674e5 (5) 48895c2430 MOV [RSP+0x30], RBX 0x0000000140b674ea (6) 660f1f440000 NOP WORD [RAX+RAX+0x0] 0x0000000140b674f0 (2) 8b07 MOV EAX, [RDI] 0x0000000140b674f2 (5) bb01000000 MOV EBX, 0x1 0x0000000140b674f7 (2) 85c0 TEST EAX, EAX 0x0000000140b674f9 (2) 741c JZ 0x140b67517 0x0000000140b674fb (5) 0f1f440000 NOP DWORD [RAX+RAX+0x0] 0x0000000140b67500 (2) f390 PAUSE 0x0000000140b67502 (2) 8bc3 MOV EAX, EBX 0x0000000140b67504 (2) ffc3 INC EBX 0x0000000140b67506 (3) 83f820 CMP EAX, 0x20 0x0000000140b67509 (2) 7e06 JLE 0x140b67511 0x0000000140b6750b (6) ff15ef74ec01 CALL QWORD [RIP+0x1ec74ef] ; KERNEL32.SwitchToThread 0x0000000140b67511 (2) 8b07 MOV EAX, [RDI] 0x0000000140b67513 (2) 85c0 TEST EAX, EAX 0x0000000140b67515 (2) 75e9 JNZ 0x140b67500 0x0000000140b67517 (5) b801000000 MOV EAX, 0x1 0x0000000140b6751c (2) 8707 XCHG [RDI], EAX 0x0000000140b6751e (3) 83f801 CMP EAX, 0x1 0x0000000140b67521 (2) 74cd JZ 0x140b674f0 0x0000000140b67523 (5) 488b5c2430 MOV RBX, [RSP+0x30] 0x0000000140b67528 (4) 4883c420 ADD RSP, 0x20 0x0000000140b6752c (1) 5f POP RDI 0x0000000140b6752d (1) c3 RET
Ogni riga riporta l’indirizzo (virtuale), tra parentesi il numero di byte (in esadecimale) occupati dall’istruzione, la codifica esadecimale dell’opcode, e infine lo mnemonico con gli eventuali argomenti a seguire. Tutte le costanti sono riportate sempre in esadecimale.
Senza soffermarci su cosa faccia il codice, già dalla prima istruzione è possibile individuare il contributo e le differenze dell’architettura x64 rispetto a quella x86. Infatti siamo in presenza dell’istruzione PUSH RDI anziché della classica PUSH EDI: sullo stack finisce l’intero contenuto del registro a 64 bit, anziché quello a 32 bit, com’è giusto che sia, visto che bisogna poi ripristinarlo all’uscita dalla routine.
Salta subito all’occhio la presenza del byte 40 all’inizio dell’opcode, il quale identifica immediatamente il prefisso REX (è un byte i cui valori ricoprono l’intervallo 40-4F, che in x86 era impiegato per codificare le istruzioni di INC e DEC sui registri), utilizzato per indirizzare gli 8 nuovi registri oppure per forzare l’uso dei 64 bit anziché i 32 bit che sono la dimensione di default anche su x64.
Sembrerebbe, quindi, tutto regolare: RDI è un registro a 64 bit, dunque richiede l’uso di REX per utilizzare anche i 32 bit superiori, senonché questo prefisso risulta del tutto inutile, poiché non ha alcune effetto, e questo per due motivi.
Il primo è che le istruzioni di PUSH e POP sono fra le poche che utilizzano operandi che di default sono a 64 bit anziché a 32, e dunque non necessitano del prefisso REX per forzare la dimensione a 64 bit, perché questa risulta già impostata.
Il secondo è che il prefisso REX risulta errato, in quanto non è impostato il flag che indica la richiesta di utilizzare 64 bit anziché 32 per l’istruzione. Anziché 40 il prefisso sarebbe dovuto essere 48, com’è possibile notare nell’istruzione immediatamente successiva, SUB RSP, 0x20, che presenta correttamente tale valore.
Si tratta quasi sicuramente di un bug del compilatore, che genera un prefisso REX ridonante in questo caso. La conferma è data dalla duale POP RDI presente alla fine della routine che, com’è possibile vedere, non usa alcun prefisso REX, ed è, quindi, costituita da un solo byte.
Si potrebbe pensare che questo bug contribuisca a far diminuire la densità del codice rispetto a quello x86, e ciò è teoricamente vero, ma bisogna considerare che per x64 le PUSH sono di gran lunga meno frequenti, per cui, a meno di errori di arrotondamento, la situazione rimarrebbe sostanzialmente invariata.
A tal proposito sappiamo che il codice a 64 bit occupa mediamente il 34% di spazio in più rispetto a quello 32 bit (4,3 byte contro 3,2), situazione che si rispecchia in questo caso, dove i 94 byte del primo corrispondo al 34% in più di spazio rispetto ai 70 byte del secondo. Anche togliendo di mezzo il prefisso REX superfluo, avremmo in ogni caso un’occupazione maggiore del 33%.
Tornando al confronto fra le due versioni della routine, si nota una scarsa influenza del prefisso REX (e, quindi, dell’uso dei 64 bit e/o dei nuovi registri) nel codice. Tolto il caso anomalo del PUSH, viene utilizzato soltanto 5 volte: decisamente poco per giustificare la considerevole diminuzione della densità del codice.
La causa principale va ricercata nella differente ABI utilizzata. x86 predilige il PUSH dei registri da preservare sullo stack, per poi ripristinarli alla fine della routine, com’è possibile vedere dallo spezzone riportato.
x64, invece, usa poco i PUSH, mentre preferisce creare uno stack frame (per le variabili locali e/o temporanee), e salvare o ripristinare i registri in questo spazio. Lo si vede all’inizio e alla fine della routine, che presenta le istruzioni SUB RSP, 0x20 e ADD RSP, 0x20 per crearlo ed eliminarlo, mentre la MOV [RSP+0x30], RBX e la MOV RBX, [RSP+0x30] si occupano rispettivamente di conservare a recuperare il valore del registro RBX.
Si tratta di istruzioni molto costose in termini di spazio, e che francamente si potrebbero anche evitare, dato il tipo di codice eseguito in ultima analisi, che potrebbe essere organizzato diversamente, magari ricalcando in parte l’ABI x64 in alcuni casi.
Sarebbero possibili anche altre ottimizzazioni, e il codice lascia trasparire anche la possibilità, per Intel o AMD, di introdurre nuove istruzioni atte a coprire meglio alcuni casi d’uso frequenti (è strano che non sia stato fatto in tutto questo tempo), ma non è questo il momento per discuterne, visto che l’articolo è stato scritto per un argomento diverso.
Dall’analisi del codice x64 emerge anche l’uso del padding di cui avevamo parlato nel precedente articolo, che serve ad allineare il codice del target dei salti a multipli di 16 byte. Sono presenti, infatti, due istruzioni NOP che da sole occupano la bellezza di 11 byte, che su 94 (o 93, tolto il REX in più) hanno senz’altro un peso non indifferente nella maggiore occupazione dello spazio.
Sia chiaro che la situazione generale non è quella rappresentata da questa routine, che è stata presa soltanto come esempio per mostrare come anche praticamente si possa trovare riscontro di quanto analizzato nei due articoli già pubblicati. Le tipologie di codice dipendono dal tipo di applicazione, e all’interno della stessa sono presenti parti anche molto eterogenee, quindi quanto visto finora non si può applicare alla generalità, ma fornisce un quadro, un andamento, che ha trovato riscontro anche in altre applicazioni disassemblate.
Sarebbe stato interessante, per completare il pezzo, riportare anche il caso di chiamata a routine con passaggio dei parametri, in modo da mostrare l’uso massiccio dei PUSH nel caso di x86 e delle MOVE e LEA nel caso di x64, ma non è stato possibile, per i (numerosi) tentativi effettuati e il poco tempo a disposizione, trovare due spezzoni che riportassero esattamente la stessa situazione.
Il prossimo pezzo della serie si occuperà dell’analisi delle istruzioni per numero di argomenti.