C# Winform串口通讯上位机源码:多串口管理与CRC帧解析

发布时间:2026/10/26 2:06:29
C# Winform串口通讯上位机源码:多串口管理与CRC帧解析

简介:这是一份面向C#初学者与工控上位机开发入门者的串口通讯实践项目源码,围绕Windows Forms桌面程序演示串口选择、波特率/数据位/停止位/校验位配置、开关串口、收发数据、事件回调与异常处理,并加入实时监听和界面交互设计,帮助读者理解串口通讯在设备控制与数据传输中的落地方式。压缩包共53个文件、约138KB,以C#源码和Winform界面资源为主,辅以项目/解决方案配置、可执行程序及少量图片、说明与INI配置等,结构完整,便于直接打开工程对照学习。已有2866人学习下载,适合作为课程设计、入门练手或小型上位机原型参考,也能从中借鉴参数保存、通讯流程拆分与常见错误提示等实现思路。

1. 产线上那台不肯重连的工控机

去年帮一个做仪表的团队收拾过一个烂摊子:产线上工控机同时挂着扫码枪、电子秤和一块老式温控板,三路串口通讯上位机各自为政,谁先抢到端口谁先跑,拔插一次 USB 转串口线,整条线就得停机重来。这类需求看着土,但它是 C# Winform 串口通讯上位机最典型的形态——界面不花哨,核心只有三件事:把端口打开、按协议收发、把数据稳定落进表格和日志。这套源码就是一个能直接跑起来的 Winform 上位机工程:多串口管理、参数配置持久化、Hex/ASCII 双模式收发、帧解析加 CRC 校验、通讯日志留存,适合刚接触工控上位机的开发者照着跑通,也适合手上有设备协议、需要快速搭一个调试工具的人拿去改。

2. 工程骨架与技术栈:一个上位机该怎么分层

2.1 从解决方案到目录:谁管界面、谁管通讯

拿到源码包先别急着按 F5,先看目录。这类上位机如果写成“一个 Form 里塞两千行”,功能是能跑,但改一次协议就要动界面代码,属于典型的不可维护形态。我拆过的合格工程一般是这么分的:

SerialUpperComputer/ ├─ SerialUpperComputer.sln ├─ src/ │ ├─ App/ # Winform 表现层 │ │ ├─ Forms/MainForm.cs # 主窗体,只管控件和事件 │ │ ├─ Forms/MainForm.Designer.cs │ │ └─ ViewModels/MainViewModel.cs │ ├─ Core/ # 通讯内核,不引用任何 Winform │ │ ├─ CommService.cs # SerialPort 封装、打开/关闭/收发 │ │ ├─ ISerialTransport.cs # 传输层接口,便于换虚拟串口做测试 │ │ └─ SerialConfig.cs # 端口参数模型 │ ├─ Protocol/ # 协议层 │ │ ├─ FrameParser.cs # 字节流 → 帧 │ │ ├─ FrameBuilder.cs # 帧 → 字节流 │ │ ├─ Crc16.cs # 校验 │ │ └─ Commands.cs # 命令字定义 │ └─ Infrastructure/ │ ├─ AppSettings.cs # 配置读写(端口记忆) │ └─ CsvLogger.cs # 通讯日志落盘 └─ tests/Protocol.Tests/ # 帧解析单元测试

分层的关键判断只有一个:Core和Protocol里不能出现System.Windows.Forms引用。这一条守住,后面做多串口、做命令行压测、写单元测试都不会被界面拖住。我见过太多工程把解析逻辑写在DataReceived事件里,结果一个解析 bug 只能在真机上复现,没有后悔药可吃。

2.2 技术选型:为什么还是 Winform 加 SerialPort

标题里写的是 Winform,实际工程里也确实没必要上 WPF 或 Avalonia。工控现场的机器大多是 Windows 7 到 Windows 10 的旧机型,.NET Framework 4.6 到 4.8 一把梭,Winform 的控件渲染最省事,数据表格、按钮、下拉框拖出来就能用,发布就是一个 exe 加几个 dll,拷贝到现场机器上双击就能跑,不用装运行时。

真正干活的类是System.IO.Ports.SerialPort,它封装了 Win32 的串口 API,Windows 上不用装任何驱动库(USB 转串口的芯片驱动另说,那是系统层的)。它自带DataReceived事件,省掉了自己开线程轮询ReadFile的麻烦。要提醒一句:如果目标框架迁移到 .NET 6 及以上,System.IO.Ports变成了独立 NuGet 包,需要额外引入,这个坑后面会再提。

2.3 一条数据从端口到界面的数据流

把链路画在脑子里比画在图上更有用:

UI 点击"发送" → CommService.Send(bytes) → 发送队列(BlockingCollection) → 后台线程 SerialPort.Write → 设备回包 → SerialPort 内部线程触发 DataReceived → 读空缓冲区,入接收队列 → 消费线程取出,交给 FrameParser.Feed → 状态机切出完整帧 + CRC 校验 → 批量 BeginInvoke 回 UI 线程刷表格 → 同时写 CSV 日志

这个链路里有三个线程:UI 线程、SerialPort 的接收辅助线程、发送队列的后台线程。新手最容易在这里翻车,因为看起来代码是顺序写的,实际上全在不同线程跑。第 4 章会把每个环节的代码摊开讲。

3. 串口打开与参数配置:8N1、波特率和握手线

3.1 SerialPort 初始化的每个参数到底改什么

先上代码,这是我一般会写的初始化片段,参数一个不落:

public sealed class CommService : IDisposable { private readonly SerialPort _port = new SerialPort(); private readonly object _stateLock = new object(); public bool IsOpen => _port.IsOpen; public void Open(SerialConfig cfg) { lock (_stateLock) { if (_port.IsOpen) _port.Close(); // 重复打开前先关,避免端口残留占用 _port.PortName = cfg.PortName; // "COM3" / "COM12",注意两位数不带前导零 _port.BaudRate = cfg.BaudRate; // 9600、19200、115200,必须和设备一致 _port.DataBits = cfg.DataBits; // 8 _port.StopBits = cfg.StopBits; // StopBits.One _port.Parity = cfg.Parity; // Parity.None _port.Handshake = cfg.Handshake; // 默认 None,485 场景才开 RTS/CTS _port.ReadBufferSize = 4096; // 接收缓冲,太小会丢,太大会加延迟 _port.WriteBufferSize = 4096; _port.ReadTimeout = 500; // 同步读的兜底超时,毫秒 _port.WriteTimeout = 500; _port.ReceivedBytesThreshold = 1; // 收到 1 字节就触发事件,别设成 8 或更大 _port.DtrEnable = cfg.DtrEnable; // 部分设备靠 DTR 电平判断上位机在线 _port.RtsEnable = cfg.RtsEnable; _port.DataReceived += OnDataReceived; _port.ErrorReceived += OnErrorReceived; _port.Open(); // 这里抛 UnauthorizedAccessException = 端口被占 } } }

参数说明按重要性排:PortName和BaudRate错一个就是全乱码或全无响应;DataBits、StopBits、Parity三项合起来就是常说的 8N1,工业设备九成以上是 8N1,只有老式仪表会用到 7E1;Handshake是硬件流控,默认None就对了,有人误开RequestToSend之后发现收不到数据,根源就在这里;ReceivedBytesThreshold我坚持设 1,设大了事件触发会延迟,短帧可能等半天。

3.2 打开与关闭的生命周期,异常要分类型处理

Open()会抛的异常不止一种,混在一起 catch 住是排查噩梦:

try { _comm.Open(cfg); _isConnected = true; } catch (UnauthorizedAccessException) { // 最常见:端口被别的进程占用,或者上次没关干净 ShowTip("端口被占用,检查是否有其他上位机在运行"); } catch (IOException ex) { // 线没插、驱动没装、USB 转串口掉线 ShowTip($"端口无法访问:{ex.Message}"); } catch (ArgumentException) { // PortName 写了个不存在的 COM 号 ShowTip("串口名称无效,请刷新端口列表"); }

关闭比打开更需要小心,这也是我在现场见过最多卡死的地方:

public void Close() { lock (_stateLock) { if (!_port.IsOpen) return; _port.DataReceived -= OnDataReceived; // 先摘事件,阻止新数据进来 _port.ErrorReceived -= OnErrorReceived; try { _port.DiscardInBuffer(); } catch { } try { _port.DiscardOutBuffer(); } catch { } _port.Close(); // 内部会等接收线程退出 _port.Dispose(); } }

顺序不能反。先摘事件再Close(),否则Close()内部等待读线程结束时,读线程可能正卡在你的事件处理里等 UI 线程回包,形成死锁,表现就是“点关闭按钮窗口卡住”。这个现象在第 5 章还会展开。

3.3 端口记忆:配置文件里该存什么

上位机每次开机都让用户重选串口和波特率,操作工很快就会骂人。配置我一般存 JSON,放%AppData%下而不是 exe 同目录——现场机器经常是只读目录或者多人共用账号。

public sealed class SerialConfig { public string PortName { get; set; } = "COM1"; public int BaudRate { get; set; } = 9600; public int DataBits { get; set; } = 8; public StopBits StopBits { get; set; } = StopBits.One; public Parity Parity { get; set; } = Parity.None; public Handshake Handshake { get; set; } = Handshake.None; public bool DtrEnable { get; set; } = true; public bool RtsEnable { get; set; } = false; public string Encoding { get; set; } = "GBK"; // 中文设备多是 GBK public bool HexDisplay { get; set; } = true; }

Encoding这一项要单独说:SerialPort.Encoding属性只影响ReadLine、Write(string)这类字符串接口,实际收发我全走byte[],编码只在“把字节解释成文本显示”这一步用。GBK 和 UTF-8 选错,界面就是一屏问号,这个属于玄学问题的重灾区。

4. 收发链路拆解:从字节流到一帧完整报文

4.1 DataReceived 跑在哪个线程,怎么把数据安全送出去

private readonly ConcurrentQueue<byte[]> _rxQueue = new ConcurrentQueue<byte[]>(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { // 这个回调由 SerialPort 内部的辅助线程触发,不是 UI 线程 try { int n = _port.BytesToRead; if (n <= 0) return; var buf = new byte[n]; int read = _port.Read(buf, 0, n); // 一次读干净,别留下半截在缓冲里 if (read <= 0) return; if (read != n) Array.Resize(ref buf, read); _rxQueue.Enqueue(buf); // 事件里只入队,不做解析、不碰控件 } catch (Exception ex) { RaiseError(ex); // 回调里抛异常会直接吞掉,必须自己兜 } }

BytesToRead加Read的组合比ReadExisting()稳,因为后者按Encoding转成字符串,遇到二进制帧会把 0x00、0x80 这类字节搞坏。事件里只做入队,是这条链路的第一原则:解析逻辑一旦放进来,解析耗时就会拖慢串口内部线程,高波特率下直接表现为丢包。

消费端由一个独立线程驱动:

private void RxLoop(CancellationToken token) { var parser = new FrameParser(); while (!token.IsCancellationRequested) { if (!_rxQueue.TryDequeue(out var chunk)) { Thread.Sleep(5); // 队列空时小睡,别用 SpinWait 烧 CPU continue; } var frames = parser.Feed(chunk); if (frames.Count > 0) OnFramesReceived(frames); } }

4.2 粘包与分包:帧头加长度字段的状态机

串口是字节流,没有“包”的概念。设备一次回 20 字节,你的DataReceived可能触发两次,分别拿到 12 字节和 8 字节;也可能两次回包粘在一次里给你 40 字节。所以必须有一个缓冲加状态机。假设帧格式是0xAA 0x55 | LEN | CMD | DATA | CRC16(小端):

public sealed class FrameParser { private readonly List<byte> _cache = new List<byte>(8192); private const int MaxFrameLen = 256; // 协议上限,超过就是脏数据 public List<Frame> Feed(byte[] chunk) { var result = new List<Frame>(); _cache.AddRange(chunk); while (true) { int head = FindHead(); // 找 AA 55 if (head < 0) { _cache.Clear(); break; } // 没有帧头,整段丢弃 if (head > 0) _cache.RemoveRange(0, head); // 丢掉帧头前的噪声字节 if (_cache.Count < 4) break; // 长度字段还没到齐,等下一批 int len = _cache[2]; if (len < 5 || len > MaxFrameLen) { _cache.RemoveAt(0); // 长度非法,滑一个字节重新找帧头 continue; } if (_cache.Count < len) break; // 半包,等下一批数据 var frame = _cache.GetRange(0, len).ToArray(); _cache.RemoveRange(0, len); ushort crc = (ushort)(frame[len - 2] | (frame[len - 1] << 8)); if (Crc16.Compute(frame, 0, len - 2) != crc) continue; // 校验失败,丢弃 result.Add(Frame.Parse(frame)); } return result; } }

这段代码有三个细节值得盯:head < 0时整段清空而不是保留尾巴,是为了防止噪声无限堆积;长度非法时RemoveAt(0)只滑一个字节而不是清空,是因为AA 55可能出现在数据区里,清空会误伤后面真正的帧;_cache必须设上限(这里靠MaxFrameLen间接约束),否则设备一直发脏数据会把内存吃光。

CRC 是配套的,Modbus 那套算法用得最多:

public static class Crc16 { // Modbus CRC16:多项式 0xA001(反向表示),初值 0xFFFF public static ushort Compute(byte[] data, int offset, int count) { ushort crc = 0xFFFF; for (int i = offset; i < offset + count; i++) { crc ^= data[i]; for (int b = 0; b < 8; b++) crc = (crc & 1) == 1 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1); } return crc; } }

参数就三个:缓冲区、起始偏移、参与计算的字节数。注意末尾两字节 CRC 本身不参与计算,所以传的是len - 2。还有人搞错高低位顺序,设备发的是小端,拼回来要low | high << 8,搞反了就是每帧校验失败。

4.3 发送队列与超时重发,别在主线程里死等

发送我从不直接在按钮事件里调Write,因为WriteTimeout一旦触发,UI 就僵在那 500 毫秒。用队列加消费线程:

private readonly BlockingCollection<TxItem> _txQueue = new BlockingCollection<TxItem>(1024); public bool Send(byte[] payload, int timeoutMs = 800) { var item = new TxItem { Payload = payload, TimeoutMs = timeoutMs, Waiter = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously) }; if (!_txQueue.TryAdd(item)) return false; // 队列满,说明下游堵了 return true; } private void TxLoop(CancellationToken token) { foreach (var item in _txQueue.GetConsumingEnumerable(token)) { try { _currentWaiter = item.Waiter; // 交给解析层,收到匹配回包时 SetResult _port.Write(item.Payload, 0, item.Payload.Length); if (!item.Waiter.Task.Wait(item.TimeoutMs)) item.Waiter.TrySetException(new TimeoutException("设备无应答")); } catch (InvalidOperationException) { break; // 端口已关闭,退出循环 } catch (TimeoutException ex) { RaiseError(ex); // 触发重发策略或提示 } finally { _currentWaiter = null; } } }

TaskCompletionSource加Wait的组合比Thread.Sleep轮询干净得多:回包一到,解析层根据命令字找到_currentWaiter并TrySetResult,等待立即结束,不用等满超时。RunContinuationsAsynchronously这个参数别省,否则回包线程可能直接执行后续逻辑,又是一次跨线程踩坑。

4.4 界面刷新与 Hex/ASCII 双模式

高频数据下最忌讳一条一条Invoke:

private void OnFramesReceived(List<Frame> frames) { if (IsDisposed || !IsHandleCreated) return; // 窗体正在关,直接丢 BeginInvoke(new Action(() => { _grid.SuspendLayout(); foreach (var f in frames) { _grid.Rows.Add(f.Timestamp.ToString("HH:mm:ss.fff"), $"0x{f.Cmd:X2}", _cfg.HexDisplay ? ToHex(f.Data) : ToText(f.Data, _cfg.Encoding)); } while (_grid.Rows.Count > 1000) _grid.Rows.RemoveAt(0); // 限行,防内存涨 _grid.ResumeLayout(); _logger.Write(frames); // 落 CSV,别在 UI 线程做 IO 重活 })); } private static string ToText(byte[] data, string enc) { // 只在显示时转文本,收发全程用 byte[] return Encoding.GetEncoding(enc).GetString(data); }

BeginInvoke而不是Invoke,是因为前者是投递消息不阻塞调用线程;SuspendLayout包住批量插入,几千行刷新也不闪。限行数 1000 是我自己的习惯,现场调试时没人会往上翻到第 5000 行,反而内存和滚动性能先垮。

5. 避坑与排查:串口上位机五个高频翻车现场

5.1 第二次打开报“拒绝访问”,进程里还留着旧句柄

现象:点了关闭按钮,再点打开,弹“对端口的访问被拒绝”;关掉程序重开又正常。

原因:Close()没有真正执行完,或者窗体关闭时走的是Application.Exit()直接杀线程,SerialPort的句柄没释放。更隐蔽的一种是DataReceived事件没摘,接收线程还挂在里面。

解决:把关闭逻辑统一收到一处,先摘事件再Close(),再加Dispose();在MainForm.FormClosing里显式调用一次,别指望析构函数。调试期可以用设备管理器看端口是否还在被占用。

5.2 点关闭按钮界面卡死,任务管理器都杀不掉

现象:程序运行正常,一点 X 窗口就白屏,鼠标转圈,任务管理器显示“未响应”。

原因:经典的死锁。DataReceived里用了Invoke(同步等待 UI 线程),而 UI 线程此时正在Close()里等接收线程退出,两边互等。

解决:事件里只入队,绝不碰控件;UI 更新一律BeginInvoke;关窗前先把CancellationToken置位,让消费线程退出,再关端口。这一条属于血泪经验,改起来两行代码,找起来能耗一下午。

5.3 高波特率下偶发半帧、数据少一截

现象:115200 下跑几分钟就丢一两帧,9600 下没事。

原因:DataReceived里做了耗时操作(写日志、刷新表格),拖慢串口内部线程,接收缓冲区溢出;或者ReceivedBytesThreshold设得太大,事件迟迟不触发。

解决:阈值设 1,事件里只Read加入队;重活全部丢到消费线程。如果还丢,把ReadBufferSize提到 8192 试试,同时确认设备端是不是发了流控信号而你没开Handshake。

5.4 中文全是问号或方块

现象:设备回的是中文,界面显示????。

原因:Encoding.GetEncoding("GBK")在 .NET Core / .NET 6+ 上默认不可用,需要注册CodePagesEncodingProvider;或者设备本身就是 GBK 而你按 UTF-8 解。

解决:启动时加一行Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);,配置里把编码做成可选下拉框,让现场自己试。收发包本身永远走byte[],编码只影响显示和日志。

5.5 换了个 USB 口,COM 号变了,配置全废

现象:昨天还好好的,今天开机连不上,设备管理器里 COM 号从 COM3 变成 COM7。

原因:USB 转串口芯片按物理端口分配 COM 号,换口就变;配置文件里存的是死字符串。

解决:配置里同时存PortName和设备的 USB 描述(可用 WMI 查Win32_PnPEntity里的Caption),启动时按描述匹配;匹配不到再退回按名字。界面上给一个“刷新端口”按钮,别让操作工去设备管理器里翻。

6. 二次开发:把写死的协议换成插件

协议要改是常态——今天接温控板,明天接另一家的扫码枪,帧头、长度字段、校验方式全不一样。我的做法是抽一个协议接口,用简单工厂注册:

public interface IProtocol { string Name { get; } // 显示在界面下拉框 byte[] Build(byte cmd, byte[] data); // 组帧 List<Frame> Parse(byte[] chunk); // 流式解析,内部自带缓冲 bool Match(Frame received, byte cmd); // 判断回包是否属于本次请求 }

Parse设计成流式是关键:调用方每次喂一段字节,由实现类自己维护缓冲,粘包分包逻辑就封在插件里,主程序完全无感。新增一种协议只要实现这个接口,在工厂里加一行注册,界面下拉框自动多一项。

改造点改动位置验证方式
换协议帧格式新增IProtocol实现单元测试喂构造好的半包、粘包用例
加数据落库CsvLogger旁挂一个仓储用虚拟串口对回环跑 10 分钟看行数
变多串口CommService提成列表,按端口名索引两路同时发,看日志时间戳是否交叉
迁 .NET 6+引System.IO.PortsNuGet 包先跑_port.Open()冒烟,再验编码

用虚拟串口对工具建一对互联的 COM 口,一端给上位机,一端用脚本模拟设备回包,是我验证任何改动的第一步——真机不在手边也能复现粘包和超时。从那以后我每次给串口工程加功能,都强制先用虚拟串口把半包、脏数据、超时三条用例跑一遍再上现场,毕竟现场停机一次的代价,比在工位上多敲二十分钟测试高太多。希望帮到你。

本文还有配套的精品资源,点击获取