Files
paulhorn 5efc5fccff docs: CLion-Abschnitt um die tatsaechliche Loesungsreihenfolge ergaenzen
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>
2026-08-30 18:47:34 +02:00

21 KiB
Raw Permalink Blame History

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 .cpp vor → 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_INTERFACE gilt, solange im Quellbaum gebaut wird. Include-Root ist <repo>/libpda, damit <libpda/contact.hpp> auf <repo>/libpda/libpda/contact.hpp zeigt.
  • INSTALL_INTERFACE gilt 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:

  • GNUInstallDirs liefert CMAKE_INSTALL_LIBDIR. Auf Fedora ist das lib64, nicht lib. Nie selbst hinschreiben.
  • configure_package_config_file statt configure_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

  1. libpda/libpda/<name>.hpp + .cpp anlegen
  2. Beide in LIBPDA_SOURCES / LIBPDA_HEADERS eintragen — kein file(GLOB)
  3. Öffentliche Deklarationen mit PDA_EXPORT markieren
  4. libpda/libpda/<name>.test.cpp daneben, 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:

  1. 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.
  2. Toolchain setzen (siehe unten), dann Tools → CMake → Reset Cache and Reload Project — in der Menüleiste, nicht in den Einstellungen.
  3. 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:

  1. CLion ein eigenes Build-Verzeichnis geben (Default cmake-build-debug, in .gitignore eingetragen) und die Preset-Profile unter Settings → Build, Execution, Deployment → CMake loeschen, nicht nur deaktivieren.
  2. Denselben Compiler erzwingen: unter Settings → Build, Execution, Deployment → Toolchains C/C++-Compiler auf /opt/homebrew/opt/llvm/bin/clang bzw. 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.