18. Mai 2020
Lesedauer 11 Min.
CMake 101
C/C++-Projekte ohne IDE kompilieren
Erste Schritte mit dem Build-System CMake.

C++-Projekte beziehungsweise die zu ihnen gehörenden Build-Prozesse werden immer komplizierter. Reichte es früher aus, Bibliotheken und Quellcode durch den Compiler und den Linker zu jagen, so sind heute oft sorgfältige Nachbearbeitungsprozesse erforderlich. Das plattformunabhängige Programmierwerkzeug CMake [1] zum Entwickeln und Erstellen von Software – das Akronym steht für Cross Patform Make – trägt diesem Trend Rechnung. Je autodidaktischer Entwickler sind, umso vertrauter ist ihnen die folgende Geschichte: Man beginnt seine Arbeiten auf der Kommandozeile, um bald eine Entwicklungsumgebung (IDE) zu entdecken. Dann – meistens kommt zu diesem Zeitpunkt schon gutes Geld ins Haus – trifft man mehr oder weniger unfreiwillig auf eine Person, die ein Build-System einzuführen versucht.Die dahinterstehende Idee ist simpel: Anstatt die Projekt-Kompilationsschritte innerhalb der IDE zu formulieren, liegen sie nun in einer oder mehreren dafür vorgesehenen Dateien. Vorteil dieser auf den ersten Blick widersinnig scheinenden Vorgehensweise ist, dass die Informationen auf diese Weise unabhängig von der IDE sind. Kommt beispielsweise eine neue Workstation in Ihr Leben, so ist das Deployment vergleichsweise einfach – der Kampf mit IDE-Konfigurationsdateien und anderen Nettigkeiten entfällt ersatzlos.
Wahl des Kampfplatzes
Seit Version 2017 von Visual Studio unterstützt auch Microsoft das CMake-Build-System als sogenannten First-Class Citizen. Microsoft nutzt es beispielsweise sehr umfangreich in den Projektbeispielen für Azure Sphere.Im Interesse des einfacheren Handlings soll CMake in diesem Artikel zunächst nicht in Visual Studio, sondern auf der Kommandozeile vorgestellt werden. Der Autor verwendet für die folgenden Schritte Ubuntu 18.04. Das Betriebssystem ist anspruchslos, Sie können es auch in einer virtuellen Maschine hosten, vergleiche Kasten Tipps zu CMake.Öffnen Sie also ein Terminalfenster und befehlen Sie das Herunterladen des Systems nach folgendem Schema:
tamhan<span class="hljs-meta">@tamhan</span>-<span class="hljs-string">thinkpad:</span>~$ sudo apt-get install cmake
[sudo] password <span class="hljs-keyword">for</span> <span class="hljs-string">tamhan:</span>
...
Setting up cmake (<span class="hljs-number">3.5</span><span class="hljs-number">.1</span><span class="hljs-number">-1</span>ubuntu3) ...
Beginn der Rituale
Als erste Übung soll ein in C++ verfasstes Hello-World-Programm zur Kompilation freigegeben werden. Was in einer IDE durch einfaches Anwerfen des Projektgenerators vonstattengeht, setzt auf der Kommandozeile etwas Überlegung und Arbeit voraus.Die mit Abstand wichtigste Entscheidung betrifft dabei die Frage, ob ein In-Source- oder ein Out-of-Source-Build erzeugt werden soll. Die beiden unterscheiden sich durch die Orte im Dateisystem, an denen die vom Kompilations- beziehungsweise Generationsprozess generierten Artefakte abgelegt werden.Tipps zu CMake
<b>VM-Anforderungen </b>
Out-of-Source-Builds sind insbesondere im Hinblick auf Versionskontrollsysteme und ähnliche Nettigkeiten die bessere Vorgehensweise. Bei ihnen liegen Build- und Ausgabe-Dateien in unterschiedlichen Ordnern (Bild 1).

Ein Out-of-Source-Buildsetzt zwei Verzeichnisse im Dateisystem voraus(Bild 1)
Autor
Im nächsten Schritt müssen Sie die benötigten Dateien generieren. Die C++-Datei ist dabei einfach. Übernehmen Sie das folgende Beispielprogramm, falls sich in Ihrer Sammlung kein attraktiveres findet:
<span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <iostream> </span>
<span class="hljs-meta">using namespace std; </span>
<span class="hljs-meta">int main() { </span>
<span class="hljs-meta"> cout << <span class="hljs-meta-string">"Hello, World!"</span>; </span>
<span class="hljs-meta"> return 0; </span>
<span class="hljs-meta">} </span>
Zur Datei CMakeLists.txt: Normalerweise erwartet CMake in jedem Ordner, der Teil der zu generierenden Solution ist, eine solche Datei. Ein Beispiel dafür sieht wie folgt aus:
cmake_minimum_required(<span class="hljs-name">VERSION</span> <span class="hljs-number">3.4</span>)
project(<span class="hljs-name">NMGApp</span>)
add_executable(<span class="hljs-name">nmgExe</span> main.cpp)
Die soeben realisierte CMake-Steuerungsdatei demonstriert einige Best Practices aus der Welt des Build-Werkzeugs. Ganz oben finden Sie das Statement cmake_minimum_required(VERSION 3.4), das immer am Beginn der Datei stehen sollte. Diese Angabe ist insofern erforderlich, als sich das Verhalten von CMake im Lauf der Jahre beziehungsweise der Versionen stark geändert hat – wenn die vorgesehene Version des Produkts festgelegt wird, können jüngere Varianten von CMake Legacy-Unterstützungsmodi oder Sonderregimes aktivieren.Darauf folgt die Projektdeklaration, die in der einfachsten und für ein C/C++-Projekt vorgesehenen Variante nur den Projektnamen festlegt. Er dient IDEs wie Visual Studio oder Xcode für die Festlegung des Namens der Projektmappe. Nun fehlt noch ein kompilierbares Artefakt, das im Beispiel auf den Namen nmgExe hört und aus der Datei main.cpp entsteht.Als Nächstes müssen Sie CMake dazu bringen, die für die Kompilation notwendigen Schritte vorzunehmen. Hierzu wechseln Sie ins Build-System und geben das folgende Kommando ein:
tamhan<span class="hljs-meta">@tamhan</span>-<span class="hljs-string">thinkpad:</span><span class="hljs-regexp">~/cmakespace$ cd build </span>
<span class="hljs-regexp">tamhan@tamhan-thinkpad:~/</span>cmakespace<span class="hljs-regexp">/build$ cmake -G </span>
<span class="hljs-regexp"> ../</span>source
CMake <span class="hljs-string">Error:</span> Could not create named generator ../source
An sich handelt es sich bei der zweiten Eingabe um einen Missbrauch von CMake. Das Übergeben des Parameters -G erlaubt dem Entwickler nämlich das Festlegen eines bestimmten Generators. Hier wird allerdings kein Name übergeben, was CMake zur Ausgabe der in Bild 2 gezeigten und von System zu System unterschiedlichen Liste der Generatoren animiert.

Unter Ubuntu 18.04sind einige Generatoren bekannt(Bild 2)
Autor
In diesem Beispiel geht es darum, ein gewöhnliches Unix-Projekt zu erzeugen, weshalb die folgende Eingabe erforderlich ist:
tamhan@tamhan-thinkpad:~/cmakespace/build$ cmake -G
"Unix Makefiles" ../source
...
-- Build files have been written to:
/home/tamhan/cmakespace/build
Im Umgang mit Linux erfahrene Entwickler sehen anhand der Ausgabe, dass CMake zur Fertigstellung des Projekts noch etwas Hilfe benötigt. Bild 3 zeigt, wie Sie das Projekt unter Zuhilfenahme des make-Werkzeugs ausführungsbereit machen.

CMakesendet Grüße(Bild 3)
Autor
Wer bisher ausschließlich unter Windows und mit Visual Studio gearbeitet hat, reibt sich an dieser Stelle ob der diversen Arbeitsschritte verwundert die Augen. Das Erzeugen eines Programms für ein unixoides Betriebssystem ist genauso wie auf Kommandozeile von Windows ein vergleichsweise komplizierter Prozess. Normalerweise verhält er sich wie in Bild 4 gezeigt.

Ausführbare Dateienentstehen in einem mehrstufigen Verfahren(Bild 4)
Autor
Ein auf CMake basierender Prozess unterscheidet sich insofern, als an der Stelle von in Handarbeit zusammengestellten Konfigurationsskripten die bekannte CMake-Datei und das CMake-Werkzeug stehen.Dieser Teil des Prozesses in Bild 5 beschränkt sich darauf, das Projekt in eine für die native Toolchain verständliche Form zu bringen. Die eigentliche Kompilation erfolgt dann mit der nativen Toolchain – im Fall einer Ubuntu-Workstation wäre dies beispielsweise GCC. CMake geht danach allerdings über gewöhnliche Makefiles hinaus: mit CTest und CPack gibt es zwei Komfortwerkzeuge, die sowohl beim Abarbeiten von Unit-Tests als auch beim Erzeugen auslieferbarer Artefakte helfen.

Ein Build-Prozessauf Basis von CMake ist vergleichsweise kompliziert(Bild 5)
Autor
Entwickler, die im Moment erste Erfahrungen mit CMake sammeln, sollten sich folgende Feststellung einprägen: CMake ist ein Werkzeug, das sich um das Festlegen des „Wie“ des Kompilationsprozesses kümmert. Die eigentliche Kompilation erfolgt dann ausschließlich unter Verwendung der jeweiligen nativen Toolchain, CMake tritt dabei nicht mehr in Erscheinung.
Variablen erleichtern Aufgaben
Sie haben soeben festgestellt, dass CMake auf die Elimination der von Entwicklern handgeschriebenen Shell-Skripte abzielt. Um dieses Ziel auch zu erreichen, muss das Produkt mehr Funktionen bieten, als man im von Haus aus verfügbaren Shell-Interpreter erwarten kann.Zur Demonstration von Variablen soll hier auf ein klassisches Antipattern zurückgegriffen werden. Es kümmert sich darum, die weiter oben verwendeten project- und executable-Deklarationen mit gültigen String-Werten auszustatten:
# Keine gute Idee
set(pName NMG)
project(${pName})
add_executable(${pName} ...)
Neben dem mit einem #-Präfix beginnenden und für die Programmauswertung irrelevanten Kommentar finden Sie hier die Methode set. Sie hat die Aufgabe, der Variablen den angelieferten Wert zu übergeben. Danach finden sich die beiden schon bekannten Statements, welche die in der Variablen enthaltenen Werte zur Befüllung des erforderlichen Parameters einspannen.Für fortgeschrittene Experimente mit Variablen benötigen wir eine Möglichkeit, um während der Abarbeitung der CMake-Datei Informationen auf der Konsole auszugeben. Der Autor betont explizit die Abarbeitung der CMake-Datei: Die Meldungen erscheinen nicht während der Kompilation, sondern während der Vorbereitung der Umgebung. Werkzeug der Wahl ist hier die Message-Funktion. Ein Beispiel:
set(nmgvar tam ist lustig)
message ("Die Variable enthält ${nmgvar}")
set(nmgvar 222)
message ("Die Variable enthält ${nmgvar}")
set(nmgvar "tam ist lustig")
message ("Die Variable enthält ${nmgvar}")
Dieser Code definiert die Variable nmgvar, um ihr nach der Ausgabe des gerade enthaltenen Werts zweimal einen neuen Wert zuzuweisen. Bild 6 demonstriert die Ausgabe unter Linux.

In der Ausgabefindet sich mehr als nur ein Semikolon(Bild 6)
Autor
Variablen sind in der Welt von CMake normalerweise Strings. Ein Sonderfall sind Listen, die bei der Ausgabe durch per Semikola getrennte Werteelemente dargestellt werden. Sie sehen hier, dass der String tam ist lustig einmal als Gesamtstring und davor einmal als Liste mit drei Wörtern ausgegeben wird. Sonst verhalten sich Variablen so, wie man es erwarten würde – es gibt sogar einen Rechenoperator, mit dem sich mathematische Ausdrücke auswerten lassen:
math(EXPR outVar mathExpr)
Ein kleiner Sonderfall sind die Umgebungsvariablen des Betriebssystems. Sie lassen sich über den ENV-Operator ansprechen, der übrigens auch zum temporären Ändern der Informationen taugt:
set(ENV{PATH} "$ENV{PATH}:/opt/myDir")
Von der Langlebigkeit
Dass CMake die Änderungen an Umgebungsvariablen nicht permanent festlegen kann, folgt aus der Logik. Bisher waren alle anderen während des Prozesses erzeugten Elemente transienter Natur. Der beste Weg zur Beschleunigung von Build-Prozessen ist die Einführung umfangreicher Caches. Diese finden sich normalerweise in Dateien mit dem Namen CMakeCache.txt. Witzigerweise legt auch der transiente Build-Prozess eine solche Datei an, die allerdings nur auf das System beziehungsweise die Umgebung bezogene Informationen enthält. Um die Möglichkeiten kennenzulernen, sollten Sie folgendes Progrämmchen starten:
if(NOT DEFINED hausdernmg)
message ("Das Haus der NMG fehlt")
set(hausdernmg "Es Lebt" CACHE STRING "")
else()
message ("Die Variable enthält ${hausdernmg}")
set(hausdernmg "Es lebt wirklich" CACHE STRING "")
endif()
Hier sehen Sie mehrere neue Konstrukte. Erstens gibt es nun eine Else-If-Selektion, die das konditionale Ausführen von CMake-Kommandos ermöglicht. Die leeren runden Klammern nach else und endif sind erforderlich – in älteren Versionen von CMake musste man darin sogar noch die „Bedingung“ der If-Selektion unterbringen.Die zweite Änderung ist die Art der Variablendeklaration. Sie weist nun zusätzlich das Attribut CACHE auf, das CMake darüber informiert, dass diese Variable persistiert werden muss. Als zweites neues Feld findet sich ein Datentyp. Neuer Wert Nummer drei ist ein – im Moment nicht weiter relevanter – Dokumentationskommentar. Folgende Liste zeigt einige unterstützte Attribute, die sich übrigens nicht auf den Befehl CMake-Run auswirken.
- BOOL: True-False-Flag. Idealerweise per Checkbox einstellbar.
- FILEPATH: Common-Dialog, der eine Datei selektiert.
- INTERNAL: Interne Variable, die dem Anwender nicht zur Anpassung vorgelegt werden soll.
- PATH: Common-Dialog, der einen Pfad selektiert.
- STRING: Zeichenkette.

Die Cache-Variableändert ihren Wert nicht(Bild 7)
Autor
else()
message ("Die Variable enthält ${hausdernmg}")
set(hausdernmg "Es Lebt wirklich" CACHE STRING ""
FORCE)
endif()
Wer diese Version des Programms zur Ausführung freigibt, stellt fest, dass alles wie erwartet funktioniert. Das Hantieren mit Datentypen und Dokumentationskommentaren ist dabei übrigens kein Selbstzweck – im CMake-Ökosystemen gibt es eine Gruppe von Programmen, die die Parametrisierung eines Kompilationsprozesses ermöglichen.Dazu werten Sie im ersten Schritt die diversen CMakeLists.txt-Dateien aus, um danach ein für den Benutzer bequem handhabbares Konfigurationsformular zu erzeugen. Eine vollständige Besprechung dieser Werkzeuge würde den Rahmen dieses Artikels sprengen. Möchten Sie eigene Experimente durchführen, so können Sie den am weitesten verbreiteten Kandidaten mit folgendem Kommando installieren:
tamhan@tamhan-thinkpad:~/cmakespace/build$ sudo apt
install cmake-qt-gui
Eine kurze Überlegung
Nachdem wir bisher eher theoretisch unterwegs waren, wollen wir uns nun eine Beispiel-CMake-Datei ansehen. Aufmerksame Leser dieses Hefts kennen die unter [2] bereitstehende Datei wahrscheinlich – es ist eines der GPIO-Samples für Azure Sphere. Microsoft integriert in Visual Studio 2019 eine sehr aktuelle Version von CMake, weshalb die Minimum-Deklaration anspruchsvoll ausfällt:
CMAKE_MINIMUM_REQUIRED(VERSION 3.8)
PROJECT(GPIO_HighLevelApp C)
Im nächsten Schritt deklariert Microsoft ein Executable. Interessant ist hier, dass die Deklaration von zwei .c-Dateien zu finden ist. Das Einbinden der Bibliotheken erfolgt dann über das Kommando TARGET_LINK_LIBRARIES – durch den jeweils übergebenen Projektnamen weiß CMake, auf welches Element sich die Kommandos beziehen.
# Create executable
ADD_EXECUTABLE(${PROJECT_NAME} main.c
epoll_timerfd_utilities.c)
TARGET_LINK_LIBRARIES(${PROJECT_NAME} applibs pthread
gcc_s c)
Danach lädt Microsoft über die include-Funktion ein Modul. Module sind in der Welt von CMake Verpackungseinheiten, die das bequeme Einbinden von Features ermöglichen. Der Beispielcode lädt die Datei AZURE_SPHERE_MAKE_IMAGE_FILE, die sich um das Bereitstellen der Image-Datei kümmert:
# Add MakeImage post-build command
INCLUDE(„${AZURE_SPHERE_MAKE_IMAGE_FILE}“)
Cross-Platform-Spiele
Einer der wichtigsten Gründe pro Build-Management-System ist mit Sicherheit, dass die resultierenden Programme dadurch von der Workstation des Entwicklers und im Idealfall sogar vom Betriebssystem unabhängig werden. In der CMake-Welt ist dies eine der Paradeanwendungen. Unter [3] finden Sie eine Liste von Hunderten Umgebungsvariablen, die eine genaue Analyse des gerade aktuellen Hostsystems erlauben. Hier soll ein einfaches Beispiel realisiert werden, das den Inhalt einer Datei je nach aktuellem Host-Betriebssystem austauscht. Unter Windows kommt dabei die Datei windows.cpp zum Einsatz, die eine freundliche Meldung ausgibt:
#include <iostream>
using namespace std;
int worker() {
cout << "Hello, Unter Windows";
}
Die für Unix-basierte Betriebssysteme vorgesehene Variante linux.cpp sieht genauso aus, bis auf den angepassten Text:
#include <iostream>
using namespace std;
int worker() {
cout << "Hello, Unter Linux";
}
Im Einsprungpunkt findet sich ebenfalls nichts Betriebssystemspezifisches. Die Methode worker wandert durch eine forward declaration ins Projekt und wird im Rahmen der Abarbeitung aktiviert:
#include <iostream>
using namespace std;
int worker();
int main() {
worker();
cout << "Hello, World!";
return 0;
}
Als Nächstes geht es um die Bereitstellung der Dateien, die der Compiler für das jeweilige Artefakt benötigt. Da wir hier mit nur wenigen Dateien arbeiten, beginnen wir mit einer Selektion. Hier findet sich je eine Instanz des <em>add_executable</em>-Kommandos, die jeweils den Einsprungpunkt und die dazugehörende plattformspezifische Datei avisiert:
cmake_minimum_required(VERSION 3.4)
project(NMGApp)
if(WIN32)
add_executable(nmgExe main.cpp windows.cpp)
elseif(UNIX)
add_executable(nmgExe main.cpp linux.cpp)
else()
message( FATAL_ERROR "Uh-Oh!" )
endif()
Die komplett in Großbuchstaben gehaltenen Plattformnamen sind dabei der primitivste Weg zum Ansteuern von Betriebssystemen.Insbesondere das Windows-Makro (WIN32) ist dabei nicht uneingeschränkt empfehlenswert, weil es mit Compilern wie MinGW mitunter zu interessanten Problemen führt.Der Message-Aufruf bekommt dieses Mal das Flag FATAL_ERROR übergeben. Er informiert die CMake-Ausführungsumgebung darüber, dass die Ausgabe dieser Nachricht die Verarbeitung des gesamten Projekts unterbrechen muss, weil ein nicht reparierbarer Fehler aufgetreten ist.Nachdem Sie das korrekte Funktionieren der Linux-Version durch den Zweikampf aus Neukompilieren und Ausführung bewiesen haben, kopieren Sie das gesamte Verzeichnis auf eine Windows-Maschine. Der Inhalt des Build-Verzeichnisses wird danach zur Beute von /dev/null.Im nächsten Schritt öffnen Sie Visual Studio und befehlen über die Option Datei | Öffnen | CMake das Laden des CMake-Files. Achten Sie im Hintergrund darauf, dass die C++-Payload im Visual-Studio-Installer aktiviert sein muss – der für Azure Sphere ausreichende Linux-Compiler genügt hier nicht.Nach dem Laden der Datei sehen Sie im Ausgabefenster einen nach dem nachfolgenden Schema ablaufenden C-Make-Lauf:
1> Die CMake-Generierung für die Konfiguration wurde
gestartet: "x64-Debug".
...
1> Codemodell extrahiert.
1> CMake-Generierung abgeschlossen.
Visual Studio fordert Sie vor der Ausführung zur Wahl des Startelements auf. Wer das Popup-Fenster aktiviert, sieht, dass das NMG-Ziel für die zum Debugging vorgesehene Anwendung bereitsteht.Nach dem ersten Lauf wirft Visual Studio als eine Folge der strengen Codeüberprüfung den Fehler „worker“: Muss einen Wert zurückgeben. Erfreulicherweise ist die nötige Korrektur recht einfach:
int worker() {
cout << "Hello, Unter Windows";
return 2;
}
Starten Sie danach einen Testlauf, um sich vom korrekten Funktionieren des Programms zu überzeugen – es wird nun, wie in Bild 8 gezeigt, einen Windows-Gruß ausgeben.

Die Windows-Versionfunktioniert problemlos(Bild 8)
Autor
Und jetzt mit Test
Test-Frameworks haben im Allgemeinen eine sehr enge Beziehung zum Ökosystem beziehungsweise zur Programmiersprache. Aus der Logik folgt, dass das im Kompilationsprozess keine Rolle spielende CMake eine etwas andere Vorgehensweise wählen muss.Aus Sicht des Build-Systems ist ein Test ein mehr oder weniger „alleinstehendes“ Programm, das im einfachsten Betriebsmodus über das Zurückgeben eines Return-Codes über Erfolg und Scheitern der in ihm enthaltenen Testfälle informiert. Die zur Deklaration eines Testfalls vorgesehene Methode sieht so aus:
add_test(NAME testName
COMMAND command [arg...]
[CONFIGURATIONS config1 [config2...]]
[WORKING_DIRECTORY dir]
)
Neben dem in CMake so gut wie überall erforderlichen Namen wird auch ein COMMAND erwartet – dabei handelt es sich um den String, der den anzuwerfen Befehl anliefert. Zudem gibt es noch die Möglichkeit, Konfigurationsoptionen und ein Arbeitsverzeichnis festzulegen. Die in Großbuchstaben gehaltenen Teile dienen als Kommandotrenner – ein eigenwilliger Aspekt der Syntax.Im nächsten Schritt wird die Datei tester.cpp gebraucht, in der das folgende Programm untergebracht wird:
#include <iostream>
int main()
{
return 0;
}
Öffnen Sie danach die Steuerungsdatei, in der Sie die folgenden Elemente unterbringen:
ENABLE_TESTING()
add_executable(test1 tester.cpp)
add_test(NAME SmallTest1 COMMAND testapp)
Für das Bereitstellen beziehungsweise Aktivieren des Testsystems müssen Sie das Makro ENABLE_TESTING() platzieren. Danach folgt die Deklaration eines weiteren Executables, in dem der Testfall unterkommt. Zu guter Letzt sorgt das Kommando add_test für die Ausführung.An dieser Stelle können Sie einen ersten Testlauf befehlen. Beachten Sie, dass die Aktivierung hier über das Kommando ctest erfolgen muss:
tamhan@tamhan-thinkpad:~/cmakespace/build$ ctest
Test project /home/tamhan/cmakespace/build
Start 1: SmallTest1
Could not find executable testapp
Nach dem Anpassen der Deklaration von add_test sehen Sie das in Bild 9 gezeigte Ergebnis, das über das erfolgreiche Abarbeiten der Testfälle informiert.

Die integrierte Testunterstützungfunktioniert!(Bild 9)
Autor
Fazit
CMake hat eine herausragende Stellung unter den Build-Systemen. Wer einmal eine vollwertige Programmiersprache anstelle von .configure-Skripten zur Hand hat, kehrt nur ungern zur „altmodischen“ Vorgehensweise zurück. Schon aus Platzgründen lässt sich hier keine vollständige Beschreibung des Systems liefern – der Autor hofft allerdings, dass er Sie zu eigenständigen Experimenten animieren konnte.Fussnoten
- CMake, https://cmake.org
- GPIO-Samples für Azure Sphere (Textdatei), http://www.dotnetpro.de/SL2006CMake1
- How To Write Platform Checks, http://www.dotnetpro.de/SL2006CMake2