Das Titelbild dieses Beitrags ist von Vladimir163rus auf Pixabay.

Eine neue Arduino-Bibliothek hat das Licht der Welt erblickt: SingleWireSerial. Sie unterstützt einadrige asynchrone serielle Halbduplex-Kommunikation. Durch die Verwendung der Eingangserfassungsfunktion der AVR-MCUs ist sie extrem genau und unterstützt Bitraten von bis zu 250 kbps. Und konträr zum Titel kann man sie auch für Zweidraht-Verbindungen verwenden.

Hintergrund

Anfang dieses Jahres hatte ich an einem Hardware-Debugger gearbeitet, der das debugWIRE-Protokoll unterstützt. Aber der Debugger lief nicht zuverlässig. Ein Problem war die serielle Kommunikation über nur eine Leitung (der RESET-Pin der MCU). Die existierenden Software-Lösungen waren alle nicht robust genug. Also habe ich mich vor kurzem daran gemacht, eine eigene Lösung zu programmieren, was dann zur SingleWireSerial-Bibliothek geführt hat. Diese erfüllt die folgenden drei Anforderungen:

  1. serielle Eindraht-Kommunikation,
  2. extrem genau und robust bis zu 125 kbps,
  3. die Kommunikationsgeschwindigkeit kann zur Laufzeit eingestellt werden.

Normalerweise erfolgt die asynchrone serielle Kommunikation über zwei Drähte. Möchte man die Kommunikation auf nur einen Draht beschränken, ist dies möglich. Allerdings ist dann ein Protokoll erforderlich, das bestimmt, welche Partei Daten senden darf. Dies alles kann auf der Software-Ebene gelöst werden. Auf der Hardware-Ebene muss man eine Lösung finden, die es beiden Parteien ermöglicht, auf nur einem Draht zu senden und zu empfangen. Oder man behandelt dieses Problem auch auf der Software-Ebene.

Wenn man die Raumtemperatur übertragen will, dann ärgert sich niemand, wenn irgendwann mal kurzzeitig die falsche Temperatur angezeigt wird. Wenn man ein System debuggt und aufgrund eines Kommunikationsfehlers ein falscher Wert angezeigt wird, dann ist das jedoch extrem ärgerlich. DebugWIRE hat leider keinerlei Form der Fehlererkennung, sodass man sich darauf verlassen können muss, dass es keine Übertragungsfehler gibt. Da die Kommunikationsgeschwindigkeit auf den Systemtakt geteilt durch 128 eingestellt ist, beträgt die Kommunikationsgeschwindigkeit bei 16 MHz Systemtakt 125 kbps. Wir brauchen also eine zu 100 % fehlerfreie serielle Kommunikation bei 125 kbps!

Schließlich ist es natürlich am einfachsten, wenn die Kommunikationsgeschwindigkeit zur Übersetzungszeit im Voraus bekannt ist. In diesem Fall ist picoUART wahrscheinlich die beste Alternative. Bei debugWIRE erfährt man jedoch erst zur Laufzeit, welche Kommunikationsgeschwindigkeit einzustellen ist. Darüber hinaus möchte man der Kommunikationsgeschwindigkeit der MCU, die debuggt werden soll, so nahe wie möglich kommen. Diese kann von den Standardgeschwindigkeiten abweichen, da die Taktfrequenz oft von einem internen RC-Oszillator gesteuert wird.

Eindraht-Kommunikation: Hard- oder Software-Lösung?

Man kann eine serielle Eindrahtlösung grundsätzlich auf zwei Arten realisieren. Erstens kann man die TX- und RX-Leitungen eines UARTs miteinander verbinden. Zweitens kann man von Anfang an nur eine Leitung nutzen.

Die Zusammenführung von TX und RX erfordert einiges an externer Hardware. Die Microchip Application Note AN2658 beschreibt dies ausführlich und verwendet zwei Transistoren – einen Inverter und einen Open-Collector-Treiber. Ein ähnlicher Effekt kann einfacher erreicht werden, indem man einen Pull-up-Widerstand an die RX-Leitung legt und eine Diode zwischen die RX- und TX-Leitung mit der Anode an der RX-Leitung legt, wie im nächsten Bild skizziert.

TX und RX verbinden

Ein hoher Pegel auf der TX-Leitung hat keine Wirkung, d.h. die gemeinsame Leitung wird vom Widerstand nach oben gezogen. Ein niedriger Pegel auf der TX-Leitung zieht die gemeinsame Leitung nach unten. Je nachdem, welche Art von Diode man verwendet, wird sie jedoch nur auf 0,3-0,7 Volt heruntergezogen. Dies sollte für alle praktischen Zwecke ausreichen, da CMOS-Chips einen niedrigen Pegel bis zu 0,3Vcc erkennen.

Die Verwendung dieser minimalen Hardware ermöglicht es, einen Hardware-UART für die Eindrahtleitung zu verwenden. Ein ärgerlicher Nebeneffekt dieser Hardware-Lösung ist jedoch, dass jedes gesendete Byte auch empfangen wird und ignoriert werden muss.

Die zweite Art von Lösung verwendet Bit-Banging, d.h. man steuert einen Pin durch Software. Durch Umschalten der Richtung eines Pins zwischen Eingang und Ausgang (mit niedrigem Pegel) erzeugt man einen Open-Drain-Ausgang, ähnlich dem obigen. Wenn man auf Eingaben wartet, muss der Pin als Eingang konfiguriert werden. Die üblichen Software-UART-Bibliotheken unterstützen dies jedoch nicht. Es ist aber nicht schwierig, sie anzupassen. OnePinSerial ist eine solche Anpassung von SoftwareSerial für den Einsatz in einem debugWIRE-Debugger. Allerdings ist diese Bibliothek nicht sehr robust. Wenn der millis-Interrupt aktiviert ist, was im beschriebenen Debugger der Fall ist, dann empfängt OnePinSerial nicht zuverlässig bei 125 kbps. Und auch wenn der millis-Interrupt deaktiviert ist, gibt es immer wieder Empfangsprobleme.

Eingangserfassung und Ausgangsvergleich

Ein Problem mit Bit-Banging-UART-Lösungen besteht darin, dass man sich auf die Codegenerierung des Compilers verlassen muss, um das richtige Timing zu erzeugen. Und die Codegenerierung kann für verschiedene Compilerversionen unterschiedlich sein. Aus dem Grund findet man in der SoftwareSerial-Klasse Code, der abhängig von der Compilerversionen unterschiedlich kompiliert wird. Natürlich könnte man Inline-Assemblercode verwenden, der einem die volle Kontrolle darüber gibt, welche Maschinenanweisung ausgeführt wird. Eine Lösung, die mit dieser Technik eine zur Laufzeit konfigurierbare Kommunikationsgeschwindigkeit implementiert, ist allerdings eine gewisse Herausforderung.

Ein zweites Problem ist, dass Interrupts, z.B. der millis-Interrupt, das Timing beim Empfang eines Bytes stören kann. Die SoftwareSerial-Klasse verwendet den Pin-Änderungs-Interrupt, um die fallende Flanke des Startbits zu erkennen. Wird der millis-Interrupt kurz vor dem Eintreffen des Startbits ausgelöst, dann könnte die Empfangs-Interrupt-Routine im schlimmsten Fall 6,7 μs zu spät starten. Für langsame Bitraten kann dies tolerierbar sein. Bei 125 kbps beträgt die Bitzeit jedoch lediglich 8 μs, sodass vermutlich Fehler beim Empfang dieses Bytes auftreten werden.

Die AltSoftSerial-Bibliothek, die ich mir in einem früheren Blogbeitrag angesehen habe, verwendet eine Funktion namens Eingabeerfassung (engl. Input Capture). Damit kann man bestimmte Ereignisse, z. B. eine fallende Flanke, mit einem Zeitstempel versehen. Der Wert eines Timers zu diesem Zeitpunkt wird im Input Capture Register (ICR) gespeichert und optional wird ein Interrupt ausgelöst. Mit diesem Feature erhält man immer den genauen Zeitpunkt, zu dem ein Startbit beginnt, auch wenn zu diesem Zeitpunkt ein anderer Interrupt aktiv war. Zeichnet man nach dem Startbit auch die Zeiten der folgenden Flanken auf, kann man ein übertragenes Byte rekonstruieren.

Zusätzlich braucht man auch einen Timer, der signalisiert, wann das Byte endet. Dies kann mithilfe der Ausgabevergleichsfunktion (engl. Output Compare) erreicht werden. Hier schreibt man einen Wert in das Output Compare Register (OCR) und wenn der Timer mit dem Wert übereinstimmt, wird eine vorkonfigurierte Aktion wie das Ändern des Pegels eines Ausgangspins oder ein Interrupt ausgelöst. Indem man den Wert so einstellt, dass nach 8,5 Bitzeiten ein solcher Interrupt ausgelöst wird, kann man alle bis dahin gesammelten Informationen nutzen, um das empfangene Byte zu rekonstruieren.

Beschleunigen des Empfangsprozesses

Ein Problem mit der AltSoftSerial-Bibliothek besteht darin, dass für jede Flanke in einem übertragenen Byte ein Interrupt ausgelöst wird und dass am Ende eines übertragenen Bytes relativ viel Rechenaufwand anfällt. Damit ist es unmöglich, hohe Bitraten, d.h. 115200 bps und höher, zu realisieren.

Um dieses Problem zu beheben, habe ich alle oben beschriebenen Dinge in eine Interrupt-Routine gestopft. Das hat einigermaßen funktioniert, war aber nicht wirklich zuverlässig. Um der Sache auf den Grund zu gehen, habe ich (mal wieder) meinen Saleae Logikanalysator benutzt. Ohne den wäre es wahrscheinlich schwer gewesen, die Probleme zu verstehen.

Hier ist das Setup, das den Logikanalysator, das FTDI-Board, das Bytes mit unterschiedlichen Kommunikationsgeschwindigkeiten sendet, und den Arduino Uno, auf dem die neue Bibliothek ausgeführt wird, zeigt. Übrigens: Ich habe die gleichen Arduino-Programme und Python-Skripte verwendet wie bei dem Vergleich verschiedener serieller Bibliotheken, um die neue Bibliothek zu testen.

Versuchsaufbau

Ich habe etwas Code eingefügt, um kurze Blips an kritischen Stellen in der Interrupt-Routine zu generieren. Das half dabei, zwei Probleme zu lokalisieren: Die Interrupt-Service-Routine benötigte zu viel Zeit für das Starten und sie benötigt zu viel Zeit, um die ISR zu beenden.

Erstens war die Zeit zwischen der fallenden Kante des Startbits und dem Zeitpunkt, an dem das ICR ausgelesen wurde, ziemlich lang. Dies sieht man im nächsten Bild, das zeigt, was bei 125 kbps passiert.

Timing des ISR-Starts

In der zweiten Spur sieht man zwei Blips. Der erste signalisiert den Zeitpunkt, zu dem das ICR ausgelesen wurde. Der zweite markiert, wann alles so eingerichtet ist, dass das eingehende Byte aufgezeichnet werden kann. Es dauert 2,7 μs, um zu dem Punkt zu kommen, an dem die ISR den Inhalt des ICR speichert und die Eingangserfassung neu konfiguriert, um steigende Kanten aufzuzeichnen. Ein großer Teil dieser Zeit erfordert dabei das Sichern von Registern auf dem Stack. Insgesamt werden 15 Register gesichert, was etwa 2 μs in Anspruch nimmt. Darüber hinaus dauert es (im schlimmsten Fall) 10 Zyklen, bis nach dem Auslösen des Interrupts die ISR gestartet wird. Der digitale Tiefpass kostet dann weitere 4 Zyklen. Alles zusammen ergibt das dann die gemessene Zeit von 2,7 µs.

Wird kurz vor der fallenden Flanke des Startbits ein millis-Interrupt ausgelöst, kann dies dazu führen, dass eine Flanke und damit ein Bit fehlt, d.h. man bekommt einen Lesefehler.

Die Startzeit konnte ich reduzieren, indem ich die ISR als „nackt“ deklariert habe und dann die Register mit Inline-Assembler-Code gespeichert und wiederhergestellt habe. Glücklicherweise ist bekannt, welche Register man speichern muss. Dies ermöglichte es, den Inhalt des ICR zu speichern, bevor alle Register auf dem Stack gerettet wurden. Das Ergebnis all dessen ist im nächsten Bild zu sehen (wieder bei 125 kbps).

Timing mit einem nackten ISR

Wie man sieht, wurde die Startzeit auf 1,75 μs reduziert (Marker P0). Der zweite Blip ist relativ spät. Dieses Ereignis muss jedoch nur vor 1,5 Bitzeiten stattfinden, was leicht erreichbar ist, selbst wenn ein millis-Interrupt den Start unserer ISR verzögert.

In der dritten (gelben) Spur wird die Schlussphase der ISR zeitlich analysiert. Der erste Blip signalisiert den Punkt, an dem das Byte vollständig gelesen ist, und der zweite Blip markiert den Punkt, kurz bevor die ISR die Instruktion „Return from Interrupt“ ausführt. Wie man sehen kann (Markierung P1), geht dies ziemlich weit in das Stopbit hinein, sodass dem Benutzerprogramm nicht viel Zeit bleibt, das empfangene Byte zu verarbeiten.

Der Weg, mit diesem Problem umzugehen, besteht darin, den Umfang der Nachbearbeitung zu reduzieren und die Anzahl der Register zu reduzieren, die am Ende wiederhergestellt werden müssen. Das Zauberwort heißt mal wieder Inline-Assemblercode. Außerdem wird jetzt das OCR benutzt, um jeweils die zeitliche Mitte eines Bits zu bestimmen. Dies ermöglichte es, die Anzahl der benutzten Register auf 5 zu reduzieren (von vorher 15). Zudem wurde die Nachbearbeitung des empfangenen Bytes darauf reduziert, das Byte im Ringpuffer zu speichern und dann die Register wiederherzustellen.

Endzeitmessung mit Inline-Baugruppen-ISR

Wie man sehen kann (Marker P0), dauert es jetzt knapp 1 μs, um die ISR zu finalisieren. Bei 125 kbps kehrt die ISR also 2,5 μs bevor das letzte Datenbit fertig ist zurück. D.h. man erhält zusätzlich 5 μs, in der das Benutzerprogramm das empfangene Byte verarbeiten kann – im Vergleich zur Vorgängerversion.

Der Inhalt des ICR wird nun bereits nach 1,3 μs gespeichert und die Frage könnte sein, ob dieser kurz genug ist, um zu garantieren, dass die nächste Flanke (der des ersten Datenbytes) den ICR nicht überschreibt. Wie bereits erwähnt, kann der millis-Interrupt im schlimmsten Fall 6,7 μs benötigen. Diese beiden Zeiten summieren sich auf 8,0 μs, was bei 125 kbps eine Bitzeit ist. Aus diesem Grund ist eine detailliertere Analyse erforderlich, die alle Worst-Case-Annahmen berücksichtigt, jedoch alle durch die Messung eingeführten Artefakte eliminiert.

Im schlimmsten Fall benötigt der millis-Interrupt 106 Zyklen. Wir müssen 4 Zyklen eines Befehls hinzufügen, der zwischen der millis-ISR und unserem ISR ausgeführt werden könnte, dann 7 Zyklen für die Verarbeitung des Interrupts und das Springen zur Startadresse und schließlich 4 Zyklen, um ein Register auf den Stack zu retten und das niederwertige Byte des ICR zu lesen. Sobald wir das niederwertige Byte gelesen haben, wird das höherwertige Byte in einem temporären Register gespeichert. Die Verzögerung von 4 Zyklen zur Erkennung einer Flanke aufgrund des digitalen Tiefpasses kann ignoriert werden, da diese Verzögerung für alle Flanken gilt. Auch die 2 Zyklen, die notwendig sind, um den Blip zu erzeugen, können ignoriert werden. Dies ergibt 106 + 4 + 7 + 4 = 121 Zyklen, was 7 weniger als 128 (=8 µs) ist. Mit anderen Worten, wir können leicht mit einem seriellen Bitstrom umgehen, der 5 % schneller als 128 kbps ist.

Was ist mit dem zweiten roten Blip? Der zweite Blip signalisiert den Zeitpunkt, zu dem das OCR eingerichtet wurde, was vor der Mitte des ersten Datenbytes geschehen muss. Wie aus der Messung P2 ersichtlich ist, bleibt noch genügend Zeit. Der dritte rote Blip ist der Zeitpunkt, an dem die ISR bereit ist, das erste Bit zu lesen. Dies sollte geschehen, bevor die erste Bitzeit zum Ende gekommen ist. Unter der Annahme, dass 75 % der Bitzeit nutzbar sind, sollte man es 12,5 % vor dem Ende tun. Offensichtlich kann man problemlos 30 % vor dem Ende der ersten Bitzeit diese Abtastung vornehmen (Marker P3).

Allerdings sind wir mit 121 Zyklen sehr nahe am Limit und wenn sich die Implementierung des millis-Interrupts oder die Codegenerierung des Compilers ändert, dann könnte man in Schwierigkeiten geraten. Für eine garantierte 100 % fehlerfreie Kommunikation (auch in Zukunft) würde ich also den millis-Interrupt deaktivieren, wenn ich die Bibliothek mit einer Bitrate von 125 kbps oder schneller verwende.

Lassen Sie uns abschließend einen Blick darauf werfen, was bei 250 kbps passiert, wenn ein Timer-Interrupt ausgelöst wird.

Timing ist wegen Millis-Interrupt deaktiviert

Marker P0 zeigt, dass die Kommunikations-ISR verzögert ist (höchstwahrscheinlich durch den millis-Interrupt). Anstatt den ICR 1,4 μs nach der fallenden Kante des Startbits zu speichern, dauert es 3,7 μs, bevor dies geschieht. Es wird jedoch kein Schaden angerichtet, da der kritische Zeitpunkt 4 μs nach der fallenden Kante liegt. Das Einrichten der OCR erfolgt jedoch 400 ns zu spät (Marker P1). In der vierten Spur befinden sich deshalb keine gelben Blips, die die Auswertezeitpunkte markieren. Und folglich werden keine Bytes empfangen.

Empirische Ergebnisse

Ich habe die neue Bibliothek auf ähnliche Weise getestet wie die anderen Software- und Hardware-UARTs. Die Ergebnisse werden in der nächsten Tabelle dargestellt. Zusammenfassend sieht es so aus, als ob man die Bibliothek bis zu 125 kbps verwenden kann, selbst wenn der millis-Interrupt aktiv ist. Die Bibliothek kann sogar mit 250 kbps umgehen – der millis-Interrupt (und auch andere Interrupts) muss in diesem Fall jedoch deaktiviert werden.

BitrateTX speed
deviation
RX speed deviation
1200-0.1%-5.9%+5.4%
2400-0.1%-5.8%+5.4%
4800-0.1%-6.0%+5.2%
9600-0.1%-5.8%+5.3%
19200-0.1%-5.7%+5.3%
38400-0.1%-5.7%+4.9%
57600-0.2%-5.3%+5.1%
115200-0.2%-5.3%+4.8%
230400+0.6%-4.2%+5.3%#
7812-0.1%-5.8%+5.2%
15625-0.1%-5.7%+5.2%
31250-0.1%-5.6%+5.1%
62500-0.1%-5.3%+4.9%
125000-0.1%-5.2%+4.9%
250000-0.1%-4.8%+3.5%#
500000-0.1%________
1M-15.9%________
Transmit speed deviation and possible speed deviations when receiving data
('#' = no millis interrupt, '*' = 2 stop bits)

Zusammenfassung

Die neue SingleWireSerial-Bibliothek arbeitet sehr zuverlässig und robust bis zu 250 kbps. Sie implementiert eine serielle Schnittstelle über einen einzigen Draht, kann aber auch in einer Zweidrahtkonfiguration verwendet werden. Daher werde ich es wahrscheinlich in Zukunft in allen Kontexten verwenden, in denen ich SoftwareSerial zuvor verwendet habe, vorausgesetzt, die entsprechenden Pins sind verfügbar. Und ich plane SingleWireSerial bei der Implementation eines debugWIRE-Debuggers einzusetzen.

Views: 85