Um introdução ao make#

Discutimos brevemente o básico do make. Esse capítulos dá ideias e estratégias de escalas make para projetos maiores.

Antes de irmos aos detalhes com make, considere alguns pontos:

  1. make é uma ferramenta Unix e pode te dar algum trabalho quando portando para plataformas não Unix. Dito isso, há outros sabores de make disponíveis, mas nem todos podem ter as funcionalidades que você queira usar.

  2. make te dá controle total do processo de geração de build, isso significa que você é responsável por todo o processo, e deverá especificar as regras para cada detalhe do seu projeto. Você poderá se encontrar gastando um tempo significativo escrevendo e mantendo seu Makefile ao invés de desenvolver seu código fonte.

  3. Você pode trabalhar com seu Makefile, mas lembre dos outros desenvolvedores no seu projeto que podem não ser familiares com make. Quanto tempo você espera que eles gastem para aprender seu Makefile e eles serão capazes de debugar ou adicionar funcionalidades?

  4. make puro não irá escalar. Cedo você adicionará programas auxiliares para gerar dinamicamente ou estaticamente seu Makefile. Eles introduzem dependências e possíveis fontes de erros. O esforço necessário para testar e documentar essas ferramentas não deve ser subestimada.

Se você acha make adequado para suas necessidades, então você pode começar escrevendo seu Makefile. Para esse curso nós iremos usar exemplos do mundo real vindo do pacote de índice, o qual (no momento dessa escrita) usa sistemas de geração de build fora o make. Esse guia apresenta uma recomendação de estilo geral para escrever make, mas também serve como demonstração de funcionalidades úteis e interessantes.

Dica

Mesmo se você achar make inadequado para gerar o build do seu projeto, essa é a ferramenta para automatizar fluxos de trabalho definidos pelos arquivos. Talvez você possa utilizar de seu poder em um contexto diferente.

Começando#

Para essa parte, trabalharemos com o Fortran CSV module (v1.2.0). Nosso objetivo é criar um Makefile para compilar esse projeto em uma biblioteca estática. Comece clonando o repositório

git clone https://github.com/jacobwilliams/fortran-csv-module -b 1.2.0
cd fortran-csv-module

Dica

Para essa parte trabalharemos com o código da tag 1.2.0 para fazê-lo o mais reprodutível o possível. Sinta-se a vontade de usar a última versão ou outro projeto.

Esse projeto usa FoBiS como sistema de geração de build, e você pode chegar o build.sh para opções com FoBiS. Nós agora iremos escrever o Makefile para esse projeto. Primeiro, checamos a estrutura do diretório e os arquivos fonte

.
├── build.sh
├── files
│   ├── test_2_columns.csv
│   └── test.csv
├── fortran-csv-module.md
├── LICENSE
├── README.md
└── src
    ├── csv_kinds.f90
    ├── csv_module.F90
    ├── csv_parameters.f90
    ├── csv_utilities.f90
    └── tests
        ├── csv_read_test.f90
        ├── csv_test.f90
        └── csv_write_test.f90

Nós encontramos sete arquivos fonte Fortran diferentes; os quatros no src devem ser compilados e adicionados na biblioteca estática enquanto que os três em src/tests contém programas individuais que dependem da biblioteca estática.

Comece criando um Makefile simples:

# Disable the default rules
MAKEFLAGS += --no-builtin-rules --no-builtin-variables

# Project name
NAME := csv

# Configuration settings
FC := gfortran
AR := ar rcs
LD := $(FC)
RM := rm -f

# List of all source files
SRCS := src/csv_kinds.f90 \
        src/csv_module.F90 \
        src/csv_parameters.f90 \
        src/csv_utilities.f90
TEST_SRCS := src/tests/csv_read_test.f90 \
             src/tests/csv_test.f90 \
             src/tests/csv_write_test.f90

# Create lists of the build artefacts in this project
OBJS := $(addsuffix .o, $(SRCS))
TEST_OBJS := $(addsuffix .o, $(TEST_SRCS))
LIB := $(patsubst %, lib%.a, $(NAME))
TEST_EXE := $(patsubst %.f90, %.exe, $(TEST_SRCS))

# Declare all public targets
.PHONY: all clean
all: $(LIB) $(TEST_EXE)

# Create the static library from the object files
$(LIB): $(OBJS)
	$(AR) $@ $^

# Link the test executables
$(TEST_EXE): %.exe: %.f90.o $(LIB)
	$(LD) -o $@ $^

# Create object files from Fortran source
$(OBJS) $(TEST_OBJS): %.o: %
	$(FC) -c -o $@ $<

# Define all module interdependencies
csv_kinds.mod := src/csv_kinds.f90.o
csv_module.mod := src/csv_module.F90.o
csv_parameters.mod := src/csv_parameters.f90.o
csv_utilities.mod := src/csv_utilities.f90.o
src/csv_module.F90.o: $(csv_utilities.mod)
src/csv_module.F90.o: $(csv_kinds.mod)
src/csv_module.F90.o: $(csv_parameters.mod)
src/csv_parameters.f90.o: $(csv_kinds.mod)
src/csv_utilities.f90.o: $(csv_kinds.mod)
src/csv_utilities.f90.o: $(csv_parameters.mod)
src/tests/csv_read_test.f90.o: $(csv_module.mod)
src/tests/csv_test.f90.o: $(csv_module.mod)
src/tests/csv_write_test.f90.o: $(csv_module.mod)

# Cleanup, filter to avoid removing source code by accident
clean:
	$(RM) $(filter %.o, $(OBJS) $(TEST_OBJS)) $(filter %.exe, $(TEST_EXE)) $(LIB) $(wildcard *.mod)

Invocando make deve construir a biblioteca estática e os executáveis dos testes como esperado:

gfortran -c -o src/csv_kinds.f90.o src/csv_kinds.f90
gfortran -c -o src/csv_parameters.f90.o src/csv_parameters.f90
gfortran -c -o src/csv_utilities.f90.o src/csv_utilities.f90
gfortran -c -o src/csv_module.F90.o src/csv_module.F90
ar rcs libcsv.a src/csv_kinds.f90.o src/csv_module.F90.o src/csv_parameters.f90.o src/csv_utilities.f90.o
gfortran -c -o src/tests/csv_read_test.f90.o src/tests/csv_read_test.f90
gfortran -o src/tests/csv_read_test.exe src/tests/csv_read_test.f90.o libcsv.a
gfortran -c -o src/tests/csv_test.f90.o src/tests/csv_test.f90
gfortran -o src/tests/csv_test.exe src/tests/csv_test.f90.o libcsv.a
gfortran -c -o src/tests/csv_write_test.f90.o src/tests/csv_write_test.f90
gfortran -o src/tests/csv_write_test.exe src/tests/csv_write_test.f90.o libcsv.a

Há algumas coisas para notar aqui, o build make geralmente interlaça os artefatos do build e o código fonte, a não ser que você implemente o diretório de build. Além disso, os arquivos fonte e dependências são especificadas explicitamente, o que resulta em diversas linhas adicionais mesmo para tal projeto simples.

Dependências geradas automaticamente#

A principal desvantagem do make para Fortran é a falta da capacidade de determinar dependências de módulo. Isso é geralmente resolvido seja adicionando à mão ou escaneando automaticamente o código fonte com uma ferramenta externa. Alguns compiladores (como o compilador Intel Fortran) também oferece a geração de dependências no formato make.

Antes de aprofundar na geração de dependências, vamos delinear o conceito de uma medida robusta para o problema de dependência. Primeiro, queremos uma abordagem que consiga processar todos os arquivos fonte independentemente, enquanto cada arquivo fonte fornece (module) ou requer (use) módulos. Quando gerando as dependências apenas o nome do arquivo fonte e os arquivos módulo são conhecidos, e nenhuma informação dos nomes dos arquivos objeto deve ser necessária.

Se você checar a seção de dependência acima você notará que todas as dependências são definidas entre arquivos objeto ao invés de arquivos fonte. Para mudar isso, podemos gerar uma relação dos arquivos fonte com seus respectivos arquivos objeto:

# Define a map from each file name to its object file
obj = $(src).o
$(foreach src, $(SRCS) $(TEST_SRCS), $(eval $(src) := $(obj)))

Note a declaração do obj como uma variável recursivamente expandida, efetivamente usamos esse mecanismo para definir a função em make. A função foreach nos permite fazer um laço em todos os arquivos fonte, enquanto que a função eval nos permite gerar as declarações make e avalia-las para esse Makefile.

Ajustamos as dependências de acordo como definimos o nome dos arquivos objeto através dos nomes dos arquivos fonte:

# Define all module interdependencies
csv_kinds.mod := $(src/csv_kinds.f90)
csv_module.mod := $(src/csv_module.F90)
csv_parameters.mod := $(src/csv_parameters.f90)
csv_utilities.mod := $(src/csv_utilities.f90)
$(src/csv_module.F90): $(csv_utilities.mod)
$(src/csv_module.F90): $(csv_kinds.mod)
$(src/csv_module.F90): $(csv_parameters.mod)
$(src/csv_parameters.f90): $(csv_kinds.mod)
$(src/csv_utilities.f90): $(csv_kinds.mod)
$(src/csv_utilities.f90): $(csv_parameters.mod)
$(src/tests/csv_read_test.f90): $(csv_module.mod)
$(src/tests/csv_test.f90): $(csv_module.mod)
$(src/tests/csv_write_test.f90): $(csv_module.mod)

A mesma estratégia de criar uma relação já é usada para os arquivos módulo, agora é apenas expandida para os arquivos objeto também.

Para gerar a respectiva relação de dependências automaticamente usaremos um script awk aqui

#!/usr/bin/awk -f

BEGIN {
    # Fortran is case insensitive, disable case sensitivity for matching
    IGNORECASE = 1
}

# Match a module statement
# - the first argument ($1) should be the whole word module
# - the second argument ($2) should be a valid module name
$1 ~ /^module$/ &&
$2 ~ /^[a-zA-Z][a-zA-Z0-9_]*$/ {
    # count module names per file to avoid having modules twice in our list
    if (modc[FILENAME,$2]++ == 0) {
        # add to the module list, the generated module name is expected
        # to be lowercase, the FILENAME is the current source file
        mod[++im] = sprintf("%s.mod = $(%s)", tolower($2), FILENAME)
    }
}

# Match a use statement
# - the first argument ($1) should be the whole word use
# - the second argument ($2) should be a valid module name
$1 ~ /^use$/ &&
$2 ~ /^[a-zA-Z][a-zA-Z0-9_]*,?$/ {
    # Remove a trailing comma from an optional only statement
    gsub(/,/, "", $2)
    # count used module names per file to avoid using modules twice in our list
    if (usec[FILENAME,$2]++ == 0) {
        # add to the used modules, the generated module name is expected
        # to be lowercase, the FILENAME is the current source file
        use[++iu] = sprintf("$(%s) += $(%s.mod)", FILENAME, tolower($2))
    }
}

# Match an include statement
# - the first argument ($1) should be the whole word include
# - the second argument ($2) can be everything, as long as delimited by quotes
$1 ~ /^(#:?)?include$/ &&
$2 ~ /^["'].+["']$/ {
    # Remove quotes from the included file name
    gsub(/'|"/, "", $2)
    # count included files per file to avoid having duplicates in our list
    if (incc[FILENAME,$2]++ == 0) {
        # Add the included file to our list, this might be case-sensitive
        inc[++ii] = sprintf("$(%s) += %s", FILENAME, $2)
    }
}

# Finally, produce the output for make, loop over all modules, use statements
# and include statements, empty lists are ignored in awk
END {
    for (i in mod) print mod[i]
    for (i in use) print use[i]
    for (i in inc) print inc[i]
}

Esse script tem algumas premissas sobre o código fonte analisado, então não funcionará para todo código Fortran (declaradamente submódulos não são suportados), mas para esse exemplo bastará.

Dica

Usando awk

O script acima usa a linguagem awk, a qual é projetada com o propósito de processar fluxos de texto e usa uma síntaxe C-like. Em awk você pode definir grupos que são avaliados em certos eventos, p.ex. quando uma linha corresponde a um padrão específico, geralmente espressada por uma expressão regular.

O script awk define cinco grupos, dois usam o padrão especial BEGIN e END os quais rodam antes do script iniciar e depois do script finalizar, respectivamente. Antes do script iniciar fazemos o script insensitivo à caixa já que estamos lidando com código fonte Fortran aqui. Também usamos a variável especial FILENAME para determinar qual arquivo nós estamos avaliado atualmente e permitir processar múltiplos arquivos de uma vez.

Com os três padrões definidos buscaremos as declarações module, use e include como as primeiras entradas delimitadas por espaço. Com o padrão usado nem todo código Fortran válido será avaliada corretamente. Um exemplo de falha seria:

use::my_module,only:proc

Para faze-lo avaliável pelo script awk adicionaremos outro grupo diretamente depois do grupo BEGIN, modificando o fluxo enquanto processa ele com

{
   gsub(/,|:/, " ")
}

Na teoria você precisaria de um avaliador Fortran completo para lidar com as linhas de continuação e outras dificuldades. Isso pode ser possível de implementar no awk mas iria requerer um script enorme no final.

Além disso, tenha em mente que gerar as dependências deve ser rápido, um avaliador caro pode produzir uma despesa adicional quando gerando dependências para uma base de código grande. Tomar premissas razoáveis pode simplificar e acelerar esse passo, mas também introduzir fontes de erro nas suas ferramentas de geração de build.

Faça o script executável (chmod +x gen-deps.awk) e teste-o com .gen-deps.awk $(find src -name *.[fF]90). Você deve ver uma saída como isto:

csv_utilities.mod = $(src/csv_utilities.f90)
csv_kinds.mod = $(src/csv_kinds.f90)
csv_parameters.mod = $(src/csv_parameters.f90)
csv_module.mod = $(src/csv_module.F90)
$(src/csv_utilities.f90) += $(csv_kinds.mod)
$(src/csv_utilities.f90) += $(csv_parameters.mod)
$(src/csv_kinds.f90) += $(iso_fortran_env.mod)
$(src/tests/csv_read_test.f90) += $(csv_module.mod)
$(src/tests/csv_read_test.f90) += $(iso_fortran_env.mod)
$(src/tests/csv_write_test.f90) += $(csv_module.mod)
$(src/tests/csv_write_test.f90) += $(iso_fortran_env.mod)
$(src/tests/csv_test.f90) += $(csv_module.mod)
$(src/tests/csv_test.f90) += $(iso_fortran_env.mod)
$(src/csv_parameters.f90) += $(csv_kinds.mod)
$(src/csv_module.F90) += $(csv_utilities.mod)
$(src/csv_module.F90) += $(csv_kinds.mod)
$(src/csv_module.F90) += $(csv_parameters.mod)
$(src/csv_module.F90) += $(iso_fortran_env.mod)

No que a saída do script usará variáveis recursivamente expandidas e não define nenhuma dependência ainda, por causa que a declaração de variáveis fora de ordem pode ser necessária e não queremos que crie qualquer coisa por acidente. Você pode verificar que a mesma informação como no snippet escrito acima é presente. A única exceção é a dependência adicional no iso_fortran_env.mod, já que é uma variável indefinida e irá apenas expandir em um string vazia e não introduzirá qualquer dependência.

Agora, você pode finalmente incluir este pedaço no seu Makefile para automatizar a geração de dependência:

# Disable the default rules
MAKEFLAGS += --no-builtin-rules --no-builtin-variables

# Project name
NAME := csv

# Configuration settings
FC := gfortran
AR := ar rcs
LD := $(FC)
RM := rm -f
GD := ./gen-deps.awk

# List of all source files
SRCS := src/csv_kinds.f90 \
        src/csv_module.F90 \
        src/csv_parameters.f90 \
        src/csv_utilities.f90
TEST_SRCS := src/tests/csv_read_test.f90 \
             src/tests/csv_test.f90 \
             src/tests/csv_write_test.f90

# Add source and tests directories to search paths
vpath % .: src
vpath % .: src/tests

# Define a map from each file name to its object file
obj = $(src).o
$(foreach src, $(SRCS) $(TEST_SRCS), $(eval $(src) := $(obj)))

# Create lists of the build artefacts in this project
OBJS := $(addsuffix .o, $(SRCS))
DEPS := $(addsuffix .d, $(SRCS))
TEST_OBJS := $(addsuffix .o, $(TEST_SRCS))
TEST_DEPS := $(addsuffix .d, $(TEST_SRCS))
LIB := $(patsubst %, lib%.a, $(NAME))
TEST_EXE := $(patsubst %.f90, %.exe, $(TEST_SRCS))

# Declare all public targets
.PHONY: all clean
all: $(LIB) $(TEST_EXE)

# Create the static library from the object files
$(LIB): $(OBJS)
	$(AR) $@ $^

# Link the test executables
$(TEST_EXE): %.exe: %.f90.o $(LIB)
	$(LD) -o $@ $^

# Create object files from Fortran source
$(OBJS) $(TEST_OBJS): %.o: % | %.d
	$(FC) -c -o $@ $<

# Process the Fortran source for module dependencies
$(DEPS) $(TEST_DEPS): %.d: %
	$(GD) $< > $@

# Define all module interdependencies
include $(DEPS) $(TEST_DEPS)
$(foreach dep, $(OBJS) $(TEST_OBJS), $(eval $(dep): $($(dep))))

# Cleanup, filter to avoid removing source code by accident
clean:
	$(RM) $(filter %.o, $(OBJS) $(TEST_OBJS)) $(filter %.d, $(DEPS) $(TEST_DEPS)) $(filter %.exe, $(TEST_EXE)) $(LIB) $(wildcard *.mod)

Aqui arquivos de dependência adicionais são gerados para cada arquivo fonte individualmente e então incluídos no Makefile principal. Al[em disso, os arquivos de dependência são adicionados como dependência para os arquivos objeto para garantir que serão gerados antes do objeto ser compilado. O carácter pipe nas dependências define a ordem de regras sem uma dependência temporal, porque não é necessário recompilar um arquivo de objeto no caso de dependências serem gerem e potencialmente não modificadas.

De novo, faremos uso da função eval para gerar as dependências em um laço foreach em todos os arquivos objeto. Note que criamos uma relação entre os arquivos objeto e os arquivos de dependência, expandindo dep uma vez rende o nome do arquivo objeto, expandir novamente rende os arquivos de objeto que ele depende.

Construindo seu projeto com make deve dar uma saída similar a

./gen-deps.awk src/csv_utilities.f90 > src/csv_utilities.f90.d
./gen-deps.awk src/csv_parameters.f90 > src/csv_parameters.f90.d
./gen-deps.awk src/csv_module.F90 > src/csv_module.F90.d
./gen-deps.awk src/csv_kinds.f90 > src/csv_kinds.f90.d
gfortran -c -o src/csv_kinds.f90.o src/csv_kinds.f90
gfortran -c -o src/csv_parameters.f90.o src/csv_parameters.f90
gfortran -c -o src/csv_utilities.f90.o src/csv_utilities.f90
gfortran -c -o src/csv_module.F90.o src/csv_module.F90
ar rcs libcsv.a src/csv_kinds.f90.o src/csv_module.F90.o src/csv_parameters.f90.o src/csv_utilities.f90.o
./gen-deps.awk src/tests/csv_read_test.f90 > src/tests/csv_read_test.f90.d
gfortran -c -o src/tests/csv_read_test.f90.o src/tests/csv_read_test.f90
gfortran -o src/tests/csv_read_test.exe src/tests/csv_read_test.f90.o libcsv.a
./gen-deps.awk src/tests/csv_test.f90 > src/tests/csv_test.f90.d
gfortran -c -o src/tests/csv_test.f90.o src/tests/csv_test.f90
gfortran -o src/tests/csv_test.exe src/tests/csv_test.f90.o libcsv.a
./gen-deps.awk src/tests/csv_write_test.f90 > src/tests/csv_write_test.f90.d
gfortran -c -o src/tests/csv_write_test.f90.o src/tests/csv_write_test.f90
gfortran -o src/tests/csv_write_test.exe src/tests/csv_write_test.f90.o libcsv.a

Uma vez que os arquivos de dependência são gerados, make irá apenas atualizá-los se a fonte mudar e não requer regerar o build novamente para cada invocação.

Dica

Com dependências corretas você pode alavancar a execução paralela no seu Makefile, apenas use a flag -j para criar múltiplos processos make.

Já que dependências podem ser geradas automaticamente, não precisa especificar os arquivos fonte explicitamente, a função wildcard pode ser usada para determiná-los dinamicamente:

# List of all source files
SRCS := $(wildcard src/*.f90) \
        $(wildcard src/*.F90)
TEST_SRCS := $(wildcard src/tests/*.f90)