Über den Beitrag
Die Datenübertragung per Serial, also über den seriellen Port, ist eines der ersten Dinge, mit denen man in der Arduino-Welt in Berührung kommt, z. B. durch Serial.println()
. In diesem Beitrag werde ich das Thema Serial sehr umfassend behandeln. Eigentlich ist Serial kein überaus komplexes Thema, aber wie bei so vielen Dingen liegt auch hier der Teufel im Detail.
Ich beginne mit einem allgemeinen Teil, in dem ich erkläre, wie die Serial Datenübertragung funktioniert und welche Einstellungen ihr vornehmen könnt. Dann erfahrt ihr, wie ihr verschiedene Mikrocontroller-Boards per (Hardware-)Serial oder SoftwareSerial miteinander kommunizieren lassen könnt. In hinteren Teil des Beitrages gehe ich auf die Serial-Funktionen ein. Eine kompaktere Funktionsübersicht zum schnellen Nachschlagen gibt es zwar schon hier auf den Arduinoseiten, dafür gibt es bei mir aber mehr Beispiele und Hinweise auf diverse Stolperfallen.
Hier der Inhalt des Beitrages im Überblick:
- Was ist Serial?
- Wie funktioniert die Serial Kommunikation?
- Einstellungen: Daten-Bits, Baudrate, Paritäts- und Stopp-Bits
- Vor- und Nachteile der Serial Datenübertragung
- Serial Datenpuffer
- HardwareSerial Kommunikation von Board zu Board
- SoftwareSerial
- Die Serial-Funktionen
Was ist Serial?
Der serielle Port (Serial) der Arduino-Boards dient der Kommunikation mit dem PC, mit bestimmten externen Bauteilen oder mit anderen Mikrocontrollern. Eigentlich ist der Begriff „Serial“ (also seriell) etwas unscharf, da auch bei I2C oder SPI serielle Kommunikationsformen sind. Genauer ausgedrückt handelt es sich bei dem seriellen Port um den USART (= Universal Synchronous/Asynchronous Receiver/Transmitter).
Wie der Name es unterstellt, beherrscht der USART synchrone und asynchrone Kommunikation. In der Arduinowelt ist die Kommunikation per USART aber grundsätzlich asynchron. Asynchron heißt, dass es keine Leitung gibt, die den Takt der Kommunikationspartner steuert, so wie das beispielsweise bei I2C oder SPI der Fall ist.
Aus programmiertechnischer Sicht ist Serial eine vorgefertigte Instanz der Klasse HardwareSerial, die zur Standardausstattung des Arduino Paketes gehört. Ihr selbst müsst also kein Serial-Objekt erzeugen. Für Serial stehen viele komfortable Funktionen zur Verfügung. Grundkenntnisse über die gängigen Serial-Funktionen setze ich voraus. Falls zwischendurch Fragen dazu auftauchen, dann schaut in den hinteren Teil des Beitrages, in dem ich auf die Serial-Funktionen eingehe.
Wie funktioniert die Serial Kommunikation?
Die an der Datenübertragung beteiligten Komponenten besitzen einen RX-Eingang (Receiver) und einen TX-Ausgang (Transmitter). D. h. grundsätzlich verbindet ihr RX mit TX und TX mit RX. Möchtet ihr nur in eine Richtung kommunizieren, reicht eine einzige TX → RX Datenleitung. Zusätzlich müssen aber beide Kommunikationspartner ein gemeinsames GND haben, da sonst die HIGH- und LOW-Level nicht eindeutig erkannt werden.
Ansonsten ist die Kommunikation erstaunlich simpel. Im Grundzustand ist die RX/TX-Datenleitung auf HIGH-Niveau. Will der Sender etwas senden, zieht er die TX-Leitung für eine bestimmte Zeit auf LOW. Das repräsentiert das Start-Bit. Dann erfolgt die Übertragung eines Bytes, beginnend mit dem LSB (Least Significant Bit), also Bit 0. Einsen werden als HIGH-Signal und Nullen also LOW-Signal übertragen. Nach Bit 7 sendet der Sender ein Stop-Bit (HIGH). Das Spiel beginnt von vorn, bis alles Bytes übertragen wurden. So funktioniert es jedenfalls bei Verwendung der Standardeinstellungen.
Mit einem Logic Analyzer habe ich die Übertragung der Nachricht „Hello World!“ vom seriellen Monitor an einen Arduino am RX-Pin des Arduinos aufgezeichnet:
Die Nachricht ist leicht zu entziffern, weil die Zeichen im ASCII-Code übertragen werden. Ich habe das hier einmal für das „e“ (ASCII-Code 101) exemplarisch „aufgedröselt“:
Zu beachten ist, dass b01100101 mit Bit 7 beginnt. Die Bit-Reihenfolge muss also umgekehrt werden.
Einstellungen: Daten-Bits, Baudrate, Paritäts- und Stopp-Bits
Serial Optionen
Daten-Bits
Normalerweise übertragt ihr die Daten byteweise, d. h. ihr sendet 8 Datenbits zwischen Start- und Stopp-Bit. Alternativ könnt ihr aber auch nur 5, 6 oder 7 Bits übertragen, was aber eher exotisch ist.
Baudrate
Da es keine Leitung für den Takt gibt, müssen sich Sender und Empfänger über die Länge der Signale einig sein. Wenn z. B. das Bit 0 eine Null ist, also LOW, woher sollte der Empfänger dann wissen, wo das Start-Bit (auch LOW) aufhört und das Bit 0 anfängt?
Damit sich die beiden Kommunikationspartner verstehen, wird auf beiden Seiten die gleiche Übertragungsgeschwindigkeit als sogenannte Baudrate (benannt nach Jean-Maurice-Émile Baudot) eingestellt. Die Baudrate gibt an, wie viele HIGH- und LOW-Signale pro Sekunde übertragen werden. Bei einer Baudrate von 9600 werden, unter Berücksichtigung des Start- und Stopp-Bits, also 960 Byte pro Sekunde übermittelt.
Paritäts-Bit
Um die Sicherheit bei der Datenübertragung zu erhöhen, könnt ihr zusätzlich ein Paritätsbit übertragen. Es gibt an, ob die Anzahl der Einsen des übertragenen Bytes gerade oder ungerade ist. Es gibt drei Optionen:
- None: kein Paritätsbit
- Even (Gerade): Paritätsbit ist 0, wenn die Zahl der Einsen gerade ist, sonst ist es 1.
- Odd (Ungerade): Paritätsbit ist 0, wenn die Zahl der Einsen ungerade ist, sonst ist es 1.
Stopp-Bits
Anstelle von einem Stopp-Bit könnt ihr auch zwei übertragen.
Wie ihr die Serial Einstellungen vornehmt
Die Einstellung der Baudrate mittels Serial.begin(baudrate)
dürfte euch bekannt sein. Die Baudrate ist nicht beliebig auswählbar, sondern es stehen bestimmte Werte zur Auswahl. Üblich sind Einstellungen zwischen 2400 und 115200 (Maximum für den seriellen Monitor).
Für die anderen Einstellungen übergebt ihr einen zweiten Parameter: Serial.begin(baudrate, config)
. Der Parameter config
ist „SERIAL_DPS“ mit:
- D (Daten-Bits): 5, 6, 7, 8
- P (Paritäts-Bit): N (none), O (Odd), E (Even)
- S (Stopp-Bit): 1, 2
Übergebt ihr keinen zweiten Parameter, dann greift der Standard SERIAL_8N1.
Empfänger und Sender müssen dieselbe Einstellung haben.
Vor- und Nachteile der Serial Datenübertragung
Vorteile:
- Datenübertragung über eine einzige Leitung möglich.
- Einfaches Protokoll.
- Weite Verbreitung.
- Gepufferte Übertragung (dazu kommen wir gleich).
Nachteile:
- Kein Hinweis, ob die Gegenstelle bereit ist, die Daten empfangen, so wie das etwa bei I2C der Fall ist (Acknowledge-Bit).
- Kein individuelles Ansprechen der Empfänger, so wie ihr das bei I2C über die Adresse oder bei SPI über die CS-Leitung realisieren könnt.
- In der Arduinoumgebung können Probleme mit dem Programm-Upload auftreten.
Anmerkungen zum letzten Punkt: Eine Besonderheit der Arduinowelt ist, dass der Bootloader den Programm-Upload auf den seriellen Port umleitet. Falls ihr nur mit dem seriellen Monitor kommuniziert, habt ihr kein Problem mit dem Programm-Upload. Anders sieht es aus, wenn ihr mit einem anderen Gerät über RX verbunden seid. Falls dieses Gerät die RX Leitung auf HIGH oder LOW ziehen sollte, funktioniert der Programm-Upload nicht mehr. Während des Uploads müsst ihr die Verbindung dann kappen.
Eine mögliche Lösung des Problems ist SoftwareSerial. Darauf komme ich später zurück.
Serial Datenpuffer
RX-Puffer
Zur Abwechslung gibt es ein bisschen Praxis. Nehmt ein ATmega328P-basiertes Arduino Bord wie den UNO R3 oder den klassischen Nano und ladet das folgende Programm hoch:
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()){ String inputString = Serial.readString(); Serial.print("I received: "); Serial.println(inputString); } // Serial.println("Delay begins..."); // delay(10000); }
Öffnet den seriellen Monitor und sendet eine Nachricht aus 100 Zeichen, z.B.:
„123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789“.
Wie erwartet, dürftet ihr Folgendes auf dem seriellen Monitor sehen:
Serial.available()
prüft, ob Daten über den seriellen Port empfangen wurden. Serial.readString()
ruft die Daten ab und gibt sie als Datentyp String zurück.
Jetzt entkommentiert ihr die Zeilen 11 und 12, ladet das Programm hoch und sendet dieselbe Nachricht noch einmal, nachdem das „Delay begins …“ auf dem seriellen Monitor erschienen ist. Nun aber seht ihr Folgendes nach Ablauf des Delays:
Nach 63 Zeichen ist Schluss, wobei es noch das unsichtbare Zeichen Nr. 64 gibt, nämlich den Null-Terminator (‚\0‘). Eigentlich ist hier aber nicht nur bemerkenswert, dass weniger Daten als gesendet angenommen werden, sondern dass überhaupt Daten angenommen werden.
Der Grund für das Verhalten ist der RX-Puffer, der bei ATmega328P basierten Arduino Boards auf 64 Zeichen festgelegt ist (63 davon nutzbar). Der Puffer nimmt Daten auf, auch wenn der Arduino gerade mit etwas anderem beschäftigt ist, wie hier dem Delay. Werden die Daten fortlaufend aus dem Puffer abgerufen, ist jederzeit genügend Platz im Puffer, um alle gesendeten Daten aufzunehmen. Ist das nicht der Fall, dann läuft der Puffer über und alle zusätzlichen Daten sind verloren. Wenn ihr das Experiment beispielsweise mit einem ESP32 wiederholt, dann bekommt ihr mit und ohne Delay alle Daten, denn der Puffer des ESP32 fasst 256 Zeichen.
TX-Puffer
Dass auch der Datenausgang gepuffert ist, lässt sich mit dem folgenden Sketch zeigen:
#include <avr/sleep.h> #define INT_PIN 2 void setup() { Serial.begin(9600); set_sleep_mode(SLEEP_MODE_PWR_DOWN); pinMode(INT_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(INT_PIN), wakeISR, RISING); } void loop() { delay(3000); Serial.println("Hi, I am going to sleep for a while..."); //Serial.flush(); delay(10); sleep_mode(); Serial.println("Woke up!"); } void wakeISR(){}
Der Sketch schickt den Arduino nach 3 Sekunden in den Schlaf. Zuvor soll er aber noch eine Nachricht auf dem seriellen Monitor ausgeben. Ein LOW-Signal am Interrupt-Pin 2 weckt den Arduino wieder. Die erste Ausgabe des Sketches sieht so aus:
Ein Teil der Nachricht wird also zunächst „verschluckt“, nach dem Wecken (einfach Pin 2 kurz mit GND verbinden) aber doch noch ausgegeben:
Verlängert ihr das delay(10)
, dann wird mehr ausgegeben, verkürzt ihr es, dann wird vor dem Schlaf weniger ausgegeben.
Die Serial.println()
Anweisung schiebt die auszugebenden Daten erst einmal nur in den TX-Puffer. Während das Programm schon weiterläuft, erfolgt im Hintergrund die eigentliche Ausgabe über den TX-Pin. Der Ausgabeprozess findet also nicht sequenziell als Teil des Programmes statt, sondern parallel dazu. In den meisten Fällen ist das wünschenswert, da nicht blockierend, in diesem speziellen Fall jedoch nicht.
Falls ihr erst mit den nächsten Schritten des Programms fortfahren wollt, wenn der TX-Puffer vollständig geleert ist, verwendet ihr die Anweisung Serial.flush()
. Entkommentiert Zeile 14 und ihr werdet sehen, dass die Nachricht vollständig auf dem seriellen Monitor erscheint, bevor sich der Arduino schlafen legt.
RX- / TX-Puffer vergrößern
Ihr könnt die RX- und TX-Puffer bei Bedarf vergrößern, müsst euch aber bewusst sein, dass ihr als Folge entsprechend weniger SRAM zur Verfügung habt.
Für die Boards, die durch das Arduino Board Paket abgedeckt sind, nehmt ihr die Einstellung der Puffergröße in der Datei HardwareSerial.h vor. Bei mir liegt sie im Verzeichnis:
C:\Users\Ewald\AppData\Local\Arduino15\packages\arduino\hardware\avr\1.8.6\cores\arduino
Für Boards mit einem SRAM größer 1024 Byte ist SERIAL_TX_BUFFER_SIZE bzw. SERIAL_RX_BUFFER_SIZE auf 64 Byte festgelegt, sonst auf 16:
#if !defined(SERIAL_TX_BUFFER_SIZE) #if ((RAMEND - RAMSTART) < 1023) #define SERIAL_TX_BUFFER_SIZE 16 #else #define SERIAL_TX_BUFFER_SIZE 64 #endif #endif #if !defined(SERIAL_RX_BUFFER_SIZE) #if ((RAMEND - RAMSTART) < 1023) #define SERIAL_RX_BUFFER_SIZE 16 #else #define SERIAL_RX_BUFFER_SIZE 64 #endif #endif #if (SERIAL_TX_BUFFER_SIZE>256) typedef uint16_t tx_buffer_index_t; #else typedef uint8_t tx_buffer_index_t; #endif #if (SERIAL_RX_BUFFER_SIZE>256) typedef uint16_t rx_buffer_index_t; #else typedef uint8_t rx_buffer_index_t; #endif
Ändert den Wert, speichert die Datei ab und ladet euren Sketch erneut hoch.
Für andere Boards müsst ihr bei dem Verzeichnis „packages“ abbiegen und dort nach HardwareSerial.h suchen.
HardwareSerial Kommunikation von Board zu Board
Jetzt werden wir verschiedene Mikrocontroller-Boards miteinander kommunizieren lassen. Dabei ist es hilfreich, wenn die Boards mit mehreren seriellen Ports ausgestattet sind.
Boards mit einem HardwareSerial Port
Vorab: für diesen Fall empfehle ich SoftwareSerial. Trotzdem will ich zeigen, wie es über HardwareSerial funktioniert.
Verbindet zwei Mikrocontroller-Boards wie folgt:
Das Transmitter-Board soll dem Receiver-Board alle drei Sekunden seine millis()
– Zeit mitteilen. Auf das Transmitter-Board ladet ihr diesen Sketch:
void setup() { Serial.begin(9600); } void loop() { unsigned long int secondsSinceStart = millis() / 1000; Serial.print(secondsSinceStart); delay(3000); }
Für den Receiver habe ich diesen Sketch vorgesehen:
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()){ unsigned int timeInSeconds = Serial.parseInt(); Serial.print("Seconds elapsed on the other Arduino: "); Serial.println(timeInSeconds); } }
Wenn ihr versucht, den Receiver-Sketch hochzuladen, dann werdet ihr sehen, dass es nicht funktioniert. Das Problem ist, dass der TX-Pin des Transmitters den RX-Pin des Receivers auf HIGH hält und das den Programm-Upload stört. Trennt also die Verbindung für den Programm-Upload.
Hier die Ausgaben auf der Receiverseite
Die Ausgabe auf der Transmitterseite ist weniger schön, weil ich print()
und nicht println()
verwendet habe. Die Funktion println()
sendet aber noch die Steuerzeichen für den Zeilenvorschub und den Wagenrücklauf. Darin findet parseInt()
keine Zahl und gibt eine 0 zurück.
Im Prinzip könnte man den Sketch und die Schaltung erweitern, sodass die Kommunikation in beide Richtungen erfolgt. Wenn ihr dann jedoch die erhaltenen Nachrichten mit Serial.print()
auf dem seriellen Monitor ausgebt, dann sendet ihr sie gleichzeitig zum Sender zurück und ihr endet in einem schnellen Pingpong. HardwareSerial hat also seine Tücken.
Boards mit mehreren HardwareSerial Ports
Wir wiederholen das Ganze mit zwei Boards, die über mehrere serielle Ports verfügen. Als Anschauungsobjekte nehmen wir einen Arduino Mega 2560 und ein ESP32-Board. Für die Übertragung von Board zu Board habe ich den Serial Port 2 („Serial2“) des Mega 2560 und den Serial Port 1 („Serial1“) des ESP32 verwendet. Die Übertragung auf den seriellen Monitor läuft über den Standard Port.
Die TX- und RX-Pins sind auf dem Mega 2560 festgelegt. Auf dem ESP32 hingegen können die Serial Ports anderen Pins zugeordnet werden.
Dadurch, dass wir für die Board-zu-Board- und die Board-zu-PC-Kommunikation unterschiedliche Ports benutzen, umgehen wir die im letzten Beispiel beschriebenen Probleme der 2-Wege Kommunikation.
In diesem Beispiel haben wir jedoch die unterschiedliche Betriebsspannung des ESP32 und des Mega-Boards zu berücksichtigen. Da der TX-Pin die Spannung hochzieht und der Arduino Mega 2560 die höhere Spannung hat, müssen wir an dieser Stelle einen Spannungsteiler oder Levelshifter verwenden.
Der Mega 2560 teilt dem ESP32 seine millis()
-Zeit mit und erhält dafür eine Empfangsbestätigung zurück. Hier der Sketch für den Mega 2560:
unsigned long int lastMessageSent = 0; void setup() { Serial.begin(9600); Serial2.begin(9600); // both Serial Ports have to initialized! } void loop() { if(millis() - lastMessageSent > 3000){ lastMessageSent = millis(); unsigned long int elapsedTime = lastMessageSent/1000; String messageOut = "Hi ESP32, I started " + String(elapsedTime) + " seconds ago"; Serial2.print(messageOut); } if(Serial2.available()){ String messageIn = Serial2.readString(); Serial.print("Received Message: "); Serial.println(messageIn); } }
Auf der ESP32-Seite ordnen wir Serial1 den Pins 18 (RX) und 19 (TX) zu. Der ESP32 wartet auf den Eingang der Nachrichten, liest sie als String ein, gibt sie auf dem seriellen Monitor aus und bedankt sich freundlich beim Mega 2560.
#define RX1 18 #define TX1 19 void setup() { Serial.begin(9600); Serial1.begin(9600, SERIAL_8N1, RX1, TX1); } void loop() { if(Serial1.available()){ String messageIn = Serial1.readString(); Serial.print("I received: "); Serial.println(messageIn); Serial1.print("Hi Mega, thank you!"); } }
Fehlt noch die Ausgabe auf dem seriellen Monitor:
Alternatives Beispiel
An einem anderen Beispiel möchte ich zeigen, wie ihr Nachrichten von einem seriellen Monitor zum anderen sendet. Hier werden die Nachrichten nicht am Stück aus dem Puffer gelesen, sondern es wird jedes einzelne empfangene Zeichen sofort auf dem seriellen Monitor ausgegeben.
Hier der Sketch für den Mega 2560:
void setup() { Serial.begin(9600); Serial2.begin(9600); } void loop() { if (Serial2.available()) { Serial.write(Serial2.read()); } if (Serial.available()) { Serial2.write(Serial.read()); } }
Und hier der Sketch für den ESP32:
#define RX1 18 #define TX1 19 void setup() { Serial.begin(9600); Serial1.begin(9600, SERIAL_8N1, RX1, TX1); } void loop() { if (Serial1.available()) { Serial.write(Serial1.read()); } if (Serial.available()) { Serial1.write(Serial.read()); } }
SoftwareSerial
Was ist SoftwareSerial?
SoftwareSerial ist, wie der Name es schon unterstellt, eine Softwarelösung, die gewöhnlichen I/O-Pins ein Verhalten und eine Funktionalität wie HardwareSerial Pins verleiht. Vom Leistungsaspekt her sollte man eher HardwareSerial verwenden, da der serielle Port Hardwarekomponenten wie etwa den Baudratengenerator mitbringt, die von SoftwareSerial emuliert werden müssen. Und das kostet Systemressourcen, die ggf. an anderer Stelle fehlen.
Wenn Ihr nur einen seriellen Port auf eurem Board zur Verfügung habt und den Problemen mit dem Programm-Upload und Wechselwirkungen mit dem seriellen Monitor aus dem Wege gehen wollte, dann spricht in den meisten Fällen aber nichts gegen die Verwendung von SoftwareSerial. SoftwareSerial gehört zu der Standardausstattung der meisten Boardpakete. Und da, wo das nicht der Fall ist (z. B. ESP32) gibt es in der Regel SoftwareSerial-Bibliotheken auf GitHub.
Bei Verwendung mehrerer SoftwareSerial Instanzen ist zu beachten, dass nur eine Instanz zu einem bestimmten Zeitpunkt Daten empfangen kann. D. h. werden mehrere SoftwareSerial-RX-Pins zum gleichen Zeitpunkt angesprochen, droht Datenverlust. Hier könnte oder müsste man sich ggf. mit Empfangsbestätigungen absichern.
Wie wird SoftwareSerial verwendet?
Das Funktionsprinzip bleibt dasselbe, einschließlich der Puffer. Alle Serial-Funktionen stehen auch für SoftwareSerial zur Verfügung. Die Handhabung benötigt daher nur geringe Umgewöhnung. Ihr müsst lediglich SoftwareSerial.h einbinden und ein SoftwareSerial-Objekt erzeugen:
#include<SoftwareSerial.h>
SoftwareSerial mySerial( RX_Pin, TX_Pin );
RX und TX könnt ihr frei wählen.
SoftwareSerial Beispiel
Als Beispiel lassen wir zwei Arduino Nano Boards über SoftwareSerial an den Pins 10 und 11 miteinander kommunizieren. Auch hier dürfen wir die GND-Verbindung nicht vergessen.
Der Sketch dazu ist für beide Boards identisch.
#include <SoftwareSerial.h> SoftwareSerial mySerial(10,11); // create a SoftwareSerial object void setup() { Serial.begin(9600); mySerial.begin(9600); } void loop() { if (mySerial.available()) { Serial.write(mySerial.read()); } if (Serial.available()) { mySerial.write(Serial.read()); } }
Natürlich könnt ihr auch unterschiedliche Boards über SoftwareSerial kommunizieren lassen. Genauso ist es möglich, auf dem einen Board HardwareSerial und dem anderen Board SoftwareSerial zu verwenden. Denkt aber immer an das Levelshifting bei unterschiedlichen Betriebsspannungen.
Die Serial-Funktionen
Schreib-Funktionen
Zum besseren Verständnis der Schreibfunktionen sollte man bedenken, dass alle zu übertragenden Zeichen am Ende in ASCII-Code übersetzt werden. Der wesentliche Unterschied der Funktionen besteht in der Interpretation der übergebenen Parameter.
print() / println()
size_t numberOfWrittenBytes = Serial.print( somethingToPrint );
size_t numberOfWrittenBytes = Serial.print( somethingToPrint, format );
Diese Funktionen dürfte jeder kennen. Sie sind sehr flexibel, denn ihr könnt ihnen Integers, Floats, Strings, Bytes und vieles mehr übergeben. Was einige vielleicht nicht wissen, ist, dass print()
und println()
die Anzahl der geschriebenen Bytes zurückgibt. Und worüber viele vielleicht bisher nicht nachgedacht haben, ist, was print()
und println()
eigentlich übertragen.
Quizfrage: Was gibt die folgende Codezeile auf dem seriellen Monitor aus?
Serial.println(Serial.println(1000));
Zunächst einmal erscheint auf dem seriellen Monitor natürlich die 1000 und dann der Rückgabewert und der lautet 6. Zwei Bytes entfallen auf den Zeilenvorschub (Line Feed, ASCII-Code: 10) und den Wagenrücklauf (Carriage Return, ASCII-Code: 13). Die „1000“ wird nicht als Zahl, sondern als Zeichenkette übertragen, d. h. die Eins als ASCII-Code 49 und die Nullen als ASCII-Code 48. Ihr könntet also genauso gut Serial.println("1000");
verwenden. Serial.print(1000);
gäbe 4 zurück, da Line Feed und Carriage Return entfallen.
Zeiger werden von print()/println()
nicht akzeptiert, mit Ausnahme von char*
(Character Arrays). Auch Zahlen größer 32 Bit (z.B. long long / int64_t) sind nicht über diese Funktionen darstellbar.
Als format
könnt ihr bei Ganzzahlen das gewünschte Zahlensystem (BIN, OCT, DEC, HEX) angeben und bei den Fließkommazahlen die Anzahl der Nachkommastellen.
Noch ein Quizfrage: Was gibt Serial.println(Serial.println(1000, BIN);
aus? Antwort: Erst einmal die 1000 im Binärformat (= 10-Bit Zahl) und dann den Rückgabewert 12. Jedes Bit der Binärzahl wird vor der Übermittlung in den ASCII-Code für 0 bzw. 1 eins übersetzt. Nicht sehr effektiv! Am schnellsten werden Zahlen im Hexadezimalsystem übertragen.
write()
size_t numberOfBytesWritten = Serial.write( aByteToWrite);
size_t numberOfBytesWritten = Serial.write( aCharacterArrayToWrite );
size_t numberOfBytesWritten = Serial.write( aCharacterArrayToWrite, length);
Im Gegensatz zu print()
interpretiert write()
übergebene Zahlen als ASCII-Code. So gibt Serial.write(65);
beispielsweise ein „A“ aus.
Hier ein kleiner Sketch, der die Funktion von Serial.write()
verdeutlicht:
void setup() { Serial.begin(9600); char charArray[6] = "ABCDE"; Serial.print("Serial.write(\'A\'): "); Serial.write('A'); Serial.println(); Serial.print("Serial.write(\"A\"): "); Serial.write("A"); Serial.println(); Serial.print("Serial.write(65) : "); Serial.write(65); Serial.println(); Serial.print("Serial.write(321): "); Serial.write(321); Serial.println(); Serial.print("Serial.print(Serial.write(65)): "); Serial.print(Serial.write(65)); Serial.println(); Serial.print("Serial.write(charArray) : "); Serial.write(charArray); Serial.println(); Serial.print("Serial.write(charArray, 3) : "); Serial.write(charArray, 3); Serial.println(); } void loop() {}
Und hier die Ausgabe:
Dazu zwei Anmerkungen:
Serial.write(65)
gibt aus oben beschriebenen Gründen nur 1 Byte zurück.- 321 ist zu groß für
Serial.write()
. Alle oberen Bytes werden abgeschnitten. 321 / 256 = 1, mit Rest 65. Nur der Rest wird übernommen.
Lese-Funktionen
Die zur Verfügung stehenden Lese-Funktionen verarbeiten den empfangenen ASCII-Code. Die Funktionen unterscheiden sich im Wesentlichen dadurch, wie die Zeichen interpretiert werden und wie viele Zeichen „am Stück“ gelesen werden.
read() / readBytes() / readBytesUntil() / peek()
1. read()
int incomingByte = Serial.read();
Der Rückgabewert von Serial.read()
ist ein Integer (auch wenn byte, uint8_t oder char ausgereicht hätte). Versendet der Sender ein „A“, dann ist der Rückgabewert von Serial.read()
auf der Empfängerseite die Zahl 65. Gebt ihr diesen Wert mit Serial.println()
auf dem seriellen Monitor aus, wird er als Integer interpretiert und es erscheint „65“. Mit Serial.write()
wird der Wert in den Typ char umgewandelt und ihr bekommt ein „A“.
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()) { int incomingByte = Serial.read(); // Serial.find('\n'); // or: find('\r'), empties the buffer Serial.println(incomingByte); Serial.write(incomingByte); Serial.println(); } }
Wenn ihr den Sketch ausprobiert und im seriellen Monitor nicht „Kein Zeilenende“ eingetragen habt, dann werden das Line Feed und / oder das Carriage Return mit übertragen. Es liegt nahe, die Zeichen abzuschneiden, indem ihr den RX-Puffer nach dem Erhalt des Zeichens leert. Dafür gibt es keine separate Funktion. Ihr könntet euch mit Serial.find('\n')
(LF) oder Serial.find('\r')
(CR) behelfen, je nachdem welche Einstellung ihr gewählt habt. Sucht ihr nach dem falschen Zeichen, blockiert ihr das Programm für eine Sekunde (Timeout).
2. readBytes()
size_t numberOfBytesWrittenToBuffer = Serial.readBytes( buffer, length );
Mit Serial.readBytes() schreibt ihr die gelesenen Bytes direkt in ein Character Array oder Byte Array. Mit der Länge der zu lesenden Zeichen ist etwas tricky. Bei einem Character Array müsst ihr darauf achten, das letzte Zeichen in buffer
für den Null-Terminator (‚\0‘) zu reservieren. Aber es gibt noch andere Dinge zu berücksichtigen.
Ladet den folgenden Sketch hoch:
void setup() { Serial.begin(9600); } void loop() { char buffer[13] = {'\0'}; if(Serial.available()) { Serial.readBytes(buffer, 12); //Serial.find('\n'); // empties the RX buffer in case you set "Both NL & CR" Serial.println(buffer); } }
Stellt den seriellen Monitor auf „Kein Zeilenende“ ein. Dann sendet diese drei Zeichenketten (ohne die Anführungsstriche):
- „Hello World!“
- „Hello World“
- „Hello World!!“
Nachricht 1 erscheint ohne merkliche Verzögerung. 12 Zeichen stehen im RX-Puffer bereit und die werden sofort eingelesen.
Die Nachricht 2 erscheint erst nach ca. einer Sekunde. Da ein Zeichen zu wenig im RX-Puffer ist, wartet das Programm 1000 Millisekunden, ob nicht doch noch ein Zeichen kommt. Falls nicht, werden nur die bis dahin vorhandenen Zeichen in buffer
geschrieben.
Bei Nachricht 3 erscheint „Hello World!“ sofort. Das zweite Ausrufezeichen verbleibt im RX-Puffer und wird im nächsten Durchgang abgeholt. Da aber die 12 Zeichen unterschritten werden, wartet das Programm wieder eine Sekunde.
Quizfrage: Was passiert bei Eingabe von „Hello World!Hello World!“? Probiert es aus.
Um alle Zeichen nach dem Zwölften abzuschneiden, könntet ihr Zeile 9 entkommentieren, nehmt damit aber die Timeout-Verzögerung in Kauf, sofern das letzte Zeichen nicht ein Line Feed ist. Ein Line Feed erzeugt ihr durch die Einstellung „Sowohl NL als auch CR“ bzw. „Neue Zeile“ oder ein println()
, falls ihr die Zeichenkette von einem anderen Board aus sendet.
3. readBytesUntil()
size_t numberOfBytesWrittenToBuffer = Serial.readBytesUntil( terminatingChar, buffer, length );
Die Funktion Serial.readBytesUntil()
beendet das Einlesen in buffer
, wenn sie auf das gesuchte Zeichen (terminating char) trifft, die vorgegebene Länge erreicht ist oder im Falle eines Timeouts. Das können wir nutzen, um Zeichenketten unbekannter Länge, aber definiertem letzten Zeichen ohne Verzögerung einzulesen. Ladet den folgenden Sketch hoch:
void setup() { Serial.begin(9600); } void loop() { char buffer[20] = {'\0'}; if(Serial.available()) { Serial.readBytesUntil('\n', buffer, 19); Serial.println(buffer); } }
Im seriellen Monitor stellt ihr „Sowohl NL als auch CR“ oder „Neue Zeile“ ein. Oder, falls ihr die Zeichenkette von einem anderen Board sendet, tut das mit println()
.
4. peek()
int incomingByte = Serial.peek();
Serial.peek()
ist wie Serial.read()
, nur wird das gelesene Zeichen nicht aus dem RX-Puffer gelöscht.
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()) { int incomingByte = Serial.peek(); Serial.write(incomingByte); } }
Die Eingabe von „Hello World!“ erzeugt die folgende Ausgabe:
readString() / readStringUntil()
String incomingString = Serial.readString();
String incomingString = Serial.readStringUntil( searchChar );
Die Funktion readString()
liest den Inhalt des RX-Puffers ein und gibt ihn als String zurück. Die Funktion wartet bis zum Timeout, „ob noch etwas kommt“. Das kann störend sein. Wir kommen gleich dazu, wie man das beschleunigen kann.
Mit readStringUntil(searchChar)
lest ihr den RX-Puffer zunächst nur bis zu dem searchChar
. Das Zeichen selbst wird nicht in den String übernommen. Falls das gesuchte Zeichen noch nicht das letzte Zeichen ist, beginnt das Spiel mit dem Rest des RX-Pufferinhaltes von vorn.
Hier ein Beispiel:
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()) { String incomingString = Serial.readStringUntil('o'); //Serial.readStringUntil('\n'); // empties the RX buffer Serial.println(incomingString); } }
Wenn ihr „Hello World“ über den seriellen Monitor sendet, dann seht ihr, dass die ersten beiden Fragmente schnell ausgegeben werden und das letzte Fragment wegen des Timeouts erst nach einer Sekunde.
Wenn ihr den Rest des Puffers nach dem ersten Fragment ignorieren wollt, dann entkommentiert Zeile 8 und übergebt ein Line Feed durch Einstellen von „Sowohl NL als auch CR“ im seriellen Monitor. Falls ihr die Zeichenkette von einem anderen Board aus sendet, verwendet println()
auf der Senderseite.
Hier die Ausgabe:
Wenn ihr ganze Zeichenkette verzögerungsfrei einlesen wollt, dann könntet ihr readStringUntil('\n');
verwenden.
Finde-Funktionen
find() / findUntil()
bool found = Serial.find( searchChar );
bool found = Serial.find( searchChar, length );
bool found = ( Serial.findUntil( *searchArray, *terminatingArray );
Mit Serial.find()
lest ihr den Puffer bis zu dem gesuchten searchChar
. Gebt ihr zusätzlich eine Länge an, dann stoppt die Funktion an der Stelle length
, sofern searchChar
nicht vorher gefunden wurde. Hier ein Beispiel:
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()) { Serial.find('l'); String incomingString = Serial.readString(); Serial.println(incomingString); } }
Ausgabe bei Eingabe von „Hello World“:
Alternativ sucht ihr ein Character Array vor einem Character Array. Streng genommen nimmt die Funktion nur Zeiger entgegen. „Sauber“ ist es also nur wie folgt, selbst, wenn ihr nur einzelne Zeichen sucht:
void setup() { Serial.begin(9600); } void loop() { char WArray[2] = "W"; char oArray[2] = "o"; if(Serial.available()) { if(Serial.findUntil(WArray, oArray)){ Serial.println("Found a \"W\" before \"o\" or end"); } else { Serial.println("No \"W\" before \"o\" or no \"o\""); } } }
Bei Verwendung von Serial.findUntil('W', 'o')
, bricht die Kompilierung mit einer Fehlermeldung ab. Serial.findUntil( "W", "o")
läuft durch, aber je nach Warnstufe in den Einstellungen der Arduino IDE mit einer Warnmeldung.
Die Eingabe von „Hello Word!“ gibt die folgende Ausgabe:
Erklärung: Bis zum ersten „o“ ist kein „W“ vorhanden → false. Im nächsten Loop-Durchlauf findet die Funktion das „W“ vor dem zweiten „o“ → true . Die Funktion stoppt nach dem „W“ und sucht nach einem weiteren „W“ vor dem „o“. Es gibt gar kein Zeichen mehr vor dem „o“ also → false. Im letzten Teilstück gibt es kein „o“, also false. Beim letzten Teilstück greift wieder der Timeout. Versucht einmal „Wieso“. Es ergibt auch zwei Meldungen, aber beide erscheinen prompt, denn nach dem „o“ ist der RX-Puffer leer.
parseInt() / parseFloat()
parseInt()
long aNumber = parseInt();
long aNumber = parseInt( lookahead );
long aNumber = parseInt( lookahead, ignore );
Die Funktion parseInt()
sucht im Puffer nach der nächsten Ganzzahl. Aufgrund ihres Rückgabewertes müsste sie eigentlich „parseLong“ heißen.
Hier ein Beispielsketch für den Aufruf ohne Parameter:
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()){ int incomingInt = Serial.parseInt(); Serial.println(incomingInt); incomingInt = Serial.parseInt(); } }
Die Eingabe von „blablabla-42blabla12345“ ergibt:
Hängt ihr an die Eingabe noch ein „blabla“ an, würdet ihr im letzten Durchlauf eine 0 bekommen.
Als Parameter lookahead
könnt zwischen folgenden Optionen wählen:
- SKIP_ALL: Das ist die Standardeinstellung, die auch greift, wenn kein Parameter übergeben wird. Alle Zeichen außer Ziffern und Minus werden ignoriert.
- SKIP_NONE: Ist das erste Zeichen keine Ziffer oder ein Minus, dann stoppt die Funktion und gibt 0 zurück.
- SKIP_WHITESPACE: Nur Leerzeichen, Tabs, Line Feeds und Carriage Returns werden übergangen. Trifft die Funktion ein anderes Zeichen, das keine Ziffer oder ein Minus ist, stoppt die Funktion und liefert 0 zurück. Auf “ -42blabla2345″ angewendet, würde die Funktion die -42 finden, dann aber im nächsten Durchlauf eine 0 zurückgeben.
Der Parameter ignore
ist ein Character, der ignoriert werden soll. Wenn ihr beispielsweise parseInt( SKIP_ALL, ',')
verwendet, wird die Zeichenkette „32,125“ als 32125 zurückgegeben.
parseFloat()
float aFloat = parseFloat();
float aFloat = parseFloat( lookahead );
float aFloat = parseFloat( lookahead, ignore );
In aller Kürze: parseFloat()
funktioniert genauso wie parseInt()
, außer, dass neben den Ziffern und dem Minus auch der Punkt gelesen wird. Der Rückgabewert ist ein Float.
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()){ float incomingFloat = Serial.parseFloat(); Serial.println(incomingFloat); } }
Eine Eingabe von „bla42blabla.bla12.345blab.78labla-4.8“ über den seriellen Monitor erzeugt:
Weitere Funktionen
begin() / end ()
Serial.begin( baudRate );
Serial.begin( baudRate, config );
Serial.end();
Auf Serial.begin()
bin ich schon hier eingegangen. Mit Serial.end()
beendet ihr Serial. Das zu nutzen ist eine Überlegung wert, falls ihr beispielsweise nur etwas während des Setups auf dem seriellen Monitor ausgeben wollt. Durch die Beendigung von Serial steht euch mehr SRAM zur Verfügung.
available() / availableForWrite()
int availableBytes = Serial.available();
int availableBytesToSendWithoutBlocking = Serial.availableForWrite();
Mit available()
prüft ihr, ob Daten im RX-Puffer vorliegen bzw. um wie viele Bytes es sich handelt. Hier ein Beispielsketch:
void setup() { Serial.begin(9600); } void loop() { if(Serial.available()) { delay(100); int availableBytes = Serial.available(); Serial.print("Available Bytes: "); Serial.println(availableBytes); Serial.println(Serial.readString()); availableBytes = Serial.available(); Serial.print("Available Bytes: "); Serial.println(availableBytes); } }
Eine Eingabe von „Hello World!“ im seriellen Monitor ergibt:
Die Funktion availableForWrite()
prüft den freien Platz im TX-Puffer. Wenn ihr mehr Bytes versenden bzw. schreiben wollt, als Platz im Puffer vorhanden ist, wird der Prozess blockierend.
flush()
Serial.flush();
Die Funktion lässt das Programm warten, bis die Übertragung von Daten aus dem TX-Puffer abgeschlossen ist. Sie dient nicht dem Leeren des Puffers an sich. Ein Beispiel habe ich hier schon gegeben.
setTimeOut()
void Serial.setTimeout( timeoutInMillisecs );
Auf den Timeout bin ich mehrfach eingegangen. Mit setTimeOut()
könnt ihr ihn modifizieren. Hier ein Beispiel, das die Wirkungsweise veranschaulicht:
void setup() { Serial.begin(9600); for(int i=0; i<5; i++){ Serial.find('x'); Serial.println("Timeout!"); } Serial.setTimeout(3000); for(int i=0; i<5; i++){ Serial.find('x'); Serial.println("Timeout!"); } } void loop() {}
Die erste For-Schleife wird im Sekundentakt durchlaufen, die zweite benötigt drei Sekunden pro Durchlauf.
if(Serial)
Auf Boards mit nativer USB Schnittstelle, wie beispielsweise dem Arduino Zero oder Micro, prüft ihr mit if(Serial)
, ob der serielle Port bereitsteht, nachdem ihr Serial mit Serial.begin()
aktiviert habt. Oder ihr wartet mittels while(!Serial) { ; }
darauf, dass er bereitsteht.
serialEvent()
Die Funktion serialEvent()
solltet ihr nicht mehr verwenden. Boards, die serialEvent()
noch unterstützen, sind der UNO R3, der klassische Nano, der Mega 2560 R3 und der Due.
Falls ihr es trotzdem nicht sein lassen wollt, habe ich hier ein einfaches Beispiel:
void serialEvent(){ Serial.println(Serial.readString()); } void setup() { Serial.begin(9600); } void loop() {}
Die Funktion serialEvent()
wird jeweils am Ende von loop()
aufgerufen, falls Daten zum Abruf bereitstehen. Mit Serial.available()
fahrt ihr besser, da ihr euren Code leichter auf andere Boards übertragen könnt.
Hallo Wolfgang!
Noch eine kleine Bitte/Zusatzfrage zum Kommando ReadBytes(buffer, len) :
Du weist besonders darauf hin, dass man das letzte Zeichen in buffer für den Null-Terminator reservieren muss.
Die Beispiele darunter mit „Hello World!“ würden aber darauf hinweisen, dass die Funktion ReadBytes den Null-Terminator NICHT schreibt. Verstehe ich es richtig, dass ich mir natürlich den Null-Terminator im Puffer selbst dazuhängen muss, wenn ich das Character-Array für Zeichenketten-Operationen (bsp. strcpy() ) verwenden möchte, oder wird von der Funktion ReadBytes wirklich hinter den angeforderten n Bytes noch zusätzlich ein Null-Terminator angehängt?
Sorry, für die vielleicht pingelige Frage, aber Du kannst es mir wahrscheinlich aus dem Stegreif beantworten.
Vielen Dank für Deine Mühe
Liebe Grüße aus Wien
Bei einem Character Array müsst ihr darauf achten, das letzte Zeichen in buffer für den Null-Terminator (‚\0‘) zu reservieren.
Hallo Johann,
readByte() schreibt keinen Null-Terminator. Du kannst ja mal den folgenden Sketch probieren:
Die Initialisierung des Character Arrays sorgt dafür, dass der 10te Character ein Null-Terminator ist. Gibst du nichts im seriellen Monitor ein, bekommst du als Ausgabe:
1 2 3 4 5 6 7 8 9 ▢
Gibst du z.B. ein XYZ im seriellen Monitor ein, dann bekommst du als Ausgabe:
X Y Z 4 5 6 7 8 9 ▢
Wenn du das Character Array mit
initialisierst, dann sind alle Character Null-Terminatoren. Aber die readByte() Funktion verhält sich neutral hinsichtlich der Nullterminatoren.
VG, Wolfgang
Hallo Wolfgang!
Zum Thema ESP32 HardwareSerial versus SoftwareSerial bin ich doch auf Unterschiede gestoßen:
Ich verwende den gleichen Source-Code sowohl für ESP8266 mit Software-Serial als auch auf ESP32 mit HardwareSerial. Bitte nicht verwirren lassen, dass mein HardwareSerial-Objekt auf dem ESP32 trotzdem „SoftSerObj“ benannt ist.
//****** Code entfernt, da schlecht lesbar in HTML – Umgebung ********//
// Bitte um Verständnis, Wolfgang //
Der Unterschied welcher mir hier auffällt: Mit SoftwareSerial werden die GPIOs für RX und TX bei der Objekt-Definition bekannt gegeben. Bei der ESP32-HardwareSerial werden die GPIOs für RX und TX beim begin()-statement bekannt gegeben.
Sorry, der Source-Code kommt hier nur verstümmelt an, weil die include-statements abgeschnitten sind, aber ich hoffe, dass der Sinn meiner Darstellung trotzdem verständlich ist….
?Wie kann man Code in einen Kommentar einfügen? Gibt es hier spezielle Tags?
Liebe Grüße
Johann
Hallo Johann,
die Kommentarfelder werden als HTML interpretiert. Dadurch gehen z.B. Einrückungen verloren. Das kann man durch eine <pre> … </pre> Umfassung ändern. Nerviger sind die eckigen Klammern, da versucht wir, sie als HTML Anweisung zu interpretieren. Ein < muss man durch < und ein > durch > ersetzen.
Und wer mehrere Kommentare hintereinander schreibt muss damit rechnen, dass die Spam-Prüfung zuschlägt. Die Kommentare sind nicht verloren, aber müssen manuell freigegeben werden.
VG, Wolfgang
Hallo Johann,
danke für den Hinweis. Dass man bei bezüglich der Pinzuweisung bei SoftwareSerial ggü. HardwareSerial gibt, sollte aus dem SoftwareSerial Abschnitt hervorgehen. Und dass die Pinzuweisung beim ESP32 mit begin() erfolgt, sollte auch aus dem Beispielsketch hervorgehen. Aber ich kann natürlich nicht alles abdecken. Jeder Mikrocontroller hat seine Eigenheiten, die z.B. festlegen, ob Pins überhaupt neu zuordbar sind und jedes Boardpaket und jede SoftwareSerial Bibliothek kann ihre Eigenheiten haben.
Ich habe mir erlaubt den Code aus deinem Kommentar herauszunehmen. Zu den Eigenheiten „Code im Kommentarfeld“ hab ich etwas als Antwort zu deinem anderen Kommentar geschrieben.
VG, Wolfgang
Hallo!
Danke für diesen interessanten Artikel!
Folgende Punkte wären vielleicht noch einer Betrachtung wert:
+) Die „alten“ seriellen RS232-Schnittstellen auf PCs und Modems haben mit +12V bis -12V gearbeitet. Da wäre es selbst mit Spannungsteiler für Arduino und ESP32 Hardware-problematisch.
+) Wie ändert man für eine Verbindung „on the fly“ die Baudrate (z.B. wenn man mit einem AT-Befehl die Baudrate des Gegenübers umstellt und dann auf der eigenen Seite nachziehen muss.
+) Besondere Bitte von mir: Kannst Du vielleicht auch die ESP32-Spezifische „Hardwate-Serial“ erklären und auch die Unterschiede zwischen Arfuino und ESP32/ESP8266 im Bereich der seriellen Schnittstelle?
Vielen Dank für Deine unermüdlichen Bemühungen
Hallo Johann,
vielen Dank für das „Danke“! Zu den Fragen:
1) Dafür gibt es den MAX3232 Chip. Entweder „nackt“ oder auch als Modul, einschließlich mit RS232 Buchse.
2) Dafür würde ich mit Serial.end() und Serial.begin( neueBaudrate ); arbeiten.
3) Die Frage ist sehr allgemein gestellt. Die positive Nachricht: Alles Serial Funktionen sind 1:1 übertragbar. Jedenfalls kommt mir keine Ausnahme in den Sinn. Auf den ESP32 bin ich ja etwas eingegangen. Er hat drei UART Ports und die lassen sich anderen Pins zuordnen wie ich an einem Beispiel gezeigt habe. Auch hatte ich gezeigt, wie man einen ESP32 mit anderen Boards über HardwareSerial kommunizieren lässt. Dann noch, dass die Puffer des ESP32 größer sind (256 Bytes) Und, nicht zu vergessen, dass man mit den Spannungsleveln aufpassen muss. Der ESP8266 hat zwei UART Ports, wovon einer auf alternative Pins umgeleitet werden kann, siehe auch hier: https://wolles-elektronikkiste.de/wemos-d1-mini-boards. Vom zweiten Port (UART1) ist nur TX nutzbar, da der RX Pin gleichzeitig für die Verbindung zum Flash-Speicher genutzt wird. Mehr fällt mir so spontan nicht ein.
VG, Wolfgang