Generowanie headerów ze stałymi w CMake

Konfiguracja CMake - wszystkie wpisy

W prawie każdym projekcie potrzebujemy przechowywać jakieś wartości, które zmieniamy w zależności od wersji projektu. Najbardziej oczywistym przykładem jest właśnie numer wersji. Ale czasem chcemy wyświetlać również commit id z gita, datę kompilacji, czy użytą wersję kompilatora. Nie muszę chyba dodawać, że aktualizacja takich danych ręcznie jest niezwykle uciążliwa, a czasem wręcz niemożliwa (jak dodać commit id bezpośrednio w kodzie bez modyfikowania bez modyfikowania go?). W większych projektach będziemy w tym celu używać dodatkowych skryptów. A CMake ma wbudowane wsparcie do tego typu operacji.

Generowanie i kompilacja

Zanim przejdziemy do rozwiązywania konkretnych problemów projektowych muszę wspomnieć o tym jak CMake przygotowuje skrypty budowania.

Za pomocą komendy cmake wykonujemy operację generowania skryptów budowania zgodnie z plikiem CMakeLists.txt i opcjami konfiguracyjnymi przekazanymi z linii komend czy GUI. Do tych opcji należą narzędzia do budowania (np. Makefile, ninja), czy omawiany wcześniej plik Toolchain.

Za pomocą raz wygenerowanych skryptów możemy później wielokrotnie uruchamiać kompilację projektu. Również jeżeli w plikach źródłowych wprowadziliśmy jakieś zmiany. Kolejne generowanie projektu jest potrzebne dopiero jeżeli zmodyfikujemy plik CMakeLists.txt na przykład dodając nowy plik źródłowy. Skrypt buildowania potrafi wykryć, kiedy wymagane jest ponowne wygenerowanie projektu i przed kompilacją uruchamia wtedy komendę cmake.

Dlaczego różnica między generowaniem i kompilacją jest taka ważna? Ponieważ mówi nam w którym momencie tworzone są nasze stałe konfiguracyjne! Skoro generowanie stałych na podstawie szablonów (do którego za chwilę przejdziemy) to opcja CMake, wartości będą się aktualizować w momencie generowania, a nie kompilacji.

Data i godzina kompilacji

Oznacza to, że daty kompilacji CMake nam nie wygeneruje. Na szczęście zrobi to dla nas kompilator! Możemy w tym celu użyć makr __DATE__ i __TIME__, które są częścią standardu C i powinny być wspierane przez każdy kompilator. Działają one bardzo podobnie jak __FILE__ oraz __LINE__ :

    printf("\n");
    printf("Compilation date: ");
    printf(__DATE__);
    printf("\n");
    printf("Compilation time: ");
    printf(__TIME__);
    printf("\n");

Output programu u mnie wygląda tak:

Compilation date: Dec 27 2020
Compilation time: 23:09:59

Wersja projektu

Z kolei przy aktualizacji wersji projektu CMake może nam już pomóc. Aby określić wersję projektu musimy podać ją w pliku CMakeLists.txt:

project(cmake_example_02 VERSION 1.0.1)

Tak więc do nazwy projektu dodajemy opcjonalny argument z numerem wersji. Jednak aby ten numer wersji wykorzystać w kodzie potrzebujemy czegoś jeszcze – szablonu kodu, gdzie wstawimy odpowiednią wartość:

//version.h.in
#define VERSION_STRING  @PROJECT_VERSION@

#define VERSION_MAJOR   @PROJECT_VERSION_MAJOR@
#define VERSION_MINOR   @PROJECT_VERSION_MINOR@
#define VERSION_PATCH   @PROJECT_VERSION_PATCH@

Utworzyłem plik version.h.in i umieściłem tam następujące define’y. Jak widać ich wartości to zmienne CMake otoczone znacznikami @ z obu stron. W szablonie możemy wykorzystać dowolną zmienną CMake. Listę wszystkich znajdziesz na tej stronie dokumentacji. Możemy też deklarować własne zmienne za pomocą komendy set().

Potrzebujemy jeszcze komendy w pliku CMakeLists.txt generującej header z szablonu:

configure_file(version.h.in inc/version.h)

W ten sposób z pliku version.h.in znajdującego się w głównym folderze źródeł zostanie utworzony plik inc/version.h w folderze wynikowym CMake. Czyli tam, gdzie znajdziemy również skrypty kompilacji.

Aby ten header mógł być używany w projekcie trzeba dodać jeszcze ścieżkę include:

include_directories( ${CMAKE_BINARY_DIR}/inc)

W tym celu posłużyłem się zmienną CMake wskazującą folder wynikowy.

Tak więc zbierając wszystko do kupy – mam plik main korzystający z version.h:

#include <stdio.h>

#include "version.h"

#define STRINGIFY1(a)    #a
#define STRINGIFY(a)     STRINGIFY1(a)

int main(void)
{
    printf(STRINGIFY(VERSION_STRING));
    printf("\n%d.%d.%d\n", VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH);

    return 0;
}

Mam plik szablonu version.h.in, gdzie deklaruje stałe zależne od CMake:

#pragma once

#define VERSION_STRING  @PROJECT_VERSION@

#define VERSION_MAJOR   @PROJECT_VERSION_MAJOR@
#define VERSION_MINOR   @PROJECT_VERSION_MINOR@
#define VERSION_PATCH   @PROJECT_VERSION_PATCH@

I mam plik CMakeLists.txt, gdzie określam wersję projektu i tworzę header z pliku konfiguracyjnego:

cmake_minimum_required(VERSION 3.10)

project(cmake_example_02 VERSION 1.0.1)

configure_file(version.h.in inc/version.h)

include_directories(
    ${CMAKE_BINARY_DIR}/inc
)

add_executable(${CMAKE_PROJECT_NAME}
    main.c
)

Po uruchomieniu programu otrzymamy następujący output:

1.0.1
1.0.1

Wersja kompilatora

Oczywiście poza danymi o wersji możemy w ten sposób obsłużyć każdą inną zmienną dostępną w CMake. Pokażę teraz jak dodać informacje o kompilatorze.

Musimy po prostu rozszerzyć plik version.h.in o dodatkowe stałe:

#define COMPILER_ID     @CMAKE_C_COMPILER_ID@
#define COMPILER        @CMAKE_C_COMPILER@

A następnie wykorzystać je w kodzie:

    printf("\n");
    printf("Compiler name: ");
    printf(STRINGIFY(COMPILER_ID));
    printf("\n");
    printf("Compiler path: ");
    printf(STRINGIFY(COMPILER));
    printf("\n");

W konfiguracji CMake nie musimy nic zmieniać.

Output programu u mnie wygląda tak:

Compiler name: GNU
Compiler path: C:/Tools/MinGW/bin/gcc.exe

Dane z gita

Kiedy chcemy w podobny sposób uzyskać dane z gita musimy już się trochę bardziej nagimnastykować. Nie ma na to prostej komendy cmake. Musimy uruchomić z cmake komendę gita zwracającą nam odpowiednie dane, przechwycić output do zmiennej i podać ją do pliku konfiguracyjnego. Rozwiązanie, które tutaj pokażę opiera się na artykule Matta Keetera. Jeżeli znacie lepsze rozwiązanie – będę wdzięczny za opisanie go w komentarzach.

Aby zapisać dane z gita posłużymy się następującymi linijkami w CMakeLists.txt:

execute_process(
        COMMAND git rev-parse HEAD
        OUTPUT_VARIABLE GIT_COMMIT)

execute_process(
        COMMAND git rev-parse --abbrev-ref HEAD
        OUTPUT_VARIABLE GIT_BRANCH)

W ten sposób powstały zmienne GIT_COMMIT i GIT_BRANCH, które teraz możemy użyć w version.h.in:

#define GIT_BRANCH      @GIT_BRANCH@
#define GIT_COMMIT      @GIT_COMMIT@

A po dodaniu następującego kodu do maina:

    printf("\n");
    printf("Git commit ID: ");
    printf(STRINGIFY(GIT_COMMIT));
    printf("\n");
    printf("Git branch: ");
    printf(STRINGIFY(GIT_BRANCH));
    printf("\n");

Otrzymamy taki output programu:

Git commit ID: f9338cecb6e77953712c42770a2306de90dc8c20
Git branch: master

To rozwiązanie jest proste i nie rozwiązuje kilku problemów. W linkowanym wyżej artykule możemy na przykład sprawdzić, czy kod zawiera jakieś zmiany od ostatniego commitu i sygnalizuje to za pomocą znaku +. Wykorzystano do tego komendę git diff.

Poza tym moje rozwiązanie pobiera dane z gita w momencie generowania. A jeżeli potem dodamy nowe commity, czy zmienimy branch bez zmian w CMake – zmienne dotyczące gita nie zostaną zaktualizowane.

Tak więc jeżeli pracujemy z kodem na maszynie developerskiej i jesteśmy w trakcie modyfikacji – musimy uważać, bo dane się nie zaktualizują. Ale do builda releasowego, gdzie budujemy od zera i nic już nie ruszamy – powinno się sprawdzić.

Kod przykładów

Kod, który użyłem w tym artykule można znaleźć na moim GitHubie. W tym samym projekcie jest również kod do wcześniejszego artykułu o podstawach CMake. Dojdą tutaj również przykłady z kolejnych części serii o CMake.

Podsumowanie

Jak widać korzystanie z CMake do generowania stałych w kodzie nie jest takie trudne, a może być całkiem przydatne. Schody zaczynają się dopiero kiedy chcemy przechwytywać dane z innych aplikacji jak na przykład git. Korzystając z CMake do generowania stałych możemy rozwiązać odwieczny problem aktualizowania zmiennych w różnych miejscach i korzystania z różnych skryptów automatycznych w każdym projekcie. A zmienne, które w ten sposób uzyskamy idealnie nadają się do konsoli debugowej, raportów o błędach, czy wyświetlania w menu. Dlatego na pewno warto zainteresować się tym tematem.

Konfiguracja CMake - Nawigacja

5 Comments

  1. No ja tam sobie zapisuję we flashu commity i tagi repo integracyjnego, jak i wszystkich podlinkowanych sub-repos, pliki nagłówkowe generuję git-hookami. Tag informuje mnie o wersji oficjalnej, commit bez taga – o jakimś tymczasowym druciarstwie 😉

  2. Hehe, no druciarstwo, robi sie te płytki tudzież inne układy niekoniecznie do końca drukowane od czasów wejścia pierwszych magazynów Elektroniki Praktycznej z FR do PL… Tam zdaje się mniej więcej ten termin uknuli, w EdW tudzież 😉 „łatwo generowane stałe dla buildów releasowych” – tak, każdy release ma taga, wszystko pomiędzy nie. Dlatego wrzucam do flasha każdy commit ID, bo często gęsto walimy na testy z czymś zupełnie niezamierzonym, ot tak, żeby coś sprawdzić, czy jakoś tam działa, a potem się okazuje, że cośtam całkiem fajnie śmignęło, tylko kto, gdzie i kiedy to coś popełnił, to z igłą w stogu siana szukać, jak się tego nie zaflashuje.

Dodaj komentarz

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