Aprendendo assembly x86-64
(gpfault.net)- Apresentação do primeiro artigo de uma série introdutória sobre assembly x86-64
- Explica a instalação das ferramentas e a estrutura básica com foco em sistemas modernos de 64 bits
- Orienta o uso de Flat Assembler (FASM) e WinDbg como principais ferramentas de desenvolvimento e depuração
- Inclui um resumo dos conhecimentos essenciais para uso prático, como formato PE, importação de DLL e convenção de chamada do Windows
- Explicação centrada na experiência prática de escrever um programa simples de encerramento e executar o procedimento de depuração
Introdução e importância
- Ao ter o primeiro contato com assembly x86, era comum nas universidades aprender em ambientes antigos, baseados em 16 bits, DOS e memória segmentada
- Como hoje os processadores de 64 bits são o padrão, esta série trata apenas do ambiente x86-64 realmente usado na prática, excluindo por completo os elementos legados
- Este tutorial se concentra no desenvolvimento de programas de 64 bits que rodam no ambiente do sistema operacional Windows
- Começa com código mínimo que acessa o sistema operacional diretamente, sem usar bibliotecas
- O texto é voltado para desenvolvedores que querem aprender assembly pela primeira vez e assume conhecimento básico de C/C++
Preparando as ferramentas de desenvolvimento
Assembler
- A CPU só consegue interpretar código de máquina, que é difícil para humanos entenderem; a linguagem assembly é sua representação em um formato legível por pessoas
- O programa que converte linguagem assembly em código de máquina é o assembler
- Não existe um padrão único para assembly x86-64, e cada assembler tem diferenças de sintaxe e comportamento
- Nesta série é usado o Flat Assembler (FASM), por ser pequeno, fácil de usar e oferecer um sistema de macros poderoso e um editor
Depurador
- Para analisar o código assembly escrito e observar o fluxo de execução, o depurador é uma ferramenta indispensável
- A recomendação é o WinDbg, que permite verificar e manipular separadamente registradores, memória e código assembly
- Ele pode ser instalado selecionando apenas os componentes necessários no Windows 10 SDK
- Com o depurador, é possível observar diretamente o estado interno do programa, a estrutura da memória e as mudanças nos registradores
A perspectiva da programação em assembly
Estrutura da CPU e conjunto de instruções
- A CPU só pode executar um conjunto limitado de operações de acordo com um conjunto de instruções específico
- Uma instrução é a unidade básica de trabalho que a CPU consegue executar
- Cada instrução opera de forma muito simples com seus parâmetros, como armazenar valores ou fazer operações aritméticas
- Em programação de baixo nível e depuração, o ponto central é entender que essa estrutura é a base de todos os conceitos de alto nível
Registradores
- Registradores são áreas de memória dedicadas e extremamente rápidas embutidas na CPU
- No x86-64, existem 16 registradores de propósito geral, todos com 64 bits
- Cada registrador pode ser acessado parcialmente em unidades de byte, word e doubleword
| Registrador | Byte inferior | Word inferior | Doubleword inferior |
|---|---|---|---|
| rax | al | ax | eax |
| rbx | bl | bx | ebx |
| rcx | cl | cx | ecx |
| rdx | dl | dx | edx |
| rsp | spl | sp | esp |
| rsi | sil | si | esi |
| rdi | dil | di | edi |
| rbp | bpl | bp | ebp |
| r8~r15 | r8b~r15b | r8w~r15w | r8d~r15d |
rspfunciona como ponteiro da pilha, ersi/rdiatuam como índices no processamento de strings, então alguns registradores têm finalidades especiaisripé o ponteiro de instrução, erflagsé um registrador especial que guarda flags de estado do resultado de operações
Memória e endereços
- A memória funciona como um arranjo contínuo de bytes a partir do índice 0
- Em arquiteturas x86 antigas, o esquema segmento-offset era obrigatório, mas no x86-64 toda a memória é tratada como um espaço de endereçamento flat
- Na prática, o sistema operacional e o hardware mapeiam dinamicamente o espaço de endereços virtuais de cada processo para a memória física
- Ou seja, mesmo que o endereço virtual seja o mesmo, em processos diferentes ele pode corresponder a memórias físicas diferentes
- Instruções e dados coexistem na mesma memória (arquitetura de von Neumann), em contraste com arquiteturas Harvard como a AVR usada em Arduino, que armazena os dados separadamente
Escrevendo o primeiro programa em assembly
- Depois de instalar o FASM, o exercício é escrever e compilar o programa simples abaixo
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
ret
Explicação do código
format PE64 NX GUI 6.0: especifica o formato do executável que o FASM vai gerar; aqui é um PE (Portable Executable) GUI de 64 bitsentry start: define o entry point do programa; a execução começa na posição do rótulostartsection '.text' code readable executable: indica que esta é a seção de código do PE, em uma área executávelstart:: dá nome ao ponto de entrada definido anteriormenteint3: breakpoint para depuração, usado para pausar o programa e inspecionar seu estadoret: instrução que retira um endereço da pilha e transfere o controle para ele; neste programa, responde com encerramento imediato
Exercício de depuração
-
No WinDbg, abra o executável (.exe) do programa acima e prepare várias janelas, como desassembly e registradores
-
Pressione F5 para fazer o programa chegar ao breakpoint e, a cada F8, execute uma instrução por vez, passo a passo
-
É possível observar em tempo real as mudanças nos registradores, como
rip -
Depois da execução de
ret, o controle é devolvido ao sistema operacional, que em seguida chamaRtlExitUserThread, encerrando a thread e o processo -
Atenção: ao encerrar apenas com
ret, o processo pode permanecer ativo dependendo da existência de outras execuções em segundo plano além da thread, portanto em um encerramento normal, o ideal é sempre chamar ExitProcess
Formato PE e importação de DLL
Visão geral da estrutura de importação de funções de DLL
- Funções WinAPI como ExitProcess ficam em KERNEL32.DLL
- Para usar essas funções externas, é necessário montar a tabela de importação do executável (seção
.idata) - A Import Directory Table (IDT) da seção idata contém informações como o nome da DLL e os endereços (RVA) de nome da função, IAT e ILT
- A IAT (Import Address Table) é sobrescrita em tempo de execução pelo loader do sistema operacional com o endereço real da função
- A Hint/Name Table é composta pelo nome de cada função e suas informações de hint
Exemplo de definição da seção .idata no FASM
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name: db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
- db/dw/dd/dq : inserem valores em unidades de byte/word/doubleword/quadword (8 bytes)
- rva : calcula o endereço virtual relativo (Relative Virtual Address) de um símbolo
- É possível referenciar funções de DLL montando manualmente a IAT e a Name Table
Convenção de chamada do Windows em 64 bits (MS x64 Calling Convention)
- É a convenção padrão que define como passar argumentos e usar a pilha em chamadas de função
- No Windows de 64 bits, usa-se a Microsoft x64 Calling Convention
- Principais características:
- O ponteiro da pilha deve estar sempre alinhado em 16 bytes
- Os 4 primeiros argumentos inteiros/ponteiros usam os registradores rcx, rdx, r8, r9
- Os 4 primeiros argumentos de ponto flutuante vão em xmm0~xmm3
- Argumentos adicionais usam a pilha
- Independentemente da quantidade de argumentos, é preciso reservar 32 bytes de shadow space na pilha
- A limpeza da pilha é responsabilidade de quem chama
Exemplo de chamada de ExitProcess
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
sub rsp, 8 * 5
xor rcx, rcx
call [ExitProcess]
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
Análise do novo código
-
sub rsp, 8 * 5: ajusta o ponteiro da pilha (reserva 40 bytes), tratando de uma vez o alinhamento em 16 bytes e a reserva do shadow space -
xor rcx, rcx: atribui 0 ao registradorrcx, que é o primeiro argumento (usado como código de saída) -
call [ExitProcess]: salta para o endereço da funçãoExitProcessefetivamente registrado na import table -
Ao executar passo a passo no WinDbg, é possível verificar diretamente as mudanças no ponteiro da pilha (
rsp), no registradorrcxe no fluxo de encerramento do processo
Encerramento
- Este artigo apresenta, com foco prático, o fluxo geral de assembly x86-64, desde a configuração das ferramentas básicas até formato PE, importação de DLL, convenção de chamada x64, criação do primeiro programa e depuração
- A próxima parte vai tratar da implementação de funções mais variadas e de código real
1 comentários
Comentários no Hacker News
Queria compartilhar um projeto que venho desenvolvendo há alguns anos
https://asm-editor.specy.app
É uma IDE online interativa que oferece suporte a várias linguagens assembly, como M68K, MIPS, RISC-V e x86
Tem muitos recursos voltados ao ensino de programação em assembly
Também pode ser incorporada em outros sites
Eu não sabia que existia acesso direto ao byte de baixo endereço em registradores de indexação de ponteiro, por exemplo, em 16/32 bits
si/esipodendo ser acessados comosilÉ um conceito parecido com acessar
ala partir deax/eaxFiquei curioso se realmente existem opcodes novos adicionados no x86_64
Acho que vou precisar conferir novamente a especificação da plataforma
Pergunto isso por pura curiosidade
Compartilho um material introdutório sobre assembly que escrevi
https://www.nayuki.io/page/a-fundamental-introduction-to-x86-assembly-programming
Fiquei curioso para saber se conseguiria deixar o dispatch do meu emulador de CPU mais rápido em assembly do que em C++, então tentei otimizar em assembly
Rodei um programa de Fibonacci, mas o resultado nem chegou perto
No fim, só fiz o merge com a opção desativada por padrão
Mesmo assim, acredito que certamente exista uma forma de deixá-lo mais rápido
https://github.com/libriscv/libriscv/blob/master/lib/libriscv/amd64/inaccurate_dispatch.nasm
Aprendendo maneiras de acessar memória, consegui melhorar um pouco o desempenho
Reduzi a jump table de 64 bits para 32 bits e a coloquei na seção
.text, para usar acesso relativo a RIPO programa de Fibonacci não precisava de muitos bytecodes
Eu realmente gostaria de ouvir dicas sobre o que mais pode ser melhorado
Não conheço bem o contexto, mas acho possível que a diferença não venha do mecanismo de dispatch em si, como a forma de fazer fetch das instruções, e sim da diferença na implementação real das instruções
Como estratégia de otimização, dá para mapear os registradores emulados diretamente para registradores reais do x86-64 e evitar totalmente gravá-los na memória
Assim, operações como
addpodem ser feitas diretamente, sem buscar os valores na memóriaSó que essa abordagem torna a escrita do emulador bem mais trabalhosa
É um material introdutório a assembly x86 com prática no navegador
Dá para executar os exemplos imediatamente, sem precisar de configuração local
https://shikaan.github.io/assembly/x86/guide/2024/09/08/x86-64-introduction-hello.html
Aliás, fui eu que escrevi esse material
Parece ser um esquema que monta diretamente com NASM e depois executa o binário, então isso me fez pensar na questão de segurança
Só pela foto de perfil, achei que fosse o junferno
Mesmo só mexer um pouco com assembly já aprofunda bastante a compreensão geral, então sempre acaba sendo uma boa experiência
Não precisa chegar ao ponto de fazer um grande projeto; recomendo criar coragem e tentar fazer um pouco por conta própria
Compartilho o link da discussão no HN da época (2020)
https://news.ycombinator.com/item?id=24195627
Ainda bem que usa a sintaxe de assembly no estilo Intel
Tenho vontade de fazer alguma coisa em assembly, mas não me vem nenhuma ideia específica
É um jogo de resolver puzzles com uma espécie de pseudo-assembly
Acho que esse tipo de jogo pode ajudar a matar a vontade de brincar com assembly