Über den Beitrag
Für die formatierte Ausgabe auf dem seriellen Monitor oder Displays stehen euch die komfortablen Funktionen sprintf()
und snprintf()
zur Verfügung. Damit könnt ihr verschiedene Datentypen in einem Character Array zusammenfassen, Ergebnisse in tabellarische Form bringen, Zahlenformate festlegen und vieles mehr.
Dieser Beitrag soll einen Überblick über die Möglichkeiten von sprintf()
bzw. snprintf()
und ihren Verwandten sprintf_P()
, snprintf_P()
und printf()
geben. Einige Boardpakete unterstützen die Formatierung von Dezimalzahlen mittels dieser Funktionen nicht. In diesen Fällen bietet sich ein Umweg über die Funktion dtostrf()
an.
Die Möglichkeiten, Ausgaben mit Serial.print()
und Escape-Sequenzen zu formatieren, sind sehr begrenzt. Aber um alle Formatierungsoptionen an einem Ort zu vereinen, gehe ich auch darauf ein.
Das erwartet euch:
- Formatierte Ausgabe mit den Serial.print() „Bordmitteln“
- Escape-Sequenzen
- Formatierte Ausgabe mit sprintf() & Co
- Formatierte Ausgabe mit dtostrf()
- Beispielsketch Tabellenausgabe
Formatierte Ausgabe mit den Serial.print() „Bordmitteln“
Wenn ihr der Funktion Serial.print()
oder Serial.println()
eine Zahl übergebt, dann könnt ihr diese mithilfe eines zweiten Parameters formatieren:
Serial.print(val, format);
Für format
habt ihr folgende Optionen:
- Bei Ganzzahlen könnt ihr die Zahlenbasis festlegen:
- HEX: Basis 16, Hexadezimalsystem
- DEC: Basis 10, Dezimalsystem (Standard),
- OCT: Basis 8, Oktalsystem
- BIN: Basis 2, Binärsystem
- Bei Dezimalzahlen legt ihr die Anzahl der Nachkommastellen fest (Standard: 2). Die letzte Stelle wird gerundet.
Dazu ein kleiner Beispielsketch:
void setup() { Serial.begin(115200); // delay(1000); //uncomment if your serial monitor is blank int a = 193; float f = PI; // PI = 3.1415926... Serial.println("Output of 193 (dec.):"); Serial.print("Hexadecimal: "); Serial.println(a, HEX); Serial.print("Decimal : "); Serial.println(a, DEC); Serial.print("Octal : "); Serial.println(a, OCT); Serial.print("Binary : "); Serial.println(a, BIN); Serial.println("\nOutput of Pi:"); // here, I already used an escape sequence Serial.print("1 decimal place : "); Serial.println(f, 1); Serial.print("2 decimal places: "); Serial.println(f, 2); Serial.print("3 decimal places: "); Serial.println(f, 3); } void loop() {}
Und hier die Ausgabe:
SRAM sparen mit dem F-Makro
Konstanter Text, also alles in Anführungsstrichen im obigen Sketch, ist ein SRAM-Fresser. Mit dem F-Makro sorgt ihr dafür, dass die auszugebende Zeichenfolge nicht im SRAM, sondern im Programmspeicher (Flash) abgelegt wird:
Serial.println(F("Output String"));
Das hat nichts mit Formatierung zu tun, es sei aber der Vollständigkeit halber hier angemerkt, weil ich weiter unten eine entsprechende Variante der sprintf()
Funktion besprechen werde.
Escape-Sequenzen
Escape-Sequenzen bieten euch weitere Optionen, die Ausgaben von Serial.print()
zu formatieren oder Sonderzeichen auszugeben. Dabei ist der Gebrauch von Escape-Sequenzen nicht auf Serial.print()
beschränkt, sondern kann in allen String-Objekten oder Character Arrays zur Anwendung kommen.
void setup() { Serial.begin(115200); // delay(1000); //uncomment if your serial monitor is blank Serial.print("\\n erzeugt einen Zeilenvorschub \n"); Serial.println("\\r erzeugt einen Wagenrücklauf \r"); Serial.println("\\xXX gibt den ASCII-Zeichen von 0xXX aus, z.B. \\x41: \x41"); Serial.println("\\uXXXX gibt das Unicode-Zeichen von Zeichen 0xXXXX aus, z.B. \\u21C4: \u21C4"); Serial.println("\\t erzeugt einen Tab, z.B. 12\t34 "); Serial.println("\\\" ergibt \""); Serial.println("\\\' ergibt \'"); Serial.println("\\\\ ergibt \\"); Serial.print("Serial.println() von "); int numOfChars = Serial.println("12345"); Serial.print(" ...erzeugt "); Serial.print(numOfChars); Serial.println(" Zeichen!"); } void loop() {}
Die Ausgabe dazu ist:
In der letzten Zeile seht ihr, dass Serial.println("...")
zwei Zeichen mehr als vielleicht erwartet erzeugt. Das liegt am „versteckten“ Wagenrücklauf und Zeilenvorschub. Die Funktion entspricht Serial.print("...\r\n")
.
Formatierte Ausgabe mit sprintf() & Co
Überblick über die Funktionen
Mit den Funktionen sprintf()
, snprintf()
printf()
und snprintf_P()
stehen euch ungleich mächtigere Werkzeuge zur Verfügung.
sprintf()
sprintf()
ist folgendermaßen definiert:
int sprintf ( char *buf, const char *format, ... );
Dabei enthält format
die auszugebenden Zeichen und ggf. Platzhalter für Variablen mit Formatierungsanweisungen. Diese Platzhalter nennt man auch Format-Spezifizierer (format specifier). Man erkennt sie an den voranstehenden Prozentzeichen. Die Variablen selbst werden, in der Reihenfolge des Auftretens ihrer Format-Spezifizierer und durch Kommata getrennt, angehängt:
sprintf(buf, "... format_specifier_1 ... format_specifier_2 ... format_specifier_3....", variable_1, variable_2, variable_3, ..,);
Das Ergebnis wird in dem Character Array buf
gespeichert und kann über Serial.print()
ausgegeben werden.
snprintf() vs. sprintf()
Die Funktion sprintf()
schreibt die in format
definierte Zeichenkette in buf
, egal ob ihr dafür genügend Speicher reserviert habt oder nicht. Wenn buf
zu klein ist, schreibt ihr einfach darüber hinaus – mit unabsehbaren Folgen (siehe weiter unten).
Mehr Sicherheit bietet die Funktion snprintf()
. Sie unterscheidet sich von sprintf()
durch die konkrete Vorgabe der Anzahl der einzulesenden Zeichen (hier: „n“) :
int snprintf ( char *buf, size_t n, const char * format, ... );
Wenn ihr „n“ mit sizeof(buf)
festlegt, seid ihr damit auf der sicheren Seite. Schlimmstenfalls wird ein Teil dessen verschluckt, was ihr ausgeben wolltet, aber ihr bekommt zumindest keine Probleme wegen des ungewollten Überschreibens von Speicher.
Auch wenn ich snsprintf()
empfehle, nutze ich im weiteren Verlauf des Beitrages sprintf()
, um das Augenmerk auf die Formatierung zu legen.
printf() / Serial.printf()
Vielleicht fragt ihr euch, ob man sich den Umweg über das Character Array buf
nicht sparen kann. In der Tat könnt ihr auf manchen Mikrocontrollerboards (z.B. ESP32) dazu die Funktion Serial.printf()
nutzen, allerdings, z. B., nicht auf den klassischen AVR-basierten Arduinos.
int Serial.printf(const char * format, ....);
Falls das Board bzw. Boardpaket eurer Wahl Serial.printf()
unterstützt, dann könnt ihr alles, was ich zu sprintf()
schreibe, einfach übertragen. Man sollte sich allerdings überlegen, ob man seine Sketche wirklich Board-spezifisch schreiben möchte.
Die Funktion printf()
(also nicht Serial.printf()
) ist generell verfügbar:
int printf (const char * format, ... );
Allerdings bekommt ihr damit nicht ohne Weiteres Ausgaben auf den seriellen Monitor gezaubert. Die Funktion läuft sozusagen ins Leere. Details dazu und wie man Abhilfe schafft, findet ihr hier und hier.
SRAM sparen mit sprintf_P() und snprintf_P()
Mit den Varianten sprintf_P()
und snprintf_P()
erreicht ihr, dass format
nicht aus dem SRAM, sondern aus dem Programmspeicher gelesen wird:
int sprintf_P ( char *buf, const char* PSTR(format), ... );
int snprintf_P ( char *buf, size_t n, const char* PSTR(format), ... );
Das PSTR-Makro ist so etwas Ähnliches wie das F-Makro. Letzteres ist aber mit sprintf_P()
und snprintf_P()
inkompatibel. Wer es genau wissen will: Das F-Makro liefert ein __FlashStringHelper*
, jedoch erwarten sprintf_P()
und snprintf_P()
ein const char*
als Parameter. Und genau das erzeugt das PSTR-Makro.
Aufbau der Format-Spezifizierer
Ein Format-Spezifierer besteht aus dem Prozentzeichen, einigen optionalen Sub-Spezifizierern und dem Konvertierungs-Spezifizierer:
% [Flags] [Breite] [.Genauigkeit] [Länge] Konvertierungs-Spezifizierer
Der Konvertierungs-Spezifizierer
Mit dem Konvertierungs-Spezifizierer legt ihr fest, als was ihr die Platzhalter ausgeben wollt. Dabei muss der Konvertierungs-Spezifizierer natürlich zu der auszugebenden Variablen passen. Hier die Optionen:
Nicht alle Konvertierungs-Spezifizierer werden von allen Boardpaketen unterstützt. Am schmerzlichsten werden Nutzer von AVR-basierten Boards, z. B. Arduino UNO R3, klassischer Arduino Nano etc., die Optionen für die Dezimalzahlen („Kommazahlen“ = Festkomma- und Gleitkommazahlen) vermissen. Dafür gibt es den Umweg über dtostrf()
, wozu wir weiter unten kommen.
Die Sub-Spezifizierer
Für das Flag habt ihr die folgenden Optionen:
Die Breite ist die Mindestbreite für die im Format-Spezifizier definierte Zeichenkette. Ist die Zeichenkette länger als in „Breite“ definiert oder gleich lang, dann hat „Breite“ keine Wirkung. Wenn die Zeichenkette kleiner als die Breite ist, wird die Differenz mit Leerzeichen oder ggf. mit Nullen (siehe Flags) aufgefüllt.
Die Wirkung der Genauigkeit ist abhängig vom Konvertierungs-Spezifizierer:
- f, e, E, a, A: die Genauigkeit gibt die Zahl der Nachkommastellen an.
- g, G: die Genauigkeit gibt die Gesamtzahl der Ziffern vor und hinter dem Komma an.
- d, i, o, u, x, X: die Genauigkeit gibt die Minimumbreite an. Ggf. vorhandene Leerstellen werden mit voranstehenden Nullen aufgefüllt.
- s: die Genauigkeit gibt die maximale Anzahl der auszugebenden Zeichen an. Darüber hinausgehende Zeichen werden abgeschnitten.
Auch die Wirkung der Länge hängt vom Konvertierungs-Spezifizierer ab:
- l (kleines „L“): macht aus d, i, o, u, x, X ein „long int“ bzw. ein „unsigned long int“.
- ll (kleine „Ls“): machen aus d, i, o, u, x, X ein „long long int“ bzw. ein „unsigned long long int“.
- L: macht aus f ein long double.
Beispielsketche
Einstiegsbeispiel
Der folgende Sketch soll veranschaulichen, wie ihr die Format-Spezifizierer in sprintf()
, snprintf()
und snprintf_P()
benutzt. Dabei verzichte ich zunächst auf Sub-Spezifizierer:
void setup() { Serial.begin(115200); char buf[60]; // more than enough capacity int a = 42; char str[] = "universe"; sprintf(buf, "%d is the meaning of life, %s %s", a, str, "and everything"); Serial.println(buf); snprintf(buf, 26, "%d is the meaning of life, %s %s", a, str, "and everything"); Serial.println(buf); snprintf(buf, sizeof(buf), "%d is the meaning of life, %s %s", a, str, "and everything"); Serial.println(buf); snprintf_P(buf, sizeof(buf), PSTR("%d is the meaning of life, %s %s"), a, str, "and everything"); Serial.println(buf); } void loop() {}
Hier die Ausgabe:
Berechnung von buf
Für die Berechnung der Größe des „Zielstrings“ (buf
) ist zu beachten, dass man die Anzahl der auszugebenden Zeichen zugrunde legt und nicht mit der Größe der Variablen rechnet. Beispiel: Ein Integer hat auf einem AVR-basierten Arduino einen Speicherbedarf von 2 Bytes. Wie viel Speicher der Integerwert aber in buf
beansprucht, hängt von der Anzahl der Ziffern ab und von ggf. von den Formatierungsparametern (zusätzliche Vorzeichen, Leerzeichen, „0x“ etc.)
Vergesst nicht, ein Zeichen für den Null-Terminator zu reservieren.
Was passieren kann, wenn ihr die Größe von buf
zu klein wählt, das seht ihr hier:
void setup() { Serial.begin(115200); char buf[5]; char buf2[6]; char str[] = "1234567890"; sprintf(buf, "%s", str); Serial.print("buf : "); Serial.println(buf); sprintf(buf2, "%s", str); Serial.print("buf2: "); Serial.println(buf2); Serial.print("buf : "); Serial.println(buf); } void loop() {}
Die Ausgabe, getestet auf einem Arduino Nano, lautet:
Wie ihr seht, fällt der Fehler erst auf, nachdem der String in buf2
geschrieben wurde und sein nicht in den reservierten Speicher passender Rest in den für buf
vorgesehenen Speicher „hineinragt“.
Auf einem ESP32 ist der Speicher auf andere Art organisiert. Hier wird buf
„von der anderen Seite kommend“ überschrieben:
Beispiel: formatierte Ausgabe von Ganzzahlen
Der nächste Beispielsketch spielt mit einigen Sub-Spezifizierern für Ganzzahlen.
void setup() { Serial.begin(115200); char buf[50]; // again, more than enough capacity int a = 193; sprintf(buf, "Standardausgabe, int (signed) : %i", a); Serial.println(buf); sprintf(buf, "Definierte Breite, rechtsbündig : %5d%5d", a, a+a); Serial.println(buf); sprintf(buf, "Definierte Breite, linksbündig : %-5d%-5d", a, a+a); Serial.println(buf); sprintf(buf, "Definierte Breite, mit Nullen : %05d", a); Serial.println(buf); sprintf(buf, "Mit Vorzeichen : %+d", a); Serial.println(buf); sprintf(buf, "Hexadezimal, klein : %x ", a); Serial.println(buf); sprintf(buf, "Hexadezimal, groß : %X", a); Serial.println(buf); sprintf(buf, "Hexadezimal, mit \"0X\" : %#X", a); Serial.println(buf); sprintf(buf, "Oktal : %o", a); Serial.println(buf); Serial.print("Keine binäre Ausgabe mit sprintf: "); Serial.println(a, BIN); sprintf(buf, "Long integer : %ld", 1234567890); Serial.println(buf); sprintf(buf, "%-32s%s%05d", "Alternativer Weg", ": ", a); Serial.println(buf); sprintf(buf, "Adresse von buf : %p", buf); Serial.println(buf); } void loop() {}
Weil ich das Augenmerk auf das Wesentliche lenken wollte, habe ich nicht die vollen Möglichkeiten der Formatierung genutzt, um beispielsweise die Doppelpunkte auszurichten. Um aber zu zeigen, wie das geht, habe ich bei der Formatierung der vorletzten Ausgabezeile („Alternativer Weg“) eine Ausnahme gemacht.
Hier noch die Ausgabe:
Beispiel: formatierte Ausgabe von Dezimalzahlen
Wie schon erwähnt, funktioniert die Formatierung von Dezimalzahlen mittels sprintf()
nicht auf den AVR-basierten Mikrocontrollerboards. Den folgenden Sketch habe ich auf einem ESP32 Board ausprobiert:
void setup() { Serial.begin(115200); char buf[60]; double a = PI; // PI = 3.1415926... sprintf(buf, "Double, Standardausgabe : %f", a); Serial.println(buf); sprintf(buf, "Mit Breite und Genauigkeit : %6.2f", a); Serial.println(buf); sprintf(buf, "Exponentialformat : %e", a); Serial.println(buf); sprintf(buf, " \" , mit Breite und Genauigkeit : %10.3e", a); Serial.println(buf); sprintf(buf, "Exponentialformat, groß : %E", a); Serial.println(buf); sprintf(buf, "Kürzeste Darstellung, klein : %g", a); Serial.println(buf); sprintf(buf, "Kürzeste Darstellung, klein : %g", 1000000 * a); Serial.println(buf); sprintf(buf, "Kürzeste Darstellung, groß : %G", a); Serial.println(buf); sprintf(buf, "Kürzeste Darstellung, groß : %G", 1000000 * a); Serial.println(buf); sprintf(buf, "Gleitkomma, hexadezimal, klein : %a", a); Serial.println(buf); sprintf(buf, "Gleitkomma, hexadezimal, groß : %A", a); Serial.println(buf); } void loop() {}
Die Ausgabe dazu ist:
Ihr könntet in dem Beispiel Pi auch als Datentyp float definieren. Ihr seht den Unterschied dann nach der siebenten Ziffer.
Formatierte Ausgabe mit dtostrf()
Dort, wo die Formatierung von Dezimalzahlen mithilfe von sprintf()
bzw. %f
nicht funktioniert, könnt ihr die Funktion dtostrf()
verwenden:
dtostrf(double f, int [-]width, int precision, char *buf)
Dabei ist:
- f: die in eine Zeichenkette zu wandelnde Zahl.
- width: die Minimumbreite.
- ein optionales „-“ erzwingt die Ausgabe des Vorzeichens.
- precision: die Nachkommastellen.
- buf: der Ausgabestring.
Auch hier müsst ihr bei der Berechnung der Größe des „Zielstrings“ (buf
) beachten, dass die Zahl der auszugebenden Zeichen (einschließlich Leerzeichen) und der Null-Terminator hineinpassen müssen.
Für die Ausgabe bietet es sich an, die Ausgabe von dtostrf()
mit sprintf()
zu kombinieren.
Beispielsketch dtostrf()
Hier einige Beispiele für den Gebrauch von dtostrf()
:
void setup() { Serial.begin(115200); char buf_1[10]; char buf_2[40]; // int i = 193; double a = PI; // PI = 3.1415926... dtostrf(a, 1, 2, buf_1); sprintf(buf_2, "Breite > minimale Breite : %s", buf_1); Serial.println(buf_2); dtostrf(a, 7, 2, buf_1); sprintf(buf_2, "Rechtsbündig : %s", buf_1); Serial.println(buf_2); dtostrf(a, -7, 2, buf_1); sprintf(buf_2, "Linksbündig : %s", buf_1); Serial.println(buf_2); dtostrf(i, 1, 0, buf_1); sprintf(buf_2, "Integer : %s", buf_1); Serial.println(buf_2); dtostrf(i, 7, 2, buf_1); sprintf(buf_2, "\"Integer zu Double\" : %s", buf_1); Serial.println(buf_2); } void loop() {}
Die Ausgabe des Sketches ist:
Beispielsketch Tabellenausgabe
Zum Schluss noch ein Beispiel, wie eine übersichtliche Ausgabe von Datensätzen aussehen könnte:
struct dataset { char name[10]; unsigned int age; unsigned int size; float weight; }; void setup() { dataset mia {"Mia", 1, 75, 9.3}; dataset michael {"Michael", 60, 185, 103.7}; dataset kevin {"Kevin", 18, 183, 75.2}; Serial.begin(115200); print_data(&mia); print_data(&michael); print_data(&kevin); } void print_data(dataset *set){ char buf[40]; char weightBuf[7]; // width 6 + null-terminator dtostrf(set->weight, 6, 1, weightBuf); sprintf(buf, "| %-8s|%4d |%4d |%s |", set->name, set->age, set->size, weightBuf); Serial.println(buf); Serial.flush(); } void loop() {}
Da die Datensätze an print_data
als Zeiger übergeben werden, muss anstelle des Punktoperators der Pfeiloperator angewendet werden. Ansonsten sollte der Sketch selbsterklärend sein.
Hier noch die Ausgabe:
Hallo Ewald,
sehr schön, eine wirklich komplette und sauber aufgebaute Darstellung! Exzellent gemacht.
Ich werde mir bei meinem Projekt „TDMM – talking digital multimeter“ erlauben, diese Funktionen zu nutzen, um die Darstellung der Messwerte zu verbessern. Über genau dieses Detail hatte ich nicht ausreichend nachgedacht.
Bei dieser Gelegenheit werde ich einen Link zu diesem Bericht setzen, auch um zu weiterhin so schönen, fundierten Beiträgen zu motivieren 🙂 Das findet sich dann im Blog art-of-electronics.blog, kann aber noch etwas dauern.
Herzliche Grüße – vielen Dank!
Michael
Herzlichen Dank für das Feedback und für den Link!
VG, Wolfgang
Hallo Wolfgang,
bin Rentner (72) und programmiere nur gelegentlich kleinere Arduino-Projekte. [Sonst noch zur Aufbesserung der Ostrente SPS&Motion-Programme mit IEC61131 strukturierter Text].
Für serielle Ausgaben bei µControllern muß ich jedesmal im Tutorial nachschauen. Dein Beitrag ist eine klasse Zusammenstellung! Habe gleich ein Lesezeichen gesetzt.
Danke und viele Grüße
Ulrich
Hallo Ulrich,
herzlichen Dank für das freundliche Feedback! Viel Spaß und Erfolg bei deinen Projekten!
VG, Wolfgang