C++ in der Embedded-Entwicklung: Keine Angst vor Templates
Anbieter zum Thema
Für manche C-Programmierer sind Makros eine segensreiche Erleichterung, für andere gefährliche Fallgruben. Die in C++ enthaltenen Template-Features erlauben dagegen, Makro-ähnliche Vorteile zu nutzen, ohne auch deren Nachteile in Kauf nehmen zu 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 gering. Die Aussagen sind vielfältig und widersprüchlich.
Teil 1 unserer Reihe zu C++ in der Embedded-Entwicklung befasste sich mit dem Testen von Embedded-Code am PC, Teil 2 behandelte den Umgang mit Heap-Daten. Der dritte Artikel dieser Serie zeigt Vorteile der Verwendung von Templates auf. Der Artikel wendet sich nicht an C++-Experten - keine neuen Informationen - sondern an den typischen C-Entwickler mit Kenntnissen in der Makroanwendung, um ihm die Vorteile von C++ nahezulegen.
Ursprünge der Template-Mechanismen als Ablösung von Makros
C hat von Anfang an eine recht simple aber leistungsstarke Makro-Vorverarbeitung eingeführt: Vor dem Compilerlauf wird eine Ersetzung entsprechend der Makro-Vorlage ( = „template“) ausgeführt, der Compiler sieht dann das angepasste Resultat:
#define MIN(X,Y) ((X) < (Y) : X : Y)
Man spart sich beim Programmieren den ausgeschriebenen möglicherweise etwas komplexeren Code und hat dennoch die passende Statementabfolge ohne zeitaufwändigeren Funktionsaufruf.
Makros können sehr viel komplexer sein als dieses Standardbeispiel, dann lohnt es sich wirklich.
float result = MIN(getFirst(), getSecond());
Doch halt! Hier bereits zeigt sich eine Falle. Was beim Aufruf in der Source richtig aussieht, führt zum zweimaligem Aufruf der Routinen getFirst() und getSecond(). Man muss also bei der Anwendung aufpassen, die Aufrufargumente vorher in Zwischenvariable schieben. C-Programmierer müssen immer aufpassen. Die andere Falle sind vergessene Klammern innerhalb des Makros, die zu falschen Vorrangentscheidungen führen können.
#define MAGNITUDE(d,q) (sqrtf(d*d + q*q))
aufgerufen mit
float magn = MAGNITUDE(x.re + d0, x.im);
Die Addition + d0 ist als hotfix-Korrektur dazugekommen, die Software war vorher praxiserprobt. Was ist falsch? Klammern im Makro vergessen und bisher nicht bemerkt.
Im anfänglichen C, bis C99 und noch einige Jahre später bis alle Compiler C99 unterstützt haben (Visual Studio ab Version 2015) gab es keine inline-Funktionen. Damit waren die Makros die einzige und probate Möglichkeit, Statements in der Ablauffolge unterzubringen, um damit call-Befehle zu meiden (Rechenzeit).
Mit inline kann man nun schreiben:
static inline min_Simple(float a, float b) {
return a < b ? a : b;
}
Das sieht ähnlich wie die Makrodefinition aus. Das Problem der vergessenen Klammern ist weg, das Problem des Doppelaufrufs ist weg, man kommt den Typtest oder eine passende Typkonvertierung dazu geschenkt. Ein Programmierfehler beim Aufruf wird nicht durch eine kaum verständliche Compilerfehlermeldung nach Expandierung des Makros angezeigt, sondern direkt wie bei einem nicht-inline-Funktionsaufruf. Alles bestens.
Doch etwas fehlt: Diese inline-Funktion geht nur für float. Man kann nun für alle möglichen Typen entsprechende inline-Funktionen schreiben, hier würde das noch überschaubar bleiben. Doch mit einem Template nutzt man den altgewohnten Vorteil, den Algorithmus für alle Typen nur einmal schreiben zu müssen, gepaart mit dem Vorteil des inline:
template<typename TType>
inline TType min_Simple(TType a, TType b) {
return a < b ? a : b;
}
Der obigen inline-Definition ist nur ein template<typename TType> - Rahmen hinzugesetzt, statt dem konkreten Typ float ist der Typ frei. Wie bei einem Makro (das von der Sache her auch ein Template, eine Vorlage ist) erfolgt die Expandierung beim Aufruf. Besser als beim Makro enthält die Expandierung hier aber die Festlegung, das a und b vom gleichen Typ sind, dass die Aufrufargumente in Variable verpackt werden und der Compiler den Aufruf kontrollieren kann, folglich bessere Fehlermeldungen absetzt. Nebenhinweis bezüglich Schreibweise: Der TType wird meistens recht lax einfach als T bezeichnet. Das Makro heißt deshalb nicht einfach min, weil damit ein Nameclash vorprogrammiert ist, es gibt irgendwo in den Headern sicher noch eine andere Definition für das sehr allgemeine Wort min.
Dem Nameclash kann man begegnen, indem man den Namespace-Mechanismus in C++ nutzt, oder mit einem spezifischen Suffix (ein Präfix geht auch, Suffix ist allerdings empfehlenswerter). Es sollte dabei natürlich aussagekräftiger sein als das einfache, in diesem Beispiel verwendete Suffix _Simple. Das Problem an Nameclashes ist, dass man deren schlummerndes Potenzial zunächst nicht bemerkt, weil es keinen unmittelbaren Clash verursacht. Dieser tritt erst in einer heißen Phase auf, wenn alle Programmteile zusammenlaufen und Projektstress vorliegt.
Beim Aufruf der template-Routine kann man nun wahlweise den Typ angegeben oder den Compiler allein entscheiden lassen:
float c = min_Simple<float>(a, b);
Das <float> kann man weglassen. An dieser Stelle meine Empfehlung: „be explicit“ – man sieht der Aufrufzeile die Tatsache an, dass hier ein Template benutzt wird, und man sieht den Typ.
Ergo: Was ist besser? Makros in C, viele inline-Definitionen (sind meist länger als dieses einfache Beispiel) oder C++ mit Templates nutzen? Die Entscheidung liegt beim Projektteam, vielleicht beim Management.
Beispiel für ein eigenes Container-Template
Templates werden oft im Zusammenhang mit Containern benutzt. Container können alles Mögliche enthalten, sie sind lediglich eine Verwaltung des Inhaltes, ähnlich wie die Container im richtigen Leben auf LKW oder Schiffen. Nur beim Be- und Entladen kommt es auf den Inhalt an. Container in der Programmierung sind oft Listen, sortierte Listen, Arrays und dergleichen. Es gibt einen markanten Unterschied in der Nutzung:
- Der Inhalt des Containers wird referenziert
- Der Inhalt des Containers ist im Speicherbereich des Container selbst enthalten.
Die referenzierte Form hat den Nachteil, dass der Inhalt irgendwo anders stehen muss. Häufig ist es aber so, dass ein Containerinhalt, also das Data Record, noch anderweitig benötigt wird. Es steht aus bestimmten algorithmischen Gründen beispielsweise in einer sortierten Liste, zusätzlich zu weiteren Verwendungen. Folglich ist die referenzierte Form mehr allgemeingültig. Es gibt noch einen anderen Vorteil: Der Algorithmus, letztlich der Maschinencode der Containerfunktionen sind bei referenzierten Inhalten vollkommen unabhängig vom Inhalt. Mit dem Inhalt wird ja selbst nicht gearbeitet.
Nehmen wir ein Beispiel eines eigenen Containers. Dieser soll Daten einer bestimmten Menge aufnehmen, referenziert. Der Typ ist per se nicht festgelegt, die Containergröße ist nicht festgelegt. Die Container-Funktionalität soll aber die Daten nach zusätzlichen Kriterien bewerten. Das führt zu folgenden zwei Template-Klassen:
/**One record in the container
* with the referenced user data
* and additional information to evaluate. */
template<typename T> struct DataRecord {
long time;
long score;
T* userData;
};
/**The container class itself. */
template<typename T, int capacityArg> class DataMng {
int zData;
int capacity;
DataRecord<T> data[capacityArg];
public: DataMng();
public: bool addData(T* userData, long time, long score);
public: T* getNewestOk();
};
Der Container enthält also eine addData-Operation und eine Operation, die aufgrund des score und time irgend geeignet die passenden Daten heraussucht und als Referenz bereitstellt.
Möglicherweise ist genau die letztgenannte Implementierung komplex. Im Sinne von „do not repeat yourself“ ist sie auch nur einmal ausprogrammiert. Es gibt aber ein Problem bei Template-Implementierungen, die allerdings bei Makros in C genau so vorliegt: Die Implementierung ist mehrfach notwendig, für jeden Anwendungstyp. Also, für den Sourcecode gilt das besagte „do not repeat yourself“. Für den Maschinencode gilt es leider nicht.
Dies ist in zweierlei Richtung sehr schade. Einerseits ist bei Embedded Anwendungen der Speicherplatz meist geringer bemessen als bei PC-Anwendungen. Andererseits kann man die Implementierung nicht in eine eigene Compilerunit verfrachten, sondern muss sie inline ausführen oder in der selben Compilerunit anordnen wie der Aufruf. Man kann sie daher immer inline im Header ausführen, da die Inkludierung eines Headers mit einer inline-Routine ohne Aufruf nicht zur Erzeugung des Maschinencodes führt. Aber wenn man die selben Template-Typen in mehreren Compilerunits verwendet, werden die Maschinencodes für den selben Typ jeweils eigenständig in der jeweiligen Compilerunit erzeugt, also doppelt und dreifach. Bei PC-Programmierung mit den GByte-großen RAMs fällt das nicht weiter auf.
Ob dieses Problem mit einem LLVM-basierendem Compiler [6] [7] besser gelöst wird, ist an dieser Stelle nicht geklärt. Beispielsweise basiert der „ARM Compiler 6“ auf LLVM. Das Potenzial für solche Lösungen könnte dort enthalten sein.
Exkurs Java und C# – Templates oder Generic
Die Erwähnung der Programmiersprachen Java und C#, teils auch im Embedded Bereich bekannt, erfolgt hier deshalb, weil mit einem etwas anderen template-ähnlichen Konzept genau dieses Problem, ein gemeinsamer Maschinencode für alle Containerelement-Typen, gelöst wurde. Es kommt häufig darauf an, Konzepte zu kennen, nicht nur deren richtige Anwendung zu ergoogeln.
Das Template-Konzept in C++ war schon von Anfang der C++-Entwicklung präsent, also bereits in den 80-ger Jahren. Java hat das ähnliche Generic-Konzept erst in 2004 mit Java 5 eingeführt, es lagen also einige Jahre Anwendungspraxis dazwischen. Es mag sogar sein, dass C# hierbei der Treiber war „Konkurrenz belebt das Geschäft“, denn bis Java 1.4 musste man bei Containerzugriffen immer noch jeweils mühsam casten.
Da Java nur mit Referenzen arbeitet, alles außer die wenigen „Primitives“ werden referenziert, stellte sich die Frage aus dem Vorkapitel „referenziert oder selbst enthalten“ nicht. Das einzige zu lösende Problem ist der cast aus der allgemein typisierten Referenz in der Containeralgorithmus -Implementierung hin zur konkreten Containerinhaltsklasse. Dieses Problem lässt sich auf reiner Compilerebene lösen, so dass man für die Abarbeitung der neuen Generic-basierenden Container in Java 5 sogar eine JRE 1.4 nutzen konnte.
Die folgende Anwendung deklariert den container mit dem Typ MyType für den Inhalt:
List<MyType> container = new LinkedList<MyType>();
Beim Einlagern in den Container wird vom Compiler automatisch der Typ geprüft, Ableitung ist selbstverständlich zugelassen:
MyDerivedType data = ... //data is a reference!
container.add(data);
Beim Herausholen ist das Element automatisch mit dem Containerinhaltstyp deklariert, hier in einer for-Schleife:
for(MyData entry : container) {
...
Will man auf den abgeleiteten Typ zugreifen, dann muss man sowieso casten. Der cast wird immer zur Runtime überwacht – wie in C++ der dynamic_cast<....>, sichere Programmierung. Programmierfehler werden lediglich hier mit einer klaren Runtimefehlermeldung (ClasscastException) bestraft, nicht mit einem Absturz und anschließendem mühevollem Debugging. Java unterstützt sogar die Angabe von Basisklassen der Generic-Typen um bestimmte Zugriffe innerhalb der Generic-Implementierung zu gestatten, in den C++-Templates erst ab C++20 in etwas anderer Form möglich, [5]:
class MyGeneric<GenericType extends MyBaseType> { .... }
Das void* Problem in C lösen
Für unser Template-Container-Beispiel in C++ verbleibt aber noch die Frage: „wie kann ich Maschinencode sparen bei an sich gleichen Ablaufcodes, weil die Zeigertypen nicht wirklich für die Containerfunktionalitäten eine Rolle spielen?“
Der C-Programmierer kann das mit Hilfe eines void*-Pointers folgendermaßen lösen:
/**One record in the container
* with the referenced user data as void*
* and additional information to evaluate. */
typedef struct DataRecordBase_T {
long time;
long score;
void* userData;
} DataRecordBase;
Anschließend lassen sich der eigentliche Container und die Zugriffsfunktionen direkt definieren, hier als Prototypen dargestellt:
int addData(void* container, int ix, int capacity
, void* userData, long time, long score);
void* getNewestOk(void* container);
Die Anwendungspraxis sähe damit wie folgt aus:
DataRecordBase dataMngA[20]; //Container for 20 Elements
int ixDataA = 0;
ixDataA = addData(dataMngA, ixDataA, 20, myDataA, time, score);
MyDataA* dataAok = (MyDataA*) getNewestOk(dataMngA);
In der Anwendungspraxis fällt es überhaupt nicht auf, wenn eine Verwechslung des Containers passiert. Jeder cast und jede Zuweisung auf void* kann fehlerträchtig sein und muss oft mühevoll herausdebuggt werden. Dabei ist der Zeitpunkt des Programmschreibens noch unkritisch, man ist schon C-gewohnt sorgfältig. Kritisch wird es bei Korrekturen, wenn aufgrund der vielen void*-Zwischenreferenzen es beim Compilieren überhaupt nicht auffällt, dass die Nachbarabteilung mittlerweile den Typ leicht abgeändert hat, aufgrund notwendiger Anpassungen, ... dies auch dokumentiert hat, was man aber nicht gelesen hat oder eben mal nicht daran gedacht.
Aber der Vorteil liegt auf der Hand: Knapper Maschinencode, die Funktionen müssen typunabhängig nur einmal vorhanden sein.
Wie nun das Nützliche mit dem Angenehmen verbinden? Man darf ja casten, aber nicht auf höherer Ebene sondern nur im eigenen Modul, wenn die Richtigkeit des cast nicht von äußeren Bedingungen abhängt. Die Implementierungsidee ist nun von den Java-Generics etwas inspiriert. Die eigentliche Implementierung ist Containerelement - typunabhängig, der Elementinhalt interessiert nicht. Also reicht eine Implementierung.
In C++ kann man nun genau die obige struct DataRecordBase mit void*-Datenzeiger für ein Element im Container parallel zum template-definiertem Typ verwenden und folgende Basisklasse ohne Template definieren:
class DataMngBase {
protected: int zData;
protected: int capacity;
protected: DataMngBase(int capacityArg
, DataRecordBase* dataSet, int sizeElement);
protected: bool addData_Base(void* userData
, DataRecordBase* dataSet, long time, long score);
public: int size();
protected: void* getNewestOk_Base(DataRecordBase* dataSet);
};
Hier tritt uns wieder das void* entgegen, anders geht es nicht. Aber es ist in einem protected-Bereich, nicht für die Anwendung.
Die Implementierung der Routinen kann wie gewohnt in irgendeinem *.cpp-File erfolgen, der Linker findet sie.
Die oben bereits gezeigte Containerklasse wird dann etwas abgewandelt:
/**The container class itself. */
template<typename T, int capacityArg>
class DataMng
: public DataMngBase {
DataRecord<T> data[capacityArg];
public: DataMng() : DataMngBase(capacityArg
, reinterpret_cast<DataRecordBase*>(this->data)
, sizeof(this->data[0]) //to check element type
) { }
public: bool addData(T* userData, long time, long score) {
return addData_Base(userData
, reinterpret_cast<DataRecordBase*>(this->data), time, score);
}
public: T* getNewestOk() {
return static_cast<T*>(getNewestOk_Base(
reinterpret_cast<DataRecordBase*>(this->data)));
}
};
Die inline-Implementierungen enthalten die notwendigen casts. Die Daten des Containers selbst werden passend „umgetypt“ auf das void*-enthaltende DataRecordBase. Jeder reinterpret_cast<...> (nicht vom Compiler getestet, entspricht dem C-cast) ist eine potenzielle Fehlerquelle – Sorgfalt ist geboten. Eine „Assertion“ sorgt für eine Überprüfung zur Entwicklungs- und Testzeit, im Release kann diese ausgeschaltet werden: Der cast darf nur dann ausgeführt werden, wenn gesichert ist, dass die Typen miteinander harmonieren. Keinesfalls darf diese Harmonie von anderen Sources als dem eigenen Modul abhängen – ein wichtiges Prinzip der fehlerarmen Programmierung, oft missachtet, hier mit der protected-Kennzeichnung gesichert.
Es lohnt sich, einen Blick auf die Implementierung des Basisklassen-Constructors zu werfen:
DataMngBase::DataMngBase ( int capacityArg
, DataRecordBase* dataSet, int sizeElement) {
this->zData = 0;
this->capacity = capacityArg;
ASSERT_emC(sizeElement == sizeof(DataRecordBase)
, "faulty data record size", 0, 0);
DataRecordBase emptyRecord = { -1, 0, null};
for(int ix = 0; ix < this->capacity; ++ix) {
dataSet[ix] = emptyRecord; //(memcpy)
}
}
Der erzeugte Maschinencode ist nur einmalig, Template-Typ-unabhängig vorhanden da er zur Basisklasse gehört. Ein Übergabeargument ist die Größe eines Elements im Container. Dieses muss zur Größe des Ersatzes DataRecordBase passen. Ansonsten gäbe es vollkommen falsche Speicherzugriffe. Freilich ist mit dem Augenmaß des Programmierers klar, dass die Größe passt – im Moment des sorgfältigen Erstprogrammierens. Man muss zusätzlich bedenken, dass in manchen Prozessoren es near- und far-Pointer gibt. Einfach ersichtlich aus dem Quelltext ist nicht unbedingt ein Pointer gleich einem Pointer (eine Speicherstelle mit einer Hardwareadresse). Daher sei das Assert auch in einem Zielsystemtest empfohlen. Ist man sich sicher dass die Typdefinitionen harmonieren, dann darf man in diesem Modul auch reinterpret-casten.
Das ASSERT_emC(...) prüft also, was sowieso klar ist? Es ist ein Makro, im Standardumfeld (emC, [3]) definiert oder recht einfach selbst zu definieren. Es darf leer sein für die Target-Compilierung, wenn der Code vorher beispielsweise auf dem PC getestet wurde, oder auch im Target möglicherweise mit einem größeren Speicherausbau als im Endgerät und weniger Funktionalität in der Abtastzeitscheibe lief. Das ASSERT_emC(...) -Makro im Testumfeld erzeugt eine Exception. Damit wird zwar nur eindeutig festgestellt, dass die Speichergröße des Elements identisch ist. Das ist aber die wichtigste Aussage. Alle anderen potenziellen Fehler führen nicht zum Absturz sondern treten in falschen Daten zu tage.
Aha, nun doch wieder ein Makro trotz aller Plädoyers für template und inline? Letztere beide sollen nur diejenigen Makros ablösen, die mit inline und Templates besser realisierbar sind, nicht die Makrotechnik als Ganzes. Ein inline void assert_emC(bool cond, char const* text, int val1, int val2){...} würde auch bei leerer Implementierung zunächst die Argumente aufbereiten, der "text" würde einen statischen Speicher bekommen und Platz brauchen. Der Compiler optimiert dann die nicht genutzten Argumente wieder weg. Das leere Makro
#define ASSERT_emC(COND, TEXT, VAL, VAL2)
Die Bemerkung (memcpy) im Comment bezieht sich darauf, dass die Zuweisung den Inhalt des emptyRecord kopiert, nicht etwa eine Stackinstanz-Referenz speichert, ebenfalls eine manchmal übersehene Fehlerquelle. Der Comment hilft, nochmals dort nachzudenken.
Die Anwendungspraxis sieht dann wie folgt aus (am Beispiel):
//Define a data type for test template
//and some data records to add
typedef struct Data_A_T { float x; } Data_A;
//The Data Manager reference
DataMng<Data_A, 10>* mngA = null;
//Some example data
Data_A dataA1 = { 5.1f};
Data_A dataA2 = { 7.2f};
//Define another data type
//and some data records to add
typedef struct Data_B_T { int a,b; } Data_B;
DataMng<Data_B, 23>* mngB = null;
Data_B dataB1 = { 1, 123};
Data_B dataB2 = { 2, 456};
void test_init_DataMng ( ) { //may thrown an exception
mngA = new DataMng<Data_A,10>();
mngB = new DataMng<Data_B,23>();
}
float test_DataMng ( ) {
//add data to the container.
//selecting a faulty container cause an error
//better than void* would be used.
mngA->addData(&dataA1, 12345, 0xaa);
mngA->addData(&dataA2, 12345, 0xbf);
//
mngB->addData(&dataB1, 12345, 0x82);
mngB->addData(&dataB2, 12345, 0x01);
//Access to the container.
//delivers the correct type.
Data_A* dataAok = mngA->getNewestOk();
Data_B* dataBok = mngB->getNewestOk();
//do anything with the result.
return dataAok->x + dataBok->a;
}
Hier treten keine casts auf, alle Typen werden vom Compiler gecheckt, Fehler in aufrufenden Modulen werden also gut erkannt. Das war unser gestecktes Ziel.
Zusatzbemerkungen: Die Daten sind hier allesamt statisch instanziiert, für das Beispiel. Das ist nicht objektorientiert. Der Datenmanager, unsere Beispiel-Template-class ist als statische Referenz ausgeführt und wird in test_init_DataMng ( ) aus dem Heap initialisiert. Das ist wichtig, da im Constructor das ASSERT_emC(...) steht. Würde der mngA, und mngB als rein statische Instanz angelegt, dann wird der Constructor in der Initialisierung vor Erreichen des main() abgearbeitet. Dort kann aber das Exception nicht abgefangen werden. Es ist wichtig, solche Constructoren gezielt im Kontext aufzurufen. Die Routine test_init_DataMng ( ) muss also in einem try-catch-Kontext stehen, in der Testphase mit Assertion-Überprüfung.
:quality(80)/p7i.vogel.de/wcms/a7/35/a735f5b7609b4d08c55785ffd662b277/90763318.jpeg)
C++ in der Embedded-Entwicklung: Umgang mit Heap-Daten
:quality(80)/p7i.vogel.de/wcms/62/60/6260547c6166be61135ca1efa5eb89ab/89197575.jpeg)
C++ in der Embedded-Entwicklung: Embedded-Code am PC testen
:quality(80)/images.vogel.de/vogelonline/bdb/1509900/1509935/original.jpg)
10 kleine Dinge, die C++ einfacher machen
Literatur- und Linkverzeichnis
[1] Erster Artikel dieser Serie: „C++ in der Embedded-Entwicklung: Embedded-Code am PC testen“
[2] Zweiter Artikel dieser Serie: „C++ in der Embedded-Entwicklung: Umgang mit Heap-Daten“
[3] Website emC des Verfassers
[4] Cpp-Referenz für Templates
[5] Daniel Penning: „Wie C++20 generische Programmierung für Embedded Systeme praxistauglich macht“
[6] Webpage des LLVM-Compiler-Konzepts
[7] Wikipedia (deutsch) zu LLVM
* 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:46903630)