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. Byłoby to możliwe do zaimplementowania w awk, ale ostatecznie wymagałoby ogromnego skryptu.

Należy również pamiętać, że generowanie zależności powinno być szybkie. Wymagający parser może stwarzać duże koszty ogólne podczas generowania zależności dla dużych baz kodu. Rozsądne założenia mogą uprościć i przyspieszyć ten krok, ale także wprowadzić źródło błędów w narzędziach do kompilacji.

Spraw, aby skrypt był wykonalny (chmod +x gen-deps.awk) oraz przetestuj go za pomocą ./gen-deps.awk $(find src -name '*.[fF]90'). Powinnieneś zobaczyć taki wynik:

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)

Należy pamiętać, że skrypty będą używać zmiennych rozszerzanych rekurencyjnie i nie będą jeszcze definiować żadnych zależności, ponieważ deklaracja zmiennych w nieuporządkowanej kolejności może być konieczna, a nie chcemy przypadkowo utworzyć żadnego docelowego elementu. Możesz także sprawdzić, czy obecne są te same informacje, co w powyższym, ręcznie napisanym fragmencie kodu. Jedynym wyjątkiem jest dodatkowa zależność pliku iso_fortran_env.mod. Jest to zmienna niezdefiniowana, dlatego zostanie ona po prostu rozwinięta do pustego ciągu znaków i nie wprowadzi żadnych dalszych zależności.

Teraz możesz wprowadzić ten fragment w twoim Makefile, aby zautomatyzować generowanie zależności:

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

Tutaj dodatkowe pliki zależności są generowane indywidualnie dla każdego pliku źródłowego, a później zawarte w głównym Makefile. Ponadto, pliki zależności są dodawane jako zależności do plików obiektowych, aby upewnić się, że są generowane zanim obiekt jest skompilowany. Kreska pionowa (ang. pipe) umieszczona w zależnościach definiuje kolejność zasad bez zależności czasowej, ponieważ rekompilacja pliku obiektowego w przypadku, kiedy zależności są odnawiane i potencjalnie niezmienione nie jest konieczna.

Ponownie, wykorzystaj funkcję eval do wygenerowania zależności w pętli foreach wykonanej na wszystkich plikach obiektowych. Zauważ, że utworzyliśmy mapę między plikami obiektów w plikach zależności. Rozwinięcie dep raz daje nazwę pliku obiektu, a jego ponowne rozwinięcie daje pliki obiektów, od których jest on zależny.

Kompilacja twojego projektu z użyciem make powinna dać podobne wyniki

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

Kiedy pliki zależności są już wygenerowane make będzie je aktualizował tylko w przypadku zmiany źródła, a nie przy każdym wywołaniu.

Wskazówka

Stosując prawidłowe zależności możesz wykorzystać równolegle wykonywanie pliku Makefile używając flagi -j, aby stworzyć wiele procesów make.

Ponieważ zależności mogą teraz być generowane automatycznie, nie ma potrzeby jawnego określania plików źródłowych. Aby określić je dynamicznie, możesz użyć funkcji wildcard:

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