Serialisierung

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Serialisierung bezeichnet den Prozess, Informationen, die in Datentypen einer Programmiersprache vorliegen, in eine "flache" Darstellung, typischerweise eine Folge aus Bytes (8-Bit-Integer), umzuwandeln. Dies ist erforderlich, um die Daten über Kommunikationsschnittstellen/Netwerke zu übertragen oder in Dateien abzulegen, da diese üblicherweise nur Bytes akzeptieren. Der umgekehrte Vorgang, aus einer Byte-Folge wieder eine Datenstruktur zu machen, heißt dementsprechend Deserialisierung. Mit anderen Worten: Serialisierung ist die Antwort auf die Frage, wie ein uint32_t in ein uint8_t[4] umgewandelt werden kann. In diesem Artikel wird gezeigt, welche Möglichkeiten zur Serialisierung es in C und C++ gibt, und eine Bibliothek vorgestellt, welche die Probleme der vorherigen Lösungen behebt.

Artikel von Niklas Gürtler. Zugehöriger Forums-Thread für Feedback

Sprachstandards und garantiertes Verhalten

Zunächst müssen einige Grundlagen geklärt werden. Die Sprachen C und C++ sind durch internationale Standards festgelegt (C durch ISO/IEC 9899 - die erste Version durch ANSI X3.159-1989, C++ durch ISO/IEC 14882), in jeweils mehreren Versionen. Diese Standards definieren das Verhalten von in der jeweiligen Sprache geschriebenen Programmen. Die verschiedenen Sprach-Implementationen sorgen für dieses Verhalten. Eine Implementation ist die Kombination von Soft-und Hardware, welche die Übersetzung und Ausführung von Programmen in C bzw. C++ übernehmen. Dazu gehören z.B. Compiler, Laufzeit-Bibliothek und Prozessor-Architektur. Es gibt eine ganze Reihe von Fällen, für die die Standards kein spezifisches Verhalten vorschreiben. Diese unterteilen sich in zwei Kategorien:

  • Implementation-defined behaviour sind Fälle, für die der Standard kein bestimmtes Verhalten vorschreibt, und deren Ergebnis von der Implementation abhängt ([1], S. 3). Solche Fälle sind nicht (notwendigerweise) Fehler im Programm, sondern machen es lediglich unportabel. Ein Beispiel ist die Ausgabe von "printf("%d\n", ((int16_t) -32768) >> 15);" - sie könnte z.B. -1 oder 1 sein, je nachdem ob die Implementation eine Sign-Extension vornimmt oder nicht.
  • Undefined behaviour sind Fälle, bei denen der Standard keinerlei Vorschriften zum Verhalten macht ([1], S. 4). Programme, bei denen solche Fälle auftreten, sind fehlerhaft im Sinne des Standards. Das Programm kann abstürzen, nicht kompilieren, wie erwartet funktionieren, nur langsam werden, die Festplatte formatieren, in schwer zu findenden Spezialfällen falsche Ergebnisse liefern, usw. Ein Beispiel ist "INT_MAX+1" - das Überlauf-Verhalten von vorzeichenbehafteten Integern ist nicht definiert.

Ein Programm, in welchem solche Fälle auftreten, funktioniert nur, wenn sich die Implementation auf eine bestimmte Art verhält, die vom Standard nicht vorgegeben ist. Somit ist ein solches Progamm nicht portabel und seine Funktion ist von ggf. schwer zu kontrollierenden Umgebungs-Faktoren abhängig. Nur Programme, welche sich ausschließlich auf das vom Standard vorgeschriebene Verhalten verlassen, funktionieren garantiert immer und auf jeder Sprach-Implementation. Der Standard gibt somit den "kleinsten Nenner" aller Implementationen an. Das vom Standard nicht vorgeschriebene Verhalten kann auf konkreten Implementationen von verschiedenen Faktoren abhängen:

  • Prozessor-Architektur (z.B. ARM vs x86 vs PowerPC)
  • Compiler (z.B. GCC vs. MSVC vs. IAR)
  • Compiler-Versionen
  • Compiler-Optionen (z.B. -funsigned-char oder -fstrict-aliasing beim GCC) und -Optimierung
  • Betriebssystem
  • Anderer Code und Daten im selben Programm

Die Änderung eines dieser Faktoren kann das Verhalten aller Programme beeinflussen, welche sich nicht ausschließlich auf das garantierte Verhalten verlassen. Da diese Faktoren teilweise schwer zu kontrollieren sind, ist eine solche Abhängigkeit meist unerwünscht. Eine konkrete Implementation kann aber bestimmtes Verhalten zusätzlich zum standardisierten garantieren, auf welches sich Programme verlassen können. Beispielsweise unterstützt der GCC nur Plattformen mit 2er-Komplement, und unter POSIX-Systemen (Unix, Linux) ist sizeof(void*) = sizeof(void (*) (void)), und CHAR_BIT=8. Solches Verhalten vorrauszusetzen ist legitim und meistens unumgänglich, aber solche Abhängigkeiten sollten dokumentiert und wenn möglich im Code hervorgehoben werden. Programme, die von nicht durch Standard oder Implementation explizit garantiertem Verhalten abhängig sind, können jederzeit aufhören zu funktionieren - solche Abhängigkeiten sollten daher unbedingt vermieden werden.

Die Fragestellung

Als Beispiel wollen wir eine Schnittstelle annehmen, welche einzelne Bytes überträgt, wie z.B. ein klassisches SPI. Wir haben ein Datenpaket "DataPacket" als struct definiert, und wollen dieses byteweise übertragen:

#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>

void send (uint8_t byte) {
	// Hier per SPI senden. Als Dummy geben wir auf die Konsole aus.
	printf ("%" PRIx8 "\n", byte);
}

typedef struct {
	uint8_t A;
	uint16_t B;
} DataPacket;

int main (void) {
	DataPacket dp = { 0x57, 0xAA55 };
	
	send (???);
}

Die Frage ist nun, was an die send()-Funktion übergeben werden muss. Die drei klassischen Antworten werden im Folgenden erläutert.

Serialisierung durch direkten Speicher-Zugriff

Da es in C und C++ möglich ist, direkt auf den den Datenstrukturen zugrundeliegenden Speicher (RAM) byteweise zuzugreifen, ergeben sich daraus drei Möglichkeiten die auf der Uminterpretierung der Daten basieren.

Serialisierung durch Pointer-Casts

Bei dieser Variante wird ein Zeiger auf das Datenpaket in einen Byte-Zeiger umgecastet. Dieser wird dann als Zeiger auf ein Array behandelt, und die einzelnen Elemente abgesendet:

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>

void send (uint8_t byte) {
	// Hier per SPI senden. Als Dummy geben wir auf die Konsole aus.
	printf ("%" PRIx8 "\n", byte);
}

typedef struct {
	uint8_t A;
	uint16_t B;
} DataPacket;

int main (void) {
	DataPacket dp = { 0x57, 0xAA55 };
	
	// Zeiger auf dp umcasten
	uint8_t* raw = (uint8_t*) &dp;
	// Durch alle Bytes iterieren
	for (size_t i = 0; i < sizeof(dp); ++i) {
		// Byte ausgeben
		send (raw [i]);
	}
}

Serialisierung per union

Bei dieser Version wird das Datenpaket in ein union gepackt. Das union erhält außerdem ein Array aus Bytes mit der gleichen Anzahl an Bytes wie das Paket enthält. Es wird zunächst die "DataPacket" Instanz im union beschrieben, und danach das Array ausgelesen.

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>

void send (uint8_t byte) {
	// Hier per SPI senden. Als Dummy geben wir auf die Konsole aus.
	printf ("%" PRIx8 "\n", byte);
}

typedef struct {
	uint8_t A;
	uint16_t B;
} DataPacket;

typedef union {
	DataPacket p;
	uint8_t raw [sizeof(DataPacket)];
} PacketRaw;

int main (void) {
	PacketRaw dp;
	dp.p.A = 0x57;
	dp.p.B = 0xAA55;
	
	// Durch alle Bytes iterieren
	for (size_t i = 0; i < sizeof(dp); ++i) {
		// Byte ausgeben
		send (dp.raw [i]);
	}
}

Serialisierung per memcpy

Bei dieser Variante wird eine Instanz vom Datenpaket via memcpy in ein Array kopiert, und dann der Arrayinhalt ausgegeben.

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>
#include <string.h>

void send (uint8_t byte) {
	// Hier per SPI senden. Als Dummy geben wir auf die Konsole aus.
	printf ("%" PRIx8 "\n", byte);
}

typedef struct {
	uint8_t A;
	uint16_t B;
} DataPacket;

int main (void) {
	DataPacket dp = { 0x57, 0xAA55 };
	
	// Array anlegen
	uint8_t raw [sizeof (DataPacket)];
	
	// Daten aus Paket in Array kopieren
	memcpy (raw, &dp, sizeof (DataPacket));
	
	// Durch alle Bytes iterieren
	for (size_t i = 0; i < sizeof(dp); ++i) {
		// Byte ausgeben
		send (raw [i]);
	}
}

Probleme dieser Ansätze

Die gezeigten Ansätze sind weit verbreitet und werden auch im Forum oft empfohlen, ohne die Probleme zu erläutern, was daher hier nachgeholt werden soll.

  • Die Variante mit Pointer-Cast ist (meistens) erlaubt, weil als Ziel-Type "uint8_t*" angenommen wird, und "uint8_t" meistens ein Alias für einen der "char"-Typen (unsigned char, ggf. char) ist. Angenommen, es wird eine 16bit-SPI-Schnittstelle verwendet und somit nach uint16_t* gecastet (was typischerweise kein Alias für einen char-Typen ist). In diesem Fall wird die Strict-Aliasing-Rule des Standards verletzt ([1] S. 77), und der Zugriff auf den Zeiger ist undefined behaviour. Ein typisches Symptom ist, dass der Code bei aktivierter Compiler-Optimierung nicht mehr funktioniert. Bei der umgekehrten Richtung zur Deserialisierung (z.B: uint8_t* -> uint32_t*) wird die Strict-Aliasing-Regel immer verletzt.
  • Die Variante mit union ist in C erlaubt ([1] S. 83), in C++ hingegen nicht[2].
  • Die memcpy-Variante ist in C und C++ erlaubt und ist somit die "korrekteste" dieser drei Varianten.

Das Ergebnis der Umwandlung, d.h. der an send() übergebene Wert, ist jedoch implementation-defined, d.h. je nach Plattform, Compiler (-Optionen, -Version) kann hier etwas anderes herauskommen. Das Ergebnis kann "zufällig" korrekt sein, aber darauf sollte man sich nicht verlassen.

Auf typischen Implementationen beeinflussen die folgenden Faktoren das Ergebnis:

  • Alignment: Wird bei der Pointer-Cast-Variante die Strict-Aliasing-Regel verletzt, können Zeiger entstehen, dessen Ziel für den Typ nicht korrekt im Speicher ausgerichtet ist. Typische Folgen sind Programmabsturz, falsche Werte oder nur langsame Zugriffe.
  • Padding: Compiler können zwischen den einzelnen Elementen eines struct "unsichtbare" Bytes einfügen um sicherzustellen, dass die folgenden Elemente korrekt im Speicher ausgerichtet sind. Vorhandensein und Anzahl dieser sogenannten Padding-Bytes hängt von der Plattform und Compiler-Optionen ab. Somit treten in den ausgegebenen Bytes "Lücken" auf. Der GCC bietet die Möglichkeit, die Padding-Bytes für einzelne structs ganz abzuschalten, indem das "packed" -Attribut angegeben wird. Dies löst aber die anderen Probleme nicht und kann alle Zugriffe auf die Elemente verlangsamen, da der Compiler dann ggf. byteweise Zugriffe erzeugen muss. Dies lässt sich beim GCC aber wiederum über "-munaligned-access" bei ARM abschalten.
  • Byte Order: Integer mit mehr als 8 Bit werden im Speicher auf mehrere Bytes aufgeteilt. Die Reihenfolge dieser Bytes ist plattform-spezifisch - x86 und ARM speichern beispielsweise das niederwertige Byte (das mit 1er bis 128er) zuerst ("little endian"), und manche ARM- und PowerPC- Plattformen das höchstwertigste Byte zuerst ("big endian"). Zudem gibt es hybrid-Varianten wie "pdp-endian". Die Folge ist, dass die Reihenfolge der an send() übergebenen Bytes von der Plattform abhängig ist. Soll also der gleiche Code auf verschiedenen Plattformen laufen, ergeben sich unterschiedliche Ausgaben.
  • Vorzeichen-Format: Werden vorzeichenbehaftete Integer gespeichert, können negative Zahlen je nach Plattform anders dargestellt werden. Am meisten verbreitet ist das 2er-Komplement-Format, aber es gibt auch Signed-Magnitude und das 1er-Komplement.

Aus diesen Gründen sollte auf die gezeigten Ansätze verzichtet werden, da sie in jedem Fall plattformspezifisch und unportabel, und manchmal ganz fehlerhaft sind. Wenn alle anderen Möglichkeiten außer Frage sind, sollte der memcpy()-Variante der Vorzug gegeben werden.

Serialisierung über Bitshifts

Das zentrale Problem besteht darin, die einzelnen Bytes eines Integers zu ermitteln, bzw. sie wieder zusammenzusetzen. Dafür gibt es aber noch eine andere Möglichkeit: Bitweise Operatoren. Über die ">>" und "&" -Operatoren kann ein Integer in seine Bytes zerlegt werden, und per "<<" und "|" wieder zusammengesetzt. Wird dies für alle Integer im Datenpaket durchgeführt, können die einzelnen Bytes direkt in ein Array geschrieben werden. Angewendet auf das obige Beispiel, inklusive Rückrichtung (Deserialisierung), sieht das so aus:

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>

void send (uint8_t byte) {
	// Hier per SPI senden. Als Dummy geben wir auf die Konsole aus.
	printf ("%" PRIx8 "\n", byte);
}

typedef struct {
	uint8_t A;
	uint16_t B;
} DataPacket;


void serialize (uint8_t* raw, const DataPacket* dp) {
	// Einzelne Bytes müssen nicht umgewandelt werden
	raw [0] = dp->A;
	// Extrahiere niederwertiges Byte und schreibe es ins Array
	raw [1] = dp->B & 0xFF;
	// Extrahiere höherwertiges Byte und schreibe es ins Array
	raw [2] = (dp->B >> 8) & 0xFF;
}

void deserialize (const uint8_t* raw, DataPacket* dp) {
	// Einzelne Bytes müssen nicht umgewandelt werden
	dp->A = raw [0];
	// Kombiniere die nächsten zwei Bytes
	dp->B =		((uint16_t) raw[1])
			|	(((uint16_t) raw[2]) << 8);
}

int main (void) {
	DataPacket dp = { 0x57, 0xAA55 };
	
	// Array anlegen
	uint8_t raw [3];
	
	// Datenpaket serialisieren
	serialize (raw, &dp);
	
	// Durch alle Bytes iterieren
	for (size_t i = 0; i < sizeof(raw); ++i) {
		// Byte ausgeben
		send (raw [i]);
	}
	
	// Datenpaket wieder deserialisieren
	deserialize (raw, &dp);
	// Daten ausgeben
	printf ("A = 0x%" PRIx8 ", B = 0x%" PRIx16 "\n", dp.A, dp.B);
}

Der Standard garantiert für diese Variante das Verhalten; die Ausgabe ist auf allen Plattformen identisch. Einzig das Nicht-Vorhandensein der Typen uint8_t und uint16_t auf exotischen Plattformen (z.B. DSP's) kann diesen Code stören - dann gibt es aber eine Compiler-Fehlermeldung statt unbemerktem Fehlverhalten des Programms. Der gezeigte Code gibt die Daten im little-endian Format aus - werden jeweils die "1" und "2" vertauscht, wird der Code auf big-endian umgebaut. Die Byte-Reihenfolge der Plattform spielt dabei keine Rolle - die Bitshift-Operationen funktionieren immer gleich, unabhängig von der Byte-Reihenfolge.

Indem Funktionen zum Extrahieren bzw. Zusammenbauen für alle Integer-Typen und jedes eingene struct definiert werden, kann man so beliebige Datenstrukturen serialisieren. Dazu ist es erforderlich, vorzeichenbehaftete Integer korrekt zu übernehmen, denn das Vorzeichen-Format der eigenen Plattform kann vom gewünschten Binärformat abweichen. Für größere Datenstrukturen wird dies jedoch schnell sehr kompliziert und damit fehleranfällig.

C++-Bibliothek zur Serialisierung

Es gibt bereits eine Reihe von Bibliotheken um das Problem der Serialisierung korrekt zu lösen, wie Boost.Serialize, Cereal, COMMS, Protocol Buffers. Für den Einsatz auf eingebetteten Systemen gibt es jedoch oft spezielle Anforderungen:

  • Häufig müssen bestimmte fixe Binärformate umgesetzt werden um standardisierte Protokolle und Formate zu implementieren
  • Die Implementation muss Ressourcen-sparsam sein, um auf Mikrocontrollern genutzt werden zu können
  • Die Bibliothek soll zu C kompatibel sein.

Um diese Anforderungen zu erfüllen, wurde eine neue C++- Bibliothek namens µSer entwickelt. Sie verpackt den gezeigten Ansatz mit Bitshifts in ein auf template-Metaprogrammierung basierendes Framework und API, um korrekte portable Serialisierung von Daten zu vereinfachen. Die Bibliothek erfordert C++17, kann aber auch in C-Projekten genutzt werden, sofern ein C++17-Compiler zur Verfügung steht.

Unter Nutzung der Bibliothek kann das Beispiel so umgeschrieben werden:

#include <cstddef>
#include <cstdint>
#include <iostream>
#include <uSer.hh>

void send (uint8_t byte) {
	// Hier per SPI senden. Als Dummy geben wir auf die Konsole aus.
	std::cout << std::hex << int { byte } << std::endl;
}

struct DataPacket {
	USER_STRUCT(DataPacket)
	uint8_t A;
	uint16_t B;
	USER_ENUM_MEM(A,B)
};

void serialize (uint8_t (&raw) [3], const DataPacket& dp) {
	uSer::serialize (raw, dp);
}

void deserialize (const uint8_t (&raw) [3], DataPacket& dp) {
	uSer::deserialize (raw, dp);
}

int main (void) {
	DataPacket dp = { 0x57, 0xAA55 };
	
	// Array anlegen
	uint8_t raw [3];
	
	// Datenpaket serialisieren
	serialize (raw, dp);
	
	// Durch alle Bytes iterieren
	for (size_t i = 0; i < sizeof(raw); ++i) {
		// Byte ausgeben
		send (raw [i]);
	}
	
	// Datenpaket wieder deserialisieren
	deserialize (raw, dp);
	// Daten ausgeben
	std::cout << "A = 0x" << std::hex << int { dp.A } << ", B = 0x" << dp.B << std::endl;
}

Der struct-Definition müssen Makro-Aufrufe hinzugefügt werden, damit die automatische Erkennung der Elemente funktioniert. Somit wird eine einfache und korrekte, portable und ressourcenschonende Möglichkeit zur Serialisierung von Daten geboten.

Quellen