CMake build sistem

Da li ste se ikada zapitali kako gigant poput Netflix-a razvija svoj softver? Servise ove kompanije koristi preko 100 miliona ljudi u svakom trenutku, širom sveta. Možete zamisliti taj zastrašujuć broj datoteka koje čine jednu njihovu aplikaciju, kao i ogroman broj biblioteka i web servisa na koje se ona poziva… Kako im uspeva da povežu ovaj, naizgled rasuti, sistem u jednu upotrebljivu celinu? Odgovor na ovo pitanje leži u temi ovog članka, a to je CMake build sistem. Listu velikih kompanija koje koriste CMake, kao i njihove utiske o samom alatu, možete pogledati ovde. Na listi se može uočiti dobar broj velikih imena, koja dolaze iz različitih polja primene softvera. Iz svega ovoga može se zaključiti da je CMake prepoznat i proveren alat, koji vlada svojim tržištem već skoro dve decenije.

Uvod u build sisteme

Build sistemi su softverski alati dizajnirani da automatizuju proces kompajliranja programa. Najopštije rečeno, posao build sistema jeste da mapira set izvornih fajlova u jedan ili više ciljnih binarnih fajlova – build targets.

CMake je open-source build sistem napravljen sa namerom da se upravlja procesom build-ovanja projekata na jednostavan i elegantan način, potpuno nezavisno od kompajlera koji je u upotrebi (compiler-independency). CMake je multiplatformski alat koji radi na svim Unix i Windows operativnim sistemima. Podržani su sledeći programski jezici: C, C++, C#, CUDA, Fortran, Java i Swift.

Ono što izdvaja CMake od ostalih sličnih alata jeste to što je on dizajniran da radi u zajednici sa domaćim build okruženjem – native build environment. CMake na osnovu jednostavnih konfiguracionih fajlova (CMakeLists.txt) postavljenih u direktorijumumima sa izvornim kodom generiše standardne konfiguracione build fajlove, koje domaće build okruženje zatim koristi kako bi obavilo build projekta i izgenerisalo potrebne binarne fajlove. Za svako okruženje se generišu odgovarajući build fajlovi koje to okruženje može da razume.

Na Unix sistemima se generišu Makefile skripte. Pomoću Makefile skripte moguće je definisati konkretan set instrukcija koji želimo izvršiti nad našim projektom prilikom svakog build-a. Zamislimo da imamo neki projekat i želimo da se uz svaki build tog projekta izgeneriše određeni broj fajlova sa nekim sadržajem, da se oni smeste u određene direktorijume, a da se pritom neki drugi fajlovi ili direktorijumi obrišu… Ovaj proces je sa rastom kompleksnosti projekta sve teže i mučnije uraditi ručno. Makefile je jedan od načina da se ovaj proces automatizuje. Mana Makefile-a jeste ta što takve skripte i same postaju previše kompleksne, posebno ako se u projektu javlja veća hijerarhija direktorijuma i podprojekata. Tada CMake dolazi na scenu – CMake automatski generiše Makefile za projekat bilo koje kompleksnosti.

Na Windows sistemima sa instaliranim Visual Studio-m, CMake zapravo generiše sve potrebne projektne i workspace fajlove. Tako da se projekat koji nije razvijan u Visual Studio-u može otvoriti u istom, i uz pomoć ugrađenih funkcionalnosti ovog okruženja obaviti potreban build.

Instalacija

Na Linux sistemima dovoljno je izvršiti sledeću komandu u terminalu:

$ sudo apt install cmake

Na MacOS sistemima dovoljno je izvršiti sledeću komandu u terminalu:

$ sudo port install cmake

Za Windows sisteme se sa zvanične stranice može preuzeti MSI installer za odgovarajuću arhitekturu koji ujedno instalira i jednostavno grafičko okruženje za ovaj alat: https://cmake.org/download/.

Važna napomena: U nastavku ovog članka svi primeri biće izvršavani na Ubuntu 18.04 operativnom sistemu. Kako je CMake cross-platform alat, nakon njegove uspešne instalacije, svi primeri moći će na identičan način da se izvršavaju i na Windows, odnosno MacOS operativnim sistemima.

Izvorni kod svih primera

Kompletan kod za sve primere koji se pojavljuju u ovom članku dostupan je za preuzimanje i pregled na sledećem GitHub repozitorijumu. Na Linux operativnim sistemima, primeri se mogu preuzeti i pokretanjem sledećih komandi iz terminala (kloniranje repozitorijuma):

$ sudo apt install git
$ git clone https://github.com/Sthrone/CMake.git

Jednostavan primer

Sada ćemo na primeru pokazati kako se CMake može primeniti za build jednostavnog C++ programa. Napravimo fajl Pozdrav.cpp sa sledećim sadržajem:

#include <iostream>

int main(int argc, char **argv)
{
    std::cout << "Pozdrav sa IMI-ja!" << std::endl;
    return 0;
}

Za pokretanje CMake build-a potreban nam je još samo jedan fajl (build definicija) CMakeLists.txt u istom direktorijumu sa sledećim sadržajem:

cmake_minimum_required(VERSION 3.10.2)
project(Pozdrav)
add_executable(imi_pozdrav Pozdrav.cpp)

Ovaj fajl sestoji se od samo tri linije… Prva linija sadrži komandu cmake_minimum_required(), koja postavlja minimalnu verziju CMake-a potrebnu za build ovog projekta. U našem slučaju to je major verzija 3, minor verzija 10 i patch verzija 2. Ova komanda omogućava bolje održavanje i podršku za vaše build okruženje i stoga uvek treba koristiti verziju CMake-a koja je trenutno instalirana na vašem sistemu. Trenutna verzija CMake-a može se odrediti upotrebom sledeće komande iz terminala:

$ cmake -version

cmake version 3.10.2

CMake suite maintained and supported by Kitware (kitware.com/cmake).

Druga linija sadrži project() komandu, kojom se setuje naziv build projekta.

Treća linija sadrži komandu add_executable(), koja je najvažnija i obavlja posao koji je nama potreban, a to je kompajliranje izvornog koda u izvršni (executable) program. Prvi argument predstavlja naziv exe fajla, dok je drugi argument naziv fajla sa izvornim kodom. Važno je napomenuti da se argumenti razdvajaju blanko karakterom.

Sada je sve spremno da se pokrene CMake build. To se obavlja izvršavanjem komande cmake u terminalu, kojoj se kao argument prosleđuje direktorijum koji sadrži izvorni kod i CMakeLists.txt fajl – u našem slučaju argument „.“ označava da prosleđujemo trenutni direktorijum.

$ cmake .

-- The C compiler identification is GNU 7.3.0
-- The CXX compiler identification is GNU 7.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/Desktop/CMake/Primer01

CMake je prepoznao podešavanja okruženja za Linux mašinu i zato je izgenerisao Makefile skriptu u datom direktorijumu, kao i dodatne konfiguracione fajlove. Makefile skriptu je moguće pogledati, ali se ne preporučuje ručna izmena njenog sadržaja, pre svega zato što će ponovnim pokretanjem komande cmake sve izmene biti prepisane. Sada kada imamo Makefile, dovoljno je samo da izvršimo make komandu iz direktorijuma koji sadrži Makefile, i time će build našeg projekta biti kompletan.

$ make

Scanning dependencies of target imi_pozdrav
[ 50%] Building CXX object CMakeFiles/imi_pozdrav.dir/Pozdrav.cpp.o
[100%] Linking CXX executable imi_pozdrav
[100%] Built target imi_pozdrav

Rezultat jeste željeni exe fajl imi_pozdrav, koji možemo testirati:

$ ./imi_pozdrav 

Pozdrav sa IMI-ja!

Program radi! Ovaj jednostavan primer savršeno demonstrira osnovne funkcionalnosti CMake-a. Sada se možemo pozabaviti nekim malo kompleksnijim slučajevima upotrebe ovog moćnog alata.

Kompletan projekat za ovaj primer može se preuzeti ovde.

Zavisnosti

Kako projekat raste, sasvim je prirodno da se fajlovi organizuju po zasebnim direktorijumima. Svaki direktorijum može sadržati poseban tip fajlova, svaki sa posebnom ulogom, a mi želimo da se svi oni ispravno ujedine i povežu u konačnom build-u projekta.

U slučajevima kada je prisutna hijerarhija direktorijuma u projektu, Makefile počinje da pokazuje svoje nedostatke – skripte postaju komplikovane i teške za praćenje. Česta je praksa da svaki poddirektorijum ima sopstveni Makefile, koji će biti pozvan od strane Makefile-a naddirektorijuma… Ali ni to nije najelegantnije rešenje za ovaj problem. CMake pruža brz i jednostavan način za suočavanje sa ovim problemom, što ćemo i pokazati na sledećem primeru.

Kako bismo lako proučili strukturu direktorijuma projekta koristićemo tree pomoćni program, koji nam u terminalu ispisuje hijerarhiju direktorijuma, počevši od trenutno aktivnog direktorijuma. Na Linux mašinama se ovaj program može instalirati pokretanjem sledeće komande iz terminala:

$ sudo apt install tree

Pokrenućemo komandu tree iz root-a našeg projekta kako bismo videli njegovu strukturu.

$ tree

.
├── build
├── CMakeLists.txt
├── include
│   └── Student.h
└── src
    ├── main.cpp
    └── Student.cpp

3 directories, 4 files

Možemo uočiti da se u ovom C++ projektu header fajlovi (.h) nalaze u include direktorijumu, a izvorni fajlovi (.cpp) u src direktorijumu. Postoji i build direktorijum, koji je trenutno prazan, u kojem će se nalaziti konačni binarni exe fajl i ostali fajlovi potrebni za build.

Cilj ovog projekta jeste da se napravi jedan objekat klase Student, koji će pozivom svoje metode moći da ispiše ime koje mu je zadato prilikom kreiranja.

U header fajlu Student.h imamo opis klase Student. Postoji jedan atribut koji predstavlja ime studenta, jedan konstruktor kojim se postavlja to ime, kao i funkcija Prikazi() kojom se pomenuto ime ispisuje na standardni izlaz:

#include <string>

using namespace std;

class Student
{
private:
    string ime;

public:
    Student(string);
    void Prikazi();
};

U fajlu Student.cpp imamo implementaciju metoda klase Student:

#include <iostream>
#include "Student.h"

using namespace std;

Student::Student(string ime)
{
    this->ime = ime;
}

void Student::Prikazi()
{
    cout << "Ime studenta: " <ime << endl;
}

Konačno, u fajlu main.cpp imamo željeno kreiranje objekta klase Student i poziv njegovog metoda:

#include "Student.h"

int main(int argc, char **argv)
{
   Student s("Stefan");
   s.Prikazi();
   
   return 0;
}

Sadržaj naše build definicije u fajlu CMakeLists.txt je samo malo proširen u odnosu na prethodni primer:

cmake_minimum_required(VERSION 3.10.2)
project(Student_Primer)

include_directories(include)

#set(SOURCES src/main.cpp src/Student.cpp)
file(GLOB SOURCES "src/*.cpp")

add_executable(testStudent ${SOURCES})

Funkcijom include_directories() omogućava se uključivanje header fajlova u build okruženje.

Pomoću set(SOURCES … ) funkcije može se vršiti inicijalizacija promenljive SOURCES koja treba da sadrži imena svih izvornih fajlova (.cpp) u projektu. Mana ove funkcije jeste ta što moraju da se navedu puni nazivi svih izvornih fajlova, jedan za drugim, dakle nije moguće samo navesti naziv celog direktorijuma sa svim izvornim fajlovima (src). Zato je ova linija u kodu zakomentarisana, a u upotrebi je funkcija file() koja omogućava korišćenje GLOB izraza za pronalaženje fajlova koji će biti dodati promenljivoj.

Sada je funkciji add_executable() dovoljno proslediti SOURCES promenljivu umesto navođenja svih izvornih fajlova u projektu.

Možemo preći na build projekta. Kako želimo da se svi build fajlovi nađu u build direktorijumu, iz root-a projekta izvršićemo sledeće komande:

$ cd build
$ cmake ..

-- The C compiler identification is GNU 7.3.0
-- The CXX compiler identification is GNU 7.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/Desktop/CMake/Primer02/build

Dakle, komandi cmake prosleđujemo adresu na kojoj se nalazi build definicija, a ona će u aktivnom direktorijumu izgenerisati sve potrebne build fajlove. U poslednjoj liniji gornjeg izlaza i piše da je cmake sve build fajlove sačuvao baš u direktorijumu build, kako smo i želeli. Makefile projekta je napravljen i nalazi se u build direktorijumu, odatle lako dobijamo željeni exe fajl pokretanjem komande make, a zatim možemo isti i da pokrenemo:

$ make

Scanning dependencies of target testStudent
[ 33%] Building CXX object CMakeFiles/testStudent.dir/src/Student.cpp.o
[ 66%] Building CXX object CMakeFiles/testStudent.dir/src/main.cpp.o
[100%] Linking CXX executable testStudent
[100%] Built target testStudent

$ ./testStudent

Ime studenta: Stefan

Prednost pakovanja build fajlova u poseban direktorijum jeste ta što se tada lako može obaviti takozvani clean rebuild – samo brisanjem celokupnog sadržaja build direktorijuma:

$ cd ..
$ rm -r build/*

Ako sada pokrenemo komandu tree videćemo da je struktura fajlova ista kao i pre pokretanja cmake komande.

Važna napomena: Ukoliko samo vršimo izmenu nad već postojećim fajlovima, nakon jednog cmake poziva, dovoljno je pozivati samo make komandu za svaku novu izmenu. Prilikom dodavanja jednog ili više novih fajlova, OBAVEZNO je ponovno pozivanje komande cmake radi ažuriranja Makefile-a!

Kompletan projekat za ovaj primer može se preuzeti ovde.

Build dinamičke biblioteke

Do sada smo na primerima pokazali samo kako se obavlja build koji za rezultat ima jedan executable fajl. CMake podržava izgradnju kako statičkih, tako i dinamičkih biblioteka. Na ovom primeru ćemo pokazati kako se obavlja build jedne dinamičke (.so) biblioteke.

Upotrebićemo isti projekat kao i za prethodnih primer, samo što ćemo ukloniti main.cpp, pošto nije potreban za build biblioteke. Tako da će naša biblioteka sadržati samo jednu klasu Student, što je sasvim dovoljno kako bi se pokazali osnovni principi izgradnje biblioteke korišćenjem CMake-a.

$ tree

.
├── build
├── CMakeLists.txt
├── include
│   └── Student.h
└── src
    └── Student.cpp

3 directories, 3 files

Isto, i u ovom projektu, header fajlovi smešteni su u include direktorijumu, a izvorni fajlovi u src direktorijumu. Prazan build direktorijum sadržaće krajnju binarnu biblioteku i sve ostale fajlove potrebne za build. Sada možemo obratiti pažnju na našu build definiciju u CMakeLists.txt fajlu:

cmake_minimum_required(VERSION 3.10.2)
project(Dinamicka_Biblioteka)
set(CMAKE_BUILD_TYPE Release)

include_directories(include)
file(GLOB SOURCES "src/*.cpp")

add_library(student SHARED ${SOURCES})

install(TARGETS studentlib DESTINATION /usr/lib)

Imamo samo par važnih promena u ovom kodu. Komandom set(CMAKE_BUILD_TYPE Release) eksplicitno navodimo da je ovo takozvani release build (druga opcija je debug build).

Sada umesto add_executable() funkcije iz prethodnih primera, imamo add_library() funkciju, koja nam omogućava izgradnju biblioteka. Da li će biblioteka biti statička ili dinamička zavisi od drugog argumenta koji se prosleđuje ovoj funkciji, taj argument može biti STATIC za statičke, odnosno SHARED za dinamičke biblioteke. Preko prvog argumenta postavili smo da ime biblioteke bude student (CMake sam dodaje prefiks lib na naziv fajla biblioteke), a za treći argument poslali smo izvorne fajlove u formi prethodno inicijalizovane promenljive SOURCES.

Konačno, imamo funkciju install(), koja je u našem slučaju opcionalna, a njome se definiše lokacija za takozvani deployment naše biblioteke. Nakon TARGETS ključne reči navodi se naziv fajla koji se instalira, dok se nakon DESTINATION ključne reči navodi adresa na sistemu. Važno je napomenuti da se ovom funkcijom samo specificiraju detalji instalacije. Sve definisane instalacije pokreću se izvršavanjem komande: sudo make install.

Možemo da obavimo build naše biblioteke na istovetan način kao i do sada:

$ cd build
$ cmake ..

-- The C compiler identification is GNU 7.3.0
-- The CXX compiler identification is GNU 7.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/Desktop/CMake/Primer03/build

$ make

Scanning dependencies of target student
[ 50%] Building CXX object CMakeFiles/student.dir/src/Student.cpp.o
[100%] Linking CXX shared library libstudent.so
[100%] Built target student

$ ls -l *.so

-rwxr-xr-x 1 ubuntu ubuntu 13352 mar 15 21:32 libstudent.so

Uočavamo da je make uspešno izgenerisao našu biblioteku libstudent.so u direktorijumu build. Komandom ldd možemo izlistati sve zavisnosti naše biblioteke:

$ ldd libstudent.so

    linux-vdso.so.1 (0x00007ffc4e18d000)
    libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f0d86f61000)
    libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f0d86d49000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0d86958000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f0d865ba000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f0d874ed000)

Setimo se da smo u build definiciji naveli i install() funkciju, kojom ćemo našu biblioteku da smestimo na odgovarajuće mesto u sistemu. Odlučili smo de da je smestimo u /usr/lib direktorijum, odakle će dostupna na celom sistemu. Samo je ostalo da pokrenemo sledeću komandu:

$ sudo make install

[sudo] password for ubuntu: 
[100%] Built target student
Install the project...
-- Install configuration: "Release"
-- Installing: /usr/lib/libstudent.so

$ ls -l /usr/lib/libstudent.so

-rw-r--r-- 1 root root 13352 mar 15 21:32 /usr/lib/libstudent.so

Ovaj korak mora se obaviti sa root privilegijama, kako bi se pisalo u /usr/lib direktorijum. Nakon pokretanja sudo make install komande, primećujemo da je u build direktorijumu nastao novi fajl install_manifest.txt. U tom fajlu izlistane su sve adrese na kojima je install komanda načinila izmene.

$ cat install_manifest.txt

/usr/lib/libstudent.so

Kompletan projekat za ovaj primer može se preuzeti ovde.

Upotreba biblioteka

U prethodnom primeru izgradili smo jednostavnu dinamičku biblioteku koristeći CMake alat. Sada je pitanje, kako tu istu biblioteku upotrebiti u konkretnim projektima? CMake i na to pitanje pruža jednostavan odgovor, što ćemo kao i uvek, pokazati na primeru.

$ tree

.
├── build
├── CMakeLists.txt
└── main.cpp

1 directory, 2 files

Imamo veoma jednostavan projekat, čija je svrha da pokaže kako se iz programa main.cpp može upotrebiti klasa koja je definisana u dinamičkoj biblioteci iz prethodnog primera. Evo kako izgleda naš main.cpp fajl:

#include "Student.h"

int main(int argc, char **argv)
{
   Student s("Stefan");
   s.Prikazi();
   
   return 0;
}

Dakle, klasu Student imamo definisanu u dinamičkoj biblioteci libstudent. Build definicija CMakeLists.txt na elegantan način opisuje traženu biblioteku:

cmake_minimum_required(VERSION 3.10.2)
project(Test_Biblioteke)

set(PROJECT_LINK_LIBS libstudent.so)
link_directories(~/Desktop/CMake/Primer03/build)
include_directories(~/Desktop/CMake/Primer03/include)

add_executable(libtest main.cpp)
target_link_libraries(libtest ${PROJECT_LINK_LIBS})

Kod je dovoljno jasan sam za sebe. Funkcijom set() promenljivoj PROJECT_LINK_LIBS pridružujemo naziv biblioteke, čiju lokaciju na sistemu otkrivamo narednom funkcijom link_directories(). Kako smo u prethodnom primeru, našu biblioteku instalirali u /usr/lib direktorijum, odakle je njena dostupnost apsolutna, funkciju link_directories() smo čak mogli i da izostavimo. I na kraju, funkcijom target_link_libraries() obavljamo linkovanje izvršnog fajla sa željenom bibliotekom.

Ostalo je samo odraditi build i pokrenuti aplikaciju:

$ cd build
$ cmake ..

-- The C compiler identification is GNU 7.3.0
-- The CXX compiler identification is GNU 7.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/Desktop/CMake/Primer04/build

$ make

Scanning dependencies of target libtest
[ 50%] Building CXX object CMakeFiles/libtest.dir/main.cpp.o
[100%] Linking CXX executable libtest
[100%] Built target libtest

$ ./libtest

Ime studenta: Stefan

Kompletan projekat za ovaj primer može se preuzeti ovde.

Zaključak

Primerima iz ovog članka dat je kratak i praktičan uvod u osnovne funkcionalnosti alata CMake. Pokazano je kako se može obaviti build jednostavnih aplikacija, biblioteka, kao i način njihovog povezivanja. Naravno, CMake je kompleksan sistem sa daleko većim brojem funkcionalnosti od onih koje su prikazane u ovom članku.

Još jedna osobina koja krasi CMake, jeste bogata i profesionalno odrađena dokumentacija koju možete pronaći na zvaničnom sajtu CMake-a. Korisnu listu svih komandi možete pogledati na CMake Index strani.

Korisni linkovi

Autor: Stefan Nestorović

Student četvrte godine Informatike na Institutu za matematiku i informatiku, Prirodno-matematičkog fakulteta u Kragujevcu.

Stefan Nestorović

Student četvrte godine Informatike na Institutu za matematiku i informatiku, Prirodno-matematičkog fakulteta u Kragujevcu.