Nota:
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
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.
- Registrar la asignación y los registros bloqueados
- Comprobadores de llamadas
- Comprobadores de pila
- Convención de llamada variadic
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
x11en lugar dex15. - 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 delx0alx7. - 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, …)llamadaf1(int, double), en x64, el segundo parámetro se asigna tanto aRDXcomo aXMM1. En Arm64EC, el segundo parámetro se asigna solo ax1. - 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
x4registro 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
x5registro 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. -
ull1es un entero de 8 bytes. Se asigna ax1. -
ull2es un entero de 8 bytes. Se asigna ax2. -
ull3es un entero de 8 bytes. Se asigna ax3.
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. -
ull1es un entero de 8 bytes. Se asigna ax2. -
ull2es un entero de 8 bytes. Se asigna ax3. -
ull3es un entero de 8 bytes. Se asigna directamente a la pila. -
x4carga la ubicación deull3en la pila. -
x5carga el tamaño deull3.
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
- Salir de Thunks
- Entrada Thunks
- Ajustadores Thunks
- Secuencias de avance rápido
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
x9para contener la dirección de la función de destino (x64). El Exit Thunk llama al emulador, pasandox9sin 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:
-
x11proporciona la dirección de la función de destino a la que se va a llamar (fBen este caso). En este momento, es posible que el comprobador de llamadas no sepa si la función de destino es Arm64EC o x64. -
x10proporciona un elemento Exit Thunk que coincide con la firma de la función a la que se llama (fBen 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:
-
x11devuelve 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:
-
x11devuelve la dirección de Exit Thunk. Esta dirección se copia de la entrada proporcionada enx10. -
x10devuelve la dirección de Exit Thunk, sin cambios desde la entrada. -
x9devuelve la función x64 de destino. Este valor puede ser el mismo que el proporcionado a través dex11.
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
A64NAMEmacro. Al compilar código de C o C++ como Arm64EC, el compilador marcaOBJcomoARM64ECque contiene código Arm64EC. Este señalización no ocurre conARMASM. 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#. LaA64NAMEmacro 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
pfEa 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) (
RtlAddFunctionTableoRtlAddGrowableFunctionTable).
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 deVirtualAllocoVirtualAllocEx) y proporcione elMEM_EXTENDED_PARAMETER_EC_CODEatributo . - Al agregar entradas de función:
- Deben estar en formato Arm64. Al compilar código Arm64EC, el
RUNTIME_FUNCTIONtipo coincide con el formato x64. Para el formato Arm64 al compilar Arm64EC, use elARM64_RUNTIME_FUNCTIONtipo en su lugar. - No use la API anterior
RtlAddFunctionTable. Use siempre la API más recienteRtlAddGrowableFunctionTable.
- Deben estar en formato Arm64. Al compilar código Arm64EC, el
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)
);