简介:这是一份以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 List<byte> { seq, cmd, (byte)payload.Length }; body.AddRange(payload); ushort crc = Crc16(body.ToArray()); var frame = new List<byte> { 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是自定义协议的命令字表,上位机和下位机各放一份enum;CRC16 最常用的是 Modbus 多项式0xA001,注意高低字节顺序,一定要先用串口助手发一帧算出来跟下位机对齐,不然最后调半天都在对不上校验。
2.2 流式解析状态机:把字节流还原成完整帧
串口、TCP、UDP 拿到的都是字节流,一帧数据可能分了好几次到达,也可能一次到达好几帧。如果每次收到回调就按帧头去解析,必然出现“半包”和“粘包”。最稳的做法是把收到的原始字节全部丢进一个帧解析器,由解析器内部维护缓冲区并输出完整帧。
public sealed class FrameParser { private readonly List<byte> _buffer = new(); public List<byte[]> Parse(byte[] chunk) { _buffer.AddRange(chunk); var frames = new List<byte[]>(); 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 封装成一个可变接口
设备通信的复杂性不只来自协议,还来自不同的底层连接方式。串口有SerialPort,TCP 有TcpClient,UDP 有UdpClient,如果每种连接都写在业务层,切换通道的工程量会非常大。常见做法是先定义一个通道接口,让三种实现各自去处理底层的打开、关闭、发送和回调。
3.1 通道接口:顶层只认一个事务对象
我一般把接口定义成下面这个样子,事件只回传原始字节,不做任何协议解析。协议解析统一放到 FrameParser 里,这样无论底层是串口还是网络,上层只收到完整帧。
public interface IChannel : IDisposable { event Action<byte[]> 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 Action<byte[]> 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 Action<byte[]> 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)); }逻辑说明:每接入一个设备就启动一个独立任务,所有设备连接集合保存在一个ConcurrentDictionary<string, 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 Action<byte[]> 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 Channel<byte[]> _outbox = Channel.CreateUnbounded<byte[]>(); 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 以后的推荐队列库,老项目也可以用BlockingCollection<byte[]>. 发送方向只有一个 Worker 在消费队列,天然避免并发写串口造成的两帧连成一片。
参数说明:这里用Unbounded是为了调试方便,但实际产测程序里我建议改用CreateBounded并设置容量,比如 1024。队列满时就丢弃新命令并记录日志,而不是让内存无限增长。
4.2 请求回令匹配:没有序列号就没有多功能
这一节要解决的问题是:上位机发了 “读温度” 和 “读电压” 两条命令,下位机回了两条响应,怎么知道哪条对应哪个请求?我维护一张命令等待表,收到响应时按序列号去匹配。
private static int _seqCounter; private readonly ConcurrentDictionary<int, TaskCompletionSource<byte[]>> _pending = new(); public Task<byte[]> 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 TaskCompletionSource<byte[]>(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,本地回环调到稳定,最后才去连真实设备。这个顺序能省掉无数个浪费在联调上的下午。希望帮到你。
本文还有配套的精品资源,点击获取