- 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
Comentários do Hacker News
Por tédio, tentei fazer algo parecido no macOS em Rust, usando
#![no_std],#![no_main]e chamadas de sistemaWRITE/EXITdiretamente; mesmo assim, um “Hello, world!” visto no Ghidra ficava, de qualquer forma, em torno de 16 KBProvavelmente dá para reduzir mais com code golf, mas parece bem provável que alguém já tenha feito isso e documentado
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainEle chamava diretamente
ExitProcess,GetStdHandleeWriteFiledekernel32, 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 nissoComo referência, o banco de dados de depuração PDB relacionado tinha 208.896 bytes
maine usar_start, além de passar flags ao linker para não alinhar seçõesEm https://darkcoding.net/software/a-very-small-rust-binary-ind..., dá para chegar facilmente perto de 500 bytes desse jeito
Em uma linguagem com stack, o executável provavelmente acabará carregando pelo menos umas duas páginas, uma somente leitura e outra leitura-escrita
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
Há outra toca de coelho que a Musl pulou. No Linux, chamar funções do sistema não é apenas usar
syscalldiretamenteA 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 comoint 0x80ousysenterhttps://man7.org/linux/man-pages/man7/vdso.7.html
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 SGXTambé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
printfvirarputs, ou então escrever desde o início um hello world usandoputsdiretamenteAlém disso, seria bom dividir a etapa de compilação em quatro fases — pré-processamento, compilação, montagem e linkedição — ou adicionar
--save-tempsaocce explicar os arquivos gerados. Quando você vê o pipeline diretamente, muito do que parece mágica deixa de parecer assimLembrei 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
readelfeobjdump, 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....
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
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
writee passa por cima do restanteAté antes da chamada de sistema, essencialmente é uma cadeia de chamadas de função em que
printfchamaputs,putschamawrite, passa umchar const*e faz um pouco de contabilidade; pessoalmente, não é a parte mais interessanteO ponto em que tudo fica realmente interessante e complexo é depois da chamada de sistema. O kernel conecta o
stdoutdo 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 antialiasingDepois 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
https://en.wikipedia.org/wiki/Visual_system
Ele aborda os internos do CPython, o
conhostdo Windows, rasterização de fontes, renderização pela GPU etc.: https://gynvael.coldwind.pl/?id=754/dev/null, nada disso aconteceria_start. Por exemplo, como um processo nasce no Linux, especialmente o bastante estranhoexecve, o processo de carregar o programa na memória,binfmt_*e o poderosobinfmt_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 omallocnecessárioA 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 interpretadorSe quiser pontos extras de nerdice, basta fazer o programa dizer “Hellorld” em vez de “Hello world”