Projektformate, die Projekteinstellungen und Build-Optionen fest an eine bestimmte IDE binden, wirken anfangs oft einsteigerfreundlich, haben aber diverse Nachteile. Könnte CMake hier mehr Unabhängigkeit bieten?
Bevor CMake an Verbreitung gewann, wurden im Embedded-Umfeld häufig IDEs mit IDE-spezifischen Projektformaten wie beispielsweise Eclipse (.cproject, .project) oder Keil µVision (.uvprojx) verwendet. Das mag ein rasches Aufsetzen des Projekts ermöglichen, bringt aber auch Nachteile mit sich.
CMake ist ein plattformübergreifender, quelloffener Metabuild-Generator, der primär im C/C++ Umfeld zum Einsatz kommt und das Buildsystem unabhängig von Entwicklungsumgebungen konfiguriert. Damit beseitigt CMake exakt die Nachteile von IDE-spezifischen Projektformaten aus dem vorherigen Kapitel.
CMake Anweisungen werden in die Skriptdateien CMakeLists.txt geschrieben. Davon muss es eine im Hauptverzeichnis geben und beliebig viele weitere in Unterordnern. CMakePresets.json konfiguriert das CMake Projekt (z.B. Debug- und Release-Variante) und wird ebenfalls im Hauptverzeichnis abgelegt.
Wird ein CMake Build angestossen, sieht der vereinfachte Ablauf wie folgt aus:
CMakePresets.json → CMakeLists.txt → CMake → Makefile → Ninja/Make/Nmake/… → Compiler, Linker → Programm/Programmbibliothek
Neben der offiziellen Dokumentation lassen sich im Internet zahlreiche Artikel finden, die die Theorie von CMake an einem minimalen Beispiel vermitteln. Dieser Blog geht über solche minimale Beispiele hinaus, wobei der Quellcode immer noch in einem überschaubaren Rahmen bleibt und doch einige Kniffe zeigt, die bei einem minimalen Beispiel aussen vor bleiben.
Die Vorlage unter GitHub beinhaltet zwei Applikationen, die beide auf mehrere Libraries zugreifen. Zudem sind Unittests basierend auf dem GoogleTest Testing und Mocking Framework enthalten. Auch wenn es sich um ein Embedded Projekt handelt, ist der CMake Teil allgemein gültig und somit auf ein beliebiges Projekt übertragbar.
├── app │ ├── app1 │ │ ├── cmake │ │ │ └── AddProductiveSources.cmake │ │ └── CMakeLists.txt │ │ │ └── ... │ ├── cmake │ ├── clang.cmake │ └── gcc-arm-none-eabi.cmake │ ├── dep │ └── googletest │ ├── CMakeLists.txt │ └── ... │ ├── lib │ ├── device_lib │ │ ├── cmake │ │ │ ├── AddProductiveSources.cmake │ │ │ └── AddTestSources.cmake │ │ ├── test │ │ │ └── CMakeLists.txt │ │ └── CMakeLists.txt │ │ │ └── ... │ ├── CMakeLists.txt └── CMakePresets.json
Die Vorlage enthält die obligatorische CMakeLists.txt im Hauptverzeichnis sowie zusätzliche CMakeLists.txt für die einzelnen Applikationen, Libraries und deren Tests. Sie beschreiben die Struktur eines Projekts und definieren Targets, Abhängigkeiten sowie Unterverzeichnisse. Dadurch findet eine klare Trennung zwischen Produktiv- und Testcode statt. In die Add*Sources.cmake sind selbstgeschriebene CMake Funktionen ausgelagert, wobei die *.cmake Dateien beliebig benannt werden können.
Für diesen und die nächsten Abschnitte empfiehlt es sich den Quellcode aus der verlinkten Vorlage nebenher zu betrachten, um den Erklärungen mühelos folgen zu können.
CMake Presets dienen dazu, häufig verwendete Konfigurationen eines CMake-Projekts festzulegen und mit anderen Entwicklern zu teilen. Dadurch können einheitliche Einstellungen für das Konfigurieren, Kompilieren und Testen des Projekts definiert und einfach wiederverwendet werden.
CMakePresets.json definiert beliebig viele Presets. Neben dem grundlegenden Preset Typ configurePresets sind auch buildPresets und testPresets enthalten. Pro Applikation gibt es ein Basis-Preset und je ein davon vererbtes Preset für eine Debug- und eines für eine Release-Variante. Die vererbten Debug- und Release-Presets sind den buildPresets angehängt, damit sie sich kompilieren lassen. Für die Unittests gibt es ebenfalls ein Basis-Preset und ein davon vererbtes Preset, das um diverse Variablen ergänzt wird, um die Unittests einzuschalten. Das vererbte Unittest-Preset ist neben den buildPresets den testPresets angehängt und wird dadurch in der IDE als Test angezeigt, der sich kompilieren, ausführen und auswerten lässt.
Variablen im Basis-Preset der Applikationen werden im vorliegenden Template dazu verwendet, um unter anderem das RTOS festzulegen. Mit der gewählten Architektur ist das möglich, weil alle Zugriffe auf ein beliebiges RTOS über die rtosAdapter_lib gekapselt sind. Im CMakeLists.txt dieses Adapters wird der Adapter für FreeRTOS gewählt. Das erlaubt es den Code generischer zu halten und sich die Möglichkeit offen zu halten zu einem späteren Zeitpunkt das RTOS zu ändern. Ein weiterer Vorteil dieser Abstraktion liegt darin, dass der Aufruf von RTOS Funktionen mit einem Mock ersetzt und somit von einem spezifischen RTOS unabhängig in einem Unittest getestet werden kann.
Auf ähnliche Weise werden über weitere Variablen der gewünschte Driver und Microcontroller gesetzt, damit in den CMakeLists.txt der entsprechenden Libraries die gewünschten Quelldateien verwendet werden.
CMakeLists.txt im Hauptverzeichnis ist der Einstiegspunkt jedes CMake Projekts. Variablen aus CMakePresets.json werden unter anderem verwendet, um festzustellen ob die Unittests oder der Produktivcode kompiliert werden soll. Je nach Wahl wird mit include der entsprechende Compiler mit gcc-arm-none-eabi.cmake oder clang.cmake ausgewählt (siehe nächster Abschnitt). Mit project wird dem Projekt ein Name zugewiesen. Wiederum basierend auf den Variablen aus CMakePresets.json werden mit add_subdirectory die Unterverzeichnisse mit eigenen CMakeLists.txt aus den Applikation/Library Unterverzeichnissen (siehe übernächster Abschnitt) dem Projekt hinzugefügt.
So wird beispielsweise die FreeRTOS_lib nur hinzugefügt, wenn der Produktivcode gebaut werden soll. Die rtosAdapter_lib hingegen wird sowohl für den Produktivcode als auch auch die Unittests hinzugefügt, um im Falle des Produktivcodes wie der Name aussagt als Adapter zu dienen und im Falle des Unittests als Mock zu dienen.
clang.cmake konfiguriert den Clang Compiler, der den Testcode kompiliert, damit anschliessend die Unittests auf dem Entwicklungscomputer ausgeführt werden können. gcc-arm-none-eabi.cmake konfiguriert den GCC Compiler, der den Produktivcode kompiliert, damit dieser anschliessend auf dem STM32 Target ausgeführt werden kann.
CMakeLists.txt in den Applikations- und Library-Verzeichnissen setzt den Namen der Applikation, der Library oder des Library-Tests. Zudem werden die Include-Verzeichnisse (target_include_directories) und mittels Add*Sources.cmake (siehe nächster Abschnitt) die target_sources hinzugefügt. Abhängigkeiten in Form von Libraries werden mit target_link_libraries hinzugefügt.
AddProductiveSources.cmake bzw. AddTestSources.cmake fügen die jeweiligen target_sources des Produktiv- oder Testcodes hinzu. Es ist zwar nicht unbedingt notwendig das als Funktionen in separaten Dateien auszulagern, kann aber die Lesbarkeit verbessern. Vorausgesetzt die Architektur und die Abhängigkeiten bleiben gleich, müssen bei Umbenennungen oder beim Hinzufügen von neuen Quelldateien nur die Add*Sources.cmake angepasst werden. Die CMakeLists.txt bleiben also gleich kompakt und damit auch gut lesbar, wenn bei grossen Projekten sehr viele Quelldateien pro Applikation/Library zusammenkommen.
Diverse Programmiersprachen bringen ein eigenes Buildsystem mit. Ein Beispiel im Embedded-Umfeld ist die noch relativ junge Programmiersprache Rust, die mit Cargo Buildsystem und Paketverwaltung in einem kombiniert, wobei Pakete dort als Crate bezeichnet werden. In diesem Fall ist CMake überflüssig und man kann sich das Schreiben von eigenen Buildskripts ersparen.
Der Initialaufwand für die Einrichtung von CMake hängt stark von der Projektgrösse und natürlich dem Erfahrungsgrad ab. Für ein minimales Setup sind nur wenige Zeilen notwendig, wohingegen für modulare Systeme mit mehreren Applikationen, Libraries und Tests mit unterschiedlichen Toolchains entsprechend mehr Zeit veranschlagt werden muss.
Die Vorteile überwiegen aber schnell und man wird die Punkte plain-text Format, Entwicklungsumgebungs- und Plattformunabhängigkeit, Stabilität sowie Open-Source zu schätzen lernen. Im eher langlebigen Embedded-Umfeld kann die Software so auch nach Jahren ohne aktive Weiterentwicklung und längst geänderter Entwicklungsumgebung in kürzester Zeit wieder in Betrieb genommen werden, um Wartungsarbeiten vorzunehmen.
Schreiben Sie einen Kommentar