Internals de ELF e Binutils: vendo o que o compilador produziu
1. TL;DR
- Você vai aprender como um arquivo ELF (Executable and Linkable Format) é estruturado e como essa estrutura mapeia para a memória em runtime.
- Vai praticar com
readelf,objdump,nm,objcopy,xxdehexdumppara responder perguntas práticas:- “Qual é o entry point?”
- “Onde fica esta função?”
- “Quais bytes correspondem a esta instrução?”
- “Por que este endereço aparece no disassembly, mas não no arquivo?”
- Vai construir um modelo mental de seções vs segmentos, símbolos e relocações.
Se você consegue ler a estrutura ELF com confiança, engenharia reversa e depuração ficam muito mais fáceis. Você para de chutar!
2. Pré-requisitos
riscv64-unknown-elf-gccreadelfobjdumpnmobjcopyxxdhexdump
3. ELF em um diagrama
Pense no ELF como tendo duas “visões” diferentes dos mesmos dados:
- Seções: visão do desenvolvedor / linker (boa para símbolos, disassembly e análise estática)
- Segmentos: visão do loader (o que é mapeado na memória quando o programa roda)
| |
4. As ferramentas “centrais” e para que serve cada uma
4.1. readelf (estrutura)
- Lê headers, seções, segmentos, símbolos, relocações.
- Melhor ferramenta para responder “o que há dentro deste ELF?”
4.2. objdump (conteúdo)
- Disassemble do código (
-d) - Dump de bytes de seções (
-s) - Mostra tabela de símbolos (
-t)
4.3. nm (símbolos)
- Lista rápida de “símbolos e endereços”
4.4. objcopy (transformação)
- Converte ELF → binário bruto (
-O binary) - Extrai uma seção
4.5. xxd / hexdump (bytes brutos)
- Verifica hipóteses no nível de bytes (endianness, offsets)
5. Na prática: inspecione um ELF bare-metal
5.1. Compile um ELF de exemplo
Vamos inspecionar um pequeno programa bare-metal que escreve na UART:
| |
5.2. Mostre o header ELF
| |
Procure por:
- Class:
ELF32 - Machine:
RISC-V - Entry point address: a primeira instrução para onde o loader salta
5.3. Mostre as seções
| |
Campos importantes:
- Name: por exemplo
.text,.rodata,.data,.bss - Addr: endereço virtual (VMA (Virtual Memory Address) quando carregado)
- Off: offset no arquivo (onde os bytes vivem no arquivo)
- Size: tamanho da seção
- Flags:
AX(alloc + execute),WA(write + alloc)
Quando você precisa mapear “este endereço em runtime” → “quais bytes no arquivo”, use:
file_offset = section_off + (address - section_addr)
Exemplo com build/lab_rv32.elf (de readelf -S):
.texttemAddr=0x80000000eOff=0x001000- Se você quer o endereço em runtime
0x80000124(o início deuart_puthex32):
file_offset = 0x001000 + (0x80000124 - 0x80000000) = 0x001124
Como provar?
- Faça dump dos bytes no arquivo nesse offset:
xxd -s 0x1124 -g 1 -l 16 build/lab_rv32.elf
- Compare com o disassembly naquele endereço:
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/lab_rv32.elf | rg -n '80000124'
Sintaxe dos comandos:
xxd -e -s 0x1124 -g 1 -l 16 build/lab_rv32.elf-e: muda para modo little-endian-s 0x1124: faz seek para o offset0x1124a partir do início do arquivo-g 1: agrupa bytes em unidades de 1 byte-l 16: mostra 16 bytes
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/lab_rv32.elf-d: disassembly de todas as seções executáveis-M numeric,no-aliases: mostra registradores numéricos e evita aliases de pseudo-instruções
rg -n '80000124'-n: inclui números de linha na saída
5.4. Mostre os segmentos (program headers)
| |
Na lista de segmentos, foque em:
- segmentos
LOAD: são mapeados na memória VirtAddr/PhysAddr: onde aparecem em runtimeFileSiz/MemSiz: bytes no arquivo vs tamanho na memória
.bss normalmente tem zero bytes no arquivo (ela e “zero-inicializada” na memória). Por isso MemSiz pode ser maior que FileSiz.6. Onde está main? (símbolos)
6.1. Rápido: nm
| |
-nordena por endereço- As letras do tipo de símbolo importam:
T/t: texto (código)D/d: dados inicializadosB/b: BSS
6.2. Mais completo: readelf -s
| |
Você verá:
- valor do símbolo (endereço)
- tamanho
- binding (local/global)
- índice da seção
7. Disassembly em que você pode confiar
7.1. Disassembly básico
| |
7.2. Prefira: registradores numéricos + sem pseudo-instruções
Pseudo-instruções podem esconder o que a CPU realmente executa.
| |
ret (que na verdade é jalr x0, x1, 0). Ver a forma “real” ajuda na depuração.7.3. Encontre uma função no disassembly
| |
8. Relacione instruções com bytes (workflow de hexdump)
Esta é uma habilidade prática de engenharia reversa:
- Identifique um endereço de instrução no
objdump. - Converta esse endereço → offset no arquivo usando as infos da seção.
- Inspecione os bytes brutos naquele offset.
8.1. Passo A: encontre o mapeamento de .text
Você quer o Addr e o Off da .text.
| |
Output:
| |
8.2. Passo B: calcule um offset no arquivo
Primeiro, pegue o endereço de main:
| |
Output:
| |
Neste ELF, main está em 0x80000280.
Pelos headers de seção, .text tem:
Addr = 0x80000000Off = 0x001000
Então:
| |
Ou deixe o bc fazer a conta:
bc é a calculadora padrão de linha de comando do Unix (suporta precisão arbitrária e diferentes bases numéricas). | |
Notas sobre bc:
ibase=16faz obcinterpretar os números de entrada como hex.obase=16imprime o resultado em hex.- Defina
obaseantes deibasepara que16não seja interpretado como hex (0x16). - O resultado é
1280em hex.
8.3. Passo C: visualize os bytes
objdump é exatamente o que a CPU busca.- Despeje os bytes da
.textcomobjdump:
| |
Output:
| |
No output do objdump -s, o endereço à esquerda (por exemplo 0x800000d0) é o endereço de runtime/VMA desses bytes quando .text é carregada na memória, não é um offset de arquivo. É o endereço base da seção somado ao offset dentro da seção.
- Para inspecionar os bytes que correspondem especificamente a
main(em0x80000280→ offset de arquivo0x1280):
| |
Output:
| |
- Qual é a visão equivalente no
xxd/hexdump?
O objdump -j .text encontra a seção .text pelo nome na tabela de seções do ELF e despeja os bytes que pertencem a ela. Já o xxd e o hexdump não conhecem seções; eles só despejam bytes crus a partir de um offset no arquivo. Neste ELF, a .text começa no offset de arquivo 0x1000 (como visto nos headers de seção), então estes comandos são visões equivalentes dos mesmos bytes:
- Visão com
xxd:
| |
- Visão com
hexdump:
| |
- Para verificar o offset do seu arquivo, confira a entrada
.textnos headers de seção:
| |
Se o offset da .text for diferente, substitua 0x1000 por esse valor.
9. Relocações: “endereços ainda não finais”
Uma relocação é uma nota do assembler para o linker dizendo: “Eu tive que colocar alguma coisa nesta instrução ou dado, mas ainda não sei o endereço final. Conserte isso depois.”
Isso acontece porque arquivos .o são gerados antes do linker decidir onde tudo vai morar na memória.
9.1. A ideia básica (com um modelo mental)
Quando você escreve:
- uma chamada para uma função (
call foo) - uma referência a um global (
la t0, global_var)
O assembler não sabe o endereço final de foo ou global_var.
Então ele:
- Emite um placeholder na instrução/dado,
- Adiciona uma entrada de relocação que descreve como corrigir depois.
Na linkedição, o linker lê essas entradas, calcula os endereços reais e reescreve os bytes.
9.2. Anatomia de uma entrada de relocação
Uma relocação normalmente inclui:
- offset: onde, dentro da seção, aplicar o patch
- type: como aplicar (absoluta, PC‑relativa, par hi/lo, etc.)
- symbol: o alvo do patch
- addend: constante extra a somar (no RISC‑V costuma ser RELA, com o adendo/adicional explícito)
9.3. Veja relocações em um .o real
Compile um arquivo objeto:
| |
Inspecione as entradas:
| |
Visões auxiliares úteis:
- Tabela de símbolos (nomes + endereços no .o)
| |
- Disassembly + relocações inline
| |
O que observar em readelf -r:
- Offset: a posição exata (em bytes) a ser patchada
- Info/Type: o tipo de relocação (específico da arquitetura)
- Symbol: o alvo (
foo,global_var, etc.) - Addend: o ajuste constante (quando presente)
9.4. O que acontece depois de linkar?
- Em um ELF totalmente linkado bare‑metal, a maioria das relocações é resolvida (os bytes já estão corrigidos).
- Em um ELF dinâmico/compartilhado, algumas relocações ficam para o loader resolver em tempo de execução.
10. ELF → binário bruto (e por que endereços desaparecem)
Converta para binário flat:
| |
Agora verifique:
| |
Por que o .bin é menor:
- Ele só contém bytes carregáveis; sem tabelas de símbolos nem headers de seção.
.bin bruto não tem endereços inerentes. Você precisa saber (ou adivinhar) o endereço de carga a partir de um bootloader, mapa de memória ou firmware ao redor.11. Exercícios
- Use
readelf -hpara achar o entry point debuild/lab_rv32.elf. - Use
nm -npara achar o endereço deadd_u32e localize-o noobjdump. - Escolha uma instrução dentro de
add_u32e encontre os bytes exatos no ELF usando o método de offset de seção. - Compile
lab.oe liste relocações; explique em uma frase o que cada relocação tenta corrigir.
11.1. Como testar suas respostas
- Você consegue apontar para um offset específico do arquivo que contém os bytes de uma instrução em um endereço virtual específico?
- Você consegue explicar por que
.bsstem tamanho na memória, mas não no arquivo?
12. Resumo
Você aprendeu a navegar pela estrutura ELF e usar binutils para conectar:
flowchart LR
A[símbolos] --> B[disassembly] --> C[bytes brutos] --> D[endereços em runtime]
12.1. Leia a seguir
No output do readelf -l, você talvez tenha visto Align 0x1000. Por que o hardware se importa com esse número? E o seu código realmente vive em 0x80000000?
Veja Capítulo 7: Memória, Paginação e a Ilusão do Hardware para descobrir os segredos da memória virtual.
A seguir: ABI do RV32 + tipos em C; vamos ligar layouts de dados em nível C (tamanhos, alinhamento, structs) aos loads/stores exatos que você vê no assembly.