Partager via


Comprendre l’ABI Arm64EC et le code d’assembly

Arm64EC (« Emulation Compatible ») est une nouvelle interface binaire d’application (ABI) pour la création d’applications pour Windows 11 sur Arm. Pour obtenir une vue d’ensemble d’Arm64EC et comment commencer à créer des applications Win32 en tant qu’Arm64EC, consultez Utilisation d’Arm64EC pour créer des applications pour Windows 11 sur les appareils Arm.

L’objectif de ce document est de fournir une vue détaillée de l’ABI Arm64EC avec suffisamment d’informations pour qu’un développeur d’applications écrive et débogue le code compilé pour Arm64EC, y compris le débogage de bas niveau/assembleur et l’écriture de code d’assembly ciblant l’ABI Arm64EC.

Conception d’Arm64EC

Arm64EC a été conçu pour fournir des fonctionnalités et des performances de niveau natif, tout en fournissant une interopérabilité transparente et directe avec du code x64 exécuté sous émulation.

Arm64EC est principalement additif à l’ABI Arm64 classique. Très peu de l’ABI classique a été modifié, mais des parties ont été ajoutées pour activer l’interopérabilité x64.

Dans ce document, l’ABI Arm64 standard standard doit être appelé « ABI classique ». Cela évite l’ambiguïté inhérente aux termes surchargés comme « Natif ». Arm64EC, pour être clair, est tout aussi natif que l’ABI d’origine.

Arm64EC vs Arm64 Classic ABI

La liste suivante indique où Arm64EC a divergent d’Arm64 Classic ABI.

Il s’agit de petits changements lorsqu’ils sont vus en perspective de la définition de l’ABI dans son ensemble.

Inscrire le mappage et les registres bloqués

Pour qu’il existe une interopérabilité au niveau du type avec du code x64, le code Arm64EC est compilé avec les mêmes définitions d’architecture de préprocesseur que le code x64.

En d’autres termes, _M_AMD64 et _AMD64_ sont définis. L’un des types affectés par cette règle est la CONTEXT structure. La CONTEXT structure définit l’état du processeur à un point donné. Il est utilisé pour des éléments tels que Exception Handling des GetThreadContext API. Le code x64 existant s’attend à ce que le contexte de l’UC soit représenté en tant que structure x64 CONTEXT ou, en d’autres termes, la structure telle qu’elle est définie pendant la CONTEXT compilation x64.

Cette structure doit être utilisée pour représenter le contexte de l’UC lors de l’exécution de code x64, ainsi que du code Arm64EC. Le code existant ne comprend pas un nouveau concept, tel que le jeu d’enregistrement du processeur qui passe de la fonction à la fonction. Si la structure x64 CONTEXT est utilisée pour représenter les états d’exécution Arm64, cela implique que les registres Arm64 sont mappés efficacement dans des registres x64.

Il implique également que tous les registres Arm64 qui ne peuvent pas être intégrés dans le x64 CONTEXT ne doivent pas être utilisés, car leurs valeurs peuvent être perdues chaque fois qu’une opération se CONTEXT produit (et certaines peuvent être asynchrones et inattendues, telles que l’opération garbage collection d’un runtime de langage managé ou d’un APC).

Les règles de mappage entre les registres Arm64EC et x64 sont représentées par la ARM64EC_NT_CONTEXT structure dans les en-têtes Windows, présentes dans le Kit de développement logiciel (SDK). Cette structure est essentiellement une union de la CONTEXT structure, exactement comme elle est définie pour x64, mais avec une superposition supplémentaire de registre Arm64.

Par exemple, RCX mappe à X0, RDX à X1SP, RSP à , RIP à PC, etc. Nous pouvons également voir comment les registres x13, , x14, x23x24, , x28n’ont v16-v31 aucune représentation et, par conséquent, ne peuvent pas être utilisés dans Arm64EC.

Cette restriction d’utilisation de registre est la première différence entre les API Arm64 Classic et EC.

Vérificateurs d’appels

Les vérificateurs d’appels font partie de Windows depuis que Control Flow Guard (CFG) a été introduit dans Windows 8.1. Les vérificateurs d’appels sont des assainisseurs d’adresses pour les pointeurs de fonction (avant que ces éléments aient été appelés désinfecteurs d’adresses). Chaque fois que le code est compilé avec l’option /guard:cf , le compilateur génère un appel supplémentaire à la fonction vérificateur juste avant chaque appel indirect/saut. La fonction de vérificateur elle-même est fournie par Windows et, pour CFG, elle effectue une vérification de validité par rapport aux cibles d’appel connues à bon fonctionnement. Ces informations sont également incluses dans les fichiers binaires compilés avec /guard:cf.

Voici un exemple d’utilisation d’un vérificateur d’appel dans Arm64 classique :

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

Dans le cas de CFG, le vérificateur d’appel retourne simplement si la cible est valide, ou échoue rapidement le processus si ce n’est pas le cas. Les vérificateurs d’appels ont des conventions d’appel personnalisées. Ils prennent le pointeur de fonction dans un registre non utilisé par la convention d’appel normale et conservent tous les registres de convention d’appel normal. De cette façon, ils n’introduisent pas de déversement d’enregistrement autour d’eux.

Les vérificateurs d’appels sont facultatifs sur toutes les autres API Windows, mais obligatoires sur Arm64EC. Sur Arm64EC, les vérificateurs d’appels accumulent la tâche de vérification de l’architecture de la fonction appelée. Ils vérifient si l’appel est une autre fonction EC (« Emulation Compatible ») ou une fonction x64 qui doit être exécutée sous émulation. Dans de nombreux cas, cela ne peut être vérifié qu’au moment de l’exécution.

Les vérificateurs d’appels Arm64EC s’appuient sur les vérificateurs Arm64 existants, mais ils ont une convention d’appel personnalisée légèrement différente. Ils prennent un paramètre supplémentaire et peuvent modifier le registre contenant l’adresse cible. Par exemple, si la cible est du code x64, le contrôle doit d’abord être transféré vers la logique de génération automatique d’émulation.

Dans Arm64EC, la même utilisation du vérificateur d’appel devient :

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

Les légères différences de Classic Arm64 sont les suivantes :

  • Le nom du symbole du vérificateur d’appel est différent.
  • L’adresse cible est fournie au x11 lieu de x15.
  • L’adresse cible (x11) est [in, out] au lieu de [in].
  • Il existe un paramètre supplémentaire, fourni via x10, appelé « Exit Thunk ».

Exit Thunk est un fonclet qui transforme les paramètres de fonction de la convention d’appel Arm64EC en convention d’appel x64.

Le vérificateur d’appel Arm64EC se trouve via un symbole différent de celui utilisé pour les autres API dans Windows. Sur l’ABI Arm64 classique, le symbole du vérificateur d’appel est __guard_check_icall_fptr. Ce symbole sera présent dans Arm64EC, mais il est là pour que le code lié de manière statique x64 utilise, pas le code Arm64EC lui-même. Le code Arm64EC utilise soit __os_arm64x_check_icall __os_arm64x_check_icall_cfg.

Sur Arm64EC, les vérificateurs d’appels ne sont pas facultatifs. Toutefois, cfG est toujours facultatif, comme c’est le cas pour d’autres API. CfG peut être désactivé au moment de la compilation, ou il peut y avoir une raison légitime de ne pas effectuer de vérification CFG même lorsque CFG est activé (par exemple, le pointeur de fonction ne réside jamais dans la mémoire RW). Pour un appel indirect avec la vérification CFG, le __os_arm64x_check_icall_cfg vérificateur doit être utilisé. Si cfG est désactivé ou inutile, __os_arm64x_check_icall doit être utilisé à la place.

Voici un tableau récapitulatif de l’utilisation du vérificateur d’appel sur Arm64 classique, x64 et Arm64EC notant le fait qu’un binaire Arm64EC peut avoir deux options en fonction de l’architecture du code.

Binary Code Appel indirect non protégé Appel indirect protégé par CFG
x64 x64 aucun vérificateur d’appel __guard_check_icall_fptr ou __guard_dispatch_icall_fptr
Arm64 Classic Arm64 aucun vérificateur d’appel __guard_check_icall_fptr
Arm64EC x64 aucun vérificateur d’appel __guard_check_icall_fptr ou __guard_dispatch_icall_fptr
Arm64EC __os_arm64x_check_icall __os_arm64x_check_icall_cfg

Indépendamment de l’ABI, le fait que CFG ait activé le code (code avec référence aux vérificateurs d’appels CFG), n’implique pas la protection CFG au moment de l’exécution. Les fichiers binaires protégés par CFG peuvent s’exécuter de bas niveau, sur les systèmes ne prenant pas en charge CFG : le vérificateur d’appel est initialisé avec un assistance sans opération au moment de la compilation. Un processus peut également avoir cfG désactivé par configuration. Lorsque CFG est désactivé (ou que la prise en charge du système d’exploitation n’est pas présente) sur les API précédentes, le système d’exploitation ne met simplement pas à jour le vérificateur d’appel lorsque le fichier binaire est chargé. Sur Arm64EC, si la protection CFG est désactivée, le système d’exploitation définit __os_arm64x_check_icall_cfg la même chose que __os_arm64x_check_icall, ce qui fournira toujours la vérification de l’architecture cible nécessaire dans tous les cas, mais pas la protection CFG.

Comme avec CFG dans Classic Arm64, l’appel à la fonction cible (x11) doit immédiatement suivre l’appel au vérificateur d’appel. L’adresse du vérificateur d’appel doit être placée dans un registre volatile et ni dans l’adresse de la fonction cible, ne doit jamais être copiée dans un autre registre ou renversée en mémoire.

Vérificateurs de pile

__chkstk est utilisé automatiquement par le compilateur chaque fois qu’une fonction alloue une zone sur la pile supérieure à une page. Pour éviter d’ignorer la page de protection de la pile protégeant la fin de la pile, __chkstk elle est appelée pour vous assurer que toutes les pages de la zone allouée sont sondées.

__chkstk est généralement appelé à partir du prologue de la fonction. Pour cette raison, et pour une génération de code optimale, elle utilise une convention d’appel personnalisée.

Cela implique que le code x64 et le code Arm64EC ont besoin de leurs propres fonctions, distinctes, __chkstk car les thunks d’entrée et de sortie supposent des conventions d’appel standard.

x64 et Arm64EC partagent le même espace de noms de symboles afin qu’il ne puisse pas y avoir deux fonctions nommées __chkstk. Pour prendre en charge la compatibilité avec le code x64 préexistant, __chkstk le nom est associé au vérificateur de pile x64. Le code Arm64EC sera utilisé __chkstk_arm64ec à la place.

La convention d’appel personnalisée pour __chkstk_arm64ec est la même que pour Classic Arm64 __chkstk: x15 fournit la taille de l’allocation en octets, divisée par 16. Tous les registres non volatiles, ainsi que tous les registres volatiles impliqués dans la convention d’appel standard sont conservés.

Tout ce qui a été dit ci-dessus s’applique __chkstk de façon égale à __security_check_cookie et à son équivalent Arm64EC : __security_check_cookie_arm64ec.

Convention d’appel variadicique

Arm64EC suit la convention d’appel ABI Arm64 classique, à l’exception des fonctions Variadic (aka varargs, aka functions with the ellipsis (. .) parameter keyword).

Pour le cas spécifique variadicique, Arm64EC suit une convention d’appel très similaire à la variadicique x64, avec seulement quelques différences. Voici les principales règles d’Arm64EC variadiciques :

  • Seuls les 4 premiers registres sont utilisés pour le passage de paramètre : x0, x1, x2, x3. Les paramètres restants sont déversés sur la pile. Ceci suit la convention d’appel variadicique x64 exactement, et diffère de Arm64 Classic, où les x0registres ->x7 sont utilisés.
  • Les paramètres à virgule flottante /SIMD transmis par l’inscription utilisent un registre à usage général, et non un SIMD. Ceci est similaire à Arm64 Classic et diffère de x64, où les paramètres FP/SIMD sont transmis à la fois dans un registre à usage général et SIMD. Par exemple, pour une fonction f1(int, …) appelée f1(int, double), sur x64, le deuxième paramètre sera affecté à la fois RDX et XMM1. Sur Arm64EC, le deuxième paramètre sera affecté à juste x1.
  • Lors du passage de structures par valeur à travers un registre, les règles de taille x64 s’appliquent : les structures dont la taille est exactement 1, 2, 4 et 8 sont chargées directement dans le registre à usage général. Les structures avec d’autres tailles sont déversées sur la pile, et un pointeur vers l’emplacement déversé est affecté au registre. Cela rétrograde essentiellement par valeur en by-reference, au bas niveau. Sur l’ABI Arm64 Classique, les structures d’une taille pouvant atteindre 16 octets sont affectées directement aux registres à usage général.
  • Le registre X4 est chargé avec un pointeur vers le premier paramètre passé via la pile (le 5e paramètre). Cela n’inclut pas les structures déversées en raison des restrictions de taille décrites ci-dessus.
  • Le registre X5 est chargé avec la taille, en octets, de tous les paramètres passés par pile (taille de tous les paramètres, en commençant par le 5e). Cela n’inclut pas les structures transmises par valeur renversées en raison des restrictions de taille décrites ci-dessus.

Dans l’exemple suivant : pt_nova_function ci-dessous prend des paramètres dans un formulaire non variadicique, par conséquent en suivant la convention d’appel Arm64 classique. Il appelle pt_va_function ensuite avec les mêmes paramètres exactement, mais dans un appel variadicique à la place.

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 prend 5 paramètres qui seront attribués en suivant les règles de convention d’appel Arm64 classiques :

  • 'f' est un double. Elle sera affectée à d0.
  • 'tc' est un struct, avec une taille de 3 octets. Elle sera affectée à x0.
  • ull1 est un entier de 8 octets. Elle sera affectée à x1.
  • ull2 est un entier de 8 octets. Elle sera affectée à x2.
  • ull3 est un entier de 8 octets. Elle sera affectée à x3.

pt_va_function est une fonction variadicique, de sorte qu’elle suit les règles variadiciques Arm64EC décrites ci-dessus :

  • 'f' est un double. Elle sera affectée à x0.
  • 'tc' est un struct, avec une taille de 3 octets. Il sera déversé sur la pile et son emplacement chargé en x1.
  • ull1 est un entier de 8 octets. Elle sera affectée à x2.
  • ull2 est un entier de 8 octets. Elle sera affectée à x3.
  • ull3 est un entier de 8 octets. Elle sera affectée directement à la pile.
  • x4 est chargé avec l’emplacement de ull3 dans la pile.
  • x5 est chargé avec la taille de ull3.

L’exemple suivant montre une sortie de compilation possible pour pt_nova_function, qui illustre les différences d’attribution de paramètre décrites ci-dessus.

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

Ajouts ABI

Pour obtenir une interopérabilité transparente avec le code x64, de nombreux ajouts ont été apportés à l’ABI Arm64 Classique. Ils gèrent les différences de conventions d’appel entre Arm64EC et x64.

La liste suivante inclut ces ajouts :

Entrée et sortie thunks

Entrée et Sortie Thunks s’occupent de traduire la convention d’appel Arm64EC (principalement identique à Classic Arm64) dans la convention d’appel x64, et vice versa.

Une idée fausse courante est que les conventions d’appel peuvent être converties en suivant une règle unique appliquée à toutes les signatures de fonction. La réalité est que les conventions d’appel ont des règles d’attribution de paramètre. Ces règles dépendent du type de paramètre et sont différentes d’ABI à ABI. Une conséquence est que la traduction entre les API sera spécifique à chaque signature de fonction, variable avec le type de chaque paramètre.

Observez la fonction suivante :

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

L’attribution de paramètres se produit comme suit :

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

Considérez maintenant une fonction différente :

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

L’attribution de paramètres se produit comme suit :

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

Ces exemples montrent que l’attribution de paramètres et la traduction varient selon le type, mais également les types des paramètres précédents de la liste sont dépendants. Ce détail est illustré par le 3ème paramètre. Dans les deux fonctions, le type du paramètre est « int », mais la traduction résultante est différente.

Les entrées et sorties Thunks existent pour cette raison et sont spécifiquement adaptées à chaque signature de fonction individuelle.

Les deux types de thunks sont, eux-mêmes, des fonctions. Les thunks d’entrée sont automatiquement appelés par l’émulateur lorsque les fonctions x64 appellent les fonctions Arm64EC (l’exécution entre Arm64EC ). Les thunks de sortie sont automatiquement appelés par les vérificateurs d’appels lorsque les fonctions Arm64EC appellent dans des fonctions x64 (l’exécution quitte Arm64EC).

Lors de la compilation du code Arm64EC, le compilateur génère un thunk d’entrée pour chaque fonction Arm64EC, correspondant à sa signature. Le compilateur génère également un Thunk exit pour chaque fonction qu’une fonction Arm64EC appelle.

Prenons l’exemple suivant :

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);
}

Lors de la compilation du code ci-dessus ciblant Arm64EC, le compilateur génère :

  • Code pour « fA ».
  • Entrée Thunk pour 'fA'
  • Quitter Thunk pour 'fB'
  • Quitter Thunk pour 'fC'

L’entrée fA Thunk est générée dans le cas fA et appelée à partir du code x64. Quittez thunks pour fB et fC sont générés dans le cas fB et/ou fC s’avèrent être du code x64.

Le même Thunk de sortie peut être généré plusieurs fois, étant donné que le compilateur les générera sur le site d’appel plutôt que la fonction elle-même. Cela peut entraîner une quantité considérable de thunks redondants de sorte que, en réalité, le compilateur applique des règles d’optimisation triviales pour s’assurer que seuls les thunks requis le rendent dans le binaire final.

Par exemple, dans un fichier binaire où la fonction A Arm64EC appelle la fonction BArm64EC , B n’est pas exportée et son adresse n’est jamais connue en dehors de A. Il est sûr d’éliminer le Thunk de sortie de A , Bainsi que l’Entrée Thunk pour B. Il est également sûr d’alias ensemble tous les segments de sortie et d’entrée qui contiennent le même code, même s’ils ont été générés pour des fonctions distinctes.

Quitter thunks

À l’aide des exemples de fonctions fA, fB et fC ci-dessus, il s’agit de la façon dont le compilateur génère à la fois fB et fC quitte les thunks :

Quitter Thunk pour 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

Quitter Thunk pour 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

Dans le fB cas, nous pouvons voir comment la présence d’un paramètre « double » entraîne la réinscription de l’affectation d’inscription gp restante, résultat des différentes règles d’affectation d’Arm64 et x64. Nous pouvons également voir que x64 affecte uniquement 4 paramètres aux registres, de sorte que le 5e paramètre doit être déversé sur la pile.

Dans le fC cas, le deuxième paramètre est une structure de longueur de 3 octets. Arm64 permet à toute structure de taille d’être affectée directement à un registre. x64 autorise uniquement les tailles 1, 2, 4 et 8. Ce Thunk de sortie doit ensuite transférer cette struct opération à partir du registre sur la pile et affecter un pointeur au registre à la place. Cela consomme toujours un registre (pour transporter le pointeur) afin qu’il ne modifie pas les affectations pour les registres restants : aucun registre ne se produit pour le 3e et le 4e paramètre. Tout comme pour le fB cas, le 5e paramètre doit être déversé sur la pile.

Considérations supplémentaires relatives à la sortie de Thunks :

  • Le compilateur les nomme non pas par le nom de la fonction qu’ils traduisent>, mais plutôt par la signature qu’ils adressent. Cela facilite la recherche de redondances.
  • Exit Thunk est appelé avec le registre x9 portant l’adresse de la fonction cible (x64). Cela est défini par le vérificateur d’appel et passe par le thunk de sortie, non perturbé, dans l’émulateur.

Après avoir réorganisé les paramètres, exit Thunk appelle ensuite l’émulateur via __os_arm64x_dispatch_call_no_redirect.

Il vaut la peine, à ce stade, d’examiner la fonction du vérificateur d’appel et de détails sur son propre ABI personnalisé. Voici à quoi ressemble un appel fB indirect :

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

Lors de l’appel du vérificateur d’appel :

  • x11 fournit l’adresse de la fonction cible à appeler (fB dans ce cas). Il peut ne pas être connu, à ce stade, si la fonction cible est Arm64EC ou x64.
  • x10 fournit un Jeu de sortie correspondant à la signature de la fonction appelée (fB dans ce cas).

Les données retournées par le vérificateur d’appel dépendent de la fonction cible arm64EC ou x64.

Si la cible est Arm64EC :

  • x11 retourne l’adresse du code Arm64EC à appeler. Il peut s’agir ou non de la même valeur que celle fournie.

Si la cible est du code x64 :

  • x11 retourne l’adresse du Thunk de sortie. Cette opération est copiée à partir de l’entrée fournie dans x10.
  • x10 retourne l’adresse du Thunk de sortie, non perturbée par l’entrée.
  • x9 retourne la fonction x64 cible. Il peut s’agir ou non de la même valeur qu’elle a été fournie via x11.

Les vérificateurs d’appels quittent toujours les paramètres de convention d’appel ne sont pas perturbés. Le code appelant doit donc suivre immédiatement l’appel au vérificateur d’appel avec blr x11 (ou br x11 en cas d’appel de fin). Il s’agit des vérificateurs d’appels inscrits. Ils conserveront toujours au-dessus et au-delà des registres non volatiles standard : x0-x8, x15(chkstk) et .q0-q7

Entrée Thunks

Les thunks d’entrée prennent en charge les transformations requises de la x64 aux conventions d’appel Arm64. C’est essentiellement l’inverse de Exit Thunks, mais il y a quelques autres aspects à prendre en compte.

Considérez l’exemple précédent de compilation fA, un thunk d’entrée est généré afin de pouvoir fA être appelé par du code x64.

Entrée Thunk pour 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’adresse de la fonction cible est fournie par l’émulateur dans x9.

Avant d’appeler l’entrée Thunk, l’émulateur x64 affiche l’adresse de retour de la pile dans le LR registre. Il est alors prévu qu’il LR pointe vers le code x64 lorsque le contrôle est transféré vers l’entrée Thunk.

L’émulateur peut également effectuer un autre ajustement de la pile, selon les éléments suivants : Les API Arm64 et x64 définissent une exigence d’alignement de pile où la pile doit être alignée sur 16 octets au point qu’une fonction est appelée. Lors de l’exécution du code Arm64, le matériel applique cette règle, mais il n’existe aucune application matérielle pour x64. Lors de l’exécution du code x64, les fonctions appelant de manière erronée avec une pile non alignée peuvent passer inaperçues indéfiniment, jusqu’à ce que des instructions d’alignement de 16 octets soient utilisées (certaines instructions SSE le font) ou que le code Arm64EC soit appelé.

Pour résoudre ce problème de compatibilité potentiel, avant d’appeler l’entrée Thunk, l’émulateur aligne toujours le pointeur de pile sur 16 octets et stocke sa valeur d’origine dans le x4 registre. Ainsi, l’entrée Thunks commence toujours à s’exécuter avec une pile alignée, mais peut toujours référencer correctement les paramètres transmis sur la pile, via x4.

En ce qui concerne les registres SIMD non volatiles, il existe une différence significative entre les conventions d’appel Arm64 et x64. Sur Arm64, les 8 octets faibles (64 bits) du registre sont considérés comme non volatiles. En d’autres termes, seule la Dn partie des Qn registres n’est pas volatile. Sur x64, les 16 octets entiers du XMMn registre sont considérés comme non volatiles. De plus, sur les registres x64 XMM6 et XMM7 non volatiles, les registres D6 et D7 (registres Arm64 correspondants) sont volatiles.

Pour traiter ces asymmetries de manipulation de registre SIMD, les entrées Thunks doivent enregistrer explicitement tous les registres SIMD considérés comme non volatiles dans x64. Cela n’est nécessaire que sur l’entrée Thunks (pas Exit Thunks), car x64 est plus strict que Arm64. En d’autres termes, l’enregistrement/conservation des règles dans x64 dépasse les exigences Arm64 dans tous les cas.

Pour résoudre la récupération correcte de ces valeurs d’inscription lors du déroulement de la pile (par exemple, setjmp + longjmp ou lève + catch), un nouveau décodage de déroulement a été introduit : save_any_reg (0xE7). Ce nouveau décodage de 3 octets permet d’enregistrer tout registre à usage général ou SIMD (y compris ceux considérés comme volatiles) et d’inclure des registres de taille Qn complète. Ce nouvel opcode est utilisé pour les Qn opérations de déversements/remplissage du registre ci-dessus. save_any_reg est compatible avec save_next_pair (0xE6).

Pour référence, voici les informations de déroulement correspondantes appartenant à l’entrée Thunk présentée ci-dessus :

   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)

Une fois la fonction Arm64EC retournée, la __os_arm64x_dispatch_ret routine est utilisée pour entrer à nouveau l’émulateur, de retour au code x64 (pointé par LR).

Les fonctions Arm64EC ont les 4 octets avant la première instruction de la fonction réservée pour stocker les informations à utiliser au moment de l’exécution. Il se trouve dans ces 4 octets que l’adresse relative de l’entrée Thunk pour la fonction est disponible. Lors de l’exécution d’un appel d’une fonction x64 vers une fonction Arm64EC, l’émulateur lit les 4 octets avant le début de la fonction, masque les deux bits inférieurs et ajoute cette quantité à l’adresse de la fonction. Cela génère l’adresse de l’entrée Thunk à appeler.

Ajusteur Thunks

Les ajustements Thunks sont des fonctions sans signature qui transfèrent simplement le contrôle vers (tail-call) une autre fonction, après avoir effectué une transformation vers l’un des paramètres. Le type du ou des paramètres en cours de transformation est connu, mais tous les paramètres restants peuvent être tout et, dans n’importe quel nombre , ajusteur Thunks ne touchera aucun registre pouvant contenir un paramètre et ne touchera pas la pile. C’est ce qui rend les fonctions sans signature d’Adjustor Thunks.

Les ajustements Thunks peuvent être générés automatiquement par le compilateur. C’est courant, par exemple, avec l’héritage multiple C++, où toute méthode virtuelle peut être déléguée à la classe parente, sans modification, en dehors d’un ajustement vers le this pointeur.

Voici un exemple réel :

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

Le thunk soustrait 8 octets au this pointeur et transfère l’appel à la classe parente.

En résumé, les fonctions Arm64EC pouvant être appelées à partir de fonctions x64 doivent avoir une entrée Thunk associée. L’entrée Thunk est spécifique à la signature. Les fonctions sans signature Arm64, telles que Adjustor Thunks, ont besoin d’un mécanisme différent qui peut gérer les fonctions sans signature.

L’entrée Thunk d’un Ajusteur Thunk utilise l’assistance __os_arm64x_x64_jump pour différer l’exécution du travail De Thunk d’entrée réel (ajuster les paramètres d’une convention à l’autre) à l’appel suivant. C’est à ce moment que la signature devient apparente. Cela inclut la possibilité de ne pas effectuer d’ajustements de convention d’appel du tout, si la cible de l’ajusteur Thunk s’avère être une fonction x64. N’oubliez pas qu’au moment où une Entrée Thunk commence à s’exécuter, les paramètres se trouvent sous leur forme x64.

Dans l’exemple ci-dessus, réfléchissez à l’apparence du code dans Arm64EC.

Ajusteur Thunk dans Arm64EC

[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

Tronc d’entrée de Thunk de l’ajusteur

[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

Séquences de transfert rapide

Certaines applications apportent des modifications au moment de l’exécution aux fonctions résidant dans des fichiers binaires qu’elles ne possèdent pas, mais dépendent des fichiers binaires du système d’exploitation courants, pour les besoins de la déviation de l’exécution lorsque la fonction est appelée. C’est également ce qu’on appelle le raccordement.

Au niveau supérieur, le processus de raccordement est simple. En détail, toutefois, le raccordement est spécifique à l’architecture et assez complexe étant donné les variations potentielles que la logique de raccordement doit traiter.

En général, le processus implique les éléments suivants :

  • Déterminez l’adresse de la fonction à raccorder.
  • Remplacez la première instruction de la fonction par un saut vers la routine de crochet.
  • Lorsque le crochet est terminé, revenez à la logique d’origine, qui inclut l’exécution de l’instruction d’origine déplacée.

Les variations proviennent de choses telles que :

  • Taille de la 1ère instruction : il est judicieux de le remplacer par un JMP qui est de la même taille ou plus petite, pour éviter de remplacer le haut de la fonction tandis que d’autres threads peuvent l’exécuter en vol.
  • Type de la première instruction : si la première instruction a une nature relative au PC, le déplacement peut nécessiter des modifications comme les champs de déplacement. Étant donné qu’elles sont susceptibles de dépasser lorsqu’une instruction est déplacée vers un endroit distant, cela peut nécessiter la fourniture d’une logique équivalente avec des instructions différentes.

En raison de toute cette complexité, la logique de raccordement robuste et générique est rare à trouver. Fréquemment, la logique présente dans les applications ne peut faire face qu’à un ensemble limité de cas que l’application s’attend à rencontrer dans les API spécifiques qui lui intéressent. Il n’est pas difficile d’imaginer la quantité d’un problème de compatibilité d’application. Même une modification simple du code ou des optimisations du compilateur peut rendre les applications inutilisables si le code ne ressemble plus exactement comme prévu.

Que se passerait-il pour ces applications s’ils rencontraient du code Arm64 lors de la configuration d’un hook ? Ils échoueraient certainement.

Les fonctions FFS (Fast-Forward Sequence) répondent à cette exigence de compatibilité dans Arm64EC.

FFS sont des fonctions x64 très petites qui ne contiennent aucune logique réelle et appel de queue à la fonction Arm64EC réelle. Ils sont facultatifs mais activés par défaut pour toutes les exportations DLL et pour n’importe quelle fonction décorée avec __declspec(hybrid_patchable).

Dans ce cas, lorsque le code obtient un pointeur vers une fonction donnée, soit GetProcAddress dans le cas d’exportation, soit par &function le __declspec(hybrid_patchable) cas, l’adresse résultante contient du code x64. Ce code x64 passe pour une fonction x64 légitime, satisfaisant la plupart de la logique de raccordement actuellement disponible.

Prenons l’exemple suivant (gestion des erreurs omise pour la concision) :

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);

La valeur du pointeur de fonction dans la pgma variable contiendra l’adresse de GetMachineTypeAttributes's FFS.

Voici un exemple de séquence de transfert rapide :

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 fonction FFS x64 a un prolog canonique et un épilogue, se terminant par un appel de fin (saut) vers la fonction réelle GetMachineTypeAttributes dans le code 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
                           [...]

Il serait très inefficace s’il était nécessaire d’exécuter 5 instructions x64 émulées entre deux fonctions Arm64EC. Les fonctions FFS sont spéciales. Les fonctions FFS ne s’exécutent pas vraiment si elles restent inchangées. L’assistance de l’appelant vérifie efficacement si le FFS n’a pas été modifié. Si c’est le cas, l’appel est transféré directement vers la destination réelle. Si le FFS a été modifié de quelque manière que ce soit, il ne sera plus un FFS. L’exécution est transférée vers le FFS modifié et exécute le code qui peut y être, en émulant le détour et toute logique de raccordement.

Lorsque le hook transfère l’exécution à la fin du FFS, il atteint finalement l’appel de fin au code Arm64EC, qui s’exécute ensuite après le hook, comme l’attend l’application.

Création d’Arm64EC dans l’assembly

Les en-têtes du Kit de développement logiciel (SDK) Windows et le compilateur C peuvent simplifier le travail de création d’assembly Arm64EC. Par exemple, le compilateur C peut être utilisé pour générer des thunks d’entrée et de sortie pour les fonctions non compilées à partir du code C.

Considérez l’exemple d’un équivalent à la fonction fD suivante qui doit être créée dans Assembly (ASM). Cette fonction peut être appelée par le code Arm64EC et x64, et le pfE pointeur de fonction peut également pointer vers le code Arm64EC ou x64.

typedef int (PF_E)(int, double);

extern PF_E * pfE;

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

L’écriture fD dans ASM ressemble à ceci :

#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

Dans l’exemple ci-dessus :

  • Arm64EC utilise la même déclaration de procédure et les macros prolog/épilogue que Arm64.
  • Les noms de fonction doivent être encapsulés par la A64NAME macro. Lors de la compilation du code C/C++ en tant qu’Arm64EC, le compilateur marque le OBJ code Arm64EC comme ARM64EC contenant. Cela ne se produit pas avec ARMASM. Lors de la compilation du code ASM, il existe un autre moyen d’informer l’éditeur de liens que le code produit est Arm64EC. Il s’agit du préfixe du nom de la fonction avec #. La A64NAME macro effectue cette opération quand elle _ARM64EC_ est définie et laisse le nom inchangé lorsqu’elle _ARM64EC_ n’est pas définie. Cela permet de partager du code source entre Arm64 et Arm64EC.
  • Le pfE pointeur de fonction doit d’abord être exécuté via le vérificateur d’appel EC, ainsi que le thunk de sortie approprié, au cas où la fonction cible était x64.

Génération de thunks d’entrée et de sortie

L’étape suivante consiste à générer l’entrée Thunk pour fD et la sortie de Thunk pour pfE. Le compilateur C peut effectuer cette tâche avec un effort minimal, à l’aide du mot clé du _Arm64XGenerateThunk compilateur.

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;
}

Le _Arm64XGenerateThunk mot clé indique au compilateur C d’utiliser la signature de fonction, d’ignorer le corps et de générer un Thunk de sortie (lorsque le paramètre est 1) ou une entrée Thunk (lorsque le paramètre est 2).

Il est recommandé de placer la génération de thunk dans son propre fichier C. Le fait d’être dans des fichiers isolés simplifie la confirmation des noms de symboles en vidant les symboles correspondants OBJ ou même en désassemblant.

Entrées personnalisées Thunks

Des macros ont été ajoutées au Kit de développement logiciel (SDK) pour faciliter la création de macros personnalisées, codées à la main, Entrée Thunks. Dans le cas où cela peut être utilisé, il s’agit de la création d’ajusteurs personnalisés Thunks.

La plupart des ajustements Thunks sont générés par le compilateur C++, mais ils peuvent également être générés manuellement. Cela se trouve dans les cas où un rappel générique transfère le contrôle au rappel réel, identifié par l’un des paramètres.

Voici un exemple de code 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

Dans cet exemple, l’adresse de fonction cible est récupérée à partir de l’élément d’une structure, fournie par référence, via le 1er paramètre. Étant donné que la structure est accessible en écriture, l’adresse cible doit être validée via Control Flow Guard (CFG).

L’exemple ci-dessous montre comment l’ajusteur Thunk équivalent ressemblerait lors du portage vers 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

Le code ci-dessus ne fournit pas de thunk de sortie (dans le registre x10). Cela n’est pas possible, car le code peut être exécuté pour de nombreuses signatures différentes. Ce code tire parti de l’appelant ayant défini x10 sur exit Thunk. L’appelant aurait effectué l’appel ciblant une signature explicite.

Le code ci-dessus a besoin d’un thunk d’entrée pour résoudre le cas lorsque l’appelant est du code x64. Voici comment créer l’entrée Thunk correspondante à l’aide de la macro pour les entrées Thunks personnalisées :

    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

Contrairement à d’autres fonctions, cette entrée Thunk ne transfère pas finalement le contrôle à la fonction associée (l’ajusteur Thunk). Dans ce cas, la fonctionnalité elle-même (effectuant l’ajustement du paramètre) est incorporée dans l’entrée Thunk et le contrôle est transféré directement vers la cible de fin, via l’assistance __os_arm64x_x64_jump .

Génération dynamique (compilation JIT) du code Arm64EC

Dans les processus Arm64EC, il existe deux types de mémoire exécutable : le code Arm64EC et le code x64.

Le système d’exploitation extrait ces informations des fichiers binaires chargés. Les fichiers binaires x64 sont tous x64 et Arm64EC contiennent une table de plages pour arm64EC et les pages de codes x64.

Qu’en est-il du code généré dynamiquement ? Les compilateurs juste-à-temps (JIT) génèrent du code, au moment de l’exécution, qui n’est pas soutenu par un fichier binaire.

Cela implique généralement :

  • Allocation de mémoire accessible en écriture (VirtualAlloc).
  • Production du code dans la mémoire allouée.
  • Reprotection de la mémoire de lecture-écriture en lecture-exécution (VirtualProtect).
  • Ajouter des entrées de fonction de déroulement pour toutes les fonctions générées non triviales (non feuille) (RtlAddFunctionTable ou RtlAddGrowableFunctionTable).

Pour des raisons de compatibilité triviales, toute application effectuant ces étapes dans un processus Arm64EC entraîne la prise en compte du code x64. Cela se produit pour n’importe quel processus à l’aide du runtime Java x64 non modifié, du runtime .NET, du moteur JavaScript, etc.

Pour générer du code dynamique Arm64EC, le processus est principalement le même avec seulement deux différences :

  • Lors de l’allocation de la mémoire, utilisez une version plus récente VirtualAlloc2 (au lieu de VirtualAlloc ou VirtualAllocEx) et fournissez l’attribut MEM_EXTENDED_PARAMETER_EC_CODE .
  • Lors de l’ajout d’entrées de fonction :
    • Ils doivent être au format Arm64. Lors de la compilation du code Arm64EC, le RUNTIME_FUNCTION type correspond au format x64. Pour le format Arm64 lors de la compilation d’Arm64EC, utilisez plutôt le ARM64_RUNTIME_FUNCTION type.
    • N’utilisez pas l’ANCIENNE RtlAddFunctionTable API. Utilisez toujours l’API la plus récente RtlAddGrowableFunctionTable à la place.

Voici un exemple d’allocation de mémoire :

    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);

Et un exemple d’ajout d’une entrée de fonction de déroulement :

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)
);