Observação
O acesso a essa página exige autorização. Você pode tentar entrar ou alterar diretórios.
O acesso a essa página exige autorização. Você pode tentar alterar os diretórios.
Arm64EC ("Emulation Compatible") é uma nova ABI (interface binária de aplicativo) para criar aplicativos para Windows 11 no Arm. Para obter uma visão geral do Arm64EC e como começar a criar aplicativos Win32 como Arm64EC, consulte Usando o Arm64EC para criar aplicativos para Windows 11 em dispositivos Arm.
Este artigo fornece uma exibição detalhada da ABI Arm64EC com informações suficientes para um desenvolvedor de aplicativos gravar e depurar código compilado para Arm64EC, incluindo depuração de nível baixo/assembler e gravação de código de assembly direcionado à ABI Arm64EC.
Projeto do Arm64EC
O Arm64EC fornece funcionalidade e desempenho de nível nativo, ao mesmo tempo em que fornece interoperabilidade transparente e direta com código x64 em execução em emulação.
O Arm64EC é principalmente aditivo à ABI do Arm64 clássico. A ABI Clássica mudou muito pouco, mas a ABI Arm64EC adicionou partes para habilitar a interoperabilidade x64.
Neste documento, a ABI Arm64 padrão original é conhecida como "ABI Clássica". Esse termo evita a ambiguidade inerente a termos sobrecarregados como "Nativo". Arm64EC é tão nativo quanto o ABI original.
Arm64EC vs. Arm64 Classic ABI
A lista a seguir aponta onde Arm64EC diverge da ABI Clássica do Arm64.
- Mapeamento de registros e registros bloqueados
- Verificadores de chamadas
- Verificadores de pilha
- Convenção de chamada variadic
Essas diferenças são pequenas alterações quando vistas em perspectiva de quanto a ABI inteira define.
Mapeamento de registros e registros bloqueados
Para habilitar a interoperabilidade no nível do tipo com código x64, o código Arm64EC é compilado com as mesmas definições de arquitetura de pré-processador que o código x64.
Em outras palavras, _M_AMD64 e _AMD64_ são definidos. Um dos tipos afetados por essa regra é a CONTEXT estrutura. A CONTEXT estrutura define o estado da CPU em um determinado ponto. É usado para coisas como Exception Handling e GetThreadContext APIs. O código x64 existente espera que o contexto da CPU seja representado como uma estrutura x64 CONTEXT ou, em outras palavras, a estrutura como ela é definida durante a CONTEXT compilação x64.
Você deve usar essa estrutura para representar o contexto da CPU durante a execução do código x64 e do código Arm64EC. O código existente não entende um novo conceito, como o conjunto de registros da CPU mudando de função para função. Se você usar a estrutura x64 CONTEXT para representar os estados de execução do Arm64, mapeará efetivamente os registros arm64 em registros x64.
Esse mapeamento também significa que você não pode usar nenhum registro arm64 que não se ajuste ao x64 CONTEXT. Seus valores podem ser perdidos sempre que uma operação usa CONTEXT (e algumas operações podem ser assíncronas e inesperadas, como a operação coleta de lixo de um Runtime de Linguagem Gerenciada ou um APC).
Os cabeçalhos do Windows no SDK representam as regras de mapeamento com a estrutura ARM64EC_NT_CONTEXT, entre os registros Arm64EC e x64. Essa estrutura é essencialmente uma união da estrutura CONTEXT, exatamente como definida para x64, mas com uma sobreposição extra de registrador Arm64.
Por exemplo, RCX mapeia para X0, RDX para X1, RSP para SP, RIP para PC, e assim por diante. Os registrosx13, x14, , x23, x24e x28v16 por meio v31 não têm representação e, portanto, não podem ser usados no Arm64EC.
Essa restrição de uso de registro é a primeira diferença entre as ABIs Arm64 Classic e EC.
Verificadores de chamadas
Os verificadores de chamadas fazem parte do Windows desde que o CFG (Control Flow Guard) foi introduzido no Windows 8.1. Os verificadores de chamadas são desinfetantes de endereço para ponteiros de função (antes essas coisas eram chamadas de desinfetantes de endereço). Sempre que você compila o código com a opção /guard:cf, o compilador gera uma chamada extra para a função de verificador pouco antes de cada chamada indireta ou salto. O Windows fornece a função de verificador em si. Para CFG, ele realiza uma verificação de validade em relação aos destinos de chamada conhecidos por serem bons. Os binários compilados com /guard:cf também incluem essas informações.
Este exemplo mostra um uso de verificador de chamadas no Arm64 Clássico:
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
No caso CFG, o verificador de chamadas simplesmente retorna se o destino é válido ou falha rapidamente no processo se não for. Os verificadores de chamadas têm convenções de chamada personalizadas. Eles pegam o ponteiro de função em um registro não usado pela convenção de chamada normal e preservam todos os registros normais de convenção de chamada. Dessa forma, eles não introduzem derramamento de registro ao seu redor.
Os verificadores de chamadas são opcionais em todas as outras ABIs do Windows, mas obrigatórios no Arm64EC. No Arm64EC, os verificadores de chamadas acumulam a tarefa de verificar a arquitetura da função que está sendo chamada. Eles verificam se a chamada é outra função EC ("Emulation Compatible") ou uma função x64 que deve ser executada sob emulação. Em muitos casos, isso só pode ser verificado em tempo de execução.
Os verificadores de chamada Arm64EC são criados com base nos verificadores Arm64 existentes, mas têm uma convenção de chamada personalizada ligeiramente diferente. Eles recebem um parâmetro extra e podem modificar o registro que contém o endereço de destino. Por exemplo, se o destino for o código x64, o controle deverá ser transferido para a lógica do scaffolding de emulação primeiro.
No Arm64EC, o mesmo uso do verificador de chamadas se tornaria:
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
Pequenas diferenças do Arm64 clássico incluem:
- O nome do símbolo para o verificador de chamadas é diferente.
- O endereço de destino é fornecido em
x11vez dex15. - O endereço de destino (
x11) é[in, out]em vez de[in]. - Existe um parâmetro extra, fornecido por meio de
x10, chamado "Exit Thunk".
Um Thunk de Saída é um funclet que transforma parâmetros de função da convenção de chamada Arm64EC em convenção de chamada x64.
O verificador de chamadas Arm64EC está localizado por meio de um símbolo diferente do usado para as outras ABIs no Windows. Na ABI Arm64 clássica, o símbolo do verificador de chamadas é __guard_check_icall_fptr. Esse símbolo estará presente no Arm64EC, mas está lá para o código x64 vinculado estaticamente usar, não o próprio código Arm64EC. O código Arm64EC usará ou __os_arm64x_check_icall__os_arm64x_check_icall_cfg.
No Arm64EC, os verificadores de chamadas não são opcionais. No entanto, o CFG ainda é opcional, como é o caso de outras ABIs. O CFG pode ser desabilitado em tempo de compilação ou pode haver um motivo legítimo para não executar uma verificação de CFG mesmo quando o CFG está habilitado (por exemplo, o ponteiro de função nunca reside na memória RW). Para uma chamada indireta com verificação CFG, o __os_arm64x_check_icall_cfg verificador deve ser usado. Se o CFG estiver desabilitado ou desnecessário, __os_arm64x_check_icall deve ser usado em seu lugar.
Abaixo está uma tabela de resumo do uso do verificador de chamadas no Arm64 clássico, x64 e Arm64EC, observando o fato de que um binário Arm64EC pode ter duas opções, dependendo da arquitetura do código.
| Binário | Code | Chamada indireta desprotegida | Chamada indireta protegida CFG |
|---|---|---|---|
| x64 | x64 | sem verificador de chamadas |
__guard_check_icall_fptr ou __guard_dispatch_icall_fptr |
| Arm64 Clássico | Arm64 | sem verificador de chamadas | __guard_check_icall_fptr |
| Arm64EC | x64 | sem verificador de chamadas |
__guard_check_icall_fptr ou __guard_dispatch_icall_fptr |
| Arm64EC | __os_arm64x_check_icall |
__os_arm64x_check_icall_cfg |
Independentemente da ABI, ter o código habilitado para CFG (código com referência aos verificadores de chamada CFG) não implica proteção CFG em tempo de execução. Os binários protegidos por CFG podem ser executados em nível inferior, em sistemas que não suportam CFG: o verificador de chamadas é inicializado com um auxiliar não operacional em tempo de compilação. Um processo também pode ter o CFG desabilitado pela configuração. Quando o CFG estiver desabilitado (ou o suporte ao sistema operacional não estiver presente) em ABIs anteriores, o sistema operacional simplesmente não atualizará o verificador de chamadas quando o binário for carregado. No Arm64EC, se a proteção CFG estiver desabilitada, o sistema operacional definirá __os_arm64x_check_icall_cfg o mesmo __os_arm64x_check_icallque , que ainda fornecerá a verificação de arquitetura de destino necessária em todos os casos, mas não a proteção CFG.
Assim como acontece com o CFG no Arm64 Clássico, a chamada para a função de destino (x11) deve seguir imediatamente a chamada para o Verificador de Chamadas. O endereço do Verificador de Chamadas deve ser colocado em um registro volátil e nem ele, nem o endereço da função de destino, devem ser copiados para outro registro ou derramados na memória.
Verificadores de pilha
__chkstk é usado automaticamente pelo compilador sempre que uma função aloca uma área na pilha maior que uma página. Para evitar ignorar a página de proteção de pilha que protege o final da pilha, __chkstk é chamado para garantir que todas as páginas na área alocada sejam investigadas.
__chkstk geralmente é chamado do prólogo da função. Por esse motivo, e para geração de código ideal, ele usa uma convenção de chamada personalizada.
Isso implica que o código x64 e o código Arm64EC precisam de suas próprias funções distintas __chkstk , pois as conversões de entrada e saída assumem convenções de chamada padrão.
x64 e Arm64EC compartilham o mesmo namespace de símbolo, portanto, não pode haver duas funções chamadas __chkstk. Para acomodar a compatibilidade com o código x64 pré-existente, __chkstk o nome será associado ao verificador de pilha x64. O código Arm64EC será usado __chkstk_arm64ec em vez disso.
A convenção de chamada personalizada para __chkstk_arm64ec é a mesma do Arm64 __chkstkClássico: x15 fornece o tamanho da alocação em bytes, dividido por 16. Todos os registros não voláteis, bem como todos os registros voláteis envolvidos na convenção de chamada padrão são preservados.
Tudo o que foi dito acima __chkstk se aplica igualmente a __security_check_cookie e sua contraparte Arm64EC: __security_check_cookie_arm64ec.
Convenção de chamada variadic
Arm64EC segue a convenção de chamada clássica da ABI Arm64, exceto para funções variádicas (também conhecidas como varargs ou funções que utilizam a palavra-chave de parâmetro elipse (. . .)).
Para o caso específico variádico, Arm64EC segue uma convenção de chamada muito semelhante à variável x64, com apenas algumas diferenças. A lista a seguir mostra as principais regras para Arm64EC variadic:
- Somente os quatro primeiros registros são usados para passagem de parâmetro:
x0, ,x1,x2,x3. Os parâmetros restantes são derramados na pilha. Essa regra segue a convenção de chamada variádica x64 com precisão e difere do Arm64 Classic, onde os registros dex0ax7são utilizados. - Os parâmetros de ponto flutuante e SIMD passados pelo registro usam um registro de uso geral, não um SIMD. Essa regra é semelhante ao Arm64 Classic e difere de x64, onde os parâmetros FP/SIMD são passados tanto em um registro de propósito geral quanto em um registro SIMD. Por exemplo, para uma função
f1(int, …)chamadaf1(int, double), em x64, o segundo parâmetro é atribuído a ambosRDXeXMM1. No Arm64EC, o segundo parâmetro é atribuído a apenasx1. - Ao passar estruturas por valor por meio de um registro, as regras de tamanho x64 se aplicam: estruturas com tamanhos exatamente 1, 2, 4 e 8 bytes são carregadas diretamente no registro de uso geral. Estruturas com outros tamanhos são derrubadas na pilha, e um ponteiro para o local onde foram derrubadas é atribuído ao registro. Esta regra basicamente converte o modo por valor em modo por referência no nível mais baixo. Na ABI Arm64 Clássica, estruturas de qualquer tamanho de até 16 bytes são atribuídas diretamente a registros de uso geral.
- O
x4registro carrega um ponteiro que aponta para o primeiro parâmetro passado pela pilha (o quinto parâmetro). Essa regra não inclui estruturas derramadas devido às restrições de tamanho descritas anteriormente. - O
x5registro carrega o tamanho, em bytes, de todos os parâmetros passados pela pilha (tamanho de todos os parâmetros, começando com o quinto). Essa regra não inclui estruturas passadas por valor derramado devido às restrições de tamanho descritas anteriormente.
No exemplo a seguir, pt_nova_function usa parâmetros em uma forma não variada, portanto, ele segue a convenção de chamada arm64 clássica. Em seguida, ele chama pt_va_function exatamente os mesmos parâmetros, mas em uma chamada variadic.
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 usa cinco parâmetros, que ele atribui seguindo as regras de convenção de chamada arm64 clássicas:
- 'f' é um duplo. Ele atribui a
d0. - 'tc' é um struct com um tamanho de 3 bytes. Ele atribui a
x0. -
ull1é um inteiro de 8 bytes. Ele atribui ax1. -
ull2é um inteiro de 8 bytes. Ele atribui ax2. -
ull3é um inteiro de 8 bytes. Atribui ax3.
pt_va_function é uma função variádica, portanto, ela segue as regras variádicas arm64EC descritas anteriormente:
- 'f' é um duplo. Ele atribui a
x0. - 'tc' é um struct com um tamanho de 3 bytes. Despeja-se na pilha e o endereço é carregado em
x1. -
ull1é um inteiro de 8 bytes. Ele atribui ax2. -
ull2é um inteiro de 8 bytes. Atribui ax3. -
ull3é um inteiro de 8 bytes. Atribui diretamente à pilha. -
x4carrega a localização deull3na pilha. -
x5carrega o tamanho deull3.
O exemplo a seguir mostra uma possível saída de compilação para pt_nova_function, o que ilustra as diferenças de atribuição de parâmetro 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
Adições de ABI
Para obter interoperabilidade transparente com código x64, faça muitas adições à ABI arm64 clássica. Essas adições lidam com as diferenças de convenções de chamada entre Arm64EC e x64.
A lista a seguir inclui estas adições:
- Thunks de entrada e saída
- Thunks de saída
- Thunks de entrada
- Thunks do Ajustador
- Sequências de avanço rápido
Thunks de entrada e saída
Os thunks de entrada e saída traduzem a convenção de chamada Arm64EC (principalmente a mesma que o Arm64 clássico) para a convenção de chamada x64 e vice-versa.
Um equívoco comum é que você pode converter convenções de chamada seguindo uma única regra aplicada a todas as assinaturas de função. A realidade é que as convenções de chamada têm regras de atribuição de parâmetros. Essas regras dependem do tipo de parâmetro e são diferentes de ABI para ABI. Uma consequência é que a tradução entre ABIs é específica para cada assinatura de função, variando com o tipo de cada parâmetro.
Considere a seguinte função:
int fJ(int a, int b, int c, int d);
A atribuição de parâmetro ocorre da seguinte maneira:
- Arm64: a -> x0, b -> x1, c -> x2, d -> x3
- x64: a -> RCX, b -> RDX, c -> R8, d -> r9
- Arm64 -> translação x64: x0 -> RCX, x1 -> RDX, x2 -> R8, x3 -> R9
Agora considere uma função diferente:
int fK(int a, double b, int c, double d);
A atribuição de parâmetro ocorre da seguinte maneira:
- Arm64: a -> x0, b -> d0, c -> x1, d -> d1
- x64: a -> RCX, b -> XMM1, c -> R8, d -> XMM3
- Arm64 -> translação x64: x0 -> RCX, d0 -> XMM1, x1 -> R8, d1 -> XMM3
Esses exemplos demonstram que a atribuição e a conversão de parâmetros variam por tipo, mas também dependem dos tipos dos parâmetros anteriores na lista. Esse detalhe é ilustrado pelo terceiro parâmetro. Em ambas as funções, o tipo do parâmetro é int, mas a tradução resultante é diferente.
Os thunks de entrada e saída existem por esse motivo e são especificamente adaptados para cada assinatura de função específica.
Ambos os tipos de thunks são funções. O emulador invoca automaticamente os thunks de entrada quando as funções x64 chamam as funções Arm64EC (a execução entra em Arm64EC). Os verificadores de chamadas invocam automaticamente os thunks de saída quando as funções Arm64EC chamam em funções x64 (a execução sai do Arm64EC).
Ao compilar código Arm64EC, o compilador gera um thunk de entrada para cada função Arm64EC, compatível com a sua assinatura. O compilador também gera um thunk de saída para cada função que uma função Arm64EC chama.
Considere o seguinte exemplo:
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);
}
Ao compilar o código anterior direcionado a Arm64EC, o compilador gera:
- Código para
fA. - Thunk de entrada para
fA - Finalizador de suspensão para
fB - Saída thunk para
fC
O compilador gera o fA entry thunk no caso de fA ser chamado a partir do código x64. O compilador gera thunks de saída para fB e fC no caso de fB e fC serem código x64.
O compilador pode gerar o mesmo thunk de saída várias vezes porque os gera no ponto de chamada, em vez de gerá-los diretamente na função. Essa duplicação pode resultar em uma quantidade considerável de thunks redundantes. Para evitar essa duplicação, o compilador aplica regras de otimização triviais para garantir que apenas os thunks necessários sejam incluídos no binário final.
Por exemplo, em um binário em que a função A Arm64EC chama a função BArm64EC, B não é exportada e seu endereço nunca é conhecido fora de A. É seguro eliminar o thunk de saída de A até B, bem como o thunk de entrada de B. Também é seguro vincular juntos todos os thunks de saída e de entrada que contêm o mesmo código, mesmo que tenham sido gerados para funções distintas.
Finalização de thunks
Usando as funções fA, fB e fC na seção anterior, o compilador gera thunks de saída fB e fC da seguinte maneira:
Sair de "thunk" para 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
Sair de thunk para 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
No caso de fB, a presença de um parâmetro double faz com que a atribuição dos registradores GP restantes seja redistribuída, resultado das regras de atribuição diferentes do Arm64 e x64. Você também pode ver que o x64 atribui apenas quatro parâmetros aos registros; portanto, o quinto parâmetro precisa ser armazenado na pilha.
fC No caso, o segundo parâmetro é uma estrutura de comprimento de 3 bytes. O Arm64 permite que qualquer estrutura de tamanho seja atribuída diretamente a um registro. X64 permite apenas os tamanhos 1, 2, 4 e 8. Esse Thunk de Saída deve transferir isso struct do registro para a pilha e atribuir um ponteiro ao registro. Essa abordagem ainda consome um registro (para carregar o ponteiro), mas não altera as atribuições para os registros restantes: nenhum rearranjo de registros ocorre para o terceiro e quarto parâmetros. Assim como para o fB caso, o quinto parâmetro deve ser derramado na pilha.
Considerações adicionais para Thunks de saída:
- O compilador os identifica não pelo nome das funções de onde traduzem e para onde traduzem, mas sim pela assinatura que utilizam. Essa convenção de nomenclatura facilita a localização de redundâncias.
- O verificador de chamadas define o registro
x9para carregar o endereço da função de destino (x64). O Thunk de Saída chama o emulador, passandox9sem alterações.
Depois de reorganizar os parâmetros, o Thunk de Saída chama o emulador por meio de __os_arm64x_dispatch_call_no_redirect.
Neste ponto, vale a pena examinar a função do verificador de chamadas e sua ABI personalizada. Veja como é uma chamada indireta para 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
Ao chamar o verificador de chamadas:
-
x11fornece o endereço da função de destino a ser chamada (fBneste caso). Neste ponto, o verificador de chamadas pode não saber se a função de destino é Arm64EC ou x64. -
x10fornece um Thunk de saída correspondente à assinatura da função que está sendo chamada (fBneste caso).
Os dados que o verificador de chamadas retorna dependem se a função de destino é Arm64EC ou x64.
Se o destino for Arm64EC:
-
x11retorna o endereço do código Arm64EC a ser chamado. Esse valor pode ser o mesmo fornecido.
Se o destino for o código x64:
-
x11retorna o endereço do Exit Thunk. Esse endereço é copiado da entrada fornecida emx10. -
x10retorna o endereço do Exit Thunk, inalterado em relação à entrada. -
x9retorna a função x64 de destino. Esse valor pode ser o mesmo fornecido por meio dex11.
Os verificadores de chamadas sempre deixam os registros de parâmetro de convenção de chamada não perturbados. O código de chamada deve seguir a chamada para o verificador de chamadas imediatamente com blr x11 (ou br x11 no caso de uma chamada final). Os verificadores de chamadas sempre preservam esses registros acima e além dos registros não voláteis padrão: x0-x8, (x15chkstk) e .q0-q7
Thunks de entrada
Os Thunks de entrada cuidam das transformações necessárias das convenções de chamada x64 para Arm64. Essa transformação é essencialmente o inverso de Exit Thunks, mas requer a consideração de mais alguns aspectos.
Considere o exemplo anterior de compilação fA. Um Thunk de Entrada é gerado para que o código x64 possa chamar 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
O emulador fornece o endereço da função de destino em x9.
Antes de chamar o Thunk de Entrada, o emulador x64 exibe o endereço de retorno da pilha no LR registro.
LR deve apontar para o código x64 quando o controle é transferido para o Thunk de Entrada.
O emulador também pode executar outro ajuste na pilha, dependendo do seguinte: as ABIs Arm64 e x64 definem um requisito de alinhamento de pilha em que a pilha deve ser alinhada a 16 bytes no ponto em que uma função é chamada. Ao executar o código Arm64, o hardware impõe essa regra, mas não há nenhuma imposição de hardware para x64. Durante a execução do código x64, chamar erroneamente funções com uma pilha desalinhada pode não ser detectado indefinidamente, até que alguma instrução de alinhamento de 16 bytes seja usada (algumas instruções SSE o fazem) ou quando o código Arm64EC for chamado.
Para resolver esse possível problema de compatibilidade, antes de chamar o Thunk de Entrada, o emulador sempre alinha o Ponteiro de Pilha para baixo para 16 bytes e armazena seu valor original no registro x4. Dessa forma, os Thunks de Entrada sempre começam a ser executados com uma pilha alinhada, mas ainda podem referenciar corretamente os parâmetros passados na pilha por meio de x4.
Quando se trata de registros SIMD não voláteis, há uma diferença significativa entre as convenções de chamada Arm64 e x64. No Arm64, os 8 bytes (64 bits) baixos do registro são considerados não voláteis. Em outras palavras, apenas a Dn parte dos Qn registros não é volátil. Em x64, todos os 16 bytes do XMMn registro são considerados não voláteis. Além disso, em x64, e XMM6 são registros não voláteis, XMM7 enquanto D6 e D7 (os registros Arm64 correspondentes) são voláteis.
Para lidar com essas assimetrias de manipulação de registro SIMD, os Thunks de Entrada devem salvar explicitamente todos os registros SIMD considerados não voláteis no x64. Esse salvamento só é necessário em Thunks de Entrada (não Thunks de Saída) porque x64 é mais rigoroso que Arm64. Em outras palavras, registrar regras de salvamento e preservação em x64 excedem os requisitos do Arm64 em todos os casos.
Para garantir a recuperação correta dos valores de registro ao descontrair a pilha (por exemplo, setjmp + longjmp ou throw + catch), foi introduzido um novo opcode de desenrolamento: save_any_reg (0xE7). Este novo opcode de desenrolamento de 3 bytes permite salvar qualquer registro de uso geral ou SIMD (incluindo os considerados voláteis) e incluindo registros de tamanho Qn normal. Esse novo opcode é usado para o Qn registro de operações de despejo e preenchimento.
save_any_reg é compatível com save_next_pair (0xE6).
Para referência, as seguintes informações de desenrolamento pertencem à Entrada Thunk apresentada 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)
Depois que a função Arm64EC retorna, a __os_arm64x_dispatch_ret rotina reentra no emulador, de volta ao código x64 (apontado por LR).
As funções Arm64EC reservam os quatro bytes antes da primeira instrução na função para armazenar informações a serem usadas em runtime. Nesses quatro bytes, pode ser encontrado o endereço relativo do Entry Thunk para a função. Ao executar uma chamada de uma função x64 para uma função Arm64EC, o emulador lê os quatro bytes antes do início da função, mascara os dois bits inferiores e adiciona esse valor ao endereço da função. Esse processo produz o endereço do Thunk de Entrada para chamar.
Thunks do Ajustador
Thunks do ajustador são funções sem assinatura que transferem o controle para (chamada final) para outra função. Antes de transferir o controle, eles transformam um dos parâmetros. O tipo dos parâmetros que estão sendo transformados é conhecido, mas todos os parâmetros restantes podem ser qualquer coisa e podem estar em qualquer número. O Ajuster Thunks não toca em nenhum registro que potencialmente contém um parâmetro e eles não tocam na pilha. A característica torna as funções do Adjustor Thunks sem assinatura.
O compilador pode gerar automaticamente Adjustor Thunks. Esse tipo de geração é comum, por exemplo, com herança múltipla em C++, em que qualquer método virtual pode delegar à classe base sem modificação, exceto por um ajuste no ponteiro this.
O exemplo a seguir mostra um cenário real:
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
b CObjectContext::Release
A conversão subtrai 8 bytes para o this ponteiro e encaminha a chamada para a classe pai.
Em resumo, as funções Arm64EC que podem ser chamadas de funções x64 devem ter um Thunk de Entrada associado. O Thunk de entrada é específico da assinatura. As funções sem assinatura do Arm64, como Thunks do Ajustador, precisam de um mecanismo diferente que possa lidar com funções sem assinatura.
O Thunk de entrada de um Thunk de ajuste usa o __os_arm64x_x64_jump auxiliar para adiar a execução do trabalho real do Thunk de entrada (ajustar os parâmetros de uma convenção para outra) para a próxima chamada. É nesse momento que a assinatura se torna aparente. Isso inclui a opção de não fazer ajustes de convenção de chamada, se o destino do Thunk do Ajustador for uma função x64. Lembre-se de que, no momento em que um Entry Thunk começa a ser executado, os parâmetros estão em sua forma x64.
No exemplo acima, considere a aparência do código no Arm64EC.
Thunk do ajustador em 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
Baú de entrada do Ajustador Thunk
[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
Sequências de avanço rápido
Alguns aplicativos fazem modificações em tempo de execução em funções que residem em binários que eles não possuem, mas dependem – normalmente binários do sistema operacional – com a finalidade de desviar a execução quando a função é chamada. Esse processo também é conhecido como hooking.
Em um alto nível, o processo de conexão é simples. Em detalhes, no entanto, o gancho é específico da arquitetura e bastante complexo, dadas as variações potenciais que a lógica do gancho deve abordar.
Em termos gerais, o processo envolve as seguintes etapas:
- Determine o endereço da função a ser conectada.
- Substitua a primeira instrução da função por um salto para a rotina do gancho.
- Quando o gancho estiver pronto, volte para a lógica original, que inclui a execução da instrução original deslocada.
As variações surgem de coisas como:
- O tamanho da primeira instrução: é uma boa ideia substituí-la por um JMP que tenha o mesmo tamanho ou menor, para evitar substituir o início da função enquanto outro thread pode estar em execução.
- O tipo da primeira instrução: se a primeira instrução tiver alguma natureza relativa ao PC, realocá-la poderá exigir a alteração de elementos como os campos de deslocamento. Como eles podem estourar quando uma instrução é movida para um lugar distante, essa alteração pode exigir fornecer lógica equivalente com instruções completamente diferentes.
Devido a toda essa complexidade, é rara encontrar uma lógica de gancho robusta e genérica. Frequentemente, a lógica presente em aplicativos só pode lidar com um conjunto limitado de casos que o aplicativo espera encontrar nas APIs específicas em que está interessado. Não é difícil imaginar quanto de um problema de compatibilidade de aplicativo é esse. Até mesmo uma alteração simples nas otimizações de código ou compilador pode tornar os aplicativos inutilizáveis se o código não for mais exatamente como esperado.
O que aconteceria com esses aplicativos se eles encontrassem o código Arm64 ao configurar um gancho? Eles certamente falhariam.
As funções FFS (sequência de avanço rápido) atendem a esse requisito de compatibilidade no Arm64EC.
FFS são funções x64 muito pequenas que não contêm lógica real e chamada final para a função Arm64EC real. Eles são opcionais, mas habilitados por padrão para todas as exportações de DLL e para qualquer função decorada com __declspec(hybrid_patchable).
Para esses casos, quando o código obtém um ponteiro para uma determinada função, no GetProcAddress caso de exportação ou, &function no __declspec(hybrid_patchable) caso, o endereço resultante contém código x64. Esse código x64 passa para uma função x64 legítima, satisfazendo a maior parte da lógica de conexão atualmente disponível.
Considere o seguinte exemplo (tratamento de erros omitido para fins de brevidade):
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);
O valor do ponteiro de função na variável pgma contém o endereço do FFS de GetMachineTypeAttributes.
Este exemplo mostra uma sequência de Avanço 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
A função FFS x64 tem um prólogo e um epílogo canônicos, terminando com uma chamada final (salto) para a função real GetMachineTypeAttributes no 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
[...]
Seria bastante ineficiente se fosse necessário executar cinco instruções x64 emuladas entre duas funções Arm64EC. As funções FFS são especiais. As funções FFS realmente não são executadas se permanecerem inalteradas. O auxiliar do verificador de chamadas verifica com eficiência se o FFS não foi alterado. Se esse for o caso, a chamada será transferida diretamente para o destino real. Se o FFS for alterado de qualquer maneira possível, ele não será mais um FFS. A execução é transferida para o FFS alterado e executa qualquer código que possa estar lá, emulando o desvio e qualquer lógica de conexão.
Quando o gancho transfere a execução de volta para o final do FFS, ele eventualmente atinge a chamada final para o código Arm64EC, que é executado após o gancho, exatamente como o aplicativo espera.
Criação do Arm64EC no assembly
Os cabeçalhos do SDK do Windows e o compilador C simplificam o trabalho de criação do assembly Arm64EC. Por exemplo, você pode usar o compilador C para gerar thunks de entrada e saída para funções que não são compiladas a partir de código C.
Considere o exemplo de uma função equivalente à seguinte função fD que você deve criar no assembly (ASM). O código Arm64EC e x64 pode chamar esta função, e o ponteiro de função pfE pode apontar para o código Arm64EC ou x64.
typedef int (PF_E)(int, double);
extern PF_E * pfE;
int fD(int i, double d) {
return (*pfE)(i, d);
}
Escrever fD no ASM pode se parecer com o seguinte código:
#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
No exemplo anterior:
- O Arm64EC usa a mesma declaração de procedimento e macros de prólogo/epílogo que o Arm64.
- Encapsular nomes de função com a
A64NAMEmacro. Quando você compila código C ou C++ como Arm64EC, o compilador marca oOBJcomoARM64ECque contém código Arm64EC. Essa marcação não acontece comARMASM. Ao compilar o código ASM, você pode informar ao vinculador que o código produzido é Arm64EC prefixando o nome da função com#. AA64NAMEmacro executa essa operação quando_ARM64EC_é definida e deixa o nome inalterado quando_ARM64EC_não está definido. Essa abordagem possibilita compartilhar o código-fonte entre Arm64 e Arm64EC. - Primeiro, você deve passar o ponteiro de função
pfEpelo verificador de chamadas EC, juntamente com o thunk de saída apropriado, caso a função de destino utilize a arquitetura x64.
Gerando thunks de entrada e saída
A próxima etapa é gerar o thunk de entrada para fD e o thunk de saída para pfE. O compilador C pode executar essa tarefa com esforço mínimo usando a palavra-chave do _Arm64XGenerateThunk 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;
}
A palavra-chave _Arm64XGenerateThunk informa ao compilador C para utilizar a assinatura da função, ignorar o corpo, e gerar um "thunk" de saída (quando o parâmetro for 1) ou um "thunk" de entrada (quando o parâmetro for 2).
Colocar a geração de thunk em seu próprio arquivo C. Estar em arquivos isolados torna mais simples confirmar os nomes dos símbolos despejando os símbolos correspondentes OBJ ou até mesmo desmontando.
Thunks de entrada personalizados
O SDK inclui macros que ajudam você a criar thunks de entrada personalizados escritos manualmente. Você pode usar essas macros ao criar thunks personalizados do ajustador.
A maioria dos thunks do ajustador é gerada pelo compilador C++, mas você também pode gerá-los manualmente. Você pode manualmente gerar um thunk de ajuste quando um retorno de chamada genérico transfere o controle para o retorno de chamada real e um dos parâmetros identifica o retorno de chamada real.
O exemplo a seguir mostra um thunk de ajuste no código Arm64 Clássico:
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
Neste exemplo, o primeiro parâmetro fornece uma referência a uma estrutura. O código recupera o endereço da função de destino de um elemento dessa estrutura. Como a estrutura é gravável, o CFG (Control Flow Guard) deve validar o endereço de destino.
O exemplo a seguir mostra como portar o thunk do ajustador equivalente para 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
O código anterior não fornece um thunk de saída (no registro x10). Essa abordagem não é possível porque o código pode ser executado para muitas assinaturas diferentes. Esse código aproveita a configuração do chamador x10 para o thunk de saída. O chamador faz a chamada direcionada a uma assinatura explícita.
O código anterior precisa de um thunk de entrada para resolver o caso quando o chamador é código x64. O exemplo a seguir mostra como criar o thunk de entrada correspondente usando a macro para thunks de entrada personalizados:
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
Ao contrário de outras funções, esse thunk de entrada não eventualmente transfere o controle para a função associada (o thunk do ajustador). Nesse caso, o thunk de entrada insere a própria funcionalidade (executando o ajuste de parâmetro) e transfere o controle diretamente para o destino final através do auxiliar __os_arm64x_x64_jump.
Geração dinâmica de código Arm64EC (compilação JIT)
Em processos arm64EC, existem dois tipos de memória executável: código Arm64EC e código x64.
O sistema operacional extrai essas informações dos binários carregados. Binários x64 são todos x64 e binários Arm64EC contêm uma tabela de intervalo para páginas de código Arm64EC versus x64.
E o código gerado dinamicamente? Compiladores JIT (just-in-time) geram código em runtime que não é apoiado por nenhum arquivo binário.
Normalmente, esse processo envolve as seguintes etapas:
- Alocando memória gravável (
VirtualAlloc). - Produzindo o código na memória alocada.
- Alterar a proteção da memória de leitura-gravação para leitura-execução (
VirtualProtect). - Adicionando entradas de função de desenrolamento para todas as funções geradas que não são triviais (não folha) (
RtlAddFunctionTableouRtlAddGrowableFunctionTable).
Por motivos triviais de compatibilidade, se um aplicativo executar essas etapas em um processo Arm64EC, o sistema operacional considerará o código como código x64. Esse comportamento ocorre para qualquer processo que use o Runtime Java x64 não modificado, o runtime do .NET, o mecanismo JavaScript e assim por diante.
Para gerar o código dinâmico Arm64EC, siga o mesmo processo com duas diferenças:
- Ao alocar a memória, use o
VirtualAlloc2mais recente (em vez deVirtualAllocouVirtualAllocEx) e forneça o atributoMEM_EXTENDED_PARAMETER_EC_CODE. - Ao adicionar entradas de função:
- Eles devem estar no formato Arm64. Ao compilar o código Arm64EC, o
RUNTIME_FUNCTIONtipo corresponde ao formato x64. Para o formato Arm64 ao compilar Arm64EC, use oARM64_RUNTIME_FUNCTIONtipo. - Não use a API mais antiga
RtlAddFunctionTable. Sempre use a API mais novaRtlAddGrowableFunctionTable.
- Eles devem estar no formato Arm64. Ao compilar o código Arm64EC, o
O exemplo a seguir mostra a alocação de memória:
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);
E o exemplo a seguir mostra como adicionar uma entrada de função de desenrolamento:
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)
);