简介:这份压缩包面向自动化、物联网及机器人应用开发者,提供一套用 C# 与 WPF 构建的机器人通信调试工程,重点演示 Socket 服务端监听、客户端连接、异步数据收发与界面交互的组织方式。包内共38个文件,以14个 cs 源码、2个 xaml 界面文件、3个 config 配置及 exe、pdb 等编译产物为主,整体约70KB,结构紧凑,便于直接运行对照或拆解复用。已有1419人学习,适合需要理解 WPF 桌面端与机器人 TCP 通信如何配合的初中级开发者参考。读者可从中梳理服务端端口监听、连接分配、UI 线程与后台通信分离、异常处理与断开资源释放等关键环节,并借助示例工程快速搭建自己的通信调试原型,排查数据收发与界面刷新中的常见问题。
1. 产线停机那次,我发现问题不在机器人
产线调试最怕的不是机器人报错,是它一声不吭。去年在一台六轴设备的联调现场,上位机界面一直显示已连接,日志里却只有一行 timeout,机器人侧的工程师咬定是上位机没把指令发出去,我这边怀疑对方控制器吞包。折腾到半夜才发现,是我们收到的两帧粘在一起,按一帧解析,长度字段直接越界,后面的报文全被丢掉。那之后我把这套机器人通讯调试的代码重新整理了一遍。它是一个用 WPF 写的桌面调试器,底层走 Socket,能手动拼帧、连续收发、十六进制查看、断线重连和心跳保活,专门用来在正式上位机写出来之前,先把协议跑通。适合两类人:一类是刚接手机器人上位机、需要一个能立刻验证收发的小工具;另一类是协议已经定了,但想看清楚每一帧字节怎么来的熟手。
2. 工程骨架与通讯分层:一次收发在 WPF 里的完整落点
拆一个桌面调试器,最容易犯的错是把 Socket 读写直接塞进按钮的 Click 事件里。这套代码一开始也是这么写的,跑通了三天,然后在高频发指令的时候界面开始假死。后来重构成三层:App 只负责界面和绑定,Protocol 只管字节和帧,Transport 管连接和线程。三层之间靠接口传数据,谁都不越界。
2.1 目录结构:协议、传输、界面三块怎么切
先把目录摊开看,后面读代码心里才有坐标。这套结构的核心原则是:Protocol 不引用任何 WPF 程序集,Transport 不引用任何 View,反过来的依赖只在 App 这一层发生。
RobotCommDebugger/ ├── src/ │ ├── RobotCommDebugger.App/ # WPF 主程序 │ │ ├── Views/ # XAML 窗口与用户控件 │ │ ├── ViewModels/ # 绑定用的 ViewModel │ │ ├── Converters/ # 十六进制/时间戳转换器 │ │ └── App.xaml │ ├── RobotCommDebugger.Protocol/ # 帧结构与编解码 │ │ ├── Frame.cs # 帧的不可变数据模型 │ │ ├── FrameCodec.cs # 封帧 / 拆帧 │ │ ├── Checksum.cs # CRC16 与累加和 │ │ └── CommandTable.cs # 命令字与名称映射 │ ├── RobotCommDebugger.Transport/ # 连接与收发 │ │ ├── ITcpChannel.cs │ │ ├── TcpChannel.cs # TcpClient 封装 │ │ ├── ReconnectPolicy.cs # 退避重连 │ │ └── HeartbeatMonitor.cs # 心跳与超时 │ └── RobotCommDebugger.Core/ # 日志、配置、Hex 工具 │ ├── ByteFormatter.cs │ └── FileLogger.cs ├── tests/ │ └── RobotCommDebugger.Protocol.Tests/ └── docs/ └── frame-spec.md # 帧格式约定,改协议先看这个把docs/frame-spec.md单独列出来是有原因的。现场联调时,机器人侧改一个校验范围、加一个字节,如果没有一份双方都认的文档,你就只能在代码里考古。这份文档写清楚帧头、长度、命令字、负载、校验各自占几个字节、大小端怎么排,改协议先改文档再改代码,这条规矩后面能省掉很多返工。
Protocol项目不依赖 WPF 带来的好处很直接:编解码逻辑可以单独跑单元测试。手上这套代码里,FrameCodec的测试用例覆盖了正常帧、半包、粘包、校验错误、长度超限五种情况,用构造的字节数组喂进去,断言输出帧数量。这类测试写一次,后面改协议心里就有底。
2.2 Transport 层:TcpClient 的读循环和写锁
连接这块没有用裸 Socket,用的是TcpClient包一层,因为它对NetworkStream的封装足够省事,而调试工具不需要处理那么多底层选项。关键在读写分离:读循环单独跑一个后台任务,写操作加锁串行化。
public sealed class TcpChannel : ITcpChannel, IDisposable { private readonly TcpClient _client = new(); private readonly SemaphoreSlim _writeLock = new(1, 1); private NetworkStream? _stream; private CancellationTokenSource? _cts; public event Action<byte[]>? FrameReceived; // 上层订阅,不在本层解析 public event Action<string>? StateChanged; public async Task ConnectAsync(string host, int port, CancellationToken token) { _cts = CancellationTokenSource.CreateLinkedTokenSource(token); await _client.ConnectAsync(host, port, _cts.Token); _client.NoDelay = true; // 调试场景要低延迟,关掉 Nagle _stream = _client.GetStream(); _ = Task.Run(() => ReadLoopAsync(_cts.Token)); // 读循环独立跑,不阻塞调用方 StateChanged?.Invoke($"Connected {host}:{port}"); } private async Task ReadLoopAsync(CancellationToken token) { var buffer = new byte[4096]; var accumulator = new List<byte>(8192); // 跨 Receive 的累积缓冲 while (!token.IsCancellationRequested) { int read; try { read = await _stream!.ReadAsync(buffer, token); } catch (OperationCanceledException) { break; } catch (IOException ex) { StateChanged?.Invoke("Disconnected: " + ex.Message); break; } if (read == 0) { StateChanged?.Invoke("RemoteClosed"); break; } accumulator.AddRange(buffer.AsSpan(0, read).ToArray()); // 交给协议层拆帧,一次可能拆出 0 到 N 帧 foreach (var frame in FrameCodec.ExtractFrames(accumulator)) FrameReceived?.Invoke(frame); } } public async Task SendAsync(byte[] data, CancellationToken token) { await _writeLock.WaitAsync(token); // 多线程写会交叉,必须串行 try { await _stream!.WriteAsync(data, token); await _stream.FlushAsync(token); } finally { _writeLock.Release(); } } public void Dispose() { _cts?.Cancel(); _stream?.Dispose(); _client.Dispose(); _writeLock.Dispose(); } }这段代码有几个参数值得单独说明。NoDelay = true关掉 Nagle 算法,调试工具要的是发出去就能马上看到回包,几毫秒的合并延迟会让时间戳对不上。缓冲区用 4096 字节是个折中值,太小会让半包概率上升,太大对单帧几百字节的机器人协议属于浪费。accumulator是这层最关键的设计,它跨越多次ReadAsync保留未消费的字节,交给FrameCodec.ExtractFrames去判断能拆出整帧还是继续等。FrameReceived只往外抛原始字节,解析成什么命令、什么含义,全部留给上层,这样传输层不关心协议版本,改协议不用动它。
_writeLock是踩出来的。早期版本没有这个锁,心跳定时器和界面按钮同时写 stream,偶发出现半帧发出去、对方收到长度字段对不上直接丢包。加了SemaphoreSlim(1,1)之后这类玄学问题彻底消失。写锁的粒度也要注意,别把组帧逻辑放进锁里,锁只包住WriteAsync那两行。
2.3 界面绑定:把字节流变成不卡的日志集合
WPF 这边最容易翻车的地方是日志。每条报文来一次Dispatcher.Invoke往ObservableCollection里塞一条,看起来优雅,实际跑起来机器人每秒回几十帧,UI 线程直接被刷爆,界面卡到动不了。这套代码的处理方式是两级:接收线程先写进一个Channel<LogEntry>,界面侧用一个定时器按 100 毫秒批量取出,一次性追加。
public sealed class LogViewModel : ObservableObject { private readonly Channel<LogEntry> _queue = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(20000) { FullMode = BoundedChannelFullMode.DropOldest }); // 满了丢最旧的,不阻塞接收 public ObservableCollection<LogEntry> Entries { get; } = new(); public LogViewModel() { // 100ms 批量刷一次,UI 线程压力从 N 次降到每秒 10 次 var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; timer.Tick += (_, _) => Flush(); timer.Start(); } public void Enqueue(LogEntry entry) => _queue.Writer.TryWrite(entry); private void Flush() { var batch = new List<LogEntry>(256); while (batch.Count < 256 && _queue.Reader.TryRead(out var item)) batch.Add(item); if (batch.Count == 0) return; foreach (var item in batch) Entries.Add(item); // 已在 UI 线程 if (Entries.Count > 5000) // 上限滚动,防内存膨胀 for (int i = 0; i < Entries.Count - 5000; i++) Entries.RemoveAt(0); } }BoundedChannelOptions的容量 20000 和DropOldest是一组搭配,意思是调试时突然来一波爆发式回包,界面宁可丢最老的日志也不能卡住接收线程,否则后面的帧全堆在 socket 缓冲区里,看起来就像断线了。DispatcherTimer的 100 毫秒是体感阈值,超过 200 毫秒人会觉得界面反应迟钝,低于 50 毫秒批量的收益就不明显了。Entries保留 5000 条上限同理,一晚上挂着调试的日志能到几十万条,全留在集合里会拖垮虚拟化。
DataGrid 那边记得开EnableRowVirtualization="True",配合固定的行高,滚动才不会卡。日志行本身用Run拼时间戳、方向、十六进制正文三段,颜色靠方向区分,比整行染色好看,也更容易在截图里辨认。
3. 协议封包与帧解析:调试器真正的价值全在这
界面做得再花,帧解析不对就是一坨好看的废铁。这一层是三块里最值得反复读的,因为它决定你收到的字节到底是不是你以为的那一帧。手上这套代码的帧格式是自定的,换成你现场的协议,改的就是这一章的几个常量。
3.1 帧格式约定:六个字段和一个容易搞错的长度定义
先把字段摆出来。这份约定对应的是一类常见的机器人控制器协议,帧头两字节做同步,长度两字节小端,命令字一字节,序号两字节,负载不定长,最后两字节 CRC16。
| 字段 | 字节数 | 说明 | 常见错法 |
|---|---|---|---|
| SOF | 2 | 固定0xAA 0x55 | 用单字节帧头,误同步率上升 |
| Len | 2 | 小端,只算 Payload 长度 | 把整帧长度写进去,解析全错位 |
| Cmd | 1 | 命令字,见命令表 | 命令字和子命令混用一字节 |
| Seq | 2 | 请求应答配对用 | 不做配对,超时无法定位 |
| Payload | N | 变长负载 | 长度字段与实际不符 |
| CRC16 | 2 | 覆盖 Len 到 Payload | 覆盖范围从 SOF 开始,校验对不上 |
Len只计算 Payload 这一条是血泪经验。现场最常见的沟通事故就是一边算整帧、一边算负载,双方各自跑得好好的,连上就全乱。CRC16 的覆盖范围同理,从 SOF 开始算还是从 Len 开始算,必须写进文档由双方确认。这套代码用 MODBUS 风格的 CRC16,初值0xFFFF,多项式0xA001,低位在前。
public static class Checksum { // MODBUS 风格 CRC16,初值 0xFFFF,多项式 0xA001(反向表示) public static ushort Crc16(ReadOnlySpan<byte> data, int offset, int count) { ushort crc = 0xFFFF; for (int i = offset; i < offset + count; i++) { crc ^= data[i]; for (int bit = 0; bit < 8; bit++) crc = (crc & 0x0001) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1); } return crc; } }三个参数里offset和count是给「只校验一段」用的,调用方传Len字段的起始位置和到 Payload 结束的长度即可,不要图省事传整个数组,那样 CRC 会把帧头也算进去,和对方永远对不上。返回值是ushort,发送时要按小端拆成两个字节写回,这里最容易顺手写成大端。
3.2 拆帧:粘包、半包和那个越界的长度字段
ExtractFrames是整个项目最该看懂的函数。它的输入是累积缓冲,输出是本次能完整解析出的帧,同时把消费掉的部分从缓冲里移除,剩下的半帧留着等下一批数据。
public static IEnumerable<byte[]> ExtractFrames(List<byte> buffer) { const int HeaderLen = 2, LenFieldLen = 2, TailLen = 4; // SOF + Len + (Cmd,Seq) 已含在 Len 之外 while (true) { // 1. 找帧头,找不到就把垃圾数据全丢掉 int sof = FindHeader(buffer); if (sof < 0) { buffer.Clear(); yield break; } if (sof > 0) buffer.RemoveRange(0, sof); // 2. 长度字段都还没收齐,等下次 if (buffer.Count < HeaderLen + LenFieldLen) yield break; int payloadLen = buffer[2] | (buffer[3] << 8); // 小端 if (payloadLen > 4096) { buffer.RemoveRange(0, 2); continue; } // 明显是脏数据,跳过这个帧头 int total = HeaderLen + LenFieldLen + 1 + 2 + payloadLen + 2; // Cmd + Seq + Payload + CRC if (buffer.Count < total) yield break; // 半包,等更多数据 var frame = buffer.GetRange(0, total).ToArray(); buffer.RemoveRange(0, total); // 3. 校验,失败就跳过一帧,别让坏帧把后面的好帧堵死 ushort expect = (ushort)(frame[total - 2] | (frame[total - 1] << 8)); ushort actual = Checksum.Crc16(frame, 2, total - 4); if (expect != actual) continue; yield return frame; } }逐段说清楚。第一步找帧头,FindHeader扫0xAA 0x55,找不到就清空缓冲,因为再攒下去也没意义,这类数据通常是对端发错端口或者上次断线的残留。第二步读长度字段,payloadLen > 4096这个上限是保护,没有它,一个脏字节能让解析器等几百 KB 数据才肯往下走,表现为「连上了但一直没反应」。第三步算整帧长度,HeaderLen + LenFieldLen + 1 + 2 + payloadLen + 2这串数字别硬编码在业务代码里,抽成常量或者从帧头结构算,改协议时只改一处。
buffer.Count < total直接yield break是半包的处理方式,注意是退出整个迭代器而不是continue,因为后面的数据一定还不完整,继续循环没有意义。校验失败用continue而不是yield break,因为缓冲里可能还有下一帧好数据,丢掉一帧坏帧继续解析,比整条链路堵死要好。GetRange加RemoveRange这两个操作在大缓冲上有 O(n) 开销,调试工具的流量规模完全够用,真要做高频采集再换成环形缓冲。
提示:
FindHeader要处理帧头跨边界的情况,比如缓冲末尾是0xAA,下一批第一个字节才是0x55。简单做法是让FindHeader只扫到Count - 1,剩下那个字节留着下次再判断。
3.3 组帧与十六进制工具:手拼一帧的完整过程
调试工具的另一半价值是手动发帧。界面上一排输入框填命令字、序号、负载十六进制串,点发送的时候组帧,然后走SendAsync。
public static byte[] BuildFrame(byte cmd, ushort seq, ReadOnlySpan<byte> payload) { var frame = new byte[9 + payload.Length]; // 2 SOF + 2 Len + 1 Cmd + 2 Seq + N + 2 CRC frame[0] = 0xAA; frame[1] = 0x55; frame[2] = (byte)(payload.Length & 0xFF); // 长度小端,只算负载 frame[3] = (byte)(payload.Length >> 8); frame[4] = cmd; frame[5] = (byte)(seq & 0xFF); frame[6] = (byte)(seq >> 8); payload.CopyTo(frame.AsSpan(7)); ushort crc = Checksum.Crc16(frame, 2, 5 + payload.Length); // 从 Len 覆盖到 Payload 末尾 frame[^2] = (byte)(crc & 0xFF); frame[^1] = (byte)(crc >> 8); return frame; }BuildFrame和ExtractFrames必须成对维护,改了一个另一个跟着改,否则调试工具自己发出去的帧自己都解析不了,那才是真尴尬。负载输入框要允许AA 55 01这种带空格的写法,也允许AA5501连写,解析前统一去掉空格和0x前缀,遇到非十六进制字符直接标红提示,不要静默忽略。
ByteFormatter负责把byte[]转成带空格的十六进制字符串加 ASCII 预览,长帧按 16 字节一行折行。ASCII 那一栏别用Encoding.ASCII,中文负载会变成问号,用Encoding.UTF8并把不可打印字符替换成.,查看效果更像常规的抓包工具。
4. 连接管理与保活:让调试器能挂一整天不掉
协议对了,接下来是稳定性。现场调试经常是挂一晚上等某个偶发问题,如果重连要手点、心跳要手动发,那这个工具根本没法用。这一章讲连接状态机、心跳和日志落盘。
4.1 连接状态与重连:退避不能是固定间隔
状态机很简单:Disconnected → Connecting → Connected → Disconnecting → Disconnected,异常路径统一回到Disconnected。麻烦的是重连节奏,早期版本用固定 1 秒重试,结果对方控制器重启要十几秒,我们这边一秒一次的连接请求把对端日志刷满,反过来影响它启动。改成指数退避加随机抖动。
public sealed class ReconnectPolicy { private readonly int _baseDelayMs; // 起始延迟,一般 500 private readonly int _maxDelayMs; // 上限,一般 10000 private int _attempt; public ReconnectPolicy(int baseDelayMs = 500, int maxDelayMs = 10_000) => (_baseDelayMs, _maxDelayMs) = (baseDelayMs, maxDelayMs); public TimeSpan NextDelay() { _attempt = Math.Min(_attempt + 1, 10); double raw = _baseDelayMs * Math.Pow(2, _attempt - 1); double capped = Math.Min(raw, _maxDelayMs); // 抖动 ±20%,避免多个客户端同时重连打到一个控制器上 double jitter = capped * (Random.Shared.NextDouble() * 0.4 - 0.2); return TimeSpan.FromMilliseconds(capped + jitter); } public void Reset() => _attempt = 0; // 连上之后必须清零,否则下次断线直接等 10 秒 }_attempt上限截到 10 是防止Math.Pow溢出成无穷大。抖动比例 20% 是个经验值,太小起不到错峰作用,太大重连时间不可预测。Reset()这一行是踩坑改出来的:原来连上之后没清零,第一次断线等 0.5 秒,第二次等 1 秒,跑到晚上重连一次要等十几秒,现场以为程序死了。
重连触发点要放在读循环退出和写异常两处,统一走一个HandleDisconnectAsync,里面先Dispose旧的TcpClient再新建。不要复用同一个TcpClient对象重连,它内部状态已经乱了,看着连上了实际写不出去,这类问题最难查。
4.2 心跳与超时:两秒一次,三次没回就判死
心跳是两条独立逻辑:定时发和定时查。发送用PeriodicTimer,间隔 2 秒,发一帧 Cmd 为0x01的短帧,负载带当前时间戳。检查侧维护一个「最后收到任意帧」的时间,超过 3 个心跳周期没有任何数据就判定链路已死,主动断开触发重连。
public sealed class HeartbeatMonitor : IDisposable { private readonly ITcpChannel _channel; private readonly TimeSpan _interval; private readonly int _missLimit; private DateTime _lastReceived = DateTime.UtcNow; private int _missCount; public HeartbeatMonitor(ITcpChannel channel, TimeSpan interval, int missLimit = 3) => (_channel, _interval, _missLimit) = (channel, interval, missLimit); public void NotifyReceived() { _lastReceived = DateTime.UtcNow; _missCount = 0; } public async Task RunAsync(CancellationToken token) { using var timer = new PeriodicTimer(_interval); ushort seq = 0; while (await timer.WaitForNextTickAsync(token)) { if (DateTime.UtcNow - _lastReceived > _interval) { if (++_missCount >= _missLimit) { OnDead?.Invoke(); return; } } var frame = FrameCodec.BuildFrame(0x01, ++seq, ReadOnlySpan<byte>.Empty); await _channel.SendAsync(frame, token); } } public event Action? OnDead; public void Dispose() { } }判死的依据是「最后收到任意帧的时间」而不是「心跳回包时间」,因为机器人正常工作时本来就在持续上报状态帧,收到任何数据都说明链路是活的,没必要非等心跳应答。_missLimit = 3配合 2 秒间隔,最坏 6 秒判定断线,对调试场景足够快,也不会因为对端忙一下误杀。心跳帧的负载留空,有些控制器会校验心跳负载格式,这条按你现场的协议文档来。
界面上把最后收发时间、已收帧数、错误帧数、当前 RTT 做成一行状态栏,一晚上挂下来扫一眼就知道有没有问题。
4.3 日志落盘:让偶发问题留下证据
偶发问题最怕没留证据。这套代码的FileLogger按天切文件,每行格式是时间戳|方向|十六进制|解析结果,写入用带缓冲的StreamWriter加定时Flush,程序退出时强制刷盘。
public sealed class FileLogger : IDisposable { private readonly StreamWriter _writer; private readonly object _lock = new(); public FileLogger(string dir) { Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"comm-{DateTime.Now:yyyyMMdd}.log"); _writer = new StreamWriter(path, append: true) { AutoFlush = false }; } public void Write(string direction, byte[] data, string? parsed = null) { var line = $"{DateTime.Now:HH:mm:ss.fff}|{direction}|{ByteFormatter.ToHex(data)}|{parsed}"; lock (_lock) _writer.WriteLine(line); // 多线程写要锁,否则行会交错 } public void Dispose() { lock (_lock) { _writer.Flush(); _writer.Dispose(); } } }AutoFlush = false是为了性能,每条报文都刷盘在高频场景下 disk IO 会成为瓶颈,改成每 500 毫秒定时 Flush 一次。lock不能省,接收线程和发送线程都会调Write,没有锁的话日志行会互相穿插,出问题时根本读不了。文件名带日期,方便按天翻查,也避免单文件涨到几个 G 打不开。
十六进制转字符串时每行别超过 64 字节的显示宽度,否则用记事本打开是一整条横线,找某个字节要横向拖半天。
5. 常见问题排查:这五个坑我基本每次都遇到
这套东西逻辑不复杂,真正耗时间的是各种「看起来连上了其实没有」的状态。下面五条基本覆盖了现场八成的问题,现象、原因、解决都能对上号。
5.1 界面卡死、日志刷不出来
现象:连上之后机器人一直回包,界面完全没反应,点按钮也不响应,任务管理器显示进程占用一个核。 原因:每条报文都在接收线程里Dispatcher.Invoke往集合里加,UI 线程被消息泵塞满,渲染帧根本排不上。 解决:改成Channel+ 定时批量刷,参考 2.3 节的写法。同时给 DataGrid 开虚拟化,日志集合设上限。改完之后界面在每秒几百帧的情况下依然能流畅滚动。
5.2 发出去对方没反应,抓包看字节又是对的
现象:自己的日志显示帧已发出,抓包工具看到字节流也对,机器人侧就是不应答。 原因:CRC 覆盖范围或大小端与对方不一致。特别是 CRC 从SOF开始算还是从Len开始算,两边各写各的,字节看起来都「像」,校验就是过不去。 解决:拿docs/frame-spec.md逐字段对,把 CRC 的起始偏移、长度、结果高低位顺序写死成常量,两边各跑一个单元测试用同一组固定字节验证。这一条我对过三次,每次都是对方文档和实现不一致。
5.3 偶发丢帧、后续帧全部错位
现象:通信跑一阵子突然没数据,重启工具就好,跑久了又出。 原因:把「一次ReadAsync的返回」当成「一帧」,没有处理半包和粘包。TCP 是字节流,一次读到的可能是半帧,也可能是两帧半。 解决:累积缓冲 +ExtractFrames循环拆帧,接收层只负责攒字节,解析逻辑与读取次数完全解耦。写完用00 01 02这种人为切割的测试数据喂进去验证。
5.4 断线重连失败,报端口已被占用
现象:断开后一直连不上,日志里是AddressAlreadyInUse或者连接超时,重启程序才恢复。 原因:旧的TcpClient和NetworkStream没有Dispose,底层 socket 句柄泄漏;或者复用了已经进入异常状态的TcpClient对象重连。 解决:断线路径统一走Dispose再新建对象,ReadLoopAsync里的异常分支也要记得清理。异步方法里加using或者try/finally,别让异常跳过释放。
5.5 十六进制显示正常但中文负载成乱码
现象:ASCII 预览栏里字母数字都对,带中文的负载显示成问号或者方块。 原因:用了Encoding.ASCII解码,非 ASCII 字节全部替换为?;或者对端用 GBK 编码,这边按 UTF-8 解。 解决:预览用Encoding.UTF8并保留原始十六进制,遇到不可打印字符替换为.。真正需要解码文本时,在界面上给一个编码下拉框,UTF-8 和 GBK 都留着,别在代码里写死。
注意:这五条里第 2 条和第 3 条最容易互相误判。区分方法是看错误帧计数,如果错误帧在涨说明收到了字节但校验不过,问题在协议;如果收帧计数完全不动,问题在拆帧或连接本身。
6. 二次开发:把调试器改成你自己的协议工作站
这套代码的价值不在它现在能干什么,在于它把「连、拆、显、存」四件事拆成了四个独立可替换的部分。真要改造成自己现场的专用工具,按下面的顺序动,基本不用碰界面。
| 改动目标 | 主要文件 | 改法 |
|---|---|---|
| 换帧格式 | FrameCodec.cs、frame-spec.md | 改BuildFrame与ExtractFrames,补单元测试 |
| 换校验算法 | Checksum.cs | 新增方法并切换调用点,保留旧算法做兼容 |
| 加命令 | CommandTable.cs | 登记命令字与名称,界面下拉自动带出 |
| 换传输方式 | ITcpChannel.cs | 实现新类(串口、UDP),上层不用改 |
| 加自动化脚本 | 新建Scripting项目 | 按seq配对请求与应答,做定时轮询 |
最实用的一条是给调试器加「命令剧本」功能。调试某一类动作时,往往是固定顺序发三五条指令、每条等应答再发下一条。手点太慢,也容易漏。加一个简单的脚本执行器,把命令序列写在 JSON 里,靠Seq字段配对请求和应答。
public async Task RunScriptAsync(IReadOnlyList<ScriptStep> steps, CancellationToken token) { foreach (var step in steps) { var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously); void Handler(byte[] frame) // 订阅一次,配对到就撤 { if (FrameCodec.ReadSeq(frame) == step.Seq) tcs.TrySetResult(frame); } _channel.FrameReceived += Handler; try { await _channel.SendAsync(FrameCodec.BuildFrame(step.Cmd, step.Seq, step.Payload), token); var resp = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(step.TimeoutSec), token); Log($"step {step.Cmd} ok, {resp.Length} bytes"); } catch (TimeoutException) { Log($"step {step.Cmd} timeout"); return; } // 超时就停,别继续发 finally { _channel.FrameReceived -= Handler; } // 必须退订,否则越挂越多 } }这里TaskCreationOptions.RunContinuationsAsynchronously别省,不加的话TrySetResult会在接收线程上同步执行后续 await 的续体,容易把接收循环卡住。finally里退订是必须的,早期版本忘了退订,跑一晚上之后每次收包都要遍历上千个残留委托,CPU 直接飙上去——这个坑我盯了半天才反应过来。超时后return而不是continue,因为前一条没应答,后一条多半也会失败,继续发只会让日志更乱。
改造的顺序建议是先换Checksum,再换FrameCodec,最后动界面。每改一处就用tests里那组固定字节跑一遍,确保拆出来的帧和手算的一致。我把这套流程固定成习惯:只要碰了协议层,先跑测试再连真机,宁可多花十分钟,也别在现场对着屏幕猜是哪边的问题。从那以后我每次开新项目,都会先把帧格式文档和那五个拆帧测试用例抄过去,后面省下的时间远超过这点投入。希望帮到你。
本文还有配套的精品资源,点击获取