Die Speicherlandschaft eines Prozesses

Dieser Text beschäftigt sich damit, welche Arten von Speicherbereichen es im Adressraum eines Prozesses gibt, und welche Variablen eines C/C++ Programms wo und auf welche Art gespeichert werden. Nützlich ist dieser Text für Leute, die besser verstehen wollen, was eigentlich im Innern eines Programms so vor sich geht, aber auch für Leute die den Speicherverbrauch reduzieren und die Performance ihrer Programme verbessern wollen. Der Text sollte auch eine (vielleicht sogar visuelle) Vorstellung vom Adressraum vermitteln.

Es wird hier nur der „Adressraum“ eines Prozesses behandelt, also der Virtuelle Speicher. Das Wissen aus dem vorherigen Artikel - Virtueller und Physikalischer Speicher - wird daher vorausgesetzt. Für uns ist der nur der „Virtuelle Speicher“ von Bedeutung, da wir aus einem Prozess heraus nur darauf Einblick haben. Die Speicherverwaltung und somit der Physikalische Speicher ist für uns vollkommen Transparent und wird vom Betriebssystem übernommen.

Die Speichersegmente

Der Adressraum eines Prozesses ist meistens in mehrere sogenannte „Segmente“ aufgeteilt. Ein Segment ist ein (meist zusammenhängender) Speicherbereich, der bestimmte Eigenschaften und Aufgaben hat. Einige dieser Eigenschaften sind:

  • Schreib/Lesbarkeit: Ist das beschreiben dieses Segmentes erlaubt?
  • Ausführbarkeit: Ist das Ausführen von Code in diesem Segment erlaubt?
  • Größe und Ort: Ist das Segment beweglich? Kann es wachsen oder schrumpfen? Oder ist es fix?
  • Verwendung: Was wird in dem Segment gespeichert?

Manche Formate von ausführbaren Dateien (wie Beispielsweise ELF) besitzen Ebenfalls Segmente, die ungefähr den hier gemeinten Segmenten im Prozessspeicher entsprechen. Nachfolgend sind einige Segmente aufgelistet, mit denen beim Programmieren in Kontakt kommt. Um euch das Verständnis zu erleichtern gibt es hier ein Listing mit einem Sinnlosen Programm, das aber alle Speicherarten erklärt:

#include <iostream>
#include <vector>
#include <cstring>
 
const int NUM_ELEMENTS = 33; // [2]
 
void printelm(const int elm) // [1]
{
	static unsigned elm_count = 0; // [3]
	std::cout<<"Element Number " /* [4] */<<++elm_count<<": "<<elm<<'\n'  /* [2] */;
}
 
 
int main(int argc, char** argv /* [5] */ ) // [1]
{
	int somevar /* [5] */ = 13 /* [2] */;
	char* str  /* [5] */ = NULL /* [2] */; 
	std::vector<int> vec(NUM_ELEMENTS) /* [6] */;
 
	str = new char[strlen(argv[0 /* [2] */ ])]; /* [7] */
 
	for (int i = 0 /* [2] */; i < NUM_ELEMENTS; i++)
		printelm(vec[i]);
 
 
	delete[] str;
}

Das "Code" Segment

Das Codesegment (auch Text Segment) ist der Teil des Adressraums, in dem der kompilierte und ausführbare Code gespeichert wird. Dieses Segment ist selbstverständlich Ausführbar und Lesbar, aber nicht Schreibbar. Jeder versuch in dieses Segment zu schreiben resultiert in einem „Segmentation Fault“. In unserem Beispiel sind das die main() und die printelm() Funktionen ([1]). In dieses Segment kommt aber nicht nur der Code den der Programmierer schreibt, sondern dort wird der gesamte Code, der zur Ausführung des Programms benötigt wird. Das ist beispielsweise Startup Code, der die Umgebungsvariablen und die Parameter für die main() Funktion übergibt und den benötigten Speicher reserviert und einrichtet. Dort kommt auch jeglicher Bibliothekscode rein, der zum Programm gelinkt wurde (Darunter ist auch die Standardbibliothek). Die Adressen für die jeweiligen Funktionen bleiben für ein kompiliertes Programm immer gleich und werden zur Linkzeit ermittelt. Eine Ausnahme hierzu sind Funktionen aus dynamischen Bibliotheken 1), die zur Laufzeit dazugelinkt und in das Codesegment eingegliedert werden.

Einfache Literale wie zum Beispiel int oder char konstanten, aber auch konstante Zeiger (wie es NULL ist) und die Ergebnisse vom sizeof() Operator werden meistens direkt im Maschinencode gespeichert, und Landen dementsprechend auch im Code Segment. Deswegen macht es auch wenig Sinn, die Adresse eines Integer- Literals zu nehmen.

In unserem Codebeispiel sind das alle Konstanten ([2]) und Literale.

Das "Data/BSS" Segment

Im Data/BSS Segment werden initialisierte und nicht initialisierte statische Daten gespeichert. Es ist meistens nicht Ausführbar, dafür aber Schreibbar. Genaugenommen sind es zwei Segmente, die aber meist zu einem zusammengefasst werden: Das Data Segment enthält Konstante Daten, die zur kompilierzeit schon feststehen. Dazu zählen nicht- POD2) konstanten wie z.B. konstante Strings, konstante Structs aber auch konstante Doubles. In unserem Beispiel sind das alle String- Literale (mit [4] markiert) Das BSS Segment enthält alle „static“ Variablen ( mit [3] markiert ).

Dieses Segment ist ebenfalls statisch, wird also nicht größer oder kleiner. Adressen zu Objekten in diesem Segment werden ebenfalls zur Linkzeit festgelegt, und bleiben für eine Ausführbare Datei immer gleich.

Der Stack

Der Stack ist eigentlich kein eigenständiges Segment - er teilt sich ein Segment mit dem Heap. Details dazu, wie das funktioniert erfahrt ihr im in diesem Artikel.

Der Stack ist sowohl schreibbar als auch lesbar. Obwohl es keinen Grund gibt, wieso er ausführbar sein sollte, lässt er sehr oft die Ausführung von Code zu.

Auf dem Stack werden lokale Variablen gespeichert, also Variablen einer Funktion. Allerdings werden auf dem Stack auch veränderbare Parameter der Funktion abgelegt, also Parameter die nicht konstant sind. Somit kann sichergestellt werden, dass die Funktion die Werte tatsächlich nicht verändert. Die lokalen Variablen und Parameter sind in unserem Beispiel mit [5] markiert.

Der Speicher wird beim Eintritt in eine Funktion belegt, und beim Austritt wieder freigegeben. Deswegen wächst der Stack zur Laufzeit. Somit sind alle Adressen nur zur Laufzeit gültig und können vor dem ausführen des Programms nicht so einfach vorhergesehen werden. Früher startete der Stack bei einer bestimmten Adresse, die zur Linkzeit festgelegt wird. Dadurch wurden manche Adressen allerdings vorhersehbar, und machten Sicherheitslücken leichter ausnutzbar. Ab dem Linux Kernel 2.6.11 und ab Windows Vista wird die Startadresse des Stacks beim Start des Prozesses zufällig gewählt. Das macht das ausnutzen von Sicherheitslücken ein bisschen schwieriger. Aber keine Sorge, auch für Stacks mit zufälliger Startadresse gibt es genug Methoden, eine Sicherheitslücke auszunutzen.

Der Heap

Der Heap teilt sich wie gesagt ein Segment mit dem Stack.

Auf dem Heap werden dynamisch allokierte Daten gespeichert, also Daten, von denen man nur während der Ausführung weiß wie groß sie sein werden. Dies geschieht in C mit den Systemfunktionen malloc(), calloc() oder realloc(), und in C++ mit den Operatoren new und new[]. In unserem Beispiel ist die allokierung mit [7] markiert. Deswegen sind auch die Adressen der allokierten Daten nur während der Laufzeit gültig.

Um Speicherlecks zu vermeiden sollte man alle Objekte, die man mit malloc() oder new allokiert hat wieder freigeben, wenn man den Speicher nicht mehr braucht. Die Objekte kurz vor dem Ende des Programms wieder freizugeben macht allerdings wenig Sinn: Das Betriebssystem räumt sowieso den ganzen Speicher eines Prozesses frei, nachdem dieser beendet wurde.

Die Speicherverwaltung wird vom Betriebssystem übernommen, für Programmierer ist sie nur über malloc() und new sichtbar. Im Regelfall geht die Speicherverwaltung mit relativ viel Aufwand einher, deswegen ist sie auch langsamer und sollte so weit wie möglich vermieden werden (sehr weit wird man sie nicht vermeiden können ;-) ).

Die Eigenschaften sind gleich wie beim Stack: schreibbar, lesbar, und meistens (leider) ausführbar.

Und was ist jetzt mit std::vector<int> ?

Ich habe noch eine kleine Spielerei eingebaut, und mit [6] markiert: Es ist der std::vector<int>. Auf den ersten Blick ist er ein Objekt auf dem Stack. Bei genauerem hinsehen merkt man aber…
.. dass es noch immer ein Objekt auf dem Stack ist. Alle Variablen, die das Objekt std::vector<int> enthält, werden lokal gespeichert. Allerdings hat ein std::vector<int> die Eigenschaft, dass er seine Größe verändern kann, und das ist nur mithilfe von dynamischer Speicherallokierung möglich. Das std::vector<> Objekt enthält einen Zeiger auf die Daten (die int's, die es Enthält), und diese liegen auf dem Heap. Das ist übrigens bei fast allen Containern der STL der Fall.

Wenn ihr also ein std::vector<> habt, merkt euch, dass der Großteil des Speichers den es verbraucht auf dem Heap liegt. Falls ihr bereits zur Kompilierzeit wisst, wie groß euer Array sein muss, und ihr nicht auf die Vorteile eines Containers verzichten wollt, könnt ihr statische Container verwenden. Bei einem relativ neuem Compiler ist das beispielsweise std::tr1::array, oder falls ihr einen älteren Compiler habt sind sie auch in der Boost Bibliothek vorhanden. Ab schätzungsweise 2009 werden statische Container in den C++ Standard integriert, und somit auf jedem neuen Compiler unter dem std:: Namespace vorhanden sein.


Autorendiskussion

1)
„Shared Libraries“ wie zum Beispiel .dll's unter Windows oder .so's unter Unix
2)
Plain Old Data - Einfache Datentypen wie einzelne Variablen oder Zeiger