Noções básicas do ABI Arm64EC e do código de assembly

Arm64EC ("Compatível com Emulação") é 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 Como usar o Arm64EC para criar aplicativos para Windows 11 em dispositivos Arm.

A finalidade deste documento é fornecer uma exibição detalhada da ABI arm64EC com informações suficientes para um desenvolvedor de aplicativos escrever e depurar código compilado para Arm64EC, incluindo depuração de baixo nível/assembler e gravação de código de assembly direcionado à ABI Arm64EC.

Design do Arm64EC

O Arm64EC foi projetado para fornecer funcionalidade e desempenho de nível nativo, ao mesmo tempo em que fornece interoperabilidade transparente e direta com código x64 em execução sob emulação.

Arm64EC é principalmente aditivo para a ABI Arm64 clássica. Muito pouco da ABI Clássica foi alterada, mas partes foram adicionadas para habilitar a interoperabilidade x64.

Neste documento, a ABI arm64 padrão original deve ser conhecida como "ABI Clássica". Isso evita a ambiguidade inerente a termos sobrecarregados, como "Nativo". Arm64EC, para ser claro, é tão nativo quanto a ABI original.

Arm64EC vs Arm64 Classic ABI

A lista a seguir aponta onde Arm64EC divergiu da ABI Clássica do Arm64.

Essas são pequenas alterações quando vistas em perspectiva de quanto a ABI inteira define.

Registrar mapeamento e registros bloqueados

Para que haja interoperabilidade no nível do tipo com código x64, o código Arm64EC é compilado com as mesmas definições de arquitetura 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. Ele é usado para itens 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 CONTEXT estrutura como ela é definida durante a compilação x64.

Essa estrutura deve ser usada para representar o contexto da CPU durante a execução do código x64, bem como o código Arm64EC. O código existente não entenderia um novo conceito, como o conjunto de registros da CPU mudando de função para função. Se a estrutura x64 CONTEXT for usada para representar os estados de execução do Arm64, isso implicará que os registros arm64 serão efetivamente mapeados em registros x64.

Isso também implica que todos os registros arm64 que não podem ser instalados no x64 CONTEXT não devem ser usados, pois seus valores podem ser perdidos sempre que uma operação usando CONTEXT ocorre (e alguns podem ser assíncronos e inesperados, como a operação coleta de lixo de um Runtime de Linguagem Gerenciada ou um APC).

As regras de mapeamento entre os registros Arm64EC e x64 são representadas pela Arm64EC_NT_CONTEXT estrutura nos cabeçalhos do Windows, presentes no SDK. Essa estrutura é essencialmente uma união da CONTEXT estrutura, exatamente como ela é definida para x64, mas com uma sobreposição de registro arm64 extra.

Por exemplo, RCX mapeia para X0, RDX para X1, RSP para SP, RIP para PC, etc. Também podemos ver como os registrosx13, x14, x23, , x24,-x28v16v31 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 que essas coisas fossem chamadas de desinfetantes de endereço). Sempre que o código for compilado com a opção /guard:cf , o compilador gerará uma chamada extra para a função de verificador pouco antes de cada chamada/salto indireto. A função de verificador em si é fornecida pelo Windows e, para CFG, executa uma verificação de validade em relação aos destinos de chamada conhecidos para serem bons. Essas informações também são incluídas em binários compilados com /guard:cf.

Este é um exemplo de um verificador de chamadas usado 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 retornará se o destino for válido ou falhará 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 apresentam registro de derramamento 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 ("Compatível com Emulação") ou uma função x64 que deve ser executada sob emulação. Em muitos casos, isso só pode ser verificado em runtime.

Os verificadores de chamadas Arm64EC se baseiam nos verificadores arm64 existentes, mas eles têm uma convenção de chamada personalizada ligeiramente diferente. Eles pegam um parâmetro extra e podem modificar o registro que contém o endereço de destino. Por exemplo, se o destino for código x64, o controle deverá ser transferido para a lógica de 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 em relação ao Arm64 Clássico incluem:

  • O nome do símbolo do verificador de chamadas é diferente.
  • O endereço de destino é fornecido em x11 vez de x15.
  • O endereço de destino (x11) é [in, out] em vez de [in].
  • Há um parâmetro extra, fornecido por meio x10de um chamado "Exit Thunk".

Um Exit Thunk é um funclet que transforma parâmetros de função da convenção de chamada Arm64EC para a 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. No ABI arm64 clássico, o símbolo do verificador de chamada é __guard_check_icall_fptr. Esse símbolo estará presente no Arm64EC, mas ele está lá para que o código vinculado estaticamente x64 seja usado, 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 estar desabilitado em tempo de compilação ou pode haver um motivo legítimo para não executar uma verificação CFG mesmo quando o CFG estiver habilitado (por exemplo, o ponteiro da função nunca reside na memória RW). Para uma chamada indireta com verificação de CFG, o __os_arm64x_check_icall_cfg verificador deve ser usado. Se o CFG estiver desabilitado ou desnecessário, __os_arm64x_check_icall deverá ser usado em vez disso.

Veja abaixo uma tabela de resumo do uso do verificador de chamadas em 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 Código Chamada indireta desprotegida Chamada indireta protegida por CFG
x64 x64 nenhum verificador de chamadas __guard_check_icall_fptr ou __guard_dispatch_icall_fptr
Arm64 Clássico Arm64 nenhum verificador de chamadas __guard_check_icall_fptr
Arm64EC x64 nenhum 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 cfg habilitado (código com referência aos verificadores de chamadas CFG), não implica a proteção cfg em runtime. Os binários protegidos por CFG podem ser executados em nível inferior, em sistemas que não dão suporte ao CFG: o verificador de chamadas é inicializado com um auxiliar sem operações no momento da 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 , o que ainda fornecerá a verificação de arquitetura de destino necessária em todos os casos, mas não na 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 do stack guard que protege o final da pilha, __chkstk é chamado para garantir que todas as páginas na área alocada sejam investigadas.

__chkstk geralmente é chamado do diálogo da função. Por esse motivo e para a 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 funções próprias, distintas, __chkstk pois os thunks de Entrada e Saída assumem convenções de chamada padrão.

x64 e Arm64EC compartilham o mesmo namespace de símbolo para que não possa haver duas funções nomeadas __chkstk. Para acomodar a compatibilidade com o código x64 pré-existente, __chkstk o nome será associado ao verificador de pilha x64. Em vez disso, o código Arm64EC será usado __chkstk_arm64ec .

A convenção __chkstk_arm64ec de chamada personalizada é 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 se __chkstk aplica igualmente ao seu equivalente arm64EC __security_check_cookie : __security_check_cookie_arm64ec. .

Convenção de chamada variada

Arm64EC segue a convenção de chamada clássica do Arm64 ABI, exceto para funções variadic (também conhecidas como varargs, também conhecidas como funções com a palavra-chave do parâmetro ellipsis (. . .).

Para o caso específico variádico, Arm64EC segue uma convenção de chamada muito semelhante à variação x64, com apenas algumas diferenças. Abaixo estão 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. Isso segue exatamente a convenção de chamada variada x64 e difere exatamente do Arm64 Classic, em que os registros são>x7 usadosx0.
  • Os parâmetros de Ponto Flutuante/SIMD que estão sendo passados pelo registro usarão um registro de General-Purpose, não um SIMD. Isso é semelhante ao Arm64 Classic e é diferente de x64, em que os parâmetros FP/SIMD são passados em um registro de General-Purpose e SIMD. Por exemplo, para uma função f1(int, …) que está sendo chamada como f1(int, double), em x64, o segundo parâmetro será atribuído a ambos RDX e XMM1. No Arm64EC, o segundo parâmetro será atribuído a apenas x1.
  • 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 serão carregadas diretamente no registro de General-Purpose. Estruturas com outros tamanhos são derramadas na pilha e um ponteiro para o local derramado é atribuído ao registro. Isso basicamente rebaixa por valor em referência, no nível baixo. Na ABI Arm64 Clássica, estruturas de qualquer tamanho de até 16 bytes são atribuídas diretamente a General-Purposed registros.
  • O registro X4 é carregado com um ponteiro para o primeiro parâmetro passado por meio da pilha (o 5º parâmetro). Isso não inclui estruturas derramadas devido às restrições de tamanho descritas acima.
  • O registro X5 é carregado com o tamanho, em bytes, de todos os parâmetros passados por pilha (tamanho de todos os parâmetros, começando com o 5º). Isso não inclui estruturas passadas por valor derramado devido às restrições de tamanho descritas acima.

No exemplo a seguir: pt_nova_function veja abaixo parâmetros em uma forma não variada, seguindo, portanto, a convenção de chamada clássica do Arm64. Em seguida, ele chama pt_va_function com exatamente os mesmos parâmetros, mas em uma chamada variada.

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 5 parâmetros que serão atribuídos seguindo as regras de convenção de chamada clássica do Arm64:

  • 'f' é um duplo. Ele será atribuído a d0.
  • 'tc' é um struct, com um tamanho de 3 bytes. Ele será atribuído a x0.
  • ull1 é um inteiro de 8 bytes. Ele será atribuído a x1.
  • ull2 é um inteiro de 8 bytes. Ele será atribuído a x2.
  • ull3 é um inteiro de 8 bytes. Ele será atribuído a x3.

pt_va_function é uma função variádica, portanto, ela seguirá as regras variádicas arm64EC descritas acima:

  • 'f' é um duplo. Ele será atribuído a x0.
  • 'tc' é um struct, com um tamanho de 3 bytes. Ele será derramado na pilha e sua localização carregada em x1.
  • ull1 é um inteiro de 8 bytes. Ele será atribuído a x2.
  • ull2 é um inteiro de 8 bytes. Ele será atribuído a x3.
  • ull3 é um inteiro de 8 bytes. Ele será atribuído diretamente à pilha.
  • x4 é carregado com o local de ull3 na pilha.
  • x5 é carregado com o tamanho de ull3.

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 acima.

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, muitas adições foram feitas à ABI arm64 clássica. Eles 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

Os Thunks de Entrada e Saída cuidam da tradução da convenção de chamada Arm64EC (principalmente a mesma que o Arm64 Clássico) na convenção de chamada x64 e vice-versa.

Um equívoco comum é que as convenções de chamada podem ser convertidas 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âmetro. Essas regras dependem do tipo de parâmetro e são diferentes da ABI para a ABI. Uma consequência é que a tradução entre ABIs será 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 ocorrerá da seguinte maneira:

  • Arm64: a -> x0, b -> x1, c -> x2, d -> x3
  • x64: a -> RCX, b -> RDX, c -> R8, d -> r9
  • Arm64 -> x64 tradução: 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 ocorrerá da seguinte maneira:

  • Arm64: a -> x0, b -> d0, c -> x1, d -> d1
  • x64: a -> RCX, b -> XMM1, c -> R8, d -> XMM3
  • Arm64 -> x64 tradução: x0 -> RCX, d0 -> XMM1, x1 -> R8, d1 -> XMM3

Esses exemplos demonstram que a atribuição de parâmetro e a tradução variam de acordo com o tipo, mas também os tipos dos parâmetros anteriores na lista dependem. Esse detalhe é ilustrado pelo terceiro parâmetro. Em ambas as funções, o tipo do parâmetro é "int", mas a tradução resultante é diferente.

Thunks de entrada e saída existem por esse motivo e são especificamente adaptados para cada assinatura de função individual.

Ambos os tipos de thunks são, por si só, funções. Os Thunks de entrada são invocados automaticamente pelo emulador quando as funções x64 chamam as funções arm64EC (a execução insere Arm64EC). Os Thunks de Saída são invocados automaticamente pelos verificadores de chamadas quando as funções Arm64EC chamam funções x64 (a execução sai do Arm64EC).

Ao compilar o código Arm64EC, o compilador gerará um Thunk de Entrada para cada função Arm64EC, correspondendo à sua assinatura. O compilador também gerará 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 acima direcionado ao Arm64EC, o compilador gerará:

  • Código para 'fA'.
  • Thunk de Entrada para 'fA'
  • Sair do Thunk para 'fB'
  • Sair do Thunk para 'fC'

O fA Thunk de Entrada é gerado no caso fA e chamado do código x64. Saia do Thunks para fB e fC são gerados no caso fB e/ou fC e acabam sendo código x64.

O mesmo Thunk de Saída pode ser gerado várias vezes, dado que o compilador os gerará no site de chamada em vez da função em si. Isso pode resultar em uma quantidade considerável de thunks redundantes, portanto, na realidade, o compilador aplicará regras de otimização triviais para garantir que apenas os thunks necessários o façam 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 para B, juntamente com o Thunk de Entrada para B. Também é seguro unir todos os thunks de Saída e Entrada que contêm o mesmo código, mesmo que tenham sido gerados para funções distintas.

Sair de Thunks

Usando as funções fAde exemplo, fB e fC acima, é assim que o compilador geraria tanto quanto fCfB Exit Thunks:

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

fB No caso, podemos ver como a presença de um parâmetro 'double' fará com que a atribuição de registro de GP restante seja reformulada, resultado das diferentes regras de atribuição do Arm64 e x64. Também podemos ver que x64 atribui apenas 4 parâmetros a registros, portanto, o 5º parâmetro deve ser derramado na pilha.

fC No caso, o segundo parâmetro é uma estrutura de comprimento de 3 bytes. O Arm64 permitirá que qualquer estrutura de tamanho seja atribuída a um registro diretamente. 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. Isso ainda consome um registro (para carregar o ponteiro) para que ele não altere as atribuições para os registros restantes: nenhum reenfatório de registro ocorre para o 3º e 4º parâmetro. Assim como no fB caso, o 5º parâmetro deve ser derramado na pilha.

Considerações adicionais para Exit Thunks:

  • O compilador os nomeará não pelo nome da> função para o qual eles se traduzem, mas sim pela assinatura que eles endereçam. Isso facilita a localização de redundâncias.
  • O Thunk de Saída é chamado com o registro x9 carregando o endereço da função de destino (x64). Isso é definido pelo verificador de chamadas e passa pelo Thunk de Saída, não perturbado, para o emulador.

Depois de reorganizar os parâmetros, o Thunk de Saída chama o emulador por meio de __os_arm64x_dispatch_call_no_redirect.

Vale a pena, neste momento, examinar a função do verificador de chamadas e detalhes sobre sua própria ABI personalizada. Essa é a aparência de uma chamada fB indireta:

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:

  • x11 fornece o endereço da função de destino a ser chamada (fB nesse caso). Pode não ser conhecido, neste momento, se a função de destino for Arm64EC ou x64.
  • x10 fornece um Thunk de Saída que corresponde à assinatura da função que está sendo chamada (fB neste caso).

Os dados retornados pelo verificador de chamadas dependerão da função de destino ser Arm64EC ou x64.

Se o destino for Arm64EC:

  • x11 retornará o endereço do código Arm64EC para chamar. Esse pode ou não ser o mesmo valor fornecido.

Se o destino for o código x64:

  • x11 retornará o endereço do Thunk de Saída. Isso é copiado da entrada fornecida em x10.
  • x10 retornará o endereço do Thunk de Saída, não perturbado da entrada.
  • x9 retornará a função x64 de destino. Esse pode ou não ser o mesmo valor em que foi fornecido por meio x11de .

Os verificadores de chamadas sempre deixarão os registros de parâmetro de convenção de chamada não perturbados, portanto, 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). Esses são os verificadores de chamadas de registros. Eles sempre preservarão registros acima e além dos padrões não voláteis: x0-x8, x15(chkstk) e .q0-q7

Thunks de entrada

O Thunks de Entrada cuida das transformações necessárias das convenções de chamada x64 para Arm64. Este é, essencialmente, o inverso de Exit Thunks, mas há mais alguns aspectos a serem considerados.

Considere o exemplo anterior de compilação fA, um Thunk de Entrada é gerado para que fA possa ser chamado pelo código x64.

Thunk de entrada 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 endereço da função de destino é fornecido pelo emulador em x9.

Antes de chamar o Thunk de Entrada, o emulador x64 exibirá o endereço de retorno da pilha no LR registro. Espera-se, então, que LR aponte para o código x64 quando o controle for 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á imposição de hardware para x64. Durante a execução do código x64, chamar erroneamente funções com uma pilha não assinada pode passar despercebida indefinidamente, até que alguma instrução de alinhamento de 16 bytes seja usada (algumas instruções SSE fazem) ou o código Arm64EC é chamado.

Para resolver esse possível problema de compatibilidade, antes de chamar o Thunk de Entrada, o emulador sempre alinhará o Ponteiro de Pilha para 16 bytes e armazenará seu valor original no x4 registro. Dessa forma, o Thunks de Entrada sempre começa a ser executado com uma pilha alinhada, mas ainda pode 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 baixos (64 bits) 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, os 16 bytes inteiros do XMMn registro são considerados não voláteis. Além disso, em x64 e XMM7 em registros não voláteis, XMM6 enquanto D6 e D7 (os registros arm64 correspondentes) são voláteis.

Para lidar com essas assimetrias de manipulação de registro SIMD, o Thunks de Entrada deve salvar explicitamente todos os registros SIMD considerados não voláteis no x64. Isso só é necessário em Thunks de Entrada (não em Exit Thunks) porque x64 é mais rigoroso que Arm64. Em outras palavras, as regras de salvamento/preservação do registro em x64 excedem os requisitos do Arm64 em todos os casos.

Para abordar a recuperação correta desses valores de registro ao desabilitar a pilha (por exemplo, setjmp + longjmp ou throw + catch), um novo opcode de desenrolamento foi introduzido: save_any_reg (0xE7). Esse novo opcode de desenrolamento de 3 bytes permite salvar qualquer registro de Uso Geral ou SIMD (incluindo os considerados voláteis) e incluir registros de tamanho Qn completo. Esse novo opcode é usado para as Qn operações de registro de derramamento/preenchimento acima. save_any_reg é compatível com save_next_pair (0xE6).

Para referência, veja abaixo as informações de desenrolamento correspondentes pertencentes ao Thunk de Entrada apresentado acima:

   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 é usada para inserir novamente o emulador, de volta ao código x64 (apontado por LR).

As funções Arm64EC têm os 4 bytes antes da primeira instrução na função reservada para armazenar informações a serem usadas no runtime. É nesses 4 bytes que o endereço relativo de Entry Thunk para a função pode ser encontrado. Ao executar uma chamada de uma função x64 para uma função Arm64EC, o emulador lerá os 4 bytes antes do início da função, mascarará os dois bits inferiores e adicionará esse valor ao endereço da função. Isso produzirá o endereço do Thunk de Entrada a ser chamado.

Thunks do ajustador

Thunks do ajustador são funções sem assinatura que simplesmente transferem o controle para (chamada final) para outra função, depois de executar alguma transformação para 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, em qualquer número, o Ajuster Thunks não tocará em nenhum registro que possa conter um parâmetro e não tocará na pilha. Isso é o que torna as funções sem assinatura do Adjustor Thunks.

O Thunks do ajustador pode ser gerado automaticamente pelo compilador. Isso é comum, por exemplo, com a herança múltipla do C++, em que qualquer método virtual pode ser delegado à classe pai, não modificado, além de um ajuste no this ponteiro.

Veja abaixo um exemplo real:

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

O thunk 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. Funções sem assinatura arm64, como Adjustor Thunks, precisam de um mecanismo diferente que possa lidar com funções sem assinatura.

O Thunk de Entrada de um Thunk do Ajustador usa o __os_arm64x_x64_jump auxiliar para adiar a execução do trabalho thunk de entrada real (ajustar os parâmetros de uma convenção para a outra) para a próxima chamada. É neste 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 Ajuster Thunk for uma função x64. Lembre-se de que, quando um Thunk de Entrada começar a ser executado, os parâmetros estarão no formato x64.

No exemplo acima, considere a aparência do código no Arm64EC.

Ajuster Thunk no 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 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 Fast-Forward

Alguns aplicativos fazem modificações em tempo de execução em funções que residem em binários que não possuem, mas dependem – normalmente binários do sistema operacional – com a finalidade de desviar a execução quando a função é chamada. Isso também é conhecido como conexão.

No alto nível, o processo de conexão é simples. Em detalhes, no entanto, a conexão é específica da arquitetura e bastante complexa, considerando as variações potenciais que a lógica de conexão deve abordar.

Em termos gerais, o processo envolve o seguinte:

  • Determine o endereço da função a ser enganada.
  • Substitua a primeira instrução da função por um salto para a rotina de gancho.
  • Quando o gancho for concluído, 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 1ª instrução: é uma boa ideia substituí-la por um JMP que tem o mesmo tamanho ou menor, para evitar substituir a parte superior da função enquanto outro thread pode estar executando-a em versão de pré-lançamento.
  • O tipo da primeira instrução: se a primeira instrução tiver alguma natureza relativa do computador, realocá-la poderá exigir a alteração de coisas como os campos de deslocamento. Como é provável que eles transbordem quando uma instrução é movida para um local distante, isso pode exigir o fornecimento de lógica equivalente com instruções diferentes completamente.

Devido a toda essa complexidade, é raro encontrar lógica de conexão 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 é um problema de compatibilidade de aplicativo. 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 parecer 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 Fast-Forward) 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 __declspec(hybrid_patchable) código obtém um ponteiro para uma determinada função, seja no GetProcAddress caso de exportação ou no &function caso, o endereço resultante conterá código x64. Esse código x64 passará para uma função x64 legítima, satisfazendo a maior parte da lógica de conexão atualmente disponível.

Considere o exemplo a seguir (tratamento de erro omitido para 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 pgma função na variável conterá o endereço do GetMachineTypeAttributesFFS de 's.

Este é um exemplo de uma sequência 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

A função FFS x64 tem um diálogo canônico e um epílogo, 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 5 instruções x64 emuladas entre duas funções Arm64EC. As funções FFS são especiais. As funções FFS não serão realmente executadas se permanecerem inativas. O auxiliar do verificador de chamadas verificará 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 tiver sido alterado de qualquer maneira possível, ele não será mais um FFS. A execução será transferida para o FFS alterado e executará 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 alcançará a chamada final para o código Arm64EC, que será executado após o gancho, assim como o aplicativo espera.

Criação do Arm64EC no Assembly

Os cabeçalhos do SDK do Windows e o compilador C podem simplificar o trabalho de criação do assembly Arm64EC. Por exemplo, o compilador C pode ser usado para gerar Thunks de Entrada e Saída para funções não compiladas do código C.

Considere o exemplo de um equivalente à função fD a seguir que deve ser criada no Assembly (ASM). Essa função pode ser chamada pelo código Arm64EC e x64 e o ponteiro de pfE função também 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 seria semelhante a:

#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 acima:

  • O Arm64EC usa a mesma declaração de procedimento e macros de prolog/epilog como Arm64.
  • Os nomes das funções devem ser encapsulados pela A64NAME macro. Ao compilar o código C/C++ como Arm64EC, o compilador marca como contendo o OBJArm64EC código Arm64EC. Isso não acontece com ArmASM. Ao compilar o código ASM, há uma maneira alternativa de informar ao vinculador que o código produzido é Arm64EC. Isso é prefixando o nome da função com #. A A64NAME macro executa essa operação quando _Arm64EC_ é definida e deixa o nome inalterado quando _Arm64EC_ não está definido. Isso possibilita compartilhar o código-fonte entre Arm64 e Arm64EC.
  • Primeiro pfE , o ponteiro de função deve ser executado pelo verificador de chamadas EC, juntamente com o Thunk de Saída apropriado, caso a função de destino seja x64.

Gerando Thunks de Entrada e Saída

A próxima etapa é gerar o Thunk fD de Entrada e o Thunk de Saída para pfE. O compilador C pode executar essa tarefa com o mínimo de esforço, 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 _Arm64XGenerateThunk palavra-chave informa ao compilador C para usar 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).

É recomendável colocar a geração thunk em seu próprio arquivo C. Estar em arquivos isolados torna mais simples confirmar os nomes de símbolos despejando os símbolos correspondentes OBJ ou até mesmo desmontando.

Thunks de Entrada Personalizada

As macros foram adicionadas ao SDK para ajudar a criar thunks de entrada personalizados, codificados à mão. Um caso em que isso pode ser usado é ao criar Thunks de Ajuste personalizado.

A maioria dos Thunks do Ajustador é gerada pelo compilador C++, mas eles também podem ser gerados manualmente. Isso pode ser encontrado em casos em que um retorno de chamada genérico transfere o controle para o retorno de chamada real, identificado por um dos parâmetros.

Abaixo está um exemplo no 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

Neste exemplo, o endereço da função de destino é recuperado do elemento de uma estrutura, fornecido por referência, por meio do 1º parâmetro. Como a estrutura é gravável, o endereço de destino deve ser validado por meio do CFG (Control Flow Guard).

O exemplo a seguir demonstra como o Thunk do Ajuster equivalente ficaria quando portado 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 acima não fornece um Thunk de Saída (no registro x10). Isso não é possível, pois o código pode ser executado para muitas assinaturas diferentes. Esse código está aproveitando o chamador que definiu x10 como Exit Thunk. O chamador teria feito a chamada visando uma assinatura explícita.

O código acima precisa de um Thunk de Entrada para resolver o caso quando o chamador for código x64. É assim que se cria o Thunk de Entrada correspondente, usando a macro para Thunks de Entrada personalizado:

    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, a funcionalidade em si (executando o ajuste de parâmetro) é inserida no Thunk de Entrada e o controle é transferido diretamente para o destino final, por meio do __os_arm64x_x64_jump auxiliar.

Código Arm64EC de Geração Dinâmica (Compilação JIT)

Nos processos arm64EC, há 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 Arm64EC contêm uma tabela de intervalo para páginas de código Arm64EC vs 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, isso implica:

  • Alocando memória gravável (VirtualAlloc).
  • Produzindo o código na memória alocada.
  • Proteger novamente a memória de Read-Write para Read-Execute (VirtualProtect).
  • Adicione entradas de função de desenrolamento para todas as funções geradas não triviais (não folhas) (RtlAddFunctionTable ou RtlAddGrowableFunctionTable).

Por motivos de compatibilidade triviais, qualquer aplicativo que execute essas etapas em um processo Arm64EC resultará no código sendo considerado código x64. Isso ocorrerá para qualquer processo usando o Java Runtime x64 não modificado, o runtime do .NET, o mecanismo JavaScript etc.

Para gerar o código dinâmico Arm64EC, o processo é basicamente o mesmo com apenas duas diferenças:

  • Ao alocar a memória, use mais VirtualAlloc2 recente (em vez de VirtualAlloc ou VirtualAllocEx) e forneça o MEM_EXTENDED_PARAMETER_EC_CODE atributo.
  • Ao adicionar entradas de função:
    • Eles devem estar no formato Arm64. Ao compilar o código Arm64EC, o RUNTIME_FUNCTION tipo corresponderá ao formato x64. Para o formato Arm64 ao compilar Am64EC, use o ARM64_RUNTIME_FUNCTION tipo em vez disso.
    • Não use a API mais antiga RtlAddFunctionTable . Sempre use a API mais RtlAddGrowableFunctionTable recente.

Abaixo está um exemplo de 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 um exemplo de adição de 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)
);