C++ in der Embedded-Entwicklung: Embedded-Code am PC testen

Von Dr. Hartmut Schorrig

Anbieter zum Thema

Im Embedded-Bereich greifen viele Entwickler noch zur Programmiersprache C. Dabei hat das objektorientierte C++ hier diverse Vorteile, die C nicht bieten kann. Diese Artikelreihe beschäftigt sich mit C++ in der Embedded-Entwicklung. Teil 1: Testen von Embedded-Code am PC.

Obwohl C++ bereits seit Jahrzehnten abwärtskompatibel zu C existiert und für die meisten „embedded“-Zielplattformen die Compiler auch als C++-Versionen angeboten werden, ist die Akzeptanz von C++ für die Embedded Programmierung vergleichsweise immer noch zu gering. Die Aussagen sind vielfältig und widersprüchlich. Die mit diesem ersten Artikel begonnene Serie soll für die Verwendung von C++ werben, aber gleichzeitig die Stellen aufzeigen, wo für Embedded Control im Vergleich zur PC-Programmierung besondere Bedingungen beachtet werden müssen.
Obwohl C++ bereits seit Jahrzehnten abwärtskompatibel zu C existiert und für die meisten „embedded“-Zielplattformen die Compiler auch als C++-Versionen angeboten werden, ist die Akzeptanz von C++ für die Embedded Programmierung vergleichsweise immer noch zu gering. Die Aussagen sind vielfältig und widersprüchlich. Die mit diesem ersten Artikel begonnene Serie soll für die Verwendung von C++ werben, aber gleichzeitig die Stellen aufzeigen, wo für Embedded Control im Vergleich zur PC-Programmierung besondere Bedingungen beachtet werden müssen.
(Bild: Clipdealer)

Im ersten Artikel wird auf das Thema „Entwickeln für Embedded – Testen auf dem PC“ eingegangen. Die Objektorientierte Programmierung kompatibel in C und C++ wird als Schlüssel-Herangehensweise auch mit Codebeispielen zur Beantwortung der Frage der Laufzeiteffektivität dargestellt.

C++ für PC-Programmierung versus Embedded

Auf dem Gebiet der PC-Programmierung ist C++ vollständig etabliert. Lediglich API-Schnittstellen (Application Interface) zum Betriebssystem sind traditionell meist in C formuliert. Schaut man auf das Thema C++ mit Blick auf diese Verbreitung und Akzeptanz und wendet dieses also gleichsam auf Embedded an, wird man an dem einen oder anderem Punkt in der Praxis scheitern, ohne vielleicht genau zu wissen warum das nicht funktionierte. Nicht jeder hat immer Zeit für Tiefenanalyse. Diese Erfahrungen werden weitergegeben. Das kann eines der Gründe für das Beharren auf C für Embedded Control sein.

Doch worin liegen die Unterschiede?
a) Programme auf dem PC in C++ laufen häufig nur eine begrenzte Zeit: Das Programm wird gestartet, die Aufgabe wird erledigt, danach fertig.
b) Die Ressourcen auf dem PC, Speicherplatz, Rechenzeit, Kommunikation, sind dem Entwicklungsfortschritt entsprechend hoch. Auf dem PC ist nicht deshalb ein noch größerer RAM-Speicher installiert, weil er benötigt wird, sondern weil es dem technischen Fortschritt entspricht. Im Embedded Bereich werden dagegen oft auch Boards älterer Bauart eingesetzt, oder die Ressourcen sind aus Leistungsbedarfs- und anderen Gründen begrenzt.
c) Embedded Anwendungen müssen häufig Echtzeitanforderungen erfüllen. Dies geht bis in Abtast- und Rechenzyklen im µs-Bereich und Latenzzeiten im kleinen µs-Bereich. Das ist keine Frage einer hohen Rechengeschwindigkeitsanforderung, sondern die Frage, wieviel µs für unerwartete Dinge auftreten können. Wenn ein selten aufgerufener Algorithmus auf einem Embedded Prozessor 100 µs braucht, dann geht dies gar nicht wenn der Rechenzyklus nur 100 µs beträgt. In einer PC-Applikation würde der gleiche Algorithmus möglicherweise 10 µs brauchen und überhaupt nicht auffallen.

Alle drei Punkte miteinander bedeuten, dass gängige Programmierpraxis und Erfahrungen auf dem PC in C++ unbesehen auf Embedded übertragen schnell zum Scheitern führen können. Also bleibt man doch lieber beim bewährten C? Das wäre zu hinterfragen.

Warum lohnt sich der Einsatz von C++ für Embedded Control?

C++ hat gegenüber C zwei markante Unterschiede, die lohnenswert sind zu betrachten:

  • Zwar unterschiedlich abhängig vom Compiler und diversen Settings, aber ein C++-Compiler führt meist schärfere Prüfungen des Codes aus und erzeugt dann Fehler, die bei C meist nur als Warnings ausgegeben werden und daher oft nicht beachtet werden. Eine Überprüfung von Zeigertypen ist essentiell, da hier Schreibfehler durchaus häufig sind. Früher waren Programme kürzer, die Entwickler hatten vielleicht mehr Zeit und konnten über den Quelltext als solchen länger nachdenken. Heute ist man von Auto-Vervollständigung und von strengen Überprüfungen aus anderen Programmiersprachen verwöhnt, vertraut darauf und übersieht beispielsweise eine Zeigerverwechslung in C; und
  • C++ hat eine prägnantere Syntax was Aufruf von Operationen in Objektorientierung betrifft. Die Programme sind besser strukturiert, kürzer, besser überschaubar.

Dagegen ist das Argument, in C++ würden umfangreiche Libraries und viele Möglichkeiten zur Verfügung stehen, für Embedded Control nicht zutreffend. Denn: Die umfangreichen Libraries sind eher für PC-Projekte gedacht. Im Embedded Control ist man geneigt, oder es ist notwendig, jede Funktion genau zu kennen. Daher ist vom Einsatz vieler Libraries eher abzusehen.

Abwärtskompatibilität: Kann man ein C-Programm mit C++ compilieren?

Dies ist eine entscheidende Frage, und sie ist klar mit einem „Ja“ zu beantworten. Man kann fast jedes C-Programm mit einem C++-Compiler übersetzen, erhält gegebenenfalls schon daher, weil es ein neuerer C++-Compiler für den PC ist, bessere Fehlermeldungen verglichen mit dem bewährten und daher älterem C-Compiler für Embedded, kann diese Fehler auf PC-Basis korrigieren. Man kann sogar weitestgehend auf dem PC testen, wie im nachfolgendem Abschnitt gezeigt wird, und erhält somit ein gut getestetes Programm, das nur für das Zielsystem mit dem älteren C-Compiler übersetzt wird. Wenn man nun unterstellt, dass der C-Compiler für bekannte Algorithmen fehlerfrei ist, dann läuft die Software.

Es gibt wenige Dinge, die nicht abwärtskompatibel sind, die also in (ANSI-) C compiliert werden und in (ANSI-) C++ syntaktisch verboten sind. Selbst in Legacy Code sind diese aber oft einfach ersetzbar. Oft ist es auch nur ein Problem von älteren C++ Versionen. ANSI-C kann als echte Teilmenge von ANSI-C++ angesehen werden.

Es gibt aber das Problem der C-Dialekte, die die Verständigung etwas erschweren und teils tatsächlich dem Compiler geschuldet sind, teils aber den User-Gepflogenheiten. Wesentliche Aspekte daraus werden im folgenden Kapitel dargestellt.

Jetzt Newsletter abonnieren

Verpassen Sie nicht unsere besten Inhalte

Mit Klick auf „Newsletter abonnieren“ erkläre ich mich mit der Verarbeitung und Nutzung meiner Daten gemäß Einwilligungserklärung (bitte aufklappen für Details) einverstanden und akzeptiere die Nutzungsbedingungen. Weitere Informationen finde ich in unserer Datenschutzerklärung.

Aufklappen für Details zu Ihrer Einwilligung

Ein weiteres Problem ist der direkte Zugriff auf Hardware (auch in C benannte CPU-Spezialregister und dergleichen) und auf Betriebssystemaufrufe, die in der Embedded Umgebung ganz anders sind als bei einer PC-Simulation. An dieser Stelle hilft Wrappen. Dieses Thema ist auch im Folgekapitel aufgegriffen.

Hat man nun diese beiden Probleme im Griff, dann kann man auf dem PC einen Algorithmus ohne seine Echtzeitaspekte, und mit nachgestellten Daten, also in einem „testbed“, der Testumgebung testen. Damit ist der Algorithmus selbst testbar als Modul- oder Unit-Test, und der C++-Compiler wird für die Prüfung der Quellen benutzt.

Man kann noch sehr viel weiter gehen und die gesamte Software mit Emulation und Simulation der Umgebung auf dem PC testen. Das ist auch anzuraten, sprengt aber den Umfang dieses Artikels.

Ist C gleich C und C immer eine Teilmenge von C++?

Diese Frage ist nicht nur für die Wiederverwendbarkeit von Software für verschiedene Plattformen wichtig, sondern auch für die Möglichkeiten des Testens von C-Quellen in C++ auf dem PC, wie es im Vorkapitel angedeutet ist.

Die Beantwortung der Frage in Kurzfassung ist optimistisch gesehen „Ja“, aber man muss möglicherweise einige Dinge bereinigen. In der Regel wird diese Frage häufig mit „Nein“ beantwortet:

Die Programmiersprache C hatte von Anfang an den Anspruch, übergreifend für verschiedene Computerplattformen einheitlich zu sein. Dieser Anspruch ist teils verlorengegangen, weil die Standardisierung diesem nicht standgehalten hat und der Entwicklung hinterherhinkte. Zudem wollten verschiedene Compilerhersteller gern eigene Features etablieren.

Man zieht sich von dieser Frage zurück, indem häufig im embedded Bereich für die bestimmte Plattform programmiert wird, einschließlich der Nutzung der jeweiligen zielsystemspezifischen IDE (Integrated Development Environment). Dann passt alles zueinander Wenn es um das Testen geht, dann gibt es die zielsystemspezifischen Hardwarezugriffsmechanismen (Debug in der Zielsystem-CPU, JTAG-Anschluss) oder andere passende Mechanismen wie spezielles Datenlogging. Ein Ablauf der Programme auf dem PC erfolgt häufig nur in der Voruntersuchungsphase.

Doch damit ist die Trennung „C ist nicht gleich C“ manifestiert. Bei C++ für den Embedded Compiler schleicht sich zusätzlich die Unsicherheit ein, dass es dafür zu wenig Praxis gäbe.

Integer-Formate

Ein immer wieder auffälliges Problem, das zur Nichtkompatibilität verschiedener C-Compiler bzw. Entwicklungsumgebungen oder wenigstens zu Ärger führen kann, ist das Integerformat für verschiedene Bitbreiten. Da dieses bis C99 offiziell nativ nicht unterstützt wurde, haben sich verschiedene Bezeichnungen int32, INT_32 und wie sie alle zufällig und ähnlich heißen, etabliert. Auch nach dem Jahre 1999 wurden nicht alle Quellen sofort umgestellt, bzw. in neuen Quellen nur noch das nunmehr standardisierte int32_t usw. verwendet. Das lag unter anderem auch daran, weil die Unterstützung für C99 nun nicht gerade sofort 1999 bereits begann. Der Visual-Studio Compiler (Microsoft) hat die wesentlichen Elemente von C99 erst mit der Version VS15 unterstützt, betrifft auch die Möglichkeit der inline-Funktionen auch in C, Variablendeklarationen nicht nur am Anfang eines Blockes und Weiteres. Embedded Compiler basieren zwar oft auf GNU, sind von daher ähnlich, aber hier sind die Spezifika der Hardware teils ein Quertreiber.

Es gibt bezüglich der Typen für Integerformate immer noch ein wesentliches Problem, das Kompatibität erschwert. Leider sind die C99-Definitionen auch laut Standard keine echte Compilereigenschaft, werden also nicht als Keyword wie int oder for erkannt, sondern sind in Headerfiles definiert. Die C99-Sprachdefinition sieht dafür den File <stdint.h> vor. Dies ist meist auch so implementiert. Man sollte also meinen, mit

#include <stdint.h>

sind die Intergertypen verfügbar. Dieser Header schließt allerdings bei PC-Compilern oft noch weitere Dinge mit ein, die im Umfeld von Embedded Control-Simulationen möglicherweise stören. Die Störung kann auch von einer Eigendefinition im Legacy Code hervorgerufen werden, die man jetzt nicht bereinigen möchte. Das andere Problem ist, dass die Standard-Integer-Typen meist mit typedef definiert werden und so formell inkompatibel sind mit den gleichbedeutenden anderweitig definierten Typen, also beispielsweise int32_t (C99) mit INT_32 (eigen legacy). Das ist vom Standard vielleicht so gewollt, aber praktisch nicht zu gebrauchen.

Mittel und Wege zur Kompatibilität

Man kann diesem Problem offensiv begegnen und folgenden Ansatz verfolgen:

C und C++ werden grundsätzlich als plattformunabhänig betrachtet. Der Quellcode wird unverändert mit mehreren Compilern übersetzt. Die Notwendigkeiten, damit dies geht, werden erledigt.

  • Es erfolgt in der Regel ein Test der Algorithmen im Testbed auf dem PC je nach Anspruch als Modultest oder mit einer komplexen Simulationsumgebung (physikalische Simulation mit einem entsprechenden Tool).
  • Dazu werden Hardwarezugriffe gewrappt (HAL = „Hardware Abstraction Layer)“: Es werden Operationen aufgerufen anstatt direkt etwa auf Hardwareadressen zuzugreifen. Für die Zielplattform werden die Operationen per inline (auch ein #define-Makro ist möglich) direkt auf die Hardwarezugriffe umgesetzt.
  • Alle Betriebssystemzugriffe werden über eine flexibles OSAL („Operation System Adaption Layer“) geführt. Beispielsweise ist das Abholen einer umlaufenden Zeit für Rechenzeitmessungen auf dem PC unter MS-Windows auf einen Aufruf von QueryPerformanceCounter aus der Windows-API, und auf der Zielplattform ist dies ein Abholen eines umlaufenden Zählers in der CPU.

Da man sich nicht auf die Kompatibilität verschiedener Compiler verlassen kann, hat der Verfasser schon seit Jahrzehnten folgende Strategie entwickelt:

  • Es werden außer den ganz allgemeinen Standards wie <stdio.h> keinesfalls plattformspezifischen Files in Anwenderquellen inkludiert (wie etwa <Winbase.h>). Für die Nutzung betriebssystemspezifischer Dinge ist die OSAL verantwortlich.
  • Auch bei den ganz allgemeinen Standard-Includes sollte man eher vorsichtig sein, da häufig plattformspezifisch weitere Header mit eher störenden Inhalten mit includiert werden.
  • Aus diesem Grund hat es sich nicht immer als zielführend erwiesen, die <stdint.h> zu includieren, sondern die entsprechenden C99-Typen sind mit #define selbst definiert, kompatible zu den verwendeten Legacy-Typen.
  • Mindestens ein file genannt <compl_adaption.h> wird grundsätzlich überall als erstes includiert. Dieser definiert in compiler- und plattformspezifischer Anpassung bestimmte allgemein verwendete Typen und Compilerschalter. Beispielsweise die oben erwähnten int32_t, INT_32 usw. aber auch Regeln für Speicherzugriff für big endian versus little endian, für die Speicherbreiten-Adressierung, etc. pp. Dieses File soll aber keine applikationsspezifischen Dinge wie Hardwarezugriffe etc. enthalten, sondern soll für alle Compiler-Target-Nutzungen gleichartig gelten unabhängig von der Applikation.

Die Anpassung an eigene projekt- oder abteilungsspezifische Konventionen gibt es ein zweites File <applstdef_emC.h> das nicht plattformspezifisch sondern applikationsspezifisch ist. Applikationsspezifisch ist beispielsweise die maximale Größe von Datenobjekten im Heap, weil häufig auf der Embedded-Zielplattform auch Sonderbedingungen gelten, die aber für die Applikation gelten. Jede Anwenderquelle schließt also <applstdef_emC.h> mit ein, diese wiederum inkludiert <compl_adaption.h>. Damit ist Standard-Compilierungsumgebung definiert und es braucht nicht die allgemeinen aber doch oft problematischen Dinge aus der Compiler-Systemumgebung. Alle Inkompatibilitätsprobleme werden über die <compl_adaption.h> gelöst.

Wie oben erwähnt, ist eben nicht auf die C99-kompatiblen Identifier für Integertypen zu setzen sondern die gewohnten beizubehalten, da die C99-Integertypen im Detailfall immer mal wieder Schwierigkeiten bereiten. Dabei ist es sehr ratsam, kein typedef zu verwenden sondern

#define int32_t long //C99-Type
#define int32 long
#define INT_32 long

zu schreiben (wobei hier long der compiler-native Type für 32-bit-Integer ist). Der große Vorteil: Die eigentlich gleichen Typen sind formal miteinander kompatibel. Man muss die verschiedenen Bezeichnungen in den Quellen zwischen Legacy-Code, anderen Abteilungen usw. nicht anpassen.

Abhängig vom Compiler kann man schreiben:

#ifndef __cplusplus
#define bool int
#define true !(0)
#define false 0
endif

- und hat damit die in C++ sprachdefinierten Typen auch in C.

Eine wichtige (wenn man so will) „Spracherweiterung“ ist:

#ifdef __cplusplus
#ifdef DEF_CPP_COMPILE //C-sources compiled as C++
#define extern_CCpp extern
#else //all C-Sources compiled with C
#define extern_CCpp extern "C"
#endif
#define extern_C extern "C"
#else
#define extern_C extern
#define extern_CCpp extern
#endif

Man kann damit vor eine Funktionsprototypdefinition schreiben:

extern_C void myFunction(MyData* thiz, float x);

In diesem Fall muss DEF_CPP_COMPILE gesetzt sein, wenn man mit C++ in C-files definierte Funktionen in C++-Manier als Linker-Label definiert sehen will.

Der Unterschied ist: C-Labels enthalten keine Typinformationen der Funktionsargumente und liefern keinen Fehler, wenn bei der Prototypdefinition, damit beim Aufruf, und bei der Funktionsdefinition etwas unterschiedliches notiert ist. Das gibt schwere Fehler bei der Abarbeitung, die möglicherweise erst spät entdeckt werden, eines der Gründe, mindestens für den Test der Software auf C++ zu setzen. Wird immer der gleiche Header includiert bei allen Verwendungen und (!) bei der Definition der Funktion, dann gibt es bei C++ auch bei extern_C bereits eine Compilerfehlermeldung. Dies sollte ein unbedingtes Style-Guide sein, wurde aber bei Legacy-Code oft so nicht realisiert und wird auch teils immer noch nicht so gesehen. Der C++-Linker ist also konsequent und meldet alle nicht konsistenten Funktionen als Fehler. Das ist oft als „signatur-sensitiv“ bezeichnet und wird lediglich als Vorteil von C++, man könne gleiche Funktionsnamen für verschiedene Funktionen benutzen, betrachtet. Der eigentliche Vorteil ist aber die Fehlermeldung. Ansonsten ist es oft besser, eben nicht gleiche Funktionsnamen zu benutzen sondern diese explizit mnemonisch zu unterscheiden („be explicit“). Doch hier gibt es verschiedene Meinungen. Das extern_C soll also lediglich die Kompatibilität von C-compilierten Teilen mit C++ sichern und funktioniert damit ohne weitere Compilerschalter. Es ist nicht nur für Funktionen, sondern auch bei Datendefinitionen anwendbar.

Das Compilieren des selben Sourcecode auf verschiedenen Plattformen kann teils sehr mühselig sein. Das liegt nicht immer nur an den Plattformen, sondern teils eben am eigenen Quellcode, mit Legacy-Anteilen, gewachsen, etwas widersprüchlich. Wenn man auf der einen Plattform plötzlich Compilerfehler oder Warnungen bekommt, die vorher (bei anderen Plattformen) nicht da waren, dann liegt das oft einfach daran, dass die anderen Plattformen diese Unzulänglichkeit durchgelassen haben. Typisch ist beispielsweise, dass eine forward struct MyType_T* on the fly (in der Definitionszeile) bei Visual Studio selbstverständlich akzeptiert wird, andere Compiler wollen aber eine eigene Zeile mit der Definition struct MyType_T; sehen, weil das so der Standard eigentlich vorschreibt (obwohl Visual Studio pragmatischer vorgeht). Also: Man muss diese Zeile ergänzen. Ein anderer Unterschied in der selben Gegend: Manche Compiler lassen die Gleichheit von Tag- und Typname bei struct zu. Und dies hat sich dann so im Legacy-Code etabliert. Korrekt ist es aber für C zu schreiben:

typedef struct Tagname { …. } Typename;

Der Tagname wird dann für die forward-Verwendung benutzt. Tag- und Typename sollen unterschiedlich sein. Kurzhinweis: Wozu forward-Deklarationen: Um Abhängigkeiten zu entflechten und zu sparen, sollte bekannt sein.

Man darf nicht in den Fehler verfallen, bei der Korrektur für den einen Compiler für einen anderen Compiler wieder Fehler zu erzeugen. Da ist also viel Sachkenntnis, oder zeitnahes Testen mit dem anderen Compiler notwendig. Insgesamt gesehen bekommt man dies aber hin! Es gibt eine realistische Schnittmenge, die alle Compiler können. Man muss diese nur manchmal ein wenig suchen, lernt aber dabei dazu.

Seminar-Tipp: Embedded Programmierung mit modernem C++

Embedded-Programmierung ist eine der Domänen für modernes C++. In diesem Seminar erlernen Sie die Vorteile der Programmiersprache speziell für die Embedded-Entwicklung. Dabei lernen Sie zuerst die Anforderungen näher kennen, und welche Antworten C++ dazu liefert, speziell zu Feldern wie Sicherheitskritische Systeme, Hohe Performanz, Eingeschränkte Ressourcen und simultanes Multitasking.

Näheres zum Seminar

Unterschiede in der Adresszählung: nicht byteweise

Die Speicherorgansisation ist bei einigen Embedded Prozessoren so, dass die Adresszählung nicht byteweise erfolgt. Bei der TMS320-Serie erfolgt sie 16-bit-wortweise, bei Analog-Devices DSP-Prozessoren 32-bit-wortweise. Wenn man also Adressrechnungen ausführt, wie man sie vom PC gewöhnt ist (byteweise Adresszählung), dann wird man Probleme bekommen. Anstatt dann wieder mit #ifdef PC_Plattform zu beginnen und verschiedene schlecht durchschaubare Quellcodes zu schreiben, hat der Verfasser in der <compl_adpation.h> ein MemUnit-Typ geschaffen, der nicht in allen Fällen identisch mit char ist. Zudem gibt es die Angabe BYTE_IN_MemUnit als numerischen Wert und Makros zur Adressrechnung, die diese Definitionen nutzen. Das sind genaugenommen eigene Spracherweiterungen, da sie in allen Quellen verwendet werden können. Sie sind kompatibel, denn diese einfachen Definitionen können in jeder Umgebung bereitgestellt und verwendet werden.

Ein mit der Adresszählung sich ergebendes Problem ist die verschiedene Abbildung von Character-Arrays. Das Problem fällt dann auf, wenn Speicherinhalte, die Strings enthalten, an eine andere Plattform übertragen werden. Die andere Plattform sieht dann beispielsweise nur in jedem vierten Byte ein Zeichen (bei ADSP-Prozessoren). Nun sind in solchen Plattformen Strings meist nur kurze Identifier-Texte. Um dort kompatibel zu bleiben, hilft nur ein Makro, dass Einzelzeichen übernimmt und diese auf Makroebene als Konstanten zusammenschiebt:

char4_emC sKennung[]={ CHAR4_emC('I','d','e','n')
       , CHAR4_emC('t','1','\0','\0') };

Das sieht zwar nicht sehr schön aus, geht aber nicht anders. Es ist nur notwendig für Strings, die ausgetauscht werden und auf der ‚anderen Seite‘ byteweise interpretiert werden. Das CHAR4-Makro schiebt die 4 Einzel-Character-Argumente auf ein 32-bit-Integer zusammen, abgearbeitet zur Compiletime, so dass es als Initializer anwendbar ist. Für Strings, die nur auf der selben Plattform verarbeitet werden, ist dieser Aufwand nicht notwendig. Die eigenen String-Funktionen kennen die Speicherart.

Rücksichtnahme „Hier geht nur C“: Kerne in C

Hat man allgemeine Algorithmen, die in verschiedenen Plattformen in Embedded genutzt werden sollen, und sind einige dieser Plattformen möglicherweise aus verschiedenen Gründen in C zu programmieren, dann sollten die Algorithmen auch in C programmiert werden. Sie sollten aber selbstverständlich wie im Vorkapitel dargestellt auch mit C++ compiliert werden, insbesondere in einem Embedded Target mit Grundsatzentscheidung für C++ und in der Testumgebung. Diese Algorithmen können mit C++ class-Definitionen gewrappt werden, um sie auch C++-like aufzurufen.

Objektorientierte Programmierung

Man kann in C objektorientiert programmieren. Das ist auch notwendig entsprechend dem Anspruch des Vorkapitels und hat keinerlei Nachteile im Vergleich zur „klassischen“ C-Programmierung.

Die Grundlage der Objektorientierung ist die Zusammenfassung von Einzeldaten zu Datenobjekten und die Zuordnung der Operationen zu den Daten. C kennt das Sprachelement struct, genau für die Objektorientierung geeignet.

Im Vergleich zur althergebrachten auf Einzeldaten orientierten C-Programmierung seien folgenden Aussagen getroffen:

  • Daten in statischen struct verhalten sich wie Einzeldaten.
  • Der Zugriff auf referenzierte Daten dauert nicht länger als der direkte Zugriff.
  • Verwendung einer einfachen class ist laufzeitidentisch mit Verwendung einer struct.

Daten in statischen struct verhalten sich wie Einzeldaten

Die Zusammenfassung von Daten in einer struct und die statische Instanziierung dieser struct liefert den gleichen Maschinencode wie statische Einzeldaten. Warum ist das so: Ein Compiler erkennt die feste Speicheradresse der Daten in der struct und bildet die Maschinenbefehle mit direktem Zugriff auf die Elemente mit der festen Speicheradresse. Damit lassen sich Legacy Codes umschreiben, ohne dass man über Performance-Einbußen und dergleichen nachdenken muss.

Folgend werden Beispiel-Maschinenbefehle gezeigt, die aus C-Zeilen mit dem C/C++-Compiler des Texas Instruments Composer Studio Version 9.2 aus dem Jahr 2016 erzeugt wurden.

int a = 0;

typedef struct TestStructAB_T {
  int a, a1;
   int array[1224];
   int b, b1;
} TestStructAB;

TestStructAB testStructAB = { 0 } ;

int b = 0;

void testAccessABstatic(int val) {

  a = val;
  b = val;
  testStructAB.a1 = val;
  testStructAB.b1 = val;
}

Im Listing dargestellt sind die erzeugten Befehle im Body der Funktion.

00000005 761F- MOVW DP,#_a ; [
00000006 0000
00000007 9600- MOV @_a,AL ; [
00000008 9601- MOV @_b,AL ; [
00000009 761F- MOVW DP,#_testStructAB+1 ; [
0000000a 0001
0000000b 9601- MOV @_testStructAB+1,AL ; [
0000000c 761F- MOVW DP,#_testStructAB+1227 ;
0000000d 0014
0000000e 960B- MOV @_testStructAB+1227,AL ;

Interessant ist, dass der Compiler die globalen Variablen nicht so anordnet wie in der Source, also b nach der Struktur, sondern er sortiert so um, dass a und b hintereinander stehen. Es ist kein Optimierungslevel gewählt (-O 0) aber –opt_for_speed ist angewählt. Damit braucht der DP-Zeiger (DataPage), für a und b nur einmal geladen werden, obwohl a und b in der Source eigentlich weit auseinander stehen. Der MOV-Befehl für Registerschreiben kann nur eine sehr begrenzte Indizierung (0..7) im 16-bit-Maschinencode. Es zeigt sich, dass der Prozessor für globale Einzelvariable in diesem Fall optimieren kann. Denn: Für den Zugriff auf die Variablen a und b in der Struktur braucht er zwei DP-Ladebefehle, da die Elemente im Beispiel weit auseinanderliegen. Ansonsten zeigt es sich, dass der selben Maschinenbefehl (96xx) für das Schreiben der Strukturdaten wie für die Einzeldaten verwendet wird.

Bedeutet dies nun, C sei optimaler weil Einzeldaten vom Compiler besser optimiert werden können? Naja. Die alternative Antwort ist: Kommt es auf extrem fast Realtime an, dann muss man auch schonmal in Listings schauen und für die kritischen Anweisungen sich die Datenanordnung auch in einer class überlegen.

Der Zugriff auf referenzierte Daten dauert nicht länger als der direkte Zugriff

Dies ist nun die entscheidende Aussage für das Befürworten von C++, denn auf Daten in einer class wird immer über einen Zeiger (this) zugegriffen. Auch hier sei der Blick zunächst auf eine struct gerichtet.

void testabref_TestStructAB(TestStructAB* thiz, int arg) {
  thiz->a = arg;
  thiz->b = arg;
}

In diesem Beispiel werden wieder die Daten der obigen struct beschrieben, aber diesmal referenziert. Da wir noch in C unterwegs sind, ist der Referenzpointer thiz mit z geschrieben. Damit kann der C++-Compiler, für dem this ein Keyword ist, benutzt werden. Der Maschinencode sieht wie folgt aus:

0000001c 96C4 MOV *+XAR4[0],AL
0000001d 8D00 MOVL XAR0,#1226
0000001e 04CA
0000001f 9694 MOV *+XAR4[AR0],AL

Auch hier ist wieder beobachtbar, dass auf die Variable a am Anfang der struct mit einem kurzen Maschinenbefehl zugegriffen werden kann, mit festem Index (hier 0), dagegen benötigt die Variable b eine zusätzliche Distanz mit Adressrechnung. Der Maschinenbefehlssatz des TMS320 ist so ausgelegt dass ein Offset bis 7 in 3 bit im 16-bit-Maschinenbefehle enthalten ist. Das ist optimal bei wenig Daten. Ein Index bis 255 wird mit einem 16-bit-Befehl in ein Register geladen. Da die Adressrechnung parallel läufig ausgeführt wird benötigt diese keine extra Zeit. Für einen Offset >255 wird wie hier gezeigt ein 2-Wort-Befehl benötigt. Das entspricht allerdings dem allgemeinen direkten Speicherzugriff mit Laden des DP-Datapage-Registers, das ebenfalls 2 Worte als Maschinenbefehl braucht. Nicht betrachtet ist der Aufwand zum Laden des thiz-Pointers außen und die Argumentübergabe. Das direkte Abzählen von Maschinenzyklen ist hierbei weniger hilfreich, da der thiz-Pointer abhängig von den Algorithmen nur einmalig geladen wird. In der Fortsetzung dieses Artikels werden Benchmark-Tests vorgestellt.

Verwendung einer einfachen class ist identisch mit Verwendung einer struct

Im folgenden wird das Pattern der Vererbung struct in class verwendet, wie es für „Kern-Algorithmen in C“ notwendig ist:

class TestClassAB: private TestStructAB
{
  public: void testabref(int arg) {
testabref_TestStructAB(this, arg); }
};

Die C++-Operation („Methode“) ruft direkt die vorhandene C-Operation („Funktion“) auf. Auch ohne inline-Keyword wird dies als inline realisiert, wie das Aufrufbeispiel im Listing zeigt:

void useClassAB(TestClassAB* data){
  int val = 123;
  data->testabref(val);
}

Das Listingfile enthält dafür:

00000007 _useClassAB__FP11TestClassAB:
00000007 9A7B MOVB AL,#123 ; [CPU_ALU] |67|
00000008 7640' LCR #_testabref_TestStructAB__FP14TestStructAB_
00000009 0000
0000000a 0006 LRETR ; [CPU_ALU]

Es wird also direkt die C-Operation gerufen, obwohl der Aufruf in C++ formuliert ist. Das gleiche Ergebnis entsteht selbstverständlich für den weniger komplexe Fall der Definition der Daten direkt in der class und der direkten Implementierung der class-Operation. Es besteht bei den einfachen class-Definitionen auch mit Einfachvererbung und ohne virtuelle Operationen kein Unterschied, ob in C gearbeitet wird (objektorientiert mit struct) oder in C++. Der entscheidende Stil-Unterschied ist, das eben objektorientiert programmiert wird, und nicht mit Einzeldaten. Aber auch hier sollte die Darstellung oben gezeigt haben, dass zwar in Einzelfällen der Compiler die Einzeldaten optimaler verarbeitet, weil er dort durch Umordnen der Daten nochmal optimieren kann, dass dies aber keine sehr wesentlichen Effekte sind und diese eher verschwinden, wenn die Algorithmen komplexer werden. Auch kann man die Anordnung der Daten zwar manuell auch in einer struct oder class entsprechend wählen, wenn man einen langsamen Prozessor hat mit wenig Programm, der dies aber möglichst schnell abarbeiten soll. Also auch dort kann man mit C++ genauso weit kommen.

Die letzte hier zu stellende Frage: Inline-Optimierung der C++-Operation. Es ist hierzu in der Definition der C-Operation testabref_TestStructAB(…) { … } ein inline vorangestellt:

Damit entsteht für den Aufruf folgender Maschinencode:

00000000 _useClassAB__FP11TestClassAB:
00000000 9A7B MOVB AL,#123 ;
00000001 96C4 MOV *+XAR4[0],AL ;
00000002 8D00 MOVL XAR0,#1226 ;
00000003 04CA
00000004 9694 MOV *+XAR4[AR0],AL ;
00000005 0006 LRETR ;

Man sieht die gleichen Maschinenbefehle beim Aufruf der C++-Operation, wie in der Implementierung in C. Bei einem höheren Optimierungslevel (-O3 Interprocedure Optimization) wird auch ohne inline-Schlüsselwort diese Optimierung ausgeführt, unabhängig von C oder C++.

In der Fortsetzung des Artikels werden noch weitere Maschinencodebeispiele auch von anderen Compilern und Prozessoren folgen, die diese Aussagen nochmals vertiefen.

Letztlich kann man sagen, dass die Hauptentscheidung nicht die zwischen C und C++ ist, sondern die für die objektorientierte Programmierung. Diese lässt sich, Stück für Stück, auch in der Aufarbeitung von Legacy Code etablieren, auch wenn man dort vorerst bei C bleibt.

Seminar-Tipp: Embedded Programmierung mit modernem C++

Embedded-Programmierung ist eine der Domänen für modernes C++. In diesem Seminar erlernen Sie die Vorteile der Programmiersprache speziell für die Embedded-Entwicklung. Dabei lernen Sie zuerst die Anforderungen näher kennen, und welche Antworten C++ dazu liefert, speziell zu Feldern wie Sicherheitskritische Systeme, Hohe Performanz, Eingeschränkte Ressourcen und simultanes Multitasking.

Näheres zum Seminar

Andere Möglichkeiten der Embedded Programmierung außer C und C++

Warum hat sich eigentlich C als Sprache der Embedded Programmierung etabliert und ist die Orientierung auf C++ langjährig tragfähig? Diese Frage sollte gestellt werden. C hat den Siegeszug im Embedded Bereich in den 90-ger deutlich begonnen und ist heute im Komplex mit C++ als Jahrzehnte-stabil anzusehen. In den 80-gern war diese Frage noch nicht so klar zu beantworten, damals gab es PL/M, Basic, (Turbo-)Pascal, alles Entwicklungen die nunmehr nur noch Geschichte sind.

Java wird im Embedded Bereich verwendet wenn die Leistungsfähigkeit ausreicht und eine Virtuelle Maschine bereitsteht, aber auch nativ als Realtime-Java. Trotz der deutlichen Sprachvorteile von Java (sichere Programmierung) hat sich dies aber nicht breit durchgesetzt.

C# hat schon im Sprachnamen einen wahrscheinlich so gewollte Anklang an C++, beide ++ sind zusammengeschoben zu einem „Doppelkreuz“. Aber C# hat eine ähnliche Bedeutung wie Java, verwendet in bestimmten Bereichen, in Konkurrenz oder Wettbewerb zu Java, aber nicht etwa führend.

Immer dann, wenn die Aufgaben speziell werden, kann auch eine domänenspezifische Ausprägung von Programmierung vorherrschen. Inwieweit die Automatisierungsprogrammierung etwa mit der IEC-61131-Norm domänenspezifisch ist, oder neben C/++ eine eigenständige Rolle besitzt, sei hier nicht betrachtet. Jedenfalls müssen die Kern-Programmierungen aller domänenspezifischen Dinge letztlich im Maschinencode vorliegen, und werden häufig in C oder C++ ausgeführt.

C hat die starke Nähe zum Maschinencode. Man kann in einem Listing-File mit der Kompilierung ausgegeben direkt nachvollziehen, welche Sourcecode-Zeilen mit welchen Maschinencodes realisiert werden, und kann für kritische Echtzeitbetrachtungen, für Sicherheit und dergleichen Aussagen treffen. C++ kann genauso eingeordnet werden, wenn man den Umgang mit zu komplexen Sprachkonstrukten, für die die Maschinencodes nicht mehr so gut nachvollziehbar sind mit der entsprechenden Vorsicht benutzt. Jedenfalls sollte man immer voraussetzen dürfen, dass es genügend Embedded-Programmierer gibt, die mit Maschinencode umgehen können. Folglich muss dieses Wissen auch zukünftig zu jeder „Technische Informatik“-Ausbildung gehören.

Der Grafischen Programmierung wird hoffentlich in den nächsten Jahren eine wachsende Bedeutung zugewiesen werden. Die Grafische Programmierung benutzt aber häufig wiederum C und C++ als Metasprache der Codegenerierung und lässt sich gut mit zeilenprogrammierten Code-Teilen mischen. Bei domänenspezifischer grafischer Programmierung gilt das indirekt, denn die Abarbeitungs-Implementierungen dafür sind dann wieder C und C++. Ähnlich ist es auch bei DSL (Domain Specific Language), die oft für die Implementierung auf dem Zielsystem nach C oder besser C++ umgesetzt werden.

* Hartmut Schorrig war in den vergangenen zwei Jahrzehnten Entwicklungsingenieur bei Siemens. In den Jahren zuvor wurden in verschiedenen Forschungsinstituten und in der Wirtschaft Erfahrungen gesammelt, anfänglich in den 80-ger Jahren mit der Entwicklung eines Industrie-PCs „MC80“, damals selbstverständlich noch in Assembler. Schon mit dem Studium „Technische Kybernetik und Automatisierungstechnik“ an der TH Ilmenau wurde der Blick auf den Zusammenhang von Elektronik, Regelungstechnik und Software gerichtet. Aktuell betreibt er die IT-Plattform vishia.org.

(ID:46557774)