Automatyzacja kompilacji - make

Wstęp



Przypuśćmy, że piszemy program w C, który składa się z kilku plików, a mianowicie main.c, komunikat.c, komunikat.h, test.c, test.h. Dla przykładu niech to będą bardzo proste źródła.

main.c:
 
#include "test.h"
int main()
{
  test();
  return 0;
}

komunikat.c:

#include "komunikat.[geshifilter-code]h[/geshifilter-code]"
const char *komunikat = "test";

komunikat.h:

#ifndef KOMUNIKAT_H
#define KOMUNIKAT_H
extern const char *komunikat;
#endif

test.c:

#include <stdio.h>
#include <math.h>
#include "test.h"
#include "komunikat.h"
void test()
{
  printf("%s\n", komunikat);
  printf("sin(2)=%f\n", sin(2));
}

test.h:

#ifndef TEST_H
#define TEST_H
extern void test();
#endif

Kompilujemy je stopniowo. Wpierw tworzymy pliki *.o z plików *.c, a następnie je linkujemy. Czyli kolejne polecenia kompilacji wyglądają następująco:

gcc -Wall -c komunikat.c -o komunikat.o
gcc -Wall -c main.c -o main.o
gcc -Wall -c test.c -o test.o
gcc -lm komunikat.o main.o test.o -o program

Dla ułatwienia kompilacji możemy sobie te polecenia zapisać do skryptu i uruchamiać ten skrypt za każdym razem, gdy zmodyfikujemy jakieś źródła. Skrypt ma jednak parę wad. Po pierwsze jeśli dodamy lub usuniemy pliki źródłowe C, to będziemy musieli przeedytować skrypt. Możemy z tym problemem sobie poradzić, jeśli w danym katalogu znajdują się tylko pliki źródłowe należące do danego programu. Wtedy wystarczy przerobić skrypt.

for f in *.c; do
  gcc -Wall -c $f -o ${f%c}o
done
gcc -lm *.o -o program

Druga wada skryptu jest taka, że kompilujemy za każdym razem wszystkie źródła, a przecież wystarczy skompilować tylko te co się zmieniły i powtórzyć linkowanie plików *.o w jeden program. Taki skrypt jesteśmy w stanie napisać, ale zrobiłoby się to bardzo skomplikowane.

Po za tym co jak będziemy chcieli zmienić opcje kompilacji, linkowania, itp. Będziemy musieli rozbudować nasz skrypt znacznie. Tymczasem gotową automatyzację udostępnia program make i skupimy się dalej na wykorzystaniu tego narzędzia do ułatwienia życia przy rekompilacji programu.

Pierwszy makefile


Program make czyta co ma zrobić z pliku o nazwie makefile, zatem cała sztuka użycia make sprowadza się do umiejętności pisania pliku makefile.

Najważnieszą składowa pliku makefilereguły. Reguła wygląda następująco:

cel: zależności
        polecenie

Reguła określa w jakis sposób należy budować cel. Znaczenie poszczególnych składników.

cel
nazwa pliku, który ma powstać z tej reguly,
zależności
lista plików od których zależy cel, tzn. zmiana któregokolwiek z tych plików oznacza, że cel też się zmieni,
polecenie
jest to polecenie za pomocą, którego ma zostać wytworzony cel.

Uwaga! Istotne jest, aby polecenie było wcięte za pomocą jednego znaku tabulacji, a nie przypadkiem spacji. Jest to dosyć kłopotliwe, ale cóż, każde narzędzie miewa swoje widzimisie.

Dla przykładu reguła

komunikat.o: komunikat.c komunikat.h
       gcc -Wall -c komunikat.c -o komunikat.o

mówi w jaki sposób tworzyć plik komunikat.o. Zależy on od dwóch plików źródłowych komunikat.c i komunikat.h.

W jaki sposób znajdować pliki zależna? Można posłużyć się opcją -MM polecenia gcc.

gcc -MM plik.c

Powyższe polecenie wyrzucie linię

plik.o: plik.c inne pliki źródłowe

pokazującą jakie są jeszcze inne pliki źródłowe, od których zależy dany plik. Forma wyjścia nie jest przypadkowa. Jest taka, aby było można ją łatwo wstawić do pliku code>makefile.M

Z pomocą gcc -MM lub bez tworzymy nasz pierwszy makefile, który wygląda tak:

program: komunikat.o main.o test.o
        gcc -lm komunikat.o main.o test.o -o program
 
komunikat.o: komunikat.c komunikat.h
        gcc -Wall -c komunikat.c -o komunikat.o
 
main.o: main.c test.h
        gcc -Wall -c main.c -o main.o
 
test.o: test.c test.h komunikat.h
        gcc -Wall -c test.c -o test.o

Teraz w wyniku wykonania polecenia make mamy taki efekt:

$ make
gcc -Wall -c komunikat.c -o komunikat.o
gcc -Wall -c main.c -o main.o
gcc -Wall -c test.c -o test.o
gcc -lm komunikat.o main.o test.o -o program

Zostały utworzone odpowiednie pliki *.o oraz program wykonywalny program.

W jaki sposób zadziałał make? Otóż jeśli nie podamy mu żadnego argumentu, to próbuje on utworzyć cel występujący w pierwszej regule pliku makefile. W tym celu tworzy wpierw wszystkie pliki od których on zależy, jeśli takowe jeszcze nie istnieją. Do tworzenia plików *.o używa dalszych reguł. Tworzenie celu kończy się porażką jeżeli zajdzie jeden z przypadków:

  • plik, od którego zależy cel, nie zostanie znaleziony,
  • nie będzie można znaleźć reguły, do utworzenia danego pliku,
  • polecenie podane w regule skończy się porażką.

Ponadto make potrafi stwierdzać, czy jest potrzeba ponownego wykonania poleceń kompilacji. Wykonajmy go jeszcze raz:

$ make -f simple.mak 
make: `program' jest aktualne.

co oznacza, że plik wykonywalny program jest aktualny i nie trzeba nic uruchamiać.

Teraz zmodyfikujmy jakiś plik. Zasymulujemy to poleceniem touch:

$ touch komunikat.h

Uruchamiamy make jeszcze raz:

$ make -f simple.mak 
gcc -Wall -c komunikat.c -o komunikat.o
gcc -Wall -c test.c -o test.o
gcc -lm komunikat.o main.o test.o -o program

Tym razem ponownie zostały utworzone te pliki wynikowe, które zależały od komunikat.h, a mianowicie są to komunikat.o i test.o. Oczywiście zostało też ponowione linkowanie.

Reguły jak polecenia

Dodamy regułę, która będzie czyścić niepotrzebne pliki:

program: komunikat.o main.o test.o
        gcc -lm komunikat.o main.o test.o -o program
 
komunikat.o: komunikat.c komunikat.h
        gcc -Wall -c komunikat.c -o komunikat.o
 
main.o: main.c test.h
        gcc -Wall -c main.c -o main.o
 
test.o: test.c test.h komunikat.h
        gcc -Wall -c test.c -o test.o
 
clean:
        rm -f program komunikat.o main.o test.o

Nowa reguła nie ma zależności, a jest jedynie cel, który w wyniku polecenia w tej regule i tak nie jest tworzony. Nie mniej takie reguły są przydatne. Są to takie reguły-polecenia. Żeby je wykonać należy wywołać polecenie make z nazwą celu jako argument:

$ make clean
rm -f program komunikat.o main.o test.o

Problem jest jednak taki, że jeśli będzie np. istniaj plik o nazwie clean to taka reguła nie zostanie wykonana, gdyż cel będzie już istniał, a wszystkie pliki, od których on zależy (wszystkie, czyli żadne) są aktualne. Żeby wskazać, że dana reguła jest tak na prawdę tylko wywołaniem polecenia, należy dodać regułę z celem o specjalnej nazwie .PHONY.

program: komunikat.o main.o test.o
        gcc -lm komunikat.o main.o test.o -o program
 
komunikat.o: komunikat.c komunikat.h
        gcc -Wall -c komunikat.c -o komunikat.o
 
main.o: main.c test.h
        gcc -Wall -c main.c -o main.o
 
test.o: test.c test.h komunikat.h
        gcc -Wall -c test.c -o test.o
 
.PHONY: clean
 
clean:
        rm -f program komunikat.o main.o test.o

Zmienne i funkcje


Zmienne

Nasz makefile jest na razie dosyć brzydki. Co jeśli będziemy chcieli dodać nowy plik wynikowy o rozszerzeniu .o? Będziemy musieli go dodać w kilku miejscach, co jest jednak dosyć żmudne.

Z pomocą przychodzą zmienne. Jeśli zadeklarujemy zmienną objects

objects=komunikat.o main.o test.o
to będziemy mogli tej zmiennej użyć w dalszej części przez wywołanie $(objects). W ten sposób znacznie uprościmy makefile:
 
objects=komunikat.o main.o test.o
 
program: $(objects)
        gcc -lm $(objects)  -o program
 
komunikat.o: komunikat.c komunikat.h
        gcc -Wall -c komunikat.c -o komunikat.o
 
main.o: main.c test.h
        gcc -Wall -c main.c -o main.o
 
test.o: test.c test.h komunikat.h
        gcc -Wall -c test.c -o test.o
 
.PHONY: clean
 
clean:
        rm -f program $(objects)

Funkcje

make udostępnia szereg wbudowanych funkcji, aby ułatwić nieco życie. Szczegółowe zestaw dostępnych funkcji jest opisany w dokumentacji, my dla przykładu pokażemy zastosowanie funkcji patsubst.

Wprowadziliśmy zmienną objects, która zawiera listę wszystkich plików wynikowych *.o, które wchodzą w skład programu. Jednakże jest tak, że każdemu plikowi źródłowemu o rozszerzeniu .c odpowiada dany plik wynikowy. Powinniśmy raczej zadeklarować zmienną

sources=komunikat.c main.c test.c

pamiętającą wszystkie pliki źródłowe, a na ich podstawie powinniśmy utworzyć zmienną objects. Możemy zastosować funkcję patsubst:

objects=$(patsubst %.c,%.o,$(sources))

Składnia tej funkcji jest następująca:

$(patsubst wzorzec_wejściowy,wzorzec_wynikowy,lista_wyrazów)

We wzorcach znak % spełni podobną rolę jak * we wzorcach nazw plików. patsubst działa mniej więcej tak, że każdy wyraz z listy kojrzy ze wzorcem wejściowym i zamienia go tak, aby pasował do wzorca wyjściowego, pozostawiając niezmienione części, które zostały przypasowane do znaku %.

W tym przypadku, gdy zamieniamy tylko sufiksy, a listą jest wartość zmiennej patsubst ma skróconą wersję:

$(sources:.c=.o)

Użyjemy właśnie jej.

sources=komunikat.c main.c test.c
objects=$(sources:.c=.o)
 
program: $(objects)
        gcc -lm $(objects)  -o program
 
komunikat.o: komunikat.c komunikat.h
        gcc -Wall -c komunikat.c -o komunikat.o
 
main.o: main.c test.h
        gcc -Wall -c main.c -o main.o
 
test.o: test.c test.h komunikat.h
        gcc -Wall -c test.c -o test.o
 
.PHONY: clean
 
clean:
        rm -f program $(objects)

Jeżeli byśmy wiedzieli, że w skład programu wchodzą wszystkie pliki *.c znajdujące się w aktualnym katalogu, to moglibyśmy zmienić deklarację zmiennej sources z użyciem funkcji wildcard

sources=$(wildcard *.c)

Zmienne automatyczne

Innym udogodnienie są zmienne automatyczne o specjalnych nazwach, które są dostępne w regułach, a dokłdnie w treści polecenia dotyczącego danej reguły. Oto przykładowe trzy:

$@
daje nazwę celu
$<
daje nazwę pierwszej zależność
$^
daje nazwy wszystkich zależności

Z użyciem tych zmiennych można trochę skróć nasz makefile:

sources=komunikat.c main.c test.c
objects=$(sources:.c=.o)
 
program: $(objects)
        gcc -lm $^ -o $@
 
komunikat.o: komunikat.c komunikat.h
        gcc -Wall -c $< -o $@
 
main.o: main.c test.h
        gcc -Wall -c $< -o $@
 
test.o: test.c test.h komunikat.h
        gcc -Wall -c $< -o $@
 
.PHONY: clean
 
clean:
        rm -f program $(objects)

Reguły schematy


Zauważmy, że po ostatnich zmianach polecenia do kompilacja programu .c w plik wynikowy .o wyglądają identycznie. Zatem może da się je jakoś raz zadeklarować. Otóż tak, z pomocą przychodzą reguły schematy.

Deklarowanie reguły schematu


Reguły schematy używane są do podowania sposobu kompilacji z jednego typu w drugi typ. W naszym przypadku mamy jeden sposób na tworzenie pliku wynikowego .o z pliku .c. Można w tym celu użyć reguły schematu, która jako cel i zależności ma wzorce.

%.o: %.c
        gcc -Wall -c $< -o $@

Zauważmy, że przy deklarowaniu takich reguł, zmienne automatyczne stają się niezbędne. Moglibyśmy teraz usunąć te trzy reguły, które służą nam do tworzenia pliku *.o i zastąpić je powyższą regułą. Jednak wtedy stracimy zależności. Otóż można zrobić tak. Zostawić same zależności bez poleceń, które są i tak zadane przez regułę schemat.

sources=komunikat.c main.c test.c
objects=$(sources:.c=.o)
 
program: $(objects)
        gcc -lm $^ -o $@
 
komunikat.o: komunikat.c komunikat.h
 
main.o: main.c test.h
 
test.o: test.c test.h komunikat.h
 
%.o: %.c
        gcc -Wall -c $< -o $@
 
.PHONY: clean
 
clean:
        rm -f program $(objects)

Użycie wbudowanych reguł schematów


Takie kompilowanie programów napisanych w C wydaje się być standardowe. Okazuje się, że dla takich czynności make ma już wbudowane reguły schematy i nie trzeba już ich deklarować. Usuńmy nasz schemat i zobatrzmy co zrobi make dla takiego pliku makefile:

sources=komunikat.c main.c test.c
objects=$(sources:.c=.o)
 
program: $(objects)
        gcc -lm $^ -o $@
 
komunikat.o: komunikat.c komunikat.h
 
main.o: main.c test.h
 
test.o: test.c test.h komunikat.h
 
.PHONY: clean
 
clean:
        rm -f program $(objects)

wykonujemy:

$ make clean
rm -f program komunikat.o main.o test.o
$ make
cc    -c -o komunikat.o komunikat.c
cc    -c -o main.o main.c
cc    -c -o test.o test.c
gcc -lm komunikat.o main.o test.o -o program

No nie do końca otrzymaliśmy to o co nam chodziło. Został użyty kompilator cc, a nie gcc. Pondto znikła opcja -Wall. Można jednak konfigurować wbudowaną regułę za pomocą zmiennych. W dokumentacji make możemy przeczytać, że wbudowana jest reguła

%.o: %.c
        $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<

Zmienna CC oznacza nazwę kompilatora programów napisanych w C, zmienna CFLAGS oznacza opcję tego kompilatora, a CPPFLAGS oznacza opcje preprocesora. Wystarczy nadać tym zmiennym odpowiednią wartość, aby uzyskać porządany efekt.

CC=gcc
CFLAGS=-Wall
 
sources=komunikat.c main.c test.c
objects=$(sources:.c=.o)
 
program: $(objects)
        gcc -lm $^ -o $@
 
komunikat.o: komunikat.c komunikat.h
 
main.o: main.c test.h
 
test.o: test.c test.h komunikat.h
 
.PHONY: clean
 
clean:
        rm -f program $(objects)