CMake et personnalisation de la configuration

10 avr. 2020#  cmake, tutorial, C++, buildsystem

Dans l’article précédent, nous avons abordé les bases de CMake et comment gérer les targets. Si vous n’êtes pas familier avec CMake, nous vous invitons à lire l’article précédent.

Dans celui-ci, nous approfondirons le sujet en traitant des fonctionnalités plus avancées afin que vous puissez personnaliser votre projet.
Il s’agit d’un élément important de CMake. En effet, chaque projet possède ses propres pré-requis de compilation, tels que la plateforme cible d’exéction, le compilateur, ou tout simplement des choix choix utilisateurs.

Nous étudierons comment utiliser des variables, des configurations de build et enfin, nous concluerons avec les generator expressions afin que vous puissiez fournir des options flexibles à vos utilisateurs.

Variables et options

Lors de l’écriture de scripts de compilation, on se retrouve rapidement à devoir manipuler des variables pour mieux contrôler la configuration du projet.

Comme dans la plupart des langages impératifs, CMake fournit des variables, des structures de contrôles (boucles / conditions) et même des fonctions.

Toutes les variables sont traitées en interne comme des chaînes de caractères, mais leur interprétation dépend de la commande qui les utilise. La commande principale pour manipuler les variables est set.

Les variables sont sensibles à la casse (case-sensitive) et peuvent avoir l’une des portées suivantes :

La signature de la fonction set est la suivante :

set(<variable> <value>... [PARENT_SCOPE])

Le paramètre PARENT_SCOPE permet d’attribuer une valeur à une variable dans la portée parente (fonction ou répertoire parent): cela peut être utilisé en tant que paramètre de sortie.

A noter que si vous passez plusieurs valeurs à set, elles sont concaténées dans une même string et séparée par des ;.
Cela signifie que si vous utilisez cette variable dans une commande qui gère les listes, vous devrez échapper les ; si vous ne souhaitez pas splitter la chaîne.

Ces variables peuvent être considérées commes des listes (lists) et manipulées via la commande list.

set(NUMBER_LIST 1 2 "3;4") # Results in NUMBER_LIST="1;2;3;4"
list(APPEND NUMBER_LIST 1) # Results in NUMBER_LIST="1;2;3;4;1"
list(REMOVE_DUPLICATES NUMBER_LIST 1) # Results in NUMBER_LIST="1;2;3;4"
set(NOT_A_LIST "a\;b") # Results in NOT_A_LIST="a\;b"  

CMakePrintHelpers

Vous aurez parfois besoin de debugger la valeur d’une variable Même si vous utilisez la commande message, CMake fournit un module utilitaire appelé CMakePrintHelpers(qui faciliter bien les choses).

Il suffit d’inclure le module, qui expose une fonction appelée cmake_print_variables et prend en paramètre la liste des noms des variables que vous voulez debugger.

Attention : ce module est destiné à un usage en développement uniquement.

Par exemple :

include(CMakePrintHelpers)

set(MYVAR value)
cmake_print_variables(MYVAR CMAKE_MAJOR_VERSION DOES_NOT_EXIST)

affichera pendant la configuration :

— MYVAR=“value” ; CMAKE_MAJOR_VERSION=“3” ; DOES_NOT_EXIST=""

On peut aussi utiliser cmake_print_properties pour afficher les propriétés de cibles, fichiers sources, etc. Plus d’informations dans la documentation officielle pour plus de details.

Cache

Le concept de cache est très important dans CMake. Il sert à mémoriser des résultats pour éviter des opérations coûteuses et répétées, mais aussi à configurer la compilation du projet.

Le cache est stocké dans un fichier CMakeCache.txt situé dans le répertoire de build. Pour le réinitialiser, il suffit de le supprimer.

Pour définir une variable dans le cache, utilisez set avec la signature suivante :

set(<variable> <value>... CACHE <type> <docstring> [FORCE])

Une variable présente dans le cache ne sera écrasée par un nouvel appel à set( ... CACHE ...), à moins d’utiliser le paramètre FORCE ou de ne pas utiliser CACHE dans la commande set.

Cela signifie qu’une variable de portée locale (répertoire ou fonction) a priorité sur une variable de cache. Cela peut être déroutant si vous oubliez la version CACHE de set, car la variable en cache semblera ignorée.

Par exemple :

set(MYVAR cachedValue CACHE STRING "someval")
cmake_print_variables(MYVAR)
set(MYVAR localValue) # Ignore the cache value
cmake_print_variables(MYVAR)

affichera :

— MYVAR=“cachedValue”
— MYVAR=“localValue”

Et si la valeur de MYVAR en cache à modifiedFromCache :

— MYVAR=“modifiedFromCache”
— MYVAR=“localValue”

Une meilleure expérience dans les interfaces graphiques (GUI)

Bien que toutes les variables soient considérées comme des chaînes de caractères en interne, vous pouvez spécifier un type aux variables du cache. Cela permet de fournir une expérience plus pratique et ergonomique au sein des boîtes de dialogues de la GUI CMake. Les valeurs de type disponibles sont : BOOL (ON/OFF), FILEPATH, PATH, STRING et INTERNAL.
Les variables INTERNAL sont particulières et ne sont pas affichées dans l’interface graphique.

CMake GUI example of variable types

Le paramètre docstring sert de résumé et s’affiche en infobulle / tooltip.

La manière la plus facile de manipuler le cache est à travers de la GUI. Mais il est tout à fait possible d’éditer le fichier CMakeCache.txt directement, qui possède également cette notion de type et de docstring.

Les variables de cache peuvent être également listée lors de l’invocation de CMake avec l’argument -L argument. -LH liste les variables avec leur documentation docstring.

Certaines variables peuvent être marquées comme avancées via la commande mark_as_advanced.
Elles seront alors masquée par défaut dans la GUI et avec l’usage du paramètre -L.
L’interface graphique propose une checkbox “advanced” pour les afficher, et le paramètre -LA répond au même besoin côté ligne de commande.

Variables de cache intégrées à CMake

CMake fournit de nombreuses variables de cache permettant de contrôler la compilation, comme CMAKE_CXX_FLAGS (la liste des flags par défaut utilisé en C++).

Ces variables ne doivent pas être surchargées au sein de votre CMakeLists.txt mais doivent être modifiées par l’utilisateurs.

Préférez toujours les commandes target_* et les properties.

Les utilisateurs peuvent aussi définir ou surchargées ces variables via la ligne de commande cmake avec l’option -D :

La syntaxe est -D <var>:<type>=<value> ou -D <var>=<value>.

Par exemple, si vous voulez utiliser la configuration Release pour des générateurs single-config (voir la section sur les configurations), vous pouvez utiliser :

cmake -DCMAKE_BUILD_TYPE=Release ..

Options du projet

La commande option

Nous avons vu que les variables du cache peuvent être vraiment pratique pour la configuration d’un projet.
Et vous pourrez constater que très souvent, la plupart d’entre eux ne sont que des switchs ON/ OFF.

C’est pourquoi une commande plus courte et plus explicite que set existe : la commande option.

option(<variable> "<help_text>" [value])

Dépendances entre options

Parfois vous voulez qu’une option dépende de la valeur d’une autre option.
Il existe pour cela le petit module CMakeDependentOption.
Ce dernier fournit la macro cmake_dependent_option : elle permet de conditionner l’existence d’une option par rapport à la valeur d’une autre option et de définir une valeur par défaut sinon.

include(CMakeDependentOption) # Needs to be called once

cmake_dependent_option(<variable> "<help_text>" <default> <conditions> <fallback>)

Les 3 premiers arguments sont les mêmes que option. La différence est que si la liste des conditions n’est pas satisfaite, alors la variable utilisera la valeur de fallback et n’apparaîtra pas dans la GUI.

Pour appel, une liste peut être créée par la concaténation de valeurs séparées par des ;.

Exemples

Un cas d’usage courant de cmake_dependent_option est la définition d’une option permettant de désactiver les tests spécifiques à un projet en fonction de la variable prédéfinie de CMake BUILD_TESTING. Cela est particulièrement utile lorsque votre projet est inclus via add_subdirectory : vous pouvez fournir à vos utilisateurs une manière de désactiver vos tests pour un projet spécifique sans impacter les autres.

Par exemple, vous pourriez écrire ce qui suit (dans cet exemple, nous utilisons le préfixe BP_ pour nos variables — à adapter selon les conventions de votre projet) :

cmake_dependent_option(BP_BUILD_TESTS
  # Par défaut, nous voulons activer les tests si CTest est activé  
  "Enable ${PROJECT_NAME} project tests targets" ON
  # Mais si BUILD_TESTING n'est pas à true, met BP_BUILD_TESTS à OFF
  "BUILD_TESTING" OFF
)

Un autre exemple utilisant plusieurs conditions :

# En considérant que la variable TARGET_SUPPORTS_AVX existe
option(BP_USE_SIMD_CODE "Enable hand optimized SIMD code" TRUE)
cmake_dependent_option(
  BP_USE_AVX "Enable hand optimized AVX code" TRUE
  "BP_USE_SIMD_CODE;TARGET_SUPPORTS_AVX" OFF
)

Notez qu’il est recommandé, par bonne pratique, de préfixer vos options (ici avec BP_) afin d’éviter tout conflit de nom avec d’autres bibliothèques, notamment si vous (ou vos utilisateurs) choisissez d’utiliser add_subdirectory pour la gestion des dépendances.

Utilisation des variables

Jusqu’à présent, nous avons vu comment définir et assigner une valeur à une variable, mais pas encore comment les utiliser.

Les variables peuvent être utilisées dans des blocs de contrôle comme if, de la manière suivante :

if(BP_USE_AVX)
  target_compile_definitions(myawesomelib PRIVATE USE_AVX=1)
endif()

Elles peuvent également être référencées directement avec la syntaxe ${variable}, qui sera remplacée par la valeur de la variable correspondante.
Notez que lorsqu’une commande attend une variable en paramètre (comme cmake_print_variables ou if1), il n’est pas nécessaire de la déréférencer. Ne confondez pas le nom d’une variable avec sa valeur !

project(Awesome)
set(${PROJECT_NAME}_Var ON) # Définit Awesome_Var=ON

Il est même possible d’imbriquer des variables. Elles sont alors évaluées de l’intérieur vers l’extérieur de l’expression (${outer_${inner_variable}_variable}).

# Affiche "The variable Awesome_Var has value ON"
message(STATUS "The variable ${PROJECT_NAME}_Var has value ${${PROJECT_NAME}_Var}")

Voici la traduction professionnelle en français :


# Affiche : "The variable Awesome_Var has value ON"
message(STATUS "The variable ${PROJECT_NAME}_Var has value ${${PROJECT_NAME}_Var}")

Les fonctionnalités optionnelles doivent rester optionnelles

Il est souvent tentant d’ajouter des options de compilation ou des fonctionnalités supplémentaires à une cible, même si elles ne sont pas strictement nécessaires à la compilation.
Cela peut concerner l’activation d’avertissements supplémentaires, l’ajout d’optimisations, la désactivation des exceptions… la liste est longue.
Ces éléments ne sont pas indispensables à la construction de votre projet, mais peuvent être utiles en phase de développement.

S’ils peuvent faciliter votre travail et celui de vos collègues, ils peuvent aussi compliquer sérieusement la tâche d’un mainteneur de paquet ou d’un utilisateur final.
En effet, vous ne contrôlez pas toujours l’environnement dans lequel votre bibliothèque sera utilisée ou déployée.

Ce sont autant de scénarios à anticiper. Votre projet CMake devrait ainsi se concentrer sur une seule question :

Comment compiler le projet ?

Si vous ajoutez des options de compilation ou des fonctionnalités optionnelles, notamment via target_compile_options, assurez-vous qu’elles soient réellement optionnelles du point de vue de l’utilisateur, et utilisez PRIVATE afin d’éviter la propagation de ces propriétés.

option(${PROJECT_NAME}_DISABLE_EH "Activez ceci pour désactiver la gestion des exceptions" ON)
if(${PROJECT_NAME}_DISABLE_EH)
   target_compile_options(yourlib PRIVATE -fflag-to-disable-exceptions)
endif()

Évitez également d’utiliser des options spécifiques à un compilateur sans vérifier préalablement leur compatibilité.

Vos utilisateurs et les mainteneurs de paquets vous en remercieront !

Voici la traduction professionnelle en français :


Types de configuration

Lors de la compilation d’un projet, il est courant d’avoir plusieurs configurations de build, par exemple une dédiée au debug, une autre destinée sans assertions, les logs de debug et avec les optimisations activées.

CMake fournit par défaut quatre types de configuration :

Il est essentiel de comprendre ces différences, car en fonction du generator utilisé lors de l’appel à CMake (Visual Studio, Makefile, Ninja…), le comportement peut varier.

Générateurs à configuration unique

Certains générateurs comme Makefiles et Ninja sont des générateurs à configuration unique.
Cela signifie que vous devez créer un dossier de build distinct pour chaque type de configuration que vous souhaitez utiliser.

La configuration est contrôlée via la variable CMAKE_BUILD_TYPE, qui peut être définie dès le premier appel à cmake depuis la ligne de commande.

Si cette variable est omise, CMAKE_BUILD_TYPE reste vide et ne correspond à aucune configuration prédéfinie.
À la place, une configuration “par défaut” sera utilisée, sans aucun flag spécifique au type de build.
Attention, cela ne correspond pas à la configuration Debug !

Générateurs à configurations multiples

Contrairement aux générateurs à configuration unique, certains générateurs permettent de gérer plusieurs configurations, ce qui est particulièrement utile pour les intégrations avec des environnements de développement (IDE).
Les générateurs Visual Studio, XCode et (depuis CMake 3.17) Ninja Multi-Config permettent de conserver plusieurs configurations dans un même dossier de build.
Dans ce cas, la variable CMAKE_BUILD_TYPE n’est pas utilisée.
À la place, on utilise la variable CMAKE_CONFIGURATION_TYPES (en cache), qui contient la liste des configurations disponibles pour ce dossier de build.

Configurations personnalisées

Avec les générateurs à configuration unique comme multiple, l’utilisateur peut ajouter ou retirer des types de configuration, et définir les options du compilateur pour chacune d’elles.
C’est particulièrement utile pour ajouter des builds spécifiques à la couverture de code, au profilage, ou aux outils de type sanitizers.

Cependant, plutôt que de détailler comment faire (pour le moment), concentrons-nous d’abord sur pourquoi vous devez rester prudent avec l’usage des configurations dans vos CMakeLists.txt.

Comme mentionné précédemment, votre CMakeLists.txt doit uniquement répondre à la question suivante :

Comment compiler le projet ?

Cela signifie que vous devez pouvoir compiler le projet même si quelqu’un ajoute ou retire une configuration, ou modifie les options d’optimisation.
Vous ne devez jamais faire d’hypothèses ni imposer la présence ou l’absence d’une configuration spécifique.

Autrement dit, ne forcez pas les valeurs des variables comme CMAKE_<LANG>_FLAGS_<CONFIG> : laissez à l’utilisateur le soin de les modifier via le cache.

Retenez simplement que si vous devez modifier la liste CMAKE_CONFIGURATION_TYPES depuis un CMakeLists.txt, cela doit impérativement être fait avant le premier appel à la commande project (car celle-ci initialise la variable si elle ne l’est pas déjà).
Et surtout, vous devez vérifier si un générateur multi-configurations est utilisé, ou si la variable a déjà été définie.

Ne définissez pas CMAKE_CONFIGURATION_TYPES si cette variable est absente : cela indique qu’un générateur à configuration unique est utilisé.
De la même manière, ne définissez pas CMAKE_BUILD_TYPE si un générateur multi-configurations est utilisé !

Cela conduit à un code du type :

if(CMAKE_CONFIGURATION_TYPES AND (NOT "Coverage" IN_LIST CMAKE_CONFIGURATION_TYPES))
  list(APPEND CMAKE_CONFIGURATION_TYPES Coverage)
endif()

Quelle que soit la situation, laissez le contrôle à l’utilisateur ou au mainteneur du paquet : ils vous en remercieront.

Les expressions générées / Generator expressions

Comme la régénération d’un projet CMake ne se produit que lorsqu’un fichier CMakeLists.txt est modifié, cela signifie que si vous souhaitez utiliser des variables définies pendant la génération du système de build (et non à la configuration), vous ne pouvez pas simplement écrire if(VARIABLE).
Il peut également arriver que vous deviez écrire du code verbeux pour récupérer des propriétés d’une target et les transmettre à une autre commande.

Pour gérer ces cas, CMake a introduit les generator expressions.
Contrairement aux variables classiques évaluées pendant la configuration, les generator expressions sont évaluées au moment de la génération du système de build.
Cela leur permet notamment de connaître les différentes configurations (Debug, Release, etc.).

Elles ne sont pas acceptées partout, mais la majorité des commandes les prennent désormais en charge. Consultez la documentation spécifique à chaque commande pour savoir si c’est le cas.

La syntaxe de base d’une expression générée est :

$<condition:valeur_si_vrai>

Et pour un opérateur ternaire :

$<IF:condition,valeur_si_vrai,valeur_si_faux>

À noter : dans les deux cas, les seules valeurs valides pour condition sont 0 ou 1.

Quelques exemples utiles

Pour évaluer une option dans une expression générée, vous pouvez utiliser l’opérateur logique $<BOOL:string> qui convertit une chaîne en booléen (0 ou 1).
C’est indispensable, car les expressions générées ne comprennent que ces deux valeurs comme condition.

Notre exemple précédent avec BP_USE_AVX peut alors être écrit comme ceci :

# Définit USE_AVX=1 uniquement si BP_USE_AVX est activé
target_compile_definitions(myawesomelib PRIVATE $<$<BOOL:${BP_USE_AVX}>:USE_AVX=1>)

Mais le véritable intérêt des expressions générées apparaît avec les requêtes de variables / variable queries, qui permettent d’adapter le comportement selon la plateforme ou le langage par exemple :

target_sources(mytarget
  PRIVATE
    source/all-platforms.cpp
    $<$<PLATFORM_ID:Windows>:source/windows-only.cpp>
    $<$<PLATFORM_ID:Linux>:source/linux-only.cpp>
)

Ce bloc ajoute source/all-platforms.cpp sur toutes les plateformes, source/windows-only.cpp uniquement sous Windows, et source/linux-only.cpp uniquement sous Linux.

Autre exemple :

target_compile_definitions(mytarget
  PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
    $<$<COMPILE_LANGUAGE:CXX>:COMPILING_CXX>
    $<$<COMPILE_LANGUAGE:CUDA>:COMPILING_CUDA>
)

Ici, on ajoute le flag DEBUG_MODE uniquement pour les builds en mode Debug, COMPILING_CXX pour les sources C++ et COMPILING_CUDA pour les sources CUDA.


Débugger les expressions générées

Étant évaluées au moment de la génération et non lors de la configuration, les expressions générées peuvent être complexes à debugger.
Comme le mentionne la documentation, vous pouvez créer une cible personnalisée pour afficher leur résultat :

add_custom_target(genexdebug COMMAND ${CMAKE_COMMAND} -E echo "$<...>")

Ensuite, il vous suffit de compiler la cible genexdebug pour voir la valeur réellement évaluée.


Espaces dans les expressions générées / generator expressions

L’une des erreurs les plus fréquentes concerne l’utilisation d’espaces dans la condition de l’expression générée.
Par exemple : $<$<CONFIG:Debug>:DEBUG_MODE> est valide, mais $< $<CONFIG:Debug> :DEBUG_MODE> ne l’est pas.

Il existe toutefois une astuce : utiliser string(CONCAT) pour créer une expression générée multi-lignes contenant des espaces :

string(CONCAT GENEXP_WITH_WHITESPACE
  $<IF: ${SOME_VAR},
    "SOME_VAR is true",
    "SOME_VAR is false"
  >
)

Cela sera interprété comme :

$<IF:$<BOOL:${SOME_VAR}>,SOME_VAR is true,SOME_VAR is false>

⚠️ Si vous avez besoin de guillemets dans l’expression finale, pensez à les échapper.


Et ensuite ?

Dans le prochain chapitre, nous parlerons des modules CMake, de la gestion des packages et de l’installation de cibles.

Footnotes

  1. Certaines variantes de la commande if nécessitent tout de même une déréférence explicite, comme if(IS_DIRECTORY chemin-vers-répertoire), car elles attendent une valeur de chemin en paramètre, et non une variable.

photoClément GRÉGOIRE

Clément GRÉGOIRE

Expert Performance & Optimisation
# C++PerformanceJeux-VidéosRendering