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.

Cet article fournit 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, notamment le débogage de bas niveau/assembleur et l’écriture de code d’assembly ciblant l’ABI Arm64EC.

Conception d’Arm64EC

Arm64EC offre 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. L’ABI classique a changé très peu, mais l’ABI Arm64EC a ajouté des parties pour permettre l’interopérabilité x64.

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

Arm64EC vs Arm64 Classic ABI

La liste suivante indique où Arm64EC diffère d’Arm64 Classic ABI.

Ces différences sont de petits changements lorsque l’on considère la quantité définie par l’ABI dans son ensemble.

Inscrire le mappage et les registres bloqués

Pour activer l’interopérabilité au niveau du type avec du code x64, le code Arm64EC se compile 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.

Vous devez utiliser cette structure pour représenter le contexte de l’UC lors de l’exécution du code x64 et du code Arm64EC. Le code existant ne comprend pas un nouveau concept, tel que l'ensemble de registres du processeur qui change d'une fonction à une autre. Si vous utilisez la structure x64 CONTEXT pour représenter les états d’exécution Arm64, vous mappez efficacement les registres Arm64 dans des registres x64.

Ce mappage signifie également que vous ne pouvez pas utiliser de registres Arm64 qui ne tiennent pas dans le x64 CONTEXT. Leurs valeurs peuvent être perdues chaque fois qu’une opération utilise CONTEXT (et certaines opérations peuvent être asynchrones et inattendues, telles que l’opération garbage collection d’un runtime de langage managé ou d’un APC).

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

Par exemple, RCX correspond à X0, RDX à X1, RSP à SP, RIP à PC, et ainsi de suite. Les registres x13, x14x23x24, x28et v16 par le biais v31 n’ont 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 l’introduction de Control Flow Guard (CFG) 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 vous compilez du code avec l’option /guard:cf, le compilateur génère un appel supplémentaire à la fonction de vérificateur juste avant chaque appel indirect ou saut. Windows fournit la fonction vérificateur elle-même. Pour CFG, il effectue une vérification de validité par rapport aux cibles d’appel connues pour être bonnes. Les fichiers binaires compilés avec /guard:cf incluent également ces informations.

Cet exemple montre une utilisation du 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 du CFG, le vérificateur d'appel vérifie 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 qui n'est pas utilisé par la convention d’appel normale et conservent tous les registres de convention d’appel normale. 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.

Binaire 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 variadiciques (également appelées varargs ou fonctions avec le mot clé de paramètre ellipsis (. .).

Pour le cas spécifique variadicique, Arm64EC suit une convention d’appel très similaire à la variadicique x64, avec seulement quelques différences. La liste suivante présente les principales règles pour Arm64EC variadic :

  • Seuls les quatre premiers registres sont utilisés pour le passage de paramètre : x0, , x1x2, x3. Les paramètres restants se déversent sur la pile. Cette règle suit exactement la convention d’appel variadique x64 et diffère d'Arm64 Classic, où les registres x0 à x7 sont utilisés.
  • Les paramètres à virgule flottante et SIMD passés par registre utilisent un registre à usage général, et non un registre SIMD. Cette règle 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 est affecté à la fois RDX et XMM1. Sur Arm64EC, le deuxième paramètre est affecté à juste x1.
  • Lors du passage de structures par valeur à travers un registre, les règles de taille x64 s’appliquent : les structures avec des tailles exactement 1, 2, 4 et 8 octets sont chargées directement dans le registre à usage général. Les structures de tailles différentes débordent sur la pile, et un pointeur vers l'emplacement débordé est assigné au registre. Cette règle rétrograde essentiellement de par valeur en par référence au niveau bas. 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 x4 registre charge un pointeur vers le premier paramètre passé via la pile (le cinquième paramètre). Cette règle n’inclut pas les structures débordées en raison des restrictions de taille décrites précédemment.
  • Le registre x5 charge la taille, en octets, de tous les paramètres passés via la pile (taille totale de tous les paramètres, en commençant par le cinquième). Cette règle n’inclut pas les structures passées par valeur débordées en raison des restrictions de taille décrites précédemment.

Dans l’exemple suivant, pt_nova_function prend des paramètres dans un formulaire non variadicique, de sorte qu’il suit 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 cinq paramètres, qu’il attribue en suivant les règles de convention d’appel Arm64 classiques :

  • 'f' est un double. Il est affecté à d0.
  • 'tc' est un struct dont la taille est de 3 octets. Il est affecté à x0.
  • ull1 est un entier de 8 octets. Il est affecté à x1.
  • ull2 est un entier de 8 octets. Il est affecté à x2.
  • ull3 est un entier de 8 octets. Il est affecté à x3.

pt_va_function est une fonction variadicique, de sorte qu’elle suit les règles variadiciques Arm64EC décrites précédemment :

  • 'f' est un double. Il est affecté à x0.
  • 'tc' est un struct dont la taille est de 3 octets. Il se déverse dans la pile et son emplacement est chargé dans x1.
  • ull1 est un entier de 8 octets. Il est affecté à x2.
  • ull2 est un entier de 8 octets. Il est affecté à x3.
  • ull3 est un entier de 8 octets. Il affecte directement à la pile.
  • x4 charge l’emplacement de ull3 dans la pile.
  • x5 charge la taille de ull3.

L’exemple suivant montre la sortie de compilation possible pour pt_nova_function, qui illustre les différences d’attribution de paramètres décrites précédemment.

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 du code x64, apportez de nombreux ajouts à l’ABI Arm64 classique. Ces ajouts gèrent les différences de conventions d’appel entre Arm64EC et x64.

La liste suivante inclut ces ajouts :

Entrée et sortie de thunks

Les thunks d’entrée et de sortie traduisent la convention d’appel Arm64EC (principalement identique à arm64 classique) en convention d’appel x64, et vice versa.

Une idée fausse courante est que vous pouvez convertir des conventions d’appel 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 est 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 et la traduction de paramètres varient selon le type, mais dépendent également des types des paramètres précédents dans la liste. Ce détail est illustré par le troisième paramètre. Dans les deux fonctions, le type du paramètre est int, mais la traduction résultante est différente.

Les thunks d’entrée et de sortie existent pour cette raison et sont spécifiquement adaptés à chaque signature de fonction.

Les deux types de thunks sont des fonctions. L’émulateur appelle automatiquement les thunks d'entrée lorsque les fonctions x64 font appel aux fonctions Arm64EC (l'exécution pénètre dans Arm64EC). Les vérificateurs d’appels appellent automatiquement les thunks de sortie lorsque les fonctions Arm64EC appellent les 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 de sortie pour chaque fonction appelée par une fonction Arm64EC.

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 précédent ciblant Arm64EC, le compilateur génère :

  • Code pour fA.
  • Thunk d'entrée pour fA
  • Quitter le thunk pour fB
  • Quitter le thunk pour fC

Le compilateur génère le fA thunk d’entrée si fA est appelé depuis le code x64. Le compilateur génère des thunks de sortie pour fB et fC dans le cas où fB et fC sont du code x64.

Le compilateur peut générer la même "exit thunk" plusieurs fois, car il la génère au site d'appel plutôt qu'à la fonction elle-même. Cette duplication peut entraîner une quantité considérable de thunks redondants. Pour éviter cette duplication, le compilateur applique des règles d’optimisation triviales pour s’assurer que seuls les thunks requis le font 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 sans risque d’éliminer le thunk de sortie de A à B, ainsi que le thunk d’entrée 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.

Sortie des thunks

À l’aide des exemples de fonctions fA, fB et fC dans la section précédente, le compilateur génère à la fois les sorties fB et fC des thunks comme suit :

Quitter thunk vers 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

Sortie vers thunk 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, la présence d'un double paramètre entraîne une réaffectation des registres GP restants, en raison des différentes règles d'affectation d'Arm64 et de x64. Vous pouvez également voir que x64 affecte uniquement quatre paramètres aux registres, de sorte que le cinquième 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 transférer ce struct du registre vers la pile et définir un pointeur vers le registre à la place. Cette approche consomme toujours un registre (pour transporter le pointeur), de sorte qu’il ne modifie pas les affectations pour les registres restants : aucune réorganisation de registres ne se produit pour les troisième et quatrième paramètres. Tout comme pour le fB cas, le cinquième 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 fonction vers lequel ils se traduisent, mais plutôt par la signature qu’ils adressent. Cette convention d’affectation de noms facilite la recherche de redondances.
  • Le vérificateur d’appel définit le registre x9 pour qu’il porte l’adresse de la fonction cible (x64). Exit Thunk appelle l’émulateur, en passant x9 sans aucune modification.

Après avoir réorganisé les paramètres, Exit Thunk fait appel à l’émulateur via __os_arm64x_dispatch_call_no_redirect.

À ce stade, il vaut la peine d’examiner la fonction du vérificateur d’appel et son 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). À ce stade, le vérificateur d’appel peut ne pas savoir 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 varient selon que la fonction cible est Arm64EC ou x64.

Si la cible est Arm64EC :

  • x11 retourne l’adresse du code Arm64EC à appeler. Cette valeur peut être identique à celle fournie.

Si la cible est du code x64 :

  • x11 retourne l’adresse du Thunk de sortie. Cette adresse est copiée à partir de l’entrée fournie dans x10.
  • x10 retourne l’adresse de Exit Thunk, non perturbée par l’entrée.
  • x9 retourne la fonction x64 cible. Cette valeur peut être identique à celle fournie via x11.

Les vérificateurs d'appels laissent toujours les registres de paramètres de convention d'appel inchangés. Le code appelant doit suivre l’appel au vérificateur d'appels avec immédiatement blr x11 (ou br x11 en cas d'appel de fin). Les vérificateurs d’appels conservent toujours ces registres 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. Cette transformation est essentiellement l’inverse de Exit Thunks, mais implique quelques aspects supplémentaires à prendre en compte.

Considérez l’exemple précédent de compilation fA. Un thunk d’entrée est généré afin que le code x64 puisse appeler fA.

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’émulateur fournit l’adresse de la fonction cible dans x9.

Avant d’appeler le Thunk d'entrée, l’émulateur x64 retire l’adresse de retour de la pile pour la placer dans le registre LR. LR est alors censé pointer sur le code x64 lorsque le transfert de contrôle s'effectue vers l'Entry Thunk.

L’émulateur peut également effectuer un autre ajustement de la pile, en fonction des é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 de code x64, l'appel erroné de fonctions avec une pile non alignée peut passer inaperçu pendant longtemps, jusqu'à ce que certaines instructions nécessitant un alignement sur 16 octets (comme certaines instructions SSE) soient utilisées ou que du code Arm64EC soit appelé.

Pour résoudre ce problème de compatibilité potentiel, avant d’appeler l'"Entry Thunk", l’émulateur aligne toujours vers le bas le pointeur de pile sur 16 octets et stocke sa valeur d’origine dans le registre x4. Ainsi, les Entry Thunks commencent toujours à s’exécuter avec une pile alignée, mais sont toujours capables de référencer correctement les paramètres passés 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. Cet enregistrement n’est nécessaire que sur les thunks d’entrée (pas les thunks de sortie), car x64 est plus strict qu'Arm64. En d’autres termes, l’enregistrement et les règles de conservation dans x64 dépassent les exigences Arm64 dans tous les cas.

Pour résoudre la récupération correcte de ces valeurs de registre lors du déroulement de la pile (par exemple, setjmp + longjmp ou throw + catch), un nouvel opcode 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 dépassements et les opérations de remplissage du registre. save_any_reg est compatible avec save_next_pair (0xE6).

Pour référence, les informations de désempilage suivantes appartiennent au point d'entrée Thunk présenté précédemment :

   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)

Après que la fonction Arm64EC retourne, la routine __os_arm64x_dispatch_ret réintègre l'émulateur et revient au code x64 (indiqué par LR).

Les fonctions Arm64EC réservent les quatre octets avant la première instruction de la fonction pour stocker des informations à utiliser au moment de l’exécution. Dans ces quatre octets, l’adresse relative de Entry 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 quatre octets avant le début de la fonction, masque les deux bits inférieurs et ajoute cette quantité à l’adresse de la fonction. Ce processus produit l'adresse du thunk d'entrée qu'il faut appeler.

Ajusteur Thunks

Les "Adjustor Thunks" sont des fonctions sans signature qui transfèrent le contrôle à une autre fonction (appel terminal). Avant de transférer le contrôle, ils transforment l’un des paramètres. Le type des paramètres transformés est connu, mais tous les autres paramètres peuvent être de n'importe quelle sorte et en n'importe quel nombre. Les Adjustor Thunks ne touchent à aucun registre qui contient potentiellement un paramètre, et ils ne touchent pas à la pile. Cette caractéristique fait des Adjustor Thunks des fonctions sans signature.

Le compilateur peut générer automatiquement des Adjustor Thunks. Cette génération est courante, par exemple, avec l’héritage multiple C++, où n’importe quelle méthode virtuelle peut déléguer à la classe parente sans modification, à l’exception d’un ajustement au this pointeur.

L’exemple suivant montre un scénario 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 d'avance rapide

Certaines applications apportent des modifications en temps d'exécution aux fonctions résidant dans des fichiers binaires qu'elles ne possèdent pas, mais dont elles dépendent, notamment des fichiers binaires du système d'exploitation, dans le but de détourner l'exécution lorsque la fonction est appelée. Ce processus est également appelé raccordement.

À un niveau élevé, 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 étapes suivantes :

  • 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 premiè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 alors 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’ils peuvent dépasser lorsqu’une instruction est déplacée vers un emplacement distant, cette modification peut nécessiter l'implémentation 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 l'ampleur d'un problème de compatibilité des applications. Même une modification simple dans les optimisations du code ou du compilateur peut rendre les applications inutilisables si le code ne semble plus exactement comme prévu.

Que se passe-t-il pour ces applications s’ils ont rencontré 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 de très petites fonctions x64 qui ne contiennent aucune logique réelle et effectuent un appel terminal à la véritable fonction Arm64EC. 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 variable pgma contient l’adresse de la FFS de GetMachineTypeAttributes.

Cet exemple montre une séquence Fast-Forward :

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 cinq 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 du vérificateur d’appel vérifie efficacement si le FFS n’a pas été modifié. Si c’est le cas, l’appel transfère directement vers la destination réelle. Si le FFS est modifié de quelque manière que ce soit, il n’est plus un FFS. L’exécution transfère vers le FFS modifié et exécute le code qui peut être présent, en émulant la déviation 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 en assembleur

Les en-têtes du Kit de développement logiciel (SDK) Windows et le compilateur C simplifient le travail de création d’assembly Arm64EC. Par exemple, vous pouvez utiliser le compilateur C pour générer des thunks d’entrée et de sortie pour les fonctions qui ne sont pas compilées à partir du code C.

Considérez l’exemple d’un équivalent à la fonction fD suivante que vous devez créer dans l’assembly (ASM). Arm64EC et code x64 peuvent appeler cette fonction, et le pfE pointeur de fonction peut 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 peut ressembler au code suivant :

#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 précédent :

  • Arm64EC utilise la même déclaration de procédure et les macros prolog/épilogue que Arm64.
  • Enveloppez les noms de fonction avec la macro A64NAME. Lorsque vous compilez du code C ou C++ comme Arm64EC, le compilateur marque le OBJ comme ARM64EC contenant du code Arm64EC. Ce marquage ne se produit pas avec ARMASM. Lorsque vous compilez du code ASM, vous pouvez informer l’éditeur de liens que le code produit est Arm64EC en préfixant le 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. Cette approche permet de partager du code source entre Arm64 et Arm64EC.
  • Vous devez d’abord exécuter le pfE pointeur de fonction 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 d’entrées et de sorties intermédiaires

L’étape suivante consiste à générer le thunk d’entrée pour fD et le thunk de sortie 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 un thunk d’entrée (lorsque le paramètre est 2).

Placez la génération de thunk dans son propre fichier source 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.

Thunks d'entrée personnalisés

Le Kit de développement logiciel (SDK) inclut des macros qui vous aident à créer des thunks d’entrée codés à la main personnalisés. Vous pouvez utiliser ces macros lorsque vous créez des thunks d’ajustement personnalisés.

La plupart des thunks d’ajustement sont générés par le compilateur C++, mais vous pouvez également les générer manuellement. Vous pourriez générer manuellement un "adjustor thunk" lorsqu'un rappel générique transfère le contrôle au rappel réel, et qu'un des paramètres identifie ce dernier.

L’exemple suivant montre un "adjustor thunk" dans le 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, le premier paramètre fournit une référence à une structure. Le code récupère l’adresse de fonction cible à partir d’un élément de cette structure. Étant donné que la structure est accessible en écriture, Control Flow Guard (CFG) doit valider l’adresse cible.

L’exemple suivant montre comment transférer le thunk d’ajustement équivalent 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 précédent ne fournit pas de thunk de sortie (dans le registre x10). Cette approche n’est pas possible, car le code peut s’exécuter pour de nombreuses signatures différentes. Ce code tire parti du réglage x10 par l’appelant pour le thunk de sortie. L’appelant effectue l’appel ciblant une signature explicite.

Le code précédent a besoin d’un thunk d’entrée pour traiter le cas où l’appelant est du code x64. L’exemple suivant montre comment créer le thunk d'entrée correspondant en utilisant la macro pour les thunks d'entrée personnalisés :

    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, ce thunk d'entrée ne transfère finalement pas le contrôle à la fonction associée (le thunk d'ajustement). Dans ce cas, le thunk d’entrée incorpore la fonctionnalité elle-même (effectuant l’ajustement des paramètres) et transfère le contrôle directement à la cible de fin via l’assistance __os_arm64x_x64_jump .

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

Dans les processus Arm64EC, deux types de mémoire exécutable existent : 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 au format x64, et les fichiers binaires Arm64EC incluent une table de correspondance pour les pages de code Arm64EC par rapport aux pages de code 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 sauvegardé par un fichier binaire.

En règle générale, ce processus implique les étapes suivantes :

  • Allocation de mémoire accessible en écriture (VirtualAlloc).
  • Production du code dans la mémoire allouée.
  • Modifier la protection de la mémoire de lecture-écriture à lecture-exécution (VirtualProtect).
  • Ajout d’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, si une application effectue ces étapes dans un processus Arm64EC, le système d’exploitation considère le code comme code x64. Ce comportement se produit pour tout processus qui utilise le runtime Java x64 non modifié, le runtime .NET, le moteur JavaScript, et ainsi de suite.

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

  • Lors de l’allocation de la mémoire, utilisez la version la 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 .

L’exemple suivant montre l’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);

L’exemple suivant montre comment ajouter une entrée de fonction de désenroulement :

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