Alignment: Korrekte Ausrichtung

Wenn aus Dateiformaten oder Datenpuffern Variablen entnommen werden, bzw. wenn Pointer auf eine bestimmte Stelle gesetzt werden und als ein bestimmter Typ re-interpretiert werden, dann hat man PC oft Glück und das funktioniert.

Will man den gleichen Code auf den Raspberry PI oder aufs SmartPhone portieren, erlebt man Abstürze.

Vielleicht handelt es sich dabei um Alignment- Exceptions.


Wenn ich meine Codesammlungen aus dem vergangenen Jahrhundert ansehe, fallen mir oft Passagen auf, wo sehr wild Byte-Pointer herum addiert werden, um dann 32-bit oder 64-bit Ganzzahlen auszulesen.

Die PC-X86 Architektur stammt aus Zeiten, wo man wenigen Kilobytes auskommen musste, und so gibt es keine Regeln für die “Ausrichtung” der Daten. Alles musste so platzsparend wie möglich hintereinander im RAM ablegbar sein.

RISC Architekturen, wie wir sie auf unseren Smartphones finden, verfolgen einen anderen Ansatz und wollen möglichst energie- und kosteneffizient auf Daten zugreifen und führen dafür Regeln ein.

Ein davon ist das Alignment, also die Ausrichtung von Daten und Variablen im Speicher.

Auch wenn jede Plattform ihre eigenen Regeln definiert, so kann man grundsätzlich sagen, dass die Standard-Bitbreite des Prozessors das Alignment vorgibt.

Auf einem N-bit System müssen alle Variablen an einer Adresse beginnen, die ein ganzzahliges Vielfaches von N-bits ist.
Bei 32 bits sind das 4 Bytes, anders gesagt, die Adresse muss ohne Divisionsrest durch 4 teilbar sein.

Die C Compiler führen deshalb für Strukturen ein Padding ein, sie füllen also den Platz zwischen Variablen mit Bytes auf, damit die nachfolgende Variable wieder perfekt ausgerichtet ist.

Daher können gleiche C Strukturen auf unterschiedlichen Plattformen unterschiedliche Größen haben:

1struct foo
2{
3  char  id;
4  long  data;
5};

hätte unter einem uralten DOS-Compiler wirklich nur 5 Bytes belegt. 32-bit Compiler hätten nach id 3 unsichtbare Padding-Bytes eingelagert und die Größe der Struktur auf 8 Bytes erhöht. Und auf 64-Bit Systemen müssten wir zuerst klären, ob long 32 (Windows) oder 64 (Linux) bits breit gewesen wäre, womit im letzteren Fall ein 16 Byte großer Bereich belegt worden wäre.

Der Code fread(&foovar, sizeof(foovar), 1, file); hätte also auf jeder Plattform etwas anderes aus einer Datei gelesen.

Geht man dazu über alles in char - Puffern zu lesen, muss man ebenso vorsichtig sein, denn:

1char buffer[16];
2...
3long* data_ptr = (long*)&buffer[3];

würde auf X86 zwar (etwas verlangsamt) funktionieren, aber auf ARM Prozessoren eine Alignment-Exception auslösen, weil der Long-Pointer auf einen nicht korrekt ausgerichteten Speicherplatz verweist.

Eine eher portable Lösung könnte so aussehen:

1long data;
2char buffer[16];
3...
4memcpy(&data, &buffer[3], sizeof(long));

Vollkommen portabel ist das aber immer noch nicht, denn wer sagt uns denn, dass die Daten im Puffer die gleiche Byte-Reihenfolge haben?

Wollen wir also wirklich plattformunabhängigen Code schreiben, müssen wir uns Serialisierungsfunktionen für alle Datentypen schreiben, die die Bytes Stück für Stück auseinandernehmen und wieder zusammensetzen.

Genau das machen aber die wenigsten Programmierer und so finden sich massenweise Codes im Netz, die auf X86-PCs gerade so funktionieren, weil man immer von Little-Endian ohne Alignment ausgeht und daher wild mit Pointer alles adressieren kann.

Fazit

Liebe Leute, versucht etwas über den Tellerrand hinaus zu blicken. Wir erleben alle 10 Jahre den Umstieg auf eine neue CPU Technologie und es sind solche Kleinigkeiten, die es verhindern, dass euer Code problemlos auf der neuen Hardware läuft.
Und es ist echt schade, wenn man dann wieder bei Null anfängt und große Bibliotheken wegwirft, nur weil da ein Byte an der falschen Stelle gelesen wird.