ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

C# Socket网络通讯从原理到落地:TCP源码、粘包拆包与心跳检测实战

C# Socket网络通讯从原理到落地:TCP源码、粘包拆包与心跳检测实战 简介这是一份面向C#初学者的Socket网络通讯完整源码示例覆盖服务端与客户端两端程序基于WinForm界面实现代码风格直接明了可帮助理解Socket连接、发送与接收消息的核心流程也适合作为课程设计或毕业设计的起点工程。压缩包共61个文件以cs源码文件为主体同时包含exe可执行程序、sln/csproj工程文件、pdb调试符号、resx资源文件等整体仅1.17MB轻量且结构完整。目前已有2914人学习下载说明其在入门场景中的实用性已被较多开发者验证。源码将server与client分区整理打开即用无需复杂配置同时代码中保留了清晰的逻辑层次读者可以对照两端程序逐步掌握通讯建立、数据交换与界面联动的方法。若想进一步扩展可在现有基础上加入多客户端管理、心跳机制或文件传输等功能方便二次开发。1. C# Socket 网络通讯一套完整源码为什么值得从头写一遍很多刚接触 C# Socket 网络通讯的人第一反应是去搜“完整源码”结果拿回来的东西要么贴了半本 C# 高级编程的片段要么封了好几层事件回调一改参数就翻车。Socket 编程的难点从来不在那几行 API而在连接什么时候断、消息什么时候拆、编码按什么解这些看不见现场的问题才是通讯层真正要交付的东西。我按“简单、清楚”的标准整理了一套可以直接新建控制台项目跑起来的 TCP 源码服务端监听、客户端连接、一次收发、断线感知每一步都能在代码里找到对应位置。源码不引第三方库不堆设计模式适合刚上手 C# Socket 编程、以及要做上位机或设备数据采集的读者。新手能照着跑通老手能直接对照边界条件补自己的工程这就是这套源码存在的价值。2. 先立通讯模型TCP 选型、阻塞式读写与 C# 里的 Socket 流程动手写代码之前有三件事要先定下来链路用 TCP 还是 UDP收发用同步还是异步Socket 从 Bind 到 Close 到底经历了什么。这三个问题不解决后面的代码再完整到排查问题时也是一只黑匣子只能靠猜。2.1 TCP 与 UDP 的选型为什么工控与上位机场景默认选 TCPC# 做设备通讯面对的大多数协议最终都跑在 Socket 上。Modbus TCP 在外面裹了一层应用协议底层还是 TCP西门子 S7 走的是加工过的 TCP自定义数据采集更是以 TCP 为主。原因不复杂TCP 有握手、确认和重传能保证字节按序到达。对“指令下发 数据回传”这种需要一问一答的采集场景UDP 天然就不合适。两种协议的业务侧代价需要分清楚对比项TCPUDP连接状态面向连接三次握手无连接直接发送可靠性确认重传、按序到达不保证不丢、不保证顺序典型场景上位机指令、PLC 点位、采集上传音视频、广播、告警遥测业务层难度要处理粘包和半包报文自带边界但丢包要业务自己兜底UDP 的代码表面上看更短但“不丢包”这个承诺要业务层自己补序号、超时重发、去重、乱序排列做起来比 TCP 的拆包麻烦得多。所以我一般建议在没有明确实时性要求时默认选 TCP。真的要用 UDP就把收发和业务解耦不要在回调里做重活否则一处丢包会把整个状态机带偏。2.2 阻塞式、异步式与事件驱动哪种写法才配得上“简单清楚”“简单清楚”在 Socket 编程里有一个容易被忽略的含义代码要能被第二个人快速看懂出问题时能顺着调用链查下去。很多人把“代码短”当成简单于是满屏 async/await实际上异步的异常栈和上下文切换在新人接手时明显增加排查成本。我常看到四种写法同步阻塞的 Read/Write、APM 风格的 BeginXxx/EndXxx、async/await 异步、SocketAsyncEventArgs。APM 回调嵌套读起来很累SocketAsyncEventArgs 适合几千连接的网关性能好但理解成本高。C# 上位机通常是几十台设备的小规模通讯同步阻塞加每客户端一个线程数据路径最直日志最好加。TcpListener 本身就是为这种写法设计的不是非要绕开它。在 .NET 6 之后的项目里为了不阻塞界面线程可以在 UI 层开 Task通讯内核仍然保持同步循环。线程池会自动在线程阻塞时补充线程几十个客户端看不出压力。等业务规模真正超过这个模型的承受范围再换异步不迟不要一开始就为了性能做架构。2.3 从 Bind 到 ReceiveSocket 生命周期与关键参数Socket 生命周期可以用一张对应表讲清楚方法角色实际作用Bind服务端把 IP 和端口绑定到套接字Listen服务端进入监听参数是 backlogAccept服务端取出一个已完成握手的 TCP 连接Connect客户端发起三次握手Send / Write双方发送字节流Receive / Read双方读取字节流这里最容易被忽略的是关闭行为。主动关闭的一方会进入 TIME_WAIT持续约几十秒到两分钟的 2MSL期间同样的端口不能被立刻重绑。服务端频繁重启遇到“地址已被占用”十有八九是这个原因。backlog 是内核里“已完成握手但应用还没 Accept”的连接队列上限给 10 以内足够给大不会提升并发能力。另一个必须建立的认知是Socket 层面没有“消息”只有“字节流”。Write 一次对端可能 Read 一次、两次也可能一次把好几条都读走。这个特性决定了通讯层必须自己设计帧边界也就是后面要展开的粘包问题。3. 完整源码落地服务端、客户端与最小可跑通实现模型立住了接下来给一套能直接复现的源码。新建一个 .NET 6 控制台项目把下面三个类分别放到项目里或者全部放在同一个 Program.cs 里也行。为了读起来清爽我按服务端、客户端、入口分开写每段代码后面跟着参数说明。3.1 TcpListener 服务端监听、Accept 与多客户端处理服务端用 TcpListener 监听 9000 端口每连进一个客户端就交给线程池处理。这样 Accept 循环不会被某个客户端的收发卡住多客户端同时在线也不互相干扰。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; namespace SocketDemo { public class TcpServer { private readonly TcpListener _listener; private readonly int _bufferSize; public TcpServer(int port, int backlog 10, int bufferSize 4096) { // IPAddress.Any 表示监听本机所有网卡的 0.0.0.0:port _listener new TcpListener(IPAddress.Any, port); _listener.Start(backlog); _bufferSize bufferSize; Console.WriteLine($[Server] listening on 0.0.0.0:{port}, backlog{backlog}); } public void Run() { while (true) { // Accept 阻塞到有客户端完成握手才返回一个 TcpClient TcpClient client _listener.AcceptTcpClient(); Console.WriteLine($[Server] client connected: {client.Client.RemoteEndPoint}); // 每个客户端一个线程池任务避免处理慢客户端时阻塞后续 Accept ThreadPool.QueueUserWorkItem(HandleClient, client); } } private void HandleClient(object state) { TcpClient client (TcpClient)state; byte[] buffer new byte[_bufferSize]; try { using (NetworkStream stream client.GetStream()) { while (true) { // Read 返回 0 表示对端完成了正常关闭 int readCount stream.Read(buffer, 0, buffer.Length); if (readCount 0) break; string text Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($[Server] recv {readCount} bytes: {text}); // 原样回给客户端方便验证整条链路 byte[] ack Encoding.UTF8.GetBytes($ack[{readCount}]: {text}); stream.Write(ack, 0, ack.Length); } } } catch (Exception ex) { // 断电、超时、对端强行断开都会走到这里先记日志再关闭连接 Console.WriteLine($[Server] client error: {ex.Message}); } finally { client.Close(); Console.WriteLine([Server] client closed); } } } }这段代码里的数据流是直线Accept 拿到连接ThreadPool 起一个任务任务里循环 Read读到 0 就退出异常就记日志最后必然 Close。IsConnected 这种属性我故意不看因为它在连接异常后仍然可能为 true不能用来判断链路真实状态。TCP 连接拔线后不会主动告知对方只有 Read 一直阻塞这个点留在第 4 章讲。参数上IPAddress.Any 监听所有网卡。只想让内网机器连就把它换成具体内网 IP避免服务暴露到额外网卡上。backlog 给 10 即可这个数字只影响排队未处理的连接数不提升收发性能。bufferSize 设为 4096 是单次 Read 的上限不是协议帧长度。如果设备单帧超过 4096这段代码会截断需要配合拆包逻辑不能简单把 buffer 开大就算解决。3.2 TcpClient 客户端连接超时、收发超时与一次标准回显客户端要解决的第一件事是连接超时。TcpClient.Connect 在目标 IP 不可达时等待时间由系统协议栈决定经常十几秒才抛错设备离线时体验很差。用 BeginConnect 加 WaitOne 可以自己控制这个时间。using System; using System.Net.Sockets; using System.Text; namespace SocketDemo { public class TcpClientHelper { private TcpClient _client; public void Connect(string ip, int port, int timeoutMs 3000, int receiveTimeoutMs 3000, int sendTimeoutMs 3000) { _client new TcpClient(); _client.ReceiveTimeout receiveTimeoutMs; _client.SendTimeout sendTimeoutMs; // BeginConnect WaitOne 是控制连接耗时的常用写法 IAsyncResult handle _client.BeginConnect(ip, port, null, null); if (!handle.AsyncWaitHandle.WaitOne(timeoutMs)) { _client.Close(); throw new TimeoutException($connect to {ip}:{port} timeout after {timeoutMs}ms); } _client.EndConnect(handle); Console.WriteLine($[Client] connected to {ip}:{port}); } public void Send(string text) { if (_client null || !_client.Connected) throw new InvalidOperationException(client is not connected); byte[] data Encoding.UTF8.GetBytes(text); _client.GetStream().Write(data, 0, data.Length); Console.WriteLine($[Client] sent {data.Length} bytes); } public string ReceiveText() { byte[] buffer new byte[4096]; int n _client.GetStream().Read(buffer, 0, buffer.Length); if (n 0) return null; return Encoding.UTF8.GetString(buffer, 0, n); } public void Close() { _client?.Close(); } } }BeginConnect 之后必须调用 EndConnect否则异步句柄泄漏。WaitOne 超时后要立刻 Close 并抛异常这个动作相当于把半握手的连接直接放弃。ReceiveTimeout 和 SendTimeout 只对已建立的连接生效超时后下一次 Read 或 Write 会抛 IOException后续走异常分支执行断开。提示示例里 Send 和 ReceiveText 分别调用 GetStream()单线程一问一答时没有问题。项目规范一点的话在 Connect 时保存一次 NetworkStream 并复用避免多个包装实例同时读写同一个 Socket。ReceiveText 是一次性 Read 的简化版它不保证“读一次就是一条完整消息”。TCP 是字节流可能只读到半条也可能一次性拿到好几条。我这里先让通路跑起来拆包统一放到第 4 章处理。3.3 入口与运行验证两个终端十分钟跑通主程序用一个参数区分服务端和客户端。带 server 参数就启动监听不带参数就走客户端交互模式方便本机自测。using System; using System.Text; namespace SocketDemo { class Program { static void Main(string[] args) { // Windows 控制台默认编码可能是 GBK先固定成 UTF8 再输出日志 Console.OutputEncoding Encoding.UTF8; if (args.Length 0 args[0] server) { new TcpServer(port: 9000).Run(); return; } TcpClientHelper helper new TcpClientHelper(); helper.Connect(127.0.0.1, 9000, timeoutMs: 3000); while (true) { Console.Write( ); string line Console.ReadLine(); if (string.IsNullOrEmpty(line)) continue; if (line exit) break; helper.Send(line); string reply helper.ReceiveText(); Console.WriteLine($reply: {reply}); } helper.Close(); } } }运行顺序很简单打开终端 A 执行dotnet run -- server再打开终端 B 执行dotnet run。在 B 输入任意字符串比如hello c# socket终端 A 会打印收到的字节数B 会收到ack回显。输入中文也能正常显示因为控制台输出编码已经固定为 UTF8。如果 reply 正常回来说明监听、握手、收发整条链路已经通了。此时可以试一下输入长度超过 4000 的文本会看到服务端被截断或者分两次读这属于预期行为不是代码坏了是拆包还没做。3.4 初始化参数表端口、backlog、缓冲区与超时参数推荐值说明端口9000避开常见系统服务客户端与服务端必须一致backlog10已完成握手但未 Accept 的连接队列上限bufferSize4096单次 Read 上限不等同业务帧长度连接超时3000ms用 BeginConnect WaitOne 实现收发超时3000ms对已建立连接生效超时抛 IOException如果是本地学习端口随意。要放到公司网络长期跑建议先和运维确认端口段避免跟其他系统撞上。连接超时 3 秒是一个比较稳的起点太短会在设备启动慢时误报离线太长会让重试机制失去意义。收发超时也一样先给 3 秒跑通再按设备实际响应时间调整。4. 踩坑排查C# Socket 最常见的 5 个“代码没错就是不工作”代码能跑通只算第一步。下面几类问题我在 Socket 项目里都遇到过现象看起来像玄学原因其实都在协议栈、编码或者系统设置里。按“监听地址、端口占用、防火墙、编码、粘包”这个顺序排查基本能覆盖多数场景。4.1 端口占用与 TIME_WAIT服务端第二次启动就报 SocketException现象第一次运行服务端正常改成代码重新编译后再次启动控制台抛出“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”或者 SocketException 10048。原因上一次进程没有退干净或者端口被别的服务占用了。还有一个常见来源是服务端主动 Close 后进入 TIME_WAIT 状态同一端口短期内无法重新绑定。解决先查端口归属再动手。在命令行执行netstat -ano | findstr 9000拿到 PID 后用tasklist /fi pid eq PID确认是什么进程。测试环境可以换一个端口立刻恢复。有人会用 SetSocketOption(ReuseAddress) 让重启更快不建议生产环境这么做它会掩盖“上一个进程还没退出”的真实故障。4.2 粘包拆包Read 一次收不到“一条消息”怎么办现象客户端连续发两条指令服务端一次 Read 同时收到两条或者一条大指令被拆成两次到达。打印日志时看到的内容和服务端发给客户端的完全对不上。原因TCP 对上层只承诺字节流不保证一次 Write 对应一次 Read。应用层如果按 Read 次数切分消息必然出现粘连和半包。解决应用层自己定义帧边界。最常见做法是“4 字节长度头 负载”服务端维护一块剩余缓冲区每次 Read 后按长度头切出完整帧using System; using System.Collections.Generic; using System.Linq; private byte[] _remain Array.Emptybyte(); public Listbyte[] TryDecodeFrames(byte[] chunk) { Listbyte[] frames new Listbyte[](); _remain _remain.Concat(chunk).ToArray(); while (_remain.Length 4) { // 先读长度头判断后续字节是否够一个整帧 int len BitConverter.ToInt32(_remain, 0); if (_remain.Length 4 len) { break; } byte[] frame _remain.Skip(4).Take(len).ToArray(); frames.Add(frame); _remain _remain.Skip(4 len).ToArray(); } return frames; }每次收到网络数据就把 chunk 交给 TryDecodeFrames返回值里的每个元素就是一条完整业务帧。长度不足就留在 _remain 里等下一次。BitConverter.ToInt32 默认解析小端序西门子 S7 和部分仪表用大端.NET 里要换成 BinaryPrimitives.ReadInt32BigEndian。这个写法适合中小协议高吞吐场景要改成偏移量裁剪避免频繁 Concat 产生新数组。4.3 断电无感服务端一直卡在 Read怎么知道设备已经离线现象设备断电或者网线被拔掉服务端控制台没有任何异常日志停在最后一条接收记录上程序还认为设备在线。原因TCP 没有物理链路检测。对端断电时不会发任何包服务端 Read 会一直阻塞既不会返回 0也不会抛异常。解决应用层做心跳。客户端每 3 秒发一个心跳帧服务端记录每个连接的最近接收时间连续 9 秒以上没收包就 Close。ReceiveTimeout 可以当一个辅助兜底单次 Read 超时会抛 IOException但它只能告诉你“这一读超时了”不能判断对端是否活着而且 3 秒超时对慢速设备会误杀。心跳字段要带设备号或连接 ID否则多客户端场景下不知道是哪个连接掉线。4.4 本机通、跨机不通防火墙、绑定地址与端口探测现象客户端用 127.0.0.1 连接服务端一切正常换成局域网 IP 后连接一直超时。原因最常见的是服务端监听地址绑在 127.0.0.1 上只接受回环连接其次是 Windows 防火墙默认拦截入站端口还有可能服务器有多个网卡客户端访问的 IP 不对。解决服务端监听改成 IPAddress.Any 并重新编译运行。客户端在服务器上用 ipconfig 确认当前有效 IP再用 PowerShell 执行Test-NetConnection 192.168.x.x -Port 9000代替 telnet 做端口探测。若网络层面通了但业务连不上去“高级安全 Windows Defender 防火墙”加一条 TCP 入站规则放行 9000。生产环境记得只对来源 IP 段开放别为了省事把整个入站关掉。4.5 编码错位与二进制协议UTF8 解 GBK 必然乱码现象设备传上来的中文字段变成乱码常见的是“锟斤拷”这类 UTF8 解码 GBK 的典型产物或者 ASCII 字符后跟了一串不认识的符号。原因设备按 GBK 或 ASCII 编码发送C# 端却用 Encoding.UTF8 解码字节和字符映射完全错位。很多工控设备固件是早期团队写的默认字符集停留在 GBK。解决协议文档里写清楚编码启动时从配置文件读 Encoding不要硬编码 UTF8。对于 S7、Modbus TCP 这类纯二进制协议不要先转字符串再解析用 MemoryStream 和 BitConverter 按偏移读字段。如果异常同时出现“远程主机强迫关闭了一个现有的连接”多半是两端协议字段理解不一致对端直接 RST 断开了连接。5. 进阶验证把“能跑通”变成“可靠通讯”的三个小习惯通讯模块能跑通之后下一步不是急着加业务而是做三轮验证。把这套验证固定成模板能省下大量后续联调时间。5.1 回环自测、跨机验证与断线测试一张表覆盖四种场景自测项操作通过标准回环收发本机起服务端和客户端发中文和长文本服务端收到客户端拿到 ack跨机连通客户端换成服务器内网 IP连接建立无超时粘包拆包客户端连续发 5 条短消息服务端按帧打印 5 条不串、不丢断线感知客户端运行中拔掉网线心跳超时后服务端打印断开日志本机回环只能证明代码逻辑对跨机才能暴露防火墙和网卡绑定问题。断线感知是最后一道关也是最容易被跳过的。5.2 把长度头固定成协议一部分发送和接收必须对称把第 4 章的 TryDecodeFrames 集成进客户端的发送逻辑发送方先拼 4 字节长度头再加负载接收方用同一套规则拆帧。长度头里存放的是负载字节数不是整个缓冲区的长度两端对大端小端的约定必须一致。这样以后设备协议变更只需要替换帧格式不碰 Socket 收发循环。5.3 心跳、超时、重连三件套通讯层交付前的最后一关客户端要加一个定时器每 3 秒发心跳帧服务端维护 LastReceiveTime超时即 Close。客户端收到断线通知后按递增退避重连比如 1 秒、2 秒、5 秒避免断线风暴打满服务端。这套组合做下来设备半夜断电服务端能在 30 秒内感知并摘掉离线连接第二天现场不会出现数据缺了整晚却无日志可查的局面。我有一年做过一个采集项目设备半夜掉电后服务端仍显示在线第二天现场反馈数据缺了整晚日志里找不出任何报错。从那以后我养成了一个习惯凡是交付通讯层必须先过“拔线之后能否感知”这一关再谈业务功能。这套验证模板帮我在后续好几个项目里避了雷希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表