ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#上位机开发:自制协议与串口/TCP/UDP通道抽象完整指南

C#上位机开发:自制协议与串口/TCP/UDP通道抽象完整指南 简介这是一份面向嵌入式与上位机开发学习者的完整项目资料基于C#设计Windows上位机配合自制通信协议实现与下位机的多功能交互支持串口、TCP、UDP三种通信方式。包内共125个文件约1.77MB其中60个h文件与39个c文件构成为下位机嵌入式代码主体基于STM32F4系列另含uvprojx工程配置、hex烧录文件、exe上位机程序及图片说明等便于对照学习。已有471人浏览学习。资料的价值在于呈现了一个从下位机C语言驱动到上位机C#界面、从自定义协议设计到多种链路调用的完整闭环可直接用于串口调试、网络通信实验或二次开发。对于想理解软硬件交互、协议封装和多线程通信的读者是一份难得的实战范本。1. 自制协议上位机的真实需求是什么桌上放着一块 STM32F103 主板或 BMS 板子测试时拿串口线连着上线后又要走网络命令还是自己定的那套帧格式——这是标题背后最常见的一类需求在 Windows 上用 C# 做一个上位机通信层同时支持串口、TCP、UDP业务由一套自制协议承载。很多设备厂商的测试工具、产线配置软件都是这么来的GRBL 上位机、BMS 通用上位机的形态也类似。这类项目真正难的不是拖控件而是三件事自制协议怎么设计才不会出现粘包和误帧三种物理链路如何抽象成一套接口让业务代码不用关心数据从哪来连续采集时 UI 为什么会越来越卡。接下来的内容按协议设计、通道抽象、业务交互、调试排错的顺序把完整方案拆开讲每步给出可运行的 C# 代码和参数取舍适合正在写上位机、或者代码能跑但加功能越来越别扭的开发者。2. 自制协议设计与解析帧格式、CRC 与粘包2.1 帧格式字段定得越死解析器越好写自制协议指下位机固件和上位机之间自己约定的字节流格式不上 Modbus、不上 CANopen。好处是帧长短、命令字随意扩展坏处是漏一个字节都可能解错。所以第一步是把帧排布写成表格而不是边写代码边定。字段长度内容帧头2 字节固定 0xAA 0x55数据长度2 字节命令字数据域的总长小端序命令字1 字节0x01 读参数、0x02 写参数、0x10 实时采集数据域0..1024 字节按命令字解释CRC162 字节CRC-16/MODBUS覆盖帧头到数据域末尾两个帧头字节选 0xAA 0x55是为了避开常见噪声。即便如此数据域里仍可能撞上 AA 55所以必须有 CRC 兜底解析器看到帧头后要校验完长度和 CRC 才算完整帧。数据长度只覆盖命令字和数据域不包括帧头和 CRC这样解析时可以先从偏移取长度再核对 CRC 范围。如果设备本身支持 Modbus用 nmodbus4 这类库会省很多事自制协议的出发点通常是下位机资源紧张、或者协议已经由硬件厂商定死上位机只能跟着解析。2.2 组帧与 CRC16-MODBUS 实现发送前把命令和数据拼成字节数组。C# 里直接用 byte[] 操作避免反复 List 扩容带来的 GC 压力。public static byte[] BuildFrame(byte cmd, byte[] payload) { int dataLen 1 (payload ! null ? payload.Length : 0); // 命令字 数据域 byte[] frame new byte[4 dataLen 2]; // 帧头2 长度2 dataLen CRC2 frame[0] 0xAA; frame[1] 0x55; frame[2] (byte)(dataLen 0xFF); frame[3] (byte)((dataLen 8) 0xFF); frame[4] cmd; if (payload ! null payload.Length 0) Array.Copy(payload, 0, frame, 5, payload.Length); ushort crc Crc16Modbus(frame, 0, 4 dataLen); // 从帧头算到数据域末尾 frame[frame.Length - 2] (byte)(crc 0xFF); frame[frame.Length - 1] (byte)((crc 8) 0xFF); return frame; } public static ushort Crc16Modbus(byte[] data, int offset, int count) { ushort crc 0xFFFF; for (int i offset; i offset count; i) { crc ^ data[i]; for (int bit 0; bit 8; bit) { bool lsb (crc 0x0001) ! 0; crc 1; if (lsb) crc ^ 0xA001; } } return crc; }BuildFrame 里 dataLen 只覆盖命令字和数据域但 CRC 从帧头开始算所以 CRC 的 count 是 4 dataLen。计算时传 offset 和 count是为了后面解析缓存时能对局部数据直接校验不需要先拷贝出来。CRC-16/MODBUS 初值固定 0xFFFF、多项式反转 0xA001与下位机 C 语言实现一致。串口调试助手里通常自带 CRC 工具对不上时要先确认 CRC 低字节在前还是高字节在前这是协议文档里必须写死的细节。2.3 状态机解帧半包、粘包与误判兜底串口或 TCP 数据到达时可能不完整半包也可能一次到好几帧粘包。解析器必须维护一块缓存。用 List 每次查找帧头再复制大批量采集时性能不够更稳的做法是字节偏移加数组裁剪。public class FrameParser { private byte[] _cache Array.Emptybyte(); public Listbyte[] Push(byte[] chunk) { var frames new Listbyte[](); // 把新数据合并到缓存末尾 byte[] merged new byte[_cache.Length chunk.Length]; Buffer.BlockCopy(_cache, 0, merged, 0, _cache.Length); Buffer.BlockCopy(chunk, 0, merged, _cache.Length, chunk.Length); _cache merged; int offset 0; while (true) { if (offset 6 _cache.Length) break; // 连最小帧都不够 if (_cache[offset] ! 0xAA || _cache[offset 1] ! 0x55) { offset; // 不是帧头滑过一个字节 continue; } int dataLen _cache[offset 2] | (_cache[offset 3] 8); if (dataLen 1024) { offset 2; continue; } // 长度越界帧头误判 int totalLen 4 dataLen 2; if (offset totalLen _cache.Length) break; // 半包等下一段数据 ushort got (ushort)(_cache[offset totalLen - 2] | (_cache[offset totalLen - 1] 8)); ushort calc Crc16Modbus(_cache, offset, 4 dataLen); if (got calc) { byte[] valid new byte[totalLen]; Array.Copy(_cache, offset, valid, 0, totalLen); frames.Add(valid); offset totalLen; } else { offset; // CRC 不对按误判帧头处理 } } if (offset 0) { byte[] remain new byte[_cache.Length - offset]; Array.Copy(_cache, offset, remain, 0, remain.Length); _cache remain; } return frames; } }Push 每次返回 0 到多帧解析完成后把已消费字节裁掉剩余自动留到下一段。while 里三个跳出点剩余不足最小帧长、长度字段越界、半包。半包保留缓存粘包继续循环。CRC 校验失败说明帧头撞上了数据内容里的 AA 55把第一字节当噪声丢掉最多浪费一个字节不会卡死协议解析。3. 串口、TCP、UDP 三通道的统一抽象3.1 传输层接口与事件模型上位机如果直接把 SerialPort 写在业务代码里加网口时要到处改。接口抽象把物理链路这一层拆掉无论串口、TCP、UDP对外只暴露四个能力打开、关闭、发送、接收事件。public interface ITransportChannel { bool IsOpen { get; } void Open(string connectionString); void Close(); void Send(byte[] data); event Actionbyte[] DataReceived; event Actionstring ErrorOccurred; }接收用事件而不用回调是因为同一份数据可能同时被帧解析器、日志记录器、曲线控件使用事件天然支持多订阅者。Open 的 connectionString 格式由工厂决定串口是 “COM3,115200,8,N,1”TCP 是 “192.168.1.10:6000”UDP 是 “本机端口:远程IP:远程端口”。自查时确认三件事调用方是否只依赖接口所有通道是否走同一套事件Close 后是否还能触发 DataReceived。三通道抽象这一层属于上位机开发面试里的高频考点能把接口设计和事件解耦讲清楚通常比堆控件更有说服力。3.2 串口通道SerialPort 参数与驱动前提串口通道的常见参数先列一张表。参数常见值说明BaudRate9600 / 115200 / 921600必须和下位机一致STM32 串口例程多用 115200DataBits8上位机基本不用 7 位ParityNone自制协议自带 CRC不需要校验位StopBitsOne改 2 位会明显拖低吞吐HandshakeNone开流控会引入 XON/XOFF 干扰二进制数据ReadTimeout / WriteTimeout500 ms防止线程永久卡在读写上具体实现如下。注意 DataReceived 是在线程池线程触发的回调里只读数据并转发事件不做 UI 操作。public class SerialChannel : ITransportChannel { private SerialPort _port; public event Actionbyte[] DataReceived; public event Actionstring ErrorOccurred; public void Open(string connectionString) { string[] p connectionString.Split(,); var port new SerialPort(p[0], int.Parse(p[1])); port.DataBits int.Parse(p[2]); port.Parity p[3] E ? Parity.Even : p[3] O ? Parity.Odd : Parity.None; port.StopBits p[4] 2 ? StopBits.Two : StopBits.One; port.Handshake Handshake.None; port.ReadTimeout 500; port.WriteTimeout 500; port.ReceivedBytesThreshold 1; port.DataReceived (s, e) { var sp (SerialPort)s; int n sp.BytesToRead; if (n 0) return; byte[] buffer new byte[n]; sp.Read(buffer, 0, n); DataReceived?.Invoke(buffer); }; port.Open(); _port port; } public void Send(byte[] data) _port?.Write(data, 0, data.Length); public void Close() { _port?.Close(); _port?.Dispose(); _port null; } public bool IsOpen _port ! null _port.IsOpen; }DataReceived 里一次性读走 BytesToRead而不是启动异步读循环因为串口数据量远小于网络线性读取开销几乎可以忽略。打开串口前先确认驱动CH340 和 FTDI 是最常见的 USB 转串口芯片设备管理器里看不到 COM 口、或者显示未知设备说明驱动没装对。用 mode 命令能列出当前所有 COM 号。上位机报“串口被占用”十有八九是串口调试助手还挂着端口关掉再试。3.3 TCP 通道超时连接、断线检测与端口占用TCP 是字节流消息边界仍在应用层解决所以第 2 章的 FrameParser 原样复用。TCP 特有的工作集中在连接超时控制和 Read 返回 0 的处理上。public class TcpChannel : ITransportChannel { private TcpClient _client; private CancellationTokenSource _cts; public event Actionbyte[] DataReceived; public event Actionstring ErrorOccurred; public void Open(string connectionString) { string[] p connectionString.Split(:); var client new TcpClient(); Task connectTask client.ConnectAsync(p[0], int.Parse(p[1])); if (!connectTask.Wait(TimeSpan.FromSeconds(3))) { client.Close(); throw new TimeoutException(TCP connect 超时); } _client client; _cts new CancellationTokenSource(); Task.Run(() ReceiveLoop(_cts.Token)); } private void ReceiveLoop(CancellationToken token) { NetworkStream stream _client.GetStream(); byte[] buffer new byte[8192]; try { while (!token.IsCancellationRequested) { int n stream.Read(buffer, 0, buffer.Length); if (n 0) break; // 对端正常关闭 byte[] chunk new byte[n]; Array.Copy(buffer, chunk, n); DataReceived?.Invoke(chunk); } } catch (IOException) { } catch (ObjectDisposedException) { } ErrorOccurred?.Invoke(TCP 连接已断开); } public void Send(byte[] data) _client?.GetStream().Write(data, 0, data.Length); public void Close() { _cts?.Cancel(); _client?.Close(); _client null; } public bool IsOpen _client ! null _client.Connected; }ConnectAsync 配合 Wait(3 秒) 是常见的同步式超时写法连接失败后必须 Close 释放 socket否则连续重连会攒出一堆 TIME_WAIT。ReceiveLoop 里 Read 返回 0 表示对端主动关闭要触发断线重连。错误统一抛给 ErrorOccurred由上层决定弹窗还是自动重连。提示启动阶段的同步等待可以接受如果连接动作放在界面线程里执行记得整体包成 async/await否则界面会卡住 3 秒。端口占用是 Windows 上位机的高频问题。监听方报 bind 错误时用 netstat -ano | findstr 6000 找到 PID再在任务管理器结束对应进程。跨机器通信偶发收不到包先临时放行防火墙端口确认网络通了再收窄规则。3.4 UDP 通道无粘包但别寄托于可靠传输UDP 按数据报收发一次 Receive 就是完整一包没有 TCP 的粘包问题逻辑上甚至不需要 FrameParser。代价是丢包、乱序、最大帧 65507 字节。上位机里 UDP 常用于广播扫描、心跳探测、日志转发这类不需要严格时序的信息。public class UdpChannel : ITransportChannel { private UdpClient _udp; private string _remoteIp; private int _remotePort; public event Actionbyte[] DataReceived; public event Actionstring ErrorOccurred; public void Open(string connectionString) { string[] p connectionString.Split(:); _remoteIp p[1]; _remotePort int.Parse(p[2]); _udp new UdpClient(int.Parse(p[0])); _udp.Client.ReceiveTimeout 1000; Task.Run(ReceiveLoop); } private void ReceiveLoop() { IPEndPoint remote new IPEndPoint(IPAddress.Any, 0); while (_udp ! null) { try { byte[] data _udp.Receive(ref remote); DataReceived?.Invoke(data); } catch (SocketException ex) { if (ex.SocketErrorCode SocketError.TimedOut) continue; break; } catch (ObjectDisposedException) { break; } } } public void Send(byte[] data) { _udp?.Send(data, data.Length, _remoteIp, _remotePort); } public void Close() { _udp?.Close(); _udp null; } public bool IsOpen _udp ! null; }Receive 是同步阻塞超时设 1 秒是为了让循环有机会检查 _udp 是否为 nullClose 之后 Receive 抛 ObjectDisposedException退出循环。如果下位机把一帧拆成多个 UDP 包应用层要按包序号重组不能简单拼接。UDP 上位机最常见的故障是接收回调里做了耗时操作把接收窗口堵住数据量大时丢包率指数上升接收线程里拿到数据后要尽快移交。3.5 工厂封装一行切换通信方式三个通道类写完后业务方完全不感知底层。工厂按配置文件生成对应实例。public enum ChannelKind { Serial, Tcp, Udp } public static class ChannelFactory { public static ITransportChannel Create(ChannelKind kind, string connStr) { switch (kind) { case ChannelKind.Serial: return new SerialChannel(); case ChannelKind.Tcp: return new TcpChannel(); case ChannelKind.Udp: return new UdpChannel(); default: throw new ArgumentOutOfRangeException(nameof(kind)); } } }调用方用 ChannelFactory.Create(config.Kind, config.ConnStr) 拿通道然后 Open、Send、订阅 DataReceived。以后加蓝牙或 CAN 也只是新增一个类和枚举主流程不动。这一层抽象完成的标志是把配置文件里 kind 从 Serial 改成 Tcp业务代码一行都不用改。4. 多功能交互的业务框架与界面刷新取舍4.1 命令分发字典替代大 switch通道收到的字节先过 FrameParser 得到完整帧再取命令字决定把载荷交给谁。命令字一多switch 必然膨胀改成注册表方式更清晰。private readonly Dictionarybyte, Actionbyte[] _handlers new Dictionarybyte, Actionbyte[](); private readonly FrameParser _parser new FrameParser(); public void RegisterHandler(byte cmd, Actionbyte[] handler) { _handlers[cmd] handler; } private void OnDataReceived(byte[] chunk) { foreach (byte[] frame in _parser.Push(chunk)) { byte cmd frame[4]; // 帧布局固定AA 55 LEN_L LEN_H CMD ... if (_handlers.TryGetValue(cmd, out Actionbyte[] handler)) { int payloadLen frame.Length - 7; byte[] payload new byte[payloadLen]; Array.Copy(frame, 5, payload, 0, payloadLen); handler(payload); } } }回调里拿到的 payload 已剥离帧头和命令字业务层看到的就是纯数据。命令字事件表可以设计成命令字含义处理动作0x01读参数填充表单控件0x02写参数写入成功后回读校验0x10实时采集压入采样队列0x81故障上报弹窗并写日志RegisterHandler 在窗体构造时集中注册。注意 handler 里不要直接改控件数据量大时这是卡顿的第一来源。4.2 心跳、超时与重传命令不发丢的保障上位机发一条命令下位机可能没收到、收到没处理、或者回复丢了。三种情况都要等超时再重发。帧协议里没有序号字段时串行等待是最稳妥的做法同一时刻只等一条回复。public class CommandSession { private readonly SemaphoreSlim _sem new SemaphoreSlim(1, 1); private readonly ITransportChannel _channel; private TaskCompletionSourcebyte[] _pending; public CommandSession(ITransportChannel channel) { _channel channel; } public async Taskbyte[] SendAndWait(byte cmd, byte[] payload, int timeoutMs 1000) { await _sem.WaitAsync(); var tcs new TaskCompletionSourcebyte[](TaskCreationOptions.RunContinuationsAsynchronously); _pending tcs; try { _channel.Send(BuildFrame(cmd, payload)); Task completed await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); if (completed ! tcs.Task) throw new TimeoutException($命令 0x{cmd:X2} 超时); return await tcs.Task; } finally { _pending null; _sem.Release(); } } public bool TryComplete(byte[] payload) { TaskCompletionSourcebyte[] tcs _pending; return tcs ! null tcs.TrySetResult(payload); } }SemaphoreSlim 会把并发命令压成串行避免回复和命令无法对应。超时用 Task.WhenAny 搭配 Task.Delay不会忙等 CPU。收到回复后在 OnDataReceived 里判断是不是等待中的命令调用 TryComplete 释放调用方。心跳一般由上位机定时发送 0x00 保活命令连续三次超时判链路断开。注意重试计数不能无限做连续三次超时就应判定链路故障否则下位机恢复后可能把积压命令一次性执行完。4.3 循环采集与 UI 刷新卡顿的来源及解法上位机最常见的高频场景是循环采集下位机每 10ms 上报一次温度上位机画实时曲线。如果 DataReceived 事件里直接 BeginInvoke 更新 Label 和 ChartUI 线程要处理的跨线程调用次数等于数据包数消息队列堆积界面先是卡顿、然后彻底无响应。解法是把“数据到达”和“UI 刷新”解耦。数据先进并发队列UI 用一个 50ms 定时器批量取。private readonly ConcurrentQueueTemperatureSample _samples new ConcurrentQueueTemperatureSample(); private void OnSamplePayload(byte[] payload) { var sample DecodeSample(payload); // 解析纯数据不做 UI 操作 _samples.Enqueue(sample); } private void RefreshTimer_Tick(object sender, EventArgs e) { var batch new ListTemperatureSample(64); while (_samples.TryDequeue(out TemperatureSample s)) batch.Add(s); if (batch.Count 0) return; labelTemperature.Text batch[batch.Count - 1].Value.ToString(F2); chartSeries.Points.SuspendUpdates(); foreach (TemperatureSample s in batch) chartSeries.Points.AddXY(s.Time, s.Value); chartSeries.Points.ResumeUpdates(); }Timer 间隔 50ms 对应 20FPS 刷新率人眼已经看不出卡顿CPU 占用却比事件驱动低一个量级。关键点接收线程只入队UI 线程只在 Tick 里出队杜绝了每个包都跨线程。曲线点数长了要做滑动窗口裁剪通常保留最近 N 点后 Clear 再整批 Add比逐点删除更省。如果采集速率极高也可以把队列换成 System.Threading.Channels 的 Channel 生产者是接收线程消费者是 UI 定时器原理相同但吞吐更高。历史数据落库同样放进 Tick批量提交比逐条插入性能好得多。4.4 扫码枪触发事件怎么接不少设备上位机带扫码功能接入方式取决于扫码枪的工作模式。串口模式下扫码枪就是第二个串口源代码上注册一个独立的 SerialChannel 实例收到一行数据直接解析条码处理路径与主通信通道完全隔离。键盘模式下扫码枪模拟键盘输入把条码字符“打”进当前焦点控件问题是焦点可能停在按钮上回车会误触发按钮事件。键盘模式建议在 Form 的 PreviewKeyDown 上做全局拦截private readonly StringBuilder _scanBuffer new StringBuilder(); private void MainForm_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e) { if (char.IsLetterOrDigit((char)e.KeyValue) || e.KeyValue -) { _scanBuffer.Append((char)e.KeyValue); e.IsInputKey true; } else if (e.KeyCode Keys.Enter) { string code _scanBuffer.ToString(); _scanBuffer.Clear(); if (code.Length 6) // 条码最小长度防误触发 HandleBarcode(code); } else if (e.KeyCode Keys.Back) { if (_scanBuffer.Length 0) _scanBuffer.Length--; } }扫码枪的输入特征是“一串可见字符 回车”且速度极快。上面这段把回车视为条码结束信号再按最小长度过滤避免单独按 Enter 触发空条码。多把扫码枪的排障串口模式能在日志里区分来源键盘模式只能靠条码前缀判断。实际项目里最好让操作工扫枪时焦点固定在专门条码框同时保留全局拦截两条路径都不容易误触。5. 调试排错与工具链技巧5.1 串口打不开 / 烧写失败先查什么串口上位机最常见的问题依次是驱动不对、串口被占用、COM 号变了。设备管理器里看到 CH340 或 FTDI 字样才算驱动正常如果显示未知设备先装对应驱动。mode 命令可以列出当前所有 COM 号确认下位机枚举到的串口没有被其他软件抢走。烧写失败多数不是接线问题而是上位机还挂着串口导致下位机进不了 BOOT 模式先把上位机关掉再让烧写工具独占端口。电脑重启后 COM 号变化导致程序找不到设备时配置里改成自动枚举串口比写死 COM3 更省心。5.2 TCP connect 超时与端口占用跨机器 TCP 连接失败先分层排查ping 不通是网络问题ping 通但 Test-NetConnection 192.168.1.10 -Port 6000 超时是端口或防火墙问题。本地程序报 bind 错误时用 netstat -ano | findstr 6000 找到 PID在任务管理器结束残留进程。UDP 收不到回包时先抓包确认对方是否发出数据再看 Windows 防火墙是否放行 UDP 入站。很多联调现场的问题不是代码而是测试工具把端口或串口独占关掉调试助手再点连接就能复现正常路径。5.3 VS2015 打开 VS2019 上位机工程的兼容处理拿到 VS2019 写的 C# 上位机源码旧机器只有 VS2015 时先看 .csproj 是不是 SDK 风格工程是的话 VS2015 打不开需要改成传统 csproj目标框架降到 .NET Framework 4.6.1 再加载。其次检查代码里的 C# 语法VS2015 只支持到 C# 6switch 表达式、可空引用类型、范围运算符都要手工改回旧写法。WinForms 工程里这些语法通常不多改完基本能编译。工程路径过长也会导致加载失败把源码移到 C:\src 这类短目录再打开比改配置快得多。5.4 用串口调试助手和离线脚本验证自制协议协议联调最有效的验证方式是双端打日志再用工具核对字节。Windows 下用串口调试助手或串口数据记录仪记录收发帧网络链路用 Wireshark 抓包。验证思路固定把一段日志按“AA 55 长度 命令 数据 CRC”排开确认每一帧的 CRC 都能对上。手工算 CRC 很磨人可以写一个几十行的 Python 脚本离线复算def crc16_modbus(data): crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc line bytes.fromhex(AA 55 06 00 01 00 00 00 00 00 00 00) expected int.from_bytes(line[-2:], little) actual crc16_modbus(line[:-2]) print(fexpected{expected:#06x} actual{actual:#06x} match{actual expected})脚本从日志文件里逐行读 hex对每帧校验 CRC输出 matchFalse 的行就是疑似问题帧再回看该帧对应下位机的状态。这个流程能在十分钟内把几百帧日志全检完比在调试助手里一帧一帧盯快得多也适合进 CI 做回归。离线复算还顺带证明了协议文档和代码实现一致换人维护时少踩不少坑。本文还有配套的精品资源点击获取
返回列表