CI067, 2018-2 © Roberto André Hexsel, 2014-2018
Makefiles
Antes de mais nada, crie um prompt vazio,
copiando a linha abaixo no seu terminal
alias prompt:=""
Três clicks no botão esquerdo para copiar, um click no meio para colar
GNU Compiler Collection
O compilador é o programa que traduz código escrito em uma linguagem
de alto nível, tal como C ou Pascal, para um programa que é o equivalente
lógico em linguagem assembly do processador que executará o
programa.
Considere uma aplicação cujo código fonte está separado em dois arquivos,
chamados de x.c, que contem a função main(), e y.c que
contem a função fun(). A figura abaixo mostra um diagrama com as
etapas da compilação dos dois arquivos para produzir o executável
a.out. Os círculos indicam os programas que traduzem "o programa" ,
de código C para código executável; junto às setas estão os nomes dos
arquivos processados pelos estágios da compilação.
Os arquivos fonte contém diretivas do tipo #include e #define
que são processadas pelo preprocessador cpp, que faz a expansão das
macros, a inclusão dos arquivos com cabeçalhos, e remove os comentários. A
saída do cpp é entregue ao compilador gcc, que faz a tradução
de C para linguagem de montagem. Ao contrário do cpp, que só
manipula texto, o compilador traduz comandos da linguagem C para
instruções em assembly.
O código em assembly é processado pelo montador as, que
produz um arquivo objeto, já com as instruções traduzidas para seus
equivalentes em binário, além da tabela de símbolos --em breve veremos uma
descrição do processo de montagem.
Os arquivos objeto, e se necessário, o código mantido em bibliotecas, são
agregados pelo ligador (ld), que finalmente produz o
arquivo executável a.out. O processo de ligação será estudado mais
adiante.
How make makes
Você trabalha no projeto gen e, inteligentemente, dividiu o código
em três módulos, sendo que cada módulo contém o código para uma das
três funcionalidades de gen.
O módulo principal consiste dos arquivos fonte gen.c hen.c ien.c. Estes
fazem a inclusão de gen.h.
O segundo módulo consiste de p.c q.c r.c. Estes fazem a inclusão de p.h.
O terceiro módulo consiste de x.c y.c z.c. Estes fazem a inclusão de x.h.
Todos os arquivos fonte fazem a inclusão das definições contidas em gen.h.
Suponha, para fins de exemplo, que a compilação de cada arquivo fonte
demora uma unidade de tempo (T), e que a ligação de todos os arquivos
objeto também demora T unidades de tempo.
A primeira compilação de seu executável custa 10T: 9 x *.c + ligação.
Se T=0,01s, a compilação pode ser considerada instantânea e o comando
abaixo é perfeitamente aceitável:
gcc -Wall -O0 -g -o gen *.c
Se T=60s, a compilação de cada objeto tem um custo significativo, e
re-compilar tudo a cada modificação trivial começa a parecer um
tanto estúpido.
Se somente um arquivo fonte foi alterado, tal como r.c, então o tempo
mínimo para compilar é 2T: a compilação de r.c mais a ligação. Para alguém
que ignora make, a compilação demoraria 600s, ou 10 minutos.
Se demora uma semana para completar seu programa, com possivelmente 60
ciclos de edição-compilação-depuração, ignorando make, você
demoraria 60x10min, o que é mais do que dez horas com compilações
inúteis. Se você sabe como tirar proveito de make, suas compilações
custariam não muito mais do que 60x20s, ou 20-30 minutos.
Há um efeito de segunda ordem importante aqui: se você deve esperar 10
minutos por uma compilação, seu foco se perde de uma forma mais irrevogável
do que se você esperar somente 2 minutos. Mais detalhes em IHC.
O programa make interpreta e executa os comandos contidos
num Makefile. Estes comandos descrevem um processo de compilação,
que pode ir de um simples
gcc -Wall x.c -o x
até a compilação automática de uma família de programas tão complexa como
as binutils ou o próprio gcc.
O manual de make encontra-se aqui.
Considere a seguinte coleção de arquivos. O conteúdo de cada arquivo está
entre as duas linhas com '---' e deve ser salvado com o nome indicado no
topo.
-- strings.h ----------------------------
int strconcat(char *, const char *);
int strrev(const char *, char *);
int strcopy(const char *, char *);
int strlength(const char *);
-----------------------------------------
-- main.c -------------------------------
#include <stdio.h>
#include "strings.h"
#define myprintfnum(n) printf("%d\n",(n));
#define myprintfstr(s) printf("%s\n",(s));
char f16[16]="abcd.efgh_ijkl-";
char d16[16];
char f8[] = "abcdefg";
char d8[] = "ABCDEFG";
char s64[64];
int main (void)
{
int num=0;
num = strrev(f16,d16);
myprintfstr(d16);
num = strcopy(f16,s64);
myprintfnum(num);
myprintfstr(s64);
num = strconcat(s64,f8);
myprintfnum(num);
myprintfstr(s64);
num = strlength(s64);
myprintfnum(num);
return(0);
}
-----------------------------------------
-- strings.c ----------------------------
char slocal[256];
int strrev(const char *si, char *so)
{
int n = 0; int m;
char *l = slocal;
while ((*l = *si)) {
l++; si++ ;
n++;
}
l--;
for (m = n; m > 0; m--)
*so++ = *l--;
*so = '\0' ;
return(n);
}
int strcopy(const char *y, char *x)
{
int i = 0;
while ( (*x++ = *y++) != '\0' )
i = i+1;
*x = '\0';
return(i+1);
}
int strconcat(char *si, const char *sf)
{
char *p = si;
int n = 0;
while (*p != '\0') {
++p;
n++;
}
while ( (*p++ = *sf++) )
n++;
return(n+1);
}
int strlength(const char *s)
{
int n = 0;
do {
++n;
} while (*s++ != '\0');
return(n);
}
-----------------------------------------
A compilação deste programa pode ser efetuada com o comando
gcc -Wall -o strings main.c strings.c
Num ciclo normal de edição, compilação, teste, depuração, o que ocorreria
são repetidas seções de edição seguidas de comandos de compilação.
Suponha que você esteja alterando somente o arquivo strings.c;
compilar todos os programas é desnecessário e uma perda de tempo.
Evidentemente, com este programa exemplo, a perda de tempo é pequena, mas
num projeto que não seja trivial, o acúmulo de desperdício é estúpido.
make pode ajudar. Criemos um Makefile para compilar o nosso
programa exemplo.
-- Makefile -----------------------------
strings: main.c strings.c strings.h
[TAB]gcc -Wall -o strings main.c strings.c
-----------------------------------------
Nosso Makefile contém somente uma regra. A sintaxe das regras é:
objetivo: dependência1 dependência2 ...
[TAB]ação1
[TAB]ação2
...
[TAB]açãoN
O objetivo (target) é o objeto a ser construído
pelas ações. As dependências determinam e sequência de
execução das regras do Makefile.
Note que cada ação DEVE ser precedida de um único caractere TAB, e
estes são mostrados em cor cinza nos exemplos.
No nosso Makefile, a regra tem como objetivo construir strings, que
é o resultado da compilação na única ação da regra. O programa strings
depende dos arquivos main.c, strings.c e strings.h, e qualquer alteração
nestes arquivos, causará a execução das ações para o objetivo strings.
Por "alteração" make entende que a data de modificação nos arquivos
das dependências é mais recente que a data de criação do objetivo. Se as
dependências são mais recentes que o objetivo, então as ações
correspondentes devem ser executadas para reconstruí-lo.
O comando
prompt: make
causa a recompilação de strings caso algum dos arquivos fonte seja
mais recente do que o executável.
A versão GNU do make procura, no diretório corrente, por arquivos
chamados GNUmakefile, makefile e Makefile, nesta
ordem. Se encontrado, as receitas do *akefile são executadas, caso
isso seja necessário.
Até aqui, nada de muito mais interessante do que um prosaico carrinho de mão.
O Makefile pode conter definições de constantes e estas podem ser usadas
nas ações.
Vejamos como pode ficar nosso Makefile com a definição de duas constantes:
-- Makefile -----------------------------
CC = gcc
CFLAGS = -Wall -O2
strings: main.o strings.o strings.h
[TAB]$(CC) $(CFLAGS) -o strings main.o strings.o
-----------------------------------------
A variável CC define qual é o compilador que deve ser empregado. A
variável CFLAGS define as opções que devem ser passadas ao compilador.
Com CC e CFLAGS definidas, make usa regras implícitas para compilar
individualmente os arquivos objeto a partir dos fontes em C.
A variável é definida com a atribuição ao "seu nome"
CC = ...
e é referenciada com $()
... $(CC) ...
Nosso segundo Makefile emprega um truque: as dependências são os arquivos
objeto, e não mais os arquivos fonte e make faz uso de uma regra de
compilação implícita para gerar os arquivos objeto. O resultado da
execução é mostrado abaixo.
prompt: make
gcc -Wall -O2 -c -o main.o main.c
gcc -Wall -O2 -c -o strings.o strings.c
gcc -Wall -O2 -o strings main.o strings.o
As duas primeiras linhas são a aplicação da regra implícita para a
construção de arquivos objeto, a terceira é a regra explícita para a
construção do objetivo strings.
Nosso segundo Makefile tem um problema: não há menção da dependência de
main.c em strings.h. Para garantir a compilação correta, acrescentamos uma
variável e uma regra.
-- Makefile -----------------------------
CC = gcc
CFLAGS = -Wall -O2
DEPS = strings.h
strings: main.o strings.o
[TAB]$(CC) $(CFLAGS) -o strings main.o strings.o
main.o: main.c $(DEPS)
[TAB]$(CC) $(CFLAGS) -c -o main.o main.c
-----------------------------------------
O objetivo main.o depende de main.c e strings.h. O arquivo objeto
strings.o é construído com a regra implícita porque não depende de nenhum
arquivo de cabeçalho.
Importante: make constrói o primeiro objetivo do Makefile.
No nosso exemplo, o objetivo é strings e este deve ser o primeiro
objetivo no arquivo. Os objetivos secundários devem estar após o objetivo
principal. Se o que se deseja é somente construir o objetivo main.o,
o argumento para o comando make deve explicitar qual o objetivo
a ser construído:
prompt: make main.o
Suponha que seu programa que trata de cadeias de caracteres é parte de um
projeto maior e portanto todos os arquivos com cabeçalhos são mantidos no
diretório ./include, e todas as bibliotecas no diretório ./lib.
Como ainda não sabemos criar bibliotecas, por enquanto somente moveremos
strings.h para ./include.
prompt: mkdir include
prompt: mv strings.h include
O arquivo main.c deve ser modificado para que o #include
aponte para os includes "de sistema" e não mais no diretório corrente, de
#include "strings.h"
para
#include <strings.h>
O Makefile deve também deve ser atualizado:
-- Makefile -----------------------------
CC = gcc
CFLAGS = -Wall -O2
INCLUDEDIR = ./include
INCL = -I$(INCLUDEDIR)
DEPS = $(INCLUDEDIR)/strings.h
strings: main.o strings.o
[TAB]$(CC) $(CFLAGS) -o strings main.o strings.o
main.o: main.c $(DEPS)
[TAB]$(CC) $(CFLAGS) -c -o main.o main.c $(INCL)
-----------------------------------------
Isso é legal, mas ainda não equivale a uma carregadeira de verdade...
Variáveis especiais e coringas podem transformar nosso Makefile numa
carregadeira padrão mineradora-advanced-plus. Senão, vejamos:
O caractere '%' (per-cento) é usado para casamento de padrões para objetivos
genéricos. A regra abaixo determina que cada arquivo objeto (X.o) depende de
um arquivo (X.c) e de $(DEPS), e que para cada arquivo objeto, a ação seja
executada.
%.o: %.c $(DEPS)
[TAB]$(CC) $(CFLAGS) -c -o $@ $< $(INCL)
O padrão '%' equivale ao prefixo do nome que aparece no objetivo, e pode
ser usado para representar o mesmo prefixo na ação. No exemplo do
parágrafo acima o % equivale a X (X.o) no objetivo, e a X na ação (X.c).
O padrão '$@' equivale ao nome completo do objetivo da regra (%.o).
O padrão '$<' equivale ao primeiro elemento na lista de dependências (%.c).
No nosso programa exemplo, a ação acima seria expandida para
gcc -Wall -O2 -c -o main.o main.c -I./include
gcc -Wall -O2 -c -o strings.o strings.c -I./include
Nas ações, os seguintes padrões podem ser usados para representar nomes de
arquivos:
$@ nome completo do objetivo desta regra
$? dependências que são mais novas que o objetivo desta regra
$* string que corresponde ao % no objetivo
$< primeiro nome na lista de dependências
$^ lista de todas as dependências, separadas por espaço
Com estes padrões, nosso Makefile pode ser reescrito de forma mais genérica
e concisa.
-- Makefile -----------------------------
CC = gcc
CFLAGS = -Wall -O2
INCLUDEDIR = ./include
INCL = -I$(INCLUDEDIR)
DEPS = $(INCLUDEDIR)/strings.h
OBJ = main.o strings.o
strings: $(OBJ)
[TAB]$(CC) $(CFLAGS) -o $@ $^
%.o: %.c $(DEPS)
[TAB]$(CC) $(CFLAGS) -c -o $@ $< $(INCL)
-----------------------------------------
No interesse da limpeza pública e conservação de bits, falta-nos uma regra
para a limpeza dos resíduos da compilação. Tipicamente, esta regra é
chamada de clean.
-- Makefile -----------------------------
CC = gcc
CFLAGS = -Wall -O2
INCL = -I./include
DEPS = include/strings.h
OBJ = main.o strings.o
strings: $(OBJ)
[TAB]$(CC) $(CFLAGS) -o $@ $^
%.o: %.c $(DEPS)
[TAB]$(CC) $(CFLAGS) -c -o $@ $< $(INCL)
clean:
[TAB]rm -f $(OBJ) core *~ $(INCL)/*~
-----------------------------------------
Para remover os arquivos intermediários, basta dizer:
prompt: make clean
Usuários de emacs provavelmente gostam de remover os arquivos
de backup (*~).
Por estas alturas, a leitora perspicaz já entendeu o objetivo do
exercício: escrever um Makefile genérico que possa ser empregado em
qualquer projeto. A variável SRC indica os nomes dos arquivos fonte, e
as regras implícitas automatizam todo o processo de compilação.
Vejamos um Makefile genérico, do tipo "preencha os espaços em branco":
-- Makefile -----------------------------
# defina aqui quaisquer ferramentas particulares ao projeto
CC = gcc
CFLAGS = -Wall -O2
# defina os arquivos com os fontes
SRCx = x.c y.c z.c
SRCp = p.c q.c r.c
SRCgen = gen.c hen.c ien.c
INCL = -I./include
# defina as dependências particulares para minimizar as compilações
DEPSgen = include/gen.h
DEPSx = include/x.h
DEPSp = include/p.h
# defina o objetivo principal para o passo final de ligação dos objeto
gen: $(OBJgen) $(OBJx) $(OBJp)
[TAB]$(CC) $(CFLAGS) -o $@ $^
# defina a regra de compilação para os OBJx
$(OBJx): $(SRCx) $(DEPSx) $(DEPSgen)
[TAB]$(CC) $(CFLAGS) -c -o $@ $< $(INCL)
# defina a regra de compilação para os OBJp
$(OBJp): $(SRCp) $(DEPSp) $(DEPSgen)
[TAB]$(CC) $(CFLAGS) -c -o $@ $< $(INCL)
# defina a regra de compilação para os OBJgen
$(OBJgen): $(SRCgen) $(DEPSgen)
[TAB]$(CC) $(CFLAGS) -c -o $@ $< $(INCL)
clean:
[TAB]rm -f $(OBJ) core *~ $(INCL)/*~
-----------------------------------------
Essa complexidade toda não faz o menor sentido no exemplo
das strings. Em qualquer projeto não trivial, com dezenas de
arquivos fonte, minimizar o tempo de compilação é crucial. Ao
particularizar, e assim minimizar, as listas de dependências, o Makefile
garante que o menor número possível de arquivos objeto são re-compilados a
cada ciclo edição-compilação-teste. O ganho de tempo ao longo do
desenvolvimento (2 semanas/meses) compensa plenamente o tempo para
eleborar um Makefile detalhado (1 hora).
Agora sim, temos uma carregadeira melhor que um carrinho de mão!
Sobre ferramentas cuja utilidade será demonstrada adiante
file O programa file mostra o "tipo" de arquivo do seu argumento.
Por exemplo, com argumentos strings.c e ./strings:
prompt: file strings.c
strings.c: C source, ASCII text
prompt: file strings # linhas quebradas para facilitar a leitura
strings: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), \
dynamically linked (uses shared libs), for GNU/Linux 2.6.26, \
BuildID[sha1]=0x1751c47dd11fc63a94099b16f2d4dc4bd87a35d4, not stripped
nm O programa nm (names) mostra os símbolos definidos no
arquivo objeto ou executável. Note que os símbolos printf e puts estão
marcados com U (undefined) e que pertencem à biblioteca glibc-2.0.
Os símbolos d16, s64 e slocal são dados não inicializados, marcados com B
(seção .bss); os símbolos main, strconcat strcopy strlength strrev marcados
com T (seção .text) são funções; os símbolos d8, f16, f8, marcados com D
são dados inicializados (seção .data). Mais detalhes nas próximas aulas.
prompt: nm strings
080497d8 d _DYNAMIC
080498cc d _GLOBAL_OFFSET_TABLE_
0804869c R _IO_stdin_used
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
w _Jv_RegisterClasses
...
08049aa0 A _end
08048680 T _fini
08048698 R _fp_hw
080482e0 T _init
0804840c T _start
08049920 b completed.5730
08049940 B d16
080498f0 D d8
080498e8 W data_start
08049900 D f16
080498f8 D f8
080484c0 t frame_dummy
08048360 T main
U printf@@GLIBC_2.0
U puts@@GLIBC_2.0
08048460 t register_tm_clones
08049960 B s64
080499a0 B slocal
08048590 T strconcat
08048550 T strcopy
080485f0 T strlength
080484f0 T strrev
ldd O programa ldd mostra a lista de bibliotecas compartilhadas de
que seu argumento necessita.
prompt: ldd strings
linux-vdso.so.1 => (0x00007ffe169e6000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fc202f5c000)
/lib64/ld-linux-x86-64.so.2 (0x0000558adc76d000)
Resumo
- Make
- Dependências
- Objetivos e ações
- Padrões e substituições
- O que faz nm?
Exercícios
--fim da aula--