Suchen

C++ in Deeply-Embedded-Systemen

Autor / Redakteur: Dr. Michael von Tessin * / Sebastian Gerstl

Tief eingebettete Systeme werden meist in C implementiert. Wieso ist das so? Könnten solche Systeme nicht auch von C++ profitieren? Ein Beitrag aus der praktischen Erfahrung bei der Umstellung einer umfangreichen, produktiven Deeply-Embedded-Codebasis von C auf C++.

Firmen zum Thema

Entgegen geläufiger Vorurteile lässt sich C++ auch in Deeply Embedded Systemen, die mit geringsten Platz- und Ressourcenansprüchen auskommen müssen – wie beispielsweise Hörgeräten – effizient einsetzen. Die objektorientierte Sprache kann hier sogar zusätzlichen Nutzen bieten.
Entgegen geläufiger Vorurteile lässt sich C++ auch in Deeply Embedded Systemen, die mit geringsten Platz- und Ressourcenansprüchen auskommen müssen – wie beispielsweise Hörgeräten – effizient einsetzen. Die objektorientierte Sprache kann hier sogar zusätzlichen Nutzen bieten.
(Bild: gemeinfrei / Pixabay )

Embedded- und Deeply-Embedded-Systeme werden häufig in C statt C++ implementiert. Der Softwareentwickler muss damit auf die Vorteile von C++ verzichten - ein stark typisiertes System, Template-Metaprogrammierung (TMP) und objektorientierte Programmierung (OOP). C++ wird nicht in Embedded-Systemen verwendet, da die Compiler in Embedded-Systemen neuere Versionen des C++-Standards oft nicht unterstützen. Ein weiter Grund ist unzureichendes Fachwissen darüber, wie man C++ in diesem Bereich einsetzt, sowie die Mär, C++ stünde im Vergleich zu C stets auch für Overhead.

In diesem Beitrag zeigen wir, wie sich C++ erfolgreich in Deeply-Embedded-Systemen einsetzen lässt. Wir untersuchen bei den gängigsten C++-Sprachfeatures, ob sie Overhead in den ausführbaren Code einbringen und wenn ja, in welchem Ausmaß. Am Ende steht eine Teilmenge hilfreicher C++-Sprachfeatures, die sich in Deeply-Embedded-Systemen einsetzten lassen und auch Mixed-Memory-Systeme (ROM/RAM/NVM) abdecken. Wir validieren unsere Behauptungen anhand der Erfahrungen, die wir bei der Umstellung einer umfangreichen, produktiven Deeply-Embedded-Codebasis von C auf C++ über drei Jahre gesammelt haben.

Dürfen Sie C++ einsetzen?

Vielleicht hindern Ihre Unternehmenspolitik oder Firmenrichtlinien Sie daran, C++ für Ihr Embedded-Projekt zu verwenden; für solche Hürden kann dieser Beitrag leider keine Lösung liefern. Vielleicht gibt es auch für das Zielsystem, mit dem Sie arbeiten müssen, keinen Compiler mit adäquatem C++-Support. Zumindest bei Arm-basierten Systemen hat sich die Lage hier erheblich verbessert. Sie basiert beispielsweise die die Compiler-Toolkette von Arm mit Version 6 auf Clang/LLVM und unterstützt somit C++14 sowie künftig auch neuere Versionen des C++-Standards.

Unsere Erfahrungen und Overhead-Messungen basieren auf dem Einsatz dieser Toolkette. Unsere Applikation (Entwicklung von Hörgeräte-Firmware nach IEC 62304) läuft auf einem ASIC mit folgenden Eigenschaften: Arm Cortex-M0 CPU mit 128 KByte ROM und 144 KByte RAM.

Warum C++ verwenden?

Viele C-Entwickler fragen sich, warum sie in ihrem Embedded-System C++ verwenden sollten. Dafür spricht, dass sie dann von der objektorientierten Programmierung (OOP) profitieren können. Viele C-Codebasen versuchen bereits, OOP „vorzugaukeln“: Es werden Strukturen verwendet, die „Klassenmember“ enthalten, und es kommen Funktionen (Zeiger weist auf diese Struktur als erstes Argument) zum Einsatz, die vorgeben, „Member-Funktionen“ zu sein. Wenn Sie grundlegende OOP-Methoden mit C++ richtig verwenden, bringt Ihnen dies weitere Vorteile:

  • Leichter anwendbare Syntax
  • Public/private-Deklarationen für Variablen/Funktionen, die vom Compiler überprüft werden
  • Kontrollierte Initialisierung/Destruktion der Membervariablen mit Konstruktoren/Destruktoren

Anspruchsvollere OOP-Konzepte (mit virtuellen Funktionen), wie Vererbung und Polymorphismus, lassen sich außerdem nur schwer wartbar in C nachbilden.

Ein weiterer Vorteil durch die Verwendung von C++ liegt darin, dass Optimierungen zur Compilezeit, z.B. Template-Metaprogrammierung (TMP), sowie die Evaluierung von constexpr-Funktionen zur Compilezeit unterstützt werden. Schon die einfachste Applikation liefert uns diese Vorteile:

  • C-Makro „Konstanten“ können mit korrekt typisierten constexpr-Variablen ersetzt werden;
  • C-Makro „Funktionen“ können mit Template-Funktionen ersetzt werden. Dies erhöht die Typsicherheit; zudem lässt sich dieselbe Funktionsdefinition nicht nur zur Compilezeit, sondern bei Bedarf auch zur Laufzeit verwenden;
  • Scoped enums (enum-Klasse) bieten im Vergleich zu regulären C-enums zusätzliche Typsicherheit; und
  • Typecasts (in Embedded-Systemen oft unvermeidlich) lassen sich hinter einer kontrollierten, kleinen Bibliothek typsicherer Template-Utilityfunktionen verbergen. So werden sämtliche Typecasts aus dem (non-library) Embedded-Anwendungscode verbannt.

Templates können auch zur Optimierung der Codegröße verwendet werden. Bei einer Umsetzung in C wäre der Code dadurch schlechter wartbar. Betrachten Sie z.B. folgende Funktion:

void ThrowException(EXCEPTION_T exception);

Sie wird an verschiedenen Stellen im Code aufgerufen, immer mit einem konstanten Aufzählungswert als Argument. Bei jedem Aufruf muss der Compiler Code generieren, um die Konstante in R0 zu laden, ehe die Funktion aufgerufen wird. In C++ können wir die folgende Template-Wrapperfunktion schreiben und aufrufen:

template<EXCEPTION_T exception>
void ThrowException() { ThrowException(exception); }

Zunächst enthält der ausführbare Code nun zusätzlich für jedes im Code verwendete Exception-Argument eine Instanz dieser Funktion. Dennoch verringert sich insgesamt die Codegröße, denn der Compiler muss nicht mehr an jedem Aufrufort eine Konstante in R0 laden. In C müssten bei einer solchen Optimierung all diese generierten Funktionen vorab manuell ausprogrammiert werden; dabei gilt es, alle Exception-Argumente, die aktuell in der Codebasis verwendet werden, zu berücksichtigen. Im Sinne einer besseren Wartbarkeit lässt sich eine solche Optimierung daher nur in C++ umsetzen.

Wie viel C++ können Sie nutzen?

Unsere Analyse des Overheads im ausführbaren Code basiert auf folgenden Annahmen:

  • Exception-Handling deaktiviert (--no_exceptions)
  • RTTI (Run-time Type Information) deaktiviert (--no_rtti_data)

Diese einfachen, aber sehr hilfreichen C++-Sprachfeatures verursachen keinen Overhead im ausführbaren Code:

  • Namespaces
  • constexpr
  • static_assert
  • “auto”-Specifier
  • Scoped enums (enum-Klasse)
  • Default-Funktionsargumente
  • Überladen von Funktionen und Operatoren

Anspruchsvolle OOP-Konzepte (mit virtuellen Methoden) verursachen durch die erforderlichen VTables [6] einen Overhead: Jede virtuelle Klasse benötigt 16 Byte für eine einfache VTable, und jede virtuelle Memberfunktion in dieser Klasse vergrößert die VTable um 4 Byte. Jede Instanz dieser Klasse erfordert zusätzlich 4 Byte für den VTable-Zeiger.

Jedoch ist ein ähnlicher Overhead auch häufig in den imitierten Ansätzen in C erforderlich, mit denen diese Dereferenzierungsfunktion implementiert werden soll.

Leider hat sich gezeigt, dass sich der Einsatz der C++ STL (Standard Template Library) in den meisten Fällen aus den folgenden Gründen verbietet:

  • Der Einsatz der STL zieht oft große Mengen an Bibliothekscode nach sich, z.B. für Exception Handling, auch wenn Exceptions deaktiviert sind; und
  • Ein Großteil der STL (z.B. Container) erfordert eine dynamische Speicherallokation; dies ist in Deeply-Embedded-Systemen häufig nicht erwünscht.

Der Verzicht auf die STL hat sich aber in unserer Praxis als weniger problematisch erwiesen als gedacht. Meist haben wir auf eine der folgenden Alternativen zurückgegriffen:

  • intrusive containers (Container ohne dynamische Speicherallokation); diese werden in der Boost C++ Library angeboten; oder
  • anwendungsspezifische Container aus einer kleinen, von uns selbst geschriebenen Library.

Anwendungsspezifische Container sind besonders hilfreich bei Systemen, die bestimmte Abwägungen erfordern: Beispielsweise wenn der Laufzeit-Overhead nicht unbedingt von Bedeutung ist, aber jedes Codebyte zählt (Imagegröße) oder wenn akzeptabel ist, dass eine Map eine nicht allzu große maximale Kapazität hat, was eine einfache arraybasierte Implementierung ohne dynamische Allokation und Pointer-Linking erlaubt. Dennoch kann in diesen Fällen der Container wie jeder andere Container verwendet werden (z.B. mit einem Iterator).

C++ für Mixed-Memory-Systeme

In einem System mit Mixed-Memory-Architektur (z.B. ROM/RAM/NVM) lässt sich C++ für das ROM-Patching verwenden. Beispiel: Sie haben eine Klasse MyLibClass, die kompiliert und im ROM gespeichert wird. Dann haben Sie noch eine Klasse MyUserClass, die MyLibClass verwendet und aus dem RAM ausgeführt wird, z.B. nachdem sie beim Boot aus dem NVM geladen wurde. Nach dem Tapeout des ROM oder vielleicht sogar nach Freigabe des Produkts, das auf diesem ROM basiert, entdecken Sie einen Bug in MyLibClass, den Sie für Ihr Produkt beheben (also patchen) wollen.

Dies ist machbar, wenn die Funktionen in MyLibClass virtuell sind. Sie können dann eine Klasse MyLibClassPatched aus der Klasse MyLibClass ableiten und die fehlerhafte Funktion überschreiben. MyLibClassPatched ist im RAM zu platzieren (da der ROM sich nicht mehr ändern lässt). Nun müssen Sie in MyUserClass nur noch MyLibClass in MyLibClassPatched ändern. Damit ist der Bug behoben, ohne die gesamte MyLibClass neu im RAM implementieren zu müssen.

ESE Kongress 2020 Der Embedded Software Engineering Kongress ist die einzige deutschsprachige Veranstaltung, die sich ausschließlich und tiefgehend den vielfältigen Themen und Herausforderungen bei der Entwicklung von Geräte- und Systemsoftware für Industrieanwendungen, Kfz-Elektronik, Telekom sowie Consumer- und Medizintechnik widmet. Das reichhaltige Angebot an Kompaktseminaren und Vorträgen sowie der Erfahrungsaustausch mit Branchenexperten sorgen für die notwendige Informationsvielfalt.

Der ESE Kongress 2020 findet vom 30.11.–04.12.2020 statt. Mehr Informationen finden Sie auf www.ese-kongress.de.

Wie stellt man eine Embedded-Codebasis von C auf C++ um?

Eine solche Umstellung erfolgt am besten in vier Stufen:

1. Der Compiler soll den Quellcode als C++ statt C interpretieren.
Bei manchen Compilern reicht es aus, die Sourcedateien von “.c” in “.cpp” umzubenennen; andere benötigen dazu ein bestimmtes Argument in der Befehlszeile. Da C annähernd eine Teilmenge von C++ ist, sind für diesen Schritt normalerweise keine Änderungen am Sourcecode nötig. Doch es gibt Ausnahmen. Wichtig ist, dass dieser Schritt zunächst für die gesamte Codebasis ausgeführt wird, sonst lassen sich die nachfolgenden Schritte für bestimmte Module womöglich nicht ausführen, z.B. wenn diese von anderen Modulen verwendet werden, die noch als „.c“ benannt sind. Auch kann dieser Schritt zu einem „Mangling“, einer Verstümmelung der Namen der erzeugten Linker-Symbole führen. Für das Verknüpfen von „.c“ und „.cpp“ Modulen ist also extern „C“ erforderlich. Dies lässt sich umgehen, indem der Schritt für alle Module gleichzeitig ausgeführt wird.

2. Modifizieren Sie den Code, um einfache C++-Sprachfeatures verwenden zu können.
Um von besserer Syntax und mehr Typsicherheit zu profitieren, kann der bisherige C-Code nun wie folgt verbessert werden:

  • Verwenden Sie Namespaces statt Variablen-/Funktionsnamen mit Präfix;
  • Verwenden Sie Aufzählungstypen mit Gültigkeitsbereich (scoped enums) statt regulärer C-Aufzählungen;
  • Statt eines speziell definierten C-Bools verwenden Sie C++-Bool;
  • Nutzen Sie static_assert statt Laufzeit-Assertionen (soweit möglich);
  • Verwenden Sie constexpr statt “constant“-Makros und (falls möglich) statt const; und
  • Verwenden Sie Template-Funktionen anstelle von „Funktionen“-Makros.

3. Wandeln Sie erkennbar imitierten „C-OOP-Code“ in richtigen OOP-Code um.
Beispiel: Es gibt eine Struktur namens INSTANCE_T, und ein Zeiger auf diese Struktur wird an diverse Funktionen übergeben, die sie bearbeiten. Wandeln Sie die Struktur in eine richtige Klasse um und machen Sie die Funktionen zu Klassenkomponenten. Vergessen Sie nicht, public/private korrekt zu spezifizieren. Wenn es eine Init-Funktion gibt, denken Sie daran, diese zum Konstruktor der Klasse umzuwandeln.

In diesen ersten drei Schritten wurde der Quellcode lesbarer, wartbarer und typsicherer. Die Größe des ausführbaren Codes verändert sich unserer Erfahrung nach in den meisten Fällen nicht. Wir konnten die Schritte 1 und 2 sogar auf den meisten Modulen im ROM durchführen, denn im ausführbaren Code wurde durch das Neukompilieren kein einziges Byte verändert.

4. Führen Sie anspruchsvollere OOP-Methoden und andere C++-Sprachfeatures ein.
Diesen letzten Schritt sollten Sie mit Bedacht ausführen. Das Entwicklerteam sollte beispielsweise Richtlinien erhalten, welche C++-Sprachfeatures wie verwendet werden sollen. Beispiel: Klassenmember-Funktionen sollten nicht unnötig als virtuell definiert werden, und die STL darf nicht verwendet werden.

Neue Module würde man nun von Anfang an nach diesen Richtlinien programmieren. Diesen Schritt auf die gesamte vorhandene Codebasis anzuwenden bedeutet jedoch einen enorm hohen Aufwand. Eine gute Strategie ist somit, diesen letzten Schritt selektiv je Modul durchzuführen, z.B. wenn ein Modul ohnehin einem Refactoring unterzogen werden muss. Mit der Zeit wandelt sich die Codebasis von C- in anspruchsvolleren C++-Code. Unsere Codebasis befindet sich seit etwa drei Jahren im Übergang. In dieser Zeit sind Dutzende von Produkten aus dieser Codebasis entstanden. Einige ältere und sehr stabile Module (z.B. Gerätetreiber) sind bei uns immer noch in C und werden es auch noch länger bleiben. Wir hatten nie den Eindruck, dass dieser Mix innerhalb derselben Codebasis ein Problem darstellt.

Fazit: Sicherer, wartbarerer Code durch Einsatz von C++

Es gibt eine mächtige C++-Teilmenge, die sich für die Implementierung von Deeply-Embedded-Systemen eignet. Damit können Programmierer sichereren und besser wartbaren Code schreiben und Optimierungen umsetzen, die in C nicht möglich wären. Unnötiger Overhead im ausführbaren Code lässt sich vermeiden, wenn man sich innerhalb der oben beschriebenen Teilmenge bewegt. Mit dem vierstufigen Ansatz lassen sich bestehende produktive Codebasen zeitlich flexibel von C auf C++ umstellen.

Dieser Beitrag wurde mit freundlicher Genehmigung des Autors dem Embedded Software Engingeering Kongress Tagungsband 2018 entnommen. Übersetzung: Sabine Pagler.

* Dr. Michael von Tessin ist bei Sonova Softwarearchitekt und -entwickler für Deeply-Embedded-Systeme, wie z.B. Hörgeräte.

(ID:46546352)