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 :
- Scope de la fonction : Une variable définie dans une fonction ne peut plus être accédée dès que la fonction a retourné une valeur
- Dossier : Une variable définie en dehors d’une fonction a la portée du fichier
CMakeLists.txt
courant. Elle sera disponible aux dossiers enfants mais non aux dossiers parents. Cela s’applique lors de l’usage deadd_subdirectory
, mais pas dans les fichiers inclus avecinclude
- Cache persistant : Une variable stockée dans le cache est visible dans tout l’entieretée du build tree
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.
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 if
1), 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.
- Vous avez activé les avertissements en erreurs ?
Un nouveau compilateur sort, et votre projet ne compile plus. - Vous avez désactivé les exceptions ?
Un utilisateur pourrait en avoir besoin. - Vous avez optimisé pour la vitesse ?
Un utilisateur pourrait vouloir optimiser pour la taille,
ou activer un comportement sécurisé pour les calculs en virgule flottante,
ou encore utiliser un compilateur compatible mais ne supportant pas certaines options.
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 :
Debug
: cette configuration est optimisée pour le debug et génère des informations complètes. Elle ne doit être utilisée qu’en environnement de développement.Release
: cette configuration active la plupart des optimisations du compilateur pour améliorer les performances, et définit la macroNDEBUG
, ce qui supprime toutes les instructionsassert
de la bibliothèque standard.
Par défaut, pour GCC et Clang, elle utilise l’option-O3
, et pour MSVC, l’option/O2
.
Aucun symbole de debug n’est généré, ce qui rend l’analyse post-mortem plus difficile.RelWithDebInfo
: similaire àRelease
, mais avec une différence importante : elle active les optimisations tout en générant les symboles de debug.
Pour MSVC, c’est équivalent àRelease
+ symboles de debug.
Pour GCC et Clang, elle utilise-O2
(au lieu de-O3
) et génère également les symboles de debug.MinSizeRel
: cette configuration optimise le code pour la taille plutôt que la vitesse, sans générer d’informations de débogage.
Elle est principalement utilisée pour les plateformes embarquées, où la taille du binaire est critique.
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
sont0
ou1
.
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
-
Certaines variantes de la commande
if
nécessitent tout de même une déréférence explicite, commeif(IS_DIRECTORY chemin-vers-répertoire)
, car elles attendent une valeur de chemin en paramètre, et non une variable. ↩