简介:面向从事工业自动化、物联网设备联调及桌面端上位机开发的初中级开发者,这份围绕机器人通信的调试代码以Socket网络通信与WPF界面框架为核心,提供可参考的服务器端实现思路:在桌面程序中监听TCP端口、接受机器人客户端连接,并通过异步方式完成指令下发与反馈接收,避免阻塞界面线程;同时借助WPF的数据绑定与界面更新能力展示通信状态和收发内容。压缩包共38个文件、约70KB,包含14个C#源码文件、2个XAML界面文件,以及解决方案与项目文件、配置和可执行文件等,便于直接查看工程结构与运行效果。已有1419人学习,适合需要理解机器人Socket通信调试、连接管理、异常处理与资源释放的读者对照参考,也可作为WPF上位机通信模块的入门示例。
1. 一套能独立跑的机器人通讯调试器,为什么值得自己写
给机器人做上位机对接,真正卡住进度的往往不是业务逻辑,而是“发出去的字节到底对不对、收回来的帧到底全不全”。这份资源是一套基于 WPF 的机器人 Socket 通讯调试代码,把 TCP 连接管理、帧编解码、收发日志、十六进制查看、指令模板和回环测试收进一个桌面程序,专门解决上位机与机器人控制器联调时的黑匣子问题。适合刚接手上位机对接的开发者,也适合手上只有一堆日志文件、想补一套可视化调试手段的熟手。它的价值不在界面好看,而在于把“通讯”这件事从玄学变成可以逐字节复现的过程。
2. 协议先行:把机器人报文拆成可复用的帧模型
多数人拿到调试代码的第一反应是先把连接按钮点亮,看得见“已连接”三个字就踏实了。我一般反着来,先花半小时把帧结构固定下来,哪怕对面控制器手册只给了几页纸。原因很直接:Socket 本身只保证字节顺序,不保证消息边界,TCP 的粘包和半包会把“一条指令”揉成任意形状,没有帧模型,后面的日志、重发、序号匹配全是空中楼阁。这一章先把协议层写死,后面的界面和队列才有地方挂。
2.1 帧结构三件事:长度、校验、序号
工业机器人控制器常见的自定义协议,无论怎么包装,绕不开三个字段:长度、校验、序号。长度用来切边界,校验用来判断该不该信,序号用来把应答和请求对上号。下面这套结构是我在多个模拟项目里反复用过的版本,字段不多但够用。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定0xAA 0x55,用来做重同步 |
| 长度 | 2 | 小端,统计从序号到载荷末尾的字节数 |
| 序号 | 2 | 小端,请求自增,应答原样带回 |
| 命令字 | 1 | 区分读状态、写点位、心跳、急停等 |
| 载荷 | N | 变长,按命令字解释 |
| 校验 | 2 | CRC16/MODBUS,覆盖长度字段到载荷末尾 |
这里有两个容易吵起来的点。长度字段要不要包含帧头?我倾向不包含,从序号开始算,这样解析时偏移量直观。校验要不要覆盖帧头?覆盖帧头的话,一旦帧头被干扰,校验必然失败,反而更容易发现错帧;但它也让“扫描重同步”变复杂。实际项目里我选择校验不覆盖帧头,靠0xAA 0x55做重同步,靠 CRC 保证内容完整,两件事分开管。
// FrameCodec.cs public static class FrameCodec { public const byte Head0 = 0xAA; public const byte Head1 = 0x55; public const int HeaderSize = 2; public const int MinFrameSize = 9; // 头2 + 长度2 + 序号2 + 命令1 + 校验2 // 编码:cmd + seq + payload -> 完整字节流 public static byte[] Encode(byte cmd, ushort seq, ReadOnlySpan<byte> payload) { // 长度 = 序号2 + 命令1 + 载荷N ushort bodyLen = (ushort)(payload.Length + 3); var buf = new byte[HeaderSize + bodyLen + 2]; buf[0] = Head0; buf[1] = Head1; BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), bodyLen); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), seq); buf[6] = cmd; payload.CopyTo(buf.AsSpan(7)); // CRC 覆盖 长度字段到载荷末尾,不含帧头 ushort crc = Crc16.Modbus(buf.AsSpan(2, bodyLen)); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(7 + payload.Length), crc); return buf; } }bodyLen用ushort是有意的,65535 字节的上限对机器人指令完全够用,也顺便堵死了“长度字段被解析成负数”的可能性。BinaryPrimitives比手写位移更不容易在小端大端上翻车,控制器那边如果是大端,只需要把这几处改成BigEndian,不用动业务代码。CRC 从下标 2 开始算,bodyLen作为长度参数,这个组合和解析端的逻辑必须严格对称,否则会出现“自己发的包自己解不出来”的尴尬。
2.2 用状态机处理粘包与半包
帧结构定完,下一个问题就是缓冲区怎么管。最省事的写法是每收一次就把数据拼进List<byte>,然后从头扫一遍找0xAA 0x55。这种写法在低速指令下能跑,一旦控制器以 100Hz 推状态帧,时间复杂度立刻变成 O(n²),界面开始卡,日志开始延迟,最后你以为是网络问题,其实是自己写的循环在拖后腿。
我用的是游标加状态机的写法:维护一个固定缓冲区和一个已填充长度,解析成功一帧就把剩余字节前移,解析失败就丢弃一个字节继续找帧头。这样每字节最多被扫描常数次,长跑不涨内存。
// FrameParser.cs public sealed class FrameParser { private readonly byte[] _buf = new byte[64 * 1024]; private int _len; // 已缓冲字节数 public List<Frame> Feed(ReadOnlySpan<byte> chunk) { var result = new List<Frame>(); if (_len + chunk.Length > _buf.Length) throw new InvalidOperationException("接收缓冲区溢出,可能存在脏数据流"); chunk.CopyTo(_buf.AsSpan(_len)); _len += chunk.Length; int offset = 0; while (true) { // 1) 找帧头,找不到就把剩下的都留下(可能是半个帧头) int head = FindHeader(offset); if (head < 0) { offset = Math.Max(offset, _len - 1); break; } offset = head; // 2) 帧头够了再看长度字段 if (_len - offset < FrameCodec.MinFrameSize) break; ushort bodyLen = BinaryPrimitives.ReadUInt16LittleEndian(_buf.AsSpan(offset + 2)); int total = bodyLen + FrameCodec.HeaderSize + 2; if (total > _buf.Length) { offset += 2; continue; } // 长度离谱,跳过这个假帧头 // 3) 数据不够,等下一次 Feed if (_len - offset < total) break; ushort crcExpect = BinaryPrimitives.ReadUInt16LittleEndian(_buf.AsSpan(offset + total - 2)); ushort crcActual = Crc16.Modbus(_buf.AsSpan(offset + 2, bodyLen)); if (crcExpect == crcActual) { ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(_buf.AsSpan(offset + 4)); byte cmd = _buf[offset + 6]; var payload = _buf.AsSpan(offset + 7, bodyLen - 3).ToArray(); result.Add(new Frame(seq, cmd, payload)); offset += total; } else { offset += 2; // 校验错,认定假帧头,向后挪两字节重找 } } // 4) 把未消费的尾巴搬回缓冲区头部 if (offset > 0) { Buffer.BlockCopy(_buf, offset, _buf, 0, _len - offset); _len -= offset; } return result; } private int FindHeader(int from) { for (int i = from; i < _len - 1; i++) if (_buf[i] == FrameCodec.Head0 && _buf[i + 1] == FrameCodec.Head1) return i; return -1; } }几个参数值得说清楚。缓冲区开 64KB,是因为状态帧最密的时候一秒能推上来几十条、每条约 40 字节,留够两秒的余量足够应付一次 UI 卡顿。FindHeader找不到时把offset设成_len - 1,是为了保留最后一个字节,避免帧头0xAA 0x55正好被切在两次Feed之间。校验失败时只挪 2 字节而不是整帧丢弃,是因为脏数据里很可能藏着真正的帧头,整帧丢弃会连带把后面的正常帧吃掉。这三处细节在真机上都被验证过,缺一个就会出现“偶尔丢一帧、重启就好”的灵异现象。
2.3 命令表与参数编解码
帧模型之上是命令语义。把命令字写成一组裸byte常量,短期能用,一个月后就没人记得0x17是读关节角还是读 IO。我的做法是用枚举加特性表,把命令字、方向、载荷长度、超时时间绑在一起。
public enum RobotCmd : byte { Heartbeat = 0x01, // 心跳,10Hz,无载荷 ReadJoint = 0x10, // 读关节角,请求无载荷,应答 6×float WritePoint = 0x21, // 写目标点位,请求 6×float + 2×ushort ReadIo = 0x30, // 读 IO,请求 1 字节端口号 EmergencyStop = 0x7F } // 命令元数据:超时与重试策略集中管理 public static class CmdMeta { public static readonly Dictionary<RobotCmd, (int TimeoutMs, int Retry)> Table = new() { [RobotCmd.Heartbeat] = (0, 0), // 心跳不重试,丢了等下一条 [RobotCmd.ReadJoint] = (300, 2), [RobotCmd.WritePoint] = (800, 1), // 写指令不轻易重试,避免重复执行 [RobotCmd.ReadIo] = (200, 3), [RobotCmd.EmergencyStop] = (0, 0) // 发送即走,不等应答 }; }WritePoint只允许重试一次,这个是血泪经验。运动指令不是幂等的,重试两次可能让机器人把同一条轨迹走两遍,轻则节拍错乱,重则撞夹具。心跳和急停的超时设成 0,表示“不等应答”,避免应答通道堵塞时把发送队列一起拖死。参数编解码统一走小端定点数,浮点用float直接按 IEEE754 传输,不用字符串,省掉一堆解析歧义。字符串类参数(比如点位名)在协议里固定长度、不足补0x00,编码统一 UTF-8,这一条在第五章还会单独展开。
3. WPF 侧的收发骨架:异步、队列、界面不卡
协议层写完,很多人的第一版界面是这样的:点击连接,socket.Connect()同步调用,接收写在while(true)里,收到数据直接textBox.Text += ...。单条指令测试时一切正常,接上控制器跑十分钟,界面开始一顿一顿,再久一点直接无响应。问题不在 WPF,在于把阻塞 IO 和 UI 线程绑在一起。这一章把收发拆成三条独立的线:连接生命周期、收包循环、界面消费。
3.1 Socket 连接生命周期与 CancellationToken
连接不是一次动作,而是一个状态机:未连接、连接中、已连接、断开中、已断开。少画一个状态,后面就要用一堆if (socket != null && socket.Connected)去补,而Connected这个属性反映的是最后一次 IO 的结果,不是实时状态,靠它判断断线迟早出事。
public sealed class RobotClient : IAsyncDisposable { private Socket? _socket; private CancellationTokenSource? _cts; private readonly Channel<Frame> _rxChannel = Channel.CreateBounded<Frame>(new BoundedChannelOptions(8192) { FullMode = BoundedChannelFullMode.DropOldest // 日志类消费,宁可丢旧的也不阻塞收包 }); public event Action<string>? OnStateChanged; public ChannelReader<Frame> Received => _rxChannel.Reader; public async Task ConnectAsync(string host, int port, CancellationToken outer) { if (_socket is not null) throw new InvalidOperationException("已连接或正在连接"); _cts = CancellationTokenSource.CreateLinkedTokenSource(outer); _cts.CancelAfter(TimeSpan.FromSeconds(5)); // 连接超时,不能无限等 var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true // 关掉 Nagle,小包别攒着 }; try { OnStateChanged?.Invoke("连接中"); await sock.ConnectAsync(host, port, _cts.Token); _socket = sock; OnStateChanged?.Invoke("已连接"); _ = Task.Run(() => ReceiveLoopAsync(sock, _cts.Token)); } catch (OperationCanceledException) { sock.Dispose(); OnStateChanged?.Invoke("连接超时"); throw; } catch (SocketException ex) { sock.Dispose(); OnStateChanged?.Invoke($"连接失败: {ex.SocketErrorCode}"); throw; } } }NoDelay = true是必选项。Nagle 算法默认会把小包攒到 200ms 或凑够 MSS 再发,机器人通讯里一条写指令才 20 字节,等 40ms 才发出去,节拍调试时你会怀疑人生。连接超时设 5 秒,是因为控制器上电到网络服务就绪有时要十几秒,设太短会把“还没起来”误判成“网络不通”,设太长又会让操作员干等。BoundedChannelFullMode.DropOldest也是刻意的:收包线程绝不能被消费端拖住,宁可丢掉旧日志,也不能让 Socket 缓冲区堆积导致内核层丢包。
3.2 收包线程到 UI 线程的桥接
收包循环只做一件事:把字节喂给解析器,把解析出的帧塞进 Channel,然后立刻回到ReceiveAsync。它不碰 UI,不写日志文件,不做业务判断。所有消费放到 UI 侧,但也不是每来一帧就刷一次界面,而是用一个 50ms 的定时器批量取。
private async Task ReceiveLoopAsync(Socket sock, CancellationToken ct) { var parser = new FrameParser(); var buffer = new byte[8192]; try { while (!ct.IsCancellationRequested) { int n = await sock.ReceiveAsync(buffer, SocketFlags.None, ct); if (n == 0) break; // 对端正常关闭 foreach (var frame in parser.Feed(buffer.AsSpan(0, n))) { if (!_rxChannel.Writer.TryWrite(frame)) _rxChannel.Writer.WriteAsync(frame, ct).AsTask().GetAwaiter().GetResult(); } } } catch (OperationCanceledException) { } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionReset) { // 控制器侧主动断开,很常见,不当作异常上报 } finally { OnStateChanged?.Invoke("已断开"); _rxChannel.Writer.TryComplete(); } }UI 侧用一个DispatcherTimer:
// MainViewModel.cs private void OnUiTick(object? sender, EventArgs e) { int budget = 200; // 一次最多刷 200 行,防止单帧突发把 UI 拖死 while (budget-- > 0 && _client.Received.TryRead(out var frame)) { var line = _logFormatter.Format(frame); // 时间戳 + 方向 + 命令名 + 载荷摘要 LogLines.Add(line); if (LogLines.Count > 5000) LogLines.RemoveAt(0); // 环形上限,别让 ObservableCollection 无限长 } }DispatcherTimer的间隔取 50ms,是因为人眼对 20fps 以上的刷新已经无感,再快只是浪费 CPU。单次预算 200 行是防止某一刻控制器突发推送几百帧,一次性往ObservableCollection里塞会把 UI 线程占住,反而让下一次定时器延迟。LogLines上限 5000 行,超出就从头删,这个数字是拍脑袋定的,实际跑下来 5000 行大约占 20MB 内存,对桌面程序可接受。很多调试工具跑一小时后变卡,就是集合只增不减。
3.3 指令下发队列与超时重发
发送端比接收端更容易写错。多个按钮同时点、定时器自动发心跳、超时重发,这三股流量如果直接并发调SendAsync,会在 Socket 层交错,出现半帧拼接。解决办法是串行化:一个SemaphoreSlim(1,1)把所有发送排成队,配合序号表做应答匹配。
private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly ConcurrentDictionary<ushort, TaskCompletionSource<Frame>> _pending = new(); private ushort _seq; public async Task<Frame?> SendAsync(RobotCmd cmd, byte[] payload, int timeoutMs, int retry) { if (_socket is null) throw new InvalidOperationException("未连接"); for (int attempt = 0; attempt <= retry; attempt++) { ushort seq = unchecked(++_seq); // 0 也合法,回绕即可 var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously); _pending[seq] = tcs; await _sendLock.WaitAsync(); try { var bytes = FrameCodec.Encode((byte)cmd, seq, payload); await _socket.SendAsync(bytes, SocketFlags.None); } finally { _sendLock.Release(); } using var cts = new CancellationTokenSource(timeoutMs); using var reg = cts.Token.Register(() => tcs.TrySetCanceled()); try { return await tcs.Task; } catch (OperationCanceledException) { _pending.TryRemove(seq, out _); } } return null; }TaskCreationOptions.RunContinuationsAsynchronously这一句不能省。默认情况下TrySetResult会同步执行后续 continuation,如果那个 continuation 里带了 UI 操作,等于在收包线程上跑 UI 代码,跨线程异常就这么来的。_seq用unchecked自增,回绕到 0 也没问题,因为序号表按在途请求做键,同一时刻不会有两个相同序号在等应答。超时时间从CmdMeta表里取,写指令 800ms、读指令 200~300ms,这个梯度是根据控制器实际响应时间调的,设得太紧会误判重发,设得太松故障发现不及时。
4. 调试界面怎么摆:日志、十六进制、指令模板
通讯骨架搭好后,界面决定这套工具好不好用。我见过不少调试器,功能都有,但用起来别扭:日志只有文本没有方向,出问题想翻字节得另开抓包工具;发指令要手敲十六进制,输错一个字节排查半天。这一章把界面分成三块:可检索的日志、双视图的报文、带占位符的指令模板。
4.1 日志分级与可检索
日志不只是“显示”,它得能被查。每条日志至少带五个字段:时间戳(精确到毫秒)、方向(TX/RX/SYS)、命令字、序号、摘要。方向用颜色区分,TX 偏蓝、RX 偏绿、SYS 灰色,这是最常见的约定,不用解释用户也能懂。
public sealed record LogEntry( DateTime Timestamp, LogDirection Direction, string Cmd, ushort Seq, string Summary, byte[] Raw); public string Format(Frame frame, LogDirection dir) { string cmdName = Enum.IsDefined(typeof(RobotCmd), frame.Cmd) ? ((RobotCmd)frame.Cmd).ToString() : $"0x{frame.Cmd:X2}"; // 摘要只取前 16 字节,避免一行日志过长撑破界面 string hex = Convert.ToHexString(frame.Payload.AsSpan(0, Math.Min(16, frame.Payload.Length))); return $"{DateTime.Now:HH:mm:ss.fff} {dir} {cmdName} #{frame.Seq} {hex}"; }日志过滤我做了三个维度:按方向、按命令字、按关键字。关键字搜索直接搜十六进制字符串,比如输入A5就能把载荷里含0xA5的帧全捞出来,比按字节翻快得多。日志上限前面说了 5000 行,但导出不加限制,点一下把当前缓冲区整个写成 CSV,时间戳、方向、命令、序号、完整十六进制各一列,方便事后拿 Excel 对时序。这一步在排查“指令发了但机器人没动”时特别有用,能一眼看出是压根没发、发了没应答、还是应答了但业务层没处理。
4.2 十六进制与文本双视图
载荷到底是字节还是字符串,协议里两种都常见。纯十六进制视图看结构清楚,但遇到 ASCII 点位名就抓瞎;纯文本视图读名字方便,但二进制字段全是乱码。我的做法是同一个byte[]渲染两遍,上下两栏联动滚动,选中某一行时高亮对应的另一栏。
public static string ToHexDump(ReadOnlySpan<byte> data, int bytesPerLine = 16) { var sb = new StringBuilder(); for (int i = 0; i < data.Length; i += bytesPerLine) { var slice = data.Slice(i, Math.Min(bytesPerLine, data.Length - i)); sb.Append(i.ToString("X4")).Append(" "); foreach (var b in slice) sb.Append(b.ToString("X2")).Append(' '); // 补齐对齐,最后一行不满 16 字节也不影响右侧字符区 for (int k = slice.Length; k < bytesPerLine; k++) sb.Append(" "); sb.Append(" |"); foreach (var b in slice) sb.Append(b is >= 0x20 and < 0x7F ? (char)b : '.'); sb.AppendLine("|"); } return sb.ToString(); }bytesPerLine = 16是终端 hexdump 的惯例,一行 16 字节对应右侧 16 个字符,位置一一对应,眼睛扫过去不用数。不可打印字符统一显示成.,这样 ASCII 字符串里的0x00补齐一眼就能看出来是填充而不是数据。右侧字符区只判0x20~0x7E,中文按 UTF-8 编码后是多字节,会显示成多个.,这是符合预期的——想看中文切到文本视图,用正确的编码解码。
4.3 指令模板与变量占位
调试阶段反复发的就那么几条指令:读关节、读 IO、写一个测试点位。每次手敲载荷不现实,我做成模板加占位符的形式,模板存在一个 JSON 里,界面上拉出来填参数就能发。
// 模板示例:{0} 到 {5} 是六个关节角,{6} 是速度百分比 public sealed class CommandTemplate { public string Name { get; init; } = ""; public RobotCmd Cmd { get; init; } public string PayloadFormat { get; init; } = ""; // 逗号分隔的字段描述 public int TimeoutMs { get; init; } = 300; public int Retry { get; init; } = 0; } public static byte[] BuildPayload(string format, IReadOnlyList<string> args) { var fields = format.Split(',', StringSplitOptions.TrimEntries); using var ms = new MemoryStream(); for (int i = 0; i < fields.Length; i++) { switch (fields[i]) { case "f32": // 单精度浮点,小端 ms.Write(BitConverter.GetBytes(float.Parse(args[i], CultureInfo.InvariantCulture))); break; case "u16": ms.Write(BitConverter.GetBytes(ushort.Parse(args[i]))); break; case "ascii16": // 定长 16 字节字符串,不足补 0 var raw = new byte[16]; Encoding.UTF8.GetBytes(args[i]).CopyTo(raw, 0); ms.Write(raw); break; } } return ms.ToArray(); }PayloadFormat用字符串描述字段类型,好处是模板可以纯配置文件化,不用为每条指令写一个类。float.Parse必须带CultureInfo.InvariantCulture,否则在某些区域设置下小数点会被当成千分位,1.5解析成15,这个坑我在两个项目上各踩过一次。ascii16定长字符串用 UTF-8 编码再补零,中文字符会占 3 字节,模板里得把长度留够,不然会被截断。
5. 避坑与排查:五个真机上翻车的瞬间
协议和界面都写完,剩下的时间基本都在和“偶发”打交道。下面五条是我和同事在联调现场真实遇到过的问题,每条都按现象、原因、解决写,能省掉一些重复试错。
5.1 日志里帧时有时无,重启后又正常
现象:跑十几分钟后开始丢日志,界面上还能看到零星几条,但对比控制器侧计数明显少了一截。原因不是网络,是ObservableCollection无限增长,UI 线程每次插入都要触发集合变更通知,几千行之后单次插入耗时上升到毫秒级,50ms 定时器的预算被吃光,Channel 里的帧越积越多,最后触发DropOldest静默丢弃。解决是给集合设上限,超出从头删,同时把单次刷新预算压到 200 行以内。改完之后连跑两小时日志计数和控制器侧完全对得上。
5.2 接收回调里直接更新界面,跑一会儿就假死
现象:收包循环里写了textBox.AppendText(...),单条指令测试没问题,控制器一开连续推流,界面几秒内失去响应。原因是ReceiveAsync的 continuation 默认在 IO 完成线程上跑,直接操作 UI 控件会触发跨线程访问,WPF 有时不报错而是排队等 UI 线程,等不到就假死。解决是把所有界面更新挪到Dispatcher或定时器消费,收包线程只往 Channel 写。判据很简单:收包循环里出现任何控件名,就是错的。
5.3 关闭连接时抛 ObjectDisposedException
现象:点断开按钮后弹异常,堆栈指向ReceiveAsync。原因是关闭流程先socket.Dispose()再取消CancellationToken,正在等待的ReceiveAsync拿到的不是取消信号而是已释放的句柄。正确顺序是先_cts.Cancel(),等接收循环自然退出,再Dispose。如果对端先断开,ReceiveAsync会返回 0,这时要主动Shutdown(SocketShutdown.Both)再释放,否则端口会处于TIME_WAIT,短时间内重连可能失败。
5.4 心跳和业务应答混在一起,超时判定误伤
现象:写点位指令偶尔超时,重发一次就成功,但控制器日志显示第一次其实收到了。原因是心跳每 100ms 发一次,和写指令共用同一个 Socket,应答帧回来时序号匹配没错,但接收队列被心跳挤满,写指令的应答排到了超时之后。解决是把心跳单独走一条通道,或者在协议层给心跳应答打标记,接收端优先派发非心跳帧。更彻底的做法是心跳用 UDP,业务用 TCP,隔离故障域。
5.5 中文点位名发过去变乱码
现象:模板里填了中文点位名,机器人侧读出来是问号。原因是编码不一致,上位机用 UTF-8,控制器侧按 GBK 解析。这类问题没有通用解,只能对齐。我的做法是在模板配置里加一个Encoding字段,默认 UTF-8,遇到老控制器改成 GBK,同时在日志里把发送的原始字节打出来,对不上时一眼能看出是编码问题还是长度截断。定长字段还要注意,UTF-8 中文占 3 字节,GBK 占 2 字节,同一个 16 字节字段能装下的字符数不同,模板的长度校验要按编码分别算。
6. 进阶:用回环服务端做无人值守回归
真机不在手边的时候,调试代码最容易退化成“只能看不能测”。我的办法是写一个回环服务端,模拟控制器行为,让上位机的连接、编解码、超时重发、日志全流程都能在本地跑通。这个服务端不需要复杂,能把帧解析出来、按命令字回一条固定应答就够用。
// LoopbackServer.cs:监听本地端口,收到什么命令回什么应答 public sealed class LoopbackServer { private readonly TcpListener _listener = new(IPAddress.Loopback, 9000); public async Task RunAsync(CancellationToken ct) { _listener.Start(); while (!ct.IsCancellationRequested) { using var client = await _listener.AcceptTcpClientAsync(ct); var stream = client.GetStream(); var parser = new FrameParser(); var buf = new byte[4096]; int n; while ((n = await stream.ReadAsync(buf, ct)) > 0) { foreach (var frame in parser.Feed(buf.AsSpan(0, n))) { // 心跳不回,其他命令原序号回一条空载荷,模拟最快应答 if ((RobotCmd)frame.Cmd == RobotCmd.Heartbeat) continue; var reply = FrameCodec.Encode(frame.Cmd, frame.Seq, ReadOnlySpan<byte>.Empty); await stream.WriteAsync(reply, ct); } } } } }有了它,回归测试就能写成脚本:连上回环服务端,按顺序发 100 条随机命令,断言每条都能在超时内拿到同序号应答,同时检查发送队列没有泄漏(_pending.Count == 0)。还可以加一个“延迟注入”,让服务端随机延迟 0~1200ms 再回,用来验证超时重发的边界。下面这张表是我常用的三组回归参数:
| 场景 | 服务端行为 | 期望结果 |
|---|---|---|
| 正常应答 | 立即回同序号 | 全部成功,无重发 |
| 慢应答 | 随机延迟 0~1200ms | 写指令最多重发 1 次,读指令重发 ≤2 次 |
| 丢应答 | 每 10 条丢 1 条 | 超时后重发,序号表不残留 |
这三组跑下来,基本能把序号回绕、并发发送、超时清理的问题提前暴露,不用等到现场真机上才发现“重连之后第一条指令必丢”这种问题。我现在的习惯是,任何通讯相关的改动,提交前先跑一遍回环回归,哪怕只改了一行日志格式——因为日志格式化里也可能藏着一次阻塞式 IO。从那以后我每次改完收发逻辑都强制走一遍回环加真机十分钟连续跑,再也没出现过“现场才发现丢帧”的情况。希望帮到你。
本文还有配套的精品资源,点击获取