简介:这份资源是面向C#初学者与WinForm开发者的TCP通信入门示例,包含服务端FrmTcpServer与客户端FrmTcpClient两套完整源码,帮助理解基于TcpListener、TcpClient与NetworkStream的面向连接通信流程,适合作为网络编程练手或课程设计的基础模板。压缩包共61个文件,以cs源码、csproj与sln工程文件、config配置、resx与resources资源、exe与pdb编译产物为主,整体约120KB,结构清晰,可直接用Visual Studio打开运行。目前已有2090人学习下载,热度稳定。读者可从中掌握服务端监听、AcceptTcpClient阻塞接收、客户端Connect连接、StreamReader与StreamWriter读写文本数据以及连接关闭等关键环节,并在此基础上扩展文件传输、在线聊天等客户端-服务器应用,是一份便于对照调试与二次开发的实用参考代码。
1. 从 FrmTcpServer 与 TcpClient 说起:一个压缩包背后藏着的 TCP 通信最小闭环
很多人第一次看到FrmTcpServer TcpClient.rar这个文件名,第一反应是「这不就是个 WinForm 的 TCP 调试工具源码包吗」。我一开始也这么想,直到有次帮朋友排查一个产线设备通信问题,他丢过来一个几乎同名的压缩包,说「服务端能收到数据但客户端一直卡在连接状态」。拆开一看,里面就两个窗体:一个FrmTcpServer,一个TcpClient,代码不到三百行,却把 TCP 通信里最容易被忽略的几个点全踩了一遍。这个标题真正指向的,不是某个具体项目,而是一类非常典型的落地场景:用 WinForm 快速搭一个 TCP 服务端和客户端,做设备联调、协议验证或者内网数据转发。适合谁?适合需要在一两天内把「能收能发」跑通的一线工程师,也适合刚接触 Socket 编程、想找一个能直接改的骨架的人。它解决的不是高并发,而是「先让链路通起来」这个最朴素的需求。
2. 拆开压缩包之前:FrmTcpServer 和 TcpClient 各自该扛什么职责
2.1 为什么服务端窗体叫 FrmTcpServer 而不是 TcpServer
命名本身就是一个信号。Frm前缀说明这是一个 WinForm 窗体类,意味着服务端的生命周期和 UI 线程绑在一起。常见做法是:窗体加载时启动监听,窗体关闭时释放监听端口和所有客户端连接。这种设计在调试阶段非常顺手,因为你可以直接在界面上看到「已启动监听」「客户端已连接」「收到 12 字节」这些状态。但它的边界也很明显:UI 线程一旦被阻塞,整个服务端就假死了。所以FrmTcpServer里真正干活的AcceptTcpClient和Receive必须放到后台线程或者用异步方法,窗体只负责显示和按钮事件转发。
我一般会把服务端拆成三层:窗体层只做界面刷新和参数读取;服务层持有TcpListener和客户端集合;会话层每个客户端一个对象,负责收发包和心跳。压缩包里如果只有一个窗体文件,那大概率是把三层揉在一起了,能跑,但加第二个客户端时就会乱。
2.2 TcpClient 窗体到底该不该管重连
TcpClient这个类名在 .NET 里是 System.Net.Sockets 自带的,但这里作为窗体名出现,说明它承担的是「客户端交互界面」的角色。很多示例代码里,客户端窗体只做两件事:连服务器、发字符串。但实际联调时,最耗时间的恰恰是断线重连和粘包处理。如果这个压缩包里的TcpClient没有重连逻辑,那它只适合一次性测试;如果有,就要看它是用定时器轮询还是用Socket.Connected属性判断——后者在 TCP 里其实不可靠,因为连接断开后Connected可能仍然返回 true,直到下一次读写才会抛异常。
提示:判断 TCP 连接是否可用,不要依赖
Connected属性,要用「发送心跳包 + 读超时」的方式。
2.3 一个最小可用的通信闭环需要哪些参数
在动手改代码之前,先把下面这张表里的参数确认一遍。这些参数在FrmTcpServer和TcpClient里必须一致,否则连不上或者收到乱码。
| 参数项 | 服务端典型值 | 客户端典型值 | 说明 |
|---|---|---|---|
| IP 地址 | 0.0.0.0 或本机内网 IP | 服务端实际 IP | 服务端监听 0.0.0.0 表示所有网卡 |
| 端口 | 8888 / 9000 等 | 同服务端 | 避开 1024 以下和已占用端口 |
| 编码方式 | UTF-8 或 GBK | 同服务端 | 中文环境常用 GBK,跨平台用 UTF-8 |
| 接收缓冲区 | 1024 或 4096 字节 | 同服务端 | 太小会频繁分包,太大浪费内存 |
| 超时时间 | 不设或 30 秒 | 5 到 10 秒 | 客户端超时短一点,避免界面卡死 |
这张表里的值不是固定的,但必须成对出现。我见过太多「服务端发中文,客户端显示问号」的问题,最后查出来就是一边 UTF-8 一边 GBK。
3. 用 FrmTcpServer 在本地跑通监听:从按钮事件到 AcceptTcpClient 的完整链路
3.1 服务端启动监听的代码骨架与参数含义
下面这段代码是FrmTcpServer里「启动监听」按钮背后的典型实现。它用异步方式接受客户端,避免阻塞 UI 线程。
private TcpListener _listener; private CancellationTokenSource _cts; private async void btnStart_Click(object sender, EventArgs e) { // 从界面读取端口,默认 8888 int port = int.Parse(txtPort.Text.Trim()); _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); // 开始监听,此时端口被占用 _cts = new CancellationTokenSource(); AppendLog($"服务端已启动,监听端口 {port}"); // 循环接受客户端,直到取消 while (!_cts.IsCancellationRequested) { try { TcpClient client = await _listener.AcceptTcpClientAsync(); AppendLog($"客户端已连接:{client.Client.RemoteEndPoint}"); _ = HandleClientAsync(client, _cts.Token); // 每个客户端独立处理 } catch (ObjectDisposedException) { break; // 监听器已关闭,正常退出 } } }逻辑说明:IPAddress.Any表示监听本机所有网卡,如果只想本机测试可以改成IPAddress.Loopback。AcceptTcpClientAsync返回的TcpClient对象代表一个已建立的连接,后续收发都基于它。_ = HandleClientAsync(...)前面的下划线表示不等待这个任务,让它在后台跑,这样主循环可以继续接受下一个客户端。
参数说明:port来自界面输入框,实际部署时建议做成配置文件。_cts是取消令牌,窗体关闭时调用_cts.Cancel()并_listener.Stop(),否则端口会一直占用,下次启动报「地址已在使用」。
3.2 接收数据时怎么处理粘包和半包
TCP 是字节流协议,没有消息边界。客户端发两次「Hello」,服务端可能一次收到「HelloHello」,也可能第一次收到「Hel」,第二次收到「loHello」。这就是粘包和半包。FrmTcpServer里如果直接用Read然后转字符串,一定会翻车。
常见做法是加一个长度前缀:发送前先写 4 字节的 int 表示消息体长度,接收时先读 4 字节,再按长度读消息体。
private async Task HandleClientAsync(TcpClient client, CancellationToken token) { NetworkStream stream = client.GetStream(); byte[] lengthBuffer = new byte[4]; while (!token.IsCancellationRequested) { // 先读 4 字节长度头 int read = await ReadExactAsync(stream, lengthBuffer, 4, token); if (read == 0) break; // 客户端正常关闭 int bodyLength = BitConverter.ToInt32(lengthBuffer, 0); if (bodyLength <= 0 || bodyLength > 1024 * 1024) break; // 防御异常长度 byte[] bodyBuffer = new byte[bodyLength]; read = await ReadExactAsync(stream, bodyBuffer, bodyLength, token); if (read == 0) break; string message = Encoding.UTF8.GetString(bodyBuffer); AppendLog($"收到:{message}"); } client.Close(); } private async Task<int> ReadExactAsync(NetworkStream stream, byte[] buffer, int count, CancellationToken token) { int offset = 0; while (offset < count) { int n = await stream.ReadAsync(buffer, offset, count - offset, token); if (n == 0) return offset; // 连接关闭 offset += n; } return offset; }逻辑说明:ReadExactAsync保证要么读满指定字节数,要么返回 0 表示连接断开。长度头用BitConverter.ToInt32解析,注意大小端要和客户端一致,.NET 默认小端。bodyLength做了上限判断,防止恶意客户端发一个超大长度导致内存爆掉。
参数说明:长度头固定 4 字节,消息体上限设 1MB,实际项目里可以根据业务调整。如果协议是文本行,也可以用\n作为分隔符,但二进制协议更推荐长度前缀。
3.3 服务端关闭时怎么优雅释放端口
窗体关闭事件里必须做三件事:取消接受循环、关闭所有客户端连接、停止监听器。
private void FrmTcpServer_FormClosing(object sender, FormClosingEventArgs e) { _cts?.Cancel(); _listener?.Stop(); // 停止监听,释放端口 // 如果有客户端集合,遍历关闭 foreach (var client in _clients) { client.Close(); } }逻辑说明:_listener.Stop()会中断AcceptTcpClientAsync,让它抛出ObjectDisposedException,在循环里捕获后退出。_clients是一个List<TcpClient>,在HandleClientAsync里添加和移除。如果不关闭客户端,端口虽然释放了,但已建立的连接会残留,直到系统回收。
参数说明:_cts和_listener在窗体加载时初始化为 null,关闭时判空。_clients的增删要考虑线程安全,简单做法是用lock。
4. TcpClient 窗体怎么连、怎么发、怎么知道对面断了
4.1 客户端连接服务端的超时控制
TcpClient.Connect是同步方法,在网络不通时会卡住几十秒,界面直接假死。正确做法是用ConnectAsync加超时。
private async void btnConnect_Click(object sender, EventArgs e) { string ip = txtIp.Text.Trim(); int port = int.Parse(txtPort.Text.Trim()); _client = new TcpClient(); // 用 Task.WhenAny 实现超时 var connectTask = _client.ConnectAsync(ip, port); var timeoutTask = Task.Delay(5000); // 5 秒超时 var completed = await Task.WhenAny(connectTask, timeoutTask); if (completed == timeoutTask) { AppendLog("连接超时"); _client.Close(); return; } // 如果 connectTask 抛异常,这里会抛出 await connectTask; AppendLog("已连接到服务端"); _stream = _client.GetStream(); _ = ReceiveLoopAsync(); // 启动接收循环 }逻辑说明:Task.WhenAny返回先完成的任务。如果是timeoutTask先完成,说明 5 秒内没连上,直接关闭。如果是connectTask先完成,还要await一次把可能的异常抛出来。ReceiveLoopAsync在后台持续读服务端发来的数据。
参数说明:超时时间 5000 毫秒可以根据网络质量调整,内网可以设 2000,跨机房设 10000。_client和_stream是窗体级字段,方便发送按钮复用。
4.2 发送数据时长度前缀怎么加
客户端发送必须和服务端接收协议一致,否则服务端解析长度头就会错位。
private async void btnSend_Click(object sender, EventArgs e) { if (_stream == null || !_stream.CanWrite) { AppendLog("未连接,无法发送"); return; } string message = txtSend.Text; byte[] body = Encoding.UTF8.GetBytes(message); byte[] length = BitConverter.GetBytes(body.Length); // 先写长度头,再写消息体 await _stream.WriteAsync(length, 0, length.Length); await _stream.WriteAsync(body, 0, body.Length); AppendLog($"已发送:{message}"); }逻辑说明:BitConverter.GetBytes把 int 转成 4 字节小端数组。两次WriteAsync之间理论上可能被其他发送操作插入,所以实际项目里应该用一个发送队列或者SemaphoreSlim保证原子性。简单测试时问题不大,但多线程发送就会粘包错乱。
参数说明:body.Length是字节数,不是字符数。中文一个字符在 UTF-8 下占 3 字节,所以「你好」的长度是 6,不是 2。
4.3 接收循环里怎么判断服务端主动断开
服务端关闭连接时,客户端ReadAsync会返回 0。这时候要停止循环并更新界面状态。
private async Task ReceiveLoopAsync() { byte[] lengthBuffer = new byte[4]; while (true) { try { int read = await ReadExactAsync(_stream, lengthBuffer, 4, CancellationToken.None); if (read == 0) { AppendLog("服务端已断开"); break; } int bodyLength = BitConverter.ToInt32(lengthBuffer, 0); byte[] bodyBuffer = new byte[bodyLength]; read = await ReadExactAsync(_stream, bodyBuffer, bodyLength, CancellationToken.None); if (read == 0) { AppendLog("服务端已断开"); break; } string message = Encoding.UTF8.GetString(bodyBuffer); AppendLog($"收到:{message}"); } catch (IOException ex) { AppendLog($"连接异常:{ex.Message}"); break; } } _client?.Close(); _stream = null; }逻辑说明:ReadExactAsync和服务端用的是同一个逻辑,可以抽成公共类。IOException通常表示连接被重置或网络中断。循环退出后关闭TcpClient并置空_stream,防止发送按钮继续往已关闭的流里写。
参数说明:CancellationToken.None表示不取消,实际项目里可以用窗体关闭事件触发取消。bodyLength同样要做上限判断,防止服务端发来异常长度。
5. 避坑与排查:FrmTcpServer 和 TcpClient 联调时最容易翻车的 5 个点
5.1 现象:服务端显示客户端已连接,但收不到任何数据
原因:客户端发送时没有加长度前缀,服务端按 4 字节长度头解析,把消息内容的前 4 个字节当成了长度,导致后续全部错位。或者客户端用了StreamWriter自动加换行,服务端没按行解析。
解决:统一协议。要么双方都用长度前缀,要么双方都用换行分隔。用长度前缀时,发送端先写BitConverter.GetBytes(body.Length),接收端先读 4 字节。用换行分隔时,接收端用StreamReader.ReadLine,但要注意ReadLine会阻塞直到遇到换行。
5.2 现象:客户端点连接按钮后界面卡死几秒
原因:用了同步的TcpClient.Connect,网络不通时系统默认超时时间很长。或者ConnectAsync没有加超时控制,虽然不阻塞 UI,但用户不知道什么时候该放弃。
解决:用Task.WhenAny加Task.Delay做超时,超时后调用_client.Close()。同时把连接按钮在连接过程中禁用,避免重复点击创建多个TcpClient。
5.3 现象:服务端关闭后重新启动,报「通常每个套接字地址只允许使用一次」
原因:上一次的TcpListener没有调用Stop(),或者调用了但端口还处于 TIME_WAIT 状态。Windows 下默认 TIME_WAIT 是 4 分钟。
解决:在TcpListener上设置ReuseAddress选项,或者确保窗体关闭时一定调用Stop()。更彻底的做法是捕获SocketException,如果错误码是AddressAlreadyInUse,提示用户换端口或者等待。
_listener = new TcpListener(IPAddress.Any, port); _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Start();5.4 现象:中文消息显示乱码
原因:服务端和客户端编码方式不一致。服务端用 UTF-8,客户端用 GBK,或者反过来。还有一种情况是用了Encoding.Default,在不同系统区域设置下结果不同。
解决:双方显式指定同一种编码,推荐 UTF-8。不要用Encoding.Default。如果协议是二进制,长度头用BitConverter,消息体用Encoding.UTF8.GetBytes和GetString。
5.5 现象:多个客户端同时连接时,服务端只收到最后一个客户端的数据
原因:HandleClientAsync里用了窗体级的_stream或者_client字段,后一个客户端把前一个覆盖了。每个客户端必须有独立的会话对象。
解决:把每个客户端的TcpClient和NetworkStream封装到一个ClientSession类里,用ConcurrentDictionary管理。窗体只负责显示日志,不持有具体连接。
private ConcurrentDictionary<string, ClientSession> _sessions = new(); // 在 Accept 循环里 string key = client.Client.RemoteEndPoint.ToString(); _sessions[key] = new ClientSession(client); _ = _sessions[key].StartAsync();6. 从能跑到好用:给 FrmTcpServer 加一个心跳检测和自动重连的实用技巧
压缩包里的代码能跑通之后,下一步就是让它「不容易断」。TCP 连接在空闲时可能被中间设备静默丢弃,双方都不知道。我一般会在服务端和客户端各加一个心跳:客户端每 10 秒发一个 1 字节的0x00,服务端收到后回一个0x01。如果客户端连续 3 次没收到回复,就主动断开并触发重连。
心跳包不要用业务消息格式,单独一个字节,避免和长度前缀混淆。服务端在HandleClientAsync里判断:如果读到的第一个字节是0x00且后面没有长度头,就当作心跳处理。更干净的做法是协议里加一个消息类型字段,但最小改动就是约定一个特殊长度值,比如长度头为-1表示心跳。
// 客户端心跳定时器 private System.Timers.Timer _heartbeatTimer; private void StartHeartbeat() { _heartbeatTimer = new System.Timers.Timer(10000); _heartbeatTimer.Elapsed += async (s, e) => { if (_stream == null || !_stream.CanWrite) return; try { byte[] heartbeat = BitConverter.GetBytes(-1); // 长度头 -1 表示心跳 await _stream.WriteAsync(heartbeat, 0, heartbeat.Length); } catch { // 发送失败,触发重连 _heartbeatTimer.Stop(); BeginReconnect(); } }; _heartbeatTimer.AutoReset = true; _heartbeatTimer.Start(); }服务端收到长度头为-1时,不回消息体,直接回一个长度头为-2的确认。客户端收到-2就重置超时计数。如果连续 3 次没收到,就关闭当前连接,用Task.Delay等 3 秒后重新调用连接逻辑。
自动重连要注意两点:一是重连期间不要重复创建定时器,用一个bool _isReconnecting标志位;二是重连成功后要重新启动接收循环和心跳定时器。我自己的习惯是把连接、接收、心跳封装成一个TcpClientWrapper类,窗体只调用ConnectAsync和SendAsync,内部状态对外透明。
注意:心跳间隔不要小于 5 秒,否则在低功耗设备上会增加不必要的网络唤醒。内网环境 10 到 30 秒都合理,跨公网可以设 5 到 10 秒。
最后说一个我踩过的坑:心跳包和业务包共用同一个NetworkStream时,如果业务包正在发送大文件,心跳包会排在后面,导致心跳超时误判。解决办法是心跳包单独走一个TcpClient连接,或者业务层加发送队列,心跳包插队。我一般选后者,用一个ConcurrentQueue加一个后台发送任务,心跳包标记为高优先级。这个改动不大,但能让长时间运行的连接稳定很多。希望帮到你。
本文还有配套的精品资源,点击获取