6 pontos por GN⁺ 2025-06-21 | 1 comentários | Compartilhar no WhatsApp
  • Makefile é usado principalmente para automatizar a compilação de C/C++ em projetos grandes, reexecutando apenas as etapas de build necessárias com base em alterações nos arquivos
  • Make compara os timestamps do sistema de arquivos de alvos e dependências, executando comandos somente quando o alvo não existe ou quando uma dependência é mais recente
  • A regra básica é composta por targets: prerequisites e comandos indentados com tab; indentação com espaços pode fazer o make falhar
  • É possível estender o fluxo de build com variáveis, curingas, variáveis automáticas, regras de padrão, condicionais, funções, make recursivo, include, .PHONY e .DELETE_ON_ERROR
  • O exemplo Cookbook transforma fontes C/C++ em src/ em artefatos em build/ e configura gerenciamento automático de dependências com -MMD -MP e -include

Quando um Makefile é necessário

  • Makefile é usado em programas grandes para decidir quais arquivos precisam ser recompilados
  • O caso central do tutorial é a compilação de arquivos C/C++
  • Make também pode ser usado fora de compilação em tarefas que precisam executar uma sequência de comandos conforme arquivos são alterados
  • Como outras ferramentas de build para C/C++, são apresentadas SCons, CMake, Bazel e Ninja
  • Java conta com Ant, Maven e Gradle, enquanto linguagens como Go, Rust e TypeScript têm suas próprias ferramentas de build
  • Linguagens interpretadas como Python, Ruby e JavaScript puro não precisam ser recompiladas quando arquivos mudam, portanto ferramentas como Makefile não são essenciais

Modelo básico de execução do Make

  • Os exemplos funcionam criando um arquivo Makefile em um terminal com make instalado e, em seguida, executando make nesse diretório
  • A regra básica segue o formato abaixo
    • targets: prerequisites
    • command iniciado por tab
  • O alvo (target) geralmente é um nome de arquivo, e o comando é a etapa para criar esse alvo
  • As dependências (prerequisites) são arquivos que precisam existir antes da execução do comando e também são chamadas de dependencies
  • As linhas de comando em um Makefile devem obrigatoriamente ser indentadas com caractere de tab; usar espaços faz o make falhar

Comparação de tempo entre alvos e dependências

  • make hello executa o comando associado ao alvo hello, mas não executa o comando se o arquivo hello já existir
  • Alvos e nomes de arquivos são diretamente relacionados; em geral, o comando de um alvo cria um arquivo com o mesmo nome do alvo
  • Na regra blah: blah.c, o comando de compilação só é executado quando blah não existe ou quando blah.c é mais recente que blah
  • Make não rastreia diretamente o histórico de alterações; ele usa timestamps do sistema de arquivos como heurística
  • Se um arquivo for modificado e depois tiver seu horário de modificação alterado para o passado, Make pode concluir incorretamente que esse arquivo não mudou

Alvo padrão e clean

  • Se nenhum alvo for especificado em make, o primeiro alvo do Makefile é executado como alvo padrão
  • O alvo all é usado para criar vários alvos de uma vez; se ele for colocado como primeira regra, executar apenas make já faz o build de todos os alvos
  • clean costuma ser usado como alvo para apagar artefatos de build, mas não é uma palavra reservada especial do Make
  • Se clean não for o primeiro alvo nem uma dependência de outro alvo, ele precisa ser chamado explicitamente, como em make clean, para ser executado
  • Se um arquivo chamado clean existir de fato, o alvo clean pode não ser executado; isso é resolvido adiante com .PHONY

Variáveis e processamento de strings

  • Variáveis do Make só podem conter strings, e o tutorial geralmente recomenda usar :=
  • Referências a variáveis usam o formato $(name) ou ${name}
  • Aspas simples e duplas não têm significado especial no próprio Make; são apenas caracteres dentro da variável
  • Há dois modos principais de definir variáveis
    • =: variável recursiva, que procura e expande variáveis quando é usada
    • :=: variável simplesmente expandida, que expande apenas valores já definidos no momento da definição
  • ?= define um valor apenas para variáveis que ainda não foram configuradas
  • += adiciona valor a uma variável
  • Variáveis não definidas são tratadas como string vazia
  • Espaços no fim de uma linha não são removidos; para criar uma variável com um único espaço, pode-se usar uma técnica como $(nullstring)

Curingas e variáveis automáticas

  • Em Make, * e % são ambos chamados de curingas, mas têm significados diferentes
  • * procura nomes de arquivos correspondentes no sistema de arquivos; em vez de usá-lo diretamente na definição de variáveis, é mais seguro envolvê-lo com a função wildcard, como em $(wildcard *.o)
  • Se * não corresponder a nenhum arquivo, ele pode permanecer como está quando não estiver dentro da função wildcard
  • % é usado em regras e em certas funções, e a parte correspondente é chamada de stem
  • Variáveis automáticas usadas com frequência incluem:
    • $@: nome do alvo
    • $?: dependências mais recentes que o alvo
    • $^: todas as dependências
    • $<: primeira dependência

Regras implícitas e regras de padrão

  • Make tem regras implícitas para compilação em C, permitindo que o build avance mesmo sem escrever explicitamente o comando de compilação
  • Regras implícitas representativas têm o formato abaixo
    • n.o é gerado a partir de n.c no formato $(CC) -c $(CPPFLAGS) $(CFLAGS) $^ -o $@
    • n.o é gerado a partir de n.cc ou n.cpp no formato $(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $^ -o $@
    • n é gerado a partir de n.o por um comando de linker
  • Variáveis importantes em regras implícitas são CC, CXX, CFLAGS, CXXFLAGS, CPPFLAGS e LDFLAGS
  • Regras de padrão estático usam o formato targets...: target-pattern: prereq-patterns ..., substituindo nos padrões de dependência o stem correspondente a % no alvo
  • Regras de padrão incluem % no alvo, como em %.o: %.c, e podem ser usadas como regras implícitas criadas manualmente
  • Regras com dois-pontos duplo :: permitem definir várias regras para o mesmo alvo, mas são usadas raramente

Execução de comandos e tratamento de erros

  • Colocar @ antes de um comando faz com que o próprio comando não seja exibido
  • Executar make -s se comporta como se cada linha tivesse @ no início
  • Cada linha de comando tem o efeito de ser executada em um novo shell, portanto um cd .. em uma linha não afeta a linha seguinte
  • Para manter o mesmo estado do shell, é preciso escrever em uma única linha, como cd ..; echo \pwd``, ou continuar a linha com barra invertida
  • O shell padrão é /bin/sh, mas pode ser alterado, como em SHELL=/bin/bash
  • Para usar o $ de variáveis do shell dentro de um Makefile, use $$
  • O tratamento de erros varia conforme o modo de execução
    • make -k: continua executando outras tarefas possíveis mesmo com erros
    • - antes do comando: ignora erros desse comando
    • make -i: aplica a ignorância de erros a todos os comandos
  • Se Make for interrompido com ctrl+c, ele apaga o alvo recém-criado e mais recente

Make recursivo e variáveis de ambiente

  • Ao chamar make novamente dentro de um Makefile, use a variável especial $(MAKE) em vez de make
  • $(MAKE) repassa flags do make e lida adequadamente com os efeitos dessas flags
  • Quando Make é iniciado, ele cria automaticamente variáveis do Make a partir das variáveis de ambiente configuradas no momento da execução
  • A diretiva export define uma variável no ambiente dos comandos de shell de todas as recipes
  • Para usar uma variável também em makes subordinados, é necessário dar export nessa variável
  • .EXPORT_ALL_VARIABLES permite exportar todas as variáveis
  • Ao especificar vários alvos de uma vez, como make clean run test, eles são executados na ordem clean, run, test

Condicionais e funções

  • Condicionais são compostos por ifeq, ifneq, ifdef, ifndef, else e endif
  • ifdef não expande referências a variáveis; ele apenas verifica se a variável está definida
  • É possível usar $(MAKEFLAGS) e findstring para verificar se uma flag como make -i foi repassada
  • Funções do Make são usadas principalmente para processamento de texto e são chamadas no formato $(fn, arguments) ou ${fn, arguments}
  • As principais funções incluem:
    • subst: substituição de strings
    • patsubst: substituição de palavras que correspondem a um padrão
    • foreach: transformação de uma lista de palavras separadas por espaços
    • if: usa o segundo argumento se o primeiro não estiver vazio; caso contrário, usa o terceiro
    • call: chama uma variável como se fosse uma função básica e usa parâmetros como $(1) e $(2)
    • shell: chama o shell, mas transforma quebras de linha em espaços
    • filter: seleciona da lista elementos que correspondem a um padrão específico
    • filter-out: seleciona elementos que não correspondem a um padrão específico

include, vpath, .PHONY, .DELETE_ON_ERROR

  • include filenames... faz com que um ou mais outros Makefiles sejam lidos
  • include é útil ao ler Makefiles gerados por opções como -M do gcc
  • vpath <pattern> <directories> especifica diretórios onde determinados arquivos de dependência devem ser procurados
  • A variável VPATH pode especificar globalmente um comportamento semelhante de caminhos de busca
  • A barra invertida \ é usada para dividir comandos longos em várias linhas
  • Adicionar .PHONY a um alvo faz com que Make não confunda esse alvo com um arquivo de mesmo nome
  • .DELETE_ON_ERROR apaga o alvo da regra se, durante a execução da regra, um comando retornar um status de saída diferente de 0
  • .DELETE_ON_ERROR não se aplica apenas ao alvo imediatamente anterior, como PHONY; ela se aplica a todos os alvos

Exemplo Makefile Cookbook

  • O exemplo Cookbook fornece um template de Makefile adequado para projetos de porte médio
  • Ao colocar arquivos C/C++ na pasta src/, o Makefile determina automaticamente os alvos de compilação e as dependências
  • A configuração das principais variáveis é a seguinte
    • TARGET_EXEC := final_program
    • BUILD_DIR := ./build
    • SRC_DIRS := ./src
  • SRCS usa find e $(shell ...) para localizar arquivos *.cpp, *.c e *.s
  • OBJS cria os caminhos dos artefatos de build adicionando BUILD_DIR antes de cada arquivo-fonte e acrescentando .o
  • DEPS cria a lista de Makefiles de dependência trocando .o por .d
  • INC_DIRS encontra todos os diretórios abaixo de src/, e INC_FLAGS adiciona o prefixo -I a cada diretório
  • CPPFLAGS := $(INC_FLAGS) -MMD -MP configura o GCC para criar arquivos de dependência .d
  • Fontes C e C++ criam arquivos .o abaixo de build/ por regras de padrão separadas, e os diretórios necessários são criados com mkdir -p $(dir $@)
  • clean é declarado como .PHONY e apaga o diretório de build com rm -r $(BUILD_DIR)
  • -include $(DEPS) inclui os arquivos .d, mas suprime erros que ocorrem quando os arquivos não existem, como no primeiro build

1 comentários

 
GN⁺ 2025-06-21
Opiniões no Hacker News
  • Há algumas flags pouco conhecidas, mas úteis, do make. A sincronização de saída faz com que stdout/stderr só sejam exibidos depois que o target termina, reduzindo o problema de logs misturados e difíceis de ler em builds paralelos
    make --output-sync=recurse -j10
    Em sistemas multiusuário ocupados, só o número de jobs com -j pode não bastar, então também dá para limitar o paralelismo com base na média de carga: make -j10 --load-average=10
    --shuffle, que randomiza a ordem de agendamento dos targets, também é útil em CI para pegar dependências ausentes em Makefiles: make --shuffle # or --shuffle=seed/reverse

    • Se são “pouco conhecidas”, seria bom que os autores do make reunissem a lista de opções em algum lugar e a distribuíssem junto com o programa. Se fosse algo como um arquivo de texto legível pelo usuário ou uma linguagem de composição tipográfica, o acesso ao conhecimento melhoraria muito
    • A que mais uso é -B, opção que força reconstruir tudo incondicionalmente
    • Já vi make -j deixar a máquina em estado de DoS várias vezes, então considero quase um bug
    • Se for um sistema multiusuário ocupado, será que o escalonador do sistema operacional não deveria dar conta?
    • Mesmo sendo útil, não é portátil. Melhor não usar fora de projetos pessoais de brincadeira que você não vai distribuir
  • Por volta de 1985, vi alguém no Graphics Lab da Boston University usando Makefile para acionar a geração de um renderizador 3D para animação. Era alguém da área de Lisp, trabalhando com geração procedural inicial e sistemas de atores 3D, e o Makefile tinha umas 10 linhas, muito elegante
    Usando apenas dependências simples por data de arquivo, ele gerava centenas de animações; o Lisp criava as formas 3D de cada quadro, e o Make gerava os frames
    Era 1985, uma época em que quase nada do que hoje consideramos óbvio em 3D e animação existia, então impressionou todo mundo. Depois, pelo que me lembro, ele trabalhou no renderizador 3D de Iron Giant e teve um papel central também em Coraline. Era Brian Gardner

  • Make é uma daquelas ferramentas que fico muito feliz de ter aprendido no começo da carreira. Hoje não uso muito, mas ela me mostrou o poder que sistemas declarativos têm em comparação com sistemas imperativos
    Percebi que essa ideia se estende naturalmente a outras tarefas. Olhando a imagem no topo deste site, parece que o autor sentiu algo parecido: dá para entender melhor receitas de cozinha se as enxergarmos de forma declarativa, como um Makefile, em vez de uma sequência de comandos em estilo script, como uma receita tradicional
    Texto relacionado: https://blog.gpkb.org/posts/cooking-with-make/
    Sempre escrevo receitas de modo que possam ser lidas como um Makefile e levo para a cozinha; fico curioso se alguém já as compôs ou exibiu assim. Acho que economizaria bastante tempo ao ler uma receita nova, sem precisar converter mentalmente o script em Makefile

    • O bom dessa abordagem é que o usuário, na prática, interpreta diretamente o grafo de dependências e vira o executor, ganhando mais controle. No exemplo da cozinha, a estrutura declarativa deixa mais claro em quais pontos há opções abertas sobre o que fazer em seguida e dá liberdade para considerar restrições externas que não estão formalmente escritas no Makefile, como a ordem que facilita lavar a louça
      Claro que o custo é ter de resolver o grafo de dependências por conta própria. Quando você só quer seguir uma sequência de passos já serializada, acaba assumindo mais trabalho
  • O texto diz que a maioria das receitas não é marcada como .PHONY e usa isso como uma espécie de motivo para não tratar do assunto no tutorial, mas é uma desculpa fraca. Ferramentas devem ensinar o uso correto
    Na minha equipe, como usamos make como executor de tarefas, levei uma chamada por adicionar e manter .PHONY em todas as receitas
    O guia de estilo de Makefile do Clark Grubb é bom: https://clarkgrubb.com/makefile-style-guide
    Fico curioso se alguém usa esse guia de estilo. Também queria saber se preferem marcar receitas phony no ponto da declaração ou manter uma lista enorme no topo do arquivo. Seria bom ter um linter que impusesse isso

    • Li e, no geral, é um bom documento. Mas discordo de alguns pontos
      Não gosto de usar -o pipefail por inércia. Pipefail é útil, mas quebra a coisa mais comum que as pessoas fazem em pipelines: filtrar saída com grep. É melhor colocá-lo individualmente só nas receitas que precisam
      Marcar targets que não são arquivos como .PHONY é estritamente correto, mas em geral não é necessário. Quando há muitos targets, isso acrescenta verbosidade desnecessária ao Makefile; acho melhor adicionar só quando for preciso
      Em receitas que produzem vários arquivos de saída, antes o padrão era usar arquivos dummy/arquivos de flag quando regras de padrão não se encaixavam, mas desde o GNU Make 4.3 há suporte nativo a targets agrupados. Também está no Ubuntu 22.04 LTS: https://www.gnu.org/software/make/manual/html_node/Multiple-...
  • Um ponto interessante recente é que o CMake concluiu que Makefiles são inadequados para projetos que usam módulos do C++20, e que o Ninja é a opção certa. https://cmake.org/cmake/help/latest/manual/cmake-cxxmodules....
    Basicamente, parece considerar que é difícil demais, ou impossível, definir estaticamente as dependências dos targets. Hoje isso é tratado dinamicamente com ferramentas como clang-scan-deps: https://llvm.org/devmtg/2019-04/slides/TechTalk-Lorenz-clang...

    • Corrijam-me se eu estiver errado, mas essa limitação relacionada a Makefiles parece ser inteiramente uma escolha do CMake, ou pelo menos o resultado da falta de voluntários para adicionar suporte ao gerador de Makefiles. O próprio Ninja também não oferece suporte a módulos C++: https://github.com/ninja-build/ninja/issues/2457
      Na verdade, como o Ninja exige que todas as dependências sejam definidas estaticamente, nesse aspecto ele tem menos recursos que o Make comum
    • Sinceramente, módulos são quase um desastre
  • Make tem seu lugar como ferramenta de build para grandes bases de código em C. Mas muitas vezes ele é tratado como uma espécie de “executor de tarefas por projeto” de uso geral, e para isso ele não se encaixa bem. Até condicionais simples são difíceis
    Por exemplo, já vi várias tentativas bem-intencionadas de encapsular Terraform com Make, mas nenhuma terminou bem

    • Make não é um executor de tarefas genérico. Ele é mais uma forma genérica de transformar scripts shell lineares em dependências declarativas. É uma ferramenta geral para o shell
    • Acho que nem a afirmação de que ele é uma ferramenta de build para grandes bases de código em C continua correta. Nos últimos 20 anos, surgiram sistemas de build mais robustos e bem definidos; já está na hora de se atualizar
    • Existe um bom executor de tarefas de uso geral? Edit: acho que entendi completamente errado o termo “executor de tarefas”
  • Um excelente tool moderno para substituir as partes em que Makefiles ficam bagunçados é o just: https://github.com/casey/just

    • Ele substitui o aspecto de “lista de pequenos scripts shell” do Make, mas não substitui a parte realmente útil, que é “executar apenas as regras que precisam ser executadas novamente”
    • Há outras opções: Task (Go) https://github.com/go-task/task, Cake (C#) https://github.com/cake-build/cake, Rake (Ruby) https://github.com/ruby/rake
      Como um conceito totalmente diferente, há também o Makedown, que apareceu no HN há 8 meses: https://news.ycombinator.com/item?id=41825344
    • Embora essas ferramentas se posicionem como alternativas ao make, pessoalmente acho que são completamente diferentes e nem são comparáveis. make é centrado em gerar artefatos e em não reconstruir o que já foi criado; just é um executor de comandos
    • A principal vantagem de usar Make como executor de comandos é que ele é uma ferramenta padrão instalada “em todo lugar”. Mesmo que as alternativas pareçam mais agradáveis de usar, nunca senti que elas trouxessem um benefício grande o suficiente para justificar instalar mais uma ferramenta
    • Task também é outra alternativa. Mas, pessoalmente, só usei em projetos simples de hobby em C, então é difícil dizer se ele aguenta bem quando a escala cresce: https://taskfile.dev/
  • Makefiles são ótimos, mas é melhor não se empolgar demais. Alguns anos atrás, tentei criar um framework GNU Make puro e percebi que estava basicamente reinventando o autoconf; só então entendi por que o GNU autotools foi criado
    Makefiles são uma espécie de tarpit de Turing curiosamente parecido com Lisp. O GNU Make ainda tem recursos de metaprogramação, então é difícil resistir à vontade de metaprogramar algum sistema profano dentro de um Makefile. O fato de o GNU Make estar tão amplamente instalado aumenta ainda mais a tentação

  • Sou uma das pessoas que criou e mantém o Task, uma alternativa ao Make. Ele existe há mais de 8 anos e continua evoluindo, então, se você está procurando algo novo, vale a pena experimentar. Fiquem à vontade para fazer perguntas também
    https://taskfile.dev/
    https://github.com/go-task/task

  • Tenho curiosidade para saber se alguém já usou tup: https://gittup.org/tup/ex_dependencies.html
    É um sistema de build que determina dependências automaticamente com base no acesso ao sistema de arquivos, então pode ser usado com qualquer compilador ou ferramenta