
简介C#编写的TCP/IP服务端与客户端源码包面向.NET网络编程学习者提供一套完整、可直接运行的客户端/服务端通信实现。压缩包共273个文件核心为77个.cs源码文件另含20个.exe可执行程序以及解决方案、工程配置、资源文件、说明文档等整体约5.94MB目录结构便于按项目模块查阅。服务端利用TcpListener循环监听端口客户端通过TcpClient建立连接并以NetworkStream完成双向数据收发资源覆盖异常处理、多线程与异步操作等进阶思路可支撑从基础通信到并发管理的学习路径。解决方案与工程文件齐全可直接编译运行并观察完整交互流程。目前已有69人学习下载适合课程设计、技术验证或毕设起步阶段既可对照源码理解TCP/IP机制也可作为二次开发的基础模板。1. C# 写 TCP/IP 服务端与客户端一个能跑通的最小闭环做上位机或者设备联调的人迟早会遇到这么一件事手头设备只给了一个 TCP 端口协议文档写得模棱两可你要自己写个服务端收数据或者写个客户端去连对方的服务端。网上搜 C# TCPIP 服务端与客户端源码能搜出一堆但多数要么只贴了循环接收的片段要么直接丢给你两个能跑但完全听不懂的工程。这篇笔记我按自己拆过的一套源码来讲——它有完整服务端、完整客户端、带心跳和断线重连能直接编译跑起来也适合拿去做二次开发。新手可以按步骤复现一遍跑通了再看接收缓冲区和拆包的逻辑熟手可以直接跳到第 5 章的踩坑部分那几条都是实际联调里翻过车的。2. 先把协议定清楚同步模型、报文体与端口选型2.1 为什么不是 Socket 裸写而是用 TcpClient / TcpListener很多新手一搜到 C# Socket 教程上来就 new Socket 然后 Bind、Listen、Accept 一套组合拳。这套 API 能跑但写多客户端管理的时候要自己维护 Socket 列表、自己处理异常断开、自己处理半包代码很容易散。常见做法是服务端用 TcpListener 包装监听逻辑客户端用 TcpClient 包装连接逻辑底层还是 Socket但网络流的读写直接用 NetworkStream 完成省掉很多底层细节。这套源码里服务端用的就是 TcpListener NetworkStream客户端是 TcpClient NetworkStream。同步还是异步这个要选清楚异步写法BeginAccept / ReadAsync能撑更高的并发但调试时调用链难跟同步写法代码直白配合 Task.Run 放后台线程单机几百路连接完全够用。对大多数设备联调场景我一般建议先落地同步模型跑通业务后再根据连接数改异步不要一上来就异步出了问题连断点都不好打。// 服务端启动监听的核心骨架 TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine(服务端已启动监听端口 9000); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ HandleClientAsync(client); // 丢到后台处理不阻塞接收新连接 }这里 AcceptTcpClientAsync 是异步接收客户端连接await 关键字保证监视线程不被卡死。HandleClientAsync 是处理单个客户端的方法每个客户端进来都开一个独立 Task 跑这样多个设备同时上报时不会互相排队影响。2.2 报文体设计为什么建议带长度字段而不是靠分隔符协议这块是最容易返工的地方。市面上常见做法有两种一种是按 \r\n 或者 0xFF 结尾去拆包简单但业务数据里如果恰好出现结尾字符就翻车另一种是包头带长度字段比如 4 字节 Int32 表示后续报文长度再接正文。我在实际项目里几乎只用第二种而且建议你也用第二种尤其是做设备通信——设备端报文里什么字节都可能出现靠分隔符太脆弱。这套源码采用的是「4 字节长度 JSON 正文」的简化协议先收 4 个字节解析出长度再按长度读满正文。为什么不直接用 JSON 序列化后按流读取因为 TCP 是流协议底层不知道你的报文边界在哪发两次数据可能一次收到发一次数据可能分两次收到不自己定边界就无法可靠还原。这就是后面要讲的粘包半包问题的根源协议设计从这里就已经决定了。// 将对象序列化为 JSON 并封包4字节长度 正文 static byte[] BuildPacket(object message) { string json JsonConvert.SerializeObject(message); byte[] body Encoding.UTF8.GetBytes(json); byte[] header BitConverter.GetBytes(body.Length); return header.Concat(body).ToArray(); }BitConverter.GetBytes 生成的是小端字节序的 4 字节长度头Concat 把包头和正文拼成一个完整的包。注意这里有个隐含约定如果对方设备是用 C/C 写的默认也是小端一致但如果对端是大端序的 Java 服务或者做了大端处理的单片机这里就要先做字节序转换否则长度解析全错这是很多联调事故的根源。2.3 端口号与防火墙9000 这个端口不是随便选的选端口有个实用原则避开知名端口80、443、8080避不开也尽量不用 1024 以下的。常见做法是挑 9000 到 19000 之间的端口不容易跟本机其他服务冲突。源码里默认监听 9000实际部署时建议改成 9001 或其他高位端口。还要提醒一点Win10 以上系统默认会有一部分 TCP 动态端口范围用 netsh int ipv4 show excludedportrange tcp 能看到选端口前先看一眼免得监听时偶发报「地址已被占用」的错。防火墙也要注意服务端第一次启动时 Windows 会弹防火墙授权窗口局域网内的客户端连不上通常是这个原因后面第 5 章会展开说。3. 服务端实现TcpListener 下的多客户端管理与拆包3.1 客户端连接管理ConcurrentDictionary 存会话服务端不能只跑一个客户端实际场景里至少得同时挂三五台设备。源码里用一个 ConcurrentDictionarystring, TcpClient 保存当前所有在线客户端key 可以用设备编号或者客户端连接时首包上报的 ID。为什么不直接用 List因为多线程同时读写List 要自己加锁ConcurrentDictionary 内部做了分段锁省心。每次客户端连接进来先收到一条握手消息里面带上设备号服务端把它注册进字典断开时从字典移除。这里有个关键处理客户端断开是异常驱动的不能靠轮询去探测要在读数据抛异常或返回 0 字节时触发清理逻辑否则字典里全是死连接内存和端口都会慢慢耗尽。// 处理单个客户端连接的完整方法骨架 private async Task HandleClientAsync(TcpClient client) { string deviceId null; try { using (client) using (NetworkStream stream client.GetStream()) { // 第一步等待握手消息拿到设备ID byte[] handshakeBuffer new byte[1024]; int read await stream.ReadAsync(handshakeBuffer, 0, handshakeBuffer.Length); deviceId ParseDeviceId(handshakeBuffer, read); _clients.TryAdd(deviceId, client); // 第二步循环读报文 while (true) { byte[] packet await ReadCompletePacketAsync(stream); if (packet null) break; await HandlePacket(deviceId, packet); } } } catch (Exception ex) { Console.WriteLine($[{deviceId}] 连接异常: {ex.Message}); } finally { if (deviceId ! null) { _clients.TryRemove(deviceId, out _); Console.WriteLine($[{deviceId}] 已断开当前在线 {_clients.Count} 台); } } }这段代码有几个细节要注意using 保证连接最终会释放ReadAsync 返回 0 说明对方正常关闭TryAdd、TryRemove 都是线程安全的。这里的关键是你如何区分「一次性握手消息」和「持续的数据报文」——如果每台设备的握手消息格式不同就需要在 ParseDeviceId 里做不同的解析分支。3.2 拆包逻辑ReadCompletePacketAsync 的完整实现这是整个服务端最容易写错的地方。TCP 流的边界不在你发送的报文边界上必须自己收满一个完整的包。实现思路是先读 4 字节长度头再根据长度头循环读 body直到读满为止。重点在于循环 Read 的时候第一次可能只读到了部分 body要计算剩余字节数继续读。// 从 NetworkStream 读取一个完整报文解决粘包半包问题 private static async Taskbyte[] ReadCompletePacketAsync(NetworkStream stream) { // 第一步完整读取 4 字节长度头 byte[] header new byte[4]; int headerRead 0; while (headerRead 4) { int n await stream.ReadAsync(header, headerRead, 4 - headerRead); if (n 0) return null; headerRead n; } // 第二步解析出 body 长度 int bodyLength BitConverter.ToInt32(header, 0); if (bodyLength 0 || bodyLength 1024 * 1024) throw new InvalidDataException($非法报文长度: {bodyLength}); // 第三步循环读够 body byte[] body new byte[bodyLength]; int bodyRead 0; while (bodyRead bodyLength) { int n await stream.ReadAsync(body, bodyRead, bodyLength - bodyRead); if (n 0) return null; bodyRead n; } return body; }这个拆包方法是全篇的核心三个关键点对应三个常见 bug第一header 必须循环读满 4 字节不能以为一次 ReadAsync 就能拿全第二bodyLength 要做合法性校验否则对端发来一个超大长度值new byte[bodyLength] 会直接抛 OutOfMemory第三body 的偏移量是 bodyRead不是 0否则第二次 Read 会把前面的数据覆盖。这里我踩过一次以前图省事header 只读一次结果在高并发小报文场景下偶发解析出错误长度表现为客户端莫名其妙断线服务端日志全是 InvalidDataException。后来抓包才发现第一次 Read 可能只读到了 2 字节后面 2 字节跟 body 的第一个字节一起到了必须循环读。3.3 心跳与超时断线检测不能只靠 TCPTCP 本身有 KeepAlive 机制但默认触发时间太长Windows 默认 2 小时设备突然断电、网线被拔这种场景服务端可能要很久才能感知。源码里使用的是应用层心跳客户端每 10 秒发一个心跳包服务端如果连续 30 秒没收到任何数据就判定超时主动断开并清理会话。// 服务端超时检测最后活跃时间 定时扫描 public class ClientSession { public string DeviceId { get; set; } public TcpClient TcpClient { get; set; } public DateTime LastActive { get; set; } } // 定时器每 5 秒扫描一次 private void CheckTimeout(object state) { DateTime now DateTime.Now; foreach (var kv in _sessions) { if ((now - kv.Value.LastActive).TotalSeconds 30) { kv.Value.TcpClient.Close(); _sessions.TryRemove(kv.Key, out _); Console.WriteLine($[{kv.Key}] 心跳超时已强制断开); } } }这个做法的思路是服务端不主动关闭连接只被动记录。LastActive 在收到任何完整报文时更新一次包括心跳和数据报文。定时器每 5 秒扫一遍超过 30 秒没活跃就 Close。Close 之后客户端的读操作会立刻抛异常从而触发 3.1 里的 finally 清理逻辑这样整个生命周期是闭环的。4. 客户端实现TcpClient 连接、重连与异步接收4.1 客户端基类连接、发送、断线重连的事件模型客户端在结构上跟服务端不是简单镜像关系它更关注「断线了怎么办」。设备端一般不会自己重启客户端作为上位机或者采集端要能在服务端重启、网线松动、服务端换 IP 后自动恢复。这套源码里客户端封装了一个重连机制断线后按 1s、2s、5s、10s 倍增重试最多 30s 封顶无限重试直到成功或用户取消。// 客户端核心逻辑循环尝试连接 public async Task ConnectWithRetryAsync(string host, int port) { int retryDelay 1000; while (!_cancelled) { try { _client new TcpClient(); await _client.ConnectAsync(host, port); _client.NoDelay true; Console.WriteLine($连接成功: {host}:{port}); OnConnected?.Invoke(); await ReceiveLoopAsync(); } catch (Exception ex) { Console.WriteLine($连接异常({retryDelay}ms 后重试): {ex.Message}); } if (_cancelled) break; await Task.Delay(retryDelay); retryDelay Math.Min(retryDelay * 2, 30000); // 指数退避上限30秒 } }指数退避的设计参考了 TCP 慢启动的思路刚断线时频繁重试没意义还不如等网络稳定重试间隔逐步加大避免对服务端造成连接风暴。NoDelay true 也比较关键它关闭了 Nagle 算法小报文不会在缓冲里滞留对实时性要求高的设备通信建议加上。客户端的所有业务逻辑都挂在 OnConnected 和 OnDataReceived 这两个事件上换项目时只要改事件处理函数就行。4.2 客户端接收循环与发送封装客户端的接收逻辑跟服务端一样要处理拆包同一个 ReadCompletePacketAsync 直接复用。差别是客户端要额外处理收到服务端主动下发的指令比如服务端要求设备回传当前状态。发送这边做了一层线程安全封装多个线程可能同时调用发送NetworkStream.Write 本身不是线程安全的并发写会报异常或者数据交错所以要加锁。// 线程安全的发送封装 private readonly object _sendLock new object(); public void Send(object message) { if (_client null || !_client.Connected) throw new InvalidOperationException(客户端未连接); byte[] packet BuildPacket(message); lock (_sendLock) { NetworkStream stream _client.GetStream(); stream.Write(packet, 0, packet.Length); stream.Flush(); } }lock 确保同一时刻只有一个线程在写流避免数据帧交错。这里最易犯的错误是以为 TcpClient.Connected 属性可靠——它只反映最近一次 IO 操作时的状态不能作为连接是否有效的依据。判断连接是否可用标准做法是尝试一次小数据读取或发送。源码里错误处理逻辑是发送抛 IOException 时触发重连并在下次心跳时确认通道是否真的重新建立。4.3 和上位机场景的衔接数据如何推向 UI如果你是在 WinForms 或 WPF 里做上位机接收线程不能直接更新 UI 控件否则会报线程间操作无效的异常。这块源码里用了一个简单方案客户端收到 JSON 报文后解析为业务对象通过事件抛出去界面层用 SynchronizationContext 或者控件本身的 Invoke 方法切回 UI 线程。实测用 100ms 的定时器去读队列也行但事件 Invoke 响应更快。// 接收数据后触发事件UI 层订阅并切换线程 public event Actionstring DataReceived; private async Task ReceiveLoopAsync() { NetworkStream stream _client.GetStream(); while (true) { byte[] body await ReadCompletePacketAsync(stream); if (body null) break; string json Encoding.UTF8.GetString(body); DataReceived?.Invoke(json); } } // WinForms 中订阅 client.DataReceived (json) { textBox1.Invoke(new Action(() textBox1.AppendText(json \r\n))); };Invoke 的方式在数据频率低时够用。每秒几百条报文时建议改用生产者消费者队列接收线程只入队UI 定时器批量出队刷新不然高频 Invoke 会把界面卡死。这个技巧对上位机场景尤其重要——处理设备实时上报扭矩、温度这类高频数据时卡 UI 是最大的败笔。5. 常见问题排查粘包、端口占用与 UI 卡死的现场还原5.1 粘包与半包一次性收到多条报文导致 JSON 解析失败现象客户端连续两次调用 Send服务端收到一条拼在一起的报文反序列化报错「意外的字符」或者一条长报文拆成了两截服务端解析不完整。原因TCP 是流协议只保证字节顺序不保证报文边界。发送两次的数据可能在一次 Read 里全部到达粘包一次大数据量的 Read 也可能只拿到报文的一部分半包。解决不要改接收逻辑去硬拆标准做法就是第 3.2 节那套「先读长度、再循环读满 body」的拆包器。如果你已经引入了这套逻辑还出问题多半是长度头的字节序和对方不一致两边的 BitConverter 结果对不上。检查方法是抓包看前 4 字节和 body 长度是否吻合。5.2 端口被占用服务端重启时报错「地址已被使用」现象开发机上一旦服务端崩溃重启监听的 9000 端口要等几十秒才释放或者直接报 SocketException地址已被使用。原因TCP 连接断开后进入 TIME_WAIT 状态端口要等 2MSL最大报文段生存期才释放。常见做法是在开发调试时开启 SO_REUSEADDR让端口可以快速重新绑定。解决绑定监听端口前设置 TcpListener 底层的 Socket 选项。源码里在 listener.Start() 之前通过 listener.Server 配置 SocketOptionName.ReuseAddress。注意生产环境要不要开取决于业务如果端口在多个实例间快速重启切换一般建议开如果可能有两个实例同时绑定同一个端口来做负载均衡就不能开。TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();5.3 UI 线程卡死上位机点击按钮后界面无响应现象WinForms 里点「开始连接」按钮后整个窗口拖不动、按钮按不了几秒后突然恢复或者直接报异常。原因在按钮点击事件里直接调用了同步的 Connect 或者 Read 方法UI 线程被阻塞了。ConnectTimeout 是 20 秒时界面就卡 20 秒。解决所有网络操作放到 Task.Run 或 async/await 里UI 线程只做状态展示。点击事件改成 async void 然后 await ConnectAsync这个模式最简单。另外 5.3 里说的接收线程切 UI 也要注意Invoke 在窗口关闭后调用会抛异常关闭窗口前先把 DataReceived 事件取消订阅。5.4 局域网内连接失败客户端报超时但服务端日志无记录现象客户端能 ping 通服务端 IP但 ConnectAsync 超时服务端日志一行都没新增。原因九成是 Windows 防火墙拦了服务端的入站连接。开发机上跑客户端和服务端没问题换到局域网另一台机器就连不上基本就是这个原因。解决第一次启动服务端时弹出的防火墙授权要勾选「专用网络」如果弹窗被忽略了去控制面板手动添加入站规则允许 TCP 9000 端口入站。注意改了防火墙不用重启服务端但测试前要先确认服务端的监听地址是 IPAddress.Any 而不是 127.0.0.1——后者只会监听本机回环地址局域网内永远连不上。5.5 大量数据时接收越来越慢最终内存涨到几个 GB现象每秒超过 500 条报文时内存肉眼可见地涨GC 频繁最终服务端卡死。原因每次收到报文都 new 一个 byte[bodyLength]短连接场景无所谓高并发场景频繁分配大数组导致 LOH大型对象堆碎片化或者接收方消费速度跟不上生产速度队列越积越多。解决压测时把 body 长度上限调低并加限制接收到的报文在 100ms 内处理完不要在接收循环里做耗时操作高频场景复用 byte[] 缓冲池但这套源码默认没做池化你需要自己加一个简单的 ArrayPool 或队列复用缓冲。90% 的上位机场景先做消费速度优化就够别急着上内存池。6. 联调与压测用脚本验证服务端上限的一个土办法源码交付时除了服务端和客户端两个 C# 工程我还建议你保留一个 Python 脚本用来做快速压力验证。为什么用 Python 而不是拿客户端工程来测因为客户端有断线重连、事件回调这些业务逻辑测的是完整链路Python 脚本可以写得更脏更直接纯粹从裸 Socket 发数据验证服务端拆包器的正确性和上限。这样以后改协议或者调参数用脚本压一遍就能定位到问题不用一直盯着 VS 的调试器。先做一个粘包测试把两条报文一次写完不等对端读取看服务端能不能正确解析成两条独立消息。这个测试直接验证 3.2 节的拆包逻辑。import socket import struct import json s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) # 模拟一次写入两条报文验证粘包处理 def pack(obj): body json.dumps(obj).encode(utf-8) return struct.pack(i, len(body)) body s.sendall(pack({type: heartbeat, id: dev01}) pack({type: data, value: 99})) s.close()struct.pack(i) 表示小端 4 字节和 C# 里 BitConverter 的默认行为对齐。粘包测试能过说明服务端的拆包器是可靠的。接下来做一个吞吐测试循环发 10000 条小报文看服务端能否全部正确解析、有没有丢包或错包同时监控内存是否稳定。import socket import struct import json import time s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) body json.dumps({type: data, value: 1}).encode(utf-8) packet struct.pack(i, len(body)) body start time.time() for i in range(10000): s.sendall(packet) s.close() print(发送耗时:, time.time() - start, 秒)我一般把主要精力放在内存曲线和日志数量服务端每解析一条报文打一行日志脚本跑完后对比日志行数和发送条数如果一致说明没有丢再看任务管理器里内存是否平稳。还有个常用的边界测试是发空报文和超大报文比如 5MB看服务端拒收逻辑是否生效——直接发 struct.pack(i, 510241024) 后的满包服务端应该在 bodyLength 合法性校验处抛异常并主动断开而不是分配 5MB 内存后继续解析。这套方式陪我验证过不少协议改动尤其是把 JSON 换成二进制协议、把同步改成异步那两次大改都是先用脚本压完才敢往设备上放。从那以后我每次写 TCP 服务端都会先把压测脚本放在工程目录里改完代码先跑一轮粘包和吞吐再联调真实设备。希望帮到你。本文还有配套的精品资源点击获取