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.

El propósito de este documento es proporcionar 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/ensamblador y la escritura de código de ensamblado destinado a la ABI de Arm64EC.

Diseño de Arm64EC

Arm64EC se diseñó para ofrecer 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. Se cambió muy poco de la ABI clásica, pero se agregaron partes para habilitar la interoperabilidad x64.

En este documento, la ABI original y estándar arm64 se denominará "ABI clásica". Esto evita la ambigüedad inherente a los términos sobrecargados, como "Nativo". Arm64EC, para ser claro, es cada bit tan nativo como la ABI original.

ABI clásica de Arm64EC frente a Arm64

En la lista siguiente se indica dónde Arm64EC ha divergido de la ABI clásica de Arm64.

Estos son pequeños cambios cuando se ven en perspectiva de la cantidad que define toda la ABI.

Registrar la asignación y los registros bloqueados

Para que haya 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.

Esta estructura debe usarse para representar el contexto de CPU mientras se ejecuta código x64, así como el código Arm64EC. El código existente no entendería un concepto nuevo, como el conjunto de registros de CPU que cambia de función a función. Si la estructura x64 CONTEXT se usa para representar estados de ejecución de Arm64, esto implica que los registros arm64 se asignan eficazmente a registros x64.

También implica que no se deben usar los registros arm64 que no se pueden instalar en x64 CONTEXT , ya que sus valores se pueden perder en cualquier momento en que se produzca una operación ( CONTEXT y algunas pueden ser asincrónicas e inesperadas, como la operación de recolección de elementos no utilizados de managed Language Runtime o un APC).

Las reglas de asignación entre los registros Arm64EC y x64 se representan mediante la ARM64EC_NT_CONTEXT estructura de los encabezados de Windows, presentes en el SDK. 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 a X1SPRIP , RSP a , a PC, etc. También podemos ver cómo los registros , , , , ,v31-x28v16 no tienen representación y, por tanto, no se pueden usar en Arm64EC. x24x23x14x13

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 el código se compila con la opción /guard:cf que el compilador generará una llamada adicional a la función checker justo antes de cada llamada o salto indirectos. Windows proporciona la propia función checker y, para CFG, realiza una comprobación de validez con los destinos de llamada conocidos para ser buenos. Esta información también se incluye en archivos binarios compilados con /guard:cf.

Este es un ejemplo de 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 devolverá si el destino es válido o producirá un error rápido en el proceso si no lo es. Los comprobadores de llamadas tienen convenciones de llamada personalizadas. Toman el puntero de función en un registro no utilizado por la convención de llamada normal y conservan todos los registros normales de convención de llamada. 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 variadices (también conocidas como varargs, también funciones con los puntos suspensivos (. . .) palabra clave de parámetro).

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

  • Solo se usan los primeros 4 registros para pasar parámetros: x0, x1, x2, x3. Los parámetros restantes se derraman en la pila. Esto sigue exactamente la convención de llamada variadic x64 y difiere de Arm64 Classic, donde se usan registrosx0>x7.
  • Los parámetros de punto flotante o SIMD que se pasan mediante el registro usarán un registro de uso general, no un SIMD. Esto es similar a Arm64 Classic y difiere de x64, donde los parámetros FP/SIMD se pasan en un registro de uso general y SIMD. Por ejemplo, para una función f1(int, …) a la que se llama como f1(int, double), en x64, el segundo parámetro se asignará a y RDXXMM1. En Arm64EC, el segundo parámetro se asignará 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 se cargarán directamente en el registro de uso general. Las estructuras con otros tamaños se derraman en la pila y se asigna un puntero a la ubicación desbordada al registro. Esto básicamente desmota por valor en por referencia, en el nivel bajo. En la ABI de Arm64 clásica, las estructuras de cualquier tamaño de hasta 16 bytes se asignan directamente a los registros de uso general.
  • El registro X4 se carga con un puntero al primer parámetro pasado a través de la pila (el 5º parámetro). Esto no incluye estructuras desbordadas debido a las restricciones de tamaño descritas anteriormente.
  • El registro X5 se carga con el tamaño, en bytes, de todos los parámetros pasados por pila (tamaño de todos los parámetros, empezando por la 5ª). Esto no incluye estructuras pasadas por valor desbordado debido a las restricciones de tamaño descritas anteriormente.

En el ejemplo siguiente: pt_nova_function a continuación se toman parámetros en forma no variádica, por lo tanto, siguiendo 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 5 parámetros que se asignarán siguiendo las reglas de convención de llamada clásicas de Arm64:

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

pt_va_function es una función variadic, por lo que seguirá las reglas variádicas arm64EC descritas anteriormente:

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

A continuación se muestra una 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, se han realizado muchas adiciones a la ABI clásica de Arm64. Controlan las diferencias de convenciones de llamada entre Arm64EC y x64.

En la lista siguiente se incluyen estas adiciones:

Entrada y salida de Thunks

Entrada y salida de Thunks se encargan de traducir la convención de llamada arm64EC (principalmente la misma que arm64 clásica) a la convención de llamada x64, y viceversa.

Una idea errónea común es que las convenciones de llamada se pueden convertir siguiendo una única regla aplicada a todas las firmas 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 será 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 producirá 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 producirá 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 la traducción de parámetros varían según el tipo, pero también los tipos de los parámetros anteriores de la lista dependen de ellos. 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, por sí mismos, funciones. El emulador invoca automáticamente los Thunks de entrada cuando las funciones x64 llaman a las funciones arm64EC (la ejecución entra en Arm64EC). Los comprobadores de llamadas invocan automáticamente Thunks cuando las funciones arm64EC llaman a funciones x64 (la ejecución sale de Arm64EC).

Al compilar código Arm64EC, el compilador generará una entrada Thunk para cada función Arm64EC, que coincide con su firma. El compilador también generará una salida de Thunk para cada función a la que llama 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 generará:

  • Código para 'fA'.
  • Entrada Thunk para 'fA'
  • Salir de Thunk para "fB"
  • Salir de Thunk para 'fC'

La fA entrada Thunk se genera en caso fA de que se llame desde código x64. Salga de Thunks para fB y fC se generen en el caso fB o fC y se conviertan en código x64.

La misma salida de Thunk se puede generar varias veces, dado que el compilador los generará en el sitio de llamada en lugar de la propia función. Esto puede dar lugar a una cantidad considerable de matones redundantes, por lo que, en realidad, el compilador aplicará reglas de optimización triviales para asegurarse de que solo los matones necesarios lo convierten 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 la salida de Thunk de A a B, junto con la entrada Thunk para B. También es seguro establecer alias entre todos los thunks Exit y Entry que contienen el mismo código, incluso si se generaron para funciones distintas.

Salir de Thunks

Con las funciones fAde ejemplo , fB y fC versiones posteriores, este es el modo en que el compilador generaría y fBfC Exit Thunks:

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 fB caso, podemos ver cómo la presencia de un parámetro "double" hará que la asignación de registro de GP restante se vuelva a reorganizar, un resultado de las distintas reglas de asignación de Arm64 y x64. También podemos ver que x64 solo asigna 4 parámetros a los registros, por lo que el 5º parámetro debe desbordarse en la pila.

En el fC caso, el segundo parámetro es una estructura de longitud de 3 bytes. Arm64 permitirá asignar cualquier estructura de tamaño a un registro directamente. 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. Esto sigue usando un registro (para llevar el puntero) para que no cambie las asignaciones de los registros restantes: no se produce ninguna reorganización de registros para el parámetro 3 y 4. Al igual que en el fB caso, el 5º parámetro debe desbordarse en la pila.

Consideraciones adicionales para Exit Thunks:

  • El compilador los denominará no por el nombre de la función que traduce de> a, sino la firma que direccionan. Esto facilita la búsqueda de redundancias.
  • Se llama a Exit Thunk con el registro x9 que lleva la dirección de la función de destino (x64). Esto se establece mediante el comprobador de llamadas y pasa a través de Exit Thunk, undisturbed, en el emulador.

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

Vale la pena, en este momento, revisar la función del comprobador de llamadas y detalles sobre su propia ABI personalizada. Esto es lo que tendría 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). Es posible que no se sepa, en este momento, 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 devueltos por el comprobador de llamadas dependerán de la función de destino que sea Arm64EC o x64.

Si el destino es Arm64EC:

  • x11 devolverá la dirección del código Arm64EC al que se va a llamar. Esto puede ser o no el mismo valor en el que se proporcionó.

Si el destino es código x64:

  • x11 devolverá la dirección de Exit Thunk. Esto se copia de la entrada proporcionada en x10.
  • x10 devolverá la dirección de Exit Thunk, sin desturbar de la entrada.
  • x9 devolverá la función x64 de destino. Esto puede ser o no el mismo valor que se proporcionó en a través x11de .

Los comprobadores de llamadas siempre dejarán que los parámetros de convención de llamada se registren indisturbibles, por lo que el código de llamada debe seguir la llamada al comprobador de llamadas inmediatamente con blr x11 (o br x11 en caso de una llamada final). Estos son los comprobadores de llamadas de registros. Siempre se conservarán más allá de los registros estándar no volátiles: 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. Esto es, básicamente, la inversa de Exit Thunks, pero hay algunos aspectos más a tener en cuenta.

Considere el ejemplo anterior de compilación fA, se genera un elemento Entry Thunk para que fA el código x64 pueda llamar a este.

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 mostrará la dirección de retorno de la pila en el LR registro. A continuación, se espera que LR apunte al código x64 cuando el control se transfiera 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 con 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 alineará el puntero de pila a 16 bytes y almacenará 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. Esto solo es necesario en Entry Thunks (no Exit Thunks) porque x64 es más estricto que Arm64. En otras palabras, el registro de reglas de guardado y conservación en x64 supera los requisitos de Arm64 en todos los casos.

Para solucionar la recuperación correcta de estos valores de registro al desenredar la pila (por ejemplo, setjmp + longjmp o iniciar + catch), se introdujo un nuevo código de operación de desenredado: 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 Qn desbordamiento o relleno del registro anteriores. save_any_reg es compatible con save_next_pair (0xE6).

Por referencia, a continuación se muestra la información correspondiente de desenredado que pertenece a la entrada Thunk presentada 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)

Una vez que se devuelve la función Arm64EC, la __os_arm64x_dispatch_ret rutina se usa para volver a escribir el emulador, de vuelta al código x64 (al que apunta LR).

Las funciones Arm64EC tienen los 4 bytes antes de la primera instrucción de la función reservada para almacenar información que se va a usar en tiempo de ejecución. En estos 4 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 leerá los 4 bytes antes del inicio de la función, enmascarar los dos bits inferiores y agregará esa cantidad a la dirección de la función. Esto generará la dirección de la entrada Thunk a la que se va a llamar.

Adjustor Thunks

Adjustor Thunks son funciones sin firma que simplemente transfieren el control a otra función (llamada final) después de realizar alguna transformación en uno de los parámetros. Se conoce el tipo de parámetros que se transforman, pero todos los parámetros restantes pueden ser cualquier cosa y, en cualquier número: Adjustor Thunks no tocará ningún registro que pueda contener un parámetro y no tocará la pila. Esto es lo que hace que las funciones sin firma de Adjustor Thunks.

El compilador puede generar automáticamente el ajustador Thunks. Esto es común, por ejemplo, con la herencia múltiple de C++, donde cualquier método virtual se puede delegar a la clase primaria, sin modificar, aparte de un ajuste al this puntero.

A continuación se muestra un ejemplo 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, sino que dependen de , normalmente binarios del sistema operativo, con el fin de desviar la ejecución cuando se llama a la función. Esto también se conoce como enlace.

En el alto nivel, 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 lo siguiente:

  • 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 instrucción 1ª: 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 puede ejecutarlo en curso.
  • El tipo de la primera instrucción: si la primera instrucción tiene cierta naturaleza relativa al pc, la reubicación puede requerir cambios como los campos de desplazamiento. Dado que es probable que se desbordan cuando una instrucción se mueve a un lugar lejano, esto puede 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. No es difícil imaginar la cantidad de problemas de compatibilidad de una aplicación. Incluso un cambio sencillo en el código o las optimizaciones del compilador puede 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 por GetProcAddress en el caso de exportación o por &function en el __declspec(hybrid_patchable) caso, la dirección resultante contendrá código x64. Ese código x64 pasará para una función x64 legítima, que satisface la mayoría de la lógica de enlace disponible actualmente.

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 de la pgma variable contendrá la dirección del GetMachineTypeAttributesFFS.

Este es un ejemplo de una secuencia de avance rápido:

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 5 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 comprobará eficazmente si no se ha cambiado el FFS. Si es así, la llamada se transferirá directamente al destino real. Si el FFS se ha cambiado de cualquier manera posible, ya no será un FFS. La ejecución se transferirá al FFS modificado y ejecutará 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 llegará a la llamada final al código Arm64EC, que se ejecutará después del enlace, igual que la aplicación que espera.

Creación de Arm64EC en ensamblado

Los encabezados de Windows SDK y el compilador de C pueden simplificar el trabajo de creación del ensamblado Arm64EC. Por ejemplo, el compilador de C se puede usar para generar Thunks de entrada y salida para funciones que no se compilan a partir del código C.

Considere el ejemplo de un equivalente a la siguiente función fD que se debe crear en Assembly (ASM). El código Arm64EC y x64 pueden llamar a esta función y el pfE puntero de función también puede apuntar a código Arm64EC o 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 tendría un aspecto similar al 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.
  • La macro debe encapsular A64NAME los nombres de función. Al compilar código de C/C++ como Arm64EC, el compilador marca OBJ como ARM64EC que contiene el código Arm64EC. Esto no sucede con ARMASM. Al compilar código ASM hay una manera alternativa de informar al enlazador de que el código generado es Arm64EC. Esto es 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. Esto permite compartir código fuente entre Arm64 y Arm64EC.
  • El pfE puntero de función debe ejecutarse primero a través del comprobador de llamadas EC, junto con el elemento Exit Thunk adecuado, en caso de que la función de destino sea x64.

Generar entradas y salir de Thunks

El siguiente paso es generar el elemento Entry Thunk para fD y 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 exit Thunk (cuando el parámetro es 1) o entry Thunk (cuando el parámetro es 2).

Se recomienda colocar 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.

Entrada personalizada Thunks

Las macros se han agregado al SDK para ayudar a crear thunks personalizados, codificados a mano. Un caso en el que se puede usar es al crear custom Adjustor Thunks.

El compilador de C++ genera la mayoría de los Thunks de Adjustor, pero también se pueden generar manualmente. Esto se puede encontrar en los casos en los que una devolución de llamada genérica transfiere el control a la devolución de llamada real, identificada por uno de los parámetros.

A continuación se muestra un ejemplo en el código clásico de Arm64:

    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, la dirección de la función de destino se recupera del elemento de una estructura, proporcionada por referencia, a través del parámetro 1. Dado que la estructura es grabable, la dirección de destino debe validarse a través de Control Flow Guard (CFG).

En el ejemplo siguiente se muestra cómo se vería el valor equivalente de Adjustor Thunk al migrarse 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 exit Thunk (en el registro x10). Esto no es posible, ya que el código se puede ejecutar para muchas firmas diferentes. Este código aprovecha el llamador que ha establecido x10 en Exit Thunk. El autor de la llamada habría realizado la llamada destinada a una firma explícita.

El código anterior necesita un elemento Entry Thunk para abordar el caso cuando el autor de la llamada es código x64. Así es como crear la entrada thunk correspondiente, con la macro de Entry Thunks personalizada:

    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 Entrada Thunk no transfiere finalmente el control a la función asociada (el Ajustador Thunk). En este caso, la propia funcionalidad (realizando el ajuste del parámetro) se inserta en Entry Thunk y el control se transfiere directamente al destino final, a través del __os_arm64x_x64_jump asistente.

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

En los procesos arm64EC hay 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 archivos binarios x64 son x64 y Arm64EC contienen una tabla de rangos para páginas de códigos Arm64EC frente a 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 esto implica:

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

Por motivos de compatibilidad triviales, cualquier aplicación que realice estos pasos en un proceso arm64EC dará lugar a que el código se considere código x64. Esto ocurrirá para cualquier proceso que use el entorno de ejecución de Java x64 sin modificar, el entorno de ejecución de .NET, el motor de JavaScript, etc.

Para generar código dinámico Arm64EC, el proceso es principalmente el mismo con solo dos diferencias:

  • Al asignar la memoria, use 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 coincidirá 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 su lugar.

A continuación se muestra un ejemplo de 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);

Y un ejemplo de 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)
);