1 pontos por GN⁺ 2025-10-26 | 1 comentários | Compartilhar no WhatsApp
  • Antes de o programa ser executado, uma análise técnica explora o processo pelo qual o kernel cria e inicializa um processo por meio da chamada de sistema execve
  • Essa chamada passa o caminho do executável, os argumentos e as variáveis de ambiente, e com base nisso o kernel carrega o executável no formato ELF
  • O arquivo ELF inclui código, dados, símbolos e informações de ligação dinâmica, e o kernel o interpreta para realizar o mapeamento de memória e a inicialização da pilha
  • Em seguida, o kernel transfere o controle para o ponto de entrada _start, e só depois da inicialização do runtime específico da linguagem a função main definida pelo usuário é chamada
  • Esse processo mostra a estrutura de colaboração entre sistema operacional, compilador e runtime e é importante para entender como a execução de programas acontece em nível de sistema

O ponto de partida da execução de programas: chamada execve

  • No Linux, a execução de programas começa com a chamada de sistema execve
    • No formato execve(const char *filename, char *const argv[], char *const envp[]), ela recebe o nome do executável, a lista de argumentos e a lista de variáveis de ambiente
    • Com isso, o kernel determina qual programa será executado e em que ambiente
  • Em linguagens de alto nível, essa chamada costuma estar encapsulada pela API de execução de processos da biblioteca padrão
    • Exemplo: std::process::Command do Rust chama execve internamente
    • Também realiza o processo de converter o nome do comando em um caminho completo, de forma semelhante à busca no PATH do shell
  • No caso de scripts com shebang (#!), o kernel executa o programa usando o interpretador especificado
    • Exemplo: #!/usr/bin/python3 → executado com o interpretador Python

ELF: a estrutura do executável

  • No Linux, os executáveis seguem o formato ELF (Executable and Linkable Format)
    • ELF é um formato padrão de arquivo executável que inclui código, dados, símbolos e informações de relocação
    • Outros sistemas operacionais usam formatos diferentes, como Mach-O (macOS) e PE (Windows)
  • O cabeçalho ELF contém informações sobre a estrutura do arquivo e o layout em memória
    • Exemplos de campos: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address é o endereço da primeira instrução a ser executada pelo programa
  • No exemplo do cabeçalho ELF, trata-se de um executável ELF32 para a arquitetura RISC-V, com o endereço 0x10358 definido como ponto de entrada

Componentes internos do ELF

  • Um arquivo ELF é composto por várias seções (section)
    • .text: código executável
    • .data: variáveis globais inicializadas
    • .bss: variáveis globais não inicializadas
    • .plt: tabela para chamadas de bibliotecas compartilhadas
    • .symtab, .strtab: tabelas de símbolos e strings
  • A PLT (Procedure Linkage Table) dá suporte a chamadas de funções de bibliotecas compartilhadas
    • Exemplo: printf, malloc da libc
    • A seção PT_INTERP do ELF especifica o vinculador dinâmico (interpretador)
  • O kernel lê o ELF, posiciona em memória as seções carregáveis e, quando necessário, aplica recursos de segurança como ASLR e bit NX

Tabela de símbolos e ligação em runtime

  • A tabela de símbolos (symtab) do ELF contém informações de endereço de funções e variáveis
    • Exemplo: há entradas como _start, main, __libc_start_main
    • Até um programa simples de “Hello, World!” pode conter mais de 2.300 símbolos
  • Isso vem, em grande parte, da biblioteca padrão e do código de inicialização do runtime
    • Porque implementações de libc como musl ou glibc estão vinculadas
  • Depois de carregar cada seção do ELF, o kernel transfere o controle para o interpretador (vinculador dinâmico)
    • O interpretador cuida de relocação, randomização de endereços (ASLR), definição de permissões de execução (bit NX) etc.

Processo de inicialização da pilha

  • Antes de executar o programa, o kernel precisa montar diretamente a pilha (stack)
    • A pilha é usada para variáveis locais, frames de chamada de função, passagem de argumentos etc.
  • Os argv e envp passados na chamada execve são armazenados na pilha
    • O programa acessa por eles os argumentos de linha de comando e as variáveis de ambiente
  • O kernel também inclui na pilha o vetor auxiliar ELF (auxv)
    • Ele contém cerca de 30 itens, como tamanho de página, metadados ELF e informações do sistema
    • Exemplo: AT_PAGESZ define o tamanho da página de memória (por exemplo, 4KiB)
  • No exemplo do emulador RISC-V, o ponteiro da pilha (sp) começa em um endereço alto, e os argumentos, variáveis de ambiente e vetor auxiliar são empilhados em ordem inversa

Ponto de entrada e função _start

  • O ponto de entrada do ELF é definido como o endereço da função _start
    • _start é o primeiro código em espaço de usuário para o qual o kernel transfere o controle
  • Na maioria das linguagens, _start realiza a inicialização do runtime antes de chamar main
    • Exemplo: std::rt::lang_start no Rust, __libc_start_main em C
  • No exemplo em Rust, é possível definir _start diretamente sem runtime usando os atributos #![no_std] e #![no_main]
    • Dentro de _start, lê-se argc, argv e envp da pilha e chama-se o ponteiro para main
  • O runtime de cada linguagem executa tarefas de inicialização específicas da linguagem, como construtores globais, armazenamento local de thread e tratamento de exceções

Fluxo completo até a chamada de main()

  • O processo completo pode ser resumido assim
    1. Chamada execve → o kernel carrega o arquivo ELF
    2. Interpretação do ELF → mapeamento das seções de código/dados, definição do interpretador
    3. Montagem da pilha → armazenamento de argumentos, variáveis de ambiente e vetor auxiliar
    4. Execução do ponto de entrada _start
    5. Inicialização do runtime e chamada de main()
  • Essa sequência mostra a estrutura de cooperação entre o kernel do sistema operacional, o formato ELF e o runtime da linguagem
  • O kernel Linux real inclui também lógicas internas adicionais, como espaço de endereçamento, tabela de processos e gerenciamento de grupos, mas este texto explica o fluxo essencial da etapa anterior

Conclusão e correção

  • O processo de execução antes de main() é uma combinação de inicialização em nível de kernel e configuração de runtime
  • Mesmo um programa simples de “Hello, World!” é executado somente depois de passar por uma estrutura ELF complexa e pela inicialização do runtime
  • Na versão inicial do texto, parte da lógica de carregamento de seções havia sido atribuída ao kernel, mas foi corrigido que isso na verdade é papel do interpretador ELF
  • Esta análise serve como material básico útil para compreender programação de sistemas, compiladores e arquitetura de sistemas operacionais

1 comentários

 
GN⁺ 2025-10-26
Comentários do Hacker News
  • Explica o processo de linkedição dinâmica de arquivos ELF
    O kernel mapeia os segmentos PT_LOAD do ELF, carrega o linker dinâmico (ld.so) indicado por PT_INTERP e então transfere o controle para ele
    Depois disso, o linker dinâmico faz sua própria relocação e carrega os objetos compartilhados necessários com mmap/mprotect
    A estrutura é comparada ao mecanismo de shebang (#!) de scripts

    • O kernel não se importa nem um pouco com as informações de seção; ele processa apenas os segmentos PT_LOAD
      Compartilha uma experiência antiga em que tentou inserir um arquivo arbitrário em um ELF com objcopy e ficou confuso porque o kernel não o carregava
      No fim, acabou criando sua própria ferramenta de patch da tabela de cabeçalhos de programa, e diz que essa funcionalidade também foi adicionada ao linker mold
      Artigo relacionado: Self-contained Lone Lisp Applications
    • O autor reconhece que antes publicou uma edição incorreta do conteúdo e diz que vai corrigir
    • Diz que sempre teve curiosidade sobre por que não existem mais loaders diferentes, já que no Linux o loader roda em espaço de usuário
  • Diz que fez experimentos empacotando todo o código antes de main() ou até sem main()
    Artigo relacionado: Packing a codebase into a single function

    • Ao ler, achou interessante que fosse surpreendentemente simples e não tão frágil
      Brinca dizendo que bastaria transformar todas as funções em algo como main(100+n, ...)
  • Diz que, se alguém tiver interesse no tema, pode consultar o cpu.land que ele criou
    Em vez de focar no layout de memória, o site trata de multitarefa e do processo de carregamento de código

    • Agradece dizendo que gosta muito do cpu.land
  • Diz que tem curiosidade sobre quantos projetos em C evitam a biblioteca padrão e fazem chamadas diretas apenas a syscalls do Linux
    Acha esse estilo de programação bem mais divertido

    • Argumenta que usar syscalls diretamente na verdade é ineficiente
      Recursos como ALSA e DRM trazem muitas vantagens quando acessados por bibliotecas de sistema, em vez de syscalls do kernel
      Explica que essa abordagem é melhor em termos de portabilidade e manutenção do que a abordagem no estilo Windows
    • Acrescenta que no Windows, se usar apenas a API Win32, não é preciso linkar o runtime de C
    • Diz que ele mesmo já criou o projeto liblinux para escrever programas usando apenas syscalls
      Hoje abandonou isso porque os headers nolibc do Linux já estão muito bons,
      mas atualmente está desenvolvendo uma linguagem interpretada Lisp baseada em syscalls
      Diz que foi uma jornada muito interessante experimentar montar diretamente um user space Linux por meio de chamadas de sistema
    • Diz que tenta manter a portabilidade, mas que descritores de arquivo são convenientes demais para abrir mão
    • Acrescenta que muito código de drivers realmente usa apenas syscalls
  • Explica que o interpretador ELF (ld.so) assume todo o carregamento depois de mapear os segmentos ELF iniciais
    O execve mapeia os segmentos PT_LOAD, preenche o vetor auxiliar na pilha e então
    salta para o ponto de entrada do interpretador ELF
    O kernel não sabe nada sobre PLT/GOT

  • Como alguém que ensina esse tema na universidade, diz que os alunos ficam confusos por causa dos diagramas de memória
    Os livros desenham os endereços mais altos na parte de cima, mas um processo Linux real
    é exibido com os endereços baixos em cima e os altos embaixo
    Ao olhar /proc/<pid>/maps, quanto mais se rola para baixo, maiores ficam os endereços
    Ou seja, a expressão “o heap cresce para cima (e a stack cresce para baixo)” fala apenas da direção numérica,
    enquanto visualmente é justamente o contrário
    Propõe que seria muito mais intuitivo desenhar como em uma IDE, com os endereços aumentando para baixo

    • Diz que a stack, de qualquer forma, cresce com a redução do ponteiro de pilha, então a expressão “cresce para baixo” continua correta
      Mas sugere que a visualização em orientação horizontal seria mais natural
    • Recorda que também passou pela mesma confusão e que a notação de endereços little-endian era difícil de entender
    • Contesta dizendo que, se pensarmos na direção em que coisas reais são empilhadas, a expressão “a stack cresce para baixo” não parece intuitiva
  • Diz que gosta de fazer esse tipo de experimento com microcontroladores PIC16 antigos
    Acha divertido lidar diretamente com ponteiro de pilha, temporizadores, configuração de variáveis e afins

  • Compartilha uma experiência relacionada a shebang (#!)
    Uma aplicação Java mostrava um erro dizendo que não conseguia encontrar o script de execução,
    mas o problema real era que o caminho do shebang no script estava errado
    Localmente funcionava bem, mas o problema apareceu porque o caminho do interpretador era diferente no servidor remoto

    • Diz que isso não é um problema exclusivo do Java, mas pode acontecer com qualquer programa em que ocorra um erro ENOENT
      Aconselha executar com strace para ver imediatamente em qual syscall o erro aconteceu
    • Compartilha um texto que analisa a estrutura do shebang: What the #! means
    • Acrescenta que, para o kernel suportar shebang, é preciso a configuração CONFIG_BINFMT_SCRIPT=y
  • Diz que, durante depuração, sempre fica confuso sobre em que momento se aplica a ordem de relocação do binário principal
    Descreve como uma espécie de magia negra entender se isso acontece antes ou depois de o linker resolver seus próprios símbolos

  • Aponta que o link da parte “lang_start function (defined here)” no Markdown está quebrado