
简介这是一份以C#编写的Windows上位机完整工程搭配自制通信协议用于与基于C语言的嵌入式下位机进行多功能交互覆盖串口、TCP、UDP三种通信方式。资源同时包含上位机源码与下位机STM32固件工程适合嵌入式开发、物联网通信以及工控数据采集方向的初学者或进阶者学习整体联调思路。压缩包共125个文件大小约1.77MB以C#源码、嵌入式C头文件与源文件h/c、工程配置uvprojx/hex、调试镜像exe/pdb及说明文档md为主另有少量图片用于界面或接线示意目录结构清晰便于对照检索。资源已有473人学习热度虽不算高但内容具有典型参考价值。通过该工程可以掌握自定义通信协议的定义与解析、串口/TCP/UDP多通道收发、多线程并发处理以及上下位机联合调试等方法配合附件中的bin/hex文件还能直接观察固件运行效果适合作为课程设计或入门实战的蓝本。1. 从串口到 TCP/UDP一套 C# 上位机自定义协议方案设备现场最常见的一个重复劳动是下位机有串口也有网口上位机就要维护串口通信、TCP 通信、UDP 通信三套代码每一套再接一套自己的协议解析。用 C# 在 Windows 上做上位机我习惯把这三条通道抽象成同一条数据管道上层只认一套自制协议帧下层通信方式随便切换。这么做之后调试工具、产测软件、实验室采集界面都能共用同一套收发逻辑新增通道也不用改业务代码。这篇文章适合正在写设备调试上位机、产测程序或采集软件的人也适合想把串口程序平滑搬到网络通信的人。我会把协议帧格式、通道封装、命令交互封装和常见坑一次说清楚。2. 自制协议设计帧头、命令字、序列号与 CRC先定好再写上位机很多刚入门的朋友会直接拿 Modbus 或者 JSON 当协议层但遇到 STM32 这类下位机时Modbus 要把数据映射到寄存器地址JSON 在单片机解析太费内存最后反而被协议反向绑定。这里我用的自制协议是一套很轻量的二进制帧下位机用 C 语言也能边收边定义成结构体上位机用字节数组直接拼帧两边不用引入任何第三方库。2.1 帧格式给下位机一个不用解释的字节结构我一般会把一帧做成下面这张表的布局。字段越少下位机解析越不容易出错。字段字节数说明帧头2固定为0xAA 0x55序列号1每条命令递增回令匹配用命令字1例如0x01读取温度、0x02写参数数据长度1数据区长度最大 255数据区N具体载荷长度由上一字段决定CRC16 校验2从序列号开始到数据区结束低字节在前序列号这个字段容易被新手忽略。上位机同时点了一个“读取电压”和一个“读取电流”如果回令都不带序号你就分不清哪条响应对应哪个请求。多功能交互越复杂序列号越重要。帧头选AA 55是为了降低真实业务数据里出现同值头部的概率但也不能完全保证所以后面依然要有状态机去滑动匹配。构造一帧命令码的 C# 代码差不多是这样。public static byte[] BuildFrame(byte seq, byte cmd, byte[] payload) { var body new Listbyte { seq, cmd, (byte)payload.Length }; body.AddRange(payload); ushort crc Crc16(body.ToArray()); var frame new Listbyte { 0xAA, 0x55 }; frame.AddRange(body); frame.Add((byte)(crc 0xFF)); // CRC 低字节 frame.Add((byte)(crc 8)); // CRC 高字节 return frame.ToArray(); }逻辑说明帧头之后先把序列号、命令字、数据长度放进 body再用 body 算 CRC最后把 CRC 追加到尾部。payload长度超过 255 时会截断如果确实有超过 255 字节的大数据块我建议把“数据长度”扩成 2 字节或者后端拆帧分批发。参数说明seq取值范围是0~255正常情况下不需要特别大cmd是自定义协议的命令字表上位机和下位机各放一份enumCRC16 最常用的是 Modbus 多项式0xA001注意高低字节顺序一定要先用串口助手发一帧算出来跟下位机对齐不然最后调半天都在对不上校验。2.2 流式解析状态机把字节流还原成完整帧串口、TCP、UDP 拿到的都是字节流一帧数据可能分了好几次到达也可能一次到达好几帧。如果每次收到回调就按帧头去解析必然出现“半包”和“粘包”。最稳的做法是把收到的原始字节全部丢进一个帧解析器由解析器内部维护缓冲区并输出完整帧。public sealed class FrameParser { private readonly Listbyte _buffer new(); public Listbyte[] Parse(byte[] chunk) { _buffer.AddRange(chunk); var frames new Listbyte[](); int offset 0; while (true) { if (_buffer.Count - offset 4) break; // 寻找帧头没找到就向后滑一个字节 if (_buffer[offset] ! 0xAA || _buffer[offset 1] ! 0x55) { offset; continue; } int dataLen _buffer[offset 4]; int total 2 3 dataLen 2; if (_buffer.Count - offset total) break; // 还没攒够一帧等下一波字节 byte[] frame _buffer.GetRange(offset, total).ToArray(); frames.Add(frame); offset total; } _buffer.RemoveRange(0, offset); return frames; } }逻辑说明offset的作用是只扫描本次新增的部分同时让已经确认不是帧头的字节被丢弃_buffer.RemoveRange(0, offset)一局结束再清理避免频繁操作内存。这个类可以二十分钟写出来却是串口通信稳定性的最大功臣。参数说明total算的是“帧头 2 字节 序列号 1 字节 命令字 1 字节 数据长度 1 字节 数据区 N 字节 CRC 2 字节”也就是2 3 dataLen 2。如果后来改了协议比如加了 2 字节版本号别漏改这个公式。2.3 为什么自制协议而不是 Modbus 或 JSON自制协议看起来像是重复造轮子但它能活下来是因为设备调试场景里往往没有比它更合适的选项。协议优点不适合的场景Modbus与 PLC、组态软件互通性好流式自定义命令多时寄存器地址维护太繁琐JSON易读、好调试单片机内存紧张且帧变化长度不定自制二进制协议解析最快、扩展自由没有现成生态需要自己维护文档下位机如果是一块 ARM 板用结构体强制转换就能解析自制帧上位机用byte[]收发效率也远高于 JSON 字符串。特别是要做高频数据采集或固件升级时二进制帧的优势非常明显。网上能搜到很多 “C# 上位机通用框架”但通用框架里的协议层通常还是要按设备重写与其套别人的壳再改不如把帧格式从头理顺。3. 统一通信通道把串口、TCP、UDP 封装成一个可变接口设备通信的复杂性不只来自协议还来自不同的底层连接方式。串口有SerialPortTCP 有TcpClientUDP 有UdpClient如果每种连接都写在业务层切换通道的工程量会非常大。常见做法是先定义一个通道接口让三种实现各自去处理底层的打开、关闭、发送和回调。3.1 通道接口顶层只认一个事务对象我一般把接口定义成下面这个样子事件只回传原始字节不做任何协议解析。协议解析统一放到 FrameParser 里这样无论底层是串口还是网络上层只收到完整帧。public interface IChannel : IDisposable { event Actionbyte[] Received; bool IsOpen { get; } string ChannelName { get; } Task OpenAsync(CancellationToken ct); Task SendAsync(byte[] bytes, CancellationToken ct); void Close(); }逻辑说明OpenAsync用于打开设备连接SendAsync只负责把原始字节写入当前通道收到数据时通过Received往外抛。设备地址、端口这些参数都在具体实现里不要在业务代码里直接操作SerialPort或TcpClient。为什么要异步因为打开 TCP 连接可能卡住很久UI 线程如果同步等待界面就会假死。所有通道都实现同一个接口之后主界面只需要保存一个IChannel _currentChannel切换串口、TCP、UDP 时业务层代码完全不用改。3.2 串口通道实现SerialPort 参数与事件注意点串口是最传统也最容易“翻车”的通道。设备管理器里有 COM 口不代表程序打开不会报错也可能是被其他程序占用或者 USB 转串口芯片的驱动问题。有些设备用 CH340 芯片没装驱动的常见现象是插上去完全没有 COM 口这是排查上位机串口打不开时最先要看的一层。public sealed class SerialChannel : IChannel { private readonly SerialPort _port; public string ChannelName _port.PortName; public bool IsOpen _port.IsOpen; public event Actionbyte[] Received; public SerialChannel(string portName, int baudRate) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.DataReceived DataReceived; } public async Task OpenAsync(CancellationToken ct) { if (!_port.IsOpen) _port.Open(); await Task.CompletedTask; } public Task SendAsync(byte[] bytes, CancellationToken ct) { _port.Write(bytes, 0, bytes.Length); return Task.CompletedTask; } private void DataReceived(object sender, SerialDataReceivedEventArgs e) { int n _port.BytesToRead; if (n 0) return; byte[] buffer new byte[n]; _port.Read(buffer, 0, n); Received?.Invoke(buffer); } public void Close() _port?.Close(); public void Dispose() Close(); }逻辑说明DataReceived事件在 .NET 后台线程触发所以这里读出的字节需要交给 FrameParser不能用它直接更新 UI。_port.BytesToRead得到的是当前串口缓冲区里的字节数并不保证一定是完整协议帧。参数说明波特率常见有 9600、115200具体由下位机固件决定。Parity.None, 8, StopBits.One是绝大多数设备的默认 8N1 配置。如果对端设备是 Modbus RTU需要设成Parity.Even但这一章讲自制协议所以先以 8N1 为例。3.3 TCP 通道实现客户端连接、服务端监听、心跳重连TCP 的上位机形态通常是两种上位机作为客户端去连设备或者上位机作为服务端等设备连上来。多数下位机是 TCP 客户端所以上位机多半要写一个TcpListener多客户端监听。先看客户端通道。public sealed class TcpClientChannel : IChannel { private TcpClient _tcp; private NetworkStream _stream; private readonly string _host; private readonly int _port; public bool IsOpen _tcp?.Connected true; public string ChannelName ${_host}:{_port}; public event Actionbyte[] Received; public async Task OpenAsync(CancellationToken ct) { _tcp new TcpClient(); await _tcp.ConnectAsync(_host, _port); // 老版本 .NET 不支持 CancellationToken _stream _tcp.GetStream(); _ Task.Run(ReceiveLoop, ct); } private async Task ReceiveLoop() { var buffer new byte[4096]; while (_tcp.Connected) { int n await _stream.ReadAsync(buffer, 0, buffer.Length); if (n 0) break; Received?.Invoke(buffer.Take(n).ToArray()); } } public async Task SendAsync(byte[] bytes, CancellationToken ct) { await _stream.WriteAsync(bytes, 0, bytes.Length); } }逻辑说明ReceiveLoop是一个独立的后台读取循环ReadAsync会一直阻塞直到收到数据或者对方关闭连接。网络流里一次ReadAsync返回的字节长度不受协议帧限制可能半帧也可能好几帧所以收到的数组要原样交给 FrameParser。TCP 服务端监听多个客户端时套路是这样_listener new TcpListener(IPAddress.Any, 6000); _listener.Start(); while (!ct.IsCancellationRequested) { TcpClient client await _listener.AcceptTcpClientAsync(ct); _ Task.Run(() HandleClient(client, ct)); }逻辑说明每接入一个设备就启动一个独立任务所有设备连接集合保存在一个ConcurrentDictionarystring, TcpClient便于向上层上报时附带设备标识。如果只是点对点调试服务端监听反而比客户端更省事因为设备重连后上位机不用主动去重连。断线重连和心跳也是 TCP 绕不开的点。我的习惯是在协议层加一条心跳帧上位机每隔 3 秒发一次如果连续 5 次没收到心跳回令就判定链路掉线关闭连接后按 1 秒、2 秒、4 秒的后退间隔重试不能写死成while(true)无限重连。3.4 UDP 通道实现无连接也要做帧校验和应答UDP 比 TCP 简单很多没有连接状态只需要绑定本地端口、指定远端 IP 和端口就能收发。但“简单”也意味着不可靠所以 UDP 通道必须配合协议层的序列号和 CRC 校验设备收到命令后要回一个应答帧上位机没收到应答就要重发。public sealed class UdpChannel : IChannel { private UdpClient _udp; private readonly int _localPort; private readonly IPEndPoint _remote; private IPEndPoint _any; public event Actionbyte[] Received; public UdpChannel(string remoteIp, int remotePort, int localPort) { _remote new IPEndPoint(IPAddress.Parse(remoteIp), remotePort); _localPort localPort; } public void Open() { _any new IPEndPoint(IPAddress.Any, 0); _udp new UdpClient(_localPort); _udp.BeginReceive(OnReceive, null); } private void OnReceive(IAsyncResult ar) { byte[] data _udp.EndReceive(ar, ref _any); Received?.Invoke(data); _udp.BeginReceive(OnReceive, null); } public async Task SendAsync(byte[] bytes, CancellationToken ct) { await _udp.SendAsync(bytes, bytes.Length, _remote); } }逻辑说明BeginReceive是经典异步模式每次收到数据后立即重新开始接收保持一个持续的读取循环。UDP 数据报边界是天然存在的所以它不像 TCP 那样容易粘包但同样可能出现半包后的乱序和重复帧必须靠协议帧里的 CRC 和序列号过滤。4. 多功能交互实现命令队列、序列号回令与 UI 刷新把协议帧和通信通道准备好只是把“路”打通了。上位机真正要做的是多功能交互读取参数、写入参数、启动设备、停止设备、固件升级、心跳轮询。多个命令同时发起时最直观的问题是界面卡死其次是回令不知道对应哪个请求。这一章讲我常用的三层解耦方式。4.1 命令队列让所有发送命令都走一条单队列如果业务代码到处直接调用channel.SendAsync一旦两个命令并发发送字节流就会在协议层交叠下位机解析必乱。所以我通常用一个异步队列把所有待发送帧串行化。private readonly Channelbyte[] _outbox Channel.CreateUnboundedbyte[](); private readonly CancellationTokenSource _cts new(); public async Task EnqueueFrame(byte[] frame) { await _outbox.Writer.WriteAsync(frame, _cts.Token); } private async Task SendWorker() { await foreach (var frame in _outbox.Reader.ReadAllAsync(_cts.Token)) { await _currentChannel.SendAsync(frame, _cts.Token); } }逻辑说明System.Threading.Channels是 .NET Core 3.0 以后的推荐队列库老项目也可以用BlockingCollectionbyte[]. 发送方向只有一个 Worker 在消费队列天然避免并发写串口造成的两帧连成一片。参数说明这里用Unbounded是为了调试方便但实际产测程序里我建议改用CreateBounded并设置容量比如 1024。队列满时就丢弃新命令并记录日志而不是让内存无限增长。4.2 请求回令匹配没有序列号就没有多功能这一节要解决的问题是上位机发了 “读温度” 和 “读电压” 两条命令下位机回了两条响应怎么知道哪条对应哪个请求我维护一张命令等待表收到响应时按序列号去匹配。private static int _seqCounter; private readonly ConcurrentDictionaryint, TaskCompletionSourcebyte[] _pending new(); public Taskbyte[] SendCommand(byte cmd, byte[] payload, int timeoutMs) { int seq Interlocked.Increment(ref _seqCounter) 0xFF; byte[] frame FrameBuilder.BuildFrame((byte)seq, cmd, payload); var tcs new TaskCompletionSourcebyte[](TaskCreationOptions.RunContinuationsAsynchronously); _pending[seq] tcs; _outbox.Writer.TryWrite(frame); var cts new CancellationTokenSource(timeoutMs); cts.Token.Register(() { if (_pending.TryRemove(seq, out var item)) item.TrySetException(new TimeoutException($命令 {cmd} 超时)); }); return tcs.Task; }逻辑说明调用方用await SendCommand(...)等待回令底层收到完整帧后解析出序列号从_pending里把对应的TaskCompletionSource取出来并TrySetResult. 等待表里的项在超时后要删掉否则后续回令会一直堆积在字典里造成内存泄漏。参数说明序列号只用 0~255很快会循环。如果同一时刻在途命令比较多可以扩充成 2 字节序列号把Interlocked控制在 65535 以内。这里最关键的是“收到重复序列号时当作异常帧丢弃”否则一个迟到响应可能替换掉新请求的结果。4.3 UI 刷新与通道切换控件跨线程的边界串口和 TCP 的接收回调都在后台线程直接往TextBox或RichTextBox写内容会抛“线程间操作无效”的异常。最简单的解决办法是捕获 UI 线程的SynchronizationContext把更新动作 Posted 到 UI 线程。private readonly SynchronizationContext _uiSync; public MainForm() { _uiSync SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); } private void OnFrameReceived(byte[] frame) { _uiSync.Post(_ { txtLog.AppendText(BitConverter.ToString(frame) Environment.NewLine); }, null); }逻辑说明SynchronizationContext.Current在窗体构造函数里拿到的是 WinForms 的上下文Post是异步调用不会阻塞后台接收线程。如果是 WPF要改用Dispatcher.BeginInvoke本质一样。通道切换界面我的做法是一个ComboBox放串口、TCP 客户端、TCP 服务端、UDP 四种类型切换时先Close()旧通道再根据选中的类型创建对应的IChannel实例连接参数区可以动态显示 COM 口、波特率、IP 和端口下方一个大的日志区显示收发帧。整个界面交互不复杂真正复杂的是后台的协议解析和队列这两块做稳了界面随便怎么改都不会乱。5. 避坑排查串口假死、TCP 粘包、UDP 收不到五个最常踩的坑上位机通信类项目最大的问题不是写代码而是出了问题不知道怎么查。下面这 5 条是我做类似项目时真实踩过、也帮同事排查过的记录按现象、原因、解决各写清楚了。5.1 打开串口报“访问被拒绝”设备管理器里却能看到 COM 口现象程序第一遍运行能打开串口关掉再开就报IOException或者被杀毒软件拦截。原因极大多数是串口句柄没释放上一个实例还没退出新的实例去打开同一个 COM 口时被系统拒绝USB 转串口驱动不正常也可能让系统识别不到这个口。解决程序退出时强制把当前通道Close()并Dispose()SerialPort一定要放在using或try/finally中。排查时先用任务管理器确认是否有残留进程再用串口调试助手试开一次如果助手也打不开先把 CH340 或 CP2102 驱动重装一遍。5.2 串口收到的帧被拆成多段丢进解析器后总是差几个字节现象下位机一次发 12 字节上位机的DataReceived事件却先触发 4 字节再触发 8 字节。原因串口事件本来就不保证一次给完整帧而且我的代码里设了ReceivedBytesThreshold 1意思是只要有 1 个字节到达就去读。解决不要试图在DataReceived里拼完整帧直接把它读出来的byte[]丢给 2.2 节的FrameParser.Parse()。解析器自己处理半包和粘包内部缓冲区会等一帧长度齐了才返回完整帧。这是最省心的方法也是我见过“串口收不到完整数据”问题里最有效的解法。5.3 TCP 长时间运行后接收缓冲区越涨越大最终内存溢出现象TCP 连接一开始收发正常运行半天后程序内存持续上涨抓包看每次通信数据量都不大。原因解析 TCP 流时没有把已解析的数据从缓冲区移除每次新数据到达都全量 append缓冲区只增不减。解决在FrameParser里用offset计算已消费位置确认完整的帧后统一RemoveRange(0, offset)。另外在循环解析时加一层保护如果缓冲区长度超过一个阈值比如 64KB就把最前面的一个字节丢掉来强制重新对齐防止设备端发的异常字节把解析器带入死循环。5.4 UDP 收不到下位机数据抓包也看不到现象上位机 UDP 程序在开发机上收发正常部署到客户电脑后收不到下位机数据用另一台 PC 往同端口发上位机也没反应。原因Windows 防火墙默认拦截陌生的 UDP 入站流量应用没有入站规则或者客户电脑本地端口选的是 0导致程序绑定到随机端口。解决安装部署时写入一条防火墙入站规则允许指定 exe 对特定 UDP 端口通信本地测试固定用127.0.0.1和具体端口不要写IPAddress.Any的绑定后却不看实际端口。下位机发广播包时还要确认目标广播地址和 PC 网卡在同一段跨网段广播包多数会被路由器丢弃。5.5 界面打开后点按钮就白屏所有命令像被卡住了现象窗体加载后界面还能显示但一点“连接”按钮就转圈几秒后弹出“未响应”。原因在 UI 线程里同步调用了Task.Result或.Wait()后台线程因为 UI 线程被阻塞回不到 UI 上下文形成死锁。解决Open 和 Send 一律写异步方法UI 事件里用async void转发给后台任务需要同步调用的单元测试项目里才用GetAwaiter().GetResult()上线的 WinForms 程序不要出现task.Wait()。界面卡死还有一个隐藏原因接收回调里直接用Invoke同步等待 UI 线程处理日志如果日志刷新太慢后台线程会被拖死改成BeginInvoke或Post才是正确姿势。6. 最小可运行启动顺序从空工程到串口、TCP、UDP 都能切再送你一个自测小技巧如果你是从空白工程开始搭这套上位机我建议按下面这张表推进每一步验证通过再进下一步不要直接写完再联调。步骤操作验证点1新建 WinForms 项目目标框架用 .NET 6/8 或 .NET Framework 4.7.2项目能正常编译运行2写FrameBuilder.BuildFrame和FrameParser.Parse用单测或控制台发送预设字节流验证回帧正确3逐个实现 SerialChannel、TcpClientChannel、TcpServerChannel、UdpChannel先用串口调试助手和 TCP/UDP 调试工具对发对收4在主窗体上放一个ComboBox切换通道类型切换后能正常打开和关闭不抛异常5接入命令队列和序列号回令表同时发多个命令日志能看到一一对应6联调真实下位机读参数、写参数、心跳轮询全部完成后再做异常断线测试我平时习惯先在本地做一次回环测试用系统自带或第三方虚拟串口软件把两个 COM 口对接或者开着上位机 TCP 服务端再用另一个终端连上去发预设帧。如果没有现成的下位机协议文档可以自己写一个 30 行的 C# 模拟器按相同帧格式回复预设数据。这样能把协议问题限制在上位机自身而不是一上来就跟硬件固件的 bug 混在一起。把通信层、协议层、业务界面这三层拆开之后后面再增加新功能就只动业务层。我自己的习惯是先把协议字段画清楚再写FrameBuilder和FrameParser本地回环调到稳定最后才去连真实设备。这个顺序能省掉无数个浪费在联调上的下午。希望帮到你。本文还有配套的精品资源点击获取