资讯动态

C# Socket完整源码:从TCP通讯到心跳重连的工业级实现

发布时间:2026/10/8 19:05:37 来源:尧图企业网站定制
简介面向C#网络编程初学者的Socket通信完整源码包基于Windows窗体实现包含服务端与客户端两个可直接运行的项目。整体代码风格简洁逻辑清晰直接打开即可运行方便查看Socket建立连接、收发数据以及关闭连接的基本流程。压缩包共有61个文件其中包含14个C#源代码文件以及解决方案工程文件、可执行程序、调试符号、窗体资源文件等多种类型包体仅1.17MB轻量易用。目前已有2915人学习下载。这套源码既适合新手对照练习网络通信基础也可以继续扩展多客户端管理、消息加密、断线重连、文件传输等功能或作为课程设计、自研小工具的基础框架整体实用价值高。1. 为什么要找一份“简单、清楚”的C# Socket完整源码接手过上位机项目的人大多有过这样的经历网上搜“C# Socket通讯源码”下载下来要么是三年前的异步长文要么塞满了自定义类、事件委托、状态机注释还没看懂框架已经把人绕晕。真正到了现场对接PLC、读仪表扭矩值或者给产线设备做数据采集时你需要的不是架构表演而是一段打开就能编译、看懂就能改、跑起来能坚持一周不掉线的通讯底座。这个标题之所以打动人是因为它承诺了两件事源码是闭环的也就是能建连、能收发、能处理断开代码是清晰的也就是变量命名直白、流程不绕弯。这篇笔记就按这个标准把一套可运行的服务端/客户端拆开讲透从模型选型一路写到心跳、重连和抓包验证看完你完全可以照着搭进自己的WinForm上位机或工控服务里。2. 通讯模型先立住TCP选型与最小双向收发骨架2.1 为什么局域网设备通讯优先选TCP而不是UDP在做C# Socket网络编程时第一步就要把传输层协议定死。绝大多数上位机和设备通讯场景比如读取PLC变量、控制AGV、采集传感器数据要的是“这条消息一定送达、顺序不颠倒”。TCP天然保证这两点它有序列号、确认重传、拥塞控制链路断了会通知应用层而UDP是尽力而为丢包不通知、乱序不纠正适合视频流、广播发现这类容忍丢失的场景。用表格看更直接对比项TCPUDP可靠性可靠重传丢失数据不可靠可能丢包消息顺序保证按发送顺序到达不保证连接状态有连接需要握手/挥手无连接C#实现复杂度中等要处理粘包拆包低一条Datagram收发适用场景指令下发、数据采集、文件传输设备发现、心跳广播、音视频我见过有人在采集项目里用UDP发扭矩指令结果现场一开大功率电机丢两条报文设备就没反应了排查了两天才换成TCP。所以只要不是对实时性极端敏感、能容忍丢包的场景TCP是默认答案下面的源码也只讲TCP。2.2 TcpListener/TcpClient还是原生Socket我选前者的理由在.NET里做TCP有两条路直接操作Socket类或者用TcpListener配合TcpClient。原生Socket能让你拿到Send/Receive/Select底层控制适合做高并发网关、自定义协议栈但它的细节太多Bind、Listen、Accept、BeginAccept一长串新手很容易在某个环节漏掉一个方法导致黑匣子式报错。TcpListener和TcpClient是对Socket的封装内部还是那套机制但把地址复用、端口绑定、连接池都简化了暴露的Stream可以直接读字节配合NetworkStream非常顺手。对一个“简单、清楚”的源码来说我一般选用封装层理由很简单代码减少三分之一读代码的人不用去啃底层API而且封装类提供了IDisposable用using包住就能正确释放连接这对防止文件句柄泄漏极其重要。如果你将来要写几百上千连接的网关再回去用原生Socket也不迟上位机设备通讯这种一两百连接以内的场景TcpClient足够扛住。2.3 30行跑通本地回环消息最小服务端与客户端理论讲完先给一个不掺任何框架的最小例子让你确认本机Socket环境是通的。服务端监听9000端口来一个客户端就开一个后台线程回显一条欢迎消息// Server: 监听本机9000端口每来一个客户端开独立线程处理 using System.Net; using System.Net.Sockets; using System.Text; var listener new TcpListener(IPAddress.Any, 9000); listener.Start(10); // backlog10控制等待队列长度 Console.WriteLine($服务端已监听: {listener.LocalEndpoint}); while (true) { TcpClient client listener.AcceptTcpClient(); // 阻塞等待连接 Console.WriteLine($客户端接入: {client.Client.RemoteEndPoint}); Thread t new Thread(() { using (client) using (NetworkStream stream client.GetStream()) { byte[] data Encoding.UTF8.GetBytes(welcome); stream.Write(data, 0, data.Length); } }); t.IsBackground true; t.Start(); }这段代码有几个值得注意的参数和习惯IPAddress.Any表示监听所有网卡如果你的工控机有多张网卡且只想服务内网可以改成具体IPbacklog设10是给TCP握手还未完成但已经进来的连接排队用的不是最大连接数上线时不需要调太大。线程设IsBackground保证主进程退出时这些工作线程会被强制终止不会拖住进程无法关闭。顺便说一句如果你在服务启动时碰到“failed to create server shutdown socket on address [localhost] and port [802”这类日志多半是端口被占用或监听地址不可用原因和排查方法第四章节会细讲。客户端的代码更短// Client: 连接127.0.0.1:9000读取服务端发来的消息 using System.Net.Sockets; using System.Text; using (var tcp new TcpClient()) { tcp.Connect(127.0.0.1, 9000); // 同步连接超时默认由系统决定 using NetworkStream stream tcp.GetStream(); byte[] lenBuf new byte[1024]; int read stream.Read(lenBuf, 0, lenBuf.Length); Console.WriteLine($收到: {Encoding.UTF8.GetString(lenBuf, 0, read)}); }注意同步Connect在网络不通时会阻塞挺久生产环境我建议用ConnectAsync加超时控制后面的断线重连章节会给出完整写法。先把这个骨架跑通你就拥有了一套最原始的C# Socket网络通讯通道。3. 完整源码的核心消息封帧、发送与可靠接收3.1 帧格式先行4字节长度头加JSON消息体Socket通讯里最坑的不是建连而是消息边界。TCP是字节流你连续发两次Write对端可能一次Read全部收走你发一个很大的包对端可能分三次才读完整这就是粘包和拆包。要把业务消息从字节流里切出来必须自己做帧格式。最稳妥且好调试的做法就是“长度头”方案每个消息前面放4字节表示后面消息体有多少字节消息体里放什么格式可以自己定我强烈建议放JSON文本用System.Text.Json序列化这样上位机里看到的日志直接能读排查问题时眼睛就是最好的调试器而不用拿十六进制去猜含义。一个典型帧长这样| 4字节 小端 int32 长度 | N字节 UTF-8 JSON文本 |为什么强调小端因为BitConverter在Windows默认就是小端直接用就行。如果将来要对接嵌入式大端设备需要改成网络序但那是跨平台的问题纯Windows环境下不用操心。选JSON而不是二进制还因为消息字段可以演进旧客户端收到新字段能忽略新客户端收到缺省字段能补默认值不用担心序列号错位。3.2 发送端封帧把业务对象变成字节流我现在写发送代码时一律封装两个函数一个把对象转成frame一个把frame写进流里。这样业务层永远只接触对象不管字节后面换序列化方案也只有一个改动点// FrameBuilder.cs: 把任意对象封成 [4字节长度 JSON字节] public static byte[] MakeFrameT(T payload) { string json JsonSerializer.Serialize(payload); byte[] body Encoding.UTF8.GetBytes(json); // 长度头用小端int默认就是BitConverter的字节序 byte[] header BitConverter.GetBytes(body.Length); if (!BitConverter.IsLittleEndian) Array.Reverse(header); byte[] frame new byte[4 body.Length]; Buffer.BlockCopy(header, 0, frame, 0, 4); Buffer.BlockCopy(body, 0, frame, 4, body.Length); return frame; } // NetworkStreamExtensions.cs: 将frame安全写入并清空发送缓冲 public static void WriteFrame(this NetworkStream stream, byte[] frame) { stream.Write(frame, 0, frame.Length); stream.Flush(); // 把应用层缓冲区数据推向网卡但不保证立即发出 }这里的Flush很关键虽然NetworkStream的缓冲区几乎不缓存内容但养成Flush的习惯能防止以后换成BufferedStream时出现“数据没发出去”的玄学问题。要注意的坑是帧长度用checked转int如果JSON超过2GB那纯属设计错误正常业务包几千字节int足够。实际项目中我还会在MakeFrame里限制最大长度超过10MB直接抛异常防止内存被打爆。3.3 接收端拆帧先读长度再循环读够消息体接收比发送难在“必须读满指定长度”。NetworkStream.Read的一次调用并不保证读够你要求的字节数尤其是大包或网络拥堵时。所以需要一个ReadExactly函数循环直到凑满// NetworkStreamExtensions.cs: 核心拆帧函数一次返回一条完整消息 public static string ReadMessage(this NetworkStream stream) { byte[] lenBuf new byte[4]; ReadExactly(stream, lenBuf, 4); // 先读4字节长度头 int len BitConverter.ToInt32(lenBuf, 0); // 获取消息体长度 if (len 0 || len MAX_MESSAGE_SIZE) throw new InvalidDataException($非法帧长度: {len}); byte[] body new byte[len]; ReadExactly(stream, body, len); // 再循环读满body return Encoding.UTF8.GetString(body); } private static void ReadExactly(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int bytesRead stream.Read(buffer, offset, count - offset); if (bytesRead 0) throw new EndOfStreamException(连接已被对端关闭); offset bytesRead; } }逻辑说明外层先读4字节长度头然后根据这个长度申请buffer再用ReadExactly去读。ReadExactly里每轮Read都会把当前读到的字节数累加到offset上直到填满整个count。如果哪次Read返回0说明对端正常关闭连接FIN包已经送达这时候不用继续等数据直接抛EndOfStreamException让上层清理资源。这个处理是整个“清楚”源码的核心很多网上的半吊子代码只读一次就丢给解析器碰上拆包直接Json解析失败然后你怀疑是不是对方的设备发错了数据排查一整天才发现是自己读取逻辑有缺陷。如果你要做的是高并发服务端这里可以把同步循环改成async用ReadAsync配合ValueTask但原理完全一样先ReadExactly拆帧再交给业务线程。同步版本虽然占线程但在连接量小于200的上位机场景里反而更好排查因为调用栈清晰断点一打就知道卡在哪一行。3.4 把消息安全送到UI线程跨线程更新是上位机的必修课C#上位机里服务端收到设备数据后通常要刷新WinForm/WPF界面比如把扭矩值显示到一个TextBox。但直接在Socket回调线程里操作控件会抛InvalidOperationException因为控件拥有自己的线程上下文。我常用的做法是封装一个UI转发器// UiDispatcher.cs: 把跨线程调用统一转到UI线程执行 public static class UiDispatcher { public static void Post(Control control, Action action) { if (control.IsDisposed) return; // 控件已被销毁丢掉本次更新 if (control.InvokeRequired) control.BeginInvoke(action); // 异步投递不阻塞Socket接收线程 else action(); // 本就属于UI线程直接执行 } }参数上说明一下BeginInvoke是异步的适合高频刷新场景比如每秒50条设备数据不会因为UI卡顿拖累Socket接收但如果业务逻辑要求“下一条数据的处理必须等到上一条UI更新完成”那就改用Invoke同步等待。很多新手把InvokeRequired判断漏掉直接BeginInvoke结果在UI线程本身调用时反而报错。把这段代码融入第3章的接收循环里你就拥有了一套从上位机UI到Socket链路的完整闭环这也是“完整源码”不只是一个控制台CtrlC/V的原因。4. 常见问题排查这套Socket代码最容易翻车的5个坑与对策4.1 端口被占用你看到的“每个套接字地址(协议/网络地址/端口)只允许使用一次”现象服务端重开时报SocketException提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次。”还有可能是another process。这是C#上位机里最高频的启动报错。原因要么上一个进程还没完全退出端口还被占用着要么上次异常退出后TCP进入TIME_WAIT状态要等几十秒到几分钟才能重新绑定。Windows对TIME_WAIT的回收时间默认为240秒反复改代码跑调试时特别容易踩。解决先查占用再在代码层面放开地址复用。我一般用两个命令先定位netstat -ano | findstr :9000 tasklist /FI PID eq pid确认是残留进程直接taskkill /PID /F。如果只是TIME_WAIT代码里在listener.Start前加一行listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start(10);这行必须在Start之前调用否则不生效。注意加了ReuseAddress后如果同一端口上有两个进程都在监听后来的会成功但收不到连接所以排错时不要只看“没报错”就完事。4.2 粘包半包解析翻车收到不完整的JSON现象设备上报数据JsonDocument.Parse偶尔报“不应包含任何其他根级别对象”或者读到的字符串开头是上一帧的残尾。原因TCP传输是按段发送的对端一条消息可能拆成两个TCP包到达与此同时多条小消息又可能合并进同一个TCP段。如果接收端只做一次Read必然遇到半包或粘包。解决严格按照3.2的帧格式处理不读长度头就不读数据。这里补充一个现场经验如果粘包问题只在业务量大的时段出现先检查发送端是不是用了多个线程同时往同一个NetworkStream写数据。Stream.Write本身不是线程安全的多线程并发写会让字节交叉再好的拆帧逻辑都救不回来。必须用lock锁住WriteFrame调用或者给每个连接单独一个发送线程加队列。4.3 Read方法卡死与返回0的区别断线判定马虎不得现象客户端拔了网线服务端的Read一直不返回线程卡住像死了一样或者客户端正常关闭服务端在循环里读到0却还在继续解析。原因Read阻塞是常态它要等数据到达才返回对端直接断电拔网线时TCP没有机会发FIN所以阻塞Read可能一直等下去。Read返回0则是对端已发送FIN并关闭连接的信号不是网络故障是正常关闭。解决给NetworkStream设置读超时超时后抛IOException让上层重连stream.ReadTimeout 10_000; // 10秒没有数据就超时然后在一个高频调用Read的接收线程里把位图超时当成断开处理。判断断线时不要用TcpClient.Connected属性它是直接读socket底层状态在TCP已经断开但没检测到时依然返回true是用来判断“上次操作是否失败”的不是实时状态。正确做法是Read返回0视为正常关闭Read抛IOException视为网络异常业务层再用心跳兜底。4.4 一个线程只处理一个连接第二个客户端连上就把第一个搞卡现象服务端用AcceptTcpClient后直接在循环里Read第二个客户端连上时第一个客户端不响应了。原因主线程在第二次Accept后代码路径只有一个处理第一个连接的Read阻塞了主流程导致无法接受新客户端。解决每个客户端必须独立线程或者用异步Accept最简单的方式就是2.3节里的写法接受连接后new Thread处理事务同时记录该线程句柄便于后续统一关闭。一个额外的经验控制线程数量每来一个客户端就开线程在内网几十个连接没问题但如果可能被大量短连接攻击请改用SemaphoreSlim限制最大并发处理数。4.5 跨线程更新UIInvoke用不对界面直接无响应现象Socket回调里给ListView加行有时抛线程间操作异常程序退出有时界面模糊了一下才刷新像是卡在垃圾回收。原因回调线程不是UI线程直接操作控件句柄没有约束。丢异常算好的更可怕的是WinForm里有个隐藏开关会把跨线程访问当作非法操作关闭检查后干脆直接崩溃。解决使用3.4节的UiDispatcher另一个稳健措施是不要在UI线程里做封帧解析接收线程只负责把原始字符串塞进并发队列UI线程用定时器从队列里取数据渲染。Queue 加锁或者直接上Channel 既有背压又有缓存适配高频设备数据。这个方案比反复Invoke更优雅也是C#上位机开发中应对大量实时数据的主流玩法。5. 产线级加固心跳保活、断线重连与多客户端管理5.1 应用层心跳比TCP KeepAlive更可控TCP有一个KeepAlive选项但默认要两小时才开始探测且Windows上对空闲连接只发探测包、不感知业务状态。对设备通讯来说你需要的是“业务层保活链路”因此双方约定一个心跳消息最实在。心跳包就两行内容// 心跳消息体定义 public class HeartbeatMessage { public string Type { get; set; } Ping; public DateTime SentUtc { get; set; } }用System.Text.Json序列化后走正常的帧格式发送不要单独拆另一套收发逻辑。心跳间隔不是拍脑袋定的设备侧一般设5秒服务端15秒没收到任何消息就判定这个连接死亡。这个比例能容忍一次网络抖动又不会让踢线判定太慢。注意心跳消息要统一走同一个接收循环服务端把它当成一次活跃事件即可不需要单独拉线程处理。5.2 服务端超时踢线别让死连接占着资源服务端每收到一条消息就更新该客户端的LastActiveUtc然后由一个后台线程定期扫描超时连接发现超时就关闭。代码逻辑如下// ServerHealthChecker.cs: 每5秒扫描一次死连接 private static readonly DictionaryTcpClient, DateTime _lastSeen new(); private static readonly object _locker new(); public static void Touch(TcpClient client) { lock (_locker) _lastSeen[client] DateTime.UtcNow; } public static void StartMonitoring() { new Thread(() { while (true) { Thread.Sleep(5000); var now DateTime.UtcNow; ListTcpClient dead new(); lock (_locker) { foreach (var kvp in _lastSeen) { if ((now - kvp.Value).TotalSeconds 15) dead.Add(kvp.Key); } foreach (var client in dead) _lastSeen.Remove(client); } foreach (var client in dead) { try { client.Close(); } catch { /* 重复关闭可以忽略 */ } Console.WriteLine($已清理超时连接: {client.Client.RemoteEndPoint}); } } }) { IsBackground true }.Start(); }重点在于处理方式先用List收集超时对象再统一关闭避免在遍历Dictionary的同时修改。Close可能会抛异常因为是网络操作必须包try catch而且重复Close是安全的。如果你在监控线程里关闭了一个正在Read的客户端那边会立刻抛ObjectDisposedException这是预期行为不是错误接收线程要捕获它当作正常退出信号。5.3 客户端断线重连指数退避是血泪经验客户端在断线后立即重连在网络还没恢复时会反复触发Connect超时每个线程都满频率试可能把服务端打到过载。我一般用指数退避// ReconnectingClient.cs: 断线后指数退避重连 private static void RunWithReconnect() { int retrySeconds 1; while (!_shutdown) { try { using var tcp new TcpClient(); tcp.Connect(_host, _port); Console.WriteLine($连接成功: {_host}:{_port}); retrySeconds 1; // 连接成功重置退避 using NetworkStream stream tcp.GetStream(); // 业务循环里做ReadMessage/WriteFrame遇到异常跳到外层catch while (!_shutdown) { string msg stream.ReadMessage(); Console.WriteLine($收到: {msg}); } } catch (EndOfStreamException) { Console.WriteLine(服务端已正常关闭连接等待重连...); } catch (SocketException ex) { Console.WriteLine($连接异常: {ex.SocketErrorCode}); } catch (IOException ex) { Console.WriteLine($读超时或IO错误: {ex.Message}); } if (_shutdown) break; Console.WriteLine($将在 {retrySeconds}s 后重连...); Thread.Sleep(retrySeconds * 1000); retrySeconds Math.Min(retrySeconds * 2, 30); // 上限30秒 } }要点说明Connect成功一次就重置retrySeconds让正常运营时恢复最快响应失败时1、2、4、8秒翻倍到30秒封顶避免长期无脑轰炸。捕获异常分三类EndOfStreamException是对方正常关闭SocketException是网络层错误IOException可能是超时。你这样分层抓日志里就能直接看出是“对端挥手”还是“链路故障”不用再去猜。这是我最推荐照抄的一段代码因为它同时解决了阻塞线程安全退出和重连风暴两个难题。5.4 多客户端管理用一个会话对象封装状态当有十几个设备同时接入时裸用DictionaryTcpClient, DateTime会越来越乱。我习惯把每个连接封装成ClientSession把流、缓冲区、最后活跃时间、业务心跳都放进去// ClientSession.cs: 一个连接一个实例状态清晰 public class ClientSession { public TcpClient Tcp { get; } public NetworkStream Stream { get; } public DateTime LastActiveUtc { get; set; } public byte[] Buffer { get; } new byte[4096]; public ClientSession(TcpClient tcp) { Tcp tcp; Stream tcp.GetStream(); LastActiveUtc DateTime.UtcNow; Stream.ReadTimeout 10_000; } }服务端用ConcurrentDictionarystring, ClientSession按设备编号或连接ID管理这样踢线、广播、查询状态都有明确入口。Buffer大小默认4096字节如果你的消息体平均大2KB以上直接调大到8192或16384避免接收时多次重新分配反之如果设备只上报几百字节4096够了太大反而浪费内存。到这一步从建连、封帧、拆帧、心跳到重连的多客户端管理全部闭环这才算配得上“完整源码”四个字。6. 验证与调试技巧压测脚本、抓包观察与日志习惯6.1 做好这层验证再开喝酒每写完一套通讯代码我习惯先跑一轮10万条消息压测。客户端开一个线程循环发100000条包含随机业务ID的JSON消息服务端收到后校验ID能对上连续性。这一步能暴露并发写冲突、漏读、缓冲区不够等问题。你自己的测试工程就照这个思路写成一个控制台对发数据量从一万起步加到一百万观察内存占用和CPU。6.2 抓包不是玄学是必要手段如果压测过了但现场偶发问题别猜用Wireshark抓回环或局域网包。选接口时本机通讯要选Loopback虚拟网卡局域网通讯选实际物理网卡过滤表达式就是tcp.port 9000。看三个核心现象TCP三次握手颜色标记、PSH标志位是否频繁说明小包多、连接关闭时FIN/RST是哪个方向发起。RST包出现几乎必有问题——往往是某端试图往已关闭的socket写数据。6.3 日志和超时参数的黄金组合留一份带时间戳的收发日志是我见过最划算的习惯。每收到一条消息写一行[时间] [设备ID] [消息类型] [数据]发送同样记录。出问题时对比日志和抓包时间线两分钟就能定位是发送端没发、还是服务端没收、还是中途丢段。超时参数也一样不要同时把收发超时设成同一个值实际发送超时通常比接收短因为发送失败很快接收超时拉长一点。最后说个我的个人教训曾经图省事没写日志现场设备半小时掉线一次最后靠WireShark抓了三小时包才发现是我自己发送线程里用了Async并发了多条写操作顺序被打乱导致粘帧。从那以后日志和抓包成了我上线的必修课希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑