quinta-feira, 15 de novembro de 2007

Compilando programas em C no linux

Bom dia pessoALL.

Estou escrevendo este post, e os próximos que virão pois, como muitos já sabem, eu estou começando a aprender a programar em C. Atualmente eu uso o C++ Builder da CodeGear(Borland) por sua familiaridade com o Delphi, que até um tempo atrás eu dominava. No entanto, não utilizo Windows tanto quanto algum tempo atrás, e por isso, eu acabei procurando uma solução que eu pudesse usar no linux sem maiores problemas. Descobri então a facilidade que é programar em C no mesmo, usando o GCC. Mas, deixemos de enrrolação e vamos ao que interessa ...

Este post tem como objetivo dar um entendimento básico de como compilar programas em C ou C++ em um sistema Linux. Em breve postarei material sobre como usar o depurador gdb ou mesmo construir suas proprias bibliotecas em C, mas este não é o objetivo agora. Agora, vamos compilar código !!

Para não complicar muito as coisas, já que também eu não tenho conhecimento suficiente da linguagem, nós ficaremos só com exemplos simples, que também ajudarão para melhor entender o uso dessas ferramentas sem se apegar em preocupações com o código criado. Neste post, nós estaremos lidando com as ferramentas de compilação diretamente da linha de comando. Existem, no entanto, várias IDEs no mercado que simplesmente automatizam todos estes passos, mas como nosso objetivo é aprender como elas funionam, elas não serão usadas aqui. O bom e velho vi será nosso amigo nessas horas !! Quem precisa de IDE quando tem o vi ??

Em resumo, nós veremos como compilar um programa, como compilar vários arquivos-fonte em um único programa, como adicionar informação de depuração e finalmente, como otimizar o código...


Compilando um único arquivo C em um programa

A maneira mais fácil de compilação é exatamente quando você tem todo o seu código fonte em um único arquivo. Isto remove vários passos desnecessários de sincronização entre vários arquivos, entre outras coisas. Vamos então criar um arquivo chamado main_unico.c com o código a seguir:
#include <stdio.h>

int main(int argc, char* argv[]) {
printf ("O Well Come!\n");
return 0;
}

Para compilar este código, nós usaremos este comando:
cc main_unico.c

Note que nós assumimos que o nome do compilador é "cc". Se estivéssemos usando um compilador GNU, o comando seria "gcc". Se estivéssemos em outros ambientes, o comando poderia variar (por exemplo, acc no solaris). Cada compilador tem suas mensagens (erros, avisos, etc) diferentes, mas em todos os casos, o compilador vai gerar um arquivo a.out como resultado, se a compilação se completar com sucesso.

Bom, você pode notar que "a.out" é um nome muito genérico para uma aplicação, não é mesmo? Para resolver este inconveniente, é só você compilar o código usando a seguinte expressão:
cc main_unico.c -o main_unico

Neste caso, nós incluímos a flag -o para determinar que o nome do programa compilado será main_unico.

Rodando o programa resultante

Depois de criarmos o programa, para rodá-lo é bem simples. Simplesmente digite seu nome no prompt, como a seguir:
main_unico

No entanto, isso requer que o diretório corrente esteja na variável de ambiente PATH (que é onde o sistema operacional deve procurar por aplicações, para não precisarmos ficar usando todo o tempo o path absoluto da aplicação). Em muitos casos, a aplicação não estará em um destes locais. Neste caso, para você executar a aplicação de onde vc está, é só chamar assim:
./main_unico

O "ponto barra" indica explicitamente ao sistema para executar a aplicação de dentro do diretório atual. Isso é útil caso nossa aplicação tenha o mesmo nome de uma aplicação do sistema.

Outro problema que pode ocorrer em alguns sistemas com segurança maior é que após a compilação, o programa seja criado sem permissões de execução. Nestes casos, nós podemos resolver isso setando a flag de execução para nosso programa, como em:
chmod +x main_unico

Criando Código para Depuração

Normalmente, quando escrevemos um programa, nós vamos querer que seja possível depurá-lo, ou seja, executar o programa passo-a-passo no depurador, verificar variáveis e a pilha de ativação, entre outras tarefas. Para que possamos depurar o nosso programa, é necessário que o compilador insira informações de depuração que serão usadas pelo depurador para referenciar o código à parte do programa que está sendo executado. Para que isto ocorra, nós devemos adicionar a flag "-g" para que o compilador o faça:
cc -g main_unico.c -o main_unico

Como citado antes, a flag "-g" fará com que o compilador gere as informações de depuração e as insira dentro do código executável, possibilitando o depurador inspecionar o programa. No entanto, estas informações adicionais aumentam o tamanho do executável. Caso não precisemos mais desta informação, no lugar de termos de compilar o programa novamente, podemos usar o comando "strip" para remover as informações de depuração:
strip main_unico

Você notará que o tamanho do arquivo será ainda menor que se não tivéssemos usado a opção "-g". Isto acontece porque mesmo uma aplicação que foi compilada sem a opção "-g" contém algumas informações sobre símbolos (nomes de funções, no caso) que o comando "strip" remove. Olhe o exemplo abaixo, para nosso código main_unico.c
$ gcc main_unico.c -o main_unico
6323 main_unico*

$ gcc -g main_unico.c -o main_unico
7131 main_unico*

$ strip main_unico
2776 main_unico*

Criando Código Otimizado

Depois de termos criado um programa e depurado ele apropriadamente, nós normalmente vamos querer que ele seja compilado em um código mais eficiente, consequentemente com um executável final menor. O compilador pode nos auxiliar nessa tarefa otimizando o código, tanto para rapidez (que roda mais rápido, mas exige maior uso de memória) ou espaço (que economiza memória, mas deixa as operações mais lentas), ou uma combinação das duas. A maneira mais básica de se produzir código otimizado é dessa forma:
cc -O main_unico.c -o main_unico

A flag "-O" dirá para o compilador que ele deverá otimizar o código. Isto as vezes leva a compilação a demorar mais na compilação, por efetuar passos adicionais durante a compilação para tornar o código mais eficiente. Esta otimização teoricamente não afeta o modo como a aplicação deve funcionar. Usualmente, nós podemos também determinar o nível de otimização do nosso compilador adicionando um número logo após a flag "-O", que determina o nível de otimização que será utilizado. Quanto maior este número, melhor otimizado nosso programa será,e mais demorada será a tarefa de compilação. Uma nota que devemos fazer é que quanto maior é a otimização aplicada em nosso código fonte, maiores são as alterações no código de modo a torná-lo mais eficiente, o que pode aumentar a chance de que uma otimização imprópria seja executada, já que algumas otimizações tendem a não conservar o modo original da execução daquele bloco otimizado.
Por exemplo, é conhecido que usar a otimização maior que 2 com o gcc resultam em bugs no programa executável. Esteja avisado então ;)

Mostrando Avisos Adicionais de Compilação

Normalmente, o compilador só gera mensagens de erro para código que não é compatível com o padrão "C Standard", e erros que usualmente tendem a causar erros de runtime. No entanto, nós podemos instruir o compilador a mostrar avisos que são menos relevantes, mas que podem ser úteis para melhorar a qualidade do código que estamos criando, e também expor bugs que mais tarde poderão se tornar grandes problemas em nosso código. Com o gcc, usamos a flag "-W" desta maneira:
cc -Wall main_unico.c -o main_unico

Primeiramente, isto irá nos chatear com uma série de mensagens sem nenhuma relevância para nosso código. No entanto, é preferível eliminar estes warnings que não usar esta flag. Isso pode nos poupar um tempo precioso que eventualmente perderíamos depois, durante a depuração do programa em busca de um erro que não é identificado facilmente.

Note que, as vezes, a flag "-Wall" gerará tantos warnings que as vezes seria melhor se ele tivesse mais níveis de prioridade nos warnings. Lendo mais sobre esta flag no man ou no help do compilador, você encontrará mais informações sobre várias formas de se usar a flag "-W" e quais delas podem realmente fazer diferença em termos de warnings. Inicialmente, isso não fará muito sentido, mas com a experiência, vc aprenderá melhor o que fazer com cada mensagem. Eu sei, No Delphi eu tinha esse problema ...

Compilando vários arquivos C em um programa

Agora que você aprendeu a compilar um programa único corretamente, vamos aprender a compilar um programa dividido em vários arquivos.

Antes de mais nada, separar o nosso programa em vários arquivos tem uma série de vantagens, enquanto um arquivo único tem várias limitações, como:

De acordo com que o codigo fonte do arquivo cresce, o tempo de compilação tende a aumentar, além do que, em qualquer pequena alteração no código, o arquivo inteiro necessita ser recompilado;

É muito difícil, senão impossível, para várias pessoas trabalharem em conjunto em um arquivo único;

Gerenciar o seu código será mais difícil, já que é mais difícil rastrear mudanças feitas no código.

A solução mais trivial é dividir o código fonte em vários arquivos, cada um agrupando funções de mesmo contexto dentro da aplicação.

Existem duas formas de se compilar um programa com vários arquivos. A primeira delas é usar uma única linha de código para copilar todos os arquivos. Suponha que tenhamos nosso código fonte organizado da seguinte forma, nos arquivos a.c; b.c e main.c:
/* a.c */
int func_a() {
return 5;
}

/* b.c */
int func_b() {
return 10 * 10;
}

/* main.c */
#include <stdio.h>

/* define algumas funções externas */
extern int func_a();
extern int func_b();

int main(int argc, char* argv[]) {
int a = func_a();
int b = func_b();
char c;
char* bc = &c;

printf("O Well Come,\n");
printf("a - %d; b - %d;\n", a, b);

return 0;
}

Nós poderemos compilar este código em uma única linha, da seguinte forma:
cc main.c a.c b.c -o multi_main

Isto fará com que o compilador compile cada arquivo separadamente, e então ligue todos no executável chamado multi_main. Duas observações devem ser levantadas:

Se eu definir uma função em um arquivo, e tentar acessá-lo de um segundo arquivo, é necessário declarar a função no segundo arquivo como uma função externa, o que é feito usando a declaração "extern" antes da mesma.

A ordem de apresentação dos arquivos pode ser diferente. O compilador (na realidade, o linker) terá que saber como juntar o código dos três arquivos para formar o executável, mesmo que o primeiro arquivo use uma função que está definida em um dos arquivos posteriores.

O problema de compilar o programa desta forma é que mesmo se nós alterarmos um dos arquivos fonte, todos os três deverão ser recompilados quando rodarmos o compilador novamente.

Para resolver este inconveniente, nós podemos dividir a compilação em estágios, compilando cada arquivo separadamente e no final, ligando todos em um único programa executável:
cc -c main.c
cc -c a.c
cc -c b.c
cc main.o a.o b.o -o multi_main

Os primeiros três comandos pegam, cada um, o código fonte e compilam em seus respectivos "código objeto". Este código objeto tem o mesmo nome do código fonte, mas com a extensão .o, no entanto, apesar de estes arquivos conterem código em linguagem de máquina, eles não estão prontos para serem executados, pois precisam passar pelo linker e ter suas dependências resolvidas. O Linker, por sua vez, pega os três .o, resolve seus símbolos e os junta em um único arquivo. Isso garante que quando a função func_a for chamada do arquivo main.o, a função do arquivo a.o que será executada. É também responsabilidade do Linker ligar o programa à biblioteca padrão do C e as bibliotecas declaradas nos includes do código, que é são bibliotecas compartilhadas.

Para você ver o quanto esta divisão do nosso código em vários arquivos é mais prático em termos de compilação, caso tenhamos que recompilar o arquivo a.c, nós só precisaríamos destes passos:
cc -c a.c
cc main.o a.o b.o -o multi_main

Em nosso exeplo é difícil mostrar a diferença que isso faz em termos de compilação mas, imaginem você compilando o kernel e então resolve adicionar um módulo à compilação do mesmo. Já pensou ter que recompilar quase 10Mb de código ?? Então, isso facilita muito o trabalho de compilação.

Aprofundando no conceito de Compilação - Passos de Compilação

Agora que nós vimos que a compilação não é um simples processo, vou tentar mostrar a lista completa de passos tomados pelo compilador para compilar um programa:

Driver: O que nós invocamos chamando "cc". É atualmente a aplicação que guia todo o processo de compilação, ativando cada parte do processo de compilação. Nós chamamos "cc" e ele passa a saída de cada parte do processo como entrada para o próximo.

Pre-processador C: Normalmente chamado "cpp", ele é o responsável por pegar o código em C e resolver todas as diretivas de processamento (arquivos #include, macros #define, inclusão condicional de código fonte via #ifdef, entre outros). Você pode invocá-lo separadamente usando a flag "-E".
cc -E main_unico.c

O Compilador C: Normalmente chamado "cc1", este o compilador realmente, que transforma o código fonte em assembler. Como você já viu, nós o invocamos usando a flag "-c", que juntamente com o cpp (e possivelmente o otimizador), geram o código em assembler.

Otimizador: As vezes vem como um módulo separado, as vezes integrado ao compilador. O otimizador é responsável por fazer a otimização do código objeto (duh). O otimizador é independente de linguagem, pois pega o código em assembler e retorna também código assembler, fazendo somente mudanças no mesmo de forma a tornar o código otimizado (duh²).

Assembler: As vezes chamado de "as". Ele pega o código em assembler gerado nas fases anteriores e converte para linguagem de máquina que é mantida nos arquivos objeto (.o). No gcc, você pode informar para o driver que gere somente o código assembler usando a flag -S
cc -S main_unico.c

Linker-Loader: Esta é a ferramenta que pega todos os arquivos objeto (e bibliotecas C), e as agrupa em um único executável, que o sistema operacional suporte. Atualmente no linux, o formato utilizado é conhecido por ELF. Em sistemas mais antigos e outras versões de Unix, é comum o uso de um outro formato, chamado "a.out". Estes formatos definem a estrutura interna do arquivo executável - localização do segmento de dados, localização do segmento do código executável, localização das informação de depuração, entre outros. É ele também o responsável por fazer as ligações externas do programa à bibliotecas C.

Como você pode ver, a compilação é na verdade, dividida em vários estágios, e nem todos os compiladores seguem exatamente estes passos, e em alguns casos (Por exemplo, os compiladores C++, que tem que lidar com situações diferentes) a situação é muitas vezes mais complexa. No entanto, a situação é similar a esta - dividir a compilação em vários passos dá ao programador uma maior flexibilidade, e permite aos desenvolvedores que reusem quantos módulos forem possíveis em diferentes compiladores e diferentes linguagens (substituindo os módulos de pre-processamento e compilação) ou para diferentes arquiteturas (substituindo os módulos de geração de assembler e o link-loader).

Considerações finais

Espero que com esse post eu possa ter esclarecido para você grande parte da tarefa de compilação usando as ferramentas que já vem com o linux. No começo é meio difícil se acostumar com a linha de comando, mas logo as coisas se ajeitam e você pega o ritmo da coisa.

Para a confecção deste post, eu usei várias fontes, entre elas a Wikipédia e o material do curso de C da UFMG, disponível em seu site. Agradeço também ao meu amigo Diego "Homer Simpson", que me ajudou na tarefa de tradução de algumas partes do material que eu usei como referência para este post.