Präprozessor-Direktiven

Über den Beitrag

Ihr alle habt schon Präprozessor-Direktiven wie #define oder #include verwendet. Die weniger Erfahrenen kennen aber vielleicht nicht wirklich den Unterschied zwischen Präprozessor-Direktiven und dem eigentlichen Programmcode. Diesen Unterschied, welche Präprozessor-Direktiven zur Verfügung stehen, wie ihr sie anwendet und welche Alternativen es gibt, soll dieser Beitrag vermitteln. Und vielleicht gibt es ja den einen oder anderen Punkt, der auch für die „älteren Hasen“ interessant ist. Folgendes behandele ich:

Was ist der Präprozessor, was sind Präprozessor-Direktiven?

Der Präprozessor ist ein Programm oder eine Komponente, die vor der eigentlichen Übersetzung oder Ausführung eines Codes eingesetzt wird. Er wird häufig in Programmiersprachen wie C und C++ (also auch Arduino-Code) verwendet, um Quellcode vor der Kompilierung zu bearbeiten. Im Wesentlichen erledigt der Präprozessor folgende Aufgaben:

  • Ersetzen von Zeichenfolgen durch Makros.
  • Einbinden von Dateien.
  • Bedingtes In- bzw. Exkludieren von Code.
  • Steuerung des Compilerverhaltens (Meldungen, Abbruch, Warnungen).

Präprozessor-Direktiven sind die Funktionen des Präprozessors. Sie sind leicht identifizierbar, da sie mit dem Hashzeichen # beginnen. Im Gegensatz zu Anweisungen des eigentlichen Programmcodes werden sie nicht mit einem Semikolon, sondern dem Zeilenende abgeschlossen.

Dateien einbinden – #include

Wie ihr wisst, bindet ihr Dateien wie etwa Bibliotheksdateien über #include ein. Dabei gibt es die Schreibweise #include <Dateiname/Pfad> und #include "Dateiname/Pfad":

  • #include <...>: Der Compiler sucht die Datei in den Standard-Include-Pfaden. Diese Pfade führen meist zu Ordnern, die „libraries“ oder „include“ heißen.
  • #include "...": Der Compiler sucht die Datei zunächst im aktuellen Verzeichnis. Wenn er sie dort nicht findet, setzt er die Suche in den Standard-Include-Pfaden fort.

Wenn ihr beispielsweise für euer Projekt eine Datei namens config.h geschrieben habt, dann ist die Wahrscheinlichkeit ziemlich hoch, dass es eine Datei mit demselben Namen irgendwo schon gibt. Dann könntet ihr das Einbinden einer falschen Datei vermeiden, indem ihr eure config.h in dem Ordner eurer „.ino“-Datei speichert und über #include "..." einbindet. 

Makros – #define

Einfache Makros ohne Variablenübergabe

Um ein Makro zu erstellen, nutzt ihr #define. In der einfachsten Form sieht das so aus:

#define identifier replacement

z. B.:

#define LED_PIN 10

Mit einfachen Makros meine ich Makros ohne weitere Variablenübergabe. In diesem Fall macht #define im Prinzip dasselbe wie die Suchen-Ersetzen-Funktion eines Textverarbeitungsprogramms. Der Identifier (offiziell: der Makroname) LED_PIN wird überall in eurem Programm durch 10 ersetzt. Dem kompilierten Programm ist nicht mehr anzusehen, ob im Quellcode die 10 oder der Identifier an den entsprechenden Stellen stand.

Zur „Rechtschreibung“: es ist allgemein üblich, die Makronamen in Großbuchstaben zu schreiben. Es funktioniert natürlich auch mit kleinen Buchstaben, nur tut ihr euch selbst und anderen einen Gefallen, wenn ihr euch an solche Konventionen haltet.

In den Tiefen der zum Arduinopaket gehörenden Bibliotheken werdet ihr viele Makronamen sehen, die mit einem oder zwei Unterstrichen beginnen und manchmal auch enden, wie „__AVR_ATmega328P__“, „_VECTORS_SIZE“ oder „__PACKED_STRUCT“. Solche Makros haben meistens eine besondere Bedeutung. Die Unterstriche stellen einen gewissen Schutz dar – solange ihr selbst darauf verzichtet.

Stolperfallen bei einfachen Makros

Das „replacement“, also der „Makro-Wert“ erhält seinen Datentyp erst durch den Kontext im Programmcode. Welchen Datentyp ihr meint und welchen Datentyp der Compiler daraus macht, sind u. U. zwei unterschiedliche Paar Schuhe. Dazu der folgende kleine Sketch:

#define A_DEF 30000
#define B_DEF 30000U
/* alternative 1: */
// static const int A_DEF = 30000;
// static const unsigned int B_DEF = 30000;
/* alternative 2: */
// static constexpr int A_DEF = 30000;
// static constexpr unsigned int B_DEF = 30000;


void setup() {
    Serial.begin(115200);
    // delay(2000); 
     
    Serial.print("A_DEF + 10000: ");
    Serial.println(A_DEF + 10000); 
    Serial.print("B_DEF + 10000: ");
    Serial.println(B_DEF + 10000); 
}

void loop() {}

Und hier die Ausgabe auf einem ATmega328P-basierten Board:

Präprozessor-Direktiven - Ausgabe out_of_range.ino
Ausgabe von out_of_range.ino

A_DEF wird offensichtlich als Signed Integer interpretiert. Da ich ein Board gewählt habe, auf dem ein Integer eine Größe von 2 Byte hat, gibt es einen Überlauf und ein entsprechend negatives Ergebnis. Zwar warnt der Compiler, aber ist leicht zu übersehen. Abhilfe schafft der numerische Literaloperator „U“ bei der Definition des Makros B_DEF, da er dem Compiler mitteilt, dass der Wert „unsigned“ sein soll. Alternativ könnt ihr „L“ für long, „UL“ für unsigned long, F für Float oder D für double anhängen. Kleinschreibung geht auch. 

Eine andere, offensichtliche Stolperfalle ist eine Namenskollision. Zwar warnt auch hier der Compiler vor einer „Redefinition“, aber die Kollision führt nicht zum Abbruch.

Alternativen zu #define (const/constexpr, enum)

const und constexpr

Gerade bei größeren Programmen ist die Verwendung von Konstanten (const) bzw. und konstanten Ausdrücken (constexpr) den einfachen #define-Direktiven vorzuziehen. Ihr drückt viel eindeutiger aus, was die Werte bedeuten. 

Zum Unterschied const vs. constexpr: Einer Konstanten const darf ihr Wert während der Laufzeit zugewiesen werden, bei einem konstanten Ausdruck constexpr passiert das zwingendermaßen während des Kompilierens.

Der Zusatz static sorgt zum einen dafür, dass eine Konstante nur einmal initialisiert wird, zum anderen kann static die Sichtbarkeit der Konstante in anderen Dateien verhindern. Die genaue Wirkung von static hängt davon ab, wo genau die Konstante definiert worden ist (z. B. innerhalb oder außerhalb von Funktionen und Klassen). Das führt hier aber zu weit.

enum

Eine Enumeration enum verwendet ihr, wenn ihr Konstanten habt, die eine Gruppe bilden, wie etwa hier die Grundfarben:

enum COLOR {
    RED   = 0xFF000,  // RGB-value
    GREEN = 0x00FF00,
    BLUE  = 0x0000FF
};

void setup() {
  Serial.begin(115200);
  COLOR myFavoriteColor = GREEN;
  if (myFavoriteColor == GREEN){
    Serial.println("My favorite color is green");
  }
}

void loop () {}

Oft geht es dabei gar nicht darum, den enum-Elementen bestimmte Werte zuzuordnen, sondern einfach nur einen Bezeichner zu haben, z. B.:

enum DAY_OF_THE_WEEK {
    MON, TUE, WED, THU, FRI, SAT, SUN
};

Makros mit Variablenübergabe

Die #define-Makros können auch Variablen entgegennehmen. Das macht sie immer noch zu einem Suchen-Ersetzen-Tool, aber in einer komplexeren Ausführung. Hier ein paar Beispiele, die zugleich ein paar Stolperfallen aufzeigen:

#define A_PLUS_B(a,b) a+b
#define A_PLUS_B_BETTER(a,b) (a+b)
#define SQUARE(a) a*a
#define SQUARE_BETTER(a) (a)*(a)
#define MAP_RANGE(a) map(a, 0, 100, 0, -500)
#define MAKE_STRING(a) #a
#define MERGE(a,b) a##b

void setup() {
    Serial.begin(115200);
    // delay(2000); 
     
    Serial.print("3 * A_PLUS_B(1,2):        ");
    Serial.println(3 * A_PLUS_B(1,2));
    
    Serial.print("3 * A_PLUS_B_BETTER(1,2): ");
    Serial.println(3 * A_PLUS_B_BETTER(1,2));

    Serial.print("SQUARE(1+2):              ");
    Serial.println(SQUARE(1+2));

    Serial.print("SQUARE_BETTER(1+2):       ");
    Serial.println(SQUARE_BETTER(1+2));

    Serial.print("MAP_RANGE(50):            ");
    Serial.println(MAP_RANGE(50));

    Serial.print("MAKE_STRING(ABC123):      ");
    Serial.println(MAKE_STRING(ABC123));
    
    String mergedString(MERGE(1,2)); // = String mergedString(12);
    Serial.print("MERGE(1,2) as String:     ");
    Serial.println(mergedString);
    
    int mergedInt = MERGE(1,2); // = int mergedInt = 12;
    Serial.print("MERGE(1,2) as Integer:    ");
    Serial.println(mergedInt);  
}

void loop() {}
 

Hier die Ausgabe:

Präprozessor-Direktiven - Ausgabe macros_with_variables.ino
Ausgabe von macros_with_variables.ino

Und hier ein paar Erläuterungen:

  • A_PLUS_B(1,2) wird durch 1+2 ersetzt.
    • Stolperfalle: 3*1+2 ist 5!
  • A_PLUS_B_BETTER(1,2) wird durch (1+2) ersetzt.
    • Also: 3*(1+2) = 9.
  • SQUARE(1+2) wird durch 1+2*1+2 ersetzt → Stolperfalle!
  • SQUARE_BETTER(1+2) wird durch (1+2)*(1+2) ersetzt und liefert das wahrscheinlich erwartete Ergebnis.
  • MAP_RANGE(50) wird durch map(50, 0, 100, 0, -500); ersetzt.
  • MAKE_STRING(ABC123) wird durch die Zeichenkette ABC123 ersetzt. 
    • Das Hashzeichen # vor dem a ist hier ein Operator, der Präprozessor anweist, a durch eine Zeichenkette zu ersetzen.
  • MERGE(1,2) wird durch 12 ersetzt.
    • Der Operator ## verkettet a und b.
    • Die Bedeutung von 12 ergibt sich erst aus dem Kontext (hier: Integer oder String)

Makros vs. Funktionen

Im letzten Beispiel haben wir dem Makro A_PLUS_B Integerwerte als Parameter übergeben. Das Coole ist, dass das Makro auch mit Float-Variablen oder Strings funktioniert. Und das Ganze in einer einzeiligen Definition! Als Funktion wäre das etwas aufwendiger. Entweder ihr löst das durch Überladung:

void setup() {
    Serial.begin(115200);

    int aInt = 1;
    int bInt = 2; 
    int cInt = aPlusB(aInt, bInt);
    Serial.print("aPlusB(aInt,bInt) = ");
    Serial.println(cInt);

    String aStr = "1";
    String bStr = "2";
    String cStr = aPlusB(aStr, bStr); 
    Serial.print("aPlusB(aStr,bStr) = ");
    Serial.println(cStr);    
}
void loop() {}

int aPlusB(int a, int b) {
    return a+b;
}

String aPlusB(String a, String b) {
    return a+b;
}

… oder ihr arbeitet mit Templates:

void setup() {
    Serial.begin(115200);

    int aInt = 1;
    int bInt = 2; 
    int cInt = aPlusB<int>(aInt, bInt);
    Serial.print("aPlusB(aInt,bInt) = ");
    Serial.println(cInt);

    String aStr = "1";
    String bStr = "2";
    String cStr = aPlusB<String>(aStr, bStr); 
    Serial.print("aPlusB(aStr, bStr) = ");
    Serial.println(cStr);    
}
void loop() {}

template <typename T>
T aPlusB(T a, T b) {    
    return a+b;
}

Beide Varianten liefern die folgende Ausgabe:

Ausgabe on overload.ino und template.ino
Ausgabe von overload.ino und template.ino

Überladung und Templates bedeuten mehr Schreibarbeit, aber diese Varianten sind einfach sicherer. Folgendes wird beispielsweise vom Makro A_PLUS_B (bzw. vom Compiler) klaglos akzeptiert:

String a = "1";
int b = 2;
Serial.println(A_PLUS_B(a, b));

Vielleicht wolltet ihr das ja – vielleicht war es aber auch ein Versehen.

„Arduino Makros“

Das Arduino-Programm-Paket verwendet Makros in enormem Umfang. Zum Teil dienen sie dazu, den Programm-Code verständlicher zu gestalten. So bedeuten beispielsweise LOW und INPUT schlicht 0, OUTPUT und HIGH sind 1.

Dann gibt es eine Reihe funktionalen Makros. Ein einfaches Beispiel ist das Makro „min“, das euch den kleineren zweier übergebener Werte liefert:

#define min(a,b) ((a)<(b)?(a):(b))

Zum Teil sind die Makros aber auch recht komplex und greifen auf weitere Makros zurück. Ein Beispiel dafür ist das „F“-Makro, das ihr aus Serial.print(F("......")); kennt:

#define F(string_literal) (reinterpret_cast<const __FlashStringHelper *>(PSTR(string_literal)))

Es verwendet das PSTR-Makro:

#define PSTR(s) ((const PROGMEM char *)(s))

Vordefinierte Makros

Vordefinierte Makros sind Bestandteil des Präprozessors und nicht über ein #define definiert. Die gängigsten vordefinierten Makros findet ihr im folgenden Sketch:

void setup() {
    Serial.begin(115200);
    Serial.print("Date: ");
    Serial.println(__DATE__); // Compiling date
    Serial.print("Time: ");
    Serial.println(__TIME__); // Compiling time
    Serial.print("File: ");
    Serial.println(__FILE__); // Compiled file
    Serial.print("Line: ");
    Serial.println(__LINE__); // Current line
    Serial.print("C++ : ");
    Serial.println(__cplusplus); // C++ Version (201103 = ISO C++ 2011)
}

void loop() {}

Hier die Ausgabe:

Vordefinierte Präprozessor-Direktiven - Ausgabe predef_macros.ino
Ausgabe predef_macros.ino

Bedingtes Einbinden von Code – #ifdef, #ifndef & Co

Ihr könnt #define nutzen, um Teile des Quellcodes einzubinden oder durch den Compiler ignorieren zu lassen.

#ifdef MACRO_NAME prüft, ob MACRO_NAME definiert wurde oder nicht. Ist MACRO_NAME definiert worden, so werden die darauffolgenden Codezeilen eingebunden. Mit #ifndef verhält es sich genau umgekehrt. 

Im einfachsten Fall steht im weiteren Verlauf des Quellcodes ein #endif, das die bedingte Einbindung beendet. Mit #else oder #elif ist aber auch eine Verzweigung möglich. Also im Prinzip genau, wie ihr es von if, else und else if her kennt. Ebenso sind Verschachtelungen möglich. Das kann aber auch schnell sehr unübersichtlich werden.

Gleichbedeutend mit #ifdef MACRO_NAME ist #if defined(MACRO_NAME). Diese Schreibweise kommt bei logischen Verknüpfungen zum Tragen, wie beispielsweise #if defined(NAME_1) && defined(NAME_2). Mit #if könnt ihr aber auch andere logische Bedingungen prüfen, wie #if A>B oder #if !(A==B).

Am Ende muss jedes #if, #ifdef oder #ifndef durch ein #endif abgeschlossen sein. 

Ein #undef MACRO_NAME tut das, was man vermuten würde: Es beendet die Definition von MACRO_NAME.

Hier ein Beispielsketch:

#define YIN
#define YANG

void setup() {
    Serial.begin(115200);
    // delay(2000); 

#ifdef YIN  // or: #if defined(YIN)
    Serial.println("YIN is defined");
#elif defined(YANG)
    Serial.println("YIN is not defined, but YANG");
#endif

#ifdef YANG
    Serial.println("YANG is defined");
#else 
    Serial.println("YANG is not defined, YIN might be defined");
#endif

#ifndef YANG
    Serial.println("YANG is not defined");
#endif

#if defined(YIN) || defined(YANG)
    Serial.println("YIN and / or YANG are defined");
#endif

#if defined(YIN) && defined(YANG)
    Serial.println("YIN and YANG are defined");
#elif defined(YIN) 
    Serial.println("Only YIN is defined");
#endif

#undef YANG
#ifndef YANG
    Serial.println("YANG is not defined (anymore?)");
#endif

}

void loop() {}

 

Der Sketch erzeugt die folgende Ausgabe:

Präprozessor-Direktiven - Ausgabe conditional_inclusion.ino
Ausgabe von conditional_inclusion.ino

Wenn ihr wollt, könnt ihr nun #define YIN und / oder #define YANG auskommentieren und sehen, wie sich die Ausgabe verändert.

Anwendungsbeispiel 1: Vermeidung von doppeltem Einlesen

Als konkretes Beispiel schauen wir uns die SPI-Bibliotheksdatei SPI.h des Arduino Renesas Paketes an (hier findet ihr sie). Viele Bibliotheken von SPI-basierten Bauteilen binden SPI.h automatisch ein. Verwendet ihr mehrere Bauteile, dann könnte SPI.h mehrfach eingelesen werden. Das wird verhindert, indem der gesamte Code in SPI.h mit der folgenden Konstruktion umfasst wird:

#ifndef _SPI_H_INCLUDED
#define _SPI_H_INCLUDED
...... Code ........
#endif

_SPI_H_INCLUDED kann so nur einmal definiert und der Code nur einmal gelesen werden.

Anwendungsbeispiel 2: Debugging

In der Entwicklungsphase eines Projektes kann es sinnvoll sein, Zwischenwerte, Sensoreinstellungen, Statusdaten oder Ähnliches auf dem seriellen Monitor ausgeben zu lassen. Andererseits sind gerade Serial.print()-Anweisungen sowohl langsam als auch Speicherfresser. In solchen Fällen empfiehlt es sich, diesen Code mittels bedingter Einbindung „ein“- oder „auszuschalten“.  Es gibt viele Bibliotheken, die eine solche Debug-Option anbieten. 

Allerdings gibt es auch hier wieder eine Stolperfalle. Schaut euch das folgende schlichte Beispiel an. Der Hauptsketch verwendet die Funktion sumUp(), die in sum.h und sum.cpp definiert ist. sumUp() addiert einfach nur drei Zahlen. Wenn DEBUG definiert ist, sollen die Zwischenschritte ausgegeben werden, ansonsten nur das Endergebnis. 

#define DEBUG
#include "sum.h"
#define SUMMAND_1 17
#define SUMMAND_2 4
#define SUMMAND_3 42

void setup() {
    Serial.begin(115200);
    int value = sumUp(SUMMAND_1, SUMMAND_2, SUMMAND_3); 
    Serial.print("Sum: ");
    Serial.println(value);
}

void loop() {}
// #define DEBUG
// #include "sum_config.h"
#include "Arduino.h"
#ifndef _SUM_UP
#define _SUM_UP
int sumUp(int, int, int);
#endif
#include "sum.h"
int sumUp(int s1, int s2, int s3){
    int result = 0;
    result += s1;
#ifdef DEBUG
    Serial.print("Interim sum 1: ");
    Serial.println(result);
#endif
    result += s2;
#ifdef DEBUG
    Serial.print("Interim sum 2: ");
    Serial.println(result);
#endif
    result += s3;
    return result;
}
#ifndef _SUM_CONF
#define _SUM_CONF
// uncomment the following line to activate DEBUG
// #define DEBUG
#endif

Wenn ihr den Sketch so wie er ist ausführt, erwartet ihr vielleicht die ausführliche Ausgabe (unten links), da DEBUG im Hauptsketch definiert ist. Ihr bekommt aber die rechte Ausgabe.

Ausgabe von sum_main.ino mit und ohne DEBUG
Ausgabe von sum_main.ino mit und ohne DEBUG

Das Problem ist, dass der Präprozessor sich nicht an die Reihenfolge hält, sondern erst die #include Direktiven ausführt. Ihr könnt das Problem lösen, indem ihr #define DEBUG in sumUp.h entkommentiert. Um es dem Nutzer zu erleichtern und das Risiko zu minimieren, dass er aus Versehen etwas Falsches auskommentiert, kommen in solchen Fällen gerne Konfigurationsdateien zum Einsatz. Entkommentiert deshalb alternativ #include "sum_config.h" in sum.h und #define DEBUG in sum_config.h.

Anwendungsbeispiel 3: MCU-spezifischer Code

Das Arduino-Ökosystem versucht, Code möglichst universell, d. h. unabhängig vom verwendeten Board bzw. Mikrocontroller (MCU) zu gestalten. Dennoch gibt es doch Dinge, die nicht überall (gleich) funktionieren, wie beispielsweise SoftwareSerial. Als konkretes Beispiel schauen wir uns einen Beispielsketch der DFRobotDFPlayerMini-Bibliothek an (hier geht’s zum vollständigen Sketch).

#if (defined(ARDUINO_AVR_UNO) || defined(ESP8266))   // Using a soft serial port
#include <SoftwareSerial.h>
SoftwareSerial softSerial(/*rx =*/4, /*tx =*/5);
#define FPSerial softSerial
#else
#define FPSerial Serial1
#endif

Nur wenn das Board ein AVR-basierter Arduino UNO ( z. B. R3) oder ein ESP8266-basiertes Board ist, wird SoftwareSerial eingebunden und das Objekt softSerial erzeugt, ansonsten wird Serial1 (HardwareSerial) verwendet. softSerial bzw. Serial1 werden in FPSerial umbenannt, sodass man im weiteren Verlauf nicht zwischen Hard- und SoftwareSerial unterschieden werden muss.

Das Beispiel zeigt aber auch, dass es Stolpersteine gibt. So funktioniert der Sketch beispielsweise nicht mit einem Arduino Nano (Classic) oder einem Arduino Pro Mini. Sie sind kein Arduino UNO, sie sind nicht ESP8266 basiert, besitzen kein Serial1 und fallen so durch das Raster. Hier hätte ich eher __AVR_ATmega328P__ oder ARDUINO_ARCH_AVR als ARDUINO_AVR_UNO gewählt.

Wo finde ich die Board- bzw. MCU-#defines?

Leider gibt es nicht die eine große Tabelle, in der man alle Definitionen für die verschiedenen Architekturen, Boards und Mikrocontroller findet (zumindest habe ich keine gefunden). Man kann sich aber anders helfen:

Stellt das Board eurer Wahl in der Arduino IDE ein und kompiliert einen beliebigen Sketch. Dann klickt ihr auf das Fenster mit den Compilermeldungen und klickt STRG/f. In das Suchfenster tragt ihr „-D“ ein. Damit werden die Stellen markiert, an denen Definitionen stehen (deren Ursprung ist: board.txt und platform.txt). Man muss ein wenig hin- und herscrollen, um die markierten Stellen zu finden.

Für ein WEMOS D1 Mini Board habe ich die Definitionen in der folgenden Zeile gefunden (verkürzte Ausgabe):

C:\Users\Ewald\AppData\Local\Arduino15\packages\esp8266\……..-DESP8266…..-DARDUINO_ESP8266_WEMOS_D1MINI -DARDUINO_ARCH_ESP8266 …..

So sah das auf dem Bildschirm aus:

Ausschnitt Compilermeldungen für WEMOS D1 mini Board

Was man auf diese Weise nicht findet, sind beispielsweise die Definitionen für die verschiedenen AVR-Mikrocontroller. Da hilft die Datei io.h aus den Compilerdateien weiter. Wo genau die bei euch liegt, hängt von eurer Installation ab. Schaut dort, wo eure „packages“ liegen. Bei mir sieht der Pfad so aus:

C:\Users\Ewald\AppData\Local\Arduino15\packages\arduino\tools\avr-gcc\7.3.0-atmel3.6.1-arduino5\avr\include\avr\io.h

Hier noch eine Zusammenstellung von Definitionen für verschiedene Boards:

Präprozessordiektiven: #defines für verschiedene Boards
Beispiele für Board- und Mikrocontroller-Definitionen

Warnungen und Fehler – #warning, #error

Mit der Präprozessor-Direktive #warning gebt ihr – Überraschung! – eine Warnung aus. Das werdet ihr im Normalfall an eine Bedingung knüpfen. Die Warnung hat keine weiteren Folgen.

Die Präprozessor-Direktive #error macht dasselbe wie #warning, führt aber zum Compilerabbruch. Hier ein schlichtes Beispiel:

#define ARRAY_SIZE 10
#define NUMBER_OF_ARRAYS 42
#if ARRAY_SIZE * NUMBER_OF_ARRAYS > 400
#warning You might run out of memory!
// #error You will run out of memory!
#endif

void setup() {}

void loop() {}

Das sind die resultierenden Ausgaben:

Beispielausgabe Präprozessor-Direktive #warning
#warning Ausgabe
Beispielausgabe Präprozessor-Direktive #error
#error Ausgabe

Zeilenzähler und Dateiname manipulieren – #line

Der Präprozessor „weiß“, in welcher Datei und in welcher Programmzeile er sich gerade befindet. Mit #line line_number "file_name" könnt ihr beides ändern. Ich weiß allerdings nicht, warum man das tun sollte (außer um andere zu ärgern!).

Wie auch immer, hier ein Beispiel:

void setup() {
//#line 33 "blabla.h"    
    int i = 42!;
}

void loop() {}

Das ist die normale Ausgabe:

„Normale“ Fehlermeldung

Und hier die manipulierte Version:

Fehlermeldung, manipuliert mit #line

Das Multifunktions-Tool – #pragma

Die #pragma-Direktiven sind ausgesprochen vielseitig, aber nicht standardisiert. D. h. jede Compiler-Implementierung kann im Prinzip eigene #pragma-Direktiven definieren. Das bedeutet zum einen, dass ich keine vollständige Übersicht geben kann, zum anderen bedeutet es, dass ich nicht garantieren kann, dass alle hier vorgestellten Direktiven mit wirklich jedem Compiler kompatibel sind. Mit den gängigen Compilern der Arduino IDE für die gängigen Boards (z.B. AVR, Renesas, ESP32, ESP8266, ARM) sollte es funktionieren. 

#pragma once

Ein einfaches #pragma once am Anfang einer Datei stellt sicher, dass diese nur einmal eingelesen wird. Das ist schon wesentlich einfacher als die herkömmliche Methode, den gesamten Dateiinhalt mit einer #ifndef#defendif Konstruktion zu umschließen.

#pragma poison

Mit #pragma poison könnt ihr Bezeichner, Funktionen oder Zeichenketten als „Gift“ kennzeichnen. Der Compiler bricht ab, wenn er auf diese trifft. Hier ein Beispiel:

#pragma GCC poison blablabla

void setup() {
    Serial.begin(115200);
    String blablabla = "Nice to see you";
}

void loop() {}
#pragma Präprozessor-Direktiven - Ausgabe pragma_poison.ino
Ausgabe von pragma_poison.ino

Das funktioniert auch mit begin oder „Nice to see you“, nicht aber einfach nur mit Nice.

#pragma message / warning / error

Die Direktive #pragma message "xxxxxxx" gibt beim Compilieren die Nachricht „xxxxxxx“ aus. Der Compilierungsvorgang wird nicht abgebrochen.  Mit #pragma GCC warning verhält es sich genauso. #pragma GCC error hingegen gibt einen Fehler aus, und der Compiler bricht die Arbeit ab.

void setup() { 
    Serial.begin(115200); 
    String blabla = "The weather is nice";
    Serial.println(blabla); 
#pragma message "This is blabla"
    Serial.println(blabla);
#pragma GCC warning "Again, this was blabla"
    Serial.println(blabla);
// #pragma GCC error "Refusing to compile this blabla any longer!"
}

void loop(){}

Die Ausgabe ist:

#pragma Präprozessor-Direktiven - Ausgabe pragma_msg_warn_err.ino
Ausgabe 1 von pragma_msg_warn_error.ino

Wenn ihr nun noch Zeile 9 entkommentiert, bekommt ihr nur die Fehlermeldung:

#pragma Präprozessor-Direktiven - Ausgabe 2 von pragma_msg_warn_err.ino
Ausgabe 2 von pragma_msg_warn_error.ino

#pragma diagnostic

Warnungen unterdrücken

Die #pragma GCC diagnostic-Direktive dient der Steuerung von Warnungen und Fehlermeldungen. Die verschiedenen Optionen werden über weitere Parameter gesteuert. #pragma GCC diagnostic ignored <warning type> unterdrückt die Warnung des Typs „warning type“.

Als Beispiel komme ich auf das Overflow-Problem zurück, das mit dem folgenden Code auftrat (bei Boards mit einer Integergröße von 2 Bytes):

//#pragma GCC diagnostic ignored "-Woverflow"
#define A 30000
#define B 10000
void setup() { 
    Serial.begin(115200); 
    Serial.println(A + B);
}

void loop(){}

Die folgende Warnung könnt ihr unterdrücken, indem ihr die erste Zeile des Sketches entkommentiert:

Ausgabe pragma_dignostic.example
Ausgabe von pragma_diagnostic.ino

Warnungen für einen Bereich unterdrücken / Fehler aus Warnungen machen

Mit #pragma GCC diagnostic error <warning-type> macht ihr aus einer Compiler-Warnung einen Compiler-Fehler, d.h. der Compiler bricht seine Arbeit ab.

Das schauen wir uns am Beispiel ungenutzter Variablen an. Für diese bekommt ihr eine Warnung, sofern ihr in den Einstellungen der Arduino IDE unter „Compiler-Meldungen“ entweder „Mehr“ oder „Alle“ ausgewählt habt. Mit #pragma GCC diagnostic error "-Wunused-variable" machen wir im nächsten Sketch daraus einen Fehler. Mit #pragma GCC diagnostic ignore "Wunused-variable" können wir, wie wir zuvor gesehen haben, die Meldung wiederum unterdrücken.

Die Unterdrückung der Meldung können wir aber auf einen ausgewählten Bereich begrenzen. Dazu dienen #pragma GCC diagnostic push und #pragma GCC diagnostic pop. Mit push merkt sich der Compiler die aktuelle Warnungseinstellung, mit pop stellt er diesen Zustand wieder her. Klingt vielleicht kompliziert, ist aber einfach:

void setup() { 
    Serial.begin(115200);
#pragma GCC diagnostic error "-Wunused-variable" // makes an error out of warning
    int usedVar = 42;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-variable" 
    int unusedVar = 43;  // error is inored here
#pragma GCC diagnostic pop
    int anotherUnusedVar = 44;  // error is not ignored here
    Serial.println(usedVar);
}

void loop(){}

Hier die Ausgabe:

#pragma Präprozessor-Direktiven - Ausgabe pragma_diag_push_pop.ino
Ausgabe von pragma_diag_push_pop.ino

Wenn ihr die Zeilen 5 und 8 auskommentiert, bekommt ihr keine Fehlermeldung. Kommentiert ihr die Zeilen 5, 6 und 8 aus, so erhaltet ihr zwei Fehlermeldungen.

#pragma pack()

Der folgende Sketch definiert eine Struktur exampleStruct, die aus zwei uint8_t- und zwei uint32_t Elementen besteht. Damit sollte die Struktur 10 Byte Speicher beanspruchen. Der Sketch gibt den tatsächlichen Speicherbedarf aus.

//#pragma pack(1)
struct exampleStruct{
    uint32_t    intVal_1;    //   4 Byte
    uint8_t     byteVal_1;   // + 1 Byte
    uint32_t    intVal_2;    // + 4 Byte
    uint8_t     byteVal_2;   // + 1 Byte = 10 Byte (in theory!)
};

void setup() { 
    Serial.begin(115200);
    Serial.print("Size of exampleStruct: ");
    Serial.println(sizeof(exampleStruct));
}

void loop(){}

Wenn ihr den Sketch auf einem AVR-basierten Board wie dem Arduino UNO R3 laufen lasst, bekommt ihr die erwarteten 10 Byte. Wechselt ihr nun beispielsweise auf ein ESP32 Board oder einen Arduino UNO R4 (Minima oder WIFI), seid ihr vielleicht erstaunt über die Ausgabe:

#pragma Präprozessor-Direktiven - Ausgabe pragma_pack.ino
Ausgabe pragma_pack.ino auf einem ESP32

Was hier passiert, nennt man Padding (~ auffüllen, polstern). Die größeren 32-Bit-Mikrocontroller arbeiten schneller, wenn die Elemente der Struktur so in Vier-Byte-Blocks gepackt werden, dass nach Möglichkeit kein Element auf zwei oder mehr Blocks verteilt wird. Die Blocks sind sozusagen Schachteln mit vier Fächern, wobei in jedes Fach ein Byte passt. Das Element intVal_1 füllt die erste Schachtel. Für byteVal_1 wird eine neue Schachtel verwendet. Für intVal_2 reichen die freien drei Fächer nicht aus, weshalb eine neue Schachtel verwendet wird. byteVal_2 braucht wieder eine neue Schachtel, gleiches gilt für intVal_2 → macht vier Schachteln = 16 Fächer = 16 Byte. 

Wenn ihr die Elemente anders anordnet, könnt ihr Platz sparen, z.B.:  intVal_1byteVal_1byteVal_2intVal_2. Dann passt byteVal_2 noch in die zweite Schachtel und ihr verbraucht nur drei Schachteln = 12 Byte.

Mit #pragma pack(x) könnt ihr die Fächerzahl x pro Schachtel festlegen. Dabei muss x = 2n sein, d. h. x = 0, 2, 4, etc. Mit #pragma pack(1) erreicht ihr die maximale Packungsdichte. Entkommentiert die Zeile 1 im obigen Beispielsketch, um die Größe von exampleStruct auf 10 Byte zu begrenzen. Wählt ihr #pragma pack(2), so ist das Ergebnis 12 Byte. Die Voreinstellung entspricht also #pragma pack(4).

9 thoughts on “Präprozessor-Direktiven

  1. Hallo Wolfgang,
    herzlichen Dank für Deinen Beitrag. Deine Seite ist inzwischen ein fester Bestandteil bei meinen Spielereien.
    Wenn ich so frech sein darf, ich hätte mal einen Wunsch für neue Themen?
    1. Könntest Du mal das Thema „Zwei Wire verwenden“ drannehmen? Du verwendest es in Deinen Bibliotheken z.B. ADS115.

    Liebe Grüße
    Siggi

    1. Hi Siggi,
      das ist keinesfalls frech. Ich freue mich über Anregungen! Du meinst mehrere Geräte an einer I2C-Schnittstelle? Viele Arduinos habe ja nur eine I2C Schnittstelle. Oder meinst du mehrere I2C Schnittstellen? Letzteres habe ich mal für den ESP32 besprochen:
      https://wolles-elektronikkiste.de/i2c-schnittstellen-des-esp32-nutzen
      Mehrere I2C-Geräte an einer Schnittstelle habe ich mal hier besprochen:
      https://wolles-elektronikkiste.de/tca9548a-i2c-multiplexer
      Aber eben im Zusammenhang mit dem TCA9548A Multiplexer.
      Ich denke, ich werde mal was über I2C, SPI, Serial und OneWire machen und dann den Punkt „mehrere Geräte“ mit abdecken.
      Also, vielen Dank nochmal!
      VG, Wolfgang

      1. „https://wolles-elektronikkiste.de/i2c-schnittstellen-des-esp32-nutzen
        Mehrere I2C-Geräte an einer Schnittstelle habe ich mal hier besprochen:

        Ich glaube, dass ist genau das was ich gesucht habe.
        Sobald ich Zeit habe werde ich es testen und berichte dann darüber.

        Liebe Grüße Siggi

        1. Hallo Wolfgang,
          wie versprochen kurzer Bericht: 😉
          <>
          Hier war der Schlüssel versteckt. Herzlichen Dank für Deine Hilfe.
          Ich bastel’ gerade mit einem Heltec LoRa V3.
          „Wire“ ist hier duch Heltec bereits mit Pin 18,17 für das OLED in der Bibliothek.

          Ich konnte nach Deinem Beispiel, ein 2. Wire Objekt erzeugen, ohne Fehlermeldung von Arduino.
          #define SDA_2 42
          #define SCL_2 41
          #define I2C_FREQ 400000

          TwoWire I2C_2 = TwoWire(1);

          Liebe Grüße
          Siggi

          Vielleicht verschiebst Du, dass hier, zu Deinem oben genannten Beitrag.

          1. Gut, dass es klappt und danke für die Rückmeldung! Ich schaue mal, wie ich das aufnehme.

  2. Wieso hast Du das gewust?
    Ich war gerade dabei, mich in das Thema einzuarbeiten!
    Wie immer – Danke für die Mühe, super Artikel!

    Liebe Grüße,
    Armin

  3. Danke, dein Artikel kommt goldrichtig.
    Ich hatte gestern gerade Probleme wegen „static“: wann, wo und warum muss man static einsetzten – et voilà! Deine fundierten und ausgezeichneten Artikel helfen mir, mich mit diesem Thema zu beschäftigen. Vielen Dank.
    Andreas

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert