Comprendere l'ABI di Arm64EC e il codice assembly

Arm64EC ("Emulation Compatible") è una nuova interfaccia binaria per applicazioni (ABI) per la creazione di applicazioni per Windows 11 su Arm. Per una panoramica su Arm64EC e su come iniziare a costruire applicazioni Win32 come Arm64EC, consulta . Usare Arm64EC per costruire applicazioni per Windows 11 su dispositivi Arm.

Lo scopo di questo documento è quello di fornire una visione dettagliata dell'ABI di Arm64EC con informazioni sufficienti per consentire a uno sviluppatore di applicazioni di scrivere e debuggare il codice compilato per Arm64EC, compreso il debug a basso livello dell'assemblatore e la scrittura di codice assembly mirato all'ABI di Arm64EC.

Progettazione di Arm64EC

Arm64EC è stato progettato per offrire funzionalità e prestazioni di livello nativo, fornendo al contempo un'interoperabilità trasparente e diretta con il codice x64 in esecuzione sotto emulazione.

Arm64EC è principalmente additivo all'ABI Arm64 classico. L'ABI classico è stato modificato in minima parte, ma sono state aggiunte alcune parti per consentire l'interoperabilità x64.

In questo documento, l'ABI Arm64 originale e standard sarà indicato come "ABI classico". In questo modo si evita l'ambiguità insita in termini sovraccarichi come "nativo". Arm64EC, per essere chiari, è nativo come l'ABI originale.

Arm64EC vs Arm64 Classic ABI

Il seguente elenco indica dove Arm64EC si è discostato dall'ABI di Arm64 Classic.

Si tratta di piccoli cambiamenti se visti in prospettiva rispetto a quanto definisce l'intero ABI.

Mappatura dei registri e registri bloccati

Affinché ci sia interoperabilità a livello di tipo con il codice x64, il codice Arm64EC è compilato con le stesse definizioni di architettura del preprocessore del codice x64.

In altre parole, _M_AMD64 e _AMD64_ sono definiti. Una delle tipologie interessate da questa regola è la struttura CONTEXT . La struttura CONTEXT definisce lo stato della CPU in un determinato momento. È utilizzato per cose come Exception Handling e GetThreadContext API. Il codice x64 esistente si aspetta che il contesto della CPU sia rappresentato come una struttura x64 CONTEXT o, in altre parole, la struttura CONTEXT come viene definita durante la compilazione x64.

Questa struttura deve essere utilizzata per rappresentare il contesto della CPU durante l'esecuzione di codice x64 e di codice Arm64EC. Il codice esistente non capirebbe un concetto nuovo, come il set di registri della CPU che cambia da una funzione all'altra. Se la struttura x64 CONTEXT viene utilizzata per rappresentare gli stati di esecuzione di Arm64, questo implica che i registri Arm64 sono effettivamente mappati in registri x64.

Implica anche che i registri Arm64 che non possono essere inseriti in CONTEXT x64 non devono essere utilizzati, poiché i loro valori possono essere persi ogni volta che si verifica un'operazione che utilizza CONTEXT (e alcuni possono essere asincroni e inaspettati, come l'operazione di Garbage Collection di un Managed Language Runtime o un APC).

Le regole di mappatura tra i registri Arm64EC e x64 sono rappresentate dalla struttura ARM64EC_NT_CONTEXT negli header di Windows, presenti nell'SDK. Questa struttura è essenzialmente un'unione della struttura CONTEXT , esattamente come è definita per x64, ma con una sovrapposizione di registri Arm64.

Ad esempio, RCX mappa a X0, RDX a X1, RSP a SP, RIP a PC, ecc. Possiamo anche vedere come i registri x13, x14, x23, x24, x28, v16-v31 non hanno alcuna rappresentazione e, quindi, non possono essere utilizzati in Arm64EC.

Questa restrizione nell'uso dei registri è la prima differenza tra le ABI di Arm64 Classic ed EC.

Controlli di chiamata

I controllori di chiamate sono parte integrante di Windows da quando Control Flow Guard (CFG) è stato introdotto in Windows 8.1. I controllori di chiamata sono dei sanificatori di indirizzi per i puntatori di funzione (prima che questi oggetti venissero chiamati sanificatori di indirizzi). Ogni volta che il codice viene compilato con l'opzione /guard:cf , il compilatore genererà una chiamata extra alla funzione di controllo prima di ogni chiamata indiretta o salto. La funzione di controllo è fornita da Windows e, per quanto riguarda il CFG, esegue un controllo di validità rispetto agli obiettivi di chiamata noti per essere buoni. Queste informazioni sono incluse anche nei binari compilati con /guard:cf.

Questo è un esempio di utilizzo del call checker in Classic Arm64:

mov     x15, <target>
adrp    x16, __guard_check_icall_fptr
ldr     x16, [x16, __guard_check_icall_fptr]
blr     x16                                     ; check target function
blr     x15                                     ; call function

Nel caso del CFG, il controllore di chiamate restituirà semplicemente se l'obiettivo è valido, o fallirà rapidamente il processo in caso contrario. I call checker hanno convenzioni di chiamata personalizzate. Prendono il puntatore della funzione in un registro non utilizzato dalla normale convenzione di chiamata e conservano tutti i registri della normale convenzione di chiamata. In questo modo, non introducono fuoriuscite di registro intorno a loro.

I controllori di chiamata sono opzionali su tutte le altre ABI di Windows, ma obbligatori su Arm64EC. Su Arm64EC, i call checker accumulano il compito di verificare l'architettura della funzione chiamata. Verificano se la chiamata è un'altra funzione EC ("Emulation Compatible") o una funzione x64 che deve essere eseguita in emulazione. In molti casi, questo può essere verificato solo in fase di esecuzione.

I controllori di chiamate Arm64EC si basano sui controllori Arm64 esistenti, ma hanno una convenzione di chiamata personalizzata leggermente diversa. Richiedono un parametro aggiuntivo e possono modificare il registro contenente l'indirizzo di destinazione. Ad esempio, se il target è un codice x64, il controllo deve essere trasferito prima alla logica di emulazione.

In Arm64EC, lo stesso uso del controllore di chiamata diventerebbe:

mov     x11, <target>
adrp    x9, __os_arm64x_check_icall_cfg
ldr     x9, [x9, __os_arm64x_check_icall_cfg] 
adrp    x10, <name of the exit thunk>
add     x10, x10, <name of the exit thunk>
blr     x9                                      ; check target function
blr     x11                                     ; call function

Le leggere differenze rispetto al Classic Arm64 includono:

  • Il nome del simbolo per il controllore di chiamata è diverso.
  • L'indirizzo di destinazione viene fornito in x11 invece che in x15.
  • L'indirizzo di destinazione (x11) è [in, out] invece di [in].
  • Esiste un parametro aggiuntivo, fornito da x10, chiamato "Exit Thunk".

An Exit Thunk è una funclet che trasforma i parametri delle funzioni dalla convenzione di chiamata Arm64EC alla convenzione di chiamata x64.

Il controllore delle chiamate Arm64EC si trova in un simbolo diverso da quello utilizzato per le altre ABI di Windows. Nell'ABI Classic Arm64, il simbolo del controllore di chiamata è __guard_check_icall_fptr. Questo simbolo sarà presente in Arm64EC, ma è presente per il codice x64 collegato staticamente, non per il codice Arm64EC stesso. Il codice Arm64EC utilizzerà __os_arm64x_check_icall o __os_arm64x_check_icall_cfg.

Su Arm64EC, i controllori di chiamata non sono opzionali. Tuttavia, CFG è ancora opzionale, come nel caso di altri ABI. Il CFG potrebbe essere disabilitato al momento della compilazione, oppure potrebbe esserci un motivo legittimo per non eseguire un controllo CFG anche quando il CFG è abilitato (ad esempio, il puntatore di funzione non risiede mai nella memoria RW). Per una chiamata indiretta con controllo CFG, è necessario utilizzare il controllore __os_arm64x_check_icall_cfg . Se il CFG è disabilitato o non è necessario, si deve utilizzare __os_arm64x_check_icall .

Di seguito è riportata una tabella riassuntiva dell'utilizzo del call checker su Arm64 classico, x64 e Arm64EC, tenendo conto del fatto che un binario Arm64EC può avere due opzioni a seconda dell'architettura del codice.

Binario Codice Chiamata indiretta non protetta Chiamata indiretta protetta CFG
x64 x64 controllo di assenza di chiamate __guard_check_icall_fptr oppure __guard_dispatch_icall_fptr
Arm64 Classic Arm64 controllo di assenza di chiamate __guard_check_icall_fptr
Arm64EC x64 controllo di assenza di chiamate __guard_check_icall_fptr oppure __guard_dispatch_icall_fptr
Arm64EC __os_arm64x_check_icall __os_arm64x_check_icall_cfg

Indipendentemente dall'ABI, avere codice abilitato al CFG (codice con riferimento ai call-checker CFG) non implica la protezione CFG a tempo di esecuzione. I binari protetti da CFG possono essere eseguiti a livello inferiore, su sistemi che non supportano CFG: il call-checker viene inizializzato con un helper no-op al momento della compilazione. Un processo può anche avere il CFG disabilitato dalla configurazione. Quando il CFG è disabilitato (o il supporto del sistema operativo non è presente) su ABI precedenti, il sistema operativo semplicemente non aggiorna il call-checker quando viene caricato il binario. Su Arm64EC, se la protezione CFG è disabilitata, il sistema operativo imposterà __os_arm64x_check_icall_cfg come __os_arm64x_check_icall, che fornirà comunque il controllo dell'architettura di destinazione in tutti i casi, ma non la protezione CFG.

Come per il CFG in Classic Arm64, la chiamata alla funzione di destinazione (x11) deve seguire immediatamente la chiamata al Call Checker. L'indirizzo del Call Checker deve essere inserito in un registro volatile e né esso né l'indirizzo della funzione di destinazione devono mai essere copiati in un altro registro o riversati in memoria.

Dama a pila

__chkstk viene utilizzato automaticamente dal compilatore ogni volta che una funzione alloca un'area dello stack più grande di una pagina. Per evitare di saltare la pagina di protezione dello stack che protegge la fine dello stack, viene chiamato __chkstk per assicurarsi che tutte le pagine dell'area allocata vengano sondate.

__chkstk viene solitamente richiamato dal prologo della funzione. Per questo motivo, e per una generazione ottimale del codice, utilizza una convenzione di chiamata personalizzata.

Questo implica che il codice x64 e il codice Arm64EC hanno bisogno di funzioni __chkstk proprie e distinte, dato che i thunk Entry e Exit assumono convenzioni di chiamata standard.

x64 e Arm64EC condividono lo stesso spazio dei simboli, quindi non possono esistere due funzioni chiamate __chkstk. Per favorire la compatibilità con il codice x64 preesistente, il nome __chkstk sarà associato allo stack checker x64. Il codice Arm64EC utilizzerà invece __chkstk_arm64ec .

La convenzione di chiamata personalizzata per __chkstk_arm64ec è la stessa di quella per Classic Arm64 __chkstk: x15 indica la dimensione dell'allocazione in byte, divisa per 16. Tutti i registri non volatili e tutti i registri volatili coinvolti nella convenzione di chiamata standard vengono conservati.

Tutto ciò che è stato detto in precedenza su __chkstk si applica anche a __security_check_cookie e alla sua controparte Arm64EC: __security_check_cookie_arm64ec.

Convenzione di chiamata variabile

Arm64EC segue la convenzione di chiamata dell'ABI Arm64 classica, tranne che per le funzioni Variadic (alias varargs, alias funzioni con l'ellissi (. . .) parola chiave del parametro).

Per il caso specifico di variadic, Arm64EC segue una convenzione di chiamata molto simile a quella di x64 variadic, con solo alcune differenze. Di seguito sono riportate le regole principali per Arm64EC variadic:

  • Solo i primi 4 registri vengono utilizzati per il passaggio dei parametri: x0, x1, x2, x3. I parametri rimanenti vengono riversati nello stack. Questo segue esattamente la convenzione di chiamata variadica x64 e differisce da Arm64 Classic, dove vengono utilizzati i registri x0->x7 .
  • I parametri in virgola mobile / SIMD passati per registro utilizzeranno un registro generico, non uno SIMD. Questo è simile ad Arm64 Classic e differisce da x64, dove i parametri FP/SIMD vengono passati sia in un registro General-Purpose che in uno SIMD. Ad esempio, per una funzione f1(int, …) che viene chiamata come f1(int, double), su x64, il secondo parametro sarà assegnato sia a RDX che a XMM1. Su Arm64EC, il secondo parametro sarà assegnato solo a x1.
  • Quando si passano strutture per valore attraverso un registro, si applicano le regole di dimensione x64: Le strutture con dimensioni esattamente 1, 2, 4 e 8 saranno caricate direttamente nel registro generale. Le strutture con altre dimensioni vengono riversate nello stack e un puntatore alla posizione riversata viene assegnato al registro. In questo modo, il by-value si trasforma in by-reference, a basso livello. Nell'ABI Arm64 classico, le strutture di qualsiasi dimensione fino a 16 byte sono assegnate direttamente ai registri General-Purposed.
  • Il registro X4 viene caricato con un puntatore al primo parametro passato via stack (il quinto parametro). Questo non include le strutture fuoriuscite a causa delle limitazioni di dimensioni sopra descritte.
  • Il registro X5 viene caricato con la dimensione, in byte, di tutti i parametri passati dallo stack (dimensione di tutti i parametri, a partire dal quinto). Questo non include le strutture superate dal valore versato a causa delle limitazioni di dimensioni sopra descritte.

Nel seguente esempio: pt_nova_function di seguito prende i parametri in forma non variabile, seguendo quindi la convenzione di chiamata classica di Arm64. Chiama quindi pt_va_function con gli stessi parametri ma in una chiamata variadica.

struct three_char {
    char a;
    char b;
    char c;
};

void
pt_va_function (
    double f,
    ...
);

void
pt_nova_function (
    double f,
    struct three_char tc,
    __int64 ull1,
    __int64 ull2,
    __int64 ull3
)
{
    pt_va_function(f, tc, ull1, ull2, ull3);
}

pt_nova_function richiede 5 parametri che saranno assegnati secondo le regole della convenzione di chiamata classica di Arm64:

  • 'f' è un doppio. Sarà assegnato a d0.
  • 'tc' è una struct con una dimensione di 3 byte. Sarà assegnato a x0.
  • ull1 è un numero intero di 8 byte. Sarà assegnato a x1.
  • ull2 è un numero intero di 8 byte. Verrà assegnato a x2.
  • ull3 è un numero intero di 8 byte. Sarà assegnato a x3.

pt_va_function è una funzione variadica, quindi seguirà le regole variadiche di Arm64EC descritte sopra:

  • 'f' è un doppio. Sarà assegnato a x0.
  • 'tc' è una struct con una dimensione di 3 byte. Verrà versato sulla pila e la sua posizione verrà caricata in x1.
  • ull1 è un numero intero di 8 byte. Verrà assegnato a x2.
  • ull2 è un numero intero di 8 byte. Sarà assegnato a x3.
  • ull3 è un numero intero di 8 byte. Verrà assegnato direttamente allo stack.
  • x4 viene caricato con la posizione di ull3 nello stack.
  • x5 viene caricato con la dimensione di ull3.

Di seguito viene mostrato un possibile output di compilazione per pt_nova_function, che illustra le differenze di assegnazione dei parametri descritte in precedenza.

stp         fp,lr,[sp,#-0x30]!
mov         fp,sp
sub         sp,sp,#0x10

str         x3,[sp]          ; Spill 5th parameter
mov         x3,x2            ; 4th parameter to x3 (from x2)
mov         x2,x1            ; 3rd parameter to x2 (from x1)
str         w0,[sp,#0x20]    ; Spill 2nd parameter
add         x1,sp,#0x20      ; Address of 2nd parameter to x1
fmov        x0,d0            ; 1st parameter to x0 (from d0)
mov         x4,sp            ; Address of the 1st in-stack parameter to x4
mov         x5,#8            ; Size of the in-stack parameter area

bl          pt_va_function

add         sp,sp,#0x10
ldp         fp,lr,[sp],#0x30
ret

Aggiunte di funzioni a gestione dinamica (ABI)

Per ottenere un'interoperabilità trasparente con il codice x64, sono state fatte molte aggiunte all'ABI di Arm64 classico. Gestiscono le differenze nelle convenzioni di chiamata tra Arm64EC e x64.

L'elenco seguente include queste aggiunte:

Thunk di entrata e di uscita

I Thunk di entrata e di uscita si occupano di tradurre la convenzione di chiamata Arm64EC (per lo più la stessa di Arm64 classico) nella convenzione di chiamata x64 e viceversa.

Un'idea sbagliata comune è che le convenzioni di chiamata possano essere convertite seguendo un'unica regola applicata a tutte le firme delle funzioni. La realtà è che le convenzioni di chiamata hanno regole di assegnazione dei parametri. Queste regole dipendono dal tipo di parametro e sono diverse da ABI a ABI. Una conseguenza è che la traduzione tra le ABI sarà specifica per ogni firma di funzione, variando con il tipo di ogni parametro.

Si consideri la funzione seguente:

int fJ(int a, int b, int c, int d);

L'assegnazione dei parametri avverrà come segue:

  • Arm64: a -> x0, b -> x1, c -> x2, d -> x3
  • x64: a -> RCX, b -> RDX, c -> R8, d -> r9
  • Arm64 -> x64 traduzione: x0 -> RCX, x1 -> RDX, x2 -> R8, x3 -> R9

Ora consideriamo una funzione diversa:

int fK(int a, double b, int c, double d);

L'assegnazione dei parametri avverrà come segue:

  • Arm64: a -> x0, b -> d0, c -> x1, d -> d1
  • x64: a -> RCX, b -> XMM1, c -> R8, d -> XMM3
  • Arm64 -> traduzione x64: x0 -> RCX, d0 -> XMM1, x1 -> R8, d1 -> XMM3

Questi esempi dimostrano che l'assegnazione e la traduzione dei parametri variano in base al tipo, ma anche che i tipi dei parametri precedenti nell'elenco dipendono da essi. Questo dettaglio è illustrato dal terzo parametro. In entrambe le funzioni, il tipo del parametro è "int", ma la traduzione risultante è diversa.

I Thunk di entrata e di uscita esistono per questo motivo e sono specificamente adattati per ogni firma di funzione individuale.

Entrambi i tipi di thunks sono, a loro volta, funzioni. Gli Entry Thunks sono invocati automaticamente dall'emulatore quando le funzioni x64 chiamano le funzioni Arm64EC (esecuzione Enters Arm64EC). I Thunk di uscita sono invocati automaticamente dai controllori di chiamata quando le funzioni Arm64EC chiamano funzioni x64 (esecuzione Exits Arm64EC).

Durante la compilazione del codice Arm64EC, il compilatore genera un Entry Thunk per ogni funzione Arm64EC che corrisponde alla sua firma. Il compilatore genererà anche un Exit Thunk per ogni funzione chiamata da una funzione Arm64EC.

Si consideri l'esempio seguente:

struct SC {
    char a;
    char b;
    char c;
};

int fB(int a, double b, int i1, int i2, int i3);

int fC(int a, struct SC c, int i1, int i2, int i3);

int fA(int a, double b, struct SC c, int i1, int i2, int i3) {
    return fB(a, b, i1, i2, i3) + fC(a, c, i1, i2, i3);
}

Quando si compila il codice di cui sopra per Arm64EC, il compilatore genera:

  • Codice per 'fA'.
  • Inserimento di Thunk per 'fA'
  • Esci da Thunk per 'fB'
  • Esci da Thunk per 'fC'

Il thunk fA Entry è generato nel caso fA e richiamato dal codice x64. I Thunk di uscita per fB e fC vengono generati nel caso in cui fB e/o fC si rivelino essere codice x64.

Lo stesso Exit Thunk può essere generato più volte, dato che il compilatore lo genera nel sito di chiamata piuttosto che nella funzione stessa. Questo può portare a una quantità considerevole di thunks ridondanti quindi, in realtà, il compilatore applicherà delle banali regole di ottimizzazione per assicurarsi che solo i thunks necessari vengano inseriti nel binario finale.

Ad esempio, in un binario in cui la funzione Arm64EC A chiama la funzione Arm64EC B, B non viene esportata e il suo indirizzo non è mai conosciuto al di fuori di A. È sicuro eliminare l'Exit Thunk da A a B, insieme all'Entry Thunk per B. È inoltre sicuro che tutti i thunk di Uscita e di Entrata che contengono lo stesso codice, anche se sono stati generati per funzioni distinte, vengano aliasati insieme.

Esci da Thunks

Utilizzando le funzioni di esempio fA, fB e fC di cui sopra, ecco come il compilatore genererebbe gli Exit Thunk fB e fC :

Esci da Thunk a int fB(int a, double b, int i1, int i2, int i3);

$iexit_thunk$cdecl$i8$i8di8i8i8:
    stp         fp,lr,[sp,#-0x10]!
    mov         fp,sp
    sub         sp,sp,#0x30
    adrp        x8,__os_arm64x_dispatch_call_no_redirect
    ldr         xip0,[x8]
    str         x3,[sp,#0x20]  ; Spill 5th param (i3) into the stack
    fmov        d1,d0          ; Move 2nd param (b) from d0 to XMM1 (x1)
    mov         x3,x2          ; Move 4th param (i2) from x2 to R9 (x3)
    mov         x2,x1          ; Move 3rd param (i1) from x1 to R8 (x2)
    blr         xip0           ; Call the emulator
    mov         x0,x8          ; Move return from RAX (x8) to x0
    add         sp,sp,#0x30
    ldp         fp,lr,[sp],#0x10
    ret

Esci da Thunk a int fC(int a, struct SC c, int i1, int i2, int i3);

$iexit_thunk$cdecl$i8$i8m3i8i8i8:
    stp         fp,lr,[sp,#-0x20]!
    mov         fp,sp
    sub         sp,sp,#0x30
    adrp        x8,__os_arm64x_dispatch_call_no_redirect
    ldr         xip0,[x8]
    str         w1,[sp,#0x40]       ; Spill 2nd param (c) onto the stack
    add         x1,sp,#0x40         ; Make RDX (x1) point to the spilled 2nd param
    str         x4,[sp,#0x20]       ; Spill 5th param (i3) into the stack
    blr         xip0                ; Call the emulator
    mov         x0,x8               ; Move return from RAX (x8) to x0
    add         sp,sp,#0x30
    ldp         fp,lr,[sp],#0x20
    ret

Nel caso di fB , possiamo vedere come la presenza di un parametro 'doppio' provocherà un rimescolamento dell'assegnazione dei registri GP rimanenti, un risultato delle diverse regole di assegnazione di Arm64 e x64. Possiamo anche notare che x64 assegna solo 4 parametri ai registri, quindi il quinto parametro deve essere versato sullo stack.

Nel caso di fC , il secondo parametro è una struttura di 3 byte. Arm64 consentirà di assegnare direttamente a un registro una struttura di qualsiasi dimensione. x64 consente solo le dimensioni 1, 2, 4 e 8. Questo Exit Thunk deve quindi trasferire questo struct dal registro allo stack e assegnare un puntatore al registro. Questo consuma ancora un registro (per trasportare il puntatore), quindi non cambia l'assegnazione dei registri rimanenti: non avviene alcun rimescolamento dei registri per il terzo e quarto parametro. Come nel caso di fB , il quinto parametro deve essere versato in pila.

Considerazioni aggiuntive per Exit Thunks:

  • Il compilatore non li chiamerà con il nome della funzione che traducono da>a, ma piuttosto con la firma a cui si rivolgono. In questo modo è più facile trovare degli esuberi.
  • L'Exit Thunk viene chiamato con il registro x9 che contiene l'indirizzo della funzione di destinazione (x64). Questo viene impostato dal controllore delle chiamate e passa attraverso l'Exit Thunk, indisturbato, nell'emulatore.

Dopo aver riorganizzato i parametri, l'Exit Thunk chiama l'emulatore tramite __os_arm64x_dispatch_call_no_redirect.

A questo punto vale la pena di rivedere la funzione del call checker e i dettagli sulla sua ABI personalizzata. Ecco come si presenta una chiamata indiretta a fB :

mov     x11, <target>
adrp    x9, __os_arm64x_check_icall_cfg
ldr     x9, [x9, __os_arm64x_check_icall_cfg] 
adrp    x10, $iexit_thunk$cdecl$i8$i8di8i8i8    ; fB function’s exit thunk
add     x10, x10, $iexit_thunk$cdecl$i8$i8di8i8i8
blr     x9                                      ; check target function
blr     x11                                     ; call function

Quando si chiama il controllore di chiamate:

  • x11 fornisce l'indirizzo della funzione di destinazione da chiamare (in questo casofB ). A questo punto potrebbe non essere noto se la funzione di destinazione è Arm64EC o x64.
  • x10 fornisce un Thunk di uscita che corrisponde alla firma della funzione chiamata (in questo casofB ).

I dati restituiti dal verificatore di chiamate dipendono dal fatto che la funzione di destinazione sia Arm64EC o x64.

Se il target è Arm64EC:

  • x11 restituirà l'indirizzo del codice Arm64EC da chiamare. Questo valore può essere o non essere lo stesso che è stato fornito in.

Se l'obiettivo è un codice x64:

  • x11 restituirà l'indirizzo dell'Exit Thunk. Questo è copiato dall'input fornito in x10.
  • x10 restituirà l'indirizzo del Thunk di uscita, indisturbato dall'input.
  • x9 restituirà la funzione x64 di destinazione. Questo valore può essere o meno lo stesso fornito da x11.

I controllori di chiamata lasceranno sempre indisturbati i registri dei parametri della convenzione di chiamata, quindi il codice chiamante deve seguire immediatamente la chiamata al controllore di chiamata con blr x11 (o br x11 nel caso di una chiamata in coda). Questi sono i registri che chiamano checker. Conserveranno sempre un livello di conservazione superiore a quello dei registri non volatili standard: x0-x8, x15(chkstk) e q0-q7.

Ingresso Thunks

Gli Entry Thunk si occupano delle trasformazioni necessarie per passare dalle convenzioni di chiamata x64 a quelle Arm64. Si tratta, in sostanza, dell'inverso di Exit Thunks, ma ci sono alcuni aspetti in più da considerare.

Considerando l'esempio precedente di compilazione di fA, viene generato un Entry Thunk in modo che fA possa essere chiamato dal codice x64.

Ingresso Thunk per int fA(int a, double b, struct SC c, int i1, int i2, int i3)

$ientry_thunk$cdecl$i8$i8dm3i8i8i8:
    stp         q6,q7,[sp,#-0xA0]!  ; Spill full non-volatile XMM registers
    stp         q8,q9,[sp,#0x20]
    stp         q10,q11,[sp,#0x40]
    stp         q12,q13,[sp,#0x60]
    stp         q14,q15,[sp,#0x80]
    stp         fp,lr,[sp,#-0x10]!
    mov         fp,sp
    ldrh        w1,[x2]             ; Load 3rd param (c) bits [15..0] directly into x1
    ldrb        w8,[x2,#2]          ; Load 3rd param (c) bits [16..23] into temp w8
    bfi         w1,w8,#0x10,#8      ; Merge 3rd param (c) bits [16..23] into x1
    mov         x2,x3               ; Move the 4th param (i1) from R9 (x3) to x2
    fmov        d0,d1               ; Move the 2nd param (b) from XMM1 (d1) to d0
    ldp         x3,x4,[x4,#0x20]    ; Load the 5th (i2) and 6th (i3) params
                                    ; from the stack into x3 and x4 (using x4)
    blr         x9                  ; Call the function (fA)
    mov         x8,x0               ; Move the return from x0 to x8 (RAX)
    ldp         fp,lr,[sp],#0x10
    ldp         q14,q15,[sp,#0x80]  ; Restore full non-volatile XMM registers
    ldp         q12,q13,[sp,#0x60]
    ldp         q10,q11,[sp,#0x40]
    ldp         q8,q9,[sp,#0x20]
    ldp         q6,q7,[sp],#0xA0
    adrp        xip0,__os_arm64x_dispatch_ret
    ldr         xip0,[xip0,__os_arm64x_dispatch_ret]
    br          xip0

L'indirizzo della funzione di destinazione è fornito dall'emulatore in x9.

Prima di chiamare l'Entry Thunk, l'emulatore x64 inserisce l'indirizzo di ritorno dallo stack nel registro LR . Si prevede quindi che LR punti al codice x64 quando il controllo viene trasferito all'Entry Thunk.

L'emulatore può anche eseguire un'altra regolazione dello stack, a seconda di quanto segue: Entrambe le ABI Arm64 e x64 definiscono un requisito di allineamento dello stack che deve essere allineato a 16 byte nel momento in cui viene chiamata una funzione. Quando si esegue codice Arm64, l'hardware applica questa regola, ma non c'è alcuna applicazione hardware per x64. Durante l'esecuzione di codice x64, la chiamata erronea di funzioni con uno stack non allineato può passare inosservata all'infinito, fino a quando non viene utilizzata un'istruzione di allineamento a 16 byte (alcune istruzioni SSE lo fanno) o non viene richiamato codice Arm64EC.

Per risolvere questo potenziale problema di compatibilità, prima di chiamare l'Entry Thunk, l'emulatore allinea sempre lo Stack Pointer a 16 byte e memorizza il suo valore originale nel registro x4 . In questo modo gli Entry Thunk iniziano sempre l'esecuzione con uno stack allineato ma possono comunque fare riferimento correttamente ai parametri passati sullo stack, tramite x4.

Per quanto riguarda i registri SIMD non volatili, c'è una differenza significativa tra le convenzioni di chiamata di Arm64 e x64. Su Arm64, gli 8 byte inferiori (64 bit) del registro sono considerati non volatili. In altre parole, solo la parte Dn dei registri Qn è non volatile. Su x64, gli interi 16 byte del registro XMMn sono considerati non volatili. Inoltre, su x64, XMM6 e XMM7 sono registri non volatili mentre D6 e D7 (i corrispondenti registri di Arm64) sono volatili.

Per risolvere queste asimmetrie nella manipolazione dei registri SIMD, gli Entry Thunk devono salvare esplicitamente tutti i registri SIMD che sono considerati non volatili in x64. Questo è necessario solo su Entry Thunks (non Exit Thunks) perché x64 è più severo di Arm64. In altre parole, le regole di salvataggio/conservazione dei registri in x64 superano i requisiti di Arm64 in tutti i casi.

Per risolvere il problema del recupero corretto di questi valori di registro quando si srotola lo stack (ad esempio setjmp + longjmp, o throw + catch), è stato introdotto un nuovo opcode di srotolamento: save_any_reg (0xE7). Questo nuovo opcode di sbroglio a 3 byte permette di salvare qualsiasi registro General Purpose o SIMD (compresi quelli considerati volatili) e anche i registri Qn a grandezza naturale. Questo nuovo opcode viene utilizzato per le operazioni di versamento/riempimento del registro Qn di cui sopra. save_any_reg è compatibile con save_next_pair (0xE6).

Come riferimento, di seguito sono riportate le informazioni di svolgimento corrispondenti all'Entry Thunk presentato in precedenza:

   Prolog unwind:
      06: E76689.. +0004 stp   q6,q7,[sp,#-0xA0]! ; Actual=stp   q6,q7,[sp,#-0xA0]!
      05: E6...... +0008 stp   q8,q9,[sp,#0x20]   ; Actual=stp   q8,q9,[sp,#0x20]
      04: E6...... +000C stp   q10,q11,[sp,#0x40] ; Actual=stp   q10,q11,[sp,#0x40]
      03: E6...... +0010 stp   q12,q13,[sp,#0x60] ; Actual=stp   q12,q13,[sp,#0x60]
      02: E6...... +0014 stp   q14,q15,[sp,#0x80] ; Actual=stp   q14,q15,[sp,#0x80]
      01: 81...... +0018 stp   fp,lr,[sp,#-0x10]! ; Actual=stp   fp,lr,[sp,#-0x10]!
      00: E1...... +001C mov   fp,sp              ; Actual=mov   fp,sp
                   +0020 (end sequence)
   Epilog #1 unwind:
      0B: 81...... +0044 ldp   fp,lr,[sp],#0x10   ; Actual=ldp   fp,lr,[sp],#0x10
      0C: E74E88.. +0048 ldp   q14,q15,[sp,#0x80] ; Actual=ldp   q14,q15,[sp,#0x80]
      0F: E74C86.. +004C ldp   q12,q13,[sp,#0x60] ; Actual=ldp   q12,q13,[sp,#0x60]
      12: E74A84.. +0050 ldp   q10,q11,[sp,#0x40] ; Actual=ldp   q10,q11,[sp,#0x40]
      15: E74882.. +0054 ldp   q8,q9,[sp,#0x20]   ; Actual=ldp   q8,q9,[sp,#0x20]
      18: E76689.. +0058 ldp   q6,q7,[sp],#0xA0   ; Actual=ldp   q6,q7,[sp],#0xA0
      1C: E3...... +0060 nop                      ; Actual=90000030
      1D: E3...... +0064 nop                      ; Actual=ldr   xip0,[xip0,#8]
      1E: E4...... +0068 end                      ; Actual=br    xip0
                   +0070 (end sequence)

Dopo il ritorno della funzione Arm64EC, la routine __os_arm64x_dispatch_ret viene utilizzata per rientrare nell'emulatore, tornando al codice x64 (indicato da LR).

Le funzioni Arm64EC hanno i 4 byte prima della prima istruzione della funzione riservati alla memorizzazione di informazioni da utilizzare in fase di esecuzione. È in questi 4 byte che si trova l'indirizzo relativo dell'Entry Thunk della funzione. Quando si esegue una chiamata da una funzione x64 a una funzione Arm64EC, l'emulatore leggerà i 4 byte prima dell'inizio della funzione, maschererà i due bit inferiori e li aggiungerà all'indirizzo della funzione. Questo produrrà l'indirizzo dell'Entry Thunk da chiamare.

Regolatore Thunks

Gli Adjustor Thunk sono funzioni senza firma che trasferiscono semplicemente il controllo a (tail-call) un'altra funzione, dopo aver eseguito una qualche trasformazione su uno dei parametri. Il tipo del parametro o dei parametri che vengono trasformati è noto, ma tutti i parametri rimanenti possono essere qualsiasi cosa e, in qualsiasi numero - Adjustor Thunks non toccherà nessun registro potenzialmente contenente un parametro e non toccherà lo stack. Questo è ciò che rende le funzioni di Adjustor Thunks prive di firma.

I Thunk di regolazione possono essere generati automaticamente dal compilatore. Questo è comune, ad esempio, con l'ereditarietà multipla del C++, dove qualsiasi metodo virtuale può essere delegato alla classe genitore, senza subire alcuna modifica, a parte una modifica al puntatore this .

Di seguito è riportato un esempio reale:

[thunk]:CObjectContext::Release`adjustor{8}':
    sub         x0,x0,#8
    b           CObjectContext::Release

Il thunk sottrae 8 byte al puntatore this e inoltra la chiamata alla classe madre.

In sintesi, le funzioni Arm64EC richiamabili dalle funzioni x64 devono avere un Entry Thunk associato. L'Entry Thunk è specifico per la firma. Le funzioni Arm64 senza firma, come gli Adjustor Thunks, necessitano di un meccanismo diverso che possa gestire le funzioni senza firma.

L'Entry Thunk di un Adjustor Thunk utilizza l'helper __os_arm64x_x64_jump per rimandare l'esecuzione del vero lavoro dell'Entry Thunk (regolare i parametri da una convenzione all'altra) alla chiamata successiva. È in questo momento che la firma diventa evidente. Questo include l'opzione di non effettuare alcun aggiustamento della convenzione di chiamata, se l'obiettivo dell'Adjustor Thunk risulta essere una funzione x64. Ricorda che quando un Entry Thunk inizia l'esecuzione, i parametri sono nella loro forma x64.

Nell'esempio precedente, considera come appare il codice in Arm64EC.

Il regolatore si è incastrato nel braccio64EC

[thunk]:CObjectContext::Release`adjustor{8}':
    sub         x0,x0,#8
    adrp        x9,CObjectContext::Release
    add         x11,x9,CObjectContext::Release
    stp         fp,lr,[sp,#-0x10]!
    mov         fp,sp
    adrp        xip0, __os_arm64x_check_icall
    ldr         xip0,[xip0, __os_arm64x_check_icall]
    blr         xip0
    ldp         fp,lr,[sp],#0x10
    br          x11

Regolatore di entrata del bagagliaio

[thunk]:CObjectContext::Release$entry_thunk`adjustor{8}':
    sub         x0,x0,#8
    adrp        x9,CObjectContext::Release
    add         x9,x9,CObjectContext::Release
    adrp        xip0,__os_arm64x_x64_jump
    ldr         xip0,[xip0,__os_arm64x_x64_jump]
    br          xip0

Sequenze di avanzamento rapido

Alcune applicazioni apportano modifiche in tempo di esecuzione a funzioni che risiedono in binari che non possiedono ma da cui dipendono, in genere binari del sistema operativo, allo scopo di deviare l'esecuzione quando la funzione viene chiamata. Questo è noto anche come aggancio.

Ad alto livello, il processo di aggancio è semplice. Nel dettaglio, tuttavia, l'aggancio è specifico per l'architettura e piuttosto complesso viste le potenziali variazioni che la logica di aggancio deve affrontare.

In termini generali, il processo prevede quanto segue:

  • Determina l'indirizzo della funzione da agganciare.
  • Sostituisci la prima istruzione della funzione con un salto alla routine di aggancio.
  • Una volta terminato l'aggancio, torna alla logica originale, che comprende l'esecuzione dell'istruzione originale spostata.

Le variazioni derivano da fattori quali:

  • La dimensione della prima istruzione: È una buona idea sostituirlo con un JMP della stessa dimensione o più piccolo, per evitare di sostituire la parte superiore della funzione mentre un altro thread potrebbe eseguirla in volo.
  • Il tipo della prima istruzione: Se la prima istruzione ha una natura relativa al PC, la sua ricollocazione potrebbe richiedere la modifica di elementi come i campi di spostamento. Poiché è probabile che vadano in overflow quando un'istruzione viene spostata in un punto lontano, potrebbe essere necessario fornire una logica equivalente con istruzioni diverse.

A causa di tutta questa complessità, è raro trovare una logica di aggancio robusta e generica. Spesso la logica presente nelle applicazioni può gestire solo un insieme limitato di casi che l'applicazione si aspetta di incontrare nelle API specifiche a cui è interessata. Non è difficile immaginare quanto questo sia un problema di compatibilità delle applicazioni. Anche una semplice modifica del codice o le ottimizzazioni del compilatore possono rendere inutilizzabili le applicazioni se il codice non è più esattamente come previsto.

Cosa succederebbe a queste applicazioni se dovessero incontrare il codice Arm64 durante l'impostazione di un hook? Fallirebbero di sicuro.

Le funzioni Fast-Forward Sequence (FFS) soddisfano questo requisito di compatibilità in Arm64EC.

Le FFS sono funzioni x64 molto piccole che non contengono alcuna logica reale e fanno da coda alla funzione Arm64EC reale. Sono opzionali ma abilitati di default per tutte le esportazioni di DLL e per qualsiasi funzione decorata con __declspec(hybrid_patchable).

In questi casi, quando il codice ottiene un puntatore a una determinata funzione, sia tramite GetProcAddress nel caso di esportazione, sia tramite &function nel caso di __declspec(hybrid_patchable) , l'indirizzo risultante conterrà codice x64. Il codice x64 passerà per una funzione x64 legittima, soddisfacendo la maggior parte della logica di aggancio attualmente disponibile.

Considera il seguente esempio (la gestione degli errori è stata omessa per brevità):

auto module_handle = 
    GetModuleHandleW(L"api-ms-win-core-processthreads-l1-1-7.dll");

auto pgma = 
    (decltype(&GetMachineTypeAttributes))
        GetProcAddress(module_handle, "GetMachineTypeAttributes");

hr = (*pgma)(IMAGE_FILE_MACHINE_Arm64, &MachineAttributes);

Il valore del puntatore a funzione nella variabile pgma conterrà l'indirizzo della FFS di GetMachineTypeAttributes.

Questo è un esempio di sequenza veloce:

kernelbase!EXP+#GetMachineTypeAttributes:
00000001`800034e0 488bc4          mov     rax,rsp
00000001`800034e3 48895820        mov     qword ptr [rax+20h],rbx
00000001`800034e7 55              push    rbp
00000001`800034e8 5d              pop     rbp
00000001`800034e9 e922032400      jmp     00000001`80243810

La funzione FFS x64 ha un prologo e un epilogo canonici, che terminano con un tail-call (salto) alla vera funzione GetMachineTypeAttributes nel codice Arm64EC:

kernelbase!GetMachineTypeAttributes:
00000001`80243810 d503237f pacibsp
00000001`80243814 a9bc7bfd stp         fp,lr,[sp,#-0x40]!
00000001`80243818 a90153f3 stp         x19,x20,[sp,#0x10]
00000001`8024381c a9025bf5 stp         x21,x22,[sp,#0x20]
00000001`80243820 f9001bf9 str         x25,[sp,#0x30]
00000001`80243824 910003fd mov         fp,sp
00000001`80243828 97fbe65e bl          kernelbase!#__security_push_cookie
00000001`8024382c d10083ff sub         sp,sp,#0x20
                           [...]

Sarebbe piuttosto inefficiente se fosse necessario eseguire 5 istruzioni emulate x64 tra due funzioni Arm64EC. Le funzioni FFS sono speciali. Le funzioni FFS non funzionano se rimangono inalterate. L'helper call-checker controllerà in modo efficiente se l'FFS non è stato modificato. In questo caso, la chiamata verrà trasferita direttamente alla destinazione reale. Se l'FFS è stato modificato in qualsiasi modo, non sarà più un FFS. L'esecuzione verrà trasferita all'FFS alterato ed eseguirà il codice eventualmente presente, emulando la deviazione e qualsiasi logica di aggancio.

Quando il gancio trasferisce l'esecuzione alla fine dell'FFS, alla fine raggiungerà la chiamata di coda al codice Arm64EC, che verrà eseguito dopo il gancio, proprio come l'applicazione si aspetta.

Creare Arm64EC in Assembly

Gli header dell'SDK di Windows e il compilatore C possono semplificare il lavoro di creazione dell'assembly Arm64EC. Ad esempio, il compilatore C può essere utilizzato per generare i Thunk di entrata e di uscita per le funzioni non compilate da codice C.

Consideriamo l'esempio di una funzione equivalente alla seguente fD che deve essere realizzata in Assembly (ASM). Questa funzione può essere chiamata sia dal codice Arm64EC che da quello x64 e il puntatore della funzione pfE può puntare sia al codice Arm64EC che a quello x64.

typedef int (PF_E)(int, double);

extern PF_E * pfE;

int fD(int i, double d) {
    return (*pfE)(i, d);
}

La scrittura di fD in ASM sarebbe qualcosa di simile:

#include "ksarm64.h"

        IMPORT  __os_arm64x_check_icall_cfg
        IMPORT |$iexit_thunk$cdecl$i8$i8d|
        IMPORT pfE

        NESTED_ENTRY_COMDAT A64NAME(fD)
        PROLOG_SAVE_REG_PAIR fp, lr, #-16!

        adrp    x11, pfE                                  ; Get the global function
        ldr     x11, [x11, pfE]                           ; pointer pfE

        adrp    x9, __os_arm64x_check_icall_cfg           ; Get the EC call checker
        ldr     x9, [x9, __os_arm64x_check_icall_cfg]     ; with CFG
        adrp    x10, |$iexit_thunk$cdecl$i8$i8d|          ; Get the Exit Thunk for
        add     x10, x10, |$iexit_thunk$cdecl$i8$i8d|     ; int f(int, double);
        blr     x9                                        ; Invoke the call checker

        blr     x11                                       ; Invoke the function

        EPILOG_RESTORE_REG_PAIR fp, lr, #16!
        EPILOG_RETURN

        NESTED_END

        end

Nell'esempio precedente:

  • Arm64EC utilizza la stessa dichiarazione di procedura e le stesse macro prolog/epilog di Arm64.
  • I nomi delle funzioni devono essere avvolti dalla macro A64NAME . Quando si compila codice C/C++ come Arm64EC, il compilatore contrassegna OBJ come ARM64EC contenente codice Arm64EC. Questo non accade con ARMASM. Quando si compila il codice ASM esiste un modo alternativo per informare il linker che il codice prodotto è Arm64EC. Questo avviene anteponendo al nome della funzione il prefisso #. La macro A64NAME esegue questa operazione quando _ARM64EC_ è definito e lascia il nome invariato quando _ARM64EC_ non è definito. Questo rende possibile la condivisione del codice sorgente tra Arm64 e Arm64EC.
  • Il puntatore della funzione pfE deve essere prima sottoposto al controllo delle chiamate EC, insieme al Thunk di uscita appropriato, nel caso in cui la funzione di destinazione sia x64.

Generare i Thunk di entrata e di uscita

Il passo successivo consiste nel generare l'Entry Thunk per fD e l'Exit Thunk per pfE. Il compilatore C può eseguire questo compito con il minimo sforzo, utilizzando la parola chiave del compilatore _Arm64XGenerateThunk .

void _Arm64XGenerateThunk(int);

int fD2(int i, double d) {
    UNREFERENCED_PARAMETER(i);
    UNREFERENCED_PARAMETER(d);
    _Arm64XGenerateThunk(2);
    return 0;
}

int fE(int i, double d) {
    UNREFERENCED_PARAMETER(i);
    UNREFERENCED_PARAMETER(d);
    _Arm64XGenerateThunk(1);
    return 0;
}

La parola chiave _Arm64XGenerateThunk indica al compilatore C di utilizzare la firma della funzione, ignorare il corpo e generare un Exit Thunk (quando il parametro è 1) o un Entry Thunk (quando il parametro è 2).

Si consiglia di inserire la generazione dei thunk nel proprio file C. Essendo in file isolati, è più semplice confermare i nomi dei simboli scaricando i simboli corrispondenti di OBJ o addirittura disassemblando.

Entrate personalizzate

Sono state aggiunte delle macro all'SDK per facilitare la creazione di Entry Thunk personalizzati e codificati a mano. Un caso in cui questo può essere utilizzato è la creazione di Thunk di regolazione personalizzati.

La maggior parte degli Adjustor Thunk sono generati dal compilatore C++, ma possono anche essere generati manualmente. Questo può verificarsi nei casi in cui una callback generica trasferisce il controllo alla callback reale, identificata da uno dei parametri.

Di seguito è riportato un esempio in codice Arm64 Classic:

    NESTED_ENTRY MyAdjustorThunk
    PROLOG_SAVE_REG_PAIR    fp, lr, #-16!
    ldr     x15, [x0, 0x18]
    adrp    x16, __guard_check_icall_fptr
    ldr     x16, [x16, __guard_check_icall_fptr]
    blr     xip0
    EPILOG_RESTORE_REG_PAIR fp, lr, #16
    EPILOG_END              br  x15
    NESTED_END

In questo esempio, l'indirizzo della funzione di destinazione viene recuperato dall'elemento di una struttura, fornito come riferimento, attraverso il primo parametro. Poiché la struttura è scrivibile, l'indirizzo di destinazione deve essere convalidato tramite Control Flow Guard (CFG).

L'esempio che segue mostra come l'equivalente Thunk dell'Aggiustatore verrebbe portato su Arm64EC:

    NESTED_ENTRY_COMDAT A64NAME(MyAdjustorThunk)
    PROLOG_SAVE_REG_PAIR    fp, lr, #-16!
    ldr     x11, [x0, 0x18]
    adrp    xip0, __os_arm64x_check_icall_cfg
    ldr     xip0, [xip0, __os_arm64x_check_icall_cfg]
    blr     xip0
    EPILOG_RESTORE_REG_PAIR fp, lr, #16
    EPILOG_END              br  x11
    NESTED_END

Il codice precedente non fornisce un Thunk di uscita (nel registro x10). Questo non è possibile perché il codice può essere eseguito per molte firme diverse. Questo codice sfrutta il fatto che il chiamante ha impostato x10 sul Thunk di uscita. Il chiamante avrebbe effettuato la chiamata puntando a una firma esplicita.

Il codice di cui sopra ha bisogno di un Entry Thunk per risolvere il caso in cui il chiamante sia un codice x64. Ecco come creare l'Entry Thunk corrispondente, utilizzando la macro per gli Entry Thunk personalizzati:

    ARM64EC_CUSTOM_ENTRY_THUNK A64NAME(MyAdjustorThunk)
    ldr     x9, [x0, 0x18]
    adrp    xip0, __os_arm64x_x64_jump
    ldr     xip0, [xip0, __os_arm64x_x64_jump]
    br      xip0
    LEAF_END

A differenza di altre funzioni, questo Entry Thunk non trasferisce il controllo alla funzione associata (il Adjustor Thunk). In questo caso, la funzionalità stessa (l'esecuzione della regolazione dei parametri) è incorporata nell'Entry Thunk e il controllo viene trasferito direttamente al target finale, tramite l'helper __os_arm64x_x64_jump .

Generazione dinamica (compilazione JIT) del codice Arm64EC

Nei processi Arm64EC ci sono due tipi di memoria eseguibile: Codice Arm64EC e codice x64.

Il sistema operativo estrae queste informazioni dai file binari caricati. i file binari x64 sono tutti x64 e Arm64EC contiene una tabella di variazione per le pagine di codice Arm64EC vs x64.

E il codice generato dinamicamente? I compilatori just-in-time (JIT) generano codice, in fase di esecuzione, che non è supportato da alcun file binario.

Di solito questo implica:

  • Allocazione della memoria scrivibile (VirtualAlloc).
  • Produrre il codice nella memoria allocata.
  • Riprotezione della memoria da Read-Write a Read-Execute (VirtualProtect).
  • Aggiungi le voci delle funzioni di sbroglio per tutte le funzioni generate non banali (non a foglia) (RtlAddFunctionTable o RtlAddGrowableFunctionTable).

Per banali ragioni di compatibilità, qualsiasi applicazione che esegua questi passaggi in un processo Arm64EC farà sì che il codice venga considerato codice x64. Questo accadrà per qualsiasi processo che utilizza il runtime Java x64 non modificato, il runtime .NET, il motore JavaScript, ecc.

Per generare codice dinamico Arm64EC, il processo è per lo più lo stesso con due sole differenze:

  • Quando si alloca la memoria, utilizza il nuovo VirtualAlloc2 (invece di VirtualAlloc o VirtualAllocEx) e fornisci l'attributo MEM_EXTENDED_PARAMETER_EC_CODE .
  • Quando si aggiungono voci di funzione:
    • Devono essere in formato Arm64. Quando si compila il codice Arm64EC, il tipo RUNTIME_FUNCTION corrisponderà al formato x64. Per il formato Arm64 durante la compilazione di Arm64EC, usa invece il tipo ARM64_RUNTIME_FUNCTION .
    • Non utilizzare la vecchia API RtlAddFunctionTable . Utilizza invece sempre la nuova API RtlAddGrowableFunctionTable .

Di seguito è riportato un esempio di allocazione della memoria:

    MEM_EXTENDED_PARAMETER Parameter = { 0 };
    Parameter.Type = MemExtendedParameterAttributeFlags;
    Parameter.ULong64 = MEM_EXTENDED_PARAMETER_EC_CODE;

    HANDLE process = GetCurrentProcess();
    ULONG allocationType = MEM_RESERVE;
    DWORD protection = PAGE_EXECUTE_READ | PAGE_TARGETS_INVALID;

    address = VirtualAlloc2 (
        process,
        NULL,
        numBytesToAllocate,
        allocationType,
        protection,
        &Parameter,
        1);

E un esempio di aggiunta di una voce della funzione Unwind:

ARM64_RUNTIME_FUNCTION FunctionTable[1];

FunctionTable[0].BeginAddress = 0;
FunctionTable[0].Flags = PdataPackedUnwindFunction;
FunctionTable[0].FunctionLength = nSize / 4;
FunctionTable[0].RegF = 0;                   // no D regs saved
FunctionTable[0].RegI = 0;                   // no X regs saved beyond fp,lr
FunctionTable[0].H = 0;                      // no home for x0-x7
FunctionTable[0].CR = PdataCrChained;        // stp fp,lr,[sp,#-0x10]!
                                             // mov fp,sp
FunctionTable[0].FrameSize = 1;              // 16 / 16 = 1

this->DynamicTable = NULL;
Result == RtlAddGrowableFunctionTable(
    &this->DynamicTable,
    reinterpret_cast<PRUNTIME_FUNCTION>(FunctionTable),
    1,
    1,
    reinterpret_cast<ULONG_PTR>(pBegin),
    reinterpret_cast<ULONG_PTR>(reinterpret_cast<PBYTE>(pBegin) + nSize)
);