A compilação e os módulos em C e em C++

Dezembro 2016

Este artigo visa introduzir os conceitos básicos da compilação em C e C++ e da programação modular.

Ele ajuda a compreender melhor as mensagens de erro do compilador. Os conceitos discutidos aqui não têm nada a ver com o fato de você usar Windows ou Linux. No entanto, vamos trabalhar mais no linux para ver com mais detalhes o que acontece durante a compilação de um programa C ou C++.

Introdução


Em geral, um compilador é projetado para converter um arquivo de texto (que contém um código-fonte) em um arquivo binário (por exemplo, um executável). Uma vez que o executável foi construído, podemos executá-lo como qualquer outro programa. Este programa pode ser executado mesmo sem código fonte.
Uma linguagem compilada (como a C ou a C++) se opõe a uma linguagem interpretada (como um script shell) ou pseudo-interpretado (por exemplo, um programa python).
Na C, a compilação vai transformar o código C de um programa em código nativo, ou seja, uma sequência de instruções binárias diretamente compreensíveis pelo processador.

Instalação de um compilador C e C++

No linux


Usamos o gcc e o g++ em geral. Para instalá-lo passamos pelo seu gerenciador de pacotes habitual. Por exemplo, no debian (ou em uma distribuição baseada no debian) basta digitar como root ou com um sudo:
aptitude update 
aptitude safe-upgrade 
aptitude install gcc g++

Também podemos instalar da mesma forma um ambiente de desenvolvimento, tais como o kdevelop (no KDE) ou o anjuta (no gnome).

No windows


Você pode usar o dev-cpp ou o código::blocks: dois ambientes de desenvolvimento livres que se baseiam no gcc e g++:
www.bloodshed.net
www.codeblocks.org

Artigo relacionado: onde encontrar um compilador C

As fases da compilação


O primeiro passo consiste em escrever o código-fonte em linguagem C ou C++ (arquivos .c e .h em em C e .cpp e .hpp em C++). Depois, executamos uma compilação, por exemplo, com gcc (em C) ou g++ (em C++). A compilação (no sentido lato do termo) é dividido em três fases principais.

1) A pré-compilação (pré-processador)


O compilador começa pela aplicação de cada instrução passada pelo pré-processador (todas as linhas que começam com um #, incluindo o #define). Estas instruções são, na realidade, muito simples, pois elas apenas recopiam ou excluem seções de código sem compilá-los. É a substituição de texto, nem mais nem menos.

É particularmente neste momento em que os "#define" encontrados em um arquivo fonte (.c ou .cpp) ou em um header (.h ou .hpp) são substituídos pelo código C/C++. Após esta etapa, então, não haverá mais instruções começando por um #, para o compilador.

2) A compilação


Em seguida, o compilador compila cada arquivo-fonte (.c e .cpp), ou seja, ele cria um arquivo binário (.o) pelo arquivo fonte, exceto para o arquivo que contém a função principal. Esta fase é a fase de compilação </ ital> em si.

Estas duas primeiras etapas são realizadas pelo cc quando usamos o gcc/g++. Enfim, o compilador passa para o arquivo com a função principal.

3) A ligação (link)


Concluindo, o compilador agrega cada arquivo .o com eventuais arquivos binários das bibliotecas que são utilizadas (arquivos .a e .so no linux, arquivos .dll e .lib no Windows).
- Uma biblioteca dinâmica (.dll e .so) não é recopiada no executável final (o que significa que o programa é menor e receberá atualizações da dita biblioteca). Em troca, a biblioteca deve estar presente no sistema onde o programa é executado.
- Uma biblioteca estática (.a) é recopiada no executável final, o que faz com que este seja completamente independente das bibliotecas instaladas do sistema no qual ele foi recopiado. Em troca, o executável é maior, ela não receberá atualizações desta biblioteca, etc ...

O linker verifica, em particular, se cada função chamada no programa não é apenas declarada (isso é feito durante a compilação), mas também implementada (coisa que ele não tinha verificado nesta fase). Ele também verifica se uma função não foi implementada em vários arquivos .o.

Esta fase, também chamada de edição de link, constitue a fase final para obter um executável (notado .exe no windows, em geral sem extensão no linux).

Esta etapa é realizada pelo ld quando utilizamos o gcc/g++.

Warnings e erros


Obviamente, em um ambiente de desenvolvimento, basta clicar em "build" e estas três fases se desenvolverão de forma transparente. Porém, é importante guardá-los em mente para interpretar corretamente as mensagens de erro ou de warning de um compilador, caso haja algum.
Lembre-se que um <ital>warning
significa que o código é ambíguo e que pode ser interpretado de forma diferente de um compilador para outro, mas o executável pode ser criado.


Por outro lado, um erro significa que o código não pode ser compilado completamente e que o executável não pode ser (re)criado. Se um código pode compilar com "warnings" e deve compilar sem erros, é melhor tentar codificar de modo a não ter nenhum erro, nem aviso.

As grandes etapas para escrever um programa em C ou em C++

Escrever o código fonte


Um simples bloco de notas pode bastar, por exemplo, podemos escrever no arquivo plop.c :
#include <stdio.h> 

int main(){ 
    printf("plop !\n"); 
    return 0; 
}

Compilar


Aqui, estou em linux então chamo diretamente o gcc (-W e -Wall para exibir mais mensagens para verificar se o código é limpo, o -o plop.exe pode dizer que o executável a ser criado deve se chamar plop.exe):

gcc -W -Wall -o plop.exe plop.c

Implicitamente, o compilador faz as três etapas que descrevi logo antes.

1) Pré-compilação
/* Tudo que é definido pelo <stdio.h>, inclusive o printf() */ 

int main(){ 
    printf("plop !\n"); 
    return 0; 
}

2) Compilação (ele encontra o printf porque este está dclarado no <stdio.h>)
3) Edição de link (ele encontra o printf no binário da lib c). Aliás, podemos vê-lo no linux com o ldd:
ldd plop.exe

.. que dá:
        linux-gate.so.1 =>  (0xb7f2b000) 
        libc.so.6 => /lib/i686/cmov/libc.so.6 (0xb7dbb000) 
        /lib/ld-linux.so.2 (0xb7f2c000) 
Na segunda linha, podemos ver que ele usa a lib c. Depois, ele cria o plop.exe. Além disso, verificamos se não há nem erro, nem warning.

Execução


Agora basta executar:
./plop.exe

... o que dá, como esperado:
plop !

Se um erro ocorre neste momento (erro de segmentação, memória insuficiente, etc ...) muitas vezes é necessário usar um desbloqueador (por exemplo, gdb ou ddd), rever o código-fonte, etc ... Em qualquer caso, não é um problema de compilação.

Artigo relacionado: Linguagem C - C++ Falha de segmentação

Atenção, grande armadilha no windows

Para executar um programa no windows, dois métodos são possíveis.

Método n° 1: Podemos lançar um programa através do MS-DOS (clicando em Iniciar Executar e digitando "cmd"). Em seguida, nos posicionamos no diretório certo com o comando cd e executmos a o programa. Neste caso, tudo ficará bem.

Método n° 2: Se executarmos o programa a partir do Explorer, o Windows executará os comandos do MS-DOS, que executará o programa, este terminará, passará o controle para os comandos do MS-DOS e o Windows fechará os comandos do MS-DOS. Concretamente, não veremos nada. Assim sendo, precisaremos botar em "pausa" antes do fim do programa se quisermos lançar o seu executável, desta maneira:
#include <stdio.h> 

int main(){ 
    printf("plop !\n"); 

    getchar(); /*o programa não avançará enquanto você estiver pressionando um botão*/ 
    return 0; 
}

Os erros comuns

Erro de compilação


Suponhemos que o meu código fonte esqueceu de incluir o arquivo <stdio.h> (no qual foi declarada a função printf) ou um ";", eu obterei uma mensagem de erro do compilação (syntax error, parse error, etc). Estes são os erros mais clássicos.

Erro de ligação


Esses erros são mais sutis, porque eles não se dizem respeito à sintaxe, mas à maneira como foi estruturado e compilado o programa. Eles são fáceis de reconhecer quando usamos gcc ou g++ já que as mensagens de erro correspondentes falam de ld (o linker).

Primeiro exemplo: mutlidefinição

Um erro de ligação pode ocorrer quando você escrever o código de um programa com vários arquivos. Vamos ilustrar este tipo de erro.
Suponhemos que o meu programa foi escrito através de três arquivos: a.h, a.c, e main.c. O header a.h foi incluído pelos dois arquivos fonte main.c e a.c. O arquivo main.c contém a função main().

1) Se eu compilar apenas o a.c., o arquivo sem "main", deveremos especificá-lo ao compilador (opção -c no gcc) se não, este não saberá como criar um executável, já que não há um ponto de partida. É por isso que o arquivo com o "main" (sem a opção -c) e os outros arquivos fonte se compilarão de forma diferente. Neste caso:

gcc -W -Wall -c a.c 
gcc -W -Wall -o plop.exe main.c a.o

As opções -W e -Wall exibem mais mensagens de warning.
- O primeiro comando constrói a.o a partir do a.c.
- O segundo vai gerar o binário associado ao main.c, o reúne com o a.o, produzindo assim um executável (plop.exe)
Vemos imediatamente que, se o programa contém um erro no a.c, o compilador irá gerar um erro ao compilar a.c. Isto irá causará erros em cascata nos outros arquivos. Portanto, quando um programa não compila, começamos pelas primeiras mensagens de erro, os resolvemos, os recompilamos, etc ... até que todos os erros sejam resolvidos.

2) Lembremos que, em tempo normal, declaramos a função no header (por exemplo a.h) :
void plop();

... e que implementamos no arquivo fonte (por exemplo, a.c):
#include "a.h" 
#include <stdio.h> 

void plop(){ 
  printf("plop !\n"); 
}

Suponhamos agora que eu implemente a função plop() no a.h (ou seja, que a função não foi apenas declarada em a.h). Em outras palavras, o arquivo a.h contém
#include <stdio.h> 

void plop(){ 
  printf("plop !\n"); 
}

... e o arquivo a.c contém, por exemplo:
#include "a.h" 

void f(){ 
  plop(); 
}

O arquivo a.h foi incluído pelo main.c e a.c. Assim, o conteúdo do a.h é recopiado no a.c. e no main.c. Assim, esses dois arquivos fonte terão, cada um, uma implementação da função plp() (a mesma, claro!), mas o compilador não saberá qual utilizar e gerará um erro de multidefinição durante a ligação (link):

(mando@aldur) (~) $ gcc -W -Wall main.c a.o 
a.o: In function 'plop': 
a.c:(.text+0x0): multiple definition of 'plop' 
/tmp/ccmRKAvQ.o:main.c:(.text+0x0): first defined here 
collect2: ld returned 1 exit status

Isso justifica por que, d um modo geral, a implementação de uma função deve ser feita em um arquivo fonte (.c ou .cpp) e não em um header (.h e .hpp). Note-se apenas duas exceções a esta regra em C++: as funções inline e templates (ou os métodos de uma classe template). Para mais detalhes podemos consultar esses dois artigos:

Os templates em C++
Os inlines em C++

Programação modular: multidefinições, círculos de inclusão...


Suponhemos que eu tenha três arquivos: main.c e os arquivos a.h e b.h. Suponhemos que o a.h e o b.h se incluam uns aos outros. Estes dois arquivos incluem-se mutuamente por tempo indeterminado! É aí que o pré-compilador vem ajudar, pois poder evitar que os #include se incluam indefinidamente, colocando no a.h:
#ifndef A_H 
#define A_H 
#include "b.h" 
/* O conteúdo do header a.h */ 

#endif

e no b.h :
#ifndef B_H 
#define B_H 
#include "a.h" 
/* O conteúdo do header b.h */ 

#endif

O que acontecer exatamente? O compilador vai começar com um arquivo (ex: o a.h). Como nesta fase o A_h ainda não foi definido, ele avança, define o a.h e continua a ler o header a.h, incluindo o b.h. Nesta fase o B_H ainda não foi definido então, da mesma forma, entramos no header b.h e ativamos o A_H.

Agora podemos ler o conteúdo do b.h que quer incluir o a.h. Voltamos novamente para o a.h mas desta vez, o A_H foi definido, logo, vamos ignorar o conteúdo do header. Acabamos de ler o header do b.h, o que resolve o # include "b.h" que estavamos tratando no a.h. Em seguida, terminamos o header b.h.

Este caso pode parecer estranho, mas é preciso entender que quando um header é incluído várias vezes, podem aparecer multidefinições (especialmente se as estruturas forem declaradas nos headers). É por isso que colcamos, sistematicamente, este mecanismo de trava em todos os headers.

Função declarada ... mas não encontrada


Se uma função for declarada, utilizada, mas não implementada, também ocorre um erro de ligação. Em geral, isso ocorre em dois casos:
- Declaramos uma função, mas ainda não a implementamos
- Queremos usar uma função de biblioteca, incluímos corretamente os headers correspondentes, mas esquecemos de passar em configuração do compilador as ditas livrarias.

Mais além com a compilação: makefile


Se, através de um "novo projeto", o ambiente de desenvolvimento permite gerir o seu código para compilá-lo completamente, é claro que ele deverá compilar cada arquivo .c e juntar tudo. Quando não houver ambiente de desenvolvimento e, para evitar digitar manualmente um "gcc" por arquivo fonte, criamos um arquivo "makefile", que lidará com a descrição de como construir o executável.

Na prática, devemos escrever um dos seus arquivos C/C++ a mais, para que o código seja fácil de compilar. Na verdade, se este arquivo foi escrito corretamente, basta executar o makefile para construir completamente o programa. No linux, digitamos apenas o comando:
make



Tradução feita por Lucia Maurity y Nouira

Veja também :
Este documento, intitulado « A compilação e os módulos em C e em C++ »a partir de CCM (br.ccm.net) está disponibilizado sob a licença Creative Commons. Você pode copiar, modificar cópias desta página, nas condições estipuladas pela licença, como esta nota aparece claramente.