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

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:

text
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:

text
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.