Modernes C++ (17/20/23) für Embedded-Systeme
Wie C++17, C++20 und C++23 Embedded-Software sicherer und klarer machen – bei kaum Laufzeitkosten, diszipliniert eingesetzt auf beschränkten Zielen.
Kurz gesagt: Modernes C++ (C++17, C++20 und C++23) macht Embedded-Software sicherer, klarer und korrekter – und dank Zero-Cost-Abstraktionen kostet das zur Laufzeit wenig bis nichts. Die Bedingung ist Disziplin: Exceptions, RTTI und dynamische Speicherallokation unter Kontrolle halten, die Codegröße im Blick behalten und genau wissen, welchen Ausschnitt der Standardbibliothek Ihre Zieltoolchain tatsächlich bereitstellt.
Lange galt C++ in Embedded-Kreisen als “C mit Klassen plus Overhead, den man sich auf einem Mikrocontroller nicht leisten kann.” Dieser Ruf ist überholt. Die Sprachrevisionen des vergangenen Jahrzehnts wurden konsequent um das Prinzip der Zero-Cost-Abstraktionen herum entworfen: Man zahlt nur für das, was man nutzt, und eine sauber geschriebene Abstraktion kompiliert zum selben Maschinencode, den man von Hand geschrieben hätte. Für ein beschränktes Zielsystem ist das genau der richtige Handel – mehr Garantien zur Kompilierzeit, dieselben Instruktionen zur Laufzeit. Die folgenden Abschnitte gehen durch, was jeder Standard beisteuert, worin der konkrete Embedded-Nutzen liegt und welche Vorbehalte einen auf einem MCU oder Bare-Metal-Board vor Ärger bewahren.
Warum modernes C++ auf beschränkte Ziele passt
Drei Eigenschaften machen die Sprache zum natürlichen Kandidaten für Embedded-Arbeit. Die erste sind Zero-Cost-Abstraktionen. Ein std::array, ein stark typisiertes Enum oder eine kleine Wrapper-Klasse verursacht nach dem Optimierer keinen Laufzeit-Mehraufwand gegenüber dem entsprechenden C-Konstrukt; die Abstraktion existiert nur im Quelltext, nicht im Binary. Lesbarkeit und Typsicherheit gibt es also gratis.
Die zweite ist RAII (Resource Acquisition Is Initialization). Eine Ressource – ein File-Deskriptor, ein DMA-Kanal, ein Mutex-Lock, ein GPIO-Handle – wird an die Lebensdauer eines Objekts gebunden. Sein Destruktor gibt sie deterministisch am Ende des umgebenden Gültigkeitsbereichs frei, auf jedem Pfad, auch bei vorzeitigem Return. Kein manuelles goto cleanup, kein vergessenes free, kein Leck, das erst nach Tagen Laufzeit auffällt. Auf einem dauerhaft laufenden Gerät ist dieser Determinismus viel wert.
Die dritte ist stärkere Typsicherheit zur Kompilierzeit. Jeder Fehler, den der Compiler abfängt, ist ein Fehler, der nie ins Feld gelangt – wo seine Reproduktion womöglich einen Servicetechniker vor Ort bedeutet. Modernes C++ verlagert mehr Prüfungen in den Compiler: starke Typen statt nacktem int, enum class statt loser Konstanten, Concepts statt untypisierter Templates. Diese Verlagerung wirkt genau dort am stärksten, wo Debugging am schwersten ist. Sie ist das Rückgrat unserer C++-Entwicklung.
C++17: Das solide Fundament
C++17 ist die Basis, die praktisch jede aktuelle Cross-Toolchain unterstützt – und damit der sichere Standard für neue Embedded-Projekte. Mehrere Features rechtfertigen ihren Einsatz sofort:
std::optional<T>modelliert “ein Wert oder nichts” ohne Sentinel-Wert oder magische-1. Eine Sensormessung, die fehlschlagen kann, liefertstd::optional<Reading>, statt den Rückgabetyp zu überladen.std::string_viewist eine besitzlose Sicht auf Zeichendaten – Strings weiterreichen ohne Kopie und ohne Allokation.std::variant<...>ist eine typsichere getaggte Union, nützlich für einen Zustandsautomaten oder eine Protokollnachricht, deren Nutzlast vom Typ abhängt, ganz ohne virtuellen Dispatch.if constexprwählt einen Zweig zur Kompilierzeit und verwirft den anderen vollständig. Code für Hardwarevarianten braucht keine SFINAE-Verrenkungen oder Tag-Dispatch mehr.- Structured Bindings zerlegen ein Pair oder kleines Struct in benannte Variablen (
auto [status, value] = read();), was deutlich besser liest als.first/.second. - Mehr
constexprverschiebt Nachschlagetabellen und abgeleitete Konstanten in die Kompilierzeit-Auswertung, sodass sie im Flash landen, statt beim Start berechnet zu werden.
C++20: Concepts, Ranges und std::span
C++20 ist ein größerer Schritt, und die Toolchain-Unterstützung ist inzwischen breit genug, um sich auf den meisten Linux-fähigen Zielen darauf zu verlassen. Drei Ergänzungen stechen für Embedded heraus.
Concepts legen benannte Anforderungen auf Template-Parameter. Statt einer Wand aus Substitutionsfehler-Rauschen liefert eine verletzte Bedingung eine Meldung, die die tatsächliche Anforderung benennt – “dieser Typ ist kein Sensor” statt fünfzig Zeilen interner Template-Instanziierung. Schnittstellen werden selbsterklärend, Fehlermeldungen lesbar.
std::span<T> ist wohl die nützlichste Embedded-Ergänzung überhaupt: eine besitzlose Sicht auf einen zusammenhängenden Puffer, die Zeiger und Länge zusammen trägt. Das klassische Paar (uint8_t* buf, size_t len) – Ursache unzähliger Überläufe – schrumpft auf ein Objekt, über dessen Grenze der Aufgerufene nicht hinaus lesen kann. Der Besitz bleibt beim Aufrufer.
consteval und constinit schärfen die Kompilierzeit-Arbeit. consteval markiert eine Funktion, die zwingend zur Kompilierzeit laufen muss; constinit garantiert, dass eine Variable konstant initialisiert wird, was das Problem der statischen Initialisierungsreihenfolge und jeglichen Laufzeit-Initialisierungsaufwand beseitigt – die Daten wandern direkt in .rodata. ranges und Designated Initializers runden die Ausgabe ab, wobei ranges einen Hinweis verdient: Auf eng beschränkten Zielen sollte man die Codegröße und Kompilierzeit im Auge behalten, die umfangreiche View-Pipelines mit sich bringen können.
C++23: std::expected und mehr Kompilierzeit-Arbeit
C++23 ist die neueste Stufe, und hier hängt die Verfügbarkeit wirklich von der Compiler-Version ab – prüfen Sie, was Ihre Cross-Toolchain mitbringt, bevor Sie darauf aufbauen.
Sein wichtigstes Feature für Embedded ist std::expected<T, E>: ein Rückgabetyp, der entweder einen Wert oder einen Fehler enthält, explizit und ohne Exceptions. Auf Zielen, auf denen Exceptions abgeschaltet sind (siehe unten), ersetzt es nackte Fehlercodes durch etwas, dessen Auswertung das Typsystem erzwingt. std::mdspan bringt eine mehrdimensionale, besitzlose Sicht – naheliegend für Bildkacheln, Sensormatrizen oder DMA-Framebuffer –, ebenfalls ohne den Speicher zu besitzen. C++23 macht zudem noch mehr der Standardbibliothek constexpr und erweitert, wie viel Berechnung sich in den Build ziehen lässt.
Beispiel: Besitz und Fehlerbehandlung ohne Exceptions
Das folgende Beispiel kombiniert drei dieser Ideen: einen RAII-Wrapper, der seinen Deskriptor nicht lecken kann, ein std::span, das nicht über seine Grenzen gelesen werden kann, und std::expected statt eines nackten Fehlercodes.
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <span>
#include <system_error>
#include <unistd.h>
// RAII: Der Destruktor schließt den fd auf jedem Ausgangspfad –
// kein Leck, kein manuelles Aufräumen, deterministische Freigabe
// am Ende des Gültigkeitsbereichs.
class FileDescriptor {
public:
explicit FileDescriptor(int fd) noexcept : fd_{fd} {}
~FileDescriptor() { if (fd_ >= 0) ::close(fd_); }
FileDescriptor(FileDescriptor&& o) noexcept : fd_{o.fd_} { o.fd_ = -1; }
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
int get() const noexcept { return fd_; }
private:
int fd_{-1};
};
// std::span übergibt (Zeiger, Länge) als ein Objekt, über dessen
// Grenze der Aufgerufene nicht liest; std::expected liefert einen
// Wert ODER einen Fehlercode, ohne eine Exception zu werfen.
std::expected<std::size_t, std::errc>
read_frame(const FileDescriptor& dev, std::span<std::uint8_t> buffer) {
const ssize_t n = ::read(dev.get(), buffer.data(), buffer.size());
if (n < 0)
return std::unexpected(static_cast<std::errc>(errno));
return static_cast<std::size_t>(n);
}
Der Aufrufer wertet das Ergebnis explizit aus – es gibt keinen versteckten Kontrollfluss und keine Möglichkeit, den Fehlerpfad versehentlich zu ignorieren:
std::array<std::uint8_t, 256> buf;
if (auto r = read_frame(dev, buf))
process(buf.data(), *r);
else
log_error(r.error());
Disziplin auf dem Ziel: Was unter Kontrolle bleiben muss
Modernes C++ ist kein Freibrief, das Ingenieursurteil abzuschalten. Mehrere Voreinstellungen, die auf dem Desktop harmlos sind, brauchen auf beschränkten Zielen bewusste Behandlung:
- Exceptions und RTTI werden auf MCUs und in hart-echtzeitfähigem Code häufig abgeschaltet (
-fno-exceptions -fno-rtti), sowohl um Codegröße zu sparen als auch um den Kontrollfluss vorhersagbar zu halten. Genau deshalb ist wertbasierte Fehlerbehandlung wiestd::expectedso wichtig. - Dynamische Allokation muss in hart-echtzeitfähigen und MCU-Kontexten kontrolliert oder vermieden werden – ein fragmentierender Heap oder eine blockierende Allokation ist ein latenter Fehler. Bevorzugen Sie Container fester Größe, Arenen und Stack-Puffer.
- Codegröße verlangt aktive Messung. Templates werden pro Typ instanziiert, und Features wie
<format>oder umfangreicheranges-Pipelines können mehr einziehen, als ein kleines ROM-Budget erlaubt. Messen, nicht annehmen. - Die Standardbibliothek steht nur als Ausschnitt bereit auf Bare-Metal- und MCU-Zielen. Eine freestanding-Implementierung liefert die Sprache und einen Kern an Headern, aber nicht zwingend
std::vectoroderstd::expected. Kennen Sie Ihr Ziel, bevor Sie um ein Feature herum entwerfen.
Die folgende Tabelle fasst den Handel für die relevantesten Features zusammen:
| Feature | Embedded-Nutzen | Vorbehalt |
|---|---|---|
| RAII | Deterministische, leckfreie Freigabe auf jedem Pfad | Setzt durchdachtes Besitz-Design voraus |
std::optional (C++17) | Expliziter “kein Wert”-Zustand ohne Sentinel | Kein Ersatz für Fehler mit Kontext |
std::span (C++20) | Sichere besitzlose Puffersicht, kein Zeiger-Länge-Paar | Keine Lebenszeitgarantie – der Puffer muss die Sicht überleben |
| Concepts (C++20) | Lesbare Template-Fehler, selbsterklärende Schnittstellen | Nur Kompilierzeit, keine Laufzeitwirkung |
constexpr / consteval | Verlagert Arbeit in den Build, ins Flash statt ins RAM | Erhöht die Kompilierzeit; nicht alles ist constexpr-fähig |
std::expected (C++23) | Fehlerbehandlung ohne Exceptions, Wert-oder-Fehler explizit | Braucht C++23-Toolchain und -Bibliothek |
Werkzeugkette: Qt und das Yocto-SDK
Zwei alltägliche Bausteine rücken modernes C++ bereits in Reichweite. Qt ist selbst ein modernes C++-Framework – Qt 6 setzt C++17 voraus und seine APIs bauen zunehmend darauf –, sodass der Bau einer Qt/QML-Oberfläche bedeutet, auf beiden Seiten der QML-Grenze modernes C++ zu schreiben. Und die Cross-Compiler, die in einem Yocto-SDK stecken, sind aktuelle GCC- und Clang-Toolchains, sodass dieselben C++20- oder C++23-Features, die Sie auf dem Desktop nutzen, auch für das Ziel verfügbar sind – ein weiterer Grund, warum die Compiler- und Standardwahl in die Embedded-Linux- und BSP-Arbeit gehört und keine Nebensache sein sollte.
Fazit
Modernes C++ ist keine Mode, sondern ein Werkzeugkasten, der Embedded-Software messbar sicherer und klarer macht und dabei das Laufzeitbudget respektiert – vorausgesetzt, man setzt es mit der Disziplin ein, die das Ziel verlangt. Die Kunst liegt darin, für ein gegebenes Board und eine gegebene Toolchain den richtigen Ausschnitt zu wählen und zu wissen, welche Voreinstellungen abzuschalten sind. Genau dieses Urteilsvermögen vermitteln wir in unseren Modern-C++-Schulungen und in der täglichen Projektarbeit. Wenn Sie abwägen, wie sich eine Embedded-Codebasis modernisieren lässt, ohne Ihre Randbedingungen zu sprengen, sprechen Sie uns an.
Häufige Fragen
- Erzeugt modernes C++ Laufzeit-Overhead auf Embedded-Zielen?
- In der Regel nicht. Zero-Cost-Abstraktionen wie std::span, std::optional oder RAII-Wrapper kompilieren nach dem Optimierer zu denselben Instruktionen wie handgeschriebenes C. Overhead entsteht durch Features, die man bewusst steuern muss – Exceptions, RTTI und dynamische Allokation –, nicht durch die Abstraktionen selbst.
- Kann ich std::expected auf einem Mikrocontroller nutzen?
- Nur wenn die Standardbibliothek Ihrer Toolchain es bereitstellt – std::expected ist ein C++23-Feature. Auf einem freestanding- oder MCU-Ziel haben Sie womöglich die Sprache, aber nicht die vollständige Bibliothek. Prüfen Sie, was Ihre Cross-Toolchain mitbringt; fehlt es, füllt ein kleiner expected-artiger Typ oder eine Fehlercode-Konvention die Lücke.
- Welchen C++-Standard sollte ein neues Embedded-Projekt anvisieren?
- C++17 ist der sichere Standard – nahezu jede aktuelle Cross-Toolchain unterstützt ihn vollständig. C++20 (Concepts, std::span, constinit) ist für die meisten Linux-fähigen Ziele verfügbar. C++23 hängt von der Compiler-Version ab und sollte pro Ziel bewusst aktiviert werden.
bitshift dynamics