Wstęp do make#

Omówiliśmy już podstawy make. W tym rozdziale zaprezentujemy pomysły i strategie skalowania make w większych projektach.

Zanim przejdziemy do szczegółów make, rozważmy kilka punktów:

  1. make jest narzędziem Unix i może sprawiać ci problemy podczas przenoszenia na platformy inne niż Unix. Oprócz tego, są również dostępne różne odmiany make, nie wszystkie mogą obsługiwać funkcje, których chcesz użyć.

  2. make daje ci pełną kontrolę nad procesem kompilacji, oznacza to również, że to ty jesteś za niego odpowiedzialny oraz musisz sprecyzować zasady dla każdego szczegółu twojego projektu. Może okazać się, że przeznaczysz znaczną część swojego czasu na pisanie i utrzymywanie twojego Makefile zamiast na pisanie kodu źródłowego.

  3. Możesz pracować ze swoim Makefile, jednak pomysł również o innych deweloperach w twoim projekcie, którzy mogą być niezaznajomieni z make. Jak dużo czasu będą musieli poświęcić na nauczenie się twojego Makefile i czy będą w stanie go debugować i dodawać własne funkcje?

  4. Czysty make nie będzie się skalował. Wkrótce dodasz programy posiłkowe, aby dynamicznie lub statycznie generować twój Makefile. Te z kolei wprowadzają zależności i prawdopodobne źródła błędów. Wysiłek włożony w testowanie i dokumentowanie tych narzędzi powinien być wzięty pod uwagę.

Jeśli myślisz, że make jest odpowiedni dla twoich potrzeb, możesz zacząć pisać swój plik Makefile. W tym kursie użyjemy rzeczywistych przykładów z indeksu pakietów, który (w czasie pisania tego kursu) używa innych systemów kompilacji niż make. Ten poradnik przedstawia ogólny rekomendowany styl pisania make oraz powinien służyć jako demonstracja użytecznych i interesujących funkcji.

Wskazówka

Nawet jeśli make nie wydaje ci się odpowiedni do kompilacji twojego projektu, jest to narzędzie do automatyzacji przepływu pracy zdefiniowanej przez pliki. Może uda ci się wykorzystać jego moc w innym kontekście.

Pierwsze kroki#

W tej części będziemy pracowali z modułem Fortran CSV (v1.2.0). Naszym celem jest napisanie Makefile, aby skompilować ten projekt do biblioteki statycznej. Zacznij od sklonowania repozytorium

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

Wskazówka

W tej części będziemy pracowali z kodem o tagu 1.2.0, aby mógł być z łatwością odtworzony. Jednak możesz użyć też najnowszej wersji lub całkowicie innego projektu.

Ten projekt używa FoBiS jako swojego systemu kompilacji, możesz więc sprawdzić build.sh aby zobaczyć opcje użyte dla FoBiS. My jednak napiszemy Makefile dla tego projektu. Na początek sprawdzamy strukturę folderów oraz pliki źródłowe

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

Znaleźliśmy siedem różnych plików źródłowych Fortran; cztery w src powinny być skompilowane i dodane do biblioteki statycznej, a trzy pliki w src/tests zawierają osobne indywidualne programy, które zależą od tej statycznej biblioteki.

Zacznij od stworzenia prostego 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)

Wywołanie make powinno zbudować bibliotekę statyczną oraz sprawdzić pliki wykonywalne:

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

Należy zwrócić tutaj uwagę na kilka rzeczy. Kompilacja za pomocą make zazwyczaj przeplata artefakty kompilacji z kodem źródłowym, chyba że włożysz dodatkowy wysiłek w zaimplementowanie folderu kompilacji. Dodatkowo, teraz pliki źródłowe i zależności są określane jawnie, co skutkuje kilkoma dodatkowymi linijkami nawet dla tak małego projektu.

Automatyczne wygenerowane zależności#

Największa wadą make przy kompilacji Fortrana jest brak możliwości określenia zależności modułu. Jest to zwykle rozwiązywane przez dodanie ich ręcznie lub automatycznie skanowanie kodu źródłowego za pomocą narzędzi zewnętrznych. Niektóre kompilatory (tak jak Intel Fortran) oferują również generowanie zależności w formacie make.

Zanim jednak zanurzymy się w generowanie zależności, zarysujmy solidną koncepcję podejścia do problemu zależności. Po pierwsze, chcemy sposobu, który pozwoli na przetworzenie wszystkich plików źródłowych oddzielnie, kiedy każdy plik źródłowy zapewnia (module) lub wymaga (use) modułów. Podczas generowania zależności tylko nazwy plików źródłowych oraz pliki modułów są znane i żadne informacje o nazwach plików obiektowych nie powinny być wymagane.

Jeśli sprawdzisz powyższą sekcję zależności zauważysz, że wszystkie zależności są zdefiniowane pomiędzy plikami obiektowymi a nie plikami źródłowymi. Aby to zmienić, możemy wygenerować mapę z plików źródłowych do odpowiednich plików obiektowych:

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

Zauważ deklarację obj jako rekursywnie rozszerzaną zmienną. Używany tego mechanizmu do efektywnego definiowania funkcji w make. Funkcja foreach pozwala nam na wykonanie pętli po wszystkich plikach źródłowych, a funkcja eval na wygenerowanie instrukcji make oraz określenie ich dla Makefile.

Odpowiednio dostosowujemy zależności, ponieważ możemy teraz zdefiniować nazwy plików obiektowych poprzez nazwy plików źródłowych:

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

Ta sama strategia tworzenia mapy jest już używana dla plików modułowych, teraz jest ona poszerzona też do plików obiektowych.

Aby automatycznie wygenerować mapę poszczególnych zależności użyjemy skryptu 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]
}

Skrypt ten zakłada kilka rzeczy na temat kodu źródłowego, który analizuje, dlatego nie będzie działał poprawnie z każdym kodem Fortran (submoduły nie są wspierane), ale dla tego przykładu jest to wystarczające.

Wskazówka

Używanie awk

Powyższy skrypt używa języka awk, który został stworzony w celu przetwarzania strumieniowego tekstu (ang. text stream processing) i używa składni podobnej do C. W awk możesz definować grupy, które są ewaluowane na podstawie pewnych zdarzeń _np. _ kiedy dany wiersz pasuje do określonego wzorca, zazwyczaj wyrażonego wyrażeniem regularnym.

Ten skrypt awk definiuje pięć grup, dwie z nich używają specjalnego wzorca BEGIN oraz END, które są uruchamiane odpowiednio zanim skrypt się rozpocznie oraz po skończeniu skryptu. Zanim skrypt się rozpocznie sprawiamy, aby nieuwzględniał wielkości liter, ponieważ mamy tutaj do czynienia z kodem źródłowym Fortran. Używamy również specjalnej zmiennej FILENAME, aby określić, który plik jest aktualnie poddawany analizie oraz aby umożliwić przetwarzanie kilku plików na raz.

Mając określone trzy wzorce, szukamy nimi poleceńmodule, use oraz include jako pierwszego wpisu rozdzielonego spacją. Przy tym zastosowanym wzorze nie każdy kod Fortran zostanie poprawnie przeanalizowany. Przykładem błędu może być:

use::my_module,only:proc

Aby ten kod mógł zostać przeanalizowany przez awk możemy dodać jeszcze jedną grupę zaraz po grupie BEGIN modyfikując tym samym strumień podczas jego przetwarzania za pomocą

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

Teoretycznie potrzebny byłby pełny parser Fortran, aby poradzić sobie z kontynuacjami wierszy oraz innymi trudnościami. Mogłoby być to możliwe do zaimplementowania w awk, ale ostatecznie wymagałoby ogromnego skryptu.

Also, keep in mind that generating the dependencies should be fast, an expensive parser can produce a significant overhead when generating dependencies for a large code base. Making reasonable assumptions can simplify and speed up this step, but also introduces an error source in your build tools.

Make the script executable (chmod +x gen-deps.awk) and test it with ./gen-deps.awk $(find src -name '*.[fF]90'). You should see output like this:

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)

Note that the scripts output will use recursively expanded variables and not define any dependencies yet, because out-of-order declaration of variables might be necessary and we do not want to create any target by accident. You can verify that the same information as in the above handwritten snippet is present. The only exception is the additional dependency on the iso_fortran_env.mod, since it is an undefined variable it will just expand to an empty string and not introduce any further dependencies.

Now, you can finally include this piece in your Makefile to automate the dependency generation:

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

Here additional dependency files are generated for each source file individually and than included into the main Makefile. Also, the dependency files are added as dependency to the object files to ensure they are generated before the object is compiled. The pipe character in the dependencies defines an order of the rules without a timestamp dependency, because it is not necessary to recompile an object file in case dependencies are regenerated and potentially unchanged.

Again, we make use of the eval function to generate the dependencies in a foreach loop over all object files. Note that we created a map between the object files in the dependency files, expanding dep once yields the object file name, expanding it again yields the object files it depends on.

Building your project with make should give an output similar to

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

Once the dependency files are generated, make will only update them if the source changes and not require to rebuild them again for every invocation.

Wskazówka

With correct dependencies you can leverage parallel execution of your Makefile, just use the -j flag to create multiple make processes.

Since dependencies can now be generated automatically, there is no need to specify the source files explicitly, the wildcard function can be used to determine them dynamically:

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