ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#上位机开发实战:自制通信协议与串口/TCP/UDP三通道封装

C#上位机开发实战:自制通信协议与串口/TCP/UDP三通道封装 简介基于C#开发的Windows上位机工程包面向需要掌握上位机与嵌入式设备联调的开发者解决串口、TCP、UDP多通道通信与自定义协议处理的实战需求。工程内含上位机软件源码、可执行程序以及配套的STM32F4下位机固件形成从PC端到MCU端完整的软硬件交互样例。包体共125个文件大小1.77MB以C语言源文件60个.h、39个.c和Keil工程文件为主辅以界面截图、调试配置、hex固件与C#入口文件便于对照上下位机两套代码理解协议设计与联调流程。下位机固件覆盖STM32F4的定时器、RTC、ADC、CAN、USART、I2C、DMA等外设驱动可演示多功能交互场景。已有473人学习适合嵌入式开发初学者或中级工程师参考整体架构、移植通信协议并排查联调问题。1. 基于C#的上位机软件自制协议与三通信到底在解决什么问题工控现场经常遇到这种尴尬下位机单片机、PLC、FPGA已经把数据采集好了但PC端没有趁手的工具要么用现成的串口助手抓包要么用别人的上位机但协议不开放。这个标题其实是在讲一个很标准的解决方案——用C#在Windows上写一套自己的上位机用自制协议跟下位机对话通信链路同时覆盖串口、TCP、UDP。它解决的痛点很具体一套代码面对三种物理链路协议自己定帧格式、校验、重传机制全部可控不用受制于Modbus等标准协议的限制。适合正在做设备调试、实验室仪器控制、或者想自己掌握通信底层细节的嵌入式工程师和上位机开发人员。这套方案的价值在于把通信层的共性问题抽象出来后面换设备、换通信方式都不用推倒重来。2. 通信层选型与封装串口、TCP、UDP三套通道的共性设计2.1 为什么同时支持三种通信场景决定通道通道决定代码结构很多新手拿到这类需求第一反应是分别写三个窗口每套通信一套代码最后项目变成一堆if-else。实际上串口、TCP、UDP的工作方式差异很大但从上位机角度看它们做的事情完全一样打开通道、发送字节、接收字节、关闭通道。真正要关心的是各自的时序和异常行为。串口适合短距离、现场抗干扰要求高的场合很多PLC和传感器仍然是RS232/RS485接口TCP适合局域网内的高可靠大数据量传输比如视觉系统传图、设备状态上报UDP适合对实时性要求极高、偶尔丢一两包也无所谓的场景比如高速运动控制的数据流。我的建议是不要上来就写死用哪种先在代码里定义一个统一的通信接口让三种实现去适配它。这样协议层永远只面对一个抽象对象换通道像换插头一样简单。2.2 串口通信封装SerialPort的正确打开方式与参数设置C#里串口用System.IO.Ports.SerialPort但这个类有不少坑。第一个坑是DataReceived事件在后台线程触发如果你在里面直接操作UI控件必死无疑。第二个坑是串口打开失败经常是参数不对——波特率、数据位、停止位、校验位必须跟下位机完全一致还要注意串口名称在USB转串口设备上会飘。我一般这样封装using System; using System.IO.Ports; using System.Text; public class SerialPortChannel : ICommunication { private SerialPort _port; private readonly object _sendLock new object(); public event Actionbyte[] DataReceived; public bool Open(string portName, int baudRate 115200, int dataBits 8, StopBits stopBits StopBits.One, Parity parity Parity.None) { try { _port new SerialPort(portName, baudRate, parity, dataBits, stopBits); _port.ReadTimeout 1000; _port.WriteTimeout 1000; _port.DataReceived OnDataReceived; _port.Open(); return true; } catch (Exception ex) { Console.WriteLine($串口打开失败: {ex.Message}); return false; } } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { try { int n _port.BytesToRead; byte[] buffer new byte[n]; _port.Read(buffer, 0, n); DataReceived?.Invoke(buffer); } catch (Exception ex) { Console.WriteLine($串口接收异常: {ex.Message}); } } public void Send(byte[] data) { lock (_sendLock) { if (_port ! null _port.IsOpen) _port.Write(data, 0, data.Length); } } public void Close() { if (_port ! null) { _port.DataReceived - OnDataReceived; _port.Close(); _port.Dispose(); } } }参数说明波特率必须与下位机一致常见的有9600、19200、115200数据位通常8位停止位1位校验位None或Even。如果通信数据量小把ReadTimeout写小一点避免读取线程挂死。DataReceived事件触发时不能保证数据一次性收全题目里的自制协议下位机可能分几包发所以后面必须要做缓冲区拼接。2.3 TCP通信封装TcpClient与异步接收TCP比串口省心不用担心波特率但坑在连接管理和断线重连。上位机作为客户端去连接下位机的TCP服务器常见做法是下位机当ServerPC当Client或者上位机自己监听一个端口等下位机连上来。我建议PC端做TCP Server这样下位机重启后能自动重连不用PC反复去连。using System; using System.Net; using System.Net.Sockets; using System.Threading; public class TcpServerChannel : ICommunication { private TcpListener _listener; private TcpClient _client; private Thread _acceptThread; private Thread _receiveThread; private volatile bool _running; public event Actionbyte[] DataReceived; public bool Open(int port) { try { _listener new TcpListener(IPAddress.Any, port); _listener.Start(); _running true; _acceptThread new Thread(AcceptLoop); _acceptThread.IsBackground true; _acceptThread.Start(); return true; } catch (Exception ex) { Console.WriteLine($TCP监听启动失败: {ex.Message}); return false; } } private void AcceptLoop() { while (_running) { try { _client _listener.AcceptTcpClient(); _client.NoDelay true; _receiveThread new Thread(ReceiveLoop); _receiveThread.IsBackground true; _receiveThread.Start(); } catch (Exception ex) { Console.WriteLine($Accept异常: {ex.Message}); Thread.Sleep(500); } } } private void ReceiveLoop() { NetworkStream stream _client.GetStream(); byte[] header new byte[4]; while (_running) { try { int read stream.Read(header, 0, header.Length); if (read 0) break; byte[] body new byte[read]; Buffer.BlockCopy(header, 0, body, 0, read); DataReceived?.Invoke(body); } catch (Exception ex) { Console.WriteLine($TCP接收异常: {ex.Message}); break; } } } public void Send(byte[] data) { if (_client ! null _client.Connected) { _client.GetStream().Write(data, 0, data.Length); } } public void Close() { _running false; _client?.Close(); _listener?.Stop(); } }这里我故意把接收写成每次读4字节——因为协议通常有固定帧头后面解析层会把字节拼成完整帧。NoDelay设为true是为了关闭Nagle算法降低小包延迟这对运动控制类交互很重要。TCP的坑在于Read不保证一次读到完整业务帧所以后面还是要做缓冲区。2.4 UDP通信封装UdpClient与无连接传输UDP是最简单的没有连接发出去就不管接收要靠线程不断Receive。它没有重传、没有流控所以协议层必须自己考虑丢包和乱序。上位机用UDP时重点是把本机端口和下位机地址/端口配置好。using System; using System.Net; using System.Net.Sockets; using System.Threading; public class UdpChannel : ICommunication { private UdpClient _udp; private Thread _receiveThread; private volatile bool _running; private IPEndPoint _remoteEndPoint; public event Actionbyte[] DataReceived; public bool Open(int localPort, string remoteIp, int remotePort) { try { _udp new UdpClient(localPort); _remoteEndPoint new IPEndPoint(IPAddress.Parse(remoteIp), remotePort); _udp.Connect(_remoteEndPoint); _running true; _receiveThread new Thread(ReceiveLoop); _receiveThread.IsBackground true; _receiveThread.Start(); return true; } catch (Exception ex) { Console.WriteLine($UDP打开失败: {ex.Message}); return false; } } private void ReceiveLoop() { IPEndPoint remote new IPEndPoint(IPAddress.Any, 0); while (_running) { try { byte[] data _udp.Receive(ref remote); DataReceived?.Invoke(data); } catch (Exception ex) { Console.WriteLine($UDP接收异常: {ex.Message}); Thread.Sleep(100); } } } public void Send(byte[] data) { _udp.Send(data, data.Length); } public void Close() { _running false; _udp?.Close(); } }注意UdpClient.Receive是阻塞的线程必须设为Background否则程序退出时会挂住。UDP的Receive每次返回一个完整的数据报不会出现TCP那种粘包但会丢包所以协议层要加序号和重传。2.5 统一接口设计用抽象接口屏蔽通信差异上面三个类都实现了同一个接口这个接口是协议层与通信层之间的隔离带。定义如下public interface ICommunication { event Actionbyte[] DataReceived; bool Open(params object[] parameters); void Send(byte[] data); void Close(); }Open用params object[]是为了让三种通道各自取参数——串口取端口名和波特率TCP取端口UDP取本地端口和远端地址。协议层不关心底层是串口还是网口它只需要拿到一个ICommunication实例调用Send发数据订阅DataReceived收数据。这种设计的最大好处是调试时用串口验收时换TCP只需要改一行new语句。很多上位机项目翻车就是栽在把协议耦合在具体通道上换个通信方式就得重写解析逻辑。3. 自制协议设计帧格式、校验、应答与超时3.1 自制协议为什么比Modbus更灵活帧格式设计思路标准协议如Modbus好用但字段固定扩展功能得去翻文档而且很多下位机资源紧张裁剪版Modbus一改就不标准了。自制协议听起来高大上其实就是自己定义一套快递单格式收件人怎么辨认、包裹多重、里面是什么货。它的优势在于帧头可以随意定命令号自己编排数据区能塞任何东西校验算法也能选轻量级或重量级。缺点也有——没有现成调试工具全得自己写。我见过的自制协议翻车点往往不是帧格式复杂而是帧边界不清晰。最常见的就是用帧头和长度字段但调试器不知道从哪里开始算一帧。所以协议设计第一步是定死帧头、长度、命令、数据、校验的顺序。3.2 帧格式定义以0xAA 0x55为例我习惯用两字节帧头0xAA 0x55后面跟一字节长度指从命令字到校验前的字节数、一字节命令字、N字节数据、一字节校验或两字节CRC16。为什么两字节帧头因为单字节帧头在数据区遇到相同字节容易误判双字节且固定顺序能大幅降低错位概率。// 帧格式: AA 55 LEN CMD DATA... CRC public class ProtocolFrame { public const byte HEAD0 0xAA; public const byte HEAD1 0x55; public byte Command { get; set; } public byte[] Data { get; set; } public byte Checksum { get; set; } public byte[] ToBytes() { int len Data.Length 3; // CMD DATA CRC byte[] frame new byte[2 1 len]; frame[0] HEAD0; frame[1] HEAD1; frame[2] (byte)len; frame[3] Command; Buffer.BlockCopy(Data, 0, frame, 4, Data.Length); frame[4 Data.Length] CalcChecksum(frame, 3, len - 1); return frame; } public static byte CalcChecksum(byte[] data, int start, int count) { byte sum 0; for (int i start; i start count; i) { sum data[i]; } return (byte)(~sum 1); // 取反加一即补码校验 } }这段代码把整帧拼出来长度字段里包含了命令字、数据区和校验码的字节数。校验算法选了取反加一的BCC简单高效下位机几行C代码就能实现。如果你的下位机寄存器多、数据量大建议换成CRC16但BCC对大多数控制场景足够。3.3 校验与重传CRC16与超时重发BCC适合低速小数据但遇到电磁干扰强的现场错帧率会上升。这时候我建议上CRC16。下面是一个查表法CRC16实现参数为Modbus标准多项式0xA001。public static class Crc16 { private static readonly ushort[] Table BuildTable(); private static ushort[] BuildTable() { ushort[] table new ushort[256]; for (ushort i 0; i 256; i) { ushort crc i; for (int j 0; j 8; j) { if ((crc 1) ! 0) crc (ushort)((crc 1) ^ 0xA001); else crc 1; } table[i] crc; } return table; } public static ushort Compute(byte[] data, int start, int count) { ushort crc 0xFFFF; for (int i start; i start count; i) { crc (ushort)((crc 8) ^ Table[(crc ^ data[i]) 0xFF]); } return crc; } }用了CRC16帧里就要把原来的BCC字段换成两字节CRC长度字段也要相应调整。上位机和下位机必须保持一致否则调试时都是错帧。做完校验还要解决一个问题下位机收到错误帧后不回应答上位机干等。所以发送指令必须带超时重传机制。public class ProtocolClient { private ICommunication _comm; private readonly object _lockObj new object(); private byte[] _pendingCommand; private DateTime _sendTime; public void SendCommand(byte cmd, byte[] data, int timeoutMs 200) { lock (_lockObj) { ProtocolFrame frame new ProtocolFrame { Command cmd, Data data }; _comm.Send(frame.ToBytes()); _pendingCommand frame.ToBytes(); _sendTime DateTime.Now; while ((DateTime.Now - _sendTime).TotalMilliseconds timeoutMs) { if (_pendingCommand null) // 收到应答后置空 return; Thread.Sleep(10); } throw new TimeoutException(命令应答超时); } } public void OnDataReceived(byte[] data) { // 这里交给帧解析器处理 // 如果解析到应答帧且与_pendingCommand匹配置空_pendingCommand } }这里用轮询等应答适合单线程发送场景。如果交互频繁建议改成手动重置事件WaitHandle避免占用CPU。记住超时不是可选项是必需品。没有超时的通信一旦下位机死机上位机永远卡死俗称等死。3.4 多功能交互设计命令字表与回调分发上位机与下位机的多功能交互本质是一张命令字表。0x01读温度0x02设置PID0x03启动电机0x04回传状态。每个命令对应一个处理函数用字典分发比写一串switch更好扩展。public class CommandDispatcher { private readonly Dictionarybyte, ActionProtocolFrame _handlers new Dictionarybyte, ActionProtocolFrame(); public void Register(byte cmd, ActionProtocolFrame handler) { _handlers[cmd] handler; } public void Dispatch(ProtocolFrame frame) { if (_handlers.TryGetValue(frame.Command, out var handler)) { handler(frame); } else { Console.WriteLine($未知命令: 0x{frame.Command:X2}); } } }使用的时候在窗体初始化时把所有命令的处理器注册一遍。这样做的好处是新增一个功能只需要增加一个Register调用和对应方法协议帧结构和分发逻辑完全不用改。下位机固件升级加了新命令上位机这里加一行就够了。4. 上位机软件架构界面、线程与数据流4.1 界面与后台分离避免卡UI的MVVM式设计WinForm上位机最常见的问题是界面上一个按钮点下去程序就卡死了。原因基本都是在UI线程里做了串口读取或TCP阻塞接收。我习惯用简单的ViewModel模式——后台线程跑通信界面只通过事件刷新。有个入门级但很实用的做法把通信服务和界面控件彻底分开界面只暴露连接、断开、发送几个方法内部实现细节全部封在服务类里。public class MainViewModel { private ICommunication _communication; private ProtocolClient _protocol; public event Actionstring LogMessage; public void Connect(string commType, params object[] args) { _communication CreateChannel(commType); _communication.Open(args); _communication.DataReceived OnDataReceived; } private ICommunication CreateChannel(string type) { switch (type) { case 串口: return new SerialPortChannel(); case TCP: return new TcpServerChannel(); case UDP: return new UdpChannel(); default: throw new ArgumentException(未知通信类型); } } private void OnDataReceived(byte[] data) { // 交给解析器而不是在这里操作UI // 解析完成后触发LogMessage事件 } }界面代码订阅LogMessage事件用Invoke把文本更新到TextBox。这样即使通信线程异常崩溃UI也不会被拖死。很多人觉得MVVM重其实这里用的事件驱动思想就够用了没必要上完整框架。4.2 数据解析线程队列与防粘包协议解析是所有上位机工程的灵魂。串口和TCP是流式传输下位机可能一次发来半包也可能两帧粘在一起。如果直接对收到的byte[]逐帧解析必然翻车。正确做法是维护一个接收缓冲区每次收到数据就Append进去然后从缓冲区里不断查找帧头找到完整帧就裁剪出来剩下继续等。public class FrameParser { private readonly Listbyte _buffer new Listbyte(); private readonly object _lockObj new object(); public event ActionProtocolFrame FrameReceived; public void Push(byte[] data) { lock (_lockObj) { _buffer.AddRange(data); TryParseFrames(); } } private void TryParseFrames() { while (_buffer.Count 2) { // 找帧头0xAA 0x55 int headIndex -1; for (int i 0; i _buffer.Count - 1; i) { if (_buffer[i] 0xAA _buffer[i 1] 0x55) { headIndex i; break; } } if (headIndex 0) { _buffer.Clear(); // 没有帧头丢弃所有数据 return; } if (headIndex 0) { _buffer.RemoveRange(0, headIndex); // 丢弃帧头前的垃圾字节 } if (_buffer.Count 3) return; // 等长度字段 int len _buffer[2]; if (_buffer.Count 3 len) return; // 等完整帧 byte[] frameBytes _buffer.GetRange(0, 3 len).ToArray(); _buffer.RemoveRange(0, 3 len); ProtocolFrame frame Parse(frameBytes); if (frame ! null) FrameReceived?.Invoke(frame); } } private ProtocolFrame Parse(byte[] frameBytes) { if (frameBytes.Length 4) return null; byte cmd frameBytes[3]; byte[] data new byte[frameBytes.Length - 5]; Buffer.BlockCopy(frameBytes, 4, data, 0, data.Length); byte crc frameBytes[frameBytes.Length - 1]; if (CalcChecksum(frameBytes, 3, frameBytes.Length - 4) ! crc) { Console.WriteLine(校验失败, 丢弃错误帧); return null; } return new ProtocolFrame { Command cmd, Data data, Checksum crc }; } }这个解析器里有个关键点找不到帧头时直接Clear缓冲区。有些场景下这么做会丢掉残帧但考虑到帧头是0xAA 0x55误码后重新同步的概率很高。另一种做法是把最后几个字节保留继续找帧头但代码复杂度会上去。对于大多数工控场景直接清是最省心的。4.3 日志与状态显示一个简单的日志组件上位机调试时没有日志等于挨打。我习惯在每次收发数据时把原始字节和解析结果写进一个全局日志同时显示在界面上。日志组件不复杂但要有时间戳、方向标记和帧内容。public class Logger { private readonly StringBuilder _sb new StringBuilder(); private readonly object _lockObj new object(); public event Actionstring NewLogLine; public void LogSend(byte[] frame) { Log($[TX] {FormatBytes(frame)}); } public void LogRecv(byte[] frame) { Log($[RX] {FormatBytes(frame)}); } private void Log(string line) { string full ${DateTime.Now:HH:mm:ss.fff} {line}; lock (_lockObj) { _sb.AppendLine(full); if (_sb.Length 100000) _sb.Remove(0, 50000); } NewLogLine?.Invoke(full); } private static string FormatBytes(byte[] data) { return BitConverter.ToString(data).Replace(-, ); } }日志要限长否则内存疯涨。50KB以后截断是经验值能保住最近几百条记录。有了日志下位机不回复、回错数据、校验失败这类玄学问题一看日志就能定位。5. 通信调试避坑从打开失败到粘包的常见问题排查5.1 串口打开失败提示端口不存在或已被占用现象调用SerialPort.Open()抛出IOException或UnauthorizedAccessException提示端口不存在或被占用。 原因最常见的是USB转串口设备没有插好或者驱动异常导致串口号漂移其次是串口被另一个调试工具比如串口助手占用。 解决先用System.IO.Ports.SerialPort.GetPortNames()列出可用端口再在界面里做下拉选择不要写死COM1。如果确认端口在但打不开去设备管理器里看是否正确识别或者重启USB转串口设备。代码层面Open失败后要释放SerialPort对象避免句柄泄漏。5.2 TCP连接意外断开SocketException异常吞掉现象下位机重启或网络抖动后上位机收不到任何数据程序没有崩溃但像死了一样。 原因TCP没有心跳机制Read在连接断开后抛SocketException异常但异常被catch后没有触发重连逻辑线程就静默退出了。 解决在ReceiveLoop的catch里判断异常类型如果是SocketException且不是超时就触发断开事件让上层重新监听或重连。另外应用层要加心跳——上位机每隔几秒发一个空命令下位机不回就认为链路断了主动重连。别指望Windows帮你维护TCP连接应用层心跳是必须的。5.3 UDP接收不到数据端口绑定与防火墙现象上位机UDP收不到下位机发的数据但下位机说已经发出来了。 原因一是上位机绑定的本地端口和下位机发送的目的端口不一致二是Windows防火墙默认拦截来自局域网其他设备的UDP包。 解决先用netstat -an | findstr UDP查看端口监听状态确认端口号没写错。防火墙问题把自己程序的入站规则加好或者临时关闭防火墙测试注意安全。更隐蔽的坑是UdpClient如果不调用ConnectReceivedFrom才能拿到发送方地址如果调用了Connect虽然能收发但过滤器会丢弃不匹配来源的包。调试时建议都打印发送方IP和端口。5.4 粘包和半包数组越界与数据错乱现象收到的数据有时一帧被拆成两段有时两帧合并成一段解析出来的命令字和CRC完全对不上。 原因串口/TCP是字节流没有分包边界。如果只取当前Read到的字节去解析必然出问题。 解决用第4章讲的FrameParser把所有数据先塞进缓冲区再解析不要直接在DataReceived事件里处理。很多人看到接收事件就兴奋地直接解析这是血泪经验换来的教训。另外注意长度字段不要用错——如果长度指的是从命令字到校验和的字节数那帧总长度就是长度字段帧头2字节下位机和上位机必须用同一个公式。5.5 跨线程更新UI控件访问异常现象串口或TCP的DataReceived事件里直接写listBox.Items.Add抛InvalidOperationException提示有另一个线程正在使用此控件。 原因事件回调跑在后台线程WinForm控件只能在创建它的线程UI线程上操作。 解决用Control.Invoke或BeginInvoke封送。最简单的方式是在事件里捕获UI控件然后调用BeginInvoke(new Action(() textBox.Text line))。注意BeginInvoke是异步的高频数据下界面可能来不及刷新导致UI线程积压。更稳妥的方案是用异步队列后台把日志塞到队列UI线程用Timer定期取一次。这个方案在大量数据时会发现界面明显流畅很多。6. 进阶技巧用模拟器验证协议让开发效率翻倍做上位机最痛苦的事不是写代码而是下位机不在手边。硬件工程师还在焊板子你这边代码写完了没法联调。后来我发现一个很实用的办法自己写一个下位机模拟器跑在PC上用TCP或串口虚拟回路跟真实通信代码对接。模拟器不用太复杂只需要解析帧头、CRC校验然后根据命令字回放固定数据。public class DeviceSimulator { private readonly TcpListener _listener; private TcpClient _client; public DeviceSimulator(int port) { _listener new TcpListener(IPAddress.Loopback, port); } public void Start() { _listener.Start(); Task.Run(() { _client _listener.AcceptTcpClient(); byte[] buffer new byte[128]; while (true) { int n _client.GetStream().Read(buffer, 0, buffer.Length); if (n 0) break; // 简单解析: 收到0xAA 0x55开头, 命令字0x01, 回一帧温度数据 if (n 4 buffer[0] 0xAA buffer[1] 0x55 buffer[3] 0x01) { byte[] reply new byte[] { 0xAA, 0x55, 0x04, 0x01, 0x1A, 0x00, 0xFB }; _client.GetStream().Write(reply, 0, reply.Length); } } }); } }这个模拟器在回环地址上监听端口你的上位机连接127.0.0.1就能联调。把模拟器的回复数据故意做错几个字节可以顺便测试上位机对错误帧的容忍度。我一般会写一个简单的协议自动化测试把几百条命令循环发下去验证每个回调、每条日志都符合预期。等真硬件到了基本一次就通。还有一个小技巧把模拟器和上位机分别跑在窗口两边左边实时看收到的字节右边看解析出的数据。以前我用串口助手抓包现在直接模拟器里把收发都显示出来比任何调试工具都直观。这套方案的另一个好处是下位机那边的协议实现可以先照模拟器抄两边对照着调能把协议不一致的概率降到最低。这些年做上位机的体会就是通信层一定要薄协议层一定要清晰模拟器一定要早写。很多项目等到现场联调才暴露协议问题那时候改起来贼被动。希望这些经验能帮你少踩几个坑祝你一次联调通过。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进