简介:基于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 Action<byte[]> 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服务器(常见做法是下位机当Server,PC当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 Action<byte[]> 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 Action<byte[]> 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 Action<byte[]> 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设置PID,0x03启动电机,0x04回传状态。每个命令对应一个处理函数,用字典分发比写一串switch更好扩展。
public class CommandDispatcher { private readonly Dictionary<byte, Action<ProtocolFrame>> _handlers = new Dictionary<byte, Action<ProtocolFrame>>(); public void Register(byte cmd, Action<ProtocolFrame> 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 Action<string> 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 List<byte> _buffer = new List<byte>(); private readonly object _lockObj = new object(); public event Action<ProtocolFrame> 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 Action<string> 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如果不调用Connect,ReceivedFrom才能拿到发送方地址;如果调用了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就能联调。把模拟器的回复数据故意做错几个字节,可以顺便测试上位机对错误帧的容忍度。我一般会写一个简单的协议自动化测试,把几百条命令循环发下去,验证每个回调、每条日志都符合预期。等真硬件到了,基本一次就通。
还有一个小技巧:把模拟器和上位机分别跑在窗口两边,左边实时看收到的字节,右边看解析出的数据。以前我用串口助手抓包,现在直接模拟器里把收发都显示出来,比任何调试工具都直观。这套方案的另一个好处是,下位机那边的协议实现可以先照模拟器抄,两边对照着调,能把协议不一致的概率降到最低。
这些年做上位机的体会就是:通信层一定要薄,协议层一定要清晰,模拟器一定要早写。很多项目等到现场联调才暴露协议问题,那时候改起来贼被动。希望这些经验能帮你少踩几个坑,祝你一次联调通过。
本文还有配套的精品资源,点击获取