C++ in Embedded Systemen: Lessons Learned!

Von Matt Kline *

Zahlreiche Unternehmen steigen inzwischen für die Embedded-Firmware-Entwicklung von C auf C++ um. Mit C++ lässt sich Firmware entwickeln, die sicherer und expressiver ist. Doch einige Features können sich als zweischneidiges Schwert entpuppen.

Anbieter zum Thema

Der Umstieg auf C++ kann für die Embedded-Entwicklung einige Hürden beinhalten.
Der Umstieg auf C++ kann für die Embedded-Entwicklung einige Hürden beinhalten.
(Bild: gemeinfrei / CC0 )

Viele der C++ Features, z.B. Klassen, automatische Ressourcenbereinigung, parametrischer Polymorphismus und zusätzliche Typsicherheit, sind auf einem RTOS oder direkt auf der Hardware ebenso nützlich wie auf einem Desktop mit Universal-Betriebssystem.

Die „auto“-Magie von C++ ist jedoch ein zweischneidiges Schwert. Einige Sprachfeatures hängen von Systemfunktionen ab, die sich für Embedded-Umgebungen nicht eignen1. Die Toolkette anzupassen ist häufig auch komplex. libgcc und libstdc++ sollen nicht gänzlich verworfen werden, denn sie ermöglichen wichtige Funktionen wie memcpy, atomare Operationen und hardwarespezifische Floating-Point-Funktionalität; bestimmte Bereiche davon sind aber zu meiden.

Dieser Beitrag beleuchtet in Kürze, was wir bei der Umstellung unserer Firmware-Entwicklung auf C++ gelernt haben, und bietet eine hoffentlich hilfreiche Basis für ähnliche Unterfangen.

Erzeugen einer Toolchain

GCC eignet sich gut als Cross Compiler für sämtliche Targets, auch für die ARM-Systeme, die wir gewöhnlich in der Embedded-Entwicklung einsetzen 2. Einige Varianten lassen sich oft aus dem Package Manager der Linux-Distribution installieren, doch es ist ausdrücklich empfohlen, dass die Teams einen eigenen Cross-Compiler bauen und einsetzen. Das bietet etliche Vorteile:

  • Wenn das gesamte Team die gleiche Version der gleichen Toolkette verwendet, erhalten alle die gleichen Builds. Beim Debuggen und Testen ist das von Vorteil.
  • Compiler werden seit den letzten Updates auf C++ immer schneller entwickelt, und viele neuere Versionen ermöglichen eine erheblich verbesserte Codegenerierung 3. Bei einem früheren Projekt sind wir auf Compiler-Bugs in älteren Versionen (4.8.x) gestoßen, die zu einem Absturz unseres Systems führten.

Es ist meist aufwändig, eine ganze Cross-Compiler-Toolchain zu erzeugen. Gute Erfahrungen bestehen hier mit crosstool-NG. Damit können Sie Ihre Toolkette mittels einer Schnittstelle ähnlich Linux make nconfig konfigurieren.

Zudem unterstützt crosstool-NG Verwaltung und Download von Abhängigkeiten und führt die Builds für Sie aus. Die neueren Versionen ermöglichen auch die Bereitstellung beliebiger GCC-Quellen, z.B. die aktuellste Release. Die resultierenden Binärdateien lassen sich statisch verknüpfen. Die Verteilung ist also ebenso einfach wie ein tar-File der Toolkette zu erzeugen und es zentral bereitzustellen. Projektmitarbeiter, die die Toolkette verwenden, können dann über ein kurzes Skript auf sie zugreifen, sie extrahieren und ausführen.

Verknüpfen von C-Code

In unseren Embedded-Projekten gibt es oft zahlreiche C-Abhängigkeiten, z.B. herstellerspezifische Treiber und das RTOS. Erzeugen Sie diese mithilfe von gcc und wrappen Sie alle Header, die Sie über #include in C++ einbinden, mit extern “C“ { }. Ebenso sind sämtliche Funktionen, die Sie aus einer nicht-C++-Umgebung heraus aufrufen wollen, z.B. RTOS-Funktionen oder Startup-Assembly, mit extern “C“ zu kennzeichnen. Damit wird der Compiler daran gehindert, das sonst übliche „Mangling“ der Symbolnamen auszuführen.

Compiler-Flags

Exception Handling und RTTI sind ohne Speicherallokation schwierig bereitzustellen und lassen sich, falls gewünscht, über -fno-exceptions, -fno-non-call-exceptions und -fno-rtti deaktivieren. Beim Reboot wird die Firmware niemals auf die gleiche Weise beendet wie ein Userspace-Programm. Teardown-Code (einschließlich globaler Destruktoren) lässt sich mittels -fno-use-cxa-atexit verwerfen. Weitere hilfreiche Flags für die Embedded-Entwicklung sind z.B.

  • -ffreestanding: Gibt an, dass sich Ihr Programm in einer Umgebung befindet, in der es möglicherweise keine Standardbibliothek-Funktionen gibt und Ihr Programm nicht bei main() beginnt.
  • -fstack-protector-strong: Wird nachfolgend beschrieben.
  • -fno-common: Stellt sicher, dass jede globale Variable nur einmal deklariert wird, in einem einzelnen Objekt. Auf manchen Targets lässt sich damit die Performance verbessern.
  • -ffunction-sections und -fdata-sections: Teilen Funktionen und Daten eine eigene ELF Sektion zu. Damit kann der Linker mit der Option --gc-sections zusätzliche ungenutzte Funktionen eliminieren.

Diese Flags sind nicht C++ spezifisch, sollen aber an dieser Stelle erwähnt werden.

Sprachfeatures aktivieren

Wie erwähnt, benötigen einige hilfreiche C++ Features die Unterstützung des darunterliegenden Systems. In einer reinen Hardware- oder RTOS-Umgebung müssen wir diese bereitstellen.

Die meisten Details sind natürlich implementierungsspezifisch. Unser Beitrag basiert auf unserer Erfahrung mit ARM Cortex-M4 Boards mit GCC6 und ist hoffentlich ein guter Ausgangspunkt, auch wenn Projekte natürlich nicht in allem übereinstimmen.

Initialisierung von globalen Objekten

Globale Objekte sind hilfreich bei der Definition von Schnittstellen zu Hardwareressourcen in einem Embedded-System. Diese Objekte können Konstruktoren enthalten. Die C++ Laufzeit stellt normalerweise sicher, dass alle globalen (oder File-lokalen) Objekte vor dem Eintritt in main() erzeugt werden. In einer Embedded-Umgebung müssen wir die Konstruktoren jedoch selbst aufrufen.

GCC gruppiert sie in ein Funktionszeiger-Array mit dem Symbolnamen init_array. Nach Hinzufügen eines Eintrags zum Linker-Skript, der in etwa wie folgt aussehen kann:

. = ALIGN(4);
.init_array :
{
    __init_array_start = .;
    KEEP (*(.init_array*))
    __init_array_end = .;
} > FLASH

lassen sich die Funktionen so aufrufen:

static void callConstructors()
{
    // Anfangs- und End-Punkte der Constructors-Liste,
   
// wie sie durch das Linker-Skript definiert sind.
   
extern void (*__init_array_start)();
    extern void (*__init_array_end)();

    // Rufe jede Funktion in dieser Liste auf.
   
// Wir müssen die Adressen der Symbole nehmen, denn
    // __init_array_start *IST*
   
// der erste function pointer, nicht dessen Adresse.
    for (void (**p)() = &__init_array_start; p < &__init_array_end; ++p) {
        (*p)();
    }
}

Nun stellt sich die Frage, wann dieser Schritt auszuführen ist. Nach der Hardware-Initialisierung? Nach dem RTOS-Setup, aber noch ehe Ihre Tasks ausgeführt werden? In der ersten RTOS-Task? Je nachdem, wie Sie das festlegen, sollten Sie vielleicht auch sicherstellen, dass diese Konstruktoren keine OS-Aufrufe ausführen oder den Hardware-Status verändern – außer RAM natürlich.

Vererbung

Bei Embedded-Systemen sollten Vererbung und Laufzeit-Polymorphismus mit Bedacht verwendet werden. Der Operator delete ist jedoch erforderlich, wenn wir den Destruktor einer Basisklasse virtuell machen – eine gängige Vorgehensweise – auch wenn wir ein Objekt dieser Klasse nie auf dem Heap erzeugen 4. Die libgcc Versionen gehen von einem Unix-artigen Userspace aus, also sollten wir einen eigenen definieren. Wenn wir die dynamische Speicherallokation vermeiden wollen, kann ein Aufruf von delete einen gravierenden Softwarefehler darstellen. Das darf uns dann gern in Panik versetzen.

void operator delete(void* p)
{
    DIE("delete called on pointer %p (was an object heap-allocated?)", p);
}

// Wie obiges Beispiel, nur als C++14 Spezifikation.
// (siehe http://en.cppreference.com/w/cpp/memory/new/operator_delete)

void operator delete(void* p, size_t t)
{
    DIE("delete called on pointer %p, size %zu", p, t);
}

Wenn Objekte mit virtuellen Destruktoren auf dem Stack erzeugt werden, wird eine Destruktor-Version ohne Operator delete verwendet.

Nicht empfohlene Sprachfeatures: Statische Objekte in Funktionen

Betrachten wir die Funktion some mit einer statischen Variablen:

void foo ( )
{
    static Bar someObject;
    // hier nun etwas mit someObject arbeiten.
}

Wenn sich das Objekt mittels Platzierung in den Sektionen .data oder .bss trivial initialisieren lässt, ist alles in Ordnung. Problematisch wird es erst, wenn ein Konstruktor zur Laufzeit aufgerufen werden muss, um someObject zu initialisieren. C++11 stellt sicher, dass die Konstruktion eines lokalen statischen Objekts frei von Race-Konditionen erfolgt. Das heißt, wenn mehrere Threads gleichzeitig foo() aufrufen, muss der Compiler einen Synchronisationsmechanismus 5 bieten, damit nur ein Thread den Initialwert des Objekts festlegt.

Da wir in unserem System Funktionen aufrufen können, ehe das OS läuft und Synchronisationsmechanismen zur Verfügung stehen, und da wir in einem Embedded-System normalerweise die gesamte Initialisierung beim Start des Embedded-Systems ausführen wollen, damit der nachfolgende Code möglichst deterministisch ist, wird stattdessen empfohlen, dass sämtliche statischen Objekte auf Dateiebene platziert werden.

Alternativ lässt sich mittels -fno-threadsafe-statics kompilieren, wenn sichergestellt ist, dass nicht gleichzeitig Funktionen mit statischen Objekten aufgerufen werden.

Nicht empfohlene Sprachfeatures: Exceptions

Moderne Exception-Handling-Mechanismen sind komplex 6. Die meisten Implementierungen, wie z.B. glibc, erfordern dynamische Speicherallokation und andere Funktionen, die uns nicht zur Verfügung stehen. Externe Lösungen, wie libunwind gehen auch davon aus, dass sie in einem Unix-ähnlichen Userspace eingesetzt werden. Aufgrund dieser Komplexität haben wir davon abgesehen, in unseren Embedded-Projekten Exceptions zu verwenden.

Wenn Sie diese Hürden überwinden wollen, könnte der folgende Vortrag von Ryan Quinn auf der CppCon 2016 eine gute Hilfestellung bieten. Um C++ Code im Linux-Kernel auszuführen, erstellte er eine eigene Stack Unwinding Bibliothek.

Erkennung von Stack-Überlauf

Betrachten wir eine Funktion, die Speicher auf dem Stack verwendet:

void bar ( )
{
    char arrayOnStack[10];
    // ...lese und schreibe ins lokale array...
}

Wenn wir auf ein Element zugreifen, das außerhalb der Array-Begrenzung liegt, beschädigen wir damit eventuell den Speicher, der dem aktuellen Stack-Frame folgt, und verursachen ein undefiniertes Verhalten - dann brauchen wir uns über den Zustand unseres Programms nicht wundern. Zum Glück kann der Compiler eine gewisse Sicherheit bieten, die uns lediglich einen Schreib- und einen Lesevorgang je Funktion kostet. Erhält der GCC eine der nachfolgenden Flags, wandelt er die o.a. Funktion in etwa wie folgt um:

void bar()
{
    // Assuming the stack grows down, out-of-bounds writes
    // to arrayOnStack may clobber _canary.

    uintptr_t _canary = __stack_chk_guard;

    char arrayOnStack[10];
    // ...read and write to the local array...
    if (_canary != __stack_chk_guard) {
        // We have done terrible things to our stack. Panic.
        __stack_chk_fail();
    }
}

Mit verschiedenen Flags lässt sich steuern, wie häufig der Compiler diese Checks hinzufügt. Hier eine kurze Übersicht, damit Sie das nicht extra nachschlagen müssen:

  • -fstack-protector: Fügt Guards ein für Funktionen, die alloca aufrufen, und für Funktionen mit [character] Puffern größer 8 Byte.
  • -fstack-protector-strong: Fügt Guards ein für Funktionen, die lokale Array-Definitionen oder Referenzen auf lokale Frame-Adressen haben. Dies ist die allgemein empfohlene Einstellung.
  • -fstack-protector-all: Fügt Guards für jede Funktion ein; dies ist möglicherweise zu viel des Guten und für ein Embedded-System zu kostspielig.
  • -fstack-protector-explicit: Fügt Guards ein für Funktionen, die mit einem stack_protect Attribut gekennzeichnet sind. Unter Umständen zu mühsam, um es effizient anzuwenden.

Um diese Guards verwenden zu können, müssen wir beide Symbole bereitstellen, die im obigen Beispiel dargestellt sind. Eines ist der Canary und das andere ist eine Funktion, die aufzurufen ist, wenn ein Stack beschädigt ist. Wenn Sie in so einem Fall in Panik verfallen oder ein Reboot ausführen, in das durchaus verständlich.

extern "C" {

// Der canary value
extern const uintptr_t __stack_chk_guard = 0xdeadbeef;

// Wird abgerufen, wenn der check fehlschlägt
[[noreturn]]
void __stack_chk_fail( )
{
    DIE("Stack overrun!");
}

} // end extern "C"

Der Compiler kümmert sich um den Rest. Hinweis: Wenn es ganz unglücklich läuft und wir es fertigbringen, über den aktuellen Stack-Frame hinaus zu lesen oder schreiben, ohne den Canary zu modifizieren, erkennt dieses System die Beschädigung nicht. Das Leben kann grausam sein.

Inlining und Optimierung

Bei der Embedded-Systementwicklung besteht eine der Herausforderungen oft darin, den Code so klein wie möglich u halten. Inlining scheint dazu eher kontraproduktiv zu sein, doch das ist nicht immer der Fall. Wenn Trivialcode als inline Funktionen in Header eingefügt wird, können moderne C++ Compiler unfassbar kleinen und effizienten Output erzeugen. Ein sehr interessantes Beispiel finden Sie im Vortrag von Jason Turner auf der CppCon2016: Rich Code for Tiny Computers (siehe auch folgendes Video).

Wenn Sie Firmware mit -Os erzeugen (d.h. auf Größe optimiert), wäre auch überlegenswert, folgendes hinzuzufügen:

  • -finline-small-functions: Für das Inlining von Funktionen, wenn der Compiler der Meinung ist, der Funktionskörper enthält weniger Befehle als Code, der zu dessen Aufruf erforderlich ist.
  • -findirect-inlining: Führt weitere Inlining-Vorgänge aus. Beispiel: Bei einem Aufruf von a() durch main() und einem Aufruf von b() durch a() könnte der Compiler den Körper von a() in main() einfügen. Dann erkennt er, dass auch b() ein guter Inlining-Kandidat ist, und fügt es ein. Durch solche nachrangigen Befehle lassen sich erhebliche Verbesserungen erzielen.

Aktuelle Projekte werden mithilfe von –O2 erzeugt, das „nahezu alle unterstützten Optimierungen bietet, die nicht zu Lasten von Platz und Geschwindigkeit gehen“. Auch hier ist die Devise: Testen, testen und nochmal testen! ARM und andere RISC-Architekturen erzeugen besonders gut lesbare Disassemblies. Machen Sie sich ein Bild davon, was der Compiler hervorbringt, wenn er verschiedene Möglichkeiten hat.

Probieren Sie auch den Godbolt compiler explorer aus; er markiert das Disassembly farbig und zeigt damit an, aus welchen Codezeilen es erzeugt wurde. Viel Glück und viel Erfolg!

* Der Autor: Matt Kline ist Software Engineer für Fluke Networks und Betreiber des Software-Entwicklungs-Blogs bitbashing.io. Der Beitrag "C++ On Embedded Systems" wurde lizensiert nach einer Creative Commons Attribution-ShareAlike 4.0 International License. (CC BY-SA 4.0). Übersetzung Sabine Pagler.

Literaturhinweise

[1] Die dynamische Speicherallokation ist das einfachste und gängigste Beispiel. Normalerweise ist sie in einem Embedded-System zu vermeiden – zumindest nach dem Startup –, doch für Exception Handling und viele weitere C++ Funktionen ist sie erforderlich.
[2] Clang bietet offenbar auch gute Cross-Compilation-Tools an, doch bisher haben wir diese nicht für unsere Firmware ausprobiert.
[3] Siehe Beispiel auf https://gcc.gnu.org/bugzilla/show_bug.cgi?id=69878.
[4] Siehe http://stackoverflow.com/q/31686508und http://eli.thegreenplace.net/2015/c-deleting-destructors-and-virtual-operator-delete/ für weitere Informationen. Leser haben uns freundlicherweise darauf hingewiesen, dass wir virtuelle Destruktoren gänzlich vermeiden sollten, wenn wir keine polymorphe Lösung benötigen. Wenn diese hinzugefügt werden, wird die Klasse größer, da eine vtable erforderlich ist.
[5] Siehe http://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/
[6] Siehe https://mentorembedded.github.io/cxx-abi/abi-eh.html für die vollständige Spec.

Literaturhinweise:

(ID:44953631)