Serial Communication App Pitfalls - Through Reconnection and Log Design
· Updated: · Go Komura · Serial Communication, RS-232, C#, .NET, Windows Development, Device Integration
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614550)
- First published
Cite this article(DOI: 10.5281/zenodo.21614549)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Serial Communication App Pitfalls - Through Reconnection and Log Design. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614549 https://comcomponent.com/en/blog/2026/03/19/001-serial-communication-app-pitfalls/
- DOI (latest version)
- 10.5281/zenodo.21614549
- DOI (this version)
- 10.5281/zenodo.22217178
Device integration, measuring instruments, PLCs, barcode readers, USB-to-serial adapters. Serial communication looks like old technology, yet it is still entirely commonplace in real Windows application work.
The slightly dangerous part is that serial communication can be started with nothing more than a single COM port and a single Read / Write. The connectivity check passes immediately, but once in production, symptoms like the following tend to appear.
- Commands and responses occasionally get out of sync
- It freezes exactly once a day
- It fails to recover only after a USB unplug/replug
- The UI sometimes stalls
- The logs contain nothing but “Timeout”
What is genuinely hard in serial communication apps is not the send/receive API itself, but boundaries, timeouts, state transitions, reconnection, and observability.
flowchart TB
accTitle: Connectivity passes but production breaks
accDescr: A diagram showing that serial communication can be started with a single COM port and a single Read and Write and that the connectivity check passes immediately, yet in production the symptoms are mismatched responses, freezes and failure to recover, and that the real difficulty lies in boundaries, timeouts, state transitions, reconnection and observability rather than the send and receive API itself.
a1["Connectivity check passes immediately"] --> a2["Breaks occasionally in production"]
a2 --> a3["Responses drift, it freezes, it does not recover"]
a3 --> a4["The hard part is not the send/receive API itself"]
a4 -.-> a5["Boundaries, timeouts, state transitions, reconnection, observability"]
Figure 1: The real difficulties of a serial communication app lie beyond the connectivity check.
Who This Article Is For, and What It Assumes
| Item | Details |
|---|---|
| Intended readers | Developers building Windows applications that talk to devices or instruments over serial. Written for those whose connectivity check already passes but who want to reduce the failures that show up only occasionally in production |
| Assumed knowledge | The ability to write applications in C#. No prior experience with serial communication is assumed |
| Assumed environment | Written around System.IO.Ports.SerialPort in .NET, but the thinking about boundaries, timeouts, and state transitions is language independent |
| Out of scope | Electrical wiring, and the protocol specification of any particular device |
Terms Used in This Article
| Term | Meaning in one line |
|---|---|
| PLC | Programmable Logic Controller. An industrial controller used to control production equipment |
| RS-232 / RS-485 | Electrical standards for serial communication. RS-232 is point to point, while RS-485 lets several devices hang off the same pair. On RS-485 you have to decide who transmits when, or they collide |
| 8N1 | Shorthand for port settings. Eight data bits, no parity (None), one stop bit |
| DTR / RTS | Control lines. Originally they signal readiness for communication and a request to send, but real devices sometimes read transitions on these lines as a cue to boot or to switch modes |
| Flow control | A mechanism that prevents sending too fast. RTS/CTS uses control lines, while XON/XOFF signals pause and resume with special characters inside the data |
| keepalive | A lightweight command sent periodically to confirm that the peer is still alive |
| Frame | The byte sequence that makes up one message. Where one frame starts and ends is decided by the protocol |
| single writer | A design that consolidates transmission into a single worker. It means never allowing a state in which anything can Write |
1. The Conclusions First
Summarized up front, in practice-oriented terms.
- Serial communication is an ordered byte stream; message boundaries do not appear on their own
- Calling
Read(100)does not guarantee exactly 100 bytes back - .NET’s
DataReceivedis not guaranteed to fire per received byte, and moreover it is not on the UI thread ReadLine()/WriteLine()behave nicely only when the peer truly speaks a line-based text protocol- One timeout is not enough. Separating the meanings -
open,inter-byte,response,reconnect- gives more stability - Rather than allowing
Writefrom anywhere, leaning toward a single writer is harder to break - With USB-serial, it is more peaceful to assume from the start: unplug/replug, re-enumeration, COM number changes, and reconnection failures
In short, the hard part of a serial communication app is not “can you open the port,” but how you turn the byte stream into meaningful messages, and how you manage the time and state around it.
Knowledge map for this article
This article is a design guide for preventing the defects that break only occasionally in C# serial communication apps used for work such as device integration. Serial communication is nothing more than an ordered byte stream and carries no message boundaries, so treating the DataReceived event as a notification that one message has arrived is not recommended, and the recommended design accumulates the received bytes first and then cuts messages out with a frame parser. Transmission should be concentrated into a single worker in a single writer design, and timeouts should be designed separately by meaning: open, inter-byte, response, and reconnect backoff. In protocols whose frames carry no request ID, responses can be mismatched after a response timeout, so what is needed is not a simple reconnect but session re-creation that rebuilds the receive buffer and the parser state as well.
flowchart LR
accTitle: Pitfalls in serial communication apps
accDescr: Diagram that starts from serial communication being a byte stream with no message boundaries and shows how a frame parser and a single writer design handle those boundaries and the order of transmissions, and how splitting timeouts by kind and the presence or absence of a request ID lead to mismatched responses and to session re-creation.
serial_communication["Serial Communication"]
frame_parser["Frame Parser (Accumulate Then Extract)"]
single_writer["Single Writer (One Write Point)"]
byte_stream["Byte Stream (Ordered Byte Sequence)"]
frame_boundary["Frame Boundary"]
datareceived_event["SerialPort.DataReceived event"]
crc_check["CRC frame validation"]
inter_byte_timeout["Inter-Byte Timeout"]
timeout_taxonomy["Timeout Taxonomy by Purpose"]
response_timeout["Response Timeout"]
reconnect_backoff["Reconnect with Backoff"]
response_mismatch_risk["Response Mismatch Risk"]
request_id["Request ID"]
session_regeneration["Session Regeneration on Reconnect"]
flow_control_lines["Serial Flow Control (DTR/RTS)"]
hex_dump_logging["Send/Receive Logging with Hex Dumps"]
serial_communication -->|"uses"| byte_stream
byte_stream -->|"requires"| frame_boundary
frame_parser -->|"implements"| frame_boundary
datareceived_event -->|"not recommended for"| frame_boundary
frame_parser -->|"recommended for"| byte_stream
single_writer -->|"recommended for"| serial_communication
frame_parser -.->|"uses"| crc_check
frame_parser -.->|"uses"| inter_byte_timeout
timeout_taxonomy -->|"uses"| inter_byte_timeout
timeout_taxonomy -->|"uses"| response_timeout
timeout_taxonomy -->|"uses"| reconnect_backoff
response_timeout -.->|"may cause"| response_mismatch_risk
request_id -->|"prevents"| response_mismatch_risk
session_regeneration -->|"recommended for"| response_mismatch_risk
session_regeneration -->|"uses"| reconnect_backoff
serial_communication -.->|"requires"| flow_control_lines
hex_dump_logging -->|"recommended for"| serial_communication
single_writer -.->|"requires"| response_timeout
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (18 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. Serial Communication Is an “Ordered Byte Stream,” Not “Messages”
From the application’s point of view, serial communication looks like “send one command, receive one response.” But at the layer below, what actually flows is just an ordered sequence of bytes.
What you sent with one Write may appear to the other side as:
- Arriving in one
Read - Arriving split into two
- Arriving concatenated with other data
Drop this premise and the app starts assuming “this Read must be this response.” That assumption is often the first land mine in serial communication apps.
flowchart TB
accTitle: Three ways one Write can arrive
accDescr: A diagram showing that what you send with a single Write does not necessarily arrive in a single Read on the other side, but may arrive split into two or concatenated with other data.
b0["One Write"] --> b1["Arrives in one Read"]
b0 --> b2["Arrives split into two"]
b0 --> b3["Arrives concatenated with other data"]
b2 -.-> b4["This Read is not necessarily this response"]
Figure 2: How one Write looks on the other side is unknowable until it arrives.
| Common assumption | Reality |
|---|---|
Read(16) returns exactly 16 bytes |
Depending on arrival and timeouts, you may only get part of it |
DataReceived = one message arrived |
The event is not guaranteed per byte, and it is not on the UI thread |
Write returned = the peer finished processing |
In most cases it is closer to “the sender queued it into a buffer” |
| The COM list = the current truth of what is connected | Enumeration order is unspecified, and results can be stale |
For this reason, in serial communication you must define message boundaries yourself, as a protocol. Fixed-length frames, delimiter-based, length + payload + checksum - any shape is fine, but entering implementation with this left vague almost guarantees pain later.
flowchart TB
accTitle: Define the boundaries yourself
accDescr: A diagram showing that serial communication requires you to define message boundaries yourself as a protocol, that fixed-length frames, delimiter-based framing, or length plus payload plus checksum are all acceptable shapes, and that entering implementation with this left vague leads to pain.
c0["Define message boundaries yourself"] --> c1["Fixed-length frames"]
c0 --> c2["Delimiter based"]
c0 --> c3["Length + payload + checksum"]
c0 -.-> c4["Leaving it vague hurts later"]
Figure 3: The layer below adds no message boundaries, so decide them first, as a protocol.
3. What to Decide First
Before building a serial communication app, decide at least the items listed here up front.
3.1 Frame Boundaries
Decide which byte sequences count as one message. Fixed length? Newline-delimited? Length-prefixed? Is there a checksum / CRC? If this is vague, the receiver cannot tell “not enough yet” from “corrupted.”
3.2 Text, Binary, or a Mix
Decide up front whether it is an ASCII / UTF-8 line protocol, pure binary, or a mixture. Especially with mixtures like “the command part is a string, the payload is binary, only the tail has a newline,” the boundary collapses quickly unless you make explicit what gets decoded and from where bytes are treated as raw.
3.3 The Meaning of Each Timeout
Timeouts are safer thought of not as one value but separated by meaning.
- open timeout: until the port opens
- inter-byte timeout: time with no byte arriving mid-frame
- response timeout: from command issue to response completion
- reconnect backoff: the wait interval between reconnection attempts
Timeouts are stable when held not as “insurance against slowness” but as rules that advance the state machine.
flowchart TB
accTitle: Separate timeouts by meaning
accDescr: A diagram showing that timeouts split into open until the port opens, inter-byte for a gap with no byte arriving mid-frame, response from command issue to response completion, and reconnect backoff as the wait between attempts, and that they are held as rules that advance the state machine.
d0["One timeout is not enough"] --> d1["open: until the port opens"]
d0 --> d2["inter-byte: silence"]
d0 --> d3["response: response completed"]
d0 -.-> d4["reconnect backoff"]
d1 --> d5["Rules that advance the state machine"]
d2 --> d5
d3 --> d5
Figure 4: Separating the four kinds of timeout lets you treat them as state machine rules.
3.4 Flow Control and Line State
The settings you want to make explicit are around here.
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
Settle this with “8N1 is roughly right” and, depending on the peer device, things will simply stop.
3.5 Separation of Responsibilities
Divide who is responsible for what.
- Who reads
- Who writes
- Who parses
- Who applies results to business state
Serial communication becomes more fragile the more the UI and the communication are mixed together.
flowchart TB
accTitle: Split responsibilities and keep the UI out of the communication
accDescr: A diagram showing the split between who reads, who writes, who parses, and who applies results to business state, and that mixing the UI with the communication makes the application more fragile.
e0["Separation of responsibilities"] --> e1["Reader and writer"]
e0 --> e2["Parser"]
e0 --> e3["Applies to business state"]
e0 -.-> e4["Mixing UI and communication is fragile"]
Figure 5: Split reading, writing, parsing, and applying, and keep the UI out of the communication.
3.6 Start / Stop / Reconnect State Transitions
At minimum, states like Closed, Opening, Ready, WaitingResponse, Fault, and Reconnecting should be part of the design. Right after an unplug/replug, the peer may still be booting, and there are times when you must not drag along the previous pending request.
stateDiagram-v2
[*] --> Closed
Closed --> Opening: Open requested
Opening --> Ready: open succeeded + initialization sequence done
Opening --> Fault: open failed / access error / initialization timeout
Ready --> WaitingResponse: command sent
WaitingResponse --> Ready: matching response frame received
WaitingResponse --> Fault: response timeout
Ready --> Fault: I/O error / disconnect detected
Fault --> Reconnecting: fail pending requests and start backoff
Reconnecting --> Opening: backoff elapsed
Reconnecting --> Closed: retry limit reached / stopped manually
Ready --> Closed: Close requested
Figure 6: State transitions of a connection session. No edge runs straight from Fault back to Ready.
What matters in this diagram is that there is no edge going directly from Fault back to Ready.
After a failure the path always runs through Reconnecting and Opening, rebuilding the receive buffer, parser state, pending requests, and the initialization sequence before returning to Ready. Take a shortcut here and you land in 4.7, believing that a repeated Open() counts as reconnection.
3.7 Logging and Investigability
This is where the worst trouble comes later, almost always. At minimum, you want to record: open / close / reopen times, the port settings used, hex dumps of sent and received frames, checksum / CRC errors, frame timeouts / response timeouts, and the reason for each reconnect.
4. Common Pitfalls
4.1 Believing “One Read = One Message”
This is the most common. Say the peer returns a frame consisting of a header, length, payload, and CRC. If you call Read(buffer, 0, expectedLength) once and assume the return is one whole frame, partial reception breaks it easily.
The three usual breakage modes:
- Only the length arrived; the payload has not come yet
- One and a half frames arrived; the second half rolls into the next
Read - Two frames arrived together; only the first is processed and the rest is discarded
Drawn out, it is nothing more than this: the order the device sent does not line up with the order Read returns.
What the device sent
[--- frame 1 ---][--- frame 2 ---]
Pattern 1: only part of it arrives
1st Read -> [ STX ][ LEN ] <- the payload has not arrived yet
2nd Read -> [ payload ][ CRC ][--- frame 2 ---]
Pattern 2: one and a half frames arrive
1st Read -> [--- frame 1 ---][ first half of frame 2 ]
2nd Read -> [ second half of frame 2 ]
Pattern 3: two frames arrive together
1st Read -> [--- frame 1 ---][--- frame 2 ---] <- easy to process one and drop the rest
In all three patterns, nothing is corrupted; the boundary positions simply do not line up with the number of Read calls. Mistake one for the other, write an implementation that treats a byte count different from what was expected as an error, and it will start counting healthy traffic as errors.
The countermeasure is simple: split the work so that received bytes are accumulated first and a parser carves frames out of the buffer. Skeleton code is in 5.1.
flowchart TB
accTitle: Accumulate first, then carve
accDescr: A diagram showing that treating what Read returns as one whole frame breaks easily on partial reception, so received bytes are accumulated into a buffer first and a parser carves frames out of that buffer.
f1["Assuming what Read returns is one frame"] --> f2["Partial reception breaks it easily"]
f2 -.->|"instead"| f3["Accumulate received bytes into a buffer"]
f3 --> f4["The parser carves out frames"]
Figure 7: Decouple what Read returns from the frame: accumulate first, then carve.
4.2 Treating DataReceived Directly as a Business Event
.NET’s SerialPort.DataReceived looks convenient, but treating it as “a message has arrived” is dangerous. In practice, regard DataReceived as merely “something seems to have arrived,” do no heavy work inside the handler, and always marshal UI updates back to the UI thread.
4.3 Believing Anyone May Write from Anywhere
A configuration where the UI button, the monitoring timer, the reconnection logic, and the keepalive each call Write directly is fragile. Serial is a byte stream, so depending on the design, command interleaving or follow-up sends while awaiting a response can occur. Especially for request-response protocols and RS-485-style buses, leaning toward a single writer is substantially more stable.
flowchart TB
accTitle: Consolidate transmission into a single writer
accDescr: A diagram showing that letting the UI button, the monitoring timer, keepalive and reconnection each call Write directly causes command interleaving and follow-up sends while a response is still awaited, and that consolidating into a single writer is more stable.
g1["UI button"] --> g4["Each one writes directly"]
g2["Monitoring timer"] --> g4
g3["keepalive and reconnect"] --> g4
g4 --> g5["Interleaving and follow-up sends occur"]
g5 -.->|"instead"| g6["Consolidate into a single writer"]
Figure 8: Do not add places that write directly; consolidate transmission into one worker.
4.4 Pushing Everything Through ReadLine() / WriteLine()
For a line-based text protocol, ReadLine() / WriteLine() are convenient. But they are convenient only when it truly is a line protocol. NewLine mismatches, newlines inside the payload, character-encoding differences, or mixed binary will break the boundary quickly.
4.5 Leaving Timeouts Undesigned, at Their Defaults
Drop in a careless synchronous read and you get a plain infinite wait. Worse, a configured timeout does not necessarily apply to every way of reading. Implementations that do synchronous reads on the UI thread, try to express everything with a single timeout, or just add retries, tend to wedge.
4.6 Taking RTS/CTS, XON/XOFF, and DTR/RTS Lightly
Handshaking and control lines matter a great deal against real hardware. With mismatched settings, the symptoms tend to be: transmission occasionally stalls, data is dropped beyond a certain volume, or behavior differs only right after opening. Some devices even read DTR/RTS transitions as boot or mode-switch signals.
4.7 Believing a Repeated Open() Equals Reconnection
Especially with USB-serial, it is entirely normal for the port to disappear temporarily, the old handle to become invalid, and the previous pending request to lose its meaning. Reconnection is safer handled as a bundle covering at least: invalidating the session, failing pending requests, stopping the reader / writer, reopening after backoff, and re-running device initialization.
flowchart TB
accTitle: Reconnection is not a repeated Open
accDescr: A diagram showing that with USB-serial the port can disappear and the old handle become invalid, so reconnection bundles invalidating the session, failing pending requests, stopping the reader and writer, reopening after a backoff, and re-running device initialization.
h1["Invalidate the session"] --> h2["Fail pending requests"]
h2 --> h3["Stop the reader / writer"]
h3 --> h4["Reopen after backoff"]
h4 --> h5["Re-run device initialization"]
h1 -.-> h6["Repeating Open alone is not enough"]
Figure 9: Treat reconnection as rebuilding the session, doing this whole sequence together.
4.8 Treating COM Port Enumeration as Truth
GetPortNames() is convenient, but appearing in the list and being openable are not the same. Blindly trusting last time’s COM7, auto-selecting the first enumeration result, or treating presence in the list as validity - these implementations cause operational trouble.
4.9 Thin Send/Receive Logs
TimeoutException, IOException, and Port closed alone tell you almost nothing. If you record send/receive timestamps, the port profile, hex dumps of traffic, parser errors, which request a response belongs to, and the trigger for each reconnect, triage advances considerably.
Decide the format up front and you can grep it and diff it later. For example, settle on a one-line format like this.
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
There are three goals here.
- Keep RX lines and PARSE lines separate. RX says how many bytes arrived; PARSE says how many frames could be carved out. In the example above, one frame arrives across the second and third RX lines, and the remainder becomes the start of the next frame. Record the two kinds mixed together and you cannot tell afterwards whether the split described in 4.1 is happening
- Make sends and receives pairable through
req=. Which response belongs to which command cannot be reconstructed from the logs alone after the fact - Record each state transition on one line. With a transition such as
Ready -> Faultand its reason in the log, the trigger for a reconnect can be traced directly
Hex dumps eat storage, so the realistic arrangement has two tiers: a bounded amount of raw log in a ring buffer, and the summary log retained long term.
flowchart TB
accTitle: Three goals for the traffic log
accDescr: A diagram showing three goals, keeping RX lines separate from PARSE lines, pairing sends and receives through req, and recording each state transition on one line, together with the two-tier arrangement of a bounded raw log in a ring buffer and a summary log retained long term.
i1["Keep RX and PARSE lines separate"] --> i4["A log you can triage from later"]
i2["Pair sends and receives through req"] --> i4
i3["Record state transitions on one line"] --> i4
i4 -.-> i5["Raw log in a ring buffer, summary log kept long term"]
Figure 10: Recording bytes arrived and frames carved separately makes the slip traceable later.
5. Best Practices
What pays off most is separating responsibilities.
reader: only reads bytes from the portwriter: only writes, in order, from the outbound queueparser: only carves frames out of the byte streamprotocol: handles request-response pairing and checksumsapp state: only updates business state
For reception, rather than treating each Read return as a business unit, the stable configuration accumulates into a buffer first and lets the parser carve out frames. Consolidating transmission into one worker - pushing the actual Write toward a single writer - reduces ordering slips.
For timeouts too, rather than settling on one number, separating them by meaning - open, inter-byte, response, reconnect - makes root-cause triage easier. Hold the port settings as a profile rather than ad-hoc code values, and log them at startup; on-site investigation becomes much easier.
Think of reconnection not as a mere reopen but as session regeneration. Rebuild everything - receive buffer, parser state, pending requests, the initialization sequence, and the readiness check - and you reduce the “breaks only occasionally” class of reconnection bugs.
Finally, we recommend keeping both raw logs and summary logs. Raw hex dumps and open / close history are strong for investigation; summaries of request IDs and retry counts are strong for operations.
flowchart TB
accTitle: A pipeline per responsibility
accDescr: A diagram showing the separation where the reader reads bytes from the port, the parser carves frames out of the accumulated buffer, the protocol handles pairing and checksums, app state updates business state, and every caller only enqueues while the writer alone writes.
p0["Port"] --> p1["reader: only reads"]
p1 --> p2["parser: carves out frames"]
p2 --> p3["protocol: pairing and checksum"]
p3 --> p4["app state: updates business state"]
q1["Callers only enqueue"] --> q2["writer: only writes, in order"]
q2 --> p0
Figure 11: The receive and send pipelines. Each role does exactly one job.
From here, skeleton code for just the two places that pay off the most. It assumes .NET 8 / C# 12 with a reference to the System.IO.Ports package.
5.1 Receiving: Accumulate First, Then Carve
As an example, assume a frame of STX(0x02), LEN(1 byte), payload(LEN bytes), and CRC16(2 bytes, little endian).
Any shape works; the point is that frames are cut according to this definition, not according to what a Read happens to return.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
public static class Crc16Modbus
{
// CRC-16/MODBUS: initial value 0xFFFF, right shift with polynomial 0xA001
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 is 1 byte, so anything from 256 bytes up wraps around on the cast.
// The payload is still copied in full, so the receiver cuts the frame at
// the truncated length and reads the middle of the payload as the CRC.
// Every frame boundary after that collapses as well. Whether to split the
// payload or widen LEN to 2 bytes is a protocol decision, so this code
// only rejects the input
if (payload.Length > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(
nameof(payload),
$"A single frame payload can be at most {byte.MaxValue} bytes because LEN is 1 byte.");
}
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>The inter-byte timeout from 3.3. How long to keep assembling a frame before giving up.</summary>
private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);
/// <summary>When the candidate currently being assembled started waiting (a monotonic value).</summary>
private long _pendingSince;
/// <summary>Reports a frame discarded because the CRC did not match. Always subscribe so it reaches the log.</summary>
public event Action<byte[]>? FrameDiscarded;
/// <summary>Reports that assembly was abandoned and the parser resynchronized. A steadily rising count points at the wiring or the settings.</summary>
public event Action<int>? Resynchronized;
/// <summary>Accumulates received bytes and returns only the frames that could be carved out.</summary>
public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
{
foreach (var b in received)
{
_buffer.Add(b);
}
var frames = new List<byte[]>();
while (true)
{
// 1. Discard bytes until STX is at the head. Noise and leftovers from the previous frame are absorbed here
var stxIndex = _buffer.IndexOf(Frame.Stx);
if (stxIndex < 0)
{
_buffer.Clear();
_pendingSince = 0; // no candidate left, so stop timing the wait as well
break;
}
if (stxIndex > 0)
{
// The head of the candidate changed, which means assembly of a different frame starts
_buffer.RemoveRange(0, stxIndex);
_pendingSince = 0;
}
// 2. Has enough arrived to read the length?
if (_buffer.Count < Frame.HeaderLength)
{
if (GiveUpOnStaleCandidate()) { continue; }
break; // not corrupted, just not enough yet
}
int payloadLength = _buffer[1];
int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;
// 3. Is one whole frame present?
if (_buffer.Count < frameLength)
{
// At this point, not enough yet and LEN corrupted by noise cannot be
// told apart. If noise or a false STX turns LEN into 255, the parser
// keeps swallowing the correct frames that arrive afterwards as
// payload, and nothing surfaces until 259 bytes are in and the CRC
// check fails. On a device with light traffic this looks like minutes
// of no response. Cap the wait, and past the cap drop the candidate
// and look for STX again
if (GiveUpOnStaleCandidate()) { continue; }
break; // leave here and wait for the next receive
}
var frame = _buffer.GetRange(0, frameLength).ToArray();
_buffer.RemoveRange(0, frameLength);
_pendingSince = 0;
// 4. Discard anything whose CRC does not match, and always report the discard
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
{
// Whether to drop a whole frame here or drop just one STX byte and re-read is a design decision.
// The former is simpler, the latter is stronger when LEN itself was noise. Pick one and write it down.
FrameDiscarded?.Invoke(frame);
}
}
return frames;
}
/// <summary>
/// If the candidate being assembled has exceeded AssemblyTimeout, discard just the leading STX byte.
/// Returns true after discarding, and the caller re-reads from the next STX.
/// A whole frame is not discarded because the real STX may sit inside this candidate.
/// </summary>
private bool GiveUpOnStaleCandidate()
{
if (_pendingSince == 0)
{
// The moment the wait begins. A wall clock can jump on NTP sync, so measure with a monotonic value
_pendingSince = Stopwatch.GetTimestamp();
return false;
}
if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
{
return false;
}
_buffer.RemoveAt(0);
_pendingSince = 0;
Resynchronized?.Invoke(_buffer.Count);
return true;
}
}
GiveUpOnStaleCandidate is the actual implementation of the inter-byte timeout from 3.3. Without it, when noise or a false STX corrupts LEN into a large value (255, say), the parser keeps treating the situation as “not enough yet.” It then swallows even the correct frames that arrive afterwards as part of the corrupted payload, and nothing surfaces until 259 bytes have accumulated and the CRC check fails. On a device with light traffic, that looks like several minutes of no response. Only one STX byte is discarded because the real STX may be buried inside the candidate.
Two premises are worth stating. This timeout is only evaluated when Append is called. If the line goes completely silent, nothing happens on the parser side, so that case is caught by the caller’s response timeout (5.2). Second, derive the value of AssemblyTimeout from the baud rate and the frame length. The lower bound is the transmission time of one byte multiplied by the largest frame you expect, plus some margin; anything shorter throws away healthy frames midway. Logging how often Resynchronized fires gives you grounds for suspecting the wiring or the baud rate setting.
flowchart TB
accTitle: A corrupted LEN swallows correct frames
accDescr: A diagram showing that when noise or a false STX corrupts LEN into a large value the parser keeps treating it as not enough yet and swallows even the correct frames that follow as payload, so nothing surfaces until the CRC check fails, and that a timeout which discards one STX byte restores synchronization.
j1["Noise or a false STX corrupts LEN"] --> j2["The parser keeps waiting for more"]
j2 --> j3["It swallows the correct frames too"]
j3 --> j4["Looks unresponsive until the CRC fails"]
j4 -.->|"fix"| j5["On timeout drop one STX byte and resync"]
Figure 12: Without an inter-byte timeout, a corrupted LEN keeps swallowing the frames that follow.
Keep the read side to reading from the port and handing bytes to the parser, nothing more. Start writing business logic here and whatever Read happens to return turns into a business unit.
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);
}
}
}
}
Not using DataReceived is deliberate. As covered in 4.2, it carries no more meaning than “something seems to have arrived,” so owning the read loop yourself makes state and timeouts easier to manage.
5.2 Sending: Consolidate into a Single Writer
On the send side, the key is to never create a state in which anything can Write.
Anyone may call the code that enqueues; only one worker performs the actual Write.
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>Upper bound of the send queue. Derive it from one device round trip multiplied by the queue depth you accept.</summary>
private const int QueueCapacity = 64;
private readonly SerialPort _port;
private readonly TimeSpan _responseTimeout;
// Do not leave this unbounded. If the UI, timers, and workers enqueue faster
// than the device turns requests around, frames and TaskCompletionSource
// instances pile up without limit and memory keeps growing even though the
// device is responding. Set a bound, and hand overflow back to the sender
private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
new BoundedChannelOptions(QueueCapacity)
{
// When it is full, TryWrite returns false and the caller learns
// immediately that the queue is backed up. DropOldest is not used:
// whoever enqueued is awaiting a Task, so a silent drop means it
// never returns
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
});
private Outbound? _inFlight;
public SingleWriter(SerialPort port, TimeSpan responseTimeout)
{
_port = port;
_responseTimeout = responseTimeout;
}
/// <summary>Safe to call from the UI or from a timer. Only one worker performs the actual Write.</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))
{
// Either the queue is full or the worker has already stopped. In both
// cases report back to the caller that the item could not be enqueued.
// Drop it silently and the Task being awaited never returns
item.Completion.TrySetException(new InvalidOperationException(
$"Could not enqueue into the send queue (capacity {QueueCapacity}, or the worker has stopped)."));
}
return item.Completion.Task;
}
/// <summary>Call this when the parser carves out a frame. It binds the frame to the one request awaiting a response.</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)
{
// A pulled cable, a closed port, or a cancellation lands here.
// Leave without completing this item and the caller awaiting
// SendAsync waits forever
Interlocked.Exchange(ref _inFlight, null);
item.Completion.TrySetException(ex);
throw;
}
// Waiting for the response here is what keeps the next command from cutting in
var timeout = Task.Delay(_responseTimeout, token);
var finished = await Task.WhenAny(item.Completion.Task, timeout);
if (finished != item.Completion.Task)
{
// On a stop request, Task.Delay is cancelled and finishes first too.
// Without this check, an orderly shutdown is treated as a timeout
// and the caller receives a TimeoutException
token.ThrowIfCancellationRequested();
// The response can arrive after WhenAny has already picked the
// timeout. In that case OnFrameReceived has taken _inFlight and
// completed this item successfully. Failing to take it means the
// response won, and TrySetException here would be a no-op. Miss
// that and continue as far as the throw, and the caller has its
// result while only the worker dies. Settle the race with
// CompareExchange
if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
{
// The response won. OnFrameReceived sets the completion, right after taking it
await item.Completion.Task;
continue;
}
item.Completion.TrySetException(new TimeoutException("No response was received."));
// Once this times out, the connection can no longer be trusted.
// The reason give up and send the next one is not good enough is
// written below: this protocol carries no request ID, so a late
// response to A ends up bound to the next command B. Following
// the state diagram in 3.6, drop to Fault and rebuild the session
throw new TimeoutException("No response, so the session will be rebuilt.");
}
}
}
finally
{
// Whatever stopped the worker, always complete everything left waiting:
// both the item awaiting a response and whatever is still queued unsent
var stopped = new OperationCanceledException("The send worker has stopped.");
Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
_queue.Writer.TryComplete();
while (_queue.Reader.TryRead(out var pending))
{
pending.Completion.TrySetException(stopped);
}
}
}
}
Stopping the whole worker on a timeout looks heavy-handed, but it is a necessary measure. This frame carries no request ID. The receiver therefore cannot tell which command the frame that just arrived answers, and OnFrameReceived has no choice but to bind it mechanically to the one request awaiting a response.
Keep sending the next command after a timeout here and this is what happens.
- Command A is sent. No response arrives within the configured time, so it is treated as a timeout
- Command B is sent next
- The late response to A is handed to the caller as the response to B
From the caller’s point of view, it sent B and got A’s value back. The value is well formed, so validation passes too, which makes this the hardest breakage of all to find. The state diagram in 3.6 has no edge running directly from Fault back to Ready precisely because this path exists. A timeout is not “one failed request” but the judgement that “this connection can no longer be trusted,” and recovery from a state where you do not know what is left in the receive buffer can only be guaranteed by session regeneration: closing the port and opening it again.
If you can reach into the protocol, the more fundamental fix is to carry a request ID in the frame and match responses against it. Then a late response is simply discarded as an unknown ID, and there is no need to rebuild the connection on every single timeout.
sequenceDiagram
accTitle: A late response bound to the wrong command
accDescr: A diagram showing that in a protocol without request IDs, sending command B after command A has timed out lets the late response to A reach the caller as the response to B, which is why the session is rebuilt through Fault after a timeout.
participant C as Caller
participant W as Worker
participant D as Device
C->>W: Send command A
W->>D: Transmit A
Note over W: No response in time, so it times out
C->>W: Send command B
W->>D: Transmit B
D-->>W: Late response to A
W-->>C: Handed over as the response to B
Note over W: This is why a timeout drops to Fault
Figure 13: Without a request ID, a late response gets bound to the next command.
Finally, wire it all together. Section 3.4 runs all the way through holding the port settings as a profile in one place and logging them at startup.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
// Keep the settings decided in 3.4 together in one place, not as ad-hoc values
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);
// Always subscribe to the events you defined. Forget this and neither discarded frames nor responses ever surface
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
{
// Even when the send fails, always stop the workers before leaving. Skip this
// and the reader/writer keep touching the stream after the using disposal has
// closed the SerialPort, and nobody observes the exceptions that result
cts.Cancel();
try
{
await Task.WhenAll(readerTask, writerTask);
}
catch (OperationCanceledException)
{
// Shutdown caused by the stop request. Treat this as the normal path
}
catch (Exception ex)
{
// A worker-side failure. Rethrowing here would mask the original reason
// for the failure (the exception from SendAsync), so only record it
Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
}
}
Putting cts.Cancel() and Task.WhenAll inside finally is not a matter of style. In serial communication, a failing SendAsync is routine rather than exceptional: the device does not answer, the cable comes loose, the write times out. Let that propagate straight up and you reach the using disposal without having stopped the workers. The reader and writer keep touching the stream after SerialPort has been closed, and nobody observes the exceptions raised there. In a long-running application this piles up gradually, as a leftover worker for every failed operation. The exception from the finally block is not rethrown, so that it does not mask the original reason for the failure (the exception from SendAsync).
flowchart TB
accTitle: Always stop the workers, even on failure
accDescr: A diagram showing that a failing SendAsync is routine in serial communication and that letting it propagate straight up disposes the SerialPort without stopping the workers, so the reader and writer keep touching a closed stream and nobody observes the exceptions, which is why cancellation and awaiting belong in finally.
k1["SendAsync fails, which is routine"] --> k2["Cancel and await inside finally"]
k2 --> k3["Stop the workers before disposal"]
k1 -.-> k4["Skip it and a closed stream keeps being touched"]
k4 -.-> k5["A long-running app keeps a worker per failure"]
Figure 14: A failed send is exactly when finally has to stop the workers before leaving.
With this shape in place, whatever you want to add later - retry, keepalive, reconnect - all fits either on the enqueueing side or on the worker side. The number of places that call Write directly does not grow, so neither do the causes of ordering slips.
6. The Checklist to Run First
- Are message boundaries written down explicitly?
- Is reception structured as byte accumulation -> frame extraction?
- Are you treating
DataReceivedas message arrival? - Is there synchronous I/O on the UI thread?
- Is transmission a single writer?
- Are timeouts split by meaning rather than being one value?
- Are
Handshake/ DTR / RTS explicit? - Does reconnection rebuild the session?
- Are raw hex dumps recorded?
- Have you tested physical unplug/replug and mid-stream disconnection?
If several of these items look shaky, it is worth pausing once before going to production.
7. Summary
Finally, the key points one more time.
- Serial communication is a byte stream, not messages
Readunits and message units do not coincide- Boundaries must be defined as a protocol
- Treating
DataReceiveddirectly as a business event is fragile - Separate send/receive responsibilities, and push transmission toward a single writer
- Split timeouts by meaning, and design reconnection at the session level
- Logs that include raw hex dumps make later investigation much easier
In other words, in a serial communication app, how you interpret the byte stream and how you control time and state matters far more than opening the port. Just separating these concerns in the initial design substantially reduces the “breaks only occasionally” class of communication defects.
flowchart TB
accTitle: Interpretation and control matter more than opening
accDescr: A diagram showing that in a serial communication app how you interpret the byte stream and how you control time and state matters more than opening the port, and that separating these concerns in the initial design reduces the defects that break only occasionally.
m1["Opening the port"] -.-> m2["This is not the hard part"]
m3["Interpreting the byte stream"] --> m5["Separate them in the initial design"]
m4["Controlling time and state"] --> m5
m5 --> m6["Fewer defects that break only occasionally"]
Figure 15: What matters is not opening the port but designing the interpretation and the control.
8. References
- Microsoft Learn,
SerialPort.DataReceivedEvent - Microsoft Learn,
SerialPort.ReadMethod - Microsoft Learn,
SerialPort.ReadTimeoutProperty - Microsoft Learn,
SerialPort.BaseStreamProperty - Microsoft Learn,
SerialPort.NewLineProperty - Microsoft Learn,
HandshakeEnum - Microsoft Learn,
SerialPort.DtrEnableProperty - Microsoft Learn,
SerialPort.RtsEnableProperty - Microsoft Learn,
SerialPort.GetPortNamesMethod - Microsoft Learn,
SerialPortClass - Microsoft Learn,
COMMTIMEOUTSstructure - Microsoft Learn,
DCBstructure - Microsoft Learn,
CreateFilefunction - pySerial API, Serial API Reference
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
How to Work with USB Devices from a Windows App — Choosing Between virtual COM, HID, WinUSB, and Vendor SDKs
A comparison of four ways to control equipment and USB devices from a Windows application: virtual COM port, HID, WinUSB, and vendor SDKs...
Windows App Outsourcing and Custom Software Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or custom software development, here is how to sort out existing software modification, devi...
Using WMI/CIM from C# and PowerShell — A Practical Guide to Hardware Info Retrieval, Process Monitoring, and Remote Queries
WMI/CIM is the standard way to get a PC's serial number, monitor free disk space, and detect process launches. This article covers how to...
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
Versioning Your Business App's Database Schema — Migration Practices to Prevent 'Every Customer Has a Different DB'
A practical guide to versioning the database schema of a business app whose databases are scattered across customer sites. Covers PRAGMA ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
Windows applications that include serial communication are more stable when designed end to end, covering receive handling, state transitions, reconnection, and UI separation.
Bug Investigation & Root Cause Analysis
This topic fits well with triaging communication failures such as occasional hangs, failure to recover only after a USB unplug/replug, and causality that cannot be traced from the logs.
Technical Consulting & Design Review
Sorting out protocol boundaries, flow control, timeouts, and single-writer design before implementation makes it easier to avoid defects with expensive rework.
Frequently Asked Questions
Common questions about the topic of this article.
- If I call Read(16) in serial communication, do I receive exactly 16 bytes?
- Not necessarily. Serial communication is an ordered byte stream, and message boundaries do not appear on their own. What you sent with a single Write may arrive at the other side split into two, or concatenated with other data. Typical breakage: the length arrives but the payload has not, one and a half frames arrive, or two frames arrive together. The fix is to split the work so that received bytes are accumulated into a buffer first and a parser carves frames out of that buffer.
- What should I watch out for when using the .NET SerialPort.DataReceived event?
- DataReceived is not guaranteed to fire once per received byte, and it does not run on the UI thread. Treating it as a notification that one message has arrived is dangerous. In practice, regard it as no more than a hint that something seems to have arrived, do no heavy work inside the handler, and always marshal UI updates back to the UI thread. Accumulating received bytes first and letting a parser carve frames out of them is the stable structure.
- How should timeouts be designed for serial communication?
- One timeout is not enough; separating them by meaning is more stable. Use an open timeout until the port opens, an inter-byte timeout for the gap with no byte arriving mid-frame, a response timeout from command issue to response completion, and a reconnect backoff as the wait interval between reconnection attempts. Timeouts are more stable when held as rules that advance the state machine rather than as insurance against slowness. Note as well that carelessly leaving a synchronous read at its defaults gives you a plain infinite wait.
- Why does a USB-to-serial adapter fail to recover after the cable is unplugged and plugged back in?
- Because with USB-serial it is entirely normal for the port to disappear temporarily, the old handle to become invalid, the COM number to change, and the previous pending request to lose its meaning. Calling Open() again is not enough to count as reconnection. Design it as session regeneration that bundles invalidating the session, failing pending requests, stopping the reader and writer, reopening after a backoff, and re-running the device initialization sequence; that reduces the reconnection bugs that break only occasionally.