Post

qmake/cmake Buildsystem-Konfigurationen für Qt-basierte dynamisch gelinkte Bibliotheken und gesamte Sitzungen/Projektmappen

qmake/cmake Buildsystem-Konfigurationen für Qt-basierte dynamisch gelinkte Bibliotheken und gesamte Sitzungen/Projektmappen

Wenn man größere Qt Projekte umsetzt, wird man sicherlich Teilkomponenten in Bibliotheken auslagern. Reine C++ Bibliotheken ohne Qt-Abhängigkeiten kann man einfach statisch ohne größere Probleme in das Programm linken. Bei Qt-basierten Bibliotheken bieten sich dynamisch gelinkte Bibliotheken an. Dieser Blogartikel beschreibt, wie man sowas mit den Buildsystemkonfigurationen qmake und cmake plattformübergreifend und für übliche Toolchains konfiguriert.

Zielstellung

Eine Qt-basierte Bibliothek soll als dynamisch gelinkte Bibliothek erstellt werden und in ein Programm gelinkt werden. Beide Projekte sollten zusammen in einer Sitzung verwaltet werden, unter folgenden Maßgaben:

  • es wird ausschließlich out-of-source gebaut, so wie das QtCreator standardmäßig macht, und die erstellten Dateien in /build/Desktop-Debug o.ä. ablegt. Bei cmake wird ebenfalls in einem separaten Erstellungsverzeichnis gebaut
  • die Binaries/Executables und die DLLs (unter Windows) sollen top-level unter /bin/debug oder /bin/release abgelegt werden. Es ist ok, wenn die Binaries von zwei separaten Toolchains (bspw. VisualStudio-Debug oder MinGW-Debug) überschreiben. Die Bibliotheken und alle generierten Dateien sollen aber separat in individuellen Shadow-Build-Verzeichnissen liegen. Das könnte man auch anders machen (binaries auch in den Shadow-Build-Ordnern ablegen), aber für das Debuggen und Zugriff auf Resourcen im Entwicklungsverzeichnis ist das pragmatisch, den relativen Pfad zum Entwicklungswurzelverzeichnis zu kennen, unabhängig davon, wo die Shadow-Build-Verzeichnisse liegen.

Ein Beispielprojekt

Um die Konfigurationsdateien und die kritischen Parameter zu erklären (und zu testen), hab ich ein kleines Testprogramm vorbereitet, welches als komplettes Beispiel herunterladbar ist:

Die Verzeichnisstruktur sieht so aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
├── CMakeLists.txt
├── SharedLibraryBuildSystemConfigs.pro
├── bin/
├── build/
├── libs
│   ├── build.cmake
│   ├── build.pri
│   └── TestLib
│       ├── CMakeLists.txt
│       ├── resources
│       │   ├── gfx
│       │   │   ├── green.png
│       │   │   ├── red.png
│       │   │   └── yellow.png
│       │   └── TestLib.qrc
│       ├── src
│       │   ├── LEDLabel.cpp
│       │   ├── LEDLabel.h
│       │   └── TestLib_global.h
│       └── TestLib.pro
└── TestApp
    ├── CMakeLists.txt
    ├── src
    │   ├── main.cpp
    │   ├── MainWindow.cpp
    │   └── MainWindow.h
    └── TestApp.pro

Die *.pro und build.pri gehören zum qmake Buildsystem. Die CMakeLists.txt-Dateien und build.cmake gehören zum cmake Buildsystem.

Standard-Verzeichnisstruktur

Die Verzeichnisstruktur folgt einem einfachen Schema, das sich beliebig auf weitere Bibliotheken und Anwendungen erweitern lässt:

Anwendungen (Executables) liegen jeweils in einem eigenen Verzeichnis auf oberster Ebene, hier TestApp/. Bibliotheken werden dagegen alle gemeinsam unterhalb von libs/ gruppiert, hier also libs/TestLib/. Diese Trennung macht auf einen Blick klar, was am Ende ein eigenständiges Programm ergibt und was nur als Baustein dafür dient.

Innerhalb jedes Projektverzeichnisses (egal ob App oder Lib) wird die gleiche, grundlegende Verzeichnisstruktur verwendet:

  • src/ enthält sämtliche Quelltext- und Headerdateien, also bei TestLib die Dateien LEDLabel.h/.cpp und TestLib_global.h, bei TestApp entsprechend main.cpp und MainWindow.h/.cpp.
  • resources/ enthält alle Ressourcen, die über das Qt-Resource-System eingebunden werden – bei TestLib also das Verzeichnis gfx mit den PNG-Grafiken sowie die zugehörige TestLib.qrc.
  • Auf Ebene des Projektverzeichnisses selbst liegen die Build-Konfigurationsdateien für beide Buildsysteme: eine *.pro-Datei für qmake (TestLib.pro, TestApp.pro) und eine CMakeLists.txt für cmake. Auf die Inhalte dieser Dateien gehe ich in den folgenden Abschnitten im Detail ein.
  • Als Namenskonvention gilt: Der Name der .pro-Datei ergibt sich aus dem Namen des Projektverzeichnisses, hier also TestLib -> TestLib.pro und TestApp -> TestApp.pro.

Zusätzlich gibt es zwei projektübergreifende Dateien direkt im Wurzelverzeichnis der Sitzung: SharedLibraryBuildSystemConfigs.pro bindet als qmake-subdirs-Projekt die Unterprojekte libs/TestLib und TestApp zusammen, während die CMakeLists.txt im Wurzelverzeichnis die entsprechende Klammer für cmake bildet. Im Verzeichnis libs/ liegen außerdem build.pri und build.cmake – gemeinsam genutzte Konfigurationsfragmente, die von allen Bibliotheks- und App-Projekten eingebunden werden und die eigentliche Build-Logik (Ausgabepfade, Compiler-Flags usw.) an zentraler Stelle bündeln.

QMAKE

Zunächst wird der Quelltext und das qmake-Buildsystem erklärt. Wir beginnen mit dem Quelltext der Bibliothek.

Die TestLib Bibliothek

Quelltext und Headerdateimakros

Jede Bibliothek bringt eine eigene <LibName>_global.h mit, hier TestLib_global.h:

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef TESTLIB_GLOBAL_H
#define TESTLIB_GLOBAL_H

#include <QtCore/qglobal.h>

#if defined(TESTLIB_LIBRARY)
#  define TESTLIB_EXPORT Q_DECL_EXPORT
#else
#  define TESTLIB_EXPORT Q_DECL_IMPORT
#endif

#endif // TESTLIB_GLOBAL_H

Das darin definierte Makro TESTLIB_EXPORT wird vor jede Klasse gesetzt, die von außerhalb der Bibliothek sichtbar sein soll, also in LEDLabel.h:

1
2
3
4
5
#include "TestLib_global.h"

class TESTLIB_EXPORT LEDLabel : public QWidget {
    ...
};

Der Hintergrund: Bei dynamisch gelinkten Bibliotheken muss der Compiler wissen, welche Symbole (Klassen, Funktionen) die Bibliothek nach außen exportiert, damit sie in der DLL/.so überhaupt auftauchen und von anderen Programmen aufgelöst werden können. Q_DECL_EXPORT und Q_DECL_IMPORT sind Qt-Makros, die genau das plattformabhängig übernehmen – unter Windows expandieren sie zu __declspec(dllexport) bzw. __declspec(dllimport), unter Linux/macOS sind sie (mit sichtbarkeitsbasiertem Compiler-Setup) weitgehend leer, da dort standardmäßig alle Symbole einer .so sichtbar sind.

Entscheidend ist nun, dass dieselbe Headerdatei sowohl beim Bauen der Bibliothek als auch beim Verwenden der Bibliothek (im Anwendungsprojekt) eingebunden wird – aber mit jeweils anderer Bedeutung des Makros. Deshalb steuert das Define TESTLIB_LIBRARY per #if defined(...), welcher der beiden Fälle gerade vorliegt:

  • Beim Übersetzen der Bibliothek selbst muss TESTLIB_LIBRARY gesetzt sein, damit TESTLIB_EXPORT zu Q_DECL_EXPORT wird und die Klasse exportiert wird. In TestLib.pro geschieht das über:
    1
    
    DEFINES += TESTLIB_LIBRARY
    
  • Im Anwendungsprojekt (TestApp), das die Bibliothek nur benutzt, wird TESTLIB_LIBRARY nicht gesetzt. Dort expandiert TESTLIB_EXPORT also zu Q_DECL_IMPORT, wodurch der Compiler weiß, dass die Symbole aus einer externen DLL importiert werden.

Wichtig ist dabei, dass DEFINES += TESTLIB_LIBRARY ausschließlich in der .pro-Datei der Bibliothek selbst steht und nicht etwa in einer gemeinsam genutzten Konfigurationsdatei wie build.pri landet – sonst würde das Define auch beim Bauen von TestApp gesetzt und die Importdeklaration ginge verloren, was zu Linkerfehlern führt.

Unter MinGW würde der Linker (ld) ohne jegliche dllexport-Markierung automatisch alle Symbole exportieren. Sobald aber – wie hier – auch nur ein Symbol explizit mit dllexport markiert wird, gilt dieser Automatismus nicht mehr, und nur die markierten Symbole werden exportiert. Die Makros bleiben also auch bei MinGW sinnvoll, um gezielt zu steuern, was exportiert wird, statt versehentlich alle internen Symbole offenzulegen. Quelle: binutils ld docs — WIN32 (cygwin/mingw)

pro-Datei und gemeinschaftlich genutzte pri-Datei

Die TestLib.pro sieht wie folgt aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
TEMPLATE = lib
# TARGET ist optional. Ohne Angabe wird der aktuelle Verzeichnisname als 
# Target verwendet. Dank unserer Namenskonvention 
# <Verzeichnisname> -> <VerzeichnisName>.pro könnte man das also weglassen.
TARGET = TestLib

QT += widgets

# Allgemeine Einstellungen, muss nach der `TEMPLATE` Zeile eingebunden werden
include( ../build.pri )

# Setze das Define um die Bibliothek zu erstellen (nur in dieser pro-Datei)
DEFINES += TESTLIB_LIBRARY

SOURCES += \
    src/LEDLabel.cpp

HEADERS += \
    src/LEDLabel.h \
    src/TestLib_global.h

RESOURCES += \
    resources/TestLib.qrc

Gleich zu Beginn, aber nach TEMPLATE=lib wird in Zeile include( ../build.pri ) eine Datei mit allgemeinen Einstellungen eingebunden. Alles Projektspezifische (TEMPLATE, TARGET, QT, DEFINES, SOURCES, HEADERS, RESOURCES) steht direkt in der .pro-Datei der Bibliothek. Alles, was dagegen für sämtliche Bibliotheken und Anwendungen der Sitzung gleichermaßen gilt – insbesondere die Ausgabepfade –, ist in build.pri ausgelagert und wird hier lediglich eingebunden. So muss diese Logik nicht in jeder einzelnen .pro-Datei dupliziert werden, sondern wird an zentraler Stelle gepflegt.

Die build.pri sieht wie folgt aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# Gemeinsame Build-Konfiguration, die von allen Bibliotheks- und 
# Anwendungs-Projektdateien eingebunden wird.
#
# Legt die gemeinsamen Ausgabeverzeichnisse fest und, für Anwendungen, 
# den Suchpfad, der zum Linken gegen die in diesem Repository gebauten 
# dynamischen Bibliotheken benötigt wird.
#
# Verwendung (in einer .pro-Datei): include( <relativer Pfad>/libs/build.pri )

# C++17 wird für Qt6 benötigt
CONFIG += c++17

# Bibliotheken werden im Unterverzeichnis 'lib' des Build-Verzeichnisses abgelegt, damit
# alle generierten Dateien einer Build-Konfiguration zusammenbleiben und der Quellcode-Baum
# sauber bleibt. Bei einem Shadow-Build, wie ihn QtCreator standardmäßig anlegt, z.B.
# build/Desktop-Debug, landen die Bibliotheken also in build/Desktop-Debug/lib.
# Auf diese Weise können unterschiedliche Build-Konfigurationen (debug/release) 
# koexistieren, ohne sich gegenseitig die Bibliotheken zu überschreiben.
#
# Executables werden in bin/debug bzw. bin/release abgelegt.

# oberstes Build-Verzeichnis: das Shadow-Build-Verzeichnis, das dem Wurzelverzeichnis
# der Sitzung entspricht, z.B. build/Desktop-Debug
# Hinweis: dies setzt einen Shadow-Build voraus, wie er standardmäßig in 
# QtCreator konfiguriert ist.
BUILD_ROOT = $$shadowed($$PWD/..)

# Verzeichnisse, die alle dynamischen Bibliotheken bzw. alle Executables 
# dieser Sitzung aufnehmen
LIB_DIR = $$BUILD_ROOT/lib
CONFIG( debug, debug|release ) {
	BIN_DIR = $$PWD/../bin/debug
}
else {
	BIN_DIR = $$PWD/../bin/release
}

# Relative Unterverzeichnisse, um generierte Dateien zu sortieren (optional)
OBJECTS_DIR = obj
MOC_DIR = moc
RCC_DIR = rcc
UI_DIR = ui

# Einstellungen für Bibliotheksprojekte
equals( TEMPLATE, lib ) {
	DESTDIR = $$LIB_DIR
	# DLLs werden im gleichen Ort wie Binaries/EXE-Dateien abgelegt
	DLLDESTDIR = $$BIN_DIR
}

# Einstellungen für Apps
else:equals( TEMPLATE, app ) {

	DESTDIR = $$BIN_DIR

	# Suchpfad für die dynamischen Bibliotheken beim Linken
	LIBS += -L$$LIB_DIR

	# teilt dem dynamischen Linker mit, wo er die dynamischen Bibliotheken zur 
	# Laufzeit findet; so kann die Programmdatei unter Linux gestartet werden, 
	# ohne dass das Bibliotheksverzeichnis zusätzlich in LD_LIBRARY_PATH 
	# eingetragen werden müsste
	unix {
		QMAKE_RPATHDIR += $$LIB_DIR
	}

}

Die Datei Abschnitt für Abschnitt:

  • CONFIG += c++17 legt den Sprachstandard fest, den alle Teilprojekte der Sitzung verwenden sollen – zentral an einer Stelle definiert, statt in jeder .pro-Datei erneut.

  • BUILD_ROOT = $$shadowed($$PWD/..): Die qmake-Funktion $$shadowed(...) übersetzt einen Pfad im Quellverzeichnis in den entsprechenden Pfad innerhalb des aktuellen Shadow-Build-Verzeichnisses. $$PWD ist dabei stets das Verzeichnis, in dem die gerade verarbeitete .pro/.pri-Datei liegt – hier also libs/. $$PWD/.. zeigt somit auf das Wurzelverzeichnis der Sitzung, und $$shadowed(...) liefert dazu den passenden Pfad im aktiven Shadow-Build-Verzeichnis, egal ob gerade build/Desktop-Debug, build/Desktop-Release oder ein anders benanntes Verzeichnis aktiv ist. BUILD_ROOT zeigt also immer auf das Shadow-Build-Verzeichnis, das dem Wurzelverzeichnis der Sitzung entspricht.

  • LIB_DIR = $$BUILD_ROOT/lib: Alle Bibliotheken landen gemeinsam in einem lib-Unterverzeichnis des Shadow-Build-Verzeichnisses. Da BUILD_ROOT bereits pro Build-Konfiguration unterschiedlich ist (Debug und Release liegen ja in unterschiedlichen Shadow-Build-Verzeichnissen), ist auch LIB_DIR automatisch pro Konfiguration getrennt.

  • BIN_DIR: Im Gegensatz zu LIB_DIR liegt dieses Verzeichnis nicht im Shadow-Build-Verzeichnis, sondern fest unterhalb des Quellverzeichnisses ($$PWD/../bin/debug bzw. .../bin/release), unabhängig davon, mit welcher Toolchain oder welchem Shadow-Build-Verzeichnis gerade gebaut wird. Die Bedingung CONFIG( debug, debug|release ) unterscheidet zwischen Debug- und Release-Build. Das entspricht genau der eingangs formulierten Zielsetzung: Alle Executables und DLLs landen gebündelt in bin/debug bzw. bin/release, auch wenn zwei unterschiedliche Toolchains (z.B. MSVC-Debug und MinGW-Debug) sich dabei gegenseitig überschreiben. Das wird hier bewusst in Kauf genommen.

  • OBJECTS_DIR, MOC_DIR, RCC_DIR, UI_DIR: Diese vier Variablen legen fest, wohin qmake die beim Bauen anfallenden Zwischendateien ablegt. Objektdateien, MOC-generierte Dateien (aus dem Qt-Meta-Object-Compiler), RCC-generierte Dateien (aus kompilierten .qrc-Ressourcen) und UI-generierte Header (aus .ui-Dateien). Da diese Pfade relativ angegeben sind, werden sie relativ zum jeweiligen Shadow-Build-Verzeichnis des Teilprojekts aufgelöst. Die Zwischendateien von TestLib und TestApp kollidieren also nicht, obwohl beide dieselben Variablennamen obj, moc usw. verwenden.

  • Der Block equals( TEMPLATE, lib ) { ... } else:equals( TEMPLATE, app ) { ... } unterscheidet anhand des Werts von TEMPLATE, ob die aktuell verarbeitete .pro-Datei eine Bibliothek oder eine Anwendung beschreibt, und setzt je nachdem unterschiedliche Variablen:

    • Für Bibliotheken (TEMPLATE = lib) wird DESTDIR auf LIB_DIR gesetzt – dorthin baut qmake also die eigentliche .so/.dll/.dylib. Unter Windows wird zusätzlich DLLDESTDIR auf BIN_DIR gesetzt: qmake kopiert die erzeugte DLL dann automatisch zusätzlich in dieses Verzeichnis, sodass sie dort neben der .exe liegt und zur Laufzeit gefunden wird.
    • Für Anwendungen (TEMPLATE = app) wird DESTDIR auf BIN_DIR gesetzt – die Programmdatei landet also direkt im gemeinsamen bin/debug- bzw. bin/release-Verzeichnis. Außerdem wird LIBS += -L$$LIB_DIR ergänzt, damit der Linker beim Bauen der Anwendung weiß, wo er nach den Bibliotheken suchen soll. Unter Unix-artigen Systemen wird zusätzlich QMAKE_RPATHDIR += $$LIB_DIR gesetzt: Damit wird der Pfad zu LIB_DIR fest als RPATH in die erzeugte Programmdatei eingetragen, sodass der dynamische Linker die .so-Datei zur Laufzeit findet, ohne dass LD_LIBRARY_PATH gesetzt werden müsste.

Wie wirkt sich das nun konkret auf TestLib.pro aus? Da diese Datei mit TEMPLATE = lib beginnt, sorgt include( ../build.pri ) dafür, dass beim Bauen von TestLib automatisch DESTDIR = $$LIB_DIR und DLLDESTDIR = $$BIN_DIR gesetzt werden, ohne dass in TestLib.pro selbst auch nur ein Wort über Ausgabepfade steht. Die .pro-Datei der Bibliothek kann sich dadurch ganz auf das Projektspezifische beschränken: den Namen (TARGET), die verwendeten Qt-Module (QT += widgets), das Export-Define (DEFINES += TESTLIB_LIBRARY) sowie die Quell-, Header- und Ressourcendateien. Genau dieselbe build.pri bindet, wie im nächsten Abschnitt gezeigt, auch TestApp.pro ein. Dort greift dann aber der app-Zweig der Fallunterscheidung, und statt DESTDIR/DLLDESTDIR werden LIBS und QMAKE_RPATHDIR gesetzt, damit die Anwendung die Bibliothek zur Link- und zur Laufzeit findet.

Das TestApp Programm und die Sitzungs-pro-Datei

pro-Datei des Programms und Linken der Bibliothek

Die TestApp.pro sieht wie folgt aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
TEMPLATE = app
# Optional, siehe Erkläung in TestLib.pro
TARGET = TestApp

QT += widgets

# Allgemeine Einstellungen, muss nach der `TEMPLATE` Zeile eingebunden werden
include( ../libs/build.pri )

SOURCES += \
	src/main.cpp \
	src/MainWindow.cpp

HEADERS += \
	src/MainWindow.h

# Include-Pfad zur Bibliothek hinzufügen
INCLUDEPATH += ../libs/TestLib/src

# Linke gegen die Bibliothek, Suchpfad ist bereits in libs/build.pri definiert
LIBS += -lTestLib

Auch hier gilt wieder das gleiche Muster wie bei TestLib.pro: TEMPLATE = app steht ganz am Anfang, direkt danach folgt include( ../libs/build.pri ). Weil TEMPLATE beim Einbinden bereits auf app gesetzt ist, greift in build.pri diesmal der equals( TEMPLATE, app )-Zweig. DESTDIR wird also auf BIN_DIR gesetzt, und LIBS += -L$$LIB_DIR sowie (unter Unix) QMAKE_RPATHDIR += $$LIB_DIR werden automatisch ergänzt, ohne dass TestApp.pro selbst etwas davon wissen muss.

Damit TestApp tatsächlich gegen TestLib linkt, sind noch zwei projektspezifische Ergänzungen nötig, die naturgemäß nicht in der gemeinsamen build.pri stehen können, da sie ja pro Anwendung/Bibliothek unterschiedlich sind:

  • INCLUDEPATH += ../libs/TestLib/src fügt das Quellverzeichnis von TestLib zum Include-Suchpfad hinzu (relativ zum Verzeichnis der TestApp.pro), damit MainWindow.cpp die Headerdatei LEDLabel.h per #include <LEDLabel.h> finden kann, ohne einen langen relativen Pfad angeben zu müssen.
  • LIBS += -lTestLib weist den Linker an, gegen TestLib zu linken. Den Suchpfad dafür (-L$$LIB_DIR) hat bereits build.pri gesetzt – hier muss also nur noch der Bibliotheksname selbst angegeben werden. Zu beachten ist, dass sich -lTestLib unter Windows und Linux gleichermaßen auf den logischen Bibliotheksnamen bezieht (qmake ergänzt automatisch die passenden Präfixe/Suffixe wie lib...so bzw. ...dll/...lib), sodass diese Zeile plattformübergreifend unverändert funktioniert.

Wichtig bei Verwendung des ld-Linkers unter Linux-GCC oder bei MinGW ist, dass die Reihenfolge der Bibliotheken in LIBS += ... korrekt sein muss. Bibliotheken, welche Symbole aus anderen Bibliotheken einbinden, müssen vor diesen aufgeführt werden.

Die Sitzungs-pro-Datei

Damit QtCreator (bzw. qmake direkt) beide Projekte, TestLib und TestApp, in einem Rutsch als zusammengehörige Sitzung öffnen und bauen kann, gibt es im Wurzelverzeichnis noch die SharedLibraryBuildSystemConfigs.pro:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Sitzungs-Datei, erstellt die Bibliothek und die darauf aufbauende Anwendung.

TEMPLATE = subdirs

SUBDIRS = \
	TestLib \
	TestApp

TestLib.file = libs/TestLib/TestLib.pro

# Der Pfad zur TestApp.pro-Datei muss nicht explizit angegeben werden, siehe
# Erklärung im Text.
TestApp.file = TestApp/TestApp.pro

# Die Anwendung verlinkt die Bibliothek, also erstelle diese als Abhängigkeit zuerst.
TestApp.depends = TestLib

TEMPLATE = subdirs markiert diese .pro-Datei als reines Sammelprojekt: Sie enthält selbst keinen Quelltext, sondern bündelt nur andere Projekte. SUBDIRS listet dazu die Kurznamen der Unterprojekte auf, TestLib.file und TestApp.file verweisen jeweils auf die zugehörige .pro-Datei relativ zum Wurzelverzeichnis. Dies ist immer dann notwendig, wenn die Unterprojekte (wie bei TestLib) nicht direkt unterhalb der Sitzungsdatei liegen, sondern in libs/TestLib. Bei TestApp kann das entfallen, da die .pro-Datei ja direkt in TestApp/TestApp.pro liegt.

Wichtig ist die letzte Zeile, TestApp.depends = TestLib: Da TestApp beim Linken auf die bereits gebaute TestLib-Bibliothek angewiesen ist, muss qmake sicherstellen, dass TestLib zuerst gebaut wird. Ohne diese explizite Abhängigkeit könnten – insbesondere bei parallelen Builds (make -j) – beide Teilprojekte gleichzeitig gebaut werden, und der Build von TestApp würde fehlschlagen, weil die Bibliothek zum Zeitpunkt des Linkens noch nicht existiert.

CMAKE

Analog zum qmake Buildsystem kann man das Projekt auch mit cmake konfigurieren. Die Struktur ist dabei ähnlich: für jedes Teilprojekt (TestLib und TestApp) gibt es je eine CMakeLists.txt-Datei. Gemeinsame Definitionen werden in einer cmake-Include-Datei abgelegt build.cmake und es gibt eine übergeordnete Sitzungsdatei CMakeLists.txt im Wurzelverzeichnis.

Die TestLib Bibliothek

Der Quelltext von TestLib ist identisch zum qmake-Beispiel oben, insbesondere die Export-/Import-Logik über TestLib_global.h und das Define TESTLIB_LIBRARY. Was sich unterscheidet, ist ausschließlich die Buildsystem-Konfiguration.

CMakeLists.txt und gemeinschaftlich genutzte build.cmake

Die libs/TestLib/CMakeLists.txt sieht wie folgt aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Allgemeine Einstellungen, legt die Ausgabeverzeichnisse fest
include( ${CMAKE_CURRENT_LIST_DIR}/../build.cmake )

file( GLOB SRCS src/*.cpp )
file( GLOB MOC_HDRS src/*.h )

add_library(TestLib SHARED
	${SRCS}
	${MOC_HDRS}
	resources/TestLib.qrc
)

# gleiches Versionierungsschema, das qmake standardmäßig anwendet -> libTestLib.so.1.0.0
set_target_properties(TestLib PROPERTIES
	VERSION 1.0.0
	SOVERSION 1
)

# Setze das Define um die Bibliothek zu erstellen (nur in der eigenen Projektdatei
# der Bibliothek). PRIVATE ist hier essentiell: Targets, die gegen die Bibliothek
# linken, dürfen dieses Define nicht sehen, damit sie die Import- statt der
# Export-Deklaration erhalten.
target_compile_definitions(TestLib PRIVATE TESTLIB_LIBRARY)

# öffentliche Header der Bibliothek, propagiert an alles, was dagegen linkt
target_include_directories(TestLib PUBLIC ${CMAKE_CURRENT_LIST_DIR}/src)

# die Bibliothek exponiert Qt-Typen in ihren öffentlichen Headern, daher PUBLIC
target_link_libraries(TestLib PUBLIC Qt6::Widgets)

Auch hier gilt das gleiche Prinzip wie bei TestLib.pro: Ganz oben wird mit include( ${CMAKE_CURRENT_LIST_DIR}/../build.cmake ) eine gemeinsam genutzte Datei eingebunden, die die Ausgabepfade und weitere sitzungsweite Einstellungen festlegt. ${CMAKE_CURRENT_LIST_DIR} ist dabei das cmake-Gegenstück zu $$PWD bei qmake – es zeigt immer auf das Verzeichnis der gerade verarbeiteten CMakeLists.txt-Datei, unabhängig davon, von wo aus sie über add_subdirectory() eingebunden wurde.

Alles Projektspezifische steht wieder direkt in der CMakeLists.txt der Bibliothek:

  • file( GLOB SRCS src/*.cpp ) und file( GLOB MOC_HDRS src/*.h ) sammeln die Quell- und Headerdateien. Im Gegensatz zu qmakes SOURCES += \ ...-Listen wird hier mit GLOB gearbeitet, sodass neue Dateien nicht manuell eingetragen werden müssen. Der Nachteil: Nach dem Hinzufügen einer neuen Datei muss cmake neu konfiguriert werden (cmake -S . -B build/...), da GLOB das Verzeichnis nur zum Konfigurationszeitpunkt einliest und Änderungen am Dateibestand sonst nicht bemerkt. Für ein wachsendes Projekt ist das ein bewusster Kompromiss aus Bequemlichkeit und Robustheit – alternativ könnte man die Dateien wie bei qmake explizit auflisten.
  • add_library(TestLib SHARED ...) erzeugt das eigentliche Bibliotheks-Target. Die .qrc-Datei wird direkt mit als Quelle angegeben; da weiter unten CMAKE_AUTORCC aktiviert ist (siehe build.cmake), erkennt cmake automatisch, dass daraus generierter Code erzeugt und mit übersetzt werden muss – analog dazu, wie qmake RESOURCES += ... implizit behandelt.
  • set_target_properties(... VERSION 1.0.0 SOVERSION 1) sorgt dafür, dass die erzeugte Bibliothek unter Linux mit der gleichen Versionierung wie im qmake-Build entsteht (libTestLib.so.1.0.0 mit Symlink libTestLib.so.1 und libTestLib.so). Ohne diese Angabe würde cmake keine Versionssuffixe anhängen.
  • target_compile_definitions(TestLib PRIVATE TESTLIB_LIBRARY) setzt das Export-Define. Entscheidend ist hier PRIVATE: Das Define gilt nur beim Übersetzen von TestLib selbst, wird aber nicht an Targets weitergereicht, die später gegen TestLib linken. Das entspricht genau dem Verhalten von qmake, wo DEFINES += TESTLIB_LIBRARY ebenfalls nur in TestLib.pro steht.
  • target_include_directories(TestLib PUBLIC ${CMAKE_CURRENT_LIST_DIR}/src) macht das src-Verzeichnis der Bibliothek als Include-Pfad verfügbar – und zwar nicht nur für TestLib selbst, sondern dank PUBLIC auch für jedes Target, das später per target_link_libraries() gegen TestLib linkt. Das ist ein wesentlicher konzeptioneller Unterschied zu qmake: Dort musste TestApp.pro den Include-Pfad zur Bibliothek noch von Hand über INCLUDEPATH += ../libs/TestLib/src ergänzen. Bei cmake genügt es, diese Anforderung einmal am Bibliotheks-Target selbst zu formulieren – sie “reist” dann automatisch mit dem Target mit, siehe Abschnitt zu TestApp unten.
  • target_link_libraries(TestLib PUBLIC Qt6::Widgets) verlinkt gegen die Qt6::Widgets-Bibliothek. Auch hier wieder PUBLIC, weil LEDLabel.h (ein öffentlicher Header von TestLib) selbst Qt-Widgets-Typen wie QWidget verwendet – wer TestLib.h einbindet, braucht also ebenfalls die Include-Pfade und (beim Linken) die Bibliothek von Qt6::Widgets. Wäre Qt nur intern in der .cpp-Datei verwendet worden, hätte PRIVATE genügt.

PUBLIC, PRIVATE und INTERFACE sind der Kern des modernen, targetbasierten cmake: Jede Eigenschaft eines Targets (Include-Pfade, Defines, verlinkte Bibliotheken, Compile-Optionen) wird als PRIVATE (nur für dieses Target selbst), PUBLIC (für dieses Target und alle, die dagegen linken) oder INTERFACE (nur für die, die dagegen linken, nicht für das Target selbst) deklariert. Dadurch propagieren sich Anforderungen automatisch durch den Abhängigkeitsgraphen, ohne dass jedes abhängige Projekt sie erneut angeben müsste.

Die libs/build.cmake ist das cmake-Gegenstück zu build.pri:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# Gemeinsame Build-Konfiguration, die von allen Bibliotheks- und
# Anwendungs-CMake-Dateien eingebunden wird.
#
# Legt die gemeinsamen Ausgabeverzeichnisse fest und, für Anwendungen, den
# Suchpfad, der zum Linken gegen die in diesem Repository gebauten
# dynamischen Bibliotheken benötigt wird.
# Dies ist das cmake-Gegenstück zu build.pri und folgt den gleichen Konventionen.
#
# Verwendung (in einer CMakeLists.txt):    
#   include( ${CMAKE_CURRENT_LIST_DIR}/<relativer Pfad>/libs/build.cmake )

# Die unten gesetzten Ausgabeverzeichnis-Variablen haben Verzeichnisscope und
# werden gelesen, wenn ein Target angelegt wird - deshalb muss diese Datei von
# jeder CMakeLists.txt eingebunden werden, die ein Target erzeugt. Der Guard
# gilt daher pro Verzeichnis, nicht global.
include_guard(DIRECTORY)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# führe die Qt-Codegeneratoren automatisch für alle Targets aus; dies ersetzt
# die Behandlung, die qmake implizit für SOURCES/HEADERS/RESOURCES/FORMS übernimmt
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)

# Wurzelverzeichnis des Repositories; diese Datei liegt in <Wurzel>/libs, ihr
# eigenes Verzeichnis ist also der Ankerpunkt - genau wie $$PWD in build.pri
# hängt dies nicht davon ab, welche CMakeLists.txt diese Datei einbindet
get_filename_component(SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)

get_property(IS_MULTI_CONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)

# Verzeichnisse, die alle dynamischen Bibliotheken bzw. alle Executables
# dieser Sitzung aufnehmen; der Generator-Ausdruck wählt das Executable-
# Verzeichnis anhand der Konfiguration aus und unterdrückt dabei zugleich das
# Konfigurations-Unterverzeichnis, das Multi-Config-Generatoren sonst anhängen würden
set(LIB_DIR "${CMAKE_BINARY_DIR}/lib")
set(BIN_DIR "${SOURCE_ROOT}/bin/$<IF:$<CONFIG:Debug>,debug,release>")

# ARCHIVE betrifft statische Bibliotheken und die Windows-Importbibliotheken,
# LIBRARY die dynamischen Bibliotheken unter unix. RUNTIME betrifft die
# Executables und, unter Windows, auch die DLLs - was DLLDESTDIR in build.pri erreicht.
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${LIB_DIR}")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${LIB_DIR}")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${BIN_DIR}")

# teilt dem dynamischen Linker mit, wo er die dynamischen Bibliotheken zur
# Laufzeit findet; so kann die Programmdatei gestartet werden, ohne dass das
# Bibliotheksverzeichnis zusätzlich in LD_LIBRARY_PATH eingetragen werden müsste
if (UNIX)
	if (IS_MULTI_CONFIG)
		# Multi-Config-Generatoren hängen den Konfigurationsnamen an das
		# einfache (generator-ausdrucksfreie) Bibliotheksausgabeverzeichnis an
		set(CMAKE_BUILD_RPATH "${LIB_DIR}/$<CONFIG>")
	else()
		set(CMAKE_BUILD_RPATH "${LIB_DIR}")
	endif()
endif()

Auch diese Datei Abschnitt für Abschnitt:

  • include_guard(DIRECTORY) verhindert, dass die Datei innerhalb desselben Verzeichnisscopes mehrfach abgearbeitet wird. Wichtig ist dabei DIRECTORY statt des Standardverhaltens (global): Die hier gesetzten CMAKE_*_OUTPUT_DIRECTORY-Variablen wirken nur im Verzeichnisscope, in dem sie gesetzt wurden (und dessen Unterverzeichnissen) – deshalb muss build.cmake von jeder CMakeLists.txt, die ein Target anlegt, erneut eingebunden werden, ein globaler Guard würde das zweite und jedes weitere include() fälschlich unterdrücken.
  • CMAKE_CXX_STANDARD, CMAKE_CXX_STANDARD_REQUIRED, CMAKE_CXX_EXTENSIONS: das cmake-Gegenstück zu CONFIG += c++17. STANDARD_REQUIRED ON sorgt dafür, dass der Build hart fehlschlägt, wenn der Compiler C++17 nicht unterstützt, statt stillschweigend auf einen älteren Standard zurückzufallen. EXTENSIONS OFF deaktiviert compilerspezifische Erweiterungen (z.B. GNU-Extensions bei GCC) zugunsten von striktem Standard-C++.
  • CMAKE_AUTOMOC, CMAKE_AUTORCC, CMAKE_AUTOUIC: Diese drei Schalter aktivieren die automatische Behandlung von Qt-spezifischen Quelldateien – der Meta-Object-Compiler für Klassen mit Q_OBJECT, der Resource-Compiler für .qrc-Dateien und der UI-Compiler für .ui-Dateien. Bei qmake übernimmt das CONFIG += qt implizit; bei cmake muss man es explizit einschalten. Da diese drei Variablen ebenfalls Verzeichnisscope haben, gehören sie folgerichtig in dieselbe gemeinsame Datei wie die Ausgabepfade.
  • get_filename_component(SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) entspricht $$PWD/.. bei qmake und liefert das Wurzelverzeichnis der Sitzung als absoluten Pfad im Quellbaum.
  • get_property(IS_MULTI_CONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) fragt ab, ob der aktuell verwendete Generator ein Multi-Config-Generator ist (z.B. Visual Studio oder Ninja Multi-Config, die Debug und Release im selben Build-Verzeichnis verwalten) oder ein Single-Config-Generator (z.B. Unix Makefiles oder das einfache Ninja, bei denen CMAKE_BUILD_TYPE pro Build-Verzeichnis fest gewählt wird, so wie es die Sitzungs-Datei unten mit je einem Verzeichnis pro Konfiguration voraussetzt). Diese Unterscheidung hat bei qmake keine Entsprechung, da qmake grundsätzlich pro Shadow-Build-Verzeichnis genau eine Konfiguration kennt.
  • LIB_DIR = "${CMAKE_BINARY_DIR}/lib" ist das Gegenstück zu LIB_DIR = $$BUILD_ROOT/lib. CMAKE_BINARY_DIR ist dabei immer das oberste Build-Verzeichnis der gesamten Sitzung (das mit cmake -B ... angegebene Verzeichnis), unabhängig davon, aus welchem Unterverzeichnis heraus build.cmake gerade eingebunden wird – es gibt bei cmake also gar kein Gegenstück zu $$shadowed(...) nötig, da CMAKE_BINARY_DIR diese Rolle von Haus aus übernimmt.
  • BIN_DIR = "${SOURCE_ROOT}/bin/$<IF:$<CONFIG:Debug>,debug,release>" verwendet einen Generator-Ausdruck ($<...>), der erst beim Build (nicht schon bei der Konfiguration) ausgewertet wird. Das ist bei Multi-Config-Generatoren notwendig, weil dort die Konfiguration erst beim Aufruf von cmake --build ... --config Debug feststeht. $<IF:$<CONFIG:Debug>,debug,release> wählt je nach aktiver Konfiguration debug oder release – das Ergebnis entspricht exakt der CONFIG( debug, debug|release ) { ... } else { ... }-Verzweigung aus build.pri.
  • CMAKE_ARCHIVE_OUTPUT_DIRECTORY, CMAKE_LIBRARY_OUTPUT_DIRECTORY, CMAKE_RUNTIME_OUTPUT_DIRECTORY: cmake unterscheidet, anders als qmakes einzelnes DESTDIR, nach Art der Ausgabedatei statt nach Projekttyp. ARCHIVE betrifft statische Bibliotheken und, unter Windows, die Import-Bibliotheken (.lib), die zusammen mit einer DLL entstehen. LIBRARY betrifft die dynamischen Bibliotheken unter Unix (.so). RUNTIME betrifft Executables und, unter Windows, auch die DLLs selbst. Genau diese letzte Eigenart macht DLLDESTDIR aus build.pri überflüssig: Da unter Windows sowohl TestApp.exe als auch TestLib.dll als RUNTIME-Artefakt gelten, landen beide durch das einmalige Setzen von CMAKE_RUNTIME_OUTPUT_DIRECTORY = BIN_DIR automatisch im gleichen Verzeichnis. Unter Linux ist das irrelevant, da dort ohnehin RPATH zum Auffinden der .so-Datei verwendet wird.
  • Der if (UNIX) ... endif()-Block setzt CMAKE_BUILD_RPATH, das cmake-Gegenstück zu QMAKE_RPATHDIR. Er trägt LIB_DIR als RPATH in die beim Build erzeugten Binaries ein, damit diese die Bibliothek zur Laufzeit finden, ohne LD_LIBRARY_PATH setzen zu müssen. Die Fallunterscheidung nach IS_MULTI_CONFIG ist nötig, weil Multi-Config-Generatoren an LIB_DIR automatisch noch ein Unterverzeichnis je Konfiguration anhängen (lib/Debug, lib/Release) – etwas, das qmake dank separater Shadow-Build-Verzeichnisse pro Konfiguration gar nicht erst kennt.

Bei einem Single-Config-Generator (Unix Makefiles, Ninja) entspricht ein Build-Verzeichnis, analog zum qmake-Shadow-Build, genau einer festen Konfiguration, die bei cmake -S . -B build/Desktop-Debug -DCMAKE_BUILD_TYPE=Debug gewählt wird. Bei einem Multi-Config-Generator (Visual Studio, Ninja Multi-Config) verwaltet dagegen ein einziges Build-Verzeichnis alle Konfigurationen gleichzeitig, und die Konfiguration wird erst beim Aufruf von cmake --build build --config Debug festgelegt. build.cmake behandelt beide Fälle einheitlich, das Beispielprojekt selbst geht aber, analog zu qmake, von einem Build-Verzeichnis pro Konfiguration aus.

Da TestLib.CMakeLists.txt mit include( .../build.cmake ) beginnt, greifen CMAKE_LIBRARY_OUTPUT_DIRECTORY und CMAKE_RUNTIME_OUTPUT_DIRECTORY automatisch, sobald add_library(TestLib SHARED ...) das Target anlegt – ganz ohne dass in TestLib/CMakeLists.txt selbst ein Wort über Ausgabepfade fällt, genau wie im qmake-Pendant.

Das TestApp Programm und die Sitzungs-CMakeLists.txt

CMakeLists.txt des Programms und Linken der Bibliothek

Die TestApp/CMakeLists.txt sieht wie folgt aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Allgemeine Einstellungen, legt das Ausgabeverzeichnis und den Suchpfad für
# die Bibliothek zur Laufzeit fest
include( ${CMAKE_CURRENT_LIST_DIR}/../libs/build.cmake )

file( GLOB SRCS src/*.cpp )
file( GLOB MOC_HDRS src/*.h )

add_executable(TestApp
	${SRCS}
	${MOC_HDRS}
)

# Linke gegen die dynamische Bibliothek. Anders als im qmake-Projekt müssen
# hier weder Include-Pfad noch Bibliothekssuchpfad gesetzt werden - beide
# werden vom TestLib-Target propagiert, das cmake auch dazu veranlasst, die
# Bibliothek zuerst zu bauen.
target_link_libraries(TestApp PRIVATE
	TestLib
	Qt6::Widgets
)

Auch hier wieder das gleiche Muster: include( .../build.cmake ) steht am Anfang, add_executable() legt das Programm-Target an. Weil add_executable (statt add_library) verwendet wird, greift beim Bauen automatisch CMAKE_RUNTIME_OUTPUT_DIRECTORY = BIN_DIR aus build.cmakeTestApp landet also im selben bin/debug- bzw. bin/release-Verzeichnis wie schon bei qmake.

Der auffälligste Unterschied zu TestApp.pro ist, wie schlank target_link_libraries(TestApp PRIVATE TestLib Qt6::Widgets) ausfällt: Es fehlt sowohl ein Gegenstück zu INCLUDEPATH += ../libs/TestLib/src als auch zu LIBS += -L$$LIB_DIR -lTestLib. Das liegt genau an dem oben erwähnten PUBLIC bei target_include_directories() und target_link_libraries() in TestLib/CMakeLists.txt: Weil TestLib seinen Include-Pfad und seine eigene Qt-Abhängigkeit als PUBLIC deklariert hat, propagieren sich beide automatisch an jedes Target, das per target_link_libraries() gegen TestLib linkt. PRIVATE bei TestApp bedeutet dabei nur, dass TestApp selbst kein Ziel ist, gegen das wiederum andere Targets linken könnten – es ist schließlich eine Anwendung und keine Bibliothek.

Auch die Build-Reihenfolge muss hier, anders als bei der Sitzungs-.pro-Datei, nirgends explizit angegeben werden: Da TestApp per target_link_libraries() von TestLib abhängt, ermittelt cmake diese Abhängigkeit automatisch aus dem Abhängigkeitsgraphen der Targets und baut TestLib in jedem Fall vor TestApp, unabhängig von der Reihenfolge, in der die add_subdirectory()-Aufrufe in der Sitzungs-CMakeLists.txt stehen.

Die LIBS += ...-Reihenfolge-Falle aus dem qmake-Abschnitt (Bibliotheken, die Symbole aus anderen Bibliotheken benötigen, müssen vor diesen aufgeführt werden) betrifft target_link_libraries() bei cmake nicht in gleicher Schärfe: cmake reicht die vollständige, transitiv aufgelöste Abhängigkeitsliste in der richtigen Reihenfolge an den Linker durch, unabhängig von der Reihenfolge der Argumente im target_link_libraries()-Aufruf selbst.

Die Sitzungs-CMakeLists.txt

Die CMakeLists.txt im Wurzelverzeichnis übernimmt die Rolle von SharedLibraryBuildSystemConfigs.pro:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Sitzungs-Datei, erstellt die Bibliothek und die darauf aufbauende Anwendung.
#
# Konfigurieren und Bauen, ein Build-Verzeichnis pro Konfiguration:
#
#   cmake -S . -B build/Desktop-Debug -DCMAKE_BUILD_TYPE=Debug
#   cmake --build build/Desktop-Debug --parallel

cmake_minimum_required(VERSION 3.16)

project(SharedLibraryBuildSystemConfigs LANGUAGES CXX)

# bildet das qmake-Standardverhalten nach: ohne explizit angeforderte
# Konfiguration wird ein Release-Build erzeugt
get_property(IS_MULTI_CONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if (NOT IS_MULTI_CONFIG AND NOT CMAKE_BUILD_TYPE)
	set(CMAKE_BUILD_TYPE Release CACHE STRING "Build config (Debug or Release)" FORCE)
endif()

# Qt wird hier einmalig gesucht, die einzelnen Targets linken nur noch gegen
# die Import-Targets (dies ist das Gegenstück zu 'QT += widgets' in den .pro-Dateien)
find_package(Qt6 REQUIRED COMPONENTS Widgets)

# die Anwendung linkt gegen die Bibliothek, also wird diese zuerst hinzugefügt;
# die tatsächliche Build-Reihenfolge wird durch target_link_libraries() in 
# TestApp erzwungen
add_subdirectory(libs/TestLib)
add_subdirectory(TestApp)
  • cmake_minimum_required(VERSION 3.16) legt die minimal benötigte cmake-Version fest, ohne die manche der hier verwendeten Sprachfeatures (u.a. generator-ausdrucksfähige CMAKE_RUNTIME_OUTPUT_DIRECTORY-Handhabung) nicht verlässlich funktionieren. Eine Entsprechung dazu gibt es bei qmake nicht.
  • project(SharedLibraryBuildSystemConfigs LANGUAGES CXX) deklariert den Namen der Sitzung und beschränkt die aktivierten Sprachen auf C++ (ohne C, was cmake sonst standardmäßig mit aktivieren würde). Das entspricht in etwa dem impliziten Namen, den qmake aus dem Dateinamen der Sitzungs-.pro-Datei ableitet.
  • Der Block mit IS_MULTI_CONFIG und CMAKE_BUILD_TYPE bildet qmakes Standardverhalten nach: Wird keine Konfiguration explizit angegeben (weder per -DCMAKE_BUILD_TYPE=... noch implizit durch einen Multi-Config-Generator), baut cmake von Haus aus ohne Optimierungen und ohne Debug-Informationen, weil CMAKE_BUILD_TYPE dann schlicht leer ist. Damit dieses Beispielprojekt sich in diesem Fall so verhält wie qmake ohne CONFIG += debug (nämlich einen Release-Build erzeugt), wird CMAKE_BUILD_TYPE in diesem Fall explizit auf Release gesetzt. Bei Multi-Config-Generatoren unterbleibt das bewusst, da dort ohnehin alle Konfigurationen gleichzeitig zur Verfügung stehen und beim Bauen ausgewählt werden.
  • find_package(Qt6 REQUIRED COMPONENTS Widgets) sucht die Qt6-Installation und die benötigte Komponente genau einmal, zentral in der Sitzungs-Datei. Anders als bei qmakes QT += widgets, das in jeder .pro-Datei wiederholt werden muss, die Qt-Widgets verwendet, wird bei cmake das daraus resultierende Import-Target Qt6::Widgets einmalig verfügbar gemacht und in den einzelnen CMakeLists.txt-Dateien nur noch per target_link_libraries() referenziert.
  • add_subdirectory(libs/TestLib) und add_subdirectory(TestApp) binden die beiden Unterprojekte ein. Ein Gegenstück zu qmakes SUBDIRS/TestLib.file/TestApp.file ist dafür nicht nötig: add_subdirectory() erwartet direkt den (relativen) Pfad zum Verzeichnis mit der jeweiligen CMakeLists.txt, unabhängig davon, ob dieses Verzeichnis wie TestApp direkt unterhalb der Sitzungs-Datei liegt oder wie libs/TestLib tiefer verschachtelt ist.
  • Eine Entsprechung zu TestApp.depends = TestLib fehlt hier bewusst: Wie oben beschrieben, ergibt sich die Build-Reihenfolge bereits automatisch aus target_link_libraries(TestApp PRIVATE TestLib ...) in TestApp/CMakeLists.txt. Die Reihenfolge der beiden add_subdirectory()-Aufrufe hier dient daher nur der Lesbarkeit, nicht der Korrektheit.

Insgesamt zeigt sich am Vergleich der beiden Sitzungs-Dateien der grundsätzliche Unterschied zwischen den beiden Buildsystemen: qmake arbeitet variablenbasiert und projektweit (Einstellungen wie INCLUDEPATH oder LIBS gelten für die gesamte .pro-Datei und müssen bei Bedarf manuell zwischen Projekten synchronisiert werden), während cmake targetbasiert arbeitet und Anforderungen über PUBLIC/PRIVATE/INTERFACE gezielt entlang des Abhängigkeitsgraphen der Targets propagiert. Für dieses kleine Beispielprojekt mit nur einer Bibliothek und einer Anwendung macht sich der Unterschied nur in wenigen Zeilen bemerkbar – bei wachsender Anzahl an Bibliotheken, die sich gegenseitig verwenden, reduziert das targetbasierte Modell von cmake den Pflegeaufwand aber deutlich.

Das kleine Testprogramm mit den beiden Buildsystemdateien kann als komplettes Beispiel heruntergeladen werden:

APPENDIX: Statisch gelinkte Qt-basierte Bibliotheken mit Resourcen

Man kann Qt-basierte Bibliotheken auch statisch linken. Dabei gibt es häufig aber Probleme mit fehlenden Ressourcen. Dazu eine kleine Erklärung (auch wenn ich dynamisch gelinkte Bibliotheken bevorzuge).

Das Hauptproblem mit eingebetteten Resourcen

Normalerweise lädt Qt alle Ressourcen einer Anwendung beim Programmstart vollautomatisch. Dies geschieht über den Konstruktor einer internen, globalen Variable.

Wenn man Ressourcen jedoch in eine statische Bibliothek (.lib oder .a) auslagert und diese mit deiner Hauptanwendung verknüpft, passiert Folgendes:

  • Linker-Optimierung: Der C++ Linker optimiert das finale Programm.
  • Fehlender Zugriff: Da im Quellcode scheinbar niemand direkt auf diese globalen Variablen der Bibliothek zugreift, hält der Linker sie für ungenutzt.
  • Ressourcenverlust: Er wirft sie heraus, und die Ressourcen fehlen zur Laufzeit (Bilder, Icons oder Config-Dateien werden nicht angezeigt).

Die Lösung: Das C++-Makro Q_INIT_RESOURCE(name) wird im Qt-Framework verwendet, um eingebettete Ressourcen (wie Bilder, Icons oder Übersetzungen) aus einer .qrc-Ressourcendatei manuell zu initialisieren und zu laden. Durch den Aufruf von Q_INIT_RESOURCE(name) zwingst du den Linker dazu, die Ressourcen in die finale ausführbare Datei einzubinden und im Qt-Ressourcensystem zu registrieren.

Wichtige Regeln zur Verwendung

  • Der Übergabeparameter: Als name übergibt man den Basisnamen der .qrc-Datei ohne Dateiendung (Beispiel: Für icons.qrc nutzt du Q_INIT_RESOURCE(icons);).
  • Keine Namespaces: Das Makro darf nicht innerhalb eines C++-Namespaces aufgerufen werden, da die generierten Initialisierer im globalen Namespace liegen. Falls man sich in einem Namespace befindest, musst der Aufruf außerhalb platziert werden.
  • Der beste Ort: Es wird üblicherweise direkt am Anfang der main()-Funktion der Hauptanwendung oder im Initialisierungsprozess der Bibliothek aufgerufen.

Code-Beispiel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <QApplication>
#include <QFile>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    // Zwingt das Laden der Ressourcen aus 'mein_archiv.qrc'
    Q_INIT_RESOURCE(mein_archiv); 

    // Jetzt ist der Pfad sicher verfügbar
    QFile file(":/images/logo.png"); 

    return app.exec();
}

Falls man Ressourcen dynamisch zur Laufzeit komplett aus dem Speicher entfernen möchte (z. B. beim Entladen eines Plugins), gibt es das Gegenstück namens Q_CLEANUP_RESOURCE(name).

This post is licensed under CC BY 4.0 by the author.