Plattformunabhängige Programmierung in C

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

von Fabian O.

Dieser Artikel nimmt am Artikelwettbewerb 2012/2013 teil.

Dieser Artikel beschreibt, was bei der Programmierung von plattformunabhängigen C-Code zu beachten ist. Ziel ist es, Softwaremodule für eingebettete Systeme auf möglichst vielen Zielplattformen einsetzen zu können.

Der Fokus liegt zunächst auf hardware- und compilerabhängigen Unterschieden der Programmiersprache C. Anhand von typischen Problemstellungen in Mikrocontrollerprogrammen wird gezeigt, in welchen Situationen sich diese Unterschiede auswirken und wie man sie plattformunabhängig lösen kann. Viele davon sind kleine Fallstricke, die sich ohne nennenswerten Mehraufwand beim Schreiben des Codes vermeiden lassen. Der Artikel soll dabei helfen, den Blick auf solche Portabiliätsprobleme zu schärfen. Für das Verständnis werden grundlegende C-Kenntnisse vorausgesetzt. Im zweiten Teil des Artikels wird gezeigt, wie sich durch Aufteilen des Programmcodes in hardwareabhängige und -unabhängige Teile plattformübergreifende Wiederverwendung in der Praxis realisieren lässt.

Allgemeines

Es gibt gute Gründe, seinen Code konsequent zu modularisieren und wiederzuverwenden: Es spart langfristig Entwicklungszeit- und kosten, vermeidet Fehler, steigert die Softwarequalität und erleichtert Änderungen und Wartung. Grundvoraussetzung für die Wiederverwendung von Code auf verschiedenen Plattformen ist zunächst, dass es einen Compiler für die Zielplattform gibt. Die Chance ist bei der Programmiersprache C mit Abstand am größten. Für praktisch jeden Prozessor, der sich in einer Hochsprache programmieren lässt, gibt es einen C-Compiler. Allerdings ist C nicht gleich C.

C-Standards: C89/C90 und C99

Heute (2013) sind im Wesentlichen zwei C-Standards relevant: Der erste genormte Standard ISO/IEC 9899:1990 aus dem Jahr 1989/90 (auch C89, C90 oder ANSI-C genannt) und der neuere C99-Standard aus dem Jahr 1999, der einige Verbesserungen, auch hinsichtlich Portabilität, mit sich bringt. Obwohl C99 mittlerweile über 13 Jahre alt ist, wird es leider immer noch nicht von allen Compilern in vollem Umfang unterstützt.[1]

Wenn größtmögliche Portabilität auf jede erdenkliche Plattform gewünscht ist, muss man sich deshalb schon vor dem Schreiben der ersten Codezeile auf die C90-Sprachmittel beschränken oder unter Umständen hohen Portierungsaufwand in Kauf nehmen. Das beginnt schon bei Dingen wie der Deklaration von lokalen Variablen, die in C90 nur direkt nach Beginn eines Blocks erlaubt ist, in C99 hingegen überall. Selbst Kommentare mit zwei einleitenden Schrägstrichen sind in C90 streng genommen unzulässig. Andere C99-Features wie die Typdefinitionen von Integern mit festen Größen lassen sich hingegen zur Not selbst nachrüsten (siehe folgende Kapitel). Eine Liste mit den Unterschieden zwischen C90 und C99 ist im Vorwort des C99-Standards und bei Wikipedia zu finden.

Wenn für eine Plattform kein C99-kompatibler Compiler vorhanden ist, lässt sich zumindest eine Teilmenge von C99 (u.a. besagte Variablendeklarationen an beliebiger Stelle) zur Not auch mit einem C++ Compiler übersetzen. Das ist insbesondere unter Microsoft Visual Studio eine Option, dessen C-Compiler den C99-Standard bis heute nicht unterstützt. Allerdings kann man auch dabei in Schwierigkeiten laufen, da C++ zwar in großen Teilen, aber nicht zu 100% "abwärtskompatibel" zu C ist. Wenn Kompatibiliät zu C++ gewünscht ist, sollte es entsprechend auch von Beginn an berücksichtigt werden: Überblick über einige Kompatibilitätsunterschiede.

Die Beispiele in diesem Artikel gehen davon aus, dass ein C99-kompatibler Compiler eingesetzt wird, da die meisten verbreiteten Compiler (allen voran der GCC) zumindest die wichtigsten Features unterstützen, unter anderem die in diesem Artikel zentralen C99-Integer-Datentypen.

Plattform

Neben dem C-Standard spielt insbesondere die Plattform, für die der Code kompiliert wird, eine gewichtige Rolle. Unter einer Plattform wird in diesem Artikel die Kombination aus Hardware (Prozessor, Mikrocontroller), Compiler-Toolchain (Compiler, Linker, Standardbibliothek) und ggf. Betriebssystem verstanden.

Die C-Standards lassen viele Details zum Verhalten des Programms bewusst offen, damit ein Compiler möglichst effizienten Maschinencode für das Zielsystem erzeugen kann. Dazu gehört unter anderem die Breite des Ganzzahl-Datentyps int. Der C-Standard fordert von einem Compiler nur, dass ein int mindestens Zahlen mit 16 Bit Breite aufnehmen kann. Wenn ein Prozessor mit breiteren Datentypen (z.B. 32 Bit) schneller rechnen kann, darf der Compiler auch 32 Bit in einem Integer speichern. Solche Unterschiede sind oft auf Eigenschaften der Zielhardware zurückzuführen, können aber in "Hosted Environments" auch abhängig vom Betriebssystem sein oder sich schlicht je nach Compiler unterscheiden.

Das führt dazu, dass sich das gleiche C-Programm auf verschiedenen Plattformen unterschiedlich verhalten kann. In der Regel ist eine Verhaltensvariante die vom Programmierer gewünschte und die andere völlig falsch. Das Problem ist, dass solche Fehler natürlich erst auffallen, wenn der Code auf eine andere Plattform portiert wird. Bei umfangreichen Code können die Fehlersuche und die zur Portierung nötigen Änderungen enormen Aufwand bedeuten. Durch sorgfältiges Beachten der vom C-Standard garantierten Eigenschaften, ohne Annahmen darüber hinaus zu treffen, lässt sich das vermeiden.

Inhalt des Artikels

Die folgenden Kapitel behandeln einige der wichtigsten Unterschiede, die sich zwischen verschiedenen Plattformen ergeben können. Einen großen Anteil des Artikels nehmen die schon angesprochenen Integer-Datentypen ein. Mit dem C99-Standard wurde eine ganze Reihe an alternativen Namen für Integer eingeführt, die dabei helfen sollen, plattformabhängige Unterschiede in den Griff zu bekommen. Allerdings ergeben sich daraus auch neue Fallstricke, etwa bei der formatierten Ein- und Ausgabe, die in den nächsten Kapiteln angesprochen werden.

Neben der Größe der Datentypen bestehen zudem fundamentale Unterschiede, wie die Daten intern im Speicher angeordnet sind. Dazu zählen die Bytereihenfolge und das Alignment von Daten in Strukturen. Das spielt besonders in eingebetteten Systemen eine bedeutende Rolle, da in dem Kontext häufig Binärdaten zwischen sehr heterogenen Systemen ausgetauscht und verarbeitet werden müssen.

Der zweite Teil des Artikels widmet sich dem Aufteilen von Programmcode in plattformabhängige und -unabhängige Teile. Denn erst durch strenge Trennung nach Abhängigkeiten lässt sich Code für eingebettete Geräte tatsächlich auf anderen Plattformen wiederverwenden. Wie sich das umsetzen lässt und wo die Grenzen dieses Ansatzes liegen, wird anhand eines ausführlichen Beispiels verdeutlicht.

Integer-Datentypen

Der erste auffällige Unterschied zwischen verschiedenen Plattformen besteht aus Sicht des C-Programmiers in den zur Verfügung stehenden Datentypen, besonders den in C allgegenwärtigen Integern (Ganzzahlen). Dieses Kapitel erklärt, wie man einen passenden, portablen Datentyp wählt und was es bei der Verwendung von C99-Integern zu beachten gibt.

Basis-Integer in C

C bietet eine überschaubare Menge an Integer-Basistypen. Deren konkrete Größe ist allerdings nicht auf allen Plattformen identisch. Der C-Standard garantiert lediglich folgende Mindestbreiten:

Integer-Basistypen in C
Typ Mindestbreite [Bit]
char 8
short int 16
int
long int 32
long long int 64

Jeden Integer-Typ gibt es in einer vorzeichenlosen (unsigned) und vorzeichenbehafteten (signed) Variante. Ein Typ ohne Vorzeichenangabe entspricht dem signed-Typ. Einzige Ausnahme ist char: Ob ein char vorzeichenbehaftet ist, hängt von der Compiler ab. Die Typen char, signed char und unsigned char sind daher als drei verschiedene Typen zu betrachten.[2] Der Typ long long int ist erst in C99 standardisiert und steht deshalb nicht auf jeder Plattform zur Verfügung.

Von der Breite des Typs und ob er vorzeichenbehaftet ist, hängt der Wertebereich ab, den man in einem Typ speichern kann. Für einen n Bit breiten unsigned-Typen beträgt der Wertebereich 0 bis 2n - 1. Der Wertebereich eines signed-Typen beträgt bei der heutzutage üblichen Zahlenrepräsentation als Zweierkomplement -2n - 1 bis 2n - 1 - 1.

Wertebereich von Integern
Breite
[Bit]
Signed Unsigned
Minimum Maximum Minimum Maximum
8 -128 127 0 255
16 -32.768 32.767 0 65.535
32 -2.147.483.648 2.147.483.647 0 4.294.967.295
64 -263 263 - 1 0 264 - 1

Auf sehr exotischen Maschinen kann die Untergrenze allerdings auch -2n - 1 + 1 betragen (Einerkomplement- bzw. Vorzeichen-und-Betrag-Repräsentation).[3] Im weiteren Verlauf des Artikels wird dieser Sonderfall nicht weiter berücksichtigt, sondern von aktuellen Prozessoren mit Zweierkomplementdarstellung ausgegangen. Die tatsächlichen Ober- und Untergrenzen sind in <limits.h> definiert. Beispiel: UINT_MAX entspricht dem maximalen Wert, der in einem unsigned int gespeichert werden kann.

Die naheliegende Vorgehensweise zur Wahl des Datentyps einer Variable ist, anhand dieser Tabelle zunächst die erforderliche Breite des Typs in Bits zu ermitteln. Anschließend nimmt man einen passenden C-Basistyp, der diese Mindestbreite garantiert. Auf den ersten Blick ist das Thema damit erledigt, denn die Mindestbreite gilt unabhängig von Hardware oder C-Compiler. Leider gibt es zwei Nachteile:

  1. Die tatsächliche Breite des Typs ist plattformabhängig. Wenn man eine feste Breite benötigt, zum Beispiel in Strukturen zum Datenaustausch mit anderen Geräten, muss man je nach Plattform einen anderen Typ wählen. Solcher Code ist also nicht portabel.
  2. Für kleine Zahlen genügen häufig 8 Bit, zum Beispiel als Schleifenzähler oder zum Speichern von Zuständen. Dafür wäre bei einem 8-Bit-Mikrocontroller ein char optimal. Auf größeren Prozessoren kann das Rechnen mit 8-Bit-Variablen hingegen langsamer als mit der nativen Registerbreite von beispielsweise 32 Bit sein, da die oberen Bits zusätzlich ausmaskiert werden müssen. Es gibt keinen Basistyp für kleine Zahlen, der auf allen Plattformen effizient verarbeitet wird.

C99-Integer mit fester Breite

Um diesen Problemen zu begegnen, wurden im C99-Standard Integer-Typen mit fester Breite definiert. Um sie zu nutzen, muss <stdint.h> inkludiert werden.

Integer mit fester Breite
Breite
[Bit]
Signed Unsigned
8 int8_t uint8_t
16 int16_t uint16_t
32 int32_t uint32_t
64 int64_t uint64_t

Diese Typen sind exakt so viele Bits breit, wie ihr Name verspricht. Sie sind Mittel der Wahl, wenn man genau diese Größe benötigt, beispielsweise in Strukturen zum Datenaustausch mit anderen Komponenten. Durch Verwendung dieser Typen verhält sich der Code auf jeder Plattform gleich.

Wie bereits angesprochen kann allerdings nicht jeder Prozessor mit jedem Typ gleich schnell rechnen. Auf einem 8-Bit-Prozessor sollte der Typ immer so kurz wie möglich sein, ein 32-Bit-Prozessor kann hingegen mit 32 Bit am besten umgehen. Wenn man konsequent für alle Variablen Festbreitentypen benutzt, läuft der Code also zwangsläufig nicht auf jeder Plattform mit bestmöglicher Performance.

Außerdem gilt es zu bedenken, dass nicht jeder dieser Typen auf jeder Plattform zur Verfügung stehen muss. Beispielsweise ist in C-Implementierungen für manche DSPs ein char mehr als 8 Bit breit. Dort gibt es ggf. uint8_t oder auch uint16_t nicht. Wenn man maximale Portabilität auf absolut jeden Prozessor anstrebt, darf man die Festbreitentypen also nicht benutzen. Auf "gewöhnlichen" Systemen vom Mikrocontroller bis zum PC sind sie aber in der Regel verfügbar, sofern der Compiler den C99-Standard unterstützt.

Falls <stdint.h> auf einer Zielplattform mangels C99-Unterstützung nicht vorhanden sein sollte, kann man die benötigten Typen selbst definieren, indem man sie mit typedef auf die vorhandenen Basistypen oder ggf. andere compilerspezifische Typen abbildet. Beispiel:

// stdint.h

typedef signed char        int8_t;
typedef signed short       int16_t;
typedef signed long        int32_t;
typedef signed long long   int64_t;

typedef unsigned char      uint8_t;
typedef unsigned short     uint16_t;
typedef unsigned long      uint32_t;
typedef unsigned long long uint64_t;

Das gilt analog für die weiteren C99-Datentypen und -Makros, die im Folgenden vorgestellt werden.

C99-Integer mit Mindestbreite

Wann immer es genügt, dass ein bestimmter Wertebereich abgedeckt ist, aber die exakte Breite nicht relevant ist, ist man mit einem flexiblen Typ, mit dem der Prozessor schnell rechnen kann, besser beraten. Das gilt besonders für lokale Variablen für Berechnungen, Schleifenzähler, Statusvariablen usw.. Für Variablen mit mindestens 16 Bit Breite ist entsprechend ein int, bei 32 Bit ein long int, und bei 64 Bit ein long long int angemessen. Es gibt allerdings keinen C-Basistyp für sehr kleine Zahlen (8 Bit), der auf jedem Prozessor effizient verarbeitet werden kann. C99 bietet dafür eine Lösung in Form von Typen mit Mindestbreite, mit denen der Prozessor möglichst schnell rechnen kann:

C99-Integer für schnelle Verarbeitung
Mindestbreite
[Bit]
Signed Unsigned Vergleichbarer C-Basistyp
8 int_fast8_t uint_fast8_t -
16 int_fast16_t uint_fast16_t (unsigned) int
32 int_fast32_t uint_fast32_t (unsigned) long int
64 int_fast64_t uint_fast64_t (unsigned) long long int

Neben den Fast-Integern bietet C99 eine weitere Reihe von Integern mit Mindestbreite namens (u)int_leastXX_t. Sie sind nicht zwangsläufig der schnellste Typ mit der jeweiligen Mindestbreite, sondern der kleinste, der auf der Plattform zur Verfügung steht. Das ist auf Plattformen relevant, auf denen ein char größer als 8 Bit ist und es dort entsprechend keinen uint8_t oder auch uint16_t gibt. Wenn man sicherstellen möchte, dass der Code auch auf solchen Prozessoren funktioniert, kann man (u)int_leastXX_t für Variablen, bei denen nicht möglichst schnelle Ausführungszeit, sondern geringer Speicherverbrauch im Vordergrund steht, verwenden. Die gleiche Wirkung bieten die C-Basistypen char, short int, long int und long long int. Ähnlich wie bei den Fast-Typen ist es Geschmackssache, ob man die Basistypen oder durchgängig C99-Typen bevorzugt.

C99-Integer mit geringem Speicherbedarf
Mindestbreite
[Bit]
Signed Unsigned Vergleichbarer C-Basistyp
8 int_least8_t uint_least8_t (signed/unsigned) char
16 int_least16_t uint_least16_t (unsigned) short int
32 int_least32_t uint_least32_t (unsigned) long int
64 int_least64_t uint_least64_t (unsigned) long long int

Die minimalen bzw. maximalen Werte, die ein C99-Integer tatsächlich aufnehmen kann, sind in <stdint.h> analog zu den Definitionen in <limits.h> definiert: UINT_FAST8_MAX enthält beispielweise den maximalen Wert eines uint_fast8_t.

Integer für besondere Zwecke

Neben den allgemeinen Integern mit garantierten Mindestbreiten bzw. festen Breiten sind in C weitere Ganzzahltypen definiert, deren Breite für einen bestimmten Anwendungszweck am besten geeignet ist:

Integer für besondere Zwecke
Headerdatei Typ Zweck
<stddef.h> size_t Kann jedes Ergebnis des sizeof()-Operators aufnehmen.
ptrdiff_t Kann jedes Ergebnis der Subtraktion zweier Zeiger aufnehmen.
wchar_t Kann jedes Multibyte-Zeichen aufnehmen.
<stdint.h> intptr_t Kann den numerischen Wert jedes Zeigers aufnehmen.
uintptr_t
intmax_t Kann den Wert jedes signed Integers aufnehmen.
uintmax_t Kann den Wert jedes unsigned Integers aufnehmen.

Diese Typen sollten gemäß ihres Anwendungszwecks eingesetzt werden. Insbesondere beim Umgang mit Zeigern als Integer ist die konsequente Verwendung von uintptr_t bzw. ptrdiff_t zu empfehlen, da die Größe eines Zeigers je nach Plattform stark unterschiedlich ausfällt (typischerweise 16, 32 oder 64 Bit). Keiner der konventionellen Integertypen hat auf jeder Plattform die passende Breite für einen Zeiger: int und long können auf 64-Bit-Plattformen zu klein sein, long long ist hingegen auf Nicht-64-Bit-Plattformen zu groß.

Der Typ size_t ist beispielsweise in Schleifen über Arrays sinnvoll, deren Größe man schlecht abschätzen kann oder möchte:

entry_t entry[] {
  // [...]
};
#define ENTRY_NUM (sizeof(entry) / sizeof(*entry))

for (size_t i = 0; i < ENTRY_NUM; i++) {
  // mach etwas mit entry[i]
}

In Integer-Berechnungen, die vom Compiler per Constant-Folding-Optimierung zur Compilezeit durchgeführt werden, eignen sich intmax_t bzw. uintmax_t, um Überläufe in Zwischenergebnissen zu vermeiden. Das Ergebnis sollte allerdings vor der Verwendung zur Laufzeit in einen kleineren Typ gecastet oder gespeichert werden, sofern nicht tatsächlich mindestens 64 Bit benötigt werden. Beispiel:

// Berechnung von 0,1337 * F_CPU
#define F_CPU_1337 ((unsigned long) ((uintmax_t) F_CPU * 1337 / 10000))

Integer-Berechnungen

Neben der Wahl des richtigen Datentyps für eine Variable gibt es ein paar wichtige Dinge bei Berechnungen und Ein- bzw. Ausgabe mit Integern zu beachten.

Promotion Rules

Zunächst gilt in C, dass sämtliche arithmetischen Integer-Operationen auf dem Typ int bzw. unsigned int durchgeführt werden, sofern nicht (mindestens) ein Operand einen größeren Wertebereich aufweist. Das wird Integer-Promotion genannt.[4] Da der C-Standard für int nur eine Mindestbreite von 16 Bit garantiert, darf man in Berechnungen nicht davon ausgehen, dass sie mit mehr als 16 Bit ausgeführt werden, sofern nicht einer der Operanden größer ist oder explizit gecastet wird.

Beispiel:

uint16_t a = 5000;
uint16_t b = 20;
uint32_t c = a * b;

Obwohl für die Variablen nur Typen mit fester Breite verwendet wurden, verhält sich der Code je nach Plattform unterschiedlich. Das liegt daran, dass die beiden uint16_t für die Multiplikation implizit in einen unsigned int gewandelt werden. Der Code ist also gleichbedeutend mit:

uint16_t a = 5000;
uint16_t b = 20;
uint32_t c = (uint32_t) ((unsigned int) a * (unsigned int) b);

Auf einer 32-Bit-Plattform, auf der ein unsigned int 32 Bit breit ist, wird die Berechnung folglich in 32 Bit durchgeführt und das Ergebnis ist 100000. Auf einer Plattform, auf der ein unsigned int nur 16 Bit umfasst, wird hingegen nur mit 16 Bit gerechnet. Das führt zu einem Überlauf, wodurch das Ergebnis 34464 ist. Daran ändert auch die Zuweisung des Ergebnisses an einen uint32_t nichts. An der Stelle ist das Kind schon in den Brunnen gefallen.

Damit auf allen Plattformen der erwartete Wert von 100000 rauskommt, muss man mindestens einen der Operanden in einen Typ mit mindestens 32 Bit Breite casten:

uint32_t c = (unsigned long) a * b;

Oder wenn man die C99-Typen bevorzugt:

uint32_t c = (uint_fast32_t) a * b;

Literale

Nicht nur Variablen, sondern auch Literale im Code, d.h. Zahlen wie 42, 0xE2F3, 0b00001101 und auch einzelne Zeichen wie 'A' haben einen Typ. Standardmäßig ist dieser Typ immer signed int, es sei denn die Zahl passt nicht in einen int. Nur dann ist sie automatisch ein long bzw. long long int.[5]

Das hat die gleichen Auswirkungen wie im oberen Beispiel:

uint32_t c = 5000 * 20;

Die Variable c enthält auf einer 32-Bit-Plattform den Wert 100000, auf einer 8/16-Bit-Plattform hingegen 34464. Klarheit kann man über einen Cast schaffen:

uint32_t c = (unsigned long) 5000 * 20;

Bei Literalen gibt es noch einen anderen Weg, nämlich die folgenden Suffixe:

Integer-Literal-Suffixe
Suffix Typ Mindestbreite
[Bit]
U unsigned 16
L long 32
UL unsigned long
LL long long 64
ULL unsigned long long

Damit kann man die Berechnung ohne Cast durchführen, indem man mindestens eines der Literale gleich als unsigned long definiert:

uint32_t c = 5000UL * 20;

Ein typischer Fehler in diesem Zusammenhang ist das Verwenden von zu kleinen Literalen in Shift-Operationen beim Maskieren bestimmter Bits. Beispiel:

uint32_t x = (1 << 20) | (1 << 21);

Dieser Code funktioniert nur auf Plattformen, auf denen ein int mindestens 32 Bit breit ist, wie erwartet. Auf 16-Bit-Plattformen wird die 1 aus dem Wertebereich hinausgeschoben. Richtig müsste der Code lauten:

uint32_t x = (1UL << 20) | (1UL << 21);

Auch hier gilt es zu beachten, dass der C-Standard nur die Mindestbreite der Typen garantiert. Das betrifft vor allem das Suffix U, das je nach Plattform zu einer 16- oder 32-Bit-Berechnung führt. Wer deshalb konsequent C99-Typen auch für Literale verwenden möchte, wird in <stdint.h> fündig. Dort sind folgende Makros definiert:

C99-Integer-Literale
Breite [Bit] Signed Unsigned
8 INT8_C(value) UINT8_C(value)
16 INT16_C(value) UINT16_C(value)
32 INT32_C(value) UINT32_C(value)
64 INT64_C(value) UINT64_C(value)
maximal INTMAX_C(value) UINTMAX_C(value)

Die obigen Beispiele würden damit folgendermaßen aussehen:

uint32_t c = UINT32_C(5000) * 20;

bzw.

uint32_t x = ((UINT32_C(1) << 20) | ((UINT32_C(1) << 21);

Etwas anders verhält es sich, wenn der C-Präprozessor eine Berechnung eines Ausdrucks durchführt, um zu überprüfen, ob ein #if- bzw. #elif-Zweig betreten werden soll. Nur in diesem speziellen Fall werden gemäß C99-Standard alle Typen als intmax_t bzw. uintmax_t angenommen, d.h. die Berechnung findet mit maximaler Breite statt.[6] Im früheren C89/C90-Standard sind die Präprozessor-Literale hingegen vom Typ long bzw. unsigned long.[7] Mit Präprozessor-Konstanten, die in 32 Bit passen, ist man dementsprechend auf der sicheren Seite.

Formatierte Ein- und Ausgabe

Wenn Integer auf textbasierten Geräten (z.B. Display, serielle Schnittstelle) ausgegeben werden sollen, müssen sie zuerst in einen String umgewandelt werden. Bei Benutzereingaben benötigt man die Umkehrung, also die Umwandlung eines Strings in einen Integer. In C steht dafür die itoa/atoi- sowie die printf/scanf-Funktionsfamilie zur Verfügung.

itoa(), utoa(), ltoa(), ultoa(), ftoa()

Die Funktionen itoa(), utoa(), ltoa(), ultoa() und ftoa() wandeln eine Zahl in einen ASCII-String um. Als Eingabe erwarten sie entsprechend ihres Namens einen int, unsigned int, long int, unsigned long int oder float. Auch hier gilt, dass man sich nur auf die Integer-Mindestbreite von 16 Bit verlassen darf. Beispiel:

char buffer[10];
utoa(100000, buffer, 10);

Je nach Breite eines unsigned int wird 100000 (bei 32 Bit) oder 34464 (bei 16 Bit) ausgeben. Wann immer der auszugebene Wert größer als 16 Bit sein kann, ist daher ltoa() bzw. ultoa() die richtige Wahl:

ultoa(100000, buffer, 10);

Zu beachten ist, dass diese Funktionen nicht vorgeschriebener Bestandteil der C-Standardbibliothek sind. Sie sind allerdings in vielen Implementierungen mit in <stdlib.h> enthalten, manchmal mit einem Unterstrich als Präfix: _itoa(), _utoa(), usw.. Wenn die Aufrufe im Code vom Namen der Funktion auf der Plattform abweichen, kann man sie mit Hilfe eines Makros oder einer Inline-Funktion "umbenennen". Wenn die Funktionen auf einer Zielplattform nicht zur Verfügung stehen, muss man ggf. auf eine eigene bzw. fremde Implementierung zurückgreifen.

atoi(), atol(), atof()

Mit atoi(), atol() und atof() lässt sich ein ASCII-String in eine Zahl umwandeln. Diese Funktionen sind im Gegensatz zu ihren Umkehrungen offizieller Bestandteil von <stdlib.h>. Wenn man Eingaben über 32767 bzw. unter -32767 erwartet, muss man analog zum obigen Beispiel auf atol() statt atoi() zurückgreifen, damit der Code auf jeder Plattform korrekt funktioniert. Es gibt keine eigenen Umwandlungsfunktionen von ASCII-String in vorzeichenlose Zahlen in der C-Standardbibliothek.

printf()

Mehr Komfort und Möglichkeiten zur formatierten Ausgabe von Text und Zahlen bieten printf() bzw. die verwandten Funktionen fprintf(), sprintf() etc. aus <stdio.h>. Die Besonderheit dieser Funktionen ist, dass sie variadisch sind, d.h. sie nehmen eine variable Anzahl an Argumenten entgegen. Das erste Argument ist ein Formatstring, der die Datentypen der folgenden Argumente und deren gewünschte Formatierung beschreibt.

Wichtig ist, dass die Datentypen der übergebenen Variablen exakt den im Formatstring angegeben Typen entsprechen müssen. Der C-Compiler führt keine Typumwandlung anhand des Formatstrings durch. Es findet allerdings eine automatische Typumwandlung gemäß den Regeln der Integer-Promotion statt (default argument promotion).[8] Das bedeutet, dass alle kleineren Typen als int zu einem (unsigned) int gewandelt werden, größere Typen bleiben hingegen unverändert.

Wenn man die C-Basistypen verwendet und im Formatstring die korrekten Typen angibt, braucht man das allerdings gar nicht im Detail wissen. Beispiel:

long  l = 100000;
int   i = 10000;
short s = 10000;
char  c = 100;
printf("%li, %i, %hi, %hhi", l, i, s, c);

Dieser Code funktioniert auf jeder Plattform. Das Gleiche gilt wegen der Integer-Promotion für:

printf("%li, %i, %i, %i", l, i, s, c);

Falsch ist hingegen:

printf("%i, %i, %i, %i", l, i, s, c);

Auf einer Plattform, auf der ein int und long int gleich breit (32 Bit) sind, fällt dieser Fehler nicht auf. Auf einer Plattform mit 16-Bit-Integerbreite ist das Verhalten allerdings undefiniert: Als erstes Argument nach dem Formatstring wird ein long mit 32 Bit übergeben, printf() erwartet gemäß Formatstring aber nur 16 Bit. Damit passen auch die Positionen der restlichen Argumente im Speicher nicht mehr zum Formatstring.

Besonders leicht passieren solche Fehler, wenn man mit den C99-Festbreiten-Integern arbeitet. Der folgende Code funktioniert auf einer 32-Bit-Plattform und sieht unverdächtig aus:

int32_t i32 = 100000;
int16_t i16 = 10000;
int8_t  i8  = 100;
printf("%i, %i, %i", i32, i16, i8);

Erst beim Portieren auf eine 8/16-Bit-Plattform kommt es zum gleichen Fehler wie vorher beschrieben. Ebenso falsch wäre aber auch:

printf("%li, %i, %i", i32, i16, i8);

Der Code ist zwar nicht mehr von der Breite des Typs int abhängig, dafür aber von der Breite von long int: Wenn ein long int 64 Bit breit ist (und ein int schmaler), wie es bei x64-Linux der Fall ist, geht der Aufruf schief.

Aus diesem Grund gibt es in <inttypes.h> Makros mit den passenden printf()-Specifiern für C99-Datentypen. Sie beginnen mit PRI, gefolgt vom Format-Specifier und dem Namen des Datentyps:

printf()-Specifier für C99-Datentypen
Typ Signed Unsigned
d (Dezimal) i (Dezimal) o (Oktal) u (Dezimal) x (Hex) X (Hex)
(u)int8_t PRId8 PRIi8 PRIo8 PRIu8 PRIx8 PRIX8
(u)int16_t PRId16 PRIi16 PRIo16 PRIu16 PRIx16 PRIX16
(u)int32_t PRId32 PRIi32 PRIo32 PRIu32 PRIx32 PRIX32
(u)int64_t PRId64 PRIi64 PRIo64 PRIu64 PRIx64 PRIX64
(u)int_fast8_t PRIdFAST8 PRIiFAST8 PRIoFAST8 PRIuFAST8 PRIxFAST8 PRIXFAST8
(u)int_fast16_t PRIdFAST16 PRIiFAST16 PRIoFAST16 PRIuFAST16 PRIxFAST16 PRIXFAST16
(u)int_fast32_t PRIdFAST32 PRIiFAST32 PRIoFAST32 PRIuFAST32 PRIxFAST32 PRIXFAST32
(u)int_fast64_t PRIdFAST64 PRIiFAST64 PRIoFAST64 PRIuFAST64 PRIxFAST64 PRIXFAST64
(u)int_least8_t PRIdLEAST8 PRIiLEAST8 PRIoLEAST8 PRIuLEAST8 PRIxLEAST8 PRIXLEAST8
(u)int_least16_t PRIdLEAST16 PRIiLEAST16 PRIoLEAST16 PRIuLEAST16 PRIxLEAST16 PRIXLEAST16
(u)int_least32_t PRIdLEAST32 PRIiLEAST32 PRIoLEAST32 PRIuLEAST32 PRIxLEAST32 PRIXLEAST32
(u)int_least64_t PRIdLEAST64 PRIiLEAST64 PRIoLEAST64 PRIuLEAST64 PRIxLEAST64 PRIXLEAST64
(u)intmax_t PRIdMAX PRIiMAX PRIoMAX PRIuMAX PRIxMAX PRIXMAX
(u)intptr_t PRIdPTR PRIiPTR PRIoPTR PRIuPTR PRIxPTR PRIXPTR

Das obige Beispiel lautet in plattformunabhängiger Schreibweise entsprechend:

int32_t i32 = 100000;
int16_t i16 = 10000;
int8_t  i8  = 100;
printf("%"PRIi32", %"PRIi16", %"PRIi8"", i32, i16, i8);

scanf()

Bei der Verwendung von scanf() bzw. den verwandten fscanf() und sscanf() ergeben sich ähnliche Probleme. scanf() nimmt ebenfalls eine variable Anzahl an Argumenten entgegen. Dabei handelt es sich um Zeiger, die angeben, wohin ein erkannter Wert geschrieben werden soll. Hier gilt es besonders aufzupassen, denn bei der Übergabe von Zeigern findet logischerweise keine Integer-Promotion wie bei printf() statt. Es ist ein Unterschied, ob man einen Zeiger auf einen long oder einen int übergibt!

Beispiel:

int32_t x;
scanf("%i", &x);

Der Code funktioniert nur auf Plattformen, auf denen ein int 32 Bit breit ist. Auf einer 16-Bit-Plattform wird scanf() hingegen nur zwei Bytes an die Adresse schreiben, an der die Variable x liegt. Je nach Speicherlayout der Plattform können das die beiden niederwertigen oder höherwertigen Bytes sein. Im ersteren Fall kommt zumindest bei kleinen, positiven Zahlen das Gleiche raus, sofern die Variable vorher mit 0 initialisiert war. In allen anderen Fällen ist der Wert von x völlig falsch.

Ebenso falsch ist folgender Code:

int32_t x;
scanf("%li", &x);

Auf einer Plattform, auf der ein long int 64 Bit breit ist (z.B. x64-Linux), würde scanf() acht Byte an die Adresse von x schreiben, an der aber nur vier Byte reserviert sind. Die anderen vier Byte überschreiben fremden Speicher!

Bei der Verwendung von scanf() zusammen mit den C99-Datentypen benötigt man dementsprechend auch die Makros aus <inttypes.h>. Sie sind nach dem gleichen Prinzip wie die printf()-Entsprechungen aufgebaut. Statt PRI beginnen sie mit SCN:

scanf()-Specifier für C99-Datentypen
Typ Signed Unsigned
d (Dezimal) i (Dezimal) o (Oktal) u (Dezimal) x (Hex)
(u)int8_t SCNd8 SCNi8 SCNo8 SCNu8 SCNx8
(u)int16_t SCNd16 SCNi16 SCNo16 SCNu16 SCNx16
(u)int32_t SCNd32 SCNi32 SCNo32 SCNu32 SCNx32
(u)int64_t SCNd64 SCNi64 SCNo64 SCNu64 SCNx64
(u)int_fast8_t SCNdFAST8 SCNiFAST8 SCNoFAST8 SCNuFAST8 SCNxFAST8
(u)int_fast16_t SCNdFAST16 SCNiFAST16 SCNoFAST16 SCNuFAST16 SCNxFAST16
(u)int_fast32_t SCNdFAST32 SCNiFAST32 SCNoFAST32 SCNuFAST32 SCNxFAST32
(u)int_fast64_t SCNdFAST64 SCNiFAST64 SCNoFAST64 SCNuFAST64 SCNxFAST64
(u)int_least8_t SCNdLEAST8 SCNiLEAST8 SCNoLEAST8 SCNuLEAST8 SCNxLEAST8
(u)int_least16_t SCNdLEAST16 SCNiLEAST16 SCNoLEAST16 SCNuLEAST16 SCNxLEAST16
(u)int_least32_t SCNdLEAST32 SCNiLEAST32 SCNoLEAST32 SCNuLEAST32 SCNxLEAST32
(u)int_least64_t SCNdLEAST64 SCNiLEAST64 SCNoLEAST64 SCNuLEAST64 SCNxLEAST64
(u)intmax_t SCNdMAX SCNiMAX SCNoMAX SCNuMAX SCNxMAX
(u)intptr_t SCNdPTR SCNiPTR SCNoPTR SCNuPTR SCNxPTR

Das obige Beispiel lautet demnach korrekt:

int32_t x;
scanf("%"SCNi32"", &x);

Fließkommazahlen

Die Fließkommatypen in C sind im Gegensatz zu den Integern relativ übersichtlich. Der Standard sieht die Typen float, double und long double vor. Sie sind immer vorzeichenbehaftet, es gibt also keine unsigned-Variante. Sie unterscheiden sich in zwei Punkten:

  • Breite des Exponenten: Je mehr Bits für den Exponenten vorgesehen sind, desto größere Beträge kann er annehmen. Das bestimmt im Wesentlichen den minimalen und maximalen Betrag, der insgesamt repräsentiert werden kann.
  • Breite der Mantisse: Je mehr Bits für die Mantisse vorgesehen sind, desto mehr Nachkommastellen können gespeichert werden. Das bestimmt die Genauigkeit bzw. Rundungsfehler in Berechnungen.

Der C-Standard schreibt keine konkrete Implementierung für die verschiedenen Typen vor. Es wird allerdings gefordert, dass ein float mindestens 6 Dezimalstellen und ein (long) double mindestens 10 Dezimalstellen ohne Verlust speichern kann. Außerdem muss 10-37 und 1037 innerhalb des Wertebereichs aller Typen liegen. Die genauen Eigenschaften sind in <float.h> definiert.[9] In der Praxis implementieren nahzu alle modernen Prozessoren den IEEE-754-Standard:

IEEE 754 Fließkommazahlen
Eigenschaft float double
Gesamtbreite 32 Bit 64 Bit
Exponentenbreite 8 Bit 11 Bit
Mantissenbreite 23 Bit 52 Bit
Dezimalstellen 7 ... 8 15 ... 16
Kleinster Betrag ˜ 5,877·10-39 ˜ 1,1125·10-308
Größter Betrag ˜ 3,403·1038 ˜ 1,798·10308

Der Typ long double kann ja nach Plattform identisch zum double sein oder einen noch größeren Wertebereich umfassen. Für Berechnungen muss entsprechend nur die benötigte Genauigkeit/Wertebereich und der Speicherbedarf berücksichtigt werden, unabhängig von der Plattform.

Übertragung zwischen Geräten

Die interne Repräsentation von Fließkommazahlen, d.h. wie die einzelnen Bits bzw. Bytes im Speicher angeordnet sind, ist im C-Standard nicht definiert. Die Typen float und double sind deshalb ohne Weiteres nicht für den Datenaustausch zwischen verschiedenen Geräten, sondern nur für interne Berechnungen geeignet.[10] Wenn Fließkommazahlen über einen Datenbus bzw. Netzwerk übertragen werden sollen, empfiehlt sich die Wandlung in Festkommazahlen (siehe Festkommaarithmetik) oder Strings.

Alternativ können Exponent und Mantisse in getrennten Integern übertragen werden. Dabei helfen die Funktionen frexp() und ldexp() aus <math.h>. In C99 gibt es sie auch in einer Variante für float-Operanden: frexpf() und ldexpf(). Das folgende Beispiel trennt einen float- bzw. double-Wert in Mantisse (int32_t) sowie Exponent (int) und setzt sie wieder zusammen:

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

int32_t split_d32(double val, int* exp)
{
  return frexp(val, exp) * (1UL << 31);
}

double merge_d32(int32_t sgn, int exp)
{
  return ldexp(sgn * (1.0 / (1UL << 31)), exp);
}

void test(double original)
{
  int     exp;
  int32_t sgn;
  double  rebuild;
  
  sgn     = split_d32(original, &exp);
  rebuild = merge_d32(sgn, exp);
  
  printf("original: %f\n", original);
  printf("rebuild:  %f\n", rebuild);
  printf("exp: %i\n", exp);
  printf("sgn: %"PRIX32"\n", sgn);
}

int main(void)
{
  float  original_float  = -98765.4321;
  double original_double = -98765.4321;

  printf("test float:\n");
  test(original_float);

  printf("\ntest double:\n");
  test(original_double);

  return 0;
}

Die Ausgabe sieht (auf einem PC) folgendermaßen aus:

test float:
original: -98765.429688
rebuild:  -98765.429688
exp: 17
sgn: 9F8CA480

test double:
original: -98765.432100
rebuild:  -98765.432068
exp: 17
sgn: 9F8CA459

Durch die Verwendung von 32 Bit als Mantissenbreite ist die Genauigkeit höher als bei einem IEEE-754-Single-Precision-Float mit nur 24 Bit. Es genügt, die unteren 8 Bit des Exponenten zu übertragen. Beim byteweisen Senden und Empfangen des int32_t muss aber in jedem Fall die Bytereihenfolge der Plattform berücksichtigt werden, siehe nächstes Kapitel. Je nach benötigter Genauigkeit können nach diesem Prinzip auch weniger oder mehr Mantissenbits (16, 24, 32, 40, ...) übertragen werden.

Fließkomma auf Mikrocontrollern

Allgemein ist zu bedenken, dass viele Mikrocontroller mangels Floating-Point-Unit (FPU) keine Fließkommazahlen in Hardware verarbeiten können. In diesem Fall muss der Compiler Fließkommaberechnungen in Software durchführen, d.h. mit Maschinenbefehlen, die auf Ganzzahlen arbeiten. Das ist vergleichsweise langsam und benötigt zusätzlichen Programmspeicher für die entsprechenden Funktionen. Es bietet auch nicht jeder Compiler überhaupt Unterstützung für Double-Precision-Berechnungen. Der avr-gcc erfüllt beispielsweise nicht den C-Standard, da er nur mit Single-Precision (32 Bit) rechnet.[11] Die Typen float und double sind auf dieser Plattform entsprechend identisch.

Sofern Code auf Plattformen ohne FPU laufen soll, empfiehlt es sich deshalb, Fließkommazahlen möglichst zu vermeiden. In vielen Fällen ist Festkommaarithmetik ein adäquater Ersatz. Unproblematisch ist hingegen, konstante Werte mit Fließkommazahlen auszurechnen und in einem Integer (als Ganz- oder Festkommazahl) zu speichern. Diese Berechnungen können zur Compilezeit durchgeführt werden, ohne dass die Fließkommabibliotheken in das fertige Programm gelinkt werden.

Byte-Reihenfolge

Neben der Breite der Datentypen ist leider auch die Anordnung der einzelnen Bytes eines Typs im Speicher je nach Plattform unterschiedlich. Solange man als C-Programmierer die Datentypen nur auf "legale" Weise umwandelt, ist das transparent: Das niederwertigste Bit (least significant bit, lsb) eines vorzeichenlosen Integers befindet sich konzeptionell ganz rechts, das höchstwertigste Bit (most significant bit, msb) ganz links. Die Wertigkeit der Bits steigt monoton von rechts nach links.

Beispiel:

uint32_t x = 3512328808;
Dezimal:                           3.512.328.808
Hexadezimal:       D1       59       E2       68
Binär:       11010001 01011001 11100010 01101000
             ^                                 ^
             msb                             lsb

Wenn man den Wert um drei Stellen nach rechts shiftet, kümmert sich der C-Compiler darum, dass der Inhalt der Variable dem erwarteten Wert entspricht:

uint32_t x = 3512328808;
x = x >> 3;
Dezimal:                             439.041.101
Hexadezimal:       1A       2B       3C       4D
Binär:       00011010 00101011 00111100 01001101
             ^                                 ^
             msb                             lsb

Type punning

Das bedeutet aber nicht zwangsläufig, dass die Bytes auch in dieser Reihenfolge im Speicher stehen. Das merkt man, wenn man das C-Typsystem umgeht, indem man auf den Speicherbereich der Variable über einen anderen Typ zugreift (Type punning). Damit bewegt man sich im Allgemeinen hart an der Grenze zu undefiniertem Verhalten. Andererseits lassen sich manche Aufgaben auf diese Weise wesentlich leichter lösen. Es gibt in C mehrere Möglichkeiten zum Type punning:

Zugriff über Zeiger mit anderem Typ:

uint32_t x = 0x1A2B3C4D;
uint8_t* p = (uint8_t*) &x;
printf("%X, %X, %X, %X\n", p[0], p[1], p[2], p[3]);

Hier wird die Speicheradresse von x (ein Zeiger auf einen uint32_t) in einen Zeiger auf einen uint8_t umgewandelt. Über diesen Zeiger können die einzelnen Bytes in der Reihenfolge gelesen werden, in der sie im Speicher stehen.

Dieses Umwandeln des Zeigers in einen Zeiger auf einen anderen Typ ist gefährlich, da es oft die C99-"Strict Aliasing"-Regel[12] bricht. Diese besagt im Wesentlichen, dass nicht über Zeiger verschiedener Typen auf das gleiche Objekt im Speicher zugegriffen werden darf. Hintergrund ist, dass der Compiler damit mehr Optimierungsmöglichkeiten bekommt, da er beispielsweise annehmen darf, dass eine Schreibzugriff über einen float* keinen Integer verändert. Eine Ausnahme sind allerdings (signed/unsigned) char* und damit auch int8_t* und uint8_t*. Diese dürfen Aliase anderer Datentypen sein, ohne dass der Compiler Zugriffe wegoptimiert. Deshalb ist dieses Beispiel auch in C99 zulässig. Ein Umwandeln in einen uint16_t* und getrennte Ausgabe der oberen und unteren 16 Bit hingegen nicht, da ein uint16_t* kein Alias zu einem uint32_t sein darf.

Union mit überlagerten Typen:

union {
  uint32_t x;
  uint8_t  y[4];
} xy;
xy.x = 0x1A2B3C4D;
printf("%X, %X, %X, %X\n", xy.y[0], xy.y[1], xy.y[2], xy.y[3]);

Diese Variante ist streng genommen nicht zulässig. Der C-Standard garantiert bei einer Union nur, dass man ein Objekt wieder über das Element wieder korrekt lesen kann, über das man es reingeschrieben hat. Und das natürlich auch nur, wenn man dazwischen keinen Schreibzugriff auf ein anderes Element der Union durchgeführt hat. Nach der Zuweisung des Werts an xy.x darf man also nur erwarten, dass man später xy.x wieder mit dem gleichen Wert auslesen kann, sofern man zwischendurch xy.y nicht verändert hat. Der Inhalt von xy.y ist gemäß C-Standard undefiniert.[13]

In der Praxis liegen die Elemente der Union allerdings an der gleichen Speicheradresse, was dem Compiler auch bekannt ist (solange man keine zusätzlichen Zeiger bildet, dann greifen wieder die Aliasing-Regeln), so dass der Code zum erwarteten Ergebnis führt. Gemäß C-Standard ist das aber wie gesagt nicht garantiert und deshalb nicht zu empfehlen.

Zugriff mit memcpy():

uint32_t x = 0x1A2B3C4D;
uint8_t  y[4];
memcpy(y, &x, 4);
printf("%X, %X, %X, %X\n", y[0], y[1], y[2], y[3]);

Hier wird ein eigenes uint8_t-Array angelegt, in das die vier Bytes des uint32_t kopiert werden. Das ist hinsichtlich Aliasing unproblematisch, da es sich ja nicht mehr um den gleichen Speicher handelt. Ein guter Compiler kann das Umkopieren per memcpy() ggf. sogar wegoptimieren. Dann ist dieser Code nicht langsamer als die ersten beiden Varianten.

Big Endian und Little Endian

Zurück zur Byte-Reihenfolge: Wenn man sich per Type punning die einzelnen Bytes des uint32_t im Speicher ausgeben lässt, wird man je nach Plattform unterschiedliche Ergebnisse bekommen. Höchstwahrscheinlich wird es eine dieser beiden Varianten sein:

1A, 2B, 3C, 4D  =>  Big Endian:    Das höchstwertigste Byte zuerst.
4D, 3C, 2B, 1A  =>  Little Endian: Das niederwertigste Byte zuerst.

Einzelheiten zu den Byte-Reihenfolgen und welche Prozessoren sie verwenden, findet man u.a. bei Wikipedia. Für die Praxis ist interessanter, in welcher Situation man überhaupt davon betroffen ist. Ohne Type punning bleibt die Byte-Reihenfolge schließlich für den Programmierer transparent. Im Prinzip kann man auch nahezu alle Aufgaben innerhalbs des C-Typsystems erledigen. Manche Dinge lassen sich mit Type punning allerdings wesentlich einfacher und wartbarer umsetzen, wie man gleich am Beispiel sehen wird. Deshalb lohnt es sich, das Thema zu verinnerlichen.

Die Byte-Reihenfolge spielt immer dann eine Rolle, wenn man mit externen Geräten wie Sensoren, Aktorik oder anderen Rechnern Binärdaten austauschen muss. Diese kommunizieren entweder über ein Datenbus wie SPI, I2C, CAN, Ethernet etc. oder eine drahtlose Verbindung. Die Daten werden typischerweise (byteweise) seriell übertragen und stehen am Ende in der gleichen Reihenfolge in einem I/O-Register bzw. einem bestimmten Speicherbereich bereit, um vom Prozessor gelesen zu werden. Wenn unter diesen Bytes mehrere aufeinanderfolgende als ein breiterer Wert interpretiert werden müssen, zum Beispiel vier Bytes als eine 32-Bit-Zahl, hängt es von der im Sender und Empfänger verwendeten Byte-Reihenfolge ab, was zu tun ist. Wenn sie übereinstimmen, können die Byte einfach als uint32_t interpretiert und in Berechnungen verwendet werden. Andernfalls müssen die Bytes zunächst in die richtige Reihenfolge gebracht werden.

Um zumindest eine Seite der Abhängigkeit zu eliminieren, hat es sich durchgesetzt, Binärzahlen über externe Verbindungen im Big-Endian-Format zu übertragen. Deshalb wird Big Endian auch als "Network byte order" bezeichnet. Der Sender muss die Daten vor der Übertragung von seiner nativen Byte-Reihenfolge in Big-Endian wandeln und der Empfänger die empfangenen Daten von Big-Endian in seine native Byte-Reihenfolge.

Beispiel:

Wir haben einen Beschleunigungssensor, der per UART regelmäßig ein ID-Byte gefolgt von zwei 16-Bit-Werten mit den Beschleunigungswerten für X- und Y- sendet. Die Z-Achse wird mit besonders hoher Genauigkeit mit 32 Bit übertragen. Die Daten werden im Big-Endian-Format gesendet und von einem AVR-Mikrocontroller empfangen, der mit Little-Endian rechnet. Der Einfachheit halber gehen wir im Beispiel davon aus, dass die Funktion uart_getchar() immer ein gültiges Zeichen liefert und keins verloren geht.

Ohne Type punning lassen sich die Daten folgendermaßen lesen und ausgeben:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} a;

uint8_t buffer[9];
for (size_t i = 0; i < sizeof(buffer); i++) {
  buffer[i] = uart_getchar();
}

a.id = buffer[0];
a.x = (buffer[1] << 8) | buffer[2];
a.y = (buffer[3] << 8) | buffer[4];
a.z = ((uint32_t) buffer[5] << 24) | ((uint32_t) buffer[6] << 16) | (buffer[7] << 8) | buffer[8];

printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, a.x, a.y, a.z);

Ein 16-Bit-Wert wird jeweils aus zwei Bytes zusammengefügt. Durch das Verschieben um 8 Stellen nach links wird das zuerst empfangene Byte zum höherwertigen Byte im resultierenden int16_t. Das danach empfangene Byte wird zum niederwertigen Byte. Analog werden die letzten vier Bytes zu einem int32_t kombiniert. Dies entspricht der Reihenfolge bzw. Wertigkeit der Bytes im Big-Endian-Format, wie sie der Sensor liefert. Beim Verschieben der oberen 16 Bit für a.z darf nicht vergessen werden, die Bytes explizit in uint32_t umzuwandeln, da standardmäßig nur mit 16 Bit gerechnet wird (Integer Promotion, siehe vorheriges Kapitel).

Das Zusammensetzen zu int16_t bzw. int32_t geschieht auf konzeptioneller Ebene innerhalb des C-Typsystems und funktioniert deshalb unabhängig von der Bytereihenfolge des Prozessors. Der Nachteil ist, dass die Position der einzelnen Felder als "magic numbers" im Code stehen. Besonders bei größeren Datenstrukturen mit 32-Bit-Typen ist das recht unübersichtlich, fehleranfällig und aufwändig zu ändern. Etwas besser änderbar ist folgende Variante:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} a;

uint8_t buffer[9];
for (size_t i = 0; i < sizeof(buffer); i++) {
  buffer[i] = uart_getchar();
}

uint8_t* p = buffer;

a.id = *p++;

a.x  = *p++ << 8;
a.x |= *p++;

a.y  = *p++ << 8;
a.y |= *p++;

a.z  = (uint32_t) *p++ << 24;
a.z |= (uint32_t) *p++ << 16;
a.z |= *p++ << 8;
a.z |= *p++;

printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, a.x, a.y, a.z);

Man geht den Empfangspuffer byteweise durch und platziert in jeder Zeile das empfangene Byte an der richtigen Stelle in der Zielvariable. Trotzdem bleibt es fehleranfällig (z.B. schreibt man schnell eine Veroderung statt Zuweisung beim ersten Byte) und ist recht länglich.

Mit Type punning könnte der Code hingegen folgendermaßen aussehen:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} a;

uint8_t* p = (uint8_t*) &a;
for (size_t i = 0; i < sizeof(a); i++) {
  p[i] = uart_getchar();
}

printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, a.x, a.y, a.z);

Hier wird ein Zeiger auf die Struktur a zu einem uint8_t-Zeiger gecastet und über den Zeiger byteweise in die Struktur geschrieben. Das ist eleganter, da der Code kürzer ist, nichts unnötig kopiert wird und keine fehlerträchtigen Zahlen mehr im Code vorkommen. Die Struktur kann bei Bedarf sehr schnell angepasst werden. Deshalb wird diese Konstruktion sehr gerne eingesetzt.

Allerdings hat der Code im Gegensatz zur ersten Version zwei Probleme:

  • Es nicht garantiert, dass die Daten ohne Padding in der Struktur stehen. Wenn der Compiler Padding-Bytes einfügt, werden zu viele Bytes vom UART gelesen und an die falschen Stellen geschrieben. Dieses Problem wird im folgenden Kapitel behandelt. Auf einem 8-Bit-Mikrocontroller wie dem AVR tritt es allerdings nicht als Fehler in Erscheinung.
  • Die Bytes liegend immer noch im Big-Endian-Format im Speicher, werden aber ohne Umwandlung als Little-Endian interpretiert. Die angezeigten Beschleunigungswerte sind entsprechend völlig falsch.

htons(), htonl(), ntohs(), ntohl()

Man braucht also Funktionen, um Zahlen von Big-Endian in die native Byte-Reihenfolge und umgekehrt umzuwandeln. Da dies im Bereich von Netzwerkprotokollen sehr häufig nötig ist, stehen dazu auf vielen Betriebssystemen die folgenden Funktionen bereit:

uint16_t htons(uint16_t x); // Host to Network Short
uint32_t htonl(uint32_t x); // Host to Network Long
uint16_t ntohs(uint16_t x); // Network to Host Short
uint32_t ntohl(uint32_t x); // Network to Host Long

Auf einem Little-Endian-System wandeln sie von Big-Endian nach Little-Endian (bzw. umgekehrt, die Operation ist identisch). Auf einem Big-Endian-System lassen sie den Wert unverändert. Unter Linux sind sie in <netinet/in.h> und unter Windows in <winsock2.h> zu finden.

Unter Verwendung dieser Funktionen kann die Ausgabe also folgendermaßen aussehen:

printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, ntohs(a.x), ntohs(a.y), ntohl(a.z));

Es ist allerdings zu empfehlen, die Werte direkt nach dem Empfang umzuwandeln und in neuen Variablen zu speichern. Zum einen spart es mehrfaches Umwandeln, wenn die Werte mehrmals benötigt werden. Zum anderen ist die Verarbeitung der Werte schneller und sicherer, wenn sie in einem Integer mit Registerbreite der Maschine und korrektem Alignment (siehe folgendes Kapitel) stehen:

uint_fast8_t id = a.id;
int  x = ntohs(a.x);
int  y = ntohs(a.y);
long z = ntohl(a.z);
printf("id=%u, X=%i, Y=%i, Z=%li\n", id, x, y, z);

Auf dem AVR-Mikrocontroller ist die Funktion ntohs() leider nicht bei den mitgelieferten Bibliotheken dabei. Man muss sie also selber schreiben. Da der AVR eine Little-Endian-Maschine ist, müssen ntohs() bzw. htons() das obere und untere Byte vertauschen. Entsprechend müssen ntohl() bzw. htonl() die Reihenfolge der vier Bytes umkehren. Eine mögliche Implementierung ist folgende:

#ifdef __BYTE_ORDER__
  #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    #define HTONS(x) ((uint16_t) (((uint16_t) (x) << 8) | ((uint16_t) (x) >> 8)))
    #define HTONL(x) ((uint32_t) (((uint32_t) HTONS(x) << 16) | HTONS((uint32_t) (x) >> 16)))
  #elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
    #define HTONS(x) ((uint16_t) (x))
    #define HTONL(x) ((uint32_t) (x))
  #else
    #error Byte order not supported!
  #endif
#else
  #error Byte order not defined!
#endif

#define NTOHS(x) HTONS(x)
#define NTOHL(x) HTONL(x)

static inline uint16_t htons(uint16_t x) {
  return HTONS(x);
}
static inline uint32_t htonl(uint32_t x) {
  return HTONL(x);
}
static inline uint16_t ntohs(uint16_t x) {
  return htons(x);
}
static inline uint32_t ntohl(uint32_t x) {
  return htonl(x);
}

Das Makro __BYTE_ORDER__ wird vom GCC-Compiler automatisch je nach Prozessortyp definiert. Die Makros HTONS(), HTONL(), NTOHS() und NTOHL() können an Stellen verwendet werden, an denen keine Funktionsaufrufe zulässig sind, beispielsweise beim Vergleich von empfangenen Werten mit einer Konstante in einem Switch-Statement:

switch (ethernet_header->type) {
  case HTONS(ETHERTYPE_IP):
    process_ip_packet();
    break;
  case HTONS(ETHERTYPE_ARP):
    process_arp_packet();
    break;
}

Auf diese Weise kann die Umkehrung der Byte-Reihenfolge bereits zu Compile-Zeit geschehen und muss nicht mehr zur Laufzeit berechnet werden. Wenn ein Funktionsaufruf möglich ist, sollte statt des Makros allerdings die entsprechende Inline-Funktion aufgerufen werden, um Nebeneffekte durch die Makros zu vermeiden.

Die obige Implementierung kann in Form der Headerdatei Byte_order.h hier runtergeladen werden. Darin befinden sich weitere Präprozessor-Überprüfungen, so dass unter Linux und Windows die vorhandenen Funktionen eingebunden werden. Die eigene Implementierung wird nur definiert, wenn noch keine andere vorhanden ist. Die Datei darf gerne um Überprüfungen auf andere Plattformen, Compiler, Betriebssysteme, Frameworks etc. erweitert werden.

Strukturen

Alignment

Im vorherigen Kapitel wurde bereits angesprochen, dass folgendes Codebeispiel je nach Plattform zu Problemen führen kann:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} a;
 
uint8_t* p = (uint8_t*) &a;
for (size_t i = 0; i < sizeof(a); i++) {
  p[i] = uart_getchar();
}
 
printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, a.x, a.y, a.z);

Neben der möglicherweise inkompatiblen Bytereihenfolge innerhalb der einzelnen int16_t- und int32_t-Elemente kann sich nämlich auch die Position der Elemente innerhalb der Struktur je nach Plattform unterscheiden. Auf einem 8-Bit-Mikrocontroller wird die Struktur wie erwartet im Speicher liegen:

Adresse (relativ) Typ Element
0x0000 uint8_t id
0x0001 int16_t x
0x0002
0x0003 int16_t y
0x0004
0x0005 int32_t z
0x0006
0x0007
0x0008

Die Struktur ist entsprechend 9 Byte groß. Beim byteweisen Beschreiben landet jedes Byte an der vorgesehenen Stelle.

Auf anderen Plattformen werden Variablen allerdings je nach Typ standardmäßig an Speicheradressen ausgerichtet (aligned), die ein Vielfaches einer bestimmten Zweierpotenz betragen. Auf einem x86-PC und vielen anderen Plattformen werden beispielsweise int16_t an Vielfachen von 2 und int32_t an Vielfachen von 4 ausgerichtet. Das hat den Hintergrund, dass diese Prozessoren Daten mit entsprechendem Alignment schneller in ein CPU-Register lesen bzw. daraus in den Speicher zurückschreiben können. Beim Zugriff auf "krumme" Speicheradressen müssen andere, langsamere Maschinenbefehle verwendet werden. Manche Prozessoren (z.B. ARM) bieten überhaupt keine Hardwareunterstützung für Unaligned-Zugriffe. In dem Fall müsste der Compiler Code generieren, der die Daten aus zwei Speicherzugriffen richtig zusammensetzt, was um ein Vielfaches langsamer als ein Aligned-Zugriff ist.

Auf einer Plattform mit oben genannten Alignment-Regeln würde die gleiche Struktur im Speicher folgendermaßen aussehen:

Adresse (relativ) Typ Element
0x0000 uint8_t id
0x0001 Padding
0x0002 int16_t x
0x0003
0x0004 int16_t y
0x0005
0x0006 Padding
0x0007
0x0008 int32_t z
0x0009
0x0010
0x0011

Die Struktur belegt statt 9 nun 12 Byte Speicher, da insgesamt 3 Padding-Bytes dazugekommen sind. Die UART-Empfangsschleife schreibt hingegen nach wie vor byteweise in die Struktur. Sie liest dementsprechend nun 12 statt 9 Bytes. Abgesehen davon, dass die letzten 3 Bytes womöglich gar nicht ankommen, stehen auch die zuvor empfangenen 9 Bytes an den falschen Stellen.

Eine Lösung des Problems wäre, die erste Variante des im vorherigen Kapitels erläuterten Codebeispiels zu verwenden:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} a;
 
uint8_t buffer[9];
for (size_t i = 0; i < sizeof(buffer); i++) {
  buffer[i] = uart_getchar();
}
 
a.id = buffer[0];
a.x = (buffer[1] << 8) | buffer[2];
a.y = (buffer[3] << 8) | buffer[4];
a.z = ((uint32_t) buffer[5] << 24) | ((uint32_t) buffer[6] << 16) | (buffer[7] << 8) | buffer[8];
 
printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, a.x, a.y, a.z);

Die Struktur belegt zwar nach wie vor 12 Byte, allerdings werden die Daten hier an die richtige Stelle geschrieben, da man explizit auf das gewünschte Element statt auf das i-te Byte der Struktur zugreift. Eine elegantere Lösung sind in solchen Fällen gepackte Strukturen.

Gepackte Strukturen

Viele Compiler bieten optional die Möglichkeit, die Elemente innerhalb einer Struktur ungeachtet der Alignment-Regeln der Plattform anzuordnen. Es werden also keine Padding-Bytes eingefügt. Solche Strukturen nennt man gepackte (packed) Strukturen. Da dies compilerspezifische Erweiterungen außerhalb des C-Standards sind, gibt es leider keine einheitliche Methode zur Deklarierung einer gepackten Struktur, die von allen C-Compilern verstanden wird. Verbreitet sind folgende zwei Varianten:

Attribute packed:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} __attribute__((packed)) a;

Pragma pack:

#pragma pack(1)
struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} a;
#pragma pack()

Beide Varianten bewirken (sofern der Compiler es unterstützt), dass die Elemente innerhalb der Struktur an Adressen mit Vielfachem 1 ausgerichtet werden, d.h. ohne Padding-Bytes. Das Attribut packed bezieht sich nur auf das Element, das damit annotiert ist. Der Befehl #pragma pack(n) gilt hingegen für alle darauf folgenden Deklarationen. Mit #pragma pack() wird das Alignment für alle folgenden Deklarationen wieder auf den Standardwert gesetzt.

Statt 1 können für n auch andere Werte (Zweierpotenzen: 2, 4, 8, 16) gewählt werden, an deren Vielfachen die Elemente ausgerichtet werden. Allerdings verliert man damit die Kompatibiliät zu Compilern, die #pragma pack() nicht unterstützen. Wenn eine bestimmte Ausrichtung benötigt wird, lässt sie sich alternativ mit von Hand eingefügten Padding-Bytes herstellen.

Wenn man sichergehen möchte, dass Code mit gepackten Strukturen auf möglichst vielen Compilern mit geringem Änderungsaufwand kompilierbar ist, kann man folgende Makros an zentraler Stelle definieren. Sollte ein Compiler eine der Varianten nicht unterstützen, kann man sie durch eine leere Definition oder ggf. eine alternative Syntax ersetzen:

#define PACKED_BEGIN_ _Pragma("pack(1)")
#define PACKED_END_   _Pragma("pack()")
#define PACKED_       __attribute__((packed))

PACKED_BEGIN_
struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} PACKED_ a;
PACKED_END_

Zeiger auf gepackte Elemente

Nicht jeder Prozessor kann auf gepackte Daten in Hardware zugreifen (z.B. ARM-Prozessoren). Auf diesen Plattformen muss der Compiler für entsprechende Zugriffe anderen Maschinencode generieren, der zwei Speicherzugriffe und das Zusammensetzen der Daten in Software durchführt. Aus Performancegründen werden Speicheradressen nicht zur Laufzeit überprüft. Deshalb muss dem Compiler zur Compilezeit bekannt sein, dass eine Adresse nicht aligned ist. Das ist nur dort der Fall, wo auf ein Element über seinen Namen in der gepackten Struktur zugegriffen wird.

Beispiel:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} __attribute__((packed)) a;
 
uint8_t* p = (uint8_t*) &a;
for (size_t i = 0; i < sizeof(a); i++) {
  p[i] = uart_getchar();
}
 
printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", a.id, a.x, a.y, a.z);

Die Elemente werden hier über ihre Namen a.id, a.x, a.y, a.z in der gepackten Struktur gelesen und die Werte an printf() übergeben. Der Compiler weiß, dass er ggf. den entsprechenden Code für die Unaligned-Zugriffe erzeugen muss. Anders sieht es im folgenden Beispiel aus:

struct accel_msg {
  uint8_t id;
  int16_t x;
  int16_t y;
  int32_t z;
} __attribute__((packed)) a;

void print_data(uint8_t* id, int16_t* x, int16_t* y, int32_t* z)
{
  printf("id=%u, X=%i, Y=%i, Z=%"PRIi32"\n", *id, *x, *y, *z);
}

void read_and_print(void)
{
  uint8_t* p = (uint8_t*) &a;
  for (size_t i = 0; i < sizeof(a); i++) {
    p[i] = uart_getchar();
  }
  print_data(&a.id, &a.x, &a.y, &a.z);
}

Hier werden Zeiger auf die gepackten Elemente gebildet, die an ungeraden Adressen im Speicher stehen. Die Zeiger werden erst in der Funktion print_data() dereferenziert. Die Funktion könnte auch in einer anderen Übersetzungseinheit (C-Datei) oder als Programmbibliothek implementiert sein. Der Compiler kann also beim Übersetzen von print_data() nicht wissen, dass die Funktion zur Laufzeit mit unausgerichteten Zeigern umgehen können muss und es daher auch nicht einbauen. Auf einem ARM-Prozessor wird dieser Zugriff entsprechend scheitern.

Manche Compiler bieten Erweiterungen, mit denen Zeiger auf Unaligned-Adressen gekennzeichnet werden können, beispielsweise das Schlüsselwort __packed beim ARM-Compiler.[14] Von der Verwendung solcher Zeiger ist allerdings dringend abzuraten. Schon aus Effizienzgründen sollten Elemente aus gepackten Strukturen so früh wie möglich in Variablen mit korrektem Alignment kopiert werden und ggf. im gleichen Zuge die Bytereihenfolge angepasst werden. Die Werte dieser Variablen bzw. Zeiger darauf können im restlichen Programm ohne Bedenken verwendet werden.

Unter Berücksichtigung aller plattformabhängigen Fallstricke könnte der Beispielcode folgendermaßen aussehen:

typedef struct {
  uint_fast8_t id;
  int  x;
  int  y;
  long z;
} accel_msg_t;

void read_accel_msg(accel_msg_t* a)
{
  PACKED_BEGIN_
  struct accel_msg_packed {
    uint8_t id;
    int16_t x;
    int16_t y;
    int32_t z;
  } PACKED_ ap;
  PACKED_END_

  uint8_t* p = (uint8_t*) &ap;
  for (size_t i = 0; i < sizeof(ap); i++) {
    p[i] = uart_getchar();
  }

  a->id = ap.id;
  a->x  = ntohs(ap.x);
  a->y  = ntohs(ap.y);
  a->z  = ntohl(ap.z);
}

void print_data(uint_fast8_t* id, int* x, int* y, long* z)
{
  printf("id=%u, X=%i, Y=%i, Z=%li\n", *id, *x, *y, *z);
}

void read_and_print(void)
{
  accel_msg_t a;
  read_accel_msg(&a);
  print_data(&a.id, &a.x, &a.y, &a.z);
}

Bitfelder

Ein Sonderfall von Strukturen sind die so genannte Bitfelder. Darin kann die genaue Breite jedes Elements in Bits vorgegeben werden. Beispiel:

struct {
  signed   int a: 10;
  signed   int b: 10;
  signed   int c:  6;
  unsigned int d:  6;
  unsigned int e:  4;
  unsigned int f:  2;
  unsigned int g:  1;
  unsigned int h:  1;
} x;

Auf die einzelnen Element kann per x.a, x.b usw. wie auf normale Integer-Elemente zugriffen werden, aber eben nur maximal die angegebene Anzahl an Bits darin gespeichert und wieder gelesen werden. Jedes Element kann mit Vorzeichenbit (signed) oder ohne (unsigned) angelegt werden. Als Typ darf laut C-Standard bool, signed int, unsigned int oder "some implementation-defined type" benutzt werden.[15] Für portablem Code sollte man sich auf die drei gemäß Standard zulässigen Typen beschränken.

Das ist im Prinzip auch schon alles, was der C-Standard einem garantiert. Es ist nicht festgelegt

  • wie groß die gesamte Struktur sein wird.
  • in welchen Basistypen die einzelnen Elemente abgelegt werden.
  • in welcher Reihenfolge die Elemente innerhalb eines Basistyps liegen.
  • in welcher Reihenfolge die Bits im Speicher liegen.
  • ob die Bits zusammenhängend oder mit einem bit- oder byteweisem Alignment im Speicher angeordnet sind.

Das alles hängt von der C-Implementierung ab.[16]

Kann man Bitfelder dann überhaupt sinnvoll und portabel nutzen? Ja, wenn man sich nur auf das verlässt, was sie garantieren: Zahlen mit einer bestimmten Anzahl an Bits abspeichern. Bitfelder eignen sich zum Beispiel gut, um binäre Zustände platzsparend abzulegen, ohne von Hand Bitmanipulation durchführen zu müssen. Sie sind aus den oben genannten Gründen hingegen völlig ungeeignet, um Datenstrukturen zu befüllen oder zu lesen, die mit anderen Geräten ausgetauscht werden sollen.

Manche Chiphersteller liefern mit ihren C-Compilern Headerdateien mit Bitfeld-Definitionen mit, die den Zugriff auf die Peripherieregister des Controllers erleichtern. Das funktioniert nur, weil diese Strukturen genau auf das Verhalten des Compilers abgestimmt sind. Als Anwendungsentwickler sollte man sich diese Technik tunlichst nicht abschauen. Neben der Anordnung der Elemente ist nämlich auch das Verhalten beim Zugriff auf volatile-Elemente in Bitfeldern im C-Standard nicht geregelt. Beim Abbilden von Bitfeldern auf Peripherieregister kann das selbst bei korrekter Position der Bits zu unerwünschten Nebenwirkungen führen, wenn benachbarte Bits unerwartet mitgelesen oder beschrieben werden.

Hardwareabhängigkeiten kapseln

Auch wenn man alle bisher genannten plattformabhängigen Unterschiede in seinem Code durch Wahl geeigneter Datentypen etc. berücksichtigt, bleiben Programme für eingebettete Systeme per Definition plattformabhängig. Es wird nicht gelingen, eine Anwendung für einen AVR-Mikrocontroller unverändert für einen PC zu kompilieren, da der PC unter anderem nicht über die gleiche Peripherie (Timer, I/O-Ports, Datenbusse, ...) verfügt. Selbst zwischen Mikrocontrollern vom gleichen Hersteller (z.B. Atmel ATmega und XMEGA) kann sich der Zugriff auf die Peripherie deutlich unterscheiden.

Wozu also das Ganze? Der "Trick" besteht darin, hardwareabhängigen und hardwareunabhängigen Code strikt zu trennen. Dabei sollte der hardwareabhängige Teil so kompakt und isoliert wie möglich sein. Wenn die Anwendung auf eine andere Hardwareplattform portiert werden soll, muss entsprechend nur der isolierte, plattformabhängige Teil des Codes ausgetauscht werden. Der plattformunabhängige Teil kann ohne Veränderungen übernommen werden, sofern die in den vorherigen Kapiteln erläuterten Feinheiten bei der Programmierung berücksichtigt wurden.

In einer Mikrocontrolleranwendung lassen sich typischerweise mindestens die folgenden vier Abhängigkeitsschichten identifizieren:

  • Anwendung: Sie verbindet alle Module miteinander und enthält die Logik, die bestimmt, was das Programm insgesamt macht. Sie kommuniziert nur über definierte Schnittstellen mit Untermodulen und enthält keinen hardwareabhängigen Code.
  • Externe Hardware: Softwaremodule, die Hardware außerhalb des Mikrocontrollers ansteuern, beispielsweise ein LCD, einen Temperatursensor oder einen Funkchip. Der Code hängt von den Eigenschaften der externen Hardware ab, jedoch nicht vom Mikrocontroller, der den Code ausführt.
  • Board: Der Code hängt vom Schaltplan des konkreten eingebetteten Systems ab. Dazu zählt insbesondere, an welchen Pins welche externe Hardware angeschlossen ist und welche dazugehörige Peripherie des Mikrocontrollers verwendet wird (z.B. Timer).
  • Mikrocontroller: Der Code steuert die Peripherie des Mikrocontrollers an, beispielsweise I/O-Ports, UART, SPI, I2C. Er hängt vom Mikrocontroller ab, der den Code ausführt.

Für bestmögliche Wiederverwendung müssen diese vier Schichten strikt voneinander getrennt werden und jede für sich austauschbar sein. Dazu müssen sie über möglichst abstrakte Schnittstellen verfügen, über die sie lose miteinander gekoppelt sind. Jede Schicht darf nur von den Schnittstellen (nicht der dahinter liegenden Implementierung!) der darunterliegenden Schicht abhängen.

Beispiel-Anwendung

Die folgende Beispielanwendung verdeutlicht, wie sich die Trennung von Code nach Abhängigkeiten in der Praxis vornehmen lässt. Die Hardware besteht aus einem Atmel XMEGA-Mikrocontroller mit einer seriellen UART-Schnittstelle und einem RFM70-Funkchip. Der Funkchip ist per SPI an den Mikrocontroller angebunden. Außerdem benötigt er zwei Output-Pins als Chip-Select und Chip-Enable. Über einen weiteren Pin kann der RFM70 einen Interrupt auslösen, wenn er ein Paket empfangen hat. Die Anwendung soll alle empfangenen Pakete auf der seriellen Schnittstelle ausgeben.

 // main.c

#include "mcu.h"
#include "rfm70.h"
#include "uart.h"
#include <stdint.h>
#include <stdio.h>

static uint8_t buffer[RFM70_MAX_PAYLOAD_LEN];

int main(void)
{
  mcu_init();
  uart_init();
  rfm70_init();
  
  stdout = &uart_stdio;
  mcu_enable_interrupts();
  
  while (1) {
    rfm70_task();
    uint_fast8_t len = rfm70_read_packet(buffer, sizeof(buffer));
    if (len > 0) {
      for (uint_fast8_t i = 0; i < len; i++) {
        putchar(buffer[i]);
      }
      putchar('\n');
    }
  }
}

Die Anwendung ruft ausschließlich Funktionen auf, die den eingebundenen Headerdateien zugeordnet werden können. Der Code enthält keine Details über den Mikrocontroller, die UART-Implementierung oder wie der RFM70-Funkchip angesprochen wird. Er ist also prinzipiell unverändert auf verschiedenen Plattformen lauffähig, sofern dort Implementierungen von mcu.h, rfm70.h und uart.h bereitstehen.

Externe Hardware

Die externe Hardware besteht im Beispiel aus dem RFM70-Funkchip. Der Code, der sich um die Ansteuerung des Chips steuert, sollte nicht davon abhängen, dass er von einem XMEGA-Mikrocontroller ausgeführt wird. Das Beispielprogramm verwendet folgende Schnittstelle:

// rfm70.h

#include <stdint.h>

#define RFM70_MAX_PAYLOAD_LEN 32

void rfm70_init(void);
void rfm70_task(void);

uint_fast8_t rfm70_read_packet(uint8_t* payload, uint_fast8_t len_max);

Sie enthält genau die Informationen, die das Programm benötigt, um das Modul zu benutzen, keine Informationen darüber hinaus (Information Hiding). Die genaue Funktionsweise der Implementierung ist für das Beispiel nebensächlich, daher ist sie hier nur in Ausschnitten abgebildet. Wichtig ist, welche Abhängigkeiten in der Implementierung bestehen:

// rfm70.c

#include "rfm70.h"
#include "rfm70_config.h"
#include "bit_macros.h"
#include <stdbool.h>
#include <stdint.h>

// Default options
#ifndef RFM70_OPTION_USE_IRQ
#define RFM70_OPTION_USE_IRQ 1
#endif

// SPI commands
#define RFM70_CMD_R_REGISTER   0x00 // Read register
#define RFM70_CMD_R_RX_PAYLOAD 0x61 // Read RX payload

// Register addresses
#define RFM70_REG_STATUS      0x07 // Status
#define RFM70_REG_FIFO_STATUS 0x17 // FIFO status

// Register bit masks
#define RFM70_STATUS_RX_DR_bp         6
#define RFM70_FIFO_STATUS_TX_EMPTY_bp 4

// Module variables
static bool packet_available = false;

#if RFM70_OPTION_USE_IRQ
  static volatile bool task_pending = true;
#endif

// Prototypes
static uint_fast8_t read_register_byte(uint_fast8_t addr);
static void read_rx_payload(uint8_t* buf, uint_fast8_t len);

// Public functions

rfm70_error_t rfm70_init(void)
{
  spi_hw_init();
  rfm70_hw_init();
  #if RFM70_OPTION_USE_IRQ
    rfm70_hw_init_irq();
  #endif

  // [...]
  
  rfm70_hw_chip_enable();
}

void rfm70_task(void)
{
  #if RFM70_OPTION_USE_IRQ
    if (!task_pending) {
      return;
    }
    task_pending = false;
  #endif
  
  // Read status registers
  uint_fast8_t status_reg = read_status_register();
  uint_fast8_t fifo_status = read_register_byte(RFM70_REG_FIFO_STATUS);

  // Packet available?
  if (bit_is_clear(fifo_status, RFM70_FIFO_STATUS_RX_EMPTY_bp)) {
    packet_available = true;
  }

  // Packet received interrupt?
  if (bit_is_set(status_reg, RFM70_STATUS_RX_DR_bp)) {
    // Clear interrupt bit
    write_register_byte(RFM70_REG_STATUS, RFM70_STATUS_RX_DR_bm);
  }

  // [...]
}

uint_fast8_t rfm70_read_packet(uint8_t* payload, uint_fast8_t len_max)
{
  if (!packet_available) {
    return 0;
  }
  packet_available = false;
  
  #if RFM70_OPTION_USE_IRQ
    // Run task again to check if there are more packets
    task_pending = true;
  #endif
  
  // Read packet
  uint_fast8_t len = read_rx_payload_width();
  if (len > len_max) {
    len = len_max;
  }
  read_rx_payload(payload, len);
  return len;
}

// Private functions

static uint_fast8_t read_register_byte(uint_fast8_t addr)
{
  uint_fast8_t val;

  rfm70_hw_chip_select();
  spi_hw_transmit_byte(RFM70_CMD_R_REGISTER | addr);
  val = spi_hw_transmit_byte(0);
  rfm70_hw_chip_unselect();

  return val;
}

static void read_rx_payload(uint8_t* buf, uint_fast8_t len)
{
  rfm70_hw_chip_select();
  spi_hw_transmit_byte(RFM70_CMD_R_RX_PAYLOAD);

  while (len > 0) {
    len--;
    *buf = spi_hw_transmit_byte(0);
    buf++;
  }
  
  rfm70_hw_chip_unselect();
}

// Interrupt service routine

#if RFM70_OPTION_USE_IRQ
RFM70_ISR()
{
  task_pending = true;
}
#endif

In rfm70.c ist sämtliche Logik enthalten, um mit SPI-Nachrichten ein Paket vom Funkchip abzurufen. All diese Informationen stammen ausschließlich aus dem Datenblatt des Funkchips (Befehle, Register, Ablauf, ...). Der Code ist also hardwareabhängig in Bezug auf den RFM70. Mit einem RFM12 würde er nicht funktionieren. Es besteht hingegen keinerlei Abhängigkeit zum XMEGA-Mikrocontroller. Sämtliche Funktionalität, die vom Mikrocontroller abhängt, ist hinter den Funktionen spi_hw_*() sowie rfm70_hw_*() verborgen. Diese Funktionen sind nicht in rfm70.c definiert.

Außerdem ist die Interrupt-Service-Routine RFM70_ISR() zu sehen. Dieser Name ist ein Makro, das ebenfalls nicht in rfm70.c definiert ist. Dadurch ist der Code unabhängig von der genauen Syntax zur Definition einer ISR, die je nach Plattform unterschiedlich aussehen kann. Als Bonus funktioniert dieser Code sogar ohne Interrupt, falls der Interrupt-Pin bei einem Board nicht mit einem externen Interrupt-Eingang des Mikrocontrollers verbunden sein sollte. Dazu muss das Makro RFM70_OPTION_USE_IRQ in der Konfiguration (siehe folgenden Abschnitt) auf 0 gesetzt werden.

Board- und mikrocontrollerspezifische Konfiguration

Die mikrocontrollerabhängigen Funktionen, die rfm70.c benötigt, werden als Inline-Funktionen über rfm70_config.h eingebunden. Das hat den Vorteil, dass der Compiler die Zugriffe auf die Peripherieregister des Mikrocontrollers ohne unnötige Funktionsaufrufe in den Maschinencode einfügen kann. Die Modularisierung ist somit mit keinen zusätzlichen Kosten (Speicher, Geschwindigkeit) verbunden.

Die boardspezifische Konfiguration sieht folgendermaßen aus:

// rfm70_config.h

// Pin definitions
// Only used in rfm70_config_xmega.h
#define RFM70_HW_CE_PORT  PORTB
#define RFM70_HW_CE_PIN   PIN1_bm

#define RFM70_HW_SSN_PORT PORTB
#define RFM70_HW_SSN_PIN  PIN3_bm

#define RFM70_HW_IRQ_PORT PORTB
#define RFM70_HW_IRQ_PIN  PIN2_bm
#define RFM70_HW_IRQ_CTRL PIN2CTRL

// Interrupt service routine of IRQ pin
#define RFM70_ISR() ISR(PORTB_INT0_vect)

// Generic ATxmega include file
#include "rfm70_config_xmega.h"

// SPI hardware
#define F_SPI 8000000
#include "spi_config_xmega_portc.h"

Die Datei enthält die Information, an welchen Pins der RFM70 angeschlossen ist und welcher externe Interrupt verwendet werden soll. Außerdem wird die SPI-Geschwindigkeit festgelegt.

Die mikrocontrollerspezifische Konfiguration befindet sich schließlich in rfm70_config_xmega.h und spi_config_xmega_portc.h:

// rfm70_config_xmega.h:

#include "bit_macros.h"
#include <avr/io.h>
#include <avr/interrupt.h>

static inline void rfm70_hw_chip_enable(void);
static inline void rfm70_hw_chip_disable(void);
static inline void rfm70_hw_chip_select(void);
static inline void rfm70_hw_chip_unselect(void);

// Initialize CE and SSN pin
static inline void rfm70_hw_init(void)
{
  // Set Pin CE (Chip Enable) to output
  RFM70_HW_CE_PORT.DIRSET = RFM70_HW_CE_PIN;
  // Set Pin SSN (Slave Select Not) to output
  RFM70_HW_SSN_PORT.DIRSET = RFM70_HW_SSN_PIN;

  rfm70_hw_chip_disable();
  rfm70_hw_chip_unselect();
}

// Initialize IRQ pin
static inline void rfm70_hw_init_irq(void)
{
  // Set Pin IRQ (Interrupt Request) to input
  RFM70_HW_IRQ_PORT.DIRCLR = RFM70_HW_IRQ_PIN;
  // Configure Pin IRQ to sense falling edges
  RFM70_HW_IRQ_PORT.RFM70_HW_IRQ_CTRL = PORT_ISC_FALLING_gc;
  // Set Pin IRQ as source for interrupt 0
  RFM70_HW_IRQ_PORT.INT0MASK = RFM70_HW_IRQ_PIN;
  // Enable interrupt 0 (low priority)
  apply_bit_mask(RFM70_HW_IRQ_PORT.INTCTRL, PORT_INT0LVL_gm, PORT_INT0LVL_LO_gc);
}

// Set Pin CE (Chip Enable) to high
static inline void rfm70_hw_chip_enable(void)
{
  RFM70_HW_CE_PORT.OUTSET = RFM70_HW_CE_PIN;
}

// Set Pin CE (Chip Enable) to low
static inline void rfm70_hw_chip_disable(void)
{
  RFM70_HW_CE_PORT.OUTCLR = RFM70_HW_CE_PIN;
}

// Set Pin SSN (Slave Select Not) to low
static inline void rfm70_hw_chip_select(void)
{
  RFM70_HW_SSN_PORT.OUTCLR = RFM70_HW_SSN_PIN;
}

// Set Pin SSN (Slave Select Not) to high
static inline void rfm70_hw_chip_unselect(void)
{
  RFM70_HW_SSN_PORT.OUTSET = RFM70_HW_SSN_PIN;
}

Erst an dieser Stelle sind die Header <avr/io.h> und <avr/interrupt.h> eingebunden, wodurch der Code abhängig vom Mikrocontroller wird. Der Umfang ist allerdings auf das absolut Nötigste beschränkt: Es werden die I/O-Register sowie der externe Interrupt initialisiert und die Inline-Funktionen zum Zugriff auf die Chip-Enable- und Chip-Select-Pins definiert. Bei Portierung auf einen anderen Mikrocontroller müssen nur diese Funktionen (sowie eine passende rfm70_config.h für das Board) neu implementiert werden.

Die Funktionen zur SPI-Übertragung sind in eine eigene Datei ausgelagert, um sie auch für andere Zwecke wiederverwenden zu können:

// spi_config_xmega_portc.h

#include "bit_macros.h"
#include <stdint.h>
#include <avr/io.h>

#ifndef F_SPI
  #error SPI frequency F_SPI not defined!
#endif

#if F_SPI != 8000000
  #error SPI frequency F_SPI not supported!
#endif

#if F_CPU != 32000000
  #error CPU frequency F_CPU not supported!
#endif

// Prototypes
static inline void    spi_hw_init(void);
static inline uint8_t spi_hw_transmit_byte(uint8_t val);

static inline void spi_hw_init(void)
{
  // Set Pin SCK (SPI Clock) to output
  PORTC.DIRSET = PIN7_bm;
  // Set Pin MOSI (Master Out Slave In) to output
  PORTC.DIRSET = PIN5_bm;
  // Set Pin MISO (Master In Slave Out) to input
  PORTC.DIRCLR = PIN6_bm;
  // Set Pin SSN (Slave Select Not) to output
  //   Note: This is not the RFM70 chip select PIN,
  //   but it must be set to output, otherwise the SPI won't work!
  PORTC.DIRSET = PIN4_bm;

  // Enable SPI master mode
  set_bit_mask(SPIC_CTRL, SPI_ENABLE_bm | SPI_MASTER_bm);

  // Set SPI clock to 8 MHz
  clear_bit_mask(SPIC_CTRL, SPI_CLK2X_bm); // No double speed
  apply_bit_mask(SPIC_CTRL, SPI_PRESCALER_gm, SPI_PRESCALER_DIV4_gc);
}

static inline uint8_t spi_hw_transmit_byte(uint8_t val)
{
  SPIC_DATA = val;
  while (bit_is_clear(SPIC_STATUS, SPI_IF_bp)) {
    // Wait
  }
  return SPIC_DATA;
}

In diesem Fall hat es sich der Autor einfach gemacht und auf weitere Universalität verzichtet. Dieser Code funktioniert nur mit einem Atmel XMEGA mit 32 MHz, Port C und 8 MHz SPI-Frequenz. Da er so kurz ist, ist das aber nicht weiter tragisch. Bei Bedarf kann er ggf. später erweitert werden oder eben per Copy & Paste eine weitere SPI-Konfiguration angelegt werden ...

Weitere Peripherie

Das Hauptprogramm benötigt außer rfm70.h auch noch die Dateien uart.h und mcu.h. Letzere abstrahiert den verwendeten Mikrocontroller:

// mcu.h

#include "mcu_config.h"

Welcher Mikrocontroller verwendet wird, steht in der boardspezifischen Konfigurationsdatei:

// mcu_config.h

#include "mcu_config_xmega.h"

Die mikrocontrollerspezifischen Funktionen stehen schließlich in mcu_config_xmega.h. Auch das ist in diesem Fall eine minimalistische Implementierung:

// mcu_config_xmega.h

#include <avr/interrupt.h>
#include <avr/io.h>

static inline void mcu_init(void)
{
  #if F_CPU == 32000000
    // Enable internal 32 MHz oscillator
    // [...]
  #else
    #error CPU frequency not supported!
  #endif

  // Enable interrupt levels
  PMIC_CTRL = PMIC_LOLVLEN_bm | PMIC_MEDLVLEN_bm | PMIC_HILVLEN_bm;
}

static inline void mcu_enable_interrupts(void)
{
  sei();
}

static inline void mcu_disable_interrupts(void)
{
  cli();
}

Die UART-Ausgabe ist ähnlich aufgebaut und beinhaltet keine Überraschungen. Erwähnenswert ist allerdings die Nutzung der Standard-I/O-Funktionen aus der C-Standardbibliothek. Auf diese Weise genügt es, an beliebiger Stelle im Programm <stdio.h> einzubinden, um per putchar, puts oder printf formatierte Ausgaben auf der seriellen Konsole erscheinen zu lassen. Diese Programmmodule sind dadurch nicht mehr von uart.h abhängig. Wie sich das auf AVR-Mikrocontrollern realisieren lässt, ist im AVR-GCC-Tutorial beschrieben. Die genaue Vorgehensweise weicht je nach Mikrocontroller bzw. Compiler-Toolchain ab. Das ist hinsichtlich Portabilität jedoch kein Problem, da die UART-Implementierung sowieso vom Mikrocontroller abhängig ist.

Organisation der Quellcode-Dateien

Da der Code sauber nach Schichten getrennt wurde, können die einzelnen Dateien nun in mehreren Anwendungen wiederverwendet werden. Die Struktur eines Gesamtprojekts könnte folgendermaßen aussehen:

Common
| board
| | board_a
| | | mcu_config.h
| | | rfm70_config.h
| | | uart_config.h
| | board_b
| | | mcu_config.h
| | | rfm70_config.h
| | | uart_config.h
| rfm70
| | rfm70.c
| | rfm70.h
| | rfm70_config_xmega.h
| tools
| | bit_macros.h
| | mcu.h
| | mcu_config_xmega.h
| | spi_config_xmega_portc.h
| | uart.c
| | uart.h
| | uart_config_xmega.h
ProjectA
| main.c
ProjectB
| main.c
ProjectC
| main.c

Der Großteil des Codes liegt in den Ordnern Common/rfm70 und Common/tools. Er kann ohne Änderungen in jedes Projekt importiert werden. Wenn ein weiterer Mikrocontroller oder ein weiteres Board unterstützt werden soll, müssen nur entsprechende Konfigurationsdateien erstellt werden. Am gemeinsam verwendeten Code sind geringe bis keine Änderungen nötig. Umgekehrt stehen gewollte Änderungen sofort in jedem Projekt zur Verfügung.

Grenzen und Kompromisse

Die vorgestellte Vorgehensweise ist natürlich kein Allheilmittel. Es ist mit dem Code etwa nicht möglich, mehr als einen RFM70-Funkchip an einem Board zu betreiben. Das rfm70-Softwaremodul ist also zwangsweise ein so genanntes Singleton: Es gibt nur eine Instanz davon. In diesem Fall dürfte das für 99% der denkbaren Anwendungen zutreffend sein. Falls man irgendwann doch in die Situation kommt, zwei oder mehr Instanzen zu benötigen, tritt der Änderungs-Worst-Case ein: Sämtliche RFM70- und SPI-Konfigurationen und sogar die Anwendungen müssen geändert werden. Man könnte beispielsweise jeder RFM70-Funktion einen Instanzzeiger übergeben, der auf eine Datenstruktur mit den instanzspezifischen Variablen zeigt und (über weitere Zeigerindirektionen) auf die für die Instanz relevanten Peripherieregister. Das kostet allerdings im Gegensatz zur Singleton-Lösung mehr Programmspeicher, RAM, Ausführungsgeschwindigkeit und erhöht die Komplexität und somit Fehleranfälligkeit, obwohl es recht wahrscheinlich nie gebraucht wird.

Wiederverwendbarkeit und Portabilität sind also immer auch ein Kompromiss aus Universalität, Resourcenverbrauch sowie Entwicklungszeit: Die Erstellung der ganzen getrennten Module kostet anfangs mehr Zeit, als alles in eine unportable C-Datei zu schreiben. Langfristig spart es hingegen Zeit, wenn neue Anwendungen existierenden und bewährten Code wiederverwenden können. Aus praktischer Erfahrung nützt es aber nichts, vermeintlich sämtliche zukünftig denkbaren Anwendungsfälle im Voraus in den Code einzubauen. Denn gemäß Murphy's Law wird man genau den Fall nicht berücksichtigen, den man später benötigt. Deshalb ist es hilfreich, wenn man mit Änderungen flexibel umgehen kann, was zu Themen wie Softwareentwicklungsprozess, Versionsverwaltung etc. führt, die dieser Artikel nicht behandelt.

Ein weiteres in diesem Artikel nicht behandeltes Themenfeld sind Betriebssysteme für eingebettete Systeme, angefangen bei sehr kompakten Systemen wie Contiki, FreeRTOS oder TinyOS bis hin zu Linux auf den großen ARM-Controllern. Diese bieten unter anderem fertige Abstraktionsschichten für häufig gebrauchte Hardware wie Timer oder die serielle Schnittstelle und sind für viele (aber nicht alle) Plattformen verfügbar. Es kann sich, besonders in größeren Anwendungen, durchaus lohnen, das Rad nicht immer neu zu erfinden, sondern auf solche bestehenden Frameworks zurückzugreifen. Dagegen spricht hingegen der Einarbeitungsaufwand und der zusätzliche Ressourcenverbrauch, der auf Mikrocontrollern nach wie vor nicht zu vernachlässigen ist. Aber auch bei Verwendung eines Betriebssystems ist man gut beraten, den Code in betriebssystemabhängige und -unabhängige Teile zu trennen, um Wiederverwendung auf unterschiedlichen Hardwareplattformen und unter verschiedenen Betriebssystemen zu ermöglichen.

Zusammenfassung

Der folgende Abschnitt fasst die wichtigsten Aussagen des Artikels zusammen, die bei der Programmierung von plattformunabhängigen C-Code beachtet werden sollten.

Integer-Datentypen:

  • Typen mit fester Breite wie uint8_t, uint16_t etc. nur zum Datenaustausch mit anderen Geräten verwenden.
  • Für lokale Variablen uint_fast8_t, uint_fast16_t etc. oder C-Basistypen verwenden.
  • In speicherverbrauchskritischen Arrays/Strukturen uint_least8_t, uint_least16_t etc. oder C-Basistypen verwenden.
  • Für spezielle Zwecke size_t, ptrdiff_t etc. verwenden.

Integer-Verarbeitung:

  • Die Größe des Typs int immer nur als das annehmen, was der C-Standard garantiert: Mindestens 16 Bit, es können aber auch mehr sein. Die Integergröße hat versteckte Auswirkungen in Berechnungen (Integer Promotion, Literale).
  • Bei Verwendung von printf() und scanf() mit C99-Typen die passenden Specifier aus <inttypes.h> verwenden. Alternativ Hilfsvariablen mit C-Basistypen verwenden.

Fließkommazahlen:

  • Nur für interne Berechnungen benutzen. Zum Austausch mit anderen Geräten die Werte in Integer oder Strings konvertieren.
  • Auf Mikrocontrollern ohne FPU Fließkommaberechnungen zur Laufzeit grundsätzlich vermeiden. Alternative: Festkommaarithmetik.

Bytereihenfolge:

  • Nicht das C-Typsystem durch Type punning (Zeiger umcasten, memcpy() oder Unions) umgehen.
  • Wenn doch: Die Funktionen htons(), htonl() etc. zum Umwandeln der Bytereihenfolge verwenden.

Alignment:

  • Keine Annahmen über Größe und Speicherlayout von Strukturen machen.
  • Wenn doch: Gepackte Strukturen verwenden (__attribute__((packed)) oder #pragma pack(1)).
  • Niemals Zeiger auf Elemente in gepackten Strukturen bilden.
  • Die Elemente aus gepackten Strukturen so früh wie möglich umkopieren und die Bytereihenfolge mit htons(), htonl() etc. anpassen.

Bitfelder:

  • Nur zum Speichern von kleinen Zahlen bzw. einzelnen Bits verwenden, nicht zum Datenaustausch mit Geräten oder Zugriff auf Peripherieregister.

Abhängigkeiten kapseln

  • Quellcode nach Abhängigkeiten in verschiedene Dateien aufteilen: Anwendung, externe Hardware, Boardkonfiguration, mikrocontrollerspezifischer Code und ggf. betriebssystemabhängiger Code.

Quellen

Siehe auch