Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Potwór przeszłości... Makefile cz. 2


2018-05-18, 00:00

Początkowo nie planowałem tego wpisu, ale wiedza na temat programu make okazała się bardziej rozległa, niż przypuszczałem. Zapraszam zatem na drugą część teoretyczną skoncentrowaną na Makefile. Dzisiaj opowiemy sobie, jak przebiega proces budowania programu przy pomocy Makefile. W poprzednim wpisie (link) opisałem stanowczo za mało, by móc stwierdzić że ta wiedza jest kompletna. Opisałem jednak wystarczająco dużo, by czytelnik mógł zrozumieć sposób jego działania. Dzisiaj omówię sposób, w jaki należy tworzyć reguły, aby móc wycisnąć z programu make to, co najlepsze.

Prawidłowa nomenklatura

Na samym początku należy wyjaśnić kilka drobnych pojęć:

  • Reguła (ang. Rule) - zbiór instrukcji tworzących jedną konkretną operację
  • Warunki wstępne (ang. Prerequisites) - zbiór reguł, od których potrzeby wykonania zależy wykonanie reguły zależnej
  • Instrukcja (ang. Command) - komenda zawarta wewnątrz reguły
  • Cel (ang. Target) - plik wynikowy generowany wewnątrz reguły
  • Cel główny (ang. Goal) - pierwsza reguła w pliku Makefile, uruchamiana za pomocą polecenia make

Równoważnikiem powyższego jest następujący (pseudo) kod:

# Makefile
Goal:
    Command;
    Command;

Rule:   Prerequsites
    Command;
    g++ main.cpp -o Target;

Uwaga! Zwróćcie uwagę na tabulacje. Program make jest bardzo wrażliwy i wymaga używania tabulacji w liniach komend.

Target oraz potrzeba rekompilacji

Główną zaletą plików Makefile jest możliwość rekompilacji jedynie tych części projektu, które zostały zmienione, bądź nie zostały jeszcze skompilowane. W jaki sposób to się odbywa? Przede wszystkim, program make lokalizuje regułę, którą należy zbudować. Jeżeli plik targetu nie istnieje, to reguła musi zostać uruchomiona. Najpierw wykonane zostają warunki wstępne, a następnie żądana reguła. Całość dzieje się rekursywnie. Kiedy plik targetu istnieje, program make sprawdza, czy jakiekolwiek warunki wstępne zmieniły się. Jeżeli chociaż jeden warunek wstępny wymaga ponownego uruchomienia, następuje jego ponowne wykonanie (rekursywnie). Po wykonaniu wszystkich warunków wstępnych, następuje wykonanie instrukcji z reguły targetu. Pamiętać należy, że jeżeli żaden warunek wstępny nie zmienił się, program make nie podejmie żadnych kroków.

Przyjrzyjmy się następującemu przykładowi:

# Makefile

main:
    g++ main.cpp -o main

Przy pierwszym uruchomieniu polecenia make zostanie wypisane na wyjściu:

#!/bin/bash
g++ main.cpp -o main

Oznacza to, że reguła main została uruchomiona, plik targetu został utworzony. Przy następnym uruchomieniu tej samej komendy ujrzeć możemy następujący tekst:

#!/bin/bash
make: `main' is up to date.

Wygląda na to, że wszystko zadziałało. Prawie wszystko. Powyższy przykład jest tylko połowicznym sukcesem, ponieważ mimo zmiany zawartości pliku main.cpp target nie zostanie ponownie przekompilowany. Dlaczego tak się dzieje? Zmiana kodu źródłowego ma się nijak do zasad rekompilacji, które opisałem powyżej. Nie zasypujmy jednak gruszek w popiele, jest na sposób.

Warunki wstępne - pliki źródłowe

Najbardziej oczywistym (i w zasadzie jedynym) rozwiązaniem problemu związanego z rekompilacją targetu po naniesieniu zmian do plików źródłowych jest umieszczenie ścieżek do plików źródłowych jako warunków wstępnych dla reguły tworzącej wspomniany target. Program make po datach modyfikacji plików dowie się, czy ponowna kompilacja jest potrzebna. Jak będzie wyglądał w takim razie plik Makefile w oparciu o nowo nabytą wiedzę? Zobaczcie sami:

# Makefile

main:   main.cpp
    g++ main.cpp -o main

Jest jeszcze jeden ważny warunek, o którym nie wspomniałem, a który sprawi, że zdefiniowana przez nas reguła zadziała prawidłowo: jej nazwa musi być dokładnie taka sama jak nazwa pliku targetu. Jeżeli warunek ten nie zostanie spełniony, re-kompilacja będzie następowała zawsze, niezależnie od tego, czy jest ona konieczna.

Reguły typu Phony

Specjalnym rodzajem reguł są te niegenerujące plików targetu, tzw. Phony Rules. Są to reguły, które służą głównie do czyszczenia katalogu budowania w celu przebudowania całości bądź części projektu od nowa (oczywiście nie tylko). Reguły typu Phony zazwyczaj nie posiadają warunków wstępnych oraz nie generują plików targetu, co powoduje, że za każdym razem efekt jej wywołania będzie jednakowy (mam na myśli to, że instrukcje zawarte w tej regule uruchomią się za każdym razem). Oto przykład prostej reguły typu Phony:

# Makefile

clean:  
    rm -R main

Reguła specjalna .PHONY

Według zasad rekompilacji, jeżeli chcemy jawnie uruchomić konkretnią regułę, przykładowo używając polecenia make clean, a w katalogu w którym działamy istnieje plik o nazwie clean (plik targetu dla tej reguły), to reguła ta nie zostanie uruchomiona. Sprawdzić wypadałoby, jak to się ma do reguł typu Phony. Niestety, ale zasada ta odnosi się również do reguł tego typu. Jak zatem poradzić sobie w takiej sytuacji? Z pomocą przychodzi nam reguła specjalna .PHONY. Jeżeli chcemy odwrócić wspomniane zachowanie, należy umieścić regułę clean jako warunek wstępny specjalnie interpretowanej reguły .PHONY. Taki zabieg pozwoli wykonywać regułę clean zawsze, niezależnie czy plik targetu dla tej reguły istnieje (przy czym pamiętać należy, że nazwa clean to może być dowolna nazwa reguły). Poniżej zamieszczam fragment kodu ilustrujący opisany przypadek:

# Makefile

main:
    g++ src/main.cpp -o main

.PHONY: clean

clean:
    rm main

Rekompilacja dużych projektów - wildcards

Prezentowane przeze mnie przykłady są bardzo proste i jest to zabieg celowy. Przykładowe fragmenty mają przekazać jedynie istotę omawianego tematu, nie rozpraszając uwagi czytelników na niepotrzebnym szumie informacyjnym. Mam nadzieję, że każdej osobie czytającej ten post przyjdzie do głowy pytanie o zastosowanie plików Makefile w większych projektach, składających się z dziesiątek, a niejednokrotnie i setek plików źródłowych. Dla wszystkich tych osób mam dobrą wiadomość: nie ma konieczności modyfikowania plików Makefile przy każdorazowym tworzeniu/usuwaniu plików źródłowych w projekcie. Istnieje bowiem mechanizm wildcards, dzięki któremu wyrażając jedną ścieżkę możemy uchwycić więcej niż jeden plik. O ile ścieżka src/main.cpp prowadzi jedynie do jednego pliku, to ścieżka:

src/*.cpp

zwróci nam listę wszystkich plików o rozszerzeniu .cpp istniejących wewnątrz katalogu src/ (symbol * oznacza dowolny ciąg znakowy). W miejsce powyższego odwołania wstawione zostaną poniższe (przykładowe oczywiście) ścieżki:

src/main.cpp src/test.cpp src/myframework.cpp

Jak można zauważyć, zwrócone zostały jedynie pliki znajdujące się dokładnie wewnątrz katalogu src. Aby do powyższego zbioru załączyć pliki spod katalogów znajdujących się jeden poziom niżej, należy podać:

src/*.cpp src/*/*.cpp

Powyższy zestaw ścieżek wygeneruje nam następujące (przykładowe) dane:

src/main.cpp src/test.cpp src/myframework.cpp src/object/code.cpp src/vendor/library.cpp

Nie jest to idealne rozwiązanie, mam nadzieję, że Was nie rozczarowałem.

Skoro dowiedzieliśmy się już, czym jest mechanizm wildcards, powiedzmy sobie jak można je wykorzystać w praktyce. Poniższy przykład prezentuje, w jaki sposób można skompilować wszystkie pliki źródłowe zawarte wewnątrz katalogu src/ do pliku targetu:

# Makefile

SOURCES=$(wildcard src/*.cpp)

main:   $(SOURCES)
    g++ $(SOURCES) -o $@

Nie jest to zbytnio skomplikowany kod, lecz należy wyjaśnić dwie kwestie. Po pierwsze, dlaczego wcześniej omówione src/*.cpp zostało zastąpione poprzez $(wildcard src/*.cpp) ? Pokusiłem się o to jedynie ze względów estetycznych. Gdybym użył SOURCES=src/*.cpp, to na wyjście program make użyłby komendy g++ src/*.cpp -o main. Kiedy jawnie wywołałem funkcję wildcard poprzez użycie SOURCES=$(wildcard src/*.cpp), uruchomione polecenie wygląda nieco inaczej:

g++ src/program.cpp src/test.cpp -o main

Jeżeli zastanawiacie się, jaka jest praktyczna różnica pomiędzy jednym a drugim poleceniem - odpowiedź brzmi: żadna. Jest to przydatne rozwiązanie, przede wszystkim kiedy potrzebujemy dowiedzieć się, które pliki biorą udział w kompilacji.

Drugą, bardzo przydatną cechą Makefile’ów jest obecność pewnych zmiennych, które pamiętają charakterystyczne informacje w obrębie kontekstu, w którym obecnie znajduje się wykonywany program. Jedną z takich zmiennych jest $@, która to zawsze zawiera nazwę bieżącego targetu (mówiąc ściślej, jest to nazwa obecnie wykonywanej reguły).

Wracając już do samego mechanizmu, to zawiera on w sobie sporą wadę: o ile faktycznie, przy zmianie kodu w którymkolwiek pliku źródłowym plik targetu zostanie przebudowany, o tyle - za każdym razem rekompilowane zostaną wszystkie pliki! Dzieje się tak, ponieważ programu g++ nie interesuje, czy jest potrzeba rekompilacji źródła, czy nie. To jest zadanie programu make. Ponieważ kompilacja wielo-plikowych projektów trwa często dosyć długo, musimy znaleźć sposób, aby każdy zmieniony plik źródłowy można było z osobna kompilować do pliku obiektowego, a następnie generować plik targetu na podstawie tych plików obiektowych. Poniżej znajduje się kod, który realizuje to zadanie:

# Makefile

SOURCES=$(wildcard src/*.cpp)
OBJECTS=$(patsubst %.cpp, %.o, $(SOURCES))

main:   $(OBJECTS)
    g++ $^ -o $@

$(OBJECTS): src/%.o : src/%.cpp
    g++ -c $< -o $@

Tutaj również pojawiło się kilka nowych konstrukcji, które wymagają wyjaśnienia. Otóż, pojawiła się konstrukcja OBJECTS=$(patsubst %.cpp, %.o, $(SOURCES)). Tworzymy zmienną OBJECTS, która wypełniona zostaje wartością zwróconą przez funkcję patsubst. Czym jest funkcja patsubst ? Jest to funkcja zamieniająca wystąpienia określonego fragmentu wewnątrz ciągu znakowego innym fragmentem. Funkcja ta korzysta z mechanizmu wildcards (znak %). Powyższe oznacza, że na podstawie wartości zmiennej SOURCES budujemy zmienną OBJECTS, przy czym ciągi .cpp zostają zamienione na .o. Zakładając, że wszystkie pliki wewnątrz katalogu src składają się na target, oraz każdy plik źródłowy ma swój odpowiedni plik obiektowy, możemy utworzyć regułę dla całej dynamicznie wygenerowanej listy plików obiektowych. Aby zrozumieć pozostałą część powyższego kodu, należy przedstawić czym są reguły o wielu targetach.

Reguły o wielu targetach

Specjalnym (bardzo ułatwiającym życie) rodzajem reguł są reguły o wielu targetach. Są to reguły, które potrafią przyjąć listę wielu targetów, na podstawie konkretnych wzorców odczytać target oraz warunki wstępne, po czym wygenerować plik targetu. Przykładowy kod:

# Makefile

main:   src/code.o src/clean.o
    g++ src/code.o src/clean.o -o main

src/code.o src/clean.o: %.o: %.cpp
    g++ -c $< -o $@

Tutaj przyjrzeć należy się ostatniej regule. Pierwsza część jej opisu zawiera listę targetów. Druga część to wzorzec dopasowywany do listy targetów w celu wyłuskania każdego kolejnego targetu z listy. Na podstawie dopasowanego wzorca generowany jest ciąg nazywany Stem (znak %), z którego korzystają druga oraz trzecia część reguły. Trzecia część reguły nazywana jest wzorcem warunków wstępnych. Dla powyższego przykładu jako Stem kolejno zostaną odnalezione src/code oraz src/clean, które zostają użyte wewnątrz instrukcji tej reguły. Brzmi to nieco skomplikowanie, ale wystarczy kilka minut testów, aby zrozumieć. Tajemnicze znaczki $< oraz $@ są zmiennymi automatycznymi, o których przeczytać można w dalszej części wpisu. Teraz, bogaci o nową wiedzę, możecie z powrotem wrócić do wcześniejszego przykładu aby móc przeanalizować już całość kodu.

Zmienne pamiętające kontekst

Pliki Makefile możemy definiować w sposób bardzo zautomatyzowany, o czym przekonaliśmy się w dzisiejszym wpisie. Aby nie zgubić się wewnątrz dynamicznie tworzonych reguł, możemy użyć specjalnego zestawu zmiennych, które pamiętają charakterystyczne informacje na temat aktualnie wykonywanej reguły. Oto ich lista:

  • $@ - nazwa pliku targetu w aktualnie uruchomionej regule
  • $< - nazwa pierwszego warunku wstępnego
  • $^ - lista wszystkich warunków wstępnych (zawiera ewentualne duplikaty)
  • $+ - lista wszystkich warunków wstępnych (bez duplikatów)
  • $? - lista wszystkich warunków wstępnych, które są nowsze niż target

Po więcej zmiennych automatycznych zapraszam na stronę dokumentacji.

Instrukcje warunkowe

Programując w C++ można spotkać się z wieloma problemami związanymi z przenośnością kodu. Czasami dla każdego rodzaju systemu operacyjnego trzeba napisać na nowo cały komponent, który następnie trzeba skompilować. Problem, z jakim się spotkamy to potrzeba automatycznej detekcji, które pliki źródłowe powinny zostać skompilowane na bieżącej maszynie? Przecież nie możemy kompilować pod Linuxem plików zawierających #include <windows.h>… Z pomocą przychodzi nam zestaw instrukcji warunkowych.

Wewnątrz plików Makefile możemy używać czterech bardzo zbliżonych do siebie instrukcji warunkowych:

  • ifeq - sprawdza, czy dwie wartości są sobie równe
  • ifneq - sprawdza, czy dwie wartości są od siebie różne
  • ifdef - sprawdza, czy zmienna posiada niepustą wartość
  • ifndef - sprawdza, czy zmienna posiada pustą wartość

Schemat budowy każdego z wyżej wymienionych typów instrukcji warunkowych wygląda następująco:

conditional-directive
    text-if-true
else
    text-if-false
endif

Ważnym jest, aby mieć świadomość, że warunki które dostarcza nam program make działają na poziomie plików Makefile, a nie instrukcji shellowych zawartych wewnątrz definiowanych reguł. Oznacza to, że jeżeli warunek nie wskoczy, to program make nie widzi tego, co zostaje wewnątrz niego. Ponieważ zmiennych definiowanych wewnątrz plików Makefile nie można modyfikować, powoduje to, że warunki działają na zasadzie preprocesora Makefie. Jeżeli przychodzi potrzeba użyć instrukcji warunkowych zależnych od aktualnego punktu wykonania programu, należy użyć instrukcji warunkowych shellowych.

Prosty tryb verbose

Jak w prosty sposób możemy wykorzystać wiedzę na temat instrukcji warunkowych? Na przykład możemy zbudować prosty tryb verbose, który odczyta wartość przekazaną do zmiennej i w zależności od tej wartości załączy regułę specjalną .SILENT:

# Makefile
VERBOSE?=FALSE

main:   clean
    g++ main.cpp -o main

clean:
    rm -rf main

ifneq ($(VERBOSE),TRUE)
.SILENT:
endif

Podsumowanie

Czytając dzisiejszy wpis zaznajomiliśmy się z prawidłową nomenklaturą, która panuje wewnątrz plików Makefile oraz dowiedzieliśmy się, jak wygląda główne flow dotyczące tworzenia reguł. Zdobyliśmy również wiedzę, w jaki sposób użyć plików Makefile, aby odrobinę uprzyjemnić sobie pracę w większych projektach, nie rekompilując tych części projektu, które ponownej kompilacji nie potrzebują. Nauczyliśmy się również, jak stosować instrukcje warunkowe, które mogą być używane między innymi do kompilacji innych części projektu w zależności od systemu operacyjnego, na którym obecnie się znajdujemy.



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.



Podobne wpisy


Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności