Введение в систему сборки make#

Мы кратко рассказали об основах make. В этой главе приводятся идеи и стратегии масштабирования использования make для более крупных проектов.

Прежде чем подробнее рассмотреть систему сборки make, остановимся на нескольких моментах:

  1. make – это инструмент Unix подобных систем и при переносе на отличные от них платформы, у вас могут возникнуть трудности. Тем не менее существуют различные варианты make, но не все они могут поддерживать функции, которые вы захотите использовать.

  2. Хотя make даёт полный контроль над процессом сборки, это также означает, что вы несёте ответственность за весь процесс и должны указать плавила для каждой части вашего проекта. Вы можете обнаружить, что тратите значительное количество времени на написание и поддержку Makefile вместо того, чтобы разрабатывать исходный код.

  3. Вы можете работать над своим Makefile, но подумайте о других разработчиках проекта, которые могут быть не знакомы с системой сборки make. Сколько времени, по вашему мнению, они потратят на изучение вашего Makefile и смогут ли они отлаживать или добавлять функции?

  4. Чистый make не масштабируется. Вскоре вы добавите вспомогательные программы для динамической или статической генерации вашего Makefile. Они вводят зависимости и возможные источники ошибок. Не следует недооценивать усилия, необходимые для тестирования и документирования этих инструментов.

Если вы считаете, что система сборки make подходит для ваших нужд, то можете приступить к написанию своего Makefile. В этом курсе мы будем использовать реальные примеры из перечня пакетов, которые (на момент написания статьи) используют системы сборки, отличные от make. Это руководство представляет собой общий рекомендуемый стиль написания make скриптов, а также служит демонстрацией полезных и интересных функций.

Совет

Даже если вы считаете систему сборки make неподходящей для сборки вашего проекта, он является инструментом для автоматизации рабочих процессов, определяемых файлами. Возможно, вы сможете использовать её возможности в другом контексте.

Первые шаги#

В этой части статьи мы будем работать с проектом Fortran CSV module (v1.2.0). Наша цель – написать Makefile для компиляции этого проекта в статическую библиотеку. Начнём с клонирования репозитория

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

Совет

Здесь мы будем работать с кодом, соответствующему тэгу 1.2.0, чтобы сделать пример максимально воспроизводимым. Не стесняйтесь использовать последнюю версию или другой проект.

Этот проект использует систему сборки FoBiS и вы можете проверить файл build.sh для получения списка опций, используемых FoBiS. Мы собираемся написать Makefile для этого проекта. Сначала мы проверим структуру каталогов и исходных файлов

.
├── 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

Мы видим здесь семь файлов исходного кода на языке Fortran: четыре файла из каталога src должны быть скомпилированы и добавлены в статическую библиотеку, а три в каталоге src/tests содержат отдельные программы, которые зависят от этой статической библиотеки.

Начнём с создания простого Makefile:

# 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)

Вызов команды make должен привести к сборке статической библиотеки и исполняемых файлов тестовых программ, как и ожидалось:

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

Следует отметить несколько моментов: сборка с помощью make чередует элементы сборки и компиляцию файлов исходного кода, если вы не приложите дополнительные усилия для реализации каталога сборки. Кроме того, сейчас исходные файлы и зависимости указываются явно, что приводит к необходимости написания дополнительных строк даже в случае такого простого примера.

Автоматически создаваемые зависимости#

Основным недостатком системы сборки make при сборке проектов на языке Fortran является отсутствие возможности определения зависимостей от модулей. Обычно это решается либо указанием их вручную, либо автоматическим сканированием исходного кода с помощью внешнего инструмента. Некоторые компиляторы (например, Intel Fortran) также предлагают возможность генерировать список зависимостей в формате проекта make.

Прежде чем перейти к генерации списка зависимостей, мы опишем концепцию надёжного подхода к проблеме зависимостей. Во-первых, нам нужен подход, который может обрабатывать все исходные файлы, независимо, при этом каждый файл исходного кода предоставляет модуль (module ) или требует (use ) модули. При создании списка зависимостей известны только имя файла исходного кода и имена файлов модулей, а информация об именах объектных файлов не должна требоваться.

Если вы посмотрите раздел о зависимостях выше, то заметите, что все зависимости определены между объектными файлами, а не файлами исходного кода. Чтобы изменить это, мы можем создать соответствие между именами файлов исходного кода и именами соответствующих объектных файлов:

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

Обратите внимание на объявление obj как рекурсивно расширяемую переменную, мы эффективно используем этот механизм для определения функции в make. Функция foreach позволяет нам перебирать все файлы исходного кода, а функция eval позволяет нам генерировать операторы make и вычислять их значения для данного Makefile.

Мы соответствующим образом корректируем список зависимостей, поскольку теперь мы можем определять имена объектных файлов через имена файлов исходного кода:

# 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)

Такая же стратегия создания соответствия имён теперь используется для файлов модулей, на этот раз она распространяется и на имена файлов объектов.

Для автоматической генерации соответствий имён зависимостей мы будем использовать скрипт awk, представленный здесь

#!/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]
}

Этот скрипт делает несколько предположений об исходном коде, который он анализирует, поэтому он не будет работать с любым кодом на языке Fortran (в частности, не поддерживается использование submodules), но для данного примера его будет достаточно.

Совет

Использование awk

В приведённом выше скрипте используется язык awk, который предназначен для обработки текстового потока и использует синтаксис, похожий на синтаксис языка C. В скрипте awk вы можете определить группы, для элементов которых вычисляются значения при определённых событиях, например, когда строка соответствует определённому шаблону, обычно выраженному в виде регулярного выражения (regular expression).

Этот сценарий awk определяет пять групп, две из которых используют специальные шаблоны BEGIN и END, которые выполняются до начала работы сценария и после его завершения, соответственно. Перед началом работы скрипта мы делаем его нечувствительным к регистру, поскольку мы имеем дело с исходным кодом на языке Fortran. Мы также используем специальную переменную FILENAME, чтобы определять какой файл мы сейчас разбираем и чтобы можно было обрабатывать несколько файлов одновременно.

С помощью трёх заданных шаблонов мы ищем в качестве первой записи, разделённой пробелами, операторы module, use и include. При использовании данного шаблона не весь допустимый исходный код может быть разобран правильно. Примером кода, который не будет обработан правильно, может быть код:

use::my_module,only:proc

Чтобы сценарий awk правильно обрабатывал его, мы можем добавить ещё одну группу непосредственно после группы BEGIN, изменяя текстовый поток во время его обработки с помощью функции

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

В теории вам потребуется полный синтаксический анализатор языка Fortran для обработки продолжений строк и других случаев. Это возможно реализовать на awk, но потребуется огромный скрипт.

Также следует помнить, что генерация списка зависимостей должны быть быстрой. Ресурсоёмкий синтаксический анализатор может вызвать значительные накладные расходы при генерации списка зависимостей для большой кодовой базы. Принятие разумных допущений может упростить и ускорить этот этап, но также добавляет источник ошибок в ваши инструменты сборки.

Сделайте скрипт исполняемым (командой chmod +x gen-deps.awk) и протестируйте его работу, выполнив команду ./gen-deps.awk $(find src -name '*.[fF]90'). Вы должны увидеть вывод, похожий на следующий:

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)

Обратите внимание, что при выводе информации скриптами будут использоваться рекурсивно расширенные переменные и пока не будут определены зависимости, поскольку может потребоваться объявление переменных не по порядку, а мы не хотим случайно создать какую-либо цель сборки. Вы можете убедиться, что здесь присутствует та же информация, что и в приведённом выше рукописном фрагменте. Единственным исключением является дополнительная зависимость от iso_fortran_env.mod, поскольку это неопределённая переменная, она просто расширится до пустой строки и не будет создавать никаких дополнительных зависимостей.

Теперь, наконец, вы можете включить этот фрагмент в свой Makefile, чтобы автоматизировать генерацию списка зависимостей:

# 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)

Здесь дополнительные файлы зависимостей генерируются для каждого файла исходного кода отдельно, а затем включаются в основной Makefile. Кроме того, файлы зависимостей добавляются в качестве зависимостей к файлам объектов, чтобы гарантировать, что они будут сгенерированы до компиляции файла объекта. Символ конвейера (|) в списке зависимостей определяет порядок правил вне зависимости от метки времени, так как нет необходимости перекомпилировать объектный файл в случае, если зависимости сгенерированы и потенциально не изменялись.

Опять же, мы используем функцию eval для генерации зависимостей в цикле foreach по всем файлам объектов. Обратите внимание, что мы создали соответствие между файлами объектов и файлами зависимостей, расширяя его один раз, получаем имя объектного файла, расширяя его снова, получаем объектные файлы, от которых он зависит.

Сборка проекта с помощью make должна дать результат похожий на следующий

./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

После создания файлов зависимостей make будет обновлять их только при изменении их источника и не будет требовать их повторного создания при каждом вызове.

Совет

При наличии правильного списка зависимостей вы можете запускать параллельное выполнение вашего Makefile, просто используйте флаг -j для создания нескольких процессов make.

Поскольку теперь список зависимостей может генерироваться автоматически, нет необходимости указывать список исходных файлов явно, для его динамического определения можно использовать функцию wildcard:

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