Ersetzt das Geruest (counter/notebook) durch ein echtes Beispiel und teilt es in zwei eigenstaendige Projekte, wie P1204R0 es fuer Bibliothek plus Programm verlangt. libpda: textfile (C, Datei-I/O und Feld-Escaping), calculator (Recursive-Descent-Parser mit std::expected), contact, editor, explorer. Exportiert pda::pda ueber install(EXPORT) und ist per find_package(pda) benutzbar. pda: Shell ohne Ein-/Ausgabe (execute() gibt Text zurueck, deshalb ohne Terminal testbar), REPL und Stapelbetrieb in main.cpp. examples/consumer und tools/check-install.sh pruefen die Export-Kette Ende-zu-Ende: installieren, dann ein fremdes Projekt dagegen bauen. docs/CMAKE.md erklaert das Target-Modell, PUBLIC/PRIVATE/INTERFACE, Generator-Ausdruecke, install/export und Symbolsichtbarkeit an diesem Projekt. 71 Tests gruen unter Homebrew-clang 22, Apple clang und GCC 16, statisch und shared, mit und ohne ASan/UBSan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 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. 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.