工业级C# TCP Server实战:高可靠连接管理与PLC通信
发布时间:2026/9/29 1:10:01来源:尧图网络
简介这是一份面向C#初学者与网络编程入门者的TCP服务器端开发实践资源聚焦Socket底层通信原理与服务端逻辑实现适用于课程设计、毕业设计及轻量级网络应用开发场景。压缩包共26个文件含7个核心C#源码文件如Program.cs、P2PServer.cs等、1个Visual Studio解决方案文件.sln和1个项目配置文件.csproj辅以编译生成的exe可执行文件、pdb调试符号及resx/resources资源文件整体仅50KB结构精简、无冗余依赖。已有343人学习下载资源完整呈现了基于.NET Framework的同步TCP服务器构建流程包括监听、连接管理、消息收发与异常处理等关键环节目录层级清晰便于理解VS工程组织方式与Socket编程典型模式是快速上手C#网络编程的实用参考范例。1. C# TCP Server 不是“写个 TcpListener 就完事”它得扛住工业现场的断连风暴、客户端乱序重连、心跳超时误判还得让上位机工程师能直接拿来改参数、加日志、接PLC——这才是标题里那个.rar包真正该干的事你解压TCPServer.rar双击TcpServer.exe控制台一闪而过或者用 Visual Studio 打开TcpServer.csproj发现Program.cs里只有三行TcpListenerAcceptTcpClient()while(true)循环读取——这绝不是工业级 C# TCP Server 的终点而是翻车起点。真实产线里西门子 PLC 每隔 30 秒发一次心跳包但偶尔丢帧HMI 上位机在 Win10 睡眠唤醒后 TCP 连接状态变成CLOSE_WAIT却不主动断开多客户端并发写入时NetworkStream.Read()返回 0 却没触发Client.Close()……这些不是“异常”是常态。本篇不讲抽象 Socket 原理只聚焦一个目标用 C# 写出能塞进工厂机柜、7×24 小时跑、运维能看懂日志、调试能定位到具体哪条指令卡死的 TCP 通信服务器端程序。适合正在做 C# 上位机、设备数据采集、PLC 数据桥接、或需要把旧 VB6/VC TCP 服务迁移到 .NET Core 的一线工程师。所有代码基于 .NET 6兼容 .NET Framework 4.7.2不依赖第三方库核心逻辑全部手写可直接编译部署。2. 从TcpListener到可维护 TCP Server为什么必须放弃“单线程 Accept 阻塞 Read”模式2.1 工业场景下阻塞式 TCP Server 的三大致命缺陷很多初学者写的 C# TCP Server本质是把 Java 或 Python 的“主线程 accept 新线程 handle”模型硬搬过来用TcpListener.Start()启动监听listener.AcceptTcpClient()阻塞等待连接再Task.Run(() HandleClient(client))把客户端扔进线程池。这种写法在实验室测 5 个客户端没问题一上产线就崩连接堆积阻塞主线程当 100 台设备同时上电重连AcceptTcpClient()被卡住新连接请求排队在 TCP backlog 队列里超过SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Backlog, 100)设置值后直接被内核丢弃设备反复重试导致网络风暴客户端断连无感知NetworkStream.Read()在对方异常断网时可能长时间阻塞默认无限期或返回 0 但不抛异常导致HandleClient线程卡死内存泄漏最终ThreadPool耗尽无心跳保活与超时控制PLC 发送数据间隔不固定若某客户端 5 分钟没发包服务端无法主动清理其TcpClient实例TcpClient.Client.Connected属性永远返回true这是 Windows Socket 的经典玄学连接句柄持续占用TIME_WAIT状态堆积。提示TcpClient.Connected是个“快照值”只反映上次 I/O 操作后的连接状态不能用于实时判断连接是否存活。必须配合心跳包或Socket.Poll()主动探测。2.2 正确选型TcpListener 异步 Accept SocketAsyncEventArgs池化复用.NET 提供两套底层 Socket API高层封装TcpListener/TcpClient—— 语法简洁适合快速原型但AcceptTcpClientAsync()仍会创建新TcpClient实例对象分配压力大底层高性能Socket类 SocketAsyncEventArgs—— 零分配、事件驱动、支持缓冲区池化是工业级服务的标配。我们选择TcpListener作为监听入口语义清晰、避免裸 Socket 复杂初始化但内部处理全部下沉到Socket层用SocketAsyncEventArgs管理接收/发送缓冲区。这样既保留上位机工程师熟悉的TcpListener接口又获得底层性能控制能力。关键设计原则Accept 异步化用listener.BeginAcceptSocket()或AcceptSocketAsync()避免主线程阻塞Client 生命周期显式管理每个连接对应一个ClientSession对象持有Socket、ReceiveArgs、SendArgs、心跳计时器、数据解析上下文接收缓冲区预分配池避免高频new byte[1024]导致 GC 压力用ArrayPoolbyte.Shared.Rent(8192)复用发送队列 异步发送ClientSession内置ConcurrentQueuebyte[]存待发数据SendAsync()完成后才出队防止Send()调用阻塞。2.3 最小可运行骨架监听启动与异步 Accept 循环// Program.cs using System; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; class Program { static async Task Main(string[] args) { var listener new TcpListener(IPAddress.Any, 8080); listener.Start(); Console.WriteLine($TCP Server started on port 8080); // 启动异步 Accept 循环 await AcceptLoop(listener); } static async Task AcceptLoop(TcpListener listener) { while (true) { try { // 使用异步 Accept不阻塞主线程 var client await listener.AcceptTcpClientAsync(); // 启动独立任务处理客户端避免 Accept 被阻塞 _ Task.Run(() HandleClient(client)); } catch (ObjectDisposedException) { // listener.Stop() 时抛出正常退出 break; } catch (Exception ex) when (ex is SocketException or IOException) { // 网络临时错误记录后继续 Console.WriteLine($Accept error: {ex.Message}); await Task.Delay(100); // 避免忙等 } } } static void HandleClient(TcpClient client) { try { // 关键设置客户端 Socket 选项 var socket client.Client; socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, 30000); // 30秒无数据则超时 socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.SendTimeout, 30000); // 创建 ClientSession 并启动接收循环 var session new ClientSession(socket); session.StartReceive(); } catch (Exception ex) { Console.WriteLine($Handle client failed: {ex}); client?.Close(); } } }这段代码已解决“Accept 阻塞”问题但HandleClient仍是同步调用。真正的工业级实现需将ClientSession改为完全异步见 3.2 节。此处仅展示骨架重点在于SetSocketOption的三个关键参数KeepAlivetrue启用 TCP KeepAlive操作系统自动发送探测包默认 2 小时后开始间隔 1 秒发 10 次ReceiveTimeout30000NetworkStream.Read()超过 30 秒无数据则抛IOException可捕获并关闭连接SendTimeout30000同理防止发送卡死。注意ReceiveTimeout和SendTimeout是Socket层设置对NetworkStream生效TcpClient.ReceiveTimeout是包装属性效果相同但推荐直接设Socket。3. ClientSession工业级连接管理的核心——心跳、超时、粘包、断连自愈3.1 心跳机制不是“发个 ping 就完事”而是双向状态同步与超时分级工业设备如西门子 S7、三菱 FX 系列的 TCP 通信协议通常要求服务端主动发起心跳例如每 15 秒发0x00 0x01客户端回0x00 0x02。但更常见的是客户端定时发心跳服务端只响应 ACK。无论哪种心跳必须与业务数据流隔离且超时判定要分层网络层心跳靠Socket.Poll()检测底层连接状态SelectMode.SelectRead100ms 内返回false表示连接已断协议层心跳解析业务数据时识别心跳包更新LastHeartbeatAt时间戳应用层超时DateTime.Now - LastHeartbeatAt 45s则判定客户端失联主动关闭。ClientSession类结构如下public class ClientSession { private readonly Socket _socket; private readonly Timer _heartbeatTimer; private readonly Timer _timeoutTimer; private DateTime _lastHeartbeatAt DateTime.UtcNow; private readonly object _lock new object(); public ClientSession(Socket socket) { _socket socket; // 心跳检测定时器每 5 秒检查一次 _heartbeatTimer new Timer(CheckHeartbeat, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); // 超时清理定时器每 30 秒扫描一次 _timeoutTimer new Timer(CheckTimeout, null, TimeSpan.Zero, TimeSpan.FromSeconds(30)); } private void CheckHeartbeat(object state) { try { // Poll 检测 Socket 是否可读即是否有数据或已断开 if (!_socket.Poll(100000, SelectMode.SelectRead)) return; // 100ms // 如果可读但 Read 返回 0说明对端已关闭 var buffer new byte[1]; int received _socket.Receive(buffer, SocketFlags.Peek); if (received 0) { Close(Socket.Poll detected disconnect); return; } } catch (SocketException ex) when (ex.SocketErrorCode SocketError.ConnectionReset || ex.SocketErrorCode SocketError.Shutdown || ex.SocketErrorCode SocketError.NotConnected) { Close($SocketException: {ex.SocketErrorCode}); } catch (ObjectDisposedException) { // Socket 已关闭 Close(Socket disposed); } } private void CheckTimeout(object state) { var elapsed DateTime.UtcNow - _lastHeartbeatAt; if (elapsed.TotalSeconds 45) // 超过45秒无心跳 { Close($No heartbeat for {elapsed.TotalSeconds:F1}s); } } public void UpdateHeartbeat() _lastHeartbeatAt DateTime.UtcNow; public void Close(string reason) { lock (_lock) { if (_socket?.Connected true) { try { _socket.Shutdown(SocketShutdown.Both); } catch { /* ignore */ } finally { _socket?.Close(); } } _heartbeatTimer?.Dispose(); _timeoutTimer?.Dispose(); Console.WriteLine($Client closed: {reason}); } } }这个实现比单纯TcpClient.Connected可靠得多Poll()是非阻塞系统调用100ms 内即可判断连接是否存活Receive(..., SocketFlags.Peek)不消耗数据只探查是否有数据可读若返回 0 则确认对端 FIN。3.2 异步接收循环用SocketAsyncEventArgs解决粘包与内存分配TCP 是字节流协议Receive()返回的字节数不等于一条完整消息长度。常见协议如 Modbus TCP、自定义二进制协议都需按包头长度字段如前 2 字节表示后续数据长度拼包。ClientSession必须实现接收缓冲区复用避免new byte[8192]频繁分配粘包拆包状态机维护ReceiveBuffer、BytesReceived、ExpectedLength异步接收链式调用ReceiveAsync()完成后立即发起下一次不阻塞线程。public class ClientSession { private readonly Socket _socket; private readonly SocketAsyncEventArgs _receiveArgs; private readonly byte[] _receiveBuffer; private int _bytesReceived; private int _expectedLength; private readonly Listbyte _messageBuffer new(); // 临时拼包用 public ClientSession(Socket socket) { _socket socket; _receiveBuffer ArrayPoolbyte.Shared.Rent(8192); _receiveArgs new SocketAsyncEventArgs { SetBuffer(_receiveBuffer, 0, _receiveBuffer.Length), UserToken this }; _receiveArgs.Completed OnReceiveCompleted; } public void StartReceive() { if (_socket.Connected) { bool willRaiseEvent _socket.ReceiveAsync(_receiveArgs); if (!willRaiseEvent) // 同步完成 { ProcessReceive(_receiveArgs); } } } private void OnReceiveCompleted(object sender, SocketAsyncEventArgs e) { ProcessReceive(e); } private void ProcessReceive(SocketAsyncEventArgs e) { if (e.BytesTransferred 0 e.SocketError SocketError.Success) { // 将接收到的数据追加到 messageBuffer _messageBuffer.AddRange(_receiveBuffer.Take(e.BytesTransferred)); // 解析完整消息示例前2字节为长度后续为数据 while (_messageBuffer.Count 2) { var lenBytes _messageBuffer.Take(2).ToArray(); _expectedLength BitConverter.ToUInt16(lenBytes, 0); if (_messageBuffer.Count 2 _expectedLength) { // 提取完整消息不含长度头 var message _messageBuffer.Skip(2).Take(_expectedLength).ToArray(); HandleMessage(message); // 移除已处理部分 _messageBuffer.RemoveRange(0, 2 _expectedLength); } else { break; // 还没收全等待下次接收 } } } else if (e.SocketError SocketError.ConnectionReset || e.SocketError SocketError.Shutdown) { Close(Remote closed connection); return; } else { Close($Receive error: {e.SocketError}); return; } // 发起下一次接收 if (_socket.Connected) { _receiveArgs.SetBuffer(0, _receiveBuffer.Length); bool willRaiseEvent _socket.ReceiveAsync(_receiveArgs); if (!willRaiseEvent) ProcessReceive(_receiveArgs); } } private void HandleMessage(byte[] data) { // 解析业务数据例如 Modbus 功能码、JSON 指令等 // 更新心跳时间戳 UpdateHeartbeat(); // 示例回传 ACK var ack new byte[] { 0x00, 0x02 }; SendAsync(ack); } public void SendAsync(byte[] data) { var sendArgs new SocketAsyncEventArgs(); var sendBuffer new byte[data.Length]; Array.Copy(data, sendBuffer, data.Length); sendArgs.SetBuffer(sendBuffer, 0, sendBuffer.Length); sendArgs.UserToken sendBuffer; // 防止 GC 回收 sendArgs.Completed (s, e) { if (e.SocketError ! SocketError.Success) { Console.WriteLine($Send failed: {e.SocketError}); } // 缓冲区使用完毕可回收若用 ArrayPool ArrayPoolbyte.Shared.Return(sendBuffer); }; bool willRaiseEvent _socket.SendAsync(sendArgs); if (!willRaiseEvent) // 同步完成 { sendArgs.Completed?.Invoke(null, sendArgs); } } }关键点_receiveBuffer用ArrayPoolbyte.Shared.Rent()分配ProcessReceive结束后应Return()但此处为简化未写实际项目需在Close()中统一归还HandleMessage中调用UpdateHeartbeat()确保协议层心跳更新SendAsync中UserToken sendBuffer是为了在回调中持有缓冲区引用防止 GC 提前回收——这是SocketAsyncEventArgs的经典踩坑点。3.3 多客户端并发安全ConcurrentDictionary管理 Session 与线程安全日志当 200 设备同时连接ClientSession实例需全局注册以便广播、查询、强制下线。必须用线程安全集合public static class SessionManager { private static readonly ConcurrentDictionarystring, ClientSession _sessions new ConcurrentDictionarystring, ClientSession(); public static void Add(string clientId, ClientSession session) _sessions.TryAdd(clientId, session); public static bool Remove(string clientId) _sessions.TryRemove(clientId, out _); public static IEnumerableClientSession GetAll() _sessions.Values; public static void Broadcast(byte[] data) { foreach (var session in _sessions.Values.ToList()) // ToList() 避免枚举时修改 { try { session.SendAsync(data); } catch { // 发送失败移除会话 _sessions.TryRemove(session.Id, out _); } } } }日志也需线程安全。不要用Console.WriteLine多线程下输出错乱改用TextWriter.Synchronized(Console.Out)或更推荐的StreamWriterlockprivate static readonly object _logLock new object(); private static readonly StreamWriter _logWriter new StreamWriter(tcpserver.log, true) { AutoFlush true }; public static void Log(string message) { lock (_logLock) { _logWriter.WriteLine($[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}); } }4. 避坑指南C# TCP Server 在工业现场踩过的 5 个血泪坑4.1 现象客户端频繁断连重连服务端日志显示SocketException: An existing connection was forcibly closed by the remote host原因Windows 默认 TCP KeepAlive 参数过于保守2 小时后才发第一个探测包而 PLC 或嵌入式设备休眠后直接切断物理连接不发 FIN 包服务端长期认为连接有效。解决手动设置 KeepAlive 参数启用快速探测// 在 ClientSession 构造函数中 var keepAliveTime TimeSpan.FromMinutes(1); // 1分钟后开始探测 var keepAliveInterval TimeSpan.FromSeconds(5); // 每5秒发一次 var keepAliveBytes new byte[] { BitConverter.GetBytes((uint)1), // Enable BitConverter.GetBytes((uint)keepAliveTime.TotalMilliseconds), BitConverter.GetBytes((uint)keepAliveInterval.TotalMilliseconds) }; _socket.IOControl(IOControlCode.KeepAliveValues, keepAliveBytes, null);4.2 现象运行 24 小时后OutOfMemoryExceptionWindbg 分析显示大量byte[]和SocketAsyncEventArgs未释放原因SocketAsyncEventArgs的SetBuffer()方法若传入栈上数组或未托管内存GC 无法回收更常见的是忘记调用ArrayPoolbyte.Shared.Return()归还缓冲区。解决所有SocketAsyncEventArgs缓冲区必须来自ArrayPoolbyte并在Close()或ProcessReceive结束时明确归还// 在 ClientSession.Close() 中 if (_receiveBuffer ! null) { ArrayPoolbyte.Shared.Return(_receiveBuffer); _receiveBuffer null; }4.3 现象西门子 S7 PLC 连接后NetworkStream.Read()总是返回 0但Socket.Connected为 true原因S7 协议握手完成后PLC 不会主动发数据服务端若只等Read()返回数据就会卡死且Connected属性不可信。解决必须结合Poll()Peek探测如 3.1 节所示对 S7 等设备启动后立即发送COTP连接请求包而非被动等待。4.4 现象多客户端并发发送大数据64KB部分客户端SendAsync()报SocketError.NoBufferSpaceAvailable原因Windows 默认SO_SNDBUF太小8KB大文件发送时内核发送缓冲区满。解决增大发送缓冲区并启用NoDelay禁用 Nagle 算法_socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.SendBuffer, 256 * 1024); _socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true);4.5 现象服务端重启后原客户端重连失败Wireshark 显示 RST 包原因客户端处于TIME_WAIT状态默认 4 分钟服务端新进程尝试绑定同一端口被拒绝或客户端缓存了旧连接信息。解决服务端启动时设置SO_REUSEADDR允许 TIME_WAIT 端口重用_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);同时客户端需实现指数退避重连1s, 2s, 4s, 8s...避免雪崩。5. 工业落地必备配置化、日志追踪、PLC 协议适配与上位机对接技巧5.1 配置文件驱动把 IP、端口、心跳间隔、缓冲区大小全抽到appsettings.json硬编码IPAddress.Any和8080是运维噩梦。.NET 6原生支持IConfiguration配置项应覆盖所有可调参数// appsettings.json { TcpServer: { ListenAddress: 0.0.0.0, Port: 8080, Backlog: 100, ReceiveBufferSize: 8192, SendBufferSize: 262144, HeartbeatIntervalSeconds: 15, MaxConnectionTimeoutSeconds: 45, LogLevel: Information }, Logging: { LogLevel: { Default: Information, System: Warning, Microsoft: Warning } } }加载方式var builder WebApplication.CreateBuilder(args); var config builder.Configuration.GetSection(TcpServer); var listenAddress IPAddress.Parse(config[ListenAddress]); var port int.Parse(config[Port]); var heartbeatSec int.Parse(config[HeartbeatIntervalSeconds]); var listener new TcpListener(listenAddress, port); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Backlog, int.Parse(config[Backlog]));这样运维人员只需改 JSON 文件无需重新编译。5.2 日志结构化用Serilog替代Console.WriteLine支持按客户端 ID 追踪Console.WriteLine日志无法关联同一客户端的多次操作。Serilog可注入ClientSession.Id作为日志上下文// Program.cs Log.Logger new LoggerConfiguration() .WriteTo.Console(outputTemplate: [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}) .CreateLogger(); // ClientSession.cs public class ClientSession { public string Id { get; } Guid.NewGuid().ToString(N).Substring(0, 8); public void LogInformation(string message) LogContext.PushProperty(ClientId, Id).Information(message); public void LogError(Exception ex, string message) LogContext.PushProperty(ClientId, Id).Error(ex, message); }日志输出形如[14:22:03 INF] [ClientId: a1b2c3d4] Client connected from 192.168.1.100:50234 [14:22:18 INF] [ClientId: a1b2c3d4] Received heartbeat [14:23:03 ERR] [ClientId: a1b2c3d4] Send failed: ConnectionReset运维可通过ClientId在 ELK 或 Seq 中一键追踪该设备全生命周期。5.3 PLC 协议适配西门子 S7、Modbus TCP 的最小解析模板标题中热词含 “c#连接西门子opc”但 OPC 是另一层协议。这里聚焦最常用的S7 协议直连非 OPC UA和Modbus TCP协议连接特征心跳包格式数据解析要点S7三次握手后发Job请求建立 PDU无标准心跳需周期发ReadSZLPDU 长度在第 2-3 字节数据在第 7 字节后Modbus TCP连接即可用无握手客户端发00 01 00 00 00 06 01 03 00 00 00 02事务ID前2、协议ID2-3、长度4-5、单元ID6、功能码7HandleMessage中可按前缀判断协议private void HandleMessage(byte[] data) { if (data.Length 2) return; // S7 协议PDU 类型在第 1 字节0x01Job, 0x02Response if (data[0] 0x01 || data[0] 0x02) { ParseS7(data); } // Modbus TCP事务ID 协议ID 0x0000 else if (data.Length 7 BitConverter.ToUInt16(data, 0) 0) { ParseModbus(data); } else { // 自定义协议如前2字节为长度 var len BitConverter.ToUInt16(data, 0); if (data.Length 2 len) { var payload data.Skip(2).Take(len).ToArray(); ProcessCustom(payload); } } }5.4 上位机对接暴露 REST API 查询在线客户端与强制下线C# 上位机常需知道哪些设备在线、手动踢掉异常连接。在TcpServer进程内集成轻量 HTTP 服务用HttpListener或WebApplication// 启动 HTTP 管理端口如 5001 var http new HttpListener(); http.Prefixes.Add(http://localhost:5001/); http.Start(); while (http.IsListening) { var ctx await http.GetContextAsync(); var path ctx.Request.Url.AbsolutePath; if (path /clients ctx.Request.HttpMethod GET) { var clients SessionManager.GetAll() .Select(s new { s.Id, s.RemoteEndPoint, s.LastHeartbeatAt }) .ToArray(); var json JsonSerializer.Serialize(clients); await ctx.Response.WriteAsync(json); } else if (path.StartsWith(/clients/) ctx.Request.HttpMethod DELETE) { var id path.Substring(/clients/.Length); if (SessionManager.Remove(id)) { ctx.Response.StatusCode 200; } else { ctx.Response.StatusCode 404; } } }上位机 C# 代码可直接调用// 强制下线 using var client new HttpClient(); await client.DeleteAsync(http://localhost:5001/clients/a1b2c3d4);这比“重启服务”或“杀进程”更精准符合工业现场最小干预原则。6. 我的血泪习惯每次交付前必做的 3 件事让客户不再半夜打电话问“为什么连不上”交付一个 C# TCP Server 给产线不是编译出exe就结束。我坚持在客户现场部署前亲手做完这三件事十年没被叫醒过第一用netstat -ano | findstr :8080看端口监听状态再telnet 127.0.0.1 8080测试本地连通性。很多问题出在防火墙或端口被占——但客户只会说“连不上”不会告诉你netstat结果。我把它写成批处理双击就执行并截图保存。第二在ClientSession的Close()方法里强制写入一条带时间戳和原因的磁盘日志并用File.AppendAllText(disconnect.log, ...)绕过任何缓冲区——因为StreamWriter可能因进程崩溃丢失最后几行。这条日志是分析断连根因的唯一证据必须落盘即刷。第三给客户留一份《应急手册》PDF里面只有三行字服务停止双击stop.bat内容taskkill /f /im TcpServer.exe查最近断连打开disconnect.log找最后一行No heartbeat for XX.Xs重启服务双击start.bat内容start TcpServer.exe—— 其他任何操作请先拍照发我。手册不教技术只教动作。客户要的是确定性不是原理。我把start.bat和stop.bat放在桌面图标换成绿色箭头和红色叉他们一眼就知道该点哪个。这些事看起来琐碎但正是它们把“能跑的代码”变成了“客户敢放进控制柜的系统”。C# TCP Server 的价值不在Socket类有多炫技而在它让产线工程师不用懂 C#也能安心睡觉。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网