quinta-feira, 22 de novembro de 2007

Automatizando a Compilação - Criando Makefiles

Nos posts anteriores nós vimos como compilar um programa via linha de comando é simples. Também vimos que compilar um fonte dividido em alguns arquivos também não é bem fácil. No entanto, com o tempo, nosso código fonte vai crescendo, crescendo, e quando menos esperarmos, ele estará enorme e espalhado por vários arquivos e, se dermos sorte, em vários diretórios, com várias opções de compilação condicional. Compilar manualmente uma aplicação assim é bastante cansativo. É aí que entra o Makefile.

O Makefile é uma coleção de instruções que devem ser executadas para compilar nosso programa. Depois de modificar alguns fontes, e usarmos o comando "make" (ou "gmake" se estivermos usando ferramentas GNU), nosso programa será recompilado o usando mínimo de comandos de compilação necessários. Somente os arquivos que foram alterados e aqueles que dependem destes arquivos serão recompilados. Claro que isto não é feito via uso de mágica (tudo bem que nas Histórias do Elfo, alguns magos fariam uso). Você precisa fornecer as regras de compilação para diversos arquivos e tipos de arquivo, e também a lista de dependências entre estes (por exemplo, se "a" for alterado, "b", "c" e "d" deverão ser recompilados), no entanto, isto só precisa ser feito uma única vez.



Estrutura de um Makefile

Um arquivo Makefile tem, normalmente, a seguinte estrutura:

Definição de Variáveis: Estas linhas definem valores para variáveis (duh). Por exemplo:

CFLAGS = -g -Wall
SRCS = main.c file1.c file2.c
CC = gcc

Regras de dependência: Estas linhas definem sob que condições determinado arquivo (ou tipo de arquivo) precisa ser recompilado. Por exemplo:

main.o: main.c
    gcc -g -Wall -c main.c

Esta regra significa que "main.o" tem de ser recompilado sempre "main.c" for modificado. A regra continua nos dizendo que para compilar "main.o", o comando "gcc -g -Wall -c main.c" necessita ser executado.

Note que cada linha na lista de comandos deve começar com um caracter TAB. O comando "make" é muito sensível em relação à sintaxe do arquivo Makefile.

Ordem de Compilação

Quando um makefile é executado, nós dizemos para o comando make para compilar um alvo específico. Este alvo é simplesmente o nome que aparece no começo de uma regra. Ele pode ser o nome de um arquivo a ser criado, ou somente um nome que é usado como ponto de início.

Quando make é invocado, Ele primeiro resolve todas as atribuições de variáveis, de cima para baixo, e quando ele encontra uma regra "A" cujo objetivo corresponde a determinado alvo (ou a primeira regra, caso não seja informada nenhuma), ele tenta resolver esta regra. Primeiramente, ele resolve recursivamente todas as dependências que aparecem na regra. Se uma regra de dependência não tem uma correspondência, mas há um arquivo em disco com esse nome, ele assume que a dependência está atualizada. Depois que todas as dependências forem resolvidas, e nenhuma delas exigindo manipulação, ou referencia a um arquivo mais novo que o alvo, a lista de comandos para a regra "A" será executada.

Isso parece um pouco complexo, mas com o exemplo a seguir você verá que é mais simples do que parece.

Iniciando com algo Simples - Um Makefile para um programa com um único arquivo

Vamos ver primeiramente um exemplo simples de como usar um arquivo Makefile para compilar um programa criado em um único fonte

# Regra default para criar o programa
all: main

# Compilando o programa
main.o: main.c
    gcc -g -Wall -c main.c

# Ligando o programa
main: main.o
    gcc -g main.o -o main

# Limpando tudo o que foi gerado automaticamente pelo comando clean
clean:
    /bin/rm -f main main.o

Algumas observações em relação este Makefile:

Nem todas as regras são usadas em todas as invocações de make. A regra "clean", por exemplo, não é utilizada normalmente quando compilando um programa, mas pode ser usado para remover objetos que são criados a partir do fonte, para diminuir o uso de espaço em disco.

Uma regra não necessita obrigatoriamente de ter nenhum tipo de dependência. Isso significa que, se executarmos o make para resolver essa regra, ela irá sempre executar sua lista de comandos, como na própria regra "clean" acima.

Uma regra não necessita obrigatoriamente de ter nenhum comando. Por exemplo, a regra "all" simplesmente invoca outras regras, mas não necessita de nenhum comando para executar. Isto é conveniente para que, se alguma pessoa rodar o make sem argumentos, esta regra seja executada, pois é a primeira regra encontrada no arquivo.

Nós usamos o caminho completo do comando rm em vez de usar somente rm, por que muitos usuários tem este comando como alias para algum outro comando, como o rm -i e, usando o caminho completo do comando, evitamos estes aliases.

Passando para algo mais Complexo - Um Makefile para um programa dividido em múltiplos arquivos

Qualquer bom programa que se preze, por mais simples que as vezes ele possa ser, usualmente nos teremos os fontes divididos em vários arquivos (salvo situações especiais). É nestes casos em que os arquivos estão espalhados e são interdependentes entre si que nós vemos o grande potencial dos Makefiles (que o diga o pessoal que desenvolve o kernel Linux).

# regra default para criar o programa
all: prog

# o programa é feito de vários arquivos fontes
prog: main.o arq1.o arq2.o
    gcc main.o arq1.o arq2.o -o prog

# regra para o arquivo "main.o"
main.o: main.c arq1.h arq2.h
    gcc -g -Wall -c main.c

# regra para o arquivo "arq1.o"
arq1.o: arq1.c arq1.h
    gcc -g -Wall -c arq1.c

# regra para o arquivo "arq2.o"
arq2.o: arq2.c arq2.h
    gcc -g -Wall -c arq2.c

# Limpando tudo o que foi gerado automaticamente pelo comando clean
clean:
    /bin/rm -f prog main.o arq1.o arq2.o

Algumas observações em relação ao Makefile:

Há uma regra para cada arquivo fonte. Isto pode aparentemente parecer redundante, mas no futuro nós veremos como lidar com isso.

Nós adicionamos dependência para arquivos incluídos (arq1.h e arq2.h) onde eles são utilizados. Se um destes arquivos de definição forem alterados, os arquivos que os utilizam necessitam serem recompilados também. Isso não é sempre verdadeiro mas, é sempre melhor fazer uma tarefa redundante que ter arquivos que não estão sincronizados com o código fonte.

Usando flags para o compilador e para o linker

Alguém me fez lembrar que as vezes (e são bem na maioria das vezes), há várias tarefas repetitivas nas regras do Makefile. Por exemplo, o que fazer se quizermos mudar as flags da compilação e passar a usar otimização(-O2) no lugar de adicionar informação de depuração(-g)? Nós poderíamos ir lá e alterar manualmente a flag para cada regra. Isto não parece muito trabalho quando se está trabalhando com somente 3 arquivos, mas se torna uma tarefa entediante quando se está lidando com uma grande quantidade de arquivos, possivelmente espalhados por vários diretórios (de novo, que o diga o pessoal que desenvolve o kernel Linux).

A solução para este problema é justamente fazer uso de variáveis para armazenar vários tipos de flags, e ainda comandos inteiros. Isto é especialmente útil quando se tenta compilar o código em diferentes compiladores, ou mesmo em diferentes plataformas, onde um mero comando rm pode estar em um diretório diferente ou mesmo substituído por outro comando, em cada plataforma.

Vamos ver então nosso mesmo Makefile anterior, mas desta vez com a inclusão do uso de variaveis no mesmo:

# usa o "gcc" para compilar os fontes
CC = gcc
# o linker é também o "gcc". em outros compiladores pode acontecer de ser algo diferente
LD = gcc
# As flags do compilador vão aqui
CFLAGS = -g -Wall
# As flags do linker vão aqui. Atualmente não há nenhum, mas se mudarmos a compilação para
# otimização de código, nós vamos com certeza querer usar a flag "-s" para retirar informações
# de depuração e de símbolos do código final
LDFLAGS =
# use este comando para apagar arquivos
RM = /bin/rm -f
# lista dos objetos gerados
OBJS = main.o arq1.o arq2.o
# nome do programa executável
PROG = prog

# regra default, para compilar tudo
all: $(PROG)

# regra para linkar o programa
$(PROG): $(OBJS)
    $(LD) $(LDFLAGS) $(OBJS) -o $(PROG)

# regra para o arquivo "main.o".
main.o: main.c arq1.h arq2.h
    $(CC) $(CFLAGS) -c main.c

# regra para o arquivo "arq1.o".
arq1.o: arq1.c arq1.h
    $(CC) $(CFLAGS) -c arq1.c

# regra para o arquivo "arq2.o".
arq2.o: arq2.c arq2.h
    $(CC) $(CFLAGS) -c arq2.c

# regra para limpar arquivos que podem ser recompilados
clean:
    $(RM) $(PROG) $(OBJS)

Algumas observações em relação ao Makefile:

Nós definimos várias variáveis em nosso Makefile. Isso torna mais fácil modificar as flags de compilação, o compilador que está sendo utilizado, etc. É uma boa prática criar variáveis até para itens que aparentemente não irão se alterar, mas que na verdade, em tempo, irão.

Nós ainda temos o problema com o fato de que ainda precisamos ter uma regra para cada fonte. Se nós quizermos mudar o formato dessas regras, será uma tarefa ainda mais entediante. Na próxima seção mostraremos como evitar esses tipos de problemas.

"Uma Regra" para Todos Dominar - Usando regras para tipos de arquivo

Agora que tiramos as redundâncias em relação à flags e outras coisas que eventualmente podem mudar em relação ao processo de compilação de nosso programa, nós vamos mostrar como eliminar as regras redundantes, e tentar usar uma única regra para todos os fontes. De qualquer maneira, eles serão compilados da mesma forma. Aqui um Makefile alterado:

# retiramos todas as definições de variáveis, pois são exatamente como no
# makefile anterior
.
.
# regra de linking continua o mesmo de antes
$(PROG): $(OBJS)
    $(LD) $(LDFLAGS) $(OBJS) -o $(PROG)

# agora começam as meta-regras para compilar qualquer arquivo "C"
%.o: %.c
    $(CC) $(CFLAGS) -c $<

Duas coisas precisam ser explicadas sobre estas meta-regras

O caractere % é um coringa, que combina com qualquer parte do nome do arquivo. Se mencionarmos % várias vezes em uma regra, todas elas precisam combinar com o mesmo valor, em uma determinada regra. Assim, nossa regra significa: "Um arquivo com sulfixo .o depende de um arquivo com mesmo nome, mas sulfixo .c"

A string "$<" faz referência à lista de dependências, que foi verificada pela regra (em nosso caso, o nome completo do fonte). Existem outras strings similares, como "$@" que se refere ao nome completo do fonte, ou "$*", que se refere à parte que foi verificada pelo caracter "%".

Automatizando a Criação de Dependências

O maior problema de se usar regras implícitas, é que nós perdemos a lista completa de dependências, que são únicos para cada arquivo. Isto pode ser resolvido usando-se regras extras para cada arquivo, mas sem nenhum comando. Isso pode ser feito manualmente, ou automatizado de várias formas diferentes. Nós demonstraremos o uso do programa makedepend.

# define a lista dos fontes
SRCS = main.c arq1.c arq2.c
.
.
# grande parte do makefile se mantém como antes
# abaixo, nós adicionamos as seguintes linhas:

# regra para criar listas de dependências, e escrevê-las em um arquivo
# chamado ".depend"
depend:
    $(RM) .depend
    makedepend -f- -- $(CFLAGS) -- $(SRCS) > .depend

# agora adiciona uma linha para adicionar a lista de dependências

include .depend

Agora, se rodarmos o comando "make depend", o programa makedepend irá escanear os fontes dados, cria a lista de dependências para cada um deles, e escreve as regras apropriadas no arquivo ".depend". Assim que esse arquivo for incluído no makefile, nós poderemos compilar a aplicação e estas dependências serão verificadas durante a compilação do programa.

Esta não é a única forma de se gerar listas de dependências. Que fiquem avisados os programadores que estiverem interessados nessa questão, leiam sobre a flag "-M" do compilador, e leiam com cuidado o manpage do makedepend. Note também que o manpage do gnu make sugere diferentes formas para fazer com que as listas de dependências sejam criadas automaticamente quando compilando o programa. A vantagem dessa abordagem é que a lista de dependência estará sempre atualizada. A desvantagem é que várias vezes ele será rodado sem necessidade, atrasando um pouco o processo de compilação. Somente com a experimentação irá mostrar exatamente a melhor forma de se usar cada alternativa.

Considerações Finais

Chegamos ao final de mais um tópico sobre o processo de criação de aplicativos em C no linux, dessa vez automatizando um processo que por muitas vezes é bastante repetitivo e cansativo. Pensem vocês o quanto a tarefa de compilação de um kernel linux ou um gnome da vida seriam difíceis sem esta ferramenta facilitadora? Nem precisamos ir longe assim não, basta uma aplicação de tamanho médio, com algumas dezenas de arquivos, para notarmos o quanto a tarefa é simplificada para simples comandos.

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.