Wyślij zapytanie Dołącz do Sii

Głównym celem artykułu jest pokazanie, w jaki sposób można ręcznie zbudować aplikację napisaną w języku C, korzystając z narzędzia GNU Make. Po jego przeczytaniu nie tylko poznacie strukturę pliku makefile, ale przede wszystkim zrozumiecie lepiej jego działanie. To daje możliwość dostosowywania gotowych szablonów pod własne projekty, a także pisania ich całkowicie od zera.

Adresatami tego wpisu są głównie programiści systemów wbudowanych, którzy mają już pewne doświadczenie w tworzeniu oprogramowania w języku C/C++, jednak do tej pory korzystali z gotowych rozwiązań. Niemniej, artykuł bazuje na przykładzie prostego projektu, dla którego makefile tworzony jest krok po kroku – w związku z tym nawet początkujące osoby nie powinny czuć się zagubione. W takim wypadku, lepsze zrozumienie struktury pliku makefile pozwoli również lepiej zrozumieć działanie CMake’a, będącego czymś w rodzaju abstrakcji dla plików makefile.

Przygotowanie

Tematyka tego wpisu skupia się na praktycznym zagadnieniu, jakim jest budowanie programu napisanego w języku C. Zachęcam więc gorąco nie tylko do przeczytania, ale również uruchamiania we własnym zakresie omawianych przykładów. Będziecie potrzebowali jedynie kompilatora języka C i dowolnego edytora.

Ponieważ wykorzystywany będzie kompilator języka C, działający jako aplikacja powłoki Unix, docelowo będziemy potrzebować zainstalowanej dystrybucji systemu Linux. Jeśli jednak jesteście użytkownikami Windowsa, nie przejmujcie się – nie musicie zmieniać systemu lub instalować drugiego.

Jeśli macie dostęp do programów takich jak VMware lub VirtualBox, to możecie uruchomić wirtualną maszynę z zainstalowanym systemem Linux, ale są jeszcze prostsze rozwiązania np.:

  • Cygwin,
  • MinGW,
  • WSL (Windows Subsystem for Linux).

Dzięki tym programom możliwe jest dodanie do systemu Windows części funkcjonalności systemu Linux w tym biblioteki standardu POSIX. W omawianych przykładach będę korzystał z ostatniej opcji, ale bez względu na to, którą wybierzecie, również będziecie w stanie zrealizować te działania.

Ostatnią rzeczą, jakiej potrzebujemy, jest przykładowy program. W artykule skupiamy się na tworzeniu plików makefile, dlatego najlepiej, jeśli kod, którego użyjemy będzie maksymalnie prosty. Poniżej zamieszczam przykład, który możecie wykorzystać. Jest to bardzo prosty kalkulator, który posiada jedynie funkcję dodawanie i odejmowanie dwóch liczb.

Przykład prostego kalkulatora
Ryc. 1 Przykład prostego kalkulatora

W głównym pliku main.c  funkcje dodawania i odejmowania zostały użyte na przykładzie wartość na stałe zapisanych w kodzie. To wszystko czego potrzebujemy, aby przejść do kompilacji.

Tworzenie pliku makefile krok po kroku

Ręczna kompilacja programu

Ponieważ przedstawiony przykład jest bardzo prosty i zawiera tylko 3 pliki, aby go skompilować, w zasadzie nie potrzebujemy pisać pliku makefile. Możemy uruchomić kompilacje, ręcznie podając listę plików.

gcc main.c calculator.c -o calculator_app

Niestety, zazwyczaj programy, nawet te mało skomplikowane, zawierają o wiele więcej plików, a ich struktura jest znacznie bardziej złożona. Pliki źródłowe są odseparowane od plików nagłówkowych i dodatkowo pogrupowane w różnych katalogach. Chcąc w ten sam sposób uruchomić kompilację, komenda stałaby się bardzo długa i wymagająca. Na szczęście rozwiązaniem tego problemu jest plik makefile.

Definiowanie targetów

Podobnie jak w przypadku ręcznego uruchomienia kompilacji, plik makefile zawiera informacje, jakie pliki, w jaki sposób i do jakiego miejsca mają zostać zbudowane, z tym, że są one umieszczone w jednym pliku. Dzięki temu docelowa komenda uruchamiająca budowanie aplikacji będzie bardzo krótka.

W głównym katalogu projektu stwórzcie nowy plik o nazwie „Makefile” bez żadnego rozszerzenia, po prostu sama nazwa, może być napisana dużą lub małą literą. Plik ten musi zawierać przynajmniej jedną definicję tak zwanego „targetu”. Można powiedzieć, że jest to coś w rodzaju nazwy operacji, którą chcemy wykonać. Ponieważ proces kompilacji zwyczajowo nazywany jest „budowaniem”, dlatego najczęściej spotkasz się z nazwą „build”, ale tak naprawdę może ona być dowolna np. „calculator”.

calculator:
	gcc main.c calculator.c -o calculator_app

Uwaga: Pamiętaj, że każda kolejna nowa linia targetu powinna zaczynać się od jednego znaku tabulatora (nie spacji). Po zapisaniu pliku możemy uruchomić budowanie naszej aplikacji kalkulatora. Tym razem, zamiast wpisywać pełną komendę, wystarczy, że uruchomimy program make, wskazując mu nazwę docelowego targetu.

make calculator

Liczba „targetów” może być dowolna. Przykładowo, jeśli chcielibyśmy skasować wynik kompilacji, możemy stworzyć nowy target o nazwie „clean”, usuwający określone pliki. Podobnie możemy stworzyć target „run”, odpowiadający za uruchamiania zbudowanej aplikacji.

calculator:
    gcc main.c calculator.c -o calculator_app

clean:
    rm -rf calculator_app

run:
    ./calculator_app

Nie zawsze musimy podawać nazwę targetu – samo polecenie „make” również zadziała. Domyślnie zostanie wykonany pierwszy napotkany target – w naszym przypadku będzie to calculator – więc wynik działania będzie dokładnie taki sam. Dlatego, pisząc pliki makefile, warto jako pierwszy target ustawić ten, z którego najczęściej będziemy korzystać. Oszczędzi nam to sporo pisania.

Jak na razie przedstawiony plik makefile w obecnej formie jest mało przydatny dla „prawdziwej aplikacji”. Definiowanie targetów w postaci jednej długiej komendy zawierającej całą listę plików będzie zarówno nieczytelne jak i uciążliwe w utrzymaniu. Istnieją jednak odpowiednie mechanizmy, które pozwalają podzielić ten proces na etapy, a część z nich nawet zautomatyzować.

Makefile target w postaci pliku

Targetem może być nie tylko „pusta nazwa”, tak jak to zrobiliśmy do tej pory. Tak naprawdę makefile domyślnie oczekuje, że będzie to plik. Dzięki temu możemy rozdzielić proces kompilacji na etapy związane z kompilacją pojedynczych plików wchodzących w skład docelowej aplikacji.

Stwórzmy dodatkowe dwa targety, po jednym dla każdego pliku źródłowego. Tym razem nie będzie to tylko pusty alias, a nazwa pliku wynikowego. Plik main.c będziemy budować do pliku wynikowego main.o, a calculator.c do calculator.o. Pliki z rozszerzeniem .o, to tzw. „pliki obiektowe” (ang. object files). Bardzo skrótowo można przedstawić je jako pliki wynikowe procesu kompilacji, które jeszcze nie nadające się do uruchomienia.

W tym przypadku docelowy target budujący wynikową aplikację powinien odwoływać się do nich, zamiast do plików źródłowych, tak jak miało to miejsce wcześniej. Po wprowadzeniu tych modyfikacji makefile powinien wyglądać następująco:

calculator:
    gcc main.o calculator.o -o calculator_app

main.o:
    gcc main.c -c main.o

calculator.o:
    gcc calculator.c -c calculator.o

clean:
    rm -rf calculator_app

run:
    ./calculator_app

Niestety, gdy tym razem spróbujemy zbudować aplikację poleceniem „make calculator” naszym oczom ukaże się błąd mówiący o tym, że pliki main.o i calculator.o nie zostały zlokalizowane. Wynika to z tego, że target „calculator” odwołuje się do plików main.o i calculator.o, zanim zostały utworzone.  Muszą one zostać uruchomione wcześniej.

Zatem kolejność powinna być następująca:

make main.o
make calculator.o
make calculator

Makefile target z warunkami wstępnymi

Aby uniknąć wpisywania targetów w ściśle określonej kolejności, można użyć tzw. warunków wstępnych (ang. prerequisites). Dają one możliwość przekazania kompilatorowi informacji o kolejność budowania poszczególnych elementów. W tym przypadku target calculator może zostać wykonany, pod warunkiem, że wcześniej zostaną wykonane inne targety.

calculator: calculator.o main.o
    gcc main.o calculator.o -o calculator_app

Teraz możemy od razu uruchomić właściwe polecenie bez informacji zwrotnej o błędzie. Efektem budowania aplikacji w ten sposób będzie nie tylko utworzenie pliku calculator_app, ale również pliku z rozszerzeniem .o.

W takim razie, aby dobrze posprzątać po procesie budowania, należałoby rozszerzyć target „clean” o możliwość ich usuwania.

calculator: calculator.o main.o
    gcc main.o calculator.o -o calculator_app

main.o:
    gcc main.c -c main.o

calculator.o:
    gcc calculator.c -c calculator.o

clean:
    rm -rf calculator_app
    rm -rf *.o

Niestety w dalszym ciągu makefile w takie formie nie jest zbyt użyteczny. Nie będziemy przecież dla każdego pliku ręcznie tworzyć dedykowanego targetu, a później jeszcze ich łączyć, aby zbudować końcowy plik wykonywalny. Idealnie byłoby, gdyby makefile robił to automatycznie. 

Automatyczne tworzenie listy plików źródłowych

Zanim przejdziemy do tworzenia pierwszego szablonu, musimy poznać jego elementy składowe. Są to znaki spacjalne tj.

  • $,
  • @,
  • %,
  • ^ ,
  • <.

Używane w różnych kombinacjach w pliku makefile pomagają w tworzeniu różnego rodzaju szablonów. Poniżej przykładowe zastosowanie:

$@oznacza nazwę pliku będącego stanowiącego target
$<nazwa pierwszej napotkanej zależności
$^nazwy wszystkich zależności
%dowolny ciąg znaków

Dzięki tym symbolom można stworzyć szablon, który będzie odpowiadał za automatyczne tworzenie targetów dla każdego pliku źródłowego.

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

Make zinterpretuje ten fragment w następujący sposób: dla każdego pliku z rozszerzeniem .c znajdującego się w głównym katalogu projektu utworzył plik z rozszerzeniem .o według reguły określonej w drugiej linii. Ta reguła to: nazwa napotkanej zależności (czyli kolejnego napotkanego pliku .c), skompilowana do pliku o tej samej nazwie, ale z rozszerzeniem .o. Jest to dokładnie ten sam proces, który wykonywaliśmy wcześniej ręcznie.

Ponieważ został on zautomatyzowany, od teraz większa liczba plików w projekcie nie będzie już stanowiła problemu. 

calculator: calculator.o main.o
    gcc main.o calculator.o -o calculator_app

clean:
    rm -rf calculator_app
    rm -rf *.o

run:
    ./calculator_app

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

Zmienne

Podobnie jak w przypadku różnych języków programowania, makefile również pozwala na tworzenie zmiennych. Ich zalety są dokładnie takie same – możemy tworzyć czytelniejszą strukturę i szybciej wprowadzać modyfikacje, edytując tylko jedno miejsca zamiast wielu. Aby utworzyć taką zmienną, wystarczy dla dowolnego ciągu znaku użyć znaku równości, przypisując zawartość, przykładowo:

NAZWA_ZMIENNEJ = TREŚĆ 

Odwołanie do takiej zmiennej polega na użyciu jej nazwy w okrągłym nawiasie, poprzedzonym znakiem $, czyli przykładowo:

$(NAZWA_ZMIENNEJ)

W ramach ćwiczenia dodamy do aktualnego pliku trzy zmienne:

  • pierwsza – będzie definiowała nazwę pliku wynikowego,
  • druga – będzie przechowywała listę plików („object files”),
  • trzecia – będzie zawierała nazwę wykorzystywanego kompilatora.
APP_NAME = calculator_app
C_OBJ    = calculator.o main.o
CC       = gcc

Teraz target calculator będzie mógł się odwoływać do tych zmiennych w następujący sposób:

calculator: $(C_OBJ)
    $(CC) $(C_OBJ) -o $(APP_NAME)

Uwaga: zmienne mogą być tworzone w dowolnym miejscu, jednak nie wcześniej niż miejsce, w którym się do niej odwołujemy.

Zazwyczaj wszystkie zmienne zebrane są w jednym miejscu na początku pliku, co znacznie ułatwia ich odnalezienie i późniejszą modyfikację.

Za pomocą zmiennych możemy również tworzyć wyrażenia warunkowe, które umożliwią sterowanie procesem kompilacji. Jako przykład zdefiniujmy zmienną DEBUG, która w zależności od wartości logicznej (true lub false) będzie definiowała, z jakich flag kompilacji będziemy korzystać:

DEBUG = true
C_FLAGS = -w

ifeq ($(DEBUG), true)
C_FLAGS += -g0
else
C_FLAGS += -g3
endif

Zastosowana została kombinacja „+=”, ponieważ nie powoduje ona nadpisania poprzedniej zawartości zmiennej nową wartością, jak to ma miejsce przy samym znaku równości, tylko rozszerzenie zawartości o kolejną wartość.

Automatyzacja z wykorzystaniem dzikich kart

Ostatnią rzeczą, którą potrzebujemy zmienić w naszym pliku makefile, aby stał się w pełni użyteczny dla dużego projektu, jest lista plików wynikowych kompilacji. Jej zawartość zależy od plików źródłowych znajdujących się w projekcie. Spróbujmy i tę listę plików stworzyć w sposób zautomatyzowany, wykorzystując dziką kartę. Dzikie karty można porównać do wbudowanych funkcji. Ich użycie wygląda podobnie jak zmiennych:

$(wildcard pattern...) 

Ten ciąg znaków, w miejscu jego użycia, zostanie zastąpiony listą plików oddzielonych spacją, których nazwy spełniają podany wzorzec. Przykładowo:

C_SRC = $(wildcard *.c)

Ten ciąg znaków spowoduje, że zmienna C_SRC będzie zawierała listę plików o dowolnej nazwie z rozszerzeniem .c, czyli plików źródłowych. Czy tak jest, możemy sprawdzić, wyświetlając zawartość komendą „echo”:

calculator: $(C_OBJ)
    echo $(C_SRC)
    $(CC) $(C_FLAGS) $(C_OBJ) -o $(APP_NAME)

Aby przekonwertować tę listę na pliki z rozszerzeniem .o, możemy wykorzystać inną funkcję o nazwie patsubst:

$(patsubst pattern,replacement,text)

Jej działanie polega na przeszukiwaniu podanego tekstu w celu znalezienia nazw spełniających dany wzorzec i zamiany ich zgodnie z podaną regułą. A więc konwersję listy plików źródłowych na listę obiektów można przeprowadzić w następujący sposób:

C_OBJ = $(patsubst %.c, %.o, $(C_SRC))

Make przeszuka zmienną C_SRC pod kątem plików o dowolnej nazwie zawierającej rozszerzenie .c i zastąpi je tą samą nazwą z rozszerzeniem .o. Musimy jedynie pamiętać, aby najpierw stworzyć i uzupełnić zmienną C_SRC, a dopiero później C_OBJ. Końcowa wersja pliku makefile powinna wyglądać tak:

APP_NAME = calculator_app
CC = gcc
DEBUG = true
C_FLAGS = -w
C_SRC = $(wildcard *.c)
C_OBJ = $(patsubst %.c, %.o, $(C_SRC))

ifeq ($(DEBUG), true)
C_FLAGS += -g0
else
C_FLAGS += -g3
endif

calculator: $(C_OBJ)
    $(CC) $(C_FLAGS) $(C_OBJ) -o $(APP_NAME)

clean:
    rm -rf calculator_app
    rm -rf *.o

run:
    ./calculator_app

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


Podsumowanie

Mam nadzieję, że po tej lekturze rozumiecie już, do czego służą pliki makefile i jak z nich korzystać. Wiecie, jak używać zmiennych, definiować targety, czy odwoływać się do wbudowanych funkcji w celu automatyzacji zadań. Wytłumaczyłem także, jak sterować procesem kompilacji.

Wszystko to otwiera przed Wami zupełnie nowe możliwości. Nie musicie polegać wyłącznie na zintegrowanych środowiska deweloperskich, aby zbudować napisaną przez Was aplikację. Możecie teraz:

  • tworzyć własne, zautomatyzowane środowiska do budowania i testowania projektu w oparciu o narzędzie CI/CD,
  • tworzyć kod, który będzie mógł być budowany na różne platformy sprzętowe,
  • pracować w metodyce TDD,
  • korzystać z innych edytorów kodu.

Opisywane w tym artykule narządzie jest dużo bardziej złożone niż to, co zostało tutaj przedstawione. Niemniej, mam nadzieję, że przekazana wiedza stanowi solidną podstawę do dalszego zgłębiania tego tematu.

Źródła

5/5 ( głosy: 7)
Ocena:
5/5 ( głosy: 7)
Autor
Avatar
Karol Mika

Inżynier elektroniki i telekomunikacji z kilkuletnim doświadczeniem w pracy z systemami wbudowanymi. Realizował projekty w większości związane z IoT. W Sii od kwietnia 2023. Wolny czas spędza na podróżach motocyklem i grze w tenisa ziemnego

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?