CMake – jak używać w większych projektach?

Konfiguracja CMake - wszystkie wpisy

We wcześniejszym artykule pokazałem jak napisać prosty skrypt CMake. Za jego pomocą możemy zrealizować najważniejsze zadania stawiane skryptowi budowania. A więc możemy dodawać pliki źródłowe, headery, flagi kompilacji, czy definicje preprocesora.

Na początek taki skrypt jest jak najbardziej w porządku. Robi to co powinien i pozwala nam oswoić się ze składnią i działaniem CMake. Ale to nie jest najlepszy sposób pisania skryptów CMake i kiedy będziemy chcieli skorzystać z bardziej zaawansowanych opcji – możemy mieć problemy.

Dlatego w dzisiejszym wpisie zobaczymy jak pisać skrypty CMake zgodnie ze sztuką i jakie z tego będziemy mieć korzyści. Dowiemy się też dlaczego w skryptach CMake powinniśmy zawsze korzystać z komend z prefixem target_.

Targety w Makefile

Jeżeli masz doświadczenie z Makefile, na pewno wiesz co to „target”. Można powiedzieć, że jest to cel działania skryptu, czyli np. plik binarny po kompilacji. Aby ten cel osiągnąć trzeba wykonać poszczególne kroki, które same w sobie również są takimi „targetami”.

Na przykład aby otrzymać plik .elf, który możemy potem uruchomić i debugować, musimy uruchomić linker. Ten z kolei potrzebuje do swego działania pliki obiektowe .o. A one są wynikiem kompilowania poszczególnych plików .c. Targetami mogą być też choćby biblioteki statyczne czy zestawy komend konsolowych.

W Makefile można często znaleźć listy targetów wyglądające mniej więcej tak:

all : $(ELF) $(LSS) $(DMP) $(HEX) $(BIN)

$(ELF) : $(OBJS)
	$(ECHO) 'Linking target: $(ELF)'
	$(CC) $(LD_FLAGS) $(OBJS) $(LD_LIBS) -o $@

$(OUT_DIR)%.o : %.c
	$(ECHO) 'Compiling file: $<'
	$(CC) -c $(C_FLAGS) $< -o $@
	$(ECHO) ' '

W ten sposób zapisujemy właśnie łańcuchy zależności prowadzące nas do końcowego wyniku.

W CMake również mamy koncepcję targetów.

Targety w CMake

Zobaczmy sobie jeszcze raz skrypt CMake (czyli plik CMakeLists.txt) z wcześniejszego artykułu:

cmake_minimum_required(VERSION 3.10)
project(cmake_example_01)
include_directories(
    inc
)
add_definitions(
    -DEXAMPLE_DEFINE
)
link_directories(
    lib
)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_FLAGS "-Wall -Wextra -Og")
set(CMAKE_EXE_LINKER_FLAGS "-Wl,-Map=${CMAKE_PROJECT_NAME}.map -Wl,--gc-sections")
add_executable(${CMAKE_PROJECT_NAME}
    main.c
)
target_link_libraries(${CMAKE_PROJECT_NAME} m)

Nazwaliśmy tutaj nasz projekt cmake_example_01 i to on jest docelowym targetem naszego skryptu. Potem używamy w innych miejscach zmiennej ${CMAKE_PROJECT_NAME}. A ostatecznym wynikiem kompilacji będzie plik binarny o takiej samej nazwie, którego utworzenie zlecamy skryptowi w komendzie add_executable. Podobieństwo do targetów z Makefile nie jest przypadkowe. W końcu CMake może wygenerować skrypty Makefile.

Widoczność symboli w CMake

Ale targety w CMake pozwalają nam na dużo więcej niż w Makefile. Za ich pomocą możemy tworzyć podprojekty i ograniczać widoczność niektórych ustawień jak np. flagi kompilacji, czy pliki .h.

Aby to osiągnąć musimy stosować nieco inną składnię niż w powyższym przykładzie. Komendy takie jak include_directories, add_definitions, czy link_directories działają globalnie na cały plik CMakeLists.txt. Ale możemy również użyć odpowiedników tych komend dla poszczególnych targetów, czyli target_include_directories, target_add_definitions, target_link_directories. Jak widać ostatnia komenda w skrypcie – target_link_libraries jest właśnie z tej kategorii.

Kiedy używamy komend z prefixem target_ musimy jako pierwszy argument podać nazwę targetu, którego komenda się tyczy. Ale jest również druga różnica. Możemy skorzystać z jednej z trzech opcji widoczności dodawanych symboli – PUBLIC, INTERFACE i PRIVATE. Do czego one służą?

PUBLIC oznacza, że dodane symbole np. include są używane zarówno do zbudowania aktualnego targetu, jak i dodawane do każdego targetu, który używa naszego projektu.

PRIVATE oznacza, że symbole są dodawane do aktualnego targetu. Natomiast inne targety, które go użyją nie widzą tych symboli. Dzięki temu możemy pozbyć się odwiecznego problemu zaśmiecania projektu np. include’ami potrzebnymi tylko w małej części projektu.

Z kolei INTERFACE oznacza, że dodawane symbole są widoczne na zewnątrz, ale nie są dodawane do builda naszego targetu. Możemy więc powiedzieć, że zawierają publiczny interfejs naszego modułu.

Przykładowe użycie – unit testy

Na początku koncepcja targetów i widoczności PUBLIC, PRIVATE i INTERFACE może się wydawać skomplikowana. Dlatego zobaczmy sobie jak to wygląda w praktyce.

Mam przykładowy projekt zawierający unit testy. Znajdziesz go na moim GitHubie. Zawiera on pliki produkcyjne (folder src/cyclic_buffer), które chcemy poddać testom. Dlatego też mamy dwa projekty testowe (test/cyclic_buffer i test/cyclic_buffer_mock). Każdy z nich ma swój plik CMakeLists.txt zawierający instrukcję utworzenia binarki uruchamiającej testy.

W naszym projekcie chcemy również wykorzystać zewnętrzną bibliotekę do testów – Unity, oraz FFF (Fake Function Framework) – bibliotekę pozwalającą tworzyć proste mocki oparte na macrach preprocesora. Chcemy, aby Unity kompilowało się do biblioteki statycznej, żebyśmy mogli jej użyć w obu projektach testowych bez potrzeby jej ponownego kompilowania. Z kolei FFF jest biblioteką składającą się z jednego headera i nie chcemy go dodawać za każdym razem do listy headerów.

Podprojekt – biblioteka statyczna

Nasz projekt składa się z czterech różnych elementów i każdemu damy oddzielny plik CMakeLists.txt. Zacznijmy od Unity. Skrypt CMake wygląda tak:

cmake_minimum_required(VERSION 3.10)

set(INCLUDE_DIRS
	src
	extras/fixture/src
	extras/memory/src
)

add_library(unity STATIC
	src/unity.c
	extras/fixture/src/unity_fixture.c
	extras/memory/src/unity_memory.c
) 

target_compile_options(unity PRIVATE "-Wall")
target_include_directories(unity PUBLIC ${INCLUDE_DIRS})

Najpierw ustawiamy wszystkie ścieżki include. Następnie za pomocą komendy add_library tworzymy bibliotekę statyczną (argument STATIC) o nazwie unity i dodajemy wszystkie pliki źródłowe, które chcemy skompilować.

Biblioteka unity korzysta z magii z użyciem preprocesora. W samym frameworku dopuszczamy istnienie pewnych warningów, których w kodzie samych unit testów, a tym bardziej produkcyjnym nie możemy tolerować. Dlatego target Unity skompilujemy jedynie z warningami -Wall i aby to ustawić użyjemy komendy target_compile_options z widocznością PRIVATE. Z kolei ścieżki include mają być widoczne w głównym projekcie unit testów, dlatego komenda target_include_directories ma widoczność PUBLIC.

Podprojekt – biblioteka header-only

Teraz pora na FFF. Składa się on tylko z jednego headera i nie zawiera żadnych plików .c. Dla FFF plik CMakeLists.txt wygląda tak:

cmake_minimum_required(VERSION 3.10)

set(INCLUDE_DIRS
	.
)

add_library(fff INTERFACE) 

target_include_directories(fff INTERFACE ${INCLUDE_DIRS})

Jako ścieżki include podaliśmy kropkę, czyli aktualny folder. Kiedy dołączymy ten projekt do innego, CMake samodzielnie ogarnie nam ścieżki. Odchodzi nam więc bardzo częste źródło błędów.

Następnie za pomocą add_library tworzymy bibliotekę, ale tym razem typu INTERFACE. Co prawda nazwa jest taka sama jak przy widoczności targetów, ale dla tej komendy INTERFACE ma nieco inne znaczenie. Oznacza, że jest to biblioteka, która nie tworzy żadnego pliku wynikowego. W przeciwieństwie np. do biblioteki statycznej. Czyli typ bibliteki INTERFACE jest dokładnie tym, czego szukamy jeżeli chcemy zrobić bibliotekę header-only.

Na koniec jeszcze musimy udostępnić na zewnątrz ścieżki include również używając opcji INTERFACE. W ten sposób skrypt dla biblioteki header-only jest gotowy.

Dodawanie podprojektów

Ok mamy już gotowe skrypty dla bibliotek Unity i FFF. Pora teraz napisać skrypty dla aplikacji testowej, która je wykorzysta. Plik CMakeLists.txt dla testów cyclic_buffer_mock wygląda tak:

cmake_minimum_required(VERSION 3.10)

project(tdd_cyclic_buffer C)

add_subdirectory(../Unity unity)
add_subdirectory(../fff fff)

set(INCLUDE_DIRS
	../../src
)

set(TEST_INCLUDE_DIRS
	.
)

set(SRCS
	../../src/cyclic_buffer/cyclic_buffer.c
)

set(TEST_SRCS
	cyclic_buffer_main.c
	cyclic_buffer_runner.c
	cyclic_buffer_test.c
)

set(GLOBAL_DEFINES

)

add_executable(${CMAKE_PROJECT_NAME} ${SRCS} ${TEST_SRCS})

target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${INCLUDE_DIRS} ${TEST_INCLUDE_DIRS})
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE ${GLOBAL_DEFINES})
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE  -Wall -Wextra)
target_link_libraries(${CMAKE_PROJECT_NAME} unity)

enable_testing()
add_test(tests ${CMAKE_PROJECT_NAME})

Nasz projekt nazywa się cyclic_buffer_mock. Aby wykorzystać Unity i FFF dodajemy je używając komendy add_subdirectory. W ten sposób zlecamy zbudowanie tych targetów (dla FFF nie trzeba nic budować) i dodajemy do naszego projektu ich publiczny interfejs, czyli wszystkie headery, flagi kompilacji, define, które mają opcję PUBLIC albo INTERFACE.

Następnie ustawiamy wszystkie potrzebne pliki korzystając ze zmiennych pomocniczych i tworzymy target za pomocą add_executable. Następnie do naszego targeta dodajemy prywatną konfigurację include, flag kompilacji itp.

Powyższy skrypt zawiera również komendy enable_testing oraz add_test, które konfigurują wsparcie CMake dla testów. Ale o tym już innym razem.

I tyle – nasz skrypt CMake jest gotowy i korzysta z modułów. Konfigurację Unity i FFF możemy teraz używać w innych aplikacjach np. cyclic_buffer, który nie korzysta z FFF i potrzebuje samo Unity.

W outpucie kompilacji zwrócę jeszcze uwagę na jedną rzecz. Flagi dla biblioteki unity wyglądają tak:

[7/9] gcc  -I"C:/Projekty/example/tdd/test/Unity/src" -I"C:/Projekty/example/tdd/test/Unity/extras/fixture/src" -I"C:/Projekty/example/tdd/test/Unity/extras/memory/src" -Wall -MD -MT unity/CMakeFiles/unity.dir/src/unity.c.obj -MF unity\CMakeFiles\unity.dir\src\unity.c.obj.d -o unity/CMakeFiles/unity.dir/src/unity.c.obj -c "C:/Projekty/example/tdd/test/Unity/src/unity.c"

Czyli mamy warningi -Wall. Natomiast dla plików testowych:

[1/9] gcc  -I../../../src -I../. -I"C:/Projekty/example/tdd/test/Unity/src" -I"C:/Projekty/example/tdd/test/Unity/extras/fixture/src" -I"C:/Projekty/example/tdd/test/Unity/extras/memory/src" -Wall -Wextra -MD -MT CMakeFiles/tdd_cyclic_buffer.dir/C_/Projekty//example/tdd/src/cyclic_buffer/cyclic_buffer.c.obj -MF CMakeFiles\tdd_cyclic_buffer.dir\C_\Projekty\example\tdd\src\cyclic_buffer\cyclic_buffer.c.obj.d -o CMakeFiles/tdd_cyclic_buffer.dir/C_/Projekty/example/tdd/src/cyclic_buffer/cyclic_buffer.c.obj -c "C:/Projekty/example/tdd/src/cyclic_buffer/cyclic_buffer.c"

I są warningi -Wall oraz -Wextra.

Zobaczmy jeszcze folder z wynikami kompilacji:

ls
build.ninja CMakeCache.txt CTestTestfile.cmake tdd_cyclic_buffer_mock.exe*  unity/ cmake_install.cmake CMakeFiles/ fff/ Testing/

ls unity
cmake_install.cmake CMakeFiles/ libunity.a

ls fff
cmake_install.cmake CMakeFiles/

Czyli poszczególne podprojekty zawierają swoje foldery. W folderze Unity jest biblioteka statyczna libunity.a i pliki obiektowe z kompilacji oraz jakaś konfiguracja CMake. Z kolei folder fff zawiera jedynie konfigurację CMake jako, że to biblioteka header-only.

Podsumowanie

Dzięki zastosowaniu opcji konfiguracyjnych przeznaczonych na konkretny target kompilacji udało nam się utworzyć moduły, które możemy wykorzystać w innych aplikacjach. Możemy też kontrolować które headery, czy flagi kompilacji mają być widoczne w projekcie używającym naszego modułu, a które nie. Otwiera to przed nami nowe możliwości konfigurowania projektów.

Dlatego właśnie pisząc docelowe skrypty CMake do naszych projektów powinniśmy zawsze używać komend z prefixem target_.

W temacie CMake polecam bloga Kuby Sejdaka, który ma na ten temat kilka świetnych wpisów. Na przykład ten:

Konfiguracja CMake - Nawigacja

2 Comments

  1. Nie kompiluje się `cyclic_buffer_mock`. Ściągnąłem projekt z githuba i w katalogu `test/cyclic_buffer_mock` utworzyłem katalog `build` (standardowa procedura z cmake) i w nim wykonałem polecenie `cmake ..` a następnie `make`. Pojawił się błąd o braku `fff.h`. Tutaj mała korekta, którą zrobiłem żeby to zaczęło działać:
    -target_link_libraries(${CMAKE_PROJECT_NAME} unity)
    +target_link_libraries(${CMAKE_PROJECT_NAME} unity fff)

    Chyba zapomniałeś wypchnąć lokalną repo do githuba?

    PS. przydałoby się dodać „build*” do „.gitgnore” i może jakieś Readme.md jak to się powinno kompilować, żeby jakiś początkujący mógł to bez problemu odpalić

    • GAndaLF

      14 marca 2021 at 12:58

      Dzięki!

      Przed pushem odpalałem tylko cyclic_buffer, a przecież on nie wykorzystuje fff. Wrzuciłem na github poprawione.

Dodaj komentarz

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