Compartir a través de


Descripción de la ABI y el código de ensamblado de ARM64EC

Arm64EC ("Compatible con emulación") es una nueva interfaz binaria de aplicaciones (ABI) para compilar aplicaciones para Windows 11 en Arm. Para obtener información general sobre Arm64EC y cómo empezar a compilar aplicaciones Win32 como Arm64EC, consulte Uso de Arm64EC para compilar aplicaciones para Windows 11 en dispositivos Arm.

En este artículo se proporciona una vista detallada de la ABI de Arm64EC con suficiente información para que un desarrollador de aplicaciones escriba y depure código compilado para Arm64EC, incluida la depuración de bajo nivel o ensamblador y la escritura de código de ensamblado destinado a la ABI de Arm64EC.

Diseño de Arm64EC

Arm64EC ofrece funcionalidad y rendimiento de nivel nativo, al tiempo que proporciona interoperabilidad transparente y directa con código x64 que se ejecuta bajo emulación.

Arm64EC es principalmente aditivo para la ABI clásica de Arm64. La ABI clásica cambió muy poco, pero la ABI arm64EC agregó partes para habilitar la interoperabilidad x64.

En este documento, la ABI original y estándar arm64 se conoce como "ABI clásica". Este término evita la ambigüedad inherente a términos sobrecargados como "Nativo". Arm64EC es tan nativo en todos los aspectos como la ABI original.

Arm64EC frente a ABI Clásica de Arm64

En la lista siguiente se señala dónde Arm64EC difiere de la ABI clásica de Arm64.

Estas diferencias son pequeños cambios cuando se ven en perspectiva de cuánto define la ABI completa.

Registrar la asignación y los registros bloqueados

Para habilitar la interoperabilidad de nivel de tipo con código x64, el código Arm64EC se compila con las mismas definiciones de arquitectura de preprocesador que el código x64.

Es decir, _M_AMD64 y _AMD64_ se definen. Uno de los tipos afectados por esta regla es la CONTEXT estructura . La CONTEXT estructura define el estado de la CPU en un punto determinado. Se usa para cosas como Exception Handling y GetThreadContext API. El código x64 existente espera que el contexto de CPU se represente como una estructura x64 CONTEXT o, en otras palabras, la estructura tal como se define durante la CONTEXT compilación x64.

Debe usar esta estructura para representar el contexto de CPU mientras ejecuta código x64 y código Arm64EC. El código existente no entiende un concepto nuevo, como el conjunto de registros de CPU que cambia de función a función. Si usa la estructura x64 CONTEXT para representar estados de ejecución de Arm64, mapea eficazmente registros Arm64 a registros x64.

Este mapeo también implica que no se pueden usar registros Arm64 que no se integren en x64 CONTEXT. Sus valores se pueden perder siempre que una operación use CONTEXT (y algunas operaciones pueden ser asincrónicas e inesperadas, como la operación recolección de elementos no utilizados de Managed Language Runtime o APC).

Los encabezados de Windows del SDK describen las reglas de asignación entre los registros Arm64EC y x64 con la estructura ARM64EC_NT_CONTEXT. Esta estructura es básicamente una unión de la CONTEXT estructura, exactamente como se define para x64, pero con una superposición de registro Arm64 adicional.

Por ejemplo, RCX se asigna a X0, RDX se asigna a X1, RSP se asigna a SP, RIP se asigna a PC, y así sucesivamente. Los registros x13, x14, x23, x24, x28, y v16 hasta v31 no tienen representación y, por lo tanto, no se pueden usar en Arm64EC.

Esta restricción de uso del registro es la primera diferencia entre las API de Arm64 Classic y EC.

Comprobadores de llamadas

Los comprobadores de llamadas han sido parte de Windows desde que control Flow Guard (CFG) se introdujo en Windows 8.1. Los comprobadores de llamadas son correctores de direcciones para punteros de función (antes de que estos elementos se llamaran correctores de direcciones). Cada vez que compila código con la opción /guard:cf, el compilador genera una llamada adicional a la función checker justo antes de cada llamada indirecta o salto. Windows proporciona la función de verificación. Para CFG, realiza una comprobación de validez contra los objetivos de llamada conocidos por ser buenos. Los archivos binarios compilados con /guard:cf también incluyen esta información.

En este ejemplo se muestra un uso del comprobador de llamadas en Arm64 clásico:

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

En el caso de CFG, el comprobador de llamadas simplemente retorna si el destino es válido o falla rápidamente el proceso si no lo está. Los comprobadores de llamadas tienen convenciones de llamada personalizadas. Toman el puntero de función en un registro que no es usado por la convención de llamada normal y conservan todos los registros definidos por la convención de llamada estándar. De este modo, no introducen derrames registrados alrededor de ellos.

Los comprobadores de llamadas son opcionales en todas las demás API de Windows, pero obligatorias en Arm64EC. En Arm64EC, los comprobadores de llamadas acumulan la tarea de comprobar la arquitectura de la función a la que se llama. Comprueban si la llamada es otra función EC ("Compatible con emulación") o una función x64 que se debe ejecutar bajo emulación. En muchos casos, esto solo se puede comprobar en tiempo de ejecución.

Los comprobadores de llamadas arm64EC se basan en los comprobadores de Arm64 existentes, pero tienen una convención de llamada personalizada ligeramente diferente. Toman un parámetro adicional y pueden modificar el registro que contiene la dirección de destino. Por ejemplo, si el destino es código x64, primero se debe transferir el control a la lógica de scaffolding de emulación.

En Arm64EC, el mismo uso del comprobador de llamadas se convertirá en:

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

Entre las ligeras diferencias de Arm64 clásico se incluyen las siguientes:

  • El nombre del símbolo del comprobador de llamadas es diferente.
  • La dirección de destino se proporciona en x11 en lugar de x15.
  • La dirección de destino (x11) es [in, out] en lugar de [in].
  • Hay un parámetro adicional, proporcionado a través x10de , denominado "Exit Thunk".

Exit Thunk es un funclet que transforma los parámetros de función de la convención de llamada arm64EC a la convención de llamada x64.

El comprobador de llamadas arm64EC se encuentra a través de un símbolo diferente al que se usa para las otras API en Windows. En la ABI clásica de Arm64, el símbolo del comprobador de llamadas es __guard_check_icall_fptr. Este símbolo estará presente en Arm64EC, pero está ahí para que el código vinculado estáticamente x64 use, no el propio código Arm64EC. El código Arm64EC usará __os_arm64x_check_icall o __os_arm64x_check_icall_cfg.

En Arm64EC, los comprobadores de llamadas no son opcionales. Sin embargo, CFG sigue siendo opcional, como sucede con otros ABIs. CFG puede deshabilitarse en tiempo de compilación o puede haber un motivo legítimo para no realizar una comprobación de CFG incluso cuando CFG está habilitado (por ejemplo, el puntero de función nunca reside en la memoria RW). Para una llamada indirecta con la comprobación de CFG, se debe usar el __os_arm64x_check_icall_cfg comprobador. Si CFG está deshabilitado o no es necesario, __os_arm64x_check_icall debe usarse en su lugar.

A continuación se muestra una tabla de resumen del uso del comprobador de llamadas en Arm64 clásico, x64 y Arm64EC, teniendo en cuenta que un binario arm64EC puede tener dos opciones en función de la arquitectura del código.

Binario Código Llamada indirecta desprotegida Llamada indirecta protegida por CFG
x64 x64 sin comprobador de llamadas __guard_check_icall_fptr o __guard_dispatch_icall_fptr
Arm64 Classic Arm64 sin comprobador de llamadas __guard_check_icall_fptr
Arm64EC x64 sin comprobador de llamadas __guard_check_icall_fptr o __guard_dispatch_icall_fptr
Arm64EC __os_arm64x_check_icall __os_arm64x_check_icall_cfg

Independientemente de la ABI, tener cfG habilitado código (código con referencia a los comprobadores de llamadas de CFG), no implica la protección de CFG en tiempo de ejecución. Los archivos binarios protegidos por CFG pueden ejecutarse de nivel inferior, en sistemas que no admiten CFG: el comprobador de llamadas se inicializa con un asistente sin operaciones en tiempo de compilación. Un proceso también puede tener CFG deshabilitado por configuración. Cuando CFG está deshabilitado (o la compatibilidad con el sistema operativo no está presente) en las INSTANCIAS anteriores, el sistema operativo simplemente no actualizará el comprobador de llamadas cuando se cargue el binario. En Arm64EC, si la protección de CFG está deshabilitada, el sistema operativo establecerá __os_arm64x_check_icall_cfg lo mismo __os_arm64x_check_icallque , que seguirá proporcionando la comprobación de la arquitectura de destino necesaria en todos los casos, pero no la protección de CFG.

Al igual que con CFG en Arm64 clásico, la llamada a la función de destino (x11) debe seguir inmediatamente la llamada al Comprobador de llamadas. La dirección del Comprobador de llamadas debe colocarse en un registro volátil y ni la dirección de la función de destino deben copiarse nunca en otro registro o desbordarse en la memoria.

Comprobadores de pila

__chkstk el compilador lo usa automáticamente cada vez que una función asigna un área en la pila mayor que una página. Para evitar omitir la página de protección de pila que protege el final de la pila, __chkstk se llama a para asegurarse de que se sondeen todas las páginas del área asignada.

__chkstk Normalmente se llama desde el prólogo de la función. Por ese motivo, y para una generación de código óptima, usa una convención de llamada personalizada.

Esto implica que el código x64 y el código Arm64EC necesitan sus propias funciones, distintas, __chkstk como entrada y salida thunks asumen las convenciones de llamada estándar.

x64 y Arm64EC comparten el mismo espacio de nombres de símbolo para que no pueda haber dos funciones denominadas __chkstk. Para dar cabida a la compatibilidad con código x64 existente previamente, __chkstk el nombre se asociará con el comprobador de pila x64. El código Arm64EC se usará __chkstk_arm64ec en su lugar.

La convención de llamada personalizada para es la misma que __chkstk_arm64ec para Arm64 __chkstkclásico: x15 proporciona el tamaño de la asignación en bytes, dividido por 16. Se conservan todos los registros no volátiles, así como todos los registros volátiles implicados en la convención de llamada estándar.

Todo lo que se ha dicho anteriormente sobre __chkstk se aplica igualmente a __security_check_cookie y su homólogo arm64EC: __security_check_cookie_arm64ec.

Convención de llamada variadic

Arm64EC sigue la convención de llamada de ABI clásica de Arm64, excepto las funciones variádicas (también conocidas como varargs o funciones con la palabra clave de parámetro de puntos suspensivos (. . .).

Para el caso específico variádico, Arm64EC sigue una convención de llamada muy similar a la variadic x64, con solo algunas diferencias. En la lista siguiente se muestran las reglas principales de Arm64EC variadic:

  • Solo se usan los cuatro primeros registros para pasar parámetros: x0, x1, x2, x3. Los parámetros restantes se desbordan en la pila. Esta regla sigue con precisión la convención de llamada variádica x64 y difiere de Arm64 Classic, donde se usan los registros del x0 al x7.
  • Los parámetros de punto flotante y SIMD que se pasan por registro usan un registro de propósito general, no uno SIMD. Esta regla es similar a Arm64 Classic y difiere de x64, donde los parámetros FP/SIMD se pasan tanto en un registro de uso general como en un registro SIMD. Por ejemplo, para una función f1(int, …) llamada f1(int, double), en x64, el segundo parámetro se asigna tanto a RDX como a XMM1. En Arm64EC, el segundo parámetro se asigna solo a x1.
  • Al pasar estructuras por valor a través de un registro, se aplican reglas de tamaño x64: las estructuras con tamaños exactamente 1, 2, 4 y 8 bytes se cargan directamente en el registro de uso general. Las estructuras de datos con otros tamaños se desplazan en la pila, y un puntero a la ubicación desplazada se asigna al registro. Esta regla básicamente degrada de por valor a por referencia a nivel bajo. En la ABI clásica de Arm64, las estructuras de cualquier tamaño de hasta 16 bytes se asignan directamente a los registros de uso general.
  • El x4 registro carga un puntero al primer parámetro pasado a través de la pila (el quinto parámetro). Esta regla no incluye estructuras desbordadas debido a las restricciones de tamaño descritas anteriormente.
  • El x5 registro carga el tamaño, en bytes, de todos los parámetros pasados por pila (tamaño de todos los parámetros, empezando por el quinto). Esta regla no incluye estructuras pasadas por valor desbordadas debido a las restricciones de tamaño descritas anteriormente.

En el ejemplo siguiente, pt_nova_function toma parámetros en un formato no variádico, por lo que sigue la convención de llamada clásica de Arm64. A continuación, llama pt_va_function a con los mismos parámetros exactamente, pero en una llamada variadic en su lugar.

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 toma cinco parámetros, que asigna después de las reglas de convención de llamada clásicas de Arm64:

  • 'f' es un doble. Se asigna a d0.
  • 'tc' es una estructura con un tamaño de 3 bytes. Se asigna a x0.
  • ull1 es un entero de 8 bytes. Se asigna a x1.
  • ull2 es un entero de 8 bytes. Se asigna a x2.
  • ull3 es un entero de 8 bytes. Se asigna a x3.

pt_va_function es una función variadic, por lo que sigue las reglas variadices arm64EC descritas anteriormente:

  • 'f' es un doble. Se asigna a x0.
  • 'tc' es una estructura con un tamaño de 3 bytes. Se derrama en la pila y su ubicación se carga en x1.
  • ull1 es un entero de 8 bytes. Se asigna a x2.
  • ull2 es un entero de 8 bytes. Se asigna a x3.
  • ull3 es un entero de 8 bytes. Se asigna directamente a la pila.
  • x4 carga la ubicación de ull3 en la pila.
  • x5 carga el tamaño de ull3.

En el ejemplo siguiente se muestra la posible salida de compilación para pt_nova_function, que muestra las diferencias de asignación de parámetros descritas anteriormente.

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

Adiciones de ABI

Para lograr una interoperabilidad transparente con código x64, realice muchas adiciones a la ABI clásica de Arm64. Estas adiciones controlan las diferencias de convenciones de llamada entre Arm64EC y x64.

En la lista siguiente se incluyen estas adiciones:

Entrada y salida de thunks

Los thunks de entrada y salida traducen la convención de llamadas Arm64EC (que es principalmente la misma que la clásica Arm64) a la convención de llamadas x64 y viceversa.

Una idea errónea común es que se puede convertir las convenciones de llamada siguiendo una sola regla aplicada a todas las signaturas de función. La realidad es que las convenciones de llamada tienen reglas de asignación de parámetros. Estas reglas dependen del tipo de parámetro y son diferentes de ABI a ABI. Una consecuencia es que la traducción entre las API es específica de cada firma de función, que varía con el tipo de cada parámetro.

Considere la función siguiente:

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

La asignación de parámetros se produce de la siguiente manera:

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

Ahora considere una función diferente:

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

La asignación de parámetros se produce de la siguiente manera:

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

Estos ejemplos muestran que la asignación y traducción de parámetros varían según el tipo, pero también dependen de los tipos de los parámetros anteriores de la lista. Este detalle se ilustra mediante el tercer parámetro. En ambas funciones, el tipo del parámetro es int, pero la traducción resultante es diferente.

Los thunks de entrada y salida existen por este motivo y se adaptan específicamente a cada firma de función individual.

Ambos tipos de thunks son funciones. El emulador invoca automáticamente los thunks de entrada cuando las funciones x64 llaman a funciones arm64EC (la ejecución entra en Arm64EC). Los comprobadores de llamadas invocan automáticamente los thunks de salida cuando las funciones arm64EC llaman a funciones x64 (la ejecución sale de Arm64EC).

Al compilar código Arm64EC, el compilador genera una entrada thunk para cada función Arm64EC, que coincide con su firma. El compilador también genera un thunk de salida para cada función llamada por una función Arm64EC.

Considere el ejemplo siguiente:

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

Al compilar el código anterior destinado a Arm64EC, el compilador genera:

  • Código para fA.
  • Entrada thunk para fA
  • Salir de thunk para fB
  • Salir de thunk para fC

El compilador genera la fA entry thunk en caso fA si se llama desde código x64. El compilador genera thunks de salida para fB y fC en caso de que fB y fC sean código x64.

El compilador puede generar el mismo thunk de salida varias veces porque los genera en el sitio de llamada en lugar de en la propia función. Esta duplicación puede dar lugar a una cantidad considerable de thunks redundantes. Para evitar esta duplicación, el compilador aplica reglas de optimización simples para asegurarse de que solo los thunks necesarios se incluyan en el binario final.

Por ejemplo, en un binario donde la función A Arm64EC llama a la función BArm64EC , B no se exporta y su dirección nunca se conoce fuera de A. Es seguro eliminar el "thunk" de salida de A a B, junto con el "thunk" de entrada para B. También es seguro aliasar conjuntamente todos los thunks de salidas y entradas que contienen el mismo código, incluso si se generaron para funciones distintas.

Salir de thunks

Con las funciones de ejemplo fA, fB y fC en la sección anterior, el compilador genera thunks de salida fB y fC de la siguiente manera:

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

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

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

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

En el caso de fB, la presencia de un parámetro double hace que la asignación del registro GP restante se reestructure, como resultado de las diferentes reglas de asignación de Arm64 y x64. También puede ver que x64 solo asigna cuatro parámetros a los registros, por lo que el quinto parámetro debe desbordarse en la pila.

En el fC caso, el segundo parámetro es una estructura de longitud de 3 bytes. Arm64 permite asignar cualquier estructura de tamaño directamente a un registro. x64 solo permite tamaños 1, 2, 4 y 8. Esta salida de Thunk debe transferirla struct desde el registro a la pila y asignar un puntero al registro en su lugar. Este enfoque sigue usando un registro (para llevar el puntero), por lo que no cambia las asignaciones de los registros restantes: no se produce ninguna reorganización de registros para los parámetros tercero y cuarto. Al igual que en el fB caso, el quinto parámetro debe desbordarse en la pila.

Consideraciones adicionales para Exit Thunks:

  • El compilador los nombra no por el nombre de la función de la cual traducen y a la cual traducen, sino más bien por la firma que abordan. Esta convención de nomenclatura facilita la búsqueda de redundancias.
  • El comprobador de llamadas configura el registro x9 para contener la dirección de la función de destino (x64). El Exit Thunk llama al emulador, pasando x9 sin cambios.

Después de reorganizar los parámetros, Exit Thunk llama al emulador a través de __os_arm64x_dispatch_call_no_redirect.

En este momento, vale la pena revisar la función del comprobador de llamadas y su ABI personalizada. Este es el aspecto de una llamada indirecta a fB :

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

Al llamar al comprobador de llamadas:

  • x11 proporciona la dirección de la función de destino a la que se va a llamar (fB en este caso). En este momento, es posible que el comprobador de llamadas no sepa si la función de destino es Arm64EC o x64.
  • x10 proporciona un elemento Exit Thunk que coincide con la firma de la función a la que se llama (fB en este caso).

Los datos que devuelve el comprobador de llamadas dependen de si la función de destino es Arm64EC o x64.

Si el destino es Arm64EC:

  • x11 devuelve la dirección del código Arm64EC al que se va a llamar. Este valor puede ser el mismo que el proporcionado en.

Si el destino es código x64:

  • x11 devuelve la dirección de Exit Thunk. Esta dirección se copia de la entrada proporcionada en x10.
  • x10 devuelve la dirección de Exit Thunk, sin cambios desde la entrada.
  • x9 devuelve la función x64 de destino. Este valor puede ser el mismo que el proporcionado a través de x11.

Los comprobadores de llamadas siempre dejan inalterados los registros de parámetros de la convención de llamadas. El código de llamada debe seguir inmediatamente la llamada al comprobador de llamadas con blr x11 (o br x11 en caso de una llamada final). Los comprobadores de llamadas siempre conservan estos registros por encima y más allá de los registros no volátiles estándar: x0-x8, x15(chkstk) y .q0-q7

Entrada Thunks

La entrada Thunks se encarga de las transformaciones necesarias de x64 a las convenciones de llamada de Arm64. Esta transformación es básicamente la inversa de Exit Thunks, pero implica algunos aspectos más que tener en cuenta.

Considere el ejemplo anterior de compilación fAde . Se genera un Entry Thunk para que el código x64 pueda llamar a fA.

Entrada Thunk para 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

El emulador proporciona la dirección de la función de destino en x9.

Antes de llamar a Entry Thunk, el emulador x64 muestra la dirección de retorno de la pila en el LR registro. LR A continuación, se espera que apunte al código x64 cuando el control se transfiere a Entry Thunk.

El emulador también puede realizar otro ajuste en la pila, dependiendo de lo siguiente: Tanto Arm64 como x64 ABIs definen un requisito de alineación de pila donde la pila debe alinearse a 16 bytes en el punto en que se llama a una función. Al ejecutar código Arm64, el hardware aplica esta regla, pero no hay ninguna aplicación de hardware para x64. Al ejecutar código x64, llamar erróneamente a funciones con una pila no alineada puede pasar desapercibida indefinidamente, hasta que se usa alguna instrucción de alineación de 16 bytes (algunas instrucciones SSE sí) o se llama al código Arm64EC.

Para solucionar este posible problema de compatibilidad, antes de llamar a Entry Thunk, el emulador siempre alinea el puntero de pila a 16 bytes y almacena su valor original en el x4 registro. De este modo, Entry Thunks siempre empieza a ejecutarse con una pila alineada, pero todavía puede hacer referencia correctamente a los parámetros pasados en la pila a través de x4.

Cuando se trata de registros SIMD no volátiles, hay una diferencia significativa entre las convenciones de llamada Arm64 y x64. En Arm64, los 8 bytes bajos (64 bits) del registro se consideran no volátiles. Es decir, solo la Dn parte de los Qn registros no es volátil. En x64, los 16 bytes completos del XMMn registro se consideran no volátiles. Además, en x64 XMM6 y XMM7 son registros no volátiles, mientras que D6 y D7 (los registros Arm64 correspondientes) son volátiles.

Para abordar estas asimetrías de manipulación de registros SIMD, Entry Thunks debe guardar explícitamente todos los registros SIMD que se consideran no volátiles en x64. Este ahorro solo es necesario en Entry Thunks (no Exit Thunks) porque x64 es más estricto que Arm64. En otras palabras, las reglas de guardado y preservación de registros en x64 superan los requisitos de Arm64 en todos los casos.

Para solucionar la correcta recuperación de estos valores de registro al desempaquetar la pila (por ejemplo, setjmp + longjmp o throw + catch), se introdujo un nuevo opcode de desempaquetado: save_any_reg (0xE7). Este nuevo código operativo de desenredado de 3 bytes permite guardar cualquier registro de uso general o SIMD (incluidos los que se consideran volátiles) e incluir registros de tamaño Qn completo. Este nuevo código de operación se usa para las operaciones de desbordamiento y relleno del Qn registro. save_any_reg es compatible con save_next_pair (0xE6).

Para referencia, la siguiente información de desenrollado pertenece al Entry Thunk mencionado anteriormente.

   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)

Después de que se devuelva la función Arm64EC, la __os_arm64x_dispatch_ret rutina re-ingresa en el emulador, de vuelta al código x64 (apuntado por LR).

Las funciones arm64EC reservan los cuatro bytes antes de la primera instrucción de la función para almacenar información que se va a usar en tiempo de ejecución. En estos cuatro bytes, se puede encontrar la dirección relativa de Entry Thunk para la función. Al realizar una llamada desde una función x64 a una función Arm64EC, el emulador lee los cuatro bytes antes del inicio de la función, enmascara los dos bits inferiores y agrega esa cantidad a la dirección de la función. Este proceso genera la dirección del Entry Thunk para la llamada.

Adjustor Thunks

Adjustor Thunks son funciones sin signatura que transfieren el control a otra función (llamada de cola). Antes de transferir el control, transforman uno de los parámetros. Se conoce el tipo de los parámetros que se transforman, pero todos los parámetros restantes pueden ser cualquier cosa y pueden estar en cualquier número. El ajustador Thunks no toca ningún registro que pueda contener un parámetro y no toca la pila. Esta característica convierte a Adjustor Thunks en funciones sin firma.

El compilador puede generar automáticamente Adjustor Thunks. Esta generación es común, por ejemplo, con la herencia múltiple de C++, donde cualquier método virtual puede delegar a la clase base sin modificaciones, excepto por un ajuste del puntero this.

En el ejemplo siguiente se muestra un escenario real:

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

El thunk resta 8 bytes al this puntero y reenvía la llamada a la clase primaria.

En resumen, las funciones arm64EC que se pueden llamar desde funciones x64 deben tener asociado Entry Thunk. Entry Thunk es específico de la firma. Las funciones sin firma arm64, como Adjustor Thunks, necesitan un mecanismo diferente que pueda controlar funciones sin firma.

El elemento Entry Thunk de un Adjustor Thunk usa el __os_arm64x_x64_jump asistente para aplazar la ejecución del trabajo real entry Thunk (ajuste los parámetros de una convención a la otra) a la siguiente llamada. Es en este momento que la firma se vuelve evidente. Esto incluye la opción de no realizar ajustes de convención de llamada en absoluto, si el destino del Adjustor Thunk resulta ser una función x64. Recuerde que a la hora en que se inicia la ejecución de Entry Thunk, los parámetros están en su formulario x64.

En el ejemplo anterior, considere el aspecto del código en Arm64EC.

Adjustor Thunk en 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

Tronco de entrada de Thunk de Adjustor

[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

Secuencias de avance rápido

Algunas aplicaciones realizan modificaciones en tiempo de ejecución en funciones que residen en archivos binarios que no poseen, pero dependen de los archivos binarios del sistema operativo normalmente, con el fin de desviar la ejecución cuando se llama a la función. Este proceso también se conoce como enganche.

En un nivel alto, el proceso de enlace es sencillo. Sin embargo, en detalle, el enlace es específico de la arquitectura y es bastante complejo, dadas las posibles variaciones que debe abordar la lógica de enlace.

En términos generales, el proceso implica los siguientes pasos:

  • Determine la dirección de la función que se va a enlazar.
  • Reemplace la primera instrucción de la función por un salto a la rutina de enlace.
  • Cuando haya terminado el enlace, vuelva a la lógica original, que incluye ejecutar la instrucción original desplazada.

Las variaciones surgen de cosas como:

  • El tamaño de la primera instrucción: es una buena idea reemplazarlo por un JMP que sea el mismo tamaño o menor, para evitar reemplazar la parte superior de la función, mientras que otro subproceso podría estar ejecutándolo en curso.
  • El tipo de la primera instrucción: si la primera instrucción tiene cierta naturaleza relativa al pc, la reubicación podría requerir cambios como los campos de desplazamiento. Dado que pueden desbordarse cuando una instrucción se mueve a un lugar lejano, este cambio podría requerir proporcionar lógica equivalente con instrucciones diferentes por completo.

Debido a toda esta complejidad, la lógica de enlace sólida y genérica es poco frecuente encontrar. Con frecuencia, la lógica presente en las aplicaciones solo puede hacer frente a un conjunto limitado de casos que la aplicación espera encontrar en las API específicas en las que está interesado. Es fácil imaginar lo grave que es este problema de compatibilidad de una aplicación. Incluso un cambio sencillo en el código o las optimizaciones del compilador podría hacer que las aplicaciones no se puedan usar si el código ya no tiene un aspecto exacto como se esperaba.

¿Qué pasaría con estas aplicaciones si encontraran código Arm64 al configurar un enlace? Seguramente fallarían.

Las funciones de secuencia de avance rápido (FFS) abordan este requisito de compatibilidad en Arm64EC.

FFS son funciones x64 muy pequeñas que no contienen lógica real ni llamada de cola a la función arm64EC real. Son opcionales, pero están habilitados de forma predeterminada para todas las exportaciones dll y para cualquier función decorada con __declspec(hybrid_patchable).

En estos casos, cuando el código obtiene un puntero a una función determinada, ya sea en GetProcAddress el caso de exportación o por &function en el __declspec(hybrid_patchable) caso, la dirección resultante contiene código x64. Ese código x64 se considera como una función x64 legítima, satisfaciendo la mayoría de la lógica de enlace actualmente disponible.

Considere el ejemplo siguiente (se omite el control de errores para mayor brevedad):

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

El valor del puntero de función en la variable pgma contiene la dirección de FFS de GetMachineTypeAttributes.

En este ejemplo se muestra una secuencia de 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 función FFS x64 tiene un prólogo y un epílogo canónicos que terminan con una llamada final (salto) a la función real GetMachineTypeAttributes en el código 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
                           [...]

Sería bastante ineficaz si fuera necesario ejecutar cinco instrucciones x64 emuladas entre dos funciones Arm64EC. Las funciones FFS son especiales. Las funciones FFS no se ejecutan realmente si permanecen inalteradas. El asistente del comprobador de llamadas comprueba eficazmente si no se ha cambiado el FFS. Si es así, la llamada se transfiere directamente al destino real. Si el FFS se modifica de cualquier manera posible, dejaría de ser un FFS. La ejecución transfiere al FFS modificado y ejecuta el código que pueda haber, emulando el desvío y cualquier lógica de enlace.

Cuando el enlace transfiere la ejecución al final del FFS, finalmente llega a la llamada final al código Arm64EC, que luego se ejecuta después del enlace, tal como espera la aplicación.

Desarrollo de Arm64EC en ensamblador

Los encabezados de Windows SDK y el compilador de C simplifican el trabajo de creación del ensamblado Arm64EC. Por ejemplo, puede utilizar el compilador de C para generar thunks de entrada y salida para funciones que no están compiladas a partir de código C.

Considere el ejemplo de una función equivalente a la siguiente función fD que debe crear en ensamblador (ASM). Tanto el código Arm64EC como el código x64 pueden llamar a esta función, y el puntero de función pfE puede apuntar al código Arm64EC o al código x64.

typedef int (PF_E)(int, double);

extern PF_E * pfE;

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

Escribir fD en ASM podría tener un aspecto similar al código siguiente:

#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

En el ejemplo anterior:

  • Arm64EC usa la misma declaración de procedimiento y macros de prólogo/epílog que Arm64.
  • Envuelva los nombres de función con el A64NAME macro. Al compilar código de C o C++ como Arm64EC, el compilador marca OBJ como ARM64EC que contiene código Arm64EC. Este señalización no ocurre con ARMASM. Al compilar código ASM, puede informar al enlazador de que el código generado es Arm64EC mediante el prefijo del nombre de la función con #. La A64NAME macro realiza esta operación cuando _ARM64EC_ se define y deja el nombre sin cambios cuando _ARM64EC_ no se define. Este enfoque permite compartir código fuente entre Arm64 y Arm64EC.
  • Primero debe ejecutar el puntero de función pfE a través del verificador de llamadas EC, junto con el thunk de salida correspondiente, cuando la función de destino sea x64.

Generación de thunks de entrada y de salida

El siguiente paso es generar el "entry thunk" para fD y el "exit thunk" para pfE. El compilador de C puede realizar esta tarea con un esfuerzo mínimo mediante la _Arm64XGenerateThunk palabra clave del compilador.

void _Arm64XGenerateThunk(int);

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

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

La _Arm64XGenerateThunk palabra clave indica al compilador de C que use la firma de la función, omita el cuerpo y genere un thunk de salida (cuando el parámetro es 1) o un thunk de entrada (cuando el parámetro es 2).

Coloque thunk generation en su propio archivo C. Estar en archivos aislados facilita la confirmación de los nombres de símbolos al volcar los símbolos correspondientes OBJ o incluso desensamblar.

Thunks de entrada personalizada

El SDK incluye macros que le ayudan a crear thunks de entrada codificados a mano y personalizados. Puede usar estas macros al crear matones de ajuste personalizados.

El compilador de C++ genera la mayoría de los thunks del ajustador, pero también puede generarlos manualmente. Puede generar manualmente un "thunk" de ajuste cuando una devolución de llamada genérica transfiere el control a la devolución de llamada real y uno de los parámetros identifica a la devolución de llamada real.

En el ejemplo siguiente se muestra un adjustor thunk en el código 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

En este ejemplo, el primer parámetro proporciona una referencia a una estructura. El código recupera la dirección de la función de destino de un elemento de esta estructura. Dado que la estructura es escribible, la Protección de Flujo de Control (CFG) debe validar la dirección de destino.

En el ejemplo siguiente se muestra cómo portar el ajustador equivalente thunk a 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

El código anterior no proporciona un exit thunk (en el registro x10). Este enfoque no es posible porque el código se puede ejecutar para muchas firmas diferentes. Este código aprovecha que el llamador establece x10 para el thunk de salida. El llamador realiza la llamada con el objetivo de una firma explícita.

El código anterior necesita un thunk de entrada para abordar el caso cuando el código que llama es x64. En el ejemplo siguiente se muestra cómo crear la correspondiente entrada thunk mediante la macro para entradas thunk personalizadas.

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

A diferencia de otras funciones, esta entry thunk no llega a transferir el control a la función asociada (el adjustor thunk). En este caso, la entrada thunk incrusta la funcionalidad en sí misma (realizando el ajuste del parámetro) y transfiere el control directamente al destino final a través de la función auxiliar __os_arm64x_x64_jump.

Generación dinámica de código Arm64EC (compilación JIT)

En los procesos arm64EC, existen dos tipos de memoria ejecutable: código Arm64EC y código x64.

El sistema operativo extrae esta información de los archivos binarios cargados. Los binarios x64 son enteramente x64, y los binarios Arm64EC contienen una tabla de intervalos para la relación entre las páginas de código Arm64EC y x64.

¿Qué ocurre con el código generado dinámicamente? Los compiladores Just-In-Time (JIT) generan código en tiempo de ejecución que no está respaldado por ningún archivo binario.

Normalmente, este proceso implica los pasos siguientes:

  • Asignar memoria grabable (VirtualAlloc).
  • Generar el código en la memoria asignada.
  • Reproteger la memoria de lectura-escritura a lectura-ejecución (VirtualProtect).
  • Agregar entradas de función de desenredado para todas las funciones generadas no triviales (no hoja) (RtlAddFunctionTable o RtlAddGrowableFunctionTable).

Por motivos de compatibilidad triviales, si una aplicación realiza estos pasos en un proceso arm64EC, el sistema operativo considera el código como código x64. Este comportamiento se produce para cualquier proceso que use el runtime de Java x64 sin modificar, el entorno de ejecución de .NET, el motor de JavaScript, etc.

Para generar código dinámico arm64EC, siga el mismo proceso con dos diferencias:

  • Al asignar la memoria, use la más reciente VirtualAlloc2 (en lugar de VirtualAlloc o VirtualAllocEx) y proporcione el MEM_EXTENDED_PARAMETER_EC_CODE atributo .
  • Al agregar entradas de función:
    • Deben estar en formato Arm64. Al compilar código Arm64EC, el RUNTIME_FUNCTION tipo coincide con el formato x64. Para el formato Arm64 al compilar Arm64EC, use el ARM64_RUNTIME_FUNCTION tipo en su lugar.
    • No use la API anterior RtlAddFunctionTable . Use siempre la API más reciente RtlAddGrowableFunctionTable .

En el ejemplo siguiente se muestra la asignación de memoria:

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

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

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

En el ejemplo siguiente se muestra cómo agregar una entrada de función de desenredado:

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