Spis treści

Program make i pisanie makefiles – podstawy

Po co to komu?

Jak wiele narzędzi programistycznych, program make powstał... z lenistwa. Większość programistów nie lubi wykonywać nudnych, powtarzających się czynności, woląc zrzucić tę robotę na komputer. I słusznie. Mały program zazwyczaj zaczyna swoje istnienie w jednym pliku źródłowym. Można go jednym poleceniem skompilować i zlinkować otrzymując od razu gotowy plik wykonywalny. Polecenie, jakiego używamy w CLI jest zazwyczaj dość długie, ale po wpisaniu go wystarczającą ilość razy znamy je już na pamięć. Można też wejść na pierwszy poziom lenistwa i zdefiniować sobie skrypt o krótkiej nazwie, zawierający wywołanie kompilatora.

W miarę, jak program się rozrasta, trzymanie go w jednym pliku staje się coraz bardziej niewygodne. Z kilku powodów. Po pierwsze długi plik niewygodnie się edytuje w edytorze, wymaga też coraz więcej pamięci. Po drugie i ważniejsze, kompilacja dużego pliku długo trwa i wymaga jeszcze więcej pamięci. Ten czynnik jest szczególnie istotny przy natywnej kompilacji na Amigach. Po trzecie wreszcie łatwiej jest zarządzać dużym projektem, jeżeli podzielony jest na mniejsze, oddzielne moduły.

W tym momencie kompilacja staje się dwuetapowa. Najpierw kompilujemy pliki źródłowe do tak zwanych „obiektów” (pliki z rozszerzeniem *.o), a potem łączymy je w gotowy program przy pomocy linkera. Gwóźdź programu tkwi w tym, że kompilować musimy tylko te pliki źródłowe, w których grzebaliśmy od poprzedniej kompilacji. Pozostałe tylko linkujemy, a ten proces jest zazwyczaj znacznie szybszy, niż kompilacja. Najczęściej między kompilacjami edytujemy tylko jeden plik. Wyobraźmy sobie, że mamy program w 10 plikach źródłowych, każdy po 10 kB. Gdybyśmy to mieli w jednym, 100 kB pliku, każda kompilacja, po najdrobniejszej nawet zmianie w kodzie trwałaby (mniej więcej) 10 razy dłużej! Kompilator zająłby też znacznie więcej pamięci.

Wadą tego rozwiązania jest więcej klepania poleceń w CLI. Oczywiście można napisać jakiś nieco bardziej złożony skrypt AmigaDOS. Jednakże skrypt musiałby sam wykrywać które pliki zostały zmodyfikowane i w związku z tym trzeba je skompilować. Z tym oczywiście również można sobie poradzić, ale nie jest to wygodne. Znacznie wygodniej jest sięgnąć po program make, który jest niczym więcej jak rozszerzeniem możliwości skryptowych CLI. Mimo, że wywodzi się z systemów uniksowych, poradzi sobie spokojnie ze środowiskiem amigowym.

W tym tekście opisuję program GNU Make, w dość antycznej wersji 3.77, która towarzyszy kompilatorowi GCC 2.95.3. Jego możliwości są wystarczające, a apetyt na zasoby komputera umiarkowany. Warto przy tym pamiętać, że program ten wcale nie wymaga do działania zainstalowanego GCC, czy pełnego środowiska GeekGadgets. Wystarczy, że mamy w systemie ixemul.library i uniksowego shella sh. Można go znaleźć np. w archiwum „BOOT.lha” z GeekGadgets. Plik wykonywalny sh możemy skopiować np. do katalogu C:.

Na Aminecie znalazłem make w wersji 3.75, skompilowany bez zależności od ixemul.library i nie wymagający rzeczonego sh. Niestety w praktyce okazało się, że posiada błędy i jest niestabilny, dlatego nie polecam.

Natomiast make 3.77 z powodzeniem wykorzystuję do kompilowania kompilatorem VBCC, czy nawet budowania większych programów w asemblerze. Oczywiście można poszukać sobie nowszej wersji, użytkownicy systemów amigowych NG, czy krosskompilujący na PC będą mieli taką nowszą wersję w standardzie. Wszystkie opisane możliwości działają tak samo w wersjach nowszych.

Ponieważ program make sprawdza zależności między plikami na podstawie ich dat, zdecydowanie warto mieć w Amidze sprawny zegar czasu rzeczywistego (RTC), albo cierpliwie po każdym włączeniu/resecie ustawiać czas systemowy na aktualny. 

Skrypt makefile

Jako że program make to rozszerzenie skryptowych możliwości samego systemu (w tym przypadku AmigaOS), wykonuje on napisany przez nas skrypt. Skrypt to zwykły plik tekstowy przygotowany dowolnym (no, prawie, o czym niżej) edytorem tekstu. Domyślną nazwą tego skryptu jest „makefile”. Jeżeli w jakimś katalogu uruchomimy program wpisując po prostu

make

to make sprawdzi, czy w katalogu bieżącym jest plik o nazwie „makefile” i spróbuje go wykonać. Oczywiście make może wykonać plik o dowolnej nazwie, skłonimy go do tego używając opcji −f. Na przykład:

make -f mejkfajl

spowoduje próbę odszukania w bieżącym katalogu i wykonania skryptu o nazwie „mejkfajl”.

Kilka ogólnych uwag o tworzeniu makefiles. Czasami możemy chcieć umieszczać w nich komentarze. Komentarz musi się zaczynać od początku linii, pierwszym znakiem takiej linii powinien być hasz (#). Niezmiernie istotną sprawą jest, aby edytor nie zamieniał tabulatorów na spacje. Tabulator w makefile jest znakiem znaczącym i powinien rozpoczynać każdą linię zawierającą polecenie dla reguły. Podstępna zamiana tego taba na spacje przez edytor, albo odruchowe wpisanie spacji z klawiatury, to najczęstsza przyczyna błędów. Trzeba też uważać na edytory automatycznie wcinające linię (auto indent). Najlepszy jest edytor pokazujący wizualnie białe znaki, na przykład CygnusED albo MorphOS-owy Scribble.

Struktura reguły

Plik „makefile” to zestaw reguł na podstawie których budowany jest ostateczny plik wynikowy (najczęściej jest to skompilowany program). Żeby nie teoretyzować, popatrzmy sobie jak może wyglądać prosta reguła:

main.o: main.c main.h functions.h
    gcc -c -o main.o -nostdlib -O2 main.c

Pierwsza linia rozpoczyna się nazwą reguły, która najczęściej jest też nazwą pliku, jaki powstaje po wykonaniu reguły. Po dwukropku zaś mamy wypisane zależności, czyli jakie inne pliki są potrzebne do stworzenia aktualnej wersji pliku wynikowego. Przykładowa reguła polega na skompilowaniu pliku źródłowego „main.c” w wyniku czego powstanie plik z kodem (obiekt) „main.o”. Dodatkowo w zależnościach mamy dwa pliki nagłówkowe, które są dołączane (dyrektywą #include) w czasie kompilacji. I teraz najważniejsze, reguła zostanie wykonana tylko wtedy, gdy którykolwiek z plików wymienionych w zależnościach jest nowszy od pliku wynikowego reguły. Zostanie też oczywiście wykonana, gdy plik wynikowy nie istnieje. Jeżeli natomiast istnieje, a wszystkie pliki-zależności są starsze, to make uznaje, że plik wynikowy jest aktualny i nie wykonuje reguły.

Pod linią zależności kolejne linie zawierają polecenia, które tworzą plik wynikowy z plików wejściowych. W naszym przykładzie jest to jedna linia, ale może ich być więcej, jeżeli do otrzymania pliku wynikowego trzeba wywołać kilka programów. Każda taka linia musi się zaczynać od tabulatora.

Powyższy przykład można więc w ludzkim języku opisać następująco:

Sposób na wygenerowanie pliku „main.o”: Jeżeli plik „main.o” nie istnieje, albo którykolwiek z plików po dwukropku jest nowszy, niż istniejący „main.o”, wykonaj kolejno polecenia w poniższych liniach.

Ten opis ma jedną lukę. Co się stanie, jeżeli nie istnieje któryś z plików podanych jako zależności? Otóż wtedy make spróbuje znaleźć w makefile regułę wytworzenia tego pliku. A więc zależnością w regule może być nie tylko plik na dysku, ale też nazwa innej reguły. Dzięki temu make potrafi obsłużyć wielostopniowy proces budowania programu. W przypadku naszej reguły przykładowej żaden z plików zależności oczywiście nie będzie posiadał swojej reguły, bo są to pliki, które tworzymy ręcznie w edytorze. W takiej sytuacji brak któregoś z tych plików oznacza poważny problem i make wyświetli komunikat o braku pliku i braku reguły dla niego.

Popatrzmy teraz na typowy przykład dwustopniowej kompilacji:

ProgramX: main.o funkcje.o
    gcc -s -O2 -nostdlib -o ProgramX main.o \
funkcje.o

main.o: main.c main.h funkcje.h
    gcc -c -O2 -nostdlib -o main.o main.c

funkcje.o: funkcje.c funkcje.h
    gcc -c -O2 -nostdlib -o funkcje.o funkcje.c

Na początek uwaga kosmetyczna, długie linie można w makefile łamać kończąc je znakiem odwrotnego ukośnika (podobnie jak makra w C), tak jak to pokazano w pierwszej regule.

Bardzo ważne jest natomiast to, jak zachowuje się make sprawdzając wielopoziomowe zależności. Zauważmy, że jeżeli dokonamy zmian na przykład w „main.c”, to nic się nie zmieni w regule tworzącej „ProgramX”, pliki obiektów nadal będą starsze, niż gotowy program. Ale make jest na to przygotowany. Jeżeli którakolwiek z zależności jest regułą, to przed rozpatrzeniem reguły nadrzędnej zostaną rozpatrzone wszystkie reguły podrzędne (a jeżeli i one mają reguły w zależnościach, to rozpatrywanie zejdzie rekurencyjnie na następny poziom). W naszym przykładzie make nie poprzestanie na stwierdzeniu, że oba pliki „main.o” i „funkcje.o” są starsze, niż „ProgramX”. Ponieważ są to jednocześnie nazwy reguł, najpierw reguły te zostaną sprawdzone. Sprawdzenie reguły „main.o” wykaże, że plik „main.c” jest nowszy od „main.o”, bo właśnie go edytowaliśmy. W związku z tym zostanie wykonana reguła dla „main.o”. Potem make wróci do nadrzędnej reguły „ProgramX” i w tym momencie okaże się, że „main.o” jest już nowszy, bo właśnie przed chwilą został skompilowany. Zatem główna reguła, linkująca obiekty w plik wykonywalny, również zostanie wykonana.

Wywoływanie reguł i reguła domyślna

Wywołując make z nazwą reguły każemy mu rozpatrzyć i ewentualnie wykonać tylko określoną regułę, oraz jej reguły podrzędne, jeżeli takie są. Wracając do przykładu, możemy wywołać make na przykład tak:

make funkcje.o

Wtedy zostanie rozpatrzona tylko reguła „funkcje.o”. Jeżeli zostanie wykonana, to plik „funkcje.o” stanie się nowszy niż „ProgramX”, ale tym razem reguła „ProgramX” nie będzie nawet rozpatrywana, tak samo jak rozpatrywana nie będzie reguła „main.o”.

Nasuwa się tu pytanie, jeżeli wywołujemy make bez nazwy reguły, to która reguła będzie wzięta do rozpatrzenia? Będzie to po prostu pierwsza reguła w makefile, dlatego pierwszą regułą w przykładzie jest wywołanie linkera, tworzące finalny plik wynikowy. Kolejność pozostałych reguł nie ma już znaczenia, make odszukuje je po nazwach.

Reguły bez zależności

Reguły bez zależności często są używane jako pomocnicze, nie budujące programu, ale wykonujące jakieś dodatkowe czynności i najczęściej uruchamiane przez podanie nazwy przy wywoływaniu make. Bardzo popularnym przykładem jest reguła clean, która „oczyszcza” katalog projektu z wszelakich plików pośrednich (najczęściej obiektów z kodem). Dla naszego przykładu może wyglądać tak:

clean:
    Delete #?.o

Na pierwszy rzut oka, skoro reguła nie ma zależności, to nie zostanie wykonana nigdy, bo nie ma plików do sprawdzenia. Ale pamiętamy, że jeżeli plik wynikowy nie istnieje, to reguła jest wykonana zawsze. Ponieważ raczej nie mamy pliku „clean”, więc

make clean
za każdym razem wykasuje nam wszystkie pliki pośrednie z kodem.
Problem moglibyśmy mieć, gdyby jednak z jakichś powodów pojawił się w katalogu projektu plik o nazwie „clean”. Jeżeli istnieje ryzyko konfliktu nazwy reguły z jakimś plikiem, można użyć pseudoreguły .PHONY. Dla reguł podanych jako jej zależności będzie pomijane sprawdzanie istnienia pliku wynikowego (o nazwie reguły), make przyjmie, że ten plik nie istnieje, nawet gdy będzie na dysku.

Zmienne

Zmienne programu make upraszczają pisanie skryptów, które często zawierają powtarzające się fragmenty. Ułatwiają też szybką modyfikację działania skryptu, czy przenoszenie go na inne systemy operacyjne. Wróćmy do naszego przykładu wielostopniowej kompilacji. Wszystkie wywołania kompilatora mają podobny zestaw parametrów, jego częścią wspólną jest:

-O2 -nostdlib

Zamiast pisać to wszędzie za każdym razem, możemy na początku makefile zdefiniować sobie zmienną:

CFLAGS = -O2 -nostdlib

A następnie użyć jej w następujący sposób

gcc -c $(CFLAGS) -o main.o main.c

W momencie napotkania sekwencji „$(<nazwa>)”, make wstawia tam zawartość zmiennej (bez znaku dolara i nawiasów). Zwyczajowo nazwy zmiennych w makefile pisze się wielkimi literami.

Póki co przykład nie jest zbyt przekonujący. Może trochę mniej pisania, ale to i tak przecież pisze się raz i można po prostu skopiować regułę odpowiednią ilość razy. Wyobraźmy sobie jednak, że zapragniemy na próbę skompilować nasz program dla procesora 68040. Musimy do każdego wywołania kompilatora dodać opcję „−m68040”. Ale, jeżeli zdefiniowaliśmy zmienną, robimy to w jednym miejscu:

CFLAGS = -O2 -nostdlib -m68040

Albo nawet tak, dzięki czemu oddzielamy sobie opcje podstawowe od eksperymentalnych:

CFLAGS = -O2 -nostdlib
CFLAGS += -m68040

Wtedy po zakończeniu eksperymentów po prostu kasujemy drugą linię. Operator „+=” powoduje dodanie nowego tekstu na końcu aktualnej zawartości zmiennej.

Przyjęło się tradycyjnie, że zmienna CC zawiera nazwę kompilatora, a CFLAGS opcje kompilacji, podobnie LD i LDFLAGS zawierają nazwę i opcje linkera. Nie jest to jednak reguła wymuszana przez make, po prostu programistyczny zwyczaj.