Der Einzeldatei-Modus (CppFileRunConfiguration) war der zaeheste Teil: er uebersetzt ohne -I und -std und sieht wie ein Projektfehler aus. Ausserdem als beobachtete Tatsache statt Theorie: CLions 'Reset Cache and Reload Project' hat die CMakeCache.txt in build/debug geloescht, weil die angelegten Preset-Profile dorthin zeigen -- auch deaktiviert. ctest lief danach weiter, nur cmake --build brach ab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21 KiB
CMake verstehen
Diese Datei erklärt CMake an diesem Projekt. Kein Referenzhandbuch — die Reihenfolge folgt der Frage „wann mache ich was?".
1. Das Modell: es gibt nur Targets
CMake ist keine Skriptsprache, die Compiler-Kommandos zusammenbaut. Es ist ein
Generator: es baut einen Graphen aus Targets und schreibt daraus
Ninja-Dateien. Alles, was du in einer CMakeLists.txt schreibst, hat genau
einen Zweck — Targets anzulegen und ihre Eigenschaften zu setzen.
Ein Target ist:
| Art | Befehl | in diesem Projekt |
|---|---|---|
| Bibliothek | add_library |
pda, pda_shell, unity |
| Programm | add_executable |
pda_app, calculator.test |
| INTERFACE-Target | add_library(x INTERFACE) |
pda_warnings, doctest |
Ein INTERFACE-Target hat keinen eigenen Quellcode. Es ist ein Bündel
Eigenschaften, das man weiterreicht — pda_warnings in cmake/Warnings.cmake
ist nichts als eine Liste von -W-Flags mit einem Namen.
Eigenschaften vererben sich am Graphen entlang. Das ist der ganze Trick, und
es ist der Grund, warum man niemals include_directories() oder
add_definitions() benutzt: die wirken global auf alles, statt an einem Target
zu hängen.
Faustregel: Jeder Befehl, den du benutzt, sollte
target_am Anfang haben. Wenn nicht, frag dich, warum.
2. PUBLIC, PRIVATE, INTERFACE
Das ist der Begriff, an dem die meisten hängenbleiben. Die drei Wörter beantworten eine Frage: Wer sieht diese Eigenschaft?
ich selbst wer mich linkt
PRIVATE ja nein
INTERFACE nein ja
PUBLIC ja ja
Aus libpda/CMakeLists.txt:
target_include_directories(pda PUBLIC ...) # contact.hpp braucht der Benutzer
target_link_libraries(pda PRIVATE pda_warnings) # unsere Warnungen sind unsere Sache
target_compile_features(pda PUBLIC cxx_std_23) # <expected> steht IM Header
Die Entscheidung hängt nur davon ab, ob die Sache im Header vorkommt:
- Etwas steht in einem öffentlichen Header →
PUBLIC - Etwas kommt nur in der
.cppvor →PRIVATE
target_compile_features(pda PUBLIC cxx_std_23) ist dafür das beste Beispiel.
contact.hpp gibt std::expected zurück. Wer libpda mit C++17 benutzen will,
soll eine klare Meldung bekommen und keinen Header-Fehler dreißig Zeilen tief.
Ein Fall aus diesem Projekt, in pda/CMakeLists.txt:
target_link_libraries(pda_shell PUBLIC pda::pda)
PUBLIC, obwohl pda_shell intern ist — weil shell.hpp die Zeile
#include <libpda/contact.hpp> enthält. Wäre das PRIVATE, würde
shell.test.cpp mit „file not found" scheitern.
3. Reihenfolge: was muss wann stehen
CMake liest von oben nach unten. Manches wird beim Anlegen eines Targets eingefroren, anderes erst am Ende ausgewertet.
Muss VOR dem ersten Target stehen (sonst wirkungslos):
set(CMAKE_CXX_STANDARD 23) # wird ins Target kopiert
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ...)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
Deshalb steht das alles in cmake/ProjectDefaults.cmake, das ganz oben
eingebunden wird. Ein set(CMAKE_CXX_STANDARD 23) nach add_library()
ändert nichts mehr — der häufigste stumme Fehler in CMake.
Darf nach dem Target stehen (arbeitet auf dem Target):
target_link_libraries(...)
target_include_directories(...)
set_target_properties(...)
Muss ins oberste CMakeLists.txt:
enable_testing() # nur hier legt es die CTestTestfile.cmake an, die ctest sucht
4. Generator-Ausdrücke
$<...> wird nicht beim Einlesen ausgewertet, sondern erst beim
Generieren — wenn CMake schon weiß, welche Konfiguration gebaut wird und ob
gerade installiert wird. Deshalb kann man damit Dinge sagen, die zur Lesezeit
noch nicht feststehen.
Die zwei wichtigsten stehen in libpda/CMakeLists.txt:
target_include_directories(pda PUBLIC
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>"
"$<INSTALL_INTERFACE:include>")
BUILD_INTERFACEgilt, solange im Quellbaum gebaut wird. Include-Root ist<repo>/libpda, damit<libpda/contact.hpp>auf<repo>/libpda/libpda/contact.hppzeigt.INSTALL_INTERFACEgilt nach dem Installieren, relativ zum Präfix.
Ohne diese Trennung zeigt die installierte Bibliothek auf dein
Build-Verzeichnis. Beim Benutzer existiert das nicht. Das ist der
klassische Fehler beim Verteilen einer Bibliothek, und er fällt im eigenen
Build nie auf — tools/check-install.sh existiert genau deswegen.
Der zweite Fall ist subtiler:
target_link_libraries(pda PRIVATE "$<BUILD_INTERFACE:pda_warnings>")
Warum nicht einfach PRIVATE pda_warnings? Eine statische Bibliothek
linkt ihre Abhängigkeiten nicht selbst — das muss der tun, der sie benutzt.
CMake trägt PRIVATE-Abhängigkeiten deshalb trotzdem als $<LINK_ONLY:...>
ins Interface ein. install(EXPORT) sieht dort pda_warnings, findet es in
keinem Export-Set und bricht ab:
install(EXPORT "pdaTargets" ...) includes target "pda" which requires
target "pda_warnings" that is not in any export set.
$<BUILD_INTERFACE:...> löst im Build zu pda_warnings auf und beim
Installieren zu nichts.
5. Superprojekt und Teilprojekte
playground/ <- Superprojekt, kein eigener Quellcode
├── libpda/ <- eigenes project()
└── pda/ <- eigenes project()
P1204R0 verlangt die Trennung von Bibliothek und Programm. Der praktische Grund: die Bibliothek muss ohne die Anwendung baubar sein, sonst ist sie keine Bibliothek.
Jedes Teilprojekt hat deshalb diesen Block:
if(PROJECT_IS_TOP_LEVEL)
# Alles holen, was sonst das Superprojekt bereitstellt
endif()
PROJECT_IS_TOP_LEVEL ist TRUE, wenn dieses project() das oberste ist.
Beide Wege müssen funktionieren:
cmake --preset debug # Superprojekt: baut beides
cmake -S libpda -B build/nur-lib # nur die Bibliothek
cmake -S pda -B build/nur-app \ # nur die Anwendung, gegen installierte libpda
-DCMAKE_PREFIX_PATH=/pfad/zur/installation
Der dritte Fall ist der wertvollste: pda/CMakeLists.txt benutzt dann
find_package(pda REQUIRED) — also genau den Weg, den ein fremdes Projekt
geht. Damit ist die Anwendung gleichzeitig der Test für den Export.
Weil cmake/ und third_party/ im Wurzelverzeichnis liegen, brauchen die
Teilprojekte im Alleinbetrieb die zweiargumentige Form:
add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../third_party" third_party)
Das zweite Argument ist das Build-Verzeichnis. Ohne es bricht CMake ab, weil das Quellverzeichnis außerhalb des Projekts liegt und CMake nicht raten will, wohin die Artefakte sollen.
Damit cmake/Warnings.cmake aus beiden Richtungen eingebunden werden kann,
steht dort include_guard(GLOBAL). Ohne das wäre der zweite
add_library(pda_warnings INTERFACE) ein harter Fehler.
6. Targetname ≠ Dateiname
add_library(pda ...) # -> libpda.a (CMake setzt "lib" davor)
add_executable(pda_app pda/main.cpp)
set_target_properties(pda_app PROPERTIES OUTPUT_NAME pda) # -> bin/pda
Zwei Targets dürfen nicht gleich heißen — die Bibliothek belegt schon pda.
Der Dateiname darf trotzdem pda sein. Ein Target namens libpda ergäbe
übrigens liblibpda.a.
Dazu der Alias:
add_library(pda::pda ALIAS pda)
Er kostet nichts und hat einen konkreten Nutzen: ein Tippfehler in einem Namen mit Doppelpunkt ist ein sofortiger CMake-Fehler. Ohne Doppelpunkt hält CMake ihn für eine Systembibliothek und scheitert erst beim Linken, mit einer viel schlechteren Meldung.
7. Eine Bibliothek zum Verteilen: install und export
Vier Schritte, alle in libpda/CMakeLists.txt:
# 1. Dateien kopieren UND das Target für den Export vormerken
install(TARGETS pda EXPORT pdaTargets
RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}"
LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}"
ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}"
INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
# 2. Header kopieren -- FILES_MATCHING, sonst kommen die .cpp mit
install(DIRECTORY libpda/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/libpda"
FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp")
# 3. pdaTargets.cmake erzeugen -- die Datei, die das Target beschreibt
install(EXPORT pdaTargets FILE pdaTargets.cmake NAMESPACE pda::
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda")
# 4. pdaConfig.cmake erzeugen -- das, was find_package(pda) sucht
configure_package_config_file(
"${CMAKE_CURRENT_SOURCE_DIR}/../cmake/pdaConfig.cmake.in"
"${CMAKE_CURRENT_BINARY_DIR}/pdaConfig.cmake"
INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda")
find_package(pda) sucht pdaConfig.cmake, das lädt pdaTargets.cmake, und
darin steht pda::pda mit allen Include-Pfaden und Flags. Der Benutzer
schreibt nur noch:
find_package(pda 0.1 REQUIRED)
target_link_libraries(consumer PRIVATE pda::pda)
Kein -I, kein -lpda, kein Pfad von Hand. Siehe examples/consumer/.
Zwei Details, die man leicht übersieht:
GNUInstallDirsliefertCMAKE_INSTALL_LIBDIR. Auf Fedora ist daslib64, nichtlib. Nie selbst hinschreiben.configure_package_config_filestattconfigure_file: es definiert@PACKAGE_INIT@, das die Pfade relativ zum tatsächlichen Ort auflöst. Ein von Hand geschriebenes Config-File mit absoluten Pfaden funktioniert nur auf dem Rechner, auf dem installiert wurde.
Hätte libpda externe Abhängigkeiten, müssten sie in pdaConfig.cmake.in
stehen:
find_dependency(ZLIB REQUIRED)
Das ist Pflicht: pdaTargets.cmake verweist auf ZLIB::ZLIB, und dieses
Target existiert beim Benutzer nur, wenn es vorher gefunden wurde.
8. Symbolsichtbarkeit
ProjectDefaults.cmake setzt:
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
Damit ist in einer Shared Library kein Symbol exportiert, solange es nicht ausdrücklich markiert ist. Das ist unter Windows ohnehin das Verhalten — es auch unter Unix zu erzwingen, findet Portabilitätsfehler sofort.
Markiert wird mit einem generierten Makro:
include(GenerateExportHeader)
generate_export_header(pda BASE_NAME PDA
EXPORT_FILE_NAME "${CMAKE_CURRENT_BINARY_DIR}/generated/libpda/pda_export.h")
und im Header:
#include <libpda/pda_export.h>
class PDA_EXPORT ContactBook { ... };
[[nodiscard]] PDA_EXPORT std::string format_error(const EvalError&);
Dazu gehört zwingend:
if(NOT BUILD_SHARED_LIBS)
target_compile_definitions(pda PUBLIC PDA_STATIC_DEFINE)
endif()
Ohne das löst PDA_EXPORT im statischen Bau unter Windows zu
__declspec(dllimport) auf und der Linker sucht eine DLL, die es nicht gibt.
PUBLIC, weil der Benutzer denselben Header inkludiert.
Der Nutzen ist prüfbar:
cmake -S . -B build/shared -DBUILD_SHARED_LIBS=ON && cmake --build build/shared
nm -gU build/shared/lib/libpda.0.1.0.dylib | c++filt | grep -c 'pda::' # 39
nm -gU build/shared/lib/libpda.0.1.0.dylib | c++filt | grep -c 'Parser' # 0
Die interne Parser-Klasse aus calculator.cpp steht im anonymen Namespace
und ist nicht exportiert — sie gehört niemandem außer der Übersetzungseinheit.
Dazu noch:
set_target_properties(pda PROPERTIES
VERSION 0.1.0 # libpda.dylib.0.1.0
SOVERSION 0) # libpda.dylib.0 <- der ABI-Stand
SOVERSION ist die Zusicherung an den Linker: alles mit derselben Zahl ist
binärkompatibel. Sie wird erhöht, wenn sich das ABI ändert — nicht bei jedem
Release.
9. Tests
enable_testing() # nur im obersten CMakeLists.txt
add_test(NAME "unity.textfile" COMMAND textfile.test)
Für C++ läuft das nicht von Hand, sondern über doctest:
doctest_discover_tests(calculator.test TEST_PREFIX "doctest.")
Das ruft die Test-Executable beim Build mit --list-test-cases auf und
legt einen CTest-Eintrag pro TEST_CASE an. Ohne das sähe CTest nur einen
groben Eintrag pro Datei, und neotest könnte Testnamen nicht auf Quellzeilen
abbilden.
Nach einem frischen Clone ist der Testbaum deshalb leer, bis einmal gebaut wurde. Das ist kein Fehler.
Die Anwendung braucht einen Kniff, der weit über CMake hinaus gilt:
add_library(pda_shell STATIC pda/shell.cpp) # alles Testbare
add_executable(pda_app pda/main.cpp) # nur Ein-/Ausgabe
target_link_libraries(pda_app PRIVATE pda_shell)
Ein Unit-Test kann pda_shell linken. Würde die Logik direkt in pda_app
stecken, müsste der Test shell.cpp erneut übersetzen und würde
main.cpp mitziehen — zwei main() in einer Executable sind ein
Linkerfehler.
10. Presets
CMakePresets.json ersetzt handgeschriebene Kommandozeilen:
cmake --preset debug # konfigurieren
cmake --build --preset debug # bauen
ctest --preset debug # testen
| Preset | Zweck |
|---|---|
debug |
Alltag |
release |
RelWithDebInfo |
asan-ubsan |
AddressSanitizer + UBSan |
apple-clang |
Portabilitätsprobe, macOS-Systemcompiler |
homebrew-gcc |
Portabilitätsprobe, GCC-Frontend |
linux-gcc |
nur auf Linux sichtbar (condition) |
binaryDir ist build/<preset> und die Tiefe ist bindend: der
neotest-CTest-Adapter sucht nur drei Ebenen tief, und der Neovim-Target-Picker
leitet den File-API-Pfad daraus ab.
Optionen setzt man ohne neuen Preset dazu:
cmake --preset debug -DBUILD_SHARED_LIBS=ON -DPDA_BUILD_TESTS=OFF
BUILD_SHARED_LIBS ist ein CMake-Konventionsname — add_library() ohne
STATIC/SHARED richtet sich danach. Deshalb nie selbst erfinden.
11. Wann mache ich was?
Eine neue Datei zu einem bestehenden Modul
Nichts. Header werden über #include gefunden.
Ein neues Modul in der Bibliothek
libpda/libpda/<name>.hpp+.cppanlegen- Beide in
LIBPDA_SOURCES/LIBPDA_HEADERSeintragen — keinfile(GLOB) - Öffentliche Deklarationen mit
PDA_EXPORTmarkieren libpda/libpda/<name>.test.cppdaneben,libpda_add_doctest_test(<name>)
Warum kein file(GLOB): CMake wertet ihn beim Konfigurieren aus. Kommt
später eine Datei dazu, merkt der Build es nicht — er bleibt grün, und die
Datei ist einfach nicht dabei. CONFIGURE_DEPENDS mildert das, kostet aber bei
jedem Build einen Verzeichnis-Scan und ist laut CMake-Doku nicht zuverlässig.
Ein neues Kommando in der Anwendung
Nur pda/pda/shell.cpp ändern. shell.cpp steht schon in pda_shell.
Eine externe Abhängigkeit
find_package(fmt REQUIRED)
target_link_libraries(pda PRIVATE fmt::fmt)
Steht sie in einem öffentlichen Header, muss sie PUBLIC sein und in
cmake/pdaConfig.cmake.in als find_dependency(fmt REQUIRED) auftauchen.
Sonst scheitert find_package(pda) beim Benutzer mit „target fmt::fmt not
found".
Etwas soll nur im Debug-Bau passieren
target_compile_definitions(pda PRIVATE "$<$<CONFIG:Debug>:PDA_DEBUG>")
Nicht if(CMAKE_BUILD_TYPE STREQUAL Debug) — das bricht bei
Multi-Config-Generatoren (Xcode, Visual Studio), wo die Konfiguration erst
beim Bauen feststeht.
12. Fehler, die dieses Projekt schon eingebaut hat
| Symptom | Ursache | Wo nachlesen |
|---|---|---|
requires target "pda_warnings" that is not in any export set |
PRIVATE reicht bei statischen Bibliotheken nicht |
Abschnitt 4 |
| Shared-Bau linkt nicht, alle Symbole fehlen | hidden gesetzt, aber PDA_EXPORT nirgends benutzt |
Abschnitt 8 |
set(CMAKE_CXX_STANDARD) wirkt nicht |
steht nach add_library() |
Abschnitt 3 |
| Benutzer bekommt Pfad ins Build-Verzeichnis | BUILD_INTERFACE/INSTALL_INTERFACE fehlt |
Abschnitt 4 |
| Neue Datei wird nicht gebaut | file(GLOB) |
Abschnitt 11 |
Die ersten beiden sind beim Bau dieses Projekts tatsächlich aufgetreten.
13. CLion
CLion liest dieselbe CMake File API wie jedes andere Werkzeug — es hat keine eigene Projektdatei. Die Meldung
This file does not belong to any project target
heisst deshalb fast nie, dass mit dem CMakeLists.txt etwas nicht stimmt. Sie
heisst: CLion hat (noch) kein CMake-Modell. Nachsehen kann man das selbst:
mkdir -p build/debug/.cmake/api/v1/query
touch build/debug/.cmake/api/v1/query/codemodel-v2
cmake --preset debug
python3 -c "
import json,glob
d=json.load(open(glob.glob('build/debug/.cmake/api/v1/reply/target-pda-*')[0]))
print(*[s['path'] for s in d['sources']], sep='\n')"
Stehen die Header dort — und sie stehen dort, weil LIBPDA_HEADERS an
add_library() geht — dann ist das Modell korrekt und der Fehler liegt bei
CLion.
Die Reihenfolge, die tatsächlich hilft
Beim ersten Einrichten dieses Projekts war es genau diese:
- Projekt über die
CMakeLists.txtöffnen, nicht über den Ordner (File → Open… → die Datei im Wurzelverzeichnis wählen → „Open as Project"). Ein als Ordner geöffnetes Projekt bekommt kein CMake-Modell, und CLion bietet stattdessen den Einzeldatei-Modus an. - Toolchain setzen (siehe unten), dann Tools → CMake → Reset Cache and Reload Project — in der Menüleiste, nicht in den Einstellungen.
- Die Einzeldatei-Konfiguration löschen: Run → Edit Configurations… → unter „C/C++ File" den Eintrag mit dem Dateinamen → −.
Schritt 3 ist der, den man am ehesten übersieht. Diese Konfiguration
(CppFileRunConfiguration) uebersetzt eine einzelne Datei ohne jede
Projekteinstellung:
clang++ .../pda/pda/main.cpp -o main # kein -I, kein -std
Solange sie oben rechts ausgewählt ist, scheitert jeder Lauf an
'libpda/details/version.hpp' file not found — obwohl das Projekt völlig in
Ordnung ist. Danach steht in derselben Liste pda_app, calculator.test,
integration_basics und so weiter.
Die Falle: zwei Compiler, ein Build-Verzeichnis
Der Preset debug setzt "CMAKE_C_COMPILER": "clang". Das wird über PATH
aufgelöst — und eine aus dem Dock gestartete GUI-Anwendung erbt die PATH aus
.zprofile nicht. In CLion löst clang deshalb auf /usr/bin/clang
(Apple clang) auf, im Terminal auf /opt/homebrew/opt/llvm/bin/clang.
Weil binaryDir in beiden Fällen build/debug ist, gewinnt schlicht, wer
zuerst konfiguriert — CMake speichert den vollen Pfad im Cache und löst
clang danach nie wieder auf. Es gibt keine Fehlermeldung, nur einen anderen
Compiler als gedacht.
Und es bleibt nicht bei einem anderen Compiler. Beim Einrichten hier hat
CLions Reset Cache and Reload Project die CMakeCache.txt im
Terminal-Build-Verzeichnis geloescht -- build/debug behielt build.ninja,
compile_commands.json und alle Binaries, nur der Cache war weg:
$ cmake --build --preset debug
Error: not a CMake build directory (missing CMakeCache.txt)
Kurios dabei: ctest --preset debug lief weiter durch, weil
CTestTestfile.cmake und die gebauten Testprogramme noch dalagen. Der Schaden
faellt also erst beim naechsten Bauen auf. Behoben mit einem einfachen
cmake --preset debug.
Ausgeloest hat das ein deaktiviertes Preset-Profil -- CLion legt beim Laden
fuer jeden Preset eines an (hier zehn, teils doppelt als debug und
debug - debug), und deren GENERATION_DIR zeigt auf build/<preset>.
Zwei saubere Auswege:
- CLion ein eigenes Build-Verzeichnis geben (Default
cmake-build-debug, in.gitignoreeingetragen) und die Preset-Profile unter Settings → Build, Execution, Deployment → CMake loeschen, nicht nur deaktivieren. - Denselben Compiler erzwingen: unter Settings → Build, Execution,
Deployment → Toolchains C/C++-Compiler auf
/opt/homebrew/opt/llvm/bin/clangbzw.clang++setzen. Die Toolchain muss dabei ganz oben in der Liste stehen (fett = Standard), sonst benutzt das CMake-Profil weiter die alte. Danach ist ein Cache-Reset Pflicht -- CMake verweigert den Compilerwechsel sonst:You have changed variables that require your cache to be deleted.
Am besten beides: eigenes Verzeichnis und derselbe Compiler. Dann sieht die IDE dieselben Diagnosen wie das Terminal, ohne sich mit ihm zu schlagen.
14. Werkzeuge zum Nachsehen
cmake --build build/debug --target help # alle Targets
cmake -S . -B build/debug --graphviz=g.dot # Abhängigkeitsgraph
cmake --install build/debug --prefix /tmp/p # ohne echte Installation ausprobieren
./tools/check-install.sh # Export-Kette Ende-zu-Ende
Um zu sehen, was ein Target tatsächlich erbt:
get_target_property(dirs pda INTERFACE_INCLUDE_DIRECTORIES)
message(STATUS "pda erbt: ${dirs}")
Und für die echten Compiler-Kommandos: build/debug/compile_commands.json —
dieselbe Datei, die clangd liest.