Proseguiamo con l’analisi del codice delle ISA x86 e x64 dal punto di vista statistico, questa volta puntando l’attenzione sulla distribuzione delle varie dimensioni delle istruzioni.
Ricordiamo che entrambe le ISA presentano opcode di lunghezza variabile, che vanno da 1 a un massimo di 15 byte. In realtà il capostipite della famiglia, l’8086, non aveva un limite vero e proprio per la lunghezza di un’istruzione, che non fosse rappresentato dalla dimensione del segmento del codice. A partire dall’80286, però, Intel ha imposto a 15 byte la lunghezza massima di un opcode.
Per l’eseguibile della beta pubblica di Adobe Photoshop CS6 a 32 bit (PS32 d’ora in poi) la situazione è la seguente:
Size Count 2 440045 3 419064 1 298078 5 190101 6 157035 4 136394 7 97835 10 4660 8 2411 11 829 9 117 Average length: 3.2
Le istruzioni più diffuse sono quelle di 2 o 3 byte, ma non sorprende molto nemmeno il terzo risultato, perché sono molto comuni le istruzioni di PUSH e POP dei registri, ad esempio. Per quelle di 2 o 3 byte la parte del leone è rappresentata dalle istruzioni che fanno riferimento alla memoria, e nel primo caso anche i salti relativi “corti” (con un byte di offset).
Le istruzioni di 4, 5 e 6 byte sono anch’esse relativamente frequenti, e sono quelle che ovviamente fanno lievitare la dimensione media delle istruzioni, portandola al valore di 3,2 byte, come abbiamo visto nel precedente articolo. In particolare fra quelle di 5 e 6 byte (e in parte anche 7) è molto diffusa la famigerata LEA, che consente di eseguire operazioni aritmetiche con più registri e un valore immediato, sopperendo a una grossa carenza dell’ISA. Anche le CALL, anch’esse molto usate, occupano 5 byte.
Molto comuni, per quelle di 7 byte, sono le istruzioni utilizzate, oltre che per la LEA già citata, per caricare costanti a 32 bit direttamente in una locazione di memoria. Operazione, questa, decisamente comoda e vantaggiosa, specialmente per un’architettura che ha così pochi registri general purpose (7 più uno per lo stack), la quale, quindi, consente di evitare di dover ricorrere ad almeno due istruzioni, sporcare un registro (in architetture “load/store“, come i RISC), e aggiungere un’eventuale load (per la costante, se questa è troppo grande per essere specificata come immediato) dalla memoria.
Sopra i 7 byte le istruzioni si fanno molto più rare, e vanno a inquadrasi quasi tutte in operazioni verso la memoria (quindi non soltanto le classiche MOVE, ma anche CMP, ADD, SUB, e TEST), spesso con un valore immediato a 32 bit. Anche qui roba molto CISC, che aiuta la densità di codice, riduce l’uso dei registri, come pure le dipendenze nella pipeline (che si creano, invece, con le altre architetture, dovendo ricorrere a dei registri “di appoggio” per il valore immediato caricato, per le operazioni da eseguire, e per l’eventuale memorizzazione del risultato).
Per la versione a 64 bit (PS64), invece, abbiamo il seguente quadro:
Size Count 3 362650 5 353288 4 283352 2 240530 8 172284 7 131164 1 91535 6 80322 9 12945 10 3725 11 3257 12 1997 13 126 14 89 15 67 Average length: 4.3
La situazione sembra molto diversa, o comunque inaspettata, visto che x64 deriva direttamente da x86 e la estende. In effetti questi freddi numeri, presi da soli, ingannano. Tenendo conto di quanto detto nel precedente articolo, invece, le similitudini saltano fuori, come vedremo quando verrà riportato l’elenco delle istruzioni più frequenti per entrambe.
Non v’è dubbio che x64 si possa considerare una nuova ISA, in quanto le estensioni apportate a x86 sono notevoli e portano a realizzare codice che può sembrare diverso a primo acchito, ma le istruzioni sono sostanzialmente le stesse (in x64 alcune vecchie / legacy sono state rimosse), e idem le modalità d’indirizzamento verso la memoria (con l’eccezione di quella nuova relativa al PC). Tutto ciò si riflette in quello che emerge poi dai disassemblati.
Tuttavia stride, e non poco, la differenza fra i 3.2 byte medi per istruzione x86 e i 4.3 per x64. Anche supponendo, e non è così, che ogni istruzione x64 facesse uso del prefisso REX per accedere agli 8 nuovi registri e/o consentire di sfruttare i 64 bit, i conti non tornerebbero, perché avremmo dovuto ottenere 4.2 byte medi e non 4.3. La differenza è piccola, ma non nulla, e in ogni caso ci stiamo mettendo nell’ipotesi del tutto irreale di impiegare sempre il prefisso REX.
La spiegazione va ricercata nella differente ABI utilizzata. x86 predilige il push dei parametri sullo stack quando deve passarli a una routine da chiamare, mentre x64 sfrutta l’abbondanza dei registri che ha a disposizione (il che gli consente anche di evitare dipendenze con lo stack, e consumare meno banda verso la memoria e/o la cache).
Mettendo assieme questo e l’uso del prefisso REX si arriva a comprendere il perché della differente distribuzione della lunghezza delle istruzioni. Ad esempio, è possibile notare che il numero di istruzioni di lunghezza di 1 byte è drasticamente ridotto, mentre prima aveva un peso notevole. Ciò è dovuto alla netta riduzione del numero di PUSH (in particolare) e POP dei registri (in misura minore), che occupano esattamente 1 byte in x86, mentre su x64 l’accesso ai nuovi 8 registri richiede il prefisso REX, e quindi occupano 2 byte.
Questo significa due cose. La prima è che le istruzioni PUSH e POP “residue” si distribuiscono fra 1 e 2 byte, ma questo avviene per buona parte delle istruzioni, e spiega pure il perché del progressivo aumento della dimensione per tutte le fasce. Per essere chiari, istruzioni x86 di 2 byte adesso, con x64, si trovano distribuite fra 2 e 3 byte; quelle che erano di 3 byte sono fra 3 e 4 byte, e così via.
A ciò si aggiunge il fatto che, togliendo di mezzo le PUSH per caricare sullo stack i valori da passare alle routine, adesso si fa molto più ricorso a istruzioni come LEA e MOVE, che mediamente occupano più spazio, specialmente la prima. Infatti possiamo vedere che le istruzioni dominanti sono diventate quelle di 4 e 5 byte, mentre è addirittura spuntata la categoria di quelle di 8 byte, che su x86 sono praticamente insignificanti.
Rimane, però, una grossa anomalia, peraltro già percepibile immediatamente: sono presenti istruzioni che arrivano perfino a 15 byte, che è la dimensione massima consentita per queste ISA. Facendo un rapido conto, l’istruzione più lunga dovrebbe occupare 4 byte per un valore immediato a 32 bit, 4 byte per l’offset per l’indirizzamento della memoria, 2 byte per Mod-R/M e SIB, uno per l’opcode vero e proprio, un altro per il prefisso REX, uno per il prefisso di segmento, e un altro ancora per quello di LOCK; totale: 14 byte.
A parte il fatto che è altamente improbabile che possa essere utilizzata un’istruzione del genere (anche perché i segmenti/selettori sono sostanzialmente spariti; vengono ancora usati, e vedremo come e perché, ma sono un’esigua minoranza, e in ogni caso le istruzioni che li usano non sono così lunghe), mancherebbe comunque un byte all’appello per arrivare ai 15 byte che contano ben 67 istruzioni di tale lunghezza.
Il motivo va ricondotto all’uso di padding col codice x64. Spesso vengono introdotte apposite istruzioni NOP, che vanno da 1 a 15 byte, per allineare il codice a 16 byte. Per la precisione, viene allineato il codice che rappresenta l’entry point per un indirizzo di salto. In questo modo quando il processore esegue un salto, essendo l’indirizzo del salto allineato a 16 byte, c’è la concreta possibilità che vengano decodificate molte più istruzioni da quella linea di cache rispetto a un salto a un indirizzo disallineato, visto che la decodifica delle istruzioni parte esattamente dal primo byte della linea di cache, anziché dalla metà (mediamente).
Nel prossimo articolo metteremo a confronto alcuni spezzoni di codice x86 e x64 per vedere “sul campo” quanto finora trattato a livello puramente descrittivo e numerico.