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


2018-05-09, 00:00

Przez długi czas moja nauka C++ głównie polegała na nauce składni. Pisałem strukturalne programy w jednym pliku (nieraz po 500 linijek w pliku) i klikałem przycisk “Compile & Run”. Działało. Nie zadawałem sobie pytania, dlaczego. Kiedy przeszedłem na Linuxa, również działało. Jedna komenda w terminalu i program skompilowany. Problem zaczął się, kiedy zacząłem programować obiektowo.

Kompilacja kodu obiektowego

Programując obiektowo, dobrym nawykiem jest wydzielanie większych klas do osobnych plików, tak aby utworzyć nowe jednostki kompilacji. Kiedy korzysta się z IDE robiącego wszystko za nas, klikamy jeden przycisk i voila! Program skompilowany. Jako młody chłopak miałem przygodę, kiedy miałem stworzyć program na Linuxie, bez IDE (komputer nie wyrabiał przy odpalaniu Netbeansa, Eclipsa i im podobnych). Chciałem pisać ładnie, obiektowo - ale - no właśnie. Jak to skompilować? Dotarłem w końcu do poleceń kompilacyjnych - przechowywałem je w notatniku. Z każdym nowym plikiem *.cpp musiałem tą długą komendę modyfikować, wklejać do terminala. Słyszałem coś o programach które mogłyby mi pomóc, ale - bałem się do nich podejść. Sądziłem, że niewiele z tego zrozumiem i stracę czas. Miałem wtedy około 15 lat.

Kiedy programowanie gryzie…

Czasami podczas nauki miałem do czynienia z miejscami, które wydawały mi się zbyt trudne, żebym mógł to zrozumieć. Tak było z Makefile’ami. Dzisiaj wiem, że było trochę inaczej. Nie miał mi kto tego wytłumaczyć, a ja nie pytałem. Makefile pojawiał się w wielu miejscach - podczas instalacji systemu operacyjnego, instalacji oprogramowania na Linuxie, tworzeniu własnych programów. W tym trzecim przypadku dawało radę to pominąć, i to był mój błąd. Dzisiaj ten błąd postaram się naprawić, systematyzując wiedzę, którą udało mi się zdobyć jakiś czas temu.

Czym jest Make?

Prawdę mówiąc, po kilkumiesięcznej zabawie z automatyzacją i skryptami shellowymi zrozumiałem, że program make (oraz w domyśle pliki Makefile) nie jest żadną częścią języka C++. Bez niego też można poradzić sobie z kompilacją programu, choć - w wielu przypadkach - może to być nader skomplikowane. Program make jest częścią projektu GNU. Został stworzony na potrzeby automatyzacji procesu kompilacji oraz budowania projektu. Jest on interpreterem plików nazywanych Makefile’ami, w których możemy tworzyć własne reguły kompilacji w taki sposób, aby można było za pomocą dwóch-trzech komend zamienić kod źródłowy w pliki wykonywalne, biblioteki itp.

Spójrzmy na przykładowy plik Makefile:

# Makefile

main:
    g++ main.cpp -o main

Mamy tutaj najprostszą postać Makefile: regułę main, oraz polecenia w niej zawarte. Pozwala to na uruchomienie komendy g++ main.cpp -o main za pomocą polecenia make. Jedyny warunek, który musi być spełniony, to ten, że musimy znajdować się w tym samym katalogu co nasz plik Makefile. Wewnątrz pliku Makefile możemy zawrzeć więcej, niż jedną regułę. Przykład:

# Makefile

main:
    g++ main.cpp -o main

clean:
    rm -f main

dump:
    g++ main.cpp -S

Skoro można utworzyć kilka niezwiązanych ze sobą reguł, to znaczy, że powinno być można odwołać się do każdej z nich. I tak właśnie jest. Zrobimy to, wywołując komendę make a po niej podając nazwę reguły, która nas interesuje. Zatem, jeżeli chcemy zobaczyć nasz kod w postaci assemblera, wpisujemy make dump, a aby posprzątać, wywołujemy make clean. Należy pamiętać, że jeżeli nie podamy nazwy reguły która nas interesuje, domyśnie uruchomią się polecenia z pierwszej reguły w pliku. W naszym wypadku jest to reguła main.

Zależności pomiędzy regułami

Bardzo ważną zaletą, której nie można pominąć, jest możliwość ustalania zależności pomiędzy regułami. W skrócie: jedna regułą może zależeć od drugiej. Lepiej - jedna reguła może zależeć od wielu reguł. Daje to spore możliwości - dzięki temu możemy na przykład budować skomplikowane procesy, takie jak np. wyczyść-skompiluj-przetestuj-zainstaluj. Możemy przygotować cztery reguły, które później zgrupujemy w jedną. Poniżej prosty przykład, z jedną zależnością:

# Makefile

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

clean:
    rm -f main

W powyższym przykładzie możemy zauważyć, że wykonując polecenie make najpierw zostaną uruchomione się polecenia spod reguły clean, a następnie zostaną wykonane się polecenia zawarte w regule main. Ten sam efekt uzyskamy, wpisując w terminalu komendę make main. Jeżeli chcemy, aby make main odpalało dodatkowo instrukcje spod więcej niż jedną reguły, to robi się to tak:

# Makefile

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

clean:
    rm -f main

debug:
    echo "Compiling project"

Proste, prawda? Możemy zatem przejść do kolejnej właściwości Makefile.

Parametryzowanie reguł

Ważnym aspektem tworzenia skryptów jest możliwość parametryzowania ich. Użytkownik może chcieć zainstalować sobie nasz program do innego katalogu, może chcieć zainstalować dodatkowe rozszerzenia (jak to robi się w przypadku kompilacji interpretera PHP) bądź zastąpić domyślny kompilator czymś lepszym. Do tego przydają się parametry, które mogą zostać zdefiniowane przez użytkownika. Poniżej przedstawię dwa sposoby, w jakie można przesłać parametry do programu make.

Parametry w linii poleceń

Najprostszym, oraz w zasadzie najbardziej intuicyjnym sposobem na przesłanie parametrów jest załączenie ich w postaci klucz=wartość w terminalu, tak jak na przykładzie poniżej:

#!/bin/bash
make install CONFIG_DIR=/etc/myprogram

Odebrać ten parametr możemy w następujący sposób:

# Makefile

main:
    echo ${CONFIG_DIR}

Jeżeli przez przypadek podamy jeden parametr dwukrotnie, to program stwierdzi, że druga wartość jest bardziej ważna niż ta pierwsza.

Konfiguracja przez zmienne środowiskowe

Drugim sposobem na przekazanie parametrów do programu make jest eksportowanie zmiennych środowiskowych w systemie. Równoważnym stwierdzeniem będzie, że program make potrafi odczytywać wartości zmiennych środowiskowych. Kontynuując powyższy przykład, jeżeli chcemy przesłać zmienną środowiskową do systemu, wystarczy użyć przykładowego polecenia:

#!/bin/bash
export CONFIG_DIR="/etc/myprogram"

Programu z powyższego przykładu nie musimy zmieniać. Jeżeli nic nie stanie na przeszkodzie, to wartość zmiennej CONFIG_DIR powinna zostać wczytana właśnie ze zmiennej środowiskowej.

Domyślne wartości parametrów

Jako programiści musimy zawsze dbać o to, aby program działał zawsze dobrze. Skoro dajemy użytkownikowi możliwość skonfigurowania sobie skryptu instalacyjnego “po swojemu”, to musimy zadbać o to, co się stanie w sytuacji kiedy nie zostaną podane żadne wartości parametrów w momencie uruchomienia programu. Domyślną wartość parametru ustawiamy używając operatora ?=:

# Makefile

CONFIG_DIR?="/etc/myprogram"

configure:
    echo "${CONFIG_DIR}"

Operator ?= różni się od operatora = tym, że ustawia wartość jeżeli nie została wcześniej ustawiona, bądź została ustawiona wartość pusta. Zatem, jeżeli nie chcemy pozwolić na zewnątrz sterować zmienną, ale chcemy aby była ona dostępna w programie - używamy operatora =.

Kontrola wyjścia

Jest jeszcze jeden temat, który chciałbym poruszyć, ponieważ drażniło mnie to od samego początku i musiałem sobie z tym jakoś poradzić. Potraktujcie to jako pro-tip ;) Mianowicie, przedstawmy sobie kod:

# Makefile

compile:
    echo "hello"

Kiedy odpalimy sobie ten program, ku naszym oczom ukaże się:

#!/bin/bash
echo "hello"
hello

Potworne. Straszne. Kto lubi perfekcję, ten wie o czym mówię. Po co do wyjścia programu jest wrzucana linijka informująca o tym, że używam echo? Chyba po to robię to echo, aby móc poinformować użytkownika, że coś się stało - a resztę wykonania programu ukryć. Niestety, problem dotyczy nie tylko instrukcji echo - każda instrukcja, przez którą przechodzi program make, jest w taki sposób traktowana. Zachowanie to nie podobało mi się, więc znalazłem na to rozwiązanie:

# Makefile

compile:
    echo "hello"

.SILENT:

Okazuje się, że jeżeli chcemy to zachowanie zmienić, trzeba do pliku Makefile wrzucić regułę specjalną .SILENT:. Tyle. Od tej pory na wyjście programu zostanie przesłane tylko to, co sami wystawimy.

Ktoś pomyśli teraz: “Ok, ale ja chcę mieć większą kontrolę - przecież chcę wyświetlać ważniejsze instrukcje, aby użytkownik wiedział co się dzieje”. Do pomocy przychodzi operator @. Jeżeli postawimy go przed instrukcją, zostanie ona wytłumiona. Na przykład:

# Makefile

main:
    @echo "Compiling..."
    g++ main.cpp -o main

Powyżej wytłumiliśmy komendę echo (wyświetli się tylko tekst “Compiling…”), ale instrukcja kompilująca już zostanie automatycznie wyświetlona na ekranie. Daje nam to większą kontrolę nad tym, co leci na wyjście programu, ale z drugiej strony - zaciemnia kod. Moim zdaniem nie ma tutaj ani lepszego, ani gorszego rozwiązania. Najważniejsze, że są możliwości.

Przykład podsumowujący wpis:

# Przykład ignorowania wartości z zewnątrz
P="$(PWD)"

# Przykład ustawiania wartości domyślnej, jeżeli nieustawiono
COMPILE_FILENAME?="main"

# Domyślna reguła
info:
    echo "> Current directory: $P"
    echo "> Compile file: ${COMPILE_FILENAME}"

# Przykład zależności od kilku reguł
production: info clean
    echo "> Compiling production code for ${COMPILE_FILENAME}..."
    g++ ${COMPILE_FILENAME}.cpp -o ${COMPILE_FILENAME}
    echo "  Compiled"

# Przykład zależności od kilku reguł
dump:   info clean
    echo "> Dumping objects and assembly"
    g++ ${COMPILE_FILENAME}.cpp -S
    g++ ${COMPILE_FILENAME}.cpp -c -o ${COMPILE_FILENAME}.o
    # Przykład wytłumienia pojedynczej instrukcji
    @touch dump
    echo "  Dumped"

# Przykład wyciszenia konkretnych instrukcji
clean:
    echo "> Cleaning previous compilation"
    @if [[ -e "$P/dump" ]]; then \
        echo "  Cleaning dumped objects"; \
        rm -f ${COMPILE_FILENAME}.s ${COMPILE_FILENAME}.o dump; \
    fi
    @if [[ -e "$P/$COMPILE_FILENAME" ]]; then \
        echo "  Cleaning executables"; \
        rm -f ${COMPILE_FILENAME}; \
    fi
    echo "  Cleaned"

# Wyciszenie domyślnego wyjścia
.SILENT:

Kto używa Makefiles?

Pozwoliłem sobie poszperać pośród bardziej znanych projektów. Okazuje się, że część z nich opiera swój system budowania właśnie o Makefiles:

Próbowałem sklasyfikować powyższą listę i jedyne co mogę o nich wspólnie powiedzieć, to to że są to projekty dojrzałe i rozległe. Można by zatem śmiało rzec, że program make jest rozwiązaniem dającym poczucie stabilności w dziedzinie, za którą jest odpowiedzialny. Dodatkowo, pliki Makefile są stosunkowo proste w zapisie oraz analizie (właściwie wszystko co znajduje się wewnątrz Makefile opiera się o skrypty shellowe).

Podsumowanie

Nauczyliśmy się, czym jest program make oraz w jaki sposób należy czytać zawartość plików Makefile. Zaznajomiliśmy się również z regułami definiowanymi wewnątrz Makefile oraz zależnościami, jakie między nimi mogą wystąpić. Dodatkowo dowiedzieliśmy się, jak przesłać do programu make parametry dodatkowe (za pomocą zmiennych środowiskowych oraz parametrów wywołania komendy). Mam nadzieję, że rozjaśniłem nieco na pozór skomplikowany i trudny do zrozumienia temat.



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.

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