I nastało nowe - CMake teoretycznie


2018-05-29, 00:00

Czytając moje posty poświęcone plikom Makefile można odnieść mylne wrażenie, że jest to najwspanialsze narzędzie do budowania projektu. Niestety, ale opierając system budowania projektu jedynie na plikach Makefile, strzelamy sobie w stopę. Wadą tego rozwiązania jest to, że sami musimy dbać o dosłownie każdy krok budowania, tracąc focus na tym, na czym powinniśmy skupić się przede wszystkim - tworzeniu programu docelowego. Na szczęście powstało narzędzie, które robi to, co programiści lubią najbardziej - robi (prawie) wszystko za nas :) Zapraszam serdecznie na wpis poświęcony programowi CMake.

Czym jest CMake?

CMake jest multiplatformowym narzędziem, które w znaczny sposób automatyzuje proces budowania i kompilacji projektu. Jego rolą jest tworzenie konfiguracji dla plików projektowych popularnych środowisk programistycznych, które następnie wykorzystywane są w procesie kompilacji. Do kompilowania przygotowanych przez CMake projektów używane są zazwyczaj natywne środowiska platformy, na której obecnie się znajdujemy.

Aby móc korzystać z dobrodziejstw które oferuje CMake, należy utworzyć wewnątrz projektu plik o nazwie CMakeLists.txt. Plik ten powinien znajdować się w głównym katalogu projektu, choć - nie jest to regułą. Minimalna (przykładowa) zawartość tego pliku wygląda następująco:

cmake_minimum_required(VERSION 3.10)
project(ProjectName)

add_executable(program main.cpp)

W pierwszej linijce definiujemy minimalną wersję CMake, jakiej używamy do budowania projektu (komenda cmake_minimum_required). Jeżeli ten warunek nie zostanie spełniony, CMake zgłosi to odpowiednim komunikatem. W drugiej linijce definiujemy nazwę projektu, a w trzeciej informujemy, że chcemy utworzyć plik wykonywalny o nazwie program na podstawie pliku źródłowego main.cpp. Nie wygląda to skomplikowanie.

Dodatkowo wspomnę, że wewnątrz komendy add_executable można zawrzeć całą listę plików źródłowych, z których składać się będzie plik wykonywalny. Dla każdego pliku źródłowego utworzony zostanie plik obiektowy, który będzie re-kompilowany tylko wtedy, kiedy kod wewnątrz źródła zostanie zmieniony. Jest to jedna z tych rzeczy, które CMake robi za nas. Poniżej przykład:

cmake_minimum_required(VERSION 3.10)
project(ProjectName)

add_executable(program main.cpp test.cpp math.cpp files.cpp)

Budowanie projektu

Budowanie projektu opartego o CMake jest bardzo przyjemne, jednakże w zależności od systemu operacyjnego może to wyglądać odrobinę inaczej. Ja działam na systemie MacOS, domyślnie użyty zostanie generator projektu Unix Makefiles. Aby zbudować projekt, w terminalu podaję serię instrukcji:

cd test-project
mkdir build
cd build
cmake ../
make

Teraz należy wyjaśnić, co dzieje się na listingu powyżej. Najpierw wchodzimy do katalogu głównego projektu, gdzie tworzymy katalog build, do którego następnie wchodzimy i wykonujemy polecenie cmake ../. Instrukcja ta tworzy pliki projektowe wewnątrz katalogu w którym obecnie się znajdujemy (build) na podstawie źródeł, które znajdują się w katalogu jeden poziom wyżej. Nie podałem żadnego generatora projektu, zatem zostaje użyty domyślny - ten generujący pliki Makefile. Ostatnim krokiem jest budowanie właściwe projektu i to w tym miejscu dochodzi do kompilacji źródeł. Wewnątrz katalogu w którym się obecnie znajdujemy zostaje wygenerowany plik wykonywalny program, gotowy do uruchomienia.

Definiowanie zmiennych

Skoro omówiony został sposób zbudowania projektu, możemy z powrotem wrócić do pliku CMakeLists.txt. Podobnie jak w plikach Makefile, CMake zezwala nam na używanie zmiennych. Odbywa się to poprzez użycie komendy set, która jako pierwszy parametr przyjmuje nazwę zmiennej, a następnie jej wartość. Przykładowy kod z utworzoną zmienną wygląda następująco:

cmake_minimum_required(VERSION 3.10)
project(testProject)

set(SOURCES main.cpp)
add_executable(program ${SOURCES})

Jak można zauważyć, odbywa się to bardzo podobnie jak w przypadku plików Makefile. Ciekawym zachowaniem jest to, że każdy kolejny parametr tej komendy traktowany jest jako dalszy ciąg wartości definiowanej zmiennej:

cmake_minimum_required(VERSION 3.10)
project(testProject)

set(SOURCES main.cpp test.cpp math.cpp files.cpp)
add_executable(program ${SOURCES})

Na wyżej zaprezentowanym przykładzie tworzymy zmienną SOURCES o wartości main.cpp test.cpp math.cpp files.cpp.

Programy wieloplikowe

Program CMake zawiera zestaw wielu bardzo rozbudowanych komend. Jedną z tych bardziej wartościowych na pewno jest komenda file. Na pierwszym miejscu wewnątrz tej komendy zawsze definiujemy operację, jaka ma zostać wykonana. Możemy zapisać wartość do pliku, zmienić nazwę pliku, dopisać coś do końca pliku i wiele innych. Dokładną listę znaleźć można pod linkiem. W mojej opinii najważniejszymi operacjami wewnątrz tej komendy są GLOB oraz GLOB_RECURSE. Operacja GLOB jest odpowiednikiem funkcji wildcard w plikach Makefile. GLOB_RECURSE, jak pewnie się domyślacie - jest tym, czego brakowało plikom Makefile - ulepszeniem funkcji wildcard, listującym również pliki wewnątrz podkatalogów. Przykładowe wywołania:

cmake_minimum_required(VERSION 3.10)
project(testProject)

file(GLOB CLASSES classes/*.cpp)
set(SOURCES main.cpp ${CLASSES})
add_executable(program ${SOURCES})

file(GLOB_RECURSE ALL_CLASSES classes/*.cpp)
set(ALL_SOURCES main.cpp ${ALL_CLASSES})
add_executable(allProgram ${ALL_SOURCES})

Po uruchomieniu polecenia make na wyjściu ukazało mi się:

Scanning dependencies of target allProgram
[ 14%] Building CXX object CMakeFiles/allProgram.dir/main.cpp.o
[ 28%] Building CXX object CMakeFiles/allProgram.dir/classes/main/aa.cpp.o
[ 42%] Building CXX object CMakeFiles/allProgram.dir/classes/math.cpp.o
[ 57%] Linking CXX executable allProgram
[ 57%] Built target allProgram
Scanning dependencies of target program
[ 71%] Building CXX object CMakeFiles/program.dir/main.cpp.o
[ 85%] Building CXX object CMakeFiles/program.dir/classes/math.cpp.o
[100%] Linking CXX executable program
[100%] Built target program

Jak widać, utworzone zostały dwa pliki wykonywalne, przy czym ten pierwszy składa się z większej ilości plików, niż ten drugi. Jest OK.

Uwaga.

GLOB (jak i również GLOB_RECURSE) nie są rozwiązaniami idealnymi, należy używać ich jedynie znając wady, które za sobą niosą. Niebawem przygotuję wpis z kompletnym przykładem, kiedy nie należy ich używać.

Tworzenie i linkowanie bibliotek

Kolejnym ważnym zagadnieniem w aspekcie budowania projektu jest tworzenie bibliotek i linkowanie ich do projektu. Pierwszym z brzegu przykładem wykorzystania bibliotek, który przychodzi mi na myśl, jest kompilacja kodu używanego w dwóch miejscach - testach oraz pliku produkcyjnym. Każdy, kto kompilował duże projekty wie, że jeżeli można coś skompilować tylko jeden raz i użyć wielokrotnie, to warto z tego skorzystać. Kompilacja jest bowiem procesem czasochłonnym.

Tworzenie biblioteki odbywa się w sposób bardzo zbliżony do tworzenia pliku wykonywalnego. Do tego celu użyć należy komendy add_library. Jako pierwszy parametr podajemy nazwę pliku tworzonej biblioteki. Drugi parametr definiuje rodzaj biblioteki, a trzeci to lista plików źródłowych, z których zostanie ta biblioteka utworzona. Załóżmy, że chcemy utworzyć bibliotekę statyczną. Wygląda to mniej więcej tak:

cmake_minimum_required(VERSION 3.10)
project(testProject)

set(LIB_SOURCES classes/math.cpp)
add_library(testLibrary STATIC ${LIB_SOURCES})

set(SOURCES main.cpp)
add_executable(program ${SOURCES})

Po przekompilowaniu (polecenie make) ukaże się:

Scanning dependencies of target program
[ 25%] Building CXX object CMakeFiles/program.dir/main.cpp.o
[ 50%] Linking CXX executable program
[ 50%] Built target program
Scanning dependencies of target testLibrary
[ 75%] Building CXX object CMakeFiles/testLibrary.dir/classes/math.cpp.o
[100%] Linking CXX static library libtestLibrary.a
[100%] Built target testLibrary

Utworzyliśmy bibliotekę, super. Co zrobić, aby została ona załączona do pliku wykonywalnego? Należy do tego celu użyć komendy target_link_libraries. Jako pierwszy parametr podajemy plik targetu, do którego biblioteka ma zostać załączona (nie musi to być plik wykonywalny, może to być przykładowo inna biblioteka). Jako kolejne parametry podajemy listę ścieżek dołączanych bibliotek. Poniżej znajduje się kompletny przykład:

cmake_minimum_required(VERSION 3.10)
project(testProject)

set(LIB_SOURCES classes/math.cpp)
add_library(testLibrary STATIC ${LIB_SOURCES})

set(SOURCES main.cpp)
add_executable(program ${SOURCES})
target_link_libraries(program testLibrary)

Uwaga! Komenda target_link_libraries nie może zostać użyta wcześniej, niż zadeklarowane zostanie utworzenie pliku targetu. W tym przypadku target_link_libraries musi zostać wywołane za add_executable.

Wskazywanie ścieżek do nagłówków

Czasami zdarza się sytuacja, kiedy potrzebujemy użyć cudzego kodu. Załóżmy, że jest to kod skompilowany, dołączyliśmy wymagane biblioteki. Aby używać tych bibliotek, należy umożliwić kompilatorowi odnalezienie ich plików nagłówkowych. Do tego celu posłuży nam komenda include_directories, która jako parametry przyjmuje ścieżki do katalogów zawierających wspomniane pliki nagłówkowe. Jeżeli na pierwszym miejscu zamieścimy dodatkowe flagi (BEFORE | AFTER | SYSTEM), będziemy mieli kontrolę nad kolejnością ładowania nagłówków. Oto przykładowe użycie tej komendy:

cmake_minimum_required(VERSION 3.10)
project(testProject)

set(SOURCES main.cpp)
add_executable(program ${SOURCES})
target_include_directories(program PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include/my-library)

Na uwagę zasługuje linijka ostatnia, a dokładniej słowo kluczowe PRIVATE. Jest to zasięg, z którym załączone zostają pliki nagłówkowe. Jeżeli jest to zasięg wyłącznie wewnątrz targetu, do którego je załączamy - zostawiamy PRIVATE. Jeżeli nagłówki te mają być propagowane wyżej (do innego targetu wykorzystującego ten bieżący), ustawiamy zasięg na PUBLIC. Istnieje jeszcze komenda include_directories(). Upraszczając, działa ona w bardzo podobny sposób jak target_include_directories() z tą różnicą, że pliki nagłówkowe zostają załączone do wszystkich targetów definiowanych na poziomie aktualnego pliku CMakeLists.txt

Rekursywność CMakeLists.txt

Super cechą CMake jest jego rekursywność. Oznacza to, że projekt możemy dzielić na mniejsze, dzięki czemu całość staje się bardziej zorganizowana. Umożliwia to również budowanie oraz kompilowanie jedynie części projektu, jeżeli tego potrzebujemy. Do tego celu wykorzystuje się komendę add_subdirectory. Jako parametr podać należy ścieżkę do katalogu, który powinien zawierać plik CMakeLists.txt oraz pliki źródłowe. Dodatkowym parametrem jest opcja EXCLUDE_FROM_ALL umożliwiająca wyłączenie sub-katalogu z targetu all (target główny). Ten temat zostawiam jedynie jako ciekawostkę, ponieważ zasługuje on na osobny wpis.

Podsumowanie

Poznaliśmy nowszą alternatywę dla plików Makefile - program CMake. Nauczyliśmy się zasad tworzenia plików CMakeLists.txt i zapoznaliśmy się z kilkoma popularnymi komendami najczęściej używanymi wewnątrz projektów. Dodatkowo dowiedzieliśmy się również, w jaki sposób można zbudować projekt używając terminala.



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.