Fallstricke bei seriellen Kommunikationsanwendungen — von der Wiederverbindung bis zum Log-Design

· · 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
  • .NETs DataReceived feuert nicht garantiert pro empfangenem Byte und läuft zudem nicht auf dem UI-Thread
  • ReadLine() / 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, reconnect zu trennen, macht es stabiler
  • Statt Write von ü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:

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR / 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.

Open angefordertOpen erfolgreich + Initialisierungssequenz abgeschlossenOpen fehlgeschlagen / Berechtigungsfehler / Initialisierungs-TimeoutBefehl gesendetpassender Antwort-Frame empfangenResponse-TimeoutI/O-Fehler / Kabelabzug erkanntausstehende Anfrage fehlschlagen lassen, Backoff startetBackoff abgelaufenObergrenze erreicht / manueller StoppClose angefordertClosedOpeningReadyFaultWaitingResponseReconnecting

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 -> Fault samt 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 Port
  • writer: schreibt nur der Reihe nach aus der Sendewarteschlange
  • parser: schneidet nur Frames aus der Byte-Folge heraus
  • protocol: behandelt die Zuordnung von Request und Response sowie die Checksumme
  • app 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.

  1. Befehl A wird gesendet. Da innerhalb der vorgesehenen Zeit keine Antwort kommt, wird es als Timeout behandelt
  2. Der nächste Befehl B wird gesendet
  3. 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 DataReceived nicht 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 Read und die Einheit einer Nachricht stimmen nicht überein
  • Grenzen müssen als Protokoll definiert werden
  • DataReceived direkt 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

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

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.

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.

Zurück zum Blog