Anatomia de um executável: ELF e segmentos de memória

1. Introdução ao formato ELF

O formato ELF (Executable and Linkable Format) é o padrão para executáveis, bibliotecas compartilhadas e arquivos objeto no Linux e na maioria dos sistemas Unix modernos. Quando você compila um programa em C com gcc, o compilador gera código objeto (.o), o linker combina esses objetos e produz um arquivo ELF executável. O ELF substituiu formatos mais antigos como a.out e COFF, oferecendo maior flexibilidade e suporte a arquiteturas diversas.

A estrutura do ELF é composta por três partes principais: o cabeçalho ELF (ELF header), que descreve o arquivo; as seções (sections), que organizam o conteúdo gerado pelo compilador; e os segmentos (segments), que definem como o carregador do sistema operacional deve mapear o executável na memória.

2. Cabeçalho ELF: o mapa do executável

O cabeçalho ELF é o ponto de partida para interpretar qualquer arquivo ELF. Ele contém informações essenciais:

  • Magic number: os primeiros 4 bytes 7f 45 4c 46 (\x7fELF)
  • Classe: 32 bits (1) ou 64 bits (2)
  • Endianness: little-endian (1) ou big-endian (2)
  • Tipo: relocatable (ET_REL), executable (ET_EXEC), shared (ET_DYN)
  • Arquitetura: ISA alvo (x86, ARM, RISC-V)
  • Entry point: endereço da primeira instrução a executar

Para inspecionar o cabeçalho, use readelf -h:

$ readelf -h programa
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Entry point address:               0x401060

Em C, podemos interpretar esse cabeçalho usando a estrutura Elf64_Ehdr:

#include <elf.h>
#include <stdio.h>

int main() {
    FILE *f = fopen("programa", "rb");
    Elf64_Ehdr ehdr;
    fread(&ehdr, sizeof(ehdr), 1, f);
    printf("Entry point: 0x%lx\n", ehdr.e_entry);
    printf("Type: %d\n", ehdr.e_type);  // 2 = ET_EXEC
    fclose(f);
    return 0;
}

3. Seções do ELF: o que o compilador gera

O compilador C organiza o código e os dados em seções dentro do arquivo objeto. As principais seções são:

  • .text: código executável da máquina (instruções)
  • .data: variáveis globais e estáticas inicializadas
  • .bss: variáveis globais e estáticas não inicializadas (ocupa espaço apenas na memória, não no arquivo)
  • .rodata: dados somente leitura (strings literais, constantes const)
  • .symtab: tabela de símbolos (nomes de funções e variáveis)
  • .debug: informações de depuração (gerado com -g)

Para listar todas as seções:

$ readelf -S programa
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [13] .text             PROGBITS         0000000000401060  00001060
       0000000000000190  0000000000000000  AX       0     0     16
  [24] .data             PROGBITS         0000000000404018  00003018
       0000000000000010  0000000000000000  WA       0     0     8
  [25] .bss              NOBITS           0000000000404028  00003028
       0000000000000008  0000000000000000  WA       0     0     8

As flags indicam permissões: A (alocável), X (executável), W (gravável).

4. Segmentos de memória: a visão em tempo de execução

Enquanto as seções são uma visão do linker, os segmentos (program headers) são a visão do carregador do kernel. O linker mapeia seções em segmentos para que o sistema operacional possa carregar o programa de forma eficiente.

Segmentos típicos:

  • LOAD: segmento carregável na memória (código ou dados)
  • INTERP: caminho do interpretador (para bibliotecas dinâmicas)
  • DYNAMIC: informações de ligação dinâmica
  • NOTE: metadados diversos
$ readelf -l programa
Elf file type is EXEC (Executable file)
Entry point 0x401060
There are 2 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000000600 0x0000000000000600  R E    0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x0000000000000200 0x0000000000000300  RW     0x1000

O primeiro LOAD (R E) contém o cabeçalho e o código (.text). O segundo LOAD (RW) contém dados inicializados e não inicializados (.data e .bss).

5. Layout da memória de um processo C

Quando o kernel carrega um executável, ele organiza a memória do processo da seguinte forma (endereços baixos para altos):

  1. Segmento de texto (.text): código executável, permissão RX
  2. Segmento de dados (.data + .bss): variáveis globais, permissão RW
  3. Heap: alocações dinâmicas (malloc, calloc), cresce para cima
  4. Bibliotecas compartilhadas: mapeadas em endereços aleatórios (ASLR)
  5. Pilha (stack): variáveis locais, cresce para baixo

Para visualizar o layout de um processo em execução:

$ pmap $(pgrep meu_programa)
Address           Kbytes     RSS   Dirty Mode  Mapping
0000555555554000      4       4       0 r-x-- meu_programa
0000555555555000      4       4       4 r---- meu_programa
0000555555556000      4       4       4 rw--- meu_programa
00007ffff7dce000   2040    1024       0 r-x-- libc.so.6
00007ffff7fce000   2040       0       0 ----- libc.so.6
00007ffff81ce000      8       8       8 rw--- libc.so.6
00007ffffffde000    132      12      12 rw--- [stack]

6. Variáveis em C e sua localização nos segmentos

Cada tipo de variável em C é alocada em um segmento específico:

#include <stdio.h>
#include <stdlib.h>

int global_inic = 42;            // .data
int global_nao_inic;             // .bss
static int estatica = 10;        // .data
static int estatica_nao_inic;    // .bss
const int constante = 100;       // .rodata (às vezes .data se modificável)

int main() {
    int local = 5;               // pilha (stack)
    static int local_estatica;   // .bss
    int *heap = malloc(10);      // heap

    printf("global_inic: %p\n", &global_inic);
    printf("global_nao_inic: %p\n", &global_nao_inic);
    printf("local: %p\n", &local);
    printf("heap: %p\n", heap);

    free(heap);
    return 0;
}

Compilando e inspecionando:

$ gcc -o programa programa.c
$ size programa
   text    data     bss     dec     hex filename
   1120     544      16    1680     690 programa
$ nm programa | grep -E "global|estatica|constante"
0000000000404020 D global_inic
0000000000404028 B global_nao_inic
0000000000404010 D estatica
0000000000404030 b estatica_nao_inic
0000000000404008 R constante

As letras indicam: D (dados inicializados), B (BSS), R (read-only), b (BSS local).

7. Ferramentas para inspecionar o executável

Além de readelf, outras ferramentas são indispensáveis:

objdump: desmonta o código e mostra seções detalhadas

$ objdump -d programa | head -20
programa:     file format elf64-x86-64
Disassembly of section .text:
0000000000401060 <main>:
  401060: 55                    push   %rbp
  401061: 48 89 e5              mov    %rsp,%rbp
  401064: 48 83 ec 10           sub    $0x10,%rsp

nm: lista símbolos (funções e variáveis) com seus endereços

$ nm programa | grep -E "T|U"
0000000000401060 T main
                 U printf@@GLIBC_2.2.5

size: exibe o tamanho das seções texto, dados e BSS

$ size programa
   text    data     bss     dec     hex filename
   1120     544      16    1680     690 programa

Exemplo prático completo:

$ cat teste.c
#include <stdio.h>
int x = 10;
int y;
int main() { return 0; }

$ gcc -o teste teste.c
$ readelf -S teste | grep -E "\.text|\.data|\.bss"
  [13] .text             PROGBITS         0000000000401060  00001060
  [24] .data             PROGBITS         0000000000404018  00003018
  [25] .bss              NOBITS           0000000000404028  00003028
$ nm teste | grep -E "x|y|main"
0000000000401060 T main
0000000000404018 D x
0000000000404028 B y

8. Considerações finais e implicações práticas

Entender a anatomia do ELF e os segmentos de memória tem implicações diretas no desenvolvimento em C:

  • Desempenho: o alinhamento de segmentos (múltiplos de 0x1000, páginas de 4KB) afeta o uso de cache e TLB. Dados muito próximos em segmentos diferentes podem causar cache misses.
  • Segurança: o bit NX (No-Execute) impede que segmentos de dados sejam executados, mitigando ataques de buffer overflow. O ASLR (Address Space Layout Randomization) randomiza endereços de segmentos para dificultar exploração de vulnerabilidades.
  • Tamanho do binário: entender a diferença entre .data e .bss ajuda a reduzir o tamanho do executável. Variáveis não inicializadas ocupam espaço apenas na memória, não no disco.
  • Linker scripts: personalizar o layout de memória com scripts de linker permite otimizações avançadas para sistemas embarcados.

Ao compilar com gcc -O2 -fstack-protector-strong, você ativa proteções que interagem diretamente com esses segmentos. Dominar esses conceitos é fundamental para programação de sistemas, depuração de baixo nível e segurança de software.

Referências