Partilhar via


Noções básicas sobre o ABI Arm64EC e o código de montagem

Arm64EC ("Emulation Compatible") é uma nova interface binária de aplicativo (ABI) 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 visã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 escrever código assembly direcionado à ABI Arm64EC.

Projeto do Arm64EC

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

O Arm64EC é principalmente aditivo ao ABI Classic Arm64. O ABI clássico mudou muito pouco, mas o ABI Arm64EC adicionou porções para permitir a interoperabilidade x64.

Neste documento, o original padrão Arm64 ABI é referido como "ABI clássico". Este termo evita a ambiguidade inerente a termos sobrecarregados como "Nativo". O Arm64EC é tão nativo quanto o ABI original.

Arm64EC vs. Arm64 ABI Clássico

A lista a seguir aponta onde o Arm64EC diverge do Arm64 Classic ABI.

Essas diferenças são pequenas mudanças quando vistas na perspetiva do que toda a interface ABI define.

Mapeamento de registos e registos bloqueados

Para habilitar a interoperabilidade no nível do tipo com o código x64, o código Arm64EC compila com as mesmas definições de arquitetura do pré-processador que o código x64.

Por outras palavras, definem-se _M_AMD64 e _AMD64_. Um dos tipos afetados por esta regra é a estrutura CONTEXT. A estrutura CONTEXT define o estado da CPU em um determinado ponto. Ele é 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 de CONTEXT x64 ou, em outras palavras, a estrutura CONTEXT como é definida durante a compilação x64.

Você deve usar essa estrutura para representar o contexto da CPU ao executar o código x64 e o código Arm64EC. O código existente não entende um conceito novo, 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 do Arm64 em registros x64.

Esse mapeamento também significa que você não pode usar nenhum registro Arm64 que não se encaixe no x64 CONTEXT. Seus valores podem ser perdidos sempre que uma operação for usada CONTEXT (e algumas operações podem ser assíncronas e inesperadas, como a operação de coleta de lixo de um Managed Language Runtime ou um APC).

Os cabeçalhos do Windows no SDK representam as regras de mapeamento entre os registadores Arm64EC e x64 com a estrutura ARM64EC_NT_CONTEXT. Esta estrutura é essencialmente uma união da estrutura CONTEXT, exatamente como é definida para x64, mas com uma camada adicional de registos Arm64.

Por exemplo, RCX mapeia para X0, RDX para X1, RSP para SP, RIP para PC, e assim por diante. Os registros x13, x14, , x23x24, x28, e v16 através v31 não têm representação e, portanto, não podem ser usados no Arm64EC.

Esta restrição de utilização do registo é a primeira diferença entre os ABIs Arm64 Classic e EC.

Verificadores de chamadas

Os verificadores de chamadas fazem parte do Windows desde que Control Flow Guard (CFG) foi introduzido no Windows 8.1. Os verificadores de chamadas são sanitizadores de endereços para ponteiros de função (antes de essas coisas serem chamadas de sanitizadores de endereços). Toda vez 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 própria função de verificador. Para CFG, ele executa uma verificação de validade em relação aos alvos de chamada conhecidos por serem bons. Binários compilados com /guard:cf também incluem essas informações.

Este exemplo mostra um uso do verificador de chamadas no Classic Arm64:

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 do CFG, o verificador de chamadas simplesmente retorna se o destino for válido, ou falha rapidamente o processo se não for. Os verificadores de chamadas têm convenções de chamadas personalizadas. Eles tomam o ponteiro de função em um registro não usado pela convenção de chamada normal e preservam todos os registros normais da convenção de chamada. Desta forma, eles não introduzem derramamento de registro em torno deles.

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 chamadas Arm64EC são construídos em cima dos verificadores Arm64 existentes, mas eles têm uma convenção de chamada personalizada ligeiramente diferente. Utilizam um parâmetro adicional e podem modificar o registo que contém o endereço de destino. Por exemplo, se o destino for o código x64, o controle deve ser transferido para a lógica de andaime 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

Ligeiras diferenças em relação ao Classic Arm64 incluem:

  • O nome do símbolo para o verificador de chamadas é diferente.
  • O endereço de destino é fornecido em x11 em vez de x15.
  • O endereço de destino (x11) é [in, out] em vez de [in].
  • Existe um parâmetro extra, fornecido através do x10, 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 através de um símbolo diferente do que é usado para os outros ABIs no Windows. Na ABI Classic Arm64, o símbolo do verificador de chamadas é __guard_check_icall_fptr. Este símbolo estará presente no Arm64EC, mas está lá para ser usado pelo código x64 vinculado estaticamente, e não pelo código Arm64EC em si. O código Arm64EC utilizará __os_arm64x_check_icall ou __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 interfaces binárias de aplicação (ABIs). CFG pode ser desativado em tempo de compilação, ou pode haver uma razão legítima para não executar uma verificação CFG mesmo quando CFG está ativado (por exemplo, o ponteiro de função nunca reside na memória RW). Para uma chamada indireta com verificação de CFG, o verificador __os_arm64x_check_icall_cfg deve ser usado. Se o CFG estiver desativado ou desnecessário, __os_arm64x_check_icall deve ser usado em vez disso.

Abaixo está uma tabela de resumo do uso do verificador de chamadas no Classic Arm64, 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 sem verificador de chamadas __guard_check_icall_fptr ou __guard_dispatch_icall_fptr
Arm64 Clássico Braço64 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 do ABI, dispor de código com CFG ativado (código com referência aos verificadores de chamadas CFG) não implica proteção CFG em tempo de execução. Os binários protegidos por CFG podem ser executados em níveis inferiores, em sistemas que não suportam CFG: o verificador de chamadas é inicializado com um auxiliar de no-op em tempo de compilação. Um processo também pode ter o CFG desativado por configuração. Quando o CFG está desativado (ou o suporte ao sistema operacional não está 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 desativada, o sistema operacional definirá __os_arm64x_check_icall_cfg da mesma forma que __os_arm64x_check_icall, que ainda fornecerá a verificação de arquitetura de destino necessária em todos os casos, mas não a proteção CFG.

Tal como acontece com CFG no Classic Arm64, a chamada para a função de destino (x11) deve seguir imediatamente a chamada para o Call Checker. O endereço do Call Checker 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 do que uma página. Para evitar ignorar a página de proteção da pilha que protege o final da pilha, __chkstk é chamado para garantir que todas as páginas na área alocada sejam verificadas.

__chkstk é geralmente chamado a partir do prólogo da função. Por esse motivo, e para uma 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 __chkstk, distintas, já que os thunks 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 nomeadas __chkstk. Para acomodar a compatibilidade com o código x64 pré-existente, __chkstk e o nome serão associados ao verificador de stack x64. O código Arm64EC utilizará __chkstk_arm64ec em vez disso.

A convenção de chamada personalizada para __chkstk_arm64ec é a mesma que a do Classic Arm64 __chkstk: x15 fornece o tamanho da alocação em bytes, que é dividido por 16. Todos os registos não voláteis, bem como todos os registos voláteis envolvidos na convenção de chamada padrão são preservados.

Tudo o que foi dito acima sobre __chkstk aplica-se igualmente ao __security_check_cookie e ao seu homólogo Arm64EC: __security_check_cookie_arm64ec.

Convenção de chamada variádica

Arm64EC segue a convenção de chamada ABI Classic Arm64, exceto para funções variádicas (também conhecidas como varargs ou funções com a palavra-chave de parâmetro de reticências (. . .) ).

Para o caso específico variádico, Arm64EC segue uma convenção de chamada muito semelhante à variádica x64, com apenas algumas diferenças. A lista a seguir mostra as principais regras para o variádico Arm64EC:

  • Apenas os quatro primeiros registos são utilizados para a passagem de parâmetros: x0, x1, x2, x3. Os parâmetros restantes são colocados na pilha. Esta regra segue exatamente a convenção de chamada variádica x64 e difere do Arm64 Classic, onde são usados os x0 registros x7.
  • Os parâmetros de ponto flutuante e SIMD passados pelo registro usam um registro de uso geral, não um registro SIMD. Esta regra é semelhante ao Arm64 Classic e difere do x64, onde os parâmetros FP/SIMD são passados em ambos um registo de propósito geral e um registo SIMD. Por exemplo, para uma função f1(int, …) chamada como f1(int, double), em x64, o segundo parâmetro é atribuído a ambos RDX e XMM1. No Arm64EC, o segundo parâmetro é atribuído a apenas x1.
  • Ao passar estruturas por valor através de um registro, aplicam-se regras de tamanho x64: estruturas com tamanhos exatos de 1, 2, 4 e 8 bytes são carregadas diretamente no registro de uso geral. Estruturas com outros tamanhos são transbordadas na pilha, e um ponteiro para o local transbordado é atribuído ao registo. Esta regra essencialmente converte a passagem por valor em passagem por referência a nível de baixa camada. No ABI Arm64 clássico, estruturas de qualquer tamanho até 16 bytes são atribuídas diretamente a registros de uso geral.
  • O x4 registro carrega um ponteiro para o primeiro parâmetro passado via stack (o quinto parâmetro). Esta regra não inclui estruturas derramadas devido às restrições de tamanho descritas anteriormente.
  • O x5 registro carrega o tamanho, em bytes, de todos os parâmetros passados por pilha (tamanho de todos os parâmetros, começando com o quinto). Esta 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 variádica, portanto, segue a convenção de chamada Classic Arm64. Em seguida, ele faz uma chamada variádica para pt_va_function com exatamente os mesmos parâmetros.

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 atribui seguindo as regras da convenção de chamada Classic Arm64:

  • 'f' é um duplo. Atribui a d0.
  • 'tc' é uma estrutura com um tamanho de 3 bytes. Atribui a x0.
  • ull1 é um inteiro de 8 bytes. Atribui a x1.
  • ull2 é um inteiro de 8 bytes. Atribui a x2.
  • ull3 é um inteiro de 8 bytes. Atribui a x3.

pt_va_function é uma função variádica, por isso segue as regras variádicas do Arm64EC descritas anteriormente:

  • 'f' é um duplo. Atribui a x0.
  • 'tc' é uma estrutura com um tamanho de 3 bytes. Ocupa a pilha e a sua localização é carregada em x1.
  • ull1 é um inteiro de 8 bytes. Atribui a x2.
  • ull2 é um inteiro de 8 bytes. Atribui a x3.
  • ull3 é um inteiro de 8 bytes. Atribui diretamente à pilha.
  • x4 carrega a localização de ull3 na pilha.
  • x5 carrega o tamanho de ull3.

O exemplo a seguir mostra a saída de compilação possível para pt_nova_function, que ilustra as diferenças de atribuição 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

Adições ABI

Para alcançar uma interoperabilidade transparente com o 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 essas adições:

Obstáculos de entrada e saída

Os thunks de entrada e saída traduzem a convenção de chamada Arm64EC (basicamente a mesma do 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. Estas 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âmetros ocorre da seguinte forma:

  • Arm64: a -> x0, b -> x1, c -> x2, d -> x3
  • x64: a -> RCX, b -> RDX, c -> R8, d -> r9
  • Arm64 -> traduçã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âmetros ocorre da seguinte forma:

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

Estes exemplos demonstram que a atribuição e a tradução de parâmetros variam de acordo com o tipo, mas também dependem dos tipos dos parâmetros anteriores na lista. Este 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 este motivo e são especificamente adaptados para cada assinatura de função individual.

Ambos os tipos de thunks são funções. O emulador chama automaticamente os thunks de entrada quando as funções x64 chamam as funções Arm64EC (execução Entra no Arm64EC). Os verificadores de chamadas invocam automaticamente os thunks de saída quando as funções Arm64EC chamam para funções x64 (execução Sai Arm64EC).

Ao compilar o código Arm64EC, o compilador gera um entry thunk para cada função Arm64EC, correspondente à sua assinatura. O compilador também gera um "exit thunk" 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.
  • Entrada thunk para fA
  • Saia do thunk para fB
  • Saída de thunk para fC

O compilador gera a thunk de entrada fA no caso de fA ser chamado por código x64. O compilador gera thunks de saída para fB e fC caso fB e fC sejam código x64.

O compilador pode gerar o mesmo thunk de saída várias vezes porque os gera no local de chamada em vez da função em si. Esta duplicação pode resultar numa 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 cheguem ao binário final.

Por exemplo, em um binário onde a função A Arm64EC chama a função BArm64EC , B não é exportado e seu endereço nunca é conhecido fora do A. É seguro eliminar o thunk de saída de A, juntamente com o thunk de entrada em relação a B. Também é seguro atribuir um alias a todos os thunks de entrada e saída que contenham o mesmo código, mesmo que tenham sido gerados para funções distintas.

Sair de thunks

Usando as funções de exemplo fA, fB e fC na seção anterior, o compilador gera tanto os thunks de saída fB quanto fC da seguinte maneira:

Saia do 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

A saída do 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 registos GP remanescentes seja remodelada, resultado das diferentes regras de atribuição do Arm64 e x64. Você também pode ver que x64 atribui apenas quatro parâmetros aos registros, assim, o quinto parâmetro deve ser colocado na pilha.

No fC caso, o segundo parâmetro é uma estrutura de 3 bytes de comprimento. O Arm64 permite que qualquer estrutura de tamanho seja atribuída diretamente a um registro. x64 só permite tamanhos 1, 2, 4 e 8. Este Exit Thunk deve transferir este struct do registo para a pilha e em vez disso atribuir um ponteiro ao registo. Essa abordagem ainda consome um registro (para carregar o ponteiro), portanto, não altera as atribuições para os registros restantes: nenhuma reorganização de registro acontece 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 Exit Thunks:

  • O compilador os nomeia não pelo nome da função que eles traduzem de e para, mas sim pela assinatura que eles abordam. Esta convenção de nomenclatura torna mais fácil encontrar redundâncias.
  • O Call Checker define o registo x9 para conter o endereço da função de destino (x64). O Exit Thunk chama o emulador, passando x9 sem alterações.

Depois de reorganizar os parâmetros, o Exit Thunk chama o emulador através de __os_arm64x_dispatch_call_no_redirect.

Neste ponto, vale a pena rever a função do verificador de chamadas e seu ABI personalizado. Eis 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 ligar para o verificador de chamadas:

  • x11 fornece o endereço da função de destino a ser chamada (fB neste caso). Neste ponto, o verificador de chamadas pode não saber se a função de destino é Arm64EC ou x64.
  • x10 fornece um Exit Thunk correspondente à assinatura da função que está sendo chamada (fB neste caso).

Os dados que o verificador de chamadas retorna dependem se a função de destino é Arm64EC ou x64.

Se o alvo for Arm64EC:

  • x11 retorna o endereço do código Arm64EC para chamada. Este valor pode ser o mesmo que o fornecido.

Se o destino for o código x64:

  • x11 retorna o endereço do Exit Thunk. Este endereço é copiado da entrada fornecida em x10.
  • x10 retorna o endereço do Thunk de saída, inalterado pela entrada.
  • x9 Retorna a função x64 de destino. Este valor pode ser o mesmo fornecido por x11.

Os verificadores de chamadas sempre deixam os registros de parâmetros da convenção de chamada intactos. 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 estes registos para além dos registos não voláteis padrão: x0-x8, x15(chkstk), e q0-q7.

Entrada Thunks

Os Entry Thunks cuidam das transformações necessárias das convenções de chamada de x64 para Arm64. Esta transformação é essencialmente o reverso dos Exit Thunks, mas envolve mais alguns aspetos a considerar.

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 Entry Thunk, o emulador x64 exibe o endereço de retorno da pilha no LR registro. LR espera-se então que aponte para o código x64 quando o controle for transferido para o Entry Thunk.

O emulador também pode executar outro ajuste na pilha, dependendo dos seguintes fatores: Os ABIs Arm64 e x64 definem um requisito de alinhamento de pilha em que a pilha deve estar alinhada a 16 bytes no momento 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. Ao executar o código x64, a chamada errónea de funções com uma pilha não alinhada pode passar despercebida indefinidamente, a menos que alguma instrução de alinhamento de 16 bytes seja usada, como acontece com algumas instruções SSE, ou quando o código Arm64EC é chamado.

Para resolver esse possível problema de compatibilidade, antes de chamar o Entry Thunk, o emulador sempre alinha para baixo o ponteiro de pilha para 16 bytes e armazena o seu valor original no registo x4. Desta forma, os Entry Thunks iniciam sempre a execução com uma pilha alinhada, mas ainda podem referenciar corretamente os parâmetros passados na pilha, via 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 baixos 8 bytes (64 bits) do registro são considerados não voláteis. Por outras palavras, apenas a parte Dn dos registos Qn é não volátil. Em x64, todos os 16 bytes do registro XMMn são considerados não voláteis. Além disso, em x64, XMM6 e XMM7 são registos não voláteis, enquanto D6 e D7 (os registos Arm64 correspondentes) são voláteis.

Para resolver essas assimetrias de manipulação de registro SIMD, os Thunks de entrada devem salvar explicitamente todos os registros SIMD que são considerados não voláteis em x64. Este armazenamento só é necessário em Thunks de entrada (não em Thunks de saída) porque o x64 é mais rigoroso do que o Arm64. Por outras palavras, as regras de poupança e preservação de registos em x64 excedem os requisitos do Arm64 em todos os casos.

Para abordar a recuperação correta desses valores de registro ao desenrolar a pilha (por exemplo, setjmp + longjmp, ou throw + catch), um novo opcode de desenrolar foi introduzido: save_any_reg (0xE7). Este novo opcode de desempacotamento de 3 bytes permite guardar qualquer registo de uso geral ou SIMD (incluindo os que são considerados voláteis) e incluir os registos Qn de tamanho completo. Este novo opcode é utilizado para registar Qn derrames e operações de enchimento. save_any_reg é compatível com save_next_pair (0xE6).

Para referência, as seguintes informações de unwind pertencem ao Entry Thunk apresentado 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 entra novamente 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 tempo de execução. Nesses quatro bytes, 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 lê os quatro bytes antes do início da função, mascara os dois bits inferiores e adiciona essa quantidade ao endereço da função. Este processo produz o endereço do Entry Thunk a ser chamado.

Ajustador Thunks

Adjustor Thunks são funções sem assinatura que transferem o controle para (tail-call) outra função. Antes de transferir o controle, eles transformam um dos parâmetros. O tipo de 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. Os Adjustor Thunks não alteram nenhum registo que potencialmente contenha um parâmetro e não afetam a pilha. O Adjustor Thunks tem uma característica que os torna funções sem assinaturas.

O compilador pode gerar automaticamente Adjustor Thunks. Este padrão é comum, por exemplo, com herança múltipla em C++, onde qualquer método virtual pode delegar à classe base sem necessidade de modificação, exceto para 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

O thunk subtrai 8 bytes do ponteiro this e encaminha a chamada para a classe pai.

Em resumo, as funções Arm64EC chamáveis a partir de funções x64 devem ter um Entry Thunk associado. O Entry Thunk é específico da assinatura. As funções sem assinatura do Arm64, como o Adjustor Thunks, precisam de um mecanismo diferente que possa lidar com funções sem assinatura.

O "Entry Thunk" de um "Adjustor Thunk" utiliza o auxiliar __os_arm64x_x64_jump para adiar a execução do trabalho real do "Entry Thunk" (que é ajustar os parâmetros de uma convenção para 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 caso o alvo do Adjustor Thunk seja 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.

Adjustor 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

Porta-malas de entrada do Adjustor 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

Algumas aplicações realizam modificações em tempo de execução em funções que residem em binários que não lhes pertencem mas dos quais dependem – geralmente binários do sistema operativo – com a finalidade de desviar a execução quando a função é chamada. Este processo também é conhecido como gancho.

A um nível elevado, o processo de hooking é simples. Em detalhes, no entanto, o hooking é específico da arquitetura e bastante complexo, dadas as variações potenciais que a lógica de hooking 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 de gancho.
  • Quando o gancho estiver pronto, volte à lógica original, que inclui executar a instrução original deslocada.

As variações surgem de coisas como:

  • O tamanho da primeira instrução: é uma boa ideia substituí-lo por um JMP do mesmo tamanho ou menor, para evitar substituir a parte superior da função enquanto outro thread pode estar executando-o em voo.
  • O tipo da primeira instrução: Se a primeira instrução tiver alguma natureza relativa ao PC, realocá-la pode exigir a alteração de coisas como os campos de deslocamento. Como eles podem transbordar quando uma instrução é movida para um lugar distante, essa alteração pode exigir o fornecimento de lógica equivalente com instruções completamente diferentes.

Devido a toda essa complexidade, uma lógica de gancho robusta e genérica é rara de encontrar. Freqüentemente, a lógica presente nos 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 o quanto isso é um problema de compatibilidade de aplicativos. Mesmo uma simples alteração no código ou otimizações do compilador pode tornar os aplicativos inutilizáveis se o código não tiver mais a aparência exata esperada.

O que aconteceria com esses aplicativos se encontrassem o código Arm64 ao configurar um gancho? Falhariam com toda a certeza.

As funções de sequência de avanço rápido (FFS) abordam este requisito de compatibilidade no Arm64EC.

FFS são funções x64 muito pequenas que não contêm lógica real e tail-call 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, seja pelo GetProcAddress caso de exportação, ou pelo &function caso __declspec(hybrid_patchable) , o endereço resultante contém código x64. Esse código x64 passa por uma função x64 legítima, satisfazendo a maior parte da lógica de gancho atualmente disponível.

Considere o seguinte exemplo (tratamento de erros omitido por 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 canónico e um epílogo, terminando com uma chamada de cauda (jump) para a função GetMachineTypeAttributes efetiva 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 não funcionam de fato se permanecerem inalteradas. O auxiliar do verificador de chamadas verifica eficientemente se o FFS não foi alterado. Se for esse o caso, a chamada é transferida diretamente para o destino real. Se o FFS for alterado de alguma forma possível, então ele não é 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 gancho.

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 então é executado após o gancho, exatamente como o aplicativo espera.

Criação de Arm64EC em linguagem assembly

Os cabeçalhos do SDK do Windows e o compilador C simplificam o trabalho de criação do assembly Arm64EC. Por exemplo, é possível utilizar 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 um equivalente à seguinte função fD que você deve criar em assembly (ASM). Tanto o código Arm64EC quanto o x64 podem chamar essa função, e o ponteiro da pfE função 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 em ASM pode ser semelhante ao 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:

  • Arm64EC usa a mesma declaração de procedimento e macros prolog/epilog que Arm64.
  • Envolva os nomes de funções com a macro A64NAME. Quando você compila o código C ou C++ como Arm64EC, o compilador marca o OBJ como ARM64EC contendo o código Arm64EC. Esta marcação não acontece com ARMASM. Ao compilar o código ASM, você pode informar ao vinculador que o código produzido é Arm64EC 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. Essa abordagem torna possível compartilhar o código-fonte entre Arm64 e Arm64EC.
  • Deve-se primeiro passar o ponteiro da função pfE através do verificador de chamada EC, juntamente com o thunk de saída apropriado, caso a função de destino seja x64.

Geração de thunks de entrada e saída

O próximo passo é gerar o thunk de entrada para fD 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 keyword instrui o compilador C a utilizar a assinatura da função, ignorar o corpo e gerar um "exit thunk" (quando o parâmetro é 1) ou um "entry thunk" (quando o parâmetro é 2).

Coloque a geração thunk em seu próprio arquivo C. Estar em ficheiros separados torna mais fácil confirmar os nomes dos símbolos, exportando os símbolos OBJ correspondentes ou até mesmo realizando a desmontagem.

Thunks de entradas personalizadas

O SDK inclui macros que ajudam a criar cliques de entrada personalizados e codificados manualmente. Você pode usar essas macros ao criar ajustes de thunks personalizados.

A maioria dos thunks de ajuste são gerados pelo compilador C++, mas você também pode gerá-los manualmente. Você pode gerar manualmente um 'adjustor thunk' 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 ajustador thunk 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 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 Control Flow Guard (CFG) deve validar o endereço de destino.

O exemplo a seguir mostra como portar o ajustador equivalente thunk 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. Este código aproveita a configuração do chamador x10 para o thunk de saída. O originador realiza a chamada visando uma assinatura específica.

O código anterior precisa de um entry thunk para resolver o caso se o chamador for código x64. O exemplo a seguir mostra como redigir a "thunk" de entrada correspondente, utilizando a macro para "thunks" de entrada personalizadas.

    ARM64EC_CUSTOM_ENTRY_THUNK A64NAME(MyAdjustorThunk)
    ldr     x9, [x0, 0x18]
    adrp    xip0, __os_arm64x_x64_jump
    ldr     xip0, [xip0, __os_arm64x_x64_jump]
    br      xip0
    LEAF_END

Ao contrário de outras funções, esta entrada thunk não transfere eventualmente o controlo para a função associada (o thunk ajustador). Neste caso, o thunk de entrada incorpora a própria funcionalidade (realizando o ajuste de parâmetro) e transfere o controlo diretamente para o destino final através do __os_arm64x_x64_jump auxiliar.

Geração dinâmica (compilação JIT) do código Arm64EC

Nos 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. Os binários x64 são todos x64, e os binários Arm64EC contêm uma tabela de intervalo para páginas de código Arm64EC versus x64.

E o código gerado dinamicamente? Os compiladores Just-in-time (JIT) geram código em tempo de execução que não é apoiado por nenhum arquivo binário.

Normalmente, este processo envolve as seguintes etapas:

  • Alocação de memória gravável (VirtualAlloc).
  • Produzindo o código na memória alocada.
  • Reprotegendo a memória de leitura-gravação para leitura-execução (VirtualProtect).
  • Adicionando entradas de função de desenrolamento para todas as funções geradas não triviais (não folha) (RtlAddFunctionTable ou RtlAddGrowableFunctionTable).

Por razões triviais de compatibilidade, se um aplicativo executa essas etapas em um processo Arm64EC, o sistema operacional considera o código como código x64. Esse comportamento acontece para qualquer processo que usa o não modificado x64 Java Runtime, .NET runtime, JavaScript engine 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 mais recente VirtualAlloc2 (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 corresponde ao formato x64. Para o formato Arm64 ao compilar Arm64EC, use em vez disso o tipo ARM64_RUNTIME_FUNCTION.
    • Não use a API mais antiga RtlAddFunctionTable . Use sempre a API mais recente RtlAddGrowableFunctionTable .

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)
);