Implementierung Ist C++ für echtzeitkritische Anwendungen sinnvoll?

Autor / Redakteur: Christian Gröling, Frederik Hertler, Christoph Zell, Dominic Kadynski * / Martina Hafner

Die Objektorientierte Programmierung in C++ hat unter vielen Embedded-Programmierern den Ruf, gegenüber der Strukturierten Programmierung in C weniger performant zu sein. Hierdurch wird C++ meist von vornherein ausgeschlossen.

Wenn man es richtig macht, geht C++ nicht zwingend zu Lasten der Performance.
Wenn man es richtig macht, geht C++ nicht zwingend zu Lasten der Performance.
(Bild: gemeinfrei / CC0 )

Insbesondere bei Programmteilen, die mit kleinen Abtastraten (<1 ms) arbeiten, so die Argumentation der C++-Kritiker, muss nach wie vor stark auf Rechenzeit-Overhead geachtet werden. Dennoch möchte man bei sinkenden Entwicklungszeiten und steigender Anzahl von Programmierern an einem Projekt die bekannten Vorteile der Objektorientierung nutzen.

Das Ziel dieser Veröffentlichung ist es daher, aufzuzeigen mit welchen Rechenzeit-Einbußen bei Verwendung der Objektorientierten Programmierung (OOP) mit C++ im Vergleich zur Strukturierten Programmierung (SP) in C zu rechnen ist. Weiterhin soll die Frage beantwortet werden, ob dies wirklich ein Ausschlusskriterium für den Einsatz von C++ in echtzeitkritischen Anwendungen bedeutet.

Die SP in C unterscheidet sich stark von der OOP in C++. Ein direkter Rechenzeit-Vergleich beider Programmierstile ist hierdurch kaum möglich. Hier sollen daher die wichtigsten elementaren Bausteine beider Sprachen miteinander verglichen werden.

Bildergalerie
Bildergalerie mit 10 Bildern

Untersucht werden die in Tabelle 1 angegebenen Szenarien. Für jedes Szenario wird jeweils ein Programmstück mit einem Compiler in Objektcode übersetzt und anschließend in Assemblercode konvertiert. Der Assemblercode erlaubt einen direkten Vergleich zwischen C und C++.

Weiterhin kann durch Zählen der benötigten CPU-Zyklen eine Abschätzung getroffen werden, mit wie viel Overhead bei C++ zu rechnen ist. Bei den Szenarien wird jeweils zwischen direkten und indirekten Aufrufen unterschieden. Unter einem direkten Aufruf versteht man den Zugriff auf ein Objekt / eine Funktion ohne Zwischenschritt.

Demgegenüber steht der indirekte Aufruf – ein Zugriff auf ein Objekt bzw. eine Funktion über einen Zeiger oder Referenz. Für die Untersuchungen wird die Prozessorarchitektur „Cortex-M3“ der Firma ARM verwendet [1][2]. Das Übersetzen des C++-Codes und das Auslesen des Assembler-Codes wird mit „gnu gcc“ in der Version 4.9.3 durchgeführt.

Damit der Assemblercode die Realität möglichst genau wiedergibt, wird beim Aufruf des gcc stets die Optimierungseinstellung -o2 verwendet.

Direkter Funktionsaufruf (ohne Kontext) vs. Statischer Methodenaufruf

Zum Vergleich von statischen Methoden mit Funktionen werden die in Bild 1 sowie Bild 2 dargestellten Code-Fragmente ausgewählt. In beiden Fällen wird die Methode/Funktion über einen direkten Zugriff aufgerufen und anschließend eine globale Variable geschrieben. Diese Aufrufart spiegelt, stark vereinfacht, den mit C üblicherweise verwendeten Programmierstil im Embedded-Bereich wieder.

Nach Übersetzung folgt der ebenfalls in Bild 1 und Bild 2 unten angegebene Assembler-Code. In roter Schrift sind die Prozessorzyklen angegeben, die für jeweils eine Operation benötigt werden. Dabei ist zu bemerken, dass Branch-Instruktionen vom Zustand der Prozessor-Pipeline und von der Branch-Prediction abhängen und für diese Operationen daher kein exakter Wert angegeben werden kann [1].

Die benötigten CPU-Zyklen sind demnach acht bis zwölf Prozessorzyklen für beide Fälle. Bemerkenswert ist, dass sowohl die statische Methode als auch die Funktion an einer festen Position im Speicher steht und daher direkt auf sie verzweigt werden kann. Aus Compiler- und Prozessor-Sicht sind beide Aufrufarten absolut identisch.

Direkter Funktionsaufruf mit Kontext vs. Indirekter virtueller Methodenaufruf

Wird in C gefordert, dass ein Algorithmus wiederverwendbar oder mehrfach-instanziierbar ist, so führt man üblicherweise einen Kontext mit, der einer C Funktion übergeben wird. In C++ passiert dies beim Zugriff auf Methoden implizit. In Bild 3 sowie Bild 4 sind beide Fälle im Pseudocode angegeben. Ebenfalls in den Bildern enthalten ist der Assemblercode.

Bis auf marginale Unterschiede beim Zugriff auf den Speicher ist der Assemblercode beider Fälle identisch. Wie zuvor ist der Compiler in der Lage eine Verzweigung auf eine konstante Adresse durchzuführen. Hinzugekommen ist die Ermittlung der Adresse des this-Zeigers bzw. die des Kontexts.

Der Compiler realisiert beide Fälle mit den gleichen Operationen. Aus Sicht einer Rechenzeitbetrachtung ergeben sich somit auch identische Ergebnisse. Für beide Fälle beträgt die Aufrufdauer neun bis 13 Zyklen.

Problematisch an dem oben angegebenen Testszenario ist, dass in C++-Klassenmethoden üblicherweise nicht direkt aufgerufen werden. Vielmehr ist es so, dass nach heutigen Programmierkonzepten eine geringe Kopplung zwischen den Klassen erwünscht ist, und somit mit Referenzen oder Zeigern auf eine Objektinstanz gearbeitet wird.

Der hierdurch resultierende indirekte Zugriff auf eine Methode benötigt zusätzliche Rechenzeit von zwei Taktzyklen (für das Laden des Zeigers), die im oben angegebenen Beispiel nicht enthalten sind. Diese zusätzliche Rechenzeit wird durch die Abfrage der Instanz Adresse aus dem Zeiger/Referenz hervorgerufen.

Indirekter Funktionsaufruf mit Kontext vs. virtueller Methodenaufruf

Die wichtigsten Prinzipien der OOP sind Vererbung und Polymorphie. Beide Prinzipien lassen sich in C ebenfalls nachprogrammieren. Da die Sprache C aber für SP entwickelt wurde, ist eine vollständige Nachbildung der OOP mit großem Aufwand verbunden.

Daher wird an dieser Stelle hierauf verzichtet. Für einen Vergleich wird wiederum auf eine Aufgabenstellung mit eingeschränktem Funktionsumfang zurückgegriffen.

Bildergalerie
Bildergalerie mit 10 Bildern

Ein Algorithmus soll zur Laufzeit austauschbar sein. In C kann dies über Funktionszeiger und ein Kontext-Argument erreicht werden. In C++ würde man von einer Basisklasse erben und durch die Basisklasse auf den Algorithmus zugreifen.

In Bild 5 sowie Bild 6 sind beide Fälle im Pseudocode angegeben. Ebenfalls in den Bildern enthalten, ist auch der resultierende Assemblercode.

Wie an dem Assemblercode zu erkennen ist, ist der Compiler nun nicht mehr in der Lage, direkt auf die Funktion/Methode zu verzweigen. Die Sprungadresse muss in beiden Fällen aus der Objektinstanz bzw. dem Kontext abgefragt werden. Dies benötigt zusätzliche Zyklen.

Im Vergleich zu dem Funktionsaufruf muss die Adresse der virtuellen Methode zusätzlich aus einer speziellen Tabelle - genannt virtual function table - abgefragt werden. Wie die virtual function table aufgebaut ist, kann [4] entnommen werden.

Letztendlich ist die Verwendung eines Funktionszeigers beim gegebenen Beispiel um durchschnittlich 2 Taktzyklen schneller. Allerdings muss hier gesagt werden, dass der Ansatz über virtuelle Methoden bei weitem flexibler ist. Insbesondere da dieses Sprachkonstrukt elementar für die gesamte OOP ist.

Der Fall der Mehrfachvererbung sowie die Verwendung von Vererbungshierachien hat den gleichen Performance Overhead wie die Einfachvererbung, da für jede vererbte Klasse eine eigene virtual function table erstellt wird.

Vergleich der Aufrufarten

Zusammenfassend sind die Ergebnisse in Tabelle 2 der Bildergalerie angegeben. Der maximale Unterschied für die gegebenen Testszenarien sind zwei Taktzyklen. Wenn man bedenkt, dass ein Zyklus typischerweise wenige Nanosekunden benötigt (100MHz = 10ns), ist der Overhead von C++ gegenüber C marginal.

Ein Zahlenbeispiel soll dies verdeutlichen: Angenommen man würde auf einem 100 MHz schnellem Cortex-M3 100 virtuelle Methoden indirekt aufrufen. Jede Methode schreibt eine Membervariable. Im Vergleich zu 100 indirekten Funktionsaufrufen mit Kontext, die jeweils eine Variable einer Struktur schreiben, wäre der zeitliche Unterschied ca. zwei Mikrosekunden.

Für den Großteil der Anwendungen ist dies durchaus vertretbar, insbesondere im Hinblick auf die genannten Vorteile der OOP.

Embedded-Programmierstil

Trotz der oben gemachten Erkenntnisse ist es ohne Weiteres möglich, in C sowie in C++ „langsamen“ Code zu schreiben. Allerdings fällt insbesondere bei C++ auf, dass die Literatur der letzten Jahre einen verständlichen aber eher ineffizienten Programmierstil fördert. Ein Vergleich beider Sprachen nutzt nur wenig, wenn in C++ ineffizienter programmiert wird.

Die nachfolgende Liste beschreibt die häufigsten Ursachen für ineffizienten Echtzeit-Code in C++:

1. Übermäßige Verwendung von Interfaces

Durch den zusätzlichen Overhead, welcher durch den Aufruf von virtuellen Methoden entsteht, sollten Interfaces (abstrakte Klassen) nur dort wo es unbedingt notwendig ist, verwendet werden. In folgenden Fällen lassen sich Interfaces nur schwer vermeiden:

  • Trennung von unterschiedlichen Schichten im System: Dies bietet Vorteile beim Testen und vermeidet zu viele Abhängigkeiten zu anderen Schichten. Man sollte aber darauf achten, dass die Schichten nicht zu feingranular werden.
  • Austausch von Algorithmen: Müssen Algorithmen zur Laufzeit ausgetauscht werden, so führt kein Weg an Interfaces vorbei (siehe Bild 7). Steht zur Compile-Zeit fest welcher Algorithmus verwendet werden soll, so bieten sich Class-Templates (z. B.: Policy-Pattern) zur Vermeidung von Interfaces an (siehe Bild 8).

Im dynamischen Fall, muss die Methode update() zwingend virtuell sein. Dies ist im statischen Fall nicht erforderlich. Somit ergibt sich ein erheblicher Performance Vorteil.

2. Zugriff auf Member

Im embedded Bereich ist es heutzutage noch üblich, direkt auf globale Variablen zuzugreifen. Aus Compiler Sicht ist dieses Vorgehen sehr performant. Moderne Softwareentwicklungsmethodiken verzichten auf derartige Konstrukte, da sie sehr fehleranfällig sind. Stattdessen sind Getter/Setter Methoden die Regel.

Diese meist sehr elementaren Methoden sollten stets mit dem Attribut inline versehen werden. Dies erlaubt dem Compiler die Methode direkt einzufügen. Berücksichtigen muss man hierbei nur, dass inline Methoden nicht mehr virtuell sein können.

3. Dynamisches Speichermanagement

Bei C++ ist die gängige Lehrmeinung, möglichst alle Objekte dynamisch anzulegen. Problematisch hieran ist, dass die Allokation und Freigabe von Speicher große Mengen Rechenzeit benötigt und nicht mehr deterministisch ist. Der Allokator muss eine Lücke im Speicher suchen, diese Lücke mit dem Inhalt füllen, Fragmentierung möglichst vermeiden, usw... .

Entsprechend darf new und delete in echtzeitkritischem Code nicht verwendet werden. Besser ist die „einmalige“ Allokation aller echtzeitkritischen Objekte zum Programmstart. Die meisten Allokatoren sperren darüber hinaus die Interrupts. Wenn eine minimale Interrupt-Latenz gefordert ist, müssen daher selbst nicht echtzeitkritische Programmteile ihren Speicher vor Beginn des echtzeitkritischen Betriebs allozieren.

4. Verwendung der Standard Template Library (STL)

Die STL ist eine Template Bibliothek mit Schwerpunkt auf Algorithmen und Datenstrukturen. Sie ist Teil des aktuellen C++ Sprachstandards. Die STL benötigt hochgradig das zuvor beschriebene dynamische Speichermanagement. Insbesondere Container sollten in echtzeitkritischem Code nicht verwendet werden.

5. C-Casts vs. C++-Casts

Für die Umwandlung eine Types existieren in C++ unterschiedliche Cast-Funktionen. Der „reinterpret_cast<>“ verhält sich zu dem C-Cast identisch hinsichtlich Funktion und Rechenzeit. Der „static_cast<>“ hat die gleiche Rechenzeit, führt aber zur Compile-Zeit eine Prüfung durch.

Er lässt sich nur für verwandte Typen oder fundamentale Typen benutzen. Auf den „dynamic_cast<>“ sollte man aus Rechenzeit, wie auch aus Speicherverbrauchs Gründen verzichten.

Zusammenfassung

C++ ist für die heutigen Anforderungen der Entwicklung wesentlich besser geeignet als C. Die Aussage, dass der Einsatz von C++ hierbei auf Kosten der Performance geht, konnte nicht bestätigt werden.

Vielmehr ist es so, dass beide Sprachen sich in einem vergleichbaren Rahmen bewegen. Unter Berücksichtigung elementarer Regeln, wie z.B., dass Vererbung in rechenzeitkritischen Code nur sehr begrenzt eingesetzt werden sollte, kann C++ für Echtzeitanwendungen bedenkenlos eingesetzt werden.

Dieser Beitrag stammt ursprünglich von Embedded-Software-Engineering.de, einer Publikation unseres Schwesterportals Elektronikpraxis.

* Dr.- Ing. Christian Gröling beschäftigte sich während seiner Promotion an der TU-Braunschweig mit embedded Software mit Fokus auf die elektrische Antriebstechnik. Diese Themenschwerpunkte baute er während seiner nachfolgenden Tätigkeit als Softwareentwickler bei der Firma LTi Drives GmbH, sowie anschließend bei Festo AG & Co. KG weiter aus. Über mehrere Jahre konnte er so Erfahrung in der embedded Softwareentwicklung insbesondere im Bereich kostensensitiver Applikationen sammeln.

Artikelfiles und Artikellinks

(ID:44612855)