- Elevator traduz estaticamente um executável x86-64 inteiro para AArch64 sem informações de depuração, código-fonte nem suposições sobre o layout do binário
- Em vez de heurísticas para distinguir código e dados, ele cria um CFG superset que contém todas as interpretações possíveis de cada byte e remove apenas os caminhos que terminam em encerramento do programa
- Ele mapeia o estado x64 um a um para registradores AArch64 e trata desvios indiretos com uma tabela de consulta que leva do endereço original ao código traduzido
- O banco de tiles offline escreve a semântica das instruções x64 em templates C e depois os compila com LLVM 20 em sequências de bytes AArch64
- O resultado é um binário AArch64 autônomo, sem tradução em tempo de execução, com desempenho igual ou melhor que o JIT em modo usuário do QEMU no SPECint 2006
Objetivo do Elevator
- Elevator é um tradutor binário totalmente estático que leva um executável x86-64 inteiro para AArch64
- Não usa informações de depuração, código-fonte, padrões de código do binário original nem suposições sobre o layout do binário
- Tradutores estáticos existentes dependem de heurísticas ou fallback em tempo de execução para distinguir código de dados, mas o Elevator traduz previamente todos os bytes do executável original segundo cada interpretação possível
- Como qualquer byte pode ser dado, parte de opcode ou parte de argumento de opcode, ele constrói um CFG superset que inclui todos os fluxos de controle possíveis e remove apenas os caminhos que levam a encerramento excepcional do programa
- A saída é composta por um binário AArch64 autônomo que inclui o código traduzido, o binário x64 original, a tabela de consulta de endereços e um driver de runtime
- Depois que a tradução termina, ele pode executar sem JIT nem suporte de tradução em tempo de execução
- Traduzir o mesmo binário de entrada duas vezes gera a mesma sequência de bits na saída, e o alvo de teste, validação, certificação e assinatura criptográfica coincide com o código realmente distribuído
- O principal custo é o aumento do tamanho do código e, em troca, há maior possibilidade de validação antes da distribuição do que em emuladores ou compiladores JIT
- A avaliação incluiu todo o benchmark SPECint 2006 e binários feitos manualmente, e o desempenho ficou no mesmo nível ou acima da emulação em modo usuário do QEMU com aceleração JIT
- Os pesquisadores afirmam que vão liberar o projeto inteiro como open source quando o trabalho for concluído
Por que a tradução estática é necessária e quais são os limites atuais
- Quando o hardware muda de uma ISA para outra, software legado precisa ser levado à nova plataforma, e recompilar o código-fonte remanescente pode não ser suficiente
- Em código legado validado ou certificado, muitas vezes o alvo da certificação não é o código-fonte, mas um executável binário autoritativo específico e amplamente testado
- Reproduzir mais tarde, bit a bit, o mesmo binário a partir do código-fonte pode exigir as versões exatas do compilador, linker e sistema de build da época, o que é pouco realista
- Se o fabricante aplicou patches diretamente no binário sem passar pelo código-fonte, recompilar a partir do código arquivado pode reintroduzir bugs que já haviam sido corrigidos
- Abordagens existentes que tratam diretamente o binário combinam emulação, tradução estática e tradução dinâmica, mas componentes adicionais do sistema executados junto com o programa traduzido passam a fazer parte da base de código confiável
- O comportamento dinâmico pode variar conforme a ordem dos testes ou as entradas, tornando difícil verificar a confiabilidade total
- Horspool e Marovac mostraram em 1980 que, para reverter um executável, é preciso distinguir com certeza código de dados e que, na maioria das arquiteturas, isso é equivalente ao problema da parada, portanto insolúvel no caso geral
- Levantadores estáticos de binário existentes aproximam a distinção entre código e dados com heurísticas, e o problema fica especialmente sério ao prever destinos de transferências indiretas de controle
- O LLBT eleva instruções ARM para LLVM IR e recompila para a arquitetura de destino, mas usa heurísticas para detectar alvos de desvios indiretos e faz várias suposições sobre o binário de entrada
- Mesmo boas heurísticas falham em alguns casos, e como elevar corretamente o binário inteiro exige acertar todas as distinções entre código e dados, a chance de falha cresce à medida que o binário aumenta
- Métodos dinâmicos seguem o fluxo de instruções realmente executado, então conseguem lidar com recuperação de instruções e fluxo de controle indireto, mas não elevam instruções que não sejam alcançadas na execução concreta
- Em ISAs com instruções de tamanho variável, como x64, uma sequência de instruções pode conter outra sequência sobreposta, e um desvio para o meio de uma instrução multibyte pode fazer operandos previamente parte dela serem decodificados como instruções separadas
- Ataques ROP e ofuscação de código podem explorar essa característica
- O Rosetta II da Apple e o Prism da Microsoft combinam componentes de tradução antecipada e tradução dinâmica
- WYTIWYG e Polynima elevam estaticamente ao longo de caminhos de fluxo de controle identificados por profiling dinâmico e usam fallback dinâmico para coletar informações de fluxo de controle quando atingem endereços-alvo não observados
- O Elevator não decide quais bytes são código ou dados, nem se são palavra de instrução ou argumento; ele inclui cada byte do executável em todos os papéis possíveis como caminhos separados de fluxo de controle
- Essa abordagem aplica disassembly superset à recompilação estática e à compilação cross-ISA, trocando precisão de decodificação por crescimento de código
Fluxo de controle e preservação de estado
- O Elevator opera dentro do código AArch64 traduzido com o princípio de preservar todo o estado x64
- Ele mapeia registradores x64 e AArch64 um a um, emulando o estado de cada registrador x64 no registrador AArch64 correspondente
- A pilha x64 é emulada diretamente sobre a pilha AArch64, e a expansão normal da pilha durante a execução é tratada pelo sistema operacional
- Sem analisar a ABI do binário x64 de entrada, ele faz tradução de ABI apenas nos pontos em que a execução sai para código externo ou retorna dele, seguindo a ABI System V x64 e o AArch64 Procedure Call Standard
- Graças à preservação completa de estado e ao mapeamento um a um de registradores, cada instrução x64 pode ser traduzida de forma independente, sem conhecer as instruções anterior e posterior
- Cada deslocamento de byte executável do binário original é interpretado ao mesmo tempo como dado e como ponto de início potencial de uma sequência de instruções
- Todo alvo potencial que não possa ser analisado estaticamente, como saltos indiretos, callbacks e dispatch em runtime, ganha um ponto de aterrissagem correspondente dentro do binário reescrito
- Em runtime, os alvos são resolvidos por uma tabela de consulta incluída no binário final, que mapeia endereços de instruções originais para endereços do código traduzido
-
Exemplo de instruções sobrepostas
Listing 1mostra uma estrutura em que, ao iniciar a decodificação em.byte 0xB0, aparecemMOV AL, 0xC3seguido deRET, enquanto ao iniciar um byte depois, emReturnC2, aparece apenasRET- Ambas as decodificações podem ser alcançadas a partir do
jzanterior, e se o tradutor escolhesse apenas uma interpretação para os dois bytes, perderia um dos caminhos
-
Exemplo de desvio indireto calculado
Listing 2mostracall Labelcriando um endereço-base relativo à tabela, que é recuperado porpop rsi; em seguida soma-se um deslocamento dependente da entrada para formar o alvo dejmp rsi- O desvio pode aterrissar em uma das quatro instruções
inc eaxcolocadas a intervalos de 2 bytes no fluxo codificado - Um tradutor que reescreve apenas alvos de salto interpretáveis estaticamente não teria onde fazer esse desvio aterrissar
-
Chamadas, retornos e desvios
- Instruções
call,returnebranchnão podem ser expressas como tiles C porque a posição do endereço de retorno, o contador de programa e o layout de flags condicionais diferem entre x64 e AArch64 - Chamadas diretas empilham o endereço de retorno x64 original na pilha emulada e desviam para o tile traduzido do callee
- Chamadas indiretas verificam se o alvo está dentro do binário traduzido ou em uma biblioteca externa; alvos internos são traduzidos pela tabela de offset x64 para tile e então desviam para esse tile
- Para alvos externos, o endereço do gadget de tradução ABI reversa é colocado em
X30, para onde a biblioteca AArch64 retornará; em seguida é feita a tradução ABI de saída antes do desvio ao alvo externo - Retornos retiram o endereço de retorno de 8 bytes da pilha emulada, comparam com o intervalo do binário x64 embutido e, se for um retorno interno, traduzem o endereço pela tabela de consulta e desviam para o tile correspondente
- Desvios diretos têm o alvo conhecido no momento da tradução, e desvios condicionais são traduzidos em desvios condicionais AArch64 que testam os bits de flags x64 guardados em
X14 - Desvios indiretos emitem a mesma verificação de limites usada em chamadas indiretas e retornos; se o alvo for externo, fazem a tradução ABI de saída
- Instruções
Pipeline de tradução baseado em tiles
- A tradução do Elevator é dividida em três etapas: geração offline do banco de tiles, reescrita por binário de entrada e empacotamento final
- A etapa offline expressa a semântica das instruções x64 como funções C, especializa por combinação de operandos sob um mapeamento fixo de registradores x64 para AArch64 e compila com um LLVM 20 modificado para produzir sequências reutilizáveis de bytes AArch64
- A etapa por binário de entrada realiza o disassembly superset e percorre o CFG resultante
- Em cada nó, para cada instrução candidata encontrada, ela procura o tile pelo nome e concatena as sequências de bytes AArch64 correspondentes
- Categorias de instruções difíceis de expressar como tiles C, como transferências de fluxo de controle e fronteiras de ABI, são tratadas com pequenos templates escritos manualmente
- A etapa de empacotamento combina o código traduzido, o binário x64 original, a tabela de consulta de endereços e o driver de runtime para gerar um binário AArch64 executável de forma independente
-
Banco de tiles offline
- Escrever manualmente uma sequência equivalente de instruções AArch64 para cada instrução x64 não é prático
- Mesmo um único template como
ADD Reg8, Reg8se expande para 256 combinações concretas de registradores, e o conjunto completo de instruções x64 tem muitas variações de registradores, operandos de memória e endereçamento com imediatos - O Elevator escreve a semântica de cada instrução x64 como uma pequena função C, depois a especializa por combinação concreta de operandos e deixa o LLVM compilá-la para AArch64
- No exemplo de
ADD Reg8, Reg8, o template atualiza os 8 bits inferiores do registrador de destino com a soma de 8 bits e preserva os 56 bits superiores, reproduzindo a semântica de escrita parcial de registrador do x64 - Como
ADD Reg8, Reg8em x64 também altera as flags Carry, Parity, Auxiliary Carry, Zero, Sign e Overflow deRFLAGS, e uma função C com valor de retorno único não comporta isso, a atualização das flags é capturada em um tile de flags separado - Uma instrução x64 pode corresponder a um ou vários tiles, e na emissão eles são concatenados novamente em sequência para reconstruir a semântica completa
- O atributo
aarch64_custom_regdeclara em quais registradores AArch64 o LLVM deve colocar o valor de retorno e cada argumento - O mapeamento fixo é escolhido para alinhar o caráter callee-saved e caller-saved do x64 System V e do AAPCS64, reduzir a reorganização de registradores de argumentos inteiros e deixar registradores callee-saved livres do AArch64 reservados para futuro estado sombra
- Os bits de
RFLAGSe o arquivo de registradoresXMMdo x64 também são mantidos em registradores AArch64 dedicados sob o mesmo princípio de mapeamento um a um - O LLVM 20 modificado trata o atributo
aarch64_custom_regpor função e reclassifica, dentro do alocador de registradores, os registradores AArch64 que carregam o estado x64 emulado como callee-saved - O
TileGenpercorre os templates C, cria cópias especializadas para cada combinação permitida de operandos e sintetiza mecanicamente os atributos a partir das posições dos parâmetros do template e do mapeamento de registradores
-
Reescrita por binário de entrada
- Dado um binário x64 de entrada, a etapa por binário executa um disassembly superset e percorre o CFG resultante
- Em cada nó, o formatador monta nomes de tiles a partir do opcode e dos operandos da instrução decodificada; quando uma instrução exige vários tiles, combina vários nomes
- O x64 não tem restrição de alinhamento do ponteiro de pilha, mas o AArch64 exige alinhamento de 16 bytes ao usar o ponteiro de pilha em operandos de memória
- Se
RSPfosse mapeado diretamente paraSP, padrões comuns de código x64, comoPUSHconsecutivos em prólogos de função, poderiam causar exceções de alinhamento no AArch64 - O Elevator faz os tiles acessarem a pilha por um registrador separado,
X25, e concretizaSPnele apenas quando o tile realmente precisa disso - Tiles compilados pelo LLVM esperam alinhamento de 16 bytes de
SPna entrada; por isso, antes de executar tiles detectados como capazes de alocar espaço de spill,SPé alinhado para baixo e depois restaurado - Como tiles de cálculo de flags são relativamente caros, se as flags forem sobrescritas antes de serem lidas por uma instrução post-dominante, o cálculo de flags do nó atual é removido
- As instruções ainda não suportadas são principalmente as extensões vetoriais largas AVX2 e posteriores do x64; nesses pontos, é inserida uma instrução de interrupção no lugar do tile
- Na avaliação completa com SPECint 2006, a ISA inteira de inteiros x86-64 e o subconjunto SSE usado pelo SPECint bastaram para executar todos os benchmarks
- O suporte a mais instruções pode ser ampliado adicionando novos tiles, mas os autores consideram improvável que engenharia adicional acrescente novos insights científicos
Tratamento das fronteiras de ABI
- O Elevator suporta apenas binários com linkedição dinâmica
- Binários com linkedição estática podem incluir diretamente instruções específicas de arquitetura, como
CPUID, enquanto binários com linkedição dinâmica delegam isso àlibc, reduzindo a necessidade de tradução - Ao interagir com bibliotecas dinamicamente vinculadas, ele suporta a transição entre a ABI Linux x64 emulada e a ABI Linux AArch64 nativa para entrar e sair do código de biblioteca AArch64 nativo
- Os elementos centrais que exigem tradução de ABI são o posicionamento dos argumentos e a localização do endereço de retorno
- A ABI System V x64 usa seis registradores de argumento:
RDI,RSI,RDX,RCX,R8eR9; argumentos adicionais são passados na pilha a partir de[RSP+8] - A instrução
CALLdo x64 armazena o endereço de retorno em[RSP] - O AArch64 Procedure Call Standard usa oito registradores de argumento,
X0-X7, coloca os argumentos restantes na pilha em[SP]e guarda o endereço de retorno emX30 -
Chamada a bibliotecas externas
- Quando uma chamada x64 traduzida tem como alvo uma biblioteca externa, o layout dos argumentos precisa ser reorganizado para seguir a convenção de chamada AArch64
- Primeiro, subtrai-se 8 de
SPpara voltar ao alinhamento em 16 bytes, e o endereço de retorno x64 que já estava na pilha é colocado em[SP+0x8] - Os valores em
[SP+0x10]e[SP+0x18]são carregados emX6eX7, permitindo que a biblioteca AArch64 veja os potenciais 7º e 8º argumentos que o código x64 deixou na pilha - Os demais argumentos potenciais na pilha continuam a partir de
[SP+0x20], o que não coincide com as posições esperadas pelo AArch64 - Remover da pilha o endereço de retorno x64 e os valores movidos para
X6eX7não é seguro, porque eles podem não ser argumentos reais, mas espaço de spill do caller ou parte de uma struct alocada na pilha do caller - O Elevator não mexe no layout de pilha do caller; em vez disso, aloca
n×8bytes extras de espaço de pilha e copianargumentos potenciais de 8 bytes a partir da posição atual - O valor padrão de
né 10, e pode ser aumentado por configuração se o binário de entrada passar mais de 16 argumentos no total a funções de bibliotecas externas - Por fim, o endereço do gadget para onde a biblioteca externa deve retornar é armazenado em
X30
-
Retorno de bibliotecas externas
- Quando o controle volta ao gadget armazenado em
X30antes da chamada à biblioteca externa, soma-sen×8ao ponteiro de pilha para limpar os argumentos copiados anteriormente - O valor de retorno da biblioteca externa é movido de
X0paraX9, que é a posição deRAXesperada pelo código x64 emulado - O endereço de retorno x64 original e o padding relacionado são retirados da pilha, o endereço é traduzido e então é feito um desvio para lá, retomando a execução após o
CALLoriginal
- Quando o controle volta ao gadget armazenado em
-
Callbacks entrando no código traduzido
- Quando código AArch64 nativo chama o binário traduzido, a convenção de chamada AArch64 precisa ser convertida para a convenção de chamada x64
- O código x64 emulado espera o 7º e o 8º argumentos na pilha, não em
X6eX7; por isso,X7é empilhado primeiro e depoisX6, colocando-os nas posições de pilha esperadas pelo x64 - Se o callee não espera de fato o 7º e o 8º argumentos, esses valores empilhados não têm efeito
- O endereço de retorno colocado em
X30pela instrução branch-and-link AArch64 da biblioteca externa é empilhado na posição em que a instrução de retorno x64 espera encontrá-lo
-
Retorno do callback para a biblioteca externa
- Quando o código traduzido retorna do callback para a biblioteca externa, o processo de entrada é executado ao contrário
- O endereço de retorno é retirado da pilha,
X6eX7são empilhados, e o espaço de pilha alocado é limpo somando0x10ao ponteiro de pilha
1 comentários
Comentários no Hacker News
Não sei exatamente o que o JIT em modo usuário do QEMU faz, mas parece haver bastante espaço para melhorar
Em 2013, criei um motor JIT que convertia de x86-64 para aarch64 e, na época, consegui executar binários beta do Fedora aarch64 e reconstruir grande parte da porta aarch64 do Fedora em Linux x86_64
Também fiz o JIT no sentido oposto, de aarch64 → x86-64, e por diversão mostrei os dois JITs executando um ao outro em loopback dentro do mesmo processo, algo como x86-64 → aarch64 → x86_64
O JIT que eu fiz mapeava instruções e estado da CPU em uma relação de 1 para muitos, e ficava cerca de 2 a 5 vezes mais lento que código nativamente recompilado
Depois, ao comparar com o JIT do QEMU, pareceu que o QEMU ficava na faixa de 10 a 50 vezes mais lento
Infelizmente, como o licenciamento não era de código aberto, não posso divulgar o código para provar isso
Principalmente se você puder especializar o projeto para “apenas x86 para aarch64” e “apenas modo usuário”, há muito ganho de desempenho possível
O suporte a modo usuário do QEMU é quase um apêndice “que por acaso funciona” preso ao suporte de emulação de sistema, e toda a estrutura do JIT também segue o modelo “guest → representação intermediária → host”, o que é bom para suportar várias arquiteturas guest e várias arquiteturas host, mas dificulta aproveitar propriedades de combinações específicas guest/host, como “x86 tem poucos registradores inteiros, então dá para fazer alocação fixa” ou “se você colocar a CPU aarch64 no modo adequado, a semântica complicada de ponto flutuante sempre fica correta”
Além disso, no desenvolvimento do QEMU gasta-se mais tempo em “emular o novo recurso de arquitetura X” do que em encontrar oportunidades de otimização de desempenho, porque quem paga o custo de desenvolvimento considera isso mais importante
O fato de a seção
.textficar 50 vezes maior é enorme, mas parece um preço aceitável para obter uma tradução totalmente determinísticaEm muitos casos, o ganho de desempenho em relação à emulação deve pesar mais do que o incômodo do aumento de tamanho
Também é interessante que multithreading e tratamento de exceções não sejam impossíveis, apenas estejam fora do escopo deste projeto
Fico curioso se o próximo passo seria usar heurísticas para podar o espaço de possibilidades e reduzir o tamanho do binário
Isso quebraria a garantia da tradução, mas poderia tornar a portabilidade binária mais prática no mundo real
Esse tradutor é muito mais lento que Box64 ou FEX, e, a menos que você não possa usar JIT por algum motivo, ele é simplesmente uma escolha pior
Sempre tive curiosidade sobre como o tradutor lida com saltos indiretos
Ao analisar o binário, você só consegue descobrir trechos de código ligados por saltos diretos, em que o endereço de destino é conhecido
Então isso significaria que, toda vez que ocorre um salto indireto, ele precisa localizar a função alvo, traduzi-la se necessário e então voltar para o código traduzido; isso não fica lento?
Queria saber se existe um método mais rápido, se é possível alinhar o endereço da função traduzida com o da função original, ou se em vez disso se coloca no endereço original um salto para o código traduzido
jmpindireto para o endereço X, o bloco correspondente está na posição Y”Isso é mais lento do que um
jmpdireto sem tabela, mas no programa original os saltos indiretos já eram mais lentos para começar e normalmente não aparecem com frequência dentro dos loops críticos para desempenhoGosto muito da ideia de um grafo de fluxo de controle superset, mas quem for ler o artigo deveria saber o seguinte
O tempo de execução melhora cerca de 4,75 vezes (mais rápido que o QEMU, mas ainda consideravelmente mais lento que o Box64), o número de instruções executadas aumenta 7 vezes e o tamanho do binário aumenta 50 vezes
Ele emula a ABI x86 até antes das chamadas externas
É preciso emular boa parte do estado da CPU x86, como EFLAGS, e até
movcomplexos precisam ser calculados individualmenteSó suporta binários single-threaded
Não há tratamento de exceções nem unwinding de pilha
Não suporta o conjunto completo de instruções
Trabalho interessante
Não examinei em detalhe, mas offsets relativos ainda parecem poder ser um problema
De qualquer forma, como o resultado da geração de código terá tamanho diferente, parece que seria necessária algum tipo de camada de tradução ou MMU, afetando principalmente tabelas de salto e desvios internos
Eu lido mais com coisas dos anos 90, e os disassemblers fazem muitas suposições sobre o início e o fim do código
Mas às vezes há casos em que não dá para encontrar um bloco binário sem conhecimento prévio, como um ponteiro de entry point em posição fixa
Com algumas passagens, talvez fosse possível refinar o binário em “áreas que certamente são código”
Se “o Elevator considera todas as interpretações possíveis de cada byte e gera previamente uma tradução separada para cada uma das possibilidades [...] podando apenas os casos que levam a falha anormal”, então todos os programas reais com possibilidade de colisão acabam sendo podados?
Nesse caso, ainda haveria colisão, mas não seria igual a uma falha causada pela execução direta do código incorreto
Para mim, a parte mais interessante é a perspectiva de certificação
Em setores regulados como aviação e dispositivos médicos, muitas vezes não se pode usar JIT exatamente porque o código executado precisa ser o código certificado
Uma tradução estática que produza um binário assinável pode ser um avanço real, mesmo com o crescimento do código
Provavelmente nem LLMs teriam como ser aplicados em escala aí, mas esse aspecto quase não aparece na grande discussão sobre “IA no trabalho”
50 vezes não é razoável, é um desastre de cache
Isso pode consumir todo o ganho de desempenho obtido por evitar JIT
Se você agrupar o código quente em um só lugar, pode fazer com que o código não usado nunca seja carregado
As instruções em si não são tão grandes assim, e a CPU também faz otimizações enquanto executa
Ele consegue lidar com código auto-modificável?
Também fico curioso sobre por que só x86_64
Parece mais significativo converter programas de 32 bits, como jogos antigos
“Código auto-modificável e código compilado por JIT. O Elevator, como todos os reescritores de binário totalmente estáticos, não suporta código auto-modificável nem código compilado por JIT”
Hoje a seção
.texté em geral somente leitura, e não há motivo para que as exigências de segurança diminuamÉ uma contradição fundamental
Porque destrói o desempenho de linhas de cache e da predição de desvios no pipeline
Além disso, viola W^X, então em geral só deve ser usado em páginas de memória compatíveis com JIT
Por isso quase sempre deve ser evitado
Na época do 486 ou do P5 isso era usado em certa medida, como empregar imediatos como variáveis de loop interno, mas hoje isso quase não acontece
Para alcançar uma emulação ou tradução quase perfeita, há muitos casos-limite sujos do x86 que precisariam ser tratados
Onde está o código-fonte?