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.
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.
En la lista siguiente se indica dónde Arm64EC ha divergido 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
Estos son pequeños cambios cuando se ven en perspectiva de la cantidad que define toda la ABI.
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 X1
SP
RIP
, RSP
a , a PC
, etc. También podemos ver cómo los registros , , , , ,v31
-x28
v16
no tienen representación y, por tanto, no se pueden usar en Arm64EC. x24
x23
x14
x13
Esta restricción de uso del registro es la primera diferencia entre las API de Arm64 Classic y EC.
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 dex15
. - La dirección de destino (
x11
) es[in, out]
en lugar de[in]
. - Hay un parámetro adicional, proporcionado a través
x10
de , 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_icall
que , 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.
__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 __chkstk
clá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
.
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 comof1(int, double)
, en x64, el segundo parámetro se asignará a yRDX
XMM1
. En Arm64EC, el segundo parámetro se asignará 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 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
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
- Salir de Thunks
- Entrada Thunks
- Adjustor Thunks
- Secuencias de avance rápido
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 B
Arm64EC , 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.
Con las funciones fA
de ejemplo , fB
y fC
versiones posteriores, este es el modo en que el compilador generaría y fB
fC
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_redirect
de .
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 enx10
.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ésx11
de .
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
.
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 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
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 GetMachineTypeAttributes
FFS.
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.
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 marcaOBJ
comoARM64EC
que contiene el código Arm64EC. Esto no sucede conARMASM
. 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#
. LaA64NAME
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.
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.
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.
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
oRtlAddGrowableFunctionTable
).
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 deVirtualAlloc
oVirtualAllocEx
) y proporcione elMEM_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 elARM64_RUNTIME_FUNCTION
tipo en su lugar. - No use la API anterior
RtlAddFunctionTable
. Use siempre la API más recienteRtlAddGrowableFunctionTable
en su lugar.
- Deben estar en formato Arm64. Al compilar código Arm64EC, el
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)
);
Comentarios de Windows on Arm
Windows on Arm es un proyecto de código abierto. Seleccione un vínculo para proporcionar comentarios: