ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# Socket通信实战:服务端与客户端完整实现及排错指南

C# Socket通信实战:服务端与客户端完整实现及排错指南 1. 项目概述1.1 为什么要把Socket通信做成一门“必修课”不管你是刚入门的C#新人还是已经在做上位机、工控系统、物联网网关的老手Socket通信几乎是绕不开的一道坎。我见过太多项目表面上是数据库操作、界面布局但真正的数据源头都在网络通信这一层。比如最常见的C#上位机场景——设备端通过TCP把采集到的温度、压力、运行状态推上来你这边要启动一个服务端来监听这些连接解析数据再转发给UI层显示。这中间的核心就是Socket。这个项目我做了一个完整的、可直接运行的C# Socket通信示例包含服务端和客户端两个部分。服务端负责监听端口、接受连接、接收客户端发来的消息并回复客户端则主动发起连接、发送消息、接收服务端响应。我没有引入任何第三方框架纯.NET基础类库实现目的是让初学者能看到最原生的通信过程也让有经验的开发者能快速拿来做原型验证。适合谁看如果你正在做C#上位机、需要对接外部设备协议、或者想搞明白客户端和服务端之间的消息交换到底是怎么回事这篇文章可以直接照着敲十分钟跑通第一个例子。如果你已经有基础可以直接跳到踩坑部分哪里都是真金白银换来的经验。1.2 项目最终能跑出什么效果先看最终效果方便你心里有个底启动服务端程序监听本机9000端口端口可以命令行参数修改启动客户端程序连接到127.0.0.1:9000客户端输入一行文字回车发送服务端立刻收到并打印出来服务端自动回复“已收到: [原始消息]”客户端同步显示多个客户端同时连接时服务端各自维护独立连接互不干扰任一端主动断开另一端能感知到连接关闭不报未处理异常这套流程是Socket通信最基础、也是最核心的闭环监听、连接、发送、接收、回执、断开。把这个跑通了后面再往WebSocket、物联网MQTT、甚至自定义工业协议上迁移思路都是相通的。2. 整体设计思路拆解2.1 选型同步阻塞还是异步这是很多新手的第一个疑问。C#里Socket通信有几种写法同步阻塞、异步回调Begin/End、还有基于Task的async/await模式。我最终选择了同步阻塞模型作为主示例原因很务实——它最容易理解每一步都像读代码一样直观等待连接就真的阻塞在那一行收到数据就真的在那一行返回。同步阻塞适合的场景是连接量不大、每条消息处理快的应用。比如一个上位机对接三五个设备、一个工具软件做本地联调同步模式完全够用而且出bug的时候好调试调用栈清清楚楚。但同步阻塞有个明显短板一个线程只能处理一个客户端连接而且线程会一直阻塞在读操作上。如果你的服务端要面对大量并发连接或者一条连接上的消息处理特别耗时比如要同步做数据库写入那同步模式就不合适了。这时候就需要async/await异步模式让线程不阻塞在读等待上能同时复用处理多个连接。我在教程里把两种模型的取舍讲清楚主示例用同步扩展部分给了异步改造思路。最怕的就是新手一上来就搞async结果连状态机都没弄明白反而陷入更复杂的坑。2.2 服务端的核心模型监听、接收、会话服务端的核心结构可以拆成三层监听层创建Socket绑定IP和端口开始Listen等待客户端接入接收层Accept阻塞等待每接入一个客户端就单独开一个线程/任务处理该连接会话层在该连接上循环读取数据、解析消息、执行业务逻辑、返回响应我分享一个很重要的设计理念——服务端不要把所有代码堆在主流程里。每一层各管一件事将来替换协议、加日志、做鉴权都更好下手。我见过很多人把Accept、Receive、业务处理全部塞在Main函数里调试的时候无从下手复用的时候无从谈起。用一张简单的示意图来说明结构不用画复杂拓扑心里有个数就行服务端 ├── 监听Socket唯一 └── 客户端连接Socket每客户端一个 ├── 连接1 → 独立线程处理 ├── 连接2 → 独立线程处理 └── 连接N → 独立线程处理2.3 客户端的设计谁来主动谁来被动客户端的设计要简单得多创建一个SocketConnect到服务端地址然后进入收发循环。这里需要注意的是TCP是双向通信的所以“客户端只能主动发、服务端只能被动收”是个错误印象。建立连接之后双方都可以随时写数据、随时读数据。我这套示例中客户端的主循环是“等待用户输入→发送→等待回显→打印”这适合演示和调试。但真实项目里客户端往往是持续接收服务端的推送比如服务端广播状态变化这时候客户端的主循环应该是“读数据→处理→再读”用户输入反而是外部事件。2.4 为什么用TCP不用UDP这个项目选择TCP不是随意的。如果做视频流、语音这种允许丢包的场景UDP是更优解但Socket通信的入门阶段TCP的价值在于——它自带可靠性数据不丢、不乱序、有连接状态。你可以先把TCP的握手、收发搞清楚再去学UDP就会快很多。另外TCP对“连接断开”的感知也更有迹可循Read/Receive返回0或者抛出异常说明连接已经关闭。这个特性在做设备断线检测时极好用。3. 核心代码实现与解析3.1 服务端实现从监听开始的完整代码服务端代码我会分块说明先看完整的骨架再逐段讲关键点。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; class TcpServer { private readonly int _port; private TcpListener _listener; private bool _running; public TcpServer(int port) { _port port; } public void Start() { _listener new TcpListener(IPAddress.Any, _port); _listener.Start(); _running true; Console.WriteLine($[服务端] 监听端口: {_port}等待客户端接入...); while (_running) { try { TcpClient client _listener.AcceptTcpClient(); Console.WriteLine($[服务端] 收到新连接: {client.Client.RemoteEndPoint}); ThreadPool.QueueUserWorkItem(HandleClient, client); } catch (SocketException ex) { if (_running) { Console.WriteLine($[服务端] Accept异常: {ex.SocketErrorCode}); } } } } private void HandleClient(object state) { TcpClient client (TcpClient)state; try { using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[1024]; int readCount; while ((readCount stream.Read(buffer, 0, buffer.Length)) ! 0) { string msg Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($[服务端] 收到消息: {msg}来自 {client.Client.RemoteEndPoint}); string response $已收到: {msg}; byte[] respBytes Encoding.UTF8.GetBytes(response); stream.Write(respBytes, 0, respBytes.Length); Console.WriteLine($[服务端] 已回复: {response}); } Console.WriteLine($[服务端] 客户端 {client.Client.RemoteEndPoint} 已断开); } } catch (Exception ex) { Console.WriteLine($[服务端] 处理客户端时异常: {ex.Message}); } finally { client.Close(); } } public void Stop() { _running false; _listener?.Stop(); } }下面是入口程序方便直接验证class Program { static void Main(string[] args) { int port 9000; if (args.Length 0) { int.TryParse(args[0], out port); } TcpServer server new TcpServer(port); Console.CancelKeyPress (sender, e) { e.Cancel true; Console.WriteLine(正在关闭服务端...); server.Stop(); }; server.Start(); } }代码里有个容易被忽略的点_listener.AcceptTcpClient()是阻塞的它是整个服务端的主等待点。我特意用ThreadPool.QueueUserWorkItem把每个客户端的处理扔到线程池去这样主循环可以立刻回到Accept继续接下一个连接。你以为这就完了还差一个关键点——为什么收到消息用Read而不是Receive。TcpClient封装了Socket往网络流Stream上做读操作时Read和Write成对出现代码更自然。而在直接操作Socket时Receive和Send才更常用。两种写法都能实现功能但后者的控制粒度更细需要自己处理缓冲区管理。我做这个示例时刻意选了TcpClientNerworkStream的组合就是希望新人先不碰底层细节等理解了连接和收发再去看Socket.Receive也不迟。3.2 客户端实现连接、发送、回显客户端的代码相对简单但也藏着不少细节最容易翻车的就是编码问题。看完整代码using System; using System.Net; using System.Net.Sockets; using System.Text; class TcpClientDemo { static void Main(string[] args) { string serverIp 127.0.0.1; int port 9000; if (args.Length 2) { serverIp args[0]; int.TryParse(args[1], out port); } try { using (TcpClient client new TcpClient()) { Console.WriteLine($[客户端] 正在连接 {serverIp}:{port}...); client.Connect(IPAddress.Parse(serverIp), port); Console.WriteLine($[客户端] 连接成功本地端点: {client.Client.LocalEndPoint}); using (NetworkStream stream client.GetStream()) { while (true) { Console.Write(请输入要发送的内容输入quit退出: ); string input Console.ReadLine(); if (string.IsNullOrEmpty(input)) continue; if (input.Equals(quit, StringComparison.OrdinalIgnoreCase)) break; byte[] sendBytes Encoding.UTF8.GetBytes(input); stream.Write(sendBytes, 0, sendBytes.Length); Console.WriteLine($[客户端] 已发送: {input}); byte[] buffer new byte[1024]; int readCount stream.Read(buffer, 0, buffer.Length); if (readCount 0) { Console.WriteLine([客户端] 服务端已关闭连接); break; } string response Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($[客户端] 收到回复: {response}); } } } } catch (SocketException ex) { Console.WriteLine($[客户端] 连接异常: {ex.SocketErrorCode} - {ex.Message}); } catch (Exception ex) { Console.WriteLine($[客户端] 发生异常: {ex.Message}); } } }很多人照抄代码第一遍就能跑通。但你要是不理解里面三个细节后面写大项目还是会踩坑。第一Connect之后不要立刻Write。TCP连接是异步建立的Connect返回时三次握手基本完成但某些情况下数据还没准备好立刻写大块数据可能触发底层缓冲问题。稳妥的做法是写完等一小会儿或者直接做一次握手式的消息交换。第二Read的返回值意味着什么。Read返回0说明对端优雅关闭返回大于0说明收到了这么多字节。但TCP是流协议不是消息协议可能一次发10个字节只收到5个也可能一次收到20个字节包括了下一条消息的前半截。这在下一步会重点讲。第三本地端点和远程端点的区别。client.Client.LocalEndPoint是本机的IP和端口多个连接时端口各不相同RemoteEndPoint是服务端的地址。这两个属性在排查多网卡机器的连接问题时特别有用。3.3 编码与缓冲区新手最容易翻车的细节我大概有一半的学员问题都出在编码上。每当你用Encoding.UTF8.GetBytes()发送然后又用Encoding.UTF8.GetString()读取两边必须用同一种编码。如果客户端用UTF8发送、服务端用GBK接收中文消息就会变成乱码这在C#上位机对接设备时尤其常见——很多设备固件默认用GBK或者ASCII。缓冲区大小的选择也有讲究。我示例里用了1024字节但真实项目要视消息类型而定纯文本控制指令512字节够用带JSON结构的数据建议4KB起步大文件分段或音视频流必须自定义分包1KB~64KB不等缓冲区太小会导致大消息被截断产生“半包”问题缓冲区太大则在低频小消息场景下浪费内存。没有银弹按业务估。提示如果你的服务端要接收不同长度的消息建议自定义一个消息头——前4个字节表示长度后面跟实际内容。这样服务端可以先读4字节得知长度再读完整的消息体。这就是“分包协议”的雏形也是后面扩展成各种工业协议的基础。4. 实操过程与联调验证4.1 搭建环境这次就不需要什么复杂装备一个Socket通信项目的开发环境甚至不需要Visual Studio完整版。我用的是.NET 8终端里跑dotnet run就能启动。如果你还在用.NET Framework 4.x复制代码同样能编译。操作步骤很简单创建一个控制台应用dotnet new console -n TcpServerDemo把服务端代码放进去再创建一个dotnet new console -n TcpClientDemo把客户端代码放进去分别打开两个终端先跑服务端再跑客户端不需要额外安装NuGet包不需要数据库不需要IIS这就是这个项目“超简单上手”的底气。4.2 先跑起来一步步看通信过程服务端终端里会看到[服务端] 监听端口: 9000等待客户端接入... [服务端] 收到新连接: 127.0.0.1:51234 [服务端] 收到消息: 你好 [服务端] 已回复: 已收到: 你好 [服务端] 客户端 127.0.0.1:51234 已断开客户端终端会看到[客户端] 正在连接 127.0.0.1:9000... [客户端] 连接成功本地端点: 127.0.0.1:51234 请输入要发送的内容输入quit退出: 你好 [客户端] 已发送: 你好 [客户端] 收到回复: 已收到: 你好这里值得停下来想一个细节服务端的“已收到: 你好”和客户端的回显内容是同一个字符串吗不是。服务端收到的是“你好”服务端构造了新的字符串“已收到: 你好”发送回去客户端再原样打印。这个区别看起来微不足道但在做协议调试的时候很重要——你在服务端打了一条日志并不代表客户端也会看到这条日志两边要分别确认自己的收发状态。4.3 多客户端接入验证服务端的并发处理能力刚才说了ThreadPool现在就实际验证一下。同时打开三个客户端终端各自连接客户端A发“第一条”客户端B发“第二条”客户端C发“第三条”服务端打印的三条消息顺序不一定是A、B、C因为线程调度没有严格顺序但每条消息都会收到并回复。这就是并发处理的基本盘。更精细的服务端还可以记录每个客户端的连接时间、最后活跃时间、消息总数这些数据在做设备状态监控时很有用。不过想真正做生产级并发ThreadPool.QueueUserWorkItem还是太糙了。后面可以考虑用Task.Run替代能自然接上async/await也可以用Channel实现生产者消费者模式让消息按顺序进入队列再由固定数量worker处理这就是更高级的话题了。4.4 断线重连与旧连接清理客户端正常输入quit退出服务端会感知Read返回0。但是客户端拔网线、断电或者程序崩溃时服务端会在一段时间后才感知到异常大约几十秒到几分钟取决于TCP心跳策略。这在长时间运行的上位机服务端里是致命问题——你以为设备还在线实际早就断了。这是个很硬的坑我在做工业现场时被坑过好几次。基础解决思路是从客户端定期发送心跳消息比如每5秒发一个心跳包服务端检查心跳如果超过30秒没收到任何数据就判定连接超时主动关闭连接释放线程资源。心跳包要放业务协议之外单独定义一种消息类型防止与正常业务数据混淆。代码里做起来不复杂就是在客户端加个定时器服务端记录最后一次活跃时间定期扫描所有连接。4.5 本机联调之后怎么跨机器联调其实整个项目最容易被新手卡住的不是写代码而是连接不上。本机跑通了不代表换台机器就能跑通。我建议按这个顺序排错服务端监听的是IPAddress.Any代表所有网卡上的9000端口都能到达客户端连接时要把127.0.0.1换成服务端的局域网IP防火墙必须放行9000端口Windows防火墙默认拦入站连接两台机器要在同一个网段或者路由可达防火墙例外命令可以这样加netsh advfirewall firewall add rule nameTCP9000 dirin actionallow protocolTCP localport9000如果你做完后发现客户端Connect仍然超时就在服务端挂一个抓包工具Wireshark或tcpdump确认SYN包是否到达。大概率是防火墙拦截也可能是服务端程序根本没起来端口被别的进程占用了。5. 常见问题与排查技巧实录5.1 客户端连不上服务端从报错到定位的排查手册报错信息五花八门但本质逃不出三类现象根本原因解决方向SocketException(10061) 目标计算机积极拒绝服务端进程没启动或端口不对确认进程存在、端口正确SocketException(10060) 连接超时网络不通、防火墙拦、跨网段、域名解析失败依次ping、查防火墙、确认地址ArgumentException host is invalidIP格式写错或用了无法解析的主机名用IPAddress.Parse严格校验避免直接传字符串到Connect我实训时见过最多的是第一种服务端启动失败但没看日志错误信息里其实写了“端口被占用”。如果碰到端口被占用用这个命令找出占用进程netstat -ano | findstr :9000然后根据PID去任务管理器结束进程或者直接改服务端的端口参数重跑。5.2 消息发了但收不到完整内容拆包粘包的根源TCP是字节流不会天然按消息切分。你Send一次10字节的内容对端可能一次Read拿到10字节也可能分两次拿到64字节。反过来你连续Send两次对端可能一次Read把两次的数据合并到一起。这就是著名的“粘包拆包”问题。我见过很多上位机程序到最后消息全是乱的根本原因就在这。解法有很多从简单到复杂排列固定长度协议每条消息固定1024字节不够补空格。简单粗暴适合指令类场景长度前缀协议前4字节或2字节声明消息体长度后面跟实际内容。推荐大部分JSON类业务使用特殊分隔符比如消息以\r\n结尾读数据时按分隔符切分。适合文本日志类协议JSON结构体长度双重校验工业协议里常见先读长度再读内容然后反序列化JSON解析失败说明数据错位我的示例代码为了演示基础没有做分包。但如果照搬到生产环境消息稍长一点就会出问题。建议实战时直接用长度前缀协议。核心逻辑是// 发送端 byte[] body Encoding.UTF8.GetBytes(jsonStr); byte[] header BitConverter.GetBytes(body.Length); // 默认小端 stream.Write(header, 0, 4); stream.Write(body, 0, body.Length); // 接收端 byte[] headerBuf new byte[4]; int read ReadFull(stream, headerBuf, 4); // 必须读满4字节 int bodyLen BitConverter.ToInt32(headerBuf, 0); byte[] bodyBuf new byte[bodyLen]; ReadFull(stream, bodyBuf, bodyLen);注意BitConverter的字节序除非你的设备那边约定大端否则C#默认小端即可。但如果对接的是Java或嵌入式设备它们很多默认大端这时候就要用BinaryPrimitives.ReverseEndianness调整否则解析出来能对上天。5.3 客户端占住线程不退出未处理异常的后果初学者常犯的错是——客户端直接CtrlC强制退出导致进程异常终止。表面没报错但从服务端角度看连接没有正常四步挥手服务端要等很久才能发现异常。长期这样会导致服务端堆积大量半开连接。半开连接占用的是什么线程、Socket句柄、内存里的缓冲区。虽然ThreadPool有上限、GC能回收托管内存但Socket句柄不会自动释放最终可能导致服务端无法再Accept新连接。这在长期运行的服务端上是个隐蔽的坑。好的习惯是客户端退出时先调用client.Close()给服务端一个明确的EOF让服务端能立即清理资源。服务端这边建议在Receive循环中捕获SocketException并判断SocketErrorCode是ConnectionReset还是ConnectionAborted分别打不同的日志便于事后复盘。5.4 一条消息拆成两半Read不是Send的一一对应我在做物联网网关时有个惨痛教训下发一条100字节的指令客户端分两次收到第一次70字节第二次30字节。服务端根本没做分包处理直接按一次Read的返回去反序列化结果连续报解析错误。从那以后我写Socket接收逻辑都强制封装一个ReadFull方法专门用来读满指定长度的字节。核心代码如下也是很多网络库的内部实现思路private static int ReadFull(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int read stream.Read(buffer, offset, count - offset); if (read 0) { throw new IOException(连接已关闭无法读取完整数据); } offset read; } return offset; }这个方法跟循环Read的区别在于它会一直读直到凑齐count字节才返回中途无论来了几次分段都不影响。如果连接断开直接抛异常让上层处理。这一行代码解决了我在生产环境遇到过的大部分“消息残废”问题。注意在处理网络流时任何一次Read都可能在缓冲区边界处返回所以必须自行控制循环不能假定一次Read拿到完整消息。这是TCP协议的本质特性不是C#的bug。5.5 中文乱码协议两侧编码不一致前面提了编码问题这里再展开讲。上位机对接设备时常见组合是C#服务端用UTF8设备单片机/PLC用GBK或ASCII两个编码不匹配中文设备名称就会变成乱码“锟斤拷”或“”。解决方案不是“统一用UTF8”这么简单而是要在协议层明确规定编码并且做编码转换。C#里用Encoding.GetEncoding(GBK)处理老设备用Encoding.UTF8处理现代系统。尤其要注意GBK在.NET Core里默认可能没有注册需要安装System.Text.Encoding.CodePages包否则运行会抛异常。我做工业对接时踩过这个坑网上资料也少这里提前给你避雷。6. 扩展从Demo到真实项目的改造建议6.1 上位机场景把Socket服务端嵌入WinForms或WPF这个示例虽然跑在控制台但很多读者实际要做的C#上位机是需要图形界面的。把服务端逻辑移植到WinForms里注意一个关键问题不要跨线程操作UI控件。服务端收到消息后默认是在ThreadPool的某个工作线程里执行的如果这时候直接更新TextBox或DataGridView会抛“线程间操作无效”异常。正确的做法是用Control.BeginInvoke或者Progress 把消息投递到UI线程。我就见过有人图省事把整个服务端循环放在UI线程里跑界面直接卡死连关闭按钮都点不动。所以要么单独一个后台Task跑服务端要么用BackgroundWorker反正别让网络循环占住消息泵。大致方向是private void OnMessageReceived(string msg, string clientAddress) { if (InvokeRequired) { BeginInvoke(new Actionstring, string(OnMessageReceived), msg, clientAddress); return; } txtLog.AppendText($[{DateTime.Now:HH:mm:ss}] {clientAddress}: {msg}\r\n); }6.2 把同步改成异步async/await与取消令牌如果你的服务端要支持几百个客户端或者客户端收发操作不能卡住UI同步模式就不够用了。改成async/await其实不复杂关键是替换掉阻塞调用async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (var stream client.GetStream()) { byte[] buffer new byte[4096]; while (!token.IsCancellationRequested) { int readCount await stream.ReadAsync(buffer, 0, buffer.Length, token); if (readCount 0) break; // 处理消息 await stream.WriteAsync(respBytes, 0, respBytes.Length, token); } } }异步改造的好处是ThreadPool不再被阻塞线程耗尽一个请求在等待数据库响应时其他数以千计的请求可以并发处理。CancellationToken要提前设计好这样服务端Stop的时候能优雅通知所有连接退出而不是粗暴Close。6.3 对接西门子PLC、OPC和ModbusSocket通信只是起点热搜词里出现“c#连接西门子opc”这个方向很多做上位机的人都在问。Socket通信和这些工业协议是底层与上层的区别Socket只负责可靠地传字节流而Modbus TCP、S7协议、OPC UA是在这个字节流之上定义的语义规则。比如其中一部分厂商协议就是在TCP载荷里包含了功能码、寄存器地址、数据长度。理解了Socket收发的底层过程再看这些协议文档你会发现它们只不过是把我在3.2节讲的“长度前缀消息体”结构换成了特定的字段排列。建议进阶路线先把TCP收发彻底吃透 → 学Modbus TCP最简单、文档多 → 再学OPC UA复杂但强大 → 最后才是设备厂商私有协议。千万不要一上来就抄别人的S7通信库出了问题根本排查不动。7. 项目收尾与调试清单到这个阶段你的代码应该能稳定跑通但距离“生产级”还差一套调试清单。我把自己常用的几招分享出来。第一日志要分级。不要直接用Console.WriteLine输出一切。建议至少分Info、Warn、Error三级。Info记录连接建立和断开、消息收发计数Warn记录重传、缓冲溢出、心跳超时Error记录异常栈。有日志才有排查权。第二链接状态可视化。我在上位机界面里会把每个客户端连接画成列表每行显示IP、端口、连入时间、最后活跃时间、在线状态。一眼就能看出哪台设备心跳断了。这种可视化不需要额外的图表库DataGridView就够了。第三端到端抓包验证。就算两边日志都对也要偶尔用Wireshark抓一次包确认实际在网络上跑了什么报文。有一次我发现服务端日志显示收到了100字节但抓包里只有80字节——原因是我的日志打印了截断后的字符串而缓冲区里确实只装了80字节问题出在发送端没发全。抓包能看出这类“假对账”。最终调试清单本机连接是否通 → telnet 127.0.0.1 9000跨机连接是否通 → 关防火墙试、换端口试、ping路由小消息是否正常 → 1~10字节边界值大消息是否被截断 → 超过缓冲区大小测试连续高频发送是否丢数据 → 写循环发1000条验证客户端突然断点服务端能否感知 → 任务管理器杀进程测试多个客户端同时在线是否互踢 → 至少3客户端并发测每次改完代码把这个清单过一遍比什么质量保障都实在。我最后分享一句经验Socket通信的学习曲线陡峭但只要亲手把一个双向收发跑通理解连接生命周期、消息边界、异常处理三件事后面的项目就都不是问题了。很多人卡住不是因为代码难而是没有认真推演一次完整的数据流动。希望这份文档能帮你把这条路走顺。
返回列表