Wird die Heap-Nutzung unterschätzt, kann dies zu mangelndem Speicher bei der Funktion »malloc()«, führen. Dies lässt sich ganz einfach durch die Prüfung des Return-Status von malloc() überprüfen, aber das wäre zu spät. Das bedeutet eine kritische Situation, denn die meisten Systeme können sich davon nicht erholen, der einzig mögliche Ausweg ist ein Neustart der Applikation.
Es ist zwar unumgänglich, die Heap-Nutzung aufgrund der dynamischen Eigenschaften des Speichers zu überschätzen, aber ein zu großer Überhang bedeutet auch eine unnötige Verschwendung von Speicherressourcen. Zwei weitere Fehler, die bei der Heap-Nutzung auftreten können, sind das Überschreiben von Heap-Daten (Variablen und Zeiger) sowie die Korrumpierung der internen Heap-Struktur. Für den dynamischen Speicherzugriff sind folgende APIs relevant:
In C++ gibt es außerdem »new Operator«, das ähnlich wie malloc() funktioniert, außerdem »new[]« und »delete Operator«, ähnlich wie »free()«, und »delete[]«. Es gibt viele dynamische Implementierungen von Speicherverwaltungen. Der meistgenutzte ist derzeit »Dlmalloc« (Doug Leaís Memory Allocator), der sowohl bei Linux als auch bei vielen Entwicklungstools für Embedded Systemen zu finden ist.
Dlmalloc ist frei und allgemein verfügbar. Die interne Struktur des Heap ist durchsetzt mit Daten, welche die Anwendung allokiert hat. Schreibt die Anwendung außerhalb der zugewiesenen Bereiche, kann dies die interne Struktur des Heap beschädigen. Bild 4 stellt vereinfacht dar, wie verschieden die Datenstrukturen um die zugewiesenen Nutzerdaten sein können. In dieser Ansicht wird deutlich, dass jeder Schreibvorgang der Anwendung außerhalb der vorgesehenen Bereiche den Heap schwer beschädigt.
Die Berechnung des Speicherbedarfs für den Heap ist alles andere als ein-fach. Viele Entwickler entscheiden sich für einen Trial-and-Error-Ansatz, weil die Alternativen zu mühselig sind. Ein typischer Algorithmus könnte sein: Finde den kleinsten Heap, bei dem die Anwendung immer noch läuft und addiere weitere 50 Prozent Speicher.
Heap-Fehler vermeiden
Es gibt eine Vielzahl von typischen Fehlern, derer sich Programmierer und Code-Prüfer bewusst sein sollten, um das Risiko zu minimieren, dass Produkte mit Heap-Fehlern in Serie gehen:
Nicht-initialisierte globale Daten werden mit 0 initialisiert. Diese wohlbekannte Tatsache macht es einfach, dasselbe für den Heap anzunehmen. Malloc(), realloc() und C++-new initialisieren die zugewiesenen Daten nicht. Es gibt eine spezielle Variante von malloc() namens calloc(), welche die zugewiesenen Daten mit 0 initialisiert. Beim C++-new wird der passende Constructor aufgerufen, also sollte sichergestellt werden, dass er alle Elemente initialisiert.
C++ verfügt über verschiedene Operatoren für Skalare und Arrays: new und delete für Skalare, new[] und delete[] für Arrays.
Dies beschädigt entweder die internen Speicherverwaltungsstrukturen oder wird später wieder bei einem Zugriff auf den später legal allokierten Bereich überschrieben. In jedem Fall lassen sich diese Fehler schwer auffinden.
Malloc(), realloc() und calloc() geben alle einen NULL-Pointer zurück, um eine Speicherknappheit anzuzeigen. Desktop-Systeme erstellen einen Speicherfehler, sodass sich während der Entwicklung einfach feststellen lässt, wann ein Speichermangel auftritt. Embedded Systeme verfügen vielleicht über Flash-Speicher auf der Adresse Null und können mit subtilen Fehlern weiterarbeiten. Verfügt der verwendete Mikrocontroller über eine Speicherschutzeinheit (MPU), kann er so konfiguriert werden, dass bei einem Schreibzugriff auf das Flash oder den Codespeicher ein Speicherschutzfehler ausgelöst wird.
Dies wird sehr wahrscheinlich die interne Speicherverwaltungsstruktur beschädigen und ist nur schwer zu erkennen.
Dies wird sehr wahrscheinlich die interne Speicherverwaltungsstruktur beschädigen und ist extrem schwer zu erkennen. Die letzten drei Fehler lassen sich einfach aufspüren, indem man Wrapper um die Standards malloc(), free() und die dazugehörigen Funktionen legt. Die Wrapper müssen einige Extra-Bytes an Speicher zuweisen, um die Zusatzinformationen unterzubringen, die für die Konsistenz-Checks benötigt werden.
Das Datenlayout im Beispiel-Wrapper in Bild 5 zeigt ganz oben eine »magische Nummer«, mit der sich Fehler aufspüren lassen und die geprüft wird, wenn die Daten wieder freigegeben werden. Das Feld unterhalb der Nutzerdaten wird vom free()-Wrapper genutzt, um die »magische Nummer« zu lokalisieren. Das Wrapper-Beispiel nutzt 8 Byte an Overhead pro Zuordnung, was für die meisten Anwendungen akzeptabel sein sollte.
Das Beispiel zeigt auch, wie man C++-new- und -delete-Operatoren global überschreibt. Dieses Beispiel wird aber all diese Fehler nur finden, wenn der zugewiesene Speicher auch zu irgendeinem Zeitpunkt wirklich freigegeben wird. Bei manchen Anwendungen trifft das möglicherweise nicht zu. In diesem Fall muss der Wrapper über eine Liste mit allen Speicherzuweisungen verfügen und regelmäßig alle aufgezeichneten Zuweisungen überprüfen.
Der Overhead für diese Implementierung ist nicht so groß (»Beispielcode 2«), wie es zunächst scheinen mag, denn die meisten Embedded Systeme nutzen den dynamischen Speicher verhältnismäßig selten und halten die Zuordnungsliste daher in einem vernünftigen Rahmen.
Die Heap-Größe festlegen
Wie lässt sich die minimale von der Anwendung benötigte Heap-Größe ermitteln? Diese Frage ist aufgrund des dynamischen Verhaltens und möglicher Fragmentierungen nicht trivial. Es empfiehlt sich, die Anwendung in einem System-Testfall ablaufen zu lassen, der so angelegt ist, dass der dynamische Speicher so viel wie möglich genutzt wird. Dabei ist es entscheidend, immer wieder die Übergänge zur niedrigen Speichernutzung auszuführen, um so die Auswirkungen einer möglichen Fragmentierung sichtbar zu machen.
Ist der Testfall abgeschlossen, vergleicht man die maximale Heap-Nutzung mit der tatsächlichen Heap-Größe. Je nach Art der Anwendung sollte ein Spielraum von 25 bis 100 Prozent vorhanden sein. Bei Systemen, die Desktop-Systeme durch das Emulieren von »sbrk()« nachahmen, ergibt sich die maximale Heap-Nutzung durch »malloc_max_footprint()«.
Bei Embedded Systemen, die sbrk() nicht emulieren, ist es üblich, den gesamten Heap in einem einzigen großen Stück an die Speicherverwaltung zu übergeben. In diesem Fall wird malloc_max_footprint() nutzlos und gibt nur die Größe des gesamten Heap zurück. Deshalb bietet sich die Abfrage »mallinfo()« nach jeder »malloc()«-Abfrage an, zum Beispiel in der zuvor beschriebenen Wrapper-Funktion, und dann den gesamten zugewiesenen Bereich zu betrachten (mallinfo->uordblks).
Mallinfo() benötigt viel Rechenleistung und beeinträchtigt die Performance. Eine bessere Vorgehensweise ist daher, den maximalen Abstand zwischen den zugewiesenen Bereichen aufzuzeichnen. Das lässt sich einfach bewerkstelligen und wird im Wrapper-Beispiel gezeigt. Der Maximalwert wird in der Variablen »myMallocMaxMem« aufgezeichnet. Voraussetzung für dieses Verfahren ist, dass der Heap ein einziger, zusammenhängender Speicherbereich ist.
Purify, Insure++ und Valgrind sind bekannte Analysewerkzeuge für Heap-bezogene Aufgabenstellungen bei Desktop-Systemen. Aus Leistungsgründen sind Tools aus dieser Kategorie nicht für kleine Embedded Systeme verfügbar. Entwickler müssen sich entweder auf die hier beschriebenen Methoden verlassen oder die dynamische Speicherverwaltung durch eine der Varianten für Konsistenzanalyse und Debugging ersetzen, wie zum Beispiel »dmalloc«.
Über die Autoren:
Lotta Frimanson und Anders Lundgren sind Produktmanager bei IAR Systems.