Fallstricke bei seriellen Kommunikationsanwendungen — von der Wiederverbindung bis zum Log-Design
· Go Komura · Serielle Kommunikation, RS-232, C#, .NET, Windows-Entwicklung, Gerätekopplung
Gerätekopplung, Messgeräte, SPS, Barcode-Leser, USB-Seriell-Wandler. Serielle Kommunikation wirkt wie alte Technik, wird aber in der Praxis von Windows-Anwendungen noch ziemlich häufig eingesetzt.
Etwas gefährlich ist, dass sich serielle Kommunikation mit nur einem COM-Port und nur einem Read / Write beginnen lässt. Der Verbindungstest funktioniert sofort, aber im Produktivbetrieb neigen dann Symptome wie diese dazu, aufzutreten:
- Befehl und Antwort geraten gelegentlich durcheinander
- einmal am Tag hängt sich alles auf
- nur nach dem Ab- und Wiederanstecken von USB erholt sich nichts
- die UI stockt manchmal
- im Log steht nur noch „Timeout“
Was bei seriellen Kommunikationsanwendungen wirklich schwierig ist, ist nicht die Sende-/Empfangs-API selbst, sondern Grenzen, Timeouts, Zustandsübergänge, Wiederverbindung und Beobachtbarkeit.
Zielgruppe und Voraussetzungen dieses Artikels
| Punkt | Inhalt |
|---|---|
| Zielgruppe | Wer Windows-Anwendungen entwickelt, die über eine serielle Schnittstelle mit Geräten oder Messgeräten verbunden sind. Gedacht für alle, bei denen der Verbindungstest zwar funktioniert, im Produktivbetrieb aber „gelegentlich“ etwas ausfällt und dieser Zustand reduziert werden soll |
| Vorausgesetztes Wissen | Die Fähigkeit, Anwendungen in C# zu schreiben. Erfahrung mit serieller Kommunikation selbst wird nicht vorausgesetzt |
| Vorausgesetzte Umgebung | Der Artikel geht von .NETs System.IO.Ports.SerialPort aus, aber die Überlegungen zu Grenzen, Timeouts und Zustandsübergängen sind unabhängig von der Sprache |
| Nicht behandelt | Elektrische Verkabelung, die Protokollspezifikation bestimmter Geräte |
In diesem Artikel verwendete Begriffe
| Begriff | Bedeutung in einem Satz |
|---|---|
| SPS | Speicherprogrammierbare Steuerung (englisch PLC, Programmable Logic Controller). Ein industrieller Controller zur Steuerung von Produktionsanlagen |
| RS-232 / RS-485 | Elektrische Normen der seriellen Kommunikation. RS-232 ist Punkt-zu-Punkt, an RS-485 lassen sich mehrere Geräte an derselben Leitung betreiben. Bei RS-485 kommt es zu Kollisionen, wenn nicht festgelegt ist, wer wann sendet |
| 8N1 | Abkürzung für eine Portkonfiguration. Steht für die Kombination aus 8 Datenbits, keiner Parität (None) und 1 Stoppbit |
| DTR / RTS | Steuerleitungen. Ursprünglich zeigen sie Sendebereitschaft oder eine Sendeanforderung an, aber auf realer Hardware wird dieser Zustandswechsel mitunter als Signal für einen Start oder Moduswechsel genutzt |
| Flusssteuerung | Ein Mechanismus, der zu schnelles Senden verhindert. RTS/CTS nutzt Steuerleitungen, XON/XOFF vermittelt Stopp und Fortsetzung über besondere Zeichen im Datenstrom |
| Keepalive | Ein leichter Befehl, der regelmäßig gesendet wird, um zu prüfen, ob die Gegenstelle noch aktiv ist |
| Frame | Die Byte-Folge für eine Nachricht. Wo ein Frame beginnt und endet, legt das Protokoll fest |
| Single Writer | Ein Design, bei dem das Senden in genau einem Worker gebündelt wird — im Sinne davon, dass man Write nicht von überall aus zulässt |
1. Das Wichtigste zuerst
Vorab in praxisnaher Formulierung zusammengefasst:
- Serielle Kommunikation ist ein geordneter Byte-Stream; Nachrichtengrenzen entstehen nicht von selbst
- Ein
Read(100)bedeutet nicht, dass genau 100 Byte zurückkommen .NETsDataReceivedfeuert nicht garantiert pro empfangenem Byte und läuft zudem nicht auf dem UI-ThreadReadLine()/WriteLine()funktionieren nur dann problemlos, wenn die Gegenseite tatsächlich ein zeilenbasiertes Textprotokoll spricht- Ein einzelner Timeout genügt nicht. Die Bedeutungen
open,inter-byte,response,reconnectzu trennen, macht es stabiler - Statt
Writevon überall zuzulassen, bricht ein Design mit Single Writer seltener zusammen - Bei USB-Seriell ist es entspannter, von Anfang an mit Ab-/Wiederanstecken, Neu-Enumeration, COM-Nummernwechsel und fehlschlagender Wiederverbindung zu rechnen
Kurz gesagt liegt die Schwierigkeit bei seriellen Kommunikationsanwendungen nicht darin, „ob sich der Port öffnen lässt“, sondern darin, wie man die Byte-Folge in bedeutungsvolle Nachrichten umwandelt und wie man Zeit und Zustand drumherum verwaltet.
2. Serielle Kommunikation ist kein „Nachrichten“-, sondern ein „geordneter Byte-Stream“
Aus Sicht der Anwendung sieht serielle Kommunikation so aus, als würde man „einen Befehl senden und eine Antwort empfangen“. Auf der darunterliegenden Ebene fließt aber tatsächlich nur eine geordnete Byte-Folge.
Auch das, was Sie mit einem einzigen Write gesendet haben, kann bei der Gegenseite so ankommen:
- in einem einzigen
Read - in zwei Teilen getrennt
- verkettet mit anderen Daten
Lässt man diese Grundannahme außer Acht, beginnt die Anwendung anzunehmen, „dieses Read muss die aktuelle Antwort sein“. Diese Annahme wird bei seriellen Kommunikationsanwendungen leicht zur ersten Mine.
| Häufige Annahme | Wirklichkeit |
|---|---|
Read(16) liefert genau 16 Byte zurück |
Je nach Ankunft und Timeout kommt womöglich nur ein Teil davon an |
DataReceived = eine Nachricht ist angekommen |
Das Ereignis ist nicht pro Byte garantiert und läuft nicht auf dem UI-Thread |
Write ist zurückgekehrt = die Gegenseite hat die Verarbeitung abgeschlossen |
In den meisten Fällen bedeutet das nur, dass der Sender es in einen Puffer eingereiht hat |
| Die COM-Liste = die aktuelle Wahrheit über bestehende Verbindungen | Die Reihenfolge der Enumeration ist nicht festgelegt, und das Ergebnis kann veraltet sein |
Deshalb muss man bei serieller Kommunikation die Nachrichtengrenzen selbst als Protokoll definieren. Feste Länge, trennzeichenbasiert, Länge + Payload + Checksumme — die Form ist beliebig, aber lässt man das bei der Umsetzung vage, wird es später fast sicher schmerzhaft.
3. Was zuerst festzulegen ist
Bevor man eine serielle Kommunikationsanwendung baut, sollte man mindestens die hier genannten Punkte vorab festlegen.
3.1 Frame-Grenzen
Man legt fest, welche Byte-Folge als eine Nachricht gilt. Feste Länge? Durch Zeilenumbruch getrennt? Mit Längenangabe? Gibt es eine Checksumme / CRC? Bleibt das vage, kann die Empfangsseite nicht unterscheiden, ob „noch nicht genug“ oder „beschädigt“ vorliegt.
3.2 Text, Binär oder eine Mischung
Man legt vorab fest, ob es sich um ein ASCII-/UTF-8-Zeilenprotokoll, reine Binärdaten oder eine Mischung aus beidem handelt. Besonders bei einer Mischung wie „der Befehlsteil ist eine Zeichenkette, das Payload ist binär, nur am Ende steht ein Zeilenumbruch“ bricht die Grenze schnell zusammen, wenn nicht explizit festgelegt ist, bis wohin decodiert wird und ab wo rohe Bytes behandelt werden.
3.3 Die Bedeutung der Timeouts
Ein Timeout ist sicherer, wenn man ihn nicht als einen einzigen Wert, sondern nach Bedeutung getrennt betrachtet.
- Open-Timeout: bis der Port geöffnet ist
- Inter-Byte-Timeout: die Zeit ohne eingehendes Byte mitten in einem Frame
- Response-Timeout: von der Befehlsausgabe bis zum Abschluss der Antwort
- Reconnect-Backoff: das Wartezeitintervall für die Wiederverbindung
Ein Timeout ist stabiler, wenn man ihn nicht als „Absicherung gegen Langsamkeit“, sondern als Regel zum Vorantreiben der Zustandsübergänge betrachtet.
3.4 Flusssteuerung und Leitungszustand
Diese Einstellungen sollte man explizit festlegen:
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
Belässt man es hier bei „8N1 passt ungefähr“, bleibt es je nach Gegengerät ganz gewöhnlich stehen.
3.5 Trennung der Zuständigkeiten
Man legt fest, wer wofür zuständig ist.
- Wer liest
- Wer schreibt
- Wer parst
- Wer die Ergebnisse in den fachlichen Zustand überträgt
Serielle Kommunikation wird umso instabiler, je mehr UI und Kommunikation vermischt werden.
3.6 Zustandsübergänge für Start, Stopp und Wiederverbindung
Mindestens Zustände wie Closed, Opening, Ready, WaitingResponse, Fault und Reconnecting sollten in das Design aufgenommen werden. Direkt nach dem Ab-/Wiederanstecken bootet die Gegenseite vielleicht noch, und man darf mitunter die vorherige ausstehende Anfrage nicht einfach mitschleppen.
stateDiagram-v2
[*] --> Closed
Closed --> Opening: Open angefordert
Opening --> Ready: Open erfolgreich + Initialisierungssequenz abgeschlossen
Opening --> Fault: Open fehlgeschlagen / Berechtigungsfehler / Initialisierungs-Timeout
Ready --> WaitingResponse: Befehl gesendet
WaitingResponse --> Ready: passender Antwort-Frame empfangen
WaitingResponse --> Fault: Response-Timeout
Ready --> Fault: I/O-Fehler / Kabelabzug erkannt
Fault --> Reconnecting: ausstehende Anfrage fehlschlagen lassen, Backoff startet
Reconnecting --> Opening: Backoff abgelaufen
Reconnecting --> Closed: Obergrenze erreicht / manueller Stopp
Ready --> Closed: Close angefordert
Wichtig an diesem Diagramm ist, dass es keine Linie gibt, die direkt von Fault zu Ready zurückführt.
Nach einer Störung durchläuft man immer Reconnecting und Opening und baut Empfangspuffer, Parser-Zustand, ausstehende Anfragen und Initialisierungssequenz neu auf, bevor man zu Ready zurückkehrt. Nimmt man hier eine Abkürzung, landet man bei dem in 4.7 beschriebenen Fehler, „sich durch bloßes erneutes Open() bereits wiederverbunden zu fühlen“.
3.7 Logging und Untersuchbarkeit
Hier entsteht im Nachhinein fast immer das größte Problem. Mindestens sollten die Zeitpunkte von Open / Close / Reopen, die verwendeten Portkonfigurationen, Hex-Dumps der gesendeten und empfangenen Frames, Checksummen-/CRC-Fehler, Frame-Timeouts / Response-Timeouts sowie die Gründe für Wiederverbindungen erhalten bleiben.
4. Häufige Fallstricke
4.1 Man geht davon aus, dass 1 Read = 1 Nachricht gilt
Das ist der häufigste Fall. Nehmen wir an, die Gegenseite liefert einen Frame aus Header, Länge, Payload und CRC zurück. Ruft man dann Read(buffer, 0, expectedLength) einmal auf und geht davon aus, der Rückgabewert sei bereits ein vollständiger Frame, bricht das bei teilweisem Empfang leicht zusammen.
Die drei üblichen Ausfallmuster sind:
- Nur die Länge ist gelesen, das Payload ist noch nicht angekommen
- Nur eineinhalb Frames sind angekommen, die zweite Hälfte fällt in den nächsten
Read - Zwei Frames sind zusammen angekommen, nur der erste wird verarbeitet und der Rest verworfen
Bildlich dargestellt geht es nur darum, dass die vom Gerät gesendete Abfolge und die von Read zurückgegebene Abfolge nicht übereinstimmen.
Was das Gerät gesendet hat
[--- Frame 1 ---][--- Frame 2 ---]
Muster 1: Es kommt nur teilweise an
1. Read -> [ STX ][ LEN ] <- Payload ist noch nicht angekommen
2. Read -> [ Payload ][ CRC ][--- Frame 2 ---]
Muster 2: Es kommt nur eineinhalb Frames an
1. Read -> [--- Frame 1 ---][ erste Hälfte von Frame 2 ]
2. Read -> [ zweite Hälfte von Frame 2 ]
Muster 3: Zwei Frames kommen zusammen an
1. Read -> [--- Frame 1 ---][--- Frame 2 ---] <- oft wird nur eines verarbeitet und der Rest verworfen
In allen drei Mustern ist nichts „beschädigt“ — es liegt lediglich daran, dass die Trennposition nicht mit der Anzahl der Read-Aufrufe übereinstimmt. Verwechselt man das und implementiert es so, dass eine abweichende Anzahl empfangener Bytes sofort als Fehler behandelt wird, beginnt man, normale Kommunikation als Fehler zu zählen.
Die Gegenmaßnahme ist einfach: erst empfangen und sammeln, dann die Frames vom Parser herausschneiden lassen. Den Grundgerüst-Code dazu finden Sie in 5.1.
4.2 DataReceived direkt zum fachlichen Ereignis machen
.NETs SerialPort.DataReceived wirkt praktisch, aber es als „eine Nachricht ist angekommen“ zu verstehen, ist gefährlich. In der Praxis sollte man DataReceived nur als Hinweis „irgendetwas ist wohl angekommen“ betrachten, im Handler keine aufwendige Verarbeitung durchführen und UI-Aktualisierungen stets auf den UI-Thread zurückführen.
4.3 Davon ausgehen, dass man von überall Write aufrufen darf
Ein Aufbau, bei dem UI-Schaltfläche, Überwachungstimer, Wiederverbindungslogik und Keepalive jeweils direkt Write aufrufen, bricht leicht zusammen. Seriell ist ein Byte-Stream, sodass je nach Design Befehle sich überschneiden oder während des Wartens auf eine Antwort weitere Sendungen nachgeschoben werden können. Besonders bei Request-Response-Protokollen und RS-485-artigen Bussen ist es deutlich stabiler, auf Single Writer zu setzen.
4.4 Alles über ReadLine() / WriteLine() abwickeln
Bei einem zeilenbasierten Textprotokoll sind ReadLine() / WriteLine() praktisch. Praktisch sind sie aber nur, wenn es sich tatsächlich um ein Zeilenprotokoll handelt. Bei einer abweichenden NewLine, Zeilenumbrüchen mitten im Payload, unterschiedlichen Zeichenkodierungen oder gemischten Binärdaten bricht die Grenze sofort zusammen.
4.5 Timeouts nicht gestalten und bei der Standardeinstellung belassen
Setzt man sorglos einen synchronen Read ein, führt das ganz gewöhnlich zu einer Endloswartezeit. Zusätzlich tückisch ist, dass ein eingestellter Timeout nicht zwangsläufig für jede Art des Lesens gilt. Implementierungen, die synchron auf dem UI-Thread lesen, versuchen, alles mit einem einzigen Timeout auszudrücken, oder nur die Anzahl der Wiederholungsversuche erhöhen, neigen dazu, ins Stocken zu geraten.
4.6 RTS/CTS, XON/XOFF, DTR/RTS auf die leichte Schulter nehmen
Handshake und Steuerleitungen wirken sich bei realer Hardware erheblich aus. Bei einer Fehlkonfiguration kommt es tendenziell zu Symptomen wie: das Senden stockt gelegentlich, ab einer bestimmten Menge gehen Daten verloren, oder das Verhalten unterscheidet sich nur direkt nach dem Öffnen. Je nach Gerät wird ein Wechsel von DTR/RTS auch als Signal für einen Start oder Moduswechsel interpretiert.
4.7 Sich durch bloßes erneutes Open() bereits wiederverbunden fühlen
Besonders bei USB-Seriell kommt es ganz gewöhnlich vor, dass der Port vorübergehend verschwindet, das alte Handle ungültig wird oder eine zuvor ausstehende Anfrage ihre Bedeutung verliert. Eine Wiederverbindung sollte mindestens Session-Invalidierung, das Fehlschlagenlassen ausstehender Anfragen, das Stoppen von Reader / Writer, das erneute Öffnen nach einem Backoff und die erneute Ausführung der Geräteinitialisierung gebündelt behandeln.
4.8 Die COM-Port-Enumeration für bare Münze nehmen
GetPortNames() ist praktisch, aber in der Liste zu erscheinen und tatsächlich öffnen zu können, ist nicht dasselbe. Implementierungen, die blind auf das vorherige COM7 vertrauen, automatisch den ersten Eintrag der Enumeration wählen oder allein das Erscheinen in der Liste als gültig ansehen, führen im Betrieb leicht zu Problemen.
4.9 Dünne Sende-/Empfangslogs
TimeoutException, IOException und Port closed allein sagen fast nichts aus. Hält man Sende-/Empfangszeitpunkte, das Portprofil, Hex-Dumps der gesendeten und empfangenen Daten, Parserfehler, zu welchem Request eine Response gehört und den Auslöser jeder Wiederverbindung fest, kommt die Eingrenzung deutlich voran.
Legt man das Format vorab fest, lassen sich später sowohl grep als auch Diff-Vergleiche durchführen. Zum Beispiel mit einem solchen einzeiligen Format:
2026-03-19T10:23:41.512+09:00 COM3 TX req=00A7 len=5 02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00 COM3 RX req=00A7 len=3 02 01
2026-03-19T10:23:41.531+09:00 COM3 RX req=00A7 len=6 10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00 COM3 PARSE req=00A7 frame=02 01 10 00 4B result=OK
2026-03-19T10:23:41.532+09:00 COM3 PARSE req=- frame=02 01 11 result=INCOMPLETE need=2
2026-03-19T10:23:43.540+09:00 COM3 ERR req=00A8 reason=response-timeout elapsed=2008ms
2026-03-19T10:23:43.541+09:00 COM3 STATE Ready -> Fault reason=response-timeout
Der Zweck hier ist dreifach:
- Die RX-Zeile und die PARSE-Zeile getrennt halten. RX steht für „wie viele Byte angekommen sind“, PARSE für „wie viele Frames herausgeschnitten werden konnten“. Im obigen Beispiel erstreckt sich ein Frame über die zweite und dritte RX-Zeile, der Rest wird zum Anfang des nächsten Frames. Vermischt man diese beiden Arten in der Aufzeichnung, lässt sich später nicht mehr feststellen, ob die in 4.1 beschriebene Verschiebung der Aufteilung aufgetreten ist
- Mit
req=Sendung und Empfang zuordenbar machen. Welche Antwort zu welchem Befehl gehört, lässt sich im Nachhinein allein aus dem Log nicht rekonstruieren - Zustandsübergänge in einer Zeile festhalten. Ist ein Übergang wie
Ready -> Faultsamt Grund festgehalten, lässt sich der Auslöser der Wiederverbindung direkt nachverfolgen
Da Hex-Dumps viel Platz beanspruchen, ist ein zweistufiger Ansatz realistisch: das Rohlog nur in begrenztem Umfang als Ringpuffer, das zusammengefasste Log dagegen langfristig aufbewahren.
5. Best Practices
Am wirksamsten ist es, die Zuständigkeiten zu trennen.
reader: liest nur die Byte-Folge vom Portwriter: schreibt nur der Reihe nach aus der Sendewarteschlangeparser: schneidet nur Frames aus der Byte-Folge herausprotocol: behandelt die Zuordnung von Request und Response sowie die Checksummeapp state: aktualisiert nur den fachlichen Zustand
Beim Empfang ist eine Struktur stabil, bei der man die Rückgabeeinheit von Read nicht direkt als fachliche Einheit verwendet, sondern zunächst in einen Puffer sammelt und dann den Parser die Frames herausschneiden lässt. Bündelt man das Senden in einem Worker und zieht den eigentlichen Write auf einen Single Writer zusammen, reduziert das Verschiebungen in der Reihenfolge.
Auch bei Timeouts lässt sich die Ursache leichter eingrenzen, wenn man sie nicht mit einer einzigen Zahl abhandelt, sondern nach Bedeutung in Open, Inter-Byte, Response und Reconnect trennt. Hält man die Portkonfiguration als Profil statt als Wert im Code und gibt sie beim Start ins Log aus, wird die Untersuchung vor Ort erheblich einfacher.
Wiederverbindung ist stabiler, wenn man sie nicht als bloßes Reopen, sondern als Session-Neuaufbau versteht. Baut man Empfangspuffer, Parser-Zustand, ausstehende Anfragen, Initialisierungssequenz und Bereitschaftsprüfung neu auf, lassen sich Wiederverbindungsfehler reduzieren, die nur gelegentlich auftreten.
Abschließend empfiehlt es sich, sowohl ein Rohlog als auch ein zusammengefasstes Log zu führen. Rohe Hex-Dumps sowie die Open-/Close-Historie sind stark für die Untersuchung, zusammengefasste Request-IDs und Wiederholungszähler sind stark für den Betrieb.
Im Folgenden finden Sie den Grundgerüst-Code für die beiden wirksamsten Stellen. Es wird von .NET 8 / C# 12 mit Referenz auf das Paket System.IO.Ports ausgegangen.
5.1 Empfang: erst sammeln, dann herausschneiden
Als Beispiel nehmen wir einen Frame aus STX(0x02), LEN(1 Byte), payload(LEN Byte) und CRC16(2 Byte, Little Endian) an.
Die Form ist beliebig, aber worum es hier geht, ist, den Frame nicht anhand der Rückgabeeinheit von Read, sondern anhand dieser Definition zu zerlegen.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
public static class Crc16Modbus
{
// CRC-16/MODBUS: Startwert 0xFFFF, Polynom 0xA001, rechtsschiebend
public static ushort Compute(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
return crc;
}
}
public static class Frame
{
public const byte Stx = 0x02;
public const int HeaderLength = 2; // STX + LEN
public const int CrcLength = 2;
public static byte[] Build(ReadOnlySpan<byte> payload)
{
// LEN ist 1 Byte groß, daher würden 256 Byte oder mehr durch den Cast
// umlaufen. Trotzdem wird das Payload vollständig kopiert, sodass die
// Empfangsseite den Frame anhand der abgeschnittenen Länge zerlegt und
// mitten im Payload die CRC zu lesen glaubt. Auch alle folgenden
// Frame-Grenzen brechen dann zusammen. Ob man aufteilt oder LEN auf
// 2 Byte erweitert, ist eine Entscheidung des Protokolls — hier wird
// nur abgelehnt
if (payload.Length > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(
nameof(payload),
$"Das Payload eines Frames darf höchstens {byte.MaxValue} Byte umfassen (da LEN 1 Byte groß ist).");
}
var frame = new byte[HeaderLength + payload.Length + CrcLength];
frame[0] = Stx;
frame[1] = (byte)payload.Length;
payload.CopyTo(frame.AsSpan(HeaderLength));
var body = frame.AsSpan(0, frame.Length - CrcLength);
BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
return frame;
}
}
public sealed class FrameParser
{
private readonly List<byte> _buffer = new();
/// <summary>Der Inter-Byte-Timeout aus 3.3. Die Zeit, bis ein im Aufbau befindlicher Frame aufgegeben wird.</summary>
private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);
/// <summary>Seit wann sich der aktuell im Aufbau befindliche Kandidat im Wartezustand befindet (monoton steigender Wert).</summary>
private long _pendingSince;
/// <summary>Benachrichtigt über einen Frame, der wegen falscher CRC verworfen wurde. Unbedingt abonnieren, um ihn ins Log zu schreiben.</summary>
public event Action<byte[]>? FrameDiscarded;
/// <summary>Benachrichtigt, dass der Aufbau aufgegeben und neu synchronisiert wurde. Steigt dieser Wert stetig an, sollte man Verkabelung oder Konfiguration in Verdacht ziehen.</summary>
public event Action<int>? Resynchronized;
/// <summary>Sammelt die empfangene Byte-Folge und gibt nur die Frames zurück, die sich herausschneiden ließen.</summary>
public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
{
foreach (var b in received)
{
_buffer.Add(b);
}
var frames = new List<byte[]>();
while (true)
{
// 1. Verwerfen, bis der Anfang STX ist. Rauschen oder Reste des vorherigen Frames werden hier aufgefangen
var stxIndex = _buffer.IndexOf(Frame.Stx);
if (stxIndex < 0)
{
_buffer.Clear();
_pendingSince = 0; // Kein Kandidat mehr vorhanden, also auch die Wartezeitmessung beenden
break;
}
if (stxIndex > 0)
{
// Der Anfang des Kandidaten hat sich geändert = der Aufbau eines anderen Frames beginnt
_buffer.RemoveRange(0, stxIndex);
_pendingSince = 0;
}
// 2. Ist genug angekommen, um die Länge lesen zu können?
if (_buffer.Count < Frame.HeaderLength)
{
if (GiveUpOnStaleCandidate()) { continue; }
break; // Nicht „beschädigt“, sondern „noch nicht genug“
}
int payloadLength = _buffer[1];
int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;
// 3. Ist ein vollständiger Frame vorhanden?
if (_buffer.Count < frameLength)
{
// „Noch nicht genug“ und „LEN ist durch Rauschen beschädigt“ lassen
// sich an dieser Stelle nicht unterscheiden. Wird LEN durch Rauschen
// oder ein falsches STX zu 255, saugt der Parser auch die danach
// ankommenden, korrekten Frames weiter als Payload auf, bis 259 Byte
// beisammen sind und die CRC verwirft — bis dahin kommt nichts an.
// Bei Geräten mit geringem Kommunikationsaufkommen wirkt das wie
// mehrere Minuten Reaktionslosigkeit. Deshalb eine Obergrenze für
// die Wartezeit setzen und beim Überschreiten den Kandidaten verwerfen
// und erneut nach STX suchen
if (GiveUpOnStaleCandidate()) { continue; }
break; // Hier aussteigen und auf den nächsten Empfang warten
}
var frame = _buffer.GetRange(0, frameLength).ToArray();
_buffer.RemoveRange(0, frameLength);
_pendingSince = 0;
// 4. Frames mit falscher CRC verwerfen. Ein Verwurf wird immer nach außen gemeldet
var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
{
frames.Add(frame);
}
else
{
// Ob man hier den kompletten Frame verwirft oder nur 1 Byte STX
// verwirft und neu liest, ist eine Designentscheidung. Ersteres ist
// einfacher, Letzteres robuster, falls LEN selbst durch Rauschen
// entstanden ist. Legen Sie fest, welche Variante gilt, und
// dokumentieren Sie das.
FrameDiscarded?.Invoke(frame);
}
}
return frames;
}
/// <summary>
/// Überschreitet der im Aufbau befindliche Kandidat den AssemblyTimeout,
/// wird nur das führende STX-Byte verworfen.
/// Bei einem Verwurf wird true zurückgegeben, und die aufrufende Seite
/// liest ab dem nächsten STX neu.
/// Es wird nicht der gesamte Frame verworfen, weil sich das echte STX
/// möglicherweise innerhalb dieses Kandidaten befindet.
/// </summary>
private bool GiveUpOnStaleCandidate()
{
if (_pendingSince == 0)
{
// Der Moment, in dem das Warten beginnt. Eine Wanduhr kann durch NTP-Synchronisierung springen, daher wird mit einem monoton steigenden Wert gemessen
_pendingSince = Stopwatch.GetTimestamp();
return false;
}
if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
{
return false;
}
_buffer.RemoveAt(0);
_pendingSince = 0;
Resynchronized?.Invoke(_buffer.Count);
return true;
}
}
GiveUpOnStaleCandidate ist die konkrete Umsetzung des in 3.3 genannten Inter-Byte-Timeouts. Ohne ihn würde der Parser, sobald LEN durch Rauschen oder ein falsches STX zu einem großen Wert (zum Beispiel 255) verfälscht wird, das weiterhin als „noch nicht genug“ behandeln. Er saugt dabei sogar die danach ankommenden, korrekten Frames als Teil des verfälschten Payloads auf, und bis 259 Byte beisammen sind und die CRC den Frame verwirft, kommt nichts an. Bei Geräten mit geringem Kommunikationsaufkommen wirkt das wie mehrere Minuten Reaktionslosigkeit. Dass nur 1 Byte STX verworfen wird, liegt daran, dass sich das echte STX möglicherweise innerhalb des Kandidaten befindet.
Zwei Voraussetzungen seien hier festgehalten. Dieser Timeout wird nur ausgewertet, wenn Append aufgerufen wird. Wird die Leitung vollständig still, passiert auf Parser-Seite nichts — das fängt der Response-Timeout der aufrufenden Seite ab (5.2). Zweitens sollte der Wert von AssemblyTimeout anhand von Baudrate und Frame-Länge bestimmt werden. Die Untergrenze ist die Übertragungszeit für 1 Byte multipliziert mit der erwarteten maximalen Frame-Länge zuzüglich eines Sicherheitspuffers; ist der Wert kürzer, werden auch normale Frames vorzeitig verworfen. Gibt man die Anzahl der Resynchronized-Auslösungen ins Log aus, liefert das ein Indiz, um Verkabelung oder Baudraten-Einstellung in Verdacht zu ziehen.
Die Leseseite beschränkt sich darauf, aus dem Port zu lesen und an den Parser weiterzugeben. Fängt man hier an, fachliche Verarbeitung zu schreiben, verwandelt sich die Rückgabeeinheit von Read in eine fachliche Einheit.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
public sealed class SerialReader
{
private readonly SerialPort _port;
private readonly FrameParser _parser;
private readonly byte[] _readBuffer = new byte[4096];
public SerialReader(SerialPort port, FrameParser parser)
{
_port = port;
_parser = parser;
}
public event Action<byte[]>? FrameReceived;
public async Task RunAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
int count;
try
{
count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
}
catch (OperationCanceledException)
{
break;
}
if (count <= 0)
{
continue;
}
foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
{
FrameReceived?.Invoke(frame);
}
}
}
}
Dass DataReceived nicht verwendet wird, ist Absicht. Wie in 4.2 beschrieben, hat es keine größere Bedeutung als „irgendetwas ist wohl angekommen“, weshalb eine eigene Leseschleife die Verwaltung von Zustand und Timeout erleichtert.
5.2 Senden: auf Single Writer setzen
Auf der Sendeseite ist entscheidend, keinen Zustand entstehen zu lassen, in dem man von überall Write aufrufen kann.
Das Einreihen in die Warteschlange darf von überall aus aufgerufen werden dürfen; das tatsächliche Write führt dagegen ausschließlich ein einziger Worker aus.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
public sealed class SingleWriter
{
private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);
/// <summary>Die Obergrenze der Sendewarteschlange. Bestimmt durch die Zeit für einen Hin- und Rückweg zum Gerät multipliziert mit der zulässigen Warteschlangenlänge.</summary>
private const int QueueCapacity = 64;
private readonly SerialPort _port;
private readonly TimeSpan _responseTimeout;
// Nicht ohne Obergrenze lassen. Reihen UI, Timer und Worker schneller ein,
// als das Gerät antworten kann, sammeln sich Frames und
// TaskCompletionSource unbegrenzt an — das Gerät antwortet zwar, aber
// nur der Speicherverbrauch wächst weiter. Eine Obergrenze festlegen
// und bei Überlauf an den Absender zurückgeben
private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
new BoundedChannelOptions(QueueCapacity)
{
// Ist die Warteschlange voll, gibt TryWrite false zurück. Die aufrufende
// Seite erfährt so sofort, dass „gerade ein Stau besteht“. DropOldest
// wird nicht verwendet — die einreihende Seite wartet auf den Task,
// stilles Verwerfen würde ihn also für immer offenlassen
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
});
private Outbound? _inFlight;
public SingleWriter(SerialPort port, TimeSpan responseTimeout)
{
_port = port;
_responseTimeout = responseTimeout;
}
/// <summary>Darf sowohl von der UI als auch vom Timer aus aufgerufen werden. Den eigentlichen Write führt ausschließlich ein einziger Worker aus.</summary>
public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
{
var item = new Outbound(
Frame.Build(payload),
new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));
if (!_queue.Writer.TryWrite(item))
{
// Entweder ist die Warteschlange voll oder der Worker ist bereits
// gestoppt. In beiden Fällen wird der aufrufenden Seite gemeldet, dass
// „das Einreihen fehlgeschlagen ist“. Stilles Verwerfen würde den
// wartenden Task für immer offenlassen
item.Completion.TrySetException(new InvalidOperationException(
$"Konnte nicht in die Sendewarteschlange eingereiht werden (Obergrenze {QueueCapacity} Einträge, oder der Worker ist bereits gestoppt)."));
}
return item.Completion.Task;
}
/// <summary>Wird aufgerufen, sobald der Parser einen Frame herausgeschnitten hat. Verknüpft ihn mit dem einen wartenden Eintrag.</summary>
public void OnFrameReceived(byte[] frame)
{
var pending = Interlocked.Exchange(ref _inFlight, null);
pending?.Completion.TrySetResult(frame);
}
public async Task RunAsync(CancellationToken token)
{
try
{
await foreach (var item in _queue.Reader.ReadAllAsync(token))
{
Interlocked.Exchange(ref _inFlight, item);
try
{
await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
}
catch (Exception ex)
{
// Kabelabzug, Portschließung oder Abbruch landen hier. Verlässt man
// diese Stelle, ohne diesen einen Eintrag abzuschließen, wartet die
// aufrufende Seite, die SendAsync awaitet, für immer
Interlocked.Exchange(ref _inFlight, null);
item.Completion.TrySetException(ex);
throw;
}
// Hier wird auf die Antwort gewartet, damit sich kein weiterer Befehl dazwischendrängt
var timeout = Task.Delay(_responseTimeout, token);
var finished = await Task.WhenAny(item.Completion.Task, timeout);
if (finished != item.Completion.Task)
{
// Auch wenn ein Stopp angefordert wurde, wird Task.Delay abgebrochen
// und endet zuerst. Prüft man das nicht, wird eine reguläre
// Beendigung fälschlich als Timeout behandelt, und die aufrufende
// Seite erhält eine TimeoutException
token.ThrowIfCancellationRequested();
// Es kann vorkommen, dass die Antwort eintrifft, nachdem WhenAny sich
// bereits für den Timeout entschieden hat. In dem Fall hat
// OnFrameReceived _inFlight bereits übernommen und diesen einen
// Eintrag erfolgreich abgeschlossen. Gelingt die Übernahme hier nicht,
// hat die Antwort gewonnen, und ein TrySetException verpufft wirkungslos.
// Bemerkt man das nicht und läuft bis zum throw weiter, erhält die
// aufrufende Seite zwar ein Ergebnis, aber nur der Worker stürzt ab.
// Der Wettlauf wird über CompareExchange entschieden
if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
{
// Die Antwort hat gewonnen. Der Abschluss wird von OnFrameReceived gesetzt (unmittelbar danach)
await item.Completion.Task;
continue;
}
item.Completion.TrySetException(new TimeoutException("Es kam keine Antwort."));
// Bei einem Timeout ist diese Verbindung nicht mehr vertrauenswürdig.
// Warum man es nicht einfach bei „aufgeben und den nächsten senden“
// belassen darf, steht weiter unten: Da dieses Protokoll keine
// Request-ID besitzt, wird eine verspätet eintreffende Antwort auf A
// fälschlich mit der Antwort auf das nächste B verknüpft. Wie im
// Zustandsübergangsdiagramm aus 3.6 wird deshalb nach Fault
// gewechselt und die Session neu aufgebaut
throw new TimeoutException("Da keine Antwort eingegangen ist, wird die Session neu aufgebaut.");
}
}
}
finally
{
// Unabhängig vom Grund, warum der Worker stoppt, werden alle
// wartenden Einträge unbedingt abgeschlossen — sowohl der eine, auf
// dessen Antwort gewartet wird, als auch alle, die noch unversendet
// in der Warteschlange lagen
var stopped = new OperationCanceledException("Der Sende-Worker wurde gestoppt.");
Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
_queue.Writer.TryComplete();
while (_queue.Reader.TryRead(out var pending))
{
pending.Completion.TrySetException(stopped);
}
}
}
}
Dass bei einem Timeout gleich der ganze Worker gestoppt wird, wirkt zwar grob, ist aber eine notwendige Maßnahme. Dieser Frame enthält keine Request-ID. Deshalb kann die Empfangsseite nicht feststellen, „zu welchem Befehl der gerade angekommene Frame gehört“, und OnFrameReceived bleibt nichts anderes übrig, als ihn mechanisch mit dem einen wartenden Eintrag zu verknüpfen.
Sendet man hier nach einem Timeout einfach den nächsten Befehl, passiert Folgendes.
- Befehl A wird gesendet. Da innerhalb der vorgesehenen Zeit keine Antwort kommt, wird es als Timeout behandelt
- Der nächste Befehl B wird gesendet
- Die verspätet eintreffende Antwort auf A wird der aufrufenden Seite als Antwort auf B übergeben
Aus Sicht der aufrufenden Seite: Man hat B gesendet, bekommt aber den Wert von A zurück. Da das Format des Werts korrekt ist, besteht er sogar die Prüfung — das ist der am schwersten zu findende Fehler. Dass im Zustandsübergangsdiagramm aus 3.6 keine Linie direkt von Fault zu Ready zurückführt, liegt genau an diesem Pfad. Ein Timeout ist keine „Einzelfall-Fehler“-Entscheidung, sondern die Entscheidung „diese Verbindung ist nicht mehr vertrauenswürdig“, und die Rückkehr aus einem Zustand, in dem unklar ist, was noch im Empfangspuffer steht, lässt sich nur durch einen Session-Neuaufbau garantieren, bei dem der Port geschlossen und neu geöffnet wird.
Lässt sich das Protokoll selbst anpassen, ist es die grundsätzlichere Lösung, dem Frame eine Request-ID mitzugeben und sie mit der Antwort abzugleichen. Dann genügt es bei einer verspätet eintreffenden Antwort, sie mit „unbekannte ID, also verwerfen“ zu behandeln, und man muss die Verbindung nicht bei jedem einzelnen Timeout neu aufbauen.
Zum Schluss setzen wir alles zusammen. Die Portkonfiguration an einer Stelle als Profil abzulegen und beim Start ins Log auszugeben — das war der Inhalt von 3.4.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
// Die in 3.4 festgelegte Konfiguration gebündelt ablegen statt als Ad-hoc-Werte
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
DtrEnable = true,
RtsEnable = true,
ReadTimeout = 500,
WriteTimeout = 500,
};
Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
$"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();
var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);
// Definierte Ereignisse unbedingt abonnieren. Vergisst man das, werden weder verworfene Frames noch Antworten sichtbar
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;
using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);
var request = new byte[] { 0x10, 0x00 };
try
{
var response = await writer.SendAsync(request);
Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
// Auch wenn das Senden fehlschlägt, wird der Worker unbedingt gestoppt,
// bevor man diese Stelle verlässt. Überspringt man das, greifen
// reader/writer auch nach dem Schließen des SerialPort durch die
// using-Freigabe weiter auf den Stream zu, und diese Ausnahme
// beobachtet niemand
cts.Cancel();
try
{
await Task.WhenAll(readerTask, writerTask);
}
catch (OperationCanceledException)
{
// Beendigung durch angeforderten Stopp. Das wird hier als Normalfall behandelt
}
catch (Exception ex)
{
// Fehler auf Worker-Seite. Ein erneutes Werfen würde hier den
// eigentlichen Fehlergrund (die Ausnahme von SendAsync) verdecken,
// daher wird nur protokolliert
Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
}
}
Dass cts.Cancel() und Task.WhenAll in finally stehen, ist keine Stilfrage. Bei serieller Kommunikation ist ein Fehlschlagen von SendAsync keine Ausnahme, sondern Alltag — das Gerät antwortet nicht, das Kabel ist abgezogen, das Schreiben läuft in einen Timeout. Verlässt man die Methode dann einfach nach oben, erreicht man die Freigabe durch using, ohne den Worker gestoppt zu haben. Auch nachdem der SerialPort geschlossen wurde, greifen reader / writer weiter auf den Stream zu, und die dabei entstehende Ausnahme beobachtet niemand. Bei einer dauerhaft laufenden Anwendung häuft sich das mit der Zeit so an, dass nur die Worker fehlgeschlagener Operationen übrig bleiben. Dass die Ausnahme im finally-Zweig nicht erneut geworfen wird, dient dazu, den eigentlichen Fehlergrund (die Ausnahme von SendAsync) nicht zu verdecken.
Hält man es in dieser Form, lässt sich alles, was man später ergänzen möchte — etwa Retry, Keepalive, Reconnect —, entweder auf der Seite des Einreihens in die Warteschlange oder auf der Worker-Seite unterbringen. Da keine weiteren Stellen entstehen, die Write direkt aufrufen, entstehen auch keine zusätzlichen Ursachen für eine Verschiebung der Reihenfolge.
6. Zuerst zu prüfende Checkliste
- Sind die Nachrichtengrenzen dokumentiert?
- Ist der Empfang als Byte-Sammlung → Frame-Extraktion strukturiert?
- Wird
DataReceivednicht als Nachrichtenankunft behandelt? - Findet keine synchrone I/O auf dem UI-Thread statt?
- Ist das Senden als Single Writer aufgebaut?
- Sind die Timeouts nach Bedeutung getrennt statt nur ein einzelner Wert?
- Sind
Handshake/ DTR / RTS explizit gesetzt? - Baut die Wiederverbindung die Session neu auf?
- Werden rohe Hex-Dumps aufgezeichnet?
- Wurden Ab-/Wiederanstecken der realen Hardware und Verbindungsabbrüche mitten im Betrieb getestet?
Sind hier mehrere Punkte fragwürdig, lohnt es sich, vor dem Produktivbetrieb einmal innezuhalten.
7. Zusammenfassung
Zum Schluss noch einmal die Kernpunkte im Überblick.
- Serielle Kommunikation ist kein Nachrichtenstrom, sondern ein Byte-Stream
- Die Einheit von
Readund die Einheit einer Nachricht stimmen nicht überein - Grenzen müssen als Protokoll definiert werden
DataReceiveddirekt zum fachlichen Ereignis zu machen, bricht leicht zusammen- Sende- und Empfangszuständigkeiten trennen, das Senden auf Single Writer ausrichten
- Timeouts nach Bedeutung trennen, Wiederverbindung auf Session-Ebene gestalten
- Ein Log mit rohen Hex-Dumps erleichtert die spätere Untersuchung erheblich
Mit anderen Worten: Bei seriellen Kommunikationsanwendungen ist nicht das Öffnen des Ports, sondern wie man die Byte-Folge interpretiert und Zeit sowie Zustand steuert, deutlich wichtiger. Trennt man das bereits im ersten Entwurf, reduziert das die Klasse von Kommunikationsfehlern, die „nur gelegentlich“ auftreten, erheblich.
8. Quellen
- Microsoft Learn,
SerialPort.DataReceived-Ereignis - Microsoft Learn,
SerialPort.Read-Methode - Microsoft Learn,
SerialPort.ReadTimeout-Eigenschaft - Microsoft Learn,
SerialPort.BaseStream-Eigenschaft - Microsoft Learn,
SerialPort.NewLine-Eigenschaft - Microsoft Learn,
Handshake-Enumeration - Microsoft Learn,
SerialPort.DtrEnable-Eigenschaft - Microsoft Learn,
SerialPort.RtsEnable-Eigenschaft - Microsoft Learn,
SerialPort.GetPortNames-Methode - Microsoft Learn,
SerialPort-Klasse - Microsoft Learn,
COMMTIMEOUTS-Struktur - Microsoft Learn,
DCB-Struktur - Microsoft Learn,
CreateFile-Funktion - pySerial API, Serial API Reference
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
WMI/CIM aus C# und PowerShell verwenden — Praxisleitfaden für Hardwareinformationen, Prozessüberwachung und Remoteabfragen
WMI/CIM ist die Standardlösung, um die Seriennummer eines PCs auszulesen, freien Festplattenspeicher zu überwachen und den Start von Proz...
USB-Geräte aus einer Windows-App ansprechen — Die Wahl zwischen virtual COM, HID, WinUSB und Hersteller-SDKs
Ein Vergleich von vier Wegen, Geräte und USB-Hardware aus einer Windows-Anwendung anzusteuern: virtueller COM-Port, HID, WinUSB und Herst...
Code-Design für Geschäftssysteme — Produkt- und Kundencodes festlegen, und Prüfziffern
Ein praxisnaher Leitfaden zur Festlegung des Codesystems eines Geschäftssystems, einschließlich Produkt- und Kundencodes. Behandelt eine ...
Das Datenbankschema Ihrer Business-Anwendung versionieren ── Migrationspraxis gegen „jeder Kunde hat eine andere Datenbank“
Ein praktischer Leitfaden zur Versionierung des Datenbankschemas einer Business-Anwendung, deren Datenbanken über zahlreiche Kundenstando...
CI/CD für WinForms-/WPF-Anwendungen in der Praxis ── Vom Build über die Signierung bis zur Distribution mit GitHub Actions automatisieren
Ein praktischer Leitfaden zur Einrichtung von CI/CD für WinForms-/WPF-Anwendungen mit GitHub Actions. Behandelt eine minimale YAML für Bu...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Bei Windows-Anwendungen mit serieller Kommunikation ist es stabiler, Empfangsverarbeitung, Zustandsübergänge, Wiederverbindung und die Trennung der UI von Anfang an mitzudenken.
Fehleruntersuchung und Ursachenanalyse
Dieses Thema passt gut zur Eingrenzung von Kommunikationsstörungen, die nur gelegentlich auftreten, sich nur nach dem Ab- und Wiederanstecken von USB nicht erholen oder deren Ursache sich in den Logs nicht nachvollziehen lässt.
Technische Beratung und Design-Review
Klärt man Protokollgrenzen, Flusssteuerung, Timeouts und das Single-Writer-Design vor der Umsetzung, lassen sich Fehler mit hohem Korrekturaufwand leichter vermeiden.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Erhält man bei serieller Kommunikation durch den Aufruf von Read(16) garantiert genau 16 Byte?
- Nicht unbedingt. Serielle Kommunikation ist ein geordneter Byte-Stream, und Nachrichtengrenzen entstehen nicht von selbst. Was die Gegenseite mit einem einzigen Write gesendet hat, kann bei Ihnen in zwei Teilen ankommen oder mit anderen Daten verkettet eintreffen. Typische Ausfallmuster sind: nur die Länge ist gelesen, das Payload fehlt noch; nur eineinhalb Frames sind angekommen; oder zwei Frames sind zusammen angekommen. Die Gegenmaßnahme besteht darin, den Empfang zunächst in einen Puffer zu sammeln und die Frames erst von dort aus über einen Parser herauszuschneiden.
- Worauf muss man beim Einsatz des .NET-Ereignisses SerialPort.DataReceived achten?
- DataReceived feuert nicht garantiert für jedes empfangene Byte und läuft auch nicht auf dem UI-Thread. Es als Benachrichtigung „eine Nachricht ist angekommen“ zu verstehen, ist gefährlich. In der Praxis sollte man es nur als Hinweis „irgendetwas ist wohl angekommen“ betrachten, im Handler keine aufwendige Verarbeitung durchführen und UI-Aktualisierungen stets auf den UI-Thread zurückführen. Eine Struktur, bei der die empfangene Byte-Folge zunächst gesammelt und dann von einem Parser in Frames zerlegt wird, ist stabil.
- Wie sollte man Timeouts bei serieller Kommunikation gestalten?
- Ein einzelner Timeout genügt nicht — es ist stabiler, sie nach Bedeutung zu trennen: der Open-Timeout bis zum Öffnen des Ports, der Inter-Byte-Timeout für die Zeit ohne eingehendes Byte mitten in einem Frame, der Response-Timeout von der Befehlsausgabe bis zum Abschluss der Antwort, und der Reconnect-Backoff als Wartezeit zwischen Wiederverbindungsversuchen. Timeouts sind stabiler, wenn man sie nicht als Absicherung gegen Langsamkeit, sondern als Regel zum Vorantreiben der Zustandsübergänge versteht. Zu beachten ist auch, dass ein sorglos belassener synchroner Read ohne Weiteres zu einer Endloswartezeit führt.
- Warum erholt sich eine USB-Seriell-Wandlung nach dem Ab- und Wiederanstecken des Kabels nicht?
- Weil bei USB-Seriell-Wandlungen ganz gewöhnlich vorkommt, dass der Port vorübergehend verschwindet, das alte Handle ungültig wird, sich die COM-Nummer ändert oder eine zuvor ausstehende Anfrage ihre Bedeutung verliert. Ein bloßes erneutes Open() reicht als Wiederverbindung nicht aus. Entwirft man die Wiederverbindung als „Session-Neuaufbau“, der Session-Invalidierung, das Fehlschlagenlassen ausstehender Anfragen, das Stoppen von Reader und Writer, das erneute Öffnen nach einem Backoff und das erneute Ausführen der Geräteinitialisierungssequenz gesammelt behandelt, lassen sich Wiederverbindungsfehler reduzieren, die nur gelegentlich auftreten.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.