☰
用C#从零实现TCP/UDP调试工具:核心代码与设计思路
2026/10/14 7:36:35 网站建设 项目流程

简介:面向C#网络编程学习者提供的Socket测试工具及完整源代码,覆盖TCP与UDP两种传输协议,适用于需要快速验证网络通信、学习System.Net.Sockets编程的开发者。压缩包共1135个文件,体积10.52MB,其中142个cs源码文件(含ClientForm.cs、ServerForm.cs等窗体和逻辑代码)、15个exe可执行程序、37个dll依赖库,以及大量png/gif/ico图标资源和界面预览图,还有SocketTest.bat启动脚本,帮助直接运行或二次开发。已有1109人学习下载。工具和源码同时打包,既能直接启动SocketTool进行TCP/UDP收发测试,也可通过阅读代码掌握Socket实例创建、IP与端口绑定、连接监听、Send/Receive数据交换等核心操作,并延伸到错误处理、并发连接管理等高级话题;有助于深入理解TCP可靠传输与UDP实时传输在实际场景中的差异。 做嵌入式上位机这些年,我电脑里换过的TCP/UDP测试工具少说也有六七个。像常见的网络调试助手确实能解决八成问题:连接设备、发报文、看返回,都很顺手。但真正到了现场联调,剩下的两成需求往往最磨人——要模拟设备主动上报、要给对端下发固件文件、要观察断线瞬间的重连表现、要把一整天的收发日志完整导出,这些需求要么没有,要么收费,要么源码闭源改不动。后来我花了一个周末,用C#把一套同时具备TCP客户端、TCP服务端、UDP收发、文件下发、Hex/ASCII切换的测试工具重新撸了一遍,核心Socket代码加起来也就几百行,却把调试效率拉高了一个档次。下面把这套工具的设计思路和关键源代码拆开讲清楚,适合C#上位机开发、PLC/单片机联调、物联网设备测试以及服务端自测的朋友参考。

1. 为什么放着现成的网络调试助手不用,偏要自己写

1.1 现成工具的短板:TCP Server、日志、数据格式都不顺手

先说一个非常具体的场景。很多网络调试助手的强项是TCP Client,弱项恰恰是TCP Server。而我调试Modbus TCP从站、模拟自定义协议设备的时候,最需要的正是一个能稳定监听、能同时管理多个客户端、能对指定客户端单独下发数据的服务端。单这一条,就把不少小工具挡在门外了。

第二个让人头疼的问题是日志。现场定位问题靠什么?靠完整的时间戳收发记录。有的工具日志只能在内置窗口里看,关掉就没有了;有的导出的日志格式混乱,连收发方向都分不清。我自己遇到过设备偶发断链,最后是靠日志里一个凌晨3点的异常断开记录,才定位到是设备侧的看门狗复位,这种问题如果没有完整日志,根本无从查起。

第三个问题是数据格式。ASCII、Hex、UTF-8、Base64之间来回切,中文显示乱码,回车换行被工具吞掉……这些细枝末节平时不起眼,一旦卡住就是整块时间被耗掉。与其每次换一个工具,不如把主动权拿回来。

1.2 自研后的定位:调试、模拟、日志回溯三合一

所以我自己写这套工具时,没有奔着“大而全”去,而是把定位锁定在三个方向:调试、模拟、日志回溯。调试指的是常规TCP/UDP收发、定时发送、循环发送;模拟指的是TCP Server模式下模拟设备端、自动回复协议帧;日志回溯指的是所有收发都带毫秒时间戳,既能落盘也能在界面直接搜索。

技术选型上,C# WinForm是最合适的。System.Net.Sockets是.NET自带的命名空间,不需要引入任何第三方组件;界面开发快,委托事件处理UI刷新很自然;而且跟我日常写的上位机程序语言栈一致,可以把协议解析代码直接复用。工具的核心模块其实就三块:TCP、UDP、数据格式转换,剩余的功能都是在这三块上做扩展。

2. 功能清单与界面布局:动手写代码前先把边界划清楚

2.1 模块拆解:三个核心加三个扩展

我习惯在写代码前先把功能范围敲定,不然很容易写着写着就失控。这套工具最终的功能模块如下:

模块功能说明优先级
协议与模式TCP Client、TCP Server、UDP三种模式切换核心
收发格式ASCII、Hex、UTF-8切换核心
连接管理断线自动重连、客户端列表、踢出客户端核心
发送能力单次发送、定时发送、循环发送、自动回复扩展
文件传输分块下发文件,带进度显示扩展
日志回溯毫秒时间戳、收发方向标识、落盘CSV扩展

优先级划分很重要。TCP/UDP收发是骨架,先写稳;自动回复和文件传输可以后面再补,不影响主流程跑通。我见过不少人一上来就想做个“巨无霸”,结果基础收发还没稳定,就已经开始堆功能,后面出了问题很难排查。

2.2 界面分区与数据流向

界面布局我建议按“左连接、右收发、下日志”的思路来分:左上角是协议模式选择,左中区域是连接参数(本地IP/端口、远端IP/端口、连接/断开按钮),左下角是TCP Server模式下的客户端列表;右上方是发送区,包含报文输入框、Hex/ASCII切换、发送按钮和定时发送参数;右下方是接收区,用Tab页区分“实时数据”“文件传输”“历史日志”;最底部状态栏实时显示连接状态和收发字节数。

整个工具的数据流向是单向的:网络层收到数据后封装成事件参数,通过BeginInvoke抛给UI线程刷新界面;UI线程用户点击发送时,把输入内容按当前格式转成byte[],再调网络层发送。界面主线程只负责显示,网络收发全部走异步方法,这是我一开始就定死的规则,后面加功能会省心很多。

3. TCP核心源码:连接、断线重连与粘包处理

3.1 客户端连接的三个关键细节

TCP Client模式的连接代码看着简单,实际坑不少。先看核心连接方法:

private TcpClient _tcpClient; private CancellationTokenSource _cts; public async Task ConnectAsync(string host, int port) { _tcpClient?.Dispose(); _cts?.Cancel(); _cts = new CancellationTokenSource(); var client = new TcpClient(); var connectTask = client.ConnectAsync(host, port); var timeoutTask = Task.Delay(3000); if (await Task.WhenAny(connectTask, timeoutTask) != connectTask) { client.Dispose(); throw new TimeoutException("连接超时"); } await connectTask; _tcpClient = client; _ = ReceiveLoopAsync(_cts.Token); }

第一个细节:不要复用同一个TcpClient实例去重连。Dispose之后这个对象基本等于废了,老老实实每次new一个。第二个细节:TcpClient.ConnectAsync本身没有超时参数,用Task.WhenAny配合Task.Delay做3秒超时,是最简洁的做法,用户填了不可达IP时不会卡住界面。第三个细节:如果用户填的是域名而不是IP,需要先用Dns.GetHostAddresses解析,再取第一个IPv4地址,很多工具在这上面翻过车。

3.2 接收循环:ReadAsync返回0意味着对面关了

接收循环是整个工具的核心,绝大多数初学者都在这里出问题。正确写法是这样的:

private async Task ReceiveLoopAsync(CancellationToken token) { byte[] buffer = new byte[4096]; var stream = _tcpClient.GetStream(); while (!token.IsCancellationRequested) { int n; try { n = await stream.ReadAsync(buffer, 0, buffer.Length, token); } catch (OperationCanceledException) { break; } if (n == 0) { OnDisconnected("对端关闭连接"); break; } byte[] data = new byte[n]; Array.Copy(buffer, 0, data, 0, n); OnDataReceived(data); } }

一定不要忽略n == 0这个分支。TCP是流协议,对端正常关闭连接时,ReadAsync并不会抛异常,而是返回0。如果漏掉这个判断,代码会陷入死循环,疯狂占用CPU。很多上位机程序出现“断开后程序假死”,基本就是这个原因。

3.3 服务端监听:TcpListener加多客户端会话管理

TCP Server模式的核心是AcceptTcpClientAsync循环,每接受一个客户端就开启一个独立接收任务,客户端对象统一放到ConcurrentDictionary里管理:

private TcpListener _listener; private ConcurrentDictionary<string, TcpClient> _clients = new(); private CancellationTokenSource _cts = new(); public async Task StartServerAsync(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); RaiseLog($"监听已启动: {port}"); while (!_cts.IsCancellationRequested) { TcpClient client; try { client = await _listener.AcceptTcpClientAsync(); } catch (ObjectDisposedException) { break; } string clientId = Guid.NewGuid().ToString("N"); _clients[clientId] = client; RaiseLog($"客户端接入: {client.Client.RemoteEndPoint}"); _ = HandleClientAsync(clientId, client, _cts.Token); } }

为什么用ConcurrentDictionary而不是普通Dictionary?因为UI线程要定时遍历客户端列表刷新界面,而接收线程也在往集合里添加和移除客户端,用普通字典必然出现线程冲突。这里用ConcurrentDictionary是最省心的选择,不需要自己加锁。

3.4 粘包与半包:按“4字节长度前缀”处理

TCP没有消息边界,这是每个写Socket程序的人都绕不开的点。新手经常发现“一次发送两条数据,对方一次收齐了”,然后怀疑是代码问题,其实这是TCP的正常行为。要解决粘包和半包,最通用的手段是自定义应用层帧格式,我用的是“4字节小端长度 + 报文载荷”:

private async Task<byte[]> ReadExactlyAsync(NetworkStream stream, int count) { byte[] buf = new byte[count]; int offset = 0; while (offset < count) { int n = await stream.ReadAsync(buf, offset, count - offset); if (n == 0) throw new EndOfStreamException("连接关闭"); offset += n; } return buf; } // 读取一条完整消息 byte[] lenBuf = await ReadExactlyAsync(stream, 4); int len = BitConverter.ToInt32(lenBuf, 0); byte[] payload = await ReadExactlyAsync(stream, len);

这个读取方式天然兼容粘包和半包:如果一包数据里含两条消息,第一次ReadExactly会读完第一条,剩下字节留在Socket缓冲区,下一次调用再读第二条;如果一条消息被拆成多个TCP段,ReadExactly内部的循环会一直读,凑够长度才返回。注意读长度字段时也必须用ReadExactlyAsync,因为4字节本身也可能被拆开。

4. UDP收发源码:UdpClient之外,还差这几个关键步骤

4.1 收发循环与取消

UDP没有连接状态,收发路径比TCP简单得多。使用UdpClient时,一个ReceiveAsync循环负责收,一个Send方法负责发:

private UdpClient _udpClient; private CancellationTokenSource _cts = new(); public void StartUdp(int localPort) { _udpClient = new UdpClient(localPort); _udpClient.Client.ReceiveBufferSize = 1024 * 1024; _ = ReceiveUdpLoopAsync(_cts.Token); } private async Task ReceiveUdpLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { UdpReceiveResult result; try { result = await _udpClient.ReceiveAsync(); } catch (ObjectDisposedException) { break; } string from = $"{result.RemoteEndPoint}"; OnUdpDataReceived(from, result.Buffer); } }

要关注的是UDP模式通常需要绑定一个本地端口,否则系统随机分配端口,对端回包时可能收不到。发送时要注意目标地址是IPEndPoint,如果填的是域名,同样需要先解析成IP。

4.2 UDP调试最容易被忽略的三个坑

第一个坑:本机“假丢包”。发送太猛的时候,UDP接收缓冲区会被塞满,后续数据直接丢弃。调大ReceiveBufferSize能缓解,但治标不治本,应用层需要自己做确认和重传。

第二个坑:单包大小。以太网MTU是1500字节,去掉IP头20字节和UDP头8字节,应用层单包建议不要超过1472字节。超过之后IP层会分片,很多嵌入式设备收到分片包直接丢弃,表现就是“数据发了但对方没反应”。

第三个坑:TCP和UDP监听同一个端口号并不会冲突。很多人听到端口占用就条件反射觉得是两个协议冲突,其实TCP 8080和UDP 8080可以同时存在互不影响。你完全可以在同一个工具里同时启动TCP Server监听9000端口和UDP监听9000端口。如果看到绑定失败报错,要么是同一协议端口被占,要么是上次的进程没退干净。

5. 进阶功能:文件下发、Hex解析与自动回复

5.1 文件分块下发:别一口气吞整个文件

给设备下发固件或配置文件时,最容易犯的错是File.ReadAllBytes一把梭。一个几十MB的文件全部读进内存,不仅浪费,UI还会卡顿。正确做法是分块读、分块发:

private const int ChunkSize = 8192; private async Task SendFileAsync(string filePath, NetworkStream stream) { using var fs = File.OpenRead(filePath); byte[] buffer = new byte[ChunkSize]; long total = fs.Length; long sent = 0; while (true) { int read = fs.Read(buffer, 0, buffer.Length); if (read == 0) break; byte[] chunk = new byte[read]; Array.Copy(buffer, chunk, read); await stream.WriteAsync(chunk, 0, chunk.Length); await stream.FlushAsync(); sent += read; UpdateProgress((double)sent / total); } }

如果对端程序支持ACK确认,建议每发一块等ACK再发下一块,超时重传当前块。没有ACK的情况下,纯UDP发文件要非常谨慎,因为UDP本身不保证送达,应用层必须自己处理丢包和乱序,否则文件传输毫无可靠性可言。

5.2 Hex字符串解析:先预处理,再逐字节转换

Hex模式是调试工具的标配,但实现时坑很多。用户在输入框里可能写的是“01 03 00 00 00 02”,也可能带“0x”前缀,甚至用逗号分隔。解析前要先统一预处理:

public static byte[] HexStringToBytes(string hex) { hex = hex.Replace("0x", "") .Replace("0X", "") .Replace(" ", "") .Replace(",", "") .Replace("-", ""); if (hex.Length % 2 != 0) throw new FormatException("十六进制字符串长度必须为偶数"); byte[] bytes = new byte[hex.Length / 2]; for (int i = 0; i < bytes.Length; i++) { bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); } return bytes; }

显示方向反过来,用BitConverter.ToString(data).Replace("-", " ")转成大写Hex带空格格式,一眼就能看清报文结构。注意不要直接按字符去转,否则“0x”前缀和空格都会变成解析炸弹。

5.3 自动回复:让工具替你做从站模拟

自动回复是我在实际联调中用的最多的功能。调试Modbus从站时,主站一直在请求,工具作为从站要一直响应。实现逻辑不复杂,收到数据后先查自动回复匹配表,匹配到了就调用发送方法:

private void OnDataReceived(byte[] data) { // 先走自动回复逻辑 if (_autoReplyEnabled) { foreach (var rule in _replyRules) { if (rule.IsMatch(data)) { var reply = rule.BuildResponse(data); Send(reply); break; } } } // 再更新UI和日志 AppendReceive(data); }

匹配规则我做了两种:完全匹配和包含匹配。Modbus TCP的报文头带事务ID,每次都变,完全匹配很难用,包含匹配更实用。回复内容里我还支持占位符,比如{seq}表示自动递增序号,这样模拟设备心跳包非常方便。

6. 发布与踩坑复盘:端口占用、UI卡死与安装包

6.1 端口被占用:netstat查杀两分钟搞定

开发调试期间遇到最多的异常就是绑定失败,常见报错类似“bind: only one usage of each socket address”。原因很简单,上一个进程没退干净,或者端口被别的程序占用了。排查只需要两步:

netstat -ano | findstr 8080 taskkill /F /PID 进程号

第一行看8080端口的占用PID,第二行强制结束进程。在代码里也要处理这种情况,捕获SocketException后判断错误码,如果是10048(Windows的WSAEADDRINUSE),直接弹窗提示“端口被占用”,而不是把英文异常甩给用户。

6.2 UI卡死:接收区刷新必须走定时器

高频数据下最典型的翻车现场是:收到一包数据就Invoke一次更新TextBox。设备每秒上报几千条数据时,UI线程根本忙不过来,整个窗口直接卡死。我的解决办法是设置一个100ms的定时器,接收线程只把数据追加到StringBuilder里,定时器每100ms把缓冲区内容一次性刷到TextBox:

private StringBuilder _receiveBuffer = new(); private void Timer_Tick(object sender, EventArgs e) { if (_receiveBuffer.Length == 0) return; txtReceive.AppendText(_receiveBuffer.ToString()); _receiveBuffer.Clear(); }

界面显示偶尔合并几条数据没关系,完整的收发记录都在日志文件里,定位问题时以日志为准。这叫“用时间换手感”,比硬扛高频刷新靠谱得多。

6.3 安装包制作:Setup Project与Inno Setup两种路线

WinForm工具做完,总要给同事或现场人员分发。最简单的是用Visual Studio自带的Setup Project扩展,右键解决方案添加新建项目,选安装向导,配置好主输出和图标,生成msi安装包,适合公司内部快速分发。缺点是每次更新都要重新改配置,自动化程度低。

我更推荐Inno Setup,用脚本控制打包流程,灵活性高,卸载清理也做得干净:

[Setup] AppName=NetworkDebugTool AppVersion=1.0.0 DefaultDirName={pf}\NetworkDebugTool OutputBaseFilename=NetworkDebugTool_Setup ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible [Files] Source: "publish\*"; DestDir: "{app}"; Flags: recursesubdirs

打包时注意目标机器有没有.NET运行时。如果项目是.NET 6/8,要么在发布时选择“自包含”,把运行时一起打进去;要么让用户先安装桌面运行时。每次打包后先在干净虚拟机里装一遍验证,再发给现场,这是我一直保留的习惯。

最后说一个真实体会:调试工具真正值钱的不是界面多花哨,而是日志能不能回溯、异常能不能复现、断线能不能感知。我做完这套C# Socket测试工具之后,很多现场问题根本不需要反复找设备厂商,直接把完整收发日志按时间戳拉出来,谁先发的、谁断的、协议哪里对不上,一目了然。如果你也想写一个TCP/UDP调试工具,先把TCP客户端、TCP服务端、UDP收发这三个基础模块写稳,再慢慢加功能;时间紧的话,把这套思路和源码直接拿过去改,把协议格式换成你手头的设备报文,很快就能上手。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询