C++ im Embedded SystemMöglichkeiten und Grenzen des Einsatzes von C++
|
class X
{
public:
static void* operator new( std::size_t s );
static void operator delete( void*, std::size_t );
};
void* X::operator new( std::size_t s )
{
if( s != sizeof(X) ) // Wichtig, wenn der Operator
{ // nur fuer X wirksam sein soll!
return ::operator new( s );
}
return specialMalloc( s );
}
void X::operator delete( void* p, std::size_t s )
{
if( s != sizeof(X) ) // dito
::operator delete( p );
else
specialFree( p );
}
| ||
|
Hier soll zunächst einmal mit dem ersten Punkt begonnen werden:
Für alle Klassen können separate new
- und delete
-Operatoren überladen werden, wenn es
der Einsatz-Kontext notwendig macht. Man deklariert die beiden Operatoren als statische Klassenelemente in der
Klasse, die an einen systemspezifischen Kontext angepasst werden soll. Die Implementierung des new
-Operators
fordert einfach die Bereitstellung von Speicher. In der delete
-Implementierung muss dieser Speicher
wieder zurückgegeben werden. Die beiden Pseudofunktionen specialMalloc()
und specialFree()
stehen für beliebige spezielle Allokations- und Deallokationsfunktionen.
Das besondere an den Implementierungen ist, dass man über einen Parameter die Größe des Speicherbereichs
überprüfen kann. Damit kann sichergestellt werden, dass die überladenen Operatoren nur für den Klassentyp
zur Anwendung kommen, für den sie definiert wurden. Kindklassen anderer Größe werden damit ausgeschlossen.
Ein Kontext, der ziemlich alle Aspekte der systemnahen Programmierung enthält ist beispielsweise die Treiberprogrammierung
unter Linux. Linux Treiber werden im Kernelspace ausgeführt. Dort sind die normalen Speicherallokationsfunktionen der C-Lib
nicht anwendbar. Es müssen spezielle Funktionen dafür angewendet werden.
Mit Listing 2 wird eine mögliche Anpassung an die Erfordernisse des Linux-Kernespaces vorgestellt.
Für eine realistische Anwendung des gezeigten Codes muss allerdings noch das Buildsystem für Linux Treiber umgestellt
werden, damit C++ Quelldateien in die Makefiles aufgenommen werden können.
In der gezeigten Implementierung wurde auf die Abfrage der Speichergröße verzichtet. Damit stellt die Klasse
X
ihren Allokations- und ihren Deallokationsoperator für sich selbst und alle abgeleiteten Klassen bereit.
Wohlgemerkt: das Beipiel in Listing 2 soll nur zeigen, dass es möglich ist C++ als Sprache an einen Kontext anzupassen, in dem spezielle Voraussetzungen für die Nutzung von dynamischem Speicher gegeben sind (auf keinen Fall soll mit diesem Beispiel gesagt werden, dass C++ für die Treiberentwicklung unter Linux der Sprache C vorzuziehen sei).
...
void* X::operator new( std::size_t s )
{
// Linux Kernel Funktion zur Allokation.
void* p = kmalloc( s, GFP_KERNEL );
return p;
}
void X::operator delete( void* p, std::size_t )
{
// Linux Kernel Funktion zur Speicherfreigabe.
kfree( p );
}
| ||
|
In dem vorgestellten Code ist noch keine Fehlerbehandlung realisiert. Auch das ist für den unterstützten Systemkontext
ein sehr spezifisches Thema. So ist es beispielsweise nicht ratsam, im Kernelspace Exception Handling zu verwenden.
Der umgebende C-Code kennt keine Exception Handler und kann somit auf Exceptions nicht reagieren.
Ein Rückgriff auf das Verhalten des „alten“ new
-Operators vor der ANSI/ISO-Standardisierung ist einfach zu
implementieren. Der new
-Operator aus den AT&T-Standards gab einfach einen Nullzeiger zurück,
wenn eiune Allokation nicht durchgeführt werden konnte. Allerdings darf dann die Klasse nicht mehr in einem Kontext
eingesetzt werden, der Exceptions bei fehlgeschlagenen Allokationen erwartet. Es muss also nicht nur die Klasse durch die
Implementierung der kontextspezifischen Funktionen vorbereitet werden sondern auch der Code, der die Klassen
nutzt. Mit diesen Voraussetzungen ist eine solche Klasse stark auf den entsprechenden Einsatz-Kontext beschränkt.
Wenn beispielsweise eine Klasse auf die in Listing 1 beschriebene Weise mit einem new
-Operator
ausgesttattet wurde, der die Allokationsfunktionen des Linux-Kernelspaces verwendet, dann kann diese Klasse
auch nur im Linux-Kernelspace eingesetzt werden. Das ist der Schwachpunkt der vorgestellten Lösung. Andererseits
ist die Implementierung relativ einfach.
Eine Erweiterung dieses Prinzip könnte die Anwendung eines Allokatortyps darstellen. Würde man die beiden Operatoren
mit Hilfe eines über Templateparameter gelieferten Typs realisieren, der zwei Methoden - alloc()
und
dealloc()
- implementiert, könnte man über diesen Templateparameter die Klasse an den Systemkontext
anpassen, in dem sie verwendet werden soll.
void* operator new( std::size_t size )
{
// Eine 0-Byte Anforderung wird als
// 1-Byte Anforderung behandelt.
// Nach dem Standard ist eine Anforderung
// von 0 Byte gueltig.
if( size == 0 ) size = 1;
for(;;)
{
Versuch, Speicher zu allokieren;
if( Versuch erfolgreich )
return Zeiger auf Speicher;
// Die Allokation ist fehlgeschlagen.
// Jetzt muss die Fehlerbehandlungsfunktion
// ermittelt werden.
new_handler globalHandler = set_new_handler( 0 );
set_new_handler( globalHandler );
if( globalHandler ) (*globalHandler)();
else throw std::bad_alloc();
}
}
void operator delete( void* p )
{
Freigabe von p;
}
| ||
|
Eine einfache Möglichkeit der Anbindung einer spezifischen Speicherverwaltung an einen beliebigen C++ Code
ist die Überladung der globalen new
- und delete
-Operatoren.
Listing 3 zeigt den schematischen Code einer globalen Überladung der Operatoren. Dabei wird
auch deutlich, wie die Ausnahmebehandlung in den new
-Operator eingebunden ist. Die globale Handler-Funktion
kann noch irgend etwas tun, um Speicher verfügbar zu machen. Wenn das nicht mehr gelingt, wird nach dem ISO-Standard die
Exception std::bad_alloc
geworfen. In einem Kontext, in dem man sich
gegen Exception Handling entscheiden muss kann man das Verhalten einfach dadurch umdefinieren, indem man einen
Nullzeiger im Fehlerfall zurück gibt. Technisch ist das sehr einfach zu realisieren. Problematisch wird es erst dann,
wenn man Code integrieren möchte, der auf dem ISO-Standard-Verhalten aufbaut und daher mit den Exceptions von
new
rechnet.
Eine einfache Überladung der globalen Operatoren für den Linux Kernelspace wird in Listing 4
gezeigt.
Während man mit der zuerst vorgestellten Möglichkeit für einzelne Klassen den Allokations- und
Deallokationsoperator zu überladen eine große Flexibilität gewinnt, zeichnet sich die zweite Möglichkeit
der globalen Umdefinition durch Einfachheit aus. Die globale Überladung definiert für alle Allokationen mit
new
die Anbindung an eine spezielle Speicherverwaltung. Die loakle Überladung eröffnet die Nutzung
unterschiedlicher Speicherverwaltungen für unterschiedliche Objekttypen.
Problematisch bleibt immer die Änderung des Fehlerverhaltens wenn auf Exceptions verzichtet werden soll. Dann muss
garantiert werden, dass kein Code in die Ausführungseinheit integriert wird der die Exceptions nutzt. Andersherum
muss bei altem AT&T konformem Code darauf geachtet werden, dass der new
-Operator im Fehlerfall
einen Nullzeiger liefert. Genauso kann auch die nothrow
-Variante des new
-Operators überschrieben
werden.
Die Klassenspezifische Überladung der Allokations- und Deallokationsoperatoren erlaubt
eventuell auch die Integration beider Codearten indem Operatoren mit unterschiedlichen Fehlerbehandlungstechniken
definiert werden.
void* operator new( std::size_t s )
{
// Linux Kernel Funktion zur Allokation.
return kmalloc( s, GFP_KERNEL );
}
void operator delete( void* p )
{
// Linux Kernel Funktion zur Deallokation.
kfree( p );
}
| ||
|
Auch dieses Beispiel in Listing 4 soll nur die grundsätzliche Anwendbarkeit von C++ in einem Systemkontext mit spezieller dynamischer Speicherverwaltung demonstrieren. Gerade in der Linux Treiberentwicklung gibt es jedeoch viele Gründe die für die Wahl der Sprache C sprechen. Insbesondere das vorbereitete Buildsystem und die vielen bereits vorhandenen Vorlagen in C.
Wie in vorangegangenen Abschnitten bereits angesprochen wurde, stellt die ANSI/ISO-Standardisierung für den Einsatz von C++ nicht unbedingt eine Vereinfachung dar. Die wesentlichen Änderungen des ISO-Standards an der Sprachsyntax von C++ gegenüber der älteren AT&T-Variante sind:
new
mit Fehlerbehandlung über Exceptions.new
-Operators mit dem alten Verhalten aber einer neuen Signatur - nothrow
Variantetype_info
.Die Änderungen des Standards an der C++ Bibliothek sind:
std
für die Bibliothek.Insbesondere die Punkte, die sich mit dem Thema Exception Handling befassen, werfen für den Einsatz in modernen Softwareprojekten die meisten Fragen auf. Folgende Punkte müssen immer geklärt werden:
EC++ ist ein von einem Konsortium japanischer Chiphersteller definierter Standard, der gegenüber C++ einen eingeschränkten Sprach- und Bibliotheksumfang aufweist. Ziel der Einschränkung war, die Sprache von allem zu befreien, was zu Ineffizienz bei Codegröße und Laufzeit führen kann. Außerdem sollte der Standard dazu führen, dass Compiler für neue embedded Plattformen möglichst einfach erstellbar sein sollen.
Spracheigenschaften, die in EC++ nicht enthalten sind:
static_cast<>
, dynamic_cast<>
, reinterpret_cast<>
und const_cast<>
mutable
Insgesamt erfährt EC++ viel Kritik aus der C++ Entwicklergemeinde, da wesentliche syntaktische Merkmale von C++ fehlen und durch aufwendigere Programmierarbeit ausgeglichen werden müssen. Es ist für einige C++ Merkmale auch nicht nachvollziehbar, warum sie in EC++ nicht gewünscht wurden. Insbesondere die Typenumwandlungsoperatoren, die Templates und die Namensräume haben keine Effizienznachteile. Insofern führt der EC++ Standard nicht wirklich zu effizienteren Programmen. Wie im Abschnitt zu Codegröße und Geschwindigkeit schon ausgeführt wurde, shließt sich der Autor dieses Artikels der genannten Kritik an. Da auch nicht sehr viele Compiler in der Lage sind, diesen Standard zu unterstützen, ist EC++ keine echte Lösung
C++ und Java sind nicht einfach zwei Sprachen, die man in ihren syntaktischen Besonderheiten vergleichen kann.
C++ ist eine Sprache, die in Hardware-spezifischen Maschinencode übersetzt wird. Java braucht eine Virtual Machine
um laufen zu können. Der zu Java-Bytecode übersetzte Quelltext ist allein nicht lauffähig. In dieser VM liegt
der Schlüssel zum Verständnis des Unterschieds, denn sie definiert ein Typensystem das erstens über alle
Plattformen vom Mobiltelefon bist zum Großrechner identisch ist und zweitens die Typen zur Laufzeit beschreiben kann.
Außerdem ist der Java-Bytecode auch auf jeder Plattform lauffähig. Diese zwei Grundeigenschaften haben weitreichende
Konsequenzen. Eine Konsequenz ist die weitrechende Plattformunabhängigkeit von Java-Code. Eine andere ist die
hohe Connectivity von Java als Plattform, denn durch das vereinheitlichte Typensystem und die zur Laufzeit beschriebenen
Typen ist es ein Leichtes zwischen Javaprogrammen eine Verbindung aufzubauen und komplexe Informationen zu übertragen.
Man überträgt einfach Objekte oder ganze Objekthierarchien. Die Java-VM ist also eine Art Betriebssystemaufsatz, eine
Art vereinheitlichende Plattform. Eine solche Vereinheitlichung zwischen verschiedenen C++-Programmen auf verschiedenen
Plattformen ist praktisch nicht zu erreichen. Man muss schon bei relativ einfachen Kommunikationsaufgaben große
Sorgfalt auf die Typisierung der Daten verwenden. Frameworks zur Vereinheitlichung der Typensysteme zur Kommunikation
gibt es einige (CORBA), sie sind jedoch alle auf eine begrenzte Anzahl von Plattformen beschränkt.
Java bringt also vor allem im Kommunikationsbereich und auch in anderen Bereichen gegenüber C++ eine wesentlich
gesteigerte Produktivität für den Entwickler mit sich. Diese gesteigerte Produktivität hat auch ihre Kosten.
Zum einen ist die Garbage Collection in Java durch parallele Threads realisiert, die autark arbeiten und nicht
bzw. nur wenig vom Programmierer beeinflusst werden können. Diese Autarkie bedeutet, dass diese Threads zu jeder Zeit
tätig werden können. Insbesondere in Codebereichen, die deterministisches Laufzeitverhalten voraussetzen, ist diese
Eigenschaft der Java-VM unakzeptabel. Der nächste Punkt ist die Typenbeschreibung: jede Klasse wird in Java zur Laufzeit
beschrieben. Üblicherweise werden die meisten Klassen zum Start des Programms geladen und beschrieben. Es findet etwas
statt, was man entfernt mit dem Linkvorgang eines C/C++-Programms vergleichen könnte, nur dass es beim Programmstart
passiert.
Der Startvorgang wird dadurch extrem langsam im Vergleich zu einem C++-Programm. Die Beschreibungen der Typen
benötigen außerdem Speicher zur Laufzeit. Das ist der Hauptgrund dafür, warum Java-Programme in der Regel
sehr viel Hauptsspeicher konsumieren. Ob diese Kosten bezahlbar sind, muss sich am gestellten Problem entscheiden. Eine
allgemeine Absage an Java im Embedded System ist sicher fehl am Platz, schließlich bekommt man einen enormen
Produktivitätsvorsprung gegenüber C++. Was man betrachen muss sind die erhöhten Kosten für die Hardware.
Diese muss man mit der Anzahl der auszuliefernden Systeme multiplizieren, um diese Kosten den (geplanten) gesparten
Entwicklungskosten gegenüberstellen zu können.
Ralf Schneeweiß - 04. August 2004
kleinere Korrekturen am 28. April 2021
1) | Es gibt eine geringe Anzahl von Definitionen, die für C und dem C-Anteil in C++ unterschiedlich sind. Insofern ist C++ nicht im strengen Sinne eine Obermenge der Sprache C. Da diese Definitionen aber strukturell nicht von Bedeutung sind, sollen sie hier außer Acht gelassen werden und C++ als strukturelle Obermenge von C betrachtet werden. |
2) | Dem öfters von eingefleischten C-Programmierern vorgebrachten Argument, dass auch in C-Projekten eine vergleichbare Strukturierung erreicht werden kann sei zugestimmt. Es lassen sich objektorientierten Programme auch in C schreiben. Die Unterstützung durch eine OO-Sprache erleichtert jedoch die Anwendung von OO-Konstrukten. |
3) | Zum Beispiel durch den Rückgriff auf die Entwurfsmuster aus [GoF95]. |
[AA01] | Andrei Alexandrescu: Modern C++ Design. Generic Programming and Design Patterns Applied. 2001. |
[TR06] | ISO/IEC TR 18015:2006(E). Technical Report on C++ Performance. 2006. |
[C++03] | ISO/IEC 14882:2003(E). International Standard Programming Languages − C++. Second edition 2003-10-15. |
[GoF95] | Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides: Design Patterns. Elements of Reusable Objectoriented Software. 1995. |