3 pontos por GN⁺ 2024-04-09 | 1 comentários | Compartilhar no WhatsApp
  • Até uma única linha de Hello World escrita em C precisa passar, em sequência, pelo executável compilado, pela biblioteca padrão de C, pelas chamadas de sistema, pelo kernel e pelo terminal para aparecer na tela
  • O resultado gerado com gcc hello.c -o hello é um executável ELF 64-bit x86-64, e o código _start é executado primeiro a partir do ponto de entrada 0x1060 do cabeçalho ELF
  • O main() escrito pelo usuário não começa diretamente; ele passa por _start e __libc_start_main, e printf("Hello World!\n") vira uma chamada puts() mais simples por otimização
  • A string é armazenada como uma sequência de bytes em 0x2004 de .rodata, e strings em C determinam o fim não por informação de tamanho, mas por um terminador NULL
  • O caminho real da saída passa pelo buffering e locking da libc, pela chamada de sistema write ou writev, pelo kernel Linux, pelo pseudo-terminal e pela renderização do emulador de terminal, podendo variar conforme o ambiente de execução

Começando pelo C Hello World

  • O programa de exemplo é o código abaixo, escrito em C
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • Ele produz o mesmo resultado que print('Hello World!') em Python, mas um programa em C não é executado diretamente por um interpretador; primeiro ele precisa ser compilado
gcc hello.c -o hello
./hello
  • O resultado da execução é o seguinte
Hello World!
  • Se você tiver conhecimento básico de C ou assembly, será fácil acompanhar o fluxo

O que é esse executável

  • No resultado de file hello, o ponto principal é ELF executable, x86-64
    • Um executável ELF é o formato de programa executável no Linux
    • x86-64 significa um programa em linguagem de máquina para processadores x86 de 64 bits
  • Ao ver o cabeçalho ELF com readelf -h hello, aparece Entry point address: 0x1060
    • Esse endereço é o ponto em que a CPU começa a executar depois que o programa é carregado

_start e a entrada na biblioteca C

  • Ao fazer o disassembly com objdump -D hello, há um _start na posição 0x1060
  • _start não é um código escrito diretamente pelo usuário, mas sim código adicionado automaticamente pelo compilador, mais precisamente pelo linker
  • Esse código faz a inicialização e depois executa a chamada abaixo
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • Essa função não está definida diretamente dentro do programa; ela fica na biblioteca padrão de C
  • Na seção dinâmica de readelf -d hello, aparece a dependência de libc.so.6
Shared library: [libc.so.6]
  • libc.so.6 é a biblioteca padrão de C do sistema, e arquivos .so no Linux contêm código que pode ser compartilhado por vários programas, como as .dll no Windows
  • A biblioteca C cuida de inicializações como o processamento de argumentos de linha de comando e variáveis de ambiente, chama main() e depois encerra o programa com o valor de retorno

O que realmente acontece em main()

  • No resultado do disassembly, main() está em 0x1149
  • O fluxo de main() é o seguinte
    • Configura o stack frame
    • Prepara os argumentos da chamada de função
    • Chama a função que imprime Hello World
    • Limpa o stack frame
    • Retorna com código de saída 0
  • O ponto central é a parte que prepara o endereço da string como argumento e chama puts@plt
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • No código-fonte havia printf(), mas o compilador o otimizou e o transformou em puts()
    • printf() é uma função complexa com suporte a saída formatada
    • Como o exemplo não usa recursos de formatação como inserção de variáveis, ele é substituído por puts(), que é mais simples
    • puts() acrescenta a quebra de linha por conta própria, então o \n da string original também é removido

Como a string é armazenada

  • A string está no endereço 0x2004 da seção .rodata
  • Os bytes nessa posição são os seguintes
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • Essa sequência de bytes é interpretada como "Hello World!" e um 0x00 no final
  • 0x00 é o terminador NULL e indica o fim de uma string em C
  • Como strings em C não carregam informação de tamanho, funções que recebem strings processam um byte por vez até encontrar o terminador NULL
  • Se não houver terminador NULL entre strings, uma função em C pode acabar tratando várias strings como se fossem uma só ou lendo memória não permitida, encerrando com Segmentation Fault

O caminho de puts() na Glibc

  • puts@plt acaba levando para a biblioteca padrão
  • Na Glibc, puts() é conectado a _IO_puts
  • _IO_puts faz o seguinte
    • Calcula o comprimento da string
    • Obtém o lock do fluxo de saída stdout
    • Verifica condições e chama _IO_sputn
    • Escreve o caractere de quebra de linha
    • Libera o lock e retorna a quantidade de caracteres escritos
  • Como a implementação interna da Glibc é grande e complexa, o texto segue o fluxo usando a biblioteca C menor musl libc

Como a saída desce na musl libc

  • O puts() da musl obtém o lock de stdout, chama fputs() e putc_unlocked('\n', stdout), e então libera o lock
  • fputs() calcula o tamanho da string e chama fwrite()
  • fwrite() por sua vez obtém o lock novamente e chama __fwritex()
  • __fwritex() verifica o estado do buffer e, se necessário, chama o ponteiro de função write do fluxo de saída
  • stdout é definido com fd = 1, e a função write inicialmente aponta para __stdout_write
  • __stdout_write() executa um ioctl TIOCGWINSZ e depois chama __stdio_write()
  • __stdio_write() faz uma chamada de sistema com SYS_writev

Chamadas de sistema e o kernel

  • A biblioteca C, sozinha, não consegue se comunicar diretamente com o hardware; o acesso ao hardware é responsabilidade do kernel do sistema operacional
  • No fim, a solicitação de saída termina em uma chamada de sistema pedindo ao sistema operacional que escreva o texto no fluxo de saída
  • Saídas comuns são feitas com a chamada de sistema write, e a musl usa writev, que permite escrever um array com vários buffers
  • Na implementação x86-64 de chamadas de sistema da musl, há funções separadas de __syscall0 até __syscall6 de acordo com a quantidade de argumentos
  • Cada função coloca os argumentos nos registradores da CPU e executa a instrução syscall
    • O controle passa para o kernel
    • O kernel lê os parâmetros nos registradores e executa a chamada de sistema solicitada

Depois do kernel, até aparecer na tela

  • O kernel Linux recebe a chamada de sistema write e grava os dados em um arquivo ou fluxo aberto
  • A chamada de sistema write recebe como argumentos o descritor de arquivo, o buffer a ser escrito e a quantidade de bytes a escrever
  • No ambiente do exemplo, o programa hello roda no emulador de terminal GNOME, e o stdout está conectado ao pseudo-terminal /dev/pts/0
  • O kernel armazena a mensagem Hello World no buffer, e o emulador de terminal a lê para exibi-la na tela
  • O emulador de terminal renderiza o texto em frames, e o servidor X ou compositor os combina com as telas de outros aplicativos antes de enviá-los ao display por meio do kernel
  • Dependendo do ambiente de execução, o caminho posterior pode ser diferente
    • Em um login remoto, o kernel envia o texto para o sshd, e o sshd o devolve ao kernel em pacotes criptografados para envio pela internet
    • Se forem usados um terminal físico e um adaptador serial-para-USB, o kernel envia o texto em pacotes USB
    • Em um framebuffer console, o kernel renderiza o texto em frames e o exibe na tela

A complexidade por trás de uma saída tão pequena

  • O envio da mensagem Hello World é apenas uma chamada de sistema gerada por um único programa
  • Software e hardware modernos são compostos por camadas tão complexas e detalhadas que é difícil rastrear completamente até mesmo uma operação pequena
  • Esta explicação omite muitos detalhes, exceções e comportamentos internos do kernel para seguir apenas o fluxo principal

1 comentários

 
GN⁺ 2024-04-09
Comentários do Hacker News
  • Por tédio, tentei fazer algo parecido no macOS em Rust, usando #![no_std], #![no_main] e chamadas de sistema WRITE/EXIT diretamente; mesmo assim, um “Hello, world!” visto no Ghidra ficava, de qualquer forma, em torno de 16 KB
    Provavelmente dá para reduzir mais com code golf, mas parece bem provável que alguém já tenha feito isso e documentado

    • No Windows, um programa semelhante tinha 3072 bytes e foi compilado com rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main
      Ele chamava diretamente ExitProcess, GetStdHandle e WriteFile de kernel32, e, por ser um hello world, deixei o tratador de panic de qualquer jeito. Ainda havia bastante padding dentro do executável, então daria para colocar mais coisas sem aumentar o tamanho, e também seria possível reduzi-lo por métodos mais “criminosos”, mas não parece haver muito sentido nisso
      Como referência, o banco de dados de depuração PDB relacionado tinha 208.896 bytes
    • Para deixá-lo o menor possível, é preciso abandonar totalmente o main e usar _start, além de passar flags ao linker para não alinhar seções
      Em https://darkcoding.net/software/a-very-small-rust-binary-ind..., dá para chegar facilmente perto de 500 bytes desse jeito
    • Code golf é divertido, mas também é preciso ver qual é o tamanho de página hoje em dia
      Em uma linguagem com stack, o executável provavelmente acabará carregando pelo menos umas duas páginas, uma somente leitura e outra leitura-escrita
    • O projeto min-sized-rust reúne muitas otimizações para reduzir o tamanho de binários Rust
      Pelo que me lembro, aplicando todas as otimizações, o hello world acabava ficando com cerca de 8 KB: https://github.com/johnthagen/min-sized-rust
    • O XNU não carrega um Mach-O menor que uma página, então, nessa plataforma, infelizmente não há muito espaço para brincar de binários pequenos
  • Há outra toca de coelho que a Musl pulou. No Linux, chamar funções do sistema não é apenas usar syscall diretamente
    A forma mais “educada” é chamar o vDSO. É uma pequena biblioteca meio mágica que o kernel mapeia automaticamente no espaço de endereços, permitindo que ele forneça o código ideal para executar chamadas de sistema
    Algumas chamadas de sistema podem ser executadas no espaço de usuário, eliminando a necessidade do próprio syscall; antigamente, o vDSO também escolhia entre mecanismos de chamada ao kernel como int 0x80 ou sysenter
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • Só no x86 de 32 bits o vDSO contém um shim genérico para chamadas de sistema rápidas
      No x86-64, o método padrão de chamada de sistema é a instrução SYSCALL, e o vDSO contém apenas funções relacionadas a tempo e algumas funções relacionadas a SGX
  • Também vale ler um texto que compara o overhead de programas “Hello World” em várias linguagens: https://drewdevault.com/2020/01/04/Slow.html
    Texto posterior: https://drewdevault.com/2020/01/08/Re-Slow.html
    Há também um texto lendário sobre como criar o menor programa no Linux. O programa simplesmente termina com o código de status 42: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    No mesmo site também é possível encontrar o menor programa “Hello World”

  • O texto basicamente passa por cima do papel do linker dinâmico, que pode ser visto como o verdadeiro ponto de entrada do programa
    Se essa perspectiva interessar, veja https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...

  • Para os fãs de DOS, um “hello, world” escrito em assembly/código de máquina para DOS podia chegar a 23 bytes: https://github.com/susam/hello
    Desses 23 bytes, 15 são ocupados pela própria string terminada com o símbolo de dólar; o código de máquina real tem apenas 8 bytes, em quatro instruções x86

  • O texto foi bom, mas eu gostaria que ele tivesse feito mais duas coisas. Seria melhor desativar a otimização e o inlining que fizeram printf virar puts, ou então escrever desde o início um hello world usando puts diretamente
    Além disso, seria bom dividir a etapa de compilação em quatro fases — pré-processamento, compilação, montagem e linkedição — ou adicionar --save-temps ao cc e explicar os arquivos gerados. Quando você vê o pipeline diretamente, muito do que parece mágica deixa de parecer assim

  • Lembrei de uma tarefa de que eu gostava na aula de programação de sistemas na faculdade: era algo como “dado um trecho de C++ hello world, entregue o menor binário compilado possível”
    Ficou na memória ter inspecionado o programa com ferramentas como readelf e objdump, removendo aos poucos camadas e otimizações do compilador até reduzi-lo ao menor binário que ainda imprimia “hello world”
    Naturalmente, ao pesquisar, havia alguém que tinha feito muito melhor do que os alunos: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • Fico pensando se o fato de aquele trecho ser em C++ tinha alguma importância
      Não daria simplesmente para criar o menor binário que imprime hello world e alegar que ele é semanticamente equivalente? Mesmo incluindo os dados da string, parece que umas dez instruções x86 bastariam
    • Se é uma tarefa tão boa, fico curioso por que não se vê mais gente criando o menor binário possível também para programas que não sejam “hello world”
      Pessoalmente, acho divertido porque gosto de economizar espaço no computador que tenho, mas hoje em dia muitos programas são escritos com 10 MiB, 20 MiB, 50 MiB, mais de 100 MiB. Alguns são feitos em ambientes comerciais para fins comerciais, mas há também muitos programas que dizem ser escritos por puro prazer. Será que não existe prazer em usar programas pequenos?
  • O encerramento no estilo “passou da meia-noite, então preciso dormir” foi, na verdade, um final perfeito para este texto

  • Infelizmente, como muitas análises profundas de “hello world”, este texto também para na chamada de sistema write e passa por cima do restante
    Até antes da chamada de sistema, essencialmente é uma cadeia de chamadas de função em que printf chama puts, puts chama write, passa um char const* e faz um pouco de contabilidade; pessoalmente, não é a parte mais interessante
    O ponto em que tudo fica realmente interessante e complexo é depois da chamada de sistema. O kernel conecta o stdout do processo à entrada do emulador de terminal, e o terminal prepara o framebuffer com a biblioteca de renderização de fontes e o driver da GPU. Ele lê do disco os contornos da fonte correspondentes aos bytes dos caracteres, ajusta ao viewport, aplica redimensionamento, kerning e métricas de fonte, e então a GPU faz a rasterização e o antialiasing
    Depois disso, o gerenciador de janelas compõe a moldura da janela do terminal e o desktop e, se houver transparência ou efeito de vidro fosco, processa tudo com shaders. O framebuffer resultante é empacotado como sinal HDMI ou DisplayPort de acordo com a resolução e a profundidade de cor do monitor, passa pelo cabo e pelos circuitos de entrada da tela e é convertido em sinais de endereçamento de pixels. LCD, OLED, plasma e CRT têm métodos de atualização diferentes; por exemplo, um OLED WRGB de 3840×2400 precisa lidar com cerca de 36,86 milhões de subpixels
    Tudo isso acontece dentro de 16,67 ms, o tempo de um quadro a 60 Hz

    • A explicação é boa, mas no fim ela para no sistema visual humano, e essa também é uma parte realmente interessante e complexa :)
      https://en.wikipedia.org/wiki/Visual_system
    • Se você gosta de explorações nesse nível de profundidade, talvez goste também do texto do Gynvael Coldwind que investiga como um hello world em Python é executado no Windows
      Ele aborda os internos do CPython, o conhost do Windows, rasterização de fontes, renderização pela GPU etc.: https://gynvael.coldwind.pl/?id=754
    • A maior parte disso não tem relação com o programa em si. Por exemplo, se a saída fosse redirecionada por pipe para /dev/null, nada disso aconteceria
    • Também fica de fora o que acontece antes de _start. Por exemplo, como um processo nasce no Linux, especialmente o bastante estranho execve, o processo de carregar o programa na memória, binfmt_* e o poderoso binfmt_misc, realocação, frames de tratamento de exceção, seções, o carregador ELF como um todo e a alocação de recursos do sistema operacional, incluindo o malloc necessário
  • A afirmação de que “diferentemente do Python, não é possível chamar um interpretador para executar este programa” não é exatamente correta
    Dá para fazer isso usando tcc -run hello.c. A rigor, ele está mais para um compilador em memória do que para um interpretador
    Se quiser pontos extras de nerdice, basta fazer o programa dizer “Hellorld” em vez de “Hello world”