Destrinchando o Hello, world! em C 💀
Programas simples frequentemente revelam conceitos fundamentais quando analisados em partes menores. Portanto, considere o exemplo clássico da programação: o Hello, world!, escrito em C.
Esse simples exemplo revela várias etapas envolvidas na construção e execução de um programa em C, além de expor camadas que normalmente ficam escondidas ao programar em linguagens de mais alto nível.
hello-world.c
#include <stdio.h>
int main(void)
{
printf("Hello, world!\n");
return 0;
}
Linha 0 — #include
Logo na primeira linha escrita em um programa C (#include <stdio.h>) já se faz necessário cavar fundo pra entender o que acontece de verdade.
TL;DR: #include <stdio.h> não é uma instrução que o programa executa — aqui, o include faz algo como: "copie as declarações contidas neste header file e cole no meu código-fonte."
Começando do primeiro caractere (sim, literalmente do primeiro), qual o papel do "#" antes do include? É uma pista para as preprocessor directives (que diga-se de passagem, eu não fazia a mínima ideia do que significava). O pré-processador participa das primeiras etapas da construção de um programa C, antes da compilação propriamente dita — nessa etapa, o texto do código-fonte é modificado. De forma simplificada, ele olha principalmente para as linhas que começam com #, lê a diretiva e reage a ela.
Aqui, a diretiva é a palavra include, que pode ser interpretada como o import de outras linguagens — i.e., uma forma de incluir código externo no seu programa. Todavia, é importante ressaltar que a implementação de include e import são técnicamente diferentes.
Em seguida, o <stdio.h>, que parece um código milenar secreto, é na verdade apenas um header file (que de qualquer forma, é a porta de entrada para esse suposto código milenar secreto).
Headers são arquivos que normalmente possuem a extensão .h por convenção; neles estão coisas como: declarações, definições de tipos, macros e outras informações que podem ser compartilhadas entre arquivos C.
Ou seja, durante o pré-processamento, o conteúdo desse arquivo é inserido no código-fonte do programa.
Nesse ponto surgiu uma dúvida: "as funções declaradas em stdio.h não são literalmente copiadas e coladas no meu código-fonte?". A resposta é: parcialmente. O conteúdo do header é inserido pelo pré-processador, porém ele não contém a implementação dessas funções; contém apenas suas declarações. Por exemplo, em stdio.h existe uma declaração semelhante a: int printf(const char *format, ...);. Essa linha informa ao compilador que existe uma função chamada printf, quais argumentos ela recebe e qual valor retorna, mas não mostra como ela funciona internamente.
Então surge outra pergunta: se meu código possui apenas a declaração de printf, onde está o código que realmente executa essa função? A implementação está presente nas bibliotecas padrão da linguagem C e é conectada ao programa em uma etapa posterior, chamada linking.
O linker é a ferramenta responsável por conectar o código compilado com as bibliotecas e recursos externos que ele utiliza, resolvendo referências como chamadas para funções declaradas em headers, mas implementadas em bibliotecas.
- header (stdio.h) → informa que algo existe e como usar;
- biblioteca → contém a implementação real;
- linker → conecta o programa com essa implementação.
Até aqui a pipeline pode ser interpretada da seguinte forma:
arquivo.c
|
v
pré-processador
|
v
código-fonte processado
|
v
compilador
|
v
linker
|
v
executável
E antes de prosseguirmos para a função main(), vale lembrar: tudo isso foi necessário apenas para utilizar printf e mostrar o mísero "Hello World" no console.
- "Então aquele maldito printf precisou passar por tudo isso para funcionar?"
- Sim (e na verdade, é só a ponta do iceberg).
Por que a função main geralmente retorna int?
O valor retornado por main é utilizado para informar ao sistema operacional como o programa terminou sua execução. Por convenção, o valor 0 indica sucesso, enquanto outros valores geralmente representam algum tipo de erro ou condição especial.
Como rodar o programa?
Naturalmente foi a minha primeira dúvida depois de seguir o exemplo de código do K&R (do clássico livro The C Programming Language) e escrever o hello-world.c. Aqui eu comecei a enxergar que o código-fonte é apenas uma parte de todo esse processo, e claro, o computador não executa diretamente o código escrito por humanos. Em algum momento, esse código precisa ser transformado em instruções que o processador consiga executar (representadas por 0s e 1s). Quem realiza esse trabalho é o compilador.
Daí outra pergunta surge: como eu instalo o tal do compilador de C, e fora ele, tem mais alguma outra coisa? Por sorte, para quem utiliza Linux, é comum que parte dessa infraestrutura já esteja presente no sistema, principalmente porque várias partes do próprio ecossistema Linux dependem da linguagem C. O motivo é que o kernel do Linux e muitas ferramentas GNU são escritas em C, então grande parte do sistema depende dela.
Supondo que fosse necessário instalar a parte em um sistema Linux, seria bem simples. Exemplo:
- Arch:
sudo pacman -S base-devel - Debian:
sudo apt install build-essential
Dentro dos pacotes base-devel para distribuições Arch ou build-essential para distribuições Debian, existem várias ferramentas, e.g.: GCC (compilador C/C++), binutils (linker, assembler e outras ferramentas), make e ferramentas auxiliares de build.
Se fosse em um sistema Windows, as opções mais comuns e práticas são:
- Visual Studio Community (pacote completo de desenvolvimento)
- MSYS2 (ambiente de desenvolvimento com GCC)
- MinGW-w64 (compilador GCC para Windows)
Com todas as ferramentas necessárias para compilar e executar um programa escrito em C, basta chamar o compilador e passar o código-fonte — ele cuidará de todo o resto, e você pode dormir em paz. Exemplo:
- GCC:
gcc main.c -o main - Clang:
clang main.c -o main
A ideia é a mesma: é executada a cadeia de etapas explicada na seção acima, o executável é gerado e agora basta rodar o programa com ./main se for Linux ou main.exe se for Windows.
Aprecie o output "Hello World" no seu terminal.
Exemplo simplificado do que existe no computador para compilar e executar um programa C:
Compilador
|
+-- gcc ou clang
Headers
|
+-- stdio.h
+-- stdlib.h
+-- string.h
+-- ...
Bibliotecas
|
+-- implementação do printf
+-- implementação do malloc
+-- implementação do fopen
+-- ...
Recapitulando
Pré-processador:
- processa diretivas como #include;
- insere o conteúdo dos headers.
Compilador:
- verifica o código;
- transforma o código C em código de máquina.
Linker:
- resolve referências externas;
- conecta bibliotecas;
- gera o executável final.
Sistema operacional:
- carrega o executável na memória;
- inicia o processo;
- fornece os recursos necessários para a execução.
Conclusão
Até o momento foi explorado, de forma simplificada, como um programa em C é desenvolvido e executado. Observar essa pipeline deixa evidente a quantidade de abstrações existentes entre o código-fonte escrito por humanos e as instruções realmente executadas pelo processador. E, naturalmente, sempre é possível "descer mais um nível": observar o assembly gerado pelo compilador ou até mesmo o binário final. A partir desse ponto, o próximo nível de abstração seria entender como o próprio processador foi projetado para interpretar essas instruções.
Aprender o básico de C é valioso justamente porque muitos conceitos deixam de parecer apenas "regras arbitrárias" e passam a fazer sentido. Entender essas camadas muda a forma como enxergamos software: percebemos que, através de abstrações construídas por nós mesmos, somos capazes de transformar ideias em instruções que uma máquina executa.
O trabalho da maioria dos programadores no dia a dia não consiste em analisar assembly, manipular memória diretamente ou implementar estruturas de dados do zero. Entretanto, estudar e experimentar esses conceitos na prática fortalece o modelo mental do desenvolvedor, tornando mais claro o que acontece por trás das abstrações utilizadas diariamente.
Da próxima vez que eu escrever em Python algo como products.append(product) ou print("*" * 10), será mais evidente que essas operações representam várias decisões e implementações que a linguagem esconde do programador. Em C, muitos desses detalhes precisam ser explicitamente descritos no código; em linguagens modernas, eles continuam existindo, mas são abstraídos para permitir maior produtividade.