Internals de ELF e Binutils: vendo o que o compilador produziu

1. TL;DR

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

3. ELF em um diagrama

Pense no ELF como tendo duas “visões” diferentes dos mesmos dados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ELF file
 ├─ ELF header
 ├─ Program header table  (segments: loader view)
 │    ├─ PT_LOAD (text)
 │    ├─ PT_LOAD (data)
 │    └─ ...
 ├─ Section header table  (sections: linker view)
 │    ├─ .text
 │    ├─ .rodata
 │    ├─ .data
 │    ├─ .bss
 │    ├─ .symtab / .strtab
 │    └─ ...
 └─ Raw section contents

4. As ferramentas “centrais” e para que serve cada uma

4.1. readelf (estrutura)

4.2. objdump (conteúdo)

4.3. nm (símbolos)

4.4. objcopy (transformação)

4.5. xxd / hexdump (bytes brutos)

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:

1
2
3
riscv64-unknown-elf-gcc -O0 -g -ffreestanding -nostdlib \
  -march=rv32im -mabi=ilp32 -T src/link.ld \
  src/start.s src/uart.c src/lab.c -o build/lab_rv32.elf

5.2. Mostre o header ELF

1
readelf -h build/lab_rv32.elf

Procure por:

5.3. Mostre as seções

1
readelf -S build/lab_rv32.elf

Campos importantes:

5.4. Mostre os segmentos (program headers)

1
readelf -l build/lab_rv32.elf

Na lista de segmentos, foque em:

6. Onde está main? (símbolos)

6.1. Rápido: nm

1
nm -n build/lab_rv32.elf | grep -E ' main$| add_u32$'

6.2. Mais completo: readelf -s

1
readelf -s build/lab_rv32.elf | grep -E ' main$| add_u32$| mmio_fake$'

Você verá:

7. Disassembly em que você pode confiar

7.1. Disassembly básico

1
riscv64-unknown-elf-objdump -d build/lab_rv32.elf | less

7.2. Prefira: registradores numéricos + sem pseudo-instruções

Pseudo-instruções podem esconder o que a CPU realmente executa.

1
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/lab_rv32.elf | less

7.3. Encontre uma função no disassembly

1
grep -n "<add_u32>" -n <(riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/lab_rv32.elf)

8. Relacione instruções com bytes (workflow de hexdump)

Esta é uma habilidade prática de engenharia reversa:

  1. Identifique um endereço de instrução no objdump.
  2. Converta esse endereço → offset no arquivo usando as infos da seção.
  3. Inspecione os bytes brutos naquele offset.

8.1. Passo A: encontre o mapeamento de .text

Você quer o Addr e o Off da .text.

1
readelf -S build/lab_rv32.elf | rg -n '\.text'

Output:

1
6:  [ 1] .text   PROGBITS   80000000 001000 000344 00  AX  0   0  4

8.2. Passo B: calcule um offset no arquivo

Primeiro, pegue o endereço de main:

1
nm -n build/lab_rv32.elf | rg ' main$'

Output:

1
80000280 T main

Neste ELF, main está em 0x80000280.
Pelos headers de seção, .text tem:

Então:

1
2
offset = 0x001000 + (0x80000280 - 0x80000000)
       = 0x001280

Ou deixe o bc fazer a conta:

1
2
bc -q <<< 'obase=16; ibase=16; 001000 + (80000280 - 80000000)'
1280

Notas sobre bc:

8.3. Passo C: visualize os bytes

  1. Despeje os bytes da .text com objdump:
1
riscv64-unknown-elf-objdump -s -j .text build/lab_rv32.elf | head -n 20

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
build/lab_rv32.elf:     file format elf32-littleriscv

Contents of section .text:
 80000000 17010001 13010100 93810100 97020000  ................
 80000010 93820235 13834180 63886200 23a00200  ...5..A.c.b.#...
 80000020 93824200 e3cc62fe ef008025 73005010  ..B...b....%s.P.
 80000030 6ff0dfff 130101fd 23268102 13040103  o.......#&......
 80000040 232ea4fc 8327c4fd 93f7f700 2326f4fe  #....'......#&..
 80000050 0327c4fe 93079000 63ece700 8327c4fe  .'......c....'..
 80000060 93f7f70f 93870703 93f7f70f 6f004001  ............o.@.
 80000070 8327c4fe 93f7f70f 93877705 93f7f70f  .'........w.....
 80000080 a305f4fe b7070010 0347b4fe 2380e700  .........G..#...
 80000090 13000000 0324c102 13010103 67800000  .....$......g...
 800000a0 130101fe 232e8100 13040102 93070500  ....#...........
 800000b0 a307f4fe b7070010 0347f4fe 2380e700  .........G..#...
 800000c0 13000000 0324c101 13010102 67800000  .....$......g...
 800000d0 130101fe 232e1100 232c8100 13040102  ....#...#,......
 800000e0 2326a4fe 6f00c001 8327c4fe 13871700  #&..o....'......
 800000f0 2326e4fe 83c70700 13850700 eff05ffa  #&............_.

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.

  1. Para inspecionar os bytes que correspondem especificamente a main (em 0x80000280 → offset de arquivo 0x1280):
1
xxd -s 0x1280 -g 1 -l 16 build/lab_rv32.elf

Output:

1
00001280: 13 01 01 fe 23 2e 11 00 23 2c 81 00 13 04 01 02  ....#...#,......
  1. 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:

1
xxd -s 0x1000 -g 1 build/lab_rv32.elf | head -n 20
1
hexdump -C -s 0x1000 build/lab_rv32.elf | head -n 20
1
readelf -S build/lab_rv32.elf | rg '\.text'

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:

O assembler não sabe o endereço final de foo ou global_var.
Então ele:

  1. Emite um placeholder na instrução/dado,
  2. 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:

9.3. Veja relocações em um .o real

Compile um arquivo objeto:

1
2
riscv64-unknown-elf-gcc -O0 -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 \
  -c src/lab.c -o build/lab.o

Inspecione as entradas:

1
readelf -r build/lab.o

Visões auxiliares úteis:

  1. Tabela de símbolos (nomes + endereços no .o)
1
readelf -s build/lab.o
  1. Disassembly + relocações inline
1
riscv64-unknown-elf-objdump -dr build/lab.o

O que observar em readelf -r:

9.4. O que acontece depois de linkar?

10. ELF → binário bruto (e por que endereços desaparecem)

Converta para binário flat:

1
riscv64-unknown-elf-objcopy -O binary build/lab_rv32.elf lab_rv32.bin

Agora verifique:

1
ls -l build/lab_rv32.elf lab_rv32.bin

Por que o .bin é menor:

11. Exercícios

  1. Use readelf -h para achar o entry point de build/lab_rv32.elf.
  2. Use nm -n para achar o endereço de add_u32 e localize-o no objdump.
  3. Escolha uma instrução dentro de add_u32 e encontre os bytes exatos no ELF usando o método de offset de seção.
  4. Compile lab.o e liste relocações; explique em uma frase o que cada relocação tenta corrigir.

11.1. Como testar suas respostas


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.