Estrutura de Dados I

Compilação separada e Make

As etapas da compilação, a compilação de projetos com vários arquivos e como automatizá-la com Makefile.

Na página de Tipo Abstrato de Dados, um programa passou a ter vários arquivos .c e .h. Esta página trata das ferramentas para compilar esses programas: as etapas da compilação, a compilação de cada arquivo em separado e o make, que automatiza todo o processo. As flags usadas em cada comando são as mesmas de sempre, explicadas em Compilando em C.

Do código ao executável

O comando gcc faz, na verdade, três trabalhos diferentes:

  1. Pré-processamento: trata as linhas que começam com #. Cada #include é trocado pelo conteúdo do arquivo incluído, cada #define é substituído onde aparece, e os trechos desligados por #if/#ifdef são removidos. Para ver o resultado, use gcc -E arquivo.c.
  2. Compilação: traduz cada arquivo .c, já pré-processado, para código de máquina, gerando um arquivo objeto (.o). Cada .c é compilado sozinho: o compilador não olha os outros arquivos do projeto, só os protótipos que vieram dos #include.
  3. Ligação: o ligador (ld) junta os arquivos objeto e o código das bibliotecas usadas em um único executável. É aqui que cada chamada é associada ao código da função chamada.
main.c e Aluno.c passam, cada um, pelo pré-processador (que insere o conteúdo de Aluno.h) e pelo compilador, gerando main.o e Aluno.o. O ligador junta os dois arquivos objeto com as bibliotecas e gera o executável TADAluno
Cada .c é pré-processado e compilado separadamente, e o ligador junta os arquivos objeto e as bibliotecas em um executável.

Saber em que etapa um erro aconteceu ajuda a entender a mensagem:

EtapaErro típicoExemplo de mensagem
Pré-processamentocabeçalho não encontradofatal error: Ponto.h: No such file or directory
Compilaçãoerro de sintaxe, tipo incompatívelerror: expected ';' before '}' token
Ligaçãofunção declarada mas não definida em nenhum .oundefined reference to 'criaPonto'

Os erros de ligação aparecem com ld ou collect2 na mensagem e citam o arquivo objeto, não uma linha do código: o programa compilou, mas falta (ou sobra) alguma função na hora de juntar as partes.

Compilando em etapas

-c interrompe o processo depois da compilação: gcc -c Aluno.c gera Aluno.o, e a ligação fica para um comando posterior. Assim, cada .c pode ser compilado separadamente, e só o que mudou precisa ser compilado de novo.

gcc -c Aluno.c                       # Aluno.o
gcc -c main.c                        # main.o
gcc main.o Aluno.o -o TADAluno       # executável TADAluno

Na prática, cada comando leva as flags da disciplina. Duas delas exigem atenção quando a compilação é feita em etapas.

Nas duas etapas

Quando a compilação é feita em etapas (-c e depois a ligação), a flag precisa aparecer nas duas. Na compilação, ela instrumenta o código, incluindo uma verificação antes de cada acesso à memória. Na ligação, ela inclui a biblioteca do sanitizer. Só na ligação, o programa roda sem verificar os acessos e erros como o estouro de vetor passam despercebidos. Só na compilação, a ligação falha com undefined reference to '_asan...'.

A biblioteca depois dos arquivos objeto

A -lm (e qualquer outra -l) vai no comando de ligação, e depois dos arquivos objeto: gcc main.o Ponto.o -lm -o TADPonto. Em muitas instalações, o ligador só aproveita de uma biblioteca as funções pedidas por algum arquivo que veio antes dela na linha de comando (veja o Makefile 5 dos exercícios).

Make

Em um projeto com muitos arquivos, é fácil esquecer o que precisa ser recompilado depois de uma alteração. Recompilar tudo sempre funciona, mas é lento, e recompilar "de cabeça" é arriscado: um .o desatualizado gera um executável que não corresponde ao código. O make resolve isso: ele lê as regras de um arquivo chamado Makefile, compara as datas de modificação dos arquivos e refaz só o que ficou desatualizado.

Regras

Um Makefile é uma lista de regras no formato:

alvo: pré-requisitos
	comando
	comando
  • alvo (target): o arquivo a ser gerado, como main.o ou o executável;
  • pré-requisitos: os arquivos de que o alvo depende. Se algum deles for mais novo que o alvo, o alvo está desatualizado;
  • comandos: o que fazer para gerar o alvo.
Tab, não espaços

Cada linha de comando precisa começar com um caractere de tabulação (Tab), não com espaços. Se o editor trocar o Tab por espaços, o make recusa o arquivo com uma mensagem pouco clara: *** missing separator. Stop.

Um primeiro Makefile

Para o TAD Aluno (arquivos main.c, Aluno.c e Aluno.h):

all: TADAluno

TADAluno: main.o Aluno.o
	gcc main.o Aluno.o -o TADAluno

main.o: main.c Aluno.h
	gcc -c main.c

Aluno.o: Aluno.c Aluno.h
	gcc -c Aluno.c

clean:
	rm -f *.o TADAluno

run: TADAluno
	./TADAluno

Digitando make, o primeiro alvo do arquivo (all) é construído. Ele depende de TADAluno, que depende de main.o e Aluno.o, que dependem dos fontes. O make percorre essas dependências e executa os comandos na ordem necessária:

$ make
gcc -c main.c
gcc -c Aluno.c
gcc main.o Aluno.o -o TADAluno

Rodando de novo, sem alterar nada:

$ make
make: Nothing to be done for 'all'.

Agora altere só Aluno.c (um comentário basta) e rode make:

$ make
gcc -c Aluno.c
gcc main.o Aluno.o -o TADAluno
Grafo de dependências: TADAluno depende de main.o e Aluno.o. main.o depende de main.c e Aluno.h, e Aluno.o depende de Aluno.c e Aluno.h. Aluno.c está marcado como alterado, e Aluno.o e TADAluno como refeitos
Dependências do TAD Aluno. Depois de alterar Aluno.c, só Aluno.o fica mais velho que um pré-requisito. Refeito Aluno.o, o executável também fica desatualizado e é ligado de novo.

Aluno.o ficou mais velho que Aluno.c e foi recompilado. main.o continua mais novo que main.c e Aluno.h, e não precisou ser tocado. Mas o novo Aluno.o é mais novo que TADAluno, e o executável foi ligado de novo.

Questão

Com esse Makefile, e tudo já compilado, você altera só Aluno.h e roda make. Quais comandos são executados?

Alvos que não são arquivos

all, clean e run não geram arquivos com esses nomes: são apenas nomes para ações. Os nomes mais comuns são all (o primeiro alvo, construído por um make sem argumentos), clean (apaga o que foi gerado) e run (executa o programa).

make          # constrói o primeiro alvo (all)
make run      # constrói, se necessário, e executa
make clean    # apaga os .o e o executável

Há um detalhe: se existir um arquivo chamado clean na pasta, o make acha que o alvo já está pronto e não faz nada:

$ make clean
make: 'clean' is up to date.

Para evitar isso, declare esses alvos como .PHONY (falsos): o make passa a executá-los sempre, sem procurar um arquivo com o nome.

.PHONY: all clean run

Variáveis

O Makefile acima ainda não usa as flags da disciplina. Copiá-las em cada comando seria repetitivo e fácil de errar. Em vez disso, use variáveis:

CFLAGS = -std=c11 -O0 -g -Wall -Wextra -Werror

e escreva $(CFLAGS) onde elas forem usadas. Para trocar uma flag, basta alterar uma linha. Alguns nomes são convenções seguidas pela maioria dos projetos:

VariávelUso
CCo compilador (gcc)
CFLAGSflags de compilação (-c)
LDFLAGSflags de ligação
LDLIBSbibliotecas (-lm)
Comentários na mesma linha

Em EXECUTAVEL = TADAluno # nome do executável, o espaço antes do # faz parte do valor da variável, que fica "TADAluno ". Na maioria dos usos não faz diferença, mas pode quebrar comandos de forma difícil de entender. Prefira comentários em linhas separadas.

Variáveis automáticas e o @

Dentro de uma regra, o make define algumas variáveis automaticamente:

VariávelValor
$@o alvo da regra
$<o primeiro pré-requisito
$^todos os pré-requisitos

Na regra main.o: main.c Aluno.h, $@ é main.o, $< é main.c e $^ é main.c Aluno.h. Assim, o comando de compilação fica $(CC) $(CFLAGS) -c $<, sem repetir o nome do arquivo.

Por padrão, o make mostra cada comando antes de executá-lo. Um @ no começo do comando esconde essa linha, o que é útil para trocar comandos longos por mensagens curtas com echo.

Um Makefile completo

Juntando tudo:

# Makefile do TAD Aluno

CC      = gcc
CFLAGS  = -std=c11 -O0 -g -fno-omit-frame-pointer \
          -Wall -Wextra -Werror -Wshadow \
          -Wno-sign-compare -Wno-unused-parameter \
          -Wno-unused-variable -Wno-unused-result \
          -fsanitize=address,undefined
LDFLAGS = -fsanitize=address,undefined
LDLIBS  = -lm
EXECUTAVEL = TADAluno

all: $(EXECUTAVEL)

$(EXECUTAVEL): main.o Aluno.o
	@echo "Ligando $^ em $@"
	@$(CC) $^ $(LDFLAGS) $(LDLIBS) -o $@

main.o: main.c Aluno.h
	@echo "Compilando $<"
	@$(CC) $(CFLAGS) -c $<

Aluno.o: Aluno.c Aluno.h
	@echo "Compilando $<"
	@$(CC) $(CFLAGS) -c $<

run: $(EXECUTAVEL)
	./$(EXECUTAVEL)

clean:
	rm -f *.o $(EXECUTAVEL)

.PHONY: all run clean
$ make
Compilando main.c
Compilando Aluno.c
Ligando main.o Aluno.o em TADAluno

Repare que -fsanitize=address,undefined aparece em CFLAGS e em LDFLAGS, que as bibliotecas vêm depois dos arquivos objeto, e que cada .o lista como pré-requisitos o seu .c e os .h que ele inclui. A barra invertida no fim de uma linha continua a definição na linha seguinte. Para outro projeto, basta trocar o nome do executável e as regras dos arquivos objeto.

Para ir além: regras de padrão e dependências automáticas

Em projetos com muitos arquivos, escrever uma regra para cada .o cansa. Uma regra de padrão vale para todos os arquivos que seguem o padrão:

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

Ela diz como gerar qualquer x.o a partir de x.c. Os .h de cada um ainda precisam ser listados, em linhas sem comando:

main.o: Aluno.h
Aluno.o: Aluno.h

Manter essa lista à mão também é fácil de errar. As flags -MMD -MP fazem o próprio gcc gerar, a cada compilação, um arquivo .d com os cabeçalhos que cada .c inclui, e a linha -include $(wildcard *.d) no Makefile os carrega. Não é necessário para os projetos da disciplina, mas é o que projetos maiores usam.

Exercícios

Encontre o erro

O executor desta página não roda o make, então os exercícios abaixo mostram o Makefile e o que aconteceu ao usá-lo. Em todos, o projeto é o TAD Aluno ou o TAD Ponto, com os arquivos corretos.

Makefile 1

TADAluno: main.o Aluno.o
    gcc main.o Aluno.o -o TADAluno

main.o: main.c Aluno.h
    gcc -c main.c

Aluno.o: Aluno.c Aluno.h
    gcc -c Aluno.c
$ make
Makefile:2: *** missing separator.  Stop.
Resposta

As linhas de comando começam com quatro espaços, e não com um Tab. Muitos editores trocam o Tab por espaços automaticamente. No VS Code, o canto inferior direito da janela mostra "Spaces" ou "Tab Size", e para arquivos Makefile ele normalmente já usa Tab.

Makefile 2

CFLAGS = -std=c11 -Wall -Wextra

TADPonto: main.o Ponto.o
	gcc main.o Ponto.o -lm -o TADPonto

main.o: main.c
	gcc $(CFLAGS) -c main.c

Ponto.o: Ponto.c
	gcc $(CFLAGS) -c Ponto.c

O projeto compilou e funcionou. Depois, os parâmetros de criaPonto e de atribuiPonto mudaram de double para float, em Ponto.h e em Ponto.c:

$ make
gcc -std=c11 -Wall -Wextra -c Ponto.c
gcc main.o Ponto.o -lm -o TADPonto
$ ./TADPonto
Distancia: 0.0000
q = (0.0, 0.0), distancia: 0.0000

Depois de um rm -f *.o TADPonto e de um novo make, tudo volta a funcionar.

Resposta

main.o não lista Ponto.h como pré-requisito. Quando o cabeçalho mudou, o make não recompilou main.c, e main.o continuou chamando criaPonto como se ela recebesse double. O executável juntou um main.o compilado com a interface antiga e um Ponto.o compilado com a nova, e os valores chegaram errados, sem nenhuma mensagem de erro. A correção é listar os cabeçalhos:

main.o: main.c Ponto.h
Ponto.o: Ponto.c Ponto.h

Makefile 3

Aluno.c tem um erro proposital: a->nome[MAX] = '\0'; escreve uma posição além do vetor nome.

CFLAGS  = -std=c11 -O0 -g -Wall -Wextra -Werror -Wshadow
LDFLAGS = -fsanitize=address,undefined
EXECUTAVEL = TADAluno

$(EXECUTAVEL): main.o Aluno.o
	gcc main.o Aluno.o $(LDFLAGS) -o $(EXECUTAVEL)

main.o: main.c Aluno.h
	gcc $(CFLAGS) -c main.c

Aluno.o: Aluno.c Aluno.h
	gcc $(CFLAGS) -c Aluno.c

run: $(EXECUTAVEL)
	./$(EXECUTAVEL)
$ make run
gcc -std=c11 -O0 -g -Wall -Wextra -Werror -Wshadow -c main.c
gcc -std=c11 -O0 -g -Wall -Wextra -Werror -Wshadow -c Aluno.c
gcc main.o Aluno.o -fsanitize=address,undefined -o TADAluno
./TADAluno
Nome: Maria | Matricula: 2024001 | CR: 8.50
Nome: Maria | Matricula: 2024001 | CR: 10.00

Por que o erro não foi detectado?

Resposta

-fsanitize só aparece na ligação. Os arquivos objeto foram compilados sem instrumentação, então nenhum acesso à memória é verificado. A ligação inclui a biblioteca do sanitizer, que ainda detecta vazamentos e free inválidos, mas não o acesso fora do vetor. Com a flag também em CFLAGS, a execução acusa o erro:

Aluno.c:29:12: runtime error: index 50 out of bounds for type 'char [50]'

Makefile 4

O mesmo projeto, agora com a flag só em CFLAGS:

CFLAGS  = -std=c11 -O0 -g -Wall -Wextra -Werror -Wshadow -fsanitize=address,undefined
EXECUTAVEL = TADAluno

$(EXECUTAVEL): main.o Aluno.o
	gcc main.o Aluno.o -o $(EXECUTAVEL)

main.o: main.c Aluno.h
	gcc $(CFLAGS) -c main.c

Aluno.o: Aluno.c Aluno.h
	gcc $(CFLAGS) -c Aluno.c
$ make
gcc -std=c11 -O0 -g -Wall -Wextra -Werror -Wshadow -fsanitize=address,undefined -c main.c
gcc -std=c11 -O0 -g -Wall -Wextra -Werror -Wshadow -fsanitize=address,undefined -c Aluno.c
gcc main.o Aluno.o -o TADAluno
/usr/bin/ld: main.o: in function `_sub_D_00099_0':
main.c:15:(.text+0x8b): undefined reference to `__asan_unregister_globals'
...
collect2: error: ld returned 1 exit status
make: *** [Makefile:5: TADAluno] Error 1
Resposta

Agora o contrário: os arquivos objeto foram instrumentados e chamam funções do sanitizer (as __asan_...), mas a ligação não inclui a biblioteca que as define. A flag precisa estar também no comando de ligação, por exemplo em uma variável LDFLAGS usada ali.

Makefile 5

Os arquivos objeto já estão compilados. Falta ligar.

TADPonto: main.o Ponto.o
	gcc -lm main.o Ponto.o -o TADPonto
$ make
gcc -lm main.o Ponto.o -o TADPonto
/usr/bin/ld: Ponto.o: in function `distanciaPonto':
Ponto.c:(.text+0x15d): undefined reference to `sqrt'
collect2: error: ld returned 1 exit status
Resposta

A -lm está, mas no lugar errado. O ligador processa os arquivos na ordem da linha de comando e, em muitas instalações (como no Ubuntu), só aproveita de uma biblioteca as funções que já foram pedidas por algum arquivo anterior. Quando ele lê -lm, ninguém pediu sqrt ainda, e a biblioteca é descartada. As bibliotecas vão depois dos arquivos objeto: gcc main.o Ponto.o -lm -o TADPonto.

Referências

Nesta página